@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.
- package/CHANGELOG.md +4 -0
- package/README.de.md +35 -4
- package/README.es.md +260 -170
- package/README.fr.md +35 -4
- package/README.hi.md +35 -4
- package/README.ja.md +35 -4
- package/README.ko.md +35 -4
- package/README.md +38 -3
- package/README.pt.md +35 -4
- package/README.ru.md +35 -4
- package/README.zh.md +260 -170
- package/assets/demo.gif +0 -0
- package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
- package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
- package/dist/extensions/llm-wiki/lib/inject.js +65 -0
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
- package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
- package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
- package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/observation.js +283 -0
- package/dist/extensions/llm-wiki/lib/recall.js +875 -0
- package/dist/extensions/llm-wiki/lib/retro.js +158 -0
- package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
- package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
- package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
- package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
- package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
- package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
- package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
- package/dist/extensions/llm-wiki/lib/utils.js +347 -0
- package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
- package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
- package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
- package/dist/mcp/exec.js +121 -0
- package/dist/mcp/index.js +229 -0
- package/dist/mcp/operations.js +130 -0
- package/dist/package.json +1 -0
- package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
- package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
- package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
- package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
- package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
- package/extensions/llm-wiki/index.ts +22 -36
- package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
- package/extensions/llm-wiki/lib/embeddings.ts +9 -3
- package/extensions/llm-wiki/lib/guardrails.ts +174 -29
- package/extensions/llm-wiki/lib/indexing.ts +2 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
- package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
- package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
- package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
- package/extensions/llm-wiki/lib/metadata.ts +531 -116
- package/extensions/llm-wiki/lib/observation.ts +37 -43
- package/extensions/llm-wiki/lib/recall.ts +61 -33
- package/extensions/llm-wiki/lib/retro.ts +65 -41
- package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
- package/extensions/llm-wiki/lib/source-packet.ts +44 -31
- package/extensions/llm-wiki/lib/tools.ts +406 -348
- package/extensions/llm-wiki/lib/trajectory.ts +15 -1
- package/extensions/llm-wiki/lib/utils.ts +121 -130
- package/extensions/llm-wiki/lib/vault-format.ts +363 -0
- package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
- package/mcp/exec.ts +122 -0
- package/mcp/index.ts +60 -250
- package/mcp/operations.ts +176 -0
- package/package.json +8 -2
- package/scripts/migrate-llm-wiki.js +801 -0
- 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
|
+
}
|