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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hypatia-auto-memory",
3
- "version": "2.0.0",
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>`), not descriptive.
211
- The descriptive title is the first heading inside the entry.
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}>
@@ -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
  ),
@@ -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
  *
@@ -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 fromSeq = consolidationStart(current, session)
410
- const toSeq = Math.min(session.seq ?? 0, current.lastLoggedSeq)
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
- const project = await projectFor(session)
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) return false
566
- const project = await projectFor(session)
567
- await queue.enqueue({
568
- kind: 'consolidate',
569
- sessionId,
570
- fromSeq,
571
- toSeq,
572
- project,
573
- immediate: true,
574
- })
575
- status.info(`consolidation scheduled at session end: ${sessionId} [${fromSeq}, ${toSeq})`)
576
- return true
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