dsh-quote-shot 0.1.1 → 0.2.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 CHANGED
@@ -55,6 +55,46 @@ node install.mjs # 链接进 profile 并写入 dsh.profile.bundles
55
55
 
56
56
  ---
57
57
 
58
+ ## 输入框里是一枚 chip,不是一行指令
59
+
60
+ 点引用按钮后,输入框里出现的是紧凑标签,而不是原来那行长文本:
61
+
62
+ ```
63
+ 〔 引用 · 好,我测试一下,我看了这次改动… 〕
64
+ ```
65
+
66
+ **这是纯外观改动。** chip 的 `label` 显示被引用内容的开头,但底层 `clipboardText` 和序列化结果仍是原来那行标记,所以:
67
+
68
+ - 模型收到的内容不变
69
+ - `quote_read` 行为不变
70
+ - context 成本不变(仍然只传 id)
71
+
72
+ chip 用 DSH 原生的 `inputTriggers.registerSource` 注册。**DSH 的输入框无法装饰纯文本**——要让一段内容显示成标签,只能让它成为编辑器认识的「引用」对象,这是唯一的路。
73
+
74
+ 草稿 API 不可用时会自动退回纯文本插入,引用照常能用。
75
+
76
+ ## 发送后:标记渲染成卡片
77
+
78
+ 发送之后,那行 `[引用消息 <id>——…]` 会在气泡里变成一张卡片:
79
+
80
+ ```
81
+ ┌ “ 引用 1a2b3c4d 展开
82
+ └ (点「展开」后显示被引用消息的正文,最高 220px 可滚动)
83
+ ```
84
+
85
+ - 卡片总是排在你自己说的话**之前**:标记插入后会被移到草稿顶端
86
+ - **点卡片主体 = 瞬间跳回原消息**,并在那一块下方画一条梭形横线(两端淡出,约 1.6 秒后消失)
87
+ - **点「展开」= 取正文**(`POST /quote-shot/message`),不预取——否则一屏十几条引用就是十几个请求
88
+ - 取正文和 `quote_read` 工具共用同一套三级查找(`resolveMessageText`),不会出现两边行为不一致
89
+
90
+ **跳转靠点击引用时记下的 DOM 锚点**(`data-chat-anchor-key`,存在 localStorage,最多 200 条)——DSH 的消息 DOM 上没有 message id,只有这个锚点。跳转是**瞬间定位**(不是平滑滚动):跳几千上万像素时,滚动动画耗时比测量窗口还长,测到的会是滚动前的坐标。
91
+
92
+ **滚出加载区的消息不在 DOM 里**(列表虚拟化),这时卡片提示「原消息不在当前视图」,不会假装滚动。刷新页面后锚点仍在,仍可跳转。
93
+
94
+ **标记是一条梭形横线,不是一个框**:外层描边会把「引用按钮所在的那一块」框住——那通常是这一轮的文件变更卡片,而不是消息本身。横线只回答「是这一带」,且用固定定位浮层绘制,不往宿主节点上写任何 class 或 style。
95
+
96
+ **这只是「人眼看到什么」的改变**:模型收到的仍然是那行标记,仍然需要调用 `quote_read` 才能读到正文。卡片不省 token,也不改变模型行为。
97
+
58
98
  ## 实现
59
99
 
60
100
  ### 链路
@@ -128,7 +168,7 @@ ctx.tools.register({
128
168
  })
129
169
  ```
130
170
 
131
- ### 引用功能本身的七个坑
171
+ ### 引用功能本身的九个坑
132
172
 
133
173
  | # | 坑 | 后果 | 教训 |
134
174
  |---|---|---|---|
@@ -139,6 +179,8 @@ ctx.tools.register({
139
179
  | 5 | 「第一个够长就返回」 | 抓到路径碎片(87 字) | 取**最外层**候选,不是最近的 |
140
180
  | 6 | 用 `useInput` 的 `phase` 判断发送 | 那个字段不存在 | **未验证的 API 假设 = 建在沙子上** |
141
181
  | 7 | **`innerText ?? textContent`** | **0.2.2 后引用全空** | 见下,这是最隐蔽的一个 |
182
+ | 8 | 拿「最后一个 live agent」当调用者 | 后台 subagent 一活跃就 `not-found` | **live agent ≠ 调用者**;后台 reviewer 也是活 agent |
183
+ | 9 | 只认 `session-<uuid>` 会话目录 | 大部分会话读不到 | DSH 两种目录命名并存,裸 uuid 也要试 |
142
184
 
143
185
  ### ⚠️ 坑 7 展开:`innerText` 对未渲染元素返回 `""`,不是 `null`
144
186
 
@@ -220,11 +262,19 @@ quote_read(messageId) → { found, messageId, text }
220
262
  - 会话日志是**多帧 zstd**(每次追加一帧),普通解压只出第一帧,必须扫 magic `28 B5 2F FD` 逐帧解开
221
263
  - 消息 id 位置:`user/message → data.id`,`assistant/message → data.message.id`
222
264
  - 同一 id 可能跨多条事件(流式分片),文本要**累加**而非覆盖
223
- - 按文件 `mtimeMs` 缓存索引,会话增长时才重建
224
- - 工具拿不到 sessionId(`exec` 只带 `signal`),用 `ctx.agents.list()` 取**最近注册的活跃 agent** 反查
265
+ - 按**文件路径**缓存索引(同一日志可能被两种目录名指向),文件增长时才重建
266
+ - 会话目录有两种命名:`session-<uuid>` 与裸 `<uuid>`,**两种都要试**
267
+ - 工具拿不到 sessionId(`exec` 只带 `signal`),所以按三级定位:① 客户端点击时上报的 `messageId → sessionId`;② 遍历**全部** live agent;③ 扫最近 25 个会话日志
225
268
 
226
269
  **已验证**:`quote_read('eb3fc73f-...')` 返回 113 字符原文,与日志里的 `len=113` 一致。
227
270
 
271
+ **回归验证**:同一个 message id(`02dc5192-...`)在修复前返回 `not-found` 且日志无记录;修复后日志为
272
+
273
+ ```
274
+ hint message=02dc5192-... session=session-1db3d041-...
275
+ quote_read session=session-1db3d041-... message=02dc5192-... len=894 via=hint
276
+ ```
277
+
228
278
  ## 开发
229
279
 
230
280
  ```sh
@@ -291,10 +341,10 @@ node install.mjs # 安装 / 刷新
291
341
  | **裸标记触发** | ❌ **0 次**(设计缺陷) |
292
342
  | **显式指令标记触发** | ✅ 调用并读到 2318 字符 |
293
343
 
294
- **未验证的风险**(重要):
344
+ **风险与边界**:
295
345
 
296
346
  1. **模型可能不调**——这次是因为提问直接指向"引用的内容",工具是唯一手段。如果引用只是背景("按上面说的改一下"),模型可能凭印象作答而跳过工具。
