@sayknow-cli/coding-agent 0.3.8 → 0.3.9

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 (57) hide show
  1. package/dist/types/config/settings-schema.d.ts +14 -0
  2. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  3. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  4. package/dist/types/modes/interactive-mode.d.ts +2 -0
  5. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  6. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  7. package/dist/types/modes/types.d.ts +2 -0
  8. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  9. package/dist/types/notifications/config-commands.d.ts +9 -0
  10. package/dist/types/notifications/config.d.ts +16 -0
  11. package/dist/types/notifications/index.d.ts +2 -0
  12. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  13. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  14. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  15. package/dist/types/notifications/rich-draft.d.ts +68 -0
  16. package/dist/types/notifications/rich-render.d.ts +90 -0
  17. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  18. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  19. package/dist/types/notifications/threaded-render.d.ts +8 -0
  20. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  21. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  22. package/package.json +7 -7
  23. package/src/cli/notify-cli.ts +11 -9
  24. package/src/cli/update-cli.ts +63 -24
  25. package/src/config/keybindings.ts +1 -1
  26. package/src/config/settings-schema.ts +8 -0
  27. package/src/internal-urls/docs-index.generated.ts +5 -5
  28. package/src/lsp/client.ts +1 -0
  29. package/src/modes/components/footer.ts +7 -2
  30. package/src/modes/components/thinking-selector.ts +10 -8
  31. package/src/modes/controllers/event-controller.ts +11 -2
  32. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  33. package/src/modes/controllers/input-controller.ts +12 -17
  34. package/src/modes/controllers/selector-controller.ts +41 -0
  35. package/src/modes/interactive-mode.ts +5 -0
  36. package/src/modes/rpc/rpc-mode.ts +3 -9
  37. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  38. package/src/modes/types.ts +2 -0
  39. package/src/modes/utils/injected-user-submission.ts +94 -0
  40. package/src/notifications/config-commands.ts +20 -0
  41. package/src/notifications/config.ts +27 -0
  42. package/src/notifications/index.ts +28 -5
  43. package/src/notifications/lifecycle-commands.ts +37 -16
  44. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  45. package/src/notifications/reply-sent-store.ts +134 -0
  46. package/src/notifications/rich-draft.ts +107 -0
  47. package/src/notifications/rich-render.ts +142 -0
  48. package/src/notifications/telegram-daemon.ts +346 -89
  49. package/src/notifications/telegram-reference.ts +17 -0
  50. package/src/notifications/threaded-render.ts +28 -1
  51. package/src/prompts/tools/search-tool-bm25.md +5 -0
  52. package/src/sdk.ts +36 -8
  53. package/src/session/agent-session.ts +6 -4
  54. package/src/skc-runtime/tmux-common.ts +1 -0
  55. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  56. package/src/slash-commands/builtin-registry.ts +78 -1
  57. package/src/utils/pasted-image-path.ts +170 -0
