@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.
- package/dist/types/config/settings-schema.d.ts +14 -0
- package/dist/types/modes/components/thinking-selector.d.ts +2 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
- package/dist/types/notifications/config-commands.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +16 -0
- package/dist/types/notifications/index.d.ts +2 -0
- package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
- package/dist/types/notifications/reply-sent-store.d.ts +53 -0
- package/dist/types/notifications/rich-draft.d.ts +68 -0
- package/dist/types/notifications/rich-render.d.ts +90 -0
- package/dist/types/notifications/telegram-daemon.d.ts +22 -0
- package/dist/types/notifications/telegram-reference.d.ts +7 -0
- package/dist/types/notifications/threaded-render.d.ts +8 -0
- package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/utils/pasted-image-path.d.ts +30 -0
- package/package.json +7 -7
- package/src/cli/notify-cli.ts +11 -9
- package/src/cli/update-cli.ts +63 -24
- package/src/config/keybindings.ts +1 -1
- package/src/config/settings-schema.ts +8 -0
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/lsp/client.ts +1 -0
- package/src/modes/components/footer.ts +7 -2
- package/src/modes/components/thinking-selector.ts +10 -8
- package/src/modes/controllers/event-controller.ts +11 -2
- package/src/modes/controllers/extension-ui-controller.ts +8 -1
- package/src/modes/controllers/input-controller.ts +12 -17
- package/src/modes/controllers/selector-controller.ts +41 -0
- package/src/modes/interactive-mode.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +3 -9
- package/src/modes/rpc/rpc-socket-security.ts +13 -1
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/injected-user-submission.ts +94 -0
- package/src/notifications/config-commands.ts +20 -0
- package/src/notifications/config.ts +27 -0
- package/src/notifications/index.ts +28 -5
- package/src/notifications/lifecycle-commands.ts +37 -16
- package/src/notifications/lifecycle-control-runtime.ts +8 -3
- package/src/notifications/reply-sent-store.ts +134 -0
- package/src/notifications/rich-draft.ts +107 -0
- package/src/notifications/rich-render.ts +142 -0
- package/src/notifications/telegram-daemon.ts +346 -89
- package/src/notifications/telegram-reference.ts +17 -0
- package/src/notifications/threaded-render.ts +28 -1
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/sdk.ts +36 -8
- package/src/session/agent-session.ts +6 -4
- package/src/skc-runtime/tmux-common.ts +1 -0
- package/src/skc-runtime/ultragoal-runtime.ts +10 -1
- package/src/slash-commands/builtin-registry.ts +78 -1
- 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
|
|
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 (
|
|
128
|
-
const p = normalizeLifecyclePath(
|
|
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 (
|
|
134
|
-
const p = normalizeLifecyclePath(
|
|
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 (
|
|
140
|
-
const repo = normalizeLifecyclePath(
|
|
141
|
-
const branch =
|
|
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
|
+
}
|