@zosmaai/pi-llm-wiki 0.10.7 → 0.11.0

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 (73) hide show
  1. package/CHANGELOG.md +4 -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 +38 -3
  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 +71 -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 +310 -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 +499 -0
  23. package/dist/extensions/llm-wiki/lib/model-command.js +86 -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 +191 -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 +172 -0
  32. package/dist/extensions/llm-wiki/lib/tools.js +1192 -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 +347 -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/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
  44. package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
  45. package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
  46. package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
  47. package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
  48. package/extensions/llm-wiki/index.ts +22 -36
  49. package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
  50. package/extensions/llm-wiki/lib/embeddings.ts +9 -3
  51. package/extensions/llm-wiki/lib/guardrails.ts +174 -29
  52. package/extensions/llm-wiki/lib/indexing.ts +2 -1
  53. package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
  54. package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
  55. package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
  56. package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
  57. package/extensions/llm-wiki/lib/metadata.ts +531 -116
  58. package/extensions/llm-wiki/lib/observation.ts +37 -43
  59. package/extensions/llm-wiki/lib/recall.ts +61 -33
  60. package/extensions/llm-wiki/lib/retro.ts +65 -41
  61. package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
  62. package/extensions/llm-wiki/lib/source-packet.ts +44 -31
  63. package/extensions/llm-wiki/lib/tools.ts +406 -348
  64. package/extensions/llm-wiki/lib/trajectory.ts +15 -1
  65. package/extensions/llm-wiki/lib/utils.ts +121 -130
  66. package/extensions/llm-wiki/lib/vault-format.ts +363 -0
  67. package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
  68. package/mcp/exec.ts +122 -0
  69. package/mcp/index.ts +60 -250
  70. package/mcp/operations.ts +176 -0
  71. package/package.json +8 -2
  72. package/scripts/migrate-llm-wiki.js +801 -0
  73. package/skills/llm-wiki/SKILL.md +8 -6
