@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
@@ -48,13 +48,107 @@ export const CommitSynthesisSchema = Type.Object({
48
48
  })),
49
49
  });
50
50
  // ── deterministic persistence (no LLM) ────────────────────
51
- function buildEntityPageBody(title, description, sourceId) {
51
+ /** Localized headings for generated pages (issue #124). */
52
+ const HEADINGS = {
53
+ ru: {
54
+ summary: "Резюме",
55
+ keyTakeaways: "Ключевые выводы",
56
+ entitiesMentioned: "Упомянутые сущности",
57
+ conceptsMentioned: "Упомянутые концепции",
58
+ notableQuotes: "Заметные цитаты",
59
+ contradictions: "Противоречия",
60
+ sourcePacket: "Пакет источника",
61
+ overview: "Обзор",
62
+ definition: "Определение",
63
+ noneRecorded: "[Не записано]",
64
+ none: "[Нет]",
65
+ id: "ID",
66
+ extracted: "Извлечено",
67
+ manifest: "Манифест",
68
+ contradiction: "⚠️ **Противоречие**",
69
+ },
70
+ fr: {
71
+ summary: "Résumé",
72
+ keyTakeaways: "Points clés",
73
+ entitiesMentioned: "Entités mentionnées",
74
+ conceptsMentioned: "Concepts mentionnés",
75
+ notableQuotes: "Citations notables",
76
+ contradictions: "Contradictions",
77
+ sourcePacket: "Paquet source",
78
+ overview: "Aperçu",
79
+ definition: "Définition",
80
+ noneRecorded: "[Aucun enregistré]",
81
+ none: "[Aucun]",
82
+ id: "ID",
83
+ extracted: "Extrait",
84
+ manifest: "Manifeste",
85
+ contradiction: "⚠️ **Contradiction**",
86
+ },
87
+ de: {
88
+ summary: "Zusammenfassung",
89
+ keyTakeaways: "Wichtige Erkenntnisse",
90
+ entitiesMentioned: "Erwähnte Entitäten",
91
+ conceptsMentioned: "Erwähnte Konzepte",
92
+ notableQuotes: "Bemerkenswerte Zitate",
93
+ contradictions: "Widersprüche",
94
+ sourcePacket: "Quellenpaket",
95
+ overview: "Übersicht",
96
+ definition: "Definition",
97
+ noneRecorded: "[Keine aufgezeichnet]",
98
+ none: "[Keine]",
99
+ id: "ID",
100
+ extracted: "Extrahiert",
101
+ manifest: "Manifest",
102
+ contradiction: "⚠️ **Widerspruch**",
103
+ },
104
+ ja: {
105
+ summary: "要約",
106
+ keyTakeaways: "主な要点",
107
+ entitiesMentioned: "言及されたエンティティ",
108
+ conceptsMentioned: "言及された概念",
109
+ notableQuotes: "注目すべき引用",
110
+ contradictions: "矛盾",
111
+ sourcePacket: "ソースパケット",
112
+ overview: "概要",
113
+ definition: "定義",
114
+ noneRecorded: "[記録なし]",
115
+ none: "[なし]",
116
+ id: "ID",
117
+ extracted: "抽出済み",
118
+ manifest: "マニフェスト",
119
+ contradiction: "⚠️ **矛盾**",
120
+ },
121
+ };
122
+ function getHeadings(lang) {
123
+ if (lang && HEADINGS[lang])
124
+ return HEADINGS[lang];
125
+ // English defaults
126
+ return {
127
+ summary: "Summary",
128
+ keyTakeaways: "Key Takeaways",
129
+ entitiesMentioned: "Entities Mentioned",
130
+ conceptsMentioned: "Concepts Mentioned",
131
+ notableQuotes: "Notable Quotes",
132
+ contradictions: "Contradictions",
133
+ sourcePacket: "Source Packet",
134
+ overview: "Overview",
135
+ definition: "Definition",
136
+ noneRecorded: "[None recorded]",
137
+ none: "[None]",
138
+ id: "ID",
139
+ extracted: "Extracted",
140
+ manifest: "Manifest",
141
+ contradiction: "⚠️ **Contradiction**",
142
+ };
143
+ }
144
+ function buildEntityPageBody(title, description, sourceId, lang) {
52
145
  const desc = description.trim() || "One-line description.";
146
+ const h = getHeadings(lang);
53
147
  return `# ${title}
54
148
 
55
149
  ${desc}
56
150
 
57
- ## Overview
151
+ ## ${h.overview}
58
152
 
59
153
  [Key facts]
60
154
 
@@ -62,13 +156,14 @@ ${desc}
62
156
 
63
157
  - [${sourceId}](/sources/${sourceId}.md)`;
64
158
  }
65
- function buildConceptPageBody(title, definition, sourceId) {
159
+ function buildConceptPageBody(title, definition, sourceId, lang) {
66
160
  const def = definition.trim() || "One-line definition.";
161
+ const h = getHeadings(lang);
67
162
  return `# ${title}
68
163
 
69
164
  ${def}
70
165
 
71
- ## Definition
166
+ ## ${h.definition}
72
167
 
73
168
  [Clear explanation]
74
169
 
@@ -77,62 +172,63 @@ ${def}
77
172
  - [${sourceId}](/sources/${sourceId}.md)`;
78
173
  }
79
174
  /** Rebuild the source page body from synthesis data, marking it ingested. */
80
- export function buildIngestedSourcePageBody(manifest, data, _date) {
175
+ export function buildIngestedSourcePageBody(manifest, data, _date, lang) {
81
176
  const id = String(manifest.id);
82
177
  const title = String(manifest.title || id);
83
178
  const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
179
+ const h = getHeadings(lang);
84
180
  const takeaways = data.key_takeaways.length > 0
85
181
  ? data.key_takeaways.map((t) => `- ${t.trim()}`).join("\n")
86
- : "- [None recorded]";
182
+ : `- ${h.noneRecorded}`;
87
183
  const entities = data.entities.length > 0
88
184
  ? data.entities.map((e) => `- [${e.title}](/entities/${slugify(e.title)}.md)`).join("\n")
89
- : "- [None]";
185
+ : `- ${h.none}`;
90
186
  const concepts = data.concepts.length > 0
91
187
  ? data.concepts.map((c) => `- [${c.title}](/concepts/${slugify(c.title)}.md)`).join("\n")
92
- : "- [None]";
188
+ : `- ${h.none}`;
93
189
  const quotes = data.quotes && data.quotes.length > 0
94
190
  ? data.quotes
95
191
  .map((q) => `> ${q.text.trim()}${q.attribution ? ` — ${q.attribution}` : ""}`)
96
192
  .join("\n\n")
97
- : "> [None recorded]";
193
+ : `> ${h.noneRecorded}`;
98
194
  const contradictions = data.contradictions && data.contradictions.length > 0
99
- ? `\n## Contradictions\n\n${data.contradictions.map((c) => `⚠️ **Contradiction**: ${c.trim()}`).join("\n")}\n`
195
+ ? `\n## ${h.contradictions}\n\n${data.contradictions.map((c) => `${h.contradiction}: ${c.trim()}`).join("\n")}\n`
100
196
  : "";
101
197
  return `# ${title}${url}
102
198
 
103
- ## Summary
199
+ ## ${h.summary}
104
200
 
105
201
  ${data.summary.trim()}
106
202
 
107
- ## Key Takeaways
203
+ ## ${h.keyTakeaways}
108
204
 
109
205
  ${takeaways}
110
206
 
111
- ## Entities Mentioned
207
+ ## ${h.entitiesMentioned}
112
208
 
113
209
  ${entities}
114
210
 
115
- ## Concepts Mentioned
211
+ ## ${h.conceptsMentioned}
116
212
 
117
213
  ${concepts}
118
214
 
119
- ## Notable Quotes
215
+ ## ${h.notableQuotes}
120
216
 
121
217
  ${quotes}
122
- ${contradictions}## Source Packet
218
+ ${contradictions}## ${h.sourcePacket}
123
219
 
124
- - **ID:** \`sources/${id}\`
125
- - **Extracted:** \`raw/sources/${id}/extracted.md\`
126
- - **Manifest:** \`raw/sources/${id}/manifest.json\`
220
+ - **${h.id}:** \`sources/${id}\`
221
+ - **${h.extracted}:** \`raw/sources/${id}/extracted.md\`
222
+ - **${h.manifest}:** \`raw/sources/${id}/manifest.json\`
127
223
  `;
128
224
  }
129
225
  /** Rebuild the source page from synthesis data, marking it ingested. */
130
- export function buildIngestedSourcePage(manifest, data, date) {
226
+ export function buildIngestedSourcePage(manifest, data, date, lang) {
131
227
  const id = String(manifest.id);
132
228
  const title = String(manifest.title || id);
133
229
  const format = String(manifest.format || "unknown");
134
230
  const captured = String(manifest.captured || date);
135
- const body = buildIngestedSourcePageBody(manifest, data, date);
231
+ const body = buildIngestedSourcePageBody(manifest, data, date, lang);
136
232
  const doc = createKnowledgeDocument(`sources/${id}.md`, {
137
233
  type: "source",
138
234
  title,
@@ -150,7 +246,7 @@ export function buildIngestedSourcePage(manifest, data, date) {
150
246
  * ingested), create missing entity/concept pages (existing pages are linked,
151
247
  * never overwritten), and log the event. Pure file I/O — no LLM, no network.
152
248
  */
153
- export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate()) {
249
+ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(), lang) {
154
250
  const result = {
155
251
  sourceId,
156
252
  sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
@@ -177,7 +273,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
177
273
  return { ok: false, sourceId, diagnostics: parsed.diagnostics };
178
274
  sourceDocument = patchKnowledgeDocument(parsed.document, {
179
275
  fields: { status: "ingested", updated: date },
180
- body: buildIngestedSourcePageBody(manifest, data, date),
276
+ body: buildIngestedSourcePageBody(manifest, data, date, lang),
181
277
  });
182
278
  }
183
279
  else {
@@ -190,7 +286,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
190
286
  captured: String(manifest.captured || date),
191
287
  status: "ingested",
192
288
  updated: date,
193
- }, buildIngestedSourcePageBody(manifest, data, date));
289
+ }, buildIngestedSourcePageBody(manifest, data, date, lang));
194
290
  }
195
291
  mkdirSync(join(paths.wiki, "sources"), { recursive: true });
196
292
  writeKnowledgeDocumentFile(result.sourcePage, sourceDocument);
@@ -211,7 +307,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
211
307
  description: e.description.trim() || "One-line description.",
212
308
  created: date,
213
309
  updated: date,
214
- }, buildEntityPageBody(e.title, e.description, sourceId), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
310
+ }, buildEntityPageBody(e.title, e.description, sourceId, lang), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
215
311
  writeKnowledgeDocumentFile(pagePath, entityDoc);
216
312
  result.entitiesCreated.push(slug);
217
313
  }
