@zosmaai/pi-llm-wiki 0.11.0 → 0.11.2

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.de.md +19 -1
  3. package/README.es.md +19 -1
  4. package/README.fr.md +19 -1
  5. package/README.hi.md +19 -1
  6. package/README.ja.md +19 -1
  7. package/README.ko.md +19 -1
  8. package/README.md +38 -3
  9. package/README.pt.md +19 -1
  10. package/README.ru.md +19 -1
  11. package/README.zh.md +19 -1
  12. package/dist/extensions/llm-wiki/lib/bootstrap.js +4 -1
  13. package/dist/extensions/llm-wiki/lib/ingest-worker.js +129 -29
  14. package/dist/extensions/llm-wiki/lib/metadata.js +37 -31
  15. package/dist/extensions/llm-wiki/lib/model-command.js +0 -1
  16. package/dist/extensions/llm-wiki/lib/runtime.js +0 -4
  17. package/dist/extensions/llm-wiki/lib/source-packet.js +1 -1
  18. package/dist/extensions/llm-wiki/lib/task-config.js +29 -0
  19. package/dist/extensions/llm-wiki/lib/tools.js +7 -0
  20. package/dist/extensions/llm-wiki/lib/utils.js +6 -0
  21. package/dist/mcp/index.js +2 -1
  22. package/docs/api.md +5 -2
  23. package/docs/architecture.md +5 -2
  24. package/docs/configuration.md +25 -0
  25. package/docs/superpowers/plans/2026-08-06-authoritative-event-history-phase-1-foundation-hardening.md +937 -0
  26. package/docs/superpowers/plans/2026-08-07-synthesis-language.md +98 -0
  27. package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +17 -2
  28. package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +6 -2
  29. package/docs/superpowers/specs/2026-08-07-synthesis-language-design.md +94 -0
  30. package/extensions/llm-wiki/lib/bootstrap.ts +4 -1
  31. package/extensions/llm-wiki/lib/ingest-worker.ts +161 -26
  32. package/extensions/llm-wiki/lib/knowledge-document.ts +2 -0
  33. package/extensions/llm-wiki/lib/metadata.ts +38 -31
  34. package/extensions/llm-wiki/lib/model-command.ts +0 -1
  35. package/extensions/llm-wiki/lib/runtime.ts +0 -3
  36. package/extensions/llm-wiki/lib/source-packet.ts +1 -1
  37. package/extensions/llm-wiki/lib/task-config.ts +36 -0
  38. package/extensions/llm-wiki/lib/tools.ts +9 -0
  39. package/extensions/llm-wiki/lib/utils.ts +7 -0
  40. package/mcp/index.ts +2 -1
  41. package/package.json +2 -2
  42. package/skills/llm-wiki/SKILL.md +4 -2
