dsh-engramory 0.1.1 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.md +59 -36
  2. package/index.js +173 -50
  3. package/package.json +2 -2
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,11 +18,17 @@ 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.
20
-
21
- **The protocol arrives with the plugin.** `ctx.skills.register()` contributes the skill at
22
- runtime, so it does not depend on landing files in one of the five skill roots dsh scans —
23
- a path that is easy to get subtly wrong and fails silently when you do.
21
+ enforced, not requested. Use **0.2.1 or later**: 0.2.0 shipped before profile installs
22
+ worked upstream and carried an `inject` declaration this Cordis does not accept — it
23
+ installed but never activated (issue #8). Outside Claude Code, dsh is the first host with
24
+ the shim actually written (a few others expose equivalent pre-write seams — see
25
+ PORTING.md — but have no shim yet).
26
+
27
+ **The protocol arrives with the plugin.** The skill is registered at runtime through
28
+ dsh's skill registry — via a reactive `ctx.inject(['skills'], …)` child, so a profile
29
+ without a registry still gets the cap and one whose registry activates late still gets
30
+ the skill — which means it does not depend on landing files in one of the five skill
31
+ roots dsh scans, a path that is easy to get subtly wrong and fails silently when you do.
24
32
 
25
33
  ## Install
26
34
 
@@ -30,16 +38,16 @@ dsh plugin --profile <name> add dsh-engramory
30
38
 
31
39
  That is the whole install. The package ships a `dsh.bundle` manifest pointing at its own
32
40
  `cordis.patch.yml`, so the row is inserted into the profile's plugin tree for you — no
33
- hand-editing. To change a default, restate the row in your profile's own patch layer:
41
+ hand-editing. To change a default, patch the EXISTING row by id in your profile's own
42
+ patch layer — do **not** use a second `- insert:` (insert always appends, so you would
43
+ end up with two engramory rows and the original caps still enforced):
34
44
 
35
45
  ```yaml
36
- - insert:
37
- - id: engramory
38
- name: dsh-engramory
39
- config:
40
- indexName: MEMORY.md
41
- maxLines: 200
42
- maxBytes: 25600
46
+ - id: engramory
47
+ config:
48
+ indexName: MEMORY.md
49
+ maxLines: 200
50
+ maxBytes: 25600
43
51
  ```
44
52
 
45
53
  A patch replaces the targeted row's whole `config`, so list every key you mean to keep.
@@ -52,7 +60,7 @@ supplies the protocol; it does not create the store.
52
60
 
53
61
  | Field | Default | Meaning |
54
62
  |---|---|---|
55
- | `indexName` | `MEMORY.md` | Basename treated as the memory index. Nothing else is inspected. |
63
+ | `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. |
56
64
  | `maxLines` | `200` | Hard line cap. Non-positive or non-finite values fall back to the default. |
57
65
  | `maxBytes` | `25600` | Hard UTF-8 byte cap (25 KB). |
58
66
  | `registerSkill` | `true` | Set `false` to keep the cap but skip the runtime skill. |
@@ -62,32 +70,47 @@ supplies the protocol; it does not create the store.
62
70
 
63
71
  | Call | Decision |
64
72
  |---|---|
65
- | `write` to the index, within caps | allow |
66
- | `write` to the index, over either cap | **deny**, naming the numbers and what to compact |
67
- | Partial edit of an index that is already over cap | **deny** — do not grow a breach |
68
- | Partial edit of a healthy index | allow |
73
+ | `write` (or `str_replace_editor` `create`) ending within caps | allow |
74
+ | `write`/`create` that GROWS the index past a cap | **deny**, naming the numbers and what to compact |
75
+ | `write`/`create` that SHRINKS or keeps an over-cap index | allow — incremental compaction (210 → 205 → 198) must stay possible |
76
+ | Edit carrying `old_str`/`new_str`: result simulated | judged by the RESULT, same grow/shrink rule as a write |
77
+ | Unsimulable partial (e.g. `insert`) on an over-cap index | **deny**, telling the agent a shrinking whole-file write passes |
78
+ | Unsimulable partial on a healthy index | allow |
79
+ | `read`, `view`, or any unknown tool | allow — recall must never be blocked, even over cap |
69
80
  | Any write to any other file | allow — not the guard's business |
70
- | Missing/unreadable index, malformed arguments | allow — a guard must never block work by accident |
81
+ | 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 |
82
+ | Missing/unreadable index on a partial edit | allow — a guard must never block work over a path it cannot read |
83
+ | Malformed execution (no arguments, non-string path/content) | allow — not recognisably a write |
71
84
 
72
- The line count ignores a trailing newline, matching `hooks/engramory_index_guard.py`, so
73
- an index sitting exactly at the cap stays writable.
85
+ The deny rule mirrors `hooks/engramory_index_guard.py` exactly: refuse only a result
86
+ that is over a cap AND grew past the current file. The line count ignores a trailing
87
+ newline, so an index sitting exactly at the cap stays writable.
74
88
 
75
89
  ## Known limits
76
90
 
77
- - **An edit that crosses the cap from under it is not caught.** A partial write does not
78
- carry the resulting text, and a synchronous guard has no business reconstructing it.
79
- The breach is caught by the next whole-file write, or by `engramory_check.py`. What is
80
- guaranteed here is that an already-over index cannot be grown further.
81
- - **Not yet verified end to end inside dsh.** The guard's decision table is covered by
82
- `node --test` (13 cases, run in Engramory's CI), and the wiring for the AGENTS.md block
83
- and skill discovery was dogfooded against a live `deepseek-v4-flash` session. But
84
- installing a *third-party plugin* into a profile currently fails on dsh 0.1.0-rc.6:
85
- `dsh plugin` shells out to `pnpm` (not bundled), and a direct `pnpm add` then dies on
86
- `ERR_PNPM_FETCH_404` for `@deepseek-ai/dsh-type-meta`, a package the published tree
87
- depends on but which is not on the registry. That is a preview-packaging problem
88
- upstream, not a plugin defect; this note goes away once a profile install resolves.
91
+ - **An unsimulable partial that crosses the cap from under it is not caught.** `insert`
92
+ and friends do not carry the resulting text. The breach is caught by the next
93
+ whole-file write, or by `engramory_check.py`. What is guaranteed: an already-over
94
+ index cannot be grown further, and a shrinking write always passes.
95
+ - **The tool roster follows dsh's documented tool-fs contract** (`write`/`edit` with
96
+ `file_path`, `str_replace_editor` with `path`) and is deliberately conservative:
97
+ unknown tools pass.
98
+ - **0.2.0 never activated; 0.2.1 is the first version that runs.** While the rc.6
99
+ preview bug blocked all third-party profile installs upstream, the only coverage a
100
+ plugin could have was mocks — and 0.2.0's mock hid two activation bugs: an
101
+ older-Cordis `{ required, optional }` inject shape (read as waiting for services
102
+ literally named `required`/`optional`, pending forever) and a bare `ctx.skills` read
103
+ of an undeclared service (throws under Cordis' reflective context). The first field
104
+ install caught both the moment installs became possible (issue #8). 0.2.1 fixes them,
105
+ verified end to end on a live dsh 0.1.0-rc.7 web profile (0.2.0 reproduces the boot
106
+ failure byte for byte; 0.2.1 boots and serves) and against the vendored
107
+ `@deepseek-ai/cordis` resolver (activation with, without, and with a late-mounted
108
+ skill registry). The test mock now mirrors the reflective-context access rules, and
109
+ the guard's decision table stays covered by `node --test` (22 cases, run in
110
+ Engramory's CI).
89
111
  - dsh is a developer preview and its plugin API can change. This plugin deliberately
90
- touches only `ctx.tools.guard()` and `ctx.skills.register()` so it stays cheap to fix.
112
+ touches only `ctx.tools.guard()` and a reactive `ctx.inject(['skills'], …)` child
113
+ that registers the skill, so it stays cheap to fix.
91
114
 
92
115
  ## License
93
116
 
package/index.js CHANGED
@@ -7,12 +7,20 @@
7
7
  * monotonic refusal — once a guard returns a reason, no later waterfall
8
8
  * listener can turn it back into an allow. That makes it a stronger seam than
9
9
  * most hosts expose, and it is why the 200-line / 25 KB limit can be enforced
10
- * here rather than merely asked for. Everywhere except Claude Code, Engramory's
11
- * cap degrades to "rules plus a checker the agent has to remember to run".
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".
12
13
  * 2. Skill delivery that does not depend on install paths. Registering at runtime
13
14
  * sidesteps the five-root scan entirely, so the protocol is present because the
14
15
  * plugin is loaded, not because a directory happened to be right.
15
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
+ *
16
24
  * Zero dependencies, no build step: plain ESM, node: builtins only.
17
25
  */
18
26
  import { readFileSync } from 'node:fs'
@@ -20,39 +28,65 @@ import { basename } from 'node:path'
20
28
 
21
29
  export const name = 'engramory'
22
30
 
23
- // `tools` carries the cap and is required. `skills` is optional so the plugin still
24
- // loads (and still caps) on a profile that mounts no skill registry.
25
- export const inject = { required: ['tools'], optional: ['skills'] }
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']
26
39
 
27
40
  /** Mirrors hooks/engramory_index_guard.py — the caps are the protocol's, not this port's. */
41
+ const DEFAULT_INDEX_NAME = 'MEMORY.md'
28
42
  const DEFAULT_MAX_LINES = 200
29
43
  const DEFAULT_MAX_BYTES = 25600
30
44
 
31
- /** Tools that replace a file wholesale, so `arguments.content` IS the post-write text. */
45
+ /** Tools that replace a file wholesale, so the post-write text is in the arguments. */
32
46
  const WHOLE_FILE_WRITES = new Set(['write'])
33
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
+
34
57
  export function apply(ctx, config = {}) {
35
58
  const settings = {
36
- indexName: config.indexName ?? 'MEMORY.md',
59
+ indexName: indexNameOf(config.indexName),
37
60
  maxLines: positive(config.maxLines, DEFAULT_MAX_LINES),
38
61
  maxBytes: positive(config.maxBytes, DEFAULT_MAX_BYTES),
39
62
  }
40
63
 
41
64
  ctx.tools.guard((exec) => refuseOversizedIndex(exec, settings))
42
65
 
43
- if (ctx.skills && config.registerSkill !== false) {
44
- const skill = config.skill ?? builtinSkillBody()
45
- ctx.skills.register({
46
- name: 'engramory',
47
- description:
48
- 'Curated file-based long-term memory: recall through MEMORY.md at the start of ' +
49
- 'a task, save durable user/feedback/project/reference facts, and sync before ' +
50
- 'compacting or opening a fresh thread.',
51
- whenToUse:
52
- 'Starting or resuming work, learning something durable worth a future session, ' +
53
- 'or approaching a compact/clear/new-thread boundary.',
54
- source: 'runtime',
55
- content: skill,
66
+ // The skill registry is optional AND may activate after this plugin — fiber order
67
+ // between unrelated providers is not guaranteed, so a one-shot probe here would
68
+ // silently skip registration on such boots. ctx.inject() is Cordis' reactive form:
69
+ // the callback runs once `skills` is available (however late), re-runs if the
70
+ // registry reloads, and never makes the cap wait. A bare `ctx.skills` read at THIS
71
+ // level would throw (undeclared service — issue #8's second crash); inside the
72
+ // callback the service is declared, so the read is legal.
73
+ if (config.registerSkill !== false) {
74
+ ctx.inject(['skills'], (inner) => {
75
+ const skill = config.skill ?? builtinSkillBody()
76
+ // effect() ties the registration to the child fiber: it is disposed when the
77
+ // registry unloads, so a reload cannot accumulate duplicate registrations.
78
+ inner.effect(() => inner.skills.register({
79
+ name: 'engramory',
80
+ description:
81
+ 'Curated file-based long-term memory: recall through MEMORY.md at the start of ' +
82
+ 'a task, save durable user/feedback/project/reference facts, and sync before ' +
83
+ 'compacting or opening a fresh thread.',
84
+ whenToUse:
85
+ 'Starting or resuming work, learning something durable worth a future session, ' +
86
+ 'or approaching a compact/clear/new-thread boundary.',
87
+ source: 'runtime',
88
+ content: skill,
89
+ }))
56
90
  })
57
91
  }
58
92
  }
@@ -62,52 +96,127 @@ export function apply(ctx, config = {}) {
62
96
  *
63
97
  * Guards are synchronous by contract, so this stays cheap: one basename comparison
64
98
  * 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.
99
+ * only I/O is a single read of the index on a gated write.
66
100
  */
67
101
  function refuseOversizedIndex(exec, settings) {
68
102
  const args = exec?.arguments
69
103
  if (!args || typeof args !== 'object') return undefined
70
104
 
71
- const filePath = args.file_path
72
- if (typeof filePath !== 'string' || basename(filePath) !== settings.indexName) {
105
+ // dsh's file tools carry the target as `file_path`; `str_replace_editor` calls it
106
+ // `path`. Compare basenames case-INsensitively: on the case-insensitive
107
+ // filesystems most stores live on (Windows/macOS), `memory.md` IS the guarded
108
+ // index, and an exact compare let that spelling through. (Same trade-off as the
109
+ // Python guard: on a case-sensitive FS this can gate an unrelated lowercase twin —
110
+ // rename it, or point `indexName` elsewhere.)
111
+ const filePath = typeof args.file_path === 'string' ? args.file_path
112
+ : typeof args.path === 'string' ? args.path
113
+ : undefined
114
+ if (filePath === undefined) return undefined
115
+ if (basename(filePath).toLowerCase() !== settings.indexName.toLowerCase()) {
73
116
  return undefined
74
117
  }
75
118
 
76
- if (WHOLE_FILE_WRITES.has(exec.name)) {
119
+ const tool = typeof exec.name === 'string' ? exec.name : ''
120
+
121
+ if (WHOLE_FILE_WRITES.has(tool)) {
77
122
  if (typeof args.content !== 'string') return undefined
78
- return verdictFor(args.content, filePath, settings)
123
+ return verdict(args.content, readIfPossible(filePath), filePath, settings)
124
+ }
125
+ if (tool === 'str_replace_editor' && args.command === 'create') {
126
+ if (typeof args.file_text !== 'string') return undefined
127
+ return verdict(args.file_text, readIfPossible(filePath), filePath, settings)
79
128
  }
80
129
 
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
130
+ const partial = PARTIAL_WRITES.has(tool)
131
+ || (tool === 'str_replace_editor'
132
+ && (args.command === 'str_replace' || args.command === 'insert'))
133
+ if (!partial) return undefined // read / view / list / unknown: never refused
134
+
135
+ const currentBuf = readIfPossible(filePath)
136
+ if (currentBuf === undefined) return undefined
137
+ const current = currentBuf.toString('utf8')
138
+ const simulated = simulateEdit(current, args)
139
+ if (simulated !== undefined) {
140
+ return verdict(simulated, currentBuf, filePath, settings)
141
+ }
142
+
143
+ // The edit's result genuinely cannot be reconstructed from its arguments. Honest,
144
+ // cheap rule: if the index is ALREADY over, refuse to mutate it blind — and name
145
+ // the open door, because "compact first" while refusing edits reads as a dead end.
88
146
  const breach = measure(current, settings)
89
147
  if (!breach) return undefined
90
148
  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.`
149
+ `${settings.indexName} is already over the Engramory cap (${breach}) and this ` +
150
+ `edit's result cannot be measured. Compact it with a whole-file write instead: ` +
151
+ `a rewrite that SHRINKS the index always passes, even while still over the cap. ` +
152
+ `Pointer-ify over-long lines, merge duplicates, archive cold notes — the index ` +
153
+ `is a table of contents, and anything past the cap silently stops being recalled.`
96
154
  )
