@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.
- package/CHANGELOG.md +4 -0
- package/README.de.md +35 -4
- package/README.es.md +260 -170
- package/README.fr.md +35 -4
- package/README.hi.md +35 -4
- package/README.ja.md +35 -4
- package/README.ko.md +35 -4
- package/README.md +38 -3
- package/README.pt.md +35 -4
- package/README.ru.md +35 -4
- package/README.zh.md +260 -170
- package/assets/demo.gif +0 -0
- package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
- package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
- package/dist/extensions/llm-wiki/lib/inject.js +65 -0
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
- package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
- package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
- package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/observation.js +283 -0
- package/dist/extensions/llm-wiki/lib/recall.js +875 -0
- package/dist/extensions/llm-wiki/lib/retro.js +158 -0
- package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
- package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
- package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
- package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
- package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
- package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
- package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
- package/dist/extensions/llm-wiki/lib/utils.js +347 -0
- package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
- package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
- package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
- package/dist/mcp/exec.js +121 -0
- package/dist/mcp/index.js +229 -0
- package/dist/mcp/operations.js +130 -0
- package/dist/package.json +1 -0
- package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
- package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
- package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
- package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
- package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
- package/extensions/llm-wiki/index.ts +22 -36
- package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
- package/extensions/llm-wiki/lib/embeddings.ts +9 -3
- package/extensions/llm-wiki/lib/guardrails.ts +174 -29
- package/extensions/llm-wiki/lib/indexing.ts +2 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
- package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
- package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
- package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
- package/extensions/llm-wiki/lib/metadata.ts +531 -116
- package/extensions/llm-wiki/lib/observation.ts +37 -43
- package/extensions/llm-wiki/lib/recall.ts +61 -33
- package/extensions/llm-wiki/lib/retro.ts +65 -41
- package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
- package/extensions/llm-wiki/lib/source-packet.ts +44 -31
- package/extensions/llm-wiki/lib/tools.ts +406 -348
- package/extensions/llm-wiki/lib/trajectory.ts +15 -1
- package/extensions/llm-wiki/lib/utils.ts +121 -130
- package/extensions/llm-wiki/lib/vault-format.ts +363 -0
- package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
- package/mcp/exec.ts +122 -0
- package/mcp/index.ts +60 -250
- package/mcp/operations.ts +176 -0
- package/package.json +8 -2
- package/scripts/migrate-llm-wiki.js +801 -0
- 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
|
+
}
|