@zosmaai/pi-llm-wiki 0.4.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 +39 -0
- package/README.md +254 -34
- package/extensions/llm-wiki/index.ts +39 -12
- package/extensions/llm-wiki/lib/recall.ts +208 -0
- package/extensions/llm-wiki/lib/retro.ts +203 -0
- package/mcp/index.ts +463 -0
- package/package.json +33 -4
- package/skills/llm-wiki/SKILL.md +45 -10
- package/.coderabbit.yaml +0 -43
- package/.github/codeql/codeql-config.yml +0 -12
- package/.github/workflows/ci.yml +0 -45
- package/.github/workflows/codeql.yml +0 -39
- package/.github/workflows/release.yml +0 -86
- package/AGENTS.md +0 -57
- package/CONTRIBUTING.md +0 -43
- package/biome.json +0 -30
- package/scripts/release.js +0 -72
- package/test/llm-wiki.test.ts +0 -724
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -16
|
@@ -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
|
+
});
|