feat: isolate Claude and Codex session sources
Persist platform_source across session creation, transcript ingestion, API query paths, and viewer state so Claude and Codex data can coexist without bleeding into each other. - add platform-source normalization helpers and persist platform_source in sdk_sessions via migration 24 with backfill and indexing - thread platformSource through CLI hooks, transcript processing, context generation, pagination, search routes, SSE payloads, and session management - expose source-aware project catalogs, viewer tabs, context preview selectors, and source badges for observations, prompts, and summaries - start the transcript watcher from the worker for transcript-based clients and preserve platform source during Codex ingestion - auto-start the worker from the MCP server for MCP-only clients and tighten stdio-driven cleanup during shutdown - keep createSDKSession backward compatible with existing custom-title callers while allowing explicit platform source forwarding
This commit is contained in:
File diff suppressed because one or more lines are too long
+1881
-57
File diff suppressed because one or more lines are too long
+374
-248
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+123
-1
@@ -355,6 +355,14 @@
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
@@ -549,6 +557,42 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tab:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
border-color: var(--color-border-focus);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.source-tab.active {
|
||||
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
|
||||
border-color: var(--color-bg-button);
|
||||
color: var(--color-text-button);
|
||||
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.18);
|
||||
}
|
||||
|
||||
.settings-btn,
|
||||
.theme-toggle-btn {
|
||||
background: var(--color-bg-card);
|
||||
@@ -887,6 +931,49 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-source {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.source-claude {
|
||||
background: rgba(255, 138, 61, 0.12);
|
||||
color: #c25a00;
|
||||
border-color: rgba(255, 138, 61, 0.22);
|
||||
}
|
||||
|
||||
.source-codex {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
color: #0f5ba7;
|
||||
border-color: rgba(33, 150, 243, 0.24);
|
||||
}
|
||||
|
||||
.source-cursor {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
color: #6d28d9;
|
||||
border-color: rgba(124, 58, 237, 0.24);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-claude {
|
||||
color: #ffb067;
|
||||
border-color: rgba(255, 176, 103, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-codex {
|
||||
color: #8fc7ff;
|
||||
border-color: rgba(143, 199, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-cursor {
|
||||
color: #c4b5fd;
|
||||
border-color: rgba(196, 181, 253, 0.2);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
margin-bottom: 14px;
|
||||
@@ -1483,6 +1570,10 @@
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1491,6 +1582,11 @@
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Hide icon links (docs, github, twitter) on tablet */
|
||||
.icon-link {
|
||||
display: none;
|
||||
@@ -1544,6 +1640,27 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.source-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logomark {
|
||||
height: 28px;
|
||||
}
|
||||
@@ -1732,6 +1849,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-selector select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-selector select {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
@@ -2873,4 +2995,4 @@
|
||||
<script src="viewer-bundle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const contextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -31,6 +32,7 @@ export const contextHandler: EventHandler = {
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
const context = getProjectContext(cwd);
|
||||
const port = getWorkerPort();
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
// Check if terminal output should be shown (load settings early)
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
@@ -38,7 +40,7 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
// Pass all projects (parent + worktree if applicable) for unified timeline
|
||||
const projectsParam = context.allProjects.join(',');
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(platformSource)}`;
|
||||
const colorApiPath = `${apiPath}&colors=true`;
|
||||
|
||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const fileEditHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -20,6 +21,7 @@ export const fileEditHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId, cwd, filePath, edits } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('fileEditHandler requires filePath');
|
||||
@@ -42,6 +44,7 @@ export const fileEditHandler: EventHandler = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const observationHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -22,6 +23,7 @@ export const observationHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId, cwd, toolName, toolInput, toolResponse } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!toolName) {
|
||||
// No tool name provided - skip observation gracefully
|
||||
@@ -51,6 +53,7 @@ export const observationHandler: EventHandler = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
platformSource,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -23,6 +24,7 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
}
|
||||
|
||||
const { sessionId } = input;
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||
@@ -39,7 +41,8 @@ export const sessionCompleteHandler: EventHandler = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId
|
||||
contentSessionId: sessionId,
|
||||
platformSource
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
export const sessionInitHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
@@ -42,6 +43,7 @@ export const sessionInitHandler: EventHandler = {
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
|
||||
const project = getProjectName(cwd);
|
||||
const platformSource = normalizePlatformSource(input.platform);
|
||||
|
||||
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
|
||||
|
||||
@@ -52,7 +54,8 @@ export const sessionInitHandler: EventHandler = {
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
project,
|
||||
prompt
|
||||
prompt,
|
||||
platformSource
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { workerHttpRequest } from '../shared/worker-utils.js';
|
||||
import { getWorkerPort, workerHttpRequest } from '../shared/worker-utils.js';
|
||||
import { ensureWorkerStarted } from '../services/worker-service.js';
|
||||
import { searchCodebase, formatSearchResults } from '../services/smart-file-read/search.js';
|
||||
import { parseFile, formatFoldedView, unfoldSymbol } from '../services/smart-file-read/parser.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -144,6 +145,26 @@ async function verifyWorkerConnection(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Worker is available for Codex and other MCP-only clients.
|
||||
* Claude hooks already start the worker; this path makes Codex turnkey.
|
||||
*/
|
||||
async function ensureWorkerConnection(): Promise<boolean> {
|
||||
if (await verifyWorkerConnection()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn('SYSTEM', 'Worker not available, attempting auto-start for MCP client');
|
||||
|
||||
try {
|
||||
const port = getWorkerPort();
|
||||
return await ensureWorkerStarted(port);
|
||||
} catch (error) {
|
||||
logger.error('SYSTEM', 'Worker auto-start failed', undefined, error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definitions with HTTP-based handlers
|
||||
* Minimal descriptions - use help() tool with operation parameter for detailed docs
|
||||
@@ -392,6 +413,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
// Prevents orphaned MCP server processes when Claude Code exits unexpectedly
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let isCleaningUp = false;
|
||||
|
||||
function handleStdioClosed() {
|
||||
cleanup('stdio-closed');
|
||||
}
|
||||
|
||||
function handleStdioError(error: Error) {
|
||||
logger.warn('SYSTEM', 'MCP stdio stream errored, shutting down', {
|
||||
message: error.message
|
||||
});
|
||||
cleanup('stdio-error');
|
||||
}
|
||||
|
||||
function attachStdioLifecycle() {
|
||||
process.stdin.on('end', handleStdioClosed);
|
||||
process.stdin.on('close', handleStdioClosed);
|
||||
process.stdin.on('error', handleStdioError);
|
||||
}
|
||||
|
||||
function detachStdioLifecycle() {
|
||||
process.stdin.off('end', handleStdioClosed);
|
||||
process.stdin.off('close', handleStdioClosed);
|
||||
process.stdin.off('error', handleStdioError);
|
||||
}
|
||||
|
||||
function startParentHeartbeat() {
|
||||
// ppid-based orphan detection only works on Unix
|
||||
@@ -414,9 +459,13 @@ function startParentHeartbeat() {
|
||||
|
||||
// Cleanup function — synchronous to ensure consistent behavior whether called
|
||||
// from signal handlers, heartbeat interval, or awaited in async context
|
||||
function cleanup() {
|
||||
function cleanup(reason: string = 'shutdown') {
|
||||
if (isCleaningUp) return;
|
||||
isCleaningUp = true;
|
||||
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
logger.info('SYSTEM', 'MCP server shutting down');
|
||||
detachStdioLifecycle();
|
||||
logger.info('SYSTEM', 'MCP server shutting down', { reason });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -428,6 +477,7 @@ process.on('SIGINT', cleanup);
|
||||
async function main() {
|
||||
// Start the MCP server
|
||||
const transport = new StdioServerTransport();
|
||||
attachStdioLifecycle();
|
||||
await server.connect(transport);
|
||||
logger.info('SYSTEM', 'Claude-mem search server started');
|
||||
|
||||
@@ -436,7 +486,7 @@ async function main() {
|
||||
|
||||
// Check Worker availability in background
|
||||
setTimeout(async () => {
|
||||
const workerAvailable = await verifyWorkerConnection();
|
||||
const workerAvailable = await ensureWorkerConnection();
|
||||
if (!workerAvailable) {
|
||||
logger.error('SYSTEM', 'Worker not available', undefined, {});
|
||||
logger.error('SYSTEM', 'Tools will fail until Worker is started');
|
||||
|
||||
@@ -130,6 +130,7 @@ export async function generateContext(
|
||||
const config = loadContextConfig();
|
||||
const cwd = input?.cwd ?? process.cwd();
|
||||
const project = getProjectName(cwd);
|
||||
const platformSource = input?.platform_source;
|
||||
|
||||
// Use provided projects array (for worktree support) or fall back to single project
|
||||
const projects = input?.projects || [project];
|
||||
@@ -149,11 +150,11 @@ export async function generateContext(
|
||||
try {
|
||||
// Query data for all projects (supports worktree: parent + worktree combined)
|
||||
const observations = projects.length > 1
|
||||
? queryObservationsMulti(db, projects, config)
|
||||
: queryObservations(db, project, config);
|
||||
? queryObservationsMulti(db, projects, config, platformSource)
|
||||
: queryObservations(db, project, config, platformSource);
|
||||
const summaries = projects.length > 1
|
||||
? querySummariesMulti(db, projects, config)
|
||||
: querySummaries(db, project, config);
|
||||
? querySummariesMulti(db, projects, config, platformSource)
|
||||
: querySummaries(db, project, config, platformSource);
|
||||
|
||||
// Handle empty state
|
||||
if (observations.length === 0 && summaries.length === 0) {
|
||||
|
||||
@@ -25,7 +25,8 @@ import { SUMMARY_LOOKAHEAD } from './types.js';
|
||||
export function queryObservations(
|
||||
db: SessionStore,
|
||||
project: string,
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): Observation[] {
|
||||
const typeArray = Array.from(config.observationTypes);
|
||||
const typePlaceholders = typeArray.map(() => '?').join(',');
|
||||
@@ -34,19 +35,38 @@ export function queryObservations(
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT
|
||||
id, memory_session_id, type, title, subtitle, narrative,
|
||||
facts, concepts, files_read, files_modified, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE project = ?
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.discovery_tokens,
|
||||
o.created_at,
|
||||
o.created_at_epoch
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
WHERE o.project = ?
|
||||
AND type IN (${typePlaceholders})
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(concepts)
|
||||
SELECT 1 FROM json_each(o.concepts)
|
||||
WHERE value IN (${conceptPlaceholders})
|
||||
)
|
||||
ORDER BY created_at_epoch DESC
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(project, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
|
||||
`).all(
|
||||
project,
|
||||
...typeArray,
|
||||
...conceptArray,
|
||||
...(platformSource ? [platformSource] : []),
|
||||
config.totalObservationCount
|
||||
) as Observation[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,15 +75,30 @@ export function queryObservations(
|
||||
export function querySummaries(
|
||||
db: SessionStore,
|
||||
project: string,
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): SessionSummary[] {
|
||||
return db.db.prepare(`
|
||||
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
WHERE ss.project = ?
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(project, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
`).all(
|
||||
...[project, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD]
|
||||
) as SessionSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +110,8 @@ export function querySummaries(
|
||||
export function queryObservationsMulti(
|
||||
db: SessionStore,
|
||||
projects: string[],
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): Observation[] {
|
||||
const typeArray = Array.from(config.observationTypes);
|
||||
const typePlaceholders = typeArray.map(() => '?').join(',');
|
||||
@@ -87,19 +123,39 @@ export function queryObservationsMulti(
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT
|
||||
id, memory_session_id, type, title, subtitle, narrative,
|
||||
facts, concepts, files_read, files_modified, discovery_tokens,
|
||||
created_at, created_at_epoch, project
|
||||
FROM observations
|
||||
WHERE project IN (${projectPlaceholders})
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.discovery_tokens,
|
||||
o.created_at,
|
||||
o.created_at_epoch,
|
||||
o.project
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
WHERE o.project IN (${projectPlaceholders})
|
||||
AND type IN (${typePlaceholders})
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(concepts)
|
||||
SELECT 1 FROM json_each(o.concepts)
|
||||
WHERE value IN (${conceptPlaceholders})
|
||||
)
|
||||
ORDER BY created_at_epoch DESC
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(...projects, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
|
||||
`).all(
|
||||
...projects,
|
||||
...typeArray,
|
||||
...conceptArray,
|
||||
...(platformSource ? [platformSource] : []),
|
||||
config.totalObservationCount
|
||||
) as Observation[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,18 +167,32 @@ export function queryObservationsMulti(
|
||||
export function querySummariesMulti(
|
||||
db: SessionStore,
|
||||
projects: string[],
|
||||
config: ContextConfig
|
||||
config: ContextConfig,
|
||||
platformSource?: string
|
||||
): SessionSummary[] {
|
||||
// Build IN clause for projects
|
||||
const projectPlaceholders = projects.map(() => '?').join(',');
|
||||
|
||||
return db.db.prepare(`
|
||||
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch, project
|
||||
FROM session_summaries
|
||||
WHERE project IN (${projectPlaceholders})
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.memory_session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch,
|
||||
ss.project
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
WHERE ss.project IN (${projectPlaceholders})
|
||||
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`).all(...projects, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
`).all(...projects, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ContextInput {
|
||||
projects?: string[];
|
||||
/** When true, return ALL observations with no limit */
|
||||
full?: boolean;
|
||||
platform_source?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export interface ContextConfig {
|
||||
export interface Observation {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
platform_source?: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
@@ -70,6 +72,7 @@ export interface Observation {
|
||||
export interface SessionSummary {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
platform_source?: string;
|
||||
request: string | null;
|
||||
investigated: string | null;
|
||||
learned: string | null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TableNameRow } from '../../types/database.js';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { isDirectChild } from '../../shared/path-utils.js';
|
||||
import { AppError } from '../server/ErrorHandler.js';
|
||||
import {
|
||||
ObservationSearchResult,
|
||||
SessionSummarySearchResult,
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
export class SessionSearch {
|
||||
private db: Database;
|
||||
|
||||
private static readonly MISSING_SEARCH_INPUT_MESSAGE = 'Either query or filters required for search';
|
||||
|
||||
constructor(dbPath?: string) {
|
||||
if (!dbPath) {
|
||||
ensureDir(DATA_DIR);
|
||||
@@ -280,7 +283,7 @@ export class SessionSearch {
|
||||
if (!query) {
|
||||
const filterClause = this.buildFilterClause(filters, params, 'o');
|
||||
if (!filterClause) {
|
||||
throw new Error('Either query or filters required for search');
|
||||
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
|
||||
}
|
||||
|
||||
const orderClause = this.buildOrderClause(orderBy, false);
|
||||
@@ -317,7 +320,7 @@ export class SessionSearch {
|
||||
delete filterOptions.type;
|
||||
const filterClause = this.buildFilterClause(filterOptions, params, 's');
|
||||
if (!filterClause) {
|
||||
throw new Error('Either query or filters required for search');
|
||||
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
|
||||
}
|
||||
|
||||
const orderClause = orderBy === 'date_asc'
|
||||
@@ -551,7 +554,7 @@ export class SessionSearch {
|
||||
// FILTER-ONLY PATH: When no query text, query user_prompts table directly
|
||||
if (!query) {
|
||||
if (baseConditions.length === 0) {
|
||||
throw new Error('Either query or filters required for search');
|
||||
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${baseConditions.join(' AND ')}`;
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
} from '../../types/database.js';
|
||||
import type { PendingMessageStore } from './PendingMessageStore.js';
|
||||
import { computeObservationContentHash, findDuplicateObservation } from './observations/store.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE, normalizePlatformSource, sortPlatformSources } from '../../shared/platform-source.js';
|
||||
|
||||
function resolveCreateSessionArgs(
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): { customTitle?: string; platformSource: string } {
|
||||
return {
|
||||
customTitle,
|
||||
platformSource: platformSource ?? DEFAULT_PLATFORM_SOURCE
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Session data store for SDK sessions, observations, and summaries
|
||||
@@ -51,6 +62,7 @@ export class SessionStore {
|
||||
this.addOnUpdateCascadeToForeignKeys();
|
||||
this.addObservationContentHashColumn();
|
||||
this.addSessionCustomTitleColumn();
|
||||
this.addSessionPlatformSourceColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +90,7 @@ export class SessionStore {
|
||||
content_session_id TEXT UNIQUE NOT NULL,
|
||||
memory_session_id TEXT UNIQUE,
|
||||
project TEXT NOT NULL,
|
||||
platform_source TEXT NOT NULL DEFAULT 'claude',
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
@@ -875,6 +888,31 @@ export class SessionStore {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add platform_source column to sdk_sessions for Claude/Codex isolation (migration 24)
|
||||
*/
|
||||
private addSessionPlatformSourceColumn(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(24) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
|
||||
const hasColumn = tableInfo.some(col => col.name === 'platform_source');
|
||||
|
||||
if (!hasColumn) {
|
||||
this.db.run(`ALTER TABLE sdk_sessions ADD COLUMN platform_source TEXT NOT NULL DEFAULT '${DEFAULT_PLATFORM_SOURCE}'`);
|
||||
logger.debug('DB', 'Added platform_source column to sdk_sessions table');
|
||||
}
|
||||
|
||||
this.db.run(`
|
||||
UPDATE sdk_sessions
|
||||
SET platform_source = '${DEFAULT_PLATFORM_SOURCE}'
|
||||
WHERE platform_source IS NULL OR platform_source = ''
|
||||
`);
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source)');
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the memory session ID for a session
|
||||
* Called by SDKAgent when it captures the session ID from the first SDK message
|
||||
@@ -1002,14 +1040,26 @@ export class SessionStore {
|
||||
subtitle: string | null;
|
||||
text: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number | null;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
|
||||
FROM observations
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
o.id,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.text,
|
||||
o.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
o.prompt_number,
|
||||
o.created_at,
|
||||
o.created_at_epoch
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
@@ -1030,16 +1080,30 @@ export class SessionStore {
|
||||
files_edited: string | null;
|
||||
notes: string | null;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number | null;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}> {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, request, investigated, learned, completed, next_steps,
|
||||
files_read, files_edited, notes, project, prompt_number,
|
||||
created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
ORDER BY created_at_epoch DESC
|
||||
SELECT
|
||||
ss.id,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
ss.completed,
|
||||
ss.next_steps,
|
||||
ss.files_read,
|
||||
ss.files_edited,
|
||||
ss.notes,
|
||||
ss.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
ss.prompt_number,
|
||||
ss.created_at,
|
||||
ss.created_at_epoch
|
||||
FROM session_summaries ss
|
||||
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
|
||||
ORDER BY ss.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
@@ -1053,6 +1117,7 @@ export class SessionStore {
|
||||
id: number;
|
||||
content_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at: string;
|
||||
@@ -1063,6 +1128,7 @@ export class SessionStore {
|
||||
up.id,
|
||||
up.content_session_id,
|
||||
s.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
up.prompt_number,
|
||||
up.prompt_text,
|
||||
up.created_at,
|
||||
@@ -1079,18 +1145,74 @@ export class SessionStore {
|
||||
/**
|
||||
* Get all unique projects from the database (for web UI project filter)
|
||||
*/
|
||||
getAllProjects(): string[] {
|
||||
const stmt = this.db.prepare(`
|
||||
getAllProjects(platformSource?: string): string[] {
|
||||
const normalizedPlatformSource = platformSource ? normalizePlatformSource(platformSource) : undefined;
|
||||
let query = `
|
||||
SELECT DISTINCT project
|
||||
FROM sdk_sessions
|
||||
WHERE project IS NOT NULL AND project != ''
|
||||
ORDER BY project ASC
|
||||
`);
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
|
||||
const rows = stmt.all() as Array<{ project: string }>;
|
||||
if (normalizedPlatformSource) {
|
||||
query += ' AND COALESCE(platform_source, ?) = ?';
|
||||
params.push(DEFAULT_PLATFORM_SOURCE, normalizedPlatformSource);
|
||||
}
|
||||
|
||||
query += ' ORDER BY project ASC';
|
||||
|
||||
const rows = this.db.prepare(query).all(...params) as Array<{ project: string }>;
|
||||
return rows.map(row => row.project);
|
||||
}
|
||||
|
||||
getProjectCatalog(): {
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
projectsBySource: Record<string, string[]>;
|
||||
} {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT
|
||||
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
project,
|
||||
MAX(started_at_epoch) as latest_epoch
|
||||
FROM sdk_sessions
|
||||
WHERE project IS NOT NULL AND project != ''
|
||||
GROUP BY COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}'), project
|
||||
ORDER BY latest_epoch DESC
|
||||
`).all() as Array<{ platform_source: string; project: string; latest_epoch: number }>;
|
||||
|
||||
const projects: string[] = [];
|
||||
const seenProjects = new Set<string>();
|
||||
const projectsBySource: Record<string, string[]> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const source = normalizePlatformSource(row.platform_source);
|
||||
|
||||
if (!projectsBySource[source]) {
|
||||
projectsBySource[source] = [];
|
||||
}
|
||||
|
||||
if (!projectsBySource[source].includes(row.project)) {
|
||||
projectsBySource[source].push(row.project);
|
||||
}
|
||||
|
||||
if (!seenProjects.has(row.project)) {
|
||||
seenProjects.add(row.project);
|
||||
projects.push(row.project);
|
||||
}
|
||||
}
|
||||
|
||||
const sources = sortPlatformSources(Object.keys(projectsBySource));
|
||||
|
||||
return {
|
||||
projects,
|
||||
sources,
|
||||
projectsBySource: Object.fromEntries(
|
||||
sources.map(source => [source, projectsBySource[source] || []])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest user prompt with session info for a Claude session
|
||||
* Used for syncing prompts to Chroma during session initialization
|
||||
@@ -1100,6 +1222,7 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
@@ -1108,7 +1231,8 @@ export class SessionStore {
|
||||
SELECT
|
||||
up.*,
|
||||
s.memory_session_id,
|
||||
s.project
|
||||
s.project,
|
||||
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source
|
||||
FROM user_prompts up
|
||||
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
|
||||
WHERE up.content_session_id = ?
|
||||
@@ -1339,11 +1463,14 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string | null;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
user_prompt: string;
|
||||
custom_title: string | null;
|
||||
} | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title
|
||||
SELECT id, content_session_id, memory_session_id, project,
|
||||
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
user_prompt, custom_title
|
||||
FROM sdk_sessions
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
@@ -1361,6 +1488,7 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
user_prompt: string;
|
||||
custom_title: string | null;
|
||||
started_at: string;
|
||||
@@ -1373,7 +1501,9 @@ export class SessionStore {
|
||||
|
||||
const placeholders = memorySessionIds.map(() => '?').join(',');
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title,
|
||||
SELECT id, content_session_id, memory_session_id, project,
|
||||
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
|
||||
user_prompt, custom_title,
|
||||
started_at, started_at_epoch, completed_at, completed_at_epoch, status
|
||||
FROM sdk_sessions
|
||||
WHERE memory_session_id IN (${placeholders})
|
||||
@@ -1418,9 +1548,17 @@ export class SessionStore {
|
||||
* Pure get-or-create: never modifies memory_session_id.
|
||||
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
|
||||
*/
|
||||
createSDKSession(contentSessionId: string, project: string, userPrompt: string, customTitle?: string): number {
|
||||
createSDKSession(
|
||||
contentSessionId: string,
|
||||
project: string,
|
||||
userPrompt: string,
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): number {
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
const resolved = resolveCreateSessionArgs(customTitle, platformSource);
|
||||
const normalizedPlatformSource = normalizePlatformSource(resolved.platformSource);
|
||||
|
||||
// Session reuse: Return existing session ID if already created for this contentSessionId.
|
||||
const existing = this.db.prepare(`
|
||||
@@ -1436,12 +1574,17 @@ export class SessionStore {
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
// Backfill custom_title if provided and not yet set
|
||||
if (customTitle) {
|
||||
if (resolved.customTitle) {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET custom_title = ?
|
||||
WHERE content_session_id = ? AND custom_title IS NULL
|
||||
`).run(customTitle, contentSessionId);
|
||||
`).run(resolved.customTitle, contentSessionId);
|
||||
}
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET platform_source = ?
|
||||
WHERE content_session_id = ?
|
||||
AND COALESCE(platform_source, '') != ?
|
||||
`).run(normalizedPlatformSource, contentSessionId, normalizedPlatformSource);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
@@ -1451,9 +1594,9 @@ export class SessionStore {
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
this.db.prepare(`
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
|
||||
(content_session_id, memory_session_id, project, platform_source, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, normalizedPlatformSource, userPrompt, resolved.customTitle || null, now.toISOString(), nowEpoch);
|
||||
|
||||
// Return new ID
|
||||
const row = this.db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
@@ -2233,9 +2376,9 @@ export class SessionStore {
|
||||
// Create new manual session
|
||||
const now = new Date();
|
||||
this.db.prepare(`
|
||||
INSERT INTO sdk_sessions (memory_session_id, content_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`).run(memorySessionId, contentSessionId, project, now.toISOString(), now.getTime());
|
||||
INSERT INTO sdk_sessions (memory_session_id, content_session_id, project, platform_source, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(memorySessionId, contentSessionId, project, DEFAULT_PLATFORM_SOURCE, now.toISOString(), now.getTime());
|
||||
|
||||
logger.info('SESSION', 'Created manual session', { memorySessionId, project });
|
||||
|
||||
@@ -2261,6 +2404,7 @@ export class SessionStore {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source?: string;
|
||||
user_prompt: string;
|
||||
started_at: string;
|
||||
started_at_epoch: number;
|
||||
@@ -2279,15 +2423,16 @@ export class SessionStore {
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO sdk_sessions (
|
||||
content_session_id, memory_session_id, project, user_prompt,
|
||||
content_session_id, memory_session_id, project, platform_source, user_prompt,
|
||||
started_at, started_at_epoch, completed_at, completed_at_epoch, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
session.content_session_id,
|
||||
session.memory_session_id,
|
||||
session.project,
|
||||
normalizePlatformSource(session.platform_source),
|
||||
session.user_prompt,
|
||||
session.started_at,
|
||||
session.started_at_epoch,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TableNameRow,
|
||||
SchemaVersion
|
||||
} from '../../../types/database.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE } from '../../../shared/platform-source.js';
|
||||
|
||||
/**
|
||||
* MigrationRunner handles all database schema migrations
|
||||
@@ -34,6 +35,7 @@ export class MigrationRunner {
|
||||
this.addOnUpdateCascadeToForeignKeys();
|
||||
this.addObservationContentHashColumn();
|
||||
this.addSessionCustomTitleColumn();
|
||||
this.addSessionPlatformSourceColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +63,7 @@ export class MigrationRunner {
|
||||
content_session_id TEXT UNIQUE NOT NULL,
|
||||
memory_session_id TEXT UNIQUE,
|
||||
project TEXT NOT NULL,
|
||||
platform_source TEXT NOT NULL DEFAULT 'claude',
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
@@ -863,4 +866,29 @@ export class MigrationRunner {
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add platform_source column to sdk_sessions for Claude/Codex isolation (migration 24)
|
||||
*/
|
||||
private addSessionPlatformSourceColumn(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(24) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
|
||||
const hasColumn = tableInfo.some(col => col.name === 'platform_source');
|
||||
|
||||
if (!hasColumn) {
|
||||
this.db.run(`ALTER TABLE sdk_sessions ADD COLUMN platform_source TEXT NOT NULL DEFAULT '${DEFAULT_PLATFORM_SOURCE}'`);
|
||||
logger.debug('DB', 'Added platform_source column to sdk_sessions table');
|
||||
}
|
||||
|
||||
this.db.run(`
|
||||
UPDATE sdk_sessions
|
||||
SET platform_source = '${DEFAULT_PLATFORM_SOURCE}'
|
||||
WHERE platform_source IS NULL OR platform_source = ''
|
||||
`);
|
||||
this.db.run('CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source)');
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
import { DEFAULT_PLATFORM_SOURCE, normalizePlatformSource } from '../../../shared/platform-source.js';
|
||||
|
||||
function resolveCreateSessionArgs(
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): { customTitle?: string; platformSource: string } {
|
||||
return {
|
||||
customTitle,
|
||||
platformSource: platformSource ?? DEFAULT_PLATFORM_SOURCE
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new SDK session (idempotent - returns existing session ID if already exists)
|
||||
@@ -22,10 +33,13 @@ export function createSDKSession(
|
||||
contentSessionId: string,
|
||||
project: string,
|
||||
userPrompt: string,
|
||||
customTitle?: string
|
||||
customTitle?: string,
|
||||
platformSource?: string
|
||||
): number {
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
const resolved = resolveCreateSessionArgs(customTitle, platformSource);
|
||||
const normalizedPlatformSource = normalizePlatformSource(resolved.platformSource);
|
||||
|
||||
// Check for existing session
|
||||
const existing = db.prepare(`
|
||||
@@ -41,12 +55,17 @@ export function createSDKSession(
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
// Backfill custom_title if provided and not yet set
|
||||
if (customTitle) {
|
||||
if (resolved.customTitle) {
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions SET custom_title = ?
|
||||
WHERE content_session_id = ? AND custom_title IS NULL
|
||||
`).run(customTitle, contentSessionId);
|
||||
`).run(resolved.customTitle, contentSessionId);
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions SET platform_source = ?
|
||||
WHERE content_session_id = ?
|
||||
AND COALESCE(platform_source, '') != ?
|
||||
`).run(normalizedPlatformSource, contentSessionId, normalizedPlatformSource);
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
@@ -56,9 +75,9 @@ export function createSDKSession(
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
db.prepare(`
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
|
||||
(content_session_id, memory_session_id, project, platform_source, user_prompt, custom_title, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, normalizedPlatformSource, userPrompt, resolved.customTitle || null, now.toISOString(), nowEpoch);
|
||||
|
||||
// Return new ID
|
||||
const row = db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
|
||||
@@ -9,9 +9,11 @@ import { writeAgentsMd } from '../../utils/agents-md-utils.js';
|
||||
import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js';
|
||||
import { expandHomePath } from './config.js';
|
||||
import type { TranscriptSchema, WatchTarget, SchemaEvent } from './types.js';
|
||||
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
platformSource: string;
|
||||
cwd?: string;
|
||||
project?: string;
|
||||
lastUserMessage?: string;
|
||||
@@ -51,6 +53,7 @@ export class TranscriptEventProcessor {
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId,
|
||||
platformSource: normalizePlatformSource(watch.name),
|
||||
pendingTools: new Map()
|
||||
};
|
||||
this.sessions.set(key, session);
|
||||
@@ -181,7 +184,7 @@ export class TranscriptEventProcessor {
|
||||
sessionId: session.sessionId,
|
||||
cwd,
|
||||
prompt,
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,7 +253,7 @@ export class TranscriptEventProcessor {
|
||||
toolName,
|
||||
toolInput: this.maybeParseJson(fields.toolInput),
|
||||
toolResponse: this.maybeParseJson(fields.toolResponse),
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,7 +266,7 @@ export class TranscriptEventProcessor {
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
filePath,
|
||||
edits: Array.isArray(fields.edits) ? fields.edits : undefined,
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,7 +308,7 @@ export class TranscriptEventProcessor {
|
||||
await sessionCompleteHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
platform: 'transcript'
|
||||
platform: session.platformSource
|
||||
});
|
||||
await this.updateContext(session, watch);
|
||||
session.pendingTools.clear();
|
||||
@@ -325,7 +328,8 @@ export class TranscriptEventProcessor {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: session.sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
last_assistant_message: lastAssistantMessage,
|
||||
platformSource: session.platformSource
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -350,7 +354,7 @@ export class TranscriptEventProcessor {
|
||||
|
||||
try {
|
||||
const response = await workerHttpRequest(
|
||||
`/api/context/inject?projects=${encodeURIComponent(projectsParam)}`
|
||||
`/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(session.platformSource)}`
|
||||
);
|
||||
if (!response.ok) return;
|
||||
|
||||
|
||||
@@ -117,15 +117,15 @@ export class TranscriptWatcher {
|
||||
const files = this.resolveWatchFiles(resolvedPath);
|
||||
|
||||
for (const filePath of files) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
await this.addTailer(filePath, watch, schema, true);
|
||||
}
|
||||
|
||||
const rescanIntervalMs = watch.rescanIntervalMs ?? 5000;
|
||||
const timer = setInterval(async () => {
|
||||
const timer = setInterval(async () => {
|
||||
const newFiles = this.resolveWatchFiles(resolvedPath);
|
||||
for (const filePath of newFiles) {
|
||||
if (!this.tailers.has(filePath)) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
await this.addTailer(filePath, watch, schema, false);
|
||||
}
|
||||
}
|
||||
}, rescanIntervalMs);
|
||||
@@ -164,13 +164,20 @@ export class TranscriptWatcher {
|
||||
return /[*?[\]{}()]/.test(inputPath);
|
||||
}
|
||||
|
||||
private async addTailer(filePath: string, watch: WatchTarget, schema: TranscriptSchema): Promise<void> {
|
||||
private async addTailer(
|
||||
filePath: string,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
initialDiscovery: boolean
|
||||
): Promise<void> {
|
||||
if (this.tailers.has(filePath)) return;
|
||||
|
||||
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
|
||||
|
||||
let offset = this.state.offsets[filePath] ?? 0;
|
||||
if (offset === 0 && watch.startAtEnd) {
|
||||
// `startAtEnd` is useful on worker startup to avoid replaying the full backlog,
|
||||
// but new transcript files must be read from byte 0 or we lose session_meta/user_message.
|
||||
if (offset === 0 && watch.startAtEnd && initialDiscovery) {
|
||||
try {
|
||||
offset = statSync(filePath).size;
|
||||
} catch {
|
||||
|
||||
@@ -115,6 +115,8 @@ import { SearchManager } from './worker/SearchManager.js';
|
||||
import { FormattingService } from './worker/FormattingService.js';
|
||||
import { TimelineService } from './worker/TimelineService.js';
|
||||
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
|
||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './transcripts/config.js';
|
||||
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||
|
||||
// HTTP route handlers
|
||||
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
|
||||
@@ -179,6 +181,9 @@ export class WorkerService {
|
||||
// Chroma MCP manager (lazy - connects on first use)
|
||||
private chromaMcpManager: ChromaMcpManager | null = null;
|
||||
|
||||
// Transcript watcher for Codex and other transcript-based clients
|
||||
private transcriptWatcher: TranscriptWatcher | null = null;
|
||||
|
||||
// Initialization tracking
|
||||
private initializationComplete: Promise<void>;
|
||||
private resolveInitialization!: () => void;
|
||||
@@ -421,6 +426,8 @@ export class WorkerService {
|
||||
this.resolveInitialization();
|
||||
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
||||
|
||||
await this.startTranscriptWatcher(settings);
|
||||
|
||||
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
||||
if (this.chromaMcpManager) {
|
||||
ChromaSync.backfillAllProjects().then(() => {
|
||||
@@ -519,6 +526,48 @@ export class WorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start transcript watcher for Codex and other transcript-based clients.
|
||||
* This is intentionally non-fatal so Claude hooks remain usable even if
|
||||
* transcript ingestion is misconfigured.
|
||||
*/
|
||||
private async startTranscriptWatcher(settings: ReturnType<typeof SettingsDefaultsManager.loadFromFile>): Promise<void> {
|
||||
const transcriptsEnabled = settings.CLAUDE_MEM_TRANSCRIPTS_ENABLED !== 'false';
|
||||
if (!transcriptsEnabled) {
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher disabled via CLAUDE_MEM_TRANSCRIPTS_ENABLED=false');
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = settings.CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH || DEFAULT_CONFIG_PATH;
|
||||
const resolvedConfigPath = expandHomePath(configPath);
|
||||
|
||||
try {
|
||||
if (!existsSync(resolvedConfigPath)) {
|
||||
writeSampleConfig(configPath);
|
||||
logger.info('TRANSCRIPT', 'Created default transcript watch config', {
|
||||
configPath: resolvedConfigPath
|
||||
});
|
||||
}
|
||||
|
||||
const transcriptConfig = loadTranscriptWatchConfig(configPath);
|
||||
const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH);
|
||||
|
||||
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath);
|
||||
await this.transcriptWatcher.start();
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher started', {
|
||||
configPath: resolvedConfigPath,
|
||||
statePath,
|
||||
watches: transcriptConfig.watches.length
|
||||
});
|
||||
} catch (error) {
|
||||
this.transcriptWatcher?.stop();
|
||||
this.transcriptWatcher = null;
|
||||
logger.error('TRANSCRIPT', 'Failed to start transcript watcher (continuing without Codex ingestion)', {
|
||||
configPath: resolvedConfigPath
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate agent based on provider settings.
|
||||
* Same logic as SessionRoutes.getActiveAgent() for consistency.
|
||||
@@ -903,6 +952,12 @@ export class WorkerService {
|
||||
* Shutdown the worker service
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.transcriptWatcher) {
|
||||
this.transcriptWatcher.stop();
|
||||
this.transcriptWatcher = null;
|
||||
logger.info('TRANSCRIPT', 'Transcript watcher stopped');
|
||||
}
|
||||
|
||||
// Stop orphan reaper before shutdown (Issue #737)
|
||||
if (this.stopOrphanReaper) {
|
||||
this.stopOrphanReaper();
|
||||
@@ -957,7 +1012,7 @@ export class WorkerService {
|
||||
* @param port - The TCP port (used for port-in-use checks and daemon spawn)
|
||||
* @returns true if worker is healthy (existing or newly started), false on failure
|
||||
*/
|
||||
async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
export async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
// Clean stale PID file first (cheap: 1 fs read + 1 signal-0 check)
|
||||
const pidFileStatus = cleanStalePidFile();
|
||||
if (pidFileStatus === 'alive') {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ActiveSession {
|
||||
contentSessionId: string; // User's Claude Code session being observed
|
||||
memorySessionId: string | null; // Memory agent's session ID for resume
|
||||
project: string;
|
||||
platformSource: string;
|
||||
userPrompt: string;
|
||||
pendingMessages: PendingMessage[]; // Deprecated: now using persistent store, kept for compatibility
|
||||
abortController: AbortController;
|
||||
@@ -97,6 +98,7 @@ export interface PaginationParams {
|
||||
offset: number;
|
||||
limit: number;
|
||||
project?: string;
|
||||
platformSource?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -117,6 +119,7 @@ export interface Observation {
|
||||
id: number;
|
||||
memory_session_id: string; // Renamed from sdk_session_id
|
||||
project: string;
|
||||
platform_source: string;
|
||||
type: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
@@ -135,6 +138,7 @@ export interface Summary {
|
||||
id: number;
|
||||
session_id: string; // content_session_id (from JOIN)
|
||||
project: string;
|
||||
platform_source: string;
|
||||
request: string | null;
|
||||
investigated: string | null;
|
||||
learned: string | null;
|
||||
@@ -149,6 +153,7 @@ export interface UserPrompt {
|
||||
id: number;
|
||||
content_session_id: string; // Renamed from claude_session_id
|
||||
project: string; // From JOIN with sdk_sessions
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at: string;
|
||||
@@ -159,6 +164,7 @@ export interface DBSession {
|
||||
id: number;
|
||||
content_session_id: string; // Renamed from claude_session_id
|
||||
project: string;
|
||||
platform_source: string;
|
||||
user_prompt: string;
|
||||
memory_session_id: string | null; // Renamed from sdk_session_id
|
||||
status: 'active' | 'completed' | 'failed';
|
||||
|
||||
@@ -71,14 +71,54 @@ export class PaginationHelper {
|
||||
/**
|
||||
* Get paginated observations
|
||||
*/
|
||||
getObservations(offset: number, limit: number, project?: string): PaginatedResult<Observation> {
|
||||
const result = this.paginate<Observation>(
|
||||
'observations',
|
||||
'id, memory_session_id, project, type, title, subtitle, narrative, text, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch',
|
||||
getObservations(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<Observation> {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
let query = `
|
||||
SELECT
|
||||
o.id,
|
||||
o.memory_session_id,
|
||||
o.project,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
o.type,
|
||||
o.title,
|
||||
o.subtitle,
|
||||
o.narrative,
|
||||
o.text,
|
||||
o.facts,
|
||||
o.concepts,
|
||||
o.files_read,
|
||||
o.files_modified,
|
||||
o.prompt_number,
|
||||
o.created_at,
|
||||
o.created_at_epoch
|
||||
FROM observations o
|
||||
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (project) {
|
||||
conditions.push('o.project = ?');
|
||||
params.push(project);
|
||||
}
|
||||
if (platformSource) {
|
||||
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
|
||||
params.push(platformSource);
|
||||
}
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ' ORDER BY o.created_at_epoch DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit + 1, offset);
|
||||
|
||||
const results = db.prepare(query).all(...params) as Observation[];
|
||||
const result: PaginatedResult<Observation> = {
|
||||
items: results.slice(0, limit),
|
||||
hasMore: results.length > limit,
|
||||
offset,
|
||||
limit,
|
||||
project
|
||||
);
|
||||
limit
|
||||
};
|
||||
|
||||
// Strip project paths from file paths before returning
|
||||
return {
|
||||
@@ -90,13 +130,14 @@ export class PaginationHelper {
|
||||
/**
|
||||
* Get paginated summaries
|
||||
*/
|
||||
getSummaries(offset: number, limit: number, project?: string): PaginatedResult<Summary> {
|
||||
getSummaries(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<Summary> {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
ss.id,
|
||||
s.content_session_id as session_id,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
ss.request,
|
||||
ss.investigated,
|
||||
ss.learned,
|
||||
@@ -110,11 +151,22 @@ export class PaginationHelper {
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (project) {
|
||||
query += ' WHERE ss.project = ?';
|
||||
conditions.push('ss.project = ?');
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
if (platformSource) {
|
||||
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
|
||||
params.push(platformSource);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ' ORDER BY ss.created_at_epoch DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit + 1, offset);
|
||||
|
||||
@@ -132,21 +184,40 @@ export class PaginationHelper {
|
||||
/**
|
||||
* Get paginated user prompts
|
||||
*/
|
||||
getPrompts(offset: number, limit: number, project?: string): PaginatedResult<UserPrompt> {
|
||||
getPrompts(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<UserPrompt> {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
|
||||
let query = `
|
||||
SELECT up.id, up.content_session_id, s.project, up.prompt_number, up.prompt_text, up.created_at, up.created_at_epoch
|
||||
SELECT
|
||||
up.id,
|
||||
up.content_session_id,
|
||||
s.project,
|
||||
COALESCE(s.platform_source, 'claude') as platform_source,
|
||||
up.prompt_number,
|
||||
up.prompt_text,
|
||||
up.created_at,
|
||||
up.created_at_epoch
|
||||
FROM user_prompts up
|
||||
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (project) {
|
||||
query += ' WHERE s.project = ?';
|
||||
conditions.push('s.project = ?');
|
||||
params.push(project);
|
||||
}
|
||||
|
||||
if (platformSource) {
|
||||
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
|
||||
params.push(platformSource);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
query += ' ORDER BY up.created_at_epoch DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit + 1, offset);
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ export class SessionManager {
|
||||
});
|
||||
session.project = dbSession.project;
|
||||
}
|
||||
if (dbSession.platform_source && dbSession.platform_source !== session.platformSource) {
|
||||
session.platformSource = dbSession.platform_source;
|
||||
}
|
||||
|
||||
// Update userPrompt for continuation prompts
|
||||
if (currentUserPrompt) {
|
||||
@@ -144,6 +147,7 @@ export class SessionManager {
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: null, // Always start fresh - SDK will capture new ID
|
||||
project: dbSession.project,
|
||||
platformSource: dbSession.platform_source,
|
||||
userPrompt,
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
|
||||
@@ -223,6 +223,7 @@ async function syncAndBroadcastObservations(
|
||||
id: obsId,
|
||||
memory_session_id: session.memorySessionId,
|
||||
session_id: session.contentSessionId,
|
||||
platform_source: session.platformSource,
|
||||
type: obs.type,
|
||||
title: obs.title,
|
||||
subtitle: obs.subtitle,
|
||||
@@ -312,6 +313,7 @@ async function syncAndBroadcastSummary(
|
||||
broadcastSummary(worker, {
|
||||
id: result.summaryId,
|
||||
session_id: session.contentSessionId,
|
||||
platform_source: session.platformSource,
|
||||
request: summary!.request,
|
||||
investigated: summary!.investigated,
|
||||
learned: summary!.learned,
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ObservationSSEPayload {
|
||||
id: number;
|
||||
memory_session_id: string | null;
|
||||
session_id: string;
|
||||
platform_source: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
@@ -50,6 +51,7 @@ export interface ObservationSSEPayload {
|
||||
export interface SummarySSEPayload {
|
||||
id: number;
|
||||
session_id: string;
|
||||
platform_source: string;
|
||||
request: string | null;
|
||||
investigated: string | null;
|
||||
learned: string | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ export class SessionEventBroadcaster {
|
||||
id: number;
|
||||
content_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
import { AppError } from '../../server/ErrorHandler.js';
|
||||
|
||||
export abstract class BaseRouteHandler {
|
||||
/**
|
||||
@@ -80,7 +81,18 @@ export abstract class BaseRouteHandler {
|
||||
protected handleError(res: Response, error: Error, context?: string): void {
|
||||
logger.failure('WORKER', context || 'Request failed', {}, error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: error.message });
|
||||
const statusCode = error instanceof AppError ? error.statusCode : 500;
|
||||
const response: Record<string, unknown> = { error: error.message };
|
||||
|
||||
if (error instanceof AppError && error.code) {
|
||||
response.code = error.code;
|
||||
}
|
||||
|
||||
if (error instanceof AppError && error.details !== undefined) {
|
||||
response.details = error.details;
|
||||
}
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* Get paginated observations
|
||||
*/
|
||||
private handleGetObservations = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { offset, limit, project } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getObservations(offset, limit, project);
|
||||
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getObservations(offset, limit, project, platformSource);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -75,8 +75,8 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* Get paginated summaries
|
||||
*/
|
||||
private handleGetSummaries = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { offset, limit, project } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getSummaries(offset, limit, project);
|
||||
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getSummaries(offset, limit, project, platformSource);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -84,8 +84,8 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* Get paginated user prompts
|
||||
*/
|
||||
private handleGetPrompts = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { offset, limit, project } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getPrompts(offset, limit, project);
|
||||
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
|
||||
const result = this.paginationHelper.getPrompts(offset, limit, project, platformSource);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -256,19 +256,19 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
* GET /api/projects
|
||||
*/
|
||||
private handleGetProjects = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const db = this.dbManager.getSessionStore().db;
|
||||
const store = this.dbManager.getSessionStore();
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT DISTINCT project
|
||||
FROM observations
|
||||
WHERE project IS NOT NULL
|
||||
GROUP BY project
|
||||
ORDER BY MAX(created_at_epoch) DESC
|
||||
`).all() as Array<{ project: string }>;
|
||||
if (platformSource) {
|
||||
res.json({
|
||||
projects: store.getAllProjects(platformSource),
|
||||
sources: [platformSource],
|
||||
projectsBySource: { [platformSource]: store.getAllProjects(platformSource) }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = rows.map(row => row.project);
|
||||
|
||||
res.json({ projects });
|
||||
res.json(store.getProjectCatalog());
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -299,12 +299,13 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
/**
|
||||
* Parse pagination parameters from request query
|
||||
*/
|
||||
private parsePaginationParams(req: Request): { offset: number; limit: number; project?: string } {
|
||||
private parsePaginationParams(req: Request): { offset: number; limit: number; project?: string; platformSource?: string } {
|
||||
const offset = parseInt(req.query.offset as string, 10) || 0;
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); // Max 100
|
||||
const project = req.query.project as string | undefined;
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
return { offset, limit, project };
|
||||
return { offset, limit, project, platformSource };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -167,6 +167,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleContextPreview = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const projectName = req.query.project as string;
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
if (!projectName) {
|
||||
this.badRequest(res, 'Project parameter is required');
|
||||
@@ -183,7 +184,9 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
const contextText = await generateContext(
|
||||
{
|
||||
session_id: 'preview-' + Date.now(),
|
||||
cwd: cwd
|
||||
cwd: cwd,
|
||||
projects: [projectName],
|
||||
platform_source: platformSource
|
||||
},
|
||||
true // useColors=true for ANSI terminal output
|
||||
);
|
||||
@@ -209,6 +212,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
const projectsParam = (req.query.projects as string) || (req.query.project as string);
|
||||
const useColors = req.query.colors === 'true';
|
||||
const full = req.query.full === 'true';
|
||||
const platformSource = req.query.platformSource as string | undefined;
|
||||
|
||||
if (!projectsParam) {
|
||||
this.badRequest(res, 'Project(s) parameter is required');
|
||||
@@ -236,7 +240,8 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
session_id: 'context-inject-' + Date.now(),
|
||||
cwd: cwd,
|
||||
projects: projects,
|
||||
full
|
||||
full,
|
||||
platform_source: platformSource
|
||||
},
|
||||
useColors
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ import { PrivacyCheckValidator } from '../../validation/PrivacyCheckValidator.js
|
||||
import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
|
||||
import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js';
|
||||
import { getProjectName } from '../../../../utils/project-name.js';
|
||||
import { normalizePlatformSource } from '../../../../shared/platform-source.js';
|
||||
|
||||
export class SessionRoutes extends BaseRouteHandler {
|
||||
private completionHandler: SessionCompletionHandler;
|
||||
@@ -348,6 +350,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
id: latestPrompt.id,
|
||||
content_session_id: latestPrompt.content_session_id,
|
||||
project: latestPrompt.project,
|
||||
platform_source: latestPrompt.platform_source,
|
||||
prompt_number: latestPrompt.prompt_number,
|
||||
prompt_text: latestPrompt.prompt_text,
|
||||
created_at_epoch: latestPrompt.created_at_epoch
|
||||
@@ -497,6 +500,8 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { contentSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
const project = typeof cwd === 'string' && cwd.trim() ? getProjectName(cwd) : '';
|
||||
|
||||
if (!contentSessionId) {
|
||||
return this.badRequest(res, 'Missing contentSessionId');
|
||||
@@ -531,7 +536,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const store = this.dbManager.getSessionStore();
|
||||
|
||||
// Get or create session
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, project, '', undefined, platformSource);
|
||||
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
|
||||
|
||||
// Privacy check: skip if user prompt was entirely private
|
||||
@@ -595,6 +600,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleSummarizeByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { contentSessionId, last_assistant_message } = req.body;
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
|
||||
if (!contentSessionId) {
|
||||
return this.badRequest(res, 'Missing contentSessionId');
|
||||
@@ -603,7 +609,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const store = this.dbManager.getSessionStore();
|
||||
|
||||
// Get or create session
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '', undefined, platformSource);
|
||||
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
|
||||
|
||||
// Privacy check: skip if user prompt was entirely private
|
||||
@@ -643,6 +649,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private handleCompleteByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { contentSessionId } = req.body;
|
||||
const platformSource = normalizePlatformSource(req.body.platformSource);
|
||||
|
||||
logger.info('HTTP', '→ POST /api/sessions/complete', { contentSessionId });
|
||||
|
||||
@@ -654,7 +661,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
|
||||
// Look up sessionDbId from contentSessionId (createSDKSession is idempotent)
|
||||
// Pass empty strings - we only need the ID lookup, not to create a new session
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '', undefined, platformSource);
|
||||
|
||||
// Check if session is in the active sessions map
|
||||
const activeSession = this.sessionManager.getSession(sessionDbId);
|
||||
@@ -698,11 +705,13 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
// may omit prompt/project in their payload (#838, #1049)
|
||||
const project = req.body.project || 'unknown';
|
||||
const prompt = req.body.prompt || '[media prompt]';
|
||||
const platformSource = req.body.platformSource || 'claude';
|
||||
const customTitle = req.body.customTitle || undefined;
|
||||
|
||||
logger.info('HTTP', 'SessionRoutes: handleSessionInitByClaudeId called', {
|
||||
contentSessionId,
|
||||
project,
|
||||
platformSource,
|
||||
prompt_length: prompt?.length,
|
||||
customTitle
|
||||
});
|
||||
@@ -715,7 +724,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const store = this.dbManager.getSessionStore();
|
||||
|
||||
// Step 1: Create/get SDK session (idempotent INSERT OR IGNORE)
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt, customTitle);
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt, customTitle, platformSource);
|
||||
|
||||
// Verify session creation with DB lookup
|
||||
const dbSession = store.getSessionById(sessionDbId);
|
||||
|
||||
@@ -76,11 +76,13 @@ export class ViewerRoutes extends BaseRouteHandler {
|
||||
// Add client to broadcaster
|
||||
this.sseBroadcaster.addClient(res);
|
||||
|
||||
// Send initial_load event with projects list
|
||||
const allProjects = this.dbManager.getSessionStore().getAllProjects();
|
||||
// Send initial_load event with project/source catalog
|
||||
const projectCatalog = this.dbManager.getSessionStore().getProjectCatalog();
|
||||
this.sseBroadcaster.broadcast({
|
||||
type: 'initial_load',
|
||||
projects: allProjects,
|
||||
projects: projectCatalog.projects,
|
||||
sources: projectCatalog.sources,
|
||||
projectsBySource: projectCatalog.projectsBySource,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: string;
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
|
||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: string; // 'true' | 'false' - enable transcript watcher ingestion for Codex and other transcript-based clients
|
||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string; // Path to transcript watcher config JSON
|
||||
// Process Management
|
||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; // Max concurrent Claude SDK agent subprocesses (default: 2)
|
||||
// Exclusion Settings
|
||||
@@ -108,6 +110,8 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: 'true',
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
|
||||
CLAUDE_MEM_TRANSCRIPTS_ENABLED: 'true',
|
||||
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'),
|
||||
// Process Management
|
||||
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
|
||||
// Exclusion Settings
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const DEFAULT_PLATFORM_SOURCE = 'claude';
|
||||
|
||||
function sanitizeRawSource(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
export function normalizePlatformSource(value?: string | null): string {
|
||||
if (!value) return DEFAULT_PLATFORM_SOURCE;
|
||||
|
||||
const source = sanitizeRawSource(value);
|
||||
if (!source) return DEFAULT_PLATFORM_SOURCE;
|
||||
|
||||
if (source === 'transcript') return 'codex';
|
||||
if (source.includes('codex')) return 'codex';
|
||||
if (source.includes('cursor')) return 'cursor';
|
||||
if (source.includes('claude')) return 'claude';
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
export function sortPlatformSources(sources: string[]): string[] {
|
||||
const priority = ['claude', 'codex', 'cursor'];
|
||||
|
||||
return [...sources].sort((a, b) => {
|
||||
const aPriority = priority.indexOf(a);
|
||||
const bPriority = priority.indexOf(b);
|
||||
|
||||
if (aPriority !== -1 || bPriority !== -1) {
|
||||
if (aPriority === -1) return 1;
|
||||
if (bPriority === -1) return -1;
|
||||
return aPriority - bPriority;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
@@ -103,6 +103,7 @@ export interface UserPromptRecord {
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
project?: string; // From JOIN with sdk_sessions
|
||||
platform_source?: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}
|
||||
@@ -115,6 +116,7 @@ export interface LatestPromptResult {
|
||||
content_session_id: string;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
|
||||
+123
-1
@@ -355,6 +355,14 @@
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
@@ -549,6 +557,42 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tab:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
border-color: var(--color-border-focus);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.source-tab.active {
|
||||
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
|
||||
border-color: var(--color-bg-button);
|
||||
color: var(--color-text-button);
|
||||
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.18);
|
||||
}
|
||||
|
||||
.settings-btn,
|
||||
.theme-toggle-btn {
|
||||
background: var(--color-bg-card);
|
||||
@@ -887,6 +931,49 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.card-source {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.source-claude {
|
||||
background: rgba(255, 138, 61, 0.12);
|
||||
color: #c25a00;
|
||||
border-color: rgba(255, 138, 61, 0.22);
|
||||
}
|
||||
|
||||
.source-codex {
|
||||
background: rgba(33, 150, 243, 0.12);
|
||||
color: #0f5ba7;
|
||||
border-color: rgba(33, 150, 243, 0.24);
|
||||
}
|
||||
|
||||
.source-cursor {
|
||||
background: rgba(124, 58, 237, 0.12);
|
||||
color: #6d28d9;
|
||||
border-color: rgba(124, 58, 237, 0.24);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-claude {
|
||||
color: #ffb067;
|
||||
border-color: rgba(255, 176, 103, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-codex {
|
||||
color: #8fc7ff;
|
||||
border-color: rgba(143, 199, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .source-cursor {
|
||||
color: #c4b5fd;
|
||||
border-color: rgba(196, 181, 253, 0.2);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
margin-bottom: 14px;
|
||||
@@ -1483,6 +1570,10 @@
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1491,6 +1582,11 @@
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Hide icon links (docs, github, twitter) on tablet */
|
||||
.icon-link {
|
||||
display: none;
|
||||
@@ -1544,6 +1640,27 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.source-tabs {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.source-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logomark {
|
||||
height: 28px;
|
||||
}
|
||||
@@ -1732,6 +1849,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-selector select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preview-selector select {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
@@ -2873,4 +2995,4 @@
|
||||
<script src="viewer-bundle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+42
-21
@@ -13,39 +13,57 @@ import { mergeAndDeduplicateByProject } from './utils/data';
|
||||
|
||||
export function App() {
|
||||
const [currentFilter, setCurrentFilter] = useState('');
|
||||
const [currentSource, setCurrentSource] = useState('all');
|
||||
const [contextPreviewOpen, setContextPreviewOpen] = useState(false);
|
||||
const [logsModalOpen, setLogsModalOpen] = useState(false);
|
||||
const [paginatedObservations, setPaginatedObservations] = useState<Observation[]>([]);
|
||||
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
|
||||
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
|
||||
|
||||
const { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected } = useSSE();
|
||||
const { observations, summaries, prompts, projects, sources, projectsBySource, isProcessing, queueDepth, isConnected } = useSSE();
|
||||
const { settings, saveSettings, isSaving, saveStatus } = useSettings();
|
||||
const { stats, refreshStats } = useStats();
|
||||
const { preference, resolvedTheme, setThemePreference } = useTheme();
|
||||
const pagination = usePagination(currentFilter);
|
||||
const pagination = usePagination(currentFilter, currentSource);
|
||||
|
||||
const availableProjects = useMemo(() => {
|
||||
if (currentSource === 'all') {
|
||||
return projects;
|
||||
}
|
||||
|
||||
return projectsBySource[currentSource] || [];
|
||||
}, [currentSource, projects, projectsBySource]);
|
||||
|
||||
const matchesSelection = useCallback((item: { project: string; platform_source: string }) => {
|
||||
const matchesProject = !currentFilter || item.project === currentFilter;
|
||||
const matchesSource = currentSource === 'all' || (item.platform_source || 'claude') === currentSource;
|
||||
return matchesProject && matchesSource;
|
||||
}, [currentFilter, currentSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFilter && !availableProjects.includes(currentFilter)) {
|
||||
setCurrentFilter('');
|
||||
}
|
||||
}, [availableProjects, currentFilter]);
|
||||
|
||||
// Merge SSE live data with paginated data, filtering by project when active
|
||||
const allObservations = useMemo(() => {
|
||||
const live = currentFilter
|
||||
? observations.filter(o => o.project === currentFilter)
|
||||
: observations;
|
||||
return mergeAndDeduplicateByProject(live, paginatedObservations);
|
||||
}, [observations, paginatedObservations, currentFilter]);
|
||||
const live = observations.filter(matchesSelection);
|
||||
const paginated = paginatedObservations.filter(matchesSelection);
|
||||
return mergeAndDeduplicateByProject(live, paginated);
|
||||
}, [observations, paginatedObservations, matchesSelection]);
|
||||
|
||||
const allSummaries = useMemo(() => {
|
||||
const live = currentFilter
|
||||
? summaries.filter(s => s.project === currentFilter)
|
||||
: summaries;
|
||||
return mergeAndDeduplicateByProject(live, paginatedSummaries);
|
||||
}, [summaries, paginatedSummaries, currentFilter]);
|
||||
const live = summaries.filter(matchesSelection);
|
||||
const paginated = paginatedSummaries.filter(matchesSelection);
|
||||
return mergeAndDeduplicateByProject(live, paginated);
|
||||
}, [summaries, paginatedSummaries, matchesSelection]);
|
||||
|
||||
const allPrompts = useMemo(() => {
|
||||
const live = currentFilter
|
||||
? prompts.filter(p => p.project === currentFilter)
|
||||
: prompts;
|
||||
return mergeAndDeduplicateByProject(live, paginatedPrompts);
|
||||
}, [prompts, paginatedPrompts, currentFilter]);
|
||||
const live = prompts.filter(matchesSelection);
|
||||
const paginated = paginatedPrompts.filter(matchesSelection);
|
||||
return mergeAndDeduplicateByProject(live, paginated);
|
||||
}, [prompts, paginatedPrompts, matchesSelection]);
|
||||
|
||||
// Toggle context preview modal
|
||||
const toggleContextPreview = useCallback(() => {
|
||||
@@ -78,24 +96,27 @@ export function App() {
|
||||
} catch (error) {
|
||||
console.error('Failed to load more data:', error);
|
||||
}
|
||||
}, [currentFilter, pagination.observations, pagination.summaries, pagination.prompts]);
|
||||
}, [pagination.observations, pagination.summaries, pagination.prompts]);
|
||||
|
||||
// Reset paginated data and load first page when filter changes
|
||||
// Reset paginated data and load first page when project/source changes
|
||||
useEffect(() => {
|
||||
setPaginatedObservations([]);
|
||||
setPaginatedSummaries([]);
|
||||
setPaginatedPrompts([]);
|
||||
handleLoadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentFilter]);
|
||||
}, [currentFilter, currentSource]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
isConnected={isConnected}
|
||||
projects={projects}
|
||||
projects={availableProjects}
|
||||
sources={sources}
|
||||
currentFilter={currentFilter}
|
||||
currentSource={currentSource}
|
||||
onFilterChange={setCurrentFilter}
|
||||
onSourceChange={setCurrentSource}
|
||||
isProcessing={isProcessing}
|
||||
queueDepth={queueDepth}
|
||||
themePreference={preference}
|
||||
|
||||
@@ -136,7 +136,17 @@ export function ContextSettingsModal({
|
||||
}, [settings]);
|
||||
|
||||
// Get context preview based on current form state
|
||||
const { preview, isLoading, error, projects, selectedProject, setSelectedProject } = useContextPreview(formState);
|
||||
const {
|
||||
preview,
|
||||
isLoading,
|
||||
error,
|
||||
projects,
|
||||
sources,
|
||||
selectedSource,
|
||||
setSelectedSource,
|
||||
selectedProject,
|
||||
setSelectedProject
|
||||
} = useContextPreview(formState);
|
||||
|
||||
const updateSetting = useCallback((key: keyof Settings, value: string) => {
|
||||
const newState = { ...formState, [key]: value };
|
||||
@@ -174,10 +184,23 @@ export function ContextSettingsModal({
|
||||
<h2>Settings</h2>
|
||||
<div className="header-controls">
|
||||
<label className="preview-selector">
|
||||
Preview for:
|
||||
Source:
|
||||
<select
|
||||
value={selectedSource || ''}
|
||||
onChange={(e) => setSelectedSource(e.target.value)}
|
||||
disabled={sources.length === 0}
|
||||
>
|
||||
{sources.map(source => (
|
||||
<option key={source} value={source}>{source}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="preview-selector">
|
||||
Project:
|
||||
<select
|
||||
value={selectedProject || ''}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
disabled={projects.length === 0}
|
||||
>
|
||||
{projects.map(project => (
|
||||
<option key={project} value={project}>{project}</option>
|
||||
|
||||
@@ -7,8 +7,11 @@ import { useSpinningFavicon } from '../hooks/useSpinningFavicon';
|
||||
interface HeaderProps {
|
||||
isConnected: boolean;
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
currentFilter: string;
|
||||
currentSource: string;
|
||||
onFilterChange: (filter: string) => void;
|
||||
onSourceChange: (source: string) => void;
|
||||
isProcessing: boolean;
|
||||
queueDepth: number;
|
||||
themePreference: ThemePreference;
|
||||
@@ -16,11 +19,26 @@ interface HeaderProps {
|
||||
onContextPreviewToggle: () => void;
|
||||
}
|
||||
|
||||
function formatSourceLabel(source: string): string {
|
||||
if (source === 'all') return 'All';
|
||||
if (source === 'claude') return 'Claude';
|
||||
if (source === 'codex') return 'Codex';
|
||||
return source.charAt(0).toUpperCase() + source.slice(1);
|
||||
}
|
||||
|
||||
function buildSourceTabs(sources: string[]): string[] {
|
||||
const merged = ['all', 'claude', 'codex', ...sources];
|
||||
return Array.from(new Set(merged.filter(Boolean)));
|
||||
}
|
||||
|
||||
export function Header({
|
||||
isConnected,
|
||||
projects,
|
||||
sources,
|
||||
currentFilter,
|
||||
currentSource,
|
||||
onFilterChange,
|
||||
onSourceChange,
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
themePreference,
|
||||
@@ -28,20 +46,36 @@ export function Header({
|
||||
onContextPreviewToggle
|
||||
}: HeaderProps) {
|
||||
useSpinningFavicon(isProcessing);
|
||||
const availableSources = buildSourceTabs(sources);
|
||||
|
||||
return (
|
||||
<div className="header">
|
||||
<h1>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
|
||||
{queueDepth > 0 && (
|
||||
<div className="queue-bubble">
|
||||
{queueDepth}
|
||||
</div>
|
||||
)}
|
||||
<div className="header-main">
|
||||
<h1>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
|
||||
{queueDepth > 0 && (
|
||||
<div className="queue-bubble">
|
||||
{queueDepth}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="logo-text">claude-mem</span>
|
||||
</h1>
|
||||
<div className="source-tabs" role="tablist" aria-label="Context source tabs">
|
||||
{availableSources.map(source => (
|
||||
<button
|
||||
key={source}
|
||||
type="button"
|
||||
className={`source-tab ${currentSource === source ? 'active' : ''}`}
|
||||
onClick={() => onSourceChange(source)}
|
||||
aria-pressed={currentSource === source}
|
||||
>
|
||||
{formatSourceLabel(source)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="logo-text">claude-mem</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="status">
|
||||
<a
|
||||
href="https://docs.claude-mem.ai"
|
||||
|
||||
@@ -52,6 +52,9 @@ export function ObservationCard({ observation }: ObservationCardProps) {
|
||||
<span className={`card-type type-${observation.type}`}>
|
||||
{observation.type}
|
||||
</span>
|
||||
<span className={`card-source source-${observation.platform_source || 'claude'}`}>
|
||||
{observation.platform_source || 'claude'}
|
||||
</span>
|
||||
<span className="card-project">{observation.project}</span>
|
||||
</div>
|
||||
<div className="view-mode-toggles">
|
||||
|
||||
@@ -14,6 +14,9 @@ export function PromptCard({ prompt }: PromptCardProps) {
|
||||
<div className="card-header">
|
||||
<div className="card-header-left">
|
||||
<span className="card-type">Prompt</span>
|
||||
<span className={`card-source source-${prompt.platform_source || 'claude'}`}>
|
||||
{prompt.platform_source || 'claude'}
|
||||
</span>
|
||||
<span className="card-project">{prompt.project}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,9 @@ export function SummaryCard({ summary }: SummaryCardProps) {
|
||||
<header className="summary-card-header">
|
||||
<div className="summary-badge-row">
|
||||
<span className="card-type summary-badge">Session Summary</span>
|
||||
<span className={`card-source source-${summary.platform_source || 'claude'}`}>
|
||||
{summary.platform_source || 'claude'}
|
||||
</span>
|
||||
<span className="summary-project-badge">{summary.project}</span>
|
||||
</div>
|
||||
{summary.request && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Settings } from '../types';
|
||||
import type { ProjectCatalog, Settings } from '../types';
|
||||
|
||||
interface UseContextPreviewResult {
|
||||
preview: string;
|
||||
@@ -7,15 +7,31 @@ interface UseContextPreviewResult {
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
selectedSource: string | null;
|
||||
setSelectedSource: (source: string) => void;
|
||||
selectedProject: string | null;
|
||||
setSelectedProject: (project: string) => void;
|
||||
}
|
||||
|
||||
function getPreferredSource(sources: string[]): string | null {
|
||||
if (sources.includes('claude')) return 'claude';
|
||||
if (sources.includes('codex')) return 'codex';
|
||||
return sources[0] || null;
|
||||
}
|
||||
|
||||
function withDefaultSources(sources: string[]): string[] {
|
||||
const merged = ['claude', 'codex', ...sources];
|
||||
return Array.from(new Set(merged));
|
||||
}
|
||||
|
||||
export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
const [preview, setPreview] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [catalog, setCatalog] = useState<ProjectCatalog>({ projects: [], sources: [], projectsBySource: {} });
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
|
||||
// Fetch projects on mount
|
||||
@@ -23,11 +39,27 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
const data = await response.json();
|
||||
if (data.projects && data.projects.length > 0) {
|
||||
setProjects(data.projects);
|
||||
setSelectedProject(data.projects[0]); // Default to first project
|
||||
const data = await response.json() as ProjectCatalog;
|
||||
const nextCatalog: ProjectCatalog = {
|
||||
projects: data.projects || [],
|
||||
sources: withDefaultSources(data.sources || []),
|
||||
projectsBySource: data.projectsBySource || {}
|
||||
};
|
||||
|
||||
setCatalog(nextCatalog);
|
||||
|
||||
const preferredSource = getPreferredSource(nextCatalog.sources);
|
||||
setSelectedSource(preferredSource);
|
||||
|
||||
if (preferredSource) {
|
||||
const sourceProjects = nextCatalog.projectsBySource[preferredSource] || [];
|
||||
setProjects(sourceProjects);
|
||||
setSelectedProject(sourceProjects[0] || null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProjects(nextCatalog.projects);
|
||||
setSelectedProject(nextCatalog.projects[0] || null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch projects:', err);
|
||||
}
|
||||
@@ -35,6 +67,18 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSource) {
|
||||
setProjects(catalog.projects);
|
||||
setSelectedProject(prev => (prev && catalog.projects.includes(prev) ? prev : catalog.projects[0] || null));
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceProjects = catalog.projectsBySource[selectedSource] || [];
|
||||
setProjects(sourceProjects);
|
||||
setSelectedProject(prev => (prev && sourceProjects.includes(prev) ? prev : sourceProjects[0] || null));
|
||||
}, [catalog, selectedSource]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!selectedProject) {
|
||||
setPreview('No project selected');
|
||||
@@ -48,6 +92,10 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
project: selectedProject
|
||||
});
|
||||
|
||||
if (selectedSource) {
|
||||
params.append('platformSource', selectedSource);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/context/preview?${params}`);
|
||||
const text = await response.text();
|
||||
|
||||
@@ -58,7 +106,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [selectedProject]);
|
||||
}, [selectedProject, selectedSource]);
|
||||
|
||||
// Debounced refresh when settings or selectedProject change
|
||||
useEffect(() => {
|
||||
@@ -68,5 +116,16 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [settings, refresh]);
|
||||
|
||||
return { preview, isLoading, error, refresh, projects, selectedProject, setSelectedProject };
|
||||
return {
|
||||
preview,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
projects,
|
||||
sources: catalog.sources,
|
||||
selectedSource,
|
||||
setSelectedSource,
|
||||
selectedProject,
|
||||
setSelectedProject
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type DataItem = Observation | Summary | UserPrompt;
|
||||
/**
|
||||
* Generic pagination hook for observations, summaries, and prompts
|
||||
*/
|
||||
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string) {
|
||||
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string, currentSource: string) {
|
||||
const [state, setState] = useState<PaginationState>({
|
||||
isLoading: false,
|
||||
hasMore: true
|
||||
@@ -22,7 +22,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
|
||||
// Track offset and filter in refs to handle synchronous resets
|
||||
const offsetRef = useRef(0);
|
||||
const lastFilterRef = useRef(currentFilter);
|
||||
const lastSelectionRef = useRef(`${currentSource}::${currentFilter}`);
|
||||
const stateRef = useRef(state);
|
||||
|
||||
/**
|
||||
@@ -31,16 +31,17 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<DataItem[]> => {
|
||||
// Check if filter changed - if so, reset pagination synchronously
|
||||
const filterChanged = lastFilterRef.current !== currentFilter;
|
||||
const selectionKey = `${currentSource}::${currentFilter}`;
|
||||
const filterChanged = lastSelectionRef.current !== selectionKey;
|
||||
|
||||
if (filterChanged) {
|
||||
offsetRef.current = 0;
|
||||
lastFilterRef.current = currentFilter;
|
||||
lastSelectionRef.current = selectionKey;
|
||||
|
||||
// Reset state both in React state and ref synchronously
|
||||
const newState = { isLoading: false, hasMore: true };
|
||||
setState(newState);
|
||||
stateRef.current = newState; // Update ref immediately to avoid stale checks
|
||||
stateRef.current = newState; // Update ref immediately to avoid stale checks
|
||||
}
|
||||
|
||||
// Prevent concurrent requests using ref (always current)
|
||||
@@ -49,6 +50,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
return [];
|
||||
}
|
||||
|
||||
stateRef.current = { ...stateRef.current, isLoading: true };
|
||||
setState(prev => ({ ...prev, isLoading: true }));
|
||||
|
||||
// Build query params using current offset from ref
|
||||
@@ -62,6 +64,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
params.append('project', currentFilter);
|
||||
}
|
||||
|
||||
if (currentSource && currentSource !== 'all') {
|
||||
params.append('platformSource', currentSource);
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -70,6 +76,13 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
|
||||
const data = await response.json() as { items: DataItem[], hasMore: boolean };
|
||||
|
||||
const nextState = {
|
||||
...stateRef.current,
|
||||
isLoading: false,
|
||||
hasMore: data.hasMore
|
||||
};
|
||||
stateRef.current = nextState;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
@@ -80,7 +93,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
offsetRef.current += UI.PAGINATION_PAGE_SIZE;
|
||||
|
||||
return data.items;
|
||||
}, [currentFilter, endpoint, dataType]);
|
||||
}, [currentFilter, currentSource, endpoint, dataType]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -91,10 +104,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
|
||||
/**
|
||||
* Hook for paginating observations
|
||||
*/
|
||||
export function usePagination(currentFilter: string) {
|
||||
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter);
|
||||
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter);
|
||||
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter);
|
||||
export function usePagination(currentFilter: string, currentSource: string) {
|
||||
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter, currentSource);
|
||||
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter, currentSource);
|
||||
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter, currentSource);
|
||||
|
||||
return {
|
||||
observations,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Observation, Summary, UserPrompt, StreamEvent } from '../types';
|
||||
import { Observation, Summary, UserPrompt, StreamEvent, ProjectCatalog } from '../types';
|
||||
import { API_ENDPOINTS } from '../constants/api';
|
||||
import { TIMING } from '../constants/timing';
|
||||
|
||||
@@ -7,16 +7,42 @@ export function useSSE() {
|
||||
const [observations, setObservations] = useState<Observation[]>([]);
|
||||
const [summaries, setSummaries] = useState<Summary[]>([]);
|
||||
const [prompts, setPrompts] = useState<UserPrompt[]>([]);
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
const [catalog, setCatalog] = useState<ProjectCatalog>({
|
||||
projects: [],
|
||||
sources: [],
|
||||
projectsBySource: {}
|
||||
});
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [queueDepth, setQueueDepth] = useState(0);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const updateCatalogForItem = (project: string, platformSource: string) => {
|
||||
setCatalog(prev => {
|
||||
const nextProjects = prev.projects.includes(project)
|
||||
? prev.projects
|
||||
: [...prev.projects, project];
|
||||
const nextSources = prev.sources.includes(platformSource)
|
||||
? prev.sources
|
||||
: [...prev.sources, platformSource];
|
||||
const sourceProjects = prev.projectsBySource[platformSource] || [];
|
||||
|
||||
return {
|
||||
projects: nextProjects,
|
||||
sources: nextSources,
|
||||
projectsBySource: {
|
||||
...prev.projectsBySource,
|
||||
[platformSource]: sourceProjects.includes(project)
|
||||
? sourceProjects
|
||||
: [...sourceProjects, project]
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
// Clean up existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
@@ -27,7 +53,6 @@ export function useSSE() {
|
||||
eventSource.onopen = () => {
|
||||
console.log('[SSE] Connected');
|
||||
setIsConnected(true);
|
||||
// Clear any pending reconnect
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
@@ -38,9 +63,8 @@ export function useSSE() {
|
||||
setIsConnected(false);
|
||||
eventSource.close();
|
||||
|
||||
// Reconnect after delay
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = undefined; // Clear before reconnecting
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
console.log('[SSE] Attempting to reconnect...');
|
||||
connect();
|
||||
}, TIMING.SSE_RECONNECT_DELAY_MS);
|
||||
@@ -52,32 +76,37 @@ export function useSSE() {
|
||||
switch (data.type) {
|
||||
case 'initial_load':
|
||||
console.log('[SSE] Initial load:', {
|
||||
projects: data.projects?.length || 0
|
||||
projects: data.projects?.length || 0,
|
||||
sources: data.sources?.length || 0
|
||||
});
|
||||
setCatalog({
|
||||
projects: data.projects || [],
|
||||
sources: data.sources || [],
|
||||
projectsBySource: data.projectsBySource || {}
|
||||
});
|
||||
// Only load projects list - data will come via pagination
|
||||
setProjects(data.projects || []);
|
||||
break;
|
||||
|
||||
case 'new_observation':
|
||||
if (data.observation) {
|
||||
console.log('[SSE] New observation:', data.observation.id);
|
||||
setObservations(prev => [data.observation, ...prev]);
|
||||
updateCatalogForItem(data.observation.project, data.observation.platform_source || 'claude');
|
||||
setObservations(prev => [data.observation!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_summary':
|
||||
if (data.summary) {
|
||||
const summary = data.summary;
|
||||
console.log('[SSE] New summary:', summary.id);
|
||||
setSummaries(prev => [summary, ...prev]);
|
||||
console.log('[SSE] New summary:', data.summary.id);
|
||||
updateCatalogForItem(data.summary.project, data.summary.platform_source || 'claude');
|
||||
setSummaries(prev => [data.summary!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'new_prompt':
|
||||
if (data.prompt) {
|
||||
const prompt = data.prompt;
|
||||
console.log('[SSE] New prompt:', prompt.id);
|
||||
setPrompts(prev => [prompt, ...prev]);
|
||||
console.log('[SSE] New prompt:', data.prompt.id);
|
||||
updateCatalogForItem(data.prompt.project, data.prompt.platform_source || 'claude');
|
||||
setPrompts(prev => [data.prompt!, ...prev]);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -94,7 +123,6 @@ export function useSSE() {
|
||||
|
||||
connect();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
@@ -105,5 +133,15 @@ export function useSSE() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected };
|
||||
return {
|
||||
observations,
|
||||
summaries,
|
||||
prompts,
|
||||
projects: catalog.projects,
|
||||
sources: catalog.sources,
|
||||
projectsBySource: catalog.projectsBySource,
|
||||
isProcessing,
|
||||
queueDepth,
|
||||
isConnected
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface Observation {
|
||||
id: number;
|
||||
memory_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
type: string;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
@@ -20,6 +21,7 @@ export interface Summary {
|
||||
id: number;
|
||||
session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
request?: string;
|
||||
investigated?: string;
|
||||
learned?: string;
|
||||
@@ -32,6 +34,7 @@ export interface UserPrompt {
|
||||
id: number;
|
||||
content_session_id: string;
|
||||
project: string;
|
||||
platform_source: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
created_at_epoch: number;
|
||||
@@ -48,10 +51,19 @@ export interface StreamEvent {
|
||||
summaries?: Summary[];
|
||||
prompts?: UserPrompt[];
|
||||
projects?: string[];
|
||||
sources?: string[];
|
||||
projectsBySource?: Record<string, string[]>;
|
||||
observation?: Observation;
|
||||
summary?: Summary;
|
||||
prompt?: UserPrompt;
|
||||
isProcessing?: boolean;
|
||||
queueDepth?: number;
|
||||
}
|
||||
|
||||
export interface ProjectCatalog {
|
||||
projects: string[];
|
||||
sources: string[];
|
||||
projectsBySource: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
|
||||
Reference in New Issue
Block a user