@zosmaai/pi-llm-wiki 0.10.9 → 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 +31 -10
  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 +26 -18
  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 +404 -342
  64. package/extensions/llm-wiki/lib/trajectory.ts +15 -1
  65. package/extensions/llm-wiki/lib/utils.ts +120 -131
  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,113 +276,37 @@ 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()));
285
- }
286
-
287
- return unquote ? unquoted(trimmed) : trimmed;
288
- }
289
-
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
- }
317
-
318
- const key = line.slice(0, idx).trim();
319
- const val = line.slice(idx + 1).trim();
320
-
321
- if (!val) {
322
- frontmatter[key] = [];
323
- currentListKey = key;
324
- } else {
325
- frontmatter[key] = parseFrontmatterValue(val);
326
- currentListKey = null;
327
- }
328
- }
329
- return { frontmatter, body: match[2] };
330
- }
331
-
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
- });
351
- }
352
- }
353
- }
354
-
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);
367
- }
368
- return links;
369
- }
370
-
371
- /** Slugify a title. */
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
+ */
372
291
  export function slugify(title: string): string {
373
- return (
292
+ const slug =
374
293
  title
375
- .toLocaleLowerCase()
294
+ .replace(/[\uFF01-\uFF5E]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
295
+ .replace(/\u3000/g, " ")
296
+ .toLowerCase()
297
+ .normalize("NFC")
376
298
  .replace(/[^\p{L}\p{N}\s-]/gu, "")
377
299
  .trim()
378
- .replace(/\s+/g, "-")
379
- .slice(0, 80) || "untitled"
380
- );
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}`;
308
+ }
309
+ return slug === "index" || slug === "log" ? `${slug}-page` : slug;
381
310
  }
382
311
 
383
312
  /** Format date as YYYY-MM-DD. */
@@ -385,38 +314,98 @@ export function fmtDate(d = new Date()): string {
385
314
  return d.toISOString().split("T")[0];
386
315
  }
387
316
 
388
- /** Run a shell command via pi.exec. */
317
+ /** Narrow exec-only interface shared by Pi and MCP. */
318
+ export type ExecApi = Pick<ExtensionAPI, "exec">;
319
+
320
+ /** Run a shell command via pi.exec and reject failed or cancelled commands. */
389
321
  export async function exec(
390
- pi: ExtensionAPI,
322
+ pi: ExecApi,
391
323
  command: string,
392
324
  args: string[],
393
325
  options?: { signal?: AbortSignal; timeout?: number; cwd?: string },
394
- ): Promise<{ stdout: string; stderr: string; code: number }> {
326
+ ): Promise<{ stdout: string; stderr: string; code: number; killed: boolean }> {
395
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
+ );
334
+ }
396
335
  return result;
397
336
  }
398
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)}`;
341
+ }
342
+
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);
357
+ }
358
+ } catch (error: unknown) {
359
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
360
+ }
361
+
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
+ }
371
+ }
372
+ }
373
+
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));
377
+ }
378
+
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
+ );
386
+ }
387
+
399
388
  /** Check if a path is inside a protected directory. */
400
389
  export function isProtectedPath(
401
390
  absPath: string,
402
391
  paths: VaultPaths,
403
392
  ): { protected: boolean; reason?: string } {
404
- const rawPath = resolve(paths.raw);
405
- const metaPath = resolve(paths.meta);
406
- const norm = resolve(absPath);
407
-
408
- if (norm.startsWith(`${rawPath}/`) || norm === rawPath) {
409
- return {
410
- protected: true,
411
- reason: "Raw sources are immutable. Use wiki_capture_source to add sources.",
412
- };
413
- }
414
- if (norm.startsWith(`${metaPath}/`) || norm === metaPath) {
415
- return {
416
- protected: true,
417
- reason: "Metadata is auto-generated. Use wiki_rebuild_meta or wiki_log_event instead.",
418
- };
419
- }
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
+ }
420
406
 
421
- return { protected: false };
407
+ return { protected: false };
408
+ } catch {
409
+ return { protected: true, reason: "Cannot safely resolve mutation path." };
410
+ }
422
411
  }