talon-agent 5.0.0 → 5.1.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/package.json +2 -2
- package/src/app.ts +3 -3
- package/src/backend/claude-sdk/factory.ts +21 -41
- package/src/backend/claude-sdk/handler.ts +191 -69
- package/src/backend/claude-sdk/host/in-process.ts +147 -0
- package/src/backend/claude-sdk/one-shot.ts +17 -2
- package/src/backend/claude-sdk/stream.ts +9 -0
- package/src/backend/codex/mcp-config.ts +1 -1
- package/src/backend/codex/one-shot.ts +18 -4
- package/src/backend/openai-agents/mcp-pool.ts +1 -1
- package/src/backend/remote-server/one-shot.ts +16 -3
- package/src/backend/runtime/index.ts +2 -2
- package/src/backend/runtime/one-shot-hooks.ts +45 -0
- package/src/backend/runtime/turn/delivery.ts +1 -1
- package/src/backend/runtime/turn/turn-phases.ts +1 -1
- package/src/bootstrap.ts +2 -2
- package/src/cli.ts +5 -2
- package/src/core/agent-runtime/README.md +15 -0
- package/src/core/agent-runtime/agent-host.ts +388 -0
- package/src/core/agent-runtime/capabilities.ts +3 -0
- package/src/core/background/cron/scheduler.ts +1 -1
- package/src/core/background/triggers/command.ts +1 -1
- package/src/{util → core/config}/harden.ts +1 -1
- package/src/core/config/index.ts +1 -1
- package/src/core/daemon/pidfile.ts +1 -1
- package/src/core/daemon/resource-sampler.ts +2 -2
- package/src/{util → core/daemon}/respawn.ts +1 -1
- package/src/core/doctor/index.ts +1 -1
- package/src/core/engine/gateway-actions/fetch-url.ts +1 -1
- package/src/core/engine/gateway-actions/index.ts +1 -1
- package/src/core/engine/gateway-actions/memory.ts +2 -2
- package/src/core/engine/gateway-actions/native/exec-background.ts +129 -0
- package/src/core/engine/gateway-actions/native/exec-remote.ts +85 -0
- package/src/core/engine/gateway-actions/native/exec.ts +182 -0
- package/src/core/engine/gateway-actions/native/index.ts +46 -0
- package/src/core/engine/gateway-actions/native/params.ts +46 -0
- package/src/core/engine/gateway-actions/native/read.ts +182 -0
- package/src/core/engine/gateway-actions/native/results.ts +34 -0
- package/src/core/engine/gateway-actions/native/search.ts +212 -0
- package/src/core/engine/gateway-actions/native/shell.ts +39 -0
- package/src/core/engine/gateway-actions/native/teleport.ts +55 -0
- package/src/core/engine/gateway-actions/native/write.ts +170 -0
- package/src/core/frontend-runtime/builtins.ts +2 -2
- package/src/core/mcp-hub/children.ts +21 -5
- package/src/core/mcp-hub/index.ts +1 -1
- package/src/core/mcp-hub/talon-server.ts +1 -1
- package/src/core/memory/taps.ts +1 -1
- package/src/core/plugin/mcp.ts +1 -1
- package/src/core/scripts/lua.ts +1 -1
- package/src/core/tools/{chat.ts → chat/chat.ts} +1 -1
- package/src/core/tools/{cross-send.ts → chat/cross-send.ts} +1 -1
- package/src/core/tools/{history.ts → chat/history.ts} +2 -2
- package/src/core/tools/{media.ts → chat/media.ts} +1 -1
- package/src/core/tools/{members.ts → chat/members.ts} +2 -2
- package/src/core/tools/{messaging.ts → chat/messaging.ts} +2 -2
- package/src/core/tools/{moderation.ts → chat/moderation.ts} +2 -2
- package/src/core/tools/{stickers.ts → chat/stickers.ts} +2 -2
- package/src/core/tools/{whatsapp.ts → chat/whatsapp.ts} +1 -1
- package/src/core/tools/{memory.ts → content/memory.ts} +2 -2
- package/src/core/tools/{web.ts → content/web.ts} +1 -1
- package/src/core/tools/index.ts +20 -20
- package/src/core/tools/{admin.ts → ops/admin.ts} +1 -1
- package/src/core/tools/{bridge.ts → ops/bridge.ts} +2 -2
- package/src/core/tools/{goals.ts → ops/goals.ts} +2 -2
- package/src/core/tools/{mesh.ts → ops/mesh.ts} +1 -1
- package/src/core/tools/{models.ts → ops/models.ts} +1 -1
- package/src/core/tools/{native.ts → ops/native.ts} +1 -1
- package/src/core/tools/{scheduling.ts → ops/scheduling.ts} +1 -1
- package/src/core/tools/{scripts.ts → ops/scripts.ts} +1 -1
- package/src/core/tools/{skills.ts → ops/skills.ts} +1 -1
- package/src/core/tools/{triggers.ts → ops/triggers.ts} +1 -1
- package/src/core/types.ts +11 -0
- package/src/{util → core/vfs}/workspace.ts +2 -2
- package/src/frontend/discord/callbacks/components/effort.ts +1 -1
- package/src/frontend/discord/callbacks/components/index.ts +1 -1
- package/src/frontend/discord/callbacks/components/settings.ts +1 -1
- package/src/frontend/discord/commands/admin.ts +1 -1
- package/src/frontend/discord/commands/info.ts +1 -1
- package/src/frontend/discord/commands/interaction.ts +1 -1
- package/src/frontend/discord/commands/session.ts +3 -3
- package/src/frontend/discord/commands/settings.ts +1 -1
- package/src/frontend/discord/handlers/messages.ts +1 -1
- package/src/frontend/discord/handlers/state.ts +1 -1
- package/src/frontend/discord/middleware.ts +1 -1
- package/src/frontend/discord/ready.ts +1 -1
- package/src/frontend/discord/render.ts +41 -259
- package/src/frontend/native/chats/chats.ts +1 -1
- package/src/frontend/native/surface/models.ts +1 -1
- package/src/frontend/native/turn/context.ts +1 -1
- package/src/frontend/native/turn/emit.ts +1 -1
- package/src/frontend/{shared → presentation}/format.ts +4 -3
- package/src/frontend/{shared → presentation}/reasoning-levels.ts +12 -0
- package/src/frontend/presentation/reports.ts +493 -0
- package/src/frontend/{shared → presentation}/session-status.ts +1 -1
- package/src/frontend/teams/commands.ts +2 -2
- package/src/frontend/teams/turn.ts +1 -1
- package/src/frontend/telegram/admin/health.ts +1 -1
- package/src/frontend/telegram/admin/sessions.ts +1 -1
- package/src/frontend/telegram/callbacks/effort.ts +2 -2
- package/src/frontend/telegram/callbacks/metrics.ts +1 -1
- package/src/frontend/telegram/callbacks/model/views.ts +1 -1
- package/src/frontend/telegram/callbacks/query.ts +1 -1
- package/src/frontend/telegram/callbacks/settings.ts +2 -2
- package/src/frontend/telegram/commands/admin.ts +4 -4
- package/src/frontend/telegram/commands/info.ts +2 -2
- package/src/frontend/telegram/commands/session.ts +4 -4
- package/src/frontend/telegram/commands/settings.ts +4 -2
- package/src/frontend/telegram/handlers/state.ts +1 -1
- package/src/frontend/telegram/model-menu.ts +1 -1
- package/src/frontend/telegram/render/html.ts +30 -0
- package/src/frontend/telegram/{helpers → render}/menu.ts +46 -19
- package/src/frontend/telegram/render/reports.ts +182 -0
- package/src/frontend/terminal/builtins/context.ts +2 -2
- package/src/frontend/terminal/builtins/session.ts +1 -1
- package/src/frontend/terminal/builtins/status.ts +2 -2
- package/src/frontend/terminal/index.ts +2 -2
- package/src/frontend/terminal/renderer.ts +1 -1
- package/src/frontend/whatsapp/commands.ts +5 -5
- package/src/frontend/whatsapp/registry.ts +1 -1
- package/src/index.ts +5 -2
- package/src/util/runtime.ts +1 -1
- package/src/core/engine/gateway-actions/native.ts +0 -1035
- package/src/frontend/telegram/helpers/diagnostics.ts +0 -428
- package/src/frontend/telegram/helpers/format.ts +0 -42
- package/src/frontend/telegram/helpers/index.ts +0 -13
- package/src/util/cleanup-registry.ts +0 -36
- /package/src/{util → core/daemon}/boot-timer.ts +0 -0
- /package/src/{util → core/frontend-runtime}/chat-id.ts +0 -0
- /package/src/{util/mcp-launcher.ts → core/mcp-hub/launcher.ts} +0 -0
- /package/src/{util → core/tools/content}/web-content.ts +0 -0
- /package/src/core/tools/{mcp-env.ts → ops/mcp-env.ts} +0 -0
- /package/src/{util → core/weaver}/session-name.ts +0 -0
- /package/src/frontend/{shared → presentation}/access.ts +0 -0
- /package/src/frontend/{shared → presentation}/model-commands.ts +0 -0
- /package/src/frontend/{shared → presentation}/plan-usage-report.ts +0 -0
- /package/src/frontend/{shared → presentation}/status-context.ts +0 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five chat reports — metrics, doctor, mesh, usage, settings — in one
|
|
3
|
+
* place, parameterised by the platform's markup dialect.
|
|
4
|
+
*
|
|
5
|
+
* Discord and Telegram carried line-for-line copies of these renderers,
|
|
6
|
+
* differing only in how they spell bold/italic/code, whether text needs
|
|
7
|
+
* HTML escaping, and how much fits in one message. Two copies of a
|
|
8
|
+
* report is two places to fix a bug and two places to drift, which is
|
|
9
|
+
* exactly what happened: the `/metrics` leaderboard cap and the metric
|
|
10
|
+
* label budget landed on Telegram and never reached Discord.
|
|
11
|
+
*
|
|
12
|
+
* The seam is `ReportFormatter`. Everything a platform does differently
|
|
13
|
+
* is one of its fields; everything else is written once here. Discord's
|
|
14
|
+
* `escape` is the identity function, so applying it at every point
|
|
15
|
+
* Telegram escapes leaves Discord's bytes untouched — and Telegram keeps
|
|
16
|
+
* escaping exactly where it always did, which is what stops a model id
|
|
17
|
+
* or a device name containing `<` from 400-ing the whole send.
|
|
18
|
+
*
|
|
19
|
+
* Where the two platforms differ in *content* rather than markup, both
|
|
20
|
+
* behaviours survive: `/metrics` really is two reports (Discord splits
|
|
21
|
+
* across messages, Telegram shrinks into one panel), and they share the
|
|
22
|
+
* section model rather than the output shape.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { DoctorReport } from "../../core/doctor/index.js";
|
|
26
|
+
import type { MeshPingResult } from "../../core/mesh/service.js";
|
|
27
|
+
import type { BackendUsageEntry } from "./plan-usage-report.js";
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_PULSE_INTERVAL_MS,
|
|
30
|
+
formatDuration,
|
|
31
|
+
formatBytes,
|
|
32
|
+
formatModelLabel,
|
|
33
|
+
} from "./format.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One platform's report dialect.
|
|
37
|
+
*
|
|
38
|
+
* Every field exists because the two implementations differed on it. Add
|
|
39
|
+
* a field only when a third frontend genuinely differs — a report that
|
|
40
|
+
* needs a new *shape* is a new function here, not a new flag.
|
|
41
|
+
*/
|
|
42
|
+
export interface ReportFormatter {
|
|
43
|
+
/** Bold run: `**s**` on Discord, `<b>s</b>` on Telegram. */
|
|
44
|
+
bold(s: string): string;
|
|
45
|
+
/** Italic run: `_s_` on Discord, `<i>s</i>` on Telegram. */
|
|
46
|
+
italic(s: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* The *other* italic spelling: `*s*` on Discord, `<i>s</i>` on
|
|
49
|
+
* Telegram. Discord markdown has two markers for one effect and
|
|
50
|
+
* `/usage` has always used this one for the staleness label; keeping
|
|
51
|
+
* both spellings is what makes that report byte-identical.
|
|
52
|
+
*/
|
|
53
|
+
emphasis(s: string): string;
|
|
54
|
+
/** Inline code run: `` `s` `` on Discord, `<code>s</code>` on Telegram. */
|
|
55
|
+
code(s: string): string;
|
|
56
|
+
/**
|
|
57
|
+
* Escape text that is not markup. Telegram parses with `parse_mode:
|
|
58
|
+
* HTML`, so every interpolated value — model ids, device names, metric
|
|
59
|
+
* keys, backend notes — must go through this or the send fails with
|
|
60
|
+
* `can't parse entities`. Discord needs none, and passes the identity
|
|
61
|
+
* function.
|
|
62
|
+
*/
|
|
63
|
+
escape(s: string): string;
|
|
64
|
+
/** Characters this platform accepts in one report message. */
|
|
65
|
+
lineLimit: number;
|
|
66
|
+
/** Longest metric key rendered before it is elided (60 vs 80). */
|
|
67
|
+
metricLabelMax: number;
|
|
68
|
+
/** Settings-panel pulse row label — Discord's carries a 🔔, Telegram's doesn't. */
|
|
69
|
+
pulseLabel: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type MetricsSnapshot = {
|
|
73
|
+
counters: Record<string, number>;
|
|
74
|
+
histograms: Record<
|
|
75
|
+
string,
|
|
76
|
+
{ count: number; avg: number; min: number; max: number }
|
|
77
|
+
>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One titled block of a metrics report. `hidden` counts rows dropped to
|
|
82
|
+
* make the report fit; it renders as a trailing "…and N more".
|
|
83
|
+
*/
|
|
84
|
+
type Section = { title: string; rows: string[]; hidden: number };
|
|
85
|
+
|
|
86
|
+
function truncateMetricLabel(label: string, max: number): string {
|
|
87
|
+
return label.length <= max ? label : `${label.slice(0, max - 3)}...`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Group a snapshot into the report's sections.
|
|
92
|
+
*
|
|
93
|
+
* `toolCallsTopN` pre-caps the `tool_calls` leaderboard: a long-lived
|
|
94
|
+
* bot accumulates a lifetime entry per distinct tool name (easily 100+
|
|
95
|
+
* once plugin and MCP tools are counted) and past the top rows the tail
|
|
96
|
+
* is one-call tools nobody reads. Discord passes `Infinity` — it splits
|
|
97
|
+
* across messages instead and has never capped.
|
|
98
|
+
*/
|
|
99
|
+
function buildMetricsSections(
|
|
100
|
+
metrics: MetricsSnapshot,
|
|
101
|
+
fmt: ReportFormatter,
|
|
102
|
+
toolCallsTopN: number,
|
|
103
|
+
): Section[] {
|
|
104
|
+
const sections: Section[] = [];
|
|
105
|
+
|
|
106
|
+
// Histograms come in two flavours: durations (keys ending in `_ms`,
|
|
107
|
+
// rendered as human times) and plain counts like `tool_calls_per_turn`
|
|
108
|
+
// (rendered as bare numbers — "min=1ms" for a count is nonsense).
|
|
109
|
+
const histKeys = Object.keys(metrics.histograms).sort();
|
|
110
|
+
const durationKeys = histKeys.filter((key) => key.endsWith("_ms"));
|
|
111
|
+
const countKeys = histKeys.filter((key) => !key.endsWith("_ms"));
|
|
112
|
+
const histLine = (key: string, format: (v: number) => string): string => {
|
|
113
|
+
const h = metrics.histograms[key]!;
|
|
114
|
+
return (
|
|
115
|
+
` ${fmt.code(fmt.escape(truncateMetricLabel(key, fmt.metricLabelMax)))}` +
|
|
116
|
+
` n=${h.count} avg=${format(h.avg)} min=${format(h.min)} ` +
|
|
117
|
+
`max=${format(h.max)}`
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
if (durationKeys.length > 0) {
|
|
121
|
+
sections.push({
|
|
122
|
+
title: fmt.bold("Latency"),
|
|
123
|
+
rows: durationKeys.map((key) => histLine(key, formatDuration)),
|
|
124
|
+
hidden: 0,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (countKeys.length > 0) {
|
|
128
|
+
sections.push({
|
|
129
|
+
title: fmt.bold("Distributions"),
|
|
130
|
+
rows: countKeys.map((key) => histLine(key, String)),
|
|
131
|
+
hidden: 0,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [prefix, keys] of groupCounters(metrics)) {
|
|
136
|
+
const shown = keys.slice(0, toolCallsTopN);
|
|
137
|
+
sections.push({
|
|
138
|
+
title: fmt.bold(fmt.escape(prefix)),
|
|
139
|
+
rows: shown.map((key) => {
|
|
140
|
+
const label = key.includes(".")
|
|
141
|
+
? key.split(".").slice(1).join(".")
|
|
142
|
+
: key;
|
|
143
|
+
const name = truncateMetricLabel(label, fmt.metricLabelMax);
|
|
144
|
+
return (
|
|
145
|
+
` ${fmt.code(fmt.escape(name))} ` +
|
|
146
|
+
`${metrics.counters[key]!.toLocaleString()}`
|
|
147
|
+
);
|
|
148
|
+
}),
|
|
149
|
+
hidden: keys.length - shown.length,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return sections;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Counter keys bucketed by their dotted namespace, groups in name order. */
|
|
157
|
+
function groupCounters(metrics: MetricsSnapshot): Array<[string, string[]]> {
|
|
158
|
+
const groups = new Map<string, string[]>();
|
|
159
|
+
for (const key of Object.keys(metrics.counters).sort()) {
|
|
160
|
+
const prefix = key.includes(".") ? key.split(".")[0]! : "general";
|
|
161
|
+
if (!groups.has(prefix)) groups.set(prefix, []);
|
|
162
|
+
groups.get(prefix)!.push(key);
|
|
163
|
+
}
|
|
164
|
+
return [...groups.keys()].sort().map((prefix): [string, string[]] => {
|
|
165
|
+
// tool_calls reads best as a leaderboard — busiest tools first.
|
|
166
|
+
// Other groups keep alphabetical order (stable lookup by name).
|
|
167
|
+
const keys = groups.get(prefix)!;
|
|
168
|
+
return [
|
|
169
|
+
prefix,
|
|
170
|
+
prefix === "tool_calls"
|
|
171
|
+
? [...keys].sort(
|
|
172
|
+
(a, b) =>
|
|
173
|
+
metrics.counters[b]! - metrics.counters[a]! || a.localeCompare(b),
|
|
174
|
+
)
|
|
175
|
+
: keys,
|
|
176
|
+
];
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A section as lines: title, rows, and the elision note when rows were dropped. */
|
|
181
|
+
function sectionLines(section: Section, fmt: ReportFormatter): string[] {
|
|
182
|
+
return [
|
|
183
|
+
section.title,
|
|
184
|
+
...section.rows,
|
|
185
|
+
...(section.hidden > 0
|
|
186
|
+
? [` ${fmt.italic(`…and ${section.hidden} more`)}`]
|
|
187
|
+
: []),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Render the metrics report across as many messages as it takes, each
|
|
193
|
+
* ≤ `maxLen`, continuation messages re-stating the title. Discord's
|
|
194
|
+
* shape: nothing is dropped, the report just gets longer.
|
|
195
|
+
*/
|
|
196
|
+
export function renderMetricsMessages(
|
|
197
|
+
metrics: MetricsSnapshot,
|
|
198
|
+
fmt: ReportFormatter,
|
|
199
|
+
maxLen = fmt.lineLimit,
|
|
200
|
+
title = "📊 Metrics",
|
|
201
|
+
): string[] {
|
|
202
|
+
const firstHeader = fmt.bold(title);
|
|
203
|
+
const continuationHeader = fmt.bold(`${title} (cont.)`);
|
|
204
|
+
const sections = buildMetricsSections(metrics, fmt, Infinity);
|
|
205
|
+
if (sections.length === 0) {
|
|
206
|
+
return [`${firstHeader}\n\n${fmt.italic("No metrics recorded yet.")}`];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const chunks: string[] = [];
|
|
210
|
+
let header = firstHeader;
|
|
211
|
+
let current = header;
|
|
212
|
+
const flush = () => {
|
|
213
|
+
chunks.push(current);
|
|
214
|
+
header = continuationHeader;
|
|
215
|
+
current = header;
|
|
216
|
+
};
|
|
217
|
+
const appendLine = (line: string) => {
|
|
218
|
+
if (!line && current === header) return;
|
|
219
|
+
const candidate = `${current}\n${line}`;
|
|
220
|
+
if (candidate.length <= maxLen) {
|
|
221
|
+
current = candidate;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (current !== header) {
|
|
225
|
+
flush();
|
|
226
|
+
if (!line) return;
|
|
227
|
+
}
|
|
228
|
+
const available = maxLen - header.length - 1;
|
|
229
|
+
if (available < 0) return;
|
|
230
|
+
const safeLine =
|
|
231
|
+
line.length <= available
|
|
232
|
+
? line
|
|
233
|
+
: available >= 4
|
|
234
|
+
? `${line.slice(0, available - 3)}...`
|
|
235
|
+
: line.slice(0, available);
|
|
236
|
+
current = `${current}\n${safeLine}`;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
for (const section of sections) {
|
|
240
|
+
appendLine("");
|
|
241
|
+
for (const line of sectionLines(section, fmt)) appendLine(line);
|
|
242
|
+
}
|
|
243
|
+
if (current !== header || chunks.length === 0) chunks.push(current);
|
|
244
|
+
return chunks;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Render the metrics report as a SINGLE message, shrinking to fit.
|
|
249
|
+
*
|
|
250
|
+
* Telegram's shape: rather than split the report across messages — which
|
|
251
|
+
* is what the tappable panel replaced — rows are dropped from the longest
|
|
252
|
+
* section first (each drop bumping that section's "…and N more") until
|
|
253
|
+
* the whole thing fits. Sections always keep at least their first row, so
|
|
254
|
+
* every group stays visible.
|
|
255
|
+
*/
|
|
256
|
+
export function renderMetricsPanel(
|
|
257
|
+
metrics: MetricsSnapshot,
|
|
258
|
+
fmt: ReportFormatter,
|
|
259
|
+
title: string,
|
|
260
|
+
maxLen = fmt.lineLimit,
|
|
261
|
+
toolCallsTopN = Infinity,
|
|
262
|
+
): string {
|
|
263
|
+
const header = fmt.bold(fmt.escape(title));
|
|
264
|
+
const sections = buildMetricsSections(metrics, fmt, toolCallsTopN);
|
|
265
|
+
if (sections.length === 0) {
|
|
266
|
+
return `${header}\n\n${fmt.italic("No metrics recorded yet.")}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const render = () =>
|
|
270
|
+
[header, ...sections.map((s) => sectionLines(s, fmt).join("\n"))].join(
|
|
271
|
+
"\n\n",
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
let out = render();
|
|
275
|
+
while (out.length > maxLen) {
|
|
276
|
+
let target: Section | undefined;
|
|
277
|
+
for (const section of sections) {
|
|
278
|
+
if (section.rows.length <= 1) continue;
|
|
279
|
+
if (!target || section.rows.length > target.rows.length) target = section;
|
|
280
|
+
}
|
|
281
|
+
if (!target) break;
|
|
282
|
+
target.rows.pop();
|
|
283
|
+
target.hidden += 1;
|
|
284
|
+
out = render();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (out.length <= maxLen) return out;
|
|
288
|
+
// Nothing left to shed (a pathologically small maxLen). Cut on a line
|
|
289
|
+
// boundary so we never slice through an HTML tag and fail the parse.
|
|
290
|
+
const cut = out.lastIndexOf("\n", maxLen - 1);
|
|
291
|
+
return cut > 0 ? out.slice(0, cut) : out.slice(0, maxLen);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const DOCTOR_ICONS: Record<string, string> = {
|
|
295
|
+
ok: "✅",
|
|
296
|
+
warn: "⚠️",
|
|
297
|
+
fail: "❌",
|
|
298
|
+
info: "▫️",
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Render a DoctorReport as one message body.
|
|
303
|
+
*
|
|
304
|
+
* Same data as `talon doctor` (src/core/doctor/) plus in-process runtime
|
|
305
|
+
* info — when this renders, the bot is by definition running, so the
|
|
306
|
+
* CLI's "is the bot up" probe becomes an uptime line instead. Callers
|
|
307
|
+
* that have a message cap split the result themselves.
|
|
308
|
+
*/
|
|
309
|
+
export function renderDoctorReport(
|
|
310
|
+
report: DoctorReport,
|
|
311
|
+
fmt: ReportFormatter,
|
|
312
|
+
): string {
|
|
313
|
+
const lines = [fmt.bold("🩺 Talon Doctor"), "", fmt.bold("Environment")];
|
|
314
|
+
|
|
315
|
+
const render = (check: DoctorReport["checks"][number]): string => {
|
|
316
|
+
const detail = check.detail ? ` (${fmt.escape(check.detail)})` : "";
|
|
317
|
+
return `${DOCTOR_ICONS[check.status]} ${fmt.escape(check.label)}${detail}`;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
for (const check of report.checks.filter((c) => !c.inactive)) {
|
|
321
|
+
lines.push(render(check));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Configured-but-idle backends get their own block: they describe what a
|
|
325
|
+
// switch would run into, not the state of the running deployment.
|
|
326
|
+
const idle = report.checks.filter((c) => c.inactive);
|
|
327
|
+
if (idle.length > 0) {
|
|
328
|
+
lines.push("", fmt.bold("Other backends"), ...idle.map(render));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
lines.push("", fmt.bold("Native modules"));
|
|
332
|
+
for (const mod of report.native) {
|
|
333
|
+
const size =
|
|
334
|
+
mod.sizeBytes !== undefined ? ` · ${formatBytes(mod.sizeBytes)}` : "";
|
|
335
|
+
const note = mod.note ? ` (${fmt.escape(mod.note)})` : "";
|
|
336
|
+
lines.push(
|
|
337
|
+
`${mod.ok ? DOCTOR_ICONS.ok : DOCTOR_ICONS.fail} ` +
|
|
338
|
+
`${fmt.code(fmt.escape(mod.name))} — ${fmt.escape(mod.language)} → ` +
|
|
339
|
+
`${fmt.escape(mod.target)}${size}${note}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
lines.push(
|
|
344
|
+
"",
|
|
345
|
+
fmt.bold("Process"),
|
|
346
|
+
`Uptime ${formatDuration(process.uptime() * 1000)} · PID ${process.pid} · ` +
|
|
347
|
+
`Node ${fmt.escape(process.versions.node)}`,
|
|
348
|
+
"",
|
|
349
|
+
report.issues === 0
|
|
350
|
+
? `${DOCTOR_ICONS.ok} All checks passed.`
|
|
351
|
+
: `${DOCTOR_ICONS.warn} ${report.issues} issue(s) found.`,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
return lines.join("\n");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function meshDeviceLine(
|
|
358
|
+
r: MeshPingResult,
|
|
359
|
+
fmt: ReportFormatter,
|
|
360
|
+
now: number,
|
|
361
|
+
): string {
|
|
362
|
+
const d = r.device;
|
|
363
|
+
const bits: string[] = [fmt.escape(d.platform)];
|
|
364
|
+
if (r.reachable && typeof r.latencyMs === "number") {
|
|
365
|
+
bits.push(`${r.latencyMs} ms`);
|
|
366
|
+
} else if (d.online && r.error) {
|
|
367
|
+
bits.push(fmt.escape(r.error));
|
|
368
|
+
} else if (!d.online) {
|
|
369
|
+
bits.push(`last seen ${formatDuration(now - d.lastSeen)} ago`);
|
|
370
|
+
}
|
|
371
|
+
if (typeof d.battery === "number") {
|
|
372
|
+
bits.push(`${d.battery}%${d.charging ? " charging" : ""}`);
|
|
373
|
+
}
|
|
374
|
+
return ` ${fmt.bold(fmt.escape(d.name))} — ${bits.join(" · ")}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Render the `/mesh` fleet report.
|
|
379
|
+
*
|
|
380
|
+
* Devices group under a state heading — Responding, Unreachable, Offline
|
|
381
|
+
* — rather than carrying a coloured status glyph per row: the grouping
|
|
382
|
+
* already says what the glyph said, and the report stays readable when
|
|
383
|
+
* the fleet grows. Empty groups are omitted entirely. `footer` is the
|
|
384
|
+
* platform's own trailer (Telegram appends the bridge block; Discord
|
|
385
|
+
* appends nothing).
|
|
386
|
+
*/
|
|
387
|
+
export function renderMeshReport(
|
|
388
|
+
results: MeshPingResult[],
|
|
389
|
+
fmt: ReportFormatter,
|
|
390
|
+
now = Date.now(),
|
|
391
|
+
footer: string[] = [],
|
|
392
|
+
): string {
|
|
393
|
+
const heading = fmt.bold("Mesh");
|
|
394
|
+
if (results.length === 0) {
|
|
395
|
+
return [
|
|
396
|
+
heading,
|
|
397
|
+
"",
|
|
398
|
+
fmt.italic("No devices have registered yet."),
|
|
399
|
+
...footer,
|
|
400
|
+
].join("\n");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const responding = results
|
|
404
|
+
.filter((r) => r.reachable)
|
|
405
|
+
.sort((a, b) => (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity));
|
|
406
|
+
const unreachable = results
|
|
407
|
+
.filter((r) => !r.reachable && r.device.online)
|
|
408
|
+
.sort((a, b) => a.device.name.localeCompare(b.device.name));
|
|
409
|
+
const offline = results
|
|
410
|
+
.filter((r) => !r.reachable && !r.device.online)
|
|
411
|
+
.sort((a, b) => b.device.lastSeen - a.device.lastSeen);
|
|
412
|
+
|
|
413
|
+
const summary = [
|
|
414
|
+
`${results.length} device${results.length === 1 ? "" : "s"}`,
|
|
415
|
+
`${responding.length} responding`,
|
|
416
|
+
...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
|
|
417
|
+
...(offline.length > 0 ? [`${offline.length} offline`] : []),
|
|
418
|
+
].join(" · ");
|
|
419
|
+
|
|
420
|
+
const lines = [heading, summary];
|
|
421
|
+
const section = (title: string, entries: MeshPingResult[]): void => {
|
|
422
|
+
if (entries.length === 0) return;
|
|
423
|
+
lines.push(
|
|
424
|
+
"",
|
|
425
|
+
fmt.bold(title),
|
|
426
|
+
...entries.map((r) => meshDeviceLine(r, fmt, now)),
|
|
427
|
+
);
|
|
428
|
+
};
|
|
429
|
+
section("Responding", responding);
|
|
430
|
+
section("Unreachable", unreachable);
|
|
431
|
+
section("Offline", offline);
|
|
432
|
+
lines.push(...footer);
|
|
433
|
+
|
|
434
|
+
return lines.join("\n");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Render the `/usage` report — one block per exposed backend. */
|
|
438
|
+
export function renderUsageMessage(
|
|
439
|
+
entries: BackendUsageEntry[],
|
|
440
|
+
fmt: ReportFormatter,
|
|
441
|
+
): string {
|
|
442
|
+
const lines = [fmt.bold("📊 Plan usage")];
|
|
443
|
+
|
|
444
|
+
for (const entry of entries) {
|
|
445
|
+
const name = fmt.escape(entry.label || entry.id);
|
|
446
|
+
if (!entry.plan) {
|
|
447
|
+
lines.push(
|
|
448
|
+
"",
|
|
449
|
+
`${fmt.bold(name)} — ${fmt.italic(fmt.escape(entry.note ?? ""))}`,
|
|
450
|
+
);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const age = entry.plan.ageLabel
|
|
454
|
+
? ` ${fmt.emphasis(`(${entry.plan.ageLabel})`)}`
|
|
455
|
+
: "";
|
|
456
|
+
const plan = entry.plan.plan ? ` · ${fmt.escape(entry.plan.plan)}` : "";
|
|
457
|
+
lines.push("", `${fmt.bold(name)}${plan}${age}`);
|
|
458
|
+
if (entry.plan.resetsAvailable) {
|
|
459
|
+
const n = entry.plan.resetsAvailable;
|
|
460
|
+
const resets = `usage limit reset${n === 1 ? "" : "s"} available`;
|
|
461
|
+
lines.push(` • You have ${fmt.bold(String(n))} ${resets}`);
|
|
462
|
+
}
|
|
463
|
+
for (const w of entry.plan.windows) {
|
|
464
|
+
const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
|
|
465
|
+
const bar = `${fmt.escape(w.label.padEnd(6))}${w.bar} ${String(w.percent).padStart(3)}%`;
|
|
466
|
+
lines.push(` ${fmt.code(bar)}${reset}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return lines.join("\n");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Settings panel body — model, effort, and the pulse row. */
|
|
474
|
+
export function renderSettingsText(
|
|
475
|
+
fmt: ReportFormatter,
|
|
476
|
+
model: string,
|
|
477
|
+
effort: string,
|
|
478
|
+
proactive: boolean,
|
|
479
|
+
pulseIntervalMs?: number,
|
|
480
|
+
modelDetails?: Array<string>,
|
|
481
|
+
): string {
|
|
482
|
+
const intervalStr = formatDuration(
|
|
483
|
+
pulseIntervalMs || DEFAULT_PULSE_INTERVAL_MS,
|
|
484
|
+
);
|
|
485
|
+
return [
|
|
486
|
+
fmt.bold("🦅 Settings"),
|
|
487
|
+
"",
|
|
488
|
+
`${fmt.bold("Model:")} ${fmt.code(fmt.escape(formatModelLabel(model)))}`,
|
|
489
|
+
...(modelDetails?.length ? modelDetails.map((d) => fmt.escape(d)) : []),
|
|
490
|
+
`${fmt.bold("Effort:")} ${effort}`,
|
|
491
|
+
`${fmt.bold(fmt.pulseLabel)} ${proactive ? "on" : "off"} (every ${intervalStr})`,
|
|
492
|
+
].join("\n");
|
|
493
|
+
}
|
|
@@ -20,7 +20,7 @@ import { clearHistory } from "../../storage/history.js";
|
|
|
20
20
|
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
21
21
|
import { resetPulseCheckpoint } from "../../core/background/pulse/pulse.js";
|
|
22
22
|
import { isPulseEnabled } from "../../core/background/pulse/pulse.js";
|
|
23
|
-
import { getWorkspaceDiskUsage } from "../../
|
|
23
|
+
import { getWorkspaceDiskUsage } from "../../core/vfs/workspace.js";
|
|
24
24
|
import { appendDailyLog } from "../../storage/daily-log.js";
|
|
25
25
|
import { resolveActiveModelForChat } from "../../core/models/active-model.js";
|
|
26
26
|
import { getPooledBackend } from "../../core/engine/backend-controller/index.js";
|
|
@@ -9,8 +9,8 @@ import { resolveModel } from "../../core/models/catalog.js";
|
|
|
9
9
|
import { resolveChatBackend } from "../../core/engine/backend-controller/index.js";
|
|
10
10
|
import { getSessionInfo } from "../../storage/sessions.js";
|
|
11
11
|
import { getChatSettings } from "../../storage/chat-settings.js";
|
|
12
|
-
import { performSessionReset } from "../
|
|
13
|
-
import { buildCacheDisplay } from "../
|
|
12
|
+
import { performSessionReset } from "../presentation/session-status.js";
|
|
13
|
+
import { buildCacheDisplay } from "../presentation/status-context.js";
|
|
14
14
|
import { buildAdaptiveCard } from "./formatting.js";
|
|
15
15
|
import type { ChatMessage } from "./graph.js";
|
|
16
16
|
import { postCard } from "./outbound.js";
|
|
@@ -12,7 +12,7 @@ import { getAllSessions } from "../../../storage/sessions.js";
|
|
|
12
12
|
import { todayLogDate } from "../../../storage/daily-log.js";
|
|
13
13
|
import { getActiveCount } from "../../../core/engine/dispatcher.js";
|
|
14
14
|
import { getHealthStatus, getRecentErrors } from "../../../util/watchdog.js";
|
|
15
|
-
import { formatDuration } from "
|
|
15
|
+
import { formatDuration } from "../../presentation/format.js";
|
|
16
16
|
|
|
17
17
|
export async function replyStats(ctx: Context): Promise<void> {
|
|
18
18
|
const h = getHealthStatus();
|
|
@@ -8,7 +8,7 @@ import { escapeHtml } from "../formatting.js";
|
|
|
8
8
|
import { resetSession, getAllSessions } from "../../../storage/sessions.js";
|
|
9
9
|
import { clearHistory } from "../../../storage/history.js";
|
|
10
10
|
import { getChatSettings } from "../../../storage/chat-settings.js";
|
|
11
|
-
import { formatModelLabel } from "
|
|
11
|
+
import { formatModelLabel } from "../../presentation/format.js";
|
|
12
12
|
import { replyHtmlChunked } from "./chunked-reply.js";
|
|
13
13
|
|
|
14
14
|
/** `/admin chats` — every active session, newest first, titled via getChat. */
|
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
type EffortLevel,
|
|
10
10
|
} from "../../../storage/chat-settings.js";
|
|
11
11
|
import { getBackendIdForChat } from "../../../core/engine/backend-controller/index.js";
|
|
12
|
-
import { renderEffortRows } from "../
|
|
12
|
+
import { renderEffortRows } from "../render/menu.js";
|
|
13
13
|
import { resolveBackendForChat } from "../model-menu.js";
|
|
14
14
|
import {
|
|
15
15
|
displayReasoningEffort,
|
|
16
16
|
getActiveReasoningLevels,
|
|
17
17
|
supportsReasoningLevel,
|
|
18
|
-
} from "../../
|
|
18
|
+
} from "../../presentation/reasoning-levels.js";
|
|
19
19
|
import { answerCallbackQuerySafe, type CallbackDeps } from "./query.js";
|
|
20
20
|
|
|
21
21
|
export async function handleEffortCallback(
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
renderMetricsKeyboard,
|
|
13
13
|
renderMetricsPanel,
|
|
14
14
|
type MetricsView,
|
|
15
|
-
} from "../
|
|
15
|
+
} from "../render/reports.js";
|
|
16
16
|
import { isAuthorizedAdmin } from "../commands/state.js";
|
|
17
17
|
import { answerCallbackQuerySafe, editOrIgnoreSame } from "./query.js";
|
|
18
18
|
|
|
@@ -7,7 +7,7 @@ import type { Context } from "grammy";
|
|
|
7
7
|
import type { TalonConfig } from "../../../core/config/index.js";
|
|
8
8
|
import type { Backend } from "../../../core/agent-runtime/capabilities.js";
|
|
9
9
|
import { logWarn } from "../../../util/log.js";
|
|
10
|
-
import type { SettingsButton } from "../
|
|
10
|
+
import type { SettingsButton } from "../render/menu.js";
|
|
11
11
|
|
|
12
12
|
export type CallbackDeps = {
|
|
13
13
|
config: TalonConfig;
|
|
@@ -20,14 +20,14 @@ import {
|
|
|
20
20
|
renderSettingsText,
|
|
21
21
|
renderSettingsKeyboard,
|
|
22
22
|
type SettingsButton,
|
|
23
|
-
} from "../
|
|
23
|
+
} from "../render/menu.js";
|
|
24
24
|
import { resolveBackendForChat } from "../model-menu.js";
|
|
25
25
|
import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
|
|
26
26
|
import {
|
|
27
27
|
displayReasoningEffort,
|
|
28
28
|
getActiveReasoningLevels,
|
|
29
29
|
supportsReasoningLevel,
|
|
30
|
-
} from "../../
|
|
30
|
+
} from "../../presentation/reasoning-levels.js";
|
|
31
31
|
import { answerCallbackQuerySafe, type CallbackDeps } from "./query.js";
|
|
32
32
|
|
|
33
33
|
export async function handleSettingsCallback(
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Bot } from "grammy";
|
|
10
10
|
import type { TalonConfig } from "../../../core/config/index.js";
|
|
11
|
-
import { respawnSelf } from "../../../
|
|
11
|
+
import { respawnSelf } from "../../../core/daemon/respawn.js";
|
|
12
12
|
import { isStaleCommand } from "../stale-command.js";
|
|
13
13
|
import {
|
|
14
14
|
getRepoRoot,
|
|
@@ -17,14 +17,14 @@ import {
|
|
|
17
17
|
import { forceDream } from "../../../core/background/dream/index.js";
|
|
18
18
|
import { escapeHtml } from "../formatting.js";
|
|
19
19
|
import { closestMatch } from "../../../native/strsim.js";
|
|
20
|
+
import { formatDuration } from "../../presentation/format.js";
|
|
20
21
|
import {
|
|
21
|
-
formatDuration,
|
|
22
22
|
renderDoctorMessage,
|
|
23
23
|
renderMetricsKeyboard,
|
|
24
24
|
renderMetricsPanel,
|
|
25
25
|
renderUsageMessage,
|
|
26
|
-
} from "../
|
|
27
|
-
import { collectPlanUsage } from "../../
|
|
26
|
+
} from "../render/reports.js";
|
|
27
|
+
import { collectPlanUsage } from "../../presentation/plan-usage-report.js";
|
|
28
28
|
import { collectDoctorReport } from "../../../core/doctor/index.js";
|
|
29
29
|
import { handleAdminCommand } from "../admin.js";
|
|
30
30
|
import { getTodayMetrics } from "../../../storage/metrics.js";
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
import type { Bot, Context } from "grammy";
|
|
6
6
|
import { isUserClientReady } from "../userbot.js";
|
|
7
7
|
import { escapeHtml } from "../formatting.js";
|
|
8
|
+
import { formatDuration } from "../../presentation/format.js";
|
|
8
9
|
import {
|
|
9
|
-
formatDuration,
|
|
10
10
|
renderMeshPairLink,
|
|
11
11
|
renderMeshReport,
|
|
12
12
|
type MeshReachability,
|
|
13
|
-
} from "../
|
|
13
|
+
} from "../render/reports.js";
|
|
14
14
|
import { isAuthorizedAdmin } from "./state.js";
|
|
15
15
|
import { getLoadedPlugins } from "../../../core/plugin/index.js";
|
|
16
16
|
import { getMeshService } from "../../../core/mesh/index.js";
|