dsh-engram 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -264,8 +264,8 @@ curl -s -X POST http://127.0.0.1:3080/api/dsh-engram/import \
264
264
  <!-- dsh-market compatibility block — keep labels bilingual and in sync with
265
265
  docs/RELEASE-NOTES.TEMPLATE.md (market README convention) -->
266
266
 
267
- - **DSH 版本 / DSH versions**: `>=0.1.2-alpha.2 <0.3.0-0` (本版 `0.5.0`; 已发布的 `0.3.7`、`0.4.0` 仍声明旧的 `0.1.x` 范围 `>=0.1.2-alpha.2 <0.2.0-0` — 见下方矩阵)
268
- - **最后验证日期 / Last verified**: 2026-10-15 — 设置模型迁移(entry-id volatile Config,兼容 DSH `0.2.x`)落地,npm test 329/329 通过;真实 `dsh 0.2.0-rc.2` 隔离 profile 运行烟测通过(见下方“当前发布”)
267
+ - **DSH 版本 / DSH versions**: `>=0.1.2-alpha.2 <0.3.0-0` (本版 `0.5.1`; 已发布的 `0.3.7`、`0.4.0` 仍声明旧的 `0.1.x` 范围 `>=0.1.2-alpha.2 <0.2.0-0` — 见下方矩阵)
268
+ - **最后验证日期 / Last verified**: 2026-10-19 — 会话扫描修复(`session.v<N>.jsonl.zstd`,issue #8)+ 数据导出/导入与替换/冲突可视化(issue #3),npm test 334/334 通过
269
269
  - **安装方式已验证 / Verified install**: `dsh plugin --profile web add dsh-engram`
270
270
 
271
271
  > **Short version.** Every **published** release (`0.2.0` … `0.3.6`) supports
@@ -279,7 +279,8 @@ curl -s -X POST http://127.0.0.1:3080/api/dsh-engram/import \
279
279
  | --- | --- | --- | --- |
280
280
  | `0.2.0` … `0.3.6` (npm, GitHub v0.3.3–v0.3.6) | published | **old DSH only** — `0.1.0-rc.7` / `0.1.0-rc.8` / `0.1.1-rc.1` / `0.1.1-rc.2` | peers `@deepseek-ai/dsh-*@^0.1.0-rc.7` |
281
281
  | `0.3.7` / `0.4.0` (npm) | published | **new DSH, settings `installSection`** — `>=0.1.2-alpha.2 <0.2.0-0` | `engines.dsh` + peers `>=0.1.2-alpha.2 <0.2.0-0` |
282
- | `0.5.0` (本期 / this release) | pre-release | **DSH `0.1.2-alpha.2` … `<0.3.0-0`** — 设置卡迁移到 entry-id volatile Config(`0.1.7+`/`0.2.x`),`≤0.1.5` 自动回退 `installSection` | `engines.dsh` + peers `>=0.1.2-alpha.2 <0.3.0-0` |
282
+ | `0.5.0` (npm) | published | **DSH `0.1.2-alpha.2` … `<0.3.0-0`** — 设置卡迁移到 entry-id volatile Config(`0.1.7+`/`0.2.x`),`≤0.1.5` 自动回退 `installSection` | `engines.dsh` + peers `>=0.1.2-alpha.2 <0.3.0-0` |
283
+ | `0.5.1` (本期 / this release) | published | 同 `0.5.0` — 会话扫描修复(`session.v<N>.jsonl.zstd`,issue #8)、数据导出/导入 GUI、替换/冲突可视化(issue #3) | 同 `0.5.0` |
283
284
 
284
285
  ### Where the API family broke — `0.1.2-alpha.2`
285
286
 
@@ -329,7 +330,14 @@ drives mutations).
329
330
  So: keep DSH on `0.1.1-rc.2` or older while running a release ≤ `0.3.6`, and
330
331
  upgrade the plugin to `>=0.3.7` before moving DSH to `0.1.2-alpha.2`.
331
332
 
332
- ### Current release — `0.5.0`
333
+ ### Current release — `0.5.1`
334
+
335
+ Patch release on top of `0.5.0`: session scanning resolves the **versioned** log
336
+ name (`session.v3/v4.jsonl.zstd`) instead of the hardcoded
337
+ `session.jsonl.zstd` — on current DSH that mismatch made `/stats`, `/toolstats`,
338
+ BoundaryPrune's repayment history and the CJK session search all return zero
339
+ silently (issue #8). The memory viewer also gains data export/import and
340
+ supersede/contradict visualization (issue #3).
333
341
 
334
342
  The published `0.4.0` declared `>=0.1.2-alpha.2 <0.2.0-0` — which the semver
335
343
  gate **rejects on every DSH `0.2.0-0` prerelease** (`<0.2.0-0` excludes
package/README.zh.md CHANGED
@@ -166,8 +166,8 @@ curl -s -X POST http://127.0.0.1:3080/api/dsh-engram/import \
166
166
 
167
167
  <!-- dsh-market 兼容信息块 —— 与 docs/RELEASE-NOTES.TEMPLATE.md 的市场 README 公约保持一致 -->
168
168
 
169
- - **DSH 版本**:`>=0.1.2-alpha.2 <0.3.0-0`(本版 `0.5.0`;已发布的 `0.3.7`、`0.4.0` 仍声明旧的 `0.1.x` 范围 `>=0.1.2-alpha.2 <0.2.0-0` —— 见下文矩阵)
170
- - **最后验证日期**:2026-10-15 —— 设置模型迁移(entry-id volatile Config,兼容 DSH `0.2.x`)落地,npm test 329/329 通过;真实 `dsh 0.2.0-rc.2` 隔离 profile 运行烟测通过(见下文“当前发布”)
169
+ - **DSH 版本**:`>=0.1.2-alpha.2 <0.3.0-0`(本版 `0.5.1`;已发布的 `0.3.7`、`0.4.0` 仍声明旧的 `0.1.x` 范围 `>=0.1.2-alpha.2 <0.2.0-0` —— 见下文矩阵)
170
+ - **最后验证日期**:2026-10-19 —— 会话扫描修复(`session.v<N>.jsonl.zstd`,issue #8)+ 数据导出/导入与替换/冲突可视化(issue #3),npm test 334/334 通过
171
171
  - **安装方式已验证**:`dsh plugin --profile web add dsh-engram`
172
172
 
173
173
  > **一句话结论**:所有**已真正发布**的版本(`0.2.0` … `0.3.6`)**只支持旧版
@@ -179,7 +179,8 @@ curl -s -X POST http://127.0.0.1:3080/api/dsh-engram/import \
179
179
  | --- | --- | --- | --- |
180
180
  | `0.2.0` … `0.3.6`(npm 全量 + GitHub v0.3.3–v0.3.6) | 已发布 | **仅旧版** —— `0.1.0-rc.7` / `0.1.0-rc.8` / `0.1.1-rc.1` / `0.1.1-rc.2` | peers `@deepseek-ai/dsh-*@^0.1.0-rc.7` |
181
181
  | `0.3.7` / `0.4.0`(npm) | 已发布 | **新版 DSH,settings `installSection`** —— `>=0.1.2-alpha.2 <0.2.0-0` | `engines.dsh` + peers 同范围 |
182
- | `0.5.0`(本期 / this release) | 预发布 | **DSH `0.1.2-alpha.2` … `<0.3.0-0`** —— 设置卡迁移到 entry-id volatile Config(`0.1.7+`/`0.2.x`),`≤0.1.5` 自动回退 `installSection` | `engines.dsh` + peers `>=0.1.2-alpha.2 <0.3.0-0` |
182
+ | `0.5.0`(npm) | 已发布 | **DSH `0.1.2-alpha.2` … `<0.3.0-0`** —— 设置卡迁移到 entry-id volatile Config(`0.1.7+`/`0.2.x`),`≤0.1.5` 自动回退 `installSection` | `engines.dsh` + peers `>=0.1.2-alpha.2 <0.3.0-0` |
183
+ | `0.5.1`(本期 / this release) | 已发布 | 同 `0.5.0` —— 会话扫描修复(`session.v<N>.jsonl.zstd`,issue #8)、数据导出/导入 GUI、替换/冲突可视化(issue #3) | 同 `0.5.0` |
183
184
 
184
185
  ### API 族断裂点:`0.1.2-alpha.2`
185
186
 
@@ -219,7 +220,13 @@ profile 条目 id(`engram`)为命名空间,不再需要任何“挂载”
219
220
  所以:跑 ≤ `0.3.6` 的已发布版本时请把 DSH 保持在 `0.1.1-rc.2` 或更旧;升级
220
221
  DSH 到 `0.1.2-alpha.2` 之前,先升级插件到 `>=0.3.7`。
221
222
 
222
- ### 当前发布版本 —— `0.5.0`
223
+ ### 当前发布版本 —— `0.5.1`
224
+
225
+ 在 `0.5.0` 之上的补丁版:会话扫描改为解析**版本化**日志名
226
+ (`session.v3/v4.jsonl.zstd`)而不硬编码 `session.jsonl.zstd`——在当前 DSH 上
227
+ 这个错位让 `/stats`、`/toolstats`、BoundaryPrune 的偿还历史、CJK 会话搜索全部
228
+ **静默返回 0**(issue #8)。记忆查看器同时补上数据导出/导入与替换/冲突可视化
229
+ (issue #3)。
223
230
 
224
231
  已发布的 `0.4.0` 声明 `>=0.1.2-alpha.2 <0.2.0-0`——这个范围被 semver 门槛在
225
232
  **所有 DSH `0.2.0-0` 预发布**上拒绝(`<0.2.0-0` 排除 `0.2.0-rc.x`),且其宿主设置端
@@ -0,0 +1,54 @@
1
+ ## dsh-engram v0.5.1
2
+
3
+ 补丁版:修复会话日志扫描对**版本化文件名**的硬编码(issue #8,所有会话扫描
4
+ 功能静默返回 0),并补齐记忆查看器的**数据导出/导入**与**替换/冲突可视化**
5
+ (issue #3 收尾)。
6
+
7
+ ### 修复
8
+
9
+ - **会话扫描识别 `session.v<N>.jsonl.zstd`**(#8)—— `lib/usage.js`、
10
+ `lib/boundary-prune.js`、`lib/session-cjk.js` 曾硬编码 `session.jsonl.zstd`;
11
+ 当前 DSH 把会话格式版本写进文件名(`0.1.x` → `session.v3.jsonl.zstd`,
12
+ `0.2.x` → `session.v4.jsonl.zstd`),`statSync` 抛 `ENOENT` 后被循环 `continue`
13
+ 静默吞掉,导致:
14
+
15
+ - 设置页 → 遥测恒为「样本不足(0 次)」,`GET /stats`、`GET /toolstats` 全 0;
16
+ - BoundaryPrune 的 `workspaceHistory()`(L0 偿还输入)看不到任何会话;
17
+ - CJK 跨会话搜索恒返回 `[]`。
18
+
19
+ 新增 `sessionLogFile(dir)`:正则匹配 `session[.v<N>].jsonl.zstd`,取目录内
20
+ **mtime 最新**的一个(迁移会话可能 v3/v4/legacy 并存,最新 = 宿主正在追加的
21
+ 文件),无匹配时回退历史名、调用方保留原有 ENOENT 处理。四处调用点统一改用它。
22
+ 本机实测(14 天窗口):`files 0 → 17`、`events 0 → 643`。
23
+
24
+ ### 功能
25
+
26
+ - **数据备份面板**(#3)—— 设置页 → Engram 记忆 →「数据备份」:一键导出当前
27
+ 工作区(或全部)为 `dsh-engram/export` v1 JSON 并下载;导入支持 **dry-run 预览**
28
+ → `merge`(只补缺失,幂等)/ `replace`(需勾选确认,宿主要求
29
+ `confirm:"restore"`)。宿主端的 `GET /export`、`POST /import` 早已存在,本版只补
30
+ GUI 入口,GUI 与 API 走同一格式。
31
+ - **替换 / 冲突可视化**(#3)—— 记忆表给被替代的旧行挂「已替换」徽标、给新行挂
32
+ 「替代 #id」跳转;记忆详情面板新增「替换 / 冲突」双向链(出边来自记录的
33
+ `supersedes`/`contradicts`,入边由已加载列表反查),元数据露出
34
+ `source`/`conditions`/`filePath`。
35
+
36
+ ### 测试
37
+
38
+ - 334/334(新增 5 个 #8 用例:版本化/纯 legacy/v3+v4+legacy 混合/陈旧 legacy
39
+ 不遮蔽 v4/无关文件名不被误选,以及 CJK 版本化读取与 `workspaceHistory` 计数)。
40
+ - `npm run typecheck`、`npm run prepublish-check` 通过;`lib/client.js` 重新构建。
41
+
42
+ ## DSH 版本支持
43
+
44
+ - **支持的 DSH 范围**:>=0.1.2-alpha.2 <0.3.0-0
45
+ - **peer 声明**:
46
+ - `@deepseek-ai/dsh-compaction`: `>=0.1.2-alpha.2 <0.3.0-0`
47
+ - `@deepseek-ai/dsh-compaction-basic`: `>=0.1.2-alpha.2 <0.3.0-0`
48
+ - `@deepseek-ai/dsh-llm`: `>=0.1.2-alpha.2 <0.3.0-0`
49
+ - `@deepseek-ai/dsh-settings`: `>=0.1.2-alpha.2 <0.3.0-0`
50
+ - `@deepseek-ai/dsh-storage-domain`: `>=0.1.2-alpha.2 <0.3.0-0`
51
+ - **验证版本**:`dsh-v0.2.0-rc.2`(与 0.5.0 相同的宿主依赖集;本版只改会话日志
52
+ 解析与纯客户端界面,未触碰设置/注入路径)
53
+ - **最后验证日期**:2026-10-19
54
+ - **安装方式已验证**:`dsh plugin --profile web add dsh-engram`
@@ -40,6 +40,7 @@ import fs from "node:fs";
40
40
  import os from "node:os";
41
41
  import path from "node:path";
42
42
 
43
+ import { sessionLogFile } from "./usage.js";
43
44
  import { workspaceKey as wk } from "./util.js";
44
45
 
45
46
  /** Fixed fallback for the repayment gate when no history exists yet. */
@@ -93,7 +94,7 @@ export function workspaceHistory(root = path.join(os.homedir(), ".dsh", "session
93
94
  let sessions = 0;
94
95
  const bdir = path.join(root, bucket);
95
96
  for (const sid of fs.readdirSync(bdir)) {
96
- const file = path.join(bdir, sid, "session.jsonl.zstd");
97
+ const file = sessionLogFile(path.join(bdir, sid));
97
98
  try {
98
99
  const st = fs.statSync(file);
99
100
  if (st.size > 0) sessions += 1;
package/lib/client.js CHANGED
@@ -153,6 +153,28 @@ var EngramApi = class {
153
153
  })
154
154
  );
155
155
  }
156
+ /**
157
+ * Data contract (issue #3) — dump the selected workspace, or every
158
+ * workspace when omitted, as a `dsh-engram/export` v1 payload. The same
159
+ * format `POST /import` restores, so the dump is a portable backup.
160
+ */
161
+ async exportData(workspace) {
162
+ return readJson(await fetch(`${API_PREFIX}/export${query({ workspace })}`));
163
+ }
164
+ /**
165
+ * Data contract (issue #3) — restore a dump. `merge` adds missing rows
166
+ * (idempotent), `replace` wipes the target workspace first (the host then
167
+ * requires `confirm: "restore"`). `dryRun` plans without writing.
168
+ */
169
+ async importData(body) {
170
+ return readJson(
171
+ await fetch(`${API_PREFIX}/import`, {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json" },
174
+ body: JSON.stringify(body)
175
+ })
176
+ );
177
+ }
156
178
  /** Settings card transport — read the `dsh-engram` namespace through our
157
179
  * own HTTP API instead of the host connection/remote RPC. */
158
180
  async getSettings() {
@@ -2254,7 +2276,7 @@ function gapsOf(t) {
2254
2276
  if (!t.memoryRefs || t.memoryRefs.length === 0) gaps.push("memory_ref");
2255
2277
  return gaps;
2256
2278
  }
2257
- function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMemory, onChanged }) {
2279
+ function EngramDetail({ target, api, tasks, nodes, links, memories = [], onClose, onNavigateMemory, onChanged }) {
2258
2280
  const { dark, vars } = useEngramTheme();
2259
2281
  const [gArt, setGArt] = (0, import_react6.useState)("");
2260
2282
  const [gEval, setGEval] = (0, import_react6.useState)("");
@@ -2274,6 +2296,15 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2274
2296
  if (target.kind !== "node" || !node) return [];
2275
2297
  return links.filter((l) => l.source === node.id || l.target === node.id).map((l) => ({ l, outgoing: l.source === node.id }));
2276
2298
  }, [links, node, target.kind]);
2299
+ const memoryLinks = (0, import_react6.useMemo)(() => {
2300
+ if (target.kind !== "memory") return null;
2301
+ const m = target.memory;
2302
+ return {
2303
+ m,
2304
+ replacedBy: memories.filter((x) => x.status === "active" && x.supersedes === m.id),
2305
+ contradictedBy: memories.filter((x) => x.status === "active" && x.contradicts === m.id)
2306
+ };
2307
+ }, [target, memories]);
2277
2308
  const hb2 = {
2278
2309
  root: {
2279
2310
  ...vars,
@@ -2304,6 +2335,8 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2304
2335
  badge: { fontSize: 10.5, fontWeight: 700, padding: "2px 8px", borderRadius: 999, color: "#fff" },
2305
2336
  tag: { fontSize: 11, padding: "1px 8px", borderRadius: 999, background: "var(--dsh-color-hover-bg, #f3f4f6)", color: "var(--dsh-color-muted, #6b7280)", border: "1px solid var(--dsh-color-border, #e5e7eb)" },
2306
2337
  chip: { fontSize: 11, padding: "2px 9px", borderRadius: 999, background: "rgba(37,99,235,.1)", color: "#2563eb", border: "1px solid rgba(37,99,235,.25)", cursor: "pointer" },
2338
+ chipSup: { fontSize: 11, padding: "2px 9px", borderRadius: 999, background: "rgba(124,58,237,.1)", color: "#7c3aed", border: "1px solid rgba(124,58,237,.25)", cursor: "pointer" },
2339
+ chipDanger: { fontSize: 11, padding: "2px 9px", borderRadius: 999, background: "rgba(220,38,38,.1)", color: "#dc2626", border: "1px solid rgba(220,38,38,.25)", cursor: "pointer" },
2307
2340
  input: { fontSize: 12, padding: "5px 8px", border: "1px solid var(--dsh-color-border, #d1d5db)", borderRadius: 8, background: "var(--dsh-color-surface, #ffffff)", color: "var(--dsh-color-muted-strong, #374151)", width: "100%", boxSizing: "border-box" },
2308
2341
  btn: { fontSize: 11.5, fontWeight: 600, padding: "5px 10px", borderRadius: 8, cursor: "pointer", border: "1px solid var(--dsh-color-border, #d1d5db)", background: "transparent", color: "var(--dsh-color-muted-strong, #374151)" },
2309
2342
  btnPrimary: { fontSize: 11.5, fontWeight: 700, padding: "5px 12px", borderRadius: 8, cursor: "pointer", border: "none", background: "#2563eb", color: "#fff" },
@@ -2442,6 +2475,29 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2442
2475
  t
2443
2476
  ] }, t)) })
2444
2477
  ] }),
2478
+ memoryLinks && (memoryLinks.m.supersedes || memoryLinks.m.contradicts || memoryLinks.replacedBy.length > 0 || memoryLinks.contradictedBy.length > 0) && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: hb2.section, children: [
2479
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { style: hb2.k, children: "\u66FF\u6362 / \u51B2\u7A81" }),
2480
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: { display: "flex", gap: 5, flexWrap: "wrap", marginTop: 3 }, children: [
2481
+ memoryLinks.m.supersedes && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipSup, title: `\u672C\u6761\u66FF\u4EE3 ${memoryLinks.m.supersedes}\uFF08\u65E7\u884C\u4FDD\u7559\u3001\u53EC\u56DE\u964D\u7EA7\uFF09`, onClick: () => onNavigateMemory(memoryLinks.m.supersedes), children: [
2482
+ "\u66FF\u4EE3 #",
2483
+ memoryLinks.m.supersedes.slice(0, 8)
2484
+ ] }),
2485
+ memoryLinks.m.contradicts && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipDanger, title: `\u672C\u6761\u4E0E ${memoryLinks.m.contradicts} \u51B2\u7A81\uFF08\u4E24\u6761\u90FD\u4FDD\u7559\uFF09`, onClick: () => onNavigateMemory(memoryLinks.m.contradicts), children: [
2486
+ "\u51B2\u7A81 #",
2487
+ memoryLinks.m.contradicts.slice(0, 8)
2488
+ ] }),
2489
+ memoryLinks.replacedBy.map((x) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipSup, title: `\u5DF2\u88AB ${x.text.slice(0, 40)}\u2026 \u66FF\u4EE3`, onClick: () => onNavigateMemory(x.id), children: [
2490
+ "\u2190 \u88AB #",
2491
+ x.id.slice(0, 8),
2492
+ " \u66FF\u4EE3"
2493
+ ] }, x.id)),
2494
+ memoryLinks.contradictedBy.map((x) => /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { style: hb2.chipDanger, title: `${x.text.slice(0, 40)}\u2026 \u4E0E\u672C\u6761\u51B2\u7A81`, onClick: () => onNavigateMemory(x.id), children: [
2495
+ "\u21C4 #",
2496
+ x.id.slice(0, 8),
2497
+ " \u51B2\u7A81"
2498
+ ] }, x.id))
2499
+ ] })
2500
+ ] }),
2445
2501
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: hb2.section, children: [
2446
2502
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { style: hb2.k, children: "\u5143\u6570\u636E" }),
2447
2503
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { style: { display: "flex", flexDirection: "column", gap: 2, marginTop: 3, fontSize: 11.5, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", color: "var(--dsh-color-muted, #6b7280)" }, children: [
@@ -2469,6 +2525,18 @@ function EngramDetail({ target, api, tasks, nodes, links, onClose, onNavigateMem
2469
2525
  " \xB7 updated ",
2470
2526
  fmtDate(target.memory.updatedAt)
2471
2527
  ] }),
2528
+ target.memory.source && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2529
+ "source ",
2530
+ target.memory.source
2531
+ ] }),
2532
+ target.memory.conditions && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2533
+ "conditions ",
2534
+ target.memory.conditions
2535
+ ] }),
2536
+ target.memory.filePath && /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2537
+ "file ",
2538
+ target.memory.filePath
2539
+ ] }),
2472
2540
  /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
