dsh-hypatia-auto-memory 2.2.2 → 2.4.0
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 +98 -14
- package/README.zh-CN.md +77 -13
- package/cordis.patch.yml +149 -0
- package/package.json +17 -5
- package/src/collector.js +152 -0
- package/src/consolidator.js +26 -6
- package/src/hypatia-cli.js +35 -3
- package/src/hypatia-client.js +2 -1
- package/src/hypatia-mcp.js +23 -1
- package/src/index.js +41 -1
- package/src/writer.js +68 -9
package/README.md
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
> 中文:[README.zh-CN.md](./README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
Event-driven [Hypatia](https://github.com/
|
|
5
|
+
Event-driven [Hypatia](https://github.com/MarchLiu/hypatia) memory for
|
|
6
6
|
[DSH](https://github.com/deepseek-ai/deepseek-harness): automatic conversation
|
|
7
7
|
logging and background consolidation — span summaries, a log₁₆(n) archive
|
|
8
8
|
cascade, and adjudicated work-unit extraction — all on a **dedicated model
|
|
9
9
|
route**, with zero model work in the main session.
|
|
10
10
|
|
|
11
11
|
Writing is automatic; **reading is the agent's job**, through the bundled
|
|
12
|
-
`hypatia-memory` skill
|
|
12
|
+
`hypatia-memory` skill, with the bundled `smart-memory` preset carrying the
|
|
13
|
+
retrieval discipline. That split is deliberate — see *Recall* below.
|
|
13
14
|
|
|
14
15
|
**This plugin replaces `dsh-hypatia`.** That plugin left the writing to the
|
|
15
16
|
agent, which in practice did not happen: in the deployment this one was written
|
|
@@ -35,14 +36,25 @@ The first call logs which the binary offers
|
|
|
35
36
|
|
|
36
37
|
The plugin also needs **dsh ≥ 0.1.7**, and dsh enforces that itself rather than
|
|
37
38
|
this README: the package declares
|
|
38
|
-
`peerDependencies["@deepseek-ai/dsh"]: ">=0.1.7-rc.1
|
|
39
|
-
sits under `dsh.engines.dsh` for display), and dsh checks
|
|
40
|
-
`@deepseek-ai/dsh*` peer against the running version when a bundle is
|
|
41
|
-
and again at startup — an older runtime refuses the install instead of
|
|
42
|
-
plugin built for API it does not have, and `dsh plugin allow-version`
|
|
43
|
-
exact-version exemption when the risk is accepted deliberately. The
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
`peerDependencies["@deepseek-ai/dsh"]: ">=0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1"`
|
|
40
|
+
(the same range also sits under `dsh.engines.dsh` for display), and dsh checks
|
|
41
|
+
every `@deepseek-ai/dsh*` peer against the running version when a bundle is
|
|
42
|
+
installed and again at startup — an older runtime refuses the install instead of
|
|
43
|
+
loading a plugin built for API it does not have, and `dsh plugin allow-version`
|
|
44
|
+
grants an exact-version exemption when the risk is accepted deliberately. The
|
|
45
|
+
range names each published channel explicitly rather than one open lower bound:
|
|
46
|
+
node-semver admits a prerelease only when the range carries a comparator with the
|
|
47
|
+
same `[major, minor, patch]`, so a bare `>=0.1.7-rc.1` matches no `0.2.x`
|
|
48
|
+
release candidate at all.
|
|
49
|
+
|
|
50
|
+
Every peer the running dsh already supplies — `@deepseek-ai/dsh`,
|
|
51
|
+
`@deepseek-ai/cordis`, and the `dsh-llm` / `dsh-tools` / `dsh-storage-domain`
|
|
52
|
+
services — is marked `optional` in `peerDependenciesMeta`, so that neither npm
|
|
53
|
+
nor a profile installation pulls a copy of its own: the launcher supplies them,
|
|
54
|
+
and two copies of a package whose instances must be shared is how a plugin ends
|
|
55
|
+
up beside the wrong one. dsh ignores the flag — its check reads
|
|
56
|
+
`peerDependencies` alone — so the ranges above are still enforced at install and
|
|
57
|
+
at startup.
|
|
46
58
|
|
|
47
59
|
```bash
|
|
48
60
|
# from a local checkout (development)
|
|
@@ -86,6 +98,66 @@ renaming its directory in place is not enough, because DSH names a skill by its
|
|
|
86
98
|
frontmatter `name`. The startup warning names the directory. The skill center shows the global view, so it can list this plugin
|
|
87
99
|
as a skill's provider while sessions load the disk copy.
|
|
88
100
|
|
|
101
|
+
## Agent preset: smart-memory(长程记忆)
|
|
102
|
+
|
|
103
|
+
The bundle ships one agent preset. `cordis.patch.yml` inserts a
|
|
104
|
+
`preset-smart-memory` row declaring `@deepseek-ai/dsh-agent-preset` with
|
|
105
|
+
`config.id: smart-memory`, so the profile's bundle layer is what puts it in the
|
|
106
|
+
agent-preset picker beside the shipped `standard`, `ptc` and `minimal`: install
|
|
107
|
+
the plugin and a memory-first agent is available, with no hand-written profile
|
|
108
|
+
patch.
|
|
109
|
+
|
|
110
|
+
**It carries the reading half of the split, and nothing else.** Writing here is
|
|
111
|
+
already automatic — logging, consolidation, the session seed. The preset carries
|
|
112
|
+
the discipline the automatic layer cannot: *when* the agent must go and look
|
|
113
|
+
(before planning, at every milestone, when an unfamiliar internal name or a
|
|
114
|
+
familiar-looking bug shows up, before anything destructive — commit, push,
|
|
115
|
+
delete or overwrite, config change, install or upgrade, migration, release —
|
|
116
|
+
whenever the user refers to history, and periodically across a long run), the
|
|
117
|
+
retrieval quality rules (query the concept rather than the user's words;
|
|
118
|
+
`hypatia similar` for ideas and `hypatia search` for exact identifiers; always
|
|
119
|
+
exclude the operational layer), and the one write rule no background pass can
|
|
120
|
+
enforce: a durable rule, taboo or correction the user states is written
|
|
121
|
+
immediately, as `knowledge-update` on an existing entry rather than
|
|
122
|
+
delete-and-recreate.
|
|
123
|
+
|
|
124
|
+
The persona states *when*; the `hypatia-memory` skill carries *how* — the
|
|
125
|
+
commands, their flags, and the shelf and scope rules. The preset deliberately
|
|
126
|
+
does not restate that protocol or mount it: it is a prompt, not a second
|
|
127
|
+
provider, so load the skill when the work touches memory.
|
|
128
|
+
|
|
129
|
+
**Tool set.** The `standard` roster with the plan-mode group and `present`
|
|
130
|
+
removed (46 tools on a web profile). Its sub-plugins — `@deepseek-ai/dsh-persona`,
|
|
131
|
+
the `dsh-tool-*` rows, `dsh-skill-filesystem` — come from the dsh installation
|
|
132
|
+
and are deliberately **not** dependencies of this package; the launcher supplies
|
|
133
|
+
them.
|
|
134
|
+
|
|
135
|
+
**Nothing here mounts the plugin's own machinery.** The preset starts no second
|
|
136
|
+
collector, consolidator, recall listener or auto-approval. Those belong to the
|
|
137
|
+
`hypatia-auto-memory` row above and behave identically whichever preset a session
|
|
138
|
+
runs under, so a session on this preset writes memory exactly like one on
|
|
139
|
+
`standard`.
|
|
140
|
+
|
|
141
|
+
**Selecting it.** Pick 长程记忆 in the new-session preset picker, or make it the
|
|
142
|
+
default for a surface from the profile's own patch file:
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
- id: agent-preset-registry
|
|
146
|
+
name: '@deepseek-ai/dsh-agent-preset-registry'
|
|
147
|
+
config:
|
|
148
|
+
selectedDefault: smart-memory
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Replacing a hand-written copy of it.** If you declared this preset yourself
|
|
152
|
+
before it shipped here, delete that copy. The packaged row deliberately reuses
|
|
153
|
+
the row id `preset-smart-memory`, and rows sharing a row id collapse to the later
|
|
154
|
+
one — the profile's patch file is applied *after* every bundle layer, so a
|
|
155
|
+
leftover copy wins and masks the packaged declaration, and the Plugin Manager
|
|
156
|
+
then reports that row as `unaddressable` because its id has two candidate rows.
|
|
157
|
+
Two *distinct* rows declaring one `config.id` fail the other way, with `Duplicate
|
|
158
|
+
agent preset: smart-memory` from the registry. A comment where the row used to be
|
|
159
|
+
is enough.
|
|
160
|
+
|
|
89
161
|
## How it works
|
|
90
162
|
|
|
91
163
|
```
|
|
@@ -153,6 +225,18 @@ agent/created ──▶ rules/taboos inject()
|
|
|
153
225
|
do have text/image content. In either mode **tool outputs themselves are never
|
|
154
226
|
stored** in the log layer.
|
|
155
227
|
|
|
228
|
+
- **A message the user took back is not remembered.** `dsh-rewind-plugin`
|
|
229
|
+
(`/rewind`) and the two `plugin:rollback` plugins hide messages from the model
|
|
230
|
+
by appending a surface replacement; the log is append-only, so the messages
|
|
231
|
+
themselves stay in the session. This plugin reads that record: a hidden message
|
|
232
|
+
is not written, and one an earlier flush already wrote is deleted from the
|
|
233
|
+
shelf. The sources are a whitelist of producers verified to mean "the user
|
|
234
|
+
discarded this branch" — DSH's own compaction is not on it, because what it
|
|
235
|
+
folds is history the user keeps, and neither is any editor plugin. Two limits:
|
|
236
|
+
a summary written before the rewind still carries the withdrawn text
|
|
237
|
+
(summaries are never rewritten), and a rewind that lands after the session's
|
|
238
|
+
last flush is not seen at all. See `docs/rewind-memory-consistency.md`.
|
|
239
|
+
|
|
156
240
|
- **The log layer is not embedded.** `msg-*` and `session-*` entries and the
|
|
157
241
|
`belongTo` / `summary` edges are written `embed: false` (hypatia #26): stored,
|
|
158
242
|
full-text indexed and reachable by JSE, but given no vector. The protocol has
|
|
@@ -308,7 +392,7 @@ agent/created ──▶ rules/taboos inject()
|
|
|
308
392
|
- **Recall** preloads project/global rules and taboos at session start. Nothing
|
|
309
393
|
else is pushed, except one line naming the shelf when it is not `default`, so
|
|
310
394
|
the agent's own `hypatia` calls look where the plugin writes. Retrieval is the agent's job through the bundled skill, which
|
|
311
|
-
is what [`memory-nolinear.md`](https://github.com/
|
|
395
|
+
is what [`memory-nolinear.md`](https://github.com/MarchLiu/hypatia/blob/main/docs/memory-nolinear.md)
|
|
312
396
|
prescribes for agents that hold context and can call tools; the previous
|
|
313
397
|
per-turn injector also vetoed DSH's own runtime context section by returning
|
|
314
398
|
from `agent/pre-step` without calling `next()`.
|
|
@@ -596,7 +680,7 @@ now only projects plugin Config forms.)
|
|
|
596
680
|
Deliberate, and worth knowing before you rely on them:
|
|
597
681
|
|
|
598
682
|
- **No topic-shift detection.**
|
|
599
|
-
[`memory-nolinear.md`](https://github.com/
|
|
683
|
+
[`memory-nolinear.md`](https://github.com/MarchLiu/hypatia/blob/main/docs/memory-nolinear.md)
|
|
600
684
|
ranks topic switches as the strongest session-splitting signal, above task
|
|
601
685
|
boundaries and time gaps. Detecting one needs a model call every turn, which contradicts this
|
|
602
686
|
plugin's central trade-off (zero model work in the main session), so it is not
|
|
@@ -639,7 +723,7 @@ Deliberate, and worth knowing before you rely on them:
|
|
|
639
723
|
one the first attempt intended.
|
|
640
724
|
- **Two of the three bundled skills are vendored copies.** `hypatia` and
|
|
641
725
|
`hypatia-dream` are copied from the [hypatia
|
|
642
|
-
repository](https://github.com/
|
|
726
|
+
repository](https://github.com/MarchLiu/hypatia)'s `skills/`, because a
|
|
643
727
|
published package cannot reach outside itself. Nothing refreshes them: when
|
|
644
728
|
the originals change upstream, copy them in by hand. `hypatia` is
|
|
645
729
|
byte-identical. `hypatia-dream` carries two local patches, which a refresh
|
|
@@ -666,7 +750,7 @@ Deliberate, and worth knowing before you rely on them:
|
|
|
666
750
|
```
|
|
667
751
|
dsh-hypatia-auto-memory/
|
|
668
752
|
├── package.json # bundle + client manifest, deps
|
|
669
|
-
├── cordis.patch.yml # bundle layer
|
|
753
|
+
├── cordis.patch.yml # bundle layer: the plugin row + the smart-memory preset
|
|
670
754
|
├── tsconfig.json # browser TS/TSX type-check config
|
|
671
755
|
├── tsconfig.build.json # declaration emit for client bundle
|
|
672
756
|
├── tsdown.config.ts # browser CJS factory build
|
package/README.zh-CN.md
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
> 本文件是 [README.md](./README.md) 的中文版。若两者不一致,以英文版为准。
|
|
4
4
|
|
|
5
5
|
面向 [DSH](https://github.com/deepseek-ai/deepseek-harness) 的事件驱动
|
|
6
|
-
[Hypatia](https://github.com/
|
|
6
|
+
[Hypatia](https://github.com/MarchLiu/hypatia) 记忆:自动记录对话、后台巩固
|
|
7
7
|
(span 摘要、log₁₆(n) 归档级联、带裁决的工作单元抽取)——全部走**专用模型路由**,
|
|
8
8
|
主会话零模型工作。
|
|
9
9
|
|
|
10
|
-
写入是全自动的;**读取由 Agent 负责**,通过随包附带的 `hypatia-memory`
|
|
11
|
-
|
|
10
|
+
写入是全自动的;**读取由 Agent 负责**,通过随包附带的 `hypatia-memory` 技能完成,
|
|
11
|
+
检索纪律则由同样随包附带的 `smart-memory` 预设承载。这种分工是有意为之——见下文
|
|
12
|
+
*Recall*。
|
|
12
13
|
|
|
13
14
|
**本插件取代 `dsh-hypatia`。** 后者把写入留给 Agent,实际上并没有发生:在编写本
|
|
14
15
|
插件的那个部署环境里,模型加载了协议,却在十五个会话中只有一个会话主动执行了
|
|
@@ -29,12 +30,20 @@
|
|
|
29
30
|
filters yes`)。
|
|
30
31
|
|
|
31
32
|
本插件还需要 **dsh ≥ 0.1.7**,而且这个下限由 dsh 自己强制执行,不靠本 README:
|
|
32
|
-
包里声明了
|
|
33
|
-
`dsh.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
包里声明了
|
|
34
|
+
`peerDependencies["@deepseek-ai/dsh"]: ">=0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1"`
|
|
35
|
+
(同样的范围也写在 `dsh.engines.dsh` 里,供展示),dsh 在安装 bundle 时、以及每次启动时,
|
|
36
|
+
会把每个 `@deepseek-ai/dsh*` peer 与自身版本比对——运行时太旧会直接拒绝安装,而不是加载
|
|
37
|
+
一个面向它并不具备的 API 写成的插件;确实要接受风险时,用 `dsh plugin allow-version`
|
|
38
|
+
授予精确版本豁免。范围把每条已发布通道逐一写出来,而不是只写一个开放下界:node-semver
|
|
39
|
+
只在区间里存在同 `[major, minor, patch]` 的比较子时才接受预发布版,因此单写
|
|
40
|
+
`>=0.1.7-rc.1` 其实一个 `0.2.x` 候选版都匹配不到。
|
|
41
|
+
|
|
42
|
+
凡是运行中的 dsh 已经提供的 peer——`@deepseek-ai/dsh`、`@deepseek-ai/cordis`,以及
|
|
43
|
+
`dsh-llm` / `dsh-tools` / `dsh-storage-domain` 三个服务——都在 `peerDependenciesMeta`
|
|
44
|
+
里标为 `optional`,这样 npm 和 profile 安装都不会各自再拉一份:它们由 launcher 提供,
|
|
45
|
+
而"需要共享实例的包存在两份"正是插件用错那一份的来源。dsh 会忽略这个标记——它的检查只读
|
|
46
|
+
`peerDependencies`——所以上面的范围在安装时和启动时依旧生效。
|
|
38
47
|
|
|
39
48
|
```bash
|
|
40
49
|
# 从本地 checkout 安装(开发)
|
|
@@ -73,6 +82,52 @@ user)只在一层内打破平局。Agent 预设(`st`、`standard`、…)
|
|
|
73
82
|
目录名。技能中心展示的是全局视图,所以它可能把本插件列为某技能的提供者,而会话
|
|
74
83
|
实际加载的是磁盘拷贝。
|
|
75
84
|
|
|
85
|
+
## Agent 预设:smart-memory(长程记忆)
|
|
86
|
+
|
|
87
|
+
本插件包内置一个 Agent 预设。`cordis.patch.yml` 插入一行 `preset-smart-memory`
|
|
88
|
+
声明 `@deepseek-ai/dsh-agent-preset`、`config.id: smart-memory`,因此是 profile 的
|
|
89
|
+
bundle 层把它送进 Agent 预设选择器,与自带的 `standard`、`ptc`、`minimal` 并列:
|
|
90
|
+
装上插件就有一个「记忆优先」的 Agent 可用,不必再手写 profile 补丁。
|
|
91
|
+
|
|
92
|
+
**它承载的是分工里「读」的那一半,仅此而已。** 这里的写入本来就是自动的——日志
|
|
93
|
+
记录、巩固、会话开场的规则种子。预设承载的是自动层做不到的纪律:**什么时候必须去
|
|
94
|
+
查**(制定计划前、每个里程碑、出现陌生内部名称或眼熟的 bug 时、任何破坏性操作之前
|
|
95
|
+
——commit、push、删除或覆盖、改配置、安装或升级、迁移、发布——用户提到历史时,以及
|
|
96
|
+
长程任务中的周期性回顾),检索质量要求(查询写成概念而非用户原话;想法用
|
|
97
|
+
`hypatia similar`,精确标识符用 `hypatia search`;始终排除运维层),以及唯一一条
|
|
98
|
+
后台流程无法强制执行的写入规则:用户给出的永久性规则、禁忌或纠正立即落库,且对已存在
|
|
99
|
+
条目用 `knowledge-update`,而不是删除后重建。
|
|
100
|
+
|
|
101
|
+
人设规定的是*什么时候*,`hypatia-memory` 技能承载的是*怎么做*——命令、参数,以及
|
|
102
|
+
shelf 与 scope 的规则。预设刻意不重述这套协议,也不挂载它:它只是提示词,不是第二个
|
|
103
|
+
提供者,所以在工作涉及记忆时加载该技能。
|
|
104
|
+
|
|
105
|
+
**工具集。** 即 `standard` 的花名册去掉 plan mode 组与 `present`(web profile 下
|
|
106
|
+
46 个工具)。它的子插件——`@deepseek-ai/dsh-persona`、各 `dsh-tool-*` 行、
|
|
107
|
+
`dsh-skill-filesystem`——来自 dsh 安装本身,**刻意不**作为本包的依赖;由 launcher
|
|
108
|
+
提供。
|
|
109
|
+
|
|
110
|
+
**这里不挂载插件自身的任何机制。** 预设不会启动第二个 collector、consolidator、
|
|
111
|
+
recall 监听或自动批准。那些属于上面那行 `hypatia-auto-memory`,且无论会话跑在哪个
|
|
112
|
+
预设下行为一致;因此用本预设的会话写记忆的方式与 `standard` 完全相同。
|
|
113
|
+
|
|
114
|
+
**怎么选。** 在新会话的预设选择器里选「长程记忆」,或在 profile 自己的补丁文件里把它
|
|
115
|
+
设为该界面的默认值:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
- id: agent-preset-registry
|
|
119
|
+
name: '@deepseek-ai/dsh-agent-preset-registry'
|
|
120
|
+
config:
|
|
121
|
+
selectedDefault: smart-memory
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**替换手写的那一份。** 如果你在本预设随包分发之前自己声明过它,请删掉那份拷贝。包内的
|
|
125
|
+
行刻意沿用 row id `preset-smart-memory`,而同名 row id 的两行会折叠为靠后的那一行
|
|
126
|
+
——profile 的补丁文件在所有 bundle 层**之后**应用,所以残留的拷贝会取胜并遮蔽包内
|
|
127
|
+
声明;插件管理器随后会把该行报成 `unaddressable`,因为同一个 id 出现了两个候选行。
|
|
128
|
+
另一种失败是两行**不同** row id 声明同一个 `config.id`:registry 会用
|
|
129
|
+
`Duplicate agent preset: smart-memory` 拒绝第二次注册。在原处留一段注释即可。
|
|
130
|
+
|
|
76
131
|
## 工作原理
|
|
77
132
|
|
|
78
133
|
```
|
|
@@ -125,6 +180,15 @@ agent/created ──▶ rules/taboos inject()
|
|
|
125
180
|
文本/图片内容的助手消息记录一份精简台账(`1. \`read\` ×5 — ✅ 1.2s total`)。无论
|
|
126
181
|
哪种模式,**工具输出本身永远不会存入**日志层。
|
|
127
182
|
|
|
183
|
+
- **被用户撤回的消息不会记入记忆。** `dsh-rewind-plugin`(`/rewind`)与两个
|
|
184
|
+
`plugin:rollback` 插件都靠追加一条 surface 替换,把消息从模型视野里藏起来;而日志
|
|
185
|
+
是 append-only 的,消息本身仍留在会话里。本插件读这条记录:被藏起来的消息不写,
|
|
186
|
+
早先的 flush 已经写出的则从 shelf 上删掉。来源是一份白名单,只收语义确实是"用户丢
|
|
187
|
+
弃了这个分支"的生产者——DSH 自己的压缩不在其中(它折叠的是用户保留的历史),编辑
|
|
188
|
+
类插件也不在其中。两个边界:回退之前已经写出的摘要仍带着被撤回的原文(摘要从不重
|
|
189
|
+
写),以及落在会话最后一次 flush 之后的回退完全看不到。见
|
|
190
|
+
`docs/rewind-memory-consistency.md`。
|
|
191
|
+
|
|
128
192
|
- **日志层不嵌入。** `msg-*` 和 `session-*` 条目以及 `belongTo` / `summary` 边都以
|
|
129
193
|
`embed: false` 写入(hypatia #26):已存储、可全文检索、可被 JSE 找到,但不生成
|
|
130
194
|
向量。协议设计了沿 `summary` 边从摘要精确下钻的召回方式,而不是按语义命中原始
|
|
@@ -237,7 +301,7 @@ agent/created ──▶ rules/taboos inject()
|
|
|
237
301
|
- **Recall** 在会话启动时预载项目/全局的 rules 和 taboos。除此之外什么都不推送,
|
|
238
302
|
除了一行在 shelf 不是 `default` 时指明 shelf 名字,好让 Agent 自己的 `hypatia`
|
|
239
303
|
调用去插件写入的地方找。检索由 Agent 通过随包技能完成,这正是
|
|
240
|
-
[`memory-nolinear.md`](https://github.com/
|
|
304
|
+
[`memory-nolinear.md`](https://github.com/MarchLiu/hypatia/blob/main/docs/memory-nolinear.md)
|
|
241
305
|
为那些持有上下文、能调用工具的 Agent 所开出的方案;此前的每轮注入器还会因为从
|
|
242
306
|
`agent/pre-step` 返回而不调用 `next()`,否决掉 DSH 自己的运行时上下文段。
|
|
243
307
|
|
|
@@ -470,7 +534,7 @@ shelf 清单本来就不是配置,而设置域现在只投影插件的 Config
|
|
|
470
534
|
刻意为之,依赖它们之前值得了解:
|
|
471
535
|
|
|
472
536
|
- **没有话题切换检测。**
|
|
473
|
-
[`memory-nolinear.md`](https://github.com/
|
|
537
|
+
[`memory-nolinear.md`](https://github.com/MarchLiu/hypatia/blob/main/docs/memory-nolinear.md)
|
|
474
538
|
把话题切换列为最强的会话切分信号,排在任务边界和时间间隔之上。检测它需要每个 turn
|
|
475
539
|
一次模型调用,这与本插件的核心取舍(主会话零模型工作)相悖,所以未实现。任务边界
|
|
476
540
|
(`turn/end` 原因)和会话关闭是被尊重的;一个覆盖三个无关话题的会话会被总结成一条。
|
|
@@ -498,7 +562,7 @@ shelf 清单本来就不是配置,而设置域现在只投影插件的 Config
|
|
|
498
562
|
- **级联在批次中途崩溃会移动分组边界。** 已连接的成员离开该层的未归档集合,所以
|
|
499
563
|
重试会归档另一批。每样东西仍恰好归档一次;只是分组不再是第一次尝试想要的。
|
|
500
564
|
- **三个随包技能里有两个是 vendored 拷贝。** `hypatia` 和 `hypatia-dream` 从
|
|
501
|
-
[hypatia 仓库](https://github.com/
|
|
565
|
+
[hypatia 仓库](https://github.com/MarchLiu/hypatia) 的 `skills/` 拷贝而来,因为
|
|
502
566
|
已发布的包够不到自身之外。没有任何东西刷新它们:上游原件变了就手工拷进来。
|
|
503
567
|
`hypatia` 逐字节相同。`hypatia-dream` 带两处本地补丁,刷新时必须重新打:
|
|
504
568
|
- **Shelf。** 请求里没给 shelf 时,它用会话种子指明的那个,只有种子没指明时才用
|
|
@@ -518,7 +582,7 @@ shelf 清单本来就不是配置,而设置域现在只投影插件的 Config
|
|
|
518
582
|
```
|
|
519
583
|
dsh-hypatia-auto-memory/
|
|
520
584
|
├── package.json # bundle + client manifest、依赖
|
|
521
|
-
├── cordis.patch.yml #
|
|
585
|
+
├── cordis.patch.yml # bundle 层:插件行 + smart-memory 预设
|
|
522
586
|
├── tsconfig.json # 浏览器 TS/TSX 类型检查配置
|
|
523
587
|
├── tsconfig.build.json # client bundle 的声明产物
|
|
524
588
|
├── tsdown.config.ts # 浏览器 CJS 工厂构建
|
package/cordis.patch.yml
CHANGED
|
@@ -21,3 +21,152 @@
|
|
|
21
21
|
- insert:
|
|
22
22
|
- id: hypatia-auto-memory
|
|
23
23
|
name: dsh-hypatia-auto-memory
|
|
24
|
+
|
|
25
|
+
# ── Agent preset: smart-memory(长程记忆) ─────────────────────────────────────
|
|
26
|
+
# The plugin owns the memory *writing* side; this preset owns the *reading*
|
|
27
|
+
# discipline. Installing this bundle therefore ships a memory-first agent — the
|
|
28
|
+
# preset is part of the package, not a profile patch a user has to hand-write.
|
|
29
|
+
# The two rows are independent: the preset mounts none of the plugin's own
|
|
30
|
+
# machinery (collector, consolidation, recall, autoApprove), and the plugin row
|
|
31
|
+
# above registers no preset.
|
|
32
|
+
#
|
|
33
|
+
# The row declares `@deepseek-ai/dsh-agent-preset` with `config.id: smart-memory`
|
|
34
|
+
# — the id the preset registry and a session header record. Its `config.plugins`
|
|
35
|
+
# is the `standard` preset's roster with the plan-mode group and `present`
|
|
36
|
+
# removed, under a memory-first persona; the sub-plugins (`@deepseek-ai/dsh-persona`,
|
|
37
|
+
# the `dsh-tool-*` rows, `dsh-skill-filesystem`) come from the dsh installation
|
|
38
|
+
# and are deliberately not dependencies of this package.
|
|
39
|
+
#
|
|
40
|
+
# The row id is deliberately the one hand-written profile patches used
|
|
41
|
+
# (`preset-smart-memory`), so such a profile only has to drop its copy. Rows
|
|
42
|
+
# sharing a row id collapse to the later one, and the profile's own
|
|
43
|
+
# cordis.patch.yml is applied after every bundle layer, so a leftover copy wins
|
|
44
|
+
# and masks this declaration; the Plugin Manager also reports that row as
|
|
45
|
+
# `unaddressable`, because its id then has two candidate rows. Two *distinct*
|
|
46
|
+
# rows declaring one `config.id` are refused instead: the registry throws
|
|
47
|
+
# `Duplicate agent preset: smart-memory` on the second registration.
|
|
48
|
+
#
|
|
49
|
+
# Edits saved from the Web preset editor override this row's `config.plugins`
|
|
50
|
+
# by id from the profile patch.
|
|
51
|
+
- insert:
|
|
52
|
+
- id: preset-smart-memory
|
|
53
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
54
|
+
config:
|
|
55
|
+
id: smart-memory
|
|
56
|
+
name: 长程记忆
|
|
57
|
+
description: >-
|
|
58
|
+
长程任务下强化 hypatia 记忆库的主动检索与落库:任务开始、里程碑、 发现新线索、重要操作前都先回忆,永久规则与纠正立即写库。
|
|
59
|
+
order: 2
|
|
60
|
+
plugins:
|
|
61
|
+
- id: persona
|
|
62
|
+
name: '@deepseek-ai/dsh-persona'
|
|
63
|
+
config:
|
|
64
|
+
prefix: |-
|
|
65
|
+
你是 {{model}} 驱动的长程记忆型编码 agent。默认假设是:这件事之前很可能已经被讨论、决定或踩过坑——先读记忆,再动手。
|
|
66
|
+
|
|
67
|
+
## 记忆系统怎么分工
|
|
68
|
+
会话日志、span 摘要、work unit 固化和会话开场的规则/禁忌种子都由 dsh-hypatia-auto-memory 在后台完成,不要手工重复写这些。你负责两件事:在正确时机主动**检索**;在用户给出永久性规则、禁忌或纠正时**立即落库**。具体命令与查询配方在 hypatia-memory 技能里(用 skill 工具加载,CLI 细节见 hypatia 技能)——本段只规定纪律。
|
|
69
|
+
|
|
70
|
+
## 检索纪律(下列时机是必须做的动作,不是建议)
|
|
71
|
+
1. 任务开始、制定计划前:先建立"已知决策 / 项目约定 / 已踩过的坑"的基线,再规划。
|
|
72
|
+
2. 每个里程碑或阶段边界:重新对齐一次,避免局部最优违背既有决策。
|
|
73
|
+
3. 任务中途发现新线索:出现陌生的内部名称或模块名、看不懂的报错、眼熟的 bug,或你意识到任务真正主题与开头不同时,立刻补查。
|
|
74
|
+
4. 重要或破坏性操作前:git commit / push、删除或覆盖文件、改配置、安装或升级插件与依赖、数据库迁移、发布或对外发送内容之前。
|
|
75
|
+
5. 用户提到历史时:例如"之前怎么定的""上次那个""为什么这么做"。
|
|
76
|
+
6. 长程任务周期性回顾:上下文已经很长,或目标已跨多轮续跑时,重新检索当前的关键主题。
|
|
77
|
+
|
|
78
|
+
检索质量要求:查询写成你想找的那个**概念**,不要照抄用户原话;语义检索用 `hypatia similar`,精确标识符用 `hypatia search`;始终排除运维层(`msg-`、`sum-`、`session-`、`hypatia-dream-run-` 开头,或带 `message`/`summary`/`session` 标签的行);一次空结果不等于记忆里没有——换措辞、提高 `--limit` 再试。
|
|
79
|
+
|
|
80
|
+
## 检索结果怎么用
|
|
81
|
+
查到既有决策就遵循它,并在回答里点明依据(条目名或结论)。记忆与当前代码或用户要求冲突时,明确说出冲突,以用户当前指令为准,并用 `supersedes` 语句把新结论落库——绝不删除旧条目。确实没有记录时,直接说明"记忆库中没有相关记录",再按第一性原理推进,不要编造记忆内容。
|
|
82
|
+
|
|
83
|
+
## 写入纪律
|
|
84
|
+
用户给出永久性规则、禁忌或纠正时立即写库(`rule` / `taboo` / `memory`,scope 用本项目的实际 scope):先 `knowledge-get`,已存在就 `knowledge-update`,不存在才 `knowledge-create`,不要删除后重建。过程流水不要手工写。
|
|
85
|
+
|
|
86
|
+
## 长程任务
|
|
87
|
+
复杂任务先用 todo_write 拆解并跟踪;跨轮目标用 goal 机制续跑,没有完成证据不要宣称完成。阶段收尾时把"值得留存的结论"落库,而不是把过程日志落库。
|
|
88
|
+
suffix: |-
|
|
89
|
+
你的工作目录是 {{cwd}}。记忆的检索与落库通过 bash 调用 hypatia CLI 完成,scope 归属由插件决定。
|
|
90
|
+
- id: agent-instructions
|
|
91
|
+
name: '@deepseek-ai/dsh-agent-instructions'
|
|
92
|
+
config:
|
|
93
|
+
maxBytes: 65536
|
|
94
|
+
- id: tool-bash
|
|
95
|
+
name: '@deepseek-ai/dsh-tool-bash'
|
|
96
|
+
disabled: !!js process.platform === 'win32'
|
|
97
|
+
- id: tool-pwsh
|
|
98
|
+
name: '@deepseek-ai/dsh-tool-pwsh'
|
|
99
|
+
disabled: !!js process.platform !== 'win32'
|
|
100
|
+
- id: tool-fs
|
|
101
|
+
name: '@deepseek-ai/dsh-tool-fs'
|
|
102
|
+
- id: tool-fs-search
|
|
103
|
+
name: '@deepseek-ai/dsh-tool-fs-search'
|
|
104
|
+
config:
|
|
105
|
+
sampleOverCapGlobResults: false
|
|
106
|
+
- id: tool-jobs
|
|
107
|
+
name: '@deepseek-ai/dsh-tool-jobs'
|
|
108
|
+
- id: skill-filesystem
|
|
109
|
+
name: '@deepseek-ai/dsh-skill-filesystem'
|
|
110
|
+
- id: tool-skill
|
|
111
|
+
name: '@deepseek-ai/dsh-tool-skill'
|
|
112
|
+
- id: command-goal
|
|
113
|
+
name: '@deepseek-ai/dsh-command-goal'
|
|
114
|
+
- id: tool-goal
|
|
115
|
+
name: '@deepseek-ai/dsh-tool-goal'
|
|
116
|
+
- id: compaction
|
|
117
|
+
name: cordis:group
|
|
118
|
+
group: true
|
|
119
|
+
isolate:
|
|
120
|
+
compaction: true
|
|
121
|
+
toolResultPruner: true
|
|
122
|
+
config:
|
|
123
|
+
- id: compaction-basic
|
|
124
|
+
name: '@deepseek-ai/dsh-compaction-basic'
|
|
125
|
+
- id: command-compact
|
|
126
|
+
name: '@deepseek-ai/dsh-command-compact'
|
|
127
|
+
- id: tool-result-pruner
|
|
128
|
+
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
129
|
+
config:
|
|
130
|
+
thresholdChars: 8192
|
|
131
|
+
headChars: 4096
|
|
132
|
+
tailChars: 1024
|
|
133
|
+
- id: delegation
|
|
134
|
+
name: cordis:group
|
|
135
|
+
group: true
|
|
136
|
+
isolate:
|
|
137
|
+
workflowEngine: true
|
|
138
|
+
config:
|
|
139
|
+
- id: tool-subagent-control
|
|
140
|
+
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
141
|
+
- id: tool-subagent-list-agents
|
|
142
|
+
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
143
|
+
- id: tool-subagent
|
|
144
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
145
|
+
config:
|
|
146
|
+
provider: spawn
|
|
147
|
+
toolName: subagent
|
|
148
|
+
modelSelectionSettings: true
|
|
149
|
+
backgroundMode: continuable
|
|
150
|
+
- id: tool-subagent-fork
|
|
151
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
152
|
+
config:
|
|
153
|
+
provider: fork
|
|
154
|
+
toolName: subagent_fork
|
|
155
|
+
backgroundMode: continuable
|
|
156
|
+
- id: workflow-ptc
|
|
157
|
+
name: '@deepseek-ai/dsh-workflow-ptc'
|
|
158
|
+
config:
|
|
159
|
+
provider: spawn
|
|
160
|
+
- id: tool-workflow
|
|
161
|
+
name: '@deepseek-ai/dsh-tool-workflow'
|
|
162
|
+
- id: tool-ask-user
|
|
163
|
+
name: '@deepseek-ai/dsh-tool-ask-user'
|
|
164
|
+
- id: tool-todo
|
|
165
|
+
name: '@deepseek-ai/dsh-tool-todo'
|
|
166
|
+
config:
|
|
167
|
+
allowParallelInProgress: true
|
|
168
|
+
- id: tool-web
|
|
169
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
170
|
+
config:
|
|
171
|
+
fetch: true
|
|
172
|
+
searchTimeoutMs: 60000
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hypatia-auto-memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Event-driven Hypatia memory for DSH: automatic conversation logging via session events, queued background consolidation (span summaries + work-unit extraction) with a dedicated model route, and recall injection — zero per-call model work in the main session.",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dsh": {
|
|
33
33
|
"engines": {
|
|
34
|
-
"dsh": ">=0.1.7-rc.1"
|
|
34
|
+
"dsh": ">=0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1"
|
|
35
35
|
},
|
|
36
36
|
"bundle": {
|
|
37
37
|
"patch": "./cordis.patch.yml"
|
|
@@ -49,25 +49,37 @@
|
|
|
49
49
|
"node": "^22.19.0 || >=24.0.0"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@deepseek-ai/schemastery": "^3.18.2",
|
|
52
|
+
"@deepseek-ai/schemastery": "^3.18.2 || ~3.18.5-alpha.1",
|
|
53
53
|
"zod": "^4.4.3"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@deepseek-ai/cordis": "^4.0.2 || ~4.0.5-alpha.1",
|
|
57
|
-
"@deepseek-ai/dsh": ">=0.1.7-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh": ">=0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
|
|
58
58
|
"@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
|
|
59
59
|
"@deepseek-ai/dsh-storage-domain": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
|
|
60
60
|
"@deepseek-ai/dsh-tools": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
|
|
61
61
|
"react": "^18.2.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
64
|
+
"@deepseek-ai/cordis": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
64
67
|
"@deepseek-ai/dsh": {
|
|
65
68
|
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@deepseek-ai/dsh-llm": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"@deepseek-ai/dsh-storage-domain": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
76
|
+
"@deepseek-ai/dsh-tools": {
|
|
77
|
+
"optional": true
|
|
66
78
|
}
|
|
67
79
|
},
|
|
68
80
|
"devDependencies": {
|
|
69
81
|
"@deepseek-ai/cordis": "^4.0.2 || ~4.0.5-alpha.1",
|
|
70
|
-
"@deepseek-ai/cordis-plugin-loader": "^1.0.5",
|
|
82
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.5 || ~1.0.6-alpha.1",
|
|
71
83
|
"@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
|
|
72
84
|
"@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
|
|
73
85
|
"@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
|
package/src/collector.js
CHANGED
|
@@ -136,6 +136,158 @@ export function countLoggableMessages(events) {
|
|
|
136
136
|
return count
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Event sources whose surface replacement means "the user took this branch
|
|
141
|
+
* back", as opposed to the producers that fold history without discarding it.
|
|
142
|
+
*
|
|
143
|
+
* Only a `replace` event carrying one of these `data.source.kind` values is a
|
|
144
|
+
* withdrawal (docs/rewind-memory-consistency.md §2.2–§2.4). The distinction is
|
|
145
|
+
* load-bearing, because every other producer's replacement is real history:
|
|
146
|
+
*
|
|
147
|
+
* - `compact-checkpoint` — DSH's own compaction, and `dsh-clear-mind`'s wrapper
|
|
148
|
+
* around the same seam: the folded region is summarised for the model, never
|
|
149
|
+
* discarded by the user.
|
|
150
|
+
* - `system-prompt` — the runtime-context snapshot (`system/message`) replaced
|
|
151
|
+
* by its own next version; observed on a real log as seq 395/396.
|
|
152
|
+
* - `context-editor` — an edit or condensation, whose own restore action brings
|
|
153
|
+
* the messages back.
|
|
154
|
+
*
|
|
155
|
+
* The list stays a whitelist on purpose. A denylist of "known non-withdrawal
|
|
156
|
+
* producers" fails open: the next plugin that uses `replace` for an edit would
|
|
157
|
+
* have its history deleted as if the user had taken it back. Members are added
|
|
158
|
+
* only after reading the producer's code and confirming the semantics, and each
|
|
159
|
+
* one gets a test in test/collector-withdrawn.test.mjs.
|
|
160
|
+
*
|
|
161
|
+
* Audited 2026-10-14: `dsh-rewind-plugin` (`dsh-rewind`) and the two rollback
|
|
162
|
+
* plugins that share `plugin:rollback` (`@domitor-syh/dsh-rollback`,
|
|
163
|
+
* `@nianchu/dsh-rollback`).
|
|
164
|
+
*
|
|
165
|
+
* The kind alone is not enough — a member also has to cite **exactly** the nodes
|
|
166
|
+
* it shadowed, see {@link withdrawnRanges}. `plugin:rollback` is the generic
|
|
167
|
+
* `plugin:<name>` spelling, so any future plugin calling itself `rollback`
|
|
168
|
+
* matches it too. Accepted knowingly: a rename shows up as a test failure,
|
|
169
|
+
* whereas a narrower string would silently stop matching the plugin it was
|
|
170
|
+
* written for.
|
|
171
|
+
*/
|
|
172
|
+
export const WITHDRAWAL_SOURCE_KINDS = Object.freeze(['dsh-rewind', 'plugin:rollback'])
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The withdrawn ranges a log slice contains, one entry per withdrawal marker.
|
|
176
|
+
*
|
|
177
|
+
* What a marker hid comes from its `sourceEventSeqs`, and the host only requires
|
|
178
|
+
* that list to **include** every shadowed node: citing extra, earlier seqs is
|
|
179
|
+
* legal (`assertSourceEventReferences` checks for `missing`, not equality).
|
|
180
|
+
* Everything cited here is deleted, so this reads exactly as far as the producer
|
|
181
|
+
* can be trusted — which is a per-member property, verified by reading each
|
|
182
|
+
* member of {@link WITHDRAWAL_SOURCE_KINDS}: `dsh-rewind-plugin` passes
|
|
183
|
+
* `[...plan.shadowedSeqs]` (its own `surface.slice(targetIndex)`), and the
|
|
184
|
+
* rollback plugins pass `seqsForStorage(shadowed)` — that same set, reordered
|
|
185
|
+
* for three or more seqs so the store's range encoder cannot compress it, which
|
|
186
|
+
* is why the **set**, not the order, is what this keeps.
|
|
187
|
+
*
|
|
188
|
+
* A new member needs that check too (docs/rewind-memory-consistency.md §2.4): a
|
|
189
|
+
* producer that also cited, say, the anchor it rewound to would have that message
|
|
190
|
+
* deleted here.
|
|
191
|
+
*
|
|
192
|
+
* @param {readonly any[]} events - a log slice that may contain markers. Callers
|
|
193
|
+
* must read to `session.seq`, not merely to their own task range: a marker's
|
|
194
|
+
* seq is greater than every seq it shadows, so it can sit past the range being
|
|
195
|
+
* logged (docs/rewind-memory-consistency.md §3 fact 5).
|
|
196
|
+
* @returns {Array<{from: number, to: number, at: number, kind: string, seqs: Set<number>}>}
|
|
197
|
+
*/
|
|
198
|
+
export function withdrawnRanges(events) {
|
|
199
|
+
const ranges = []
|
|
200
|
+
for (const event of events ?? []) {
|
|
201
|
+
const op = event?.surfaceOp
|
|
202
|
+
if (op?.op !== 'replace') continue
|
|
203
|
+
// Only a user message can carry these markers. This also rules out the
|
|
204
|
+
// runtime-context snapshot (`system/message`) and the tool-result pruner
|
|
205
|
+
// (`tool/result`), both of which replace a single node with itself.
|
|
206
|
+
if (event.type !== 'user/message') continue
|
|
207
|
+
const kind = event.data?.source?.kind
|
|
208
|
+
if (typeof kind !== 'string' || WITHDRAWAL_SOURCE_KINDS.includes(kind) === false) continue
|
|
209
|
+
const cited = Array.isArray(event.sourceEventSeqs) ? event.sourceEventSeqs : []
|
|
210
|
+
const seqs = new Set(cited.filter((seq) => Number.isInteger(seq)))
|
|
211
|
+
// The host refuses an empty list on a replacement, so a marker citing nothing
|
|
212
|
+
// is one this build cannot read. Reading `[startSeq, endSeq]` instead would
|
|
213
|
+
// be a guess, and wrong in the case that matters most: those two numbers
|
|
214
|
+
// denote SURFACE order, so a slice containing a compaction checkpoint can
|
|
215
|
+
// have `startSeq > endSeq`.
|
|
216
|
+
if (seqs.size === 0) continue
|
|
217
|
+
ranges.push({ from: op.startSeq, to: op.endSeq, at: event.seq, kind, seqs })
|
|
218
|
+
}
|
|
219
|
+
return ranges
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Every shadowed seq of every marker in the slice, as one lookup set. */
|
|
223
|
+
export function withdrawnSeqs(events) {
|
|
224
|
+
const seqs = new Set()
|
|
225
|
+
for (const range of withdrawnRanges(events)) {
|
|
226
|
+
for (const seq of range.seqs) seqs.add(seq)
|
|
227
|
+
}
|
|
228
|
+
return seqs
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The dense entry ordinal each of `seqs` was named by — `msg-<session>-<N>`.
|
|
233
|
+
*
|
|
234
|
+
* Same count as `countLoggableMessages`, over the same prefix, so a name derived
|
|
235
|
+
* here cannot drift from the name the span wrote: a withdrawal that arrives
|
|
236
|
+
* after its messages were logged has to retract them by name, and the only
|
|
237
|
+
* durable record of that name is the log itself.
|
|
238
|
+
*
|
|
239
|
+
* @param {readonly any[]} events - events covering the counting prefix, i.e. from
|
|
240
|
+
* the inherited prefix through the shadowed seqs.
|
|
241
|
+
* @param {Set<number>} seqs - shadowed seqs, as `withdrawnSeqs` returns them.
|
|
242
|
+
* @param {number} [inherited] - size of the prefix a fork inherited, which
|
|
243
|
+
* contributes no ordinals of its own.
|
|
244
|
+
* @returns {Map<number, number>} seq -> ordinal. A shadowed seq that is not a
|
|
245
|
+
* loggable message is absent: it never had an entry.
|
|
246
|
+
*/
|
|
247
|
+
export function withdrawnOrdinals(events, seqs, inherited = 0) {
|
|
248
|
+
const ordinals = new Map()
|
|
249
|
+
if (seqs === undefined || seqs.size === 0) return ordinals
|
|
250
|
+
let index = 0
|
|
251
|
+
for (const event of events ?? []) {
|
|
252
|
+
if (event.seq < inherited || isLoggableMessage(event) === false) continue
|
|
253
|
+
if (seqs.has(event.seq)) ordinals.set(event.seq, index)
|
|
254
|
+
index += 1
|
|
255
|
+
}
|
|
256
|
+
return ordinals
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What one log task must do about the withdrawals it can see — the whole
|
|
261
|
+
* decision, as a pure function of the log, so the executor only carries it out.
|
|
262
|
+
*
|
|
263
|
+
* A withdrawal has two faces, and they are told apart by position rather than by
|
|
264
|
+
* state: a shadowed message *inside* the range being written must not be written
|
|
265
|
+
* at all, while one *before* it was written by an earlier span and has to be
|
|
266
|
+
* retracted by name. Which of the two it is, is `seq < fromSeq`.
|
|
267
|
+
*
|
|
268
|
+
* @param {{log: readonly any[], prefix: readonly any[], fromSeq: number, inherited?: number}} input
|
|
269
|
+
* `log` is the marker window: from the start of the range being written to the
|
|
270
|
+
* end of the session log, because a marker's seq is greater than every seq it
|
|
271
|
+
* shadows and a window cut at the task's own end can miss one. `prefix` is
|
|
272
|
+
* `[inherited, fromSeq)`, where the messages an earlier span already wrote are
|
|
273
|
+
* counted — the same prefix `baseIndex` counts, so a retraction names the entry
|
|
274
|
+
* the span named.
|
|
275
|
+
* @returns {{seqs: Set<number>, retract: Map<number, number>}} `seqs` are the
|
|
276
|
+
* shadowed seqs in the window (in-span members are matched against the items
|
|
277
|
+
* about to be written); `retract` maps each shadowed seq *before* `fromSeq` to
|
|
278
|
+
* the dense entry ordinal it was named by. Shadowed seqs that are not loggable
|
|
279
|
+
* messages appear in neither: they never had an entry.
|
|
280
|
+
*/
|
|
281
|
+
export function withdrawalWork({ log, prefix, fromSeq, inherited = 0 }) {
|
|
282
|
+
const seqs = withdrawnSeqs(log)
|
|
283
|
+
if (seqs.size === 0) return { seqs, retract: new Map() }
|
|
284
|
+
const before = new Set([...seqs].filter((seq) => seq < fromSeq))
|
|
285
|
+
return {
|
|
286
|
+
seqs,
|
|
287
|
+
retract: before.size === 0 ? new Map() : withdrawnOrdinals(prefix, before, inherited),
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
139
291
|
/**
|
|
140
292
|
* @param {{
|
|
141
293
|
* ctx: import('@deepseek-ai/cordis').Context,
|
package/src/consolidator.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { absolutizeDates, blocksToText, eventDate, redactSecrets } from './content-policy.js'
|
|
18
|
-
import { countLoggableMessages, isCompactionReplacement, isLoggableMessage } from './collector.js'
|
|
18
|
+
import { countLoggableMessages, isCompactionReplacement, isLoggableMessage, withdrawnSeqs } from './collector.js'
|
|
19
19
|
import { messageName, summaryName } from './writer.js'
|
|
20
20
|
import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
|
|
21
21
|
import { TaskDeferredError } from './queue.js'
|
|
@@ -251,13 +251,15 @@ function consolidationInstruction(maxWorkUnits) {
|
|
|
251
251
|
* @param {{maxInputTokens: number}} config
|
|
252
252
|
* @param {Date} now - base for relative dates in an event that carries no
|
|
253
253
|
* timestamp; otherwise each line is resolved against its own event's time.
|
|
254
|
-
* @param {{before?: any}} [context] - the event just
|
|
255
|
-
* leading compaction replacement is recognised (it
|
|
254
|
+
* @param {{before?: any, withdrawn?: Set<number>}} [context] - the event just
|
|
255
|
+
* ahead of the span, so a leading compaction replacement is recognised (it
|
|
256
|
+
* restates an old result); and the seqs a withdrawal marker took out of the
|
|
257
|
+
* model's view, which must not reach the summariser at all.
|
|
256
258
|
* @returns {{text: string, lastSeq: number, complete: boolean}} `lastSeq` is the
|
|
257
259
|
* seq of the last event included; `complete` is false when the budget forced
|
|
258
260
|
* later events out of this run.
|
|
259
261
|
*/
|
|
260
|
-
export function buildTranscript(events, config, now = new Date(), { before } = {}) {
|
|
262
|
+
export function buildTranscript(events, config, now = new Date(), { before, withdrawn } = {}) {
|
|
261
263
|
const bySeq = new Map(events.map((event) => [event.seq, event]))
|
|
262
264
|
// Tool results cite their call by id; the readable name lives on the call.
|
|
263
265
|
const toolNames = new Map()
|
|
@@ -272,6 +274,12 @@ export function buildTranscript(events, config, now = new Date(), { before } = {
|
|
|
272
274
|
/** @type {Array<{seq: number, line: string}>} */
|
|
273
275
|
const entries = []
|
|
274
276
|
for (const event of events) {
|
|
277
|
+
// A withdrawn message never reaches the summariser. Retracting it is only
|
|
278
|
+
// half the job: a summary written from it would carry the branch the user
|
|
279
|
+
// took back into the summary layer, where no later delete can reach it,
|
|
280
|
+
// because summaries are immutable and named by span
|
|
281
|
+
// (docs/rewind-memory-consistency.md §4.4).
|
|
282
|
+
if (withdrawn?.has(event.seq) === true) continue
|
|
275
283
|
if (event.type === 'user/message') {
|
|
276
284
|
if (event.data?.source?.kind !== 'user') continue
|
|
277
285
|
const text = clean(blocksToText(event.data?.content ?? []), event)
|
|
@@ -696,7 +704,13 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
696
704
|
const events = session.snapshotEvents(fromSeq, task.toSeq)
|
|
697
705
|
const now = new Date()
|
|
698
706
|
const before = fromSeq > 0 ? session.snapshotEvents(fromSeq - 1, fromSeq)[0] : undefined
|
|
699
|
-
|
|
707
|
+
// Read the markers to `session.seq` rather than to `task.toSeq`: a marker's
|
|
708
|
+
// seq is greater than every seq it shadows, so this span's upper bound — and
|
|
709
|
+
// here that is `min(session.seq, lastLoggedSeq)` — can cut one off. Same
|
|
710
|
+
// visibility problem as the log executor (docs/rewind-memory-consistency.md
|
|
711
|
+
// §3 fact 5).
|
|
712
|
+
const withdrawn = withdrawnSeqs(session.snapshotEvents(fromSeq, session.seq ?? 0))
|
|
713
|
+
const { text: transcript, lastSeq, complete } = buildTranscript(events, consolidation, now, { before, withdrawn })
|
|
700
714
|
if (transcript.trim() === '') {
|
|
701
715
|
await advanceProgress(progress, task.sessionId, () => ({ lastConsolidatedSeq: task.toSeq }))
|
|
702
716
|
return
|
|
@@ -829,7 +843,13 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
829
843
|
const items = []
|
|
830
844
|
for (const event of events) {
|
|
831
845
|
if (!isLoggableMessage(event)) continue
|
|
832
|
-
|
|
846
|
+
// A withdrawn message has no entry to cite, and `items` is by contract the
|
|
847
|
+
// list of entries the span actually produced (writer.js `writeSummary`), so
|
|
848
|
+
// the ordinal is still counted but the name is not asserted. Counting it
|
|
849
|
+
// keeps the remaining ordinals aligned with the log.
|
|
850
|
+
if (event.seq < coveredToSeq && withdrawn.has(event.seq) === false) {
|
|
851
|
+
items.push(messageName(task.sessionId, itemIndex))
|
|
852
|
+
}
|
|
833
853
|
itemIndex += 1
|
|
834
854
|
}
|
|
835
855
|
await writer.writeSummary({
|
package/src/hypatia-cli.js
CHANGED
|
@@ -183,13 +183,19 @@ export function createHypatiaCli(ctx, config) {
|
|
|
183
183
|
* about `--no-embed` — they gained it together, but the answer is only worth
|
|
184
184
|
* having if it is true of each command it gates.
|
|
185
185
|
*
|
|
186
|
-
* @returns {Promise<{noEmbed: boolean, similarFilters: boolean}>}
|
|
186
|
+
* @returns {Promise<{noEmbed: boolean, similarFilters: boolean, deleteKnowledge: boolean}>}
|
|
187
187
|
*/
|
|
188
188
|
async function probeFeatures() {
|
|
189
|
-
const [knowledge, statement, similar] = await Promise.all([
|
|
189
|
+
const [knowledge, statement, similar, remove] = await Promise.all([
|
|
190
190
|
run(['knowledge-create', '--help']),
|
|
191
191
|
run(['statement-create', '--help']),
|
|
192
192
|
run(['similar', '--help']),
|
|
193
|
+
// Asked like the others, answered differently: a binary from before
|
|
194
|
+
// `knowledge-delete` existed (hypatia #22) prints a clap usage error and
|
|
195
|
+
// exits 2, and that is an answer ("cannot delete"), not a broken binary —
|
|
196
|
+
// the three required probes above still decide whether the client works at
|
|
197
|
+
// all. A withdrawal is therefore skipped rather than retracted there.
|
|
198
|
+
run(['knowledge-delete', '--help']),
|
|
193
199
|
])
|
|
194
200
|
for (const result of [knowledge, statement, similar]) {
|
|
195
201
|
if (result.exitCode !== 0) {
|
|
@@ -203,6 +209,7 @@ export function createHypatiaCli(ctx, config) {
|
|
|
203
209
|
return {
|
|
204
210
|
noEmbed: knowledge.stdout.includes('--no-embed') && statement.stdout.includes('--no-embed'),
|
|
205
211
|
similarFilters: similar.stdout.includes('--exclude-tags'),
|
|
212
|
+
deleteKnowledge: remove.exitCode === 0,
|
|
206
213
|
}
|
|
207
214
|
}
|
|
208
215
|
|
|
@@ -216,7 +223,7 @@ export function createHypatiaCli(ctx, config) {
|
|
|
216
223
|
// with its own error; the answer here only decides which flags that
|
|
217
224
|
// call carries, and the conservative answer is none.
|
|
218
225
|
featuresProbe = undefined
|
|
219
|
-
return { noEmbed: false, similarFilters: false }
|
|
226
|
+
return { noEmbed: false, similarFilters: false, deleteKnowledge: false }
|
|
220
227
|
})
|
|
221
228
|
}
|
|
222
229
|
return featuresProbe
|
|
@@ -281,6 +288,31 @@ export function createHypatiaCli(ctx, config) {
|
|
|
281
288
|
return runCreate(argv)
|
|
282
289
|
},
|
|
283
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Remove one knowledge entry.
|
|
293
|
+
*
|
|
294
|
+
* Deleting a name that is already gone exits 1 with `Error: not found:
|
|
295
|
+
* knowledge '<name>'` — a normal outcome for a caller whose retraction may
|
|
296
|
+
* be replayed, so it is reported as `false` instead of thrown. Statements
|
|
297
|
+
* mentioning the name are deliberately kept by hypatia
|
|
298
|
+
* (`wu-knowledge-delete-retains-statements-7487bf50`), which is why a
|
|
299
|
+
* withdrawal leaves `belongTo` / `summary` edges behind and nothing here
|
|
300
|
+
* tries to remove them.
|
|
301
|
+
*
|
|
302
|
+
* @returns {Promise<boolean>} true when this call removed an entry.
|
|
303
|
+
*/
|
|
304
|
+
async knowledgeDelete(name, shelf = shelfOf()) {
|
|
305
|
+
const result = await run(['knowledge-delete', name, '--shelf', shelf])
|
|
306
|
+
if (result.exitCode === 0) return true
|
|
307
|
+
const stderr = result.stderr.trim()
|
|
308
|
+
if (/^Error: not found:/.test(stderr)) return false
|
|
309
|
+
throw new HypatiaCliError(`hypatia exited ${result.exitCode}: knowledge-delete ${name}`, {
|
|
310
|
+
code: 'NONZERO_EXIT',
|
|
311
|
+
stderr: stderr.slice(0, 500),
|
|
312
|
+
exitCode: result.exitCode,
|
|
313
|
+
})
|
|
314
|
+
},
|
|
315
|
+
|
|
284
316
|
/**
|
|
285
317
|
* `embed: false` as on `knowledgeCreate`. A statement has no update
|
|
286
318
|
* command, so the choice is made once, here.
|
package/src/hypatia-client.js
CHANGED
|
@@ -61,7 +61,7 @@ export function createHypatiaClient(ctx, config, { status, connection }) {
|
|
|
61
61
|
if (!announced) {
|
|
62
62
|
announced = true
|
|
63
63
|
const yes = (flag) => (flag ? 'yes' : 'no')
|
|
64
|
-
status.info?.(`hypatia over ${usesMcp() ? 'mcp' : 'cli'}: no-embed ${yes(found.noEmbed)}, similar filters ${yes(found.similarFilters)}`)
|
|
64
|
+
status.info?.(`hypatia over ${usesMcp() ? 'mcp' : 'cli'}: no-embed ${yes(found.noEmbed)}, similar filters ${yes(found.similarFilters)}, delete ${yes(found.deleteKnowledge)}`)
|
|
65
65
|
}
|
|
66
66
|
return found
|
|
67
67
|
}
|
|
@@ -71,6 +71,7 @@ export function createHypatiaClient(ctx, config, { status, connection }) {
|
|
|
71
71
|
features,
|
|
72
72
|
knowledgeGet: (...args) => via('knowledgeGet', args),
|
|
73
73
|
knowledgeCreate: (...args) => via('knowledgeCreate', args),
|
|
74
|
+
knowledgeDelete: (...args) => via('knowledgeDelete', args),
|
|
74
75
|
statementCreate: (...args) => via('statementCreate', args),
|
|
75
76
|
search: (...args) => via('search', args),
|
|
76
77
|
similar: (...args) => via('similar', args),
|
package/src/hypatia-mcp.js
CHANGED
|
@@ -58,7 +58,7 @@ export const REQUIRED_TOOLS = Object.freeze([
|
|
|
58
58
|
* The names mirror hypatia-cli.js's `features()`.
|
|
59
59
|
*
|
|
60
60
|
* @param {Array<{name?: string, inputSchema?: {properties?: object}}>} tools
|
|
61
|
-
* @returns {{noEmbed: boolean, similarFilters: boolean}}
|
|
61
|
+
* @returns {{noEmbed: boolean, similarFilters: boolean, deleteKnowledge: boolean}}
|
|
62
62
|
*/
|
|
63
63
|
export function featuresOf(tools) {
|
|
64
64
|
const accepts = (name, argument) => Object.hasOwn(
|
|
@@ -68,6 +68,11 @@ export function featuresOf(tools) {
|
|
|
68
68
|
return {
|
|
69
69
|
noEmbed: accepts('knowledge_create', 'embed') && accepts('statement_create', 'embed'),
|
|
70
70
|
similarFilters: accepts('similar', 'exclude_tags'),
|
|
71
|
+
// Presence, not schema: `knowledge_delete` takes nothing this client does not
|
|
72
|
+
// already send. It is a capability rather than a REQUIRED_TOOL — a server
|
|
73
|
+
// that cannot delete is still a server worth writing memory to, and
|
|
74
|
+
// rejecting its whole MCP over it would push every call back to the CLI.
|
|
75
|
+
deleteKnowledge: tools.some((tool) => tool?.name === 'knowledge_delete'),
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -499,6 +504,23 @@ export function createHypatiaMcp(connection, config) {
|
|
|
499
504
|
return (await create('knowledge_create', lists(args, entry))) !== undefined
|
|
500
505
|
},
|
|
501
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Remove one knowledge entry. A name that is already gone arrives as a tool
|
|
509
|
+
* error (`not found: knowledge '<name>'`, classified `NOT_FOUND`), which is
|
|
510
|
+
* a normal outcome for a replayable retraction rather than a failure.
|
|
511
|
+
*
|
|
512
|
+
* @returns {Promise<boolean>} true when this call removed an entry.
|
|
513
|
+
*/
|
|
514
|
+
async knowledgeDelete(name, shelf = shelfOf()) {
|
|
515
|
+
try {
|
|
516
|
+
await invoke('knowledge_delete', { name, shelf })
|
|
517
|
+
return true
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (error instanceof HypatiaCliError && error.code === 'NOT_FOUND') return false
|
|
520
|
+
throw error
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
|
|
502
524
|
/**
|
|
503
525
|
* `embed: false` as on `knowledgeCreate`.
|
|
504
526
|
* @param {{data?: string, scopes?: string[], embed?: boolean, shelf?: string}} [entry]
|
package/src/index.js
CHANGED
|
@@ -37,7 +37,9 @@ import { createHypatiaClient } from './hypatia-client.js'
|
|
|
37
37
|
import { createHypatiaCli } from './hypatia-cli.js'
|
|
38
38
|
import { createWriter } from './writer.js'
|
|
39
39
|
import { TaskDeferredError, createQueue } from './queue.js'
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
countLoggableMessages, createCollector, formatSpan, withdrawalWork, withdrawnOrdinals, withdrawnSeqs,
|
|
42
|
+
} from './collector.js'
|
|
41
43
|
import { createCascade } from './cascade.js'
|
|
42
44
|
import { createConsolidator, PLUGIN_NAME } from './consolidator.js'
|
|
43
45
|
import {
|
|
@@ -371,6 +373,18 @@ function applyCollect(ctx, configHandle, status) {
|
|
|
371
373
|
// is replay-stable by construction and cannot drift out of sync.
|
|
372
374
|
const inherited = session.inheritedEventCount ?? 0
|
|
373
375
|
const baseIndex = countLoggableMessages(session.snapshotEvents(inherited, task.fromSeq))
|
|
376
|
+
// The marker window ends at `session.seq`, never at the task's own end: a
|
|
377
|
+
// marker's seq is greater than every seq it shadows, so it can sit past the
|
|
378
|
+
// range being logged — which is exactly the case where what it hid was
|
|
379
|
+
// written by an earlier run (docs/rewind-memory-consistency.md §3 fact 5).
|
|
380
|
+
// Everything else about the decision is collector.js's, where it is a pure
|
|
381
|
+
// function over the log and therefore testable without a session.
|
|
382
|
+
const { seqs: withdrawn, retract } = withdrawalWork({
|
|
383
|
+
log: session.snapshotEvents(task.fromSeq, session.seq ?? 0),
|
|
384
|
+
prefix: session.snapshotEvents(inherited, task.fromSeq),
|
|
385
|
+
fromSeq: task.fromSeq,
|
|
386
|
+
inherited,
|
|
387
|
+
})
|
|
374
388
|
const formatted = formatSpan(events, {
|
|
375
389
|
now: new Date(),
|
|
376
390
|
maxAssistantChars: config.collector.maxAssistantChars,
|
|
@@ -379,14 +393,28 @@ function applyCollect(ctx, configHandle, status) {
|
|
|
379
393
|
baseIndex,
|
|
380
394
|
before: task.fromSeq > 0 ? session.snapshotEvents(task.fromSeq - 1, task.fromSeq)[0] : undefined,
|
|
381
395
|
})
|
|
396
|
+
// A message the user took back is skipped, and one an earlier run wrote
|
|
397
|
+
// before the marker landed is retracted below. Anchored on the log range,
|
|
398
|
+
// never on "does the entry exist": a replayed span sees the same marker
|
|
399
|
+
// again, which is what keeps a retracted message from being written back.
|
|
400
|
+
const skipped = new Set()
|
|
382
401
|
for (const item of formatted) {
|
|
402
|
+
const isWithdrawn = withdrawn.has(item.seq)
|
|
403
|
+
if (isWithdrawn) skipped.add(item.index)
|
|
383
404
|
await writer.writeMessage({
|
|
384
405
|
sessionId: task.sessionId,
|
|
385
406
|
index: item.index,
|
|
386
407
|
markdown: item.markdown,
|
|
387
408
|
project: task.project,
|
|
409
|
+
withdrawn: isWithdrawn,
|
|
388
410
|
})
|
|
389
411
|
}
|
|
412
|
+
// Retracting is idempotent, and the attempts stop once the watermark passes
|
|
413
|
+
// the marker's own seq, after which it leaves every later window too.
|
|
414
|
+
for (const index of retract.values()) {
|
|
415
|
+
skipped.add(index)
|
|
416
|
+
await writer.retractMessage({ sessionId: task.sessionId, index })
|
|
417
|
+
}
|
|
390
418
|
const linkTo = baseIndex + formatted.length
|
|
391
419
|
// `belongTo` edges only exist once the session node does. Most sessions
|
|
392
420
|
// never produce one (it needs a host-supplied summary), so this stays a
|
|
@@ -397,8 +425,11 @@ function applyCollect(ctx, configHandle, status) {
|
|
|
397
425
|
sessionId: task.sessionId,
|
|
398
426
|
markdown: '',
|
|
399
427
|
project: task.project,
|
|
428
|
+
// The cursor counts every loggable message, withdrawn ones included, so
|
|
429
|
+
// the ordinals stay aligned with the log; only the edges are skipped.
|
|
400
430
|
linkFrom: before.lastBelongToIndex,
|
|
401
431
|
linkTo,
|
|
432
|
+
skip: skipped,
|
|
402
433
|
})
|
|
403
434
|
}
|
|
404
435
|
// Awaited: the task must not be reported done — and deleted — before the
|
|
@@ -432,12 +463,21 @@ function applyCollect(ctx, configHandle, status) {
|
|
|
432
463
|
const inherited = session.inheritedEventCount ?? 0
|
|
433
464
|
const current = state.progress.get(task.sessionId) ?? EMPTY_PROGRESS
|
|
434
465
|
const linkTo = countLoggableMessages(session.snapshotEvents(inherited, current.lastLoggedSeq))
|
|
466
|
+
// This node links every message logged so far, so it owes the same skip set
|
|
467
|
+
// the log executor used — without it this node's own range would settle
|
|
468
|
+
// edges to messages that have no entry to link from. The markers are read
|
|
469
|
+
// from the whole log (the node is written once per session), and a marker
|
|
470
|
+
// anywhere in it can only hide earlier seqs: one slice, two passes.
|
|
471
|
+
const log = session.snapshotEvents(inherited, session.seq ?? 0)
|
|
472
|
+
const withdrawn = withdrawnSeqs(log)
|
|
473
|
+
const skip = new Set(withdrawnOrdinals(log, withdrawn, inherited).values())
|
|
435
474
|
await writer.writeSessionNode({
|
|
436
475
|
sessionId: task.sessionId,
|
|
437
476
|
markdown,
|
|
438
477
|
project: task.project,
|
|
439
478
|
linkFrom: current.lastBelongToIndex,
|
|
440
479
|
linkTo,
|
|
480
|
+
skip,
|
|
441
481
|
})
|
|
442
482
|
await advanceProgress(state.progress, task.sessionId, (progressRow) => ({
|
|
443
483
|
hasSessionNode: 1,
|
package/src/writer.js
CHANGED
|
@@ -116,6 +116,52 @@ export function summaryName(sessionId, fromSeq, toSeq) {
|
|
|
116
116
|
* relationship rather than a guessed one.
|
|
117
117
|
*/
|
|
118
118
|
export function createWriter(cli, { status, adjudicate }) {
|
|
119
|
+
/**
|
|
120
|
+
* Whether this shelf can delete at all, warned about once.
|
|
121
|
+
*
|
|
122
|
+
* `knowledgeDelete` is a capability, not part of the baseline: a hypatia from
|
|
123
|
+
* before `knowledge-delete` existed (hypatia #22), or an MCP server without
|
|
124
|
+
* the tool, answers `features()` false. A withdrawal is then skipped instead
|
|
125
|
+
* of retracted — the message is still never written again, because the
|
|
126
|
+
* decision is anchored on the log range rather than on state this plugin
|
|
127
|
+
* keeps, so the only cost is one entry left for the user to remove by hand.
|
|
128
|
+
*/
|
|
129
|
+
let warnedNoDelete = false
|
|
130
|
+
async function canRetract() {
|
|
131
|
+
const { deleteKnowledge } = await cli.features()
|
|
132
|
+
if (deleteKnowledge === false && warnedNoDelete === false) {
|
|
133
|
+
warnedNoDelete = true
|
|
134
|
+
status.warn('withdrawn messages cannot be retracted: this hypatia has no knowledge-delete, so they are skipped instead of removed')
|
|
135
|
+
}
|
|
136
|
+
return deleteKnowledge !== false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Take back one message the user withdrew.
|
|
141
|
+
*
|
|
142
|
+
* Get before delete, because the entry usually does not exist yet: a
|
|
143
|
+
* withdrawal is normally seen by the very span that would have written it.
|
|
144
|
+
* The get is also what makes a replay safe — `knowledge-delete` on a missing
|
|
145
|
+
* name is an error on the CLI and `NOT_FOUND` over MCP.
|
|
146
|
+
*
|
|
147
|
+
* Statements that mention the name are kept by hypatia on purpose
|
|
148
|
+
* (`wu-knowledge-delete-retains-statements-7487bf50`), so a withdrawal can
|
|
149
|
+
* leave a `belongTo` or `summary` edge pointing at an entry that is gone.
|
|
150
|
+
* That is the recorded, accepted graph semantic; nothing here removes them.
|
|
151
|
+
*
|
|
152
|
+
* @param {{sessionId: string, index: number}} entry
|
|
153
|
+
* @returns {Promise<{name: string, retracted: boolean}>}
|
|
154
|
+
*/
|
|
155
|
+
async function retractMessage({ sessionId, index }) {
|
|
156
|
+
const name = messageName(sessionId, index)
|
|
157
|
+
const existing = await cli.knowledgeGet(name)
|
|
158
|
+
if (existing.found === false) return { name, retracted: false }
|
|
159
|
+
if ((await canRetract()) === false) return { name, retracted: false }
|
|
160
|
+
const removed = await cli.knowledgeDelete(name)
|
|
161
|
+
if (removed) status.count('retracted')
|
|
162
|
+
return { name, retracted: removed === true }
|
|
163
|
+
}
|
|
164
|
+
|
|
119
165
|
/**
|
|
120
166
|
* Log one conversation message. Duplicates (same name already stored, e.g.
|
|
121
167
|
* from a replayed task) are counted and skipped — the watermark must still
|
|
@@ -128,9 +174,14 @@ export function createWriter(cli, { status, adjudicate }) {
|
|
|
128
174
|
* stored and full-text searchable; only the vector is not made. On a binary
|
|
129
175
|
* without `--no-embed` the flag is dropped by the transport.
|
|
130
176
|
*
|
|
131
|
-
* @param {{sessionId: string, index: number, markdown: string, project: string
|
|
177
|
+
* @param {{sessionId: string, index: number, markdown: string, project: string,
|
|
178
|
+
* withdrawn?: boolean}} entry - `withdrawn` marks a message a
|
|
179
|
+
* withdrawal marker took out of the model's view: it is not written, and one
|
|
180
|
+
* an earlier run already wrote is removed. The executor decides this from
|
|
181
|
+
* the log (`withdrawnSeqs` in collector.js); here it is only carried out.
|
|
132
182
|
*/
|
|
133
|
-
async function writeMessage({ sessionId, index, markdown, project }) {
|
|
183
|
+
async function writeMessage({ sessionId, index, markdown, project, withdrawn = false }) {
|
|
184
|
+
if (withdrawn) return { ...(await retractMessage({ sessionId, index })), written: false }
|
|
134
185
|
const name = messageName(sessionId, index)
|
|
135
186
|
const existing = await cli.knowledgeGet(name)
|
|
136
187
|
if (existing.found) {
|
|
@@ -201,18 +252,23 @@ export function createWriter(cli, { status, adjudicate }) {
|
|
|
201
252
|
* without one is simply skipped rather than given an invented summary
|
|
202
253
|
* (`skills/hypatia-memory/SKILL.md`, "do not fabricate session summaries").
|
|
203
254
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
255
|
+
* This writer never rewrites a session node: the plugin does not use
|
|
256
|
+
* `knowledge-update` on the log layer, so a second title cannot replace the
|
|
257
|
+
* first — the original stands and the caller is told nothing was written.
|
|
206
258
|
*
|
|
207
259
|
* Node and `belongTo` edges are written `embed: false`, as the messages are:
|
|
208
260
|
* the node is bookkeeping for the traversal, and its title is what
|
|
209
261
|
* `session/title` already gave DSH.
|
|
210
262
|
*
|
|
211
263
|
* @param {{sessionId: string, markdown: string, project: string,
|
|
212
|
-
* linkFrom: number, linkTo: number}} entry -
|
|
213
|
-
* the half-open range of message ordinals still
|
|
264
|
+
* linkFrom: number, linkTo: number, skip?: Set<number>}} entry -
|
|
265
|
+
* `[linkFrom, linkTo)` is the half-open range of message ordinals still
|
|
266
|
+
* needing a `belongTo` edge. `skip` holds ordinals that must NOT be linked:
|
|
267
|
+
* a message withdrawn from the model's view has no entry to link from, while
|
|
268
|
+
* the cursor still advances past it, so the caller keeps counting every
|
|
269
|
+
* loggable message (docs/rewind-memory-consistency.md §4.5).
|
|
214
270
|
*/
|
|
215
|
-
async function writeSessionNode({ sessionId, markdown, project, linkFrom = 0, linkTo = 0 }) {
|
|
271
|
+
async function writeSessionNode({ sessionId, markdown, project, linkFrom = 0, linkTo = 0, skip }) {
|
|
216
272
|
const name = sessionNodeName(sessionId)
|
|
217
273
|
const existing = await cli.knowledgeGet(name)
|
|
218
274
|
if (existing.found) {
|
|
@@ -227,10 +283,13 @@ export function createWriter(cli, { status, adjudicate }) {
|
|
|
227
283
|
await cli.knowledgeCreate(name, { data: markdown, tags: ['session'], scopes: [project], embed: false })
|
|
228
284
|
status.count('written')
|
|
229
285
|
}
|
|
286
|
+
let links = 0
|
|
230
287
|
for (let index = linkFrom; index < linkTo; index += 1) {
|
|
288
|
+
if (skip?.has(index) === true) continue
|
|
231
289
|
await cli.statementCreate(messageName(sessionId, index), 'belongTo', name, { scopes: [project], embed: false })
|
|
290
|
+
links += 1
|
|
232
291
|
}
|
|
233
|
-
return { name, written: existing.found === false, links
|
|
292
|
+
return { name, written: existing.found === false, links }
|
|
234
293
|
}
|
|
235
294
|
|
|
236
295
|
/**
|
|
@@ -349,5 +408,5 @@ export function createWriter(cli, { status, adjudicate }) {
|
|
|
349
408
|
return { name, written: existing.found === false, verdict, nearest }
|
|
350
409
|
}
|
|
351
410
|
|
|
352
|
-
return { writeMessage, writeSummary, writeSessionNode, writeWorkUnit, findCandidates, messageName, summaryName }
|
|
411
|
+
return { writeMessage, retractMessage, writeSummary, writeSessionNode, writeWorkUnit, findCandidates, messageName, summaryName }
|
|
353
412
|
}
|