297
- 2. **多会话可能读错**——工具拿不到 sessionId,用 `ctx.agents.list()` 取**最近注册的活跃 agent** 反查。**多个对话同时活跃时可能读到错的那个**。
347
+ 2. **极冷的会话仍可能读不到**——三级定位覆盖「点击时上报的会话」「存活会话」「最近 25 个日志」。如果引用的是一条更早、更冷会话里的消息,扫盘窗口没覆盖到,仍会 `not-found`(此时日志会写明 `MISS` 和试过哪些会话)。
298
348
 
299
349
  ---
300
350
 
@@ -326,7 +376,7 @@ node install.mjs # 安装 / 刷新
326
376
 
327
377
  - **草稿多一行标记**:`[引用消息 xxx]` 会出现在输入框里,发送后成为消息正文的一部分。
328
378
  - **模型可能不调工具**:见上,引用可能被忽略。
329
- - **多会话可能读错**:工具用「最近活跃 agent」反查会话,多对话并行时不保险。
379
+ - **极冷的会话读不到**:定位覆盖点击时上报的会话、存活会话、最近 25 个日志;更早且未在窗口内的会话会 `not-found`。
330
380
  - **抓取的是「当时的 DOM」**:卡片预览是点击瞬间的快照。
331
381
  - **引用后不打字直接发送**:卡片不会自动收起(检测不到发送)。
332
382
  - **排版丢失**:DOM 提取只取 `innerText`,代码块/表格会变成纯文本。