2473
2541
  "TTL ",
2474
2542
  daysLeft(target.memory.expiresAt)
@@ -2791,6 +2859,14 @@ function EngramSection({ api, t }) {
2791
2859
  const [closeEval, setCloseEval] = (0, import_react7.useState)("");
2792
2860
  const [closeRefs, setCloseRefs] = (0, import_react7.useState)("");
2793
2861
  const [closeBusy, setCloseBusy] = (0, import_react7.useState)(false);
2862
+ const [importDump, setImportDump] = (0, import_react7.useState)(null);
2863
+ const [importName, setImportName] = (0, import_react7.useState)("");
2864
+ const [importMode, setImportMode] = (0, import_react7.useState)("merge");
2865
+ const [importDryRun, setImportDryRun] = (0, import_react7.useState)(true);
2866
+ const [importReplaceOk, setImportReplaceOk] = (0, import_react7.useState)(false);
2867
+ const [importBusy, setImportBusy] = (0, import_react7.useState)(false);
2868
+ const [importReport, setImportReport] = (0, import_react7.useState)(null);
2869
+ const [dataError, setDataError] = (0, import_react7.useState)(null);
2794
2870
  const refresh = (0, import_react7.useCallback)(async () => {
2795
2871
  setBusy(true);
2796
2872
  setError(null);
@@ -2847,6 +2923,24 @@ function EngramSection({ api, t }) {
2847
2923
  for (const t2 of tasks) map.set(t2.id, t2.name);
2848
2924
  return (id) => map.get(id) ?? id;
2849
2925
  }, [nodes, tasks]);
2926
+ const supersedeIndex = (0, import_react7.useMemo)(() => {
2927
+ const replacedBy = /* @__PURE__ */ new Map();
2928
+ const contradictedBy = /* @__PURE__ */ new Map();
2929
+ for (const m of memories) {
2930
+ if (m.status !== "active") continue;
2931
+ if (m.supersedes) {
2932
+ const list = replacedBy.get(m.supersedes) ?? [];
2933
+ list.push(m);
2934
+ replacedBy.set(m.supersedes, list);
2935
+ }
2936
+ if (m.contradicts) {
2937
+ const list = contradictedBy.get(m.contradicts) ?? [];
2938
+ list.push(m);
2939
+ contradictedBy.set(m.contradicts, list);
2940
+ }
2941
+ }
2942
+ return { replacedBy, contradictedBy };
2943
+ }, [memories]);
2850
2944
  (0, import_react7.useEffect)(() => {
2851
2945
  if (!newTaskWs && workspaces.length > 0 && !workspace) setNewTaskWs(workspaces[0]);
2852
2946
  }, [newTaskWs, workspaces, workspace]);
@@ -2880,6 +2974,60 @@ function EngramSection({ api, t }) {
2880
2974
  setGcRunning(false);
2881
2975
  }
2882
2976
  };
2977
+ const downloadExport = async () => {
2978
+ setDataError(null);
2979
+ try {
2980
+ const dump = await api.exportData(workspace || void 0);
2981
+ const blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" });
2982
+ const url = URL.createObjectURL(blob);
2983
+ const a = document.createElement("a");
2984
+ const safe = (workspace || "all").replace(/[^a-zA-Z0-9._-]+/g, "_");
2985
+ a.href = url;
2986
+ a.download = `dsh-engram-export-${safe}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`;
2987
+ a.click();
2988
+ URL.revokeObjectURL(url);
2989
+ } catch (e) {
2990
+ setDataError(e instanceof Error ? e.message : String(e));
2991
+ }
2992
+ };
2993
+ const pickImportFile = async (file) => {
2994
+ setDataError(null);
2995
+ setImportReport(null);
2996
+ setImportDump(null);
2997
+ setImportName("");
2998
+ if (!file) return;
2999
+ try {
3000
+ const text = await file.text();
3001
+ setImportDump(JSON.parse(text));
3002
+ setImportName(file.name);
3003
+ } catch (e) {
3004
+ setDataError(`\u65E0\u6CD5\u89E3\u6790 ${file.name}\uFF1A${e instanceof Error ? e.message : String(e)}`);
3005
+ }
3006
+ };
3007
+ const runImport = async () => {
3008
+ if (importDump === null) return;
3009
+ if (importMode === "replace" && !importReplaceOk) {
3010
+ setDataError("\u66FF\u6362\u6A21\u5F0F\u4F1A\u5148\u6E05\u7A7A\u76EE\u6807\u5DE5\u4F5C\u533A \u2014 \u8BF7\u5148\u52FE\u9009\u786E\u8BA4");
3011
+ return;
3012
+ }
3013
+ setImportBusy(true);
3014
+ setDataError(null);
3015
+ try {
3016
+ const report = await api.importData({
3017
+ payload: importDump,
3018
+ mode: importMode,
3019
+ dryRun: importDryRun,
3020
+ workspace: workspace || void 0,
3021
+ confirm: importMode === "replace" ? "restore" : void 0
3022
+ });
3023
+ setImportReport(report);
3024
+ if (!report.dryRun) await refresh();
3025
+ } catch (e) {
3026
+ setDataError(e instanceof Error ? e.message : String(e));
3027
+ } finally {
3028
+ setImportBusy(false);
3029
+ }
3030
+ };
2883
3031
  const groupedRows = workspace === "" ? groupByWorkspace(memories) : [[workspace, memories]];
2884
3032
  const taskGroups = workspace === "" ? groupByWorkspace(tasks) : [[workspace, tasks]];
2885
3033
  const linkGroups = workspace === "" ? groupByWorkspace(links) : [[workspace, links]];
@@ -3069,6 +3217,90 @@ function EngramSection({ api, t }) {
3069
3217
  ] }, l.id))
