dsh-continual-evolve 0.15.5 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ English | [中文](README.zh.md)
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-258%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-273%20passing-brightgreen)]()
11
11
  [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
12
  [![Coverage · branches](https://img.shields.io/badge/coverage_branches-90%25-green)]()
13
13
  [![Coverage · functions](https://img.shields.io/badge/coverage_functions-100%25-brightgreen)]()
@@ -21,10 +21,10 @@ Reusable experience gathered in one session (user preferences, pitfalls, project
21
21
  ## How it works
22
22
 
23
23
  1. **Session-start injection** — the plugin registers a single system-prompt section: the workspace's memory index as a store query (hooks only, whole-line truncated over budget, ordered feedback > user > reference), the central-library path, and a three-line guide. The section is computed once per session and reused byte-for-byte, keeping the system prompt stable and the prompt cache warm; an empty store costs zero tokens.
24
- 2. **Four 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. `skill_write` (create/update/delete in the project, plus an explicit move-to-global) sediments reusable procedures through the same discipline (kebab ids, secret screening, 64KB cap, no duplicate ids or bodies across scopes); every applied skill is materialized by the plugin as `<workspace>/.agents/skills/<id>/SKILL.md` — the database is the source of truth, the files are a read-only projection patrol restores on drift. `skill_read` fetches skill bodies by id and searches project plus global skills (the project shadows a same-named global). 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, consecutive quiet turns of one session as a single row carrying how many wake-ups it stands for.
24
+ 2. **Four 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. `skill_write` (create/update/delete in the project, plus an explicit move-to-global) sediments reusable procedures through the same discipline (kebab ids, secret screening, 64KB cap, no duplicate ids or bodies across scopes); every applied skill is materialized by the plugin as `<workspace>/.agents/skills/<id>/SKILL.md` — the database is the source of truth, the files are a read-only projection patrol restores on drift. `skill_read` fetches skill bodies by id and searches project plus global skills (the project shadows a same-named global); each body served by id increments that skill's usage counter (use count + last used), which the card shows as pruning evidence. 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, consecutive quiet turns of one session as a single row carrying how many wake-ups it stands for. Per-run token usage is normalized through `token-usage.ts`, and the card shows a one-line review audit (applied/rejected/skipped/failed counts plus token totals) from the same ledger.
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. Skill mutations land the same way in `skill_log`. A hygiene pass rides on every applied run: orphan FTS rows are dropped and secret-bearing rows are quarantined (hidden, never deleted; a quarantined skill's materialized file is removed so it stops loading). 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 (opening on the host's current workspace), a search box, per-entry relative updated times, click-to-preview bodies (5 MiB cap), patrol warnings, and a skills section listing the workspace's project skills plus visible global skills (global rows badged, project shadowing global) with the same preview. 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, a review-audit line, click-to-preview bodies (5 MiB cap), patrol warnings, and a skills section listing the workspace's project skills plus visible global skills (global rows badged, project shadowing global) with the same preview, each row showing its consumption count. 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 (258 tests)
74
+ pnpm test # vitest (273 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)
package/README.zh.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-258%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-273%20passing-brightgreen)]()
11
11
  [![Coverage · statements](https://img.shields.io/badge/coverage_statements-96%25-brightgreen)]()
12
12
  [![Coverage · branches](https://img.shields.io/badge/coverage_branches-90%25-green)]()
13
13
  [![Coverage · functions](https://img.shields.io/badge/coverage_functions-100%25-brightgreen)]()
@@ -21,10 +21,10 @@ Agent 每个会话积累的可复用经验(用户偏好、踩坑教训、项
21
21
  ## 工作原理
22
22
 
23
23
  1. **开局注入** —— 插件只注册一个 system-prompt section:注入本工作区的记忆索引(查库结果,只含钩子,超预算按整行截断,feedback > user > reference 排序)、中央库路径、三行指南。该 section 每会话只算一次并逐字节复用,system prompt 稳定、prompt cache 持续命中;空 store 零 token。
24
- 2. **四个工具,一个写者** —— `memory_write`(create/update/delete)承接显式的记住/忘掉:每条提案过代码门禁(id 规范、type 枚举、feedback 的 Why/How 契约、v1 移植的 secret 筛查、64KB 正文上限)后在一个事务内落盘。`memory_read` 按 id 取正文、按关键词检索——空格分隔的关键词取 AND,短词与 trigram 未命中时回退字面子串匹配;注入索引只有钩子。`skill_write`(项目内 create/update/delete + 显式搬到全局)沉淀可复用的操作规程,走同一套纪律(短横线 id、secret 筛查、64KB 上限、跨范围禁重名重文);每条落地的技能由插件代码物化为 `<workspace>/.agents/skills/<id>/SKILL.md`——库是唯一真相,文件是只读投影,漂移由巡检按库还原。`skill_read` 按 id 取技能正文、检索项目与全局技能(同名时项目遮蔽全局)。宿主没有工具服务时降级为告诉用户措辞,注入不受影响。
25
- 3. **提案制提取** —— 插件唯一的后台自动化,全程 `extraction_log` 可对账:每个 `agent/turn-stopping` 调度**该会话自己**的边界,状态按会话隔离(运行中只保留最新边界——合并而非排队;`compaction/start` 与会话收尾触发 drain),**没有空闲计时器**。一次运行读取该会话游标之后的轨迹增量,用 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 未命中时回退字面子串匹配;注入索引只有钩子。`skill_write`(项目内 create/update/delete + 显式搬到全局)沉淀可复用的操作规程,走同一套纪律(短横线 id、secret 筛查、64KB 上限、跨范围禁重名重文);每条落地的技能由插件代码物化为 `<workspace>/.agents/skills/<id>/SKILL.md`——库是唯一真相,文件是只读投影,漂移由巡检按库还原。`skill_read` 按 id 取技能正文、检索项目与全局技能(同名时项目遮蔽全局);按 id 取到的每份正文计一次取用(取用次数 + 上次取用,卡片展示,作为剪枝证据)。宿主没有工具服务时降级为告诉用户措辞,注入不受影响。
25
+ 3. **提案制提取** —— 插件唯一的后台自动化,全程 `extraction_log` 可对账:每个 `agent/turn-stopping` 调度**该会话自己**的边界,状态按会话隔离(运行中只保留最新边界——合并而非排队;`compaction/start` 与会话收尾触发 drain),**没有空闲计时器**。一次运行读取该会话游标之后的轨迹增量,用 FTS 取相似既有记忆喂给一次 LLM 调用,模型的结构化提案过同一套门禁落盘;失败与被拒不推游标,下次触发重试。内部 agent、空增量、无真实用户文本的轮次、当轮已有显式 `memory_write` 的轮次机械跳过——每次跳过都记账,同一会话连续的空转轮次合成一行并累计唤醒次数。每次调用的 token 用量经 `token-usage.ts` 归一汇总,卡片给出一行评审审计(各状态计数 + token 总量)。
26
26
  4. **回滚靠账本,不靠机制** —— 每次变更落一条带 before/after 全文快照的 `extraction_log` 行;undo 就是取 before 重写。技能变更同样落 `skill_log`。每次应用后随跑一次巡检:孤儿 FTS 行清除、含密钥条目隔离(隐藏,不物理删除;被隔离技能的物化文件一并撤下,使其不再被加载)。只读卡片显形巡检异常。
27
- 5. **只读卡片** —— 挂在官方插件管理的本 bundle 页面(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡,内容对齐 ZCode 设置的记忆选项卡——下拉作用域选择器列出库里的工作区分区(默认选中宿主当前工作区)、搜索框、每条记忆的相对更新时间、点击行内展开正文预览(5 MiB 上限)、巡检异常警告,以及技能区(本工作区技能 + 可见的全局技能,全局行打标记,同名时项目遮蔽全局),预览同记忆。显示名与描述走宿主 `locale/*.json` 通道双语;宿主主题 token、`Button` 原语、中英文案跟随 DSH 语言。卡片是数据库的请求时投影,没有编辑路径。
27
+ 5. **只读卡片** —— 挂在官方插件管理的本 bundle 页面(`plugins.bundle.config` slot,老宿主自动降级为无卡片):形态对齐市场自家设置卡,内容对齐 ZCode 设置的记忆选项卡——下拉作用域选择器列出库里的工作区分区(默认选中宿主当前工作区)、搜索框、每条记忆的相对更新时间、评审审计行、点击行内展开正文预览(5 MiB 上限)、巡检异常警告,以及技能区(本工作区技能 + 可见的全局技能,全局行打标记,同名时项目遮蔽全局),预览同记忆,每行显示取用次数。显示名与描述走宿主 `locale/*.json` 通道双语;宿主主题 token、`Button` 原语、中英文案跟随 DSH 语言。卡片是数据库的请求时投影,没有编辑路径。
28
28
 
29
29
  记忆类型沿 ZCode 分类法:`user`(画像与环境事实)、`feedback`(被纠正/确认的做法——正文必带 **Why:** 与 **How to apply:**)、`reference`(资源指针)。提取边界只收"跟人与环境走的知识":决策与取舍天然属于仓库,走仓库的 ADR 路线,不进记忆库。
30
30
 
@@ -71,7 +71,7 @@ profile patch 示例:
71
71
 
72
72
  ```bash
73
73
  pnpm install && pnpm build # 依赖 + 干净构建 -> lib/
74
- pnpm test # vitest(258 例)
74
+ pnpm test # vitest(273 例)
75
75
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
76
76
  pnpm lint # oxlint src test client
77
77
  pnpm check:pack # 发布产物一致性(prepack 也会跑)
package/client/client.js CHANGED
@@ -102,6 +102,8 @@ window.__ModuleLoader__.load({
102
102
  skillsEmpty: "该工作区还没有技能——明确告诉模型沉淀为技能即可,重启后生效。",
103
103
  skillScopeGlobal: "全局",
104
104
  skillPreviewDeleted: "该技能已被删除或搬家,请刷新列表。",
105
+ skillUsed: "用过 {count} 次",
106
+ reviewStats: "评审:应用 {applied} · 拒绝 {rejected} · 跳过 {skipped} · 失败 {failed} · token {tokens}",
105
107
  scopeLabel: "选择工作区",
106
108
  searchPlaceholder: "搜索记忆…",
107
109
  searchEmpty: "没有匹配的记忆文件。",
@@ -134,6 +136,8 @@ window.__ModuleLoader__.load({
134
136
  skillsEmpty: "No skills in this workspace yet — ask the model to save one as a skill, then restart.",
135
137
  skillScopeGlobal: "global",
136
138
  skillPreviewDeleted: "This skill was deleted or moved — refresh the list.",
139
+ skillUsed: "used {count} times",
140
+ reviewStats: "Reviews: {applied} applied · {rejected} rejected · {skipped} skipped · {failed} failed · {tokens} tokens",
137
141
  scopeLabel: "Choose workspace",
138
142
  searchPlaceholder: "Search memory files…",
139
143
  searchEmpty: "No matching memory files.",
@@ -192,6 +196,22 @@ window.__ModuleLoader__.load({
192
196
  }).format(date);
193
197
  }
194
198
 
199
+ /**
200
+ * Compact token totals for the review line: 0 / 999 / 12.4k / 3.1M.
201
+ */
202
+ function formatTokens(total) {
203
+ if (typeof total !== "number" || !isFinite(total) || total <= 0) {
204
+ return "0";
205
+ }
206
+ if (total >= 1000000) {
207
+ return (total / 1000000).toFixed(1) + "M";
208
+ }
209
+ if (total >= 1000) {
210
+ return (total / 1000).toFixed(1) + "k";
211
+ }
212
+ return String(Math.round(total));
213
+ }
214
+
195
215
  /**
196
216
  * Assemble the card's view model: the workspace catalogue, then one
197
217
  * snapshot per workspace. Network or API failure per workspace degrades
@@ -457,6 +477,11 @@ window.__ModuleLoader__.load({
457
477
  if (time !== "") {
458
478
  rowChildren.push(createElement("span", { key: "time", className: "dce-file-time" }, time));
459
479
  }
480
+ // Skill rows carry V2 usage counters; memory rows have no such
481
+ // field and skip this span. Zero means never consumed.
482
+ if (typeof file.useCount === "number" && file.useCount > 0) {
483
+ rowChildren.push(createElement("span", { key: "usage", className: "dce-file-time" }, t("skillUsed", { count: file.useCount })));
484
+ }
460
485
  var children = [
461
486
  createElement(
462
487
  "button",
@@ -738,6 +763,29 @@ window.__ModuleLoader__.load({
738
763
  t("stats", { files: snapshot.fileCount }),
739
764
  ),
740
765
  ];
766
+ // Review-loop audit rides the memory snapshot: one compact line
767
+ // when the ledger has any judgments, nothing when it is quiet.
768
+ var review = snapshot.review;
769
+ if (
770
+ review !== null &&
771
+ review !== undefined &&
772
+ (review.applied + review.rejected + review.skipped + review.failed) > 0
773
+ ) {
774
+ var reviewTokens = review.tokens === null || review.tokens === undefined ? 0 : review.tokens.total;
775
+ parts.push(
776
+ createElement(
777
+ "div",
778
+ { key: "review", className: "dce-hint" },
779
+ t("reviewStats", {
780
+ applied: review.applied,
781
+ rejected: review.rejected,
782
+ skipped: review.skipped,
783
+ failed: review.failed,
784
+ tokens: formatTokens(reviewTokens),
785
+ }),
786
+ ),
787
+ );
788
+ }
741
789
  if (snapshot.quarantined.length > 0) {
742
790
  parts.push(
743
791
  createElement("div", { key: "quarantined", className: "dce-warn" }, t("quarantined") + snapshot.quarantined.map(function (entry) { return entry.id; }).join("、")),
@@ -12,6 +12,7 @@
12
12
  * never a path. Handlers are synchronous and never throw past the response.
13
13
  */
14
14
  import { memoryEntryContent, listCardWorkspaces, memorySnapshot, skillEntryContent, skillSnapshot } from "./memory-snapshot.js";
15
+ import { skillUsagePartition } from "./skill-rules.js";
15
16
  import { canonicalPath } from "./workspace-hint.js";
16
17
  /** URL prefix of the card API (kind-exact routes registered under it). */
17
18
  export const CARD_API_WORKSPACES_PATH = "/dsh-continual-evolve/api/v1/workspaces";
@@ -173,6 +174,13 @@ export function mountCardRoutes(webServer, store, options = {}) {
173
174
  }
174
175
  return;
175
176
  }
177
+ // V2 usage loop: a served preview is one real consumption.
178
+ try {
179
+ store.recordSkillUse(skillUsagePartition(content.scope, root), content.id, content.scope);
180
+ }
181
+ catch {
182
+ // Accounting never fails the preview it rides on.
183
+ }
176
184
  sendJson(response, 200, {
177
185
  id: content.id,
178
186
  scope: content.scope,
@@ -1,4 +1,4 @@
1
- import { MEMORY_FILE_PREVIEW_LIMIT, type MemoryStore } from "./store.js";
1
+ import { MEMORY_FILE_PREVIEW_LIMIT, type MemoryStore, type ReviewSummary } from "./store.js";
2
2
  export { MEMORY_FILE_PREVIEW_LIMIT };
3
3
  /** One memory row the card renders. */
4
4
  export interface MemoryFileInfo {
@@ -32,6 +32,8 @@ export interface MemorySnapshot {
32
32
  orphanFtsRows: number;
33
33
  quarantined: number;
34
34
  } | null;
35
+ /** Review-loop audit over the workspace's extraction ledger (zeros when none). */
36
+ review: ReviewSummary;
35
37
  }
36
38
  /**
37
39
  * The read-only snapshot for one workspace partition. A workspace without
@@ -74,6 +76,10 @@ export interface SkillFileInfo {
74
76
  status: string;
75
77
  /** Update time in epoch milliseconds (the card's "updated" line). */
76
78
  updatedAt: number;
79
+ /** Bodies served by id (V2 usage loop); 0 when never consumed. */
80
+ useCount: number;
81
+ /** Last consumption in epoch milliseconds, `null` when never consumed. */
82
+ lastUsedAt: number | null;
77
83
  }
78
84
  /** The full read-only skill snapshot the card renders for one workspace root. */
79
85
  export interface SkillSnapshot {
@@ -8,7 +8,8 @@
8
8
  */
9
9
  import { existsSync } from "node:fs";
10
10
  import { basename } from "node:path";
11
- import { MEMORY_FILE_PREVIEW_LIMIT } from "./store.js";
11
+ import { MEMORY_FILE_PREVIEW_LIMIT, zeroReviewSummary } from "./store.js";
12
+ import { skillUsagePartition } from "./skill-rules.js";
12
13
  export { MEMORY_FILE_PREVIEW_LIMIT };
13
14
  /**
14
15
  * The read-only snapshot for one workspace partition. A workspace without
@@ -26,6 +27,7 @@ export function memorySnapshot(store, root) {
26
27
  files: [],
27
28
  quarantined: [],
28
29
  lastPatrol: null,
30
+ review: zeroReviewSummary(),
29
31
  };
30
32
  let all;
31
33
  try {
@@ -64,8 +66,17 @@ export function memorySnapshot(store, root) {
64
66
  .filter((record) => record.status === "quarantined")
65
67
  .map((record) => ({ id: record.id, reason: quarantineReasonOf(store, record) })),
66
68
  lastPatrol,
69
+ review: reviewOf(store, workspaceRoot),
67
70
  };
68
71
  }
72
+ function reviewOf(store, root) {
73
+ try {
74
+ return store.reviewSummary(root);
75
+ }
76
+ catch {
77
+ return zeroReviewSummary();
78
+ }
79
+ }
69
80
  function infoOf(record) {
70
81
  return {
71
82
  id: record.id,
@@ -168,7 +179,19 @@ function scopedSkills(store, root) {
168
179
  }
169
180
  return merged;
170
181
  }
171
- function skillInfoOf(entry) {
182
+ function skillInfoOf(store, entry) {
183
+ let useCount = 0;
184
+ let lastUsedAt = null;
185
+ try {
186
+ const use = store.getSkillUse(skillUsagePartition(entry.scope, entry.record.workspaceId), entry.record.id, entry.scope);
187
+ if (use !== undefined) {
188
+ useCount = use.useCount;
189
+ lastUsedAt = use.lastUsedAt === null ? null : Date.parse(use.lastUsedAt) || 0;
190
+ }
191
+ }
192
+ catch {
193
+ // Usage is decoration: the snapshot degrades to zeros, never throws.
194
+ }
172
195
  return {
173
196
  id: entry.record.id,
174
197
  title: entry.record.title,
@@ -176,6 +199,8 @@ function skillInfoOf(entry) {
176
199
  scope: entry.scope,
177
200
  status: entry.record.status,
178
201
  updatedAt: Date.parse(entry.record.updatedAt) || 0,
202
+ useCount,
203
+ lastUsedAt,
179
204
  };
180
205
  }
181
206
  /**
@@ -200,7 +225,7 @@ export function skillSnapshot(store, root) {
200
225
  exists: true,
201
226
  readError: null,
202
227
  fileCount: entries.filter((entry) => entry.record.status === "active").length,
203
- files: entries.filter((entry) => entry.record.status === "active").map(skillInfoOf),
228
+ files: entries.filter((entry) => entry.record.status === "active").map((entry) => skillInfoOf(store, entry)),
204
229
  quarantined: entries
205
230
  .filter((entry) => entry.record.status === "quarantined")
206
231
  .map((entry) => ({ id: entry.record.id, scope: entry.scope, reason: quarantineReasonOf(store, entry.record) })),
@@ -64,6 +64,12 @@ export declare function validateRenderedSkill(skill: {
64
64
  export declare function projectSkillsDir(workspaceId: string): string;
65
65
  /** Where global skills materialize: the host-wide discovered skill directory. */
66
66
  export declare function globalSkillsDir(): string;
67
+ /**
68
+ * The store partition a skill scope reads and writes usage in: the global
69
+ * partition for global rows, the workspace itself otherwise. One function
70
+ * so the tool, card, and snapshot paths cannot drift apart.
71
+ */
72
+ export declare function skillUsagePartition(scope: string, cwd: string): string;
67
73
  /**
68
74
  * Resolve and defend the materialized directory for a skill id. Ids are
69
75
  * kebab slugs by gate, but the containment check stays: a caller bug must
@@ -142,6 +142,14 @@ export function projectSkillsDir(workspaceId) {
142
142
  export function globalSkillsDir() {
143
143
  return join(dshHome(), "skills");
144
144
  }
145
+ /**
146
+ * The store partition a skill scope reads and writes usage in: the global
147
+ * partition for global rows, the workspace itself otherwise. One function
148
+ * so the tool, card, and snapshot paths cannot drift apart.
149
+ */
150
+ export function skillUsagePartition(scope, cwd) {
151
+ return scope === "global" ? SKILL_GLOBAL_WORKSPACE : cwd;
152
+ }
145
153
  /**
146
154
  * Resolve and defend the materialized directory for a skill id. Ids are
147
155
  * kebab slugs by gate, but the containment check stays: a caller bug must
@@ -12,6 +12,7 @@
12
12
  * the plugin imports nothing from DSH at runtime.
13
13
  */
14
14
  import { cwdOf } from "./memory-section.js";
15
+ import { skillUsagePartition } from "./skill-rules.js";
15
16
  function textTool(name, description, parameters, execute) {
16
17
  return {
17
18
  name,
@@ -113,7 +114,13 @@ export function registerSkillTools(tools, store) {
113
114
  const limit = clampLimit(args.limit);
114
115
  if (typeof args.id === "string" && args.id.length > 0) {
115
116
  const found = readWithScope(store, cwd, args.id);
116
- return found === undefined ? `没有找到 id 为 "${args.id}" 的技能。` : formatSkill(found.record, found.scope);
117
+ if (found === undefined) {
118
+ return `没有找到 id 为 "${args.id}" 的技能。`;
119
+ }
120
+ // V2 usage loop: a served body is one real consumption.
121
+ // Search/list hits are noise and stay uncounted.
122
+ store.recordSkillUse(skillUsagePartition(found.scope, cwd), found.record.id, found.scope);
123
+ return formatSkill(found.record, found.scope);
117
124
  }
118
125
  const query = typeof args.query === "string" ? args.query : "";
119
126
  const merged = query.length > 0 ? searchWithScope(store, cwd, query, limit) : listWithScope(store, cwd, limit);
package/lib/store.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { MemoryProposal } from "./memory-rules.js";
2
2
  import { type SkillProposal } from "./skill-rules.js";
3
3
  import { type ProposalContext, type ProposalOutcome } from "./store-apply.js";
4
- export type { MemoryProposal, ProposalContext, ProposalOutcome };
4
+ import { type UsageTotals } from "./token-usage.js";
5
+ export type { MemoryProposal, ProposalContext, ProposalOutcome, UsageTotals };
5
6
  /** Thrown when the runtime lacks `node:sqlite` — the degrade signal. */
6
7
  export declare class StoreUnavailableError extends Error {
7
8
  }
@@ -43,6 +44,28 @@ export interface SkillFileWarning {
43
44
  id: string;
44
45
  warning: string;
45
46
  }
47
+ /** Aggregate consumption of one skill (the V2 usage loop's only counter). */
48
+ export interface SkillUse {
49
+ useCount: number;
50
+ /** ISO instant of the last counted use, `null` when never consumed. */
51
+ lastUsedAt: string | null;
52
+ }
53
+ /** One review-loop audit view over the extraction ledger (read-only). */
54
+ export interface ReviewSummary {
55
+ applied: number;
56
+ rejected: number;
57
+ skipped: number;
58
+ failed: number;
59
+ /** Top skip reasons, most frequent first (at most 3, display-truncated). */
60
+ topSkips: {
61
+ reason: string;
62
+ count: number;
63
+ }[];
64
+ /** Token totals over the same rows (unnormalizable payloads contribute nothing). */
65
+ tokens: UsageTotals;
66
+ }
67
+ /** The empty audit view: no rows, no signal — never a failure. */
68
+ export declare function zeroReviewSummary(): ReviewSummary;
46
69
  /** One stored memory record (snake_case columns camelCased). */
47
70
  export interface MemoryRecord {
48
71
  workspaceId: string;
@@ -173,6 +196,21 @@ export interface MemoryStore {
173
196
  };
174
197
  /** Partitions holding active skills (the card's skill fence). */
175
198
  listSkillWorkspaces(): WorkspaceSummary[];
199
+ /**
200
+ * Record one real skill consumption (a body served by id, never a list
201
+ * or search hit). Accounting never fails the read — failures degrade
202
+ * silently, exactly like file sync never fails a write batch.
203
+ */
204
+ recordSkillUse(workspaceId: string, skillId: string, scope: string): void;
205
+ /** Aggregate usage for one skill, undefined when never consumed. */
206
+ getSkillUse(workspaceId: string, skillId: string, scope: string): SkillUse | undefined;
207
+ /**
208
+ * Read-only audit over the extraction ledger for one workspace: judgment
209
+ * counts, top skip reasons, and token totals. Degrades to zeros on any
210
+ * fault — the card observes, never breaks. `since` is an inclusive ISO
211
+ * lower bound; empty counts every row.
212
+ */
213
+ reviewSummary(workspaceId: string, since?: string): ReviewSummary;
176
214
  }
177
215
  /** Default database location: `$DSH_HOME/evolve/memory.db`, else `~/.dsh/…`. */
178
216
  export declare function defaultStorePath(): string;
package/lib/store.js CHANGED
@@ -17,6 +17,7 @@ import { secretLeakReason } from "./memory-rules.js";
17
17
  import { globalSkillsDir, projectSkillsDir, renderSkillMarkdown, SKILL_GENERATED_MARKER, skillDir, skillFieldGateReasons, slugifySkillId, validateRenderedSkill, } from "./skill-rules.js";
18
18
  import { isValidMemoryId } from "./memory-rules.js";
19
19
  import { applyProposals } from "./store-apply.js";
20
+ import { normalizeUsage, sumUsage, zeroUsage } from "./token-usage.js";
20
21
  /** Thrown when the runtime lacks `node:sqlite` — the degrade signal. */
21
22
  export class StoreUnavailableError extends Error {
22
23
  }
@@ -100,7 +101,21 @@ CREATE TABLE IF NOT EXISTS skill_log (
100
101
  run_id TEXT, ts TEXT NOT NULL, session_id TEXT, workspace_id TEXT,
101
102
  trigger TEXT, decision TEXT, status TEXT, files TEXT, warnings TEXT
102
103
  );
104
+ -- Skill usage counters (V2 usage loop): one aggregate row per skill, never a
105
+ -- per-hit ledger, so the table cannot grow without bound and needs no prune.
106
+ CREATE TABLE IF NOT EXISTS skill_usage (
107
+ workspace_id TEXT NOT NULL,
108
+ skill_id TEXT NOT NULL,
109
+ scope TEXT NOT NULL CHECK (scope IN ('project','global')),
110
+ use_count INTEGER NOT NULL DEFAULT 0,
111
+ last_used_at TEXT,
112
+ PRIMARY KEY (workspace_id, skill_id, scope)
113
+ ) STRICT;
103
114
  `;
115
+ /** The empty audit view: no rows, no signal — never a failure. */
116
+ export function zeroReviewSummary() {
117
+ return { applied: 0, rejected: 0, skipped: 0, failed: 0, topSkips: [], tokens: zeroUsage() };
118
+ }
104
119
  /** Default database location: `$DSH_HOME/evolve/memory.db`, else `~/.dsh/…`. */
105
120
  export function defaultStorePath() {
106
121
  const home = process.env.DSH_HOME?.trim();
@@ -296,6 +311,10 @@ export async function openMemoryStore(explicitPath) {
296
311
  skillActiveRows: database.prepare("SELECT * FROM skills WHERE status = 'active'"),
297
312
  skillQuarantine: database.prepare("UPDATE skills SET status = 'quarantined' WHERE workspace_id = ? AND id = ?"),
298
313
  skillWorkspaces: database.prepare("SELECT workspace_id, COUNT(*) AS n, MAX(updated_at) AS u FROM skills WHERE status = 'active' GROUP BY workspace_id ORDER BY u DESC"),
314
+ reviewRows: database.prepare("SELECT status, skip_reason, usage FROM extraction_log WHERE workspace_id = ? AND ts >= ?"),
315
+ skillUseGet: database.prepare("SELECT use_count, last_used_at FROM skill_usage WHERE workspace_id = ? AND skill_id = ? AND scope = ?"),
316
+ skillUseUpsert: database.prepare("INSERT INTO skill_usage (workspace_id, skill_id, scope, use_count, last_used_at) VALUES (?, ?, ?, 1, ?) " +
317
+ "ON CONFLICT (workspace_id, skill_id, scope) DO UPDATE SET use_count = use_count + 1, last_used_at = excluded.last_used_at"),
299
318
  };
300
319
  // LIKE fallback: one OR-triplet per token, every token required. Statements
301
320
  // are cached by token count — the query set repeats, the counts don't grow.
@@ -787,6 +806,72 @@ export async function openMemoryStore(explicitPath) {
787
806
  updatedAt: row.u,
788
807
  }));
789
808
  },
809
+ recordSkillUse(workspaceId, skillId, scope) {
810
+ try {
811
+ stmts.skillUseUpsert.run(workspaceId, skillId, scope, new Date().toISOString());
812
+ }
813
+ catch {
814
+ // Accounting never fails the read it rides on.
815
+ }
816
+ },
817
+ getSkillUse(workspaceId, skillId, scope) {
818
+ try {
819
+ const row = stmts.skillUseGet.get(workspaceId, skillId, scope);
820
+ if (row === undefined) {
821
+ return undefined;
822
+ }
823
+ return { useCount: row.use_count, lastUsedAt: row.last_used_at };
824
+ }
825
+ catch {
826
+ return undefined;
827
+ }
828
+ },
829
+ reviewSummary(workspaceId, since) {
830
+ try {
831
+ const rows = stmts.reviewRows.all(workspaceId, since ?? "");
832
+ const summary = zeroReviewSummary();
833
+ const skips = new Map();
834
+ for (const row of rows) {
835
+ if (row.status === "applied") {
836
+ summary.applied += 1;
837
+ }
838
+ else if (row.status === "rejected") {
839
+ summary.rejected += 1;
840
+ }
841
+ else if (row.status === "failed") {
842
+ summary.failed += 1;
843
+ }
844
+ else {
845
+ summary.skipped += 1;
846
+ }
847
+ if (row.status !== "applied" && row.status !== "rejected" && typeof row.skip_reason === "string" && row.skip_reason.length > 0) {
848
+ skips.set(row.skip_reason, (skips.get(row.skip_reason) ?? 0) + 1);
849
+ }
850
+ if (typeof row.usage === "string" && row.usage.length > 0) {
851
+ try {
852
+ const normalized = normalizeUsage(JSON.parse(row.usage));
853
+ if (normalized !== null) {
854
+ summary.tokens = sumUsage(summary.tokens, normalized);
855
+ }
856
+ }
857
+ catch {
858
+ // One corrupt payload must not void the whole audit.
859
+ }
860
+ }
861
+ }
862
+ summary.topSkips = [...skips.entries()]
863
+ .sort((a, b) => b[1] - a[1])
864
+ .slice(0, 3)
865
+ .map(([reason, count]) => ({
866
+ reason: reason.length > 80 ? `${reason.slice(0, 80)}…` : reason,
867
+ count,
868
+ }));
869
+ return summary;
870
+ }
871
+ catch {
872
+ return zeroReviewSummary();
873
+ }
874
+ },
790
875
  };
791
876
  return store;
792
877
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The single accounting entry for direct LLM calls (the `token-usage.ts`
3
+ * the coding standard names). Observed provider payloads disagree on keys —
4
+ * `inputTokens`/`outputTokens` are usual, `totalTokens`, `cacheReadTokens`,
5
+ * `cacheWriteTokens`, and `reasoningTokens` come and go — so every call site
6
+ * normalizes through {@link normalizeUsage} and aggregates with
7
+ * {@link sumUsage}. Nothing here touches the store: aggregation over ledger
8
+ * rows lives in `store.reviewSummary`, which stays read-only and bounded.
9
+ */
10
+ /** Token counts for one LLM call, every field present, zeros when unknown. */
11
+ export interface UsageTotals {
12
+ input: number;
13
+ output: number;
14
+ cacheRead: number;
15
+ cacheWrite: number;
16
+ reasoning: number;
17
+ total: number;
18
+ }
19
+ /** The zero value: no signal, not a failure. */
20
+ export declare function zeroUsage(): UsageTotals;
21
+ /**
22
+ * Normalize one provider usage payload to full totals. Returns `null` when
23
+ * the payload carries no usable numeric signal at all (not an object, or an
24
+ * object with no recognized numeric field) — callers treat that as "no
25
+ * accounting for this run", never as zeros that would dilute real totals.
26
+ */
27
+ export declare function normalizeUsage(raw: unknown): UsageTotals | null;
28
+ /** Pure field-wise sum; either side may be a zero value. */
29
+ export declare function sumUsage(a: UsageTotals, b: UsageTotals): UsageTotals;
30
+ //# sourceMappingURL=token-usage.d.ts.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The single accounting entry for direct LLM calls (the `token-usage.ts`
3
+ * the coding standard names). Observed provider payloads disagree on keys —
4
+ * `inputTokens`/`outputTokens` are usual, `totalTokens`, `cacheReadTokens`,
5
+ * `cacheWriteTokens`, and `reasoningTokens` come and go — so every call site
6
+ * normalizes through {@link normalizeUsage} and aggregates with
7
+ * {@link sumUsage}. Nothing here touches the store: aggregation over ledger
8
+ * rows lives in `store.reviewSummary`, which stays read-only and bounded.
9
+ */
10
+ /** The zero value: no signal, not a failure. */
11
+ export function zeroUsage() {
12
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
13
+ }
14
+ function num(value) {
15
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
16
+ }
17
+ /**
18
+ * Normalize one provider usage payload to full totals. Returns `null` when
19
+ * the payload carries no usable numeric signal at all (not an object, or an
20
+ * object with no recognized numeric field) — callers treat that as "no
21
+ * accounting for this run", never as zeros that would dilute real totals.
22
+ */
23
+ export function normalizeUsage(raw) {
24
+ if (typeof raw !== "object" || raw === null) {
25
+ return null;
26
+ }
27
+ const payload = raw;
28
+ const input = num(payload.inputTokens);
29
+ const output = num(payload.outputTokens);
30
+ const cacheRead = num(payload.cacheReadTokens);
31
+ const cacheWrite = num(payload.cacheWriteTokens);
32
+ const reasoning = num(payload.reasoningTokens);
33
+ const recognized = [input, output, cacheRead, cacheWrite, reasoning].some((n) => n > 0);
34
+ const totalRaw = num(payload.totalTokens);
35
+ if (!recognized && totalRaw === 0) {
36
+ return null;
37
+ }
38
+ return {
39
+ input,
40
+ output,
41
+ cacheRead,
42
+ cacheWrite,
43
+ reasoning,
44
+ total: totalRaw > 0 ? totalRaw : input + output,
45
+ };
46
+ }
47
+ /** Pure field-wise sum; either side may be a zero value. */
48
+ export function sumUsage(a, b) {
49
+ return {
50
+ input: a.input + b.input,
51
+ output: a.output + b.output,
52
+ cacheRead: a.cacheRead + b.cacheRead,
53
+ cacheWrite: a.cacheWrite + b.cacheWrite,
54
+ reasoning: a.reasoning + b.reasoning,
55
+ total: a.total + b.total,
56
+ };
57
+ }
58
+ //# sourceMappingURL=token-usage.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-continual-evolve",
3
- "version": "0.15.5",
3
+ "version": "0.15.6",
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": {