@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 +52 -0
- package/dist/audit.d.ts +1 -1
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +26 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +1 -1
- package/dist/embedding-store.d.ts +46 -0
- package/dist/embedding-store.js +141 -0
- package/dist/embeddings.d.ts +15 -3
- package/dist/embeddings.js +48 -18
- package/dist/index.js +261 -91
- package/dist/learnings.d.ts +2 -0
- package/dist/learnings.js +58 -19
- package/dist/server-registry.d.ts +47 -0
- package/dist/server-registry.js +162 -0
- package/dist/shared-index.d.ts +45 -0
- package/dist/shared-index.js +113 -0
- package/package.json +1 -1
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;
|
package/dist/cli-commands.js
CHANGED
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
|
-
|
|
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
|
package/dist/embeddings.d.ts
CHANGED
|
@@ -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
|
|
21
|
-
*
|
|
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<
|
|
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;
|
package/dist/embeddings.js
CHANGED
|
@@ -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
|
|
82
|
-
*
|
|
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
|
|
86
|
-
const
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|