3070
3218
  ] })
3071
3219
  ] }),
3220
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: s2.subPanel, children: [
3221
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }, children: [
3222
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { fontWeight: 700, fontSize: 13 }, children: "\u6570\u636E\u5907\u4EFD\uFF08export / import\uFF09" }),
3223
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { style: s2.btn, title: "\u4E0B\u8F7D\u5F53\u524D\u5DE5\u4F5C\u533A\uFF08\u6216\u5168\u90E8\uFF09\u7684 dsh-engram/export v1 \u8F6C\u50A8", onClick: () => void downloadExport(), children: "\u5BFC\u51FA JSON" }),
3224
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { ...s2.btn, display: "inline-flex", alignItems: "center", gap: 6, cursor: "pointer" }, children: [
3225
+ "\u9009\u62E9\u5BFC\u5165\u6587\u4EF6\u2026",
3226
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3227
+ "input",
3228
+ {
3229
+ type: "file",
3230
+ accept: "application/json,.json",
3231
+ style: { display: "none" },
3232
+ onChange: (e) => {
3233
+ void pickImportFile(e.target.files?.[0] ?? null);
3234
+ e.currentTarget.value = "";
3235
+ }
3236
+ }
3237
+ )
3238
+ ] }),
3239
+ importName && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: s2.mono, children: importName })
3240
+ ] }),
3241
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { fontSize: 11.5, color: "var(--dsh-color-muted, #6b7280)", marginTop: 4 }, children: [
3242
+ "\u5BFC\u51FA\u5373\u53EF\u79FB\u690D\u5907\u4EFD\uFF08\u8BB0\u5FC6/\u4EFB\u52A1/\u5173\u7CFB/\u8282\u70B9\uFF0C\u542B\u5F52\u6863\uFF09\uFF1B",
3243
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("b", { children: "merge" }),
3244
+ " \u53EA\u8865\u7F3A\u5931\uFF08\u5E42\u7B49\uFF09\uFF0C",
3245
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("b", { children: "replace" }),
3246
+ " \u5148\u6E05\u7A7A\u76EE\u6807\u5DE5\u4F5C\u533A\u518D\u6062\u590D\u3002\u8BB0\u5FC6\u6B63\u6587\u4E0E\u6240\u6709\u5199\u5165\u8DEF\u5F84\u4E00\u6837\u8FC7\u8131\u654F\u3002"
3247
+ ] }),
3248
+ importDump !== null && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { marginTop: 8, display: "flex", flexDirection: "column", gap: 6 }, children: [
3249
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", fontSize: 12.5 }, children: [
3250
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { display: "inline-flex", gap: 4, alignItems: "center" }, children: [
3251
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("input", { type: "checkbox", checked: importDryRun, onChange: (e) => setImportDryRun(e.target.checked) }),
3252
+ "\u4EC5\u9884\u89C8\uFF08dry run\uFF09"
3253
+ ] }),
3254
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
3255
+ "select",
3256
+ {
3257
+ style: s2.input,
3258
+ value: importMode,
3259
+ onChange: (e) => {
3260
+ const mode = e.target.value;
3261
+ setImportMode(mode);
3262
+ if (mode !== "replace") setImportReplaceOk(false);
3263
+ },
3264
+ children: [
3265
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "merge", children: "\u5408\u5E76\uFF08merge\uFF0C\u5E42\u7B49\uFF09" }),
3266
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "replace", children: "\u66FF\u6362\uFF08replace\uFF0C\u5148\u6E05\u7A7A\u5DE5\u4F5C\u533A\uFF09" })
3267
+ ]
3268
+ }
3269
+ ),
3270
+ importMode === "replace" && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("label", { style: { display: "inline-flex", gap: 4, alignItems: "center", color: "#b45309" }, children: [
3271
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("input", { type: "checkbox", checked: importReplaceOk, onChange: (e) => setImportReplaceOk(e.target.checked) }),
3272
+ "\u6211\u786E\u8BA4\u66FF\u6362",
3273
+ workspace ? `\uFF08${workspace}\uFF09` : "\uFF08\u9700\u5148\u9009\u5DE5\u4F5C\u533A\uFF09"
3274
+ ] }),
3275
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3276
+ "button",
3277
+ {
3278
+ style: s2.btnPrimary,
3279
+ disabled: importBusy || importMode === "replace" && (!importReplaceOk || !workspace),
3280
+ onClick: () => void runImport(),
3281
+ children: importBusy ? "\u2026" : importDryRun ? "\u9884\u89C8\u5BFC\u5165" : "\u6267\u884C\u5BFC\u5165"
3282
+ }
3283
+ )
3284
+ ] }),
3285
+ importReport && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: { fontSize: 12.5 }, children: [
3286
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { children: [
3287
+ importReport.dryRun ? "dry-run \u9884\u89C8\uFF1A" : "\u5DF2\u5BFC\u5165\uFF1A",
3288
+ " ",
3289
+ Object.entries(importReport.written).map(([k, v]) => `${k} ${v}`).join(" \xB7 ") || "\u2014",
3290
+ importReport.skipped.length > 0 ? ` \xB7 \u8DF3\u8FC7 ${importReport.skipped.length}` : ""
3291
+ ] }),
3292
+ importReport.skipped.slice(0, 5).map((x, i) => /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: s2.mono, children: [
3293
+ "- ",
3294
+ x.table,
3295
+ " ",
3296
+ x.id ? x.id.slice(0, 8) : "?",
3297
+ ": ",
3298
+ x.reason
3299
+ ] }, `${x.table}-${x.id ?? i}`))
3300
+ ] }),
3301
+ dataError && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: { fontSize: 12, color: "#dc2626", fontWeight: 600 }, children: dataError })
3302
+ ] })
3303
+ ] }),
3072
3304
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("div", { style: s2.row, children: [
3073
3305
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("select", { style: s2.input, value: kind, onChange: (e) => setKind(e.target.value), children: [
3074
3306
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("option", { value: "", children: "\u5168\u90E8\u7C7B\u578B" }),
@@ -3142,7 +3374,8 @@ function EngramSection({ api, t }) {
3142
3374
  ] }) }, `h-${r.ws}`) : /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("tr", { children: [
3143
3375
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("td", { style: { ...s2.td, whiteSpace: "nowrap" }, children: [
3144
3376
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.badge, background: KIND_COLORS2[r.m.kind] ?? "#6b7280" }, children: KIND_LABEL2[r.m.kind] ?? r.m.kind }),
3145
- r.m.status === "archived" && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.tag, color: "#b45309", background: "#fef3c7" }, children: "archived" })
3377
+ r.m.status === "archived" && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.tag, color: "#b45309", background: "#fef3c7" }, children: "archived" }),
3378
+ supersedeIndex.replacedBy.has(r.m.id) && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: { ...s2.tag, color: "#b45309", background: "#fef3c7" }, title: `\u5DF2\u88AB ${(supersedeIndex.replacedBy.get(r.m.id) ?? []).map((x) => `#${x.id.slice(0, 8)}`).join(", ")} \u66FF\u4EE3\uFF08\u65E7\u884C\u4FDD\u7559\u3001\u53EC\u56DE\u964D\u7EA7\uFF09`, children: "\u5DF2\u66FF\u6362" })
3146
3379
  ] }),