package/client.js CHANGED
@@ -15,6 +15,8 @@ window.__ModuleLoader__.load({
15
15
  factory: (require) => {
16
16
  var module = { exports: {} };
17
17
  var exports = module.exports;
18
+ /** Plugin context, for the components that need services outside a slot. */
19
+ let pluginCtx = null;
18
20
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
19
21
 
20
22
  const react = require('react');
@@ -42,6 +44,8 @@ window.__ModuleLoader__.load({
42
44
  const CARD_PREVIEW_CHARS = 60;
43
45
  /** Host route that performs the injection. */
44
46
  const INJECT_ROUTE = '/quote-shot/inject';
47
+ /** Host route where the click's session is reported for `quote_read`. */
48
+ const HINT_ROUTE = '/quote-shot/hint';
45
49
 
46
50
  //#region quote state
47
51
  /**
@@ -112,6 +116,31 @@ window.__ModuleLoader__.load({
112
116
  /* ignored on purpose */
113
117
  }
114
118
  }
119
+ /**
120
+ * Tell the Host which session this quote id belongs to.
121
+ *
122
+ * `quote_read` runs later with no session context, and the live-agent
123
+ * registry cannot be trusted to point at the caller (background subagents
124
+ * are live agents too). The click is the one moment the id and the session
125
+ * are both known, so it is recorded here.
126
+ *
127
+ * Fire-and-forget: a lost hint only costs the Host a slower fallback lookup.
128
+ *
129
+ * @param messageId - the quoted message id.
130
+ * @param sessionId - the session the button was clicked in.
131
+ */
132
+ function postHint(messageId, sessionId) {
133
+ try {
134
+ fetch(HINT_ROUTE, {
135
+ method: 'POST',
136
+ credentials: 'same-origin',
137
+ headers: { 'content-type': 'application/json' },
138
+ body: JSON.stringify({ messageId, sessionId })
139
+ }).catch(() => {});
140
+ } catch {
141
+ /* ignored on purpose */
142
+ }
143
+ }
115
144
  //#endregion
116
145
 
117
146
  /**
@@ -128,6 +157,281 @@ window.__ModuleLoader__.load({
128
157
  * @param anchor - the clicked button element.
129
158
  * @returns the text, or an empty string when nothing usable was found.
130
159
  */
160
+ /**
161
+ * Where a quoted message was on screen, keyed by message id.
162
+ *
163
+ * DSH's message DOM carries no message id — only `data-chat-anchor-key` — so
164
+ * a message id alone cannot find its row. The one moment the row IS known is
165
+ * the quote click, because the button lives inside it; the row's anchor is
166
+ * recorded then and replayed later.
167
+ *
168
+ * Global rather than per-session: ids are UUIDs so they cannot collide, and
169
+ * the sent-message card has no session id available.
170
+ */
171
+ const ANCHOR_STORE = 'dsh-quote-shot:anchors';
172
+ const ANCHOR_LIMIT = 200;
173
+
174
+ /**
175
+ * Chip source name.
176
+ *
177
+ * DSH renders a composer chip when the draft carries a reference of this
178
+ * source. The chip exists only for APPEARANCE: its label is a short tag, its
179
+ * clipboardText and serialised form are the same marker we have always sent,
180
+ * so the model and `quote_read` see exactly what they saw before.
181
+ */
182
+ const CHIP_SOURCE = 'quote-shot';
183
+
184
+ /**
185
+ * The marker that travels with the message.
186
+ *
187
+ * @param messageId - quoted message id.
188
+ * @returns the instruction line.
189
+ */
190
+ function buildMarker(messageId) {
191
+ return `[引用消息 ${messageId}——请先调用 quote_read 读取全文,再回答]`;
192
+ }
193
+
194
+ /**
195
+ * Insert one quote chip at the very top of the draft.
196
+ *
197
+ * Done as a single `setDraft` rather than insert-then-reorder: a chip has an
198
+ * offset, so moving text after the fact would desynchronise it.
199
+ *
200
+ * @param messageId - quoted message id.
201
+ * @param sessionId - the session owning the composer.
202
+ * @param preview - the quoted text, used for the chip's label.
203
+ * @returns true when the draft was rewritten.
204
+ */
205
+ function insertQuoteChip(messageId, sessionId, preview) {
206
+ const actx = pluginCtx?.sessions?.scope?.(sessionId);
207
+ const input = actx === undefined || actx === null ? undefined : pluginCtx?.conversation?.input?.for?.(actx);
208
+ const snapshot = input?.draftSnapshot;
209
+ if (snapshot === undefined || snapshot === null || typeof snapshot.text !== 'string') return false;
210
+
211
+ const marker = buildMarker(messageId);
212
+ const added = `${marker}\n`;
213
+ // An id alone tells the reader nothing; the opening words do.
214
+ const flat = String(preview ?? '').replace(/\s+/g, ' ').trim();
215
+ const label =
216
+ flat.length === 0
217
+ ? `引用 ${messageId.slice(0, 8)}`
218
+ : `引用 · ${flat.slice(0, 28)}${flat.length > 28 ? '…' : ''}`;
219
+ const chip = {
220
+ source: CHIP_SOURCE,
221
+ ref: messageId,
222
+ offset: 0,
223
+ length: marker.length,
224
+ label,
225
+ clipboardText: marker
226
+ };
227
+ const references = (Array.isArray(snapshot.references) ? snapshot.references : [])
228
+ .map((reference) => ({ ...reference, offset: reference.offset + added.length }));
229
+ references.push(chip);
230
+ references.sort((a, b) => a.offset - b.offset);
231
+
232
+ input.setDraft({ text: added + snapshot.text, references });
233
+ return true;
234
+ }
235
+
236
+ /**
237
+ * Remember which DOM row a message occupies.
238
+ *
239
+ * Stores the row anchor AND its turn id. The row is NOT a whole message: a
240
+ * reply is several rows (text, tool cards, file-change card) and the quote
241
+ * button lives in the LAST one, so highlighting just that row framed the
242
+ * file card instead of the answer. The turn id lets the jump widen to the
243
+ * whole exchange.
244
+ *
245
+ * @param messageId - quoted message id.
246
+ * @param row - the message row element.
247
+ */
248
+ function rememberAnchor(messageId, row) {
249
+ // Diagnose FIRST: the previous version returned before this ran whenever
250
+ // `closest` missed, which is exactly the case worth seeing. Records the
251
+ // whole ancestor chain (tag, class head, data-* attrs) so the real wrapper
252
+ // can be identified from outside.
253
+ try {
254
+ const chain = [];
255
+ let node = row;
256
+ for (let depth = 0; depth < 10 && node !== null && node !== undefined; depth += 1) {
257
+ const bits = [];
258
+ for (const attr of node.attributes ?? []) {
259
+ if (!attr.name.startsWith('data-')) continue;
260
+ bits.push(`${attr.name}=${String(attr.value).slice(0, 20)}`);
261
+ if (bits.length >= 3) break;
262
+ }
263
+ const cls = String(node.className ?? '').split(/\s+/).filter(Boolean)[0] ?? '';
264
+ chain.push(`${node.tagName?.toLowerCase()}.${cls.slice(0, 22)}[${bits.join(',')}]`);
265
+ node = node.parentElement;
266
+ }
267
+ postLog('anchor-chain', { rowFound: row !== null && row !== undefined, chain });
268
+ } catch {
269
+ /* diagnostics never break the click */
270
+ }
271
+
272
+ const key = row?.getAttribute?.('data-chat-anchor-key');
273
+ if (typeof key !== 'string' || key.length === 0) return;
274
+ const turn = row?.getAttribute?.('data-chat-turn') ?? null;
275
+ try {
276
+ const all = JSON.parse(localStorage.getItem(ANCHOR_STORE) ?? '{}');
277
+ delete all[messageId];
278
+ all[messageId] = { key, turn };
279
+ const ids = Object.keys(all);
280
+ for (const stale of ids.slice(0, Math.max(0, ids.length - ANCHOR_LIMIT))) delete all[stale];
281
+ localStorage.setItem(ANCHOR_STORE, JSON.stringify(all));
282
+ } catch {
283
+ /* storage is best-effort; jumping just degrades */
284
+ }
285
+ }
286
+
287
+ /**
288
+ * The recorded row anchor for a message.
289
+ *
290
+ * Tolerates the older plain-string format.
291
+ *
292
+ * @param messageId - quoted message id.
293
+ * @returns `{ key, turn }`, or null.
294
+ */
295
+ function anchorFor(messageId) {
296
+ try {
297
+ const all = JSON.parse(localStorage.getItem(ANCHOR_STORE) ?? '{}');
298
+ const value = all[messageId];
299
+ if (typeof value === 'string') return { key: value, turn: null };
300
+ if (value !== null && typeof value === 'object' && typeof value.key === 'string') return value;
301
+ return null;
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Scroll to a quoted message and flash it.
309
+ *
310
+ * Highlights the whole turn when the turn id is known, otherwise the row
311
+ * itself. Messages the virtualised list has not rendered are not in the DOM
312
+ * at all; there is no honest way to jump to them, so this reports "not on
313
+ * screen" instead of inventing a scroll.
314
+ *
315
+ * @param messageId - quoted message id.
316
+ * @returns true when something was found and scrolled to.
317
+ */
318
+ /** The flash line currently on screen, so a second jump replaces it. */
319
+ let flashLine = null;
320
+
321
+ /**
322
+ * Draw a spindle line under a row for a moment.
323
+ *
324
+ * A full outline around the row was wrong: it framed whichever block the
325
+ * quote button happens to live in (often a file-change card). A thin line
326
+ * marks the position without claiming a boundary, and it is drawn as a
327
+ * fixed-position overlay so no host node is touched.
328
+ *
329
+ * @param row - the row to underline.
330
+ */
331
+ function flashRow(row) {
332
+ if (flashLine !== null) {
333
+ flashLine.remove();
334
+ flashLine = null;
335
+ }
336
+
337
+ const paint = () => {
338
+ const rect = row.getBoundingClientRect();
339
+ postLog('flash-paint', {
340
+ top: Math.round(rect.top),
341
+ bottom: Math.round(rect.bottom),
342
+ width: Math.round(rect.width),
343
+ height: Math.round(rect.height),
344
+ viewport: window.innerHeight
345
+ });
346
+ if (rect.width === 0 || rect.height === 0) return;
347
+
348
+ // Keep the line inside the viewport: a tall row centred by scrollIntoView
349
+ // can have its bottom below the fold, and a line drawn there is invisible.
350
+ const maxY = window.innerHeight - 14;
351
+ const y = Math.max(12, Math.min(rect.bottom, maxY));
352
+ const line = document.createElement('div');
353
+ line.style.cssText = [
354
+ 'position:fixed',
355
+ 'pointer-events:none',
356
+ 'z-index:2147483000',
357
+ `left:${rect.left + rect.width * 0.06}px`,
358
+ `width:${rect.width * 0.88}px`,
359
+ // Lifted off the row's bottom edge: sitting flush against it made the
360
+ // line read as the divider of the NEXT message.
361
+ `top:${y - 23}px`,
362
+ 'height:3px',
363
+ 'border-radius:2px',
364
+ 'background:linear-gradient(90deg,transparent 0%,#4d6bfe 18%,#4d6bfe 82%,transparent 100%)',
365
+ 'box-shadow:0 0 8px rgba(77,107,254,0.45)',
366
+ 'opacity:0',
367
+ 'transition:opacity 220ms ease'
368
+ ].join(';');
369
+ document.body.appendChild(line);
370
+ flashLine = line;
371
+ requestAnimationFrame(() => {
372
+ line.style.opacity = '1';
373
+ });
374
+ setTimeout(() => {
375
+ line.style.opacity = '0';
376
+ }, 1600);
377
+ setTimeout(() => {
378
+ line.remove();
379
+ if (flashLine === line) flashLine = null;
380
+ }, 2000);
381
+ };
382
+
383
+ // Wait until the row is actually INSIDE the viewport before measuring.
384
+ // Position stability alone is not enough: a scroll that has not started
385
+ // yet is perfectly stable, and measuring then draws the line off-screen.
386
+ let frames = 0;
387
+ const settle = () => {
388
+ frames += 1;
389
+ const rect = row.getBoundingClientRect();
390
+ const inView = rect.bottom > 8 && rect.top < window.innerHeight - 8;
391
+ if (inView && frames >= 2) {
392
+ paint();
393
+ return;
394
+ }
395
+ if (frames > 60) {
396
+ paint();
397
+ return;
398
+ }
399
+ requestAnimationFrame(settle);
400
+ };
401
+ requestAnimationFrame(settle);
402
+ }
403
+
404
+ function jumpToMessage(messageId) {
405
+ const record = anchorFor(messageId);
406
+ if (record === null) {
407
+ postLog('jump-miss', { messageId, reason: 'no-anchor' });
408
+ return false;
409
+ }
410
+
411
+ // Back to the behaviour that demonstrably worked: find the recorded row
412
+ // verbatim. The turn-prefix widening is gone until the row it lands on is
413
+ // understood; a broken jump is worse than an imprecise one.
414
+ let row = null;
415
+ try {
416
+ row = document.querySelector(`[data-chat-anchor-key="${CSS.escape(record.key)}"]`);
417
+ } catch (error) {
418
+ postLog('jump-miss', { messageId, reason: 'selector-error', detail: String((error && error.message) || error) });
419
+ return false;
420
+ }
421
+ if (row === null) {
422
+ postLog('jump-miss', { messageId, reason: 'row-gone', key: record.key });
423
+ return false;
424
+ }
425
+
426
+ // `behavior: 'auto'` on purpose. Smooth scrolling a jump of tens of
427
+ // thousands of pixels takes longer than the measuring window, and the line
428
+ // was then painted at a stale position (measured top: -19816).
429
+ row.scrollIntoView({ behavior: 'auto', block: 'center' });
430
+ flashRow(row);
431
+ postLog('jump-ok', { messageId, key: record.key });
432
+ return true;
433
+ }
434
+
131
435
  function readQuoteText(anchor) {
132
436
  try {
133
437
  const selection = window.getSelection();
@@ -404,6 +708,10 @@ window.__ModuleLoader__.load({
404
708
  }
405
709
 
406
710
  const text = readQuoteText(event.currentTarget);
711
+ // Record which row this message occupies: the message DOM carries no
712
+ // message id, and the sent card needs a way back. Done here because the
713
+ // button sits inside that row.
714
+ rememberAnchor(messageId, event.currentTarget.closest('[data-chat-anchor-key]'));
407
715
  if (text.length === 0) {
408
716
  // Ship the DOM shape along with the failure: message markup is not a
409
717
  // contract, so the extraction strategy cannot be fixed from a bare
@@ -427,20 +735,58 @@ window.__ModuleLoader__.load({
427
735
  // A bare `[引用消息 <id>]` failed in real use: the model decided it did
428
736
  // not need the content and answered from memory (measured 0 calls across
429
737
  // 3 quotes). Quoting means "look at this", so the marker now says so.
430
- const marker = `[引用消息 ${messageId}——请先调用 quote_read 读取全文,再回答]`;
738
+ const marker = buildMarker(messageId);
431
739
  setBusy(true);
432
740
  let inserted = false;
741
+ let usedChip = false;
433
742
  try {
434
- inserted = actions.insertText(`${marker}\n`, actions.captureInsertion());
435
- } catch {
743
+ inserted = insertQuoteChip(messageId, sessionId, text);
744
+ usedChip = inserted;
745
+ } catch (error) {
746
+ postLog('chip-insert-failed', { messageId, detail: String((error && error.message) || error) });
436
747
  inserted = false;
437
748
  }
749
+ if (!inserted) {
750
+ // Fallback: plain text. The chip is nicer presentation, not a
751
+ // requirement — quoting must keep working even if the draft API
752
+ // behaves differently on another DSH build.
753
+ try {
754
+ inserted = actions.insertText(`${marker}\n`, actions.captureInsertion());
755
+ } catch {
756
+ inserted = false;
757
+ }
758
+ }
438
759
  setBusy(false);
439
760
  if (inserted !== true) {
440
761
  report('insert-failed', { messageId });
441
762
  return;
442
763
  }
443
764
 
765
+ // Only the plain-text fallback needs reordering; a chip is written at
766
+ // offset 0 directly, and shifting text afterwards would corrupt it.
767
+ if (!usedChip) {
768
+ try {
769
+ const actx = pluginCtx?.sessions?.scope?.(sessionId);
770
+ const input = actx === undefined || actx === null ? undefined : pluginCtx?.conversation?.input?.for?.(actx);
771
+ const snapshot = input?.draftSnapshot;
772
+ const noChips = Array.isArray(snapshot?.references) && snapshot.references.length === 0;
773
+ if (noChips && typeof snapshot.text === 'string') {
774
+ const index = snapshot.text.lastIndexOf(marker);
775
+ const before = index > 0 ? snapshot.text.slice(0, index).replace(/\s+$/, '') : '';
776
+ if (index > 0 && before.length > 0) {
777
+ const after = snapshot.text.slice(index + marker.length);
778
+ input.setDraft({ text: `${marker}\n${before}${after}`, references: [] });
779
+ }
780
+ }
781
+ } catch {
782
+ report('reorder-skipped', { messageId });
783
+ }
784
+ }
785
+
786
+ // Record the id -> session pair before the marker leaves the client, so
787
+ // `quote_read` can resolve the owner instead of guessing a live agent.
788
+ postHint(messageId, sessionId);
789
+
444
790
  replaceQuote(sessionId, {
445
791
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
446
792
  preview: previewOf(text),
@@ -672,14 +1018,229 @@ window.__ModuleLoader__.load({
672
1018
  );
673
1019
  }
674
1020
 
1021
+ /**
1022
+ * The one-line marker this plugin puts in the composer.
1023
+ *
1024
+ * Group 1 is the message id. Kept in sync with the string built in
1025
+ * QuoteActions; the quote em-dash is part of the contract.
1026
+ */
1027
+ const QUOTE_MARK = /\[引用消息\s+([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\s*——[^\]]*\]/g;
1028
+
1029
+ /** Rule id for sent bubbles; must not collide with DSH's own user-text rules. */
1030
+ const CARD_NODE = 'dsh-quote-shot-card';
1031
+
1032
+ /**
1033
+ * userText rule: find one quote marker at or after `from`.
1034
+ *
1035
+ * DSH runs these rules over the text of a SENT user message and lets the
1036
+ * plugin replace the matched span with a React node, which is how a plain
1037
+ * `[引用消息 …]` turns into a card in the bubble.
1038
+ */
1039
+ const quoteRule = {
1040
+ id: CARD_NODE,
1041
+ order: 15,
1042
+ match(text, from) {
1043
+ QUOTE_MARK.lastIndex = from;
1044
+ const m = QUOTE_MARK.exec(text);
1045
+ if (m === null) return undefined;
1046
+ return {
1047
+ id: CARD_NODE,
1048
+ start: m.index,
1049
+ end: m.index + m[0].length,
1050
+ data: { messageId: m[1] }
1051
+ };
1052
+ }
1053
+ };
1054
+
1055
+ /**
1056
+ * The card a sent quote renders as.
1057
+ *
1058
+ * It shows nothing about the quoted text until opened: the marker carries
1059
+ * only an id, and prefetching the body for every card would fire a request
1060
+ * per quote on every render. Opening asks the Host for the text once and
1061
+ * caches it on the component.
1062
+ *
1063
+ * @param props - `node` from the rule (`data.messageId`, `data.text`/`note`).
1064
+ * @returns the card element.
1065
+ */
1066
+ function SentQuoteCard(props) {
1067
+ const node = props.node ?? {};
1068
+ const messageId = String(node?.data?.messageId ?? '');
1069
+ const [open, setOpen] = react.useState(false);
1070
+ const [body, setBody] = react.useState(null);
1071
+ const [state, setState] = react.useState('idle');
1072
+ const [notice, setNotice] = react.useState(null);
1073
+
1074
+ const toggle = async () => {
1075
+ if (open) {
1076
+ setOpen(false);
1077
+ return;
1078
+ }
1079
+ setOpen(true);
1080
+ if (body !== null || state === 'loading') return;
1081
+ setState('loading');
1082
+ try {
1083
+ const response = await fetch('/quote-shot/message', {
1084
+ method: 'POST',
1085
+ credentials: 'same-origin',
1086
+ headers: { 'content-type': 'application/json' },
1087
+ body: JSON.stringify({ messageId })
1088
+ });
1089
+ const payload = await response.json();
1090
+ if (payload.ok === true) {
1091
+ setBody(String(payload.text ?? ''));
1092
+ setState('done');
1093
+ } else {
1094
+ setBody('(找不到这条消息的正文)');
1095
+ setState('missing');
1096
+ }
1097
+ } catch {
1098
+ setBody('(读取失败)');
1099
+ setState('error');
1100
+ }
1101
+ };
1102
+
1103
+ const shortId = messageId.slice(0, 8);
1104
+
1105
+ const jump = () => {
1106
+ if (jumpToMessage(messageId)) return;
1107
+ // The row is gone: the virtualised list dropped it, or the page reloaded
1108
+ // before any anchor was recorded for this id.
1109
+ setNotice('原消息不在当前视图');
1110
+ setTimeout(() => setNotice(null), 2200);
1111
+ };
1112
+
1113
+ const header = h(
1114
+ 'span',
1115
+ {
1116
+ role: 'button',
1117
+ tabIndex: 0,
1118
+ onClick: jump,
1119
+ onKeyDown: (event) => {
1120
+ if (event.key === 'Enter' || event.key === ' ') {
1121
+ event.preventDefault();
1122
+ jump();
1123
+ }
1124
+ },
1125
+ title: '点击回到原消息',
1126
+ style: {
1127
+ display: 'flex',
1128
+ alignItems: 'center',
1129
+ gap: '6px',
1130
+ padding: '3px 8px',
1131
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1132
+ cursor: 'pointer',
1133
+ fontSize: '12px',
1134
+ lineHeight: '16px',
1135
+ color: 'color-mix(in srgb, currentColor 58%, transparent)'
1136
+ }
1137
+ },
1138
+ h('span', { 'aria-hidden': 'true' }, '\u275D'),
1139
+ h('span', null, `引用 ${shortId}`),
1140
+ notice !== null ? h('span', { style: { opacity: 0.85 } }, notice) : null,
1141
+ h(
1142
+ 'span',
1143
+ {
1144
+ role: 'button',
1145
+ tabIndex: 0,
1146
+ onClick: (event) => {
1147
+ event.stopPropagation();
1148
+ toggle();
1149
+ },
1150
+ onKeyDown: (event) => {
1151
+ if (event.key === 'Enter' || event.key === ' ') {
1152
+ event.preventDefault();
1153
+ event.stopPropagation();
1154
+ toggle();
1155
+ }
1156
+ },
1157
+ style: { marginLeft: 'auto', opacity: 0.7, cursor: 'pointer' }
1158
+ },
1159
+ open ? '收起' : state === 'loading' ? '读取中…' : '展开'
1160
+ )
1161
+ );
1162
+
1163
+ const children = [header];
1164
+
1165
+ if (open) {
1166
+ children.push(
1167
+ h(
1168
+ 'span',
1169
+ {
1170
+ key: 'body',
1171
+ style: {
1172
+ display: 'block',
1173
+ marginTop: '6px',
1174
+ padding: '0 8px 4px',
1175
+ maxHeight: '220px',
1176
+ overflowY: 'auto',
1177
+ whiteSpace: 'pre-wrap',
1178
+ overflowWrap: 'anywhere',
1179
+ fontSize: '13px',
1180
+ lineHeight: '1.55',
1181
+ color: 'color-mix(in srgb, currentColor 80%, transparent)'
1182
+ }
1183
+ },
1184
+ state === 'loading' ? '…' : body ?? ''
1185
+ )
1186
+ );
1187
+ }
1188
+
1189
+ return h(
1190
+ 'span',
1191
+ {
1192
+ style: {
1193
+ display: 'block',
1194
+ margin: '2px 0 6px',
1195
+ borderLeft: '2px solid color-mix(in srgb, currentColor 35%, transparent)',
1196
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1197
+ background: 'color-mix(in srgb, currentColor 6%, transparent)',
1198
+ userSelect: 'text'
1199
+ }
1200
+ },
1201
+ ...children
1202
+ );
1203
+ }
1204
+
675
1205
  /**
676
1206
  * Register the dictionaries, the message action and the composer cards.
677
1207
  *
678
1208
  * @param ctx - the client plugin context.
679
1209
  */
680
1210
  function apply(ctx) {
1211
+ pluginCtx = ctx;
681
1212
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'quote-shot: dictionaries');
682
1213
 
1214
+ // The composer chip. It never offers completion candidates — it exists so
1215
+ // the draft can carry a reference this plugin owns, which DSH draws as a
1216
+ // compact tag instead of the raw instruction line.
1217
+ ctx.effect(
1218
+ () =>
1219
+ ctx.inputTriggers.registerSource({
1220
+ trigger: '@',
1221
+ name: CHIP_SOURCE,
1222
+ order: 99,
1223
+ showGroupTitle: false,
1224
+ candidates: () => Promise.resolve([]),
1225
+ onPick: () => undefined,
1226
+ openReference: () => undefined,
1227
+ codec: {
1228
+ clipboardText: (ref) => buildMarker(String(ref)),
1229
+ serialize: (ref) => Promise.resolve(buildMarker(String(ref)))
1230
+ }
1231
+ }),
1232
+ 'quote-shot: chip source'
1233
+ );
1234
+
1235
+ // Sent bubble: the marker becomes a card instead of a run of grey text.
1236
+ ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'quote-shot: user-text rule');
1237
+ ctx.slots.inject('conversation.user.text.node', () =>
1238
+ ctx.slots.register(
1239
+ { name: 'conversation.user.text.node', key: CARD_NODE, priority: 100 },
1240
+ SentQuoteCard
1241
+ )
1242
+ );
1243
+
683
1244
  ctx.slots.inject('conversation.chat.assistant-actions', () =>
684
1245
  ctx.slots.register(
685
1246
  {
@@ -711,9 +1272,10 @@ window.__ModuleLoader__.load({
711
1272
  }
712
1273
 
713
1274
  exports.apply = apply;
714
- exports.inject = ['slots', 'locale'];
1275
+ exports.inject = ['slots', 'locale', 'uiConversation', 'conversation', 'sessions', 'inputTriggers'];
715
1276
  exports.QuoteActions = QuoteActions;
716
1277
  exports.QuoteCard = QuoteCard;
1278
+ exports.SentQuoteCard = SentQuoteCard;
717
1279
  exports.readQuoteText = readQuoteText;
718
1280
  exports.previewOf = previewOf;
719
1281
  exports.injectQuote = injectQuote;
package/index.js CHANGED
@@ -16,7 +16,7 @@ import { appendFileSync } from 'node:fs';
16
16
  import { homedir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
 
19
- import { readMessageById } from './session-reader.js';
19
+ import { readMessageById, readMessageFromFile, listRecentSessionFiles } from './session-reader.js';
20
20
 
21
21
  /**
22
22
  * Diagnostic log path.
@@ -55,7 +55,44 @@ export const inject = ['webServer', 'agents', 'tools'];
55
55
  /** Route owned by this plugin. */
56
56
  const ROUTE = '/quote-shot/inject';
57
57
 
58
- /** Longest accepted quote body, so one call cannot flood the context. */
58
+ /**
59
+ * Route where the client reports which session a quote id belongs to.
60
+ *
61
+ * This is the one piece of knowledge the Host cannot recover reliably: the
62
+ * click happens in a known session, but `quote_read` runs later with no session
63
+ * context at all. Remembering the click's sessionId turns "guess which live
64
+ * agent is calling" into a direct lookup.
65
+ */
66
+ const HINT_ROUTE = '/quote-shot/hint';
67
+
68
+ /**
69
+ * messageId -> sessionId, filled by the client when a quote is placed.
70
+ *
71
+ * Bounded: a long session can quote many messages, and only recent quotes are
72
+ * plausibly still unread. Oldest entries fall out first (Map preserves order).
73
+ */
74
+ const quoteHints = new Map();
75
+ const MAX_HINTS = 200;
76
+
77
+ /**
78
+ * Remember which session a quoted message belongs to.
79
+ *
80
+ * @param messageId - quoted message id.
81
+ * @param sessionId - session the quote button was clicked in.
82
+ */
83
+ function rememberHint(messageId, sessionId) {
84
+ if (messageId.length === 0 || sessionId.length === 0) return;
85
+ quoteHints.delete(messageId);
86
+ quoteHints.set(messageId, sessionId);
87
+ if (quoteHints.size > MAX_HINTS) {
88
+ const oldest = quoteHints.keys().next().value;
89
+ quoteHints.delete(oldest);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Longest accepted quote body, so one call cannot flood the context.
95
+ */
59
96
  const MAX_TEXT_CHARS = 20_000;
60
97
  /** Longest accepted summary line shown in the collapsed row. */
61
98
  const MAX_SUMMARY_CHARS = 200;
@@ -111,6 +148,76 @@ function buildSummary(text) {
111
148
  *
112
149
  * @param ctx - the Host plugin context.
113
150
  */
151
+ /**
152
+ * Locate one message's full text, in three tiers.
153
+ *
154
+ * Shared by the `quote_read` tool (the model) and `/quote-shot/message` (the
155
+ * client, which draws the sent-message card). Both need the same answer, so the
156
+ * search lives here instead of being duplicated.
157
+ *
158
+ * @param ctx - the Host plugin context (for the live-agent registry).
159
+ * @param messageId - the message id to find.
160
+ * @returns `{ text, via, sessionId }`, or null when nothing matched.
161
+ */
162
+ function resolveMessageText(ctx, messageId) {
163
+ const startedAt = Date.now();
164
+ const tried = [];
165
+
166
+ // 1. The session the client reported when the quote was placed. This is the
167
+ // exact answer, and the only step that works when the caller is not the
168
+ // session that owns the message.
169
+ const hinted = quoteHints.get(messageId);
170
+ if (typeof hinted === 'string' && hinted.length > 0) {
171
+ tried.push(hinted);
172
+ const text = readMessageById(hinted, messageId);
173
+ if (text !== null) {
174
+ trace(`lookup session=${hinted} message=${messageId} len=${text.length} via=hint`);
175
+ return { text, via: 'hint', sessionId: hinted };
176
+ }
177
+ }
178
+
179
+ // 2. Every live agent, newest first. Background subagents are live agents
180
+ // too, so "the last one" is not a safe guess.
181
+ let live = [];
182
+ try {
183
+ live = ctx.agents.list();
184
+ } catch {
185
+ live = [];
186
+ }
187
+ for (let index = live.length - 1; index >= 0; index -= 1) {
188
+ const sessionId = live[index]?.session?.id;
189
+ if (typeof sessionId !== 'string' || sessionId.length === 0) continue;
190
+ if (tried.includes(sessionId)) continue;
191
+ tried.push(sessionId);
192
+ const text = readMessageById(sessionId, messageId);
193
+ if (text !== null) {
194
+ trace(`lookup session=${sessionId} message=${messageId} len=${text.length} via=live`);
195
+ return { text, via: 'live', sessionId };
196
+ }
197
+ }
198
+
199
+ // 3. On-disk scan of the most recently used logs: covers a restarted DSH or a
200
+ // conversation that is no longer live. Message ids are UUIDs.
201
+ let sources = [];
202
+ try {
203
+ sources = listRecentSessionFiles(25);
204
+ } catch {
205
+ sources = [];
206
+ }
207
+ for (const source of sources) {
208
+ const text = readMessageFromFile(source.file, messageId);
209
+ if (text !== null) {
210
+ trace(`lookup session=${source.sessionId} message=${messageId} len=${text.length} via=scan`);
211
+ return { text, via: 'scan', sessionId: source.sessionId };
212
+ }
213
+ }
214
+
215
+ // Failure MUST be traced. Silently returning "not found" is exactly how the
216
+ // earlier bug stayed invisible.
217
+ trace(`lookup MISS message=${messageId} tried=[${tried.join(', ')}] scanned=${sources.length} ms=${Date.now() - startedAt}`);
218
+ return null;
219
+ }
220
+
114
221
  export function apply(ctx) {
115
222
  ctx.effect(
116
223
  () =>
@@ -232,6 +339,86 @@ export function apply(ctx) {
232
339
  'quote-shot: client log route'
233
340
  );
234
341
 
342
+ // Client hint sink: which session the quote was placed in.
343
+ //
344
+ // Fire-and-forget from the client. A missed hint only costs a slower lookup
345
+ // (the registry and the on-disk scan still run), so failures are silent.
346
+ ctx.effect(
347
+ () =>
348
+ ctx.webServer.register({
349
+ kind: 'exact',
350
+ path: HINT_ROUTE,
351
+ handler: async (request, response) => {
352
+ const reply = (status, body) => {
353
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
354
+ response.end(JSON.stringify(body));
355
+ };
356
+ if (request.method !== 'POST') {
357
+ response.writeHead(405, { allow: 'POST' });
358
+ response.end();
359
+ return;
360
+ }
361
+ const body = await readJson(request);
362
+ if (body === null) {
363
+ reply(400, { ok: false, code: 'bad-json' });
364
+ return;
365
+ }
366
+ const messageId = typeof body.messageId === 'string' ? body.messageId.trim() : '';
367
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
368
+ if (messageId.length === 0 || sessionId.length === 0) {
369
+ reply(400, { ok: false, code: 'missing-fields' });
370
+ return;
371
+ }
372
+ rememberHint(messageId, sessionId);
373
+ trace(`hint message=${messageId} session=${sessionId}`);
374
+ reply(200, { ok: true });
375
+ }
376
+ }),
377
+ 'quote-shot: hint route'
378
+ );
379
+
380
+ // Client-side message text lookup, for the sent-message card.
381
+ //
382
+ // The card shows the quoted body when opened, but the renderer cannot call a
383
+ // model tool. This exposes the same three-tier lookup over HTTP so the card
384
+ // does not have to duplicate any of it.
385
+ ctx.effect(
386
+ () =>
387
+ ctx.webServer.register({
388
+ kind: 'exact',
389
+ path: '/quote-shot/message',
390
+ handler: async (request, response) => {
391
+ const reply = (status, body) => {
392
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
393
+ response.end(JSON.stringify(body));
394
+ };
395
+ if (request.method !== 'POST') {
396
+ response.writeHead(405, { allow: 'POST' });
397
+ response.end();
398
+ return;
399
+ }
400
+ const body = await readJson(request);
401
+ if (body === null) {
402
+ reply(400, { ok: false, code: 'bad-json' });
403
+ return;
404
+ }
405
+ const messageId = typeof body.messageId === 'string' ? body.messageId.trim() : '';
406
+ if (messageId.length === 0) {
407
+ reply(400, { ok: false, code: 'missing-messageId' });
408
+ return;
409
+ }
410
+ const found = resolveMessageText(ctx, messageId);
411
+ if (found === null) {
412
+ reply(200, { ok: false, code: 'not-found' });
413
+ return;
414
+ }
415
+ trace(`card message=${messageId} len=${found.text.length} via=${found.via}`);
416
+ reply(200, { ok: true, text: found.text });
417
+ }
418
+ }),
419
+ 'quote-shot: message route'
420
+ );
421
+
235
422
  // The `quote_read` tool: read a message's full text by id.
236
423
  //
237
424
  // This is what makes "quote by reference" possible — the composer carries only
@@ -294,26 +481,9 @@ export function apply(ctx) {
294
481
  async execute(args) {
295
482
  const messageId = typeof args?.messageId === 'string' ? args.messageId.trim() : '';
296
483
  if (messageId.length === 0) return { found: false, messageId: '', error: 'missing-messageId' };
297
-
298
- let live = [];
299
- try {
300
- live = ctx.agents.list();
301
- } catch {
302
- live = [];
303
- }
304
- if (live.length === 0) return { found: false, messageId, error: 'no-active-conversation' };
305
-
306
- // Most recently registered live agent is the one issuing this call.
307
- const owner = live[live.length - 1];
308
- const sessionId = owner?.session?.id;
309
- if (typeof sessionId !== 'string' || sessionId.length === 0) {
310
- return { found: false, messageId, error: 'no-session-id' };
311
- }
312
-
313
- const text = readMessageById(sessionId, messageId);
314
- if (text === null) return { found: false, messageId, error: 'not-found' };
315
- trace(`quote_read session=${sessionId} message=${messageId} len=${text.length}`);
316
- return { found: true, messageId, text };
484
+ const found = resolveMessageText(ctx, messageId);
485
+ if (found === null) return { found: false, messageId, error: 'not-found' };
486
+ return { found: true, messageId, text: found.text };
317
487
  }
318
488
  });
319
489
  trace('tool quote_read registered');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quote-shot",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Quote a whole DSH message by reference: the composer carries only a message id, and the model reads the full text on demand through a quote_read tool instead of pasting it into the prompt.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/session-reader.js CHANGED
@@ -25,9 +25,29 @@ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions');
25
25
  /** Zstd frame magic number. */
26
26
  const MAGIC = [0x28, 0xb5, 0x2f, 0xfd];
27
27
 
28
- /** Per-session cache: sessionId -> { mtimeMs, index }. */
28
+ /** Per-file cache: absolute log path -> { mtimeMs, index }. */
29
29
  const cache = new Map();
30
30
 
31
+ /** Log file name inside a session directory. */
32
+ const LOG_NAME = 'session.v4.jsonl.zstd';
33
+
34
+ /**
35
+ * Directory names one session log may live under.
36
+ *
37
+ * DSH is not consistent here: sessions created by the desktop UI are stored as
38
+ * `session-<uuid>`, while subagent sessions and older logs use a bare `<uuid>`.
39
+ * On this machine that was 12 prefixed vs 161 bare logs in ONE workspace slug,
40
+ * so matching only the prefixed form silently missed almost every session.
41
+ *
42
+ * @param sessionId - bare session uuid, with or without the `session-` prefix.
43
+ * @returns candidate directory names, most likely first.
44
+ */
45
+ function sessionDirCandidates(sessionId) {
46
+ const bare = String(sessionId).replace(/^session-/, '');
47
+ if (bare.length === 0) return [];
48
+ return [`session-${bare}`, bare];
49
+ }
50
+
31
51
  /**
32
52
  * Locate the durable log file for one session.
33
53
  *
@@ -38,21 +58,63 @@ const cache = new Map();
38
58
  * @returns absolute path, or null when no log exists.
39
59
  */
40
60
  export function findSessionFile(sessionId) {
41
- const bare = String(sessionId).replace(/^session-/, '');
42
- if (bare.length === 0 || !existsSync(SESSIONS_ROOT)) return null;
61
+ if (!existsSync(SESSIONS_ROOT)) return null;
43
62
  let slugs;
44
63
  try {
45
64
  slugs = readdirSync(SESSIONS_ROOT);
46
65
  } catch {
47
66
  return null;
48
67
  }
49
- for (const slug of slugs) {
50
- const file = join(SESSIONS_ROOT, slug, `session-${bare}`, 'session.v4.jsonl.zstd');
51
- if (existsSync(file)) return file;
68
+ for (const dir of sessionDirCandidates(sessionId)) {
69
+ for (const slug of slugs) {
70
+ const file = join(SESSIONS_ROOT, slug, dir, LOG_NAME);
71
+ if (existsSync(file)) return file;
72
+ }
52
73
  }
53
74
  return null;
54
75
  }
55
76
 
77
+ /**
78
+ * Every session log under the sessions root, newest first.
79
+ *
80
+ * Used as the last-resort lookup: a quoted message can be older than the set of
81
+ * live agents (DSH restarted, or the conversation was reopened from history), so
82
+ * the owning session has to be found on disk rather than in the registry.
83
+ *
84
+ * @param limit - how many of the most recently touched logs to return.
85
+ * @returns `{ sessionId, file, mtimeMs }` entries, newest first.
86
+ */
87
+ export function listRecentSessionFiles(limit = 25) {
88
+ if (!existsSync(SESSIONS_ROOT)) return [];
89
+ const found = [];
90
+ let slugs;
91
+ try {
92
+ slugs = readdirSync(SESSIONS_ROOT);
93
+ } catch {
94
+ return [];
95
+ }
96
+ for (const slug of slugs) {
97
+ let dirs;
98
+ try {
99
+ dirs = readdirSync(join(SESSIONS_ROOT, slug));
100
+ } catch {
101
+ continue;
102
+ }
103
+ for (const dir of dirs) {
104
+ const file = join(SESSIONS_ROOT, slug, dir, LOG_NAME);
105
+ let stat;
106
+ try {
107
+ stat = statSync(file);
108
+ } catch {
109
+ continue;
110
+ }
111
+ found.push({ sessionId: dir.replace(/^session-/, ''), file, mtimeMs: stat.mtimeMs });
112
+ }
113
+ }
114
+ found.sort((left, right) => right.mtimeMs - left.mtimeMs);
115
+ return found.slice(0, Math.max(1, limit));
116
+ }
117
+
56
118
  /**
57
119
  * Decompress every zstd frame in a multi-frame buffer.
58
120
  *
@@ -122,24 +184,25 @@ function textOf(data) {
122
184
  }
123
185
 
124
186
  /**
125
- * Build (or reuse) the id → text index for one session.
187
+ * Build (or reuse) the id → text index for one log file.
126
188
  *
127
189
  * A single message id can appear across several events (streaming parts), so
128
190
  * text is accumulated per id rather than overwritten.
129
191
  *
130
- * @param sessionId - session whose log to index.
192
+ * Cached by FILE PATH, not session id: the same log can be reached through a
193
+ * prefixed and a bare directory name, and keying by id would build it twice.
194
+ *
195
+ * @param file - absolute log path.
131
196
  * @returns the index, or null when the log cannot be read.
132
197
  */
133
- function indexFor(sessionId) {
134
- const file = findSessionFile(sessionId);
135
- if (file === null) return null;
198
+ function indexForFile(file) {
136
199
  let mtimeMs;
137
200
  try {
138
201
  mtimeMs = statSync(file).mtimeMs;
139
202
  } catch {
140
203
  return null;
141
204
  }
142
- const cached = cache.get(sessionId);
205
+ const cached = cache.get(file);
143
206
  if (cached !== undefined && cached.mtimeMs === mtimeMs) return cached.index;
144
207
 
145
208
  let raw;
@@ -166,12 +229,26 @@ function indexFor(sessionId) {
166
229
  const previous = index.get(id);
167
230
  index.set(id, previous === undefined ? body : `${previous}\n${body}`);
168
231
  }
169
- cache.set(sessionId, { mtimeMs, index });
232
+ cache.set(file, { mtimeMs, index });
170
233
  return index;
171
234
  }
172
235
 
173
236
  /**
174
- * Read one message's full text by id.
237
+ * Read one message's text out of a specific log file.
238
+ *
239
+ * @param file - absolute log path.
240
+ * @param messageId - the message id.
241
+ * @returns the text, or null when the id is not in that log.
242
+ */
243
+ export function readMessageFromFile(file, messageId) {
244
+ if (typeof file !== 'string' || typeof messageId !== 'string') return null;
245
+ const index = indexForFile(file);
246
+ if (index === null) return null;
247
+ return index.get(messageId) ?? null;
248
+ }
249
+
250
+ /**
251
+ * Read one message's full text by id, from its owning session.
175
252
  *
176
253
  * @param sessionId - owning session (bare uuid or `session-`-prefixed).
177
254
  * @param messageId - the message id.
@@ -179,9 +256,9 @@ function indexFor(sessionId) {
179
256
  */
180
257
  export function readMessageById(sessionId, messageId) {
181
258
  if (typeof sessionId !== 'string' || typeof messageId !== 'string') return null;
182
- const index = indexFor(sessionId);
183
- if (index === null) return null;
184
- return index.get(messageId) ?? null;
259
+ const file = findSessionFile(sessionId);
260
+ if (file === null) return null;
261
+ return readMessageFromFile(file, messageId);
185
262
  }
186
263
 
187
264
  /**
package/test.mjs CHANGED
@@ -242,6 +242,8 @@ console.log('\napply() wiring');
242
242
  const localeRegs = [];
243
243
  const slotInjections = [];
244
244
  const seats = [];
245
+ const sources = [];
246
+ const rules = [];
245
247
  const ctx = {
246
248
  effect(fn) {
247
249
  return fn();
@@ -261,14 +263,43 @@ console.log('\napply() wiring');
261
263
  register(spec, component) {
262
264
  return { spec, component };
263
265
  }
264
- }
266
+ },
267
+ // Services added by the card / chip work. The mock has to model them, or
268
+ // apply() throws on the first dereference and every later check is noise.
269
+ inputTriggers: {
270
+ registerSource(source) {
271
+ sources.push(source);
272
+ return () => {};
273
+ }
274
+ },
275
+ uiConversation: {
276
+ userText: {
277
+ register(rule) {
278
+ rules.push(rule);
279
+ return () => {};
280
+ }
281
+ }
282
+ },
283
+ conversation: { input: { for: () => undefined } },
284
+ sessions: { scope: () => null }
265
285
  };
266
286
  api.apply(ctx);
267
287
  check('registers the dictionaries', localeRegs.length === 1 && localeRegs[0].namespace === 'quoteShot', JSON.stringify(localeRegs.map((r) => r.namespace)));
268
288
  check('dictionaries carry zh and en', Boolean(localeRegs[0]?.dictionaries?.zh && localeRegs[0]?.dictionaries?.en));
269
- check('injects into two slots', slotInjections.length === 2, slotInjections.join(','));
270
- check('first seat is the message action', slotInjections[0] === 'conversation.chat.assistant-actions', slotInjections[0]);
271
- check('second seat is the composer dock', slotInjections[1] === 'conversation.input.dock', slotInjections[1]);
289
+ check('registers the chip source', sources.length === 1 && sources[0].name === 'quote-shot', JSON.stringify(sources.map((s) => s.name)));
290
+ check(
291
+ 'chip codec covers clipboard and serialize',
292
+ typeof sources[0]?.codec?.clipboardText === 'function' && typeof sources[0]?.codec?.serialize === 'function'
293
+ );
294
+ check(
295
+ 'chip serialises to the same marker',
296
+ sources[0]?.codec?.clipboardText('abcdefgh-0000-0000-0000-000000000000')?.startsWith('[引用消息 abcdefgh-') === true
297
+ );
298
+ check('registers the user-text rule', rules.length === 1 && rules[0].id === 'dsh-quote-shot-card', String(rules[0]?.id));
299
+ check('injects into three slots', slotInjections.length === 3, slotInjections.join(','));
300
+ check('seat order: user text, message action, composer dock', slotInjections[0] === 'conversation.user.text.node', slotInjections[0]);
301
+ check('second seat is the message action', slotInjections[1] === 'conversation.chat.assistant-actions', slotInjections[1]);
302
+ check('third seat is the composer dock', slotInjections[2] === 'conversation.input.dock', slotInjections[2]);
272
303
  const actionSeat = seats.find((s) => s?.spec?.id === 'quote');
273
304
  const cardSeat = seats.find((s) => s?.spec?.id === 'quote-card');
274
305
  check('registers the action component', actionSeat?.component === api.QuoteActions);