talon-agent 4.3.2 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +1 -1
  3. package/src/backend/claude-sdk/doctor.ts +1 -1
  4. package/src/backend/claude-sdk/state.ts +1 -1
  5. package/src/backend/codex/doctor.ts +1 -1
  6. package/src/backend/codex/init.ts +1 -1
  7. package/src/backend/codex/state.ts +1 -1
  8. package/src/backend/openai-agents/doctor.ts +1 -1
  9. package/src/backend/openai-agents/init.ts +1 -1
  10. package/src/backend/openai-agents/state.ts +1 -1
  11. package/src/backend/remote-server/factory.ts +1 -1
  12. package/src/backend/remote-server/mcp.ts +1 -1
  13. package/src/backend/remote-server/server-bindings.ts +1 -1
  14. package/src/backend/remote-server/state.ts +1 -1
  15. package/src/backend/shared/system-prompt.ts +1 -1
  16. package/src/bootstrap.ts +3 -3
  17. package/src/cli/chat.ts +1 -1
  18. package/src/cli/config.ts +2 -2
  19. package/src/cli/doctor.ts +1 -1
  20. package/src/cli/index.ts +8 -0
  21. package/src/cli/memory.ts +166 -0
  22. package/src/cli/setup.ts +1 -1
  23. package/src/core/agent-runtime/backend-registry.ts +2 -2
  24. package/src/core/agent-runtime/model-ref.ts +2 -2
  25. package/src/core/auth/expiry-monitor.ts +1 -1
  26. package/src/{util/config.ts → core/config/index.ts} +8 -8
  27. package/src/core/{doctor.ts → doctor/index.ts} +11 -11
  28. package/src/core/engine/backend-controller/legacy.ts +1 -1
  29. package/src/core/engine/backend-controller/pool.ts +1 -1
  30. package/src/core/engine/backend-controller/rebind.ts +1 -1
  31. package/src/core/engine/backend-controller/state.ts +1 -1
  32. package/src/core/engine/gateway-actions/plugins.ts +1 -1
  33. package/src/core/engine/model-audit.ts +1 -1
  34. package/src/core/{notify.ts → frontend-runtime/admin-notify.ts} +1 -1
  35. package/src/core/frontend-runtime/capabilities.ts +1 -1
  36. package/src/core/models/active-model.ts +1 -1
  37. package/src/core/plugin/builtins.ts +2 -2
  38. package/src/core/plugin/manage.ts +1 -1
  39. package/src/core/plugin/native-runtimes.ts +1 -1
  40. package/src/frontend/discord/admin.ts +1 -1
  41. package/src/frontend/discord/callbacks/components/index.ts +1 -1
  42. package/src/frontend/discord/callbacks/components/settings.ts +1 -1
  43. package/src/frontend/discord/callbacks/components/types.ts +1 -1
  44. package/src/frontend/discord/callbacks/modals.ts +1 -1
  45. package/src/frontend/discord/commands/admin.ts +2 -2
  46. package/src/frontend/discord/commands/definitions.ts +1 -1
  47. package/src/frontend/discord/commands/info.ts +1 -1
  48. package/src/frontend/discord/commands/router.ts +1 -1
  49. package/src/frontend/discord/commands/session.ts +1 -1
  50. package/src/frontend/discord/commands/settings.ts +1 -1
  51. package/src/frontend/discord/handlers/delivery.ts +1 -1
  52. package/src/frontend/discord/handlers/messages.ts +1 -1
  53. package/src/frontend/discord/handlers/queue.ts +1 -1
  54. package/src/frontend/discord/handlers/state.ts +1 -1
  55. package/src/frontend/discord/index.ts +1 -1
  56. package/src/frontend/discord/middleware.ts +1 -1
  57. package/src/frontend/discord/render.ts +1 -1
  58. package/src/frontend/discord/runtime.ts +1 -1
  59. package/src/frontend/native/extensions.ts +4 -1
  60. package/src/frontend/native/index.ts +1 -1
  61. package/src/frontend/native/runtime.ts +1 -1
  62. package/src/frontend/native/settings.ts +1 -1
  63. package/src/frontend/shared/model-commands.ts +1 -1
  64. package/src/frontend/shared/plan-usage-report.ts +1 -1
  65. package/src/frontend/shared/reasoning-levels.ts +1 -1
  66. package/src/frontend/shared/session-status.ts +1 -1
  67. package/src/frontend/teams/index.ts +1 -1
  68. package/src/frontend/teams/runtime.ts +1 -1
  69. package/src/frontend/telegram/actions/chat-info.ts +1 -1
  70. package/src/frontend/telegram/actions/coerce.ts +15 -0
  71. package/src/frontend/telegram/actions/index.ts +2 -2
  72. package/src/frontend/telegram/actions/media.ts +1 -1
  73. package/src/frontend/telegram/actions/messaging.ts +3 -4
  74. package/src/frontend/telegram/actions/moderation/chat.ts +63 -0
  75. package/src/frontend/telegram/actions/moderation/index.ts +74 -0
  76. package/src/frontend/telegram/actions/moderation/members.ts +77 -0
  77. package/src/frontend/telegram/actions/moderation/permissions.ts +69 -0
  78. package/src/frontend/telegram/actions/moderation/topics.ts +48 -0
  79. package/src/frontend/telegram/actions/moderation/types.ts +16 -0
  80. package/src/frontend/telegram/actions/rich-messages.ts +51 -0
  81. package/src/frontend/telegram/actions/{shared.ts → send.ts} +9 -59
  82. package/src/frontend/telegram/admin/background.ts +66 -0
  83. package/src/frontend/telegram/admin/chunked-reply.ts +20 -0
  84. package/src/frontend/telegram/admin/health.ts +81 -0
  85. package/src/frontend/telegram/admin/sessions.ts +105 -0
  86. package/src/frontend/telegram/admin.ts +57 -262
  87. package/src/frontend/telegram/callbacks/auth.ts +1 -1
  88. package/src/frontend/telegram/callbacks/effort.ts +1 -1
  89. package/src/frontend/telegram/callbacks/index.ts +3 -3
  90. package/src/frontend/telegram/callbacks/metrics.ts +1 -1
  91. package/src/frontend/telegram/callbacks/model/backend.ts +156 -0
  92. package/src/frontend/telegram/callbacks/model/control.ts +29 -0
  93. package/src/frontend/telegram/callbacks/model/nav.ts +54 -0
  94. package/src/frontend/telegram/callbacks/model/select.ts +100 -0
  95. package/src/frontend/telegram/callbacks/model/types.ts +39 -0
  96. package/src/frontend/telegram/callbacks/model/views.ts +116 -0
  97. package/src/frontend/telegram/callbacks/model.ts +61 -331
  98. package/src/frontend/telegram/callbacks/pulse.ts +1 -1
  99. package/src/frontend/telegram/callbacks/{shared.ts → query.ts} +3 -2
  100. package/src/frontend/telegram/callbacks/settings.ts +1 -1
  101. package/src/frontend/telegram/callbacks/whatsapp.ts +1 -1
  102. package/src/frontend/telegram/commands/admin.ts +108 -77
  103. package/src/frontend/telegram/commands/index.ts +1 -1
  104. package/src/frontend/telegram/commands/settings.ts +65 -43
  105. package/src/frontend/telegram/commands/state.ts +1 -1
  106. package/src/frontend/telegram/commands/whatsapp-pairing.ts +1 -1
  107. package/src/frontend/telegram/handlers/context.ts +1 -1
  108. package/src/frontend/telegram/handlers/delivery.ts +2 -2
  109. package/src/frontend/telegram/handlers/messages.ts +1 -1
  110. package/src/frontend/telegram/handlers/queue.ts +1 -1
  111. package/src/frontend/telegram/handlers/state.ts +1 -1
  112. package/src/frontend/telegram/helpers/diagnostics.ts +1 -1
  113. package/src/frontend/telegram/index.ts +1 -1
  114. package/src/frontend/telegram/middleware.ts +167 -202
  115. package/src/frontend/telegram/model-menu.ts +1 -1
  116. package/src/frontend/terminal/builtins/context.ts +184 -0
  117. package/src/frontend/terminal/builtins/help.ts +35 -0
  118. package/src/frontend/terminal/builtins/index.ts +23 -0
  119. package/src/frontend/terminal/builtins/model.ts +198 -0
  120. package/src/frontend/terminal/builtins/session.ts +101 -0
  121. package/src/frontend/terminal/builtins/status.ts +202 -0
  122. package/src/frontend/terminal/command-registry.ts +80 -0
  123. package/src/frontend/terminal/commands.ts +11 -674
  124. package/src/frontend/terminal/index.ts +1 -1
  125. package/src/frontend/terminal/input/history.ts +64 -0
  126. package/src/frontend/terminal/input/keys.ts +143 -0
  127. package/src/frontend/terminal/input/parts.ts +33 -0
  128. package/src/frontend/terminal/input/paste.ts +53 -0
  129. package/src/frontend/terminal/input/render.ts +42 -0
  130. package/src/frontend/terminal/input/state.ts +60 -0
  131. package/src/frontend/terminal/input.ts +27 -342
  132. package/src/frontend/whatsapp/connection.ts +1 -1
  133. package/src/frontend/whatsapp/index.ts +2 -2
  134. package/src/frontend/whatsapp/pairing-service.ts +1 -1
  135. package/src/frontend/whatsapp/runtime.ts +1 -1
  136. package/src/plugins/github/provision.ts +1 -1
  137. package/src/plugins/mempalace/provision.ts +1 -1
  138. package/src/plugins/playwright/provision.ts +1 -1
  139. package/src/storage/memory.ts +505 -0
  140. package/src/storage/repositories/memory-repo.ts +308 -0
  141. package/src/storage/sql/memory.sql +95 -0
  142. package/src/storage/sql/schema.sql +81 -0
  143. package/src/storage/sql/statements.generated.ts +146 -0
  144. package/src/frontend/telegram/actions/moderation.ts +0 -255
  145. /package/src/core/{doctor-types.ts → doctor/types.ts} +0 -0
  146. /package/src/core/{pairing-broker.ts → frontend-runtime/pairing-broker.ts} +0 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Member ops — ban / unban / mute / unmute / promote / demote /
