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 +11 -11
- package/README.zh.md +11 -11
- package/lib/extraction.d.ts +4 -8
- package/lib/extraction.js +56 -45
- package/lib/index.d.ts +15 -22
- package/lib/index.js +7 -8
- package/lib/store.d.ts +5 -1
- package/lib/store.js +44 -10
- package/package.json +7 -5
- package/lib/known-workspaces.d.ts +0 -22
- package/lib/known-workspaces.js +0 -42
- package/lib/workspace-catalog.d.ts +0 -30
- package/lib/workspace-catalog.js +0 -149
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@ English | [中文](README.zh.md)
|
|
|
7
7
|
[](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](package.json)
|
|
10
|
-
[]()
|
|
11
|
+
[]()
|
|
12
|
+
[]()
|
|
13
13
|
[]()
|
|
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`
|
|
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
|
-
|
|
67
|
+
extraction: true
|
|
69
68
|
```
|
|
70
69
|
|
|
71
70
|
## Development
|
|
72
71
|
|
|
73
72
|
```bash
|
|
74
|
-
pnpm install && pnpm build # deps +
|
|
75
|
-
pnpm test # vitest (
|
|
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 (
|
|
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
|
[](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](package.json)
|
|
10
|
-
[]()
|
|
11
|
+
[]()
|
|
12
|
+
[]()
|
|
13
13
|
[]()
|
|
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`
|
|
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
|
-
|
|
67
|
+
extraction: true
|
|
69
68
|
```
|
|
70
69
|
|
|
71
70
|
## 开发
|
|
72
71
|
|
|
73
72
|
```bash
|
|
74
|
-
pnpm install && pnpm build # 依赖 +
|
|
75
|
-
pnpm test # vitest(
|
|
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 # 运行器 +
|
|
91
|
+
│ ├── extraction.ts # 运行器 + 按会话分槽的调度器(轮级、无空闲计时器)
|
|
92
92
|
│ ├── extraction-surface.ts # 轨迹切片:游标、资格判定、序列化
|
|
93
93
|
│ ├── extraction-prompt.ts # 提取 prompt + 应答解析
|
|
94
94
|
│ ├── import-md.ts # 旧版 markdown 无损迁移
|
package/lib/extraction.d.ts
CHANGED
|
@@ -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
|
|
56
|
-
*
|
|
57
|
-
* one
|
|
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
|
|
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
|
|
169
|
-
*
|
|
170
|
-
* one
|
|
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
|
|
173
|
-
const debounceMs = Math.max(0, (options.debounceMin ?? 10) * 60_000);
|
|
172
|
+
export function createExtractionScheduler(host, deps) {
|
|
174
173
|
const listeners = [];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
if (
|
|
225
|
-
flush("turn-debounce");
|
|
230
|
+
const target = targetOf(agent);
|
|
231
|
+
if (target === null) {
|
|
226
232
|
return;
|
|
227
233
|
}
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
241
|
+
schedule(payload.agent);
|
|
241
242
|
}));
|
|
242
243
|
listeners.push(host.on("session/event", (session, event) => {
|
|
243
|
-
|
|
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
|
-
|
|
252
|
+
drain(id, "compaction");
|
|
247
253
|
}));
|
|
248
254
|
listeners.push(host.on("agent/disposed", (payload) => {
|
|
249
255
|
const id = payload.agent?.id;
|
|
250
|
-
if (
|
|
256
|
+
if (typeof id !== "string") {
|
|
251
257
|
return;
|
|
252
258
|
}
|
|
253
|
-
|
|
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(
|
|
270
|
+
host.logger("continual-evolve").info("extraction scheduler armed (per-session slots, turn-level, single-flight per session)");
|
|
256
271
|
return () => {
|
|
257
|
-
|
|
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,
|
|
15
|
-
* single-flight; proposals land only through the
|
|
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
|
|
63
|
-
*
|
|
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
|
|
76
|
-
*
|
|
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
|
|
109
|
-
*
|
|
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
|
|
122
|
-
*
|
|
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,
|
|
15
|
-
* single-flight; proposals land only through the
|
|
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
|
|
52
|
-
*
|
|
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 }
|
|
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
|
-
/**
|
|
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
|
|
91
|
-
function ftsPhrase(
|
|
92
|
-
return `"${
|
|
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
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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.
|
|
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": "
|
|
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": "
|
|
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
|
package/lib/known-workspaces.js
DELETED
|
@@ -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
|
package/lib/workspace-catalog.js
DELETED
|
@@ -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
|