pi-vault-mind 0.7.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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +428 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +1 -0
  5. package/dist/src/commands.d.ts +9 -0
  6. package/dist/src/commands.js +813 -0
  7. package/dist/src/events.d.ts +13 -0
  8. package/dist/src/events.js +236 -0
  9. package/dist/src/graph.d.ts +3 -0
  10. package/dist/src/graph.js +234 -0
  11. package/dist/src/index.d.ts +2 -0
  12. package/dist/src/index.js +61 -0
  13. package/dist/src/lance.d.ts +40 -0
  14. package/dist/src/lance.js +409 -0
  15. package/dist/src/server.d.ts +25 -0
  16. package/dist/src/server.js +180 -0
  17. package/dist/src/settings-ui.d.ts +9 -0
  18. package/dist/src/settings-ui.js +313 -0
  19. package/dist/src/state.d.ts +2 -0
  20. package/dist/src/state.js +16 -0
  21. package/dist/src/tools.d.ts +2 -0
  22. package/dist/src/tools.js +772 -0
  23. package/dist/src/types.d.ts +103 -0
  24. package/dist/src/types.js +51 -0
  25. package/dist/src/utils.d.ts +17 -0
  26. package/dist/src/utils.js +102 -0
  27. package/dist/src/vault-writer.d.ts +17 -0
  28. package/dist/src/vault-writer.js +141 -0
  29. package/dist/src/watcher.d.ts +91 -0
  30. package/dist/src/watcher.js +411 -0
  31. package/dist/src/widget.d.ts +3 -0
  32. package/dist/src/widget.js +12 -0
  33. package/dist/test/index.test.d.ts +1 -0
  34. package/dist/test/index.test.js +368 -0
  35. package/package.json +83 -0
  36. package/skills/vault-mind/SKILL.md +260 -0
  37. package/skills/vault-mind/references/tool-reference.md +53 -0
  38. package/skills/vault-mind-broadcaster/SKILL.md +112 -0
  39. package/skills/vault-mind-heavy-lifter/SKILL.md +34 -0
  40. package/skills/vault-mind-manager/SKILL.md +35 -0
  41. package/skills/vault-mind-miner/SKILL.md +40 -0
  42. package/skills/vault-mind-setup/SKILL.md +385 -0
  43. package/skills/vault-mind-setup/references/obsidian-cli-and-plugins.md +269 -0
  44. package/skills/vault-mind-setup/references/obsidian-vault-structure.md +106 -0
  45. package/skills/vault-mind-setup/references/pi-extension-wiring.md +236 -0
  46. package/skills/vault-mind-setup/references/troubleshooting-tree.md +147 -0
