@zosmaai/pi-llm-wiki 0.10.6 → 0.10.9

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/README.md CHANGED
@@ -447,6 +447,20 @@ Thanks to everyone who has contributed! This list is regenerated automatically b
447
447
  <sub><b>Daniel Naab</b></sub>
448
448
  </a>
449
449
  </td>
450
+ <td align="center">
451
+ <a href="https://github.com/deestax">
452
+ <img src="https://avatars.githubusercontent.com/u/152369481?v=4" width="64;" alt="deestax"/>
453
+ <br />
454
+ <sub><b>Superdao</b></sub>
455
+ </a>
456
+ </td>
457
+ <td align="center">
458
+ <a href="https://github.com/xcsf">
459
+ <img src="https://avatars.githubusercontent.com/u/43439835?v=4" width="64;" alt="xcsf"/>
460
+ <br />
461
+ <sub><b>xcsf</b></sub>
462
+ </a>
463
+ </td>
450
464
  <td align="center">
451
465
  <a href="https://github.com/mystery4f">
452
466
  <img src="https://avatars.githubusercontent.com/u/40482524?v=4" width="64;" alt="mystery4f"/>
@@ -1,3 +1,4 @@
1
+ import { resolve, sep } from "node:path";
1
2
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
2
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
4
  import { scheduleReindex } from "./indexing.js";
@@ -11,6 +12,175 @@ import { isProtectedPath, resolveVaultPaths } from "./utils.js";
11
12
 
12
13
  let pendingRebuild = false;
13
14
 