@@ -0,0 +1,98 @@
1
+ # Configurable Synthesis Language Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use /skill:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Allow vault owners to configure the narrative language used by background ingest synthesis via `.pi/settings.json`.
6
+
7
+ **Architecture:** Add `synthesisLanguage` field to `TaskConfig`, parse it in `readNamespacedConfig`, pass it through `wiki_ingest` → `runIngestSynthesis`, and conditionally append a language instruction to the ingest worker's system prompt.
8
+
9
+ **Tech Stack:** TypeScript (ES2022, ESM), Vitest, Biome
10
+
11
+ **Roadmap:** None
12
+
13
+ **Phase:** Single-plan implementation
14
+
15
+ ---
16
+
17
+ ### Task 1: Add `synthesisLanguage` to TaskConfig
18
+
19
+ **Files:**
20
+ - Modify: `extensions/llm-wiki/lib/task-config.ts`
21
+
22
+ - [ ] Add `synthesisLanguage?: string` field to `TaskConfig` interface (after `trajectories`)
23
+ - [ ] In `readNamespacedConfig`, parse `section.synthesisLanguage` as a non-empty trimmed string:
24
+ ```ts
25
+ const lang = section.synthesisLanguage;
26
+ if (typeof lang === "string" && lang.trim()) out.synthesisLanguage = lang.trim();
27
+ ```
28
+ - [ ] Run `pnpm typecheck` — confirm no errors
29
+ - [ ] Commit: `feat: add synthesisLanguage config field`
30
+
31
+ ### Task 2: Wire `synthesisLanguage` into ingest worker
32
+
33
+ **Files:**
34
+ - Modify: `extensions/llm-wiki/lib/ingest-worker.ts`
35
+
36
+ - [ ] Add `synthesisLanguage?: string` to `RunIngestSynthesisArgs` interface
37
+ - [ ] In `runIngestSynthesis`, after destructuring args, build the system prompt:
38
+ ```ts
39
+ const languageInstruction = synthesisLanguage
40
+ ? `\n\nWrite all generated narrative content in ${synthesisLanguage}. Preserve product names, repository names, APIs, paths, commands, code, field names, and technical identifiers in their original form.`
41
+ : "";
42
+ const systemPrompt = INGEST_SYSTEM + languageInstruction;
43
+ ```
44
+ - [ ] Pass `systemPrompt` (instead of `INGEST_SYSTEM`) to `runSubAgent`
45
+ - [ ] Run `pnpm typecheck` — confirm no errors
46
+ - [ ] Commit: `feat: inject synthesisLanguage into ingest system prompt`
47
+
48
+ ### Task 3: Pass `synthesisLanguage` from wiki_ingest tool
49
+
50
+ **Files:**
51
+ - Modify: `extensions/llm-wiki/lib/tools.ts`
52
+
53
+ - [ ] In the `wiki_ingest` tool's background synthesis block (around line 409), pass `synthesisLanguage` to `runIngestSynthesis`:
54
+ ```ts
55
+ const committed = await runIngestSynthesis({
56
+ model: resolved.model as Parameters<typeof runIngestSynthesis>[0]["model"],
57
+ apiKey: resolved.apiKey,
58
+ headers: resolved.headers,
59
+ paths,
60
+ sourceId: s.id,
61
+ manifest: s.manifest,
62
+ extracted: s.extracted,
63
+ synthesisLanguage: runtime.config.synthesisLanguage,
64
+ });
65
+ ```
66
+ - [ ] Run `pnpm typecheck` — confirm no errors
67
+ - [ ] Run `pnpm test` — confirm existing tests pass
68
+ - [ ] Commit: `feat: wire synthesisLanguage through wiki_ingest tool`
69
+
70
+ ### Task 4: Add unit test for synthesis language injection
71
+
72
+ **Files:**
73
+ - Create: `extensions/llm-wiki/test/ingest-worker-synthesis-language.test.ts`
74
+
75
+ - [ ] Write a focused test: verify that when `synthesisLanguage` is set, the language instruction is appended to the system prompt
76
+ - Mock `runSubAgent` or inspect the constructed prompt
77
+ - Assert the instruction contains the configured language tag and the preservation clause
78
+ - [ ] Run `pnpm test` — confirm test passes
79
+ - [ ] Commit: `test: verify synthesisLanguage injection`
80
+
81
+ ### Task 5: Update documentation
82
+
83
+ **Files:**
84
+ - Modify: `docs/configuration.md`
85
+
86
+ - [ ] Add a section for `synthesisLanguage` under the `llm-wiki` config docs:
87
+ - Description: language for background ingest synthesis narrative content
88
+ - Type: BCP 47 language tag (string)
89
+ - Default: undefined (English synthesis)
90
+ - Example JSON snippet
91
+ - [ ] Commit: `docs: document synthesisLanguage config`
92
+
93
+ ### Task 6: Final verification
94
+
95
+ - [ ] Run `pnpm lint` — confirm no Biome issues
96
+ - [ ] Run `pnpm test` — confirm all tests pass
97
+ - [ ] Run `pnpm typecheck` — confirm no TypeScript errors
98
+ - [ ] Commit: `ci: verify synthesisLanguage implementation`
@@ -43,7 +43,7 @@ Those requirements belong to later child specs.
43
43
  2. **Mode controls bundle behavior, not readability.** Every mode reads both legacy and OKF-shaped pages. Mode controls reserved files and generated projections.
44
44
  3. **Missing metadata does not become invented metadata.** Foundation never fabricates authorship, verification, provenance, or timestamps it cannot know.
45
45
  4. **Unknown data survives rewrites.** Unknown ordinary YAML fields remain semantically equivalent after parse and serialize.
46
- 5. **Generated files are projections.** Registry, backlinks, indexes, and logs derive from authoritative pages and events.
46
+ 5. **Generated files are projections; authoritative extension state is not.** Registry, backlinks, indexes, and logs derive from authoritative pages and events. `meta/events.jsonl` is extension-written state, but it is not generated metadata because no rebuild can reconstruct it.
47
47
  6. **Invalid explicit configuration fails closed.** Unknown mode and OKF version values never silently downgrade to legacy behavior.
48
48
 
