dsh-continual-evolve 0.14.0 → 0.14.2

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,7 +7,7 @@ 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-80%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-92%20passing-brightgreen)]()
11
11
  [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
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)]()
@@ -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), 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.
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 behind a dropdown scope selector fed by the host workspace registry, 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,9 +66,9 @@ Profile patch example:
66
66
 
67
67
  ```bash
68
68
  pnpm install && pnpm build # deps + tsc -> lib/
69
- pnpm test # vitest (80 tests)
69
+ pnpm test # vitest (92 tests)
70
70
  pnpm test:coverage # v8 coverage, CI-enforced thresholds
71
- pnpm lint # oxlint src test
71
+ pnpm lint # oxlint src test client
72
72
  ```
73
73
 
74
74
  Layout:
@@ -79,12 +79,13 @@ Layout:
79
79
  │ ├── memory-section.ts # injection: read index / bootstrap / truncate / session freeze
80
80
  │ ├── memory-guide.ts # guide: when_to_save and upkeep (adapted from ZCode)
81
81
  │ ├── memory-snapshot.ts # card: read-only store projection (entries / drift / error degrade)
82
- │ ├── known-workspaces.ts# card: in-process LRU workspace allowlist (the API read fence)
82
+ │ ├── workspace-catalog.ts# card: workspace list from the host registry (the API read fence)
83
+ │ ├── known-workspaces.ts# card: in-process LRU fallback source for the catalogue
83
84
  │ └── card-routes.ts # card: GET-only read API on the host webServer
84
85
  ├── client/
85
86
  │ └── client.js # hand-written client bundle: read-only card in the plugin manager
86
87
  ├── locale/ # host package metadata: localized display name + description (zh/en)
87
- ├── test/ # vitest suite (7 files)
88
+ ├── test/ # vitest suite (8 files)
88
89
  ├── lib/ # build output (tsc)
89
90
  ├── docs/ # design.md (design) · FAQ.md (real-world pitfalls)
90
91
  └── .agents/ # AI collaboration layer (AGENTS.md, skills, ADR notes)
package/README.zh.md CHANGED
@@ -7,7 +7,7 @@
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-80%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-92%20passing-brightgreen)]()
11
11
  [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
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)]()
@@ -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,老宿主自动降级为无卡片):形态对齐市场自家设置卡(默认收起、头部整行可点 + 旋转 chevron、hover/展开框态),内容对齐 ZCode 设置的记忆选项卡——工作区一次选一个(目录名 chips)、文件搜索框、每个文件的相对更新时间、点击行内展开文件内容预览(5 MiB 上限),外加索引漂移警告(未索引文件与索引失联文件)。插件的显示名与描述走宿主包元数据 `locale/*.json` 通道双语切换。卡片与市场同款——宿主主题 token、`Button` 原语刷新按钮、中英文案跟随 DSH 语言(老宿主降级为同主题裸元素)。卡片是磁盘文件的请求时投影——单一事实源永远是 `.evolve/memory/` 里的文件,卡片没有编辑路径,编辑请直接改文件。
26
+ 4. **只读卡片** —— 插件在官方插件管理的本 bundle 页面挂一张只读记忆卡片(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡(默认收起、头部整行可点 + 旋转 chevron、hover/展开框态),内容对齐 ZCode 设置的记忆选项卡——工作区一次选一个(下拉作用域选择器,目录取宿主工作区注册表)、文件搜索框、每个文件的相对更新时间、点击行内展开文件内容预览(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,9 +66,9 @@ profile patch 示例:
66
66
 
67
67
  ```bash
68
68
  pnpm install && pnpm build # 依赖 + tsc -> lib/
69
- pnpm test # vitest(80 例)
69
+ pnpm test # vitest(92 例)
70
70
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
71
- pnpm lint # oxlint src test
71
+ pnpm lint # oxlint src test client
72
72
  ```
73
73
 
74
74
  目录结构:
@@ -79,12 +79,13 @@ pnpm lint # oxlint src test
79
79
  │ ├── memory-section.ts # 注入:读索引 / bootstrap / 截断 / 会话冻结
80
80
  │ ├── memory-guide.ts # 指南:when_to_save 与维护纪律(改编自 ZCode)
81
81
  │ ├── memory-snapshot.ts # 卡片:记忆库只读投影(条目 / 漂移 / 错误降级)
82
- │ ├── known-workspaces.ts# 卡片:进程内 LRU 工作区白名单(API 读围栏)
82
+ │ ├── workspace-catalog.ts# 卡片:工作区目录(宿主注册表;API 读围栏)
83
+ │ ├── known-workspaces.ts# 卡片:目录的进程内 LRU 回退源
83
84
  │ └── card-routes.ts # 卡片:webServer 上的 GET-only 只读 API
84
85
  ├── client/
85
86
  │ └── client.js # 手写 client bundle:官方插件管理内只读卡片
86
87
  ├── locale/ # 宿主包元数据:显示名 + 描述双语(zh/en)
87
- ├── test/ # vitest 测试(7 个文件)
88
+ ├── test/ # vitest 测试(8 个文件)
88
89
  ├── lib/ # 构建产物(tsc)
89
90
  ├── docs/ # design.md(设计)· FAQ.md(踩坑)
90
91
  └── .agents/ # AI 协作层(AGENTS.md、技能、ADR 笔记)
package/client/client.js CHANGED
@@ -25,13 +25,15 @@
25
25
  * reads as another product). The stylesheet is injected once, like the
26
26
  * host's own CSS-module tags.
27
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.
28
+ * Content (v0.14, scope selector v0.15): aligned with ZCode's Settings → Memory
29
+ * viewer, which the .evolve store shape was itself aligned with — one workspace
30
+ * selected at a time behind a dropdown scope selector (workspaces come from the
31
+ * host registry, so the selector lists the same workspaces the workspace picker
32
+ * does), a file search box, per-file relative updated times, and a
33
+ * click-to-preview body served by the host's content endpoint (5 MiB cap,
34
+ * deleted/changed guards). The drift warnings (dangling index rows / unindexed
35
+ * files) stay: they are this plugin's OBSERVATION surface and a deliberate
36
+ * superset of ZCode's viewer.
35
37
  * Locale/theme/primitives wiring unchanged from v0.13: `ctx.locale.register`
