diff --git a/src/cli/handlers/session-init.ts b/src/cli/handlers/session-init.ts index 5c2554096..01a9102fb 100644 --- a/src/cli/handlers/session-init.ts +++ b/src/cli/handlers/session-init.ts @@ -23,7 +23,9 @@ export const sessionInitHandler: EventHandler = { return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS }; } - const { sessionId, cwd, prompt: rawPrompt } = input; + const { sessionId, prompt: rawPrompt } = input; + // Use same fallback as context handler to ensure consistent project key resolution + const cwd = input.cwd ?? process.cwd(); // Guard: Codex CLI and other platforms may not provide a session_id (#744) if (!sessionId) { diff --git a/src/servers/mcp-server.ts b/src/servers/mcp-server.ts index 164f95a51..9f53d1a2e 100644 --- a/src/servers/mcp-server.ts +++ b/src/servers/mcp-server.ts @@ -225,14 +225,14 @@ async function ensureWorkerConnection(): Promise { if (!started) { logger.error( 'SYSTEM', - 'Worker auto-start returned false — MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running. Check earlier log lines for the specific failure reason (Bun not found, missing worker bundle, port conflict, etc.).' + 'Worker auto-start returned false — MCP tools that require the worker (search, timeline, get_observations, get_sessions, get_prompts) will fail until the worker is running. Check earlier log lines for the specific failure reason (Bun not found, missing worker bundle, port conflict, etc.).' ); } return started; } catch (error) { logger.error( 'SYSTEM', - 'Worker auto-start threw — MCP tools that require the worker (search, timeline, get_observations) will fail until the worker is running.', + 'Worker auto-start threw — MCP tools that require the worker (search, timeline, get_observations, get_sessions, get_prompts) will fail until the worker is running.', undefined, error as Error ); @@ -250,7 +250,10 @@ const tools = [ description: `3-LAYER WORKFLOW (ALWAYS FOLLOW): 1. search(query) → Get index with IDs (~50-100 tokens/result) 2. timeline(anchor=ID) → Get context around interesting results -3. get_observations([IDs]) → Fetch full details ONLY for filtered IDs +3. Fetch full details ONLY for filtered IDs using the matching tool: + - Observation IDs (emoji-prefixed): get_observations([IDs]) + - Session IDs (S-prefixed, e.g. S42): get_sessions([IDs]) + - Prompt IDs (from prompt results): get_prompts([IDs]) NEVER fetch full details without filtering first. 10x token savings.`, inputSchema: { type: 'object', @@ -271,9 +274,11 @@ NEVER fetch full details without filtering first. 10x token savings.`, \`timeline(anchor=, depth_before=3, depth_after=3)\` Returns: Chronological context showing what was happening -3. **Fetch** - Get full details ONLY for relevant IDs - \`get_observations(ids=[...])\` # ALWAYS batch for 2+ items - Returns: Complete details (~500-1000 tokens/result) +3. **Fetch** - Get full details ONLY for relevant IDs (use the right tool for each ID type) + - \`get_observations(ids=[...])\` for observation IDs (emoji-prefixed in timeline) + - \`get_sessions(ids=[...])\` for session IDs (S-prefixed, e.g. S42) + - \`get_prompts(ids=[...])\` for user prompt IDs + ALWAYS batch for 2+ items. Returns ~500-1000 tokens/result. **Why:** 10x token savings. Never fetch full details without filtering first.` }] @@ -323,7 +328,7 @@ NEVER fetch full details without filtering first. 10x token savings.`, }, { name: 'get_observations', - description: 'Step 3: Fetch full details for filtered IDs. Params: ids (array of observation IDs, required), orderBy, limit, project', + description: 'Fetch full observation details by IDs (numeric IDs from search results prefixed with observation emoji icons). Params: ids (array of observation IDs, required), orderBy, limit, project', inputSchema: { type: 'object', properties: { @@ -340,6 +345,44 @@ NEVER fetch full details without filtering first. 10x token savings.`, return await callWorkerAPIPost('/api/observations/batch', args); } }, + { + name: 'get_sessions', + description: 'Fetch full session summary details by IDs (numeric IDs from timeline prefixed with "S", e.g. S42). Params: ids (array of session summary IDs, required), orderBy, limit, project', + inputSchema: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'number' }, + description: 'Array of session summary IDs to fetch (required)' + } + }, + required: ['ids'], + additionalProperties: true + }, + handler: async (args: any) => { + return await callWorkerAPIPost('/api/sessions/batch', args); + } + }, + { + name: 'get_prompts', + description: 'Fetch full user prompt details by IDs (numeric IDs from search results tagged as prompts). Params: ids (array of user_prompt IDs, required), orderBy, limit, project', + inputSchema: { + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'number' }, + description: 'Array of user prompt IDs to fetch (required)' + } + }, + required: ['ids'], + additionalProperties: true + }, + handler: async (args: any) => { + return await callWorkerAPIPost('/api/prompts/batch', args); + } + }, { name: 'smart_search', description: 'Search codebase for symbols, functions, classes using tree-sitter AST parsing. Returns folded structural views with token counts. Use path parameter to scope the search.', diff --git a/src/services/context/ObservationCompiler.ts b/src/services/context/ObservationCompiler.ts index 21c5b50f7..82c313c68 100644 --- a/src/services/context/ObservationCompiler.ts +++ b/src/services/context/ObservationCompiler.ts @@ -315,11 +315,12 @@ export function buildTimeline( ...summaries.map(summary => ({ type: 'summary' as const, data: summary })) ]; - // Sort chronologically + // Sort newest-first so that when Claude Code truncates SessionStart output + // to 2KB, the user sees the most recent (and most relevant) data timeline.sort((a, b) => { const aEpoch = a.type === 'observation' ? a.data.created_at_epoch : a.data.displayEpoch; const bEpoch = b.type === 'observation' ? b.data.created_at_epoch : b.data.displayEpoch; - return aEpoch - bEpoch; + return bEpoch - aEpoch; }); return timeline; diff --git a/src/services/context/formatters/AgentFormatter.ts b/src/services/context/formatters/AgentFormatter.ts index f358b8efa..fa3897c26 100644 --- a/src/services/context/formatters/AgentFormatter.ts +++ b/src/services/context/formatters/AgentFormatter.ts @@ -50,7 +50,7 @@ export function renderAgentLegend(): string[] { return [ `Legend: 🎯session ${typeLegendItems}`, `Format: ID TIME TYPE TITLE`, - `Fetch details: get_observations([IDs]) | Search: mem-search skill`, + `Fetch: get_observations([IDs]) for observations, get_sessions([IDs]) for S-prefixed sessions, get_prompts([IDs]) for prompts | Search: mem-search skill`, '' ]; } @@ -215,7 +215,7 @@ export function renderAgentFooter(totalDiscoveryTokens: number, totalReadTokens: const workTokensK = Math.round(totalDiscoveryTokens / 1000); return [ '', - `Access ${workTokensK}k tokens of past work via get_observations([IDs]) or mem-search skill.` + `Access ${workTokensK}k tokens of past work: get_observations([IDs]) for observations, get_sessions([IDs]) for sessions, get_prompts([IDs]) for prompts, or mem-search skill.` ]; } diff --git a/src/services/context/formatters/HumanFormatter.ts b/src/services/context/formatters/HumanFormatter.ts index 1abfa740a..2fc6956f2 100644 --- a/src/services/context/formatters/HumanFormatter.ts +++ b/src/services/context/formatters/HumanFormatter.ts @@ -74,7 +74,7 @@ export function renderHumanContextIndex(): string[] { `${colors.dim}Context Index: This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.${colors.reset}`, '', `${colors.dim}When you need implementation details, rationale, or debugging context:${colors.reset}`, - `${colors.dim} - Fetch by ID: get_observations([IDs]) for observations visible in this index${colors.reset}`, + `${colors.dim} - Fetch by ID: get_observations([IDs]) for observations, get_sessions([IDs]) for sessions, get_prompts([IDs]) for prompts${colors.reset}`, `${colors.dim} - Search history: Use the mem-search skill for past decisions, bugs, and deeper research${colors.reset}`, `${colors.dim} - Trust this index over re-reading code for past decisions and learnings${colors.reset}`, '' diff --git a/src/services/context/sections/TimelineRenderer.ts b/src/services/context/sections/TimelineRenderer.ts index b1f437245..6332536f7 100644 --- a/src/services/context/sections/TimelineRenderer.ts +++ b/src/services/context/sections/TimelineRenderer.ts @@ -30,11 +30,11 @@ export function groupTimelineByDay(timeline: TimelineItem[]): Map { const aDate = new Date(a[0]).getTime(); const bDate = new Date(b[0]).getTime(); - return aDate - bDate; + return bDate - aDate; }); return new Map(sortedEntries); diff --git a/src/services/smart-file-read/parser.ts b/src/services/smart-file-read/parser.ts index 1adf22aa1..23659fea6 100644 --- a/src/services/smart-file-read/parser.ts +++ b/src/services/smart-file-read/parser.ts @@ -319,17 +319,25 @@ export function resolveGrammarPathWithFallback(language: string, projectRoot?: s // --- Query patterns (declarative symbol extraction) --- -const QUERIES: Record = { - jsts: ` +// Shared base for JS and TS: function, class, method, import, export +const JSTS_BASE_QUERY = ` (function_declaration name: (identifier) @name) @func (lexical_declaration (variable_declarator name: (identifier) @name value: [(arrow_function) (function_expression)])) @const_func (class_declaration name: (type_identifier) @name) @cls (method_definition name: (property_identifier) @name) @method +(import_statement) @imp +(export_statement) @exp +`; + +const QUERIES: Record = { + // JavaScript: only node types that exist in tree-sitter-javascript grammar + javascript: JSTS_BASE_QUERY, + + // TypeScript: extends JS base with TS-only node types (interface, type alias, enum) + typescript: `${JSTS_BASE_QUERY} (interface_declaration name: (type_identifier) @name) @iface (type_alias_declaration name: (type_identifier) @name) @tdef (enum_declaration name: (identifier) @name) @enm -(import_statement) @imp -(export_statement) @exp `, python: ` @@ -490,9 +498,10 @@ const QUERIES: Record = { function getQueryKey(language: string): string { switch (language) { case "javascript": + return "javascript"; case "typescript": case "tsx": - return "jsts"; + return "typescript"; case "python": return "python"; case "go": return "go"; case "rust": return "rust"; diff --git a/src/services/sqlite/observations/store.ts b/src/services/sqlite/observations/store.ts index 00d181a8f..eb01c9559 100644 --- a/src/services/sqlite/observations/store.ts +++ b/src/services/sqlite/observations/store.ts @@ -6,7 +6,6 @@ import { createHash } from 'crypto'; import { Database } from 'bun:sqlite'; import { logger } from '../../../utils/logger.js'; -import { getProjectContext } from '../../../utils/project-name.js'; import type { ObservationInput, StoreObservationResult } from './types.js'; /** Deduplication window: observations with the same content hash within this window are skipped */ @@ -61,8 +60,11 @@ export function storeObservation( const timestampEpoch = overrideTimestampEpoch ?? Date.now(); const timestampIso = new Date(timestampEpoch).toISOString(); - // Guard against empty project string (race condition where project isn't set yet) - const resolvedProject = project || getProjectContext(process.cwd()).primary; + // Guard against empty project string (race condition where project isn't set yet). + // Use 'unknown-project' instead of getProjectContext(process.cwd()) because the + // worker process's cwd is not the user's project directory, which would cause a + // project-key mismatch between writes and reads (#1918). + const resolvedProject = project || 'unknown-project'; // Content-hash deduplication const contentHash = computeObservationContentHash(memorySessionId, observation.title, observation.narrative); diff --git a/src/services/worker/http/routes/DataRoutes.ts b/src/services/worker/http/routes/DataRoutes.ts index b476a3d92..9212912df 100644 --- a/src/services/worker/http/routes/DataRoutes.ts +++ b/src/services/worker/http/routes/DataRoutes.ts @@ -44,8 +44,10 @@ export class DataRoutes extends BaseRouteHandler { app.get('/api/observations/by-file', this.handleGetObservationsByFile.bind(this)); app.post('/api/observations/batch', this.handleGetObservationsByIds.bind(this)); app.get('/api/session/:id', this.handleGetSessionById.bind(this)); + app.post('/api/sessions/batch', this.handleGetSessionSummariesByIds.bind(this)); app.post('/api/sdk-sessions/batch', this.handleGetSdkSessionsByIds.bind(this)); app.get('/api/prompt/:id', this.handleGetPromptById.bind(this)); + app.post('/api/prompts/batch', this.handleGetPromptsByIds.bind(this)); // Metadata endpoints app.get('/api/stats', this.handleGetStats.bind(this)); @@ -187,6 +189,40 @@ export class DataRoutes extends BaseRouteHandler { res.json(sessions[0]); }); + /** + * Get session summaries by array of IDs + * POST /api/sessions/batch + * Body: { ids: number[], orderBy?: 'date_desc' | 'date_asc', limit?: number, project?: string } + */ + private handleGetSessionSummariesByIds = this.wrapHandler((req: Request, res: Response): void => { + let { ids, orderBy, limit, project } = req.body; + + // Coerce string-encoded arrays from MCP clients (e.g. "[1,2,3]" or "1,2,3") + if (typeof ids === 'string') { + try { ids = JSON.parse(ids); } catch { ids = ids.split(',').map(Number); } + } + + if (!ids || !Array.isArray(ids)) { + this.badRequest(res, 'ids must be an array of numbers'); + return; + } + + if (ids.length === 0) { + res.json([]); + return; + } + + if (!ids.every((id: any) => typeof id === 'number' && Number.isInteger(id))) { + this.badRequest(res, 'All ids must be integers'); + return; + } + + const store = this.dbManager.getSessionStore(); + const sessions = store.getSessionSummariesByIds(ids, { orderBy, limit, project }); + + res.json(sessions); + }); + /** * Get SDK sessions by SDK session IDs * POST /api/sdk-sessions/batch @@ -229,6 +265,40 @@ export class DataRoutes extends BaseRouteHandler { res.json(prompts[0]); }); + /** + * Get user prompts by array of IDs + * POST /api/prompts/batch + * Body: { ids: number[], orderBy?: 'date_desc' | 'date_asc', limit?: number, project?: string } + */ + private handleGetPromptsByIds = this.wrapHandler((req: Request, res: Response): void => { + let { ids, orderBy, limit, project } = req.body; + + // Coerce string-encoded arrays from MCP clients (e.g. "[1,2,3]" or "1,2,3") + if (typeof ids === 'string') { + try { ids = JSON.parse(ids); } catch { ids = ids.split(',').map(Number); } + } + + if (!ids || !Array.isArray(ids)) { + this.badRequest(res, 'ids must be an array of numbers'); + return; + } + + if (ids.length === 0) { + res.json([]); + return; + } + + if (!ids.every((id: any) => typeof id === 'number' && Number.isInteger(id))) { + this.badRequest(res, 'All ids must be integers'); + return; + } + + const store = this.dbManager.getSessionStore(); + const prompts = store.getUserPromptsByIds(ids, { orderBy, limit, project }); + + res.json(prompts); + }); + /** * Get database statistics (with worker metadata) */