@modusensus/dsh-mneme 0.1.6 → 0.2.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/LICENSE +21 -21
- package/README.md +239 -207
- package/cordis.patch.yml +15 -15
- package/lib/api.js +277 -245
- package/lib/client.js +461 -461
- package/lib/commands.js +64 -64
- package/lib/config.js +49 -15
- package/lib/dream/clustering.js +118 -0
- package/lib/dream/decisions.js +128 -121
- package/lib/dream.js +428 -209
- package/lib/embedding.js +97 -97
- package/lib/index.js +176 -120
- package/lib/inject.js +47 -47
- package/lib/local-embedder.js +265 -0
- package/lib/mirror.js +131 -131
- package/lib/reranker.js +203 -0
- package/lib/service.js +268 -174
- package/lib/settings.js +142 -142
- package/lib/store.js +409 -310
- package/lib/summarize.js +171 -171
- package/lib/tools.js +230 -241
- package/lib/vector-index.js +106 -0
- package/package.json +7 -3
- package/src/api.js +277 -245
- package/src/commands.js +64 -64
- package/src/config.js +49 -15
- package/src/dream/clustering.js +118 -0
- package/src/dream/decisions.js +128 -121
- package/src/dream.js +428 -209
- package/src/embedding.js +97 -97
- package/src/index.js +176 -120
- package/src/inject.js +47 -47
- package/src/local-embedder.js +265 -0
- package/src/mirror.js +131 -131
- package/src/reranker.js +203 -0
- package/src/service.js +268 -174
- package/src/settings.js +142 -142
- package/src/store.js +409 -310
- package/src/summarize.js +171 -171
- package/src/tools.js +230 -241
- package/src/vector-index.js +106 -0
package/src/reranker.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Cross-encoder re-ranker for dsh-mneme recall candidates. Uses
|
|
2
|
+
// bge-reranker-base through transformers.js: tries the native `rerank` task
|
|
3
|
+
// first, then the sequence-classification head (sigmoid on the logit delta),
|
|
4
|
+
// and finally feature-extraction over concatenated query+passage (cosine).
|
|
5
|
+
// Every strategy funnels into scorePair(query, passage) so tests can inject a
|
|
6
|
+
// fake scorer and never download a model. Failures throw — the caller degrades
|
|
7
|
+
// back to the original candidate order.
|
|
8
|
+
function hashString(s) {
|
|
9
|
+
let h = 5381;
|
|
10
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
|
11
|
+
return h.toString(16);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Same provider#hash fingerprint convention as the embedders. */
|
|
15
|
+
function modelHash(model) {
|
|
16
|
+
return `${model}#${hashString(model)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Lazy default pipeline factory: dynamic import keeps module load cheap. */
|
|
20
|
+
async function defaultPipelineLoader(task, model, options) {
|
|
21
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
22
|
+
return pipeline(task, model, options);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Flatten a transformers.js Tensor [batch, seq, dim] into number[][] rows. */
|
|
26
|
+
function tensorToRows(tensor) {
|
|
27
|
+
const { data, dims } = tensor;
|
|
28
|
+
const dim = dims[dims.length - 1] || 0;
|
|
29
|
+
const rows = [];
|
|
30
|
+
for (let i = 0; i < data.length; i += dim) rows.push(Array.from(data.subarray(i, i + dim)));
|
|
31
|
+
if (rows.length === 0 && dim > 0) rows.push(Array.from(data));
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cosine(a, b) {
|
|
36
|
+
let dot = 0;
|
|
37
|
+
let na = 0;
|
|
38
|
+
let nb = 0;
|
|
39
|
+
for (let i = 0; i < a.length; i++) {
|
|
40
|
+
dot += a[i] * b[i];
|
|
41
|
+
na += a[i] * a[i];
|
|
42
|
+
nb += b[i] * b[i];
|
|
43
|
+
}
|
|
44
|
+
if (na === 0 || nb === 0) return 0;
|
|
45
|
+
return dot / Math.sqrt(na * nb);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clamp01(x) {
|
|
49
|
+
return Math.max(0, Math.min(1, x));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Pipeline strategies, best to worst. `rerank` is unsupported by the bundled
|
|
53
|
+
// transformers.js (v4.2.0) and rejects cheaply before any model download, so
|
|
54
|
+
// the cascade normally lands on the classification head.
|
|
55
|
+
const STRATEGIES = [
|
|
56
|
+
["rerank", "rerank"],
|
|
57
|
+
["text-classification", "tc"],
|
|
58
|
+
["feature-extraction", "fe"]
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export class LocalReranker {
|
|
62
|
+
constructor(opts = {}) {
|
|
63
|
+
this.model = opts.model || "Xenova/bge-reranker-base";
|
|
64
|
+
this.batchSize = opts.batchSize || 8;
|
|
65
|
+
this.maxCandidates = opts.maxCandidates || 30;
|
|
66
|
+
this.scoreThreshold = opts.scoreThreshold ?? 0.1;
|
|
67
|
+
this.device = opts.device || "cpu";
|
|
68
|
+
this.cacheDir = String(opts.cacheDir ?? "").trim();
|
|
69
|
+
this.logger = opts.logger ?? null;
|
|
70
|
+
this.engineFactory = opts.engineFactory || defaultPipelineLoader;
|
|
71
|
+
// Injectable seam: async (query, passage) => number. When set, init()
|
|
72
|
+
// skips model loading entirely so tests never hit the network.
|
|
73
|
+
this.scorePair = opts.scorePair ?? null;
|
|
74
|
+
this.pipeline = null;
|
|
75
|
+
this._batchScorer = null;
|
|
76
|
+
this._queryVec = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Load the model; throws when no strategy can be bound. */
|
|
80
|
+
async init() {
|
|
81
|
+
if (this.scorePair) return this;
|
|
82
|
+
let lastErr = null;
|
|
83
|
+
for (const [task, strategy] of STRATEGIES) {
|
|
84
|
+
try {
|
|
85
|
+
this.pipeline = await this.engineFactory(task, this.model, this._engineOptions());
|
|
86
|
+
this._bindBatchScorer(strategy);
|
|
87
|
+
this.logger?.info?.(
|
|
88
|
+
`[dsh-mneme] local reranker ready: ${this.model} (strategy=${strategy}, device=${this.device})`
|
|
89
|
+
);
|
|
90
|
+
return this;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
lastErr = err;
|
|
93
|
+
this.logger?.warn?.(
|
|
94
|
+
`[dsh-mneme] reranker task "${task}" unavailable for ${this.model}: ${String(err?.message ?? err)}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`LocalReranker failed to load ${this.model}: ${String(lastErr?.message ?? lastErr)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_engineOptions() {
|
|
102
|
+
const options = { device: this.device };
|
|
103
|
+
if (this.cacheDir) options.cache_dir = this.cacheDir;
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Bind a batch scorer for the chosen strategy. */
|
|
108
|
+
_bindBatchScorer(strategy) {
|
|
109
|
+
if (strategy === "rerank") {
|
|
110
|
+
// Native cross-encoder task (transformers.js >= 4.6): { query, documents }
|
|
111
|
+
// returns one score per document.
|
|
112
|
+
this._batchScorer = async (query, passages) => {
|
|
113
|
+
const out = await this.pipeline({ query, documents: passages });
|
|
114
|
+
if (!Array.isArray(out)) throw new Error("rerank pipeline returned unexpected output");
|
|
115
|
+
return out.map((r) => clamp01(typeof r?.score === "number" ? r.score : 0));
|
|
116
|
+
};
|
|
117
|
+
} else if (strategy === "tc") {
|
|
118
|
+
// Classification head: tokenize (query, passage) pairs, score with
|
|
119
|
+
// sigmoid(l1 - l0) so relevance lands in [0, 1].
|
|
120
|
+
this._batchScorer = async (query, passages) => {
|
|
121
|
+
const { tokenizer, model } = this.pipeline;
|
|
122
|
+
const inputs = tokenizer(passages.map(() => query), {
|
|
123
|
+
text_pair: passages,
|
|
124
|
+
padding: true,
|
|
125
|
+
truncation: true
|
|
126
|
+
});
|
|
127
|
+
const logits = await model(inputs).then((o) => o.logits);
|
|
128
|
+
const dims = logits.dims;
|
|
129
|
+
const cols = dims[dims.length - 1] || 2;
|
|
130
|
+
const rows = [];
|
|
131
|
+
for (let i = 0; i < dims[0]; i++) {
|
|
132
|
+
const base = i * cols;
|
|
133
|
+
const l0 = logits.data[base];
|
|
134
|
+
const l1 = cols > 1 ? logits.data[base + 1] : l0;
|
|
135
|
+
// sigmoid(l1 - l0) == softmax probability of the positive class.
|
|
136
|
+
rows.push(clamp01(1 / (1 + Math.exp(l0 - l1))));
|
|
137
|
+
}
|
|
138
|
+
return rows;
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
// Feature extraction: mean-pool the concatenated pair and compare with
|
|
142
|
+
// the query embedding via cosine. Degraded but model-agnostic.
|
|
143
|
+
this._batchScorer = async (query, passages) => {
|
|
144
|
+
if (!this._queryVec) {
|
|
145
|
+
const t = await this.pipeline([query], { pooling: "mean", normalize: true });
|
|
146
|
+
this._queryVec = tensorToRows(t)[0];
|
|
147
|
+
}
|
|
148
|
+
const t = await this.pipeline(passages.map((p) => `${query}\n${p}`), {
|
|
149
|
+
pooling: "mean",
|
|
150
|
+
normalize: true
|
|
151
|
+
});
|
|
152
|
+
return tensorToRows(t).map((v) => clamp01(cosine(this._queryVec, v)));
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Score one batch of passages; injected scorePair scores pair by pair. */
|
|
158
|
+
async _scores(query, passages) {
|
|
159
|
+
if (this._batchScorer) return this._batchScorer(query, passages);
|
|
160
|
+
return Promise.all(passages.map((p) => (p ? this.scorePair(query, p) : 0)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Re-rank recall candidates. Returns [{ id, score }] filtered by
|
|
165
|
+
* scoreThreshold and sorted by descending score. Throws on engine failure —
|
|
166
|
+
* the caller degrades to the original candidate order.
|
|
167
|
+
*/
|
|
168
|
+
async rerank(query, candidates) {
|
|
169
|
+
if (!Array.isArray(candidates)) throw new TypeError("rerank expects an array of candidates");
|
|
170
|
+
const list = candidates.slice(0, this.maxCandidates);
|
|
171
|
+
if (!list.length) return [];
|
|
172
|
+
const q = String(query ?? "");
|
|
173
|
+
const results = [];
|
|
174
|
+
for (let i = 0; i < list.length; i += this.batchSize) {
|
|
175
|
+
const chunk = list.slice(i, i + this.batchSize);
|
|
176
|
+
const passages = chunk.map((c) => [c.title, c.content].filter(Boolean).join("\n"));
|
|
177
|
+
const scores = await this._scores(q, passages);
|
|
178
|
+
if (!Array.isArray(scores) || scores.length !== chunk.length) {
|
|
179
|
+
throw new Error("reranker scorer returned mismatched scores");
|
|
180
|
+
}
|
|
181
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
182
|
+
results.push({ id: chunk[j].id, score: clamp01(Number(scores[j]) || 0) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return results
|
|
186
|
+
.filter((r) => r.score >= this.scoreThreshold)
|
|
187
|
+
.sort((a, b) => b.score - a.score);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
get modelHash() {
|
|
191
|
+
return modelHash(this.model);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
dispose() {
|
|
195
|
+
try {
|
|
196
|
+
this.pipeline?.dispose?.();
|
|
197
|
+
} catch {
|
|
198
|
+
// best-effort: some engines free resources on GC
|
|
199
|
+
}
|
|
200
|
+
this.pipeline = null;
|
|
201
|
+
this._queryVec = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/service.js
CHANGED
|
@@ -1,174 +1,268 @@
|
|
|
1
|
-
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
2
|
-
|
|
3
|
-
export function createService({ store, mirror, config, onWrite }) {
|
|
4
|
-
// Optional dream scheduler hook, installed via setDreamHook after creation
|
|
5
|
-
// (the scheduler holds a reference back to the service, so it cannot be
|
|
6
|
-
// passed in the constructor). Fired on the same write events as onWrite.
|
|
7
|
-
let dreamHook = null;
|
|
8
|
-
|
|
9
|
-
// Optional vector embedder, installed via setEmbedder after creation. After
|
|
10
|
-
// any content write it fire-and-forgets a re-embed of the row so vector
|
|
11
|
-
// search stays in sync; failures are swallowed inside the embedder.
|
|
12
|
-
let embedder = null;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1
|
+
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
2
|
+
|
|
3
|
+
export function createService({ store, mirror, config, onWrite }) {
|
|
4
|
+
// Optional dream scheduler hook, installed via setDreamHook after creation
|
|
5
|
+
// (the scheduler holds a reference back to the service, so it cannot be
|
|
6
|
+
// passed in the constructor). Fired on the same write events as onWrite.
|
|
7
|
+
let dreamHook = null;
|
|
8
|
+
|
|
9
|
+
// Optional vector embedder, installed via setEmbedder after creation. After
|
|
10
|
+
// any content write it fire-and-forgets a re-embed of the row so vector
|
|
11
|
+
// search stays in sync; failures are swallowed inside the embedder.
|
|
12
|
+
let embedder = null;
|
|
13
|
+
let vectorIndex = null;
|
|
14
|
+
let reranker = null;
|
|
15
|
+
|
|
16
|
+
function scheduleEmbed(memory) {
|
|
17
|
+
if (embedder && memory?.id) {
|
|
18
|
+
try { embedder.schedule(memory); } catch { /* ignore */ }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cross-encoder rerank over a candidate list (best effort). Reranker
|
|
24
|
+
* failures degrade to the original candidate order — reranking is an
|
|
25
|
+
* accuracy upgrade, never a correctness gate.
|
|
26
|
+
*/
|
|
27
|
+
async function rerankCandidates(query, candidates, topK) {
|
|
28
|
+
if (!reranker || !candidates.length) return candidates.slice(0, topK);
|
|
29
|
+
try {
|
|
30
|
+
const scored = await reranker.rerank(query, candidates.map((c) => ({ id: c.id, title: c.title, content: c.content })));
|
|
31
|
+
if (!Array.isArray(scored)) return candidates.slice(0, topK);
|
|
32
|
+
const byId = new Map(candidates.map((c) => [c.id, c]));
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const s of scored) {
|
|
35
|
+
const c = byId.get(s.id);
|
|
36
|
+
if (c) { out.push({ ...c, score: s.score }); if (out.length >= topK) break; }
|
|
37
|
+
}
|
|
38
|
+
return out.length ? out : candidates.slice(0, topK);
|
|
39
|
+
} catch {
|
|
40
|
+
return candidates.slice(0, topK);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Semantic-aware memory search: keyword recall (store.search) plus optional
|
|
46
|
+
* vector recall + rerank. mode:
|
|
47
|
+
* auto (default) keyword first, vector fills remaining slots (legacy)
|
|
48
|
+
* hybrid vector first, keyword fills remaining slots
|
|
49
|
+
* vector vector only, falls back to keyword when unavailable
|
|
50
|
+
* keyword text only, never touches the embedder
|
|
51
|
+
* useRerank runs the cross-encoder over the merged list when a reranker is
|
|
52
|
+
* installed; results carry an extra `score` when reranked.
|
|
53
|
+
*/
|
|
54
|
+
async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true } = {}) {
|
|
55
|
+
const q = String(query ?? "").trim();
|
|
56
|
+
if (!q) return [];
|
|
57
|
+
const lim = topK > 0 ? topK : 20;
|
|
58
|
+
|
|
59
|
+
const keyword = store.search(q, { limit: lim });
|
|
60
|
+
const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
|
|
61
|
+
let vector = [];
|
|
62
|
+
if (wantVector && embedder) {
|
|
63
|
+
try {
|
|
64
|
+
// Legacy embedders expose embed(query); local ones expose embedSingle.
|
|
65
|
+
const embedSingle = typeof embedder.embedSingle === "function"
|
|
66
|
+
? embedder.embedSingle.bind(embedder)
|
|
67
|
+
: embedder.embed.bind(embedder);
|
|
68
|
+
const qv = await embedSingle(q);
|
|
69
|
+
if (qv?.length) {
|
|
70
|
+
vector = vectorIndex
|
|
71
|
+
? vectorIndex.search(qv, { limit: lim * 2, threshold: threshold ?? 0 })
|
|
72
|
+
: store.searchVector(qv, { limit: lim * 2, threshold: threshold ?? 0 });
|
|
73
|
+
}
|
|
74
|
+
} catch { /* vector unavailable: keep keyword results */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let merged;
|
|
78
|
+
if (mode === "keyword") {
|
|
79
|
+
merged = keyword;
|
|
80
|
+
} else if (mode === "vector" || mode === "hybrid") {
|
|
81
|
+
// semantic-first: vector recalls lead, keyword fills remaining slots
|
|
82
|
+
merged = vector.length ? vector.slice(0, lim) : keyword;
|
|
83
|
+
const seen = new Set(merged.map((m) => m.id));
|
|
84
|
+
for (const m of keyword) {
|
|
85
|
+
if (merged.length >= lim) break;
|
|
86
|
+
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// auto: keyword leads, vector fills remaining slots (legacy behavior)
|
|
90
|
+
merged = keyword.slice(0, lim);
|
|
91
|
+
const seen = new Set(merged.map((m) => m.id));
|
|
92
|
+
for (const m of vector) {
|
|
93
|
+
if (merged.length >= lim) break;
|
|
94
|
+
if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
merged = merged.slice(0, lim);
|
|
99
|
+
if (useRerank && reranker && merged.length) {
|
|
100
|
+
return rerankCandidates(q, merged, lim);
|
|
101
|
+
}
|
|
102
|
+
return merged;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fire-and-forget write notification; errors are swallowed to keep write
|
|
107
|
+
* paths clean. The store mutation has already committed, so a throwing
|
|
108
|
+
* subscriber must not surface as a write failure. Archive/forget flags are
|
|
109
|
+
* state toggles, not content writes, so they never notify.
|
|
110
|
+
*/
|
|
111
|
+
function notifyWrite() {
|
|
112
|
+
if (onWrite) {
|
|
113
|
+
try { onWrite(); } catch { /* ignore */ }
|
|
114
|
+
}
|
|
115
|
+
if (dreamHook) {
|
|
116
|
+
try { dreamHook(); } catch { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Save a memory, merging into an existing one when title matches within the same type.
|
|
122
|
+
* @returns {{action: "created"|"merged", memory: object}}
|
|
123
|
+
*/
|
|
124
|
+
function saveWithDedupe(memory) {
|
|
125
|
+
const existing = store
|
|
126
|
+
.list({ type: memory.type, limit: 100 })
|
|
127
|
+
.find((m) => m.title.trim() === String(memory.title).trim());
|
|
128
|
+
if (existing) {
|
|
129
|
+
const merged = store.update(existing.id, {
|
|
130
|
+
content: memory.content ?? existing.content,
|
|
131
|
+
importance: memory.importance ?? existing.importance,
|
|
132
|
+
tags: memory.tags ?? existing.tags,
|
|
133
|
+
title: memory.title ?? existing.title
|
|
134
|
+
});
|
|
135
|
+
syncMirror();
|
|
136
|
+
notifyWrite();
|
|
137
|
+
scheduleEmbed(merged);
|
|
138
|
+
return { action: "merged", memory: merged };
|
|
139
|
+
}
|
|
140
|
+
const created = store.save({
|
|
141
|
+
type: memory.type,
|
|
142
|
+
title: memory.title,
|
|
143
|
+
content: memory.content,
|
|
144
|
+
tags: memory.tags ?? [],
|
|
145
|
+
importance: memory.importance ?? 3,
|
|
146
|
+
source: memory.source ?? "manual"
|
|
147
|
+
});
|
|
148
|
+
syncMirror();
|
|
149
|
+
notifyWrite();
|
|
150
|
+
scheduleEmbed(created);
|
|
151
|
+
return { action: "created", memory: created };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Candidate memories for automatic context injection:
|
|
156
|
+
* summaries first, then all preferences, then non-forgotten items with
|
|
157
|
+
* importance >= threshold. History is never auto-injected. Archived entries
|
|
158
|
+
* are excluded (store.list already filters them by default; the extra
|
|
159
|
+
* !m.archived check is kept as double insurance).
|
|
160
|
+
*/
|
|
161
|
+
function injectCandidates({ maxItems = 5, threshold = 3 } = {}) {
|
|
162
|
+
const items = store.list({ limit: 200, includeForgotten: false })
|
|
163
|
+
.filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
|
|
164
|
+
(m.type === "summary" || m.type === "preference" || m.importance >= threshold))
|
|
165
|
+
.sort((a, b) => {
|
|
166
|
+
const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
|
|
167
|
+
const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
|
|
168
|
+
return pa - pb || b.importance - a.importance;
|
|
169
|
+
});
|
|
170
|
+
return items.slice(0, maxItems);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Merge human edits parsed from a mirror file back into the store.
|
|
175
|
+
* Only content/title are taken; structure fields stay machine-owned.
|
|
176
|
+
*/
|
|
177
|
+
function mergeHumanEdits(type, edits) {
|
|
178
|
+
let applied = 0;
|
|
179
|
+
for (const edit of edits) {
|
|
180
|
+
if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
|
|
181
|
+
const existing = store.getById(edit.id);
|
|
182
|
+
if (!existing || existing.type !== type) continue;
|
|
183
|
+
const patch = {};
|
|
184
|
+
if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
|
|
185
|
+
if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
|
|
186
|
+
if (Object.keys(patch).length) {
|
|
187
|
+
store.update(edit.id, patch);
|
|
188
|
+
applied++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (applied) {
|
|
192
|
+
syncMirror();
|
|
193
|
+
notifyWrite();
|
|
194
|
+
}
|
|
195
|
+
return applied;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function toApiList(rows) {
|
|
199
|
+
return rows.map((m) => ({
|
|
200
|
+
id: m.id,
|
|
201
|
+
type: m.type,
|
|
202
|
+
title: m.title,
|
|
203
|
+
content: m.content,
|
|
204
|
+
tags: m.tags,
|
|
205
|
+
importance: m.importance,
|
|
206
|
+
source: m.source,
|
|
207
|
+
created_at: m.created_at,
|
|
208
|
+
updated_at: m.updated_at
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Re-render the human-editable mirror after any store mutation. Only
|
|
214
|
+
* non-forgotten memories are mirrored: forgotten entries must not reach the
|
|
215
|
+
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
216
|
+
*/
|
|
217
|
+
function syncMirror() {
|
|
218
|
+
if (mirror) mirror.sync(store.list({ limit: 500, includeForgotten: false }));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
saveWithDedupe,
|
|
223
|
+
injectCandidates,
|
|
224
|
+
mergeHumanEdits,
|
|
225
|
+
toApiList,
|
|
226
|
+
setDreamHook(fn) { dreamHook = fn; },
|
|
227
|
+
setEmbedder(emb) { embedder = emb; },
|
|
228
|
+
setVectorIndex(vi) { vectorIndex = vi; },
|
|
229
|
+
setReranker(rn) { reranker = rn; },
|
|
230
|
+
searchMemories,
|
|
231
|
+
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
232
|
+
search: (q, o) => store.search(q, o),
|
|
233
|
+
searchVector: (v, o) => store.searchVector(v, o),
|
|
234
|
+
embeddedCount: () => store.embeddedCount(),
|
|
235
|
+
list: (o) => store.list(o),
|
|
236
|
+
all: () => store.all(),
|
|
237
|
+
count: (type) => store.count(type),
|
|
238
|
+
getById: (id) => store.getById(id),
|
|
239
|
+
remove: (id) => {
|
|
240
|
+
store.remove(id);
|
|
241
|
+
syncMirror();
|
|
242
|
+
notifyWrite();
|
|
243
|
+
},
|
|
244
|
+
update: (id, p) => {
|
|
245
|
+
const updated = store.update(id, p);
|
|
246
|
+
syncMirror();
|
|
247
|
+
notifyWrite();
|
|
248
|
+
scheduleEmbed(updated);
|
|
249
|
+
return updated;
|
|
250
|
+
},
|
|
251
|
+
setForget: (id, f) => {
|
|
252
|
+
const updated = store.setForget(id, f);
|
|
253
|
+
syncMirror();
|
|
254
|
+
return updated;
|
|
255
|
+
},
|
|
256
|
+
setArchived: (id, f) => {
|
|
257
|
+
const updated = store.setArchived(id, f);
|
|
258
|
+
syncMirror();
|
|
259
|
+
return updated;
|
|
260
|
+
},
|
|
261
|
+
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|
|
262
|
+
// an audit write is bookkeeping, and notifyWrite would loop back into the
|
|
263
|
+
// dream scheduler that just recorded the run.
|
|
264
|
+
saveDreamRun: (run) => store.saveDreamRun(run),
|
|
265
|
+
getDreamRun: (id) => store.getDreamRun(id),
|
|
266
|
+
listDreamRuns: (opts) => store.listDreamRuns(opts)
|
|
267
|
+
};
|
|
268
|
+
}
|