@modusensus/dsh-mneme 0.2.7 → 0.2.9
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/README.md +3 -3
- package/lib/config.js +12 -1
- package/lib/dream/decisions.js +3 -3
- package/lib/dream.js +149 -6
- package/lib/index.js +17 -0
- package/lib/service.js +53 -9
- package/lib/store.js +332 -6
- package/package.json +1 -1
- package/src/config.js +12 -1
- package/src/dream/decisions.js +3 -3
- package/src/dream.js +149 -6
- package/src/index.js +17 -0
- package/src/service.js +53 -9
- package/src/store.js +332 -6
- package/test/audit.test.js +92 -0
- package/test/conflict-freeze.test.js +290 -0
- package/test/dream.test.js +126 -0
- package/test/policy-epoch.test.js +259 -0
- package/test/recall-layer.test.js +314 -0
- package/test/receipt-chain.test.js +451 -0
- package/test/store.test.js +67 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/modusensus/dsh-mneme)
|
|
9
9
|
|
|
10
10
|
> 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
|
|
11
11
|
|
|
@@ -225,7 +225,7 @@ src/
|
|
|
225
225
|
lib/
|
|
226
226
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
227
227
|
└── *.js # src 的同步分发产物
|
|
228
|
-
test/ #
|
|
228
|
+
test/ # 355 个 node:test 测试(含审计与三轴线压测不变量)
|
|
229
229
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
|
|
230
230
|
```
|
|
231
231
|
|
|
@@ -234,7 +234,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
234
234
|
```bash
|
|
235
235
|
cd dsh-mneme
|
|
236
236
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
237
|
-
npm test # 运行
|
|
237
|
+
npm test # 运行 355 个测试
|
|
238
238
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
239
239
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
240
240
|
```
|
package/lib/config.js
CHANGED
|
@@ -13,6 +13,10 @@ export const Config = z.object({
|
|
|
13
13
|
dreamProvider: z.string(),
|
|
14
14
|
dreamModel: z.string(),
|
|
15
15
|
dreamMaxTokens: z.natural().min(256).max(32768).default(4096),
|
|
16
|
+
// Rule version for dream adjudication: when this bumps, older dream_runs
|
|
17
|
+
// degrade to historical evidence (their receipts no longer drive live
|
|
18
|
+
// decisions). Default 0 = no versioning in use yet.
|
|
19
|
+
policyEpoch: z.natural().min(0).max(1000000).default(0),
|
|
16
20
|
|
|
17
21
|
// --- API protection ------------------------------------------------------
|
|
18
22
|
// Optional shared token for the plugin's HTTP API. Empty (default) keeps
|
|
@@ -63,5 +67,12 @@ export const Config = z.object({
|
|
|
63
67
|
reflectionUpdateEnabled: z.boolean().default(true),
|
|
64
68
|
reflectionFailureTracking: z.boolean().default(true),
|
|
65
69
|
reflectionUpdateMaxPerRun: z.natural().min(0).max(5).default(2),
|
|
66
|
-
reflectionUpdateMinAgeHours: z.natural().min(0).max(168).default(24)
|
|
70
|
+
reflectionUpdateMinAgeHours: z.natural().min(0).max(168).default(24),
|
|
71
|
+
|
|
72
|
+
// --- conflict freeze: manual review for conflicting memories (v0.2.1) ---
|
|
73
|
+
// Opt-in by default: when true, conflicting memories are not auto-merged
|
|
74
|
+
// and are marked as pending manual review instead.
|
|
75
|
+
conflictFreezeEnabled: z.boolean().default(false),
|
|
76
|
+
// Maximum number of frozen conflicts to keep pending for manual review.
|
|
77
|
+
conflictFreezeMaxPending: z.natural().min(1).max(1000).default(100),
|
|
67
78
|
});
|
package/lib/dream/decisions.js
CHANGED
|
@@ -244,7 +244,7 @@ function applyMerge(d, service, snapshot) {
|
|
|
244
244
|
});
|
|
245
245
|
return {
|
|
246
246
|
applied: 1,
|
|
247
|
-
committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance }
|
|
247
|
+
committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance, count_before: d.ids.length, count_after: 1 }
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
250
|
|
|
@@ -266,7 +266,7 @@ function applyConflict(d, service, snapshot) {
|
|
|
266
266
|
});
|
|
267
267
|
service.setArchived(d.loser, true);
|
|
268
268
|
});
|
|
269
|
-
return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser } };
|
|
269
|
+
return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser, count_before: 2, count_after: 1 } };
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
function applyUpdate(d, service, snapshot) {
|
|
@@ -288,5 +288,5 @@ function applyUpdate(d, service, snapshot) {
|
|
|
288
288
|
importance: d.importance ?? cur.importance
|
|
289
289
|
});
|
|
290
290
|
});
|
|
291
|
-
return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance } };
|
|
291
|
+
return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance, count_before: 1, count_after: 1 } };
|
|
292
292
|
}
|
package/lib/dream.js
CHANGED
|
@@ -100,6 +100,78 @@ export function buildOutcome(decisions) {
|
|
|
100
100
|
return { byId };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Content-addressed digest of the memories a verdict was decided against
|
|
105
|
+
* (id + title + content + importance), sorted by id so identical inputs always
|
|
106
|
+
* hash the same. This is the per-record "判定依据" fingerprint: a receipt whose
|
|
107
|
+
* digest cannot be reproduced from the involved memories is a bare claim, and a
|
|
108
|
+
* digest match with a divergent outcome pinpoints drift to the exact record.
|
|
109
|
+
*/
|
|
110
|
+
export function hashDecisionInput(memories) {
|
|
111
|
+
const canon = (memories ?? [])
|
|
112
|
+
.map((m) => [m.id, m.title, m.content, m.importance])
|
|
113
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
114
|
+
.map((p) => p.map((x) => String(x ?? "")).join(""))
|
|
115
|
+
.join("");
|
|
116
|
+
return createHash("sha256").update(canon).digest("hex");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the per-record receipts for a run's actually-committed mutable verdicts
|
|
121
|
+
* (merge/conflict/update) — one row per verdict in the receipt_chain. Inputs
|
|
122
|
+
* are drawn from the run snapshot (what the LLM actually arbitrated against),
|
|
123
|
+
* and the idempotency counters count_before → count_after come from the
|
|
124
|
+
* committed sub-step, so replaying the same decision must reproduce the same
|
|
125
|
+
* numbers. verdict starts "live"; a later policy_epoch upgrade will batch-mark
|
|
126
|
+
* older verdicts "historical" (a receipt_chain rewrite driven by the store's
|
|
127
|
+
* getLatestPolicyEpoch — out of scope for this pass), while "revoked" is
|
|
128
|
+
* reserved for verdicts later overturned by an explicit human decision.
|
|
129
|
+
*/
|
|
130
|
+
function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) {
|
|
131
|
+
const at = (id) => snapshot?.get?.(id);
|
|
132
|
+
const receipts = [];
|
|
133
|
+
for (const c of committed ?? []) {
|
|
134
|
+
const base = {
|
|
135
|
+
run_id: runId,
|
|
136
|
+
verdict: "live",
|
|
137
|
+
count_before: c.count_before,
|
|
138
|
+
count_after: c.count_after,
|
|
139
|
+
policy_epoch: policyEpoch,
|
|
140
|
+
created_at: new Date().toISOString()
|
|
141
|
+
};
|
|
142
|
+
if (c.action === "merge") {
|
|
143
|
+
receipts.push({
|
|
144
|
+
...base,
|
|
145
|
+
receipt_id: randomUUID(),
|
|
146
|
+
record_id: c.keepSource,
|
|
147
|
+
kind: "merge",
|
|
148
|
+
input_digest: hashDecisionInput((c.ids ?? []).map(at).filter(Boolean)),
|
|
149
|
+
keep_source: c.keepSource,
|
|
150
|
+
sources: c.ids
|
|
151
|
+
});
|
|
152
|
+
} else if (c.action === "conflict") {
|
|
153
|
+
receipts.push({
|
|
154
|
+
...base,
|
|
155
|
+
receipt_id: randomUUID(),
|
|
156
|
+
record_id: c.winner,
|
|
157
|
+
kind: "conflict",
|
|
158
|
+
input_digest: hashDecisionInput([at(c.winner), at(c.loser)].filter(Boolean)),
|
|
159
|
+
winner_id: c.winner,
|
|
160
|
+
loser_id: c.loser
|
|
161
|
+
});
|
|
162
|
+
} else if (c.action === "update") {
|
|
163
|
+
receipts.push({
|
|
164
|
+
...base,
|
|
165
|
+
receipt_id: randomUUID(),
|
|
166
|
+
record_id: c.ids[0],
|
|
167
|
+
kind: "update",
|
|
168
|
+
input_digest: hashDecisionInput([at(c.ids[0])].filter(Boolean))
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return receipts;
|
|
173
|
+
}
|
|
174
|
+
|
|
103
175
|
/**
|
|
104
176
|
* Consume an LLM stream and return the accumulated text. Direct text-delta
|
|
105
177
|
* accumulation covers both the real protocol ({type:"text-delta", index, text})
|
|
@@ -278,6 +350,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
278
350
|
const route = resolveRoute(ctx, config, logger);
|
|
279
351
|
const runId = randomUUID();
|
|
280
352
|
const snapshotHash = hashSnapshot([...snapshot.values()]);
|
|
353
|
+
// Conflict freeze (opt-in): when enabled, conflict decisions are parked for
|
|
354
|
+
// manual review instead of auto-adjudicated. Read once up front so the
|
|
355
|
+
// prompt hint and the apply-split agree on the same gate.
|
|
356
|
+
const freezeEnabled = config.conflictFreezeEnabled === true;
|
|
281
357
|
// Every exit (success or failure) funnels through `finish`, which writes
|
|
282
358
|
// the audit row + receipt. A record failure is logged, never thrown —
|
|
283
359
|
// auditing must not break the consolidation path. Failed runs still
|
|
@@ -304,6 +380,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
304
380
|
model: route?.model,
|
|
305
381
|
snapshot_hash: snapshotHash,
|
|
306
382
|
input_count: snapshot.size,
|
|
383
|
+
// 裁决规则版本号:config.policyEpoch(默认 0)。规则升级后该行保留
|
|
384
|
+
// 当时的 epoch,旧裁决据此降级为历史证据(store 层 getLatestPolicyEpoch
|
|
385
|
+
// 只负责读取当前生效版本,写入由这里完成)。
|
|
386
|
+
policy_epoch: config.policyEpoch ?? 0,
|
|
307
387
|
// Full input snapshot (canonical fields) so the exact arbitration
|
|
308
388
|
// input can be rebuilt offline from the audit row alone — the
|
|
309
389
|
// digest + decisions + outcome triple makes silent errors locatable
|
|
@@ -364,6 +444,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
364
444
|
).join("\n");
|
|
365
445
|
}
|
|
366
446
|
|
|
447
|
+
// Freeze-aware prompt: in freeze mode the conflict branch still outputs
|
|
448
|
+
// winner/loser (validation requires them) but they are treated as tentative
|
|
449
|
+
// candidates — the human makes the final call, not the model.
|
|
450
|
+
const consolidationPrompt = freezeEnabled
|
|
451
|
+
? CONSOLIDATION_PROMPT + `\n\n当前为「冲突冻结」模式:检测到内容矛盾的条目时,仍请输出 conflict,并以 winner/loser 作为候选、reason 说明理由;冲突不会被自动裁决,而会冻结待人工确认。`
|
|
452
|
+
: CONSOLIDATION_PROMPT;
|
|
367
453
|
let decisionText;
|
|
368
454
|
try {
|
|
369
455
|
decisionText = await streamText(ctx, {
|
|
@@ -372,7 +458,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
372
458
|
purpose: "compaction",
|
|
373
459
|
maxTokens: config.dreamMaxTokens ?? 4096,
|
|
374
460
|
messages: [
|
|
375
|
-
{ role: "system", content: [{ type: "text", text:
|
|
461
|
+
{ role: "system", content: [{ type: "text", text: consolidationPrompt }] },
|
|
376
462
|
{ role: "user", content: [{ type: "text", text: listText }] }
|
|
377
463
|
]
|
|
378
464
|
});
|
|
@@ -416,10 +502,59 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
416
502
|
}
|
|
417
503
|
}
|
|
418
504
|
|
|
505
|
+
// Conflict freeze (opt-in): when enabled, conflict decisions are not
|
|
506
|
+
// auto-adjudicated — no winner kept, no loser archived. The pair is parked
|
|
507
|
+
// in conflict_pending for human review instead. Best-effort: a store
|
|
508
|
+
// failure here must never block the run (fail-safe — the memories are left
|
|
509
|
+
// untouched and nothing is arbitrated). The cap (conflictFreezeMaxPending)
|
|
510
|
+
// bounds the review queue; overflow is skipped with a warning.
|
|
511
|
+
let frozenCount = 0;
|
|
512
|
+
const frozenIds = [];
|
|
513
|
+
const applyList = freezeEnabled ? decisions.filter((d) => d.action !== "conflict") : decisions;
|
|
514
|
+
if (freezeEnabled) {
|
|
515
|
+
const conflictsToFreeze = decisions.filter((d) => d.action === "conflict");
|
|
516
|
+
if (conflictsToFreeze.length > 0) {
|
|
517
|
+
try {
|
|
518
|
+
const maxPending = Number.isInteger(config.conflictFreezeMaxPending) ? config.conflictFreezeMaxPending : 100;
|
|
519
|
+
const pendingNow = service.countConflictPending();
|
|
520
|
+
const budget = Math.max(0, maxPending - pendingNow);
|
|
521
|
+
const toFreeze = conflictsToFreeze.slice(0, budget);
|
|
522
|
+
if (conflictsToFreeze.length > budget) {
|
|
523
|
+
logger?.warn?.(`dsh-mneme dream: conflict freeze queue full (${pendingNow}/${maxPending}), skipped ${conflictsToFreeze.length - budget} conflict(s)`);
|
|
524
|
+
}
|
|
525
|
+
for (const d of toFreeze) {
|
|
526
|
+
try {
|
|
527
|
+
service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
|
|
528
|
+
frozenCount++;
|
|
529
|
+
frozenIds.push(d.winner, d.loser);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
logger?.warn?.(`dsh-mneme dream: failed to freeze conflict ${d.winner}/${d.loser}: ${String(error)}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
} catch (error) {
|
|
535
|
+
logger?.warn?.(`dsh-mneme dream: conflict freeze lookup failed: ${String(error)}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
419
540
|
// CAS-guarded, per-decision-transactional apply against the run snapshot:
|
|
420
541
|
// a target changed during the LLM call is skipped and reported as a
|
|
421
|
-
// conflict instead of being overwritten (item ①).
|
|
422
|
-
|
|
542
|
+
// conflict instead of being overwritten (item ①). Frozen conflicts are
|
|
543
|
+
// excluded from this list (they are parked, not applied).
|
|
544
|
+
const { applied, conflicts, failures, committed } = applyDecisions(applyList, service, logger, snapshot);
|
|
545
|
+
// Per-record receipt chain: one row per actually-committed merge/conflict/
|
|
546
|
+
// update verdict, stamped with the decision-basis digest + idempotency
|
|
547
|
+
// counters (count_before → count_after). Written here, before the run audit
|
|
548
|
+
// row, so the verdict trail always precedes the run trail it belongs to.
|
|
549
|
+
// Bookkeeping: a write failure is logged and swallowed — it must never
|
|
550
|
+
// block the consolidation flow.
|
|
551
|
+
try {
|
|
552
|
+
for (const r of buildRecordReceipts({ runId, committed, snapshot, policyEpoch: config.policyEpoch ?? 0 })) {
|
|
553
|
+
service.saveReceipt(r);
|
|
554
|
+
}
|
|
555
|
+
} catch (error) {
|
|
556
|
+
logger?.warn?.(`dsh-mneme dream: failed to write per-record receipt: ${String(error)}`);
|
|
557
|
+
}
|
|
423
558
|
// Attach the pre-update snapshot to the audit copy of each update decision
|
|
424
559
|
// so the recorded row shows the before/after delta, not just the target.
|
|
425
560
|
const auditDecisions = decisions.map((d) =>
|
|
@@ -432,18 +567,25 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
432
567
|
// claim "merge-archived" (item ②). Conflicts/failures ride along so the
|
|
433
568
|
// audit row records why the run diverged.
|
|
434
569
|
const outcome = { ...buildOutcome(committed), conflicts, failures };
|
|
570
|
+
// Frozen conflicts were not adjudicated: mark both sides pending in the
|
|
571
|
+
// per-id outcome so the audit row shows they were parked, not skipped.
|
|
572
|
+
if (frozenIds.length) {
|
|
573
|
+
for (const id of frozenIds) outcome.byId[id] = "conflict-pending";
|
|
574
|
+
}
|
|
435
575
|
// Decisions validated but not fully committed → reconcile (not ok).
|
|
436
576
|
const partial = conflicts.length > 0 || failures.length > 0;
|
|
437
577
|
// No decision landed (all-keep, or every decision skipped as an idempotent
|
|
438
578
|
// replay) → nothing substantive changed. Distinct from a success: such a
|
|
439
579
|
// run must never be reported as ok, or the audit claims work that never
|
|
440
580
|
// happened and the scheduler refreshes the baseline on a false positive.
|
|
441
|
-
|
|
581
|
+
// Frozen conflicts are substantive output (parked for review), so a run
|
|
582
|
+
// that only froze conflicts is not a noop.
|
|
583
|
+
const noChange = frozenCount === 0 && applied === 0 && committed.every((c) => c.action === "keep");
|
|
442
584
|
|
|
443
585
|
// Keep the vector index consistent with the post-dream store state.
|
|
444
586
|
if (semantic?.embedder && semantic?.vectorIndex) {
|
|
445
587
|
try {
|
|
446
|
-
await maintainIndexAfterDream(
|
|
588
|
+
await maintainIndexAfterDream(applyList, service, semantic);
|
|
447
589
|
} catch (error) {
|
|
448
590
|
logger?.warn?.(`[dsh-mneme] dream index maintenance failed: ${String(error)}`);
|
|
449
591
|
}
|
|
@@ -465,7 +607,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
465
607
|
});
|
|
466
608
|
} catch (error) {
|
|
467
609
|
logger?.warn?.(`dsh-mneme dream: summary llm call failed: ${String(error)}`);
|
|
468
|
-
return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, summary: false });
|
|
610
|
+
return finish({ ok: false, error: "llm failed", applied, decisions: auditDecisions, outcome, frozen: frozenCount, summary: false });
|
|
469
611
|
}
|
|
470
612
|
let summaryStored = false;
|
|
471
613
|
if (summaryText !== undefined && summaryText.trim()) {
|
|
@@ -512,6 +654,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
512
654
|
outcome,
|
|
513
655
|
conflicts,
|
|
514
656
|
failures,
|
|
657
|
+
frozen: frozenCount,
|
|
515
658
|
summary: summaryStored
|
|
516
659
|
});
|
|
517
660
|
}
|
package/lib/index.js
CHANGED
|
@@ -44,6 +44,23 @@ export const apply = (ctx, config) => {
|
|
|
44
44
|
const mirror = createMirror(memoryDir);
|
|
45
45
|
const service = createService({ store, mirror, config: cfg });
|
|
46
46
|
|
|
47
|
+
// Recall-layer receipt: when searchMemories runs with recordRecall=true, the
|
|
48
|
+
// retrieval scene (query/mode/topK/threshold + candidates) is persisted to
|
|
49
|
+
// recall_runs for audit/replay — the sibling of the dream_runs judgment trail.
|
|
50
|
+
// Best-effort: a failed recall write must never break the search.
|
|
51
|
+
service.setRecallRecorder((recall) => {
|
|
52
|
+
try {
|
|
53
|
+
store.saveRecallRun({
|
|
54
|
+
query: recall.query,
|
|
55
|
+
mode: recall.mode,
|
|
56
|
+
topK: recall.topK,
|
|
57
|
+
threshold: recall.threshold ?? null,
|
|
58
|
+
candidates: recall.candidates ?? [],
|
|
59
|
+
created_at: recall.createdAt
|
|
60
|
+
});
|
|
61
|
+
} catch { /* non-fatal: recall recording is bookkeeping */ }
|
|
62
|
+
});
|
|
63
|
+
|
|
47
64
|
// User-configurable settings (profile, rules) and custom commands share the
|
|
48
65
|
// same SQLite file but live in dedicated tables, isolated from memories.
|
|
49
66
|
const settings = createSettings(store.db);
|
package/lib/service.js
CHANGED
|
@@ -16,6 +16,13 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
16
16
|
let vectorIndex = null;
|
|
17
17
|
let reranker = null;
|
|
18
18
|
|
|
19
|
+
// Optional recall recorder, installed via setRecallRecorder after creation.
|
|
20
|
+
// When searchMemories is called with recordRecall=true it receives the
|
|
21
|
+
// actual merged recall scene (candidates + scores + source + threshold) so
|
|
22
|
+
// the retrieval layer can be audited/replayed — the sibling of the dream
|
|
23
|
+
// judgment-layer audit trail (dream_runs).
|
|
24
|
+
let recallRecorder = null;
|
|
25
|
+
|
|
19
26
|
// Transaction nesting depth. Inside service.transaction the per-mutation side
|
|
20
27
|
// effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
|
|
21
28
|
// never leaves the mirror file diverged from the database; transaction()
|
|
@@ -43,7 +50,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
43
50
|
const out = [];
|
|
44
51
|
for (const s of scored) {
|
|
45
52
|
const c = byId.get(s.id);
|
|
46
|
-
if (c) { out.push({ ...c, score: s.score }); if (out.length >= topK) break; }
|
|
53
|
+
if (c) { out.push({ ...c, score: s.score, source: "rerank" }); if (out.length >= topK) break; }
|
|
47
54
|
}
|
|
48
55
|
return out.length ? out : candidates.slice(0, topK);
|
|
49
56
|
} catch {
|
|
@@ -78,15 +85,16 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
78
85
|
return base * (0.5 + (row.importance ?? 3) / 10);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true } = {}) {
|
|
88
|
+
async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = {}) {
|
|
82
89
|
const q = String(query ?? "").trim();
|
|
83
90
|
if (!q) return [];
|
|
84
91
|
const lim = topK > 0 ? topK : 20;
|
|
85
92
|
|
|
86
93
|
// Keyword results, decorated with a score so they can be weight-blended
|
|
87
|
-
// with vector results and reported uniformly.
|
|
94
|
+
// with vector results and reported uniformly. source tracks where each
|
|
95
|
+
// candidate came from for the recall layer receipt.
|
|
88
96
|
const rawKeyword = store.search(q, { limit: lim });
|
|
89
|
-
const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q) }));
|
|
97
|
+
const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
|
|
90
98
|
const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
|
|
91
99
|
let vector = [];
|
|
92
100
|
if (wantVector && embedder) {
|
|
@@ -100,7 +108,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
100
108
|
const hits = vectorIndex
|
|
101
109
|
? vectorIndex.search(qv, { limit: lim * 2, threshold: threshold ?? 0 })
|
|
102
110
|
: store.searchVector(qv, { limit: lim * 2, threshold: threshold ?? 0 });
|
|
103
|
-
vector = hits.map((m) => ({ ...m, vector: true }));
|
|
111
|
+
vector = hits.map((m) => ({ ...m, vector: true, source: "vector" }));
|
|
104
112
|
}
|
|
105
113
|
} catch { /* vector unavailable: keep keyword results */ }
|
|
106
114
|
}
|
|
@@ -147,10 +155,34 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
147
155
|
}
|
|
148
156
|
|
|
149
157
|
merged = merged.slice(0, lim);
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
const result = useRerank && reranker && merged.length
|
|
159
|
+
? await rerankCandidates(q, merged, lim)
|
|
160
|
+
: merged;
|
|
161
|
+
|
|
162
|
+
// Recall layer receipt: with recordRecall on, hand the actual merged
|
|
163
|
+
// candidate list (id/title/content/score/source) to the injected recorder
|
|
164
|
+
// before returning, making the retrieval scene replayable — the sibling of
|
|
165
|
+
// the dream judgment-layer audit trail. Recorder failures must never break
|
|
166
|
+
// the search itself.
|
|
167
|
+
if (recordRecall && recallRecorder) {
|
|
168
|
+
try {
|
|
169
|
+
recallRecorder({
|
|
170
|
+
query: q,
|
|
171
|
+
mode,
|
|
172
|
+
topK: lim,
|
|
173
|
+
threshold: threshold ?? null,
|
|
174
|
+
candidates: result.map((m) => ({
|
|
175
|
+
id: m.id,
|
|
176
|
+
title: m.title,
|
|
177
|
+
content: m.content,
|
|
178
|
+
score: m.score ?? null,
|
|
179
|
+
source: m.source ?? "keyword"
|
|
180
|
+
})),
|
|
181
|
+
createdAt: new Date().toISOString()
|
|
182
|
+
});
|
|
183
|
+
} catch { /* recall receipt is best effort */ }
|
|
152
184
|
}
|
|
153
|
-
return
|
|
185
|
+
return result;
|
|
154
186
|
}
|
|
155
187
|
|
|
156
188
|
/**
|
|
@@ -355,6 +387,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
355
387
|
setEmbedder(emb) { embedder = emb; },
|
|
356
388
|
setVectorIndex(vi) { vectorIndex = vi; },
|
|
357
389
|
setReranker(rn) { reranker = rn; },
|
|
390
|
+
setRecallRecorder(fn) { recallRecorder = fn; },
|
|
358
391
|
searchMemories,
|
|
359
392
|
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
360
393
|
search: (q, o) => store.search(q, o),
|
|
@@ -441,6 +474,17 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
441
474
|
// dream scheduler that just recorded the run.
|
|
442
475
|
saveDreamRun: (run) => store.saveDreamRun(run),
|
|
443
476
|
getDreamRun: (id) => store.getDreamRun(id),
|
|
444
|
-
listDreamRuns: (opts) => store.listDreamRuns(opts)
|
|
477
|
+
listDreamRuns: (opts) => store.listDreamRuns(opts),
|
|
478
|
+
// Per-record receipt chain (same bookkeeping semantics as saveDreamRun: an
|
|
479
|
+
// audit write, never a write-hook-triggering memory mutation).
|
|
480
|
+
saveReceipt: (r) => store.saveReceipt(r),
|
|
481
|
+
getReceipt: (id) => store.getReceipt(id),
|
|
482
|
+
listReceipts: (opts) => store.listReceipts(opts),
|
|
483
|
+
// Conflict freeze bookkeeping (same semantics as the audit passthroughs
|
|
484
|
+
// above: an audit write, never a write-hook-triggering memory mutation).
|
|
485
|
+
saveConflictPending: (r) => store.saveConflictPending(r),
|
|
486
|
+
listConflictPending: (opts) => store.listConflictPending(opts),
|
|
487
|
+
resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
|
|
488
|
+
countConflictPending: () => store.countConflictPending()
|
|
445
489
|
};
|
|
446
490
|
}
|