@try-works/dsh-memory 0.1.26 → 0.1.27

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
@@ -3,7 +3,8 @@
3
3
  A tracker and planner: what exists, what is verified working, what is written but not
4
4
  loaded, and what is left. Kept as a record of MEASURED state, not intention.
5
5
 
6
- **Last measured: 2026-10-06** (347 tests passing, 41 src files, 50 test files).
6
+ **Last measured: 2026-10-12** (408 tests passing; plugin at 0.1.26, published and installed
7
+ byte-identical to source; store committed and pushed to `origin`).
7
8
 
8
9
  ## How to read this
9
10
 
@@ -64,6 +65,8 @@ Observed in the running host. Each row names the effect that was seen, not a cla
64
65
  | `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" |
65
66
  | 0.1.21 guard is LOADED | the 2026-10-11 06:01+08 restart booted the installed 0.1.21; `map-publish ok:true` events follow boot, so the guarded publisher runs. The suppression branch itself cannot be observed on this harness — it only fires on one that does not stamp the marker |
66
67
  | Boot tasks backup + restore ran live | at boot (2026-10-10T22:03:14Z) the log records `tasks-backup ok:true written:0 refreshed:5` and `tasks-restore ok:true owner:session-6e8d9a10 present:5 added:0 paused:0` — the restore reached the schedule service, found everything present, and duplicated nothing |
68
+ | Git sync works live | after the 0.1.22+ boot the log shows real `sync` events, `state/sync.json` records `ok:true remote:https://github.com/try-works/dsh-memory-store` (2026-10-11T10:34:54Z), and `git log` in the store holds `memory(store): update [dsh-memory]` commits — the first syncs this plugin has ever performed |
69
+ | History ingest works live | `history-check ok=true` entries after the boot, including `count=1` at 09:37Z — a commit was actually ingested; the hourly `null.run` error era is over |
67
70
 
68
71
  ## WRITTEN but NOT LOADED
69
72
 
@@ -76,6 +79,8 @@ Implemented and tested on disk. The running host predates them, so none of this
76
79
  | `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` |
77
80
  | 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 |
78
81
  | Git runner resolves the subprocess service lazily (0.1.22) | `makeGitRunner` probed `ctx.get('subprocess')` ONCE at wire time; cordis `get()` only returns services whose providing fiber is ACTIVE (vendor/cordis reflect.ts), so during mount it returns undefined, git stayed null forever, and every git path was dead: zero sync events ever recorded, and `history-check` threw `Cannot read properties of null (reading 'run')` hourly since 2026-10-06. The runner now re-probes at use and reports an honest reason while absent | 405 tests pass; the new test pins no-probe-at-mount, an honest refusal while the service is absent, and working ingest after it appears |
82
+ | `classifyGitResult` reads `exitCode`, the name the runner actually returns (0.1.23-0.1.25) | the doc promised `code` and the code read `code`, but the injected runner returns `exitCode` — every successful git command was classified as a failure, so pull/push always reported failed and sync "had never once succeeded". `memory_sync` also stopped counting local commits as `synced: true` | sync tests drive the runner through the real classifier; an empty remote is asserted as the first push, not a failed pull |
83
+ | `memory_recover` run path gets real services (0.1.26) | `recoverNow.run` called `recoverOne(store, services, ...)` where `services` was NEVER DEFINED — a ReferenceError the catch quietly turned into "1 declined or refused", so 75 recoverable candidates sat behind a tool that could only refuse them (observed live: limit:1 rebuilt 0). Services are now resolved at call time (`sessionQuery`), and the summary uses the plugin's own memory-model ladder | 408 tests pass; the new test executes the REAL run path through the wiring: plan → requeue → readSession → capture → digest file written, queue leaves ORPHANED |
79
84
 
80
85
  How to tell loaded from not: ask the host. `memory_recover` used to reply *"unavailable in this
81
86
  context"* and now replies *"returned invalid output: value is not lossless JSON"*. The change of
@@ -121,15 +126,31 @@ reporting success.
121
126
  Known limitation, by design: the host deletes a task when its owning session stops, so a restored
