switchroom 0.19.1 → 0.19.3

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.
Files changed (80) hide show
  1. package/dist/agent-scheduler/index.js +31 -1
  2. package/dist/auth-broker/index.js +565 -48
  3. package/dist/cli/autoaccept-poll.js +31 -1
  4. package/dist/cli/drive-write-pretool.mjs +32 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +32 -2
  6. package/dist/cli/switchroom.js +1148 -274
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/profiles/default/CLAUDE.md.hbs +8 -0
  13. package/skills/mental-model-curator/SKILL.md +68 -2
  14. package/skills/switchroom-cli/SKILL.md +25 -0
  15. package/telegram-plugin/auth-snapshot-format.ts +143 -12
  16. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +1427 -689
  18. package/telegram-plugin/dist/server.js +8 -2
  19. package/telegram-plugin/external-spend.ts +135 -0
  20. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  21. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  22. package/telegram-plugin/gateway/auth-command.ts +138 -5
  23. package/telegram-plugin/gateway/gateway.ts +141 -158
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  25. package/telegram-plugin/gateway/model-command.ts +309 -1
  26. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  27. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  28. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  29. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  30. package/telegram-plugin/gateway/stream-render.ts +22 -5
  31. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  33. package/telegram-plugin/quota-bar-format.ts +78 -12
  34. package/telegram-plugin/quota-check.ts +17 -2
  35. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  36. package/telegram-plugin/session-tail.ts +27 -3
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  38. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  39. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  40. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  41. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  42. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
  43. package/telegram-plugin/tests/model-command.test.ts +220 -0
  44. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  45. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  46. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  47. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  48. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  49. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  50. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  51. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  52. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  53. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  54. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  55. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
  56. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  57. package/vendor/hindsight-memory/README.md +2 -1
  58. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  60. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  61. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  62. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  63. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  64. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  65. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  66. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  67. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  68. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  78. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  79. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  80. package/vendor/hindsight-memory/settings.json +3 -1
