@heixui/dsh-session-suite 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -27,8 +27,25 @@ import { stripStepTagsFromStream } from './lib/step-tag-filter.js'
27
27
 
28
28
  export const name = 'session-suite'
29
29
 
30
- /** The HTTP carrier is the only hard requirement; the rest degrade gracefully. */
31
- export const inject = ['connection']
30
+ /**
31
+ * `agents` is required for implicit memory extraction.
32
+ *
33
+ * Cordis dispatch is scope-filtered: "agent-scoped listeners receive only events from
34
+ * sessions entered through that agent's context". A listener on the plugin's ROOT
35
+ * context therefore never sees an agent's session events — which is why our
36
+ * `session/event` handler was registered successfully, never fired, and left the pending
37
+ * queue empty for six rounds of fixes.
38
+ *
39
+ * dsh-fish-memory (16k downloads) solves the same problem by injecting `agents`, taking
40
+ * one with `ctx.agents.get(sid)`, and listening on `agent.ctx` — the agent's own context.
41
+ * Its source records the trap in a comment: the Cordis events that exist are
42
+ * `agent/inbox/inserted` and `agent/turn-stopping`, NOT `user/message` /
43
+ * `assistant/message` (those are SessionEvent types carried by `session/event`).
44
+ *
45
+ * Everything else stays optional: the HTTP carrier is still the only hard requirement,
46
+ * and a Host without `agents` degrades to no implicit extraction rather than failing.
47
+ */
48
+ export const inject = ['connection', 'agents', 'timer']
32
49
 