49
49
  ## Vault Mode
@@ -350,6 +350,12 @@ An index is a projection. Users and importers cannot use it to hide a concept fr
350
350
 
351
351
  `meta/events.jsonl` remains the authoritative append-only event source in both modes. `wiki/log.md` is a generated OKF projection only in `okf-0.2` mode. Foundation generates no per-directory logs.
352
352
 
353
+ `meta/events.jsonl` is durable extension-owned vault state. Users who need activity continuity must preserve it when backing up or synchronizing a complete pi-llm-wiki vault. It is not derivable from canonical pages, raw source packets, `meta/log.md`, or `wiki/log.md`.
354
+
355
+ Foundation does not make the JSONL event source part of the distributable OKF bundle. `wiki/log.md` is a portable snapshot of recorded activity at projection time, not a recovery format and not a promise that an imported bundle can continue the originating vault's event stream. Import, export, and imported-history composition belong to the later Interchange child specification.
356
+
357
+ The event stream records selected extension operations. It is not a complete revision history: manual file edits do not fabricate events, while extension-owned operational actions may emit events. Documentation and UI text must call it an activity history rather than a complete content audit trail.
358
+
353
359
  ### Authoritative event shape
354
360
 
355
361
  Each valid JSONL line must contain:
@@ -363,6 +369,8 @@ Each valid JSONL line must contain:
363
369
 
364
370
  Additional JSON-compatible fields are allowed. Event production is mode-independent: the same successful capture, creation, update, retro, observation, ingestion, and other tool-owned mutations append the same event in legacy and OKF modes. Event writers append only after the associated authoritative wiki mutation succeeds. A projection rebuild itself does not append an event. Manual file edits do not fabricate events because the extension cannot infer actor or intent safely.
365
371
 
372
+ Fields projected into `wiki/log.md` must be safe for a distributable bundle. A local file capture event records its stable `source_id` and format but not the caller-supplied `file_path`; the exact path remains in the extension-owned raw source manifest. Manual event details are user-controlled and documentation must warn callers not to include secrets or machine-local paths intended to remain private.
373
+
366
374
  ### Log template
367
375
 
368
376
  ```markdown
@@ -398,7 +406,14 @@ If any concept has malformed frontmatter, normalized identity collision, or unsu
398
406
  - preserve the previous registry, backlinks, indexes, and logs
399
407
  - do not publish a partial metadata generation
400
408
 
401
- Unresolved links and malformed event lines are non-blocking projection diagnostics: valid concepts may still be indexed, and the previous semantics for reporting gaps remain available. The rebuild result reports every diagnostic.
409
+ Unresolved links and malformed event lines are non-blocking projection diagnostics: valid concepts may still be indexed, and valid event lines may still be projected. A missing or unreadable `meta/events.jsonl` is different from a present empty stream. Rebuild reports `event_source_missing` or `event_source_unreadable`, continues publishing registry, backlink, and index projections, and leaves existing `meta/log.md` and `wiki/log.md` byte-identical. A present zero-byte event file is an explicitly empty authoritative stream and generates the normal empty log projections.
410
+
411
+ Non-blocking diagnostics include:
412
+
413
+ - `link_unresolved`
414
+ - `event_invalid_json`
415
+ - `event_source_missing`
416
+ - `event_source_unreadable`
402
417
 
403
418
  On successful rebuild:
404
419
 
@@ -106,12 +106,14 @@ The competitive advantage for pi-llm-wiki is combining OKF interoperability with
106
106
  ├── imports/ # extension-owned import staging and audit records
107
107
  │ ├── pending/<import-id>/
108
108
  │ └── applied/<import-id>/manifest.json
109
- ├── meta/ # generated registry, backlinks, embeddings
109
+ ├── meta/ # durable local events + generated internal projections
110
110
  └── outputs/ # reports and explicit exports
111
111
  ```
112
112
 
113
113
  `.llm-wiki/wiki/` is the distributable OKF bundle. Product-specific raw packets, staging state, and generated search metadata remain outside it.
114
114
 
115
+ `meta/events.jsonl` is durable local pi-llm-wiki state but is not part of the base OKF bundle. A full-vault backup preserves it; an OKF-only export does not. The exported `wiki/log.md` is therefore a readable history snapshot, not a lossless or resumable event source.
116
+
115
117
  Source pages inside the bundle provide stable provenance targets for canonical pages. Raw packet paths may remain pi-llm-wiki extension metadata on source pages, but portable provenance references should resolve to source pages or external resources rather than escaping the bundle.
