@zosmaai/pi-llm-wiki 0.11.2 → 0.11.4

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 (43) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.de.md +2 -1
  3. package/README.es.md +2 -1
  4. package/README.fr.md +2 -1
  5. package/README.hi.md +2 -1
  6. package/README.ja.md +2 -1
  7. package/README.ko.md +2 -1
  8. package/README.md +89 -10
  9. package/README.pt.md +2 -1
  10. package/README.ru.md +2 -1
  11. package/README.zh.md +2 -1
  12. package/commands/wiki-digest.md +28 -0
  13. package/commands/wiki-discover.md +30 -0
  14. package/commands/wiki-ingest.md +36 -0
  15. package/commands/wiki-init.md +30 -0
  16. package/commands/wiki-lint.md +25 -0
  17. package/commands/wiki-query.md +37 -0
  18. package/commands/wiki-record.md +36 -0
  19. package/commands/wiki-req.md +55 -0
  20. package/commands/wiki-retro.md +34 -0
  21. package/commands/wiki-run.md +31 -0
  22. package/commands/wiki-skills.md +26 -0
  23. package/commands/wiki-status.md +16 -0
  24. package/dist/extensions/llm-wiki/lib/host.js +97 -0
  25. package/dist/extensions/llm-wiki/lib/observation.js +9 -0
  26. package/dist/extensions/llm-wiki/lib/runtime.js +13 -1
  27. package/dist/extensions/llm-wiki/lib/task-config.js +74 -49
  28. package/dist/extensions/llm-wiki/lib/tools.js +10 -7
  29. package/dist/extensions/llm-wiki/lib/utils.js +59 -16
  30. package/dist/mcp/index.js +55 -3
  31. package/dist/mcp/operations.js +39 -3
  32. package/docs/api.md +9 -1
  33. package/docs/configuration.md +49 -8
  34. package/extensions/llm-wiki/index.ts +44 -6
  35. package/extensions/llm-wiki/lib/host.ts +125 -0
  36. package/extensions/llm-wiki/lib/observation.ts +14 -1
  37. package/extensions/llm-wiki/lib/runtime.ts +13 -1
  38. package/extensions/llm-wiki/lib/task-config.ts +115 -55
  39. package/extensions/llm-wiki/lib/tools.ts +10 -7
  40. package/extensions/llm-wiki/lib/utils.ts +55 -14
  41. package/mcp/index.ts +65 -2
  42. package/mcp/operations.ts +47 -4
  43. package/package.json +12 -1
@@ -1,6 +1,13 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
2
+ import { dirname } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import {
5
+ type HostKind,
6
+ detectHost,
7
+ listGlobalSettingsFiles,
8
+ listProjectSettingsFiles,
9
+ resolveProjectSettingsPath,
10
+ } from "./host.js";
4
11
 
5
12
  /**
6
13
  * Configuration for the background-task lane (issue #64, part of #63).
@@ -12,8 +19,11 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
12
19
  *
13
20
  * Resolution order (later wins):
14
21
  * 1. built-in DEFAULTS
15
- * 2. global settings: <agentDir>/settings.json → { "llm-wiki": { ... } }
16
- * 3. project settings: <cwd>/.pi/settings.json → { "llm-wiki": { ... } }
22
+ * 2. global settings: <agentDir>/{settings.json,config.yml}
23
+ * 3. project settings: <cwd>/{.pi,.omp}/{settings.json,config.yml}
24
+ *
25
+ * Both host layouts are read (see ./host.ts): pi uses `.pi`, oh-my-pi uses
26
+ * `.omp`, and each file is keyed by the namespaced `llm-wiki` section.
17
27
  *
18
28
  * When `taskModel` is unset, the background lane falls back to the session
19
29
  * model (see Runtime.resolveModel), so the feature is zero-config by default.
@@ -93,6 +103,27 @@ export interface TaskConfig {
93
103
  */
94
104
  notices?: boolean;
95
105
 
