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
@@ -1,11 +1,16 @@
1
1
  /**
2
- * History capture middleware — runs for ALL messages, before handlers.
3
- * Records every message into the in-memory history buffer.
2
+ * Update-level middleware — runs for ALL messages, before handlers.
3
+ * Records every message into the in-memory history buffer, keeps the
4
+ * update offset / forum topic / userbot access current, and wires the
5
+ * per-message-type handlers. `registerMiddleware` binds each piece in the
6
+ * order grammY must see them.
4
7
  */
5
8
 
6
- import type { Bot } from "grammy";
7
- import type { TalonConfig } from "../../util/config.js";
9
+ import type { Bot, Context, Filter, NextFunction } from "grammy";
10
+ import type { Message } from "grammy/types";
11
+ import type { TalonConfig } from "../../core/config/index.js";
8
12
  import { pushMessage } from "../../storage/history.js";
13
+ import type { HistoryMessage } from "../../storage/repositories/history-repo.js";
9
14
  import { allowChat, revokeChat } from "./userbot.js";
10
15
  import { registerChat } from "../../core/background/pulse.js";
11
16
  import { log } from "../../util/log.js";
@@ -26,214 +31,165 @@ import {
26
31
  handleVideoNoteMessage,
27
32
  } from "./handlers/index.js";
28
33
 