@@ -0,0 +1,232 @@
1
+ import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
2
+ import { scheduleReindex } from "./indexing.js";
3
+ import { rebuildMetadataLight } from "./metadata.js";
4
+ import { isPathWithin, isProtectedPath, resolveVaultPaths } from "./utils.js";
5
+ import { inspectVaultFormat, isGeneratedOkfPath } from "./vault-format.js";
6
+ /**
7
+ * Guardrails and auto-rebuild hooks for the LLM Wiki extension.
8
+ */
9
+ let pendingRebuild = false;
10
+ const APPLY_PATCH_PATH_NOISE = /^\*{0,3}\s*(?:(?:update|add|delete|move)[^A-Za-z0-9]*(?:file|to)?[^A-Za-z0-9]*:)?\s*\*{0,3}\s*/i;
11
+ const PATCH_INPUT_KEYS = { input: true, _input: true, patch: true };
12
+ const DESTINATION_KEYS = {
13
+ rename: true,
14
+ move: true,
15
+ dest: true,
16
+ destination: true,
17
+ newPath: true,
18
+ };
19
+ function normalizeMutationPath(target) {
20
+ const trimmed = target.trim();
21
+ if (!trimmed)
22
+ return undefined;
23
+ const first = trimmed[0];
24
+ const last = trimmed[trimmed.length - 1];
25
+ const quoted = first === '"' || first === "'";
26
+ if (quoted !== (last === '"' || last === "'") || (quoted && first !== last)) {
27
+ return undefined;
28
+ }
29
+ const unquoted = quoted ? trimmed.slice(1, -1) : trimmed;
30
+ return unquoted.replace(APPLY_PATCH_PATH_NOISE, "") || undefined;
31
+ }
32
+ function parsePatchHeader(line) {
33
+ const trimmed = line.replace(/\r$/, "").trimEnd();
34
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
35
+ return undefined;
36
+ const body = trimmed.slice(1, -1).trim();
37
+ const tag = /#[0-9A-Fa-f]{4}\s*$/.exec(body);
38
+ const rawTarget = tag ? body.slice(0, tag.index) : body.replace(/\s+$/, "");
39
+ if (!rawTarget || rawTarget.includes("#"))
40
+ return undefined;
41
+ return normalizeMutationPath(rawTarget);
42
+ }
43
+ function parseMoveDestination(line) {
44
+ const rawDestination = line.trim().slice(2).trim();
45
+ if (!rawDestination)
46
+ return undefined;
47
+ const quote = rawDestination[0];
48
+ if (quote !== '"' && quote !== "'")
49
+ return normalizeMutationPath(rawDestination);
50
+ let cursor = 1;
51
+ while (cursor < rawDestination.length) {
52
+ if (rawDestination[cursor] === "\\" && cursor + 1 < rawDestination.length) {
53
+ cursor += 2;
54
+ continue;
55
+ }
56
+ if (rawDestination[cursor] === quote) {
57
+ return cursor === rawDestination.length - 1
58
+ ? normalizeMutationPath(rawDestination)
59
+ : undefined;
60
+ }
61
+ cursor++;
62
+ }
63
+ return undefined;
64
+ }
65
+ function scanPatchString(input) {
66
+ const paths = [];
67
+ let sawHeader = false;
68
+ let sectionHasMove = false;
69
+ let complete = true;
70
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
71
+ for (const line of stripped.split("\n")) {
72
+ const trimmed = line.replace(/\r$/, "").trim();
73
+ if (trimmed.startsWith("[")) {
74
+ sawHeader = true;
75
+ sectionHasMove = false;
76
+ const path = parsePatchHeader(line);
77
+ if (path)
78
+ paths.push(path);
79
+ else
80
+ complete = false;
81
+ continue;
82
+ }
83
+ if (!/^MV(?:\s|$)/.test(trimmed))
84
+ continue;
85
+ const destination = parseMoveDestination(trimmed);
86
+ if (!sawHeader || sectionHasMove || !destination)
87
+ complete = false;
88
+ else {
89
+ paths.push(destination);
90
+ sectionHasMove = true;
91
+ }
92
+ }
93
+ return { paths, complete: sawHeader && complete };
94
+ }
95
+ function mergeMutationScans(target, source) {
96
+ target.paths.push(...source.paths);
97
+ target.complete &&= source.complete;
98
+ }
99
+ function addMutationPath(scan, target) {
100
+ const path = normalizeMutationPath(target);
101
+ if (path)
102
+ scan.paths.push(path);
103
+ else
104
+ scan.complete = false;
105
+ }
106
+ function collectMutationPaths(input, seen, stringsArePatches = false) {
107
+ if (typeof input === "string") {
108
+ return stringsArePatches ? scanPatchString(input) : { paths: [], complete: true };
109
+ }
110
+ if (!input || typeof input !== "object" || seen.has(input)) {
111
+ return { paths: [], complete: true };
112
+ }
113
+ seen.add(input);
114
+ const scan = { paths: [], complete: true };
115
+ if (Array.isArray(input)) {
116
+ for (const value of input) {
117
+ mergeMutationScans(scan, collectMutationPaths(value, seen, stringsArePatches));
118
+ }
119
+ return scan;
120
+ }
121
+ const record = input;
122
+ if (typeof record.path === "string" && record.path.length > 0) {
123
+ addMutationPath(scan, record.path);
124
+ }
125
+ const eventPaths = Array.isArray(record.paths) ? record.paths : [record.paths];
126
+ for (const path of eventPaths) {
127
+ if (typeof path === "string" && path.length > 0)
128
+ addMutationPath(scan, path);
129
+ }
130
+ for (const [key, value] of Object.entries(record)) {
131
+ if (key === "path" || key === "paths")
132
+ continue;
133
+ if (DESTINATION_KEYS[key] === true) {
134
+ if (typeof value === "string" && value.length > 0)
135
+ addMutationPath(scan, value);
136
+ else
137
+ scan.complete = false;
138
+ continue;
139
+ }
140
+ const childStringsArePatches = stringsArePatches || PATCH_INPUT_KEYS[key] === true;
141
+ if (typeof value !== "string" && (!value || typeof value !== "object"))
142
+ continue;
143
+ mergeMutationScans(scan, collectMutationPaths(value, seen, childStringsArePatches));
144
+ }
145
+ return scan;
146
+ }
147
+ function inspectMutationPaths(input) {
148
+ const stringsArePatches = typeof input === "string" || Array.isArray(input);
149
+ const scan = collectMutationPaths(input, new WeakSet(), stringsArePatches);
150
+ return { paths: [...new Set(scan.paths)], complete: scan.complete };
151
+ }
152
+ /** Return every file path targeted by a write or patch-shaped edit input. */
153
+ export function extractMutationPaths(input) {
154
+ return inspectMutationPaths(input).paths;
155
+ }
156
+ /** True when a write or patch-shaped edit targets a page in the wiki directory. */
157
+ export function hasWikiMutation(input, wikiPath) {
158
+ return extractMutationPaths(input).some((path) => isPathWithin(wikiPath, path));
159
+ }
160
+ export function mutationBlockReason(path, paths) {
161
+ const protectedPath = isProtectedPath(path, paths);
162
+ if (protectedPath.protected)
163
+ return protectedPath.reason;
164
+ if (isPathWithin(paths.dotWiki, path)) {
165
+ const state = inspectVaultFormat(paths);
166
+ if (state.blocking) {
167
+ return `Wiki vault configuration is invalid: ${state.diagnostics[0].message}`;
168
+ }
169
+ }
170
+ if (isGeneratedOkfPath(path, paths)) {
171
+ return "Generated OKF indexes and log are read-only. Use wiki_rebuild_meta or the page-producing tool that owns the source mutation.";
172
+ }
173
+ return undefined;
174
+ }
175
+ /** Install guardrails on the extension API. */
176
+ export function installGuardrails(pi, runtime) {
177
+ // Block direct edits to raw/ and meta/, plus OKF generated projections
178
+ pi.on("tool_call", async (event) => {
179
+ if (isToolCallEventType("write", event)) {
180
+ const path = event.input.path;
181
+ const paths = resolveVaultPaths(process.cwd());
182
+ const reason = mutationBlockReason(path, paths);
183
+ if (reason)
184
+ return { block: true, reason };
185
+ }
186
+ if (isToolCallEventType("edit", event)) {
187
+ const mutation = inspectMutationPaths(event.input);
188
+ const targetPaths = mutation.paths;
189
+ if (!mutation.complete || targetPaths.length === 0) {
190
+ return { block: true, reason: "Cannot determine the files targeted by this edit." };
191
+ }
192
+ const paths = resolveVaultPaths(process.cwd());
193
+ for (const path of targetPaths) {
194
+ const reason = mutationBlockReason(path, paths);
195
+ if (reason)
196
+ return { block: true, reason };
197
+ }
198
+ }
199
+ });
200
+ // Track wiki edits for auto-rebuild
201
+ pi.on("tool_result", async (event) => {
202
+ if (event.toolName === "write" || event.toolName === "edit") {
203
+ const paths = resolveVaultPaths(process.cwd());
204
+ if (hasWikiMutation(event.input, paths.wiki)) {
205
+ pendingRebuild = true;
206
+ }
207
+ }
208
+ });
209
+ // Rebuild metadata at end of turn if wiki was modified, then refresh
210
+ // semantic embeddings in the background (#66) so manual page edits get
211
+ // re-embedded. Both are best-effort no-ops when nothing is configured.
212
+ pi.on("turn_end", async (_event, ctx) => {
213
+ if (pendingRebuild) {
214
+ pendingRebuild = false;
215
+ try {
216
+ const paths = resolveVaultPaths(process.cwd());
217
+ // Manual page edits also rebuild off the critical path. Without a
218
+ // runtime (shouldn't happen in normal wiring) fall back to inline.
219
+ if (runtime) {
220
+ const launchCtx = ctx ? { hasUI: ctx.hasUI, ui: ctx.ui } : { hasUI: false };
221
+ scheduleReindex(runtime, launchCtx, paths);
222
+ }
223
+ else {
224
+ rebuildMetadataLight(paths);
225
+ }
226
+ }
227
+ catch {
228
+ // Silently fail — metadata rebuild is best-effort
229
+ }
230
+ }
231
+ });
232
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Non-blocking vault (re)indexing.
3
+ *
4
+ * Writing a wiki page (observe / retro / capture / ensure_page) and editing one
5
+ * by hand both require the derived metadata — `meta/registry.json`,
6
+ * `backlinks.json`, `index.md`, `log.md` — to be rebuilt, plus (optionally) the
7
+ * semantic embedding store to be refreshed. That rebuild is O(pages): it
8
+ * rescans every page in the vault. Doing it inline on the tool's / turn's
9
+ * critical path makes every write get slower as the vault grows.
10
+ *
11
+ * `scheduleReindex` moves that work off the caller's stack onto the shared
12
+ * background Runtime (#64) and coalesces a burst of writes into a single pass:
13
+ *
14
+ * - A leading micro-yield guarantees the caller (a tool's `execute`, or the
15
+ * turn_end handler) returns BEFORE the heavy rebuild runs.
16
+ * - A per-vault `dirty` flag + drain loop means writes that land while a pass
17
+ * is in flight are folded into a trailing rebuild instead of being lost —
18
+ * this also covers the async window of the embeddings refresh.
19
+ * - A per-vault `inflight` guard collapses concurrent schedule calls onto the
20
+ * same promise (single-flight), so N writes in a turn cost one rebuild.
21
+ *
22
+ * Errors are isolated by `Runtime.launchTask`; the promise never rejects. The
23
+ * embeddings step is a no-op unless an embedder is configured (#66/#67).
24
+ */
25
+ import { reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
26
+ import { rebuildMetadataLight } from "./metadata.js";
27
+ /** Promise of the current background pass, keyed by vault root. */
28
+ const inflight = new Map();
29
+ /** Vault roots with writes awaiting a (re)build. */
30
+ const dirty = new Set();
31
+ /** Stable single-flight label for a vault's background index pass. */
32
+ export function indexLabel(root) {
33
+ return `index:${root}`;
34
+ }
35
+ /**
36
+ * Schedule a non-blocking metadata rebuild (+ embeddings refresh) for a vault.
37
+ * Returns the promise of the in-flight pass so callers/tests can await drainage
38
+ * (the agent loop itself never awaits it). Safe to call on every write.
39
+ */
40
+ export function scheduleReindex(runtime, ctx, paths) {
41
+ const root = paths.root;
42
+ dirty.add(root);
43
+ const active = inflight.get(root);
44
+ if (active)
45
+ return active;
46
+ const pass = runtime.launchTask(ctx, indexLabel(root), async () => {
47
+ // Yield once so the caller returns before the O(pages) rebuild runs. This
48
+ // is what makes the surrounding write non-blocking.
49
+ await Promise.resolve();
50
+ try {
51
+ // Drain: keep rebuilding until no new write arrived during the previous
52
+ // pass. The loop re-checks AFTER the awaited embeddings step, so writes
53
+ // that land during embedding are not lost.
54
+ while (dirty.has(root)) {
55
+ dirty.delete(root);
56
+ const projection = rebuildMetadataLight(paths);
57
+ if (!projection.ok)
58
+ continue;
59
+ // Refresh embeddings only after metadata is consistent. Stale-aware and
60
+ // a no-op unless an embedder is configured.
61
+ runtime.ensureConfig(root);
62
+ const embedder = resolveEmbedder(runtime.config);
63
+ if (embedder)
64
+ await reindexEmbeddings(paths, embedder);
65
+ }
66
+ }
67
+ finally {
68
+ inflight.delete(root);
69
+ }
70
+ });
71
+ inflight.set(root, pass);
72
+ return pass;
73
+ }
74
+ /** Test-only: clear coalescing state between cases. */
75
+ export function __resetIndexingState() {
76
+ inflight.clear();
77
+ dirty.clear();
78
+ }
@@ -0,0 +1,310 @@
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
+ function buildEntityPageBody(title, description, sourceId) {
52
+ const desc = description.trim() || "One-line description.";
53
+ return `# ${title}
54
+
55
+ ${desc}
56
+
57
+ ## Overview
58
+
59
+ [Key facts]
60
+
61
+ ## Links
62
+
63
+ - [${sourceId}](/sources/${sourceId}.md)`;
64
+ }
65
+ function buildConceptPageBody(title, definition, sourceId) {
66
+ const def = definition.trim() || "One-line definition.";
67
+ return `# ${title}
68
+
69
+ ${def}
70
+
71
+ ## Definition
72
+
73
+ [Clear explanation]
74
+
75
+ ## Links
76
+
77
+ - [${sourceId}](/sources/${sourceId}.md)`;
78
+ }
79
+ /** Rebuild the source page body from synthesis data, marking it ingested. */
80
+ export function buildIngestedSourcePageBody(manifest, data, _date) {
81
+ const id = String(manifest.id);
82
+ const title = String(manifest.title || id);
83
+ const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
84
+ const takeaways = data.key_takeaways.length > 0
85
+ ? data.key_takeaways.map((t) => `- ${t.trim()}`).join("\n")
86
+ : "- [None recorded]";
87
+ const entities = data.entities.length > 0
88
+ ? data.entities.map((e) => `- [${e.title}](/entities/${slugify(e.title)}.md)`).join("\n")
89
+ : "- [None]";
90
+ const concepts = data.concepts.length > 0
91
+ ? data.concepts.map((c) => `- [${c.title}](/concepts/${slugify(c.title)}.md)`).join("\n")
92
+ : "- [None]";
93
+ const quotes = data.quotes && data.quotes.length > 0
94
+ ? data.quotes
95
+ .map((q) => `> ${q.text.trim()}${q.attribution ? ` — ${q.attribution}` : ""}`)
96
+ .join("\n\n")
97
+ : "> [None recorded]";
98
+ const contradictions = data.contradictions && data.contradictions.length > 0
99
+ ? `\n## Contradictions\n\n${data.contradictions.map((c) => `⚠️ **Contradiction**: ${c.trim()}`).join("\n")}\n`
100
+ : "";
101
+ return `# ${title}${url}
102
+
103
+ ## Summary
104
+
105
+ ${data.summary.trim()}
106
+
107
+ ## Key Takeaways
108
+
109
+ ${takeaways}
110
+
111
+ ## Entities Mentioned
112
+
113
+ ${entities}
114
+
115
+ ## Concepts Mentioned
116
+
117
+ ${concepts}
118
+
119
+ ## Notable Quotes
120
+
121
+ ${quotes}
122
+ ${contradictions}## Source Packet
123
+
124
+ - **ID:** \`sources/${id}\`
125
+ - **Extracted:** \`raw/sources/${id}/extracted.md\`
126
+ - **Manifest:** \`raw/sources/${id}/manifest.json\`
127
+ `;
128
+ }
129
+ /** Rebuild the source page from synthesis data, marking it ingested. */
130
+ export function buildIngestedSourcePage(manifest, data, date) {
131
+ const id = String(manifest.id);
132
+ const title = String(manifest.title || id);
133
+ const format = String(manifest.format || "unknown");
134
+ const captured = String(manifest.captured || date);
135
+ const body = buildIngestedSourcePageBody(manifest, data, date);
136
+ const doc = createKnowledgeDocument(`sources/${id}.md`, {
137
+ type: "source",
138
+ title,
139
+ format,
140
+ source_id: id,
141
+ raw_path: `raw/sources/${id}/extracted.md`,
142
+ captured,
143
+ status: "ingested",
144
+ updated: date,
145
+ }, body);
146
+ return serializeKnowledgeDocument(doc);
147
+ }
148
+ /**
149
+ * Persist a synthesis deterministically: rewrite the source page (status →
150
+ * ingested), create missing entity/concept pages (existing pages are linked,
151
+ * never overwritten), and log the event. Pure file I/O — no LLM, no network.
152
+ */
153
+ export function commitSynthesis(paths, sourceId, manifest, data, date = fmtDate()) {
154
+ const result = {
155
+ sourceId,
156
+ sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
157
+ entitiesCreated: [],
158
+ conceptsCreated: [],
159
+ entitiesLinked: [],
160
+ conceptsLinked: [],
161
+ contradictions: data.contradictions?.length ?? 0,
162
+ };
163
+ try {
164
+ assertWritableVault(paths);
165
+ }
166
+ catch (error) {
167
+ if (error instanceof VaultWriteError) {
168
+ return { ok: false, sourceId, diagnostics: error.diagnostics };
169
+ }
170
+ throw error;
171
+ }
172
+ // Patch existing documents so unknown fields, legacy sources, and titles survive.
173
+ let sourceDocument;
174
+ if (existsSync(result.sourcePage)) {
175
+ const parsed = readKnowledgeDocumentFile(result.sourcePage, `sources/${sourceId}.md`);
176
+ if (!parsed.ok)
177
+ return { ok: false, sourceId, diagnostics: parsed.diagnostics };
178
+ sourceDocument = patchKnowledgeDocument(parsed.document, {
179
+ fields: { status: "ingested", updated: date },
180
+ body: buildIngestedSourcePageBody(manifest, data, date),
181
+ });
182
+ }
183
+ else {
184
+ sourceDocument = createKnowledgeDocument(`sources/${sourceId}.md`, {
185
+ type: "source",
186
+ title: String(manifest.title || sourceId),
187
+ format: String(manifest.format || "unknown"),
188
+ source_id: sourceId,
189
+ raw_path: `raw/sources/${sourceId}/extracted.md`,
190
+ captured: String(manifest.captured || date),
191
+ status: "ingested",
192
+ updated: date,
193
+ }, buildIngestedSourcePageBody(manifest, data, date));
194
+ }
195
+ mkdirSync(join(paths.wiki, "sources"), { recursive: true });
196
+ writeKnowledgeDocumentFile(result.sourcePage, sourceDocument);
197
+ // Entity pages — create if absent, link if present.
198
+ mkdirSync(join(paths.wiki, "entities"), { recursive: true });
199
+ for (const e of data.entities) {
200
+ const slug = slugify(e.title);
201
+ if (!slug)
202
+ continue;
203
+ const pagePath = join(paths.wiki, "entities", `${slug}.md`);
204
+ if (existsSync(pagePath)) {
205
+ result.entitiesLinked.push(slug);
206
+ }
207
+ else {
208
+ const entityDoc = createKnowledgeDocument(`entities/${slug}.md`, {
209
+ type: "entity",
210
+ title: e.title,
211
+ description: e.description.trim() || "One-line description.",
212
+ created: date,
213
+ updated: date,
214
+ }, buildEntityPageBody(e.title, e.description, sourceId), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
215
+ writeKnowledgeDocumentFile(pagePath, entityDoc);
216
+ result.entitiesCreated.push(slug);
217
+ }
218
+ }
219
+ // Concept pages — create if absent, link if present.
220
+ mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
221
+ for (const c of data.concepts) {
222
+ const slug = slugify(c.title);
223
+ if (!slug)
224
+ continue;
225
+ const pagePath = join(paths.wiki, "concepts", `${slug}.md`);
226
+ if (existsSync(pagePath)) {
227
+ result.conceptsLinked.push(slug);
228
+ }
229
+ else {
230
+ const conceptDoc = createKnowledgeDocument(`concepts/${slug}.md`, {
231
+ type: "concept",
232
+ title: c.title,
233
+ description: c.definition.trim() || "One-line definition.",
234
+ created: date,
235
+ updated: date,
236
+ }, buildConceptPageBody(c.title, c.definition, sourceId), [{ id: sourceId, resource: `/sources/${sourceId}.md` }]);
237
+ writeKnowledgeDocumentFile(pagePath, conceptDoc);
238
+ result.conceptsCreated.push(slug);
239
+ }
240
+ }
241
+ appendEvent(paths, {
242
+ kind: "ingest",
243
+ source_id: sourceId,
244
+ entities_created: result.entitiesCreated.length,
245
+ concepts_created: result.conceptsCreated.length,
246
+ contradictions: result.contradictions,
247
+ background: true,
248
+ });
249
+ return { ok: true, ...result };
250
+ }
251
+ // ── sub-agent synthesis (LLM) ─────────────────────────────
252
+ export const INGEST_SYSTEM = `You are the LLM Wiki ingestion synthesizer. You turn a single captured source's extracted text into structured wiki knowledge.
253
+
254
+ Read the source content, then call \`commit_synthesis\` EXACTLY ONCE with:
255
+ - summary: a faithful 2-3 paragraph summary (no fabrication).
256
+ - key_takeaways: the most important points.
257
+ - entities: named people, organizations, tools, products actually mentioned.
258
+ - concepts: ideas, patterns, frameworks actually discussed.
259
+ - quotes: notable verbatim quotes (optional).
260
+ - contradictions: tensions with general knowledge or noted in the text (optional).
261
+
262
+ Rules:
263
+ - Never fabricate. Only include entities/concepts present in the source.
264
+ - Keep descriptions to one line.
265
+ - After calling commit_synthesis once, reply with a one-line confirmation and stop.`;
266
+ /**
267
+ * Run the synthesis sub-agent for a single source, then commit + rebuild
268
+ * metadata. Returns the commit result, or undefined if the model produced no
269
+ * synthesis.
270
+ */
271
+ export async function runIngestSynthesis(args) {
272
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal } = args;
273
+ const content = extracted.slice(0, maxChars ?? 24_000);
274
+ if (!content.trim())
275
+ return undefined;
276
+ let committed;
277
+ const commitTool = {
278
+ name: "commit_synthesis",
279
+ label: "Commit synthesis",
280
+ description: "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
281
+ parameters: CommitSynthesisSchema,
282
+ execute: async (_id, params) => {
283
+ const outcome = commitSynthesis(paths, sourceId, manifest, params);
284
+ if (!outcome.ok) {
285
+ return {
286
+ content: [{ type: "text", text: `Failed: ${outcome.diagnostics[0].message}` }],
287
+ details: { sourceId },
288
+ isError: true,
289
+ };
290
+ }
291
+ committed = outcome;
292
+ 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.`;
293
+ return { content: [{ type: "text", text: ack }], details: { sourceId } };
294
+ },
295
+ };
296
+ const title = String(manifest.title || sourceId);
297
+ const userPrompt = `Synthesize this captured source into wiki knowledge by calling commit_synthesis once.\n\nSOURCE: ${title} (${sourceId})\n\nEXTRACTED CONTENT:\n${content}`;
298
+ await runSubAgent({
299
+ model,
300
+ apiKey,
301
+ headers,
302
+ systemPrompt: INGEST_SYSTEM,
303
+ userPrompt,
304
+ tools: [commitTool],
305
+ signal,
306
+ });
307
+ if (committed)
308
+ rebuildMetadataLight(paths);
309
+ return committed;
310
+ }
@@ -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
+ }