106
+ /**
107
+ * Let the PERSONAL wiki act as this project's ambient vault when the project
108
+ * has no wiki of its own.
109
+ *
110
+ * Ambient surfaces are the ones that fire without the user asking: the
111
+ * session notice, the periodic observe/retro reminder, and `before_agent_start`
112
+ * recall injection. `resolveVaultRoot` falls back to the personal vault when
113
+ * a project has none, so with this on those surfaces speak up in EVERY
114
+ * directory once a personal vault exists — injecting reminders and unrelated
115
+ * cross-project recall hits into repositories where no wiki was initialized.
116
+ *
117
+ * Host-dependent default, because the two hosts disagree on what silence
118
+ * means for a globally installed plugin:
119
+ * - pi → `true` (historical behavior, unchanged)
120
+ * - omp → `false` (a repository without a wiki stays quiet)
121
+ *
122
+ * The wiki TOOLS are registered either way, so `/wiki-init` and
123
+ * `wiki_bootstrap` always work; only the unprompted injections are gated.
124
+ */
125
+ ambientPersonalVault?: boolean;
126
+
96
127
  /**
97
128
  * Agent-trajectory working-memory (capture → distill → recall), issue #80.
98
129
  * OPT-IN, default OFF: only an explicit `trajectories: true` enables it.
@@ -120,6 +151,19 @@ export function noticesEnabled(config: TaskConfig | undefined): boolean {
120
151
  return config?.notices !== false;
121
152
  }
122
153
 
154
+ /**
155
+ * Resolve whether the personal vault may serve as this project's ambient
156
+ * vault. Explicit `ambientPersonalVault` wins; otherwise the host decides
157
+ * (see the field docs on {@link TaskConfig.ambientPersonalVault}).
158
+ */
159
+ export function personalVaultIsAmbient(
160
+ config: TaskConfig | undefined,
161
+ host: HostKind = detectHost(),
162
+ ): boolean {
163
+ if (typeof config?.ambientPersonalVault === "boolean") return config.ambientPersonalVault;
164
+ return host === "pi";
165
+ }
166
+
123
167
  /**
124
168
  * Resolve whether agent-trajectory working-memory is enabled (issue #80).
125
169
  * INVERSE polarity of `noticesEnabled`: defaults to `false`; only an explicit
@@ -180,6 +224,10 @@ function readNamespacedConfig(path: string): Partial<TaskConfig> {
180
224
  out.notices = section.notices;
181
225
  }
182
226
 
227
+ if (typeof section.ambientPersonalVault === "boolean") {
228
+ out.ambientPersonalVault = section.ambientPersonalVault;
229
+ }
230
+
183
231
  if (typeof section.trajectories === "boolean") {
184
232
  out.trajectories = section.trajectories;
185
233
  }
@@ -235,14 +283,20 @@ export function validateSynthesisLanguage(tag: string): string | undefined {
235
283
  }
236
284
 
237
285
  /**
238
- * Read a settings JSON file as a plain object, or `{}` when it is absent or
286
+ * Read a settings file as a plain object, or `{}` when it is absent or
239
287
  * corrupt. Reads directly (no `existsSync` pre-check) so there is no
240
288
  * check-then-use race: a missing file throws ENOENT, which the catch treats
241
289
  * the same as an empty file.
290
+ *
291
+ * `config.yml` / `config.yaml` are parsed as YAML — that is the format oh-my-pi
292
+ * migrates its settings to. Everything else is JSON. JSON is a YAML subset, so
293
+ * the YAML parser also accepts a `.yml` file that actually holds JSON.
242
294
  */
