dsh-hypatia-auto-memory 2.0.0 → 2.1.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/index.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * ├─ recall (inject agents) — rules/taboos preload at session start
12
12
  * ├─ housekeeping (inject sessionPersistence) — prune progress of vanished sessions
13
13
  * ├─ memory-api (inject webServer) — the Memory tab + shelf listing routes
14
+ * ├─ on-demand (inject commands) — /hypatia-summarize, no model turn
15
+ * ├─ on-demand (inject tools) — the hypatia_summarize tool
14
16
  * ├─ auto-approve (inject approval, tools) — the agent's own bash `hypatia` calls
15
17
  * └─ skills (inject skills) — hypatia-memory, hypatia, hypatia-dream
16
18
  *
@@ -38,6 +40,14 @@ import { TaskDeferredError, createQueue } from './queue.js'
38
40
  import { countLoggableMessages, createCollector, formatSpan } from './collector.js'
39
41
  import { createCascade } from './cascade.js'
40
42
  import { createConsolidator, PLUGIN_NAME } from './consolidator.js'
43
+ import {
44
+ SUMMARIZE_COMMAND,
45
+ SUMMARIZE_TOOL,
46
+ disabledOutcome,
47
+ registerSummarizeCommand,
48
+ registerSummarizeTool,
49
+ unavailableOutcome,
50
+ } from './on-demand.js'
41
51
  import { openModelLog } from './model-log.js'
42
52
  import { createReasoningResolver } from './reasoning.js'
43
53
  import { buildStatus } from './memory-status.js'
@@ -126,17 +136,18 @@ function applyCollect(ctx, configHandle, status) {
126
136
  })
127
137
 
128
138
  /**
129
- * The Memory tab's read side and the settings card's shelf listing.
139
+ * The Memory tab's read side and the settings card's shelf listing, plus the
140
+ * one write route an explicit "summarise now" rides.
130
141
  * Optional: it needs `webServer`, so a headless or TUI composition simply has
131
142
  * no Memory tab and nothing else changes. The route authenticates itself —
132
143
  * see memory-api.js.
133
144
  */
