@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
|
@@ -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 } 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,
|
|
@@ -843,6 +847,10 @@ export interface TelegramDaemonOptions {
|
|
|
843
847
|
* default applies (e.g. lifecycle control disabled), no control server starts.
|
|
844
848
|
*/
|
|
845
849
|
createLifecycleControlServer?: LifecycleControlServerFactory | null;
|
|
850
|
+
/** Rich text promotion (enabled by default; see rich-render.ts). */
|
|
851
|
+
rich?: { enabled: boolean };
|
|
852
|
+
/** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
|
|
853
|
+
richDraft?: { enabled: boolean };
|
|
846
854
|
}
|
|
847
855
|
|
|
848
856
|
interface SessionSocket {
|
|
@@ -883,6 +891,10 @@ export class TelegramNotificationDaemon {
|
|
|
883
891
|
private readonly pool: RateLimitPool<{ send: ThreadedSend; topicId?: string }>;
|
|
884
892
|
private readonly poller: TelegramUpdatePoller;
|
|
885
893
|
private readonly dispatchState = new TelegramEventDispatchState();
|
|
894
|
+
/** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
|
|
895
|
+
private readonly replyStore: ReplySentStore;
|
|
896
|
+
/** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
|
|
897
|
+
private readonly draftStream = new DraftStreamState();
|
|
886
898
|
/** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */
|
|
887
899
|
private readonly topicOwnerByIdentity = new Map<string, string>();
|
|
888
900
|
/** Non-identity frames held until identity creates the correct thread. */
|
|
@@ -1120,7 +1132,7 @@ export class TelegramNotificationDaemon {
|
|
|
1120
1132
|
/** Build an authenticated lifecycle frame from a parsed command + identity. */
|
|
1121
1133
|
private buildLifecycleFrame(
|
|
1122
1134
|
parsed:
|
|
1123
|
-
| { kind: "create"; target: SessionCreateTarget }
|
|
1135
|
+
| { kind: "create"; target: SessionCreateTarget; modelPreset?: string }
|
|
1124
1136
|
| { kind: "close"; target: SessionCloseTarget }
|
|
1125
1137
|
| { kind: "resume"; target: SessionResumeTarget },
|
|
1126
1138
|
updateId: number,
|
|
@@ -1138,6 +1150,7 @@ export class TelegramNotificationDaemon {
|
|
|
1138
1150
|
chatId,
|
|
1139
1151
|
token,
|
|
1140
1152
|
target: parsed.target,
|
|
1153
|
+
modelPreset: parsed.modelPreset,
|
|
1141
1154
|
};
|
|
1142
1155
|
}
|
|
1143
1156
|
if (parsed.kind === "close") {
|
|
@@ -1242,6 +1255,7 @@ export class TelegramNotificationDaemon {
|
|
|
1242
1255
|
|
|
1243
1256
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
1244
1257
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
1258
|
+
this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
|
|
1245
1259
|
this.aliasTable = createAliasTable();
|
|
1246
1260
|
this.botApi =
|
|
1247
1261
|
opts.botApi ??
|
|
@@ -1798,8 +1812,23 @@ export class TelegramNotificationDaemon {
|
|
|
1798
1812
|
return { images, fileNotes };
|
|
1799
1813
|
}
|
|
1800
1814
|
|
|
1815
|
+
/**
|
|
1816
|
+
* Serialize all pool flushes. Every caller (`submitThreadedFrame`, the flat
|
|
1817
|
+
* fallback, the drain timer's `void this.flushPool()`, topic teardown) goes
|
|
1818
|
+
* through one promise chain, so two flushes never interleave — a live send can
|
|
1819
|
+
* never be in-flight while a finalized flush reads `liveMessages` and decides
|
|
1820
|
+
* to post a fresh (duplicate) final. Errors are swallowed so one failed flush
|
|
1821
|
+
* never poisons the queue (each flush is already best-effort internally).
|
|
1822
|
+
*/
|
|
1823
|
+
private flushChain: Promise<void> = Promise.resolve();
|
|
1824
|
+
private flushPool(): Promise<void> {
|
|
1825
|
+
const next = this.flushChain.then(() => this.flushPoolInner());
|
|
1826
|
+
this.flushChain = next.catch(() => {});
|
|
1827
|
+
return next;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1801
1830
|
/** Drain the shared rate-limit pool and deliver each granted send to its topic. */
|
|
1802
|
-
private async
|
|
1831
|
+
private async flushPoolInner(): Promise<void> {
|
|
1803
1832
|
const batch = this.pool.drain();
|
|
1804
1833
|
// Within a batch a finalized frame supersedes any still-queued live frame for
|
|
1805
1834
|
// the same streamed message (finalized outranks live), so drop the stale live
|
|
@@ -1811,6 +1840,17 @@ export class TelegramNotificationDaemon {
|
|
|
1811
1840
|
finalizedKeys.add(`${item.sessionId}:${item.coalesceKey}`);
|
|
1812
1841
|
}
|
|
1813
1842
|
}
|
|
1843
|
+
// Cross-batch protection: also purge any live frame still QUEUED for a
|
|
1844
|
+
// message whose finalized frame is in this batch, so a stale live edit can
|
|
1845
|
+
// never be delivered on a later drain after the authoritative final.
|
|
1846
|
+
if (finalizedKeys.size > 0) {
|
|
1847
|
+
this.pool.removeWhere(
|
|
1848
|
+
it =>
|
|
1849
|
+
it.lane === "live" &&
|
|
1850
|
+
it.coalesceKey !== undefined &&
|
|
1851
|
+
finalizedKeys.has(`${it.sessionId}:${it.coalesceKey}`),
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1814
1854
|
for (const item of batch) {
|
|
1815
1855
|
const { send, topicId } = item.payload;
|
|
1816
1856
|
if (topicId && !(await this.pairedChatIsPrivate())) continue;
|
|
@@ -1820,6 +1860,33 @@ export class TelegramNotificationDaemon {
|
|
|
1820
1860
|
const editKey = ckey !== undefined ? `${item.sessionId}:${ckey}` : undefined;
|
|
1821
1861
|
if (item.lane === "live" && editKey && finalizedKeys.has(editKey)) continue;
|
|
1822
1862
|
try {
|
|
1863
|
+
// Draft streaming (opt-in, off by default): stream a live turn frame as a
|
|
1864
|
+
// best-effort rich-draft preview, debounced to >=1.5s per session through
|
|
1865
|
+
// this same rate-limited drain; a finalized frame ends the turn's draft
|
|
1866
|
+
// window. Entirely inert when richDraft is off (the enabled gate /
|
|
1867
|
+
// shouldStreamDraft fail closed), so off-state HTML request bodies stay
|
|
1868
|
+
// byte-identical.
|
|
1869
|
+
if (this.opts.richDraft?.enabled === true && this.opts.rich?.enabled !== false) {
|
|
1870
|
+
if (send.lane === "finalized" && send.method === "sendMessage") {
|
|
1871
|
+
this.draftStream.reset(item.sessionId);
|
|
1872
|
+
} else if (
|
|
1873
|
+
shouldStreamDraft({
|
|
1874
|
+
enabled: this.opts.richDraft.enabled,
|
|
1875
|
+
send,
|
|
1876
|
+
})
|
|
1877
|
+
) {
|
|
1878
|
+
const draftId = this.draftStream.tryClaim(item.sessionId, this.opts.now?.() ?? Date.now());
|
|
1879
|
+
if (draftId !== undefined) {
|
|
1880
|
+
await deliverDraft(
|
|
1881
|
+
this.botApi,
|
|
1882
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
1883
|
+
draftId,
|
|
1884
|
+
send.richDraftMarkdown!,
|
|
1885
|
+
logger,
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1823
1890
|
if (send.method === "sendPhoto" && send.photoBase64) {
|
|
1824
1891
|
// Real photo upload (the default botApi multiparts base64 -> file).
|
|
1825
1892
|
await this.botApi.call("sendPhoto", {
|
|
@@ -1841,53 +1908,139 @@ export class TelegramNotificationDaemon {
|
|
|
1841
1908
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1842
1909
|
});
|
|
1843
1910
|
} 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,
|
|
1911
|
+
// Rich pre-branch: promote stable non-editable finalized text to a fresh
|
|
1912
|
+
// sendRichMessage when enabled. Off/miss falls through to the unchanged
|
|
1913
|
+
// upstream edit/send path, so off behavior is byte-identical.
|
|
1914
|
+
if (
|
|
1915
|
+
shouldPromoteRich({
|
|
1916
|
+
enabled: this.opts.rich?.enabled === false ? false : true,
|
|
1917
|
+
send,
|
|
1918
|
+
})
|
|
1919
|
+
) {
|
|
1920
|
+
const sendHtmlFallback = async () => {
|
|
1921
|
+
// Fairness: this frame consumed exactly one token, so send only the
|
|
1922
|
+
// first HTML chunk now and requeue any continuations as their own
|
|
1923
|
+
// non-editable, HTML-only pool items (rich markers stripped) — same
|
|
1924
|
+
// per-token discipline as the non-rich split path.
|
|
1925
|
+
const chunks = splitTelegramHtml(send.text!);
|
|
1926
|
+
await this.botApi.call("sendMessage", {
|
|
1927
|
+
chat_id: this.opts.chatId,
|
|
1928
|
+
...threadField,
|
|
1929
|
+
text: chunks[0]!,
|
|
1930
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1931
|
+
});
|
|
1932
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
1933
|
+
this.pool.submit({
|
|
1934
|
+
sessionId: item.sessionId,
|
|
1935
|
+
lane: item.lane,
|
|
1936
|
+
payload: {
|
|
1937
|
+
send: {
|
|
1938
|
+
...send,
|
|
1939
|
+
method: "sendMessage",
|
|
1940
|
+
text: chunks[i]!,
|
|
1941
|
+
editable: false,
|
|
1942
|
+
coalesceKey: undefined,
|
|
1943
|
+
photoBase64: undefined,
|
|
1944
|
+
documentBase64: undefined,
|
|
1945
|
+
richMarkdown: undefined,
|
|
1946
|
+
richDraftMarkdown: undefined,
|
|
1947
|
+
richClass: undefined,
|
|
1948
|
+
},
|
|
1949
|
+
topicId,
|
|
1885
1950
|
},
|
|
1886
|
-
|
|
1887
|
-
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
const richMessageId = await deliverRichWithFallback(
|
|
1955
|
+
this.botApi,
|
|
1956
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
1957
|
+
send,
|
|
1958
|
+
sendHtmlFallback,
|
|
1959
|
+
logger,
|
|
1960
|
+
);
|
|
1961
|
+
// Index the sent rich message so an inbound reply to it can restore
|
|
1962
|
+
// the original markdown as context (Telegram does not echo it back).
|
|
1963
|
+
if (richMessageId !== undefined) {
|
|
1964
|
+
await this.replyStore.record({
|
|
1965
|
+
chatId: this.opts.chatId,
|
|
1966
|
+
messageId: richMessageId,
|
|
1967
|
+
text: send.richMarkdown!,
|
|
1888
1968
|
});
|
|
1889
1969
|
}
|
|
1890
|
-
|
|
1970
|
+
} else {
|
|
1971
|
+
const chunks = splitTelegramHtml(send.text);
|
|
1972
|
+
const existingId = editKey ? this.liveMessages.get(editKey) : undefined;
|
|
1973
|
+
let firstMessageId: number | undefined;
|
|
1974
|
+
if (editKey && existingId !== undefined) {
|
|
1975
|
+
// Edit the existing streamed message in place with the first chunk
|
|
1976
|
+
// so a finalized turn never leaves a stale live preview. A LOCAL
|
|
1977
|
+
// try/catch keeps a failed edit from aborting the continuation
|
|
1978
|
+
// requeue below; "message is not modified" is a success (the message
|
|
1979
|
+
// already shows this text); a missing/deleted backing message (or a
|
|
1980
|
+
// transport error) resends so the first chunk is never lost.
|
|
1981
|
+
let edited = false;
|
|
1982
|
+
try {
|
|
1983
|
+
const res = (await this.botApi.call("editMessageText", {
|
|
1984
|
+
chat_id: this.opts.chatId,
|
|
1985
|
+
message_id: existingId,
|
|
1986
|
+
text: chunks[0],
|
|
1987
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1988
|
+
})) as { ok?: boolean; description?: string } | null;
|
|
1989
|
+
edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
|
|
1990
|
+
} catch {
|
|
1991
|
+
edited = false;
|
|
1992
|
+
}
|
|
1993
|
+
if (edited) {
|
|
1994
|
+
firstMessageId = existingId;
|
|
1995
|
+
} else {
|
|
1996
|
+
const res = (await this.botApi.call("sendMessage", {
|
|
1997
|
+
chat_id: this.opts.chatId,
|
|
1998
|
+
...threadField,
|
|
1999
|
+
text: chunks[0]!,
|
|
2000
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2001
|
+
})) as { result?: { message_id?: number } };
|
|
2002
|
+
firstMessageId = res?.result?.message_id;
|
|
2003
|
+
}
|
|
2004
|
+
} else {
|
|
2005
|
+
// No streamed message to edit: a single granted slot maps to a
|
|
2006
|
+
// single Telegram send.
|
|
2007
|
+
const res = (await this.botApi.call("sendMessage", {
|
|
2008
|
+
chat_id: this.opts.chatId,
|
|
2009
|
+
...threadField,
|
|
2010
|
+
text: chunks[0]!,
|
|
2011
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2012
|
+
})) as { result?: { message_id?: number } };
|
|
2013
|
+
firstMessageId = res?.result?.message_id;
|
|
2014
|
+
}
|
|
2015
|
+
// Continuation chunks are FINALIZED-lane only. A live preview is a
|
|
2016
|
+
// single edit-safe chunk (its authoritative full text arrives with the
|
|
2017
|
+
// finalized frame), so a split live frame never fans out into stale,
|
|
2018
|
+
// non-coalesced continuation messages. Finalized continuations are
|
|
2019
|
+
// fresh, non-editable, HTML-only sends (rich markers stripped) so they
|
|
2020
|
+
// can never be re-promoted to a duplicate sendRichMessage.
|
|
2021
|
+
if (item.lane !== "live") {
|
|
2022
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
2023
|
+
this.pool.submit({
|
|
2024
|
+
sessionId: item.sessionId,
|
|
2025
|
+
lane: item.lane,
|
|
2026
|
+
payload: {
|
|
2027
|
+
send: {
|
|
2028
|
+
...send,
|
|
2029
|
+
method: "sendMessage",
|
|
2030
|
+
text: chunks[i]!,
|
|
2031
|
+
editable: false,
|
|
2032
|
+
coalesceKey: undefined,
|
|
2033
|
+
photoBase64: undefined,
|
|
2034
|
+
documentBase64: undefined,
|
|
2035
|
+
richMarkdown: undefined,
|
|
2036
|
+
richDraftMarkdown: undefined,
|
|
2037
|
+
richClass: undefined,
|
|
2038
|
+
},
|
|
2039
|
+
topicId,
|
|
2040
|
+
},
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
1891
2044
|
if (editKey && ckey !== undefined && firstMessageId !== undefined) {
|
|
1892
2045
|
this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
|
|
1893
2046
|
}
|
|
@@ -1959,7 +2112,7 @@ export class TelegramNotificationDaemon {
|
|
|
1959
2112
|
try {
|
|
1960
2113
|
await this.botApi.call("sendMessage", {
|
|
1961
2114
|
chat_id: this.opts.chatId,
|
|
1962
|
-
text: "
|
|
2115
|
+
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
2116
|
parse_mode: TELEGRAM_PARSE_MODE,
|
|
1964
2117
|
});
|
|
1965
2118
|
} catch {
|
|
@@ -2108,24 +2261,58 @@ export class TelegramNotificationDaemon {
|
|
|
2108
2261
|
});
|
|
2109
2262
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
2110
2263
|
// Daemon keyboards use alias callback data with compact one-based tap targets;
|
|
2111
|
-
// full option text is rendered in the message body by buildActionMessage.
|
|
2264
|
+
// full option text is rendered in the message body by buildActionMessage/buildActionMarkdown.
|
|
2112
2265
|
const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
|
|
2113
2266
|
this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
|
|
2114
2267
|
);
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2268
|
+
// HTML delivery: one sendMessage per chunk, keyboard on the last chunk;
|
|
2269
|
+
// returns the last chunk's message_id (the reply-routable message).
|
|
2270
|
+
const sendHtmlChunks = async (): Promise<number | undefined> => {
|
|
2271
|
+
const chunks = splitTelegramHtml(rendered.text);
|
|
2272
|
+
let result: { result?: { message_id?: number } } = {};
|
|
2273
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2274
|
+
result = (await this.botApi.call("sendMessage", {
|
|
2275
|
+
chat_id: this.opts.chatId,
|
|
2276
|
+
...threadField,
|
|
2277
|
+
text: chunks[i]!,
|
|
2278
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2279
|
+
...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
|
|
2280
|
+
})) as { result?: { message_id?: number } };
|
|
2281
|
+
}
|
|
2282
|
+
return result.result?.message_id;
|
|
2283
|
+
};
|
|
2284
|
+
const kind = msg.kind === "idle" ? "idle" : "ask";
|
|
2285
|
+
if (this.opts.rich?.enabled !== false) {
|
|
2286
|
+
// Rich (default on): promote to sendRichMessage with a top-level
|
|
2287
|
+
// reply_markup (probe-confirmed). Any miss falls back to the HTML loop.
|
|
2288
|
+
|
|
2289
|
+
const outcome = await deliverRichActionWithFallback(
|
|
2290
|
+
this.botApi,
|
|
2291
|
+
{ chat_id: this.opts.chatId, ...threadField },
|
|
2292
|
+
{
|
|
2293
|
+
markdown: buildActionMarkdown({
|
|
2294
|
+
kind,
|
|
2295
|
+
question: msg.question,
|
|
2296
|
+
options: msg.options,
|
|
2297
|
+
summary: msg.summary,
|
|
2298
|
+
}),
|
|
2299
|
+
replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
|
|
2300
|
+
requireMessageId: kind === "ask",
|
|
2301
|
+
},
|
|
2302
|
+
sendHtmlChunks,
|
|
2303
|
+
logger,
|
|
2304
|
+
);
|
|
2305
|
+
// Only asks are reply-routable; idle pings register no route.
|
|
2306
|
+
if (kind === "ask" && outcome.messageId !== undefined)
|
|
2307
|
+
this.messageRoutes.set(String(outcome.messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
2308
|
+
} else {
|
|
2309
|
+
// Off: byte-identical to the pre-rich HTML path.
|
|
2310
|
+
const messageId = await sendHtmlChunks();
|
|
2311
|
+
// Only asks are reply-routable; idle pings register no route (parity
|
|
2312
|
+
// with the rich branch and correct even in the byte-identical off path).
|
|
2313
|
+
if (kind === "ask" && messageId !== undefined)
|
|
2314
|
+
this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
2125
2315
|
}
|
|
2126
|
-
const messageId = result.result?.message_id;
|
|
2127
|
-
if (messageId !== undefined)
|
|
2128
|
-
this.messageRoutes.set(String(messageId), { sessionId: session.sessionId, actionId: msg.id });
|
|
2129
2316
|
await this.persistAliases();
|
|
2130
2317
|
} else if (msg.type === "action_resolved" && msg.id) {
|
|
2131
2318
|
session.pending.delete(msg.id);
|
|
@@ -2181,6 +2368,65 @@ export class TelegramNotificationDaemon {
|
|
|
2181
2368
|
}
|
|
2182
2369
|
}
|
|
2183
2370
|
}
|
|
2371
|
+
// Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
|
|
2372
|
+
// session config forward. Handled at paired-chat pre-routing, before threaded
|
|
2373
|
+
// injection and independent of any session WebSocket, so it works even when
|
|
2374
|
+
// no session is connected and never becomes an ask answer.
|
|
2375
|
+
{
|
|
2376
|
+
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
2377
|
+
const chat = m?.chat as { id?: unknown } | undefined;
|
|
2378
|
+
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
2379
|
+
const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
2380
|
+
// Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
|
|
2381
|
+
// appends @botname in groups; the bot username may be unknown if getMe
|
|
2382
|
+
// failed) so a rich command is never leaked into threaded injection / an
|
|
2383
|
+
// ask answer. Argument validity is decided by parseRichToggleCommand below.
|
|
2384
|
+
const isRichCommand = rawFirst?.split("@")[0] === "/rich";
|
|
2385
|
+
if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
|
|
2386
|
+
// Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
|
|
2387
|
+
// paired chat — the same contract as session delivery and lifecycle
|
|
2388
|
+
// commands. A group/supergroup chatId (legacy or hand-edited) must never
|
|
2389
|
+
// let an arbitrary chat member toggle the owner's notification config.
|
|
2390
|
+
if (!(await this.pairedChatIsPrivate())) return;
|
|
2391
|
+
const updateId = (update as { update_id?: number }).update_id;
|
|
2392
|
+
// Dedupe redelivered updates so a toggle+confirmation runs at most once.
|
|
2393
|
+
if (typeof updateId === "number") {
|
|
2394
|
+
if (this.dispatchState.seenUpdateIds.has(updateId)) return;
|
|
2395
|
+
await this.rememberSeenUpdateId(updateId);
|
|
2396
|
+
}
|
|
2397
|
+
const threadField =
|
|
2398
|
+
typeof m.message_thread_id === "number" ? { message_thread_id: m.message_thread_id as number } : {};
|
|
2399
|
+
const reply = async (body: string): Promise<void> => {
|
|
2400
|
+
try {
|
|
2401
|
+
await this.botApi.call("sendMessage", {
|
|
2402
|
+
chat_id: this.opts.chatId,
|
|
2403
|
+
...threadField,
|
|
2404
|
+
text: body,
|
|
2405
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
2406
|
+
});
|
|
2407
|
+
} catch {
|
|
2408
|
+
// Best-effort confirmation; never block on the notice.
|
|
2409
|
+
}
|
|
2410
|
+
};
|
|
2411
|
+
const desired = parseRichToggleCommand(cmdText ?? "");
|
|
2412
|
+
if (desired === undefined) {
|
|
2413
|
+
await reply("Usage: /rich on|off");
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
try {
|
|
2417
|
+
await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
|
|
2418
|
+
} catch (err) {
|
|
2419
|
+
logger.warn(
|
|
2420
|
+
`notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
|
|
2421
|
+
);
|
|
2422
|
+
await reply("Rich messages: unchanged (settings write failed)");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
this.opts.rich = { enabled: desired };
|
|
2426
|
+
await reply(desired ? "Rich messages: on" : "Rich messages: off");
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2184
2430
|
// Threaded injection: a free-text message in a known topic (not a button
|
|
2185
2431
|
// tap and not a reply to a specific ask message) injects a user turn or an
|
|
2186
2432
|
// in-thread config command. Fail-closed: paired chat + known topic +
|
|
@@ -2211,7 +2457,17 @@ export class TelegramNotificationDaemon {
|
|
|
2211
2457
|
const images = attachmentResult?.images ?? [];
|
|
2212
2458
|
const fileNotes = attachmentResult?.fileNotes ?? [];
|
|
2213
2459
|
const hasMedia = images.length > 0 || fileNotes.length > 0;
|
|
2214
|
-
const
|
|
2460
|
+
const baseInjectedText = [inbound.text, ...fileNotes].filter(Boolean).join("\n");
|
|
2461
|
+
// A reply to a rich message we sent (not an ask route) loses its original
|
|
2462
|
+
// text: Telegram does not echo it in reply_to_message. Restore it from the
|
|
2463
|
+
// reply index as a labeled context prefix; a miss leaves the turn unchanged.
|
|
2464
|
+
const repliedOriginal =
|
|
2465
|
+
typeof replyTo === "number"
|
|
2466
|
+
? this.replyStore.lookup({ chatId: this.opts.chatId, messageId: replyTo })
|
|
2467
|
+
: undefined;
|
|
2468
|
+
const injectedText = repliedOriginal
|
|
2469
|
+
? `> replied-to message:\n${repliedOriginal}\n\n${baseInjectedText}`
|
|
2470
|
+
: baseInjectedText;
|
|
2215
2471
|
const cfg = hasMedia ? undefined : parseInThreadConfigCommand(inbound.text);
|
|
2216
2472
|
// A plain (non-config) message while an ask is pending for this session
|
|
2217
2473
|
// answers that ask as free-input — instead of starting a new user turn.
|
|
@@ -2291,7 +2547,8 @@ export class TelegramNotificationDaemon {
|
|
|
2291
2547
|
{ command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
|
|
2292
2548
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
2293
2549
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
2294
|
-
{ command: "
|
|
2550
|
+
{ command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
|
|
2551
|
+
{ command: "session_create", description: "Create a SKC session: path, worktree, or dir [--mpreset]" },
|
|
2295
2552
|
{ command: "session_recent", description: "List recent SKC sessions" },
|
|
2296
2553
|
{ command: "session_close", description: "Close a SKC-managed session" },
|
|
2297
2554
|
{ command: "session_resume", description: "Resume or reattach a session" },
|
|
@@ -2321,6 +2578,7 @@ export class TelegramNotificationDaemon {
|
|
|
2321
2578
|
await this.loadAliases();
|
|
2322
2579
|
await this.loadTopics();
|
|
2323
2580
|
await this.loadSeenUpdateIds();
|
|
2581
|
+
await this.replyStore.load();
|
|
2324
2582
|
await this.runScan();
|
|
2325
2583
|
// Owner-only: start the session-lifecycle control server now that
|
|
2326
2584
|
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
@@ -2341,31 +2599,30 @@ export class TelegramNotificationDaemon {
|
|
|
2341
2599
|
await this.runScan();
|
|
2342
2600
|
if (await this.controlStopRequested()) break;
|
|
2343
2601
|
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
|
-
}
|
|
2602
|
+
if (this.sessions.size > 0) {
|
|
2603
|
+
idleSince = this.runtime.now();
|
|
2604
|
+
} else if (idleElapsed) {
|
|
2605
|
+
// Zero sessions past the idle window: exit so the owner does not run
|
|
2606
|
+
// forever. An active session resets the idle window above.
|
|
2607
|
+
break;
|
|
2608
|
+
}
|
|
2609
|
+
// Poll getUpdates whenever the daemon owns the token — even with zero
|
|
2610
|
+
// sessions and no lifecycle control — so daemon-local commands (/rich,
|
|
2611
|
+
// /session_*) are always received until idle-exit.
|
|
2612
|
+
const activePoll = this.runtime.createAbortController();
|
|
2613
|
+
try {
|
|
2614
|
+
await this.pollOnce(activePoll.signal);
|
|
2615
|
+
this.loopBackoff.reset();
|
|
2616
|
+
} catch (e) {
|
|
2617
|
+
// A transient getUpdates/network failure must not kill the daemon.
|
|
2618
|
+
// Back off (bounded, below the heartbeat TTL) and keep renewing
|
|
2619
|
+
// ownership at the loop top.
|
|
2620
|
+
const backoffMs = this.loopBackoff.next();
|
|
2621
|
+
logger.warn(`notifications: getUpdates failed, backing off ${backoffMs}ms: ${String(e)}`);
|
|
2622
|
+
await this.runtime.sleep(backoffMs);
|
|
2623
|
+
continue;
|
|
2624
|
+
} finally {
|
|
2625
|
+
this.runtime.clearAbortController(activePoll);
|
|
2369
2626
|
}
|
|
2370
2627
|
if (await this.controlStopRequested()) break;
|
|
2371
2628
|
await this.runtime.sleep(10);
|
|
@@ -143,6 +143,23 @@ export function buildActionMessage(action: {
|
|
|
143
143
|
return { text: body, inline_keyboard };
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/** Render an `action_needed` body as raw markdown (rich-message source; the HTML fallback stays on buildActionMessage). */
|
|
147
|
+
export function buildActionMarkdown(action: {
|
|
148
|
+
kind: "ask" | "idle";
|
|
149
|
+
question?: string;
|
|
150
|
+
options?: string[];
|
|
151
|
+
summary?: string;
|
|
152
|
+
}): string {
|
|
153
|
+
if (action.kind === "idle") {
|
|
154
|
+
return action.summary ? `🟢 Agent idle\n${action.summary}` : "🟢 Agent idle";
|
|
155
|
+
}
|
|
156
|
+
const heading = `❓ **${action.question ?? "Question"}**`;
|
|
157
|
+
const options = action.options ?? [];
|
|
158
|
+
if (options.length === 0) return `${heading}\n\n(reply with text)`;
|
|
159
|
+
const list = options.map((label, i) => `${i + 1}. ${label.replace(/^\s*\d+[.)]\s+/, "")}`).join("\n");
|
|
160
|
+
return `${heading}\n\n${list}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
146
163
|
/** Send Telegram HTML text chunks sequentially so long messages preserve order. */
|
|
147
164
|
export async function sendTelegramHtmlChunks(
|
|
148
165
|
send: TelegramSend,
|
|
@@ -38,6 +38,13 @@ export interface ThreadedSend {
|
|
|
38
38
|
* message. Set for streamed turn frames so live + finalized share one message.
|
|
39
39
|
*/
|
|
40
40
|
editable?: boolean;
|
|
41
|
+
/** Rich message class metadata. Only finalized final-answer sends are ever
|
|
42
|
+
* rich-promoted; the daemon gate (`shouldPromoteRich`) requires exactly this. */
|
|
43
|
+
richClass?: "final";
|
|
44
|
+
/** Rich final-answer markdown (raw). Delivery marker derived ONLY from a frame's `finalAnswer` bit; never inferred from `phase`. */
|
|
45
|
+
richMarkdown?: string;
|
|
46
|
+
/** Live-turn raw markdown for opt-in draft streaming (set ONLY on non-finalized turn frames; never triggers rich-final promotion, which requires `lane === "finalized"`). */
|
|
47
|
+
richDraftMarkdown?: string;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
interface ThreadedFrame {
|
|
@@ -58,6 +65,7 @@ interface ThreadedFrame {
|
|
|
58
65
|
cwd?: unknown;
|
|
59
66
|
// turn_stream
|
|
60
67
|
phase?: unknown;
|
|
68
|
+
finalAnswer?: boolean;
|
|
61
69
|
text?: unknown;
|
|
62
70
|
messageRef?: unknown;
|
|
63
71
|
// image_attachment / file_attachment
|
|
@@ -75,6 +83,10 @@ function str(v: unknown): string | undefined {
|
|
|
75
83
|
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
function isDotOnlyText(value: string): boolean {
|
|
87
|
+
return /^[.\s]+$/.test(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
78
90
|
/** Format the one-time identity header as pinned bullets. */
|
|
79
91
|
export function formatIdentityHeader(frame: {
|
|
80
92
|
repo?: unknown;
|
|
@@ -139,6 +151,7 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
|
|
|
139
151
|
case "turn_stream": {
|
|
140
152
|
const raw = str(frame.text);
|
|
141
153
|
if (!raw) return undefined;
|
|
154
|
+
if (frame.phase === "finalized" && isDotOnlyText(raw)) return undefined;
|
|
142
155
|
const text = markdownToTelegramHtml(raw);
|
|
143
156
|
const finalized = frame.phase === "finalized";
|
|
144
157
|
// A per-turn ref ties the streamed live edits and the finalized text to
|
|
@@ -156,6 +169,16 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
|
|
|
156
169
|
text,
|
|
157
170
|
coalesceKey,
|
|
158
171
|
editable: coalesceKey !== undefined,
|
|
172
|
+
// Rich-final markers are set ONLY on a non-editable finalized final
|
|
173
|
+
// (no messageRef / coalesceKey). A streamed (editable) final owns a live
|
|
174
|
+
// message edited in place, so it is never rich-promoted and carries no
|
|
175
|
+
// rich marker to leak into split continuations.
|
|
176
|
+
richClass: frame.finalAnswer === true && coalesceKey === undefined ? "final" : undefined,
|
|
177
|
+
richMarkdown: frame.finalAnswer === true && coalesceKey === undefined ? raw : undefined,
|
|
178
|
+
// Live-only draft marker: carries the RAW markdown on non-finalized turn
|
|
179
|
+
// frames so the opt-in draft gate has the source. It never arms rich-final
|
|
180
|
+
// promotion (shouldPromoteRich requires lane === "finalized").
|
|
181
|
+
richDraftMarkdown: finalized ? undefined : raw,
|
|
159
182
|
};
|
|
160
183
|
}
|
|
161
184
|
case "image_attachment": {
|
|
@@ -188,7 +211,11 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
|
|
|
188
211
|
const redact = typeof frame.redact === "boolean" ? `redact ${frame.redact ? "on" : "off"}` : undefined;
|
|
189
212
|
const parts = [verbosity ? `verbosity ${verbosity}` : undefined, redact].filter(Boolean);
|
|
190
213
|
return parts.length
|
|
191
|
-
? {
|
|
214
|
+
? {
|
|
215
|
+
method: "sendMessage",
|
|
216
|
+
lane: "idle",
|
|
217
|
+
text: finalizeTelegramHtml(`⚙ ${escapeHtml(parts.join(", "))}`),
|
|
218
|
+
}
|
|
192
219
|
: undefined;
|
|
193
220
|
}
|
|
194
221
|
default:
|