Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/cli/handlers/session-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
57 changes: 50 additions & 7 deletions src/servers/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,14 @@ async function ensureWorkerConnection(): Promise<boolean> {
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
);
Expand All @@ -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',
Expand All @@ -271,9 +274,11 @@ NEVER fetch full details without filtering first. 10x token savings.`,
\`timeline(anchor=<ID>, 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.`
}]
Expand Down Expand Up @@ -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: {
Expand All @@ -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.',
Expand Down
5 changes: 3 additions & 2 deletions src/services/context/ObservationCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/services/context/formatters/AgentFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
''
];
}
Expand Down Expand Up @@ -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.`
];
}

Expand Down
2 changes: 1 addition & 1 deletion src/services/context/formatters/HumanFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
''
Expand Down
4 changes: 2 additions & 2 deletions src/services/context/sections/TimelineRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ export function groupTimelineByDay(timeline: TimelineItem[]): Map<string, Timeli
itemsByDay.get(day)!.push(item);
}

// Sort days chronologically
// Sort days newest-first so truncated output shows recent data
const sortedEntries = Array.from(itemsByDay.entries()).sort((a, b) => {
const aDate = new Date(a[0]).getTime();
const bDate = new Date(b[0]).getTime();
return aDate - bDate;
return bDate - aDate;
});

return new Map(sortedEntries);
Expand Down
19 changes: 14 additions & 5 deletions src/services/smart-file-read/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,17 +319,25 @@ export function resolveGrammarPathWithFallback(language: string, projectRoot?: s

// --- Query patterns (declarative symbol extraction) ---

const QUERIES: Record<string, string> = {
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<string, string> = {
// 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: `
Expand Down Expand Up @@ -490,9 +498,10 @@ const QUERIES: Record<string, string> = {
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";
Expand Down
8 changes: 5 additions & 3 deletions src/services/sqlite/observations/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Historical observations under old wrong key are orphaned

The new fallback ('unknown-project') correctly prevents future writes from using the worker's process.cwd(), but any observations already stored under the incorrect cwd-derived project key (from before this fix) are still stranded — they won't surface in queries scoped to the user's real project key. Worth documenting a one-time migration path (or at least a note in the changelog) so users who hit #1918 before this fix know how to recover those records.

Fix in Claude Code

// 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);
Expand Down
70 changes: 70 additions & 0 deletions src/services/worker/http/routes/DataRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 limit not validated before SQL interpolation

Both new batch handlers (handleGetSessionSummariesByIds and handleGetPromptsByIds) extract limit from req.body and pass it straight through to the SQL query as LIMIT ${limit}. A non-integer value (e.g. "10; SELECT 1") would be interpolated into the prepared-statement template before this.db.prepare() is called. While bun:sqlite rejects multi-statement strings at prepare-time, it will also throw an unhandled error for any non-numeric value, since the handler has no integer guard on limit.

The existing handleGetObservationsByIds follows the same pattern — consider adding a consistent integer check when limit is truthy:

Suggested change
* POST /api/sdk-sessions/batch
if (limit !== undefined && (!Number.isInteger(Number(limit)) || Number(limit) < 1)) {
this.badRequest(res, 'limit must be a positive integer');
return;
}

Fix in Claude Code

Expand Down Expand Up @@ -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)
*/
Expand Down
Loading