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,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal input — raw stdin with manual key parsing.
|
|
3
|
+
*
|
|
4
|
+
* Input is a list of parts: text segments and collapsed paste blocks.
|
|
5
|
+
* You can type, paste, type more, paste again. Backspace removes from the end.
|
|
6
|
+
* Enter submits everything. Ctrl+U clears all.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import pc from "picocolors";
|
|
10
|
+
|
|
11
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type InputHandler = {
|
|
14
|
+
onLine(callback: (text: string) => void): void;
|
|
15
|
+
prompt(): void;
|
|
16
|
+
waitForInput(): Promise<string>;
|
|
17
|
+
close(): void;
|
|
18
|
+
pause(): void;
|
|
19
|
+
resume(): void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type TextPart = { type: "text"; content: string };
|
|
23
|
+
type PastePart = { type: "paste"; content: string };
|
|
24
|
+
type Part = TextPart | PastePart;
|
|
25
|
+
|
|
26
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const PASTE_COLLAPSE_LINES = 3;
|
|
29
|
+
const PASTE_COLLAPSE_CHARS = 150;
|
|
30
|
+
const PASTE_START = "\x1b[200~";
|
|
31
|
+
const PASTE_END = "\x1b[201~";
|
|
32
|
+
|
|
33
|
+
// ── Factory ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function createInput(promptStr: string): InputHandler {
|
|
36
|
+
let lineCallback: ((text: string) => void) | null = null;
|
|
37
|
+
let pendingResolve: ((value: string) => void) | null = null;
|
|
38
|
+
let paused = false;
|
|
39
|
+
|
|
40
|
+
// Input is an ordered list of parts
|
|
41
|
+
let parts: Part[] = [{ type: "text", content: "" }];
|
|
42
|
+
|
|
43
|
+
// Bracketed paste accumulation
|
|
44
|
+
let inPaste = false;
|
|
45
|
+
let pasteAccum = "";
|
|
46
|
+
|
|
47
|
+
// ── Helpers ──
|
|
48
|
+
|
|
49
|
+
function lastPart(): Part {
|
|
50
|
+
return parts[parts.length - 1]!;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Ensure the last part is a text part (for typing into). */
|
|
54
|
+
function ensureTrailingText(): TextPart {
|
|
55
|
+
const last = lastPart();
|
|
56
|
+
if (last.type === "text") return last;
|
|
57
|
+
const t: TextPart = { type: "text", content: "" };
|
|
58
|
+
parts.push(t);
|
|
59
|
+
return t;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pasteTag(p: PastePart): string {
|
|
63
|
+
const lines = p.content.split("\n").length;
|
|
64
|
+
return lines > 1
|
|
65
|
+
? `[Pasted ~${lines} lines]`
|
|
66
|
+
: `[Pasted ${p.content.length} chars]`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Drawing ──
|
|
70
|
+
// Track how many visual rows the last render occupied.
|
|
71
|
+
// Move up to the first row, clear to end of screen, rewrite.
|
|
72
|
+
|
|
73
|
+
let prevRows = 1;
|
|
74
|
+
|
|
75
|
+
function redraw(): void {
|
|
76
|
+
const cols = process.stdout.columns || 80;
|
|
77
|
+
|
|
78
|
+
// Build display string and measure visible length (strip ANSI)
|
|
79
|
+
let display = promptStr;
|
|
80
|
+
let visLen = 4; // " ❯ " = 4 visible chars
|
|
81
|
+
for (const p of parts) {
|
|
82
|
+
if (p.type === "text") {
|
|
83
|
+
display += p.content;
|
|
84
|
+
visLen += p.content.length;
|
|
85
|
+
} else {
|
|
86
|
+
const tag = pasteTag(p);
|
|
87
|
+
display += pc.dim(tag);
|
|
88
|
+
visLen += tag.length;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Move cursor to start of the previous render, clear to end of screen
|
|
93
|
+
if (prevRows > 1) {
|
|
94
|
+
process.stdout.write(`\x1b[${prevRows - 1}A`); // move up
|
|
95
|
+
}
|
|
96
|
+
process.stdout.write(`\r\x1b[J${display}\x1b[?25h`); // col 0, clear to EOS, write, show cursor
|
|
97
|
+
|
|
98
|
+
prevRows = Math.max(1, Math.ceil(visLen / cols));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getFullText(): string {
|
|
102
|
+
return parts
|
|
103
|
+
.map((p) => p.content)
|
|
104
|
+
.join("\n")
|
|
105
|
+
.trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clear(): void {
|
|
109
|
+
parts = [{ type: "text", content: "" }];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function submit(): void {
|
|
113
|
+
const text = getFullText();
|
|
114
|
+
clear();
|
|
115
|
+
process.stdout.write("\n");
|
|
116
|
+
|
|
117
|
+
if (pendingResolve) {
|
|
118
|
+
const resolve = pendingResolve;
|
|
119
|
+
pendingResolve = null;
|
|
120
|
+
resolve(text);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (lineCallback) lineCallback(text);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function handlePasteComplete(text: string): void {
|
|
127
|
+
const lineCount = text.split("\n").length;
|
|
128
|
+
if (
|
|
129
|
+
lineCount >= PASTE_COLLAPSE_LINES ||
|
|
130
|
+
text.length > PASTE_COLLAPSE_CHARS
|
|
131
|
+
) {
|
|
132
|
+
// Collapse into a paste part
|
|
133
|
+
parts.push({ type: "paste", content: text });
|
|
134
|
+
} else {
|
|
135
|
+
// Short paste — inline into current text part
|
|
136
|
+
ensureTrailingText().content += text.replace(/\n/g, " ");
|
|
137
|
+
}
|
|
138
|
+
redraw();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function handleBackspace(): void {
|
|
142
|
+
const last = lastPart();
|
|
143
|
+
if (last.type === "text" && last.content.length > 0) {
|
|
144
|
+
// Delete last char from text
|
|
145
|
+
last.content = last.content.slice(0, -1);
|
|
146
|
+
} else if (
|
|
147
|
+
last.type === "text" &&
|
|
148
|
+
last.content === "" &&
|
|
149
|
+
parts.length > 1
|
|
150
|
+
) {
|
|
151
|
+
// Empty trailing text — remove it, then remove the paste before it
|
|
152
|
+
parts.pop();
|
|
153
|
+
parts.pop();
|
|
154
|
+
// Ensure we always have at least one text part
|
|
155
|
+
if (parts.length === 0) parts.push({ type: "text", content: "" });
|
|
156
|
+
ensureTrailingText();
|
|
157
|
+
} else if (last.type === "paste") {
|
|
158
|
+
// Remove the paste block
|
|
159
|
+
parts.pop();
|
|
160
|
+
if (parts.length === 0) parts.push({ type: "text", content: "" });
|
|
161
|
+
ensureTrailingText();
|
|
162
|
+
}
|
|
163
|
+
redraw();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Raw mode ──
|
|
167
|
+
|
|
168
|
+
if (process.stdin.isTTY) {
|
|
169
|
+
process.stdin.setRawMode(true);
|
|
170
|
+
}
|
|
171
|
+
process.stdin.resume();
|
|
172
|
+
process.stdin.setEncoding("utf8");
|
|
173
|
+
process.stdout.write("\x1b[?2004h");
|
|
174
|
+
|
|
175
|
+
process.stdin.on("data", (chunk: string) => {
|
|
176
|
+
if (paused) return;
|
|
177
|
+
|
|
178
|
+
// ── Bracketed paste ──
|
|
179
|
+
if (chunk.includes(PASTE_START)) {
|
|
180
|
+
inPaste = true;
|
|
181
|
+
pasteAccum = chunk.split(PASTE_START).slice(1).join(PASTE_START);
|
|
182
|
+
if (pasteAccum.includes(PASTE_END)) {
|
|
183
|
+
inPaste = false;
|
|
184
|
+
handlePasteComplete(pasteAccum.split(PASTE_END)[0]!);
|
|
185
|
+
pasteAccum = "";
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (inPaste) {
|
|
190
|
+
if (chunk.includes(PASTE_END)) {
|
|
191
|
+
pasteAccum += chunk.split(PASTE_END)[0]!;
|
|
192
|
+
inPaste = false;
|
|
193
|
+
handlePasteComplete(pasteAccum);
|
|
194
|
+
pasteAccum = "";
|
|
195
|
+
} else {
|
|
196
|
+
pasteAccum += chunk;
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Normal input ──
|
|
202
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
203
|
+
const ch = chunk[i]!;
|
|
204
|
+
const code = chunk.charCodeAt(i);
|
|
205
|
+
|
|
206
|
+
if (code === 0x03) {
|
|
207
|
+
// Ctrl+C
|
|
208
|
+
process.stdout.write("\n\x1b[?2004l");
|
|
209
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (code === 0x15) {
|
|
214
|
+
// Ctrl+U
|
|
215
|
+
clear();
|
|
216
|
+
redraw();
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (code === 0x0d || code === 0x0a) {
|
|
221
|
+
// Enter
|
|
222
|
+
if (getFullText()) {
|
|
223
|
+
submit();
|
|
224
|
+
} else {
|
|
225
|
+
process.stdout.write("\n");
|
|
226
|
+
redraw();
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (code === 0x7f || code === 0x08) {
|
|
232
|
+
// Backspace
|
|
233
|
+
handleBackspace();
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (code === 0x1b) {
|
|
238
|
+
if (i + 1 < chunk.length && chunk[i + 1] === "[") {
|
|
239
|
+
// ANSI escape sequence (arrows, etc.) — skip
|
|
240
|
+
i += 2;
|
|
241
|
+
while (i < chunk.length && chunk.charCodeAt(i) < 0x40) i++;
|
|
242
|
+
} else if (pendingResolve) {
|
|
243
|
+
// Bare Escape during waitForInput — cancel
|
|
244
|
+
const resolve = pendingResolve;
|
|
245
|
+
pendingResolve = null;
|
|
246
|
+
clear();
|
|
247
|
+
process.stdout.write("\n");
|
|
248
|
+
resolve("");
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (code === 0x09) {
|
|
254
|
+
// Tab
|
|
255
|
+
ensureTrailingText().content += " ";
|
|
256
|
+
redraw();
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (code < 0x20) continue;
|
|
261
|
+
|
|
262
|
+
// Printable char
|
|
263
|
+
ensureTrailingText().content += ch;
|
|
264
|
+
redraw();
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
onLine(callback) {
|
|
270
|
+
lineCallback = callback;
|
|
271
|
+
},
|
|
272
|
+
prompt() {
|
|
273
|
+
paused = false;
|
|
274
|
+
prevRows = 1; // fresh prompt = 1 row
|
|
275
|
+
redraw();
|
|
276
|
+
},
|
|
277
|
+
waitForInput(): Promise<string> {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
pendingResolve = resolve;
|
|
280
|
+
paused = false;
|
|
281
|
+
prevRows = 1;
|
|
282
|
+
redraw();
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
pause() {
|
|
286
|
+
paused = true;
|
|
287
|
+
},
|
|
288
|
+
resume() {
|
|
289
|
+
paused = false;
|
|
290
|
+
},
|
|
291
|
+
close() {
|
|
292
|
+
process.stdout.write("\x1b[?2004l");
|
|
293
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
294
|
+
process.stdin.pause();
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal renderer — minimal, clean output.
|
|
3
|
+
*
|
|
4
|
+
* The renderer NEVER touches readline. It only writes to stdout.
|
|
5
|
+
* The caller (index.ts) is responsible for pausing/resuming readline.
|
|
6
|
+
* Spinner uses atomic \r overwrite — single write call, zero flicker.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import pc from "picocolors";
|
|
10
|
+
|
|
11
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type StatusBarInfo = {
|
|
14
|
+
model: string;
|
|
15
|
+
sessionName?: string;
|
|
16
|
+
turns: number;
|
|
17
|
+
inputTokens: number;
|
|
18
|
+
outputTokens: number;
|
|
19
|
+
cacheHitPct: number;
|
|
20
|
+
costUsd: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type Renderer = {
|
|
24
|
+
readonly cols: number;
|
|
25
|
+
writeln(text?: string): void;
|
|
26
|
+
writeSystem(text: string): void;
|
|
27
|
+
writeError(text: string): void;
|
|
28
|
+
renderAssistantMessage(text: string): void;
|
|
29
|
+
renderToolCall(toolName: string, input: Record<string, unknown>): void;
|
|
30
|
+
renderStatusLine(
|
|
31
|
+
durationMs: number,
|
|
32
|
+
tools: number,
|
|
33
|
+
info: StatusBarInfo,
|
|
34
|
+
): void;
|
|
35
|
+
startSpinner(label?: string): void;
|
|
36
|
+
updateSpinnerLabel(label: string): void;
|
|
37
|
+
stopSpinner(): void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
43
|
+
const HIDDEN_TOOLS = new Set(["TodoRead", "TodoWrite"]);
|
|
44
|
+
|
|
45
|
+
// ── Helpers (exported for testing) ───────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export function wrap(text: string, indent: number, maxWidth: number): string {
|
|
48
|
+
const width = maxWidth - indent;
|
|
49
|
+
if (width <= 20) return text;
|
|
50
|
+
const pad = " ".repeat(indent);
|
|
51
|
+
return text
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map((line) => {
|
|
54
|
+
if (line.length <= width) return pad + line;
|
|
55
|
+
const words = line.split(" ");
|
|
56
|
+
const wrapped: string[] = [];
|
|
57
|
+
let cur = "";
|
|
58
|
+
for (const w of words) {
|
|
59
|
+
if (cur.length + w.length + 1 > width && cur) {
|
|
60
|
+
wrapped.push(pad + cur);
|
|
61
|
+
cur = w;
|
|
62
|
+
} else {
|
|
63
|
+
cur = cur ? cur + " " + w : w;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (cur) wrapped.push(pad + cur);
|
|
67
|
+
return wrapped.join("\n");
|
|
68
|
+
})
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function formatTimeAgo(ts: number): string {
|
|
73
|
+
const sec = Math.floor((Date.now() - ts) / 1000);
|
|
74
|
+
if (sec < 60) return "just now";
|
|
75
|
+
const min = Math.floor(sec / 60);
|
|
76
|
+
if (min < 60) return `${min}m ago`;
|
|
77
|
+
const hr = Math.floor(min / 60);
|
|
78
|
+
if (hr < 24) return `${hr}h ago`;
|
|
79
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fmtTok(n: number): string {
|
|
83
|
+
if (n < 1000) return String(n);
|
|
84
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
85
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function extractToolDetail(
|
|
89
|
+
input: Record<string, unknown>,
|
|
90
|
+
maxLen: number,
|
|
91
|
+
): string {
|
|
92
|
+
if (input.command) {
|
|
93
|
+
const s = String(input.description || input.command);
|
|
94
|
+
return s.length > maxLen ? s.slice(0, maxLen - 3) + "..." : s;
|
|
95
|
+
}
|
|
96
|
+
if (input.file_path) return String(input.file_path);
|
|
97
|
+
if (input.pattern && input.path) return `${input.pattern} in ${input.path}`;
|
|
98
|
+
if (input.pattern) return String(input.pattern);
|
|
99
|
+
if (input.action) return String(input.action);
|
|
100
|
+
if (input.query) return String(input.query).slice(0, maxLen);
|
|
101
|
+
if (input.url) return String(input.url).slice(0, maxLen);
|
|
102
|
+
if (input.type) return String(input.type);
|
|
103
|
+
if (input.name) return String(input.name);
|
|
104
|
+
if (input.model) return String(input.model);
|
|
105
|
+
if (input.package_url) return String(input.package_url);
|
|
106
|
+
if (input.build_number) return `#${input.build_number}`;
|
|
107
|
+
if (input.packages) return (input.packages as string[]).join(", ");
|
|
108
|
+
const parts: string[] = [];
|
|
109
|
+
for (const [k, v] of Object.entries(input)) {
|
|
110
|
+
if (k === "_chatId") continue;
|
|
111
|
+
if (typeof v === "string" && v.length > 0)
|
|
112
|
+
parts.push(`${k}=${v.length > 30 ? v.slice(0, 30) + "..." : v}`);
|
|
113
|
+
else if (typeof v === "number" || typeof v === "boolean")
|
|
114
|
+
parts.push(`${k}=${v}`);
|
|
115
|
+
}
|
|
116
|
+
return parts.join(", ").slice(0, maxLen);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function cleanToolName(name: string): string {
|
|
120
|
+
if (name.startsWith("mcp__")) {
|
|
121
|
+
const parts = name.split("__");
|
|
122
|
+
return parts[parts.length - 1] || name;
|
|
123
|
+
}
|
|
124
|
+
return name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Factory ──────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export function createRenderer(cols?: number, displayName = "Talon"): Renderer {
|
|
130
|
+
const COLS = cols ?? Math.min(process.stdout.columns || 100, 120);
|
|
131
|
+
const botName = displayName;
|
|
132
|
+
|
|
133
|
+
let spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
134
|
+
let spinnerFrame = 0;
|
|
135
|
+
let spinnerLabel = "thinking";
|
|
136
|
+
let spinnerLineLen = 0;
|
|
137
|
+
let hasToolOutput = false;
|
|
138
|
+
|
|
139
|
+
// ── Output primitives ──
|
|
140
|
+
|
|
141
|
+
function writeln(text = ""): void {
|
|
142
|
+
process.stdout.write(`\x1b[2K\r${text}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function writeSystem(text: string): void {
|
|
146
|
+
writeln(` ${pc.dim(text)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeError(text: string): void {
|
|
150
|
+
writeln();
|
|
151
|
+
writeln(` ${pc.red("✖")} ${pc.red(text)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Messages ──
|
|
155
|
+
|
|
156
|
+
function renderAssistantMessage(text: string): void {
|
|
157
|
+
writeln();
|
|
158
|
+
writeln(` ${pc.cyan("▍")} ${pc.bold(pc.cyan(botName))}`);
|
|
159
|
+
for (const line of wrap(text, 2, COLS).split("\n")) {
|
|
160
|
+
writeln(` ${pc.cyan("▍")}${line}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderToolCall(
|
|
165
|
+
toolName: string,
|
|
166
|
+
input: Record<string, unknown>,
|
|
167
|
+
): void {
|
|
168
|
+
const clean = cleanToolName(toolName);
|
|
169
|
+
if (HIDDEN_TOOLS.has(clean)) return;
|
|
170
|
+
if (!hasToolOutput) {
|
|
171
|
+
hasToolOutput = true;
|
|
172
|
+
writeln();
|
|
173
|
+
}
|
|
174
|
+
const display = clean.replace(/_/g, " ");
|
|
175
|
+
const maxD = COLS - display.length - 16;
|
|
176
|
+
const detail = extractToolDetail(input, maxD);
|
|
177
|
+
writeln(
|
|
178
|
+
` ${pc.dim("→")} ${pc.yellow(display)}${detail ? ` ${pc.dim(detail)}` : ""}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderStatusLine(
|
|
183
|
+
ms: number,
|
|
184
|
+
tools: number,
|
|
185
|
+
info: StatusBarInfo,
|
|
186
|
+
): void {
|
|
187
|
+
const p = [
|
|
188
|
+
`${(ms / 1000).toFixed(1)}s`,
|
|
189
|
+
info.model,
|
|
190
|
+
];
|
|
191
|
+
if (info.sessionName) p.push(`"${info.sessionName}"`);
|
|
192
|
+
p.push(
|
|
193
|
+
`${info.turns} turn${info.turns !== 1 ? "s" : ""}`,
|
|
194
|
+
`${fmtTok(info.inputTokens + info.outputTokens)} tok`,
|
|
195
|
+
`${info.cacheHitPct}% cache`,
|
|
196
|
+
);
|
|
197
|
+
if (tools > 0) p.push(`${tools} tool${tools > 1 ? "s" : ""}`);
|
|
198
|
+
writeln();
|
|
199
|
+
writeln(` ${pc.dim(p.join(" · "))}`);
|
|
200
|
+
hasToolOutput = false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Spinner ──
|
|
204
|
+
// Pure stdout. Never touches readline.
|
|
205
|
+
|
|
206
|
+
function startSpinner(label = "thinking"): void {
|
|
207
|
+
stopSpinner();
|
|
208
|
+
spinnerLabel = label;
|
|
209
|
+
spinnerFrame = 0;
|
|
210
|
+
spinnerLineLen = 0;
|
|
211
|
+
spinnerTimer = setInterval(() => {
|
|
212
|
+
spinnerFrame = (spinnerFrame + 1) % FRAMES.length;
|
|
213
|
+
const line = ` ${pc.dim(FRAMES[spinnerFrame]!)} ${pc.dim(spinnerLabel)}`;
|
|
214
|
+
const pad =
|
|
215
|
+
spinnerLineLen > line.length
|
|
216
|
+
? " ".repeat(spinnerLineLen - line.length)
|
|
217
|
+
: "";
|
|
218
|
+
spinnerLineLen = line.length;
|
|
219
|
+
process.stdout.write(`\r${line}${pad}`);
|
|
220
|
+
}, 80);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function updateSpinnerLabel(label: string): void {
|
|
224
|
+
spinnerLabel = label;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function stopSpinner(): void {
|
|
228
|
+
if (spinnerTimer) {
|
|
229
|
+
clearInterval(spinnerTimer);
|
|
230
|
+
spinnerTimer = null;
|
|
231
|
+
process.stdout.write("\x1b[2K\r");
|
|
232
|
+
spinnerLineLen = 0;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
cols: COLS,
|
|
238
|
+
writeln,
|
|
239
|
+
writeSystem,
|
|
240
|
+
writeError,
|
|
241
|
+
renderAssistantMessage,
|
|
242
|
+
renderToolCall,
|
|
243
|
+
renderStatusLine,
|
|
244
|
+
startSpinner,
|
|
245
|
+
updateSpinnerLabel,
|
|
246
|
+
stopSpinner,
|
|
247
|
+
};
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Talon — agentic AI harness.
|
|
3
|
+
* Composition root: loads config, creates frontend + backend, wires dispatcher.
|
|
4
|
+
*
|
|
5
|
+
* Frontends (Telegram, Terminal) and backends (Claude, OpenCode)
|
|
6
|
+
* are loaded dynamically — only the selected platform's dependencies are required.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getFrontends } from "./util/config.js";
|
|
10
|
+
import { startUploadCleanup, stopUploadCleanup } from "./util/workspace.js";
|
|
11
|
+
import { flushSessions } from "./storage/sessions.js";
|
|
12
|
+
import { flushChatSettings } from "./storage/chat-settings.js";
|
|
13
|
+
import { flushCronJobs } from "./storage/cron-store.js";
|
|
14
|
+
import { flushHistory } from "./storage/history.js";
|
|
15
|
+
import { flushMediaIndex } from "./storage/media-index.js";
|
|
16
|
+
import { getActiveCount } from "./core/dispatcher.js";
|
|
17
|
+
import { startPulseTimer, stopPulseTimer } from "./core/pulse.js";
|
|
18
|
+
import { startCronTimer, stopCronTimer } from "./core/cron.js";
|
|
19
|
+
import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
|
|
20
|
+
import { log, logError, logWarn } from "./util/log.js";
|
|
21
|
+
import { bootstrap, initBackendAndDispatcher } from "./bootstrap.js";
|
|
22
|
+
import { Gateway } from "./core/gateway.js";
|
|
23
|
+
import type { Frontend } from "./bootstrap.js";
|
|
24
|
+
|
|
25
|
+
// ── Bootstrap ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
import { writeFileSync, unlinkSync } from "node:fs";
|
|
28
|
+
import { files as pathFiles } from "./util/paths.js";
|
|
29
|
+
|
|
30
|
+
const { config } = await bootstrap();
|
|
31
|
+
|
|
32
|
+
// Write PID file for daemon management
|
|
33
|
+
try { writeFileSync(pathFiles.pid, String(process.pid)); } catch { /* ok */ }
|
|
34
|
+
|
|
35
|
+
// ── Create gateway + frontend ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const gateway = new Gateway();
|
|
38
|
+
|
|
39
|
+
const selectedFrontend = getFrontends(config)[0]; // use first configured frontend
|
|
40
|
+
let frontend: Frontend;
|
|
41
|
+
|
|
42
|
+
if (selectedFrontend === "terminal") {
|
|
43
|
+
const { createTerminalFrontend } = await import("./frontend/terminal/index.js");
|
|
44
|
+
frontend = createTerminalFrontend(config, gateway);
|
|
45
|
+
log("bot", "Frontend: Terminal");
|
|
46
|
+
} else if (selectedFrontend === "teams") {
|
|
47
|
+
const { createTeamsFrontend } = await import("./frontend/teams/index.js");
|
|
48
|
+
frontend = createTeamsFrontend(config, gateway);
|
|
49
|
+
log("bot", "Frontend: Teams");
|
|
50
|
+
} else {
|
|
51
|
+
const { createTelegramFrontend } = await import("./frontend/telegram/index.js");
|
|
52
|
+
frontend = createTelegramFrontend(config, gateway);
|
|
53
|
+
log("bot", "Frontend: Telegram");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Create backend + wire dispatcher ─────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
await initBackendAndDispatcher(config, frontend);
|
|
59
|
+
|
|
60
|
+
// ── Graceful shutdown ────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
let shuttingDown = false;
|
|
63
|
+
|
|
64
|
+
const SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
65
|
+
|
|
66
|
+
async function gracefulShutdown(signal: string): Promise<void> {
|
|
67
|
+
if (shuttingDown) return;
|
|
68
|
+
shuttingDown = true;
|
|
69
|
+
log("shutdown", `${signal} received, shutting down gracefully...`);
|
|
70
|
+
|
|
71
|
+
const forceTimer = setTimeout(() => {
|
|
72
|
+
logError("shutdown", "Timeout exceeded, forcing exit");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
75
|
+
forceTimer.unref();
|
|
76
|
+
|
|
77
|
+
const pending = getActiveCount();
|
|
78
|
+
if (pending > 0) {
|
|
79
|
+
log("shutdown", `Waiting for ${pending} in-flight queries to drain...`);
|
|
80
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await frontend.stop();
|
|
84
|
+
if (config.backend === "opencode") {
|
|
85
|
+
const { stopOpenCodeServer } = await import("./backend/opencode/index.js");
|
|
86
|
+
stopOpenCodeServer();
|
|
87
|
+
}
|
|
88
|
+
// Destroy plugins (cleanup resources)
|
|
89
|
+
if (config.plugins.length > 0) {
|
|
90
|
+
const { destroyPlugins } = await import("./core/plugin.js");
|
|
91
|
+
await destroyPlugins();
|
|
92
|
+
}
|
|
93
|
+
stopPulseTimer();
|
|
94
|
+
stopCronTimer();
|
|
95
|
+
stopWatchdog();
|
|
96
|
+
stopUploadCleanup();
|
|
97
|
+
flushSessions();
|
|
98
|
+
flushChatSettings();
|
|
99
|
+
flushCronJobs();
|
|
100
|
+
flushHistory();
|
|
101
|
+
flushMediaIndex();
|
|
102
|
+
try { unlinkSync(pathFiles.pid); } catch { /* ok */ }
|
|
103
|
+
log("shutdown", "State saved");
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
108
|
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
109
|
+
|
|
110
|
+
process.on("uncaughtException", (err) => {
|
|
111
|
+
logError("bot", "Uncaught exception", err);
|
|
112
|
+
flushSessions();
|
|
113
|
+
flushChatSettings();
|
|
114
|
+
flushCronJobs();
|
|
115
|
+
flushHistory();
|
|
116
|
+
flushMediaIndex();
|
|
117
|
+
process.exit(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
process.on("unhandledRejection", (reason) => {
|
|
121
|
+
logWarn(
|
|
122
|
+
"bot",
|
|
123
|
+
`Unhandled rejection: ${reason instanceof Error ? reason.message : reason}`,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── Start ────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
async function main(): Promise<void> {
|
|
130
|
+
await frontend.init();
|
|
131
|
+
log("bot", "Starting Talon...");
|
|
132
|
+
|
|
133
|
+
if (config.pulse) startPulseTimer(config.pulseIntervalMs);
|
|
134
|
+
startCronTimer();
|
|
135
|
+
startWatchdog(config.workspace);
|
|
136
|
+
startUploadCleanup(config.workspace);
|
|
137
|
+
|
|
138
|
+
await frontend.start();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main().catch((err) => {
|
|
142
|
+
logError("bot", "Fatal startup error", err);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|