peon-mem 1.0.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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +301 -0
  3. package/bin/peon-mem.mjs +273 -0
  4. package/dist/brain.d.ts +72 -0
  5. package/dist/brain.js +224 -0
  6. package/dist/compression.d.ts +9 -0
  7. package/dist/compression.js +37 -0
  8. package/dist/config.d.ts +22 -0
  9. package/dist/config.js +99 -0
  10. package/dist/daemon-cli.d.ts +2 -0
  11. package/dist/daemon-cli.js +54 -0
  12. package/dist/daemon.d.ts +23 -0
  13. package/dist/daemon.js +1078 -0
  14. package/dist/embedding-store.d.ts +43 -0
  15. package/dist/embedding-store.js +169 -0
  16. package/dist/embeddings.d.ts +93 -0
  17. package/dist/embeddings.js +345 -0
  18. package/dist/entities.d.ts +61 -0
  19. package/dist/entities.js +191 -0
  20. package/dist/entity-extraction.d.ts +33 -0
  21. package/dist/entity-extraction.js +75 -0
  22. package/dist/eval-metrics.d.ts +27 -0
  23. package/dist/eval-metrics.js +50 -0
  24. package/dist/evaluation.d.ts +58 -0
  25. package/dist/evaluation.js +244 -0
  26. package/dist/global-extraction.d.ts +15 -0
  27. package/dist/global-extraction.js +61 -0
  28. package/dist/global-memory.d.ts +43 -0
  29. package/dist/global-memory.js +306 -0
  30. package/dist/global-promotion.d.ts +25 -0
  31. package/dist/global-promotion.js +29 -0
  32. package/dist/hyde.d.ts +31 -0
  33. package/dist/hyde.js +46 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +246 -0
  36. package/dist/injection.d.ts +38 -0
  37. package/dist/injection.js +133 -0
  38. package/dist/logger.d.ts +17 -0
  39. package/dist/logger.js +63 -0
  40. package/dist/memory-mutations.d.ts +24 -0
  41. package/dist/memory-mutations.js +57 -0
  42. package/dist/memory-store.d.ts +194 -0
  43. package/dist/memory-store.js +1205 -0
  44. package/dist/monitor.d.ts +13 -0
  45. package/dist/monitor.js +977 -0
  46. package/dist/overview.d.ts +73 -0
  47. package/dist/overview.js +104 -0
  48. package/dist/processor.d.ts +90 -0
  49. package/dist/processor.js +450 -0
  50. package/dist/quality.d.ts +86 -0
  51. package/dist/quality.js +338 -0
  52. package/dist/recuration.d.ts +13 -0
  53. package/dist/recuration.js +65 -0
  54. package/dist/reranker.d.ts +34 -0
  55. package/dist/reranker.js +89 -0
  56. package/dist/retrieval.d.ts +106 -0
  57. package/dist/retrieval.js +392 -0
  58. package/dist/session-index.d.ts +34 -0
  59. package/dist/session-index.js +87 -0
  60. package/dist/temporal.d.ts +20 -0
  61. package/dist/temporal.js +62 -0
  62. package/dist/token-ab-monitor.d.ts +1 -0
  63. package/dist/token-ab-monitor.js +7 -0
  64. package/dist/tools.d.ts +232 -0
  65. package/dist/tools.js +546 -0
  66. package/dist/types.d.ts +169 -0
  67. package/dist/types.js +1 -0
  68. package/docs/assets/neural-universe.png +0 -0
  69. package/package.json +57 -0
  70. package/scripts/claude-peon-hook.mjs +522 -0
  71. package/scripts/codex-peon-hook.mjs +4 -0
  72. package/scripts/eval-retrieval-labeled.mjs +135 -0
  73. package/scripts/eval-retrieval.mjs +96 -0
  74. package/scripts/evaluate-peon.mjs +47 -0
  75. package/scripts/install-peon-stl.mjs +82 -0
  76. package/scripts/install-peon.mjs +318 -0
  77. package/scripts/lib/eval-ledger.mjs +104 -0
  78. package/scripts/lib/stl-classify.mjs +44 -0
  79. package/scripts/longmemeval-eval.mjs +144 -0
  80. package/scripts/peon-report.mjs +155 -0
  81. package/scripts/peon-stl.mjs +506 -0
  82. package/scripts/token-ab-monitor.html +235 -0
