dsh-continual-evolve 0.15.1 → 0.15.3
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 +7 -6
- package/README.zh.md +7 -6
- package/client/client.js +24 -12
- package/lib/card-routes.d.ts +13 -1
- package/lib/card-routes.js +51 -8
- package/lib/extraction.js +6 -1
- package/lib/index.js +10 -2
- package/lib/store.d.ts +16 -2
- package/lib/store.js +71 -1
- package/lib/workspace-hint.d.ts +17 -0
- package/lib/workspace-hint.js +65 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ 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
11
|
[]()
|
|
12
12
|
[]()
|
|
13
13
|
[]()
|
|
@@ -22,9 +22,9 @@ Reusable experience gathered in one session (user preferences, pitfalls, project
|
|
|
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
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.
|
|
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, consecutive quiet turns of one session as a single row carrying how many wake-ups it stands for.
|
|
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
|
|
|
@@ -71,7 +71,7 @@ Profile patch example:
|
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
73
|
pnpm install && pnpm build # deps + clean build -> lib/
|
|
74
|
-
pnpm test # vitest (
|
|
74
|
+
pnpm test # vitest (194 tests)
|
|
75
75
|
pnpm test:coverage # v8 coverage, CI-enforced thresholds
|
|
76
76
|
pnpm lint # oxlint src test client
|
|
77
77
|
pnpm check:pack # published-artifact consistency (also runs on prepack)
|
|
@@ -93,11 +93,12 @@ Layout:
|
|
|
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
|
-
│
|
|
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 (
|
|
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,7 +7,7 @@
|
|
|
7
7
|
[](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
[](package.json)
|
|
10
|
-
[]()
|
|
11
11
|
[]()
|
|
12
12
|
[]()
|
|
13
13
|
[]()
|
|
@@ -22,9 +22,9 @@ Agent 每个会话积累的可复用经验(用户偏好、踩坑教训、项
|
|
|
22
22
|
|
|
23
23
|
1. **开局注入** —— 插件只注册一个 system-prompt section:注入本工作区的记忆索引(查库结果,只含钩子,超预算按整行截断,feedback > user > reference 排序)、中央库路径、三行指南。该 section 每会话只算一次并逐字节复用,system prompt 稳定、prompt cache 持续命中;空 store 零 token。
|
|
24
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`
|
|
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
|
|
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
|
|
|
@@ -71,7 +71,7 @@ profile patch 示例:
|
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
73
|
pnpm install && pnpm build # 依赖 + 干净构建 -> lib/
|
|
74
|
-
pnpm test # vitest(
|
|
74
|
+
pnpm test # vitest(194 例)
|
|
75
75
|
pnpm test:coverage # v8 覆盖率,CI 强制阈值
|
|
76
76
|
pnpm lint # oxlint src test client
|
|
77
77
|
pnpm check:pack # 发布产物一致性(prepack 也会跑)
|
|
@@ -93,11 +93,12 @@ pnpm check:pack # 发布产物一致性(prepack 也会跑)
|
|
|
93
93
|
│ ├── extraction-prompt.ts # 提取 prompt + 应答解析
|
|
94
94
|
│ ├── import-md.ts # 旧版 markdown 无损迁移
|
|
95
95
|
│ ├── memory-snapshot.ts # 卡片:记忆库只读投影(条目 / 巡检 / 错误降级)
|
|
96
|
-
│
|
|
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 测试(
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
) {
|
|
830
|
-
|
|
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",
|
package/lib/card-routes.d.ts
CHANGED
|
@@ -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
|
package/lib/card-routes.js
CHANGED
|
@@ -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)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* when the database has rows for it,
|
|
11
|
-
* filesystem; the content endpoint takes an id,
|
|
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
|
-
|
|
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
|
package/lib/extraction.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { boundaryCursorOf, evaluateSlice, eventsAfterCursor, isInternalAgent, seqOfCursor, serializeIncrement } from "./extraction-surface.js";
|
|
2
2
|
import { EXTRACTION_SYSTEM_PROMPT, extractionPrompt, manifestLineOf, parseExtractionAnswer } from "./extraction-prompt.js";
|
|
3
3
|
import { cwdOf } from "./memory-section.js";
|
|
4
|
+
/** Head length of a failed run's raw answer kept in the ledger `files` column. */
|
|
5
|
+
const FAILURE_RAW_MAX = 2048;
|
|
4
6
|
/**
|
|
5
7
|
* Run one extraction pass for a target. Never throws: every terminal path
|
|
6
8
|
* is either a landed application, a ledgered skip, or a ledgered failure.
|
|
@@ -76,7 +78,9 @@ export async function runExtraction(deps, target, opts) {
|
|
|
76
78
|
const parsed = parseExtractionAnswer(text);
|
|
77
79
|
if (parsed.decision === "error") {
|
|
78
80
|
// A malformed answer is a failed run: the increment stays unconsumed
|
|
79
|
-
// and the next trigger retries it.
|
|
81
|
+
// and the next trigger retries it. The answer's head rides in the
|
|
82
|
+
// ledger row — the stream is gone after this, so without it the
|
|
83
|
+
// failure is undiagnosable after the fact.
|
|
80
84
|
store.logSkipped({
|
|
81
85
|
...ledgerBase,
|
|
82
86
|
skipReason: parsed.reason,
|
|
@@ -84,6 +88,7 @@ export async function runExtraction(deps, target, opts) {
|
|
|
84
88
|
model: target.model,
|
|
85
89
|
usage,
|
|
86
90
|
durationMs: Date.now() - started,
|
|
91
|
+
files: text.slice(0, FAILURE_RAW_MAX),
|
|
87
92
|
});
|
|
88
93
|
return { status: "failed", skipReason: parsed.reason };
|
|
89
94
|
}
|
package/lib/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import { createFrozenMemorySection, cwdOf, DEFAULT_MAX_CHARS, DEFAULT_MEMORY_SEC
|
|
|
33
33
|
import { registerMemoryTools } from "./memory-tools.js";
|
|
34
34
|
import { importWorkspaceMd } from "./import-md.js";
|
|
35
35
|
import { openMemoryStore } from "./store.js";
|
|
36
|
+
import { newestRegistryPath } from "./workspace-hint.js";
|
|
36
37
|
export const name = "continual-evolve";
|
|
37
38
|
export const inject = ["systemPrompt"];
|
|
38
39
|
export const Config = z.object({
|
|
@@ -117,6 +118,10 @@ function mountExtractionWhenAvailable(host, store, config) {
|
|
|
117
118
|
* Mount the card routes on the host web server through a nested inject, so
|
|
118
119
|
* the plugin stays mountable on hosts without that service. Called once the
|
|
119
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).
|
|
120
125
|
*/
|
|
121
126
|
function mountCardWhenAvailable(host, store) {
|
|
122
127
|
const nested = host;
|
|
@@ -125,12 +130,15 @@ function mountCardWhenAvailable(host, store) {
|
|
|
125
130
|
return;
|
|
126
131
|
}
|
|
127
132
|
nested.inject(["webServer"], (scoped) => {
|
|
128
|
-
const
|
|
133
|
+
const context = scoped;
|
|
134
|
+
const webServer = context.webServer;
|
|
129
135
|
if (webServer === undefined) {
|
|
130
136
|
host.logger("continual-evolve").warn("webServer service present but empty — memory card routes not mounted (memory section unaffected)");
|
|
131
137
|
return;
|
|
132
138
|
}
|
|
133
|
-
mountCardRoutes(webServer, store
|
|
139
|
+
mountCardRoutes(webServer, store, {
|
|
140
|
+
currentWorkspace: () => newestRegistryPath(context.get?.("workspaceRegistry")),
|
|
141
|
+
});
|
|
134
142
|
});
|
|
135
143
|
}
|
|
136
144
|
export function apply(host, config) {
|
package/lib/store.d.ts
CHANGED
|
@@ -30,7 +30,12 @@ export interface MemoryRecord {
|
|
|
30
30
|
createdAt: string;
|
|
31
31
|
updatedAt: string;
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* One ledger row (audit + rollback via embedded before/after snapshots).
|
|
35
|
+
* Consecutive mechanical skips of the same kind share one row, whose
|
|
36
|
+
* `occurrences` column counts the wake-ups it stands for (`1` for everything
|
|
37
|
+
* else); the interface never sets it — the store owns that column.
|
|
38
|
+
*/
|
|
34
39
|
export interface LedgerEntry {
|
|
35
40
|
runId?: string | undefined;
|
|
36
41
|
sessionId?: string | undefined;
|
|
@@ -42,7 +47,7 @@ export interface LedgerEntry {
|
|
|
42
47
|
durationMs?: number | undefined;
|
|
43
48
|
model?: string | undefined;
|
|
44
49
|
usage?: unknown;
|
|
45
|
-
/** Applied/attempted mutations: `{id, action, before?, after?, sourceSeqs}
|
|
50
|
+
/** Applied/attempted mutations: `{id, action, before?, after?, sourceSeqs}`; a parse-failed run instead carries the raw answer's head. */
|
|
46
51
|
files?: unknown;
|
|
47
52
|
}
|
|
48
53
|
/** Workspace rows the card's selector lists. */
|
|
@@ -86,6 +91,15 @@ export interface MemoryStore {
|
|
|
86
91
|
state(workspaceId: string, key: string): string | undefined;
|
|
87
92
|
setState(workspaceId: string, key: string, value: string): void;
|
|
88
93
|
listWorkspaces(): WorkspaceSummary[];
|
|
94
|
+
/**
|
|
95
|
+
* The partition with the most recent session activity: the newest ledger
|
|
96
|
+
* instant against the newest memory write, whichever is later. The ledger
|
|
97
|
+
* carries a row for every run — including skips and explicit writes — so it
|
|
98
|
+
* tracks "a session worked here", while the memory write is the durable
|
|
99
|
+
* fallback for a partition whose ledger rows were pruned. Undefined only
|
|
100
|
+
* when the store holds neither.
|
|
101
|
+
*/
|
|
102
|
+
mostRecentlyActiveWorkspace(): string | undefined;
|
|
89
103
|
/** Hygiene pass: orphan FTS cleanup + secret quarantine. */
|
|
90
104
|
patrol(): PatrolResult;
|
|
91
105
|
/** Ledger rows older than the cutoff, removed (the one unbounded table). */
|
package/lib/store.js
CHANGED
|
@@ -26,6 +26,15 @@ export const MD_IMPORT_STATE_KEY = "md-imported";
|
|
|
26
26
|
export const GLOBAL_WORKSPACE = "";
|
|
27
27
|
/** Preview size cap for the card's content endpoint (ZCode's viewer uses 5 MiB). */
|
|
28
28
|
export const MEMORY_FILE_PREVIEW_LIMIT = 5 * 1024 * 1024;
|
|
29
|
+
/** Ledger column counting the wake-ups one coalesced row stands for. */
|
|
30
|
+
const LEDGER_OCCURRENCES = "occurrences";
|
|
31
|
+
/**
|
|
32
|
+
* Skips a busy-but-quiet session repeats verbatim, turn after turn: the
|
|
33
|
+
* ledger keeps one counted row instead of one row per wake-up. Fault-shaped
|
|
34
|
+
* skips (`no-route`, `no-surface`, `no-workspace`) and every failure stay one
|
|
35
|
+
* row per occurrence — a persistent fault deserves a timeline, not a counter.
|
|
36
|
+
*/
|
|
37
|
+
const COALESCED_SKIP_REASONS = new Set(["no-user-prose", "no-new-events"]);
|
|
29
38
|
const DDL = `
|
|
30
39
|
PRAGMA journal_mode=WAL;
|
|
31
40
|
PRAGMA busy_timeout=5000;CREATE TABLE IF NOT EXISTS memories (
|
|
@@ -58,7 +67,7 @@ CREATE TABLE IF NOT EXISTS extraction_log (
|
|
|
58
67
|
trigger TEXT CHECK (trigger IN ('turn-debounce','compaction','close-drain','explicit','import')),
|
|
59
68
|
decision TEXT, skip_reason TEXT,
|
|
60
69
|
status TEXT, duration_ms INTEGER, model TEXT, usage TEXT,
|
|
61
|
-
files TEXT
|
|
70
|
+
files TEXT, occurrences INTEGER NOT NULL DEFAULT 1
|
|
62
71
|
);
|
|
63
72
|
CREATE TABLE IF NOT EXISTS state (
|
|
64
73
|
workspace_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
@@ -119,6 +128,7 @@ export async function openMemoryStore(explicitPath) {
|
|
|
119
128
|
throw new StoreUnavailableError(`node:sqlite unavailable (Node ≥ 22.5 required) — memory store disabled: ${error instanceof Error ? error.message : String(error)}`);
|
|
120
129
|
}
|
|
121
130
|
database.exec(DDL);
|
|
131
|
+
ensureLedgerOccurrences(database);
|
|
122
132
|
const stmts = {
|
|
123
133
|
get: database.prepare("SELECT * FROM memories WHERE workspace_id = ? AND id = ?"),
|
|
124
134
|
list: database.prepare(`SELECT * FROM memories WHERE workspace_id = ? AND status = 'active' ${LIST_ORDER}`),
|
|
@@ -131,8 +141,15 @@ export async function openMemoryStore(explicitPath) {
|
|
|
131
141
|
"ON CONFLICT (workspace_id, key) DO UPDATE SET value = excluded.value"),
|
|
132
142
|
cursorGet: database.prepare("SELECT CAST(value AS INTEGER) AS seq FROM state WHERE workspace_id = ? AND key = ?"),
|
|
133
143
|
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"),
|
|
144
|
+
recentLedger: database.prepare("SELECT workspace_id, MAX(ts) AS ts FROM extraction_log " +
|
|
145
|
+
"WHERE workspace_id IS NOT NULL AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
|
|
146
|
+
recentMemory: database.prepare("SELECT workspace_id, MAX(updated_at) AS ts FROM memories " +
|
|
147
|
+
"WHERE status = 'active' AND workspace_id <> '' GROUP BY workspace_id ORDER BY ts DESC LIMIT 1"),
|
|
134
148
|
ledger: database.prepare("INSERT INTO extraction_log (run_id, ts, session_id, workspace_id, trigger, decision, skip_reason, status, duration_ms, model, usage, files) " +
|
|
135
149
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
|
150
|
+
latestSkip: database.prepare("SELECT rowid AS rid, skip_reason AS reason, status FROM extraction_log " +
|
|
151
|
+
"WHERE workspace_id IS ? AND session_id IS ? ORDER BY ts DESC, rowid DESC LIMIT 1"),
|
|
152
|
+
bumpSkip: database.prepare(`UPDATE extraction_log SET ts = ?, ${LEDGER_OCCURRENCES} = ${LEDGER_OCCURRENCES} + 1 WHERE rowid = ?`),
|
|
136
153
|
prune: database.prepare("DELETE FROM extraction_log WHERE ts < ?"),
|
|
137
154
|
ftsOrphans: database.prepare("SELECT f.rowid AS rid FROM memories_fts f LEFT JOIN memories m ON m.workspace_id = f.workspace_id AND m.id = f.id WHERE m.id IS NULL"),
|
|
138
155
|
ftsDrop: database.prepare("DELETE FROM memories_fts WHERE rowid = ?"),
|
|
@@ -157,6 +174,27 @@ export async function openMemoryStore(explicitPath) {
|
|
|
157
174
|
});
|
|
158
175
|
return stmt.all(workspaceId, ...patterns, limit).map(recordOf);
|
|
159
176
|
};
|
|
177
|
+
/**
|
|
178
|
+
* Fold a repeat of the previous row's mechanical skip into that row: the
|
|
179
|
+
* quiet turns of one session are one cursor position, so they are one
|
|
180
|
+
* ledger row carrying how many wake-ups it stands for. Only a
|
|
181
|
+
* *consecutive* repeat coalesces — anything in between (a model decision,
|
|
182
|
+
* an applied batch, a different reason, another session) starts a new row,
|
|
183
|
+
* which is exactly what "the same cursor position" means here. The bumped
|
|
184
|
+
* row takes the newest `ts`, so recency queries still see the session as
|
|
185
|
+
* active.
|
|
186
|
+
*/
|
|
187
|
+
const coalesceSkip = (entry) => {
|
|
188
|
+
if (entry.status !== "skipped" || entry.skipReason === undefined || !COALESCED_SKIP_REASONS.has(entry.skipReason)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const latest = stmts.latestSkip.get(entry.workspaceId ?? null, entry.sessionId ?? null);
|
|
192
|
+
if (latest === undefined || latest.status !== "skipped" || latest.reason !== entry.skipReason) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
stmts.bumpSkip.run(new Date().toISOString(), latest.rid);
|
|
196
|
+
return true;
|
|
197
|
+
};
|
|
160
198
|
const store = {
|
|
161
199
|
path,
|
|
162
200
|
get(workspaceId, id) {
|
|
@@ -186,6 +224,9 @@ export async function openMemoryStore(explicitPath) {
|
|
|
186
224
|
return applyProposals({ database, now: () => new Date().toISOString() }, ctx, proposals).outcomes;
|
|
187
225
|
},
|
|
188
226
|
logSkipped(entry) {
|
|
227
|
+
if (coalesceSkip(entry)) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
189
230
|
writeLedger(stmts.ledger, entry);
|
|
190
231
|
},
|
|
191
232
|
cursor(workspaceId, sessionId) {
|
|
@@ -209,6 +250,23 @@ export async function openMemoryStore(explicitPath) {
|
|
|
209
250
|
updatedAt: row.u,
|
|
210
251
|
}));
|
|
211
252
|
},
|
|
253
|
+
mostRecentlyActiveWorkspace() {
|
|
254
|
+
// Both instants are ISO-8601 UTC, so the newer one is the greater
|
|
255
|
+
// string; comparing them keeps the two sources one ordering.
|
|
256
|
+
const rows = [stmts.recentLedger.get(), stmts.recentMemory.get()];
|
|
257
|
+
let best;
|
|
258
|
+
for (const row of rows) {
|
|
259
|
+
const workspaceId = row?.workspace_id;
|
|
260
|
+
const ts = row?.ts;
|
|
261
|
+
if (typeof workspaceId !== "string" || workspaceId.length === 0 || typeof ts !== "string") {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (best === undefined || ts > best.ts) {
|
|
265
|
+
best = { workspaceId, ts };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return best?.workspaceId;
|
|
269
|
+
},
|
|
212
270
|
patrol() {
|
|
213
271
|
const result = { ts: new Date().toISOString(), orphanFtsRows: 0, quarantined: [] };
|
|
214
272
|
for (const row of stmts.ftsOrphans.all()) {
|
|
@@ -234,6 +292,18 @@ export async function openMemoryStore(explicitPath) {
|
|
|
234
292
|
};
|
|
235
293
|
return store;
|
|
236
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Add the ledger's `occurrences` column to a store created before coalescing
|
|
297
|
+
* existed. The DDL only covers fresh files, and the plugin is the sole writer
|
|
298
|
+
* of an existing user database — the one migration the ledger needs.
|
|
299
|
+
*/
|
|
300
|
+
function ensureLedgerOccurrences(database) {
|
|
301
|
+
const columns = database.prepare("SELECT name FROM pragma_table_info('extraction_log')").all();
|
|
302
|
+
if (columns.some((column) => column.name === LEDGER_OCCURRENCES)) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
database.exec(`ALTER TABLE extraction_log ADD COLUMN ${LEDGER_OCCURRENCES} INTEGER NOT NULL DEFAULT 1`);
|
|
306
|
+
}
|
|
237
307
|
/** JSON-encode the optional ledger fields and insert the row (never throws past a warn). */
|
|
238
308
|
function writeLedger(stmt, entry) {
|
|
239
309
|
stmt.run(entry.runId ?? null, new Date().toISOString(), entry.sessionId ?? null, entry.workspaceId ?? null, entry.trigger, entry.decision ?? null, entry.skipReason ?? null, entry.status ?? null, entry.durationMs ?? null, entry.model ?? null, entry.usage === undefined ? null : JSON.stringify(entry.usage), entry.files === undefined ? null : JSON.stringify(entry.files));
|
|
@@ -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.
|
|
3
|
+
"version": "0.15.3",
|
|
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": {
|