97
155
  }
98
156
 
99
- function verdictFor(text, filePath, settings) {
100
- const breach = measure(text, settings)
101
- if (!breach) return undefined
157
+ /**
158
+ * Mirrors the Python guard's deny rule: refuse only when a dimension ends OVER its
159
+ * cap AND grew past the current file, so a shrinking/keeping write on an over-cap
160
+ * index always passes and incremental compaction stays possible. A missing or
161
+ * unreadable current file counts as empty — a first write past the cap is refused.
162
+ */
163
+ function verdict(text, currentBuf, filePath, settings) {
164
+ const lines = countLines(text)
165
+ const bytes = Buffer.byteLength(text, 'utf8')
166
+ // Current byte size comes from the RAW buffer, mirroring how the Python guard
167
+ // sizes the on-disk file: decoding first inflated a non-UTF-8 index ~3x (every
168
+ // bad byte becomes a 3-byte U+FFFD), which made a genuinely growing write look
169
+ // like a shrink and pass. Newlines survive a lossy decode, so the line count may
170
+ // use the decoded text.
171
+ const curLines = currentBuf === undefined ? 0 : countLines(currentBuf.toString('utf8'))
172
+ const curBytes = currentBuf === undefined ? 0 : currentBuf.length
173
+ const over = []
174
+ if (lines > settings.maxLines && lines > curLines) {
175
+ over.push(`${lines} lines > ${settings.maxLines}`)
176
+ }
177
+ if (bytes > settings.maxBytes && bytes > curBytes) {
178
+ over.push(`${bytes} bytes > ${settings.maxBytes}`)
179
+ }
180
+ if (!over.length) return undefined
102
181
  return (
103
182
  `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".`
183
+ `(${over.join(', ')}). The index is loaded every session and the host only reads ` +
184
+ `so far, so anything past the cap silently stops being recalled. Compact before ` +
185
+ `writing: move detail into the linked note files, merge duplicates, archive cold ` +
186
+ `notes, and keep every line to "one short hook + link". A write that SHRINKS the ` +
187
+ `index always passes, so you can compact step by step.`
108
188
  )
109
189
  }
110
190
 
191
+ /**
192
+ * Reconstruct a partial edit's post-write text when its arguments carry enough to
193
+ * do it (`old_str`/`new_str`, or the Claude-style `old_string`/`new_string`).
194
+ * Mirrors the Python guard's Edit simulation: replace the unique occurrence, or all
195
+ * of them under a replace-all flag; an absent or ambiguous old-string means the
196
+ * real tool errors and changes nothing, so the current text is the honest
197
+ * prediction. Returns undefined when the shape isn't recognised.
198
+ */
199
+ function simulateEdit(current, args) {
200
+ const oldStr = firstString(args.old_str, args.old_string)
201
+ if (oldStr === undefined || oldStr === '') return undefined
202
+ const newStr = firstString(args.new_str, args.new_string) ?? ''
203
+ if (args.replace_all === true || args.replaceAll === true) {
204
+ return current.split(oldStr).join(newStr)
205
+ }
206
+ const first = current.indexOf(oldStr)
207
+ if (first === -1) return current // the real tool errors: nothing changes
208
+ const second = current.indexOf(oldStr, first + oldStr.length)
209
+ if (second !== -1) return current // ambiguous: the real tool errors, nothing changes
210
+ return current.slice(0, first) + newStr + current.slice(first + oldStr.length)
211
+ }
212
+
213
+ function firstString(...values) {
214
+ for (const v of values) {
215
+ if (typeof v === 'string') return v
216
+ }
217
+ return undefined
218
+ }
219
+
111
220
  /** Returns a human-readable breach description, or `null` when the text is within caps. */
112
221
  function measure(text, { maxLines, maxBytes }) {
113
222
  const lines = countLines(text)
@@ -126,18 +235,31 @@ function countLines(text) {
126
235
 
127
236
  /**
128
237
  * 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.
238
+ * oversized index, never to block work because a path could not be read. Returns the
239
+ * RAW buffer — byte comparisons must use on-disk bytes (see verdict), decode at the
240
+ * call sites that need text.
130
241
  */
131
242
  function readIfPossible(filePath) {
132
243
  try {
133
- return readFileSync(filePath, 'utf8')
244
+ return readFileSync(filePath)
134
245
  } catch {
135
246
  return undefined
136
247
  }
137
248
  }
138
249
 
139
250
  function positive(value, fallback) {
140
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
251
+ if (!Number.isFinite(value)) return fallback
252
+ const floored = Math.floor(value)
253
+ return floored > 0 ? floored : fallback
254
+ }
255
+
256
+ /**
257
+ * An empty or non-string indexName must fall back, not silently disable the cap:
258
+ * the numeric caps already recover from nonsense values, and this field is the one
259
+ * that decides whether the guard fires at all.
260
+ */
261
+ function indexNameOf(value) {
262
+ return typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_INDEX_NAME
141
263
  }
142
264
 
143
265
  /**
@@ -194,9 +316,10 @@ function builtinSkillBody() {
194
316
  '',
195
317
  '## The cap is enforced here',
196
318
  '',
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.',
319
+ 'This host denies a write that would GROW the index past 200 lines / 25 KB. That is',
320
+ 'a real refusal, not a warning — but a write that shrinks the index always passes,',
321
+ 'so compact first, step by step if needed: the index is loaded every session, and',
322
+ 'anything past the cap silently stops being recalled.',
200
323
  '',
201
324
  'Never write credentials, keys, tokens, or cookies into memory — record only where',
202
325
  'the secret lives.',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-engramory",
3
- "version": "0.1.1",
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.1",
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",