mlx-bun 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -20
- package/docs/design/batching-perf-path.md +232 -0
- package/docs/design/batching-v2-plan.md +515 -0
- package/docs/design/bucketing-stage.md +196 -0
- package/docs/design/compat-cli-surface-design.md +11 -11
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dreaming-nightly-pipeline.md +291 -0
- package/docs/design/dspark-speculative-decoding.md +106 -0
- package/docs/design/generic-model-support.md +381 -0
- package/docs/design/memory-inference-path.md +422 -0
- package/docs/design/memory-synthesis.md +287 -0
- package/docs/design/minicpm5-decode-megakernel.md +484 -0
- package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
- package/docs/design/omlx-adoption-map.md +90 -0
- package/docs/design/orpo-dynamic-lambda.md +41 -0
- package/docs/design/orpo-training.md +35 -5
- package/docs/design/parallel-slots.md +12 -1
- package/docs/design/parity-tier-dag.md +13 -5
- package/docs/design/segmented-backward-training.md +15 -0
- package/docs/design/spec-decode-larger-targets.md +229 -0
- package/docs/design/ssd-kv-cold-tier.md +158 -0
- package/docs/design/tauri-desktop-app.md +2 -2
- package/docs/design/the-dreaming-handoff.md +141 -0
- package/docs/design/the-dreaming-master-plan.md +446 -0
- package/docs/design/write-pipeline-entity.md +468 -0
- package/docs/investigations/curve-bisector-routing.md +1 -1
- package/docs/investigations/decode-roofline-lookagain.md +348 -0
- package/docs/investigations/dspark-handoff.md +58 -0
- package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -2
- package/docs/planning/curve-sampler-research-plan.md +331 -0
- package/docs/planning/memory-docs-and-dag-plan.md +185 -0
- package/docs/planning/pi-integration-review.md +178 -0
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.9.md +122 -0
- package/docs/planning/web-ui-pass-plan.md +213 -0
- package/docs/planning/website-readme-pass-plan.md +224 -0
- package/docs/reference/cli.md +255 -0
- package/docs/reference/embedding.md +7 -2
- package/docs/reference/library-api.md +22 -3
- package/docs/reference/memory.md +5 -5
- package/docs/reference/models.md +137 -0
- package/docs/reference/orpo-quickstart.md +6 -0
- package/docs/reference/server-api.md +187 -9
- package/docs/reference/server-config.md +43 -9
- package/docs/reference/training.md +31 -12
- package/package.json +1 -1
- package/src/anthropic.ts +22 -0
- package/src/assets/curve-designer.html +1 -1
- package/src/chat-template.ts +25 -7
- package/src/cli.ts +1077 -90
- package/src/config.ts +4 -1
- package/src/download.ts +99 -8
- package/src/embed.ts +77 -0
- package/src/eval/perplexity.ts +164 -0
- package/src/eval/runner.ts +15 -6
- package/src/eval/tasks/bfcl.ts +1 -1
- package/src/generate.ts +149 -18
- package/src/harness-pi.ts +43 -17
- package/src/index.ts +2 -0
- package/src/kv-store.ts +240 -98
- package/src/lora.ts +2 -2
- package/src/memory/article.ts +558 -0
- package/src/memory/chunk-validation.ts +150 -0
- package/src/memory/chunk.ts +305 -12
- package/src/memory/cluster.ts +344 -17
- package/src/memory/coverage.ts +134 -0
- package/src/memory/crosslink.ts +0 -0
- package/src/memory/db.ts +184 -45
- package/src/memory/entity.ts +203 -0
- package/src/memory/gate.ts +187 -0
- package/src/memory/history.ts +152 -0
- package/src/memory/ingest.ts +324 -11
- package/src/memory/model.ts +321 -0
- package/src/memory/normalize.ts +671 -0
- package/src/memory/parse.ts +22 -0
- package/src/memory/pipeline.ts +242 -30
- package/src/memory/prompts.ts +42 -0
- package/src/memory/query.ts +334 -0
- package/src/memory/reconcile.ts +734 -0
- package/src/memory/redirect.ts +108 -0
- package/src/memory/reindex.ts +325 -0
- package/src/memory/resolve.ts +307 -0
- package/src/memory/route.ts +272 -0
- package/src/memory/schedule.ts +6 -4
- package/src/memory/stages.ts +774 -0
- package/src/memory/synthesize.ts +1629 -20
- package/src/memory/tools.ts +446 -96
- package/src/memory/vault.ts +462 -35
- package/src/memory/wikify.ts +1057 -16
- package/src/mlx/array.ts +21 -0
- package/src/mlx/ffi.ts +3 -0
- package/src/mlx/ops.ts +72 -0
- package/src/model/compiled-decode.ts +43 -8
- package/src/model/factory.ts +29 -8
- package/src/model/flash-attention.ts +4 -2
- package/src/model/fused-decode-kernel.ts +14 -7
- package/src/model/fused-mlp-kernel.ts +169 -0
- package/src/model/fused-swiglu-kernel.ts +240 -0
- package/src/model/gemma4-base.ts +96 -7
- package/src/model/gemma4.ts +33 -2
- package/src/model/minicpm5.ts +106 -8
- package/src/model/qwen3-delta.ts +48 -1
- package/src/model/qwen3.ts +276 -0
- package/src/model/qwen3_5.ts +11 -2
- package/src/model/steel-linear-kernel.ts +96 -0
- package/src/model/support.ts +38 -8
- package/src/model/universal/archs.ts +332 -0
- package/src/model/universal/dense.ts +576 -0
- package/src/model/universal/modules.ts +163 -0
- package/src/model/universal/rope.ts +366 -0
- package/src/pi-provider.ts +1 -1
- package/src/pi-terminal.ts +29 -8
- package/src/pi-web.ts +19 -2
- package/src/prompt-cache.ts +55 -9
- package/src/quantize/config-writer.ts +1 -1
- package/src/registry.ts +219 -8
- package/src/responses.ts +23 -0
- package/src/sampler.ts +220 -9
- package/src/serve/batch-scheduler.ts +307 -83
- package/src/serve/generation-gateway.ts +156 -29
- package/src/server.ts +746 -73
- package/src/spec/drafter.ts +81 -49
- package/src/spec/dspark/data-dflash.ts +156 -0
- package/src/spec/dspark/data.ts +201 -0
- package/src/spec/dspark/generate-dflash.ts +193 -0
- package/src/spec/dspark/generate.ts +290 -0
- package/src/spec/dspark/loss.ts +213 -0
- package/src/spec/dspark/module-dflash.ts +361 -0
- package/src/spec/dspark/module.ts +540 -0
- package/src/spec/dspark/sample.ts +112 -0
- package/src/spec/generate.ts +40 -19
- package/src/ssd-cache.ts +213 -0
- package/src/status-page.html +1 -1
- package/src/tokenizer.ts +7 -0
- package/src/train/flash-cce.ts +81 -23
- package/src/train/fuse.ts +243 -0
- package/src/train/index.ts +3 -0
- package/src/train/job.ts +8 -0
- package/src/train/loss.ts +341 -40
- package/src/train/prefix-shared.ts +62 -11
- package/src/train/segmented.ts +215 -55
- package/src/train/trainer.ts +112 -38
- package/src/web/skills/memory/SKILL.md +40 -17
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
// mlx-bun memory — RECONCILE pass (P7-T3): make an article internally consistent
|
|
2
|
+
// with its latest-dated position AFTER a self-healing fold.
|
|
3
|
+
//
|
|
4
|
+
// A targeted PATCH resolves ONE claim toward the user's newest statement — it
|
|
5
|
+
// flips the LEAD (the verdict) to the current value. But the rest of the article
|
|
6
|
+
// can be left contradicting that verdict: an INFOBOX relationship field still
|
|
7
|
+
// records the superseded value (`<preference_key>: <old value>` while the lead now
|
|
8
|
+
// says `<new value>`), and a sibling BODY section still asserts the old take. This pass
|
|
9
|
+
// runs AFTER the patch loop, scans those surfaces, and updates whatever the fold
|
|
10
|
+
// made stale so the whole article ends consistent with the latest position.
|
|
11
|
+
//
|
|
12
|
+
// It is the same discipline as PATCH: every edit goes through NORMALIZE + the
|
|
13
|
+
// conservative gate, and it is PROVENANCE-PRESERVING — a section rewrite keeps
|
|
14
|
+
// every pre-existing `[^k]` marker (phrased as a change over time), so the gate's
|
|
15
|
+
// citation-survival check passes instead of NO-OP'ing the fold. It is bounded
|
|
16
|
+
// (only relationship infobox fields + body sections are touched; world-fact / spec
|
|
17
|
+
// fields are left alone) and idempotent: a fully-consistent article is a NO-OP.
|
|
18
|
+
|
|
19
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import { articleStructure, fenceLabel, isFenceLine, parseInfobox, parseLead } from "./article";
|
|
23
|
+
import { articleTitle } from "./cluster";
|
|
24
|
+
import type { MemoryStore } from "./db";
|
|
25
|
+
import { gateEdit } from "./gate";
|
|
26
|
+
import {
|
|
27
|
+
buildContradictionClassifierPrompt,
|
|
28
|
+
buildHistoryEntry,
|
|
29
|
+
classifyContradiction,
|
|
30
|
+
type ContradictionKind,
|
|
31
|
+
markerNear,
|
|
32
|
+
upsertHistoryEntry,
|
|
33
|
+
} from "./history";
|
|
34
|
+
import { callLocal, MAX_OUTPUT_TOKENS } from "./model";
|
|
35
|
+
import { normalizeArticle } from "./normalize";
|
|
36
|
+
import {
|
|
37
|
+
articleFootnoteState,
|
|
38
|
+
entityStem,
|
|
39
|
+
PHYSICAL_SPEC_KEYS,
|
|
40
|
+
referenceDatesByMarker,
|
|
41
|
+
sanitizeLead,
|
|
42
|
+
sanitizeSection,
|
|
43
|
+
type SynthesisCall,
|
|
44
|
+
} from "./synthesize";
|
|
45
|
+
import { articlesDir, commitVault, extractSection, listArticles, slugifyHeading, vaultRoot } from "./vault";
|
|
46
|
+
|
|
47
|
+
// ---- relationship-field classification -------------------------------------
|
|
48
|
+
|
|
49
|
+
/** Infobox keys we NEVER touch — schema keys plus the physical spec keys (a
|
|
50
|
+
* spec is a world-fact, not a stance that a verdict can supersede). */
|
|
51
|
+
const RECONCILE_SKIP_KEYS = new Set(["type", "kind", "aliases", "categories"]);
|
|
52
|
+
|
|
53
|
+
/** A relationship/opinion key whose value records the user's stance — the only
|
|
54
|
+
* infobox fields a verdict change can make stale (favorite / opinion / status /
|
|
55
|
+
* preference / current pick …). World-fact + spec keys are excluded above. */
|
|
56
|
+
const RELATIONSHIP_KEY_RE =
|
|
57
|
+
/(favou?rite|prefer|preference|pick|choice|chosen|opinion|verdict|status|stance|current|rating|sentiment|\btake\b|likes?|dislikes?|using|used_for|owns?|owned|acquired)/i;
|
|
58
|
+
|
|
59
|
+
/** True when an infobox field is a relationship/stance fact (refreshable), not a
|
|
60
|
+
* world-fact or physical spec (left untouched). */
|
|
61
|
+
export function isRelationshipKey(key: string): boolean {
|
|
62
|
+
const k = key.trim().toLowerCase();
|
|
63
|
+
if (RECONCILE_SKIP_KEYS.has(k)) return false;
|
|
64
|
+
if (PHYSICAL_SPEC_KEYS.has(k)) return false;
|
|
65
|
+
return RELATIONSHIP_KEY_RE.test(k);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- prompt builders (schematic — no parrot-able concrete values) ----------
|
|
69
|
+
|
|
70
|
+
/** The model sentinel for "this fact already agrees with the summary". */
|
|
71
|
+
export const RECONCILE_KEEP = "KEEP";
|
|
72
|
+
|
|
73
|
+
/** Check ONE infobox relationship fact against the current verdict (the lead). The
|
|
74
|
+
* model answers KEEP when it agrees, or the corrected VALUE (no key, no prose)
|
|
75
|
+
* when it is stale. Schematic on purpose. */
|
|
76
|
+
export function buildInfoboxReconcilePrompt(verdict: string, key: string, value: string): string {
|
|
77
|
+
return (
|
|
78
|
+
`You are checking ONE infobox fact against the article's current summary for internal consistency.\n\n` +
|
|
79
|
+
`CURRENT SUMMARY (the user's latest position):\n${verdict}\n\n` +
|
|
80
|
+
`INFOBOX FACT:\n${key}: ${value}\n\n` +
|
|
81
|
+
`If the fact AGREES with the current summary, answer exactly ${RECONCILE_KEEP}. ` +
|
|
82
|
+
`If it CONTRADICTS the summary (it records a preference, opinion, or status the summary has since changed), ` +
|
|
83
|
+
`answer with the corrected VALUE only — the single value that matches the summary, no key, no prose, no quotes.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Detection (used only for a ground-truth field with no KNOWN superseded value):
|
|
88
|
+
* does this passage state, as the user's CURRENT choice, a value that DIFFERS
|
|
89
|
+
* from the established current value? Answer KEEP, or that different value only. */
|
|
90
|
+
export function buildProseConflictPrompt(label: string, groundTruth: string, body: string): string {
|
|
91
|
+
return (
|
|
92
|
+
`ESTABLISHED CURRENT VALUE: ${groundTruth}\n\n` +
|
|
93
|
+
`${label}:\n${body || "(empty)"}\n\n` +
|
|
94
|
+
`Does this passage state, as the user's CURRENT / present choice, a value DIFFERENT from ${groundTruth}? ` +
|
|
95
|
+
`If it states only ${groundTruth} as current (any other value appears solely as past history), answer exactly ${RECONCILE_KEEP}. ` +
|
|
96
|
+
`Otherwise answer with that different current value ONLY — no key, no prose, no quotes.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The CONSTRAINED consistency rewrite — the safety net that guarantees the prose
|
|
102
|
+
* agrees with the infobox GROUND TRUTH. Both values are real article data (the
|
|
103
|
+
* resolved current value + the value it replaced), not copyable example values:
|
|
104
|
+
* we tell the model exactly which value is current and which is former, so the
|
|
105
|
+
* small model only has to demote the named superseded value to past tense — far
|
|
106
|
+
* more reliable than the open-ended "resolve toward latest" fold. Every existing
|
|
107
|
+
* `[^k]` marker is preserved (provenance), framed as a change over time.
|
|
108
|
+
*/
|
|
109
|
+
export function buildProseConsistencyPrompt(
|
|
110
|
+
label: string,
|
|
111
|
+
groundTruth: string,
|
|
112
|
+
superseded: string,
|
|
113
|
+
body: string,
|
|
114
|
+
): string {
|
|
115
|
+
return (
|
|
116
|
+
`You are correcting ONE passage of a personal-wiki article so it agrees with the article's ESTABLISHED CURRENT fact.\n\n` +
|
|
117
|
+
`ESTABLISHED CURRENT VALUE: ${groundTruth}\n` +
|
|
118
|
+
`FORMER (SUPERSEDED) VALUE: ${superseded}\n\n` +
|
|
119
|
+
`${label}:\n${body || "(empty)"}\n\n` +
|
|
120
|
+
`Rewrite the passage so it reads as a change OVER TIME: state ${superseded} ONLY in the past tense ` +
|
|
121
|
+
`("previously ${superseded}", "originally ${superseded}", "${superseded} was"), and state ${groundTruth} as the user's CURRENT, present choice. ` +
|
|
122
|
+
`${superseded} must NEVER appear as a current/present favourite, pick, or "the one I reach for". ` +
|
|
123
|
+
`Keep EVERY existing [^k] citation marker: attach the ${superseded} marker to the past clause and the ${groundTruth} marker to the present clause, so no [^N] is lost. ` +
|
|
124
|
+
`Prefer ONE flowing statement — "previously ${superseded}[^k]; now ${groundTruth} is my current pick[^n]". ` +
|
|
125
|
+
`Output ONLY the rewritten passage prose — no heading, no list, no References section.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The CURRENT-STATE-ONLY rewrite. Used for the LEAD (which must read as a clean
|
|
131
|
+
* current-state summary — never scattered "previously …" caveats) and for a
|
|
132
|
+
* factual-ERROR fix in any passage (the wrong value is removed as if it had never
|
|
133
|
+
* been there). The passage is rewritten to state ONLY `groundTruth` as current and
|
|
134
|
+
* to DROP `superseded` entirely. For an evolution the dropped value's trajectory
|
|
135
|
+
* is recorded in the article's `## History` section instead; for an error it is
|
|
136
|
+
* simply gone. Both values are real article data, not copyable example values.
|
|
137
|
+
*/
|
|
138
|
+
export function buildCurrentStateOnlyPrompt(
|
|
139
|
+
label: string,
|
|
140
|
+
groundTruth: string,
|
|
141
|
+
superseded: string,
|
|
142
|
+
body: string,
|
|
143
|
+
isError: boolean,
|
|
144
|
+
): string {
|
|
145
|
+
const why = isError
|
|
146
|
+
? `The value ${superseded} was a MISTAKE — a wrong fact. Remove it entirely; it must NOT appear anywhere in the passage (no "previously", no history). ` +
|
|
147
|
+
`You may drop ${superseded}'s citation marker.`
|
|
148
|
+
: `Do NOT mention ${superseded} in this passage — its trajectory is recorded separately in the History section. ` +
|
|
149
|
+
`Keep ${groundTruth}'s [^k] citation marker.`;
|
|
150
|
+
return (
|
|
151
|
+
`You are rewriting ONE passage of a personal-wiki article to state ONLY the user's CURRENT value.\n\n` +
|
|
152
|
+
`CURRENT VALUE: ${groundTruth}\n` +
|
|
153
|
+
`VALUE TO REMOVE: ${superseded}\n\n` +
|
|
154
|
+
`${label}:\n${body || "(empty)"}\n\n` +
|
|
155
|
+
`Rewrite the passage so it states ${groundTruth} as the user's current, present value, in the present tense. ` +
|
|
156
|
+
`${why} ` +
|
|
157
|
+
`Output ONLY the rewritten passage prose — no heading, no list, no References section.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Sharper retry instruction when the first rewrite still asserts the superseded
|
|
162
|
+
* value as current. */
|
|
163
|
+
export function proseRetryHint(groundTruth: string, superseded: string): string {
|
|
164
|
+
return (
|
|
165
|
+
`STILL WRONG: the passage must NOT claim ${superseded} is current, now, or a favourite. ` +
|
|
166
|
+
`The ONLY current value is ${groundTruth}. Write ${superseded} strictly as a FORMER value ("previously ${superseded}[^k]"), ` +
|
|
167
|
+
`and ${groundTruth} as the present one ("now ${groundTruth}[^n]"). Demote ${superseded} to the past tense.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---- pure helpers ----------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/** Distinct `[^N]` markers in a block (definition lines excluded). */
|
|
174
|
+
function markersIn(body: string): number[] {
|
|
175
|
+
const out: number[] = [];
|
|
176
|
+
const seen = new Set<number>();
|
|
177
|
+
for (const line of body.split("\n")) {
|
|
178
|
+
if (/^\[\^\d+\]:/.test(line.trim())) continue;
|
|
179
|
+
for (const m of line.matchAll(/\[\^(\d+)\]/g)) {
|
|
180
|
+
const n = parseInt(m[1]!, 10);
|
|
181
|
+
if (!seen.has(n)) {
|
|
182
|
+
seen.add(n);
|
|
183
|
+
out.push(n);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Escape a literal string for embedding in a RegExp. */
|
|
191
|
+
function escapeRe(s: string): string {
|
|
192
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Past-tense framing cues — when one governs the clause that mentions a value,
|
|
196
|
+
* the value reads as HISTORY, not a present assertion. Deliberately EXCLUDES
|
|
197
|
+
* "currently"/"now"/"today" (those are PRESENT, the very thing we flag). */
|
|
198
|
+
const PAST_FRAME_RE =
|
|
199
|
+
/\b(previous|origin|earlier|used to|no longer|formerly|initial|at first|back then|before|had been|until|supersed|was|were|moved on|switched)\b/i;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* True when `value` is asserted as the user's CURRENT / present choice somewhere
|
|
203
|
+
* in `text` and that clause is NOT framed as past. A "clause" is a span between
|
|
204
|
+
* sentence / `;` breaks, so "previously <old>[^1]; now <new>[^2]" reads the <old>
|
|
205
|
+
* clause as history (past-framed) and is NOT flagged. This is the deterministic
|
|
206
|
+
* detector AND the post-rewrite acceptance check the retry hinges on. Pure.
|
|
207
|
+
*/
|
|
208
|
+
export function assertsValueAsCurrent(text: string, value: string): boolean {
|
|
209
|
+
const t = text.toLowerCase();
|
|
210
|
+
const v = value.trim().toLowerCase();
|
|
211
|
+
if (!v || !t.includes(v)) return false;
|
|
212
|
+
const ev = escapeRe(v);
|
|
213
|
+
const present = new RegExp(
|
|
214
|
+
`(${ev}[^.;!?]{0,60}\\b(is|are|remain|stay|favou?rite|prefer|reach for|number-one|pick|go-to|current|now)\\b)|` +
|
|
215
|
+
`(\\b(favou?rite|prefer|current(ly)?|now|pick|reach for|go-to|reach|consider)\\b[^.;!?]{0,60}${ev})`,
|
|
216
|
+
);
|
|
217
|
+
for (const clause of t.split(/[.;!?]+/)) {
|
|
218
|
+
if (!clause.includes(v)) continue;
|
|
219
|
+
if (!present.test(clause)) continue;
|
|
220
|
+
if (PAST_FRAME_RE.test(clause)) continue; // framed as history → fine
|
|
221
|
+
return true; // present-tense, not past-framed → stale
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Collapse adjacent duplicate footnote markers (`[^2][^2]` or `[^2] [^2]` →
|
|
227
|
+
* `[^2]`) — the lead artifact a fold can leave. Pure. */
|
|
228
|
+
export function dedupeAdjacentMarkers(text: string): string {
|
|
229
|
+
return text.replace(/(\[\^\d+\])(\s*\1)+/g, "$1");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Strip a model value reply to a clean single-line value (drop quotes, a stray
|
|
233
|
+
* echoed `key:` prefix, surrounding markup). */
|
|
234
|
+
function cleanValue(raw: string, key: string): string {
|
|
235
|
+
let v = raw.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
236
|
+
v = v.replace(new RegExp(`^${key}\\s*:`, "i"), "").trim();
|
|
237
|
+
v = v.replace(/^[-*+]\s+/, "").replace(/^["'`]+|["'`.]+$/g, "").trim();
|
|
238
|
+
return v;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Replace the value of one `key:` line inside the article's fenced ```info block.
|
|
242
|
+
* Returns the article unchanged when the key is absent. Pure. */
|
|
243
|
+
export function setInfoboxFieldValue(content: string, key: string, newValue: string): string {
|
|
244
|
+
const lines = content.split(/\r?\n/);
|
|
245
|
+
let inInfo = false;
|
|
246
|
+
for (let i = 0; i < lines.length; i++) {
|
|
247
|
+
const line = lines[i]!;
|
|
248
|
+
if (isFenceLine(line)) {
|
|
249
|
+
if (!inInfo && fenceLabel(line) === "info") inInfo = true;
|
|
250
|
+
else if (inInfo) break; // closing fence — leave the info block
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (inInfo) {
|
|
254
|
+
const idx = line.indexOf(":");
|
|
255
|
+
if (idx > 0 && line.slice(0, idx).trim() === key) {
|
|
256
|
+
lines[i] = `${key}: ${newValue}`;
|
|
257
|
+
return lines.join("\n");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return content;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- the reconcile core (pure w.r.t. disk) ---------------------------------
|
|
265
|
+
|
|
266
|
+
export interface ReconcileDeps {
|
|
267
|
+
/** Model call; defaults to `callLocal("synthesis", …)`. */
|
|
268
|
+
call?: SynthesisCall;
|
|
269
|
+
/** Other article stems (wikilink resolution in NORMALIZE). */
|
|
270
|
+
stems?: Set<string>;
|
|
271
|
+
/** Article stem (H1/NORMALIZE); defaults to the entity stem of the H1 title. */
|
|
272
|
+
stem?: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface ReconcileResult {
|
|
276
|
+
content: string;
|
|
277
|
+
changed: boolean;
|
|
278
|
+
/** Infobox keys whose value was refreshed to the current verdict. */
|
|
279
|
+
refreshedFields: string[];
|
|
280
|
+
/** Passage labels rewritten toward the ground-truth value (`lead` + section anchors). */
|
|
281
|
+
rewrittenSections: string[];
|
|
282
|
+
/** True when NORMALIZE+gate vetoed the reconciled article (kept the original). */
|
|
283
|
+
gateVetoed: boolean;
|
|
284
|
+
reason: string | null;
|
|
285
|
+
normalizeNotes: string[];
|
|
286
|
+
/** Passage labels where, even after the retry, the small model could NOT demote
|
|
287
|
+
* the superseded value to past tense (left as the gate-vetoed original). Logged
|
|
288
|
+
* honestly — these are a base-model limit, not a silent success. */
|
|
289
|
+
unresolved: string[];
|
|
290
|
+
/** True when at least one passage rewrite needed the sharper retry. */
|
|
291
|
+
retried: boolean;
|
|
292
|
+
/** Trajectory lines written to the `## History` section for EVOLUTION
|
|
293
|
+
* corrections (the user's position changing over time). Empty when every
|
|
294
|
+
* contradiction was a factual error (silently overwritten, no History). */
|
|
295
|
+
historyEntries: string[];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** One prose-passage to reconcile: the LEAD or a body section. */
|
|
299
|
+
interface Passage {
|
|
300
|
+
/** Stable label for `rewrittenSections` / logs (`lead` or the section anchor). */
|
|
301
|
+
label: string;
|
|
302
|
+
/** Current passage body (no heading). */
|
|
303
|
+
body: string;
|
|
304
|
+
/** Sanitizer for a model rewrite (section vs lead). */
|
|
305
|
+
sanitize: (raw: string) => string;
|
|
306
|
+
/** Splice a rewritten body back into the article. */
|
|
307
|
+
write: (article: string, newBody: string) => string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Collect the reconcilable prose passages (lead + every body section, skipping
|
|
311
|
+
* References / See also / Notes) with the helpers to read+write each. */
|
|
312
|
+
function collectPassages(article: string): Passage[] {
|
|
313
|
+
const out: Passage[] = [];
|
|
314
|
+
const items = articleStructure(article);
|
|
315
|
+
|
|
316
|
+
const lead = items.find((it) => it.kind === "lead");
|
|
317
|
+
if (lead) {
|
|
318
|
+
const lines = article.split("\n");
|
|
319
|
+
const start = lead.startLine - 1;
|
|
320
|
+
const end = lead.endLine - 1;
|
|
321
|
+
const body = lines.slice(start, end + 1).join("\n").trim();
|
|
322
|
+
if (body) {
|
|
323
|
+
out.push({
|
|
324
|
+
label: "lead",
|
|
325
|
+
body,
|
|
326
|
+
sanitize: sanitizeLead,
|
|
327
|
+
write: (a, nb) => {
|
|
328
|
+
const ls = a.split("\n");
|
|
329
|
+
return [...ls.slice(0, start), nb, ...ls.slice(end + 1)].join("\n");
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const item of items) {
|
|
336
|
+
if (item.kind !== "section" || !item.title) continue;
|
|
337
|
+
const anchor = slugifyHeading(item.title);
|
|
338
|
+
const block = extractSection(article, anchor);
|
|
339
|
+
if (block == null) continue;
|
|
340
|
+
const headingLine = block.split("\n")[0] ?? `## ${item.title}`;
|
|
341
|
+
const body = block.split("\n").slice(1).join("\n").trim();
|
|
342
|
+
if (!body) continue;
|
|
343
|
+
const title = item.title;
|
|
344
|
+
out.push({
|
|
345
|
+
label: anchor,
|
|
346
|
+
body,
|
|
347
|
+
sanitize: (raw) => sanitizeSection(raw, title),
|
|
348
|
+
write: (a, nb) => {
|
|
349
|
+
const cur = extractSection(a, anchor);
|
|
350
|
+
if (cur == null) return a;
|
|
351
|
+
const idx = a.indexOf(cur);
|
|
352
|
+
if (idx < 0) return a;
|
|
353
|
+
return a.slice(0, idx) + `${headingLine}\n\n${nb}\n` + a.slice(idx + cur.length);
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Outcome of one constrained passage rewrite. */
|
|
361
|
+
interface PassageFix {
|
|
362
|
+
body: string;
|
|
363
|
+
changed: boolean;
|
|
364
|
+
retried: boolean;
|
|
365
|
+
/** True when the small model still left the superseded value as current AFTER
|
|
366
|
+
* the retry (or dropped a marker) — kept the original; report honestly. */
|
|
367
|
+
unresolved: boolean;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Make ONE passage agree that `groundTruth` is current and `superseded` is former.
|
|
372
|
+
* No-op when the passage does not assert `superseded` as current (idempotent).
|
|
373
|
+
* Otherwise: constrained rewrite → deterministic check → ONE sharper retry; if it
|
|
374
|
+
* still asserts the stale value as current OR would drop a `[^k]` marker, keep the
|
|
375
|
+
* original and flag `unresolved`. Markers are deduped; provenance is preserved.
|
|
376
|
+
*/
|
|
377
|
+
async function reconcileOnePassage(
|
|
378
|
+
call: SynthesisCall,
|
|
379
|
+
passage: Passage,
|
|
380
|
+
groundTruth: string,
|
|
381
|
+
superseded: string,
|
|
382
|
+
): Promise<PassageFix> {
|
|
383
|
+
const keep: PassageFix = { body: passage.body, changed: false, retried: false, unresolved: false };
|
|
384
|
+
if (!superseded || !groundTruth || superseded.toLowerCase() === groundTruth.toLowerCase()) return keep;
|
|
385
|
+
if (!assertsValueAsCurrent(passage.body, superseded)) return keep; // already clean / absent
|
|
386
|
+
|
|
387
|
+
const before = new Set(markersIn(passage.body));
|
|
388
|
+
const attempt = async (prompt: string): Promise<string> => {
|
|
389
|
+
const raw = await call(prompt, { maxTokens: MAX_OUTPUT_TOKENS });
|
|
390
|
+
return dedupeAdjacentMarkers(passage.sanitize(raw)).trim();
|
|
391
|
+
};
|
|
392
|
+
const accept = (x: string): boolean => {
|
|
393
|
+
if (!x) return false;
|
|
394
|
+
if (assertsValueAsCurrent(x, superseded)) return false; // still asserts stale value as current
|
|
395
|
+
const after = new Set(markersIn(x));
|
|
396
|
+
return ![...before].some((m) => !after.has(m)); // every marker preserved (provenance)
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const base = buildProseConsistencyPrompt(passage.label, groundTruth, superseded, passage.body);
|
|
400
|
+
let body = await attempt(base);
|
|
401
|
+
if (accept(body)) return { body, changed: true, retried: false, unresolved: false };
|
|
402
|
+
|
|
403
|
+
body = await attempt(`${base}\n\n${proseRetryHint(groundTruth, superseded)}`);
|
|
404
|
+
if (accept(body)) return { body, changed: true, retried: true, unresolved: false };
|
|
405
|
+
|
|
406
|
+
return { ...keep, retried: true, unresolved: true }; // base-model limit — keep original
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Make ONE passage state ONLY `groundTruth` as current and DROP `superseded`
|
|
411
|
+
* entirely (the LEAD path, and the factual-ERROR path). No-op when the passage
|
|
412
|
+
* does not mention `superseded`. Otherwise: constrained rewrite → deterministic
|
|
413
|
+
* check (superseded gone, groundTruth present) → ONE sharper retry; if it still
|
|
414
|
+
* mentions `superseded`, keep the original and flag `unresolved`. For an evolution
|
|
415
|
+
* the dropped value's `[^k]` is preserved in the History section (added by the
|
|
416
|
+
* caller); for an error it is allowed to fall away.
|
|
417
|
+
*/
|
|
418
|
+
async function reconcileOnePassageCurrentState(
|
|
419
|
+
call: SynthesisCall,
|
|
420
|
+
passage: Passage,
|
|
421
|
+
groundTruth: string,
|
|
422
|
+
superseded: string,
|
|
423
|
+
isError: boolean,
|
|
424
|
+
): Promise<PassageFix> {
|
|
425
|
+
const keep: PassageFix = { body: passage.body, changed: false, retried: false, unresolved: false };
|
|
426
|
+
if (!superseded || !groundTruth || superseded.toLowerCase() === groundTruth.toLowerCase()) return keep;
|
|
427
|
+
if (!passage.body.toLowerCase().includes(superseded.toLowerCase())) return keep; // already clean
|
|
428
|
+
|
|
429
|
+
const attempt = async (prompt: string): Promise<string> => {
|
|
430
|
+
const raw = await call(prompt, { maxTokens: MAX_OUTPUT_TOKENS });
|
|
431
|
+
return dedupeAdjacentMarkers(passage.sanitize(raw)).trim();
|
|
432
|
+
};
|
|
433
|
+
const accept = (x: string): boolean => {
|
|
434
|
+
if (!x) return false;
|
|
435
|
+
if (x.toLowerCase().includes(superseded.toLowerCase())) return false; // value not removed
|
|
436
|
+
return x.toLowerCase().includes(groundTruth.toLowerCase()); // current value present
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const base = buildCurrentStateOnlyPrompt(passage.label, groundTruth, superseded, passage.body, isError);
|
|
440
|
+
let body = await attempt(base);
|
|
441
|
+
if (accept(body)) return { body, changed: true, retried: false, unresolved: false };
|
|
442
|
+
|
|
443
|
+
body = await attempt(`${base}\n\n${proseRetryHint(groundTruth, superseded)}`);
|
|
444
|
+
if (accept(body)) return { body, changed: true, retried: true, unresolved: false };
|
|
445
|
+
|
|
446
|
+
return { ...keep, retried: true, unresolved: true };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Reconcile ONE article to its INFOBOX GROUND TRUTH. The infobox relationship
|
|
451
|
+
* value is the authority (it is resolved first, against the lead verdict): once
|
|
452
|
+
* resolved, the LEAD and EVERY body section must AGREE with it. We refresh stale
|
|
453
|
+
* infobox fields, then for each passage that still asserts a SUPERSEDED value as
|
|
454
|
+
* current we run a CONSTRAINED rewrite ("make <ground-truth> current and <old>
|
|
455
|
+
* past") with a deterministic check + one retry — far more reliable for the small
|
|
456
|
+
* model than the open-ended fold. Every rewrite is provenance-preserving (keeps
|
|
457
|
+
* its `[^k]` markers, dedupes duplicates). Runs NORMALIZE + the gate; a veto keeps
|
|
458
|
+
* the original. A consistent article is a byte-identical NO-OP. Pure w.r.t. disk.
|
|
459
|
+
*/
|
|
460
|
+
export async function reconcileContent(article: string, deps: ReconcileDeps = {}): Promise<ReconcileResult> {
|
|
461
|
+
const call: SynthesisCall = deps.call ?? ((p, o) => callLocal("synthesis", { user: p }, o));
|
|
462
|
+
const title = articleTitle(article, deps.stem ?? "");
|
|
463
|
+
const stem = deps.stem ?? entityStem(title);
|
|
464
|
+
const stems = new Set(deps.stems ?? []);
|
|
465
|
+
stems.add(stem);
|
|
466
|
+
|
|
467
|
+
const noop: ReconcileResult = {
|
|
468
|
+
content: article,
|
|
469
|
+
changed: false,
|
|
470
|
+
refreshedFields: [],
|
|
471
|
+
rewrittenSections: [],
|
|
472
|
+
gateVetoed: false,
|
|
473
|
+
reason: null,
|
|
474
|
+
normalizeNotes: [],
|
|
475
|
+
unresolved: [],
|
|
476
|
+
retried: false,
|
|
477
|
+
historyEntries: [],
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// The verdict — the latest-dated position — lives in the LEAD. With no lead
|
|
481
|
+
// there is nothing to resolve the infobox against.
|
|
482
|
+
const verdict = parseLead(article);
|
|
483
|
+
if (!verdict || !verdict.trim()) return { ...noop, reason: "no lead verdict to reconcile against" };
|
|
484
|
+
|
|
485
|
+
let next = article;
|
|
486
|
+
const refreshedFields: string[] = [];
|
|
487
|
+
const rewrittenSections: string[] = [];
|
|
488
|
+
const unresolved: string[] = [];
|
|
489
|
+
let retried = false;
|
|
490
|
+
|
|
491
|
+
// Provenance lookups read from the ORIGINAL article (markers/hashes/dates are
|
|
492
|
+
// stable there before any rewrite moves them around). Markers are found in the
|
|
493
|
+
// PROSE only — the infobox value precedes the body, so scanning it would mis-
|
|
494
|
+
// attribute a value to the lead's citation.
|
|
495
|
+
const prose = article.replace(/```info[\s\S]*?```/i, "");
|
|
496
|
+
const refDates = referenceDatesByMarker(article);
|
|
497
|
+
const fnState = articleFootnoteState(article);
|
|
498
|
+
const hashByMarker = new Map<number, string>();
|
|
499
|
+
for (const [h, n] of fnState.byHash) hashByMarker.set(n, h);
|
|
500
|
+
|
|
501
|
+
// (1) INFOBOX relationship fields — resolve any that contradict the verdict.
|
|
502
|
+
// Each resolved field carries a GROUND-TRUTH value; a refreshed one also yields a
|
|
503
|
+
// KNOWN superseded (old) value, the precise correction to propagate into the prose.
|
|
504
|
+
// CLASSIFY each contradiction: a relationship/opinion/stance the user CHANGED over
|
|
505
|
+
// time is an EVOLUTION (keep the trajectory in `## History`); a wrong world-fact /
|
|
506
|
+
// spec is an ERROR (silently overwrite, prune the wrong value entirely).
|
|
507
|
+
interface Correction {
|
|
508
|
+
groundTruth: string;
|
|
509
|
+
superseded: string;
|
|
510
|
+
kind: ContradictionKind;
|
|
511
|
+
earlierMarker: number | null;
|
|
512
|
+
laterMarker: number | null;
|
|
513
|
+
laterDate: string | null;
|
|
514
|
+
}
|
|
515
|
+
const corrections: Correction[] = [];
|
|
516
|
+
const groundTruthOnly: string[] = []; // resolved values with no known old value (KEEP)
|
|
517
|
+
const box = parseInfobox(article);
|
|
518
|
+
if (box) {
|
|
519
|
+
for (const f of box.fields) {
|
|
520
|
+
if (!isRelationshipKey(f.key)) continue; // world-fact / spec → untouched
|
|
521
|
+
const old = f.value.trim();
|
|
522
|
+
const reply = await call(buildInfoboxReconcilePrompt(verdict, f.key, f.value), { maxTokens: MAX_OUTPUT_TOKENS });
|
|
523
|
+
const cleaned = cleanValue(reply, f.key);
|
|
524
|
+
if (cleaned && !/^keep$/i.test(cleaned) && cleaned !== old) {
|
|
525
|
+
next = setInfoboxFieldValue(next, f.key, cleaned);
|
|
526
|
+
refreshedFields.push(f.key);
|
|
527
|
+
const kind = classifyContradiction(
|
|
528
|
+
await call(buildContradictionClassifierPrompt(old, cleaned), { maxTokens: MAX_OUTPUT_TOKENS }),
|
|
529
|
+
);
|
|
530
|
+
const earlierMarker = markerNear(prose, old);
|
|
531
|
+
const laterMarker = markerNear(prose, cleaned);
|
|
532
|
+
corrections.push({
|
|
533
|
+
groundTruth: cleaned,
|
|
534
|
+
superseded: old,
|
|
535
|
+
kind,
|
|
536
|
+
earlierMarker,
|
|
537
|
+
laterMarker,
|
|
538
|
+
laterDate: laterMarker != null ? refDates.get(laterMarker) ?? null : null,
|
|
539
|
+
});
|
|
540
|
+
} else {
|
|
541
|
+
groundTruthOnly.push(old); // already current — superseded value unknown
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// (2) PROSE — the LEAD and every body section must agree with the ground truth.
|
|
547
|
+
// The LEAD is held to a CLEAN CURRENT-STATE summary (no "previously …" caveats);
|
|
548
|
+
// an ERROR correction strips the wrong value from EVERY passage; an EVOLUTION
|
|
549
|
+
// keeps the body's in-context trajectory and consolidates it into `## History`.
|
|
550
|
+
for (const passage of collectPassages(next)) {
|
|
551
|
+
let body = passage.body;
|
|
552
|
+
let touched = false;
|
|
553
|
+
const isLead = passage.label === "lead";
|
|
554
|
+
|
|
555
|
+
// (2a) KNOWN corrections (infobox refresh old→new): deterministic + reliable.
|
|
556
|
+
for (const corr of corrections) {
|
|
557
|
+
const live: Passage = { ...passage, body };
|
|
558
|
+
const currentStateOnly = isLead || corr.kind === "error";
|
|
559
|
+
const fix = currentStateOnly
|
|
560
|
+
? await reconcileOnePassageCurrentState(call, live, corr.groundTruth, corr.superseded, corr.kind === "error")
|
|
561
|
+
: await reconcileOnePassage(call, live, corr.groundTruth, corr.superseded);
|
|
562
|
+
retried ||= fix.retried;
|
|
563
|
+
if (fix.unresolved) unresolved.push(passage.label);
|
|
564
|
+
if (fix.changed) {
|
|
565
|
+
body = fix.body;
|
|
566
|
+
touched = true;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// (2b) Ground-truth fields the infobox already had right (no known old value):
|
|
571
|
+
// ask the model whether the prose names a DIFFERENT value as current, then run
|
|
572
|
+
// the same constrained rewrite. Guarded by the deterministic check so a model
|
|
573
|
+
// hallucination cannot trigger a needless rewrite.
|
|
574
|
+
for (const gt of groundTruthOnly) {
|
|
575
|
+
const detect = cleanValue(
|
|
576
|
+
await call(buildProseConflictPrompt(passage.label, gt, body), { maxTokens: MAX_OUTPUT_TOKENS }),
|
|
577
|
+
"",
|
|
578
|
+
);
|
|
579
|
+
if (!detect || /^keep$/i.test(detect) || detect.toLowerCase() === gt.toLowerCase()) continue;
|
|
580
|
+
const live: Passage = { ...passage, body };
|
|
581
|
+
const fix = await reconcileOnePassage(call, live, gt, detect);
|
|
582
|
+
retried ||= fix.retried;
|
|
583
|
+
if (fix.unresolved) unresolved.push(passage.label);
|
|
584
|
+
if (fix.changed) {
|
|
585
|
+
body = fix.body;
|
|
586
|
+
touched = true;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (touched && body !== passage.body) {
|
|
591
|
+
next = passage.write(next, body);
|
|
592
|
+
rewrittenSections.push(passage.label);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// (3) HISTORY — for EVOLUTION corrections, PRESERVE the trajectory in a
|
|
597
|
+
// `## History` section (we keep the history). For ERROR corrections, the wrong
|
|
598
|
+
// value's citation may have been pruned everywhere — allow the gate to drop it.
|
|
599
|
+
const historyEntries: string[] = [];
|
|
600
|
+
const allowDroppedHashes = new Set<string>();
|
|
601
|
+
for (const corr of corrections) {
|
|
602
|
+
if (corr.kind === "evolution") {
|
|
603
|
+
const entry = buildHistoryEntry({
|
|
604
|
+
earlier: corr.superseded,
|
|
605
|
+
earlierMarker: corr.earlierMarker,
|
|
606
|
+
later: corr.groundTruth,
|
|
607
|
+
laterMarker: corr.laterMarker,
|
|
608
|
+
laterDate: corr.laterDate,
|
|
609
|
+
});
|
|
610
|
+
const withHistory = upsertHistoryEntry(next, entry);
|
|
611
|
+
if (withHistory !== next) {
|
|
612
|
+
next = withHistory;
|
|
613
|
+
historyEntries.push(entry);
|
|
614
|
+
}
|
|
615
|
+
} else if (corr.earlierMarker != null) {
|
|
616
|
+
// A factual error: the wrong value (and only it) is allowed to lose its cite.
|
|
617
|
+
const h = hashByMarker.get(corr.earlierMarker);
|
|
618
|
+
if (h) allowDroppedHashes.add(h);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (!refreshedFields.length && !rewrittenSections.length && !historyEntries.length) {
|
|
623
|
+
return { ...noop, unresolved, retried }; // already consistent (or unrepairable)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// NORMALIZE then the conservative gate over (original, reconciled).
|
|
627
|
+
const norm = normalizeArticle(next, { stem, stems });
|
|
628
|
+
if (norm.content === article) return { ...noop, normalizeNotes: norm.notes, unresolved, retried };
|
|
629
|
+
const verdictGate = gateEdit(article, norm.content, { allowDroppedHashes });
|
|
630
|
+
if (!verdictGate.ok) {
|
|
631
|
+
return { ...noop, gateVetoed: true, reason: verdictGate.reason, normalizeNotes: norm.notes, unresolved, retried };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
content: norm.content,
|
|
636
|
+
changed: true,
|
|
637
|
+
refreshedFields,
|
|
638
|
+
rewrittenSections,
|
|
639
|
+
gateVetoed: false,
|
|
640
|
+
reason: null,
|
|
641
|
+
normalizeNotes: norm.notes,
|
|
642
|
+
unresolved,
|
|
643
|
+
retried,
|
|
644
|
+
historyEntries,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---- effectful entry: reconcile a vault article + ledger -------------------
|
|
649
|
+
|
|
650
|
+
export interface ReconcileArticleOpts {
|
|
651
|
+
/** Vault root (honors MLX_BUN_WIKI); defaults to `vaultRoot()`. */
|
|
652
|
+
root?: string;
|
|
653
|
+
/** Model-call override (tests inject a fake). */
|
|
654
|
+
call?: SynthesisCall;
|
|
655
|
+
/** Skip the git commit (tests). */
|
|
656
|
+
commit?: boolean;
|
|
657
|
+
now?: number;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export interface ReconcileArticleResult {
|
|
661
|
+
stem: string;
|
|
662
|
+
reconciled: boolean;
|
|
663
|
+
refreshedFields: string[];
|
|
664
|
+
rewrittenSections: string[];
|
|
665
|
+
gateVetoed: boolean;
|
|
666
|
+
reason: string | null;
|
|
667
|
+
/** Passages the model could not demote even after the retry (honest reporting). */
|
|
668
|
+
unresolved: string[];
|
|
669
|
+
/** True when at least one rewrite needed the sharper retry. */
|
|
670
|
+
retried: boolean;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Reconcile a vault article on disk and persist it: read the file, run
|
|
675
|
+
* {@link reconcileContent}, write back + record a `reconciled_articles` ledger row
|
|
676
|
+
* when it changed, and commit (unless suppressed). A consistent article writes
|
|
677
|
+
* nothing. Used by SYNTHESIZE after the PATCH loop for every article it touched.
|
|
678
|
+
*/
|
|
679
|
+
export async function reconcileArticle(
|
|
680
|
+
store: MemoryStore,
|
|
681
|
+
stem: string,
|
|
682
|
+
opts: ReconcileArticleOpts = {},
|
|
683
|
+
): Promise<ReconcileArticleResult> {
|
|
684
|
+
const root = opts.root ?? vaultRoot();
|
|
685
|
+
const base: ReconcileArticleResult = {
|
|
686
|
+
stem,
|
|
687
|
+
reconciled: false,
|
|
688
|
+
refreshedFields: [],
|
|
689
|
+
rewrittenSections: [],
|
|
690
|
+
gateVetoed: false,
|
|
691
|
+
reason: null,
|
|
692
|
+
unresolved: [],
|
|
693
|
+
retried: false,
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const path = join(articlesDir(root), `${stem}.md`);
|
|
697
|
+
let article: string;
|
|
698
|
+
try {
|
|
699
|
+
article = await readFile(path, "utf8");
|
|
700
|
+
} catch {
|
|
701
|
+
return { ...base, reason: `article not found: ${stem}` };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const stems = new Set(await listArticles(root));
|
|
705
|
+
const res = await reconcileContent(article, { call: opts.call, stems, stem });
|
|
706
|
+
if (!res.changed) {
|
|
707
|
+
return { ...base, gateVetoed: res.gateVetoed, reason: res.reason, unresolved: res.unresolved, retried: res.retried };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
await writeFile(path, res.content);
|
|
711
|
+
|
|
712
|
+
const now = opts.now ?? Date.now();
|
|
713
|
+
store.db
|
|
714
|
+
.query(
|
|
715
|
+
"INSERT OR REPLACE INTO reconciled_articles (article_stem, reconciled_at, refreshed, rewritten) VALUES (?,?,?,?)",
|
|
716
|
+
)
|
|
717
|
+
.run(stem, now, res.refreshedFields.join(","), res.rewrittenSections.join(","));
|
|
718
|
+
|
|
719
|
+
if (opts.commit !== false) {
|
|
720
|
+
await commitVault(
|
|
721
|
+
root,
|
|
722
|
+
`memory: reconcile ${stem} (${res.refreshedFields.length} field(s), ${res.rewrittenSections.length} section(s))`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
...base,
|
|
728
|
+
reconciled: true,
|
|
729
|
+
refreshedFields: res.refreshedFields,
|
|
730
|
+
rewrittenSections: res.rewrittenSections,
|
|
731
|
+
unresolved: res.unresolved,
|
|
732
|
+
retried: res.retried,
|
|
733
|
+
};
|
|
734
|
+
}
|