Compare commits

..
Author SHA1 Message Date
a11ec9c468 fix(dream): stamp incremental extraction watermark (#2636)
The Dream cycle disables sync's inline extraction and routes changed
slugs through extractForSlugs, which flushed link/timeline batches but
never stamped links_extracted_at — so incrementally extracted pages
stayed permanently visible to `extract --stale` / doctor.

Collect processedRefs per successfully processed page and stamp them
via stampExtracted (best-effort) after both batch flushes, non-dry-run
mode 'all' only. Source-id threading from the original PR #2637 already
landed on master via #1503/#1747, so this rebase carries only the
missing watermark stamp plus regression tests.

Takeover of #2637.

Co-authored-by: JavanC <JavanC@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:34:22 -07:00
5 changed files with 62 additions and 63 deletions
+12
View File
@@ -1025,6 +1025,10 @@ async function extractForSlugs(
let linksCreated = 0;
let timelineCreated = 0;
let pagesProcessed = 0;
// #2636: successfully processed pages get their extraction watermark
// stamped after the final flush (mode 'all' only — a partial-mode run
// hasn't done the full extraction the watermark asserts).
const processedRefs: Array<{ slug: string; source_id: string }> = [];
// Issue #972: read the basename flag once per extract run.
const globalBasename = await isGlobalBasenameEnabled(engine);
@@ -1113,6 +1117,7 @@ async function extractForSlugs(
}
pagesProcessed++;
if (!dryRun) processedRefs.push({ slug, source_id: sourceId ?? 'default' });
} catch { /* skip unreadable */ }
progress.tick(1);
},
@@ -1120,6 +1125,13 @@ async function extractForSlugs(
await flushLinks();
await flushTimeline();
// #2636: the Dream cycle disables sync's inline extraction and routes
// changed slugs through this incremental path — without a stamp here,
// those pages never get links_extracted_at and stay permanently visible
// to `extract --stale` / doctor. Stamp only after BOTH batches flushed.
if (!dryRun && mode === 'all') {
await stampExtracted(engine, processedRefs);
}
progress.finish();
if (!jsonMode) {
+1 -5
View File
@@ -22,7 +22,6 @@
*/
import { resolveRecipe } from './model-resolver.ts';
import { listRecipes } from './recipes/index.ts';
import { AIConfigError } from './errors.ts';
export interface ProviderCapabilities {
@@ -78,10 +77,7 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti
if (!chat) {
throw new AIConfigError(
`Provider "${recipe.id}" does not offer a chat touchpoint.`,
// Computed from the registry so the hint can't drift into listing
// chat-less providers (the pre-fix list falsely included embedding-only
// recipes, sending users in circles — #1157).
`Known providers with chat: ${listRecipes().filter(r => r.touchpoints.chat).map(r => r.id).join(', ')}. Pick one for models.tier.subagent.`,
`Known providers with chat: openai, anthropic, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai, dashscope, minimax, zhipu, ollama, llama-server. Pick one for models.tier.subagent.`,
);
}
+4 -19
View File
@@ -1,10 +1,9 @@
import type { Recipe } from '../types.ts';
/**
* Zhipu AI (智谱AI) BigModel Open Platform. OpenAI-compatible /embeddings and
* /chat/completions endpoints at open.bigmodel.cn. Hosts embedding-2 (1024d),
* embedding-3 (Matryoshka up to 2048d), and the GLM chat family (glm-5.1 etc.)
* with native tool calling — usable for models.tier.subagent (#1157).
* Zhipu AI (智谱AI) BigModel Open Platform. OpenAI-compatible /embeddings
* endpoint at open.bigmodel.cn. Hosts embedding-2 (1024d) and embedding-3
* (Matryoshka up to 2048d).
*
* embedding-3 at 2048 dims exceeds pgvector's HNSW cap of 2000 — those
* brains fall back to exact vector scans (see
@@ -26,20 +25,6 @@ export const zhipu: Recipe = {
setup_url: 'https://open.bigmodel.cn/',
},
touchpoints: {
chat: {
// Informational list (openai-compat tier: assertTouchpoint doesn't
// enforce it), so newer GLM ids pass without a recipe edit.
models: ['glm-5.1', 'glm-4.6', 'glm-4.5'],
supports_tools: true,
// gbrain-side stable tool ids (v0.38 D11) decoupled the loop from
// Anthropic response formats; GLM tool calling is stable through the
// OpenAI-compat path, same as deepseek/groq.
supports_subagent_loop: true,
// Anthropic-style cache_control markers are not honored on the
// OpenAI-compat path — the loop runs hot (degraded:no_caching warn).
supports_prompt_cache: false,
max_context_tokens: 128000,
},
embedding: {
models: ['embedding-3', 'embedding-2'],
default_dims: 1024,
@@ -51,5 +36,5 @@ export const zhipu: Recipe = {
},
},
setup_hint:
'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`. Chat/subagent: use `zhipu:glm-5.1`.',
'Get an API key at https://open.bigmodel.cn/, then `export ZHIPUAI_API_KEY=...`',
};
-39
View File
@@ -69,45 +69,6 @@ describe('recipe: zhipu', () => {
expect(sql.toLowerCase()).toContain('hnsw');
});
test('chat touchpoint declares GLM models with tool + subagent-loop support (#1157)', () => {
const r = getRecipe('zhipu')!;
expect(r.touchpoints.chat).toBeDefined();
expect(r.touchpoints.chat!.models).toContain('glm-5.1');
expect(r.touchpoints.chat!.supports_tools).toBe(true);
expect(r.touchpoints.chat!.supports_subagent_loop).toBe(true);
expect(r.touchpoints.chat!.supports_prompt_cache).toBe(false);
});
test('zhipu:glm-5.1 passes the subagent capability gate (degraded:no_caching, not refused)', async () => {
// Pre-fix: getProviderCapabilities threw "does not offer a chat touchpoint"
// and classifyCapabilities returned 'unknown' → subagent submit refused.
const { getProviderCapabilities, classifyCapabilities } =
await import('../../src/core/ai/capabilities.ts');
const caps = getProviderCapabilities('zhipu:glm-5.1');
expect(caps.supportsToolCalling).toBe(true);
expect(classifyCapabilities('zhipu:glm-5.1')).toBe('degraded:no_caching');
});
test('no-chat-touchpoint error hint lists only providers that actually have chat', async () => {
// The hint is computed from the registry; every provider it names must
// really carry a chat touchpoint (pre-fix it hardcoded zhipu/dashscope/
// minimax, all embedding-only at the time).
const { getProviderCapabilities } = await import('../../src/core/ai/capabilities.ts');
const { listRecipes } = await import('../../src/core/ai/recipes/index.ts');
let hint = '';
try {
getProviderCapabilities('voyage:voyage-3');
throw new Error('expected AIConfigError for embedding-only provider');
} catch (e) {
hint = (e as { fix?: string }).fix ?? String(e);
}
const listed = hint.match(/chat: ([^.]+)\./)?.[1]?.split(', ') ?? [];
expect(listed.length).toBeGreaterThan(0);
const withChat = new Set(listRecipes().filter(r => r.touchpoints.chat).map(r => r.id));
for (const id of listed) expect(withChat.has(id)).toBe(true);
expect(listed).toContain('zhipu');
});
test('dimsProviderOptions threads dimensions for embedding-3 (Matryoshka)', async () => {
// Codex finding #1: Zhipu embedding-3 is Matryoshka 256-2048. Without
// `dimensions` on the wire, user-selected non-default dims are
+45
View File
@@ -60,6 +60,51 @@ async function seedPage(slug: string, body: string): Promise<void> {
}
describe('runExtractCore — incremental cycle path (#417)', () => {
test('Dream incremental all-mode stamps the source-scoped extraction watermark (#2636)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
['repo-a', 'repo-a', tempDir],
);
await engine.putPage('people/alice-example', {
type: 'person',
title: 'alice-example',
compiled_truth: '# alice',
timeline: '',
frontmatter: {},
content_hash: 'h',
}, { sourceId: 'repo-a' });
writeFileSync(join(tempDir, 'people/alice-example.md'), '# alice');
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
sourceId: 'repo-a',
});
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
`SELECT links_extracted_at FROM pages WHERE slug = $1 AND source_id = $2`,
['people/alice-example', 'repo-a'],
);
expect(rows[0]?.links_extracted_at).not.toBeNull();
expect(await engine.countStalePagesForExtraction({ sourceId: 'repo-a' })).toBe(0);
});
test('Dream incremental dry-run does NOT stamp the watermark', async () => {
await seedPage('people/alice-example', '# alice');
await runExtractCore(engine as unknown as BrainEngine, {
mode: 'all',
dir: tempDir,
slugs: ['people/alice-example'],
dryRun: true,
});
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
`SELECT links_extracted_at FROM pages WHERE slug = $1`,
['people/alice-example'],
);
expect(rows[0]?.links_extracted_at ?? null).toBeNull();
});
test('1. slugs: [] returns immediately with zero counts (early-return path)', async () => {
await seedPage('people/alice-example', '# alice');
const result = await runExtractCore(engine as unknown as BrainEngine, {