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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hypatia-auto-memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Event-driven Hypatia memory for DSH: automatic conversation logging via session events, queued background consolidation (span summaries + work-unit extraction) with a dedicated model route, and recall injection — zero per-call model work in the main session.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"@deepseek-ai/dsh": ">=0.1.7-rc.1",
|
|
54
54
|
"@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
|
|
55
55
|
"@deepseek-ai/dsh-storage-domain": "^0.1.7-rc.1",
|
|
56
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
|
|
56
57
|
"react": "^18.2.0"
|
|
57
58
|
},
|
|
58
59
|
"peerDependenciesMeta": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hypatia-memory
|
|
3
|
-
description: Long-term memory for DSH via hypatia. Conversation logging and background consolidation run automatically; retrieval is yours to drive. Use when the task touches past decisions, project conventions, prior bugs, or anything the user expects you to already know — and whenever the user says "read hypatia", "查一下 hypatia", "记住", "忘记".
|
|
3
|
+
description: Long-term memory for DSH via hypatia. Conversation logging and background consolidation run automatically; retrieval is yours to drive. Use when the task touches past decisions, project conventions, prior bugs, or anything the user expects you to already know — and whenever the user says "read hypatia", "查一下 hypatia", "记住", "忘记". Also use when the user explicitly asks to summarise or consolidate a session now ("整理一下这个会话", "summarise this session").
|
|
4
4
|
user-invocable: false
|
|
5
5
|
allowed-tools: Bash, Read, Grep, Glob
|
|
6
6
|
---
|
|
@@ -198,6 +198,39 @@ cascade-deleted; remove them with `hypatia statement-delete <head> <relation>
|
|
|
198
198
|
<tail>` when they would otherwise dangle. Deleted memories stay deleted — the
|
|
199
199
|
collector's watermark has already moved past them.
|
|
200
200
|
|
|
201
|
+
## Manual consolidation on request
|
|
202
|
+
|
|
203
|
+
The automatic layer cuts spans on turn and token thresholds. Those thresholds
|
|
204
|
+
pace an ongoing conversation; they are not a promise about *when* a summary
|
|
205
|
+
exists. When the user asks for one **now** — "summarise this session", "整理一下
|
|
206
|
+
这个会话", "把这段对话总结一下" — there is an action for it:
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
hypatia_summarize # the session you are running in
|
|
210
|
+
hypatia_summarize {"sessionId": "<session-id>"} # or one the user names
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
It enqueues the span that is currently unsummarised, for the plugin's own
|
|
214
|
+
background writer, on its dedicated model route. It returns immediately, so tell
|
|
215
|
+
the user it is **queued**, not that a summary exists; the Memory tab's
|
|
216
|
+
summarisation row catches up on its next poll.
|
|
217
|
+
|
|
218
|
+
- **Trigger only on an explicit request.** Never call it because the session
|
|
219
|
+
feels long, because a turn just ended, or because you judge a summary would be
|
|
220
|
+
useful. The premise of this plugin is that a model left to decide when to
|
|
221
|
+
write memory does not write it: on the manual protocol, one of fifteen measured
|
|
222
|
+
sessions followed it, and only because the user asked directly. A
|
|
223
|
+
self-initiated call is that same failure with an extra model call attached.
|
|
224
|
+
- **Do not** write `sum-*` entries yourself, and **do not** run the cascade by
|
|
225
|
+
hand. The plugin's writer is idempotent by name and owns the watermark — the
|
|
226
|
+
session-log `seq` range, which you cannot see. A hand-written summary gets a
|
|
227
|
+
different name, so the automatic layer later writes its own over the same
|
|
228
|
+
messages and the shelf carries both; and a batch the plugin did not choose
|
|
229
|
+
leaves an archive entry with a partial member set.
|
|
230
|
+
- **Say that the composer has a command.** `/hypatia-summarize` queues exactly
|
|
231
|
+
the same work from the UI, without a model turn and without spending this
|
|
232
|
+
session's context. For a user who just wants it done, that is the cheaper path.
|
|
233
|
+
|
|
201
234
|
## Known gaps in the automatic layer
|
|
202
235
|
|
|
203
236
|
Say so plainly if a user's expectation depends on one of these:
|
|
@@ -207,7 +240,18 @@ Say so plainly if a user's expectation depends on one of these:
|
|
|
207
240
|
unrelated topics summarises them together.
|
|
208
241
|
- **No `session-<id>` node** unless the host produced a session title or a
|
|
209
242
|
compaction summary to build it from.
|
|
210
|
-
- **Summary names are mechanical** (`sum-<session>-<from>-<to>`
|
|
211
|
-
The descriptive title is the
|
|
243
|
+
- **Summary names are mechanical** (`sum-<session>-<from>-<to>` at tier 1,
|
|
244
|
+
`sum<level>-<digest>` above it), not descriptive. The descriptive title is the
|
|
245
|
+
first heading inside the entry.
|
|
246
|
+
- **The summary layer is a cascade, not a flat list.** `summary 1` entries each
|
|
247
|
+
condense a span of messages. Once `cascade.batchSize` of one tier (16 by
|
|
248
|
+
default) have no `summary` edge archiving them, they are condensed into one
|
|
249
|
+
entry a tier up, tagged `summary 2`, and so on; the plugin stops creating tiers
|
|
250
|
+
past `summary 8`, a ceiling rather than an expected depth. `sum` is the
|
|
251
|
+
operational prefix for that whole layer (alongside `msg-`, `session-` and
|
|
252
|
+
`hypatia-dream-run-`), which is why every exclusion list skips it. What makes
|
|
253
|
+
an entry a cascade *member* is not its name but the pair "tagged `summary <N>`
|
|
254
|
+
and carrying no incoming `summary` edge" — the plugin's batches come from
|
|
255
|
+
hypatia's `$not-summaried`, which anti-joins on the edge.
|
|
212
256
|
- **Work-unit dedup is best-effort** and degrades to "no relationship" whenever
|
|
213
257
|
the shelf has no embedding model.
|
|
@@ -82,7 +82,8 @@
|
|
|
82
82
|
|
|
83
83
|
.status,
|
|
84
84
|
.hint,
|
|
85
|
-
.error
|
|
85
|
+
.error,
|
|
86
|
+
.notice {
|
|
86
87
|
margin: 0;
|
|
87
88
|
font-size: 12px;
|
|
88
89
|
line-height: 1.6;
|
|
@@ -97,6 +98,10 @@
|
|
|
97
98
|
color: var(--dsw-alias-label-error);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
.notice {
|
|
102
|
+
color: var(--dsw-alias-label-secondary);
|
|
103
|
+
}
|
|
104
|
+
|
|
100
105
|
.group {
|
|
101
106
|
display: flex;
|
|
102
107
|
flex-direction: column;
|
|
@@ -177,6 +182,38 @@
|
|
|
177
182
|
background: var(--dsw-alias-bg-layer-3);
|
|
178
183
|
}
|
|
179
184
|
|
|
185
|
+
/* Sits on the "consolidation" row, beside the gap it acts on. Disabled — not
|
|
186
|
+
hidden — once the session is caught up, so the control stays where the user
|
|
187
|
+
already found it. */
|
|
188
|
+
.summarize {
|
|
189
|
+
appearance: none;
|
|
190
|
+
border: 0.5px solid var(--dsw-alias-border-l4);
|
|
191
|
+
border-radius: 6px;
|
|
192
|
+
background: none;
|
|
193
|
+
color: var(--dsw-alias-label-secondary);
|
|
194
|
+
cursor: pointer;
|
|
195
|
+
font: inherit;
|
|
196
|
+
font-size: 11px;
|
|
197
|
+
padding: 1px 8px;
|
|
198
|
+
white-space: nowrap;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.summarize:hover:enabled {
|
|
202
|
+
border-color: var(--dsw-alias-label-dimmed);
|
|
203
|
+
color: var(--dsw-alias-label-primary);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.summarize:focus-visible {
|
|
207
|
+
outline: 2px solid var(--dsw-alias-brand-primary);
|
|
208
|
+
outline-offset: 1px;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.summarize:disabled {
|
|
212
|
+
border-color: var(--dsw-alias-border-l2);
|
|
213
|
+
color: var(--dsw-alias-label-dimmed);
|
|
214
|
+
cursor: default;
|
|
215
|
+
}
|
|
216
|
+
|
|
180
217
|
.failures {
|
|
181
218
|
display: grid;
|
|
182
219
|
gap: 6px;
|
|
@@ -22,11 +22,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
22
22
|
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
23
23
|
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
24
24
|
import {
|
|
25
|
+
SUMMARIZE_REASON,
|
|
26
|
+
canSummarize,
|
|
25
27
|
consolidationGap,
|
|
26
28
|
mergeMemory,
|
|
27
29
|
type FetchMemory,
|
|
28
30
|
type MemoryEntry,
|
|
29
31
|
type MemoryPayload,
|
|
32
|
+
type SummarizeMemory,
|
|
33
|
+
type SummarizeResult,
|
|
30
34
|
} from './memory-client'
|
|
31
35
|
import { NS } from './locales'
|
|
32
36
|
import css from './MemoryView.module.css'
|
|
@@ -39,8 +43,43 @@ export type MemoryViewProps = {
|
|
|
39
43
|
/** The session this tab is bound to; supplied by the session-scoped slot. */
|
|
40
44
|
sessionId: string
|
|
41
45
|
fetch: FetchMemory
|
|
46
|
+
/** Queues an explicit consolidation of one session; never runs the model here. */
|
|
47
|
+
summarize: SummarizeMemory
|
|
42
48
|
} & PropsLocale<typeof NS>
|
|
43
49
|
|
|
50
|
+
/** One line of feedback from an explicit consolidation request. */
|
|
51
|
+
type Notice = { kind: 'ok' | 'error'; text: string }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render the Host's answer to one request.
|
|
55
|
+
*
|
|
56
|
+
* "Nothing to do" and "already queued" are not failures and do not read as one:
|
|
57
|
+
* the request was answered truthfully. Only a refusal — the feature is off, the
|
|
58
|
+
* route is missing, the session is gone — is shown as an error.
|
|
59
|
+
*/
|
|
60
|
+
function noticeFor(result: SummarizeResult, t: MemoryViewProps['t']): Notice {
|
|
61
|
+
if (result.queued) {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'ok',
|
|
64
|
+
text: t('memorySummarizeQueued', { from: String(result.fromSeq), to: String(result.toSeq) }),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
switch (result.reason) {
|
|
68
|
+
case SUMMARIZE_REASON.empty:
|
|
69
|
+
return { kind: 'ok', text: t('memorySummarizeEmpty') }
|
|
70
|
+
case SUMMARIZE_REASON.busy:
|
|
71
|
+
return { kind: 'ok', text: t('memorySummarizeBusy') }
|
|
72
|
+
case SUMMARIZE_REASON.disabled:
|
|
73
|
+
return { kind: 'error', text: t('memorySummarizeDisabled') }
|
|
74
|
+
case SUMMARIZE_REASON.unavailable:
|
|
75
|
+
return { kind: 'error', text: t('memorySummarizeUnavailable') }
|
|
76
|
+
case SUMMARIZE_REASON.unknownSession:
|
|
77
|
+
return { kind: 'error', text: t('memorySummarizeUnknown') }
|
|
78
|
+
default:
|
|
79
|
+
return { kind: 'error', text: t('memorySummarizeUnknownReason', { reason: result.reason }) }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
/** Local wall-clock stamp for the freshness line. */
|
|
45
84
|
function formatTime(epochMs: number): string {
|
|
46
85
|
if (!Number.isFinite(epochMs) || epochMs <= 0) return '—'
|
|
@@ -98,10 +137,14 @@ function Entry({ entry, badge, open, onToggle, labels, t }: {
|
|
|
98
137
|
)
|
|
99
138
|
}
|
|
100
139
|
|
|
101
|
-
export function MemoryView({ sessionId, fetch, t }: MemoryViewProps) {
|
|
140
|
+
export function MemoryView({ sessionId, fetch, summarize, t }: MemoryViewProps) {
|
|
102
141
|
const [payload, setPayload] = useState<MemoryPayload | undefined>(undefined)
|
|
103
142
|
const [loading, setLoading] = useState(true)
|
|
104
143
|
const [error, setError] = useState('')
|
|
144
|
+
/** True while the Host is being asked to queue a consolidation. */
|
|
145
|
+
const [summarizing, setSummarizing] = useState(false)
|
|
146
|
+
/** The last explicit request's answer, cleared when the session switches. */
|
|
147
|
+
const [notice, setNotice] = useState<Notice | undefined>(undefined)
|
|
105
148
|
/**
|
|
106
149
|
* The entries the reader opened, by name. Absent means closed, which is the
|
|
107
150
|
* default: a toggle survives the poll that replaces the payload, and an entry
|
|
@@ -153,6 +196,7 @@ export function MemoryView({ sessionId, fetch, t }: MemoryViewProps) {
|
|
|
153
196
|
setPayload(undefined)
|
|
154
197
|
setOverrides({})
|
|
155
198
|
setContentAt(undefined)
|
|
199
|
+
setNotice(undefined)
|
|
156
200
|
setLoading(true)
|
|
157
201
|
void load(true)
|
|
158
202
|
}, [load, sessionId])
|
|
@@ -178,6 +222,37 @@ export function MemoryView({ sessionId, fetch, t }: MemoryViewProps) {
|
|
|
178
222
|
setOverrides((current) => ({ ...current, [name]: !open }))
|
|
179
223
|
}, [])
|
|
180
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Ask the Host to consolidate this session now.
|
|
227
|
+
*
|
|
228
|
+
* Only queues: the summary is written in the background, so the answer says
|
|
229
|
+
* what was accepted rather than what was written. A queued request refreshes
|
|
230
|
+
* the status so the "behind" figure catches up with the next poll; the
|
|
231
|
+
* watermark watch above then re-reads the shelf when consolidation lands.
|
|
232
|
+
*/
|
|
233
|
+
const onSummarize = useCallback(() => {
|
|
234
|
+
setSummarizing(true)
|
|
235
|
+
setNotice(undefined)
|
|
236
|
+
void (async () => {
|
|
237
|
+
try {
|
|
238
|
+
const result = await summarize(sessionId)
|
|
239
|
+
if (activeSession.current !== sessionId) return
|
|
240
|
+
setNotice(noticeFor(result, t))
|
|
241
|
+
if (result.queued) void load(false)
|
|
242
|
+
} catch (failure) {
|
|
243
|
+
if (activeSession.current !== sessionId) return
|
|
244
|
+
setNotice({
|
|
245
|
+
kind: 'error',
|
|
246
|
+
text: t('memorySummarizeFailed', {
|
|
247
|
+
message: failure instanceof Error ? failure.message : String(failure),
|
|
248
|
+
}),
|
|
249
|
+
})
|
|
250
|
+
} finally {
|
|
251
|
+
if (activeSession.current === sessionId) setSummarizing(false)
|
|
252
|
+
}
|
|
253
|
+
})()
|
|
254
|
+
}, [load, sessionId, summarize, t])
|
|
255
|
+
|
|
181
256
|
const session = payload?.session
|
|
182
257
|
const content = payload?.content
|
|
183
258
|
const summaries = content?.summaries ?? []
|
|
@@ -225,6 +300,10 @@ export function MemoryView({ sessionId, fetch, t }: MemoryViewProps) {
|
|
|
225
300
|
|
|
226
301
|
{error !== '' ? <p className={css.error} role="status">{t('memoryLoadFailed', { message: error })}</p> : null}
|
|
227
302
|
|
|
303
|
+
{notice !== undefined
|
|
304
|
+
? <p className={notice.kind === 'ok' ? css.notice : css.error} role="status">{notice.text}</p>
|
|
305
|
+
: null}
|
|
306
|
+
|
|
228
307
|
{loading && payload === undefined
|
|
229
308
|
? <p className={css.status}>{t('memoryLoading')}</p>
|
|
230
309
|
: session === undefined
|
|
@@ -249,6 +328,20 @@ export function MemoryView({ sessionId, fetch, t }: MemoryViewProps) {
|
|
|
249
328
|
? t('memoryCaughtUp')
|
|
250
329
|
: t('memoryBehind', { behind: String(consolidationGap(session)) })}
|
|
251
330
|
</span>
|
|
331
|
+
{/* Disabled rather than hidden once caught up: hiding it
|
|
332
|
+
would make the user rediscover it the next time the
|
|
333
|
+
session falls behind, which is exactly when they look
|
|
334
|
+
for it. It sits on the row that states the gap, so it
|
|
335
|
+
appears at the moment the gap does. */}
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
className={css.summarize}
|
|
339
|
+
disabled={summarizing || !canSummarize(session)}
|
|
340
|
+
title={t('memorySummarizeHint')}
|
|
341
|
+
onClick={onSummarize}
|
|
342
|
+
>
|
|
343
|
+
{summarizing ? t('memorySummarizing') : t('memorySummarize')}
|
|
344
|
+
</button>
|
|
252
345
|
</dd>
|
|
253
346
|
</div>
|
|
254
347
|
<div className={css.row}>
|
package/src/client/index.tsx
CHANGED
|
@@ -29,7 +29,7 @@ import { SettingsCard } from './SettingsCard'
|
|
|
29
29
|
import type { ConfigShape } from './SettingsCard'
|
|
30
30
|
import type { LoadConsolidationModelCatalog } from './consolidation-models'
|
|
31
31
|
import { MemoryView } from './MemoryView'
|
|
32
|
-
import { fetchMemory, fetchShelves } from './memory-client'
|
|
32
|
+
import { fetchMemory, fetchShelves, summarizeMemory } from './memory-client'
|
|
33
33
|
import type { LoadShelfInventory } from './shelves'
|
|
34
34
|
import { BUNDLE_CONFIG_SEAT, SETTINGS_TAB_SEAT, installPluginCard } from './plugin-card-seat'
|
|
35
35
|
import { en, NS, zh } from './locales'
|
|
@@ -131,7 +131,7 @@ export function apply(ctx: Context): void {
|
|
|
131
131
|
order: 20,
|
|
132
132
|
locale: NS,
|
|
133
133
|
label: () => t('viewMemory'),
|
|
134
|
-
inject: () => ({ fetch: fetchMemory }),
|
|
134
|
+
inject: () => ({ fetch: fetchMemory, summarize: summarizeMemory }),
|
|
135
135
|
},
|
|
136
136
|
MemoryView,
|
|
137
137
|
),
|
package/src/client/locales.ts
CHANGED
|
@@ -77,6 +77,17 @@ export const zh = {
|
|
|
77
77
|
memoryConsolidated: '摘要整合',
|
|
78
78
|
memoryCaughtUp: '已结清',
|
|
79
79
|
memoryBehind: '较记录落后 {behind} 条',
|
|
80
|
+
memorySummarize: '立即整理',
|
|
81
|
+
memorySummarizing: '整理中…',
|
|
82
|
+
memorySummarizeHint: '不等阈值,立刻把这段尚未摘要的对话交给后台整合。已结清时按钮禁用但保留在原位,下次落后时不必重新找它。',
|
|
83
|
+
memorySummarizeQueued: '已入队:将整理第 {from} – {to} 条事件;摘要在后台由插件的专用模型路由写入,不会占用本会话的上下文。',
|
|
84
|
+
memorySummarizeEmpty: '已结清,没有需要整理的内容。',
|
|
85
|
+
memorySummarizeBusy: '同样的区间已有一次整合在队列中,无需重复触发。',
|
|
86
|
+
memorySummarizeDisabled: '整合功能已在设置中关闭。',
|
|
87
|
+
memorySummarizeUnavailable: '整合不可用:当前组合没有挂载模型路由。',
|
|
88
|
+
memorySummarizeUnknown: '这个会话已经不在内存中,无法整理。',
|
|
89
|
+
memorySummarizeUnknownReason: '未能整理:{reason}',
|
|
90
|
+
memorySummarizeFailed: '无法触发整理:{message}',
|
|
80
91
|
memoryPending: '待整合内容',
|
|
81
92
|
memoryPendingValue: '约 {tokens} token',
|
|
82
93
|
memorySessionNode: '会话节点',
|
|
@@ -183,6 +194,17 @@ export const en = {
|
|
|
183
194
|
memoryConsolidated: 'Summarisation',
|
|
184
195
|
memoryCaughtUp: 'caught up',
|
|
185
196
|
memoryBehind: '{behind} events behind the log',
|
|
197
|
+
memorySummarize: 'Summarise now',
|
|
198
|
+
memorySummarizing: 'Summarising…',
|
|
199
|
+
memorySummarizeHint: 'Consolidate the part of this conversation that has not been summarised yet, without waiting for the thresholds. Once caught up the button is disabled but stays in place, so it need not be rediscovered the next time the session falls behind.',
|
|
200
|
+
memorySummarizeQueued: 'Queued: events {from}–{to} will be summarised. The summary is written in the background on the plugin\'s own model route and costs this session no context.',
|
|
201
|
+
memorySummarizeEmpty: 'Nothing to summarise: the log is already consolidated.',
|
|
202
|
+
memorySummarizeBusy: 'A consolidation covering the same span is already queued.',
|
|
203
|
+
memorySummarizeDisabled: 'Consolidation is turned off in the settings.',
|
|
204
|
+
memorySummarizeUnavailable: 'Consolidation is unavailable: this composition mounted no model route for it.',
|
|
205
|
+
memorySummarizeUnknown: 'That session is no longer in memory, so it cannot be consolidated.',
|
|
206
|
+
memorySummarizeUnknownReason: 'Could not consolidate: {reason}',
|
|
207
|
+
memorySummarizeFailed: 'Could not start consolidation: {message}',
|
|
186
208
|
memoryPending: 'Waiting to summarise',
|
|
187
209
|
memoryPendingValue: '~{tokens} tokens',
|
|
188
210
|
memorySessionNode: 'Session node',
|
|
@@ -15,6 +15,19 @@ import type { LoadShelfInventory, ShelfInfo, ShelfInventory } from './shelves'
|
|
|
15
15
|
/** Route prefix the Host claims; the tab appends `/session`. */
|
|
16
16
|
export const MEMORY_API_PREFIX = '/api/dsh-hypatia-auto-memory'
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Why an explicit consolidation request queued nothing, matching the Host's
|
|
20
|
+
* `SUMMARIZE_REASON` vocabulary (src/consolidator.js). Kept as named constants
|
|
21
|
+
* so the view's switch and the Host's answers cannot drift apart on a typo.
|
|
22
|
+
*/
|
|
23
|
+
export const SUMMARIZE_REASON = {
|
|
24
|
+
disabled: 'disabled',
|
|
25
|
+
empty: 'empty',
|
|
26
|
+
busy: 'busy',
|
|
27
|
+
unknownSession: 'unknown-session',
|
|
28
|
+
unavailable: 'unavailable',
|
|
29
|
+
} as const
|
|
30
|
+
|
|
18
31
|
/** One task that exhausted its attempts. */
|
|
19
32
|
export interface FailedMemoryTask {
|
|
20
33
|
kind: string
|
|
@@ -83,6 +96,33 @@ export interface MemoryPayload {
|
|
|
83
96
|
/** Read one session's memory. `content` also asks the Host to read the shelf. */
|
|
84
97
|
export type FetchMemory = (sessionId: string, options: { content: boolean }) => Promise<MemoryPayload>
|
|
85
98
|
|
|
99
|
+
/** One explicit consolidation request's outcome, as the Host reports it. */
|
|
100
|
+
export interface SummarizeResult {
|
|
101
|
+
sessionId: string
|
|
102
|
+
/** True when the Host accepted a span; false when there was nothing to do. */
|
|
103
|
+
queued: boolean
|
|
104
|
+
/** Empty when queued; otherwise one of {@link SUMMARIZE_REASON}. */
|
|
105
|
+
reason: string
|
|
106
|
+
/** The half-open span the Host cut, in session-log events. */
|
|
107
|
+
fromSeq: number
|
|
108
|
+
toSeq: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Ask the Host to consolidate one session now.
|
|
113
|
+
*
|
|
114
|
+
* This only queues the same task the automatic trigger queues — the summary is
|
|
115
|
+
* written in the background on the plugin's dedicated model route — so the
|
|
116
|
+
* promise settles long before anything appears in the shelf. The tab's normal
|
|
117
|
+
* watermark poll is what shows the result.
|
|
118
|
+
*/
|
|
119
|
+
export type SummarizeMemory = (sessionId: string) => Promise<SummarizeResult>
|
|
120
|
+
|
|
121
|
+
/** Whether "summarise now" has work to do for this session. */
|
|
122
|
+
export function canSummarize(session: SessionMemoryStatus): boolean {
|
|
123
|
+
return session.known && !session.caughtUp
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
/**
|
|
87
127
|
* The URL one refresh reads.
|
|
88
128
|
* @param sessionId - the session the tab is bound to.
|
|
@@ -112,6 +152,62 @@ export const fetchMemory: FetchMemory = async (sessionId, { content }) => {
|
|
|
112
152
|
return payload
|
|
113
153
|
}
|
|
114
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The URL one explicit consolidation request POSTs to.
|
|
157
|
+
* @param sessionId - the session to consolidate.
|
|
158
|
+
* @returns a same-origin path with its query.
|
|
159
|
+
*/
|
|
160
|
+
export function summarizeUrl(sessionId: string): string {
|
|
161
|
+
const query = new URLSearchParams({ session: sessionId })
|
|
162
|
+
return `${MEMORY_API_PREFIX}/consolidate?${query.toString()}`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Ask the Host to consolidate one session now.
|
|
167
|
+
*
|
|
168
|
+
* `POST`, never `GET`: a write must not be reachable from a link or an image
|
|
169
|
+
* tag. The Host keeps the same self-authentication on this route as on the read
|
|
170
|
+
* ones (loopback socket, loopback `Host`, same-origin), and the body is empty —
|
|
171
|
+
* everything the request means is in the URL.
|
|
172
|
+
*/
|
|
173
|
+
export const summarizeMemory: SummarizeMemory = async (sessionId) => {
|
|
174
|
+
const response = await fetch(summarizeUrl(sessionId), {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
credentials: 'same-origin',
|
|
177
|
+
headers: { accept: 'application/json' },
|
|
178
|
+
})
|
|
179
|
+
const body: unknown = await response.json().catch(() => undefined)
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
const message = isRecord(body) && typeof body.error === 'string' ? body.error : `HTTP ${String(response.status)}`
|
|
182
|
+
throw new Error(message)
|
|
183
|
+
}
|
|
184
|
+
const result = parseSummarizeResult(body, sessionId)
|
|
185
|
+
if (result === undefined) throw new Error('the Host returned an unreadable consolidation answer')
|
|
186
|
+
return result
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read the Host's consolidation answer, defensively.
|
|
191
|
+
*
|
|
192
|
+
* A body without a boolean `queued` is unreadable rather than "not queued": the
|
|
193
|
+
* whole point of the answer is to distinguish "accepted" from "nothing to do",
|
|
194
|
+
* and guessing would put a wrong sentence in front of the user.
|
|
195
|
+
*
|
|
196
|
+
* @param value - the parsed response body.
|
|
197
|
+
* @param sessionId - the session that was asked for.
|
|
198
|
+
* @returns the result, or undefined when the body is not one.
|
|
199
|
+
*/
|
|
200
|
+
export function parseSummarizeResult(value: unknown, sessionId: string): SummarizeResult | undefined {
|
|
201
|
+
if (!isRecord(value) || typeof value.queued !== 'boolean') return undefined
|
|
202
|
+
return {
|
|
203
|
+
sessionId: str(value.sessionId) || sessionId,
|
|
204
|
+
queued: value.queued,
|
|
205
|
+
reason: str(value.reason),
|
|
206
|
+
fromSeq: num(value.fromSeq),
|
|
207
|
+
toSeq: num(value.toSeq),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
115
211
|
/**
|
|
116
212
|
* Fetch the shelf listing for the settings card.
|
|
117
213
|
*
|
package/src/consolidator.js
CHANGED
|
@@ -84,6 +84,29 @@ export function budgetForEffort(baseTokens, effort) {
|
|
|
84
84
|
return outputBudget(baseTokens, effort === THINKING_OFF_EFFORT)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** The one queue kind explicit requests and the automatic triggers both use. */
|
|
88
|
+
const CONSOLIDATE_KIND = 'consolidate'
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Why an explicit "summarise this session now" request queued nothing.
|
|
92
|
+
*
|
|
93
|
+
* The three faces (slash command, Memory tab button, agent tool) all report
|
|
94
|
+
* from this vocabulary rather than inventing their own wording, because they
|
|
95
|
+
* all call the same host action and must tell the user the same truth.
|
|
96
|
+
*/
|
|
97
|
+
export const SUMMARIZE_REASON = Object.freeze({
|
|
98
|
+
/** The plugin or its consolidation half is switched off in the config. */
|
|
99
|
+
Disabled: 'disabled',
|
|
100
|
+
/** Everything logged is already consolidated: there is no span to cut. */
|
|
101
|
+
Empty: 'empty',
|
|
102
|
+
/** A queued or running task already covers the whole requested span. */
|
|
103
|
+
Busy: 'busy',
|
|
104
|
+
/** No session with that id is live or reachable through persistence. */
|
|
105
|
+
Unknown: 'unknown-session',
|
|
106
|
+
/** No consolidation half is mounted, so nothing can run (no model route). */
|
|
107
|
+
Unavailable: 'unavailable',
|
|
108
|
+
})
|
|
109
|
+
|
|
87
110
|
/** Distinguishes permanent failures (no retry value) from transient ones. */
|
|
88
111
|
export class PermanentConsolidationError extends Error {
|
|
89
112
|
constructor(message) {
|
|
@@ -398,7 +421,6 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
398
421
|
if ((state.pendingTokens ?? 0) < tokenFloor) {
|
|
399
422
|
return { resetTokens: false, advanceCheckpoint: false }
|
|
400
423
|
}
|
|
401
|
-
const current = progress.get(sessionId) ?? EMPTY_PROGRESS
|
|
402
424
|
// Awaited: the resolver falls back to persistence for a session the store
|
|
403
425
|
// will never publish again (see persisted-session.js).
|
|
404
426
|
const session = await sessions.get(sessionId)
|
|
@@ -406,24 +428,11 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
406
428
|
status.warn(`consolidation deferred for ${sessionId}: session not live`)
|
|
407
429
|
return { resetTokens: false, advanceCheckpoint: false }
|
|
408
430
|
}
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
if (toSeq <= fromSeq) {
|
|
431
|
+
const span = await enqueueSpan(sessionId, session)
|
|
432
|
+
if (span === undefined) {
|
|
412
433
|
return { resetTokens: false, advanceCheckpoint: false }
|
|
413
434
|
}
|
|
414
|
-
|
|
415
|
-
// Immediate: the thresholds already decided this span is ready. Riding the
|
|
416
|
-
// flush window only delayed it by `flushWindowMs` (two minutes by default),
|
|
417
|
-
// and a restart inside that window left the task persisted but unscheduled.
|
|
418
|
-
await queue.enqueue({
|
|
419
|
-
kind: 'consolidate',
|
|
420
|
-
sessionId,
|
|
421
|
-
fromSeq,
|
|
422
|
-
toSeq,
|
|
423
|
-
project,
|
|
424
|
-
immediate: true,
|
|
425
|
-
})
|
|
426
|
-
status.info(`consolidation scheduled: ${sessionId} [${fromSeq}, ${toSeq})`)
|
|
435
|
+
status.info(`consolidation scheduled: ${sessionId} [${span.fromSeq}, ${span.toSeq})`)
|
|
427
436
|
return { resetTokens: true, advanceCheckpoint: true }
|
|
428
437
|
}
|
|
429
438
|
|
|
@@ -543,6 +552,39 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
543
552
|
}
|
|
544
553
|
}
|
|
545
554
|
|
|
555
|
+
/**
|
|
556
|
+
* The one place a consolidation span is cut and queued.
|
|
557
|
+
*
|
|
558
|
+
* Every automatic trigger and every explicit user request goes through this,
|
|
559
|
+
* so the range arithmetic (`fromSeq` never inside a fork's inherited prefix,
|
|
560
|
+
* `toSeq` never past what has been logged) and the task shape exist once.
|
|
561
|
+
*
|
|
562
|
+
* @param {string} sessionId
|
|
563
|
+
* @param {any} session
|
|
564
|
+
* @returns {Promise<{fromSeq: number, toSeq: number} | undefined>} the span,
|
|
565
|
+
* or undefined when there is nothing to cut.
|
|
566
|
+
*/
|
|
567
|
+
async function enqueueSpan(sessionId, session) {
|
|
568
|
+
const current = progress.get(sessionId) ?? EMPTY_PROGRESS
|
|
569
|
+
const fromSeq = consolidationStart(current, session)
|
|
570
|
+
const toSeq = Math.min(session?.seq ?? 0, current.lastLoggedSeq)
|
|
571
|
+
if (toSeq <= fromSeq) return undefined
|
|
572
|
+
const project = await projectFor(session)
|
|
573
|
+
// Immediate: the caller already decided this span is ready — the thresholds
|
|
574
|
+
// were met, the session is ending, or a human asked. Riding the flush window
|
|
575
|
+
// only delayed it by `flushWindowMs` (two minutes by default), and a restart
|
|
576
|
+
// inside that window left the task persisted but unscheduled.
|
|
577
|
+
await queue.enqueue({
|
|
578
|
+
kind: CONSOLIDATE_KIND,
|
|
579
|
+
sessionId,
|
|
580
|
+
fromSeq,
|
|
581
|
+
toSeq,
|
|
582
|
+
project,
|
|
583
|
+
immediate: true,
|
|
584
|
+
})
|
|
585
|
+
return { fromSeq, toSeq }
|
|
586
|
+
}
|
|
587
|
+
|
|
546
588
|
/**
|
|
547
589
|
* Final consolidation for a session that is ending.
|
|
548
590
|
*
|
|
@@ -559,21 +601,64 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
559
601
|
async function onSessionEnd(sessionId, session) {
|
|
560
602
|
const config = getConfig()
|
|
561
603
|
if (config.enabled === false || config.consolidation.enabled === false) return false
|
|
604
|
+
const span = await enqueueSpan(sessionId, session)
|
|
605
|
+
if (span === undefined) return false
|
|
606
|
+
status.info(`consolidation scheduled at session end: ${sessionId} [${span.fromSeq}, ${span.toSeq})`)
|
|
607
|
+
return true
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Consolidate a session because a HUMAN asked for it right now.
|
|
612
|
+
*
|
|
613
|
+
* Same path as {@link onSessionEnd} — same span arithmetic, same task, same
|
|
614
|
+
* executor — with one difference: the session is not ending, so the cut is
|
|
615
|
+
* only a cut. The span is `[watermark, min(seq, lastLoggedSeq))` at the
|
|
616
|
+
* moment of the request, and everything appended afterwards waits for the
|
|
617
|
+
* next trigger exactly as it would have. Nothing is frozen.
|
|
618
|
+
*
|
|
619
|
+
* The thresholds are deliberately bypassed. They pace an ongoing
|
|
620
|
+
* conversation; an explicit request IS the reason to cut now, and the
|
|
621
|
+
* measured gap this exists for is 151 logged events against a token floor
|
|
622
|
+
* that had only accumulated 645 — a request cannot wait for a gate that may
|
|
623
|
+
* never open. The config switches are NOT bypassed: an explicit request is
|
|
624
|
+
* not a reason to write while the user has the plugin or its consolidation
|
|
625
|
+
* half turned off.
|
|
626
|
+
*
|
|
627
|
+
* @param {string} sessionId
|
|
628
|
+
* @returns {Promise<{sessionId: string, queued: boolean, reason: string,
|
|
629
|
+
* fromSeq: number, toSeq: number}>} `reason` is '' when queued, otherwise
|
|
630
|
+
* one of {@link SUMMARIZE_REASON}.
|
|
631
|
+
*/
|
|
632
|
+
async function consolidateNow(sessionId) {
|
|
633
|
+
const config = getConfig()
|
|
634
|
+
if (config.enabled === false || config.consolidation.enabled === false) {
|
|
635
|
+
return { sessionId, queued: false, reason: SUMMARIZE_REASON.Disabled, fromSeq: 0, toSeq: 0 }
|
|
636
|
+
}
|
|
637
|
+
const session = await sessions.get(sessionId)
|
|
638
|
+
if (session === undefined) {
|
|
639
|
+
status.warn(`consolidation requested for ${sessionId}, but the session is not available`)
|
|
640
|
+
return { sessionId, queued: false, reason: SUMMARIZE_REASON.Unknown, fromSeq: 0, toSeq: 0 }
|
|
641
|
+
}
|
|
562
642
|
const current = progress.get(sessionId) ?? EMPTY_PROGRESS
|
|
563
643
|
const fromSeq = consolidationStart(current, session)
|
|
564
644
|
const toSeq = Math.min(session?.seq ?? 0, current.lastLoggedSeq)
|
|
565
|
-
if (toSeq <= fromSeq)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
645
|
+
if (toSeq <= fromSeq) {
|
|
646
|
+
return { sessionId, queued: false, reason: SUMMARIZE_REASON.Empty, fromSeq, toSeq }
|
|
647
|
+
}
|
|
648
|
+
// A task already accepted for this range makes the request redundant: it is
|
|
649
|
+
// the same work. Only work that is actually scheduled counts — a `deferred`
|
|
650
|
+
// record is waiting for a session that is, by construction, available here
|
|
651
|
+
// (the lookup above just answered), so this request is re-enqueueing it,
|
|
652
|
+
// which is what schedules it. Nothing that falls through loses events:
|
|
653
|
+
// `enqueue` widens the record and the queue re-runs the uncovered tail.
|
|
654
|
+
const existing = queue.peek?.(CONSOLIDATE_KIND, sessionId)
|
|
655
|
+
const scheduled = existing !== undefined && (existing.status === 'pending' || existing.status === 'running')
|
|
656
|
+
if (scheduled && toSeq <= existing.toSeq) {
|
|
657
|
+
return { sessionId, queued: false, reason: SUMMARIZE_REASON.Busy, fromSeq, toSeq }
|
|
658
|
+
}
|
|
659
|
+
await enqueueSpan(sessionId, session)
|
|
660
|
+
status.info(`consolidation scheduled on request: ${sessionId} [${fromSeq}, ${toSeq})`)
|
|
661
|
+
return { sessionId, queued: true, reason: '', fromSeq, toSeq }
|
|
577
662
|
}
|
|
578
663
|
|
|
579
664
|
/** Queue executor for one consolidate task. */
|
|
@@ -797,6 +882,7 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
|
|
|
797
882
|
return {
|
|
798
883
|
onTurnEnd,
|
|
799
884
|
onSessionEnd,
|
|
885
|
+
consolidateNow,
|
|
800
886
|
adjudicate,
|
|
801
887
|
// The pure priority selector, shared with the cascade: both executors hold
|
|
802
888
|
// their own `task.attempts`, so neither needs a place of its own in a
|