dsh-engram 0.3.4 → 0.3.5
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 +156 -22
- package/README.zh.md +108 -13
- package/cordis.patch.yml +6 -0
- package/docs/CONTEXT-GC-GUIDE.zh.md +192 -0
- package/docs/PROPOSAL-agentmemory.zh.md +108 -0
- package/docs/PROPOSAL-beads.zh.md +81 -0
- package/docs/PROPOSAL-contextgc.zh.md +188 -0
- package/docs/PROPOSAL-esrtrigger.zh.md +199 -0
- package/docs/PROPOSAL-hindsight.zh.md +156 -0
- package/lib/api.js +19 -0
- package/lib/capture.js +132 -4
- package/lib/client.js +32 -1
- package/lib/compaction.js +2 -18
- package/lib/context-gc.js +234 -2
- package/lib/goal-capture.js +89 -0
- package/lib/index-block.js +4 -0
- package/lib/index.js +196 -4
- package/lib/redact.js +48 -0
- package/lib/session-cjk.js +159 -0
- package/lib/settings.js +7 -0
- package/lib/status.js +57 -0
- package/lib/store.js +124 -6
- package/lib/todo-sink.js +146 -0
- package/lib/tools.js +58 -34
- package/lib/trigger.js +10 -8
- package/lib/usage.js +1 -0
- package/lib/web-provision.js +430 -0
- package/package.json +13 -2
- package/scripts/dsh-engram.mjs +268 -0
- package/scripts/engram-web-compaction.mjs +7 -0
- package/scripts/prepublish-check.mjs +106 -0
- package/scripts/setup-links.mjs +241 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# dsh-engram 改进方案 · 借自 Hindsight(三个概念,不引重路线)
|
|
2
|
+
|
|
3
|
+
> 依据:[vectorize-io/hindsight](https://github.com/vectorize-io/hindsight) 深度研究(2026 春季快照)。
|
|
4
|
+
> 原则:三条全部**纯规则 / 无向量 / 无 LLM 蒸馏 / 确定性可单测**,符合既有约束
|
|
5
|
+
> (不引重路线、中文、host 改动等自然重启、dock 极简)。
|
|
6
|
+
> 真实基线:Kototoro memories 71(扁平)/ tasks 1 / links 4 / nodes 3;toolstats 近 14 天
|
|
7
|
+
> todo 48 · ESR ~6 · esr_node/esr_link 接近零。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 总览
|
|
12
|
+
|
|
13
|
+
| # | 概念(来源) | 解决什么 | 落点 | 成本 | 依赖 |
|
|
14
|
+
|---|---|---|---|---|---|
|
|
15
|
+
| A | **Observation 范式**:证据引用 + proof_count + refine-not-overwrite(consolidator.py) | 71 条扁平记忆无从浓缩;失败/成功经验被覆盖不保留证据链 | `lib/store.js` 写入侧 + `lib/obs.js` 新模块 + board 观测区 | host ~250 行 | 无 |
|
|
16
|
+
| B | **Mental model 预计算**:常驻答案读零计算 + 水印刷新(mental_models + last_memory_seen_at) | 每次会话现查照样费 tokens;dock/board 每次重算 | `lib/mental.js` + 新工具 `esr_model` + board 常驻条 | host ~200 行 | 依赖 A 提供的 observation 汇总 |
|
|
17
|
+
| C | **召回重排信号**:proof_count boost × recency 半衰期(reranking.py) | 现行排序只有 recency+精确匹配;失败复活性差 | `lib/rerank.js` 纯函数 + bm25Rank 接入 | host ~80 行 | 无 |
|
|
18
|
+
|
|
19
|
+
状态:C ✅(7093fb5)/ A ✅(7103a22)/ B ✅。建议实施顺序:**C → A → B**。
|
|
20
|
+
B 落地要点:`models` 表注册(ws/content/generated_at/dirty/sources_hash);
|
|
21
|
+
`lib/mental.js` 纯函数 compileSummary(任务/实体/观测/记忆种类聚合 → markdown)、
|
|
22
|
+
computeSourcesHash、modelStale(dirty ∨ 超 10min ∨ 输入 hash 变)、getModel(冷缓存重算并持久化);
|
|
23
|
+
esr_* 六个写工具成功路径统一 `markModelDirty`;agent 工具 `esr_model`(返回常驻摘要+生成分钟数)
|
|
24
|
+
与 `/model` 端点;board 顶部可折叠「常驻摘要 · 生成于 N 分钟前」。新测试 6 个(宿主 67/67)。
|
|
25
|
+
C/A/B 的 host 部分(rerank、/observations、/model、esr_model、dirty 钩子)随下次 `dsh web` 重启生效;
|
|
26
|
+
board 观测条/常驻条 client-hmr 即时生效。
|
|
27
|
+
A 落地要点(相对本稿):`observations` 表注册进 `engramDomainSpec`;
|
|
28
|
+
`storeMemory` 三路(新建/exact-dup/失败复活)均触发 `integrateObservation`,其中
|
|
29
|
+
**失败复活(revive)是「新发生」→ forceEvidence 爬 proof**,exact-dup(同源)只刷
|
|
30
|
+
span;`engram_recall` 尾部注入 proof≥2 的观测段;board 加可折叠「观测 · 信念 N」条;
|
|
31
|
+
`/observations` 端点与 /toolstats 同批等重启;新测试 12 个(宿主 61/61)。
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## A. Observation:证据浓缩 + 证据计数 + 增强而非覆盖
|
|
36
|
+
|
|
37
|
+
### 问题
|
|
38
|
+
- 记忆是扁平列表;同一主题的多次失败/多次成功没有聚合,`esr_recall` 返回的是散点。
|
|
39
|
+
- D1/D3 的「失败记忆复活」只恢复了单条,没有形成「这个模式被证明 N 次」的信念。
|
|
40
|
+
|
|
41
|
+
### 数据模型(新增 `tables.observations`,存 storages/dsh_engram.json)
|
|
42
|
+
```jsonc
|
|
43
|
+
{
|
|
44
|
+
"id": "obs_<8>",
|
|
45
|
+
"ws": "/ws/A", // "" = 全局
|
|
46
|
+
"text": "浓缩后的信念语句(初始 = 触发记忆文本)",
|
|
47
|
+
"kind": "belief", // belief | pattern | risk(由触发记忆 tags 推导,不引 LLM)
|
|
48
|
+
"proof": { "count": 3, "sources": ["mem_<8>", ...] },
|
|
49
|
+
"span": { "first_seen_at": 1, "last_seen_at": 2 },
|
|
50
|
+
"negations": 0, // 反证计数 → 触发 weaken/stale
|
|
51
|
+
"trend": "stable", // new|strengthening|stable|weakening|stale(算法计算)
|
|
52
|
+
"tags": ["fail"],
|
|
53
|
+
"updated_at": 3
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 算法(`lib/obs.js`,全部纯函数 + 一个写入钩子)
|
|
58
|
+
1. **`concentrate(ws, memText, memId, tags)`** —— host 写入记忆(esr_store_memory / 失败复活 / engram 自动捕获)后调用:
|
|
59
|
+
- 桶化:按 `(entity 锚点 或 tags) + 字符 n-gram Jaccard ≥ 阈值(0.45)` 找既有 observation(中文无需向量)。
|
|
60
|
+
- 命中 → **merge 而非覆盖**:`proof.count+1`、`sources.push(memId)`、`span.last_seen_at = now`、`updated_at=now`;`text` 仅在新增细节时 append(简单规则:新文本含旧文本不存在的 token 段则并句)。
|
|
61
|
+
- 命中且可判定为**反证**(tags 含 `error`+当前 text 与旧 text 出现否定词对)→ `negations+1`,`proof.count` 不变。
|
|
62
|
+
- 未命中 → 新建 observation(`proof.count=1`)。
|
|
63
|
+
2. **`computeTrend(span, now, recentDays=30, oldDays=90)`** —— 借鉴 observations.py 的 `Trend`:
|
|
64
|
+
- last_seen 在 30 天内且 first_seen 更早 → `strengthening`
|
|
65
|
+
- 全部证据在 30 天内 → `new`
|
|
66
|
+
- 90 天内有证据 → `stable`
|
|
67
|
+
- 90 天内无新证据但有历史 → `weakening`;无证据 → 不存在的桶自清(GC 归入 esr_gc)。
|
|
68
|
+
3. **读取注入**:`esr_recall` 把 `proof.count ≥ 2` 的 observations 作为高权重候选混入返回(标记 `source: observation`),证据门语义不变。
|
|
69
|
+
|
|
70
|
+
### Web 展示
|
|
71
|
+
- 只进 **board**(dock 保持极简):columns 上方一行可折叠「观测 · N 条信念」条,展示 `text · ✓×count · 趋势图标`,展开可看 sources 列表。
|
|
72
|
+
- KPI 行(toolstats 那行)旁加 `观测 N`。
|
|
73
|
+
|
|
74
|
+
### 测试
|
|
75
|
+
`test/obs.test.mjs`(node:test 直接 import):
|
|
76
|
+
- 首次写入建桶 / 二次相同语义 merge(count 2 且 sources 追加)/ 反证 negations+1 /
|
|
77
|
+
trend 四种时间窗 / 阈值下不误并 / Jaccard 中文样例。
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## B. Mental model:常驻摘要 + 水印刷新
|
|
82
|
+
|
|
83
|
+
### 问题
|
|
84
|
+
- agent 每轮现查「这个工作区现在什么状态」要花多次 recall;看板每次挂载重算 overview + tasks + links。
|
|
85
|
+
- 产出一个可被反复读取、只在数据变化时才重算的「常驻答案」→ 读零计算。
|
|
86
|
+
|
|
87
|
+
### 数据模型(新增 `tables.models`)
|
|
88
|
+
```jsonc
|
|
89
|
+
{
|
|
90
|
+
"ws": "/ws/A",
|
|
91
|
+
"content": "## /ws/A 常驻摘要\n- 任务:2 active(1 就绪)/ 1 stable\n- 实体 3 · 链接 4\n- 观测 6 条信念(2 失败模式已重复 3 次)\n- 近 14 天:todo 48 · ESR 6\n- 生成于 N 分钟前",
|
|
92
|
+
"generated_at": 1,
|
|
93
|
+
"dirty": true, // 任何 esr_* 写操作后置 true
|
|
94
|
+
"sources_hash": "sha256 摘要(惰性校验可跳过重算)"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 算法(`lib/mental.js`)
|
|
99
|
+
- **`bumpDirty(ws)`** —— host 工具写钩子(esr_task/esr_close/esr_node/esr_link/esr_store_memory/todo 沉淀)之后调用。
|
|
100
|
+
- **`getModel(ws)`** —— 读缓存;`dirty` 或过期(>10min)则**纯聚合重算**(无 LLM):复用 `domain` API 的 tasks/entities/links 计数 + `collectToolCounts` 的近况 + `tables.observations` 的 top 信念 → 拼 markdown;重算后 `dirty=false`。
|
|
101
|
+
- **暴露**:
|
|
102
|
+
- agent 工具 `esr_model(ws?)` —— 返回常驻摘要 markdown(**零 LLM、一次 DB 读**)放在工具说明顶部「先看 model 再 recall」;response 带 `modeling:` 行同款风格。
|
|
103
|
+
- web endpoint `/model?ws=`(route 注册随下次重启,与 /toolstats 同批)。
|
|
104
|
+
|
|
105
|
+
### Web 展示
|
|
106
|
+
- board 顶部「常驻摘要 · 生成于 N 分钟前」可折叠条(可复制、可下载为 md——复用自己的 buildTasksMarkdown 思路,纯 client 无依赖)。
|
|
107
|
+
|
|
108
|
+
### 测试
|
|
109
|
+
`test/mental.test.mjs`:dirty→重算 / 未 dirty→命中缓存(generated_at 不变)/ 写操作触发 bump(route 注入假 service)/ 空工作区占位内容。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## C. 召回重排:proof_count × recency 半衰期
|
|
114
|
+
|
|
115
|
+
### 问题与现状核实
|
|
116
|
+
- 现行 `util.js bm25Rank` **已含** recency 半衰期因子 `1 + 0.5·exp(-ageDays/14)`(14 天、最多 +50%,
|
|
117
|
+
不覆盖强词法相关),也有 tag-exact/fuzzy/phrase/BM25 权重——真正缺失的是**证据强度信号**:
|
|
118
|
+
同样词频的记忆,被证明过 N 次的(hits)没有被排序优待。
|
|
119
|
+
- D2 失败复活已在 error 记忆重复时递增 `hits`;它就是现成的 proof 计数。
|
|
120
|
+
|
|
121
|
+
### 实现(已落地:`lib/rerank.js` + `util.js bm25Rank` 接线)
|
|
122
|
+
```
|
|
123
|
+
score_final = score_lexical // BM25 + tag/phrase boost(既有)
|
|
124
|
+
× (1 + 0.5·exp(-ageDays/14)) // recency 半衰期(既有)
|
|
125
|
+
× evidenceBoost(hits) // 新增:1 + 0.1·min(max(0,hits), 5),饱和 ±5%
|
|
126
|
+
```
|
|
127
|
+
- `evidenceBoost(hits, {alpha=0.1, cap=5})`:0→1.0、5→1.5(封顶),保守不喧宾夺主(借鉴 reranking.py 的 proof alpha)。
|
|
128
|
+
- `scoreCandidate(matchScore, hits)`、`sortByEvidence(list, opts)`(稳定、可注入 tiebreak)。
|
|
129
|
+
- 接入点:`bm25Rank` 词法分后乘 `evidenceBoost(r.hits ?? 0)`——encerence recall/searchMemories 全走这里,零额外改动。
|
|
130
|
+
- 「失败复活」天然增强:高 hits 的失败记忆在同词频下与新鲜记忆竞争更接近。
|
|
131
|
+
|
|
132
|
+
### 测试
|
|
133
|
+
`test/rerank.test.mjs`:三种 matchKind 基准 / 半衰期形状(t=0, halflife, 2*halflife 分别为 1, 0.5, 0.25)/ proof cap / 稳定排序(同分保序)/ 未来时间戳钳制。
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 分阶段计划(每阶段:代码 + 测试 + client-hmr 即时 / host 待重启 + 中文总结)
|
|
138
|
+
|
|
139
|
+
| 阶段 | 内容 | 交付物 | 生效 |
|
|
140
|
+
|---|---|---|---|
|
|
141
|
+
| C | rerank 纯函数 + 接入 esr_recall | `lib/rerank.js` + `test/rerank.test.mjs` | 等自然重启 |
|
|
142
|
+
| A | observations 表 + concentrate/trend + recall 注入 + board 观测条 | `lib/obs.js` + `lib/store.js` 钩子 + `test/obs.test.mjs` + board | host 待重启;board 即时 |
|
|
143
|
+
| B | mental 缓存 + esr_model 工具 + /model 端点 + board 常驻条 | `lib/mental.js` + `test/mental.test.mjs` + board | host 待重启;board 即时 |
|
|
144
|
+
|
|
145
|
+
每阶段与既有实现正交:不动 dock 交互(除可选 KPI 一列)、不动证据门、不动 esr_close 语义。
|
|
146
|
+
|
|
147
|
+
## 明确不做(保留约束)
|
|
148
|
+
- 不做 LLM 抽取/浓缩(Hindsight retain 管线)——成本与我们的形态不匹配。
|
|
149
|
+
- 不做向量/图库/交叉编码器重排。
|
|
150
|
+
- 不做 mental model 的「用户定义问题」自由文本——用固定模板,纯聚合。
|
|
151
|
+
- observations 的「来源逐 quote 引用」简化为 sources 列表(证据门已足够)。
|
|
152
|
+
|
|
153
|
+
## 风险
|
|
154
|
+
- 浓缩误并(Jaccard 阈值误命中)→ 反证机制 + GC 可兜;阈值可配。
|
|
155
|
+
- observation 桶增长失控 → 每 ws 上限(默认 50 条),超出按 proof 降序逐出(保留计数进 audit),归入 esr_gc。
|
|
156
|
+
- mental 缓存脏标记漏置 → `bumpDirty` 挂全量写钩子 + 过期兜底(10min 强制重算)。
|
package/lib/api.js
CHANGED
|
@@ -157,6 +157,7 @@ export function makeEngramRoutes(service) {
|
|
|
157
157
|
indexes,
|
|
158
158
|
captures: service.captureStats ?? { total: 0, git: 0, file: 0, error: 0 },
|
|
159
159
|
gc: service.gcStats ?? { lastRun: 0, archivedMemories: 0, archivedTasks: 0, removedLinks: 0 },
|
|
160
|
+
compaction: service.compactionStatus ?? { host: "unknown", web: { action: "unknown", presets: [] } },
|
|
160
161
|
config: service.config ?? {},
|
|
161
162
|
anonymous: {
|
|
162
163
|
version: 2,
|
|
@@ -180,6 +181,23 @@ export function makeEngramRoutes(service) {
|
|
|
180
181
|
writeJson(res, 200, { items });
|
|
181
182
|
};
|
|
182
183
|
|
|
184
|
+
// Goal-domain sediment: memories auto-captured from `goal/change` events
|
|
185
|
+
// (tag "goal"). A compact read surface for the GUI, backed by the same
|
|
186
|
+
// searchMemory view as /memories.
|
|
187
|
+
const goals = async (req, res) => {
|
|
188
|
+
const domain = await withDomain(res);
|
|
189
|
+
if (domain === void 0) return;
|
|
190
|
+
const params = readQuery(req);
|
|
191
|
+
const limit = Number(params.get("limit") ?? 50);
|
|
192
|
+
const items = domain.searchMemories({
|
|
193
|
+
workspace: params.get("workspace") || void 0,
|
|
194
|
+
tag: "goal",
|
|
195
|
+
status: "active",
|
|
196
|
+
limit,
|
|
197
|
+
}).map((m) => ({ id: m.id, kind: m.kind, text: m.text, createdAt: m.createdAt, workspace: m.workspace }));
|
|
198
|
+
writeJson(res, 200, { items });
|
|
199
|
+
};
|
|
200
|
+
|
|
183
201
|
const tasks = async (req, res) => {
|
|
184
202
|
const domain = await withDomain(res);
|
|
185
203
|
if (domain === void 0) return;
|
|
@@ -503,6 +521,7 @@ export function makeEngramRoutes(service) {
|
|
|
503
521
|
return [
|
|
504
522
|
make("GET", "/overview", overview),
|
|
505
523
|
make("GET", "/memories", memories),
|
|
524
|
+
make("GET", "/goals", goals),
|
|
506
525
|
makeUnion("/tasks", { GET: tasks, POST: createTask }),
|
|
507
526
|
make("GET", "/links", links),
|
|
508
527
|
make("GET", "/nodes", entities),
|
package/lib/capture.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* branch / tag / cherry-pick). Pure plumbing (`push`, `stash`, `status`)
|
|
12
12
|
* is deliberately NOT captured: those are operational echoes, not lasting
|
|
13
13
|
* decisions, and would only drown the memory pool and the [ENGRAM] index.
|
|
14
|
+
* - failing test runs (`npm test` / `node --test` / vitest / jest / pytest)
|
|
15
|
+
* as high-signal error memories tagged `test`.
|
|
14
16
|
* - writes/edits to significant config & doc paths
|
|
15
17
|
* - repeated tool errors (error signal, deduped by message)
|
|
16
18
|
* symbolic-index's own insight: one file edit is noise; accumulated edits and rare
|
|
@@ -22,6 +24,56 @@ import { workspaceKey as workspaceKeyOf } from "./util.js";
|
|
|
22
24
|
|
|
23
25
|
const GIT_MILESTONE_SIGNAL = 0.5;
|
|
24
26
|
const GIT_COMMIT_SIGNAL = 0.55;
|
|
27
|
+
const TEST_FAILURE_SIGNAL = 0.35;
|
|
28
|
+
const FIX_CLOSURE_SIGNAL = 0.45;
|
|
29
|
+
|
|
30
|
+
/** Concrete text: a test-run command (npm/pnpm/yarn/bun/npx/node --test/…). */
|
|
31
|
+
const TEST_RUN_RE = /\b(npm|pnpm|yarn|bun|npx|corepack)\s+(?:run\s+)?test\b|\bnode\s+--test\b|\bvitest\b|\bpytest\b|\bjest\b/;
|
|
32
|
+
/** A result line that reads as a failure point (deterministic keyword scan). */
|
|
33
|
+
const FAILURE_LINE_RE = /(assertionerror|#\s*fail(?:ed)?|not\s+ok|✗|✖|✘|\bFAIL(?:ED)?\b|tests?\s+failed|failed\s+tests?\b|\d+\s+failed|non-zero exit|exception|traceback|error:\s)/i;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize a command to a stable short signature tag (first two tokens,
|
|
37
|
+
* spaces → underscores, ≤48 chars) used to pair a failure with its later
|
|
38
|
+
* successful run. Returns null for empty commands.
|
|
39
|
+
*/
|
|
40
|
+
export function cmdTag(command) {
|
|
41
|
+
const tokens = String(command ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
|
42
|
+
if (tokens.length === 0) return null;
|
|
43
|
+
return `cmd:${tokens.join("_").slice(0, 48)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Failure→fix closure (zero LLM): when a command that previously failed (an
|
|
48
|
+
* ACTIVE kind:error memory tagged with the same `cmd:` signature) now runs
|
|
49
|
+
* successfully, produce a `procedure` memory plus the ids of the resolved
|
|
50
|
+
* error rows. Deterministic; fires only on genuine same-command
|
|
51
|
+
* success-after-failure.
|
|
52
|
+
*/
|
|
53
|
+
export function fixClosureEntry(name, command, result, previousErrors) {
|
|
54
|
+
if (!["bash", "shell", "exec"].includes(name)) return null;
|
|
55
|
+
if (result?.isError === true) return null; // not a success
|
|
56
|
+
if (typeof command !== "string" || command.trim().length === 0) return null;
|
|
57
|
+
const tag = cmdTag(command);
|
|
58
|
+
if (tag === null) return null;
|
|
59
|
+
const failing = (previousErrors ?? []).filter(
|
|
60
|
+
(m) => m.kind === "error" && m.status === "active" && (m.tags ?? []).includes(tag),
|
|
61
|
+
);
|
|
62
|
+
if (failing.length === 0) return null;
|
|
63
|
+
const head = command.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
64
|
+
const isTest = TEST_RUN_RE.test(command);
|
|
65
|
+
const plural = failing.length > 1 ? "s" : "";
|
|
66
|
+
return {
|
|
67
|
+
entry: {
|
|
68
|
+
kind: "procedure",
|
|
69
|
+
text: `fixed: ${head} — ${failing.length} earlier failing run${plural} now succeed`,
|
|
70
|
+
signal: FIX_CLOSURE_SIGNAL,
|
|
71
|
+
tags: ["procedure", "auto-captured", "fix", tag, ...(isTest ? ["test"] : [])],
|
|
72
|
+
filePath: null,
|
|
73
|
+
},
|
|
74
|
+
resolvedIds: failing.map((m) => m.id),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
25
77
|
|
|
26
78
|
/** Git operations that carry lasting meaning (milestones). */
|
|
27
79
|
const GIT_OPS = [
|
|
@@ -109,9 +161,30 @@ export function makeCaptureHandler(store, config, log, stats) {
|
|
|
109
161
|
// Errors take precedence over tool-name classification: a failed edit
|
|
110
162
|
// tool is a FAILURE of that file, not an "Edited" event (filePath anchor).
|
|
111
163
|
const errPath = String(args.path ?? args.file_path ?? args.filePath ?? "");
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
164
|
+
const cmd = cmdTag(args.command); // pairs this failure with a later success
|
|
165
|
+
// A failing test run is the highest-value coding signal: name it
|
|
166
|
+
// explicitly and surface the concrete failure point.
|
|
167
|
+
const testFail = extractTestFailure(name, args.command, result);
|
|
168
|
+
if (testFail !== null) {
|
|
169
|
+
entry = { kind: "error", text: testFail, signal: TEST_FAILURE_SIGNAL, tags: ["error", "test", "auto-captured", cmd].filter(Boolean), filePath: null };
|
|
170
|
+
} else {
|
|
171
|
+
const msg = errorHead(result);
|
|
172
|
+
if (msg !== null) {
|
|
173
|
+
entry = { kind: "error", text: `${name} failed: ${msg}`, signal: 0.25, tags: ["error", "auto-captured", cmd].filter(Boolean), filePath: errPath || null };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else if (isCommandRun(name) && typeof args.command === "string" && store.listMemories !== void 0) {
|
|
177
|
+
// Failure→fix closure: the same command that once failed now succeeds —
|
|
178
|
+
// sediment a `procedure` and resolve the earlier error rows.
|
|
179
|
+
const fix = fixClosureEntry(
|
|
180
|
+
name,
|
|
181
|
+
args.command,
|
|
182
|
+
result,
|
|
183
|
+
store.listMemories(workspaceKeyOf(cwd), 200),
|
|
184
|
+
);
|
|
185
|
+
if (fix !== null) {
|
|
186
|
+
entry = fix.entry;
|
|
187
|
+
entry._resolvedIds = fix.resolvedIds;
|
|
115
188
|
}
|
|
116
189
|
} else if (EDIT_TOOLS.has(name)) {
|
|
117
190
|
const path = String(args.path ?? args.file_path ?? args.filePath ?? "");
|
|
@@ -142,6 +215,7 @@ export function makeCaptureHandler(store, config, log, stats) {
|
|
|
142
215
|
recent.delete(first);
|
|
143
216
|
}
|
|
144
217
|
|
|
218
|
+
const resolvedIds = entry._resolvedIds ?? [];
|
|
145
219
|
void store
|
|
146
220
|
.storeMemory(
|
|
147
221
|
{
|
|
@@ -156,7 +230,13 @@ export function makeCaptureHandler(store, config, log, stats) {
|
|
|
156
230
|
},
|
|
157
231
|
config,
|
|
158
232
|
)
|
|
159
|
-
.then(() => {
|
|
233
|
+
.then(() => {
|
|
234
|
+
// Failure→fix closure: mark the earlier error rows resolved (tag
|
|
235
|
+
// append only — never re-ranks them, per store.tagMemory's contract).
|
|
236
|
+
for (const id of resolvedIds) {
|
|
237
|
+
void store.tagMemory?.(workspaceKeyOf(cwd), id, "resolved")?.catch?.(() => null);
|
|
238
|
+
}
|
|
239
|
+
})
|
|
160
240
|
.catch((error) => log?.warn?.(`engram auto-capture failed: ${String(error)}`));
|
|
161
241
|
} catch (error) {
|
|
162
242
|
// fully contained — a capture bug must never disturb the tool pipeline
|
|
@@ -187,6 +267,11 @@ function extractGit(name, command) {
|
|
|
187
267
|
return null;
|
|
188
268
|
}
|
|
189
269
|
|
|
270
|
+
/** Shell-run tool names whose failures/successes carry a command signature. */
|
|
271
|
+
function isCommandRun(name) {
|
|
272
|
+
return name === "bash" || name === "shell" || name === "exec";
|
|
273
|
+
}
|
|
274
|
+
|
|
190
275
|
function errorHead(result) {
|
|
191
276
|
// canonical value or rendered content
|
|
192
277
|
const value = result?.value;
|
|
@@ -206,6 +291,49 @@ function errorHead(result) {
|
|
|
206
291
|
return null;
|
|
207
292
|
}
|
|
208
293
|
|
|
294
|
+
/**
|
|
295
|
+
* When a bash/shell run of a test command exits non-zero, produce a
|
|
296
|
+
* deterministic failure memory: `tests failed (<cmd>): <failure line>`.
|
|
297
|
+
* Returns null for non-test commands, successful runs, or when no failure
|
|
298
|
+
* line can be pinned down (generic error extraction then applies).
|
|
299
|
+
*/
|
|
300
|
+
function extractTestFailure(name, command, result) {
|
|
301
|
+
if (name !== "bash" && name !== "shell" && name !== "exec") return null;
|
|
302
|
+
if (typeof command !== "string" || !TEST_RUN_RE.test(command)) return null;
|
|
303
|
+
if (result?.isError !== true) return null;
|
|
304
|
+
const line = failureLine(result);
|
|
305
|
+
if (line === null) return null;
|
|
306
|
+
const head = command.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
307
|
+
return `tests failed (${head}): ${line}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** First failure-flavoured line of a result (≤120 chars), else its head. */
|
|
311
|
+
function failureLine(result) {
|
|
312
|
+
const blocks = [];
|
|
313
|
+
const value = result?.value;
|
|
314
|
+
if (typeof value === "string" && value.length > 0) blocks.push(value);
|
|
315
|
+
if (value !== null && typeof value === "object") {
|
|
316
|
+
for (const k of ["error", "stderr", "message", "stdout"]) {
|
|
317
|
+
if (typeof value[k] === "string" && value[k].length > 0) blocks.push(value[k]);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const content = result?.content;
|
|
321
|
+
if (Array.isArray(content)) {
|
|
322
|
+
for (const block of content) {
|
|
323
|
+
if (block?.type === "text" && typeof block.text === "string") blocks.push(block.text);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const block of blocks) {
|
|
327
|
+
const lines = block.split("\n");
|
|
328
|
+
for (const line of lines) {
|
|
329
|
+
const trimmed = line.trim().replace(/\s+/g, " ").slice(0, 120);
|
|
330
|
+
if (FAILURE_LINE_RE.test(trimmed)) return trimmed;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const first = blocks[0];
|
|
334
|
+
return typeof first === "string" && first.length > 0 ? first.split("\n")[0].trim().slice(0, 120) : null;
|
|
335
|
+
}
|
|
336
|
+
|
|
209
337
|
function dirOf(path) {
|
|
210
338
|
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
211
339
|
return i > 0 ? path.slice(0, i) : ".";
|
package/lib/client.js
CHANGED
|
@@ -3374,7 +3374,17 @@ var ADVANCED_GROUPS = [
|
|
|
3374
3374
|
{ key: "gcEnabled", label: "\u8BB0\u5FC6 GC", hint: "\u5B9A\u65F6\u56DE\u6536\uFF08\u8FC7\u671F/\u8D85\u5BB9\u91CF/stable \u8D85\u7A97/\u60AC\u7A7A\u94FE\u63A5\uFF09", kind: "bool" },
|
|
3375
3375
|
{ key: "gcNarrative", label: "Context GC \u53D9\u4E8B\u515C\u5E95", hint: "\u81EA\u52A8 compact \u65F6\u65E0\u951A\u8F6E\u6B21\u7528 LLM \u6458\u8981\uFF1B\u5173\u6389=\u7EAF\u673A\u68B0\uFF08\u96F6 LLM\uFF09", kind: "bool" },
|
|
3376
3376
|
{ key: "gcStableRetentionDays", label: "stable \u4EFB\u52A1\u4FDD\u7559\uFF08\u5929\uFF09", hint: "\u8D85\u7A97\u540E\u7531 GC \u5F52\u6863\u3001\u79BB\u5F00 [ESR] \u8868\u9762", kind: "num", min: 0, max: 3650 },
|
|
3377
|
-
{ key: "verifyArtifact", label: "\u6821\u9A8C artifact \u5B58\u5728", hint: "esr_close \u7684 artifact \u987B\u5728\u5DE5\u4F5C\u533A\u78C1\u76D8\u4E0A\u771F\u5B9E\u5B58\u5728\uFF08URL \u8C41\u514D\uFF09\uFF1Bforce:true/\u5173\u6389\u53EF\u8DF3\u8FC7", kind: "bool" }
|
|
3377
|
+
{ key: "verifyArtifact", label: "\u6821\u9A8C artifact \u5B58\u5728", hint: "esr_close \u7684 artifact \u987B\u5728\u5DE5\u4F5C\u533A\u78C1\u76D8\u4E0A\u771F\u5B9E\u5B58\u5728\uFF08URL \u8C41\u514D\uFF09\uFF1Bforce:true/\u5173\u6389\u53EF\u8DF3\u8FC7", kind: "bool" },
|
|
3378
|
+
{ key: "autoSinkTodosOnEnd", label: "\u4F1A\u8BDD\u7ED3\u675F\u81EA\u52A8\u6C89\u6DC0 todo", hint: "\u4F1A\u8BDD\u7ED3\u675F\u65F6\u4ECD pending \u7684 todo \u81EA\u52A8\u8F6C\u6210 ESR draft\uFF08\u6309\u540D\u53BB\u91CD\u3001\u53D7\u4EFB\u52A1\u4E0A\u9650\u7EA6\u675F\uFF09\uFF1B\u5173\u6389=\u968F\u4F1A\u8BDD\u5F03\u53BB", kind: "bool" },
|
|
3379
|
+
{ key: "autoWebCompaction", label: "web \u9762\u81EA\u52A8\u542F\u7528 Context GC", hint: "\u542F\u52A8\u65F6\u628A\u5168\u90E8 stock \u5E03\u5C40\u7684 agent \u9884\u8BBE\uFF08shipped + \u81EA\u5B9A\u4E49\u6839\uFF09\u7684 compaction \u7EC4\u81EA\u52A8\u6362\u6210 dsh-engram/compaction\uFF1B\u5173\u6389=\u4E0D\u518D\u81EA\u52A8\u63A5\u7BA1\uFF08\u5DF2\u63A5\u7BA1\u7684\u9700 web-compaction:revert \u8FD8\u539F\uFF09", kind: "bool" }
|
|
3380
|
+
]
|
|
3381
|
+
},
|
|
3382
|
+
{
|
|
3383
|
+
id: "semantics-adv",
|
|
3384
|
+
title: "\u8BB0\u5FC6\u8BED\u4E49\uFF08\u9AD8\u7EA7\uFF09",
|
|
3385
|
+
description: "supersedes / contradict \u7684\u542F\u53D1\u5F0F",
|
|
3386
|
+
fields: [
|
|
3387
|
+
{ key: "autoSupersede", label: "\u81EA\u52A8 supersedes", hint: "\u5B9E\u4F53\u951A\u5B9A + \u66FF\u6362\u5F0F\u66F4\u65B0\uFF08\u6539\u7528/\u4E0D\u518D/no longer\u2026\uFF09\u81EA\u52A8\u628A\u65E7\u8BB0\u5FC6\u6807\u4E3A superseded\uFF1B\u65E7\u884C\u4FDD\u7559\u3001\u53EC\u56DE\u964D\u7EA7\u3002\u9ED8\u8BA4\u5173\uFF0C\u663E\u5F0F supersedes \u6C38\u8FDC\u4F18\u5148", kind: "bool" }
|
|
3378
3388
|
]
|
|
3379
3389
|
},
|
|
3380
3390
|
{
|
|
@@ -3882,6 +3892,26 @@ var TREND_LABEL = {
|
|
|
3882
3892
|
weakening: "\u51CF\u5F31",
|
|
3883
3893
|
stale: "\u9648\u65E7"
|
|
3884
3894
|
};
|
|
3895
|
+
function ContextGcChip({ compaction }) {
|
|
3896
|
+
const host = compaction?.host ?? "unknown";
|
|
3897
|
+
const hostLabel = host === "context-gc" ? "Context GC\xB7\u4E3B\u673A" : host === "default" ? "\u9ED8\u8BA4\u6458\u8981" : host === "unavailable" ? "GC \u4E0D\u53EF\u7528" : "GC \u72B6\u6001\u2026";
|
|
3898
|
+
const wired = (compaction?.web?.presets ?? []).filter((p) => p.action === "swapped" || p.action === "already").length;
|
|
3899
|
+
const webLabel = wired > 0 ? `\xB7${wired} \u9884\u8BBE` : "";
|
|
3900
|
+
const color = host === "context-gc" ? "var(--dsw-alias-success-strong, #059669)" : host === "default" || host === "unavailable" ? "var(--dsw-alias-label-secondary, var(--dsh-color-muted, #6b7280))" : "var(--dsw-alias-label-tertiary, #9ca3af)";
|
|
3901
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
|
|
3902
|
+
"span",
|
|
3903
|
+
{
|
|
3904
|
+
title: `host \u5E73\u9762\uFF1A${host}
|
|
3905
|
+
web \u5E73\u9762\uFF1A${compaction?.web?.action ?? "?"}\uFF08${wired} \u4E2A\u9884\u8BBE\u5DF2\u63A5\u7BA1\uFF09
|
|
3906
|
+
\u5173\u6389\u81EA\u52A8\u88C5\u914D\u89C1\u8BBE\u7F6E\u5361\u300Cweb \u9762\u81EA\u52A8\u542F\u7528 Context GC\u300D\uFF1B\u8FD8\u539F\u7528 npx dsh-engram revert`,
|
|
3907
|
+
style: { fontSize: 11, fontWeight: 600, color, border: `1px solid ${color}55`, borderRadius: 999, padding: "2px 8px", whiteSpace: "nowrap" },
|
|
3908
|
+
children: [
|
|
3909
|
+
hostLabel,
|
|
3910
|
+
webLabel
|
|
3911
|
+
]
|
|
3912
|
+
}
|
|
3913
|
+
);
|
|
3914
|
+
}
|
|
3885
3915
|
function EngramBoard({ api, onRequestClose }) {
|
|
3886
3916
|
const { vars } = useEngramTheme();
|
|
3887
3917
|
const [overview, setOverview] = (0, import_react9.useState)(null);
|
|
@@ -4108,6 +4138,7 @@ function EngramBoard({ api, onRequestClose }) {
|
|
|
4108
4138
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { style: hb.title, children: "ESR \u4EFB\u52A1\u770B\u677F" }),
|
|
4109
4139
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { style: hb.sub, children: "draft \u2192 active(\u8BC1\u636E) \u2192 stable \xB7 \u8DE8\u5DE5\u4F5C\u533A \xB7 \u4E0E esr_task/esr_close \u540C\u4E00\u8BC1\u636E\u95E8" }),
|
|
4110
4140
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { style: hb.legendChip, children: "\u4EC5 ESR \xB7 \u4F1A\u8BDD\u5185 todo \u89C1\u8F93\u5165\u6846\u4E0A\u65B9\u4EFB\u52A1\u6761" }),
|
|
4141
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(ContextGcChip, { compaction: overview?.compaction }),
|
|
4111
4142
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("span", { style: hb.seg, children: [
|
|
4112
4143
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", style: viewMode === "board" ? hb.segActive : hb.segBtn, onClick: () => setViewMode("board"), title: "\u56DB\u5217\u4EFB\u52A1\u770B\u677F", children: "\u770B\u677F" }),
|
|
4113
4144
|
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "button", style: viewMode === "graph" ? hb.segActive : hb.segBtn, onClick: () => setViewMode("graph"), title: "\u5B9E\u4F53\u5173\u7CFB\u56FE\u8C31\uFF08esr_node / esr_link\uFF09", children: "\u56FE\u8C31" })
|
package/lib/compaction.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* entry never writes).
|
|
32
32
|
*/
|
|
33
33
|
import { openEngramDomain } from "./store.js";
|
|
34
|
-
import {
|
|
34
|
+
import { mountCompactionEngine, collectPointerContext } from "./context-gc.js";
|
|
35
35
|
import { workspaceKey as wk } from "./util.js";
|
|
36
36
|
|
|
37
37
|
export const name = "dsh-engram-compaction";
|
|
@@ -56,9 +56,6 @@ export function apply(ctx, config = {}) {
|
|
|
56
56
|
const deps =
|
|
57
57
|
mode === "context-gc"
|
|
58
58
|
? {
|
|
59
|
-
narrativeEnabled: config.gcNarrative !== false,
|
|
60
|
-
narrativeMaxTokens: config.gcNarrativeMaxTokens ?? 1024,
|
|
61
|
-
narrativeMaxChars: config.gcNarrativeMaxChars ?? 4000,
|
|
62
59
|
readWorkspace: async (workspace, report) => {
|
|
63
60
|
if (!workspace) return null;
|
|
64
61
|
const domain = await domainFor(ctx);
|
|
@@ -68,18 +65,5 @@ export function apply(ctx, config = {}) {
|
|
|
68
65
|
}
|
|
69
66
|
: {};
|
|
70
67
|
|
|
71
|
-
return
|
|
72
|
-
.then((Engine) => {
|
|
73
|
-
if (!Engine) {
|
|
74
|
-
ctx.logger?.warn?.("engram context-gc: dsh-compaction-basic unavailable — no compaction engine");
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
new Engine();
|
|
78
|
-
ctx.logger?.info?.(
|
|
79
|
-
`engram context-gc: compaction = ${mode === "context-gc" ? "Context GC (mechanical eviction + re-fetch pointers)" : "default LLM summarizer (gcReplacesCompaction:false)"}`,
|
|
80
|
-
);
|
|
81
|
-
})
|
|
82
|
-
.catch((error) => {
|
|
83
|
-
ctx.logger?.warn?.(`engram context-gc mount failed: ${String(error)}`);
|
|
84
|
-
});
|
|
68
|
+
return mountCompactionEngine(ctx, config, deps);
|
|
85
69
|
}
|