134
- function mountMemoryApi({ cli, read, shared }) {
145
+ function mountMemoryApi({ cli, read, shared, summarize }) {
135
146
  ctx.plugin({
136
147
  name: `${name}/memory-api`,
137
148
  inject: ['webServer'],
138
149
  apply: (c) => {
139
- const api = createMemoryApi({ cli, read, status })
150
+ const api = createMemoryApi({ cli, read, status, summarize })
140
151
  c.effect(() => {
141
152
  const unregister = c.webServer.register(api.route)
142
153
  shared.memoryApi = api
@@ -166,6 +177,9 @@ function applyCollect(ctx, configHandle, status) {
166
177
  }),
167
178
  read: (sessionId) => buildStatus({ progressEntries: [], taskEntries: [], sessionId }),
168
179
  shared: {},
180
+ // No state is being written while the plugin is off, so the route
181
+ // answers with the truth rather than an empty span.
182
+ summarize: disabledOutcome,
169
183
  })
170
184
  return
171
185
  }
@@ -263,6 +277,20 @@ function applyCollect(ctx, configHandle, status) {
263
277
  // needs `webServer`, and a headless composition has none.
264
278
  const shared = { consolidator: undefined, memoryApi: undefined }
265
279
 
280
+ /**
281
+ * The one function all three explicit faces call: the slash command, the
282
+ * tool, and the Memory tab's button. Late-bound like the rest of `shared`,
283
+ * because the consolidator only exists once `llm` arrives; before that (or
284
+ * in a composition without `llm` at all) the request is answered honestly
285
+ * as unavailable rather than silently dropped.
286
+ *
287
+ * @param {string} sessionId
288
+ * @returns {Promise<{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number}>}
289
+ */
290
+ const summarizeNow = (sessionId) => shared.consolidator === undefined
291
+ ? Promise.resolve(unavailableOutcome(sessionId))
292
+ : shared.consolidator.consolidateNow(sessionId)
293
+
266
294
  // Mounted before reconcile and backfill, which can take long or fail: the
267
295
  // settings card's shelf listing rides this route family, and a broken
268
296
  // shelf is exactly when the user needs it.
@@ -274,6 +302,7 @@ function applyCollect(ctx, configHandle, status) {
274
302
  sessionId,
275
303
  }),
276
304
  shared,
305
+ summarize: summarizeNow,
277
306
  })
278
307
 
279
308
  const writer = createWriter(cli, {
@@ -526,6 +555,30 @@ function applyCollect(ctx, configHandle, status) {
526
555
  },
527
556
  })
528
557
 
558
+ // The two Host-side faces of an explicit "summarise this session now".
559
+ //
560
+ // Separate fibers on purpose: `commands` is absent from a headless or TUI
561
+ // composition while `tools` is present, and one fiber injecting both would
562
+ // take the tool down with the missing command registry. Neither is mounted
563
+ // under `auto-approve`, which exists only when `autoApprove !== false` and
564
+ // has nothing to do with this — the tool is a first-class action, not a
565
+ // shell call to approve.
566
+ //
567
+ // Both read `summarizeNow` at invocation time, so registering them before
568
+ // the consolidate fiber settles is fine; a request that arrives before a
569
+ // route exists is answered as unavailable instead of silently dropped.
570
+ ctx.inject(['commands'], (commandCtx) => {
571
+ try {
572
+ registerSummarizeCommand(commandCtx, { run: summarizeNow, status })
573
+ } catch (error) {
574
+ status.warn(`/${SUMMARIZE_COMMAND} could not be registered: ${String(error)}`)
575
+ }
576
+ })
577
+ ctx.inject(['tools'], (toolCtx) => {
578
+ void registerSummarizeTool(toolCtx, { run: summarizeNow, status })
579
+ .catch((error) => status.warn(`the ${SUMMARIZE_TOOL} tool could not be registered: ${String(error)}`))
580
+ })
581
+
529
582
  ctx.plugin({
530
583
  name: `${name}/recall`,
531
584
  inject: ['agents'],
package/src/memory-api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The Memory tab's read-only HTTP route family.
2
+ * The Memory tab's HTTP route family: the read side the tab is built on, plus
3
+ * one write route for an explicit "summarise this session now".
3
4
  *
4
5
  * Why a route and not a settings namespace. The tab needs two different things
5
6
  * from the Host: a small per-session status that changes whenever a task
@@ -36,6 +37,20 @@
36
37
  * claim; the loopback socket is what actually bounds reachability, and the other
37
38
  * two narrow it to this page.
38
39
  *
40
+ * ### Why a WRITE route is allowed to live here
41
+ *
42
+ * `POST /consolidate` is the family's first route that causes work, so it is
43
+ * worth stating why the three checks above are still enough. It writes nothing
44
+ * itself: it queues the same consolidation task the automatic trigger queues,
45
+ * through the same function, on the same durable queue. It cannot choose what
46
+ * is written (the range comes from the Host's own watermarks), cannot name an
47
+ * entry, and cannot reach beyond the shelf the profile selected. What a hostile
48
+ * page could do, if it got past the checks, is cause summarisation the user did
49
+ * not ask for — so the checks are kept exactly as they were, and the write
50
+ * route is only reachable from a same-origin page on this loopback GUI. Every
51
+ * route in the family, read or write, calls `isTrustedRequest` first; the write
52
+ * route adds a method gate (`POST` only) on top.
53
+ *
39
54
  * ## What it will not return
40
55
  *
41
56
  * Summaries and work units are distilled knowledge, so their bodies are served —
@@ -383,13 +398,21 @@ export function fitBudget(entries, spent = 0) {
383
398
  * cli: {query: (jse: string) => Promise<any>, listShelves: () => Promise<{name: string, path: string, connected: boolean}[]>},
384
399
  * read: (sessionId: string) => {sessions: object[], failed: object[]},
385
400
  * status: import('./status.js').StatusLog,
401
+ * summarize?: (sessionId: string) => Promise<{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number}>,
386
402
  * ttlMs?: number,
387
403
  * shelvesTtlMs?: number,
388
404
  * now?: () => number,
389
- * }} deps - `read` folds the state tables for one session (see memory-status.js).
405
+ * }} deps - `read` folds the state tables for one session (see memory-status.js);
406
+ * `summarize` is the same host action the slash command and the tool call
407
+ * (see on-demand.js). Absent, the write route answers 503: a composition
408
+ * without a consolidator cannot consolidate, and saying so is better than
409
+ * pretending to queue.
390
410
  * @returns {{route: {kind: string, path: string, handler: Function}, invalidate: (sessionId?: string) => void, fetchContent: (sessionId: string, watermark?: number) => Promise<object>, fetchShelves: () => Promise<object>}}
391
411
  */
392
- export function createMemoryApi({ cli, read, status, ttlMs = CONTENT_TTL_MS, shelvesTtlMs = SHELVES_TTL_MS, now = Date.now }) {
412
+ export function createMemoryApi({
413
+ cli, read, status, summarize,
414
+ ttlMs = CONTENT_TTL_MS, shelvesTtlMs = SHELVES_TTL_MS, now = Date.now,
415
+ }) {
393
416
  /** @type {Map<string, {at: number, watermark: number | undefined, value: object}>} */
394
417
  const cache = new Map()
395
418
  /**
@@ -570,15 +593,49 @@ export function createMemoryApi({ cli, read, status, ttlMs = CONTENT_TTL_MS, she
570
593
  writeJson(res, 200, payload)
571
594
  }
572
595
 
596
+ /**
597
+ * Queue an explicit consolidation for one session.
598
+ *
599
+ * `POST` only, because a write must never be triggerable by a link or an
600
+ * `<img>`: a browser sends neither for a POST, and the same-origin check
601
+ * refuses a cross-site one that a form could send. The body is not read —
602
+ * the request carries its whole meaning in the session parameter — so there
603
+ * is nothing here for a malformed payload to corrupt.
604
+ */
605
+ async function handleConsolidate(req, res, query) {
606
+ const sessionId = query.get('session') ?? ''
607
+ if (sessionId === '') {
608
+ writeJson(res, 400, { error: 'the session query parameter is required' })
609
+ return
610
+ }
611
+ // Same shape guard as the read route: a session id is hex, dashes and the
612
+ // `session-` prefix, so this refuses only values that could not be one.
613
+ if (/[%\\]/.test(sessionId)) {
614
+ writeJson(res, 400, { error: 'malformed session id' })
615
+ return
616
+ }
617
+ if (summarize === undefined) {
618
+ writeJson(res, 503, { error: 'consolidation is not available in this composition' })
619
+ return
620
+ }
621
+ try {
622
+ // Always 200 for an outcome the action itself produced — including
623
+ // "nothing to do" and "already queued". They are answers, not failures,
624
+ // and the tab renders the reason; a 4xx would turn the truthful answer
625
+ // into an error banner.
626
+ writeJson(res, 200, await summarize(sessionId))
627
+ } catch (error) {
628
+ const message = error instanceof Error ? error.message : String(error)
629
+ status.warn(`consolidation request failed for ${sessionId}: ${message}`)
630
+ writeJson(res, 500, { error: message })
631
+ }
632
+ }
633
+
573
634
  async function handler(req, res) {
574
635
  if (!isTrustedRequest(req)) {
575
636
  writeJson(res, 403, { error: 'forbidden' })
576
637
  return
577
638
  }
578
- if ((req.method ?? 'GET') !== 'GET') {
579
- writeJson(res, 405, { error: 'method not allowed' })
580
- return
581
- }
582
639
  let url
583
640
  try {
584
641
  // The base is a placeholder: only the pathname and search are read.
@@ -587,15 +644,32 @@ export function createMemoryApi({ cli, read, status, ttlMs = CONTENT_TTL_MS, she
587
644
  writeJson(res, 400, { error: 'malformed request url' })
588
645
  return
589
646
  }
647
+ const method = req.method ?? 'GET'
590
648
  const path = url.pathname.slice(MEMORY_API_PREFIX.length)
591
649
  if (path === '/shelves') {
650
+ if (method !== 'GET') {
651
+ writeJson(res, 405, { error: 'method not allowed' })
652
+ return
653
+ }
592
654
  writeJson(res, 200, await fetchShelves())
593
655
  return
594
656
  }
657
+ if (path === '/consolidate') {
658
+ if (method !== 'POST') {
659
+ writeJson(res, 405, { error: 'method not allowed' })
660
+ return
661
+ }
662
+ await handleConsolidate(req, res, url.searchParams)
663
+ return
664
+ }
595
665
  if (path !== '/session') {
596
666
  writeJson(res, 404, { error: 'not found' })
597
667
  return
598
668
  }
669
+ if (method !== 'GET') {
670
+ writeJson(res, 405, { error: 'method not allowed' })
671
+ return
672
+ }
599
673
  await handleSession(req, res, url.searchParams)
600
674
  }
601
675
 
@@ -0,0 +1,249 @@
1
+ /**
2
+ * On-demand consolidation: the two surfaces a user drives directly.
3
+ *
4
+ * The plugin writes memory in the background, on thresholds. That is the right
5
+ * default and the wrong answer when a reader wants the summary NOW — measured
6
+ * live, a session sat 151 logged events behind while the token floor had only
7
+ * accumulated 645, because the floor counts user/assistant prose and the
8
+ * trigger was freshly restarted. Waiting for the gate is not an option a user
9
+ * should have to accept, so there is an explicit request.
10
+ *
11
+ * There are three faces, and this module owns the two that live in the Host:
12
+ *
13
+ * - the slash command `hypatia-summarize`, executed by the UI **without a model
14
+ * turn** (`CommandDefinition.handler`'s contract), and
15
+ * - the tool `hypatia_summarize`, which is the skill's execution path: the
16
+ * agent asks for consolidation instead of writing `sum-*` entries itself.
17
+ *
18
+ * The third face — the Memory tab's button — rides the same host action from
19
+ * `memory-api.js`. All three call one function and report its one result
20
+ * vocabulary, so they cannot drift into telling a user different things.
21
+ *
22
+ * What is deliberately NOT here: any way to run the consolidation model call
23
+ * inline. An explicit request only ENQUEUES. The queue executor is the single
24
+ * writer of `sum-*` / `wu-*` (idempotent by name), and a synchronous second
25
+ * writer would be exactly the duplicate this design exists to avoid.
26
+ *
27
+ * @module dsh-hypatia-auto-memory/on-demand
28
+ */
29
+
30
+ import { SUMMARIZE_REASON } from './consolidator.js'
31
+
32
+ /** Slash command a user types in the composer. */
33
+ export const SUMMARIZE_COMMAND = 'hypatia-summarize'
34
+
35
+ /** Agent-facing tool name, per DSH's snake_case tool convention. */
36
+ export const SUMMARIZE_TOOL = 'hypatia_summarize'
37
+
38
+ /** Plugin-owned identity of the command definition, independent of its name. */
39
+ export const SUMMARIZE_DEFINITION_ID = 'dsh-hypatia-auto-memory/hypatia-summarize'
40
+
41
+ /**
42
+ * `/`-menu description. The client keeps the Host's own text for a non-builtin
43
+ * command and shows it in the Commands section, after the builtins, so this
44
+ * line is the whole discovery story for the command — it has to say what the
45
+ * command does without relying on the name being recognized.
46
+ *
47
+ * Bilingual on purpose: the dictionaries this plugin ships are, and the command
48
+ * registry has one `description` field with no locale behind it.
49
+ */
50
+ export const SUMMARIZE_COMMAND_DESCRIPTION = 'Summarise this session into Hypatia memory now · 立即整理本会话'
51
+
52
+ /** Tool description; model-facing, so English only and explicit about the side effect. */
53
+ export const SUMMARIZE_TOOL_DESCRIPTION = 'Summarise a session into long-term Hypatia memory right now, without waiting '
54
+ + 'for the automatic background thresholds. Queues the consolidation span that is currently unsummarised; the '
55
+ + 'summary is produced in the background on the plugin\'s dedicated model route, so this call returns immediately '
56
+ + 'and does not consume this session\'s context. Use it only when the user explicitly asks to summarise, '
57
+ + 'consolidate or digest the current (or a named) session now. Never call it on your own initiative.'
58
+
59
+ /**
60
+ * The session id a command invocation or a tool call is bound to.
61
+ *
62
+ * Mirrors the collector's own derivation (`header.id` first): the header is the
63
+ * durable copy, and a detached session exposes the same value through `id`.
64
+ *
65
+ * @param {any} session
66
+ * @returns {string} the id, or '' when there is none to read.
67
+ */
68
+ export function sessionIdOf(session) {
69
+ const value = session?.header?.id ?? session?.id
70
+ return value === undefined || value === null ? '' : String(value)
71
+ }
72
+
73
+ /**
74
+ * The one result an unavailable consolidation half reports.
75
+ * @param {string} sessionId
76
+ * @returns {{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number}}
77
+ */
78
+ export function unavailableOutcome(sessionId) {
79
+ return { sessionId, queued: false, reason: SUMMARIZE_REASON.Unavailable, fromSeq: 0, toSeq: 0 }
80
+ }
81
+
82
+ /**
83
+ * The one result the whole plugin being switched off reports, for the route
84
+ * family that stays mounted so a user can still see why nothing happens.
85
+ * @param {string} sessionId
86
+ * @returns {{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number}}
87
+ */
88
+ export function disabledOutcome(sessionId) {
89
+ return { sessionId, queued: false, reason: SUMMARIZE_REASON.Disabled, fromSeq: 0, toSeq: 0 }
90
+ }
91
+
92
+ /**
93
+ * The human-readable rendering of one outcome, shared by the command and the
94
+ * Memory tab's button. English, because it is also the tool's model-facing text
95
+ * (see {@link summarizeOutcomeValue}); the client localizes its own rendering
96
+ * and falls back to the reason code, not to this sentence.
97
+ *
98
+ * @param {{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number}} result
99
+ * @returns {string}
100
+ */
101
+ export function outcomeText(result) {
102
+ if (result.queued) {
103
+ return `Consolidation queued for ${result.sessionId} over events [${result.fromSeq}, ${result.toSeq}). `
104
+ + 'The summary is written in the background on the plugin\'s own model route.'
105
+ }
106
+ switch (result.reason) {
107
+ case SUMMARIZE_REASON.Empty:
108
+ return `${result.sessionId} has nothing left to summarise: every logged event is already consolidated.`
109
+ case SUMMARIZE_REASON.Busy:
110
+ return `A consolidation covering [${result.fromSeq}, ${result.toSeq}) is already queued for ${result.sessionId}.`
111
+ case SUMMARIZE_REASON.Disabled:
112
+ return 'Consolidation is turned off (hypatia-auto-memory.consolidation.enabled).'
113
+ case SUMMARIZE_REASON.Unavailable:
114
+ return 'Consolidation is unavailable: no model route is mounted for it.'
115
+ case SUMMARIZE_REASON.Unknown:
116
+ return `No session "${result.sessionId}" is available to consolidate.`
117
+ default:
118
+ return `Consolidation was not queued (${result.reason}).`
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The command's verdict on one outcome.
124
+ *
125
+ * "Nothing to do" and "already queued" are successes: the handler did its job
126
+ * and the answer is truthful. Only a genuine refusal — the feature is off, no
127
+ * route exists, the session is gone — is an error, which the composer renders
128
+ * as one.
129
+ *
130
+ * @param {{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number}} result
131
+ * @returns {{kind: 'success', text: string} | {kind: 'error', text: string}}
132
+ */
133
+ export function commandResult(result) {
134
+ const text = outcomeText(result)
135
+ if (result.queued) return { kind: 'success', text }
136
+ if (result.reason === SUMMARIZE_REASON.Empty || result.reason === SUMMARIZE_REASON.Busy) {
137
+ return { kind: 'success', text }
138
+ }
139
+ return { kind: 'error', text }
140
+ }
141
+
142
+ /**
143
+ * The tool's canonical value for one outcome.
144
+ *
145
+ * Structured rather than prose-only so an agent can branch on `queued` and
146
+ * `reason`; `message` carries the explanatory sentence for the common case.
147
+ *
148
+ * @param {{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number}} result
149
+ * @returns {{sessionId: string, queued: boolean, reason: string, fromSeq: number, toSeq: number, message: string}}
150
+ */
151
+ export function summarizeOutcomeValue(result) {
152
+ return { ...result, message: outcomeText(result) }
153
+ }
154
+
155
+ /**
156
+ * The tool's `execute`, split from registration so it is unit-testable without
157
+ * the DSH package graph.
158
+ *
159
+ * The calling session is the default and an explicit `sessionId` overrides it.
160
+ * A call with neither is an error: guessing "the first session" or "the most
161
+ * recent one" would consolidate a conversation nobody asked about.
162
+ *
163
+ * @param {{run: (sessionId: string) => Promise<any>}} deps
164
+ * @returns {(args: {sessionId?: string}, exec: {agent?: {session: any}}) => Promise<any>}
165
+ */
166
+ export function summarizeToolHandler({ run }) {
167
+ return async (args, exec) => {
168
+ const explicit = typeof args?.sessionId === 'string' ? args.sessionId.trim() : ''
169
+ const sessionId = explicit !== '' ? explicit : sessionIdOf(exec?.agent?.session)
170
+ if (sessionId === '') {
171
+ throw new Error('hypatia_summarize: no calling session is bound to this call; pass sessionId explicitly')
172
+ }
173
+ return summarizeOutcomeValue(await run(sessionId))
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Register `/hypatia-summarize`.
179
+ *
180
+ * The handler never sends anything to the model — that is the registry's own
181
+ * contract for `handler`, and the acceptance test for this face is that no
182
+ * model call is logged.
183
+ *
184
+ * @param {import('@deepseek-ai/cordis').Context} ctx - a `commands`-injected context.
185
+ * @param {{run: (sessionId: string) => Promise<any>, status: import('./status.js').StatusLog}} deps
186
+ */
187
+ export function registerSummarizeCommand(ctx, { run, status }) {
188
+ ctx.effect(() => ctx.commands.register({
189
+ definitionId: SUMMARIZE_DEFINITION_ID,
190
+ name: SUMMARIZE_COMMAND,
191
+ description: SUMMARIZE_COMMAND_DESCRIPTION,
192
+ handler: async (invocation) => {
193
+ const sessionId = sessionIdOf(invocation?.agent?.session)
194
+ if (sessionId === '') {
195
+ return { kind: 'error', text: 'hypatia-summarize: this invocation has no session to consolidate' }
196
+ }
197
+ return commandResult(await run(sessionId))
198
+ },
199
+ }), 'dsh-hypatia-auto-memory: /hypatia-summarize')
200
+ status.info(`command registered: /${SUMMARIZE_COMMAND}`)
201
+ }
202
+
203
+ /**
204
+ * Register the `hypatia_summarize` tool.
205
+ *
206
+ * `@deepseek-ai/dsh-tools` is imported lazily, like `@deepseek-ai/dsh-llm` in
207
+ * the consolidator: the pure helpers above stay importable (and testable)
208
+ * without the DSH package graph, and a composition that has no tool registry
209
+ * never needs the package at all.
210
+ *
211
+ * @param {import('@deepseek-ai/cordis').Context} ctx - a `tools`-injected context.
212
+ * @param {{run: (sessionId: string) => Promise<any>, status: import('./status.js').StatusLog}} deps
213
+ * @returns {Promise<void>} resolves once the definition is registered.
214
+ */
215
+ export async function registerSummarizeTool(ctx, { run, status }) {
216
+ const { defineTool } = await import('@deepseek-ai/dsh-tools')
217
+ const definition = defineTool({
218
+ name: SUMMARIZE_TOOL,
219
+ description: SUMMARIZE_TOOL_DESCRIPTION,
220
+ parameters: {
221
+ sessionId: {
222
+ type: 'string',
223
+ description: 'Session id to summarise. Defaults to the session this call runs in.',
224
+ },
225
+ },
226
+ output: {
227
+ schema: {
228
+ type: 'object',
229
+ additionalProperties: false,
230
+ properties: {
231
+ sessionId: { type: 'string', required: true },
232
+ queued: { type: 'boolean', required: true },
233
+ reason: {
234
+ type: 'string',
235
+ required: true,
236
+ description: 'Empty when queued; otherwise disabled, empty, busy, unknown-session or unavailable.',
237
+ },
238
+ fromSeq: { type: 'number', required: true },
239
+ toSeq: { type: 'number', required: true },
240
+ message: { type: 'string', required: true },
241
+ },
242
+ },
243
+ render: (_args, value) => [{ type: 'text', text: value.message }],
244
+ },
245
+ execute: summarizeToolHandler({ run }),
246
+ })
247
+ ctx.effect(() => ctx.tools.register(definition), 'dsh-hypatia-auto-memory: hypatia_summarize tool')
248
+ status.info(`tool registered: ${SUMMARIZE_TOOL}`)
249
+ }