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.
- package/README.md +59 -36
- package/index.js +173 -50
- 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
|
|
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.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
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
|
-
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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`
|
|
66
|
-
| `write`
|
|
67
|
-
|
|
|
68
|
-
|
|
|
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
|
|
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
|
|
73
|
-
|
|
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
|
|
78
|
-
carry the resulting text
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
- **
|
|
82
|
-
`
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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.
|
|
11
|
-
* cap degrades to "rules plus a checker the agent has to
|
|
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
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
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
|
-
`
|
|
93
|
-
`
|
|
94
|
-
`
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
`(${
|
|
105
|
-
`so anything past the cap silently stops being recalled. Compact before
|
|
106
|
-
`move detail into the linked note files, merge duplicates, archive cold
|
|
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
|
|
244
|
+
return readFileSync(filePath)
|
|
134
245
|
} catch {
|
|
135
246
|
return undefined
|
|
136
247
|
}
|
|
137
248
|
}
|
|
138
249
|
|
|
139
250
|
function positive(value, fallback) {
|
|
140
|
-
|
|
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
|
|
198
|
-
'a real refusal, not a warning
|
|
199
|
-
'
|
|
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.
|
|
4
|
-
"description": "Curated file-based long-term memory for DeepSeek Harness — human-readable markdown notes, one
|
|
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",
|