dsh-engramory 0.2.1 → 0.2.3
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 +438 -327
- package/package.json +56 -56
package/index.js
CHANGED
|
@@ -1,327 +1,438 @@
|
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
`
|
|
185
|
-
`
|
|
186
|
-
`
|
|
187
|
-
`
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
function
|
|
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, run a curation ' +
|
|
101
|
+
'checkpoint when a task finishes, and sync before compacting or opening a ' +
|
|
102
|
+
'fresh thread.',
|
|
103
|
+
whenToUse:
|
|
104
|
+
'Starting or resuming work, learning something durable worth a future session, ' +
|
|
105
|
+
'finishing a task — including one that leaves nothing worth saving, since the ' +
|
|
106
|
+
'checkpoint is still a decision to make — or approaching a ' +
|
|
107
|
+
'compact/clear/new-thread boundary.',
|
|
108
|
+
source: 'runtime',
|
|
109
|
+
content: skill,
|
|
110
|
+
}))
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The cap itself. Returning a string denies the call; `undefined` lets it through.
|
|
117
|
+
*
|
|
118
|
+
* Guards are synchronous by contract, so this stays cheap: one basename comparison
|
|
119
|
+
* rejects the overwhelming majority of calls before anything is measured, and the
|
|
120
|
+
* only I/O is a single read of the index on a gated write.
|
|
121
|
+
*/
|
|
122
|
+
function refuseOversizedIndex(exec, settings) {
|
|
123
|
+
const args = exec?.arguments
|
|
124
|
+
if (!args || typeof args !== 'object') return undefined
|
|
125
|
+
|
|
126
|
+
// dsh's file tools carry the target as `file_path`; `str_replace_editor` calls it
|
|
127
|
+
// `path`. Compare basenames case-INsensitively: on the case-insensitive
|
|
128
|
+
// filesystems most stores live on (Windows/macOS), `memory.md` IS the guarded
|
|
129
|
+
// index, and an exact compare let that spelling through. (Same trade-off as the
|
|
130
|
+
// Python guard: on a case-sensitive FS this can gate an unrelated lowercase twin —
|
|
131
|
+
// rename it, or point `indexName` elsewhere.)
|
|
132
|
+
const filePath = typeof args.file_path === 'string' ? args.file_path
|
|
133
|
+
: typeof args.path === 'string' ? args.path
|
|
134
|
+
: undefined
|
|
135
|
+
if (filePath === undefined) return undefined
|
|
136
|
+
if (basename(filePath).toLowerCase() !== settings.matchName) return undefined
|
|
137
|
+
|
|
138
|
+
const tool = typeof exec.name === 'string' ? exec.name : ''
|
|
139
|
+
|
|
140
|
+
// Nothing below can refuse a read, a view, or an unknown tool, so leave before the
|
|
141
|
+
// pinned-path check: pathKey() touches the filesystem and a guard runs on EVERY tool
|
|
142
|
+
// call. Ordering it here keeps the common case a pair of string compares.
|
|
143
|
+
const gated = WHOLE_FILE_WRITES.has(tool)
|
|
144
|
+
|| PARTIAL_WRITES.has(tool)
|
|
145
|
+
|| (tool === 'str_replace_editor'
|
|
146
|
+
&& (args.command === 'create' || args.command === 'str_replace'
|
|
147
|
+
|| args.command === 'insert'))
|
|
148
|
+
if (!gated) return undefined
|
|
149
|
+
// If the guard is pinned to one file, this is where an unrelated same-named file in
|
|
150
|
+
// another project drops out.
|
|
151
|
+
if (settings.indexKey !== undefined && pathKey(filePath) !== settings.indexKey) {
|
|
152
|
+
return undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (WHOLE_FILE_WRITES.has(tool)) {
|
|
156
|
+
if (typeof args.content !== 'string') return undefined
|
|
157
|
+
return verdict(args.content, readIfPossible(filePath), filePath, settings)
|
|
158
|
+
}
|
|
159
|
+
if (tool === 'str_replace_editor' && args.command === 'create') {
|
|
160
|
+
if (typeof args.file_text !== 'string') return undefined
|
|
161
|
+
return verdict(args.file_text, readIfPossible(filePath), filePath, settings)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const partial = PARTIAL_WRITES.has(tool)
|
|
165
|
+
|| (tool === 'str_replace_editor'
|
|
166
|
+
&& (args.command === 'str_replace' || args.command === 'insert'))
|
|
167
|
+
if (!partial) return undefined // read / view / list / unknown: never refused
|
|
168
|
+
|
|
169
|
+
const currentBuf = readIfPossible(filePath)
|
|
170
|
+
if (currentBuf === undefined) return undefined
|
|
171
|
+
const current = currentBuf.toString('utf8')
|
|
172
|
+
const simulated = simulateEdit(current, args)
|
|
173
|
+
if (simulated !== undefined) {
|
|
174
|
+
return verdict(simulated, currentBuf, filePath, settings)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The edit's result genuinely cannot be reconstructed from its arguments. Honest,
|
|
178
|
+
// cheap rule: if the index is ALREADY over, refuse to mutate it blind — and name
|
|
179
|
+
// the open door, because "compact first" while refusing edits reads as a dead end.
|
|
180
|
+
const breach = measure(current, settings)
|
|
181
|
+
if (!breach) return undefined
|
|
182
|
+
return (
|
|
183
|
+
`${displayName(settings)} is already over the Engramory cap (${breach}) and this ` +
|
|
184
|
+
`edit's result cannot be measured. Compact it with a whole-file write instead: ` +
|
|
185
|
+
`a rewrite that SHRINKS the index always passes, even while still over the cap. ` +
|
|
186
|
+
`Pointer-ify over-long lines, merge duplicates, archive cold notes — the index ` +
|
|
187
|
+
`is a table of contents, and anything past the cap silently stops being recalled.` +
|
|
188
|
+
notMyIndexHint(settings)
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mirrors the Python guard's deny rule: refuse only when a dimension ends OVER its
|
|
194
|
+
* cap AND grew past the current file, so a shrinking/keeping write on an over-cap
|
|
195
|
+
* index always passes and incremental compaction stays possible. A missing or
|
|
196
|
+
* unreadable current file counts as empty — a first write past the cap is refused.
|
|
197
|
+
*/
|
|
198
|
+
function verdict(text, currentBuf, filePath, settings) {
|
|
199
|
+
const lines = countLines(text)
|
|
200
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
201
|
+
// Current byte size comes from the RAW buffer, mirroring how the Python guard
|
|
202
|
+
// sizes the on-disk file: decoding first inflated a non-UTF-8 index ~3x (every
|
|
203
|
+
// bad byte becomes a 3-byte U+FFFD), which made a genuinely growing write look
|
|
204
|
+
// like a shrink and pass. Newlines survive a lossy decode, so the line count may
|
|
205
|
+
// use the decoded text.
|
|
206
|
+
const curLines = currentBuf === undefined ? 0 : countLines(currentBuf.toString('utf8'))
|
|
207
|
+
const curBytes = currentBuf === undefined ? 0 : currentBuf.length
|
|
208
|
+
const over = []
|
|
209
|
+
if (lines > settings.maxLines && lines > curLines) {
|
|
210
|
+
over.push(`${lines} lines > ${settings.maxLines}`)
|
|
211
|
+
}
|
|
212
|
+
if (bytes > settings.maxBytes && bytes > curBytes) {
|
|
213
|
+
over.push(`${bytes} bytes > ${settings.maxBytes}`)
|
|
214
|
+
}
|
|
215
|
+
if (!over.length) return undefined
|
|
216
|
+
return (
|
|
217
|
+
`This write would put ${basename(filePath)} over the Engramory index cap ` +
|
|
218
|
+
`(${over.join(', ')}). The index is loaded every session and the host only reads ` +
|
|
219
|
+
`so far, so anything past the cap silently stops being recalled. Compact before ` +
|
|
220
|
+
`writing: move detail into the linked note files, merge duplicates, archive cold ` +
|
|
221
|
+
`notes, and keep every line to "one short hook + link". A write that SHRINKS the ` +
|
|
222
|
+
`index always passes, so you can compact step by step.` + notMyIndexHint(settings)
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The name to show in a refusal. `indexPath` wins over `indexName` when both are set,
|
|
229
|
+
* so quoting the ignored one would send a user looking for the wrong file.
|
|
230
|
+
*/
|
|
231
|
+
function displayName(settings) {
|
|
232
|
+
return settings.indexPath === undefined
|
|
233
|
+
? settings.indexName
|
|
234
|
+
: basename(settings.indexPath)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Without `indexPath` the guard matches on basename alone, so it can land on a file
|
|
240
|
+
* that is not a memory index at all. Say so in the refusal: a user who hits this
|
|
241
|
+
* needs the way out, not just the cap.
|
|
242
|
+
*/
|
|
243
|
+
function notMyIndexHint(settings) {
|
|
244
|
+
if (settings.indexPath !== undefined) return ''
|
|
245
|
+
return (
|
|
246
|
+
` (If this file is NOT your memory index, set this plugin's \`indexPath\` config ` +
|
|
247
|
+
`to your real index's absolute path so only that file is gated.)`
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Reconstruct a partial edit's post-write text when its arguments carry enough to
|
|
253
|
+
* do it (`old_str`/`new_str`, or the Claude-style `old_string`/`new_string`).
|
|
254
|
+
* Mirrors the Python guard's Edit simulation: replace the unique occurrence, or all
|
|
255
|
+
* of them under a replace-all flag; an absent or ambiguous old-string means the
|
|
256
|
+
* real tool errors and changes nothing, so the current text is the honest
|
|
257
|
+
* prediction. Returns undefined when the shape isn't recognised.
|
|
258
|
+
*/
|
|
259
|
+
function simulateEdit(current, args) {
|
|
260
|
+
const oldStr = firstString(args.old_str, args.old_string)
|
|
261
|
+
if (oldStr === undefined || oldStr === '') return undefined
|
|
262
|
+
const newStr = firstString(args.new_str, args.new_string) ?? ''
|
|
263
|
+
if (args.replace_all === true || args.replaceAll === true) {
|
|
264
|
+
return current.split(oldStr).join(newStr)
|
|
265
|
+
}
|
|
266
|
+
const first = current.indexOf(oldStr)
|
|
267
|
+
if (first === -1) return current // the real tool errors: nothing changes
|
|
268
|
+
const second = current.indexOf(oldStr, first + oldStr.length)
|
|
269
|
+
if (second !== -1) return current // ambiguous: the real tool errors, nothing changes
|
|
270
|
+
return current.slice(0, first) + newStr + current.slice(first + oldStr.length)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function firstString(...values) {
|
|
274
|
+
for (const v of values) {
|
|
275
|
+
if (typeof v === 'string') return v
|
|
276
|
+
}
|
|
277
|
+
return undefined
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Returns a human-readable breach description, or `null` when the text is within caps. */
|
|
281
|
+
function measure(text, { maxLines, maxBytes }) {
|
|
282
|
+
const lines = countLines(text)
|
|
283
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
284
|
+
const over = []
|
|
285
|
+
if (lines > maxLines) over.push(`${lines} lines > ${maxLines}`)
|
|
286
|
+
if (bytes > maxBytes) over.push(`${bytes} bytes > ${maxBytes}`)
|
|
287
|
+
return over.length ? over.join(', ') : null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Trailing-newline-insensitive, matching how the Python guard counts. */
|
|
291
|
+
function countLines(text) {
|
|
292
|
+
if (!text) return 0
|
|
293
|
+
return text.split('\n').length - (text.endsWith('\n') ? 1 : 0)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A missing or unreadable index is not a breach: the guard's job is to stop an
|
|
298
|
+
* oversized index, never to block work because a path could not be read. Returns the
|
|
299
|
+
* RAW buffer — byte comparisons must use on-disk bytes (see verdict), decode at the
|
|
300
|
+
* call sites that need text.
|
|
301
|
+
*/
|
|
302
|
+
function readIfPossible(filePath) {
|
|
303
|
+
try {
|
|
304
|
+
return readFileSync(filePath)
|
|
305
|
+
} catch {
|
|
306
|
+
return undefined
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function positive(value, fallback) {
|
|
311
|
+
if (!Number.isFinite(value)) return fallback
|
|
312
|
+
const floored = Math.floor(value)
|
|
313
|
+
return floored > 0 ? floored : fallback
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Normalise a path for identity comparison. `realpath` resolves symlinks and `..` so
|
|
318
|
+
* two spellings of the same file compare equal, but it throws for a path with no file
|
|
319
|
+
* behind it yet — and a plain `resolve` fallback is NOT interchangeable with it: an
|
|
320
|
+
* index pinned before it exists, under a symlinked ancestor, would be keyed by its
|
|
321
|
+
* alias at config time and by the link's target on every later call. The keys would
|
|
322
|
+
* never match again and the guard would silently stop guarding its own index.
|
|
323
|
+
*
|
|
324
|
+
* So resolve the deepest ancestor that DOES exist and re-attach the missing tail:
|
|
325
|
+
* the same file then keys identically whether or not it exists yet. Case is folded on
|
|
326
|
+
* Windows only, mirroring `os.path.normcase` in the Python guard — folding it on Linux
|
|
327
|
+
* would make an unrelated `memory.md` collide with the pinned index. (Known limit:
|
|
328
|
+
* `toLowerCase` is not NTFS's exact case-folding table, same as the Python guard.)
|
|
329
|
+
*/
|
|
330
|
+
function pathKey(p) {
|
|
331
|
+
let head = resolve(p)
|
|
332
|
+
const tail = []
|
|
333
|
+
for (;;) {
|
|
334
|
+
try {
|
|
335
|
+
head = realpathSync(head)
|
|
336
|
+
break
|
|
337
|
+
} catch {
|
|
338
|
+
const parent = dirname(head)
|
|
339
|
+
if (parent === head) break // hit the root with nothing resolvable
|
|
340
|
+
tail.unshift(basename(head))
|
|
341
|
+
head = parent
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const out = tail.length ? join(head, ...tail) : head
|
|
345
|
+
return process.platform === 'win32' ? out.toLowerCase() : out
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* An empty or non-string indexName must fall back, not silently disable the cap:
|
|
351
|
+
* the numeric caps already recover from nonsense values, and this field is the one
|
|
352
|
+
* that decides whether the guard fires at all.
|
|
353
|
+
*/
|
|
354
|
+
function indexNameOf(value) {
|
|
355
|
+
return typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_INDEX_NAME
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The always-on block states the discipline; this is the on-demand detail the model
|
|
360
|
+
* pulls when it is actually about to recall, write, or sync. Kept deliberately short —
|
|
361
|
+
* the full protocol is SKILL.md in the Engramory repo, and a skill body that has to be
|
|
362
|
+
* read before it is useful is a skill that will not be read.
|
|
363
|
+
*/
|
|
364
|
+
function builtinSkillBody() {
|
|
365
|
+
return [
|
|
366
|
+
'# Engramory — curated file-based memory',
|
|
367
|
+
'',
|
|
368
|
+
'One canonical store: `MEMORY.md` is an index of pointers, one line per memory;',
|
|
369
|
+
'each fact lives in its own small markdown file beside it. Never put content in',
|
|
370
|
+
'the index, and never keep a second parallel store for handoffs. The ACTIVE',
|
|
371
|
+
'store is flat — the four type names are values of the `type:` field, not',
|
|
372
|
+
'subdirectories to create; `archive/` is the one reserved subdirectory, for',
|
|
373
|
+
'notes retired out of the index.',
|
|
374
|
+
'',
|
|
375
|
+
'## Recall',
|
|
376
|
+
'',
|
|
377
|
+
'At the start of a task, read the index and open only the notes whose hooks look',
|
|
378
|
+
'relevant. Treat what you recall as background that may be stale: re-verify any',
|
|
379
|
+
'file, flag, or version against the repo before acting on it.',
|
|
380
|
+
'',
|
|
381
|
+
'## Write',
|
|
382
|
+
'',
|
|
383
|
+
'Before writing, confirm the fact is not already in the repo, git history, or the',
|
|
384
|
+
'instruction files, and that it is not a secret value. Search the index and update',
|
|
385
|
+
'an existing note rather than adding a near-duplicate. A new note is one atomic',
|
|
386
|
+
'fact with frontmatter:',
|
|
387
|
+
'',
|
|
388
|
+
'```markdown',
|
|
389
|
+
'---',
|
|
390
|
+
'name: <kebab-case-slug>',
|
|
391
|
+
'description: <one sharp line — this is what future-you reads to decide to open it>',
|
|
392
|
+
'type: user | feedback | project | reference',
|
|
393
|
+
'scope: global | repo # optional: does this still hold in another repo?',
|
|
394
|
+
'created: YYYY-MM-DD',
|
|
395
|
+
'updated: YYYY-MM-DD',
|
|
396
|
+
'---',
|
|
397
|
+
'```',
|
|
398
|
+
'',
|
|
399
|
+
'`feedback` and `project` notes MUST carry a `**Why:**` line and a',
|
|
400
|
+
'`**How to apply:**` line. Add exactly one pointer line to the index, and delete',
|
|
401
|
+
'memories that turn out to be wrong.',
|
|
402
|
+
'',
|
|
403
|
+
'Store settled facts, never current state: "2.0 shipped on 2026-01-15" is durable;',
|
|
404
|
+
'"the current version is X", the tip commit, or a passing test count will rot —',
|
|
405
|
+
'record where to read those instead.',
|
|
406
|
+
'',
|
|
407
|
+
'An unfinished task may keep AT MOST ONE live `project` note holding its goal,',
|
|
408
|
+
'status, decisions, constraints, blockers, and next step together — the single',
|
|
409
|
+
'exception to one-file-one-fact, and a ceiling rather than a quota: a task that',
|
|
410
|
+
'needs no resumable state keeps no note. Update it IN PLACE. Never a dated',
|
|
411
|
+
'series (`state-2026-01-15.md`, `state-2026-01-16.md`, …), never a second',
|
|
412
|
+
'handoff log indexed beside it, and retire its transient state once it ends.',
|
|
413
|
+
'',
|
|
414
|
+
'## When a task finishes',
|
|
415
|
+
'',
|
|
416
|
+
'Run one curation checkpoint. It is a judgement, not a write: promote what is',
|
|
417
|
+
'durable, retire the transient state the CURRENT task left on its own live',
|
|
418
|
+
'`project` note if it has one, and when nothing is worth keeping, write',
|
|
419
|
+
'nothing and say so. Never append a per-turn log TO THE STORE, and never touch',
|
|
420
|
+
'a file just to mark it fresh — a timestamp is not a memory.',
|
|
421
|
+
'',
|
|
422
|
+
'## Sync',
|
|
423
|
+
'',
|
|
424
|
+
'Before a deliberate compact, clear, or new thread: scan the task, dedup and update,',
|
|
425
|
+
'refresh project state, promote only reusable feedback, retire completed transient',
|
|
426
|
+
'state, then report what was added, updated, archived, and skipped.',
|
|
427
|
+
'',
|
|
428
|
+
'## The cap is enforced here',
|
|
429
|
+
'',
|
|
430
|
+
'This host denies a write that would GROW the index past 200 lines / 25 KB. That is',
|
|
431
|
+
'a real refusal, not a warning — but a write that shrinks the index always passes,',
|
|
432
|
+
'so compact first, step by step if needed: the index is loaded every session, and',
|
|
433
|
+
'anything past the cap silently stops being recalled.',
|
|
434
|
+
'',
|
|
435
|
+
'Never write credentials, keys, tokens, or cookies into memory — record only where',
|
|
436
|
+
'the secret lives.',
|
|
437
|
+
].join('\n')
|
|
438
|
+
}
|