@try-works/dsh-memory 0.1.20 → 0.1.21

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/STATUS.md CHANGED
@@ -61,6 +61,7 @@ Observed in the running host. Each row names the effect that was seen, not a cla
61
61
  | Injection metric counts injections | `events.inject` advances by 1 per injection; the old character total is preserved under `legacy.inject` |
62
62
  | `memory_recover` is wired | calling it no longer answers "unavailable in this context"; it reaches output validation |
63
63
  | Session logs are searchable in principle | zstd, not encrypted; 368 logs, 740MB compressed, ~2.96GB plaintext |
64
+ | `memory/map` events carry `ignorable: true` | the live session log's own event is marked, and both dsh-memory logs pass the harness's `validateStoredEvents` — the exact check that refused session-cc4795a7 with "unknown to this harness and not marked ignorable" |
64
65
 
65
66
  ## WRITTEN but NOT LOADED
66
67
 
@@ -71,6 +72,7 @@ Implemented and tested on disk. The running host predates them, so none of this
71
72
  | `due()` priority ordering | retry → abandon → work → recover, so a bounded batch reaches a due retry | measured: the retry sat at position 101 of 111 and is now position 1 |
72
73
  | `hostExtras(host)` as one source | `index.js` and the wiring test shared a copied list that drifted | the test now derives extras from the host object |
73
74
  | `memory_recover` report omits its `entries` field rather than blanking it | the old line added `entries: undefined`, which is NOT valid JSON, so the harness rejected the whole result | the new guard round-trips every tool output; reintroducing it fails with `value.entries is undefined` |
75
+ | Publisher stops when the harness does not stamp `ignorable` (0.1.21) | ≤0.1.19 appended `memory/map` WITHOUT the marker into ten real session logs, so every later load of those sessions failed ("unknown to this harness and not marked ignorable"). The guard checks the RETURN VALUE of `session.append` and suppresses publishing for that session at the first refusal, instead of poisoning the log | 403 tests pass; the new test drives a harness-shaped stub that returns an unstamped event and asserts the next turn appends nothing, even with a changed store and a passed throttle |
74
76
 
75
77
  How to tell loaded from not: ask the host. `memory_recover` used to reply *"unavailable in this
76
78
  context"* and now replies *"returned invalid output: value is not lossless JSON"*. The change of
@@ -145,6 +147,11 @@ the flag on did not change what the user sees.
145
147
 
146
148
  ### Blocked on a restart
147
149
 
150
+ - [ ] **Publish 0.1.21, install, restart.** The guard against unmarked `memory/map` events is
151
+ written and tested (403 pass) but the publish needs npm credentials that were not available
152
+ in this session (`npm publish` → ENEEDAUTH). Installed 0.1.20 already appends WITH the
153
+ marker, so nothing is at risk while this waits; the guard only protects against a harness
154
+ older than the `append(type, data, { ignorable: true })` option.
148
155
  - [x] **Restart, then check the tasks layer — DONE 2026-10-09.** The restart confirmed it. See the
149
156
  tasks-layer section above for the two log entries and the counts. It writes to ANOTHER plugin's storage through
