dsh-engram 0.3.5 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -6
- package/README.zh.md +77 -4
- package/lib/api.js +57 -2
- package/lib/client.js +13 -1
- package/lib/index-block.js +92 -1
- package/lib/index.js +37 -1
- package/lib/settings.js +6 -0
- package/lib/store.js +287 -0
- package/lib/tools.js +48 -14
- package/lib/usage.js +146 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,17 @@ with one goal: **save tokens**.
|
|
|
40
40
|
tokenization), recall runs a bounded substring scan over the newest session
|
|
41
41
|
logs (zstd-decompressed, LRU-cached) and appends matched past sessions —
|
|
42
42
|
deterministic `# past sessions` lines, capped files and bytes.
|
|
43
|
+
- **Session-start auto-recall (`autoRecallOnStart`, default on)** — the pull-only
|
|
44
|
+
design had a gap proven in real use: recall tools relied on model initiative,
|
|
45
|
+
and in practice the model almost never calls `engram_recall` on its own (19
|
|
46
|
+
real sessions, thousands of tool calls, ~5 recall calls total). So at the FIRST
|
|
47
|
+
assembly of a new session the host runs one deterministic BM25 recall keyed on
|
|
48
|
+
the session's first user message and injects the top ≤ `autoRecallLimit` hits
|
|
49
|
+
as a `[RECALL]` block (default 700-char budget, "few and precise" — agentmemory
|
|
50
|
+
candidate ①). Relevant memory is in context even without a single recall call.
|
|
51
|
+
Pure rule, zero LLM, pure read (never bumps `hits`); superseded stale truth does
|
|
52
|
+
not occupy an injection slot (still fetchable via `engram_detail`); frozen per
|
|
53
|
+
session together with `[ENGRAM]` for prefix stability.
|
|
43
54
|
- **Memory-to-memory semantics** — `engram_store` accepts optional
|
|
44
55
|
`supersedes` / `contradicts` memory ids (validated against the same
|
|
45
56
|
workspace). A superseded ("stale truth") memory is demoted to the tail of
|
|
@@ -128,14 +139,105 @@ LLM-distillation and vector/graph niches are already crowded. dsh-engram fills t
|
|
|
128
139
|
three gaps that matter for token discipline:
|
|
129
140
|
|
|
130
141
|
1. **No model in the write path** — capture is deterministic pattern matching.
|
|
131
|
-
2. **No raw text in the prompt** — a bounded symbolic index is injected,
|
|
132
|
-
|
|
142
|
+
2. **No raw text in the prompt** — a bounded symbolic index is injected, plus the
|
|
143
|
+
top ≤ N memories auto-recalled for the session's first message (within budget);
|
|
144
|
+
deeper retrieval stays on demand ("retrieved ≠ full-text injected").
|
|
133
145
|
3. **Honest task closure** — STABLE cannot be declared without evidence.
|
|
134
146
|
|
|
135
147
|
DSH already provides cross-session FTS (`ctx.sessionQuery`), storage
|
|
136
148
|
(`ctx.storageDomain`), prompt-injection hooks and settings slots; dsh-engram is a
|
|
137
149
|
thin composition layer over them, not a re-implementation.
|
|
138
150
|
|
|
151
|
+
## Security model
|
|
152
|
+
|
|
153
|
+
The DSH plugin ecosystem is young and unvetted — there is no official directory,
|
|
154
|
+
no signature check, and the harness's read/write permission tiers do **not**
|
|
155
|
+
constrain what a plugin's own code can do. So the trust bar for a memory plugin
|
|
156
|
+
is higher than for a tool: a memory plugin sits on your prompt prefix and writes
|
|
157
|
+
to disk on your behalf. dsh-engram's model, stated plainly:
|
|
158
|
+
|
|
159
|
+
- **No network, ever.** The plugin has no outbound socket, no telemetry, no
|
|
160
|
+
update pinger. Nothing here can leak your sessions off the machine.
|
|
161
|
+
- **No shell, no filesystem access beyond one storage file.** It never executes
|
|
162
|
+
commands and never touches arbitrary paths. All data lives in the single
|
|
163
|
+
storage-domain unit `~/.dsh/storages/dsh_engram.json` (plus the session-log
|
|
164
|
+
reads it makes through DSH's own `ctx.sessionQuery`).
|
|
165
|
+
- **Secrets are redacted before they can land on disk.** Every write path —
|
|
166
|
+
`engram_store`, auto-capture, and the import/restore route — runs text through
|
|
167
|
+
the deterministic `redactText` rule engine (API keys, JWTs, `Bearer` tokens,
|
|
168
|
+
private keys, `key=value` secret shapes) *before* the dedup hash, the char
|
|
169
|
+
cap, and storage. Nothing sensitive is ever persisted, so nothing sensitive
|
|
170
|
+
can be recalled later.
|
|
171
|
+
- **Doing harm requires your harness's own permissions.** The plugin only *asks*
|
|
172
|
+
the host to do things via the same seams the tools you already trust use. It
|
|
173
|
+
does not widen sandbox policy or escalate to `danger-full-access` — if you
|
|
174
|
+
keep your profile's defaults, engram inherits exactly those bounds.
|
|
175
|
+
- **Read-mostly web API, loopback-fenced.** The GUI routes under
|
|
176
|
+
`/api/dsh-engram` reject any non-loopback caller unless you explicitly
|
|
177
|
+
whitelist a hostname via `trustedHosts`; even then the same-origin fence holds.
|
|
178
|
+
- **Verifiable.** `npm run dsh-engram -- doctor` (`/scripts/dsh-engram.mjs
|
|
179
|
+
doctor`) reports what the plugin is configured to touch; the whole store is
|
|
180
|
+
inspectable, nothing is opaque. If you ever doubt a claim in this section, the
|
|
181
|
+
code to check is `lib/redact.js`, `lib/store.js` and this plugin's
|
|
182
|
+
`cordis.patch.yml`.
|
|
183
|
+
|
|
184
|
+
What this deliberately does **not** claim: it is not an air-gap. If your profile
|
|
185
|
+
grants the agent `danger-full-access` or a shell, the agent can use those —
|
|
186
|
+
engram is a memory layer, not a sandbox. The point above is that *dsh-engram
|
|
187
|
+
itself* adds no surface of its own.
|
|
188
|
+
|
|
189
|
+
## Data contract: export / import
|
|
190
|
+
|
|
191
|
+
Memory should not be a hostage. The four tables (memories / tasks / links /
|
|
192
|
+
entities) can be dumped and restored through a stable, versioned, machine-readable
|
|
193
|
+
shape — so the corpus survives a reinstall, a machine move, or (when a thin
|
|
194
|
+
adapter is written) a move to another harness, MCP end-point or Claude Code
|
|
195
|
+
skill. The contract is plain JSON with a self-describing header:
|
|
196
|
+
|
|
197
|
+
```http
|
|
198
|
+
GET /api/dsh-engram/export?workspace=/path/to/project # one workspace
|
|
199
|
+
GET /api/dsh-engram/export # every workspace
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```jsonc
|
|
203
|
+
{
|
|
204
|
+
"meta": { "format": "dsh-engram/export", "version": 1, "exportedAt": 1724…, "workspace": "/path/to/project" },
|
|
205
|
+
"memories": [ /* all rows, archived included — a backup never loses provenance */ ],
|
|
206
|
+
"tasks": [ /* draft/active/stable + archived */ ],
|
|
207
|
+
"links": [ /* typed edges */ ],
|
|
208
|
+
"entities": [ /* graph nodes */ ]
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Restore is idempotent by default and gated when destructive:
|
|
213
|
+
|
|
214
|
+
```http
|
|
215
|
+
POST /api/dsh-engram/import # body: { payload, mode, dryRun?, workspace?, confirm? }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- **`mode: "merge"`** (default) — writes only rows whose `id` is absent, so a
|
|
219
|
+
backup can be re-applied any number of times without duplicating anything. Each
|
|
220
|
+
row keeps its own `workspace`.
|
|
221
|
+
- **`mode: "replace"`** — wipes the target `workspace` from all four tables, then
|
|
222
|
+
restores only its rows. Destructive, so it additionally requires
|
|
223
|
+
`confirm: "restore"` in the body.
|
|
224
|
+
- **`dryRun: true`** — computes the identical plan (what would be written /
|
|
225
|
+
skipped) without writing a single row.
|
|
226
|
+
- **Import honors the same invariants as `storeMemory`**: memory text passes the
|
|
227
|
+
secret redactor before touching disk, and rows that would break the contract
|
|
228
|
+
(missing id/workspace, empty text, over `maxMemoryChars`, id already present
|
|
229
|
+
in merge, over the workspace memory cap) are skipped and listed in the
|
|
230
|
+
response report instead of aborting the batch.
|
|
231
|
+
|
|
232
|
+
A restore round-trip for one workspace is then:
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
curl -s "http://127.0.0.1:3080/api/dsh-engram/export?workspace=$PWD" -o engram-backup.json
|
|
236
|
+
curl -s -X POST http://127.0.0.1:3080/api/dsh-engram/import \
|
|
237
|
+
-H "content-type: application/json" \
|
|
238
|
+
-d "{\"payload\": $(cat engram-backup.json), \"mode\": \"merge\"}"
|
|
239
|
+
```
|
|
240
|
+
|
|
139
241
|
## Install
|
|
140
242
|
|
|
141
243
|
```sh
|
|
@@ -317,8 +419,8 @@ npm run build:client
|
|
|
317
419
|
|
|
318
420
|
| Tool | Purpose | Kind |
|
|
319
421
|
|---|---|---|
|
|
320
|
-
| `engram_store` | Explicitly store one memory (kind, tags, optional entity anchor, optional supersedes/contradicts
|
|
321
|
-
| `engram_recall` | Deterministic keyword recall over workspace memories; optional `search_sessions` FTS over past sessions | read |
|
|
422
|
+
| `engram_store` | Explicitly store one memory (kind, tags, optional entity anchor, optional supersedes/contradicts ids, optional `source`/`conditions`) | write |
|
|
423
|
+
| `engram_recall` | Deterministic keyword recall over workspace memories; optional `search_sessions` FTS over past sessions; opt-in `scope=global`/`recallScope` cross-workspace recall with `[W:<ws>]` origin markers | read |
|
|
322
424
|
| `engram_detail` | Full record of one memory id (provenance, tags, hits) | read |
|
|
323
425
|
| `esr_task` | Create a task entity (draft → active) | write |
|
|
324
426
|
| `esr_close` | Close a task via the evidence protocol (artifact + evaluation + memory_ref) | write |
|
|
@@ -542,7 +644,11 @@ ESR operating protocol (static, byte-identical every turn)
|
|
|
542
644
|
[ENGRAM] workspace: symbolic-index · 2 memories · 1 task(s) active · 0 links
|
|
543
645
|
[D] 06-18 Decided: use sqlite-vec for retrieval #a2331d87
|
|
544
646
|
[T] 06-18 Retrieval upgrade — ACTIVE · gap: artifact, evaluation, memory_ref #tsk_8b26
|
|
545
|
-
drill:
|
|
647
|
+
drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)
|
|
648
|
+
|
|
649
|
+
[RECALL] recall · 1 hit(s) · first msg: retrieval
|
|
650
|
+
- [D] 06-18 Decided: use sqlite-vec for retrieval upgrade #a2331d87 ×2
|
|
651
|
+
context: use above · engram_detail <id> · engram_recall <query>
|
|
546
652
|
|
|
547
653
|
[ESR] tasks: 1 active / 1 stable
|
|
548
654
|
- tsk_0d: Retrieval upgrade — ACTIVE · gap: artifact, evaluation, memory_ref
|
|
@@ -564,6 +670,14 @@ records via `engram_detail`. When a workspace has no tasks, `[ESR]` still render
|
|
|
564
670
|
one line naming `esr_task`/`esr_close` so the mechanism stays visible to the
|
|
565
671
|
model instead of vanishing.
|
|
566
672
|
|
|
673
|
+
`[RECALL]` (session-start auto recall, `autoRecallOnStart`) runs one deterministic
|
|
674
|
+
BM25 pass over the workspace keyed on the session's first user message and injects
|
|
675
|
+
the top `autoRecallLimit` hits, bounded by the `autoRecallMaxChars` character
|
|
676
|
+
budget; nothing renders on zero hits or an empty workspace. It is a *pointer* — the
|
|
677
|
+
model should treat it as "this is relevant; `engram_detail` for the full record" —
|
|
678
|
+
rather than full-text dumping. It is a pure read (never bumps `hits`), superseded
|
|
679
|
+
stale truth never occupies a slot, and it freezes per session with `[ENGRAM]`.
|
|
680
|
+
|
|
567
681
|
## Config
|
|
568
682
|
|
|
569
683
|
Defaults are token-conscious; override any key via the profile patch
|
|
@@ -574,6 +688,10 @@ Defaults are token-conscious; override any key via the profile patch
|
|
|
574
688
|
config:
|
|
575
689
|
autoCapture: true # zero-LLM tool-result capture
|
|
576
690
|
sessionSearch: true # engram_recall may also FTS past sessions
|
|
691
|
+
recallScope: workspace # recall scope: "workspace" (strict isolation, default) | "global" (opt-in cross-workspace recall with [W:<ws>] origin markers)
|
|
692
|
+
autoRecallOnStart: true # session-start auto recall (false = back to pure pull)
|
|
693
|
+
autoRecallLimit: 3 # [RECALL] max injected hits (few and precise)
|
|
694
|
+
autoRecallMaxChars: 700 # [RECALL] char budget
|
|
577
695
|
autoCapturePerSession: 40
|
|
578
696
|
indexMaxLines: 12 # [ENGRAM] line cap
|
|
579
697
|
indexMaxChars: 700 # [ENGRAM] char cap (token budget)
|
|
@@ -616,7 +734,11 @@ dedup 1.0.
|
|
|
616
734
|
**/api/dsh-engram/stats + the observability panel** is the real-session layer —
|
|
617
735
|
it answers how the model actually uses the memory in production (ESR
|
|
618
736
|
proactivity ratio, recall hit rate, detail conversion), while the eval
|
|
619
|
-
answers how good the retrieval layer itself is.
|
|
737
|
+
answers how good the retrieval layer itself is. The stats read the
|
|
738
|
+
concatenated multi-frame `session.jsonl.zstd` logs with Node's built-in
|
|
739
|
+
`node:zlib` (frame scan + one reused decoder handle) — **no system `zstd` CLI
|
|
740
|
+
is required**, so the telemetry works out-of-the-box on Windows and other
|
|
741
|
+
hosts without an external codec.
|
|
620
742
|
|
|
621
743
|
Repo layout: `lib/` (host half: store / capture / index-block / tools / api /
|
|
622
744
|
settings), `client/` (browser half, TSX + `build.mjs`), `test/` (node:test).
|
package/README.zh.md
CHANGED
|
@@ -28,6 +28,13 @@
|
|
|
28
28
|
且查询含 **CJK 中文**(FTS5 中文分词整段只算一个 token)时,召回会对最近若干会话日志做
|
|
29
29
|
有界的**子串扫描**(zstd 解压 + LRU 缓存,限文件数与字节),追加命中会话的确定性
|
|
30
30
|
`# past sessions` 行。
|
|
31
|
+
- **会话启动自动召回(`autoRecallOnStart`,默认开)** — 纯拉取式设计有个被实证戳穿的盲区:
|
|
32
|
+
召回工具只交模型自觉,而真实会话里模型几乎从不主动调 `engram_recall`(实测 19 个会话数千次
|
|
33
|
+
工具调用只出现 ~5 次召回)。因此宿主在新会话**首次组装**时,用会话**首条用户消息**对工作区做
|
|
34
|
+
一次确定性 BM25 召回,把命中前 ≤3 条直接注入 `[RECALL]` 块(默认 700 字符预算,"少而准",
|
|
35
|
+
agentmemory 候选①)——相关记忆不依赖模型想起来就已在上下文里。纯规则、零 LLM、
|
|
36
|
+
纯读不 bump 命中;superseded 过期真相不占注入槽(仍可 `engram_detail` 重取);
|
|
37
|
+
与 `[ENGRAM]` 一起按会话冻结保持前缀稳定。
|
|
31
38
|
- **记忆间语义(supersede/contradict)** — `engram_store` 可选收 `supersedes` / `contradicts`
|
|
32
39
|
记忆 id(同工作区校验)。被 supersede 的「过期真相」在召回里**降级到尾部**、并从 `[ENGRAM]`
|
|
33
40
|
块中**剔除**(新陈述占行);被 contradicts 的记忆保留排序但标注 `· contradicted by <id>`。
|
|
@@ -83,12 +90,65 @@ MIT · node >= 22.19 · host 半边 + 浏览器半边合在一包
|
|
|
83
90
|
dsh-engram 补的是对 token 纪律真正重要的三个空白:
|
|
84
91
|
|
|
85
92
|
1. **写入路径没有模型** — 捕获是确定性的模式匹配。
|
|
86
|
-
2.
|
|
93
|
+
2. **提示词里不灌全文** — 只注入有界的符号索引 + 会话启动时自动召回的最相关 ≤3 条(预算内),
|
|
94
|
+
更深的检索按需进行("检索到≠灌全文")。
|
|
87
95
|
3. **诚实的任务闭环** — 没有证据就不能宣布 STABLE。
|
|
88
96
|
|
|
89
97
|
DSH 已经提供跨会话 FTS(`ctx.sessionQuery`)、存储(`ctx.storageDomain`)、提示词注入钩子和设置槽位;
|
|
90
98
|
dsh-engram 只是这些能力之上的一层薄组合层,而非重新实现。
|
|
91
99
|
|
|
100
|
+
## 安全模型
|
|
101
|
+
|
|
102
|
+
DSH 插件生态还很年轻、缺乏背书:没有官方目录、没有签名校验,且 harness 的读写权限三档**约束不了插件自身代码**。因此记忆插件面对的信任何题比工具更高——它趴在你的提示词前缀上,还替你在磁盘上写东西。dsh-engram 的模型,直说如下:
|
|
103
|
+
|
|
104
|
+
- **永远不联网。** 本插件没有出口 socket、没有遥测、没有更新探针。任何东西都不可能把会话带出本机。
|
|
105
|
+
- **无 shell、除一个存储文件外不碰文件系统。** 不执行命令、不碰任意路径。全部数据只落在一个 storage-domain 单元 `~/.dsh/storages/dsh_engram.json`(外加经 DSH 自身 `ctx.sessionQuery` 做的会话日志读取)。
|
|
106
|
+
- **密钥在落盘之前就被脱敏。** 每条写入路径——`engram_store`、自动捕获,以及本节的**导入/恢复**——都先把文本过一遍确定性的 `redactText` 规则引擎(API Key、JWT、`Bearer` token、私钥、`key=value` 密钥形态),*之后*才做去重哈希、字符上限与落盘。敏感文本永不落盘,召回时自然也不会有。
|
|
107
|
+
- **要做什么坏事,必须借你 harness 自己的权限。** 插件只通过你已信任的工具同款的 seam 向宿主"请求"做事;不扩沙箱策略、不拉高到 `danger-full-access`——你保持 profile 默认,engram 就只继承那些边界。
|
|
108
|
+
- **Web API 只读为主、loopback 栅栏。** `/api/dsh-engram` 下的 GUI 路由拒绝任何非回环调用方,除非你用 `trustedHosts` 显式放行某个主机名;即便放行,同源栅栏依然生效。
|
|
109
|
+
- **可核验。** `npm run dsh-engram -- doctor`(`scripts/dsh-engram.mjs doctor`)报告插件被配置去碰什么;整个 store 可检视、不透明的东西为零。如果对这一段有怀疑,要查的代码就是 `lib/redact.js`、`lib/store.js` 和本插件的 `cordis.patch.yml`。
|
|
110
|
+
|
|
111
|
+
这**不是**说它是个气闸:如果你的 profile 给了 agent `danger-full-access` 或 shell,agent 可以用——engram 是记忆层而不是沙箱。上面这句话的意思是:*dsh-engram 自己*不新增任何攻击面。
|
|
112
|
+
|
|
113
|
+
## 数据契约:导出 / 导入
|
|
114
|
+
|
|
115
|
+
记忆不该当人质。四张表(memories / tasks / links / entities)可以用一个稳定、带版本号的机器可读格式整体倒出再恢复——重装、换机,或者(写一层薄适配器之后)搬到别的 harness / MCP 端点 / Claude Code skill,语料都跟得走。契约是带自描述头的纯 JSON:
|
|
116
|
+
|
|
117
|
+
```http
|
|
118
|
+
GET /api/dsh-engram/export?workspace=/path/to/project # 单个工作区
|
|
119
|
+
GET /api/dsh-engram/export # 全部工作区
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```jsonc
|
|
123
|
+
{
|
|
124
|
+
"meta": { "format": "dsh-engram/export", "version": 1, "exportedAt": 1724…, "workspace": "/path/to/project" },
|
|
125
|
+
"memories": [ /* 全部行,含归档——备份不丢溯源 */ ],
|
|
126
|
+
"tasks": [ /* draft/active/stable + 归档 */ ],
|
|
127
|
+
"links": [ /* 类型化边 */ ],
|
|
128
|
+
"entities": [ /* 图节点 */ ]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
恢复默认幂等、破坏性操作有门禁:
|
|
133
|
+
|
|
134
|
+
```http
|
|
135
|
+
POST /api/dsh-engram/import # body: { payload, mode, dryRun?, workspace?, confirm? }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **`mode: "merge"`**(默认)— 只写 `id` 尚不存在的行,备份可以随便重复套用而不会重复;每行保留自己的 `workspace`。
|
|
139
|
+
- **`mode: "replace"`** — 先把目标 `workspace` 的四张表清空,再只恢复属于它的行。破坏性操作,因此 body 里还要求 `confirm: "restore"`。
|
|
140
|
+
- **`dryRun: true`** — 只算出完全相同的执行计划(会写什么 / 跳过什么),不写任何一行。
|
|
141
|
+
- **导入遵守与 `storeMemory` 相同的不可变约定**:记忆文本落盘前先过密钥脱敏;会破坏契约的行(缺 id/workspace、空文本、超 `maxMemoryChars`、merge 时 id 已存在、超工作区记忆上限)会被跳过并写进响应报告,而不是中止整批。
|
|
142
|
+
|
|
143
|
+
单工作区的恢复往返:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
curl -s "http://127.0.0.1:3080/api/dsh-engram/export?workspace=$PWD" -o engram-backup.json
|
|
147
|
+
curl -s -X POST http://127.0.0.1:3080/api/dsh-engram/import \
|
|
148
|
+
-H "content-type: application/json" \
|
|
149
|
+
-d "{\"payload\": $(cat engram-backup.json), \"mode\": \"merge\"}"
|
|
150
|
+
```
|
|
151
|
+
|
|
92
152
|
## 安装
|
|
93
153
|
|
|
94
154
|
```sh
|
|
@@ -224,8 +284,8 @@ npm run eval # 离线召回 + 结构基准(确定性语料,跑真实 store/
|
|
|
224
284
|
|
|
225
285
|
| 工具 | 用途 | 类型 |
|
|
226
286
|
|---|---|---|
|
|
227
|
-
| `engram_store` | 显式存入一条记忆(kind、tags、可选实体锚点、可选 supersedes/contradicts
|
|
228
|
-
| `engram_recall` | 工作区记忆的确定性关键词召回;可选 `search_sessions` 跨会话 FTS | 读 |
|
|
287
|
+
| `engram_store` | 显式存入一条记忆(kind、tags、可选实体锚点、可选 supersedes/contradicts id,可选 `source`/`conditions`) | 写 |
|
|
288
|
+
| `engram_recall` | 工作区记忆的确定性关键词召回;可选 `search_sessions` 跨会话 FTS;可选 `scope=global`/`recallScope` 跨工作区召回(带 `[W:<ws>]` 来源标记) | 读 |
|
|
229
289
|
| `engram_detail` | 一条记忆 id 的完整记录(来源、标签、命中数) | 读 |
|
|
230
290
|
| `esr_task` | 创建任务实体(draft → active) | 写 |
|
|
231
291
|
| `esr_close` | 按证据协议关闭任务(artifact + evaluation + memory_ref) | 写 |
|
|
@@ -402,7 +462,11 @@ ESR 操作协议(静态,每轮逐字节相同)
|
|
|
402
462
|
[ENGRAM] workspace: symbolic-index · 2 memories · 1 task(s) active · 0 links
|
|
403
463
|
[D] 06-18 Decided: use sqlite-vec for retrieval #a2331d87
|
|
404
464
|
[T] 06-18 Retrieval upgrade — ACTIVE · gap: artifact, evaluation, memory_ref #tsk_8b26
|
|
405
|
-
drill:
|
|
465
|
+
drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)
|
|
466
|
+
|
|
467
|
+
[RECALL] recall · 1 hit(s) · first msg: retrieval
|
|
468
|
+
- [D] 06-18 Decided: use sqlite-vec for retrieval upgrade #a2331d87 ×2
|
|
469
|
+
context: use above · engram_detail <id> · engram_recall <query>
|
|
406
470
|
|
|
407
471
|
[ESR] tasks: 1 active / 1 stable
|
|
408
472
|
- tsk_0d: Retrieval upgrade — ACTIVE · gap: artifact, evaluation, memory_ref
|
|
@@ -420,6 +484,11 @@ promote: 2 pending todo(s) vs 1 ESR task(s) — esr_task(name="…") #suggest-pr
|
|
|
420
484
|
`#` id 通过 `engram_detail` 取完整记录。工作区没有任务时,`[ESR]` 仍会渲染一行点名 `esr_task`/`esr_close`,
|
|
421
485
|
让机制对模型保持可见,而不是整体消失。
|
|
422
486
|
|
|
487
|
+
`[RECALL]`(会话启动自动召回,`autoRecallOnStart`)按会话首条消息对工作区做确定性 BM25 召回,
|
|
488
|
+
把命中前 `autoRecallLimit` 条注入,受 `autoRecallMaxChars` 字符预算约束;无命中或空工作区时不渲染。
|
|
489
|
+
它是一条**提示**而不是全文灌入——模型该把它当成"这条相关,可能需要用 `engram_detail` 看细节"。
|
|
490
|
+
纯读、不 bump 命中、superseded 过期真相不占槽位;随 `[ENGRAM]` 一起按会话冻结。
|
|
491
|
+
|
|
423
492
|
## 配置
|
|
424
493
|
|
|
425
494
|
默认值以 token 为优先;可通过 profile 补丁(`~/.dsh/profiles/web/cordis.patch.yml`)或 Web 配置卡片覆盖任意键:
|
|
@@ -429,6 +498,10 @@ promote: 2 pending todo(s) vs 1 ESR task(s) — esr_task(name="…") #suggest-pr
|
|
|
429
498
|
config:
|
|
430
499
|
autoCapture: true # 零 LLM 工具结果捕获
|
|
431
500
|
sessionSearch: true # engram_recall 也可对历史会话 FTS
|
|
501
|
+
recallScope: workspace # 召回范围:workspace(严格工作区隔离,默认)| global(可选跨工作区召回,带 [W:<ws>] 来源标记)
|
|
502
|
+
autoRecallOnStart: true # 会话启动自动召回:按首条消息注入 [RECALL] 块(false=回到纯拉取)
|
|
503
|
+
autoRecallLimit: 3 # [RECALL] 最多注入条数(少而准)
|
|
504
|
+
autoRecallMaxChars: 700 # [RECALL] 字符预算
|
|
432
505
|
autoCapturePerSession: 40
|
|
433
506
|
indexMaxLines: 12 # [ENGRAM] 行数上限
|
|
434
507
|
indexMaxChars: 700 # [ENGRAM] 字符上限(token 预算)
|
package/lib/api.js
CHANGED
|
@@ -18,6 +18,9 @@ export const API_PREFIX = "/api/dsh-engram";
|
|
|
18
18
|
/** Cap on JSON request bodies (archives/deletes are tiny). */
|
|
19
19
|
const MAX_JSON_BODY_BYTES = 32 * 1024;
|
|
20
20
|
|
|
21
|
+
/** Import dumps carry whole workspaces of memories/tasks — allow up to 32 MiB. */
|
|
22
|
+
const MAX_IMPORT_BODY_BYTES = 32 * 1024 * 1024;
|
|
23
|
+
|
|
21
24
|
/** Loopback literal check plus browser same-origin markers. */
|
|
22
25
|
function isLoopbackRequest(request, trustedHosts = []) {
|
|
23
26
|
const address = request.socket?.remoteAddress;
|
|
@@ -67,13 +70,13 @@ function readQuery(req) {
|
|
|
67
70
|
return url.searchParams;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
function readJsonBody(req) {
|
|
73
|
+
function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
|
|
71
74
|
return new Promise((resolve) => {
|
|
72
75
|
let size = 0;
|
|
73
76
|
const chunks = [];
|
|
74
77
|
req.on("data", (chunk) => {
|
|
75
78
|
size += chunk.length;
|
|
76
|
-
if (size >
|
|
79
|
+
if (size > maxBytes) {
|
|
77
80
|
resolve({ error: "body too large" });
|
|
78
81
|
req.destroy();
|
|
79
82
|
return;
|
|
@@ -300,6 +303,56 @@ export function makeEngramRoutes(service) {
|
|
|
300
303
|
}
|
|
301
304
|
};
|
|
302
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Data contract — dump every row (memories/tasks/links/entities, archived
|
|
308
|
+
* included) of one workspace (`?workspace=`) or of every workspace, in the
|
|
309
|
+
* machine-readable `dsh-engram/export` v1 format that `POST /import`
|
|
310
|
+
* round-trips. Export is the stable, format-versioned exit door: the memory
|
|
311
|
+
* corpus never depends on engram's internal tables, and a thin adapter
|
|
312
|
+
* (another harness, MCP, a plain backup script) only needs this shape.
|
|
313
|
+
*/
|
|
314
|
+
const exportData = async (req, res) => {
|
|
315
|
+
const domain = await withDomain(res);
|
|
316
|
+
if (domain === void 0) return;
|
|
317
|
+
const workspace = readQuery(req).get("workspace") || void 0;
|
|
318
|
+
try {
|
|
319
|
+
writeJson(res, 200, domain.exportAll(workspace));
|
|
320
|
+
} catch (error) {
|
|
321
|
+
writeError(res, 500, String(error?.message ?? error));
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/** Data contract — restore a `dsh-engram/export` dump (see store.importAll:
|
|
326
|
+
* merge = idempotent add-missing, replace = wipe workspace then restore;
|
|
327
|
+
* `dryRun` plans without writing; `confirm: "restore"` is required for the
|
|
328
|
+
* destructive replace mode). Memory text passes the same secret redactor as
|
|
329
|
+
* every other write path. */
|
|
330
|
+
const importData = async (req, res) => {
|
|
331
|
+
const domain = await withDomain(res);
|
|
332
|
+
if (domain === void 0) return;
|
|
333
|
+
const body = await readJsonBody(req, MAX_IMPORT_BODY_BYTES);
|
|
334
|
+
if (body.error) return writeError(res, 400, body.error);
|
|
335
|
+
const { payload, mode, dryRun, workspace, confirm } = body.value ?? {};
|
|
336
|
+
if (mode !== "merge" && mode !== "replace") {
|
|
337
|
+
return writeError(res, 400, "mode must be 'merge' or 'replace'");
|
|
338
|
+
}
|
|
339
|
+
if (mode === "replace" && confirm !== "restore") {
|
|
340
|
+
return writeError(res, 400, "replace mode is destructive — pass confirm: \"restore\" to proceed");
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const report = await domain.importAll(payload, {
|
|
344
|
+
mode: mode ?? "merge",
|
|
345
|
+
dryRun: dryRun === true,
|
|
346
|
+
workspace: typeof workspace === "string" && workspace.length > 0 ? workspace : void 0,
|
|
347
|
+
config: service.config ?? {},
|
|
348
|
+
});
|
|
349
|
+
writeJson(res, 200, report);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const status = error instanceof Error && error.code === "ENGRAM_INVALID_ARGS" ? 400 : 500;
|
|
352
|
+
writeError(res, status, String(error?.message ?? error));
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
303
356
|
const guard = (req, res, method, handler) => {
|
|
304
357
|
if ((req.method ?? "GET").toUpperCase() !== method) {
|
|
305
358
|
writeError(res, 405, "method not allowed");
|
|
@@ -536,5 +589,7 @@ export function makeEngramRoutes(service) {
|
|
|
536
589
|
make("POST", "/memories/delete", remove),
|
|
537
590
|
make("POST", "/tasks/close", closeTask),
|
|
538
591
|
make("POST", "/gc", gcRun),
|
|
592
|
+
make("GET", "/export", exportData),
|
|
593
|
+
make("POST", "/import", importData),
|
|
539
594
|
];
|
|
540
595
|
}
|
package/lib/client.js
CHANGED
|
@@ -3341,6 +3341,7 @@ var import_react8 = require("react");
|
|
|
3341
3341
|
var import_jsx_runtime8 = require("react/jsx-runtime");
|
|
3342
3342
|
var COMMON_FIELDS = [
|
|
3343
3343
|
{ key: "autoCapture", label: "\u81EA\u52A8\u6355\u83B7", hint: "\u96F6 LLM \u4ECE\u5DE5\u5177\u7ED3\u679C\u63D0\u53D6\u8BB0\u5FC6\uFF08git/\u5173\u952E\u6587\u4EF6/\u9519\u8BEF\uFF09", kind: "bool" },
|
|
3344
|
+
{ key: "autoRecallOnStart", label: "\u4F1A\u8BDD\u542F\u52A8\u81EA\u52A8\u53EC\u56DE", hint: "\u65B0\u4F1A\u8BDD\u6309\u9996\u6761\u6D88\u606F\u81EA\u52A8\u628A\u6700\u76F8\u5173\u8BB0\u5FC6\u6CE8\u5165 [RECALL] \u5757\uFF08\u65E0\u9700\u6A21\u578B\u60F3\u8D77\u8C03 engram_recall\uFF09\uFF1B\u5173\u6389=\u56DE\u5230\u7EAF\u62C9\u53D6", kind: "bool" },
|
|
3344
3345
|
{ key: "maxRecallPerSession", label: "\u6BCF\u4F1A\u8BDD\u53BB\u91CD\u4E0A\u9650", hint: "recall \u6BCF\u4F1A\u8BDD\u6700\u591A\u4FDD\u7559 N \u6761\uFF08\u8DE8\u4F1A\u8BDD\u53BB\u91CD\uFF09", kind: "num", min: 1, max: 10 },
|
|
3345
3346
|
{ key: "expireDays", label: "TTL\uFF08\u5929\uFF09", hint: "0 = \u4E0D\u8FC7\u671F", kind: "num", min: 0, max: 3650 },
|
|
3346
3347
|
{ key: "indexMaxChars", label: "\u7D22\u5F15\u5B57\u7B26\u4E0A\u9650", hint: "[ENGRAM] \u5757 token \u9884\u7B97", kind: "num", min: 0, max: 4e3 }
|
|
@@ -3352,7 +3353,9 @@ var ADVANCED_GROUPS = [
|
|
|
3352
3353
|
description: "\u5176\u4F59\u6355\u83B7/\u641C\u7D22\u65CB\u94AE",
|
|
3353
3354
|
fields: [
|
|
3354
3355
|
{ key: "autoCapturePerSession", label: "\u6BCF\u4F1A\u8BDD\u6355\u83B7\u4E0A\u9650", hint: "\u5355\u4F1A\u8BDD\u81EA\u52A8\u6355\u83B7\u6761\u6570\u4E0A\u9650", kind: "num", min: 0, max: 1e3 },
|
|
3355
|
-
{ key: "
|
|
3356
|
+
{ key: "autoRecallLimit", label: "\u81EA\u52A8\u53EC\u56DE\u6761\u6570", hint: "[RECALL] \u5757\u6700\u591A\u6CE8\u5165\u7684\u8BB0\u5FC6\u6761\u6570\uFF08\u5C11\u800C\u51C6\uFF09", kind: "num", min: 1, max: 8 },
|
|
3357
|
+
{ key: "sessionSearch", label: "\u4F1A\u8BDD\u5386\u53F2\u641C\u7D22", hint: "engram_recall \u652F\u6301\u8DE8\u4F1A\u8BDD FTS \u515C\u5E95", kind: "bool" },
|
|
3358
|
+
{ key: "recallScope", label: "\u53EC\u56DE\u8303\u56F4", hint: "workspace=\u4E25\u683C\u5F53\u524D\u5DE5\u4F5C\u533A\u9694\u79BB\uFF08\u9ED8\u8BA4\uFF0Ctoken \u7EAA\u5F8B\uFF09\uFF1Bglobal=\u672C\u5DE5\u4F5C\u533A\u547D\u4E2D\u5F31/\u7A7A\u65F6\u4ECE\u5176\u5B83\u5DE5\u4F5C\u533A\u6C60\u53EC\u56DE\u5E76\u6807 [W:<workspace>] \u6765\u6E90\uFF08\u4F1A\u6253\u7834\u9694\u79BB\uFF09", kind: "select", options: ["workspace", "global"] }
|
|
3356
3359
|
]
|
|
3357
3360
|
},
|
|
3358
3361
|
{
|
|
@@ -3651,6 +3654,15 @@ function EngramConfigCard({ scope }) {
|
|
|
3651
3654
|
disabled: !writable,
|
|
3652
3655
|
onChange: (e) => setField(field.key, e.target.checked)
|
|
3653
3656
|
}
|
|
3657
|
+
) : field.kind === "select" ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
3658
|
+
"select",
|
|
3659
|
+
{
|
|
3660
|
+
style: { ...s3.input, width: field.width ?? 160 },
|
|
3661
|
+
value: raw === void 0 ? "" : String(raw),
|
|
3662
|
+
disabled: !writable,
|
|
3663
|
+
onChange: (e) => setField(field.key, e.target.value),
|
|
3664
|
+
children: (field.options ?? []).map((opt) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("option", { value: opt, children: opt }, opt))
|
|
3665
|
+
}
|
|
3654
3666
|
) : field.kind === "text" ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
3655
3667
|
"input",
|
|
3656
3668
|
{
|
package/lib/index-block.js
CHANGED
|
@@ -115,7 +115,7 @@ export function renderIndex(store, workspace, cwd, config) {
|
|
|
115
115
|
if (entityNames.length > 0) lines.push(`nodes: ${truncate(entityNames.join(", "), 72)}`);
|
|
116
116
|
for (const m of memories.slice(0, config.indexMaxLines)) lines.push(memoryLine(m, config));
|
|
117
117
|
for (const t of tasks.slice(0, 4)) lines.push(taskStateLine(t));
|
|
118
|
-
lines.push("drill:
|
|
118
|
+
lines.push("drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)");
|
|
119
119
|
return enforceCharCap(lines, config.indexMaxChars);
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -133,6 +133,97 @@ function enforceCharCap(lines, maxChars) {
|
|
|
133
133
|
return joined;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Text of the session's first user message (the opening instruction of the
|
|
137
|
+
* conversation). Walk `session.events` for the first `user/message` event and
|
|
138
|
+
* join its `data.content` text blocks; fall back to the first text-bearing
|
|
139
|
+
* event ("goal"/reminder start) guardedly. Returns "" when there is none.
|
|
140
|
+
* Pure, deterministic — the auto-recall (B) keys its query off this. */
|
|
141
|
+
export function firstUserMessageText(session) {
|
|
142
|
+
if (session === null || typeof session !== "object") return "";
|
|
143
|
+
const events = Array.isArray(session.events) ? session.events : [];
|
|
144
|
+
if (events.length === 0) return "";
|
|
145
|
+
const textOf = (data) => {
|
|
146
|
+
if (data === null || typeof data !== "object") return "";
|
|
147
|
+
const blocks = Array.isArray(data.content) ? data.content : [];
|
|
148
|
+
if (blocks.length > 0) {
|
|
149
|
+
return blocks
|
|
150
|
+
.map((b) => (b !== null && typeof b === "object" && typeof b.text === "string" ? b.text : ""))
|
|
151
|
+
.join(" ")
|
|
152
|
+
.trim();
|
|
153
|
+
}
|
|
154
|
+
return typeof data.text === "string" ? data.text.trim() : "";
|
|
155
|
+
};
|
|
156
|
+
for (const event of events) {
|
|
157
|
+
if (event === null || typeof event !== "object") continue;
|
|
158
|
+
if (event.type !== "user/message") continue;
|
|
159
|
+
const text = textOf(event.data);
|
|
160
|
+
if (text.length > 0) return text;
|
|
161
|
+
}
|
|
162
|
+
// Fallback: no user/message yet — first event carrying text (goal-driven,
|
|
163
|
+
// headless or reminder-seeded starts) still gives the recall a key.
|
|
164
|
+
for (const event of events) {
|
|
165
|
+
if (event === null || typeof event !== "object" || event.type === "user/message") continue;
|
|
166
|
+
const text = textOf(event.data);
|
|
167
|
+
if (text.length > 0) return text;
|
|
168
|
+
}
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Host-side session-start auto-recall (方案 B): at the first assembly of a new
|
|
174
|
+
* session, deterministically key a bounded BM25 recall off the session's first
|
|
175
|
+
* user message and inject the top hits as a `[RECALL]` block — so relevant
|
|
176
|
+
* memory reaches the model even when it never calls `engram_recall` on its own
|
|
177
|
+
* (the observed real-world gap). Pure rule, zero LLM, no `hits` bumps (a pure
|
|
178
|
+
* read). Falls back to the newest high-signal memories when the query is empty
|
|
179
|
+
* and `allowRecencyFallback` is on. Returns "" when nothing useful to show.
|
|
180
|
+
*/
|
|
181
|
+
export function renderStartRecall(store, workspace, config = {}, opts = {}) {
|
|
182
|
+
if (store === null || typeof store?.recall !== "function") return "";
|
|
183
|
+
const limit = Math.max(1, Math.min(8, Number.isFinite(config.autoRecallLimit) ? Math.trunc(config.autoRecallLimit) : 3));
|
|
184
|
+
const maxChars = Number.isFinite(config.autoRecallMaxChars) && config.autoRecallMaxChars > 0 ? Math.trunc(config.autoRecallMaxChars) : 700;
|
|
185
|
+
const rawQuery = String(opts.query ?? "").trim().slice(0, 300);
|
|
186
|
+
const query = rawQuery.length > 0 ? rawQuery : (opts.allowRecencyFallback ?? true) ? null : "";
|
|
187
|
+
const items = query === null
|
|
188
|
+
? (store.listMemories?.(workspace, limit) ?? []).slice(0, limit)
|
|
189
|
+
: store.recall(workspace, query, limit, { maxPerSession: limit });
|
|
190
|
+
const hits = Array.isArray(items) ? items.filter((m) => m !== null && m !== void 0) : [];
|
|
191
|
+
// "Few and precise" (agentmemory 候选①): never let a superseded stale row —
|
|
192
|
+
// which `recall` demotes to the tail of an unbounded list — occupy one of the
|
|
193
|
+
// ≤ N auto-injected slots. Same policy as the [ENGRAM] index. The stale row
|
|
194
|
+
// stays re-fetchable via engram_detail, just not auto-pushed.
|
|
195
|
+
const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
|
|
196
|
+
const fresh = hits.filter((m) => !supersededBy.has(m.id));
|
|
197
|
+
if (fresh.length === 0) return "";
|
|
198
|
+
|
|
199
|
+
const lines = [];
|
|
200
|
+
const source = query === null ? "newest memory" : "recall";
|
|
201
|
+
lines.push(`[RECALL] ${source} · ${fresh.length} hit(s)${query ? ` · first msg: ${escapeLt(truncate(query, 40))}` : " · no user text yet"}`);
|
|
202
|
+
for (const m of fresh) {
|
|
203
|
+
const px = prefixFor(m.kind);
|
|
204
|
+
const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
|
|
205
|
+
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
206
|
+
const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), 140);
|
|
207
|
+
lines.push(`- [${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
|
|
208
|
+
}
|
|
209
|
+
lines.push("context: use above · engram_detail <id> · engram_recall <query>");
|
|
210
|
+
return enforceStartRecallCap(lines, maxChars);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Keep the [RECALL] header + context tail, drop hits until under budget;
|
|
214
|
+
* hard-truncate with an ellipsis when even the shell exceeds the cap. */
|
|
215
|
+
function enforceStartRecallCap(lines, maxChars) {
|
|
216
|
+
let joined = lines.join("\n");
|
|
217
|
+
while (joined.length > maxChars && lines.length > 2) {
|
|
218
|
+
lines.splice(lines.length - 2, 1);
|
|
219
|
+
joined = lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
if (joined.length > maxChars) {
|
|
222
|
+
joined = `${joined.slice(0, Math.max(1, maxChars - 1))}…`;
|
|
223
|
+
}
|
|
224
|
+
return joined;
|
|
225
|
+
}
|
|
226
|
+
|
|
136
227
|
/**
|
|
137
228
|
* One-line escalation reminder from an arbitrary balance view
|
|
138
229
|
* ({ memCalls, esrCalls, days }). Surfaces the workspace real recent
|
package/lib/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { openEngramDomain } from "./store.js";
|
|
|
23
23
|
import { registerTools } from "./tools.js";
|
|
24
24
|
import { makeCaptureHandler } from "./capture.js";
|
|
25
25
|
import { installGoalCapture } from "./goal-capture.js";
|
|
26
|
-
import { renderIndex, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
26
|
+
import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
27
27
|
import { makeTriggerRecorder } from "./trigger.js";
|
|
28
28
|
import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
|
|
29
29
|
import { installEngramSettings } from "./settings.js";
|
|
@@ -48,9 +48,25 @@ const DEFAULTS = {
|
|
|
48
48
|
autoCaptureGlobalCap: 500,
|
|
49
49
|
// recall
|
|
50
50
|
sessionSearch: true,
|
|
51
|
+
// opt-in cross-workspace recall ("workspace" = current default, strict
|
|
52
|
+
// per-workspace isolation; "global" = when the local pool has weak/no hits,
|
|
53
|
+
// also rank entries from other workspaces' pools with a recency/origin-aware
|
|
54
|
+
// penalty and surface a [W:<workspace>] origin marker). Token-discipline
|
|
55
|
+
// default is preserved; this is strictly an enable knob.
|
|
56
|
+
recallScope: "workspace",
|
|
51
57
|
// session-diversified recall: max memories per session before cross-session
|
|
52
58
|
// evidence takes over (agentmemory 候选①; "few and precise")
|
|
53
59
|
maxRecallPerSession: 3,
|
|
60
|
+
// Host-side session-start auto-recall (方案 B): at the FIRST assembly of a
|
|
61
|
+
// new session, key a bounded BM25 recall off the session's first user message
|
|
62
|
+
// and inject the top hits as a [RECALL] block — relevant memory reaches the
|
|
63
|
+
// model even when it never calls engram_recall on its own (the observed gap:
|
|
64
|
+
// pull-only recall left everything to model initiative and almost never
|
|
65
|
+
// fired). Pure rule, zero LLM, per-session frozen with the [ENGRAM] block.
|
|
66
|
+
// "few and precise" — agentmemory 候选①'s lesson from the coding leaderboard.
|
|
67
|
+
autoRecallOnStart: true,
|
|
68
|
+
autoRecallLimit: 3,
|
|
69
|
+
autoRecallMaxChars: 700,
|
|
54
70
|
// [ENGRAM] index budget (token discipline)
|
|
55
71
|
indexMaxLines: 12,
|
|
56
72
|
indexMaxChars: 700,
|
|
@@ -282,12 +298,32 @@ export function apply(ctx, config) {
|
|
|
282
298
|
return "";
|
|
283
299
|
}
|
|
284
300
|
let block = "";
|
|
301
|
+
let pendingRecall = false;
|
|
285
302
|
try {
|
|
286
303
|
const workspace = resolvePath(cwd);
|
|
287
304
|
block = renderIndex(opened, workspace, cwd, resolved);
|
|
305
|
+
// Host-side session-start auto-recall (方案 B): append the [RECALL]
|
|
306
|
+
// block keyed on the session's FIRST user message. The cache only freezes
|
|
307
|
+
// once there is a query to key on — if the user text isn't in the events
|
|
308
|
+
// yet (exotic pre-message assemblies), return the index alone and let the
|
|
309
|
+
// next assembly retry, so a new conversation never permanently misses its
|
|
310
|
+
// recalled memory.
|
|
311
|
+
if (resolved.autoRecallOnStart !== false) {
|
|
312
|
+
const query = firstUserMessageText(session);
|
|
313
|
+
if (query.length === 0) {
|
|
314
|
+
pendingRecall = true;
|
|
315
|
+
} else {
|
|
316
|
+
const recall = renderStartRecall(opened, workspace, resolved, { query, allowRecencyFallback: false });
|
|
317
|
+
block = recall.length > 0 ? `${block}\n${recall}`.trim() : block;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
288
320
|
} catch (error) {
|
|
289
321
|
log?.warn?.(`engram index render failed: ${String(error)}`);
|
|
290
322
|
}
|
|
323
|
+
// Never cache a render that skipped auto-recall for lack of an opening
|
|
324
|
+
// message — the next assembly will have it and can still materialize the
|
|
325
|
+
// [RECALL] block.
|
|
326
|
+
if (pendingRecall) return block;
|
|
291
327
|
sessionCache.set(session, block);
|
|
292
328
|
return block;
|
|
293
329
|
};
|
package/lib/settings.js
CHANGED
|
@@ -22,6 +22,9 @@ export const SETTINGS_NS = settingsNamespace(SETTINGS_NS_NAME);
|
|
|
22
22
|
export const SETTINGS_KEYS = [
|
|
23
23
|
"autoCapture",
|
|
24
24
|
"sessionSearch",
|
|
25
|
+
"recallScope",
|
|
26
|
+
"autoRecallOnStart",
|
|
27
|
+
"autoRecallLimit",
|
|
25
28
|
"maxRecallPerSession",
|
|
26
29
|
"autoCapturePerSession",
|
|
27
30
|
"indexMaxLines",
|
|
@@ -55,6 +58,9 @@ export function makeSettingsSchema(defaults) {
|
|
|
55
58
|
return z.object({
|
|
56
59
|
autoCapture: z.boolean().default(defaults.autoCapture),
|
|
57
60
|
sessionSearch: z.boolean().default(defaults.sessionSearch),
|
|
61
|
+
recallScope: z.union(["workspace", "global"]).default(defaults.recallScope ?? "workspace").description("召回范围:workspace=严格按当前工作区隔离(默认、token 纪律);global=本工作区命中弱/空时,从其它工作区池按 recency/origin 加权召回并标 [W:<workspace>] 来源。开启会打破跨工作区隔离,请按需选用"),
|
|
62
|
+
autoRecallOnStart: z.boolean().default(defaults.autoRecallOnStart).description("新会话开始时,宿主按首条消息对工作区做确定性召回,把命中记忆直接注入 [RECALL] 块(无需模型自己想起调 engram_recall;关掉=回到纯拉取)"),
|
|
63
|
+
autoRecallLimit: z.natural().min(1).max(8).default(defaults.autoRecallLimit).description("[RECALL] 块最多注入的记忆条数(agentmemory 候选①:少而准)"),
|
|
58
64
|
maxRecallPerSession: z.natural().min(1).max(10).default(defaults.maxRecallPerSession),
|
|
59
65
|
autoCapturePerSession: z.natural().max(1000).default(defaults.autoCapturePerSession),
|
|
60
66
|
indexMaxLines: z.natural().max(50).default(defaults.indexMaxLines),
|