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.
- package/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal command registry — extensible slash command system.
|
|
3
|
+
*
|
|
4
|
+
* Each command is a self-contained handler registered via `registerCommand()`.
|
|
5
|
+
* New commands = one function call. Handlers are independently testable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
10
|
+
import type { Renderer } from "./renderer.js";
|
|
11
|
+
import { formatTimeAgo } from "./renderer.js";
|
|
12
|
+
import { isTerminalChatId } from "../../util/chat-id.js";
|
|
13
|
+
|
|
14
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export type CommandContext = {
|
|
17
|
+
/** Current chat ID (getter — may change on /resume). */
|
|
18
|
+
chatId: () => string;
|
|
19
|
+
config: TalonConfig;
|
|
20
|
+
renderer: Renderer;
|
|
21
|
+
reprompt: () => void;
|
|
22
|
+
initNewChat: (id?: string) => void;
|
|
23
|
+
waitForInput: () => Promise<string>;
|
|
24
|
+
/** Close the terminal (for /quit). */
|
|
25
|
+
close: () => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CommandHandler = (
|
|
29
|
+
args: string,
|
|
30
|
+
ctx: CommandContext,
|
|
31
|
+
) => Promise<void>;
|
|
32
|
+
|
|
33
|
+
export type Command = {
|
|
34
|
+
name: string;
|
|
35
|
+
aliases?: string[];
|
|
36
|
+
argHint?: string;
|
|
37
|
+
description: string;
|
|
38
|
+
handler: CommandHandler;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const commands: Command[] = [];
|
|
44
|
+
const nameIndex = new Map<string, Command>();
|
|
45
|
+
|
|
46
|
+
export function registerCommand(cmd: Command): void {
|
|
47
|
+
commands.push(cmd);
|
|
48
|
+
nameIndex.set(cmd.name, cmd);
|
|
49
|
+
if (cmd.aliases) {
|
|
50
|
+
for (const alias of cmd.aliases) {
|
|
51
|
+
nameIndex.set(alias, cmd);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Try to run a slash command. Returns true if handled, false if not a command. */
|
|
57
|
+
export async function tryRunCommand(
|
|
58
|
+
text: string,
|
|
59
|
+
ctx: CommandContext,
|
|
60
|
+
): Promise<boolean> {
|
|
61
|
+
if (!text.startsWith("/")) return false;
|
|
62
|
+
|
|
63
|
+
const spaceIdx = text.indexOf(" ");
|
|
64
|
+
const cmdName = (spaceIdx === -1 ? text : text.slice(0, spaceIdx))
|
|
65
|
+
.slice(1)
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
const args = spaceIdx === -1 ? "" : text.slice(spaceIdx + 1).trim();
|
|
68
|
+
|
|
69
|
+
const cmd = nameIndex.get(cmdName);
|
|
70
|
+
if (!cmd) return false;
|
|
71
|
+
|
|
72
|
+
await cmd.handler(args, ctx);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Get all registered commands (for /help rendering). */
|
|
77
|
+
export function getCommands(): readonly Command[] {
|
|
78
|
+
return commands;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Clear all registered commands (for testing). */
|
|
82
|
+
export function clearCommands(): void {
|
|
83
|
+
commands.length = 0;
|
|
84
|
+
nameIndex.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Built-in commands ────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export function registerBuiltinCommands(): void {
|
|
90
|
+
registerCommand({
|
|
91
|
+
name: "model",
|
|
92
|
+
argHint: "[name]",
|
|
93
|
+
description: "Switch model (opus, sonnet, haiku)",
|
|
94
|
+
async handler(args, ctx) {
|
|
95
|
+
const { getChatSettings, setChatModel, resolveModelName } =
|
|
96
|
+
await import("../../storage/chat-settings.js");
|
|
97
|
+
if (!args) {
|
|
98
|
+
ctx.renderer.writeSystem(
|
|
99
|
+
`Model: ${getChatSettings(ctx.chatId()).model ?? ctx.config.model}`,
|
|
100
|
+
);
|
|
101
|
+
} else {
|
|
102
|
+
setChatModel(ctx.chatId(), resolveModelName(args));
|
|
103
|
+
ctx.renderer.writeSystem(`Model → ${resolveModelName(args)}`);
|
|
104
|
+
}
|
|
105
|
+
ctx.reprompt();
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
registerCommand({
|
|
110
|
+
name: "effort",
|
|
111
|
+
argHint: "[lvl]",
|
|
112
|
+
description: "Thinking effort (off/low/medium/high/max)",
|
|
113
|
+
async handler(args, ctx) {
|
|
114
|
+
const { getChatSettings, setChatEffort } =
|
|
115
|
+
await import("../../storage/chat-settings.js");
|
|
116
|
+
if (!args) {
|
|
117
|
+
ctx.renderer.writeSystem(
|
|
118
|
+
`Effort: ${getChatSettings(ctx.chatId()).effort ?? "adaptive"}`,
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
setChatEffort(
|
|
122
|
+
ctx.chatId(),
|
|
123
|
+
args === "adaptive"
|
|
124
|
+
? undefined
|
|
125
|
+
: (args as "off" | "low" | "medium" | "high" | "max"),
|
|
126
|
+
);
|
|
127
|
+
ctx.renderer.writeSystem(`Effort → ${args}`);
|
|
128
|
+
}
|
|
129
|
+
ctx.reprompt();
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
registerCommand({
|
|
134
|
+
name: "status",
|
|
135
|
+
description: "Session stats",
|
|
136
|
+
async handler(_args, ctx) {
|
|
137
|
+
const { getSessionInfo } = await import("../../storage/sessions.js");
|
|
138
|
+
const { getLoadedPlugins } = await import("../../core/plugin.js");
|
|
139
|
+
const info = getSessionInfo(ctx.chatId());
|
|
140
|
+
const u = info.usage;
|
|
141
|
+
const cacheHit =
|
|
142
|
+
u.totalInputTokens + u.totalCacheRead > 0
|
|
143
|
+
? Math.round(
|
|
144
|
+
(u.totalCacheRead / (u.totalInputTokens + u.totalCacheRead)) *
|
|
145
|
+
100,
|
|
146
|
+
)
|
|
147
|
+
: 0;
|
|
148
|
+
ctx.renderer.writeln();
|
|
149
|
+
const nameStr = info.sessionName ? `"${info.sessionName}" · ` : "";
|
|
150
|
+
ctx.renderer.writeln(
|
|
151
|
+
` ${pc.bold("Session")} ${nameStr}turns ${info.turns} · ${cacheHit}% cache`,
|
|
152
|
+
);
|
|
153
|
+
ctx.renderer.writeln(
|
|
154
|
+
` ${pc.dim(`in ${u.totalInputTokens.toLocaleString()} · out ${u.totalOutputTokens.toLocaleString()} tokens`)}`,
|
|
155
|
+
);
|
|
156
|
+
const plugins = getLoadedPlugins();
|
|
157
|
+
if (plugins.length > 0) {
|
|
158
|
+
ctx.renderer.writeln();
|
|
159
|
+
ctx.renderer.writeln(` ${pc.bold("Plugins")}`);
|
|
160
|
+
for (const p of plugins) {
|
|
161
|
+
const ver = p.plugin.version ? pc.dim(` v${p.plugin.version}`) : "";
|
|
162
|
+
const desc = p.plugin.description
|
|
163
|
+
? ` ${pc.dim(p.plugin.description)}`
|
|
164
|
+
: "";
|
|
165
|
+
const tools = p.plugin.mcpServerPath
|
|
166
|
+
? pc.green("mcp")
|
|
167
|
+
: pc.dim("actions only");
|
|
168
|
+
ctx.renderer.writeln(
|
|
169
|
+
` ${pc.green("●")} ${p.plugin.name}${ver} ${tools}${desc}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
ctx.reprompt();
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
registerCommand({
|
|
178
|
+
name: "reset",
|
|
179
|
+
description: "Start a fresh session",
|
|
180
|
+
async handler(_args, ctx) {
|
|
181
|
+
ctx.initNewChat();
|
|
182
|
+
ctx.renderer.writeSystem("Session cleared.");
|
|
183
|
+
ctx.reprompt();
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
registerCommand({
|
|
188
|
+
name: "resume",
|
|
189
|
+
description: "List & resume a past session",
|
|
190
|
+
async handler(_args, ctx) {
|
|
191
|
+
const { getAllSessions } = await import("../../storage/sessions.js");
|
|
192
|
+
const sessions = getAllSessions()
|
|
193
|
+
.filter(
|
|
194
|
+
(s) =>
|
|
195
|
+
isTerminalChatId(s.chatId) &&
|
|
196
|
+
s.chatId !== ctx.chatId() &&
|
|
197
|
+
s.info.turns > 0,
|
|
198
|
+
)
|
|
199
|
+
.sort((a, b) => b.info.lastActive - a.info.lastActive)
|
|
200
|
+
.slice(0, 10);
|
|
201
|
+
|
|
202
|
+
if (sessions.length === 0) {
|
|
203
|
+
ctx.renderer.writeSystem("No previous sessions to resume.");
|
|
204
|
+
ctx.reprompt();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
ctx.renderer.writeln();
|
|
209
|
+
ctx.renderer.writeln(` ${pc.bold("Past sessions")}`);
|
|
210
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
211
|
+
const s = sessions[i]!;
|
|
212
|
+
const name = s.info.sessionName
|
|
213
|
+
? `"${s.info.sessionName}"`
|
|
214
|
+
: pc.dim("(unnamed)");
|
|
215
|
+
const turns = `${s.info.turns} turn${s.info.turns !== 1 ? "s" : ""}`;
|
|
216
|
+
const ago = formatTimeAgo(s.info.lastActive);
|
|
217
|
+
const model = s.info.lastModel
|
|
218
|
+
? s.info.lastModel
|
|
219
|
+
.replace("claude-", "")
|
|
220
|
+
.replace(/-(\d+)-(\d+).*/, " $1.$2")
|
|
221
|
+
: "";
|
|
222
|
+
ctx.renderer.writeln(
|
|
223
|
+
` ${pc.green(String(i + 1))}. ${name} ${pc.dim(`${turns} · ${ago}${model ? ` · ${model}` : ""}`)}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
ctx.renderer.writeln();
|
|
227
|
+
ctx.renderer.writeln(
|
|
228
|
+
` ${pc.dim("Enter number to resume (Esc to cancel):")}`,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const input = await ctx.waitForInput();
|
|
232
|
+
const num = parseInt(input, 10);
|
|
233
|
+
if (num >= 1 && num <= sessions.length) {
|
|
234
|
+
const selected = sessions[num - 1]!;
|
|
235
|
+
ctx.initNewChat(selected.chatId);
|
|
236
|
+
const name = selected.info.sessionName
|
|
237
|
+
? `"${selected.info.sessionName}"`
|
|
238
|
+
: `(${selected.info.turns} turns)`;
|
|
239
|
+
ctx.renderer.writeSystem(`Resumed: ${name}`);
|
|
240
|
+
} else {
|
|
241
|
+
ctx.renderer.writeSystem("Cancelled.");
|
|
242
|
+
}
|
|
243
|
+
ctx.reprompt();
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
registerCommand({
|
|
248
|
+
name: "rename",
|
|
249
|
+
argHint: "[name]",
|
|
250
|
+
description: "Name the current session",
|
|
251
|
+
async handler(args, ctx) {
|
|
252
|
+
const { getSession, setSessionName } =
|
|
253
|
+
await import("../../storage/sessions.js");
|
|
254
|
+
// Ensure session exists in store (auto-creates if needed)
|
|
255
|
+
getSession(ctx.chatId());
|
|
256
|
+
if (!args) {
|
|
257
|
+
const session = getSession(ctx.chatId());
|
|
258
|
+
ctx.renderer.writeSystem(
|
|
259
|
+
session.sessionName
|
|
260
|
+
? `Session name: "${session.sessionName}"`
|
|
261
|
+
: "Session has no name.",
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
setSessionName(ctx.chatId(), args);
|
|
265
|
+
ctx.renderer.writeSystem(`Session renamed to "${args}"`);
|
|
266
|
+
}
|
|
267
|
+
ctx.reprompt();
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
registerCommand({
|
|
272
|
+
name: "help",
|
|
273
|
+
description: "Show available commands",
|
|
274
|
+
async handler(_args, ctx) {
|
|
275
|
+
ctx.renderer.writeln();
|
|
276
|
+
for (const cmd of getCommands()) {
|
|
277
|
+
if (cmd.name === "help") continue; // show help last
|
|
278
|
+
const nameStr = `/${cmd.name}`;
|
|
279
|
+
const argStr = cmd.argHint ? ` ${cmd.argHint}` : "";
|
|
280
|
+
const pad = " ".repeat(
|
|
281
|
+
Math.max(1, 16 - nameStr.length - argStr.length),
|
|
282
|
+
);
|
|
283
|
+
ctx.renderer.writeln(
|
|
284
|
+
` ${pc.cyan(nameStr)}${pc.dim(argStr)}${pad}${pc.dim(cmd.description)}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// Help itself at the end
|
|
288
|
+
ctx.renderer.writeln(
|
|
289
|
+
` ${pc.cyan("/help")} ${pc.dim("Show available commands")}`,
|
|
290
|
+
);
|
|
291
|
+
ctx.reprompt();
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
registerCommand({
|
|
296
|
+
name: "quit",
|
|
297
|
+
aliases: ["exit"],
|
|
298
|
+
description: "Exit",
|
|
299
|
+
async handler(_args, ctx) {
|
|
300
|
+
ctx.close();
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal frontend — slim orchestrator wiring renderer, commands, and input.
|
|
3
|
+
*
|
|
4
|
+
* Readline lifecycle:
|
|
5
|
+
* - rl starts active (prompt shown)
|
|
6
|
+
* - User presses Enter → rl.pause() immediately → processing begins
|
|
7
|
+
* - Processing finishes → all output written → rl.resume() + rl.prompt()
|
|
8
|
+
* - Renderer NEVER touches readline. Only this file does.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
import type { TalonConfig } from "../../util/config.js";
|
|
13
|
+
import type { ContextManager, ActionResult } from "../../core/types.js";
|
|
14
|
+
import type { Gateway } from "../../core/gateway.js";
|
|
15
|
+
import { log } from "../../util/log.js";
|
|
16
|
+
import {
|
|
17
|
+
deriveNumericChatId,
|
|
18
|
+
generateTerminalChatId,
|
|
19
|
+
} from "../../util/chat-id.js";
|
|
20
|
+
import { createRenderer } from "./renderer.js";
|
|
21
|
+
import { createInput } from "./input.js";
|
|
22
|
+
import {
|
|
23
|
+
registerBuiltinCommands,
|
|
24
|
+
tryRunCommand,
|
|
25
|
+
clearCommands,
|
|
26
|
+
type CommandContext,
|
|
27
|
+
} from "./commands.js";
|
|
28
|
+
|
|
29
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
let terminalChatId = "";
|
|
32
|
+
let terminalNumericId = 0;
|
|
33
|
+
|
|
34
|
+
function initNewChat(chatId?: string): void {
|
|
35
|
+
terminalChatId = chatId ?? generateTerminalChatId();
|
|
36
|
+
terminalNumericId = deriveNumericChatId(terminalChatId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Action handler (bridge) ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function createActionHandler(
|
|
42
|
+
gateway: Gateway,
|
|
43
|
+
renderer: ReturnType<typeof createRenderer>,
|
|
44
|
+
): (
|
|
45
|
+
body: Record<string, unknown>,
|
|
46
|
+
chatId: number,
|
|
47
|
+
) => Promise<ActionResult | null> {
|
|
48
|
+
return async (body) => {
|
|
49
|
+
const action = body.action as string;
|
|
50
|
+
switch (action) {
|
|
51
|
+
case "send_message": {
|
|
52
|
+
renderer.stopSpinner();
|
|
53
|
+
renderer.renderAssistantMessage(String(body.text ?? ""));
|
|
54
|
+
gateway.incrementMessages(terminalNumericId);
|
|
55
|
+
return { ok: true, message_id: Date.now() };
|
|
56
|
+
}
|
|
57
|
+
case "react": {
|
|
58
|
+
renderer.stopSpinner();
|
|
59
|
+
renderer.writeln(` ${pc.cyan("▍")} ${String(body.emoji ?? "👍")}`);
|
|
60
|
+
gateway.incrementMessages(terminalNumericId);
|
|
61
|
+
return { ok: true };
|
|
62
|
+
}
|
|
63
|
+
case "send_message_with_buttons": {
|
|
64
|
+
renderer.stopSpinner();
|
|
65
|
+
renderer.renderAssistantMessage(String(body.text ?? ""));
|
|
66
|
+
const rows = body.rows as Array<Array<{ text: string }>> | undefined;
|
|
67
|
+
if (rows) {
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
renderer.writeln(
|
|
70
|
+
` ${pc.cyan("▍")} ${row.map((b) => pc.dim(`[${b.text}]`)).join(" ")}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
gateway.incrementMessages(terminalNumericId);
|
|
75
|
+
return { ok: true, message_id: Date.now() };
|
|
76
|
+
}
|
|
77
|
+
case "edit_message":
|
|
78
|
+
case "delete_message":
|
|
79
|
+
case "pin_message":
|
|
80
|
+
case "unpin_message":
|
|
81
|
+
case "forward_message":
|
|
82
|
+
case "copy_message":
|
|
83
|
+
case "send_chat_action":
|
|
84
|
+
return { ok: true };
|
|
85
|
+
case "get_chat_info":
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
id: terminalNumericId,
|
|
89
|
+
type: "private",
|
|
90
|
+
title: "Terminal",
|
|
91
|
+
};
|
|
92
|
+
default:
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Frontend interface ───────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export type TerminalFrontend = {
|
|
101
|
+
context: ContextManager;
|
|
102
|
+
sendTyping: (chatId: number) => Promise<void>;
|
|
103
|
+
sendMessage: (chatId: number, text: string) => Promise<void>;
|
|
104
|
+
getBridgePort: () => number;
|
|
105
|
+
init: () => Promise<void>;
|
|
106
|
+
start: () => Promise<void>;
|
|
107
|
+
stop: () => Promise<void>;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function createTerminalFrontend(
|
|
111
|
+
config: TalonConfig,
|
|
112
|
+
gateway: Gateway,
|
|
113
|
+
): TerminalFrontend {
|
|
114
|
+
const renderer = createRenderer(undefined, config.botDisplayName);
|
|
115
|
+
let currentPhase: "idle" | "thinking" | "tool" | "text" = "idle";
|
|
116
|
+
let toolCallCount = 0;
|
|
117
|
+
|
|
118
|
+
const context: ContextManager = {
|
|
119
|
+
acquire: () => gateway.setContext(terminalNumericId, terminalChatId),
|
|
120
|
+
release: () => gateway.clearContext(terminalNumericId),
|
|
121
|
+
getMessageCount: (chatId: number) => gateway.getMessageCount(chatId),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
context,
|
|
126
|
+
sendTyping: async () => {
|
|
127
|
+
renderer.startSpinner(
|
|
128
|
+
currentPhase === "tool" ? "running tools" : "thinking",
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
sendMessage: async (_chatId: number, text: string) => {
|
|
132
|
+
renderer.stopSpinner();
|
|
133
|
+
renderer.renderAssistantMessage(text);
|
|
134
|
+
},
|
|
135
|
+
getBridgePort: () => gateway.getPort(),
|
|
136
|
+
|
|
137
|
+
async init() {
|
|
138
|
+
gateway.setFrontendHandler(createActionHandler(gateway, renderer));
|
|
139
|
+
const port = await gateway.start(19877);
|
|
140
|
+
log("bot", `Terminal gateway on port ${port}`);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async start() {
|
|
144
|
+
initNewChat();
|
|
145
|
+
|
|
146
|
+
const modelDisplay = config.model
|
|
147
|
+
.replace("claude-", "")
|
|
148
|
+
.replace(
|
|
149
|
+
/^(\w+)-(\d+)-(\d+)/,
|
|
150
|
+
(_, name: string, maj: string, min: string) =>
|
|
151
|
+
`${name.charAt(0).toUpperCase() + name.slice(1)} ${maj}.${min}`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
renderer.writeln();
|
|
155
|
+
renderer.writeln(
|
|
156
|
+
` ${pc.bold(pc.cyan(config.botDisplayName))} ${pc.dim(modelDisplay)}`,
|
|
157
|
+
);
|
|
158
|
+
renderer.writeln(` ${pc.dim("─".repeat(renderer.cols - 2))}`);
|
|
159
|
+
|
|
160
|
+
const { execute } = await import("../../core/dispatcher.js");
|
|
161
|
+
const { getSessionInfo } = await import("../../storage/sessions.js");
|
|
162
|
+
const input = createInput(` ${pc.green("❯")} `);
|
|
163
|
+
|
|
164
|
+
clearCommands();
|
|
165
|
+
registerBuiltinCommands();
|
|
166
|
+
|
|
167
|
+
function pauseInput(): void {
|
|
168
|
+
input.pause();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function reprompt(): void {
|
|
172
|
+
renderer.writeln(); // blank line before prompt
|
|
173
|
+
input.resume();
|
|
174
|
+
input.prompt();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const cmdCtx: CommandContext = {
|
|
178
|
+
chatId: () => terminalChatId,
|
|
179
|
+
config,
|
|
180
|
+
renderer,
|
|
181
|
+
reprompt,
|
|
182
|
+
initNewChat,
|
|
183
|
+
waitForInput: () => input.waitForInput(),
|
|
184
|
+
close: () => {
|
|
185
|
+
renderer.writeln();
|
|
186
|
+
renderer.writeln(` ${pc.dim("Goodbye!")}`);
|
|
187
|
+
renderer.writeln();
|
|
188
|
+
input.close();
|
|
189
|
+
process.exit(0);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
input.onLine(async (text) => {
|
|
194
|
+
if (!text) {
|
|
195
|
+
reprompt();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Slash commands — these handle their own reprompt
|
|
200
|
+
if (await tryRunCommand(text, cmdCtx)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── AI query ──
|
|
205
|
+
pauseInput(); // readline off until we're done
|
|
206
|
+
toolCallCount = 0;
|
|
207
|
+
currentPhase = "thinking";
|
|
208
|
+
renderer.startSpinner("thinking");
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const result = await execute({
|
|
212
|
+
chatId: terminalChatId,
|
|
213
|
+
numericChatId: terminalNumericId,
|
|
214
|
+
prompt: text,
|
|
215
|
+
senderName: "User",
|
|
216
|
+
isGroup: false,
|
|
217
|
+
source: "message",
|
|
218
|
+
onStreamDelta: (_accumulated, phase) => {
|
|
219
|
+
if (phase === "thinking" && currentPhase !== "thinking") {
|
|
220
|
+
currentPhase = "thinking";
|
|
221
|
+
renderer.updateSpinnerLabel("thinking");
|
|
222
|
+
} else if (phase === "text" && currentPhase !== "text") {
|
|
223
|
+
currentPhase = "text";
|
|
224
|
+
renderer.updateSpinnerLabel("responding");
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
onToolUse: (toolName, toolInput) => {
|
|
228
|
+
renderer.stopSpinner();
|
|
229
|
+
currentPhase = "tool";
|
|
230
|
+
toolCallCount++;
|
|
231
|
+
renderer.renderToolCall(toolName, toolInput);
|
|
232
|
+
renderer.startSpinner("running tools");
|
|
233
|
+
},
|
|
234
|
+
onTextBlock: async (blockText) => {
|
|
235
|
+
renderer.stopSpinner();
|
|
236
|
+
renderer.renderAssistantMessage(blockText);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
renderer.stopSpinner();
|
|
241
|
+
currentPhase = "idle";
|
|
242
|
+
|
|
243
|
+
if (result.bridgeMessageCount === 0 && result.text?.trim()) {
|
|
244
|
+
renderer.renderAssistantMessage(result.text);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const info = getSessionInfo(terminalChatId);
|
|
248
|
+
const u = info.usage;
|
|
249
|
+
const cacheHit =
|
|
250
|
+
u.totalInputTokens + u.totalCacheRead > 0
|
|
251
|
+
? Math.round(
|
|
252
|
+
(u.totalCacheRead / (u.totalInputTokens + u.totalCacheRead)) *
|
|
253
|
+
100,
|
|
254
|
+
)
|
|
255
|
+
: 0;
|
|
256
|
+
renderer.renderStatusLine(result.durationMs, toolCallCount, {
|
|
257
|
+
model: modelDisplay,
|
|
258
|
+
sessionName: info.sessionName,
|
|
259
|
+
turns: info.turns,
|
|
260
|
+
inputTokens: u.totalInputTokens,
|
|
261
|
+
outputTokens: u.totalOutputTokens,
|
|
262
|
+
cacheHitPct: cacheHit,
|
|
263
|
+
costUsd: u.estimatedCostUsd,
|
|
264
|
+
});
|
|
265
|
+
reprompt(); // readline back on, show prompt
|
|
266
|
+
} catch (err) {
|
|
267
|
+
renderer.stopSpinner();
|
|
268
|
+
currentPhase = "idle";
|
|
269
|
+
renderer.writeError(err instanceof Error ? err.message : String(err));
|
|
270
|
+
reprompt();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
input.prompt();
|
|
275
|
+
await new Promise(() => {});
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async stop() {
|
|
279
|
+
await gateway.stop();
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|