@yolk_vat-y/dsh-project-memory 0.5.13 → 0.5.14

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/CHANGELOG.md CHANGED
@@ -1,3 +1,129 @@
1
+ ## 0.5.14 (2026-10-02)
2
+
3
+ `npm test` **540 → 561 项 / 28 → 30 个文件**;`npm run eval:injection` 逐项不变
4
+ (命中 14 / 假阳性 0 / 漏召 0,P/R 1.00/1.00,7 次注入 857 字符)。
5
+
6
+ ### 修复:`/` 菜单里「工作流」三行点了没反应
7
+
8
+ 同一个病根:**UI 动作被写成了宿主命令**。那三行的实现是
9
+ `remote.commands.execute(sessionId, '/tasks insight list project')` —— 命令在宿主侧确实执行了,
10
+ 但客户端渲染命令节点的是按**命令名**分发的 `TaskCommandNode`(`key: 'tasks'`):它拿到
11
+ `name === 'tasks'` 就用**任务**解析器去解**记忆**载荷,`parseTaskPayloadText` 必然返回 null,
12
+ 于是对面板零影响,只在对话里留下一个 `/tasks 已执行` 节点。用户看到的就是「点了没反应」。
13
+
14
+ 根因是面板的视图页是 `TaskPanel` 组件**内部**的 `useState('task')` —— 组件外面没有任何可寻址
15
+ 的落点,所以菜单行只能绕宿主命令,而那条路又够不到面板。
16
+
17
+ - `view` 移进 UI store(`task-ui-store.ts`):新增 `PANEL_VIEWS` / `normalizeView()` 与
18
+ `setView()` / `openView()`,持久化且可从组件外寻址;面板的切页按钮改为读它(不再各抄一份)。
19
+ - `/` 菜单三行改为**纯客户端**:`consumeSpan()` 之后直接 `openView(view)`,不再发宿主命令
20
+ (`SlashSourceDeps.run` 随之删除,`client.ts` 里的 `runCommand` 变成死代码也删掉)。
21
+ - 候选不再携带 `line` 字段。
22
+ - 测试:`test/client-slash.test.mjs` 的点击语义改为断言 **localStorage 里的 `view` / `closed`**
23
+ (旧断言是"命令行映射正确",正是它把 bug 放了过去),并加一条"视图入口不得携带命令行"。
24
+
25
+ ### 修复:`show_task_panel` 从来没有打开过面板
26
+
27
+ 这个工具读 `exec.ctx` 再 `emit('dsh:task-panel:show')`,而宿主契约里**没有 `ctx`**:
28
+ `ToolRunContext` 只扩了 `deferContext()` / `concludeTurn()` 两个自有成员
29
+ (`packages/core/tools/src/index.ts:418`)。于是它每次都只返回「无法获取上下文」,
30
+ 那个事件名也是编出来的 —— 面板从未被它打开过。
31
+
32
+ 面板的 `closed` / `minimized` 是**浏览器侧** UI store 的状态,宿主进程碰不到,所以正确的
33
+ 位置是宿主给出的扩展点:`ui-tool` 在 `conversation.chat.node`(key `tool-call`)下声明了按
34
+ **工具名**分发的子槽 `tool.call.toolview`(契约原文:*"Any name is allowed, including tools
35
+ registered by your package. Register with `key: '<tool name>'`"*)。
36
+
37
+ - 新增 `src/client/ShowTaskPanelNode.tsx`:认领 `show_task_panel` 这个 key,工具结果一渲染
38
+ 就调 `taskUIStore.open()`。**只在现场执行时打开** —— 历史节点(刷新 / 切会话后)会直接以
39
+ `result` 阶段挂载,那时只展示不开面板,否则每次打开会话都弹一次(与 `/tasks` 命令节点的
40
+ `live` 判据同款)。
41
+ - 宿主侧删掉那段触碰不到 `ctx` 的代码,改为只让调用真实发生并返回可读结果。
42
+ - 测试:新增 `test/client-toolview.test.mjs`(5 项:注册 key、历史回放不弹、现场执行弹、
43
+ 槽缺失时降级、畸形 ctx 不抛)+ `test/task-view.test.mjs` 补一项(不依赖 `exec`)。
44
+
45
+ ### 性能:影子日志瘦身 2×,单项目日志上限 4.5 MB → 1.5 MB
46
+
47
+ `admission-shadow.jsonl` 的体积 **90.6% 是 `candidates`**(实测 210 行 / 965 KB,候选占
48
+ 875 KB),而真实 store 单步就能产生 40~60 条(中位 42)。三处都是纯冗余:
49
+
50
+ - `support` / `terms` 是**查询级**量,同一个 pre-step 内逐条恒定(实测 139/139 步)→ 提到行级;
51
+ - 候选只可能来自提示通道(trigger 在 `injected` / `dropped` 里)→ 不再逐条记 `channel`;
52
+ - 候选按分数降序、`decision === 'cand'` 必然在最前 → **全部 cand + 按排名补足到 16 条**,
53
+ 截断前的总数记在 `candidatesTotal`,不静默。
54
+
55
+ 实测单行 **6071 B → 2935 B(48%)**。同时把 `shadowMaxBytes` 默认 2 MB 降到 512 KB:
56
+ 轮转只保留一代 `.1`,所以单项目日志硬上限从 `2×2MB + 2×256KB ≈ 4.5MB` 降到 **≈1.5MB**
57
+ (按每行 ~1.5KB 算仍有 ~680 步历史窗口,远超"离线重放最近历史"所需)。
58
+ 日志**本来就是有上限的**(`rotateIfOversized` 覆盖旧的 `.1`,不是无限追加);
59
+ 要彻底不要日志,`autoContext.shadowLog: false` 即可。
60
+
61
+ ### 修复:会话配额把长会话的后段永久致盲
62
+
63
+ `maxItemsPerSession` / `maxItemCharsPerSession` 从 12 / 4000 抬到 **60 / 24000**。它们是**保险丝**,
64
+ 不是节流阀——节流一直由单轮 `maxTokens` 与 `gateCooldownSteps` 负责,但默认值太小、真实用量
65
+ 确实到得了:实测 5 个 root 的 2781 个 pre-step(含轮转历史)里 **11/81 个会话打满**,打满之后
66
+ 再无条目注入(`session-chars` 1115 步 + `session-items` 119 步),且**全部尾部失明步的 72% 来自
67
+ 这 11 个会话**(最坏的一个 223 步里 168 步静默,末次注入停在 step 54)。机制一行未改,显式配小值
68
+ 即可复现旧行为。
69
+
70
+ ### 修复:配额邻近上限时的「细缝」静默不可诊断
71
+
72
+ 配额逼近上限却未触顶时(实测 `4000 − 3962 = 38` 字符),每条候选都过得了全部门槛,却永远塞不下
73
+ 最小正文(提示 120 / 触发 48),于是**永久**静默——而日志里只有一条无量纲的 `budget`。
74
+ 现在丢弃会带出 `remaining` / `need` 两个数字。
75
+
76
+ ### 修复:注入判据的观测缺口(三处)
77
+
78
+ - 会话限流命中时 `hintCands` 被整个置空,静默步**一个候选都不落盘**(实测 1420 步全空)→
79
+ 改为照旧评分、只不注入,影子记录才答得出「不限流这一步会注入什么」。
80
+ - `buildInjection` 的空块早退分支漏掉了 `candidates`,把候选一并丢了。
81
+ - 候选只有门槛结论、没有预算结论 → 每个候选新增 `outcome`
82
+ (`injected` / `budget` / `quota` / `idle` / `gate` / `unscheduled`),与 `decision` 正交;
83
+ 影子行另记 `scoreQuery`(**实际**用于评分的 intent + 写目标拼合,与人类消息 `query` 不是一回事)
84
+ 与 `dropped`。
85
+
86
+ ### 性能:每步评分从 O(C²) 降到 O(C)
87
+
88
+ `idfCoverage` 对**每个候选**都重建一遍整个语料的词集合。真实 store(72 条)实测单步
89
+ `buildInjection` **113 ms → 9 ms**;只看覆盖率那段是 147.5 → 1.3 ms(109×),且逐字段一致
90
+ (已固化为回归用例)。评分在每个 pre-step 都跑,所以这是「每条模型消息」级别的开销。
91
+
92
+ ### 修复:IDF 语料与检索语料不同源
93
+
94
+ `store.js` 的 `_rebuildIdf` 手抄了一份 `title×5 + keywords + summary`,而检索侧的
95
+ `weightedFieldText` 是 `title×5 + keywords + summary + **terms** + sourcePath`。于是只出现在
96
+ `terms` 里的词 `df=0` → 被当成极稀有词拿虚高 IDF(`terms` 正是为修「只有前 300 字符可检索」
97
+ 加的主检索面)。改为直接调 `makeSearchText`,权重自动对齐。
98
+
99
+ ### 观察:注入块抬头标注生产者
100
+
101
+ `[Memory Inject] auto-context` → `[Memory Inject] dsh-project-memory · auto-context`。
102
+ `source.kind` 只有宿主看得到(请求序列化只取 `role` / `content`,provider 对 `.source` 零引用),
103
+ 抬头是模型与 GUI 用户唯一能看到生产者的地方。
104
+
105
+ ### 工具描述修订
106
+
107
+ - `remember` 指向了一个**不存在**的工具(`search_experience`)→ 改为 `query_memory`。
108
+ - `remember` 与 `save_lesson` 职责相邻却都没说清怎么选 → 两处互相点名(`remember` 写独立的
109
+ 经验文件;新建内容优先 `save_lesson`,它带 scope / 合并 / 晋升);`forget` 的参数说明同步。
110
+ - `watch_repo` 不再建议「重载插件」(模型做不到这件事)。
111
+
112
+ ### 修复:`writeJsonAtomic` 的两份副本已漂移
113
+
114
+ 同一个函数在 `store.js` 与 `insight-store.js` 各有一份。`f9a1389` 给前一份补了「失败即删自己的
115
+ `.tmp`」,v0.5.0 后加的那份没有,写成了「失败后重试同一个 rename」—— 瞬时失败下可能重试成功,
116
+ 然后照样抛错。两份的父目录契约也不同(一份要求已存在,一份自建)。
117
+
118
+ 后果落在 global insight 文件上:它写失败会永久留下 `<file>.<pid>.tmp`,因为
119
+ `cleanStaleTmp()` 只扫项目 store 目录,够不着 `~/.config/dsh-project-memory/`。
120
+
121
+ - 抽成 `src/util/fs.js` 的单一实现:先建父目录,失败时删掉自己的 `.tmp` 并原样抛出。
122
+ - 两份副本删除;`insight-store.js` 清掉随之无用的 import。
123
+ - `store.js` 两处 `mkdirSync(shards)` 已冗余,一并删除。分片写的 mkdir 次数不变(1 次,
124
+ 只是挪进函数内)。
125
+ - 测试:新增 `test/atomic-write.test.mjs`(6 项),含「`shards/` 被删后 store 仍写得进去」。
126
+
1
127
  ## 0.5.13 (2026-09-28)
2
128
 
3
129
  适配 DSH 0.2.x。`npm test` **539 → 540 项 / 28 个文件**。
package/README.md CHANGED
@@ -161,15 +161,15 @@ The workflow panel is collapsible, automatically adapts to dsh and theme plugin
161
161
  | `reflection.enabled` | false | v0.5 LLM reflection, **draft-only at task level** (fires on task switch-away / archive). `cooldownMs` `1800000`, `maxLessonsPerReflect` `3`, `maxDecisionsPerReflect` `2` |
162
162
  | `autoContext.enabled` | true | silent injection wrapper (resident task card + gated items). Inert (full passthrough) until the host exposes a resolvable session cwd; `maxTokens` `400`, `editedMax` `3` (how many recently-written "editing now" files the resident task card shows), `signalMinRatio` `0.5` (a hint must reach half of its layer's top score), `skipEchoSelfTodo` `true` (don't echo the task card back when the model itself maintains the task list with no newer human message; relevant insights still inject), `budgetLog` `off` (budget-drop audit on stderr: `off` silent / `once` at most one line per session / `all` one line per changed dropped set), `reinjectItemsAfter` `0` (cooldown, in pre-steps, before the same insight may be injected again), `rootNotice` `true` (when the memory root is inferred from a marker-less working directory, tell the model once where memory lives and how to change it) |
163
163
  | `autoContext.gateCooldownSteps` | 2 | **admission knobs.** Minimum number of pre-steps between two *item* injections (the resident task card is exempt — it is a state snapshot and should update when it changes). This is the main "don't inject often" dial |
164
- | `autoContext.maxItemsPerSession` | 12 | hard per-session cap on injected items; the budget is a ceiling, not a target — once exhausted the item channel stays silent |
165
- | `autoContext.maxItemCharsPerSession` | 4000 | same, in characters |
164
+ | `autoContext.maxItemsPerSession` | 60 | **runaway fuse, not a throttle** — throttling is done by `maxTokens` (per step) and `gateCooldownSteps`. It is a hard per-session ceiling: once reached, the item channel stays silent for the rest of the session. The default sat at 12 until 0.5.14, which real usage did reach (longest observed session: 35 items), permanently blinding the tail of long sessions. Set it lower to reproduce the old behaviour |
165
+ | `autoContext.maxItemCharsPerSession` | 24000 | same, in characters |
166
166
  | `autoContext.hintMinCoverage` | 0.45 | **absolute** floor for the statistical (hint) channel: IDF-weighted share of the query's information mass the entry covers. A ratio-only threshold cannot tell signal from "best of a bad lot" (`relative:1.00` on an unrelated entry). Raised from 0.30 in 0.5.8: on a real 43-entry store the control scenario injected 3 unrelated hints at cov 0.32–0.35, because a same-corpus store flattens IDF |
167
167
  | `autoContext.hintMinMatched` | 2 | a hint must share at least this many terms with the query — one generic word ("plugin") is not evidence |
168
168
  | `autoContext.hintMinSupport` | 0.15 | channel-level silence: if less than this share of the query's terms exist anywhere in the corpus, the hint channel says nothing this round — a long sentence that happens to share one word otherwise reports `cov:1.00` |
169
169
  | `autoContext.legacyScope` | `filter` | how to treat a legacy `trigger.scope`: `filter` keeps the old semantics, `ignore` drops it. `npm run selfcheck:triggers` reports entries whose scope values cannot intersect the project tag space |
170
170
  | `autoContext.auditLog` | true | append one JSONL line per **actual** injection to `<root>/.dsh-project-memory/injection-audit.jsonl` (what was injected, why it matched, what was dropped, session budget snapshot); rotates to `.1` past `auditMaxBytes` (`262144`). Silent on any I/O error — never affects the host request |
171
- | `autoContext.shadowLog` | true | append one JSONL line **per step** (including steps that injected nothing) to `admission-shadow.jsonl`: every scored candidate with its judgement features (`rel` / `coverage` / `matched` / `support` / `terms` / `decision`) plus the step's `query` / `ops` / `writes`. This is what makes a threshold change answerable offline on real history (`decision` shows which gate rejected each candidate). Rotates past `shadowMaxBytes` (`2097152`). Disk only — never enters the prompt, costs no tokens |
172
- | `autoContext.shadowMaxBytes` | 2097152 | rotation cap for `admission-shadow.jsonl` |
171
+ | `autoContext.shadowLog` | true | append one JSONL line **per step** (including steps that injected nothing) to `admission-shadow.jsonl`: the scored candidates near the head of the ranking (`rel` / `coverage` / `matched` / `decision` / `outcome`) plus the step's `query` / `scoreQuery` / `ops` / `writes`. This is what makes a threshold change answerable offline on real history (`decision` shows which gate rejected each candidate, `outcome` what the budget did with it). Rotates past `shadowMaxBytes` (`524288`), keeping one `.1` generation, so the per-project log ceiling is `2 × shadowMaxBytes + 2 × auditMaxBytes` ≈ 1.5 MB. Disk only — never enters the prompt, costs no tokens |
172
+ | `autoContext.shadowMaxBytes` | 524288 | rotation cap for `admission-shadow.jsonl` (one `.1` generation is kept) |
173
173
 
174
174
  ### Injection admission (why it stays quiet)
175
175
 
@@ -298,7 +298,7 @@ These commands are for **maintaining the plugin code** — regular users do not
298
298
 
299
299
  ```bash
300
300
  npm install
301
- npm test # 539 tests (214 core + 16 TaskBridge + 12 insight-store + 9 insight-actions + 8 doc-index + 7 auto-inject + 10 host-contract + 5 reflection + 4 llm-route + 2 client-hints + 10 recall + 14 readiness + 7 insight-derive + 7 readiness-eval + 6 ops + 11 injection-audit + 5 injection-budget + 6 injection-scenarios + 18 bugfix-0.5.7 + 3 client-icons + 10 client-slash + 5 workflow-command + 7 client-session-id + 6 task-view + 79 root-guards + 9 store-gitignore + 22 store-cache + 27 enhancer)
301
+ npm test # 561 tests (216 core + 16 TaskBridge + 12 insight-store + 9 insight-actions + 8 doc-index + 7 auto-inject + 10 host-contract + 5 reflection + 5 llm-route + 2 client-hints + 10 recall + 15 readiness + 7 insight-derive + 7 readiness-eval + 6 ops + 12 injection-audit + 10 injection-budget + 6 injection-scenarios + 18 bugfix-0.5.7 + 3 client-icons + 10 client-slash + 5 client-toolview + 5 workflow-command + 7 client-session-id + 7 task-view + 79 root-guards + 9 store-gitignore + 6 atomic-write + 22 store-cache + 27 enhancer)
302
302
  npm run eval:injection # scenario P/R on the synthetic pool: 14/14 hits, 0 false positives, control group clean
303
303
  npm run eval:injection -- --store .dsh-project-memory/insights.json # replay on YOUR store; control group is a hard gate
304
304
  npm run selfcheck:triggers # which entries can still push, which declarations are dead (reads your local store)
package/README.zh-CN.md CHANGED
@@ -158,15 +158,15 @@ TaskPanel (Container)
158
158
  | `reflection.enabled` | false | v0.5 LLM 反思,**只写任务级草稿**(触发于任务切走/归档)。`cooldownMs` `1800000`、`maxLessonsPerReflect` `3`、`maxDecisionsPerReflect` `2` |
159
159
  | `autoContext.enabled` | true | v0.5 静默注入包装(entry 常驻块 + relevance)。宿主无法解析会话 cwd 时完全透传(零副作用);`maxTokens` `400`、`editedMax` `3`(resident 任务卡显示最近"编辑中"文件数)、`signalMinRatio` `0.5`(提示至少要达到该层最高分的一半)、`skipEchoSelfTodo` `true`(模型自己写/维护任务清单后、无新人类消息时不回声任务卡,省 token;相关 insights 仍注入)、`budgetLog` `off`(预算丢弃审计写到 stderr:`off` 静默 / `once` 每会话最多一行 / `all` 丢弃组合每变化一次一行。注入按优先级排程,预算不够时丢掉低优先级条目属于**正常降级而非故障**,所以默认不占用用户终端)、`reinjectItemsAfter` `0`(同一条 insight 重复注入的冷却步数;`0` = 正文没变就不在本会话内再注入——注入消息留在会话历史里,重发只是重复占位)、`rootNotice` `true`(记忆根是从无标记的工作目录**推定**出来时,向模型通告一次根在哪、怎么改) |
160
160
  | `autoContext.gateCooldownSteps` | 2 | **准入旋钮**:两次*条目*注入之间至少隔几步(常驻任务卡不受限——它是状态快照,内容变了就该更新)。这是"别频繁注入"的主旋钮 |
161
- | `autoContext.maxItemsPerSession` | 12 | 每会话条目注入条数硬上限;预算是上限不是目标,用尽后条目通道持续沉默 |
162
- | `autoContext.maxItemCharsPerSession` | 4000 | 同上,按字符计 |
161
+ | `autoContext.maxItemsPerSession` | 60 | **runaway 保险丝,不是节流阀** —— 节流由 `maxTokens`(单轮)与 `gateCooldownSteps` 负责。它是每会话硬上限:一旦触顶,本会话余下部分条目通道持续沉默。0.5.14 之前默认 12,而真实用量确实到得了(实测最长会话 35 条),于是长会话的后段被永久致盲。配小值可复现旧行为 |
162
+ | `autoContext.maxItemCharsPerSession` | 24000 | 同上,按字符计 |
163
163
  | `autoContext.hintMinCoverage` | 0.45 | 提示通道的**绝对**下限:条目覆盖了查询多少 IDF 加权信息量。只用相对阈值分不出"有信号"和"矮子里拔将军"(实测无关条目也拿 `relative:1.00`)。0.5.8 从 0.30 上调:真实 43 条 store 上对照组以 cov 0.32~0.35 注入了 3 条无关提示——同源语料会把 IDF 分辨力拉平 |
164
164
  | `autoContext.hintMinMatched` | 2 | 提示还必须至少共享这么多个词:单个通用词("插件")不构成证据 |
165
165
  | `autoContext.hintMinSupport` | 0.15 | 通道级沉默:查询里能在语料中找到对应的词占比低于此值时,提示通道本轮整体不出声——否则一句只碰巧共享一个词的长句子会报出 `cov:1.00` |
166
166
  | `autoContext.legacyScope` | `filter` | 旧 `trigger.scope` 的处理:`filter` 保留旧语义,`ignore` 丢弃。`npm run selfcheck:triggers` 会列出 scope 值与项目画像 tag 空间不可能相交的条目 |
167
167
  | `autoContext.auditLog` | true | 每次**真实**注入往 `<root>/.dsh-project-memory/injection-audit.jsonl` 追加一行(注入了什么、为什么命中、丢了什么、会话额度快照);超过 `auditMaxBytes`(`262144`)轮转 `.1`。任何 IO 失败都静默,绝不影响宿主请求 |
168
- | `autoContext.shadowLog` | true | **每步**(含什么都没注入的步)往 `admission-shadow.jsonl` 追加一行:本步全部被评分的候选 + 判据特征(`rel` / `coverage` / `matched` / `support` / `terms` / `decision`)+ 场景(`query` / `ops` / `writes`)。它让"换个阈值会怎样"可以在真实历史上离线回答(`decision` 直接指出每条候选卡在哪一关)。超过 `shadowMaxBytes`(`2097152`)轮转。只写盘,不进 prompt、不花 token |
169
- | `autoContext.shadowMaxBytes` | 2097152 | `admission-shadow.jsonl` 的轮转上限 |
168
+ | `autoContext.shadowLog` | true | **每步**(含什么都没注入的步)往 `admission-shadow.jsonl` 追加一行:排名靠前的被评分候选(`rel` / `coverage` / `matched` / `decision` / `outcome`)+ 场景(`query` / `scoreQuery` / `ops` / `writes`)。它让"换个阈值会怎样"可以在真实历史上离线回答(`decision` 指出每条候选卡在哪一关,`outcome` 指出预算拿它怎么办)。超过 `shadowMaxBytes`(`524288`)轮转,只保留一代 `.1`,所以单项目日志硬上限 ≈ `2 × shadowMaxBytes + 2 × auditMaxBytes` ≈ 1.5 MB。只写盘,不进 prompt、不花 token |
169
+ | `autoContext.shadowMaxBytes` | 524288 | `admission-shadow.jsonl` 的轮转上限(保留一代 `.1`) |
170
170
 
171
171
  ### 注入的准入化(为什么它保持安静)
172
172
 
@@ -295,7 +295,7 @@ node scripts/bench.mjs /你的/项目路径 [--json] [--samples 100] [--no-pdf]
295
295
 
296
296
  ```bash
297
297
  npm install
298
- npm test # 539 项测试(核心 214 + TaskBridge 16 + insight-store 12 + insight-actions 9 + doc-index 8 + auto-inject 7 + host-contract 10 + reflection 5 + llm-route 4 + client-hints 2 + recall 10 + readiness 14 + insight-derive 7 + readiness-eval 7 + ops 6 + injection-audit 11 + injection-budget 5 + injection-scenarios 6 + bugfix-0.5.7 18 + client-icons 3 + client-slash 10 + workflow-command 5 + client-session-id 7 + task-view 6 + root-guards 79 + store-gitignore 9 + store-cache 22 + enhancer 27)
298
+ npm test # 561 项测试(核心 216 + TaskBridge 16 + insight-store 12 + insight-actions 9 + doc-index 8 + auto-inject 7 + host-contract 10 + reflection 5 + llm-route 5 + client-hints 2 + recall 10 + readiness 15 + insight-derive 7 + readiness-eval 7 + ops 6 + injection-audit 12 + injection-budget 10 + injection-scenarios 6 + bugfix-0.5.7 18 + client-icons 3 + client-slash 10 + client-toolview 5 + workflow-command 5 + client-session-id 7 + task-view 7 + root-guards 79 + store-gitignore 9 + atomic-write 6 + store-cache 22 + enhancer 27)
299
299
  npm run eval:injection # 合成池上的场景 P/R:命中 14/14、假阳性 0、对照组零注入
300
300
  npm run eval:injection -- --store .dsh-project-memory/insights.json # 用你自己的 store 重放;对照组是硬闸门
301
301
  npm run selfcheck:triggers # 哪些条目还推得动、哪些声明是死的(读你本地的 store)
package/client/client.js CHANGED
@@ -365,6 +365,20 @@ window.__ModuleLoader__.load({
365
365
  * - 启动/刷新强制 closed: true,面板默认不显示
366
366
  */
367
367
  const STORAGE_KEY = "dsh-pm-task-panel-ui";
368
+ /**
369
+ * 面板的三个视图页。**单一事实来源**:面板的切页按钮与 `/` 菜单的三行都读它。
370
+ * 两边各抄一份必然漂移 —— 实测就是这样:菜单行去执行宿主命令"开记忆页",而面板的 view
371
+ * 是组件内部 state,谁也够不着它,于是点击没有任何可见效果。
372
+ */
373
+ const PANEL_VIEWS = [
374
+ "task",
375
+ "project",
376
+ "global"
377
+ ];
378
+ /** 把任意值收敛成一个合法视图页(脏 localStorage / 外部传入都要过这一关)。 */
379
+ function normalizeView(value) {
380
+ return PANEL_VIEWS.includes(value) ? value : "task";
381
+ }
368
382
  function defaultPosition() {
369
383
  if (typeof window !== "undefined") return {
370
384
  x: Math.max(16, window.innerWidth - 408),
@@ -391,7 +405,8 @@ window.__ModuleLoader__.load({
391
405
  minimized: parsed.minimized !== false,
392
406
  closed: true,
393
407
  theme: typeof parsed.theme === "string" ? parsed.theme : "native",
394
- showHints: parsed.showHints !== false
408
+ showHints: parsed.showHints !== false,
409
+ view: normalizeView(parsed.view)
395
410
  };
396
411
  }
397
412
  } catch {}
@@ -401,7 +416,8 @@ window.__ModuleLoader__.load({
401
416
  minimized: true,
402
417
  closed: true,
403
418
  theme: "native",
404
- showHints: true
419
+ showHints: true,
420
+ view: "task"
405
421
  };
406
422
  }
407
423
  function persistUI(state) {
@@ -455,6 +471,36 @@ window.__ModuleLoader__.load({
455
471
  closed: false
456
472
  });
457
473
  },
474
+ /**
475
+ * 切页但不动开合状态(面板里那个循环按钮用)。
476
+ * 非法值一律忽略 —— 这个入口会被菜单行与脏 localStorage 碰到,静默归一比抛错合适。
477
+ */
478
+ setView(view) {
479
+ if (!PANEL_VIEWS.includes(view)) return;
480
+ if (uiState.view === view) return;
481
+ setUIState({
482
+ ...uiState,
483
+ view
484
+ });
485
+ },
486
+ /**
487
+ * 打开面板并切到指定页 —— `/` 菜单那三行的**全部**动作。
488
+ *
489
+ * 纯客户端:不再绕 `remote.commands.execute('/tasks insight list …')` 一圈。那条路
490
+ * 只在对话里留下一个命令节点,而客户端渲染命令节点的是按**命令名**分发的
491
+ * `TaskCommandNode`(`name === 'tasks'`),它拿任务解析器去解记忆载荷,解析必然失败
492
+ * —— 于是点菜单"开记忆页"什么都不会发生。
493
+ * @param view - 目标视图页;非法值直接忽略(不打开面板,避免"点了没反应还弹窗")。
494
+ */
495
+ openView(view) {
496
+ if (!PANEL_VIEWS.includes(view)) return;
497
+ setUIState({
498
+ ...uiState,
499
+ view,
500
+ minimized: false,
501
+ closed: false
502
+ });
503
+ },
458
504
  minimize() {
459
505
  setUIState({
460
506
  ...uiState,
@@ -1769,11 +1815,7 @@ window.__ModuleLoader__.load({
1769
1815
  "in_progress",
1770
1816
  "completed"
1771
1817
  ];
1772
- const VIEW_CYCLE = [
1773
- "task",
1774
- "project",
1775
- "global"
1776
- ];
1818
+ const VIEW_CYCLE = PANEL_VIEWS;
1777
1819
  function getT(ctx) {
1778
1820
  return createTranslate(ctx?.locale?.getSnapshot?.()?.active === "zh" ? zh : en);
1779
1821
  }
@@ -1816,14 +1858,14 @@ window.__ModuleLoader__.load({
1816
1858
  const [syncedAt, setSyncedAt] = (0, react.useState)(0);
1817
1859
  const [syncError, setSyncError] = (0, react.useState)(null);
1818
1860
  const showHints = ui.showHints;
1819
- const [view, setView] = (0, react.useState)("task");
1861
+ const dataActions = useTaskDataActions();
1862
+ const uiActions = useTaskUIActions();
1863
+ const view = ui.view;
1820
1864
  const cycleView = () => {
1821
1865
  const i = VIEW_CYCLE.indexOf(view);
1822
- setView(VIEW_CYCLE[(i + 1) % VIEW_CYCLE.length]);
1866
+ uiActions.setView(VIEW_CYCLE[(i + 1) % VIEW_CYCLE.length]);
1823
1867
  };
1824
1868
  const viewTitle = view === "task" ? t("panel.title") : view === "global" ? t("view.global") : t("view.project");
1825
- const dataActions = useTaskDataActions();
1826
- const uiActions = useTaskUIActions();
1827
1869
  const tasks = Array.isArray(data.tasks) ? data.tasks : [];
1828
1870
  const activeTasks = tasks.filter((task) => !task.archived);
1829
1871
  const boundTask = data.boundTaskId ? tasks.find((task) => task.id === data.boundTaskId) ?? null : null;
@@ -2302,6 +2344,53 @@ window.__ModuleLoader__.load({
2302
2344
  });
2303
2345
  }
2304
2346
  //#endregion
2347
+ //#region src/client/ShowTaskPanelNode.tsx
2348
+ /**
2349
+ * `show_task_panel` 工具调用的会话内视图 —— 它的作用就是**把面板打开**。
2350
+ *
2351
+ * 为什么这件事必须在这里做:面板的显示状态(closed / minimized)是**浏览器侧**的
2352
+ * (`task-ui-store.ts`),宿主进程碰不到它。宿主侧原本写的是
2353
+ * `exec.ctx.events.emit('dsh:task-panel:show')`,而宿主契约里根本没有 `ctx`:
2354
+ *
2355
+ * packages/core/tools/src/index.ts:418
2356
+ * export interface ToolRunContext extends ToolExecution {
2357
+ * deferContext(context: UserMessage): void
2358
+ * concludeTurn(): void
2359
+ * }
2360
+ *
2361
+ * 于是那个工具每次都只返回「无法获取上下文」,面板从来不会打开 —— 插件里唯一的
2362
+ * 宿主命令之外,这是第二个"写了但没生效"的面(详见 CHANGELOG 0.5.14)。
2363
+ *
2364
+ * 正确的位置是宿主给出的扩展点:`ui-tool` 注册了 `conversation.chat.node`(key `tool-call`),
2365
+ * 其下有个按**工具名**分发的子槽 `tool.call.toolview`(契约原文:*Any name is allowed,
2366
+ * including tools registered by your package. Register with `key: '<tool name>'`*)。
2367
+ * 在这里认领 `show_task_panel` 这个 key,工具结果一渲染就把面板打开。
2368
+ */
2369
+ /**
2370
+ * 只在**现场执行**时打开面板。
2371
+ *
2372
+ * 历史节点在刷新 / 切换会话后挂载时会**直接**处于 `result` 阶段;现场调用则会先经过
2373
+ * `preparing` / `start`。这与 `/tasks` 命令节点用的判据同款(见 `TaskCommandNode.tsx`
2374
+ * 的 `live` ref):刷新后只展示、不再开面板,否则每次打开会话都会弹一次面板。
2375
+ */
2376
+ function ShowTaskPanelNode(props) {
2377
+ const phase = props?.phase;
2378
+ const live = (0, react.useRef)(phase !== "result");
2379
+ (0, react.useEffect)(() => {
2380
+ if (phase !== "result" || !live.current) return;
2381
+ live.current = false;
2382
+ taskUIStore.actions.open();
2383
+ }, [phase]);
2384
+ return null;
2385
+ }
2386
+ /** 把 `show_task_panel` 的视图注册进宿主的 `tool.call.toolview` 槽。 */
2387
+ function registerShowTaskPanelView(slots) {
2388
+ slots.inject("tool.call.toolview", () => slots.register({
2389
+ name: "tool.call.toolview",
2390
+ key: "show_task_panel"
2391
+ }, ShowTaskPanelNode));
2392
+ }
2393
+ //#endregion
2305
2394
  //#region src/client/slash.ts
2306
2395
  /** 源的稳定标识:同一 trigger 内唯一,重复注册会抛错。 */
2307
2396
  const SLASH_SOURCE_NAME = "project-memory";
@@ -2312,7 +2401,7 @@ window.__ModuleLoader__.load({
2312
2401
  descriptionKey: "slash.tasks-desc",
2313
2402
  icon: IconChecklistOutline16,
2314
2403
  match: ["tasks", "task"],
2315
- line: "/tasks"
2404
+ view: "task"
2316
2405
  },
2317
2406
  {
2318
2407
  labelKey: "view.project",
@@ -2323,7 +2412,7 @@ window.__ModuleLoader__.load({
2323
2412
  "project",
2324
2413
  "insight"
2325
2414
  ],
2326
- line: "/tasks insight list project"
2415
+ view: "project"
2327
2416
  },
2328
2417
  {
2329
2418
  labelKey: "view.global",
@@ -2334,7 +2423,7 @@ window.__ModuleLoader__.load({
2334
2423
  "global",
2335
2424
  "insight"
2336
2425
  ],
2337
- line: "/tasks insight list global"
2426
+ view: "global"
2338
2427
  }
2339
2428
  ];
2340
2429
  /** 按当前语言取翻译函数。 */
@@ -2361,7 +2450,6 @@ window.__ModuleLoader__.load({
2361
2450
  description: t(row.descriptionKey),
2362
2451
  icon: row.icon,
2363
2452
  section: t("slash.group"),
2364
- line: row.line,
2365
2453
  terms: [title, ...row.match].map((term) => term.toLowerCase())
2366
2454
  };
2367
2455
  }).filter((row) => query === "" || row.terms.some((term) => term.startsWith(query))).map(({ terms: _terms, ...candidate }) => candidate);
@@ -2385,13 +2473,18 @@ window.__ModuleLoader__.load({
2385
2473
  }
2386
2474
  }
2387
2475
  /**
2388
- * 一次菜单点击:消费掉触发 token 后立刻执行该行对应的命令行。
2476
+ * 一次菜单点击:消费掉触发 token 后**直接打开面板的对应页**。
2389
2477
  *
2390
- * 不返回 claim(回填 `/xxx ` 再等回车):这三行都是「打开某个视图」,claim 会多要一次回车,
2391
- * 而且子动词(`insight list project`)也没法由一个 claim token 表达。消费失败时仍然执行,
2392
- * 只是草稿里残留的触发文本要用户自己清掉 —— 比回填一个会执行错命令的 claim 安全。
2478
+ * 这三行是「打开某个视图」,不是「执行某条命令」,所以走客户端自己的 UI store:
2479
+ * 面板的 `closed` / `view` 都是浏览器侧状态,宿主命令碰不到它们。旧实现执行
2480
+ * `remote.commands.execute('/tasks insight list project')`,只在对话里留下一个命令节点,
2481
+ * 而客户端渲染命令节点的是按**命令名**分发的 `TaskCommandNode`(`name === 'tasks'`),
2482
+ * 它拿任务解析器去解记忆载荷 → 解析失败 → 对面板零影响。**这就是"点了没反应"的原因。**
2483
+ *
2484
+ * 不返回 claim(回填 `/xxx ` 再等回车):claim 会多要一次回车,而这三行没有参数要填。
2485
+ * 消费失败时仍然切页 —— 草稿里残留的触发文本要用户自己清掉,比"点了完全没反应"好。
2393
2486
  * @param t - 翻译函数(按当前语言把候选 name 映射回 ROWS)。
2394
- * @param deps - 会话服务与命令执行通道。
2487
+ * @param deps - 会话服务与 UI store 动作。
2395
2488
  * @param pick - 宿主给的点击载荷。
2396
2489
  * @returns PickOutcome;拿不到可用形状时返回 undefined(菜单照常关闭,草稿不动)。
2397
2490
  */
@@ -2401,9 +2494,11 @@ window.__ModuleLoader__.load({
2401
2494
  const row = ROWS.find((candidate) => t(candidate.labelKey) === title);
2402
2495
  if (row === void 0) return void 0;
2403
2496
  consumeSpan(deps, pick);
2404
- deps.run(pick.session, row.line, []).catch((err) => {
2405
- console.warn(`[dsh-project-memory] ${row.line} failed:`, err);
2406
- });
2497
+ try {
2498
+ deps.openView(row.view);
2499
+ } catch (err) {
2500
+ console.warn(`[dsh-project-memory] open view ${row.view} failed:`, err);
2501
+ }
2407
2502
  return "handled";
2408
2503
  }
2409
2504
  /**
@@ -2452,34 +2547,6 @@ window.__ModuleLoader__.load({
2452
2547
  "locale"
2453
2548
  ];
2454
2549
  /**
2455
- * 执行一条命令行,映射成 composer 的 SubmitOutcome。
2456
- * 与 TaskPanel 走同一个 remote.commands.execute 通道(同样的返回信封)。
2457
- * @param commands - ctx.remote.commands
2458
- * @param session - 会话投影(只读 sessionId)
2459
- * @param line - 完整命令行(含前导斜杠)
2460
- * @param attachments - 提交附件(菜单路径恒为空)
2461
- * @returns {kind:'success'|'error', text?}
2462
- */
2463
- async function runCommand(commands, session, line, attachments = []) {
2464
- const sessionId = session?.sessionId;
2465
- if (typeof sessionId !== "string" || !commands || typeof commands.execute !== "function") return {
2466
- kind: "error",
2467
- text: `no session / commands service for ${line}`
2468
- };
2469
- const envelope = await commands.execute(sessionId, line, [...attachments]);
2470
- if (envelope && typeof envelope === "object" && envelope.ok === false) return {
2471
- kind: "error",
2472
- text: `command.execute failed: ${envelope.error?.code ?? "unknown"}`
2473
- };
2474
- const execution = envelope && typeof envelope === "object" && "value" in envelope ? envelope.value : envelope;
2475
- const result = execution?.result ?? execution;
2476
- if (result?.kind === "error") return {
2477
- kind: "error",
2478
- text: result.text ?? `${line} failed`
2479
- };
2480
- return { kind: "success" };
2481
- }
2482
- /**
2483
2550
  * 注册自建的 `/` 菜单源(见 slash.ts)。
2484
2551
  *
2485
2552
  * 整段都是**可选增强**:宿主没有 `inputTriggers` 服务、或该服务换了契约时,绝不能因此
@@ -2506,7 +2573,7 @@ window.__ModuleLoader__.load({
2506
2573
  }
2507
2574
  const source = createSlashSource(scope, {
2508
2575
  sessions: scope.sessions,
2509
- run: (session, line, attachments) => runCommand(scope?.remote?.commands, session, line, attachments)
2576
+ openView: (view) => taskUIStore.actions.openView(view)
2510
2577
  });
2511
2578
  scope.effect(() => inputTriggers.registerSource(source), "dsh-project-memory: slash source");
2512
2579
  } catch (err) {
@@ -2530,6 +2597,7 @@ window.__ModuleLoader__.load({
2530
2597
  name: "conversation.chat.commandview",
2531
2598
  key
2532
2599
  }, TaskCommandNode));
2600
+ registerShowTaskPanelView(slots);
2533
2601
  } catch (err) {
2534
2602
  console.warn(`[${NS}] task panel registration failed:`, err);
2535
2603
  }