116
118
 
117
119
  `imports/**` is extension-owned and protected by the same tool-call guardrail model as `raw/**` and `meta/**`. Pending imports never participate in recall or metadata indexing.
@@ -253,7 +255,7 @@ The metadata rebuild writes:
253
255
 
254
256
  Each directory index lists only direct concepts and immediate child directories. This preserves progressive disclosure and avoids loading a recursive catalog into context.
255
257
 
256
- Reserved `index.md` and `log.md` files are not concept documents and are excluded from ordinary concept recall. Imported reserved files are validated and recorded in the import manifest, but the live bundle regenerates its own indexes and log. Foreign concept paths and document-level metadata are preserved; arbitrary foreign index prose is not merged into the live generated index.
258
+ Reserved `index.md` and `log.md` files are not concept documents and are excluded from ordinary concept recall. Imported reserved files are validated and recorded in the import manifest, but the live bundle regenerates its own indexes and log. Foreign concept paths and document-level metadata are preserved; arbitrary foreign index prose is not merged into the live generated index. Before Interchange implementation, its normative child spec must define whether an imported `log.md` is archived, retained as a separate historical baseline, or replaced when a new local event stream begins. It must not imply that Markdown prose can reconstruct the originating JSONL stream.
257
259
 
258
260
  ## Import Design
259
261
 
@@ -334,6 +336,8 @@ live legacy + OKF pages
334
336
 
335
337
  Export never mutates the live vault. It preserves safe concept paths, bodies, unknown document fields, standard metadata, and pi-llm-wiki extension fields. Legacy pages are converted in memory. Generated `meta/**`, pending imports, source packet internals, and embeddings are not exported.
336
338
 
339
+ Because `meta/events.jsonl` is excluded, exported `log.md` is a deterministic snapshot rather than a resumable event ledger. Export must use only bundle-safe projected fields. Portable event continuity or a machine-readable event sidecar requires a separately reviewed Interchange decision and is not implied by Foundation.
340
+
337
341
  Raw evidence remains represented through portable source concept pages and their provenance links. A later option may package selected original artifacts under an OKF `references/` convention.
338
342
 
339
343
  ## Migration Design