3147
3380
  /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("td", { style: { ...s2.td, minWidth: 0 }, children: [
3148
3381
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { title: r.m.text, style: expandedRows.has(r.m.id) ? s2.expanded : s2.clamp3, children: r.m.text }),
@@ -3159,7 +3392,11 @@ function EngramSection({ api, t }) {
3159
3392
  " \xB7 ",
3160
3393
  daysLeft2(r.m.expiresAt)
3161
3394
  ] }),
3162
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { style: s2.linkBtn, onClick: () => toggleExpand(r.m.id), children: expandedRows.has(r.m.id) ? "\u6536\u8D77" : "\u5C55\u5F00\u5168\u6587" })
3395
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { style: s2.linkBtn, onClick: () => toggleExpand(r.m.id), children: expandedRows.has(r.m.id) ? "\u6536\u8D77" : "\u5C55\u5F00\u5168\u6587" }),
3396
+ r.m.supersedes && /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("button", { style: s2.linkBtn, title: `\u6253\u5F00\u88AB\u66FF\u4EE3\u7684\u65E7\u8BB0\u5FC6 ${r.m.supersedes}`, onClick: () => openMemory(r.m.supersedes), children: [
3397
+ "\u66FF\u4EE3 #",
3398
+ r.m.supersedes.slice(0, 8)
3399
+ ] })
3163
3400
  ] }),