150
157
  that plugin's service, so it deserves a real observation rather than a passing suite. After
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Git-backed, workspace-scoped memory for the DeepSeek Harness (global layer + per-workspace layers)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,40 @@
1
+ /* eslint-disable no-console */
2
+ // One-shot diagnostic: decompress a session.v4.jsonl.zstd artifact with the
3
+ // harness's own frame primitives and report memory/map rows and their envelope.
4
+ import { readFileSync } from 'node:fs'
5
+ import { scanZstdFrames, decompressZstdFrame } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
6
+
7
+ const file = process.argv[2]
8
+ if (!file) throw new Error('usage: inspect-log.ts <session.v4.jsonl.zstd>')
9
+
10
+ const buf = readFileSync(file)
11
+ const scan = scanZstdFrames(buf)
12
+ const lines: string[] = []
13
+ for (const frame of scan.frames) {
14
+ const plain = await decompressZstdFrame(buf.subarray(frame.start, frame.end))
15
+ lines.push(...plain.toString('utf8').split('\n').filter((l) => l.trim() !== ''))
16
+ }
17
+ console.log(`frames=${scan.frames.length} torn=${scan.tornStart ?? '-'} rows=${lines.length}`)
18
+ if (scan.tornStart !== undefined) console.log(`tornStart=${scan.tornStart}`)
19
+
20
+ const header = lines[0]
21
+ console.log(`header: ${header?.slice(0, 300) ?? '(none)'}`)
22
+
23
+ const hits = lines.filter((l) => l.startsWith('{"type":"memory/map"'))
24
+ console.log(`\nmemory/map EVENT rows: ${hits.length}`)
25
+ for (const h of hits) {
26
+ let env: { seq?: number; time?: number; ignorable?: unknown } = {}
27
+ try { env = JSON.parse(h) } catch { /* not json */ }
28
+ console.log(`seq=${env.seq} time=${env.time} ignorable=${JSON.stringify(env.ignorable)} keys=${Object.keys(env).join(',')}`)
29
+ }
30
+
31
+ const unmarked = hits.filter((l) => !l.includes('"ignorable":true'))
32
+ console.log(`\nUNMARKED memory/map events: ${unmarked.length}`)
33
+ for (const h of unmarked.slice(0, 10)) console.log(h.slice(0, 300))
34
+
35
+ console.log('\nlast 4 rows:')
36
+ for (const l of lines.slice(-4)) {
37
+ let env: { type?: string; seq?: number; time?: number } = {}
38
+ try { env = JSON.parse(l) } catch { /* not json */ }
39
+ console.log(`type=${env.type} seq=${env.seq} time=${env.time}`)
40
+ }
@@ -0,0 +1,130 @@
1
+ /* eslint-disable no-console */
2
+ // One-shot repair for the dsh-memory "unknown event type" incident.
3
+ //
4
+ // Plugin <=0.1.19 appended `{"type":"memory/map",...}` events WITHOUT the
5
+ // `ignorable: true` envelope marker. The harness's fail-closed storage contract
6
+ // (session-persistence/src/storage-contract.ts) refuses such logs on every
7
+ // load: "unknown to this harness and not marked ignorable". This rewrites each
8
+ // poisoned log with the marker inserted into the memory/map rows, preserving
9
+ // every other byte, the header-only first frame, frame checksums and seq
10
+ // contiguity, then verifies the result through the harness's own validator.
11
+ //
12
+ // Idempotent: files with zero unmarked memory/map events are left untouched.
13
+ // Usage: node --import tsx/esm scripts/repair-ignorable.ts [session-dir ...]
14
+ // (no args = scan the default sessions root and repair every broken log)
15
+ import { copyFileSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
16
+ import { join, basename } from 'node:path'
17
+ import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix, compressZstdFrame } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
18
+ import { validateStoredEvents } from 'file:///D:/deepseek-harness/packages/session/session-persistence/src/storage-contract.ts'
19
+
20
+ const ROOT = 'C:/Users/erikb/.dsh/sessions'
21
+ const CUT_MS = Date.parse('2026-10-09T20:53:00Z')
22
+
23
+ /** Insert the marker into one memory/map row, preserving every other byte of the line. */
24
+ function markLine(line) {
25
+ if (!line.startsWith('{"type":"memory/map"') || line.includes('"ignorable":true')) return { line, marked: false }
26
+ const edited = line.replace(/^(\{"type":"memory\/map","seq":\d+,"time":\d+,)/, '$1"ignorable":true,')
27
+ if (edited === line) throw new Error(`memory/map row did not match the envelope pattern: ${line.slice(0, 80)}`)
28
+ return { line: edited, marked: true }
29
+ }
30
+
31
+ function markText(text) {
32
+ let marked = 0
33
+ const out = text.split('\n').map((line) => {
34
+ const r = markLine(line)
35
+ if (r.marked) marked += 1
36
+ return r.line
37
+ }).join('\n')
38
+ return { text: out, marked }
39
+ }
40
+
41
+ /** Repair one file in place; returns a report or throws. */
42
+ async function repairFile(file) {
43
+ const buf = readFileSync(file)
44
+ const scan = scanZstdFrames(buf)
45
+ const frames = []
46
+ for (const frame of scan.frames) {
47
+ const plain = await decompressZstdFrame(buf.subarray(frame.start, frame.end))
48
+ frames.push(plain.toString('utf8'))
49
+ }
50
+ if (scan.tornStart !== undefined) {
51
+ const prefix = await decompressZstdPrefix(buf.subarray(scan.tornStart))
52
+ frames.push(prefix.toString('utf8'))
53
+ }
54
+ let marked = 0
55
+ const edited = []
56
+ for (const text of frames) {
57
+ const r = markText(text)
58
+ marked += r.marked
59
+ edited.push(r.text)
60
+ }
61
+ if (marked === 0) return { file, repaired: false, reason: 'no unmarked memory/map events' }
62
+
63
+ // Compress each frame back as its own checksummed frame: frame 1 stays the
64
+ // header-only frame, batch boundaries are preserved, and a recovered torn
65
+ // prefix becomes a complete final frame.
66
+ const parts = []
67
+ for (const text of edited) parts.push(await compressZstdFrame(Buffer.from(text, 'utf8')))
68
+ const out = Buffer.concat(parts)
69
+
70
+ const backup = `${file}.bak-pre-ignorable`
71
+ if (!existsSync(backup)) copyFileSync(file, backup)
72
+ const tmp = `${file}.repair.tmp`
73
+ writeFileSync(tmp, out)
74
+ renameSync(tmp, file)
75
+ return { file, repaired: true, marked }
76
+ }
77
+
78
+ /** Verify a file the way the gateway will read it: frames decode, frame 1 is
79
+ * header-only, every row parses, seq is contiguous, and the storage contract
80
+ * (the exact validator that refused these logs) accepts every event. */
81
+ async function verifyFile(file) {
82
+ const buf = readFileSync(file)
83
+ const scan = scanZstdFrames(buf)
84
+ if (scan.frames.length === 0) throw new Error('no frames')
85
+ const header = (await decompressZstdFrame(buf.subarray(scan.frames[0].start, scan.frames[0].end))).toString('utf8')
86
+ if (header.indexOf('\n') !== header.length - 1) throw new Error('first frame is not exactly one header line')
87
+ const lines = [header.trimEnd()]
88
+ for (const frame of scan.frames.slice(1)) {
89
+ lines.push(...(await decompressZstdFrame(buf.subarray(frame.start, frame.end))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
90
+ }
91
+ if (scan.tornStart !== undefined) {
92
+ lines.push(...(await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
93
+ }
94
+ const rows = lines.map((l) => JSON.parse(l))
95
+ const meta = rows[0]
96
+ const events = rows.slice(1)
97
+ // Relative contiguity: a fork inherits a prefix, so the log may start at a nonzero seq.
98
+ for (let i = 1; i < events.length; i += 1) {
99
+ if (events[i].seq !== events[0].seq + i) throw new Error(`seq gap at index ${i}: ${events[i].seq}`)
100
+ }
101
+ const unmarked = events.filter((e) => e.type === 'memory/map' && e.ignorable !== true)
102
+ if (unmarked.length > 0) throw new Error(`${unmarked.length} memory/map events still unmarked`)
103
+ validateStoredEvents(meta, events, { kind: 'jsonl', path: file })
104
+ return { rows: rows.length, events: events.length, memoryMaps: events.filter((e) => e.type === 'memory/map').length }
105
+ }
106
+
107
+ function collectFiles() {
108
+ const dirs = process.argv.slice(2).filter((a) => !a.startsWith('-'))
109
+ if (dirs.length > 0) return dirs.map((d) => join(d, 'session.v4.jsonl.zstd'))
110
+ const out = []
111
+ const walk = (dir) => {
112
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
113
+ const p = join(dir, e.name)
114
+ if (e.isDirectory()) walk(p)
115
+ else if (e.name.endsWith('.zstd') && !e.name.includes('.bak') && statSync(p).mtimeMs >= CUT_MS) out.push(p)
116
+ }
117
+ }
118
+ walk(ROOT)
119
+ return out
120
+ }
121
+
122
+ let repaired = 0
123
+ for (const file of collectFiles()) {
124
+ const r = await repairFile(file)
125
+ if (!r.repaired) continue
126
+ repaired += 1
127
+ const v = await verifyFile(file)
128
+ console.log(`repaired ${basename(file)}: marked ${r.marked} events; verified ${v.events} events, ${v.memoryMaps} memory/map rows, storage contract OK`)
129
+ }
130
+ console.log(`\nrepaired ${repaired} file(s); backups kept as <file>.bak-pre-ignorable`)
@@ -0,0 +1,43 @@
1
+ /* eslint-disable no-console */
2
+ // One-shot diagnostic: scan every session log modified since the first
3
+ // memory/map publish and report unmarked memory/map events per file.
4
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
5
+ import { join, basename } from 'node:path'
6
+ import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
7
+
8
+ const root = process.argv[2] ?? 'C:/Users/erikb/.dsh/sessions'
9
+ const cut = Date.parse(process.argv[3] ?? '2026-10-09T20:53:00Z')
10
+
11
+ function walk(dir: string): string[] {
12
+ const out: string[] = []
13
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
14
+ const p = join(dir, e.name)
15
+ if (e.isDirectory()) out.push(...walk(p))
16
+ else if (e.name.endsWith('.zstd') && !e.name.includes('.bak') && statSync(p).mtimeMs >= cut) out.push(p)
17
+ }
18
+ return out
19
+ }
20
+
21
+ let scanned = 0
22
+ for (const file of walk(root)) {
23
+ scanned += 1
24
+ let text = ''
25
+ try {
26
+ const buf = readFileSync(file)
27
+ const scan = scanZstdFrames(buf)
28
+ for (const f of scan.frames) text += (await decompressZstdFrame(buf.subarray(f.start, f.end))).toString('utf8')
29
+ if (scan.tornStart !== undefined) text += (await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString('utf8')
30
+ } catch (err) {
31
+ console.log(`${basename(file)}: DECOMPRESS ERROR ${String(err).slice(0, 120)}`)
32
+ continue
33
+ }
34
+ let events = 0
35
+ let unmarked = 0
36
+ for (const line of text.split('\n')) {
37
+ if (!line.startsWith('{"type":"memory/map"')) continue
38
+ events += 1
39
+ if (!line.includes('"ignorable":true')) unmarked += 1
40
+ }
41
+ if (events > 0) console.log(`${unmarked > 0 ? 'BROKEN ' : 'ok '} events=${events} unmarked=${unmarked} ${file}`)
42
+ }
43
+ console.log(`scanned ${scanned} files`)
@@ -0,0 +1,26 @@
1
+ /* eslint-disable no-console */
2
+ // Read-only: run the harness's own storage-contract validator over a live log.
3
+ import { readFileSync } from 'node:fs'
4
+ import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
5
+ import { validateStoredEvents } from 'file:///D:/deepseek-harness/packages/session/session-persistence/src/storage-contract.ts'
6
+
7
+ for (const file of process.argv.slice(2)) {
8
+ try {
9
+ const buf = readFileSync(file)
10
+ const scan = scanZstdFrames(buf)
11
+ const header = (await decompressZstdFrame(buf.subarray(scan.frames[0].start, scan.frames[0].end))).toString('utf8')
12
+ if (header.indexOf('\n') !== header.length - 1) throw new Error('first frame is not exactly one header line')
13
+ const lines = [header.trimEnd()]
14
+ for (const frame of scan.frames.slice(1)) {
15
+ lines.push(...(await decompressZstdFrame(buf.subarray(frame.start, frame.end))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
16
+ }
17
+ if (scan.tornStart !== undefined) {
18
+ lines.push(...(await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
19
+ }
20
+ const rows = lines.map((l) => JSON.parse(l))
21
+ validateStoredEvents(rows[0], rows.slice(1), { kind: 'jsonl', path: file })
22
+ console.log(`OK ${rows.length} rows, ${rows.slice(1).filter((e) => e.type === 'memory/map').length} memory/map: ${file}`)
23
+ } catch (err) {
24
+ console.log(`FAIL ${file}: ${String(err).slice(0, 300)}`)
25
+ }
26
+ }
package/src/host.js CHANGED
@@ -74,6 +74,7 @@ export function wireHost(ctx, store, cfg) {
74
74
  let restoreAttempted = false
75
75
  const lastMapPublish = new Map()
76
76
  const lastMapSignature = new Map()
77
+ const mapPublishSuppressed = new Set()
77
78
  let lastHistoryAttempt = 0
78
79
  let syncInFlight = false
79
80
 
@@ -721,6 +722,9 @@ export function wireHost(ctx, store, cfg) {
721
722
  try {
722
723
  if (!session || typeof session.append !== 'function' || !session.id) return
723
724
  if (isSubagentSession(session)) return
725
+ // A session whose harness did not honor ignorable is poisoned by every further append;
726
+ // the decision to stop is made once, below, and honored on every later attempt.
727
+ if (mapPublishSuppressed.has(session.id)) return
724
728
  const now = Date.now()
725
729
  if (!force && now - (lastMapPublish.get(session.id) ?? 0) < MAP_PUBLISH_MS) return
726
730
  lastMapPublish.set(session.id, now)
@@ -737,7 +741,21 @@ export function wireHost(ctx, store, cfg) {
737
741
  const signature = JSON.stringify({ ...payload, generatedAt: '' })
738
742
  if (!force && lastMapSignature.get(session.id) === signature) return
739
743
  lastMapSignature.set(session.id, signature)
740
- session.append('memory/map', payload)
744
+ // Out-of-repo event types are absent from KNOWN_SESSION_EVENT_TYPES; without
745
+ // ignorable:true the persistence read path refuses the whole session log.
746
+ // The append returns the logged event: if the harness did NOT stamp
747
+ // ignorable onto it (a harness older than the option), the very next load
748
+ // of this session would fail with "unknown to this harness and not marked
749
+ // ignorable". Detecting that from the return value lets us stop before a
750
+ // log is poisoned, instead of discovering it when history fails to load.
751
+ const logged = session.append('memory/map', payload, { ignorable: true })
752
+ if (logged?.ignorable !== true) {
753
+ if (!mapPublishSuppressed.has(session.id)) {
754
+ mapPublishSuppressed.add(session.id)
755
+ logRun(store.root, 'map-publish', { ok: false, error: 'harness did not stamp ignorable; publishing suppressed for this session' })
756
+ }
757
+ return
758
+ }
741
759
  logRun(store.root, 'map-publish', { ok: true, scope, key: key ?? '', bytes: signature.length })
742
760
  } catch (error) {
743
761
  // Never let a view break the conversation it is describing.