@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,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
+ }