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/README.md +37 -3
- package/README.zh-CN.md +28 -4
- package/lib/client.js +199 -7
- package/lib/client.js.map +1 -1
- package/package.json +2 -1
- package/skills/hypatia-memory/SKILL.md +47 -3
- package/src/client/MemoryView.module.css +38 -1
- package/src/client/MemoryView.tsx +94 -1
- package/src/client/index.tsx +2 -2
- package/src/client/locales.ts +22 -0
- package/src/client/memory-client.ts +96 -0
- package/src/consolidator.js +115 -29
- package/src/index.js +56 -3
- package/src/memory-api.js +81 -7
- package/src/on-demand.js +249 -0
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
|
|
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({
|
|
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
|
|
package/src/on-demand.js
ADDED
|
@@ -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
|
+
}
|