@try-works/dsh-memory 0.1.26 → 0.1.28
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 +86 -50
- package/package.json +2 -1
- package/scripts/inspect-one-session.ts +28 -0
- package/scripts/inspect-search-db.ts +24 -0
- package/scripts/orphan-for-recovery.ts +24 -0
- package/scripts/probe-open.ts +13 -0
- package/scripts/probe-rows.ts +16 -0
- package/scripts/probe-search.ts +44 -0
- package/scripts/scan-unreadable.ts +52 -0
- package/scripts/skip-subagent-held.ts +24 -0
- package/src/client/index.js +7 -1
- package/src/consolidate-run.js +10 -9
- package/src/host.js +34 -8
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-
|
|
6
|
+
**Last measured: 2026-10-17** (408 tests passing; plugin at 0.1.27, published and installed
|
|
7
|
+
byte-identical and confirmed LOADED by the running host; queue terminal-clean).
|
|
7
8
|
|
|
8
9
|
## How to read this
|
|
9
10
|
|
|
@@ -64,23 +65,31 @@ 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
|
+
## Shipped and LOADED
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
Formerly "WRITTEN but NOT LOADED". Every row below was verified present in the **installed**
|
|
74
|
+
0.1.27 package (grepped in `profiles/web/node_modules/@try-works/dsh-memory`), not merely in the
|
|
75
|
+
source tree — that distinction is the whole point of this document. One row used to sit here as
|
|
76
|
+
"what remains pending"; nothing does now.
|
|
71
77
|
|
|
72
|
-
| change | why it matters | how it was verified
|
|
78
|
+
| change | why it matters | how it was verified |
|
|
73
79
|
|---|---|---|
|
|
74
80
|
| `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 |
|
|
75
|
-
| `hostExtras(host)` as one source | `index.js` and the wiring test shared a copied list that drifted | the test
|
|
76
|
-
| `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
|
|
77
|
-
| 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 |
|
|
78
|
-
| 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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
| `hostExtras(host)` as one source | `index.js` and the wiring test shared a copied list that drifted | the test derives extras from the host object |
|
|
82
|
+
| `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 guard round-trips every tool output; reintroducing it fails with `value.entries is undefined` |
|
|
83
|
+
| 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 | the 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 |
|
|
84
|
+
| 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, 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 from 2026-10-06. The runner now re-probes at use and reports an honest reason while absent | the test pins no-probe-at-mount, an honest refusal while the service is absent, and working ingest after it appears; live proof: real `sync` events and `state/sync.json` ok:true |
|
|
85
|
+
| `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; live proof: the store pushed to GitHub |
|
|
86
|
+
| `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 | the test executes the REAL run path through the wiring: plan → requeue → readSession → capture → digest file written, queue leaves ORPHANED; live proof: all 75 recovered |
|
|
87
|
+
| Consolidation never sees a header-only digest (0.1.27) | `runConsolidation` batched `withContent.concat(headerOnly)`, so a digest with no summary section went to the model as a bare header. The reply could only be "nothing durable", which never advanced the entry, so the same empty shells were re-sent every cadence — one entry reached `consolidateAttempts: 39`. Capture writes summaries; consolidation now only reads them | the test asserts the model is never called for a header-only digest |
|
|
88
|
+
|
|
89
|
+
How to tell loaded from not: ask the host, and read the **installed** file rather than the source
|
|
90
|
+
tree. A version string and a successful `install_bundle` are both insufficient — on 2026-10-12 a
|
|
91
|
+
fix was committed but the version was not bumped, so the install was a no-op and the host kept
|
|
92
|
+
running the old code while the source tree looked correct.
|
|
84
93
|
### The tasks layer (new since the last restart)
|
|
85
94
|
|
|
86
95
|
A fifth layer, `tasks`, global only: `global/tasks/` holds one JSON file per DSH scheduled task
|
|
@@ -89,8 +98,9 @@ your automated tasks and is in no git repository. The host DELETES a task when i
|
|
|
89
98
|
stops, so a task can vanish with nothing to observe; the backup runs on the sync cadence and a
|
|
90
99
|
restore re-arms what is missing at the first session after boot.
|
|
91
100
|
|
|
92
|
-
Verified on disk:
|
|
93
|
-
byte-identical.
|
|
101
|
+
Verified on disk: 408 tests pass, and `@try-works/dsh-memory@0.1.27` is published and installed
|
|
102
|
+
byte-identical. (The layer landed at 0.1.16; that figure was left in place here long after it
|
|
103
|
+
stopped being the current release.)
|
|
94
104
|
|
|
95
105
|
- `backupTasks` against `E:\dsh-memory`: two cron tasks captured, a second pass `written=0`.
|
|
96
106
|
- The layer is at `global/tasks/`. It first shipped at `<root>/tasks`; every test used a
|
|
@@ -121,15 +131,31 @@ reporting success.
|
|
|
121
131
|
Known limitation, by design: the host deletes a task when its owning session stops, so a restored
|
|
122
132
|
task lives as long as the session that adopted it. The boot restore is what re-arms them.
|
|
123
133
|
|
|
124
|
-
##
|
|
134
|
+
## OUT OF SCOPE — not dsh-memory's problem
|
|
125
135
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
Closed 2026-10-12 by operator decision. Recorded once so it is not re-investigated.
|
|
137
|
+
|
|
138
|
+
The sidebar's full-text session search does not work on this machine, and **that is not
|
|
139
|
+
dsh-memory's concern**. The session logs under `~/.dsh/sessions/` are DSH's own files: they are
|
|
140
|
+
written by `session-persistence-jsonl`, they exist whether or not this plugin is installed, and
|
|
141
|
+
they are not memory data. This store holds no session logs at all.
|
|
142
|
+
|
|
143
|
+
What was found while investigating (kept only so nobody repeats the work): 91 of 630 stored logs
|
|
144
|
+
are legacy v0 artifacts DSH cannot read (`subagent/descriptor ... unsupported descriptor version
|
|
145
|
+
2`); one such log aborts the whole corpus reconcile, so the search index stays empty and the
|
|
146
|
+
sidebar falls back to matching titles. Verified by isolation — two valid v4 logs return content
|
|
147
|
+
hits, and adding one bad v0 log fails the same query.
|
|
131
148
|
|
|
132
|
-
|
|
149
|
+
Why this is not a dsh-memory bug: the plugin **never calls session search**. It reads individual
|
|
150
|
+
sessions directly through `sessionQuery.readSession` in `capture.js` and `recover.js`. The only
|
|
151
|
+
link is one config line — this plugin's patch layer sets `openAt: first-search` on the
|
|
152
|
+
`session-query-sqlite` row, which is what turns the harness's opt-in search on. Nothing in the
|
|
153
|
+
plugin's code is involved.
|
|
154
|
+
|
|
155
|
+
Per instruction: do not modify the harness, do not touch the session logs, do not work around
|
|
156
|
+
this from the plugin. Sidebar search stays title-only; dsh-memory is unaffected.
|
|
157
|
+
|
|
158
|
+
### Where the sidebar search comes from
|
|
133
159
|
|
|
134
160
|
Recorded because it was got wrong once. `client/ui-workspace/src/client/tree.ts`
|
|
135
161
|
`deriveSearchResults` has two sources:
|
|
@@ -139,22 +165,21 @@ Recorded because it was got wrong once. `client/ui-workspace/src/client/tree.ts`
|
|
|
139
165
|
sessionTitle(summary).toLowerCase().includes(q)
|
|
140
166
|
|| labelOf(summary).toLowerCase().includes(q)
|
|
141
167
|
|
|
142
|
-
// CONTENT - server-side FTS hits, empty
|
|
168
|
+
// CONTENT - server-side FTS hits, empty because the index cannot fill
|
|
143
169
|
for (const item of content.items) include(summary)
|
|
144
170
|
```
|
|
145
171
|
|
|
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
172
|
## TODO — ordered
|
|
150
173
|
|
|
151
174
|
### Blocked on a restart
|
|
152
175
|
|
|
153
|
-
- [
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
176
|
+
- [x] **Restart to load 0.1.26 — DONE 2026-10-12 05:18+08.** After the boot, `memory_recover({limit:1})`
|
|
177
|
+
rebuilt one candidate (log: `recover count:1 detail.id 9521e4c9...`), and the rebuilt digest
|
|
178
|
+
was committed by the sync cadence right after. The recover run path is live.
|
|
179
|
+
- [x] **Restart to load 0.1.22 — DONE 2026-10-11.** The lazy git-runner fix is live: real `sync`
|
|
180
|
+
events in the log, `state/sync.json` ok:true against the configured remote, `memory(store)`
|
|
181
|
+
commits in the store's git history, and `history-check ok=true` entries including an ingested
|
|
182
|
+
commit. The hourly `null.run` era is over.
|
|
158
183
|
- [x] **Restart to load 0.1.21 — DONE 2026-10-11 06:01+08.** The host booted the installed 0.1.21;
|
|
159
184
|
`map-publish ok:true` events follow boot and the boot tasks backup/restore ran live (see
|
|
160
185
|
LIVE table).
|
|
@@ -173,23 +198,33 @@ the flag on did not change what the user sees.
|
|
|
173
198
|
|
|
174
199
|
- [x] `memory_recover({})` — DONE 2026-10-11: report-only call returned a clean report (75
|
|
175
200
|
recoverable, 0 oversized), no error.
|
|
176
|
-
- [
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
201
|
+
- [x] `memory_recover({ limit: 1 })` — DONE 2026-10-12: rebuilt 1, declined 0 (the 0.1.26 run
|
|
202
|
+
path). Attempted earlier on 0.1.25 it rebuilt 0 and declined 1 — the live proof that the
|
|
203
|
+
undefined-`services` ReferenceError was real.
|
|
204
|
+
- [x] The rest in batches of ~10 — DONE 2026-10-12: all 75 candidates recovered (1 proof + 10/10
|
|
205
|
+
batches through the queue, `logs/memory.log` shows `recover` count=75), 0 remain. Each
|
|
206
|
+
rebuild was followed by a sync commit; `state/sync.json` stayed ok:true. Recovered digests
|
|
207
|
+
now flow into the normal consolidation cadence.
|
|
180
208
|
### Open work, not blocked
|
|
181
209
|
|
|
182
|
-
- [
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
210
|
+
- [x] **The held backlog — CLEARED 2026-10-12.** Both figures quoted here for weeks were WRONG,
|
|
211
|
+
which is why this sat untouched: the real store had **one** record marked `unclassified` (not
|
|
212
|
+
79 — the append-only journal is the authority, and it contained exactly one), and
|
|
213
|
+
`state/backfill-hold.json` is read by **no production code at all** (an orphaned artefact of the
|
|
214
|
+
retired `sessions.json` migration; 27 of its 28 ids are not even in the queue). The genuine
|
|
215
|
+
backlog was 80 pending digests, consolidated in batches into **~215 entries**. Two real defects
|
|
216
|
+
were found while doing it:
|
|
217
|
+
- **Header-only digests were sent to the model.** `runConsolidation` batched
|
|
218
|
+
`withContent.concat(headerOnly)`, so a digest with no summary section went to the LLM as a bare
|
|
219
|
+
header. The reply could only be "nothing durable", which never advanced the entry, so the same
|
|
220
|
+
empty shells were re-sent every cadence — one entry reached `consolidateAttempts: 39`. Fixed:
|
|
221
|
+
consolidation now filters to digests that actually contain a summary section (capture writes
|
|
222
|
+
summaries; consolidation only reads them), with a test asserting the model is never called.
|
|
223
|
+
- **A stuck `pending` candidate had no operator path.** Recovery accepts orphaned/abandoned/failed
|
|
224
|
+
but not pending, and capture never re-summarizes a historical session. One genuine main-agent
|
|
225
|
+
session (1317 events) was recovered by marking it orphaned, then `memory_recover`.
|
|
226
|
+
The other 8 stragglers were `origin: subagent` sessions — never memory material by
|
|
227
|
+
`isSubagentSession` — and were skipped with a truthful reason.
|
|
193
228
|
- [ ] **Agent-facing session tools** (`session_search` and friends). NOT a config change. In a
|
|
194
229
|
Web profile the tool plane belongs to a preset, and a patch REPLACES a preset row config
|
|
195
230
|
wholesale — so adding one tool means restating the whole ~20-row `config.plugins` list and
|
|
@@ -217,10 +252,11 @@ Kept because the pattern matters more than the list: every one of these was foun
|
|
|
217
252
|
| A test edit silently discarded | my own script re-read and overwrote the file before writing |
|
|
218
253
|
## Published
|
|
219
254
|
|
|
220
|
-
**`@try-works/dsh-memory@0.1.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
255
|
+
**`@try-works/dsh-memory@0.1.26`** on npm, installed in the profile byte-identical to source.
|
|
256
|
+
(0.1.0 was the first release; the Memory tab, the tasks layer, the ignorable/log-poisoning fix, the
|
|
257
|
+
git-sync fixes and the recovery run-path fix each added versions in between.) The unscoped name
|
|
258
|
+
`dsh-memory` was already taken by an unrelated DeepSeek Harness memory plugin, so the package
|
|
259
|
+
publishes under the existing `@try-works` scope.
|
|
224
260
|
|
|
225
261
|
What did NOT change, deliberately: the Cordis plugin name (`export const name = 'dsh-memory'`) and
|
|
226
262
|
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.
|
|
3
|
+
"version": "0.1.28",
|
|
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",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
41
|
+
"@deepseek-ai/dsh-session": "^0.2.1-rc.2",
|
|
41
42
|
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
|
|
42
43
|
"@deepseek-ai/dsh-llm": "^0.2.0-rc.2"
|
|
43
44
|
},
|
|
@@ -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,24 @@
|
|
|
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
|
+
const from = entry.state
|
|
15
|
+
// ORPHANED is only reachable from PENDING (and FAILED/ABANDONED). A candidate parked in HELD
|
|
16
|
+
// must be released back to PENDING first, which is legal, then orphaned. Do the shortest legal
|
|
17
|
+
// walk rather than guessing at the transition graph.
|
|
18
|
+
if (from === STATES.HELD) {
|
|
19
|
+
transition(ROOT, id, STATES.PENDING)
|
|
20
|
+
} else if (from !== STATES.PENDING && from !== STATES.FAILED && from !== STATES.ABANDONED) {
|
|
21
|
+
throw new Error(`${id} is ${from}, which has no legal route to orphaned`)
|
|
22
|
+
}
|
|
23
|
+
transition(ROOT, id, STATES.ORPHANED, { reason: REASONS.DIGEST_GONE })
|
|
24
|
+
console.log(`orphaned ${id} (was ${from}); now run memory_recover({ id: '${id}' })`)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis"
|
|
2
|
+
import { SessionStore } from "@deepseek-ai/dsh-session"
|
|
3
|
+
import JsonlSessionPersistence from "@deepseek-ai/dsh-session-persistence-jsonl"
|
|
4
|
+
const ctx = new Context()
|
|
5
|
+
await ctx.plugin(SessionStore)
|
|
6
|
+
await ctx.plugin(JsonlSessionPersistence, { root: "C:/Users/erikb/.dsh/sessions", compression: "zstd" })
|
|
7
|
+
const p = ctx.get("sessionPersistence")
|
|
8
|
+
const id = "session-b21ecc7e-7743-4b06-b07f-453541dbcde5"
|
|
9
|
+
try {
|
|
10
|
+
const h = await p.open(id, "read")
|
|
11
|
+
const snap = await h.read()
|
|
12
|
+
console.log("REAL OPEN OK — events:", snap?.events?.length ?? snap?.log?.length ?? "(shape)")
|
|
13
|
+
} catch (e) { console.log("REAL OPEN FAILED:", String(e.message).slice(0, 300)) }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix } from "file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts"
|
|
3
|
+
import { validateStoredEvents } from "file:///D:/deepseek-harness/packages/session/session-persistence/src/storage-contract.ts"
|
|
4
|
+
const buf = readFileSync(process.argv[2])
|
|
5
|
+
const scan = scanZstdFrames(buf)
|
|
6
|
+
let text = ""
|
|
7
|
+
for (const f of scan.frames) text += (await decompressZstdFrame(buf.subarray(f.start, f.end))).toString("utf8")
|
|
8
|
+
if (scan.tornStart !== undefined) text += (await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString("utf8")
|
|
9
|
+
const rows = text.split("\n").filter(Boolean).map(l => JSON.parse(l))
|
|
10
|
+
const meta = rows[0]; const events = rows.slice(1)
|
|
11
|
+
const dups = new Map(); for (const e of events) dups.set(e.seq, (dups.get(e.seq) ?? 0) + 1)
|
|
12
|
+
console.log("events:", events.length, "| duplicate seqs:", [...dups].filter(([,n]) => n > 1).slice(0, 5))
|
|
13
|
+
const bad = events.filter(e => e.type === "memory/map" && e.ignorable !== true)
|
|
14
|
+
console.log("memory/map without marker:", bad.length)
|
|
15
|
+
try { validateStoredEvents(meta, events, { kind: "jsonl", path: process.argv[2] }); console.log("VALIDATOR: OK") }
|
|
16
|
+
catch (e) { console.log("VALIDATOR THREW:", String(e.message).slice(0, 300)) }
|
|
@@ -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)`)
|
package/src/client/index.js
CHANGED
|
@@ -400,7 +400,13 @@ window.__ModuleLoader__.load({
|
|
|
400
400
|
// The host PUBLISHES the map into the log itself, so the tab fills in on its own and needs
|
|
401
401
|
// nobody to call a tool first. This is the route that matters; the tool/result route below
|
|
402
402
|
// is kept so a session whose snapshots predate the publisher still draws.
|
|
403
|
-
|
|
403
|
+
//
|
|
404
|
+
// BOTH TYPE NAMES. Harness 0.2.1 writes out-of-repo records through appendPluginRecord,
|
|
405
|
+
// which names them under the `plugin:` grammar; older builds wrote the bare `memory/map`
|
|
406
|
+
// type, and a V3 log migrated to V4 is renamed into `plugin:` but a V4 log written by an
|
|
407
|
+
// old build keeps the bare name. Accepting only one of them makes the tab silently empty
|
|
408
|
+
// for half the session history.
|
|
409
|
+
if (event.type === 'plugin:memory/map' || event.type === 'memory/map') {
|
|
404
410
|
const published = event.data
|
|
405
411
|
if (published === null || typeof published !== 'object') continue
|
|
406
412
|
if (typeof published.generatedAt !== 'string' || typeof published.scope !== 'string') continue
|
package/src/consolidate-run.js
CHANGED
|
@@ -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
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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,
|
package/src/host.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { BlockAssembler, ReasoningEffortId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
6
|
+
// Namespace import on purpose: an older harness does not export appendPluginRecord, and a named
|
|
7
|
+
// import of a missing ESM export fails at link time — which would break the whole plugin load
|
|
8
|
+
// instead of falling back to the append-with-ignorable path.
|
|
9
|
+
import * as dshSession from '@deepseek-ai/dsh-session'
|
|
6
10
|
import { captureSession, pendingDigests, pruneDigests } from './capture.js'
|
|
7
11
|
import { runConsolidation } from './consolidate-run.js'
|
|
8
12
|
import { runRefresh } from './refresh.js'
|
|
@@ -807,14 +811,14 @@ export function wireHost(ctx, store, cfg) {
|
|
|
807
811
|
if (!force && lastMapSignature.get(session.id) === signature) return
|
|
808
812
|
lastMapSignature.set(session.id, signature)
|
|
809
813
|
// Out-of-repo event types are absent from KNOWN_SESSION_EVENT_TYPES; without
|
|
810
|
-
// ignorable:true the persistence read path refuses the whole session log.
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
// of
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
if (
|
|
814
|
+
// ignorable:true the persistence read path refuses the whole session log. Harness 0.2.1
|
|
815
|
+
// moved that contract: `Session.append` no longer accepts an `ignorable` option at all
|
|
816
|
+
// (its third argument is surface metadata, valid only for surface event types), and an
|
|
817
|
+
// out-of-repo type is written through `appendPluginRecord`, which stamps `ignorable: true`
|
|
818
|
+
// by construction under the `plugin:` namespace. Both paths are handled by appendMapRecord;
|
|
819
|
+
// if the harness refuses to stamp the marker we suppress publishing rather than poison the
|
|
820
|
+
// log, so a later load cannot fail with "unknown to this harness and not marked ignorable".
|
|
821
|
+
if (!appendMapRecord(session, payload)) {
|
|
818
822
|
if (!mapPublishSuppressed.has(session.id)) {
|
|
819
823
|
mapPublishSuppressed.add(session.id)
|
|
820
824
|
logRun(store.root, 'map-publish', { ok: false, error: 'harness did not stamp ignorable; publishing suppressed for this session' })
|
|
@@ -828,6 +832,28 @@ export function wireHost(ctx, store, cfg) {
|
|
|
828
832
|
}
|
|
829
833
|
}
|
|
830
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Write the map snapshot into the session log as an ignorable record.
|
|
837
|
+
*
|
|
838
|
+
* Two harness contracts, chosen at call time rather than captured at wire time:
|
|
839
|
+
* - 0.2.1+: `appendPluginRecord(session, 'plugin:memory/map', data)`; the harness owns the
|
|
840
|
+
* envelope and always stamps `ignorable: true`, which is exactly what out-of-repo records
|
|
841
|
+
* require.
|
|
842
|
+
* - older: `session.append(type, data, { ignorable: true })`, whose RETURN VALUE must be
|
|
843
|
+
* checked — a harness that ignores the option returns an event without the marker, and
|
|
844
|
+
* every further append would poison the log.
|
|
845
|
+
* @returns whether the record was written with the ignorable marker.
|
|
846
|
+
*/
|
|
847
|
+
function appendMapRecord(session, payload) {
|
|
848
|
+
const appendRecord = cfg.appendPluginRecord !== undefined ? cfg.appendPluginRecord : dshSession.appendPluginRecord
|
|
849
|
+
if (typeof appendRecord === 'function') {
|
|
850
|
+
appendRecord(session, 'plugin:memory/map', payload)
|
|
851
|
+
return true
|
|
852
|
+
}
|
|
853
|
+
const logged = session.append('memory/map', payload, { ignorable: true })
|
|
854
|
+
return logged?.ignorable === true
|
|
855
|
+
}
|
|
856
|
+
|
|
831
857
|
function maybeSync() {
|
|
832
858
|
if (syncInFlight || !git.available()) return
|
|
833
859
|
if (!scheduler.shouldCommit() && !scheduler.shouldSync()) return
|