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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user