@modusensus/dsh-mneme 0.3.7 → 0.4.1
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 +30 -3
- package/lib/api.js +8 -0
- package/lib/config.js +28 -0
- package/lib/dream/decisions.js +79 -1
- package/lib/index.js +21 -0
- package/lib/service.js +103 -31
- package/lib/sleep.js +461 -0
- package/lib/store.js +186 -41
- package/package.json +1 -1
- package/src/api.js +8 -0
- package/src/config.js +28 -0
- package/src/dream/decisions.js +79 -1
- package/src/index.js +21 -0
- package/src/service.js +103 -31
- package/src/sleep.js +461 -0
- package/src/store.js +186 -41
- package/test/mirror-generation.test.js +24 -21
- package/test/peer-blockers.test.js +148 -0
- package/test/sleep.test.js +401 -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
|
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
- **SQLite 主存储**:`~/.dsh/memory/memory.db`,`node:sqlite` 内置,零原生依赖
|
|
19
19
|
- **Markdown 镜像**:`preferences.md` / `projects.md` / `decisions.md` / `history.md` / `summary.md`,人类可读、可手工编辑(**人工修改优先**合并回库)
|
|
20
20
|
- **4+1 种记忆类型**:`preference`(偏好)/ `project`(项目)/ `decision`(决策)/ `history`(历史)/ `summary`(总览)
|
|
21
|
+
- **镜像同步状态机(v0.3.6+)**:mirror 与主库强一致,用 `generation`(期望轮次)/ `applied_generation`(已应用轮次)建模同步债务
|
|
22
|
+
- 业务写操作在**自身事务内原子递增** desired generation——崩溃在 COMMIT 后、渲染前,重启也能凭 durable 债务恢复,绝不静默跳过(v0.3.8)
|
|
23
|
+
- `generation` 用 SQLite 原子语句递增,多进程并发零丢失;带 `CHECK` 上界,负数/溢出拒绝
|
|
24
|
+
- 逐 type 记录 `committed / failed / pending` 回执,健康端点区分 `ok / degraded / unknown`
|
|
25
|
+
- 状态写失败不静默:同步失败落日志并留债务,重启自动收敛
|
|
21
26
|
|
|
22
27
|
### 模型工具(7 个)
|
|
23
28
|
|
|
@@ -106,6 +111,28 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
106
111
|
|
|
107
112
|
> 📖 详见 [实体结构化记忆设计](docs/ENTITIES.md) · [语义增强架构](docs/SEMANTIC.md) · [本地模型部署指南](docs/LOCAL_MODEL.md) · [从 v0.1 升级说明](docs/MIGRATION.md)
|
|
108
113
|
|
|
114
|
+
## 🆕 最近版本亮点
|
|
115
|
+
|
|
116
|
+
| 版本 | 亮点 |
|
|
117
|
+
|------|------|
|
|
118
|
+
| **v0.3.8** | audit peer 复验 6 项运行时阻断全部修复:desired generation 同事务原子递增(崩溃窗口不再静默跳过)、同步失败不静默、原子 generation 增量(多进程零丢失)、逐 type committed/failed/pending 回执、读取失败显式 unknown、generation 上界/负数 CHECK |
|
|
119
|
+
| **v0.3.7** | 启动竞态修复:人工编辑 md 镜像后重启向量重建失败(回灌移入 init 就绪后 + scheduleEmbed 就绪门) |
|
|
120
|
+
| **v0.3.6** | mirror 同步状态机:generation/applied_generation 债务建模、F-NEW-03 mirror 健康状态、持久 dirty + 启动 recoverMirror |
|
|
121
|
+
| **v0.3.0** | 记忆基因:实体/属性/关系三表 + 时间轴 + 实体搜索 + autoDream supersedes |
|
|
122
|
+
|
|
123
|
+
## 🗺️ 进化路线图
|
|
124
|
+
|
|
125
|
+
| 版本 | 状态 | 主题 | 说明 |
|
|
126
|
+
|------|------|------|------|
|
|
127
|
+
| v0.2.x | ✅ 完成 | 语义增强 + 反思更新 | 本地 Embedding/Rerank/聚类、`failure_memories` 失败追踪 |
|
|
128
|
+
| v0.3.0 | ✅ 完成 | 记忆基因 | entities/attrs/relations 三表 + 时间轴 + 实体搜索 |
|
|
129
|
+
| v0.3.6–0.3.8 | ✅ 完成 | 镜像一致性 + 审计加固 | generation 同步状态机、audit peer 6 项运行时阻断修复、447 测试全绿 |
|
|
130
|
+
| **v0.4.0** | ⏳ 规划 | 反思性成长 | 纠错双向回流(改记忆同时反思"为什么记错/召回错")+ 规则演进(从 failure 提炼规律注入系统提示)+ 自适应参数 |
|
|
131
|
+
| **v0.4.1** | ✅ 完成 | 系统级睡眠 | Sleep 调度器(空闲检测 + 最小间隔,无 cron)+ 三层深度整理(冲突消解 / 归档降级 / 模式发现)+ create 决策 + 访问 touch 免生成号,471 测试全绿 |
|
|
132
|
+
| **v0.5.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
|
|
133
|
+
|
|
134
|
+
> 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为 v0.4.0 铺好路。
|
|
135
|
+
|
|
109
136
|
## 📦 安装
|
|
110
137
|
|
|
111
138
|
### 前置条件
|
|
@@ -243,7 +270,7 @@ src/
|
|
|
243
270
|
lib/
|
|
244
271
|
├── client.js # Web 面板(手写 ModuleLoader bundle)
|
|
245
272
|
└── *.js # src 的同步分发产物
|
|
246
|
-
test/ #
|
|
273
|
+
test/ # 447 个 node:test 测试(含审计与三轴线压测不变量)
|
|
247
274
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步
|
|
248
275
|
```
|
|
249
276
|
|
|
@@ -252,7 +279,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
252
279
|
```bash
|
|
253
280
|
cd dsh-mneme
|
|
254
281
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
255
|
-
npm test # 运行
|
|
282
|
+
npm test # 运行 447 个测试
|
|
256
283
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
257
284
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
258
285
|
```
|
package/lib/api.js
CHANGED
|
@@ -289,6 +289,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
289
289
|
sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
|
|
290
290
|
return;
|
|
291
291
|
}
|
|
292
|
+
// Real read failure surfaces as dirty === null (peer blocker 5): report
|
|
293
|
+
// unknown explicitly instead of collapsing into a false "ok"/"degraded".
|
|
294
|
+
if (state.dirty === null) {
|
|
295
|
+
sendJson(res, 200, {
|
|
296
|
+
mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
292
300
|
// Sanitized: boolean dirty + coarse status only; error string is mapped to
|
|
293
301
|
// a bounded code, never echoed verbatim.
|
|
294
302
|
let code = null;
|
package/lib/config.js
CHANGED
|
@@ -91,4 +91,32 @@ export const Config = z.object({
|
|
|
91
91
|
entityExtractionMaxAttrs: z.natural().min(1).max(50).default(20),
|
|
92
92
|
// Prefix/semantic search over entity names (used by recall).
|
|
93
93
|
entitySearchEnabled: z.boolean().default(true),
|
|
94
|
+
|
|
95
|
+
// --- system-level sleep (v0.4.1) -----------------------------------------
|
|
96
|
+
// Opt-in: when false (default) the plugin never runs a sleep cycle, so the
|
|
97
|
+
// access-touch bookkeeping on recall/inject paths stays off too. Sleep is
|
|
98
|
+
// three phases: conflict resolution (reuses the dream conflict machinery),
|
|
99
|
+
// archival demotion (unrecalled memories tier down to summary then archive),
|
|
100
|
+
// and pattern discovery (LLM scans recent memories and mints type=pattern
|
|
101
|
+
// entries with evidence references).
|
|
102
|
+
sleepEnabled: z.boolean().default(false),
|
|
103
|
+
// A sleep run only fires when the store has been idle for this long and the
|
|
104
|
+
// last run is older than sleepMinIntervalHours. Idle detection replaces a
|
|
105
|
+
// cron-like schedule (DSH plugins have no resident crontab).
|
|
106
|
+
sleepIdleMinutes: z.natural().min(1).max(1440).default(30),
|
|
107
|
+
sleepMinIntervalHours: z.natural().min(1).max(168).default(8),
|
|
108
|
+
// Unrecalled (COALESCE(last_accessed_at, updated_at, created_at)) beyond
|
|
109
|
+
// sleepArchiveDays → demote: full body moves to _full_content, content
|
|
110
|
+
// becomes a one-line summary. Beyond sleepDeepArchiveDays → archive.
|
|
111
|
+
sleepArchiveDays: z.natural().min(1).max(365).default(30),
|
|
112
|
+
sleepDeepArchiveDays: z.natural().min(1).max(3650).default(90),
|
|
113
|
+
// How many most-recent memories the pattern-discovery pass scans.
|
|
114
|
+
sleepPatternScanCount: z.natural().min(10).max(500).default(100),
|
|
115
|
+
// Max patterns minted per sleep run (mirrors decisions maxCreatePerRun).
|
|
116
|
+
sleepMaxPatterns: z.natural().min(1).max(20).default(5),
|
|
117
|
+
// Optional LLM route override; empty = fall back to agentDefaultModel then
|
|
118
|
+
// the dream route. Distinct from dreamProvider/dreamModel so the sleep pass
|
|
119
|
+
// can pin a cheaper model for its bulk summarization.
|
|
120
|
+
sleepProvider: z.string().default(""),
|
|
121
|
+
sleepModel: z.string().default(""),
|
|
94
122
|
});
|
package/lib/dream/decisions.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
|
|
1
|
+
const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update", "create"]);
|
|
2
|
+
|
|
3
|
+
// create is used by sleep pattern discovery (v0.4.1). It fabricates a new
|
|
4
|
+
// memory of any known type (default pattern) rather than touching existing ids.
|
|
5
|
+
const CREATE_TYPES = new Set(["pattern", "preference", "project", "decision", "history", "summary"]);
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* Validate a dream decision list against a snapshot of eligible memories.
|
|
@@ -9,6 +13,7 @@ const ACTIONS = new Set(["keep", "merge", "archive", "conflict", "update"]);
|
|
|
9
13
|
export function validateDecisions(decisions, snapshot, options = {}) {
|
|
10
14
|
const errors = [];
|
|
11
15
|
const maxUpdatePerRun = options.maxUpdatePerRun ?? 2;
|
|
16
|
+
const maxCreatePerRun = options.maxCreatePerRun ?? 5;
|
|
12
17
|
const minAgeHours = options.minAgeHours ?? 24;
|
|
13
18
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
14
19
|
return { ok: false, errors: ["decision list must be a non-empty array"] };
|
|
@@ -20,6 +25,35 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
20
25
|
errors.push(`${at}: invalid action ${JSON.stringify(d?.action)}`);
|
|
21
26
|
continue;
|
|
22
27
|
}
|
|
28
|
+
// create claims no existing id: it fabricates a new memory, so it runs its
|
|
29
|
+
// own field validation and skips the ids-required check + the claimed set.
|
|
30
|
+
if (d.action === "create") {
|
|
31
|
+
if (typeof d.title !== "string" || !d.title.trim()) {
|
|
32
|
+
errors.push(`${at}: create needs non-empty title`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (typeof d.content !== "string" || !d.content.trim()) {
|
|
36
|
+
errors.push(`${at}: create needs non-empty content`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (d.importance !== undefined && (!Number.isInteger(d.importance) || d.importance < 1 || d.importance > 5)) {
|
|
40
|
+
errors.push(`${at}: create importance must be an integer 1-5 when provided`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (d.type !== undefined && (typeof d.type !== "string" || !CREATE_TYPES.has(d.type))) {
|
|
44
|
+
errors.push(`${at}: create type must be one of ${[...CREATE_TYPES].join(", ")}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (d.evidence !== undefined && !Array.isArray(d.evidence)) {
|
|
48
|
+
errors.push(`${at}: create evidence must be an array of memory ids`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (d.tags !== undefined && !Array.isArray(d.tags)) {
|
|
52
|
+
errors.push(`${at}: create tags must be an array`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
23
57
|
const ids = d.action === "conflict" ? [d.winner, d.loser] : (d.ids ?? []);
|
|
24
58
|
if (d.action === "conflict") {
|
|
25
59
|
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
@@ -95,6 +129,12 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
95
129
|
if (updateCount > maxUpdatePerRun) {
|
|
96
130
|
errors.push(`too many update decisions: ${updateCount} > ${maxUpdatePerRun}`);
|
|
97
131
|
}
|
|
132
|
+
// Cap create churn: a pattern-discovery loop fabricating endless new memories
|
|
133
|
+
// would bloat the store, so a run can mint at most maxCreatePerRun.
|
|
134
|
+
const createCount = decisions.filter((d) => d.action === "create").length;
|
|
135
|
+
if (createCount > maxCreatePerRun) {
|
|
136
|
+
errors.push(`too many create decisions: ${createCount} > ${maxCreatePerRun}`);
|
|
137
|
+
}
|
|
98
138
|
// Every snapshot id must appear in at least one decision
|
|
99
139
|
for (const id of snapshot.keys()) {
|
|
100
140
|
if (!claimed.has(id)) errors.push(`memory ${JSON.stringify(id)} missing from decisions`);
|
|
@@ -202,10 +242,48 @@ function applyOne(d, service, snapshot, config = {}) {
|
|
|
202
242
|
case "archive": return applyArchive(d, service, snapshot);
|
|
203
243
|
case "merge": return applyMerge(d, service, snapshot, config);
|
|
204
244
|
case "conflict": return applyConflict(d, service, snapshot);
|
|
245
|
+
case "create": return applyCreate(d, service, snapshot);
|
|
205
246
|
default: return applyUpdate(d, service, snapshot, config);
|
|
206
247
|
}
|
|
207
248
|
}
|
|
208
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Mint a new memory (sleep pattern discovery). saveWithDedupe dedupes by
|
|
252
|
+
* (type, title) so a replayed create merges instead of duplicating — the
|
|
253
|
+
* idempotency guard. Evidence ids are folded into tags as `ev:<id>` so a
|
|
254
|
+
* pattern's provenance stays queryable after creation.
|
|
255
|
+
*/
|
|
256
|
+
function applyCreate(d, service, snapshot) {
|
|
257
|
+
const evidence = Array.isArray(d.evidence) ? d.evidence : [];
|
|
258
|
+
const tags = [
|
|
259
|
+
...(Array.isArray(d.tags) ? d.tags : []),
|
|
260
|
+
...evidence.map((id) => `ev:${id}`)
|
|
261
|
+
];
|
|
262
|
+
const result = service.saveWithDedupe({
|
|
263
|
+
type: d.type ?? "pattern",
|
|
264
|
+
title: d.title,
|
|
265
|
+
content: d.content,
|
|
266
|
+
importance: d.importance ?? 3,
|
|
267
|
+
tags,
|
|
268
|
+
source: "dream-create"
|
|
269
|
+
});
|
|
270
|
+
if (!result?.memory) return "skipped";
|
|
271
|
+
return {
|
|
272
|
+
applied: 1,
|
|
273
|
+
committed: {
|
|
274
|
+
action: "create",
|
|
275
|
+
id: result.memory.id,
|
|
276
|
+
type: d.type ?? "pattern",
|
|
277
|
+
title: d.title,
|
|
278
|
+
content: d.content,
|
|
279
|
+
importance: d.importance ?? 3,
|
|
280
|
+
evidence,
|
|
281
|
+
count_before: 0,
|
|
282
|
+
count_after: 1
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
209
287
|
function applyArchive(d, service, snapshot) {
|
|
210
288
|
const targets = d.ids.filter((id) => {
|
|
211
289
|
const mem = service.getById(id);
|
package/lib/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createTools } from "./tools.js";
|
|
|
5
5
|
import { createInjector } from "./inject.js";
|
|
6
6
|
import { createSummarizer } from "./summarize.js";
|
|
7
7
|
import { createDreamScheduler } from "./dream.js";
|
|
8
|
+
import { createSleepScheduler, runSleep } from "./sleep.js";
|
|
8
9
|
import { createApi } from "./api.js";
|
|
9
10
|
import { createSettings } from "./settings.js";
|
|
10
11
|
import { createCommandManager } from "./commands.js";
|
|
@@ -181,6 +182,25 @@ export const apply = (ctx, config) => {
|
|
|
181
182
|
service.setDreamHook(() => dream.maybeSchedule(service));
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Sleep scheduler (v0.4.1): idle-triggered deep pass (conflict resolution +
|
|
186
|
+
// archival demotion + pattern discovery). opt-in via sleepEnabled; writes
|
|
187
|
+
// through the service reset the idle clock (setSleepHook), and when the store
|
|
188
|
+
// stays quiet for sleepIdleMinutes past the sleepMinIntervalHours gate, the
|
|
189
|
+
// scheduler fires one cycle. The onRun closure reuses the same semantic
|
|
190
|
+
// pipeline as dream for the conflict phase.
|
|
191
|
+
let sleep = null;
|
|
192
|
+
if (cfg.sleepEnabled) {
|
|
193
|
+
sleep = createSleepScheduler({
|
|
194
|
+
service,
|
|
195
|
+
config: cfg,
|
|
196
|
+
logger: ctx.logger,
|
|
197
|
+
onRun: () => (sleep
|
|
198
|
+
? runSleep(ctx, service, cfg, ctx.logger, { embedder, vectorIndex })
|
|
199
|
+
: Promise.resolve({ ok: true, skipped: true }))
|
|
200
|
+
});
|
|
201
|
+
service.setSleepHook(() => sleep.noteWrite());
|
|
202
|
+
}
|
|
203
|
+
|
|
184
204
|
// Entity gene extraction (v0.3.0): wire the extractor into the service as a
|
|
185
205
|
// hook so saveWithDedupe can fire-and-forget an extraction pass on fresh
|
|
186
206
|
// writes. The service never sees ctx.llm — index.js adapts it here into the
|
|
@@ -251,6 +271,7 @@ export const apply = (ctx, config) => {
|
|
|
251
271
|
}
|
|
252
272
|
commands?.dispose();
|
|
253
273
|
if (dream) await dream.dispose();
|
|
274
|
+
if (sleep) await sleep.dispose();
|
|
254
275
|
store.close();
|
|
255
276
|
};
|
|
256
277
|
};
|
package/lib/service.js
CHANGED
|
@@ -9,6 +9,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
9
9
|
// passed in the constructor). Fired on the same write events as onWrite.
|
|
10
10
|
let dreamHook = null;
|
|
11
11
|
|
|
12
|
+
// Optional sleep scheduler hook (v0.4.1), installed via setSleepHook after
|
|
13
|
+
// creation. Fired on the same write events: it tells the sleep scheduler the
|
|
14
|
+
// store just changed so the idle-detection clock resets.
|
|
15
|
+
let sleepHook = null;
|
|
16
|
+
|
|
12
17
|
// Optional vector embedder, installed via setEmbedder after creation. After
|
|
13
18
|
// any content write it fire-and-forgets a re-embed of the row so vector
|
|
14
19
|
// search stays in sync; failures are swallowed inside the embedder.
|
|
@@ -37,6 +42,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
37
42
|
// replays them exactly once against the committed state.
|
|
38
43
|
let txDepth = 0;
|
|
39
44
|
|
|
45
|
+
// Serial task queue (sleep v0.4.1). Long-running background passes — dream
|
|
46
|
+
// consolidation, sleep cycles — must never overlap: two sleep runs racing
|
|
47
|
+
// would double-demote or double-mint patterns. enqueue chains the task onto
|
|
48
|
+
// a promise tail so N callers can queue work that runs strictly one at a
|
|
49
|
+
// time. A task that rejects doesn't poison the queue (the tail swallows the
|
|
50
|
+
// rejection) but the rejection still propagates to that caller.
|
|
51
|
+
let queueTail = Promise.resolve();
|
|
52
|
+
function enqueue(fn) {
|
|
53
|
+
const next = queueTail.then(fn, fn);
|
|
54
|
+
queueTail = next.catch(() => {});
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
// issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
|
|
41
59
|
// Ollama) exposes an async init(), so between `setEmbedder` and init()
|
|
42
60
|
// resolving there is a window where embedSingle would throw "not initialized"
|
|
@@ -243,11 +261,15 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
243
261
|
// entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
|
|
244
262
|
if (config?.entitySearchEnabled) {
|
|
245
263
|
if (q.startsWith("entity:")) {
|
|
246
|
-
|
|
264
|
+
const hits = searchByEntity(q.slice(7).trim(), options);
|
|
265
|
+
touchRecalled(hits);
|
|
266
|
+
return hits;
|
|
247
267
|
}
|
|
248
268
|
if (q.startsWith("attr:")) {
|
|
249
269
|
const [key, value] = q.slice(5).split("=");
|
|
250
|
-
|
|
270
|
+
const hits = searchByAttr(key, value, options);
|
|
271
|
+
touchRecalled(hits);
|
|
272
|
+
return hits;
|
|
251
273
|
}
|
|
252
274
|
}
|
|
253
275
|
|
|
@@ -345,6 +367,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
345
367
|
});
|
|
346
368
|
} catch { /* recall receipt is best effort */ }
|
|
347
369
|
}
|
|
370
|
+
touchRecalled(result);
|
|
348
371
|
return result;
|
|
349
372
|
}
|
|
350
373
|
|
|
@@ -362,6 +385,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
362
385
|
if (dreamHook) {
|
|
363
386
|
try { dreamHook(); } catch { /* ignore */ }
|
|
364
387
|
}
|
|
388
|
+
if (sleepHook) {
|
|
389
|
+
try { sleepHook(); } catch { /* ignore */ }
|
|
390
|
+
}
|
|
365
391
|
}
|
|
366
392
|
|
|
367
393
|
/**
|
|
@@ -384,10 +410,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
384
410
|
throw error;
|
|
385
411
|
} finally {
|
|
386
412
|
txDepth--;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
413
|
+
// Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
|
|
414
|
+
// debt was already recorded by markMirrorDirty inside syncMirror, so a
|
|
415
|
+
// restart recovers — but the operator must see it now, not after restart.
|
|
416
|
+
const syncResult = syncMirror();
|
|
417
|
+
if (!syncResult?.success && !syncResult?.deferred) {
|
|
418
|
+
logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
|
|
391
419
|
}
|
|
392
420
|
notifyWrite();
|
|
393
421
|
}
|
|
@@ -408,7 +436,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
408
436
|
tags: memory.tags ?? existing.tags,
|
|
409
437
|
title: memory.title ?? existing.title
|
|
410
438
|
});
|
|
411
|
-
|
|
439
|
+
afterSync("write");
|
|
412
440
|
notifyWrite();
|
|
413
441
|
scheduleEmbed(merged);
|
|
414
442
|
return { action: "merged", memory: merged };
|
|
@@ -421,13 +449,30 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
421
449
|
importance: memory.importance ?? 3,
|
|
422
450
|
source: memory.source ?? "manual"
|
|
423
451
|
});
|
|
424
|
-
|
|
452
|
+
afterSync("write");
|
|
425
453
|
notifyWrite();
|
|
426
454
|
scheduleEmbed(created);
|
|
427
455
|
scheduleEntityExtraction(created);
|
|
428
456
|
return { action: "created", memory: created };
|
|
429
457
|
}
|
|
430
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Sleep touch (v0.4.1): when sleep is enabled, any memory surfaced by recall
|
|
461
|
+
* or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
|
|
462
|
+
* days → demote/archive" tiering counts real access. Best-effort and gated on
|
|
463
|
+
* config.sleepEnabled — when sleep is off this is a complete no-op (no writes
|
|
464
|
+
* on the hot recall path). A touch failure must never break search/inject.
|
|
465
|
+
*/
|
|
466
|
+
function touchRecalled(memories) {
|
|
467
|
+
if (config?.sleepEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
|
|
468
|
+
for (const m of memories) {
|
|
469
|
+
if (!m?.id) continue;
|
|
470
|
+
try {
|
|
471
|
+
store.touchAccess(m.id);
|
|
472
|
+
} catch { /* touch is best effort */ }
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
431
476
|
/**
|
|
432
477
|
* Candidate memories for automatic context injection:
|
|
433
478
|
* summaries first, then all preferences, then non-forgotten items with
|
|
@@ -444,7 +489,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
444
489
|
const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
|
|
445
490
|
return pa - pb || b.importance - a.importance;
|
|
446
491
|
});
|
|
447
|
-
|
|
492
|
+
const selected = items.slice(0, maxItems);
|
|
493
|
+
touchRecalled(selected);
|
|
494
|
+
return selected;
|
|
448
495
|
}
|
|
449
496
|
|
|
450
497
|
/**
|
|
@@ -481,7 +528,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
481
528
|
}
|
|
482
529
|
}
|
|
483
530
|
if (applied) {
|
|
484
|
-
|
|
531
|
+
afterSync("write");
|
|
485
532
|
notifyWrite();
|
|
486
533
|
}
|
|
487
534
|
return applied;
|
|
@@ -577,16 +624,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
577
624
|
// - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
|
|
578
625
|
// - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
|
|
579
626
|
function syncMirror() {
|
|
580
|
-
if (txDepth > 0 || !mirror) return; // deferred to the transaction's commit
|
|
627
|
+
if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
|
|
581
628
|
const now = new Date().toISOString();
|
|
582
629
|
let gen;
|
|
583
630
|
try {
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
631
|
+
// desired generation 已在业务写事务中原子递增(peer blocker 1);这里
|
|
632
|
+
// 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration。
|
|
633
|
+
const state = store.getMirrorState();
|
|
634
|
+
gen = state?.generation ?? 0;
|
|
587
635
|
} catch (stateError) {
|
|
588
|
-
logger?.warn?.("syncMirror:
|
|
589
|
-
return;
|
|
636
|
+
logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
|
|
637
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
590
638
|
}
|
|
591
639
|
// coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
|
|
592
640
|
// 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
|
|
@@ -603,37 +651,53 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
603
651
|
// 全量渲染
|
|
604
652
|
mirror.sync(reconcileHumanEdits(list));
|
|
605
653
|
|
|
606
|
-
// 成功:CAS/fence 绑定到本地 gen,旧 worker(gen
|
|
654
|
+
// 成功:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被拦截。
|
|
655
|
+
// 此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
|
|
607
656
|
try {
|
|
608
657
|
store.markMirrorCleanForGeneration(gen, now);
|
|
609
658
|
} catch (stateError) {
|
|
610
659
|
logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
|
|
660
|
+
return { success: false, error: stateError?.message ?? String(stateError) };
|
|
611
661
|
}
|
|
612
|
-
// 逐 type 标记为
|
|
662
|
+
// 逐 type 标记为 committed(peer blocker 4: per-type receipt)
|
|
613
663
|
for (const type of coveredTypes) {
|
|
614
664
|
try {
|
|
615
|
-
store.setTypeStatus(type, {
|
|
665
|
+
store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
|
|
616
666
|
} catch (stateError) {
|
|
617
|
-
logger?.warn?.(`syncMirror: setTypeStatus(${type})
|
|
667
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) committed failed:`, stateError);
|
|
618
668
|
}
|
|
619
669
|
}
|
|
670
|
+
return { success: true };
|
|
620
671
|
} catch (error) {
|
|
621
672
|
const errMsg = error?.message ?? String(error);
|
|
622
673
|
logger?.warn?.("syncMirror failed:", error);
|
|
623
674
|
try {
|
|
624
|
-
// 债务绑定到新的一轮(desired generation
|
|
675
|
+
// 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
|
|
676
|
+
// generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
|
|
625
677
|
store.markMirrorDirty(errMsg, now);
|
|
626
678
|
} catch (stateError) {
|
|
627
679
|
logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
|
|
628
680
|
}
|
|
629
|
-
// 逐 type 标记为
|
|
681
|
+
// 逐 type 标记为 failed(applied_gen 不动)
|
|
630
682
|
for (const type of coveredTypes) {
|
|
631
683
|
try {
|
|
632
|
-
store.setTypeStatus(type, {
|
|
684
|
+
store.setTypeStatus(type, { status: "failed", last_error: errMsg });
|
|
633
685
|
} catch (stateError) {
|
|
634
|
-
logger?.warn?.(`syncMirror: setTypeStatus(${type})
|
|
686
|
+
logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
|
|
635
687
|
}
|
|
636
688
|
}
|
|
689
|
+
return { success: false, error: errMsg };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// afterSync: run syncMirror and surface a failure to the operator instead of
|
|
694
|
+
// swallowing it (peer blocker 2). The mirror debt has already been persisted
|
|
695
|
+
// by markMirrorDirty inside syncMirror, so a restart recovers — but the
|
|
696
|
+
// calling write path must not report clean while the mirror is known-stale.
|
|
697
|
+
function afterSync(label) {
|
|
698
|
+
const r = syncMirror();
|
|
699
|
+
if (!r?.success && !r?.deferred) {
|
|
700
|
+
logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
|
|
637
701
|
}
|
|
638
702
|
}
|
|
639
703
|
|
|
@@ -718,10 +782,11 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
718
782
|
success_at: state.success_at ?? null
|
|
719
783
|
};
|
|
720
784
|
} catch (error) {
|
|
721
|
-
// fail-safe
|
|
785
|
+
// fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
|
|
786
|
+
// 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
|
|
722
787
|
logger?.warn?.("getMirrorHealth failed:", error);
|
|
723
788
|
return {
|
|
724
|
-
dirty:
|
|
789
|
+
dirty: null,
|
|
725
790
|
last_error: error?.message ?? String(error),
|
|
726
791
|
last_attempt: null,
|
|
727
792
|
success_at: null
|
|
@@ -738,7 +803,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
738
803
|
mergeHumanEdits,
|
|
739
804
|
toApiList,
|
|
740
805
|
transaction,
|
|
806
|
+
enqueue,
|
|
741
807
|
setDreamHook(fn) { dreamHook = fn; },
|
|
808
|
+
setSleepHook(fn) { sleepHook = fn; },
|
|
742
809
|
setEmbedder(emb) {
|
|
743
810
|
embedder = emb;
|
|
744
811
|
if (!emb) {
|
|
@@ -780,7 +847,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
780
847
|
getById: (id) => store.getById(id),
|
|
781
848
|
remove: (id) => {
|
|
782
849
|
store.remove(id);
|
|
783
|
-
|
|
850
|
+
afterSync("write");
|
|
784
851
|
notifyWrite();
|
|
785
852
|
},
|
|
786
853
|
update: (id, p, ctx = {}) => {
|
|
@@ -806,7 +873,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
806
873
|
memory_id: id
|
|
807
874
|
});
|
|
808
875
|
}
|
|
809
|
-
|
|
876
|
+
afterSync("write");
|
|
810
877
|
notifyWrite();
|
|
811
878
|
scheduleEmbed(updated);
|
|
812
879
|
return updated;
|
|
@@ -835,19 +902,24 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
835
902
|
memory_id: id
|
|
836
903
|
});
|
|
837
904
|
}
|
|
838
|
-
|
|
905
|
+
afterSync("write");
|
|
839
906
|
notifyWrite();
|
|
840
907
|
scheduleEmbed(updated);
|
|
841
908
|
return updated;
|
|
842
909
|
},
|
|
843
910
|
setForget: (id, f) => {
|
|
844
911
|
const updated = store.setForget(id, f);
|
|
845
|
-
|
|
912
|
+
afterSync("write");
|
|
846
913
|
return updated;
|
|
847
914
|
},
|
|
848
915
|
setArchived: (id, f) => {
|
|
849
916
|
const updated = store.setArchived(id, f);
|
|
850
|
-
|
|
917
|
+
afterSync("write");
|
|
918
|
+
return updated;
|
|
919
|
+
},
|
|
920
|
+
demoteToSummary: (id, summary, opts) => {
|
|
921
|
+
const updated = store.demoteToSummary(id, summary, opts);
|
|
922
|
+
afterSync("write");
|
|
851
923
|
return updated;
|
|
852
924
|
},
|
|
853
925
|
// autoDream audit trail: passthroughs deliberately bypass write hooks —
|