@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
@@ -12,6 +12,7 @@ import {
12
12
  resolveVaultPaths,
13
13
  writeJson,
14
14
  } from "./utils.js";
15
+ import { assertWritableVault, inspectWritableVault } from "./vault-format.js";
15
16
 
16
17
  /**
17
18
  * Agent trajectory memory — the working-memory half of the wiki.
@@ -230,6 +231,7 @@ export function captureTrajectory(
230
231
  paths: VaultPaths,
231
232
  input: CaptureTrajectoryInput,
232
233
  ): CaptureTrajectoryResult {
234
+ assertWritableVault(paths);
233
235
  const trajectoryId = nextTrajectoryId(paths);
234
236
  const packetPath = join(paths.rawTrajectories, trajectoryId);
235
237
  mkdirSync(packetPath, { recursive: true });
@@ -356,7 +358,19 @@ export function registerWikiCaptureTrajectory(pi: ExtensionAPI): void {
356
358
  }),
357
359
  async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
358
360
  const paths = resolveVaultPaths(ctx.cwd ?? process.cwd());
359
- if (!existsSync(join(paths.dotWiki, "config.json"))) return vaultMissing();
361
+ const vaultCheck = inspectWritableVault(paths);
362
+ if (!vaultCheck.ok) {
363
+ return {
364
+ content: [
365
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
366
+ ],
367
+ details: {
368
+ error: vaultCheck.diagnostics[0].code,
369
+ diagnostics: vaultCheck.diagnostics,
370
+ } as Record<string, unknown>,
371
+ isError: true,
372
+ };
373
+ }
360
374
 
361
375
  let steps = params.steps as TrajectoryStep[] | undefined;
362
376
  let model = params.model;
@@ -1,15 +1,17 @@
1
1
  import {
2
2
  existsSync,
3
+ lstatSync,
3
4
  mkdirSync,
4
5
  readFileSync,
5
6
  readdirSync,
7
+ readlinkSync,
8
+ realpathSync,
6
9
  renameSync,
7
10
  rmdirSync,
8
- statSync,
9
11
  writeFileSync,
10
12
  } from "node:fs";
11
13
  import { homedir } from "node:os";
12
- import { dirname, join, resolve } from "node:path";
14
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
13
15
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
14
16
 
15
17
  /**
@@ -33,13 +35,12 @@ export type VaultFormat = "new" | "legacy" | "none";
33
35
 
34
36
  /**
35
37
  * Detect the vault format at a given directory.
36
- * Returns "new" if .llm-wiki/config.json exists,
37
- * "legacy" if .wiki/config.json exists,
38
- * "none" otherwise.
38
+ * Returns "new" if .llm-wiki exists, "legacy" if .wiki exists,
39
+ * and "none" otherwise. A missing config is still a detected, damaged vault.
39
40
  */
40
41
  export function detectVaultFormat(dir: string): VaultFormat {
41
- if (existsSync(join(dir, ".llm-wiki", "config.json"))) return "new";
42
- if (existsSync(join(dir, ".wiki", "config.json"))) return "legacy";
42
+ if (existsSync(join(dir, ".llm-wiki"))) return "new";
43
+ if (existsSync(join(dir, ".wiki"))) return "legacy";
43
44
  return "none";
44
45
  }
45
46
 
