dsh-hypatia-auto-memory 2.0.0 → 2.1.1

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 CHANGED
@@ -101,6 +101,8 @@ collect fiber dispose ──▶ the same flush for every session still open, per
101
101
  before the queue stops; the model call finishes at the next start
102
102
  (a restart, a config-change restart, or a crash: the startup
103
103
  consolidation backfill)
104
+ explicit request ──▶ /hypatia-summarize (no model turn) | hypatia_summarize |
105
+ the Memory tab button ──▶ the same consolidate task, thresholds waived
104
106
  agent/created ──▶ rules/taboos inject()
105
107
  ```
106
108
 
@@ -221,6 +223,34 @@ agent/created ──▶ rules/taboos inject()
221
223
  This is the protocol's log₁₆(n) archive: each tier compresses only material a
222
224
  previous tier already distilled.
223
225
 
226
+ - **Consolidation can also be requested explicitly.** Three faces call one host
227
+ action: the slash command **`/hypatia-summarize`** (in the composer's `/` menu,
228
+ executed by the UI *without a model turn*), the **`hypatia_summarize`** tool
229
+ (the `hypatia-memory` skill's execution path, so the agent never hand-writes a
230
+ `sum-*` entry), and a **Summarise now** button on the Memory tab, which sits on
231
+ the row that reports how far behind the session is — the moment a reader wants
232
+ it. A request cuts the span `[lastConsolidatedSeq, min(seq, lastLoggedSeq))`
233
+ **at that moment** and queues the same `consolidate` task the automatic trigger
234
+ queues: same kind, same executor, same idempotent writer. `checkEveryTurns` and
235
+ `minNewTokens` are deliberately bypassed — they pace an ongoing conversation,
236
+ and an explicit request is the reason to cut now. Measured live, a session sat
237
+ 151 logged events behind while the token floor had accumulated 645 of 3000, so
238
+ waiting for the gate is not an answer to accept. The config switches are *not*
239
+ bypassed, and a request is only a cut, never a freeze: events appended
240
+ afterwards wait for the next trigger as usual.
241
+
242
+ "Nothing left to summarise" and "this span is already queued" are reported as
243
+ such rather than queued again, so asking twice does not produce two summaries —
244
+ and because the writer is idempotent by name, even a race cannot. The button is
245
+ **disabled but kept in place** once the session is caught up: hiding it would
246
+ make the user rediscover it every time the session falls behind, which is
247
+ exactly when they look for it. It rides the Memory tab's own route family,
248
+ whose first **write** route (`POST /consolidate`) keeps all three
249
+ self-authentication checks the read routes use — loopback socket, loopback
250
+ `Host`, same-origin — and refuses anything but `POST`, so the write cannot be
251
+ triggered by a link or an image tag. Nothing on any of the three faces runs a
252
+ model call in the main session.
253
+
224
254
  - **Work units** are adjudicated, not guessed. Candidates come from `similar`
225
255
  (the only search that reports a distance), with the operational layer left
226
256
  out — in the query, by `--exclude-tags`, on a binary that has it (hypatia
@@ -325,7 +355,7 @@ agent/created ──▶ rules/taboos inject()
325
355
  verbatim, the reader just wrote them, and the agent is the right reader for
326
356
  them.
327
357
 
328
- The data arrives over a session-scoped, read-only HTTP route this plugin
358
+ The data arrives over a session-scoped HTTP route family this plugin
329
359
  registers on the DSH web server, `/api/dsh-hypatia-auto-memory/session`: status
330
360
  from the in-memory state tables on every poll, the shelf content only when it
331
361
  can have changed (on open, on a session switch, on a manual refresh, and when
@@ -335,7 +365,10 @@ agent/created ──▶ rules/taboos inject()
335
365
  and 120 000 per response. JSE has no `ORDER BY`, so "newest" is decided only
336
366
  after reading every candidate; when a scan reaches its cap the counts become
337
367
  lower bounds, and the tab reports that response as truncated rather than
338
- leaving a short list unexplained.
368
+ leaving a short list unexplained. The family's one **write** route,
369
+ `POST /consolidate`, is what the **Summarise now** button calls; it writes
370
+ nothing itself — it queues the same consolidation task the automatic trigger
371
+ queues (see *On-demand* above).
339
372
  Two channels were rejected on the way here. A **session projection** would be
340
373
  the best shape, but the event that would drive it cannot be written from
341
374
  outside this repository: `Session.append` offers no way to set the envelope's
@@ -650,7 +683,8 @@ dsh-hypatia-auto-memory/
650
683
  │ ├── cascade.js # log₁₆(n) hierarchical summary archive
651
684
  │ ├── model-log.js # bounded record of which model each attempt ran on
652
685
  │ ├── memory-status.js # per-session fold of the two state tables (pure)
653
- │ ├── memory-api.js # Memory tab + shelf listing: read-only route family
686
+ │ ├── memory-api.js # Memory tab + shelf listing; its one write route too
687
+ │ ├── on-demand.js # /hypatia-summarize + the hypatia_summarize tool
654
688
  │ ├── recall.js # rules/taboos preload at session start
655
689
  │ ├── auto-approve.js # approves the agent's own plain bash hypatia calls
656
690
  │ ├── skills.js # bundled skill registration (never shadows another provider)
package/README.zh-CN.md CHANGED
@@ -87,6 +87,8 @@ session/disposed ──▶ final flush + consolidation, thresholds waived (DSH
87
87
  collect fiber 拆除 ──▶ 对每个仍打开的会话做同样的收尾,并在队列停止前落盘;模型
88
88
  调用留到下次启动完成(重启、配置变更重启,或崩溃:启动时的
89
89
  consolidation backfill)
90
+ 显式请求 ──▶ /hypatia-summarize(不过模型)| hypatia_summarize | 记忆标签页按钮
91
+ ──▶ 同一个 consolidate 任务,但跳过阈值
90
92
  agent/created ──▶ rules/taboos inject()
91
93
  ```
