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:
2026-05-22 20:08:47 +00:00
parent 0fa46cd06c
commit 3992a9fcb7
8 changed files with 940 additions and 14 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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())

View 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') },
];
}

View 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.
`;