3164
3401
  r.m.tags.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { style: { marginTop: 3, display: "flex", flexWrap: "wrap", gap: 4 }, children: r.m.tags.map((tag) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { style: s2.tag, children: tag }, tag)) })
3165
3402
  ] }),
@@ -3359,6 +3596,7 @@ ${r.m.text.slice(0, 60)}`)) void act(() => api.remove(r.m.id, r.m.workspace));
3359
3596
  tasks,
3360
3597
  nodes,
3361
3598
  links,
3599
+ memories,
3362
3600
  onClose: () => setDetail(null),
3363
3601
  onNavigateMemory: openMemory,
3364
3602
  onChanged: () => void refresh()
@@ -22,7 +22,7 @@
22
22
  import fs from "node:fs";
23
23
  import path from "node:path";
24
24
 
25
- import { decodeZstd, sessionsRoot } from "./usage.js";
25
+ import { decodeZstd, sessionLogFile, sessionsRoot } from "./usage.js";
26
26
  import { redactText } from "./redact.js";
27
27
 
28
28
  /** CJK ideographs (incl. Ext-A and compatibility ideographs). */
@@ -135,7 +135,7 @@ export function searchSessionsCJK({
135
135
  }
136
136
  const files = [];
137
137
  for (const sid of sids) {
138
- const file = path.join(bucketDir, sid, "session.jsonl.zstd");
138
+ const file = sessionLogFile(path.join(bucketDir, sid));
139
139
  let st;
140
140
  try {
141
141
  st = fs.statSync(file);
package/lib/usage.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * on every engram/esr tool call, and a separate session-log scan that counted
8
8
  * tool calls (to include the built-in `todo` tool). Both are now ONE module:
9
9
  * both /stats and /toolstats answer from the canonical log stream
10
- * `~/.dsh/sessions/<bucket>/<sid>/session.jsonl.zstd`, decompressed on demand
11
- * with a short TTL cache. No tool call pays a write anymore (pure GUI-side
10
+ * `~/.dsh/sessions/<bucket>/<sid>/session[.v<N>].jsonl.zstd`, decompressed on
11
+ * demand with a short TTL cache. No tool call pays a write anymore (pure GUI-side
12
12
  * observability), and the log stream is the single source of truth.
13
13
  *
14
14
  * Zero external dependency: Node >=22.19 provides the zstd codec in
@@ -44,6 +44,49 @@ export function sessionsRoot() {
44
44
  return path.join(os.homedir(), ".dsh", "sessions");
45
45
  }
46
46
 
47
+ /**
48
+ * Session log file names: current DSH stamps the session format version into
49
+ * the name (`session.v3.jsonl.zstd` on 0.1.x, `session.v4.jsonl.zstd` on 0.2);
50
+ * older releases used the unversioned `session.jsonl.zstd` (issue #8).
51
+ */
52
+ const SESSION_LOG_RE = /^session(?:\.v\d+)?\.jsonl\.zstd$/;
53
+
54
+ /**
55
+ * Resolve the log file inside one session directory.
56
+ *
57
+ * A directory normally holds exactly one log, but a session migrated across
58
+ * format versions can hold both a v3 and a v4 file (and a legacy unversioned
59
+ * one). The newest-mtime match wins — that is the file the host is currently
60
+ * appending to. When nothing matches, the historical unversioned name is
61
+ * returned so callers keep their existing ENOENT handling (`statSync` throws →
62
+ * session skipped) instead of silently scanning the wrong corpus.
63
+ */
64
+ export function sessionLogFile(dir) {
65
+ let names;
66
+ try {
67
+ names = fs.readdirSync(dir);
68
+ } catch {
69
+ return path.join(dir, "session.jsonl.zstd");
70
+ }
71
+ let best = null;
72
+ let bestMtime = -1;
73
+ for (const name of names) {
74
+ if (!SESSION_LOG_RE.test(name)) continue;
75
+ let st;
76
+ try {
77
+ st = fs.statSync(path.join(dir, name));
78
+ } catch {
79
+ continue; // unreadable entry: ignore
80
+ }
81
+ if (!st.isFile()) continue;
82
+ if (st.mtimeMs > bestMtime) {
83
+ bestMtime = st.mtimeMs;
84
+ best = name;
85
+ }
86
+ }
87
+ return path.join(dir, best ?? "session.jsonl.zstd");
88
+ }
89
+
47
90
  /** Byte range of one structurally complete zstd frame in a concatenated stream. */
48
91
  function frameRanges(buffer) {
49
92
  const frames = [];
@@ -244,7 +287,7 @@ export function collectToolCounts({ root = sessionsRoot(), days = 14, now = Date
244
287
  for (const bucket of buckets) {
245
288
  const bdir = path.join(root, bucket);
246
289
  for (const sid of listDirOrEmpty(bdir)) {
247
- const file = path.join(bdir, sid, "session.jsonl.zstd");
290
+ const file = sessionLogFile(path.join(bdir, sid));
248
291
  let st;
249
292
  try {
250
293
  st = fs.statSync(file);
@@ -330,7 +373,7 @@ export function collectUsageStats({ root = sessionsRoot(), days = 14, now = Date
330
373
  const buckets = listDirOrEmpty(root).filter((b) => b.startsWith("--"));
331
374
  for (const bucket of buckets) {
332
375
  for (const sid of listDirOrEmpty(path.join(root, bucket))) {
333
- const file = path.join(root, bucket, sid, "session.jsonl.zstd");
376
+ const file = sessionLogFile(path.join(root, bucket, sid));
334
377
  let st;
335
378
  try {
336
379
  st = fs.statSync(file);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-engram",
3
3
  "description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "packageManager": "pnpm@11.22.0",
6
6
  "license": "MIT",
7
7
  "repository": {