92
94
 
@@ -175,6 +177,25 @@ agent/created ──▶ rules/taboos inject()
175
177
  免费得到 FIFO 分批)。这就是协议的 log₁₆(n) 归档:每一层只压缩上一层已经蒸馏过的
176
178
  内容。
177
179
 
180
+ - **也可以显式要求立刻整合。** 三个面对应同一个 host 侧动作:斜杠命令
181
+ **`/hypatia-summarize`**(在 composer 的 `/` 菜单里,由 UI 执行、**不过模型**)、工具
182
+ **`hypatia_summarize`**(`hypatia-memory` 技能的执行路径,Agent 因此不会自己手写
183
+ `sum-*` 条目),以及记忆标签页上的**「立即整理」按钮**——它就在报告「落后多少」的那
184
+ 一行上,正是读者需要它的时刻。一次请求按**触发那一刻**的水位切出
185
+ `[lastConsolidatedSeq, min(seq, lastLoggedSeq))`,入队与自动触发**完全相同**的
186
+ `consolidate` 任务:同一种 kind、同一个执行器、同一个幂等写手。`checkEveryTurns` 与
187
+ `minNewTokens` 被刻意绕过——它们用来给进行中的对话定节奏,而显式请求本身就是「现在
188
+ 就切」的理由。实测中一个会话落后 151 个事件,而门槛只累计到 3000 中的 645,所以
189
+ 「等闸门开」不是一个应当被接受的答案。配置开关**不**绕过;而且请求只是一次切分,
190
+ 不是冻结:之后产生的事件照旧等下一次触发。
191
+
192
+ 「没有需要整理的内容」和「这个区间已经入队」会如实回报而不是再入队一次,所以问两遍
193
+ 不会产生两条摘要——何况写手按名字幂等,即使并发也不会。结清后按钮**禁用但保留在原
194
+ 位**:隐藏它会让用户每次落后时都要重新发现它,而那正是他们找它的时候。它走记忆标签
195
+ 页自己的路由族,其中第一条**写**路由(`POST /consolidate`)完整保留读路由的三条自
196
+ 认证——回环 socket、回环 `Host`、同源——并且只接受 `POST`,所以链接或 `<img>` 无法触
197
+ 发这次写入。三个面都不会在主会话里发起模型调用。
198
+
178
199
  - **工作单元是裁决出来的,不是猜出来的。** 候选来自 `similar`(唯一会报告距离的
179
200
  检索),且排除操作层——在具备该能力的二进制上(hypatia #35)通过查询里的
180
201
  `--exclude-tags`;在没这个能力的二进制上,抓四倍的量再丢弃——再按距离上限过滤,
@@ -250,14 +271,16 @@ agent/created ──▶ rules/taboos inject()
250
271
  原始 `msg-*` 条目**故意不显示**:它是对话原文,读的人刚刚写过,而正确的读者是
251
272
  Agent。
252
273
 
253
- 数据经由本插件注册在 DSH web 服务器上的、会话作用域的只读 HTTP 路由
274
+ 数据经由本插件注册在 DSH web 服务器上的、会话作用域的 HTTP 路由族
254
275
  `/api/dsh-hypatia-auto-memory/session` 到达浏览器:状态每次轮询都从内存中的状态表
255
276
  现算,shelf 内容只在可能变化时才读(打开时、切换会话时、手动刷新时,以及整合推进了
256
277
  该会话水位时),其间缓存 15 秒。标签页在屏幕上时每 5 秒轮询一次,响应是有界的:
257
278
  最新的 40 条摘要与 40 条工作单元,每条正文 4000 字符、整份响应 120000 字符。
258
279
  JSE 没有 `ORDER BY`,所以「最新」只能在读完所有候选之后判定;扫描触到上限时计数
259
- 变成下界,标签页会把该次响应标为已截断,而不是让短列表无从解释。路上否掉了两条
260
- 通道。**session projection** 形态最
280
+ 变成下界,标签页会把该次响应标为已截断,而不是让短列表无从解释。这个路由族里唯一的
281
+ **写**路由 `POST /consolidate` 就是「立即整理」按钮调用的那个;它自己不写任何东西
282
+ ——它入队的是与自动触发完全相同的整合任务(见上文「也可以显式要求立刻整合」)。
283
+ 路上否掉了两条通道。**session projection** 形态最
261
284
  合适,但驱动它的事件在本仓库之外写不出来:`Session.append` 没有任何途径设置信封的
262
285
  `ignorable` 标记,而持久化读取路径会拒绝一个带着未知事件类型、又没有该标记的会话
263
286
  日志——所以自定义事件会破坏会话重载。**设置命名空间**即便在还存在时也是根作用域的:
@@ -513,7 +536,8 @@ dsh-hypatia-auto-memory/
513
536
  │ ├── cascade.js # log₁₆(n) 分层摘要归档
514
537
  │ ├── model-log.js # 每次尝试实际用了哪个模型的有界记录
515
538
  │ ├── memory-status.js # 两张状态表按会话折叠(纯函数)
516
- │ ├── memory-api.js # 记忆标签页与 shelf 清单:只读路由族 + JSE 读取
539
+ │ ├── memory-api.js # 记忆标签页与 shelf 清单;含唯一的写路由
540
+ │ ├── on-demand.js # /hypatia-summarize 与 hypatia_summarize 工具
517
541
  │ ├── recall.js # 会话启动时的 rules/taboos 预载
518
542
  │ ├── auto-approve.js # 批准 Agent 自己的纯 bash hypatia 调用
519
543
  │ ├── skills.js # 随包技能注册(从不遮蔽其它提供者)
package/lib/client.js CHANGED
@@ -307,6 +307,17 @@ window.__ModuleLoader__.load({
307
307
  memoryConsolidated: "摘要整合",
308
308
  memoryCaughtUp: "已结清",
309
309
  memoryBehind: "较记录落后 {behind} 条",
310
+ memorySummarize: "立即整理",
311
+ memorySummarizing: "整理中…",
312
+ memorySummarizeHint: "不等阈值,立刻把这段尚未摘要的对话交给后台整合。已结清时按钮禁用但保留在原位,下次落后时不必重新找它。",
313
+ memorySummarizeQueued: "已入队:将整理第 {from} – {to} 条事件;摘要在后台由插件的专用模型路由写入,不会占用本会话的上下文。",
314
+ memorySummarizeEmpty: "已结清,没有需要整理的内容。",
315
+ memorySummarizeBusy: "同样的区间已有一次整合在队列中,无需重复触发。",
316
+ memorySummarizeDisabled: "整合功能已在设置中关闭。",
317
+ memorySummarizeUnavailable: "整合不可用:当前组合没有挂载模型路由。",
318
+ memorySummarizeUnknown: "这个会话已经不在内存中,无法整理。",
319
+ memorySummarizeUnknownReason: "未能整理:{reason}",
320
+ memorySummarizeFailed: "无法触发整理:{message}",
310
321
  memoryPending: "待整合内容",
311
322
  memoryPendingValue: "约 {tokens} token",
312
323
  memorySessionNode: "会话节点",
@@ -407,6 +418,17 @@ window.__ModuleLoader__.load({
407
418
  memoryConsolidated: "Summarisation",
408
419
  memoryCaughtUp: "caught up",
409
420
  memoryBehind: "{behind} events behind the log",
421
+ memorySummarize: "Summarise now",
422
+ memorySummarizing: "Summarising…",
423
+ 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.",
424
+ 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.",
425
+ memorySummarizeEmpty: "Nothing to summarise: the log is already consolidated.",
426
+ memorySummarizeBusy: "A consolidation covering the same span is already queued.",
427
+ memorySummarizeDisabled: "Consolidation is turned off in the settings.",
428
+ memorySummarizeUnavailable: "Consolidation is unavailable: this composition mounted no model route for it.",
429
+ memorySummarizeUnknown: "That session is no longer in memory, so it cannot be consolidated.",
430
+ memorySummarizeUnknownReason: "Could not consolidate: {reason}",
431
+ memorySummarizeFailed: "Could not start consolidation: {message}",
410
432
  memoryPending: "Waiting to summarise",
411
433
  memoryPendingValue: "~{tokens} tokens",
412
434
  memorySessionNode: "Session node",
@@ -1341,6 +1363,22 @@ window.__ModuleLoader__.load({
1341
1363
  /** Route prefix the Host claims; the tab appends `/session`. */
1342
1364
  const MEMORY_API_PREFIX = "/api/dsh-hypatia-auto-memory";
1343
1365
  /**
1366
+ * Why an explicit consolidation request queued nothing, matching the Host's
1367
+ * `SUMMARIZE_REASON` vocabulary (src/consolidator.js). Kept as named constants
1368
+ * so the view's switch and the Host's answers cannot drift apart on a typo.
1369
+ */
1370
+ const SUMMARIZE_REASON = {
1371
+ disabled: "disabled",
1372
+ empty: "empty",
1373
+ busy: "busy",
1374
+ unknownSession: "unknown-session",
1375
+ unavailable: "unavailable"
1376
+ };
1377
+ /** Whether "summarise now" has work to do for this session. */
1378
+ function canSummarize(session) {
1379
+ return session.known && !session.caughtUp;
1380
+ }
1381
+ /**
1344
1382
  * The URL one refresh reads.
1345
1383
  * @param sessionId - the session the tab is bound to.
1346
1384
  * @param content - whether to ask the Host to read the shelf as well.
@@ -1368,6 +1406,59 @@ window.__ModuleLoader__.load({
1368
1406
  return payload;
1369
1407
  };
1370
1408
  /**
1409
+ * The URL one explicit consolidation request POSTs to.
1410
+ * @param sessionId - the session to consolidate.
1411
+ * @returns a same-origin path with its query.
1412
+ */
1413
+ function summarizeUrl(sessionId) {
1414
+ const query = new URLSearchParams({ session: sessionId });
1415
+ return `${MEMORY_API_PREFIX}/consolidate?${query.toString()}`;
1416
+ }
1417
+ /**
1418
+ * Ask the Host to consolidate one session now.
1419
+ *
1420
+ * `POST`, never `GET`: a write must not be reachable from a link or an image
1421
+ * tag. The Host keeps the same self-authentication on this route as on the read
1422
+ * ones (loopback socket, loopback `Host`, same-origin), and the body is empty —
1423
+ * everything the request means is in the URL.
1424
+ */
1425
+ const summarizeMemory = async (sessionId) => {
1426
+ const response = await fetch(summarizeUrl(sessionId), {
1427
+ method: "POST",
1428
+ credentials: "same-origin",
1429
+ headers: { accept: "application/json" }
1430
+ });
1431
+ const body = await response.json().catch(() => void 0);
1432
+ if (!response.ok) {
1433
+ const message = isRecord(body) && typeof body.error === "string" ? body.error : `HTTP ${String(response.status)}`;
1434
+ throw new Error(message);
1435
+ }
1436
+ const result = parseSummarizeResult(body, sessionId);
1437
+ if (result === void 0) throw new Error("the Host returned an unreadable consolidation answer");
1438
+ return result;
1439
+ };
1440
+ /**
1441
+ * Read the Host's consolidation answer, defensively.
1442
+ *
1443
+ * A body without a boolean `queued` is unreadable rather than "not queued": the
1444
+ * whole point of the answer is to distinguish "accepted" from "nothing to do",
1445
+ * and guessing would put a wrong sentence in front of the user.
1446
+ *
1447
+ * @param value - the parsed response body.
1448
+ * @param sessionId - the session that was asked for.
1449
+ * @returns the result, or undefined when the body is not one.
1450
+ */
1451
+ function parseSummarizeResult(value, sessionId) {
1452
+ if (!isRecord(value) || typeof value.queued !== "boolean") return void 0;
1453
+ return {
1454
+ sessionId: str(value.sessionId) || sessionId,
1455
+ queued: value.queued,
1456
+ reason: str(value.reason),
1457
+ fromSeq: num(value.fromSeq),
1458
+ toSeq: num(value.toSeq)
1459
+ };
1460
+ }
1461
+ /**
1371
1462
  * Fetch the shelf listing for the settings card.
1372
1463
  *
1373
1464
  * Rides the same self-authenticating route family as the Memory tab — the
@@ -1531,7 +1622,7 @@ window.__ModuleLoader__.load({
1531
1622
  }
1532
1623
  //#endregion
1533
1624
  //#region \0dsh-hypatia-css:/home/runner/work/dsh-hypatia-auto-memory/dsh-hypatia-auto-memory/src/client/MemoryView.module.css.mjs
1534
- const css = ".LBuOzG_panel{box-sizing:border-box;width:100%;height:100%;min-height:0;padding:16px;padding-bottom:calc(var(--dsh-composer-height,152px) + 16px);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);flex-direction:column;align-items:center;display:flex;overflow-y:auto}.LBuOzG_column{flex-direction:column;gap:20px;width:100%;min-width:0;max-width:880px;display:flex}.LBuOzG_head{justify-content:space-between;align-items:baseline;gap:12px;display:flex}.LBuOzG_title{color:var(--dsw-alias-label-primary);margin:0;font-size:15px;font-weight:600;line-height:1.4}.LBuOzG_headMeta{align-items:center;gap:10px;display:flex}.LBuOzG_updated{color:var(--dsw-alias-label-tertiary);font-size:12px}.LBuOzG_refresh{appearance:none;border:.5px solid var(--dsw-alias-border-l4);color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;background:0 0;border-radius:8px;padding:2px 8px;font-size:12px}.LBuOzG_refresh:hover{border-color:var(--dsw-alias-label-dimmed);color:var(--dsw-alias-label-primary)}.LBuOzG_refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.LBuOzG_status,.LBuOzG_hint,.LBuOzG_error{margin:0;font-size:12px;line-height:1.6}.LBuOzG_status,.LBuOzG_hint{color:var(--dsw-alias-label-tertiary)}.LBuOzG_error{color:var(--dsw-alias-label-error)}.LBuOzG_group{border-top:.5px solid var(--dsw-alias-border-l2);flex-direction:column;gap:10px;padding-top:16px;display:flex}.LBuOzG_groupTitle{color:var(--dsw-alias-label-primary);align-items:baseline;gap:8px;margin:0;font-size:13px;font-weight:600;display:flex}.LBuOzG_count{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:11px;font-weight:400}.LBuOzG_rows{grid-template-columns:max-content minmax(0,1fr);align-items:baseline;gap:7px 20px;margin:0;display:grid}.LBuOzG_row{display:contents}.LBuOzG_label{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px}.LBuOzG_value{min-width:0;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;align-items:baseline;gap:8px;margin:0;font-size:12px;display:flex}.LBuOzG_calm,.LBuOzG_busy{white-space:nowrap;border-radius:6px;padding:1px 6px;font-size:11px}.LBuOzG_calm{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2)}.LBuOzG_busy{color:var(--dsw-alias-label-primary);border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3)}.LBuOzG_failures{gap:6px;margin:0;padding:0;list-style:none;display:grid}.LBuOzG_failure{background:var(--dsw-alias-bg-layer-2);border-radius:8px;gap:2px;padding:8px 10px;display:grid}.LBuOzG_failureKind{color:var(--dsw-alias-label-primary);font-size:12px}.LBuOzG_failureError{color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;font-size:11px;line-height:1.5}.LBuOzG_entries{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.LBuOzG_entry{border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;overflow:hidden}.LBuOzG_entryHead{appearance:none;width:100%;color:inherit;cursor:pointer;font:inherit;text-align:left;background:0 0;border:0;justify-content:space-between;align-items:baseline;gap:12px;padding:8px 12px;display:flex}.LBuOzG_entryHead:hover{background:var(--dsw-alias-bg-layer-3)}.LBuOzG_entryHead:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.LBuOzG_entryName{min-width:0;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family-mono,ui-monospace, SFMono-Regular, Menlo, monospace);text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.LBuOzG_entryMeta{flex:none;align-items:baseline;gap:8px;display:flex}.LBuOzG_badge{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary);white-space:nowrap;border-radius:6px;padding:1px 6px;font-size:11px}.LBuOzG_entryTime{color:var(--dsw-alias-label-quaternary);font-variant-numeric:tabular-nums;white-space:nowrap;font-size:11px}.LBuOzG_entryToggle{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:11px}.LBuOzG_entryBody{border-top:.5px solid var(--dsw-alias-border-l2);overflow-wrap:anywhere;padding:10px 12px;font-size:13px;line-height:1.65}.LBuOzG_entryPreview{color:var(--dsw-alias-label-tertiary);text-overflow:ellipsis;white-space:nowrap;margin:0;padding:0 12px 10px;font-size:12px;line-height:1.5;overflow:hidden}.LBuOzG_hint{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:12px}";
1625
+ const css = ".LBuOzG_panel{box-sizing:border-box;width:100%;height:100%;min-height:0;padding:16px;padding-bottom:calc(var(--dsh-composer-height,152px) + 16px);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);flex-direction:column;align-items:center;display:flex;overflow-y:auto}.LBuOzG_column{flex-direction:column;gap:20px;width:100%;min-width:0;max-width:880px;display:flex}.LBuOzG_head{justify-content:space-between;align-items:baseline;gap:12px;display:flex}.LBuOzG_title{color:var(--dsw-alias-label-primary);margin:0;font-size:15px;font-weight:600;line-height:1.4}.LBuOzG_headMeta{align-items:center;gap:10px;display:flex}.LBuOzG_updated{color:var(--dsw-alias-label-tertiary);font-size:12px}.LBuOzG_refresh{appearance:none;border:.5px solid var(--dsw-alias-border-l4);color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;background:0 0;border-radius:8px;padding:2px 8px;font-size:12px}.LBuOzG_refresh:hover{border-color:var(--dsw-alias-label-dimmed);color:var(--dsw-alias-label-primary)}.LBuOzG_refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.LBuOzG_status,.LBuOzG_hint,.LBuOzG_error,.LBuOzG_notice{margin:0;font-size:12px;line-height:1.6}.LBuOzG_status,.LBuOzG_hint{color:var(--dsw-alias-label-tertiary)}.LBuOzG_error{color:var(--dsw-alias-label-error)}.LBuOzG_notice{color:var(--dsw-alias-label-secondary)}.LBuOzG_group{border-top:.5px solid var(--dsw-alias-border-l2);flex-direction:column;gap:10px;padding-top:16px;display:flex}.LBuOzG_groupTitle{color:var(--dsw-alias-label-primary);align-items:baseline;gap:8px;margin:0;font-size:13px;font-weight:600;display:flex}.LBuOzG_count{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;font-size:11px;font-weight:400}.LBuOzG_rows{grid-template-columns:max-content minmax(0,1fr);align-items:baseline;gap:7px 20px;margin:0;display:grid}.LBuOzG_row{display:contents}.LBuOzG_label{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px}.LBuOzG_value{min-width:0;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;align-items:baseline;gap:8px;margin:0;font-size:12px;display:flex}.LBuOzG_calm,.LBuOzG_busy{white-space:nowrap;border-radius:6px;padding:1px 6px;font-size:11px}.LBuOzG_calm{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2)}.LBuOzG_busy{color:var(--dsw-alias-label-primary);border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3)}.LBuOzG_summarize{appearance:none;border:.5px solid var(--dsw-alias-border-l4);color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;white-space:nowrap;background:0 0;border-radius:6px;padding:1px 8px;font-size:11px}.LBuOzG_summarize:hover:enabled{border-color:var(--dsw-alias-label-dimmed);color:var(--dsw-alias-label-primary)}.LBuOzG_summarize:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}.LBuOzG_summarize:disabled{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-dimmed);cursor:default}.LBuOzG_failures{gap:6px;margin:0;padding:0;list-style:none;display:grid}.LBuOzG_failure{background:var(--dsw-alias-bg-layer-2);border-radius:8px;gap:2px;padding:8px 10px;display:grid}.LBuOzG_failureKind{color:var(--dsw-alias-label-primary);font-size:12px}.LBuOzG_failureError{color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;font-size:11px;line-height:1.5}.LBuOzG_entries{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}.LBuOzG_entry{border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;overflow:hidden}.LBuOzG_entryHead{appearance:none;width:100%;color:inherit;cursor:pointer;font:inherit;text-align:left;background:0 0;border:0;justify-content:space-between;align-items:baseline;gap:12px;padding:8px 12px;display:flex}.LBuOzG_entryHead:hover{background:var(--dsw-alias-bg-layer-3)}.LBuOzG_entryHead:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.LBuOzG_entryName{min-width:0;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family-mono,ui-monospace, SFMono-Regular, Menlo, monospace);text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.LBuOzG_entryMeta{flex:none;align-items:baseline;gap:8px;display:flex}.LBuOzG_badge{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary);white-space:nowrap;border-radius:6px;padding:1px 6px;font-size:11px}.LBuOzG_entryTime{color:var(--dsw-alias-label-quaternary);font-variant-numeric:tabular-nums;white-space:nowrap;font-size:11px}.LBuOzG_entryToggle{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:11px}.LBuOzG_entryBody{border-top:.5px solid var(--dsw-alias-border-l2);overflow-wrap:anywhere;padding:10px 12px;font-size:13px;line-height:1.65}.LBuOzG_entryPreview{color:var(--dsw-alias-label-tertiary);text-overflow:ellipsis;white-space:nowrap;margin:0;padding:0 12px 10px;font-size:12px;line-height:1.5;overflow:hidden}.LBuOzG_hint{border-top:.5px solid var(--dsw-alias-border-l2);padding-top:12px}";
1535
1626
  const tagId = "dsh-hypatia-auto-memory/MemoryView.module.css";
1536
1627
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
1537
1628
  const tag = document.createElement("style");
@@ -1566,11 +1657,13 @@ window.__ModuleLoader__.load({
1566
1657
  "headMeta": "LBuOzG_headMeta",
1567
1658
  "hint": "LBuOzG_hint",
1568
1659
  "label": "LBuOzG_label",
1660
+ "notice": "LBuOzG_notice",
1569
1661
  "panel": "LBuOzG_panel",
1570
1662
  "refresh": "LBuOzG_refresh",
1571
1663
  "row": "LBuOzG_row",
1572
1664
  "rows": "LBuOzG_rows",
1573
1665
  "status": "LBuOzG_status",
1666
+ "summarize": "LBuOzG_summarize",
1574
1667
  "title": "LBuOzG_title",
1575
1668
  "updated": "LBuOzG_updated",
1576
1669
  "value": "LBuOzG_value"
@@ -1598,6 +1691,48 @@ window.__ModuleLoader__.load({
1598
1691
  */
1599
1692
  /** How often the tab re-reads the status half while it is on screen. */
1600
1693
  const POLL_MS = 5e3;
1694
+ /**
1695
+ * Render the Host's answer to one request.
1696
+ *
1697
+ * "Nothing to do" and "already queued" are not failures and do not read as one:
1698
+ * the request was answered truthfully. Only a refusal — the feature is off, the
1699
+ * route is missing, the session is gone — is shown as an error.
1700
+ */
1701
+ function noticeFor(result, t) {
1702
+ if (result.queued) return {
1703
+ kind: "ok",
1704
+ text: t("memorySummarizeQueued", {
1705
+ from: String(result.fromSeq),
1706
+ to: String(result.toSeq)
1707
+ })
1708
+ };
1709
+ switch (result.reason) {
1710
+ case SUMMARIZE_REASON.empty: return {
1711
+ kind: "ok",
1712
+ text: t("memorySummarizeEmpty")
1713
+ };
1714
+ case SUMMARIZE_REASON.busy: return {
1715
+ kind: "ok",
1716
+ text: t("memorySummarizeBusy")
1717
+ };
1718
+ case SUMMARIZE_REASON.disabled: return {
1719
+ kind: "error",
1720
+ text: t("memorySummarizeDisabled")
1721
+ };
1722
+ case SUMMARIZE_REASON.unavailable: return {
1723
+ kind: "error",
1724
+ text: t("memorySummarizeUnavailable")
1725
+ };
1726
+ case SUMMARIZE_REASON.unknownSession: return {
1727
+ kind: "error",
1728
+ text: t("memorySummarizeUnknown")
1729
+ };
1730
+ default: return {
1731
+ kind: "error",
1732
+ text: t("memorySummarizeUnknownReason", { reason: result.reason })
1733
+ };
1734
+ }
1735
+ }
1601
1736
  /** Local wall-clock stamp for the freshness line. */
1602
1737
  function formatTime(epochMs) {
1603
1738
  if (!Number.isFinite(epochMs) || epochMs <= 0) return "—";
@@ -1664,10 +1799,14 @@ window.__ModuleLoader__.load({
1664
1799
  })]
1665
1800
  });
1666
1801
  }
1667
- function MemoryView({ sessionId, fetch, t }) {
1802
+ function MemoryView({ sessionId, fetch, summarize, t }) {
1668
1803
  const [payload, setPayload] = (0, react.useState)(void 0);
1669
1804
  const [loading, setLoading] = (0, react.useState)(true);
1670
1805
  const [error, setError] = (0, react.useState)("");
1806
+ /** True while the Host is being asked to queue a consolidation. */
1807
+ const [summarizing, setSummarizing] = (0, react.useState)(false);
1808
+ /** The last explicit request's answer, cleared when the session switches. */
1809
+ const [notice, setNotice] = (0, react.useState)(void 0);
1671
1810
  /**
1672
1811
  * The entries the reader opened, by name. Absent means closed, which is the
1673
1812
  * default: a toggle survives the poll that replaces the payload, and an entry
@@ -1716,6 +1855,7 @@ window.__ModuleLoader__.load({
1716
1855
  setPayload(void 0);
1717
1856
  setOverrides({});
1718
1857
  setContentAt(void 0);
1858
+ setNotice(void 0);
1719
1859
  setLoading(true);
1720
1860
  load(true);
1721
1861
  }, [load, sessionId]);
@@ -1741,6 +1881,39 @@ window.__ModuleLoader__.load({
1741
1881
  [name]: !open
1742
1882
  }));
1743
1883
  }, []);
1884
+ /**
1885
+ * Ask the Host to consolidate this session now.
1886
+ *
1887
+ * Only queues: the summary is written in the background, so the answer says
1888
+ * what was accepted rather than what was written. A queued request refreshes
1889
+ * the status so the "behind" figure catches up with the next poll; the
1890
+ * watermark watch above then re-reads the shelf when consolidation lands.
1891
+ */
1892
+ const onSummarize = (0, react.useCallback)(() => {
1893
+ setSummarizing(true);
1894
+ setNotice(void 0);
1895
+ (async () => {
1896
+ try {
1897
+ const result = await summarize(sessionId);
1898
+ if (activeSession.current !== sessionId) return;
1899
+ setNotice(noticeFor(result, t));
1900
+ if (result.queued) load(false);
1901
+ } catch (failure) {
1902
+ if (activeSession.current !== sessionId) return;
1903
+ setNotice({
1904
+ kind: "error",
1905
+ text: t("memorySummarizeFailed", { message: failure instanceof Error ? failure.message : String(failure) })
1906
+ });
1907
+ } finally {
1908
+ if (activeSession.current === sessionId) setSummarizing(false);
1909
+ }
1910
+ })();
1911
+ }, [
1912
+ load,
1913
+ sessionId,
1914
+ summarize,
1915
+ t
1916
+ ]);
1744
1917
  const session = payload?.session;
1745
1918
  const content = payload?.content;
1746
1919
  const summaries = content?.summaries ?? [];
@@ -1790,6 +1963,11 @@ window.__ModuleLoader__.load({
1790
1963
  role: "status",
1791
1964
  children: t("memoryLoadFailed", { message: error })
1792
1965
  }) : null,
1966
+ notice !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1967
+ className: notice.kind === "ok" ? MemoryView_module_css_default.notice : MemoryView_module_css_default.error,
1968
+ role: "status",
1969
+ children: notice.text
1970
+ }) : null,
1793
1971
  loading && payload === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1794
1972
  className: MemoryView_module_css_default.status,
1795
1973
  children: t("memoryLoading")
@@ -1822,10 +2000,21 @@ window.__ModuleLoader__.load({
1822
2000
  children: t("memoryConsolidated")
1823
2001
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("dd", {
1824
2002
  className: MemoryView_module_css_default.value,
1825
- children: [t("memorySeqValue", { seq: String(session.consolidatedSeq) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1826
- className: session.caughtUp ? MemoryView_module_css_default.calm : MemoryView_module_css_default.busy,
1827
- children: session.caughtUp ? t("memoryCaughtUp") : t("memoryBehind", { behind: String(consolidationGap(session)) })
1828
- })]
2003
+ children: [
2004
+ t("memorySeqValue", { seq: String(session.consolidatedSeq) }),
2005
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2006
+ className: session.caughtUp ? MemoryView_module_css_default.calm : MemoryView_module_css_default.busy,
2007
+ children: session.caughtUp ? t("memoryCaughtUp") : t("memoryBehind", { behind: String(consolidationGap(session)) })
2008
+ }),
2009
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2010
+ type: "button",
2011
+ className: MemoryView_module_css_default.summarize,
2012
+ disabled: summarizing || !canSummarize(session),
2013
+ title: t("memorySummarizeHint"),
2014
+ onClick: onSummarize,
2015
+ children: summarizing ? t("memorySummarizing") : t("memorySummarize")
2016
+ })
2017
+ ]
1829
2018
  })]
1830
2019
  }),
1831
2020
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2146,7 +2335,10 @@ window.__ModuleLoader__.load({
2146
2335
  order: 20,
2147
2336
  locale: NS,
2148
2337
  label: () => t("viewMemory"),
2149
- inject: () => ({ fetch: fetchMemory })
2338
+ inject: () => ({
2339
+ fetch: fetchMemory,
2340
+ summarize: summarizeMemory
2341
+ })
2150
2342
  }, MemoryView));
2151
2343
  ctx.effect(() => () => {
2152
2344
  unregister();