3
+ * set_admin_title, plus the join-request trio. Every op here except
4
+ * `list_join_requests` needs a `user_id`; the router has already checked
5
+ * that, so `userId!` is safe.
6
+ */
7
+
8
+ import { clearJoinRequest, listJoinRequests } from "../../join-requests.js";
9
+ import {
10
+ DEFAULT_ADMIN_RIGHTS,
11
+ FULL_MEMBER_PERMISSIONS,
12
+ untilDate,
13
+ } from "./permissions.js";
14
+ import type { ModerationOps } from "./types.js";
15
+
16
+ export const memberOps: ModerationOps = {
17
+ ban: async ({ body, chatId, ctx: { bot }, userId }) => {
18
+ await bot.api.banChatMember(chatId, userId!, {
19
+ until_date: untilDate(body.minutes),
20
+ revoke_messages: body.delete_messages === true || undefined,
21
+ });
22
+ return { ok: true };
23
+ },
24
+ unban: async ({ chatId, ctx: { bot }, userId }) => {
25
+ // only_if_banned keeps this from kicking a current member.
26
+ await bot.api.unbanChatMember(chatId, userId!, {
27
+ only_if_banned: true,
28
+ });
29
+ return { ok: true };
30
+ },
31
+ mute: async ({ body, chatId, ctx: { bot }, userId }) => {
32
+ await bot.api.restrictChatMember(
33
+ chatId,
34
+ userId!,
35
+ { can_send_messages: false },
36
+ { until_date: untilDate(body.minutes) },
37
+ );
38
+ return { ok: true };
39
+ },
40
+ unmute: async ({ chatId, ctx: { bot }, userId }) => {
41
+ await bot.api.restrictChatMember(chatId, userId!, FULL_MEMBER_PERMISSIONS);
42
+ return { ok: true };
43
+ },
44
+ promote: async ({ chatId, ctx: { bot }, userId }) => {
45
+ await bot.api.promoteChatMember(chatId, userId!, DEFAULT_ADMIN_RIGHTS);
46
+ return { ok: true };
47
+ },
48
+ demote: async ({ chatId, ctx: { bot }, userId }) => {
49
+ const revoked = Object.fromEntries(
50
+ Object.keys(DEFAULT_ADMIN_RIGHTS).map((k) => [k, false]),
51
+ );
52
+ await bot.api.promoteChatMember(chatId, userId!, revoked);
53
+ return { ok: true };
54
+ },
55
+ set_admin_title: async ({ body, chatId, ctx: { bot }, userId }) => {
56
+ await bot.api.setChatAdministratorCustomTitle(
57
+ chatId,
58
+ userId!,
59
+ String(body.title ?? ""),
60
+ );
61
+ return { ok: true };
62
+ },
63
+ approve_join_request: async ({ chatId, ctx: { bot }, userId }) => {
64
+ await bot.api.approveChatJoinRequest(chatId, userId!);
65
+ clearJoinRequest(chatId, userId!);
66
+ return { ok: true };
67
+ },
68
+ decline_join_request: async ({ chatId, ctx: { bot }, userId }) => {
69
+ await bot.api.declineChatJoinRequest(chatId, userId!);
70
+ clearJoinRequest(chatId, userId!);
71
+ return { ok: true };
72
+ },
73
+ list_join_requests: async ({ chatId }) => ({
74
+ ok: true,
75
+ requests: listJoinRequests(chatId),
76
+ }),
77
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Permission vocabulary shared by the member and chat ops.
3
+ */
4
+
5
+ import type { ChatPermissions } from "grammy/types";
6
+
7
+ /** Seconds-from-now → Telegram until_date, honouring "omit = forever". */
8
+ export function untilDate(minutes: unknown): number | undefined {
9
+ const n = Number(minutes);
10
+ if (!Number.isFinite(n) || n <= 0) return undefined;
11
+ return Math.floor(Date.now() / 1000) + Math.round(n * 60);
12
+ }
13
+
14
+ /**
15
+ * Friendly permission names → ChatPermissions. `send_media` fans out to all
16
+ * the per-kind media flags; raw `can_*` keys pass through untouched so the
17
+ * full API surface stays reachable.
18
+ */
19
+ export function toChatPermissions(
20
+ raw: Record<string, boolean>,
21
+ ): ChatPermissions {
22
+ const out: Record<string, boolean> = {};
23
+ const alias: Record<string, string[]> = {
24
+ send_messages: ["can_send_messages"],
25
+ send_media: [
26
+ "can_send_audios",
27
+ "can_send_documents",
28
+ "can_send_photos",
29
+ "can_send_videos",
30
+ "can_send_video_notes",
31
+ "can_send_voice_notes",
32
+ ],
33
+ send_polls: ["can_send_polls"],
34
+ send_other: ["can_send_other_messages"],
35
+ web_previews: ["can_add_web_page_previews"],
36
+ change_info: ["can_change_info"],
37
+ invite_users: ["can_invite_users"],
38
+ pin_messages: ["can_pin_messages"],
39
+ manage_topics: ["can_manage_topics"],
40
+ };
41
+ for (const [key, value] of Object.entries(raw)) {
42
+ if (typeof value !== "boolean") continue;
43
+ for (const target of alias[key] ?? (key.startsWith("can_") ? [key] : [])) {
44
+ out[target] = value;
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** Everything a mute takes away; unmute grants it back. */
51
+ export const FULL_MEMBER_PERMISSIONS: ChatPermissions = toChatPermissions({
52
+ send_messages: true,
53
+ send_media: true,
54
+ send_polls: true,
55
+ send_other: true,
56
+ web_previews: true,
57
+ });
58
+
59
+ /** The workaday admin kit `promote` grants; demote sets all of these false. */
60
+ export const DEFAULT_ADMIN_RIGHTS = {
61
+ can_manage_chat: true,
62
+ can_delete_messages: true,
63
+ can_restrict_members: true,
64
+ can_pin_messages: true,
65
+ can_invite_users: true,
66
+ can_change_info: true,
67
+ can_manage_topics: true,
68
+ can_manage_video_chats: true,
69
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Forum-topic ops — create / edit / close / reopen / delete.
3
+ */
4
+
5
+ import type { Bot } from "grammy";
6
+ import { toPositiveId } from "../coerce.js";
7
+ import type { ModerationOp, ModerationOps } from "./types.js";
8
+
9
+ /** An op on an existing topic: validates `thread_id`, then runs `call`. */
10
+ function topicOp(
11
+ call: (
12
+ api: Bot["api"],
13
+ chatId: number,
14
+ threadId: number,
15
+ body: Record<string, unknown>,
16
+ ) => Promise<unknown>,
17
+ ): ModerationOp {
18
+ return async ({ op, body, chatId, ctx: { bot } }) => {
19
+ const threadId = toPositiveId(body.thread_id);
20
+ if (threadId === undefined)
21
+ return { ok: false, error: `${op}: thread_id required` };
22
+ await call(bot.api, chatId, threadId, body);
23
+ return { ok: true };
24
+ };
25
+ }
26
+
27
+ export const topicOps: ModerationOps = {
28
+ create_topic: async ({ body, chatId, ctx: { bot } }) => {
29
+ if (!body.title)
30
+ return { ok: false, error: "create_topic: title required" };
31
+ const topic = await bot.api.createForumTopic(chatId, String(body.title));
32
+ return { ok: true, thread_id: topic.message_thread_id };
33
+ },
34
+ edit_topic: topicOp((api, chatId, threadId, body) =>
35
+ api.editForumTopic(chatId, threadId, {
36
+ name: body.title ? String(body.title) : undefined,
37
+ }),
38
+ ),
39
+ close_topic: topicOp((api, chatId, threadId) =>
40
+ api.closeForumTopic(chatId, threadId),
41
+ ),
42
+ reopen_topic: topicOp((api, chatId, threadId) =>
43
+ api.reopenForumTopic(chatId, threadId),
44
+ ),
45
+ delete_topic: topicOp((api, chatId, threadId) =>
46
+ api.deleteForumTopic(chatId, threadId),
47
+ ),
48
+ };
@@ -0,0 +1,16 @@
1
+ import type { ActionResult } from "../../../../core/types.js";
2
+ import type { TelegramActionContext } from "../types.js";
3
+
4
+ /** One `moderate` call, pre-parsed: the op name, the raw body, and the
5
+ * `user_id` the router already validated for ops that need one. */
6
+ type ModerationRequest = {
7
+ op: string;
8
+ body: Record<string, unknown>;
9
+ chatId: number;
10
+ ctx: TelegramActionContext;
11
+ userId: number | undefined;
12
+ };
13
+
14
+ export type ModerationOp = (req: ModerationRequest) => Promise<ActionResult>;
15
+
16
+ export type ModerationOps = Record<string, ModerationOp>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Rich Messages capability probe — whether native Rich Markdown delivery is
3
+ * worth attempting on this Bot API server, latched off on the first
4
+ * "method not found".
5
+ */
6
+
7
+ import { logWarn } from "../../../util/log.js";
8
+
9
+ /**
10
+ * Rich Messages arrived in Bot API 10.2. A self-hosted Bot API server (or a
11
+ * deployment pinned to an older release) rejects `sendRichMessage` outright,
12
+ * and retrying it per message would cost a doomed round-trip plus a warning
13
+ * line on every single send. Probe once and latch, mirroring the
14
+ * `draftsSupported` capability probe in `handlers/delivery.ts`.
15
+ */
16
+ let richMessagesSupported = true;
17
+
18
+ /** Telegram's shape for "this build doesn't have that method". */
19
+ const METHOD_UNAVAILABLE_RE =
20
+ /method not found|not supported|unknown method|unsupported method/i;
21
+
22
+ /** True while native Rich Markdown delivery is still worth attempting. */
23
+ export function richMessagesAvailable(): boolean {
24
+ return richMessagesSupported;
25
+ }
26
+
27
+ /**
28
+ * Log a failed Rich Message call and latch the capability off when the failure
29
+ * says the method itself is missing. Payload-level rejections (a markdown
30
+ * string Telegram won't parse) stay one-off — the next message may be fine.
31
+ */
32
+ export function noteRichMessageFailure(err: unknown, context: string): void {
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ if (METHOD_UNAVAILABLE_RE.test(msg)) {
35
+ richMessagesSupported = false;
36
+ logWarn(
37
+ "bot",
38
+ `Rich Messages unavailable on this Bot API server — using legacy HTML from now on (${context}): ${msg}`,
39
+ );
40
+ return;
41
+ }
42
+ logWarn(
43
+ "bot",
44
+ `Rich Markdown failed; falling back to HTML (${context}): ${msg}`,
45
+ );
46
+ }
47
+
48
+ /** Test seam: re-arm the capability probe between cases. */
49
+ export function resetRichMessageSupport(): void {
50
+ richMessagesSupported = true;
51
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Shared helpers for Telegram action handlers: reply-parameter extraction and
3
- * native Rich Markdown delivery with legacy fallbacks.
2
+ * Outbound Telegram sends: reply-parameter extraction, delivery modifiers,
3
+ * and text delivery with native Rich Markdown and legacy HTML / plain-text
4
+ * fallbacks.
4
5
  */
5
6
 
6
7
  import type { Bot } from "grammy";
@@ -8,6 +9,11 @@ import { markdownToTelegramHtml } from "../formatting.js";
8
9
  import { logWarn } from "../../../util/log.js";
9
10
  import { ambientThreadId, resolveThreadId } from "../topics.js";
10
11
  import { TELEGRAM_MAX_TEXT } from "./types.js";
12
+ import { toPositiveId } from "./coerce.js";
13
+ import {
14
+ noteRichMessageFailure,
15
+ richMessagesAvailable,
16
+ } from "./rich-messages.js";
11
17
 
12
18
  export function replyParams(
13
19
  body: Record<string, unknown>,
@@ -62,62 +68,6 @@ export function sendOpts(
62
68
  };
63
69
  }
64
70
 
65
- /**
66
- * Telegram/GramJS APIs expect numeric IDs. `snowflakeOrIdSchema` normalizes IDs
67
- * to digit-strings (so 17-19 digit Discord snowflakes survive validation), so a
68
- * Telegram message/reply/offset ID can arrive here as a string. Coerce it back
69
- * to a positive integer: Telegram IDs are well within 2^53, so this is lossless.
70
- * Returns undefined for missing / non-positive / non-integer values.
71
- */
72
- export function toPositiveId(v: unknown): number | undefined {
73
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
74
- return Number.isInteger(n) && n > 0 ? n : undefined;
75
- }
76
-
77
- /**
78
- * Rich Messages arrived in Bot API 10.2. A self-hosted Bot API server (or a
79
- * deployment pinned to an older release) rejects `sendRichMessage` outright,
80
- * and retrying it per message would cost a doomed round-trip plus a warning
81
- * line on every single send. Probe once and latch, mirroring the
82
- * `draftsSupported` capability probe in `handlers/delivery.ts`.
83
- */
84
- let richMessagesSupported = true;
85
-
86
- /** Telegram's shape for "this build doesn't have that method". */
87
- const METHOD_UNAVAILABLE_RE =
88
- /method not found|not supported|unknown method|unsupported method/i;
89
-
90
- /** True while native Rich Markdown delivery is still worth attempting. */
91
- export function richMessagesAvailable(): boolean {
92
- return richMessagesSupported;
93
- }
94
-
95
- /**
96
- * Log a failed Rich Message call and latch the capability off when the failure
97
- * says the method itself is missing. Payload-level rejections (a markdown
98
- * string Telegram won't parse) stay one-off — the next message may be fine.
99
- */
100
- export function noteRichMessageFailure(err: unknown, context: string): void {
101
- const msg = err instanceof Error ? err.message : String(err);
102
- if (METHOD_UNAVAILABLE_RE.test(msg)) {
103
- richMessagesSupported = false;
104
- logWarn(
105
- "bot",
106
- `Rich Messages unavailable on this Bot API server — using legacy HTML from now on (${context}): ${msg}`,
107
- );
108
- return;
109
- }
110
- logWarn(
111
- "bot",
112
- `Rich Markdown failed; falling back to HTML (${context}): ${msg}`,
113
- );
114
- }
115
-
116
- /** Test seam: re-arm the capability probe between cases. */
117
- export function resetRichMessageSupport(): void {
118
- richMessagesSupported = true;
119
- }
120
-
121
71
  export async function sendText(
122
72
  bot: Bot,
123
73
  chatId: number,
@@ -141,7 +91,7 @@ export async function sendText(
141
91
  message_thread_id: ambientThreadId(chatId),
142
92
  };
143
93
 
144
- if (richMessagesSupported) {
94
+ if (richMessagesAvailable()) {
145
95
  try {
146
96
  const sent = await bot.api.sendRichMessage(
147
97
  chatId,
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `/admin cron` / `pulse` — the background-job subcommands.
3
+ */
4
+
5
+ import type { Bot, Context } from "grammy";
6
+ import { escapeHtml } from "../formatting.js";
7
+ import {
8
+ getAllCronJobs,
9
+ describeSchedule,
10
+ nextRunAt,
11
+ } from "../../../storage/cron.js";
12
+ import { getPulseStatus } from "../../../core/background/pulse.js";
13
+ import { replyHtmlChunked } from "./chunked-reply.js";
14
+
15
+ export async function replyCronJobs(ctx: Context): Promise<void> {
16
+ const jobs = getAllCronJobs();
17
+ if (jobs.length === 0) {
18
+ await ctx.reply("No cron jobs.");
19
+ return;
20
+ }
21
+ const lines = jobs.map((j) => {
22
+ const nextMs = nextRunAt(j);
23
+ const last = j.lastRunAt
24
+ ? new Date(j.lastRunAt).toISOString().slice(0, 16).replace("T", " ")
25
+ : "never";
26
+ const next = nextMs
27
+ ? new Date(nextMs).toISOString().slice(0, 16).replace("T", " ")
28
+ : "?";
29
+ return `${j.enabled ? "✓" : "✗"} <b>${escapeHtml(j.name)}</b>\n <code>${escapeHtml(describeSchedule(j))}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
30
+ });
31
+ await replyHtmlChunked(
32
+ ctx,
33
+ `<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
34
+ );
35
+ }
36
+
37
+ export async function replyPulseStatus(
38
+ ctx: Context,
39
+ _rest: string[],
40
+ bot: Bot,
41
+ ): Promise<void> {
42
+ const chats = getPulseStatus();
43
+ if (chats.length === 0) {
44
+ await ctx.reply("No pulse chats.");
45
+ return;
46
+ }
47
+ const lines = await Promise.all(
48
+ chats.map(async (p) => {
49
+ let title = p.chatId;
50
+ try {
51
+ const id = parseInt(p.chatId, 10);
52
+ if (!isNaN(id)) {
53
+ const chat = await bot.api.getChat(id);
54
+ title = "title" in chat ? (chat.title ?? p.chatId) : p.chatId;
55
+ }
56
+ } catch {
57
+ /* skip */
58
+ }
59
+ return `${p.enabled ? "✓" : "✗"} ${escapeHtml(title)}`;
60
+ }),
61
+ );
62
+ await replyHtmlChunked(
63
+ ctx,
64
+ `<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
65
+ );
66
+ }
@@ -0,0 +1,20 @@
1
+ import type { Context } from "grammy";
2
+ import { splitMessage } from "../formatting.js";
3
+ import { TELEGRAM_MAX_TEXT } from "../actions/types.js";
4
+
5
+ /**
6
+ * Reply with an HTML listing, split across messages when it outgrows
7
+ * Telegram's 4096-char cap. The per-item listings (chats, cron, pulse)
8
+ * scale with the daemon's chat count, and a single oversized
9
+ * `ctx.reply` fails the whole command with 400 "message is too long".
10
+ * Entries are `\n\n`-separated and each carries balanced tags, so the
11
+ * paragraph-first splitter never cuts through markup.
12
+ */
13
+ export async function replyHtmlChunked(
14
+ ctx: Context,
15
+ text: string,
16
+ ): Promise<void> {
17
+ for (const chunk of splitMessage(text, TELEGRAM_MAX_TEXT)) {
18
+ await ctx.reply(chunk, { parse_mode: "HTML" });
19
+ }
20
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `/admin stats` / `errors` / `logs` / `daily` — the daemon-health
3
+ * subcommands.
4
+ */
5
+
6
+ import type { Context } from "grammy";
7
+ import { readFileSync } from "node:fs";
8
+ import { files, dirs } from "../../../util/paths.js";
9
+ import { tailFile } from "../../../util/tail-file.js";
10
+ import { escapeHtml } from "../formatting.js";
11
+ import { getAllSessions } from "../../../storage/sessions.js";
12
+ import { todayLogDate } from "../../../storage/daily-log.js";
13
+ import { getActiveCount } from "../../../core/engine/dispatcher.js";
14
+ import { getHealthStatus, getRecentErrors } from "../../../util/watchdog.js";
15
+ import { formatDuration } from "../helpers/index.js";
16
+
17
+ export async function replyStats(ctx: Context): Promise<void> {
18
+ const h = getHealthStatus();
19
+ const sessions = getAllSessions();
20
+ const turns = sessions.reduce((s, x) => s + x.info.turns, 0);
21
+ const mem = process.memoryUsage();
22
+ await ctx.reply(
23
+ [
24
+ `<b>🦅 Talon Stats</b>`,
25
+ "",
26
+ `<b>Uptime:</b> ${formatDuration(h.uptimeMs)}`,
27
+ `<b>Messages:</b> ${h.totalMessagesProcessed}`,
28
+ `<b>Sessions:</b> ${sessions.length}`,
29
+ `<b>Turns:</b> ${turns}`,
30
+ `<b>Last active:</b> ${h.msSinceLastMessage < 60000 ? "now" : formatDuration(h.msSinceLastMessage) + " ago"}`,
31
+ "",
32
+ `<b>Memory:</b> ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB heap / ${(mem.rss / 1024 / 1024).toFixed(1)}MB rss`,
33
+ `<b>Queue:</b> ${getActiveCount()}`,
34
+ `<b>Errors:</b> ${h.recentErrorCount}`,
35
+ ].join("\n"),
36
+ { parse_mode: "HTML" },
37
+ );
38
+ }
39
+
40
+ export async function replyRecentErrors(ctx: Context): Promise<void> {
41
+ const errors = getRecentErrors(5);
42
+ if (errors.length === 0) {
43
+ await ctx.reply("No recent errors.");
44
+ return;
45
+ }
46
+ const lines = errors.map(
47
+ (e) =>
48
+ `<code>[${new Date(e.timestamp).toISOString().slice(11, 19)}]</code> ${escapeHtml(e.message.slice(0, 200))}`,
49
+ );
50
+ await ctx.reply(
51
+ `<b>Recent Errors (${errors.length})</b>\n\n` + lines.join("\n\n"),
52
+ { parse_mode: "HTML" },
53
+ );
54
+ }
55
+
56
+ export async function replyLogTail(ctx: Context): Promise<void> {
57
+ const logPath = files.log;
58
+ try {
59
+ const lines = tailFile(logPath);
60
+ await ctx.reply(`<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`, {
61
+ parse_mode: "HTML",
62
+ });
63
+ } catch {
64
+ await ctx.reply(`Could not read ${logPath}`);
65
+ }
66
+ }
67
+
68
+ export async function replyDailyLog(ctx: Context): Promise<void> {
69
+ const today = todayLogDate();
70
+ const logPath = `${dirs.logs}/${today}.md`;
71
+ try {
72
+ const content = readFileSync(logPath, "utf-8");
73
+ const lines = content.trim().split("\n").slice(-30).join("\n");
74
+ await ctx.reply(
75
+ `<b>Daily log (${today})</b>\n\n<pre>${escapeHtml(lines.slice(0, 3800))}</pre>`,
76
+ { parse_mode: "HTML" },
77
+ );
78
+ } catch {
79
+ await ctx.reply(`No daily log for ${today}.`);
80
+ }
81
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `/admin chats` / `broadcast` / `kill` — the session-level subcommands.
3
+ */
4
+
5
+ import type { Bot, Context } from "grammy";
6
+ import type { TalonConfig } from "../../../core/config/index.js";
7
+ import { escapeHtml } from "../formatting.js";
8
+ import { resetSession, getAllSessions } from "../../../storage/sessions.js";
9
+ import { clearHistory } from "../../../storage/history.js";
10
+ import { getChatSettings } from "../../../storage/chat-settings.js";
11
+ import { formatModelLabel } from "../helpers/index.js";
12
+ import { replyHtmlChunked } from "./chunked-reply.js";
13
+
14
+ /** `/admin chats` — every active session, newest first, titled via getChat. */
15
+ export async function replyActiveChats(
16
+ ctx: Context,
17
+ _rest: string[],
18
+ bot: Bot,
19
+ config: TalonConfig,
20
+ ): Promise<void> {
21
+ const sessions = getAllSessions();
22
+ if (sessions.length === 0) {
23
+ await ctx.reply("No active sessions.");
24
+ return;
25
+ }
26
+ sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
27
+
28
+ const titles = new Map<string, string>();
29
+ await Promise.all(
30
+ sessions.map(async (s) => {
31
+ try {
32
+ const id = parseInt(s.chatId, 10);
33
+ if (isNaN(id)) return;
34
+ const chat = await bot.api.getChat(id);
35
+ titles.set(
36
+ s.chatId,
37
+ "title" in chat
38
+ ? (chat.title ?? "DM")
39
+ : "first_name" in chat
40
+ ? (chat.first_name ?? "DM")
41
+ : "DM",
42
+ );
43
+ } catch {
44
+ /* inaccessible */
45
+ }
46
+ }),
47
+ );
48
+
49
+ const lines = sessions.map((s) => {
50
+ const age = s.info.lastActive
51
+ ? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
52
+ : "?";
53
+ const title = titles.get(s.chatId) ?? s.chatId;
54
+ const model = formatModelLabel(
55
+ getChatSettings(s.chatId).model ?? config.model,
56
+ );
57
+ // `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
58
+ // it gets the same escaping the title already had.
59
+ return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
60
+ });
61
+ await replyHtmlChunked(
62
+ ctx,
63
+ `<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
64
+ );
65
+ }
66
+
67
+ export async function broadcast(
68
+ ctx: Context,
69
+ rest: string[],
70
+ bot: Bot,
71
+ ): Promise<void> {
72
+ const text = rest.join(" ");
73
+ if (!text) {
74
+ await ctx.reply("Usage: /admin broadcast <text>");
75
+ return;
76
+ }
77
+ const sessions = getAllSessions();
78
+ let sent = 0,
79
+ failed = 0;
80
+ for (const s of sessions) {
81
+ const id = parseInt(s.chatId, 10);
82
+ if (isNaN(id)) continue;
83
+ try {
84
+ await bot.api.sendMessage(id, text);
85
+ sent++;
86
+ await new Promise((r) => setTimeout(r, 40));
87
+ } catch {
88
+ failed++;
89
+ }
90
+ }
91
+ await ctx.reply(
92
+ `Broadcast: ${sent} sent, ${failed} failed (${sessions.length} total).`,
93
+ );
94
+ }
95
+
96
+ export async function killSession(ctx: Context, rest: string[]): Promise<void> {
97
+ const target = rest[0];
98
+ if (!target) {
99
+ await ctx.reply("Usage: /admin kill <chatId>");
100
+ return;
101
+ }
102
+ resetSession(target);
103
+ clearHistory(target);
104
+ await ctx.reply(`Session ${target} reset.`);
105
+ }