29
- export function registerMiddleware(bot: Bot, config: TalonConfig): void {
30
- // ── Update-offset tracking (every update, before anything else) ──────────
31
- // Telegram redelivers any update whose id was never confirmed; the
32
- // shutdown path confirms this one so a process-ending command can't be
33
- // served twice. See update-offset.ts.
34
- bot.use((ctx, next) => {
35
- noteUpdateId(ctx.update.update_id);
36
- return next();
37
- });
34
+ // ── Update-offset tracking (every update, before anything else) ──────────
35
+ // Telegram redelivers any update whose id was never confirmed; the
36
+ // shutdown path confirms this one so a process-ending command can't be
37
+ // served twice. See update-offset.ts.
38
+ function trackUpdateOffset(ctx: Context, next: NextFunction): Promise<void> {
39
+ noteUpdateId(ctx.update.update_id);
40
+ return next();
41
+ }
38
42
 
39
- // ── History capture (runs for ALL messages, before handlers) ─────────────
40
- bot.on("message", (ctx, next) => {
41
- const chatId = String(ctx.chat.id);
42
- const sender = getSenderName(ctx.from);
43
- // Keep the ambient forum topic current so outbound sends (which have no
44
- // reply anchor — drafts, media, plain sends) land in the topic the
45
- // conversation is actually happening in, not General.
46
- noteInboundThread(ctx.chat.id, ctx.message);
47
- // The handle is the only addressable form of a user — persist it with
48
- // every row so later readers (history views, heartbeat-composed
49
- // messages) can mention someone instead of guessing.
50
- const senderHandle = ctx.from?.username;
51
- const senderId = ctx.from?.id ?? 0;
52
- const msgId = ctx.message.message_id;
53
- const replyToMsgId = ctx.message.reply_to_message?.message_id;
43
+ type HistoryEntry = Pick<
44
+ HistoryMessage,
45
+ "text" | "mediaType" | "stickerFileId"
46
+ >;
54
47
 
55
- // Register this chat for userbot access
56
- allowChat(ctx.chat.id);
57
- // Only register groups for pulse (DMs don't need it — bot always responds)
58
- const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
59
- if (isGroup) registerChat(chatId);
60
- const timestamp = ctx.message.date * 1000;
48
+ /** The media kinds history records, checked in this order. */
49
+ function mediaHistoryEntry(message: Message): HistoryEntry | undefined {
50
+ if ("photo" in message && message.photo) {
51
+ return { text: message.caption || "(photo)", mediaType: "photo" };
52
+ }
53
+ if ("document" in message && message.document) {
54
+ const name = message.document.file_name || "file";
55
+ return { text: message.caption || `(sent ${name})`, mediaType: "document" };
56
+ }
57
+ if ("voice" in message && message.voice) {
58
+ return { text: "(voice message)", mediaType: "voice" };
59
+ }
60
+ if ("sticker" in message && message.sticker) {
61
+ return {
62
+ text: message.sticker.emoji || "(sticker)",
63
+ mediaType: "sticker",
64
+ stickerFileId: message.sticker.file_id,
65
+ };
66
+ }
67
+ if ("video" in message && message.video) {
68
+ return { text: message.caption || "(video)", mediaType: "video" };
69
+ }
70
+ if ("animation" in message && message.animation) {
71
+ return { text: message.caption || "(GIF)", mediaType: "animation" };
72
+ }
73
+ if ("audio" in message && message.audio) {
74
+ const title = message.audio.title || message.audio.file_name || "audio";
75
+ return {
76
+ text: message.caption || `(audio: ${title})`,
77
+ mediaType: "document", // treat audio like documents in history
78
+ };
79
+ }
80
+ if ("video_note" in message && message.video_note) {
81
+ return { text: "(video note)", mediaType: "video" };
82
+ }
83
+ return undefined;
84
+ }
61
85
 
62
- if ("text" in ctx.message && ctx.message.text) {
63
- pushMessage(chatId, {
64
- msgId,
65
- senderId,
66
- senderName: sender,
67
- senderHandle,
68
- text: ctx.message.text,
69
- replyToMsgId,
70
- timestamp,
71
- });
72
- } else if ("photo" in ctx.message && ctx.message.photo) {
73
- pushMessage(chatId, {
74
- msgId,
75
- senderId,
76
- senderName: sender,
77
- senderHandle,
78
- text: ctx.message.caption || "(photo)",
79
- replyToMsgId,
80
- timestamp,
81
- mediaType: "photo",
82
- });
83
- } else if ("document" in ctx.message && ctx.message.document) {
84
- const name = ctx.message.document.file_name || "file";
85
- pushMessage(chatId, {
86
- msgId,
87
- senderId,
88
- senderName: sender,
89
- senderHandle,
90
- text: ctx.message.caption || `(sent ${name})`,
91
- replyToMsgId,
92
- timestamp,
93
- mediaType: "document",
94
- });
95
- } else if ("voice" in ctx.message && ctx.message.voice) {
96
- pushMessage(chatId, {
97
- msgId,
98
- senderId,
99
- senderName: sender,
100
- senderHandle,
101
- text: "(voice message)",
102
- replyToMsgId,
103
- timestamp,
104
- mediaType: "voice",
105
- });
106
- } else if ("sticker" in ctx.message && ctx.message.sticker) {
107
- pushMessage(chatId, {
108
- msgId,
109
- senderId,
110
- senderName: sender,
111
- senderHandle,
112
- text: ctx.message.sticker.emoji || "(sticker)",
113
- replyToMsgId,
114
- timestamp,
115
- mediaType: "sticker",
116
- stickerFileId: ctx.message.sticker.file_id,
117
- });
118
- } else if ("video" in ctx.message && ctx.message.video) {
119
- pushMessage(chatId, {
120
- msgId,
121
- senderId,
122
- senderName: sender,
123
- senderHandle,
124
- text: ctx.message.caption || "(video)",
125
- replyToMsgId,
126
- timestamp,
127
- mediaType: "video",
128
- });
129
- } else if ("animation" in ctx.message && ctx.message.animation) {
130
- pushMessage(chatId, {
131
- msgId,
132
- senderId,
133
- senderName: sender,
134
- senderHandle,
135
- text: ctx.message.caption || "(GIF)",
136
- replyToMsgId,
137
- timestamp,
138
- mediaType: "animation",
139
- });
140
- } else if ("audio" in ctx.message && ctx.message.audio) {
141
- const title =
142
- ctx.message.audio.title || ctx.message.audio.file_name || "audio";
143
- pushMessage(chatId, {
144
- msgId,
145
- senderId,
146
- senderName: sender,
147
- senderHandle,
148
- text: ctx.message.caption || `(audio: ${title})`,
149
- replyToMsgId,
150
- timestamp,
151
- mediaType: "document", // treat audio like documents in history
152
- });
153
- } else if ("video_note" in ctx.message && ctx.message.video_note) {
154
- pushMessage(chatId, {
155
- msgId,
156
- senderId,
157
- senderName: sender,
158
- senderHandle,
159
- text: "(video note)",
160
- replyToMsgId,
161
- timestamp,
162
- mediaType: "video",
163
- });
164
- } else if ("location" in ctx.message && ctx.message.location) {
165
- pushMessage(chatId, {
166
- msgId,
167
- senderId,
168
- senderName: sender,
169
- senderHandle,
170
- text: `(shared location: ${ctx.message.location.latitude}, ${ctx.message.location.longitude})`,
171
- replyToMsgId,
172
- timestamp,
173
- });
174
- } else if ("contact" in ctx.message && ctx.message.contact) {
175
- const name = [
176
- ctx.message.contact.first_name,
177
- ctx.message.contact.last_name,
178
- ]
179
- .filter(Boolean)
180
- .join(" ");
181
- pushMessage(chatId, {
182
- msgId,
183
- senderId,
184
- senderName: sender,
185
- senderHandle,
186
- text: `(shared contact: ${name})`,
187
- replyToMsgId,
188
- timestamp,
189
- });
190
- }
86
+ /** The history row's content for one inbound message; nothing for kinds
87
+ * history doesn't record. */
88
+ function historyEntryFor(message: Message): HistoryEntry | undefined {
89
+ if ("text" in message && message.text) {
90
+ return { text: message.text };
91
+ }
92
+ const media = mediaHistoryEntry(message);
93
+ if (media) return media;
94
+ if ("location" in message && message.location) {
95
+ return {
96
+ text: `(shared location: ${message.location.latitude}, ${message.location.longitude})`,
97
+ };
98
+ }
99
+ if ("contact" in message && message.contact) {
100
+ const name = [message.contact.first_name, message.contact.last_name]
101
+ .filter(Boolean)
102
+ .join(" ");
103
+ return { text: `(shared contact: ${name})` };
104
+ }
105
+ return undefined;
106
+ }
191
107
 
192
- return next();
193
- });
108
+ // ── History capture (runs for ALL messages, before handlers) ─────────────
109
+ function captureHistory(
110
+ ctx: Filter<Context, "message">,
111
+ next: NextFunction,
112
+ ): Promise<void> {
113
+ const chatId = String(ctx.chat.id);
114
+ const sender = getSenderName(ctx.from);
115
+ // Keep the ambient forum topic current so outbound sends (which have no
116
+ // reply anchor — drafts, media, plain sends) land in the topic the
117
+ // conversation is actually happening in, not General.
118
+ noteInboundThread(ctx.chat.id, ctx.message);
119
+ // The handle is the only addressable form of a user — persist it with
120
+ // every row so later readers (history views, heartbeat-composed
121
+ // messages) can mention someone instead of guessing.
122
+ const senderHandle = ctx.from?.username;
123
+ const senderId = ctx.from?.id ?? 0;
124
+ const msgId = ctx.message.message_id;
125
+ const replyToMsgId = ctx.message.reply_to_message?.message_id;
194
126
 
195
- // ── Reaction tap — feed reactions on Talon's own messages to the soul ────
196
- // Telegram only delivers `message_reaction` updates when subscribed via
197
- // allowed_updates (see index.ts) and, in groups, when the bot is an admin.
198
- // The handler is inert unless the soul is enabled.
199
- bot.on("message_reaction", (ctx) => {
200
- const mr = ctx.messageReaction;
201
- // Ignore the bot reacting to messages itself.
202
- if (mr.user?.id === ctx.me.id) return;
203
- const added = newlyAddedEmojis(mr.old_reaction, mr.new_reaction);
204
- recordReactionToBot(mr.chat.id, mr.message_id, added);
205
- });
127
+ // Register this chat for userbot access
128
+ allowChat(ctx.chat.id);
129
+ // Only register groups for pulse (DMs don't need it — bot always responds)
130
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
131
+ if (isGroup) registerChat(chatId);
132
+ const timestamp = ctx.message.date * 1000;
206
133
 
207
- // ── Join requests — cache for moderate(op="list_join_requests") ─────────
208
- // Delivered only when subscribed via allowed_updates (see index.ts) and
209
- // the bot admins a chat whose invite link requires approval. Stored, not
210
- // enqueued: a join request isn't a conversation turn.
211
- bot.on("chat_join_request", (ctx) => {
212
- const req = ctx.chatJoinRequest;
213
- recordJoinRequest(ctx.chat.id, {
214
- userId: req.from.id,
215
- name: getSenderName(req.from),
216
- username: req.from.username,
217
- bio: req.bio,
218
- at: Date.now(),
134
+ const entry = historyEntryFor(ctx.message);
135
+ if (entry) {
136
+ pushMessage(chatId, {
137
+ msgId,
138
+ senderId,
139
+ senderName: sender,
140
+ senderHandle,
141
+ replyToMsgId,
142
+ timestamp,
143
+ ...entry,
219
144
  });
220
- log(
221
- "bot",
222
- `Join request for chat ${ctx.chat.id} from ${req.from.id} (@${req.from.username ?? "?"})`,
223
- );
224
- });
145
+ }
146
+
147
+ return next();
148
+ }
225
149
 
226
- // ── Bot removed from group — revoke userbot access ─────────────────────
227
- bot.on("my_chat_member", (ctx) => {
228
- const newStatus = ctx.myChatMember.new_chat_member.status;
229
- if (newStatus === "left" || newStatus === "kicked") {
230
- const chatId = ctx.chat.id;
231
- revokeChat(chatId);
232
- log("bot", `Removed from chat ${chatId} — revoked userbot access`);
233
- }
150
+ // ── Reaction tap — feed reactions on Talon's own messages to the soul ────
151
+ // Telegram only delivers `message_reaction` updates when subscribed via
152
+ // allowed_updates (see index.ts) and, in groups, when the bot is an admin.
153
+ // The handler is inert unless the soul is enabled.
154
+ function tapReaction(ctx: Filter<Context, "message_reaction">): void {
155
+ const mr = ctx.messageReaction;
156
+ // Ignore the bot reacting to messages itself.
157
+ if (mr.user?.id === ctx.me.id) return;
158
+ const added = newlyAddedEmojis(mr.old_reaction, mr.new_reaction);
159
+ recordReactionToBot(mr.chat.id, mr.message_id, added);
160
+ }
161
+
162
+ // ── Join requests — cache for moderate(op="list_join_requests") ─────────
163
+ // Delivered only when subscribed via allowed_updates (see index.ts) and
164
+ // the bot admins a chat whose invite link requires approval. Stored, not
165
+ // enqueued: a join request isn't a conversation turn.
166
+ function cacheJoinRequest(ctx: Filter<Context, "chat_join_request">): void {
167
+ const req = ctx.chatJoinRequest;
168
+ recordJoinRequest(ctx.chat.id, {
169
+ userId: req.from.id,
170
+ name: getSenderName(req.from),
171
+ username: req.from.username,
172
+ bio: req.bio,
173
+ at: Date.now(),
234
174
  });
175
+ log(
176
+ "bot",
177
+ `Join request for chat ${ctx.chat.id} from ${req.from.id} (@${req.from.username ?? "?"})`,
178
+ );
179
+ }
235
180
 
236
- // ── Message handlers (delegated to handlers.ts) ──────────────────────────
181
+ // ── Bot removed from group — revoke userbot access ─────────────────────
182
+ function revokeOnRemoval(ctx: Filter<Context, "my_chat_member">): void {
183
+ const newStatus = ctx.myChatMember.new_chat_member.status;
184
+ if (newStatus === "left" || newStatus === "kicked") {
185
+ const chatId = ctx.chat.id;
186
+ revokeChat(chatId);
187
+ log("bot", `Removed from chat ${chatId} — revoked userbot access`);
188
+ }
189
+ }
190
+
191
+ // ── Message handlers (delegated to handlers.ts) ──────────────────────────
192
+ function registerMessageHandlers(bot: Bot, config: TalonConfig): void {
237
193
  bot.on("message:text", (ctx) => handleTextMessage(ctx, bot, config));
238
194
  bot.on("message:photo", (ctx) => handlePhotoMessage(ctx, bot, config));
239
195
  bot.on("message:document", (ctx) => handleDocumentMessage(ctx, bot, config));
@@ -248,3 +204,12 @@ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
248
204
  handleVideoNoteMessage(ctx, bot, config),
249
205
  );
250
206
  }
207
+
208
+ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
209
+ bot.use(trackUpdateOffset);
210
+ bot.on("message", captureHistory);
211
+ bot.on("message_reaction", tapReaction);
212
+ bot.on("chat_join_request", cacheJoinRequest);
213
+ bot.on("my_chat_member", revokeOnRemoval);
214
+ registerMessageHandlers(bot, config);
215
+ }
@@ -26,7 +26,7 @@
26
26
  * Discord types leak in.
27
27
  */
28
28
 
29
- import type { TalonConfig } from "../../util/config.js";
29
+ import type { TalonConfig } from "../../core/config/index.js";
30
30
  import type { Backend } from "../../core/agent-runtime/capabilities.js";
31
31
  import {
32
32
  buildModelMenuState,
@@ -0,0 +1,184 @@
1
+ /** `/context` — the context window, broken down by segment. */
2
+
3
+ import pc from "picocolors";
4
+ import {
5
+ buildContextBreakdown,
6
+ estimateContextTokens,
7
+ apportionCells,
8
+ type ContextBreakdown,
9
+ type ContextSegmentKey,
10
+ } from "../../shared/status-context.js";
11
+ import { getRecentHistory } from "../../../storage/history.js";
12
+ import { formatTokenCount } from "../../shared/format.js";
13
+ import { getChatSettings } from "../../../storage/chat-settings.js";
14
+ import { resolveModelId as resolveModelName } from "../../../core/models/catalog.js";
15
+ import { getSessionInfo } from "../../../storage/sessions.js";
16
+ import type { Command, CommandContext } from "../command-registry.js";
17
+
18
+ // ── /context rendering ───────────────────────────────────────────────────────
19
+
20
+ /** Each used segment gets its own colour; free is a dim hatch. */
21
+ const CONTEXT_SEGMENT_COLOR: Record<ContextSegmentKey, (s: string) => string> =
22
+ {
23
+ system: pc.blue,
24
+ tools: pc.yellow,
25
+ conversation: pc.cyan,
26
+ };
27
+
28
+ const CONTEXT_BAR_WIDTH = 42;
29
+
30
+ /**
31
+ * The segmented bar: one coloured run per segment (proportional to the window),
32
+ * then the free space as a dim `░` hatch. Cell counts come from
33
+ * `apportionCells`, so the runs always sum to exactly the bar width.
34
+ */
35
+ function renderContextBar(bd: ContextBreakdown): string {
36
+ const weights = bd.segments.map((s) => s.tokens);
37
+ if (bd.windowKnown) weights.push(bd.free);
38
+ const cells = apportionCells(weights, CONTEXT_BAR_WIDTH);
39
+ let bar = "";
40
+ bd.segments.forEach((s, i) => {
41
+ bar += CONTEXT_SEGMENT_COLOR[s.key]("█".repeat(cells[i] ?? 0));
42
+ });
43
+ if (bd.windowKnown) {
44
+ bar += pc.dim("░".repeat(cells[bd.segments.length] ?? 0));
45
+ }
46
+ return bar;
47
+ }
48
+
49
+ /** One aligned legend row per segment (and Free), colour-matched to the bar. */
50
+ function renderContextLegend(bd: ContextBreakdown): string[] {
51
+ const rows = bd.segments.map((s) => ({
52
+ dot: CONTEXT_SEGMENT_COLOR[s.key]("●"),
53
+ label: s.label,
54
+ tokens: s.tokens,
55
+ pct: s.pct,
56
+ }));
57
+ if (bd.windowKnown) {
58
+ rows.push({
59
+ dot: pc.dim("░"),
60
+ label: "Free",
61
+ tokens: bd.free,
62
+ pct: bd.freePct,
63
+ });
64
+ }
65
+ const labelW = Math.max(...rows.map((r) => r.label.length));
66
+ const tokW = Math.max(...rows.map((r) => formatTokenCount(r.tokens).length));
67
+ return rows.map((r) => {
68
+ const label = r.label.padEnd(labelW);
69
+ const tok = formatTokenCount(r.tokens).padStart(tokW);
70
+ const pct = `${r.pct}%`.padStart(6);
71
+ return `${r.dot} ${label} ${pc.dim(tok)} ${pc.dim(pct)}`;
72
+ });
73
+ }
74
+
75
+ // ── /context ─────────────────────────────────────────────────────────────────
76
+
77
+ /** Window + a friendly model name, enriched from the backend like /status. */
78
+ async function resolveWindowAndModel(
79
+ be: CommandContext["backend"],
80
+ activeModel: string,
81
+ contextWindow: number,
82
+ ): Promise<{ contextWindow: number; modelName: string }> {
83
+ let modelName = resolveModelName(activeModel);
84
+ if (be?.models?.getRawModelInfo) {
85
+ const mi = await be.models
86
+ .getRawModelInfo(activeModel)
87
+ .catch(() => undefined);
88
+ if (mi) {
89
+ if (mi.contextWindow) contextWindow ||= mi.contextWindow;
90
+ if (mi.displayName) modelName = mi.displayName;
91
+ }
92
+ }
93
+ return { contextWindow, modelName };
94
+ }
95
+
96
+ function writeContextReport(
97
+ ctx: CommandContext,
98
+ bd: ContextBreakdown,
99
+ modelName: string,
100
+ ): void {
101
+ const windowStr = bd.windowKnown
102
+ ? `${formatTokenCount(bd.max)} window`
103
+ : pc.dim("window unknown");
104
+ ctx.renderer.writeln(
105
+ ` ${pc.bold("Context")} ${modelName} · ${windowStr}`,
106
+ );
107
+
108
+ const usedStr = bd.windowKnown
109
+ ? `${formatTokenCount(bd.used)} / ${formatTokenCount(bd.max)} · ${bd.usedPct}% used`
110
+ : `${formatTokenCount(bd.used)} used`;
111
+ ctx.renderer.writeln(
112
+ ` ${bd.warn ? pc.yellow(usedStr) : pc.dim(usedStr)}${bd.warn ? pc.yellow(" · nearing limit") : ""}`,
113
+ );
114
+ ctx.renderer.writeln();
115
+ ctx.renderer.writeln(` ${renderContextBar(bd)}`);
116
+ ctx.renderer.writeln();
117
+ for (const line of renderContextLegend(bd)) {
118
+ ctx.renderer.writeln(` ${line}`);
119
+ }
120
+ // Explain only what's on screen: Tools is derivable (and shown) only
121
+ // when the backend reported a real fill.
122
+ const hasTools = bd.segments.some((s) => s.key === "tools");
123
+ ctx.renderer.writeln(
124
+ ` ${pc.dim(
125
+ hasTools
126
+ ? "System measured; Conversation estimated; Tools is the remainder."
127
+ : "System measured; Conversation estimated from stored history.",
128
+ )}`,
129
+ );
130
+ }
131
+
132
+ export const contextCommand: Command = {
133
+ name: "context",
134
+ aliases: ["ctx"],
135
+ description: "Context-window usage, broken down",
136
+ async handler(_args, ctx) {
137
+ const chatId = ctx.chatId();
138
+ const u = getSessionInfo(chatId).usage;
139
+ const be = ctx.backend;
140
+ const activeModel = getChatSettings(chatId).model ?? ctx.config.model;
141
+
142
+ const { contextWindow, modelName } = await resolveWindowAndModel(
143
+ be,
144
+ activeModel,
145
+ u.contextWindow,
146
+ );
147
+
148
+ // System = the actual frozen prompt the model is running with. Measure
149
+ // it directly rather than rebuilding, so the number matches what was
150
+ // really sent (the prompt is frozen per session by design).
151
+ const parts = ctx.config.systemPromptParts;
152
+ const systemText = parts
153
+ ? [parts.staticText, parts.dynamicText].filter(Boolean).join("\n")
154
+ : (ctx.config.systemPrompt ?? "");
155
+ const systemTokens = estimateContextTokens(systemText);
156
+
157
+ // Conversation = stored history for this chat. An estimate: the model's
158
+ // real in-window history may be smaller after compaction, which the
159
+ // breakdown clamps against the authoritative fill.
160
+ const history = getRecentHistory(chatId, 2000);
161
+ const conversationTokens = estimateContextTokens(
162
+ history.map((m) => m.text ?? "").join("\n"),
163
+ );
164
+
165
+ const bd = buildContextBreakdown({
166
+ contextTokens: u.contextTokens,
167
+ contextWindow,
168
+ systemTokens,
169
+ conversationTokens,
170
+ });
171
+
172
+ ctx.renderer.writeln();
173
+ if (!bd.known) {
174
+ ctx.renderer.writeln(
175
+ ` ${pc.bold("Context")} ${pc.dim("no usage yet — send a message first")}`,
176
+ );
177
+ ctx.reprompt();
178
+ return;
179
+ }
180
+
181
+ writeContextReport(ctx, bd, modelName);
182
+ ctx.reprompt();
183
+ },
184
+ };
@@ -0,0 +1,35 @@
1
+ /** `/help` and `/quit` — the registry's own commands. */
2
+
3
+ import pc from "picocolors";
4
+ import { getCommands, type Command } from "../command-registry.js";
5
+
6
+ export const helpCommand: Command = {
7
+ name: "help",
8
+ description: "Show available commands",
9
+ async handler(_args, ctx) {
10
+ ctx.renderer.writeln();
11
+ for (const cmd of getCommands()) {
12
+ if (cmd.name === "help") continue; // show help last
13
+ const nameStr = `/${cmd.name}`;
14
+ const argStr = cmd.argHint ? ` ${cmd.argHint}` : "";
15
+ const pad = " ".repeat(Math.max(1, 16 - nameStr.length - argStr.length));
16
+ ctx.renderer.writeln(
17
+ ` ${pc.cyan(nameStr)}${pc.dim(argStr)}${pad}${pc.dim(cmd.description)}`,
18
+ );
19
+ }
20
+ // Help itself at the end
21
+ ctx.renderer.writeln(
22
+ ` ${pc.cyan("/help")} ${pc.dim("Show available commands")}`,
23
+ );
24
+ ctx.reprompt();
25
+ },
26
+ };
27
+
28
+ export const quitCommand: Command = {
29
+ name: "quit",
30
+ aliases: ["exit"],
31
+ description: "Exit",
32
+ async handler(_args, ctx) {
33
+ ctx.close();
34
+ },
35
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Built-in terminal commands, in registration order. The order is what
3
+ * `/help` prints and what `getCommands()` returns, so it is pinned by test.
4
+ */
5
+
6
+ import type { Command } from "../command-registry.js";
7
+ import { modelCommand, effortCommand } from "./model.js";
8
+ import { statusCommand } from "./status.js";
9
+ import { contextCommand } from "./context.js";
10
+ import { resetCommand, resumeCommand, renameCommand } from "./session.js";
11
+ import { helpCommand, quitCommand } from "./help.js";
12
+
13
+ export const BUILTIN_COMMANDS: readonly Command[] = [
14
+ modelCommand,
15
+ effortCommand,
16
+ statusCommand,
17
+ contextCommand,
18
+ resetCommand,
19
+ resumeCommand,
20
+ renameCommand,
21
+ helpCommand,
22
+ quitCommand,
23
+ ];