@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
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: Run the full wiki cycle: discover → ingest → lint. Optionally schedule for auto-updates.
3
+ argument-hint: "[--schedule daily|weekly]"
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-run
9
+
10
+ Run the complete wiki maintenance cycle: discover new sources, ingest them, and lint for health.
11
+
12
+ ## User Arguments
13
+
14
+ $ARGUMENTS
15
+
16
+ ## Steps
17
+
18
+ 1. **Discover:** Use web search to find new sources on the wiki's topic, then capture each with `wiki_capture_source(url=<url>)` (max 5-10).
19
+ 2. **Ingest:** Call `wiki_ingest(batch_size=3)` and process returned sources — read extracted.md, update source pages, create entity/concept pages, add cross-references.
20
+ 3. **Lint:** Call `wiki_lint(auto_fix=false)` to run a health check.
21
+ 4. If critical gaps found → optionally run one more discover+ingest cycle.
22
+ 5. Save summary to `.llm-wiki/outputs/run-YYYY-MM-DD.md` using the `write` tool.
23
+ 6. Report final summary.
24
+
25
+ ### Scheduling
26
+
27
+ If `--schedule` is provided, call `wiki_watch(interval=<daily|weekly|hourly>)`.
28
+
29
+ **Important:** `wiki_watch` does NOT install a schedule. It only prints a `crontab` line.
30
+ Report the printed line to the user verbatim and tell them to install it themselves with
31
+ `crontab -e`. Do not claim the schedule is active until they confirm they have done so.
@@ -0,0 +1,26 @@
1
+ ---
2
+ description: Search the wiki's distilled skills and past cases for patterns relevant to the current task — "have I done something like this before?".
3
+ argument-hint: "[query] [--kind skill|case]"
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-skills
9
+
10
+ Search the agent working-memory layer of the wiki: reusable **skills** distilled from past trajectories, and specific past **cases**.
11
+
12
+ ## User Arguments
13
+
14
+ $ARGUMENTS
15
+
16
+ ## Steps
17
+
18
+ 1. Call `wiki_recall_skill` with:
19
+ - `query`: the current task description or key terms (defaults to `$ARGUMENTS`)
20
+ - `kind`: optional — `skill`, `case`, or `any` (default)
21
+ - `max_results`: optional (default 5)
22
+ 2. Read the most relevant skill/case pages with `read`.
23
+ 3. Apply the recalled pattern to the current task, citing the source page with `[[skills/...]]` or `[[cases/...]]` where helpful.
24
+ 4. If no relevant skill/case exists, proceed with the task and consider running `/wiki-record` afterward so the next attempt benefits.
25
+
26
+ **Tip:** Skills generalize across many trajectories ("how I do X"); cases are concrete past runs ("the time I did X for project Y"). Search `any` first, then narrow.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Show wiki health overview — source count, page stats, orphan count, last activity dates.
3
+ argument-hint: ""
4
+ section: LLM Wiki
5
+ topLevelCli: true
6
+ ---
7
+
8
+ # /wiki-status
9
+
10
+ Show a quick overview of wiki health and statistics.
11
+
12
+ ## Steps
13
+
14
+ 1. Call `wiki_status()` to get the current wiki health report.
15
+ 2. Present the results to the user.
16
+ 3. If health shows warnings (orphans > 5, many gaps), suggest running `/wiki-lint` for a detailed analysis.
@@ -0,0 +1,97 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ /** Project config directory name per host. */
5
+ const CONFIG_DIR = { pi: ".pi", omp: ".omp" };
6
+ /** Settings file names inside a config directory, lowest → highest precedence. */
7
+ const SETTINGS_FILES = ["settings.json", "config.yml", "config.yaml"];
8
+ /**
9
+ * Detect which agent is hosting this extension.
10
+ *
11
+ * Ordered by reliability:
12
+ * 1. `LLM_WIKI_HOST` — explicit escape hatch (tests, exotic embeddings).
13
+ * 2. The agent directory path: pi resolves `~/.pi/agent`, omp `~/.omp/agent`.
14
+ * A `PI_CODING_AGENT_DIR` override that keeps the marker segment still
15
+ * classifies correctly; anything else falls through.
16
+ * 3. `OMP_PROFILE`, which omp sets on itself whenever a profile is active.
17
+ * 4. Default `pi` — the historical behaviour.
18
+ */
19
+ export function detectHost() {
20
+ const forced = process.env.LLM_WIKI_HOST?.trim().toLowerCase();
21
+ if (forced === "omp" || forced === "pi")
22
+ return forced;
23
+ let agentDir = "";
24
+ try {
25
+ agentDir = getAgentDir();
26
+ }
27
+ catch {
28
+ agentDir = "";
29
+ }
30
+ if (agentDir) {
31
+ const segments = agentDir.split(/[\\/]/);
32
+ if (segments.includes(".omp"))
33
+ return "omp";
34
+ if (segments.includes(".pi"))
35
+ return "pi";
36
+ }
37
+ if (process.env.OMP_PROFILE !== undefined)
38
+ return "omp";
39
+ return "pi";
40
+ }
41
+ /**
42
+ * Every project settings file that may hold `llm-wiki` configuration, ordered
43
+ * from lowest to highest precedence so callers can merge left-to-right.
44
+ *
45
+ * The host's *native* directory is last (wins). The foreign directory is still
46
+ * read so a vault configured under pi keeps working after omp takes over the
47
+ * repository, and vice versa. Within a directory `config.yml` follows
48
+ * `settings.json`, matching omp's own project-settings precedence.
49
+ */
50
+ export function listProjectSettingsFiles(cwd, host = detectHost()) {
51
+ const foreign = host === "omp" ? "pi" : "omp";
52
+ const files = [];
53
+ for (const kind of [foreign, host]) {
54
+ const dir = join(cwd, CONFIG_DIR[kind]);
55
+ for (const name of SETTINGS_FILES)
56
+ files.push(join(dir, name));
57
+ }
58
+ return files;
59
+ }
60
+ /**
61
+ * User-level settings files, lowest → highest precedence.
62
+ *
63
+ * `getAgentDir()` already resolves per host (`~/.pi/agent` vs `~/.omp/agent`),
64
+ * so only the file names differ: omp migrates `settings.json` into `config.yml`
65
+ * on first start, and a migrated install has *only* the YAML file.
66
+ */
67
+ export function listGlobalSettingsFiles() {
68
+ let agentDir = "";
69
+ try {
70
+ agentDir = getAgentDir();
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ if (!agentDir)
76
+ return [];
77
+ return SETTINGS_FILES.map((name) => join(agentDir, name));
78
+ }
79
+ /**
80
+ * The project settings file this extension writes to.
81
+ *
82
+ * Always JSON (`settings.json`) — both hosts read it, and rewriting a user's
83
+ * hand-authored `config.yml` would destroy comments and formatting.
84
+ *
85
+ * Directory choice: an already-existing project config directory wins (so a
86
+ * repo that only has `.pi/` keeps a single settings file), otherwise the
87
+ * detected host's native directory is created.
88
+ */
89
+ export function resolveProjectSettingsPath(cwd, host = detectHost()) {
90
+ const native = join(cwd, CONFIG_DIR[host]);
91
+ if (existsSync(native))
92
+ return join(native, "settings.json");
93
+ const foreign = join(cwd, CONFIG_DIR[host === "omp" ? "pi" : "omp"]);
94
+ if (existsSync(foreign))
95
+ return join(foreign, "settings.json");
96
+ return join(native, "settings.json");
97
+ }
@@ -239,6 +239,11 @@ export function buildReminderText() {
239
239
  * `options.display` (issue #77) controls whether the reminder is shown to the
240
240
  * user (`true`, the default) or injected silently into model context only
241
241
  * (`false`). Pass a resolver so the live `notices` config is read at send time.
242
+ *
243
+ * `options.enabled` gates the reminder entirely — note that `display: false`
244
+ * still injects it into model context, so it is NOT a way to switch the
245
+ * reminder off. Callers pass a resolver that answers "does a wiki apply to the
246
+ * current working directory", evaluated per turn because the session can move.
242
247
  */
243
248
  export function registerObservationReminder(pi, reminderState, options) {
244
249
  const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
@@ -267,6 +272,10 @@ export function registerObservationReminder(pi, reminderState, options) {
267
272
  // errors cause multiple retries, each firing agent_end).
268
273
  if ("willRetry" in event && event.willRetry)
269
274
  return;
275
+ // No wiki applies here: never nag, and never accumulate a pending reminder
276
+ // that would fire the moment the session moves into a wiki-bearing project.
277
+ if (options?.enabled && !options.enabled())
278
+ return;
270
279
  turnsSinceLastReminder++;
271
280
  if (turnsSinceLastReminder < REMINDER_INTERVAL)
272
281
  return;
@@ -143,9 +143,21 @@ export class Runtime {
143
143
  */
144
144
  launchReported(ctx, label, work) {
145
145
  return this.launchTask(ctx, label, async () => {
146
+ // Capture synchronously — after `await work()` the extension ctx may be
147
+ // a stale proxy (newSession/fork/switchSession/reload) and accessing
148
+ // ctx.hasUI or ctx.ui on it throws (see launchTask).
149
+ const hasUI = ctx.hasUI;
150
+ const ui = ctx.ui;
146
151
  const summary = await work();
147
- if (summary)
152
+ if (summary) {
153
+ // Instant completion feedback: the nextTurn report below is queued for
154
+ // the next user prompt, so without a toast a background task looks
155
+ // stuck. Mirrors the failure notification in launchTask and the
156
+ // success toast already used by wiki_ingest.
157
+ if (hasUI && ui)
158
+ ui.notify(summary.split("\n")[0].replace(/\*\*/g, ""), "info");
148
159
  this.report(summary);
160
+ }
149
161
  });
150
162
  }
151
163
  /**
@@ -1,6 +1,7 @@
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 { detectHost, listGlobalSettingsFiles, listProjectSettingsFiles, resolveProjectSettingsPath, } from "./host.js";
4
5
  export const TASK_DEFAULTS = {};
5
6
  /**
6
7
  * Resolve whether user-facing wiki notices are enabled (issue #77). Defaults
@@ -9,6 +10,16 @@ export const TASK_DEFAULTS = {};
9
10
  export function noticesEnabled(config) {
10
11
  return config?.notices !== false;
11
12
  }
13
+ /**
14
+ * Resolve whether the personal vault may serve as this project's ambient
15
+ * vault. Explicit `ambientPersonalVault` wins; otherwise the host decides
16
+ * (see the field docs on {@link TaskConfig.ambientPersonalVault}).
17
+ */
18
+ export function personalVaultIsAmbient(config, host = detectHost()) {
19
+ if (typeof config?.ambientPersonalVault === "boolean")
20
+ return config.ambientPersonalVault;
21
+ return host === "pi";
22
+ }
12
23
  /**
13
24
  * Resolve whether agent-trajectory working-memory is enabled (issue #80).
14
25
  * INVERSE polarity of `noticesEnabled`: defaults to `false`; only an explicit
@@ -64,6 +75,9 @@ function readNamespacedConfig(path) {
64
75
  if (typeof section.notices === "boolean") {
65
76
  out.notices = section.notices;
66
77
  }
78
+ if (typeof section.ambientPersonalVault === "boolean") {
79
+ out.ambientPersonalVault = section.ambientPersonalVault;
80
+ }
67
81
  if (typeof section.trajectories === "boolean") {
68
82
  out.trajectories = section.trajectories;
69
83
  }
@@ -121,14 +135,19 @@ export function validateSynthesisLanguage(tag) {
121
135
  return canonical[0];
122
136
  }
123
137
  /**
124
- * Read a settings JSON file as a plain object, or `{}` when it is absent or
138
+ * Read a settings file as a plain object, or `{}` when it is absent or
125
139
  * corrupt. Reads directly (no `existsSync` pre-check) so there is no
126
140
  * check-then-use race: a missing file throws ENOENT, which the catch treats
127
141
  * the same as an empty file.
142
+ *
143
+ * `config.yml` / `config.yaml` are parsed as YAML — that is the format oh-my-pi
144
+ * migrates its settings to. Everything else is JSON. JSON is a YAML subset, so
145
+ * the YAML parser also accepts a `.yml` file that actually holds JSON.
128
146
  */
129
147
  function readSettingsObject(path) {
130
148
  try {
131
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
149
+ const text = readFileSync(path, "utf-8");
150
+ const parsed = path.endsWith(".yml") || path.endsWith(".yaml") ? parseYaml(text) : JSON.parse(text);
132
151
  if (parsed && typeof parsed === "object")
133
152
  return parsed;
134
153
  }
@@ -138,64 +157,70 @@ function readSettingsObject(path) {
138
157
  return {};
139
158
  }
140
159
  /**
141
- * Persist (or clear) the wiki background `taskModel` in the PROJECT settings
142
- * file `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue
143
- * #69). Project settings win over global in `loadTaskConfig`, so this takes
144
- * effect immediately on the next config load. Other top-level keys and other
145
- * `llm-wiki` settings are preserved; passing `undefined` removes the key
146
- * (reverting to the session model).
160
+ * Rewrite the `llm-wiki` section of the project settings file, preserving every
161
+ * other top-level key and every other setting in the section.
162
+ *
163
+ * The target file is chosen by `resolveProjectSettingsPath` `.pi/settings.json`
164
+ * or `.omp/settings.json` depending on host and on what already exists — and is
165
+ * always JSON, which both hosts read.
147
166
  */
148
- export function persistTaskModel(cwd, model) {
149
- const settingsPath = join(cwd, ".pi", "settings.json");
167
+ function updateProjectSection(cwd, mutate) {
168
+ const settingsPath = resolveProjectSettingsPath(cwd);
150
169
  const raw = readSettingsObject(settingsPath);
151
170
  const existing = raw[SETTINGS_KEY];
152
171
  const section = existing && typeof existing === "object" ? { ...existing } : {};
153
- if (model) {
154
- section.taskModel = { provider: model.provider, id: model.id };
155
- }
156
- else {
157
- // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key (vs setting undefined) keeps the JSON clean
158
- delete section.taskModel;
159
- }
172
+ mutate(section);
160
173
  raw[SETTINGS_KEY] = section;
161
174
  mkdirSync(dirname(settingsPath), { recursive: true });
162
175
  writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
163
176
  }
164
177
  /**
165
- * Persist the agent-trajectory flag in the PROJECT settings file
166
- * `<cwd>/.pi/settings.json` under the namespaced `llm-wiki` key (issue #80).
167
- * Mirrors `persistTaskModel`: project settings win in `loadTaskConfig`, other
168
- * keys are preserved. `true` writes `trajectories: true`; `false` removes the
169
- * key (reverting to the default-off behavior).
178
+ * Persist (or clear) the wiki background `taskModel` in the PROJECT settings
179
+ * file under the namespaced `llm-wiki` key (issue #69). Project settings win
180
+ * over global in `loadTaskConfig`, so this takes effect immediately on the next
181
+ * config load. Passing `undefined` removes the key (reverting to the session
182
+ * model).
183
+ */
184
+ export function persistTaskModel(cwd, model) {
185
+ updateProjectSection(cwd, (section) => {
186
+ if (model) {
187
+ section.taskModel = { provider: model.provider, id: model.id };
188
+ }
189
+ else {
190
+ // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key (vs setting undefined) keeps the JSON clean
191
+ delete section.taskModel;
192
+ }
193
+ });
194
+ }
195
+ /**
196
+ * Persist the agent-trajectory flag in the PROJECT settings file under the
197
+ * namespaced `llm-wiki` key (issue #80). Mirrors `persistTaskModel`: `true`
198
+ * writes `trajectories: true`; `false` removes the key (reverting to the
199
+ * default-off behavior).
170
200
  */
171
201
  export function persistTrajectoriesEnabled(cwd, enabled) {
172
- const settingsPath = join(cwd, ".pi", "settings.json");
173
- const raw = readSettingsObject(settingsPath);
174
- const existing = raw[SETTINGS_KEY];
175
- const section = existing && typeof existing === "object" ? { ...existing } : {};
176
- if (enabled) {
177
- section.trajectories = true;
178
- }
179
- else {
180
- // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key keeps the JSON clean (default is off)
181
- delete section.trajectories;
182
- }
183
- raw[SETTINGS_KEY] = section;
184
- mkdirSync(dirname(settingsPath), { recursive: true });
185
- writeFileSync(settingsPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
202
+ updateProjectSection(cwd, (section) => {
203
+ if (enabled) {
204
+ section.trajectories = true;
205
+ }
206
+ else {
207
+ // biome-ignore lint/performance/noDelete: one-off settings rewrite, not a hot path; removing the key keeps the JSON clean (default is off)
208
+ delete section.trajectories;
209
+ }
210
+ });
186
211
  }
212
+ /**
213
+ * Merge the `llm-wiki` section from every settings file both hosts may use,
214
+ * lowest precedence first: built-in defaults, then user-level files, then
215
+ * project-level files. Absent files contribute nothing.
216
+ */
187
217
  export function loadTaskConfig(cwd) {
188
- let globalPath;
189
- try {
190
- globalPath = join(getAgentDir(), "settings.json");
218
+ const config = { ...TASK_DEFAULTS };
219
+ for (const path of listGlobalSettingsFiles()) {
220
+ Object.assign(config, readNamespacedConfig(path));
191
221
  }
192
- catch {
193
- globalPath = "";
222
+ for (const path of listProjectSettingsFiles(cwd)) {
223
+ Object.assign(config, readNamespacedConfig(path));
194
224
  }
195
- const projectPath = join(cwd, ".pi", "settings.json");
196
- return {
197
- ...TASK_DEFAULTS,
198
- ...(globalPath ? readNamespacedConfig(globalPath) : {}),
199
- ...readNamespacedConfig(projectPath),
200
- };
225
+ return config;
201
226
  }
@@ -694,7 +694,7 @@ export function registerWikiLint(pi, runtime) {
694
694
  // run it in the background and report the health summary (issue #77).
695
695
  return dispatchReported(runtime, ctx, {
696
696
  label: `lint:${paths.root}`,
697
- started: "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted when it completes.",
697
+ started: "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted with your next message.",
698
698
  work: async () => runWikiLint(paths, params.auto_fix === true),
699
699
  });
700
700
  },
@@ -827,11 +827,14 @@ function runWikiLint(paths, autoFix) {
827
827
  "",
828
828
  ].filter(Boolean);
829
829
  const reportPath = autoFix ? join(paths.outputs, `lint-${fmtDate()}.md`) : undefined;
830
+ // The gap snapshot is generated discovery metadata consumed by wiki_status:
831
+ // persist it on every successful lint so status never reports a stale count.
832
+ // Corrective actions below (report, event, meta rebuild) stay autoFix-only.
833
+ writeJson(join(paths.discoveries, "gaps.json"), {
834
+ gaps,
835
+ generated: new Date().toISOString(),
836
+ });
830
837
  if (autoFix && reportPath) {
831
- writeJson(join(paths.discoveries, "gaps.json"), {
832
- gaps,
833
- generated: new Date().toISOString(),
834
- });
835
838
  mkdirSync(paths.outputs, { recursive: true });
836
839
  writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf8");
837
840
  appendEvent(paths, {
@@ -954,7 +957,7 @@ export function registerWikiRebuildMeta(pi, runtime) {
954
957
  // report on completion (issue #77).
955
958
  return dispatchReported(runtime, ctx, {
956
959
  label: `rebuild_meta:${paths.root}`,
957
- started: "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported when it completes.",
960
+ started: "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported with your next message.",
958
961
  work: async () => {
959
962
  const result = rebuildMetadata(paths);
960
963
  // No rebuild_meta event — rebuild is a projection, not an authoritative mutation
@@ -1027,7 +1030,7 @@ export function registerWikiReindexEmbeddings(pi, runtime) {
1027
1030
  // report the stats on completion (issue #77).
1028
1031
  return dispatchReported(runtime, ctx, {
1029
1032
  label: `reindex_embeddings:${paths.root}`,
1030
- started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported when it completes.`,
1033
+ started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported with your next message.`,
1031
1034
  details: { enabled: true, model: embedder.model },
1032
1035
  work: async () => {
1033
1036
  const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true });
@@ -82,20 +82,46 @@ export function migrateDoubledPersonalVault(parentRoot = getPersonalWikiRoot())
82
82
  /**
83
83
  * Check if a vault is the personal wiki location.
84
84
  * Used in layered recall to avoid double-counting.
85
+ *
86
+ * Compares PHYSICAL paths, not strings. On image-based ("atomic") Linux
87
+ * distributions `/home` is a symlink to `var/home`, so `homedir()` yields the
88
+ * `$HOME` string (`/home/u`) while `process.cwd()` — and therefore the root
89
+ * `resolveVaultRoot()` walks up to — yields `/var/home/u`. A string compare
90
+ * calls the personal vault a project vault, which makes layered recall search
91
+ * the same vault twice and `vaultPageCount()` double-count it.
92
+ *
93
+ * Exact equality, NOT containment: a vault nested under the home directory
94
+ * (`~/projects/foo/.llm-wiki`) is a project vault and must stay one.
85
95
  */
86
96
  export function isPersonalVault(paths) {
87
- return paths.root === getPersonalWikiRoot();
97
+ const personalRoot = getPersonalWikiRoot();
98
+ // Fast path: identical strings need no filesystem syscalls.
99
+ if (paths.root === personalRoot)
100
+ return true;
101
+ try {
102
+ return relativePhysicalPath(personalRoot, paths.root) === "";
103
+ }
104
+ catch {
105
+ // Unresolvable path (permissions, symlink cycle): fall back to "not
106
+ // personal" so layered recall degrades to searching both vaults rather
107
+ // than silently dropping the personal layer.
108
+ return false;
109
+ }
88
110
  }
89
111
  /**
90
- * Resolve vault root from cwd with personal fallback.
112
+ * Resolve the vault root that belongs to THIS project, or `null` when the
113
+ * project has none.
91
114
  *
92
115
  * Priority:
93
- * 1. cwd has .llm-wiki/ → project wiki (explicit)
94
- * 2. Walk up from cwd parent project wiki
95
- * 3. ~/.llm-wiki/ existspersonal wiki
96
- * 4. Fallback: ~/.llm-wiki/ (create personal wiki)
116
+ * 1. cwd has `.llm-wiki/` (or legacy `.wiki/`) → project wiki (explicit)
117
+ * 2. `WIKI_HOME` user-selected root, explicit enough to count as the project's
118
+ * 3. Walk up from cwd parent project wiki (monorepo / nested workspace)
119
+ *
120
+ * Deliberately does NOT fall back to the personal wiki: callers that need the
121
+ * fallback use {@link resolveVaultRoot}, callers that must distinguish "this
122
+ * project has a wiki" from "some wiki exists somewhere" use this.
97
123
  */
98
- export function resolveVaultRoot(cwd) {
124
+ export function resolveProjectVaultRoot(cwd) {
99
125
  // A vault rooted at cwd is always the project-local choice.
100
126
  if (detectVaultFormat(cwd) !== "none")
101
127
  return cwd;
@@ -103,19 +129,36 @@ export function resolveVaultRoot(cwd) {
103
129
  // over an unrelated personal vault found while walking parent directories.
104
130
  if (process.env.WIKI_HOME)
105
131
  return process.env.WIKI_HOME;
106
- // Walk up looking for a vault sentinel (new or legacy)
132
+ // Walk up looking for a vault sentinel (new or legacy).
107
133
  let dir = cwd;
108
134
  while (dir !== dirname(dir)) {
109
135
  dir = dirname(dir);
110
- if (detectVaultFormat(dir) !== "none")
111
- return dir;
136
+ if (detectVaultFormat(dir) === "none")
137
+ continue;
138
+ // Skip the personal vault: it is an ancestor of EVERY project under the
139
+ // home directory (`~/projects/foo`, and on Windows even the temp dir), so
140
+ // counting it here would report a project vault for directories that have
141
+ // none. `resolveVaultRoot` still falls back to it explicitly.
142
+ if (isPersonalVault(getVaultPaths(dir)))
143
+ continue;
144
+ return dir;
112
145
  }
113
- // Check personal wiki at ~/.llm-wiki/
114
- const personalRoot = getPersonalWikiRoot();
115
- if (detectVaultFormat(personalRoot) !== "none")
116
- return personalRoot;
117
- // Fallback: personal wiki
118
- return personalRoot;
146
+ return null;
147
+ }
148
+ /**
149
+ * Resolve vault root from cwd with personal fallback.
150
+ *
151
+ * Priority:
152
+ * 1-3. {@link resolveProjectVaultRoot}
153
+ * 4. Personal wiki root (`~`, or `WIKI_HOME`) — used whether or not it already
154
+ * holds a vault, so first-run bootstrap has somewhere to write.
155
+ */
156
+ export function resolveVaultRoot(cwd) {
157
+ // Realpath the personal fallback so a symlinked `$HOME` (atomic-OS layouts)
158
+ // yields the PHYSICAL root the ancestor walk used to return — the #145
159
+ // regression guard pins that. `realpathWithMissingTail` also covers first-run
160
+ // bootstrap, where the personal root does not exist on disk yet.
161
+ return resolveProjectVaultRoot(cwd) ?? realpathWithMissingTail(getPersonalWikiRoot());
119
162
  }
120
163
  /** Get all vault paths for the new (.llm-wiki) layout. */
121
164
  export function getVaultPaths(root) {
package/dist/mcp/index.js CHANGED
@@ -13,9 +13,9 @@ import { join } from "node:path";
13
13
  import { McpServer } from "@modelcontextprotocol/server";
14
14
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
15
15
  import * as z from "zod/v4";
16
- import { resolveVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
16
+ import { getVaultPaths, resolveVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
17
17
  import { createExecApi } from "./exec.js";
18
- import { captureSourceOperation, recallOperation, retroOperation, searchOperation, statusOperation, } from "./operations.js";
18
+ import { bootstrapOperation, captureSourceOperation, recallOperation, retroOperation, searchOperation, statusOperation, } from "./operations.js";
19
19
  const execApi = createExecApi();
20
20
  // ─── Vault Detection ────────────────────────────────────
21
21
  /** Resolve vault paths, same as Pi extension. */
@@ -23,6 +23,18 @@ function getPaths() {
23
23
  const root = process.env.WIKI_ROOT || process.cwd();
24
24
  return resolveVaultPaths(root);
25
25
  }
26
+ /**
27
+ * The vault root this server was configured with, without resolution.
28
+ *
29
+ * `getPaths()` RESOLVES an existing vault: on a root that has none it walks up
30
+ * to a parent vault and then falls back to the personal vault. That is right
31
+ * for reading and writing pages, and wrong for creating one — bootstrap must
32
+ * create the vault where the client pointed the server, not wherever
33
+ * resolution lands. The Pi tool draws the same distinction.
34
+ */
35
+ function getConfiguredPaths() {
36
+ return getVaultPaths(process.env.WIKI_ROOT || process.cwd());
37
+ }
26
38
  function hasVault() {
27
39
  const paths = getPaths();
28
40
  return existsSync(join(paths.dotWiki, "config.json"));
@@ -32,9 +44,49 @@ const server = new McpServer({
32
44
  name: "llm-wiki",
33
45
  version: "1.0.0",
34
46
  });
47
+ // ---- wiki_bootstrap ----
48
+ //
49
+ // Registered first, and the only tool not gated on an existing vault: the
50
+ // other five fail closed with a message naming this one, which an MCP-only
51
+ // client could not act on while it was extension-only (issue #130).
52
+ server.registerTool("wiki_bootstrap", {
53
+ description: "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.",
54
+ inputSchema: z.object({
55
+ topic: z.string().describe("Main topic of the wiki"),
56
+ mode: z.string().optional().describe("personal or company (default: personal)"),
57
+ }),
58
+ }, async ({ topic, mode }) => {
59
+ const paths = getConfiguredPaths();
60
+ const result = await bootstrapOperation(paths, { topic, mode });
61
+ if (!result.ok) {
62
+ return {
63
+ content: [
64
+ {
65
+ type: "text",
66
+ text: `Vault error: ${result.diagnostics[0].message}`,
67
+ },
68
+ ],
69
+ isError: true,
70
+ };
71
+ }
72
+ const warnings = result.diagnostics.map((d) => `⚠️ ${d.code}: ${d.message}`);
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text",
77
+ text: [
78
+ `${result.created ? "Wiki vault created" : "Wiki vault updated"} at ${paths.root}`,
79
+ "Structure: .llm-wiki/{raw,wiki,meta} plus config and WIKI_SCHEMA.md",
80
+ "Next: capture a source with wiki_capture_source, or save an insight with wiki_retro.",
81
+ ...warnings,
82
+ ].join("\n"),
83
+ },
84
+ ],
85
+ };
86
+ });
35
87
  // ---- wiki_recall ----
36
88
  server.registerTool("wiki_recall", {
37
- description: "Search the wiki for pages relevant to a query. Returns matching page IDs, titles, types, and content previews.",
89
+ description: "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.",
38
90
  inputSchema: z.object({
39
91
  query: z.string().describe("Search query — use the user's full request or key terms"),
40
92
  max_results: z.number().optional().default(5).describe("Max results (default: 5, max: 10)"),