switchroom 0.19.6 → 0.19.8
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/dist/cli/switchroom.js +8 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +4 -4
- package/skills/telegram-formatting/SKILL.md +147 -0
- package/telegram-plugin/dist/gateway/gateway.js +124 -15
- package/telegram-plugin/gateway/gateway.ts +10 -5
- package/telegram-plugin/gateway/outbound-send-path.ts +55 -16
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -8
- package/telegram-plugin/gateway/subagent-handback-marker.ts +198 -19
- package/telegram-plugin/render/ir.ts +34 -26
- package/telegram-plugin/render/render.ts +12 -3
- package/telegram-plugin/rich-send.ts +16 -10
- package/telegram-plugin/shared/bot-runtime.ts +57 -0
- package/telegram-plugin/tests/format-guard-pins.test.ts +93 -0
- package/telegram-plugin/tests/render/underline-wire-outcome.test.ts +32 -0
- package/telegram-plugin/tests/rich-markdown-guard-transformer.test.ts +121 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -7
- package/telegram-plugin/tests/stream-render-golden.test.ts +140 -4
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +143 -14
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.19.
|
|
2123
|
+
var VERSION = "0.19.8", COMMIT_SHA = "c356d3ba";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -23861,7 +23861,8 @@ function getBuiltinDefaultSkillEntries() {
|
|
|
23861
23861
|
"switchroom-health",
|
|
23862
23862
|
"switchroom-runtime",
|
|
23863
23863
|
"mental-model-curator",
|
|
23864
|
-
"dev-protocol"
|
|
23864
|
+
"dev-protocol",
|
|
23865
|
+
"telegram-formatting"
|
|
23865
23866
|
];
|
|
23866
23867
|
return [
|
|
23867
23868
|
...anthropic.map((key) => ({ key, optOutKey: key, source: "anthropic" })),
|
|
@@ -29440,6 +29441,11 @@ right answer at all. Structure exists for the reader, not the writer: a two-item
|
|
|
29440
29441
|
bullet list is worse than a sentence, a heading on a three-line reply is noise. When
|
|
29441
29442
|
in doubt, shorter and plainer wins.
|
|
29442
29443
|
|
|
29444
|
+
Full palette when a rich or long message earns it \u2014 expandable blockquotes, spoilers,
|
|
29445
|
+
highlight, code-fence language hints, tables, escaping and chunking rules: load the
|
|
29446
|
+
\`telegram-formatting\` skill. Reach for it only when you're actually composing that
|
|
29447
|
+
message, never for everyday replies.
|
|
29448
|
+
|
|
29443
29449
|
Every turn that answers a user message ends with a user-visible \`reply\`
|
|
29444
29450
|
\u2014 Telegram is all the user sees; your terminal output
|
|
29445
29451
|
never reaches them.`, TELEGRAM_ENV_PLACEHOLDER = `# Set your bot token: TELEGRAM_BOT_TOKEN=your-token-here
|
|
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26663
26663
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26664
26664
|
|
|
26665
26665
|
// src/build-info.ts
|
|
26666
|
-
var VERSION = "0.19.
|
|
26666
|
+
var VERSION = "0.19.8";
|
|
26667
26667
|
|
|
26668
26668
|
// src/cli/resolve-version.ts
|
|
26669
26669
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.19.
|
|
4
|
+
"version": "0.19.8",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -72,11 +72,11 @@ The plugin's auto-retain (Stop hook) fires every turn, but in chunked mode each
|
|
|
72
72
|
|
|
73
73
|
### When to synthesize — concrete triggers
|
|
74
74
|
|
|
75
|
-
Auto-recall and auto-retain
|
|
75
|
+
Auto-recall and auto-retain feed the bank but never *synthesize* — that's on you, only if you act on these triggers. Each has a backstop:
|
|
76
76
|
|
|
77
|
-
- **Reflect instead of hand-assembling.**
|
|
78
|
-
- **Propose a model when you keep re-deriving.**
|
|
79
|
-
- **Merge or retire directives when they pile up.** Directives
|
|
77
|
+
- **Reflect instead of hand-assembling.** About to fire 2+ manual `recall`s for one answer ("summarize where Y stands")? Call `mcp__hindsight__reflect` instead. (Backstop: auto-recall injects the top hits every turn — reflect is the escalation.)
|
|
78
|
+
- **Propose a model when you keep re-deriving.** Rebuilt the *same standing answer* across sessions? Propose a mental model via `mcp__switchroom-telegram__mental_model_propose(name, source_query)` (or run the `mental-model-curator` skill). Not for a one-off fact (`retain`) or identity (profile banks own that).
|
|
79
|
+
- **Merge or retire directives when they pile up.** Directives cap at `MAX_DIRECTIVES=15` active per bank — past that the lowest-priority ones drop silently from recall. When they overlap or read stale, run the `mental-model-curator` merge/retire pass (deletes stay operator-approved). (Backstop: `switchroom doctor` WARNs at >12, FAILs at >15.)
|
|
80
80
|
|
|
81
81
|
## Sub-Agent Delegation
|
|
82
82
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: telegram-formatting
|
|
3
|
+
description: >
|
|
4
|
+
Use when composing a rich or long Telegram reply and you want the full
|
|
5
|
+
formatting palette with exact syntax — expandable blockquotes, spoilers,
|
|
6
|
+
highlight, code-fence language hints, GFM tables, nested lists — plus the
|
|
7
|
+
escaping rules and the framework's send-time chunking/normalizer behaviour.
|
|
8
|
+
Load it when a message genuinely needs structure, NOT for everyday short
|
|
9
|
+
replies (plain prose already wins there). Teaches judgment first: which
|
|
10
|
+
construct helps the reader vs when plain text is better. Do NOT use for
|
|
11
|
+
deciding whether to reply, or for non-Telegram output.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Telegram formatting — the full palette
|
|
15
|
+
|
|
16
|
+
Every outbound Switchroom message renders as raw GFM markdown over Telegram
|
|
17
|
+
Bot API 10.1 rich messages (`telegram-plugin/rich-send.ts` `richMessage(md)` →
|
|
18
|
+
`{ markdown }` → `sendRichMessage` / `editMessageText({ markdown })`). No HTML,
|
|
19
|
+
no `parse_mode`. This skill is the depth reference behind the boot-injected
|
|
20
|
+
floor card: the full construct vocabulary, correct syntax, escaping, and the
|
|
21
|
+
send-time behaviour you can rely on.
|
|
22
|
+
|
|
23
|
+
## Judgment first — reach for structure only when it helps the reader
|
|
24
|
+
|
|
25
|
+
The floor card's stance is the law here too: **structure exists for the reader,
|
|
26
|
+
not the writer.** Loading this skill does not mean "use everything below." Match
|
|
27
|
+
the construct to the message.
|
|
28
|
+
|
|
29
|
+
- **Short answers (a line or two): plain prose, no formatting.** "on it,
|
|
30
|
+
pulling the logs now" is already perfect. No bold, no bullets, no headings.
|
|
31
|
+
Most replies live here — don't dress them up.
|
|
32
|
+
- **Default: light structure.** Bold ONLY the one key fact or answer, never
|
|
33
|
+
more. A list only for 3+ genuinely parallel items the reader will scan or
|
|
34
|
+
compare; two items or a flowing thought stay prose. `code spans` for
|
|
35
|
+
identifiers (filenames, commands, config keys, error codes) — tap-to-copy.
|
|
36
|
+
- **Long / multi-section answers may add the rich constructs below** — tables,
|
|
37
|
+
headings, blockquotes, expandable blocks, fences — but only when they cut the
|
|
38
|
+
reader's effort. If the structure doesn't reduce scanning effort, drop it. A
|
|
39
|
+
two-item bullet list is worse than a sentence; a heading on a three-line reply
|
|
40
|
+
is noise. When in doubt, shorter and plainer wins.
|
|
41
|
+
|
|
42
|
+
Over-bolded messages (most of the text bold, whole paragraphs/lists bolded) get
|
|
43
|
+
their bold stripped at send time — so bold sparingly and deliberately.
|
|
44
|
+
|
|
45
|
+
## Full rich vocabulary
|
|
46
|
+
|
|
47
|
+
### Inline spans
|
|
48
|
+
|
|
49
|
+
| Effect | Markdown | When / notes |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| Bold | `**text**` | The one key fact or answer, not decoration. |
|
|
52
|
+
| Italic | `*text*` or `_text_` | Light emphasis, labels, asides. |
|
|
53
|
+
| Strikethrough | `~~text~~` | Retractions, "was X now Y". |
|
|
54
|
+
| Spoiler | `\|\|text\|\|` | Only for an opt-in surprise or a reveal the reader chose to wait for (a punchline they want suspended) — NEVER to hide an answer someone is asking for or anxious about; when in doubt, show it plainly. Surfaces as a `spoiler` entity on the wire (live-verified 2026-07). |
|
|
55
|
+
| Highlight / marked | `==text==` | Surfaces as a `marked` entity on the wire (live-verified 2026-07). `=` is an `escapeMarkdown` special, so dynamic text won't trigger it by accident. |
|
|
56
|
+
| Inline code | `` `text` `` | Identifiers, tap-to-copy. Content is literal — no escaping inside. |
|
|
57
|
+
| Link | `[label](https://…)` | Standard GFM link. |
|
|
58
|
+
|
|
59
|
+
**Do NOT rely on these — they don't render as intended:**
|
|
60
|
+
|
|
61
|
+
- **Underline** — there is NO underline token on this path. `__text__` renders
|
|
62
|
+
as **bold** (Telegram's rich-message markdown parser reads a `__…__` run
|
|
63
|
+
identically to `**…**`, live-verified against the Bot API 2026-07). Use `**`
|
|
64
|
+
for bold and don't reach for underline.
|
|
65
|
+
- **Subscript** `~text~` (single tilde) and **superscript** `^text^` fall back
|
|
66
|
+
to literal text in rich messages — avoid (write "squared", not `x^2^`).
|
|
67
|
+
- **Custom emoji** (premium custom-emoji entity) renders as a normal emoji for
|
|
68
|
+
non-premium viewers — don't rely on it to carry meaning.
|
|
69
|
+
|
|
70
|
+
(Inline math `$…$`, HTML `<details>`/collapsible, and footnotes `[^1]` are NOT
|
|
71
|
+
supported on this path — do not emit them; they degrade to literal or neutralised
|
|
72
|
+
text.)
|
|
73
|
+
|
|
74
|
+
### Block types
|
|
75
|
+
|
|
76
|
+
- **Code fence** — ` ```lang ` … ` ``` `. Multi-line literal output (diffs,
|
|
77
|
+
logs, JSON, command blocks). The language hint (`diff`, `json`, `bash`, …)
|
|
78
|
+
sharpens syntax rendering — use it. Content inside is verbatim, never escape
|
|
79
|
+
it; the only hazard is an embedded ` ``` ` closing the block early, which the
|
|
80
|
+
framework defuses (`preBlock` in `shared/bot-runtime.ts`).
|
|
81
|
+
- **Preformatted block** — a code fence with NO language, for fixed-width
|
|
82
|
+
non-code (ASCII tables, aligned columns).
|
|
83
|
+
- **Bulleted list** — `- item` (also `*` / `+`). 3+ parallel items only.
|
|
84
|
+
- **Numbered list** — `1. item`. Ordered steps or ranked items.
|
|
85
|
+
- **Nested lists** — indent sub-items; tight (no blank lines) vs loose (blank
|
|
86
|
+
lines between items) both render. 3-level nesting is live-verified.
|
|
87
|
+
- **Task list** — `- [ ] todo` / `- [x] done`.
|
|
88
|
+
- **Table** — GFM pipe table (`| col | col |` + `| --- | --- |` separator),
|
|
89
|
+
optional per-column alignment (`:---`, `:---:`, `---:`). 2-D data ONLY (rows ×
|
|
90
|
+
columns) — not a substitute for prose. Chunk-safe: `splitMarkdownChunks` never
|
|
91
|
+
bisects a row.
|
|
92
|
+
- **Blockquote** — `> quoted`. Quoted text or an indented continuation; the
|
|
93
|
+
right way to indent, because Telegram drops leading whitespace.
|
|
94
|
+
- **Expandable blockquote** — `**> …` (Bot API 10.1). A long quote/aside the
|
|
95
|
+
reader can collapse and expand. The flagship rich construct — use it for a
|
|
96
|
+
long quotation, a stack trace, or a detailed aside you don't want dominating
|
|
97
|
+
the message. First line carries the `**> ` marker; continuation lines use `> `.
|
|
98
|
+
- **Section heading** — `#` … `######`. Only in a genuinely long, multi-section
|
|
99
|
+
answer. Never on a short reply.
|
|
100
|
+
- **Divider** — `---` (thematic break). Heavy horizontal rule between genuinely
|
|
101
|
+
separate sections. Use sparingly.
|
|
102
|
+
- **Collage / album** — multiple images grouped in one message (media group).
|
|
103
|
+
Send via the attachment path, not markdown.
|
|
104
|
+
|
|
105
|
+
## Escaping rules
|
|
106
|
+
|
|
107
|
+
Dynamic content (filenames, ids, arbitrary user text) interpolated into a
|
|
108
|
+
hand-built markdown card MUST be escaped so it renders LITERALLY instead of
|
|
109
|
+
being parsed as formatting. Use `escapeMarkdown(value)` from
|
|
110
|
+
`telegram-plugin/format.ts`.
|
|
111
|
+
|
|
112
|
+
`escapeMarkdown` escapes exactly the characters that trigger INLINE formatting:
|
|
113
|
+
backslash, `` ` ``, `*`, `_`, `~`, `=`, `[`, `]`, `|` — the set `` \`*_~=[]| ``.
|
|
114
|
+
The backslash is escaped first so it never double-escapes a following special.
|
|
115
|
+
It deliberately does **not** escape `.` `-` `+` `#` `(` `)` `{` `}` `!` `>`:
|
|
116
|
+
those are only meaningful at line-start or in link/structure context, and
|
|
117
|
+
escaping them mid-word would litter filenames (`foo.ts`), versions (`v1.2-rc`),
|
|
118
|
+
and URLs with visible backslashes.
|
|
119
|
+
|
|
120
|
+
- **Bold/italic a dynamic value:** `` `**${escapeMarkdown(value)}**` ``.
|
|
121
|
+
- **Code-span a dynamic value:** `` `\`${value}\`` `` — code spans need NO
|
|
122
|
+
escaping (backtick content is already literal). This is the preferred, safest
|
|
123
|
+
way to render any identifier.
|
|
124
|
+
|
|
125
|
+
## Send-time behaviour you can rely on
|
|
126
|
+
|
|
127
|
+
- **Chunking.** Hard cap is `RICH_MESSAGE_MAX_CHARS = 32768` (32768 accepted,
|
|
128
|
+
32769 rejected — the single constant, never re-derive it). A longer body is
|
|
129
|
+
split by `splitMarkdownChunks(text, 32768)` in `format.ts`: it cuts at the
|
|
130
|
+
largest safe boundary (blank line → newline → space), **never bisects a fenced
|
|
131
|
+
code block** (`backOffOpenFence`) and **never bisects a table row**
|
|
132
|
+
(`backOffTableRow`). A single indivisible region larger than the cap is
|
|
133
|
+
emitted whole and re-split / hard-sliced at send time rather than hanging.
|
|
134
|
+
Long before 32768, ask whether a wall of text is the right answer at all.
|
|
135
|
+
- **Typography normalizer (deterministic, every message).** Block spacing (one
|
|
136
|
+
blank line between distinct blocks), em/en dashes, and `•` bullet markers are
|
|
137
|
+
rewritten at send time. A LONE `\n` between two prose paragraphs is promoted
|
|
138
|
+
to a real visual break; runs of 3+ newlines collapse to `\n\n`; lists, tables,
|
|
139
|
+
code, and existing `\n\n` gaps are left exactly as written. Don't hand-tune
|
|
140
|
+
spacing or fight the normalizer — write the content, the gateway makes the
|
|
141
|
+
typography consistent.
|
|
142
|
+
|
|
143
|
+
## The one rule that outranks everything here
|
|
144
|
+
|
|
145
|
+
You loaded this skill to format a rich message well — but the best formatting is
|
|
146
|
+
still the least that serves the reader. Use the palette to make a genuinely
|
|
147
|
+
complex answer scannable, never to decorate a simple one.
|
|
@@ -41612,6 +41612,7 @@ function shouldEmitShadowTrace(eventKind, effectCount, globalKind, verbose = gwT
|
|
|
41612
41612
|
}
|
|
41613
41613
|
|
|
41614
41614
|
// shared/bot-runtime.ts
|
|
41615
|
+
init_rich_send();
|
|
41615
41616
|
var tgPostTagStore = new AsyncLocalStorage;
|
|
41616
41617
|
function escapeHtmlForTg(text) {
|
|
41617
41618
|
return text.replace(/([\\`*_~=\[\]|])/g, "\\$1");
|
|
@@ -62511,6 +62512,7 @@ import { createHash as createHash2 } from "crypto";
|
|
|
62511
62512
|
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
62512
62513
|
init_flood_circuit_breaker();
|
|
62513
62514
|
init_format();
|
|
62515
|
+
init_rich_send();
|
|
62514
62516
|
var tgPostTagStore2 = new AsyncLocalStorage2;
|
|
62515
62517
|
function _getTgPostTags() {
|
|
62516
62518
|
return tgPostTagStore2.getStore();
|
|
@@ -62555,6 +62557,21 @@ function installTgPostLogger(bot) {
|
|
|
62555
62557
|
}
|
|
62556
62558
|
});
|
|
62557
62559
|
}
|
|
62560
|
+
function installRichMarkdownGuard(bot) {
|
|
62561
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
62562
|
+
if ((method === "sendRichMessage" || method === "editMessageText") && payload != null) {
|
|
62563
|
+
const p = payload;
|
|
62564
|
+
const rich = p.rich_message;
|
|
62565
|
+
if (rich != null && typeof rich.markdown === "string") {
|
|
62566
|
+
const guarded = guardAccidentalFormatting(rich.markdown);
|
|
62567
|
+
if (guarded !== rich.markdown) {
|
|
62568
|
+
p.rich_message = { ...rich, markdown: guarded };
|
|
62569
|
+
}
|
|
62570
|
+
}
|
|
62571
|
+
}
|
|
62572
|
+
return prev(method, payload, signal);
|
|
62573
|
+
});
|
|
62574
|
+
}
|
|
62558
62575
|
|
|
62559
62576
|
// flood-circuit-breaker.ts
|
|
62560
62577
|
import {
|
|
@@ -74662,11 +74679,12 @@ async function sendReply(deps, req) {
|
|
|
74662
74679
|
const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
|
|
74663
74680
|
const resolvedTurnId = ownerTurn?.turnId ?? null;
|
|
74664
74681
|
const ownerEndedAt = ownerTurn?.endedAt ?? null;
|
|
74682
|
+
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
|
|
74665
74683
|
const handbackAt = getLastSubagentHandbackAt(chat_id);
|
|
74666
74684
|
const now = Date.now();
|
|
74667
74685
|
const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
|
|
74668
|
-
const replyIsOwnAnswer = ownerTier === "live" || !handbackCouldOwnReply;
|
|
74669
|
-
const decision = flushedTurnSupersede.take(chat_id,
|
|
74686
|
+
const replyIsOwnAnswer = ownerTier === "live" || ownerTier === "latest-ended" && !handbackCouldOwnReply;
|
|
74687
|
+
const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
|
|
74670
74688
|
if (decision.supersede) {
|
|
74671
74689
|
process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
|
|
74672
74690
|
`);
|
|
@@ -78177,13 +78195,33 @@ function resolveReplyOwnerTurnId(candidates) {
|
|
|
78177
78195
|
}
|
|
78178
78196
|
|
|
78179
78197
|
// gateway/subagent-handback-marker.ts
|
|
78198
|
+
var MAIN_THREAD_KEY = "<main>";
|
|
78199
|
+
|
|
78180
78200
|
class SubagentHandbackMarker {
|
|
78181
|
-
|
|
78182
|
-
|
|
78183
|
-
|
|
78201
|
+
byChat = new Map;
|
|
78202
|
+
threadKey(threadId) {
|
|
78203
|
+
return threadId == null ? MAIN_THREAD_KEY : String(threadId);
|
|
78204
|
+
}
|
|
78205
|
+
record(chatId, threadId, now) {
|
|
78206
|
+
let inner = this.byChat.get(chatId);
|
|
78207
|
+
if (inner == null) {
|
|
78208
|
+
inner = new Map;
|
|
78209
|
+
this.byChat.set(chatId, inner);
|
|
78210
|
+
}
|
|
78211
|
+
inner.set(this.threadKey(threadId), now);
|
|
78184
78212
|
}
|
|
78185
|
-
lastAt(chatId) {
|
|
78186
|
-
return this.
|
|
78213
|
+
lastAt(chatId, threadId) {
|
|
78214
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
|
|
78215
|
+
}
|
|
78216
|
+
lastAtInChat(chatId) {
|
|
78217
|
+
const inner = this.byChat.get(chatId);
|
|
78218
|
+
if (inner == null || inner.size === 0)
|
|
78219
|
+
return null;
|
|
78220
|
+
let max = -Infinity;
|
|
78221
|
+
for (const ts of inner.values())
|
|
78222
|
+
if (ts > max)
|
|
78223
|
+
max = ts;
|
|
78224
|
+
return max === -Infinity ? null : max;
|
|
78187
78225
|
}
|
|
78188
78226
|
}
|
|
78189
78227
|
|
|
@@ -84663,6 +84701,76 @@ function buildDiffPreviewCard(input) {
|
|
|
84663
84701
|
return { text: text4, reply_markup: kb };
|
|
84664
84702
|
}
|
|
84665
84703
|
|
|
84704
|
+
// gateway/subagent-handback-marker.ts
|
|
84705
|
+
var INBOUND_SOURCE_CLASSIFICATION = {
|
|
84706
|
+
subagent_handback: { decoupledCompletion: true },
|
|
84707
|
+
cron: { decoupledCompletion: false },
|
|
84708
|
+
reaction: { decoupledCompletion: false },
|
|
84709
|
+
subagent_progress: { decoupledCompletion: false },
|
|
84710
|
+
resume_interrupted: { decoupledCompletion: false },
|
|
84711
|
+
resume_deferred: { decoupledCompletion: false },
|
|
84712
|
+
resume_watchdog_timeout: { decoupledCompletion: false },
|
|
84713
|
+
vault_grant_approved: { decoupledCompletion: false },
|
|
84714
|
+
vault_grant_denied: { decoupledCompletion: false },
|
|
84715
|
+
vault_grant_timeout: { decoupledCompletion: false },
|
|
84716
|
+
vault_save_completed: { decoupledCompletion: false },
|
|
84717
|
+
vault_save_discarded: { decoupledCompletion: false },
|
|
84718
|
+
vault_save_failed: { decoupledCompletion: false },
|
|
84719
|
+
vault_save_timeout: { decoupledCompletion: false },
|
|
84720
|
+
secret_provided: { decoupledCompletion: false },
|
|
84721
|
+
secret_declined: { decoupledCompletion: false },
|
|
84722
|
+
secret_provide_failed: { decoupledCompletion: false },
|
|
84723
|
+
secret_request_timeout: { decoupledCompletion: false },
|
|
84724
|
+
mental_model_propose_timeout: { decoupledCompletion: false },
|
|
84725
|
+
bridge_dead_restart: { decoupledCompletion: false },
|
|
84726
|
+
obligation_represent: { decoupledCompletion: false },
|
|
84727
|
+
missed_approval_retry: { decoupledCompletion: false },
|
|
84728
|
+
skill_proposal_apply: { decoupledCompletion: false },
|
|
84729
|
+
warmup: { decoupledCompletion: false },
|
|
84730
|
+
mental_model_proposal_applied: { decoupledCompletion: false },
|
|
84731
|
+
mental_model_proposal_denied: { decoupledCompletion: false },
|
|
84732
|
+
mental_model_proposal_failed: { decoupledCompletion: false },
|
|
84733
|
+
webhook: { decoupledCompletion: false },
|
|
84734
|
+
linear: { decoupledCompletion: false }
|
|
84735
|
+
};
|
|
84736
|
+
function stampsHandbackMarker(source) {
|
|
84737
|
+
if (source == null)
|
|
84738
|
+
return false;
|
|
84739
|
+
const known = INBOUND_SOURCE_CLASSIFICATION[source];
|
|
84740
|
+
if (known == null)
|
|
84741
|
+
return true;
|
|
84742
|
+
return known.decoupledCompletion;
|
|
84743
|
+
}
|
|
84744
|
+
var MAIN_THREAD_KEY2 = "<main>";
|
|
84745
|
+
|
|
84746
|
+
class SubagentHandbackMarker2 {
|
|
84747
|
+
byChat = new Map;
|
|
84748
|
+
threadKey(threadId) {
|
|
84749
|
+
return threadId == null ? MAIN_THREAD_KEY2 : String(threadId);
|
|
84750
|
+
}
|
|
84751
|
+
record(chatId, threadId, now) {
|
|
84752
|
+
let inner = this.byChat.get(chatId);
|
|
84753
|
+
if (inner == null) {
|
|
84754
|
+
inner = new Map;
|
|
84755
|
+
this.byChat.set(chatId, inner);
|
|
84756
|
+
}
|
|
84757
|
+
inner.set(this.threadKey(threadId), now);
|
|
84758
|
+
}
|
|
84759
|
+
lastAt(chatId, threadId) {
|
|
84760
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
|
|
84761
|
+
}
|
|
84762
|
+
lastAtInChat(chatId) {
|
|
84763
|
+
const inner = this.byChat.get(chatId);
|
|
84764
|
+
if (inner == null || inner.size === 0)
|
|
84765
|
+
return null;
|
|
84766
|
+
let max = -Infinity;
|
|
84767
|
+
for (const ts of inner.values())
|
|
84768
|
+
if (ts > max)
|
|
84769
|
+
max = ts;
|
|
84770
|
+
return max === -Infinity ? null : max;
|
|
84771
|
+
}
|
|
84772
|
+
}
|
|
84773
|
+
|
|
84666
84774
|
// gateway/pending-inbound-buffer.ts
|
|
84667
84775
|
var DEFAULT_PENDING_INBOUND_CAP = 32;
|
|
84668
84776
|
function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
@@ -84777,9 +84885,9 @@ function createPendingInboundBuffer(opts = {}) {
|
|
|
84777
84885
|
}
|
|
84778
84886
|
}
|
|
84779
84887
|
q.push(msg);
|
|
84780
|
-
if (msg.meta?.source
|
|
84888
|
+
if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
|
|
84781
84889
|
try {
|
|
84782
|
-
opts.onHandbackEnqueue(msg.chatId, msg.ts);
|
|
84890
|
+
opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts);
|
|
84783
84891
|
} catch {}
|
|
84784
84892
|
}
|
|
84785
84893
|
spool?.put(agent, msg);
|
|
@@ -93460,10 +93568,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
|
|
|
93460
93568
|
}
|
|
93461
93569
|
|
|
93462
93570
|
// ../src/build-info.ts
|
|
93463
|
-
var VERSION = "0.19.
|
|
93464
|
-
var COMMIT_SHA = "
|
|
93465
|
-
var COMMIT_DATE = "2026-07-
|
|
93466
|
-
var LATEST_PR =
|
|
93571
|
+
var VERSION = "0.19.8";
|
|
93572
|
+
var COMMIT_SHA = "c356d3ba";
|
|
93573
|
+
var COMMIT_DATE = "2026-07-20T23:16:37Z";
|
|
93574
|
+
var LATEST_PR = 3483;
|
|
93467
93575
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
93468
93576
|
|
|
93469
93577
|
// gateway/boot-version.ts
|
|
@@ -96382,7 +96490,7 @@ var RECENT_TURNS_MAX = 32;
|
|
|
96382
96490
|
var recentTurnsById = new Map;
|
|
96383
96491
|
var recentTurnIdBySourceMessageId = new Map;
|
|
96384
96492
|
var subagentHandbackMarker = new SubagentHandbackMarker;
|
|
96385
|
-
var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.
|
|
96493
|
+
var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAtInChat(chatId);
|
|
96386
96494
|
function rememberRecentTurn(turn) {
|
|
96387
96495
|
recentTurnsById.set(turn.turnId, turn);
|
|
96388
96496
|
if (turn.sourceMessageId != null) {
|
|
@@ -99172,7 +99280,7 @@ var pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
99172
99280
|
const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
|
|
99173
99281
|
swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
|
|
99174
99282
|
},
|
|
99175
|
-
onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts)
|
|
99283
|
+
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
|
|
99176
99284
|
});
|
|
99177
99285
|
function agentHasInFlightBackgroundWork(now) {
|
|
99178
99286
|
if (countRunningWorkers() > 0)
|
|
@@ -106335,6 +106443,7 @@ async function initGatewayBot() {
|
|
|
106335
106443
|
}
|
|
106336
106444
|
bot = new import_grammy15.Bot(TOKEN);
|
|
106337
106445
|
installTgPostLogger(bot);
|
|
106446
|
+
installRichMarkdownGuard(bot);
|
|
106338
106447
|
installUpdateTap(bot, (line) => process.stderr.write(line));
|
|
106339
106448
|
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
106340
106449
|
try {
|
|
@@ -295,7 +295,7 @@ import {
|
|
|
295
295
|
} from '../retry-api-call.js'
|
|
296
296
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
297
297
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
298
|
-
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
298
|
+
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
|
|
299
299
|
import {
|
|
300
300
|
floodStatePath,
|
|
301
301
|
floodWindowsPath,
|
|
@@ -3889,12 +3889,17 @@ const recentTurnsById = new Map<string, CurrentTurn>()
|
|
|
3889
3889
|
// Evicted in lock-step with recentTurnsById so it can't outgrow it.
|
|
3890
3890
|
const recentTurnIdBySourceMessageId = new Map<number, string>()
|
|
3891
3891
|
|
|
3892
|
-
// fix/backstop-duplicate-reply — per-chat marker of the most recent
|
|
3892
|
+
// fix/backstop-duplicate-reply — per-chat/thread marker of the most recent
|
|
3893
3893
|
// gateway-synthesized `subagent_handback` enqueue (logic in
|
|
3894
3894
|
// subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
|
|
3895
|
+
// The record is thread-resolved (retains the originating topic), but the
|
|
3896
|
+
// content-gate READ is CHAT-WIDE (dup-audit MUST-FIX 2): the latest-ended owner
|
|
3897
|
+
// tier is chat-wide, so a thread-specific gate read was steerable by the reply's
|
|
3898
|
+
// own `message_thread_id` → silent edit-over. `lastAtInChat` makes the gate
|
|
3899
|
+
// un-steerable (any in-window handback in the chat keeps the content gate).
|
|
3895
3900
|
const subagentHandbackMarker = new SubagentHandbackMarker()
|
|
3896
3901
|
const getLastSubagentHandbackAt = (chatId: string): number | null =>
|
|
3897
|
-
subagentHandbackMarker.
|
|
3902
|
+
subagentHandbackMarker.lastAtInChat(chatId)
|
|
3898
3903
|
|
|
3899
3904
|
function rememberRecentTurn(turn: CurrentTurn): void {
|
|
3900
3905
|
recentTurnsById.set(turn.turnId, turn)
|
|
@@ -9984,7 +9989,7 @@ const pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
9984
9989
|
// fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
|
|
9985
9990
|
// enqueue chokepoint so a boot-replayed handback (not just the live synthesis
|
|
9986
9991
|
// push) populates it. Every `subagent_handback` push funnels through here.
|
|
9987
|
-
onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts),
|
|
9992
|
+
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
|
|
9988
9993
|
})
|
|
9989
9994
|
|
|
9990
9995
|
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
@@ -22965,7 +22970,7 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22965
22970
|
}
|
|
22966
22971
|
|
|
22967
22972
|
bot = new Bot(TOKEN)
|
|
22968
|
-
installTgPostLogger(bot)
|
|
22973
|
+
installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
|
|
22969
22974
|
|
|
22970
22975
|
// Diagnostic update tap (#3300): one compact line per received update, logged
|
|
22971
22976
|
// BEFORE any specific handler runs, so a routing-layer drop is diagnosable
|
|
@@ -898,22 +898,45 @@ export async function sendReply(
|
|
|
898
898
|
//
|
|
899
899
|
// MUST-FIX 1 (silent-data-loss): the owner-resolution `quoted` / `origin`
|
|
900
900
|
// tiers are derived from MODEL-SUPPLIED args (`args.reply_to` /
|
|
901
|
-
// `args.origin_turn_id`), so a
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
901
|
+
// `args.origin_turn_id`), so a reply can STEER itself — pass
|
|
902
|
+
// `reply_to = <another ended turn's source msg>` and it resolves THAT turn via
|
|
903
|
+
// `quoted`. Marker-absence proves "own answer" ONLY for the framework-derived
|
|
904
|
+
// `latest-ended` tier; a model-steered `quoted`/`origin` attribution with no
|
|
905
|
+
// marker is NOT evidence of ownership, so bypassing the content gate there
|
|
906
|
+
// silently edits over a different ended turn's delivered answer (the #3429
|
|
907
|
+
// double-loss, executed by Fable 2026-07-21). The `replyIsOwnAnswer`
|
|
908
|
+
// computation below therefore restricts the bypass to `live` + `latest-ended`;
|
|
909
|
+
// `quoted`/`origin` ALWAYS traverse the content gate. See that comment.
|
|
909
910
|
//
|
|
910
|
-
// The content gate is therefore kept whenever a
|
|
911
|
-
// window (
|
|
912
|
-
//
|
|
913
|
-
// Concurrency note: a case-A own
|
|
914
|
-
// background handback in the same ≤TTL
|
|
915
|
-
//
|
|
911
|
+
// The content gate is therefore kept whenever a decoupled completion WAS
|
|
912
|
+
// enqueued in the window (on the latest-ended tier) OR the reply resolved via
|
|
913
|
+
// a model-steerable tier: the late reply might carry foreign content, so it
|
|
914
|
+
// sends fresh (two messages, #3429 preserved). Concurrency note: a case-A own
|
|
915
|
+
// reply coinciding with an unrelated background handback in the same ≤TTL
|
|
916
|
+
// window degrades to two messages — safe (never a silent drop/edit).
|
|
916
917
|
const ownerEndedAt = ownerTurn?.endedAt ?? null
|
|
918
|
+
// MUST-FIX 2 (dup-audit / Fable 2026-07-21) — key BOTH the supersede lane and
|
|
919
|
+
// the marker-gate read on the FRAMEWORK-resolved thread (the owner turn's
|
|
920
|
+
// `sessionThreadId` — the lane the flush recorded on), NOT the raw model arg
|
|
921
|
+
// `args.message_thread_id` (`replyThreadId`). The flush records on
|
|
922
|
+
// `turn.sessionThreadId`; the owner turn resolved here IS that turn, so its
|
|
923
|
+
// thread is where its record and its handback marker live. Reading the gate on
|
|
924
|
+
// the raw arg let a reply carry `message_thread_id=<other topic>` to dodge a
|
|
925
|
+
// handback marker stamped on the real topic → silent edit-over (the regression
|
|
926
|
+
// F2's raw-arg keying introduced). The owner turn's thread is not model-
|
|
927
|
+
// derived, so it cannot be steered. Falls back to the raw arg only when no
|
|
928
|
+
// owner turn resolved (no record to clobber on the collapse path).
|
|
929
|
+
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId
|
|
930
|
+
// MUST-FIX 2 (dup-audit / Fable) — the content-gate READ is CHAT-WIDE, not
|
|
931
|
+
// lane-specific: `findLatestEndedTurnForChat` resolves owners chat-wide, so a
|
|
932
|
+
// handback in topic A can supersede topic B's ended turn; a thread-keyed gate
|
|
933
|
+
// read (the F2 regression) let a reply dodge that handback by carrying a
|
|
934
|
+
// different `message_thread_id`. Chat-wide makes the gate un-steerable — any
|
|
935
|
+
// in-window handback in the chat keeps the content gate (accepting the F2
|
|
936
|
+
// visible-dup in the overlap window; a self-healing dup beats a silent loss).
|
|
937
|
+
// The supersede `take()` LANE below stays thread-resolved (`gateThreadId` =
|
|
938
|
+
// the owner turn's framework thread, not the raw arg), so a handback's
|
|
939
|
+
// correction only ever touches ITS OWN topic's record.
|
|
917
940
|
const handbackAt = getLastSubagentHandbackAt(chat_id)
|
|
918
941
|
const now = Date.now()
|
|
919
942
|
const handbackCouldOwnReply =
|
|
@@ -921,10 +944,26 @@ export async function sendReply(
|
|
|
921
944
|
ownerEndedAt != null &&
|
|
922
945
|
handbackAt > ownerEndedAt &&
|
|
923
946
|
now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
|
|
924
|
-
|
|
947
|
+
// MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) — restrict the
|
|
948
|
+
// content-gate BYPASS to the tiers whose attribution is NOT model-steerable:
|
|
949
|
+
// - `live` — the framework-owned live `currentTurn` (not model-derived,
|
|
950
|
+
// and `decideSupersede`'s same-turnId check bars it from an
|
|
951
|
+
// ended turn's record). Bypasses even a handback window.
|
|
952
|
+
// - `latest-ended` — the ambiguous DM/late-reply fallback the marko fix
|
|
953
|
+
// actually needs; bypass ONLY when no decoupled completion is
|
|
954
|
+
// in the window (marker-absence ⇒ own answer).
|
|
955
|
+
// The `quoted` / `origin` tiers resolve from MODEL-SUPPLIED args
|
|
956
|
+
// (`args.reply_to` / `args.origin_turn_id`), so a reply can steer ITSELF onto
|
|
957
|
+
// a DIFFERENT ended turn's record — with marker-absence they used to bypass
|
|
958
|
+
// the content gate and silently edit-over that turn's delivered answer (the
|
|
959
|
+
// #3429 double-loss, executed by Fable). Those tiers therefore NEVER bypass:
|
|
960
|
+
// they always go through the content gate, so foreign content sends fresh and
|
|
961
|
+
// only a genuine same-answer reply collapses.
|
|
962
|
+
const replyIsOwnAnswer =
|
|
963
|
+
ownerTier === 'live' || (ownerTier === 'latest-ended' && !handbackCouldOwnReply)
|
|
925
964
|
const decision = flushedTurnSupersede.take(
|
|
926
965
|
chat_id,
|
|
927
|
-
|
|
966
|
+
gateThreadId,
|
|
928
967
|
{ liveTurnId: resolvedTurnId, replyText: text, positiveAttribution: replyIsOwnAnswer, now },
|
|
929
968
|
)
|
|
930
969
|
if (decision.supersede) {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
33
|
import type { InboundSpool } from './inbound-spool.js'
|
|
34
|
+
import { stampsHandbackMarker } from './subagent-handback-marker.js'
|
|
34
35
|
|
|
35
36
|
/** Default cap per agent. Tuned for `should fit a reasonable backlog of
|
|
36
37
|
* approval cards stacked while bridge is offline` but no more. */
|
|
@@ -82,13 +83,17 @@ export interface PendingInboundBufferOptions {
|
|
|
82
83
|
/**
|
|
83
84
|
* fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
|
|
84
85
|
* `subagent_handback` envelope (live synthesis AND boot-replay re-push),
|
|
85
|
-
* carrying the envelope's `chatId
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* carrying the envelope's `chatId`, its `threadId` (the originating forum
|
|
87
|
+
* topic, or undefined for a DM), and its own `ts` (ms). The gateway wires this
|
|
88
|
+
* to the per-chat/thread subagent-handback marker so the supersede path can
|
|
89
|
+
* tell a flushed turn's own late reply from a background handback attributed to
|
|
90
|
+
* it — INCLUDING after a restart, where the only handback push is the replay.
|
|
91
|
+
* The `threadId` is passed so the marker keys on the SAME `chatId|threadId`
|
|
92
|
+
* lane the supersede registry uses (dup-audit F2): a handback in one topic must
|
|
93
|
+
* not hold the content gate open in another. Best-effort: a throw here never
|
|
94
|
+
* breaks the push hot path.
|
|
90
95
|
*/
|
|
91
|
-
onHandbackEnqueue?: (chatId: string, ts: number) => void
|
|
96
|
+
onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
/**
|
|
@@ -362,9 +367,17 @@ export function createPendingInboundBuffer(
|
|
|
362
367
|
// content gate → silent edit-over-answer. Uses the envelope's own `ts`
|
|
363
368
|
// (ms, `Date.now()`-derived at synthesis) so the marker reflects when the
|
|
364
369
|
// handback actually happened, not the replay moment. Best-effort.
|
|
365
|
-
|
|
370
|
+
// F1 (dup-audit) — the ONE chokepoint that decides which sources stamp the
|
|
371
|
+
// decoupled-completion marker, delegated to the single `stampsHandbackMarker`
|
|
372
|
+
// predicate (its membership is the invariant's only extension point). Every
|
|
373
|
+
// inbound — live synthesis AND boot-replay — funnels through this push(), so
|
|
374
|
+
// routing the decision here makes "a decoupled late-reply source stamps the
|
|
375
|
+
// marker" true BY CONSTRUCTION rather than by per-feature discipline.
|
|
376
|
+
if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
|
|
366
377
|
try {
|
|
367
|
-
|
|
378
|
+
// F2 (dup-audit): pass the envelope's originating topic so the marker
|
|
379
|
+
// keys on the same `chatId|threadId` lane as the supersede registry.
|
|
380
|
+
opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts)
|
|
368
381
|
} catch {
|
|
369
382
|
/* marker stamp is best-effort; never break the push hot path */
|
|
370
383
|
}
|