talon-agent 1.0.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 (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Shared helpers used by commands, callbacks, and the settings panel.
3
+ */
4
+
5
+ import { escapeHtml } from "./formatting.js";
6
+ const DEFAULT_PULSE_INTERVAL_MS = 5 * 60 * 1000;
7
+
8
+ /** Parse a duration string like "30m", "2h", "1h30m" into milliseconds. */
9
+ export function parseInterval(input: string): number | null {
10
+ const match = input.match(/^(?:(\d+)h)?(?:(\d+)m)?$/);
11
+ if (!match || (!match[1] && !match[2])) return null;
12
+ const hours = parseInt(match[1] || "0", 10);
13
+ const minutes = parseInt(match[2] || "0", 10);
14
+ const ms = (hours * 60 + minutes) * 60 * 1000;
15
+ return ms > 0 ? ms : null;
16
+ }
17
+
18
+ export function formatDuration(ms: number): string {
19
+ const s = Math.floor(ms / 1000);
20
+ if (s < 60) return `${s}s`;
21
+ const m = Math.floor(s / 60);
22
+ if (m < 60) return `${m}m ${s % 60}s`;
23
+ const h = Math.floor(m / 60);
24
+ return `${h}h ${m % 60}m`;
25
+ }
26
+
27
+ export function formatTokenCount(n: number): string {
28
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
29
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
30
+ return String(n);
31
+ }
32
+
33
+ export function formatBytes(bytes: number): string {
34
+ if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
35
+ if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
36
+ if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(1)} KB`;
37
+ return `${bytes} B`;
38
+ }
39
+
40
+ export function renderSettingsText(
41
+ model: string,
42
+ effort: string,
43
+ proactive: boolean,
44
+ pulseIntervalMs?: number,
45
+ ): string {
46
+ const intervalStr = pulseIntervalMs
47
+ ? formatDuration(pulseIntervalMs)
48
+ : formatDuration(DEFAULT_PULSE_INTERVAL_MS);
49
+ return [
50
+ "<b>\uD83E\uDD85 Settings</b>",
51
+ "",
52
+ `<b>Model:</b> <code>${escapeHtml(model)}</code>`,
53
+ `<b>Effort:</b> ${effort}`,
54
+ `<b>Pulse:</b> ${proactive ? "on" : "off"} (every ${intervalStr})`,
55
+ ].join("\n");
56
+ }
57
+
58
+ export function renderSettingsKeyboard(
59
+ model: string,
60
+ effort: string,
61
+ proactive: boolean,
62
+ ): Array<Array<{ text: string; callback_data: string }>> {
63
+ const isModel = (id: string) => model.includes(id);
64
+ return [
65
+ [
66
+ {
67
+ text: isModel("sonnet") ? "\u2713 Sonnet" : "Sonnet",
68
+ callback_data: "settings:model:sonnet",
69
+ },
70
+ {
71
+ text: isModel("opus") ? "\u2713 Opus" : "Opus",
72
+ callback_data: "settings:model:opus",
73
+ },
74
+ {
75
+ text: isModel("haiku") ? "\u2713 Haiku" : "Haiku",
76
+ callback_data: "settings:model:haiku",
77
+ },
78
+ ],
79
+ [
80
+ {
81
+ text: effort === "low" ? "\u2713 Low" : "Low",
82
+ callback_data: "settings:effort:low",
83
+ },
84
+ {
85
+ text: effort === "medium" ? "\u2713 Med" : "Med",
86
+ callback_data: "settings:effort:medium",
87
+ },
88
+ {
89
+ text: effort === "high" ? "\u2713 High" : "High",
90
+ callback_data: "settings:effort:high",
91
+ },
92
+ {
93
+ text: effort === "adaptive" ? "\u2713 Auto" : "Auto",
94
+ callback_data: "settings:effort:adaptive",
95
+ },
96
+ ],
97
+ [
98
+ {
99
+ text: proactive ? "Pulse: ON" : "Pulse: OFF",
100
+ callback_data: `settings:proactive:${proactive ? "off" : "on"}`,
101
+ },
102
+ { text: "Done", callback_data: "settings:done" },
103
+ ],
104
+ ];
105
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Telegram frontend factory.
3
+ *
4
+ * Encapsulates everything Telegram-specific: Bot instance, command registration,
5
+ * GramJS userbot, graceful shutdown. Registers its action handler with the
6
+ * core gateway so MCP tool calls route to Telegram API.
7
+ */
8
+
9
+ import { Bot, InputFile } from "grammy";
10
+ import { autoRetry } from "@grammyjs/auto-retry";
11
+ import { apiThrottler } from "@grammyjs/transformer-throttler";
12
+ import type { TalonConfig } from "../../util/config.js";
13
+ import type { ContextManager } from "../../core/types.js";
14
+ import type { Gateway } from "../../core/gateway.js";
15
+ import { createTelegramActionHandler, sendText } from "./actions.js";
16
+ import {
17
+ initUserClient,
18
+ disconnectUserClient,
19
+ } from "./userbot.js";
20
+ import { registerCommands, setAdminUserId } from "./commands.js";
21
+ import { registerMiddleware } from "./middleware.js";
22
+ import { registerCallbacks } from "./callbacks.js";
23
+ import { log, logError } from "../../util/log.js";
24
+
25
+ // ── Frontend interface ──────────────────────────────────────────────────────
26
+
27
+ export type TelegramFrontend = {
28
+ context: ContextManager;
29
+ sendTyping: (chatId: number) => Promise<void>;
30
+ sendMessage: (chatId: number, text: string) => Promise<void>;
31
+ getBridgePort: () => number;
32
+ init: () => Promise<void>;
33
+ start: () => Promise<void>;
34
+ stop: () => Promise<void>;
35
+ };
36
+
37
+ // ── Factory ─────────────────────────────────────────────────────────────────
38
+
39
+ export function createTelegramFrontend(config: TalonConfig, gateway: Gateway): TelegramFrontend {
40
+ const bot = new Bot(config.botToken!);
41
+ bot.api.config.use(apiThrottler());
42
+ bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 60 }));
43
+
44
+ const context: ContextManager = {
45
+ acquire: (chatId: number) => gateway.setContext(chatId),
46
+ release: (chatId: number) => gateway.clearContext(chatId),
47
+ getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
48
+ };
49
+
50
+ return {
51
+ context,
52
+
53
+ sendTyping: (chatId: number) =>
54
+ bot.api.sendChatAction(chatId, "typing").then(() => {}),
55
+
56
+ sendMessage: async (chatId: number, text: string) => {
57
+ await sendText(bot, chatId, text);
58
+ },
59
+
60
+ getBridgePort: () => gateway.getPort(),
61
+
62
+ async init() {
63
+ // Register Telegram action handler with the core gateway
64
+ gateway.setFrontendHandler(createTelegramActionHandler(bot, InputFile, config.botToken!, gateway));
65
+
66
+ const port = await gateway.start(19876);
67
+ log("bot", `Gateway started on port ${port}`);
68
+
69
+ setAdminUserId(config.adminUserId);
70
+
71
+ registerCommands(bot, config);
72
+ registerMiddleware(bot, config);
73
+ registerCallbacks(bot, config);
74
+
75
+ await bot.api.deleteMyCommands();
76
+ await bot.api.setMyCommands([
77
+ { command: "start", description: "Introduction" },
78
+ { command: "settings", description: "View and change all chat settings" },
79
+ { command: "memory", description: "View what Talon remembers" },
80
+ { command: "status", description: "Session info, usage, and stats" },
81
+ { command: "ping", description: "Health check with latency" },
82
+ { command: "model", description: "Show or change model" },
83
+ { command: "effort", description: "Set thinking effort level" },
84
+ { command: "pulse", description: "Conversation engagement settings" },
85
+ { command: "reset", description: "Clear session and start fresh" },
86
+ { command: "restart", description: "Restart the bot (admin)" },
87
+ { command: "dream", description: "Force memory consolidation" },
88
+ { command: "plugins", description: "List loaded plugins" },
89
+ { command: "help", description: "All commands and features" },
90
+ ]);
91
+ log("commands", "Registered bot commands with Telegram");
92
+
93
+ const apiId = config.apiId ?? 0;
94
+ const apiHash = config.apiHash ?? "";
95
+ if (apiId && apiHash) {
96
+ initUserClient({ apiId, apiHash })
97
+ .then((ok) => {
98
+ if (ok) log("userbot", "Full Telegram history access enabled.");
99
+ else log("userbot", "Not authorized. Run: npx tsx src/login.ts");
100
+ })
101
+ .catch((err) => logError("userbot", "Init failed", err));
102
+ } else {
103
+ log("userbot", "TALON_API_ID/TALON_API_HASH not set -- using in-memory history only.");
104
+ }
105
+ },
106
+
107
+ async start() {
108
+ bot.catch((err: unknown) => {
109
+ const msg = err instanceof Error ? err.message : String(err);
110
+ logError("bot", "Unhandled bot error", err);
111
+ if (/unauthorized|401|not found|404/i.test(msg)) {
112
+ logError("bot", "Bot token appears invalid — shutting down");
113
+ process.exit(1);
114
+ }
115
+ });
116
+ await bot.start({
117
+ onStart: (info) => log("bot", `Talon running as @${info.username}`),
118
+ });
119
+ },
120
+
121
+ async stop() {
122
+ try { await bot.stop(); log("shutdown", "Bot disconnected"); }
123
+ catch (err) { logError("shutdown", "Bot stop error", err); }
124
+ try { await disconnectUserClient(); log("shutdown", "User client disconnected"); }
125
+ catch (err) { logError("shutdown", "User client disconnect error", err); }
126
+ try { await gateway.stop(); log("shutdown", "Gateway stopped"); }
127
+ catch (err) { logError("shutdown", "Gateway stop error", err); }
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * History capture middleware — runs for ALL messages, before handlers.
3
+ * Records every message into the in-memory history buffer.
4
+ */
5
+
6
+ import type { Bot } from "grammy";
7
+ import type { TalonConfig } from "../../util/config.js";
8
+ import { pushMessage } from "../../storage/history.js";
9
+ import { allowChat, revokeChat } from "./userbot.js";
10
+ import { registerChat } from "../../core/pulse.js";
11
+ import { log } from "../../util/log.js";
12
+ import { getSenderName } from "./handlers.js";
13
+ import {
14
+ handleTextMessage,
15
+ handlePhotoMessage,
16
+ handleDocumentMessage,
17
+ handleVoiceMessage,
18
+ handleStickerMessage,
19
+ handleVideoMessage,
20
+ handleAnimationMessage,
21
+ handleAudioMessage,
22
+ handleVideoNoteMessage,
23
+ } from "./handlers.js";
24
+
25
+ export function registerMiddleware(bot: Bot, config: TalonConfig): void {
26
+ // ── History capture (runs for ALL messages, before handlers) ─────────────
27
+ bot.on("message", (ctx, next) => {
28
+ const chatId = String(ctx.chat.id);
29
+ const sender = getSenderName(ctx.from);
30
+ const senderId = ctx.from?.id ?? 0;
31
+ const msgId = ctx.message.message_id;
32
+ const replyToMsgId = ctx.message.reply_to_message?.message_id;
33
+
34
+ // Register this chat for userbot access
35
+ allowChat(ctx.chat.id);
36
+ // Only register groups for pulse (DMs don't need it — bot always responds)
37
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
38
+ if (isGroup) registerChat(chatId);
39
+ const timestamp = ctx.message.date * 1000;
40
+
41
+ if ("text" in ctx.message && ctx.message.text) {
42
+ pushMessage(chatId, {
43
+ msgId,
44
+ senderId,
45
+ senderName: sender,
46
+ text: ctx.message.text,
47
+ replyToMsgId,
48
+ timestamp,
49
+ });
50
+ } else if ("photo" in ctx.message && ctx.message.photo) {
51
+ pushMessage(chatId, {
52
+ msgId,
53
+ senderId,
54
+ senderName: sender,
55
+ text: ctx.message.caption || "(photo)",
56
+ replyToMsgId,
57
+ timestamp,
58
+ mediaType: "photo",
59
+ });
60
+ } else if ("document" in ctx.message && ctx.message.document) {
61
+ const name = ctx.message.document.file_name || "file";
62
+ pushMessage(chatId, {
63
+ msgId,
64
+ senderId,
65
+ senderName: sender,
66
+ text: ctx.message.caption || `(sent ${name})`,
67
+ replyToMsgId,
68
+ timestamp,
69
+ mediaType: "document",
70
+ });
71
+ } else if ("voice" in ctx.message && ctx.message.voice) {
72
+ pushMessage(chatId, {
73
+ msgId,
74
+ senderId,
75
+ senderName: sender,
76
+ text: "(voice message)",
77
+ replyToMsgId,
78
+ timestamp,
79
+ mediaType: "voice",
80
+ });
81
+ } else if ("sticker" in ctx.message && ctx.message.sticker) {
82
+ pushMessage(chatId, {
83
+ msgId,
84
+ senderId,
85
+ senderName: sender,
86
+ text: ctx.message.sticker.emoji || "(sticker)",
87
+ replyToMsgId,
88
+ timestamp,
89
+ mediaType: "sticker",
90
+ stickerFileId: ctx.message.sticker.file_id,
91
+ });
92
+ } else if ("video" in ctx.message && ctx.message.video) {
93
+ pushMessage(chatId, {
94
+ msgId,
95
+ senderId,
96
+ senderName: sender,
97
+ text: ctx.message.caption || "(video)",
98
+ replyToMsgId,
99
+ timestamp,
100
+ mediaType: "video",
101
+ });
102
+ } else if ("animation" in ctx.message && ctx.message.animation) {
103
+ pushMessage(chatId, {
104
+ msgId,
105
+ senderId,
106
+ senderName: sender,
107
+ text: ctx.message.caption || "(GIF)",
108
+ replyToMsgId,
109
+ timestamp,
110
+ mediaType: "animation",
111
+ });
112
+ } else if ("audio" in ctx.message && ctx.message.audio) {
113
+ const title = ctx.message.audio.title || ctx.message.audio.file_name || "audio";
114
+ pushMessage(chatId, {
115
+ msgId,
116
+ senderId,
117
+ senderName: sender,
118
+ text: ctx.message.caption || `(audio: ${title})`,
119
+ replyToMsgId,
120
+ timestamp,
121
+ mediaType: "document", // treat audio like documents in history
122
+ });
123
+ } else if ("video_note" in ctx.message && ctx.message.video_note) {
124
+ pushMessage(chatId, {
125
+ msgId,
126
+ senderId,
127
+ senderName: sender,
128
+ text: "(video note)",
129
+ replyToMsgId,
130
+ timestamp,
131
+ mediaType: "video",
132
+ });
133
+ } else if ("location" in ctx.message && ctx.message.location) {
134
+ pushMessage(chatId, {
135
+ msgId,
136
+ senderId,
137
+ senderName: sender,
138
+ text: `(shared location: ${ctx.message.location.latitude}, ${ctx.message.location.longitude})`,
139
+ replyToMsgId,
140
+ timestamp,
141
+ });
142
+ } else if ("contact" in ctx.message && ctx.message.contact) {
143
+ const name = [ctx.message.contact.first_name, ctx.message.contact.last_name].filter(Boolean).join(" ");
144
+ pushMessage(chatId, {
145
+ msgId,
146
+ senderId,
147
+ senderName: sender,
148
+ text: `(shared contact: ${name})`,
149
+ replyToMsgId,
150
+ timestamp,
151
+ });
152
+ }
153
+
154
+ return next();
155
+ });
156
+
157
+ // ── Bot removed from group — revoke userbot access ─────────────────────
158
+ bot.on("my_chat_member", (ctx) => {
159
+ const newStatus = ctx.myChatMember.new_chat_member.status;
160
+ if (newStatus === "left" || newStatus === "kicked") {
161
+ const chatId = ctx.chat.id;
162
+ revokeChat(chatId);
163
+ log("bot", `Removed from chat ${chatId} — revoked userbot access`);
164
+ }
165
+ });
166
+
167
+ // ── Message handlers (delegated to handlers.ts) ──────────────────────────
168
+ bot.on("message:text", (ctx) => handleTextMessage(ctx, bot, config));
169
+ bot.on("message:photo", (ctx) => handlePhotoMessage(ctx, bot, config));
170
+ bot.on("message:document", (ctx) => handleDocumentMessage(ctx, bot, config));
171
+ bot.on("message:voice", (ctx) => handleVoiceMessage(ctx, bot, config));
172
+ bot.on("message:sticker", (ctx) => handleStickerMessage(ctx, bot, config));
173
+ bot.on("message:video", (ctx) => handleVideoMessage(ctx, bot, config));
174
+ bot.on("message:animation", (ctx) => handleAnimationMessage(ctx, bot, config));
175
+ bot.on("message:audio", (ctx) => handleAudioMessage(ctx, bot, config));
176
+ bot.on("message:video_note", (ctx) => handleVideoNoteMessage(ctx, bot, config));
177
+ }