@compr/opscontext-mcp 2.5.8 → 2.6.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 CHANGED
@@ -4,6 +4,58 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
4
4
 
5
5
  > Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
6
6
 
7
+ ## [2.6.0] 2026-09-05: One indexer, many readers
8
+
9
+ Every Claude Code chat spawns its own MCP server, plus launchd, plus VS Code. Measured on
10
+ 2026-09-05 (SESSION_26): eleven servers, 9.3 CPU-hours in 1.4 h, load average 230, each one
11
+ parsing the same ~820 sources and re-embedding every chunk after every save. The embedding cache
12
+ had hit 2 times in 51 starts, because its key was one hash over the whole corpus, git status
13
+ included; and a hit meant the model never loaded, so no semantic search and no re-embed, ever.
14
+
15
+ ### Added
16
+
17
+ - **Content-addressed vector store** (`[EMBEDDINGS-ARE-CONTENT-ADDRESSED]`,
18
+ `src/embedding-store.ts`): one vector per distinct embedded text in
19
+ `~/.contextengine/embeddings.bin`, shared by every server on the machine. A doc change embeds
20
+ only its new chunks; a cold start embeds only texts never seen before. Always on. The old
21
+ `embedding-cache.json` is no longer read.
22
+ - **Shared index with one writer per corpus** (`[ONE-INDEXER-MANY-READERS]`,
23
+ `src/shared-index.ts`), behind `CONTEXTENGINE_SHARED_INDEX=1`: the indexer is elected from the
24
+ server registry (a build equal to the file on disk first, then the earliest start), parses,
25
+ imports learnings, embeds, watches the files and writes `~/.contextengine/index/<corpus>.json`;
26
+ every other server of that corpus loads it, reloads within 3 s of a change, never watches files,
27
+ never sweep-imports learnings, and loads the model only on its first semantic query. A reader
28
+ with no index yet builds once locally. Off, every server indexes on its own as before.
29
+ - `contextengine servers` shows `indexer` / `reader` and the corpus per server; audit events
30
+ `server.role` and `index.write`; `scripts/trial-shared-index.mjs` proves the behaviour on
31
+ three servers from three cwds (one re-index, readers current within 5 s, reader CPU flat).
32
+
33
+ ### Changed
34
+
35
+ - The `reindex` tool on a reader reloads the shared index and names the indexer instead of
36
+ rebuilding on its own.
37
+ - The "too many servers" warning counts only servers that index on their own.
38
+
39
+ ## [2.5.9] — 2026-09-05 — The servers inventory themselves; growth is a tripwire too
40
+
41
+ The evening 2.5.7 shipped, two MCP servers that had started before the build kept the old
42
+ importer for two hours and re-imported 1,766 records the owner had just had deleted. `ps` found
43
+ them on the second look only. Nine more servers, one per open chat, were each re-embedding the
44
+ corpus after every doc change (load average 230). Nothing in the product could say any of this.
45
+
46
+ ### Added
47
+
48
+ - **Server registry** (`[SERVERS-ARE-INVENTORIED]`, `src/server-registry.ts`): every server writes
49
+ `~/.contextengine/servers/<pid>.json` at the top of `main()` with pid, parent process, start time,
50
+ version, a sha256 of the script it loaded and its cwd; heartbeats every 60 s; removes the record on
51
+ exit; emits `server.start` to the audit log. `contextengine servers` and the end-session checklist
52
+ (§ 3b) list live servers, drop dead records, flag **STALE BUILD** when the script on disk no longer
53
+ matches the hash a server loaded, and warn above 3 concurrent servers. Exit 1 on any warning.
54
+ - **Growth tripwire** (`[STORE-GROWTH-IS-A-TRIPWIRE-TOO]`): a write that adds more than 200 records
55
+ to the learnings store is refused (`learning.store_growth_refused`), the mirror of the shrink guard.
56
+ The auto-import reports `refused` and the server keeps running; `import_learnings` and the CLI
57
+ print the refusal. `CONTEXTENGINE_ALLOW_BULK=1` for one deliberate bulk import.
58
+
7
59
  ## [2.5.8] — 2026-09-05 — "rules" is not a learnings heading
8
60
 
9
61
  ### Fixed
