@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.
- package/CHANGELOG.md +11 -0
- package/README.de.md +2 -1
- package/README.es.md +2 -1
- package/README.fr.md +2 -1
- package/README.hi.md +2 -1
- package/README.ja.md +2 -1
- package/README.ko.md +2 -1
- package/README.md +89 -10
- package/README.pt.md +2 -1
- package/README.ru.md +2 -1
- package/README.zh.md +2 -1
- package/commands/wiki-digest.md +28 -0
- package/commands/wiki-discover.md +30 -0
- package/commands/wiki-ingest.md +36 -0
- package/commands/wiki-init.md +30 -0
- package/commands/wiki-lint.md +25 -0
- package/commands/wiki-query.md +37 -0
- package/commands/wiki-record.md +36 -0
- package/commands/wiki-req.md +55 -0
- package/commands/wiki-retro.md +34 -0
- package/commands/wiki-run.md +31 -0
- package/commands/wiki-skills.md +26 -0
- package/commands/wiki-status.md +16 -0
- package/dist/extensions/llm-wiki/lib/host.js +97 -0
- package/dist/extensions/llm-wiki/lib/observation.js +9 -0
- package/dist/extensions/llm-wiki/lib/runtime.js +13 -1
- package/dist/extensions/llm-wiki/lib/task-config.js +74 -49
- package/dist/extensions/llm-wiki/lib/tools.js +10 -7
- package/dist/extensions/llm-wiki/lib/utils.js +59 -16
- package/dist/mcp/index.js +55 -3
- package/dist/mcp/operations.js +39 -3
- package/docs/api.md +9 -1
- package/docs/configuration.md +49 -8
- package/extensions/llm-wiki/index.ts +44 -6
- package/extensions/llm-wiki/lib/host.ts +125 -0
- package/extensions/llm-wiki/lib/observation.ts +14 -1
- package/extensions/llm-wiki/lib/runtime.ts +13 -1
- package/extensions/llm-wiki/lib/task-config.ts +115 -55
- package/extensions/llm-wiki/lib/tools.ts +10 -7
- package/extensions/llm-wiki/lib/utils.ts +55 -14
- package/mcp/index.ts +65 -2
- package/mcp/operations.ts +47 -4
- 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
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
-
|
|
149
|
-
const settingsPath =
|
|
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
|
-
|
|
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
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
218
|
+
const config = { ...TASK_DEFAULTS };
|
|
219
|
+
for (const path of listGlobalSettingsFiles()) {
|
|
220
|
+
Object.assign(config, readNamespacedConfig(path));
|
|
191
221
|
}
|
|
192
|
-
|
|
193
|
-
|
|
222
|
+
for (const path of listProjectSettingsFiles(cwd)) {
|
|
223
|
+
Object.assign(config, readNamespacedConfig(path));
|
|
194
224
|
}
|
|
195
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
94
|
-
* 2.
|
|
95
|
-
* 3.
|
|
96
|
-
*
|
|
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
|
|
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)
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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)"),
|