@zosmaai/pi-llm-wiki 0.11.3 → 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.
@@ -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
  }
@@ -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) {
@@ -31,17 +31,34 @@ The personal vault lives at `~/.llm-wiki/` (or `$WIKI_HOME`) and is always avail
31
31
  | ----------------------------- | ----------- | ----------------------------------------------- |
32
32
  | `WIKI_HOME` | `~/.llm-wiki` | Override the personal wiki vault location |
33
33
  | `WIKI_MARKITDOWN_TIMEOUT_MS` | 180000 | Timeout (ms) for MarkItDown PDF/text extraction |
34
+ | `LLM_WIKI_HOST` | auto | Force the host layout: `pi` or `omp` |
34
35
 
35
- ## Pi Agent Settings
36
+ ## Agent Settings
36
37
 
37
- Runtime settings for the wiki's background tasks live in `.pi/settings.json` under the `llm-wiki` namespace. These can be set globally (`~/.pi/agent/settings.json`) or per-project (`<cwd>/.pi/settings.json`).
38
+ Runtime settings for the wiki's background tasks live under the `llm-wiki`
39
+ namespace of the host's settings file. Both host layouts are read and merged,
40
+ lowest precedence first:
38
41
 
39
- | Setting | Default | Description |
40
- | --------------------- | ------- | ------------------------------------------------------------ |
41
- | `taskModel` | — | Model for background tasks (`{ provider: "openai", id: "gpt-4o" }`) |
42
- | `synthesisLanguage` | — | BCP 47 language tag for ingest synthesis (e.g. `"ru"`, `"fr"`). When unset, synthesis defaults to English. |
43
- | `trajectories` | false | Enable agent-trajectory working-memory |
44
- | `notices` | true | Show wiki activity notices in chat |
42
+ 1. `<agentDir>/settings.json`, then `config.yml` / `config.yaml`
43
+ `~/.pi/agent` under pi, `~/.omp/agent` under oh-my-pi
44
+ 2. `<cwd>/.pi/{settings.json,config.yml,config.yaml}`
45
+ 3. `<cwd>/.omp/{settings.json,config.yml,config.yaml}`
46
+
47
+ The **host-native** project directory is applied last, so it wins: `.omp` under
48
+ oh-my-pi, `.pi` under pi. Reading the other host's directory means a vault
49
+ configured under pi keeps working after `omp` takes over the repository.
50
+
51
+ `/wiki-model` and `/wiki-trajectories` write JSON only, into whichever project
52
+ config directory already exists (host-native first, created if neither is
53
+ present). A hand-authored `config.yml` is read but never rewritten.
54
+
55
+ | Setting | Default | Description |
56
+ | ---------------------- | ---------- | ------------------------------------------------------------ |
57
+ | `taskModel` | — | Model for background tasks (`{ provider: "openai", id: "gpt-4o" }`) |
58
+ | `synthesisLanguage` | — | BCP 47 language tag for ingest synthesis (e.g. `"ru"`, `"fr"`). When unset, synthesis defaults to English. |
59
+ | `trajectories` | false | Enable agent-trajectory working-memory |
60
+ | `notices` | true | Show wiki activity notices in chat |
61
+ | `ambientPersonalVault` | host-dependent | Let the personal vault act as the ambient vault in projects that have no wiki. `true` under pi, `false` under oh-my-pi — see below. |
45
62
 
46
63
  Example:
47
64
 
@@ -66,6 +83,30 @@ The vault root is resolved in this priority order:
66
83
 
67
84
  This means when you're in a project with its own `.llm-wiki/`, that project wiki is active. When you're outside any project wiki, your personal `~/.llm-wiki/` takes over automatically.
68
85
 
86
+ ### Ambient surfaces in projects without a wiki
87
+
88
+ Three surfaces fire without being asked: the session notice, the periodic
89
+ observe/retro reminder, and the `before_agent_start` recall injection (plus its
90
+ `<wiki_status>` system-prompt footer).
91
+
92
+ Because vault resolution falls back to the personal vault, those surfaces would
93
+ otherwise speak up in *every* directory as soon as `~/.llm-wiki/` exists —
94
+ injecting reminders and unrelated cross-project recall hits into repositories
95
+ where no wiki was ever initialized. Under oh-my-pi the plugin is installed once
96
+ and loads in every project, so that fallback is **off** by default there; under
97
+ pi the historical behaviour is kept.
98
+
99
+ `ambientPersonalVault` overrides the host default in either direction:
100
+
101
+ ```json
102
+ { "llm-wiki": { "ambientPersonalVault": true } }
103
+ ```
104
+
105
+ The gate only affects unprompted injections. Tools and slash commands are
106
+ always registered, so `/wiki-init` and `wiki_bootstrap` work in any directory —
107
+ and once a project has its own `.llm-wiki/`, every ambient surface turns back on
108
+ for it.
109
+
69
110
  ## Page Frontmatter
70
111
 
71
112
  ```yaml
@@ -20,7 +20,12 @@ import {
20
20
  } from "./lib/recall.js";
21
21
  import { registerWikiRetro } from "./lib/retro.js";
22
22
  import { registerBackgroundRuntime } from "./lib/runtime.js";
23
- import { loadTaskConfig, noticesEnabled, trajectoriesEnabled } from "./lib/task-config.js";
23
+ import {
24
+ loadTaskConfig,
25
+ noticesEnabled,
26
+ personalVaultIsAmbient,
27
+ trajectoriesEnabled,
28
+ } from "./lib/task-config.js";
24
29
  import {
25
30
  registerWikiBootstrap,
26
31
  registerWikiCaptureSource,
@@ -40,7 +45,11 @@ import {
40
45
  registerWikiDistillSkills,
41
46
  registerWikiRecallSkill,
42
47
  } from "./lib/trajectory.js";
43
- import { migrateDoubledPersonalVault, resolveVaultPaths } from "./lib/utils.js";
48
+ import {
49
+ migrateDoubledPersonalVault,
50
+ resolveProjectVaultRoot,
51
+ resolveVaultPaths,
52
+ } from "./lib/utils.js";
44
53
  import { inspectWritableVault } from "./lib/vault-format.js";
45
54
  import { applySessionStartStatus } from "./lib/visible-status.js";
46
55
 
@@ -68,6 +77,26 @@ export default function (pi: ExtensionAPI) {
68
77
  // work. Created first so tools (e.g. wiki_ingest) can dispatch to it.
69
78
  const runtime = registerBackgroundRuntime(pi);
70
79
 
80
+ /**
81
+ * Does a wiki apply to the directory this session is working in?
82
+ *
83
+ * Gates every AMBIENT surface — the ones that speak without being asked:
84
+ * session bootstrap/notice, the periodic observe/retro reminder, and
85
+ * `before_agent_start` recall injection. Tools and commands are registered
86
+ * regardless, so `/wiki-init` remains the way in.
87
+ *
88
+ * `resolveVaultRoot` falls back to the personal vault when a project has
89
+ * none, which is why the ambient surfaces used to fire in EVERY directory
90
+ * once a personal vault existed — reminders and unrelated cross-project
91
+ * recall hits leaking into repositories that never initialized a wiki.
92
+ * Under omp that fallback is off by default (`llm-wiki.ambientPersonalVault`);
93
+ * under pi it stays on, preserving the historical behavior.
94
+ *
95
+ * Resolved per call, not once at load: `cwd` changes within a session.
96
+ */
97
+ const wikiAppliesTo = (cwd: string): boolean =>
98
+ resolveProjectVaultRoot(cwd) !== null || personalVaultIsAmbient(runtime.config);
99
+
71
100
  registerWikiBootstrap(pi);
72
101
  registerWikiCaptureSource(pi, runtime);
73
102
  registerWikiIngest(pi, runtime);
@@ -106,9 +135,11 @@ export default function (pi: ExtensionAPI) {
106
135
  registerWikiObserve(pi, runtime, reminderState);
107
136
  // Visible observe/retro reminder by default (issue #77); silenced when the
108
137
  // user sets `llm-wiki.notices: false`. Resolver reads the live config so the
109
- // setting takes effect without a restart.
138
+ // setting takes effect without a restart. `display: false` still injects the
139
+ // reminder into model context, so the "no wiki here" case needs its own gate.
110
140
  registerObservationReminder(pi, reminderState, {
111
141
  display: () => noticesEnabled(runtime.config),
142
+ enabled: () => wikiAppliesTo(process.cwd()),
112
143
  });
113
144
 
114
145
  installGuardrails(pi, runtime);
@@ -137,6 +168,12 @@ export default function (pi: ExtensionAPI) {
137
168
  console.warn(`[llm-wiki] doubled-dotdir migration skipped: ${(err as Error).message}`);
138
169
  }
139
170
 
171
+ // Ambient gate. `ensureConfig` first so an explicit
172
+ // `llm-wiki.ambientPersonalVault` is honored on the very first session —
173
+ // `runtime.config` is otherwise empty until the first `turn_start`.
174
+ runtime.ensureConfig(process.cwd());
175
+ if (!wikiAppliesTo(process.cwd())) return;
176
+
140
177
  const paths = resolveVaultPaths(process.cwd());
141
178
  if (!existsSync(join(paths.dotWiki, "config.json"))) {
142
179
  // Silently create the wiki vault — no UI prompts. Topic/mode will be
@@ -168,9 +205,8 @@ export default function (pi: ExtensionAPI) {
168
205
 
169
206
  // Surface the "wiki active" badge and the active background task model
170
207
  // (issue #69), both gated by `llm-wiki.notices` (issue #77, regression
171
- // fixed in #83, helper extracted in #84). `ensureConfig` MUST run first so
172
- // the gate sees the loaded project settings.
173
- runtime.ensureConfig(process.cwd());
208
+ // fixed in #83, helper extracted in #84). The `ensureConfig` above the
209
+ // ambient gate already loaded the project settings this reads.
174
210
  applySessionStartStatus({
175
211
  ui: ctx.ui,
176
212
  runtime,
@@ -196,6 +232,8 @@ export default function (pi: ExtensionAPI) {
196
232
  // from the user's first prompt and update config via wiki_bootstrap.
197
233
  // 2. Search both personal + project vaults for relevant pages.
198
234
  pi.on("before_agent_start", async (event, ctx) => {
235
+ if (!wikiAppliesTo(process.cwd())) return;
236
+
199
237
  const paths = resolveVaultPaths(process.cwd());
200
238
  if (!existsSync(join(paths.dotWiki, "config.json"))) {
201
239
  return;
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+
5
+ /**
6
+ * Host adapter for the two coding agents that can load this extension:
7
+ *
8
+ * - **pi** — `@mariozechner/pi-coding-agent`, config dir `.pi`
9
+ * - **omp** — oh-my-pi (`@oh-my-pi/pi-coding-agent`), config dir `.omp`
10
+ *
11
+ * omp rewrites `@mariozechner/pi-*` (and bare `typebox`) imports onto its own
12
+ * bundled packages at load time (its `legacy-pi-compat.ts`), so the *module
13
+ * graph* needs no changes. What does differ is the on-disk config layout:
14
+ *
15
+ * | | pi | omp |
16
+ * |---|---|---|
17
+ * | user dir | `~/.pi/agent` | `~/.omp/agent` |
18
+ * | project dir | `<cwd>/.pi` | `<cwd>/.omp` |
19
+ * | settings file | `settings.json` | `settings.json`, then `config.yml` |
20
+ *
21
+ * omp explicitly does **not** read `.pi` (its config source order is
22
+ * `.omp` → `.claude` → `.codex` → `.gemini`), so a wiki configured under pi
23
+ * would silently lose its settings after switching hosts. This module keeps
24
+ * both layouts readable and picks a sensible file to write to.
25
+ *
26
+ * Everything here is additive: on pi with only a `.pi/` directory the effective
27
+ * behaviour is identical to the pre-compat code path, which keeps upstream
28
+ * merges clean.
29
+ */
30
+
31
+ export type HostKind = "pi" | "omp";
32
+
33
+ /** Project config directory name per host. */
34
+ const CONFIG_DIR: Record<HostKind, string> = { pi: ".pi", omp: ".omp" };
35
+
36
+ /** Settings file names inside a config directory, lowest → highest precedence. */
37
+ const SETTINGS_FILES = ["settings.json", "config.yml", "config.yaml"] as const;
38
+
39
+ /**
40
+ * Detect which agent is hosting this extension.
41
+ *
42
+ * Ordered by reliability:
43
+ * 1. `LLM_WIKI_HOST` — explicit escape hatch (tests, exotic embeddings).
44
+ * 2. The agent directory path: pi resolves `~/.pi/agent`, omp `~/.omp/agent`.
45
+ * A `PI_CODING_AGENT_DIR` override that keeps the marker segment still
46
+ * classifies correctly; anything else falls through.
47
+ * 3. `OMP_PROFILE`, which omp sets on itself whenever a profile is active.
48
+ * 4. Default `pi` — the historical behaviour.
49
+ */
50
+ export function detectHost(): HostKind {
51
+ const forced = process.env.LLM_WIKI_HOST?.trim().toLowerCase();
52
+ if (forced === "omp" || forced === "pi") return forced;
53
+
54
+ let agentDir = "";
55
+ try {
56
+ agentDir = getAgentDir();
57
+ } catch {
58
+ agentDir = "";
59
+ }
60
+ if (agentDir) {
61
+ const segments = agentDir.split(/[\\/]/);
62
+ if (segments.includes(".omp")) return "omp";
63
+ if (segments.includes(".pi")) return "pi";
64
+ }
65
+
66
+ if (process.env.OMP_PROFILE !== undefined) return "omp";
67
+ return "pi";
68
+ }
69
+
70
+ /**
71
+ * Every project settings file that may hold `llm-wiki` configuration, ordered
72
+ * from lowest to highest precedence so callers can merge left-to-right.
73
+ *
74
+ * The host's *native* directory is last (wins). The foreign directory is still
75
+ * read so a vault configured under pi keeps working after omp takes over the
76
+ * repository, and vice versa. Within a directory `config.yml` follows
77
+ * `settings.json`, matching omp's own project-settings precedence.
78
+ */
79
+ export function listProjectSettingsFiles(cwd: string, host: HostKind = detectHost()): string[] {
80
+ const foreign: HostKind = host === "omp" ? "pi" : "omp";
81
+ const files: string[] = [];
82
+ for (const kind of [foreign, host]) {
83
+ const dir = join(cwd, CONFIG_DIR[kind]);
84
+ for (const name of SETTINGS_FILES) files.push(join(dir, name));
85
+ }
86
+ return files;
87
+ }
88
+
89
+ /**
90
+ * User-level settings files, lowest → highest precedence.
91
+ *
92
+ * `getAgentDir()` already resolves per host (`~/.pi/agent` vs `~/.omp/agent`),
93
+ * so only the file names differ: omp migrates `settings.json` into `config.yml`
94
+ * on first start, and a migrated install has *only* the YAML file.
95
+ */
96
+ export function listGlobalSettingsFiles(): string[] {
97
+ let agentDir = "";
98
+ try {
99
+ agentDir = getAgentDir();
100
+ } catch {
101
+ return [];
102
+ }
103
+ if (!agentDir) return [];
104
+ return SETTINGS_FILES.map((name) => join(agentDir, name));
105
+ }
106
+
107
+ /**
108
+ * The project settings file this extension writes to.
109
+ *
110
+ * Always JSON (`settings.json`) — both hosts read it, and rewriting a user's
111
+ * hand-authored `config.yml` would destroy comments and formatting.
112
+ *
113
+ * Directory choice: an already-existing project config directory wins (so a
114
+ * repo that only has `.pi/` keeps a single settings file), otherwise the
115
+ * detected host's native directory is created.
116
+ */
117
+ export function resolveProjectSettingsPath(cwd: string, host: HostKind = detectHost()): string {
118
+ const native = join(cwd, CONFIG_DIR[host]);
119
+ if (existsSync(native)) return join(native, "settings.json");
120
+
121
+ const foreign = join(cwd, CONFIG_DIR[host === "omp" ? "pi" : "omp"]);
122
+ if (existsSync(foreign)) return join(foreign, "settings.json");
123
+
124
+ return join(native, "settings.json");
125
+ }
@@ -322,11 +322,20 @@ export function buildReminderText(): string {
322
322
  * `options.display` (issue #77) controls whether the reminder is shown to the
323
323
  * user (`true`, the default) or injected silently into model context only
324
324
  * (`false`). Pass a resolver so the live `notices` config is read at send time.
325
+ *
326
+ * `options.enabled` gates the reminder entirely — note that `display: false`
327
+ * still injects it into model context, so it is NOT a way to switch the
328
+ * reminder off. Callers pass a resolver that answers "does a wiki apply to the
329
+ * current working directory", evaluated per turn because the session can move.
325
330
  */
326
331
  export function registerObservationReminder(
327
332
  pi: ExtensionAPI,
328
333
  reminderState: ReminderState,
329
- options?: { turnsBetweenReminders?: number; display?: boolean | (() => boolean) },
334
+ options?: {
335
+ turnsBetweenReminders?: number;
336
+ display?: boolean | (() => boolean);
337
+ enabled?: () => boolean;
338
+ },
330
339
  ): void {
331
340
  const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
332
341
  const resolveDisplay = (): boolean => {
@@ -355,6 +364,10 @@ export function registerObservationReminder(
355
364
  // errors cause multiple retries, each firing agent_end).
356
365
  if ("willRetry" in event && (event as { willRetry?: boolean }).willRetry) return;
357
366
 
367
+ // No wiki applies here: never nag, and never accumulate a pending reminder
368
+ // that would fire the moment the session moves into a wiki-bearing project.
369
+ if (options?.enabled && !options.enabled()) return;
370
+
358
371
  turnsSinceLastReminder++;
359
372
  if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
360
373
  if (reminderState.observeDoneThisSession) return;