@try-works/dsh-memory 0.1.20 → 0.1.22

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,12 @@ the flag on did not change what the user sees.
145
147
 
146
148
  ### Blocked on a restart
147
149
 
150
+ - [ ] **Restart to load 0.1.21.** Published and installed: 0.1.21 is on npm (tarball byte-identical to
151
+ source) and installed in the profile (host.js byte-identical, guard at host.js:727/753). The
152
+ profile's minimumReleaseAge gate needed its dsh-memory exclusion extended to 0.1.21 in
153
+ pnpm-workspace.yaml, the same convention as every earlier version. The host half loads at
154
+ boot, so the guard only takes effect after a restart; installed 0.1.20 already appends WITH
155
+ the marker, so nothing is at risk while this waits.
148
156
  - [x] **Restart, then check the tasks layer — DONE 2026-10-09.** The restart confirmed it. See the
149
157
  tasks-layer section above for the two log entries and the counts. It writes to ANOTHER plugin's storage through
150
158
  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.22",
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
@@ -29,15 +29,28 @@ const TASKS_BACKUP_MS = 30 * 60 * 1000
29
29
  const IDLE_TICK_MS = 60_000
30
30
 
31
31
  function makeGitRunner(ctx) {
32
- const subprocess = ctx.get('subprocess')
33
- if (!subprocess || typeof subprocess.resolveExecutable !== 'function' || typeof subprocess.spawn !== 'function') return null
32
+ // The subprocess service is NOT resolvable during apply. cordis ctx.get() only returns an
33
+ // implementation whose PROVIDING FIBER IS ACTIVE (vendor/cordis reflect.ts _getImpl), and no
34
+ // fiber is active while the composition is still mounting. The first version probed here,
35
+ // once, at wire time - it cached undefined, returned null, and every git path was dead
36
+ // forever: zero sync events ever recorded, and the history hook threw "Cannot read
37
+ // properties of null (reading 'run')" every hour. The service exists by first USE (an agent
38
+ // pre-step, minutes after boot), so resolve lazily and keep re-probing until it appears.
39
+ let subprocess
40
+ const probe = () => {
41
+ const svc = ctx.get('subprocess')
42
+ if (svc && typeof svc.resolveExecutable === 'function' && typeof svc.spawn === 'function') subprocess = svc
43
+ return subprocess
44
+ }
34
45
  let executablePromise
35
46
  const run = async (argv, opts = {}) => {
36
- if (!executablePromise) executablePromise = subprocess.resolveExecutable('git', undefined, undefined).catch(() => null)
47
+ const svc = probe()
48
+ if (!svc) return { exitCode: 127, stdout: '', stderr: 'subprocess service unavailable' }
49
+ if (!executablePromise) executablePromise = svc.resolveExecutable('git', undefined, undefined).catch(() => null)
37
50
  const executable = await executablePromise
38
51
  if (!executable) return { exitCode: 127, stdout: '', stderr: 'git unavailable' }
39
52
  try {
40
- const handle = subprocess.spawn({
53
+ const handle = svc.spawn({
41
54
  argv: [executable, ...argv],
42
55
  cwd: opts.cwd,
43
56
  stdio: { stdin: 'ignore', stdout: { maxBytes: 4 * 1024 * 1024 }, stderr: { maxBytes: 16 * 1024 } },
@@ -52,7 +65,9 @@ function makeGitRunner(ctx) {
52
65
  return { exitCode: 1, stdout: '', stderr: String(error) }
53
66
  }
54
67
  }
55
- return new GitRunner(run)
68
+ const runner = new GitRunner(run)
69
+ runner.available = () => Boolean(probe())
70
+ return runner
56
71
  }
57
72
 
58
73
  function resolveTarget(agent) {
@@ -74,6 +89,7 @@ export function wireHost(ctx, store, cfg) {
74
89
  let restoreAttempted = false
75
90
  const lastMapPublish = new Map()
76
91
  const lastMapSignature = new Map()
92
+ const mapPublishSuppressed = new Set()
77
93
  let lastHistoryAttempt = 0
78
94
  let syncInFlight = false
79
95
 
@@ -234,6 +250,9 @@ export function wireHost(ctx, store, cfg) {
234
250
  if (!workspace) return { ok: false, reason: 'no workspace for this session', added: 0 }
235
251
  const path = readAssociations(store.root)[workspace]?.path
236
252
  if (!path) return { ok: false, reason: 'no local folder associated with ' + workspace, added: 0 }
253
+ // Report the honest reason instead of throwing: a hook that throws hourly because a service
254
+ // was probed too early must never look like a repository with no commits.
255
+ if (!git.available()) return { ok: false, reason: 'subprocess service unavailable', added: 0 }
237
256
  return ingestHistory(store.root, workspace, path, (argv, opts) => git.run(argv, opts), { limit })
238
257
  }
239
258
 
@@ -569,6 +588,9 @@ export function wireHost(ctx, store, cfg) {
569
588
  }
570
589
 
571
590
  async function runSync(force = false) {
591
+ // Not a git failure: no CONFLICT marker, no status record - the capability simply is not
592
+ // there yet. available() re-probes, so this is true only while the host is still mounting.
593
+ if (!git.available()) return { initialized: false, committed: false, synced: false, error: 'subprocess service unavailable' }
572
594
  const branch = cfg.gitBranch || 'main'
573
595
  const hasGit = existsSync(join(store.root, '.git'))
574
596
  if (!hasGit) {
@@ -721,6 +743,9 @@ export function wireHost(ctx, store, cfg) {
721
743
  try {
722
744
  if (!session || typeof session.append !== 'function' || !session.id) return
723
745
  if (isSubagentSession(session)) return
746
+ // A session whose harness did not honor ignorable is poisoned by every further append;
747
+ // the decision to stop is made once, below, and honored on every later attempt.
748
+ if (mapPublishSuppressed.has(session.id)) return
724
749
  const now = Date.now()
725
750
  if (!force && now - (lastMapPublish.get(session.id) ?? 0) < MAP_PUBLISH_MS) return
726
751
  lastMapPublish.set(session.id, now)
@@ -737,7 +762,21 @@ export function wireHost(ctx, store, cfg) {
737
762
  const signature = JSON.stringify({ ...payload, generatedAt: '' })
738
763
  if (!force && lastMapSignature.get(session.id) === signature) return
739
764
  lastMapSignature.set(session.id, signature)
740
- session.append('memory/map', payload)
765
+ // Out-of-repo event types are absent from KNOWN_SESSION_EVENT_TYPES; without
766
+ // ignorable:true the persistence read path refuses the whole session log.
767
+ // The append returns the logged event: if the harness did NOT stamp
768
+ // ignorable onto it (a harness older than the option), the very next load
769
+ // of this session would fail with "unknown to this harness and not marked
770
+ // ignorable". Detecting that from the return value lets us stop before a
771
+ // log is poisoned, instead of discovering it when history fails to load.
772
+ const logged = session.append('memory/map', payload, { ignorable: true })
773
+ if (logged?.ignorable !== true) {
774
+ if (!mapPublishSuppressed.has(session.id)) {
775
+ mapPublishSuppressed.add(session.id)
776
+ logRun(store.root, 'map-publish', { ok: false, error: 'harness did not stamp ignorable; publishing suppressed for this session' })
777
+ }
778
+ return
779
+ }
741
780
  logRun(store.root, 'map-publish', { ok: true, scope, key: key ?? '', bytes: signature.length })
742
781
  } catch (error) {
743
782
  // Never let a view break the conversation it is describing.
@@ -746,7 +785,7 @@ export function wireHost(ctx, store, cfg) {
746
785
  }
747
786
 
748
787
  function maybeSync() {
749
- if (syncInFlight || !git) return
788
+ if (syncInFlight || !git.available()) return
750
789
  if (!scheduler.shouldCommit() && !scheduler.shouldSync()) return
751
790
  syncInFlight = true
752
791
  // Sync wrote its outcome only to state/sync.json, so the log could not answer whether the