@zosmaai/pi-llm-wiki 0.5.0 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.0] - 2026-05-11
6
+
7
+ ### Added
8
+ - **Phase 1 — Auto-recall** (PR #19 by @arjun-zosma): New `wiki_recall` tool for explicit searches. Extension now auto-searches wiki before every user turn via `before_agent_start` hook. Matching pages injected as "Relevant Wiki Knowledge" into system prompt. 8 new tests.
9
+ - **Phase 2 — Auto-capture** (PR #20 by @arjun-zosma): New `wiki_retro` tool for saving atomic insights from completed tasks. Creates source packets with manifest, extracted text, and source page. 4 new tests.
10
+ - **Phase 3 — MCP Server** (PR #21 by @arjun-zosma): Standalone MCP server using `@modelcontextprotocol/server` (v2 SDK) with stdio transport. Exposes 5 tools: wiki_recall, wiki_search, wiki_status, wiki_retro, wiki_capture_source. Cross-platform reach to Claude Code, Cursor, Windsurf.
11
+ - **12 extension tools** (up from 10): wiki_recall (auto at turn start) and wiki_retro (manual at task end)
12
+ - **SKILL.md**: Auto-Recall section, wiki_recall + wiki_retro tool docs, "Task → Capture → Retro" workflow
13
+
14
+ ### Changed
15
+ - Extension registers 12 tools instead of 10
16
+ - Status bar now shows "12 tools, auto-recall active"
17
+
5
18
  ## [0.5.0] - 2026-05-11
6
19
 
7
20
  ### Added
@@ -1,5 +1,9 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
4
  import { installGuardrails } from "./lib/guardrails.js";
5
+ import { formatRecallContext, registerWikiRecall, searchWiki } from "./lib/recall.js";
6
+ import { registerWikiRetro } from "./lib/retro.js";
3
7
  import {
4
8
  registerWikiBootstrap,
5
9
  registerWikiCaptureSource,
@@ -12,25 +16,22 @@ import {
12
16
  registerWikiStatus,
13
17
  registerWikiWatch,
14
18
  } from "./lib/tools.js";
19
+ import { getVaultPaths, resolveVaultRoot } from "./lib/utils.js";
15
20
 
16
21
  /**
17
22
  * @zosmaai/pi-llm-wiki — LLM Wiki extension for Pi
18
23
  *
19
- * Registers 10 custom tools and installs guardrails:
20
- * - wiki_bootstrap Initialize a new vault
21
- * - wiki_capture_source Capture URL/file/text into source packet
22
- * - wiki_ingest Get batch of sources needing synthesis
23
- * - wiki_ensure_page Create canonical page from template
24
- * - wiki_search Search generated registry
25
- * - wiki_lint Health check with auto-fix
26
- * - wiki_status Instant stats from registry
27
- * - wiki_rebuild_meta Force metadata rebuild
28
- * - wiki_log_event Append event and regenerate log
29
- * - wiki_watch Schedule auto-updates
24
+ * Registers 11 custom tools and installs guardrails:
25
+ * All 10 original tools + wiki_recall (auto-recall at session start)
30
26
  *
31
27
  * Guardrails:
32
28
  * - Blocks direct edits to raw/** and meta/**
33
29
  * - Auto-rebuilds metadata after wiki/** edits
30
+ *
31
+ * Auto-recall:
32
+ * - before_agent_start hook searches wiki for pages relevant to user prompt
33
+ * - Injects matching knowledge as system context
34
+ * - wiki_recall tool available for explicit deep searches
34
35
  */
35
36
 
36
37
  export default function (pi: ExtensionAPI) {
@@ -44,10 +45,36 @@ export default function (pi: ExtensionAPI) {
44
45
  registerWikiRebuildMeta(pi);
45
46
  registerWikiLogEvent(pi);
46
47
  registerWikiWatch(pi);
48
+ registerWikiRecall(pi);
49
+ registerWikiRetro(pi);
47
50
 
48
51
  installGuardrails(pi);
49
52
 
50
53
  pi.on("session_start", async (_event, ctx) => {
51
- ctx.ui.setStatus("llm-wiki", "🧠 LLM Wiki (10 tools, guardrails active)");
54
+ ctx.ui.setStatus("llm-wiki", "🧠 LLM Wiki (11 tools, auto-recall active)");
55
+ });
56
+
57
+ // ─── Auto-recall hook ──────────────────────────────
58
+ // Before each agent turn, search the wiki for pages relevant
59
+ // to the user's prompt and inject them as system context.
60
+ pi.on("before_agent_start", async (event, _ctx) => {
61
+ const root = resolveVaultRoot(process.cwd());
62
+ if (!existsSync(join(root, ".wiki", "config.json"))) {
63
+ return; // No wiki vault — nothing to recall
64
+ }
65
+
66
+ const paths = getVaultPaths(root);
67
+ const prompt = event.prompt || "";
68
+ if (!prompt.trim()) return;
69
+
70
+ const results = searchWiki(paths, prompt);
71
+ if (results.length === 0) return;
72
+
73
+ const context = formatRecallContext(results);
74
+ if (!context) return;
75
+
76
+ return {
77
+ systemPrompt: `${event.systemPrompt}\n\n${context}`,
78
+ };
52
79
  });
53
80
  }
@@ -0,0 +1,208 @@
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, getVaultPaths, readJson, readText, resolveVaultRoot } 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 root = resolveVaultRoot(process.cwd());
152
+ const paths = getVaultPaths(root);
153
+
154
+ if (!existsSync(join(root, ".wiki", "config.json"))) {
155
+ return {
156
+ content: [
157
+ {
158
+ type: "text",
159
+ text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
160
+ },
161
+ ],
162
+ details: { error: "no_vault" } as Record<string, unknown>,
163
+ isError: true,
164
+ };
165
+ }
166
+
167
+ const maxResults = Math.min(params.max_results ?? 5, 10);
168
+ const results = searchWiki(paths, params.query, maxResults);
169
+
170
+ if (results.length === 0) {
171
+ return {
172
+ content: [
173
+ {
174
+ type: "text",
175
+ text: `No wiki pages found matching "${params.query}". Use wiki_search for broader results.`,
176
+ },
177
+ ],
178
+ details: { query: params.query, matches: [] } as Record<string, unknown>,
179
+ };
180
+ }
181
+
182
+ return {
183
+ content: [
184
+ {
185
+ type: "text",
186
+ text: [
187
+ `🧠 **${results.length} wiki page(s) relevant** to "${params.query}":`,
188
+ "",
189
+ ...results.map(
190
+ (r) =>
191
+ `- [[${r.id}]] — *${r.type}* — ${r.title}${
192
+ r.preview ? `\n > ${r.preview.slice(0, 150)}` : ""
193
+ }`,
194
+ ),
195
+ "",
196
+ "Use `read` on any page for full content.",
197
+ "Use `wiki_retro` to save new insights from this task.",
198
+ ].join("\n"),
199
+ },
200
+ ],
201
+ details: { query: params.query, matches: results.map((r) => r.id) } as Record<
202
+ string,
203
+ unknown
204
+ >,
205
+ };
206
+ },
207
+ });
208
+ }
@@ -0,0 +1,203 @@
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 {
7
+ type VaultPaths,
8
+ fmtDate,
9
+ getVaultPaths,
10
+ nextSourceId,
11
+ resolveVaultRoot,
12
+ } from "./utils.js";
13
+
14
+ // ─── Public API ────────────────────────────────────────
15
+
16
+ export interface RetroResult {
17
+ sourceId: string;
18
+ packetPath: string;
19
+ sourcePagePath: string;
20
+ }
21
+
22
+ /**
23
+ * Save an atomic insight into the wiki as a source packet + source page.
24
+ * Returns the source ID, packet path, and source page path.
25
+ */
26
+ export function saveInsight(
27
+ paths: VaultPaths,
28
+ slug: string,
29
+ title: string,
30
+ body: string,
31
+ category?: string,
32
+ ): RetroResult {
33
+ const sourceId = nextSourceId(paths);
34
+ const packetPath = join(paths.rawSources, sourceId);
35
+ mkdirSync(packetPath, { recursive: true });
36
+ mkdirSync(join(packetPath, "attachments"), { recursive: true });
37
+
38
+ const today = fmtDate();
39
+
40
+ // Write manifest
41
+ const manifest = {
42
+ id: sourceId,
43
+ title,
44
+ slug,
45
+ category: category || "uncategorized",
46
+ captured: today,
47
+ format: "insight",
48
+ packet_version: "1.0",
49
+ };
50
+ writeFileSync(
51
+ join(packetPath, "manifest.json"),
52
+ `${JSON.stringify(manifest, null, 2)}\n`,
53
+ "utf-8",
54
+ );
55
+
56
+ // Write extracted text (the insight body in markdown)
57
+ const extracted = [
58
+ `# ${title}`,
59
+ "",
60
+ body,
61
+ "",
62
+ "---",
63
+ `*Captured: ${today}*`,
64
+ category ? `*Category: ${category}*` : "",
65
+ ]
66
+ .filter(Boolean)
67
+ .join("\n");
68
+ writeFileSync(join(packetPath, "extracted.md"), extracted, "utf-8");
69
+
70
+ // Create source page
71
+ const sourcePageDir = join(paths.wiki, "sources");
72
+ mkdirSync(sourcePageDir, { recursive: true });
73
+ const sourcePagePath = join(sourcePageDir, `${sourceId}.md`);
74
+
75
+ const tagLine = category ? `category: ${category}` : "";
76
+ const sourcePageContent = [
77
+ "---",
78
+ "type: source",
79
+ `title: "${title}"`,
80
+ `source_id: ${sourceId}`,
81
+ "status: insight",
82
+ `created: ${today}`,
83
+ `updated: ${today}`,
84
+ tagLine,
85
+ "---",
86
+ "",
87
+ `# ${title}`,
88
+ "",
89
+ body,
90
+ "",
91
+ "## Source",
92
+ "",
93
+ `- **Packet:** \`${packetPath}\``,
94
+ `- **Captured:** ${today}`,
95
+ category ? `- **Category:** ${category}` : "",
96
+ "",
97
+ "## Related",
98
+ "",
99
+ "_(Add [[wikilinks]] to related pages)_",
100
+ "",
101
+ ]
102
+ .filter((l) => l !== "")
103
+ .join("\n");
104
+ writeFileSync(sourcePagePath, sourcePageContent, "utf-8");
105
+
106
+ // Log event
107
+ appendEvent(paths, {
108
+ kind: "retro",
109
+ source_id: sourceId,
110
+ title,
111
+ slug,
112
+ category: category || "uncategorized",
113
+ });
114
+
115
+ // Rebuild metadata
116
+ rebuildMetadataLight(paths);
117
+
118
+ return { sourceId, packetPath, sourcePagePath };
119
+ }
120
+
121
+ // ─── Tool Registration ──────────────────────────────────
122
+
123
+ /**
124
+ * Register the `wiki_retro` tool.
125
+ * The model calls this to save an atomic insight from a completed task.
126
+ * Inspired by the memex_retro pattern.
127
+ */
128
+ export function registerWikiRetro(pi: ExtensionAPI): void {
129
+ pi.registerTool({
130
+ name: "wiki_retro",
131
+ label: "Wiki Retro",
132
+ description:
133
+ "Save an atomic insight from a completed task into the wiki. " +
134
+ "Creates a source packet and source page. The insight will be " +
135
+ "surfaced automatically by wiki_recall in future sessions.",
136
+ promptSnippet: "Save atomic insights from completed tasks into the wiki",
137
+ promptGuidelines: [
138
+ "Use wiki_retro at the END of every meaningful task to save what you learned.",
139
+ "Write atomic insights — one insight per call. Use multiple calls for multiple insights.",
140
+ "The insight will be auto-surfaced by wiki_recall in future sessions.",
141
+ ],
142
+ parameters: Type.Object({
143
+ slug: Type.String({
144
+ description:
145
+ "Unique kebab-case identifier (e.g. 'jwt-revocation-pattern'). Used for lookups.",
146
+ }),
147
+ title: Type.String({
148
+ description: "Short descriptive title (60 chars max). Noun phrase, not a sentence.",
149
+ }),
150
+ body: Type.String({
151
+ description:
152
+ "Markdown body with [[wikilinks]] to related wiki pages. Explain what was learned.",
153
+ }),
154
+ category: Type.Optional(
155
+ Type.String({
156
+ description: "Optional category (e.g. frontend, architecture, devops, bugfix, design)",
157
+ }),
158
+ ),
159
+ }),
160
+ async execute(_toolCallId, params) {
161
+ const root = resolveVaultRoot(process.cwd());
162
+ const paths = getVaultPaths(root);
163
+
164
+ if (!existsSync(join(root, ".wiki", "config.json"))) {
165
+ return {
166
+ content: [
167
+ {
168
+ type: "text",
169
+ text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
170
+ },
171
+ ],
172
+ details: { error: "no_vault" } as Record<string, unknown>,
173
+ isError: true,
174
+ };
175
+ }
176
+
177
+ const result = saveInsight(paths, params.slug, params.title, params.body, params.category);
178
+
179
+ return {
180
+ content: [
181
+ {
182
+ type: "text",
183
+ text: [
184
+ `🧠 **Insight saved**: ${params.title}`,
185
+ "",
186
+ `- Source: \`${result.sourceId}\``,
187
+ `- Packet: \`${result.packetPath}\``,
188
+ `- Page: \`${result.sourcePagePath}\``,
189
+ "",
190
+ "This insight will be auto-surfaced by wiki_recall in future sessions.",
191
+ ].join("\n"),
192
+ },
193
+ ],
194
+ details: {
195
+ sourceId: result.sourceId,
196
+ slug: params.slug,
197
+ title: params.title,
198
+ category: params.category || null,
199
+ } as Record<string, unknown>,
200
+ };
201
+ },
202
+ });
203
+ }
package/mcp/index.ts ADDED
@@ -0,0 +1,463 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * LLM Wiki MCP Server
5
+ *
6
+ * Exposes wiki tools over the Model Context Protocol (MCP).
7
+ * Run: node mcp/index.js
8
+ * Or via package.json: pi install npm:@zosmaai/pi-llm-wiki && node mcp/index.js
9
+ *
10
+ * Environment:
11
+ * WIKI_ROOT — path to wiki vault (default: auto-detect from cwd)
12
+ * WIKI_MARKITDOWN_TIMEOUT_MS — PDF extraction timeout (default: 180000)
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { McpServer } from "@modelcontextprotocol/server";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
19
+ import * as z from "zod/v4";
20
+
21
+ // ─── Wiki Vault Detection ──────────────────────────────
22
+
23
+ interface VaultPaths {
24
+ root: string;
25
+ rawSources: string;
26
+ wiki: string;
27
+ meta: string;
28
+ }
29
+
30
+ function resolveVaultRoot(cwd: string): string | null {
31
+ if (existsSync(join(cwd, ".wiki", "config.json"))) return cwd;
32
+ const parts = cwd.split("/");
33
+ for (let i = parts.length - 1; i >= 0; i--) {
34
+ const dir = parts.slice(0, i + 1).join("/") || "/";
35
+ if (existsSync(join(dir, ".wiki", "config.json"))) return dir;
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function getPaths(): VaultPaths {
41
+ const root = process.env.WIKI_ROOT || resolveVaultRoot(process.cwd()) || process.cwd();
42
+ return {
43
+ root,
44
+ rawSources: join(root, "raw", "sources"),
45
+ wiki: join(root, "wiki"),
46
+ meta: join(root, "meta"),
47
+ };
48
+ }
49
+
50
+ function hasVault(): boolean {
51
+ return existsSync(join(getPaths().root, ".wiki", "config.json"));
52
+ }
53
+
54
+ // ─── Helpers ────────────────────────────────────────────
55
+
56
+ function readJson<T>(path: string, defaultVal: T): T {
57
+ try {
58
+ if (!existsSync(path)) return defaultVal;
59
+ return JSON.parse(readFileSync(path, "utf-8")) as T;
60
+ } catch {
61
+ return defaultVal;
62
+ }
63
+ }
64
+
65
+ // ─── MCP Server ─────────────────────────────────────────
66
+
67
+ const server = new McpServer({
68
+ name: "llm-wiki",
69
+ version: "1.0.0",
70
+ });
71
+
72
+ // ---- wiki_recall ----
73
+
74
+ server.registerTool(
75
+ "wiki_recall",
76
+ {
77
+ description:
78
+ "Search the wiki for pages relevant to a query. Returns matching page IDs, titles, types, and content previews.",
79
+ inputSchema: z.object({
80
+ query: z.string().describe("Search query — use the user's full request or key terms"),
81
+ max_results: z.number().optional().default(5).describe("Max results (default: 5, max: 10)"),
82
+ }),
83
+ },
84
+ async ({ query, max_results }) => {
85
+ if (!hasVault()) {
86
+ return {
87
+ content: [
88
+ {
89
+ type: "text" as const,
90
+ text: "No wiki vault found. Set WIKI_ROOT or run wiki_bootstrap first.",
91
+ },
92
+ ],
93
+ isError: true,
94
+ };
95
+ }
96
+
97
+ const paths = getPaths();
98
+ const registry = readJson<{
99
+ pages: Record<string, { type: string; title: string; [key: string]: unknown }>;
100
+ }>(join(paths.meta, "registry.json"), { pages: {} });
101
+
102
+ const terms = query
103
+ .toLowerCase()
104
+ .split(/\s+/)
105
+ .filter((t) => t.length > 2)
106
+ .slice(0, 10);
107
+
108
+ if (terms.length === 0) {
109
+ return {
110
+ content: [{ type: "text" as const, text: "Query too short." }],
111
+ };
112
+ }
113
+
114
+ type Scored = { id: string; score: number };
115
+ const scored: Scored[] = [];
116
+
117
+ for (const [id, entry] of Object.entries(registry.pages)) {
118
+ let score = 0;
119
+ const title = String(entry.title || "").toLowerCase();
120
+ const type = String(entry.type || "").toLowerCase();
121
+
122
+ for (const term of terms) {
123
+ if (id.toLowerCase().includes(term)) score += 3;
124
+ if (title.includes(term)) score += 4;
125
+ if (type.includes(term)) score += 1;
126
+ }
127
+
128
+ const tags = String(entry.tags || entry.category || entry.domain || "").toLowerCase();
129
+ for (const term of terms) {
130
+ if (tags.includes(term)) score += 2;
131
+ }
132
+
133
+ if (score > 0) scored.push({ id, score });
134
+ }
135
+
136
+ scored.sort((a, b) => b.score - a.score);
137
+ const top = scored.slice(0, Math.min(max_results ?? 5, 10));
138
+
139
+ const results = top.map(({ id }) => {
140
+ const entry = registry.pages[id];
141
+ let preview = "";
142
+ const pagePath = join(paths.wiki, `${id}.md`);
143
+ if (existsSync(pagePath)) {
144
+ const content = readFileSync(pagePath, "utf-8");
145
+ preview = content
146
+ .replace(/^---[\s\S]*?---\n/, "")
147
+ .trim()
148
+ .slice(0, 200)
149
+ .replace(/\n/g, " ");
150
+ }
151
+ return {
152
+ id,
153
+ title: String(entry?.title || id),
154
+ type: String(entry?.type || "page"),
155
+ preview,
156
+ };
157
+ });
158
+
159
+ return {
160
+ content: [
161
+ {
162
+ type: "text" as const,
163
+ text: JSON.stringify(results, null, 2),
164
+ },
165
+ ],
166
+ };
167
+ },
168
+ );
169
+
170
+ // ---- wiki_search ----
171
+
172
+ server.registerTool(
173
+ "wiki_search",
174
+ {
175
+ description: "Search the wiki registry for pages matching a query.",
176
+ inputSchema: z.object({
177
+ query: z.string().describe("Search term"),
178
+ type: z
179
+ .string()
180
+ .optional()
181
+ .describe("Filter by page type (source, entity, concept, synthesis, analysis)"),
182
+ }),
183
+ },
184
+ async ({ query, type }) => {
185
+ if (!hasVault()) {
186
+ return {
187
+ content: [
188
+ {
189
+ type: "text" as const,
190
+ text: "No wiki vault found. Set WIKI_ROOT or run wiki_bootstrap first.",
191
+ },
192
+ ],
193
+ isError: true,
194
+ };
195
+ }
196
+
197
+ const paths = getPaths();
198
+ const registry = readJson<{
199
+ pages: Record<string, { type: string; title: string; [key: string]: unknown }>;
200
+ }>(join(paths.meta, "registry.json"), { pages: {} });
201
+
202
+ const q = query.toLowerCase();
203
+ const matches = Object.entries(registry.pages)
204
+ .filter(([id, entry]) => {
205
+ const matchesQuery =
206
+ id.toLowerCase().includes(q) ||
207
+ String(entry.title).toLowerCase().includes(q) ||
208
+ String(entry.type).toLowerCase().includes(q);
209
+ const matchesType = !type || String(entry.type).toLowerCase() === type.toLowerCase();
210
+ return matchesQuery && matchesType;
211
+ })
212
+ .map(([id, entry]) => ({
213
+ id,
214
+ title: entry.title,
215
+ type: entry.type,
216
+ }));
217
+
218
+ return {
219
+ content: [
220
+ {
221
+ type: "text" as const,
222
+ text:
223
+ matches.length > 0 ? JSON.stringify(matches, null, 2) : `No pages found for "${query}"`,
224
+ },
225
+ ],
226
+ };
227
+ },
228
+ );
229
+
230
+ // ---- wiki_status ----
231
+
232
+ server.registerTool(
233
+ "wiki_status",
234
+ {
235
+ description: "Show wiki health and stats: page counts, orphans, recent activity.",
236
+ inputSchema: z.object({}),
237
+ },
238
+ async () => {
239
+ if (!hasVault()) {
240
+ return {
241
+ content: [
242
+ {
243
+ type: "text" as const,
244
+ text: "No wiki vault found. Set WIKI_ROOT or run wiki_bootstrap first.",
245
+ },
246
+ ],
247
+ isError: true,
248
+ };
249
+ }
250
+
251
+ const paths = getPaths();
252
+ const registry = readJson<{
253
+ version: string;
254
+ last_updated: string;
255
+ pages: Record<string, { type: string; title: string; [key: string]: unknown }>;
256
+ }>(join(paths.meta, "registry.json"), {
257
+ version: "1.0",
258
+ last_updated: "",
259
+ pages: {},
260
+ });
261
+
262
+ const config = readJson<Record<string, unknown>>(join(paths.root, ".wiki", "config.json"), {});
263
+
264
+ const byType: Record<string, number> = {};
265
+ for (const entry of Object.values(registry.pages)) {
266
+ byType[entry.type] = (byType[entry.type] || 0) + 1;
267
+ }
268
+
269
+ return {
270
+ content: [
271
+ {
272
+ type: "text" as const,
273
+ text: JSON.stringify(
274
+ {
275
+ topic: config.topic || "Unknown",
276
+ mode: config.mode || "personal",
277
+ totalPages: Object.keys(registry.pages).length,
278
+ byType,
279
+ lastUpdated: registry.last_updated || "Never",
280
+ },
281
+ null,
282
+ 2,
283
+ ),
284
+ },
285
+ ],
286
+ };
287
+ },
288
+ );
289
+
290
+ // ---- wiki_retro ----
291
+
292
+ server.registerTool(
293
+ "wiki_retro",
294
+ {
295
+ description:
296
+ "Save an atomic insight from a completed task into the wiki. Creates a source packet and source page.",
297
+ inputSchema: z.object({
298
+ slug: z.string().describe("Unique kebab-case identifier (e.g. 'jwt-revocation-pattern')"),
299
+ title: z.string().describe("Short descriptive title (60 chars max)"),
300
+ body: z
301
+ .string()
302
+ .describe(
303
+ "Markdown body explaining what was learned. Include [[wikilinks]] to related pages.",
304
+ ),
305
+ category: z
306
+ .string()
307
+ .optional()
308
+ .describe("Category (e.g. frontend, architecture, devops, bugfix)"),
309
+ }),
310
+ },
311
+ async ({ slug, title, body, category }) => {
312
+ if (!hasVault()) {
313
+ return {
314
+ content: [
315
+ {
316
+ type: "text" as const,
317
+ text: "No wiki vault found. Set WIKI_ROOT or run wiki_bootstrap first.",
318
+ },
319
+ ],
320
+ isError: true,
321
+ };
322
+ }
323
+
324
+ const { saveInsight } = (await import("../extensions/llm-wiki/lib/retro.js")) as {
325
+ saveInsight: (
326
+ paths: Record<string, string>,
327
+ slug: string,
328
+ title: string,
329
+ body: string,
330
+ category?: string,
331
+ ) => { sourceId: string; packetPath: string; sourcePagePath: string };
332
+ };
333
+
334
+ const vaultPaths = {
335
+ ...getPaths(),
336
+ raw: join(getPaths().root, "raw"),
337
+ dotWiki: join(getPaths().root, ".wiki"),
338
+ outputs: join(getPaths().root, "outputs"),
339
+ discoveries: join(getPaths().root, ".discoveries"),
340
+ };
341
+
342
+ const result = saveInsight(vaultPaths, slug, title, body, category);
343
+
344
+ return {
345
+ content: [
346
+ {
347
+ type: "text" as const,
348
+ text: `Insight saved: ${result.sourceId} — ${title}`,
349
+ },
350
+ ],
351
+ };
352
+ },
353
+ );
354
+
355
+ // ---- wiki_capture_source ----
356
+
357
+ server.registerTool(
358
+ "wiki_capture_source",
359
+ {
360
+ description: "Capture a URL, local file, or pasted text into an immutable source packet.",
361
+ inputSchema: z.object({
362
+ text: z.string().optional().describe("Text content to capture"),
363
+ url: z.string().optional().describe("URL to capture"),
364
+ file_path: z.string().optional().describe("Local file path to capture"),
365
+ title: z.string().optional().describe("Title for the captured source"),
366
+ }),
367
+ },
368
+ async ({ text, url: urlParam, file_path, title }) => {
369
+ if (!hasVault()) {
370
+ return {
371
+ content: [
372
+ {
373
+ type: "text" as const,
374
+ text: "No wiki vault found. Set WIKI_ROOT or run wiki_bootstrap first.",
375
+ },
376
+ ],
377
+ isError: true,
378
+ };
379
+ }
380
+
381
+ const vaultPaths = {
382
+ ...getPaths(),
383
+ raw: join(getPaths().root, "raw"),
384
+ dotWiki: join(getPaths().root, ".wiki"),
385
+ outputs: join(getPaths().root, "outputs"),
386
+ discoveries: join(getPaths().root, ".discoveries"),
387
+ };
388
+
389
+ let result: { sourceId: string };
390
+
391
+ if (urlParam) {
392
+ // For MCP, use simple curl-based capture
393
+ const { captureUrl } = (await import("../extensions/llm-wiki/lib/source-packet.js")) as {
394
+ captureUrl: (
395
+ pi: never,
396
+ paths: Record<string, string>,
397
+ url: string,
398
+ signal?: AbortSignal,
399
+ ) => Promise<{ sourceId: string }>;
400
+ };
401
+ result = await captureUrl(
402
+ { exec: async () => ({ stdout: "", stderr: "", code: 0 }) } as never,
403
+ vaultPaths,
404
+ urlParam,
405
+ );
406
+ } else if (file_path) {
407
+ const { captureFile } = (await import("../extensions/llm-wiki/lib/source-packet.js")) as {
408
+ captureFile: (
409
+ pi: never,
410
+ paths: Record<string, string>,
411
+ filePath: string,
412
+ signal?: AbortSignal,
413
+ ) => Promise<{ sourceId: string }>;
414
+ };
415
+ result = await captureFile(
416
+ { exec: async () => ({ stdout: "", stderr: "", code: 0 }) } as never,
417
+ vaultPaths,
418
+ file_path,
419
+ );
420
+ } else if (text) {
421
+ const { captureText } = (await import("../extensions/llm-wiki/lib/source-packet.js")) as {
422
+ captureText: (
423
+ paths: Record<string, string>,
424
+ text: string,
425
+ title?: string,
426
+ ) => { sourceId: string };
427
+ };
428
+ result = captureText(vaultPaths, text, title);
429
+ } else {
430
+ return {
431
+ content: [
432
+ {
433
+ type: "text" as const,
434
+ text: "Provide one of: text, url, or file_path",
435
+ },
436
+ ],
437
+ isError: true,
438
+ };
439
+ }
440
+
441
+ return {
442
+ content: [
443
+ {
444
+ type: "text" as const,
445
+ text: `Source captured: ${result.sourceId}`,
446
+ },
447
+ ],
448
+ };
449
+ },
450
+ );
451
+
452
+ // ─── Main ───────────────────────────────────────────────
453
+
454
+ async function main() {
455
+ const transport = new StdioServerTransport();
456
+ await server.connect(transport);
457
+ console.error("🧠 LLM Wiki MCP Server running on stdio");
458
+ }
459
+
460
+ main().catch((err) => {
461
+ console.error("MCP Server error:", err);
462
+ process.exit(1);
463
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -37,6 +37,7 @@
37
37
  "extensions",
38
38
  "skills",
39
39
  "prompts",
40
+ "mcp",
40
41
  "docs",
41
42
  "assets",
42
43
  "README.md",
@@ -62,7 +63,10 @@
62
63
  "extensions": ["./extensions"],
63
64
  "skills": ["./skills"],
64
65
  "prompts": ["./prompts"],
65
- "image": "https://raw.githubusercontent.com/zosmaai/pi-llm-wiki/main/assets/screenshot.png"
66
+ "image": "https://raw.githubusercontent.com/zosmaai/pi-llm-wiki/main/assets/screenshot.png",
67
+ "mcpservers": {
68
+ "llm-wiki": "node ./mcp/index.js"
69
+ }
66
70
  },
67
71
  "peerDependencies": {
68
72
  "@mariozechner/pi-coding-agent": "*",
@@ -71,6 +75,9 @@
71
75
  "engines": {
72
76
  "node": ">=18"
73
77
  },
78
+ "dependencies": {
79
+ "@modelcontextprotocol/server": "^2.0.0-alpha.2"
80
+ },
74
81
  "devDependencies": {
75
82
  "@biomejs/biome": "^1.9.4",
76
83
  "@mariozechner/pi-coding-agent": "^0.70.2",
@@ -45,21 +45,48 @@ WIKI_ROOT/
45
45
 
46
46
  ## How the Extension Helps You
47
47
 
48
- | Task | Before (skill-only) | Now (extension-backed) |
49
- | ------------------ | ---------------------------- | ------------------------------------- |
50
- | Track ingestion | Manual `history.json` | Automatic via `meta/registry.json` |
51
- | Update INDEX | Manual edit after every page | Auto-rebuilds on turn end |
52
- | Update LOG | Manual append | Auto-generated from `events.jsonl` |
53
- | Find orphans | Shell `grep` scans | Instant from `backlinks.json` |
54
- | Block raw edits | Skill says "don't" | Extension **enforces** immutability |
55
- | Create source page | 8 tool calls | `wiki_capture_source` + LLM synthesis |
48
+ | Task | Before (skill-only) | Now (extension-backed) |
49
+ | --------------------------- | ---------------------------- | ------------------------------------- |
50
+ | Track ingestion | Manual `history.json` | Automatic via `meta/registry.json` |
51
+ | Update INDEX | Manual edit after every page | Auto-rebuilds on turn end |
52
+ | Update LOG | Manual append | Auto-generated from `events.jsonl` |
53
+ | Find orphans | Shell `grep` scans | Instant from `backlinks.json` |
54
+ | Block raw edits | Skill says "don't" | Extension **enforces** immutability |
55
+ | Create source page | 8 tool calls | `wiki_capture_source` + LLM synthesis |
56
+ | **Recall wiki knowledge** | Never happens | **Auto-search before every turn** |
57
+ | **Save task insights** | Manual capture | `wiki_retro` — one tool call |
58
+
59
+ ## 🔄 Auto-Recall (New)
60
+
61
+ **The extension now automatically searches the wiki before every user turn.**
62
+
63
+ When you send a prompt, the extension:
64
+ 1. Extracts key terms from your request
65
+ 2. Searches the wiki registry for matching pages
66
+ 3. Injects matching page titles + summaries into context
67
+ 4. You see this as "Relevant Wiki Knowledge" in your system prompt
68
+
69
+ **This means the wiki works as an automatic second brain.**
70
+ You don't need to remember to search — relevant knowledge is surfaced automatically.
71
+
72
+ ### Manual recall for deeper searches
73
+
74
+ If the auto-recall doesn't find enough context, call `wiki_recall` explicitly:
75
+
76
+ ```
77
+ wiki_recall(query="specific terms...", max_results=10)
78
+ ```
79
+
80
+ This gives you more control over the search terms and returns content previews.
56
81
 
57
82
  ## Available Tools
58
83
 
59
- Use these directly — they handle scaffolding and bookkeeping:
84
+ Use these directly — they handle scaffolding, bookkeeping, recall, and capture:
60
85
 
61
86
  - `wiki_bootstrap` — Initialize a new vault
62
87
  - `wiki_capture_source` — Capture URL/file/text into immutable packet + skeleton page
88
+ - `wiki_recall` — **Auto-called at turn start.** Search wiki for task-relevant pages
89
+ - `wiki_retro` — Save an atomic insight from a completed task into the wiki
63
90
  - `wiki_ingest` — Get batch of uningested sources with extracted text
64
91
  - `wiki_ensure_page` — Create entity/concept/synthesis/analysis page from template
65
92
  - `wiki_search` — Search registry for existing pages
@@ -83,12 +110,20 @@ Use these directly — they handle scaffolding and bookkeeping:
83
110
 
84
111
  ### Query → Answer → File
85
112
 
86
- 1. `wiki_search(query="...")` to find relevant pages
113
+ 1. **Auto-recall**: Extension surfaces relevant wiki pages automatically
87
114
  2. Read those pages
88
115
  3. Synthesize answer with `[[wikilink]]` citations
89
116
  4. If novel: create analysis page via `wiki_ensure_page(type="analysis")`
90
117
  5. Extension auto-updates metadata
91
118
 
119
+ ### Task → Capture → Retro
120
+
121
+ 1. Complete a meaningful task
122
+ 2. Call `wiki_retro` to save key insights
123
+ 3. The insight is captured as a source packet
124
+ 4. Extension auto-updates metadata
125
+ 5. Next time, auto-recall surfaces your saved insight
126
+
92
127
  ## Page Conventions
93
128
 
94
129
  ### Naming