dsh-continual-evolve 0.15.0 → 0.15.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,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-168%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-90%25-green)]()
10
+ [![Tests](https://img.shields.io/badge/tests-190%20passing-brightgreen)]()
11
+ [![Coverage · statements](https://img.shields.io/badge/coverage_statements-97%25-brightgreen)]()
12
+ [![Coverage · branches](https://img.shields.io/badge/coverage_branches-91%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`): every memory lives in one central SQLite database (`~/.dsh/evolve/memory.db`, rows partitioned by workspace), the index is injected at session start, and the plugin's code is the literal **sole writer** — the model proposes, code gates and lands.
@@ -21,10 +21,10 @@ Reusable experience gathered in one session (user preferences, pitfalls, project
21
21
  ## How it works
22
22
 
23
23
  1. **Session-start injection** — the plugin registers a single system-prompt section: the workspace's memory index as a store query (hooks only, whole-line truncated over budget, ordered feedback > user > reference), the central-library path, and a three-line 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
- 2. **Two tools, one writer** — `memory_write` (create/update/delete) is the explicit path for "remember …" / "forget …": every proposal passes the code gates (id shape, type enum, the feedback Why/How contract, secret screening carried over from v1, 64KB body cap) inside one transaction. `memory_read` fetches bodies and searches by keyword — the injected index carries hooks only. On hosts without the tools service the plugin degrades to "tell the user" phrasing and stays functional.
25
- 3. **Proposal-based extraction** — the only background automation, audited in the `extraction_log`: every `agent/turn-stopping` rearms an idle debounce (default 10 min, `memoryIndex.debounceMin`, single-flight with burst coalescing; `compaction/start` and session close flush). A run reads the session increment since its cursor, feeds FTS-similar existing memories to one LLM call, and the model's structured proposal lands through the same gates; failures and rejections never advance the cursor. Internal agents, empty increments, turns without real user prose, and turns that already carried an explicit `memory_write` are mechanically skipped — and every skip is ledgered.
24
+ 2. **Two tools, one writer** — `memory_write` (create/update/delete) is the explicit path for "remember …" / "forget …": every proposal passes the code gates (id shape, type enum, the feedback Why/How contract, secret screening carried over from v1, 64KB body cap) inside one transaction. `memory_read` fetches bodies by id and searches by keyword — whitespace-separated terms are AND-combined, with literal substring matching for short terms and whenever the trigram index misses; the injected index carries hooks only. On hosts without the tools service the plugin degrades to "tell the user" phrasing and stays functional.
25
+ 3. **Proposal-based extraction** — the only background automation, audited in the `extraction_log`: every `agent/turn-stopping` schedules that session's boundary, with state kept **per session** (a run in flight keeps only its newest boundary — coalescing, not queueing; `compaction/start` and session close drain it) and **no idle timer**. A run reads the session increment since its cursor, feeds FTS-similar existing memories to one LLM call, and the model's structured proposal lands through the same gates; failures and rejections never advance the cursor. Internal agents, empty increments, turns without real user prose, and turns that already carried an explicit `memory_write` are mechanically skipped — and every skip is ledgered.
26
26
  4. **Rollback is a ledger, not a mechanism** — every mutation lands an `extraction_log` row with full before/after snapshots; undo means rewriting the `before` values. A hygiene pass rides on every applied run: orphan FTS rows are dropped and secret-bearing rows are quarantined (hidden, never deleted). The read-only card surfaces quarantine anomalies.
27
- 5. **Read-only card** — mounted on the plugin's 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 and aligned with ZCode's Settings → Memory viewer: a dropdown scope selector over the store's workspace partitions, a search box, per-entry relative updated times, click-to-preview bodies (5 MiB cap), and patrol warnings. Localized through the host's `locale/*.json` metadata channel; host theme tokens, the `Button` primitive, and zh/en copy that tracks the DSH language. The card is a request-time projection of the database and has no edit path.
27
+ 5. **Read-only card** — mounted on the plugin's 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 and aligned with ZCode's Settings → Memory viewer: a dropdown scope selector over the store's workspace partitions (opening on the host's current workspace), a search box, per-entry relative updated times, click-to-preview bodies (5 MiB cap), and patrol warnings. Localized through the host's `locale/*.json` metadata channel; host theme tokens, the `Button` primitive, and zh/en copy that tracks the DSH language. The card is a request-time projection of the database and has no edit path.
28
28
 
29
29
  Memory types follow the ZCode taxonomy: `user` (profile and environment facts), `feedback` (corrected or confirmed practices — bodies must carry **Why:** and **How to apply:** lines), `reference` (resource pointers). The extraction boundary is person- and environment-scoped knowledge only: decisions and trade-offs are repo-scoped by nature and belong to the repo's ADR route, never to memory.
30
30
 
@@ -54,8 +54,7 @@ No commands. On the first session after restart the index is injected automatica
54
54
  | `memoryIndex.guide` | `true` | Inject the guide (zero tokens when off and the index is empty) |
55
55
  | `memoryIndex.order` | `400` | Section order (upstream named slots start at `PLAN_POLICY=500`) |
56
56
  | `memoryIndex.maxChars` | `6000` | Hard character budget for the injected index |
57
- | `memoryIndex.extraction` | `true` | The proposal-based extraction run |
58
- | `memoryIndex.debounceMin` | `10` | Idle debounce minutes after a turn (0 = run every turn) |
57
+ | `memoryIndex.extraction` | `true` | The proposal-based extraction run (turn-level, per-session slots, no idle timer) |
59
58
  | `memoryCard.enabled` | `true` | Read-only memory card in the official plugin manager (degrades to no card when the host lacks `webServer`/`plugins.bundle.config`) |
60
59
 
61
60
  Profile patch example:
@@ -65,16 +64,17 @@ Profile patch example:
65
64
  config:
66
65
  memoryIndex:
67
66
  maxChars: 8000
68
- debounceMin: 5
67
+ extraction: true
69
68
  ```
70
69
 
71
70
  ## Development
72
71
 
73
72
  ```bash
74
- pnpm install && pnpm build # deps + tsc -> lib/
75
- pnpm test # vitest (168 tests)
73
+ pnpm install && pnpm build # deps + clean build -> lib/
74
+ pnpm test # vitest (190 tests)
76
75
  pnpm test:coverage # v8 coverage, CI-enforced thresholds
77
76
  pnpm lint # oxlint src test client
77
+ pnpm check:pack # published-artifact consistency (also runs on prepack)
78
78
  ```
79
79
 
80
80
  Layout:
@@ -88,16 +88,17 @@ Layout:
88
88
  │ ├── memory-section.ts # injection: store query / truncate / session freeze
89
89
  │ ├── memory-guide.ts # the main-session guide (three lines)
90
90
  │ ├── memory-tools.ts # memory_write + memory_read (host-shaped definitions)
91
- │ ├── extraction.ts # runner + scheduler (debounce, single-flight, flush)
91
+ │ ├── extraction.ts # runner + per-session scheduler (turn-level, no idle timer)
92
92
  │ ├── extraction-surface.ts # trajectory slicing: cursor, eligibility, serialization
93
93
  │ ├── extraction-prompt.ts # the extraction prompt + answer parsing
94
94
  │ ├── import-md.ts # lossless legacy markdown migration
95
95
  │ ├── memory-snapshot.ts # card: read-only store projection (entries / patrol / degrade)
96
- │ └── card-routes.ts # card: GET-only read API on the host webServer
96
+ │ ├── card-routes.ts # card: GET-only read API on the host webServer
97
+ │ └── workspace-hint.ts # card: the host's current workspace as the opening default
97
98
  ├── client/
98
99
  │ └── client.js # hand-written client bundle: read-only card in the plugin manager
99
100
  ├── locale/ # host package metadata: localized display name + description (zh/en)
100
- ├── test/ # vitest suite (15 files)
101
+ ├── test/ # vitest suite (16 files)
101
102
  ├── lib/ # build output (tsc)
102
103
  ├── docs/ # design.md (design) · FAQ.md (real-world pitfalls)
103
104
  └── .agents/ # AI collaboration layer (AGENTS.md, skills, ADR notes)
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-168%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-90%25-green)]()
10
+ [![Tests](https://img.shields.io/badge/tests-190%20passing-brightgreen)]()
11
+ [![Coverage · statements](https://img.shields.io/badge/coverage_statements-97%25-brightgreen)]()
12
+ [![Coverage · branches](https://img.shields.io/badge/coverage_branches-91%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`)的工作区记忆插件:全部记忆住在一个中央 SQLite 库里(`~/.dsh/evolve/memory.db`,按工作区路径行分区),会话开局注入索引,插件的代码是字面意义的**唯一写者**——模型提议,代码门禁后落盘。
@@ -21,10 +21,10 @@ Agent 每个会话积累的可复用经验(用户偏好、踩坑教训、项
21
21
  ## 工作原理
22
22
 
23
23
  1. **开局注入** —— 插件只注册一个 system-prompt section:注入本工作区的记忆索引(查库结果,只含钩子,超预算按整行截断,feedback > user > reference 排序)、中央库路径、三行指南。该 section 每会话只算一次并逐字节复用,system prompt 稳定、prompt cache 持续命中;空 store 零 token。
24
- 2. **两个工具,一个写者** —— `memory_write`(create/update/delete)承接显式的"记住……"/"忘掉……":每条提案过代码门禁(id 规范、type 枚举、feedback 的 Why/How 契约、v1 移植的 secret 筛查、64KB 正文上限)后在一个事务内落盘。`memory_read` 按 id 取正文、按关键词检索——注入索引只有钩子。宿主没有工具服务时降级为"告诉用户"措辞,注入不受影响。
25
- 3. **提案制提取** —— 插件唯一的后台自动化,全程 `extraction_log` 可对账:每个 `agent/turn-stopping` 重置空闲去抖计时器(默认 10 分钟,`memoryIndex.debounceMin`,单飞行合并突发;`compaction/start` 与会话收尾强制 flush)。一次运行读取该会话游标之后的轨迹增量,用 FTS 取相似既有记忆喂给一次 LLM 调用,模型的结构化提案过同一套门禁落盘;失败与被拒不推游标,下次触发重试。内部 agent、空增量、无真实用户文本的轮次、当轮已有显式 `memory_write` 的轮次机械跳过——每次跳过都记账。
24
+ 2. **两个工具,一个写者** —— `memory_write`(create/update/delete)承接显式的"记住……"/"忘掉……":每条提案过代码门禁(id 规范、type 枚举、feedback 的 Why/How 契约、v1 移植的 secret 筛查、64KB 正文上限)后在一个事务内落盘。`memory_read` 按 id 取正文、按关键词检索——空格分隔的关键词取 AND,短词与 trigram 未命中时回退字面子串匹配;注入索引只有钩子。宿主没有工具服务时降级为"告诉用户"措辞,注入不受影响。
25
+ 3. **提案制提取** —— 插件唯一的后台自动化,全程 `extraction_log` 可对账:每个 `agent/turn-stopping` 调度**该会话自己**的边界,状态按会话隔离(运行中只保留最新边界——合并而非排队;`compaction/start` 与会话收尾触发 drain),**没有空闲计时器**。一次运行读取该会话游标之后的轨迹增量,用 FTS 取相似既有记忆喂给一次 LLM 调用,模型的结构化提案过同一套门禁落盘;失败与被拒不推游标,下次触发重试。内部 agent、空增量、无真实用户文本的轮次、当轮已有显式 `memory_write` 的轮次机械跳过——每次跳过都记账。
26
26
  4. **回滚靠账本,不靠机制** —— 每次变更落一条带 before/after 全文快照的 `extraction_log` 行;undo 就是取 before 重写。每次应用后随跑一次巡检:孤儿 FTS 行清除、含密钥条目隔离(隐藏,不物理删除)。只读卡片显形巡检异常。
27
- 5. **只读卡片** —— 挂在官方插件管理的本 bundle 页面(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡,内容对齐 ZCode 设置的记忆选项卡——下拉作用域选择器列出库里的工作区分区、搜索框、每条记忆的相对更新时间、点击行内展开正文预览(5 MiB 上限)、巡检异常警告。显示名与描述走宿主 `locale/*.json` 通道双语;宿主主题 token、`Button` 原语、中英文案跟随 DSH 语言。卡片是数据库的请求时投影,没有编辑路径。
27
+ 5. **只读卡片** —— 挂在官方插件管理的本 bundle 页面(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡,内容对齐 ZCode 设置的记忆选项卡——下拉作用域选择器列出库里的工作区分区(默认选中宿主当前工作区)、搜索框、每条记忆的相对更新时间、点击行内展开正文预览(5 MiB 上限)、巡检异常警告。显示名与描述走宿主 `locale/*.json` 通道双语;宿主主题 token、`Button` 原语、中英文案跟随 DSH 语言。卡片是数据库的请求时投影,没有编辑路径。
28
28
 
29
29
  记忆类型沿 ZCode 分类法:`user`(画像与环境事实)、`feedback`(被纠正/确认的做法——正文必带 **Why:** 与 **How to apply:**)、`reference`(资源指针)。提取边界只收"跟人与环境走的知识":决策与取舍天然属于仓库,走仓库的 ADR 路线,不进记忆库。
30
30
 
@@ -54,8 +54,7 @@ dsh plugin add ZK-Andy/dsh-continual-evolve
54
54
  | `memoryIndex.guide` | `true` | 是否注入指南(关闭且索引为空时该 section 零 token) |
55
55
  | `memoryIndex.order` | `400` | section 顺序(上游命名槽位自 `PLAN_POLICY=500` 起) |
56
56
  | `memoryIndex.maxChars` | `6000` | 注入索引的硬字符预算 |
57
- | `memoryIndex.extraction` | `true` | 提案制提取运行 |
58
- | `memoryIndex.debounceMin` | `10` | 轮末空闲去抖分钟数(0 = 每轮即跑) |
57
+ | `memoryIndex.extraction` | `true` | 提案制提取运行(轮级、按会话分槽、无空闲计时器) |
59
58
  | `memoryCard.enabled` | `true` | 官方插件管理内的只读记忆卡片(宿主缺 `webServer`/`plugins.bundle.config` 时自动降级为无卡片) |
60
59
 
61
60
  profile patch 示例:
@@ -65,16 +64,17 @@ profile patch 示例:
65
64
  config:
66
65
  memoryIndex:
67
66
  maxChars: 8000
68
- debounceMin: 5
67
+ extraction: true
69
68
  ```
70
69
 
71
70
  ## 开发
72
71
 
73
72
  ```bash
74
- pnpm install && pnpm build # 依赖 + tsc -> lib/
75
- pnpm test # vitest(168 例)
73
+ pnpm install && pnpm build # 依赖 + 干净构建 -> lib/
74
+ pnpm test # vitest(190 例)
76
75
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
77
76
  pnpm lint # oxlint src test client
77
+ pnpm check:pack # 发布产物一致性(prepack 也会跑)
78
78
  ```
79
79
 
80
80
  目录结构:
@@ -88,16 +88,17 @@ pnpm lint # oxlint src test client
88
88
  │ ├── memory-section.ts # 注入:查库 / 截断 / 会话冻结
89
89
  │ ├── memory-guide.ts # 主会话指南(三行)
90
90
  │ ├── memory-tools.ts # memory_write + memory_read(宿主形状定义)
91
- │ ├── extraction.ts # 运行器 + 调度器(去抖、单飞行、flush)
91
+ │ ├── extraction.ts # 运行器 + 按会话分槽的调度器(轮级、无空闲计时器)
92
92
  │ ├── extraction-surface.ts # 轨迹切片:游标、资格判定、序列化
93
93
  │ ├── extraction-prompt.ts # 提取 prompt + 应答解析
94
94
  │ ├── import-md.ts # 旧版 markdown 无损迁移
95
95
  │ ├── memory-snapshot.ts # 卡片:记忆库只读投影(条目 / 巡检 / 错误降级)
96
- │ └── card-routes.ts # 卡片:webServer 上的 GET-only 只读 API
96
+ │ ├── card-routes.ts # 卡片:webServer 上的 GET-only 只读 API
97
+ │ └── workspace-hint.ts # 卡片:宿主当前工作区作为打开时的默认选中
97
98
  ├── client/
98
99
  │ └── client.js # 手写 client bundle:官方插件管理内只读卡片
99
100
  ├── locale/ # 宿主包元数据:显示名 + 描述双语(zh/en)
100
- ├── test/ # vitest 测试(15 个文件)
101
+ ├── test/ # vitest 测试(16 个文件)
101
102
  ├── lib/ # 构建产物(tsc)
102
103
  ├── docs/ # design.md(设计)· FAQ.md(踩坑)
103
104
  └── .agents/ # AI 协作层(AGENTS.md、技能、ADR 笔记)
package/client/client.js CHANGED
@@ -27,11 +27,13 @@
27
27
  *
28
28
  * Content (v0.14, scope selector v0.15): aligned with ZCode's Settings → Memory
29
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
30
+ * selected at a time behind a dropdown scope selector (workspaces are the
31
+ * store's partitions, so the selector lists exactly what the read fence
32
+ * serves), a file search box, per-file relative updated times, and a
33
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
34
+ * deleted/changed guards). The card opens on the server's `defaultRoot` (the
35
+ * host's current workspace), falling back to the first listed partition. The
36
+ * drift warnings (dangling index rows / unindexed
35
37
  * files) stay: they are this plugin's OBSERVATION surface and a deliberate
36
38
  * superset of ZCode's viewer.
37
39
  * Locale/theme/primitives wiring unchanged from v0.13: `ctx.locale.register`
@@ -219,7 +221,12 @@ window.__ModuleLoader__.load({
219
221
  }
220
222
  }),
221
223
  );
222
- return { workspaces: entries };
224
+ return {
225
+ workspaces: entries,
226
+ // The server's answer to "which partition is this user in", or null
227
+ // when it has no opinion (the card then opens on the first row).
228
+ defaultRoot: typeof listBody.defaultRoot === "string" ? listBody.defaultRoot : null,
229
+ };
223
230
  }
224
231
 
225
232
  /**
@@ -821,13 +828,18 @@ window.__ModuleLoader__.load({
821
828
  inner = createElement("div", { key: "inner", className: "dce-empty" }, t("empty"));
822
829
  } else {
823
830
  var selected = selection.root;
824
- if (
825
- selected === null ||
826
- !model.model.workspaces.some(function (workspace) {
827
- return workspace.root === selected;
828
- })
829
- ) {
830
- selected = model.model.workspaces[0].root;
831
+ var listed = function (root) {
832
+ return model.model.workspaces.some(function (workspace) {
833
+ return workspace.root === root;
834
+ });
835
+ };
836
+ if (selected === null || !listed(selected)) {
837
+ // The server names the workspace to open on (the host's current
838
+ // one); an unknown or absent name leaves the first row, which is
839
+ // the pre-hint behaviour.
840
+ selected = typeof model.model.defaultRoot === "string" && listed(model.model.defaultRoot)
841
+ ? model.model.defaultRoot
842
+ : model.model.workspaces[0].root;
831
843
  }
832
844
  inner = createElement(WorkspaceBlock, {
833
845
  key: "inner",
@@ -23,11 +23,23 @@ export interface CardRoute {
23
23
  export interface CardWebServer {
24
24
  register(route: CardRoute): unknown;
25
25
  }
26
+ /**
27
+ * Per-request hints the mount takes from the host. Both are optional: a host
28
+ * without the service behind them simply leaves the default to the next source
29
+ * in the chain (see {@link defaultRootOf}).
30
+ */
31
+ export interface CardRouteOptions {
32
+ /**
33
+ * The GUI's current workspace, resolved live on every request so a
34
+ * workspace attached while the card is open is picked up on refresh.
35
+ */
36
+ currentWorkspace?: (() => string | undefined) | undefined;
37
+ }
26
38
  /**
27
39
  * Register the card routes and return a disposer that removes them.
28
40
  * Registration return values are passed through untouched: whatever disposer
29
41
  * shape the host hands back (or none, as on some host versions), the
30
42
  * disposer calls only the functions among them.
31
43
  */
32
- export declare function mountCardRoutes(webServer: CardWebServer, store: MemoryStore): () => void;
44
+ export declare function mountCardRoutes(webServer: CardWebServer, store: MemoryStore, options?: CardRouteOptions): () => void;
33
45
  //# sourceMappingURL=card-routes.d.ts.map
@@ -4,14 +4,15 @@
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
6
  * three endpoints are GET-only projections of the central SQLite store: the
7
- * workspace list (active partitions whose directory still exists), the
8
- * memory snapshot for a known workspace, and one record's body by id. The
9
- * store itself is the security fence — a `root` parameter only resolves
10
- * when the database has rows for it, so the API cannot probe the
11
- * filesystem; the content endpoint takes an id, never a path. Handlers are
12
- * synchronous and never throw past the response.
7
+ * workspace list (active partitions whose directory still exists) plus the
8
+ * partition the card should open on, the memory snapshot for a known
9
+ * workspace, and one record's body by id. The store itself is the security
10
+ * fence — a `root` parameter only resolves when the database has rows for it,
11
+ * so the API cannot probe the filesystem; the content endpoint takes an id,
12
+ * never a path. Handlers are synchronous and never throw past the response.
13
13
  */
14
14
  import { memoryEntryContent, listCardWorkspaces, memorySnapshot } from "./memory-snapshot.js";
15
+ import { canonicalPath } from "./workspace-hint.js";
15
16
  /** URL prefix of the card API (kind-exact routes registered under it). */
16
17
  export const CARD_API_WORKSPACES_PATH = "/dsh-continual-evolve/api/v1/workspaces";
17
18
  export const CARD_API_MEMORY_PATH = "/dsh-continual-evolve/api/v1/memory";
@@ -33,7 +34,7 @@ function isKnownRoot(store, root) {
33
34
  * shape the host hands back (or none, as on some host versions), the
34
35
  * disposer calls only the functions among them.
35
36
  */
36
- export function mountCardRoutes(webServer, store) {
37
+ export function mountCardRoutes(webServer, store, options = {}) {
37
38
  const disposers = [];
38
39
  disposers.push(webServer.register({
39
40
  kind: "exact",
@@ -44,7 +45,8 @@ export function mountCardRoutes(webServer, store) {
44
45
  response.end();
45
46
  return;
46
47
  }
47
- sendJson(response, 200, { workspaces: listCardWorkspaces(store) });
48
+ const workspaces = listCardWorkspaces(store);
49
+ sendJson(response, 200, { workspaces, defaultRoot: defaultRootOf(store, workspaces, options) });
48
50
  },
49
51
  }));
50
52
  disposers.push(webServer.register({
@@ -153,4 +155,45 @@ function idParamOf(request) {
153
155
  return null;
154
156
  }
155
157
  }
158
+ /**
159
+ * The partition the card opens on: the host's current workspace when it is one
160
+ * the card lists, else the store's most recently active partition, else null —
161
+ * the client then falls back to the first row. A hint that names an unlisted
162
+ * root is ignored rather than trusted, because the listed set is a projection
163
+ * of the store and nothing outside it is readable; spelling differences
164
+ * (symlink vs cwd) are resolved before the comparison.
165
+ */
166
+ function defaultRootOf(store, workspaces, options) {
167
+ const listed = (candidate) => {
168
+ if (typeof candidate !== "string" || candidate.length === 0) {
169
+ return null;
170
+ }
171
+ const exact = workspaces.find((workspace) => workspace.root === candidate);
172
+ if (exact !== undefined) {
173
+ return exact.root;
174
+ }
175
+ const canonical = canonicalPath(candidate);
176
+ return workspaces.find((workspace) => canonicalPath(workspace.root) === canonical)?.root ?? null;
177
+ };
178
+ const candidates = [];
179
+ try {
180
+ candidates.push(options.currentWorkspace?.());
181
+ }
182
+ catch {
183
+ // A failing hint is simply no hint; the store still answers.
184
+ }
185
+ try {
186
+ candidates.push(store.mostRecentlyActiveWorkspace());
187
+ }
188
+ catch {
189
+ // A failing query leaves the default to the client's first row.
190
+ }
191
+ for (const candidate of candidates) {
192
+ const root = listed(candidate);
193
+ if (root !== null) {
194
+ return root;
195
+ }
196
+ }
197
+ return null;
198
+ }
156
199
  //# sourceMappingURL=card-routes.js.map
@@ -46,15 +46,11 @@ export interface SchedulerHost {
46
46
  warn(message: string): void;
47
47
  };
48
48
  }
49
- export interface SchedulerOptions {
50
- /** Idle debounce in minutes (default 10); 0 fires on every turn. */
51
- debounceMin?: number;
52
- }
53
49
  /**
54
50
  * Wire the extraction triggers onto the host context. Returns a disposer
55
- * that removes the listeners and clears the timer. The scheduler is the
56
- * only background automation the plugin runs, and it is bounded by design:
57
- * one timer, one in-flight run, one pending slot.
51
+ * that removes the listeners. The scheduler is the only background
52
+ * automation the plugin runs, and it is bounded by design: one boundary and
53
+ * one in-flight run per session.
58
54
  */
59
- export declare function createExtractionScheduler(host: SchedulerHost, deps: ExtractionDeps, options?: SchedulerOptions): () => void;
55
+ export declare function createExtractionScheduler(host: SchedulerHost, deps: ExtractionDeps): () => void;
60
56
  //# sourceMappingURL=extraction.d.ts.map
package/lib/extraction.js CHANGED
@@ -165,16 +165,21 @@ async function streamOnce(llm, target, prompt) {
165
165
  }
166
166
  /**
167
167
  * Wire the extraction triggers onto the host context. Returns a disposer
168
- * that removes the listeners and clears the timer. The scheduler is the
169
- * only background automation the plugin runs, and it is bounded by design:
170
- * one timer, one in-flight run, one pending slot.
168
+ * that removes the listeners. The scheduler is the only background
169
+ * automation the plugin runs, and it is bounded by design: one boundary and
170
+ * one in-flight run per session.
171
171
  */
172
- export function createExtractionScheduler(host, deps, options = {}) {
173
- const debounceMs = Math.max(0, (options.debounceMin ?? 10) * 60_000);
172
+ export function createExtractionScheduler(host, deps) {
174
173
  const listeners = [];
175
- let timer = null;
176
- let running = false;
177
- let pending = null;
174
+ const slots = new Map();
175
+ const slotOf = (sessionId) => {
176
+ let slot = slots.get(sessionId);
177
+ if (slot === undefined) {
178
+ slot = { pending: null, running: false };
179
+ slots.set(sessionId, slot);
180
+ }
181
+ return slot;
182
+ };
178
183
  const targetOf = (agent) => {
179
184
  const id = agent?.id;
180
185
  if (typeof id !== "string" || id.length === 0) {
@@ -188,17 +193,14 @@ export function createExtractionScheduler(host, deps, options = {}) {
188
193
  model: typeof route?.model === "string" ? route.model : undefined,
189
194
  };
190
195
  };
191
- const flush = (trigger) => {
192
- if (timer !== null) {
193
- clearTimeout(timer);
194
- timer = null;
195
- }
196
- if (running || pending === null) {
196
+ const drain = (sessionId, trigger) => {
197
+ const slot = slotOf(sessionId);
198
+ if (slot.running || slot.pending === null) {
197
199
  return;
198
200
  }
199
- const target = pending;
200
- pending = null;
201
- running = true;
201
+ const target = slot.pending;
202
+ slot.pending = null;
203
+ slot.running = true;
202
204
  void runExtraction(deps, {
203
205
  workspaceId: cwdOf(target.agent),
204
206
  sessionId: target.sessionId,
@@ -208,57 +210,66 @@ export function createExtractionScheduler(host, deps, options = {}) {
208
210
  }, { trigger }).catch((error) => {
209
211
  host.logger("continual-evolve").warn(`extraction run failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`);
210
212
  }).finally(() => {
211
- running = false;
212
- if (pending !== null) {
213
- // A turn arrived mid-run and refreshed the pending slot: drain it.
214
- flush("turn-debounce");
213
+ slot.running = false;
214
+ if (slot.pending !== null) {
215
+ // A turn landed mid-run: the newest boundary is the one to run.
216
+ drain(sessionId, "turn-debounce");
215
217
  }
216
218
  });
217
219
  };
218
- const arm = (agent) => {
219
- const target = targetOf(agent);
220
- if (target === null) {
220
+ /**
221
+ * Schedule one session's boundary. Internal agents (subagents and DSH
222
+ * internal agents) are filtered at this entry rather than inside the run:
223
+ * they are not memory sources, so they never occupy a slot, never drain
224
+ * another session's boundary, and leave no ledger rows behind.
225
+ */
226
+ const schedule = (agent) => {
227
+ if (isInternalAgent(agent)) {
221
228
  return;
222
229
  }
223
- pending = target; // Coalescing: the latest boundary wins, nothing queues.
224
- if (debounceMs === 0) {
225
- flush("turn-debounce");
230
+ const target = targetOf(agent);
231
+ if (target === null) {
226
232
  return;
227
233
  }
228
- if (timer !== null) {
229
- clearTimeout(timer);
230
- }
231
- timer = setTimeout(() => {
232
- timer = null;
233
- flush("turn-debounce");
234
- }, debounceMs);
234
+ slotOf(target.sessionId).pending = target;
235
+ drain(target.sessionId, "turn-debounce");
235
236
  };
236
237
  listeners.push(host.on("agent/turn-stopping", (payload) => {
237
238
  if (payload.agent === undefined) {
238
239
  return;
239
240
  }
240
- arm(payload.agent);
241
+ schedule(payload.agent);
241
242
  }));
242
243
  listeners.push(host.on("session/event", (session, event) => {
243
- if (event?.type !== "compaction/start" || pending === null || pending.sessionId !== session?.id) {
244
+ const id = session?.id;
245
+ if (event?.type !== "compaction/start" || typeof id !== "string") {
246
+ return;
247
+ }
248
+ const slot = slots.get(id);
249
+ if (slot === undefined || slot.pending === null) {
244
250
  return;
245
251
  }
246
- flush("compaction");
252
+ drain(id, "compaction");
247
253
  }));
248
254
  listeners.push(host.on("agent/disposed", (payload) => {
249
255
  const id = payload.agent?.id;
250
- if (pending === null || pending.sessionId !== id) {
256
+ if (typeof id !== "string") {
251
257
  return;
252
258
  }
253
- flush("close-drain");
259
+ const slot = slots.get(id);
260
+ if (slot === undefined) {
261
+ return;
262
+ }
263
+ if (slot.pending !== null) {
264
+ drain(id, "close-drain");
265
+ }
266
+ // The session is gone: drop its slot. A run already in flight holds
267
+ // its own reference and simply finds nothing left to drain.
268
+ slots.delete(id);
254
269
  }));
255
- host.logger("continual-evolve").info(`extraction scheduler armed (debounce ${options.debounceMin ?? 10}min, single-flight with coalescing)`);
270
+ host.logger("continual-evolve").info("extraction scheduler armed (per-session slots, turn-level, single-flight per session)");
256
271
  return () => {
257
- if (timer !== null) {
258
- clearTimeout(timer);
259
- timer = null;
260
- }
261
- pending = null;
272
+ slots.clear();
262
273
  for (const listener of listeners) {
263
274
  if (typeof listener === "function") {
264
275
  listener();
package/lib/index.d.ts CHANGED
@@ -11,8 +11,9 @@
11
11
  * as the injected index, frozen per session for the prompt cache;
12
12
  * - the model tools (`memory-tools.ts`): `memory_write` for explicit
13
13
  * "记住/忘掉" instructions, `memory_read` for bodies and search;
14
- * - the extraction scheduler (`extraction.ts`): turn-level, debounced,
15
- * single-flight; proposals land only through the store's gates;
14
+ * - the extraction scheduler (`extraction.ts`): turn-level, per-session
15
+ * slots, single-flight per session; proposals land only through the
16
+ * store's gates;
16
17
  * - the read-only card routes (`card-routes.ts`) in the official plugin
17
18
  * manager.
18
19
  *
@@ -59,26 +60,22 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
59
60
  order: z<number, number, "plain">;
60
61
  maxChars: z<number, number, "plain">;
61
62
  /**
62
- * The proposal-based extraction run (trigger: turn-level with idle
63
- * debounce; writes land only through the store's gates). Off keeps
64
- * the plugin injection + explicit tools only.
63
+ * The proposal-based extraction run (trigger: turn-level, per-session
64
+ * slots, no idle timer; writes land only through the store's gates).
65
+ * Off keeps the plugin injection + explicit tools only.
65
66
  */
66
67
  extraction: z<boolean, boolean, "plain">;
67
- /** Idle debounce minutes between turn end and an extraction run (0 = per turn). */
68
- debounceMin: z<number, number, "plain">;
69
68
  }>>, Schemastery.ObjectT<NoInfer<{
70
69
  enabled: z<boolean, boolean, "plain">;
71
70
  guide: z<boolean, boolean, "plain">;
72
71
  order: z<number, number, "plain">;
73
72
  maxChars: z<number, number, "plain">;
74
73
  /**
75
- * The proposal-based extraction run (trigger: turn-level with idle
76
- * debounce; writes land only through the store's gates). Off keeps
77
- * the plugin injection + explicit tools only.
74
+ * The proposal-based extraction run (trigger: turn-level, per-session
75
+ * slots, no idle timer; writes land only through the store's gates).
76
+ * Off keeps the plugin injection + explicit tools only.
78
77
  */
79
78
  extraction: z<boolean, boolean, "plain">;
80
- /** Idle debounce minutes between turn end and an extraction run (0 = per turn). */
81
- debounceMin: z<number, number, "plain">;
82
79
  }>>, "plain">;
83
80
  /**
84
81
  * The read-only memory card in the official plugin manager
@@ -105,26 +102,22 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
105
102
  order: z<number, number, "plain">;
106
103
  maxChars: z<number, number, "plain">;
107
104
  /**
108
- * The proposal-based extraction run (trigger: turn-level with idle
109
- * debounce; writes land only through the store's gates). Off keeps
110
- * the plugin injection + explicit tools only.
105
+ * The proposal-based extraction run (trigger: turn-level, per-session
106
+ * slots, no idle timer; writes land only through the store's gates).
107
+ * Off keeps the plugin injection + explicit tools only.
111
108
  */
112
109
  extraction: z<boolean, boolean, "plain">;
113
- /** Idle debounce minutes between turn end and an extraction run (0 = per turn). */
114
- debounceMin: z<number, number, "plain">;
115
110
  }>>, Schemastery.ObjectT<NoInfer<{
116
111
  enabled: z<boolean, boolean, "plain">;
117
112
  guide: z<boolean, boolean, "plain">;
118
113
  order: z<number, number, "plain">;
119
114
  maxChars: z<number, number, "plain">;
120
115
  /**
121
- * The proposal-based extraction run (trigger: turn-level with idle
122
- * debounce; writes land only through the store's gates). Off keeps
123
- * the plugin injection + explicit tools only.
116
+ * The proposal-based extraction run (trigger: turn-level, per-session
117
+ * slots, no idle timer; writes land only through the store's gates).
118
+ * Off keeps the plugin injection + explicit tools only.
124
119
  */
125
120
  extraction: z<boolean, boolean, "plain">;
126
- /** Idle debounce minutes between turn end and an extraction run (0 = per turn). */
127
- debounceMin: z<number, number, "plain">;
128
121
  }>>, "plain">;
129
122
  /**
130
123
  * The read-only memory card in the official plugin manager
package/lib/index.js CHANGED
@@ -11,8 +11,9 @@
11
11
  * as the injected index, frozen per session for the prompt cache;
12
12
  * - the model tools (`memory-tools.ts`): `memory_write` for explicit
13
13
  * "记住/忘掉" instructions, `memory_read` for bodies and search;
14
- * - the extraction scheduler (`extraction.ts`): turn-level, debounced,
15
- * single-flight; proposals land only through the store's gates;
14
+ * - the extraction scheduler (`extraction.ts`): turn-level, per-session
15
+ * slots, single-flight per session; proposals land only through the
16
+ * store's gates;
16
17
  * - the read-only card routes (`card-routes.ts`) in the official plugin
17
18
  * manager.
18
19
  *
@@ -32,6 +33,7 @@ import { createFrozenMemorySection, cwdOf, DEFAULT_MAX_CHARS, DEFAULT_MEMORY_SEC
32
33
  import { registerMemoryTools } from "./memory-tools.js";
33
34
  import { importWorkspaceMd } from "./import-md.js";
34
35
  import { openMemoryStore } from "./store.js";
36
+ import { newestRegistryPath } from "./workspace-hint.js";
35
37
  export const name = "continual-evolve";
36
38
  export const inject = ["systemPrompt"];
37
39
  export const Config = z.object({
@@ -48,13 +50,11 @@ export const Config = z.object({
48
50
  order: z.natural(),
49
51
  maxChars: z.natural(),
50
52
  /**
51
- * The proposal-based extraction run (trigger: turn-level with idle
52
- * debounce; writes land only through the store's gates). Off keeps
53
- * the plugin injection + explicit tools only.
53
+ * The proposal-based extraction run (trigger: turn-level, per-session
54
+ * slots, no idle timer; writes land only through the store's gates).
55
+ * Off keeps the plugin injection + explicit tools only.
54
56
  */
55
57
  extraction: z.boolean(),
56
- /** Idle debounce minutes between turn end and an extraction run (0 = per turn). */
57
- debounceMin: z.natural(),
58
58
  }),
59
59
  /**
60
60
  * The read-only memory card in the official plugin manager
@@ -111,13 +111,17 @@ function mountExtractionWhenAvailable(host, store, config) {
111
111
  log.warn("sessionQuery service present but empty — extraction not armed (injection and tools unaffected)");
112
112
  return;
113
113
  }
114
- createExtractionScheduler(events, { store, surface, llm }, { debounceMin: config.memoryIndex?.debounceMin ?? 10 });
114
+ createExtractionScheduler(events, { store, surface, llm });
115
115
  });
116
116
  }
117
117
  /**
118
118
  * Mount the card routes on the host web server through a nested inject, so
119
119
  * the plugin stays mountable on hosts without that service. Called once the
120
120
  * store is ready — the routes are projections of the store, not of files.
121
+ *
122
+ * The card's opening workspace comes from the host registry, read through
123
+ * `ctx.get` on every request (the FAQ #2 sibling-service rule — the service is
124
+ * optional, so it must not join the inject list, which would gate the mount).
121
125
  */
122
126
  function mountCardWhenAvailable(host, store) {
123
127
  const nested = host;
@@ -126,12 +130,15 @@ function mountCardWhenAvailable(host, store) {
126
130
  return;
127
131
  }
128
132
  nested.inject(["webServer"], (scoped) => {
129
- const webServer = scoped.webServer;
133
+ const context = scoped;
134
+ const webServer = context.webServer;
130
135
  if (webServer === undefined) {
131
136
  host.logger("continual-evolve").warn("webServer service present but empty — memory card routes not mounted (memory section unaffected)");
132
137
  return;
133
138
  }
134
- mountCardRoutes(webServer, store);
139
+ mountCardRoutes(webServer, store, {
140
+ currentWorkspace: () => newestRegistryPath(context.get?.("workspaceRegistry")),
141
+ });
135
142
  });
136
143
  }
137
144
  export function apply(host, config) {
package/lib/store.d.ts CHANGED
@@ -71,7 +71,11 @@ export interface MemoryStore {
71
71
  list(workspaceId: string): MemoryRecord[];
72
72
  /** Every non-archived record regardless of status (the card's projection). */
73
73
  listAll(workspaceId: string): MemoryRecord[];
74
- /** FTS search (trigram, workspace-scoped); <3-char queries fall back to LIKE. */
74
+ /**
75
+ * Keyword search, workspace-scoped: whitespace-separated tokens are
76
+ * AND-combined — trigram FTS when every token is ≥3 chars, literal LIKE
77
+ * substrings otherwise, and a LIKE fallback whenever FTS finds nothing.
78
+ */
75
79
  search(workspaceId: string, query: string, limit?: number): MemoryRecord[];
76
80
  /** The sole write gate: validate all proposals, apply atomically, ledger. */
77
81
  applyProposals(ctx: ProposalContext, proposals: readonly MemoryProposal[]): ProposalOutcome[];
@@ -82,6 +86,15 @@ export interface MemoryStore {
82
86
  state(workspaceId: string, key: string): string | undefined;
83
87
  setState(workspaceId: string, key: string, value: string): void;
84
88
  listWorkspaces(): WorkspaceSummary[];
89
+ /**
90
+ * The partition with the most recent session activity: the newest ledger
91
+ * instant against the newest memory write, whichever is later. The ledger
92
+ * carries a row for every run — including skips and explicit writes — so it
93
+ * tracks "a session worked here", while the memory write is the durable
94
+ * fallback for a partition whose ledger rows were pruned. Undefined only
95
+ * when the store holds neither.
96
+ */
97
+ mostRecentlyActiveWorkspace(): string | undefined;
85
98
  /** Hygiene pass: orphan FTS cleanup + secret quarantine. */
86
99
  patrol(): PatrolResult;
87
100
  /** Ledger rows older than the cutoff, removed (the one unbounded table). */
package/lib/store.js CHANGED
@@ -87,9 +87,22 @@ function recordOf(row) {
87
87
  };
88
88
  }
89
89
  const LIST_ORDER = "ORDER BY CASE type WHEN 'feedback' THEN 0 WHEN 'user' THEN 1 ELSE 2 END, updated_at DESC";
90
- /** Quote a user query as an FTS5 phrase (trigram needs ≥3 chars to match). */
91
- function ftsPhrase(query) {
92
- return `"${query.replaceAll('"', '""')}"`;
90
+ /** Quote one token as an FTS5 phrase (trigram needs ≥3 chars to match). */
91
+ function ftsPhrase(token) {
92
+ return `"${token.replaceAll('"', '""')}"`;
93
+ }
94
+ /**
95
+ * Split a query into keyword tokens on ASCII or full-width whitespace. Each
96
+ * token is matched on its own and AND-combined: trigram FTS can only match
97
+ * ≥3-char runs, so a keyword pair like `中文 交接` is unrepresentable as one
98
+ * FTS phrase and silently returns nothing under a phrase match.
99
+ */
100
+ function queryTokens(query) {
101
+ return query.split(/[\s\u3000]+/).filter((token) => token.length > 0);
102
+ }
103
+ /** `%token%` with LIKE metacharacters escaped — keywords match literally. */
104
+ function likePattern(token) {
105
+ return `%${token.replaceAll(/[\\%_]/g, "\\$&")}%`;
93
106
  }
94
107
  /** Open (creating on first use) the central store. */
95
108
  export async function openMemoryStore(explicitPath) {
@@ -113,13 +126,15 @@ export async function openMemoryStore(explicitPath) {
113
126
  fts: database.prepare(`SELECT m.* FROM memories m WHERE (m.workspace_id, m.id) IN (
114
127
  SELECT workspace_id, id FROM memories_fts WHERE memories_fts MATCH ? AND workspace_id = ?
115
128
  ) AND m.status = 'active' ${LIST_ORDER} LIMIT ?`),
116
- like: database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status = 'active'
117
- AND (title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\') ${LIST_ORDER} LIMIT ?`),
118
129
  stateGet: database.prepare("SELECT value FROM state WHERE workspace_id = ? AND key = ?"),
119
130
  stateSet: database.prepare("INSERT INTO state (workspace_id, key, value) VALUES (?, ?, ?) " +
120
131
  "ON CONFLICT (workspace_id, key) DO UPDATE SET value = excluded.value"),
121
132
  cursorGet: database.prepare("SELECT CAST(value AS INTEGER) AS seq FROM state WHERE workspace_id = ? AND key = ?"),
122
133
  workspaces: database.prepare("SELECT workspace_id, COUNT(*) AS n, MAX(updated_at) AS u FROM memories WHERE status = 'active' GROUP BY workspace_id ORDER BY u DESC"),
134
+ recentLedger: database.prepare("SELECT workspace_id, MAX(ts) AS ts FROM extraction_log " +
135
+ "WHERE workspace_id IS NOT NULL AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
136
+ recentMemory: database.prepare("SELECT workspace_id, MAX(updated_at) AS ts FROM memories " +
137
+ "WHERE status = 'active' AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
123
138
  ledger: database.prepare("INSERT INTO extraction_log (run_id, ts, session_id, workspace_id, trigger, decision, skip_reason, status, duration_ms, model, usage, files) " +
124
139
  "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
125
140
  prune: database.prepare("DELETE FROM extraction_log WHERE ts < ?"),
@@ -128,6 +143,24 @@ export async function openMemoryStore(explicitPath) {
128
143
  activeRows: database.prepare("SELECT * FROM memories WHERE status = 'active'"),
129
144
  quarantine: database.prepare("UPDATE memories SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
130
145
  };
146
+ // LIKE fallback: one OR-triplet per token, every token required. Statements
147
+ // are cached by token count — the query set repeats, the counts don't grow.
148
+ const likeStmts = new Map();
149
+ const likeSearch = (workspaceId, tokens, limit) => {
150
+ let stmt = likeStmts.get(tokens.length);
151
+ if (stmt === undefined) {
152
+ const clause = tokens
153
+ .map(() => "(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')")
154
+ .join(" AND ");
155
+ stmt = database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status = 'active' AND ${clause} ${LIST_ORDER} LIMIT ?`);
156
+ likeStmts.set(tokens.length, stmt);
157
+ }
158
+ const patterns = tokens.flatMap((token) => {
159
+ const pattern = likePattern(token);
160
+ return [pattern, pattern, pattern];
161
+ });
162
+ return stmt.all(workspaceId, ...patterns, limit).map(recordOf);
163
+ };
131
164
  const store = {
132
165
  path,
133
166
  get(workspaceId, id) {
@@ -141,12 +174,17 @@ export async function openMemoryStore(explicitPath) {
141
174
  return stmts.listAll.all(workspaceId).map(recordOf);
142
175
  },
143
176
  search(workspaceId, query, limit = 20) {
144
- const trimmed = query.trim();
145
- if (trimmed.length < 3) {
146
- const like = `%${trimmed.replaceAll(/[\\%_]/g, "\\$&")}%`;
147
- return stmts.like.all(workspaceId, like, like, like, limit).map(recordOf);
177
+ const tokens = queryTokens(query);
178
+ if (tokens.length === 0) {
179
+ return [];
148
180
  }
149
- return stmts.fts.all(ftsPhrase(trimmed), workspaceId, limit).map(recordOf);
181
+ if (tokens.every((token) => token.length >= 3)) {
182
+ const hits = stmts.fts.all(tokens.map(ftsPhrase).join(" AND "), workspaceId, limit);
183
+ if (hits.length > 0) {
184
+ return hits.map(recordOf);
185
+ }
186
+ }
187
+ return likeSearch(workspaceId, tokens, limit);
150
188
  },
151
189
  applyProposals(ctx, proposals) {
152
190
  return applyProposals({ database, now: () => new Date().toISOString() }, ctx, proposals).outcomes;
@@ -175,6 +213,23 @@ export async function openMemoryStore(explicitPath) {
175
213
  updatedAt: row.u,
176
214
  }));
177
215
  },
216
+ mostRecentlyActiveWorkspace() {
217
+ // Both instants are ISO-8601 UTC, so the newer one is the greater
218
+ // string; comparing them keeps the two sources one ordering.
219
+ const rows = [stmts.recentLedger.get(), stmts.recentMemory.get()];
220
+ let best;
221
+ for (const row of rows) {
222
+ const workspaceId = row?.workspace_id;
223
+ const ts = row?.ts;
224
+ if (typeof workspaceId !== "string" || workspaceId.length === 0 || typeof ts !== "string") {
225
+ continue;
226
+ }
227
+ if (best === undefined || ts > best.ts) {
228
+ best = { workspaceId, ts };
229
+ }
230
+ }
231
+ return best?.workspaceId;
232
+ },
178
233
  patrol() {
179
234
  const result = { ts: new Date().toISOString(), orphanFtsRows: 0, quarantined: [] };
180
235
  for (const row of stmts.ftsOrphans.all()) {
@@ -0,0 +1,17 @@
1
+ /** The slice of `ctx.workspaceRegistry` this hint consumes. */
2
+ export interface WorkspaceRegistryLike {
3
+ list(): unknown;
4
+ }
5
+ /**
6
+ * Canonical comparison spelling for one path: `fs.realpath` when the directory
7
+ * survives, the resolved spelling otherwise. The registry stores realpath while
8
+ * a partition id is the session's cwd, so the two can differ by a symlink.
9
+ */
10
+ export declare function canonicalPath(path: string): string;
11
+ /**
12
+ * The path of the registry record with the newest `updatedAt`, or undefined
13
+ * when the service is absent, malformed, or holds no usable record. Never
14
+ * throws: a host service is untrusted input and a hint may not break a route.
15
+ */
16
+ export declare function newestRegistryPath(registry: unknown): string | undefined;
17
+ //# sourceMappingURL=workspace-hint.d.ts.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The card's "current workspace" hint — which partition the card selects when
3
+ * it opens.
4
+ *
5
+ * The card is one page in the host's plugin manager: it has no session of its
6
+ * own, so "current" means "most recently active", and the host's workspace
7
+ * registry (`ctx.workspaceRegistry`) is the freshest record of that — attaching
8
+ * a session is a durable mutation, so the record with the newest `updatedAt` is
9
+ * the workspace the user just worked in. The registry is consumed **only** as
10
+ * this hint: the card's read fence and the listed set both stay projections of
11
+ * the store (ADR `2026-10-06-sqlite-single-store`), and an absent or unusable
12
+ * registry degrades to the store's own activity ordering.
13
+ */
14
+ import { realpathSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+ /**
17
+ * Canonical comparison spelling for one path: `fs.realpath` when the directory
18
+ * survives, the resolved spelling otherwise. The registry stores realpath while
19
+ * a partition id is the session's cwd, so the two can differ by a symlink.
20
+ */
21
+ export function canonicalPath(path) {
22
+ try {
23
+ return realpathSync(path);
24
+ }
25
+ catch {
26
+ return resolve(path);
27
+ }
28
+ }
29
+ /**
30
+ * The path of the registry record with the newest `updatedAt`, or undefined
31
+ * when the service is absent, malformed, or holds no usable record. Never
32
+ * throws: a host service is untrusted input and a hint may not break a route.
33
+ */
34
+ export function newestRegistryPath(registry) {
35
+ if (registry === null || registry === undefined) {
36
+ return undefined;
37
+ }
38
+ const list = registry.list;
39
+ if (typeof list !== "function") {
40
+ return undefined;
41
+ }
42
+ let rows;
43
+ try {
44
+ rows = list.call(registry);
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ if (!Array.isArray(rows)) {
50
+ return undefined;
51
+ }
52
+ let best;
53
+ for (const row of rows) {
54
+ const path = row?.path;
55
+ const updatedAt = row?.updatedAt;
56
+ if (typeof path !== "string" || path.trim().length === 0 || typeof updatedAt !== "string") {
57
+ continue;
58
+ }
59
+ if (best === undefined || updatedAt > best.updatedAt) {
60
+ best = { path: path.trim(), updatedAt };
61
+ }
62
+ }
63
+ return best?.path;
64
+ }
65
+ //# sourceMappingURL=workspace-hint.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "ZCode-style workspace memory for DeepSeek Harness: one central SQLite store partitioned by workspace, index injection, and sole-writer gated writes.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,18 +53,20 @@
53
53
  "access": "public"
54
54
  },
55
55
  "scripts": {
56
- "build": "tsc -p tsconfig.json",
57
- "prepare": "tsc -p tsconfig.json",
56
+ "build": "npm run clean && tsc -p tsconfig.json",
57
+ "prepare": "npm run build",
58
+ "prepack": "npm run build && npm run check:pack",
58
59
  "dev": "tsc -p tsconfig.json --watch",
59
60
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.scripts.json --noEmit",
60
61
  "test": "vitest run",
61
62
  "test:coverage": "vitest run --coverage",
62
63
  "test:watch": "vitest",
63
64
  "lint": "oxlint src test client",
64
- "clean": "rm -rf lib",
65
+ "clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"",
65
66
  "check:docs": "tsx scripts/run-gates.ts docs",
66
67
  "check:handoff": "tsx scripts/verify-handoff-structure.ts --self-test && tsx scripts/verify-handoff-structure.ts",
67
- "check:governance": "tsx scripts/verify-governance.ts"
68
+ "check:governance": "tsx scripts/verify-governance.ts",
69
+ "check:pack": "tsx scripts/verify-pack-contents.ts"
68
70
  },
69
71
  "peerDependencies": {
70
72
  "@deepseek-ai/schemastery": "^3.18.1"
@@ -1,22 +0,0 @@
1
- /** One known workspace: the resolved root and when it was last served. */
2
- export interface KnownWorkspace {
3
- root: string;
4
- /** ISO timestamp of the most recent section assembly seen for this root. */
5
- lastSeen: string;
6
- }
7
- /** The allowlist the card routes read through. */
8
- export interface KnownWorkspaces {
9
- /**
10
- * Record (or refresh) a workspace root. A relative or blank cwd is a
11
- * silent no-op — the caller is the injection path, where a bad cwd
12
- * already renders an empty section and must not turn into a throw.
13
- */
14
- remember(cwd: string): void;
15
- /** Known roots, most recently served first. */
16
- list(): KnownWorkspace[];
17
- }
18
- /** How many recent workspaces the card tracks. */
19
- export declare const DEFAULT_KNOWN_WORKSPACE_LIMIT = 8;
20
- /** Create the in-memory allowlist; `limit` is the LRU bound. */
21
- export declare function createKnownWorkspaces(limit?: number): KnownWorkspaces;
22
- //# sourceMappingURL=known-workspaces.d.ts.map
@@ -1,42 +0,0 @@
1
- /**
2
- * The workspace roots the card may look at, in memory only.
3
- *
4
- * Populated as a side effect of section assembly (the plugin already knows
5
- * each session's cwd there), this list is the card's read allowlist: the
6
- * memory API refuses any root the running process has not served a session
7
- * in, so the endpoint cannot be used to probe the filesystem. Bounded LRU,
8
- * never persisted — a restart starts empty and one session in a workspace
9
- * puts it back. No second store on disk.
10
- */
11
- import { isAbsolute, resolve } from "node:path";
12
- /** How many recent workspaces the card tracks. */
13
- export const DEFAULT_KNOWN_WORKSPACE_LIMIT = 8;
14
- /** Create the in-memory allowlist; `limit` is the LRU bound. */
15
- export function createKnownWorkspaces(limit = DEFAULT_KNOWN_WORKSPACE_LIMIT) {
16
- const max = Math.max(1, limit);
17
- // Insertion order encodes recency: the most recently served root is last.
18
- const recent = new Map();
19
- return {
20
- remember(cwd) {
21
- const trimmed = cwd.trim();
22
- if (trimmed.length === 0 || !isAbsolute(trimmed)) {
23
- return;
24
- }
25
- recent.delete(resolve(trimmed));
26
- recent.set(resolve(trimmed), new Date().toISOString());
27
- while (recent.size > max) {
28
- const oldest = recent.keys().next();
29
- if (oldest.done === true) {
30
- break;
31
- }
32
- recent.delete(oldest.value);
33
- }
34
- },
35
- list() {
36
- return [...recent.entries()]
37
- .reverse()
38
- .map(([root, lastSeen]) => ({ root, lastSeen }));
39
- },
40
- };
41
- }
42
- //# sourceMappingURL=known-workspaces.js.map
@@ -1,30 +0,0 @@
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
@@ -1,149 +0,0 @@
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