@zosmaai/pi-llm-wiki 0.10.7 → 0.11.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.de.md +35 -4
  3. package/README.es.md +260 -170
  4. package/README.fr.md +35 -4
  5. package/README.hi.md +35 -4
  6. package/README.ja.md +35 -4
  7. package/README.ko.md +35 -4
  8. package/README.md +38 -3
  9. package/README.pt.md +35 -4
  10. package/README.ru.md +35 -4
  11. package/README.zh.md +260 -170
  12. package/assets/demo.gif +0 -0
  13. package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
  14. package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
  15. package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
  16. package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
  17. package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
  18. package/dist/extensions/llm-wiki/lib/inject.js +65 -0
  19. package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
  20. package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
  21. package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
  22. package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
  23. package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
  24. package/dist/extensions/llm-wiki/lib/observation.js +283 -0
  25. package/dist/extensions/llm-wiki/lib/recall.js +875 -0
  26. package/dist/extensions/llm-wiki/lib/retro.js +158 -0
  27. package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
  28. package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
  29. package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
  30. package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
  31. package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
  32. package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
  33. package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
  34. package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
  35. package/dist/extensions/llm-wiki/lib/utils.js +347 -0
  36. package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
  37. package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
  38. package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
  39. package/dist/mcp/exec.js +121 -0
  40. package/dist/mcp/index.js +229 -0
  41. package/dist/mcp/operations.js +130 -0
  42. package/dist/package.json +1 -0
  43. package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
  44. package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
  45. package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
  46. package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
  47. package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
  48. package/extensions/llm-wiki/index.ts +22 -36
  49. package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
  50. package/extensions/llm-wiki/lib/embeddings.ts +9 -3
  51. package/extensions/llm-wiki/lib/guardrails.ts +174 -29
  52. package/extensions/llm-wiki/lib/indexing.ts +2 -1
  53. package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
  54. package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
  55. package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
  56. package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
  57. package/extensions/llm-wiki/lib/metadata.ts +531 -116
  58. package/extensions/llm-wiki/lib/observation.ts +37 -43
  59. package/extensions/llm-wiki/lib/recall.ts +61 -33
  60. package/extensions/llm-wiki/lib/retro.ts +65 -41
  61. package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
  62. package/extensions/llm-wiki/lib/source-packet.ts +44 -31
  63. package/extensions/llm-wiki/lib/tools.ts +406 -348
  64. package/extensions/llm-wiki/lib/trajectory.ts +15 -1
  65. package/extensions/llm-wiki/lib/utils.ts +121 -130
  66. package/extensions/llm-wiki/lib/vault-format.ts +363 -0
  67. package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
  68. package/mcp/exec.ts +122 -0
  69. package/mcp/index.ts +60 -250
  70. package/mcp/operations.ts +176 -0
  71. package/package.json +8 -2
  72. package/scripts/migrate-llm-wiki.js +801 -0
  73. package/skills/llm-wiki/SKILL.md +8 -6
