@sayknow-cli/coding-agent 0.3.8 → 0.3.10
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/CHANGELOG.md +13 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +5 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/model-profile-activation.d.ts +7 -7
- package/dist/types/config/model-registry.d.ts +11 -3
- package/dist/types/config/model-resolver.d.ts +2 -2
- package/dist/types/config/settings-schema.d.ts +15 -1
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -2
- package/dist/types/modes/components/thinking-selector.d.ts +7 -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 +3 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
- package/dist/types/notifications/config-commands.d.ts +38 -0
- package/dist/types/notifications/config.d.ts +16 -0
- package/dist/types/notifications/index.d.ts +14 -1
- 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 +10 -0
- package/dist/types/notifications/topic-registry.d.ts +4 -5
- package/dist/types/runtime-credential-selector.d.ts +7 -0
- package/dist/types/sdk.d.ts +7 -1
- package/dist/types/session/agent-session.d.ts +6 -3
- package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/skill-discovery.d.ts +40 -0
- package/dist/types/utils/pasted-image-path.d.ts +30 -0
- package/package.json +7 -7
- package/src/cli/args.ts +8 -0
- package/src/cli/notify-cli.ts +38 -10
- package/src/cli.ts +5 -0
- package/src/commands/launch.ts +5 -0
- package/src/commands/notify.ts +2 -2
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profile-activation.ts +23 -7
- package/src/config/model-registry.ts +107 -11
- package/src/config/model-resolver.ts +5 -16
- package/src/config/settings-schema.ts +11 -2
- package/src/config/settings.ts +24 -1
- package/src/extensibility/runtime-skill-discovery.ts +229 -0
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/lsp/client.ts +1 -0
- package/src/main.ts +26 -3
- package/src/modes/components/footer.ts +7 -2
- package/src/modes/components/settings-selector.ts +3 -3
- package/src/modes/components/thinking-selector.ts +83 -20
- 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 +62 -26
- package/src/modes/controllers/selector-controller.ts +43 -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 +3 -0
- package/src/modes/utils/injected-user-submission.ts +94 -0
- package/src/modes/utils/ui-helpers.ts +48 -15
- package/src/notifications/config-commands.ts +90 -0
- package/src/notifications/config.ts +27 -0
- package/src/notifications/index.ts +184 -14
- 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 +143 -0
- package/src/notifications/telegram-daemon.ts +431 -91
- package/src/notifications/telegram-reference.ts +17 -0
- package/src/notifications/threaded-render.ts +42 -1
- package/src/notifications/topic-registry.ts +9 -8
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/skill-discovery.md +13 -0
- package/src/runtime-credential-selector.ts +35 -0
- package/src/sdk.ts +104 -13
- package/src/session/agent-session.ts +48 -22
- package/src/skc-runtime/psmux-detect.ts +17 -2
- 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/system-prompt.ts +4 -6
- package/src/tools/index.ts +4 -0
- package/src/tools/skill-discovery.ts +73 -0
- package/src/tools/skill.ts +15 -2
- package/src/utils/pasted-image-path.ts +170 -0
|
@@ -9,7 +9,7 @@ import type { Settings } from "../config/settings";
|
|
|
9
9
|
import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
10
10
|
import { resolveSkcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
11
11
|
import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
|
|
12
|
-
import { parseInThreadConfigCommand } from "./config-commands";
|
|
12
|
+
import { parseInThreadConfigCommand, parseRichToggleCommand, parseTelegramControlCommand } from "./config-commands";
|
|
13
13
|
import { daemonPaths } from "./daemon-paths";
|
|
14
14
|
import {
|
|
15
15
|
buildCompactChoiceGrid,
|
|
@@ -44,8 +44,12 @@ import {
|
|
|
44
44
|
import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
|
|
45
45
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
46
46
|
import { listRecentSessions } from "./recent-activity";
|
|
47
|
+
import { ReplySentStore } from "./reply-sent-store";
|
|
48
|
+
import { DraftStreamState, deliverDraft, shouldStreamDraft } from "./rich-draft";
|
|
49
|
+
import { deliverRichActionWithFallback, deliverRichWithFallback, shouldPromoteRich } from "./rich-render";
|
|
47
50
|
import {
|
|
48
51
|
type AliasTable,
|
|
52
|
+
buildActionMarkdown,
|
|
49
53
|
buildActionMessage,
|
|
50
54
|
type CallbackRoute,
|
|
51
55
|
createAliasTable,
|
|
@@ -107,6 +111,21 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
|
|
|
107
111
|
export const NOTIFICATION_PROTOCOL_VERSION = 2;
|
|
108
112
|
|
|
109
113
|
const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Durably persist a `/rich` toggle. A real {@link Settings} exposes
|
|
117
|
+
* `flushOrThrow()`, which rejects on a failed config.yml write (its `set()` is a
|
|
118
|
+
* fire-and-forget whose background save swallows errors). The lightweight daemon
|
|
119
|
+
* settings has no `flushOrThrow` — its `set()` already wrote durably and throws
|
|
120
|
+
* on failure — so its plain `flush()` no-op drain is sufficient.
|
|
121
|
+
*/
|
|
122
|
+
async function flushRichToggleSettings(settings: Settings): Promise<void> {
|
|
123
|
+
if (typeof settings.flushOrThrow === "function") {
|
|
124
|
+
await settings.flushOrThrow();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await settings.flush();
|
|
128
|
+
}
|
|
110
129
|
const RATE_LIMIT_FLUSH_INTERVAL_MS = 1_000;
|
|
111
130
|
// How often the daemon rescans for newly-started sessions. This MUST run
|
|
112
131
|
// independently of the Telegram getUpdates long-poll (up to 25s): otherwise a
|
|
@@ -843,6 +862,10 @@ export interface TelegramDaemonOptions {
|
|
|
843
862
|
* default applies (e.g. lifecycle control disabled), no control server starts.
|
|
844
863
|
*/
|
|
845
864
|
createLifecycleControlServer?: LifecycleControlServerFactory | null;
|
|
865
|
+
/** Rich text promotion (enabled by default; see rich-render.ts). */
|
|
866
|
+
rich?: { enabled: boolean };
|
|
867
|
+
/** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
|
|
868
|
+
richDraft?: { enabled: boolean };
|
|
846
869
|
}
|
|
847
870
|
|
|
848
871
|
interface SessionSocket {
|
|
@@ -883,6 +906,10 @@ export class TelegramNotificationDaemon {
|
|
|
883
906
|
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
884
907
|
private readonly poller: TelegramUpdatePoller;
|
|
885
908
|
private readonly dispatchState = new TelegramEventDispatchState();
|
|
909
|
+
/** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
|
|
910
|
+
private readonly replyStore: ReplySentStore;
|
|
911
|
+
/** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
|
|
912
|
+
private readonly draftStream = new DraftStreamState();
|
|
886
913
|
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
887
914
|
private readonly topicOwnerByIdentity = new Map<string, string>();
|
|
888
915
|
/** Non-identity frames held until identity creates the correct thread. */
|
|
@@ -1120,7 +1147,7 @@ export class TelegramNotificationDaemon {
|
|
|
1120
1147
|
/** Build an authenticated lifecycle frame from a parsed command + identity. */
|
|
1121
1148
|
private buildLifecycleFrame(
|
|
1122
1149
|
parsed:
|
|
1123
|
-
| { kind: "create"; target: SessionCreateTarget }
|
|
1150
|
+
| { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
|
|
1124
1151
|
| { kind: "close"; target: SessionCloseTarget }
|
|
1125
1152
|
| { kind: "resume"; target: SessionResumeTarget },
|
|
1126
1153
|
updateId: number,
|
|
@@ -1138,6 +1165,7 @@ export class TelegramNotificationDaemon {
|
|
|
1138
1165
|
chatId,
|
|
1139
1166
|
token,
|
|
1140
1167
|
target: parsed.target,
|
|
1168
|
+
modelPreset: parsed.modelPreset,
|
|
1141
1169
|
};
|
|
1142
1170
|
}
|
|
1143
1171
|
if (parsed.kind === "close") {
|
|
@@ -1242,6 +1270,7 @@ export class TelegramNotificationDaemon {
|
|
|
1242
1270
|
|
|
1243
1271
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
1244
1272
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
1273
|
+
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
1245
1274
|
this.aliasTable = createAliasTable();
|
|
1246
1275
|
this.botApi =
|
|
1247
1276
|
opts.botApi ??
|
|
@@ -1536,6 +1565,7 @@ export class TelegramNotificationDaemon {
|
|
|
1536
1565
|
"image_attachment",
|
|
1537
1566
|
"file_attachment",
|
|
1538
1567
|
"config_update",
|
|
1568
|
+
"control_command_result",
|
|
1539
1569
|
]);
|
|
1540
1570
|
|
|
1541
1571
|
private topicNameFor(sessionId: string, msg: { title?: unknown; repo?: unknown; branch?: unknown }): string {
|
|
@@ -1798,8 +1828,23 @@ export class TelegramNotificationDaemon {
|
|
|
1798
1828
|
return { images, fileNotes };
|
|
1799
1829
|
}
|
|
1800
1830
|
|
|
1831
|
+
/**
|
|
1832
|
+
* Serialize all pool flushes. Every caller (`submitThreadedFrame`, the flat
|
|
1833
|
+
* fallback, the drain timer's `void this.flushPool()`, topic teardown) goes
|
|
1834
|
+
* through one promise chain, so two flushes never interleave — a live send can
|
|
1835
|
+
* never be in-flight while a finalized flush reads `liveMessages` and decides
|
|
1836
|
+
* to post a fresh (duplicate) final. Errors are swallowed so one failed flush
|
|
1837
|
+
* never poisons the queue (each flush is already best-effort internally).
|
|
1838
|
+
*/
|
|
1839
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
1840
|
+
private flushPool(): Promise<void> {
|
|
1841
|
+
const next = this.flushChain.then(() => this.flushPoolInner());
|
|
1842
|
+
this.flushChain = next.catch(() => {});
|
|
1843
|
+
return next;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1801
1846
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
1802
|
-
private async
|
|
1847
|
+
private async flushPoolInner(): Promise<void> {
|
|
1803
1848
|
const batch = this.pool.drain();
|
|
1804
1849
|
// Within a batch a finalized frame supersedes any still-queued live frame for
|
|
1805
1850
|
// the same streamed message (finalized outranks live), so drop the stale live
|
|
@@ -1811,6 +1856,17 @@ export class TelegramNotificationDaemon {
|
|
|
1811
1856
|
finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
|
|
1812
1857
|
}
|
|
1813
1858
|
}
|
|
1859
|
+
// Cross-batch protection: also purge any live frame still QUEUED for a
|
|
1860
|
+
// message whose finalized frame is in this batch, so a stale live edit can
|
|
1861
|
+
// never be delivered on a later drain after the authoritative final.
|
|
1862
|
+
if (finalizedKeys.size > 0) {
|
|
1863
|
+
this.pool.removeWhere(
|
|
1864
|
+
it =>
|
|
1865
|
+
it.lane === "live" &&
|
|
1866
|
+
it.coalesceKey !== undefined &&
|
|
1867
|
+
finalizedKeys.has(`${it.sessionId}:${it.coalesceKey}`),
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1814
1870
|
for (const item of batch) {
|
|
1815
1871
|
const { send, topicId } = item.payload;
|
|
1816
1872
|
if (topicId && !(await this.pairedChatIsPrivate())) continue;
|
|
@@ -1820,6 +1876,33 @@ export class TelegramNotificationDaemon {
|
|
|
1820
1876
|
const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
|
|
1821
1877
|
if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
|
|
1822
1878
|
try {
|
|
1879
|
+
// Draft streaming (opt-in, off by default): stream a live turn frame as a
|
|
1880
|
+
// best-effort rich-draft preview, debounced to >=1.5s per session through
|
|
1881
|
+
// this same rate-limited drain; a finalized frame ends the turn's draft
|
|
1882
|
+
// window. Entirely inert when richDraft is off (the enabled gate /
|
|
1883
|
+
// shouldStreamDraft fail closed), so off-state HTML request bodies stay
|
|
1884
|
+
// byte-identical.
|
|
1885
|
+
if (this.opts.richDraft?.enabled === true && this.opts.rich?.enabled !== false) {
|
|
1886
|
+
if (send.lane === "finalized" && send.method === "sendMessage") {
|
|
1887
|
+
this.draftStream.reset(item.sessionId);
|
|
1888
|
+
} else if (
|
|
1889
|
+
shouldStreamDraft({
|
|
1890
|
+
enabled: this.opts.richDraft.enabled,
|
|
1891
|
+
send,
|
|
1892
|
+
})
|
|
1893
|
+
) {
|
|
1894
|
+
const draftId = this.draftStream.tryClaim(item.sessionId, this.opts.now?.() ?? Date.now());
|
|
1895
|
+
if (draftId !== undefined) {
|
|
1896
|
+
await deliverDraft(
|
|
1897
|
+
this.botApi,
|
|
1898
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
1899
|
+
draftId,
|
|
1900
|
+
send.richDraftMarkdown!,
|
|
1901
|
+
logger,
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1823
1906
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
1824
1907
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
1825
1908
|
await this.botApi.call("sendPhoto", {
|
|
@@ -1841,53 +1924,139 @@ export class TelegramNotificationDaemon {
|
|
|
1841
1924
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1842
1925
|
});
|
|
1843
1926
|
} else if (send.text) {
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
photoBase64: undefined,
|
|
1884
|
-
documentBase64: undefined,
|
|
1927
|
+
// Rich pre-branch: promote stable non-editable finalized text to a fresh
|
|
1928
|
+
// sendRichMessage when enabled. Off/miss falls through to the unchanged
|
|
1929
|
+
// upstream edit/send path, so off behavior is byte-identical.
|
|
1930
|
+
if (
|
|
1931
|
+
shouldPromoteRich({
|
|
1932
|
+
enabled: this.opts.rich?.enabled !== false,
|
|
1933
|
+
send,
|
|
1934
|
+
})
|
|
1935
|
+
) {
|
|
1936
|
+
const sendHtmlFallback = async () => {
|
|
1937
|
+
// Fairness: this frame consumed exactly one token, so send only the
|
|
1938
|
+
// first HTML chunk now and requeue any continuations as their own
|
|
1939
|
+
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
1940
|
+
// per-token discipline as the non-rich split path.
|
|
1941
|
+
const chunks = splitTelegramHtml(send.text!);
|
|
1942
|
+
await this.botApi.call("sendMessage", {
|
|
1943
|
+
chat_id: this.opts.chatId,
|
|
1944
|
+
...threadField,
|
|
1945
|
+
text: chunks[0]!,
|
|
1946
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1947
|
+
});
|
|
1948
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
1949
|
+
this.pool.submit({
|
|
1950
|
+
sessionId: item.sessionId,
|
|
1951
|
+
lane: item.lane,
|
|
1952
|
+
payload: {
|
|
1953
|
+
send: {
|
|
1954
|
+
...send,
|
|
1955
|
+
method: "sendMessage",
|
|
1956
|
+
text: chunks[i]!,
|
|
1957
|
+
editable: false,
|
|
1958
|
+
coalesceKey: undefined,
|
|
1959
|
+
photoBase64: undefined,
|
|
1960
|
+
documentBase64: undefined,
|
|
1961
|
+
richMarkdown: undefined,
|
|
1962
|
+
richDraftMarkdown: undefined,
|
|
1963
|
+
richClass: undefined,
|
|
1964
|
+
},
|
|
1965
|
+
topicId,
|
|
1885
1966
|
},
|
|
1886
|
-
|
|
1887
|
-
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
const richMessageId = await deliverRichWithFallback(
|
|
1971
|
+
this.botApi,
|
|
1972
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
1973
|
+
send,
|
|
1974
|
+
sendHtmlFallback,
|
|
1975
|
+
logger,
|
|
1976
|
+
);
|
|
1977
|
+
// Index the sent rich message so an inbound reply to it can restore
|
|
1978
|
+
// the original markdown as context (Telegram does not echo it back).
|
|
1979
|
+
if (richMessageId !== undefined) {
|
|
1980
|
+
await this.replyStore.record({
|
|
1981
|
+
chatId: this.opts.chatId,
|
|
1982
|
+
messageId: richMessageId,
|
|
1983
|
+
text: send.richMarkdown!,
|
|
1888
1984
|
});
|
|
1889
1985
|
}
|
|
1890
|
-
|
|
1986
|
+
} else {
|
|
1987
|
+
const chunks = splitTelegramHtml(send.text);
|
|
1988
|
+
const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
|
|
1989
|
+
let firstMessageId: number | undefined;
|
|
1990
|
+
if (editKey && existingId !== undefined) {
|
|
1991
|
+
// Edit the existing streamed message in place with the first chunk
|
|
1992
|
+
// so a finalized turn never leaves a stale live preview. A LOCAL
|
|
1993
|
+
// try/catch keeps a failed edit from aborting the continuation
|
|
1994
|
+
// requeue below; "message is not modified" is a success (the message
|
|
1995
|
+
// already shows this text); a missing/deleted backing message (or a
|
|
1996
|
+
// transport error) resends so the first chunk is never lost.
|
|
1997
|
+
let edited = false;
|
|
1998
|
+
try {
|
|
1999
|
+
const res = (await this.botApi.call("editMessageText", {
|
|
2000
|
+
chat_id: this.opts.chatId,
|
|
2001
|
+
message_id: existingId,
|
|
2002
|
+
text: chunks[0],
|
|
2003
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2004
|
+
})) as { ok?: boolean; description?: string } | null;
|
|
2005
|
+
edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
|
|
2006
|
+
} catch {
|
|
2007
|
+
edited = false;
|
|
2008
|
+
}
|
|
2009
|
+
if (edited) {
|
|
2010
|
+
firstMessageId = existingId;
|
|
2011
|
+
} else {
|
|
2012
|
+
const res = (await this.botApi.call("sendMessage", {
|
|
2013
|
+
chat_id: this.opts.chatId,
|
|
2014
|
+
...threadField,
|
|
2015
|
+
text: chunks[0]!,
|
|
2016
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2017
|
+
})) as { result?: { message_id?: number } };
|
|
2018
|
+
firstMessageId = res?.result?.message_id;
|
|
2019
|
+
}
|
|
2020
|
+
} else {
|
|
2021
|
+
// No streamed message to edit: a single granted slot maps to a
|
|
2022
|
+
// single Telegram send.
|
|
2023
|
+
const res = (await this.botApi.call("sendMessage", {
|
|
2024
|
+
chat_id: this.opts.chatId,
|
|
2025
|
+
...threadField,
|
|
2026
|
+
text: chunks[0]!,
|
|
2027
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2028
|
+
})) as { result?: { message_id?: number } };
|
|
2029
|
+
firstMessageId = res?.result?.message_id;
|
|
2030
|
+
}
|
|
2031
|
+
// Continuation chunks are FINALIZED-lane only. A live preview is a
|
|
2032
|
+
// single edit-safe chunk (its authoritative full text arrives with the
|
|
2033
|
+
// finalized frame), so a split live frame never fans out into stale,
|
|
2034
|
+
// non-coalesced continuation messages. Finalized continuations are
|
|
2035
|
+
// fresh, non-editable, HTML-only sends (rich markers stripped) so they
|
|
2036
|
+
// can never be re-promoted to a duplicate sendRichMessage.
|
|
2037
|
+
if (item.lane !== "live") {
|
|
2038
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
2039
|
+
this.pool.submit({
|
|
2040
|
+
sessionId: item.sessionId,
|
|
2041
|
+
lane: item.lane,
|
|
2042
|
+
payload: {
|
|
2043
|
+
send: {
|
|
2044
|
+
...send,
|
|
2045
|
+
method: "sendMessage",
|
|
2046
|
+
text: chunks[i]!,
|
|
2047
|
+
editable: false,
|
|
2048
|
+
coalesceKey: undefined,
|
|
2049
|
+
photoBase64: undefined,
|
|
2050
|
+
documentBase64: undefined,
|
|
2051
|
+
richMarkdown: undefined,
|
|
2052
|
+
richDraftMarkdown: undefined,
|
|
2053
|
+
richClass: undefined,
|
|
2054
|
+
},
|
|
2055
|
+
topicId,
|
|
2056
|
+
},
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
1891
2060
|
if (editKey && ckey !== undefined && firstMessageId !== undefined) {
|
|
1892
2061
|
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
1893
2062
|
}
|
|
@@ -1959,7 +2128,7 @@ export class TelegramNotificationDaemon {
|
|
|
1959
2128
|
try {
|
|
1960
2129
|
await this.botApi.call("sendMessage", {
|
|
1961
2130
|
chat_id: this.opts.chatId,
|
|
1962
|
-
text: "
|
|
2131
|
+
text: "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands.",
|
|
1963
2132
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1964
2133
|
});
|
|
1965
2134
|
} catch {
|
|
@@ -2066,16 +2235,22 @@ export class TelegramNotificationDaemon {
|
|
|
2066
2235
|
// Rename the topic if the title changed (e.g. the session title was
|
|
2067
2236
|
// auto-generated after the topic was first created). This runs on
|
|
2068
2237
|
// every identity frame, but does NOT re-send the bulleted message.
|
|
2238
|
+
// Only commit the new registry name after Telegram accepts the edit:
|
|
2239
|
+
// a transient editForumTopic failure must remain retryable on the
|
|
2240
|
+
// next identity re-assert instead of leaving the remote topic stuck
|
|
2241
|
+
// at the provisional "SKC <id>" name forever.
|
|
2069
2242
|
const name = this.topicNameFor(session.sessionId, msg);
|
|
2070
|
-
if (this.topics.
|
|
2243
|
+
if (this.topics.needsRename(session.sessionId, name)) {
|
|
2071
2244
|
try {
|
|
2072
2245
|
await this.botApi.call("editForumTopic", {
|
|
2073
2246
|
chat_id: this.opts.chatId,
|
|
2074
2247
|
message_thread_id: Number(topicId),
|
|
2075
2248
|
name,
|
|
2076
2249
|
});
|
|
2250
|
+
this.topics.markNameApplied(session.sessionId, name);
|
|
2077
2251
|
} catch {
|
|
2078
|
-
// Best-effort rename; never block delivery.
|
|
2252
|
+
// Best-effort rename; never block delivery. Leave the old
|
|
2253
|
+
// registry name intact so a later identity frame retries.
|
|
2079
2254
|
}
|
|
2080
2255
|
}
|
|
2081
2256
|
// Send the full bulleted identity header EXACTLY ONCE per topic.
|
|
@@ -2108,24 +2283,58 @@ export class TelegramNotificationDaemon {
|
|
|
2108
2283
|
});
|
|
2109
2284
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
2110
2285
|
// Daemon keyboards use alias callback data with compact one-based tap targets;
|
|
2111
|
-
// full option text is rendered in the message body by buildActionMessage.
|
|
2286
|
+
// full option text is rendered in the message body by buildActionMessage/buildActionMarkdown.
|
|
2112
2287
|
const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
|
|
2113
2288
|
this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
|
|
2114
2289
|
);
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2290
|
+
// HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
|
|
2291
|
+
// returns the last chunk's message_id (the reply-routable message).
|
|
2292
|
+
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
2293
|
+
const chunks = splitTelegramHtml(rendered.text);
|
|
2294
|
+
let result: { result?: { message_id?: number } } = {};
|
|
2295
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2296
|
+
result = (await this.botApi.call("sendMessage", {
|
|
2297
|
+
chat_id: this.opts.chatId,
|
|
2298
|
+
...threadField,
|
|
2299
|
+
text: chunks[i]!,
|
|
2300
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2301
|
+
...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
2302
|
+
})) as { result?: { message_id?: number } };
|
|
2303
|
+
}
|
|
2304
|
+
return result.result?.message_id;
|
|
2305
|
+
};
|
|
2306
|
+
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
2307
|
+
if (this.opts.rich?.enabled !== false) {
|
|
2308
|
+
// Rich (default on): promote to sendRichMessage with a top-level
|
|
2309
|
+
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
2310
|
+
|
|
2311
|
+
const outcome = await deliverRichActionWithFallback(
|
|
2312
|
+
this.botApi,
|
|
2313
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
2314
|
+
{
|
|
2315
|
+
markdown: buildActionMarkdown({
|
|
2316
|
+
kind,
|
|
2317
|
+
question: msg.question,
|
|
2318
|
+
options: msg.options,
|
|
2319
|
+
summary: msg.summary,
|
|
2320
|
+
}),
|
|
2321
|
+
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
2322
|
+
requireMessageId: kind === "ask",
|
|
2323
|
+
},
|
|
2324
|
+
sendHtmlChunks,
|
|
2325
|
+
logger,
|
|
2326
|
+
);
|
|
2327
|
+
// Only asks are reply-routable; idle pings register no route.
|
|
2328
|
+
if (kind === "ask" && outcome.messageId !== undefined)
|
|
2329
|
+
this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
2330
|
+
} else {
|
|
2331
|
+
// Off: byte-identical to the pre-rich HTML path.
|
|
2332
|
+
const messageId = await sendHtmlChunks();
|
|
2333
|
+
// Only asks are reply-routable; idle pings register no route (parity
|
|
2334
|
+
// with the rich branch and correct even in the byte-identical off path).
|
|
2335
|
+
if (kind === "ask" && messageId !== undefined)
|
|
2336
|
+
this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
2125
2337
|
}
|
|
2126
|
-
const messageId = result.result?.message_id;
|
|
2127
|
-
if (messageId !== undefined)
|
|
2128
|
-
this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
2129
2338
|
await this.persistAliases();
|
|
2130
2339
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
2131
2340
|
session.pending.delete(msg.id);
|
|
@@ -2181,6 +2390,74 @@ export class TelegramNotificationDaemon {
|
|
|
2181
2390
|
}
|
|
2182
2391
|
}
|
|
2183
2392
|
}
|
|
2393
|
+
// Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
|
|
2394
|
+
// session config forward. Handled at paired-chat pre-routing, before threaded
|
|
2395
|
+
// injection and independent of any session WebSocket, so it works even when
|
|
2396
|
+
// no session is connected and never becomes an ask answer.
|
|
2397
|
+
{
|
|
2398
|
+
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
2399
|
+
const chat = m?.chat as { id?: unknown } | undefined;
|
|
2400
|
+
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
2401
|
+
const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
2402
|
+
// Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
|
|
2403
|
+
// appends @botname in groups; the bot username may be unknown if getMe
|
|
2404
|
+
// failed) so a rich command is never leaked into threaded injection / an
|
|
2405
|
+
// ask answer. Argument validity is decided by parseRichToggleCommand below.
|
|
2406
|
+
const isRichCommand = rawFirst?.split("@")[0] === "/rich";
|
|
2407
|
+
if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
|
|
2408
|
+
// Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
|
|
2409
|
+
// paired chat — the same contract as session delivery and lifecycle
|
|
2410
|
+
// commands. A group/supergroup chatId (legacy or hand-edited) must never
|
|
2411
|
+
// let an arbitrary chat member toggle the owner's notification config.
|
|
2412
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
2413
|
+
const updateId = (update as { update_id?: number }).update_id;
|
|
2414
|
+
// Dedupe redelivered updates so a toggle+confirmation runs at most once.
|
|
2415
|
+
if (typeof updateId === "number") {
|
|
2416
|
+
if (this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
2417
|
+
await this.rememberSeenUpdateId(updateId);
|
|
2418
|
+
}
|
|
2419
|
+
const threadField =
|
|
2420
|
+
typeof m.message_thread_id === "number" ? { message_thread_id: m.message_thread_id as number } : {};
|
|
2421
|
+
const reply = async (body: string): Promise<void> => {
|
|
2422
|
+
try {
|
|
2423
|
+
await this.botApi.call("sendMessage", {
|
|
2424
|
+
chat_id: this.opts.chatId,
|
|
2425
|
+
...threadField,
|
|
2426
|
+
text: body,
|
|
2427
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2428
|
+
});
|
|
2429
|
+
} catch {
|
|
2430
|
+
// Best-effort confirmation; never block on the notice.
|
|
2431
|
+
}
|
|
2432
|
+
};
|
|
2433
|
+
const desired = parseRichToggleCommand(cmdText ?? "");
|
|
2434
|
+
if (desired === undefined) {
|
|
2435
|
+
await reply("Usage: /rich on|off");
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
try {
|
|
2439
|
+
await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
|
|
2440
|
+
// Confirm success only after a DURABLE write. The real Settings.set is
|
|
2441
|
+
// a synchronous fire-and-forget whose queued save (Settings.#saveNow)
|
|
2442
|
+
// swallows write errors, and Settings.flush() inherits that — neither
|
|
2443
|
+
// rejects on a failed config.yml write. flushOrThrow() rethrows the
|
|
2444
|
+
// durable-write failure so it lands in the catch below (in-memory
|
|
2445
|
+
// isolated Settings short-circuit and never throw). The lightweight
|
|
2446
|
+
// daemon settings has no flushOrThrow: its set() already wrote durably
|
|
2447
|
+
// (and throws on failure), so its flush() is only a no-op drain.
|
|
2448
|
+
await flushRichToggleSettings(this.opts.settings);
|
|
2449
|
+
} catch (err) {
|
|
2450
|
+
logger.warn(
|
|
2451
|
+
`notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
|
|
2452
|
+
);
|
|
2453
|
+
await reply("Rich messages: unchanged (settings write failed)");
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
this.opts.rich = { enabled: desired };
|
|
2457
|
+
await reply(desired ? "Rich messages: on" : "Rich messages: off");
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2184
2461
|
// Threaded injection: a free-text message in a known topic (not a button
|
|
2185
2462
|
// tap and not a reply to a specific ask message) injects a user turn or an
|
|
2186
2463
|
// in-thread config command. Fail-closed: paired chat + known topic +
|
|
@@ -2211,7 +2488,56 @@ export class TelegramNotificationDaemon {
|
|
|
2211
2488
|
const images = attachmentResult?.images ?? [];
|
|
2212
2489
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
2213
2490
|
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
2214
|
-
const
|
|
2491
|
+
const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
2492
|
+
// A reply to a rich message we sent (not an ask route) loses its original
|
|
2493
|
+
// text: Telegram does not echo it in reply_to_message. Restore it from the
|
|
2494
|
+
// reply index as a labeled context prefix; a miss leaves the turn unchanged.
|
|
2495
|
+
const repliedOriginal =
|
|
2496
|
+
typeof replyTo === "number"
|
|
2497
|
+
? this.replyStore.lookup({ chatId: this.opts.chatId, messageId: replyTo })
|
|
2498
|
+
: undefined;
|
|
2499
|
+
const injectedText = repliedOriginal
|
|
2500
|
+
? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
|
|
2501
|
+
: baseInjectedText;
|
|
2502
|
+
const control = hasMedia
|
|
2503
|
+
? { kind: "none" as const }
|
|
2504
|
+
: parseTelegramControlCommand(inbound.text, this.botUsername);
|
|
2505
|
+
if (control.kind !== "none") {
|
|
2506
|
+
await this.rememberSeenUpdateId(inbound.updateId);
|
|
2507
|
+
const sendControlNotice = async (body: string): Promise<void> => {
|
|
2508
|
+
try {
|
|
2509
|
+
await this.botApi.call("sendMessage", {
|
|
2510
|
+
chat_id: this.opts.chatId,
|
|
2511
|
+
message_thread_id: Number(inbound.threadId),
|
|
2512
|
+
text: body,
|
|
2513
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2514
|
+
});
|
|
2515
|
+
} catch {
|
|
2516
|
+
// Best-effort control feedback; never convert to user input.
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
if (control.kind === "ignored") return;
|
|
2520
|
+
if (control.kind === "invalid") {
|
|
2521
|
+
await sendControlNotice(control.usage);
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (session?.ws.readyState !== WebSocket.OPEN) {
|
|
2525
|
+
await sendControlNotice("Session control unavailable: session is disconnected.");
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
session.ws.send(
|
|
2529
|
+
JSON.stringify({
|
|
2530
|
+
type: "control_command",
|
|
2531
|
+
sessionId: inbound.sessionId,
|
|
2532
|
+
token: session.token,
|
|
2533
|
+
requestId: `tg:${inbound.updateId}`,
|
|
2534
|
+
updateId: inbound.updateId,
|
|
2535
|
+
threadId: inbound.threadId,
|
|
2536
|
+
command: control.command,
|
|
2537
|
+
}),
|
|
2538
|
+
);
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2215
2541
|
const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
|
|
2216
2542
|
// A plain (non-config) message while an ask is pending for this session
|
|
2217
2543
|
// answers that ask as free-input — instead of starting a new user turn.
|
|
@@ -2228,6 +2554,15 @@ export class TelegramNotificationDaemon {
|
|
|
2228
2554
|
}),
|
|
2229
2555
|
);
|
|
2230
2556
|
await this.rememberSeenUpdateId(inbound.updateId);
|
|
2557
|
+
await this.botApi
|
|
2558
|
+
.call("sendMessage", {
|
|
2559
|
+
chat_id: this.opts.chatId,
|
|
2560
|
+
message_thread_id: Number(inbound.threadId),
|
|
2561
|
+
text: "Received as an answer to the pending ask.",
|
|
2562
|
+
})
|
|
2563
|
+
.catch(error => {
|
|
2564
|
+
logger.warn(`telegram: failed to acknowledge pending ask reply: ${String(error)}`);
|
|
2565
|
+
});
|
|
2231
2566
|
if (inbound.messageId !== undefined) await this.setReaction(inbound.messageId, QUEUED_REACTION);
|
|
2232
2567
|
return;
|
|
2233
2568
|
}
|
|
@@ -2291,7 +2626,12 @@ export class TelegramNotificationDaemon {
|
|
|
2291
2626
|
{ command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
|
|
2292
2627
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
2293
2628
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
2294
|
-
{ command: "
|
|
2629
|
+
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
2630
|
+
{ command: "reasoning", description: "Show or change reasoning effort in this session" },
|
|
2631
|
+
{ command: "usage", description: "Show provider/local usage for this session" },
|
|
2632
|
+
{ command: "context", description: "Show current context usage for this session" },
|
|
2633
|
+
{ command: "compact", description: "Compact this session: /compact [instructions]" },
|
|
2634
|
+
{ command: "session_create", description: "Create a SKC session: path, worktree, or dir [--mpreset]" },
|
|
2295
2635
|
{ command: "session_recent", description: "List recent SKC sessions" },
|
|
2296
2636
|
{ command: "session_close", description: "Close a SKC-managed session" },
|
|
2297
2637
|
{ command: "session_resume", description: "Resume or reattach a session" },
|
|
@@ -2321,6 +2661,7 @@ export class TelegramNotificationDaemon {
|
|
|
2321
2661
|
await this.loadAliases();
|
|
2322
2662
|
await this.loadTopics();
|
|
2323
2663
|
await this.loadSeenUpdateIds();
|
|
2664
|
+
await this.replyStore.load();
|
|
2324
2665
|
await this.runScan();
|
|
2325
2666
|
// Owner-only: start the session-lifecycle control server now that
|
|
2326
2667
|
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
@@ -2341,31 +2682,30 @@ export class TelegramNotificationDaemon {
|
|
|
2341
2682
|
await this.runScan();
|
|
2342
2683
|
if (await this.controlStopRequested()) break;
|
|
2343
2684
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
2344
|
-
if (this.sessions.size
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
//
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
}
|
|
2685
|
+
if (this.sessions.size > 0) {
|
|
2686
|
+
idleSince = this.runtime.now();
|
|
2687
|
+
} else if (idleElapsed) {
|
|
2688
|
+
// Zero sessions past the idle window: exit so the owner does not run
|
|
2689
|
+
// forever. An active session resets the idle window above.
|
|
2690
|
+
break;
|
|
2691
|
+
}
|
|
2692
|
+
// Poll getUpdates whenever the daemon owns the token — even with zero
|
|
2693
|
+
// sessions and no lifecycle control — so daemon-local commands (/rich,
|
|
2694
|
+
// /session_*) are always received until idle-exit.
|
|
2695
|
+
const activePoll = this.runtime.createAbortController();
|
|
2696
|
+
try {
|
|
2697
|
+
await this.pollOnce(activePoll.signal);
|
|
2698
|
+
this.loopBackoff.reset();
|
|
2699
|
+
} catch (e) {
|
|
2700
|
+
// A transient getUpdates/network failure must not kill the daemon.
|
|
2701
|
+
// Back off (bounded, below the heartbeat TTL) and keep renewing
|
|
2702
|
+
// ownership at the loop top.
|
|
2703
|
+
const backoffMs = this.loopBackoff.next();
|
|
2704
|
+
logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
|
|
2705
|
+
await this.runtime.sleep(backoffMs);
|
|
2706
|
+
continue;
|
|
2707
|
+
} finally {
|
|
2708
|
+
this.runtime.clearAbortController(activePoll);
|
|
2369
2709
|
}
|
|
2370
2710
|
if (await this.controlStopRequested()) break;
|
|
2371
2711
|
await this.runtime.sleep(10);
|