Merge main into thedotmack/file-read-timeline-inject

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-05 03:00:06 -07:00
95 changed files with 11818 additions and 5886 deletions
+2 -2
View File
@@ -49,8 +49,8 @@ export class SDKAgent {
// Find Claude executable
const claudePath = this.findClaudeExecutable();
// Get model ID and disallowed tools
const modelId = this.getModelId();
// Get model ID (tier routing override takes precedence)
const modelId = session.modelOverride || this.getModelId();
// Memory agent is OBSERVER ONLY - no tools allowed
const disallowedTools = [
'Bash', // Prevent infinite loops
@@ -68,6 +68,19 @@ export async function processAgentResponse(
const observations = parseObservations(text, session.contentSessionId);
const summary = parseSummary(text, session.sessionDbId);
if (
text.trim() &&
observations.length === 0 &&
!summary &&
!/<observation>|<summary>|<skip_summary\b/.test(text)
) {
const preview = text.length > 200 ? `${text.slice(0, 200)}...` : text;
logger.warn('PARSER', `${agentName} returned non-XML response; observation content was discarded`, {
sessionId: session.sessionDbId,
preview
});
}
// Convert nullable fields to empty strings for storeSummary (if summary exists)
const summaryForStore = normalizeSummaryForStorage(summary);
@@ -38,6 +38,7 @@ export class SearchRoutes extends BaseRouteHandler {
app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this));
app.get('/api/context/preview', this.handleContextPreview.bind(this));
app.get('/api/context/inject', this.handleContextInject.bind(this));
app.post('/api/context/semantic', this.handleSemanticContext.bind(this));
// Timeline and help endpoints
app.get('/api/timeline/by-query', this.handleGetTimelineByQuery.bind(this));
@@ -246,6 +247,54 @@ export class SearchRoutes extends BaseRouteHandler {
res.send(contextText);
});
/**
* Semantic context search for per-prompt injection
* POST /api/context/semantic { q, project?, limit? }
*
* Queries Chroma for observations semantically similar to the user's prompt.
* Returns compact markdown for injection as additionalContext.
*/
private handleSemanticContext = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const query = (req.body?.q || req.query.q) as string;
const project = (req.body?.project || req.query.project) as string;
const limit = Math.min(Math.max(parseInt(String(req.body?.limit || req.query.limit || '5'), 10) || 5, 1), 20);
if (!query || query.length < 20) {
res.json({ context: '', count: 0 });
return;
}
try {
const result = await this.searchManager.search({
query,
type: 'observations',
project,
limit: String(limit),
format: 'json'
});
const observations = (result as any)?.observations || [];
if (!observations.length) {
res.json({ context: '', count: 0 });
return;
}
// Format as compact markdown for context injection
const lines: string[] = ['## Relevant Past Work (semantic match)\n'];
for (const obs of observations.slice(0, limit)) {
const date = obs.created_at?.slice(0, 10) || '';
lines.push(`### ${obs.title || 'Observation'} (${date})`);
if (obs.narrative) lines.push(obs.narrative);
lines.push('');
}
res.json({ context: lines.join('\n'), count: observations.length });
} catch (error) {
logger.error('SEARCH', 'Semantic context query failed', {}, error as Error);
res.json({ context: '', count: 0 });
}
});
/**
* Get timeline by query (search first, then get timeline around best match)
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
@@ -106,6 +106,8 @@ export class SessionRoutes extends BaseRouteHandler {
// Start generator if not running
if (!session.generatorPromise) {
// Apply tier routing before starting the generator
this.applyTierRouting(session);
this.spawnInProgress.set(sessionDbId, true);
this.startGeneratorWithProvider(session, selectedProvider, source);
return;
@@ -126,6 +128,7 @@ export class SessionRoutes extends BaseRouteHandler {
session.abortController = new AbortController();
session.lastGeneratorActivity = Date.now();
// Start a fresh generator
this.applyTierRouting(session);
this.spawnInProgress.set(sessionDbId, true);
this.startGeneratorWithProvider(session, selectedProvider, 'stale-recovery');
return;
@@ -283,6 +286,7 @@ export class SessionRoutes extends BaseRouteHandler {
this.crashRecoveryScheduled.delete(sessionDbId);
const stillExists = this.sessionManager.getSession(sessionDbId);
if (stillExists && !stillExists.generatorPromise) {
this.applyTierRouting(stillExists);
this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery');
}
}, backoffMs);
@@ -321,6 +325,7 @@ export class SessionRoutes extends BaseRouteHandler {
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
app.get('/api/sessions/status', this.handleStatusByClaudeId.bind(this));
}
/**
@@ -631,6 +636,39 @@ export class SessionRoutes extends BaseRouteHandler {
res.json({ status: 'queued' });
});
/**
* Get session status by contentSessionId (summarize handler polls this)
* GET /api/sessions/status?contentSessionId=...
*
* Returns queue depth so the Stop hook can wait for summary completion.
*/
private handleStatusByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const contentSessionId = req.query.contentSessionId as string;
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId query parameter');
}
const store = this.dbManager.getSessionStore();
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
const session = this.sessionManager.getSession(sessionDbId);
if (!session) {
res.json({ status: 'not_found', queueLength: 0 });
return;
}
const pendingStore = this.sessionManager.getPendingMessageStore();
const queueLength = pendingStore.getPendingCount(sessionDbId);
res.json({
status: 'active',
sessionDbId,
queueLength,
uptime: Date.now() - session.startTime
});
});
/**
* Complete session by contentSessionId (session-complete hook uses this)
* POST /api/sessions/complete
@@ -669,6 +707,8 @@ export class SessionRoutes extends BaseRouteHandler {
}
// Complete the session (removes from active sessions map)
// Note: The Stop hook (summarize handler) waits for pending work before calling
// this endpoint. No polling here — that's the hook's responsibility.
await this.completionHandler.completeByDbId(sessionDbId);
logger.info('SESSION', 'Session completed via API', {
@@ -777,4 +817,60 @@ export class SessionRoutes extends BaseRouteHandler {
contextInjected
});
});
// Simple tool names that produce low-complexity observations
private static readonly SIMPLE_TOOLS = new Set([
'Read', 'Glob', 'Grep', 'LS', 'ListMcpResourcesTool'
]);
/**
* Apply tier routing: select model based on pending queue complexity.
* - Summarize in queue → summary model (e.g., Opus)
* - All simple tools → simple model (e.g., Haiku)
* - Otherwise → default model (no override)
*/
private applyTierRouting(session: NonNullable<ReturnType<typeof this.sessionManager.getSession>>): void {
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (settings.CLAUDE_MEM_TIER_ROUTING_ENABLED === 'false') {
session.modelOverride = undefined;
return;
}
// Clear stale override before re-evaluating — prevents previous tier
// from persisting when queue composition changes between spawns.
session.modelOverride = undefined;
const pendingStore = this.sessionManager.getPendingMessageStore();
const pending = pendingStore.peekPendingTypes(session.sessionDbId);
if (pending.length === 0) {
session.modelOverride = undefined;
return;
}
const hasSummarize = pending.some(m => m.message_type === 'summarize');
const allSimple = pending.every(m =>
m.message_type === 'observation' && m.tool_name && SessionRoutes.SIMPLE_TOOLS.has(m.tool_name)
);
if (hasSummarize) {
const summaryModel = settings.CLAUDE_MEM_TIER_SUMMARY_MODEL;
if (summaryModel) {
session.modelOverride = summaryModel;
logger.debug('SESSION', `Tier routing: summary model`, {
sessionId: session.sessionDbId, model: summaryModel
});
}
} else if (allSimple) {
const simpleModel = settings.CLAUDE_MEM_TIER_SIMPLE_MODEL;
if (simpleModel) {
session.modelOverride = simpleModel;
logger.debug('SESSION', `Tier routing: simple model`, {
sessionId: session.sessionDbId, model: simpleModel
});
}
} else {
session.modelOverride = undefined;
}
}
}
@@ -24,9 +24,30 @@ export class SessionCompletionHandler {
* Used by DELETE /api/sessions/:id and POST /api/sessions/:id/complete
*/
async completeByDbId(sessionDbId: number): Promise<void> {
// Delete from session manager (aborts SDK agent)
// Delete from session manager (aborts SDK agent via SIGTERM)
await this.sessionManager.deleteSession(sessionDbId);
// Drain orphaned pending messages left by SIGTERM.
// When deleteSession() aborts the generator, pending messages in the queue
// are never processed. Without drain, they stay in 'pending' status forever
// since no future generator will pick them up for a completed session.
// Note: this is best-effort — if a generator outlives the 30s SIGTERM timeout
// (SessionManager.deleteSession), it may enqueue messages after this drain.
// In practice this race is rare (zero orphans over 23 days, 3400+ observations).
try {
const pendingStore = this.sessionManager.getPendingMessageStore();
const drainedCount = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
if (drainedCount > 0) {
logger.warn('SESSION', `Drained ${drainedCount} orphaned pending messages on session completion`, {
sessionId: sessionDbId, drainedCount
});
}
} catch (e) {
logger.debug('SESSION', 'Failed to drain pending queue on session completion', {
sessionId: sessionDbId, error: e instanceof Error ? e.message : String(e)
});
}
// Broadcast session completed event
this.eventBroadcaster.broadcastSessionCompleted(sessionDbId);
}