@zosmaai/pi-llm-wiki 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +33 -3
- package/extensions/llm-wiki/index.ts +49 -10
- package/extensions/llm-wiki/lib/embeddings.ts +420 -0
- package/extensions/llm-wiki/lib/guardrails.ts +15 -4
- package/extensions/llm-wiki/lib/indexing.ts +88 -0
- package/extensions/llm-wiki/lib/ingest-worker.ts +281 -0
- package/extensions/llm-wiki/lib/model-command.ts +128 -0
- package/extensions/llm-wiki/lib/observation.ts +34 -11
- package/extensions/llm-wiki/lib/recall.ts +331 -10
- package/extensions/llm-wiki/lib/retro.ts +13 -4
- package/extensions/llm-wiki/lib/runtime.ts +216 -0
- package/extensions/llm-wiki/lib/source-extractors.ts +120 -11
- package/extensions/llm-wiki/lib/source-packet.ts +19 -1
- package/extensions/llm-wiki/lib/subagent.ts +82 -0
- package/extensions/llm-wiki/lib/task-config.ts +195 -0
- package/extensions/llm-wiki/lib/tools.ts +178 -8
- package/package.json +3 -2
- package/prompts/wiki-ingest.md +7 -4
- package/skills/llm-wiki/SKILL.md +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,41 @@
|
|
|
6
6
|
- **Personal wiki created at doubled path `~/.llm-wiki/.llm-wiki/…`**: `getPersonalWikiRoot()` returned the dot-dir itself (`~/.llm-wiki`) while `getVaultPaths()` then appended another `.llm-wiki/` segment, so the personal vault was written to `~/.llm-wiki/.llm-wiki/wiki/…`. Fixed by aligning `getPersonalWikiRoot()` with the same "root = parent of `.llm-wiki/`" contract used by project vaults. `WIKI_HOME` continues to override the parent.
|
|
7
7
|
|
|
8
8
|
### Added
|
|
9
|
+
- **Model selection surface for background tasks** (Issue #69, part of epic #63): the wiki background lane (ingest synthesis, etc.) now has a user-facing surface to choose its model, defaulting to the **session model** with zero config.
|
|
10
|
+
- **`/wiki-model` slash command**: run with no argument for an interactive picker (lists `modelRegistry.getAvailable()`); `/wiki-model provider/id` to set directly (scriptable, no UI needed); `/wiki-model session` (or `clear`/`default`/`reset`) to revert to the session model. The choice is **persisted** to project settings (`.pi/settings.json` under `llm-wiki.taskModel`, preserving other keys) and applied immediately, with a status-bar label of the active model.
|
|
11
|
+
- **Per-call `model` override** on heavy tools (`wiki_ingest`): an optional `'provider/id'` param that overrides the configured `taskModel` for that one call. Precedence is **override > configured taskModel > session model**; each layer is applied only when the model is in the registry, and a missing/unknown layer warns (when UI is available) and falls through — so a bad ref degrades gracefully instead of failing.
|
|
12
|
+
- **`parseModelRef` / `persistTaskModel`** helpers (`extensions/llm-wiki/lib/task-config.ts`): parse a `provider/id` ref (split on the first slash so slash-bearing model ids survive) and read-modify-write the namespaced project settings (creating `.pi/` as needed, clearing the key on `undefined`). `Runtime.resolveModel(ctx, override?)` gained the override layer; `formatActiveModelLabel` renders the status line.
|
|
13
|
+
- **15 tests** (`test/model-selection.test.ts`): ref parsing (incl. slash-bearing ids and rejects), persistence round-trip (write/read/clear/preserve-other-keys/create-dir), `resolveModel` precedence (override > config > session, with graceful fallback + warning), and the active-model label. Written red-first.
|
|
14
|
+
- **Formal two-stage recall (links-first, gated by vault size)** (Issue #68, part of epic #63): recall now scales as the vault grows by switching from inline content previews to a **ranked list of links** once the vault crosses a configurable size threshold, with the agent expanding chosen links on demand via `read` (memex-style two-stage retrieval).
|
|
15
|
+
- **Stage 1 (links-first)**: above the threshold, both `wiki_recall` and the `before_agent_start` auto-injection return ranked links carrying `id`, `title`, `type`, `score`, and a single short snippet — **no full previews** — so a large vault never floods the system prompt. **Stage 2 (expand)**: the agent calls `read`/`wiki_read` on the links it actually needs. The two-step contract is documented in `SKILL.md`.
|
|
16
|
+
- **No regression for small vaults**: at or below the threshold the existing preview-inline rendering is byte-for-byte unchanged (`formatRecallContext` default path), so small vaults keep today's behavior.
|
|
17
|
+
- **Page-count gate (deliberate choice over token-budget)**: the threshold is the registered page count from `meta/registry.json` — an O(1) read with **zero page-body I/O**, so the gate itself never reads page contents as the vault grows (a token-budget gate would have to touch every page, defeating the cheap-recall goal). New `recallLinksThreshold` setting (namespaced `llm-wiki`, default **50**, clamped to a non-negative integer). Set `0` to force links-first for any non-empty vault, or a large value to always keep previews inline. The auto-injection counts only the project vault (matching its search scope); the `wiki_recall` tool counts project + personal.
|
|
18
|
+
- **7 tests** (`test/recall.test.ts`): page-count derivation, threshold gating in both directions (incl. the `0`-forces-links-first edge), small-vault preview regression (byte-for-byte equal to `linksOnly:false`), large-vault links-only output shape (id/title/type/score/snippet, no full preview), single-line snippet truncation, threshold config parsing/clamping, and end-to-end below-vs-above gating.
|
|
19
|
+
- **Hybrid lexical + semantic recall ranking, LLM-free hot path** (Issue #67, part of epic #63): recall now blends the existing weighted lexical score with **semantic cosine similarity** against the precomputed `meta/embeddings.json` sidecar (#66), so paraphrased queries surface pages that pure keyword matching misses.
|
|
20
|
+
- **No re-embedding per query**: page vectors are computed at write time (#66). The only per-query embedding work is a **single, cached** lookup of the (short) query string via the configured embedder (`searchWikiHybrid` in `extensions/llm-wiki/lib/recall.ts`); repeated recalls of the same query in a session reuse the cached vector. Ranking itself (`searchWiki`) stays **synchronous and offline** — pure vector math, no network.
|
|
21
|
+
- **Score fusion**: the lexical score keeps its original absolute scale and the semantic signal is added as a bounded, weighted, non-negative boost (`fuseScores` = `lexical + semanticWeight × SEMANTIC_SCALE × max(cosine, 0)`). This preserves `minScore` semantics for auto-injection noise control — a semantic-only match must be **strongly** relevant (cosine ≳ 0.84 at the default weight) to clear the auto-inject threshold. New `semanticWeight` setting (namespaced `llm-wiki`, default `0.5`, clamped to `[0,1]`).
|
|
22
|
+
- **Pure-lexical fallback preserved**: with no embeddings sidecar (or no embedder configured) the query embedding is **skipped entirely** (zero network) and recall is byte-for-byte the prior lexical behavior. Embedding/network failures degrade gracefully to lexical. The `wiki_recall` tool and the `before_agent_start` auto-injection both use the hybrid path.
|
|
23
|
+
- **8 tests** (`test/recall.test.ts`): score-fusion math, paraphrase recall (semantic-only page surfaced), ranking reorder vs pure lexical, no-sidecar regression (identical to lexical), empty/missing-sidecar safety, `minScore` still filtering weak cosine, and the hybrid wrapper skipping vs performing+caching the single query embedding. Embedding is mocked — no network in tests.
|
|
24
|
+
- **Background semantic embeddings computed at write time** (Issue #66, part of epic #63): every wiki page gets a normalized embedding vector computed in the **background**, so future semantic retrieval (#67) can rank pages **without any embedding/LLM call in the query hot path**.
|
|
25
|
+
- `extensions/llm-wiki/lib/embeddings.ts`: stores vectors in a `meta/embeddings.json` sidecar keyed by page id, each with a **content hash + model** for staleness detection. `embedPages()` embeds a set of pages (skipping fresh ones unless `force`); `reindexEmbeddings()` backfills an entire vault and prunes entries for deleted pages. Vectors are L2-normalized for cosine; `cosineSimilarity()` is exported for #67.
|
|
26
|
+
- **Write-time triggers (all background, never block the agent)**: ingest commit embeds the source + entity/concept pages it wrote; `wiki_ensure_page` embeds the new page; manual wiki edits are re-embedded after the end-of-turn metadata rebuild. All single-flight per label via the #64 runtime.
|
|
27
|
+
- **Configurable, OpenAI-compatible provider** (mirrors memex): new `embeddingProvider` / `embeddingModel` / `embeddingBaseUrl` / `embeddingApiKey` / `embeddingApiKeyEnv` fields in the namespaced `llm-wiki` settings (`extensions/llm-wiki/lib/task-config.ts`). The embedding API key has its **own** auth path, independent of the chat-model resolution.
|
|
28
|
+
- **New `wiki_reindex_embeddings` tool**: backfill an existing vault or refresh stale pages (`force` to re-embed everything).
|
|
29
|
+
- **Fully optional, no-op by default**: with no `embeddingProvider` configured, embeddings are disabled silently and existing lexical search is completely unaffected. Opt-in is explicit — an ambient `OPENAI_API_KEY` alone does **not** enable it.
|
|
30
|
+
- **23 tests** (`test/embeddings.test.ts`, additions to `test/runtime.test.ts`): write-time embedding + normalized storage, content-hash and model staleness, `force`, backfill + prune, the no-provider no-op (and no auto-enable), vector math, and config parsing. Embedding is mocked — no network in tests.
|
|
31
|
+
- **Background ingest synthesis** (Issue #65, part of epic #63): `wiki_ingest` now synthesizes captured sources in the **background by default** — the main agent is no longer blocked while source pages and entity/concept pages are written.
|
|
32
|
+
- New `background` parameter (default `true`). When a task model resolves, each source is dispatched to a background sub-agent via the #64 runtime and the tool returns immediately with a non-blocking notification; the main agent is told **not** to synthesize those sources itself. The sub-agent makes one structured `commit_synthesis` call.
|
|
33
|
+
- **Graceful fallback**: when no model/API key is available (or `background=false`), `wiki_ingest` returns the extracted batch exactly as before for the main agent to synthesize — fully backward compatible.
|
|
34
|
+
- `extensions/llm-wiki/lib/ingest-worker.ts`: `commitSynthesis()` deterministically rewrites the source page (`status: skeleton → ingested`), creates missing entity/concept pages (existing pages are linked, never overwritten), records an `ingest` event, and rebuilds metadata. `runIngestSynthesis()` drives the synthesis sub-agent. `buildIngestedSourcePage()` renders the filled page.
|
|
35
|
+
- **10 tests** (`test/ingest-worker.test.ts`, `test/ingest-tool.test.ts`): deterministic page/entity/concept writing, link-don't-overwrite, event logging, empty-slug safety, and tool-level background dispatch + all three fallback paths (no model, `background=false`, no runtime).
|
|
36
|
+
- **Background-task lane infrastructure** (Issue #64, part of epic #63): foundational runtime so wiki tasks can run off the main agent thread without blocking the user.
|
|
37
|
+
- `Runtime` (`extensions/llm-wiki/lib/runtime.ts`): `launchTask()` fires detached, single-flight-per-label background work with isolated error handling; `resolveModel()` picks the configured `taskModel` → session-model fallback → API-key resolution, returning a graceful `{ ok: false }` so callers keep the synchronous path when no model/key is available; `awaitAll()` drains in-flight work.
|
|
38
|
+
- `registerBackgroundRuntime()`: wires the runtime into the extension lifecycle, draining in-flight tasks on `session_before_compact` and `session_shutdown` so background work is never lost.
|
|
39
|
+
- `loadTaskConfig()` (`extensions/llm-wiki/lib/task-config.ts`): reads an optional `taskModel` from pi's namespaced `llm-wiki` settings (global + project). Zero-config by default.
|
|
40
|
+
- `runSubAgent()` (`extensions/llm-wiki/lib/subagent.ts`): thin, generic `agentLoop` wrapper for focused background sub-agents.
|
|
41
|
+
- **15 unit tests** (`test/runtime.test.ts`) covering config precedence, model resolution + fallbacks, single-flight, concurrency, error isolation, and drain.
|
|
42
|
+
- No user-facing behavior change yet; concrete workers land in #65 (background ingest) and #66 (background embeddings).
|
|
43
|
+
- **Test isolation fix** (`test/recall.test.ts`): the personal-vault recall suite now sandboxes `WIKI_HOME` to a temp dir per test, so it no longer reads (or writes) the developer's real `~/.llm-wiki`. Previously these tests passed only by scheduling luck and could pollute the real home vault.
|
|
9
44
|
- **`migrateDoubledPersonalVault()`** helper (`extensions/llm-wiki/lib/utils.ts`): Idempotent, in-place flatten of any vault that was already written to the broken doubled layout. Moves entries from `<root>/.llm-wiki/.llm-wiki/*` up to `<root>/.llm-wiki/*`, preserves outer entries on collision, removes the inner dir only when fully drained. Returns `null` when the layout is already correct, so it is safe to call on every session start.
|
|
10
45
|
- **Auto-migration on `session_start`**: The extension now runs `migrateDoubledPersonalVault()` on the personal wiki at every session start. Existing broken vaults are flattened the next time the user opens or reloads pi — no manual step required. A one-line status message is shown when a flatten actually happens; otherwise the check is silent.
|
|
11
46
|
- **`scripts/migrate-llm-wiki.js --fix-doubled`** flag: Manual recovery for arbitrary roots (`--fix-doubled ~/`, `--fix-doubled /some/project`). Supports `--dry-run` and `--force`.
|
package/README.md
CHANGED
|
@@ -399,9 +399,39 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, test patterns, and
|
|
|
399
399
|
|
|
400
400
|
## Contributors
|
|
401
401
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
Thanks to everyone who has contributed! This list is regenerated automatically by [`.github/workflows/contributors.yml`](.github/workflows/contributors.yml) — see [#60](https://github.com/zosmaai/pi-llm-wiki/issues/60) for the rationale.
|
|
403
|
+
|
|
404
|
+
<!-- readme: contributors -start -->
|
|
405
|
+
<table>
|
|
406
|
+
<tbody>
|
|
407
|
+
<tr>
|
|
408
|
+
<td align="center">
|
|
409
|
+
<a href="https://github.com/arjun-zosma">
|
|
410
|
+
<img src="https://avatars.githubusercontent.com/u/25246034?v=4" width="64;" alt="arjun-zosma"/>
|
|
411
|
+
<br />
|
|
412
|
+
<sub><b>Arjun Nayak</b></sub>
|
|
413
|
+
</a>
|
|
414
|
+
</td>
|
|
415
|
+
<td align="center">
|
|
416
|
+
<a href="https://github.com/jfraser">
|
|
417
|
+
<img src="https://avatars.githubusercontent.com/u/165964?v=4" width="64;" alt="jfraser"/>
|
|
418
|
+
<br />
|
|
419
|
+
<sub><b>James Fraser</b></sub>
|
|
420
|
+
</a>
|
|
421
|
+
</td>
|
|
422
|
+
<td align="center">
|
|
423
|
+
<a href="https://github.com/Shanvit7">
|
|
424
|
+
<img src="https://avatars.githubusercontent.com/u/64424817?v=4" width="64;" alt="Shanvit7"/>
|
|
425
|
+
<br />
|
|
426
|
+
<sub><b>Shanvit S Shetty</b></sub>
|
|
427
|
+
</a>
|
|
428
|
+
</td>
|
|
429
|
+
</tr>
|
|
430
|
+
<tbody>
|
|
431
|
+
</table>
|
|
432
|
+
<!-- readme: contributors -end -->
|
|
433
|
+
|
|
434
|
+
<sub>Full history: [contributors graph](https://github.com/zosmaai/pi-llm-wiki/graphs/contributors).</sub>
|
|
405
435
|
|
|
406
436
|
---
|
|
407
437
|
|
|
@@ -2,13 +2,25 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { installGuardrails } from "./lib/guardrails.js";
|
|
5
|
+
import {
|
|
6
|
+
MODEL_STATUS_KEY,
|
|
7
|
+
formatActiveModelLabel,
|
|
8
|
+
registerWikiModelCommand,
|
|
9
|
+
} from "./lib/model-command.js";
|
|
5
10
|
import {
|
|
6
11
|
createReminderState,
|
|
7
12
|
registerObservationReminder,
|
|
8
13
|
registerWikiObserve,
|
|
9
14
|
} from "./lib/observation.js";
|
|
10
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
formatRecallContext,
|
|
17
|
+
registerWikiRecall,
|
|
18
|
+
searchWikiHybrid,
|
|
19
|
+
shouldUseLinksFirst,
|
|
20
|
+
vaultPageCount,
|
|
21
|
+
} from "./lib/recall.js";
|
|
11
22
|
import { registerWikiRetro } from "./lib/retro.js";
|
|
23
|
+
import { registerBackgroundRuntime } from "./lib/runtime.js";
|
|
12
24
|
import {
|
|
13
25
|
registerWikiBootstrap,
|
|
14
26
|
registerWikiCaptureSource,
|
|
@@ -17,6 +29,7 @@ import {
|
|
|
17
29
|
registerWikiLint,
|
|
18
30
|
registerWikiLogEvent,
|
|
19
31
|
registerWikiRebuildMeta,
|
|
32
|
+
registerWikiReindexEmbeddings,
|
|
20
33
|
registerWikiSearch,
|
|
21
34
|
registerWikiStatus,
|
|
22
35
|
registerWikiWatch,
|
|
@@ -49,23 +62,32 @@ import {
|
|
|
49
62
|
*/
|
|
50
63
|
|
|
51
64
|
export default function (pi: ExtensionAPI) {
|
|
65
|
+
// Background-task lane (issues #64, #65): shared runtime for off-thread LLM
|
|
66
|
+
// work. Created first so tools (e.g. wiki_ingest) can dispatch to it.
|
|
67
|
+
const runtime = registerBackgroundRuntime(pi);
|
|
68
|
+
|
|
52
69
|
registerWikiBootstrap(pi);
|
|
53
|
-
registerWikiCaptureSource(pi);
|
|
54
|
-
registerWikiIngest(pi);
|
|
55
|
-
registerWikiEnsurePage(pi);
|
|
70
|
+
registerWikiCaptureSource(pi, runtime);
|
|
71
|
+
registerWikiIngest(pi, runtime);
|
|
72
|
+
registerWikiEnsurePage(pi, runtime);
|
|
56
73
|
registerWikiSearch(pi);
|
|
57
74
|
registerWikiLint(pi);
|
|
58
75
|
registerWikiStatus(pi);
|
|
59
76
|
registerWikiRebuildMeta(pi);
|
|
77
|
+
registerWikiReindexEmbeddings(pi, runtime);
|
|
60
78
|
registerWikiLogEvent(pi);
|
|
61
79
|
registerWikiWatch(pi);
|
|
62
|
-
registerWikiRecall(pi);
|
|
63
|
-
registerWikiRetro(pi);
|
|
80
|
+
registerWikiRecall(pi, runtime);
|
|
81
|
+
registerWikiRetro(pi, runtime);
|
|
82
|
+
// Model selection surface (issue #69): /wiki-model command to view/set the
|
|
83
|
+
// background task model. The taskModel config field + resolveModel already
|
|
84
|
+
// exist; this exposes them to the user (default stays the session model).
|
|
85
|
+
registerWikiModelCommand(pi, runtime);
|
|
64
86
|
const reminderState = createReminderState();
|
|
65
|
-
registerWikiObserve(pi, reminderState);
|
|
87
|
+
registerWikiObserve(pi, runtime, reminderState);
|
|
66
88
|
registerObservationReminder(pi, reminderState);
|
|
67
89
|
|
|
68
|
-
installGuardrails(pi);
|
|
90
|
+
installGuardrails(pi, runtime);
|
|
69
91
|
|
|
70
92
|
// Track if wiki was just auto-created and needs topic inference
|
|
71
93
|
let needsTopicInference = false;
|
|
@@ -123,6 +145,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
123
145
|
}
|
|
124
146
|
|
|
125
147
|
ctx.ui.setStatus("llm-wiki", "🧠 LLM Wiki (13 tools, observe + recall active)");
|
|
148
|
+
|
|
149
|
+
// Surface the active background task model (issue #69). Defaults to the
|
|
150
|
+
// session model when no taskModel is configured.
|
|
151
|
+
runtime.ensureConfig(process.cwd());
|
|
152
|
+
const modelLabel = formatActiveModelLabel(runtime.config, (ctx.model as { id?: string })?.id);
|
|
153
|
+
ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${modelLabel}`);
|
|
126
154
|
});
|
|
127
155
|
|
|
128
156
|
// ─── Layered recall + topic inference hook ──────────
|
|
@@ -182,9 +210,20 @@ Then call wiki_bootstrap with the inferred topic and mode to finalize the setup.
|
|
|
182
210
|
// or multiple body matches. This eliminates accidental body-only
|
|
183
211
|
// substring matches (e.g. a Tally page matching on common words).
|
|
184
212
|
// includePersonal=false: personal vault is excluded from auto-injection.
|
|
185
|
-
|
|
213
|
+
// Hybrid: blends semantic cosine when embeddings exist (single cached
|
|
214
|
+
// query embedding); degrades to pure lexical otherwise. minScore=5 still
|
|
215
|
+
// gates noise — a semantic-only match must be strongly relevant to pass.
|
|
216
|
+
runtime.ensureConfig(process.cwd());
|
|
217
|
+
const results = await searchWikiHybrid(paths, prompt, 3, 5, false, {
|
|
218
|
+
config: runtime.config,
|
|
219
|
+
});
|
|
186
220
|
if (results.length > 0) {
|
|
187
|
-
|
|
221
|
+
// Two-stage gate (issue #68): above the vault-size threshold, inject
|
|
222
|
+
// ranked LINKS only (the agent expands them on demand via `read`) so a
|
|
223
|
+
// large vault never floods the system prompt with inline previews.
|
|
224
|
+
// includePersonal=false here mirrors the auto-injection search scope.
|
|
225
|
+
const linksOnly = shouldUseLinksFirst(vaultPageCount(paths, false), runtime.config);
|
|
226
|
+
const recallContext = formatRecallContext(results, { linksOnly });
|
|
188
227
|
if (recallContext) {
|
|
189
228
|
injectedContext += `\n\n${recallContext}`;
|
|
190
229
|
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { request as httpRequest } from "node:http";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { Registry } from "./metadata.js";
|
|
7
|
+
import type { LaunchCtx, Runtime } from "./runtime.js";
|
|
8
|
+
import type { TaskConfig } from "./task-config.js";
|
|
9
|
+
import { type VaultPaths, parseFrontmatter, readJson, writeJson } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Background semantic embeddings, computed at write time (issue #66, epic #63).
|
|
13
|
+
*
|
|
14
|
+
* Every wiki page gets a normalized embedding vector stored in a sidecar
|
|
15
|
+
* (`meta/embeddings.json`), keyed by page id with a content hash for staleness
|
|
16
|
+
* detection. Embeddings are computed in the background via the #64 runtime so
|
|
17
|
+
* the main agent is never blocked, and so that semantic retrieval (#67) can
|
|
18
|
+
* rank pages WITHOUT any embedding/LLM call in the query hot path.
|
|
19
|
+
*
|
|
20
|
+
* Design principles:
|
|
21
|
+
* - Fully optional: with no embedding provider configured, `resolveEmbedder`
|
|
22
|
+
* returns undefined and every entry point no-ops silently. Existing lexical
|
|
23
|
+
* search (lib/recall.ts) is untouched. This is the default.
|
|
24
|
+
* - Embeddings have their OWN auth path (an embedding API key + an
|
|
25
|
+
* OpenAI-compatible endpoint), independent of the chat-model resolution in
|
|
26
|
+
* Runtime.resolveModel.
|
|
27
|
+
* - The compute/store functions take an injected `Embedder`, so unit tests
|
|
28
|
+
* mock embedding with NO network.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ── constants ─────────────────────────────────────────────
|
|
32
|
+
export const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
|
|
33
|
+
export const DEFAULT_EMBEDDING_BASE_URL = "https://api.openai.com";
|
|
34
|
+
/** Cap on body chars fed into a single embedding (keep prompts bounded). */
|
|
35
|
+
const MAX_BODY_CHARS = 8_000;
|
|
36
|
+
const STORE_VERSION = "1.0";
|
|
37
|
+
|
|
38
|
+
// ── types ─────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** Embeds a batch of texts into raw (un-normalized) vectors. */
|
|
41
|
+
export type EmbedFn = (texts: string[]) => Promise<number[][]>;
|
|
42
|
+
|
|
43
|
+
/** A resolved embedding backend: a model label + the embed function. */
|
|
44
|
+
export interface Embedder {
|
|
45
|
+
model: string;
|
|
46
|
+
embed: EmbedFn;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EmbeddingEntry {
|
|
50
|
+
/** sha256 of the exact text that was embedded — drives staleness detection. */
|
|
51
|
+
hash: string;
|
|
52
|
+
/** Embedding model label that produced this vector. */
|
|
53
|
+
model: string;
|
|
54
|
+
/** Vector dimensionality. */
|
|
55
|
+
dim: number;
|
|
56
|
+
/** Normalized (unit-length) vector for cosine similarity. */
|
|
57
|
+
vector: number[];
|
|
58
|
+
/** ISO timestamp of when this entry was written. */
|
|
59
|
+
updated: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EmbeddingStore {
|
|
63
|
+
version: string;
|
|
64
|
+
/** Keyed by folder-qualified page id (e.g. "concepts/rag"). */
|
|
65
|
+
entries: Record<string, EmbeddingEntry>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EmbedStats {
|
|
69
|
+
embedded: number;
|
|
70
|
+
skipped: number;
|
|
71
|
+
total: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ReindexStats extends EmbedStats {
|
|
75
|
+
pruned: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── vector math (shared with retrieval #67) ───────────────
|
|
79
|
+
|
|
80
|
+
/** Normalize a vector to unit length so dot product == cosine similarity. */
|
|
81
|
+
export function normalizeVector(vec: number[]): number[] {
|
|
82
|
+
const sanitized = vec.map((v) => (Number.isFinite(v) ? v : 0));
|
|
83
|
+
const magnitude = Math.sqrt(sanitized.reduce((sum, v) => sum + v * v, 0));
|
|
84
|
+
if (magnitude < 1e-10) return new Array(sanitized.length).fill(0);
|
|
85
|
+
return sanitized.map((v) => v / magnitude);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Cosine similarity of two vectors. Robust to un-normalized input. */
|
|
89
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
90
|
+
if (a.length === 0 || a.length !== b.length) return 0;
|
|
91
|
+
let dot = 0;
|
|
92
|
+
let normA = 0;
|
|
93
|
+
let normB = 0;
|
|
94
|
+
for (let i = 0; i < a.length; i++) {
|
|
95
|
+
dot += a[i] * b[i];
|
|
96
|
+
normA += a[i] * a[i];
|
|
97
|
+
normB += b[i] * b[i];
|
|
98
|
+
}
|
|
99
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
100
|
+
if (denom === 0) return 0;
|
|
101
|
+
return dot / denom;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Stable content hash of the text that was (or will be) embedded. */
|
|
105
|
+
export function contentHash(text: string): string {
|
|
106
|
+
return createHash("sha256").update(text).digest("hex");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── embedding text ────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the text to embed for a page: its title + salient frontmatter +
|
|
113
|
+
* (bounded) body. Mirrors memex's `buildEmbeddingText` — front-loading the
|
|
114
|
+
* high-signal metadata then appending the body content.
|
|
115
|
+
*/
|
|
116
|
+
export function buildEmbeddingText(
|
|
117
|
+
id: string,
|
|
118
|
+
frontmatter: Record<string, unknown>,
|
|
119
|
+
body: string,
|
|
120
|
+
): string {
|
|
121
|
+
const parts: string[] = [];
|
|
122
|
+
|
|
123
|
+
const title = frontmatter.title;
|
|
124
|
+
parts.push(`title: ${typeof title === "string" && title.trim() ? title.trim() : id}`);
|
|
125
|
+
if (typeof frontmatter.type === "string" && frontmatter.type.trim()) {
|
|
126
|
+
parts.push(`type: ${frontmatter.type.trim()}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const key of [
|
|
130
|
+
"aliases",
|
|
131
|
+
"recall_triggers",
|
|
132
|
+
"summary",
|
|
133
|
+
"description",
|
|
134
|
+
"tags",
|
|
135
|
+
"category",
|
|
136
|
+
"domain",
|
|
137
|
+
]) {
|
|
138
|
+
const val = frontmatter[key];
|
|
139
|
+
if (typeof val === "string" && val.trim()) {
|
|
140
|
+
parts.push(`${key}: ${val.trim()}`);
|
|
141
|
+
} else if (Array.isArray(val) && val.length > 0) {
|
|
142
|
+
parts.push(`${key}: ${val.map((v) => String(v)).join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const head = parts.join("\n");
|
|
147
|
+
const trimmedBody = body.trim().slice(0, MAX_BODY_CHARS);
|
|
148
|
+
return trimmedBody ? `${head}\n\n${trimmedBody}` : head;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── sidecar store I/O ─────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export function embeddingStorePath(paths: VaultPaths): string {
|
|
154
|
+
return join(paths.meta, "embeddings.json");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function readEmbeddingStore(paths: VaultPaths): EmbeddingStore {
|
|
158
|
+
const store = readJson<EmbeddingStore>(embeddingStorePath(paths), {
|
|
159
|
+
version: STORE_VERSION,
|
|
160
|
+
entries: {},
|
|
161
|
+
});
|
|
162
|
+
if (!store.entries || typeof store.entries !== "object") {
|
|
163
|
+
return { version: STORE_VERSION, entries: {} };
|
|
164
|
+
}
|
|
165
|
+
return store;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function writeEmbeddingStore(paths: VaultPaths, store: EmbeddingStore): void {
|
|
169
|
+
writeJson(embeddingStorePath(paths), store);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True if the page id has no fresh embedding for the given hash + model. */
|
|
173
|
+
export function isStale(store: EmbeddingStore, id: string, hash: string, model: string): boolean {
|
|
174
|
+
const entry = store.entries[id];
|
|
175
|
+
if (!entry) return true;
|
|
176
|
+
return entry.hash !== hash || entry.model !== model;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── compute ───────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
interface PageText {
|
|
182
|
+
id: string;
|
|
183
|
+
text: string;
|
|
184
|
+
hash: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read a page file (if present) and derive its embedding text + hash. */
|
|
188
|
+
function readPageText(paths: VaultPaths, id: string): PageText | undefined {
|
|
189
|
+
const pagePath = join(paths.wiki, `${id}.md`);
|
|
190
|
+
if (!existsSync(pagePath)) return undefined;
|
|
191
|
+
const raw = readFileSync(pagePath, "utf-8");
|
|
192
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
193
|
+
const text = buildEmbeddingText(id, frontmatter, body);
|
|
194
|
+
return { id, text, hash: contentHash(text) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Embed the given page ids, writing fresh vectors into the sidecar store.
|
|
199
|
+
* Stale-aware: pages whose hash + model already match are skipped (unless
|
|
200
|
+
* `force`). Pure async — pass a mock `Embedder` to test without a network.
|
|
201
|
+
*/
|
|
202
|
+
export async function embedPages(
|
|
203
|
+
paths: VaultPaths,
|
|
204
|
+
ids: string[],
|
|
205
|
+
embedder: Embedder,
|
|
206
|
+
opts: { force?: boolean } = {},
|
|
207
|
+
): Promise<EmbedStats> {
|
|
208
|
+
const store = readEmbeddingStore(paths);
|
|
209
|
+
const targets: PageText[] = [];
|
|
210
|
+
let skipped = 0;
|
|
211
|
+
|
|
212
|
+
const seen = new Set<string>();
|
|
213
|
+
for (const id of ids) {
|
|
214
|
+
if (seen.has(id)) continue;
|
|
215
|
+
seen.add(id);
|
|
216
|
+
const page = readPageText(paths, id);
|
|
217
|
+
if (!page) continue;
|
|
218
|
+
if (!opts.force && !isStale(store, id, page.hash, embedder.model)) {
|
|
219
|
+
skipped++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
targets.push(page);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (targets.length > 0) {
|
|
226
|
+
const vectors = await embedder.embed(targets.map((t) => t.text));
|
|
227
|
+
const now = new Date().toISOString();
|
|
228
|
+
for (let i = 0; i < targets.length; i++) {
|
|
229
|
+
const vec = normalizeVector(vectors[i] ?? []);
|
|
230
|
+
store.entries[targets[i].id] = {
|
|
231
|
+
hash: targets[i].hash,
|
|
232
|
+
model: embedder.model,
|
|
233
|
+
dim: vec.length,
|
|
234
|
+
vector: vec,
|
|
235
|
+
updated: now,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
writeEmbeddingStore(paths, store);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { embedded: targets.length, skipped, total: seen.size };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Embed every registered wiki page that has a backing file, skipping fresh
|
|
246
|
+
* ones (unless `force`), and prune sidecar entries for deleted pages. This is
|
|
247
|
+
* the backfill / re-embed-stale path used by the reindex command.
|
|
248
|
+
*/
|
|
249
|
+
export async function reindexEmbeddings(
|
|
250
|
+
paths: VaultPaths,
|
|
251
|
+
embedder: Embedder,
|
|
252
|
+
opts: { force?: boolean } = {},
|
|
253
|
+
): Promise<ReindexStats> {
|
|
254
|
+
const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
|
|
255
|
+
version: "1.0",
|
|
256
|
+
last_updated: "",
|
|
257
|
+
pages: {},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const ids = Object.keys(registry.pages).filter((id) => existsSync(join(paths.wiki, `${id}.md`)));
|
|
261
|
+
|
|
262
|
+
const stats = await embedPages(paths, ids, embedder, opts);
|
|
263
|
+
|
|
264
|
+
// Prune entries whose page file no longer exists.
|
|
265
|
+
const store = readEmbeddingStore(paths);
|
|
266
|
+
let pruned = 0;
|
|
267
|
+
for (const id of Object.keys(store.entries)) {
|
|
268
|
+
if (!existsSync(join(paths.wiki, `${id}.md`))) {
|
|
269
|
+
delete store.entries[id];
|
|
270
|
+
pruned++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (pruned > 0) writeEmbeddingStore(paths, store);
|
|
274
|
+
|
|
275
|
+
return { ...stats, pruned };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── provider resolution (OpenAI-compatible) ───────────────
|
|
279
|
+
|
|
280
|
+
/** Compose the /v1/embeddings request path from an optional base path. */
|
|
281
|
+
function embeddingsRequestPath(basePath: string): string {
|
|
282
|
+
if (!basePath || basePath === "/") return "/v1/embeddings";
|
|
283
|
+
if (basePath.endsWith("/v1")) return `${basePath}/embeddings`;
|
|
284
|
+
return `${basePath}/v1/embeddings`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface EmbeddingApiResponse {
|
|
288
|
+
data?: Array<{ index: number; embedding: number[] }>;
|
|
289
|
+
error?: { message?: string };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Create an `EmbedFn` backed by an OpenAI-compatible `/v1/embeddings`
|
|
294
|
+
* endpoint. Uses node's http/https directly (no SDK) so it works against
|
|
295
|
+
* OpenAI, Azure (with an api-key header), or any compatible gateway.
|
|
296
|
+
*/
|
|
297
|
+
export function createOpenAIEmbedFn(cfg: {
|
|
298
|
+
apiKey: string;
|
|
299
|
+
baseUrl: string;
|
|
300
|
+
model: string;
|
|
301
|
+
headers?: Record<string, string>;
|
|
302
|
+
}): EmbedFn {
|
|
303
|
+
const parsed = new URL(cfg.baseUrl);
|
|
304
|
+
const basePath = parsed.pathname.replace(/\/$/, "");
|
|
305
|
+
const requestPath = embeddingsRequestPath(basePath);
|
|
306
|
+
const useHttp = parsed.protocol === "http:";
|
|
307
|
+
const port = parsed.port ? Number(parsed.port) : undefined;
|
|
308
|
+
|
|
309
|
+
return (texts) =>
|
|
310
|
+
new Promise<number[][]>((resolve, reject) => {
|
|
311
|
+
if (texts.length === 0) {
|
|
312
|
+
resolve([]);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const body = JSON.stringify({ model: cfg.model, input: texts });
|
|
316
|
+
const reqFn = useHttp ? httpRequest : httpsRequest;
|
|
317
|
+
const req = reqFn(
|
|
318
|
+
{
|
|
319
|
+
hostname: parsed.hostname,
|
|
320
|
+
...(port ? { port } : {}),
|
|
321
|
+
path: requestPath,
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: {
|
|
324
|
+
"Content-Type": "application/json",
|
|
325
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
326
|
+
...(cfg.headers ?? {}),
|
|
327
|
+
"Content-Length": Buffer.byteLength(body),
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
(res) => {
|
|
331
|
+
let data = "";
|
|
332
|
+
res.on("data", (chunk) => {
|
|
333
|
+
data += chunk.toString();
|
|
334
|
+
});
|
|
335
|
+
res.on("end", () => {
|
|
336
|
+
try {
|
|
337
|
+
const parsedBody = JSON.parse(data) as EmbeddingApiResponse;
|
|
338
|
+
if (parsedBody.error) {
|
|
339
|
+
reject(new Error(`embedding API error: ${parsedBody.error.message ?? "unknown"}`));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const rows = parsedBody.data ?? [];
|
|
343
|
+
const sorted = [...rows].sort((a, b) => a.index - b.index);
|
|
344
|
+
resolve(sorted.map((d) => d.embedding));
|
|
345
|
+
} catch (err) {
|
|
346
|
+
reject(new Error(`failed to parse embedding response: ${(err as Error).message}`));
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
);
|
|
351
|
+
req.on("error", reject);
|
|
352
|
+
req.write(body);
|
|
353
|
+
req.end();
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Resolve an `Embedder` from config, or `undefined` when embeddings are not
|
|
359
|
+
* configured (the default — fully optional, silent no-op).
|
|
360
|
+
*
|
|
361
|
+
* Opt-in is explicit: `embeddingProvider` MUST be set (we do not auto-enable
|
|
362
|
+
* just because an ambient OPENAI_API_KEY happens to exist). Only the
|
|
363
|
+
* OpenAI-compatible provider is supported; anything else no-ops.
|
|
364
|
+
*/
|
|
365
|
+
export function resolveEmbedder(config: TaskConfig): Embedder | undefined {
|
|
366
|
+
const provider = config.embeddingProvider?.trim().toLowerCase();
|
|
367
|
+
if (!provider) return undefined;
|
|
368
|
+
if (provider !== "openai" && provider !== "openai-compatible") return undefined;
|
|
369
|
+
|
|
370
|
+
const keyEnv = config.embeddingApiKeyEnv?.trim() || "OPENAI_API_KEY";
|
|
371
|
+
const apiKey = config.embeddingApiKey?.trim() || process.env[keyEnv]?.trim();
|
|
372
|
+
if (!apiKey) return undefined;
|
|
373
|
+
|
|
374
|
+
const model = config.embeddingModel?.trim() || DEFAULT_EMBEDDING_MODEL;
|
|
375
|
+
const baseUrl =
|
|
376
|
+
config.embeddingBaseUrl?.trim() ||
|
|
377
|
+
process.env.OPENAI_BASE_URL?.trim() ||
|
|
378
|
+
DEFAULT_EMBEDDING_BASE_URL;
|
|
379
|
+
|
|
380
|
+
return { model, embed: createOpenAIEmbedFn({ apiKey, baseUrl, model }) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── background launch helpers (used by tools/guardrails) ──
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Launch a background task that embeds a specific set of pages, if (and only
|
|
387
|
+
* if) an embedder is configured. No-op (returns false) otherwise. Single-flight
|
|
388
|
+
* per label, error-isolated, drained at compaction/shutdown — all via #64.
|
|
389
|
+
*/
|
|
390
|
+
export function launchEmbedPages(
|
|
391
|
+
runtime: Runtime,
|
|
392
|
+
ctx: LaunchCtx,
|
|
393
|
+
paths: VaultPaths,
|
|
394
|
+
ids: string[],
|
|
395
|
+
label: string,
|
|
396
|
+
): boolean {
|
|
397
|
+
if (ids.length === 0) return false;
|
|
398
|
+
runtime.ensureConfig(paths.root);
|
|
399
|
+
const embedder = resolveEmbedder(runtime.config);
|
|
400
|
+
if (!embedder) return false;
|
|
401
|
+
runtime.launchTask(ctx, label, async () => {
|
|
402
|
+
await embedPages(paths, ids, embedder);
|
|
403
|
+
});
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Launch a background reindex (embed all stale registered pages + prune
|
|
409
|
+
* deleted), if an embedder is configured. No-op otherwise. Single-flight per
|
|
410
|
+
* vault so repeated writes within a turn collapse into one pass.
|
|
411
|
+
*/
|
|
412
|
+
export function launchReindex(runtime: Runtime, ctx: LaunchCtx, paths: VaultPaths): boolean {
|
|
413
|
+
runtime.ensureConfig(paths.root);
|
|
414
|
+
const embedder = resolveEmbedder(runtime.config);
|
|
415
|
+
if (!embedder) return false;
|
|
416
|
+
runtime.launchTask(ctx, `embed:reindex:${paths.root}`, async () => {
|
|
417
|
+
await reindexEmbeddings(paths, embedder);
|
|
418
|
+
});
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { scheduleReindex } from "./indexing.js";
|
|
3
4
|
import { rebuildMetadataLight } from "./metadata.js";
|
|
5
|
+
import type { Runtime } from "./runtime.js";
|
|
4
6
|
import { isProtectedPath, resolveVaultPaths } from "./utils.js";
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -10,7 +12,7 @@ import { isProtectedPath, resolveVaultPaths } from "./utils.js";
|
|
|
10
12
|
let pendingRebuild = false;
|
|
11
13
|
|
|
12
14
|
/** Install guardrails on the extension API. */
|
|
13
|
-
export function installGuardrails(pi: ExtensionAPI): void {
|
|
15
|
+
export function installGuardrails(pi: ExtensionAPI, runtime?: Runtime): void {
|
|
14
16
|
// Block direct edits to raw/ and meta/
|
|
15
17
|
pi.on("tool_call", async (event) => {
|
|
16
18
|
if (isToolCallEventType("write", event)) {
|
|
@@ -44,13 +46,22 @@ export function installGuardrails(pi: ExtensionAPI): void {
|
|
|
44
46
|
}
|
|
45
47
|
});
|
|
46
48
|
|
|
47
|
-
// Rebuild metadata at end of turn if wiki was modified
|
|
48
|
-
|
|
49
|
+
// Rebuild metadata at end of turn if wiki was modified, then refresh
|
|
50
|
+
// semantic embeddings in the background (#66) so manual page edits get
|
|
51
|
+
// re-embedded. Both are best-effort no-ops when nothing is configured.
|
|
52
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
49
53
|
if (pendingRebuild) {
|
|
50
54
|
pendingRebuild = false;
|
|
51
55
|
try {
|
|
52
56
|
const paths = resolveVaultPaths(process.cwd());
|
|
53
|
-
|
|
57
|
+
// Manual page edits also rebuild off the critical path. Without a
|
|
58
|
+
// runtime (shouldn't happen in normal wiring) fall back to inline.
|
|
59
|
+
if (runtime) {
|
|
60
|
+
const launchCtx = ctx ? { hasUI: ctx.hasUI, ui: ctx.ui } : { hasUI: false as const };
|
|
61
|
+
scheduleReindex(runtime, launchCtx, paths);
|
|
62
|
+
} else {
|
|
63
|
+
rebuildMetadataLight(paths);
|
|
64
|
+
}
|
|
54
65
|
} catch {
|
|
55
66
|
// Silently fail — metadata rebuild is best-effort
|
|
56
67
|
}
|