dsh-continual-evolve 0.13.0 → 0.14.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
@@ -7,9 +7,9 @@ English | [中文](README.zh.md)
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-62%20passing-brightgreen)]()
11
- [![Coverage · statements](https://img.shields.io/badge/coverage_statements-98%25-brightgreen)]()
12
- [![Coverage · branches](https://img.shields.io/badge/coverage_branches-95%25-green)]()
10
+ [![Tests](https://img.shields.io/badge/tests-80%20passing-brightgreen)]()
11
+ [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
+ [![Coverage · branches](https://img.shields.io/badge/coverage_branches-93%25-green)]()
13
13
  [![Coverage · functions](https://img.shields.io/badge/coverage_functions-100%25-brightgreen)]()
14
14
 
15
15
  A workspace-memory plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`): one plain-markdown memory store per workspace (`<workspace>/.evolve/memory/`), the index injected at session start, used by the model with its **native file tools** — and nothing else.
@@ -23,7 +23,7 @@ Reusable experience gathered in one session (user preferences, pitfalls, project
23
23
  1. **Session-start injection** — the plugin registers a single system-prompt section: the `MEMORY.md` index content (whole-line truncated over budget, with a read-the-directory hint), the absolute store path, and the when_to_save guide. The section is computed once per session and reused byte-for-byte, keeping the system prompt stable and the prompt cache warm; an empty store costs zero tokens.
24
24
  2. **Native read/write** — DSH fully permits file access inside the workspace (reads are never fenced; the write fence only covers paths outside the workspace, see [`docs/FAQ.md`](docs/FAQ.md) #6). The model reads, writes, and edits memory files and maintains the index directly; the store is bootstrapped on first use.
25
25
  3. **Governance is the file** — no versions, no snapshots, no approvals, no background extraction: a bad memory is a visible file in the workspace, and deleting it is the retirement path. Memories are personal context — on bootstrap in a git workspace the store appends `.evolve/` to the workspace `.gitignore` (non-git workspaces are left untouched).
26
- 4. **Read-only card** — the plugin mounts a read-only memory card on its own page in the official plugin manager (the `plugins.bundle.config` slot; hosts without it silently degrade to no card): recently served workspaces, per-workspace entry and index counts, and index-drift warnings (unindexed files, index rows whose file is gone). The card follows the market's look — host theme tokens, the `Button` primitive for refresh, and zh/en copy that tracks the DSH language (plain themed elements on old hosts). The card is a request-time projection of the files on disk — the single source of truth remains `.evolve/memory/`; the card has no edit path, edit the files directly.
26
+ 4. **Read-only card** — the plugin mounts a read-only memory card on its own page in the official plugin manager (the `plugins.bundle.config` slot; hosts without it silently degrade to no card), shaped like the market's own settings card (collapsed by default, header toggle with a rotating chevron, hover/open frame states) and aligned with ZCode's Settings → Memory viewer: one workspace at a time (directory-name chips), a file search box, per-file relative updated times, click-to-preview file bodies (5 MiB cap), plus index-drift warnings (unindexed files, index rows whose file is gone). The plugin's display name and description localize through the host's package `locale/*.json` metadata channel. The card follows the market's look — host theme tokens, the `Button` primitive for refresh, and zh/en copy that tracks the DSH language (plain themed elements on old hosts). The card is a request-time projection of the files on disk — the single source of truth remains `.evolve/memory/`; the card has no edit path, edit the files directly.
27
27
 
28
28
  Memory file format (same as ZCode): frontmatter with `name` / `description` (the hook that decides whether a future session recalls it) / `metadata.type` (`user | feedback | project | reference`); `feedback` bodies must carry **Why:** and **How to apply:** lines.
29
29
 
@@ -66,7 +66,7 @@ Profile patch example:
66
66
 
67
67
  ```bash
68
68
  pnpm install && pnpm build # deps + tsc -> lib/
69
- pnpm test # vitest (66 tests)
69
+ pnpm test # vitest (80 tests)
70
70
  pnpm test:coverage # v8 coverage, CI-enforced thresholds
71
71
  pnpm lint # oxlint src test
72
72
  ```
@@ -83,6 +83,7 @@ Layout:
83
83
  │ └── card-routes.ts # card: GET-only read API on the host webServer
84
84
  ├── client/
85
85
  │ └── client.js # hand-written client bundle: read-only card in the plugin manager
86
+ ├── locale/ # host package metadata: localized display name + description (zh/en)
86
87
  ├── test/ # vitest suite (7 files)
87
88
  ├── lib/ # build output (tsc)
88
89
  ├── docs/ # design.md (design) · FAQ.md (real-world pitfalls)
package/README.zh.md CHANGED
@@ -7,9 +7,9 @@
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-62%20passing-brightgreen)]()
11
- [![Coverage · statements](https://img.shields.io/badge/coverage_statements-98%25-brightgreen)]()
12
- [![Coverage · branches](https://img.shields.io/badge/coverage_branches-95%25-green)]()
10
+ [![Tests](https://img.shields.io/badge/tests-80%20passing-brightgreen)]()
11
+ [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
+ [![Coverage · branches](https://img.shields.io/badge/coverage_branches-93%25-green)]()
13
13
  [![Coverage · functions](https://img.shields.io/badge/coverage_functions-100%25-brightgreen)]()
14
14
 
15
15
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)的工作区记忆插件:每个工作区一个纯 markdown 记忆库(`<workspace>/.evolve/memory/`),会话开局注入索引,模型用**原生文件读写工具**直接使用——仅此而已。
@@ -23,7 +23,7 @@ Agent 每个会话积累的可复用经验(用户偏好、踩坑教训、项
23
23
  1. **开局注入** —— 插件只注册一个 system-prompt section:注入 `MEMORY.md` 索引正文(超预算按整行截断并提示读目录)、记忆目录绝对路径、when_to_save 指南。该 section 每会话只算一次并逐字节复用,system prompt 稳定、prompt cache 持续命中;空 store 零 token。
24
24
  2. **原生读写** —— DSH 对工作区内文件读写全放行(读路径从不设防,写围栏只围工作区外,见 [`docs/FAQ.md`](docs/FAQ.md) #6)。模型直接 Read/Write/Edit 记忆文件并维护索引;store 不存在时自动 bootstrap。
25
25
  3. **治理即文件** —— 无版本、无快照、无审批、无后台提取:一条坏记忆就是工作区里一个可见的文件,删掉它就是退役。记忆是个人上下文——bootstrap 时若工作区是 git 仓库会自动把 `.evolve/` 追加进 `.gitignore`(非 git 工作区不碰任何文件)。
26
- 4. **只读卡片** —— 插件在官方插件管理的本 bundle 页面挂一张只读记忆卡片(`plugins.bundle.config` slot,老宿主自动降级为无卡片):最近服务过的工作区、各自记忆库的条目数/索引行数、索引漂移警告(未索引文件与索引失联文件)。卡片与市场同款——宿主主题 token、`Button` 原语刷新按钮、中英文案跟随 DSH 语言(老宿主降级为同主题裸元素)。卡片是磁盘文件的请求时投影——单一事实源永远是 `.evolve/memory/` 里的文件,卡片没有编辑路径,编辑请直接改文件。
26
+ 4. **只读卡片** —— 插件在官方插件管理的本 bundle 页面挂一张只读记忆卡片(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡(默认收起、头部整行可点 + 旋转 chevron、hover/展开框态),内容对齐 ZCode 设置的记忆选项卡——工作区一次选一个(目录名 chips)、文件搜索框、每个文件的相对更新时间、点击行内展开文件内容预览(5 MiB 上限),外加索引漂移警告(未索引文件与索引失联文件)。插件的显示名与描述走宿主包元数据 `locale/*.json` 通道双语切换。卡片与市场同款——宿主主题 token、`Button` 原语刷新按钮、中英文案跟随 DSH 语言(老宿主降级为同主题裸元素)。卡片是磁盘文件的请求时投影——单一事实源永远是 `.evolve/memory/` 里的文件,卡片没有编辑路径,编辑请直接改文件。
27
27
 
28
28
  记忆文件格式(与 ZCode 一致):frontmatter 带 `name` / `description`(决定未来会话会不会想起它)/ `metadata.type`(`user | feedback | project | reference`);`feedback` 正文必带 **Why:** 与 **How to apply:**。
29
29
 
@@ -66,7 +66,7 @@ profile patch 示例:
66
66
 
67
67
  ```bash
68
68
  pnpm install && pnpm build # 依赖 + tsc -> lib/
69
- pnpm test # vitest(66 例)
69
+ pnpm test # vitest(80 例)
70
70
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
71
71
  pnpm lint # oxlint src test
72
72
  ```
@@ -83,6 +83,7 @@ pnpm lint # oxlint src test
83
83
  │ └── card-routes.ts # 卡片:webServer 上的 GET-only 只读 API
84
84
  ├── client/
85
85
  │ └── client.js # 手写 client bundle:官方插件管理内只读卡片
86
+ ├── locale/ # 宿主包元数据:显示名 + 描述双语(zh/en)
86
87
  ├── test/ # vitest 测试(7 个文件)
87
88
  ├── lib/ # 构建产物(tsc)
88
89
  ├── docs/ # design.md(设计)· FAQ.md(踩坑)
package/client/client.js CHANGED
@@ -17,12 +17,25 @@
17
17
  * call with null (the bundle list row shows the package description; the
18
18
  * card is the page).
19
19
  *
20
- * Market parity (v0.13): locale/theme/primitives wiring copied from
21
- * dshmarket's client — `ctx.locale.register(NS, { zh, en })` + `bind`,
22
- * `var(--dsw-alias-*)` theme tokens, `Button` primitive for the refresh
23
- * control. Missing primitives/locale degrade to themed plain elements, so an
24
- * old host never blanks the settings dialog. The card stays a read-only
25
- * projection: fetched at render time from this plugin's host routes
20
+ * Card form (v0.14): the dshmarket SettingsCard shape, one for one — a
21
+ * collapsed-by-default framed card whose header is the toggle button
22
+ * (name + one-liner + rotating chevron), body rendered only when open, hover
23
+ * and open states on the frame (see that bundle's "why the chrome is
24
+ * hand-built" note: a flat, always-expanded box next to rows that collapse
25
+ * reads as another product). The stylesheet is injected once, like the
26
+ * host's own CSS-module tags.
27
+ *
28
+ * Content (v0.14): aligned with ZCode's Settings → Memory viewer, which the
29
+ * .evolve store shape was itself aligned with — one workspace selected at a
30
+ * time (chips; the known-workspace LRU is bounded at 8), a file search box,
31
+ * per-file relative updated times, and a click-to-preview body served by the
32
+ * host's content endpoint (5 MiB cap, deleted/changed guards). The drift
33
+ * warnings (dangling index rows / unindexed files) stay: they are this
34
+ * plugin's OBSERVATION surface and a deliberate superset of ZCode's viewer.
35
+ * Locale/theme/primitives wiring unchanged from v0.13: `ctx.locale.register`
36
+ * + `bind`, `var(--dsw-alias-*)` tokens, primitives with plain-element
37
+ * fallbacks so an old host never blanks the settings dialog. Read-only
38
+ * projection, fetched at render time from this plugin's host routes
26
39
  * (same-origin). No editor, no write path, no cached state.
27
40
  */
28
41
  window.__ModuleLoader__.load({
@@ -65,9 +78,11 @@ window.__ModuleLoader__.load({
65
78
  var NS = "dsh-continual-evolve";
66
79
  var SLOT = "plugins.bundle.config";
67
80
  var API_ROOT = "/dsh-continual-evolve/api/v1";
81
+ var STYLE_ID = "dsh-continual-evolve-card-css";
68
82
 
69
83
  var zh = {
70
- title: "工作区记忆(只读投影)",
84
+ title: "工作区记忆",
85
+ desc: "各工作区 .evolve/memory/ 的只读投影。",
71
86
  refresh: "刷新",
72
87
  hint: "事实源是各工作区 .evolve/memory/ 下的 markdown 文件——模型原生读写;本卡片只展示,编辑请直接改文件。",
73
88
  loading: "读取中…",
@@ -76,12 +91,25 @@ window.__ModuleLoader__.load({
76
91
  absent: "该工作区尚无记忆库(开一次会话自动创建)。",
77
92
  readFailed: "记忆库读取失败:",
78
93
  stats: "{files} 条记忆 · 索引 {index} 行",
94
+ filesHeading: "文件",
95
+ searchPlaceholder: "搜索记忆文件…",
96
+ searchEmpty: "没有匹配的记忆文件。",
79
97
  missing: "索引失联(索引引用但文件已删):",
80
98
  unindexed: "未入索引(文件存在但索引没有行):",
81
99
  readError: "读取失败:",
100
+ previewLoading: "正在加载文件…",
101
+ previewTooLarge: "该记忆文件超过 5 MiB 预览上限。",
102
+ previewDeleted: "该记忆文件已被删除,请刷新文件列表。",
103
+ previewFailed: "内容读取失败:",
104
+ justNow: "刚刚",
105
+ minutesAgo: "{count} 分钟前",
106
+ today: "今天 {time}",
107
+ yesterday: "昨天 {time}",
108
+ localeTag: "zh-CN",
82
109
  };
83
110
  var en = {
84
- title: "Workspace memory (read-only)",
111
+ title: "Workspace Memory",
112
+ desc: "Read-only projection of each workspace's .evolve/memory/.",
85
113
  refresh: "Refresh",
86
114
  hint: "The source of truth is the markdown files under each workspace's .evolve/memory/ — read and written by the model; this card only displays them, edit the files directly.",
87
115
  loading: "Loading…",
@@ -90,9 +118,21 @@ window.__ModuleLoader__.load({
90
118
  absent: "No memory store in this workspace yet (one is created when you open a session).",
91
119
  readFailed: "Failed to read the memory store: ",
92
120
  stats: "{files} memories · {index} index rows",
121
+ filesHeading: "Files",
122
+ searchPlaceholder: "Search memory files…",
123
+ searchEmpty: "No matching memory files.",
93
124
  missing: "Dangling index (indexed but deleted): ",
94
125
  unindexed: "Unindexed (on disk, missing from the index): ",
95
126
  readError: "Read failed: ",
127
+ previewLoading: "Loading file…",
128
+ previewTooLarge: "This memory file exceeds the 5 MiB preview limit.",
129
+ previewDeleted: "This memory file has been deleted — refresh the file list.",
130
+ previewFailed: "Failed to read the content: ",
131
+ justNow: "Just now",
132
+ minutesAgo: "{count} min ago",
133
+ today: "Today {time}",
134
+ yesterday: "Yesterday {time}",
135
+ localeTag: "en-US",
96
136
  };
97
137
 
98
138
  function format(template, params) {
@@ -102,6 +142,41 @@ window.__ModuleLoader__.load({
102
142
  });
103
143
  }
104
144
 
145
+ /**
146
+ * Relative updated time, ZCode viewer style: just now / N minutes ago /
147
+ * today / yesterday, then a calendar rendering via Intl in the active
148
+ * language (the `localeTag` dictionary key carries that language out).
149
+ */
150
+ function formatRelative(updatedAt, now, t) {
151
+ if (typeof updatedAt !== "number" || !isFinite(updatedAt) || updatedAt <= 0) {
152
+ return "";
153
+ }
154
+ var minute = 60000;
155
+ var diff = now - updatedAt;
156
+ if (diff < minute) {
157
+ return t("justNow");
158
+ }
159
+ if (diff < 60 * minute) {
160
+ return t("minutesAgo", { count: Math.floor(diff / minute) });
161
+ }
162
+ var date = new Date(updatedAt);
163
+ var time = new Intl.DateTimeFormat(t("localeTag"), { hour: "2-digit", minute: "2-digit" }).format(date);
164
+ var startOfDay = new Date(now);
165
+ startOfDay.setHours(0, 0, 0, 0);
166
+ if (updatedAt >= startOfDay.getTime()) {
167
+ return t("today", { time: time });
168
+ }
169
+ if (updatedAt >= startOfDay.getTime() - 24 * 60 * 60 * 1000) {
170
+ return t("yesterday", { time: time });
171
+ }
172
+ var sameYear = date.getFullYear() === new Date(now).getFullYear();
173
+ return new Intl.DateTimeFormat(t("localeTag"), {
174
+ month: "numeric",
175
+ day: "numeric",
176
+ year: sameYear ? undefined : "numeric",
177
+ }).format(date);
178
+ }
179
+
105
180
  /**
106
181
  * Assemble the card's view model: the workspace allowlist, then one
107
182
  * snapshot per workspace. Network or API failure per workspace degrades
@@ -141,157 +216,355 @@ window.__ModuleLoader__.load({
141
216
  return { workspaces: entries };
142
217
  }
143
218
 
144
- var CARD_STYLE = {
145
- background: "var(--dsw-alias-bg-layer-1,#fff)",
146
- border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)",
147
- borderRadius: "12px",
148
- padding: "12px 14px",
149
- display: "flex",
150
- flexDirection: "column",
151
- gap: "12px",
152
- };
153
- var TITLE_STYLE = {
154
- color: "var(--dsw-alias-label-primary,#1f2328)",
155
- fontSize: "15px",
156
- fontWeight: 600,
157
- lineHeight: "22px",
158
- };
159
- var HINT_STYLE = {
160
- color: "var(--dsw-alias-label-tertiary,#8b93a1)",
161
- fontSize: "12px",
162
- lineHeight: "18px",
163
- };
164
- var SECTION_STYLE = {
165
- borderTop: "1px solid var(--dsw-alias-border-l2,#f0f1f3)",
166
- paddingTop: "8px",
167
- display: "flex",
168
- flexDirection: "column",
169
- gap: "6px",
170
- };
171
- var ROOT_STYLE = {
172
- fontFamily: "ui-monospace,Menlo,monospace",
173
- fontSize: "11px",
174
- color: "var(--dsw-alias-label-secondary,#6b7280)",
175
- wordBreak: "break-all",
176
- };
177
- var STATS_STYLE = {
178
- fontSize: "12px",
179
- lineHeight: "18px",
180
- color: "var(--dsw-alias-label-secondary,#6b7280)",
181
- };
182
- var WARN_STYLE = {
183
- fontSize: "12px",
184
- lineHeight: "18px",
185
- color: "var(--dsw-alias-state-warn-primary,#b45309)",
186
- };
187
- var ERROR_STYLE = {
188
- fontSize: "12px",
189
- lineHeight: "18px",
190
- color: "var(--dsw-alias-state-error-primary,#dc2626)",
191
- };
192
- var LIST_STYLE = { margin: "4px 0 0", paddingLeft: "18px", fontSize: "12px", lineHeight: "18px" };
193
- var FALLBACK_BUTTON_STYLE = {
194
- background: "var(--dsw-alias-bg-layer-2,#f3f4f6)",
195
- color: "var(--dsw-alias-label-primary,#1f2328)",
196
- border: "1px solid var(--dsw-alias-border-l2,#e5e7eb)",
197
- borderRadius: "6px",
198
- padding: "3px 10px",
199
- fontSize: "12px",
200
- lineHeight: "18px",
201
- cursor: "pointer",
202
- };
219
+ /**
220
+ * Fetch one memory file's body for the preview. Outcomes mirror the
221
+ * endpoint's status mapping so the card can show the ZCode viewer's
222
+ * deleted / too-large / failed states instead of a raw error.
223
+ *
224
+ * @param {typeof fetch} fetchImpl - injectable for tests.
225
+ * @param {string} root - known workspace root.
226
+ * @param {string} file - plain .md file name inside that memory store.
227
+ * @returns {Promise<{status: "ready"|"deleted"|"tooLarge"|"failed", content: string, message: string}>}
228
+ */
229
+ async function loadFileContent(fetchImpl, root, file) {
230
+ var url = API_ROOT + "/memory/file?root=" + encodeURIComponent(root) + "&file=" + encodeURIComponent(file);
231
+ try {
232
+ var response = await fetchImpl(url);
233
+ if (response.ok) {
234
+ var body = await response.json();
235
+ return {
236
+ status: "ready",
237
+ content: typeof body.content === "string" ? body.content : "",
238
+ message: "",
239
+ };
240
+ }
241
+ if (response.status === 404) {
242
+ return { status: "deleted", content: "", message: "" };
243
+ }
244
+ if (response.status === 413) {
245
+ return { status: "tooLarge", content: "", message: "" };
246
+ }
247
+ return { status: "failed", content: "", message: "HTTP " + response.status };
248
+ } catch (error) {
249
+ return {
250
+ status: "failed",
251
+ content: "",
252
+ message: error instanceof Error ? error.message : String(error),
253
+ };
254
+ }
255
+ }
256
+
257
+ /** Display label for a workspace: its directory name (ZCode shows a project name, not a path). */
258
+ function workspaceLabel(root) {
259
+ var normalized = String(root).replace(/[\\/]+$/, "");
260
+ var index = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
261
+ return index >= 0 ? normalized.slice(index + 1) : normalized;
262
+ }
263
+
264
+ /**
265
+ * Inject the card stylesheet once, the way the host's own CSS-module
266
+ * tags work. The classes are prefixed `dce-` and every value falls back
267
+ * to a fixed color after the token, so an old theme never blanks text.
268
+ */
269
+ function injectCardStyle() {
270
+ if (typeof document === "undefined") {
271
+ return;
272
+ }
273
+ if (document.getElementById(STYLE_ID) !== null) {
274
+ return;
275
+ }
276
+ var style = document.createElement("style");
277
+ style.id = STYLE_ID;
278
+ style.dataset.plugin = NS;
279
+ style.textContent = [
280
+ ".dce-card{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);background:var(--dsw-alias-bg-layer-3,#fff);border-radius:12px;transition:border-color .16s,background .16s}",
281
+ ".dce-card:hover{border-color:var(--dsw-alias-label-dimmed,#c8ccd4)}",
282
+ '.dce-card[data-open="true"]{background:var(--dsw-alias-bg-layer-2,#f7f8fa);border-color:var(--dsw-alias-label-dimmed,#c8ccd4)}',
283
+ ".dce-head{appearance:none;-webkit-appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
284
+ ".dce-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4f6ef7);outline-offset:-2px}",
285
+ ".dce-head-text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
286
+ ".dce-name{color:var(--dsw-alias-label-primary,#1f2328);font-size:15px;font-weight:600;line-height:1.4}",
287
+ ".dce-desc{color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:13px;line-height:1.5}",
288
+ ".dce-chevron{color:var(--dsw-alias-label-tertiary,#8b93a1);flex:none;transition:transform .16s;display:inline-flex}",
289
+ '.dce-chevron[data-open="true"]{transform:rotate(180deg)}',
290
+ ".dce-body{border-top:1px solid var(--dsw-alias-border-l2,#e5e7eb);margin:0 16px;padding:12px 0 14px;display:flex;flex-direction:column;gap:10px}",
291
+ ".dce-hint{color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:12px;line-height:18px}",
292
+ ".dce-empty{border:1px dashed var(--dsw-alias-border-l3,#d1d9e0);border-radius:10px;padding:16px;text-align:center;color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:13px;line-height:20px}",
293
+ ".dce-chips{display:flex;flex-wrap:wrap;gap:6px}",
294
+ ".dce-chip{font:inherit;cursor:pointer;background:0 0;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:var(--dsw-radius-md,8px);color:var(--dsw-alias-label-secondary,#6b7280);padding:3px 10px;font-size:12px;line-height:18px}",
295
+ '.dce-chip[data-active="true"]{background:var(--dsw-alias-bg-module-platform,#eef1f4);border-color:var(--dsw-alias-border-l3,#d1d9e0);color:var(--dsw-alias-label-primary,#1f2328)}',
296
+ ".dce-search{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l4,#d8dee4);border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-1,#fff);height:32px;color:var(--dsw-alias-label-primary,#1f2328);font:inherit;font-size:12px;padding:0 10px;outline:none}",
297
+ ".dce-search::placeholder{color:var(--dsw-alias-label-tertiary,#8b93a1)}",
298
+ ".dce-search:focus-visible{border-color:var(--dsw-alias-brand-primary,#4f6ef7)}",
299
+ ".dce-files-head{display:flex;align-items:center;gap:10px;min-height:28px}",
300
+ ".dce-files-title{color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;font-weight:600;line-height:18px}",
301
+ ".dce-files-count{color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:12px;line-height:18px}",
302
+ ".dce-spacer{flex:1}",
303
+ ".dce-list{margin:0;padding:0;list-style:none;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:10px;overflow:hidden;background:var(--dsw-alias-bg-layer-1,#fff)}",
304
+ ".dce-item+.dce-item{border-top:1px solid var(--dsw-alias-border-l2,#e5e7eb)}",
305
+ ".dce-row{display:flex;width:100%;align-items:center;gap:10px;padding:8px 12px;background:0 0;border:0;font:inherit;color:inherit;text-align:left;cursor:pointer}",
306
+ ".dce-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.03))}",
307
+ ".dce-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4f6ef7);outline-offset:-2px}",
308
+ ".dce-file-name{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary,#1f2328);font-size:13px;font-weight:500;line-height:18px}",
309
+ ".dce-file-time{flex:none;color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:11px;line-height:16px}",
310
+ ".dce-preview{margin:0;padding:8px 12px 10px;background:var(--dsw-alias-bg-module-platform,#eef1f4);border-top:1px solid var(--dsw-alias-border-l2,#e5e7eb)}",
311
+ ".dce-pre{margin:0;max-height:240px;overflow:auto;font-family:var(--ds-font-family-code,ui-monospace,Menlo,monospace);font-size:11px;line-height:17px;color:var(--dsw-alias-label-primary,#1f2328);white-space:pre-wrap;word-break:break-word}",
312
+ ".dce-workspace{display:flex;flex-direction:column;gap:10px}",
313
+ ".dce-stats{color:var(--dsw-alias-label-secondary,#6b7280);font-size:12px;line-height:18px}",
314
+ ".dce-warn{color:var(--dsw-alias-state-warn-primary,#b45309);font-size:12px;line-height:18px}",
315
+ ".dce-error{color:var(--dsw-alias-state-error-primary,#dc2626);font-size:12px;line-height:18px}",
316
+ ".dce-fallback-btn{background:var(--dsw-alias-bg-layer-2,#f3f4f6);color:var(--dsw-alias-label-primary,#1f2328);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:6px;padding:3px 10px;font-size:12px;line-height:18px;cursor:pointer}",
317
+ ].join("\n");
318
+ document.head.appendChild(style);
319
+ }
320
+
321
+ /** Chevron from the host primitives, or a plain glyph when they are absent. */
322
+ function Chevron() {
323
+ if (
324
+ primitives !== undefined &&
325
+ primitives !== null &&
326
+ typeof primitives.IconChevronDownOutlineRegular !== "undefined" &&
327
+ primitives.IconChevronDownOutlineRegular !== null
328
+ ) {
329
+ return createElement(primitives.IconChevronDownOutlineRegular, { size: 14, "aria-hidden": "true" });
330
+ }
331
+ return createElement("span", { "aria-hidden": "true" }, "▾");
332
+ }
203
333
 
204
334
  function RefreshButton(props) {
205
335
  var UI = props.UI;
206
336
  var t = props.t;
207
337
  var onClick = props.onClick;
208
338
  if (UI !== null && UI !== undefined && typeof UI.Button !== "undefined" && UI.Button !== null) {
209
- return createElement(
210
- UI.Button,
211
- { variant: "outline", size: "sm", onClick: onClick },
212
- t("refresh"),
213
- );
339
+ return createElement(UI.Button, { variant: "outline", size: "sm", onClick: onClick }, t("refresh"));
214
340
  }
215
- return createElement("button", { type: "button", style: FALLBACK_BUTTON_STYLE, onClick: onClick }, t("refresh"));
341
+ return createElement(
342
+ "button",
343
+ { type: "button", className: "dce-fallback-btn", onClick: onClick },
344
+ t("refresh"),
345
+ );
216
346
  }
217
347
 
218
- /** One workspace block: root, stats, drift warnings, per-file lines. */
219
- function WorkspaceBlock(props) {
220
- var entry = props.entry;
348
+ /** One memory file row: name + relative updated time, click toggles the body preview. */
349
+ function FileRow(props) {
350
+ var file = props.file;
351
+ var now = props.now;
221
352
  var t = props.t;
222
- if (entry.error !== null && entry.error !== undefined) {
223
- return createElement(
224
- "div",
225
- { style: SECTION_STYLE },
226
- createElement("div", { style: ROOT_STYLE }, entry.root),
227
- createElement("div", { style: ERROR_STYLE }, t("readError") + entry.error),
228
- );
229
- }
230
- var snapshot = entry.snapshot;
231
- if (snapshot === null || snapshot === undefined || !snapshot.exists) {
232
- return createElement(
233
- "div",
234
- { style: SECTION_STYLE },
235
- createElement("div", { style: ROOT_STYLE }, entry.root),
236
- createElement("div", { style: HINT_STYLE }, t("absent")),
237
- );
238
- }
239
- if (snapshot.readError !== null && snapshot.readError !== undefined) {
240
- return createElement(
241
- "div",
242
- { style: SECTION_STYLE },
243
- createElement("div", { style: ROOT_STYLE }, entry.root),
244
- createElement("div", { style: ERROR_STYLE }, t("readFailed") + snapshot.readError),
245
- );
353
+ var expanded = props.expanded;
354
+ var preview = props.preview;
355
+ var onToggle = props.onToggle;
356
+ var rowChildren = [
357
+ createElement("span", { key: "name", className: "dce-file-name" }, file.file),
358
+ ];
359
+ var time = formatRelative(file.updatedAt, now, t);
360
+ if (time !== "") {
361
+ rowChildren.push(createElement("span", { key: "time", className: "dce-file-time" }, time));
246
362
  }
247
363
  var children = [
248
- createElement("div", { key: "root", style: ROOT_STYLE }, entry.root),
249
364
  createElement(
250
- "div",
251
- { key: "stats", style: STATS_STYLE },
252
- t("stats", { files: snapshot.fileCount, index: snapshot.indexEntryCount }),
365
+ "button",
366
+ {
367
+ key: "row",
368
+ type: "button",
369
+ className: "dce-row",
370
+ "aria-expanded": expanded ? "true" : "false",
371
+ onClick: onToggle,
372
+ },
373
+ rowChildren,
253
374
  ),
254
375
  ];
255
- if (snapshot.missingFiles.length > 0) {
256
- children.push(
257
- createElement("div", { key: "missing", style: WARN_STYLE }, t("missing") + snapshot.missingFiles.join("、")),
376
+ if (expanded) {
377
+ var body = null;
378
+ if (preview === null || preview.file !== file.file) {
379
+ body = createElement("div", { className: "dce-hint" }, t("previewLoading"));
380
+ } else if (preview.status === "loading") {
381
+ body = createElement("div", { className: "dce-hint" }, t("previewLoading"));
382
+ } else if (preview.status === "ready") {
383
+ body = createElement("pre", { className: "dce-pre" }, preview.content);
384
+ } else if (preview.status === "deleted") {
385
+ body = createElement("div", { className: "dce-hint" }, t("previewDeleted"));
386
+ } else if (preview.status === "tooLarge") {
387
+ body = createElement("div", { className: "dce-hint" }, t("previewTooLarge"));
388
+ } else {
389
+ body = createElement("div", { className: "dce-error" }, t("previewFailed") + preview.message);
390
+ }
391
+ children.push(createElement("div", { key: "preview", className: "dce-preview" }, body));
392
+ }
393
+ return createElement("li", { className: "dce-item" }, children);
394
+ }
395
+
396
+ /** One workspace's memory store: stats, drift warnings, searchable file list with previews. */
397
+ function WorkspaceBlock(props) {
398
+ var entry = props.entry;
399
+ var selected = props.selected;
400
+ var onSelect = props.onSelect;
401
+ var t = props.t;
402
+ var UI = props.UI;
403
+ var now = props.now;
404
+ var expandedFile = props.expandedFile;
405
+ var preview = props.preview;
406
+ var onToggleFile = props.onToggleFile;
407
+ var onRefresh = props.onRefresh;
408
+ var query = props.query;
409
+ var onQueryChange = props.onQueryChange;
410
+ var chips = [];
411
+ if (entry.workspaces.length > 1) {
412
+ chips = createElement(
413
+ "div",
414
+ { key: "chips", className: "dce-chips", role: "tablist", "aria-label": t("filesHeading") },
415
+ entry.workspaces.map(function (workspace) {
416
+ return createElement(
417
+ "button",
418
+ {
419
+ key: workspace.root,
420
+ type: "button",
421
+ className: "dce-chip",
422
+ "data-active": workspace.root === selected ? "true" : "false",
423
+ onClick: function () {
424
+ onSelect(workspace.root);
425
+ },
426
+ },
427
+ workspaceLabel(workspace.root),
428
+ );
429
+ }),
258
430
  );
259
431
  }
260
- if (snapshot.unindexedFiles.length > 0) {
261
- children.push(
432
+ var active = entry.workspaces.find(function (workspace) {
433
+ return workspace.root === selected;
434
+ });
435
+ if (active === undefined) {
436
+ return createElement("div", { key: "block" }, chips);
437
+ }
438
+ var body = null;
439
+ if (active.error !== null && active.error !== undefined) {
440
+ body = [
441
+ createElement("div", { key: "root", className: "dce-stats" }, active.root),
442
+ createElement("div", { key: "err", className: "dce-error" }, t("readError") + active.error),
443
+ ];
444
+ } else if (active.snapshot === null || active.snapshot === undefined || !active.snapshot.exists) {
445
+ body = createElement("div", { className: "dce-empty" }, t("absent"));
446
+ } else if (active.snapshot.readError !== null && active.snapshot.readError !== undefined) {
447
+ body = [
448
+ createElement("div", { key: "root", className: "dce-stats" }, active.root),
449
+ createElement("div", { key: "err", className: "dce-error" }, t("readFailed") + active.snapshot.readError),
450
+ ];
451
+ } else {
452
+ var snapshot = active.snapshot;
453
+ var normalizedQuery = String(query).trim().toLocaleLowerCase();
454
+ var files = snapshot.files.filter(function (file) {
455
+ if (normalizedQuery.length === 0) {
456
+ return true;
457
+ }
458
+ return [file.file, file.name, file.description, file.type]
459
+ .filter(function (value) {
460
+ return typeof value === "string";
461
+ })
462
+ .some(function (value) {
463
+ return value.toLocaleLowerCase().includes(normalizedQuery);
464
+ });
465
+ });
466
+ var parts = [
467
+ createElement(
468
+ "div",
469
+ { key: "stats", className: "dce-stats" },
470
+ t("stats", { files: snapshot.fileCount, index: snapshot.indexEntryCount }),
471
+ ),
472
+ ];
473
+ if (snapshot.missingFiles.length > 0) {
474
+ parts.push(
475
+ createElement("div", { key: "missing", className: "dce-warn" }, t("missing") + snapshot.missingFiles.join("、")),
476
+ );
477
+ }
478
+ if (snapshot.unindexedFiles.length > 0) {
479
+ parts.push(
480
+ createElement(
481
+ "div",
482
+ { key: "unindexed", className: "dce-warn" },
483
+ t("unindexed") + snapshot.unindexedFiles.join("、"),
484
+ ),
485
+ );
486
+ }
487
+ parts.push(
488
+ createElement("input", {
489
+ key: "search",
490
+ type: "search",
491
+ className: "dce-search",
492
+ value: query,
493
+ placeholder: t("searchPlaceholder"),
494
+ "aria-label": t("searchPlaceholder"),
495
+ onChange: function (event) {
496
+ onQueryChange(event.currentTarget.value);
497
+ },
498
+ }),
499
+ );
500
+ parts.push(
262
501
  createElement(
263
502
  "div",
264
- { key: "unindexed", style: WARN_STYLE },
265
- t("unindexed") + snapshot.unindexedFiles.join("、"),
503
+ { key: "head", className: "dce-files-head" },
504
+ createElement("span", { className: "dce-files-title" }, t("filesHeading")),
505
+ createElement("span", { className: "dce-files-count" }, String(files.length)),
506
+ createElement("span", { className: "dce-spacer" }),
507
+ createElement(RefreshButton, {
508
+ UI: UI,
509
+ t: t,
510
+ onClick: onRefresh,
511
+ }),
266
512
  ),
267
513
  );
514
+ if (files.length === 0) {
515
+ parts.push(createElement("div", { key: "none", className: "dce-empty" }, t("searchEmpty")));
516
+ } else {
517
+ parts.push(
518
+ createElement(
519
+ "ul",
520
+ { key: "files", className: "dce-list" },
521
+ files.map(function (file) {
522
+ return createElement(FileRow, {
523
+ key: file.file,
524
+ file: file,
525
+ now: now,
526
+ t: t,
527
+ expanded: expandedFile === file.file,
528
+ preview: preview,
529
+ onToggle: function () {
530
+ onToggleFile(file.file);
531
+ },
532
+ });
533
+ }),
534
+ ),
535
+ );
536
+ }
537
+ body = parts;
268
538
  }
269
- children.push(
270
- createElement(
271
- "ul",
272
- { key: "files", style: LIST_STYLE },
273
- snapshot.files.map(function (file) {
274
- return createElement(
275
- "li",
276
- { key: file.file },
277
- createElement("strong", null, file.file),
278
- file.description !== "" ? " — " + file.description : "",
279
- );
280
- }),
281
- ),
282
- );
283
- return createElement("div", { style: SECTION_STYLE }, children);
539
+ return createElement("div", { key: "block", className: "dce-workspace" }, chips, body);
284
540
  }
285
541
 
286
- /** The card page: refresh control + one block per known workspace. */
542
+ /**
543
+ * The card: a collapsed-by-default framed disclosure (dshmarket
544
+ * SettingsCard shape) whose open body is the ZCode-memory-viewer-shaped
545
+ * read-only projection.
546
+ */
287
547
  function MemoryCard(props) {
288
548
  var t = props.t;
289
549
  var UI = props.UI;
550
+ injectCardStyle();
551
+ var openState = useState(false);
552
+ var open = openState[0];
553
+ var setOpen = openState[1];
290
554
  var state = useState({ status: "loading", model: null, message: "" });
291
555
  var model = state[0];
292
556
  var setModel = state[1];
293
557
  var reloadState = useState(0);
294
558
  var reload = reloadState[1];
559
+ var selectionState = useState({ root: null, query: "", expandedFile: null });
560
+ var selection = selectionState[0];
561
+ var setSelection = selectionState[1];
562
+ var previewState = useState({ file: null, status: "loading", content: "", message: "" });
563
+ var preview = previewState[0];
564
+ var setPreview = previewState[1];
565
+ var nowState = useState(Date.now());
566
+ var now = nowState[0];
567
+ var setNow = nowState[1];
295
568
  useEffect(
296
569
  function () {
297
570
  var alive = true;
@@ -311,42 +584,116 @@ window.__ModuleLoader__.load({
311
584
  },
312
585
  [reloadState[0]],
313
586
  );
587
+ useEffect(
588
+ function () {
589
+ if (open) setNow(Date.now());
590
+ },
591
+ [open],
592
+ );
593
+ var refresh = function () {
594
+ setModel({ status: "loading", model: null, message: "" });
595
+ setSelection(function (current) {
596
+ return { root: current.root, query: "", expandedFile: null };
597
+ });
598
+ setPreview({ file: null, status: "loading", content: "", message: "" });
599
+ reload(function (n) {
600
+ return n + 1;
601
+ });
602
+ };
603
+ var header = createElement(
604
+ "button",
605
+ {
606
+ type: "button",
607
+ className: "dce-head",
608
+ "aria-expanded": open ? "true" : "false",
609
+ onClick: function () {
610
+ setOpen(!open);
611
+ },
612
+ },
613
+ createElement(
614
+ "div",
615
+ { className: "dce-head-text" },
616
+ createElement("div", { className: "dce-name" }, t("title")),
617
+ createElement("div", { className: "dce-desc" }, t("desc")),
618
+ ),
619
+ createElement(
620
+ "span",
621
+ { className: "dce-chevron", "data-open": open ? "true" : "false" },
622
+ createElement(Chevron, null),
623
+ ),
624
+ );
314
625
  var body = null;
626
+ if (!open) {
627
+ return createElement("div", { className: "dce-card", "data-open": "false" }, header);
628
+ }
629
+ var inner = null;
315
630
  if (model.status === "loading") {
316
- body = createElement("div", { key: "body", style: HINT_STYLE }, t("loading"));
631
+ inner = createElement("div", { key: "inner", className: "dce-hint" }, t("loading"));
317
632
  } else if (model.status === "error") {
318
- body = createElement("div", { key: "body", style: ERROR_STYLE }, t("loadFailed") + model.message);
633
+ inner = createElement("div", { key: "inner", className: "dce-error" }, t("loadFailed") + model.message);
319
634
  } else if (model.model.workspaces.length === 0) {
320
- body = createElement("div", { key: "body", style: HINT_STYLE }, t("empty"));
635
+ inner = createElement("div", { key: "inner", className: "dce-empty" }, t("empty"));
321
636
  } else {
322
- body = createElement(
323
- "div",
324
- { key: "body" },
325
- model.model.workspaces.map(function (entry) {
326
- return createElement(WorkspaceBlock, { key: entry.root, entry: entry, t: t, UI: UI });
327
- }),
328
- );
637
+ var selected = selection.root;
638
+ if (
639
+ selected === null ||
640
+ !model.model.workspaces.some(function (workspace) {
641
+ return workspace.root === selected;
642
+ })
643
+ ) {
644
+ selected = model.model.workspaces[0].root;
645
+ }
646
+ inner = createElement(WorkspaceBlock, {
647
+ key: "inner",
648
+ entry: model.model,
649
+ selected: selected,
650
+ onSelect: function (root) {
651
+ setSelection({ root: root, query: "", expandedFile: null });
652
+ setPreview({ file: null, status: "loading", content: "", message: "" });
653
+ },
654
+ t: t,
655
+ UI: UI,
656
+ now: now,
657
+ expandedFile: selection.expandedFile,
658
+ preview: preview,
659
+ onToggleFile: function (file) {
660
+ if (selection.expandedFile === file) {
661
+ setSelection(function (current) {
662
+ return { root: current.root, query: current.query, expandedFile: null };
663
+ });
664
+ return;
665
+ }
666
+ setSelection(function (current) {
667
+ return { root: current.root, query: current.query, expandedFile: file };
668
+ });
669
+ setPreview({ file: file, status: "loading", content: "", message: "" });
670
+ loadFileContent(fetch, selected, file).then(function (result) {
671
+ setPreview(function (current) {
672
+ if (current.file !== file) {
673
+ return current;
674
+ }
675
+ return { file: file, status: result.status, content: result.content, message: result.message };
676
+ });
677
+ });
678
+ },
679
+ query: selection.query,
680
+ onQueryChange: function (value) {
681
+ setSelection(function (current) {
682
+ return { root: current.root, query: value, expandedFile: current.expandedFile };
683
+ });
684
+ },
685
+ });
329
686
  }
687
+ body = createElement(
688
+ "div",
689
+ { key: "body", className: "dce-body" },
690
+ createElement("div", { className: "dce-hint" }, t("hint")),
691
+ inner,
692
+ );
330
693
  return createElement(
331
694
  "div",
332
- { style: CARD_STYLE },
333
- createElement(
334
- "div",
335
- { style: { display: "flex", alignItems: "center", gap: "12px" } },
336
- createElement("div", { style: TITLE_STYLE }, t("title")),
337
- createElement("span", { style: { flex: 1 } }),
338
- createElement(RefreshButton, {
339
- UI: UI,
340
- t: t,
341
- onClick: function () {
342
- setModel({ status: "loading", model: null, message: "" });
343
- reload(function (n) {
344
- return n + 1;
345
- });
346
- },
347
- }),
348
- ),
349
- createElement("div", { style: HINT_STYLE }, t("hint")),
695
+ { className: "dce-card", "data-open": "true" },
696
+ header,
350
697
  body,
351
698
  );
352
699
  }
@@ -354,6 +701,7 @@ window.__ModuleLoader__.load({
354
701
  exports.name = NS;
355
702
  exports.inject = ["slots", "locale", "theme"];
356
703
  exports.loadCardModel = loadCardModel;
704
+ exports.loadFileContent = loadFileContent;
357
705
  exports.apply = function apply(ctx) {
358
706
  var tRaw = null;
359
707
  try {
@@ -2,6 +2,7 @@ import type { KnownWorkspaces } from "./known-workspaces.js";
2
2
  /** URL prefix of the card API (kind-exact routes registered under it). */
3
3
  export declare const CARD_API_WORKSPACES_PATH = "/dsh-continual-evolve/api/v1/workspaces";
4
4
  export declare const CARD_API_MEMORY_PATH = "/dsh-continual-evolve/api/v1/memory";
5
+ export declare const CARD_API_MEMORY_FILE_PATH = "/dsh-continual-evolve/api/v1/memory/file";
5
6
  /** Minimal request shape the handlers touch (Node IncomingMessage subset). */
6
7
  export interface CardRequest {
7
8
  method?: string;
@@ -3,17 +3,20 @@
3
3
  *
4
4
  * Mounted through the host's `webServer` service (same registration shape
5
5
  * dshmarket uses: `kind: "exact"` routes on the profile's web server), the
6
- * two endpoints are GET-only projections: the workspace allowlist and, for a
7
- * known root, the memory snapshot. The allowlist IS the security fence — the
8
- * memory endpoint resolves its `root` parameter only against roots the
9
- * running process has actually served, so the API cannot read arbitrary
10
- * paths. Handlers are synchronous and never throw past the response.
6
+ * three endpoints are GET-only projections: the workspace allowlist, the
7
+ * memory snapshot for a known root, and one memory file's body for the card's
8
+ * content preview. The allowlist IS the security fence — the memory endpoints
9
+ * resolve their `root` parameter only against roots the running process has
10
+ * actually served, and the file endpoint additionally accepts only plain .md
11
+ * entries of that workspace's memory directory, so the API cannot read
12
+ * arbitrary paths. Handlers are synchronous and never throw past the response.
11
13
  */
12
14
  import { resolve } from "node:path";
13
- import { memorySnapshot } from "./memory-snapshot.js";
15
+ import { memoryFileContent, memorySnapshot } from "./memory-snapshot.js";
14
16
  /** URL prefix of the card API (kind-exact routes registered under it). */
15
17
  export const CARD_API_WORKSPACES_PATH = "/dsh-continual-evolve/api/v1/workspaces";
16
18
  export const CARD_API_MEMORY_PATH = "/dsh-continual-evolve/api/v1/memory";
19
+ export const CARD_API_MEMORY_FILE_PATH = "/dsh-continual-evolve/api/v1/memory/file";
17
20
  function sendJson(response, status, body) {
18
21
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
19
22
  response.end(JSON.stringify(body));
@@ -65,6 +68,56 @@ export function mountCardRoutes(webServer, workspaces) {
65
68
  sendJson(response, 200, memorySnapshot(known.root));
66
69
  },
67
70
  }));
71
+ disposers.push(webServer.register({
72
+ kind: "exact",
73
+ path: CARD_API_MEMORY_FILE_PATH,
74
+ handler(request, response) {
75
+ if (!isGet(request)) {
76
+ response.writeHead(405, { allow: "GET" });
77
+ response.end();
78
+ return;
79
+ }
80
+ const root = rootParamOf(request);
81
+ if (root === null) {
82
+ sendJson(response, 400, { error: "root query parameter is required and must be an absolute path" });
83
+ return;
84
+ }
85
+ const file = fileParamOf(request);
86
+ if (file === null) {
87
+ sendJson(response, 400, { error: "file query parameter is required" });
88
+ return;
89
+ }
90
+ const known = workspaces.list().find((workspace) => workspace.root === resolve(root));
91
+ if (known === undefined) {
92
+ // Same fence as the snapshot route: 404 without distinguishing
93
+ // "never served" from "wrong path".
94
+ sendJson(response, 404, { error: "unknown workspace root" });
95
+ return;
96
+ }
97
+ const content = memoryFileContent(known.root, file);
98
+ if (!content.ok) {
99
+ // The fence reasons stay indistinguishable from absence in status
100
+ // terms only where safe: "outside" answers 400 (a client bug),
101
+ // absence 404, oversized 413 — never a body leak either way.
102
+ if (content.reason === "outside") {
103
+ sendJson(response, 400, { error: "file must be a plain .md entry of the memory directory" });
104
+ }
105
+ else if (content.reason === "too-large") {
106
+ sendJson(response, 413, { error: "memory file exceeds the 5 MiB preview limit" });
107
+ }
108
+ else {
109
+ sendJson(response, 404, { error: "memory file not found" });
110
+ }
111
+ return;
112
+ }
113
+ sendJson(response, 200, {
114
+ file: content.file,
115
+ content: content.content,
116
+ mtimeMs: content.mtimeMs,
117
+ changed: content.changed,
118
+ });
119
+ },
120
+ }));
68
121
  return () => {
69
122
  for (const disposer of disposers) {
70
123
  if (typeof disposer === "function") {
@@ -89,4 +142,19 @@ function rootParamOf(request) {
89
142
  return null;
90
143
  }
91
144
  }
145
+ /** The decoded `file` query parameter, or null when absent. */
146
+ function fileParamOf(request) {
147
+ try {
148
+ const url = new URL(request.url ?? "/", "http://dsh-card.invalid");
149
+ const file = url.searchParams.get("file");
150
+ if (file === null || file.trim().length === 0) {
151
+ return null;
152
+ }
153
+ return file;
154
+ }
155
+ catch (error) {
156
+ void error;
157
+ return null;
158
+ }
159
+ }
92
160
  //# sourceMappingURL=card-routes.js.map
@@ -1,11 +1,18 @@
1
1
  /** The index file name (same store contract as the injection path). */
2
2
  export declare const MEMORY_SNAPSHOT_INDEX_FILE = "MEMORY.md";
3
+ /**
4
+ * Preview size cap for the file content endpoint — files above it answer
5
+ * `too-large` instead of their body (ZCode's memory viewer uses the same 5 MiB).
6
+ */
7
+ export declare const MEMORY_FILE_PREVIEW_LIMIT: number;
3
8
  /** One memory file with its frontmatter fields ("" when absent). */
4
9
  export interface MemoryFileInfo {
5
10
  file: string;
6
11
  name: string;
7
12
  description: string;
8
13
  type: string;
14
+ /** File modification time in epoch milliseconds (the card's "updated" line). */
15
+ updatedAt: number;
9
16
  }
10
17
  /** One parsed index row: `- [title](file.md) — hook`. */
11
18
  export interface MemoryIndexRow {
@@ -51,4 +58,25 @@ export declare function parseMemoryFrontmatter(text: string): MemoryFrontmatter;
51
58
  * card must observe, never break.
52
59
  */
53
60
  export declare function memorySnapshot(root: string): MemorySnapshot;
61
+ /** The content endpoint's outcomes: the body, or why there is none. */
62
+ export type MemoryFileContent = {
63
+ ok: true;
64
+ file: string;
65
+ content: string;
66
+ mtimeMs: number;
67
+ changed: boolean;
68
+ } | {
69
+ ok: false;
70
+ reason: "outside" | "absent" | "too-large";
71
+ };
72
+ /**
73
+ * Read one memory file's body for the card's content preview — the viewer
74
+ * side of the same read-only fence as `memorySnapshot`. The requested file
75
+ * name must be a plain entry of the workspace's memory directory (no path
76
+ * separators, no traversal, resolved location stays inside the directory);
77
+ * anything else answers `outside` without touching the filesystem, so the
78
+ * endpoint cannot become a path oracle. Oversized files answer `too-large`
79
+ * with the body unread.
80
+ */
81
+ export declare function memoryFileContent(root: string, file: string): MemoryFileContent;
54
82
  //# sourceMappingURL=memory-snapshot.d.ts.map
@@ -4,11 +4,16 @@
4
4
  * card is a viewer, never a second store: no caching, no writes, no bootstrap
5
5
  * (unlike the injection path, a missing store is reported, not created).
6
6
  */
7
- import { existsSync, readdirSync, readFileSync } from "node:fs";
8
- import { join, resolve } from "node:path";
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { basename, join, resolve } from "node:path";
9
9
  import { memoryDirFor } from "./memory-section.js";
10
10
  /** The index file name (same store contract as the injection path). */
11
11
  export const MEMORY_SNAPSHOT_INDEX_FILE = "MEMORY.md";
12
+ /**
13
+ * Preview size cap for the file content endpoint — files above it answer
14
+ * `too-large` instead of their body (ZCode's memory viewer uses the same 5 MiB).
15
+ */
16
+ export const MEMORY_FILE_PREVIEW_LIMIT = 5 * 1024 * 1024;
12
17
  /**
13
18
  * Parse `- [title](file.md) …` rows out of an index text. Anything else
14
19
  * (headings, the bootstrap hint comment, blank lines) is ignored.
@@ -67,7 +72,8 @@ function readStoreState(memoryDir) {
67
72
  const diskFiles = listStoreFiles(memoryDir);
68
73
  const files = diskFiles.map((file) => {
69
74
  const frontmatter = parseMemoryFrontmatter(readFileSync(join(memoryDir, file), "utf8"));
70
- return { file, name: frontmatter.name, description: frontmatter.description, type: frontmatter.type };
75
+ const updatedAt = statSync(join(memoryDir, file)).mtimeMs;
76
+ return { file, name: frontmatter.name, description: frontmatter.description, type: frontmatter.type, updatedAt };
71
77
  });
72
78
  return { indexRows: parseMemoryIndexRows(indexText), diskFiles, files };
73
79
  }
@@ -122,4 +128,52 @@ export function memorySnapshot(root) {
122
128
  readError: null,
123
129
  };
124
130
  }
131
+ /**
132
+ * Read one memory file's body for the card's content preview — the viewer
133
+ * side of the same read-only fence as `memorySnapshot`. The requested file
134
+ * name must be a plain entry of the workspace's memory directory (no path
135
+ * separators, no traversal, resolved location stays inside the directory);
136
+ * anything else answers `outside` without touching the filesystem, so the
137
+ * endpoint cannot become a path oracle. Oversized files answer `too-large`
138
+ * with the body unread.
139
+ */
140
+ export function memoryFileContent(root, file) {
141
+ const memoryDir = memoryDirFor(resolve(root));
142
+ if (file.length === 0 ||
143
+ !file.endsWith(".md") ||
144
+ file.includes("/") ||
145
+ file.includes("\\") ||
146
+ basename(file) !== file) {
147
+ return { ok: false, reason: "outside" };
148
+ }
149
+ const target = resolve(memoryDir, file);
150
+ if (!target.startsWith(memoryDir + "/") && target !== memoryDir) {
151
+ return { ok: false, reason: "outside" };
152
+ }
153
+ let before;
154
+ try {
155
+ before = statSync(target);
156
+ }
157
+ catch {
158
+ return { ok: false, reason: "absent" };
159
+ }
160
+ if (!before.isFile()) {
161
+ return { ok: false, reason: "absent" };
162
+ }
163
+ if (before.size > MEMORY_FILE_PREVIEW_LIMIT) {
164
+ return { ok: false, reason: "too-large" };
165
+ }
166
+ let content;
167
+ try {
168
+ content = readFileSync(target, "utf8");
169
+ }
170
+ catch {
171
+ return { ok: false, reason: "absent" };
172
+ }
173
+ // The file may have been rewritten between stat and read — surface that
174
+ // instead of silently showing stale bytes (same guard shape as ZCode's
175
+ // memory viewer's "changed during read" state).
176
+ const after = statSync(target);
177
+ return { ok: true, file, content, mtimeMs: after.mtimeMs, changed: after.mtimeMs !== before.mtimeMs };
178
+ }
125
179
  //# sourceMappingURL=memory-snapshot.js.map
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Workspace Memory",
4
+ "description": "Per-workspace markdown memory store with session-start index injection."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "工作区记忆",
4
+ "description": "每个工作区一个 markdown 记忆库,会话开局注入索引,模型原生读写。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.13.0",
4
- "description": "DSH 工作区记忆:每个工作区一个 markdown 记忆库,会话开局注入索引,模型原生读写 / ZCode-style workspace memory for DeepSeek Harness: a per-workspace markdown store with session-start index injection.",
3
+ "version": "0.14.0",
4
+ "description": "ZCode-style workspace memory for DeepSeek Harness: a per-workspace markdown store with session-start index injection.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -20,13 +20,15 @@
20
20
  "default": "./lib/index.js"
21
21
  },
22
22
  "./client": "./client/client.js",
23
- "./package.json": "./package.json"
23
+ "./package.json": "./package.json",
24
+ "./locale/*.json": "./locale/*.json"
24
25
  },
25
26
  "files": [
26
27
  "lib/**/*.js",
27
28
  "lib/**/*.d.ts",
28
29
  "client/client.js",
29
30
  "cordis.patch.yml",
31
+ "locale/*.json",
30
32
  "README.md",
31
33
  "LICENSE"
32
34
  ],