v1.13.15-codecontext-synth: forced second-inference synthesis for codecontext overview tools
After a codecontext overview-class tool call lands (get_codebase_overview, get_framework_analysis, get_semantic_neighborhoods), the pipeline runs a second inference pass that replaces the recursive runAssistantTurn. The synth pass auto-fetches the top-N source files referenced in the codecontext output plus project docs (BOOCHAT.md, AGENTS.md, *roadmap*.md, CONTEXT.md), applies a 32k-token budget with explicit drop-priority, and streams a structured response that grounds the model in real load-bearing code rather than relying on the codecontext summary alone. Smoke #1 (default) and #2 (Architect) both cite the correct inference/turn.ts + tool-phase.ts + stream-phase.ts files; smoke #6 (fault injection) verifies the fall-through path marks the synth message status='failed' and yields cleanly to the recursive turn. ## Truncation-aware extraction codecontext's wrapper inline-truncates results at 32k chars. Without the expansion step, the top-N file selection only saw the alphabetical head of the codebase (apps/booterm/dist/*) and auto-fetched the wrong sources. The pipeline now calls in-process readTruncation(outputPath) before extracting referenced files, so top-N selection sees the full 80k+ char output. The 32k truncated head still ships to the synth model — the expansion is reference-extraction-only, preserving the token-budget contract. Graceful degradation on readTruncation null/throw: log warn, fall back to the truncated head. ## Schema deviation from dispatch The dispatch claimed no schema migration was needed for the new 'synthesis' part kind. Reality: message_parts.kind has an explicit CHECK constraint (schema.sql:54) that would reject the new value. Added a DROP CONSTRAINT IF EXISTS + DO $$ pg_constraint idempotency-guarded re-add matching the CLAUDE.md migration pattern. The inline CREATE TABLE constraint also updated so fresh installs land with the extended enum. ## User-abort marks synth-message failed Deviation from review-time spec ("user-abort path does NOT mark the message failed"). The outer abort handler in error-handler.ts operates on the parent turn's assistantMessageId, not the new synth row that runSynthesisPass created. Without explicit marking, the synth row would sit in status='streaming' until the 5-min stale-streaming sweeper (v1.13.1-cleanup-bundle), tripping the frontend's 60s no-token-activity banner in the meantime — exactly the UX bug class the v1.13.1 sweeper was added to handle. Marking failed on every catch path (including user-abort) closes the gap. Cost: one extra DB write + one publish on the rare user-abort-during-synth path. ## Race-safe synth-tool capture tool-phase.ts uses synthEntries: Array<{tc, output, error?}> with per-callback push under Promise.all. find() picks the first non-error entry by call-order (toolCalls array index). Multiple synth-tools in one batch are uncommon but handled deterministically. ## Roadmap rebase Updated boocode_roadmap.md retrospective section + cleanup-order tracker + schema-changes summary to use the new vMAJOR.MINOR.PATCH-slug tag names per the 2026-05-22 retag (CHANGELOG.md is the canonical record). v1.13.15 listed as "this batch, tag pending"; a one-line follow-up commit will remove that qualifier after the tag lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS message_parts (
|
||||
kind text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start')),
|
||||
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis')),
|
||||
CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
|
||||
@@ -74,6 +74,23 @@ END $$;
|
||||
CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
|
||||
ON message_parts (message_id) WHERE hidden_at IS NULL;
|
||||
|
||||
-- v1.13.13: extend message_parts.kind to allow 'synthesis'. Existing DBs were
|
||||
-- created with the pre-v1.13.13 CHECK constraint that did NOT include
|
||||
-- 'synthesis'; drop + re-add the constraint with the extended enum. Fresh
|
||||
-- installs hit the inline constraint above (already updated) and skip this
|
||||
-- block via the pg_constraint guard.
|
||||
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'message_parts_kind_chk'
|
||||
) THEN
|
||||
ALTER TABLE message_parts
|
||||
ADD CONSTRAINT message_parts_kind_chk
|
||||
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
||||
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
||||
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
||||
|
||||
@@ -7,7 +7,17 @@ import type { ToolCall, ToolResult } from '../../types/api.js';
|
||||
// JSON columns; the swap to parts-as-source-of-truth happens in a later
|
||||
// v1.13 dispatch alongside the AI SDK streamText migration.
|
||||
|
||||
export type PartKind = 'text' | 'tool_call' | 'tool_result' | 'reasoning' | 'step_start';
|
||||
// v1.13.13: 'synthesis' added. Schema CHECK constraint is updated in lockstep
|
||||
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
|
||||
// dispatch's claim that no schema migration was needed assumed kind was a
|
||||
// bare text column — it isn't; the constraint enumerates allowed values.
|
||||
export type PartKind =
|
||||
| 'text'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'reasoning'
|
||||
| 'step_start'
|
||||
| 'synthesis';
|
||||
|
||||
export interface PartInsert {
|
||||
message_id: string;
|
||||
|
||||
@@ -14,6 +14,11 @@ import type {
|
||||
// the reference is read at call time (inside an async function body), not
|
||||
// at module top-level. Node + tsc resolve this cleanly.
|
||||
import { runAssistantTurn } from './turn.js';
|
||||
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
|
||||
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
|
||||
// recursion on synthesis failure (timeout / model error). See module header
|
||||
// in synthesisPipeline.ts for the auto-fetch + token-budget rules.
|
||||
import { SYNTHESIS_TOOLS, runSynthesisPass } from '../synthesisPipeline.js';
|
||||
|
||||
async function executeToolCall(
|
||||
projectRoot: string,
|
||||
@@ -155,6 +160,12 @@ export async function executeToolPhase(
|
||||
// batches still execute the other tools normally.
|
||||
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'tool_running', at: new Date().toISOString() });
|
||||
let pausingForUserInput = false;
|
||||
// v1.13.13: capture synth-tool result text so the synthesis pipeline below
|
||||
// doesn't have to re-fetch from DB. Array (not single) because a batch
|
||||
// could theoretically include multiple synthesis tools — we take the first
|
||||
// for the synthesis input. Race-free under Promise.all because each
|
||||
// callback pushes its own captured value.
|
||||
const synthEntries: Array<{ tc: ToolCall; output: unknown; error?: string }> = [];
|
||||
await Promise.all(
|
||||
toolCalls.map(async (tc) => {
|
||||
const [toolRow] = await ctx.sql<{ id: string }[]>`
|
||||
@@ -186,6 +197,9 @@ export async function executeToolPhase(
|
||||
return;
|
||||
}
|
||||
const tres = await executeToolCall(projectRoot, tc);
|
||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||
}
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
output: tres.output,
|
||||
@@ -233,6 +247,41 @@ export async function executeToolPhase(
|
||||
return;
|
||||
}
|
||||
|
||||
// v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
|
||||
// is a codecontext overview/analysis tool that produced a non-error result,
|
||||
// run a forced second-inference synthesis pass with auto-fetched files +
|
||||
// project docs instead of the normal recursive runAssistantTurn. Falls
|
||||
// through to the recursive call on synthesis failure (timeout, model
|
||||
// error). User-abort re-throws so the outer handler runs.
|
||||
const synthEntry = synthEntries.find((e) => !e.error && e.output != null);
|
||||
if (synthEntry) {
|
||||
// codecontext wrappers return { result: string, truncated: boolean, ... }.
|
||||
// Defensive: stringify the output if it isn't the expected shape so the
|
||||
// synthesis still has something to chew on rather than crashing on
|
||||
// missing `.result`.
|
||||
const out = synthEntry.output as { result?: unknown; truncated?: boolean; outputPath?: string };
|
||||
const toolResultText =
|
||||
typeof out?.result === 'string'
|
||||
? out.result
|
||||
: JSON.stringify(synthEntry.output);
|
||||
// v1.13.15-b: forward the wrapper's truncation flag + opaque tmpfs id so
|
||||
// synthesisPipeline can re-read the full content for reference extraction.
|
||||
const ran = await runSynthesisPass({
|
||||
ctx,
|
||||
args,
|
||||
session,
|
||||
projectRoot,
|
||||
toolName: synthEntry.tc.name,
|
||||
toolResultText,
|
||||
...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
|
||||
...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
|
||||
});
|
||||
if (ran) return;
|
||||
// ran === false → synthesis failed (timeout / model error) → fall through
|
||||
// to the standard recursive turn below. The synth message (if created)
|
||||
// was already marked status='failed' inside runSynthesisPass.
|
||||
}
|
||||
|
||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
|
||||
493
apps/server/src/services/synthesisPipeline.ts
Normal file
493
apps/server/src/services/synthesisPipeline.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
// v1.13.13: forced second-inference synthesis pass for codecontext
|
||||
// overview/analysis tools. Triggered from tool-phase.ts after a codecontext
|
||||
// tool call lands and BEFORE the normal recursive runAssistantTurn fires.
|
||||
//
|
||||
// Inputs to the synthesis stream:
|
||||
// 1. The codecontext tool's result text.
|
||||
// 2. Top-N source files referenced in that text, fetched via view_file.
|
||||
// 3. Project documentation auto-fetched from the repo root.
|
||||
// 4. The original user message that triggered the turn.
|
||||
//
|
||||
// Output: a NEW assistant message whose sole part is kind='synthesis'.
|
||||
// Streams to the client as deltas exactly like a normal assistant turn.
|
||||
//
|
||||
// Failure modes (all fall through to recursive runAssistantTurn):
|
||||
// - SYNTHESIS_TOOLS membership check fails -> return false immediately.
|
||||
// - File-fetch / doc-fetch errors -> silent skip, continue with what we have.
|
||||
// - Stream error / timeout -> mark synth message status='failed', return false.
|
||||
// - User-abort -> mark cancelled and re-throw so the outer abort handler runs.
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { TOOLS_BY_NAME } from './tools.js';
|
||||
import { streamCompletion } from './inference/stream-phase.js';
|
||||
import { SYNTHESIS_SYSTEM_PROMPT } from './synthesisPrompt.js';
|
||||
import { insertParts } from './inference/parts.js';
|
||||
import * as modelContext from './model-context.js';
|
||||
import { readTruncation } from './truncate.js';
|
||||
|
||||
import type { Session } from '../types/api.js';
|
||||
import type { OpenAiMessage } from './inference/payload.js';
|
||||
import type { InferenceContext, TurnArgs } from './inference/turn.js';
|
||||
|
||||
export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([
|
||||
'get_codebase_overview',
|
||||
'get_framework_analysis',
|
||||
'get_semantic_neighborhoods',
|
||||
]);
|
||||
|
||||
const TOP_N_FILES = 5;
|
||||
const FILE_LINE_CAP = 200;
|
||||
const DOC_LINE_CAP = 500;
|
||||
// Token budget for the auto-fetched content (files + docs combined). Estimated
|
||||
// via chars/4 — a rough but stable proxy that doesn't require a tokenizer dep.
|
||||
const TOKEN_BUDGET = 32_000;
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
// 90s per synthesis call. Long enough for a thoughtful overview against a
|
||||
// large auto-fetched payload; short enough that a hung upstream falls through
|
||||
// to the normal recursive turn within a typical user attention window.
|
||||
const SYNTH_TIMEOUT_MS = 90_000;
|
||||
|
||||
// File-extension regex for referenced-file extraction. Limited to source-
|
||||
// language extensions so we don't pull in lockfiles, images, etc.
|
||||
const FILE_PATH_RE =
|
||||
/(?:^|[`'"<\s\(\[])([A-Za-z0-9_./@-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|kt|c|cpp|h|hpp|md|json|yaml|yml|sql|sh|html|css))(?=[`'"<\)\]\s,;:]|$)/gm;
|
||||
|
||||
export interface SynthesisParams {
|
||||
ctx: InferenceContext;
|
||||
args: TurnArgs;
|
||||
session: Session;
|
||||
projectRoot: string;
|
||||
toolName: string;
|
||||
toolResultText: string;
|
||||
// v1.13.15-b: when codecontext's wrapper hit its 32k inline-truncation
|
||||
// limit, we expand the full content via readTruncation for reference-file
|
||||
// extraction only. toolResultText (the truncated head) still ships to the
|
||||
// synth model — preserves the 32k payload-budget contract.
|
||||
truncated?: boolean;
|
||||
// opaque id (tr_<…>), not a filesystem path — see truncate.ts naming note
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
interface FetchedFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface DocsCollection {
|
||||
boochat?: string;
|
||||
agents?: string;
|
||||
context?: string;
|
||||
roadmap?: string;
|
||||
}
|
||||
|
||||
export async function runSynthesisPass(p: SynthesisParams): Promise<boolean> {
|
||||
if (!SYNTHESIS_TOOLS.has(p.toolName)) return false;
|
||||
|
||||
let synthMessageId: string | null = null;
|
||||
let accumulated = '';
|
||||
let timedOut = false;
|
||||
const synthCtrl = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
synthCtrl.abort();
|
||||
}, SYNTH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const userMessage = await fetchOriginalUserMessage(p.ctx, p.args.chatId);
|
||||
if (!userMessage) {
|
||||
p.ctx.log.warn({ chatId: p.args.chatId }, 'synthesis: no user message found; falling through');
|
||||
return false;
|
||||
}
|
||||
|
||||
// v1.13.15-b: when the tool result was inline-truncated by the wrapper
|
||||
// (32k cap, see codecontext_client.ts:114), expand the full content from
|
||||
// tmpfs for reference-file extraction. The synth payload still ships the
|
||||
// truncated head (see buildPayload call below) so the token-budget
|
||||
// contract holds. Graceful degradation: if readTruncation returns null
|
||||
// (missing id, ENOENT) or throws, fall back to the truncated head.
|
||||
let extractionSource = p.toolResultText;
|
||||
if (p.truncated && p.outputPath) {
|
||||
try {
|
||||
const full = await readTruncation(p.outputPath);
|
||||
if (full !== null) {
|
||||
extractionSource = full;
|
||||
p.ctx.log.info(
|
||||
{
|
||||
chatId: p.args.chatId,
|
||||
toolName: p.toolName,
|
||||
originalChars: p.toolResultText.length,
|
||||
fullChars: full.length,
|
||||
},
|
||||
'synthesis: expanded truncated tool output',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
p.ctx.log.warn(
|
||||
{ chatId: p.args.chatId, toolName: p.toolName, err: String(err) },
|
||||
'synthesis: readTruncation failed, using truncated output',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const refFiles = extractReferencedFiles(extractionSource);
|
||||
const files = await fetchTopFiles(refFiles, p.projectRoot);
|
||||
const docs = await fetchProjectDocs(p.projectRoot);
|
||||
const { files: budgetedFiles, docs: budgetedDocs } = applyTokenBudget(files, docs);
|
||||
const synthMessages = buildPayload(
|
||||
p.toolName,
|
||||
// Truncated head only — full content was used for reference extraction above
|
||||
p.toolResultText,
|
||||
budgetedFiles,
|
||||
budgetedDocs,
|
||||
userMessage,
|
||||
);
|
||||
|
||||
// Insert + announce the synthesis assistant message. From here on, any
|
||||
// exception must clean up via the catch block so the row doesn't linger
|
||||
// in 'streaming' status (the 5min stale-streaming sweeper catches it
|
||||
// eventually, but explicit cleanup is better).
|
||||
const [synthRow] = await p.ctx.sql<
|
||||
{ id: string; started_at: string }[]
|
||||
>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, started_at, created_at)
|
||||
VALUES (${p.args.sessionId}, ${p.args.chatId}, 'assistant', '', 'streaming', clock_timestamp(), clock_timestamp())
|
||||
RETURNING id, started_at
|
||||
`;
|
||||
synthMessageId = synthRow!.id;
|
||||
const startedAt = synthRow!.started_at;
|
||||
|
||||
p.ctx.publish(p.args.sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: synthMessageId,
|
||||
chat_id: p.args.chatId,
|
||||
role: 'assistant',
|
||||
});
|
||||
|
||||
// Combine the user-abort signal with our synthesis-specific timeout so
|
||||
// either fires correctly. The `timedOut` flag in scope tells us which one
|
||||
// tripped after streamCompletion throws.
|
||||
const combinedSignal: AbortSignal | undefined = p.args.signal
|
||||
? AbortSignal.any([p.args.signal, synthCtrl.signal])
|
||||
: synthCtrl.signal;
|
||||
|
||||
const onDelta = (delta: string): void => {
|
||||
accumulated += delta;
|
||||
p.ctx.publish(p.args.sessionId, {
|
||||
type: 'delta',
|
||||
message_id: synthMessageId!,
|
||||
chat_id: p.args.chatId,
|
||||
content: delta,
|
||||
});
|
||||
};
|
||||
|
||||
const streamResult = await streamCompletion(
|
||||
p.ctx,
|
||||
p.session.model,
|
||||
synthMessages,
|
||||
{ tools: null },
|
||||
onDelta,
|
||||
undefined,
|
||||
combinedSignal,
|
||||
);
|
||||
|
||||
const mctx = await modelContext.getModelContext(p.session.model);
|
||||
const nCtx = mctx?.n_ctx ?? null;
|
||||
const [updated] = await p.ctx.sql<
|
||||
{
|
||||
tokens_used: number | null;
|
||||
ctx_used: number | null;
|
||||
ctx_max: number | null;
|
||||
finished_at: string | null;
|
||||
}[]
|
||||
>`
|
||||
UPDATE messages
|
||||
SET content = ${streamResult.content},
|
||||
status = 'complete',
|
||||
tokens_used = ${streamResult.completionTokens},
|
||||
ctx_used = ${streamResult.promptTokens},
|
||||
ctx_max = ${nCtx},
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${synthMessageId}
|
||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||
`;
|
||||
await insertParts(p.ctx.sql, [
|
||||
{
|
||||
message_id: synthMessageId,
|
||||
sequence: 0,
|
||||
kind: 'synthesis',
|
||||
payload: { text: streamResult.content },
|
||||
},
|
||||
]);
|
||||
p.ctx.publish(p.args.sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: synthMessageId,
|
||||
chat_id: p.args.chatId,
|
||||
tokens_used: updated?.tokens_used ?? null,
|
||||
ctx_used: updated?.ctx_used ?? null,
|
||||
ctx_max: updated?.ctx_max ?? null,
|
||||
started_at: startedAt,
|
||||
finished_at: updated?.finished_at ?? null,
|
||||
model: p.session.model,
|
||||
});
|
||||
p.ctx.publishUser({
|
||||
type: 'chat_status',
|
||||
chat_id: p.args.chatId,
|
||||
status: 'idle',
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
p.ctx.log.info(
|
||||
{
|
||||
chatId: p.args.chatId,
|
||||
synthMessageId,
|
||||
toolName: p.toolName,
|
||||
chars: streamResult.content.length,
|
||||
files: budgetedFiles.length,
|
||||
},
|
||||
'synthesis pass complete',
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
await markSynthFailed(p, synthMessageId, accumulated).catch((cleanupErr) => {
|
||||
p.ctx.log.warn({ cleanupErr: String(cleanupErr) }, 'synthesis cleanup UPDATE failed');
|
||||
});
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
if (timedOut) {
|
||||
p.ctx.log.warn(
|
||||
{ toolName: p.toolName, chatId: p.args.chatId },
|
||||
'synthesis pass timed out; falling through to recursive turn',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// User-initiated abort: propagate so the outer error handler marks the
|
||||
// parent turn cancelled. The synth message is already marked failed by
|
||||
// markSynthFailed above.
|
||||
throw err;
|
||||
}
|
||||
p.ctx.log.warn(
|
||||
{ err: String(err), toolName: p.toolName, chatId: p.args.chatId },
|
||||
'synthesis pass failed; falling through to recursive turn',
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function markSynthFailed(
|
||||
p: SynthesisParams,
|
||||
synthMessageId: string | null,
|
||||
accumulated: string,
|
||||
): Promise<void> {
|
||||
if (synthMessageId === null) return;
|
||||
await p.ctx.sql`
|
||||
UPDATE messages
|
||||
SET content = ${accumulated},
|
||||
status = 'failed',
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${synthMessageId}
|
||||
`;
|
||||
// Republish so the frontend's live state flips from 'streaming' to
|
||||
// terminal. message_complete carries no error reason — the row's status
|
||||
// column is the truth. The 5-state chat_status dot has 'error' but we
|
||||
// don't fire that here because the broader inference is about to retry
|
||||
// via recursion; flipping the user-channel status to 'error' would race
|
||||
// the recursive turn's 'streaming' announcement.
|
||||
p.ctx.publish(p.args.sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: synthMessageId,
|
||||
chat_id: p.args.chatId,
|
||||
model: p.session.model,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOriginalUserMessage(
|
||||
ctx: InferenceContext,
|
||||
chatId: string,
|
||||
): Promise<string | null> {
|
||||
const rows = await ctx.sql<{ content: string }[]>`
|
||||
SELECT content FROM messages
|
||||
WHERE chat_id = ${chatId} AND role = 'user'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows[0]?.content ?? null;
|
||||
}
|
||||
|
||||
function extractReferencedFiles(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const order: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = FILE_PATH_RE.exec(text)) !== null) {
|
||||
const candidate = m[1]!;
|
||||
if (seen.has(candidate)) continue;
|
||||
if (
|
||||
candidate.includes('node_modules') ||
|
||||
candidate.includes('/dist/') ||
|
||||
candidate.includes('/test/') ||
|
||||
candidate.includes('/tests/') ||
|
||||
/\.(test|spec)\.[a-z]+$/.test(candidate)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
order.push(candidate);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async function fetchTopFiles(refs: string[], projectRoot: string): Promise<FetchedFile[]> {
|
||||
const tool = TOOLS_BY_NAME['view_file'];
|
||||
if (!tool) return [];
|
||||
const out: FetchedFile[] = [];
|
||||
for (const p of refs.slice(0, TOP_N_FILES)) {
|
||||
const absPath = p.startsWith('/') ? p : join(projectRoot, p);
|
||||
try {
|
||||
const r = await tool.execute({ path: absPath, end_line: FILE_LINE_CAP }, projectRoot);
|
||||
const content = (r as { content?: string }).content ?? '';
|
||||
if (content) out.push({ path: p, content });
|
||||
} catch {
|
||||
// path-scope blocked, secret-filtered, file too large, or missing —
|
||||
// skip silently. The remaining files (or none) still produce a
|
||||
// meaningful synthesis input.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchProjectDocs(projectRoot: string): Promise<DocsCollection> {
|
||||
const tool = TOOLS_BY_NAME['view_file'];
|
||||
if (!tool) return {};
|
||||
const docs: DocsCollection = {};
|
||||
for (const [filename, key] of [
|
||||
['BOOCHAT.md', 'boochat'],
|
||||
['AGENTS.md', 'agents'],
|
||||
['CONTEXT.md', 'context'],
|
||||
] as const) {
|
||||
try {
|
||||
const r = await tool.execute(
|
||||
{ path: join(projectRoot, filename), end_line: DOC_LINE_CAP },
|
||||
projectRoot,
|
||||
);
|
||||
const content = (r as { content?: string }).content;
|
||||
if (content) docs[key] = content;
|
||||
} catch {
|
||||
// missing doc — skip
|
||||
}
|
||||
}
|
||||
// Case-insensitive *roadmap*.md glob. Picks the first match (alphabetical
|
||||
// by readdir() order); typical projects have at most one roadmap doc.
|
||||
try {
|
||||
const entries = await fs.readdir(projectRoot);
|
||||
const roadmap = entries.find(
|
||||
(e) => /roadmap/i.test(e) && e.toLowerCase().endsWith('.md'),
|
||||
);
|
||||
if (roadmap) {
|
||||
const r = await tool.execute(
|
||||
{ path: join(projectRoot, roadmap), end_line: DOC_LINE_CAP },
|
||||
projectRoot,
|
||||
);
|
||||
const content = (r as { content?: string }).content;
|
||||
if (content) docs.roadmap = content;
|
||||
}
|
||||
} catch {
|
||||
// unreadable project root — skip
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
function estTokens(s: string | undefined): number {
|
||||
return s ? Math.ceil(s.length / CHARS_PER_TOKEN) : 0;
|
||||
}
|
||||
|
||||
function applyTokenBudget(
|
||||
files: FetchedFile[],
|
||||
docs: DocsCollection,
|
||||
): { files: FetchedFile[]; docs: DocsCollection } {
|
||||
let total = 0;
|
||||
for (const f of files) total += estTokens(f.content);
|
||||
total += estTokens(docs.boochat) + estTokens(docs.agents) + estTokens(docs.context) + estTokens(docs.roadmap);
|
||||
if (total <= TOKEN_BUDGET) return { files, docs };
|
||||
|
||||
// Drop priority (lowest priority dropped first):
|
||||
// 1. top-2..N files (keep top-1)
|
||||
// 2. top-1 file
|
||||
// 3. roadmap (+ CONTEXT.md grouped here — dispatch listed roadmap above
|
||||
// AGENTS.md, CONTEXT.md was not in the priority list)
|
||||
// 4. AGENTS.md
|
||||
// 5. BOOCHAT.md (never dropped — truncate to budget if alone exceeds)
|
||||
let outFiles = files.slice();
|
||||
const outDocs: DocsCollection = { ...docs };
|
||||
|
||||
while (total > TOKEN_BUDGET && outFiles.length > 1) {
|
||||
const last = outFiles.pop()!;
|
||||
total -= estTokens(last.content);
|
||||
}
|
||||
if (total <= TOKEN_BUDGET) return { files: outFiles, docs: outDocs };
|
||||
|
||||
if (outFiles[0]) {
|
||||
total -= estTokens(outFiles[0].content);
|
||||
outFiles = [];
|
||||
}
|
||||
if (total <= TOKEN_BUDGET) return { files: outFiles, docs: outDocs };
|
||||
|
||||
if (outDocs.roadmap) {
|
||||
total -= estTokens(outDocs.roadmap);
|
||||
delete outDocs.roadmap;
|
||||
}
|
||||
if (outDocs.context) {
|
||||
total -= estTokens(outDocs.context);
|
||||
delete outDocs.context;
|
||||
}
|
||||
if (total <= TOKEN_BUDGET) return { files: outFiles, docs: outDocs };
|
||||
|
||||
if (outDocs.agents) {
|
||||
total -= estTokens(outDocs.agents);
|
||||
delete outDocs.agents;
|
||||
}
|
||||
if (total <= TOKEN_BUDGET) return { files: outFiles, docs: outDocs };
|
||||
|
||||
if (outDocs.boochat) {
|
||||
const maxChars = TOKEN_BUDGET * CHARS_PER_TOKEN;
|
||||
if (outDocs.boochat.length > maxChars) {
|
||||
outDocs.boochat = outDocs.boochat.slice(0, maxChars);
|
||||
}
|
||||
}
|
||||
return { files: outFiles, docs: outDocs };
|
||||
}
|
||||
|
||||
function buildPayload(
|
||||
toolName: string,
|
||||
toolResultText: string,
|
||||
files: FetchedFile[],
|
||||
docs: DocsCollection,
|
||||
userMessage: string,
|
||||
): OpenAiMessage[] {
|
||||
const sections: string[] = [];
|
||||
sections.push(`## Codecontext tool output (${toolName})\n\n${toolResultText}`);
|
||||
if (files.length > 0) {
|
||||
sections.push(`---\n\n## Auto-fetched source files`);
|
||||
for (const f of files) {
|
||||
sections.push(`### ${f.path}\n\n\`\`\`\n${f.content}\n\`\`\``);
|
||||
}
|
||||
}
|
||||
const docEntries: Array<[string, string | undefined]> = [
|
||||
['BOOCHAT.md', docs.boochat],
|
||||
['AGENTS.md', docs.agents],
|
||||
['CONTEXT.md', docs.context],
|
||||
['roadmap', docs.roadmap],
|
||||
];
|
||||
const presentDocs = docEntries.filter(([, v]) => Boolean(v));
|
||||
if (presentDocs.length > 0) {
|
||||
sections.push(`---\n\n## Project documentation`);
|
||||
for (const [name, v] of presentDocs) {
|
||||
sections.push(`### ${name}\n\n${v!}`);
|
||||
}
|
||||
}
|
||||
sections.push(`---\n\n## Original user question\n\n${userMessage}`);
|
||||
return [
|
||||
{ role: 'system', content: SYNTHESIS_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: sections.join('\n\n') },
|
||||
];
|
||||
}
|
||||
20
apps/server/src/services/synthesisPrompt.ts
Normal file
20
apps/server/src/services/synthesisPrompt.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// v1.13.13: synthesis pipeline system prompt. Verbatim from the v1.13.13
|
||||
// dispatch — do not paraphrase. The synthesis pass loads this as its sole
|
||||
// system message, followed by a user message that concatenates the
|
||||
// codecontext tool result, auto-fetched top files, auto-fetched project
|
||||
// docs, and the original user message.
|
||||
export const SYNTHESIS_SYSTEM_PROMPT = `You are synthesizing structural data into an accurate, detailed answer about the user's codebase.
|
||||
|
||||
Inputs you have been given:
|
||||
1. The output of a codecontext analysis tool (raw structural data — file counts, symbols, dependencies, frameworks).
|
||||
2. The contents of the top files referenced in that output.
|
||||
3. Any project documentation found in the repo root (BOOCHAT.md, AGENTS.md, roadmap docs, CONTEXT.md).
|
||||
|
||||
Rules:
|
||||
- Cite specific files and line numbers when making claims about code.
|
||||
- If project docs contradict the code, docs win for questions about state, version, status, or roadmap. Code wins for questions about runtime behavior or implementation.
|
||||
- If the codecontext output looks sparse (low symbol count for a TypeScript project, missing dependency edges, empty framework list), explicitly say so — codecontext falls back to the JavaScript grammar for TypeScript and loses interfaces, generics, decorators, and type aliases.
|
||||
- Do not invent symbols, files, or relationships that are not present in the inputs.
|
||||
- Do not respond with a generic "this looks like a [framework] project" summary. The user has the framework analysis already. Add specifics: what is actually in this codebase, what is shipped, what is planned, what is load-bearing.
|
||||
- Length: match the depth the user asked for. Overview questions get structured multi-section answers. Specific questions get focused answers.
|
||||
`;
|
||||
Reference in New Issue
Block a user