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