15
+ const APPLY_PATCH_PATH_NOISE =
16
+ /^\*{0,3}\s*(?:(?:update|add|delete|move)[^A-Za-z0-9]*(?:file|to)?[^A-Za-z0-9]*:)?\s*\*{0,3}\s*/i;
17
+ const PATCH_INPUT_KEYS: Record<string, true> = { input: true, _input: true, patch: true };
18
+ const DESTINATION_KEYS: Record<string, true> = {
19
+ rename: true,
20
+ move: true,
21
+ dest: true,
22
+ destination: true,
23
+ newPath: true,
24
+ };
25
+
26
+ interface MutationScan {
27
+ paths: string[];
28
+ complete: boolean;
29
+ }
30
+
31
+ function normalizeMutationPath(target: string): string | undefined {
32
+ const trimmed = target.trim();
33
+ if (!trimmed) return undefined;
34
+ const first = trimmed[0];
35
+ const last = trimmed[trimmed.length - 1];
36
+ const quoted = first === '"' || first === "'";
37
+ if (quoted !== (last === '"' || last === "'") || (quoted && first !== last)) {
38
+ return undefined;
39
+ }
40
+ const unquoted = quoted ? trimmed.slice(1, -1) : trimmed;
41
+ return unquoted.replace(APPLY_PATCH_PATH_NOISE, "") || undefined;
42
+ }
43
+
44
+ function parsePatchHeader(line: string): string | undefined {
45
+ const trimmed = line.replace(/\r$/, "").trimEnd();
46
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
47
+
48
+ const body = trimmed.slice(1, -1).trim();
49
+ const tag = /#[0-9A-Fa-f]{4}\s*$/.exec(body);
50
+ const rawTarget = tag ? body.slice(0, tag.index) : body.replace(/\s+$/, "");
51
+ if (!rawTarget || rawTarget.includes("#")) return undefined;
52
+
53
+ return normalizeMutationPath(rawTarget);
54
+ }
55
+
56
+ function parseMoveDestination(line: string): string | undefined {
57
+ const rawDestination = line.trim().slice(2).trim();
58
+ if (!rawDestination) return undefined;
59
+ const quote = rawDestination[0];
60
+ if (quote !== '"' && quote !== "'") return normalizeMutationPath(rawDestination);
61
+
62
+ let cursor = 1;
63
+ while (cursor < rawDestination.length) {
64
+ if (rawDestination[cursor] === "\\" && cursor + 1 < rawDestination.length) {
65
+ cursor += 2;
66
+ continue;
67
+ }
68
+ if (rawDestination[cursor] === quote) {
69
+ return cursor === rawDestination.length - 1
70
+ ? normalizeMutationPath(rawDestination)
71
+ : undefined;
72
+ }
73
+ cursor++;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ function scanPatchString(input: string): MutationScan {
79
+ const paths: string[] = [];
80
+ let sawHeader = false;
81
+ let sectionHasMove = false;
82
+ let complete = true;
83
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
84
+
85
+ for (const line of stripped.split("\n")) {
86
+ const trimmed = line.replace(/\r$/, "").trim();
87
+ if (trimmed.startsWith("[")) {
88
+ sawHeader = true;
89
+ sectionHasMove = false;
90
+ const path = parsePatchHeader(line);
91
+ if (path) paths.push(path);
92
+ else complete = false;
93
+ continue;
94
+ }
95
+ if (!/^MV(?:\s|$)/.test(trimmed)) continue;
96
+ const destination = parseMoveDestination(trimmed);
97
+ if (!sawHeader || sectionHasMove || !destination) complete = false;
98
+ else {
99
+ paths.push(destination);
100
+ sectionHasMove = true;
101
+ }
102
+ }
103
+
104
+ return { paths, complete: sawHeader && complete };
105
+ }
106
+
107
+ function mergeMutationScans(target: MutationScan, source: MutationScan): void {
108
+ target.paths.push(...source.paths);
109
+ target.complete &&= source.complete;
110
+ }
111
+
112
+ function addMutationPath(scan: MutationScan, target: string): void {
113
+ const path = normalizeMutationPath(target);
114
+ if (path) scan.paths.push(path);
115
+ else scan.complete = false;
116
+ }
117
+
118
+ function collectMutationPaths(
119
+ input: unknown,
120
+ seen: WeakSet<object>,
121
+ stringsArePatches = false,
122
+ ): MutationScan {
123
+ if (typeof input === "string") {
124
+ return stringsArePatches ? scanPatchString(input) : { paths: [], complete: true };
125
+ }
126
+ if (!input || typeof input !== "object" || seen.has(input)) {
127
+ return { paths: [], complete: true };
128
+ }
129
+
130
+ seen.add(input);
131
+ const scan: MutationScan = { paths: [], complete: true };
132
+ if (Array.isArray(input)) {
133
+ for (const value of input) {
134
+ mergeMutationScans(scan, collectMutationPaths(value, seen, stringsArePatches));
135
+ }
136
+ return scan;
137
+ }
138
+
139
+ const record = input as Record<string, unknown>;
140
+ if (typeof record.path === "string" && record.path.length > 0) {
141
+ addMutationPath(scan, record.path);
142
+ }
143
+ const eventPaths = Array.isArray(record.paths) ? record.paths : [record.paths];
144
+ for (const path of eventPaths) {
145
+ if (typeof path === "string" && path.length > 0) addMutationPath(scan, path);
146
+ }
147
+
148
+ for (const [key, value] of Object.entries(record)) {
149
+ if (key === "path" || key === "paths") continue;
150
+ if (DESTINATION_KEYS[key] === true) {
151
+ if (typeof value === "string" && value.length > 0) addMutationPath(scan, value);
152
+ else scan.complete = false;
153
+ continue;
154
+ }
155
+ const childStringsArePatches = stringsArePatches || PATCH_INPUT_KEYS[key] === true;
156
+ if (typeof value !== "string" && (!value || typeof value !== "object")) continue;
157
+ mergeMutationScans(scan, collectMutationPaths(value, seen, childStringsArePatches));
158
+ }
159
+ return scan;
160
+ }
161
+
162
+ function inspectMutationPaths(input: unknown): MutationScan {
163
+ const stringsArePatches = typeof input === "string" || Array.isArray(input);
164
+ const scan = collectMutationPaths(input, new WeakSet(), stringsArePatches);
165
+ return { paths: [...new Set(scan.paths)], complete: scan.complete };
166
+ }
167
+
168
+ /** Return every file path targeted by a write or patch-shaped edit input. */
169
+ export function extractMutationPaths(input: unknown): string[] {
170
+ return inspectMutationPaths(input).paths;
171
+ }
172
+
173
+ /** True when a write or patch-shaped edit targets a page in the wiki directory. */
174
+ export function hasWikiMutation(input: unknown, wikiPath: string): boolean {
175
+ const resolvedWikiPath = resolve(wikiPath);
176
+ return extractMutationPaths(input).some((path) => {
177
+ const resolvedPath = resolve(path);
178
+ return (
179
+ resolvedPath === resolvedWikiPath || resolvedPath.startsWith(`${resolvedWikiPath}${sep}`)
180
+ );
181
+ });
182
+ }
183
+
14
184
  /** Install guardrails on the extension API. */
15
185
  export function installGuardrails(pi: ExtensionAPI, runtime?: Runtime): void {
16
186
  // Block direct edits to raw/ and meta/
@@ -25,11 +195,18 @@ export function installGuardrails(pi: ExtensionAPI, runtime?: Runtime): void {
25
195
  }
26
196
 
27
197
  if (isToolCallEventType("edit", event)) {
28
- const path = event.input.path as string;
198
+ const mutation = inspectMutationPaths(event.input);
199
+ const targetPaths = mutation.paths;
200
+ if (!mutation.complete || targetPaths.length === 0) {
201
+ return { block: true, reason: "Cannot determine the files targeted by this edit." };
202
+ }
203
+
29
204
  const paths = resolveVaultPaths(process.cwd());
30
- const check = isProtectedPath(path, paths);
31
- if (check.protected) {
32
- return { block: true, reason: check.reason };
205
+ for (const path of targetPaths) {
206
+ const check = isProtectedPath(path, paths);
207
+ if (check.protected) {
208
+ return { block: true, reason: check.reason };
209
+ }
33
210
  }
34
211
  }
35
212
  });
@@ -37,10 +214,8 @@ export function installGuardrails(pi: ExtensionAPI, runtime?: Runtime): void {
37
214
  // Track wiki edits for auto-rebuild
38
215
  pi.on("tool_result", async (event) => {
39
216
  if (event.toolName === "write" || event.toolName === "edit") {
40
- const path = event.input.path as string;
41
217
  const paths = resolveVaultPaths(process.cwd());
42
- const wikiPath = `${paths.wiki}/`;
43
- if (path?.startsWith(wikiPath)) {
218
+ if (hasWikiMutation(event.input, paths.wiki)) {
44
219
  pendingRebuild = true;
45
220
  }
46
221
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, relative } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
5
  import { launchEmbedPages, reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
@@ -26,6 +26,7 @@ import {
26
26
  getVaultPaths,
27
27
  readJson,
28
28
  resolveVaultPaths,
29
+ slugify,
29
30
  writeJson,
30
31
  } from "./utils.js";
31
32
 
@@ -390,7 +391,11 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
390
391
  const manifestPath = join(paths.rawSources, id, "manifest.json");
391
392
  const extracted = existsSync(extractedPath) ? readFileSync(extractedPath, "utf-8") : "";
392
393
  const manifest = readJson<Record<string, unknown>>(manifestPath, {});
393
- return { id, extracted, manifest };
394
+ // Vault-relative path used in tool messages so the read tool can open
395
+ // the file from the vault root (fix #101: agent previously got
396
+ // "raw/sources/..." and failed on new-layout vaults).
397
+ const relRaw = relative(paths.root, paths.rawSources);
398
+ return { id, extracted, manifest, relRaw };
394
399
  });
395
400
 
396
401
  // ── Background synthesis (issue #65) ──────────────────
@@ -474,7 +479,7 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
474
479
  [
475
480
  `- **${s.id}**: ${s.manifest.title || s.id}`,
476
481
  ` - Extracted: ${s.extracted.length} chars`,
477
- ` - Read: \`raw/sources/${s.id}/extracted.md\``,
482
+ ` - Read: \`${s.relRaw}/${s.id}/extracted.md\``,
478
483
  ].join("\n"),
479
484
  ),
480
485
  "",
@@ -540,12 +545,7 @@ export function registerWikiEnsurePage(pi: ExtensionAPI, runtime?: Runtime): voi
540
545
  | "requirement"
541
546
  | "skill"
542
547
  | "case";
543
- const slug = params.title
544
- .toLowerCase()
545
- .replace(/[^a-z0-9\s-]/g, "")
546
- .trim()
547
- .replace(/\s+/g, "-")
548
- .slice(0, 80);
548
+ const slug = slugify(params.title);
549
549
 
550
550
  const folderMap: Record<string, string> = {
551
551
  entity: "entities",
@@ -370,12 +370,14 @@ export function extractWikilinks(content: string): string[] {
370
370
 
371
371
  /** Slugify a title. */
372
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);
373
+ return (
374
+ title
375
+ .toLocaleLowerCase()
376
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
377
+ .trim()
378
+ .replace(/\s+/g, "-")
379
+ .slice(0, 80) || "untitled"
380
+ );
379
381
  }
380
382
 
381
383
  /** Format date as YYYY-MM-DD. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.10.6",
3
+ "version": "0.10.9",
4
4
  "description": "Self-maintaining LLM Wiki for Pi — Karpathy-pattern knowledge base with immutable source capture, automated ingestion, search, linting, and Obsidian-compatible vault. auto-updating personal & company wiki.",
5
5
  "keywords": [
6
6
  "pi",
@@ -19,7 +19,7 @@ $ARGUMENTS
19
19
  2. If the tool reports "All sources ingested", inform the user and stop.
20
20
  3. **If the tool reports it is ingesting in the background**, the synthesis sub-agent is handling those sources on the configured task model. Do NOT synthesize them yourself — just report which sources were dispatched and stop. (You'll be notified as each completes.)
21
21
  4. **Otherwise** (the tool returned extracted content — background unavailable or `background=false`), for each source in the returned batch:
22
- a. Read the extracted text from `raw/sources/<SOURCE_ID>/extracted.md`
22
+ a. Read the extracted text from `.llm-wiki/raw/sources/<SOURCE_ID>/extracted.md`
23
23
  b. Update the skeleton source page in `wiki/sources/` with a proper summary, key entities, and concepts
24
24
  c. Use `wiki_ensure_page(type=entity, title=<name>)` for each new entity (people, orgs, tools, products)
25
25
  d. Use `wiki_ensure_page(type=concept, title=<name>)` for each new concept (ideas, patterns, frameworks)