@heixui/dsh-session-suite 0.7.0 → 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.
Files changed (2) hide show
  1. package/lib/memory-host.js +44 -104
  2. package/package.json +1 -1
@@ -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
- if (agent) recallByScope.set(agent, { text, at: Date.now() })
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, 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
- }
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 after a turn --------------------------------
279
- safe('implicit extraction', () => {
280
- // A startup marker, recorded before the listener is even registered.
281
- //
282
- // The whole question "why is the queue empty" has two halves that look identical
283
- // on screen: the hook is not registered at all, or it is registered and never
284
- // called. This records the first half so the trace can distinguish them — the
285
- // block used to render only after extraction ran, so a dead hook produced no
286
- // trace and therefore no diagnostics.
287
- extractionTrace.registered = true
288
- extractionTrace.registeredAt = Date.now()
289
- publishExtractionTrace(engine)
290
- ctx.effect(
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.7.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",