diff --git a/src/core/contextual-retrieval-service.ts b/src/core/contextual-retrieval-service.ts
index 0e22b7746..a65c8e074 100644
--- a/src/core/contextual-retrieval-service.ts
+++ b/src/core/contextual-retrieval-service.ts
@@ -54,6 +54,7 @@ import {
import {
generatePerChunkSynopsis,
SYNOPSIS_PROMPT_VERSION,
+ SYNOPSIS_DOC_MAX_CHARS,
type GeneratePerChunkSynopsisResult,
} from './page-summary.ts';
import {
@@ -103,8 +104,17 @@ function getEmbeddingModelTag(): string {
export function computeCorpusGeneration(args: {
crMode: CRMode;
haikuModel: string;
+ /**
+ * Resolved `SYNOPSIS_DOC_MAX_CHARS` for per_chunk_synopsis runs. When
+ * present, folded into the hash so changes to
+ * `GBRAIN_SYNOPSIS_DOC_MAX_CHARS` invalidate the prior cache cleanly.
+ * Omit for `crMode !== 'per_chunk_synopsis'` — title / none modes
+ * don't consult the cap and the field stays out of the hash for
+ * back-compat with pre-cap embeddings.
+ */
+ synopsisDocMaxChars?: number;
}): string {
- return createHash('sha256')
+ const h = createHash('sha256')
.update(args.crMode)
.update('|')
.update(String(SYNOPSIS_PROMPT_VERSION))
@@ -113,9 +123,11 @@ export function computeCorpusGeneration(args: {
.update('|')
.update(String(TITLE_WRAPPER_VERSION))
.update('|')
- .update(getEmbeddingModelTag())
- .digest('hex')
- .slice(0, 16);
+ .update(getEmbeddingModelTag());
+ if (args.synopsisDocMaxChars !== undefined) {
+ h.update('|doc_cap=').update(String(args.synopsisDocMaxChars));
+ }
+ return h.digest('hex').slice(0, 16);
}
/**
@@ -253,7 +265,11 @@ export async function reembedPageWithContextualRetrieval(
args.pageSlug,
args.sourceId,
resolution.mode,
- computeCorpusGeneration({ crMode: resolution.mode, haikuModel: args.haikuModel ?? DEFAULT_HAIKU_MODEL }),
+ computeCorpusGeneration({
+ crMode: resolution.mode,
+ haikuModel: args.haikuModel ?? DEFAULT_HAIKU_MODEL,
+ synopsisDocMaxChars: resolution.mode === 'per_chunk_synopsis' ? SYNOPSIS_DOC_MAX_CHARS : undefined,
+ }),
);
return { kind: 'skipped', reason: 'no_chunks' };
}
@@ -282,6 +298,7 @@ export async function reembedPageWithContextualRetrieval(
const corpus_generation = computeCorpusGeneration({
crMode: attemptMode,
haikuModel,
+ synopsisDocMaxChars: attemptMode === 'per_chunk_synopsis' ? SYNOPSIS_DOC_MAX_CHARS : undefined,
});
// ── PHASE 2: single DB transaction ───────────────────────────
diff --git a/src/core/import-file.ts b/src/core/import-file.ts
index 4e7722abb..f988ee1cf 100644
--- a/src/core/import-file.ts
+++ b/src/core/import-file.ts
@@ -733,6 +733,11 @@ export async function importFromContent(
: computeCorpusGeneration({
crMode: effectiveCRMode,
haikuModel: 'anthropic:claude-haiku-4-5-20251001',
+ // Inline import-file path never uses per_chunk_synopsis (refuses
+ // upstream); pass undefined so the doc-cap field stays out of
+ // the hash here. Per_chunk_synopsis runs through the Minion
+ // backfill handler which threads SYNOPSIS_DOC_MAX_CHARS through
+ // the service layer.
});
// Transaction wraps all DB writes. Every per-page tx call carries the
diff --git a/src/core/page-summary.ts b/src/core/page-summary.ts
index c4d7deedc..78e04d41c 100644
--- a/src/core/page-summary.ts
+++ b/src/core/page-summary.ts
@@ -44,6 +44,33 @@ const HAIKU_MAX_TOKENS = 200;
/** Default model when caller doesn't override. Resolves through the gateway. */
const DEFAULT_SYNOPSIS_MODEL = 'anthropic:claude-haiku-4-5-20251001';
+/**
+ * Hard cap on `documentText` length (chars) before send.
+ *
+ * 2026-05-25 fix wave: small local chat models (Gemma 4 E2B, Qwen3 4B) get
+ * dramatically slower on long contexts even with 131K-token windows declared.
+ * A 73K-char page synopsis on Gemma 4 E2B takes 60-120s, exceeding the
+ * worker's default 30s `lockDuration` and tripping `lock-lost` errors.
+ *
+ * Truncate to a budget that fits a small model's effective throughput while
+ * preserving enough document context for the synopsis to be useful. Truncates
+ * the TAIL because the head (title, frontmatter, intro) carries the
+ * document-level anchor the synopsis needs.
+ *
+ * Override per workload via `GBRAIN_SYNOPSIS_DOC_MAX_CHARS`. Default 32768
+ * (~8K tokens at 4 chars/tok) keeps small-model synopsis under ~30s.
+ * Anthropic Haiku is unaffected at this cap; bump higher when running
+ * frontier models if you want richer document anchoring.
+ */
+export const SYNOPSIS_DOC_MAX_CHARS = (() => {
+ const env = process.env.GBRAIN_SYNOPSIS_DOC_MAX_CHARS;
+ if (env && /^\d+$/.test(env)) {
+ const n = parseInt(env, 10);
+ if (n >= 512 && n <= 1_048_576) return n;
+ }
+ return 32768;
+})();
+
/**
* Synopsis prompt version. Folded into corpus_generation so prompt edits
* invalidate prior embeddings via the v0.40.3.0 query_cache.page_generations
@@ -188,11 +215,19 @@ function buildUserPrompt(
documentText: string,
chunkText: string,
): string {
+ // Tail-truncate `documentText` to `SYNOPSIS_DOC_MAX_CHARS` so small local
+ // chat models don't stall on >100KB pages. Head preserved (title block,
+ // frontmatter, intro paragraphs carry the document-level anchor).
+ let trimmedDoc = documentText;
+ if (documentText.length > SYNOPSIS_DOC_MAX_CHARS) {
+ trimmedDoc = documentText.slice(0, SYNOPSIS_DOC_MAX_CHARS) +
+ `\n\n[... ${documentText.length - SYNOPSIS_DOC_MAX_CHARS} chars truncated for synopsis budget ...]`;
+ }
return [
`${pageTitle}`,
'',
'',
- documentText,
+ trimmedDoc,
'',
'',
'',