dsh-hypatia-auto-memory 0.2.0 → 0.4.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/src/cascade.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { createHash } from 'node:crypto'
23
23
 
24
24
  import { PLUGIN_NAME } from './consolidator.js'
25
+ import { NULL_MODEL_LOG } from './model-log.js'
25
26
 
26
27
  /** Tag marking the tier an entry belongs to. */
27
28
  export function levelTag(level) {
@@ -80,9 +81,10 @@ function archiveInstruction(level, count) {
80
81
  * selectRoute: (routes: any) => any,
81
82
  * getConfig: () => any,
82
83
  * status: import('./status.js').StatusLog,
84
+ * modelLog?: ReturnType<import('./model-log.js').createModelLog>,
83
85
  * }} deps
84
86
  */
85
- export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
87
+ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelLog = NULL_MODEL_LOG }) {
86
88
  /**
87
89
  * Ask the model to archive one batch.
88
90
  * @returns {Promise<{title: string, summary: string} | undefined>}
@@ -93,8 +95,11 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
93
95
  return `### ${i + 1}. ${row.name}\n${data}`
94
96
  }).join('\n\n')
95
97
 
98
+ const attempt = modelLog.begin('memory-cascade', route)
96
99
  const controller = new AbortController()
97
100
  const timer = setTimeout(() => controller.abort(), timeoutMs)
101
+ let outcome = 'incomplete'
102
+ let detail = ''
98
103
  try {
99
104
  const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
100
105
  const assembler = new BlockAssembler()
@@ -112,18 +117,38 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
112
117
  })) {
113
118
  assembler.push(chunk)
114
119
  }
115
- if (assembler.finish.kind !== 'stop') return undefined
120
+ if (assembler.finish.kind !== 'stop') {
121
+ detail = String(assembler.finish.kind)
122
+ return undefined
123
+ }
116
124
  const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
117
125
  const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
118
- const parsed = JSON.parse(fence ? fence[1].trim() : text)
126
+ // Its own catch: a parse failure is an unusable reply, not a failed call,
127
+ // and Node's parse error quotes the reply's opening characters.
128
+ let parsed
129
+ try {
130
+ parsed = JSON.parse(fence ? fence[1].trim() : text)
131
+ } catch {
132
+ detail = 'unparseable reply'
133
+ return undefined
134
+ }
119
135
  const summary = typeof parsed?.summary === 'string' ? parsed.summary.trim() : ''
120
- if (summary === '') return undefined
136
+ if (summary === '') {
137
+ detail = 'no summary'
138
+ return undefined
139
+ }
121
140
  const title = typeof parsed?.title === 'string' && parsed.title.trim() !== ''
122
141
  ? parsed.title.trim()
123
142
  : `Archive tier ${level}`
143
+ outcome = 'ok'
124
144
  return { title, summary }
145
+ } catch (error) {
146
+ outcome = 'error'
147
+ detail = error instanceof Error ? error.message : String(error)
148
+ throw error
125
149
  } finally {
126
150
  clearTimeout(timer)
151
+ void attempt.finish(outcome, detail)
127
152
  }
128
153
  }
129
154
 
package/src/collector.js CHANGED
@@ -59,6 +59,26 @@ const RECORDED_TYPES = new Set([
59
59
  'tool/result',
60
60
  ])
61
61
 
62
+ /**
63
+ * Whether an assistant/message event carries anything worth logging on its own.
64
+ *
65
+ * A turn that only issued tool calls (no text, no reasoning, no interruption
66
+ * marker) adds nothing to the conversation record — the tool outcomes already
67
+ * live in the session log if needed. Skipping these avoids msg-* entries whose
68
+ * entire body would be `(tool calls only)` or an empty placeholder.
69
+ *
70
+ * @param {any} event
71
+ * @returns {boolean}
72
+ */
73
+ function hasAssistantContent(event) {
74
+ const blocks = event.data?.message?.content ?? []
75
+ for (const block of blocks) {
76
+ if (block.type === 'text' && String(block.text ?? '').trim() !== '') return true
77
+ if (block.type === 'image') return true
78
+ }
79
+ return false
80
+ }
81
+
62
82
  /**
63
83
  * Whether this event becomes one `msg-*` entry.
64
84
  *
@@ -66,11 +86,17 @@ const RECORDED_TYPES = new Set([
66
86
  * output) are excluded: they are not things the human said, and re-logging them
67
87
  * would let memory amplify into itself.
68
88
  *
89
+ * Assistant turns whose only content is tool calls are also excluded: they have
90
+ * no substantive message to remember.
91
+ *
69
92
  * @param {any} event
70
93
  * @returns {boolean}
71
94
  */
72
95
  export function isLoggableMessage(event) {
73
- if (event?.type === 'assistant/message') return true
96
+ if (event?.type === 'assistant/message') {
97
+ const interrupted = event.data?.interrupted === true
98
+ return interrupted || hasAssistantContent(event)
99
+ }
74
100
  return event?.type === 'user/message' && event.data?.source?.kind === 'user'
75
101
  }
76
102
 
package/src/config.js CHANGED
@@ -35,7 +35,11 @@ export const DEFAULTS = {
35
35
  // User messages were never capped, so a pasted log went into hypatia whole.
36
36
  // Generous, because what the user typed is the part most worth keeping.
37
37
  maxUserChars: 32000,
38
- toolLedger: true,
38
+ // Tool-call ledgers (name / count / success / duration) are intentionally
39
+ // off by default: they still reveal which tools were used and when, and the
40
+ // user has asked to avoid writing any tool-call-derived data to hypatia.
41
+ // Set this to true to restore the previous behavior.
42
+ toolLedger: false,
39
43
  },
40
44
  consolidation: {
41
45
  enabled: true,
@@ -14,14 +14,37 @@
14
14
  * @module dsh-hypatia-auto-memory/consolidator
15
15
  */
16
16
 
17
- import { absolutizeDates, blocksToText, eventDate, flattenToolResult, redactSecrets } from './content-policy.js'
17
+ import { absolutizeDates, blocksToText, eventDate, redactSecrets } from './content-policy.js'
18
18
  import { countLoggableMessages, isCompactionReplacement, isLoggableMessage } from './collector.js'
19
19
  import { messageName, summaryName } from './writer.js'
20
20
  import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
21
21
  import { TaskDeferredError } from './queue.js'
22
+ import { NULL_MODEL_LOG } from './model-log.js'
22
23
 
23
24
  export const PLUGIN_NAME = 'dsh-hypatia-auto-memory'
24
25
 
26
+ /**
27
+ * Output budget for one adjudication.
28
+ *
29
+ * It is not 200 — the value it carried for as long as the call existed — because
30
+ * a thinking-enabled route spends the SAME cap on its reasoning and its answer.
31
+ * `deepseek-*` resolves an omitted `reasoningEffort` to `high` (the
32
+ * `llm-deepseek` profile default) and puts roughly half its output tokens into
33
+ * reasoning, so a 200-token adjudication ended with `max-tokens` before the
34
+ * verdict was ever emitted: every other work unit was silently stored with no
35
+ * relationship at all. `ADJUDICATION_REASONING_EFFORT` below usually removes
36
+ * the need for the headroom, but a route that cannot honour `off` — or a
37
+ * provider that ignores the control — still has to fit thinking plus a verdict.
38
+ */
39
+ export const ADJUDICATION_MAX_TOKENS = 1024
40
+
41
+ /**
42
+ * What the adjudication call asks for on a route that advertises it. A verdict
43
+ * is a six-way classification of two short texts: thinking there spends the
44
+ * output budget and the latency for nothing.
45
+ */
46
+ export const ADJUDICATION_REASONING_EFFORT = 'off'
47
+
25
48
  /** Distinguishes permanent failures (no retry value) from transient ones. */
26
49
  export class PermanentConsolidationError extends Error {
27
50
  constructor(message) {
@@ -178,8 +201,11 @@ export function buildTranscript(events, config, now = new Date(), { before } = {
178
201
  const callId = event.data?.message?.source?.callId
179
202
  const name = toolNames.get(callId) ?? 'tool'
180
203
  const ok = event.data?.error === undefined
181
- const first = clean(flattenToolResult(event), event).split('\n')[0] ?? ''
182
- entries.push({ seq: event.seq, line: `T: ${name} ${ok ? '✅' : '❌'} ${first}`.trimEnd() })
204
+ // Tool outputs are intentionally kept out of the consolidation transcript.
205
+ // The model only sees that a tool was called and whether it succeeded;
206
+ // the actual result text never reaches the remote model or the summaries
207
+ // and work units derived from it.
208
+ entries.push({ seq: event.seq, line: `T: ${name} ${ok ? '✅' : '❌'}` })
183
209
  }
184
210
  }
185
211
 
@@ -260,13 +286,46 @@ export function parseConsolidationOutput(raw, maxWorkUnits) {
260
286
  * writer: ReturnType<import('./writer.js').createWriter>,
261
287
  * getConfig: () => any,
262
288
  * status: import('./status.js').StatusLog,
289
+ * modelLog?: ReturnType<import('./model-log.js').createModelLog>,
263
290
  * projectFor: (session: any) => Promise<string>,
264
291
  * }} deps
265
292
  */
266
- export function createConsolidator({ queue, progress, sessions, llm, cli, writer, getConfig, status, projectFor }) {
293
+ export function createConsolidator({ queue, progress, sessions, llm, cli, writer, getConfig, status, modelLog = NULL_MODEL_LOG, projectFor }) {
267
294
  let warnedNoRoute = false
268
295
  const selectRoute = createConsolidationRouteSelector()
269
296
 
297
+ /**
298
+ * Whether a route may be asked for {@link ADJUDICATION_REASONING_EFFORT},
299
+ * memoized per route for this instance.
300
+ *
301
+ * The core rejects a request whose effort the model does not advertise
302
+ * (`UNSUPPORTED_REASONING_EFFORT`, `llm`'s `resolveCallWithInfo`), so the
303
+ * control only follows a positive capability answer. A route that cannot be
304
+ * inspected — an adapter that throws, or the `llm` double a unit test hands
305
+ * in — simply gets no effort, which is what every call did before this.
306
+ * Capability does not change while an instance lives; a profile reload
307
+ * rebuilds the instance and the cache with it.
308
+ *
309
+ * @param {{provider: string, model: string}} route
310
+ * @param {AbortSignal} signal - the same signal the call carries, so a
311
+ * lookup cannot outlive the timeout it is part of.
312
+ */
313
+ const thinkingOff = new Map()
314
+ async function canDisableThinking(route, signal) {
315
+ const key = `${route.provider}\0${route.model}`
316
+ if (thinkingOff.has(key)) return thinkingOff.get(key)
317
+ let supported = false
318
+ try {
319
+ const info = await llm.resolveModelInfo?.(route.provider, route.model, signal)
320
+ supported = Array.isArray(info?.reasoning?.efforts)
321
+ && info.reasoning.efforts.some((effort) => effort?.id === ADJUDICATION_REASONING_EFFORT)
322
+ } catch (error) {
323
+ status.warn(`reasoning capability lookup failed for ${key.replace('\0', '/')}: ${String(error)}`)
324
+ }
325
+ thinkingOff.set(key, supported)
326
+ return supported
327
+ }
328
+
270
329
  /**
271
330
  * Turn-end trigger check. The turn threshold is a minimum interval between
272
331
  * accepted consolidation tasks, not a polling cadence: once the interval
@@ -385,9 +444,14 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
385
444
  listed,
386
445
  ].join('\n')
387
446
 
447
+ const attempt = modelLog.begin('memory-adjudication', route)
388
448
  const controller = new AbortController()
389
449
  const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
450
+ let outcome = 'incomplete'
451
+ let detail = ''
390
452
  try {
453
+ // Asked for only when the route advertises it; see `canDisableThinking`.
454
+ const skipThinking = await canDisableThinking(route, controller.signal)
391
455
  const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
392
456
  const assembler = new BlockAssembler()
393
457
  for await (const chunk of llm.stream({
@@ -398,20 +462,41 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
398
462
  content: [{ type: 'text', text: instruction }],
399
463
  source: { kind: 'plugin', plugin: PLUGIN_NAME },
400
464
  })],
401
- maxTokens: 200,
465
+ maxTokens: ADJUDICATION_MAX_TOKENS,
466
+ ...skipThinking ? { reasoningEffort: ADJUDICATION_REASONING_EFFORT } : {},
402
467
  purpose: 'memory-adjudication',
403
468
  signal: controller.signal,
404
469
  })) {
405
470
  assembler.push(chunk)
406
471
  }
407
- if (assembler.finish.kind !== 'stop') return undefined
472
+ if (assembler.finish.kind !== 'stop') {
473
+ detail = String(assembler.finish.kind)
474
+ return undefined
475
+ }
408
476
  const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
409
477
  const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
410
- const parsed = JSON.parse(fence ? fence[1].trim() : text)
411
- if (typeof parsed?.verdict !== 'string') return undefined
478
+ // Its own catch: a parse failure is an unusable reply, not a failed call,
479
+ // and Node's parse error quotes the reply's opening characters.
480
+ let parsed
481
+ try {
482
+ parsed = JSON.parse(fence ? fence[1].trim() : text)
483
+ } catch {
484
+ detail = 'unparseable reply'
485
+ return undefined
486
+ }
487
+ if (typeof parsed?.verdict !== 'string') {
488
+ detail = 'no verdict'
489
+ return undefined
490
+ }
491
+ outcome = 'ok'
412
492
  return { verdict: parsed.verdict, target: typeof parsed.target === 'string' ? parsed.target : '' }
493
+ } catch (error) {
494
+ outcome = 'error'
495
+ detail = error instanceof Error ? error.message : String(error)
496
+ throw error
413
497
  } finally {
414
498
  clearTimeout(timer)
499
+ void attempt.finish(outcome, detail)
415
500
  }
416
501
  }
417
502
 
@@ -488,44 +573,84 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
488
573
  // watermark and the next trigger resumes from exactly there.
489
574
  const coveredToSeq = complete ? task.toSeq : lastSeq + 1
490
575
 
576
+ const attempt = modelLog.begin('memory-consolidation', route)
491
577
  const controller = new AbortController()
492
578
  const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
493
- let assembled
579
+ let outcome = 'incomplete'
580
+ let detail = ''
581
+ let parsedOutput
494
582
  try {
495
- const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
496
- const assembler = new BlockAssembler()
497
- const request = createUserMessage({
498
- content: [{ type: 'text', text: `${consolidationInstruction(consolidation.maxWorkUnitsPerRun)}\n\n<transcript>\n${transcript}\n</transcript>` }],
499
- source: { kind: 'plugin', plugin: PLUGIN_NAME },
500
- })
501
- for await (const chunk of llm.stream({
502
- provider: route.provider,
503
- model: route.model,
504
- system: 'You produce strict JSON only. You never add prose around it.',
505
- messages: [request],
506
- maxTokens: consolidation.maxOutputTokens,
507
- purpose: 'memory-consolidation',
508
- signal: controller.signal,
509
- })) {
510
- assembler.push(chunk)
583
+ let assembled
584
+ try {
585
+ const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
586
+ const assembler = new BlockAssembler()
587
+ const request = createUserMessage({
588
+ content: [{ type: 'text', text: `${consolidationInstruction(consolidation.maxWorkUnitsPerRun)}\n\n<transcript>\n${transcript}\n</transcript>` }],
589
+ source: { kind: 'plugin', plugin: PLUGIN_NAME },
590
+ })
591
+ for await (const chunk of llm.stream({
592
+ provider: route.provider,
593
+ model: route.model,
594
+ system: 'You produce strict JSON only. You never add prose around it.',
595
+ messages: [request],
596
+ maxTokens: consolidation.maxOutputTokens,
597
+ purpose: 'memory-consolidation',
598
+ signal: controller.signal,
599
+ })) {
600
+ assembler.push(chunk)
601
+ }
602
+ assembled = assembler
603
+ } finally {
604
+ clearTimeout(timer)
511
605
  }
512
- assembled = assembler
513
- } finally {
514
- clearTimeout(timer)
515
- }
516
- const finish = assembled.finish
517
- if (finish.kind !== 'stop') {
518
- if (finish.kind === 'max-tokens') {
519
- throw new PermanentConsolidationError('consolidation hit the output token cap (truncated JSON)')
606
+ // The attempt stays open through parsing: a stream that ended with `stop`
607
+ // but yields no usable summary is `incomplete`, not `ok` — the cursor was
608
+ // consumed, nothing was stored, and a table that said otherwise would
609
+ // send a reader looking for a summary that does not exist. The throws are
610
+ // re-issued unchanged, so recording never changes which error the queue
611
+ // sees.
612
+ const finish = assembled.finish
613
+ if (finish.kind !== 'stop') {
614
+ // The thrown message is computed exactly as it was before this attempt
615
+ // was recorded: `error` AND `aborted` carry the provider's reason, and
616
+ // losing it for `aborted` would replace a timeout with a bare
617
+ // 'aborted' in the task row. `detail` uses the same message; `finish`
618
+ // caps it.
619
+ const message = finish.kind === 'error' || finish.kind === 'aborted'
620
+ ? finish.failure?.message ?? finish.kind
621
+ : finish.kind
622
+ if (finish.kind === 'error') outcome = 'error'
623
+ detail = String(message)
624
+ throw finish.kind === 'max-tokens'
625
+ ? new PermanentConsolidationError('consolidation hit the output token cap (truncated JSON)')
626
+ : new Error(`consolidation model call failed: ${message}`)
627
+ }
628
+ const text = assembled.blocks()
629
+ .filter((block) => block.type === 'text')
630
+ .map((block) => block.text)
631
+ .join('\n')
632
+ try {
633
+ parsedOutput = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
634
+ } catch (parseError) {
635
+ // A parse failure is an unusable reply, not a failed call, and its
636
+ // message quotes the reply — so it never becomes `detail`. See the
637
+ // `detail` contract in model-log.js.
638
+ detail = 'unusable output'
639
+ throw parseError
520
640
  }
521
- const message = finish.kind === 'error' || finish.kind === 'aborted' ? finish.failure?.message ?? finish.kind : finish.kind
522
- throw new Error(`consolidation model call failed: ${message}`)
641
+ outcome = 'ok'
642
+ } catch (error) {
643
+ // Only a throw that arrived with nothing classified is a failed call
644
+ // rather than an unusable reply.
645
+ if (outcome === 'incomplete' && detail === '') {
646
+ outcome = 'error'
647
+ detail = error instanceof Error ? error.message : String(error)
648
+ }
649
+ throw error
650
+ } finally {
651
+ void attempt.finish(outcome, detail)
523
652
  }
524
- const text = assembled.blocks()
525
- .filter((block) => block.type === 'text')
526
- .map((block) => block.text)
527
- .join('\n')
528
- const { summary, workUnits } = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
653
+ const { summary, workUnits } = parsedOutput
529
654
 
530
655
  const span = summaryName(task.sessionId, fromSeq, coveredToSeq)
531
656
  // The entries the log executor actually wrote for this span. Both sides
@@ -277,7 +277,11 @@ export function blocksToText(blocks) {
277
277
  for (const block of blocks ?? []) {
278
278
  if (block.type === 'text') parts.push(block.text)
279
279
  else if (block.type === 'image') parts.push('[image]')
280
- else if (block.type === 'thinking') parts.push(`[thinking: ${String(block.text ?? '').slice(0, 200)}]`)
280
+ // Thinking / reasoning blocks are internal monologue, not something the
281
+ // assistant said to the user. Including them created `[reasoning]`
282
+ // placeholder entries with no substantive content; skip them so only the
283
+ // final text / image output reaches the log.
284
+ else if (block.type === 'thinking' || block.type === 'reasoning') continue
281
285
  else if (block.type === 'tool-result') parts.push(blocksToText(block.content ?? []))
282
286
  // A tool-call block is the model asking for a tool, not something it said.
283
287
  // Rendered as `[tool-call]` it filled the content of half the stored
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ import { TaskDeferredError, createQueue } from './queue.js'
37
37
  import { countLoggableMessages, createCollector, formatSpan } from './collector.js'
38
38
  import { createCascade } from './cascade.js'
39
39
  import { createConsolidator, PLUGIN_NAME } from './consolidator.js'
40
+ import { openModelLog } from './model-log.js'
40
41
  import { createRecall } from './recall.js'
41
42
  import { createPersistedSessions } from './persisted-session.js'
42
43
  import { createAutoApprove } from './auto-approve.js'
@@ -93,6 +94,20 @@ function applyCollect(ctx, cordisConfig) {
93
94
  progress: shelfTable(openedState.progress, shelf),
94
95
  tasks: shelfTable(openedState.tasks, shelf),
95
96
  }
97
+ // Which model a background attempt ran on is otherwise unknowable from
98
+ // outside the process: the cursor that picks it is process-local, the host
99
+ // logger scrolls away, and the usage ledger cannot see plugin calls. Kept in
100
+ // its own domain so a diagnostics row can never reject the open of the
101
+ // watermarks (see model-log.js). Not shelf-scoped: the route list is config,
102
+ // and the question is about this process, not this shelf.
103
+ const openedModelLog = await openModelLog(ctx, { status })
104
+ if (disposed) {
105
+ // Same race as the state domain above: if the fiber went away while this
106
+ // was opening, its disposer may never run, so close the handle here.
107
+ await openedModelLog.domain?.close().catch(() => {})
108
+ return
109
+ }
110
+ const modelLog = openedModelLog.modelLog
96
111
  let announcedShelf = shelf
97
112
  configHandle.onChange((value) => {
98
113
  if (value.shelf === announcedShelf) return
@@ -364,6 +379,7 @@ function applyCollect(ctx, cordisConfig) {
364
379
  writer,
365
380
  getConfig: configHandle.get,
366
381
  status,
382
+ modelLog,
367
383
  projectFor: collector.projectFor,
368
384
  })
369
385
  queue.registerExecutor('consolidate', consolidator.execute)
@@ -376,6 +392,7 @@ function applyCollect(ctx, cordisConfig) {
376
392
  selectRoute: consolidator.selectRoute,
377
393
  getConfig: configHandle.get,
378
394
  status,
395
+ modelLog,
379
396
  })
380
397
  queue.registerExecutor('cascade', cascade.execute)
381
398
  shared.consolidator = consolidator