@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,347 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Detect the vault format at a given directory.
|
|
6
|
+
* Returns "new" if .llm-wiki exists, "legacy" if .wiki exists,
|
|
7
|
+
* and "none" otherwise. A missing config is still a detected, damaged vault.
|
|
8
|
+
*/
|
|
9
|
+
export function detectVaultFormat(dir) {
|
|
10
|
+
if (existsSync(join(dir, ".llm-wiki")))
|
|
11
|
+
return "new";
|
|
12
|
+
if (existsSync(join(dir, ".wiki")))
|
|
13
|
+
return "legacy";
|
|
14
|
+
return "none";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Get the personal wiki root directory.
|
|
18
|
+
*
|
|
19
|
+
* The "root" follows the same contract as project wikis: it is the directory
|
|
20
|
+
* that *contains* the `.llm-wiki/` dot-dir, NOT the dot-dir itself.
|
|
21
|
+
* So the personal vault lives at `<root>/.llm-wiki/`.
|
|
22
|
+
*
|
|
23
|
+
* Default root: `homedir()` → personal vault at `~/.llm-wiki/`.
|
|
24
|
+
* Override: `WIKI_HOME` env var → personal vault at `$WIKI_HOME/.llm-wiki/`.
|
|
25
|
+
*
|
|
26
|
+
* NOTE: Previously this returned `~/.llm-wiki` (the dot-dir itself), which
|
|
27
|
+
* caused `getVaultPaths()` to compose paths like `~/.llm-wiki/.llm-wiki/raw`.
|
|
28
|
+
* See `migrateDoubledPersonalVault()` for the one-shot recovery.
|
|
29
|
+
*/
|
|
30
|
+
export function getPersonalWikiRoot() {
|
|
31
|
+
const envWiki = process.env.WIKI_HOME;
|
|
32
|
+
if (envWiki)
|
|
33
|
+
return envWiki;
|
|
34
|
+
return homedir();
|
|
35
|
+
}
|
|
36
|
+
/** Get VaultPaths for the personal wiki. */
|
|
37
|
+
export function getPersonalWikiPaths() {
|
|
38
|
+
return getVaultPaths(getPersonalWikiRoot());
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One-shot, idempotent migration for vaults that were created with the broken
|
|
42
|
+
* `getPersonalWikiRoot()` (returned the dot-dir itself, so `getVaultPaths()`
|
|
43
|
+
* composed `<root>/.llm-wiki/.llm-wiki/...`).
|
|
44
|
+
*
|
|
45
|
+
* Detects a doubled layout at `<root>/.llm-wiki/.llm-wiki/config.json` and
|
|
46
|
+
* flattens it up by one level. Safe to call on every session start: if the
|
|
47
|
+
* doubled sentinel is absent, this is a no-op.
|
|
48
|
+
*
|
|
49
|
+
* Returns a description of the action taken (or `null` if no migration was
|
|
50
|
+
* needed) so callers can surface a one-line status message.
|
|
51
|
+
*/
|
|
52
|
+
export function migrateDoubledPersonalVault(parentRoot = getPersonalWikiRoot()) {
|
|
53
|
+
const outerDotWiki = join(parentRoot, ".llm-wiki");
|
|
54
|
+
const innerDotWiki = join(outerDotWiki, ".llm-wiki");
|
|
55
|
+
const innerSentinel = join(innerDotWiki, "config.json");
|
|
56
|
+
if (!existsSync(innerSentinel))
|
|
57
|
+
return null;
|
|
58
|
+
const moved = [];
|
|
59
|
+
const skipped = [];
|
|
60
|
+
for (const entry of readdirSync(innerDotWiki)) {
|
|
61
|
+
const src = join(innerDotWiki, entry);
|
|
62
|
+
const dest = join(outerDotWiki, entry);
|
|
63
|
+
if (existsSync(dest)) {
|
|
64
|
+
// Collision — leave the inner copy in place rather than clobber.
|
|
65
|
+
skipped.push(entry);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
renameSync(src, dest);
|
|
69
|
+
moved.push(entry);
|
|
70
|
+
}
|
|
71
|
+
// Only remove the inner dir if it is fully drained.
|
|
72
|
+
if (skipped.length === 0) {
|
|
73
|
+
try {
|
|
74
|
+
rmdirSync(innerDotWiki);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Leave behind if something raced us; harmless.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { moved, from: innerDotWiki, to: outerDotWiki, skipped };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check if a vault is the personal wiki location.
|
|
84
|
+
* Used in layered recall to avoid double-counting.
|
|
85
|
+
*/
|
|
86
|
+
export function isPersonalVault(paths) {
|
|
87
|
+
return paths.root === getPersonalWikiRoot();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Resolve vault root from cwd with personal fallback.
|
|
91
|
+
*
|
|
92
|
+
* Priority:
|
|
93
|
+
* 1. cwd has .llm-wiki/ → project wiki (explicit)
|
|
94
|
+
* 2. Walk up from cwd → parent project wiki
|
|
95
|
+
* 3. ~/.llm-wiki/ exists → personal wiki
|
|
96
|
+
* 4. Fallback: ~/.llm-wiki/ (create personal wiki)
|
|
97
|
+
*/
|
|
98
|
+
export function resolveVaultRoot(cwd) {
|
|
99
|
+
// A vault rooted at cwd is always the project-local choice.
|
|
100
|
+
if (detectVaultFormat(cwd) !== "none")
|
|
101
|
+
return cwd;
|
|
102
|
+
// An explicit WIKI_HOME is a testable/user-selected fallback and must win
|
|
103
|
+
// over an unrelated personal vault found while walking parent directories.
|
|
104
|
+
if (process.env.WIKI_HOME)
|
|
105
|
+
return process.env.WIKI_HOME;
|
|
106
|
+
// Walk up looking for a vault sentinel (new or legacy)
|
|
107
|
+
let dir = cwd;
|
|
108
|
+
while (dir !== dirname(dir)) {
|
|
109
|
+
dir = dirname(dir);
|
|
110
|
+
if (detectVaultFormat(dir) !== "none")
|
|
111
|
+
return dir;
|
|
112
|
+
}
|
|
113
|
+
// Check personal wiki at ~/.llm-wiki/
|
|
114
|
+
const personalRoot = getPersonalWikiRoot();
|
|
115
|
+
if (detectVaultFormat(personalRoot) !== "none")
|
|
116
|
+
return personalRoot;
|
|
117
|
+
// Fallback: personal wiki
|
|
118
|
+
return personalRoot;
|
|
119
|
+
}
|
|
120
|
+
/** Get all vault paths for the new (.llm-wiki) layout. */
|
|
121
|
+
export function getVaultPaths(root) {
|
|
122
|
+
return {
|
|
123
|
+
root,
|
|
124
|
+
raw: join(root, ".llm-wiki", "raw"),
|
|
125
|
+
rawSources: join(root, ".llm-wiki", "raw", "sources"),
|
|
126
|
+
rawTrajectories: join(root, ".llm-wiki", "raw", "trajectories"),
|
|
127
|
+
wiki: join(root, ".llm-wiki", "wiki"),
|
|
128
|
+
meta: join(root, ".llm-wiki", "meta"),
|
|
129
|
+
dotWiki: join(root, ".llm-wiki"),
|
|
130
|
+
outputs: join(root, ".llm-wiki", "outputs"),
|
|
131
|
+
discoveries: join(root, ".llm-wiki", ".discoveries"),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Get all vault paths for the legacy (.wiki) layout. */
|
|
135
|
+
export function getLegacyVaultPaths(root) {
|
|
136
|
+
return {
|
|
137
|
+
root,
|
|
138
|
+
raw: join(root, "raw"),
|
|
139
|
+
rawSources: join(root, "raw", "sources"),
|
|
140
|
+
rawTrajectories: join(root, "raw", "trajectories"),
|
|
141
|
+
wiki: join(root, "wiki"),
|
|
142
|
+
meta: join(root, "meta"),
|
|
143
|
+
dotWiki: join(root, ".wiki"),
|
|
144
|
+
outputs: join(root, "outputs"),
|
|
145
|
+
discoveries: join(root, ".discoveries"),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Resolve vault paths, auto-detecting new vs legacy layout.
|
|
150
|
+
* This is the main entry point: use this instead of resolveVaultRoot + getVaultPaths.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveVaultPaths(cwd) {
|
|
153
|
+
const root = resolveVaultRoot(cwd);
|
|
154
|
+
const format = detectVaultFormat(root);
|
|
155
|
+
if (format === "legacy")
|
|
156
|
+
return getLegacyVaultPaths(root);
|
|
157
|
+
return getVaultPaths(root);
|
|
158
|
+
}
|
|
159
|
+
/** Ensure all vault directories exist. */
|
|
160
|
+
export function ensureVaultStructure(paths) {
|
|
161
|
+
// NOTE: the agent-trajectory dirs (raw/trajectories, wiki/skills, wiki/cases)
|
|
162
|
+
// are intentionally NOT created here — they are created lazily on first
|
|
163
|
+
// capture/distill (issue #80), so a vault with the feature off carries no
|
|
164
|
+
// trace of it. All readers of these paths are existsSync-guarded.
|
|
165
|
+
const dirs = [
|
|
166
|
+
paths.rawSources,
|
|
167
|
+
join(paths.raw, "assets"),
|
|
168
|
+
join(paths.wiki, "sources"),
|
|
169
|
+
join(paths.wiki, "entities"),
|
|
170
|
+
join(paths.wiki, "concepts"),
|
|
171
|
+
join(paths.wiki, "syntheses"),
|
|
172
|
+
join(paths.wiki, "analyses"),
|
|
173
|
+
join(paths.wiki, "requirements"),
|
|
174
|
+
paths.meta,
|
|
175
|
+
paths.dotWiki,
|
|
176
|
+
paths.outputs,
|
|
177
|
+
paths.discoveries,
|
|
178
|
+
join(paths.dotWiki, "templates"),
|
|
179
|
+
join(paths.dotWiki, "templates", "pages"),
|
|
180
|
+
];
|
|
181
|
+
for (const d of dirs)
|
|
182
|
+
mkdirSync(d, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
/** Read JSON file or return default. */
|
|
185
|
+
export function readJson(path, defaultValue) {
|
|
186
|
+
try {
|
|
187
|
+
if (!existsSync(path))
|
|
188
|
+
return defaultValue;
|
|
189
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return defaultValue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/** Write JSON file atomically. */
|
|
196
|
+
export function writeJson(path, data) {
|
|
197
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
198
|
+
}
|
|
199
|
+
/** Read text file or return empty string. */
|
|
200
|
+
export function readText(path) {
|
|
201
|
+
try {
|
|
202
|
+
if (!existsSync(path))
|
|
203
|
+
return "";
|
|
204
|
+
return readFileSync(path, "utf-8");
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return "";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Generate the next source ID. */
|
|
211
|
+
export function nextSourceId(paths) {
|
|
212
|
+
return nextSequentialId(paths.rawSources, "SRC");
|
|
213
|
+
}
|
|
214
|
+
/** Generate the next trajectory ID. */
|
|
215
|
+
export function nextTrajectoryId(paths) {
|
|
216
|
+
return nextSequentialId(paths.rawTrajectories, "TRJ");
|
|
217
|
+
}
|
|
218
|
+
/** Generate the next sequential, date-stamped packet ID for a raw subdir. */
|
|
219
|
+
function nextSequentialId(dir, kind) {
|
|
220
|
+
const today = new Date().toISOString().split("T")[0];
|
|
221
|
+
const prefix = `${kind}-${today}`;
|
|
222
|
+
if (!existsSync(dir))
|
|
223
|
+
return `${prefix}-001`;
|
|
224
|
+
const dirs = readdirSync(dir)
|
|
225
|
+
.filter((d) => d.startsWith(prefix))
|
|
226
|
+
.sort();
|
|
227
|
+
if (dirs.length === 0)
|
|
228
|
+
return `${prefix}-001`;
|
|
229
|
+
const last = dirs[dirs.length - 1];
|
|
230
|
+
const num = Number.parseInt(last.slice(-3), 10);
|
|
231
|
+
return `${prefix}-${String(num + 1).padStart(3, "0")}`;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Slugify a title to a kebab-case page slug.
|
|
235
|
+
*
|
|
236
|
+
* - Folds full-width ASCII forms and ideographic spaces without changing
|
|
237
|
+
* unrelated Unicode compatibility characters.
|
|
238
|
+
* - Collapses whitespace AND existing hyphens into a single `-`.
|
|
239
|
+
* - Trims leading/trailing hyphens.
|
|
240
|
+
* - Prefixes Windows reserved device names (CON, PRN, AUX, NUL, COM1-9,
|
|
241
|
+
* LPT1-9, including superscript 1-3 forms) with `_`.
|
|
242
|
+
* - Appends `-page` to `index`/`log` slugs to avoid colliding with the
|
|
243
|
+
* special INDEX/LOG wiki pages.
|
|
244
|
+
*/
|
|
245
|
+
export function slugify(title) {
|
|
246
|
+
const slug = title
|
|
247
|
+
.replace(/[\uFF01-\uFF5E]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
|
|
248
|
+
.replace(/\u3000/g, " ")
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.normalize("NFC")
|
|
251
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
252
|
+
.trim()
|
|
253
|
+
.replace(/[\s-]+/g, "-")
|
|
254
|
+
.replace(/^-+|-+$/g, "")
|
|
255
|
+
.slice(0, 80)
|
|
256
|
+
.replace(/[\uD800-\uDBFF]$/, "")
|
|
257
|
+
.replace(/^-+|-+$/g, "") || "untitled";
|
|
258
|
+
if (/^(?:con|prn|aux|nul|com(?:[1-9¹²³])|lpt(?:[1-9¹²³]))$/.test(slug)) {
|
|
259
|
+
return `_${slug}`;
|
|
260
|
+
}
|
|
261
|
+
return slug === "index" || slug === "log" ? `${slug}-page` : slug;
|
|
262
|
+
}
|
|
263
|
+
/** Format date as YYYY-MM-DD. */
|
|
264
|
+
export function fmtDate(d = new Date()) {
|
|
265
|
+
return d.toISOString().split("T")[0];
|
|
266
|
+
}
|
|
267
|
+
/** Run a shell command via pi.exec and reject failed or cancelled commands. */
|
|
268
|
+
export async function exec(pi, command, args, options) {
|
|
269
|
+
const result = await pi.exec(command, args, options ?? {});
|
|
270
|
+
if (result.killed)
|
|
271
|
+
throw new Error(`Command timed out or was aborted: ${command}`);
|
|
272
|
+
if (result.code !== 0) {
|
|
273
|
+
const detail = result.stderr.trim();
|
|
274
|
+
throw new Error(`Command failed (${command} exited ${result.code})${detail ? `: ${detail}` : ""}`);
|
|
275
|
+
}
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
function appendPath(base, ...parts) {
|
|
279
|
+
if (parts.length === 0)
|
|
280
|
+
return base;
|
|
281
|
+
return `${base}${base.endsWith(sep) ? "" : sep}${parts.join(sep)}`;
|
|
282
|
+
}
|
|
283
|
+
function realpathWithMissingTail(path, seen = new Set()) {
|
|
284
|
+
let current = path;
|
|
285
|
+
const tail = [];
|
|
286
|
+
while (true) {
|
|
287
|
+
try {
|
|
288
|
+
const stat = lstatSync(current);
|
|
289
|
+
if (stat.isSymbolicLink()) {
|
|
290
|
+
const symlinkPath = appendPath(realpathSync.native(dirname(current)), basename(current));
|
|
291
|
+
if (seen.has(symlinkPath))
|
|
292
|
+
throw new Error(`Cannot resolve symlink cycle: ${path}`);
|
|
293
|
+
seen.add(symlinkPath);
|
|
294
|
+
const target = readlinkSync(current).toString();
|
|
295
|
+
const resolvedTarget = isAbsolute(target) ? target : appendPath(dirname(current), target);
|
|
296
|
+
return realpathWithMissingTail(appendPath(resolvedTarget, ...tail.reverse()), seen);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error.code !== "ENOENT")
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return appendPath(realpathSync.native(current), ...tail.reverse());
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (error.code !== "ENOENT")
|
|
308
|
+
throw error;
|
|
309
|
+
const parent = dirname(current);
|
|
310
|
+
if (parent === current)
|
|
311
|
+
throw error;
|
|
312
|
+
tail.push(basename(current));
|
|
313
|
+
current = parent;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Return the candidate path relative to the physical root. */
|
|
318
|
+
export function relativePhysicalPath(rootPath, candidatePath) {
|
|
319
|
+
return relative(realpathWithMissingTail(rootPath), realpathWithMissingTail(candidatePath));
|
|
320
|
+
}
|
|
321
|
+
/** Check physical containment, resolving existing symlink ancestors. */
|
|
322
|
+
export function isPathWithin(rootPath, candidatePath) {
|
|
323
|
+
const relation = relativePhysicalPath(rootPath, candidatePath);
|
|
324
|
+
return (relation === "" ||
|
|
325
|
+
(!isAbsolute(relation) && relation !== ".." && !relation.startsWith(`..${sep}`)));
|
|
326
|
+
}
|
|
327
|
+
/** Check if a path is inside a protected directory. */
|
|
328
|
+
export function isProtectedPath(absPath, paths) {
|
|
329
|
+
try {
|
|
330
|
+
if (isPathWithin(paths.raw, absPath)) {
|
|
331
|
+
return {
|
|
332
|
+
protected: true,
|
|
333
|
+
reason: "Raw sources are immutable. Use wiki_capture_source to add sources.",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
if (isPathWithin(paths.meta, absPath)) {
|
|
337
|
+
return {
|
|
338
|
+
protected: true,
|
|
339
|
+
reason: "Metadata is auto-generated. Use wiki_rebuild_meta or wiki_log_event instead.",
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return { protected: false };
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return { protected: true, reason: "Cannot safely resolve mutation path." };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
import { parseKnowledgeDocument, parseMarkdownFrontmatter, } from "./knowledge-document.js";
|
|
4
|
+
import { relativePhysicalPath } from "./utils.js";
|
|
5
|
+
export class VaultWriteError extends Error {
|
|
6
|
+
diagnostics;
|
|
7
|
+
constructor(diagnostics) {
|
|
8
|
+
super(diagnostics[0]?.message ?? "Wiki vault is not writable");
|
|
9
|
+
this.diagnostics = diagnostics;
|
|
10
|
+
this.name = "VaultWriteError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const RESERVED_NAMES = new Set(["index", "log"]);
|
|
14
|
+
export function compareCodePoint(a, b) {
|
|
15
|
+
const left = a.normalize("NFC");
|
|
16
|
+
const right = b.normalize("NFC");
|
|
17
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
18
|
+
}
|
|
19
|
+
function diag(severity, code, path, message) {
|
|
20
|
+
return { severity, code, path, message };
|
|
21
|
+
}
|
|
22
|
+
function isReservedName(filename) {
|
|
23
|
+
const name = filename.toLowerCase();
|
|
24
|
+
return name === "index.md" || name === "log.md";
|
|
25
|
+
}
|
|
26
|
+
export function readVaultConfig(paths) {
|
|
27
|
+
const path = join(paths.dotWiki, "config.json");
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
30
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
31
|
+
throw new Error("config.json must contain an object");
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, config: parsed };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
diagnostic: diag("error", "config_invalid_knowledge_format", "config.json", `Cannot read valid wiki config: ${error.message}`),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function inspectVaultFormat(paths) {
|
|
43
|
+
const diagnostics = [];
|
|
44
|
+
let blocking = false;
|
|
45
|
+
const configResult = readVaultConfig(paths);
|
|
46
|
+
if (!configResult.ok) {
|
|
47
|
+
return {
|
|
48
|
+
knowledgeFormat: "legacy",
|
|
49
|
+
diagnostics: [configResult.diagnostic],
|
|
50
|
+
blocking: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const config = configResult.config;
|
|
54
|
+
const rawFormat = config.knowledge_format;
|
|
55
|
+
// Resolve format
|
|
56
|
+
let format;
|
|
57
|
+
if (rawFormat === undefined) {
|
|
58
|
+
format = "legacy";
|
|
59
|
+
}
|
|
60
|
+
else if (rawFormat === "legacy" || rawFormat === "okf-0.2") {
|
|
61
|
+
format = rawFormat;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
return {
|
|
65
|
+
knowledgeFormat: "legacy",
|
|
66
|
+
diagnostics: [
|
|
67
|
+
diag("error", "config_invalid_knowledge_format", "config.json", `Invalid knowledge_format value: ${JSON.stringify(rawFormat)}`),
|
|
68
|
+
],
|
|
69
|
+
blocking: true,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// In OKF mode, check root index version
|
|
73
|
+
// Missing root index is repairable; version mismatch blocks until explicitly handled
|
|
74
|
+
if (format === "okf-0.2") {
|
|
75
|
+
const rootIndexPath = join(paths.wiki, "index.md");
|
|
76
|
+
let rootContent;
|
|
77
|
+
try {
|
|
78
|
+
rootContent = readFileSync(rootIndexPath, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT") {
|
|
82
|
+
diagnostics.push(diag("error", "okf_version_mismatch", "wiki/index.md", `Cannot read OKF root index: ${error.message}`));
|
|
83
|
+
blocking = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (rootContent !== undefined) {
|
|
87
|
+
const frontmatter = parseMarkdownFrontmatter(rootContent, "index.md");
|
|
88
|
+
if (!frontmatter.ok || frontmatter.mapping.okf_version !== "0.2") {
|
|
89
|
+
diagnostics.push(diag("error", "okf_version_mismatch", "wiki/index.md", frontmatter.ok
|
|
90
|
+
? 'OKF root index must declare okf_version "0.2"'
|
|
91
|
+
: `Malformed OKF root index: ${frontmatter.diagnostics[0].message}`));
|
|
92
|
+
blocking = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// In legacy mode, check if root index declares unsupported version
|
|
97
|
+
if (format === "legacy") {
|
|
98
|
+
const rootIndexPath = join(paths.wiki, "index.md");
|
|
99
|
+
try {
|
|
100
|
+
const content = readFileSync(rootIndexPath, "utf8");
|
|
101
|
+
const frontmatter = parseMarkdownFrontmatter(content, "index.md");
|
|
102
|
+
if (frontmatter.ok && frontmatter.mapping.okf_version !== undefined) {
|
|
103
|
+
if (frontmatter.mapping.okf_version !== "0.2") {
|
|
104
|
+
diagnostics.push(diag("error", "okf_version_mismatch", "wiki/index.md", `Root index declares unsupported okf_version "${frontmatter.mapping.okf_version}"`));
|
|
105
|
+
blocking = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// No root index in legacy mode - fine
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
knowledgeFormat: format,
|
|
115
|
+
diagnostics,
|
|
116
|
+
blocking,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function collectMarkdownFiles(dir, wikiRoot) {
|
|
120
|
+
const files = [];
|
|
121
|
+
const diagnostics = [];
|
|
122
|
+
let entries;
|
|
123
|
+
try {
|
|
124
|
+
entries = readdirSync(dir).sort(compareCodePoint);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
diagnostics.push(diag("error", "frontmatter_parse_error", relative(wikiRoot, dir).replace(/\\/g, "/") || ".", `Failed to scan knowledge directory: ${error.message}`));
|
|
128
|
+
return { files, diagnostics };
|
|
129
|
+
}
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const fullPath = join(dir, entry);
|
|
132
|
+
try {
|
|
133
|
+
const stat = lstatSync(fullPath);
|
|
134
|
+
if (stat.isSymbolicLink())
|
|
135
|
+
continue;
|
|
136
|
+
if (stat.isDirectory()) {
|
|
137
|
+
const child = collectMarkdownFiles(fullPath, wikiRoot);
|
|
138
|
+
files.push(...child.files);
|
|
139
|
+
diagnostics.push(...child.diagnostics);
|
|
140
|
+
}
|
|
141
|
+
else if (stat.isFile() && entry.toLowerCase().endsWith(".md") && !isReservedName(entry)) {
|
|
142
|
+
files.push(fullPath);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
diagnostics.push(diag("error", "frontmatter_parse_error", relative(wikiRoot, fullPath).replace(/\\/g, "/"), `Failed to inspect knowledge path: ${error.message}`));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { files, diagnostics };
|
|
150
|
+
}
|
|
151
|
+
/** Validate vault is writable: exists, valid mode, no blocking version mismatch. */
|
|
152
|
+
export function inspectWritableVault(paths) {
|
|
153
|
+
// Check vault exists
|
|
154
|
+
if (!existsSync(paths.dotWiki)) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
diagnostics: [
|
|
158
|
+
diag("error", "config_invalid_knowledge_format", paths.dotWiki, "Wiki vault not found. Run wiki_bootstrap first."),
|
|
159
|
+
],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const state = inspectVaultFormat(paths);
|
|
163
|
+
if (state.blocking) {
|
|
164
|
+
return { ok: false, diagnostics: state.diagnostics };
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, format: state.knowledgeFormat };
|
|
167
|
+
}
|
|
168
|
+
/** Assert that an authoritative writer may mutate this vault. */
|
|
169
|
+
export function assertWritableVault(paths) {
|
|
170
|
+
const result = inspectWritableVault(paths);
|
|
171
|
+
if (!result.ok)
|
|
172
|
+
throw new VaultWriteError(result.diagnostics);
|
|
173
|
+
return result.format;
|
|
174
|
+
}
|
|
175
|
+
/** Check if path is a generated OKF reserved file (mode-aware). */
|
|
176
|
+
export function isGeneratedOkfPath(path, paths) {
|
|
177
|
+
const state = inspectVaultFormat(paths);
|
|
178
|
+
if (state.blocking || state.knowledgeFormat !== "okf-0.2")
|
|
179
|
+
return false;
|
|
180
|
+
const rel = relativePhysicalPath(paths.wiki, path);
|
|
181
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
182
|
+
return false;
|
|
183
|
+
const parts = rel.split(sep);
|
|
184
|
+
const name = parts.at(-1)?.toLowerCase();
|
|
185
|
+
return name === "index.md" || (parts.length === 1 && name === "log.md");
|
|
186
|
+
}
|
|
187
|
+
export function discoverKnowledgeDocuments(paths) {
|
|
188
|
+
const diagnostics = [];
|
|
189
|
+
const documents = [];
|
|
190
|
+
let blocking = false;
|
|
191
|
+
const seenIds = new Map();
|
|
192
|
+
const scan = collectMarkdownFiles(paths.wiki, paths.wiki);
|
|
193
|
+
diagnostics.push(...scan.diagnostics);
|
|
194
|
+
if (scan.diagnostics.length > 0)
|
|
195
|
+
blocking = true;
|
|
196
|
+
for (const file of scan.files) {
|
|
197
|
+
const physicalPath = relative(paths.wiki, file).replace(/\\/g, "/");
|
|
198
|
+
const normalizedPath = physicalPath.normalize("NFC");
|
|
199
|
+
const id = normalizedPath.replace(/\.md$/, "");
|
|
200
|
+
// Check for reserved names (case-insensitive)
|
|
201
|
+
const filename = (normalizedPath.split("/").pop() ?? "").replace(/\.md$/i, "").toLowerCase();
|
|
202
|
+
if (RESERVED_NAMES.has(filename)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Check for identity collision
|
|
206
|
+
const collisionKey = id.toLowerCase();
|
|
207
|
+
const existing = seenIds.get(collisionKey);
|
|
208
|
+
if (existing && existing.physicalPath !== physicalPath) {
|
|
209
|
+
diagnostics.push(diag("error", "concept_identity_collision", physicalPath, `Identity collision between ${existing.physicalPath} and ${physicalPath}`));
|
|
210
|
+
blocking = true;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
seenIds.set(collisionKey, { id, physicalPath });
|
|
214
|
+
// Parse the document
|
|
215
|
+
try {
|
|
216
|
+
const content = readFileSync(file, "utf8");
|
|
217
|
+
const result = parseKnowledgeDocument(content, normalizedPath);
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
diagnostics.push(...result.diagnostics);
|
|
220
|
+
blocking = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const doc = result.document;
|
|
224
|
+
if (result.diagnostics.length > 0) {
|
|
225
|
+
diagnostics.push(...result.diagnostics);
|
|
226
|
+
}
|
|
227
|
+
documents.push({
|
|
228
|
+
...doc,
|
|
229
|
+
id,
|
|
230
|
+
path: normalizedPath,
|
|
231
|
+
absolutePath: file,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
const err = e;
|
|
236
|
+
diagnostics.push(diag("error", "frontmatter_parse_error", normalizedPath, `Failed to read file: ${err.message}`));
|
|
237
|
+
blocking = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Sort documents by code point order
|
|
241
|
+
documents.sort((a, b) => compareCodePoint(a.id, b.id));
|
|
242
|
+
return {
|
|
243
|
+
documents,
|
|
244
|
+
diagnostics,
|
|
245
|
+
blocking,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { MODEL_STATUS_KEY, formatActiveModelLabel } from "./model-command.js";
|
|
2
|
+
import { noticesEnabled } from "./task-config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Apply the two post-session-start visible status lines (issue #77,
|
|
5
|
+
* regression-fixed in #83, comments + tests hardened in #84):
|
|
6
|
+
*
|
|
7
|
+
* 1. `🧠 LLM Wiki (… tools, … active)` — the "wiki is loaded" badge
|
|
8
|
+
* 2. `🧠 wiki model: <label>` — the active background task model
|
|
9
|
+
*
|
|
10
|
+
* Both are user-facing chat noise and are gated by `llm-wiki.notices`
|
|
11
|
+
* (default `true`). When `notices: false`, neither status is set — that's the
|
|
12
|
+
* contract the regression in #83 was about.
|
|
13
|
+
*
|
|
14
|
+
* Extracted from `index.ts` so the gating contract is unit-testable without
|
|
15
|
+
* faking the entire pi extension factory; see `test/visible-activity.test.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Pure modulo `ui.setStatus`. Callers MUST run `runtime.ensureConfig(...)` for
|
|
18
|
+
* the current cwd before invoking this so `noticesEnabled(runtime.config)`
|
|
19
|
+
* sees the loaded project settings.
|
|
20
|
+
*/
|
|
21
|
+
export function applySessionStartStatus(opts) {
|
|
22
|
+
// Single gate for BOTH status lines (#83 added two adjacent guards; #84
|
|
23
|
+
// collapses them — same condition, same scope).
|
|
24
|
+
if (!noticesEnabled(opts.runtime.config))
|
|
25
|
+
return;
|
|
26
|
+
opts.ui.setStatus("llm-wiki", opts.trajectoriesOn
|
|
27
|
+
? "🧠 LLM Wiki (16 tools, trajectory + observe + recall active)"
|
|
28
|
+
: "🧠 LLM Wiki (13 tools, observe + recall active)");
|
|
29
|
+
const modelLabel = formatActiveModelLabel(opts.runtime.config, opts.sessionModelId);
|
|
30
|
+
opts.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${modelLabel}`);
|
|
31
|
+
}
|