@@ -0,0 +1,71 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { appendEvent, rebuildMetadata } from "./metadata.js";
4
+ import { ensureVaultStructure, fmtDate, writeJson } from "./utils.js";
5
+ import { inspectWritableVault, readVaultConfig } from "./vault-format.js";
6
+ export const WIKI_SCHEMA = [
7
+ "# LLM Wiki Schema",
8
+ "",
9
+ "## Ownership Rules",
10
+ "",
11
+ "| Path | Owner | Rule |",
12
+ "|------|-------|------|",
13
+ "| raw/** | extension | immutable after capture |",
14
+ "| wiki/** | model + user | editable knowledge pages |",
15
+ "| meta/* | extension | auto-generated |",
16
+ "| . | human + explicit request | operating rules |",
17
+ "",
18
+ "## Source Packet Format",
19
+ "",
20
+ "```",
21
+ "raw/sources/SRC-YYYY-MM-DD-NNN/",
22
+ " manifest.json",
23
+ " original/",
24
+ " extracted.md",
25
+ " attachments/",
26
+ "```",
27
+ "",
28
+ "## Page Types",
29
+ "",
30
+ "- **source** — what this specific source says",
31
+ "- **entity** — people, orgs, tools, products",
32
+ "- **concept** — ideas, patterns, frameworks",
33
+ "- **synthesis** — cross-source theses and tensions",
34
+ "- **analysis** — durable filed answers from queries",
35
+ "- **requirement** — atomic requirements with status, priority, and traceability",
36
+ "",
37
+ "## Linking Style",
38
+ "",
39
+ "- New internal links: [label](/folder/page.md)",
40
+ "- Legacy readable links: [[folder/page]]",
41
+ "- Source citation: [source](/sources/SRC-YYYY-MM-DD-NNN.md)",
42
+ "",
43
+ ].join("\n");
44
+ export function bootstrapVault(paths, input) {
45
+ const configPath = join(paths.dotWiki, "config.json");
46
+ const created = !existsSync(paths.dotWiki);
47
+ let existing = {};
48
+ if (!created) {
49
+ const writable = inspectWritableVault(paths);
50
+ if (!writable.ok)
51
+ return { ok: false, created: false, diagnostics: writable.diagnostics };
52
+ const config = readVaultConfig(paths);
53
+ if (!config.ok)
54
+ return { ok: false, created: false, diagnostics: [config.diagnostic] };
55
+ existing = config.config;
56
+ }
57
+ const config = {
58
+ ...existing,
59
+ name: input.topic,
60
+ mode: input.mode,
61
+ topic: input.topic,
62
+ created: existing.created ?? fmtDate(),
63
+ version: existing.version ?? "1.0",
64
+ ...(created ? { knowledge_format: "okf-0.2" } : {}),
65
+ };
66
+ ensureVaultStructure(paths);
67
+ writeJson(configPath, config);
68
+ writeFileSync(join(paths.dotWiki, "WIKI_SCHEMA.md"), WIKI_SCHEMA, "utf8");
69
+ appendEvent(paths, { kind: "bootstrap", topic: input.topic, mode: input.mode });
70
+ return { ok: true, created, projection: rebuildMetadata(paths) };
71
+ }
@@ -0,0 +1,401 @@
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 { parseKnowledgeDocument } from "./knowledge-document.js";
7
+ import { readJson, writeJson } from "./utils.js";
8
+ import { assertWritableVault } from "./vault-format.js";
9
+ /**
10
+ * Background semantic embeddings, computed at write time (issue #66, epic #63).
11
+ *
12
+ * Every wiki page gets a normalized embedding vector stored in a sidecar
13
+ * (`meta/embeddings.json`), keyed by page id with a content hash for staleness
14
+ * detection. Embeddings are computed in the background via the #64 runtime so
15
+ * the main agent is never blocked, and so that semantic retrieval (#67) can
16
+ * rank pages WITHOUT any embedding/LLM call in the query hot path.
17
+ *
18
+ * Design principles:
19
+ * - Fully optional: with no embedding provider configured, `resolveEmbedder`
20
+ * returns undefined and every entry point no-ops silently. Existing lexical
21
+ * search (lib/recall.ts) is untouched. This is the default.
22
+ * - Embeddings have their OWN auth path (an embedding API key + an
23
+ * OpenAI-compatible endpoint), independent of the chat-model resolution in
24
+ * Runtime.resolveModel.
25
+ * - The compute/store functions take an injected `Embedder`, so unit tests
26
+ * mock embedding with NO network.
27
+ */
28
+ // ── constants ─────────────────────────────────────────────
29
+ export const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
30
+ export const DEFAULT_EMBEDDING_BASE_URL = "https://api.openai.com";
31
+ /** Cap on body chars fed into a single embedding (keep prompts bounded). */
32
+ const MAX_BODY_CHARS = 8_000;
33
+ const STORE_VERSION = "1.0";
34
+ // ── vector math (shared with retrieval #67) ───────────────
35
+ /** Normalize a vector to unit length so dot product == cosine similarity. */
36
+ export function normalizeVector(vec) {
37
+ const sanitized = vec.map((v) => (Number.isFinite(v) ? v : 0));
38
+ const magnitude = Math.sqrt(sanitized.reduce((sum, v) => sum + v * v, 0));
39
+ if (magnitude < 1e-10)
40
+ return new Array(sanitized.length).fill(0);
41
+ return sanitized.map((v) => v / magnitude);
42
+ }
43
+ /** Cosine similarity of two vectors. Robust to un-normalized input. */
44
+ export function cosineSimilarity(a, b) {
45
+ if (a.length === 0 || a.length !== b.length)
46
+ return 0;
47
+ let dot = 0;
48
+ let normA = 0;
49
+ let normB = 0;
50
+ for (let i = 0; i < a.length; i++) {
51
+ dot += a[i] * b[i];
52
+ normA += a[i] * a[i];
53
+ normB += b[i] * b[i];
54
+ }
55
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
56
+ if (denom === 0)
57
+ return 0;
58
+ return dot / denom;
59
+ }
60
+ /** Stable content hash of the text that was (or will be) embedded. */
61
+ export function contentHash(text) {
62
+ return createHash("sha256").update(text).digest("hex");
63
+ }
64
+ // ── embedding text ────────────────────────────────────────
65
+ /**
66
+ * Build the text to embed for a page: its title + salient frontmatter +
67
+ * (bounded) body. Mirrors memex's `buildEmbeddingText` — front-loading the
68
+ * high-signal metadata then appending the body content.
69
+ */
70
+ export function buildEmbeddingText(id, frontmatter, body) {
71
+ const parts = [];
72
+ const title = frontmatter.title;
73
+ parts.push(`title: ${typeof title === "string" && title.trim() ? title.trim() : id}`);
74
+ if (typeof frontmatter.type === "string" && frontmatter.type.trim()) {
75
+ parts.push(`type: ${frontmatter.type.trim()}`);
76
+ }
77
+ for (const key of [
78
+ "aliases",
79
+ "recall_triggers",
80
+ "summary",
81
+ "description",
82
+ "tags",
83
+ "category",
84
+ "domain",
85
+ ]) {
86
+ const val = frontmatter[key];
87
+ if (typeof val === "string" && val.trim()) {
88
+ parts.push(`${key}: ${val.trim()}`);
89
+ }
90
+ else if (Array.isArray(val) && val.length > 0) {
91
+ parts.push(`${key}: ${val.map((v) => String(v)).join(", ")}`);
92
+ }
93
+ }
94
+ const head = parts.join("\n");
95
+ const trimmedBody = body.trim().slice(0, MAX_BODY_CHARS);
96
+ return trimmedBody ? `${head}\n\n${trimmedBody}` : head;
97
+ }
98
+ // ── sidecar store I/O ─────────────────────────────────────
99
+ export function embeddingStorePath(paths) {
100
+ return join(paths.meta, "embeddings.json");
101
+ }
102
+ export function readEmbeddingStore(paths) {
103
+ const store = readJson(embeddingStorePath(paths), {
104
+ version: STORE_VERSION,
105
+ entries: {},
106
+ });
107
+ if (!store.entries || typeof store.entries !== "object") {
108
+ return { version: STORE_VERSION, entries: {} };
109
+ }
110
+ return store;
111
+ }
112
+ export function writeEmbeddingStore(paths, store) {
113
+ assertWritableVault(paths);
114
+ writeJson(embeddingStorePath(paths), store);
115
+ }
116
+ /** True if the page id has no fresh embedding for the given hash + model. */
117
+ export function isStale(store, id, hash, model) {
118
+ const entry = store.entries[id];
119
+ if (!entry)
120
+ return true;
121
+ return entry.hash !== hash || entry.model !== model;
122
+ }
123
+ /** Read a page file (if present) and derive its embedding text + hash. */
124
+ function readPageText(paths, id) {
125
+ const pagePath = join(paths.wiki, `${id}.md`);
126
+ if (!existsSync(pagePath))
127
+ return undefined;
128
+ const raw = readFileSync(pagePath, "utf-8");
129
+ const result = parseKnowledgeDocument(raw, `${id}.md`);
130
+ if (!result.ok)
131
+ return undefined;
132
+ const text = buildEmbeddingText(id, result.document.frontmatter, result.document.body);
133
+ return { id, text, hash: contentHash(text) };
134
+ }
135
+ /**
136
+ * Embed the given page ids, writing fresh vectors into the sidecar store.
137
+ * Stale-aware: pages whose hash + model already match are skipped (unless
138
+ * `force`). Pure async — pass a mock `Embedder` to test without a network.
139
+ */
140
+ export async function embedPages(paths, ids, embedder, opts = {}) {
141
+ assertWritableVault(paths);
142
+ const store = readEmbeddingStore(paths);
143
+ const targets = [];
144
+ let skipped = 0;
145
+ const seen = new Set();
146
+ for (const id of ids) {
147
+ if (seen.has(id))
148
+ continue;
149
+ seen.add(id);
150
+ const page = readPageText(paths, id);
151
+ if (!page)
152
+ continue;
153
+ if (!opts.force && !isStale(store, id, page.hash, embedder.model)) {
154
+ skipped++;
155
+ continue;
156
+ }
157
+ targets.push(page);
158
+ }
159
+ if (targets.length > 0) {
160
+ const vectors = await embedder.embed(targets.map((t) => t.text));
161
+ const now = new Date().toISOString();
162
+ for (let i = 0; i < targets.length; i++) {
163
+ const vec = normalizeVector(vectors[i] ?? []);
164
+ store.entries[targets[i].id] = {
165
+ hash: targets[i].hash,
166
+ model: embedder.model,
167
+ dim: vec.length,
168
+ vector: vec,
169
+ updated: now,
170
+ };
171
+ }
172
+ writeEmbeddingStore(paths, store);
173
+ }
174
+ return { embedded: targets.length, skipped, total: seen.size };
175
+ }
176
+ /**
177
+ * Embed every registered wiki page that has a backing file, skipping fresh
178
+ * ones (unless `force`), and prune sidecar entries for deleted pages. This is
179
+ * the backfill / re-embed-stale path used by the reindex command.
180
+ */
181
+ export async function reindexEmbeddings(paths, embedder, opts = {}) {
182
+ assertWritableVault(paths);
183
+ const registry = readJson(join(paths.meta, "registry.json"), {
184
+ version: "1.0",
185
+ last_updated: "",
186
+ pages: {},
187
+ });
188
+ const ids = Object.keys(registry.pages).filter((id) => existsSync(join(paths.wiki, `${id}.md`)));
189
+ const stats = await embedPages(paths, ids, embedder, opts);
190
+ // Prune entries whose page file no longer exists.
191
+ const store = readEmbeddingStore(paths);
192
+ let pruned = 0;
193
+ for (const id of Object.keys(store.entries)) {
194
+ if (!existsSync(join(paths.wiki, `${id}.md`))) {
195
+ delete store.entries[id];
196
+ pruned++;
197
+ }
198
+ }
199
+ if (pruned > 0)
200
+ writeEmbeddingStore(paths, store);
201
+ return { ...stats, pruned };
202
+ }
203
+ // ── provider resolution (OpenAI-compatible) ───────────────
204
+ /** Compose the /v1/embeddings request path from an optional base path. */
205
+ function embeddingsRequestPath(basePath) {
206
+ if (!basePath || basePath === "/")
207
+ return "/v1/embeddings";
208
+ if (basePath.endsWith("/v1"))
209
+ return `${basePath}/embeddings`;
210
+ return `${basePath}/v1/embeddings`;
211
+ }
212
+ /**
213
+ * Max inputs per embeddings request. Providers cap batch size server-side
214
+ * (OpenAI 2048, Google Vertex `text-embedding-*` 250, Cohere v3 96); we chunk
215
+ * under the smallest common limit so a reindex of hundreds of pages never
216
+ * overruns the cap. Exported so tests and callers can reference the default.
217
+ */
218
+ export const DEFAULT_MAX_EMBED_BATCH = 96;
219
+ /**
220
+ * Approx per-request character budget. Providers also cap *total tokens* per
221
+ * request (Google Vertex `text-embedding-*` = 20,000 tokens). Tokenizing here
222
+ * would add a dependency, so we approximate with a conservative char budget
223
+ * (~2.8 chars/token observed for wiki prose, with headroom) — the request
224
+ * stays well under the token cap without counting tokens.
225
+ */
226
+ export const DEFAULT_MAX_EMBED_BATCH_CHARS = 45_000;
227
+ /**
228
+ * Split `texts` into batches that stay under BOTH a count cap and a total
229
+ * char budget. A single oversized text is emitted as its own batch rather than
230
+ * dropped, so every input is always embedded. Pure — unit-testable with no
231
+ * network.
232
+ */
233
+ export function chunkByBudget(texts, maxCount, maxChars) {
234
+ const batches = [];
235
+ let batch = [];
236
+ let chars = 0;
237
+ for (const text of texts) {
238
+ // Close the current batch before adding a text that would exceed either
239
+ // cap — but never emit an empty batch (an oversized text stands alone).
240
+ if (batch.length > 0 && (batch.length >= maxCount || chars + text.length > maxChars)) {
241
+ batches.push(batch);
242
+ batch = [];
243
+ chars = 0;
244
+ }
245
+ batch.push(text);
246
+ chars += text.length;
247
+ }
248
+ if (batch.length > 0)
249
+ batches.push(batch);
250
+ return batches;
251
+ }
252
+ /**
253
+ * Parse an OpenAI-compatible embeddings response into row-ordered vectors,
254
+ * throwing on any error shape so failures are never silently stored as empty
255
+ * vectors. Handles gateways that report errors via a nested `detail` field
256
+ * (GSA USAi / Vertex) or via HTTP status alone, and providers that omit the
257
+ * per-row `index` (returning rows in request order). Pure — unit-testable.
258
+ */
259
+ export function parseEmbeddingResponse(status, body, expectedCount) {
260
+ if (body.error || body.detail !== undefined || status < 200 || status >= 300) {
261
+ const message = body.error?.message ??
262
+ (typeof body.detail === "string"
263
+ ? body.detail
264
+ : body.detail !== undefined
265
+ ? JSON.stringify(body.detail)
266
+ : `HTTP ${status}`);
267
+ throw new Error(`embedding API error: ${message}`);
268
+ }
269
+ const rows = body.data ?? [];
270
+ if (rows.length !== expectedCount) {
271
+ throw new Error(`embedding API returned ${rows.length} vectors for ${expectedCount} inputs`);
272
+ }
273
+ const ordered = rows.every((r) => typeof r.index === "number")
274
+ ? [...rows].sort((a, b) => a.index - b.index)
275
+ : rows;
276
+ return ordered.map((r) => r.embedding);
277
+ }
278
+ /**
279
+ * Create an `EmbedFn` backed by an OpenAI-compatible `/v1/embeddings`
280
+ * endpoint. Uses node's http/https directly (no SDK) so it works against
281
+ * OpenAI, Azure (with an api-key header), or any compatible gateway.
282
+ *
283
+ * Inputs are split into batches under both the instance-count and total-char
284
+ * caps (see `chunkByBudget`) and re-joined in request order, so provider batch
285
+ * limits never truncate a reindex. Any request error is thrown, not swallowed.
286
+ */
287
+ export function createOpenAIEmbedFn(cfg) {
288
+ const parsed = new URL(cfg.baseUrl);
289
+ const basePath = parsed.pathname.replace(/\/$/, "");
290
+ const requestPath = embeddingsRequestPath(basePath);
291
+ const useHttp = parsed.protocol === "http:";
292
+ const port = parsed.port ? Number(parsed.port) : undefined;
293
+ const embedBatch = (texts) => new Promise((resolve, reject) => {
294
+ if (texts.length === 0) {
295
+ resolve([]);
296
+ return;
297
+ }
298
+ const body = JSON.stringify({ model: cfg.model, input: texts });
299
+ const reqFn = useHttp ? httpRequest : httpsRequest;
300
+ const req = reqFn({
301
+ hostname: parsed.hostname,
302
+ ...(port ? { port } : {}),
303
+ path: requestPath,
304
+ method: "POST",
305
+ headers: {
306
+ "Content-Type": "application/json",
307
+ Authorization: `Bearer ${cfg.apiKey}`,
308
+ ...(cfg.headers ?? {}),
309
+ "Content-Length": Buffer.byteLength(body),
310
+ },
311
+ }, (res) => {
312
+ let data = "";
313
+ res.on("data", (chunk) => {
314
+ data += chunk.toString();
315
+ });
316
+ res.on("end", () => {
317
+ let parsedBody;
318
+ try {
319
+ parsedBody = JSON.parse(data);
320
+ }
321
+ catch (err) {
322
+ reject(new Error(`failed to parse embedding response: ${err.message}`));
323
+ return;
324
+ }
325
+ try {
326
+ resolve(parseEmbeddingResponse(res.statusCode ?? 0, parsedBody, texts.length));
327
+ }
328
+ catch (err) {
329
+ reject(err);
330
+ }
331
+ });
332
+ });
333
+ req.on("error", reject);
334
+ req.write(body);
335
+ req.end();
336
+ });
337
+ return async (texts) => {
338
+ const out = [];
339
+ for (const batch of chunkByBudget(texts, DEFAULT_MAX_EMBED_BATCH, DEFAULT_MAX_EMBED_BATCH_CHARS)) {
340
+ out.push(...(await embedBatch(batch)));
341
+ }
342
+ return out;
343
+ };
344
+ }
345
+ /**
346
+ * Resolve an `Embedder` from config, or `undefined` when embeddings are not
347
+ * configured (the default — fully optional, silent no-op).
348
+ *
349
+ * Opt-in is explicit: `embeddingProvider` MUST be set (we do not auto-enable
350
+ * just because an ambient OPENAI_API_KEY happens to exist). Only the
351
+ * OpenAI-compatible provider is supported; anything else no-ops.
352
+ */
353
+ export function resolveEmbedder(config) {
354
+ const provider = config.embeddingProvider?.trim().toLowerCase();
355
+ if (!provider)
356
+ return undefined;
357
+ if (provider !== "openai" && provider !== "openai-compatible")
358
+ return undefined;
359
+ const keyEnv = config.embeddingApiKeyEnv?.trim() || "OPENAI_API_KEY";
360
+ const apiKey = config.embeddingApiKey?.trim() || process.env[keyEnv]?.trim();
361
+ if (!apiKey)
362
+ return undefined;
363
+ const model = config.embeddingModel?.trim() || DEFAULT_EMBEDDING_MODEL;
364
+ const baseUrl = config.embeddingBaseUrl?.trim() ||
365
+ process.env.OPENAI_BASE_URL?.trim() ||
366
+ DEFAULT_EMBEDDING_BASE_URL;
367
+ return { model, embed: createOpenAIEmbedFn({ apiKey, baseUrl, model }) };
368
+ }
369
+ // ── background launch helpers (used by tools/guardrails) ──
370
+ /**
371
+ * Launch a background task that embeds a specific set of pages, if (and only
372
+ * if) an embedder is configured. No-op (returns false) otherwise. Single-flight
373
+ * per label, error-isolated, drained at compaction/shutdown — all via #64.
374
+ */
375
+ export function launchEmbedPages(runtime, ctx, paths, ids, label) {
376
+ if (ids.length === 0)
377
+ return false;
378
+ runtime.ensureConfig(paths.root);
379
+ const embedder = resolveEmbedder(runtime.config);
380
+ if (!embedder)
381
+ return false;
382
+ runtime.launchTask(ctx, label, async () => {
383
+ await embedPages(paths, ids, embedder);
384
+ });
385
+ return true;
386
+ }
387
+ /**
388
+ * Launch a background reindex (embed all stale registered pages + prune
389
+ * deleted), if an embedder is configured. No-op otherwise. Single-flight per
390
+ * vault so repeated writes within a turn collapse into one pass.
391
+ */
392
+ export function launchReindex(runtime, ctx, paths) {
393
+ runtime.ensureConfig(paths.root);
394
+ const embedder = resolveEmbedder(runtime.config);
395
+ if (!embedder)
396
+ return false;
397
+ runtime.launchTask(ctx, `embed:reindex:${paths.root}`, async () => {
398
+ await reindexEmbeddings(paths, embedder);
399
+ });
400
+ return true;
401
+ }