@heixui/dsh-session-suite 0.5.1 → 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/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
@@ -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,66 @@ 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) 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
+
217
324
  // --- 3. Implicit extraction after a turn --------------------------------
218
325
  safe('implicit extraction', () => {
219
326
  // A startup marker, recorded before the listener is even registered.
@@ -595,6 +702,44 @@ function contextualQuery(messages, limits = {}) {
595
702
  return `${earlier.join(' ')} ${current}`.trim()
596
703
  }
597
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
+
598
743
  /**
599
744
  * Ask a small model call what, if anything, is worth remembering from this turn.
600
745
  *
@@ -684,8 +829,15 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
684
829
  if (!llm?.stream) return failExtraction('the Host exposes no llm.stream')
685
830
 
686
831
  const sessionController = ctx.get('sessionController')
687
- const inspection = await sessionController?.inspect?.(sessionId).catch(() => undefined)
688
- 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)
689
841
  const turn = lastCompletedTurn(events)
690
842
  if (typeof turn !== 'number') return failExtraction('this session has no completed turn yet')
691
843
  const transcript = turnTranscript(events, turn)
@@ -694,7 +846,7 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
694
846
  // The memory model is a fallback chain: a free provider that is rate limited
695
847
  // (429) or unreachable must not silently disable extraction for the session,
696
848
  // so every candidate is tried in order until one answers.
697
- const candidates = memoryCandidates(config, ctx)
849
+ const candidates = memoryCandidates(config, ctx, settings)
698
850
  if (candidates.length === 0) {
699
851
  return failExtraction(
700
852
  config?.implicitModel === 'current'
@@ -856,7 +1008,7 @@ function numberOr(value, fallback) {
856
1008
  * @param {any} config plugin config
857
1009
  * @returns {{ provider: string, model: string }[]} candidates in priority order
858
1010
  */
859
- function memoryCandidates(config, ctx) {
1011
+ function memoryCandidates(config, ctx, settings) {
860
1012
  const raw = []
861
1013
  const push = value => {
862
1014
  if (Array.isArray(value)) raw.push(...value)
@@ -870,7 +1022,16 @@ function memoryCandidates(config, ctx) {
870
1022
  // the offered chain, and that pick must beat the chain (which is a fallback list,
871
1023
  // not a preference). Parsed on the last slash so a model id that itself contains a
872
1024
  // slash — "Qwen/Qwen2.5-7B-Instruct" — survives intact.
873
- 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
+ : ''
874
1035
  if (picked && picked !== 'current' && picked !== 'off') {
875
1036
  const at = picked.indexOf('/')
876
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.1",
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",