@modusensus/dsh-mneme 0.2.4 → 0.2.6
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 +10 -9
- package/lib/client.js +2 -2
- package/lib/config.js +5 -2
- package/lib/dream/decisions.js +174 -62
- package/lib/dream.js +30 -5
- package/lib/index.js +3 -1
- package/lib/mirror.js +7 -1
- package/lib/service.js +117 -3
- package/lib/store.js +40 -0
- package/lib/tools.js +47 -4
- package/package.json +3 -1
- package/scripts/benchmark-embed.js +201 -0
- package/scripts/benchmark-rerank.js +166 -0
- package/scripts/e2e-dsh.js +216 -0
- package/scripts/stress-dsh.js +255 -0
- package/scripts/sync-lib.js +47 -0
- package/src/config.js +5 -2
- package/src/dream/decisions.js +174 -62
- package/src/dream.js +30 -5
- package/src/index.js +3 -1
- package/src/mirror.js +7 -1
- package/src/service.js +117 -3
- package/src/store.js +40 -0
- package/src/tools.js +47 -4
- package/test/api.test.js +385 -0
- package/test/audit.test.js +290 -0
- package/test/client.test.js +44 -0
- package/test/clustering.test.js +100 -0
- package/test/commands.test.js +69 -0
- package/test/config.test.js +31 -0
- package/test/dream.test.js +526 -0
- package/test/helpers/dream-mock.js +82 -0
- package/test/inject.test.js +82 -0
- package/test/local-embedder.test.js +227 -0
- package/test/mirror.test.js +249 -0
- package/test/reflection.test.js +226 -0
- package/test/reranker.test.js +197 -0
- package/test/semantic.test.js +123 -0
- package/test/service-search.test.js +169 -0
- package/test/service.test.js +198 -0
- package/test/settings.test.js +101 -0
- package/test/store.test.js +293 -0
- package/test/stress.test.js +209 -0
- package/test/summarize.test.js +156 -0
- package/test/tools.test.js +265 -0
- package/test/vector-index.test.js +205 -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
|
|
|
@@ -19,16 +19,17 @@
|
|
|
19
19
|
- **Markdown 镜像**:`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md`,人类可读、可手工编辑(**人工修改优先**合并回库)
|
|
20
20
|
- **4+1 种记忆类型**:`preference`(偏好)/ `project`(项目)/ `decision`(决策)/ `history`(历史)/ `summary`(总览)
|
|
21
21
|
|
|
22
|
-
### 模型工具(
|
|
22
|
+
### 模型工具(7 个)
|
|
23
23
|
|
|
24
24
|
| 工具 | 功能 |
|
|
25
25
|
|------|------|
|
|
26
26
|
| `memory_save` | 记录一条记忆(自动按标题去重合并) |
|
|
27
27
|
| `memory_search` | 全文搜索(中文子串友好,可启用向量语义搜索) |
|
|
28
|
-
| `memory_list` |
|
|
28
|
+
| `memory_list` | 按类型分页列出(`include_archived=true` 可查看已归档) |
|
|
29
29
|
| `memory_update` | 修改已有记忆 |
|
|
30
30
|
| `memory_delete` | 删除记忆 |
|
|
31
31
|
| `memory_forget` | 抑制注入(降权不删除,可恢复) |
|
|
32
|
+
| `memory_archive` | 归档/恢复记忆(v0.2.5;归档后隐藏于列表/搜索/注入/整理,`archived=false` 可恢复) |
|
|
32
33
|
|
|
33
34
|
### 自动注入 + 会话摘要
|
|
34
35
|
|
|
@@ -173,8 +174,8 @@ dsh web
|
|
|
173
174
|
| `vectorSearchThreshold` | `0.65` | 向量搜索相似度阈值 |
|
|
174
175
|
| `hybridSearchVectorWeight` | `0.6` | 混合搜索向量权重 |
|
|
175
176
|
| `hybridSearchKeywordWeight` | `0.4` | 混合搜索关键词权重 |
|
|
176
|
-
| `rerankEnabled` | `
|
|
177
|
-
| `rerankProvider` | `
|
|
177
|
+
| `rerankEnabled` | `false` | 是否启用 Rerank 精排(显式开启才加载本地 onnxruntime 模型) |
|
|
178
|
+
| `rerankProvider` | `none` | Rerank 后端:`local` / `none`(默认 `none`) |
|
|
178
179
|
| `rerankModel` | `Xenova/bge-reranker-base` | Rerank 交叉编码模型 |
|
|
179
180
|
| `rerankBatchSize` | `8` | Rerank 批大小 |
|
|
180
181
|
| `rerankMaxCandidates` | `30` | Rerank 最大候选数 |
|
|
@@ -196,7 +197,7 @@ dsh web
|
|
|
196
197
|
│ 服务层:saveWithDedupe / injectCandidates │
|
|
197
198
|
│ / mergeHumanEdits / onWrite 钩子 │
|
|
198
199
|
├─────────────────────────────────────────────────┤
|
|
199
|
-
│ 模型接口:
|
|
200
|
+
│ 模型接口:7 个工具 + 自动注入 + 会话摘要 │
|
|
200
201
|
├─────────────────────────────────────────────────┤
|
|
201
202
|
│ autoDream:阈值调度 → LLM 决策清单 │
|
|
202
203
|
│ → 校验(fail-safe)→ 应用 → 摘要 │
|
|
@@ -213,7 +214,7 @@ src/
|
|
|
213
214
|
├── mirror.js # Markdown 镜像(渲染/解析,人工优先)
|
|
214
215
|
├── service.js # 领域逻辑(去重合并、注入筛选、写入钩子)
|
|
215
216
|
├── config.js # schemastery 配置 schema
|
|
216
|
-
├── tools.js #
|
|
217
|
+
├── tools.js # 7 个模型工具(defineTool)
|
|
217
218
|
├── inject.js # systemPrompt.context 动态注入
|
|
218
219
|
├── summarize.js # 会话结束 LLM 摘要
|
|
219
220
|
├── dream.js # autoDream 调度 + runDream(LLM 决策 + 摘要)
|
|
@@ -224,7 +225,7 @@ src/
|
|
|
224
225
|
lib/
|
|
225
226
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
226
227
|
└── *.js # src 的同步分发产物
|
|
227
|
-
test/ #
|
|
228
|
+
test/ # 258 个 node:test 测试(含审计与三轴线压测不变量)
|
|
228
229
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
|
|
229
230
|
```
|
|
230
231
|
|
|
@@ -233,7 +234,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
233
234
|
```bash
|
|
234
235
|
cd dsh-mneme
|
|
235
236
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
236
|
-
npm test # 运行
|
|
237
|
+
npm test # 运行 258 个测试
|
|
237
238
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
238
239
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
239
240
|
```
|
package/lib/client.js
CHANGED
|
@@ -178,7 +178,7 @@ window.__ModuleLoader__.load({
|
|
|
178
178
|
|
|
179
179
|
const tabs = ["all", "preference", "project", "decision", "history"];
|
|
180
180
|
|
|
181
|
-
const body = react.createElement("div", { style: embedded ? {
|
|
181
|
+
const body = react.createElement("div", { style: embedded ? { width: "100%", display: "flex", flexDirection: "column" } : styles.panel },
|
|
182
182
|
!embedded && react.createElement("div", { style: styles.header },
|
|
183
183
|
react.createElement("span", { style: styles.title }, t("memory.panel.title")),
|
|
184
184
|
react.createElement("button", { style: styles.close, onClick: onClose }, "×")
|
|
@@ -365,7 +365,7 @@ window.__ModuleLoader__.load({
|
|
|
365
365
|
const hintStyle = { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)", marginBottom: 8 };
|
|
366
366
|
const rowStyle = { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-l1, #eee)" };
|
|
367
367
|
|
|
368
|
-
const body = h("div", { style: embedded ? {
|
|
368
|
+
const body = h("div", { style: embedded ? { width: "100%", display: "flex", flexDirection: "column" } : styles.panel },
|
|
369
369
|
!embedded && h("div", { style: styles.header },
|
|
370
370
|
h("span", { style: styles.title }, t("memory.settings.title")),
|
|
371
371
|
h("button", { style: styles.close, onClick: onClose }, "×")
|
package/lib/config.js
CHANGED
|
@@ -49,8 +49,11 @@ export const Config = z.object({
|
|
|
49
49
|
hybridSearchKeywordWeight: z.number().min(0).max(1).default(0.4),
|
|
50
50
|
|
|
51
51
|
// --- semantic: rerank layer (v0.2) --------------------------------------
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Opt-in by default (item ⑥): the local cross-encoder pulls in onnxruntime
|
|
53
|
+
// (transformers.js) at init, so a bare install must not load it. Only an
|
|
54
|
+
// explicit rerankEnabled=true + rerankProvider="local" constructs LocalReranker.
|
|
55
|
+
rerankEnabled: z.boolean().default(false),
|
|
56
|
+
rerankProvider: z.union([z.const("local"), z.const("none")]).default("none"),
|
|
54
57
|
rerankModel: z.string().default("Xenova/bge-reranker-base"),
|
|
55
58
|
rerankBatchSize: z.natural().min(1).max(64).default(8),
|
|
56
59
|
rerankMaxCandidates: z.natural().min(5).max(100).default(30),
|
package/lib/dream/decisions.js
CHANGED
|
@@ -102,79 +102,191 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
102
102
|
return { ok: errors.length === 0, errors };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** Marker thrown when a decision target changed since the run snapshot. */
|
|
106
|
+
export class CasConflictError extends Error {
|
|
107
|
+
constructor(action, ids, reason) {
|
|
108
|
+
super(`cas conflict: ${action} targets changed since snapshot (${reason})`);
|
|
109
|
+
this.name = "CasConflictError";
|
|
110
|
+
this.action = action;
|
|
111
|
+
this.ids = ids;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function decisionIds(d) {
|
|
116
|
+
return d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* CAS guard (item ①): every target memory must still match what the run
|
|
121
|
+
* snapshot captured — otherwise the decision was computed against stale state
|
|
122
|
+
* and applying it would overwrite a concurrent edit. Snapshotless replays skip
|
|
123
|
+
* the guard entirely (per-action idempotency checks handle those). Throws
|
|
124
|
+
* CasConflictError on the first mismatch; the caller's transaction rolls back.
|
|
125
|
+
*/
|
|
126
|
+
function casGuard(service, snapshot, ids) {
|
|
127
|
+
if (!snapshot) return;
|
|
128
|
+
for (const id of ids) {
|
|
129
|
+
const expect = snapshot.get(id);
|
|
130
|
+
if (!expect) continue; // not in snapshot: validated elsewhere, skip guard
|
|
131
|
+
const current = service.getById(id);
|
|
132
|
+
if (!current) {
|
|
133
|
+
throw new CasConflictError("deleted", [id], `memory ${id} was removed`);
|
|
134
|
+
}
|
|
135
|
+
const changed = expect.updated_at !== undefined
|
|
136
|
+
? current.updated_at !== expect.updated_at
|
|
137
|
+
: current.content !== expect.content || current.title !== expect.title;
|
|
138
|
+
if (changed) {
|
|
139
|
+
throw new CasConflictError(
|
|
140
|
+
"changed",
|
|
141
|
+
[id],
|
|
142
|
+
`memory ${id} was concurrently modified (expected updated_at=${expect.updated_at}, got ${current.updated_at})`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
105
148
|
/**
|
|
106
149
|
* Apply a validated decision list to the service. Caller must validate first.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
150
|
+
* Each decision runs inside its own SQLite transaction (item ②): the multi-step
|
|
151
|
+
* mutation of a decision is atomic, so a merge can never leave "keeper updated
|
|
152
|
+
* but source not archived" or vice versa — a throwing sub-step rolls the whole
|
|
153
|
+
* decision back.
|
|
110
154
|
*
|
|
111
155
|
* @param decisions - validated decision list.
|
|
112
|
-
* @param service - memory service (saveWithDedupe/getById/update/setArchived).
|
|
156
|
+
* @param service - memory service (saveWithDedupe/getById/update/setArchived/transaction).
|
|
113
157
|
* @param logger - optional logger ({ warn }); per-decision failures are logged.
|
|
114
|
-
* @
|
|
158
|
+
* @param snapshot - optional Map<id, memory> captured before the LLM call; when
|
|
159
|
+
* provided, every decision target is CAS-checked against it and a decision
|
|
160
|
+
* computed from stale state is skipped and reported as a conflict instead of
|
|
161
|
+
* overwriting concurrent writes (item ①).
|
|
162
|
+
* @returns {{ applied: number, conflicts: Array, failures: Array, committed: Array }}
|
|
163
|
+
* applied - number of decisions/memories actually committed (archive counts
|
|
164
|
+
* each archived memory as one, merge/conflict/update count one).
|
|
165
|
+
* conflicts - decisions skipped because a target changed since the snapshot.
|
|
166
|
+
* failures - decisions that threw mid-transaction (fully rolled back).
|
|
167
|
+
* committed - the decisions that actually landed, for outcome/receipt based
|
|
168
|
+
* on real committed sub-steps rather than the raw LLM list.
|
|
115
169
|
*/
|
|
116
|
-
export function applyDecisions(decisions, service, logger = null) {
|
|
170
|
+
export function applyDecisions(decisions, service, logger = null, snapshot = null) {
|
|
117
171
|
let applied = 0;
|
|
172
|
+
const conflicts = [];
|
|
173
|
+
const failures = [];
|
|
174
|
+
const committed = [];
|
|
118
175
|
for (const [i, d] of decisions.entries()) {
|
|
119
176
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
} else if (d.action === "merge") {
|
|
127
|
-
const keeper = service.getById(d.keepSource);
|
|
128
|
-
if (!keeper || keeper.archived) continue;
|
|
129
|
-
const sources = d.ids.filter((id) => id !== d.keepSource);
|
|
130
|
-
// Idempotent replay: if every other source is already archived, this
|
|
131
|
-
// merge already landed — skip so a replayed/concurrent decision never
|
|
132
|
-
// double-counts or re-applies (guard against duplicate merges).
|
|
133
|
-
if (sources.every((id) => service.getById(id)?.archived)) continue;
|
|
134
|
-
service.update(d.keepSource, {
|
|
135
|
-
title: d.title,
|
|
136
|
-
content: d.content,
|
|
137
|
-
importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
|
|
138
|
-
});
|
|
139
|
-
for (const id of sources) {
|
|
140
|
-
const mem = service.getById(id);
|
|
141
|
-
if (mem && !mem.archived) { service.setArchived(id, true); }
|
|
142
|
-
}
|
|
143
|
-
applied++;
|
|
144
|
-
} else if (d.action === "conflict") {
|
|
145
|
-
const winner = service.getById(d.winner);
|
|
146
|
-
const loser = service.getById(d.loser);
|
|
147
|
-
if (!winner || !loser) continue;
|
|
148
|
-
// Idempotent replay: an already-archived loser means the conflict was
|
|
149
|
-
// already adjudicated — skip so the provenance note is never
|
|
150
|
-
// re-appended and the loser is not re-archived.
|
|
151
|
-
if (loser.archived) continue;
|
|
152
|
-
service.update(d.winner, {
|
|
153
|
-
content: `${winner.content}\n\n(已否决旧信息:${[...loser.content].slice(0, 100).join("")})`
|
|
154
|
-
});
|
|
155
|
-
service.setArchived(d.loser, true);
|
|
156
|
-
applied++;
|
|
157
|
-
} else if (d.action === "update") {
|
|
158
|
-
const id = d.ids[0];
|
|
159
|
-
const mem = service.getById(id);
|
|
160
|
-
if (!mem || mem.archived) continue;
|
|
161
|
-
// 幂等检查:如果字段已与目标一致则跳过
|
|
162
|
-
const same = (d.title === undefined || d.title === mem.title)
|
|
163
|
-
&& (d.content === undefined || d.content === mem.content)
|
|
164
|
-
&& (d.importance === undefined || d.importance === mem.importance);
|
|
165
|
-
if (same) continue;
|
|
166
|
-
service.update(id, {
|
|
167
|
-
title: d.title ?? mem.title,
|
|
168
|
-
content: d.content ?? mem.content,
|
|
169
|
-
importance: d.importance ?? mem.importance
|
|
170
|
-
});
|
|
171
|
-
applied++;
|
|
177
|
+
// keep is a confirmed no-op: it commits nothing but still records the
|
|
178
|
+
// per-id disposition so the outcome covers every snapshot memory.
|
|
179
|
+
if (d.action === "keep") {
|
|
180
|
+
committed.push({ action: "keep", ids: d.ids });
|
|
181
|
+
continue;
|
|
172
182
|
}
|
|
183
|
+
const outcome = applyOne(d, service, snapshot);
|
|
184
|
+
if (outcome === "skipped") continue;
|
|
185
|
+
applied += outcome.applied;
|
|
186
|
+
committed.push(outcome.committed);
|
|
173
187
|
} catch (error) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
188
|
+
if (error instanceof CasConflictError) {
|
|
189
|
+
conflicts.push({ index: i, action: d.action, ids: error.ids, reason: error.message });
|
|
190
|
+
logger?.warn?.(`dsh-mneme dream: ${error.message}`);
|
|
191
|
+
} else {
|
|
192
|
+
failures.push({ index: i, action: d.action, ids: decisionIds(d), reason: error.message });
|
|
193
|
+
logger?.warn?.(`dsh-mneme dream: failed to apply ${d.action} at index ${i}: ${error.message}`);
|
|
194
|
+
}
|
|
177
195
|
}
|
|
178
196
|
}
|
|
179
|
-
return applied;
|
|
197
|
+
return { applied, conflicts, failures, committed };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function applyOne(d, service, snapshot) {
|
|
201
|
+
switch (d.action) {
|
|
202
|
+
case "archive": return applyArchive(d, service, snapshot);
|
|
203
|
+
case "merge": return applyMerge(d, service, snapshot);
|
|
204
|
+
case "conflict": return applyConflict(d, service, snapshot);
|
|
205
|
+
default: return applyUpdate(d, service, snapshot);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function applyArchive(d, service, snapshot) {
|
|
210
|
+
const targets = d.ids.filter((id) => {
|
|
211
|
+
const mem = service.getById(id);
|
|
212
|
+
return mem && !mem.archived; // existing, not-yet-archived rows only
|
|
213
|
+
});
|
|
214
|
+
if (targets.length === 0) return "skipped"; // all already archived: idempotent replay
|
|
215
|
+
service.transaction(() => {
|
|
216
|
+
casGuard(service, snapshot, d.ids);
|
|
217
|
+
for (const id of d.ids) {
|
|
218
|
+
const mem = service.getById(id);
|
|
219
|
+
if (mem && !mem.archived) service.setArchived(id, true);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return { applied: targets.length, committed: { action: "archive", ids: targets } };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function applyMerge(d, service, snapshot) {
|
|
226
|
+
const sources = d.ids.filter((id) => id !== d.keepSource);
|
|
227
|
+
// Idempotent replay: if every other source is already archived, this merge
|
|
228
|
+
// already landed — skip so a replayed/concurrent decision never double-counts
|
|
229
|
+
// or re-applies (guard against duplicate merges).
|
|
230
|
+
if (sources.every((id) => service.getById(id)?.archived)) return "skipped";
|
|
231
|
+
service.transaction(() => {
|
|
232
|
+
casGuard(service, snapshot, d.ids);
|
|
233
|
+
const keeper = service.getById(d.keepSource);
|
|
234
|
+
if (!keeper || keeper.archived) return; // missing keeper: no write, still a clean commit
|
|
235
|
+
service.update(d.keepSource, {
|
|
236
|
+
title: d.title,
|
|
237
|
+
content: d.content,
|
|
238
|
+
importance: d.importance ?? Math.max(keeper.importance, ...d.ids.map((id) => service.getById(id)?.importance ?? 1))
|
|
239
|
+
});
|
|
240
|
+
for (const id of sources) {
|
|
241
|
+
const mem = service.getById(id);
|
|
242
|
+
if (mem && !mem.archived) service.setArchived(id, true);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
applied: 1,
|
|
247
|
+
committed: { action: "merge", ids: d.ids, keepSource: d.keepSource, title: d.title, content: d.content, importance: d.importance }
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function applyConflict(d, service, snapshot) {
|
|
252
|
+
const winner = service.getById(d.winner);
|
|
253
|
+
const loser = service.getById(d.loser);
|
|
254
|
+
if (!winner || !loser) return "skipped";
|
|
255
|
+
// Idempotent replay: an already-archived loser means the conflict was already
|
|
256
|
+
// adjudicated — skip so the provenance note is never re-appended and the loser
|
|
257
|
+
// is not re-archived.
|
|
258
|
+
if (loser.archived) return "skipped";
|
|
259
|
+
service.transaction(() => {
|
|
260
|
+
casGuard(service, snapshot, [d.winner, d.loser]);
|
|
261
|
+
const winnerNow = service.getById(d.winner);
|
|
262
|
+
const loserNow = service.getById(d.loser);
|
|
263
|
+
if (!winnerNow || !loserNow || loserNow.archived) return;
|
|
264
|
+
service.update(d.winner, {
|
|
265
|
+
content: `${winnerNow.content}\n\n(已否决旧信息:${[...loserNow.content].slice(0, 100).join("")})`
|
|
266
|
+
});
|
|
267
|
+
service.setArchived(d.loser, true);
|
|
268
|
+
});
|
|
269
|
+
return { applied: 1, committed: { action: "conflict", winner: d.winner, loser: d.loser } };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function applyUpdate(d, service, snapshot) {
|
|
273
|
+
const id = d.ids[0];
|
|
274
|
+
const mem = service.getById(id);
|
|
275
|
+
if (!mem || mem.archived) return "skipped";
|
|
276
|
+
// 幂等检查:如果字段已与目标一致则跳过
|
|
277
|
+
const same = (d.title === undefined || d.title === mem.title)
|
|
278
|
+
&& (d.content === undefined || d.content === mem.content)
|
|
279
|
+
&& (d.importance === undefined || d.importance === mem.importance);
|
|
280
|
+
if (same) return "skipped";
|
|
281
|
+
service.transaction(() => {
|
|
282
|
+
casGuard(service, snapshot, [id]);
|
|
283
|
+
const cur = service.getById(id);
|
|
284
|
+
if (!cur || cur.archived) return;
|
|
285
|
+
service.update(id, {
|
|
286
|
+
title: d.title ?? cur.title,
|
|
287
|
+
content: d.content ?? cur.content,
|
|
288
|
+
importance: d.importance ?? cur.importance
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
return { applied: 1, committed: { action: "update", ids: [id], title: d.title, content: d.content, importance: d.importance } };
|
|
180
292
|
}
|
package/lib/dream.js
CHANGED
|
@@ -65,7 +65,10 @@ export function parseReceipt(receipt) {
|
|
|
65
65
|
const parts = receipt.split(":");
|
|
66
66
|
if (parts.length !== 8 || parts[0] !== "dsh-mneme" || parts[1] !== "run") return undefined;
|
|
67
67
|
const [, , runId, status, snapshotHash, inputCount, applied, summaryStored] = parts;
|
|
68
|
-
|
|
68
|
+
// reconcile = decisions validated but one or more did not commit (CAS
|
|
69
|
+
// conflict / transaction rollback) — the store diverges from the decision
|
|
70
|
+
// list and the run must be reconciled, never reported as a fake ok.
|
|
71
|
+
if (!runId || !/^(ok|failed|reconcile)$/.test(status)) return undefined;
|
|
69
72
|
const count = Number(inputCount);
|
|
70
73
|
const appliedN = Number(applied);
|
|
71
74
|
if (!Number.isInteger(count) || !Number.isInteger(appliedN)) return undefined;
|
|
@@ -282,7 +285,11 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
282
285
|
// (e.g. summary step failed after consolidation), so the partial write is
|
|
283
286
|
// replayable too.
|
|
284
287
|
const finish = (result) => {
|
|
285
|
-
|
|
288
|
+
// status is derived from what actually committed: ok only when the full
|
|
289
|
+
// decision list landed; reconcile when decisions were validated but some
|
|
290
|
+
// did not commit (CAS conflict / rollback); failed on any LLM/validation
|
|
291
|
+
// error. No fake "ok" for a partial commit.
|
|
292
|
+
const status = result.status ?? (result.ok ? "ok" : "failed");
|
|
286
293
|
const applied = result.applied ?? 0;
|
|
287
294
|
const summaryStored = result.summary ?? false;
|
|
288
295
|
const receipt = buildReceipt({ runId, status, snapshotHash, inputCount: snapshot.size, applied, summaryStored });
|
|
@@ -407,7 +414,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
407
414
|
}
|
|
408
415
|
}
|
|
409
416
|
|
|
410
|
-
|
|
417
|
+
// CAS-guarded, per-decision-transactional apply against the run snapshot:
|
|
418
|
+
// a target changed during the LLM call is skipped and reported as a
|
|
419
|
+
// conflict instead of being overwritten (item ①).
|
|
420
|
+
const { applied, conflicts, failures, committed } = applyDecisions(decisions, service, logger, snapshot);
|
|
411
421
|
// Attach the pre-update snapshot to the audit copy of each update decision
|
|
412
422
|
// so the recorded row shows the before/after delta, not just the target.
|
|
413
423
|
const auditDecisions = decisions.map((d) =>
|
|
@@ -415,7 +425,13 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
415
425
|
? { ...d, _before: updateSnapshots[d.ids[0]] }
|
|
416
426
|
: d
|
|
417
427
|
);
|
|
418
|
-
|
|
428
|
+
// Outcome is derived from the ACTUALLY committed sub-steps, never from the
|
|
429
|
+
// raw LLM decision list — a merge whose archive step rolled back must not
|
|
430
|
+
// claim "merge-archived" (item ②). Conflicts/failures ride along so the
|
|
431
|
+
// audit row records why the run diverged.
|
|
432
|
+
const outcome = { ...buildOutcome(committed), conflicts, failures };
|
|
433
|
+
// Decisions validated but not fully committed → reconcile (not ok).
|
|
434
|
+
const partial = conflicts.length > 0 || failures.length > 0;
|
|
419
435
|
|
|
420
436
|
// Keep the vector index consistent with the post-dream store state.
|
|
421
437
|
if (semantic?.embedder && semantic?.vectorIndex) {
|
|
@@ -460,7 +476,16 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
460
476
|
} catch { /* best-effort */ }
|
|
461
477
|
}
|
|
462
478
|
}
|
|
463
|
-
return finish({
|
|
479
|
+
return finish({
|
|
480
|
+
ok: !partial,
|
|
481
|
+
status: partial ? "reconcile" : "ok",
|
|
482
|
+
applied,
|
|
483
|
+
decisions: auditDecisions,
|
|
484
|
+
outcome,
|
|
485
|
+
conflicts,
|
|
486
|
+
failures,
|
|
487
|
+
summary: summaryStored
|
|
488
|
+
});
|
|
464
489
|
}
|
|
465
490
|
|
|
466
491
|
return { maybeSchedule, runDream, dispose };
|
package/lib/index.js
CHANGED
|
@@ -83,7 +83,9 @@ export const apply = (ctx, config) => {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Cross-encoder rerank over recall candidates. Best-effort: a failed model
|
|
86
|
-
// load only disables reranking, never search itself.
|
|
86
|
+
// load only disables reranking, never search itself. Explicit opt-in only
|
|
87
|
+
// (rerankEnabled defaults to false): constructing LocalReranker is what pulls
|
|
88
|
+
// in onnxruntime, so the default config never loads it (item ⑥).
|
|
87
89
|
if (cfg.rerankEnabled && cfg.rerankProvider === "local") {
|
|
88
90
|
try {
|
|
89
91
|
reranker = new LocalReranker({
|
package/lib/mirror.js
CHANGED
|
@@ -92,10 +92,16 @@ export function createMirror(dir) {
|
|
|
92
92
|
if (lastSep) body = body.slice(0, lastSep.index);
|
|
93
93
|
body = body.trim();
|
|
94
94
|
|
|
95
|
+
// The machine-written "更新时间" line records the store's updated_at at
|
|
96
|
+
// render time — the version token for detecting a concurrent store write
|
|
97
|
+
// during a three-way merge of human edits (see service.syncMirror).
|
|
98
|
+
const block = text.slice(blockStart, blockEnd);
|
|
99
|
+
const updatedMatch = block.match(/- \*\*更新时间\*\*: ([^\n]+)/);
|
|
95
100
|
edits.push({
|
|
96
101
|
id: anchor[1],
|
|
97
102
|
title: titleMatch ? unescape(titleMatch[1]).trim() : undefined,
|
|
98
|
-
content: body
|
|
103
|
+
content: body,
|
|
104
|
+
updated_at: updatedMatch ? updatedMatch[1].trim() : undefined
|
|
99
105
|
});
|
|
100
106
|
|
|
101
107
|
const lineEnd = text.indexOf("\n", blockStart);
|
package/lib/service.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { TYPE_FILE } from "./mirror.js";
|
|
2
3
|
|
|
3
4
|
const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
|
|
4
5
|
|
|
@@ -15,7 +16,14 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
15
16
|
let vectorIndex = null;
|
|
16
17
|
let reranker = null;
|
|
17
18
|
|
|
19
|
+
// Transaction nesting depth. Inside service.transaction the per-mutation side
|
|
20
|
+
// effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
|
|
21
|
+
// never leaves the mirror file diverged from the database; transaction()
|
|
22
|
+
// replays them exactly once against the committed state.
|
|
23
|
+
let txDepth = 0;
|
|
24
|
+
|
|
18
25
|
function scheduleEmbed(memory) {
|
|
26
|
+
if (txDepth > 0) return; // deferred to the transaction's commit
|
|
19
27
|
if (embedder && memory?.id) {
|
|
20
28
|
try { embedder.schedule(memory); } catch { /* ignore */ }
|
|
21
29
|
}
|
|
@@ -152,6 +160,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
152
160
|
* state toggles, not content writes, so they never notify.
|
|
153
161
|
*/
|
|
154
162
|
function notifyWrite() {
|
|
163
|
+
if (txDepth > 0) return; // deferred to the transaction's commit
|
|
155
164
|
if (onWrite) {
|
|
156
165
|
try { onWrite(); } catch { /* ignore */ }
|
|
157
166
|
}
|
|
@@ -160,6 +169,31 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
160
169
|
}
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Run several store mutations atomically (SQLite BEGIN/COMMIT/ROLLBACK) and
|
|
174
|
+
* fire the deferred side effects once against the committed state. A throwing
|
|
175
|
+
* body rolls the whole batch back — no partial writes, no diverged mirror.
|
|
176
|
+
* Errors propagate to the caller. NOTE: the commit path re-renders the mirror
|
|
177
|
+
* and notifies subscribers, but re-embedding is left to the caller (the dream
|
|
178
|
+
* flow re-embeds through maintainIndexAfterDream).
|
|
179
|
+
*/
|
|
180
|
+
function transaction(fn) {
|
|
181
|
+
store.db.exec("BEGIN");
|
|
182
|
+
txDepth++;
|
|
183
|
+
try {
|
|
184
|
+
const result = fn();
|
|
185
|
+
store.db.exec("COMMIT");
|
|
186
|
+
return result;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try { store.db.exec("ROLLBACK"); } catch { /* store may be closed */ }
|
|
189
|
+
throw error;
|
|
190
|
+
} finally {
|
|
191
|
+
txDepth--;
|
|
192
|
+
syncMirror();
|
|
193
|
+
notifyWrite();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
163
197
|
/**
|
|
164
198
|
* Save a memory, merging into an existing one when title matches within the same type.
|
|
165
199
|
* @returns {{action: "created"|"merged", memory: object}}
|
|
@@ -253,12 +287,62 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
253
287
|
}
|
|
254
288
|
|
|
255
289
|
/**
|
|
256
|
-
*
|
|
290
|
+
* Three-way merge of in-flight human mirror edits before a re-render.
|
|
291
|
+
* Runs on every syncMirror, so a human edit made between two store writes is
|
|
292
|
+
* never silently overwritten by the next sync (human priority is not limited
|
|
293
|
+
* to startup). Per edited entry:
|
|
294
|
+
* - file changed only → human wins; the edit is merged back into the store.
|
|
295
|
+
* - file AND store changed → real three-way conflict: keep the human edit
|
|
296
|
+
* and append a marker preserving the store's concurrent version, so no
|
|
297
|
+
* side is dropped.
|
|
298
|
+
* - store changed only → store wins (the file is simply re-rendered).
|
|
299
|
+
* Only title/content are taken (structure fields stay machine-owned, matching
|
|
300
|
+
* mergeHumanEdits). Returns the memory list to render.
|
|
301
|
+
*/
|
|
302
|
+
function reconcileHumanEdits(memories) {
|
|
303
|
+
if (!mirror) return memories;
|
|
304
|
+
const byType = new Map();
|
|
305
|
+
for (const m of memories) {
|
|
306
|
+
if (!byType.has(m.type)) byType.set(m.type, []);
|
|
307
|
+
byType.get(m.type).push(m);
|
|
308
|
+
}
|
|
309
|
+
const result = [];
|
|
310
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
311
|
+
const list = byType.get(type) ?? [];
|
|
312
|
+
if (list.length === 0) continue;
|
|
313
|
+
const editsById = new Map(mirror.readHumanEdits(type).map((e) => [e.id, e]));
|
|
314
|
+
for (const m of list) {
|
|
315
|
+
const edit = editsById.get(m.id);
|
|
316
|
+
if (!edit) { result.push(m); continue; }
|
|
317
|
+
const humanChanged = (typeof edit.title === "string" && edit.title !== m.title)
|
|
318
|
+
|| (typeof edit.content === "string" && edit.content !== m.content);
|
|
319
|
+
if (!humanChanged) { result.push(m); continue; }
|
|
320
|
+
// Store changed since the file was last rendered (file records the
|
|
321
|
+
// store's updated_at at render time) AND the file was hand-edited.
|
|
322
|
+
const storeChanged = edit.updated_at !== undefined && m.updated_at !== edit.updated_at;
|
|
323
|
+
if (storeChanged) {
|
|
324
|
+
const marker = `\n\n> ⚠️ 并发冲突:人工编辑 vs 记忆库并发更新(${m.updated_at})\n> 记忆库版本:${m.content}`;
|
|
325
|
+
store.update(m.id, { title: edit.title, content: `${edit.content}${marker}` });
|
|
326
|
+
} else {
|
|
327
|
+
store.update(m.id, { title: edit.title, content: edit.content });
|
|
328
|
+
}
|
|
329
|
+
const merged = store.getById(m.id);
|
|
330
|
+
scheduleEmbed(merged);
|
|
331
|
+
result.push(merged);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Re-render the human-editable mirror after any store mutation, merging any
|
|
339
|
+
* in-flight human edits first (never silently overwriting them). Only
|
|
257
340
|
* non-forgotten memories are mirrored: forgotten entries must not reach the
|
|
258
341
|
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
259
342
|
*/
|
|
260
343
|
function syncMirror() {
|
|
261
|
-
if (mirror)
|
|
344
|
+
if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
|
|
345
|
+
mirror.sync(reconcileHumanEdits(store.list({ limit: 500, includeForgotten: false })));
|
|
262
346
|
}
|
|
263
347
|
|
|
264
348
|
return {
|
|
@@ -266,6 +350,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
266
350
|
injectCandidates,
|
|
267
351
|
mergeHumanEdits,
|
|
268
352
|
toApiList,
|
|
353
|
+
transaction,
|
|
269
354
|
setDreamHook(fn) { dreamHook = fn; },
|
|
270
355
|
setEmbedder(emb) { embedder = emb; },
|
|
271
356
|
setVectorIndex(vi) { vectorIndex = vi; },
|
|
@@ -277,7 +362,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
277
362
|
embeddedCount: () => store.embeddedCount(),
|
|
278
363
|
list: (o) => store.list(o),
|
|
279
364
|
all: () => store.all(),
|
|
280
|
-
count: (type) => store.count(type),
|
|
365
|
+
count: (type, opts) => store.count(type, opts),
|
|
281
366
|
getById: (id) => store.getById(id),
|
|
282
367
|
remove: (id) => {
|
|
283
368
|
store.remove(id);
|
|
@@ -312,6 +397,35 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
312
397
|
scheduleEmbed(updated);
|
|
313
398
|
return updated;
|
|
314
399
|
},
|
|
400
|
+
// Compare-and-set update: applies the patch only when the row still carries
|
|
401
|
+
// `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
|
|
402
|
+
// can re-read and retry — the primitive that prevents lost updates across
|
|
403
|
+
// concurrent read-modify-write (see scripts/stress-dsh.js axis 3).
|
|
404
|
+
compareAndUpdate: (id, expectedUpdatedAt, patch, ctx = {}) => {
|
|
405
|
+
const old = store.getById(id);
|
|
406
|
+
const updated = store.compareAndUpdate(id, expectedUpdatedAt, patch);
|
|
407
|
+
if (updated === undefined) return undefined; // CAS miss: no write, no side effects
|
|
408
|
+
const hasMeaningfulChange = old && updated && (
|
|
409
|
+
old.content !== updated.content ||
|
|
410
|
+
old.title !== updated.title ||
|
|
411
|
+
old.importance !== updated.importance
|
|
412
|
+
);
|
|
413
|
+
if (hasMeaningfulChange && config.reflectionFailureTracking) {
|
|
414
|
+
store.saveFailure({
|
|
415
|
+
id: randomUUID(),
|
|
416
|
+
query: ctx.query ?? null,
|
|
417
|
+
expected: updated.content,
|
|
418
|
+
actual: old.content,
|
|
419
|
+
before: { title: old.title, content: old.content, importance: old.importance },
|
|
420
|
+
failure_type: "user_correction",
|
|
421
|
+
memory_id: id
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
syncMirror();
|
|
425
|
+
notifyWrite();
|
|
426
|
+
scheduleEmbed(updated);
|
|
427
|
+
return updated;
|
|
428
|
+
},
|
|
315
429
|
setForget: (id, f) => {
|
|
316
430
|
const updated = store.setForget(id, f);
|
|
317
431
|
syncMirror();
|