@@ -31,7 +31,7 @@ function normalizeLifecycleCommandToken(
31
31
 
32
32
  /** A parsed, validated lifecycle command (transport identity added by caller). */
33
33
  export type ParsedLifecycleCommand =
34
- | { kind: "create"; target: SessionCreateTarget }
34
+ | { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
35
35
  | { kind: "close"; target: SessionCloseTarget }
36
36
  | { kind: "resume"; target: SessionResumeTarget }
37
37
  | { kind: "recent"; which: "create" | "resume" | "all" }
@@ -41,9 +41,9 @@ export type ParsedLifecycleCommand =
41
41
 
42
42
  const USAGE = [
43
43
  "Session commands:",
44
- "/session_create path <dir>",
45
- "/session_create worktree <repo> <branch>",
46
- "/session_create dir <newdir>",
44
+ "/session_create path <dir> [--mpreset <profile>]",
45
+ "/session_create worktree <repo> <branch> [--mpreset <profile>]",
46
+ "/session_create dir <newdir> [--mpreset <profile>]",
47
47
  "/session_close <sessionId>",
48
48
  "/session_resume <sessionId|prefix>",
49
49
  "/session_recent [create|resume]",
@@ -69,6 +69,23 @@ export function isLifecycleCommandText(
69
69
  return normalizeLifecycleCommandToken(rawCommand ?? "", ctx) !== undefined;
70
70
  }
71
71
 
72
+ /** Extract `--mpreset <name>` (or `--mpreset=<name>`) from args, returning remaining positional args. */
73
+ function extractModelPreset(args: string[]): { positional: string[]; modelPreset?: string } {
74
+ const positional: string[] = [];
75
+ let modelPreset: string | undefined;
76
+ for (let i = 0; i < args.length; i++) {
77
+ const arg = args[i]!;
78
+ if (arg === "--mpreset" && i + 1 < args.length) {
79
+ modelPreset = args[++i]!;
80
+ } else if (arg.startsWith("--mpreset=")) {
81
+ modelPreset = arg.slice("--mpreset=".length);
82
+ } else {
83
+ positional.push(arg);
84
+ }
85
+ }
86
+ return { positional, modelPreset };
87
+ }
88
+
72
89
  /**
73
90
  * Parse a paired-chat message into a lifecycle command. Returns `none` for
74
91
  * non-lifecycle text, `usage`/`reject` for malformed input (no side effect), or
@@ -121,29 +138,33 @@ export function parseLifecycleCommand(
121
138
  return { kind: "resume", target: { sessionIdOrPrefix: idOrPrefix } };
122
139
  }
123
140
 
124
- // /session_create <kind> ...
125
- const kind = args[0];
141
+ // /session_create <kind> ... [--mpreset <profile>]
142
+ const { positional, modelPreset } = extractModelPreset(args);
143
+ if (modelPreset !== undefined && !isSafeIdentifier(modelPreset)) {
144
+ return { kind: "reject", reason: "invalid_target", message: `Invalid model preset name.\n\n${USAGE}` };
145
+ }
146
+ const kind = positional[0];
126
147
  if (kind === "path") {
127
- if (args.length !== 2) return { kind: "usage", message: USAGE };
128
- const p = normalizeLifecyclePath(args[1]!);
148
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
149
+ const p = normalizeLifecyclePath(positional[1]!);
129
150
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
130
- return { kind: "create", target: { kind: "existing_path", path: p } };
151
+ return { kind: "create", target: { kind: "existing_path", path: p }, modelPreset };
131
152
  }
132
153
  if (kind === "dir") {
133
- if (args.length !== 2) return { kind: "usage", message: USAGE };
134
- const p = normalizeLifecyclePath(args[1]!);
154
+ if (positional.length !== 2) return { kind: "usage", message: USAGE };
155
+ const p = normalizeLifecyclePath(positional[1]!);
135
156
  if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
136
- return { kind: "create", target: { kind: "plain_dir", path: p } };
157
+ return { kind: "create", target: { kind: "plain_dir", path: p }, modelPreset };
137
158
  }
138
159
  if (kind === "worktree") {
139
- if (args.length !== 3) return { kind: "usage", message: USAGE };
140
- const repo = normalizeLifecyclePath(args[1]!);
141
- const branch = args[2]!;
160
+ if (positional.length !== 3) return { kind: "usage", message: USAGE };
161
+ const repo = normalizeLifecyclePath(positional[1]!);
162
+ const branch = positional[2]!;
142
163
  if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
143
164
  if (!isSafeBranch(branch)) {
144
165
  return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
145
166
  }
146
- return { kind: "create", target: { kind: "worktree", repo, branch } };
167
+ return { kind: "create", target: { kind: "worktree", repo, branch }, modelPreset };
147
168
  }
148
169
  return { kind: "usage", message: USAGE };
149
170
  }
@@ -127,22 +127,27 @@ function tmuxSessionNameFor(sessionId: string): string {
127
127
  * The launched session id is carried via `SKC_SESSION_ID` in the child env (see
128
128
  * {@link daemonSpawnCreate}); the root `skc` launcher has no `--session-id`
129
129
  * flag, so it must never appear in argv. Only flags the launch parser actually
130
- * supports are emitted (`--worktree <branch>` for worktree targets). */
130
+ * supports are emitted (`--worktree <branch>` for worktree targets,
131
+ * `--mpreset <profile>` for model presets). */
131
132
  export function buildCreateArgv(
132
133
  frame: SessionCreateFrame,
133
134
  _ids: { intendedSessionId: string; startupPromptRef?: string },
134
135
  ): { cwd: string; args: string[] } {
136
+ const extraArgs: string[] = [];
137
+ if (frame.modelPreset) {
138
+ extraArgs.push("--mpreset", frame.modelPreset);
139
+ }
135
140
  if (frame.target.kind === "worktree") {
136
141
  const cwd = normalizeLifecyclePath(frame.target.repo);
137
142
  if (!cwd) throw new Error("invalid_lifecycle_repo_path");
138
143
  // Use the `--worktree=<branch>` form so the branch is a single argv token:
139
144
  // a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
140
145
  // launcher flag / detached-mode trigger.
141
- return { cwd, args: [`--worktree=${frame.target.branch}`] };
146
+ return { cwd, args: [`--worktree=${frame.target.branch}`, ...extraArgs] };
142
147
  }
143
148
  const cwd = normalizeLifecyclePath(frame.target.path);
144
149
  if (!cwd) throw new Error("invalid_lifecycle_path");
145
- return { cwd, args: [] };
150
+ return { cwd, args: extraArgs };
146
151
  }
147
152
 
148
153
  /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + SKC tags. */
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Index of rich messages the daemon sent, mapping `${chat_id}:${message_id}` to
3
+ * the original markdown. Telegram does not echo a `sendRichMessage` message's
4
+ * text in a reply's `reply_to_message`, so a user replying to a rich final
5
+ * answer would otherwise strand the agent without the quoted context. The daemon
6
+ * records the markdown on every promoted send and looks it up on inbound replies
7
+ * to restore that context.
8
+ *
9
+ * Fail-closed and off-path-neutral: nothing is recorded unless a send was
10
+ * actually promoted to rich, `record` is a no-op on any error (the in-memory map
11
+ * is only mutated after the atomic persist succeeds), and `lookup` returns
12
+ * undefined on any miss or error, so a bad index write/read never kills the
13
+ * daemon.
14
+ */
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { daemonPaths } from "./daemon-paths";
19
+
20
+ /** Max distinct (chat, message) entries retained; oldest-by-timestamp evicted past this. */
21
+ const MAX_ENTRIES = 1000;
22
+ /** Max stored characters of the original markdown per entry. */
23
+ const MAX_TEXT_LENGTH = 2000;
24
+ /** Persisted index filename under the notifications directory. */
25
+ const INDEX_FILENAME = "telegram-rich-sent-index.json";
26
+
27
+ /**
28
+ * Minimal async filesystem surface the store needs. Structurally a subset of the
29
+ * daemon's `TelegramDaemonFs`, so the daemon can pass its own `fs` straight
30
+ * through and tests can inject a fake.
31
+ */
32
+ export interface ReplySentStoreFs {
33
+ mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<unknown>;
34
+ readFile(path: string, encoding: BufferEncoding): Promise<string>;
35
+ writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise<void>;
36
+ rename(oldPath: string, newPath: string): Promise<void>;
37
+ chmod(path: string, mode: number): Promise<void>;
38
+ }
39
+
40
+ const nodeFs: ReplySentStoreFs = fs.promises as unknown as ReplySentStoreFs;
41
+
42
+ interface StoredEntry {
43
+ text: string;
44
+ ts: number;
45
+ }
46
+
47
+ interface PersistShape {
48
+ version: 1;
49
+ entries: Record<string, StoredEntry>;
50
+ }
51
+
52
+ /** Compose the stable per-message key. */
53
+ function keyFor(chatId: string | number, messageId: number): string {
54
+ return `${chatId}:${messageId}`;
55
+ }
56
+
57
+ /** Drop oldest-by-timestamp entries until at most {@link MAX_ENTRIES} remain. */
58
+ function evictToCap(entries: Map<string, StoredEntry>): void {
59
+ if (entries.size <= MAX_ENTRIES) return;
60
+ // Stable sort keeps insertion order among equal timestamps, so the oldest
61
+ // inserted entry is evicted first when timestamps tie.
62
+ const oldestFirst = [...entries.entries()].sort((a, b) => a[1].ts - b[1].ts);
63
+ const overflow = entries.size - MAX_ENTRIES;
64
+ for (let i = 0; i < overflow; i++) entries.delete(oldestFirst[i]![0]);
65
+ }
66
+
67
+ export class ReplySentStore {
68
+ readonly #dir: string;
69
+ readonly #file: string;
70
+ readonly #fsImpl: ReplySentStoreFs;
71
+ readonly #now: () => number;
72
+ #entries = new Map<string, StoredEntry>();
73
+
74
+ constructor(input: { agentDir: string; fs?: ReplySentStoreFs; now?: () => number }) {
75
+ this.#dir = daemonPaths(input.agentDir).dir;
76
+ this.#file = path.join(this.#dir, INDEX_FILENAME);
77
+ this.#fsImpl = input.fs ?? nodeFs;
78
+ this.#now = input.now ?? Date.now;
79
+ }
80
+
81
+ /** Restore the persisted index into memory. No-op on a missing or corrupt file. */
82
+ async load(): Promise<void> {
83
+ try {
84
+ const parsed = JSON.parse(await this.#fsImpl.readFile(this.#file, "utf8")) as Partial<PersistShape>;
85
+ const restored = new Map<string, StoredEntry>();
86
+ for (const [key, value] of Object.entries(parsed?.entries ?? {})) {
87
+ if (value && typeof value.text === "string" && typeof value.ts === "number") {
88
+ restored.set(key, { text: value.text, ts: value.ts });
89
+ }
90
+ }
91
+ evictToCap(restored);
92
+ this.#entries = restored;
93
+ } catch {
94
+ // Missing/corrupt index: keep the current in-memory map (empty on first load).
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Record the original markdown of a rich message the daemon just sent. The
100
+ * text is capped at {@link MAX_TEXT_LENGTH}; the index is capped at
101
+ * {@link MAX_ENTRIES} (oldest-by-timestamp evicted). No-op on any failure: the
102
+ * in-memory map is only replaced after the atomic persist succeeds.
103
+ */
104
+ async record(input: { chatId: string | number; messageId: number; text: string }): Promise<void> {
105
+ try {
106
+ const text = input.text.length > MAX_TEXT_LENGTH ? input.text.slice(0, MAX_TEXT_LENGTH) : input.text;
107
+ const next = new Map(this.#entries);
108
+ next.set(keyFor(input.chatId, input.messageId), { text, ts: this.#now() });
109
+ evictToCap(next);
110
+ await this.#persist(next);
111
+ this.#entries = next;
112
+ } catch {
113
+ // Best-effort: never let an index write kill the daemon.
114
+ }
115
+ }
116
+
117
+ /** Look up the original markdown for a message the daemon sent. undefined on miss or failure. */
118
+ lookup(input: { chatId: string | number; messageId: number }): string | undefined {
119
+ try {
120
+ return this.#entries.get(keyFor(input.chatId, input.messageId))?.text;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ async #persist(entries: Map<string, StoredEntry>): Promise<void> {
127
+ await this.#fsImpl.mkdir(this.#dir, { recursive: true, mode: 0o700 });
128
+ const payload: PersistShape = { version: 1, entries: Object.fromEntries(entries) };
129
+ const tmp = `${this.#file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
130
+ await this.#fsImpl.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
131
+ await this.#fsImpl.chmod(tmp, 0o600).catch(() => undefined);
132
+ await this.#fsImpl.rename(tmp, this.#file);
133
+ }
134
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Opt-in rich-draft streaming for a turn's in-progress preview.
3
+ *
4
+ * When the operator opts in (`richDraft.enabled`) the daemon streams LIVE turn
5
+ * markdown to the Bot API `sendRichMessageDraft` method as a debounced preview.
6
+ * Every failure is a harmless no-op (warn once, keep the daemon alive), and when
7
+ * off no draft call is ever made — so the off-state request bodies stay
8
+ * byte-identical.
9
+ */
10
+
11
+ import type { BotApi } from "./telegram-daemon";
12
+ import type { ThreadedSend } from "./threaded-render";
13
+
14
+ /** Minimum gap between two draft sends for one session (debounce floor). */
15
+ export const DRAFT_DEBOUNCE_MS = 1_500;
16
+
17
+ /**
18
+ * Wrap raw markdown + a monotonic draft id in the `sendRichMessageDraft` request
19
+ * payload shape: mirrors `buildRichMessage`'s proven `rich_message.markdown`
20
+ * content wrapper, with a top-level `draft_id` selecting the draft revision to
21
+ * update (a routing param, like `message_thread_id`).
22
+ */
23
+ export function buildRichDraft(draftId: number, raw: string): { draft_id: number; rich_message: { markdown: string } } {
24
+ return { draft_id: draftId, rich_message: { markdown: raw } };
25
+ }
26
+
27
+ /**
28
+ * Whether a granted send should stream a rich draft. Fail-closed: every clause
29
+ * must hold, otherwise no draft is sent. Mirrors `shouldPromoteRich` but targets
30
+ * the LIVE lane and the live-only `richDraftMarkdown` marker (set by
31
+ * `renderThreadedFrame` for non-finalized turn frames).
32
+ */
33
+ export function shouldStreamDraft(input: { enabled?: boolean; send: ThreadedSend }): boolean {
34
+ const { enabled, send } = input;
35
+ return (
36
+ enabled === true &&
37
+ send.method === "sendMessage" &&
38
+ send.lane === "live" &&
39
+ typeof send.richDraftMarkdown === "string" &&
40
+ send.richDraftMarkdown.trim().length > 0
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Per-session debounce + monotonic draft-id state for draft streaming. Skips a
46
+ * draft when less than `debounceMs` has elapsed since the session's last SENT
47
+ * draft (the rate-limit pool already coalesces live frames to the latest, so a
48
+ * skipped frame is naturally superseded by the next one). `reset` clears a
49
+ * session's window when its turn finalizes so the next turn starts fresh.
50
+ */
51
+ export class DraftStreamState {
52
+ readonly #debounceMs: number;
53
+ readonly #lastSentAt = new Map<string, number>();
54
+ /** Daemon-global monotonic draft id: unique across sessions so two sessions
55
+ * sharing one flat chat (no per-topic thread key) never collide on draft id. */
56
+ #nextDraftId = 0;
57
+
58
+ constructor(debounceMs: number = DRAFT_DEBOUNCE_MS) {
59
+ this.#debounceMs = debounceMs;
60
+ }
61
+
62
+ /**
63
+ * If enough time has elapsed since the session's last draft, record `now` as
64
+ * the new last-sent time and return the next monotonic draft id; otherwise
65
+ * return `undefined` (debounced — the caller skips this frame).
66
+ */
67
+ tryClaim(sessionId: string, now: number): number | undefined {
68
+ const last = this.#lastSentAt.get(sessionId);
69
+ if (last !== undefined && now - last < this.#debounceMs) return undefined;
70
+ this.#lastSentAt.set(sessionId, now);
71
+ const id = ++this.#nextDraftId;
72
+ return id;
73
+ }
74
+
75
+ /** Clear a session's debounce window (called when its turn finalizes). The
76
+ * global draft id keeps incrementing so ids are never reused across turns. */
77
+ reset(sessionId: string): void {
78
+ this.#lastSentAt.delete(sessionId);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Best-effort delivery of a rich draft. Never throws and has no HTML fallback: a
84
+ * draft is a purely additive preview, so on any failure (a thrown transport
85
+ * error or an `{ ok: false }` JSON response — the transport returns `res.json()`
86
+ * for JSON methods, so `ok:false` does not throw) it warns exactly once and
87
+ * returns; the unchanged live HTML send still carries the content.
88
+ */
89
+ export async function deliverDraft(
90
+ botApi: BotApi,
91
+ base: { chat_id: string | number; message_thread_id?: number },
92
+ draftId: number,
93
+ raw: string,
94
+ log?: { warn(msg: string): void },
95
+ ): Promise<void> {
96
+ let failure: string | undefined;
97
+ try {
98
+ const res = await botApi.call("sendRichMessageDraft", { ...base, ...buildRichDraft(draftId, raw) });
99
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
100
+ const description = (res as { description?: unknown }).description;
101
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
102
+ }
103
+ } catch (err) {
104
+ failure = err instanceof Error ? err.message : String(err);
105
+ }
106
+ if (failure !== undefined) log?.warn(`notifications: sendRichMessageDraft failed (${failure}); draft skipped`);
107
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Rich-message promotion for stable non-editable Telegram text sends.
3
+ *
4
+ * When enabled, the daemon promotes eligible finalized `sendMessage` payloads
5
+ * carrying raw markdown to the Bot API `sendRichMessage` method. On any miss or
6
+ * failure the daemon keeps the unchanged HTML `sendMessage` path, so the
7
+ * off-state request bodies are byte-identical.
8
+ */
9
+
10
+ import type { BotApi } from "./telegram-daemon";
11
+ import type { ThreadedSend } from "./threaded-render";
12
+
13
+ /**
14
+ * Telegram's hard per-message character ceiling (4096). Surfaced here purely as
15
+ * documentation and a marker for a future native rich-message splitter — it is
16
+ * intentionally NON-BEHAVIORAL and MUST stay that way: nothing in the rich path
17
+ * branches on this value.
18
+ *
19
+ * Overflow is already safe without it. The production final-answer text is capped
20
+ * at 3500 chars upstream (`summaryFromMessage(..., 3500)`), so a promoted
21
+ * `sendRichMessage` never approaches this ceiling; and if the Bot API ever rejects
22
+ * an oversized rich payload it returns `{ ok: false }`, which
23
+ * `deliverRichWithFallback` (below) turns into the chunked HTML `splitTelegramHtml`
24
+ * fallback (each chunk ≤ TELEGRAM_MESSAGE_LIMIT). This constant only marks where a
25
+ * future rich splitter would read its ceiling; wiring it into a branch would change
26
+ * byte-for-byte behavior and is out of scope.
27
+ */
28
+ export const RICH_MESSAGE_LIMIT = 4096;
29
+
30
+ /** Wrap raw markdown in the `sendRichMessage` request payload shape. */
31
+ export function buildRichMessage(
32
+ raw: string,
33
+ extras: { reply_markup?: unknown } = {},
34
+ ): { rich_message: { markdown: string }; reply_markup?: unknown } {
35
+ return { rich_message: { markdown: raw }, ...extras };
36
+ }
37
+
38
+ /**
39
+ * Whether a granted send should be promoted to `sendRichMessage`. Fail-closed
40
+ * and class-aware: every clause must hold, otherwise the daemon keeps the HTML path.
41
+ */
42
+ export function shouldPromoteRich(input: { enabled?: boolean; send: ThreadedSend }): boolean {
43
+ const { enabled, send } = input;
44
+ return (
45
+ enabled === true &&
46
+ send.method === "sendMessage" &&
47
+ send.lane === "finalized" &&
48
+ send.richClass === "final" &&
49
+ send.editable !== true &&
50
+ typeof send.richMarkdown === "string" &&
51
+ send.richMarkdown.trim().length > 0 &&
52
+ typeof send.text === "string" &&
53
+ send.text.length > 0
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Deliver the promoted rich message, falling back to `fallbackDeliver` (the
59
+ * unchanged HTML `sendMessage` loop) on any failure. A failure is either a
60
+ * thrown transport error or a `{ ok: false }` JSON response (the transport
61
+ * returns `res.json()` for JSON methods, so `ok:false` does not throw). On
62
+ * failure exactly one diagnostic is logged before the fallback runs; on success
63
+ * the fallback never runs.
64
+ *
65
+ * Returns the sent message's `message_id` on success (when the response carries
66
+ * one), otherwise `undefined` — including every failure/fallback path and a
67
+ * success whose response omits `result.message_id`. Callers that ignore the
68
+ * return value are unaffected.
69
+ */
70
+ export async function deliverRichWithFallback(
71
+ botApi: BotApi,
72
+ base: { chat_id: string | number; message_thread_id?: number },
73
+ send: ThreadedSend,
74
+ fallbackDeliver: () => Promise<void>,
75
+ log?: { warn(msg: string): void },
76
+ ): Promise<number | undefined> {
77
+ let failure: string | undefined;
78
+ let messageId: number | undefined;
79
+ try {
80
+ const res = await botApi.call("sendRichMessage", { ...base, ...buildRichMessage(send.richMarkdown!) });
81
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
82
+ const description = (res as { description?: unknown }).description;
83
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
84
+ } else {
85
+ const candidate = (res as { result?: { message_id?: unknown } } | null)?.result?.message_id;
86
+ if (typeof candidate === "number") messageId = candidate;
87
+ }
88
+ } catch (err) {
89
+ failure = err instanceof Error ? err.message : String(err);
90
+ }
91
+ if (failure === undefined) return messageId;
92
+ log?.warn(`notifications: sendRichMessage failed (${failure}); falling back to HTML`);
93
+ await fallbackDeliver();
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Deliver an action-needed (ask/idle) message via `sendRichMessage`, falling
99
+ * back to the unchanged HTML chunk loop on any failure. Mirrors
100
+ * {@link deliverRichWithFallback} but takes an explicit markdown body plus an
101
+ * optional top-level `reply_markup` (probe-confirmed: `sendRichMessage` accepts
102
+ * `reply_markup` alongside `rich_message`), and surfaces a structured outcome so
103
+ * the daemon can route inbound replies to the resulting message id.
104
+ *
105
+ * On rich success: returns `{ messageId, usedRich: true, usedFallback: false }`
106
+ * where `messageId` is `res.result.message_id` when present. On a `{ ok:false }`
107
+ * response or a thrown transport error: warns exactly once, runs `htmlFallback`,
108
+ * and returns `{ messageId, usedRich: false, usedFallback: true }` where
109
+ * `messageId` is the fallback's return value (the last HTML chunk's id).
110
+ */
111
+ export async function deliverRichActionWithFallback(
112
+ botApi: BotApi,
113
+ base: { chat_id: string | number; message_thread_id?: number },
114
+ opts: { markdown: string; replyMarkup?: unknown; requireMessageId?: boolean },
115
+ htmlFallback: () => Promise<number | undefined>,
116
+ log?: { warn(msg: string): void },
117
+ ): Promise<{ messageId?: number; usedRich: boolean; usedFallback: boolean }> {
118
+ let failure: string | undefined;
119
+ let messageId: number | undefined;
120
+ try {
121
+ const res = await botApi.call("sendRichMessage", {
122
+ ...base,
123
+ ...buildRichMessage(opts.markdown, opts.replyMarkup === undefined ? {} : { reply_markup: opts.replyMarkup }),
124
+ });
125
+ if (res !== null && typeof res === "object" && (res as { ok?: unknown }).ok === false) {
126
+ const description = (res as { description?: unknown }).description;
127
+ failure = typeof description === "string" && description.length > 0 ? description : "ok:false";
128
+ } else {
129
+ const candidate = (res as { result?: { message_id?: unknown } } | null)?.result?.message_id;
130
+ if (typeof candidate === "number") messageId = candidate;
131
+ // Ask messages MUST be reply-routable: if a rich success carries no numeric
132
+ // message_id, fall back to HTML so a routable id is guaranteed.
133
+ else if (opts.requireMessageId) failure = "rich response missing message_id";
134
+ }
135
+ } catch (err) {
136
+ failure = err instanceof Error ? err.message : String(err);
137
+ }
138
+ if (failure === undefined) return { messageId, usedRich: true, usedFallback: false };
139
+ log?.warn(`notifications: sendRichMessage(action) failed (${failure}); falling back to HTML`);
140
+ const fallbackId = await htmlFallback();
141
+ return { messageId: fallbackId, usedRich: false, usedFallback: true };
142
+ }