dsh-engramory 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # dsh-engramory
2
2
 
3
3
  Curated, file-based long-term memory for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) —
4
- plain markdown notes you can read and `git log`, one store shared across every host you
5
- use, and an index cap that is a **real refusal** rather than a request.
4
+ plain markdown notes you can open with anything, one store shared across every host you
5
+ use, and an index cap that is a **real refusal** rather than a request. (If the store
6
+ lives inside a project's git repo it must be git-ignored — memories carry machine-local
7
+ detail; see SKILL.md §1. A *dedicated private* repo for the store itself is fine.)
6
8
 
7
9
  Part of [Engramory](https://github.com/tinqiao-oss/engramory).
8
10
 
@@ -16,7 +18,10 @@ because the index loads every session and everything past the cap silently stops
16
18
  recalled. On most hosts that limit is rules plus a checker the agent has to remember to
17
19
  run. dsh exposes `ctx.tools.guard()` — a synchronous, **monotonic** refusal: once a guard
18
20
  returns a reason, no later listener can turn it back into an allow. So here the limit is
19
- enforced, not requested. Outside Claude Code, this is the only host where that is true.
21
+ enforced, not requested — **once the plugin is actually installed and running**; a
22
+ profile install currently fails upstream (see "Known limits"), and until then the cap on
23
+ dsh is still rules plus the checker. Outside Claude Code, this is the only host where a
24
+ deterministic cap is even possible.
20
25
 
21
26
  **The protocol arrives with the plugin.** `ctx.skills.register()` contributes the skill at
22
27
  runtime, so it does not depend on landing files in one of the five skill roots dsh scans —
@@ -28,15 +33,22 @@ a path that is easy to get subtly wrong and fails silently when you do.
28
33
  dsh plugin --profile <name> add dsh-engramory
29
34
  ```
30
35
 
31
- Then mount it in that profile's `cordis.patch.yml`:
36
+ That is the whole install. The package ships a `dsh.bundle` manifest pointing at its own
37
+ `cordis.patch.yml`, so the row is inserted into the profile's plugin tree for you — no
38
+ hand-editing. To change a default, patch the EXISTING row by id in your profile's own
39
+ patch layer — do **not** use a second `- insert:` (insert always appends, so you would
40
+ end up with two engramory rows and the original caps still enforced):
32
41
 
33
42
  ```yaml
34
43
  - id: engramory
35
- name: dsh-engramory
36
44
  config:
37
- indexName: MEMORY.md # optional
45
+ indexName: MEMORY.md
46
+ maxLines: 200
47
+ maxBytes: 25600
38
48
  ```
39
49
 
50
+ A patch replaces the targeted row's whole `config`, so list every key you mean to keep.
51
+
40
52
  For the store itself and the always-on block, use the installer in the Engramory repo
41
53
  (`python tools/engramory_init.py dsh --install-skill`). This plugin enforces the cap and
42
54
  supplies the protocol; it does not create the store.
@@ -45,7 +57,7 @@ supplies the protocol; it does not create the store.
45
57
 
46
58
  | Field | Default | Meaning |
47
59
  |---|---|---|
48
- | `indexName` | `MEMORY.md` | Basename treated as the memory index. Nothing else is inspected. |
60
+ | `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. |
49
61
  | `maxLines` | `200` | Hard line cap. Non-positive or non-finite values fall back to the default. |
50
62
  | `maxBytes` | `25600` | Hard UTF-8 byte cap (25 KB). |
51
63
  | `registerSkill` | `true` | Set `false` to keep the cap but skip the runtime skill. |
@@ -55,24 +67,34 @@ supplies the protocol; it does not create the store.
55
67
 
56
68
  | Call | Decision |
57
69
  |---|---|
58
- | `write` to the index, within caps | allow |
59
- | `write` to the index, over either cap | **deny**, naming the numbers and what to compact |
60
- | Partial edit of an index that is already over cap | **deny** — do not grow a breach |
61
- | Partial edit of a healthy index | allow |
70
+ | `write` (or `str_replace_editor` `create`) ending within caps | allow |
71
+ | `write`/`create` that GROWS the index past a cap | **deny**, naming the numbers and what to compact |
72
+ | `write`/`create` that SHRINKS or keeps an over-cap index | allow — incremental compaction (210 → 205 → 198) must stay possible |
73
+ | Edit carrying `old_str`/`new_str`: result simulated | judged by the RESULT, same grow/shrink rule as a write |
74
+ | Unsimulable partial (e.g. `insert`) on an over-cap index | **deny**, telling the agent a shrinking whole-file write passes |
75
+ | Unsimulable partial on a healthy index | allow |
76
+ | `read`, `view`, or any unknown tool | allow — recall must never be blocked, even over cap |
62
77
  | Any write to any other file | allow — not the guard's business |
63
- | Missing/unreadable index, malformed arguments | allow — a guard must never block work by accident |
78
+ | 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 |
79
+ | Missing/unreadable index on a partial edit | allow — a guard must never block work over a path it cannot read |
80
+ | Malformed execution (no arguments, non-string path/content) | allow — not recognisably a write |
64
81
 
65
- The line count ignores a trailing newline, matching `hooks/engramory_index_guard.py`, so
66
- an index sitting exactly at the cap stays writable.
82
+ The deny rule mirrors `hooks/engramory_index_guard.py` exactly: refuse only a result
83
+ that is over a cap AND grew past the current file. The line count ignores a trailing
84
+ newline, so an index sitting exactly at the cap stays writable.
67
85
 
68
86
  ## Known limits
69
87
 
70
- - **An edit that crosses the cap from under it is not caught.** A partial write does not
71
- carry the resulting text, and a synchronous guard has no business reconstructing it.
72
- The breach is caught by the next whole-file write, or by `engramory_check.py`. What is
73
- guaranteed here is that an already-over index cannot be grown further.
88
+ - **An unsimulable partial that crosses the cap from under it is not caught.** `insert`
89
+ and friends do not carry the resulting text. The breach is caught by the next
90
+ whole-file write, or by `engramory_check.py`. What is guaranteed: an already-over
91
+ index cannot be grown further, and a shrinking write always passes.
92
+ - **The tool roster follows dsh's documented tool-fs contract** (`write`/`edit` with
93
+ `file_path`, `str_replace_editor` with `path`) and is deliberately conservative:
94
+ unknown tools pass. Until the plugin runs inside a real profile this mapping is
95
+ documentation-verified only.
74
96
  - **Not yet verified end to end inside dsh.** The guard's decision table is covered by
