@heixui/dsh-session-suite 0.5.0 → 0.6.0

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/client.js CHANGED
@@ -272,6 +272,7 @@ window.__ModuleLoader__.load({
272
272
  'memory.extractionNotRegistered': 'HOOK NOT REGISTERED',
273
273
  'memory.extractionNoTrace': 'no extraction trace recorded by this Host',
274
274
  'memory.diagTitle': 'Extraction diagnostics:',
275
+ 'memory.diagSteps': '{ok}/{total} hooks installed',
275
276
  'memory.accept': 'Keep',
276
277
  'memory.reject': 'Discard',
277
278
  'memory.accepted': 'Kept',
@@ -520,6 +521,7 @@ window.__ModuleLoader__.load({
520
521
  'memory.extractionNotRegistered': '钩子未注册',
521
522
  'memory.extractionNoTrace': '本次运行没有记录到提取痕迹',
522
523
  'memory.diagTitle': '提取诊断:',
524
+ 'memory.diagSteps': '{ok}/{total} 个钩子已装',
523
525
  'memory.accept': '保留',
524
526
  'memory.reject': '丢弃',
525
527
  'memory.accepted': '已保留',
@@ -1047,6 +1049,7 @@ window.__ModuleLoader__.load({
1047
1049
  font-weight: 600;
1048
1050
  color: var(--dsw-alias-label-primary, #1f1f1f);
1049
1051
  }
1052
+ .dss-diag-steps { color: var(--dsw-alias-label-secondary, #6b6b6b); }
1050
1053
  /* The summary charts. Two narrow panels for composition and health, one wide panel
1051
1054
  for tags: composition and health are short lists, tags can run long. Bars are divs,
1052
1055
  not SVG, so they inherit the theme and stay legible in both palettes. */
@@ -3449,6 +3452,10 @@ window.__ModuleLoader__.load({
3449
3452
  // unremarkable" from "every memory model failed" or "a filter ate the
3450
3453
  // result". When there is nothing pending, say why.
3451
3454
  const extraction = status?.extraction
3455
+ // Which optional hooks installed. Reported separately from the extraction trace
3456
+ // because a hook that never installed cannot write a trace — that is the whole
3457
+ // point: "no trace" has two causes and this separates them.
3458
+ const installStatus = Array.isArray(status?.install) ? status.install : []
3452
3459
  const pendingBlock =
3453
3460
  pending.length > 0
3454
3461
  ? h(
@@ -3565,6 +3572,23 @@ window.__ModuleLoader__.load({
3565
3572
  : t('memory.extractionNotRegistered'),
3566
3573
  )
3567
3574
  : null,
3575
+ // The registration ledger: which optional hooks installed and which
3576
+ // threw. This is the only way to see a step that never ran, because the
3577
+ // code that would have reported it is the code that failed.
3578
+ installStatus.length > 0
3579
+ ? h(
3580
+ 'span',
3581
+ { className: 'dss-diag-steps' },
3582
+ t('memory.diagSteps', {
3583
+ ok: installStatus.filter(step => step.ok).length,
3584
+ total: installStatus.length,
3585
+ }),
3586
+ installStatus
3587
+ .filter(step => !step.ok)
3588
+ .map(step => ` · ${step.label}: ${step.error ?? 'failed'}`)
3589
+ .join(''),
3590
+ )
3591
+ : null,
3568
3592
  ),
3569
3593
  )
3570
3594
 
package/cordis.patch.yml CHANGED
@@ -54,12 +54,19 @@
54
54
  # says otherwise.
55
55
  recallLimit: 6
56
56
  # How much of the conversation before the current message is folded into the
57
- # retrieval query. A follow-up like "那它为什么慢?" has no referent for "它", so
58
- # searching those characters alone cannot find the memory that answers it.
59
- # The previous user turns are appended BEFORE the current question, so the
60
- # question's own terms still dominate the score. 0 disables the behaviour and
61
- # searches the current message exactly as before.
62
- contextQueryChars: 160
57
+ # retrieval query. DEFAULT 0 = OFF.
58
+ #
59
+ # Why off by default: the model already receives the full conversation history,
60
+ # so it knows what 「它」 refers to. Folding earlier turns into the query only
61
+ # helps the RETRIEVAL step pick a better memory; it does not make the model
62
+ # understand the question better. That is a real but small gain, and it costs a
63
+ # little accuracy on self-contained questions that get padded unnecessarily.
64
+ #
65
+ # Turn it on when the prompt is short by design — a workflow that deliberately
66
+ # keeps requests small (an agent that works from a compact brief rather than a
67
+ # long transcript) benefits, because there the history is NOT in the prompt.
68
+ # 160 characters across 3 previous turns is a reasonable starting point.
69
+ contextQueryChars: 0
63
70
  contextQueryTurns: 3
64
71
  # How many of the most important memories ride along regardless of the
65
72
  # question. Without a resident layer, an ordinary follow-up like "继续"
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']
32
49
 
33
50
  /**
34
51
  * @param {any} ctx plugin context
@@ -97,6 +114,10 @@ export function apply(ctx, config = {}) {
97
114
  try {
98
115
  const installed = installMemory({ ctx, root, config, log })
99
116
  service.longTerm = installed.engine
117
+ // Kept separately: the engine is the memory store, and this is the record of
118
+ // which optional registrations succeeded. Exposed through the status endpoint so
119
+ // a missing feature can be traced to a step that never installed.
120
+ service.memoryInstallStatus = installed.installStatus
100
121
  log('long-term memory engine ready')
101
122
  } catch (error) {
102
123
  log('long-term memory failed to install; continuing without it', error)
package/lib/api.js CHANGED
@@ -303,8 +303,14 @@ async function respond(service, deps, action, input) {
303
303
  })
304
304
  case 'longterm.maintain':
305
305
  return json({ ok: true, data: await service.longTerm.maintain(workspaceOf(input)) })
306
- case 'longterm.status':
307
- return json({ ok: true, data: await service.longTerm.status() })
306
+ case 'longterm.status': {
307
+ // The engine's own status plus the registration ledger. Two sources on purpose:
308
+ // the engine cannot report a hook that never installed, because the code that
309
+ // would have written it is the code that failed.
310
+ const status = await service.longTerm.status()
311
+ const install = typeof service.memoryInstallStatus === 'function' ? service.memoryInstallStatus() : []
312
+ return json({ ok: true, data: { ...status, install } })
313
+ }
308
314
  case 'longterm.workspaces':
309
315
  return json({ ok: true, data: await service.longTerm.workspaces() })
310
316
  case 'longterm.models':
@@ -91,13 +91,21 @@ export function installMemory({ ctx, root, config, log }) {
91
91
  * Run one optional registration; a failure is reported and skipped so a single
92
92
  * incompatible hook can never disable the rest of the bundle.
93
93
  *
94
+ * Every step records its outcome. The silent-catch was the reason a broken step was
95
+ * invisible: "the queue is empty" could mean the hook was never installed, and
96
+ * nothing on screen said so. The steps list is published through status() so the
97
+ * settings page can show which registrations succeeded.
98
+ *
94
99
  * @param {string} label what is being installed
95
100
  * @param {() => void} install registration body
96
101
  */
102
+ const steps = []
97
103
  const safe = (label, install) => {
98
104
  try {
99
105
  install()
106
+ steps.push({ label, ok: true })
100
107
  } catch (error) {
108
+ steps.push({ label, ok: false, error: error instanceof Error ? error.message : String(error) })
101
109
  log(`long-term memory: ${label} failed to install`, error)
102
110
  }
103
111
  }
@@ -186,6 +194,53 @@ export function installMemory({ ctx, root, config, log }) {
186
194
  if (agent) recallByScope.set(agent, { text, at: Date.now() })
187
195
  lastRecall = text
188
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
+ }
189
244
  } catch (error) {
190
245
  // A failed rebuild must not leave the previous answer in place.
191
246
  //
@@ -206,6 +261,66 @@ export function installMemory({ ctx, root, config, log }) {
206
261
  ),
207
262
  )
208
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) return
292
+ // The model choice lives in settings.json, not in the patch config.
293
+ void engine
294
+ .settings()
295
+ .then(settings => {
296
+ const chosen = settings?.implicitModel ?? config?.implicitModel
297
+ if (!chosen || chosen === 'off') return undefined
298
+ const cwd = resolveCwd(ctx, agent)
299
+ return extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
300
+ })
301
+ .finally(() => publishExtractionTrace(engine))
302
+ .catch(error => {
303
+ extractionTrace.stage = 'threw'
304
+ extractionTrace.reason = errorMessage(error)
305
+ publishExtractionTrace(engine)
306
+ log('implicit memory extraction failed', error)
307
+ })
308
+ })
309
+ }
310
+
311
+ // Mount for agents that already exist, and for every one created later.
312
+ for (const agent of agents.list?.() ?? []) mountFor(agent)
313
+ if (typeof ctx.on === 'function') {
314
+ ctx.on('agent/created', payload => {
315
+ try {
316
+ mountFor(payload?.agent ?? payload)
317
+ } catch {
318
+ /* one bad agent must not stop the rest */
319
+ }
320
+ })
321
+ }
322
+ })
323
+
209
324
  // --- 3. Implicit extraction after a turn --------------------------------
210
325
  safe('implicit extraction', () => {
211
326
  // A startup marker, recorded before the listener is even registered.
@@ -410,6 +525,9 @@ export function installMemory({ ctx, root, config, log }) {
410
525
  return {
411
526
  engine,
412
527
  dispose: () => {},
528
+ // The outcome of every optional registration, so "the queue is empty" can be
529
+ // traced to a specific step that never installed.
530
+ installStatus: () => [...steps],
413
531
  // Exposed so the settings page and the status endpoint can report whether the
414
532
  // last recall rebuild failed. Without this the failure is silent: the model
415
533
  // simply acts as if the memory is not there.
@@ -535,8 +653,17 @@ function contextualQuery(messages, limits = {}) {
535
653
  const current = lastUserText(messages)
536
654
  if (!current) return ''
537
655
 
538
- const maxChars = limits.chars ?? 160
539
- const maxTurns = limits.turns ?? 3
656
+ // 0 is the default, and it means disabled.
657
+ //
658
+ // Why: the model already receives the conversation history, so it knows what a
659
+ // pronoun refers to. Folding earlier turns into the retrieval query only improves
660
+ // which MEMORY is picked — a real but small gain — and it costs a little accuracy
661
+ // on self-contained questions that get padded unnecessarily. It matters for a
662
+ // workflow that deliberately keeps the prompt small, where the history is not there
663
+ // to be read; that is the case this exists for, and it is opt-in.
664
+ const maxChars = typeof limits.chars === 'number' ? limits.chars : 0
665
+ const maxTurns = typeof limits.turns === 'number' ? limits.turns : 3
666
+ if (maxChars <= 0) return current
540
667
 
541
668
  // A self-contained question needs no help. The test is NOT length alone: a short
542
669
  // question that names its own subject ("导出的图片为什么断链") is self-contained,
@@ -575,6 +702,44 @@ function contextualQuery(messages, limits = {}) {
575
702
  return `${earlier.join(' ')} ${current}`.trim()
576
703
  }
577
704
 
705
+ /**
706
+ * Read a session's events through whichever Host API is present.
707
+ *
708
+ * The Host moved from `sessionController.inspect(sessionId)` to
709
+ * `sessionController.events({ address }, signal)`. Calling the old name behind an
710
+ * optional chain returned `undefined` with no error, so every consumer silently saw an
711
+ * empty session — share and segment-delete failed with a raw TypeError, and implicit
712
+ * extraction reported "no completed turn yet" forever.
713
+ *
714
+ * Both shapes are attempted, newest first, and a missing implementation throws rather
715
+ * than returning an empty list: an empty list is indistinguishable from an empty session,
716
+ * which is exactly how this stayed hidden.
717
+ *
718
+ * @param {any} controller session controller
719
+ * @param {string} sessionId session
720
+ * @returns {Promise<any[]>} events
721
+ */
722
+ async function readSessionEvents(controller, sessionId) {
723
+ if (!controller) throw new Error('the sessionController service is not mounted')
724
+
725
+ if (typeof controller.events === 'function') {
726
+ // `events(request, signal)` — the implementation calls `signal.throwIfAborted()`,
727
+ // so a real signal is required.
728
+ const page = await controller.events({ address: { kind: 'session', sessionId } }, new AbortController().signal)
729
+ const records = Array.isArray(page?.records) ? page.records : []
730
+ return records
731
+ .map(record => (record?.type === 'event' ? record.event : record?.event ?? record))
732
+ .filter(Boolean)
733
+ }
734
+
735
+ if (typeof controller.inspect === 'function') {
736
+ const inspection = await controller.inspect(sessionId)
737
+ return Array.isArray(inspection?.events) ? inspection.events : []
738
+ }
739
+
740
+ throw new Error('this Host exposes neither sessionController.events nor sessionController.inspect')
741
+ }
742
+
578
743
  /**
579
744
  * Ask a small model call what, if anything, is worth remembering from this turn.
580
745
  *
@@ -664,8 +829,15 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
664
829
  if (!llm?.stream) return failExtraction('the Host exposes no llm.stream')
665
830
 
666
831
  const sessionController = ctx.get('sessionController')
667
- const inspection = await sessionController?.inspect?.(sessionId).catch(() => undefined)
668
- const events = inspection?.events ?? []
832
+ // Read the session's events through whichever API this Host exposes.
833
+ //
834
+ // The optional-chained `inspect?.()` call silently produced `undefined` on the current
835
+ // build — which has no `inspect` — so `events` was empty, `turn` was not a number, and
836
+ // extraction returned "this session has no completed turn yet" forever. That is the
837
+ // real reason the pending queue never filled, and it is the same defect that broke
838
+ // share and segment-delete. The `?.` hid it: a missing method read as "nothing to do"
839
+ // rather than as an error.
840
+ const events = await readSessionEvents(sessionController, sessionId)
669
841
  const turn = lastCompletedTurn(events)
670
842
  if (typeof turn !== 'number') return failExtraction('this session has no completed turn yet')
671
843
  const transcript = turnTranscript(events, turn)
@@ -674,7 +846,7 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
674
846
  // The memory model is a fallback chain: a free provider that is rate limited
675
847
  // (429) or unreachable must not silently disable extraction for the session,
676
848
  // so every candidate is tried in order until one answers.
677
- const candidates = memoryCandidates(config, ctx)
849
+ const candidates = memoryCandidates(config, ctx, settings)
678
850
  if (candidates.length === 0) {
679
851
  return failExtraction(
680
852
  config?.implicitModel === 'current'
@@ -836,7 +1008,7 @@ function numberOr(value, fallback) {
836
1008
  * @param {any} config plugin config
837
1009
  * @returns {{ provider: string, model: string }[]} candidates in priority order
838
1010
  */
839
- function memoryCandidates(config, ctx) {
1011
+ function memoryCandidates(config, ctx, settings) {
840
1012
  const raw = []
841
1013
  const push = value => {
842
1014
  if (Array.isArray(value)) raw.push(...value)
@@ -850,7 +1022,16 @@ function memoryCandidates(config, ctx) {
850
1022
  // the offered chain, and that pick must beat the chain (which is a fallback list,
851
1023
  // not a preference). Parsed on the last slash so a model id that itself contains a
852
1024
  // slash — "Qwen/Qwen2.5-7B-Instruct" — survives intact.
853
- const picked = typeof config?.implicitModel === 'string' ? config.implicitModel : ''
1025
+ //
1026
+ // Read from SETTINGS first: the UI persists the choice to memory/settings.json, and
1027
+ // reading only `config` meant a chosen model was never seen — the picker worked, the
1028
+ // file had the value, and extraction still never ran.
1029
+ const picked =
1030
+ typeof settings?.implicitModel === 'string'
1031
+ ? settings.implicitModel
1032
+ : typeof config?.implicitModel === 'string'
1033
+ ? config.implicitModel
1034
+ : ''
854
1035
  if (picked && picked !== 'current' && picked !== 'off') {
855
1036
  const at = picked.indexOf('/')
856
1037
  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.0",
3
+ "version": "0.6.0",
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",