@@ -134,9 +135,13 @@ export function isPersonalVault(paths: VaultPaths): boolean {
134
135
  * 4. Fallback: ~/.llm-wiki/ (create personal wiki)
135
136
  */
136
137
  export function resolveVaultRoot(cwd: string): string {
137
- // Check for any vault format at cwd
138
+ // A vault rooted at cwd is always the project-local choice.
138
139
  if (detectVaultFormat(cwd) !== "none") return cwd;
139
140
 
141
+ // An explicit WIKI_HOME is a testable/user-selected fallback and must win
142
+ // over an unrelated personal vault found while walking parent directories.
143
+ if (process.env.WIKI_HOME) return process.env.WIKI_HOME;
144
+
140
145
  // Walk up looking for a vault sentinel (new or legacy)
141
146
  let dir = cwd;
142
147
  while (dir !== dirname(dir)) {
@@ -271,127 +276,113 @@ function nextSequentialId(dir: string, kind: string): string {
271
276
  return `${prefix}-${String(num + 1).padStart(3, "0")}`;
272
277
  }
273
278
 
274
- /** Parse a small, dependency-free YAML scalar/inline-array value. */
275
- function parseFrontmatterValue(raw: string, unquote = false): unknown {
276
- const trimmed = raw.trim();
277
- const unquoted = (value: string) => value.replace(/^(["'])(.*)\1$/, "$2").trim();
278
-
279
- if (!trimmed) return "";
280
-
281
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
282
- const inner = trimmed.slice(1, -1).trim();
283
- if (!inner) return [];
284
- return inner.split(",").map((item) => unquoted(item.trim()));
279
+ /**
280
+ * Slugify a title to a kebab-case page slug.
281
+ *
282
+ * - Folds full-width ASCII forms and ideographic spaces without changing
283
+ * unrelated Unicode compatibility characters.
284
+ * - Collapses whitespace AND existing hyphens into a single `-`.
285
+ * - Trims leading/trailing hyphens.
286
+ * - Prefixes Windows reserved device names (CON, PRN, AUX, NUL, COM1-9,
287
+ * LPT1-9, including superscript 1-3 forms) with `_`.
288
+ * - Appends `-page` to `index`/`log` slugs to avoid colliding with the
289
+ * special INDEX/LOG wiki pages.
290
+ */
291
+ export function slugify(title: string): string {
292
+ const slug =
293
+ title
294
+ .replace(/[\uFF01-\uFF5E]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
295
+ .replace(/\u3000/g, " ")
296
+ .toLowerCase()
297
+ .normalize("NFC")
298
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
299
+ .trim()
300
+ .replace(/[\s-]+/g, "-")
301
+ .replace(/^-+|-+$/g, "")
302
+ .slice(0, 80)
303
+ .replace(/[\uD800-\uDBFF]$/, "")
304
+ .replace(/^-+|-+$/g, "") || "untitled";
305
+
306
+ if (/^(?:con|prn|aux|nul|com(?:[1-9¹²³])|lpt(?:[1-9¹²³]))$/.test(slug)) {
307
+ return `_${slug}`;
285
308
  }
286
-
287
- return unquote ? unquoted(trimmed) : trimmed;
309
+ return slug === "index" || slug === "log" ? `${slug}-page` : slug;
288
310
  }
289
311
 
290
- /** Extract frontmatter from markdown. */
291
- export function parseFrontmatter(content: string): {
292
- frontmatter: Record<string, unknown>;
293
- body: string;
294
- } {
295
- const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
296
- if (!match) return { frontmatter: {}, body: content };
297
-
298
- const frontmatter: Record<string, unknown> = {};
299
- const lines = match[1].split("\n");
300
- let currentListKey: string | null = null;
301
-
302
- for (const line of lines) {
303
- const listMatch = line.match(/^\s*-\s+(.*)$/);
304
- if (listMatch && currentListKey) {
305
- const current = frontmatter[currentListKey];
306
- const list = Array.isArray(current) ? current : [];
307
- list.push(parseFrontmatterValue(listMatch[1], true));
308
- frontmatter[currentListKey] = list;
309
- continue;
310
- }
311
-
312
- const idx = line.indexOf(":");
313
- if (idx <= 0) {
314
- currentListKey = null;
315
- continue;
316
- }
312
+ /** Format date as YYYY-MM-DD. */
313
+ export function fmtDate(d = new Date()): string {
314
+ return d.toISOString().split("T")[0];
315
+ }
317
316
 
318
- const key = line.slice(0, idx).trim();
319
- const val = line.slice(idx + 1).trim();
317
+ /** Narrow exec-only interface shared by Pi and MCP. */
318
+ export type ExecApi = Pick<ExtensionAPI, "exec">;
320
319
 
321
- if (!val) {
322
- frontmatter[key] = [];
323
- currentListKey = key;
324
- } else {
325
- frontmatter[key] = parseFrontmatterValue(val);
326
- currentListKey = null;
327
- }
320
+ /** Run a shell command via pi.exec and reject failed or cancelled commands. */
321
+ export async function exec(
322
+ pi: ExecApi,
323
+ command: string,
324
+ args: string[],
325
+ options?: { signal?: AbortSignal; timeout?: number; cwd?: string },
326
+ ): Promise<{ stdout: string; stderr: string; code: number; killed: boolean }> {
327
+ const result = await pi.exec(command, args, options ?? {});
328
+ if (result.killed) throw new Error(`Command timed out or was aborted: ${command}`);
329
+ if (result.code !== 0) {
330
+ const detail = result.stderr.trim();
331
+ throw new Error(
332
+ `Command failed (${command} exited ${result.code})${detail ? `: ${detail}` : ""}`,
333
+ );
328
334
  }
329
- return { frontmatter, body: match[2] };
335
+ return result;
336
+ }
337
+
338
+ function appendPath(base: string, ...parts: string[]): string {
339
+ if (parts.length === 0) return base;
340
+ return `${base}${base.endsWith(sep) ? "" : sep}${parts.join(sep)}`;
330
341
  }
331
342
 
332
- /** Find all wiki pages recursively. */
333
- export function findWikiPages(
334
- wikiDir: string,
335
- ): Array<{ path: string; relative: string; content: string }> {
336
- const results: Array<{ path: string; relative: string; content: string }> = [];
337
-
338
- function walk(dir: string, rel: string) {
339
- if (!existsSync(dir)) return;
340
- for (const entry of readdirSync(dir)) {
341
- const full = join(dir, entry);
342
- const stat = statSync(full);
343
- if (stat.isDirectory()) {
344
- walk(full, rel ? `${rel}/${entry}` : entry);
345
- } else if (entry.endsWith(".md")) {
346
- results.push({
347
- path: full,
348
- relative: rel ? `${rel}/${entry.slice(0, -3)}` : entry.slice(0, -3),
349
- content: readFileSync(full, "utf-8"),
350
- });
343
+ function realpathWithMissingTail(path: string, seen = new Set<string>()): string {
344
+ let current = path;
345
+ const tail: string[] = [];
346
+
347
+ while (true) {
348
+ try {
349
+ const stat = lstatSync(current);
350
+ if (stat.isSymbolicLink()) {
351
+ const symlinkPath = appendPath(realpathSync.native(dirname(current)), basename(current));
352
+ if (seen.has(symlinkPath)) throw new Error(`Cannot resolve symlink cycle: ${path}`);
353
+ seen.add(symlinkPath);
354
+ const target = readlinkSync(current).toString();
355
+ const resolvedTarget = isAbsolute(target) ? target : appendPath(dirname(current), target);
356
+ return realpathWithMissingTail(appendPath(resolvedTarget, ...tail.reverse()), seen);
351
357
  }
358
+ } catch (error: unknown) {
359
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
352
360
  }
353
- }
354
361
 
355
- walk(wikiDir, "");
356
- return results;
357
- }
358
-
359
- /** Extract all [[wikilinks]] from content. */
360
- export function extractWikilinks(content: string): string[] {
361
- const links: string[] = [];
362
- const regex = /\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g;
363
- let m: RegExpExecArray | null = regex.exec(content);
364
- while (m !== null) {
365
- links.push(m[1]);
366
- m = regex.exec(content);
362
+ try {
363
+ return appendPath(realpathSync.native(current), ...tail.reverse());
364
+ } catch (error: unknown) {
365
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
366
+ const parent = dirname(current);
367
+ if (parent === current) throw error;
368
+ tail.push(basename(current));
369
+ current = parent;
370
+ }
367
371
  }
368
- return links;
369
372
  }
370
373
 
371
- /** Slugify a title. */
372
- export function slugify(title: string): string {
373
- return title
374
- .toLowerCase()
375
- .replace(/[^a-z0-9\s-]/g, "")
376
- .trim()
377
- .replace(/\s+/g, "-")
378
- .slice(0, 80);
379
- }
380
-
381
- /** Format date as YYYY-MM-DD. */
382
- export function fmtDate(d = new Date()): string {
383
- return d.toISOString().split("T")[0];
374
+ /** Return the candidate path relative to the physical root. */
375
+ export function relativePhysicalPath(rootPath: string, candidatePath: string): string {
376
+ return relative(realpathWithMissingTail(rootPath), realpathWithMissingTail(candidatePath));
384
377
  }
385
378
 
386
- /** Run a shell command via pi.exec. */
387
- export async function exec(
388
- pi: ExtensionAPI,
389
- command: string,
390
- args: string[],
391
- options?: { signal?: AbortSignal; timeout?: number; cwd?: string },
392
- ): Promise<{ stdout: string; stderr: string; code: number }> {
393
- const result = await pi.exec(command, args, options ?? {});
394
- return result;
379
+ /** Check physical containment, resolving existing symlink ancestors. */
380
+ export function isPathWithin(rootPath: string, candidatePath: string): boolean {
381
+ const relation = relativePhysicalPath(rootPath, candidatePath);
382
+ return (
383
+ relation === "" ||
384
+ (!isAbsolute(relation) && relation !== ".." && !relation.startsWith(`..${sep}`))
385
+ );
395
386
  }
396
387
 
397
388
  /** Check if a path is inside a protected directory. */
@@ -399,22 +390,22 @@ export function isProtectedPath(
399
390
  absPath: string,
400
391
  paths: VaultPaths,
401
392
  ): { protected: boolean; reason?: string } {
402
- const rawPath = resolve(paths.raw);
403
- const metaPath = resolve(paths.meta);
404
- const norm = resolve(absPath);
405
-
406
- if (norm.startsWith(`${rawPath}/`) || norm === rawPath) {
407
- return {
408
- protected: true,
409
- reason: "Raw sources are immutable. Use wiki_capture_source to add sources.",
410
- };
411
- }
412
- if (norm.startsWith(`${metaPath}/`) || norm === metaPath) {
413
- return {
414
- protected: true,
415
- reason: "Metadata is auto-generated. Use wiki_rebuild_meta or wiki_log_event instead.",
416
- };
417
- }
393
+ try {
394
+ if (isPathWithin(paths.raw, absPath)) {
395
+ return {
396
+ protected: true,
397
+ reason: "Raw sources are immutable. Use wiki_capture_source to add sources.",
398
+ };
399
+ }
400
+ if (isPathWithin(paths.meta, absPath)) {
401
+ return {
402
+ protected: true,
403
+ reason: "Metadata is auto-generated. Use wiki_rebuild_meta or wiki_log_event instead.",
404
+ };
405
+ }
418
406
 
419
- return { protected: false };
407
+ return { protected: false };
408
+ } catch {
409
+ return { protected: true, reason: "Cannot safely resolve mutation path." };
410
+ }
420
411
  }