@@ -0,0 +1,772 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { Type } from "typebox";
4
+ import { graphUpsert, queryGraph } from "./graph.js";
5
+ import { getStatus, searchFts, searchHybrid, upsertEntry, } from "./lance.js";
6
+ import { collectionNames, ensureDir, findConfig, loadConfig } from "./utils.js";
7
+ import { VaultWriter } from "./vault-writer.js";
8
+ export const registerTools = (pi) => {
9
+ /* ── promote_wiki ── */
10
+ pi.registerTool({
11
+ name: "promote_wiki",
12
+ label: "Promote Wiki Entries",
13
+ description: "Promote entries from one collection to another via the pending queue for human approval.",
14
+ promptSnippet: 'promote_wiki(sourceCollection="research", targetCollection="main", entryIds=["id1"], reason="...")',
15
+ promptGuidelines: [
16
+ "Use when you find an insight in one collection that should be promoted to a more permanent one.",
17
+ 'Stages entries in "pending" for human review. Does NOT append directly to target.',
18
+ "Provide a clear reason why these entries are worth promoting.",
19
+ ],
20
+ parameters: Type.Object({
21
+ sourceCollection: Type.String({ description: "Collection where entries currently reside" }),
22
+ targetCollection: Type.String({ description: "Collection where entries should be moved" }),
23
+ entryIds: Type.Array(Type.String(), { description: "List of entry IDs to promote" }),
24
+ reason: Type.String({ description: "Reason for promoting these entries" }),
25
+ }),
26
+ async execute(_id, params, _signal, _onUpdate, ctx) {
27
+ const cfg = loadConfig(ctx.cwd);
28
+ const sourceDef = cfg.collections[params.sourceCollection];
29
+ const targetDef = cfg.collections[params.targetCollection];
30
+ const pendingDef = cfg.collections.pending;
31
+ if (!sourceDef)
32
+ return {
33
+ content: [
34
+ { type: "text", text: `Unknown source collection "${params.sourceCollection}".` },
35
+ ],
36
+ details: {},
37
+ };
38
+ if (!targetDef)
39
+ return {
40
+ content: [
41
+ { type: "text", text: `Unknown target collection "${params.targetCollection}".` },
42
+ ],
43
+ details: {},
44
+ };
45
+ if (!pendingDef)
46
+ return {
47
+ content: [{ type: "text", text: "Pending collection not configured. Run /wiki init." }],
48
+ details: {},
49
+ };
50
+ if (!fs.existsSync(sourceDef.path))
51
+ return {
52
+ content: [{ type: "text", text: `Source collection file missing at ${sourceDef.path}.` }],
53
+ details: {},
54
+ };
55
+ const lines = fs.readFileSync(sourceDef.path, "utf-8").split("\n").filter(Boolean);
56
+ const entries = lines
57
+ .map((l) => {
58
+ try {
59
+ return JSON.parse(l);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ })
65
+ .filter(Boolean);
66
+ const promotedIds = [];
67
+ for (const id of params.entryIds) {
68
+ const entry = entries.find((e) => e.id === id);
69
+ if (!entry)
70
+ continue;
71
+ const promotionEntry = {
72
+ ...entry,
73
+ _promotion_target: params.targetCollection,
74
+ _promotion_source: params.sourceCollection,
75
+ _promotion_reason: params.reason,
76
+ _promoted_at: new Date().toISOString(),
77
+ };
78
+ ensureDir(pendingDef.path);
79
+ fs.appendFileSync(pendingDef.path, `${JSON.stringify(promotionEntry)}\n`);
80
+ promotedIds.push(id);
81
+ }
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: promotedIds.length > 0
87
+ ? `Staged ${promotedIds.length} entry(ies) for promotion from "${params.sourceCollection}" to "${params.targetCollection}". Use /wiki approve to finalize.`
88
+ : `No matching entries found for IDs: ${params.entryIds.join(", ")}`,
89
+ },
90
+ ],
91
+ details: {},
92
+ };
93
+ },
94
+ });
95
+ /* ── wiki_search ── */
96
+ pi.registerTool({
97
+ name: "wiki_search",
98
+ label: "Wiki Semantic Search",
99
+ description: "Semantic vector search across indexed wiki collections using LanceDB.",
100
+ promptSnippet: 'wiki_search(collection="main", query="...", limit=5)',
101
+ promptGuidelines: [
102
+ "Use wiki_search for natural-language semantic queries.",
103
+ "For exact keyword/phrase matching, use wiki_fts_search instead.",
104
+ ],
105
+ parameters: Type.Object({
106
+ collection: Type.String({ description: "Collection name (e.g. main)", default: "main" }),
107
+ query: Type.String({ description: "Natural-language search query" }),
108
+ limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })),
109
+ }),
110
+ async execute(_id, params, _signal, _onUpdate, ctx) {
111
+ const cfg = loadConfig(ctx.cwd);
112
+ try {
113
+ const results = await searchHybrid(cfg.wiki.dataDir, params.collection, params.query, params.limit ?? 5, cfg.wiki);
114
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], details: {} };
115
+ }
116
+ catch (error) {
117
+ return { content: [{ type: "text", text: `Error: ${error.message}` }], details: {} };
118
+ }
119
+ },
120
+ });
121
+ /* ── wiki_fts_search ── */
122
+ pi.registerTool({
123
+ name: "wiki_fts_search",
124
+ label: "Wiki Full-Text Search",
125
+ description: "Full-text keyword search (Tantivy BM25) across indexed wiki collections.",
126
+ promptSnippet: 'wiki_fts_search(collection="main", query="login", limit=5)',
127
+ promptGuidelines: [
128
+ "Use for exact keyword/phrase matching. For natural-language queries, use wiki_search instead.",
129
+ ],
130
+ parameters: Type.Object({
131
+ collection: Type.String({ description: "Collection name (e.g. main)", default: "main" }),
132
+ query: Type.String({ description: "Keyword or phrase for full-text search" }),
133
+ limit: Type.Optional(Type.Number({ description: "Max results (default 5)" })),
134
+ }),
135
+ async execute(_id, params, _signal, _onUpdate, ctx) {
136
+ const cfg = loadConfig(ctx.cwd);
137
+ try {
138
+ const results = await searchFts(cfg.wiki.dataDir, params.collection, params.query, params.limit ?? 5, cfg.wiki);
139
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], details: {} };
140
+ }
141
+ catch (error) {
142
+ return { content: [{ type: "text", text: `Error: ${error.message}` }], details: {} };
143
+ }
144
+ },
145
+ });
146
+ /* ── wiki_graph_query ── */
147
+ pi.registerTool({
148
+ name: "wiki_graph_query",
149
+ label: "Wiki Graph Query",
150
+ description: "Traverse entity connections in the LanceDB graph tables.",
151
+ promptSnippet: 'wiki_graph_query(entity="Auth", depth=1)',
152
+ promptGuidelines: ["Use to find relations to a specific entity or concept."],
153
+ parameters: Type.Object({
154
+ entity: Type.String({ description: "Entity to search for relations" }),
155
+ depth: Type.Optional(Type.Number({ description: "Depth of traversal (default 1)" })),
156
+ }),
157
+ async execute(_id, params, _signal, _onUpdate, ctx) {
158
+ const cfg = loadConfig(ctx.cwd);
159
+ try {
160
+ const results = await queryGraph(cfg.wiki.dataDir, cfg.wiki, params.entity, params.depth ?? 1);
161
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], details: {} };
162
+ }
163
+ catch (error) {
164
+ return { content: [{ type: "text", text: `Error: ${error.message}` }], details: {} };
165
+ }
166
+ },
167
+ });
168
+ /* ── wiki_sync ── */
169
+ pi.registerTool({
170
+ name: "wiki_sync",
171
+ label: "Wiki Sync to Vault",
172
+ description: "Push matching wiki entries to an Obsidian vault as markdown pages or canvas graph files.",
173
+ promptSnippet: 'wiki_sync(vault="default", query="tag:decision", collection="main")',
174
+ promptGuidelines: [
175
+ "Use to push agent knowledge to a human-readable Obsidian vault.",
176
+ 'Omit vault to use the configured default vault. Use vault="*" for all configured vaults.',
177
+ 'format="markdown" writes individual .md files. format="canvas" generates a .canvas graph file from entity relations.',
178
+ ],
179
+ parameters: Type.Object({
180
+ vault: Type.Optional(Type.String({
181
+ description: 'Vault name (defaults to "default"). Use "*" for all configured vaults.',
182
+ })),
183
+ query: Type.Optional(Type.String({ description: "Free-text search to select entries to sync" })),
184
+ tag: Type.Optional(Type.String({ description: "Sync entries matching this tag" })),
185
+ collection: Type.Optional(Type.String({ description: "Collection to sync from (default: main)" })),
186
+ format: Type.Optional(Type.Union([Type.Literal("markdown"), Type.Literal("canvas")], {
187
+ description: "Output format (default: markdown)",
188
+ })),
189
+ limit: Type.Optional(Type.Number({ description: "Max entries (default 20)" })),
190
+ }),
191
+ async execute(_id, params, _signal, _onUpdate, ctx) {
192
+ const cfg = loadConfig(ctx.cwd);
193
+ if (!cfg.wiki.vaults || Object.keys(cfg.wiki.vaults).length === 0) {
194
+ return {
195
+ content: [
196
+ { type: "text", text: "No vaults configured. Add vaults to wiki.vaults in config." },
197
+ ],
198
+ details: {},
199
+ };
200
+ }
201
+ const vaultNames = params.vault === "*" ? Object.keys(cfg.wiki.vaults) : [params.vault || "default"];
202
+ const results = [];
203
+ for (const vaultName of vaultNames) {
204
+ const vaultCfg = cfg.wiki.vaults[vaultName];
205
+ if (!vaultCfg) {
206
+ results.push(`⚠️ Vault "${vaultName}" not configured.`);
207
+ continue;
208
+ }
209
+ try {
210
+ const writer = new VaultWriter(vaultCfg.path, cfg.wiki);
211
+ if (params.format === "canvas") {
212
+ const count = await writer.syncGraphToCanvas();
213
+ results.push(`✅ Vault "${vaultName}": canvas graph synced (${count} edges)`);
214
+ }
215
+ else {
216
+ const collection = params.collection || "main";
217
+ const query = params.query || (params.tag ? `tag:${params.tag}` : "");
218
+ const count = await writer.syncEntries(collection, query, params.limit ?? 20);
219
+ results.push(`✅ Vault "${vaultName}": ${count} entries synced to Agent/Inbox/`);
220
+ }
221
+ }
222
+ catch (err) {
223
+ results.push(`❌ Vault "${vaultName}": ${err.message}`);
224
+ }
225
+ }
226
+ return { content: [{ type: "text", text: results.join("\n") }], details: {} };
227
+ },
228
+ });
229
+ /* ── query_wiki ── */
230
+ pi.registerTool({
231
+ name: "query_wiki",
232
+ label: "Query Wiki",
233
+ description: "Deterministic JSONL search by collection name. Use for exact lookups and filtered retrieval.",
234
+ promptSnippet: 'query_wiki(collection="main", query="...", filters={key: "value"})',
235
+ promptGuidelines: [
236
+ "Use when you know the collection name and need exact matches.",
237
+ "query does substring search across all text fields; filters does exact key-value matching.",
238
+ "If the collection is missing, run /wiki init first.",
239
+ ],
240
+ parameters: Type.Object({
241
+ collection: Type.String({ description: "Collection name (e.g. main, pending)" }),
242
+ query: Type.Optional(Type.String({ description: "Free-text substring search across all text fields" })),
243
+ filters: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Exact {field: value} matches" })),
244
+ }),
245
+ async execute(_id, params, _signal, _onUpdate, ctx) {
246
+ const cfg = loadConfig(ctx.cwd);
247
+ const def = cfg.collections[params.collection];
248
+ if (!def)
249
+ return {
250
+ content: [
251
+ {
252
+ type: "text",
253
+ text: `Unknown collection "${params.collection}". Available: ${collectionNames(cfg).join(", ")}`,
254
+ },
255
+ ],
256
+ details: {},
257
+ };
258
+ if (!fs.existsSync(def.path))
259
+ return {
260
+ content: [
261
+ {
262
+ type: "text",
263
+ text: `Collection "${params.collection}" not found at ${def.path}. Run /wiki init.`,
264
+ },
265
+ ],
266
+ details: {},
267
+ };
268
+ const lines = fs.readFileSync(def.path, "utf-8").split("\n").filter(Boolean);
269
+ const results = [];
270
+ for (const line of lines) {
271
+ try {
272
+ const entry = JSON.parse(line);
273
+ let match = true;
274
+ if (params.query) {
275
+ const text = def.schema
276
+ .map((f) => entry[f] ?? "")
277
+ .join(" ")
278
+ .toLowerCase();
279
+ match = text.includes(params.query.toLowerCase());
280
+ }
281
+ if (match && params.filters) {
282
+ for (const [k, v] of Object.entries(params.filters)) {
283
+ if (String(entry[k] ?? "") !== String(v)) {
284
+ match = false;
285
+ break;
286
+ }
287
+ }
288
+ }
289
+ if (match)
290
+ results.push(entry);
291
+ }
292
+ catch {
293
+ /* ignore malformed */
294
+ }
295
+ }
296
+ return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }], details: {} };
297
+ },
298
+ });
299
+ /* ── append_wiki ── */
300
+ pi.registerTool({
301
+ name: "append_wiki",
302
+ label: "Append Wiki Entry",
303
+ description: "Append to a wiki collection. Autopilot dual-writes to JSONL + LanceDB with optional auto-sync to vault.",
304
+ promptSnippet: 'append_wiki(collection="main", mode="autopilot", entry={...})',
305
+ promptGuidelines: [
306
+ "Use autopilot for everyday notes — deduplicates automatically.",
307
+ "Use gated mode when the fact needs human review (queues in pending).",
308
+ "Use strict mode only for the most sensitive facts requiring explicit user confirmation.",
309
+ "Entry keys must match the collection schema. Call describe_wiki first if unsure.",
310
+ 'When the user says "Remember: [fact]", automatically use append_wiki to store it.',
311
+ 'Set sync="auto" to push substantial entries to configured vault(s).',
312
+ ],
313
+ parameters: Type.Object({
314
+ collection: Type.String({
315
+ description: "Target collection name (call describe_wiki if unsure)",
316
+ }),
317
+ mode: Type.Union([Type.Literal("strict"), Type.Literal("gated"), Type.Literal("autopilot")]),
318
+ entry: Type.Record(Type.String(), Type.String(), {
319
+ description: "Field-key → value map matching the collection schema",
320
+ }),
321
+ sync: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("manual"), Type.Literal("none")], {
322
+ description: "Vault sync mode (default: auto)",
323
+ })),
324
+ vault: Type.Optional(Type.String({ description: "Specific vault to sync to (default: all configured)" })),
325
+ }),
326
+ async execute(_id, params, _signal, _onUpdate, ctx) {
327
+ const cfg = loadConfig(ctx.cwd);
328
+ const def = cfg.collections[params.collection];
329
+ if (!def)
330
+ return {
331
+ content: [
332
+ {
333
+ type: "text",
334
+ text: `Unknown collection "${params.collection}". Available: ${collectionNames(cfg).join(", ")}`,
335
+ },
336
+ ],
337
+ details: {},
338
+ };
339
+ const line = `${JSON.stringify(params.entry)}\n`;
340
+ if (params.mode === "strict") {
341
+ const ok = await ctx.ui.confirm("Strict Mode", `Approve entry for "${params.collection}"?\n\n${JSON.stringify(params.entry, null, 2)}`);
342
+ if (ok) {
343
+ ensureDir(def.path);
344
+ fs.appendFileSync(def.path, line);
345
+ return {
346
+ content: [{ type: "text", text: `Appended to "${params.collection}".` }],
347
+ details: {},
348
+ };
349
+ }
350
+ return { content: [{ type: "text", text: "Entry rejected." }], details: {} };
351
+ }
352
+ if (params.mode === "gated") {
353
+ const pending = cfg.collections.pending || def;
354
+ ensureDir(pending.path);
355
+ fs.appendFileSync(pending.path, line);
356
+ return { content: [{ type: "text", text: `Queued for "${pending.path}".` }], details: {} };
357
+ }
358
+ // autopilot
359
+ if (def.dedupField && fs.existsSync(def.path)) {
360
+ try {
361
+ const results = await searchHybrid(cfg.wiki.dataDir, params.collection, params.entry[def.dedupField] || "", 1, cfg.wiki);
362
+ if (results.length > 0 &&
363
+ results[0][def.dedupField] === params.entry[def.dedupField]) {
364
+ return {
365
+ content: [
366
+ { type: "text", text: `Duplicate detected via "${def.dedupField}". Skipped.` },
367
+ ],
368
+ details: {},
369
+ };
370
+ }
371
+ }
372
+ catch {
373
+ /* ignore */
374
+ }
375
+ }
376
+ ensureDir(def.path);
377
+ fs.appendFileSync(def.path, line);
378
+ try {
379
+ await upsertEntry(cfg.wiki.dataDir, params.collection, params.entry, cfg.wiki);
380
+ if (cfg.wiki.graph?.enabled) {
381
+ await graphUpsert(cfg.wiki.dataDir, cfg.wiki, params.entry);
382
+ }
383
+ // Auto-sync to vault if enabled
384
+ const syncMode = params.sync ?? "auto";
385
+ if (syncMode !== "none" && cfg.wiki.vaults && Object.keys(cfg.wiki.vaults).length > 0) {
386
+ const entry = params.entry;
387
+ const shouldSync = syncMode === "auto" || syncMode === "manual";
388
+ const isSubstantial = (entry.fact || "").length > 200;
389
+ const forceTag = entry.tag && ["decision", "insight", "requirement"].includes(entry.tag);
390
+ if (shouldSync && (isSubstantial || forceTag)) {
391
+ const vaultNames = params.vault ? [params.vault] : Object.keys(cfg.wiki.vaults);
392
+ for (const vn of vaultNames) {
393
+ const vc = cfg.wiki.vaults[vn];
394
+ if (!vc)
395
+ continue;
396
+ try {
397
+ const writer = new VaultWriter(vc.path, cfg.wiki);
398
+ await writer.writeEntry(params.collection, entry);
399
+ }
400
+ catch {
401
+ /* vault write is best-effort */
402
+ }
403
+ }
404
+ }
405
+ // Auto-sync canvas graph if enabled
406
+ if (cfg.wiki.graph?.canvasSync &&
407
+ cfg.wiki.graph?.enabled &&
408
+ cfg.wiki.vaults &&
409
+ Object.keys(cfg.wiki.vaults).length > 0) {
410
+ for (const vn of params.vault ? [params.vault] : Object.keys(cfg.wiki.vaults)) {
411
+ const vc = cfg.wiki.vaults[vn];
412
+ if (!vc)
413
+ continue;
414
+ try {
415
+ const writer = new VaultWriter(vc.path, cfg.wiki);
416
+ await writer.syncGraphToCanvas();
417
+ }
418
+ catch {
419
+ /* canvas sync is best-effort */
420
+ }
421
+ }
422
+ }
423
+ }
424
+ }
425
+ catch (error) {
426
+ return {
427
+ content: [
428
+ {
429
+ type: "text",
430
+ text: `Appended to "${params.collection}" (autopilot) but index failed: ${error.message}`,
431
+ },
432
+ ],
433
+ details: {},
434
+ };
435
+ }
436
+ return {
437
+ content: [{ type: "text", text: `Appended to "${params.collection}" (autopilot).` }],
438
+ details: {},
439
+ };
440
+ },
441
+ });
442
+ /* ── configure_wiki ── */
443
+ pi.registerTool({
444
+ name: "configure_wiki",
445
+ label: "Configure Wiki",
446
+ description: "Read or update the pi-vault-mind config at runtime.",
447
+ promptSnippet: 'configure_wiki(action="read")',
448
+ promptGuidelines: [
449
+ 'Use configure_wiki(action="read") to inspect current config.',
450
+ 'Use configure_wiki(action="update", config={...}) to change schema, add collections, or modify vaults.',
451
+ ],
452
+ parameters: Type.Object({
453
+ action: Type.Union([Type.Literal("read"), Type.Literal("update")]),
454
+ config: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Partial config object to merge" })),
455
+ }),
456
+ async execute(_id, params, _signal, _onUpdate, ctx) {
457
+ const cfgPath = findConfig(ctx.cwd).project || path.join(ctx.cwd, "pi-vault-mind.config.json");
458
+ if (params.action === "read" || !params.config) {
459
+ const cfg = loadConfig(ctx.cwd);
460
+ return { content: [{ type: "text", text: JSON.stringify(cfg, null, 2) }], details: {} };
461
+ }
462
+ let existing = {};
463
+ if (fs.existsSync(cfgPath)) {
464
+ try {
465
+ existing = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
466
+ }
467
+ catch {
468
+ /* fresh */
469
+ }
470
+ }
471
+ const merged = { ...existing, ...params.config };
472
+ ensureDir(cfgPath);
473
+ fs.writeFileSync(cfgPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
474
+ ctx.ui.notify?.(`Config updated: ${cfgPath}`, "info");
475
+ return { content: [{ type: "text", text: JSON.stringify(merged, null, 2) }], details: {} };
476
+ },
477
+ });
478
+ /* ── describe_wiki ── */
479
+ pi.registerTool({
480
+ name: "describe_wiki",
481
+ label: "Describe Wiki",
482
+ description: "Introspect a named collection: schema, entry count, and sample entries.",
483
+ promptSnippet: 'describe_wiki(collection="main")',
484
+ promptGuidelines: [
485
+ "Call before append_wiki if you are unsure what fields the collection expects.",
486
+ ],
487
+ parameters: Type.Object({
488
+ collection: Type.String({ description: "Collection name to inspect" }),
489
+ }),
490
+ async execute(_id, params, _signal, _onUpdate, ctx) {
491
+ const cfg = loadConfig(ctx.cwd);
492
+ const def = cfg.collections[params.collection];
493
+ if (!def)
494
+ return {
495
+ content: [
496
+ {
497
+ type: "text",
498
+ text: `Unknown collection "${params.collection}". Available: ${collectionNames(cfg).join(", ")}`,
499
+ },
500
+ ],
501
+ details: {},
502
+ };
503
+ if (!fs.existsSync(def.path))
504
+ return {
505
+ content: [
506
+ {
507
+ type: "text",
508
+ text: `Collection "${params.collection}" not found at ${def.path}. Run /wiki init.`,
509
+ },
510
+ ],
511
+ details: {},
512
+ };
513
+ const lines = fs.readFileSync(def.path, "utf-8").split("\n").filter(Boolean);
514
+ const total = lines.length;
515
+ let first = null;
516
+ let last = null;
517
+ let malformed = 0;
518
+ for (const line of lines) {
519
+ try {
520
+ const e = JSON.parse(line);
521
+ if (!first)
522
+ first = e;
523
+ last = e;
524
+ }
525
+ catch {
526
+ malformed++;
527
+ }
528
+ }
529
+ const report = [
530
+ `Collection: "${params.collection}"`,
531
+ `Path: ${def.path}`,
532
+ `Schema: [${def.schema.join(", ")}]`,
533
+ `Total entries: ${total}`,
534
+ malformed ? `Malformed lines: ${malformed}` : "",
535
+ `DedupField: ${def.dedupField ?? "(none)"}`,
536
+ "",
537
+ "First entry:",
538
+ JSON.stringify(first, null, 2),
539
+ "",
540
+ "Most recent entry:",
541
+ JSON.stringify(last, null, 2),
542
+ ]
543
+ .filter(Boolean)
544
+ .join("\n");
545
+ return { content: [{ type: "text", text: report }], details: {} };
546
+ },
547
+ });
548
+ /* ── wiki_stats ── */
549
+ pi.registerTool({
550
+ name: "wiki_stats",
551
+ label: "Wiki Stats",
552
+ description: "Dashboard of all collections: counts, sizes, pending queue size, and LanceDB health.",
553
+ promptSnippet: "wiki_stats()",
554
+ promptGuidelines: ["Call for a quick overview before writing or after a batch of appends."],
555
+ parameters: Type.Object({}),
556
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
557
+ const cfg = loadConfig(ctx.cwd);
558
+ const lines = [
559
+ "pi-vault-mind Stats",
560
+ `Config path: project=${findConfig(ctx.cwd).project || "(none)"}, global=${findConfig(ctx.cwd).global || "(none)"}`,
561
+ "",
562
+ `LanceDB DataDir: ${cfg.wiki.dataDir}`,
563
+ `Injectors: ${cfg.injectors.length}`,
564
+ "",
565
+ ];
566
+ for (const [name, def] of Object.entries(cfg.collections)) {
567
+ let count = 0;
568
+ let malformed = 0;
569
+ if (fs.existsSync(def.path)) {
570
+ const data = fs.readFileSync(def.path, "utf-8").split("\n").filter(Boolean);
571
+ for (const line of data) {
572
+ try {
573
+ JSON.parse(line);
574
+ count++;
575
+ }
576
+ catch {
577
+ malformed++;
578
+ }
579
+ }
580
+ }
581
+ const size = fs.existsSync(def.path) ? `${fs.statSync(def.path).size} bytes` : "missing";
582
+ lines.push(` "${name}"`, ` → ${def.path}`, ` Schema: [${def.schema.join(", ")}]`, ` Entries: ${count} entries, ${size}${malformed ? `, ${malformed} malformed` : ""}`);
583
+ if (def.dedupField)
584
+ lines.push(` DedupField: ${def.dedupField}`);
585
+ }
586
+ return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
587
+ },
588
+ });
589
+ /* ── wiki_export ── */
590
+ pi.registerTool({
591
+ name: "wiki_export",
592
+ label: "Wiki Export",
593
+ description: "Export a collection to JSON, CSV, or Markdown.",
594
+ promptSnippet: 'wiki_export(collection="main", format="json")',
595
+ promptGuidelines: [
596
+ "Use to share, archive, or import wiki data elsewhere. Schema keys become column headers for CSV/Markdown.",
597
+ ],
598
+ parameters: Type.Object({
599
+ collection: Type.String({ description: "Collection name to export" }),
600
+ format: Type.Union([Type.Literal("json"), Type.Literal("csv"), Type.Literal("markdown")], {
601
+ description: "Export format (default json)",
602
+ }),
603
+ }),
604
+ async execute(_id, params, _signal, _onUpdate, ctx) {
605
+ const cfg = loadConfig(ctx.cwd);
606
+ const def = cfg.collections[params.collection];
607
+ if (!def)
608
+ return {
609
+ content: [{ type: "text", text: `Unknown collection "${params.collection}".` }],
610
+ details: {},
611
+ };
612
+ if (!fs.existsSync(def.path))
613
+ return {
614
+ content: [{ type: "text", text: `Collection "${params.collection}" not found.` }],
615
+ details: {},
616
+ };
617
+ const lines = fs.readFileSync(def.path, "utf-8").split("\n").filter(Boolean);
618
+ const entries = [];
619
+ for (const line of lines) {
620
+ try {
621
+ entries.push(JSON.parse(line));
622
+ }
623
+ catch {
624
+ /* skip malformed */
625
+ }
626
+ }
627
+ let text = "";
628
+ const format = params.format || "json";
629
+ if (format === "json") {
630
+ text = JSON.stringify(entries, null, 2);
631
+ }
632
+ else {
633
+ const schemaKeys = def.schema.length
634
+ ? def.schema
635
+ : entries[0]
636
+ ? Object.keys(entries[0])
637
+ : [];
638
+ const escCsv = (s) => `"${String(s ?? "").replace(/"/g, '""')}"`;
639
+ if (format === "csv") {
640
+ text = [
641
+ schemaKeys.map(escCsv).join(","),
642
+ ...entries.map((e) => schemaKeys.map((k) => escCsv(e[k])).join(",")),
643
+ ].join("\n");
644
+ }
645
+ else {
646
+ const esc = (s) => String(s ?? "")
647
+ .replace(/\|/g, "\\|")
648
+ .replace(/\n/g, " ");
649
+ text = [
650
+ `| ${schemaKeys.join(" | ")} |`,
651
+ `| ${schemaKeys.map(() => "---").join(" | ")} |`,
652
+ ...entries.map((e) => `| ${schemaKeys.map((k) => esc(e[k])).join(" | ")} |`),
653
+ ].join("\n");
654
+ }
655
+ }
656
+ return { content: [{ type: "text", text }], details: {} };
657
+ },
658
+ });
659
+ /* ── wiki_ingest ── */
660
+ pi.registerTool({
661
+ name: "wiki_ingest",
662
+ label: "Wiki Ingest",
663
+ description: "Ingest documents (URLs, HTML, PDFs) into the wiki. Converts to clean markdown via any2md, then returns the text for entity extraction and append_wiki.",
664
+ promptSnippet: 'wiki_ingest(source="https://arxiv.org/abs/...", collection="main")',
665
+ promptGuidelines: [
666
+ "Use to convert external documents into wiki-ready markdown.",
667
+ "Accepts URLs (https://...), local file paths, or raw text blobs.",
668
+ "When source is a URL, calls any2md to fetch and convert to markdown.",
669
+ "When source is a file path, reads the file directly (use any2md via bash for PDFs/DOCX).",
670
+ "Returns the converted markdown — you then call append_wiki for extracted facts.",
671
+ ],
672
+ parameters: Type.Object({
673
+ source: Type.String({ description: "URL, file path, or raw text to ingest" }),
674
+ sourceType: Type.Optional(Type.Union([Type.Literal("url"), Type.Literal("file"), Type.Literal("text")], {
675
+ description: "Source type (auto-detected if omitted)",
676
+ })),
677
+ }),
678
+ async execute(_id, params, _signal, _onUpdate, ctx) {
679
+ const source = params.source;
680
+ let sourceType = params.sourceType;
681
+ // Auto-detect source type
682
+ if (!sourceType) {
683
+ if (source.startsWith("http://") || source.startsWith("https://")) {
684
+ sourceType = "url";
685
+ }
686
+ else if (fs.existsSync(source)) {
687
+ sourceType = "file";
688
+ }
689
+ else {
690
+ sourceType = "text";
691
+ }
692
+ }
693
+ let markdown = "";
694
+ try {
695
+ if (sourceType === "url") {
696
+ // Use any2md via npx to fetch and convert
697
+ const cwd = path.dirname(source) === "." ? ctx.cwd : ctx.cwd;
698
+ try {
699
+ const result = await pi.exec("npx", ["-y", "any2md", source], { cwd, timeout: 60000 });
700
+ markdown = result.stdout || result.stderr || "";
701
+ if (!markdown.trim()) {
702
+ return {
703
+ content: [{ type: "text", text: `any2md returned empty output for ${source}` }],
704
+ details: {},
705
+ };
706
+ }
707
+ }
708
+ catch (err) {
709
+ return {
710
+ content: [
711
+ {
712
+ type: "text",
713
+ text: `any2md failed: ${err.message || err}. Try installing: npm install -g any2md`,
714
+ },
715
+ ],
716
+ details: {},
717
+ };
718
+ }
719
+ }
720
+ else if (sourceType === "file") {
721
+ // Read the file directly
722
+ try {
723
+ markdown = fs.readFileSync(source, "utf-8");
724
+ }
725
+ catch (err) {
726
+ return {
727
+ content: [{ type: "text", text: `Cannot read file: ${err.message}` }],
728
+ details: {},
729
+ };
730
+ }
731
+ }
732
+ else {
733
+ // Raw text — passthrough
734
+ markdown = source;
735
+ }
736
+ const lines = markdown.split("\n");
737
+ const preview = lines.slice(0, 20).join("\n");
738
+ const truncated = lines.length > 20 ? markdown : markdown;
739
+ return {
740
+ content: [
741
+ {
742
+ type: "text",
743
+ text: `Ingested from ${sourceType}: ${source}\nLines: ${lines.length}\n\n--- PREVIEW (first 20 lines) ---\n${preview}${lines.length > 20 ? "\n... (truncated, full text available via read)" : ""}\n\n--- FULL TEXT ---\n${truncated}`,
744
+ },
745
+ ],
746
+ details: { sourceType, source, lineCount: lines.length },
747
+ };
748
+ }
749
+ catch (error) {
750
+ return { content: [{ type: "text", text: `Ingest error: ${error.message}` }], details: {} };
751
+ }
752
+ },
753
+ });
754
+ /* ── wiki_status ── */
755
+ pi.registerTool({
756
+ name: "wiki_status",
757
+ label: "Wiki Status",
758
+ description: "Show LanceDB status: data directory and tables.",
759
+ promptSnippet: "wiki_status()",
760
+ parameters: Type.Object({}),
761
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
762
+ const cfg = loadConfig(ctx.cwd);
763
+ try {
764
+ const status = await getStatus(cfg.wiki.dataDir);
765
+ return { content: [{ type: "text", text: JSON.stringify(status, null, 2) }], details: {} };
766
+ }
767
+ catch (error) {
768
+ return { content: [{ type: "text", text: `Error: ${error.message}` }], details: {} };
769
+ }
770
+ },
771
+ });
772
+ };