@exaudeus/memory-mcp 1.5.2 → 1.7.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/dist/embedder.d.ts +66 -0
- package/dist/embedder.js +204 -0
- package/dist/index.js +1 -0
- package/dist/ranking.d.ts +57 -0
- package/dist/ranking.js +140 -0
- package/dist/store.d.ts +37 -6
- package/dist/store.js +274 -47
- package/dist/text-analyzer.d.ts +7 -0
- package/dist/text-analyzer.js +18 -3
- package/dist/thresholds.d.ts +16 -0
- package/dist/thresholds.js +16 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.js +5 -0
- package/package.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { EmbeddingVector } from './types.js';
|
|
2
|
+
/** Why embedding failed — discriminated union for observability.
|
|
3
|
+
* The store doesn't branch on the reason (always falls back to keywords),
|
|
4
|
+
* but structured errors enable logging and diagnostics. */
|
|
5
|
+
export type EmbedFailure = {
|
|
6
|
+
readonly kind: 'provider-unavailable';
|
|
7
|
+
readonly reason: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: 'timeout';
|
|
10
|
+
readonly ms: number;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'invalid-input';
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
};
|
|
15
|
+
/** Embedding outcome — success carries the vector, failure carries structured diagnostics.
|
|
16
|
+
* Matches the StoreResult/CorrectResult pattern from types.ts. */
|
|
17
|
+
export type EmbedResult = {
|
|
18
|
+
readonly ok: true;
|
|
19
|
+
readonly vector: EmbeddingVector;
|
|
20
|
+
} | {
|
|
21
|
+
readonly ok: false;
|
|
22
|
+
readonly failure: EmbedFailure;
|
|
23
|
+
};
|
|
24
|
+
/** Embedding provider boundary — injected into the store.
|
|
25
|
+
* Implementations: OllamaEmbedder (production), FakeEmbedder (tests).
|
|
26
|
+
*
|
|
27
|
+
* Minimal interface: one method + one property.
|
|
28
|
+
* No isAvailable() — redundant with embed() returning a failure.
|
|
29
|
+
* No embedBatch() — interface segregation; batch is a standalone utility. */
|
|
30
|
+
export interface Embedder {
|
|
31
|
+
embed(text: string, signal?: AbortSignal): Promise<EmbedResult>;
|
|
32
|
+
readonly dimensions: number;
|
|
33
|
+
}
|
|
34
|
+
/** Production embedder using local Ollama instance.
|
|
35
|
+
* Uses node:http directly — no external dependencies. */
|
|
36
|
+
export declare class OllamaEmbedder implements Embedder {
|
|
37
|
+
readonly dimensions: number;
|
|
38
|
+
private readonly model;
|
|
39
|
+
private readonly baseUrl;
|
|
40
|
+
private readonly timeoutMs;
|
|
41
|
+
constructor(opts?: {
|
|
42
|
+
readonly model?: string;
|
|
43
|
+
readonly baseUrl?: string;
|
|
44
|
+
readonly timeoutMs?: number;
|
|
45
|
+
readonly dimensions?: number;
|
|
46
|
+
});
|
|
47
|
+
embed(text: string, signal?: AbortSignal): Promise<EmbedResult>;
|
|
48
|
+
}
|
|
49
|
+
/** Deterministic embedder for testing — uses character trigram frequency to produce
|
|
50
|
+
* a fixed-dimension vector from input text. Same text → same vector. Similar texts
|
|
51
|
+
* → high cosine similarity. Dissimilar texts → low cosine similarity.
|
|
52
|
+
*
|
|
53
|
+
* Limitation: models *string* similarity, not *semantic* similarity.
|
|
54
|
+
* "async workflows" and "asynchronous work patterns" score low despite being
|
|
55
|
+
* semantically identical. FakeEmbedder tests prove pipeline mechanics (merge,
|
|
56
|
+
* degradation, round-trip). Real semantic ordering is tested separately with
|
|
57
|
+
* fixture vectors from nomic-embed-text. */
|
|
58
|
+
export declare class FakeEmbedder implements Embedder {
|
|
59
|
+
readonly dimensions: number;
|
|
60
|
+
constructor(dimensions?: number);
|
|
61
|
+
embed(text: string, _signal?: AbortSignal): Promise<EmbedResult>;
|
|
62
|
+
}
|
|
63
|
+
/** Batch embed texts sequentially. Pure composition over Embedder.embed().
|
|
64
|
+
* Sequential because local Ollama benefits from serialized requests (single GPU/CPU).
|
|
65
|
+
* Not on the interface — interface segregation. */
|
|
66
|
+
export declare function batchEmbed(embedder: Embedder, texts: readonly string[]): Promise<ReadonlyArray<EmbedResult>>;
|
package/dist/embedder.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Embedding provider boundary — the only seam that touches embedding infrastructure.
|
|
2
|
+
// Injected into the store for testability; FakeEmbedder for tests, OllamaEmbedder for production.
|
|
3
|
+
//
|
|
4
|
+
// Design:
|
|
5
|
+
// - Errors are data: EmbedResult discriminated union, never throws
|
|
6
|
+
// - Small interface: one method + one property. Batch is a standalone utility.
|
|
7
|
+
// - Cancellation first-class: AbortSignal propagated through embed()
|
|
8
|
+
// - Graceful degradation: embed failure → keyword-only search, never a crash
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { asEmbeddingVector } from './types.js';
|
|
11
|
+
// ─── OllamaEmbedder ───────────────────────────────────────────────────────
|
|
12
|
+
/** Default timeout for embed calls — generous for cold model loads */
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
|
+
/** Production embedder using local Ollama instance.
|
|
15
|
+
* Uses node:http directly — no external dependencies. */
|
|
16
|
+
export class OllamaEmbedder {
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.model = opts?.model ?? 'nomic-embed-text';
|
|
19
|
+
this.baseUrl = opts?.baseUrl ?? 'http://localhost:11434';
|
|
20
|
+
this.timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
21
|
+
this.dimensions = opts?.dimensions ?? 384;
|
|
22
|
+
}
|
|
23
|
+
async embed(text, signal) {
|
|
24
|
+
// Validate at boundary — no HTTP call wasted on empty input
|
|
25
|
+
const trimmed = text.trim();
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
return { ok: false, failure: { kind: 'invalid-input', reason: 'empty text' } };
|
|
28
|
+
}
|
|
29
|
+
// Compose caller's signal with internal timeout
|
|
30
|
+
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
31
|
+
const combinedSignal = signal
|
|
32
|
+
? AbortSignal.any([signal, timeoutSignal])
|
|
33
|
+
: timeoutSignal;
|
|
34
|
+
const body = JSON.stringify({ model: this.model, prompt: trimmed });
|
|
35
|
+
const url = new URL('/api/embeddings', this.baseUrl);
|
|
36
|
+
const httpResult = await httpPost(url, body, combinedSignal);
|
|
37
|
+
// HTTP layer failed — map to embed failure
|
|
38
|
+
if (!httpResult.ok) {
|
|
39
|
+
if (httpResult.failure === 'aborted') {
|
|
40
|
+
return { ok: false, failure: { kind: 'timeout', ms: this.timeoutMs } };
|
|
41
|
+
}
|
|
42
|
+
return { ok: false, failure: { kind: 'provider-unavailable', reason: httpResult.failure } };
|
|
43
|
+
}
|
|
44
|
+
const response = httpResult.body;
|
|
45
|
+
// Validate response shape — Ollama returns { embedding: number[] }
|
|
46
|
+
if (!response.embedding || !Array.isArray(response.embedding)) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
failure: { kind: 'provider-unavailable', reason: 'unexpected response format — missing embedding array' },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Validate array contents — coercion to Float32Array silently produces NaN for non-numbers
|
|
53
|
+
if (response.embedding.length > 0 && typeof response.embedding[0] !== 'number') {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
failure: { kind: 'provider-unavailable', reason: 'embedding array contains non-numeric values' },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const vector = asEmbeddingVector(new Float32Array(response.embedding));
|
|
60
|
+
// Dimension sanity check — catches model mismatch at boundary
|
|
61
|
+
if (vector.length !== this.dimensions) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
failure: {
|
|
65
|
+
kind: 'provider-unavailable',
|
|
66
|
+
reason: `dimension mismatch: expected ${this.dimensions}, got ${vector.length}`,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, vector };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ─── FakeEmbedder ─────────────────────────────────────────────────────────
|
|
74
|
+
/** Deterministic embedder for testing — uses character trigram frequency to produce
|
|
75
|
+
* a fixed-dimension vector from input text. Same text → same vector. Similar texts
|
|
76
|
+
* → high cosine similarity. Dissimilar texts → low cosine similarity.
|
|
77
|
+
*
|
|
78
|
+
* Limitation: models *string* similarity, not *semantic* similarity.
|
|
79
|
+
* "async workflows" and "asynchronous work patterns" score low despite being
|
|
80
|
+
* semantically identical. FakeEmbedder tests prove pipeline mechanics (merge,
|
|
81
|
+
* degradation, round-trip). Real semantic ordering is tested separately with
|
|
82
|
+
* fixture vectors from nomic-embed-text. */
|
|
83
|
+
export class FakeEmbedder {
|
|
84
|
+
constructor(dimensions = 384) {
|
|
85
|
+
this.dimensions = dimensions;
|
|
86
|
+
}
|
|
87
|
+
async embed(text, _signal) {
|
|
88
|
+
const trimmed = text.trim();
|
|
89
|
+
if (!trimmed) {
|
|
90
|
+
return { ok: false, failure: { kind: 'invalid-input', reason: 'empty text' } };
|
|
91
|
+
}
|
|
92
|
+
const vector = trigramVector(trimmed, this.dimensions);
|
|
93
|
+
return { ok: true, vector };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Produce a deterministic vector from text using character trigram frequency.
|
|
97
|
+
* Each trigram hashes to a bucket (dimension index). The resulting vector is
|
|
98
|
+
* L2-normalized so cosine similarity between two vectors is meaningful.
|
|
99
|
+
*
|
|
100
|
+
* Pure function — no side effects, deterministic. */
|
|
101
|
+
function trigramVector(text, dimensions) {
|
|
102
|
+
const normalized = text.toLowerCase();
|
|
103
|
+
const raw = new Float32Array(dimensions);
|
|
104
|
+
// Accumulate trigram counts into dimension buckets
|
|
105
|
+
for (let i = 0; i <= normalized.length - 3; i++) {
|
|
106
|
+
const trigram = normalized.substring(i, i + 3);
|
|
107
|
+
const bucket = trigramHash(trigram, dimensions);
|
|
108
|
+
raw[bucket] += 1;
|
|
109
|
+
}
|
|
110
|
+
// L2 normalize — cosine similarity requires unit-length or at least
|
|
111
|
+
// consistent normalization to be meaningful
|
|
112
|
+
let norm = 0;
|
|
113
|
+
for (let i = 0; i < dimensions; i++) {
|
|
114
|
+
norm += raw[i] * raw[i];
|
|
115
|
+
}
|
|
116
|
+
norm = Math.sqrt(norm);
|
|
117
|
+
if (norm > 0) {
|
|
118
|
+
for (let i = 0; i < dimensions; i++) {
|
|
119
|
+
raw[i] /= norm;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return asEmbeddingVector(raw);
|
|
123
|
+
}
|
|
124
|
+
/** Simple hash of a trigram string to a bucket index.
|
|
125
|
+
* Deterministic: same trigram always maps to the same bucket. */
|
|
126
|
+
function trigramHash(trigram, buckets) {
|
|
127
|
+
let hash = 0;
|
|
128
|
+
for (let i = 0; i < trigram.length; i++) {
|
|
129
|
+
hash = ((hash << 5) - hash + trigram.charCodeAt(i)) | 0;
|
|
130
|
+
}
|
|
131
|
+
return ((hash % buckets) + buckets) % buckets;
|
|
132
|
+
}
|
|
133
|
+
// ─── Batch utility ────────────────────────────────────────────────────────
|
|
134
|
+
/** Batch embed texts sequentially. Pure composition over Embedder.embed().
|
|
135
|
+
* Sequential because local Ollama benefits from serialized requests (single GPU/CPU).
|
|
136
|
+
* Not on the interface — interface segregation. */
|
|
137
|
+
export async function batchEmbed(embedder, texts) {
|
|
138
|
+
const results = [];
|
|
139
|
+
for (const text of texts) {
|
|
140
|
+
results.push(await embedder.embed(text));
|
|
141
|
+
}
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
/** Minimal HTTP POST using node:http — returns a result type, never throws.
|
|
145
|
+
* No external dependencies — the Ollama API is simple enough for raw HTTP.
|
|
146
|
+
*
|
|
147
|
+
* Uses a settled guard to prevent double-resolution when abort races with
|
|
148
|
+
* a network error (req.destroy triggers error handler alongside abort handler). */
|
|
149
|
+
function httpPost(url, body, signal) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
let settled = false;
|
|
152
|
+
const settle = (result) => {
|
|
153
|
+
if (settled)
|
|
154
|
+
return;
|
|
155
|
+
settled = true;
|
|
156
|
+
signal.removeEventListener('abort', onAbort);
|
|
157
|
+
resolve(result);
|
|
158
|
+
};
|
|
159
|
+
// Already aborted before we start — fail fast
|
|
160
|
+
if (signal.aborted) {
|
|
161
|
+
resolve({ ok: false, failure: 'aborted' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const onAbort = () => {
|
|
165
|
+
req.destroy();
|
|
166
|
+
settle({ ok: false, failure: 'aborted' });
|
|
167
|
+
};
|
|
168
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
169
|
+
const req = http.request({
|
|
170
|
+
hostname: url.hostname,
|
|
171
|
+
port: url.port || 80,
|
|
172
|
+
path: url.pathname,
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'Content-Length': Buffer.byteLength(body),
|
|
177
|
+
},
|
|
178
|
+
}, (res) => {
|
|
179
|
+
const chunks = [];
|
|
180
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
181
|
+
res.on('end', () => {
|
|
182
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
183
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
184
|
+
settle({ ok: false, failure: `HTTP ${res.statusCode}: ${raw.slice(0, 200)}` });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
settle({ ok: true, body: JSON.parse(raw) });
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
settle({ ok: false, failure: `invalid JSON response: ${raw.slice(0, 200)}` });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
res.on('error', (err) => {
|
|
195
|
+
settle({ ok: false, failure: err.message });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
req.on('error', (err) => {
|
|
199
|
+
settle({ ok: false, failure: err.message });
|
|
200
|
+
});
|
|
201
|
+
req.write(body);
|
|
202
|
+
req.end();
|
|
203
|
+
});
|
|
204
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -909,6 +909,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
909
909
|
// --- Search mode: context provided → keyword search across all topics ---
|
|
910
910
|
const max = maxResults ?? 10;
|
|
911
911
|
const threshold = minMatch ?? 0.2;
|
|
912
|
+
// Resolve which lobes to search — follows the degradation ladder via resolveLobesForRead().
|
|
912
913
|
const allLobeResults = [];
|
|
913
914
|
const ctxEntryLobeMap = new Map(); // entry id → lobe name
|
|
914
915
|
let label;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { MemoryEntry, EmbeddingVector } from './types.js';
|
|
2
|
+
/** Which ranking signal produced this result. */
|
|
3
|
+
export type RankSource = 'keyword' | 'semantic' | 'merged';
|
|
4
|
+
/** A scored search result. Shared return type for all ranking functions. */
|
|
5
|
+
export interface ScoredEntry {
|
|
6
|
+
readonly entry: MemoryEntry;
|
|
7
|
+
readonly score: number;
|
|
8
|
+
readonly matchedKeywords: readonly string[];
|
|
9
|
+
readonly source: RankSource;
|
|
10
|
+
/** Raw cosine similarity before boost multiplication.
|
|
11
|
+
* Present for semantic and merged results; absent for keyword-only.
|
|
12
|
+
* Used for debug logging (threshold calibration) and display. */
|
|
13
|
+
readonly semanticSimilarity?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Shared context for ranking functions — pure data, no callbacks.
|
|
16
|
+
* Groups the parameters that keywordRank and semanticRank need,
|
|
17
|
+
* keeping function signatures tight.
|
|
18
|
+
*
|
|
19
|
+
* freshEntryIds is precomputed by the store — the ranking function checks
|
|
20
|
+
* set membership rather than calling into the store's private staleness logic.
|
|
21
|
+
* This keeps the function provably pure.
|
|
22
|
+
*
|
|
23
|
+
* defaultModuleBoost is the fallback boost for modules/* topics not in topicBoost.
|
|
24
|
+
* Injected here so ranking.ts needs zero direct threshold imports. */
|
|
25
|
+
export interface RankContext {
|
|
26
|
+
readonly currentBranch: string;
|
|
27
|
+
readonly branchFilter: string | undefined;
|
|
28
|
+
readonly topicBoost: Readonly<Record<string, number>>;
|
|
29
|
+
readonly freshEntryIds: ReadonlySet<string>;
|
|
30
|
+
readonly defaultModuleBoost: number;
|
|
31
|
+
}
|
|
32
|
+
/** Rank entries by keyword overlap with context keywords.
|
|
33
|
+
* Pure extraction of the ranking logic from contextSearch — identical scoring.
|
|
34
|
+
*
|
|
35
|
+
* Filter + rank in one pass for efficiency (~200 entries, not worth two iterations).
|
|
36
|
+
* Branch filtering for recent-work is applied here because it's a pre-condition
|
|
37
|
+
* for ranking, not a separate pipeline stage.
|
|
38
|
+
*
|
|
39
|
+
* Does NOT include the "always include user entries" policy — that's an
|
|
40
|
+
* orchestration concern that stays in contextSearch. */
|
|
41
|
+
export declare function keywordRank(entries: readonly MemoryEntry[], contextKeywords: ReadonlySet<string>, minMatch: number, ctx: RankContext): readonly ScoredEntry[];
|
|
42
|
+
/** Rank entries by cosine similarity between query embedding and stored vectors.
|
|
43
|
+
* Pure function — no I/O, no side effects.
|
|
44
|
+
*
|
|
45
|
+
* Entries without vectors are silently skipped — they participate via keyword ranking only.
|
|
46
|
+
* Branch filtering applied (recent-work scoped to current branch).
|
|
47
|
+
*
|
|
48
|
+
* @param minSimilarity Minimum cosine similarity to include. Caller provides the
|
|
49
|
+
* threshold (SEMANTIC_MIN_SIMILARITY for production, 0 for debug mode to see all scores). */
|
|
50
|
+
export declare function semanticRank(entries: readonly MemoryEntry[], vectors: ReadonlyMap<string, EmbeddingVector>, queryVector: EmbeddingVector, minSimilarity: number, ctx: RankContext): readonly ScoredEntry[];
|
|
51
|
+
/** Merge keyword and semantic ranking results using max-score strategy.
|
|
52
|
+
* For each entry: final score = max(keywordScore, semanticScore).
|
|
53
|
+
* Entries in both lists get source: 'merged', preserving matchedKeywords from
|
|
54
|
+
* keyword result and semanticSimilarity from semantic result.
|
|
55
|
+
*
|
|
56
|
+
* No weighted fusion, no magic constants. Deterministic — same inputs, same output. */
|
|
57
|
+
export declare function mergeRankings(keywordResults: readonly ScoredEntry[], semanticResults: readonly ScoredEntry[]): readonly ScoredEntry[];
|
package/dist/ranking.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Domain ranking functions — scoring MemoryEntries using text analysis primitives.
|
|
2
|
+
// Pure functions: no I/O, no side effects, deterministic.
|
|
3
|
+
//
|
|
4
|
+
// Separated from text-analyzer.ts (which works on strings/sets, not domain types)
|
|
5
|
+
// and from store.ts (which handles orchestration and persistence).
|
|
6
|
+
// This module is the ranking pipeline seam for keyword, semantic, and merged ranking.
|
|
7
|
+
import { REFERENCE_BOOST_MULTIPLIER, TAG_MATCH_BOOST, } from './thresholds.js';
|
|
8
|
+
import { extractKeywords, stem, cosineSimilarity } from './text-analyzer.js';
|
|
9
|
+
// ─── Shared helpers ────────────────────────────────────────────────────────
|
|
10
|
+
/** Resolve topic boost for an entry. Falls back to defaultModuleBoost for
|
|
11
|
+
* modules/* topics, or 1.0 for unknown topics. */
|
|
12
|
+
function getTopicBoost(topic, ctx) {
|
|
13
|
+
return ctx.topicBoost[topic] ?? (topic.startsWith('modules/') ? ctx.defaultModuleBoost : 1.0);
|
|
14
|
+
}
|
|
15
|
+
/** Check whether a recent-work entry should be filtered out by branch. */
|
|
16
|
+
function isBranchFiltered(entry, ctx) {
|
|
17
|
+
return entry.topic === 'recent-work'
|
|
18
|
+
&& ctx.branchFilter !== '*'
|
|
19
|
+
&& !!entry.branch
|
|
20
|
+
&& entry.branch !== ctx.currentBranch;
|
|
21
|
+
}
|
|
22
|
+
// ─── Keyword ranking ───────────────────────────────────────────────────────
|
|
23
|
+
/** Rank entries by keyword overlap with context keywords.
|
|
24
|
+
* Pure extraction of the ranking logic from contextSearch — identical scoring.
|
|
25
|
+
*
|
|
26
|
+
* Filter + rank in one pass for efficiency (~200 entries, not worth two iterations).
|
|
27
|
+
* Branch filtering for recent-work is applied here because it's a pre-condition
|
|
28
|
+
* for ranking, not a separate pipeline stage.
|
|
29
|
+
*
|
|
30
|
+
* Does NOT include the "always include user entries" policy — that's an
|
|
31
|
+
* orchestration concern that stays in contextSearch. */
|
|
32
|
+
export function keywordRank(entries, contextKeywords, minMatch, ctx) {
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (isBranchFiltered(entry, ctx))
|
|
36
|
+
continue;
|
|
37
|
+
// Include tag values as keywords so tagged entries surface in context search
|
|
38
|
+
const tagKeywordPart = entry.tags ? ` ${entry.tags.join(' ')}` : '';
|
|
39
|
+
const entryKeywords = extractKeywords(`${entry.title} ${entry.content}${tagKeywordPart}`);
|
|
40
|
+
const matchedKeywords = [];
|
|
41
|
+
for (const kw of contextKeywords) {
|
|
42
|
+
if (entryKeywords.has(kw))
|
|
43
|
+
matchedKeywords.push(kw);
|
|
44
|
+
}
|
|
45
|
+
if (matchedKeywords.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
// Enforce minimum match threshold
|
|
48
|
+
const matchRatio = matchedKeywords.length / contextKeywords.size;
|
|
49
|
+
if (matchRatio < minMatch)
|
|
50
|
+
continue;
|
|
51
|
+
// Score = keyword match ratio × confidence × topic boost × freshness × reference boost × tag boost
|
|
52
|
+
const boost = getTopicBoost(entry.topic, ctx);
|
|
53
|
+
const freshnessMultiplier = ctx.freshEntryIds.has(entry.id) ? 1.0 : 0.7;
|
|
54
|
+
// Reference boost: exact class/file name match in references gets a 1.3x multiplier
|
|
55
|
+
const referenceBoost = entry.references?.some(ref => {
|
|
56
|
+
const basename = ref.split('/').pop()?.replace(/\.\w+$/, '') ?? ref;
|
|
57
|
+
return contextKeywords.has(stem(basename.toLowerCase()));
|
|
58
|
+
}) ? REFERENCE_BOOST_MULTIPLIER : 1.0;
|
|
59
|
+
// Tag boost: if any tag exactly matches a context keyword, boost the entry
|
|
60
|
+
const tagBoost = entry.tags?.some(tag => contextKeywords.has(tag))
|
|
61
|
+
? TAG_MATCH_BOOST : 1.0;
|
|
62
|
+
const score = matchRatio * entry.confidence * boost * freshnessMultiplier * referenceBoost * tagBoost;
|
|
63
|
+
results.push({ entry, score, matchedKeywords, source: 'keyword' });
|
|
64
|
+
}
|
|
65
|
+
return results.sort((a, b) => b.score - a.score);
|
|
66
|
+
}
|
|
67
|
+
// ─── Semantic ranking ──────────────────────────────────────────────────────
|
|
68
|
+
/** Rank entries by cosine similarity between query embedding and stored vectors.
|
|
69
|
+
* Pure function — no I/O, no side effects.
|
|
70
|
+
*
|
|
71
|
+
* Entries without vectors are silently skipped — they participate via keyword ranking only.
|
|
72
|
+
* Branch filtering applied (recent-work scoped to current branch).
|
|
73
|
+
*
|
|
74
|
+
* @param minSimilarity Minimum cosine similarity to include. Caller provides the
|
|
75
|
+
* threshold (SEMANTIC_MIN_SIMILARITY for production, 0 for debug mode to see all scores). */
|
|
76
|
+
export function semanticRank(entries, vectors, queryVector, minSimilarity, ctx) {
|
|
77
|
+
const results = [];
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (isBranchFiltered(entry, ctx))
|
|
80
|
+
continue;
|
|
81
|
+
const entryVector = vectors.get(entry.id);
|
|
82
|
+
if (!entryVector)
|
|
83
|
+
continue;
|
|
84
|
+
const similarity = cosineSimilarity(queryVector, entryVector);
|
|
85
|
+
if (similarity < minSimilarity)
|
|
86
|
+
continue;
|
|
87
|
+
// Score = cosine similarity × confidence × topic boost × freshness
|
|
88
|
+
// No reference/tag boost — those are keyword-domain signals captured by keywordRank
|
|
89
|
+
const boost = getTopicBoost(entry.topic, ctx);
|
|
90
|
+
const freshnessMultiplier = ctx.freshEntryIds.has(entry.id) ? 1.0 : 0.7;
|
|
91
|
+
const score = similarity * entry.confidence * boost * freshnessMultiplier;
|
|
92
|
+
results.push({
|
|
93
|
+
entry,
|
|
94
|
+
score,
|
|
95
|
+
matchedKeywords: [],
|
|
96
|
+
source: 'semantic',
|
|
97
|
+
semanticSimilarity: similarity,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return results.sort((a, b) => b.score - a.score);
|
|
101
|
+
}
|
|
102
|
+
// ─── Merge ─────────────────────────────────────────────────────────────────
|
|
103
|
+
/** Merge keyword and semantic ranking results using max-score strategy.
|
|
104
|
+
* For each entry: final score = max(keywordScore, semanticScore).
|
|
105
|
+
* Entries in both lists get source: 'merged', preserving matchedKeywords from
|
|
106
|
+
* keyword result and semanticSimilarity from semantic result.
|
|
107
|
+
*
|
|
108
|
+
* No weighted fusion, no magic constants. Deterministic — same inputs, same output. */
|
|
109
|
+
export function mergeRankings(keywordResults, semanticResults) {
|
|
110
|
+
// Index keyword results by entry ID for O(1) lookup during merge
|
|
111
|
+
const keywordById = new Map();
|
|
112
|
+
for (const r of keywordResults) {
|
|
113
|
+
keywordById.set(r.entry.id, r);
|
|
114
|
+
}
|
|
115
|
+
const merged = new Map();
|
|
116
|
+
// Process semantic results — check for keyword counterpart
|
|
117
|
+
for (const sem of semanticResults) {
|
|
118
|
+
const kw = keywordById.get(sem.entry.id);
|
|
119
|
+
if (kw) {
|
|
120
|
+
// Entry in both lists — use max score, merge signals
|
|
121
|
+
merged.set(sem.entry.id, {
|
|
122
|
+
entry: sem.entry,
|
|
123
|
+
score: Math.max(sem.score, kw.score),
|
|
124
|
+
matchedKeywords: kw.matchedKeywords, // from keyword (semantic has none)
|
|
125
|
+
source: 'merged',
|
|
126
|
+
semanticSimilarity: sem.semanticSimilarity, // from semantic
|
|
127
|
+
});
|
|
128
|
+
keywordById.delete(sem.entry.id); // consumed
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Semantic-only
|
|
132
|
+
merged.set(sem.entry.id, sem);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Remaining keyword-only results
|
|
136
|
+
for (const kw of keywordById.values()) {
|
|
137
|
+
merged.set(kw.entry.id, kw);
|
|
138
|
+
}
|
|
139
|
+
return Array.from(merged.values()).sort((a, b) => b.score - a.score);
|
|
140
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, DurabilityDecision, QueryResult, StoreResult, CorrectResult, MemoryStats, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
|
|
2
|
+
import type { ScoredEntry } from './ranking.js';
|
|
2
3
|
export declare class MarkdownMemoryStore {
|
|
3
4
|
private readonly config;
|
|
4
5
|
private readonly memoryPath;
|
|
5
6
|
private readonly clock;
|
|
6
7
|
private readonly git;
|
|
8
|
+
private readonly embedder;
|
|
7
9
|
private entries;
|
|
10
|
+
private vectors;
|
|
8
11
|
private corruptFileCount;
|
|
9
12
|
constructor(config: MemoryConfig);
|
|
10
13
|
/** Resolved behavior thresholds — user config merged over defaults.
|
|
@@ -27,13 +30,14 @@ export declare class MarkdownMemoryStore {
|
|
|
27
30
|
stats(): Promise<MemoryStats>;
|
|
28
31
|
/** Bootstrap: scan repo structure and seed initial knowledge */
|
|
29
32
|
bootstrap(): Promise<StoreResult[]>;
|
|
30
|
-
/** Search across all topics using keyword
|
|
33
|
+
/** Search across all topics using keyword + semantic ranking with topic-based boosting.
|
|
34
|
+
* Orchestrator: reload, extract keywords, embed query, rank, merge, apply policies.
|
|
35
|
+
*
|
|
36
|
+
* Graceful degradation: when embedder is null or embed fails, semantic results
|
|
37
|
+
* are empty and merge produces keyword-only results — identical to pre-embedding behavior.
|
|
38
|
+
*
|
|
31
39
|
* @param minMatch Minimum ratio of context keywords that must match (0-1, default 0.2) */
|
|
32
|
-
contextSearch(context: string, maxResults?: number, branchFilter?: string, minMatch?: number): Promise<
|
|
33
|
-
entry: MemoryEntry;
|
|
34
|
-
score: number;
|
|
35
|
-
matchedKeywords: string[];
|
|
36
|
-
}>>;
|
|
40
|
+
contextSearch(context: string, maxResults?: number, branchFilter?: string, minMatch?: number): Promise<readonly ScoredEntry[]>;
|
|
37
41
|
/** Generate a collision-resistant ID: {prefix}-{8 random hex chars} */
|
|
38
42
|
private generateId;
|
|
39
43
|
/** Compute relative file path for an entry within the memory directory */
|
|
@@ -46,6 +50,26 @@ export declare class MarkdownMemoryStore {
|
|
|
46
50
|
private persistEntry;
|
|
47
51
|
/** Delete the file for an entry */
|
|
48
52
|
private deleteEntryFile;
|
|
53
|
+
/** .vec file format version — future-proofs for metadata additions.
|
|
54
|
+
* v1: [u8 version=0x01][u32LE dimensions][f32LE × dimensions] */
|
|
55
|
+
private static readonly VEC_FORMAT_VERSION;
|
|
56
|
+
/** Write a vector sidecar file for an entry.
|
|
57
|
+
* Format: [u8 version][u32LE dims][f32LE × dims]. Total: 5 + dims*4 bytes. */
|
|
58
|
+
private persistVector;
|
|
59
|
+
/** Load and validate a vector sidecar file.
|
|
60
|
+
* Returns null on: missing file, unknown version, wrong dimensions, corrupt size.
|
|
61
|
+
* Null is not an error — it means the entry works via keyword fallback. */
|
|
62
|
+
private loadVector;
|
|
63
|
+
/** Delete a vector sidecar file. Fire-and-forget — failure is harmless. */
|
|
64
|
+
private deleteVector;
|
|
65
|
+
/** Derive the .vec path from a .md relative path */
|
|
66
|
+
private vecPathFromMdPath;
|
|
67
|
+
/** Load vectors for all known entries. Cleans up orphan .vec files.
|
|
68
|
+
* Skipped entirely when embedder is null (no wasted I/O).
|
|
69
|
+
* Returns a fresh map — no mutation of store state. */
|
|
70
|
+
private loadVectorSnapshot;
|
|
71
|
+
/** Recursively find all .vec files in a directory */
|
|
72
|
+
private findVecFiles;
|
|
49
73
|
/** Load all entries from disk and return as an immutable snapshot.
|
|
50
74
|
* Pure read — no mutation. Callers decide whether to cache.
|
|
51
75
|
* Tracks corrupt files for observability without failing the load. */
|
|
@@ -71,6 +95,13 @@ export declare class MarkdownMemoryStore {
|
|
|
71
95
|
/** Find entries in the same topic with significant overlap (dedup detection).
|
|
72
96
|
* Uses hybrid jaccard+containment similarity. */
|
|
73
97
|
private findRelatedEntries;
|
|
98
|
+
/** Find semantic duplicates by cosine similarity against stored vectors.
|
|
99
|
+
* Same-topic only. Returns entries above DEDUP_SEMANTIC_THRESHOLD.
|
|
100
|
+
* Returns empty when no embedder or no vectors available. */
|
|
101
|
+
private findSemanticDuplicates;
|
|
102
|
+
/** Merge keyword-based and semantic-based dedup results, dedup by ID,
|
|
103
|
+
* keeping the entry with higher similarity score. */
|
|
104
|
+
private mergeRelatedEntries;
|
|
74
105
|
/** Tag frequency across all entries — for vocabulary echo in store responses.
|
|
75
106
|
* Returns tags sorted by frequency (descending). O(N) over entries. */
|
|
76
107
|
getTagFrequency(): ReadonlyMap<string, number>;
|
package/dist/store.js
CHANGED
|
@@ -6,10 +6,11 @@ import path from 'path';
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { execFile } from 'child_process';
|
|
8
8
|
import { promisify } from 'util';
|
|
9
|
-
import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel, parseTags } from './types.js';
|
|
10
|
-
import { DEDUP_SIMILARITY_THRESHOLD, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD,
|
|
9
|
+
import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel, parseTags, asEmbeddingVector } from './types.js';
|
|
10
|
+
import { DEDUP_SIMILARITY_THRESHOLD, DEDUP_SEMANTIC_THRESHOLD, SEMANTIC_MIN_SIMILARITY, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD, TOPIC_BOOST, MODULE_TOPIC_BOOST, USER_ALWAYS_INCLUDE_SCORE_FRACTION, DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, DEFAULT_MAX_PREFERENCE_SUGGESTIONS, } from './thresholds.js';
|
|
11
11
|
import { realGitService } from './git-service.js';
|
|
12
|
-
import { extractKeywords,
|
|
12
|
+
import { extractKeywords, similarity, cosineSimilarity, matchesFilter, computeRelevanceScore, } from './text-analyzer.js';
|
|
13
|
+
import { keywordRank, semanticRank, mergeRankings } from './ranking.js';
|
|
13
14
|
import { detectEphemeralSignals, formatEphemeralWarning, getEphemeralSeverity } from './ephemeral.js';
|
|
14
15
|
// Used only by bootstrap() for git log — not part of the GitService boundary
|
|
15
16
|
// because bootstrap is a one-shot utility, not a recurring operation
|
|
@@ -17,11 +18,13 @@ const execFileAsync = promisify(execFile);
|
|
|
17
18
|
export class MarkdownMemoryStore {
|
|
18
19
|
constructor(config) {
|
|
19
20
|
this.entries = new Map();
|
|
21
|
+
this.vectors = new Map();
|
|
20
22
|
this.corruptFileCount = 0;
|
|
21
23
|
this.config = config;
|
|
22
24
|
this.memoryPath = config.memoryPath;
|
|
23
25
|
this.clock = config.clock ?? realClock;
|
|
24
26
|
this.git = config.git ?? realGitService;
|
|
27
|
+
this.embedder = config.embedder ?? null;
|
|
25
28
|
}
|
|
26
29
|
/** Resolved behavior thresholds — user config merged over defaults.
|
|
27
30
|
* Centralizes threshold resolution so every caller gets the same value. */
|
|
@@ -99,8 +102,22 @@ export class MarkdownMemoryStore {
|
|
|
99
102
|
this.entries.set(id, entry);
|
|
100
103
|
const file = this.entryToRelativePath(entry);
|
|
101
104
|
await this.persistEntry(entry);
|
|
102
|
-
//
|
|
103
|
-
|
|
105
|
+
// Embed and persist vector — awaited so .vec exists when store() returns
|
|
106
|
+
if (this.embedder) {
|
|
107
|
+
const embedText = `${title}\n\n${content}`;
|
|
108
|
+
const embedResult = await this.embedder.embed(embedText);
|
|
109
|
+
if (embedResult.ok) {
|
|
110
|
+
await this.persistVector(file, embedResult.vector);
|
|
111
|
+
this.vectors.set(entry.id, embedResult.vector);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
process.stderr.write(`[memory-mcp] Embedding failed for ${entry.id}: ${embedResult.failure.kind}\n`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Dedup: merge keyword-based and semantic-based duplicate detection
|
|
118
|
+
const keywordDupes = this.findRelatedEntries(entry, existing?.id);
|
|
119
|
+
const semanticDupes = this.findSemanticDuplicates(entry.id, topic);
|
|
120
|
+
const relatedEntries = this.mergeRelatedEntries(keywordDupes, semanticDupes);
|
|
104
121
|
// Surface relevant preferences if storing a non-preference entry
|
|
105
122
|
const relevantPreferences = (topic !== 'preferences' && topic !== 'user')
|
|
106
123
|
? this.findRelevantPreferences(entry)
|
|
@@ -304,6 +321,19 @@ export class MarkdownMemoryStore {
|
|
|
304
321
|
};
|
|
305
322
|
this.entries.set(id, updated);
|
|
306
323
|
await this.persistEntry(updated);
|
|
324
|
+
// Re-embed: content changed, old vector is stale
|
|
325
|
+
if (this.embedder) {
|
|
326
|
+
const embedText = `${updated.title}\n\n${updated.content}`;
|
|
327
|
+
const embedResult = await this.embedder.embed(embedText);
|
|
328
|
+
if (embedResult.ok) {
|
|
329
|
+
const updatedFile = this.entryToRelativePath(updated);
|
|
330
|
+
await this.persistVector(updatedFile, embedResult.vector);
|
|
331
|
+
this.vectors.set(updated.id, embedResult.vector);
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
process.stderr.write(`[memory-mcp] Re-embedding failed for ${updated.id}: ${embedResult.failure.kind}\n`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
307
337
|
return { corrected: true, id, action, newConfidence: 1.0, trust: 'user' };
|
|
308
338
|
}
|
|
309
339
|
/** Get memory health statistics */
|
|
@@ -415,59 +445,77 @@ export class MarkdownMemoryStore {
|
|
|
415
445
|
return results;
|
|
416
446
|
}
|
|
417
447
|
// --- Contextual search (memory_context) ---
|
|
418
|
-
/** Search across all topics using keyword
|
|
448
|
+
/** Search across all topics using keyword + semantic ranking with topic-based boosting.
|
|
449
|
+
* Orchestrator: reload, extract keywords, embed query, rank, merge, apply policies.
|
|
450
|
+
*
|
|
451
|
+
* Graceful degradation: when embedder is null or embed fails, semantic results
|
|
452
|
+
* are empty and merge produces keyword-only results — identical to pre-embedding behavior.
|
|
453
|
+
*
|
|
419
454
|
* @param minMatch Minimum ratio of context keywords that must match (0-1, default 0.2) */
|
|
420
455
|
async contextSearch(context, maxResults = 10, branchFilter, minMatch = 0.2) {
|
|
421
456
|
// Reload from disk to pick up changes from other processes
|
|
422
457
|
await this.reloadFromDisk();
|
|
423
458
|
const contextKeywords = extractKeywords(context);
|
|
424
|
-
|
|
459
|
+
// Only bail on zero keywords when there's no embedder to fall back on.
|
|
460
|
+
// Stopword-heavy queries produce zero keywords but can yield semantic results.
|
|
461
|
+
if (contextKeywords.size === 0 && !this.embedder)
|
|
425
462
|
return [];
|
|
426
463
|
const currentBranch = branchFilter || await this.getCurrentBranch();
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
for (const entry of
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
464
|
+
const allEntries = Array.from(this.entries.values());
|
|
465
|
+
// Precompute freshness set — keeps ranking functions provably pure (no callbacks)
|
|
466
|
+
const freshEntryIds = new Set();
|
|
467
|
+
for (const entry of allEntries) {
|
|
468
|
+
if (this.isFresh(entry))
|
|
469
|
+
freshEntryIds.add(entry.id);
|
|
470
|
+
}
|
|
471
|
+
const ctx = {
|
|
472
|
+
currentBranch,
|
|
473
|
+
branchFilter,
|
|
474
|
+
topicBoost: TOPIC_BOOST,
|
|
475
|
+
freshEntryIds,
|
|
476
|
+
defaultModuleBoost: MODULE_TOPIC_BOOST,
|
|
477
|
+
};
|
|
478
|
+
// Keyword ranking (may be empty for stopword-heavy queries)
|
|
479
|
+
const keywordResults = contextKeywords.size > 0
|
|
480
|
+
? keywordRank(allEntries, contextKeywords, minMatch, ctx)
|
|
481
|
+
: [];
|
|
482
|
+
// Semantic ranking (only if embedder available and query embeds successfully)
|
|
483
|
+
const debug = process.env.MEMORY_MCP_DEBUG === '1';
|
|
484
|
+
let semanticResults = [];
|
|
485
|
+
if (this.embedder) {
|
|
486
|
+
const queryResult = await this.embedder.embed(context);
|
|
487
|
+
if (queryResult.ok) {
|
|
488
|
+
// In debug mode, get ALL scores (threshold=0) for calibration logging
|
|
489
|
+
const rawSemanticResults = semanticRank(allEntries, this.vectors, queryResult.vector, debug ? 0 : SEMANTIC_MIN_SIMILARITY, ctx);
|
|
490
|
+
if (debug) {
|
|
491
|
+
for (const r of rawSemanticResults) {
|
|
492
|
+
const included = (r.semanticSimilarity ?? 0) >= SEMANTIC_MIN_SIMILARITY;
|
|
493
|
+
process.stderr.write(`[memory-mcp:debug] semantic ${(r.semanticSimilarity ?? 0).toFixed(3)} ${r.entry.id} "${r.entry.title}"${included ? '' : ' ← below threshold'}\n`);
|
|
494
|
+
}
|
|
495
|
+
// Filter to threshold after logging all scores
|
|
496
|
+
semanticResults = rawSemanticResults.filter(r => (r.semanticSimilarity ?? 0) >= SEMANTIC_MIN_SIMILARITY);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
semanticResults = rawSemanticResults;
|
|
500
|
+
}
|
|
442
501
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
// Score = keyword match ratio x confidence x topic boost x reference boost
|
|
450
|
-
const boost = topicBoost[entry.topic] ?? (entry.topic.startsWith('modules/') ? MODULE_TOPIC_BOOST : 1.0);
|
|
451
|
-
const freshnessMultiplier = this.isFresh(entry) ? 1.0 : 0.7;
|
|
452
|
-
// Reference boost: exact class/file name match in references gets a 1.3x multiplier.
|
|
453
|
-
// Extracts the basename (without extension) from each reference path and stems it,
|
|
454
|
-
// then checks for overlap with the context keywords.
|
|
455
|
-
const referenceBoost = entry.references?.some(ref => {
|
|
456
|
-
const basename = ref.split('/').pop()?.replace(/\.\w+$/, '') ?? ref;
|
|
457
|
-
return contextKeywords.has(stem(basename.toLowerCase()));
|
|
458
|
-
}) ? REFERENCE_BOOST_MULTIPLIER : 1.0;
|
|
459
|
-
// Tag boost: if any tag exactly matches a context keyword, boost the entry
|
|
460
|
-
const tagBoost = entry.tags?.some(tag => contextKeywords.has(tag))
|
|
461
|
-
? TAG_MATCH_BOOST : 1.0;
|
|
462
|
-
const score = matchRatio * entry.confidence * boost * freshnessMultiplier * referenceBoost * tagBoost;
|
|
463
|
-
results.push({ entry, score, matchedKeywords });
|
|
464
|
-
}
|
|
465
|
-
// Always include user entries even if no keyword match (they're always relevant)
|
|
502
|
+
// If embed fails: semanticResults stays empty, keyword results used alone
|
|
503
|
+
}
|
|
504
|
+
// Merge keyword + semantic results using max-score strategy
|
|
505
|
+
const merged = mergeRankings(keywordResults, semanticResults);
|
|
506
|
+
// Policy: always include user entries even if no keyword/semantic match
|
|
507
|
+
const results = [...merged];
|
|
466
508
|
for (const entry of this.entries.values()) {
|
|
467
509
|
if (entry.topic === 'user' && !results.find(r => r.entry.id === entry.id)) {
|
|
468
|
-
results.push({
|
|
510
|
+
results.push({
|
|
511
|
+
entry,
|
|
512
|
+
score: entry.confidence * USER_ALWAYS_INCLUDE_SCORE_FRACTION,
|
|
513
|
+
matchedKeywords: [],
|
|
514
|
+
source: 'keyword',
|
|
515
|
+
});
|
|
469
516
|
}
|
|
470
517
|
}
|
|
518
|
+
// Re-sort after policy injections to maintain score-descending invariant
|
|
471
519
|
return results
|
|
472
520
|
.sort((a, b) => b.score - a.score)
|
|
473
521
|
.slice(0, maxResults);
|
|
@@ -544,6 +592,8 @@ export class MarkdownMemoryStore {
|
|
|
544
592
|
await fs.unlink(fullPath);
|
|
545
593
|
}
|
|
546
594
|
catch { /* already gone */ }
|
|
595
|
+
// Delete companion .vec file if it exists
|
|
596
|
+
await this.deleteVector(relativePath);
|
|
547
597
|
// Clean up empty parent directories
|
|
548
598
|
try {
|
|
549
599
|
const dir = path.dirname(fullPath);
|
|
@@ -554,6 +604,128 @@ export class MarkdownMemoryStore {
|
|
|
554
604
|
}
|
|
555
605
|
catch { /* ignore */ }
|
|
556
606
|
}
|
|
607
|
+
/** Write a vector sidecar file for an entry.
|
|
608
|
+
* Format: [u8 version][u32LE dims][f32LE × dims]. Total: 5 + dims*4 bytes. */
|
|
609
|
+
async persistVector(relativePath, vector) {
|
|
610
|
+
const vecPath = this.vecPathFromMdPath(relativePath);
|
|
611
|
+
const buf = Buffer.alloc(5 + vector.length * 4);
|
|
612
|
+
buf.writeUInt8(MarkdownMemoryStore.VEC_FORMAT_VERSION, 0);
|
|
613
|
+
buf.writeUInt32LE(vector.length, 1);
|
|
614
|
+
for (let i = 0; i < vector.length; i++) {
|
|
615
|
+
buf.writeFloatLE(vector[i], 5 + i * 4);
|
|
616
|
+
}
|
|
617
|
+
await fs.mkdir(path.dirname(vecPath), { recursive: true });
|
|
618
|
+
await fs.writeFile(vecPath, buf);
|
|
619
|
+
}
|
|
620
|
+
/** Load and validate a vector sidecar file.
|
|
621
|
+
* Returns null on: missing file, unknown version, wrong dimensions, corrupt size.
|
|
622
|
+
* Null is not an error — it means the entry works via keyword fallback. */
|
|
623
|
+
async loadVector(relativePath) {
|
|
624
|
+
if (!this.embedder)
|
|
625
|
+
return null;
|
|
626
|
+
const vecPath = this.vecPathFromMdPath(relativePath);
|
|
627
|
+
let buf;
|
|
628
|
+
try {
|
|
629
|
+
buf = await fs.readFile(vecPath);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return null; // missing — entry predates embeddings
|
|
633
|
+
}
|
|
634
|
+
// Minimum valid size: 1 (version) + 4 (dims) = 5 bytes
|
|
635
|
+
if (buf.length < 5) {
|
|
636
|
+
process.stderr.write(`[memory-mcp] Corrupt .vec file (too small): ${vecPath}\n`);
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
const version = buf.readUInt8(0);
|
|
640
|
+
if (version !== MarkdownMemoryStore.VEC_FORMAT_VERSION) {
|
|
641
|
+
process.stderr.write(`[memory-mcp] Unknown .vec version ${version}: ${vecPath}\n`);
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
const storedDims = buf.readUInt32LE(1);
|
|
645
|
+
if (storedDims !== this.embedder.dimensions) {
|
|
646
|
+
// Dimension mismatch — model changed. Entry needs re-embed, not an error.
|
|
647
|
+
process.stderr.write(`[memory-mcp] Dimension mismatch in ${vecPath}: stored ${storedDims}, expected ${this.embedder.dimensions}\n`);
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
const expectedSize = 5 + storedDims * 4;
|
|
651
|
+
if (buf.length !== expectedSize) {
|
|
652
|
+
process.stderr.write(`[memory-mcp] Corrupt .vec file (expected ${expectedSize} bytes, got ${buf.length}): ${vecPath}\n`);
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
const raw = new Float32Array(storedDims);
|
|
656
|
+
for (let i = 0; i < storedDims; i++) {
|
|
657
|
+
raw[i] = buf.readFloatLE(5 + i * 4);
|
|
658
|
+
}
|
|
659
|
+
return asEmbeddingVector(raw);
|
|
660
|
+
}
|
|
661
|
+
/** Delete a vector sidecar file. Fire-and-forget — failure is harmless. */
|
|
662
|
+
async deleteVector(mdRelativePath) {
|
|
663
|
+
const vecPath = this.vecPathFromMdPath(mdRelativePath);
|
|
664
|
+
try {
|
|
665
|
+
await fs.unlink(vecPath);
|
|
666
|
+
}
|
|
667
|
+
catch { /* already gone or never existed */ }
|
|
668
|
+
}
|
|
669
|
+
/** Derive the .vec path from a .md relative path */
|
|
670
|
+
vecPathFromMdPath(mdRelativePath) {
|
|
671
|
+
const vecRelative = mdRelativePath.replace(/\.md$/, '.vec');
|
|
672
|
+
return path.join(this.memoryPath, vecRelative);
|
|
673
|
+
}
|
|
674
|
+
/** Load vectors for all known entries. Cleans up orphan .vec files.
|
|
675
|
+
* Skipped entirely when embedder is null (no wasted I/O).
|
|
676
|
+
* Returns a fresh map — no mutation of store state. */
|
|
677
|
+
async loadVectorSnapshot(entries) {
|
|
678
|
+
if (!this.embedder)
|
|
679
|
+
return new Map();
|
|
680
|
+
const vectors = new Map();
|
|
681
|
+
const entryIds = new Set(entries.keys());
|
|
682
|
+
// Load vectors for known entries
|
|
683
|
+
for (const entry of entries.values()) {
|
|
684
|
+
const relativePath = this.entryToRelativePath(entry);
|
|
685
|
+
const vector = await this.loadVector(relativePath);
|
|
686
|
+
if (vector) {
|
|
687
|
+
vectors.set(entry.id, vector);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Clean up orphan .vec files — entries deleted while embedder was unavailable
|
|
691
|
+
try {
|
|
692
|
+
const vecFiles = await this.findVecFiles(this.memoryPath);
|
|
693
|
+
for (const vecFile of vecFiles) {
|
|
694
|
+
const relativePath = path.relative(this.memoryPath, vecFile);
|
|
695
|
+
// Derive the entry ID: the .vec filename without extension matches the .md filename
|
|
696
|
+
const mdRelative = relativePath.replace(/\.vec$/, '.md');
|
|
697
|
+
// Check if any entry maps to this path
|
|
698
|
+
const hasMatchingEntry = Array.from(entries.values()).some(e => this.entryToRelativePath(e) === mdRelative);
|
|
699
|
+
if (!hasMatchingEntry) {
|
|
700
|
+
try {
|
|
701
|
+
await fs.unlink(vecFile);
|
|
702
|
+
process.stderr.write(`[memory-mcp] Cleaned up orphan .vec: ${relativePath}\n`);
|
|
703
|
+
}
|
|
704
|
+
catch { /* ignore cleanup failures */ }
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch { /* ignore — directory may not exist yet */ }
|
|
709
|
+
return vectors;
|
|
710
|
+
}
|
|
711
|
+
/** Recursively find all .vec files in a directory */
|
|
712
|
+
async findVecFiles(dir) {
|
|
713
|
+
const results = [];
|
|
714
|
+
try {
|
|
715
|
+
const dirEntries = await fs.readdir(dir, { withFileTypes: true });
|
|
716
|
+
for (const dirEntry of dirEntries) {
|
|
717
|
+
const fullPath = path.join(dir, dirEntry.name);
|
|
718
|
+
if (dirEntry.isDirectory()) {
|
|
719
|
+
results.push(...await this.findVecFiles(fullPath));
|
|
720
|
+
}
|
|
721
|
+
else if (dirEntry.name.endsWith('.vec')) {
|
|
722
|
+
results.push(fullPath);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
catch { /* ignore */ }
|
|
727
|
+
return results;
|
|
728
|
+
}
|
|
557
729
|
/** Load all entries from disk and return as an immutable snapshot.
|
|
558
730
|
* Pure read — no mutation. Callers decide whether to cache.
|
|
559
731
|
* Tracks corrupt files for observability without failing the load. */
|
|
@@ -576,13 +748,16 @@ export class MarkdownMemoryStore {
|
|
|
576
748
|
catch {
|
|
577
749
|
// Empty memory — first run
|
|
578
750
|
}
|
|
579
|
-
|
|
751
|
+
// Load vectors after entries — needs the entry map for orphan detection
|
|
752
|
+
const vectors = await this.loadVectorSnapshot(entries);
|
|
753
|
+
return { entries, vectors, corruptFileCount };
|
|
580
754
|
}
|
|
581
755
|
/** Reload entries from disk into the store's working state.
|
|
582
756
|
* This is the single mutation point for disk reads. */
|
|
583
757
|
async reloadFromDisk() {
|
|
584
758
|
const snapshot = await this.loadSnapshot();
|
|
585
759
|
this.entries = new Map(snapshot.entries);
|
|
760
|
+
this.vectors = new Map(snapshot.vectors);
|
|
586
761
|
this.corruptFileCount = snapshot.corruptFileCount;
|
|
587
762
|
}
|
|
588
763
|
/** Recursively find all .md files in a directory */
|
|
@@ -785,6 +960,51 @@ export class MarkdownMemoryStore {
|
|
|
785
960
|
trust: r.entry.trust,
|
|
786
961
|
}));
|
|
787
962
|
}
|
|
963
|
+
/** Find semantic duplicates by cosine similarity against stored vectors.
|
|
964
|
+
* Same-topic only. Returns entries above DEDUP_SEMANTIC_THRESHOLD.
|
|
965
|
+
* Returns empty when no embedder or no vectors available. */
|
|
966
|
+
findSemanticDuplicates(excludeId, topic) {
|
|
967
|
+
const newVector = this.vectors.get(excludeId);
|
|
968
|
+
if (!newVector)
|
|
969
|
+
return [];
|
|
970
|
+
const related = [];
|
|
971
|
+
for (const entry of this.entries.values()) {
|
|
972
|
+
if (entry.id === excludeId)
|
|
973
|
+
continue;
|
|
974
|
+
if (entry.topic !== topic)
|
|
975
|
+
continue;
|
|
976
|
+
const entryVector = this.vectors.get(entry.id);
|
|
977
|
+
if (!entryVector)
|
|
978
|
+
continue;
|
|
979
|
+
const sim = cosineSimilarity(newVector, entryVector);
|
|
980
|
+
if (sim > DEDUP_SEMANTIC_THRESHOLD) {
|
|
981
|
+
related.push({ entry, similarity: sim });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return related
|
|
985
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
986
|
+
.slice(0, this.behavior.maxDedupSuggestions)
|
|
987
|
+
.map(r => ({
|
|
988
|
+
id: r.entry.id,
|
|
989
|
+
title: r.entry.title,
|
|
990
|
+
content: r.entry.content,
|
|
991
|
+
confidence: r.entry.confidence,
|
|
992
|
+
trust: r.entry.trust,
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
/** Merge keyword-based and semantic-based dedup results, dedup by ID,
|
|
996
|
+
* keeping the entry with higher similarity score. */
|
|
997
|
+
mergeRelatedEntries(keywordDupes, semanticDupes) {
|
|
998
|
+
const byId = new Map();
|
|
999
|
+
for (const r of keywordDupes)
|
|
1000
|
+
byId.set(r.id, r);
|
|
1001
|
+
for (const r of semanticDupes) {
|
|
1002
|
+
if (!byId.has(r.id))
|
|
1003
|
+
byId.set(r.id, r);
|
|
1004
|
+
// If already present from keyword dedup, keep whichever — both indicate duplication
|
|
1005
|
+
}
|
|
1006
|
+
return Array.from(byId.values()).slice(0, this.behavior.maxDedupSuggestions);
|
|
1007
|
+
}
|
|
788
1008
|
/** Tag frequency across all entries — for vocabulary echo in store responses.
|
|
789
1009
|
* Returns tags sorted by frequency (descending). O(N) over entries. */
|
|
790
1010
|
getTagFrequency() {
|
|
@@ -889,3 +1109,10 @@ export class MarkdownMemoryStore {
|
|
|
889
1109
|
}));
|
|
890
1110
|
}
|
|
891
1111
|
}
|
|
1112
|
+
// ─── Vector sidecar storage ─────────────────────────────────────────────
|
|
1113
|
+
// Sidecar .vec files alongside .md entries. Embeddings are a cache — delete
|
|
1114
|
+
// all .vec files and the system degrades to keyword search. Non-atomic with
|
|
1115
|
+
// .md writes (both failure modes are benign since vectors are derived data).
|
|
1116
|
+
/** .vec file format version — future-proofs for metadata additions.
|
|
1117
|
+
* v1: [u8 version=0x01][u32LE dimensions][f32LE × dimensions] */
|
|
1118
|
+
MarkdownMemoryStore.VEC_FORMAT_VERSION = 0x01;
|
package/dist/text-analyzer.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EmbeddingVector } from './types.js';
|
|
1
2
|
/** Parsed filter group: required, excluded, exact-match, and tag terms */
|
|
2
3
|
export interface FilterGroup {
|
|
3
4
|
readonly must: Set<string>;
|
|
@@ -36,6 +37,12 @@ export declare function jaccardSimilarity(a: Set<string>, b: Set<string>): numbe
|
|
|
36
37
|
/** Containment similarity: |intersection| / min(|a|, |b|)
|
|
37
38
|
* Catches when one entry is a subset of a larger one */
|
|
38
39
|
export declare function containmentSimilarity(a: Set<string>, b: Set<string>): number;
|
|
40
|
+
/** Cosine similarity between two embedding vectors.
|
|
41
|
+
* Returns 0.0–1.0 for normalized vectors (most embedding models normalize output).
|
|
42
|
+
* Returns 0 for zero vectors (not NaN) — no information means no similarity.
|
|
43
|
+
*
|
|
44
|
+
* Pure function. No allocations beyond locals. ~1ms for 200 entries at 384 dims. */
|
|
45
|
+
export declare function cosineSimilarity(a: EmbeddingVector, b: EmbeddingVector): number;
|
|
39
46
|
/** Combined similarity: max(jaccard, containment) with title boost.
|
|
40
47
|
* Title keywords get double weight by being included twice. */
|
|
41
48
|
export declare function similarity(titleA: string, contentA: string, titleB: string, contentB: string): number;
|
package/dist/text-analyzer.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Pure text analysis: stemming, keyword extraction, similarity, filter parsing.
|
|
2
2
|
// Stateless — all functions are pure. No I/O, no side effects.
|
|
3
3
|
//
|
|
4
|
-
// Design: this module is the seam for
|
|
4
|
+
// Design: this module is the seam for search strategies.
|
|
5
5
|
// v1: keyword matching with naive stemming (this file)
|
|
6
|
-
// v2:
|
|
7
|
-
// v3:
|
|
6
|
+
// v2: embedding-based cosine similarity (this file + embedder.ts)
|
|
7
|
+
// v3: graph-enriched retrieval (if proven needed — see graph-library-design.md)
|
|
8
8
|
// Stopwords for keyword extraction — common English words with no semantic value
|
|
9
9
|
const STOPWORDS = new Set([
|
|
10
10
|
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
@@ -139,6 +139,21 @@ export function containmentSimilarity(a, b) {
|
|
|
139
139
|
}
|
|
140
140
|
return intersection / Math.min(a.size, b.size);
|
|
141
141
|
}
|
|
142
|
+
/** Cosine similarity between two embedding vectors.
|
|
143
|
+
* Returns 0.0–1.0 for normalized vectors (most embedding models normalize output).
|
|
144
|
+
* Returns 0 for zero vectors (not NaN) — no information means no similarity.
|
|
145
|
+
*
|
|
146
|
+
* Pure function. No allocations beyond locals. ~1ms for 200 entries at 384 dims. */
|
|
147
|
+
export function cosineSimilarity(a, b) {
|
|
148
|
+
let dot = 0, normA = 0, normB = 0;
|
|
149
|
+
for (let i = 0; i < a.length; i++) {
|
|
150
|
+
dot += a[i] * b[i];
|
|
151
|
+
normA += a[i] * a[i];
|
|
152
|
+
normB += b[i] * b[i];
|
|
153
|
+
}
|
|
154
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
155
|
+
return denom === 0 ? 0 : dot / denom;
|
|
156
|
+
}
|
|
142
157
|
/** Combined similarity: max(jaccard, containment) with title boost.
|
|
143
158
|
* Title keywords get double weight by being included twice. */
|
|
144
159
|
export function similarity(titleA, contentA, titleB, contentB) {
|
package/dist/thresholds.d.ts
CHANGED
|
@@ -14,6 +14,22 @@ export declare const CONFLICT_MIN_CONTENT_CHARS = 50;
|
|
|
14
14
|
/** Opposition keyword pairs for enhanced conflict detection.
|
|
15
15
|
* When entries overlap AND use opposing terms, boost the conflict signal. */
|
|
16
16
|
export declare const OPPOSITION_PAIRS: ReadonlyArray<readonly [string, string]>;
|
|
17
|
+
/** Minimum cosine similarity for semantic search results.
|
|
18
|
+
* Below this, entries are noise — embedding models produce non-zero similarity
|
|
19
|
+
* even for unrelated text.
|
|
20
|
+
*
|
|
21
|
+
* CALIBRATION NOTE: 0.45 is a strict starting point. With nomic-embed-text,
|
|
22
|
+
* unrelated text pairs routinely score 0.2-0.4 because the model produces
|
|
23
|
+
* non-orthogonal embeddings for any English text. Starting strict and loosening
|
|
24
|
+
* with data is safer than starting loose.
|
|
25
|
+
*
|
|
26
|
+
* Use MEMORY_MCP_DEBUG=1 env var to see raw cosine scores for calibration. */
|
|
27
|
+
export declare const SEMANTIC_MIN_SIMILARITY = 0.45;
|
|
28
|
+
/** Minimum cosine similarity for semantic dedup at store time.
|
|
29
|
+
* Higher than SEMANTIC_MIN_SIMILARITY because flagging false duplicates is more
|
|
30
|
+
* disruptive than missing real ones. Two entries must be quite similar to be
|
|
31
|
+
* flagged as potential duplicates. */
|
|
32
|
+
export declare const DEDUP_SEMANTIC_THRESHOLD = 0.8;
|
|
17
33
|
/** Score multiplier when a reference path basename matches the context keywords. */
|
|
18
34
|
export declare const REFERENCE_BOOST_MULTIPLIER = 1.3;
|
|
19
35
|
/** Per-topic scoring boost factors for contextSearch().
|
package/dist/thresholds.js
CHANGED
|
@@ -40,6 +40,22 @@ export const OPPOSITION_PAIRS = [
|
|
|
40
40
|
['throw', 'return'], // exceptions vs Result types
|
|
41
41
|
['imperative', 'declarative'],
|
|
42
42
|
];
|
|
43
|
+
/** Minimum cosine similarity for semantic search results.
|
|
44
|
+
* Below this, entries are noise — embedding models produce non-zero similarity
|
|
45
|
+
* even for unrelated text.
|
|
46
|
+
*
|
|
47
|
+
* CALIBRATION NOTE: 0.45 is a strict starting point. With nomic-embed-text,
|
|
48
|
+
* unrelated text pairs routinely score 0.2-0.4 because the model produces
|
|
49
|
+
* non-orthogonal embeddings for any English text. Starting strict and loosening
|
|
50
|
+
* with data is safer than starting loose.
|
|
51
|
+
*
|
|
52
|
+
* Use MEMORY_MCP_DEBUG=1 env var to see raw cosine scores for calibration. */
|
|
53
|
+
export const SEMANTIC_MIN_SIMILARITY = 0.45;
|
|
54
|
+
/** Minimum cosine similarity for semantic dedup at store time.
|
|
55
|
+
* Higher than SEMANTIC_MIN_SIMILARITY because flagging false duplicates is more
|
|
56
|
+
* disruptive than missing real ones. Two entries must be quite similar to be
|
|
57
|
+
* flagged as potential duplicates. */
|
|
58
|
+
export const DEDUP_SEMANTIC_THRESHOLD = 0.80;
|
|
43
59
|
/** Score multiplier when a reference path basename matches the context keywords. */
|
|
44
60
|
export const REFERENCE_BOOST_MULTIPLIER = 1.30;
|
|
45
61
|
/** Per-topic scoring boost factors for contextSearch().
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Embedder } from './embedder.js';
|
|
1
2
|
/** Trust levels for knowledge sources, ordered by reliability */
|
|
2
3
|
export type TrustLevel = 'user' | 'agent-confirmed' | 'agent-inferred';
|
|
3
4
|
/** Ephemeral detection severity — three distinct levels so consumers can branch exhaustively.
|
|
@@ -12,6 +13,16 @@ export type TopicScope = 'user' | 'preferences' | 'architecture' | 'conventions'
|
|
|
12
13
|
export type Tag = string & {
|
|
13
14
|
readonly __brand: 'Tag';
|
|
14
15
|
};
|
|
16
|
+
/** Embedding vector — branded Float32Array to prevent passing arbitrary float arrays.
|
|
17
|
+
* The brand carries domain intent: this is a valid embedding from a specific model,
|
|
18
|
+
* not an arbitrary numeric buffer. Decoupled from embedder.ts so text-analyzer.ts
|
|
19
|
+
* can import it without depending on the embedding provider. */
|
|
20
|
+
export type EmbeddingVector = Float32Array & {
|
|
21
|
+
readonly __brand: 'EmbeddingVector';
|
|
22
|
+
};
|
|
23
|
+
/** Construct an EmbeddingVector from a Float32Array. Boundary validation only —
|
|
24
|
+
* callers (OllamaEmbedder, FakeEmbedder, vector deserialization) validate dimensions. */
|
|
25
|
+
export declare function asEmbeddingVector(raw: Float32Array): EmbeddingVector;
|
|
15
26
|
/** Parse a raw string into a Tag, returning null for invalid input.
|
|
16
27
|
* Normalizes to lowercase. Rejects empty, too-long, or non-slug strings. */
|
|
17
28
|
export declare function parseTag(raw: string): Tag | null;
|
|
@@ -220,6 +231,7 @@ export interface MemoryConfig {
|
|
|
220
231
|
readonly behavior?: BehaviorConfig;
|
|
221
232
|
readonly clock?: Clock;
|
|
222
233
|
readonly git?: GitService;
|
|
234
|
+
readonly embedder?: Embedder;
|
|
223
235
|
}
|
|
224
236
|
/** Default confidence values by trust level */
|
|
225
237
|
export declare const DEFAULT_CONFIDENCE: Record<TrustLevel, number>;
|
package/dist/types.js
CHANGED
|
@@ -10,6 +10,11 @@ export function parseTrustLevel(raw) {
|
|
|
10
10
|
return TRUST_LEVELS.includes(raw) ? raw : null;
|
|
11
11
|
}
|
|
12
12
|
const FIXED_TOPICS = ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'];
|
|
13
|
+
/** Construct an EmbeddingVector from a Float32Array. Boundary validation only —
|
|
14
|
+
* callers (OllamaEmbedder, FakeEmbedder, vector deserialization) validate dimensions. */
|
|
15
|
+
export function asEmbeddingVector(raw) {
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
13
18
|
const TAG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
14
19
|
const MAX_TAG_LENGTH = 50;
|
|
15
20
|
const MAX_TAGS_PER_ENTRY = 10;
|