dsh-continual-evolve 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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-173%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,8 +21,8 @@ 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
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.
28
28
 
@@ -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 (173 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,7 +88,7 @@ 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
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-173%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,8 +21,8 @@ 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
27
  5. **只读卡片** —— 挂在官方插件管理的本 bundle 页面(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡,内容对齐 ZCode 设置的记忆选项卡——下拉作用域选择器列出库里的工作区分区、搜索框、每条记忆的相对更新时间、点击行内展开正文预览(5 MiB 上限)、巡检异常警告。显示名与描述走宿主 `locale/*.json` 通道双语;宿主主题 token、`Button` 原语、中英文案跟随 DSH 语言。卡片是数据库的请求时投影,没有编辑路径。
28
28
 
@@ -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(173 例)
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,7 +88,7 @@ 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 无损迁移
@@ -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
  *
@@ -48,13 +49,11 @@ export const Config = z.object({
48
49
  order: z.natural(),
49
50
  maxChars: z.natural(),
50
51
  /**
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.
52
+ * The proposal-based extraction run (trigger: turn-level, per-session
53
+ * slots, no idle timer; writes land only through the store's gates).
54
+ * Off keeps the plugin injection + explicit tools only.
54
55
  */
55
56
  extraction: z.boolean(),
56
- /** Idle debounce minutes between turn end and an extraction run (0 = per turn). */
57
- debounceMin: z.natural(),
58
57
  }),
59
58
  /**
60
59
  * The read-only memory card in the official plugin manager
@@ -111,7 +110,7 @@ function mountExtractionWhenAvailable(host, store, config) {
111
110
  log.warn("sessionQuery service present but empty — extraction not armed (injection and tools unaffected)");
112
111
  return;
113
112
  }
114
- createExtractionScheduler(events, { store, surface, llm }, { debounceMin: config.memoryIndex?.debounceMin ?? 10 });
113
+ createExtractionScheduler(events, { store, surface, llm });
115
114
  });
116
115
  }
117
116
  /**
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[];
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,8 +126,6 @@ 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"),
@@ -128,6 +139,24 @@ export async function openMemoryStore(explicitPath) {
128
139
  activeRows: database.prepare("SELECT * FROM memories WHERE status = 'active'"),
129
140
  quarantine: database.prepare("UPDATE memories SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
130
141
  };
142
+ // LIKE fallback: one OR-triplet per token, every token required. Statements
143
+ // are cached by token count — the query set repeats, the counts don't grow.
144
+ const likeStmts = new Map();
145
+ const likeSearch = (workspaceId, tokens, limit) => {
146
+ let stmt = likeStmts.get(tokens.length);
147
+ if (stmt === undefined) {
148
+ const clause = tokens
149
+ .map(() => "(title LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')")
150
+ .join(" AND ");
151
+ stmt = database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status = 'active' AND ${clause} ${LIST_ORDER} LIMIT ?`);
152
+ likeStmts.set(tokens.length, stmt);
153
+ }
154
+ const patterns = tokens.flatMap((token) => {
155
+ const pattern = likePattern(token);
156
+ return [pattern, pattern, pattern];
157
+ });
158
+ return stmt.all(workspaceId, ...patterns, limit).map(recordOf);
159
+ };
131
160
  const store = {
132
161
  path,
133
162
  get(workspaceId, id) {
@@ -141,12 +170,17 @@ export async function openMemoryStore(explicitPath) {
141
170
  return stmts.listAll.all(workspaceId).map(recordOf);
142
171
  },
143
172
  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);
173
+ const tokens = queryTokens(query);
174
+ if (tokens.length === 0) {
175
+ return [];
176
+ }
177
+ if (tokens.every((token) => token.length >= 3)) {
178
+ const hits = stmts.fts.all(tokens.map(ftsPhrase).join(" AND "), workspaceId, limit);
179
+ if (hits.length > 0) {
180
+ return hits.map(recordOf);
181
+ }
148
182
  }
149
- return stmts.fts.all(ftsPhrase(trimmed), workspaceId, limit).map(recordOf);
183
+ return likeSearch(workspaceId, tokens, limit);
150
184
  },
151
185
  applyProposals(ctx, proposals) {
152
186
  return applyProposals({ database, now: () => new Date().toISOString() }, ctx, proposals).outcomes;
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.1",
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