36
38
  * + `bind`, `var(--dsw-alias-*)` tokens, primitives with plain-element
37
39
  * fallbacks so an old host never blanks the settings dialog. Read-only
@@ -74,6 +76,7 @@ window.__ModuleLoader__.load({
74
76
  var createElement = react.createElement;
75
77
  var useState = react.useState;
76
78
  var useEffect = react.useEffect;
79
+ var useRef = react.useRef;
77
80
 
78
81
  var NS = "dsh-continual-evolve";
79
82
  var SLOT = "plugins.bundle.config";
@@ -87,11 +90,12 @@ window.__ModuleLoader__.load({
87
90
  hint: "事实源是各工作区 .evolve/memory/ 下的 markdown 文件——模型原生读写;本卡片只展示,编辑请直接改文件。",
88
91
  loading: "读取中…",
89
92
  loadFailed: "卡片数据加载失败:",
90
- empty: "本进程还没有服务过任何工作区会话——在某个工作区开一次会话后回来刷新。",
93
+ empty: "还没有任何工作区有记忆库——在某个工作区开一次会话后回来刷新。",
91
94
  absent: "该工作区尚无记忆库(开一次会话自动创建)。",
92
95
  readFailed: "记忆库读取失败:",
93
96
  stats: "{files} 条记忆 · 索引 {index} 行",
94
97
  filesHeading: "文件",
98
+ scopeLabel: "选择工作区",
95
99
  searchPlaceholder: "搜索记忆文件…",
96
100
  searchEmpty: "没有匹配的记忆文件。",
97
101
  missing: "索引失联(索引引用但文件已删):",
@@ -114,11 +118,12 @@ window.__ModuleLoader__.load({
114
118
  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.",
115
119
  loading: "Loading…",
116
120
  loadFailed: "Failed to load card data: ",
117
- empty: "This process has not served any workspace session yet — open a session in a workspace, then come back and refresh.",
121
+ empty: "No workspace has a memory store yet — open a session in a workspace, then come back and refresh.",
118
122
  absent: "No memory store in this workspace yet (one is created when you open a session).",
119
123
  readFailed: "Failed to read the memory store: ",
120
124
  stats: "{files} memories · {index} index rows",
121
125
  filesHeading: "Files",
126
+ scopeLabel: "Choose workspace",
122
127
  searchPlaceholder: "Search memory files…",
123
128
  searchEmpty: "No matching memory files.",
124
129
  missing: "Dangling index (indexed but deleted): ",
@@ -178,7 +183,7 @@ window.__ModuleLoader__.load({
178
183
  }
179
184
 
180
185
  /**
181
- * Assemble the card's view model: the workspace allowlist, then one
186
+ * Assemble the card's view model: the workspace catalogue, then one
182
187
  * snapshot per workspace. Network or API failure per workspace degrades
183
188
  * to an `error` string on that workspace's entry — one bad store never
184
189
  * blanks the whole card.
@@ -196,17 +201,20 @@ window.__ModuleLoader__.load({
196
201
  var entries = await Promise.all(
197
202
  workspaces.map(async function (workspace) {
198
203
  var root = typeof workspace.root === "string" ? workspace.root : "";
199
- var lastSeen = typeof workspace.lastSeen === "string" ? workspace.lastSeen : "";
204
+ var label =
205
+ typeof workspace.label === "string" && workspace.label.trim().length > 0
206
+ ? workspace.label
207
+ : workspaceLabel(root);
200
208
  try {
201
209
  var snapshotResponse = await fetchImpl(API_ROOT + "/memory?root=" + encodeURIComponent(root));
202
210
  if (!snapshotResponse.ok) {
203
- return { root: root, lastSeen: lastSeen, snapshot: null, error: "HTTP " + snapshotResponse.status };
211
+ return { root: root, label: label, snapshot: null, error: "HTTP " + snapshotResponse.status };
204
212
  }
205
- return { root: root, lastSeen: lastSeen, snapshot: await snapshotResponse.json(), error: null };
213
+ return { root: root, label: label, snapshot: await snapshotResponse.json(), error: null };
206
214
  } catch (error) {
207
215
  return {
208
216
  root: root,
209
- lastSeen: lastSeen,
217
+ label: label,
210
218
  snapshot: null,
211
219
  error: error instanceof Error ? error.message : String(error),
212
220
  };
@@ -290,9 +298,17 @@ window.__ModuleLoader__.load({
290
298
  ".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
299
  ".dce-hint{color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:12px;line-height:18px}",
292
300
  ".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)}',
301
+ ".dce-scope-wrap{position:relative;display:inline-flex;min-width:0;max-width:100%;align-self:flex-start}",
302
+ ".dce-scope-pill{border-radius:999px}",
303
+ ".dce-scope{display:inline-flex;align-items:center;gap:8px;max-width:100%;padding:4px 10px;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:999px;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#1f2328);font:inherit;font-size:12px;line-height:18px;cursor:pointer}",
304
+ ".dce-scope:hover{border-color:var(--dsw-alias-label-dimmed,#c8ccd4)}",
305
+ ".dce-scope:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4f6ef7);outline-offset:1px}",
306
+ ".dce-scope-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
307
+ ".dce-scope-caret{display:inline-flex;color:var(--dsw-alias-label-tertiary,#8b93a1)}",
308
+ ".dce-menu{position:absolute;top:calc(100% + 6px);left:0;z-index:20;min-width:240px;max-width:min(420px,80vw);max-height:320px;overflow:auto;padding:4px;border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 8px 24px rgba(0,0,0,.12)}",
309
+ ".dce-menu-item{display:flex;flex-direction:column;gap:2px;width:100%;padding:6px 10px;border:0;border-radius:8px;background:0 0;font:inherit;color:inherit;text-align:left;cursor:pointer}",
310
+ ".dce-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.03))}",
311
+ '.dce-menu-item[data-active="true"]{background:var(--dsw-alias-bg-module-platform,#eef1f4)}',
296
312
  ".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
313
  ".dce-search::placeholder{color:var(--dsw-alias-label-tertiary,#8b93a1)}",
298
314
  ".dce-search:focus-visible{border-color:var(--dsw-alias-brand-primary,#4f6ef7)}",
@@ -305,6 +321,7 @@ window.__ModuleLoader__.load({
305
321
  ".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
322
  ".dce-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.03))}",
307
323
  ".dce-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4f6ef7);outline-offset:-2px}",
324
+ ".dce-file-icon{display:inline-flex;flex:none;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:var(--dsw-alias-bg-module-platform,#eef1f4)}",
308
325
  ".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
326
  ".dce-file-time{flex:none;color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:11px;line-height:16px}",
310
327
  ".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)}",
@@ -353,9 +370,20 @@ window.__ModuleLoader__.load({
353
370
  var expanded = props.expanded;
354
371
  var preview = props.preview;
355
372
  var onToggle = props.onToggle;
356
- var rowChildren = [
357
- createElement("span", { key: "name", className: "dce-file-name" }, file.file),
358
- ];
373
+ var UI = props.UI;
374
+ var rowChildren = [];
375
+ if (UI !== null && UI !== undefined && UI.FileIcon !== undefined && UI.FileIcon !== null) {
376
+ // ZCode's memory rows open with a category tile; the host classifies
377
+ // the file itself, so a memory .md gets the markdown glyph.
378
+ rowChildren.push(
379
+ createElement(
380
+ "span",
381
+ { key: "icon", className: "dce-file-icon" },
382
+ createElement(UI.FileIcon, { path: file.file, size: 16 }),
383
+ ),
384
+ );
385
+ }
386
+ rowChildren.push(createElement("span", { key: "name", className: "dce-file-name" }, file.file));
359
387
  var time = formatRelative(file.updatedAt, now, t);
360
388
  if (time !== "") {
361
389
  rowChildren.push(createElement("span", { key: "time", className: "dce-file-time" }, time));
@@ -393,47 +421,215 @@ window.__ModuleLoader__.load({
393
421
  return createElement("li", { className: "dce-item" }, children);
394
422
  }
395
423
 
396
- /** One workspace's memory store: stats, drift warnings, searchable file list with previews. */
397
- function WorkspaceBlock(props) {
398
- var entry = props.entry;
424
+ /** The host's folder marker for a workspace, or nothing on an old host. */
425
+ function FolderMark(props) {
426
+ var UI = props.UI;
427
+ if (UI !== null && UI !== undefined && UI.Folder !== undefined && UI.Folder !== null) {
428
+ return createElement(UI.Folder, { size: 16, "aria-hidden": "true" });
429
+ }
430
+ return null;
431
+ }
432
+
433
+ /**
434
+ * The workspace scope selector, ZCode's Settings → Memory shape: one
435
+ * scope at a time behind a dropdown (folder marker + current workspace
436
+ * label + chevron), never a row of chips — a chip row can only render
437
+ * when several workspaces are known, so in the common single-workspace
438
+ * case it renders nothing at all and the capability reads as missing.
439
+ *
440
+ * The list is the host's own `Menu` primitive whenever the host provides
441
+ * it: the same surface the rest of the settings UI uses, and it owns
442
+ * outside-click/Escape handling. A hand-rolled document listener gets
443
+ * that wrong — React flushes this component's effect synchronously for
444
+ * the discrete click that opens the list, so a listener attached there
445
+ * catches that very click and closes the list again (the button then
446
+ * looks dead). The hand-rolled list stays as the old-host fallback and
447
+ * closes on a pointerdown outside its wrapper, which cannot be the click
448
+ * that opened it.
449
+ */
450
+ function WorkspaceScopeMenu(props) {
451
+ var workspaces = props.workspaces;
399
452
  var selected = props.selected;
400
453
  var onSelect = props.onSelect;
401
454
  var t = props.t;
402
455
  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(
456
+ var openState = useState(false);
457
+ var open = openState[0];
458
+ var setOpen = openState[1];
459
+ var wrapRef = useRef(null);
460
+ var hostMenu =
461
+ UI !== null && UI !== undefined && UI.Menu !== undefined && UI.Menu !== null ? UI.Menu : null;
462
+ useEffect(
463
+ function () {
464
+ if (hostMenu !== null || !open || typeof document === "undefined") {
465
+ return undefined;
466
+ }
467
+ var onPointerDown = function (event) {
468
+ var node = wrapRef.current;
469
+ var target = event.target;
470
+ var inside =
471
+ node !== null &&
472
+ node !== undefined &&
473
+ typeof node.contains === "function" &&
474
+ target !== null &&
475
+ target !== undefined &&
476
+ node.contains(target);
477
+ if (!inside) {
478
+ setOpen(false);
479
+ }
480
+ };
481
+ var onKeyDown = function (event) {
482
+ if (event.key === "Escape") {
483
+ setOpen(false);
484
+ }
485
+ };
486
+ document.addEventListener("pointerdown", onPointerDown);
487
+ document.addEventListener("keydown", onKeyDown);
488
+ return function () {
489
+ document.removeEventListener("pointerdown", onPointerDown);
490
+ document.removeEventListener("keydown", onKeyDown);
491
+ };
492
+ },
493
+ [open, hostMenu],
494
+ );
495
+ var current = null;
496
+ for (var index = 0; index < workspaces.length; index++) {
497
+ if (workspaces[index].root === selected) {
498
+ current = workspaces[index];
499
+ break;
500
+ }
501
+ }
502
+ if (current === null && workspaces.length > 0) {
503
+ current = workspaces[0];
504
+ }
505
+ var label = current === null ? "" : current.label || workspaceLabel(current.root);
506
+ var scopeChildren = [
507
+ createElement("span", { key: "label", className: "dce-scope-label" }, label),
508
+ createElement("span", { key: "caret", className: "dce-scope-caret" }, createElement(Chevron, null)),
509
+ ];
510
+ var toggle = function () {
511
+ setOpen(!open);
512
+ };
513
+ var trigger = null;
514
+ if (UI !== null && UI !== undefined && typeof UI.Button !== "undefined" && UI.Button !== null) {
515
+ trigger = createElement(
516
+ UI.Button,
517
+ {
518
+ key: "trigger",
519
+ variant: "outline",
520
+ size: "md",
521
+ icon: createElement(FolderMark, { UI: UI }),
522
+ className: "dce-scope-pill",
523
+ type: "button",
524
+ "aria-haspopup": "menu",
525
+ "aria-expanded": open ? "true" : "false",
526
+ "aria-label": t("scopeLabel"),
527
+ onClick: toggle,
528
+ },
529
+ scopeChildren,
530
+ );
531
+ } else {
532
+ trigger = createElement(
533
+ "button",
534
+ {
535
+ key: "trigger",
536
+ type: "button",
537
+ className: "dce-scope",
538
+ "aria-haspopup": "menu",
539
+ "aria-expanded": open ? "true" : "false",
540
+ "aria-label": t("scopeLabel"),
541
+ onClick: toggle,
542
+ },
543
+ scopeChildren,
544
+ );
545
+ }
546
+ if (hostMenu !== null) {
547
+ return createElement(hostMenu, {
548
+ key: "scope",
549
+ open: open,
550
+ anchor: trigger,
551
+ items: workspaces.map(function (workspace) {
552
+ return {
553
+ id: workspace.root,
554
+ label: workspace.label || workspaceLabel(workspace.root),
555
+ icon: createElement(FolderMark, { UI: UI }),
556
+ };
557
+ }),
558
+ selectedId: selected,
559
+ onSelect: function (id) {
560
+ setOpen(false);
561
+ if (id !== selected) {
562
+ onSelect(id);
563
+ }
564
+ },
565
+ onClose: function () {
566
+ setOpen(false);
567
+ },
568
+ align: "start",
569
+ side: "bottom",
570
+ portal: true,
571
+ selection: "check",
572
+ });
573
+ }
574
+ var menu = null;
575
+ if (open) {
576
+ menu = createElement(
413
577
  "div",
414
- { key: "chips", className: "dce-chips", role: "tablist", "aria-label": t("filesHeading") },
415
- entry.workspaces.map(function (workspace) {
578
+ { key: "menu", className: "dce-menu", role: "menu", "aria-label": t("scopeLabel") },
579
+ workspaces.map(function (workspace) {
416
580
  return createElement(
417
581
  "button",
418
582
  {
419
583
  key: workspace.root,
420
584
  type: "button",
421
- className: "dce-chip",
585
+ role: "menuitemradio",
586
+ "aria-checked": workspace.root === selected ? "true" : "false",
587
+ className: "dce-menu-item",
422
588
  "data-active": workspace.root === selected ? "true" : "false",
423
589
  onClick: function () {
424
- onSelect(workspace.root);
590
+ setOpen(false);
591
+ if (workspace.root !== selected) {
592
+ onSelect(workspace.root);
593
+ }
425
594
  },
426
595
  },
427
- workspaceLabel(workspace.root),
596
+ workspace.label || workspaceLabel(workspace.root),
428
597
  );
429
598
  }),
430
599
  );
431
600
  }
601
+ return createElement("div", { key: "scope", className: "dce-scope-wrap", ref: wrapRef }, trigger, menu);
602
+ }
603
+
604
+ /** One workspace's memory store: stats, drift warnings, searchable file list with previews. */
605
+ function WorkspaceBlock(props) {
606
+ var entry = props.entry;
607
+ var selected = props.selected;
608
+ var onSelect = props.onSelect;
609
+ var t = props.t;
610
+ var UI = props.UI;
611
+ var now = props.now;
612
+ var expandedFile = props.expandedFile;
613
+ var preview = props.preview;
614
+ var onToggleFile = props.onToggleFile;
615
+ var onRefresh = props.onRefresh;
616
+ var query = props.query;
617
+ var onQueryChange = props.onQueryChange;
618
+ var scope = createElement(WorkspaceScopeMenu, {
619
+ key: "scope",
620
+ workspaces: entry.workspaces.map(function (workspace) {
621
+ return { root: workspace.root, label: workspace.label };
622
+ }),
623
+ selected: selected,
624
+ onSelect: onSelect,
625
+ t: t,
626
+ UI: UI,
627
+ });
432
628
  var active = entry.workspaces.find(function (workspace) {
433
629
  return workspace.root === selected;
434
630
  });
435
631
  if (active === undefined) {
436
- return createElement("div", { key: "block" }, chips);
632
+ return createElement("div", { key: "block", className: "dce-workspace" }, scope);
437
633
  }
438
634
  var body = null;
439
635
  if (active.error !== null && active.error !== undefined) {
@@ -524,6 +720,7 @@ window.__ModuleLoader__.load({
524
720
  file: file,
525
721
  now: now,
526
722
  t: t,
723
+ UI: UI,
527
724
  expanded: expandedFile === file.file,
528
725
  preview: preview,
529
726
  onToggle: function () {
@@ -536,7 +733,7 @@ window.__ModuleLoader__.load({
536
733
  }
537
734
  body = parts;
538
735
  }
539
- return createElement("div", { key: "block", className: "dce-workspace" }, chips, body);
736
+ return createElement("div", { key: "block", className: "dce-workspace" }, scope, body);
540
737
  }
541
738
 
542
739
  /**
@@ -651,6 +848,7 @@ window.__ModuleLoader__.load({
651
848
  setSelection({ root: root, query: "", expandedFile: null });
652
849
  setPreview({ file: null, status: "loading", content: "", message: "" });
653
850
  },
851
+ onRefresh: refresh,
654
852
  t: t,
655
853
  UI: UI,
656
854
  now: now,
@@ -750,7 +948,15 @@ window.__ModuleLoader__.load({
750
948
  }
751
949
  var UI = null;
752
950
  if (primitives !== undefined && primitives !== null && typeof primitives.Button !== "undefined") {
753
- UI = { Button: primitives.Button };
951
+ // The scope selector prefers the host's own Menu and folder icon;
952
+ // each stays optional so a host with only Button still gets the
953
+ // hand-rolled list and a plain trigger.
954
+ UI = {
955
+ Button: primitives.Button,
956
+ Menu: primitives.Menu,
957
+ Folder: primitives.IconFolderOpenOutlineRegular,
958
+ FileIcon: primitives.FileTypeIcon,
959
+ };
754
960
  }
755
961
  ctx.slots.inject(SLOT, function () {
756
962
  return ctx.slots.register(
@@ -1,4 +1,4 @@
1
- import type { KnownWorkspaces } from "./known-workspaces.js";
1
+ import type { WorkspaceCatalog } from "./workspace-catalog.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";
@@ -29,5 +29,5 @@ export interface CardWebServer {
29
29
  * shape the host hands back (or none, as on some host versions), the
30
30
  * disposer calls only the functions among them.
31
31
  */
32
- export declare function mountCardRoutes(webServer: CardWebServer, workspaces: KnownWorkspaces): () => void;
32
+ export declare function mountCardRoutes(webServer: CardWebServer, catalog: WorkspaceCatalog): () => void;
33
33
  //# sourceMappingURL=card-routes.d.ts.map
@@ -3,13 +3,14 @@
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
- * three endpoints are GET-only projections: the workspace allowlist, the
6
+ * three endpoints are GET-only projections: the workspace catalogue, the
7
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.
8
+ * content preview. The catalogue IS the security fence — the memory endpoints
9
+ * resolve their `root` parameter only against workspaces the host's registry
10
+ * (or, on hosts without one, a root this process has served) knows about, and
11
+ * the file endpoint additionally accepts only plain .md entries of that
12
+ * workspace's memory directory, so the API cannot read arbitrary paths.
13
+ * Handlers are synchronous and never throw past the response.
13
14
  */
14
15
  import { resolve } from "node:path";
15
16
  import { memoryFileContent, memorySnapshot } from "./memory-snapshot.js";
@@ -30,7 +31,7 @@ function isGet(request) {
30
31
  * shape the host hands back (or none, as on some host versions), the
31
32
  * disposer calls only the functions among them.
32
33
  */
33
- export function mountCardRoutes(webServer, workspaces) {
34
+ export function mountCardRoutes(webServer, catalog) {
34
35
  const disposers = [];
35
36
  disposers.push(webServer.register({
36
37
  kind: "exact",
@@ -41,7 +42,9 @@ export function mountCardRoutes(webServer, workspaces) {
41
42
  response.end();
42
43
  return;
43
44
  }
44
- sendJson(response, 200, { workspaces: workspaces.list() });
45
+ // The listed set is the display projection (workspaces with a
46
+ // memory directory); the read fence is `known` below.
47
+ sendJson(response, 200, { workspaces: catalog.list() });
45
48
  },
46
49
  }));
47
50
  disposers.push(webServer.register({
@@ -58,10 +61,10 @@ export function mountCardRoutes(webServer, workspaces) {
58
61
  sendJson(response, 400, { error: "root query parameter is required and must be an absolute path" });
59
62
  return;
60
63
  }
61
- const known = workspaces.list().find((workspace) => workspace.root === resolve(root));
64
+ const known = knownEntryOf(catalog, root);
62
65
  if (known === undefined) {
63
- // The allowlist answers 404 without distinguishing "never
64
- // served" from "wrong path": no filesystem oracle here.
66
+ // The catalogue answers 404 without distinguishing "not a
67
+ // workspace here" from "wrong path": no filesystem oracle.
65
68
  sendJson(response, 404, { error: "unknown workspace root" });
66
69
  return;
67
70
  }
@@ -87,10 +90,10 @@ export function mountCardRoutes(webServer, workspaces) {
87
90
  sendJson(response, 400, { error: "file query parameter is required" });
88
91
  return;
89
92
  }
90
- const known = workspaces.list().find((workspace) => workspace.root === resolve(root));
93
+ const known = knownEntryOf(catalog, root);
91
94
  if (known === undefined) {
92
95
  // Same fence as the snapshot route: 404 without distinguishing
93
- // "never served" from "wrong path".
96
+ // "not a workspace here" from "wrong path".
94
97
  sendJson(response, 404, { error: "unknown workspace root" });
95
98
  return;
96
99
  }
@@ -126,6 +129,11 @@ export function mountCardRoutes(webServer, workspaces) {
126
129
  }
127
130
  };
128
131
  }
132
+ /** The catalogued workspace a `root` query parameter resolves to, if any. */
133
+ function knownEntryOf(catalog, root) {
134
+ const target = resolve(root);
135
+ return catalog.known().find((workspace) => workspace.root === target);
136
+ }
129
137
  /** The decoded `root` query parameter, or null when absent/relative. */
130
138
  function rootParamOf(request) {
131
139
  try {
package/lib/index.d.ts CHANGED
@@ -9,12 +9,13 @@
9
9
  * background extraction, and no governance. See
10
10
  * .agents/notes/implemented/architecture/2026-10-04-zcode-alignment-teardown.md.
11
11
  *
12
- * The 2026-10-04 plugin-management card adds a read-only surface: the
13
- * workspace roots the section has served feed an in-memory allowlist, and a
14
- * `webServer` route pair projects any known workspace's store for the card
15
- * in the official plugin manager (`plugins.bundle.config`, client bundle in
16
- * client/client.js). The card is a viewer — see
17
- * .agents/notes/implemented/feature/2026-10-04-plugin-management-card.md.
12
+ * The 2026-10-04 plugin-management card adds a read-only surface: the host's
13
+ * workspace registry feeds a catalogue, and a `webServer` route triple projects
14
+ * any catalogued workspace's store for the card in the official plugin manager
15
+ * (`plugins.bundle.config`, client bundle in client/client.js). The card is a
16
+ * viewer — see
17
+ * .agents/notes/implemented/feature/2026-10-04-plugin-management-card.md and
18
+ * .agents/notes/implemented/feature/2026-10-05-zcode-workspace-switcher.md.
18
19
  *
19
20
  * The host API surface is declared locally (the minimal shape this plugin
20
21
  * touches) instead of importing the DSH type packages — the section call is
package/lib/index.js CHANGED
@@ -9,12 +9,13 @@
9
9
  * background extraction, and no governance. See
10
10
  * .agents/notes/implemented/architecture/2026-10-04-zcode-alignment-teardown.md.
11
11
  *
12
- * The 2026-10-04 plugin-management card adds a read-only surface: the
13
- * workspace roots the section has served feed an in-memory allowlist, and a
14
- * `webServer` route pair projects any known workspace's store for the card
15
- * in the official plugin manager (`plugins.bundle.config`, client bundle in
16
- * client/client.js). The card is a viewer — see
17
- * .agents/notes/implemented/feature/2026-10-04-plugin-management-card.md.
12
+ * The 2026-10-04 plugin-management card adds a read-only surface: the host's
13
+ * workspace registry feeds a catalogue, and a `webServer` route triple projects
14
+ * any catalogued workspace's store for the card in the official plugin manager
15
+ * (`plugins.bundle.config`, client bundle in client/client.js). The card is a
16
+ * viewer — see
17
+ * .agents/notes/implemented/feature/2026-10-04-plugin-management-card.md and
18
+ * .agents/notes/implemented/feature/2026-10-05-zcode-workspace-switcher.md.
18
19
  *
19
20
  * The host API surface is declared locally (the minimal shape this plugin
20
21
  * touches) instead of importing the DSH type packages — the section call is
@@ -24,6 +25,7 @@ import z from "@deepseek-ai/schemastery";
24
25
  import { mountCardRoutes } from "./card-routes.js";
25
26
  import { createKnownWorkspaces } from "./known-workspaces.js";
26
27
  import { createFrozenMemorySection, cwdOf, DEFAULT_MAX_CHARS, DEFAULT_MEMORY_SECTION_ORDER, MEMORY_SECTION_NAME, memorySectionText, } from "./memory-section.js";
28
+ import { createWorkspaceCatalog } from "./workspace-catalog.js";
27
29
  export const name = "continual-evolve";
28
30
  export const inject = ["systemPrompt"];
29
31
  export const Config = z.object({
@@ -53,9 +55,12 @@ export const Config = z.object({
53
55
  /**
54
56
  * Mount the card routes on the host web server through a nested inject, so
55
57
  * the plugin stays mountable on hosts without that service. Called only when
56
- * the card is enabled.
58
+ * the card is enabled. The workspace registry is looked up inside the scoped
59
+ * context (nothing else injects it: a host without the service must still get
60
+ * the card) and read per request, so a workspace created while the card is
61
+ * open appears on the next refresh.
57
62
  */
58
- function mountCardWhenAvailable(host, workspaces) {
63
+ function mountCardWhenAvailable(host, served) {
59
64
  const nested = host;
60
65
  if (typeof nested.inject !== "function") {
61
66
  host.logger("continual-evolve").warn("host context exposes no inject — memory card routes not mounted (memory section unaffected)");
@@ -67,9 +72,36 @@ function mountCardWhenAvailable(host, workspaces) {
67
72
  host.logger("continual-evolve").warn("webServer service present but empty — memory card routes not mounted (memory section unaffected)");
68
73
  return;
69
74
  }
70
- mountCardRoutes(webServer, workspaces);
75
+ const catalog = createWorkspaceCatalog({
76
+ registry: registryLookup(scoped),
77
+ served: served,
78
+ homeDir: process.env.DSH_HOME,
79
+ });
80
+ mountCardRoutes(webServer, catalog);
71
81
  });
72
82
  }
83
+ /**
84
+ * A live `ctx.workspaceRegistry` getter over the scoped context. The service
85
+ * is a sibling, so it is reached with `ctx.get` (property access only walks
86
+ * fiber ancestors) and treated as optional: an absent, foreign or throwing
87
+ * service degrades the catalogue to its fallback sources instead of taking the
88
+ * card down.
89
+ */
90
+ function registryLookup(scoped) {
91
+ const context = scoped;
92
+ return () => {
93
+ if (typeof context.get !== "function") {
94
+ return undefined;
95
+ }
96
+ try {
97
+ const service = context.get("workspaceRegistry");
98
+ return service === undefined || service === null ? undefined : service;
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ };
104
+ }
73
105
  export function apply(host, config) {
74
106
  if (config.memoryIndex?.enabled === false) {
75
107
  return;
@@ -0,0 +1,30 @@
1
+ import type { KnownWorkspaces } from "./known-workspaces.js";
2
+ /** One workspace the card knows: canonical root plus its display label. */
3
+ export interface CatalogEntry {
4
+ root: string;
5
+ /** Display title from the registry, else the directory name. */
6
+ label: string;
7
+ }
8
+ /** The read fence (`known`) and the listed set (`list`). */
9
+ export interface WorkspaceCatalog {
10
+ /** Every workspace the card may read, in registry order. */
11
+ known(): CatalogEntry[];
12
+ /** The known workspaces that currently have a memory directory. */
13
+ list(): CatalogEntry[];
14
+ }
15
+ /** The slice of `ctx.workspaceRegistry` this plugin consumes. */
16
+ export interface WorkspaceRegistryLike {
17
+ list(): unknown;
18
+ }
19
+ /** Where the catalog gets its workspace records, in precedence order. */
20
+ export interface WorkspaceCatalogOptions {
21
+ /** Live registry lookup; undefined when the host exposes no registry. */
22
+ registry?: (() => WorkspaceRegistryLike | undefined) | undefined;
23
+ /** The roots this process served — the last-resort source. */
24
+ served: KnownWorkspaces;
25
+ /** Harness home; enables the `storages/workspace.json` fallback when set. */
26
+ homeDir?: string | undefined;
27
+ }
28
+ /** Create the catalog over the given sources. */
29
+ export declare function createWorkspaceCatalog(options: WorkspaceCatalogOptions): WorkspaceCatalog;
30
+ //# sourceMappingURL=workspace-catalog.d.ts.map
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The card's workspace catalog — which workspaces the card may read, and which
3
+ * of them it lists.
4
+ *
5
+ * The list comes from the host's workspace registry (`ctx.workspaceRegistry`,
6
+ * the durable records the workspace picker itself renders), not from the
7
+ * sessions this process happened to serve: a card that only knows its own
8
+ * session history can never offer ZCode's workspace switching — with one
9
+ * served root the selector has nothing to switch to. The served-roots map
10
+ * stays as a source (a session in a workspace the registry does not know about
11
+ * still shows up), and `storages/workspace.json` is read only when the
12
+ * registry service yields nothing: that file is an internal format, so it is a
13
+ * fallback, never the primary.
14
+ *
15
+ * Two questions, deliberately answered by two methods. `known()` is the read
16
+ * fence: can this root be read at all? `list()` is the display projection:
17
+ * which workspaces have a memory directory right now? ZCode's viewer lists
18
+ * only workspaces that have one, but the fence cannot be that filter — a store
19
+ * deleted between listing and read must degrade to the empty state on its own
20
+ * entry, not turn the read into a 404.
21
+ */
22
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
23
+ import { basename, join, resolve } from "node:path";
24
+ import { memoryDirFor } from "./memory-section.js";
25
+ /** The registry stores `fs.realpath` already; resolve the rest the same way. */
26
+ function canonicalPath(path) {
27
+ try {
28
+ return realpathSync(path);
29
+ }
30
+ catch {
31
+ // A vanished or unreadable directory keeps its resolved spelling: the
32
+ // registry itself never rewrites a record when its directory disappears.
33
+ return resolve(path);
34
+ }
35
+ }
36
+ /** One catalogue entry from a path/title pair, or undefined when unusable. */
37
+ function entryOf(path, title) {
38
+ if (typeof path !== "string" || path.trim().length === 0) {
39
+ return undefined;
40
+ }
41
+ const root = canonicalPath(path);
42
+ const label = typeof title === "string" && title.trim().length > 0 ? title.trim() : basename(root);
43
+ return { root, label };
44
+ }
45
+ /** Records from the live registry service. */
46
+ function registryEntries(registry) {
47
+ if (registry === undefined) {
48
+ return [];
49
+ }
50
+ let records;
51
+ try {
52
+ records = registry.list();
53
+ }
54
+ catch {
55
+ // A registry that throws is a host fault the card must not inherit:
56
+ // the next source still fills the list.
57
+ return [];
58
+ }
59
+ if (!Array.isArray(records)) {
60
+ return [];
61
+ }
62
+ const entries = [];
63
+ for (const record of records) {
64
+ if (typeof record !== "object" || record === null) {
65
+ continue;
66
+ }
67
+ const entry = entryOf(record.path, record.title);
68
+ if (entry !== undefined) {
69
+ entries.push(entry);
70
+ }
71
+ }
72
+ return entries;
73
+ }
74
+ /** Records from the registry's own durable file, for hosts without the service. */
75
+ function fileEntries(homeDir) {
76
+ if (homeDir === undefined || homeDir.trim().length === 0) {
77
+ return [];
78
+ }
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(readFileSync(join(homeDir, "storages", "workspace.json"), "utf8"));
82
+ }
83
+ catch {
84
+ // Missing or unreadable file: no workspaces from this source, no throw.
85
+ return [];
86
+ }
87
+ const table = parsed?.tables?.workspaces;
88
+ if (typeof table !== "object" || table === null || Array.isArray(table)) {
89
+ return [];
90
+ }
91
+ const entries = [];
92
+ for (const record of Object.values(table)) {
93
+ if (typeof record !== "object" || record === null) {
94
+ continue;
95
+ }
96
+ const entry = entryOf(record.path, record.title);
97
+ if (entry !== undefined) {
98
+ entries.push(entry);
99
+ }
100
+ }
101
+ return entries;
102
+ }
103
+ /** Records from the roots this process served, most recent first. */
104
+ function servedEntries(served) {
105
+ return served.list().map(({ root }) => {
106
+ const canonical = canonicalPath(root);
107
+ return { root: canonical, label: basename(canonical) };
108
+ });
109
+ }
110
+ /** ZCode's memory-directory rule: a plain directory, never a symlink. */
111
+ function hasMemoryDirectory(root) {
112
+ try {
113
+ const stat = lstatSync(memoryDirFor(root));
114
+ return stat.isDirectory() && !stat.isSymbolicLink();
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /** Deduplicate by canonical root, keeping the first (highest-precedence) entry. */
121
+ function dedupe(entries) {
122
+ const seen = new Set();
123
+ const unique = [];
124
+ for (const entry of entries) {
125
+ if (seen.has(entry.root)) {
126
+ continue;
127
+ }
128
+ seen.add(entry.root);
129
+ unique.push(entry);
130
+ }
131
+ return unique;
132
+ }
133
+ /** Create the catalog over the given sources. */
134
+ export function createWorkspaceCatalog(options) {
135
+ const known = () => {
136
+ const live = registryEntries(options.registry?.());
137
+ if (live.length > 0) {
138
+ return dedupe([...live, ...servedEntries(options.served)]);
139
+ }
140
+ return dedupe([...fileEntries(options.homeDir), ...servedEntries(options.served)]);
141
+ };
142
+ return {
143
+ known,
144
+ list() {
145
+ return known().filter((entry) => hasMemoryDirectory(entry.root));
146
+ },
147
+ };
148
+ }
149
+ //# sourceMappingURL=workspace-catalog.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
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": {
@@ -60,7 +60,7 @@
60
60
  "test": "vitest run",
61
61
  "test:coverage": "vitest run --coverage",
62
62
  "test:watch": "vitest",
63
- "lint": "oxlint src test",
63
+ "lint": "oxlint src test client",
64
64
  "clean": "rm -rf lib",
65
65
  "check:docs": "tsx scripts/run-gates.ts docs",
66
66
  "check:handoff": "tsx scripts/verify-handoff-structure.ts --self-test && tsx scripts/verify-handoff-structure.ts",