@zosmaai/pi-llm-wiki 0.10.7 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.de.md +35 -4
- package/README.es.md +260 -170
- package/README.fr.md +35 -4
- package/README.hi.md +35 -4
- package/README.ja.md +35 -4
- package/README.ko.md +35 -4
- package/README.md +38 -3
- package/README.pt.md +35 -4
- package/README.ru.md +35 -4
- package/README.zh.md +260 -170
- package/assets/demo.gif +0 -0
- package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
- package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
- package/dist/extensions/llm-wiki/lib/inject.js +65 -0
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
- package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
- package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
- package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/observation.js +283 -0
- package/dist/extensions/llm-wiki/lib/recall.js +875 -0
- package/dist/extensions/llm-wiki/lib/retro.js +158 -0
- package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
- package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
- package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
- package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
- package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
- package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
- package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
- package/dist/extensions/llm-wiki/lib/utils.js +347 -0
- package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
- package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
- package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
- package/dist/mcp/exec.js +121 -0
- package/dist/mcp/index.js +229 -0
- package/dist/mcp/operations.js +130 -0
- package/dist/package.json +1 -0
- package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
- package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
- package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
- package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
- package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
- package/extensions/llm-wiki/index.ts +22 -36
- package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
- package/extensions/llm-wiki/lib/embeddings.ts +9 -3
- package/extensions/llm-wiki/lib/guardrails.ts +174 -29
- package/extensions/llm-wiki/lib/indexing.ts +2 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
- package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
- package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
- package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
- package/extensions/llm-wiki/lib/metadata.ts +531 -116
- package/extensions/llm-wiki/lib/observation.ts +37 -43
- package/extensions/llm-wiki/lib/recall.ts +61 -33
- package/extensions/llm-wiki/lib/retro.ts +65 -41
- package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
- package/extensions/llm-wiki/lib/source-packet.ts +44 -31
- package/extensions/llm-wiki/lib/tools.ts +406 -348
- package/extensions/llm-wiki/lib/trajectory.ts +15 -1
- package/extensions/llm-wiki/lib/utils.ts +121 -130
- package/extensions/llm-wiki/lib/vault-format.ts +363 -0
- package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
- package/mcp/exec.ts +122 -0
- package/mcp/index.ts +60 -250
- package/mcp/operations.ts +176 -0
- package/package.json +8 -2
- package/scripts/migrate-llm-wiki.js +801 -0
- package/skills/llm-wiki/SKILL.md +8 -6
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type KnowledgeDiagnostic,
|
|
5
|
+
type KnowledgeDocument,
|
|
6
|
+
parseKnowledgeDocument,
|
|
7
|
+
parseMarkdownFrontmatter,
|
|
8
|
+
} from "./knowledge-document.js";
|
|
9
|
+
import { type VaultPaths, relativePhysicalPath } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export type KnowledgeFormat = "legacy" | "okf-0.2";
|
|
12
|
+
|
|
13
|
+
export interface VaultFormatState {
|
|
14
|
+
knowledgeFormat: KnowledgeFormat;
|
|
15
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
16
|
+
blocking: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscoveredDocument extends KnowledgeDocument {
|
|
20
|
+
absolutePath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DiscoveryResult {
|
|
24
|
+
documents: DiscoveredDocument[];
|
|
25
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
26
|
+
blocking: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class VaultWriteError extends Error {
|
|
30
|
+
constructor(readonly diagnostics: KnowledgeDiagnostic[]) {
|
|
31
|
+
super(diagnostics[0]?.message ?? "Wiki vault is not writable");
|
|
32
|
+
this.name = "VaultWriteError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RESERVED_NAMES = new Set(["index", "log"]);
|
|
37
|
+
|
|
38
|
+
export function compareCodePoint(a: string, b: string): number {
|
|
39
|
+
const left = a.normalize("NFC");
|
|
40
|
+
const right = b.normalize("NFC");
|
|
41
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function diag(
|
|
45
|
+
severity: "warning" | "error",
|
|
46
|
+
code: KnowledgeDiagnostic["code"],
|
|
47
|
+
path: string,
|
|
48
|
+
message: string,
|
|
49
|
+
): KnowledgeDiagnostic {
|
|
50
|
+
return { severity, code, path, message };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isReservedName(filename: string): boolean {
|
|
54
|
+
const name = filename.toLowerCase();
|
|
55
|
+
return name === "index.md" || name === "log.md";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VaultConfigRead =
|
|
59
|
+
| { ok: true; config: Record<string, unknown> }
|
|
60
|
+
| { ok: false; diagnostic: KnowledgeDiagnostic };
|
|
61
|
+
|
|
62
|
+
export function readVaultConfig(paths: VaultPaths): VaultConfigRead {
|
|
63
|
+
const path = join(paths.dotWiki, "config.json");
|
|
64
|
+
try {
|
|
65
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
66
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
67
|
+
throw new Error("config.json must contain an object");
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, config: parsed as Record<string, unknown> };
|
|
70
|
+
} catch (error: unknown) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
diagnostic: diag(
|
|
74
|
+
"error",
|
|
75
|
+
"config_invalid_knowledge_format",
|
|
76
|
+
"config.json",
|
|
77
|
+
`Cannot read valid wiki config: ${(error as Error).message}`,
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function inspectVaultFormat(paths: VaultPaths): VaultFormatState {
|
|
84
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
85
|
+
let blocking = false;
|
|
86
|
+
|
|
87
|
+
const configResult = readVaultConfig(paths);
|
|
88
|
+
if (!configResult.ok) {
|
|
89
|
+
return {
|
|
90
|
+
knowledgeFormat: "legacy",
|
|
91
|
+
diagnostics: [configResult.diagnostic],
|
|
92
|
+
blocking: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const config = configResult.config;
|
|
97
|
+
const rawFormat = config.knowledge_format;
|
|
98
|
+
|
|
99
|
+
// Resolve format
|
|
100
|
+
let format: KnowledgeFormat;
|
|
101
|
+
if (rawFormat === undefined) {
|
|
102
|
+
format = "legacy";
|
|
103
|
+
} else if (rawFormat === "legacy" || rawFormat === "okf-0.2") {
|
|
104
|
+
format = rawFormat;
|
|
105
|
+
} else {
|
|
106
|
+
return {
|
|
107
|
+
knowledgeFormat: "legacy",
|
|
108
|
+
diagnostics: [
|
|
109
|
+
diag(
|
|
110
|
+
"error",
|
|
111
|
+
"config_invalid_knowledge_format",
|
|
112
|
+
"config.json",
|
|
113
|
+
`Invalid knowledge_format value: ${JSON.stringify(rawFormat)}`,
|
|
114
|
+
),
|
|
115
|
+
],
|
|
116
|
+
blocking: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// In OKF mode, check root index version
|
|
121
|
+
// Missing root index is repairable; version mismatch blocks until explicitly handled
|
|
122
|
+
if (format === "okf-0.2") {
|
|
123
|
+
const rootIndexPath = join(paths.wiki, "index.md");
|
|
124
|
+
let rootContent: string | undefined;
|
|
125
|
+
try {
|
|
126
|
+
rootContent = readFileSync(rootIndexPath, "utf8");
|
|
127
|
+
} catch (error: unknown) {
|
|
128
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
129
|
+
diagnostics.push(
|
|
130
|
+
diag(
|
|
131
|
+
"error",
|
|
132
|
+
"okf_version_mismatch",
|
|
133
|
+
"wiki/index.md",
|
|
134
|
+
`Cannot read OKF root index: ${(error as Error).message}`,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
blocking = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (rootContent !== undefined) {
|
|
141
|
+
const frontmatter = parseMarkdownFrontmatter(rootContent, "index.md");
|
|
142
|
+
if (!frontmatter.ok || frontmatter.mapping.okf_version !== "0.2") {
|
|
143
|
+
diagnostics.push(
|
|
144
|
+
diag(
|
|
145
|
+
"error",
|
|
146
|
+
"okf_version_mismatch",
|
|
147
|
+
"wiki/index.md",
|
|
148
|
+
frontmatter.ok
|
|
149
|
+
? 'OKF root index must declare okf_version "0.2"'
|
|
150
|
+
: `Malformed OKF root index: ${frontmatter.diagnostics[0].message}`,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
blocking = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// In legacy mode, check if root index declares unsupported version
|
|
159
|
+
if (format === "legacy") {
|
|
160
|
+
const rootIndexPath = join(paths.wiki, "index.md");
|
|
161
|
+
try {
|
|
162
|
+
const content = readFileSync(rootIndexPath, "utf8");
|
|
163
|
+
const frontmatter = parseMarkdownFrontmatter(content, "index.md");
|
|
164
|
+
if (frontmatter.ok && frontmatter.mapping.okf_version !== undefined) {
|
|
165
|
+
if (frontmatter.mapping.okf_version !== "0.2") {
|
|
166
|
+
diagnostics.push(
|
|
167
|
+
diag(
|
|
168
|
+
"error",
|
|
169
|
+
"okf_version_mismatch",
|
|
170
|
+
"wiki/index.md",
|
|
171
|
+
`Root index declares unsupported okf_version "${frontmatter.mapping.okf_version}"`,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
blocking = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// No root index in legacy mode - fine
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
knowledgeFormat: format,
|
|
184
|
+
diagnostics,
|
|
185
|
+
blocking,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface MarkdownScan {
|
|
190
|
+
files: string[];
|
|
191
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function collectMarkdownFiles(dir: string, wikiRoot: string): MarkdownScan {
|
|
195
|
+
const files: string[] = [];
|
|
196
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
197
|
+
let entries: string[];
|
|
198
|
+
try {
|
|
199
|
+
entries = readdirSync(dir).sort(compareCodePoint);
|
|
200
|
+
} catch (error: unknown) {
|
|
201
|
+
diagnostics.push(
|
|
202
|
+
diag(
|
|
203
|
+
"error",
|
|
204
|
+
"frontmatter_parse_error",
|
|
205
|
+
relative(wikiRoot, dir).replace(/\\/g, "/") || ".",
|
|
206
|
+
`Failed to scan knowledge directory: ${(error as Error).message}`,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
return { files, diagnostics };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const entry of entries) {
|
|
213
|
+
const fullPath = join(dir, entry);
|
|
214
|
+
try {
|
|
215
|
+
const stat = lstatSync(fullPath);
|
|
216
|
+
if (stat.isSymbolicLink()) continue;
|
|
217
|
+
if (stat.isDirectory()) {
|
|
218
|
+
const child = collectMarkdownFiles(fullPath, wikiRoot);
|
|
219
|
+
files.push(...child.files);
|
|
220
|
+
diagnostics.push(...child.diagnostics);
|
|
221
|
+
} else if (stat.isFile() && entry.toLowerCase().endsWith(".md") && !isReservedName(entry)) {
|
|
222
|
+
files.push(fullPath);
|
|
223
|
+
}
|
|
224
|
+
} catch (error: unknown) {
|
|
225
|
+
diagnostics.push(
|
|
226
|
+
diag(
|
|
227
|
+
"error",
|
|
228
|
+
"frontmatter_parse_error",
|
|
229
|
+
relative(wikiRoot, fullPath).replace(/\\/g, "/"),
|
|
230
|
+
`Failed to inspect knowledge path: ${(error as Error).message}`,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { files, diagnostics };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Validate vault is writable: exists, valid mode, no blocking version mismatch. */
|
|
239
|
+
export function inspectWritableVault(
|
|
240
|
+
paths: VaultPaths,
|
|
241
|
+
): { ok: true; format: KnowledgeFormat } | { ok: false; diagnostics: KnowledgeDiagnostic[] } {
|
|
242
|
+
// Check vault exists
|
|
243
|
+
if (!existsSync(paths.dotWiki)) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
diagnostics: [
|
|
247
|
+
diag(
|
|
248
|
+
"error",
|
|
249
|
+
"config_invalid_knowledge_format",
|
|
250
|
+
paths.dotWiki,
|
|
251
|
+
"Wiki vault not found. Run wiki_bootstrap first.",
|
|
252
|
+
),
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const state = inspectVaultFormat(paths);
|
|
257
|
+
if (state.blocking) {
|
|
258
|
+
return { ok: false, diagnostics: state.diagnostics };
|
|
259
|
+
}
|
|
260
|
+
return { ok: true, format: state.knowledgeFormat };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Assert that an authoritative writer may mutate this vault. */
|
|
264
|
+
export function assertWritableVault(paths: VaultPaths): KnowledgeFormat {
|
|
265
|
+
const result = inspectWritableVault(paths);
|
|
266
|
+
if (!result.ok) throw new VaultWriteError(result.diagnostics);
|
|
267
|
+
return result.format;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Check if path is a generated OKF reserved file (mode-aware). */
|
|
271
|
+
export function isGeneratedOkfPath(path: string, paths: VaultPaths): boolean {
|
|
272
|
+
const state = inspectVaultFormat(paths);
|
|
273
|
+
if (state.blocking || state.knowledgeFormat !== "okf-0.2") return false;
|
|
274
|
+
const rel = relativePhysicalPath(paths.wiki, path);
|
|
275
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false;
|
|
276
|
+
const parts = rel.split(sep);
|
|
277
|
+
const name = parts.at(-1)?.toLowerCase();
|
|
278
|
+
return name === "index.md" || (parts.length === 1 && name === "log.md");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function discoverKnowledgeDocuments(paths: VaultPaths): DiscoveryResult {
|
|
282
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
283
|
+
const documents: DiscoveredDocument[] = [];
|
|
284
|
+
let blocking = false;
|
|
285
|
+
const seenIds = new Map<string, { id: string; physicalPath: string }>();
|
|
286
|
+
|
|
287
|
+
const scan = collectMarkdownFiles(paths.wiki, paths.wiki);
|
|
288
|
+
diagnostics.push(...scan.diagnostics);
|
|
289
|
+
if (scan.diagnostics.length > 0) blocking = true;
|
|
290
|
+
|
|
291
|
+
for (const file of scan.files) {
|
|
292
|
+
const physicalPath = relative(paths.wiki, file).replace(/\\/g, "/");
|
|
293
|
+
const normalizedPath = physicalPath.normalize("NFC");
|
|
294
|
+
const id = normalizedPath.replace(/\.md$/, "");
|
|
295
|
+
|
|
296
|
+
// Check for reserved names (case-insensitive)
|
|
297
|
+
const filename = (normalizedPath.split("/").pop() ?? "").replace(/\.md$/i, "").toLowerCase();
|
|
298
|
+
if (RESERVED_NAMES.has(filename)) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Check for identity collision
|
|
303
|
+
const collisionKey = id.toLowerCase();
|
|
304
|
+
const existing = seenIds.get(collisionKey);
|
|
305
|
+
if (existing && existing.physicalPath !== physicalPath) {
|
|
306
|
+
diagnostics.push(
|
|
307
|
+
diag(
|
|
308
|
+
"error",
|
|
309
|
+
"concept_identity_collision",
|
|
310
|
+
physicalPath,
|
|
311
|
+
`Identity collision between ${existing.physicalPath} and ${physicalPath}`,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
blocking = true;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
seenIds.set(collisionKey, { id, physicalPath });
|
|
318
|
+
|
|
319
|
+
// Parse the document
|
|
320
|
+
try {
|
|
321
|
+
const content = readFileSync(file, "utf8");
|
|
322
|
+
const result = parseKnowledgeDocument(content, normalizedPath);
|
|
323
|
+
|
|
324
|
+
if (!result.ok) {
|
|
325
|
+
diagnostics.push(...result.diagnostics);
|
|
326
|
+
blocking = true;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const doc = result.document;
|
|
331
|
+
if (result.diagnostics.length > 0) {
|
|
332
|
+
diagnostics.push(...result.diagnostics);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
documents.push({
|
|
336
|
+
...doc,
|
|
337
|
+
id,
|
|
338
|
+
path: normalizedPath,
|
|
339
|
+
absolutePath: file,
|
|
340
|
+
});
|
|
341
|
+
} catch (e: unknown) {
|
|
342
|
+
const err = e as Error;
|
|
343
|
+
diagnostics.push(
|
|
344
|
+
diag(
|
|
345
|
+
"error",
|
|
346
|
+
"frontmatter_parse_error",
|
|
347
|
+
normalizedPath,
|
|
348
|
+
`Failed to read file: ${err.message}`,
|
|
349
|
+
),
|
|
350
|
+
);
|
|
351
|
+
blocking = true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Sort documents by code point order
|
|
356
|
+
documents.sort((a, b) => compareCodePoint(a.id, b.id));
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
documents,
|
|
360
|
+
diagnostics,
|
|
361
|
+
blocking,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { KnowledgeDiagnostic } from "./knowledge-document.js";
|
|
4
|
+
import type { Registry } from "./metadata.js";
|
|
5
|
+
import { readJson } from "./utils.js";
|
|
6
|
+
import type { VaultPaths } from "./utils.js";
|
|
7
|
+
import type { KnowledgeFormat } from "./vault-format.js";
|
|
8
|
+
import {
|
|
9
|
+
compareCodePoint,
|
|
10
|
+
discoverKnowledgeDocuments,
|
|
11
|
+
inspectVaultFormat,
|
|
12
|
+
} from "./vault-format.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shared wiki service functions consumed by Pi and MCP.
|
|
16
|
+
*
|
|
17
|
+
* These are pure adapters over the shared document model and registry.
|
|
18
|
+
* No YAML parsing, file scanning, or page serialization here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface RegistrySearchResult {
|
|
22
|
+
matches: Array<{ id: string; title: string; type: string }>;
|
|
23
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WikiStatusSnapshot {
|
|
27
|
+
knowledgeFormat: KnowledgeFormat;
|
|
28
|
+
totalPages: number;
|
|
29
|
+
byType: Record<string, number>;
|
|
30
|
+
blockingDiagnostics: KnowledgeDiagnostic[];
|
|
31
|
+
lastUpdated: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Search the registry for matching concepts.
|
|
36
|
+
*
|
|
37
|
+
* Matches ID, semantic title, type, category, domain, tags, aliases, and recall triggers.
|
|
38
|
+
* Preserves unknown types as strings.
|
|
39
|
+
*/
|
|
40
|
+
export function searchRegistry(
|
|
41
|
+
paths: VaultPaths,
|
|
42
|
+
query: string,
|
|
43
|
+
typeFilter?: string,
|
|
44
|
+
): RegistrySearchResult {
|
|
45
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
46
|
+
const vaultState = inspectVaultFormat(paths);
|
|
47
|
+
diagnostics.push(...vaultState.diagnostics);
|
|
48
|
+
|
|
49
|
+
const registryPath = join(paths.meta, "registry.json");
|
|
50
|
+
if (!existsSync(registryPath)) {
|
|
51
|
+
return { matches: [], diagnostics };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const registry = readJson<Registry>(registryPath, {
|
|
55
|
+
version: "1.0",
|
|
56
|
+
last_updated: "",
|
|
57
|
+
pages: {},
|
|
58
|
+
});
|
|
59
|
+
const normalizedQuery = query.toLowerCase();
|
|
60
|
+
|
|
61
|
+
const matches: Array<{ id: string; title: string; type: string }> = [];
|
|
62
|
+
|
|
63
|
+
for (const [id, entry] of Object.entries(registry.pages)) {
|
|
64
|
+
// Apply type filter
|
|
65
|
+
if (typeFilter && String(entry.type).toLowerCase() !== typeFilter.toLowerCase()) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check if query matches any searchable field
|
|
70
|
+
if (matchesField(id, entry, normalizedQuery)) {
|
|
71
|
+
matches.push({
|
|
72
|
+
id,
|
|
73
|
+
title: String(entry.title || id),
|
|
74
|
+
type: String(entry.type || "unknown"),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Sort by code point order for determinism
|
|
80
|
+
matches.sort((a, b) => compareCodePoint(a.id, b.id));
|
|
81
|
+
|
|
82
|
+
return { matches, diagnostics };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function matchesField(id: string, entry: Record<string, unknown>, query: string): boolean {
|
|
86
|
+
// Match ID
|
|
87
|
+
if (id.toLowerCase().includes(query)) return true;
|
|
88
|
+
|
|
89
|
+
// Match title
|
|
90
|
+
if (
|
|
91
|
+
String(entry.title || "")
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.includes(query)
|
|
94
|
+
)
|
|
95
|
+
return true;
|
|
96
|
+
|
|
97
|
+
// Match type
|
|
98
|
+
if (
|
|
99
|
+
String(entry.type || "")
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.includes(query)
|
|
102
|
+
)
|
|
103
|
+
return true;
|
|
104
|
+
|
|
105
|
+
// Match category/domain
|
|
106
|
+
if (
|
|
107
|
+
String(entry.category || "")
|
|
108
|
+
.toLowerCase()
|
|
109
|
+
.includes(query)
|
|
110
|
+
)
|
|
111
|
+
return true;
|
|
112
|
+
if (
|
|
113
|
+
String(entry.domain || "")
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.includes(query)
|
|
116
|
+
)
|
|
117
|
+
return true;
|
|
118
|
+
|
|
119
|
+
// Match tags (array or string)
|
|
120
|
+
const tags = entry.tags;
|
|
121
|
+
if (Array.isArray(tags)) {
|
|
122
|
+
if (tags.some((t) => String(t).toLowerCase().includes(query))) return true;
|
|
123
|
+
} else if (typeof tags === "string" && tags.toLowerCase().includes(query)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Match aliases (array or string)
|
|
128
|
+
const aliases = entry.aliases;
|
|
129
|
+
if (Array.isArray(aliases)) {
|
|
130
|
+
if (aliases.some((a) => String(a).toLowerCase().includes(query))) return true;
|
|
131
|
+
} else if (typeof aliases === "string" && aliases.toLowerCase().includes(query)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Match recall_triggers (array or string)
|
|
136
|
+
const triggers = entry.recall_triggers;
|
|
137
|
+
if (Array.isArray(triggers)) {
|
|
138
|
+
if (triggers.some((t) => String(t).toLowerCase().includes(query))) return true;
|
|
139
|
+
} else if (typeof triggers === "string" && triggers.toLowerCase().includes(query)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get a status snapshot of the wiki.
|
|
148
|
+
*
|
|
149
|
+
* Reports resolved knowledge_format, page counts, and blocking diagnostics.
|
|
150
|
+
*/
|
|
151
|
+
export function getWikiStatus(paths: VaultPaths): WikiStatusSnapshot {
|
|
152
|
+
const vaultState = inspectVaultFormat(paths);
|
|
153
|
+
const diagnostics = [...vaultState.diagnostics];
|
|
154
|
+
|
|
155
|
+
// Also check discovery for current concept health
|
|
156
|
+
const discovery = discoverKnowledgeDocuments(paths);
|
|
157
|
+
diagnostics.push(...discovery.diagnostics);
|
|
158
|
+
|
|
159
|
+
const registryPath = join(paths.meta, "registry.json");
|
|
160
|
+
let registry: Registry | undefined;
|
|
161
|
+
if (existsSync(registryPath)) {
|
|
162
|
+
registry = readJson<Registry>(registryPath, { version: "1.0", last_updated: "", pages: {} });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const byType: Record<string, number> = {};
|
|
166
|
+
let totalPages = 0;
|
|
167
|
+
|
|
168
|
+
if (registry) {
|
|
169
|
+
for (const entry of Object.values(registry.pages)) {
|
|
170
|
+
totalPages++;
|
|
171
|
+
const type = String(entry.type || "unknown");
|
|
172
|
+
byType[type] = (byType[type] || 0) + 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
knowledgeFormat: vaultState.knowledgeFormat,
|
|
178
|
+
totalPages,
|
|
179
|
+
byType,
|
|
180
|
+
blockingDiagnostics: diagnostics.filter((d) => d.severity === "error"),
|
|
181
|
+
lastUpdated: registry?.last_updated || "",
|
|
182
|
+
};
|
|
183
|
+
}
|
package/mcp/exec.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import type { ExecApi } from "../extensions/llm-wiki/lib/utils.js";
|
|
3
|
+
|
|
4
|
+
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
export function createExecApi(): ExecApi {
|
|
7
|
+
return {
|
|
8
|
+
exec(command, args, options = {}) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
let killed = false;
|
|
11
|
+
let settled = false;
|
|
12
|
+
let stdoutLimited = false;
|
|
13
|
+
let stderrLimited = false;
|
|
14
|
+
let forceTimer: NodeJS.Timeout | undefined;
|
|
15
|
+
let stdout = "";
|
|
16
|
+
let stderr = "";
|
|
17
|
+
const child = spawn(command, args, {
|
|
18
|
+
cwd: options.cwd,
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
detached: process.platform !== "win32",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const sendSignal = (signal: "SIGTERM" | "SIGKILL") => {
|
|
24
|
+
if (process.platform === "win32" && child.pid) {
|
|
25
|
+
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => {});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (child.pid) {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(-child.pid, signal);
|
|
31
|
+
return;
|
|
32
|
+
} catch {
|
|
33
|
+
// Fall back to the direct child when process-group signalling fails.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
child.kill(signal);
|
|
37
|
+
};
|
|
38
|
+
const forceStop = () => {
|
|
39
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
40
|
+
forceTimer = undefined;
|
|
41
|
+
sendSignal("SIGKILL");
|
|
42
|
+
};
|
|
43
|
+
const stop = () => {
|
|
44
|
+
if (killed) return;
|
|
45
|
+
killed = true;
|
|
46
|
+
sendSignal("SIGTERM");
|
|
47
|
+
if (process.platform !== "win32") forceTimer = setTimeout(forceStop, 100);
|
|
48
|
+
};
|
|
49
|
+
const appendOutput = (
|
|
50
|
+
current: string,
|
|
51
|
+
chunk: string,
|
|
52
|
+
limited: boolean,
|
|
53
|
+
): [string, boolean] => {
|
|
54
|
+
if (limited) return [current, true];
|
|
55
|
+
const remaining = MAX_OUTPUT_BYTES - Buffer.byteLength(current);
|
|
56
|
+
if (remaining <= 0) {
|
|
57
|
+
stop();
|
|
58
|
+
return [current, true];
|
|
59
|
+
}
|
|
60
|
+
const bytes = Buffer.from(chunk);
|
|
61
|
+
if (bytes.byteLength <= remaining) return [current + chunk, false];
|
|
62
|
+
stop();
|
|
63
|
+
let prefix = "";
|
|
64
|
+
let prefixBytes = 0;
|
|
65
|
+
for (const char of chunk) {
|
|
66
|
+
const charBytes = Buffer.byteLength(char);
|
|
67
|
+
if (prefixBytes + charBytes > remaining) break;
|
|
68
|
+
prefix += char;
|
|
69
|
+
prefixBytes += charBytes;
|
|
70
|
+
}
|
|
71
|
+
return [current + prefix, true];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
child.stdout?.setEncoding("utf8");
|
|
75
|
+
child.stderr?.setEncoding("utf8");
|
|
76
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
77
|
+
[stdout, stdoutLimited] = appendOutput(stdout, chunk, stdoutLimited);
|
|
78
|
+
});
|
|
79
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
80
|
+
[stderr, stderrLimited] = appendOutput(stderr, chunk, stderrLimited);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const timer = options.timeout ? setTimeout(stop, options.timeout) : undefined;
|
|
84
|
+
const abort = () => stop();
|
|
85
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
86
|
+
|
|
87
|
+
const finish = (code: number) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
if (forceTimer) {
|
|
90
|
+
clearTimeout(forceTimer);
|
|
91
|
+
forceTimer = undefined;
|
|
92
|
+
if (child.pid) {
|
|
93
|
+
try {
|
|
94
|
+
process.kill(-child.pid, "SIGKILL");
|
|
95
|
+
} catch {
|
|
96
|
+
// The process group already exited; do not signal the closed child's stale PID.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
cleanup();
|
|
101
|
+
resolve({
|
|
102
|
+
stdout,
|
|
103
|
+
stderr,
|
|
104
|
+
code: stdoutLimited || stderrLimited ? 1 : killed && code === 0 ? 1 : code,
|
|
105
|
+
killed,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
child.once("error", () => finish(1));
|
|
109
|
+
child.once("close", (code) => finish(typeof code === "number" ? code : 1));
|
|
110
|
+
|
|
111
|
+
function cleanup() {
|
|
112
|
+
settled = true;
|
|
113
|
+
if (timer) clearTimeout(timer);
|
|
114
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
115
|
+
options.signal?.removeEventListener("abort", abort);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (options.signal?.aborted) stop();
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|