@@ -0,0 +1,43 @@
1
+ import { type EmbeddingClient, type EmbeddingVector } from "./embeddings.js";
2
+ import type { MemoryRecord } from "./types.js";
3
+ /**
4
+ * Sidecar vector store for memory embeddings.
5
+ *
6
+ * Vectors live in `<memoryDir>/brain/embeddings.jsonl`, keyed by record id, kept
7
+ * OUT of memories.jsonl so the structured brain stays human-readable. Each entry
8
+ * carries the content hash + model it was computed from, so a vector is only
9
+ * recomputed when the record's content changes or the embedding model changes.
10
+ */
11
+ export interface StoredEmbedding {
12
+ id: string;
13
+ model: string;
14
+ hash: string;
15
+ vector: EmbeddingVector;
16
+ }
17
+ export interface SyncResult {
18
+ vectorById: Map<string, EmbeddingVector>;
19
+ computed: number;
20
+ reused: number;
21
+ pruned: number;
22
+ }
23
+ export declare class EmbeddingStore {
24
+ private readonly filePath;
25
+ private cache?;
26
+ private constructor();
27
+ static open(memoryDir: string): Promise<EmbeddingStore>;
28
+ load(): Promise<Map<string, StoredEmbedding>>;
29
+ /**
30
+ * Ensure every record has a current embedding. Recomputes only what changed,
31
+ * prunes vectors for deleted records, persists the result, and returns the
32
+ * id → vector map ready for hybrid ranking. Embedding failures degrade to an
33
+ * empty map rather than throwing (retrieval falls back to lexical-only).
34
+ */
35
+ sync(records: MemoryRecord[], client: EmbeddingClient | null): Promise<SyncResult>;
36
+ /** Read vectors without recomputing — used by read-only retrieval paths. */
37
+ vectorById(): Promise<Map<string, EmbeddingVector>>;
38
+ private persist;
39
+ }
40
+ /** Serialize a vector as base64 of its float32 bytes — ~4x smaller + faster to parse than JSON float64. */
41
+ export declare function encodeVector(vector: EmbeddingVector): string;
42
+ /** Decode a base64 float32 vector back to number[]; null on malformed/misaligned input. */
43
+ export declare function decodeVector(b64: string): number[] | null;
@@ -0,0 +1,169 @@
1
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { contentHash } from "./embeddings.js";
4
+ export class EmbeddingStore {
5
+ filePath;
6
+ // mtime-keyed cache so the (multi-MB) sidecar isn't re-read+parsed on every prompt's
7
+ // read-only retrieval. Invalidated by mtime change (incl. our own atomic persist).
8
+ cache;
9
+ constructor(filePath) {
10
+ this.filePath = filePath;
11
+ }
12
+ static async open(memoryDir) {
13
+ const filePath = join(memoryDir, "brain", "embeddings.jsonl");
14
+ await mkdir(dirname(filePath), { recursive: true });
15
+ const store = new EmbeddingStore(filePath);
16
+ return store;
17
+ }
18
+ async load() {
19
+ let mtimeMs = 0;
20
+ try {
21
+ mtimeMs = (await stat(this.filePath)).mtimeMs;
22
+ }
23
+ catch {
24
+ mtimeMs = 0; // missing file → treat as empty, mtime 0
25
+ }
26
+ if (this.cache && this.cache.mtimeMs === mtimeMs)
27
+ return this.cache.map;
28
+ const raw = await readFile(this.filePath, "utf8").catch(() => "");
29
+ const map = new Map();
30
+ for (const line of raw.split(/\r?\n/)) {
31
+ const trimmed = line.trim();
32
+ if (!trimmed)
33
+ continue;
34
+ try {
35
+ const stored = parseStoredLine(JSON.parse(trimmed));
36
+ if (stored)
37
+ map.set(stored.id, stored);
38
+ }
39
+ catch {
40
+ // skip malformed lines — never let a bad vector block retrieval
41
+ }
42
+ }
43
+ this.cache = { mtimeMs, map };
44
+ return map;
45
+ }
46
+ /**
47
+ * Ensure every record has a current embedding. Recomputes only what changed,
48
+ * prunes vectors for deleted records, persists the result, and returns the
49
+ * id → vector map ready for hybrid ranking. Embedding failures degrade to an
50
+ * empty map rather than throwing (retrieval falls back to lexical-only).
51
+ */
52
+ async sync(records, client) {
53
+ if (!client) {
54
+ return { vectorById: new Map(), computed: 0, reused: 0, pruned: 0 };
55
+ }
56
+ const existing = await this.load();
57
+ const liveIds = new Set(records.map((record) => record.id));
58
+ const pruned = [...existing.keys()].filter((id) => !liveIds.has(id)).length;
59
+ const toCompute = [];
60
+ let reused = 0;
61
+ for (const record of records) {
62
+ const prior = existing.get(record.id);
63
+ if (prior && prior.model === client.model && prior.hash === contentHash(embeddingText(record))) {
64
+ reused += 1;
65
+ }
66
+ else {
67
+ toCompute.push(record);
68
+ }
69
+ }
70
+ const result = new Map();
71
+ for (const record of records) {
72
+ const prior = existing.get(record.id);
73
+ if (prior && prior.model === client.model && prior.hash === contentHash(embeddingText(record))) {
74
+ result.set(record.id, prior);
75
+ }
76
+ }
77
+ let computed = 0;
78
+ if (toCompute.length > 0) {
79
+ try {
80
+ const vectors = await client.embed(toCompute.map((record) => embeddingText(record)));
81
+ toCompute.forEach((record, i) => {
82
+ result.set(record.id, {
83
+ id: record.id,
84
+ model: client.model,
85
+ hash: contentHash(embeddingText(record)),
86
+ vector: vectors[i] ?? []
87
+ });
88
+ });
89
+ computed = toCompute.length;
90
+ }
91
+ catch {
92
+ // On a hard failure, keep whatever we already had and continue lexical-only.
93
+ }
94
+ }
95
+ // Only touch disk when the vector set actually changed.
96
+ if (computed > 0 || pruned > 0) {
97
+ await this.persist(records, result);
98
+ }
99
+ const vectorById = new Map();
100
+ for (const [id, stored] of result)
101
+ vectorById.set(id, stored.vector);
102
+ return { vectorById, computed, reused, pruned };
103
+ }
104
+ /** Read vectors without recomputing — used by read-only retrieval paths. */
105
+ async vectorById() {
106
+ const stored = await this.load();
107
+ const map = new Map();
108
+ for (const [id, value] of stored)
109
+ map.set(id, value.vector);
110
+ return map;
111
+ }
112
+ async persist(records, result) {
113
+ // Write in record order for stable diffs; only persist vectors we actually have.
114
+ const lines = records
115
+ .map((record) => result.get(record.id))
116
+ .filter((value) => Boolean(value))
117
+ // Persist the vector as base64 float32 (`vec`), ~4x smaller and ~4x faster to parse than a
118
+ // JSON float64 array. Legacy `vector`-array lines are still read on load and get re-encoded
119
+ // to `vec` here on their next persist (lazy migration; no separate migration step needed).
120
+ .map((value) => JSON.stringify({ id: value.id, model: value.model, hash: value.hash, vec: encodeVector(value.vector) }));
121
+ // Atomic write (tmp + rename) so a crash mid-write can't truncate the sidecar.
122
+ const tmp = `${this.filePath}.tmp`;
123
+ await writeFile(tmp, lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
124
+ await rename(tmp, this.filePath);
125
+ this.cache = undefined; // invalidate; next load() re-reads the fresh file
126
+ }
127
+ }
128
+ /** Embed the record type alongside content so type acts as a soft semantic anchor. */
129
+ function embeddingText(record) {
130
+ const entities = record.entities.length > 0 ? ` ${record.entities.join(" ")}` : "";
131
+ return `${record.type}: ${record.content}${entities}`;
132
+ }
133
+ /** Serialize a vector as base64 of its float32 bytes — ~4x smaller + faster to parse than JSON float64. */
134
+ export function encodeVector(vector) {
135
+ return Buffer.from(new Float32Array(vector).buffer).toString("base64");
136
+ }
137
+ /** Decode a base64 float32 vector back to number[]; null on malformed/misaligned input. */
138
+ export function decodeVector(b64) {
139
+ try {
140
+ const buf = Buffer.from(b64, "base64");
141
+ if (buf.byteLength === 0 || buf.byteLength % 4 !== 0)
142
+ return null;
143
+ return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ }
149
+ /**
150
+ * Parse one sidecar line into a StoredEmbedding, accepting BOTH the current base64-float32 `vec`
151
+ * form and the legacy JSON-array `vector` form (so old sidecars keep working until re-persisted).
152
+ */
153
+ function parseStoredLine(value) {
154
+ if (!value || typeof value !== "object")
155
+ return null;
156
+ const record = value;
157
+ if (typeof record.id !== "string" || typeof record.model !== "string" || typeof record.hash !== "string")
158
+ return null;
159
+ let vector = null;
160
+ if (typeof record.vec === "string") {
161
+ vector = decodeVector(record.vec);
162
+ }
163
+ else if (Array.isArray(record.vector) && record.vector.every((entry) => typeof entry === "number")) {
164
+ vector = record.vector;
165
+ }
166
+ if (!vector)
167
+ return null;
168
+ return { id: record.id, model: record.model, hash: record.hash, vector };
169
+ }
@@ -0,0 +1,93 @@
1
+ import type { PeonConfig } from "./config.js";
2
+ /**
3
+ * Peon embeddings layer.
4
+ *
5
+ * Provides vector embeddings for memory records so retrieval can rank by meaning,
6
+ * not just keyword overlap. Designed local-first:
7
+ *
8
+ * - "local" mode (default): deterministic hashed character-trigram embeddings.
9
+ * No API key, no network, fully offline. Captures fuzzy/lexical similarity
10
+ * (typos, substrings, shared word stems) in vector space. Deterministic, so
11
+ * tests are stable and identical content always yields identical vectors.
12
+ *
13
+ * - "api" mode: real semantic embeddings via the OpenRouter embeddings endpoint.
14
+ * Falls back to local embeddings on any failure so the pipeline never breaks.
15
+ *
16
+ * - "off" mode: no embeddings; retrieval stays purely lexical.
17
+ */
18
+ export type EmbeddingVector = number[];
19
+ export declare const LOCAL_EMBEDDING_DIM = 256;
20
+ export declare const LOCAL_EMBEDDING_MODEL = "peon-local-trigram-v1";
21
+ export interface EmbeddingClient {
22
+ readonly model: string;
23
+ embed(texts: string[]): Promise<EmbeddingVector[]>;
24
+ }
25
+ /** Cosine similarity of two vectors. Returns 0 for empty/mismatched/zero vectors. */
26
+ export declare function cosineSimilarity(a: EmbeddingVector, b: EmbeddingVector): number;
27
+ /** L2-normalize a vector in place-safe fashion (returns a new array). */
28
+ export declare function l2normalize(vector: EmbeddingVector): EmbeddingVector;
29
+ /**
30
+ * Deterministic local embedding: hashed character trigrams folded into a fixed
31
+ * dimensional, L2-normalized vector. Two texts that share character trigrams end
32
+ * up with a high cosine similarity, giving robust fuzzy lexical matching offline.
33
+ */
34
+ export declare function localEmbed(text: string, dim?: number): EmbeddingVector;
35
+ /** Stable content hash used to detect when a record's embedding must be recomputed. */
36
+ export declare function contentHash(text: string): string;
37
+ export declare class LocalEmbeddingClient implements EmbeddingClient {
38
+ private readonly dim;
39
+ readonly model = "peon-local-trigram-v1";
40
+ constructor(dim?: number);
41
+ embed(texts: string[]): Promise<EmbeddingVector[]>;
42
+ }
43
+ export interface OpenRouterEmbeddingClientOptions {
44
+ apiKey: string;
45
+ model: string;
46
+ baseUrl?: string;
47
+ fetchImpl?: typeof fetch;
48
+ }
49
+ export declare class OpenRouterEmbeddingClient implements EmbeddingClient {
50
+ readonly model: string;
51
+ private readonly apiKey;
52
+ private readonly fetchImpl;
53
+ private readonly baseUrl;
54
+ constructor(options: OpenRouterEmbeddingClientOptions);
55
+ embed(texts: string[]): Promise<EmbeddingVector[]>;
56
+ }
57
+ export interface OllamaEmbeddingClientOptions {
58
+ model: string;
59
+ baseUrl?: string;
60
+ fetchImpl?: typeof fetch;
61
+ }
62
+ /**
63
+ * Local semantic embeddings via an Ollama server (default http://127.0.0.1:11434).
64
+ * Same quality class as API embeddings but ~30ms on-machine instead of a ~1.3s remote
65
+ * round-trip, zero API spend, fully offline. Model is part of the cache/sidecar hash,
66
+ * so switching models auto-triggers document re-embeds through the existing sync path.
67
+ */
68
+ export declare class OllamaEmbeddingClient implements EmbeddingClient {
69
+ readonly model: string;
70
+ private readonly baseUrl;
71
+ private readonly fetchImpl;
72
+ constructor(options: OllamaEmbeddingClientOptions);
73
+ embed(texts: string[]): Promise<EmbeddingVector[]>;
74
+ }
75
+ /**
76
+ * Resilient client that tries the API client first and transparently falls back
77
+ * to local embeddings on any error, so a flaky network never blocks memory writes.
78
+ */
79
+ export declare class FallbackEmbeddingClient implements EmbeddingClient {
80
+ private readonly primary;
81
+ private readonly fallback;
82
+ private readonly onFallback?;
83
+ readonly model: string;
84
+ constructor(primary: EmbeddingClient, fallback?: EmbeddingClient, onFallback?: ((error: unknown) => void) | undefined);
85
+ embed(texts: string[]): Promise<EmbeddingVector[]>;
86
+ }
87
+ export type EmbeddingMode = PeonConfig["embeddingMode"];
88
+ export interface CreateEmbeddingClientOptions {
89
+ config: Pick<PeonConfig, "embeddingMode" | "embeddingModel" | "openRouterApiKey" | "ollamaBaseUrl" | "provider" | "llmApiKey" | "llmBaseUrl">;
90
+ onFallback?: (error: unknown) => void;
91
+ }
92
+ /** Build the embedding client implied by config, or null when embeddings are off. */
93
+ export declare function createEmbeddingClient(options: CreateEmbeddingClientOptions): EmbeddingClient | null;
@@ -0,0 +1,345 @@
1
+ import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const LOCAL_EMBEDDING_DIM = 256;
5
+ export const LOCAL_EMBEDDING_MODEL = "peon-local-trigram-v1";
6
+ /** Cosine similarity of two vectors. Returns 0 for empty/mismatched/zero vectors. */
7
+ export function cosineSimilarity(a, b) {
8
+ if (a.length === 0 || a.length !== b.length)
9
+ return 0;
10
+ let dot = 0;
11
+ let normA = 0;
12
+ let normB = 0;
13
+ for (let i = 0; i < a.length; i += 1) {
14
+ dot += a[i] * b[i];
15
+ normA += a[i] * a[i];
16
+ normB += b[i] * b[i];
17
+ }
18
+ if (normA === 0 || normB === 0)
19
+ return 0;
20
+ const sim = dot / (Math.sqrt(normA) * Math.sqrt(normB));
21
+ return Number.isFinite(sim) ? sim : 0;
22
+ }
23
+ /** L2-normalize a vector in place-safe fashion (returns a new array). */
24
+ export function l2normalize(vector) {
25
+ let norm = 0;
26
+ for (const value of vector)
27
+ norm += value * value;
28
+ norm = Math.sqrt(norm);
29
+ if (norm === 0)
30
+ return vector.slice();
31
+ return vector.map((value) => value / norm);
32
+ }
33
+ /**
34
+ * Deterministic local embedding: hashed character trigrams folded into a fixed
35
+ * dimensional, L2-normalized vector. Two texts that share character trigrams end
36
+ * up with a high cosine similarity, giving robust fuzzy lexical matching offline.
37
+ */
38
+ export function localEmbed(text, dim = LOCAL_EMBEDDING_DIM) {
39
+ const vector = new Array(dim).fill(0);
40
+ const normalized = ` ${text.toLowerCase().replace(/\s+/g, " ").trim()} `;
41
+ if (normalized.trim().length === 0)
42
+ return vector;
43
+ // Character trigrams capture morphology and tolerate typos/substrings.
44
+ for (let i = 0; i + 3 <= normalized.length; i += 1) {
45
+ const gram = normalized.slice(i, i + 3);
46
+ const bucket = fnv1aInt(gram) % dim;
47
+ // Signed contribution reduces hash-collision cancellation bias.
48
+ const sign = (fnv1aInt(`sign:${gram}`) & 1) === 0 ? 1 : -1;
49
+ vector[bucket] += sign;
50
+ }
51
+ // Whole-token bucketing adds a coarse lexical signal on top of trigrams.
52
+ for (const token of normalized.split(" ").filter((t) => t.length > 1)) {
53
+ const bucket = fnv1aInt(`tok:${token}`) % dim;
54
+ vector[bucket] += 2;
55
+ }
56
+ return l2normalize(vector);
57
+ }
58
+ /** Stable content hash used to detect when a record's embedding must be recomputed. */
59
+ export function contentHash(text) {
60
+ return fnv1aHex(text.trim());
61
+ }
62
+ export class LocalEmbeddingClient {
63
+ dim;
64
+ model = LOCAL_EMBEDDING_MODEL;
65
+ constructor(dim = LOCAL_EMBEDDING_DIM) {
66
+ this.dim = dim;
67
+ }
68
+ async embed(texts) {
69
+ return texts.map((text) => localEmbed(text, this.dim));
70
+ }
71
+ }
72
+ /**
73
+ * Module-level LRU for SINGLE-text embeddings (query-shaped calls). Every prompt's retrieval
74
+ * embeds the user query via a remote round-trip — serve telemetry measured it at ~1.4s of the
75
+ * injection latency — and the same queries recur constantly (hook boilerplate like "recent
76
+ * project context…", "Continue", repeated user questions). Same model+text is deterministic, so
77
+ * caching is correctness-free; it cuts BOTH the latency and the OpenRouter spend, and makes eval
78
+ * reruns of the same qrels deterministic (removes live-query-embedding noise from ledger A/Bs).
79
+ * Keyed by model + contentHash(text); capped; daemon-lifetime (stores are cached per project).
80
+ */
81
+ const QUERY_EMBED_CACHE_MAX = 512; // ~12KB/vector → ≤ ~6MB in memory
82
+ const queryEmbedCache = new Map();
83
+ // PERSISTED across restarts/processes: an append-only JSONL sidecar (base64 float32), so repeat
84
+ // queries stay free-and-fast after a daemon restart and eval scripts reuse the daemon's vectors
85
+ // (zero marginal OpenRouter spend for known queries). Append-only by design — never rewritten or
86
+ // deleted; on load we read the TAIL (newest wins) so unbounded growth can't hurt startup.
87
+ const QUERY_EMBED_CACHE_FILE = process.env.PEON_QUERY_EMBED_CACHE ||
88
+ join(homedir(), "Library", "Application Support", "Peon", "query-embeddings.jsonl");
89
+ const PERSIST_READ_TAIL_BYTES = 8 * 1024 * 1024;
90
+ let persistedLoaded = false;
91
+ function b64encode(vector) {
92
+ return Buffer.from(new Float32Array(vector).buffer).toString("base64");
93
+ }
94
+ function b64decode(b64) {
95
+ try {
96
+ const buf = Buffer.from(b64, "base64");
97
+ if (buf.byteLength === 0 || buf.byteLength % 4 !== 0)
98
+ return null;
99
+ return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ function loadPersistedOnce() {
106
+ if (persistedLoaded)
107
+ return;
108
+ persistedLoaded = true;
109
+ try {
110
+ const size = statSync(QUERY_EMBED_CACHE_FILE).size;
111
+ const fd = openSync(QUERY_EMBED_CACHE_FILE, "r");
112
+ try {
113
+ const len = Math.min(size, PERSIST_READ_TAIL_BYTES);
114
+ const buf = Buffer.alloc(len);
115
+ readSync(fd, buf, 0, len, size - len);
116
+ let text = buf.toString("utf8");
117
+ if (len < size)
118
+ text = text.slice(text.indexOf("\n") + 1); // drop partial first line
119
+ for (const line of text.split(/\r?\n/)) {
120
+ if (!line.trim())
121
+ continue;
122
+ try {
123
+ const row = JSON.parse(line);
124
+ const vec = typeof row.v === "string" ? b64decode(row.v) : null;
125
+ if (row.k && vec)
126
+ queryEmbedCache.set(row.k, vec); // newest wins (later lines overwrite)
127
+ }
128
+ catch { /* skip bad line */ }
129
+ }
130
+ // enforce the in-memory cap (keep the newest entries)
131
+ while (queryEmbedCache.size > QUERY_EMBED_CACHE_MAX) {
132
+ const oldest = queryEmbedCache.keys().next().value;
133
+ if (oldest === undefined)
134
+ break;
135
+ queryEmbedCache.delete(oldest);
136
+ }
137
+ }
138
+ finally {
139
+ closeSync(fd);
140
+ }
141
+ }
142
+ catch { /* no cache file yet / unreadable → start empty */ }
143
+ }
144
+ function cacheKey(model, text) {
145
+ return `${model}:${contentHash(text)}`;
146
+ }
147
+ function cacheGet(key) {
148
+ loadPersistedOnce();
149
+ const hit = queryEmbedCache.get(key);
150
+ if (hit) {
151
+ // refresh recency (Map preserves insertion order → delete+set = LRU touch)
152
+ queryEmbedCache.delete(key);
153
+ queryEmbedCache.set(key, hit);
154
+ }
155
+ return hit;
156
+ }
157
+ function cachePut(key, vector) {
158
+ if (queryEmbedCache.size >= QUERY_EMBED_CACHE_MAX) {
159
+ const oldest = queryEmbedCache.keys().next().value;
160
+ if (oldest !== undefined)
161
+ queryEmbedCache.delete(oldest);
162
+ }
163
+ queryEmbedCache.set(key, vector);
164
+ try {
165
+ mkdirSync(dirname(QUERY_EMBED_CACHE_FILE), { recursive: true });
166
+ appendFileSync(QUERY_EMBED_CACHE_FILE, JSON.stringify({ k: key, v: b64encode(vector) }) + "\n");
167
+ }
168
+ catch { /* persistence is best-effort — never fail an embed over it */ }
169
+ }
170
+ export class OpenRouterEmbeddingClient {
171
+ model;
172
+ apiKey;
173
+ fetchImpl;
174
+ baseUrl;
175
+ constructor(options) {
176
+ this.apiKey = options.apiKey;
177
+ this.model = options.model;
178
+ this.baseUrl = (options.baseUrl ?? "https://openrouter.ai/api/v1").replace(/\/$/, "");
179
+ this.fetchImpl = options.fetchImpl ?? fetch;
180
+ }
181
+ async embed(texts) {
182
+ if (texts.length === 0)
183
+ return [];
184
+ // Cache single-text (query) calls — the per-prompt hot path. Batch (document sync) calls
185
+ // pass through untouched.
186
+ const key = texts.length === 1 ? cacheKey(this.model, texts[0]) : null;
187
+ if (key) {
188
+ const hit = cacheGet(key);
189
+ if (hit)
190
+ return [hit];
191
+ }
192
+ const response = await this.fetchImpl(this.baseUrl + "/embeddings", {
193
+ method: "POST",
194
+ headers: {
195
+ Authorization: `Bearer ${this.apiKey}`,
196
+ "Content-Type": "application/json"
197
+ },
198
+ body: JSON.stringify({ model: this.model, input: texts })
199
+ });
200
+ if (!response.ok) {
201
+ const body = await response.text().catch(() => "");
202
+ throw new Error(`OpenRouter embeddings failed with ${response.status}${body ? `: ${body}` : ""}`);
203
+ }
204
+ const json = (await response.json());
205
+ const data = json.data ?? [];
206
+ if (data.length !== texts.length) {
207
+ throw new Error(`OpenRouter embeddings returned ${data.length} vectors for ${texts.length} inputs.`);
208
+ }
209
+ // Preserve input order even if the API returns an index field out of order.
210
+ const ordered = [...data].sort((left, right) => (left.index ?? 0) - (right.index ?? 0));
211
+ const vectors = ordered.map((item, i) => {
212
+ const vector = item.embedding;
213
+ if (!Array.isArray(vector) || vector.length === 0) {
214
+ throw new Error(`OpenRouter embeddings returned an empty vector at index ${i}.`);
215
+ }
216
+ return l2normalize(vector);
217
+ });
218
+ if (key && vectors.length === 1)
219
+ cachePut(key, vectors[0]);
220
+ return vectors;
221
+ }
222
+ }
223
+ /**
224
+ * Local semantic embeddings via an Ollama server (default http://127.0.0.1:11434).
225
+ * Same quality class as API embeddings but ~30ms on-machine instead of a ~1.3s remote
226
+ * round-trip, zero API spend, fully offline. Model is part of the cache/sidecar hash,
227
+ * so switching models auto-triggers document re-embeds through the existing sync path.
228
+ */
229
+ export class OllamaEmbeddingClient {
230
+ model;
231
+ baseUrl;
232
+ fetchImpl;
233
+ constructor(options) {
234
+ this.model = options.model;
235
+ this.baseUrl = (options.baseUrl ?? "http://127.0.0.1:11434").replace(/\/$/, "");
236
+ this.fetchImpl = options.fetchImpl ?? fetch;
237
+ }
238
+ async embed(texts) {
239
+ if (texts.length === 0)
240
+ return [];
241
+ const key = texts.length === 1 ? cacheKey(this.model, texts[0]) : null;
242
+ if (key) {
243
+ const hit = cacheGet(key);
244
+ if (hit)
245
+ return [hit];
246
+ }
247
+ // Chunk large batches — a whole-brain re-embed (thousands of texts) in one request
248
+ // overwhelms the local server; ~64 per call keeps each request small and streams progress.
249
+ const CHUNK = 64;
250
+ const vectors = [];
251
+ for (let start = 0; start < texts.length; start += CHUNK) {
252
+ const slice = texts.slice(start, start + CHUNK);
253
+ const response = await this.fetchImpl(`${this.baseUrl}/api/embed`, {
254
+ method: "POST",
255
+ headers: { "Content-Type": "application/json" },
256
+ body: JSON.stringify({ model: this.model, input: slice })
257
+ });
258
+ if (!response.ok) {
259
+ const body = await response.text().catch(() => "");
260
+ throw new Error(`Ollama embeddings failed with ${response.status}${body ? `: ${body}` : ""}`);
261
+ }
262
+ const json = (await response.json());
263
+ const data = json.embeddings ?? [];
264
+ if (data.length !== slice.length) {
265
+ throw new Error(`Ollama embeddings returned ${data.length} vectors for ${slice.length} inputs.`);
266
+ }
267
+ for (let i = 0; i < data.length; i += 1) {
268
+ const vector = data[i];
269
+ if (!Array.isArray(vector) || vector.length === 0) {
270
+ throw new Error(`Ollama embeddings returned an empty vector at index ${start + i}.`);
271
+ }
272
+ vectors.push(l2normalize(vector));
273
+ }
274
+ }
275
+ if (key && vectors.length === 1)
276
+ cachePut(key, vectors[0]);
277
+ return vectors;
278
+ }
279
+ }
280
+ /**
281
+ * Resilient client that tries the API client first and transparently falls back
282
+ * to local embeddings on any error, so a flaky network never blocks memory writes.
283
+ */
284
+ export class FallbackEmbeddingClient {
285
+ primary;
286
+ fallback;
287
+ onFallback;
288
+ model;
289
+ constructor(primary, fallback = new LocalEmbeddingClient(), onFallback) {
290
+ this.primary = primary;
291
+ this.fallback = fallback;
292
+ this.onFallback = onFallback;
293
+ this.model = primary.model;
294
+ }
295
+ async embed(texts) {
296
+ try {
297
+ return await this.primary.embed(texts);
298
+ }
299
+ catch (error) {
300
+ this.onFallback?.(error);
301
+ return this.fallback.embed(texts);
302
+ }
303
+ }
304
+ }
305
+ /** Build the embedding client implied by config, or null when embeddings are off. */
306
+ export function createEmbeddingClient(options) {
307
+ const { config } = options;
308
+ if (config.embeddingMode === "off")
309
+ return null;
310
+ if (config.embeddingMode === "ollama") {
311
+ // Local semantic embeddings. Fall back to the API client (if configured) then trigram-local,
312
+ // so a stopped Ollama service degrades instead of breaking retrieval.
313
+ const ollama = new OllamaEmbeddingClient({
314
+ model: config.embeddingModel ?? "nomic-embed-text",
315
+ baseUrl: config.ollamaBaseUrl
316
+ });
317
+ const fallback = config.openRouterApiKey && config.embeddingModel && config.embeddingModel.includes("/")
318
+ ? new FallbackEmbeddingClient(new OpenRouterEmbeddingClient({ apiKey: config.openRouterApiKey, model: config.embeddingModel }), new LocalEmbeddingClient(), options.onFallback)
319
+ : new LocalEmbeddingClient();
320
+ return new FallbackEmbeddingClient(ollama, fallback, options.onFallback);
321
+ }
322
+ const apiKey = config.llmApiKey ?? config.openRouterApiKey;
323
+ const embeddable = config.provider !== "anthropic"; // Anthropic has no embeddings API — local fallback
324
+ if (config.embeddingMode === "api" && apiKey && config.embeddingModel && embeddable) {
325
+ const primary = new OpenRouterEmbeddingClient({
326
+ apiKey,
327
+ model: config.embeddingModel,
328
+ baseUrl: config.llmBaseUrl
329
+ });
330
+ return new FallbackEmbeddingClient(primary, new LocalEmbeddingClient(), options.onFallback);
331
+ }
332
+ // Default and "api"-without-credentials both resolve to deterministic local embeddings.
333
+ return new LocalEmbeddingClient();
334
+ }
335
+ function fnv1aInt(value) {
336
+ let hash = 0x811c9dc5;
337
+ for (let i = 0; i < value.length; i += 1) {
338
+ hash ^= value.charCodeAt(i);
339
+ hash = Math.imul(hash, 0x01000193);
340
+ }
341
+ return hash >>> 0;
342
+ }
343
+ function fnv1aHex(value) {
344
+ return fnv1aInt(value).toString(16).padStart(8, "0");
345
+ }