@@ -0,0 +1,94 @@
1
+ # Configurable Language for Background Ingest Synthesis
2
+
3
+ **Issue:** [#124](https://github.com/zosmaai/pi-llm-wiki/issues/124)
4
+ **Date:** 2026-08-07
5
+ **Status:** Approved
6
+
7
+ ## Problem
8
+
9
+ Background ingest synthesis (`wiki_ingest(background=true)`) always produces English narrative content, regardless of the vault's authoring language. The background sub-agent does not inherit language instructions from `AGENTS.md`, `APPEND_SYSTEM.md`, `WIKI_SCHEMA.md`, or the main session prompt.
10
+
11
+ Workarounds are inadequate:
12
+ - `background=false` returns extracted content to the main session, consuming context window.
13
+ - Manual translation after ingest is error-prone and defeats the purpose of background synthesis.
14
+
15
+ ## Goal
16
+
17
+ Allow vault owners to configure the narrative language used by background ingest synthesis, without copying the main conversation context into the background worker.
18
+
19
+ ## Design
20
+
21
+ ### Configuration
22
+
23
+ - **Field:** `synthesisLanguage`
24
+ - **Type:** BCP 47 language tag (e.g., `"ru"`, `"fr"`, `"en"`)
25
+ - **Location:** `.pi/settings.json` under the `llm-wiki` namespace
26
+ - **Default:** undefined (no change to current behavior)
27
+
28
+ Example:
29
+ ```json
30
+ {
31
+ "llm-wiki": {
32
+ "synthesisLanguage": "ru"
33
+ }
34
+ }
35
+ ```
36
+
37
+ This matches the existing pattern used by `taskModel`, `trajectories`, `notices`, etc.
38
+
39
+ ### System Prompt Modification
40
+
41
+ When `synthesisLanguage` is configured, the background ingest worker appends a fixed instruction block to its system prompt (`INGEST_SYSTEM` in `ingest-worker.ts`):
42
+
43
+ > Write all generated content in {language}, including titles, headings, summaries, descriptions, and concept/entity names. Only preserve code, API names, file paths, commands, exact technical identifiers, and verbatim quotations in their original form.
44
+
45
+ The BCP 47 tag is validated using `Intl.getCanonicalLocales()` and canonicalized before use. Invalid or suspicious tags (containing newlines, quotes, or instruction-like words) are rejected.
46
+
47
+ Additionally, the page renderer translates fixed headings (Summary, Key Takeaways, etc.) into the configured language for supported languages (Russian, French, German, Japanese). Unsupported languages fall back to English headings.
48
+
49
+ ### Scope
50
+
51
+ The language setting applies to LLM-generated narrative fields:
52
+ - Page titles
53
+ - Headings
54
+ - Summaries
55
+ - Descriptions
56
+ - Key takeaways
57
+ - Conclusions
58
+ - Entity and concept descriptions
59
+ - Synthesis and analysis text
60
+
61
+ It does NOT apply to:
62
+ - Raw captured source content (`extracted.md`)
63
+ - Code blocks
64
+ - Technical identifiers (API names, paths, commands, field names)
65
+ - Source quotations
66
+
67
+ ### Implementation Changes
68
+
69
+ 1. **`lib/task-config.ts`**
70
+ - Add `synthesisLanguage?: string` to `TaskConfig` interface
71
+ - Parse in `readNamespacedConfig` as a non-empty trimmed string
72
+
73
+ 2. **`lib/ingest-worker.ts`**
74
+ - Add `synthesisLanguage?: string` to `RunIngestSynthesisArgs`
75
+ - In `runIngestSynthesis`, conditionally append the language instruction to `INGEST_SYSTEM` when `synthesisLanguage` is set
76
+
77
+ 3. **`lib/tools.ts`** (wiki_ingest tool)
78
+ - Pass `runtime.config.synthesisLanguage` into `runIngestSynthesis` args
79
+
80
+ ### Acceptance Criteria
81
+
82
+ - [ ] Background ingest generates synthesis in the configured language
83
+ - [ ] Configuration works with `wiki_ingest(background=true)`
84
+ - [ ] Main conversation context is NOT copied into the background worker
85
+ - [ ] Existing behavior unchanged when no language is configured
86
+ - [ ] Technical identifiers and source quotations remain in their original language
87
+ - [ ] Generated titles, headings, summaries, and conclusions consistently follow the configured language
88
+
89
+ ## Out of Scope
90
+
91
+ - Per-source language override
92
+ - Automatic language detection from source content
93
+ - User-editable prompt template (fixed wording for now)
94
+ - Language setting for other background tasks (embeddings, topic inference) — can be added later if needed
@@ -14,9 +14,12 @@ export const WIKI_SCHEMA = [
14
14
  "|------|-------|------|",
15
15
  "| raw/** | extension | immutable after capture |",
16
16
  "| wiki/** | model + user | editable knowledge pages |",
17
- "| meta/* | extension | auto-generated |",
17
+ "| meta/events.jsonl | extension tools | append-only authoritative state |",
18
+ "| meta/* except events.jsonl | extension | generated projections |",
18
19
  "| . | human + explicit request | operating rules |",
19
20
  "",
21
+ "Back up `meta/events.jsonl` to preserve activity history. Generated logs cannot reconstruct it.",
22
+ "",
20
23
  "## Source Packet Format",
21
24
  "",
22
25
  "```",
@@ -92,13 +92,113 @@ export type CommitSynthesisOutcome =
92
92
 
93
93
  // ── deterministic persistence (no LLM) ────────────────────
94
94
 
95
- function buildEntityPageBody(title: string, description: string, sourceId: string): string {
95
+ /** Localized headings for generated pages (issue #124). */
96
+ const HEADINGS: Record<string, Record<string, string>> = {
97
+ ru: {
98
+ summary: "Резюме",
99
+ keyTakeaways: "Ключевые выводы",
100
+ entitiesMentioned: "Упомянутые сущности",
101
+ conceptsMentioned: "Упомянутые концепции",
102
+ notableQuotes: "Заметные цитаты",
103
+ contradictions: "Противоречия",
104
+ sourcePacket: "Пакет источника",
105
+ overview: "Обзор",
106
+ definition: "Определение",
107
+ noneRecorded: "[Не записано]",
108
+ none: "[Нет]",
109
+ id: "ID",
110
+ extracted: "Извлечено",
111
+ manifest: "Манифест",
112
+ contradiction: "⚠️ **Противоречие**",
113
+ },
114
+ fr: {
115
+ summary: "Résumé",
116
+ keyTakeaways: "Points clés",
117
+ entitiesMentioned: "Entités mentionnées",
118
+ conceptsMentioned: "Concepts mentionnés",
119
+ notableQuotes: "Citations notables",
120
+ contradictions: "Contradictions",
121
+ sourcePacket: "Paquet source",
122
+ overview: "Aperçu",
123
+ definition: "Définition",
124
+ noneRecorded: "[Aucun enregistré]",
125
+ none: "[Aucun]",
126
+ id: "ID",
127
+ extracted: "Extrait",
128
+ manifest: "Manifeste",
129
+ contradiction: "⚠️ **Contradiction**",
130
+ },
131
+ de: {
132
+ summary: "Zusammenfassung",
133
+ keyTakeaways: "Wichtige Erkenntnisse",
134
+ entitiesMentioned: "Erwähnte Entitäten",
135
+ conceptsMentioned: "Erwähnte Konzepte",
136
+ notableQuotes: "Bemerkenswerte Zitate",
137
+ contradictions: "Widersprüche",
138
+ sourcePacket: "Quellenpaket",
139
+ overview: "Übersicht",
140
+ definition: "Definition",
141
+ noneRecorded: "[Keine aufgezeichnet]",
142
+ none: "[Keine]",
143
+ id: "ID",
144
+ extracted: "Extrahiert",
145
+ manifest: "Manifest",
146
+ contradiction: "⚠️ **Widerspruch**",
147
+ },
148
+ ja: {
149
+ summary: "要約",
150
+ keyTakeaways: "主な要点",
151
+ entitiesMentioned: "言及されたエンティティ",
152
+ conceptsMentioned: "言及された概念",
153
+ notableQuotes: "注目すべき引用",
154
+ contradictions: "矛盾",
155
+ sourcePacket: "ソースパケット",
156
+ overview: "概要",
157
+ definition: "定義",
158
+ noneRecorded: "[記録なし]",
159
+ none: "[なし]",
160
+ id: "ID",
161
+ extracted: "抽出済み",
162
+ manifest: "マニフェスト",
163
+ contradiction: "⚠️ **矛盾**",
164
+ },
165
+ };
166
+
167
+ function getHeadings(lang?: string): Record<string, string> {
168
+ if (lang && HEADINGS[lang]) return HEADINGS[lang];
169
+ // English defaults
170
+ return {
171
+ summary: "Summary",
172
+ keyTakeaways: "Key Takeaways",
173
+ entitiesMentioned: "Entities Mentioned",
174
+ conceptsMentioned: "Concepts Mentioned",
175
+ notableQuotes: "Notable Quotes",
176
+ contradictions: "Contradictions",
177
+ sourcePacket: "Source Packet",
178
+ overview: "Overview",
179
+ definition: "Definition",
180
+ noneRecorded: "[None recorded]",
181
+ none: "[None]",
182
+ id: "ID",
183
+ extracted: "Extracted",
184
+ manifest: "Manifest",
185
+ contradiction: "⚠️ **Contradiction**",
186
+ };
187
+ }
188
+
189
+ function buildEntityPageBody(
190
+ title: string,
191
+ description: string,
192
+ sourceId: string,
193
+ lang?: string,
194
+ ): string {
96
195
  const desc = description.trim() || "One-line description.";
196
+ const h = getHeadings(lang);
97
197
  return `# ${title}
98
198
 
99
199
  ${desc}
100
200
 
101
- ## Overview
201
+ ## ${h.overview}
102
202
 
103
203
  [Key facts]
104
204
 
@@ -107,13 +207,19 @@ ${desc}
107
207
  - [${sourceId}](/sources/${sourceId}.md)`;
108
208
  }
109
209
 
110
- function buildConceptPageBody(title: string, definition: string, sourceId: string): string {
210
+ function buildConceptPageBody(
211
+ title: string,
212
+ definition: string,
213
+ sourceId: string,
214
+ lang?: string,
215
+ ): string {
111
216
  const def = definition.trim() || "One-line definition.";
217
+ const h = getHeadings(lang);
112
218
  return `# ${title}
113
219
 
114
220
  ${def}
115
221
 
116
- ## Definition
222
+ ## ${h.definition}
117
223
 
118
224
  [Clear explanation]
119
225
 
@@ -127,60 +233,62 @@ export function buildIngestedSourcePageBody(
127
233
  manifest: Record<string, unknown>,
128
234
  data: SynthesisData,
129
235
  _date: string,
236
+ lang?: string,
130
237
  ): string {
131
238
  const id = String(manifest.id);
132
239
  const title = String(manifest.title || id);
133
240
  const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
241
+ const h = getHeadings(lang);
134
242
 
135
243
  const takeaways =
136
244
  data.key_takeaways.length > 0
137
245
  ? data.key_takeaways.map((t) => `- ${t.trim()}`).join("\n")
138
- : "- [None recorded]";
246
+ : `- ${h.noneRecorded}`;
139
247
  const entities =
140
248
  data.entities.length > 0
141
249
  ? data.entities.map((e) => `- [${e.title}](/entities/${slugify(e.title)}.md)`).join("\n")
142
- : "- [None]";
250
+ : `- ${h.none}`;
143
251
  const concepts =
144
252
  data.concepts.length > 0
145
253
  ? data.concepts.map((c) => `- [${c.title}](/concepts/${slugify(c.title)}.md)`).join("\n")
146
- : "- [None]";
254
+ : `- ${h.none}`;
147
255
  const quotes =
148
256
  data.quotes && data.quotes.length > 0
149
257
  ? data.quotes
150
258
  .map((q) => `> ${q.text.trim()}${q.attribution ? ` — ${q.attribution}` : ""}`)
151
259
  .join("\n\n")
152
- : "> [None recorded]";
260
+ : `> ${h.noneRecorded}`;
153
261
  const contradictions =
154
262
  data.contradictions && data.contradictions.length > 0
155
- ? `\n## Contradictions\n\n${data.contradictions.map((c) => `⚠️ **Contradiction**: ${c.trim()}`).join("\n")}\n`
263
+ ? `\n## ${h.contradictions}\n\n${data.contradictions.map((c) => `${h.contradiction}: ${c.trim()}`).join("\n")}\n`
156
264
  : "";
157
265
 
158
266
  return `# ${title}${url}
159
267
 
160
- ## Summary
268
+ ## ${h.summary}
161
269
 
162
270
  ${data.summary.trim()}
163
271
 
164
- ## Key Takeaways
272
+ ## ${h.keyTakeaways}
165
273
 
166
274
  ${takeaways}
167
275
 
168
- ## Entities Mentioned
276
+ ## ${h.entitiesMentioned}
169
277
 
170
278
  ${entities}
171
279
 
172
- ## Concepts Mentioned
280
+ ## ${h.conceptsMentioned}
173
281
 
174
282
  ${concepts}
175
283
 
176
- ## Notable Quotes
284
+ ## ${h.notableQuotes}
177
285
 
178
286
  ${quotes}
179
- ${contradictions}## Source Packet
287
+ ${contradictions}## ${h.sourcePacket}
180
288
 
181
- - **ID:** \`sources/${id}\`
182
- - **Extracted:** \`raw/sources/${id}/extracted.md\`
183
- - **Manifest:** \`raw/sources/${id}/manifest.json\`
289
+ - **${h.id}:** \`sources/${id}\`
290
+ - **${h.extracted}:** \`raw/sources/${id}/extracted.md\`
291
+ - **${h.manifest}:** \`raw/sources/${id}/manifest.json\`
184
292
  `;
185
293
  }
186
294
 
@@ -189,12 +297,13 @@ export function buildIngestedSourcePage(
189
297
  manifest: Record<string, unknown>,
190
298
  data: SynthesisData,
191
299
  date: string,
300
+ lang?: string,
192
301
  ): string {
193
302
  const id = String(manifest.id);
194
303
  const title = String(manifest.title || id);
195
304
  const format = String(manifest.format || "unknown");
196
305
  const captured = String(manifest.captured || date);
197
- const body = buildIngestedSourcePageBody(manifest, data, date);
306
+ const body = buildIngestedSourcePageBody(manifest, data, date, lang);
198
307
  const doc = createKnowledgeDocument(
199
308
  `sources/${id}.md`,
200
309
  {
@@ -223,6 +332,7 @@ export function commitSynthesis(
223
332
  manifest: Record<string, unknown>,
224
333
  data: SynthesisData,
225
334
  date: string = fmtDate(),
335
+ lang?: string,
226
336
  ): CommitSynthesisOutcome {
227
337
  const result: CommitResult = {
228
338
  sourceId,
@@ -250,7 +360,7 @@ export function commitSynthesis(
250
360
  if (!parsed.ok) return { ok: false, sourceId, diagnostics: parsed.diagnostics };
251
361
  sourceDocument = patchKnowledgeDocument(parsed.document, {
252
362
  fields: { status: "ingested", updated: date },
253
- body: buildIngestedSourcePageBody(manifest, data, date),
363
+ body: buildIngestedSourcePageBody(manifest, data, date, lang),
254
364
  });
255
365
  } else {
256
366
  sourceDocument = createKnowledgeDocument(
@@ -265,7 +375,7 @@ export function commitSynthesis(
265
375
  status: "ingested",
266
376
  updated: date,
267
377
  },
268
- buildIngestedSourcePageBody(manifest, data, date),
378
+ buildIngestedSourcePageBody(manifest, data, date, lang),
269
379
  );
270
380
  }
271
381
  mkdirSync(join(paths.wiki, "sources"), { recursive: true });
@@ -289,7 +399,7 @@ export function commitSynthesis(
289
399
  created: date,
290
400
  updated: date,
291
401
  },
292
- buildEntityPageBody(e.title, e.description, sourceId),
402
+ buildEntityPageBody(e.title, e.description, sourceId, lang),
293
403
  [{ id: sourceId, resource: `/sources/${sourceId}.md` }],
294
404
  );
295
405
  writeKnowledgeDocumentFile(pagePath, entityDoc);
@@ -315,7 +425,7 @@ export function commitSynthesis(
315
425
  created: date,
316
426
  updated: date,
317
427
  },
318
- buildConceptPageBody(c.title, c.definition, sourceId),
428
+ buildConceptPageBody(c.title, c.definition, sourceId, lang),
319
429
  [{ id: sourceId, resource: `/sources/${sourceId}.md` }],
320
430
  );
321
431
  writeKnowledgeDocumentFile(pagePath, conceptDoc);
@@ -363,6 +473,8 @@ export interface RunIngestSynthesisArgs {
363
473
  /** Cap on extracted chars fed to the model (avoid huge prompts). Default 24k. */
364
474
  maxChars?: number;
365
475
  signal?: AbortSignal;
476
+ /** BCP 47 language tag for narrative content (issue #124). */
477
+ synthesisLanguage?: string;
366
478
  }
367
479
 
368
480
  /**
@@ -373,10 +485,26 @@ export interface RunIngestSynthesisArgs {
373
485
  export async function runIngestSynthesis(
374
486
  args: RunIngestSynthesisArgs,
375
487
  ): Promise<CommitResult | undefined> {
376
- const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal } = args;
488
+ const {
489
+ model,
490
+ apiKey,
491
+ headers,
492
+ paths,
493
+ sourceId,
494
+ manifest,
495
+ extracted,
496
+ maxChars,
497
+ signal,
498
+ synthesisLanguage,
499
+ } = args;
377
500
  const content = extracted.slice(0, maxChars ?? 24_000);
378
501
  if (!content.trim()) return undefined;
379
502
 
503
+ const languageInstruction = synthesisLanguage
504
+ ? `\n\nWrite all generated content in ${synthesisLanguage}, including titles, headings, summaries, descriptions, and concept/entity names. Only preserve code, API names, file paths, commands, exact technical identifiers, and verbatim quotations in their original form.`
505
+ : "";
506
+ const systemPrompt = INGEST_SYSTEM + languageInstruction;
507
+
380
508
  let committed: CommitResult | undefined;
381
509
 
382
510
  const commitTool: AgentTool<typeof CommitSynthesisSchema> = {
@@ -386,7 +514,14 @@ export async function runIngestSynthesis(
386
514
  "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
387
515
  parameters: CommitSynthesisSchema,
388
516
  execute: async (_id, params) => {
389
- const outcome = commitSynthesis(paths, sourceId, manifest, params);
517
+ const outcome = commitSynthesis(
518
+ paths,
519
+ sourceId,
520
+ manifest,
521
+ params,
522
+ undefined,
523
+ synthesisLanguage,
524
+ );
390
525
  if (!outcome.ok) {
391
526
  return {
392
527
  content: [{ type: "text", text: `Failed: ${outcome.diagnostics[0].message}` }],
@@ -411,7 +546,7 @@ export async function runIngestSynthesis(
411
546
  model,
412
547
  apiKey,
413
548
  headers,
414
- systemPrompt: INGEST_SYSTEM,
549
+ systemPrompt,
415
550
  userPrompt,
416
551
  tools: [commitTool as AgentTool],
417
552
  signal,
@@ -21,6 +21,8 @@ export type DiagnosticCode =
21
21
  | "okf_version_mismatch"
22
22
  | "link_path_escape"
23
23
  | "link_unresolved"
24
+ | "event_source_missing"
25
+ | "event_source_unreadable"
24
26
  | "event_invalid_json"
25
27
  | "event_invalid_timestamp"
26
28
  | "event_missing_kind";