dsh-engramory 0.2.1 → 0.2.2
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 +124 -117
- package/README.zh-CN.md +111 -0
- package/index.js +417 -327
- package/package.json +56 -56
package/README.md
CHANGED
|
@@ -1,117 +1,124 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
the
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
|
72
|
-
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
|
79
|
-
|
|
80
|
-
|
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
- **
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
1
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
2
|
+
|
|
3
|
+
# dsh-engramory
|
|
4
|
+
|
|
5
|
+
[](https://unstone.github.io/dsh-xray/registry.html#tinqiao-oss__engramory)
|
|
6
|
+
|
|
7
|
+
<sub>`C2` is the level `manifest.bundle.patch` puts every mountable dsh plugin at (74.6% of the scanned ecosystem); it is the only flag on this card — no `exec`, `eval`, install script, outbound domain, or environment read.</sub>
|
|
8
|
+
|
|
9
|
+
Curated, file-based long-term memory for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) —
|
|
10
|
+
plain markdown notes you can open with anything, one store shared across every host you
|
|
11
|
+
use, and an index cap that is a **real refusal** rather than a request. (If the store
|
|
12
|
+
lives inside a project's git repo it must be git-ignored — memories carry machine-local
|
|
13
|
+
detail; see SKILL.md §1. A *dedicated private* repo for the store itself is fine.)
|
|
14
|
+
|
|
15
|
+
Part of [Engramory](https://github.com/tinqiao-oss/engramory).
|
|
16
|
+
|
|
17
|
+
## Why a plugin, when an `AGENTS.md` block already works
|
|
18
|
+
|
|
19
|
+
The block does carry the discipline, and for recall that is enough. Two things it cannot
|
|
20
|
+
do:
|
|
21
|
+
|
|
22
|
+
**The cap becomes deterministic.** Engramory keeps its index under 200 lines / 25 KB
|
|
23
|
+
because the index loads every session and everything past the cap silently stops being
|
|
24
|
+
recalled. On most hosts that limit is rules plus a checker the agent has to remember to
|
|
25
|
+
run. dsh exposes `ctx.tools.guard()` — a synchronous, **monotonic** refusal: once a guard
|
|
26
|
+
returns a reason, no later listener can turn it back into an allow. So here the limit is
|
|
27
|
+
enforced, not requested. Use **0.2.1 or later**: 0.2.0 shipped before profile installs
|
|
28
|
+
worked upstream and carried an `inject` declaration this Cordis does not accept — it
|
|
29
|
+
installed but never activated (issue #8). Outside Claude Code, dsh is the first host with
|
|
30
|
+
the shim actually written (a few others expose equivalent pre-write seams — see
|
|
31
|
+
PORTING.md — but have no shim yet).
|
|
32
|
+
|
|
33
|
+
**The protocol arrives with the plugin.** The skill is registered at runtime through
|
|
34
|
+
dsh's skill registry — via a reactive `ctx.inject(['skills'], …)` child, so a profile
|
|
35
|
+
without a registry still gets the cap and one whose registry activates late still gets
|
|
36
|
+
the skill — which means it does not depend on landing files in one of the five skill
|
|
37
|
+
roots dsh scans, a path that is easy to get subtly wrong and fails silently when you do.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
dsh plugin --profile <name> add dsh-engramory
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That is the whole install. The package ships a `dsh.bundle` manifest pointing at its own
|
|
46
|
+
`cordis.patch.yml`, so the row is inserted into the profile's plugin tree for you — no
|
|
47
|
+
hand-editing. To change a default, patch the EXISTING row by id in your profile's own
|
|
48
|
+
patch layer — do **not** use a second `- insert:` (insert always appends, so you would
|
|
49
|
+
end up with two engramory rows and the original caps still enforced):
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
- id: engramory
|
|
53
|
+
config:
|
|
54
|
+
indexName: MEMORY.md
|
|
55
|
+
maxLines: 200
|
|
56
|
+
maxBytes: 25600
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A patch replaces the targeted row's whole `config`, so list every key you mean to keep.
|
|
60
|
+
|
|
61
|
+
For the store itself and the always-on block, use the installer in the Engramory repo
|
|
62
|
+
(`python tools/engramory_init.py dsh --install-skill`). This plugin enforces the cap and
|
|
63
|
+
supplies the protocol; it does not create the store.
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
| Field | Default | Meaning |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `indexName` | `MEMORY.md` | Basename treated as the memory index (case-insensitive; both `file_path` and `str_replace_editor`'s `path` are checked). An empty or non-string value falls back to the default. Nothing else is inspected. |
|
|
70
|
+
| `maxLines` | `200` | Hard line cap. Non-positive or non-finite values fall back to the default. |
|
|
71
|
+
| `maxBytes` | `25600` | Hard UTF-8 byte cap (25 KB). |
|
|
72
|
+
| `indexPath` | unset | Absolute path of the ONE index to guard. Without it the guard matches on basename alone, so an unrelated `MEMORY.md` in another project is gated too; set this and only that exact file is. Compared by identity (symlinks and `..` resolved; case-folded on Windows only), and a path that does not exist yet still has its first write guarded. |
|
|
73
|
+
| `registerSkill` | `true` | Set `false` to keep the cap but skip the runtime skill. |
|
|
74
|
+
| `skill` | built-in | Replace the skill body with your own markdown. |
|
|
75
|
+
|
|
76
|
+
## What the guard actually does
|
|
77
|
+
|
|
78
|
+
| Call | Decision |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `write` (or `str_replace_editor` `create`) ending within caps | allow |
|
|
81
|
+
| `write`/`create` that GROWS the index past a cap | **deny**, naming the numbers and what to compact |
|
|
82
|
+
| `write`/`create` that SHRINKS or keeps an over-cap index | allow — incremental compaction (210 → 205 → 198) must stay possible |
|
|
83
|
+
| Edit carrying `old_str`/`new_str`: result simulated | judged by the RESULT, same grow/shrink rule as a write |
|
|
84
|
+
| Unsimulable partial (e.g. `insert`) on an over-cap index | **deny**, telling the agent a shrinking whole-file write passes |
|
|
85
|
+
| Unsimulable partial on a healthy index | allow |
|
|
86
|
+
| `read`, `view`, or any unknown tool | allow — recall must never be blocked, even over cap |
|
|
87
|
+
| Any write to any other file | allow — not the guard's business |
|
|
88
|
+
| Missing/unreadable current index on a whole-file write | treated as EMPTY (mirrors the Python guard) — a within-caps write passes, an over-cap first write is refused |
|
|
89
|
+
| Missing/unreadable index on a partial edit | allow — a guard must never block work over a path it cannot read |
|
|
90
|
+
| Malformed execution (no arguments, non-string path/content) | allow — not recognisably a write |
|
|
91
|
+
|
|
92
|
+
The deny rule mirrors `hooks/engramory_index_guard.py` exactly: refuse only a result
|
|
93
|
+
that is over a cap AND grew past the current file. The line count ignores a trailing
|
|
94
|
+
newline, so an index sitting exactly at the cap stays writable.
|
|
95
|
+
|
|
96
|
+
## Known limits
|
|
97
|
+
|
|
98
|
+
- **An unsimulable partial that crosses the cap from under it is not caught.** `insert`
|
|
99
|
+
and friends do not carry the resulting text. The breach is caught by the next
|
|
100
|
+
whole-file write, or by `engramory_check.py`. What is guaranteed: an already-over
|
|
101
|
+
index cannot be grown further, and a shrinking write always passes.
|
|
102
|
+
- **The tool roster follows dsh's documented tool-fs contract** (`write`/`edit` with
|
|
103
|
+
`file_path`, `str_replace_editor` with `path`) and is deliberately conservative:
|
|
104
|
+
unknown tools pass.
|
|
105
|
+
- **0.2.0 never activated; 0.2.1 is the first version that runs.** While the rc.6
|
|
106
|
+
preview bug blocked all third-party profile installs upstream, the only coverage a
|
|
107
|
+
plugin could have was mocks — and 0.2.0's mock hid two activation bugs: an
|
|
108
|
+
older-Cordis `{ required, optional }` inject shape (read as waiting for services
|
|
109
|
+
literally named `required`/`optional`, pending forever) and a bare `ctx.skills` read
|
|
110
|
+
of an undeclared service (throws under Cordis' reflective context). The first field
|
|
111
|
+
install caught both the moment installs became possible (issue #8). 0.2.1 fixes them,
|
|
112
|
+
verified end to end on a live dsh 0.1.0-rc.7 web profile (0.2.0 reproduces the boot
|
|
113
|
+
failure byte for byte; 0.2.1 boots and serves) and against the vendored
|
|
114
|
+
`@deepseek-ai/cordis` resolver (activation with, without, and with a late-mounted
|
|
115
|
+
skill registry). The test mock now mirrors the reflective-context access rules, and
|
|
116
|
+
the guard's decision table stays covered by `node --test` (28 cases, run in
|
|
117
|
+
Engramory's CI).
|
|
118
|
+
- dsh is a developer preview and its plugin API can change. This plugin deliberately
|
|
119
|
+
touches only `ctx.tools.guard()` and a reactive `ctx.inject(['skills'], …)` child
|
|
120
|
+
that registers the skill, so it stays cheap to fix.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT — see the [Engramory repository](https://github.com/tinqiao-oss/engramory).
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
[English](README.md) | **简体中文**
|
|
2
|
+
|
|
3
|
+
# dsh-engramory
|
|
4
|
+
|
|
5
|
+
[](https://unstone.github.io/dsh-xray/registry.html#tinqiao-oss__engramory)
|
|
6
|
+
|
|
7
|
+
<sub>`C2` 是 `manifest.bundle.patch` 给每个可挂载 dsh 插件定的档位(被扫描生态里 74.6% 都在这一档);它是这张卡片上**唯一**的标记 —— 没有 `exec`、没有 `eval`、没有安装脚本、没有外连域名、不读环境变量。</sub>
|
|
8
|
+
|
|
9
|
+
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 用的**策展式、文件式**长期记忆 ——
|
|
10
|
+
纯 markdown 笔记,随便什么工具都能打开;一个记忆库被你用的所有宿主共用;索引上限是一次
|
|
11
|
+
**真正的拒绝**,而不是一句请求。(记忆库若位于某个项目的 git 仓库内,必须 git-ignore ——
|
|
12
|
+
记忆里带着本机独有的细节,见 SKILL.md §1。给记忆库单独开一个*私有仓*则完全可以。)
|
|
13
|
+
|
|
14
|
+
隶属于 [Engramory](https://github.com/tinqiao-oss/engramory)。
|
|
15
|
+
|
|
16
|
+
## 已经有 `AGENTS.md` 常驻块了,为什么还要插件
|
|
17
|
+
|
|
18
|
+
那个常驻块确实承载了纪律,单论召回也够用了。但有两件事它做不到:
|
|
19
|
+
|
|
20
|
+
**上限从此是确定性的。** Engramory 把索引控制在 200 行 / 25 KB 以内,是因为索引每次会话
|
|
21
|
+
都要加载,超出的部分会**悄无声息地不再被召回**。在多数宿主上,这个限制只是"规则 + 一个
|
|
22
|
+
要靠 agent 自己记得去跑的校验器"。而 dsh 暴露了 `ctx.tools.guard()` —— 一个同步的、
|
|
23
|
+
**单调**的否决:守卫一旦返回理由,后面任何监听器都无法把它翻回放行。所以在这里,上限是被
|
|
24
|
+
**强制执行**的,不是被请求的。请用 **0.2.1 或更高版本**:0.2.0 发布时上游的 profile 安装还
|
|
25
|
+
没打通,而且它的 `inject` 声明是这版 Cordis 不接受的写法 —— 装得上,但**永远激活不了**
|
|
26
|
+
(issue #8)。在 Claude Code 之外,dsh 是第一个真正把这层垫片写出来的宿主(另有几个宿主
|
|
27
|
+
同样暴露了等价的写前接缝,见 PORTING.md,但还没有垫片实现)。
|
|
28
|
+
|
|
29
|
+
**协议随插件一起到达。** skill 是在运行时通过 dsh 的 skill 注册表注册的 —— 走一个响应式的
|
|
30
|
+
`ctx.inject(['skills'], …)` 子上下文,所以没有注册表的 profile 照样拿到上限,而注册表晚一步
|
|
31
|
+
挂载的 profile 也照样拿得到 skill。这意味着它**不依赖**把文件放进 dsh 扫描的那五个 skill 根
|
|
32
|
+
目录之一 —— 那条路很容易在细微处放错,而一旦放错就是**静默失败**。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
dsh plugin --profile <名字> add dsh-engramory
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
装到这里就完了。这个包自带 `dsh.bundle` manifest 指向它自己的 `cordis.patch.yml`,所以那一行
|
|
41
|
+
会被自动插进 profile 的插件树里 —— 不需要手改配置。要改默认值,请在你自己 profile 的 patch
|
|
42
|
+
层里**按 id 修改那一行已存在的配置**,**不要**再写一个 `- insert:`(insert 永远是追加,你会得到
|
|
43
|
+
两行 engramory,而原来那行的上限依旧在生效):
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
- id: engramory
|
|
47
|
+
config:
|
|
48
|
+
indexName: MEMORY.md
|
|
49
|
+
maxLines: 200
|
|
50
|
+
maxBytes: 25600
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
一次 patch 会**整体替换**目标行的 `config`,所以你想保留的键要逐个列全。
|
|
54
|
+
|
|
55
|
+
记忆库本身和常驻块,请用 Engramory 仓库里的安装器
|
|
56
|
+
(`python tools/engramory_init.py dsh --install-skill`)。本插件负责强制上限、提供协议;
|
|
57
|
+
**它不创建记忆库**。
|
|
58
|
+
|
|
59
|
+
## 配置
|
|
60
|
+
|
|
61
|
+
| 字段 | 默认值 | 含义 |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `indexName` | `MEMORY.md` | 被当作记忆索引的文件名(不区分大小写;`file_path` 与 `str_replace_editor` 的 `path` 都会检查)。空值或非字符串会回退到默认值。除此之外不检查任何东西。 |
|
|
64
|
+
| `maxLines` | `200` | 硬性行数上限。非正数或非有限值回退到默认值。 |
|
|
65
|
+
| `maxBytes` | `25600` | 硬性 UTF-8 字节上限(25 KB)。 |
|
|
66
|
+
| `indexPath` | 未设置 | 要守护的**那一个**索引的绝对路径。不设它时,守卫只按文件名匹配,于是**别的项目里一个无关的 `MEMORY.md` 也会被拦**;设了它就只有这个确切的文件会被守。比较走的是身份而非拼写(symlink 与 `..` 都会解析;仅在 Windows 上折叠大小写),而且即使文件尚不存在,它的第一次写入照样受守护。 |
|
|
67
|
+
| `registerSkill` | `true` | 设为 `false` 则保留上限、但跳过运行时 skill。 |
|
|
68
|
+
| `skill` | 内置 | 用你自己的 markdown 替换 skill 正文。 |
|
|
69
|
+
|
|
70
|
+
## 守卫到底做了什么
|
|
71
|
+
|
|
72
|
+
| 调用 | 判决 |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `write`(或 `str_replace_editor` 的 `create`)且结果在上限内 | 放行 |
|
|
75
|
+
| `write`/`create` 会让索引**增长**并越过上限 | **拒绝**,并报出具体数字和该压缩什么 |
|
|
76
|
+
| `write`/`create` 让一个已超限的索引**缩小或持平** | 放行 —— 增量压缩(210 → 205 → 198)必须走得通 |
|
|
77
|
+
| 带 `old_str`/`new_str` 的 edit:结果可模拟 | 按**结果**判定,增长/缩小规则与整文件写入相同 |
|
|
78
|
+
| 无法模拟的部分写入(如 `insert`)落在已超限的索引上 | **拒绝**,并告诉 agent:缩小的整文件写入是放行的 |
|
|
79
|
+
| 无法模拟的部分写入落在健康的索引上 | 放行 |
|
|
80
|
+
| `read`、`view`,以及任何未知工具 | 放行 —— 哪怕已超限,召回也绝不能被挡住 |
|
|
81
|
+
| 对任何其他文件的写入 | 放行 —— 不关守卫的事 |
|
|
82
|
+
| 整文件写入时当前索引缺失/读不到 | 视为**空**(与 Python 版守卫一致)—— 上限内的写入放行,而首次就超限的写入被拒 |
|
|
83
|
+
| 部分编辑时索引缺失/读不到 | 放行 —— 守卫绝不能因为一个自己读不了的路径而挡住工作 |
|
|
84
|
+
| 畸形的执行(没有 arguments、路径/内容不是字符串) | 放行 —— 它压根不像一次写入 |
|
|
85
|
+
|
|
86
|
+
拒绝规则与 `hooks/engramory_index_guard.py` **完全一致**:只拒绝那种结果既超过上限、
|
|
87
|
+
又比当前文件更大的写入。行数统计忽略结尾换行,所以一个正好卡在上限的索引仍然可写。
|
|
88
|
+
|
|
89
|
+
## 已知限制
|
|
90
|
+
|
|
91
|
+
- **从上限之下一跃越过上限的、无法模拟的部分写入,抓不住。** `insert` 这类调用并不携带
|
|
92
|
+
写入后的文本。这种越界会由下一次整文件写入、或 `engramory_check.py` 抓到。能保证的是:
|
|
93
|
+
一个**已经超限**的索引不会被继续撑大,而缩小的写入永远放行。
|
|
94
|
+
- **工具名单遵循 dsh 文档化的 tool-fs 契约**(`write`/`edit` 用 `file_path`,
|
|
95
|
+
`str_replace_editor` 用 `path`),并且刻意保守:未知工具一律放行。
|
|
96
|
+
- **0.2.0 从未激活成功;0.2.1 才是第一个真正跑得起来的版本。** 在 rc.6 预览版那个 bug
|
|
97
|
+
堵住上游所有第三方 profile 安装期间,插件能有的覆盖只有 mock —— 而 0.2.0 的 mock 恰好
|
|
98
|
+
掩盖了两个激活期 bug:一个是旧版 Cordis 的 `{ required, optional }` inject 写法(被解读成
|
|
99
|
+
在等待名字就叫 `required`/`optional` 的服务,于是永远 pending),另一个是裸读未声明的
|
|
100
|
+
`ctx.skills`(在 Cordis 的反射式上下文下会直接抛错)。安装一旦成为可能,**首个真实安装
|
|
101
|
+
当场把两个都暴露了**(issue #8)。0.2.1 修掉了它们,并在一个真跑着的 dsh 0.1.0-rc.7 web
|
|
102
|
+
profile 上端到端验证过(0.2.0 逐字节复现启动失败;0.2.1 能启动并提供服务),也对着 dsh
|
|
103
|
+
内置的 `@deepseek-ai/cordis` 解析器验过(注册表存在、不存在、以及晚挂载三种情况下的激活)。
|
|
104
|
+
测试用的 mock 现在会照着反射式上下文的访问规则来,守卫的判决表则由 `node --test`
|
|
105
|
+
持续覆盖(28 个用例,在 Engramory 的 CI 里跑)。
|
|
106
|
+
- dsh 目前是开发者预览版,插件 API 可能变化。本插件刻意只碰 `ctx.tools.guard()` 和一个
|
|
107
|
+
注册 skill 的响应式 `ctx.inject(['skills'], …)` 子上下文,所以要跟着改也很便宜。
|
|
108
|
+
|
|
109
|
+
## 许可
|
|
110
|
+
|
|
111
|
+
MIT —— 见 [Engramory 仓库](https://github.com/tinqiao-oss/engramory)。
|
package/index.js
CHANGED
|
@@ -1,327 +1,417 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-engramory — the Engramory memory discipline as a DeepSeek Harness plugin.
|
|
3
|
-
*
|
|
4
|
-
* Two things the always-on AGENTS.md block cannot do on its own:
|
|
5
|
-
*
|
|
6
|
-
* 1. A DETERMINISTIC index cap. dsh's `ctx.tools.guard()` is a synchronous,
|
|
7
|
-
* monotonic refusal — once a guard returns a reason, no later waterfall
|
|
8
|
-
* listener can turn it back into an allow. That makes it a stronger seam than
|
|
9
|
-
* most hosts expose, and it is why the 200-line / 25 KB limit can be enforced
|
|
10
|
-
* here rather than merely asked for. Without a shim like this one, everywhere
|
|
11
|
-
* except Claude Code the cap degrades to "rules plus a checker the agent has to
|
|
12
|
-
* remember to run".
|
|
13
|
-
* 2. Skill delivery that does not depend on install paths. Registering at runtime
|
|
14
|
-
* sidesteps the five-root scan entirely, so the protocol is present because the
|
|
15
|
-
* plugin is loaded, not because a directory happened to be right.
|
|
16
|
-
*
|
|
17
|
-
* The decision table mirrors hooks/engramory_index_guard.py: deny only a write that
|
|
18
|
-
* ENDS over a cap AND grew past the current file — a shrinking rewrite always passes,
|
|
19
|
-
* so an over-cap index can be compacted incrementally (210 → 205 → 198). Only known
|
|
20
|
-
* mutating tools are gated; `read` and unknown tools are never refused (the first cut
|
|
21
|
-
* gated everything that named the index, which blocked recall of an over-cap index at
|
|
22
|
-
* exactly the moment compaction was needed).
|
|
23
|
-
*
|
|
24
|
-
* Zero dependencies, no build step: plain ESM, node: builtins only.
|
|
25
|
-
*/
|
|
26
|
-
import { readFileSync } from 'node:fs'
|
|
27
|
-
import { basename } from 'node:path'
|
|
28
|
-
|
|
29
|
-
export const name = 'engramory'
|
|
30
|
-
|
|
31
|
-
// `tools` carries the cap and is the plugin's only declared dependency. Cordis reads
|
|
32
|
-
// `inject` as an array of service names (or an object KEYED by them) and treats every
|
|
33
|
-
// entry as a hard wait — the `{ required, optional }` shape is older-Cordis syntax that
|
|
34
|
-
// the build dsh vendors resolves as services literally named "required"/"optional",
|
|
35
|
-
// which never appear, so the plugin sat pending forever (issue #8). `skills` is
|
|
36
|
-
// deliberately NOT declared: the cap must mount even on a profile with no skill
|
|
37
|
-
// registry, so apply() probes it at runtime instead.
|
|
38
|
-
export const inject = ['tools']
|
|
39
|
-
|
|
40
|
-
/** Mirrors hooks/engramory_index_guard.py — the caps are the protocol's, not this port's. */
|
|
41
|
-
const DEFAULT_INDEX_NAME = 'MEMORY.md'
|
|
42
|
-
const DEFAULT_MAX_LINES = 200
|
|
43
|
-
const DEFAULT_MAX_BYTES = 25600
|
|
44
|
-
|
|
45
|
-
/** Tools that replace a file wholesale, so the post-write text is in the arguments. */
|
|
46
|
-
const WHOLE_FILE_WRITES = new Set(['write'])
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Tools that mutate a file in place. Only tools named here (plus
|
|
50
|
-
* `str_replace_editor`'s write commands, handled explicitly) count as partial
|
|
51
|
-
* writes. Unknown tools pass through: this guard is a discipline rail, not a
|
|
52
|
-
* security boundary (SECURITY.md), and a false refusal of `read` costs more than a
|
|
53
|
-
* miss — the next whole-file write is measured exactly regardless.
|
|
54
|
-
*/
|
|
55
|
-
const PARTIAL_WRITES = new Set(['edit', 'str_replace', 'insert'])
|
|
56
|
-
|
|
57
|
-
export function apply(ctx, config = {}) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
`
|
|
183
|
-
`
|
|
184
|
-
`
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-engramory — the Engramory memory discipline as a DeepSeek Harness plugin.
|
|
3
|
+
*
|
|
4
|
+
* Two things the always-on AGENTS.md block cannot do on its own:
|
|
5
|
+
*
|
|
6
|
+
* 1. A DETERMINISTIC index cap. dsh's `ctx.tools.guard()` is a synchronous,
|
|
7
|
+
* monotonic refusal — once a guard returns a reason, no later waterfall
|
|
8
|
+
* listener can turn it back into an allow. That makes it a stronger seam than
|
|
9
|
+
* most hosts expose, and it is why the 200-line / 25 KB limit can be enforced
|
|
10
|
+
* here rather than merely asked for. Without a shim like this one, everywhere
|
|
11
|
+
* except Claude Code the cap degrades to "rules plus a checker the agent has to
|
|
12
|
+
* remember to run".
|
|
13
|
+
* 2. Skill delivery that does not depend on install paths. Registering at runtime
|
|
14
|
+
* sidesteps the five-root scan entirely, so the protocol is present because the
|
|
15
|
+
* plugin is loaded, not because a directory happened to be right.
|
|
16
|
+
*
|
|
17
|
+
* The decision table mirrors hooks/engramory_index_guard.py: deny only a write that
|
|
18
|
+
* ENDS over a cap AND grew past the current file — a shrinking rewrite always passes,
|
|
19
|
+
* so an over-cap index can be compacted incrementally (210 → 205 → 198). Only known
|
|
20
|
+
* mutating tools are gated; `read` and unknown tools are never refused (the first cut
|
|
21
|
+
* gated everything that named the index, which blocked recall of an over-cap index at
|
|
22
|
+
* exactly the moment compaction was needed).
|
|
23
|
+
*
|
|
24
|
+
* Zero dependencies, no build step: plain ESM, node: builtins only.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
27
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
export const name = 'engramory'
|
|
30
|
+
|
|
31
|
+
// `tools` carries the cap and is the plugin's only declared dependency. Cordis reads
|
|
32
|
+
// `inject` as an array of service names (or an object KEYED by them) and treats every
|
|
33
|
+
// entry as a hard wait — the `{ required, optional }` shape is older-Cordis syntax that
|
|
34
|
+
// the build dsh vendors resolves as services literally named "required"/"optional",
|
|
35
|
+
// which never appear, so the plugin sat pending forever (issue #8). `skills` is
|
|
36
|
+
// deliberately NOT declared: the cap must mount even on a profile with no skill
|
|
37
|
+
// registry, so apply() probes it at runtime instead.
|
|
38
|
+
export const inject = ['tools']
|
|
39
|
+
|
|
40
|
+
/** Mirrors hooks/engramory_index_guard.py — the caps are the protocol's, not this port's. */
|
|
41
|
+
const DEFAULT_INDEX_NAME = 'MEMORY.md'
|
|
42
|
+
const DEFAULT_MAX_LINES = 200
|
|
43
|
+
const DEFAULT_MAX_BYTES = 25600
|
|
44
|
+
|
|
45
|
+
/** Tools that replace a file wholesale, so the post-write text is in the arguments. */
|
|
46
|
+
const WHOLE_FILE_WRITES = new Set(['write'])
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tools that mutate a file in place. Only tools named here (plus
|
|
50
|
+
* `str_replace_editor`'s write commands, handled explicitly) count as partial
|
|
51
|
+
* writes. Unknown tools pass through: this guard is a discipline rail, not a
|
|
52
|
+
* security boundary (SECURITY.md), and a false refusal of `read` costs more than a
|
|
53
|
+
* miss — the next whole-file write is measured exactly regardless.
|
|
54
|
+
*/
|
|
55
|
+
const PARTIAL_WRITES = new Set(['edit', 'str_replace', 'insert'])
|
|
56
|
+
|
|
57
|
+
export function apply(ctx, config = {}) {
|
|
58
|
+
// `indexPath` pins the guard to ONE file. Without it the only signal is the
|
|
59
|
+
// basename, so an unrelated MEMORY.md in any other project is gated too — a real
|
|
60
|
+
// refusal on a file that is not a memory index at all, with no way to opt out
|
|
61
|
+
// (renaming `indexName` would just unguard the real index). The Claude Code hook
|
|
62
|
+
// has had ENGRAMORY_INDEX_PATH for exactly this; this is its counterpart.
|
|
63
|
+
//
|
|
64
|
+
// The key and the basename are computed ONCE here: guards run on every tool call
|
|
65
|
+
// and must stay cheap, so the per-call path stays a string compare and the single
|
|
66
|
+
// realpath() only happens when the basename already matched.
|
|
67
|
+
const indexPath = typeof config.indexPath === 'string' && config.indexPath.trim()
|
|
68
|
+
? config.indexPath.trim()
|
|
69
|
+
: undefined
|
|
70
|
+
const settings = {
|
|
71
|
+
indexName: indexNameOf(config.indexName),
|
|
72
|
+
indexPath,
|
|
73
|
+
indexKey: indexPath === undefined ? undefined : pathKey(indexPath),
|
|
74
|
+
// When pinned, the name to match is the pinned file's own basename.
|
|
75
|
+
matchName: (indexPath === undefined
|
|
76
|
+
? indexNameOf(config.indexName)
|
|
77
|
+
: basename(indexPath)).toLowerCase(),
|
|
78
|
+
maxLines: positive(config.maxLines, DEFAULT_MAX_LINES),
|
|
79
|
+
maxBytes: positive(config.maxBytes, DEFAULT_MAX_BYTES),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
ctx.tools.guard((exec) => refuseOversizedIndex(exec, settings))
|
|
83
|
+
|
|
84
|
+
// The skill registry is optional AND may activate after this plugin — fiber order
|
|
85
|
+
// between unrelated providers is not guaranteed, so a one-shot probe here would
|
|
86
|
+
// silently skip registration on such boots. ctx.inject() is Cordis' reactive form:
|
|
87
|
+
// the callback runs once `skills` is available (however late), re-runs if the
|
|
88
|
+
// registry reloads, and never makes the cap wait. A bare `ctx.skills` read at THIS
|
|
89
|
+
// level would throw (undeclared service — issue #8's second crash); inside the
|
|
90
|
+
// callback the service is declared, so the read is legal.
|
|
91
|
+
if (config.registerSkill !== false) {
|
|
92
|
+
ctx.inject(['skills'], (inner) => {
|
|
93
|
+
const skill = config.skill ?? builtinSkillBody()
|
|
94
|
+
// effect() ties the registration to the child fiber: it is disposed when the
|
|
95
|
+
// registry unloads, so a reload cannot accumulate duplicate registrations.
|
|
96
|
+
inner.effect(() => inner.skills.register({
|
|
97
|
+
name: 'engramory',
|
|
98
|
+
description:
|
|
99
|
+
'Curated file-based long-term memory: recall through MEMORY.md at the start of ' +
|
|
100
|
+
'a task, save durable user/feedback/project/reference facts, and sync before ' +
|
|
101
|
+
'compacting or opening a fresh thread.',
|
|
102
|
+
whenToUse:
|
|
103
|
+
'Starting or resuming work, learning something durable worth a future session, ' +
|
|
104
|
+
'or approaching a compact/clear/new-thread boundary.',
|
|
105
|
+
source: 'runtime',
|
|
106
|
+
content: skill,
|
|
107
|
+
}))
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The cap itself. Returning a string denies the call; `undefined` lets it through.
|
|
114
|
+
*
|
|
115
|
+
* Guards are synchronous by contract, so this stays cheap: one basename comparison
|
|
116
|
+
* rejects the overwhelming majority of calls before anything is measured, and the
|
|
117
|
+
* only I/O is a single read of the index on a gated write.
|
|
118
|
+
*/
|
|
119
|
+
function refuseOversizedIndex(exec, settings) {
|
|
120
|
+
const args = exec?.arguments
|
|
121
|
+
if (!args || typeof args !== 'object') return undefined
|
|
122
|
+
|
|
123
|
+
// dsh's file tools carry the target as `file_path`; `str_replace_editor` calls it
|
|
124
|
+
// `path`. Compare basenames case-INsensitively: on the case-insensitive
|
|
125
|
+
// filesystems most stores live on (Windows/macOS), `memory.md` IS the guarded
|
|
126
|
+
// index, and an exact compare let that spelling through. (Same trade-off as the
|
|
127
|
+
// Python guard: on a case-sensitive FS this can gate an unrelated lowercase twin —
|
|
128
|
+
// rename it, or point `indexName` elsewhere.)
|
|
129
|
+
const filePath = typeof args.file_path === 'string' ? args.file_path
|
|
130
|
+
: typeof args.path === 'string' ? args.path
|
|
131
|
+
: undefined
|
|
132
|
+
if (filePath === undefined) return undefined
|
|
133
|
+
if (basename(filePath).toLowerCase() !== settings.matchName) return undefined
|
|
134
|
+
|
|
135
|
+
const tool = typeof exec.name === 'string' ? exec.name : ''
|
|
136
|
+
|
|
137
|
+
// Nothing below can refuse a read, a view, or an unknown tool, so leave before the
|
|
138
|
+
// pinned-path check: pathKey() touches the filesystem and a guard runs on EVERY tool
|
|
139
|
+
// call. Ordering it here keeps the common case a pair of string compares.
|
|
140
|
+
const gated = WHOLE_FILE_WRITES.has(tool)
|
|
141
|
+
|| PARTIAL_WRITES.has(tool)
|
|
142
|
+
|| (tool === 'str_replace_editor'
|
|
143
|
+
&& (args.command === 'create' || args.command === 'str_replace'
|
|
144
|
+
|| args.command === 'insert'))
|
|
145
|
+
if (!gated) return undefined
|
|
146
|
+
// If the guard is pinned to one file, this is where an unrelated same-named file in
|
|
147
|
+
// another project drops out.
|
|
148
|
+
if (settings.indexKey !== undefined && pathKey(filePath) !== settings.indexKey) {
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (WHOLE_FILE_WRITES.has(tool)) {
|
|
153
|
+
if (typeof args.content !== 'string') return undefined
|
|
154
|
+
return verdict(args.content, readIfPossible(filePath), filePath, settings)
|
|
155
|
+
}
|
|
156
|
+
if (tool === 'str_replace_editor' && args.command === 'create') {
|
|
157
|
+
if (typeof args.file_text !== 'string') return undefined
|
|
158
|
+
return verdict(args.file_text, readIfPossible(filePath), filePath, settings)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const partial = PARTIAL_WRITES.has(tool)
|
|
162
|
+
|| (tool === 'str_replace_editor'
|
|
163
|
+
&& (args.command === 'str_replace' || args.command === 'insert'))
|
|
164
|
+
if (!partial) return undefined // read / view / list / unknown: never refused
|
|
165
|
+
|
|
166
|
+
const currentBuf = readIfPossible(filePath)
|
|
167
|
+
if (currentBuf === undefined) return undefined
|
|
168
|
+
const current = currentBuf.toString('utf8')
|
|
169
|
+
const simulated = simulateEdit(current, args)
|
|
170
|
+
if (simulated !== undefined) {
|
|
171
|
+
return verdict(simulated, currentBuf, filePath, settings)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The edit's result genuinely cannot be reconstructed from its arguments. Honest,
|
|
175
|
+
// cheap rule: if the index is ALREADY over, refuse to mutate it blind — and name
|
|
176
|
+
// the open door, because "compact first" while refusing edits reads as a dead end.
|
|
177
|
+
const breach = measure(current, settings)
|
|
178
|
+
if (!breach) return undefined
|
|
179
|
+
return (
|
|
180
|
+
`${displayName(settings)} is already over the Engramory cap (${breach}) and this ` +
|
|
181
|
+
`edit's result cannot be measured. Compact it with a whole-file write instead: ` +
|
|
182
|
+
`a rewrite that SHRINKS the index always passes, even while still over the cap. ` +
|
|
183
|
+
`Pointer-ify over-long lines, merge duplicates, archive cold notes — the index ` +
|
|
184
|
+
`is a table of contents, and anything past the cap silently stops being recalled.` +
|
|
185
|
+
notMyIndexHint(settings)
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Mirrors the Python guard's deny rule: refuse only when a dimension ends OVER its
|
|
191
|
+
* cap AND grew past the current file, so a shrinking/keeping write on an over-cap
|
|
192
|
+
* index always passes and incremental compaction stays possible. A missing or
|
|
193
|
+
* unreadable current file counts as empty — a first write past the cap is refused.
|
|
194
|
+
*/
|
|
195
|
+
function verdict(text, currentBuf, filePath, settings) {
|
|
196
|
+
const lines = countLines(text)
|
|
197
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
198
|
+
// Current byte size comes from the RAW buffer, mirroring how the Python guard
|
|
199
|
+
// sizes the on-disk file: decoding first inflated a non-UTF-8 index ~3x (every
|
|
200
|
+
// bad byte becomes a 3-byte U+FFFD), which made a genuinely growing write look
|
|
201
|
+
// like a shrink and pass. Newlines survive a lossy decode, so the line count may
|
|
202
|
+
// use the decoded text.
|
|
203
|
+
const curLines = currentBuf === undefined ? 0 : countLines(currentBuf.toString('utf8'))
|
|
204
|
+
const curBytes = currentBuf === undefined ? 0 : currentBuf.length
|
|
205
|
+
const over = []
|
|
206
|
+
if (lines > settings.maxLines && lines > curLines) {
|
|
207
|
+
over.push(`${lines} lines > ${settings.maxLines}`)
|
|
208
|
+
}
|
|
209
|
+
if (bytes > settings.maxBytes && bytes > curBytes) {
|
|
210
|
+
over.push(`${bytes} bytes > ${settings.maxBytes}`)
|
|
211
|
+
}
|
|
212
|
+
if (!over.length) return undefined
|
|
213
|
+
return (
|
|
214
|
+
`This write would put ${basename(filePath)} over the Engramory index cap ` +
|
|
215
|
+
`(${over.join(', ')}). The index is loaded every session and the host only reads ` +
|
|
216
|
+
`so far, so anything past the cap silently stops being recalled. Compact before ` +
|
|
217
|
+
`writing: move detail into the linked note files, merge duplicates, archive cold ` +
|
|
218
|
+
`notes, and keep every line to "one short hook + link". A write that SHRINKS the ` +
|
|
219
|
+
`index always passes, so you can compact step by step.` + notMyIndexHint(settings)
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The name to show in a refusal. `indexPath` wins over `indexName` when both are set,
|
|
226
|
+
* so quoting the ignored one would send a user looking for the wrong file.
|
|
227
|
+
*/
|
|
228
|
+
function displayName(settings) {
|
|
229
|
+
return settings.indexPath === undefined
|
|
230
|
+
? settings.indexName
|
|
231
|
+
: basename(settings.indexPath)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Without `indexPath` the guard matches on basename alone, so it can land on a file
|
|
237
|
+
* that is not a memory index at all. Say so in the refusal: a user who hits this
|
|
238
|
+
* needs the way out, not just the cap.
|
|
239
|
+
*/
|
|
240
|
+
function notMyIndexHint(settings) {
|
|
241
|
+
if (settings.indexPath !== undefined) return ''
|
|
242
|
+
return (
|
|
243
|
+
` (If this file is NOT your memory index, set this plugin's \`indexPath\` config ` +
|
|
244
|
+
`to your real index's absolute path so only that file is gated.)`
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Reconstruct a partial edit's post-write text when its arguments carry enough to
|
|
250
|
+
* do it (`old_str`/`new_str`, or the Claude-style `old_string`/`new_string`).
|
|
251
|
+
* Mirrors the Python guard's Edit simulation: replace the unique occurrence, or all
|
|
252
|
+
* of them under a replace-all flag; an absent or ambiguous old-string means the
|
|
253
|
+
* real tool errors and changes nothing, so the current text is the honest
|
|
254
|
+
* prediction. Returns undefined when the shape isn't recognised.
|
|
255
|
+
*/
|
|
256
|
+
function simulateEdit(current, args) {
|
|
257
|
+
const oldStr = firstString(args.old_str, args.old_string)
|
|
258
|
+
if (oldStr === undefined || oldStr === '') return undefined
|
|
259
|
+
const newStr = firstString(args.new_str, args.new_string) ?? ''
|
|
260
|
+
if (args.replace_all === true || args.replaceAll === true) {
|
|
261
|
+
return current.split(oldStr).join(newStr)
|
|
262
|
+
}
|
|
263
|
+
const first = current.indexOf(oldStr)
|
|
264
|
+
if (first === -1) return current // the real tool errors: nothing changes
|
|
265
|
+
const second = current.indexOf(oldStr, first + oldStr.length)
|
|
266
|
+
if (second !== -1) return current // ambiguous: the real tool errors, nothing changes
|
|
267
|
+
return current.slice(0, first) + newStr + current.slice(first + oldStr.length)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function firstString(...values) {
|
|
271
|
+
for (const v of values) {
|
|
272
|
+
if (typeof v === 'string') return v
|
|
273
|
+
}
|
|
274
|
+
return undefined
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Returns a human-readable breach description, or `null` when the text is within caps. */
|
|
278
|
+
function measure(text, { maxLines, maxBytes }) {
|
|
279
|
+
const lines = countLines(text)
|
|
280
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
281
|
+
const over = []
|
|
282
|
+
if (lines > maxLines) over.push(`${lines} lines > ${maxLines}`)
|
|
283
|
+
if (bytes > maxBytes) over.push(`${bytes} bytes > ${maxBytes}`)
|
|
284
|
+
return over.length ? over.join(', ') : null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Trailing-newline-insensitive, matching how the Python guard counts. */
|
|
288
|
+
function countLines(text) {
|
|
289
|
+
if (!text) return 0
|
|
290
|
+
return text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A missing or unreadable index is not a breach: the guard's job is to stop an
|
|
295
|
+
* oversized index, never to block work because a path could not be read. Returns the
|
|
296
|
+
* RAW buffer — byte comparisons must use on-disk bytes (see verdict), decode at the
|
|
297
|
+
* call sites that need text.
|
|
298
|
+
*/
|
|
299
|
+
function readIfPossible(filePath) {
|
|
300
|
+
try {
|
|
301
|
+
return readFileSync(filePath)
|
|
302
|
+
} catch {
|
|
303
|
+
return undefined
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function positive(value, fallback) {
|
|
308
|
+
if (!Number.isFinite(value)) return fallback
|
|
309
|
+
const floored = Math.floor(value)
|
|
310
|
+
return floored > 0 ? floored : fallback
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Normalise a path for identity comparison. `realpath` resolves symlinks and `..` so
|
|
315
|
+
* two spellings of the same file compare equal, but it throws for a path with no file
|
|
316
|
+
* behind it yet — and a plain `resolve` fallback is NOT interchangeable with it: an
|
|
317
|
+
* index pinned before it exists, under a symlinked ancestor, would be keyed by its
|
|
318
|
+
* alias at config time and by the link's target on every later call. The keys would
|
|
319
|
+
* never match again and the guard would silently stop guarding its own index.
|
|
320
|
+
*
|
|
321
|
+
* So resolve the deepest ancestor that DOES exist and re-attach the missing tail:
|
|
322
|
+
* the same file then keys identically whether or not it exists yet. Case is folded on
|
|
323
|
+
* Windows only, mirroring `os.path.normcase` in the Python guard — folding it on Linux
|
|
324
|
+
* would make an unrelated `memory.md` collide with the pinned index. (Known limit:
|
|
325
|
+
* `toLowerCase` is not NTFS's exact case-folding table, same as the Python guard.)
|
|
326
|
+
*/
|
|
327
|
+
function pathKey(p) {
|
|
328
|
+
let head = resolve(p)
|
|
329
|
+
const tail = []
|
|
330
|
+
for (;;) {
|
|
331
|
+
try {
|
|
332
|
+
head = realpathSync(head)
|
|
333
|
+
break
|
|
334
|
+
} catch {
|
|
335
|
+
const parent = dirname(head)
|
|
336
|
+
if (parent === head) break // hit the root with nothing resolvable
|
|
337
|
+
tail.unshift(basename(head))
|
|
338
|
+
head = parent
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const out = tail.length ? join(head, ...tail) : head
|
|
342
|
+
return process.platform === 'win32' ? out.toLowerCase() : out
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* An empty or non-string indexName must fall back, not silently disable the cap:
|
|
348
|
+
* the numeric caps already recover from nonsense values, and this field is the one
|
|
349
|
+
* that decides whether the guard fires at all.
|
|
350
|
+
*/
|
|
351
|
+
function indexNameOf(value) {
|
|
352
|
+
return typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_INDEX_NAME
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The always-on block states the discipline; this is the on-demand detail the model
|
|
357
|
+
* pulls when it is actually about to recall, write, or sync. Kept deliberately short —
|
|
358
|
+
* the full protocol is SKILL.md in the Engramory repo, and a skill body that has to be
|
|
359
|
+
* read before it is useful is a skill that will not be read.
|
|
360
|
+
*/
|
|
361
|
+
function builtinSkillBody() {
|
|
362
|
+
return [
|
|
363
|
+
'# Engramory — curated file-based memory',
|
|
364
|
+
'',
|
|
365
|
+
'One canonical store: `MEMORY.md` is an index of pointers, one line per memory;',
|
|
366
|
+
'each fact lives in its own small markdown file beside it. Never put content in',
|
|
367
|
+
'the index, and never keep a second parallel store for handoffs.',
|
|
368
|
+
'',
|
|
369
|
+
'## Recall',
|
|
370
|
+
'',
|
|
371
|
+
'At the start of a task, read the index and open only the notes whose hooks look',
|
|
372
|
+
'relevant. Treat what you recall as background that may be stale: re-verify any',
|
|
373
|
+
'file, flag, or version against the repo before acting on it.',
|
|
374
|
+
'',
|
|
375
|
+
'## Write',
|
|
376
|
+
'',
|
|
377
|
+
'Before writing, confirm the fact is not already in the repo, git history, or the',
|
|
378
|
+
'instruction files, and that it is not a secret value. Search the index and update',
|
|
379
|
+
'an existing note rather than adding a near-duplicate. A new note is one atomic',
|
|
380
|
+
'fact with frontmatter:',
|
|
381
|
+
'',
|
|
382
|
+
'```markdown',
|
|
383
|
+
'---',
|
|
384
|
+
'name: <kebab-case-slug>',
|
|
385
|
+
'description: <one sharp line — this is what future-you reads to decide to open it>',
|
|
386
|
+
'type: user | feedback | project | reference',
|
|
387
|
+
'scope: global | repo # optional: does this still hold in another repo?',
|
|
388
|
+
'created: YYYY-MM-DD',
|
|
389
|
+
'updated: YYYY-MM-DD',
|
|
390
|
+
'---',
|
|
391
|
+
'```',
|
|
392
|
+
'',
|
|
393
|
+
'`feedback` and `project` notes MUST carry a `**Why:**` line and a',
|
|
394
|
+
'`**How to apply:**` line. Add exactly one pointer line to the index, and delete',
|
|
395
|
+
'memories that turn out to be wrong.',
|
|
396
|
+
'',
|
|
397
|
+
'Store settled facts, never current state: "2.0 shipped on 2026-01-15" is durable;',
|
|
398
|
+
'"the current version is X", the tip commit, or a passing test count will rot —',
|
|
399
|
+
'record where to read those instead.',
|
|
400
|
+
'',
|
|
401
|
+
'## Sync',
|
|
402
|
+
'',
|
|
403
|
+
'Before a deliberate compact, clear, or new thread: scan the task, dedup and update,',
|
|
404
|
+
'refresh project state, promote only reusable feedback, retire completed transient',
|
|
405
|
+
'state, then report what was added, updated, archived, and skipped.',
|
|
406
|
+
'',
|
|
407
|
+
'## The cap is enforced here',
|
|
408
|
+
'',
|
|
409
|
+
'This host denies a write that would GROW the index past 200 lines / 25 KB. That is',
|
|
410
|
+
'a real refusal, not a warning — but a write that shrinks the index always passes,',
|
|
411
|
+
'so compact first, step by step if needed: the index is loaded every session, and',
|
|
412
|
+
'anything past the cap silently stops being recalled.',
|
|
413
|
+
'',
|
|
414
|
+
'Never write credentials, keys, tokens, or cookies into memory — record only where',
|
|
415
|
+
'the secret lives.',
|
|
416
|
+
].join('\n')
|
|
417
|
+
}
|
package/package.json
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-engramory",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Curated file-based long-term memory for DeepSeek Harness — human-readable markdown notes, one plain-file store shared across hosts (git-ignored when it lives inside a project repo), and a deterministic index cap enforced by ctx.tools.guard()",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"dsh-plugin",
|
|
7
|
-
"deepseek-harness",
|
|
8
|
-
"dsh",
|
|
9
|
-
"memory",
|
|
10
|
-
"agent-memory",
|
|
11
|
-
"long-term-memory",
|
|
12
|
-
"engramory",
|
|
13
|
-
"markdown"
|
|
14
|
-
],
|
|
15
|
-
"type": "module",
|
|
16
|
-
"main": "index.js",
|
|
17
|
-
"dsh": {
|
|
18
|
-
"bundle": {
|
|
19
|
-
"patch": "./cordis.patch.yml"
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
|
-
"exports": {
|
|
23
|
-
".": "./index.js",
|
|
24
|
-
"./package.json": "./package.json"
|
|
25
|
-
},
|
|
26
|
-
"files": [
|
|
27
|
-
"index.js",
|
|
28
|
-
"cordis.patch.yml",
|
|
29
|
-
"README.md",
|
|
30
|
-
"LICENSE"
|
|
31
|
-
],
|
|
32
|
-
"license": "MIT",
|
|
33
|
-
"repository": {
|
|
34
|
-
"type": "git",
|
|
35
|
-
"url": "git+https://github.com/tinqiao-oss/engramory.git",
|
|
36
|
-
"directory": "adapters/dsh/plugin"
|
|
37
|
-
},
|
|
38
|
-
"homepage": "https://github.com/tinqiao-oss/engramory/tree/master/adapters/dsh#readme",
|
|
39
|
-
"bugs": {
|
|
40
|
-
"url": "https://github.com/tinqiao-oss/engramory/issues"
|
|
41
|
-
},
|
|
42
|
-
"engines": {
|
|
43
|
-
"node": ">=18"
|
|
44
|
-
},
|
|
45
|
-
"peerDependencies": {
|
|
46
|
-
"@deepseek-ai/dsh-tools": ">=0.0.1-rc.1"
|
|
47
|
-
},
|
|
48
|
-
"peerDependenciesMeta": {
|
|
49
|
-
"@deepseek-ai/dsh-tools": {
|
|
50
|
-
"optional": false
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
"publishConfig": {
|
|
54
|
-
"access": "public"
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-engramory",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Curated file-based long-term memory for DeepSeek Harness — human-readable markdown notes, one plain-file store shared across hosts (git-ignored when it lives inside a project repo), and a deterministic index cap enforced by ctx.tools.guard()",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"memory",
|
|
10
|
+
"agent-memory",
|
|
11
|
+
"long-term-memory",
|
|
12
|
+
"engramory",
|
|
13
|
+
"markdown"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"dsh": {
|
|
18
|
+
"bundle": {
|
|
19
|
+
"patch": "./cordis.patch.yml"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.js",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/tinqiao-oss/engramory.git",
|
|
36
|
+
"directory": "adapters/dsh/plugin"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/tinqiao-oss/engramory/tree/master/adapters/dsh#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/tinqiao-oss/engramory/issues"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@deepseek-ai/dsh-tools": ">=0.0.1-rc.1"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@deepseek-ai/dsh-tools": {
|
|
50
|
+
"optional": false
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|