Merge branch 'pr-1472' into integration/validation-batch

# Conflicts:
#	plugin/scripts/context-generator.cjs
#	plugin/scripts/mcp-server.cjs
#	plugin/scripts/worker-service.cjs
#	plugin/ui/viewer-bundle.js
#	src/cli/handlers/context.ts
#	src/services/sqlite/SessionStore.ts
#	src/services/sqlite/migrations/runner.ts
#	src/services/worker-service.ts
#	src/shared/SettingsDefaultsManager.ts
This commit is contained in:
Alex Newman
2026-04-06 14:23:18 -07:00
50 changed files with 3852 additions and 683 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

View File

@@ -355,6 +355,14 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.header-main {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
flex-wrap: wrap;
}
.sidebar-header {
padding: 14px 18px;
border-bottom: 1px solid var(--color-border-primary);
@@ -549,6 +557,42 @@
font-size: 13px;
}
.source-tabs {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.source-tab {
background: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-secondary);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
line-height: 1;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.source-tab:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: translateY(-1px);
}
.source-tab.active {
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
border-color: var(--color-bg-button);
color: var(--color-text-button);
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.18);
}
.settings-btn,
.theme-toggle-btn {
background: var(--color-bg-card);
@@ -887,6 +931,49 @@
letter-spacing: 0.5px;
}
.card-source {
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid transparent;
}
.source-claude {
background: rgba(255, 138, 61, 0.12);
color: #c25a00;
border-color: rgba(255, 138, 61, 0.22);
}
.source-codex {
background: rgba(33, 150, 243, 0.12);
color: #0f5ba7;
border-color: rgba(33, 150, 243, 0.24);
}
.source-cursor {
background: rgba(124, 58, 237, 0.12);
color: #6d28d9;
border-color: rgba(124, 58, 237, 0.24);
}
[data-theme="dark"] .source-claude {
color: #ffb067;
border-color: rgba(255, 176, 103, 0.2);
}
[data-theme="dark"] .source-codex {
color: #8fc7ff;
border-color: rgba(143, 199, 255, 0.2);
}
[data-theme="dark"] .source-cursor {
color: #c4b5fd;
border-color: rgba(196, 181, 253, 0.2);
}
.card-title {
font-size: 17px;
margin-bottom: 14px;
@@ -1483,6 +1570,10 @@
padding: 14px 20px;
}
.header-main {
gap: 12px;
}
.status {
gap: 6px;
}
@@ -1491,6 +1582,11 @@
max-width: 160px;
}
.source-tab {
padding: 6px 10px;
font-size: 11px;
}
/* Hide icon links (docs, github, twitter) on tablet */
.icon-link {
display: none;
@@ -1544,6 +1640,28 @@
gap: 8px;
}
.header-main {
gap: 10px;
}
.source-tabs {
width: 100%;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.source-tabs::-webkit-scrollbar {
display: none;
}
.source-tab {
flex-shrink: 0;
padding: 5px 10px;
font-size: 11px;
}
.logomark {
height: 28px;
}
@@ -1732,6 +1850,11 @@
white-space: nowrap;
}
.preview-selector select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.preview-selector select {
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
@@ -2873,4 +2996,4 @@
<script src="viewer-bundle.js"></script>
</body>
</html>
</html>

View File

@@ -12,6 +12,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
export const contextHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -31,6 +32,7 @@ export const contextHandler: EventHandler = {
const cwd = input.cwd ?? process.cwd();
const context = getProjectContext(cwd);
const port = getWorkerPort();
const platformSource = normalizePlatformSource(input.platform);
// Check if terminal output should be shown (load settings early)
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
@@ -38,7 +40,7 @@ export const contextHandler: EventHandler = {
// Pass all projects (parent + worktree if applicable) for unified timeline
const projectsParam = context.allProjects.join(',');
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(platformSource)}`;
const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath;
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)

View File

@@ -9,6 +9,7 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
export const fileEditHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -20,6 +21,7 @@ export const fileEditHandler: EventHandler = {
}
const { sessionId, cwd, filePath, edits } = input;
const platformSource = normalizePlatformSource(input.platform);
if (!filePath) {
throw new Error('fileEditHandler requires filePath');
@@ -42,6 +44,7 @@ export const fileEditHandler: EventHandler = {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
platformSource,
tool_name: 'write_file',
tool_input: { filePath, edits },
tool_response: { success: true },

View File

@@ -11,6 +11,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { isProjectExcluded } from '../../utils/project-filter.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
export const observationHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -22,6 +23,7 @@ export const observationHandler: EventHandler = {
}
const { sessionId, cwd, toolName, toolInput, toolResponse } = input;
const platformSource = normalizePlatformSource(input.platform);
if (!toolName) {
// No tool name provided - skip observation gracefully
@@ -51,6 +53,7 @@ export const observationHandler: EventHandler = {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
platformSource,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,

View File

@@ -12,6 +12,7 @@
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
export const sessionCompleteHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -23,6 +24,7 @@ export const sessionCompleteHandler: EventHandler = {
}
const { sessionId } = input;
const platformSource = normalizePlatformSource(input.platform);
if (!sessionId) {
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
@@ -39,7 +41,8 @@ export const sessionCompleteHandler: EventHandler = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId
contentSessionId: sessionId,
platformSource
})
});

View File

@@ -12,6 +12,7 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { isProjectExcluded } from '../../utils/project-filter.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
export const sessionInitHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -42,6 +43,7 @@ export const sessionInitHandler: EventHandler = {
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
const project = getProjectName(cwd);
const platformSource = normalizePlatformSource(input.platform);
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
@@ -52,7 +54,8 @@ export const sessionInitHandler: EventHandler = {
body: JSON.stringify({
contentSessionId: sessionId,
project,
prompt
prompt,
platformSource
})
});

View File

@@ -27,7 +27,8 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { workerHttpRequest } from '../shared/worker-utils.js';
import { getWorkerPort, workerHttpRequest } from '../shared/worker-utils.js';
import { ensureWorkerStarted } from '../services/worker-service.js';
import { searchCodebase, formatSearchResults } from '../services/smart-file-read/search.js';
import { parseFile, formatFoldedView, unfoldSymbol } from '../services/smart-file-read/parser.js';
import { readFile } from 'node:fs/promises';
@@ -144,6 +145,26 @@ async function verifyWorkerConnection(): Promise<boolean> {
}
}
/**
* Ensure Worker is available for Codex and other MCP-only clients.
* Claude hooks already start the worker; this path makes Codex turnkey.
*/
async function ensureWorkerConnection(): Promise<boolean> {
if (await verifyWorkerConnection()) {
return true;
}
logger.warn('SYSTEM', 'Worker not available, attempting auto-start for MCP client');
try {
const port = getWorkerPort();
return await ensureWorkerStarted(port);
} catch (error) {
logger.error('SYSTEM', 'Worker auto-start failed', undefined, error as Error);
return false;
}
}
/**
* Tool definitions with HTTP-based handlers
* Minimal descriptions - use help() tool with operation parameter for detailed docs
@@ -392,6 +413,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
// Prevents orphaned MCP server processes when Claude Code exits unexpectedly
const HEARTBEAT_INTERVAL_MS = 30_000;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let isCleaningUp = false;
function handleStdioClosed() {
cleanup('stdio-closed');
}
function handleStdioError(error: Error) {
logger.warn('SYSTEM', 'MCP stdio stream errored, shutting down', {
message: error.message
});
cleanup('stdio-error');
}
function attachStdioLifecycle() {
process.stdin.on('end', handleStdioClosed);
process.stdin.on('close', handleStdioClosed);
process.stdin.on('error', handleStdioError);
}
function detachStdioLifecycle() {
process.stdin.off('end', handleStdioClosed);
process.stdin.off('close', handleStdioClosed);
process.stdin.off('error', handleStdioError);
}
function startParentHeartbeat() {
// ppid-based orphan detection only works on Unix
@@ -414,9 +459,13 @@ function startParentHeartbeat() {
// Cleanup function — synchronous to ensure consistent behavior whether called
// from signal handlers, heartbeat interval, or awaited in async context
function cleanup() {
function cleanup(reason: string = 'shutdown') {
if (isCleaningUp) return;
isCleaningUp = true;
if (heartbeatTimer) clearInterval(heartbeatTimer);
logger.info('SYSTEM', 'MCP server shutting down');
detachStdioLifecycle();
logger.info('SYSTEM', 'MCP server shutting down', { reason });
process.exit(0);
}
@@ -428,6 +477,7 @@ process.on('SIGINT', cleanup);
async function main() {
// Start the MCP server
const transport = new StdioServerTransport();
attachStdioLifecycle();
await server.connect(transport);
logger.info('SYSTEM', 'Claude-mem search server started');
@@ -436,7 +486,7 @@ async function main() {
// Check Worker availability in background
setTimeout(async () => {
const workerAvailable = await verifyWorkerConnection();
const workerAvailable = await ensureWorkerConnection();
if (!workerAvailable) {
logger.error('SYSTEM', 'Worker not available', undefined, {});
logger.error('SYSTEM', 'Tools will fail until Worker is started');

View File

@@ -130,6 +130,7 @@ export async function generateContext(
const config = loadContextConfig();
const cwd = input?.cwd ?? process.cwd();
const project = getProjectName(cwd);
const platformSource = input?.platform_source;
// Use provided projects array (for worktree support) or fall back to single project
const projects = input?.projects || [project];
@@ -149,11 +150,11 @@ export async function generateContext(
try {
// Query data for all projects (supports worktree: parent + worktree combined)
const observations = projects.length > 1
? queryObservationsMulti(db, projects, config)
: queryObservations(db, project, config);
? queryObservationsMulti(db, projects, config, platformSource)
: queryObservations(db, project, config, platformSource);
const summaries = projects.length > 1
? querySummariesMulti(db, projects, config)
: querySummaries(db, project, config);
? querySummariesMulti(db, projects, config, platformSource)
: querySummaries(db, project, config, platformSource);
// Handle empty state
if (observations.length === 0 && summaries.length === 0) {

View File

@@ -26,7 +26,8 @@ import { SUMMARY_LOOKAHEAD } from './types.js';
export function queryObservations(
db: SessionStore,
project: string,
config: ContextConfig
config: ContextConfig,
platformSource?: string
): Observation[] {
const typeArray = Array.from(config.observationTypes);
const typePlaceholders = typeArray.map(() => '?').join(',');
@@ -35,19 +36,38 @@ export function queryObservations(
return db.db.prepare(`
SELECT
id, memory_session_id, type, title, subtitle, narrative,
facts, concepts, files_read, files_modified, discovery_tokens,
created_at, created_at_epoch
FROM observations
WHERE project = ?
o.id,
o.memory_session_id,
COALESCE(s.platform_source, 'claude') as platform_source,
o.type,
o.title,
o.subtitle,
o.narrative,
o.facts,
o.concepts,
o.files_read,
o.files_modified,
o.discovery_tokens,
o.created_at,
o.created_at_epoch
FROM observations o
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
WHERE o.project = ?
AND type IN (${typePlaceholders})
AND EXISTS (
SELECT 1 FROM json_each(concepts)
SELECT 1 FROM json_each(o.concepts)
WHERE value IN (${conceptPlaceholders})
)
ORDER BY created_at_epoch DESC
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
ORDER BY o.created_at_epoch DESC
LIMIT ?
`).all(project, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
`).all(
project,
...typeArray,
...conceptArray,
...(platformSource ? [platformSource] : []),
config.totalObservationCount
) as Observation[];
}
/**
@@ -56,15 +76,30 @@ export function queryObservations(
export function querySummaries(
db: SessionStore,
project: string,
config: ContextConfig
config: ContextConfig,
platformSource?: string
): SessionSummary[] {
return db.db.prepare(`
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
FROM session_summaries
WHERE project = ?
ORDER BY created_at_epoch DESC
SELECT
ss.id,
ss.memory_session_id,
COALESCE(s.platform_source, 'claude') as platform_source,
ss.request,
ss.investigated,
ss.learned,
ss.completed,
ss.next_steps,
ss.created_at,
ss.created_at_epoch
FROM session_summaries ss
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
WHERE ss.project = ?
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
ORDER BY ss.created_at_epoch DESC
LIMIT ?
`).all(project, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
`).all(
...[project, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD]
) as SessionSummary[];
}
/**
@@ -76,7 +111,8 @@ export function querySummaries(
export function queryObservationsMulti(
db: SessionStore,
projects: string[],
config: ContextConfig
config: ContextConfig,
platformSource?: string
): Observation[] {
const typeArray = Array.from(config.observationTypes);
const typePlaceholders = typeArray.map(() => '?').join(',');
@@ -88,19 +124,39 @@ export function queryObservationsMulti(
return db.db.prepare(`
SELECT
id, memory_session_id, type, title, subtitle, narrative,
facts, concepts, files_read, files_modified, discovery_tokens,
created_at, created_at_epoch, project
FROM observations
WHERE project IN (${projectPlaceholders})
o.id,
o.memory_session_id,
COALESCE(s.platform_source, 'claude') as platform_source,
o.type,
o.title,
o.subtitle,
o.narrative,
o.facts,
o.concepts,
o.files_read,
o.files_modified,
o.discovery_tokens,
o.created_at,
o.created_at_epoch,
o.project
FROM observations o
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
WHERE o.project IN (${projectPlaceholders})
AND type IN (${typePlaceholders})
AND EXISTS (
SELECT 1 FROM json_each(concepts)
SELECT 1 FROM json_each(o.concepts)
WHERE value IN (${conceptPlaceholders})
)
ORDER BY created_at_epoch DESC
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
ORDER BY o.created_at_epoch DESC
LIMIT ?
`).all(...projects, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
`).all(
...projects,
...typeArray,
...conceptArray,
...(platformSource ? [platformSource] : []),
config.totalObservationCount
) as Observation[];
}
/**
@@ -112,18 +168,32 @@ export function queryObservationsMulti(
export function querySummariesMulti(
db: SessionStore,
projects: string[],
config: ContextConfig
config: ContextConfig,
platformSource?: string
): SessionSummary[] {
// Build IN clause for projects
const projectPlaceholders = projects.map(() => '?').join(',');
return db.db.prepare(`
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch, project
FROM session_summaries
WHERE project IN (${projectPlaceholders})
ORDER BY created_at_epoch DESC
SELECT
ss.id,
ss.memory_session_id,
COALESCE(s.platform_source, 'claude') as platform_source,
ss.request,
ss.investigated,
ss.learned,
ss.completed,
ss.next_steps,
ss.created_at,
ss.created_at_epoch,
ss.project
FROM session_summaries ss
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
WHERE ss.project IN (${projectPlaceholders})
${platformSource ? "AND COALESCE(s.platform_source, 'claude') = ?" : ''}
ORDER BY ss.created_at_epoch DESC
LIMIT ?
`).all(...projects, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
`).all(...projects, ...(platformSource ? [platformSource] : []), config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
}
/**

View File

@@ -15,6 +15,7 @@ export interface ContextInput {
projects?: string[];
/** When true, return ALL observations with no limit */
full?: boolean;
platform_source?: string;
[key: string]: any;
}
@@ -49,6 +50,7 @@ export interface ContextConfig {
export interface Observation {
id: number;
memory_session_id: string;
platform_source?: string;
type: string;
title: string | null;
subtitle: string | null;
@@ -70,6 +72,7 @@ export interface Observation {
export interface SessionSummary {
id: number;
memory_session_id: string;
platform_source?: string;
request: string | null;
investigated: string | null;
learned: string | null;

View File

@@ -3,6 +3,7 @@ import { TableNameRow } from '../../types/database.js';
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
import { logger } from '../../utils/logger.js';
import { isDirectChild } from '../../shared/path-utils.js';
import { AppError } from '../server/ErrorHandler.js';
import {
ObservationSearchResult,
SessionSummarySearchResult,
@@ -22,6 +23,8 @@ import {
export class SessionSearch {
private db: Database;
private static readonly MISSING_SEARCH_INPUT_MESSAGE = 'Either query or filters required for search';
constructor(dbPath?: string) {
if (!dbPath) {
ensureDir(DATA_DIR);
@@ -280,7 +283,7 @@ export class SessionSearch {
if (!query) {
const filterClause = this.buildFilterClause(filters, params, 'o');
if (!filterClause) {
throw new Error('Either query or filters required for search');
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
}
const orderClause = this.buildOrderClause(orderBy, false);
@@ -317,7 +320,7 @@ export class SessionSearch {
delete filterOptions.type;
const filterClause = this.buildFilterClause(filterOptions, params, 's');
if (!filterClause) {
throw new Error('Either query or filters required for search');
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
}
const orderClause = orderBy === 'date_asc'
@@ -551,7 +554,7 @@ export class SessionSearch {
// FILTER-ONLY PATH: When no query text, query user_prompts table directly
if (!query) {
if (baseConditions.length === 0) {
throw new Error('Either query or filters required for search');
throw new AppError(SessionSearch.MISSING_SEARCH_INPUT_MESSAGE, 400, 'INVALID_SEARCH_REQUEST');
}
const whereClause = `WHERE ${baseConditions.join(' AND ')}`;

View File

@@ -15,6 +15,18 @@ import {
import type { PendingMessageStore } from './PendingMessageStore.js';
import { computeObservationContentHash, findDuplicateObservation } from './observations/store.js';
import { parseFileList } from './observations/files.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 ? normalizePlatformSource(platformSource) : undefined
};
}
>>>>>>> pr-1472
/**
* Session data store for SDK sessions, observations, and summaries
@@ -52,6 +64,7 @@ export class SessionStore {
this.addOnUpdateCascadeToForeignKeys();
this.addObservationContentHashColumn();
this.addSessionCustomTitleColumn();
this.addSessionPlatformSourceColumn();
}
/**
@@ -79,6 +92,7 @@ export class SessionStore {
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
platform_source TEXT NOT NULL DEFAULT 'claude',
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
@@ -876,6 +890,36 @@ export class SessionStore {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(23, new Date().toISOString());
}
/**
* Add platform_source column to sdk_sessions for Claude/Codex isolation (migration 24)
*/
private addSessionPlatformSourceColumn(): void {
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasColumn = tableInfo.some(col => col.name === 'platform_source');
const indexInfo = this.db.query('PRAGMA index_list(sdk_sessions)').all() as IndexInfo[];
const hasIndex = indexInfo.some(index => index.name === 'idx_sdk_sessions_platform_source');
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(24) as SchemaVersion | undefined;
if (applied && hasColumn && hasIndex) return;
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 = ''
`);
if (!hasIndex) {
this.db.run('CREATE INDEX IF NOT EXISTS idx_sdk_sessions_platform_source ON sdk_sessions(platform_source)');
}
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
}
/**
* Update the memory session ID for a session
* Called by SDKAgent when it captures the session ID from the first SDK message
@@ -1013,14 +1057,26 @@ export class SessionStore {
subtitle: string | null;
text: string;
project: string;
platform_source: string;
prompt_number: number | null;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
SELECT
o.id,
o.type,
o.title,
o.subtitle,
o.text,
o.project,
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
o.prompt_number,
o.created_at,
o.created_at_epoch
FROM observations o
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
ORDER BY o.created_at_epoch DESC
LIMIT ?
`);
@@ -1041,16 +1097,30 @@ export class SessionStore {
files_edited: string | null;
notes: string | null;
project: string;
platform_source: string;
prompt_number: number | null;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
SELECT
ss.id,
ss.request,
ss.investigated,
ss.learned,
ss.completed,
ss.next_steps,
ss.files_read,
ss.files_edited,
ss.notes,
ss.project,
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
ss.prompt_number,
ss.created_at,
ss.created_at_epoch
FROM session_summaries ss
LEFT JOIN sdk_sessions s ON ss.memory_session_id = s.memory_session_id
ORDER BY ss.created_at_epoch DESC
LIMIT ?
`);
@@ -1064,6 +1134,7 @@ export class SessionStore {
id: number;
content_session_id: string;
project: string;
platform_source: string;
prompt_number: number;
prompt_text: string;
created_at: string;
@@ -1074,6 +1145,7 @@ export class SessionStore {
up.id,
up.content_session_id,
s.project,
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
up.prompt_number,
up.prompt_text,
up.created_at,
@@ -1090,18 +1162,74 @@ export class SessionStore {
/**
* Get all unique projects from the database (for web UI project filter)
*/
getAllProjects(): string[] {
const stmt = this.db.prepare(`
getAllProjects(platformSource?: string): string[] {
const normalizedPlatformSource = platformSource ? normalizePlatformSource(platformSource) : undefined;
let query = `
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`);
`;
const params: unknown[] = [];
const rows = stmt.all() as Array<{ project: string }>;
if (normalizedPlatformSource) {
query += ' AND COALESCE(platform_source, ?) = ?';
params.push(DEFAULT_PLATFORM_SOURCE, normalizedPlatformSource);
}
query += ' ORDER BY project ASC';
const rows = this.db.prepare(query).all(...params) as Array<{ project: string }>;
return rows.map(row => row.project);
}
getProjectCatalog(): {
projects: string[];
sources: string[];
projectsBySource: Record<string, string[]>;
} {
const rows = this.db.prepare(`
SELECT
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
project,
MAX(started_at_epoch) as latest_epoch
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
GROUP BY COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}'), project
ORDER BY latest_epoch DESC
`).all() as Array<{ platform_source: string; project: string; latest_epoch: number }>;
const projects: string[] = [];
const seenProjects = new Set<string>();
const projectsBySource: Record<string, string[]> = {};
for (const row of rows) {
const source = normalizePlatformSource(row.platform_source);
if (!projectsBySource[source]) {
projectsBySource[source] = [];
}
if (!projectsBySource[source].includes(row.project)) {
projectsBySource[source].push(row.project);
}
if (!seenProjects.has(row.project)) {
seenProjects.add(row.project);
projects.push(row.project);
}
}
const sources = sortPlatformSources(Object.keys(projectsBySource));
return {
projects,
sources,
projectsBySource: Object.fromEntries(
sources.map(source => [source, projectsBySource[source] || []])
)
};
}
/**
* Get latest user prompt with session info for a Claude session
* Used for syncing prompts to Chroma during session initialization
@@ -1111,6 +1239,7 @@ export class SessionStore {
content_session_id: string;
memory_session_id: string;
project: string;
platform_source: string;
prompt_number: number;
prompt_text: string;
created_at_epoch: number;
@@ -1119,7 +1248,8 @@ export class SessionStore {
SELECT
up.*,
s.memory_session_id,
s.project
s.project,
COALESCE(s.platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source
FROM user_prompts up
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
WHERE up.content_session_id = ?
@@ -1340,11 +1470,14 @@ export class SessionStore {
content_session_id: string;
memory_session_id: string | null;
project: string;
platform_source: string;
user_prompt: string;
custom_title: string | null;
} | null {
const stmt = this.db.prepare(`
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title
SELECT id, content_session_id, memory_session_id, project,
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
user_prompt, custom_title
FROM sdk_sessions
WHERE id = ?
LIMIT 1
@@ -1362,6 +1495,7 @@ export class SessionStore {
content_session_id: string;
memory_session_id: string;
project: string;
platform_source: string;
user_prompt: string;
custom_title: string | null;
started_at: string;
@@ -1374,7 +1508,9 @@ export class SessionStore {
const placeholders = memorySessionIds.map(() => '?').join(',');
const stmt = this.db.prepare(`
SELECT id, content_session_id, memory_session_id, project, user_prompt, custom_title,
SELECT id, content_session_id, memory_session_id, project,
COALESCE(platform_source, '${DEFAULT_PLATFORM_SOURCE}') as platform_source,
user_prompt, custom_title,
started_at, started_at_epoch, completed_at, completed_at_epoch, status
FROM sdk_sessions
WHERE memory_session_id IN (${placeholders})
@@ -1419,14 +1555,22 @@ export class SessionStore {
* Pure get-or-create: never modifies memory_session_id.
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
*/
createSDKSession(contentSessionId: string, project: string, userPrompt: string, customTitle?: string): number {
createSDKSession(
contentSessionId: string,
project: string,
userPrompt: string,
customTitle?: string,
platformSource?: string
): number {
const now = new Date();
const nowEpoch = now.getTime();
const resolved = resolveCreateSessionArgs(customTitle, platformSource);
const normalizedPlatformSource = resolved.platformSource ?? DEFAULT_PLATFORM_SOURCE;
// Session reuse: Return existing session ID if already created for this contentSessionId.
const existing = this.db.prepare(`
SELECT id FROM sdk_sessions WHERE content_session_id = ?
`).get(contentSessionId) as { id: number } | undefined;
SELECT id, platform_source FROM sdk_sessions WHERE content_session_id = ?
`).get(contentSessionId) as { id: number; platform_source: string | null } | undefined;
if (existing) {
// Backfill project if session was created by another hook with empty project
@@ -1437,11 +1581,29 @@ export class SessionStore {
`).run(project, contentSessionId);
}
// Backfill custom_title if provided and not yet set
if (customTitle) {
if (resolved.customTitle) {
this.db.prepare(`
UPDATE sdk_sessions SET custom_title = ?
WHERE content_session_id = ? AND custom_title IS NULL
`).run(customTitle, contentSessionId);
`).run(resolved.customTitle, contentSessionId);
}
if (resolved.platformSource) {
const storedPlatformSource = existing.platform_source?.trim()
? normalizePlatformSource(existing.platform_source)
: undefined;
if (!storedPlatformSource) {
this.db.prepare(`
UPDATE sdk_sessions SET platform_source = ?
WHERE content_session_id = ?
AND COALESCE(platform_source, '') = ''
`).run(resolved.platformSource, contentSessionId);
} else if (storedPlatformSource !== resolved.platformSource) {
throw new Error(
`Platform source conflict for session ${contentSessionId}: existing=${storedPlatformSource}, received=${resolved.platformSource}`
);
}
}
return existing.id;
}
@@ -1452,9 +1614,9 @@ export class SessionStore {
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
this.db.prepare(`
INSERT INTO sdk_sessions
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
(content_session_id, memory_session_id, project, platform_source, user_prompt, custom_title, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, normalizedPlatformSource, userPrompt, resolved.customTitle || null, now.toISOString(), nowEpoch);
// Return new ID
const row = this.db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
@@ -2243,9 +2405,9 @@ export class SessionStore {
// Create new manual session
const now = new Date();
this.db.prepare(`
INSERT INTO sdk_sessions (memory_session_id, content_session_id, project, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, ?, 'active')
`).run(memorySessionId, contentSessionId, project, now.toISOString(), now.getTime());
INSERT INTO sdk_sessions (memory_session_id, content_session_id, project, platform_source, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, ?, ?, 'active')
`).run(memorySessionId, contentSessionId, project, DEFAULT_PLATFORM_SOURCE, now.toISOString(), now.getTime());
logger.info('SESSION', 'Created manual session', { memorySessionId, project });
@@ -2271,6 +2433,7 @@ export class SessionStore {
content_session_id: string;
memory_session_id: string;
project: string;
platform_source?: string;
user_prompt: string;
started_at: string;
started_at_epoch: number;
@@ -2289,15 +2452,16 @@ export class SessionStore {
const stmt = this.db.prepare(`
INSERT INTO sdk_sessions (
content_session_id, memory_session_id, project, user_prompt,
content_session_id, memory_session_id, project, platform_source, user_prompt,
started_at, started_at_epoch, completed_at, completed_at_epoch, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
session.content_session_id,
session.memory_session_id,
session.project,
normalizePlatformSource(session.platform_source),
session.user_prompt,
session.started_at,
session.started_at_epoch,

View File

@@ -6,6 +6,7 @@ import {
TableNameRow,
SchemaVersion
} from '../../../types/database.js';
import { DEFAULT_PLATFORM_SOURCE } from '../../../shared/platform-source.js';
/**
* MigrationRunner handles all database schema migrations
@@ -34,7 +35,8 @@ export class MigrationRunner {
this.addOnUpdateCascadeToForeignKeys();
this.addObservationContentHashColumn();
this.addSessionCustomTitleColumn();
this.createObservationFeedbackTable();
this.createObservationFeedbackTable();
this.addSessionPlatformSourceColumn();
}
/**
@@ -62,6 +64,7 @@ export class MigrationRunner {
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
platform_source TEXT NOT NULL DEFAULT 'claude',
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
@@ -654,10 +657,8 @@ export class MigrationRunner {
this.db.run('BEGIN TRANSACTION');
try {
// ==========================================
// 1. Recreate observations table
// ==========================================
// =================================== // 1. Recreate observations table
// ===================================
// Drop FTS triggers first (they reference the observations table)
this.db.run('DROP TRIGGER IF EXISTS observations_ai');
this.db.run('DROP TRIGGER IF EXISTS observations_ad');
@@ -730,10 +731,8 @@ export class MigrationRunner {
`);
}
// ==========================================
// 2. Recreate session_summaries table
// ==========================================
// =================================== // 2. Recreate session_summaries table
// ===================================
// Clean up leftover temp table from a previously-crashed run
this.db.run('DROP TABLE IF EXISTS session_summaries_new');
@@ -890,5 +889,32 @@ export class MigrationRunner {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
logger.debug('DB', 'Created observation_feedback table for usage tracking');
* Add platform_source column to sdk_sessions for Claude/Codex isolation (migration 24)
*/
private addSessionPlatformSourceColumn(): void {
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasColumn = tableInfo.some(col => col.name === 'platform_source');
const indexInfo = this.db.query('PRAGMA index_list(sdk_sessions)').all() as IndexInfo[];
const hasIndex = indexInfo.some(index => index.name === 'idx_sdk_sessions_platform_source');
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(24) as SchemaVersion | undefined;
if (applied && hasColumn && hasIndex) return;
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 = ''
`);
if (!hasIndex) {
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());
}
}

View File

@@ -5,6 +5,17 @@
import type { Database } from 'bun:sqlite';
import { logger } from '../../../utils/logger.js';
import { DEFAULT_PLATFORM_SOURCE, normalizePlatformSource } from '../../../shared/platform-source.js';
function resolveCreateSessionArgs(
customTitle?: string,
platformSource?: string
): { customTitle?: string; platformSource?: string } {
return {
customTitle,
platformSource: platformSource ? normalizePlatformSource(platformSource) : undefined
};
}
/**
* Create a new SDK session (idempotent - returns existing session ID if already exists)
@@ -22,15 +33,18 @@ export function createSDKSession(
contentSessionId: string,
project: string,
userPrompt: string,
customTitle?: string
customTitle?: string,
platformSource?: string
): number {
const now = new Date();
const nowEpoch = now.getTime();
const resolved = resolveCreateSessionArgs(customTitle, platformSource);
const normalizedPlatformSource = resolved.platformSource ?? DEFAULT_PLATFORM_SOURCE;
// Check for existing session
const existing = db.prepare(`
SELECT id FROM sdk_sessions WHERE content_session_id = ?
`).get(contentSessionId) as { id: number } | undefined;
SELECT id, platform_source FROM sdk_sessions WHERE content_session_id = ?
`).get(contentSessionId) as { id: number; platform_source: string | null } | undefined;
if (existing) {
// Backfill project if session was created by another hook with empty project
@@ -41,11 +55,29 @@ export function createSDKSession(
`).run(project, contentSessionId);
}
// Backfill custom_title if provided and not yet set
if (customTitle) {
if (resolved.customTitle) {
db.prepare(`
UPDATE sdk_sessions SET custom_title = ?
WHERE content_session_id = ? AND custom_title IS NULL
`).run(customTitle, contentSessionId);
`).run(resolved.customTitle, contentSessionId);
}
if (resolved.platformSource) {
const storedPlatformSource = existing.platform_source?.trim()
? normalizePlatformSource(existing.platform_source)
: undefined;
if (!storedPlatformSource) {
db.prepare(`
UPDATE sdk_sessions SET platform_source = ?
WHERE content_session_id = ?
AND COALESCE(platform_source, '') = ''
`).run(resolved.platformSource, contentSessionId);
} else if (storedPlatformSource !== resolved.platformSource) {
throw new Error(
`Platform source conflict for session ${contentSessionId}: existing=${storedPlatformSource}, received=${resolved.platformSource}`
);
}
}
return existing.id;
}
@@ -56,9 +88,9 @@ export function createSDKSession(
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
db.prepare(`
INSERT INTO sdk_sessions
(content_session_id, memory_session_id, project, user_prompt, custom_title, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, userPrompt, customTitle || null, now.toISOString(), nowEpoch);
(content_session_id, memory_session_id, project, platform_source, user_prompt, custom_title, started_at, started_at_epoch, status)
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, 'active')
`).run(contentSessionId, project, normalizedPlatformSource, userPrompt, resolved.customTitle || null, now.toISOString(), nowEpoch);
// Return new ID
const row = db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')

View File

@@ -17,7 +17,9 @@ import type {
*/
export function getSessionById(db: Database, id: number): SessionBasic | null {
const stmt = 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, 'claude') as platform_source,
user_prompt, custom_title
FROM sdk_sessions
WHERE id = ?
LIMIT 1
@@ -38,7 +40,9 @@ export function getSdkSessionsBySessionIds(
const placeholders = memorySessionIds.map(() => '?').join(',');
const stmt = 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, 'claude') as platform_source,
user_prompt, custom_title,
started_at, started_at_epoch, completed_at, completed_at_epoch, status
FROM sdk_sessions
WHERE memory_session_id IN (${placeholders})

View File

@@ -12,6 +12,7 @@ export interface SessionBasic {
content_session_id: string;
memory_session_id: string | null;
project: string;
platform_source: string;
user_prompt: string;
custom_title: string | null;
}
@@ -24,6 +25,7 @@ export interface SessionFull {
content_session_id: string;
memory_session_id: string;
project: string;
platform_source: string;
user_prompt: string;
custom_title: string | null;
started_at: string;

View File

@@ -9,9 +9,11 @@ import { writeAgentsMd } from '../../utils/agents-md-utils.js';
import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js';
import { expandHomePath } from './config.js';
import type { TranscriptSchema, WatchTarget, SchemaEvent } from './types.js';
import { normalizePlatformSource } from '../../shared/platform-source.js';
interface SessionState {
sessionId: string;
platformSource: string;
cwd?: string;
project?: string;
lastUserMessage?: string;
@@ -51,6 +53,7 @@ export class TranscriptEventProcessor {
if (!session) {
session = {
sessionId,
platformSource: normalizePlatformSource(watch.name),
pendingTools: new Map()
};
this.sessions.set(key, session);
@@ -181,7 +184,7 @@ export class TranscriptEventProcessor {
sessionId: session.sessionId,
cwd,
prompt,
platform: 'transcript'
platform: session.platformSource
});
}
@@ -250,7 +253,7 @@ export class TranscriptEventProcessor {
toolName,
toolInput: this.maybeParseJson(fields.toolInput),
toolResponse: this.maybeParseJson(fields.toolResponse),
platform: 'transcript'
platform: session.platformSource
});
}
@@ -263,7 +266,7 @@ export class TranscriptEventProcessor {
cwd: session.cwd ?? process.cwd(),
filePath,
edits: Array.isArray(fields.edits) ? fields.edits : undefined,
platform: 'transcript'
platform: session.platformSource
});
}
@@ -305,7 +308,7 @@ export class TranscriptEventProcessor {
await sessionCompleteHandler.execute({
sessionId: session.sessionId,
cwd: session.cwd ?? process.cwd(),
platform: 'transcript'
platform: session.platformSource
});
await this.updateContext(session, watch);
session.pendingTools.clear();
@@ -325,7 +328,8 @@ export class TranscriptEventProcessor {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: session.sessionId,
last_assistant_message: lastAssistantMessage
last_assistant_message: lastAssistantMessage,
platformSource: session.platformSource
})
});
} catch (error) {
@@ -350,7 +354,7 @@ export class TranscriptEventProcessor {
try {
const response = await workerHttpRequest(
`/api/context/inject?projects=${encodeURIComponent(projectsParam)}`
`/api/context/inject?projects=${encodeURIComponent(projectsParam)}&platformSource=${encodeURIComponent(session.platformSource)}`
);
if (!response.ok) return;

View File

@@ -117,15 +117,15 @@ export class TranscriptWatcher {
const files = this.resolveWatchFiles(resolvedPath);
for (const filePath of files) {
await this.addTailer(filePath, watch, schema);
await this.addTailer(filePath, watch, schema, true);
}
const rescanIntervalMs = watch.rescanIntervalMs ?? 5000;
const timer = setInterval(async () => {
const timer = setInterval(async () => {
const newFiles = this.resolveWatchFiles(resolvedPath);
for (const filePath of newFiles) {
if (!this.tailers.has(filePath)) {
await this.addTailer(filePath, watch, schema);
await this.addTailer(filePath, watch, schema, false);
}
}
}, rescanIntervalMs);
@@ -164,13 +164,20 @@ export class TranscriptWatcher {
return /[*?[\]{}()]/.test(inputPath);
}
private async addTailer(filePath: string, watch: WatchTarget, schema: TranscriptSchema): Promise<void> {
private async addTailer(
filePath: string,
watch: WatchTarget,
schema: TranscriptSchema,
initialDiscovery: boolean
): Promise<void> {
if (this.tailers.has(filePath)) return;
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
let offset = this.state.offsets[filePath] ?? 0;
if (offset === 0 && watch.startAtEnd) {
// `startAtEnd` is useful on worker startup to avoid replaying the full backlog,
// but new transcript files must be read from byte 0 or we lose session_meta/user_message.
if (offset === 0 && watch.startAtEnd && initialDiscovery) {
try {
offset = statSync(filePath).size;
} catch {

View File

@@ -116,6 +116,8 @@ import { SearchManager } from './worker/SearchManager.js';
import { FormattingService } from './worker/FormattingService.js';
import { TimelineService } from './worker/TimelineService.js';
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './transcripts/config.js';
import { TranscriptWatcher } from './transcripts/watcher.js';
// HTTP route handlers
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
@@ -184,6 +186,9 @@ export class WorkerService {
// Chroma MCP manager (lazy - connects on first use)
private chromaMcpManager: ChromaMcpManager | null = null;
// Transcript watcher for Codex and other transcript-based clients
private transcriptWatcher: TranscriptWatcher | null = null;
// Initialization tracking
private initializationComplete: Promise<void>;
private resolveInitialization!: () => void;
@@ -429,21 +434,7 @@ export class WorkerService {
this.resolveInitialization();
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
// Auto-start transcript watchers if configured
if (existsSync(TRANSCRIPT_CONFIG_PATH)) {
try {
const transcriptConfig = loadTranscriptWatchConfig(TRANSCRIPT_CONFIG_PATH);
if (transcriptConfig.watches.length > 0) {
const transcriptStatePath = expandHomePath(transcriptConfig.stateFile ?? '~/.claude-mem/transcript-watch-state.json');
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, transcriptStatePath);
await this.transcriptWatcher.start();
logger.info('SYSTEM', `Transcript watcher started with ${transcriptConfig.watches.length} watch target(s)`);
}
} catch (transcriptError) {
logger.warn('SYSTEM', 'Failed to start transcript watcher (non-fatal)', {}, transcriptError as Error);
// Non-fatal — worker continues without transcript watching
}
}
await this.startTranscriptWatcher(settings);
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
if (this.chromaMcpManager) {
@@ -454,8 +445,13 @@ export class WorkerService {
});
}
// Connect to MCP server
// Mark MCP as externally ready once the bundled stdio server binary exists.
// Codex/Claude Desktop connect to this binary directly; the loopback client
// below is only a best-effort self-check and should not mark health false.
const mcpServerPath = path.join(__dirname, 'mcp-server.cjs');
this.mcpReady = existsSync(mcpServerPath);
// Best-effort loopback MCP self-check
getSupervisor().assertCanSpawn('mcp server');
const transport = new StdioClientTransport({
command: 'node',
@@ -477,7 +473,7 @@ export class WorkerService {
await Promise.race([mcpConnectionPromise, timeoutPromise]);
} catch (connectionError) {
clearTimeout(timeoutId!);
logger.warn('WORKER', 'MCP server connection failed, cleaning up subprocess', {
logger.warn('WORKER', 'MCP loopback self-check failed, cleaning up subprocess', {
error: connectionError instanceof Error ? connectionError.message : String(connectionError)
});
try {
@@ -485,7 +481,10 @@ export class WorkerService {
} catch {
// Best effort: the supervisor handles later process cleanup for survivors.
}
throw connectionError;
logger.info('WORKER', 'Bundled MCP server remains available for external stdio clients', {
path: mcpServerPath
});
return;
}
clearTimeout(timeoutId!);
@@ -500,8 +499,7 @@ export class WorkerService {
getSupervisor().unregisterProcess('mcp-server');
});
}
this.mcpReady = true;
logger.success('WORKER', 'MCP server connected');
logger.success('WORKER', 'MCP loopback self-check connected');
// Start orphan reaper to clean up zombie processes (Issue #737)
this.stopOrphanReaper = startOrphanReaper(() => {
@@ -543,6 +541,48 @@ export class WorkerService {
}
}
/**
* Start transcript watcher for Codex and other transcript-based clients.
* This is intentionally non-fatal so Claude hooks remain usable even if
* transcript ingestion is misconfigured.
*/
private async startTranscriptWatcher(settings: ReturnType<typeof SettingsDefaultsManager.loadFromFile>): Promise<void> {
const transcriptsEnabled = settings.CLAUDE_MEM_TRANSCRIPTS_ENABLED !== 'false';
if (!transcriptsEnabled) {
logger.info('TRANSCRIPT', 'Transcript watcher disabled via CLAUDE_MEM_TRANSCRIPTS_ENABLED=false');
return;
}
const configPath = settings.CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH || DEFAULT_CONFIG_PATH;
const resolvedConfigPath = expandHomePath(configPath);
try {
if (!existsSync(resolvedConfigPath)) {
writeSampleConfig(configPath);
logger.info('TRANSCRIPT', 'Created default transcript watch config', {
configPath: resolvedConfigPath
});
}
const transcriptConfig = loadTranscriptWatchConfig(configPath);
const statePath = expandHomePath(transcriptConfig.stateFile ?? DEFAULT_STATE_PATH);
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, statePath);
await this.transcriptWatcher.start();
logger.info('TRANSCRIPT', 'Transcript watcher started', {
configPath: resolvedConfigPath,
statePath,
watches: transcriptConfig.watches.length
});
} catch (error) {
this.transcriptWatcher?.stop();
this.transcriptWatcher = null;
logger.error('TRANSCRIPT', 'Failed to start transcript watcher (continuing without Codex ingestion)', {
configPath: resolvedConfigPath
}, error as Error);
}
}
/**
* Get the appropriate agent based on provider settings.
* Same logic as SessionRoutes.getActiveAgent() for consistency.
@@ -934,6 +974,12 @@ export class WorkerService {
* Shutdown the worker service
*/
async shutdown(): Promise<void> {
if (this.transcriptWatcher) {
this.transcriptWatcher.stop();
this.transcriptWatcher = null;
logger.info('TRANSCRIPT', 'Transcript watcher stopped');
}
// Stop orphan reaper before shutdown (Issue #737)
if (this.stopOrphanReaper) {
this.stopOrphanReaper();
@@ -995,7 +1041,7 @@ export class WorkerService {
* @param port - The TCP port (used for port-in-use checks and daemon spawn)
* @returns true if worker is healthy (existing or newly started), false on failure
*/
async function ensureWorkerStarted(port: number): Promise<boolean> {
export async function ensureWorkerStarted(port: number): Promise<boolean> {
// Clean stale PID file first (cheap: 1 fs read + 1 signal-0 check)
const pidFileStatus = cleanStalePidFile();
if (pidFileStatus === 'alive') {

View File

@@ -22,6 +22,7 @@ export interface ActiveSession {
contentSessionId: string; // User's Claude Code session being observed
memorySessionId: string | null; // Memory agent's session ID for resume
project: string;
platformSource: string;
userPrompt: string;
pendingMessages: PendingMessage[]; // Deprecated: now using persistent store, kept for compatibility
abortController: AbortController;
@@ -99,6 +100,7 @@ export interface PaginationParams {
offset: number;
limit: number;
project?: string;
platformSource?: string;
}
// ============================================================================
@@ -119,6 +121,7 @@ export interface Observation {
id: number;
memory_session_id: string; // Renamed from sdk_session_id
project: string;
platform_source: string;
type: string;
title: string;
subtitle: string | null;
@@ -137,6 +140,7 @@ export interface Summary {
id: number;
session_id: string; // content_session_id (from JOIN)
project: string;
platform_source: string;
request: string | null;
investigated: string | null;
learned: string | null;
@@ -151,6 +155,7 @@ export interface UserPrompt {
id: number;
content_session_id: string; // Renamed from claude_session_id
project: string; // From JOIN with sdk_sessions
platform_source: string;
prompt_number: number;
prompt_text: string;
created_at: string;
@@ -161,6 +166,7 @@ export interface DBSession {
id: number;
content_session_id: string; // Renamed from claude_session_id
project: string;
platform_source: string;
user_prompt: string;
memory_session_id: string | null; // Renamed from sdk_session_id
status: 'active' | 'completed' | 'failed';

View File

@@ -71,14 +71,54 @@ export class PaginationHelper {
/**
* Get paginated observations
*/
getObservations(offset: number, limit: number, project?: string): PaginatedResult<Observation> {
const result = this.paginate<Observation>(
'observations',
'id, memory_session_id, project, type, title, subtitle, narrative, text, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch',
getObservations(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<Observation> {
const db = this.dbManager.getSessionStore().db;
let query = `
SELECT
o.id,
o.memory_session_id,
o.project,
COALESCE(s.platform_source, 'claude') as platform_source,
o.type,
o.title,
o.subtitle,
o.narrative,
o.text,
o.facts,
o.concepts,
o.files_read,
o.files_modified,
o.prompt_number,
o.created_at,
o.created_at_epoch
FROM observations o
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
`;
const params: unknown[] = [];
const conditions: string[] = [];
if (project) {
conditions.push('o.project = ?');
params.push(project);
}
if (platformSource) {
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
params.push(platformSource);
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`;
}
query += ' ORDER BY o.created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit + 1, offset);
const results = db.prepare(query).all(...params) as Observation[];
const result: PaginatedResult<Observation> = {
items: results.slice(0, limit),
hasMore: results.length > limit,
offset,
limit,
project
);
limit
};
// Strip project paths from file paths before returning
return {
@@ -90,13 +130,14 @@ export class PaginationHelper {
/**
* Get paginated summaries
*/
getSummaries(offset: number, limit: number, project?: string): PaginatedResult<Summary> {
getSummaries(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<Summary> {
const db = this.dbManager.getSessionStore().db;
let query = `
SELECT
ss.id,
s.content_session_id as session_id,
COALESCE(s.platform_source, 'claude') as platform_source,
ss.request,
ss.investigated,
ss.learned,
@@ -110,11 +151,22 @@ export class PaginationHelper {
`;
const params: any[] = [];
const conditions: string[] = [];
if (project) {
query += ' WHERE ss.project = ?';
conditions.push('ss.project = ?');
params.push(project);
}
if (platformSource) {
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
params.push(platformSource);
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`;
}
query += ' ORDER BY ss.created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit + 1, offset);
@@ -132,21 +184,40 @@ export class PaginationHelper {
/**
* Get paginated user prompts
*/
getPrompts(offset: number, limit: number, project?: string): PaginatedResult<UserPrompt> {
getPrompts(offset: number, limit: number, project?: string, platformSource?: string): PaginatedResult<UserPrompt> {
const db = this.dbManager.getSessionStore().db;
let query = `
SELECT up.id, up.content_session_id, s.project, up.prompt_number, up.prompt_text, up.created_at, up.created_at_epoch
SELECT
up.id,
up.content_session_id,
s.project,
COALESCE(s.platform_source, 'claude') as platform_source,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
JOIN sdk_sessions s ON up.content_session_id = s.content_session_id
`;
const params: any[] = [];
const conditions: string[] = [];
if (project) {
query += ' WHERE s.project = ?';
conditions.push('s.project = ?');
params.push(project);
}
if (platformSource) {
conditions.push(`COALESCE(s.platform_source, 'claude') = ?`);
params.push(platformSource);
}
if (conditions.length > 0) {
query += ` WHERE ${conditions.join(' AND ')}`;
}
query += ' ORDER BY up.created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit + 1, offset);

View File

@@ -77,6 +77,9 @@ export class SessionManager {
});
session.project = dbSession.project;
}
if (dbSession.platform_source && dbSession.platform_source !== session.platformSource) {
session.platformSource = dbSession.platform_source;
}
// Update userPrompt for continuation prompts
if (currentUserPrompt) {
@@ -144,6 +147,7 @@ export class SessionManager {
contentSessionId: dbSession.content_session_id,
memorySessionId: null, // Always start fresh - SDK will capture new ID
project: dbSession.project,
platformSource: dbSession.platform_source,
userPrompt,
pendingMessages: [],
abortController: new AbortController(),

View File

@@ -238,6 +238,7 @@ async function syncAndBroadcastObservations(
id: obsId,
memory_session_id: session.memorySessionId,
session_id: session.contentSessionId,
platform_source: session.platformSource,
type: obs.type,
title: obs.title,
subtitle: obs.subtitle,
@@ -327,6 +328,7 @@ async function syncAndBroadcastSummary(
broadcastSummary(worker, {
id: result.summaryId,
session_id: session.contentSessionId,
platform_source: session.platformSource,
request: summary!.request,
investigated: summary!.investigated,
learned: summary!.learned,

View File

@@ -33,6 +33,7 @@ export interface ObservationSSEPayload {
id: number;
memory_session_id: string | null;
session_id: string;
platform_source: string;
type: string;
title: string | null;
subtitle: string | null;
@@ -50,6 +51,7 @@ export interface ObservationSSEPayload {
export interface SummarySSEPayload {
id: number;
session_id: string;
platform_source: string;
request: string | null;
investigated: string | null;
learned: string | null;

View File

@@ -23,6 +23,7 @@ export class SessionEventBroadcaster {
id: number;
content_session_id: string;
project: string;
platform_source: string;
prompt_number: number;
prompt_text: string;
created_at_epoch: number;

View File

@@ -11,6 +11,7 @@
import { Request, Response } from 'express';
import { logger } from '../../../utils/logger.js';
import { AppError } from '../../server/ErrorHandler.js';
export abstract class BaseRouteHandler {
/**
@@ -78,9 +79,22 @@ export abstract class BaseRouteHandler {
* Checks headersSent to avoid "Cannot set headers after they are sent" errors
*/
protected handleError(res: Response, error: Error, context?: string): void {
// [APPROVED OVERRIDE]: Worker routes need centralized AppError translation so
// status/code/details stay consistent across every HTTP handler.
logger.failure('WORKER', context || 'Request failed', {}, error);
if (!res.headersSent) {
res.status(500).json({ error: error.message });
const statusCode = error instanceof AppError ? error.statusCode : 500;
const response: Record<string, unknown> = { error: error.message };
if (error instanceof AppError && error.code) {
response.code = error.code;
}
if (error instanceof AppError && error.details !== undefined) {
response.details = error.details;
}
res.status(statusCode).json(response);
}
}
}

View File

@@ -66,8 +66,8 @@ export class DataRoutes extends BaseRouteHandler {
* Get paginated observations
*/
private handleGetObservations = this.wrapHandler((req: Request, res: Response): void => {
const { offset, limit, project } = this.parsePaginationParams(req);
const result = this.paginationHelper.getObservations(offset, limit, project);
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
const result = this.paginationHelper.getObservations(offset, limit, project, platformSource);
res.json(result);
});
@@ -75,8 +75,8 @@ export class DataRoutes extends BaseRouteHandler {
* Get paginated summaries
*/
private handleGetSummaries = this.wrapHandler((req: Request, res: Response): void => {
const { offset, limit, project } = this.parsePaginationParams(req);
const result = this.paginationHelper.getSummaries(offset, limit, project);
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
const result = this.paginationHelper.getSummaries(offset, limit, project, platformSource);
res.json(result);
});
@@ -84,8 +84,8 @@ export class DataRoutes extends BaseRouteHandler {
* Get paginated user prompts
*/
private handleGetPrompts = this.wrapHandler((req: Request, res: Response): void => {
const { offset, limit, project } = this.parsePaginationParams(req);
const result = this.paginationHelper.getPrompts(offset, limit, project);
const { offset, limit, project, platformSource } = this.parsePaginationParams(req);
const result = this.paginationHelper.getPrompts(offset, limit, project, platformSource);
res.json(result);
});
@@ -256,19 +256,19 @@ export class DataRoutes extends BaseRouteHandler {
* GET /api/projects
*/
private handleGetProjects = this.wrapHandler((req: Request, res: Response): void => {
const db = this.dbManager.getSessionStore().db;
const store = this.dbManager.getSessionStore();
const platformSource = req.query.platformSource as string | undefined;
const rows = db.prepare(`
SELECT DISTINCT project
FROM observations
WHERE project IS NOT NULL
GROUP BY project
ORDER BY MAX(created_at_epoch) DESC
`).all() as Array<{ project: string }>;
if (platformSource) {
res.json({
projects: store.getAllProjects(platformSource),
sources: [platformSource],
projectsBySource: { [platformSource]: store.getAllProjects(platformSource) }
});
return;
}
const projects = rows.map(row => row.project);
res.json({ projects });
res.json(store.getProjectCatalog());
});
/**
@@ -299,12 +299,13 @@ export class DataRoutes extends BaseRouteHandler {
/**
* Parse pagination parameters from request query
*/
private parsePaginationParams(req: Request): { offset: number; limit: number; project?: string } {
private parsePaginationParams(req: Request): { offset: number; limit: number; project?: string; platformSource?: string } {
const offset = parseInt(req.query.offset as string, 10) || 0;
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); // Max 100
const project = req.query.project as string | undefined;
const platformSource = req.query.platformSource as string | undefined;
return { offset, limit, project };
return { offset, limit, project, platformSource };
}
/**

View File

@@ -168,6 +168,7 @@ export class SearchRoutes extends BaseRouteHandler {
*/
private handleContextPreview = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const projectName = req.query.project as string;
const platformSource = req.query.platformSource as string | undefined;
if (!projectName) {
this.badRequest(res, 'Project parameter is required');
@@ -184,7 +185,9 @@ export class SearchRoutes extends BaseRouteHandler {
const contextText = await generateContext(
{
session_id: 'preview-' + Date.now(),
cwd: cwd
cwd: cwd,
projects: [projectName],
platform_source: platformSource
},
true // forHuman=true for ANSI terminal output
);
@@ -210,6 +213,7 @@ export class SearchRoutes extends BaseRouteHandler {
const projectsParam = (req.query.projects as string) || (req.query.project as string);
const forHuman = req.query.colors === 'true';
const full = req.query.full === 'true';
const platformSource = req.query.platformSource as string | undefined;
if (!projectsParam) {
this.badRequest(res, 'Project(s) parameter is required');
@@ -237,7 +241,8 @@ export class SearchRoutes extends BaseRouteHandler {
session_id: 'context-inject-' + Date.now(),
cwd: cwd,
projects: projects,
full
full,
platform_source: platformSource
},
forHuman
);

View File

@@ -22,6 +22,8 @@ import { PrivacyCheckValidator } from '../../validation/PrivacyCheckValidator.js
import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js';
import { getProjectName } from '../../../../utils/project-name.js';
import { normalizePlatformSource } from '../../../../shared/platform-source.js';
export class SessionRoutes extends BaseRouteHandler {
private completionHandler: SessionCompletionHandler;
@@ -354,6 +356,7 @@ export class SessionRoutes extends BaseRouteHandler {
id: latestPrompt.id,
content_session_id: latestPrompt.content_session_id,
project: latestPrompt.project,
platform_source: latestPrompt.platform_source,
prompt_number: latestPrompt.prompt_number,
prompt_text: latestPrompt.prompt_text,
created_at_epoch: latestPrompt.created_at_epoch
@@ -503,6 +506,8 @@ export class SessionRoutes extends BaseRouteHandler {
*/
private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
const project = typeof cwd === 'string' && cwd.trim() ? getProjectName(cwd) : '';
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId');
@@ -537,7 +542,7 @@ export class SessionRoutes extends BaseRouteHandler {
const store = this.dbManager.getSessionStore();
// Get or create session
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const sessionDbId = store.createSDKSession(contentSessionId, project, '', undefined, platformSource);
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
// Privacy check: skip if user prompt was entirely private
@@ -601,6 +606,7 @@ export class SessionRoutes extends BaseRouteHandler {
*/
private handleSummarizeByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, last_assistant_message } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId');
@@ -609,7 +615,7 @@ export class SessionRoutes extends BaseRouteHandler {
const store = this.dbManager.getSessionStore();
// Get or create session
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const sessionDbId = store.createSDKSession(contentSessionId, '', '', undefined, platformSource);
const promptNumber = store.getPromptNumberFromUserPrompts(contentSessionId);
// Privacy check: skip if user prompt was entirely private
@@ -682,6 +688,7 @@ export class SessionRoutes extends BaseRouteHandler {
*/
private handleCompleteByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const { contentSessionId } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
logger.info('HTTP', '→ POST /api/sessions/complete', { contentSessionId });
@@ -693,7 +700,7 @@ export class SessionRoutes extends BaseRouteHandler {
// Look up sessionDbId from contentSessionId (createSDKSession is idempotent)
// Pass empty strings - we only need the ID lookup, not to create a new session
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const sessionDbId = store.createSDKSession(contentSessionId, '', '', undefined, platformSource);
// Check if session is in the active sessions map
const activeSession = this.sessionManager.getSession(sessionDbId);
@@ -739,11 +746,13 @@ export class SessionRoutes extends BaseRouteHandler {
// may omit prompt/project in their payload (#838, #1049)
const project = req.body.project || 'unknown';
const prompt = req.body.prompt || '[media prompt]';
const platformSource = req.body.platformSource || 'claude';
const customTitle = req.body.customTitle || undefined;
logger.info('HTTP', 'SessionRoutes: handleSessionInitByClaudeId called', {
contentSessionId,
project,
platformSource,
prompt_length: prompt?.length,
customTitle
});
@@ -756,7 +765,7 @@ export class SessionRoutes extends BaseRouteHandler {
const store = this.dbManager.getSessionStore();
// Step 1: Create/get SDK session (idempotent INSERT OR IGNORE)
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt, customTitle);
const sessionDbId = store.createSDKSession(contentSessionId, project, prompt, customTitle, platformSource);
// Verify session creation with DB lookup
const dbSession = store.getSessionById(sessionDbId);

View File

@@ -76,11 +76,13 @@ export class ViewerRoutes extends BaseRouteHandler {
// Add client to broadcaster
this.sseBroadcaster.addClient(res);
// Send initial_load event with projects list
const allProjects = this.dbManager.getSessionStore().getAllProjects();
// Send initial_load event with project/source catalog
const projectCatalog = this.dbManager.getSessionStore().getProjectCatalog();
this.sseBroadcaster.broadcast({
type: 'initial_load',
projects: allProjects,
projects: projectCatalog.projects,
sources: projectCatalog.sources,
projectsBySource: projectCatalog.projectsBySource,
timestamp: Date.now()
});

View File

@@ -51,7 +51,9 @@ export interface SettingsDefaults {
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: string;
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string; // 'true' | 'false' - write to CLAUDE.local.md instead of CLAUDE.md
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: string; // 'true' | 'false' - write to CLAUDE.local.md instead of CLAUDE.md
CLAUDE_MEM_TRANSCRIPTS_ENABLED: string; // 'true' | 'false' - enable transcript watcher ingestion for Codex and other transcript-based clients
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: string; // Path to transcript watcher config JSON
// Process Management
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: string; // Max concurrent Claude SDK agent subprocesses (default: 2)
// Exclusion Settings
@@ -120,7 +122,9 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
CLAUDE_MEM_CONTEXT_SHOW_TERMINAL_OUTPUT: 'true',
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false', // When true, writes to CLAUDE.local.md instead of CLAUDE.md
CLAUDE_MEM_FOLDER_USE_LOCAL_MD: 'false', // When true, writes to CLAUDE.local.md instead of CLAUDE.md
CLAUDE_MEM_TRANSCRIPTS_ENABLED: 'true',
CLAUDE_MEM_TRANSCRIPTS_CONFIG_PATH: join(homedir(), '.claude-mem', 'transcript-watch.json'),
// Process Management
CLAUDE_MEM_MAX_CONCURRENT_AGENTS: '2', // Max concurrent Claude SDK agent subprocesses
// Exclusion Settings

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);
});
}

View File

@@ -103,6 +103,7 @@ export interface UserPromptRecord {
prompt_number: number;
prompt_text: string;
project?: string; // From JOIN with sdk_sessions
platform_source?: string;
created_at: string;
created_at_epoch: number;
}
@@ -115,6 +116,7 @@ export interface LatestPromptResult {
content_session_id: string;
memory_session_id: string;
project: string;
platform_source: string;
prompt_number: number;
prompt_text: string;
created_at_epoch: number;

View File

@@ -355,6 +355,14 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
.header-main {
display: flex;
align-items: center;
gap: 18px;
min-width: 0;
flex-wrap: wrap;
}
.sidebar-header {
padding: 14px 18px;
border-bottom: 1px solid var(--color-border-primary);
@@ -549,6 +557,42 @@
font-size: 13px;
}
.source-tabs {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.source-tab {
background: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-secondary);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
line-height: 1;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
}
.source-tab:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-focus);
color: var(--color-text-primary);
transform: translateY(-1px);
}
.source-tab.active {
background: linear-gradient(135deg, var(--color-bg-button) 0%, var(--color-accent-primary) 100%);
border-color: var(--color-bg-button);
color: var(--color-text-button);
box-shadow: 0 2px 8px rgba(9, 105, 218, 0.18);
}
.settings-btn,
.theme-toggle-btn {
background: var(--color-bg-card);
@@ -887,6 +931,49 @@
letter-spacing: 0.5px;
}
.card-source {
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
border: 1px solid transparent;
}
.source-claude {
background: rgba(255, 138, 61, 0.12);
color: #c25a00;
border-color: rgba(255, 138, 61, 0.22);
}
.source-codex {
background: rgba(33, 150, 243, 0.12);
color: #0f5ba7;
border-color: rgba(33, 150, 243, 0.24);
}
.source-cursor {
background: rgba(124, 58, 237, 0.12);
color: #6d28d9;
border-color: rgba(124, 58, 237, 0.24);
}
[data-theme="dark"] .source-claude {
color: #ffb067;
border-color: rgba(255, 176, 103, 0.2);
}
[data-theme="dark"] .source-codex {
color: #8fc7ff;
border-color: rgba(143, 199, 255, 0.2);
}
[data-theme="dark"] .source-cursor {
color: #c4b5fd;
border-color: rgba(196, 181, 253, 0.2);
}
.card-title {
font-size: 17px;
margin-bottom: 14px;
@@ -1483,6 +1570,10 @@
padding: 14px 20px;
}
.header-main {
gap: 12px;
}
.status {
gap: 6px;
}
@@ -1491,6 +1582,11 @@
max-width: 160px;
}
.source-tab {
padding: 6px 10px;
font-size: 11px;
}
/* Hide icon links (docs, github, twitter) on tablet */
.icon-link {
display: none;
@@ -1544,6 +1640,28 @@
gap: 8px;
}
.header-main {
gap: 10px;
}
.source-tabs {
width: 100%;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.source-tabs::-webkit-scrollbar {
display: none;
}
.source-tab {
flex-shrink: 0;
padding: 5px 10px;
font-size: 11px;
}
.logomark {
height: 28px;
}
@@ -1732,6 +1850,11 @@
white-space: nowrap;
}
.preview-selector select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.preview-selector select {
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
@@ -2873,4 +2996,4 @@
<script src="viewer-bundle.js"></script>
</body>
</html>
</html>

View File

@@ -13,39 +13,57 @@ import { mergeAndDeduplicateByProject } from './utils/data';
export function App() {
const [currentFilter, setCurrentFilter] = useState('');
const [currentSource, setCurrentSource] = useState('all');
const [contextPreviewOpen, setContextPreviewOpen] = useState(false);
const [logsModalOpen, setLogsModalOpen] = useState(false);
const [paginatedObservations, setPaginatedObservations] = useState<Observation[]>([]);
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
const { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected } = useSSE();
const { observations, summaries, prompts, projects, sources, projectsBySource, isProcessing, queueDepth, isConnected } = useSSE();
const { settings, saveSettings, isSaving, saveStatus } = useSettings();
const { stats, refreshStats } = useStats();
const { preference, resolvedTheme, setThemePreference } = useTheme();
const pagination = usePagination(currentFilter);
const pagination = usePagination(currentFilter, currentSource);
const availableProjects = useMemo(() => {
if (currentSource === 'all') {
return projects;
}
return projectsBySource[currentSource] || [];
}, [currentSource, projects, projectsBySource]);
const matchesSelection = useCallback((item: { project: string; platform_source: string }) => {
const matchesProject = !currentFilter || item.project === currentFilter;
const matchesSource = currentSource === 'all' || (item.platform_source || 'claude') === currentSource;
return matchesProject && matchesSource;
}, [currentFilter, currentSource]);
useEffect(() => {
if (currentFilter && !availableProjects.includes(currentFilter)) {
setCurrentFilter('');
}
}, [availableProjects, currentFilter]);
// Merge SSE live data with paginated data, filtering by project when active
const allObservations = useMemo(() => {
const live = currentFilter
? observations.filter(o => o.project === currentFilter)
: observations;
return mergeAndDeduplicateByProject(live, paginatedObservations);
}, [observations, paginatedObservations, currentFilter]);
const live = observations.filter(matchesSelection);
const paginated = paginatedObservations.filter(matchesSelection);
return mergeAndDeduplicateByProject(live, paginated);
}, [observations, paginatedObservations, matchesSelection]);
const allSummaries = useMemo(() => {
const live = currentFilter
? summaries.filter(s => s.project === currentFilter)
: summaries;
return mergeAndDeduplicateByProject(live, paginatedSummaries);
}, [summaries, paginatedSummaries, currentFilter]);
const live = summaries.filter(matchesSelection);
const paginated = paginatedSummaries.filter(matchesSelection);
return mergeAndDeduplicateByProject(live, paginated);
}, [summaries, paginatedSummaries, matchesSelection]);
const allPrompts = useMemo(() => {
const live = currentFilter
? prompts.filter(p => p.project === currentFilter)
: prompts;
return mergeAndDeduplicateByProject(live, paginatedPrompts);
}, [prompts, paginatedPrompts, currentFilter]);
const live = prompts.filter(matchesSelection);
const paginated = paginatedPrompts.filter(matchesSelection);
return mergeAndDeduplicateByProject(live, paginated);
}, [prompts, paginatedPrompts, matchesSelection]);
// Toggle context preview modal
const toggleContextPreview = useCallback(() => {
@@ -78,24 +96,27 @@ export function App() {
} catch (error) {
console.error('Failed to load more data:', error);
}
}, [currentFilter, pagination.observations, pagination.summaries, pagination.prompts]);
}, [pagination.observations, pagination.summaries, pagination.prompts]);
// Reset paginated data and load first page when filter changes
// Reset paginated data and load first page when project/source changes
useEffect(() => {
setPaginatedObservations([]);
setPaginatedSummaries([]);
setPaginatedPrompts([]);
handleLoadMore();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentFilter]);
}, [currentFilter, currentSource]);
return (
<>
<Header
isConnected={isConnected}
projects={projects}
projects={availableProjects}
sources={sources}
currentFilter={currentFilter}
currentSource={currentSource}
onFilterChange={setCurrentFilter}
onSourceChange={setCurrentSource}
isProcessing={isProcessing}
queueDepth={queueDepth}
themePreference={preference}

View File

@@ -136,7 +136,17 @@ export function ContextSettingsModal({
}, [settings]);
// Get context preview based on current form state
const { preview, isLoading, error, projects, selectedProject, setSelectedProject } = useContextPreview(formState);
const {
preview,
isLoading,
error,
projects,
sources,
selectedSource,
setSelectedSource,
selectedProject,
setSelectedProject
} = useContextPreview(formState);
const updateSetting = useCallback((key: keyof Settings, value: string) => {
const newState = { ...formState, [key]: value };
@@ -174,10 +184,23 @@ export function ContextSettingsModal({
<h2>Settings</h2>
<div className="header-controls">
<label className="preview-selector">
Preview for:
Source:
<select
value={selectedSource || ''}
onChange={(e) => setSelectedSource(e.target.value)}
disabled={sources.length === 0}
>
{sources.map(source => (
<option key={source} value={source}>{source}</option>
))}
</select>
</label>
<label className="preview-selector">
Project:
<select
value={selectedProject || ''}
onChange={(e) => setSelectedProject(e.target.value)}
disabled={projects.length === 0}
>
{projects.map(project => (
<option key={project} value={project}>{project}</option>

View File

@@ -7,8 +7,11 @@ import { useSpinningFavicon } from '../hooks/useSpinningFavicon';
interface HeaderProps {
isConnected: boolean;
projects: string[];
sources: string[];
currentFilter: string;
currentSource: string;
onFilterChange: (filter: string) => void;
onSourceChange: (source: string) => void;
isProcessing: boolean;
queueDepth: number;
themePreference: ThemePreference;
@@ -16,11 +19,26 @@ interface HeaderProps {
onContextPreviewToggle: () => void;
}
function formatSourceLabel(source: string): string {
if (source === 'all') return 'All';
if (source === 'claude') return 'Claude';
if (source === 'codex') return 'Codex';
return source.charAt(0).toUpperCase() + source.slice(1);
}
function buildSourceTabs(sources: string[]): string[] {
const merged = ['all', 'claude', 'codex', ...sources];
return Array.from(new Set(merged.filter(Boolean)));
}
export function Header({
isConnected,
projects,
sources,
currentFilter,
currentSource,
onFilterChange,
onSourceChange,
isProcessing,
queueDepth,
themePreference,
@@ -28,20 +46,36 @@ export function Header({
onContextPreviewToggle
}: HeaderProps) {
useSpinningFavicon(isProcessing);
const availableSources = buildSourceTabs(sources);
return (
<div className="header">
<h1>
<div style={{ position: 'relative', display: 'inline-block' }}>
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
{queueDepth > 0 && (
<div className="queue-bubble">
{queueDepth}
</div>
)}
<div className="header-main">
<h1>
<div style={{ position: 'relative', display: 'inline-block' }}>
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
{queueDepth > 0 && (
<div className="queue-bubble">
{queueDepth}
</div>
)}
</div>
<span className="logo-text">claude-mem</span>
</h1>
<div className="source-tabs" role="tablist" aria-label="Context source tabs">
{availableSources.map(source => (
<button
key={source}
type="button"
className={`source-tab ${currentSource === source ? 'active' : ''}`}
onClick={() => onSourceChange(source)}
aria-pressed={currentSource === source}
>
{formatSourceLabel(source)}
</button>
))}
</div>
<span className="logo-text">claude-mem</span>
</h1>
</div>
<div className="status">
<a
href="https://docs.claude-mem.ai"

View File

@@ -52,6 +52,9 @@ export function ObservationCard({ observation }: ObservationCardProps) {
<span className={`card-type type-${observation.type}`}>
{observation.type}
</span>
<span className={`card-source source-${observation.platform_source || 'claude'}`}>
{observation.platform_source || 'claude'}
</span>
<span className="card-project">{observation.project}</span>
</div>
<div className="view-mode-toggles">

View File

@@ -14,6 +14,9 @@ export function PromptCard({ prompt }: PromptCardProps) {
<div className="card-header">
<div className="card-header-left">
<span className="card-type">Prompt</span>
<span className={`card-source source-${prompt.platform_source || 'claude'}`}>
{prompt.platform_source || 'claude'}
</span>
<span className="card-project">{prompt.project}</span>
</div>
</div>

View File

@@ -21,6 +21,9 @@ export function SummaryCard({ summary }: SummaryCardProps) {
<header className="summary-card-header">
<div className="summary-badge-row">
<span className="card-type summary-badge">Session Summary</span>
<span className={`card-source source-${summary.platform_source || 'claude'}`}>
{summary.platform_source || 'claude'}
</span>
<span className="summary-project-badge">{summary.project}</span>
</div>
{summary.request && (

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import type { Settings } from '../types';
import type { ProjectCatalog, Settings } from '../types';
interface UseContextPreviewResult {
preview: string;
@@ -7,15 +7,31 @@ interface UseContextPreviewResult {
error: string | null;
refresh: () => Promise<void>;
projects: string[];
sources: string[];
selectedSource: string | null;
setSelectedSource: (source: string) => void;
selectedProject: string | null;
setSelectedProject: (project: string) => void;
}
function getPreferredSource(sources: string[]): string | null {
if (sources.includes('claude')) return 'claude';
if (sources.includes('codex')) return 'codex';
return sources[0] || null;
}
function withDefaultSources(sources: string[]): string[] {
const merged = ['claude', 'codex', ...sources];
return Array.from(new Set(merged));
}
export function useContextPreview(settings: Settings): UseContextPreviewResult {
const [preview, setPreview] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [catalog, setCatalog] = useState<ProjectCatalog>({ projects: [], sources: [], projectsBySource: {} });
const [projects, setProjects] = useState<string[]>([]);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [selectedProject, setSelectedProject] = useState<string | null>(null);
// Fetch projects on mount
@@ -23,11 +39,27 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
async function fetchProjects() {
try {
const response = await fetch('/api/projects');
const data = await response.json();
if (data.projects && data.projects.length > 0) {
setProjects(data.projects);
setSelectedProject(data.projects[0]); // Default to first project
const data = await response.json() as ProjectCatalog;
const nextCatalog: ProjectCatalog = {
projects: data.projects || [],
sources: withDefaultSources(data.sources || []),
projectsBySource: data.projectsBySource || {}
};
setCatalog(nextCatalog);
const preferredSource = getPreferredSource(nextCatalog.sources);
setSelectedSource(preferredSource);
if (preferredSource) {
const sourceProjects = nextCatalog.projectsBySource[preferredSource] || [];
setProjects(sourceProjects);
setSelectedProject(sourceProjects[0] || null);
return;
}
setProjects(nextCatalog.projects);
setSelectedProject(nextCatalog.projects[0] || null);
} catch (err) {
console.error('Failed to fetch projects:', err);
}
@@ -35,6 +67,18 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
fetchProjects();
}, []);
useEffect(() => {
if (!selectedSource) {
setProjects(catalog.projects);
setSelectedProject(prev => (prev && catalog.projects.includes(prev) ? prev : catalog.projects[0] || null));
return;
}
const sourceProjects = catalog.projectsBySource[selectedSource] || [];
setProjects(sourceProjects);
setSelectedProject(prev => (prev && sourceProjects.includes(prev) ? prev : sourceProjects[0] || null));
}, [catalog, selectedSource]);
const refresh = useCallback(async () => {
if (!selectedProject) {
setPreview('No project selected');
@@ -48,6 +92,10 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
project: selectedProject
});
if (selectedSource) {
params.append('platformSource', selectedSource);
}
const response = await fetch(`/api/context/preview?${params}`);
const text = await response.text();
@@ -58,7 +106,7 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
}
setIsLoading(false);
}, [selectedProject]);
}, [selectedProject, selectedSource]);
// Debounced refresh when settings or selectedProject change
useEffect(() => {
@@ -68,5 +116,16 @@ export function useContextPreview(settings: Settings): UseContextPreviewResult {
return () => clearTimeout(timeout);
}, [settings, refresh]);
return { preview, isLoading, error, refresh, projects, selectedProject, setSelectedProject };
return {
preview,
isLoading,
error,
refresh,
projects,
sources: catalog.sources,
selectedSource,
setSelectedSource,
selectedProject,
setSelectedProject
};
}

View File

@@ -14,7 +14,7 @@ type DataItem = Observation | Summary | UserPrompt;
/**
* Generic pagination hook for observations, summaries, and prompts
*/
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string) {
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string, currentSource: string) {
const [state, setState] = useState<PaginationState>({
isLoading: false,
hasMore: true
@@ -22,7 +22,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
// Track offset and filter in refs to handle synchronous resets
const offsetRef = useRef(0);
const lastFilterRef = useRef(currentFilter);
const lastSelectionRef = useRef(`${currentSource}::${currentFilter}`);
const stateRef = useRef(state);
/**
@@ -31,16 +31,17 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
*/
const loadMore = useCallback(async (): Promise<DataItem[]> => {
// Check if filter changed - if so, reset pagination synchronously
const filterChanged = lastFilterRef.current !== currentFilter;
const selectionKey = `${currentSource}::${currentFilter}`;
const filterChanged = lastSelectionRef.current !== selectionKey;
if (filterChanged) {
offsetRef.current = 0;
lastFilterRef.current = currentFilter;
lastSelectionRef.current = selectionKey;
// Reset state both in React state and ref synchronously
const newState = { isLoading: false, hasMore: true };
setState(newState);
stateRef.current = newState; // Update ref immediately to avoid stale checks
stateRef.current = newState; // Update ref immediately to avoid stale checks
}
// Prevent concurrent requests using ref (always current)
@@ -49,6 +50,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
return [];
}
stateRef.current = { ...stateRef.current, isLoading: true };
setState(prev => ({ ...prev, isLoading: true }));
// Build query params using current offset from ref
@@ -62,6 +64,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
params.append('project', currentFilter);
}
if (currentSource && currentSource !== 'all') {
params.append('platformSource', currentSource);
}
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) {
@@ -70,6 +76,13 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
const data = await response.json() as { items: DataItem[], hasMore: boolean };
const nextState = {
...stateRef.current,
isLoading: false,
hasMore: data.hasMore
};
stateRef.current = nextState;
setState(prev => ({
...prev,
isLoading: false,
@@ -80,7 +93,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
offsetRef.current += UI.PAGINATION_PAGE_SIZE;
return data.items;
}, [currentFilter, endpoint, dataType]);
}, [currentFilter, currentSource, endpoint, dataType]);
return {
...state,
@@ -91,10 +104,10 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
/**
* Hook for paginating observations
*/
export function usePagination(currentFilter: string) {
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter);
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter);
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter);
export function usePagination(currentFilter: string, currentSource: string) {
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter, currentSource);
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter, currentSource);
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter, currentSource);
return {
observations,

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { Observation, Summary, UserPrompt, StreamEvent } from '../types';
import { Observation, Summary, UserPrompt, StreamEvent, ProjectCatalog } from '../types';
import { API_ENDPOINTS } from '../constants/api';
import { TIMING } from '../constants/timing';
@@ -7,16 +7,42 @@ export function useSSE() {
const [observations, setObservations] = useState<Observation[]>([]);
const [summaries, setSummaries] = useState<Summary[]>([]);
const [prompts, setPrompts] = useState<UserPrompt[]>([]);
const [projects, setProjects] = useState<string[]>([]);
const [catalog, setCatalog] = useState<ProjectCatalog>({
projects: [],
sources: [],
projectsBySource: {}
});
const [isConnected, setIsConnected] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [queueDepth, setQueueDepth] = useState(0);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const updateCatalogForItem = (project: string, platformSource: string) => {
setCatalog(prev => {
const nextProjects = prev.projects.includes(project)
? prev.projects
: [...prev.projects, project];
const nextSources = prev.sources.includes(platformSource)
? prev.sources
: [...prev.sources, platformSource];
const sourceProjects = prev.projectsBySource[platformSource] || [];
return {
projects: nextProjects,
sources: nextSources,
projectsBySource: {
...prev.projectsBySource,
[platformSource]: sourceProjects.includes(project)
? sourceProjects
: [...sourceProjects, project]
}
};
});
};
useEffect(() => {
const connect = () => {
// Clean up existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
@@ -27,7 +53,6 @@ export function useSSE() {
eventSource.onopen = () => {
console.log('[SSE] Connected');
setIsConnected(true);
// Clear any pending reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
@@ -38,9 +63,8 @@ export function useSSE() {
setIsConnected(false);
eventSource.close();
// Reconnect after delay
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = undefined; // Clear before reconnecting
reconnectTimeoutRef.current = undefined;
console.log('[SSE] Attempting to reconnect...');
connect();
}, TIMING.SSE_RECONNECT_DELAY_MS);
@@ -52,32 +76,37 @@ export function useSSE() {
switch (data.type) {
case 'initial_load':
console.log('[SSE] Initial load:', {
projects: data.projects?.length || 0
projects: data.projects?.length || 0,
sources: data.sources?.length || 0
});
setCatalog({
projects: data.projects || [],
sources: data.sources || [],
projectsBySource: data.projectsBySource || {}
});
// Only load projects list - data will come via pagination
setProjects(data.projects || []);
break;
case 'new_observation':
if (data.observation) {
console.log('[SSE] New observation:', data.observation.id);
setObservations(prev => [data.observation, ...prev]);
updateCatalogForItem(data.observation.project, data.observation.platform_source || 'claude');
setObservations(prev => [data.observation!, ...prev]);
}
break;
case 'new_summary':
if (data.summary) {
const summary = data.summary;
console.log('[SSE] New summary:', summary.id);
setSummaries(prev => [summary, ...prev]);
console.log('[SSE] New summary:', data.summary.id);
updateCatalogForItem(data.summary.project, data.summary.platform_source || 'claude');
setSummaries(prev => [data.summary!, ...prev]);
}
break;
case 'new_prompt':
if (data.prompt) {
const prompt = data.prompt;
console.log('[SSE] New prompt:', prompt.id);
setPrompts(prev => [prompt, ...prev]);
console.log('[SSE] New prompt:', data.prompt.id);
updateCatalogForItem(data.prompt.project, data.prompt.platform_source || 'claude');
setPrompts(prev => [data.prompt!, ...prev]);
}
break;
@@ -94,7 +123,6 @@ export function useSSE() {
connect();
// Cleanup on unmount
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
@@ -105,5 +133,15 @@ export function useSSE() {
};
}, []);
return { observations, summaries, prompts, projects, isProcessing, queueDepth, isConnected };
return {
observations,
summaries,
prompts,
projects: catalog.projects,
sources: catalog.sources,
projectsBySource: catalog.projectsBySource,
isProcessing,
queueDepth,
isConnected
};
}

View File

@@ -2,6 +2,7 @@ export interface Observation {
id: number;
memory_session_id: string;
project: string;
platform_source: string;
type: string;
title: string | null;
subtitle: string | null;
@@ -20,6 +21,7 @@ export interface Summary {
id: number;
session_id: string;
project: string;
platform_source: string;
request?: string;
investigated?: string;
learned?: string;
@@ -32,6 +34,7 @@ export interface UserPrompt {
id: number;
content_session_id: string;
project: string;
platform_source: string;
prompt_number: number;
prompt_text: string;
created_at_epoch: number;
@@ -48,10 +51,19 @@ export interface StreamEvent {
summaries?: Summary[];
prompts?: UserPrompt[];
projects?: string[];
sources?: string[];
projectsBySource?: Record<string, string[]>;
observation?: Observation;
summary?: Summary;
prompt?: UserPrompt;
isProcessing?: boolean;
queueDepth?: number;
}
export interface ProjectCatalog {
projects: string[];
sources: string[];
projectsBySource: Record<string, string[]>;
}
export interface Settings {

View File

@@ -22,6 +22,10 @@ interface TableColumnInfo {
notnull: number;
}
interface IndexInfo {
name: string;
}
interface SchemaVersion {
version: number;
}
@@ -46,6 +50,11 @@ function getSchemaVersions(db: Database): number[] {
return rows.map(r => r.version);
}
function getIndexNames(db: Database, table: string): string[] {
const rows = db.prepare(`PRAGMA index_list(${table})`).all() as IndexInfo[];
return rows.map(r => r.name);
}
describe('MigrationRunner', () => {
let db: Database;
@@ -158,6 +167,43 @@ describe('MigrationRunner', () => {
});
});
describe('schema drift recovery for migration 24', () => {
it('should repair platform_source column and index even when version 24 is already recorded', () => {
db.run(`
CREATE TABLE IF NOT EXISTS schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
applied_at TEXT NOT NULL
)
`);
db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(24, new Date().toISOString());
db.run(`
CREATE TABLE sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_session_id TEXT UNIQUE NOT NULL,
memory_session_id TEXT UNIQUE,
project TEXT NOT NULL,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT NOT NULL CHECK(status IN ('active','completed','failed'))
)
`);
const runner = new MigrationRunner(db);
expect(() => runner.runAllMigrations()).not.toThrow();
const columnNames = getColumns(db, 'sdk_sessions').map(column => column.name);
expect(columnNames).toContain('platform_source');
const indexNames = getIndexNames(db, 'sdk_sessions');
expect(indexNames).toContain('idx_sdk_sessions_platform_source');
});
});
describe('issue #979 — old DatabaseManager version conflict', () => {
it('should create core tables even when old migration versions 1-7 are in schema_versions', () => {
// Simulate the old DatabaseManager having applied its migrations 1-7

View File

@@ -130,6 +130,38 @@ describe('Sessions Module', () => {
});
});
describe('platform_source', () => {
it('should default new sessions to claude when platformSource is omitted', () => {
const sessionId = createSDKSession(db, 'session-platform-1', 'project', 'prompt');
const session = getSessionById(db, sessionId);
expect(session?.platform_source).toBe('claude');
});
it('should preserve a non-default platform_source for legacy callers that omit platformSource', () => {
const sessionId = createSDKSession(db, 'session-platform-2', 'project', 'prompt', undefined, 'codex');
let session = getSessionById(db, sessionId);
expect(session?.platform_source).toBe('codex');
createSDKSession(db, 'session-platform-2', 'project', 'prompt');
session = getSessionById(db, sessionId);
expect(session?.platform_source).toBe('codex');
});
it('should reject explicit platform_source conflicts for the same session', () => {
createSDKSession(db, 'session-platform-3', 'project', 'prompt', undefined, 'codex');
expect(() => createSDKSession(
db,
'session-platform-3',
'project',
'prompt',
undefined,
'claude'
)).toThrow(/Platform source conflict/);
});
});
describe('updateMemorySessionId', () => {
it('should update memory_session_id for existing session', () => {
const contentSessionId = 'content-session-update';