33
50
  /**
34
51
  * @param {any} ctx plugin context
@@ -194,6 +194,53 @@ export function installMemory({ ctx, root, config, log }) {
194
194
  if (agent) recallByScope.set(agent, { text, at: Date.now() })
195
195
  lastRecall = text
196
196
  lastRecallFailed = false
197
+
198
+ // Implicit extraction, driven from HERE rather than from session/event.
199
+ //
200
+ // The event listener was registered successfully — installStatus reports
201
+ // `implicit extraction: ok` — and the log demonstrably contains 123 `turn/end`
202
+ // events, yet the handler never ran: `lastExtraction` stayed null across five
203
+ // fixes. Rather than keep chasing why an event listener does not receive
204
+ // events on a context where a sibling listener does, use the hook that is
205
+ // known to fire every step. `agent/pre-step` runs before each step and is
206
+ // where the memory injection already happens, so its liveness is proven by
207
+ // the feature working at all.
208
+ //
209
+ // The timing is one step later than turn/end would be: this extracts the
210
+ // PREVIOUS turn, because at pre-step the current turn's transcript is not
211
+ // complete yet. That is acceptable — a memory becomes available one step
212
+ // later, and the alternative is a feature that never runs.
213
+ //
214
+ // The model choice is read from ENGINE SETTINGS, not from `config`.
215
+ //
216
+ // This was the actual reason extraction never ran: the settings UI writes
217
+ // `implicitModel` through `longterm.settings`, which lands in
218
+ // `memory/settings.json` — while the guard read `config.implicitModel`, a
219
+ // patch value this profile never sets. The guard was therefore always false,
220
+ // so `extractAfterTurn` was never even called, and the trace stayed null.
221
+ // Verified against the live file: settings.json held
222
+ // "agnes-backup/agnes-3.0-flash" while the patch declared no such key.
223
+ //
224
+ // Guarded so a failure here cannot affect the recall decision above: the
225
+ // whole point of pre-step is that it returns `decision` intact.
226
+ const chosen = (await engine.settings().catch(() => ({})))?.implicitModel ?? config?.implicitModel
227
+ if (chosen && chosen !== 'off') {
228
+ const sessionId = agent?.sessionId
229
+ if (typeof sessionId === 'string' && sessionId) {
230
+ void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
231
+ .finally(() => publishExtractionTrace(engine))
232
+ .catch(error => {
233
+ extractionTrace.stage = 'threw'
234
+ extractionTrace.reason = errorMessage(error)
235
+ publishExtractionTrace(engine)
236
+ log('implicit memory extraction failed', error)
237
+ })
238
+ } else {
239
+ extractionTrace.reason = `pre-step ran but agent.sessionId was ${typeof sessionId}`
240
+ extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
241
+ publishExtractionTrace(engine)
242
+ }
243
+ }
197
244
  } catch (error) {
198
245
  // A failed rebuild must not leave the previous answer in place.
199
246
  //
@@ -214,6 +261,106 @@ export function installMemory({ ctx, root, config, log }) {
214
261
  ),
215
262
  )
216
263
 
264
+ // --- 2b. Per-agent extraction trigger -----------------------------------
265
+ //
266
+ // This is the trigger that actually works, copied from how dsh-fish-memory does it
267
+ // (16k downloads, demonstrably working). Two things had to be true and neither was:
268
+ //
269
+ // 1. the listener must be registered on the AGENT's context, not the plugin root.
270
+ // Cordis dispatch is scope-filtered, so a root-context `session/event` listener
271
+ // receives nothing from an agent's session — which is exactly what we observed:
272
+ // registered successfully, `lastExtraction` stayed null forever.
273
+ // 2. `agent/turn-stopping` is the hook that fires at the end of a turn on that
274
+ // scope. `user/message` and `assistant/message` are SessionEvent TYPES carried
275
+ // by `session/event`, not Cordis events — a trap fish-memory documents in a
276
+ // comment after hitting it themselves.
277
+ //
278
+ // The agent is reached through `ctx.agents`, so this requires the `agents` service;
279
+ // index.js now injects it. Without it the feature degrades to off rather than throwing.
280
+ safe('per-agent extraction hook', () => {
281
+ const agents = ctx.get?.('agents')
282
+ if (!agents) return
283
+
284
+ /** @param {any} agent live agent @returns {void} */
285
+ const mountFor = agent => {
286
+ const agentCtx = agent?.ctx
287
+ if (!agentCtx || typeof agentCtx.on !== 'function') return
288
+ // One listener per agent; the context disposes it with the agent.
289
+ agentCtx.on('agent/turn-stopping', () => {
290
+ const sessionId = agent?.sessionId
291
+ if (typeof sessionId !== 'string' || !sessionId) {
292
+ extractionTrace.reason = `turn-stopping fired but agent.sessionId was ${typeof sessionId}`
293
+ publishExtractionTrace(engine)
294
+ return
295
+ }
296
+ // The model choice lives in settings.json, not in the patch config.
297
+ void engine
298
+ .settings()
299
+ .then(settings => {
300
+ const chosen = settings?.implicitModel ?? config?.implicitModel
301
+ if (!chosen || chosen === 'off') {
302
+ extractionTrace.reason = 'no implicit model is selected in settings'
303
+ publishExtractionTrace(engine)
304
+ return undefined
305
+ }
306
+ const cwd = resolveCwd(ctx, agent)
307
+ return extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
308
+ })
309
+ .finally(() => publishExtractionTrace(engine))
310
+ .catch(error => {
311
+ extractionTrace.stage = 'threw'
312
+ extractionTrace.reason = errorMessage(error)
313
+ publishExtractionTrace(engine)
314
+ log('implicit memory extraction failed', error)
315
+ })
316
+ })
317
+ }
318
+
319
+ // Mount by POLLING, not by an event.
320
+ //
321
+ // The first version listened for `agent/created`, which never delivered a usable
322
+ // agent — the hook stayed unmounted and the trace stayed null, the same symptom as
323
+ // every earlier attempt. dsh-fish-memory does not use an event either: it runs
324
+ // `ctx.effect(() => ctx.interval(tick, 500))` and calls `agents.list()` every half
325
+ // second, mounting on any agent it has not seen yet. That is what makes its version
326
+ // work, and its source notes the tradeoff — the tick is cheap because `agents.list()`
327
+ // reads memory.
328
+ //
329
+ // Polling also handles the case an event cannot: the agent instance is REPLACED when
330
+ // a session is resumed, a plugin reloads, or the Host rebuilds it. A one-shot
331
+ // listener dies with the old instance; a poll re-mounts on the new one.
332
+ const mounted = new WeakSet()
333
+ const tick = () => {
334
+ let live = []
335
+ try {
336
+ live = agents.list?.() ?? []
337
+ } catch {
338
+ return
339
+ }
340
+ for (const agent of live) {
341
+ if (!agent || mounted.has(agent)) continue
342
+ // Subagents only do one job; a memory store for them is wasted work.
343
+ if (agent.origin === 'subagent' || agent.delegationDepth > 0) continue
344
+ mounted.add(agent)
345
+ try {
346
+ mountFor(agent)
347
+ } catch (error) {
348
+ log('could not mount the extraction hook on an agent', error)
349
+ }
350
+ }
351
+ }
352
+ // First pass immediately, so an already-running session is covered without waiting.
353
+ tick()
354
+ // Then every half second. `ctx.interval` is the Cordis timer facility; fall back to
355
+ // setInterval when it is absent so the feature still works on a leaner Host.
356
+ if (typeof ctx.interval === 'function') ctx.effect(() => ctx.interval(tick, 500))
357
+ else if (typeof setInterval === 'function') {
358
+ const handle = setInterval(tick, 500)
359
+ handle.unref?.()
360
+ ctx.effect(() => () => clearInterval(handle))
361
+ }
362
+ })
363
+
217
364
  // --- 3. Implicit extraction after a turn --------------------------------