122
127
  task lives as long as the session that adopted it. The boot restore is what re-arms them.
123
128
 
124
- ## NOT VERIFIED — treat these as unknown
129
+ ## OUT OF SCOPE — not dsh-memory's problem
125
130
 
126
- | item | what is actually known | what would settle it |
127
- |---|---|---|
128
- | Session CONTENT search | The index database exists at `~/.dsh/storages/session-search.sqlite` and was opened, so `openAt: first-search` took effect. It holds **0 sessions, 0 documents, `global_generation` 0**. Every sidebar result therefore comes from a browser-side TITLE match. | A **content-only** hit: a query matching text inside a session that appears in no title or workspace name. |
129
- | Cause of the empty index | Reconciliation runs `_observeStable -> persistence.list -> readColdSessionLog` and indexes nothing. Candidates, untraced: persistence binding unavailable, cold reads failing silently, live sessions skipped. | Tracing the reconcile path with a real store. |
130
- | Sidebar behaviour with a populated index | The service uses FTS5 MATCH and the client merges content hits with local title matches. The merge code is real; it has never had content to merge. | The same content-only hit above. |
131
+ Closed 2026-10-12 by operator decision. Recorded once so it is not re-investigated.
132
+
133
+ The sidebar's full-text session search does not work on this machine, and **that is not
134
+ dsh-memory's concern**. The session logs under `~/.dsh/sessions/` are DSH's own files: they are
135
+ written by `session-persistence-jsonl`, they exist whether or not this plugin is installed, and
136
+ they are not memory data. This store holds no session logs at all.
137
+
138
+ What was found while investigating (kept only so nobody repeats the work): 91 of 630 stored logs
139
+ are legacy v0 artifacts DSH cannot read (`subagent/descriptor ... unsupported descriptor version
140
+ 2`); one such log aborts the whole corpus reconcile, so the search index stays empty and the
141
+ sidebar falls back to matching titles. Verified by isolation — two valid v4 logs return content
142
+ hits, and adding one bad v0 log fails the same query.
131
143
 
132
- ### Where the sidebar search actually comes from
144
+ Why this is not a dsh-memory bug: the plugin **never calls session search**. It reads individual
145
+ sessions directly through `sessionQuery.readSession` in `capture.js` and `recover.js`. The only
146
+ link is one config line — this plugin's patch layer sets `openAt: first-search` on the
147
+ `session-query-sqlite` row, which is what turns the harness's opt-in search on. Nothing in the
148
+ plugin's code is involved.
149
+
150
+ Per instruction: do not modify the harness, do not touch the session logs, do not work around
151
+ this from the plugin. Sidebar search stays title-only; dsh-memory is unaffected.
152
+
153
+ ### Where the sidebar search comes from
133
154
 
134
155
  Recorded because it was got wrong once. `client/ui-workspace/src/client/tree.ts`
135
156
  `deriveSearchResults` has two sources:
@@ -139,22 +160,21 @@ Recorded because it was got wrong once. `client/ui-workspace/src/client/tree.ts`
139
160
  sessionTitle(summary).toLowerCase().includes(q)
140
161
  || labelOf(summary).toLowerCase().includes(q)
141
162
 
142
- // CONTENT - server-side FTS hits, empty today
163
+ // CONTENT - server-side FTS hits, empty because the index cannot fill
143
164
  for (const item of content.items) include(summary)