75
- `node --test` (13 cases, run in Engramory's CI), and the wiring for the AGENTS.md block
97
+ `node --test` (20 cases, run in Engramory's CI), and the wiring for the AGENTS.md block
76
98
  and skill discovery was dogfooded against a live `deepseek-v4-flash` session. But
77
99
  installing a *third-party plugin* into a profile currently fails on dsh 0.1.0-rc.6:
78
100
  `dsh plugin` shells out to `pnpm` (not bundled), and a direct `pnpm add` then dies on
@@ -0,0 +1,16 @@
1
+ # The dsh-engramory bundle patch: one row, inserted over whatever layers came before
2
+ # it. A patch replaces the targeted row's whole `config`, so a profile overriding this
3
+ # row must restate every key it means to keep.
4
+ #
5
+ # The numbers below are the protocol's own caps, not this port's opinion: 200 lines /
6
+ # 25 KB is what keeps MEMORY.md cheap enough to load at the start of every session, and
7
+ # it is the same limit hooks/engramory_index_guard.py enforces on Claude Code. They live
8
+ # here, beside the row, so a deployment can raise or lower them without touching code.
9
+
10
+ - insert:
11
+ - id: engramory
12
+ name: dsh-engramory
13
+ config:
14
+ indexName: MEMORY.md
15
+ maxLines: 200
16
+ maxBytes: 25600
package/index.js CHANGED
@@ -13,6 +13,13 @@
13
13
  * sidesteps the five-root scan entirely, so the protocol is present because the
14
14
  * plugin is loaded, not because a directory happened to be right.
15
15
  *
16
+ * The decision table mirrors hooks/engramory_index_guard.py: deny only a write that
17
+ * ENDS over a cap AND grew past the current file — a shrinking rewrite always passes,
18
+ * so an over-cap index can be compacted incrementally (210 → 205 → 198). Only known
19
+ * mutating tools are gated; `read` and unknown tools are never refused (the first cut
20
+ * gated everything that named the index, which blocked recall of an over-cap index at
21
+ * exactly the moment compaction was needed).
22
+ *
16
23
  * Zero dependencies, no build step: plain ESM, node: builtins only.
17
24
  */
18
25
  import { readFileSync } from 'node:fs'
@@ -25,15 +32,25 @@ export const name = 'engramory'
25
32
  export const inject = { required: ['tools'], optional: ['skills'] }
26
33
 
27
34
  /** Mirrors hooks/engramory_index_guard.py — the caps are the protocol's, not this port's. */
35
+ const DEFAULT_INDEX_NAME = 'MEMORY.md'
28
36
  const DEFAULT_MAX_LINES = 200
29
37
  const DEFAULT_MAX_BYTES = 25600
30
38
 
31
- /** Tools that replace a file wholesale, so `arguments.content` IS the post-write text. */
39
+ /** Tools that replace a file wholesale, so the post-write text is in the arguments. */
32
40
  const WHOLE_FILE_WRITES = new Set(['write'])
33
41
 
42
+ /**
43
+ * Tools that mutate a file in place. Only tools named here (plus
44
+ * `str_replace_editor`'s write commands, handled explicitly) count as partial
45
+ * writes. Unknown tools pass through: this guard is a discipline rail, not a
46
+ * security boundary (SECURITY.md), and a false refusal of `read` costs more than a
47
+ * miss — the next whole-file write is measured exactly regardless.
48
+ */
49
+ const PARTIAL_WRITES = new Set(['edit', 'str_replace', 'insert'])
50
+
34
51
  export function apply(ctx, config = {}) {
35
52
  const settings = {
36
- indexName: config.indexName ?? 'MEMORY.md',
53
+ indexName: indexNameOf(config.indexName),
37
54
  maxLines: positive(config.maxLines, DEFAULT_MAX_LINES),
38
55
  maxBytes: positive(config.maxBytes, DEFAULT_MAX_BYTES),
39
56
  }
@@ -62,52 +79,127 @@ export function apply(ctx, config = {}) {
62
79
  *
63
80
  * Guards are synchronous by contract, so this stays cheap: one basename comparison
64
81
  * rejects the overwhelming majority of calls before anything is measured, and the
65
- * only I/O is a single read of the index on a partial write.
82
+ * only I/O is a single read of the index on a gated write.
66
83
  */
67
84
  function refuseOversizedIndex(exec, settings) {
68
85
  const args = exec?.arguments
69
86
  if (!args || typeof args !== 'object') return undefined
70
87
 
71
- const filePath = args.file_path
72
- if (typeof filePath !== 'string' || basename(filePath) !== settings.indexName) {
88
+ // dsh's file tools carry the target as `file_path`; `str_replace_editor` calls it
89
+ // `path`. Compare basenames case-INsensitively: on the case-insensitive
90
+ // filesystems most stores live on (Windows/macOS), `memory.md` IS the guarded
91
+ // index, and an exact compare let that spelling through. (Same trade-off as the
92
+ // Python guard: on a case-sensitive FS this can gate an unrelated lowercase twin —
93
+ // rename it, or point `indexName` elsewhere.)
94
+ const filePath = typeof args.file_path === 'string' ? args.file_path
95
+ : typeof args.path === 'string' ? args.path
96
+ : undefined
97
+ if (filePath === undefined) return undefined
98
+ if (basename(filePath).toLowerCase() !== settings.indexName.toLowerCase()) {
73
99
  return undefined
74
100
  }
75
101
 
76
- if (WHOLE_FILE_WRITES.has(exec.name)) {
102
+ const tool = typeof exec.name === 'string' ? exec.name : ''
103
+
104
+ if (WHOLE_FILE_WRITES.has(tool)) {
77
105
  if (typeof args.content !== 'string') return undefined
78
- return verdictFor(args.content, filePath, settings)
106
+ return verdict(args.content, readIfPossible(filePath), filePath, settings)
107
+ }
108
+ if (tool === 'str_replace_editor' && args.command === 'create') {
109
+ if (typeof args.file_text !== 'string') return undefined
110
+ return verdict(args.file_text, readIfPossible(filePath), filePath, settings)
79
111
  }
80
112
 
81
- // A partial write (an edit / replace) does not carry the resulting text, and a
82
- // guard must not do expensive work to reconstruct it. The honest, cheap rule: if
83
- // the index is ALREADY over, refuse to grow it further. An edit that crosses the
84
- // line from under it still gets through here — engramory_check.py is the backstop,
85
- // and the next whole-file write is caught exactly.
86
- const current = readIfPossible(filePath)
87
- if (current === undefined) return undefined
113
+ const partial = PARTIAL_WRITES.has(tool)
114
+ || (tool === 'str_replace_editor'
115
+ && (args.command === 'str_replace' || args.command === 'insert'))
116
+ if (!partial) return undefined // read / view / list / unknown: never refused
117
+
118
+ const currentBuf = readIfPossible(filePath)
119
+ if (currentBuf === undefined) return undefined
120
+ const current = currentBuf.toString('utf8')
121
+ const simulated = simulateEdit(current, args)
122
+ if (simulated !== undefined) {
123
+ return verdict(simulated, currentBuf, filePath, settings)
124
+ }
125
+
126
+ // The edit's result genuinely cannot be reconstructed from its arguments. Honest,
127
+ // cheap rule: if the index is ALREADY over, refuse to mutate it blind — and name
128
+ // the open door, because "compact first" while refusing edits reads as a dead end.
88
129
  const breach = measure(current, settings)
89
130
  if (!breach) return undefined
90
131
  return (
91
- `${settings.indexName} is already over the Engramory cap (${breach}). ` +
92
- `Editing it further would push it deeper past the limit. Compact it first: ` +
93
- `pointer-ify over-long lines, merge duplicates, and archive cold notes — the ` +
94
- `index is a table of contents, and anything past the cap silently stops being ` +
95
- `recalled.`
132
+ `${settings.indexName} is already over the Engramory cap (${breach}) and this ` +
133
+ `edit's result cannot be measured. Compact it with a whole-file write instead: ` +
134
+ `a rewrite that SHRINKS the index always passes, even while still over the cap. ` +
135
+ `Pointer-ify over-long lines, merge duplicates, archive cold notes — the index ` +
136
+ `is a table of contents, and anything past the cap silently stops being recalled.`
96
137
  )
97
138
  }
98
139
 
99
- function verdictFor(text, filePath, settings) {
100
- const breach = measure(text, settings)
101
- if (!breach) return undefined
140
+ /**
141
+ * Mirrors the Python guard's deny rule: refuse only when a dimension ends OVER its
142
+ * cap AND grew past the current file, so a shrinking/keeping write on an over-cap
143
+ * index always passes and incremental compaction stays possible. A missing or
144
+ * unreadable current file counts as empty — a first write past the cap is refused.
145
+ */
146
+ function verdict(text, currentBuf, filePath, settings) {
147
+ const lines = countLines(text)
148
+ const bytes = Buffer.byteLength(text, 'utf8')
149
+ // Current byte size comes from the RAW buffer, mirroring how the Python guard
150
+ // sizes the on-disk file: decoding first inflated a non-UTF-8 index ~3x (every
151
+ // bad byte becomes a 3-byte U+FFFD), which made a genuinely growing write look
152
+ // like a shrink and pass. Newlines survive a lossy decode, so the line count may
153
+ // use the decoded text.
154
+ const curLines = currentBuf === undefined ? 0 : countLines(currentBuf.toString('utf8'))
155
+ const curBytes = currentBuf === undefined ? 0 : currentBuf.length
156
+ const over = []
157
+ if (lines > settings.maxLines && lines > curLines) {
158
+ over.push(`${lines} lines > ${settings.maxLines}`)
159
+ }
160
+ if (bytes > settings.maxBytes && bytes > curBytes) {
161
+ over.push(`${bytes} bytes > ${settings.maxBytes}`)
162
+ }
163
+ if (!over.length) return undefined
102
164
  return (
103
165
  `This write would put ${basename(filePath)} over the Engramory index cap ` +
104
- `(${breach}). The index is loaded every session and the host only reads so far, ` +
105
- `so anything past the cap silently stops being recalled. Compact before writing: ` +
106
- `move detail into the linked note files, merge duplicates, archive cold notes, ` +
107
- `and keep every line to "one short hook + link".`
166
+ `(${over.join(', ')}). The index is loaded every session and the host only reads ` +
167
+ `so far, so anything past the cap silently stops being recalled. Compact before ` +
168
+ `writing: move detail into the linked note files, merge duplicates, archive cold ` +
169
+ `notes, and keep every line to "one short hook + link". A write that SHRINKS the ` +
170
+ `index always passes, so you can compact step by step.`
108
171
  )
109
172
  }
110
173
 
174
+ /**
175
+ * Reconstruct a partial edit's post-write text when its arguments carry enough to
176
+ * do it (`old_str`/`new_str`, or the Claude-style `old_string`/`new_string`).
177
+ * Mirrors the Python guard's Edit simulation: replace the unique occurrence, or all
178
+ * of them under a replace-all flag; an absent or ambiguous old-string means the
179
+ * real tool errors and changes nothing, so the current text is the honest
180
+ * prediction. Returns undefined when the shape isn't recognised.
181
+ */
182
+ function simulateEdit(current, args) {
183
+ const oldStr = firstString(args.old_str, args.old_string)
184
+ if (oldStr === undefined || oldStr === '') return undefined
185
+ const newStr = firstString(args.new_str, args.new_string) ?? ''
186
+ if (args.replace_all === true || args.replaceAll === true) {
187
+ return current.split(oldStr).join(newStr)
188
+ }
189
+ const first = current.indexOf(oldStr)
190
+ if (first === -1) return current // the real tool errors: nothing changes
191
+ const second = current.indexOf(oldStr, first + oldStr.length)
192
+ if (second !== -1) return current // ambiguous: the real tool errors, nothing changes
193
+ return current.slice(0, first) + newStr + current.slice(first + oldStr.length)
194
+ }
195
+
196
+ function firstString(...values) {
197
+ for (const v of values) {
198
+ if (typeof v === 'string') return v
199
+ }
200
+ return undefined
201
+ }
202
+
111
203
  /** Returns a human-readable breach description, or `null` when the text is within caps. */
112
204
  function measure(text, { maxLines, maxBytes }) {
113
205
  const lines = countLines(text)
@@ -126,18 +218,31 @@ function countLines(text) {
126
218
 
127
219
  /**
128
220
  * A missing or unreadable index is not a breach: the guard's job is to stop an
129
- * oversized index, never to block work because a path could not be read.
221
+ * oversized index, never to block work because a path could not be read. Returns the
222
+ * RAW buffer — byte comparisons must use on-disk bytes (see verdict), decode at the
223
+ * call sites that need text.
130
224
  */
131
225
  function readIfPossible(filePath) {
132
226
  try {
133
- return readFileSync(filePath, 'utf8')
227
+ return readFileSync(filePath)
134
228
  } catch {
135
229
  return undefined
136
230
  }
137
231
  }
138
232
 
139
233
  function positive(value, fallback) {
140
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
234
+ if (!Number.isFinite(value)) return fallback
235
+ const floored = Math.floor(value)
236
+ return floored > 0 ? floored : fallback
237
+ }
238
+
239
+ /**
240
+ * An empty or non-string indexName must fall back, not silently disable the cap:
241
+ * the numeric caps already recover from nonsense values, and this field is the one
242
+ * that decides whether the guard fires at all.
243
+ */
244
+ function indexNameOf(value) {
245
+ return typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_INDEX_NAME
141
246
  }
142
247
 
143
248
  /**
@@ -194,9 +299,10 @@ function builtinSkillBody() {
194
299
  '',
195
300
  '## The cap is enforced here',
196
301
  '',
197
- 'This host denies a write that would push the index past 200 lines / 25 KB. That is',
198
- 'a real refusal, not a warning: compact first — the index is loaded every session,',
199
- 'and anything past the cap silently stops being recalled.',
302
+ 'This host denies a write that would GROW the index past 200 lines / 25 KB. That is',
303
+ 'a real refusal, not a warning — but a write that shrinks the index always passes,',
304
+ 'so compact first, step by step if needed: the index is loaded every session, and',
305
+ 'anything past the cap silently stops being recalled.',
200
306
  '',
201
307
  'Never write credentials, keys, tokens, or cookies into memory — record only where',
202
308
  'the secret lives.',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-engramory",
3
- "version": "0.1.0",
4
- "description": "Curated file-based long-term memory for DeepSeek Harness — human-readable markdown notes, one git-versioned store shared across hosts, and a deterministic index cap enforced by ctx.tools.guard()",
3
+ "version": "0.2.0",
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
5
  "keywords": [
6
6
  "dsh-plugin",
7
7
  "deepseek-harness",
@@ -14,12 +14,18 @@
14
14
  ],
15
15
  "type": "module",
16
16
  "main": "index.js",
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ }
21
+ },
17
22
  "exports": {
18
23
  ".": "./index.js",
19
24
  "./package.json": "./package.json"
20
25
  },
21
26
  "files": [
22
27
  "index.js",
28
+ "cordis.patch.yml",
23
29
  "README.md",
24
30
  "LICENSE"
25
31
  ],