@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 +24 -0
- package/cordis.patch.yml +13 -6
- package/index.js +23 -2
- package/lib/api.js +8 -2
- package/lib/memory-host.js +188 -7
- package/lib/service.js +93 -7
- package/package.json +1 -1
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.
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
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
|
-
/**
|
|
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
|
|
@@ -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
|
-
|
|
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':
|
package/lib/memory-host.js
CHANGED
|
@@ -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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|