@@ -233,7 +329,7 @@ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(
233
329
  description: c.definition.trim() || "One-line definition.",
234
330
  created: date,
235
331
  updated: date,
236
- }, buildConceptPageBody(c.title, c.definition, sourceId), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
332
+ }, buildConceptPageBody(c.title, c.definition, sourceId, lang), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
237
333
  writeKnowledgeDocumentFile(pagePath, conceptDoc);
238
334
  result.conceptsCreated.push(slug);
239
335
  }
@@ -269,10 +365,14 @@ Rules:
269
365
  * synthesis.
270
366
  */
271
367
  export async function runIngestSynthesis(args) {
272
- const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal } = args;
368
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, } = args;
273
369
  const content = extracted.slice(0, maxChars ?? 24_000);
274
370
  if (!content.trim())
275
371
  return undefined;
372
+ const languageInstruction = synthesisLanguage
373
+ ? `\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.`
374
+ : "";
375
+ const systemPrompt = INGEST_SYSTEM + languageInstruction;
276
376
  let committed;
277
377
  const commitTool = {
278
378
  name: "commit_synthesis",
@@ -280,7 +380,7 @@ export async function runIngestSynthesis(args) {
280
380
  description: "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
281
381
  parameters: CommitSynthesisSchema,
282
382
  execute: async (_id, params) => {
283
- const outcome = commitSynthesis(paths, sourceId, manifest, params);
383
+ const outcome = commitSynthesis(paths, sourceId, manifest, params, undefined, synthesisLanguage);
284
384
  if (!outcome.ok) {
285
385
  return {
286
386
  content: [{ type: "text", text: `Failed: ${outcome.diagnostics[0].message}` }],
@@ -299,7 +399,7 @@ export async function runIngestSynthesis(args) {
299
399
  model,
300
400
  apiKey,
301
401
  headers,
302
- systemPrompt: INGEST_SYSTEM,
402
+ systemPrompt,
303
403
  userPrompt,
304
404
  tools: [commitTool],
305
405
  signal,
@@ -26,17 +26,21 @@ export function rebuildMetadata(paths) {
26
26
  // Step 4: Build backlinks from discovered documents
27
27
  const knownIds = new Set(documents.map((d) => d.id));
28
28
  const backlinks = buildBacklinks(documents, knownIds, allDiagnostics);
29
- const eventLogResult = buildOkfLog(readText(join(paths.meta, "events.jsonl")));
30
- allDiagnostics.push(...eventLogResult.diagnostics);
29
+ const eventSource = readEventSource(join(paths.meta, "events.jsonl"));
30
+ const eventLogResult = eventSource.available ? buildOkfLog(eventSource.content) : undefined;
31
+ if (eventLogResult)
32
+ allDiagnostics.push(...eventLogResult.diagnostics);
33
+ else if (!eventSource.available)
34
+ allDiagnostics.push(eventSource.diagnostic);
31
35
  // Step 5: Build meta/index.md
32
36
  const metaIndex = buildIndexMarkdown(registry);
33
37
  // Step 6: Build meta/log.md (existing rich format)
34
- const metaLog = buildLogMarkdown(paths);
38
+ const metaLog = eventSource.available ? buildLogMarkdown(eventSource.content) : undefined;
35
39
  // Step 7: Build OKF projections if in okf-0.2 mode
36
40
  const okfIndexes = vaultState.knowledgeFormat === "okf-0.2"
37
41
  ? buildDirectoryIndexes(documents, readJson(join(paths.dotWiki, "config.json"), {}))
38
42
  : null;
39
- const okfLog = vaultState.knowledgeFormat === "okf-0.2" ? eventLogResult.markdown : null;
43
+ const okfLog = vaultState.knowledgeFormat === "okf-0.2" ? eventLogResult?.markdown : undefined;
40
44
  // Step 8: Atomic write all projections
41
45
  mkdirSync(paths.meta, { recursive: true });
42
46
  const registryJson = `${JSON.stringify(registry, null, 2)}\n`;
@@ -44,7 +48,8 @@ export function rebuildMetadata(paths) {
44
48
  atomicWriteFile(join(paths.meta, "registry.json"), registryJson);
45
49
  atomicWriteFile(join(paths.meta, "backlinks.json"), backlinksJson);
46
50
  atomicWriteFile(join(paths.meta, "index.md"), metaIndex);
47
- atomicWriteFile(join(paths.meta, "log.md"), metaLog);
51
+ if (metaLog !== undefined)
52
+ atomicWriteFile(join(paths.meta, "log.md"), metaLog);
48
53
  // Step 9: Write OKF projections if applicable
49
54
  if (okfIndexes && okfIndexes.size > 0) {
50
55
  mkdirSync(paths.wiki, { recursive: true });
@@ -54,7 +59,7 @@ export function rebuildMetadata(paths) {
54
59
  // Prune obsolete generated indexes
55
60
  pruneObsoleteIndexes(paths, okfIndexes);
56
61
  }
57
- if (okfLog !== null) {
62
+ if (okfLog !== undefined) {
58
63
  mkdirSync(paths.wiki, { recursive: true });
59
64
  atomicWriteFile(join(paths.wiki, "log.md"), okfLog);
60
65
  }
@@ -196,25 +201,21 @@ function buildIndexMarkdown(registry) {
196
201
  sections.push(`---\n*Last updated: ${registry.last_updated}* | *Total pages: ${Object.keys(registry.pages).length}*`);
197
202
  return `${sections.join("\n")}\n`;
198
203
  }
199
- /** Build meta/log.md in existing rich format. */
200
- function buildLogMarkdown(paths) {
201
- const eventsPath = join(paths.meta, "events.jsonl");
204
+ function buildLogMarkdown(eventsJsonl) {
202
205
  const events = [];
203
- if (existsSync(eventsPath)) {
204
- const raw = readText(eventsPath).trim();
205
- for (const line of raw.split("\n")) {
206
- if (!line.trim())
207
- continue;
208
- try {
209
- const candidate = JSON.parse(line);
210
- if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
211
- events.push(candidate);
212
- }
213
- }
214
- catch {
215
- // skip malformed
206
+ const raw = eventsJsonl.trim();
207
+ for (const line of raw.split("\n")) {
208
+ if (!line.trim())
209
+ continue;
210
+ try {
211
+ const candidate = JSON.parse(line);
212
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
213
+ events.push(candidate);
216
214
  }
217
215
  }
216
+ catch {
217
+ // Keep backward-compatible rich-log behavior: malformed lines are omitted.
218
+ }
218
219
  }
219
220
  const lines = [];
220
221
  lines.push("# Activity Log\n\n> Auto-generated from meta/events.jsonl. Do not edit manually.\n");
@@ -230,9 +231,8 @@ function buildLogMarkdown(paths) {
230
231
  lines.push(`- ${details}`);
231
232
  lines.push("");
232
233
  }
233
- if (events.length === 0) {
234
+ if (events.length === 0)
234
235
  lines.push("_No events recorded yet._\n");
235
- }
236
236
  return `${lines.join("\n")}\n`;
237
237
  }
238
238
  /** Append an event to events.jsonl. */
@@ -254,15 +254,21 @@ function atomicWriteFile(path, content) {
254
254
  writeFileSync(temporary, content, "utf8");
255
255
  renameSync(temporary, path);
256
256
  }
257
- /** Read text file or return empty string. */
258
- function readText(path) {
257
+ function readEventSource(filePath, diagnosticPath = "meta/events.jsonl") {
259
258
  try {
260
- if (!existsSync(path))
261
- return "";
262
- return readFileSync(path, "utf8");
259
+ return { available: true, content: readFileSync(filePath, "utf8") };
263
260
  }
264
- catch {
265
- return "";
261
+ catch (error) {
262
+ const code = error.code;
263
+ // ENOENT = missing; ENOTDIR/EISDIR = not a regular file (FreeBSD dir hazard); else unreadable
264
+ const diagnosticCode = code === "ENOENT" ? "event_source_missing" : "event_source_unreadable";
265
+ const message = diagnosticCode === "event_source_missing"
266
+ ? "Authoritative event source is missing; existing log projections were preserved"
267
+ : "Authoritative event source is unreadable; existing log projections were preserved";
268
+ return {
269
+ available: false,
270
+ diagnostic: okfDiag("warning", diagnosticCode, diagnosticPath, message),
271
+ };
266
272
  }
267
273
  }
268
274
  /** Prune obsolete generated indexes in OKF mode. */
@@ -35,7 +35,6 @@ export function registerWikiModelCommand(pi, runtime) {
35
35
  const apply = (model) => {
36
36
  persistTaskModel(ctx.cwd, model);
37
37
  runtime.config = { ...runtime.config, taskModel: model };
38
- runtime.configLoaded = true;
39
38
  const label = formatActiveModelLabel(runtime.config, sessionId);
40
39
  ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${label}`);
41
40
  ctx.ui.notify(`LLM Wiki: background tasks now use ${label}`, "info");
@@ -1,7 +1,6 @@
1
1
  import { TASK_DEFAULTS, loadTaskConfig, noticesEnabled } from "./task-config.js";
2
2
  export class Runtime {
3
3
  config = { ...TASK_DEFAULTS };
4
- configLoaded = false;
5
4
  /**
6
5
  * Extension API handle, attached at registration. Used by `report()` to emit
7
6
  * visible completion messages for background actions (issue #77). Optional so
@@ -15,10 +14,7 @@ export class Runtime {
15
14
  /** Whether we've already surfaced a model-resolution failure (avoid spam). */
16
15
  resolveFailureNotified = false;
17
16
  ensureConfig(cwd) {
18
- if (this.configLoaded)
19
- return;
20
17
  this.config = loadTaskConfig(cwd);
21
- this.configLoaded = true;
22
18
  }
23
19
  /** True if a task with this label is currently running. */
24
20
  isInFlight(label) {
@@ -80,7 +80,7 @@ function fileCaptureSource(pi, filePath, signal) {
80
80
  file_path: filePath,
81
81
  format: extractor.format,
82
82
  }),
83
- event: () => ({ file_path: filePath, format: extractor.format }),
83
+ event: () => ({ format: extractor.format }),
84
84
  };
85
85
  }
86
86
  function textCaptureSource(text, title) {
@@ -67,6 +67,12 @@ function readNamespacedConfig(path) {
67
67
  if (typeof section.trajectories === "boolean") {
68
68
  out.trajectories = section.trajectories;
69
69
  }
70
+ const lang = section.synthesisLanguage;
71
+ if (typeof lang === "string" && lang.trim()) {
72
+ const canonical = validateSynthesisLanguage(lang.trim());
73
+ if (canonical)
74
+ out.synthesisLanguage = canonical;
75
+ }
70
76
  return out;
71
77
  }
72
78
  catch {
@@ -91,6 +97,29 @@ export function parseModelRef(ref) {
91
97
  return undefined;
92
98
  return { provider, id };
93
99
  }
100
+ /**
101
+ * Validate and canonicalize a BCP 47 language tag for synthesisLanguage (issue #124).
102
+ * Returns the canonical tag, or undefined if invalid or suspicious.
103
+ * Uses Intl.getCanonicalLocales for validation; rejects tags containing
104
+ * newlines, quotes, or other prompt-injection candidates.
105
+ */
106
+ export function validateSynthesisLanguage(tag) {
107
+ // Reject obvious injection attempts: newlines, quotes, braces, angle brackets
108
+ if (/\n|\r|"|'|<|>|{|}/.test(tag))
109
+ return undefined;
110
+ // Reject if it looks like an instruction (contains "write", "ignore", "translate", etc.)
111
+ const lower = tag.toLowerCase();
112
+ if (/\b(write|ignore|translate|system|prompt|instruction)\b/.test(lower))
113
+ return undefined;
114
+ // Basic BCP 47 pattern: language[-script][-region][-variant]*
115
+ // Must start with 2-3 letter language code
116
+ if (!/^[a-z]{2,3}(?:-[A-Za-z]{1,8})*$/.test(tag))
117
+ return undefined;
118
+ const canonical = Intl.getCanonicalLocales(tag);
119
+ if (canonical.length === 0)
120
+ return undefined;
121
+ return canonical[0];
122
+ }
94
123
  /**
95
124
  * Read a settings JSON file as a plain object, or `{}` when it is absent or
96
125
  * corrupt. Reads directly (no `existsSync` pre-check) so there is no
@@ -327,6 +327,7 @@ export function registerWikiIngest(pi, runtime) {
327
327
  sourceId: s.id,
328
328
  manifest: s.manifest,
329
329
  extracted: s.extracted,
330
+ synthesisLanguage: runtime.config.synthesisLanguage,
330
331
  });
331
332
  if (committed) {
332
333
  // Background semantic embeddings (#66): embed the pages this
@@ -960,6 +961,12 @@ export function registerWikiRebuildMeta(pi, runtime) {
960
961
  if (!result.ok) {
961
962
  return `⚠️ LLM Wiki: rebuild had issues — ${result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("; ")}`;
962
963
  }
964
+ const warnings = result.diagnostics.filter((diagnostic) => diagnostic.severity === "warning");
965
+ if (warnings.length > 0) {
966
+ return `⚠️ LLM Wiki: metadata rebuilt with warnings — ${warnings
967
+ .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`)
968
+ .join("; ")}`;
969
+ }
963
970
  const registry = readJson(join(paths.meta, "registry.json"), {
964
971
  version: "1.0",
965
972
  last_updated: "",
@@ -333,6 +333,12 @@ export function isProtectedPath(absPath, paths) {
333
333
  reason: "Raw sources are immutable. Use wiki_capture_source to add sources.",
334
334
  };
335
335
  }
336
+ if (relativePhysicalPath(paths.meta, absPath) === "events.jsonl") {
337
+ return {
338
+ protected: true,
339
+ reason: "Event history is append-only authoritative state. Use wiki_log_event or an owning wiki operation instead.",
340
+ };
341
+ }
336
342
  if (isPathWithin(paths.meta, absPath)) {
337
343
  return {
338
344
  protected: true,
package/dist/mcp/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  */
11
11
  import { existsSync } from "node:fs";
12
12
  import { join } from "node:path";
13
- import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
13
+ import { McpServer } from "@modelcontextprotocol/server";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
14
15
  import * as z from "zod/v4";
15
16
  import { resolveVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
16
17
  import { createExecApi } from "./exec.js";
package/docs/api.md CHANGED
@@ -280,6 +280,8 @@ Health is `"⚠️ Warning"` when orphan count exceeds 5, `"🔴 Empty"` when th
280
280
  Force a full synchronous rebuild of all generated metadata: `registry.json`, `backlinks.json`,
281
281
  `index.md`, `log.md`. Use when metadata appears out of sync with actual wiki files.
282
282
 
283
+ If `meta/events.jsonl` is missing or unreadable, rebuild reports a warning and preserves existing log projections while continuing to rebuild registry, backlinks, and indexes. A present zero-byte event file is an intentional empty history.
284
+
283
285
  **Parameters**
284
286
 
285
287
  None.
@@ -294,8 +296,9 @@ details: { pageCount: number }
294
296
 
295
297
  ## wiki_log_event
296
298
 
297
- Append a structured event to `meta/events.jsonl` and regenerate `meta/log.md`. Every event is
298
- timestamped automatically.
299
+ Append a structured event to the authoritative, append-only `meta/events.jsonl` stream and regenerate available log projections. Every event is timestamped automatically. The event stream must be preserved in full-vault backups; generated Markdown logs cannot reconstruct it.
300
+
301
+ `details` is user-controlled and may appear in OKF-mode `wiki/log.md`. Do not include secrets, credentials, or private machine-local paths. Built-in local-file capture records its stable source ID and format in events, omitting the caller-supplied input path from the event stream and generated log projections. The exact path remains in the extension-owned raw manifest. Other wiki projections are not covered by this guarantee: extraction-failure text and source-page titles may still surface path-derived strings, so treat the source page area as potentially machine-local until a dedicated sanitization pass exists.
299
302
 
300
303
  **Parameters**
301
304
 
@@ -55,7 +55,7 @@ WIKI_ROOT/
55
55
  │ ├── analyses/ # Durable query answers
56
56
  │ ├── cases/ # One specific past task per trajectory
57
57
  │ └── skills/ # Reusable patterns distilled from trajectories
58
- ├── meta/ # Auto-generated (extension-owned)
58
+ ├── meta/ # Durable event source + generated internal projections
59
59
  │ ├── registry.json # Master page catalog
60
60
  │ ├── backlinks.json # Inbound link map
61
61
  │ ├── index.md # Human-readable catalog
@@ -73,9 +73,12 @@ WIKI_ROOT/
73
73
  | --------------------- | ------------------------ | ------------------------ |
74
74
  | `.llm-wiki/raw/**` | Extension | Immutable after capture |
75
75
  | `.llm-wiki/wiki/**` | Model + user | Editable knowledge pages |
76
- | `.llm-wiki/meta/**` | Extension | Auto-generated |
76
+ | `.llm-wiki/meta/events.jsonl` | Extension tools | Authoritative, append-only; preserve in full-vault backups |
77
+ | `.llm-wiki/meta/**` except `events.jsonl` | Extension | Generated projections |
77
78
  | `.llm-wiki/` | Human + explicit request | Operating rules |
78
79
 
80
+ `events.jsonl` records selected extension operations, not every filesystem edit. `meta/log.md` and OKF-mode `wiki/log.md` are one-way projections; neither can recover the event stream.
81
+
79
82
  ## Source Packet Format
80
83
 
81
84
  Each captured source becomes a packet:
@@ -32,6 +32,31 @@ The personal vault lives at `~/.llm-wiki/` (or `$WIKI_HOME`) and is always avail
32
32
  | `WIKI_HOME` | `~/.llm-wiki` | Override the personal wiki vault location |
33
33
  | `WIKI_MARKITDOWN_TIMEOUT_MS` | 180000 | Timeout (ms) for MarkItDown PDF/text extraction |
34
34
 
35
+ ## Pi Agent Settings
36
+
37
+ Runtime settings for the wiki's background tasks live in `.pi/settings.json` under the `llm-wiki` namespace. These can be set globally (`~/.pi/agent/settings.json`) or per-project (`<cwd>/.pi/settings.json`).
38
+
39
+ | Setting | Default | Description |
40
+ | --------------------- | ------- | ------------------------------------------------------------ |
41
+ | `taskModel` | — | Model for background tasks (`{ provider: "openai", id: "gpt-4o" }`) |
42
+ | `synthesisLanguage` | — | BCP 47 language tag for ingest synthesis (e.g. `"ru"`, `"fr"`). When unset, synthesis defaults to English. |
43
+ | `trajectories` | false | Enable agent-trajectory working-memory |
44
+ | `notices` | true | Show wiki activity notices in chat |
45
+
46
+ Example:
47
+
48
+ ```json
49
+ {
50
+ "llm-wiki": {
51
+ "synthesisLanguage": "ru",
52
+ "taskModel": {
53
+ "provider": "openai",
54
+ "id": "gpt-4o"
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
35
60
  ## Vault Resolution
36
61
 
37
62
  The vault root is resolved in this priority order: