@zosmaai/pi-llm-wiki 0.10.9 → 0.11.1

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 (82) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.de.md +35 -4
  3. package/README.es.md +260 -170
  4. package/README.fr.md +35 -4
  5. package/README.hi.md +35 -4
  6. package/README.ja.md +35 -4
  7. package/README.ko.md +35 -4
  8. package/README.md +41 -12
  9. package/README.pt.md +35 -4
  10. package/README.ru.md +35 -4
  11. package/README.zh.md +260 -170
  12. package/assets/demo.gif +0 -0
  13. package/dist/extensions/llm-wiki/lib/bootstrap.js +74 -0
  14. package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
  15. package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
  16. package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
  17. package/dist/extensions/llm-wiki/lib/ingest-worker.js +410 -0
  18. package/dist/extensions/llm-wiki/lib/inject.js +65 -0
  19. package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
  20. package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
  21. package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
  22. package/dist/extensions/llm-wiki/lib/metadata.js +505 -0
  23. package/dist/extensions/llm-wiki/lib/model-command.js +85 -0
  24. package/dist/extensions/llm-wiki/lib/observation.js +283 -0
  25. package/dist/extensions/llm-wiki/lib/recall.js +875 -0
  26. package/dist/extensions/llm-wiki/lib/retro.js +158 -0
  27. package/dist/extensions/llm-wiki/lib/runtime.js +187 -0
  28. package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
  29. package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
  30. package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
  31. package/dist/extensions/llm-wiki/lib/task-config.js +201 -0
  32. package/dist/extensions/llm-wiki/lib/tools.js +1199 -0
  33. package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
  34. package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
  35. package/dist/extensions/llm-wiki/lib/utils.js +353 -0
  36. package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
  37. package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
  38. package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
  39. package/dist/mcp/exec.js +121 -0
  40. package/dist/mcp/index.js +229 -0
  41. package/dist/mcp/operations.js +130 -0
  42. package/dist/package.json +1 -0
  43. package/docs/api.md +5 -2
  44. package/docs/architecture.md +5 -2
  45. package/docs/configuration.md +25 -0
  46. package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
  47. package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
  48. package/docs/superpowers/plans/2026-08-06-authoritative-event-history-phase-1-foundation-hardening.md +937 -0
  49. package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
  50. package/docs/superpowers/plans/2026-08-07-synthesis-language.md +98 -0
  51. package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +593 -0
  52. package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +542 -0
  53. package/docs/superpowers/specs/2026-08-07-synthesis-language-design.md +94 -0
  54. package/extensions/llm-wiki/index.ts +22 -36
  55. package/extensions/llm-wiki/lib/bootstrap.ts +87 -0
  56. package/extensions/llm-wiki/lib/embeddings.ts +9 -3
  57. package/extensions/llm-wiki/lib/guardrails.ts +26 -18
  58. package/extensions/llm-wiki/lib/indexing.ts +2 -1
  59. package/extensions/llm-wiki/lib/ingest-worker.ts +304 -28
  60. package/extensions/llm-wiki/lib/knowledge-document.ts +663 -0
  61. package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
  62. package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
  63. package/extensions/llm-wiki/lib/metadata.ts +550 -128
  64. package/extensions/llm-wiki/lib/model-command.ts +0 -1
  65. package/extensions/llm-wiki/lib/observation.ts +37 -43
  66. package/extensions/llm-wiki/lib/recall.ts +61 -33
  67. package/extensions/llm-wiki/lib/retro.ts +65 -41
  68. package/extensions/llm-wiki/lib/runtime.ts +0 -3
  69. package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
  70. package/extensions/llm-wiki/lib/source-packet.ts +45 -32
  71. package/extensions/llm-wiki/lib/task-config.ts +36 -0
  72. package/extensions/llm-wiki/lib/tools.ts +413 -342
  73. package/extensions/llm-wiki/lib/trajectory.ts +15 -1
  74. package/extensions/llm-wiki/lib/utils.ts +127 -131
  75. package/extensions/llm-wiki/lib/vault-format.ts +363 -0
  76. package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
  77. package/mcp/exec.ts +122 -0
  78. package/mcp/index.ts +60 -250
  79. package/mcp/operations.ts +176 -0
  80. package/package.json +8 -2
  81. package/scripts/migrate-llm-wiki.js +801 -0
  82. package/skills/llm-wiki/SKILL.md +12 -8
@@ -0,0 +1,410 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Type } from "typebox";
4
+ import { createKnowledgeDocument, patchKnowledgeDocument, readKnowledgeDocumentFile, serializeKnowledgeDocument, writeKnowledgeDocumentFile, } from "./knowledge-document.js";
5
+ import { appendEvent, rebuildMetadataLight } from "./metadata.js";
6
+ import { runSubAgent } from "./subagent.js";
7
+ import { fmtDate, slugify } from "./utils.js";
8
+ import { VaultWriteError, assertWritableVault } from "./vault-format.js";
9
+ /**
10
+ * Background ingest synthesis (issue #65, part of epic #63).
11
+ *
12
+ * Moves the work the main agent used to do during `wiki_ingest` — reading a
13
+ * captured source's extracted text and writing the source page + entity /
14
+ * concept pages — onto a background sub-agent, so capturing/ingesting never
15
+ * stalls the user.
16
+ *
17
+ * Design: the sub-agent produces ONE structured `commit_synthesis` call; the
18
+ * persistence (`commitSynthesis`) is fully deterministic and unit-testable
19
+ * without an LLM. This mirrors pi-observational-memory's single-structured-tool
20
+ * pattern and keeps the file-writing logic verifiable in isolation.
21
+ */
22
+ // ── structured synthesis schema ───────────────────────────
23
+ export const CommitSynthesisSchema = Type.Object({
24
+ summary: Type.String({
25
+ minLength: 1,
26
+ description: "2-3 paragraph summary of the source's key content.",
27
+ }),
28
+ key_takeaways: Type.Array(Type.String({ minLength: 1 }), {
29
+ description: "The most important points, one per item.",
30
+ }),
31
+ entities: Type.Array(Type.Object({
32
+ title: Type.String({
33
+ minLength: 1,
34
+ description: "Entity name (person, org, tool, product).",
35
+ }),
36
+ description: Type.String({ description: "One-line description of the entity." }),
37
+ }), { description: "Named entities mentioned in the source." }),
38
+ concepts: Type.Array(Type.Object({
39
+ title: Type.String({ minLength: 1, description: "Concept name (idea, pattern, framework)." }),
40
+ definition: Type.String({ description: "One-line definition of the concept." }),
41
+ }), { description: "Concepts discussed in the source." }),
42
+ quotes: Type.Optional(Type.Array(Type.Object({
43
+ text: Type.String({ minLength: 1 }),
44
+ attribution: Type.Optional(Type.String()),
45
+ }), { description: "Notable verbatim quotes." })),
46
+ contradictions: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
47
+ description: "Tensions/contradictions with existing wiki content, if any.",
48
+ })),
49
+ });
50
+ // ── deterministic persistence (no LLM) ────────────────────
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) {
145
+ const desc = description.trim() || "One-line description.";
146
+ const h = getHeadings(lang);
147
+ return `# ${title}
148
+
149
+ ${desc}
150
+
151
+ ## ${h.overview}
152
+
153
+ [Key facts]
154
+
155
+ ## Links
156
+
157
+ - [${sourceId}](/sources/${sourceId}.md)`;
158
+ }
159
+ function buildConceptPageBody(title, definition, sourceId, lang) {
160
+ const def = definition.trim() || "One-line definition.";
161
+ const h = getHeadings(lang);
162
+ return `# ${title}
163
+
164
+ ${def}
165
+
166
+ ## ${h.definition}
167
+
168
+ [Clear explanation]
169
+
170
+ ## Links
171
+
172
+ - [${sourceId}](/sources/${sourceId}.md)`;
173
+ }
174
+ /** Rebuild the source page body from synthesis data, marking it ingested. */
175
+ export function buildIngestedSourcePageBody(manifest, data, _date, lang) {
176
+ const id = String(manifest.id);
177
+ const title = String(manifest.title || id);
178
+ const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
179
+ const h = getHeadings(lang);
180
+ const takeaways = data.key_takeaways.length > 0
181
+ ? data.key_takeaways.map((t) => `- ${t.trim()}`).join("\n")
182
+ : `- ${h.noneRecorded}`;
183
+ const entities = data.entities.length > 0
184
+ ? data.entities.map((e) => `- [${e.title}](/entities/${slugify(e.title)}.md)`).join("\n")
185
+ : `- ${h.none}`;
186
+ const concepts = data.concepts.length > 0
187
+ ? data.concepts.map((c) => `- [${c.title}](/concepts/${slugify(c.title)}.md)`).join("\n")
188
+ : `- ${h.none}`;
189
+ const quotes = data.quotes && data.quotes.length > 0
190
+ ? data.quotes
191
+ .map((q) => `> ${q.text.trim()}${q.attribution ? ` — ${q.attribution}` : ""}`)
192
+ .join("\n\n")
193
+ : `> ${h.noneRecorded}`;
194
+ const contradictions = data.contradictions && data.contradictions.length > 0
195
+ ? `\n## ${h.contradictions}\n\n${data.contradictions.map((c) => `${h.contradiction}: ${c.trim()}`).join("\n")}\n`
196
+ : "";
197
+ return `# ${title}${url}
198
+
199
+ ## ${h.summary}
200
+
201
+ ${data.summary.trim()}
202
+
203
+ ## ${h.keyTakeaways}
204
+
205
+ ${takeaways}
206
+
207
+ ## ${h.entitiesMentioned}
208
+
209
+ ${entities}
210
+
211
+ ## ${h.conceptsMentioned}
212
+
213
+ ${concepts}
214
+
215
+ ## ${h.notableQuotes}
216
+
217
+ ${quotes}
218
+ ${contradictions}## ${h.sourcePacket}
219
+
220
+ - **${h.id}:** \`sources/${id}\`
221
+ - **${h.extracted}:** \`raw/sources/${id}/extracted.md\`
222
+ - **${h.manifest}:** \`raw/sources/${id}/manifest.json\`
223
+ `;
224
+ }
225
+ /** Rebuild the source page from synthesis data, marking it ingested. */
226
+ export function buildIngestedSourcePage(manifest, data, date, lang) {
227
+ const id = String(manifest.id);
228
+ const title = String(manifest.title || id);
229
+ const format = String(manifest.format || "unknown");
230
+ const captured = String(manifest.captured || date);
231
+ const body = buildIngestedSourcePageBody(manifest, data, date, lang);
232
+ const doc = createKnowledgeDocument(`sources/${id}.md`, {
233
+ type: "source",
234
+ title,
235
+ format,
236
+ source_id: id,
237
+ raw_path: `raw/sources/${id}/extracted.md`,
238
+ captured,
239
+ status: "ingested",
240
+ updated: date,
241
+ }, body);
242
+ return serializeKnowledgeDocument(doc);
243
+ }
244
+ /**
245
+ * Persist a synthesis deterministically: rewrite the source page (status →
246
+ * ingested), create missing entity/concept pages (existing pages are linked,
247
+ * never overwritten), and log the event. Pure file I/O — no LLM, no network.
248
+ */
249
+ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate(), lang) {
250
+ const result = {
251
+ sourceId,
252
+ sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
253
+ entitiesCreated: [],
254
+ conceptsCreated: [],
255
+ entitiesLinked: [],
256
+ conceptsLinked: [],
257
+ contradictions: data.contradictions?.length ?? 0,
258
+ };
259
+ try {
260
+ assertWritableVault(paths);
261
+ }
262
+ catch (error) {
263
+ if (error instanceof VaultWriteError) {
264
+ return { ok: false, sourceId, diagnostics: error.diagnostics };
265
+ }
266
+ throw error;
267
+ }
268
+ // Patch existing documents so unknown fields, legacy sources, and titles survive.
269
+ let sourceDocument;
270
+ if (existsSync(result.sourcePage)) {
271
+ const parsed = readKnowledgeDocumentFile(result.sourcePage, `sources/${sourceId}.md`);
272
+ if (!parsed.ok)
273
+ return { ok: false, sourceId, diagnostics: parsed.diagnostics };
274
+ sourceDocument = patchKnowledgeDocument(parsed.document, {
275
+ fields: { status: "ingested", updated: date },
276
+ body: buildIngestedSourcePageBody(manifest, data, date, lang),
277
+ });
278
+ }
279
+ else {
280
+ sourceDocument = createKnowledgeDocument(`sources/${sourceId}.md`, {
281
+ type: "source",
282
+ title: String(manifest.title || sourceId),
283
+ format: String(manifest.format || "unknown"),
284
+ source_id: sourceId,
285
+ raw_path: `raw/sources/${sourceId}/extracted.md`,
286
+ captured: String(manifest.captured || date),
287
+ status: "ingested",
288
+ updated: date,
289
+ }, buildIngestedSourcePageBody(manifest, data, date, lang));
290
+ }
291
+ mkdirSync(join(paths.wiki, "sources"), { recursive: true });
292
+ writeKnowledgeDocumentFile(result.sourcePage, sourceDocument);
293
+ // Entity pages — create if absent, link if present.
294
+ mkdirSync(join(paths.wiki, "entities"), { recursive: true });
295
+ for (const e of data.entities) {
296
+ const slug = slugify(e.title);
297
+ if (!slug)
298
+ continue;
299
+ const pagePath = join(paths.wiki, "entities", `${slug}.md`);
300
+ if (existsSync(pagePath)) {
301
+ result.entitiesLinked.push(slug);
302
+ }
303
+ else {
304
+ const entityDoc = createKnowledgeDocument(`entities/${slug}.md`, {
305
+ type: "entity",
306
+ title: e.title,
307
+ description: e.description.trim() || "One-line description.",
308
+ created: date,
309
+ updated: date,
310
+ }, buildEntityPageBody(e.title, e.description, sourceId, lang), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
311
+ writeKnowledgeDocumentFile(pagePath, entityDoc);
312
+ result.entitiesCreated.push(slug);
313
+ }
314
+ }
315
+ // Concept pages — create if absent, link if present.
316
+ mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
317
+ for (const c of data.concepts) {
318
+ const slug = slugify(c.title);
319
+ if (!slug)
320
+ continue;
321
+ const pagePath = join(paths.wiki, "concepts", `${slug}.md`);
322
+ if (existsSync(pagePath)) {
323
+ result.conceptsLinked.push(slug);
324
+ }
325
+ else {
326
+ const conceptDoc = createKnowledgeDocument(`concepts/${slug}.md`, {
327
+ type: "concept",
328
+ title: c.title,
329
+ description: c.definition.trim() || "One-line definition.",
330
+ created: date,
331
+ updated: date,
332
+ }, buildConceptPageBody(c.title, c.definition, sourceId, lang), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
333
+ writeKnowledgeDocumentFile(pagePath, conceptDoc);
334
+ result.conceptsCreated.push(slug);
335
+ }
336
+ }
337
+ appendEvent(paths, {
338
+ kind: "ingest",
339
+ source_id: sourceId,
340
+ entities_created: result.entitiesCreated.length,
341
+ concepts_created: result.conceptsCreated.length,
342
+ contradictions: result.contradictions,
343
+ background: true,
344
+ });
345
+ return { ok: true, ...result };
346
+ }
347
+ // ── sub-agent synthesis (LLM) ─────────────────────────────
348
+ export const INGEST_SYSTEM = `You are the LLM Wiki ingestion synthesizer. You turn a single captured source's extracted text into structured wiki knowledge.
349
+
350
+ Read the source content, then call \`commit_synthesis\` EXACTLY ONCE with:
351
+ - summary: a faithful 2-3 paragraph summary (no fabrication).
352
+ - key_takeaways: the most important points.
353
+ - entities: named people, organizations, tools, products actually mentioned.
354
+ - concepts: ideas, patterns, frameworks actually discussed.
355
+ - quotes: notable verbatim quotes (optional).
356
+ - contradictions: tensions with general knowledge or noted in the text (optional).
357
+
358
+ Rules:
359
+ - Never fabricate. Only include entities/concepts present in the source.
360
+ - Keep descriptions to one line.
361
+ - After calling commit_synthesis once, reply with a one-line confirmation and stop.`;
362
+ /**
363
+ * Run the synthesis sub-agent for a single source, then commit + rebuild
364
+ * metadata. Returns the commit result, or undefined if the model produced no
365
+ * synthesis.
366
+ */
367
+ export async function runIngestSynthesis(args) {
368
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal, synthesisLanguage, } = args;
369
+ const content = extracted.slice(0, maxChars ?? 24_000);
370
+ if (!content.trim())
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;
376
+ let committed;
377
+ const commitTool = {
378
+ name: "commit_synthesis",
379
+ label: "Commit synthesis",
380
+ description: "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
381
+ parameters: CommitSynthesisSchema,
382
+ execute: async (_id, params) => {
383
+ const outcome = commitSynthesis(paths, sourceId, manifest, params, undefined, synthesisLanguage);
384
+ if (!outcome.ok) {
385
+ return {
386
+ content: [{ type: "text", text: `Failed: ${outcome.diagnostics[0].message}` }],
387
+ details: { sourceId },
388
+ isError: true,
389
+ };
390
+ }
391
+ committed = outcome;
392
+ const ack = `Committed: source page + ${committed.entitiesCreated.length} new entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} new concept${committed.conceptsCreated.length === 1 ? "" : "s"}. Reply with a one-line confirmation and stop.`;
393
+ return { content: [{ type: "text", text: ack }], details: { sourceId } };
394
+ },
395
+ };
396
+ const title = String(manifest.title || sourceId);
397
+ const userPrompt = `Synthesize this captured source into wiki knowledge by calling commit_synthesis once.\n\nSOURCE: ${title} (${sourceId})\n\nEXTRACTED CONTENT:\n${content}`;
398
+ await runSubAgent({
399
+ model,
400
+ apiKey,
401
+ headers,
402
+ systemPrompt,
403
+ userPrompt,
404
+ tools: [commitTool],
405
+ signal,
406
+ });
407
+ if (committed)
408
+ rebuildMetadataLight(paths);
409
+ return committed;
410
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * System-prompt context injection primitives (issues #87, #92).
3
+ *
4
+ * The `before_agent_start` hook augments the chained system prompt with a
5
+ * visible wiki-status footer. This module isolates that append so it can be
6
+ * unit-tested for idempotency — a turn that aborts (network error / ESC) and is
7
+ * retried can carry the prior injection forward, and a naive append stacks the
8
+ * footer 2x, 3x, ...
9
+ *
10
+ * It also owns the cache-safety split (issue #92): VOLATILE per-turn context
11
+ * (recall results, one-time topic-inference directive) must never enter the
12
+ * system prompt — that is the provider's primary cache prefix, so per-turn
13
+ * variation there forces a full cache miss every turn. Volatile content is
14
+ * routed into a tail conversation message instead. `buildAgentStartInjection`
15
+ * is the single, pure decision point for that split.
16
+ */
17
+ /** The always-injected wiki-status footer (sans surrounding whitespace). */
18
+ export const WIKI_STATUS_BLOCK = "<wiki_status>LLM Wiki active — use wiki_recall for deeper search, wiki_observe to record observations, wiki_retro to save insights.</wiki_status>";
19
+ /**
20
+ * Append the wiki-status footer to a system prompt — idempotently (issue #87).
21
+ *
22
+ * Strips any already-present footer (with its leading blank line) before
23
+ * appending exactly one. This makes the injection safe across aborted/retried
24
+ * agent starts that carry the prior injection forward in the chained system
25
+ * prompt, so the footer never stacks (2x, 3x, ...).
26
+ * See test/inject-idempotent.test.ts.
27
+ */
28
+ export function appendWikiStatus(systemPrompt) {
29
+ const base = systemPrompt.split(`\n\n${WIKI_STATUS_BLOCK}`).join("");
30
+ return `${base}\n\n${WIKI_STATUS_BLOCK}`;
31
+ }
32
+ /** Normalize upstream Pi and OMP system-prompt representations. */
33
+ export function normalizeSystemPrompt(systemPrompt) {
34
+ if (Array.isArray(systemPrompt))
35
+ return systemPrompt.join("\n\n");
36
+ return typeof systemPrompt === "string" ? systemPrompt : "";
37
+ }
38
+ /** customType of the hidden tail message carrying volatile per-turn context. */
39
+ export const WIKI_RECALL_MESSAGE_TYPE = "wiki-recall-context";
40
+ /**
41
+ * Split a turn's injection into a cache-stable system prompt and a volatile
42
+ * tail message (issue #92).
43
+ *
44
+ * - `systemPrompt` is always `appendWikiStatus(baseSystemPrompt)` — the static
45
+ * footer only. It carries NONE of `dynamicBlocks`, so it does not vary with
46
+ * recall results and never breaks the provider cache prefix.
47
+ * - `dynamicBlocks` (recall context, topic-inference directive, ...) are
48
+ * trimmed, emptied entries dropped, and joined with a blank line into the
49
+ * message body. When nothing survives, no message is emitted.
50
+ *
51
+ * Pure and side-effect free — see test/agent-start-injection.test.ts.
52
+ */
53
+ export function buildAgentStartInjection(baseSystemPrompt, dynamicBlocks) {
54
+ const systemPrompt = appendWikiStatus(normalizeSystemPrompt(baseSystemPrompt));
55
+ const content = dynamicBlocks
56
+ .map((b) => b?.trim())
57
+ .filter((b) => Boolean(b))
58
+ .join("\n\n");
59
+ if (!content)
60
+ return { systemPrompt };
61
+ return {
62
+ systemPrompt,
63
+ message: { customType: WIKI_RECALL_MESSAGE_TYPE, content, display: false },
64
+ };
65
+ }