@@ -17792,6 +17792,7 @@ function startSessionTail(config2) {
17792
17792
  let stopped = false;
17793
17793
  let pendingPartial = "";
17794
17794
  const fileCursors = new Map;
17795
+ const replayUntilByFile = new Map;
17795
17796
  function readNew() {
17796
17797
  if (stopped || !currentFile)
17797
17798
  return;
@@ -17800,11 +17801,15 @@ function startSessionTail(config2) {
17800
17801
  if (stat.size < cursor) {
17801
17802
  cursor = 0;
17802
17803
  pendingPartial = "";
17803
- if (currentFile != null)
17804
+ if (currentFile != null) {
17804
17805
  fileCursors.delete(currentFile);
17806
+ replayUntilByFile.delete(currentFile);
17807
+ }
17805
17808
  }
17806
17809
  if (stat.size === cursor)
17807
17810
  return;
17811
+ const chunkStart = cursor;
17812
+ const isReplayChunk = chunkStart < (replayUntilByFile.get(currentFile) ?? 0);
17808
17813
  const buf = Buffer.alloc(stat.size - cursor);
17809
17814
  const fd = openSync(currentFile, "r");
17810
17815
  try {
@@ -17824,7 +17829,7 @@ function startSessionTail(config2) {
17824
17829
  const sid = sessionIdForFile(currentFile);
17825
17830
  for (const ev of events) {
17826
17831
  try {
17827
- onEvent(decorate(ev, sid));
17832
+ onEvent(decorate(isReplayChunk && ev.kind === "model" ? { ...ev, replayed: true } : ev, sid));
17828
17833
  } catch (err) {
17829
17834
  log?.(`session-tail: onEvent threw: ${err.message}`);
17830
17835
  }
@@ -17877,6 +17882,7 @@ function startSessionTail(config2) {
17877
17882
  const size = statSync4(file).size;
17878
17883
  cursor = computeFirstAttachCursor(file, size);
17879
17884
  if (cursor < size) {
17885
+ replayUntilByFile.set(file, size);
17880
17886
  log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
17881
17887
  } else {
17882
17888
  log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
@@ -0,0 +1,135 @@
1
+ /**
2
+ * External (non-Claude / OpenRouter cash) spend for the `/usage` card.
3
+ *
4
+ * Layout B (operator-locked 2026-07-19):
5
+ * ```
6
+ * - 💸 External
7
+ * - 24h `$X.XX` · 7d `$Y.YY`
8
+ * - top `model $a` · `model $b` · `model $c`
9
+ * ```
10
+ *
11
+ * **Durable path:** the auth-broker holds the LiteLLM master key and
12
+ * serves a sanitized summary via `get-external-spend`. Agents never
13
+ * receive the master key. Soft-fail → omit the External block.
14
+ *
15
+ * Pure helpers (filter/format) live in `src/litellm/external-spend.ts`
16
+ * and are re-exported here for card rendering + tests.
17
+ */
18
+
19
+ import {
20
+ formatUsd,
21
+ type ExternalSpendSummary,
22
+ type ExternalSpendTopModel,
23
+ } from '../src/litellm/external-spend.js';
24
+
25
+ export type { ExternalSpendSummary, ExternalSpendTopModel };
26
+ export {
27
+ isExternalModel,
28
+ shortModelLabel,
29
+ formatUsd,
30
+ summarizeExternalSpend,
31
+ normalizeSpendLogRows,
32
+ utcDateString,
33
+ addUtcDays,
34
+ EXTERNAL_SPEND_CACHE_TTL_MS,
35
+ type LiteLLMDaySpendRow,
36
+ } from '../src/litellm/external-spend.js';
37
+
38
+ /** Soft client-side cache so strip double-taps of /usage within a process. */
39
+ const CLIENT_CACHE_TTL_MS = 30_000;
40
+ let clientCache: { atMs: number; summary: ExternalSpendSummary } | null = null;
41
+
42
+ /** Test seam. */
43
+ export function clearExternalSpendCache(): void {
44
+ clientCache = null;
45
+ }
46
+
47
+ /**
48
+ * Render layout-B bullet lines (no trailing freshness).
49
+ * Returns `[]` when summary is null/undefined so callers can omit.
50
+ * Zero totals still render — honest "no external spend".
51
+ */
52
+ export function formatExternalSpendBlock(
53
+ summary: ExternalSpendSummary | null | undefined,
54
+ ): string[] {
55
+ if (summary == null) return [];
56
+ const lines = [
57
+ '- 💸 External',
58
+ `- 24h \`${formatUsd(summary.day24hUsd)}\` · 7d \`${formatUsd(summary.day7dUsd)}\``,
59
+ ];
60
+ if (summary.top.length > 0) {
61
+ const topParts = summary.top.map((t) => `\`${t.label} ${formatUsd(t.usd)}\``);
62
+ lines.push(`- top ${topParts.join(' · ')}`);
63
+ }
64
+ return lines;
65
+ }
66
+
67
+ export interface FetchExternalSpendDeps {
68
+ now?: Date;
69
+ /** Prefer injected client for tests; defaults to env agent socket. */
70
+ getExternalSpend?: (forceLive?: boolean) => Promise<{
71
+ available: boolean;
72
+ day24hUsd?: number;
73
+ day7dUsd?: number;
74
+ top?: Array<{ label: string; usd: number }>;
75
+ capturedAtMs?: number;
76
+ served?: 'live' | 'cache';
77
+ reason?: string;
78
+ }>;
79
+ forceLive?: boolean;
80
+ bypassCache?: boolean;
81
+ cacheTtlMs?: number;
82
+ }
83
+
84
+ /**
85
+ * Best-effort fleet External spend for `/usage`.
86
+ * Primary: auth-broker `get-external-spend` (master key stays in broker).
87
+ * Returns null when unavailable — caller omits the block.
88
+ */
89
+ export async function fetchExternalSpendSummary(
90
+ nowOrDeps: Date | FetchExternalSpendDeps = {},
91
+ ): Promise<ExternalSpendSummary | null> {
92
+ const deps: FetchExternalSpendDeps =
93
+ nowOrDeps instanceof Date ? { now: nowOrDeps } : nowOrDeps;
94
+ const ttl = deps.cacheTtlMs ?? CLIENT_CACHE_TTL_MS;
95
+ if (!deps.bypassCache && clientCache && Date.now() - clientCache.atMs < ttl) {
96
+ return clientCache.summary;
97
+ }
98
+
99
+ try {
100
+ let data: Awaited<ReturnType<NonNullable<FetchExternalSpendDeps['getExternalSpend']>>>;
101
+ if (deps.getExternalSpend) {
102
+ data = await deps.getExternalSpend(deps.forceLive);
103
+ } else {
104
+ // Lazy import avoids pulling the full client graph into pure test paths.
105
+ const { AuthBrokerClient } = await import('../src/auth/broker/client.js');
106
+ const client = new AuthBrokerClient();
107
+ try {
108
+ data = await client.getExternalSpend(deps.forceLive);
109
+ } finally {
110
+ try {
111
+ await client.close();
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ }
116
+ }
117
+ if (!data?.available) return null;
118
+ if (
119
+ typeof data.day24hUsd !== 'number' ||
120
+ typeof data.day7dUsd !== 'number' ||
121
+ !Array.isArray(data.top)
122
+ ) {
123
+ return null;
124
+ }
125
+ const summary: ExternalSpendSummary = {
126
+ day24hUsd: data.day24hUsd,
127
+ day7dUsd: data.day7dUsd,
128
+ top: data.top.map((t) => ({ label: String(t.label), usd: Number(t.usd) })),
129
+ };
130
+ clientCache = { atMs: Date.now(), summary };
131
+ return summary;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
@@ -31,7 +31,16 @@
31
31
  * `record()` (a much smaller window), `take` finds no record and the duplicate
32
32
  * can still slip through. We deliberately trade that residual window for the
33
33
  * safety guarantee that we NEVER delete a message we cannot positively attribute
34
- * to the reply's own turn (identity-only supersede — see `decideSupersede`).
34
+ * to the reply's own turn (identity-scoped supersede — see `decideSupersede`).
35
+ *
36
+ * #3429 content gate: identity is NECESSARY but not sufficient. Because the
37
+ * flush ends its turn before recording, an async sub-agent handback landing
38
+ * within the TTL resolves the flush-delivered ENDED turn as its owner too —
39
+ * same identity as the turn's own late replay. When the caller supplies the
40
+ * landing reply's text, `decideSupersede` additionally requires it to BE the
41
+ * flushed answer (`flushedAnswerMatchesReply`); otherwise the decision is
42
+ * 'new-content' and the gateway sends fresh (a notifying new message) with the
43
+ * record left intact.
35
44
  *
36
45
  * Pure module: no I/O, no globals, no clock reads beyond the caller-supplied
37
46
  * `now`. Fully unit-testable; the gateway wires the actual delete/send.
@@ -51,9 +60,11 @@ export interface FlushedTurnRecord {
51
60
  /** The Telegram message id(s) the flush posted (edit target + any extra
52
61
  * chunk messages). Superseding deletes all of them. */
53
62
  messageIds: number[]
54
- /** The text the flush delivered retained for diagnostics/logging only. The
55
- * supersede decision NEVER compares text (that is the whole point: it fires
56
- * even when the flushed text differs from the reply text). */
63
+ /** The text the flush delivered. Originally diagnostics-only; since #3429 it
64
+ * also feeds the new-content gate (`flushedAnswerMatchesReply`): an
65
+ * identity-matched reply that is NOT the same answer (neither equal nor a
66
+ * bounded containment) is an async handback and must send fresh instead of
67
+ * editing/deleting the flushed message. */
57
68
  text: string
58
69
  /** Wall-clock ms when recorded. */
59
70
  ts: number
@@ -71,8 +82,67 @@ export interface SupersedeDecision {
71
82
  /** Message ids the gateway must delete before/instead of the fresh send.
72
83
  * Empty when `supersede` is false. */
73
84
  deleteMessageIds: number[]
74
- /** Machine-readable reason (for logs / tests). */
75
- reason: 'supersede' | 'no-record' | 'expired' | 'different-turn'
85
+ /** Machine-readable reason (for logs / tests). `'new-content'` (#3429): the
86
+ * record matched by identity + TTL but the landing reply carries genuinely
87
+ * DIFFERENT content than the flushed answer — an async handback attributed
88
+ * to the flush-delivered ended turn, NOT that turn's own answer landing
89
+ * late. The gateway must send FRESH (a notifying new message), never
90
+ * edit/delete the flushed message; the record is NOT consumed. */
91
+ reason: 'supersede' | 'no-record' | 'expired' | 'different-turn' | 'new-content'
92
+ /** The matched record's flushed text — populated whenever a fresh record for
93
+ * the reply's turn identity was found (`reason` 'supersede' or
94
+ * 'new-content'). The gateway stashes it on the owner turn atom so the
95
+ * answer-delivered latch can make the same content-vs-flush discrimination
96
+ * on the no-record retry/race paths (#3429). */
97
+ recordText?: string
98
+ }
99
+
100
+ /**
101
+ * #3429 — minimum length a CONTAINED text must have for a containment match.
102
+ * The legitimate containment class is a flush that delivered
103
+ * `narration\n\nanswer` being corrected by the clean `answer`-only reply — the
104
+ * contained side is a full answer, comfortably long. A trivially short
105
+ * contained string (e.g. a reply "Done." that happens to appear inside the
106
+ * flushed blob) is NOT positive evidence of the same answer, and a false match
107
+ * here silently edits/suppresses genuinely new content — the exact #3429
108
+ * failure. Below this floor only whitespace-normalized EQUALITY matches; the
109
+ * worst case of declining is a rare duplicate message, which beats a silent
110
+ * drop (the #3426/#3428 precedent).
111
+ */
112
+ export const SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS = 32
113
+
114
+ /** Collapse whitespace runs so flush-pipeline vs reply-pipeline spacing
115
+ * (paragraph spacers, hard-break promotion) never defeats the comparison. */
116
+ function normalizeForMatch(text: string): string {
117
+ return text.replace(/\s+/g, ' ').trim()
118
+ }
119
+
120
+ /**
121
+ * #3429 — is the landing reply the SAME ANSWER the flush already delivered
122
+ * (a late canonical correction), as opposed to genuinely new content (an async
123
+ * handback that merely resolved the flush-delivered ended turn as its owner)?
124
+ *
125
+ * Deterministic string decision, no timing:
126
+ * - whitespace-normalized equality → same answer;
127
+ * - containment either direction (flush = `narration\n\nanswer` ⊇ reply, or
128
+ * reply ⊇ a partially-delivered flush), guarded by
129
+ * `SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS` on the CONTAINED side so a short
130
+ * coincidental substring can never claim a match.
131
+ *
132
+ * A model-REGENERATED paraphrase of the same answer is indistinguishable from
133
+ * new content and therefore sends fresh — a rare duplicate message, the same
134
+ * conscious trade #3428 shipped for the reply-armed latch. A silent
135
+ * drop/edit-in-place of a genuinely new handback is the strictly worse
136
+ * failure.
137
+ */
138
+ export function flushedAnswerMatchesReply(flushedText: string, replyText: string): boolean {
139
+ const flushed = normalizeForMatch(flushedText)
140
+ const reply = normalizeForMatch(replyText)
141
+ if (flushed.length === 0 || reply.length === 0) return false
142
+ if (flushed === reply) return true
143
+ if (reply.length >= SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS && flushed.includes(reply)) return true
144
+ if (flushed.length >= SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS && reply.includes(flushed)) return true
145
+ return false
76
146
  }
77
147
 
78
148
  /**
@@ -93,10 +163,25 @@ export interface SupersedeDecision {
93
163
  * now resolves a last-known turnId for the reply before calling in, so the
94
164
  * common late-replay case still matches by identity rather than relying on the
95
165
  * promiscuous null branch.)
166
+ *
167
+ * #3429 content gate: identity alone is NOT sufficient. The flush ends its
168
+ * turn synchronously BEFORE recording (stream-render `endCurrentTurnAtomic` →
169
+ * `record`), so EVERY superseding reply is a late reply, and an async
170
+ * sub-agent handback landing within the TTL with no live gateway turn resolves
171
+ * the flush-delivered ENDED turn as its owner via the latest-ended tier —
172
+ * exactly the same identity as the turn's own canonical late replay. The
173
+ * observed failure (msgs 10482/10486, 2026-07-20): the handback consumed the
174
+ * record and EDITED the flushed message in place with unrelated new content —
175
+ * Telegram edits do not re-notify, so the handback never surfaced client-side
176
+ * AND the flushed answer was destroyed. When the caller supplies `replyText`
177
+ * and it is NOT the same answer (`flushedAnswerMatchesReply`), the decision is
178
+ * `'new-content'`: no supersede, record left intact for the genuine replay,
179
+ * and the gateway sends the reply FRESH. Callers that omit `replyText`
180
+ * (identity-only legacy shape) keep the pre-#3429 behaviour.
96
181
  */
97
182
  export function decideSupersede(
98
183
  record: FlushedTurnRecord | undefined,
99
- args: { liveTurnId: string | null; now: number; ttlMs?: number },
184
+ args: { liveTurnId: string | null; replyText?: string | null; now: number; ttlMs?: number },
100
185
  ): SupersedeDecision {
101
186
  const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
102
187
  if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
@@ -110,7 +195,18 @@ export function decideSupersede(
110
195
  if (!sameTurn) {
111
196
  return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
112
197
  }
113
- return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
198
+ // #3429 same turn identity, but genuinely different content: an async
199
+ // handback attributed to the flush-delivered ended turn, not the turn's own
200
+ // answer landing late. Never edit/delete the flushed message for it.
201
+ if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
202
+ return { supersede: false, deleteMessageIds: [], reason: 'new-content', recordText: record.text }
203
+ }
204
+ return {
205
+ supersede: true,
206
+ deleteMessageIds: [...record.messageIds],
207
+ reason: 'supersede',
208
+ recordText: record.text,
209
+ }
114
210
  }
115
211
 
116
212
  /**
@@ -225,23 +321,31 @@ export class FlushedTurnSupersedeRegistry {
225
321
 
226
322
  /** Decide supersede for a landing reply WITHOUT consuming the record. Selects
227
323
  * the record whose turnId matches the reply's resolved `liveTurnId` (or the
228
- * null-turnId record when `liveTurnId == null`). */
324
+ * null-turnId record when `liveTurnId == null`). `replyText` (#3429) enables
325
+ * the new-content gate; omitting it keeps the identity-only legacy shape. */
229
326
  peek(
230
327
  chatId: string,
231
328
  threadId: number | undefined,
232
- args: { liveTurnId: string | null; now: number },
329
+ args: { liveTurnId: string | null; replyText?: string | null; now: number },
233
330
  ): SupersedeDecision {
234
331
  const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
235
- return decideSupersede(rec, { liveTurnId: args.liveTurnId, now: args.now, ttlMs: this.ttlMs })
332
+ return decideSupersede(rec, {
333
+ liveTurnId: args.liveTurnId,
334
+ replyText: args.replyText,
335
+ now: args.now,
336
+ ttlMs: this.ttlMs,
337
+ })
236
338
  }
237
339
 
238
340
  /** Decide supersede AND, on a supersede, consume the matched record (so a
239
341
  * second replay of the same reply doesn't try to delete the same — now gone —
240
- * messages again). Returns the same decision `peek` would. */
342
+ * messages again). A `'new-content'` decision (#3429) does NOT consume: the
343
+ * record stays live for the turn's own canonical replay until the TTL.
344
+ * Returns the same decision `peek` would. */
241
345
  take(
242
346
  chatId: string,
243
347
  threadId: number | undefined,
244
- args: { liveTurnId: string | null; now: number },
348
+ args: { liveTurnId: string | null; replyText?: string | null; now: number },
245
349
  ): SupersedeDecision {
246
350
  const lane = makeKey(chatId, threadId)
247
351
  const decision = this.peek(chatId, threadId, args)