144
165
  ```
145
166
 
146
- So title search works and content search is wired but starved. The distinction matters: turning
147
- the flag on did not change what the user sees.
148
-
149
167
  ## TODO — ordered
150
168
 
151
169
  ### Blocked on a restart
152
170
 
153
- - [ ] **Restart to load 0.1.22.** The lazy git-runner fix is published; install is pending the
154
- registry finishing its staged publish (E409 "previously staged version" on retry). Once the
155
- profile install lands and the host restarts, the hourly `history-check ok:false null.run`
156
- should become either a working ingest or an honest `reason` — and git sync gets its first
157
- real chance to run ever.
171
+ - [x] **Restart to load 0.1.26 — DONE 2026-10-12 05:18+08.** After the boot, `memory_recover({limit:1})`
172
+ rebuilt one candidate (log: `recover count:1 detail.id 9521e4c9...`), and the rebuilt digest
173
+ was committed by the sync cadence right after. The recover run path is live.
174
+ - [x] **Restart to load 0.1.22 — DONE 2026-10-11.** The lazy git-runner fix is live: real `sync`
175
+ events in the log, `state/sync.json` ok:true against the configured remote, `memory(store)`
176
+ commits in the store's git history, and `history-check ok=true` entries including an ingested
177
+ commit. The hourly `null.run` era is over.
158
178
  - [x] **Restart to load 0.1.21 — DONE 2026-10-11 06:01+08.** The host booted the installed 0.1.21;
159
179
  `map-publish ok:true` events follow boot and the boot tasks backup/restore ran live (see
160
180
  LIVE table).
@@ -173,23 +193,33 @@ the flag on did not change what the user sees.
173
193
 
174
194
  - [x] `memory_recover({})` — DONE 2026-10-11: report-only call returned a clean report (75
175
195
  recoverable, 0 oversized), no error.
176
- - [ ] `memory_recover({ limit: 1 })` — **prove it end to end on one** before spending on 76.
177
- - [ ] Then the rest in batches of ~10. Cost is roughly 76-150 model calls plus consolidation,
178
- against **52 MB** of logs. All 76 are under the 8 MB approval threshold, so nothing will
179
- stop an accidental `limit: 76` — the one-candidate proof is the guard, not the threshold.
196
+ - [x] `memory_recover({ limit: 1 })` — DONE 2026-10-12: rebuilt 1, declined 0 (the 0.1.26 run
197
+ path). Attempted earlier on 0.1.25 it rebuilt 0 and declined 1 — the live proof that the
198
+ undefined-`services` ReferenceError was real.
199
+ - [x] The rest in batches of ~10 — DONE 2026-10-12: all 75 candidates recovered (1 proof + 10/10
200
+ batches through the queue, `logs/memory.log` shows `recover` count=75), 0 remain. Each
201
+ rebuild was followed by a sync commit; `state/sync.json` stayed ok:true. Recovered digests
202
+ now flow into the normal consolidation cadence.
180
203
  ### Open work, not blocked
181
204
 
182
- - [ ] **Held backfills: 28.** `state/backfill-hold.json`. Released one as a gate test and it
183
- produced 2 entries against a 1-3 target, where the live rate was 7-10 — so the conservative
184
- bar works. Release in batches and watch the rate after each; ~7 per digest means the bar is
185
- not reaching the prompt and the batch should stop.
186
- - [ ] **79 held records, reason `unclassified`.** Legacy records whose summary was empty and
187
- whose cause was never recorded, so a conclusion and a defect are indistinguishable. Parked
188
- deliberately: retrying them unasked would be a backfill nobody requested. An operator
189
- decision, not a bug.
190
- - [ ] **Session content search.** See NOT VERIFIED. Two routes: trace why reconciliation indexes
191
- nothing, or accept title search and close it. Do not claim it works until a content-only
192
- hit is observed.
205
+ - [x] **The held backlog — CLEARED 2026-10-12.** Both figures quoted here for weeks were WRONG,
206
+ which is why this sat untouched: the real store had **one** record marked `unclassified` (not
207
+ 79 — the append-only journal is the authority, and it contained exactly one), and
208
+ `state/backfill-hold.json` is read by **no production code at all** (an orphaned artefact of the
209
+ retired `sessions.json` migration; 27 of its 28 ids are not even in the queue). The genuine
210
+ backlog was 80 pending digests, consolidated in batches into **~215 entries**. Two real defects
211
+ were found while doing it:
212
+ - **Header-only digests were sent to the model.** `runConsolidation` batched
213
+ `withContent.concat(headerOnly)`, so a digest with no summary section went to the LLM as a bare
214
+ header. The reply could only be "nothing durable", which never advanced the entry, so the same
215
+ empty shells were re-sent every cadence — one entry reached `consolidateAttempts: 39`. Fixed:
216
+ consolidation now filters to digests that actually contain a summary section (capture writes
217
+ summaries; consolidation only reads them), with a test asserting the model is never called.
218
+ - **A stuck `pending` candidate had no operator path.** Recovery accepts orphaned/abandoned/failed
219
+ but not pending, and capture never re-summarizes a historical session. One genuine main-agent
220
+ session (1317 events) was recovered by marking it orphaned, then `memory_recover`.
221
+ The other 8 stragglers were `origin: subagent` sessions — never memory material by
222
+ `isSubagentSession` — and were skipped with a truthful reason.
193
223
  - [ ] **Agent-facing session tools** (`session_search` and friends). NOT a config change. In a
194
224
  Web profile the tool plane belongs to a preset, and a patch REPLACES a preset row config
195
225
  wholesale — so adding one tool means restating the whole ~20-row `config.plugins` list and
@@ -217,10 +247,11 @@ Kept because the pattern matters more than the list: every one of these was foun
217
247
  | A test edit silently discarded | my own script re-read and overwrote the file before writing |
218
248
  ## Published
219
249
 
220
- **`@try-works/dsh-memory@0.1.16`** on npm (0.1.0 was the first release; the Memory tab and the
221
- tasks layer each added several). The unscoped name `dsh-memory` was already taken by an
222
- unrelated DeepSeek Harness memory plugin, so the package publishes under the existing `@try-works`
223
- scope.
250
+ **`@try-works/dsh-memory@0.1.26`** on npm, installed in the profile byte-identical to source.
251
+ (0.1.0 was the first release; the Memory tab, the tasks layer, the ignorable/log-poisoning fix, the
252
+ git-sync fixes and the recovery run-path fix each added versions in between.) The unscoped name
253
+ `dsh-memory` was already taken by an unrelated DeepSeek Harness memory plugin, so the package
254
+ publishes under the existing `@try-works` scope.
224
255
 
225
256
  What did NOT change, deliberately: the Cordis plugin name (`export const name = 'dsh-memory'`) and
226
257
  the bundle row id (`id: dsh-memory`). Config is addressed by ROW ID, so an existing profile's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@try-works/dsh-memory",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
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,28 @@
1
+ /* eslint-disable no-console */
2
+ // One-session inspection via the harness's real session-query reader.
3
+ import { Context } from '@deepseek-ai/cordis'
4
+ import { SessionStore } from '@deepseek-ai/dsh-session'
5
+ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
6
+ import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
7
+
8
+ const root = process.argv[2] ?? 'C:/Users/erikb/.dsh/sessions'
9
+ const id = process.argv[3]
10
+ if (!id) throw new Error('usage: inspect-one-session.ts <sessions-root> <id>')
11
+ const ctx = new Context()
12
+ await ctx.plugin(SessionStore)
13
+ await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
14
+ await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search' })
15
+ const snapshot = await ctx.sessionQuery.readSession(id)
16
+ console.log(JSON.stringify({ id: snapshot.session.id, cwd: snapshot.session.cwd, createdAt: snapshot.session.createdAt, count: snapshot.events.length, origin: snapshot.session.origin, delegationDepth: snapshot.session.delegationDepth, parentSession: snapshot.session.parentSession }))
17
+ for (const event of snapshot.events) {
18
+ if (event.type === 'user/message') {
19
+ const text = event.data.message?.content?.filter?.(x => x.type === 'text').map(x => x.text).join('\n') ?? ''
20
+ if (text) console.log(`USER[${event.seq}]: ${text.slice(0, 500)}`)
21
+ } else if (event.type === 'assistant/message') {
22
+ const text = event.data.message?.content?.filter?.(x => x.type === 'text').map(x => x.text).join('\n') ?? ''
23
+ if (text) console.log(`ASSISTANT[${event.seq}]: ${text.slice(0, 350)}`)
24
+ } else if (event.type === 'tool/call') {
25
+ console.log(`TOOL[${event.seq}]: ${event.data.name} ${String(event.data.arguments).slice(0, 250)}`)
26
+ }
27
+ }
28
+ process.exit(0)
@@ -0,0 +1,24 @@
1
+ /* eslint-disable no-console */
2
+ // Diagnostic: read the session-search index the way a search would.
3
+ import { DatabaseSync } from 'node:sqlite'
4
+
5
+ const db = new DatabaseSync(process.argv[2] ?? 'C:/Users/erikb/.dsh/storages/session-search.sqlite', { readOnly: true })
6
+ const tables = db.prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY name").all()
7
+ console.log('objects:', tables.map((t) => `${t.type}:${t.name}`).join(', ') || '(none)')
8
+
9
+ for (const { name, type } of tables) {
10
+ if (type !== 'table' || name.startsWith('sqlite_')) continue
11
+ try {
12
+ const n = db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get()
13
+ console.log(` ${name}: ${n.n} rows`)
14
+ } catch (err) {
15
+ console.log(` ${name}: unreadable (${String(err).slice(0, 80)})`)
16
+ }
17
+ }
18
+
19
+ // Show the schema so the columns a search would read are visible.
20
+ for (const { name, type } of tables) {
21
+ if (type !== 'table' || name.startsWith('sqlite_')) continue
22
+ const sql = db.prepare('SELECT sql FROM sqlite_master WHERE name = ?').get(name)
23
+ console.log(`\n--- ${name} ---\n${sql.sql}`)
24
+ }
@@ -0,0 +1,15 @@
1
+ /* eslint-disable no-console */
2
+ // One-shot: mark a legacy header-only candidate orphaned so memory_recover can rebuild it.
3
+ // A `pending` entry with a header-only digest (no summary) cannot be consolidated, and capture
4
+ // will never re-summarize a historical session on its own. Recovery is the path that re-reads
5
+ // the session log and re-captures it.
6
+ import { readQueue, transition, STATES, REASONS } from '../src/queue.js'
7
+
8
+ const ROOT = process.argv[2] ?? 'E:/dsh-memory'
9
+ const id = process.argv[3]
10
+ if (!id) throw new Error('usage: orphan-for-recovery.ts <root> <id>')
11
+
12
+ const entry = readQueue(ROOT).get(id)
13
+ if (!entry) throw new Error(id + ' is not in the queue')
14
+ transition(ROOT, id, STATES.ORPHANED, { reason: REASONS.DIGEST_GONE })
15
+ console.log(`orphaned ${id} (was ${entry.state}); now run memory_recover({ id: '${id}' })`)
@@ -0,0 +1,44 @@
1
+ /* eslint-disable no-console */
2
+ // Probe: does the session-search index actually index the REAL session logs?
3
+ //
4
+ // The live index at ~/.dsh/storages/session-search.sqlite holds 0 rows and has never been
5
+ // written since it was created. Because `openAt: first-search` means reconciliation runs on the
6
+ // first real search, "empty" could equally mean "never searched" or "searches index nothing".
7
+ // This assembles the real persistence + query services over a COPY of a real sessions root, so a
8
+ // search can be observed without touching the live index.
9
+ //
10
+ // Usage: node --import tsx/esm scripts/probe-search.ts <sessions-root-copy> <out.sqlite>
11
+ import { Context } from '@deepseek-ai/cordis'
12
+ import { SessionStore } from '@deepseek-ai/dsh-session'
13
+ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
14
+ import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
15
+
16
+ const root = process.argv[2]
17
+ const out = process.argv[3]
18
+ if (!root || !out) throw new Error('usage: probe-search.ts <sessions-root> <out.sqlite>')
19
+
20
+ const ctx = new Context()
21
+ await ctx.plugin(SessionStore)
22
+ await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
23
+ await ctx.plugin(SqliteSessionQueryEngine, { path: out, openAt: 'first-search' })
24
+
25
+ const persistence = ctx.get('sessionPersistence')
26
+ console.log('persistence service:', persistence ? 'present' : 'MISSING')
27
+ const listed = await persistence.list()
28
+ console.log('persistence.list():', listed.length, 'stored session(s)')
29
+ for (const s of listed.slice(0, 3)) console.log(' -', s.header.id, 'cwd=' + s.header.cwd)
30
+
31
+ const query = ctx.get('sessionQuery')
32
+ console.log('sessionQuery service:', query ? 'present' : 'MISSING')
33
+
34
+ // A word that certainly appears inside some session body but in no title.
35
+ const needle = process.argv[4] ?? 'the'
36
+ const page = await query.searchSessions({ query: needle, limit: 5 })
37
+ console.log(`\nsearchSessions(${JSON.stringify(needle)}) -> ${page.items.length} hit(s)`)
38
+ for (const hit of page.items.slice(0, 5)) {
39
+ console.log(' hit', hit.header.id, 'live=' + hit.live, 'persisted=' + hit.persisted)
40
+ console.log(' ', String(hit.bestMatch.snippet).replace(/\s+/g, ' ').slice(0, 140))
41
+ }
42
+
43
+ await ctx.stop?.()
44
+ process.exit(0)
@@ -0,0 +1,52 @@
1
+ /* eslint-disable no-console */
2
+ // Diagnostic: which stored session logs can the harness NOT read?
3
+ //
4
+ // The SQLite search index reconciles by listing persistence and cold-reading each log. A single
5
+ // unreadable artifact aborts the whole observation (`session-search persistence observation
6
+ // failed`), so the index stays empty and every search returns an error instead of results. This
7
+ // walks the real sessions root and reports each artifact that fails to read, with its reason.
8
+ //
9
+ // Usage: node --import tsx/esm scripts/scan-unreadable.ts [sessions-root]
10
+ import { Context } from '@deepseek-ai/cordis'
11
+ import { SessionStore } from '@deepseek-ai/dsh-session'
12
+ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
13
+ import { readColdSessionLog } from '@deepseek-ai/dsh-session-query'
14
+
15
+ const root = process.argv[2] ?? 'C:/Users/erikb/.dsh/sessions'
16
+
17
+ const ctx = new Context()
18
+ await ctx.plugin(SessionStore)
19
+ await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
20
+ const persistence = ctx.get('sessionPersistence')
21
+
22
+ const listed = await persistence.list()
23
+ console.log(`stored sessions: ${listed.length}\n`)
24
+
25
+ let ok = 0
26
+ const failures = []
27
+ for (const snapshot of listed) {
28
+ try {
29
+ await readColdSessionLog(persistence, snapshot.header.id, undefined)
30
+ ok += 1
31
+ } catch (error) {
32
+ failures.push({ id: snapshot.header.id, cwd: snapshot.header.cwd, error: String((error && error.message) || error) })
33
+ }
34
+ }
35
+
36
+ console.log(`readable: ${ok}`)
37
+ console.log(`UNREADABLE: ${failures.length}\n`)
38
+ for (const f of failures) {
39
+ console.log(`- ${f.id} cwd=${f.cwd}`)
40
+ console.log(` ${f.error.slice(0, 220)}`)
41
+ }
42
+
43
+ // Group the failure reasons so the shape of the problem is visible at a glance.
44
+ console.log('\n--- reasons, grouped ---')
45
+ const groups = new Map()
46
+ for (const f of failures) {
47
+ const key = f.error.replace(/\(raw log:.*\)/, '').replace(/session-[0-9a-f-]+|[0-9a-f]{8}-[0-9a-f-]{27,}/g, '<id>').trim().slice(0, 120)
48
+ groups.set(key, (groups.get(key) ?? 0) + 1)
49
+ }
50
+ for (const [reason, count] of [...groups].sort((a, b) => b[1] - a[1])) console.log(`${count} ${reason}`)
51
+
52
+ process.exit(0)
@@ -0,0 +1,24 @@
1
+ /* eslint-disable no-console */
2
+ // One-shot: settle subagent candidates stuck in `pending` with header-only digests.
3
+ // These sessions are `origin: subagent` / `delegationDepth > 0`, so by the plugin's own rule
4
+ // they are never memory material; their parent transcript holds the story. They reached `pending`
5
+ // through the legacy migration and now have no summary to consolidate, so they would otherwise
6
+ // sit in the backlog forever. This marks them skipped with a truthful reason.
7
+ import { readQueue, transition, STATES, REASONS } from '../src/queue.js'
8
+
9
+ const ROOT = process.argv[2] ?? 'E:/dsh-memory'
10
+ const ids = process.argv.slice(3)
11
+ if (ids.length === 0) throw new Error('usage: skip-subagent-held.ts <root> <id...>')
12
+
13
+ // The subagent determination is NOT made here: the caller lists ids it has already verified
14
+ // (by reading each session's header) as subagent sessions. This script only performs the queue
15
+ // transition with a truthful reason, so it cannot skip a candidate on a guess.
16
+ let skipped = 0
17
+ for (const id of ids) {
18
+ const entry = readQueue(ROOT).get(id)
19
+ if (!entry) { console.log(`not in queue: ${id}`); continue }
20
+ transition(ROOT, id, STATES.SKIPPED, { reason: REASONS.TRIVIAL })
21
+ console.log(`skipped subagent: ${id}`)
22
+ skipped += 1
23
+ }
24
+ console.log(`\nskipped ${skipped} subagent candidate(s)`)
@@ -10,18 +10,19 @@ import { readAssociations } from './workspace.js'
10
10
  export async function runConsolidation(store, {
11
11
  llm, digests, indexHeads, guidance, batchSize = 3, maxPromptBytes = 65536, maxDigestBytes = 20000, maxAttempts = 3,
12
12
  } = {}) {
13
- // Prefer digests that actually have something to consolidate: a header-only digest costs a
14
- // batch slot and a share of the prompt budget while carrying nothing.
13
+ // A header-only digest carries NOTHING to consolidate: capture has not summarized it yet.
14
+ // Sending it to the model spends a call on a header with no body, and the reply can only be
15
+ // "nothing durable" — which never advances the entry, so the same empty shells get re-sent
16
+ // forever (one real entry accumulated 39 attempts). Consolidation works on summaries; capture
17
+ // is what fills summaries. Header-only candidates are therefore left untouched here.
15
18
  const all = digests ?? pendingDigests(store)
16
19
  const withContent = []
17
- const headerOnly = []
18
20
  for (const item of all) {
19
21
  const raw = readDigestText(store.root, item.id)
20
22
  if (raw.includes('summary (')) withContent.push(item)
21
- else headerOnly.push({ ...item, empty: true })
22
23
  }
23
- const pending = withContent.concat(headerOnly).slice(0, batchSize)
24
- if (pending.length === 0) return { consolidated: 0, written: 0 }
24
+ if (withContent.length === 0) return { consolidated: 0, written: 0 }
25
+ const pending = withContent.slice(0, batchSize)
25
26
  // Bound the prompt: a digest is clipped to its own ceiling, and the whole payload
26
27
  // to the prompt budget. Each digest carries its header plus the most recent bullets.
27
28
  // Hand the model the RIGHT workspace key instead of letting it invent one from a path.
@@ -41,9 +42,9 @@ export async function runConsolidation(store, {
41
42
  const full = readDigestText(store.root, item.id)
42
43
  const text = clipBlock(full, Math.min(maxDigestBytes, budget))
43
44
  if (text === '') continue
44
- // A digest with no summary section is a HEADER ONLY: capture has not summarized it yet
45
- // (or its summary failed). It is not empty of purpose, it is empty of content, and sending
46
- // it wastes a batch slot while inviting the model to mark it done forever.
45
+ // Every item in `pending` now carries a summary section (see the filter above), so `empty`
46
+ // is never true here. Kept explicit so a future change to the filter cannot silently revive
47
+ // the empty-digest branch that used to send a bare header to the model.
47
48
  const empty = !full.includes('summary (')
48
49
  // A backfilled digest carries material reconstructed from an old session rather than a live
49
50
  // summary. If ANY digest in the batch came from a backfill, the pass takes the stricter bar,