package/dist/audit.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error" | "audit.rotate" | "audit.redact";
1
+ export type AuditEvent = "learning.save" | "learning.delete" | "learning.store_unreadable" | "learning.store_shrink_refused" | "learning.store_growth_refused" | "server.start" | "server.role" | "index.write" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error" | "audit.rotate" | "audit.redact";
2
2
  export interface AuditRecord {
3
3
  ts: string;
4
4
  event: AuditEvent;
@@ -27,6 +27,7 @@ export const KNOWN_COMMANDS = [
27
27
  "audit-rotate",
28
28
  "audit-redact-ack",
29
29
  "audit-verify",
30
+ "servers",
30
31
  "autostart-status",
31
32
  "cost",
32
33
  "deactivate",
package/dist/cli.js CHANGED
@@ -655,6 +655,7 @@ import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationError
655
655
  import { buildCostReport } from "./cost-report.js";
656
656
  import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
657
657
  import { safeAppend } from "./audit.js";
658
+ import { listServers, formatServers } from "./server-registry.js";
658
659
  import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
659
660
  import { fileURLToPath } from "url";
660
661
  // ---------------------------------------------------------------------------
@@ -2208,6 +2209,10 @@ async function cliEndSession() {
2208
2209
  if (autoImport.imported > 0) {
2209
2210
  checks.push(`📥 Auto-imported ${autoImport.imported} new learnings from ${autoImport.total} doc sources\n`);
2210
2211
  }
2212
+ if (autoImport.refused) {
2213
+ checks.push(`- ⛔ Auto-import write refused: ${autoImport.refused}`);
2214
+ failCount++;
2215
+ }
2211
2216
  // --- Check 3: Learnings Store ---
2212
2217
  checks.push("## 3. Learnings Store\n");
2213
2218
  const stats = learningsStats();
@@ -2225,6 +2230,13 @@ async function cliEndSession() {
2225
2230
  passCount++;
2226
2231
  checks.push("");
2227
2232
  // --- Check 4: Sessions ---
2233
+ // --- Check 3b: running servers ([LOCK] [SERVERS-ARE-INVENTORIED]) ---
2234
+ checks.push("## 3b. Running servers\n");
2235
+ const fleet = listServers();
2236
+ checks.push("```\n" + formatServers(fleet) + "\n```");
2237
+ if (fleet.warnings.length > 0)
2238
+ failCount += fleet.warnings.length;
2239
+ checks.push("");
2228
2240
  checks.push("## 4. Sessions\n");
2229
2241
  const sessions = listSessions();
2230
2242
  if (sessions.length > 0) {
@@ -2290,7 +2302,14 @@ async function cliImportLearnings(args) {
2290
2302
  console.error(" --permissive: every H3 heading, bold bullet and table row too.");
2291
2303
  process.exit(1);
2292
2304
  }
2293
- const result = importLearningsFromFile(filePath, category, project, { permissive });
2305
+ let result;
2306
+ try {
2307
+ result = importLearningsFromFile(filePath, category, project, { permissive });
2308
+ }
2309
+ catch (e) {
2310
+ console.error(`⛔ Import refused: ${e?.message || e}`);
2311
+ process.exit(1);
2312
+ }
2294
2313
  console.log(`\n📥 Import Results:`);
2295
2314
  console.log(` Imported: ${result.imported}`);
2296
2315
  console.log(` Updated: ${result.updated}`);
@@ -2508,6 +2527,7 @@ Usage:
2508
2527
  Export hash-chained audit log (evidence aligned with
2509
2528
  SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
2510
2529
  contextengine audit-verify Verify audit log chain integrity (tamper detection)
2530
+ contextengine servers List running MCP servers, their build vs the file on disk
2511
2531
  contextengine audit-redact-ack Acknowledge deliberately redacted records on the chain (--index i,j --reason "...")
2512
2532
  contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
2513
2533
  Move old history into an archive segment. Archives
@@ -2719,6 +2739,11 @@ else if (command === "audit-redact-ack") {
2719
2739
  else if (command === "audit-rotate") {
2720
2740
  cliAuditRotate(process.argv.slice(3));
2721
2741
  }
2742
+ else if (command === "servers") {
2743
+ const fleet = listServers();
2744
+ console.log(formatServers(fleet));
2745
+ process.exit(fleet.warnings.length > 0 ? 1 : 0);
2746
+ }
2722
2747
  else if (command === "audit-verify") {
2723
2748
  cliAuditVerify().catch((err) => {
2724
2749
  console.error("Error:", err);
package/dist/config.d.ts CHANGED
@@ -49,6 +49,11 @@ export interface ContextEngineConfig {
49
49
  enabled?: boolean;
50
50
  }>;
51
51
  }
52
+ /**
53
+ * Look for contextengine.json in standard locations.
54
+ * Priority: env var > CWD > home dir
55
+ */
56
+ export declare function findConfigFile(): string | null;
52
57
  /**
53
58
  * Load knowledge sources.
54
59
  *
package/dist/config.js CHANGED
@@ -28,7 +28,7 @@ const DEFAULT_PATTERNS = [
28
28
  * Look for contextengine.json in standard locations.
29
29
  * Priority: env var > CWD > home dir
30
30
  */
31
- function findConfigFile() {
31
+ export function findConfigFile() {
32
32
  const candidates = [];
33
33
  const envPath = process.env.CONTEXTENGINE_CONFIG;
34
34
  if (envPath) {
@@ -0,0 +1,46 @@
1
+ export declare const EMBED_DIM = 384;
2
+ export declare function embeddingStoreDir(): string;
3
+ export declare function embeddingStorePath(): string;
4
+ /** The key of one embedded text: 16 bytes (32 hex) of SHA-256 over model + text. */
5
+ export declare function embedKey(model: string, text: string): string;
6
+ export interface StoreLoad {
7
+ vectors: Map<string, Float32Array>;
8
+ /** Records on disk, duplicates included (the same key appended twice by two servers). */
9
+ records: number;
10
+ /** Bytes ignored at the tail (a partial record from a concurrent appender). */
11
+ partialBytes: number;
12
+ /** True when the file exists but its header is not ours; nothing is read from it. */
13
+ foreign: boolean;
14
+ }
15
+ /** Read the whole store. Missing file = empty store. ~7 MB for 4,400 vectors, tens of ms. */
16
+ export declare function loadEmbeddingStore(path?: string): StoreLoad;
17
+ /**
18
+ * Append vectors. One write syscall for the batch; the header is written with the first batch.
19
+ * Two servers creating the file at the same instant both write header + batch and the last
20
+ * truncating write wins; the loser's vectors are simply embedded again later.
21
+ */
22
+ export declare function appendEmbeddings(entries: Array<[string, Float32Array]>, path?: string): {
23
+ written: number;
24
+ bytes: number;
25
+ };
26
+ /**
27
+ * Rewrite the store with only the live keys, temp file + rename. Called by an indexer when the
28
+ * file holds far more records than the corpus needs (every save of an edited doc appends its
29
+ * changed chunks again). A record appended by another server between the read and the rename
30
+ * is lost and re-embedded later; nothing else can be.
31
+ */
32
+ export declare function compactEmbeddingStore(liveKeys: Set<string>, path?: string, opts?: {
33
+ minRecords?: number;
34
+ ratio?: number;
35
+ }): {
36
+ compacted: boolean;
37
+ before: number;
38
+ after: number;
39
+ };
40
+ export declare const _internal: {
41
+ HEADER_BYTES: number;
42
+ RECORD_BYTES: number;
43
+ KEY_BYTES: number;
44
+ MAGIC: string;
45
+ };
46
+ //# sourceMappingURL=embedding-store.d.ts.map
@@ -0,0 +1,141 @@
1
+ // [LOCKED] [EMBEDDINGS-ARE-CONTENT-ADDRESSED] 2026-09-05
2
+ // [NEVER] key the embedding cache on the whole corpus again (one hash over every chunk), and
3
+ // [NEVER] skip loading the model because "the cache hit".
4
+ // WHY: the previous cache (src/cache.ts, removed in this commit) used one SHA-256 over all
5
+ // ~4,400 chunks as its key, and the corpus contains a `git diff --stat HEAD~1..HEAD` ops
6
+ // chunk per project, the learnings and the last session. Any commit in any of 40 projects,
7
+ // any saved learning, or another server with a different cwd writing the same single-slot
8
+ // file made it stale: measured 2 hits in 51 server starts (SESSION_26). Every start and
9
+ // every doc change then re-embedded every chunk, about 2 min unloaded and 29 min under the
10
+ // load those re-embeds themselves created (nine chats open, load average 230). And on the
11
+ // rare hit, initEmbeddings() was never called, so that server had no query pipeline: no
12
+ // semantic search, and reindex() never re-embedded again for its whole life.
13
+ // FIX: one vector per distinct embedded text, keyed by SHA-256(model + text), in an
14
+ // append-only binary file shared by every server on the machine. A doc change embeds
15
+ // only its new chunks; a cold start embeds only texts never seen before, whatever the cwd.
16
+ // Whoever embeds loads the model at start; a reader of the shared index loads it on its
17
+ // first semantic query, never "skipped because the cache hit". Appends are one write
18
+ // syscall each; a torn tail is cut before the next append and ignored by the loader.
19
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, truncateSync, writeFileSync } from "fs";
20
+ import { join } from "path";
21
+ import { homedir } from "os";
22
+ import { createHash } from "crypto";
23
+ export const EMBED_DIM = 384;
24
+ const MAGIC = "CEEMB001"; // 8 bytes
25
+ const HEADER_BYTES = 16; // magic (8) + dims uint32 LE (4) + reserved (4)
26
+ const KEY_BYTES = 16;
27
+ const RECORD_BYTES = KEY_BYTES + EMBED_DIM * 4;
28
+ export function embeddingStoreDir() {
29
+ return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
30
+ }
31
+ export function embeddingStorePath() {
32
+ return join(embeddingStoreDir(), "embeddings.bin");
33
+ }
34
+ /** The key of one embedded text: 16 bytes (32 hex) of SHA-256 over model + text. */
35
+ export function embedKey(model, text) {
36
+ return createHash("sha256").update(model).update("\0").update(text).digest("hex").slice(0, KEY_BYTES * 2);
37
+ }
38
+ function header() {
39
+ const b = Buffer.alloc(HEADER_BYTES);
40
+ b.write(MAGIC, 0, "ascii");
41
+ b.writeUInt32LE(EMBED_DIM, 8);
42
+ return b;
43
+ }
44
+ function recordOf(key, vec) {
45
+ const b = Buffer.alloc(RECORD_BYTES);
46
+ Buffer.from(key, "hex").copy(b, 0, 0, KEY_BYTES);
47
+ for (let i = 0; i < EMBED_DIM; i++)
48
+ b.writeFloatLE(vec[i] ?? 0, KEY_BYTES + i * 4);
49
+ return b;
50
+ }
51
+ /** Read the whole store. Missing file = empty store. ~7 MB for 4,400 vectors, tens of ms. */
52
+ export function loadEmbeddingStore(path = embeddingStorePath()) {
53
+ const out = { vectors: new Map(), records: 0, partialBytes: 0, foreign: false };
54
+ if (!existsSync(path))
55
+ return out;
56
+ let buf;
57
+ try {
58
+ buf = readFileSync(path);
59
+ }
60
+ catch {
61
+ return out;
62
+ }
63
+ if (buf.length < HEADER_BYTES || buf.toString("ascii", 0, 8) !== MAGIC || buf.readUInt32LE(8) !== EMBED_DIM) {
64
+ out.foreign = true;
65
+ return out;
66
+ }
67
+ const body = buf.length - HEADER_BYTES;
68
+ const n = Math.floor(body / RECORD_BYTES);
69
+ out.partialBytes = body - n * RECORD_BYTES;
70
+ for (let r = 0; r < n; r++) {
71
+ const off = HEADER_BYTES + r * RECORD_BYTES;
72
+ const key = buf.toString("hex", off, off + KEY_BYTES);
73
+ // A copy, not a view: the file buffer must be collectable.
74
+ const vec = new Float32Array(EMBED_DIM);
75
+ for (let i = 0; i < EMBED_DIM; i++)
76
+ vec[i] = buf.readFloatLE(off + KEY_BYTES + i * 4);
77
+ out.vectors.set(key, vec);
78
+ }
79
+ out.records = n;
80
+ return out;
81
+ }
82
+ /**
83
+ * Append vectors. One write syscall for the batch; the header is written with the first batch.
84
+ * Two servers creating the file at the same instant both write header + batch and the last
85
+ * truncating write wins; the loser's vectors are simply embedded again later.
86
+ */
87
+ export function appendEmbeddings(entries, path = embeddingStorePath()) {
88
+ if (entries.length === 0)
89
+ return { written: 0, bytes: 0 };
90
+ const recs = Buffer.concat(entries.map(([k, v]) => recordOf(k, v)));
91
+ mkdirSync(join(path, ".."), { recursive: true });
92
+ let size = 0;
93
+ try {
94
+ size = statSync(path).size;
95
+ }
96
+ catch {
97
+ size = 0;
98
+ }
99
+ if (size < HEADER_BYTES) {
100
+ writeFileSync(path, Buffer.concat([header(), recs]));
101
+ return { written: entries.length, bytes: recs.length };
102
+ }
103
+ // A torn tail (a writer that died mid-record) would shift the frame of every record appended
104
+ // after it; cut back to the last whole record before adding ours.
105
+ const torn = (size - HEADER_BYTES) % RECORD_BYTES;
106
+ if (torn !== 0)
107
+ truncateSync(path, size - torn);
108
+ appendFileSync(path, recs);
109
+ return { written: entries.length, bytes: recs.length };
110
+ }
111
+ /**
112
+ * Rewrite the store with only the live keys, temp file + rename. Called by an indexer when the
113
+ * file holds far more records than the corpus needs (every save of an edited doc appends its
114
+ * changed chunks again). A record appended by another server between the read and the rename
115
+ * is lost and re-embedded later; nothing else can be.
116
+ */
117
+ export function compactEmbeddingStore(liveKeys, path = embeddingStorePath(), opts = {}) {
118
+ const minRecords = opts.minRecords ?? 10_000;
119
+ const ratio = opts.ratio ?? 2;
120
+ // Decide from the size first: below the floor there is nothing to read.
121
+ let onDisk = 0;
122
+ try {
123
+ onDisk = Math.max(0, Math.floor((statSync(path).size - HEADER_BYTES) / RECORD_BYTES));
124
+ }
125
+ catch {
126
+ onDisk = 0;
127
+ }
128
+ if (onDisk < minRecords)
129
+ return { compacted: false, before: onDisk, after: onDisk };
130
+ const load = loadEmbeddingStore(path);
131
+ const live = [...load.vectors.entries()].filter(([k]) => liveKeys.has(k));
132
+ if (load.records < minRecords || load.records < ratio * Math.max(live.length, 1)) {
133
+ return { compacted: false, before: load.records, after: load.records };
134
+ }
135
+ const tmp = `${path}.tmp-${process.pid}`;
136
+ writeFileSync(tmp, Buffer.concat([header(), ...live.map(([k, v]) => recordOf(k, v))]));
137
+ renameSync(tmp, path);
138
+ return { compacted: true, before: load.records, after: live.length };
139
+ }
140
+ export const _internal = { HEADER_BYTES, RECORD_BYTES, KEY_BYTES, MAGIC };
141
+ //# sourceMappingURL=embedding-store.js.map
@@ -1,4 +1,5 @@
1
1
  import type { Chunk } from "./ingest.js";
2
+ export declare const MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
2
3
  /**
3
4
  * Initialize the embedding pipeline (downloads model on first run, ~22MB).
4
5
  * Subsequent calls use cached model.
@@ -16,11 +17,22 @@ export interface EmbeddedChunk {
16
17
  chunk: Chunk;
17
18
  vector: Float32Array;
18
19
  }
20
+ /** The exact text that is embedded for a chunk; the store key is derived from it. */
21
+ export declare function embedInputOf(chunk: Chunk): string;
22
+ export declare function embedKeyOf(chunk: Chunk): string;
23
+ export interface EmbedChunksResult {
24
+ embedded: EmbeddedChunk[];
25
+ /** Chunks whose vector came from the shared store. */
26
+ reused: number;
27
+ /** Chunks embedded now and appended to the store. */
28
+ fresh: number;
29
+ }
19
30
  /**
20
- * Embed all chunks. Returns the chunks with their vectors.
21
- * Shows progress on stderr.
31
+ * Embed all chunks, reusing the content-addressed store for every text it already holds and
32
+ * embedding only the rest. [LOCK] [EMBEDDINGS-ARE-CONTENT-ADDRESSED]
33
+ * Returns the chunks in input order. Shows progress on stderr.
22
34
  */
23
- export declare function embedChunks(chunks: Chunk[]): Promise<EmbeddedChunk[]>;
35
+ export declare function embedChunks(chunks: Chunk[], store?: Map<string, Float32Array>, embedFn?: (text: string) => Promise<Float32Array>, storePath?: string): Promise<EmbedChunksResult>;
24
36
  export interface VectorSearchResult {
25
37
  chunk: Chunk;
26
38
  score: number;
@@ -14,10 +14,11 @@
14
14
  // a whole class of buyers. A static import would silently re-break this.
15
15
  // FIX: If you need to take a dependency on a transformer feature, add it
16
16
  // behind the same dynamic-import + isEmbeddingsReady() check pattern.
17
+ import { embedKey, appendEmbeddings } from "./embedding-store.js";
17
18
  // We dynamically import @huggingface/transformers to keep startup fast
18
19
  // and handle the case where it fails gracefully.
19
20
  let embedPipeline = null;
20
- const MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
21
+ export const MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
21
22
  /**
22
23
  * Initialize the embedding pipeline (downloads model on first run, ~22MB).
23
24
  * Subsequent calls use cached model.
@@ -77,29 +78,58 @@ function cosineSimilarity(a, b) {
77
78
  }
78
79
  return dot;
79
80
  }
81
+ /** The exact text that is embedded for a chunk; the store key is derived from it. */
82
+ export function embedInputOf(chunk) {
83
+ // Embed section + content together for context
84
+ return `${chunk.section}\n${chunk.content}`.slice(0, 512);
85
+ }
86
+ export function embedKeyOf(chunk) {
87
+ return embedKey(MODEL_NAME, embedInputOf(chunk));
88
+ }
80
89
  /**
81
- * Embed all chunks. Returns the chunks with their vectors.
82
- * Shows progress on stderr.
90
+ * Embed all chunks, reusing the content-addressed store for every text it already holds and
91
+ * embedding only the rest. [LOCK] [EMBEDDINGS-ARE-CONTENT-ADDRESSED]
92
+ * Returns the chunks in input order. Shows progress on stderr.
83
93
  */
84
- export async function embedChunks(chunks) {
85
- const results = [];
86
- const total = chunks.length;
94
+ export async function embedChunks(chunks, store, embedFn = embedText, storePath) {
95
+ const known = store ?? new Map();
96
+ const keys = chunks.map(embedKeyOf);
97
+ const results = new Array(chunks.length);
98
+ const todo = [];
99
+ for (let i = 0; i < chunks.length; i++) {
100
+ const v = known.get(keys[i]);
101
+ if (v)
102
+ results[i] = { chunk: chunks[i], vector: v };
103
+ else
104
+ todo.push(i);
105
+ }
106
+ const total = todo.length;
87
107
  const batchSize = 10;
88
- for (let i = 0; i < total; i += batchSize) {
89
- const batch = chunks.slice(i, i + batchSize);
90
- const batchResults = await Promise.all(batch.map(async (chunk) => {
91
- // Embed section + content together for context
92
- const text = `${chunk.section}\n${chunk.content}`.slice(0, 512);
93
- const vector = await embedText(text);
94
- return { chunk, vector };
95
- }));
96
- results.push(...batchResults);
97
- const done = Math.min(i + batchSize, total);
108
+ const appended = [];
109
+ for (let b = 0; b < total; b += batchSize) {
110
+ const batch = todo.slice(b, b + batchSize);
111
+ const vectors = await Promise.all(batch.map((i) => embedFn(embedInputOf(chunks[i]))));
112
+ batch.forEach((i, j) => {
113
+ results[i] = { chunk: chunks[i], vector: vectors[j] };
114
+ if (!known.has(keys[i])) {
115
+ known.set(keys[i], vectors[j]);
116
+ appended.push([keys[i], vectors[j]]);
117
+ }
118
+ });
119
+ const done = Math.min(b + batchSize, total);
98
120
  if (done % 50 === 0 || done === total) {
99
- console.error(`[ContextEngine] 📊 Embedded ${done}/${total} chunks`);
121
+ console.error(`[ContextEngine] 📊 Embedded ${done}/${total} new chunks`);
122
+ }
123
+ }
124
+ if (appended.length > 0) {
125
+ try {
126
+ appendEmbeddings(appended, storePath);
127
+ }
128
+ catch (err) {
129
+ console.error(`[ContextEngine] ⚠ embedding store append failed: ${err.message}`);
100
130
  }
101
131
  }
102
- return results;
132
+ return { embedded: results, reused: chunks.length - total, fresh: total };
103
133
  }
104
134
  /**
105
135
  * Semantic search: embed the query, then find most similar chunks.