@heixui/dsh-session-suite 0.6.2 → 0.8.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 +29 -7
- package/cordis.patch.yml +13 -2
- package/lib/api.js +49 -0
- package/lib/memory-host.js +44 -104
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -2937,8 +2937,18 @@ window.__ModuleLoader__.load({
|
|
|
2937
2937
|
// settings page said something else entirely. 'all' now means every store.
|
|
2938
2938
|
useEffect(() => {
|
|
2939
2939
|
let alive = true
|
|
2940
|
+
// Whether the long-term memory engine is running at all.
|
|
2941
|
+
//
|
|
2942
|
+
// It is OFF by default because it collides with dsh-fish-memory: both inject at
|
|
2943
|
+
// `systemPrompt.context({ order: 900 })`, so two memory blocks per request and
|
|
2944
|
+
// double the tokens. When it is off the API answers LONGTERM_DISABLED and the
|
|
2945
|
+
// panel must show only the session half — a memory row that opens onto a
|
|
2946
|
+
// disabled engine is worse than no row.
|
|
2940
2947
|
Promise.all([
|
|
2941
|
-
call({ action: 'longterm.list', scope: 'all' }).
|
|
2948
|
+
call({ action: 'longterm.list', scope: 'all' }).then(
|
|
2949
|
+
value => ({ enabled: true, value }),
|
|
2950
|
+
error => ({ enabled: error?.code !== 'LONGTERM_DISABLED', value: [] }),
|
|
2951
|
+
),
|
|
2942
2952
|
call({ action: 'list' }).catch(() => []),
|
|
2943
2953
|
// Per-workspace breakdown, so the totals can be explained rather than
|
|
2944
2954
|
// merely displayed. The counts above are sums; without this the user
|
|
@@ -2946,15 +2956,16 @@ window.__ModuleLoader__.load({
|
|
|
2946
2956
|
// made the earlier 17/21/27/51 mismatch look like a bug.
|
|
2947
2957
|
call({ action: 'longterm.workspaces' }).catch(() => []),
|
|
2948
2958
|
])
|
|
2949
|
-
.then(([
|
|
2959
|
+
.then(([memory, sessions, workspaces]) => {
|
|
2950
2960
|
if (!alive) return
|
|
2951
2961
|
const items = Array.isArray(sessions) ? sessions : []
|
|
2952
2962
|
const stores = Array.isArray(workspaces) ? workspaces : []
|
|
2953
|
-
const memoryRows = Array.isArray(
|
|
2963
|
+
const memoryRows = Array.isArray(memory?.value) ? memory.value : []
|
|
2954
2964
|
// The global store's size, so the breakdown adds up to the total instead
|
|
2955
2965
|
// of leaving the difference unexplained.
|
|
2956
2966
|
const globalRows = memoryRows.filter(row => row.scope === 'global').length
|
|
2957
2967
|
setCounts({
|
|
2968
|
+
memoryEnabled: memory?.enabled === true,
|
|
2958
2969
|
memories: memoryRows.length,
|
|
2959
2970
|
memoryActive: memoryRows.filter(row => row.archived !== true).length,
|
|
2960
2971
|
memoryArchived: memoryRows.filter(row => row.archived === true).length,
|
|
@@ -3042,8 +3053,14 @@ window.__ModuleLoader__.load({
|
|
|
3042
3053
|
...[
|
|
3043
3054
|
['panel.statSessions', counts?.sessions, 'panel.statSessionsHint'],
|
|
3044
3055
|
['panel.statSnapshots', counts?.snapshots, 'panel.statSnapshotsHint'],
|
|
3045
|
-
|
|
3046
|
-
|
|
3056
|
+
// Memory figures only exist when the engine is on. Showing a zero here
|
|
3057
|
+
// would claim "you have no memories" when the truth is "memory is off".
|
|
3058
|
+
...(counts?.memoryEnabled === true
|
|
3059
|
+
? [
|
|
3060
|
+
['panel.statMemories', counts?.memories, 'panel.statMemoriesHint'],
|
|
3061
|
+
['panel.statArchived', counts?.archived, 'panel.statArchivedHint'],
|
|
3062
|
+
]
|
|
3063
|
+
: [['panel.statArchived', counts?.archived, 'panel.statArchivedHint']]),
|
|
3047
3064
|
].map(([label, value, hint]) =>
|
|
3048
3065
|
h(
|
|
3049
3066
|
'div',
|
|
@@ -3055,7 +3072,8 @@ window.__ModuleLoader__.load({
|
|
|
3055
3072
|
),
|
|
3056
3073
|
),
|
|
3057
3074
|
// The decomposition, so the total is checkable rather than a number to trust.
|
|
3058
|
-
|
|
3075
|
+
// Only when memory is on: it explains the memory total.
|
|
3076
|
+
counts?.memoryEnabled === true && counts?.stores?.length
|
|
3059
3077
|
? h(
|
|
3060
3078
|
'div',
|
|
3061
3079
|
{ className: 'dss-breakdown' },
|
|
@@ -3085,7 +3103,11 @@ window.__ModuleLoader__.load({
|
|
|
3085
3103
|
'div',
|
|
3086
3104
|
{ className: 'dss-nav' },
|
|
3087
3105
|
row('snapshots', t('panel.snapshots'), t('panel.snapshotsHint'), counts?.snapshots),
|
|
3088
|
-
|
|
3106
|
+
// The memory screen is omitted entirely when the engine is off: it would
|
|
3107
|
+
// open onto a disabled backend and report LONGTERM_DISABLED in red.
|
|
3108
|
+
counts?.memoryEnabled === true
|
|
3109
|
+
? row('memory', t('panel.memory'), t('panel.memoryHint'), counts?.memories)
|
|
3110
|
+
: null,
|
|
3089
3111
|
row('archived', t('panel.archived'), t('panel.archivedHint'), counts?.archived),
|
|
3090
3112
|
),
|
|
3091
3113
|
h(
|
package/cordis.patch.yml
CHANGED
|
@@ -34,8 +34,19 @@
|
|
|
34
34
|
stripStepTags: true
|
|
35
35
|
|
|
36
36
|
# ---------- long-term memory ----------
|
|
37
|
-
#
|
|
38
|
-
|
|
37
|
+
# OFF by default. The session half of this bundle (snapshots, rewind,
|
|
38
|
+
# delete, share, export, archive) does not need it and is always on.
|
|
39
|
+
#
|
|
40
|
+
# Why off: dsh-fish-memory registers the SAME injection point —
|
|
41
|
+
# `systemPrompt.context({ order: 900 })` — so with both plugins enabled
|
|
42
|
+
# every request carries two memory blocks. That doubles the injected
|
|
43
|
+
# tokens and hands the model two possibly-contradictory fact sets.
|
|
44
|
+
# fish-memory's implicit extraction demonstrably works; ours never
|
|
45
|
+
# produced a single suggestion across seven fix attempts and, in the
|
|
46
|
+
# last one, crashed the Host with a 500ms polling timer. The memory
|
|
47
|
+
# engine therefore stays available but dormant: set this to true only
|
|
48
|
+
# after disabling fish-memory's injection.
|
|
49
|
+
longTermMemory: false
|
|
39
50
|
# Model chain used for implicit extraction (one cheap call per turn).
|
|
40
51
|
# Tried in order until one answers; a 429 or an unreachable provider just
|
|
41
52
|
# moves on to the next entry. All of these are free tiers.
|
package/lib/api.js
CHANGED
|
@@ -10,6 +10,31 @@
|
|
|
10
10
|
/** Absolute path Connection registers. */
|
|
11
11
|
export const API_PATH = '/api/session-suite'
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Every action that needs the long-term memory engine.
|
|
15
|
+
*
|
|
16
|
+
* Kept as an explicit set rather than an `action.startsWith('longterm.')` test so that a
|
|
17
|
+
* misspelled action still fails as an unknown action (400) instead of being reported as
|
|
18
|
+
* "memory is disabled" — the second answer would hide a typo.
|
|
19
|
+
*/
|
|
20
|
+
export const LONGTERM_ACTIONS = new Set([
|
|
21
|
+
'longterm.list',
|
|
22
|
+
'longterm.remember',
|
|
23
|
+
'longterm.update',
|
|
24
|
+
'longterm.remove',
|
|
25
|
+
'longterm.recall',
|
|
26
|
+
'longterm.explain',
|
|
27
|
+
'longterm.pending',
|
|
28
|
+
'longterm.settle',
|
|
29
|
+
'longterm.maintain',
|
|
30
|
+
'longterm.status',
|
|
31
|
+
'longterm.workspaces',
|
|
32
|
+
'longterm.models',
|
|
33
|
+
'longterm.stats',
|
|
34
|
+
'longterm.settings',
|
|
35
|
+
'longterm.export',
|
|
36
|
+
])
|
|
37
|
+
|
|
13
38
|
const MAX_BODY_BYTES = 256 * 1024
|
|
14
39
|
|
|
15
40
|
/**
|
|
@@ -63,6 +88,30 @@ async function handle(service, deps, request) {
|
|
|
63
88
|
* @returns {Promise<Response>} response
|
|
64
89
|
*/
|
|
65
90
|
async function respond(service, deps, action, input) {
|
|
91
|
+
// The long-term memory engine is OPTIONAL and off by default.
|
|
92
|
+
//
|
|
93
|
+
// It is off because it collides with dsh-fish-memory: both register
|
|
94
|
+
// `systemPrompt.context({ order: 900 })`, so with both installed every request
|
|
95
|
+
// carries two memory blocks — double the tokens and two possibly-contradictory
|
|
96
|
+
// sets of facts. fish-memory's implicit extraction works and ours never did, so
|
|
97
|
+
// the memory half of this bundle is opt-in and the session half is always on.
|
|
98
|
+
//
|
|
99
|
+
// When the engine is absent every longterm.* action must answer cleanly rather
|
|
100
|
+
// than throw. `service.longTerm` is undefined in that case, and `await
|
|
101
|
+
// undefined.list(...)` would surface as an opaque 500 in the panel.
|
|
102
|
+
//
|
|
103
|
+
// The guard checks the action against a KNOWN list first: an action that does
|
|
104
|
+
// not exist is still a 400 (a typo must be loud), and only a real action is
|
|
105
|
+
// reported as disabled. Returning "disabled" for a nonexistent action would
|
|
106
|
+
// silently hide a wrong action name.
|
|
107
|
+
if (!service.longTerm && LONGTERM_ACTIONS.has(action)) {
|
|
108
|
+
return json({
|
|
109
|
+
ok: false,
|
|
110
|
+
error: 'long-term memory is disabled in this profile',
|
|
111
|
+
code: 'LONGTERM_DISABLED',
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
66
115
|
switch (action) {
|
|
67
116
|
case 'ping':
|
|
68
117
|
return json({ ok: true, data: { ready: true, version: 1 } })
|
package/lib/memory-host.js
CHANGED
|
@@ -143,12 +143,29 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
143
143
|
systemPrompt.context({
|
|
144
144
|
name: 'session-suite-memory',
|
|
145
145
|
order: 900,
|
|
146
|
+
// Memory text is PLAIN TEXT and must not be parsed as a prompt template.
|
|
147
|
+
//
|
|
148
|
+
// The Host's context/section helpers interpolate by default: a `{{name}}`
|
|
149
|
+
// anywhere in the injected text is treated as a variable, and an unknown one
|
|
150
|
+
// throws `unknown prompt variable`. Our memories come from user conversations,
|
|
151
|
+
// so they routinely contain double braces — code snippets, templates, logs.
|
|
152
|
+
// Without this flag ONE such memory would fail every turn of every session
|
|
153
|
+
// until the plugin was disabled.
|
|
154
|
+
//
|
|
155
|
+
// Learned from dsh-fish-memory, which added the same flag after hitting it
|
|
156
|
+
// (its source records the failure mode: "the user could only turn the plugin
|
|
157
|
+
// off"). We are not copying its code, only the fact that the switch exists.
|
|
158
|
+
interpolate: false,
|
|
146
159
|
text: context => {
|
|
147
160
|
const scoped = context?.scope ? recallByScope.get(context.scope) : undefined
|
|
148
161
|
// Expiry is checked here rather than only at write time: a cached
|
|
149
162
|
// value outlives its usefulness the moment the library changes
|
|
150
163
|
// underneath it, and this is the only place that can see the age.
|
|
151
164
|
if (scoped && Date.now() - scoped.at < RECALL_TTL_MS) return scoped.text
|
|
165
|
+
// No fresh entry for this scope: fall back to the last text computed for
|
|
166
|
+
// ANY scope. That is deliberate — the context provider can run for a scope
|
|
167
|
+
// the pre-step hook did not resolve (a resumed session, a step served from
|
|
168
|
+
// a cached decision), and returning '' there would inject nothing at all.
|
|
152
169
|
return lastRecall || ''
|
|
153
170
|
},
|
|
154
171
|
}),
|
|
@@ -191,56 +208,22 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
191
208
|
turns: typeof config?.contextQueryTurns === 'number' ? config.contextQueryTurns : undefined,
|
|
192
209
|
})
|
|
193
210
|
const text = await buildRecallText({ engine, cwd, question, config })
|
|
194
|
-
|
|
211
|
+
// Cache key includes the QUESTION, not just the agent.
|
|
212
|
+
//
|
|
213
|
+
// `systemPrompt.context`'s text() is called several times per request (the
|
|
214
|
+
// equivalent plugin measured 5.9 on average, up to 24 during long reasoning),
|
|
215
|
+
// so caching is necessary. But caching by agent alone means a changed question
|
|
216
|
+
// inside the same window reuses the previous answer — the injected memory then
|
|
217
|
+
// describes a question the model is no longer asking. Keying on agent+question
|
|
218
|
+
// keeps the reuse and drops the wrongness.
|
|
219
|
+
if (agent) recallByScope.set(agent, { text, at: Date.now(), question })
|
|
195
220
|
lastRecall = text
|
|
196
221
|
lastRecallFailed = false
|
|
197
222
|
|
|
198
|
-
// Implicit extraction
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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
|
-
}
|
|
223
|
+
// Implicit extraction is NOT run here. See the note at section 3: the model
|
|
224
|
+
// writes memories by calling the `remember` tool mid-conversation, which is
|
|
225
|
+
// the mechanism that actually works. A per-turn background model call was
|
|
226
|
+
// never demonstrated to produce anything and cost a Host crash to attempt.
|
|
244
227
|
} catch (error) {
|
|
245
228
|
// A failed rebuild must not leave the previous answer in place.
|
|
246
229
|
//
|
|
@@ -275,63 +258,20 @@ export function installMemory({ ctx, root, config, log }) {
|
|
|
275
258
|
// stays off until a trigger can be proven safe. Do not re-add a polling loop
|
|
276
259
|
// without (1) injecting timer, (2) wrapping the tick body in try/catch, and
|
|
277
260
|
// (3) clearing the timer on dispose.
|
|
278
|
-
// --- 3. Implicit extraction
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
ctx.on('session/event', (session, event) => {
|
|
293
|
-
// Count what arrives, not just what is acted on.
|
|
294
|
-
//
|
|
295
|
-
// The queue stayed empty for an entire session and the reason could have
|
|
296
|
-
// been any of: the listener never fired, it fired but saw no `turn/end`,
|
|
297
|
-
// `session.id` was not the shape assumed, or extraction ran and refused.
|
|
298
|
-
// Each has a different fix, so the trace records which one happened.
|
|
299
|
-
//
|
|
300
|
-
// These counters are published IMMEDIATELY, not in the finally below. The
|
|
301
|
-
// first version only published after extraction ran, so a session where the
|
|
302
|
-
// hook never fired left `lastExtraction` null and the settings page showed
|
|
303
|
-
// no counters at all — the diagnostic could not report the one case it
|
|
304
|
-
// existed for.
|
|
305
|
-
extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
|
|
306
|
-
extractionTrace.at = Date.now()
|
|
307
|
-
publishExtractionTrace(engine)
|
|
308
|
-
if (!event || event.type !== 'turn/end') return
|
|
309
|
-
extractionTrace.turnEndsSeen = (extractionTrace.turnEndsSeen ?? 0) + 1
|
|
310
|
-
publishExtractionTrace(engine)
|
|
311
|
-
// A branded id is still a string at runtime; accept any non-empty one and
|
|
312
|
-
// report the actual shape when it is not.
|
|
313
|
-
const sessionId = session?.id
|
|
314
|
-
if (typeof sessionId !== 'string' || !sessionId) {
|
|
315
|
-
extractionTrace.reason = `a turn ended but the session id was ${typeof sessionId}`
|
|
316
|
-
publishExtractionTrace(engine)
|
|
317
|
-
return
|
|
318
|
-
}
|
|
319
|
-
const cwd = resolveCwd(ctx, session)
|
|
320
|
-
void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
|
|
321
|
-
// The trace is published in a finally so a thrown error still leaves a
|
|
322
|
-
// readable cause on the settings page instead of an empty queue with
|
|
323
|
-
// no explanation.
|
|
324
|
-
.finally(() => publishExtractionTrace(engine))
|
|
325
|
-
.catch(error => {
|
|
326
|
-
extractionTrace.stage = 'threw'
|
|
327
|
-
extractionTrace.reason = errorMessage(error)
|
|
328
|
-
publishExtractionTrace(engine)
|
|
329
|
-
log('implicit memory extraction failed', error)
|
|
330
|
-
})
|
|
331
|
-
}),
|
|
332
|
-
'session-suite: implicit memory',
|
|
333
|
-
)
|
|
334
|
-
})
|
|
261
|
+
// --- 3. Implicit extraction: REMOVED (0.8.0) -----------------------------
|
|
262
|
+
//
|
|
263
|
+
// Seven fix attempts targeted a "one cheap model call per turn" pipeline driven by
|
|
264
|
+
// a Cordis event. It never fired once. Reading dsh-fish-memory's source settled why:
|
|
265
|
+
// that plugin has no such pipeline at all. Its memories are written by the MODEL,
|
|
266
|
+
// calling a `remember` tool mid-conversation; `agent/turn-stopping` there only marks
|
|
267
|
+
// whether a recalled memory was actually used ("assembled" → "consumed").
|
|
268
|
+
//
|
|
269
|
+
// So the direction was wrong, not the wiring. A background timer that calls a model
|
|
270
|
+
// on every turn also has a real cost and a real failure surface — the last attempt
|
|
271
|
+
// crashed the Host — and the benefit was never demonstrated. It is removed rather
|
|
272
|
+
// than left dormant, so no future reader mistakes it for working code.
|
|
273
|
+
//
|
|
274
|
+
// The memory TOOL remains (below), which is the mechanism that actually works.
|
|
335
275
|
|
|
336
276
|
// --- 3b. Automatic maintenance -------------------------------------------
|
|
337
277
|
// Scoring, decay, clustering, and cold archiving are otherwise only reachable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heixui/dsh-session-suite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|