@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 +19 -2
- package/lib/memory-host.js +166 -5
- package/lib/service.js +93 -7
- package/package.json +1 -1
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
|
-
/**
|
|
31
|
-
|
|
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
|
package/lib/memory-host.js
CHANGED
|
@@ -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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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.
|
|
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",
|