243
295
  function readSettingsObject(path: string): Record<string, unknown> {
244
296
  try {
245
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
297
+ const text = readFileSync(path, "utf-8");
298
+ const parsed =
299
+ path.endsWith(".yml") || path.endsWith(".yaml") ? parseYaml(text) : JSON.parse(text);
246
300
  if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
247
301
  } catch {
248
302
  // Missing or corrupt settings file: start from an empty object.
@@ -251,30 +305,25 @@ function readSettingsObject(path: string): Record<string, unknown> {
251
305
  }
252
306
 
253
307
  /**
254
- * Persist (or clear) the wiki background `taskModel` in the PROJECT settings
255
- * file `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue
256
- * #69). Project settings win over global in `loadTaskConfig`, so this takes
257
- * effect immediately on the next config load. Other top-level keys and other
258
- * `llm-wiki` settings are preserved; passing `undefined` removes the key
259
- * (reverting to the session model).
308
+ * Rewrite the `llm-wiki` section of the project settings file, preserving every
309
+ * other top-level key and every other setting in the section.
310
+ *
311
+ * The target file is chosen by `resolveProjectSettingsPath` `.pi/settings.json`
312
+ * or `.omp/settings.json` depending on host and on what already exists — and is
313
+ * always JSON, which both hosts read.
260
314
  */
261
- export function persistTaskModel(
315
+ function updateProjectSection(
262
316
  cwd: string,
263
- model: { provider: string; id: string } | undefined,
317
+ mutate: (section: Record<string, unknown>) => void,
264
318
  ): void {
265
- const settingsPath = join(cwd, ".pi", "settings.json");
319
+ const settingsPath = resolveProjectSettingsPath(cwd);
266
320
  const raw = readSettingsObject(settingsPath);
267
321
 
268
322
  const existing = raw[SETTINGS_KEY];
269
323
  const section: Record<string, unknown> =
270
324
  existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
271
325
 
272
- if (model) {
273
- section.taskModel = { provider: model.provider, id: model.id };
274
- } else {
275
- // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key (vs setting undefined) keeps the JSON clean
276
- delete section.taskModel;
277
- }
326
+ mutate(section);
278
327
  raw[SETTINGS_KEY] = section;
279
328
 
280
329
  mkdirSync(dirname(settingsPath), { recursive: true });
@@ -282,44 +331,55 @@ export function persistTaskModel(
282
331
  }
283
332
 
284
333
  /**
285
- * Persist the agent-trajectory flag in the PROJECT settings file
286
- * `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue #80).
287
- * Mirrors `persistTaskModel`: project settings win in `loadTaskConfig`, other
288
- * keys are preserved. `true` writes `trajectories: true`; `false` removes the
289
- * key (reverting to the default-off behavior).
334
+ * Persist (or clear) the wiki background `taskModel` in the PROJECT settings
335
+ * file under the namespaced `llm-wiki` key (issue #69). Project settings win
336
+ * over global in `loadTaskConfig`, so this takes effect immediately on the next
337
+ * config load. Passing `undefined` removes the key (reverting to the session
338
+ * model).
290
339
  */
291
- export function persistTrajectoriesEnabled(cwd: string, enabled: boolean): void {
292
- const settingsPath = join(cwd, ".pi", "settings.json");
293
- const raw = readSettingsObject(settingsPath);
294
-
295
- const existing = raw[SETTINGS_KEY];
296
- const section: Record<string, unknown> =
297
- existing && typeof existing === "object" ? { ...(existing as Record<string, unknown>) } : {};
298
-
299
- if (enabled) {
300
- section.trajectories = true;
301
- } else {
302
- // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key keeps the JSON clean (default is off)
303
- delete section.trajectories;
304
- }
305
- raw[SETTINGS_KEY] = section;
340
+ export function persistTaskModel(
341
+ cwd: string,
342
+ model: { provider: string; id: string } | undefined,
343
+ ): void {
344
+ updateProjectSection(cwd, (section) => {
345
+ if (model) {
346
+ section.taskModel = { provider: model.provider, id: model.id };
347
+ } else {
348
+ // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key (vs setting undefined) keeps the JSON clean
349
+ delete section.taskModel;
350
+ }
351
+ });
352
+ }
306
353
 
307
- mkdirSync(dirname(settingsPath), { recursive: true });
308
- writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
354
+ /**
355
+ * Persist the agent-trajectory flag in the PROJECT settings file under the
356
+ * namespaced `llm-wiki` key (issue #80). Mirrors `persistTaskModel`: `true`
357
+ * writes `trajectories: true`; `false` removes the key (reverting to the
358
+ * default-off behavior).
359
+ */
360
+ export function persistTrajectoriesEnabled(cwd: string, enabled: boolean): void {
361
+ updateProjectSection(cwd, (section) => {
362
+ if (enabled) {
363
+ section.trajectories = true;
364
+ } else {
365
+ // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key keeps the JSON clean (default is off)
366
+ delete section.trajectories;
367
+ }
368
+ });
309
369
  }
310
370
 
371
+ /**
372
+ * Merge the `llm-wiki` section from every settings file both hosts may use,
373
+ * lowest precedence first: built-in defaults, then user-level files, then
374
+ * project-level files. Absent files contribute nothing.
375
+ */
311
376
  export function loadTaskConfig(cwd: string): TaskConfig {
312
- let globalPath: string;
313
- try {
314
- globalPath = join(getAgentDir(), "settings.json");
315
- } catch {
316
- globalPath = "";
377
+ const config: TaskConfig = { ...TASK_DEFAULTS };
378
+ for (const path of listGlobalSettingsFiles()) {
379
+ Object.assign(config, readNamespacedConfig(path));
317
380
  }
318
- const projectPath = join(cwd, ".pi", "settings.json");
319
-
320
- return {
321
- ...TASK_DEFAULTS,
322
- ...(globalPath ? readNamespacedConfig(globalPath) : {}),
323
- ...readNamespacedConfig(projectPath),
324
- };
381
+ for (const path of listProjectSettingsFiles(cwd)) {
382
+ Object.assign(config, readNamespacedConfig(path));
383
+ }
384
+ return config;
325
385
  }
@@ -821,7 +821,7 @@ export function registerWikiLint(pi: ExtensionAPI, runtime?: Runtime): void {
821
821
  return dispatchReported(runtime, ctx as ToolCtx, {
822
822
  label: `lint:${paths.root}`,
823
823
  started:
824
- "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted when it completes.",
824
+ "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted with your next message.",
825
825
  work: async () => runWikiLint(paths, params.auto_fix === true),
826
826
  });
827
827
  },
@@ -962,11 +962,14 @@ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
962
962
  "",
963
963
  ].filter(Boolean);
964
964
  const reportPath = autoFix ? join(paths.outputs, `lint-${fmtDate()}.md`) : undefined;
965
+ // The gap snapshot is generated discovery metadata consumed by wiki_status:
966
+ // persist it on every successful lint so status never reports a stale count.
967
+ // Corrective actions below (report, event, meta rebuild) stay autoFix-only.
968
+ writeJson(join(paths.discoveries, "gaps.json"), {
969
+ gaps,
970
+ generated: new Date().toISOString(),
971
+ });
965
972
  if (autoFix && reportPath) {
966
- writeJson(join(paths.discoveries, "gaps.json"), {
967
- gaps,
968
- generated: new Date().toISOString(),
969
- });
970
973
  mkdirSync(paths.outputs, { recursive: true });
971
974
  writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf8");
972
975
  appendEvent(paths, {
@@ -1106,7 +1109,7 @@ export function registerWikiRebuildMeta(pi: ExtensionAPI, runtime?: Runtime): vo
1106
1109
  return dispatchReported(runtime, ctx as ToolCtx, {
1107
1110
  label: `rebuild_meta:${paths.root}`,
1108
1111
  started:
1109
- "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported when it completes.",
1112
+ "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported with your next message.",
1110
1113
  work: async () => {
1111
1114
  const result = rebuildMetadata(paths);
1112
1115
  // No rebuild_meta event — rebuild is a projection, not an authoritative mutation
@@ -1187,7 +1190,7 @@ export function registerWikiReindexEmbeddings(pi: ExtensionAPI, runtime?: Runtim
1187
1190
  // report the stats on completion (issue #77).
1188
1191
  return dispatchReported(runtime, ctx as ToolCtx, {
1189
1192
  label: `reindex_embeddings:${paths.root}`,
1190
- started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported when it completes.`,
1193
+ started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported with your next message.`,
1191
1194
  details: { enabled: true, model: embedder.model },
1192
1195
  work: async () => {
1193
1196
  const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true });
@@ -120,21 +120,45 @@ export function migrateDoubledPersonalVault(
120
120
  /**
121
121
  * Check if a vault is the personal wiki location.
122
122
  * Used in layered recall to avoid double-counting.
123
+ *
124
+ * Compares PHYSICAL paths, not strings. On image-based ("atomic") Linux
125
+ * distributions `/home` is a symlink to `var/home`, so `homedir()` yields the
126
+ * `$HOME` string (`/home/u`) while `process.cwd()` — and therefore the root
127
+ * `resolveVaultRoot()` walks up to — yields `/var/home/u`. A string compare
128
+ * calls the personal vault a project vault, which makes layered recall search
129
+ * the same vault twice and `vaultPageCount()` double-count it.
130
+ *
131
+ * Exact equality, NOT containment: a vault nested under the home directory
132
+ * (`~/projects/foo/.llm-wiki`) is a project vault and must stay one.
123
133
  */
124
134
  export function isPersonalVault(paths: VaultPaths): boolean {
125
- return paths.root === getPersonalWikiRoot();
135
+ const personalRoot = getPersonalWikiRoot();
136
+ // Fast path: identical strings need no filesystem syscalls.
137
+ if (paths.root === personalRoot) return true;
138
+ try {
139
+ return relativePhysicalPath(personalRoot, paths.root) === "";
140
+ } catch {
141
+ // Unresolvable path (permissions, symlink cycle): fall back to "not
142
+ // personal" so layered recall degrades to searching both vaults rather
143
+ // than silently dropping the personal layer.
144
+ return false;
145
+ }
126
146
  }
127
147
 
128
148
  /**
129
- * Resolve vault root from cwd with personal fallback.
149
+ * Resolve the vault root that belongs to THIS project, or `null` when the
150
+ * project has none.
130
151
  *
131
152
  * Priority:
132
- * 1. cwd has .llm-wiki/ → project wiki (explicit)
133
- * 2. Walk up from cwd parent project wiki
134
- * 3. ~/.llm-wiki/ existspersonal wiki
135
- * 4. Fallback: ~/.llm-wiki/ (create personal wiki)
153
+ * 1. cwd has `.llm-wiki/` (or legacy `.wiki/`) → project wiki (explicit)
154
+ * 2. `WIKI_HOME` user-selected root, explicit enough to count as the project's
155
+ * 3. Walk up from cwd parent project wiki (monorepo / nested workspace)
156
+ *
157
+ * Deliberately does NOT fall back to the personal wiki: callers that need the
158
+ * fallback use {@link resolveVaultRoot}, callers that must distinguish "this
159
+ * project has a wiki" from "some wiki exists somewhere" use this.
136
160
  */
137
- export function resolveVaultRoot(cwd: string): string {
161
+ export function resolveProjectVaultRoot(cwd: string): string | null {
138
162
  // A vault rooted at cwd is always the project-local choice.
139
163
  if (detectVaultFormat(cwd) !== "none") return cwd;
140
164
 
@@ -142,19 +166,36 @@ export function resolveVaultRoot(cwd: string): string {
142
166
  // over an unrelated personal vault found while walking parent directories.
143
167
  if (process.env.WIKI_HOME) return process.env.WIKI_HOME;
144
168
 
145
- // Walk up looking for a vault sentinel (new or legacy)
169
+ // Walk up looking for a vault sentinel (new or legacy).
146
170
  let dir = cwd;
147
171
  while (dir !== dirname(dir)) {
148
172
  dir = dirname(dir);
149
- if (detectVaultFormat(dir) !== "none") return dir;
173
+ if (detectVaultFormat(dir) === "none") continue;
174
+ // Skip the personal vault: it is an ancestor of EVERY project under the
175
+ // home directory (`~/projects/foo`, and on Windows even the temp dir), so
176
+ // counting it here would report a project vault for directories that have
177
+ // none. `resolveVaultRoot` still falls back to it explicitly.
178
+ if (isPersonalVault(getVaultPaths(dir))) continue;
179
+ return dir;
150
180
  }
151
181
 
152
- // Check personal wiki at ~/.llm-wiki/
153
- const personalRoot = getPersonalWikiRoot();
154
- if (detectVaultFormat(personalRoot) !== "none") return personalRoot;
182
+ return null;
183
+ }
155
184
 
156
- // Fallback: personal wiki
157
- return personalRoot;
185
+ /**
186
+ * Resolve vault root from cwd with personal fallback.
187
+ *
188
+ * Priority:
189
+ * 1-3. {@link resolveProjectVaultRoot}
190
+ * 4. Personal wiki root (`~`, or `WIKI_HOME`) — used whether or not it already
191
+ * holds a vault, so first-run bootstrap has somewhere to write.
192
+ */
193
+ export function resolveVaultRoot(cwd: string): string {
194
+ // Realpath the personal fallback so a symlinked `$HOME` (atomic-OS layouts)
195
+ // yields the PHYSICAL root the ancestor walk used to return — the #145
196
+ // regression guard pins that. `realpathWithMissingTail` also covers first-run
197
+ // bootstrap, where the personal root does not exist on disk yet.
198
+ return resolveProjectVaultRoot(cwd) ?? realpathWithMissingTail(getPersonalWikiRoot());
158
199
  }
159
200
 
160
201
  /** Get all vault paths for the new (.llm-wiki) layout. */
package/mcp/index.ts CHANGED
@@ -15,9 +15,10 @@ import { join } from "node:path";
15
15
  import { McpServer } from "@modelcontextprotocol/server";
16
16
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
17
17
  import * as z from "zod/v4";
18
- import { resolveVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
18
+ import { getVaultPaths, resolveVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
19
19
  import { createExecApi } from "./exec.js";
20
20
  import {
21
+ bootstrapOperation,
21
22
  captureSourceOperation,
22
23
  recallOperation,
23
24
  retroOperation,
@@ -35,6 +36,19 @@ function getPaths(): ReturnType<typeof resolveVaultPaths> {
35
36
  return resolveVaultPaths(root);
36
37
  }
37
38
 
39
+ /**
40
+ * The vault root this server was configured with, without resolution.
41
+ *
42
+ * `getPaths()` RESOLVES an existing vault: on a root that has none it walks up
43
+ * to a parent vault and then falls back to the personal vault. That is right
44
+ * for reading and writing pages, and wrong for creating one — bootstrap must
45
+ * create the vault where the client pointed the server, not wherever
46
+ * resolution lands. The Pi tool draws the same distinction.
47
+ */
48
+ function getConfiguredPaths(): ReturnType<typeof getVaultPaths> {
49
+ return getVaultPaths(process.env.WIKI_ROOT || process.cwd());
50
+ }
51
+
38
52
  function hasVault(): boolean {
39
53
  const paths = getPaths();
40
54
  return existsSync(join(paths.dotWiki, "config.json"));
@@ -47,13 +61,62 @@ const server = new McpServer({
47
61
  version: "1.0.0",
48
62
  });
49
63
 
64
+ // ---- wiki_bootstrap ----
65
+ //
66
+ // Registered first, and the only tool not gated on an existing vault: the
67
+ // other five fail closed with a message naming this one, which an MCP-only
68
+ // client could not act on while it was extension-only (issue #130).
69
+
70
+ server.registerTool(
71
+ "wiki_bootstrap",
72
+ {
73
+ description:
74
+ "Create an LLM Wiki vault at this server's wiki root (WIKI_ROOT, or the working directory). Writes config, schema, templates and metadata scaffolding. Run this first when no vault exists; safe to re-run on an existing vault, where it updates the config and rebuilds metadata without touching pages.",
75
+ inputSchema: z.object({
76
+ topic: z.string().describe("Main topic of the wiki"),
77
+ mode: z.string().optional().describe("personal or company (default: personal)"),
78
+ }),
79
+ },
80
+ async ({ topic, mode }) => {
81
+ const paths = getConfiguredPaths();
82
+ const result = await bootstrapOperation(paths, { topic, mode });
83
+
84
+ if (!result.ok) {
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text" as const,
89
+ text: `Vault error: ${result.diagnostics[0].message}`,
90
+ },
91
+ ],
92
+ isError: true,
93
+ };
94
+ }
95
+
96
+ const warnings = result.diagnostics.map((d) => `⚠️ ${d.code}: ${d.message}`);
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text" as const,
101
+ text: [
102
+ `${result.created ? "Wiki vault created" : "Wiki vault updated"} at ${paths.root}`,
103
+ "Structure: .llm-wiki/{raw,wiki,meta} plus config and WIKI_SCHEMA.md",
104
+ "Next: capture a source with wiki_capture_source, or save an insight with wiki_retro.",
105
+ ...warnings,
106
+ ].join("\n"),
107
+ },
108
+ ],
109
+ };
110
+ },
111
+ );
112
+
50
113
  // ---- wiki_recall ----
51
114
 
52
115
  server.registerTool(
53
116
  "wiki_recall",
54
117
  {
55
118
  description:
56
- "Search the wiki for pages relevant to a query. Returns matching page IDs, titles, types, and content previews.",
119
+ "Search the wiki for pages relevant to a query. Searches the resolved vault and the personal vault (~/.llm-wiki) together, deduplicated, with personal hits labelled. Returns matching page IDs, titles, types, and content previews.",
57
120
  inputSchema: z.object({
58
121
  query: z.string().describe("Search query — use the user's full request or key terms"),
59
122
  max_results: z.number().optional().default(5).describe("Max results (default: 5, max: 10)"),
package/mcp/operations.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  */
8
8
 
9
9
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
10
+ import { bootstrapVault } from "../extensions/llm-wiki/lib/bootstrap.js";
10
11
  import { type ProjectionResult, rebuildMetadata } from "../extensions/llm-wiki/lib/metadata.js";
11
- import { searchWiki } from "../extensions/llm-wiki/lib/recall.js";
12
+ import { type RecallResult, searchWikiLayered } from "../extensions/llm-wiki/lib/recall.js";
12
13
  import { saveInsight } from "../extensions/llm-wiki/lib/retro.js";
13
14
  import { captureFile, captureText, captureUrl } from "../extensions/llm-wiki/lib/source-packet.js";
14
15
  import type { VaultPaths } from "../extensions/llm-wiki/lib/utils.js";
@@ -30,16 +31,58 @@ function projectionOutcome(
30
31
  };
31
32
  }
32
33
 
33
- /** Shared recall operation: calls searchWiki and appends vault diagnostics. */
34
+ /**
35
+ * Shared bootstrap operation: create (or update) the vault at `paths`.
36
+ *
37
+ * This is the one operation that must work when no vault exists — every other
38
+ * one fails closed naming it. `bootstrapVault` is pure Node (`node:fs`,
39
+ * `node:path` and sibling lib modules), so it needs no model and no
40
+ * credentials, which is what makes it fit the MCP surface.
41
+ *
42
+ * A failed projection rebuild is reported as diagnostics alongside `ok: true`:
43
+ * the vault has been written to disk by then, and `wiki_lint` is the repair
44
+ * path, so failing the call outright would misreport what happened.
45
+ */
46
+ export async function bootstrapOperation(
47
+ paths: VaultPaths,
48
+ input: { topic: string; mode?: string },
49
+ ): Promise<
50
+ | { ok: true; created: boolean; diagnostics: Array<{ code: string; message: string }> }
51
+ | { ok: false; diagnostics: Array<{ code: string; message: string }> }
52
+ > {
53
+ const result = bootstrapVault(paths, { topic: input.topic, mode: input.mode ?? "personal" });
54
+ if (!result.ok) {
55
+ return {
56
+ ok: false,
57
+ diagnostics: result.diagnostics.map(({ code, message }) => ({ code, message })),
58
+ };
59
+ }
60
+ const projection = projectionOutcome(result.projection);
61
+ return {
62
+ ok: true,
63
+ created: result.created,
64
+ diagnostics: projection.ok ? [] : projection.diagnostics,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Shared recall operation: layered search plus vault diagnostics.
70
+ *
71
+ * Layering is the shared contract, not an extension-only feature: MCP clients
72
+ * get the same personal + project merge the Pi `wiki_recall` tool does.
73
+ * `searchWikiLayered` appends personal-vault hits, deduplicates by page ID and
74
+ * tags personal results with `vaultLabel`. It is a no-op when no personal vault
75
+ * exists, or when the resolved vault IS the personal vault.
76
+ */
34
77
  export async function recallOperation(
35
78
  paths: VaultPaths,
36
79
  query: string,
37
80
  maxResults = 5,
38
81
  ): Promise<{
39
- results: Array<{ id: string; title: string; type: string; preview?: string }>;
82
+ results: RecallResult[];
40
83
  diagnostics: Array<{ code: string; message: string }>;
41
84
  }> {
42
- const results = searchWiki(paths, query, maxResults);
85
+ const results = searchWikiLayered(paths, query, maxResults);
43
86
  const vaultState = inspectVaultFormat(paths);
44
87
  return {
45
88
  results,
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
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",
7
7
  "pi-package",
8
8
  "pi-extension",
9
9
  "pi-skill",
10
+ "omp",
11
+ "oh-my-pi",
12
+ "omp-plugin",
13
+ "omp-extension",
10
14
  "llm-wiki",
11
15
  "karpathy",
12
16
  "knowledge-base",
@@ -37,6 +41,7 @@
37
41
  "extensions",
38
42
  "skills",
39
43
  "prompts",
44
+ "commands",
40
45
  "scripts/migrate-llm-wiki.js",
41
46
  "mcp",
42
47
  "dist",
@@ -64,6 +69,11 @@
64
69
  "llm-wiki": "node ./dist/mcp/index.js"
65
70
  }
66
71
  },
72
+ "omp": {
73
+ "extensions": [
74
+ "./extensions/llm-wiki/index.ts"
75
+ ]
76
+ },
67
77
  "peerDependencies": {
68
78
  "@mariozechner/pi-coding-agent": "*",
69
79
  "typebox": "*"
@@ -96,6 +106,7 @@
96
106
  "test": "vitest run",
97
107
  "test:watch": "vitest",
98
108
  "test:coverage": "vitest run --coverage",
109
+ "build:commands": "node scripts/build-commands.js",
99
110
  "build:mcp": "node scripts/build-mcp.js",
100
111
  "typecheck": "tsc --noEmit",
101
112
  "lint": "biome check .",