@modusensus/dsh-mneme 0.5.3 → 0.6.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/README.md +19 -4
- package/lib/config.js +5 -0
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +2 -2
- package/lib/index.js +20 -0
- package/lib/service.js +29 -1
- package/lib/store.js +65 -6
- package/lib/tools.js +16 -5
- package/package.json +1 -1
- package/src/config.js +5 -0
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +2 -2
- package/src/index.js +20 -0
- package/src/service.js +29 -1
- package/src/store.js +65 -6
- package/src/tools.js +16 -5
- package/test/service.test.js +106 -0
- package/test/store.test.js +76 -0
- package/test/tools.test.js +20 -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
|
|
|
@@ -30,6 +30,7 @@ dsh web
|
|
|
30
30
|
- 不是向量数据库——语义搜索是可选增强,默认零额外依赖
|
|
31
31
|
- 不替代会话日志——它存的是「值得跨会话记住的」精炼知识
|
|
32
32
|
- 不改变模型本身——进化的是记忆库与每次注入的上下文
|
|
33
|
+
- 删对话 ≠ 删记忆——开启会话生命周期后,删除会话只是把该会话出生的记忆**软隐藏**(可恢复),数据不丢
|
|
33
34
|
|
|
34
35
|
## ✨ 功能
|
|
35
36
|
|
|
@@ -52,7 +53,7 @@ dsh web
|
|
|
52
53
|
| `memory_search` | 全文搜索(中文子串友好,可启用向量语义搜索) |
|
|
53
54
|
| `memory_list` | 按类型分页列出(`include_archived=true` 可查看已归档) |
|
|
54
55
|
| `memory_update` | 修改已有记忆 |
|
|
55
|
-
| `memory_delete` |
|
|
56
|
+
| `memory_delete` | 删除记忆(v0.6.0 支持 `query` 按描述删除,不只能靠记忆 ID) |
|
|
56
57
|
| `memory_forget` | 抑制注入(降权不删除,可恢复) |
|
|
57
58
|
| `memory_archive` | 归档/恢复记忆(v0.2.5;归档后隐藏于列表/搜索/注入/整理,`archived=false` 可恢复) |
|
|
58
59
|
|
|
@@ -102,7 +103,16 @@ dsh web
|
|
|
102
103
|
|
|
103
104
|
> 配置详见 `docs/SLEEP.md`;迁移说明见 `docs/MIGRATION.md`。
|
|
104
105
|
|
|
105
|
-
###
|
|
106
|
+
### 会话生命周期:把对话当存档点 💾(v0.6.0,opt-in)
|
|
107
|
+
|
|
108
|
+
**默认关闭**(`sessionLifecycleEnabled: false`,保持旧行为)。开启后,会话被删除/销毁(DSH `session/disposed` 事件)时,自动把该会话内出生(`session_id` 溯源)的记忆**软隐藏**——不再出现在检索/注入/列表/整理,但**不删除**,随时可恢复:
|
|
109
|
+
|
|
110
|
+
- **与 `archived` 正交**:`archived` 是用户/AI 主动"长期保留但安静",`session_disposed_at` 是会话删除被动隔离,两者互不覆盖。恢复会话绝不复活你手动归档的记忆
|
|
111
|
+
- **全局记忆免疫**:存量无 `session_id` 的记忆视为全局,永不参与会话清理
|
|
112
|
+
- **幂等 + 熔断**:dispose/restore 状态守卫幂等(重复调用 no-op);事件回调内部异常 catch 住,不抛进 DSH 会话清理流程
|
|
113
|
+
- **恢复**:整会话 `service.restoreBySession(sessionId)` 一键还原;`service.listBySession(sessionId, { includeDisposed: true })` 可查看当前隐藏了哪些(DTO 带 `disposed` 标记)
|
|
114
|
+
|
|
115
|
+
|
|
106
116
|
|
|
107
117
|
官方设置面板 → 「记忆库设置」→「记忆」标签:按类型浏览、全文搜索;启用向量搜索后可用「语义」切换做向量召回。
|
|
108
118
|
|
|
@@ -202,6 +212,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
202
212
|
|
|
203
213
|
| 版本 | 亮点 |
|
|
204
214
|
|------|------|
|
|
215
|
+
| **v0.6.0** | 会话生命周期(把对话当存档点):`session_disposed_at` 独立字段软隐藏会话删除的记忆(与 `archived` 正交,可恢复)+ `memory_delete` 支持描述删除 + 事件订阅熔断;阿里云 kimi-k2.7-code 审查 4 项修复;628 测试全绿 |
|
|
205
216
|
| **v0.5.0** | 主区「记忆库」视图(取代侧边栏抽屉)+ 记忆图谱可视化(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准评测;593 测试全绿 |
|
|
206
217
|
| **v0.4.2** | autoSummarize 自定义模型:`summarizeProvider`/`summarizeModel` 配置项,可独立指定轻量模型(如 qwen3.6-plus)用于会话摘要,节省主模型 token;473 测试全绿 |
|
|
207
218
|
| **v0.4.0** | 系统级睡眠 Sleep Mode:空闲触发的四阶段深度维护(冲突消解 / 归档降级 / 模式发现 / 关系补全),可中断、串行安全、fail-safe,分层压缩释放冷记忆;471 测试全绿 |
|
|
@@ -227,7 +238,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
227
238
|
| **v0.4.6** | ✅ 完成 | 8 项修复(向量链路 + 注入/质量/审计) | 向量链路修复(embedSingle 适配 / `autoReindexOnBoot` 存量回填 / `vector_meta` 元数据)+ 注入语义召回 `hybridInject` + 同标题追加 `content_history` + 注入长度上限(单条 300 / 整块 1500)+ 记忆质量过滤 `memoryQualityFilter` + LLM 消耗审计 `llmAudit`(表 + 埋点 + 只读 API);553 测试全绿 |
|
|
228
239
|
| **v0.4.7** | ✅ 完成 | schema 迁移幂等化 | 并发打开同一 db 时 `PRAGMA table_info` 检查与 ALTER 非原子,可能重复 `ADD COLUMN` 报 duplicate column name;改用 `addColumn` helper 吞掉竞态(try/catch),12 处迁移统一收口 |
|
|
229
240
|
| **v0.5.0** | ✅ 完成 | 召回融合与记忆可视化 | 主区「记忆库」视图 + 记忆图谱(ego-graph API + 零依赖 SVG 力导向)+ BM25 三路召回融合 + 自适应阈值 + 会话热记忆 + 召回基准;593 测试全绿 |
|
|
230
|
-
| **v0.6.0
|
|
241
|
+
| **v0.6.0** | ✅ 完成 | 会话生命周期 | 把对话当存档点:`session_disposed_at` 软隐藏会话删除的记忆(与 `archived` 正交、可恢复)+ `memory_delete` 描述删除 + 事件熔断;628 测试全绿 |
|
|
242
|
+
| **v0.7.0+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + 跨 workspace 记忆共享(等 DSH 支持) |
|
|
231
243
|
|
|
232
244
|
> 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
|
|
233
245
|
|
|
@@ -280,6 +292,8 @@ dsh web
|
|
|
280
292
|
dreamThresholdCount: 10
|
|
281
293
|
dreamThresholdChars: 5000
|
|
282
294
|
dreamDelayMs: 2000
|
|
295
|
+
# 会话生命周期(v0.6.0,默认关):会话被删时软隐藏其记忆,可恢复
|
|
296
|
+
# sessionLifecycleEnabled: false
|
|
283
297
|
```
|
|
284
298
|
|
|
285
299
|
## ⚙️ 配置
|
|
@@ -342,6 +356,7 @@ dsh web
|
|
|
342
356
|
| `searchSemanticDedupThreshold` | `0.95` | 语义去重相似度阈值(v0.5.0,默认 0.95,范围 0.5-1.0):`searchSemanticDedup=true` 时生效,调整可防小模型误折叠 |
|
|
343
357
|
| `memoryQualityFilter` | `{enabled:true, archiveThreshold:30, degradeThreshold:60, minContentLength:10}` | 记忆质量过滤(v0.4.6,默认开):写库前启发式打分 0-100,元记忆词汇/自指/过短/重复/近似重复扣分;≥60 正常存储,30-60 降权(注入排序按 importance×quality/100),<30 归档标记 `low_quality`(显式搜索仍可召回,永不自动注入) |
|
|
344
358
|
| `llmAudit` | `{enabled:true, retentionDays:90}` | LLM 消耗审计(v0.4.6,默认开):每次后台 LLM 调用(autoDream/autoSummarize)写 `llm_audit_logs`(tokens/duration/status/source);失败记 error 不阻塞;只读 API `/api/dsh-mneme/semantic/llm-audit` + `/llm-audit/stats` |
|
|
359
|
+
| `sessionLifecycleEnabled` | `false` | 会话生命周期(v0.6.0,默认关):开启后会话被删除/销毁时自动把该会话出生的记忆软隐藏(`session_disposed_at`,与 `archived` 正交、可恢复);存量无 `session_id` 的记忆永不参与清理 |
|
|
345
360
|
|
|
346
361
|
> 🔐 **API 安全**:DSH 无内置鉴权且默认仅监听 `127.0.0.1`。插件 API 默认开放(便于 Web 面板即装即用)。如需防护(如局域网暴露),在配置中设置 `apiToken`:写操作(画像/规则/命令)与密钥端点(`vector-config`、`vector-reindex`)需携带 `Authorization: Bearer <token>`(前端设置面板可填入同一 token),只读的 `list` / `search` / `semantic` 保持开放。`/api/dsh-mneme/vector-config` 返回的 `apiKey` 已掩码(`sk-***…`),存储仍保留明文供调用;前端回传空或掩码值表示"不改 key"。
|
|
347
362
|
|
package/lib/config.js
CHANGED
|
@@ -4,6 +4,11 @@ export const Config = z.object({
|
|
|
4
4
|
memoryDir: z.string().default("~/.dsh/memory"),
|
|
5
5
|
autoInject: z.boolean().default(true),
|
|
6
6
|
autoSummarize: z.boolean().default(true),
|
|
7
|
+
// Session lifecycle (v0.6.0): when enabled, deleting/disposing a session also
|
|
8
|
+
// archives every memory that was born in it (treating the session as a save
|
|
9
|
+
// point — entries stay recoverable via memory_archive/restoreBySession).
|
|
10
|
+
// Default OFF: legacy behavior, a disposed session leaves its memories active.
|
|
11
|
+
sessionLifecycleEnabled: z.boolean().default(false),
|
|
7
12
|
// Optional model override for summarization. When both are non-empty, they
|
|
8
13
|
// take priority over the session's current model. Empty = use the session's
|
|
9
14
|
// active provider/model (same as before).
|
package/lib/dream/sleep.js
CHANGED
|
@@ -108,7 +108,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
108
108
|
}
|
|
109
109
|
const strictness = config.sleepConflictStrictness ?? "normal";
|
|
110
110
|
const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
|
|
111
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
|
|
111
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten && m.type !== "summary");
|
|
112
112
|
if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
|
|
113
113
|
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
114
114
|
|
|
@@ -239,7 +239,7 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
|
239
239
|
const archived = [];
|
|
240
240
|
for (const m of service.all()) {
|
|
241
241
|
if (signal?.aborted) break;
|
|
242
|
-
if (m.archived || m.forgotten) continue;
|
|
242
|
+
if (m.archived || m.forgotten || m.session_disposed_at) continue;
|
|
243
243
|
const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
|
|
244
244
|
if (!ref) continue;
|
|
245
245
|
const t = new Date(ref).getTime();
|
|
@@ -273,7 +273,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
273
273
|
const limit = config.sleepPatternMinMemories ?? 100;
|
|
274
274
|
const memories = service
|
|
275
275
|
.list({ limit: 200, includeForgotten: false })
|
|
276
|
-
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
276
|
+
.filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary" && m.type !== "pattern")
|
|
277
277
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
278
278
|
.slice(0, limit);
|
|
279
279
|
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
@@ -342,7 +342,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
|
|
|
342
342
|
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
343
343
|
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
344
344
|
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
345
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
345
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten);
|
|
346
346
|
const seen = new Set();
|
|
347
347
|
const related = [];
|
|
348
348
|
const MAX_RELATIONS_PER_ORPHAN = 3;
|
package/lib/dream.js
CHANGED
|
@@ -495,7 +495,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
495
495
|
let inFlight = null;
|
|
496
496
|
|
|
497
497
|
function shouldTrigger(service) {
|
|
498
|
-
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
498
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary");
|
|
499
499
|
const count = memories.length;
|
|
500
500
|
const chars = totalChars(memories);
|
|
501
501
|
const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
|
|
@@ -844,7 +844,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
844
844
|
: {}),
|
|
845
845
|
messages: [
|
|
846
846
|
{ role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
|
|
847
|
-
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
847
|
+
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
848
848
|
]
|
|
849
849
|
}, reportUsage));
|
|
850
850
|
} catch (error) {
|
package/lib/index.js
CHANGED
|
@@ -317,6 +317,26 @@ export const apply = (ctx, config) => {
|
|
|
317
317
|
const summarizer = createSummarizer(ctx, service, cfg);
|
|
318
318
|
disposers.push(summarizer.dispose);
|
|
319
319
|
|
|
320
|
+
// Session lifecycle (v0.6.0): when a session leaves the store and the toggle
|
|
321
|
+
// is enabled, mark every memory born in it as session-disposed (hidden from
|
|
322
|
+
// injection/search/dream but never destroyed — recoverable via
|
|
323
|
+
// restoreBySession). Default off, so a disposed session leaves its memories
|
|
324
|
+
// active (legacy behavior). Every path is guarded: a failure inside the
|
|
325
|
+
// callback must never propagate into DSH's session teardown (that would crash
|
|
326
|
+
// the plugin on the very delete action it serves).
|
|
327
|
+
if (cfg.sessionLifecycleEnabled) {
|
|
328
|
+
disposers.push(ctx.on("session/disposed", (session) => {
|
|
329
|
+
const sessionId = session?.id;
|
|
330
|
+
if (!sessionId) return;
|
|
331
|
+
try {
|
|
332
|
+
const { disposed } = service.disposeBySession(sessionId);
|
|
333
|
+
ctx.logger?.info?.(`[dsh-mneme] session disposed, hid ${disposed} memory(s) for ${sessionId}`);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
ctx.logger?.warn?.(`[dsh-mneme] session dispose failed for ${sessionId}: ${String(error)}`);
|
|
336
|
+
}
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
if (ctx.webServer) {
|
|
321
341
|
const api = createApi(ctx, service, settings, commands ?? {
|
|
322
342
|
add: () => { throw new Error("commands unavailable"); },
|
package/lib/service.js
CHANGED
|
@@ -331,7 +331,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
331
331
|
function bm25Recall(q, limit) {
|
|
332
332
|
if (config?.bm25SearchEnabled === false) return [];
|
|
333
333
|
try {
|
|
334
|
-
const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
|
|
334
|
+
const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived && !m.session_disposed_at);
|
|
335
335
|
if (!docs.length) return [];
|
|
336
336
|
return createBM25Index(docs).search(q, { limit });
|
|
337
337
|
} catch {
|
|
@@ -999,6 +999,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
999
999
|
tags: m.tags,
|
|
1000
1000
|
importance: m.importance,
|
|
1001
1001
|
source: m.source,
|
|
1002
|
+
// session_id is optional on the wire: only carry it when present, so the
|
|
1003
|
+
// DTO stays a lossless JSON object (undefined would vanish on serialize).
|
|
1004
|
+
...(m.session_id != null ? { session_id: m.session_id } : {}),
|
|
1005
|
+
// Disposed state rides along when set, so a restore flow is not a blind
|
|
1006
|
+
// op — the caller can see which entries are hidden before restoreBySession.
|
|
1007
|
+
...(m.session_disposed_at != null ? { disposed: true } : {}),
|
|
1002
1008
|
created_at: m.created_at,
|
|
1003
1009
|
updated_at: m.updated_at
|
|
1004
1010
|
}));
|
|
@@ -1339,6 +1345,28 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1339
1345
|
afterSync("write");
|
|
1340
1346
|
notifyWrite();
|
|
1341
1347
|
},
|
|
1348
|
+
// Session lifecycle (v0.6.0): mark/clear the session-disposed state on every
|
|
1349
|
+
// memory born in a given session. Uses the dedicated `session_disposed_at`
|
|
1350
|
+
// column, orthogonal to `archived` — restoring a session never resurrects
|
|
1351
|
+
// memories the user archived on purpose. Nothing is destroyed; a session
|
|
1352
|
+
// treated as a save point is fully recoverable via restoreBySession.
|
|
1353
|
+
disposeBySession: (sessionId) => {
|
|
1354
|
+
const disposed = store.setDisposedBySession(sessionId, true);
|
|
1355
|
+
if (disposed > 0) {
|
|
1356
|
+
afterSync("write");
|
|
1357
|
+
notifyWrite();
|
|
1358
|
+
}
|
|
1359
|
+
return { disposed };
|
|
1360
|
+
},
|
|
1361
|
+
restoreBySession: (sessionId) => {
|
|
1362
|
+
const restored = store.setDisposedBySession(sessionId, false);
|
|
1363
|
+
if (restored > 0) {
|
|
1364
|
+
afterSync("write");
|
|
1365
|
+
notifyWrite();
|
|
1366
|
+
}
|
|
1367
|
+
return { restored };
|
|
1368
|
+
},
|
|
1369
|
+
listBySession: (sessionId, opts = {}) => toApiList(store.listBySession(sessionId, opts)),
|
|
1342
1370
|
update: (id, p, ctx = {}) => {
|
|
1343
1371
|
const old = store.getById(id);
|
|
1344
1372
|
const updated = store.update(id, p);
|
package/lib/store.js
CHANGED
|
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
11
11
|
importance INTEGER NOT NULL DEFAULT 3,
|
|
12
12
|
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
13
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
session_disposed_at TEXT,
|
|
14
15
|
source TEXT,
|
|
15
16
|
session_id TEXT,
|
|
16
17
|
content_history TEXT,
|
|
@@ -321,6 +322,7 @@ function toRow(row) {
|
|
|
321
322
|
importance: row.importance,
|
|
322
323
|
forgotten: row.forgotten === 1,
|
|
323
324
|
archived: row.archived === 1,
|
|
325
|
+
session_disposed_at: row.session_disposed_at ?? undefined,
|
|
324
326
|
source: row.source ?? undefined,
|
|
325
327
|
session_id: row.session_id ?? undefined,
|
|
326
328
|
content_history: parseJsonArray(row.content_history),
|
|
@@ -567,6 +569,7 @@ export function createStore(path) {
|
|
|
567
569
|
};
|
|
568
570
|
|
|
569
571
|
addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
572
|
+
addColumn("memories", "session_disposed_at", "ALTER TABLE memories ADD COLUMN session_disposed_at TEXT");
|
|
570
573
|
addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
571
574
|
addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
572
575
|
addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
@@ -575,6 +578,12 @@ export function createStore(path) {
|
|
|
575
578
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
576
579
|
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
577
580
|
|
|
581
|
+
// Composite index for session-lifecycle queries (dispose/restore/listBySession).
|
|
582
|
+
// Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
|
|
583
|
+
// ADD COLUMN above, so the index would fail at db.exec(SCHEMA) time. CREATE
|
|
584
|
+
// INDEX IF NOT EXISTS is atomic, so the two-process race is safe here.
|
|
585
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, session_disposed_at)");
|
|
586
|
+
|
|
578
587
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
579
588
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
580
589
|
addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
@@ -618,7 +627,7 @@ export function createStore(path) {
|
|
|
618
627
|
return ts;
|
|
619
628
|
}
|
|
620
629
|
|
|
621
|
-
function count(type, { includeForgotten = false, includeArchived = false } = {}) {
|
|
630
|
+
function count(type, { includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
|
|
622
631
|
const clauses = [];
|
|
623
632
|
const params = [];
|
|
624
633
|
if (type !== undefined) {
|
|
@@ -631,6 +640,9 @@ export function createStore(path) {
|
|
|
631
640
|
if (!includeArchived) {
|
|
632
641
|
clauses.push("archived = 0");
|
|
633
642
|
}
|
|
643
|
+
if (!includeDisposed) {
|
|
644
|
+
clauses.push("session_disposed_at IS NULL");
|
|
645
|
+
}
|
|
634
646
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
635
647
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
636
648
|
}
|
|
@@ -820,6 +832,45 @@ export function createStore(path) {
|
|
|
820
832
|
return getById(id);
|
|
821
833
|
}
|
|
822
834
|
|
|
835
|
+
// --- session lifecycle (v0.6.0) ------------------------------------------
|
|
836
|
+
// Session dispose is orthogonal to `archived`: memory_archive is the user/AI
|
|
837
|
+
// choosing to keep an entry long-term-but-quiet, while session_disposed_at
|
|
838
|
+
// marks entries hidden because the session they were born in was deleted
|
|
839
|
+
// (a reversible "undo" — restoreBySession clears it). They never clobber each
|
|
840
|
+
// other: restoreBySession must not resurrect user-archived memories.
|
|
841
|
+
// Mirrors list/search: disposed rows are hidden by default. A consumer that
|
|
842
|
+
// needs to see the full picture (e.g. a restore flow that tells the user
|
|
843
|
+
// "these N entries were hidden") opts in via includeDisposed.
|
|
844
|
+
function listBySession(sessionId, { includeDisposed = false } = {}) {
|
|
845
|
+
const disposedFilter = includeDisposed ? "" : "AND session_disposed_at IS NULL";
|
|
846
|
+
const rows = db.prepare(
|
|
847
|
+
`SELECT * FROM memories WHERE session_id = ? ${disposedFilter} ORDER BY updated_at DESC`
|
|
848
|
+
).all(sessionId);
|
|
849
|
+
return rows.map(toRow);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Idempotent by state guard, not timestamp compare (nowIso() differs every
|
|
853
|
+
// call, so a fresh-timestamp re-dispose would spuriously count): dispose only
|
|
854
|
+
// touches rows that are NOT yet disposed; restore only touches rows that ARE.
|
|
855
|
+
// updated_at is deliberately left alone — this is a lifecycle flag, not
|
|
856
|
+
// content — so a true flip is the sole trigger for a mirror generation.
|
|
857
|
+
function setDisposedBySession(sessionId, disposed) {
|
|
858
|
+
const at = disposed ? nowIso() : null;
|
|
859
|
+
let affected = 0;
|
|
860
|
+
runAtomically(() => {
|
|
861
|
+
const result = disposed
|
|
862
|
+
? db.prepare(
|
|
863
|
+
"UPDATE memories SET session_disposed_at = ? WHERE session_id = ? AND session_disposed_at IS NULL"
|
|
864
|
+
).run(at, sessionId)
|
|
865
|
+
: db.prepare(
|
|
866
|
+
"UPDATE memories SET session_disposed_at = NULL WHERE session_id = ? AND session_disposed_at IS NOT NULL"
|
|
867
|
+
).run(sessionId);
|
|
868
|
+
affected = result.changes;
|
|
869
|
+
if (affected > 0) incrementGeneration();
|
|
870
|
+
});
|
|
871
|
+
return affected;
|
|
872
|
+
}
|
|
873
|
+
|
|
823
874
|
// --- sleep-mode storage support (v0.4.0) ---------------------------------
|
|
824
875
|
// touchLastAccess stamps the read time on recall/inject paths. It deliberately
|
|
825
876
|
// does NOT bump the mirror generation: reads must not mark the mirror dirty.
|
|
@@ -876,6 +927,7 @@ export function createStore(path) {
|
|
|
876
927
|
const rows = db.prepare(
|
|
877
928
|
`SELECT * FROM memories
|
|
878
929
|
WHERE forgotten = 0 AND archived = 0
|
|
930
|
+
AND session_disposed_at IS NULL
|
|
879
931
|
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
880
932
|
ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
|
|
881
933
|
LIMIT ?`
|
|
@@ -883,7 +935,7 @@ export function createStore(path) {
|
|
|
883
935
|
return rows.map(toRow);
|
|
884
936
|
}
|
|
885
937
|
|
|
886
|
-
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
|
|
938
|
+
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
|
|
887
939
|
const clauses = [];
|
|
888
940
|
const params = [];
|
|
889
941
|
if (type) {
|
|
@@ -896,6 +948,9 @@ export function createStore(path) {
|
|
|
896
948
|
if (!includeArchived) {
|
|
897
949
|
clauses.push("archived = 0");
|
|
898
950
|
}
|
|
951
|
+
if (!includeDisposed) {
|
|
952
|
+
clauses.push("session_disposed_at IS NULL");
|
|
953
|
+
}
|
|
899
954
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
900
955
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
901
956
|
const rows = db.prepare(
|
|
@@ -953,7 +1008,7 @@ export function createStore(path) {
|
|
|
953
1008
|
).all(limit);
|
|
954
1009
|
}
|
|
955
1010
|
|
|
956
|
-
function search(query, { limit = 20, includeArchived = false } = {}) {
|
|
1011
|
+
function search(query, { limit = 20, includeArchived = false, includeDisposed = false } = {}) {
|
|
957
1012
|
const q = String(query).trim();
|
|
958
1013
|
if (!q) return [];
|
|
959
1014
|
// Plain LIKE substring scan over title/content/tags (wildcards escaped so
|
|
@@ -962,9 +1017,10 @@ export function createStore(path) {
|
|
|
962
1017
|
const like = `%${escapeLike(q)}%`;
|
|
963
1018
|
const { limit: lim } = sanitizePage(limit, 0, 20);
|
|
964
1019
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1020
|
+
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
965
1021
|
const rows = db.prepare(
|
|
966
1022
|
`SELECT * FROM memories
|
|
967
|
-
WHERE ${archivedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
1023
|
+
WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
968
1024
|
ORDER BY
|
|
969
1025
|
CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
|
|
970
1026
|
importance DESC,
|
|
@@ -995,12 +1051,13 @@ export function createStore(path) {
|
|
|
995
1051
|
* Brute-force cosine similarity over embedded rows. Returns rows decorated
|
|
996
1052
|
* with a `score` (0..1). Only rows with a stored embedding participate.
|
|
997
1053
|
*/
|
|
998
|
-
function searchVector(vector, { limit = 20, includeArchived = false, threshold = 0 } = {}) {
|
|
1054
|
+
function searchVector(vector, { limit = 20, includeArchived = false, includeDisposed = false, threshold = 0 } = {}) {
|
|
999
1055
|
if (!Array.isArray(vector) || !vector.length) return [];
|
|
1000
1056
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1057
|
+
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
1001
1058
|
const rows = db.prepare(
|
|
1002
1059
|
`SELECT * FROM memories
|
|
1003
|
-
WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
|
|
1060
|
+
WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
|
|
1004
1061
|
).all();
|
|
1005
1062
|
const scored = [];
|
|
1006
1063
|
for (const row of rows) {
|
|
@@ -1871,6 +1928,8 @@ export function createStore(path) {
|
|
|
1871
1928
|
remove,
|
|
1872
1929
|
setForget,
|
|
1873
1930
|
setArchived,
|
|
1931
|
+
listBySession,
|
|
1932
|
+
setDisposedBySession,
|
|
1874
1933
|
touchLastAccess,
|
|
1875
1934
|
demoteToSummary,
|
|
1876
1935
|
restoreContent,
|
package/lib/tools.js
CHANGED
|
@@ -180,9 +180,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
180
180
|
|
|
181
181
|
defineTool({
|
|
182
182
|
name: "memory_delete",
|
|
183
|
-
description: "Permanently delete a memory entry.",
|
|
183
|
+
description: "Permanently delete a memory entry. Pass id for exact delete, or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip.",
|
|
184
184
|
parameters: {
|
|
185
|
-
id: { type: "string",
|
|
185
|
+
id: { type: "string", description: "Exact memory id to delete (from memory_list/memory_search output)" },
|
|
186
|
+
query: { type: "string", description: "Delete the best-matching entry for this text (searches title/content/tags; uses hybrid recall when an embedder is configured)" }
|
|
186
187
|
},
|
|
187
188
|
output: {
|
|
188
189
|
schema: {
|
|
@@ -193,9 +194,19 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
193
194
|
render: (_args, value) => TEXT_OUTPUT(value.deleted ? "Memory deleted." : "Memory not found.")
|
|
194
195
|
},
|
|
195
196
|
async execute(args) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
if (args.id) {
|
|
198
|
+
const existed = service.getById(args.id) !== undefined;
|
|
199
|
+
if (existed) service.remove(args.id);
|
|
200
|
+
return { deleted: existed };
|
|
201
|
+
}
|
|
202
|
+
if (args.query) {
|
|
203
|
+
const [best] = await service.searchMemories(args.query, { mode: "auto", topK: 1, useRerank: true });
|
|
204
|
+
if (best) {
|
|
205
|
+
service.remove(best.id);
|
|
206
|
+
return { deleted: true };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { deleted: false };
|
|
199
210
|
}
|
|
200
211
|
}),
|
|
201
212
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/config.js
CHANGED
|
@@ -4,6 +4,11 @@ export const Config = z.object({
|
|
|
4
4
|
memoryDir: z.string().default("~/.dsh/memory"),
|
|
5
5
|
autoInject: z.boolean().default(true),
|
|
6
6
|
autoSummarize: z.boolean().default(true),
|
|
7
|
+
// Session lifecycle (v0.6.0): when enabled, deleting/disposing a session also
|
|
8
|
+
// archives every memory that was born in it (treating the session as a save
|
|
9
|
+
// point — entries stay recoverable via memory_archive/restoreBySession).
|
|
10
|
+
// Default OFF: legacy behavior, a disposed session leaves its memories active.
|
|
11
|
+
sessionLifecycleEnabled: z.boolean().default(false),
|
|
7
12
|
// Optional model override for summarization. When both are non-empty, they
|
|
8
13
|
// take priority over the session's current model. Empty = use the session's
|
|
9
14
|
// active provider/model (same as before).
|
package/src/dream/sleep.js
CHANGED
|
@@ -108,7 +108,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
108
108
|
}
|
|
109
109
|
const strictness = config.sleepConflictStrictness ?? "normal";
|
|
110
110
|
const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
|
|
111
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
|
|
111
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten && m.type !== "summary");
|
|
112
112
|
if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
|
|
113
113
|
if (signal?.aborted) return { status: "aborted", reason: "user activity" };
|
|
114
114
|
|
|
@@ -239,7 +239,7 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
|
|
|
239
239
|
const archived = [];
|
|
240
240
|
for (const m of service.all()) {
|
|
241
241
|
if (signal?.aborted) break;
|
|
242
|
-
if (m.archived || m.forgotten) continue;
|
|
242
|
+
if (m.archived || m.forgotten || m.session_disposed_at) continue;
|
|
243
243
|
const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
|
|
244
244
|
if (!ref) continue;
|
|
245
245
|
const t = new Date(ref).getTime();
|
|
@@ -273,7 +273,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
273
273
|
const limit = config.sleepPatternMinMemories ?? 100;
|
|
274
274
|
const memories = service
|
|
275
275
|
.list({ limit: 200, includeForgotten: false })
|
|
276
|
-
.filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
|
|
276
|
+
.filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary" && m.type !== "pattern")
|
|
277
277
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
|
|
278
278
|
.slice(0, limit);
|
|
279
279
|
if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
|
|
@@ -342,7 +342,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
|
|
|
342
342
|
if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
|
|
343
343
|
const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
|
|
344
344
|
if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
|
|
345
|
-
const memories = service.all().filter((m) => !m.archived && !m.forgotten);
|
|
345
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten);
|
|
346
346
|
const seen = new Set();
|
|
347
347
|
const related = [];
|
|
348
348
|
const MAX_RELATIONS_PER_ORPHAN = 3;
|
package/src/dream.js
CHANGED
|
@@ -495,7 +495,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
495
495
|
let inFlight = null;
|
|
496
496
|
|
|
497
497
|
function shouldTrigger(service) {
|
|
498
|
-
const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
|
|
498
|
+
const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary");
|
|
499
499
|
const count = memories.length;
|
|
500
500
|
const chars = totalChars(memories);
|
|
501
501
|
const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
|
|
@@ -844,7 +844,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
844
844
|
: {}),
|
|
845
845
|
messages: [
|
|
846
846
|
{ role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
|
|
847
|
-
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
847
|
+
{ role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
|
|
848
848
|
]
|
|
849
849
|
}, reportUsage));
|
|
850
850
|
} catch (error) {
|
package/src/index.js
CHANGED
|
@@ -317,6 +317,26 @@ export const apply = (ctx, config) => {
|
|
|
317
317
|
const summarizer = createSummarizer(ctx, service, cfg);
|
|
318
318
|
disposers.push(summarizer.dispose);
|
|
319
319
|
|
|
320
|
+
// Session lifecycle (v0.6.0): when a session leaves the store and the toggle
|
|
321
|
+
// is enabled, mark every memory born in it as session-disposed (hidden from
|
|
322
|
+
// injection/search/dream but never destroyed — recoverable via
|
|
323
|
+
// restoreBySession). Default off, so a disposed session leaves its memories
|
|
324
|
+
// active (legacy behavior). Every path is guarded: a failure inside the
|
|
325
|
+
// callback must never propagate into DSH's session teardown (that would crash
|
|
326
|
+
// the plugin on the very delete action it serves).
|
|
327
|
+
if (cfg.sessionLifecycleEnabled) {
|
|
328
|
+
disposers.push(ctx.on("session/disposed", (session) => {
|
|
329
|
+
const sessionId = session?.id;
|
|
330
|
+
if (!sessionId) return;
|
|
331
|
+
try {
|
|
332
|
+
const { disposed } = service.disposeBySession(sessionId);
|
|
333
|
+
ctx.logger?.info?.(`[dsh-mneme] session disposed, hid ${disposed} memory(s) for ${sessionId}`);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
ctx.logger?.warn?.(`[dsh-mneme] session dispose failed for ${sessionId}: ${String(error)}`);
|
|
336
|
+
}
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
|
|
320
340
|
if (ctx.webServer) {
|
|
321
341
|
const api = createApi(ctx, service, settings, commands ?? {
|
|
322
342
|
add: () => { throw new Error("commands unavailable"); },
|
package/src/service.js
CHANGED
|
@@ -331,7 +331,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
331
331
|
function bm25Recall(q, limit) {
|
|
332
332
|
if (config?.bm25SearchEnabled === false) return [];
|
|
333
333
|
try {
|
|
334
|
-
const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
|
|
334
|
+
const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived && !m.session_disposed_at);
|
|
335
335
|
if (!docs.length) return [];
|
|
336
336
|
return createBM25Index(docs).search(q, { limit });
|
|
337
337
|
} catch {
|
|
@@ -999,6 +999,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
999
999
|
tags: m.tags,
|
|
1000
1000
|
importance: m.importance,
|
|
1001
1001
|
source: m.source,
|
|
1002
|
+
// session_id is optional on the wire: only carry it when present, so the
|
|
1003
|
+
// DTO stays a lossless JSON object (undefined would vanish on serialize).
|
|
1004
|
+
...(m.session_id != null ? { session_id: m.session_id } : {}),
|
|
1005
|
+
// Disposed state rides along when set, so a restore flow is not a blind
|
|
1006
|
+
// op — the caller can see which entries are hidden before restoreBySession.
|
|
1007
|
+
...(m.session_disposed_at != null ? { disposed: true } : {}),
|
|
1002
1008
|
created_at: m.created_at,
|
|
1003
1009
|
updated_at: m.updated_at
|
|
1004
1010
|
}));
|
|
@@ -1339,6 +1345,28 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1339
1345
|
afterSync("write");
|
|
1340
1346
|
notifyWrite();
|
|
1341
1347
|
},
|
|
1348
|
+
// Session lifecycle (v0.6.0): mark/clear the session-disposed state on every
|
|
1349
|
+
// memory born in a given session. Uses the dedicated `session_disposed_at`
|
|
1350
|
+
// column, orthogonal to `archived` — restoring a session never resurrects
|
|
1351
|
+
// memories the user archived on purpose. Nothing is destroyed; a session
|
|
1352
|
+
// treated as a save point is fully recoverable via restoreBySession.
|
|
1353
|
+
disposeBySession: (sessionId) => {
|
|
1354
|
+
const disposed = store.setDisposedBySession(sessionId, true);
|
|
1355
|
+
if (disposed > 0) {
|
|
1356
|
+
afterSync("write");
|
|
1357
|
+
notifyWrite();
|
|
1358
|
+
}
|
|
1359
|
+
return { disposed };
|
|
1360
|
+
},
|
|
1361
|
+
restoreBySession: (sessionId) => {
|
|
1362
|
+
const restored = store.setDisposedBySession(sessionId, false);
|
|
1363
|
+
if (restored > 0) {
|
|
1364
|
+
afterSync("write");
|
|
1365
|
+
notifyWrite();
|
|
1366
|
+
}
|
|
1367
|
+
return { restored };
|
|
1368
|
+
},
|
|
1369
|
+
listBySession: (sessionId, opts = {}) => toApiList(store.listBySession(sessionId, opts)),
|
|
1342
1370
|
update: (id, p, ctx = {}) => {
|
|
1343
1371
|
const old = store.getById(id);
|
|
1344
1372
|
const updated = store.update(id, p);
|
package/src/store.js
CHANGED
|
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
11
11
|
importance INTEGER NOT NULL DEFAULT 3,
|
|
12
12
|
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
13
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
session_disposed_at TEXT,
|
|
14
15
|
source TEXT,
|
|
15
16
|
session_id TEXT,
|
|
16
17
|
content_history TEXT,
|
|
@@ -321,6 +322,7 @@ function toRow(row) {
|
|
|
321
322
|
importance: row.importance,
|
|
322
323
|
forgotten: row.forgotten === 1,
|
|
323
324
|
archived: row.archived === 1,
|
|
325
|
+
session_disposed_at: row.session_disposed_at ?? undefined,
|
|
324
326
|
source: row.source ?? undefined,
|
|
325
327
|
session_id: row.session_id ?? undefined,
|
|
326
328
|
content_history: parseJsonArray(row.content_history),
|
|
@@ -567,6 +569,7 @@ export function createStore(path) {
|
|
|
567
569
|
};
|
|
568
570
|
|
|
569
571
|
addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
572
|
+
addColumn("memories", "session_disposed_at", "ALTER TABLE memories ADD COLUMN session_disposed_at TEXT");
|
|
570
573
|
addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
571
574
|
addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
572
575
|
addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
@@ -575,6 +578,12 @@ export function createStore(path) {
|
|
|
575
578
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
576
579
|
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
577
580
|
|
|
581
|
+
// Composite index for session-lifecycle queries (dispose/restore/listBySession).
|
|
582
|
+
// Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
|
|
583
|
+
// ADD COLUMN above, so the index would fail at db.exec(SCHEMA) time. CREATE
|
|
584
|
+
// INDEX IF NOT EXISTS is atomic, so the two-process race is safe here.
|
|
585
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, session_disposed_at)");
|
|
586
|
+
|
|
578
587
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
579
588
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
580
589
|
addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
@@ -618,7 +627,7 @@ export function createStore(path) {
|
|
|
618
627
|
return ts;
|
|
619
628
|
}
|
|
620
629
|
|
|
621
|
-
function count(type, { includeForgotten = false, includeArchived = false } = {}) {
|
|
630
|
+
function count(type, { includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
|
|
622
631
|
const clauses = [];
|
|
623
632
|
const params = [];
|
|
624
633
|
if (type !== undefined) {
|
|
@@ -631,6 +640,9 @@ export function createStore(path) {
|
|
|
631
640
|
if (!includeArchived) {
|
|
632
641
|
clauses.push("archived = 0");
|
|
633
642
|
}
|
|
643
|
+
if (!includeDisposed) {
|
|
644
|
+
clauses.push("session_disposed_at IS NULL");
|
|
645
|
+
}
|
|
634
646
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
635
647
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
636
648
|
}
|
|
@@ -820,6 +832,45 @@ export function createStore(path) {
|
|
|
820
832
|
return getById(id);
|
|
821
833
|
}
|
|
822
834
|
|
|
835
|
+
// --- session lifecycle (v0.6.0) ------------------------------------------
|
|
836
|
+
// Session dispose is orthogonal to `archived`: memory_archive is the user/AI
|
|
837
|
+
// choosing to keep an entry long-term-but-quiet, while session_disposed_at
|
|
838
|
+
// marks entries hidden because the session they were born in was deleted
|
|
839
|
+
// (a reversible "undo" — restoreBySession clears it). They never clobber each
|
|
840
|
+
// other: restoreBySession must not resurrect user-archived memories.
|
|
841
|
+
// Mirrors list/search: disposed rows are hidden by default. A consumer that
|
|
842
|
+
// needs to see the full picture (e.g. a restore flow that tells the user
|
|
843
|
+
// "these N entries were hidden") opts in via includeDisposed.
|
|
844
|
+
function listBySession(sessionId, { includeDisposed = false } = {}) {
|
|
845
|
+
const disposedFilter = includeDisposed ? "" : "AND session_disposed_at IS NULL";
|
|
846
|
+
const rows = db.prepare(
|
|
847
|
+
`SELECT * FROM memories WHERE session_id = ? ${disposedFilter} ORDER BY updated_at DESC`
|
|
848
|
+
).all(sessionId);
|
|
849
|
+
return rows.map(toRow);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Idempotent by state guard, not timestamp compare (nowIso() differs every
|
|
853
|
+
// call, so a fresh-timestamp re-dispose would spuriously count): dispose only
|
|
854
|
+
// touches rows that are NOT yet disposed; restore only touches rows that ARE.
|
|
855
|
+
// updated_at is deliberately left alone — this is a lifecycle flag, not
|
|
856
|
+
// content — so a true flip is the sole trigger for a mirror generation.
|
|
857
|
+
function setDisposedBySession(sessionId, disposed) {
|
|
858
|
+
const at = disposed ? nowIso() : null;
|
|
859
|
+
let affected = 0;
|
|
860
|
+
runAtomically(() => {
|
|
861
|
+
const result = disposed
|
|
862
|
+
? db.prepare(
|
|
863
|
+
"UPDATE memories SET session_disposed_at = ? WHERE session_id = ? AND session_disposed_at IS NULL"
|
|
864
|
+
).run(at, sessionId)
|
|
865
|
+
: db.prepare(
|
|
866
|
+
"UPDATE memories SET session_disposed_at = NULL WHERE session_id = ? AND session_disposed_at IS NOT NULL"
|
|
867
|
+
).run(sessionId);
|
|
868
|
+
affected = result.changes;
|
|
869
|
+
if (affected > 0) incrementGeneration();
|
|
870
|
+
});
|
|
871
|
+
return affected;
|
|
872
|
+
}
|
|
873
|
+
|
|
823
874
|
// --- sleep-mode storage support (v0.4.0) ---------------------------------
|
|
824
875
|
// touchLastAccess stamps the read time on recall/inject paths. It deliberately
|
|
825
876
|
// does NOT bump the mirror generation: reads must not mark the mirror dirty.
|
|
@@ -876,6 +927,7 @@ export function createStore(path) {
|
|
|
876
927
|
const rows = db.prepare(
|
|
877
928
|
`SELECT * FROM memories
|
|
878
929
|
WHERE forgotten = 0 AND archived = 0
|
|
930
|
+
AND session_disposed_at IS NULL
|
|
879
931
|
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
880
932
|
ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
|
|
881
933
|
LIMIT ?`
|
|
@@ -883,7 +935,7 @@ export function createStore(path) {
|
|
|
883
935
|
return rows.map(toRow);
|
|
884
936
|
}
|
|
885
937
|
|
|
886
|
-
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
|
|
938
|
+
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
|
|
887
939
|
const clauses = [];
|
|
888
940
|
const params = [];
|
|
889
941
|
if (type) {
|
|
@@ -896,6 +948,9 @@ export function createStore(path) {
|
|
|
896
948
|
if (!includeArchived) {
|
|
897
949
|
clauses.push("archived = 0");
|
|
898
950
|
}
|
|
951
|
+
if (!includeDisposed) {
|
|
952
|
+
clauses.push("session_disposed_at IS NULL");
|
|
953
|
+
}
|
|
899
954
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
900
955
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
901
956
|
const rows = db.prepare(
|
|
@@ -953,7 +1008,7 @@ export function createStore(path) {
|
|
|
953
1008
|
).all(limit);
|
|
954
1009
|
}
|
|
955
1010
|
|
|
956
|
-
function search(query, { limit = 20, includeArchived = false } = {}) {
|
|
1011
|
+
function search(query, { limit = 20, includeArchived = false, includeDisposed = false } = {}) {
|
|
957
1012
|
const q = String(query).trim();
|
|
958
1013
|
if (!q) return [];
|
|
959
1014
|
// Plain LIKE substring scan over title/content/tags (wildcards escaped so
|
|
@@ -962,9 +1017,10 @@ export function createStore(path) {
|
|
|
962
1017
|
const like = `%${escapeLike(q)}%`;
|
|
963
1018
|
const { limit: lim } = sanitizePage(limit, 0, 20);
|
|
964
1019
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1020
|
+
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
965
1021
|
const rows = db.prepare(
|
|
966
1022
|
`SELECT * FROM memories
|
|
967
|
-
WHERE ${archivedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
1023
|
+
WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
968
1024
|
ORDER BY
|
|
969
1025
|
CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
|
|
970
1026
|
importance DESC,
|
|
@@ -995,12 +1051,13 @@ export function createStore(path) {
|
|
|
995
1051
|
* Brute-force cosine similarity over embedded rows. Returns rows decorated
|
|
996
1052
|
* with a `score` (0..1). Only rows with a stored embedding participate.
|
|
997
1053
|
*/
|
|
998
|
-
function searchVector(vector, { limit = 20, includeArchived = false, threshold = 0 } = {}) {
|
|
1054
|
+
function searchVector(vector, { limit = 20, includeArchived = false, includeDisposed = false, threshold = 0 } = {}) {
|
|
999
1055
|
if (!Array.isArray(vector) || !vector.length) return [];
|
|
1000
1056
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1057
|
+
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
1001
1058
|
const rows = db.prepare(
|
|
1002
1059
|
`SELECT * FROM memories
|
|
1003
|
-
WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
|
|
1060
|
+
WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
|
|
1004
1061
|
).all();
|
|
1005
1062
|
const scored = [];
|
|
1006
1063
|
for (const row of rows) {
|
|
@@ -1871,6 +1928,8 @@ export function createStore(path) {
|
|
|
1871
1928
|
remove,
|
|
1872
1929
|
setForget,
|
|
1873
1930
|
setArchived,
|
|
1931
|
+
listBySession,
|
|
1932
|
+
setDisposedBySession,
|
|
1874
1933
|
touchLastAccess,
|
|
1875
1934
|
demoteToSummary,
|
|
1876
1935
|
restoreContent,
|
package/src/tools.js
CHANGED
|
@@ -180,9 +180,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
180
180
|
|
|
181
181
|
defineTool({
|
|
182
182
|
name: "memory_delete",
|
|
183
|
-
description: "Permanently delete a memory entry.",
|
|
183
|
+
description: "Permanently delete a memory entry. Pass id for exact delete, or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip.",
|
|
184
184
|
parameters: {
|
|
185
|
-
id: { type: "string",
|
|
185
|
+
id: { type: "string", description: "Exact memory id to delete (from memory_list/memory_search output)" },
|
|
186
|
+
query: { type: "string", description: "Delete the best-matching entry for this text (searches title/content/tags; uses hybrid recall when an embedder is configured)" }
|
|
186
187
|
},
|
|
187
188
|
output: {
|
|
188
189
|
schema: {
|
|
@@ -193,9 +194,19 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
193
194
|
render: (_args, value) => TEXT_OUTPUT(value.deleted ? "Memory deleted." : "Memory not found.")
|
|
194
195
|
},
|
|
195
196
|
async execute(args) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
if (args.id) {
|
|
198
|
+
const existed = service.getById(args.id) !== undefined;
|
|
199
|
+
if (existed) service.remove(args.id);
|
|
200
|
+
return { deleted: existed };
|
|
201
|
+
}
|
|
202
|
+
if (args.query) {
|
|
203
|
+
const [best] = await service.searchMemories(args.query, { mode: "auto", topK: 1, useRerank: true });
|
|
204
|
+
if (best) {
|
|
205
|
+
service.remove(best.id);
|
|
206
|
+
return { deleted: true };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { deleted: false };
|
|
199
210
|
}
|
|
200
211
|
}),
|
|
201
212
|
|
package/test/service.test.js
CHANGED
|
@@ -76,6 +76,10 @@ test("toApiList maps store rows to wire DTOs", () => {
|
|
|
76
76
|
assert.equal(dto[0].title, "选型");
|
|
77
77
|
assert.equal(dto[0].content, "node:sqlite");
|
|
78
78
|
assert.equal(dto[0].importance, 3);
|
|
79
|
+
// session_id rides the DTO when present (session lifecycle needs it)
|
|
80
|
+
const withSession = service.saveWithDedupe({ type: "decision", title: "带会话", content: "y", session_id: "sess-x" });
|
|
81
|
+
const dto2 = service.toApiList([withSession.memory]);
|
|
82
|
+
assert.equal(dto2[0].session_id, "sess-x");
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
test("mergeHumanEdits skips edits without id and keeps applying the rest", () => {
|
|
@@ -327,3 +331,105 @@ test("Bug7: injection ranking re-weights by quality_score (degraded memories ran
|
|
|
327
331
|
assert.equal(candidates[0].title, "高质量", "100-quality preference leads the degraded one");
|
|
328
332
|
assert.equal(candidates[1].title, "元记忆");
|
|
329
333
|
});
|
|
334
|
+
|
|
335
|
+
// --- session lifecycle (v0.6.0): dispose/restore by session -----------------
|
|
336
|
+
// session_disposed_at is orthogonal to `archived`: dispose hides only entries
|
|
337
|
+
// born in the deleted session, and restore never resurrects user-archived ones.
|
|
338
|
+
|
|
339
|
+
test("disposeBySession marks only that session's memories", () => {
|
|
340
|
+
const store = createStore(":memory:");
|
|
341
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
342
|
+
const s1 = svc.saveWithDedupe({ type: "project", title: "会话A记忆", content: "x", session_id: "sess-1" });
|
|
343
|
+
svc.saveWithDedupe({ type: "project", title: "会话B记忆", content: "y", session_id: "sess-2" });
|
|
344
|
+
|
|
345
|
+
const res = svc.disposeBySession("sess-1");
|
|
346
|
+
assert.deepEqual(res, { disposed: 1 });
|
|
347
|
+
assert.ok(svc.getById(s1.memory.id).session_disposed_at, "session-1 memory marked disposed");
|
|
348
|
+
// disposed memories vanish from default list (like archived)
|
|
349
|
+
assert.ok(!svc.list({ type: "project" }).some((m) => m.id === s1.memory.id), "disposed hidden from default list");
|
|
350
|
+
const other = svc.list({ type: "project", includeDisposed: true });
|
|
351
|
+
assert.ok(other.some((m) => m.title === "会话B记忆" && !m.session_disposed_at), "other session untouched");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("restoreBySession clears the dispose mark and makes entries visible again", () => {
|
|
355
|
+
const store = createStore(":memory:");
|
|
356
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
357
|
+
const s1 = svc.saveWithDedupe({ type: "project", title: "存档会话", content: "x", session_id: "sess-9" });
|
|
358
|
+
svc.disposeBySession("sess-9");
|
|
359
|
+
|
|
360
|
+
assert.ok(!svc.list({ type: "project" }).some((m) => m.id === s1.memory.id), "hidden while disposed");
|
|
361
|
+
const res = svc.restoreBySession("sess-9");
|
|
362
|
+
assert.deepEqual(res, { restored: 1 });
|
|
363
|
+
assert.equal(svc.getById(s1.memory.id).session_disposed_at, undefined, "dispose mark cleared");
|
|
364
|
+
assert.ok(svc.list({ type: "project" }).some((m) => m.id === s1.memory.id), "visible again");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("disposeBySession is idempotent: re-disposing returns 0", () => {
|
|
368
|
+
const store = createStore(":memory:");
|
|
369
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
370
|
+
svc.saveWithDedupe({ type: "project", title: "重复销毁", content: "x", session_id: "sess-3" });
|
|
371
|
+
|
|
372
|
+
assert.deepEqual(svc.disposeBySession("sess-3"), { disposed: 1 });
|
|
373
|
+
assert.deepEqual(svc.disposeBySession("sess-3"), { disposed: 0 }, "no rows flipped the second time");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("disposeBySession on an unknown session is a no-op", () => {
|
|
377
|
+
const store = createStore(":memory:");
|
|
378
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
379
|
+
assert.deepEqual(svc.disposeBySession("nope"), { disposed: 0 });
|
|
380
|
+
assert.deepEqual(svc.restoreBySession("nope"), { restored: 0 });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("legacy rows without session_id are never touched by session lifecycle", () => {
|
|
384
|
+
const store = createStore(":memory:");
|
|
385
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
386
|
+
const legacy = svc.saveWithDedupe({ type: "preference", title: "全局记忆", content: "no session", session_id: undefined });
|
|
387
|
+
|
|
388
|
+
svc.disposeBySession("sess-x");
|
|
389
|
+
assert.equal(svc.getById(legacy.memory.id).session_disposed_at, undefined, "global memory survives session dispose");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("restoreBySession does NOT resurrect user-archived memories (orthogonal)", () => {
|
|
393
|
+
const store = createStore(":memory:");
|
|
394
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
395
|
+
// user manually archived this one (archived=true), then the session is disposed
|
|
396
|
+
const { memory: userArchived } = svc.saveWithDedupe({ type: "decision", title: "手动归档", content: "x", session_id: "sess-4" });
|
|
397
|
+
svc.setArchived(userArchived.id, true);
|
|
398
|
+
svc.disposeBySession("sess-4");
|
|
399
|
+
|
|
400
|
+
svc.restoreBySession("sess-4");
|
|
401
|
+
assert.equal(svc.getById(userArchived.id).archived, true, "user archive survives restore");
|
|
402
|
+
assert.equal(svc.getById(userArchived.id).session_disposed_at, undefined, "dispose mark cleared but archive kept");
|
|
403
|
+
assert.ok(!svc.list({ type: "decision" }).some((m) => m.id === userArchived.id), "still hidden by user archive");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("listBySession returns only that session's memories via wire DTOs", () => {
|
|
407
|
+
const store = createStore(":memory:");
|
|
408
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
409
|
+
const { memory } = svc.saveWithDedupe({ type: "decision", title: "会话决策", content: "d", session_id: "sess-7" });
|
|
410
|
+
svc.saveWithDedupe({ type: "decision", title: "别会话", content: "e", session_id: "sess-8" });
|
|
411
|
+
|
|
412
|
+
const rows = svc.listBySession("sess-7");
|
|
413
|
+
assert.equal(rows.length, 1);
|
|
414
|
+
assert.equal(rows[0].id, memory.id);
|
|
415
|
+
assert.equal(rows[0].title, "会话决策");
|
|
416
|
+
assert.equal(rows[0].session_id, "sess-7");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("listBySession DTO hides disposed by default and flags it when included", () => {
|
|
420
|
+
const store = createStore(":memory:");
|
|
421
|
+
const svc = createService({ store, mirror: null, config: {} });
|
|
422
|
+
const { memory } = svc.saveWithDedupe({ type: "decision", title: "会话决策", content: "d", session_id: "sess-9" });
|
|
423
|
+
svc.disposeBySession("sess-9");
|
|
424
|
+
|
|
425
|
+
assert.equal(svc.listBySession("sess-9").length, 0, "disposed hidden by default");
|
|
426
|
+
const visible = svc.listBySession("sess-9", { includeDisposed: true });
|
|
427
|
+
assert.equal(visible.length, 1);
|
|
428
|
+
assert.equal(visible[0].id, memory.id);
|
|
429
|
+
assert.equal(visible[0].disposed, true, "DTO carries disposed marker so restore is not blind");
|
|
430
|
+
|
|
431
|
+
svc.restoreBySession("sess-9");
|
|
432
|
+
const restored = svc.listBySession("sess-9");
|
|
433
|
+
assert.equal(restored.length, 1, "restore brings it back to default view");
|
|
434
|
+
assert.equal(restored[0].disposed, undefined, "marker cleared after restore");
|
|
435
|
+
});
|
package/test/store.test.js
CHANGED
|
@@ -358,3 +358,79 @@ test("conflict_pending table persists across store reopen", () => {
|
|
|
358
358
|
assert.equal(pending[0].reason, "x");
|
|
359
359
|
s2.close();
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
// --- session lifecycle (v0.6.0): disposed rows are filtered out ------------
|
|
363
|
+
|
|
364
|
+
test("session_disposed_at column exists and survives migration", () => {
|
|
365
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-disposed-"));
|
|
366
|
+
const path = join(dir, "memory.db");
|
|
367
|
+
const s1 = createStore(path);
|
|
368
|
+
const { id } = s1.save({ type: "decision", title: "迁移", content: "c", session_id: "sess-m" });
|
|
369
|
+
s1.setDisposedBySession("sess-m", true);
|
|
370
|
+
const row = s1.db.prepare("SELECT session_disposed_at FROM memories WHERE id = ?").get(id);
|
|
371
|
+
assert.ok(row.session_disposed_at, "dispose timestamp persisted");
|
|
372
|
+
s1.close();
|
|
373
|
+
// reopen: column survives
|
|
374
|
+
const s2 = createStore(path);
|
|
375
|
+
const live = s2.list({ type: "decision" });
|
|
376
|
+
assert.ok(!live.some((m) => m.id === id), "disposed row excluded after reopen");
|
|
377
|
+
s2.close();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("search and list exclude disposed rows unless includeDisposed", () => {
|
|
381
|
+
const store = createStore(":memory:");
|
|
382
|
+
const a = store.save({ type: "decision", title: "活着", content: "keyword alpha", session_id: "s1" });
|
|
383
|
+
const b = store.save({ type: "decision", title: "被销毁", content: "keyword beta", session_id: "s2" });
|
|
384
|
+
store.setDisposedBySession("s2", true);
|
|
385
|
+
|
|
386
|
+
const searchRes = store.search("keyword");
|
|
387
|
+
assert.ok(searchRes.some((m) => m.id === a.id), "live row found");
|
|
388
|
+
assert.ok(!searchRes.some((m) => m.id === b.id), "disposed row excluded from search");
|
|
389
|
+
const searchIncl = store.search("keyword", { includeDisposed: true });
|
|
390
|
+
assert.ok(searchIncl.some((m) => m.id === b.id), "includeDisposed surfaces it");
|
|
391
|
+
|
|
392
|
+
const listRes = store.list({ type: "decision" });
|
|
393
|
+
assert.ok(listRes.some((m) => m.id === a.id));
|
|
394
|
+
assert.ok(!listRes.some((m) => m.id === b.id), "disposed excluded from list");
|
|
395
|
+
const listIncl = store.list({ type: "decision", includeDisposed: true });
|
|
396
|
+
assert.ok(listIncl.some((m) => m.id === b.id), "includeDisposed lists it");
|
|
397
|
+
assert.equal(store.count("decision", { includeDisposed: true }), 2);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("searchVector excludes disposed rows", () => {
|
|
401
|
+
const store = createStore(":memory:");
|
|
402
|
+
const a = store.save({ type: "decision", title: "向量A", content: "v", session_id: "s1" });
|
|
403
|
+
const b = store.save({ type: "decision", title: "向量B", content: "v", session_id: "s2" });
|
|
404
|
+
store.setEmbedding(a.id, [1, 0, 0]);
|
|
405
|
+
store.setEmbedding(b.id, [1, 0, 0]);
|
|
406
|
+
store.setDisposedBySession("s2", true);
|
|
407
|
+
|
|
408
|
+
const res = store.searchVector([1, 0, 0], { limit: 10 });
|
|
409
|
+
assert.ok(res.some((m) => m.id === a.id));
|
|
410
|
+
assert.ok(!res.some((m) => m.id === b.id), "disposed row excluded from vector search");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("setDisposedBySession round-trips and listBySession sees all states", () => {
|
|
414
|
+
const store = createStore(":memory:");
|
|
415
|
+
const a = store.save({ type: "decision", title: "会话记忆", content: "c", session_id: "sess-z" });
|
|
416
|
+
assert.equal(store.setDisposedBySession("sess-z", true), 1, "first dispose flips 1");
|
|
417
|
+
assert.equal(store.setDisposedBySession("sess-z", true), 0, "re-dispose is a no-op");
|
|
418
|
+
assert.equal(store.setDisposedBySession("sess-z", false), 1, "restore flips 1");
|
|
419
|
+
assert.equal(store.setDisposedBySession("sess-z", false), 0, "re-restore is a no-op");
|
|
420
|
+
assert.equal(store.getById(a.id).session_disposed_at, undefined, "mark cleared after restore");
|
|
421
|
+
|
|
422
|
+
const bySession = store.listBySession("sess-z");
|
|
423
|
+
assert.equal(bySession.length, 1);
|
|
424
|
+
assert.equal(bySession[0].id, a.id);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("listBySession hides disposed by default, includeDisposed opts in", () => {
|
|
428
|
+
const store = createStore(":memory:");
|
|
429
|
+
const a = store.save({ type: "decision", title: "会话记忆", content: "c", session_id: "sess-y" });
|
|
430
|
+
store.setDisposedBySession("sess-y", true);
|
|
431
|
+
|
|
432
|
+
assert.equal(store.listBySession("sess-y").length, 0, "disposed row hidden by default");
|
|
433
|
+
const all = store.listBySession("sess-y", { includeDisposed: true });
|
|
434
|
+
assert.equal(all.length, 1, "includeDisposed reveals it");
|
|
435
|
+
assert.equal(all[0].id, a.id);
|
|
436
|
+
});
|
package/test/tools.test.js
CHANGED
|
@@ -142,6 +142,26 @@ test("memory_delete on missing id returns deleted:false", async () => {
|
|
|
142
142
|
assert.equal(result.deleted, false);
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
test("memory_delete by query deletes the best match (no id round-trip)", async () => {
|
|
146
|
+
const { registered, service, store } = setup();
|
|
147
|
+
const { memory } = service.saveWithDedupe({ type: "preference", title: "喜欢 Rust", content: "用户偏好 Rust 优先于 Go", importance: 4 });
|
|
148
|
+
service.saveWithDedupe({ type: "preference", title: "喜欢 Python", content: "用户偏好 Python 写脚本", importance: 3 });
|
|
149
|
+
const del = registered.find((t) => t.name === "memory_delete");
|
|
150
|
+
const result = await del.execute({ query: "Rust" });
|
|
151
|
+
assert.equal(result.deleted, true);
|
|
152
|
+
assert.equal(service.getById(memory.id), undefined, "best-matching Rust entry removed");
|
|
153
|
+
assert.equal(store.count(), 1, "non-matching entry untouched");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("memory_delete by query with no match returns deleted:false", async () => {
|
|
157
|
+
const { registered, service, store } = setup();
|
|
158
|
+
service.saveWithDedupe({ type: "preference", title: "喜欢 Rust", content: "用户偏好 Rust", importance: 4 });
|
|
159
|
+
const del = registered.find((t) => t.name === "memory_delete");
|
|
160
|
+
const result = await del.execute({ query: "完全不存在的关键词xyz" });
|
|
161
|
+
assert.equal(result.deleted, false);
|
|
162
|
+
assert.equal(store.count(), 1, "nothing removed when no match");
|
|
163
|
+
});
|
|
164
|
+
|
|
145
165
|
test("memory_forget suppresses injection without deleting", async () => {
|
|
146
166
|
const { registered, service, store } = setup();
|
|
147
167
|
const { memory } = service.saveWithDedupe({ type: "project", title: "t", content: "c", importance: 5 });
|