@zosmaai/pi-llm-wiki 0.5.0 → 0.6.1

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.
@@ -0,0 +1,207 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import type { Registry } from "./metadata.js";
6
+ import { type VaultPaths, readJson, resolveVaultPaths } from "./utils.js";
7
+
8
+ // ─── Public API ────────────────────────────────────────
9
+
10
+ export interface RecallResult {
11
+ /** Page identifier (folder-qualified, e.g. "concepts/rag") */
12
+ id: string;
13
+ /** Page title */
14
+ title: string;
15
+ /** Page type: source, entity, concept, synthesis, analysis */
16
+ type: string;
17
+ /** First N chars of page content for context */
18
+ preview: string;
19
+ /** Relative path from wiki root */
20
+ path: string;
21
+ }
22
+
23
+ /**
24
+ * Search the wiki registry for pages matching a query.
25
+ * Returns up to `maxResults` matches, each with a content preview.
26
+ */
27
+ export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): RecallResult[] {
28
+ const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
29
+ version: "1.0",
30
+ last_updated: "",
31
+ pages: {},
32
+ });
33
+
34
+ const q = query.toLowerCase();
35
+ const terms = q
36
+ .split(/\s+/)
37
+ .filter((t) => t.length > 2)
38
+ .slice(0, 10);
39
+
40
+ if (terms.length === 0) return [];
41
+
42
+ type Scored = { id: string; entry: Registry["pages"][string]; score: number };
43
+ const scored: Scored[] = [];
44
+
45
+ for (const [id, entry] of Object.entries(registry.pages)) {
46
+ let score = 0;
47
+ const title = String(entry.title || "").toLowerCase();
48
+ const type = String(entry.type || "").toLowerCase();
49
+
50
+ for (const term of terms) {
51
+ if (id.toLowerCase().includes(term)) score += 3;
52
+ if (title.includes(term)) score += 4;
53
+ if (type.includes(term)) score += 1;
54
+ }
55
+
56
+ // Boost if query terms appear in tags/categories
57
+ const tags = String(entry.tags || entry.category || entry.domain || "");
58
+ for (const term of terms) {
59
+ if (tags.toLowerCase().includes(term)) score += 2;
60
+ }
61
+
62
+ if (score > 0) {
63
+ scored.push({ id, entry, score });
64
+ }
65
+ }
66
+
67
+ scored.sort((a, b) => b.score - a.score);
68
+ const top = scored.slice(0, maxResults);
69
+
70
+ return top.map(({ id, entry }) => {
71
+ // Try to read page content for preview
72
+ let preview = "";
73
+ const pagePath = join(paths.wiki, `${id}.md`);
74
+ if (existsSync(pagePath)) {
75
+ const content = readFileSync(pagePath, "utf-8");
76
+ // Strip frontmatter
77
+ const body = content.replace(/^---[\s\S]*?---\n/, "").trim();
78
+ preview = body.slice(0, 200).replace(/\n/g, " ");
79
+ }
80
+
81
+ return {
82
+ id,
83
+ title: String(entry.title || id),
84
+ type: String(entry.type || "page"),
85
+ preview,
86
+ path: pagePath,
87
+ };
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Format recall results as a compact system-prompt section.
93
+ */
94
+ export function formatRecallContext(results: RecallResult[]): string {
95
+ if (results.length === 0) return "";
96
+
97
+ const lines: string[] = [
98
+ "## Relevant Wiki Knowledge",
99
+ "",
100
+ `_${results.length} page(s) matched your query — reviewed automatically by LLM Wiki._`,
101
+ "",
102
+ ];
103
+
104
+ for (const r of results) {
105
+ lines.push(`- **[[${r.id}]]** — *${r.type}* — ${r.title}`);
106
+ if (r.preview) {
107
+ // Truncate preview to one line
108
+ const preview = r.preview.length > 120 ? `${r.preview.slice(0, 120)}…` : r.preview;
109
+ lines.push(` ${preview}`);
110
+ }
111
+ lines.push("");
112
+ }
113
+
114
+ lines.push(
115
+ "Use `read` to view full pages. Add new findings via wiki_ensure_page or wiki_retro.",
116
+ "",
117
+ );
118
+
119
+ return lines.join("\n");
120
+ }
121
+
122
+ // ─── Tool Registration ──────────────────────────────────
123
+
124
+ /**
125
+ * Register the `wiki_recall` tool.
126
+ * The model can call this explicitly to search the wiki.
127
+ * It is also called automatically via before_agent_start hook.
128
+ */
129
+ export function registerWikiRecall(pi: ExtensionAPI): void {
130
+ pi.registerTool({
131
+ name: "wiki_recall",
132
+ label: "Wiki Recall",
133
+ description:
134
+ "Search the wiki for pages relevant to a query. " +
135
+ "Returns matching page IDs, titles, types, and content previews. " +
136
+ "Called automatically at session start — use explicitly to dig deeper.",
137
+ promptSnippet: "Recall wiki knowledge relevant to the current task",
138
+ promptGuidelines: [
139
+ "Use wiki_recall at the START of every task to find relevant wiki knowledge.",
140
+ "The extension auto-calls wiki_recall — but calling it explicitly with specific terms gets better results.",
141
+ ],
142
+ parameters: Type.Object({
143
+ query: Type.String({
144
+ description: "Search query — use the user's full request or key terms",
145
+ }),
146
+ max_results: Type.Optional(
147
+ Type.Number({ description: "Max results (default: 5, max: 10)", default: 5 }),
148
+ ),
149
+ }),
150
+ async execute(_toolCallId, params) {
151
+ const paths = resolveVaultPaths(process.cwd());
152
+
153
+ if (!existsSync(join(paths.dotWiki, "config.json"))) {
154
+ return {
155
+ content: [
156
+ {
157
+ type: "text",
158
+ text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
159
+ },
160
+ ],
161
+ details: { error: "no_vault" } as Record<string, unknown>,
162
+ isError: true,
163
+ };
164
+ }
165
+
166
+ const maxResults = Math.min(params.max_results ?? 5, 10);
167
+ const results = searchWiki(paths, params.query, maxResults);
168
+
169
+ if (results.length === 0) {
170
+ return {
171
+ content: [
172
+ {
173
+ type: "text",
174
+ text: `No wiki pages found matching "${params.query}". Use wiki_search for broader results.`,
175
+ },
176
+ ],
177
+ details: { query: params.query, matches: [] } as Record<string, unknown>,
178
+ };
179
+ }
180
+
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: [
186
+ `🧠 **${results.length} wiki page(s) relevant** to "${params.query}":`,
187
+ "",
188
+ ...results.map(
189
+ (r) =>
190
+ `- [[${r.id}]] — *${r.type}* — ${r.title}${
191
+ r.preview ? `\n > ${r.preview.slice(0, 150)}` : ""
192
+ }`,
193
+ ),
194
+ "",
195
+ "Use `read` on any page for full content.",
196
+ "Use `wiki_retro` to save new insights from this task.",
197
+ ].join("\n"),
198
+ },
199
+ ],
200
+ details: { query: params.query, matches: results.map((r) => r.id) } as Record<
201
+ string,
202
+ unknown
203
+ >,
204
+ };
205
+ },
206
+ });
207
+ }
@@ -0,0 +1,196 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { appendEvent, rebuildMetadataLight } from "./metadata.js";
6
+ import { type VaultPaths, fmtDate, nextSourceId, resolveVaultPaths } from "./utils.js";
7
+
8
+ // ─── Public API ────────────────────────────────────────
9
+
10
+ export interface RetroResult {
11
+ sourceId: string;
12
+ packetPath: string;
13
+ sourcePagePath: string;
14
+ }
15
+
16
+ /**
17
+ * Save an atomic insight into the wiki as a source packet + source page.
18
+ * Returns the source ID, packet path, and source page path.
19
+ */
20
+ export function saveInsight(
21
+ paths: VaultPaths,
22
+ slug: string,
23
+ title: string,
24
+ body: string,
25
+ category?: string,
26
+ ): RetroResult {
27
+ const sourceId = nextSourceId(paths);
28
+ const packetPath = join(paths.rawSources, sourceId);
29
+ mkdirSync(packetPath, { recursive: true });
30
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
31
+
32
+ const today = fmtDate();
33
+
34
+ // Write manifest
35
+ const manifest = {
36
+ id: sourceId,
37
+ title,
38
+ slug,
39
+ category: category || "uncategorized",
40
+ captured: today,
41
+ format: "insight",
42
+ packet_version: "1.0",
43
+ };
44
+ writeFileSync(
45
+ join(packetPath, "manifest.json"),
46
+ `${JSON.stringify(manifest, null, 2)}\n`,
47
+ "utf-8",
48
+ );
49
+
50
+ // Write extracted text (the insight body in markdown)
51
+ const extracted = [
52
+ `# ${title}`,
53
+ "",
54
+ body,
55
+ "",
56
+ "---",
57
+ `*Captured: ${today}*`,
58
+ category ? `*Category: ${category}*` : "",
59
+ ]
60
+ .filter(Boolean)
61
+ .join("\n");
62
+ writeFileSync(join(packetPath, "extracted.md"), extracted, "utf-8");
63
+
64
+ // Create source page
65
+ const sourcePageDir = join(paths.wiki, "sources");
66
+ mkdirSync(sourcePageDir, { recursive: true });
67
+ const sourcePagePath = join(sourcePageDir, `${sourceId}.md`);
68
+
69
+ const tagLine = category ? `category: ${category}` : "";
70
+ const sourcePageContent = [
71
+ "---",
72
+ "type: source",
73
+ `title: "${title}"`,
74
+ `source_id: ${sourceId}`,
75
+ "status: insight",
76
+ `created: ${today}`,
77
+ `updated: ${today}`,
78
+ tagLine,
79
+ "---",
80
+ "",
81
+ `# ${title}`,
82
+ "",
83
+ body,
84
+ "",
85
+ "## Source",
86
+ "",
87
+ `- **Packet:** \`${packetPath}\``,
88
+ `- **Captured:** ${today}`,
89
+ category ? `- **Category:** ${category}` : "",
90
+ "",
91
+ "## Related",
92
+ "",
93
+ "_(Add [[wikilinks]] to related pages)_",
94
+ "",
95
+ ]
96
+ .filter((l) => l !== "")
97
+ .join("\n");
98
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
99
+
100
+ // Log event
101
+ appendEvent(paths, {
102
+ kind: "retro",
103
+ source_id: sourceId,
104
+ title,
105
+ slug,
106
+ category: category || "uncategorized",
107
+ });
108
+
109
+ // Rebuild metadata
110
+ rebuildMetadataLight(paths);
111
+
112
+ return { sourceId, packetPath, sourcePagePath };
113
+ }
114
+
115
+ // ─── Tool Registration ──────────────────────────────────
116
+
117
+ /**
118
+ * Register the `wiki_retro` tool.
119
+ * The model calls this to save an atomic insight from a completed task.
120
+ * Inspired by the memex_retro pattern.
121
+ */
122
+ export function registerWikiRetro(pi: ExtensionAPI): void {
123
+ pi.registerTool({
124
+ name: "wiki_retro",
125
+ label: "Wiki Retro",
126
+ description:
127
+ "Save an atomic insight from a completed task into the wiki. " +
128
+ "Creates a source packet and source page. The insight will be " +
129
+ "surfaced automatically by wiki_recall in future sessions.",
130
+ promptSnippet: "Save atomic insights from completed tasks into the wiki",
131
+ promptGuidelines: [
132
+ "Use wiki_retro at the END of every meaningful task to save what you learned.",
133
+ "Write atomic insights — one insight per call. Use multiple calls for multiple insights.",
134
+ "The insight will be auto-surfaced by wiki_recall in future sessions.",
135
+ ],
136
+ parameters: Type.Object({
137
+ slug: Type.String({
138
+ description:
139
+ "Unique kebab-case identifier (e.g. 'jwt-revocation-pattern'). Used for lookups.",
140
+ }),
141
+ title: Type.String({
142
+ description: "Short descriptive title (60 chars max). Noun phrase, not a sentence.",
143
+ }),
144
+ body: Type.String({
145
+ description:
146
+ "Markdown body with [[wikilinks]] to related wiki pages. Explain what was learned.",
147
+ }),
148
+ category: Type.Optional(
149
+ Type.String({
150
+ description: "Optional category (e.g. frontend, architecture, devops, bugfix, design)",
151
+ }),
152
+ ),
153
+ }),
154
+ async execute(_toolCallId, params) {
155
+ const paths = resolveVaultPaths(process.cwd());
156
+
157
+ if (!existsSync(join(paths.dotWiki, "config.json"))) {
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
163
+ },
164
+ ],
165
+ details: { error: "no_vault" } as Record<string, unknown>,
166
+ isError: true,
167
+ };
168
+ }
169
+
170
+ const result = saveInsight(paths, params.slug, params.title, params.body, params.category);
171
+
172
+ return {
173
+ content: [
174
+ {
175
+ type: "text",
176
+ text: [
177
+ `🧠 **Insight saved**: ${params.title}`,
178
+ "",
179
+ `- Source: \`${result.sourceId}\``,
180
+ `- Packet: \`${result.packetPath}\``,
181
+ `- Page: \`${result.sourcePagePath}\``,
182
+ "",
183
+ "This insight will be auto-surfaced by wiki_recall in future sessions.",
184
+ ].join("\n"),
185
+ },
186
+ ],
187
+ details: {
188
+ sourceId: result.sourceId,
189
+ slug: params.slug,
190
+ title: params.title,
191
+ category: params.category || null,
192
+ } as Record<string, unknown>,
193
+ };
194
+ },
195
+ });
196
+ }
@@ -13,13 +13,14 @@ import {
13
13
  import { captureFile, captureText, captureUrl } from "./source-packet.js";
14
14
  import {
15
15
  type VaultPaths,
16
+ detectVaultFormat,
16
17
  ensureVaultStructure,
17
18
  extractWikilinks,
18
19
  findWikiPages,
19
20
  fmtDate,
20
21
  getVaultPaths,
21
22
  readJson,
22
- resolveVaultRoot,
23
+ resolveVaultPaths,
23
24
  writeJson,
24
25
  } from "./utils.js";
25
26
 
@@ -28,12 +29,11 @@ import {
28
29
  */
29
30
 
30
31
  function getPaths(cwd = process.cwd()): VaultPaths {
31
- const root = resolveVaultRoot(cwd);
32
- return getVaultPaths(root);
32
+ return resolveVaultPaths(cwd);
33
33
  }
34
34
 
35
35
  function requireVault(paths: VaultPaths): { ok: true } | { ok: false; reason: string } {
36
- if (!existsSync(join(paths.root, ".wiki", "config.json"))) {
36
+ if (detectVaultFormat(paths.root) === "none") {
37
37
  return { ok: false, reason: `No wiki found at ${paths.root}. Run wiki_bootstrap first.` };
38
38
  }
39
39
  return { ok: true };
@@ -83,7 +83,7 @@ export function registerWikiBootstrap(pi: ExtensionAPI): void {
83
83
  "| raw/** | extension | immutable after capture |",
84
84
  "| wiki/** | model + user | editable knowledge pages |",
85
85
  "| meta/* | extension | auto-generated |",
86
- "| .wiki/* | human + explicit request | operating rules |",
86
+ "| . | human + explicit request | operating rules |",
87
87
  "",
88
88
  "## Source Packet Format",
89
89
  "",
@@ -109,7 +109,7 @@ export function registerWikiBootstrap(pi: ExtensionAPI): void {
109
109
  "- Citation: [[sources/SRC-YYYY-MM-DD-NNN]]",
110
110
  "",
111
111
  ].join("\n");
112
- writeFileSync(join(paths.root, "WIKI_SCHEMA.md"), schema, "utf-8");
112
+ writeFileSync(join(paths.dotWiki, "WIKI_SCHEMA.md"), schema, "utf-8");
113
113
 
114
114
  rebuildMetadata(paths);
115
115
  appendEvent(paths, { kind: "bootstrap", topic: params.topic, mode });
@@ -122,10 +122,11 @@ export function registerWikiBootstrap(pi: ExtensionAPI): void {
122
122
  `✅ Wiki bootstrapped at \`${paths.root}\``,
123
123
  "",
124
124
  "**Structure:**",
125
- "- raw/sources/ — immutable source packets",
126
- "- wiki/ — editable knowledge pages",
127
- "- meta/ — auto-generated metadata",
128
- "- .wiki/ — config and templates",
125
+ "- .llm-wiki/raw/sources/ — immutable source packets",
126
+ "- .llm-wiki/wiki/ — editable knowledge pages",
127
+ "- .llm-wiki/meta/ — auto-generated metadata",
128
+ "- .llm-wiki/ — config and templates",
129
+ "- .llm-wiki/WIKI_SCHEMA.md — operating rules",
129
130
  "",
130
131
  "Next: Use wiki_capture_source to add your first source.",
131
132
  ].join("\n"),
@@ -17,24 +17,53 @@ export interface VaultPaths {
17
17
  discoveries: string;
18
18
  }
19
19
 
20
+ /** Detect whether a vault root uses new (.llm-wiki) or legacy (.wiki) layout. */
21
+ export type VaultFormat = "new" | "legacy" | "none";
22
+
23
+ /**
24
+ * Detect the vault format at a given directory.
25
+ * Returns "new" if .llm-wiki/config.json exists,
26
+ * "legacy" if .wiki/config.json exists,
27
+ * "none" otherwise.
28
+ */
29
+ export function detectVaultFormat(dir: string): VaultFormat {
30
+ if (existsSync(join(dir, ".llm-wiki", "config.json"))) return "new";
31
+ if (existsSync(join(dir, ".wiki", "config.json"))) return "legacy";
32
+ return "none";
33
+ }
34
+
20
35
  /** Resolve vault root from cwd or find nearest wiki root. */
21
36
  export function resolveVaultRoot(cwd: string): string {
22
- // If cwd has .wiki/config.json, it's the root
23
- if (existsSync(join(cwd, ".wiki", "config.json"))) return cwd;
37
+ // Check for any vault format at cwd
38
+ if (detectVaultFormat(cwd) !== "none") return cwd;
24
39
 
25
- // Walk up looking for .wiki/config.json
40
+ // Walk up looking for a vault sentinel (new or legacy)
26
41
  let dir = cwd;
27
42
  while (dir !== dirname(dir)) {
28
- if (existsSync(join(dir, ".wiki", "config.json"))) return dir;
29
43
  dir = dirname(dir);
44
+ if (detectVaultFormat(dir) !== "none") return dir;
30
45
  }
31
46
 
32
47
  // Fallback: cwd itself
33
48
  return cwd;
34
49
  }
35
50
 
36
- /** Get all vault paths. */
51
+ /** Get all vault paths for the new (.llm-wiki) layout. */
37
52
  export function getVaultPaths(root: string): VaultPaths {
53
+ return {
54
+ root,
55
+ raw: join(root, ".llm-wiki", "raw"),
56
+ rawSources: join(root, ".llm-wiki", "raw", "sources"),
57
+ wiki: join(root, ".llm-wiki", "wiki"),
58
+ meta: join(root, ".llm-wiki", "meta"),
59
+ dotWiki: join(root, ".llm-wiki"),
60
+ outputs: join(root, ".llm-wiki", "outputs"),
61
+ discoveries: join(root, ".llm-wiki", ".discoveries"),
62
+ };
63
+ }
64
+
65
+ /** Get all vault paths for the legacy (.wiki) layout. */
66
+ export function getLegacyVaultPaths(root: string): VaultPaths {
38
67
  return {
39
68
  root,
40
69
  raw: join(root, "raw"),
@@ -47,6 +76,17 @@ export function getVaultPaths(root: string): VaultPaths {
47
76
  };
48
77
  }
49
78
 
79
+ /**
80
+ * Resolve vault paths, auto-detecting new vs legacy layout.
81
+ * This is the main entry point: use this instead of resolveVaultRoot + getVaultPaths.
82
+ */
83
+ export function resolveVaultPaths(cwd: string): VaultPaths {
84
+ const root = resolveVaultRoot(cwd);
85
+ const format = detectVaultFormat(root);
86
+ if (format === "legacy") return getLegacyVaultPaths(root);
87
+ return getVaultPaths(root);
88
+ }
89
+
50
90
  /** Ensure all vault directories exist. */
51
91
  export function ensureVaultStructure(paths: VaultPaths): void {
52
92
  const dirs = [
@@ -199,10 +239,10 @@ export async function exec(
199
239
  /** Check if a path is inside a protected directory. */
200
240
  export function isProtectedPath(
201
241
  absPath: string,
202
- root: string,
242
+ paths: VaultPaths,
203
243
  ): { protected: boolean; reason?: string } {
204
- const rawPath = resolve(root, "raw");
205
- const metaPath = resolve(root, "meta");
244
+ const rawPath = resolve(paths.raw);
245
+ const metaPath = resolve(paths.meta);
206
246
  const norm = resolve(absPath);
207
247
 
208
248
  if (norm.startsWith(`${rawPath}/`) || norm === rawPath) {