@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 CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-603%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-628%20passed-success)](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
- ### Web 记忆面板
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+** | 🚀 远期 | 自进化记忆 | 兴趣漂移跟踪 + workspace 记忆共享(等 DSH 支持) |
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).
@@ -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", required: true }
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
- const existed = service.getById(args.id) !== undefined;
197
- if (existed) service.remove(args.id);
198
- return { deleted: existed };
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.5.3",
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).
@@ -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", required: true }
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
- const existed = service.getById(args.id) !== undefined;
197
- if (existed) service.remove(args.id);
198
- return { deleted: existed };
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
 
@@ -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
+ });
@@ -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
+ });
@@ -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 });