218
365
  safe('implicit extraction', () => {
219
366
  // A startup marker, recorded before the listener is even registered.
@@ -595,6 +742,44 @@ function contextualQuery(messages, limits = {}) {
595
742
  return `${earlier.join(' ')} ${current}`.trim()
596
743
  }
597
744
 
745
+ /**
746
+ * Read a session's events through whichever Host API is present.
747
+ *
748
+ * The Host moved from `sessionController.inspect(sessionId)` to
749
+ * `sessionController.events({ address }, signal)`. Calling the old name behind an
750
+ * optional chain returned `undefined` with no error, so every consumer silently saw an
751
+ * empty session — share and segment-delete failed with a raw TypeError, and implicit
752
+ * extraction reported "no completed turn yet" forever.
753
+ *
754
+ * Both shapes are attempted, newest first, and a missing implementation throws rather
755
+ * than returning an empty list: an empty list is indistinguishable from an empty session,
756
+ * which is exactly how this stayed hidden.
757
+ *
758
+ * @param {any} controller session controller
759
+ * @param {string} sessionId session
760
+ * @returns {Promise<any[]>} events
761
+ */
762
+ async function readSessionEvents(controller, sessionId) {
763
+ if (!controller) throw new Error('the sessionController service is not mounted')
764
+
765
+ if (typeof controller.events === 'function') {
766
+ // `events(request, signal)` — the implementation calls `signal.throwIfAborted()`,
767
+ // so a real signal is required.
768
+ const page = await controller.events({ address: { kind: 'session', sessionId } }, new AbortController().signal)
769
+ const records = Array.isArray(page?.records) ? page.records : []
770
+ return records
771
+ .map(record => (record?.type === 'event' ? record.event : record?.event ?? record))
772
+ .filter(Boolean)
773
+ }
774
+
775
+ if (typeof controller.inspect === 'function') {
776
+ const inspection = await controller.inspect(sessionId)
777
+ return Array.isArray(inspection?.events) ? inspection.events : []
778
+ }
779
+
780
+ throw new Error('this Host exposes neither sessionController.events nor sessionController.inspect')
781
+ }
782
+
598
783
  /**
599
784
  * Ask a small model call what, if anything, is worth remembering from this turn.
600
785
  *
@@ -684,8 +869,15 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
684
869
  if (!llm?.stream) return failExtraction('the Host exposes no llm.stream')
685
870
 
686
871
  const sessionController = ctx.get('sessionController')
687
- const inspection = await sessionController?.inspect?.(sessionId).catch(() => undefined)
688
- const events = inspection?.events ?? []
872
+ // Read the session's events through whichever API this Host exposes.
873
+ //
874
+ // The optional-chained `inspect?.()` call silently produced `undefined` on the current
875
+ // build — which has no `inspect` — so `events` was empty, `turn` was not a number, and
876
+ // extraction returned "this session has no completed turn yet" forever. That is the
877
+ // real reason the pending queue never filled, and it is the same defect that broke
878
+ // share and segment-delete. The `?.` hid it: a missing method read as "nothing to do"
879
+ // rather than as an error.
880
+ const events = await readSessionEvents(sessionController, sessionId)
689
881
  const turn = lastCompletedTurn(events)
690
882
  if (typeof turn !== 'number') return failExtraction('this session has no completed turn yet')
691
883
  const transcript = turnTranscript(events, turn)
@@ -694,7 +886,7 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
694
886
  // The memory model is a fallback chain: a free provider that is rate limited
695
887
  // (429) or unreachable must not silently disable extraction for the session,
696
888
  // so every candidate is tried in order until one answers.
697
- const candidates = memoryCandidates(config, ctx)
889
+ const candidates = memoryCandidates(config, ctx, settings)
698
890
  if (candidates.length === 0) {
699
891
  return failExtraction(
700
892
  config?.implicitModel === 'current'
@@ -856,7 +1048,7 @@ function numberOr(value, fallback) {
856
1048
  * @param {any} config plugin config
857
1049
  * @returns {{ provider: string, model: string }[]} candidates in priority order
858
1050
  */
859
- function memoryCandidates(config, ctx) {
1051
+ function memoryCandidates(config, ctx, settings) {
860
1052
  const raw = []
861
1053
  const push = value => {
862
1054
  if (Array.isArray(value)) raw.push(...value)
@@ -870,7 +1062,16 @@ function memoryCandidates(config, ctx) {
870
1062
  // the offered chain, and that pick must beat the chain (which is a fallback list,
871
1063
  // not a preference). Parsed on the last slash so a model id that itself contains a
872
1064
  // slash — "Qwen/Qwen2.5-7B-Instruct" — survives intact.
873
- const picked = typeof config?.implicitModel === 'string' ? config.implicitModel : ''
1065
+ //
1066
+ // Read from SETTINGS first: the UI persists the choice to memory/settings.json, and
1067
+ // reading only `config` meant a chosen model was never seen — the picker worked, the
1068
+ // file had the value, and extraction still never ran.
1069
+ const picked =
1070
+ typeof settings?.implicitModel === 'string'
1071
+ ? settings.implicitModel
1072
+ : typeof config?.implicitModel === 'string'
1073
+ ? config.implicitModel
1074
+ : ''
874
1075
  if (picked && picked !== 'current' && picked !== 'off') {
875
1076
  const at = picked.indexOf('/')
876
1077
  if (at > 0 && at < picked.length - 1) {
package/lib/service.js CHANGED
@@ -51,15 +51,98 @@ export class SessionSuiteService {
51
51
  }
52
52
 
53
53
  /**
54
+ * Read one session's header and events.
55
+ *
56
+ * The controller's shape changed between Host builds. Older builds exposed
57
+ * `inspect(sessionId)` returning `{ meta, events }`; the current one exposes
58
+ * `events({ address, ... }, signal)` returning a page of `{ records: [{ type, event }] }`.
59
+ * Calling the old name on the new build throws `controller.inspect is not a function`,
60
+ * which surfaced as share and segment-delete both failing with that raw error in the
61
+ * dialog — the feature looked broken rather than the Host having moved on.
62
+ *
63
+ * Both shapes are attempted, newest first, so this works on either build without
64
+ * pinning to one. The normalised result is the same either way, so every caller
65
+ * above this line stays unchanged.
66
+ *
54
67
  * @param {string} sessionId session
55
68
  * @returns {Promise<{ meta: any, events: any[], title?: string, cwd?: string }>} inspection
56
69
  */
57
70
  async inspect(sessionId) {
58
71
  const controller = this.#require('sessionController')
59
- const inspection = await controller.inspect(sessionId)
60
- if (!inspection) throw new Error(`session ${sessionId} was not found`)
61
- const events = Array.isArray(inspection.events) ? inspection.events : []
62
- return { meta: inspection.meta ?? { id: sessionId }, events, title: titleOf(events), cwd: inspection.meta?.cwd }
72
+
73
+ // Current build: a paged read keyed by a session address.
74
+ if (typeof controller.events === 'function') {
75
+ // The Remote signature is `events(request, signal)` and the implementation calls
76
+ // `signal.throwIfAborted()` before reading — so a missing signal throws
77
+ // "Cannot read properties of undefined (reading 'throwIfAborted')" rather than
78
+ // defaulting to "no cancellation". Pass a real AbortSignal.
79
+ //
80
+ // A single call returns ONE PAGE, not the whole session. The share dialog showed
81
+ // "3/3 turns" for a conversation with 129 — the reader had been given the last
82
+ // page and nothing else. Page backwards until the reader reports no more, so
83
+ // callers get the complete history they were always promised.
84
+ const signal = new AbortController().signal
85
+ const collected = []
86
+ let beforeSeq
87
+ let guard = 0
88
+ while (guard < 400) {
89
+ guard += 1
90
+ const request = { address: { kind: 'session', sessionId } }
91
+ if (typeof beforeSeq === 'number') request.beforeSeq = beforeSeq
92
+ const page = await controller.events(request, signal)
93
+ const records = Array.isArray(page?.records) ? page.records : []
94
+ const events = records
95
+ .map(record => (record?.type === 'event' ? record.event : record?.event ?? record))
96
+ .filter(Boolean)
97
+ // Pages arrive newest-first or oldest-first depending on the cursor; collecting
98
+ // in order and de-duplicating by seq at the end keeps this independent of which.
99
+ collected.push(...events)
100
+ if (!page?.hasMore || records.length === 0) break
101
+ // The cursor is the inclusive boundary of the page just read.
102
+ const next = typeof page?.cursor === 'number' ? page.cursor : undefined
103
+ if (next === undefined || next === beforeSeq) break
104
+ beforeSeq = next
105
+ }
106
+ const seen = new Map()
107
+ for (const event of collected) {
108
+ const key = typeof event?.seq === 'number' ? event.seq : undefined
109
+ if (key === undefined) continue
110
+ if (!seen.has(key)) seen.set(key, event)
111
+ }
112
+ const events = [...seen.values()].sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
113
+ const meta = { id: sessionId, cwd: undefined }
114
+ // The header lives in the session summary; the event stream does not carry it.
115
+ const summary = await this.#sessionSummary(controller, sessionId)
116
+ if (summary?.cwd) meta.cwd = summary.cwd
117
+ return { meta, events, title: titleOf(events), cwd: meta.cwd }
118
+ }
119
+
120
+ // Older build: a single inspect call.
121
+ if (typeof controller.inspect === 'function') {
122
+ const inspection = await controller.inspect(sessionId)
123
+ if (!inspection) throw new Error(`session ${sessionId} was not found`)
124
+ const events = Array.isArray(inspection.events) ? inspection.events : []
125
+ return { meta: inspection.meta ?? { id: sessionId }, events, title: titleOf(events), cwd: inspection.meta?.cwd }
126
+ }
127
+
128
+ throw new Error('this Host exposes neither sessionController.events nor sessionController.inspect')
129
+ }
130
+
131
+ /**
132
+ * Find one session's summary row, for fields the event stream does not carry.
133
+ *
134
+ * @param {any} controller session controller
135
+ * @param {string} sessionId session
136
+ * @returns {Promise<any | undefined>} summary
137
+ */
138
+ async #sessionSummary(controller, sessionId) {
139
+ try {
140
+ const value = await controller.list({})
141
+ const items = Array.isArray(value?.items) ? value.items : []
142
+ return items.find(item => item?.sessionId === sessionId)
143
+ } catch {
144
+ return undefined
145
+ }
63
146
  }
64
147
 
65
148
  /**
@@ -541,9 +624,12 @@ export class SessionSuiteService {
541
624
  // `signal.throwIfAborted()` while admitting the prompt, so omitting the
542
625
  // second argument makes it dereference `undefined` — reported as
543
626
  // "Cannot read properties of undefined (reading 'throwIfAborted')".
544
- const result = signal === undefined
545
- ? await controller.prompt(request)
546
- : await controller.prompt(request, signal)
627
+ //
628
+ // A branch on `signal === undefined` still passed undefined in that case, which is
629
+ // exactly the input that crashes. Always supply a real signal: reuse the caller's
630
+ // when there is one, otherwise create an unaborted one.
631
+ const effective = signal ?? new AbortController().signal
632
+ const result = await controller.prompt(request, effective)
547
633
  if (result && result.accepted === false) throw new Error('the Host rejected the resent prompt')
548
634
  }
549
635
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heixui/dsh-session-suite",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Session checkpoints for DSH: rewind a conversation with its files and memory, permanent session deletion, and shareable exports",
5
5
  "type": "module",
6
6
  "main": "./index.js",