talon-agent 3.33.4 → 3.34.1
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/package.json +5 -2
- package/src/app.ts +16 -8
- package/src/backend/claude-sdk/handler.ts +428 -415
- package/src/backend/claude-sdk/stream.ts +76 -53
- package/src/backend/codex/handler/message.ts +275 -366
- package/src/backend/codex/handler/rollout-accounting.ts +137 -0
- package/src/backend/openai-agents/handler/message.ts +282 -356
- package/src/backend/remote-server/chat-turn.ts +66 -122
- package/src/backend/remote-server/index.ts +4 -0
- package/src/backend/remote-server/mcp.ts +73 -9
- package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
- package/src/backend/remote-server/sessions.ts +2 -2
- package/src/backend/remote-server/turn.ts +58 -55
- package/src/backend/shared/cache-telemetry.ts +17 -1
- package/src/backend/shared/handler-to-events.ts +11 -16
- package/src/backend/shared/index.ts +14 -15
- package/src/backend/shared/result-events.ts +30 -0
- package/src/backend/shared/turn-phases.ts +277 -0
- package/src/bootstrap.ts +120 -79
- package/src/cli/setup.ts +375 -349
- package/src/core/background/cron-spec.ts +273 -0
- package/src/core/background/heartbeat/agent.ts +167 -104
- package/src/core/background/triggers/exit.ts +19 -2
- package/src/core/background/triggers/resume.ts +14 -7
- package/src/core/background/triggers/state.ts +9 -0
- package/src/core/engine/backend-controller/pool.ts +2 -0
- package/src/core/engine/backend-controller/state.ts +25 -12
- package/src/core/engine/gateway-actions/cron.ts +28 -292
- package/src/core/engine/gateway-routes.ts +239 -0
- package/src/core/engine/gateway.ts +66 -238
- package/src/core/mcp-hub/child-transport.ts +215 -0
- package/src/core/mcp-hub/children.ts +72 -13
- package/src/core/mcp-hub/index.ts +32 -13
- package/src/core/models/active-model.ts +29 -6
- package/src/core/vfs/mounts/files.ts +128 -115
- package/src/core/weaver/shuttle.ts +33 -2
- package/src/core/weaver/weaver.ts +37 -6
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
- package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
- package/src/frontend/discord/callbacks/components/effort.ts +72 -0
- package/src/frontend/discord/callbacks/components/index.ts +120 -0
- package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
- package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
- package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
- package/src/frontend/discord/callbacks/components/model.ts +33 -0
- package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
- package/src/frontend/discord/callbacks/components/settings.ts +243 -0
- package/src/frontend/discord/callbacks/components/types.ts +34 -0
- package/src/frontend/discord/callbacks/index.ts +4 -4
- package/src/frontend/discord/connection.ts +36 -0
- package/src/frontend/discord/diagnostics.ts +62 -0
- package/src/frontend/discord/guild-policy.ts +89 -0
- package/src/frontend/discord/index.ts +44 -305
- package/src/frontend/discord/outbound.ts +61 -0
- package/src/frontend/discord/ready.ts +73 -0
- package/src/frontend/discord/runtime.ts +55 -0
- package/src/frontend/native/chat-lifecycle.ts +39 -0
- package/src/frontend/native/chat-wire.ts +69 -0
- package/src/frontend/native/context.ts +106 -0
- package/src/frontend/native/control.ts +78 -0
- package/src/frontend/native/emit.ts +167 -0
- package/src/frontend/native/empty-chat-sweep.ts +50 -0
- package/src/frontend/native/handlers.ts +121 -0
- package/src/frontend/native/history.ts +101 -0
- package/src/frontend/native/index.ts +90 -1293
- package/src/frontend/native/media.ts +43 -0
- package/src/frontend/native/models.ts +221 -0
- package/src/frontend/native/queue.ts +47 -0
- package/src/frontend/native/reset.ts +50 -0
- package/src/frontend/native/routes/chats.ts +115 -0
- package/src/frontend/native/routes/daemon.ts +70 -0
- package/src/frontend/native/routes/host.ts +151 -0
- package/src/frontend/native/routes/index.ts +22 -0
- package/src/frontend/native/routes/mesh.ts +72 -0
- package/src/frontend/native/routes/models.ts +54 -0
- package/src/frontend/native/routes/params.ts +29 -0
- package/src/frontend/native/routes/pre-auth.ts +94 -0
- package/src/frontend/native/routes/table.ts +92 -0
- package/src/frontend/native/runtime.ts +109 -0
- package/src/frontend/native/server.ts +30 -555
- package/src/frontend/native/status.ts +26 -0
- package/src/frontend/native/tool-result.ts +48 -0
- package/src/frontend/native/turn.ts +341 -0
- package/src/frontend/telegram/admin.ts +75 -52
- package/src/frontend/whatsapp/access.ts +67 -0
- package/src/frontend/whatsapp/connection.ts +280 -0
- package/src/frontend/whatsapp/inbound.ts +327 -0
- package/src/frontend/whatsapp/index.ts +28 -599
- package/src/frontend/whatsapp/runtime.ts +74 -0
- package/src/storage/metrics.ts +18 -0
- package/src/storage/session-record.ts +29 -0
- package/src/storage/sessions.ts +24 -0
- package/src/storage/trigger-store.ts +8 -0
- package/src/util/boot-timer.ts +31 -0
- package/src/util/concurrency.ts +28 -0
- package/src/util/watchdog.ts +32 -7
- package/src/frontend/discord/callbacks/components.ts +0 -793
- package/src/frontend/discord/callbacks/shared.ts +0 -22
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-result summarisation — the bounded, readable string the app shows in
|
|
3
|
+
* its expanded tool view (and the turn-meta sidecar persists).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Best-effort JSON stringify that never throws (circular refs → String()). */
|
|
7
|
+
function safeJson(value: unknown): string {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.stringify(value, null, 2);
|
|
10
|
+
} catch {
|
|
11
|
+
return String(value);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reduce a tool's raw result to a readable, bounded string for the app's
|
|
17
|
+
* expanded tool view. MCP results are typically
|
|
18
|
+
* `{ content: [{ type: "text", text }] }`, so pull the text parts out; fall
|
|
19
|
+
* back to pretty JSON for anything else. Truncated so a huge file read or
|
|
20
|
+
* search dump can't bloat the wire payload or the history sidecar.
|
|
21
|
+
*/
|
|
22
|
+
export function summarizeToolResult(result: unknown): string | undefined {
|
|
23
|
+
if (result == null) return undefined;
|
|
24
|
+
let text: string;
|
|
25
|
+
if (typeof result === "string") {
|
|
26
|
+
text = result;
|
|
27
|
+
} else {
|
|
28
|
+
const content = (result as { content?: unknown }).content;
|
|
29
|
+
if (Array.isArray(content)) {
|
|
30
|
+
const parts = content
|
|
31
|
+
.map((c) =>
|
|
32
|
+
c &&
|
|
33
|
+
typeof c === "object" &&
|
|
34
|
+
typeof (c as { text?: unknown }).text === "string"
|
|
35
|
+
? (c as { text: string }).text
|
|
36
|
+
: "",
|
|
37
|
+
)
|
|
38
|
+
.filter((s) => s.length > 0);
|
|
39
|
+
text = parts.length > 0 ? parts.join("\n") : safeJson(result);
|
|
40
|
+
} else {
|
|
41
|
+
text = safeJson(result);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
text = text.trim();
|
|
45
|
+
if (!text) return undefined;
|
|
46
|
+
const MAX = 4000;
|
|
47
|
+
return text.length > MAX ? `${text.slice(0, MAX)}\n… (truncated)` : text;
|
|
48
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user turn — drive `dispatcher.execute()` and forward the canonical
|
|
3
|
+
* `AgentEvent` stream to clients as Bridge events: reasoning + tool activity
|
|
4
|
+
* stream live, while the persisted reply arrives via the gateway action
|
|
5
|
+
* handler (tool-only backends) or the trailing-prose fallback (text-mode
|
|
6
|
+
* backends) — mirroring the terminal renderer's delivery semantics.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExecuteResult } from "../../core/types.js";
|
|
10
|
+
import type { AgentEvent } from "../../core/agent-runtime/events.js";
|
|
11
|
+
import { execute } from "../../core/engine/dispatcher.js";
|
|
12
|
+
import { toolInputToRecord } from "../../core/agent-runtime/events.js";
|
|
13
|
+
import { getBackendForChat } from "../../core/engine/backend-controller/index.js";
|
|
14
|
+
import { isDeliveryTool } from "../../core/tools/index.js";
|
|
15
|
+
import { recordTurnMeta } from "./turn-meta.js";
|
|
16
|
+
import type { ChatEntry } from "./chats.js";
|
|
17
|
+
import { refreshContext } from "./context.js";
|
|
18
|
+
import { emitAssistant, emitUser } from "./emit.js";
|
|
19
|
+
import type { BridgeEvent } from "./protocol.js";
|
|
20
|
+
import { takeQueued } from "./queue.js";
|
|
21
|
+
import type { LiveToolEntry, NativeRuntime } from "./runtime.js";
|
|
22
|
+
import { summarizeToolResult } from "./tool-result.js";
|
|
23
|
+
|
|
24
|
+
/** The tool calls of one running turn, in call order. */
|
|
25
|
+
type TurnTools = Map<string, LiveToolEntry>;
|
|
26
|
+
|
|
27
|
+
export type StartTurnOptions = { imagePath?: string; attachmentPath?: string };
|
|
28
|
+
|
|
29
|
+
/** True when a turn is currently running for a chat (used to decide queue). */
|
|
30
|
+
export function isBusy(runtime: NativeRuntime, chatId: string): boolean {
|
|
31
|
+
return runtime.liveTurns.has(chatId);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Events that reconstruct every in-progress turn for a freshly-connected
|
|
36
|
+
* client: a turn_start (so the live turn renders), a typing indicator, and
|
|
37
|
+
* each tool's call — plus its result when it has already finished. Replayed
|
|
38
|
+
* right after `hello` so a reconnect mid-turn shows the full tool timeline
|
|
39
|
+
* immediately rather than only the tools that happen to fire afterwards.
|
|
40
|
+
*/
|
|
41
|
+
export function liveTurnEvents(runtime: NativeRuntime): BridgeEvent[] {
|
|
42
|
+
const events: BridgeEvent[] = [];
|
|
43
|
+
for (const [chatId, tools] of runtime.liveTurns) {
|
|
44
|
+
events.push({ kind: "turn_start", chatId });
|
|
45
|
+
events.push({ kind: "typing", chatId, on: true });
|
|
46
|
+
for (const { call, done } of tools.values()) {
|
|
47
|
+
events.push({
|
|
48
|
+
kind: "tool",
|
|
49
|
+
chatId,
|
|
50
|
+
id: call.id,
|
|
51
|
+
name: call.name,
|
|
52
|
+
phase: "call",
|
|
53
|
+
...(call.input ? { input: call.input } : {}),
|
|
54
|
+
});
|
|
55
|
+
if (done) {
|
|
56
|
+
events.push({
|
|
57
|
+
kind: "tool",
|
|
58
|
+
chatId,
|
|
59
|
+
id: call.id,
|
|
60
|
+
name: call.name,
|
|
61
|
+
phase: "result",
|
|
62
|
+
...(call.error ? { error: call.error } : {}),
|
|
63
|
+
...(call.output ? { output: call.output } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return events;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Safety net: any tool the backend announced but never resolved (a crash can
|
|
73
|
+
* eat a tool_result; callback backends historically never emitted one —
|
|
74
|
+
* handler-to-events now pairs each tool_call with an immediate synthetic
|
|
75
|
+
* result) gets a synthetic result at turn end — a spinner the app opened on
|
|
76
|
+
* phase:"call" must always see a phase:"result".
|
|
77
|
+
*/
|
|
78
|
+
function flushOpenTools(
|
|
79
|
+
runtime: NativeRuntime,
|
|
80
|
+
entry: ChatEntry,
|
|
81
|
+
turnTools: TurnTools,
|
|
82
|
+
): void {
|
|
83
|
+
for (const [id, live] of turnTools) {
|
|
84
|
+
if (live.done) continue;
|
|
85
|
+
live.done = true;
|
|
86
|
+
live.call.durationMs = Date.now() - live.startedAt;
|
|
87
|
+
runtime.broadcast({
|
|
88
|
+
kind: "tool",
|
|
89
|
+
chatId: entry.id,
|
|
90
|
+
id,
|
|
91
|
+
name: live.call.name,
|
|
92
|
+
phase: "result",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Forward one agent event to clients, recording tool activity as it goes. */
|
|
98
|
+
function forwardAgentEvent(
|
|
99
|
+
runtime: NativeRuntime,
|
|
100
|
+
entry: ChatEntry,
|
|
101
|
+
turnTools: TurnTools,
|
|
102
|
+
event: AgentEvent,
|
|
103
|
+
): void {
|
|
104
|
+
switch (event.type) {
|
|
105
|
+
case "reasoning":
|
|
106
|
+
if (event.text)
|
|
107
|
+
runtime.broadcast({
|
|
108
|
+
kind: "reasoning",
|
|
109
|
+
chatId: entry.id,
|
|
110
|
+
text: event.text,
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
case "text_delta":
|
|
114
|
+
runtime.broadcast({ kind: "delta", chatId: entry.id, text: event.text });
|
|
115
|
+
break;
|
|
116
|
+
case "tool_call": {
|
|
117
|
+
// Delivery plumbing (end_turn / send_message / react) never
|
|
118
|
+
// enters the tool timeline — its effect arrives as the
|
|
119
|
+
// `message`/reaction itself. Skipping here keeps the live
|
|
120
|
+
// stream, the mid-turn replay to late joiners, and the
|
|
121
|
+
// persisted turn meta consistent from one choke point.
|
|
122
|
+
if (isDeliveryTool(event.name)) break;
|
|
123
|
+
const input = toolInputToRecord(event.name, event.input);
|
|
124
|
+
turnTools.set(event.id, {
|
|
125
|
+
call: { id: event.id, name: event.name, input },
|
|
126
|
+
startedAt: Date.now(),
|
|
127
|
+
});
|
|
128
|
+
runtime.broadcast({
|
|
129
|
+
kind: "tool",
|
|
130
|
+
chatId: entry.id,
|
|
131
|
+
id: event.id,
|
|
132
|
+
name: event.name,
|
|
133
|
+
phase: "call",
|
|
134
|
+
input,
|
|
135
|
+
});
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case "tool_result": {
|
|
139
|
+
if (isDeliveryTool(event.name)) break;
|
|
140
|
+
const output = summarizeToolResult(event.result);
|
|
141
|
+
const live = turnTools.get(event.id);
|
|
142
|
+
if (live) {
|
|
143
|
+
live.done = true;
|
|
144
|
+
live.call.durationMs = Date.now() - live.startedAt;
|
|
145
|
+
if (event.error) live.call.error = event.error;
|
|
146
|
+
if (output) live.call.output = output;
|
|
147
|
+
}
|
|
148
|
+
runtime.broadcast({
|
|
149
|
+
kind: "tool",
|
|
150
|
+
chatId: entry.id,
|
|
151
|
+
id: event.id,
|
|
152
|
+
name: event.name,
|
|
153
|
+
phase: "result",
|
|
154
|
+
...(event.error ? { error: event.error } : {}),
|
|
155
|
+
...(output ? { output } : {}),
|
|
156
|
+
});
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case "error":
|
|
160
|
+
runtime.broadcast({
|
|
161
|
+
kind: "error",
|
|
162
|
+
chatId: entry.id,
|
|
163
|
+
message: event.error.message,
|
|
164
|
+
});
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Settle a completed turn: deliver trailing prose, persist meta, close it. */
|
|
170
|
+
function finishTurn(
|
|
171
|
+
runtime: NativeRuntime,
|
|
172
|
+
entry: ChatEntry,
|
|
173
|
+
turnTools: TurnTools,
|
|
174
|
+
result: ExecuteResult,
|
|
175
|
+
): void {
|
|
176
|
+
// Trailing-prose fallback: text-mode backends (kilo/opencode/codex)
|
|
177
|
+
// deliver the reply as plain text rather than a bridge send. When no
|
|
178
|
+
// delivery tool fired this turn, surface result.text as the message —
|
|
179
|
+
// exactly what the terminal renderer does.
|
|
180
|
+
let delivered = result.bridgeMessageCount;
|
|
181
|
+
if (delivered === 0 && result.text.trim()) {
|
|
182
|
+
emitAssistant(runtime, entry, result.text.trim());
|
|
183
|
+
delivered = 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Resolve open tool spinners BEFORE persisting turn meta, so the
|
|
187
|
+
// synthetic durations land in the recorded timeline too.
|
|
188
|
+
flushOpenTools(runtime, entry, turnTools);
|
|
189
|
+
|
|
190
|
+
// Persist what this turn did against its final assistant message, so
|
|
191
|
+
// reloaded history keeps the tool timeline + stats footer. Only when
|
|
192
|
+
// the turn actually delivered — otherwise the "last assistant id"
|
|
193
|
+
// belongs to a previous turn and the meta would land on the wrong row.
|
|
194
|
+
if (delivered > 0) {
|
|
195
|
+
const msgId = runtime.lastAssistantId.get(entry.id);
|
|
196
|
+
if (msgId) {
|
|
197
|
+
const tools = [...turnTools.values()].map((t) => t.call);
|
|
198
|
+
recordTurnMeta(entry.id, msgId, {
|
|
199
|
+
durationMs: result.durationMs,
|
|
200
|
+
tokensIn: result.inputTokens,
|
|
201
|
+
tokensOut: result.outputTokens,
|
|
202
|
+
...(tools.length ? { tools } : {}),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
runtime.broadcast({ kind: "typing", chatId: entry.id, on: false });
|
|
208
|
+
runtime.broadcast({
|
|
209
|
+
kind: "turn_end",
|
|
210
|
+
chatId: entry.id,
|
|
211
|
+
delivered,
|
|
212
|
+
durationMs: result.durationMs,
|
|
213
|
+
usage: { input: result.inputTokens, output: result.outputTokens },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Refresh the live context-window readout now the turn's usage has
|
|
217
|
+
// settled, and push it to clients via chat_updated. Best-effort — a
|
|
218
|
+
// failure here must never surface as a turn error.
|
|
219
|
+
void refreshContext(runtime, entry).catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Close a turn that threw: flush spinners, surface the error, end it. */
|
|
223
|
+
function failTurn(
|
|
224
|
+
runtime: NativeRuntime,
|
|
225
|
+
entry: ChatEntry,
|
|
226
|
+
turnTools: TurnTools,
|
|
227
|
+
err: unknown,
|
|
228
|
+
start: number,
|
|
229
|
+
): void {
|
|
230
|
+
flushOpenTools(runtime, entry, turnTools);
|
|
231
|
+
runtime.broadcast({ kind: "typing", chatId: entry.id, on: false });
|
|
232
|
+
runtime.broadcast({
|
|
233
|
+
kind: "error",
|
|
234
|
+
chatId: entry.id,
|
|
235
|
+
message: err instanceof Error ? err.message : String(err),
|
|
236
|
+
});
|
|
237
|
+
runtime.broadcast({
|
|
238
|
+
kind: "turn_end",
|
|
239
|
+
chatId: entry.id,
|
|
240
|
+
delivered: 0,
|
|
241
|
+
durationMs: Date.now() - start,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function runTurn(
|
|
246
|
+
runtime: NativeRuntime,
|
|
247
|
+
entry: ChatEntry,
|
|
248
|
+
text: string,
|
|
249
|
+
messageId: number,
|
|
250
|
+
attachmentPath?: string,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
const start = Date.now();
|
|
253
|
+
// Tool calls observed during this turn, in call order — recorded into the
|
|
254
|
+
// turn-meta sidecar at turn_end so history can replay the timeline. Also
|
|
255
|
+
// registered in `liveTurns` so a client connecting mid-turn can be replayed
|
|
256
|
+
// the activity so far (cleared in the `finally`).
|
|
257
|
+
const turnTools: TurnTools = new Map();
|
|
258
|
+
runtime.liveTurns.set(entry.id, turnTools);
|
|
259
|
+
// Point the model at an attached image so it can read the file itself.
|
|
260
|
+
const prompt = attachmentPath
|
|
261
|
+
? `${text ? `${text}\n\n` : ""}[Attached image: ${attachmentPath}]`
|
|
262
|
+
: text;
|
|
263
|
+
try {
|
|
264
|
+
const result = await execute({
|
|
265
|
+
chatId: entry.id,
|
|
266
|
+
numericChatId: entry.numericId,
|
|
267
|
+
prompt,
|
|
268
|
+
senderName: "User",
|
|
269
|
+
isGroup: false,
|
|
270
|
+
// Give the model the user message's id so `[msg_id:N]` is present and
|
|
271
|
+
// react/reply/edit target the user's message — not the bot's.
|
|
272
|
+
messageId,
|
|
273
|
+
source: "message",
|
|
274
|
+
onEvent: async (event) =>
|
|
275
|
+
forwardAgentEvent(runtime, entry, turnTools, event),
|
|
276
|
+
});
|
|
277
|
+
finishTurn(runtime, entry, turnTools, result);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
failTurn(runtime, entry, turnTools, err, start);
|
|
280
|
+
} finally {
|
|
281
|
+
// The turn is over (delivered or errored) — stop replaying it to new
|
|
282
|
+
// clients. Any late tool spinner has already been flushed above.
|
|
283
|
+
runtime.liveTurns.delete(entry.id);
|
|
284
|
+
// Flush a queued follow-up as a fresh turn: "once it's done it will
|
|
285
|
+
// send". Deferred so we don't re-enter runTurn inside its own finally.
|
|
286
|
+
const queued = takeQueued(runtime, entry);
|
|
287
|
+
if (queued) {
|
|
288
|
+
setImmediate(() =>
|
|
289
|
+
startTurn(runtime, entry, queued.text, {
|
|
290
|
+
imagePath: queued.imagePath,
|
|
291
|
+
attachmentPath: queued.attachmentPath,
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Emit the user message, open the turn, and drive it. Shared by the /send
|
|
299
|
+
* handler and the queued-follow-up flush. */
|
|
300
|
+
export function startTurn(
|
|
301
|
+
runtime: NativeRuntime,
|
|
302
|
+
entry: ChatEntry,
|
|
303
|
+
text: string,
|
|
304
|
+
options?: StartTurnOptions,
|
|
305
|
+
): void {
|
|
306
|
+
const messageId = emitUser(
|
|
307
|
+
runtime,
|
|
308
|
+
entry,
|
|
309
|
+
text,
|
|
310
|
+
options?.imagePath,
|
|
311
|
+
options?.attachmentPath,
|
|
312
|
+
);
|
|
313
|
+
runtime.broadcast({ kind: "turn_start", chatId: entry.id });
|
|
314
|
+
runtime.broadcast({ kind: "typing", chatId: entry.id, on: true });
|
|
315
|
+
void runTurn(runtime, entry, text, messageId, options?.attachmentPath);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Best-effort interrupt of a chat's in-flight turn. `true` if one was
|
|
319
|
+
* running and got signalled. */
|
|
320
|
+
export async function interruptTurn(
|
|
321
|
+
runtime: NativeRuntime,
|
|
322
|
+
chatId: string,
|
|
323
|
+
): Promise<boolean> {
|
|
324
|
+
const entry = runtime.chats.get(chatId);
|
|
325
|
+
if (!entry) return false;
|
|
326
|
+
// Only meaningful while a turn is actually running.
|
|
327
|
+
if (!isBusy(runtime, chatId)) return false;
|
|
328
|
+
let backend = null;
|
|
329
|
+
try {
|
|
330
|
+
backend = getBackendForChat(chatId);
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const interrupt = backend?.chat?.interruptChatTurn;
|
|
335
|
+
if (!interrupt) return false;
|
|
336
|
+
try {
|
|
337
|
+
return await interrupt(chatId);
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
|
|
|
7
7
|
import type { TalonConfig } from "../../util/config.js";
|
|
8
8
|
import { files, dirs } from "../../util/paths.js";
|
|
9
9
|
import { tailFile } from "../../util/tail-file.js";
|
|
10
|
-
import { escapeHtml } from "./formatting.js";
|
|
10
|
+
import { escapeHtml, splitMessage } from "./formatting.js";
|
|
11
|
+
import { TELEGRAM_MAX_TEXT } from "./actions/types.js";
|
|
11
12
|
import { resetSession, getAllSessions } from "../../storage/sessions.js";
|
|
12
13
|
import { clearHistory } from "../../storage/history.js";
|
|
13
14
|
import { todayLogDate } from "../../storage/daily-log.js";
|
|
@@ -22,6 +23,72 @@ import { getPulseStatus } from "../../core/background/pulse.js";
|
|
|
22
23
|
import { getHealthStatus, getRecentErrors } from "../../util/watchdog.js";
|
|
23
24
|
import { formatDuration, formatModelLabel } from "./helpers/index.js";
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Reply with an HTML listing, split across messages when it outgrows
|
|
28
|
+
* Telegram's 4096-char cap. The per-item listings below (chats, cron,
|
|
29
|
+
* pulse) scale with the daemon's chat count, and a single oversized
|
|
30
|
+
* `ctx.reply` fails the whole command with 400 "message is too long".
|
|
31
|
+
* Entries are `\n\n`-separated and each carries balanced tags, so the
|
|
32
|
+
* paragraph-first splitter never cuts through markup.
|
|
33
|
+
*/
|
|
34
|
+
async function replyHtmlChunked(ctx: Context, text: string): Promise<void> {
|
|
35
|
+
for (const chunk of splitMessage(text, TELEGRAM_MAX_TEXT)) {
|
|
36
|
+
await ctx.reply(chunk, { parse_mode: "HTML" });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `/admin chats` — every active session, newest first, titled via getChat. */
|
|
41
|
+
async function replyActiveChats(
|
|
42
|
+
ctx: Context,
|
|
43
|
+
bot: Bot,
|
|
44
|
+
config: TalonConfig,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const sessions = getAllSessions();
|
|
47
|
+
if (sessions.length === 0) {
|
|
48
|
+
await ctx.reply("No active sessions.");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
sessions.sort((a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0));
|
|
52
|
+
|
|
53
|
+
const titles = new Map<string, string>();
|
|
54
|
+
await Promise.all(
|
|
55
|
+
sessions.map(async (s) => {
|
|
56
|
+
try {
|
|
57
|
+
const id = parseInt(s.chatId, 10);
|
|
58
|
+
if (isNaN(id)) return;
|
|
59
|
+
const chat = await bot.api.getChat(id);
|
|
60
|
+
titles.set(
|
|
61
|
+
s.chatId,
|
|
62
|
+
"title" in chat
|
|
63
|
+
? (chat.title ?? "DM")
|
|
64
|
+
: "first_name" in chat
|
|
65
|
+
? (chat.first_name ?? "DM")
|
|
66
|
+
: "DM",
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
/* inaccessible */
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const lines = sessions.map((s) => {
|
|
75
|
+
const age = s.info.lastActive
|
|
76
|
+
? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
|
|
77
|
+
: "?";
|
|
78
|
+
const title = titles.get(s.chatId) ?? s.chatId;
|
|
79
|
+
const model = formatModelLabel(
|
|
80
|
+
getChatSettings(s.chatId).model ?? config.model,
|
|
81
|
+
);
|
|
82
|
+
// `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
|
|
83
|
+
// it gets the same escaping the title already had.
|
|
84
|
+
return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
|
|
85
|
+
});
|
|
86
|
+
await replyHtmlChunked(
|
|
87
|
+
ctx,
|
|
88
|
+
`<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
25
92
|
export async function handleAdminCommand(
|
|
26
93
|
ctx: Context,
|
|
27
94
|
bot: Bot,
|
|
@@ -32,52 +99,7 @@ export async function handleAdminCommand(
|
|
|
32
99
|
|
|
33
100
|
switch (subcommand) {
|
|
34
101
|
case "chats": {
|
|
35
|
-
|
|
36
|
-
if (sessions.length === 0) {
|
|
37
|
-
await ctx.reply("No active sessions.");
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
sessions.sort(
|
|
41
|
-
(a, b) => (b.info.lastActive || 0) - (a.info.lastActive || 0),
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
const titles = new Map<string, string>();
|
|
45
|
-
await Promise.all(
|
|
46
|
-
sessions.map(async (s) => {
|
|
47
|
-
try {
|
|
48
|
-
const id = parseInt(s.chatId, 10);
|
|
49
|
-
if (isNaN(id)) return;
|
|
50
|
-
const chat = await bot.api.getChat(id);
|
|
51
|
-
titles.set(
|
|
52
|
-
s.chatId,
|
|
53
|
-
"title" in chat
|
|
54
|
-
? (chat.title ?? "DM")
|
|
55
|
-
: "first_name" in chat
|
|
56
|
-
? (chat.first_name ?? "DM")
|
|
57
|
-
: "DM",
|
|
58
|
-
);
|
|
59
|
-
} catch {
|
|
60
|
-
/* inaccessible */
|
|
61
|
-
}
|
|
62
|
-
}),
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const lines = sessions.map((s) => {
|
|
66
|
-
const age = s.info.lastActive
|
|
67
|
-
? `${Math.round((Date.now() - s.info.lastActive) / 60000)}m ago`
|
|
68
|
-
: "?";
|
|
69
|
-
const title = titles.get(s.chatId) ?? s.chatId;
|
|
70
|
-
const model = formatModelLabel(
|
|
71
|
-
getChatSettings(s.chatId).model ?? config.model,
|
|
72
|
-
);
|
|
73
|
-
// `model` is a catalog id (OpenRouter/Kilo ids are free-form), so
|
|
74
|
-
// it gets the same escaping the title already had.
|
|
75
|
-
return `<b>${escapeHtml(title)}</b> <code>${s.chatId}</code>\n ${s.info.turns} turns | ${age} | ${escapeHtml(model)}`;
|
|
76
|
-
});
|
|
77
|
-
await ctx.reply(
|
|
78
|
-
`<b>Active chats (${sessions.length})</b>\n\n` + lines.join("\n\n"),
|
|
79
|
-
{ parse_mode: "HTML" },
|
|
80
|
-
);
|
|
102
|
+
await replyActiveChats(ctx, bot, config);
|
|
81
103
|
return;
|
|
82
104
|
}
|
|
83
105
|
|
|
@@ -189,9 +211,9 @@ export async function handleAdminCommand(
|
|
|
189
211
|
: "?";
|
|
190
212
|
return `${j.enabled ? "\u2713" : "\u2717"} <b>${escapeHtml(j.name)}</b>\n <code>${escapeHtml(describeSchedule(j))}</code> | ${j.type} | runs: ${j.runCount} | last: ${last} | next: ${next}`;
|
|
191
213
|
});
|
|
192
|
-
await
|
|
214
|
+
await replyHtmlChunked(
|
|
215
|
+
ctx,
|
|
193
216
|
`<b>Cron Jobs (${jobs.length})</b>\n\n` + lines.join("\n\n"),
|
|
194
|
-
{ parse_mode: "HTML" },
|
|
195
217
|
);
|
|
196
218
|
return;
|
|
197
219
|
}
|
|
@@ -217,9 +239,10 @@ export async function handleAdminCommand(
|
|
|
217
239
|
return `${p.enabled ? "\u2713" : "\u2717"} ${escapeHtml(title)}`;
|
|
218
240
|
}),
|
|
219
241
|
);
|
|
220
|
-
await
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
await replyHtmlChunked(
|
|
243
|
+
ctx,
|
|
244
|
+
`<b>Pulse (${chats.length})</b>\n\n` + lines.join("\n"),
|
|
245
|
+
);
|
|
223
246
|
return;
|
|
224
247
|
}
|
|
225
248
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access gates — the allow-lists are the entire permission model. DMs
|
|
3
|
+
* match `allowedJids` (see identity.ts); groups go through `groupPolicy`
|
|
4
|
+
* and, in mention mode, must address the bot.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { WAMessage } from "baileys";
|
|
8
|
+
import { logWarn } from "../../util/log.js";
|
|
9
|
+
import { bareId } from "./identity.js";
|
|
10
|
+
import type { WhatsAppRuntime } from "./runtime.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How long a group's membership answer is trusted. Group metadata costs a
|
|
14
|
+
* round-trip, and the `with-allowed-user` policy would otherwise pay it on
|
|
15
|
+
* every inbound message; memberships change on the order of days.
|
|
16
|
+
*/
|
|
17
|
+
const GROUP_POLICY_CACHE_MS = 10 * 60_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* May the bot act in this group? `allowedGroups` is always honoured;
|
|
21
|
+
* beyond it the policy decides, and "with-allowed-user" asks WhatsApp
|
|
22
|
+
* who is in the group (cached — see GROUP_POLICY_CACHE_MS).
|
|
23
|
+
*/
|
|
24
|
+
export async function isGroupAllowed(
|
|
25
|
+
runtime: WhatsAppRuntime,
|
|
26
|
+
jid: string,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
if (runtime.allowedGroups.has(bareId(jid))) return true;
|
|
29
|
+
if (runtime.settings.groupPolicy === "listed") return false;
|
|
30
|
+
if (runtime.settings.groupPolicy === "all") return true;
|
|
31
|
+
|
|
32
|
+
const cached = runtime.groupAllowCache.get(jid);
|
|
33
|
+
if (cached && Date.now() - cached.at < GROUP_POLICY_CACHE_MS) {
|
|
34
|
+
return cached.allowed;
|
|
35
|
+
}
|
|
36
|
+
let allowed = false;
|
|
37
|
+
try {
|
|
38
|
+
const meta = await runtime.sock!.groupMetadata(jid);
|
|
39
|
+
// A participant is listed by whichever form the group uses, so
|
|
40
|
+
// check every id WhatsApp gives us for them.
|
|
41
|
+
allowed = meta.participants.some((p) =>
|
|
42
|
+
[p.id, p.lid, p.phoneNumber]
|
|
43
|
+
.filter((id): id is string => Boolean(id))
|
|
44
|
+
.some((id) => runtime.allowedDms.has(bareId(id))),
|
|
45
|
+
);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// A metadata failure must not silently open the group up.
|
|
48
|
+
logWarn(
|
|
49
|
+
"whatsapp",
|
|
50
|
+
`Group policy check failed for ${jid}: ${err instanceof Error ? err.message : err}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
runtime.groupAllowCache.set(jid, { allowed, at: Date.now() });
|
|
54
|
+
return allowed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Is this group message addressed to us — @mentioned or quoting us? */
|
|
58
|
+
export function isAddressedToSelf(
|
|
59
|
+
selfIds: readonly string[],
|
|
60
|
+
msg: WAMessage,
|
|
61
|
+
): boolean {
|
|
62
|
+
if (selfIds.length === 0) return false;
|
|
63
|
+
const ctx = msg.message?.extendedTextMessage?.contextInfo;
|
|
64
|
+
const isSelf = (j: string): boolean => selfIds.includes(bareId(j));
|
|
65
|
+
if ((ctx?.mentionedJid ?? []).some(isSelf)) return true;
|
|
66
|
+
return Boolean(ctx?.participant && isSelf(ctx.participant));
|
|
67
|
+
}
|