talon-agent 3.33.4 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/storage/metrics.ts +18 -0
  69. package/src/storage/session-record.ts +29 -0
  70. package/src/storage/sessions.ts +24 -0
  71. package/src/util/boot-timer.ts +31 -0
  72. package/src/util/concurrency.ts +28 -0
  73. package/src/frontend/discord/callbacks/components.ts +0 -793
  74. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -0,0 +1,74 @@
1
+ /**
2
+ * WhatsApp frontend runtime — the state every module shares.
3
+ *
4
+ * `createWhatsAppFrontend` used to hold all of this as closure variables
5
+ * with every handler nested inside it. It is now one explicit object,
6
+ * constructed once, that each module (access, inbound, connection) takes
7
+ * as its first parameter. The runtime carries the parsed settings, the
8
+ * allow-lists and the live socket state; the modules own the behaviour.
9
+ */
10
+
11
+ import type { WASocket } from "baileys";
12
+ import type { TalonConfig } from "../../util/config.js";
13
+ import type { Gateway } from "../../core/engine/gateway.js";
14
+ import { bareId } from "./identity.js";
15
+
16
+ type WhatsAppSettings = {
17
+ allowedJids: string[];
18
+ allowedGroups: string[];
19
+ groupPolicy: "listed" | "with-allowed-user" | "all";
20
+ respondMode: "mention" | "all";
21
+ pairingNumber?: string;
22
+ sendReadReceipts: boolean;
23
+ };
24
+
25
+ /** Reconnect backoff: WhatsApp throttles a client that hammers it. */
26
+ export const RECONNECT_BASE_MS = 2_000;
27
+ export const RECONNECT_MAX_MS = 60_000;
28
+
29
+ export type WhatsAppRuntime = {
30
+ readonly config: TalonConfig;
31
+ readonly gateway: Gateway;
32
+ readonly settings: WhatsAppSettings;
33
+ /** `allowedJids` / `allowedGroups`, normalised to bare ids. */
34
+ readonly allowedDms: ReadonlySet<string>;
35
+ readonly allowedGroups: ReadonlySet<string>;
36
+ /** Cached `groupPolicy` verdicts, keyed by group JID. */
37
+ readonly groupAllowCache: Map<string, { allowed: boolean; at: number }>;
38
+ /** The live socket. Reconnects replace it, so read it at each use. */
39
+ sock: WASocket | null;
40
+ stopping: boolean;
41
+ reconnectDelay: number;
42
+ /** One "not linked" admin note per outage, not one per QR window. */
43
+ unpairedNotified: boolean;
44
+ /** Our own ids (phone and LID), once connected — for mention detection. */
45
+ selfIds: string[];
46
+ };
47
+
48
+ export function createWhatsAppRuntime(
49
+ config: TalonConfig,
50
+ gateway: Gateway,
51
+ ): WhatsAppRuntime {
52
+ const settings: WhatsAppSettings = {
53
+ allowedJids: [],
54
+ allowedGroups: [],
55
+ groupPolicy: "listed",
56
+ respondMode: "mention",
57
+ sendReadReceipts: true,
58
+ ...((config as Record<string, unknown>).whatsapp as
59
+ Partial<WhatsAppSettings> | undefined),
60
+ };
61
+ return {
62
+ config,
63
+ gateway,
64
+ settings,
65
+ allowedDms: new Set(settings.allowedJids.map(bareId)),
66
+ allowedGroups: new Set(settings.allowedGroups.map(bareId)),
67
+ groupAllowCache: new Map(),
68
+ sock: null,
69
+ stopping: false,
70
+ reconnectDelay: RECONNECT_BASE_MS,
71
+ unpairedNotified: false,
72
+ selfIds: [],
73
+ };
74
+ }
@@ -9,6 +9,16 @@ import {
9
9
  type MetricsGrain,
10
10
  type MetricsLatencyAgg,
11
11
  } from "./sessions.js";
12
+ import { emptyPhases, TURN_PHASES, type TurnPhase } from "./session-record.js";
13
+
14
+ /** Snapshot key for each turn phase — same `_ms` suffix the panels key on. */
15
+ const PHASE_HISTOGRAM: Record<TurnPhase, string> = {
16
+ queueWait: "turn.queue_wait_ms",
17
+ warpResolve: "turn.warp_resolve_ms",
18
+ firstToken: "turn.first_token_ms",
19
+ stream: "turn.stream_ms",
20
+ delivery: "turn.delivery_ms",
21
+ };
12
22
 
13
23
  const legacyCounters = new Map<string, number>();
14
24
 
@@ -64,8 +74,12 @@ function buildSnapshot(
64
74
  const apiCallsPerTurn = emptyAgg();
65
75
  const cacheHitPercent = emptyAgg();
66
76
  const backendLatency = new Map<string, MetricsLatencyAgg>();
77
+ const phases = emptyPhases();
67
78
 
68
79
  for (const grain of grains) {
80
+ for (const phase of TURN_PHASES) {
81
+ mergeAgg(phases[phase], grain.phases[phase]);
82
+ }
69
83
  const c = grain.counters;
70
84
  addCounter(counters, "queries_total", c.queries);
71
85
  addCounter(counters, "turns_with_tools_total", c.turnsWithTools);
@@ -131,6 +145,10 @@ function buildSnapshot(
131
145
  if (agg.count)
132
146
  histograms[`backend.${backend}.response_latency_ms`] = snapshotAgg(agg);
133
147
  }
148
+ for (const phase of TURN_PHASES) {
149
+ if (phases[phase].count)
150
+ histograms[PHASE_HISTOGRAM[phase]] = snapshotAgg(phases[phase]);
151
+ }
134
152
 
135
153
  return { counters, histograms };
136
154
  }
@@ -54,6 +54,21 @@ export type MetricsLatencyAgg = {
54
54
  maxMs: number;
55
55
  };
56
56
 
57
+ /**
58
+ * Where a turn's wall-clock went, as seen from the Weaver: waiting in the
59
+ * chat's FIFO, resolving the warp, until the backend's first event, the
60
+ * whole stream, and the slice of it spent inside frontend delivery.
61
+ */
62
+ export const TURN_PHASES = [
63
+ "queueWait",
64
+ "warpResolve",
65
+ "firstToken",
66
+ "stream",
67
+ "delivery",
68
+ ] as const;
69
+ export type TurnPhase = (typeof TURN_PHASES)[number];
70
+ export type MetricsPhaseAggs = Record<TurnPhase, MetricsLatencyAgg>;
71
+
57
72
  export type MetricsGrain = {
58
73
  counters: MetricsCounterSet;
59
74
  latency: MetricsLatencyAgg;
@@ -62,6 +77,7 @@ export type MetricsGrain = {
62
77
  cacheHitPercent: MetricsLatencyAgg;
63
78
  toolCallsPerTurn: MetricsLatencyAgg;
64
79
  apiCallsPerTurn: MetricsLatencyAgg;
80
+ phases: MetricsPhaseAggs;
65
81
  };
66
82
 
67
83
  export type SessionMetrics = {
@@ -127,6 +143,14 @@ export const emptyLatency = (): MetricsLatencyAgg => ({
127
143
  maxMs: 0,
128
144
  });
129
145
 
146
+ export const emptyPhases = (): MetricsPhaseAggs => ({
147
+ queueWait: emptyLatency(),
148
+ warpResolve: emptyLatency(),
149
+ firstToken: emptyLatency(),
150
+ stream: emptyLatency(),
151
+ delivery: emptyLatency(),
152
+ });
153
+
130
154
  export const emptyGrain = (): MetricsGrain => ({
131
155
  counters: emptyCounters(),
132
156
  latency: emptyLatency(),
@@ -135,6 +159,7 @@ export const emptyGrain = (): MetricsGrain => ({
135
159
  cacheHitPercent: emptyLatency(),
136
160
  toolCallsPerTurn: emptyLatency(),
137
161
  apiCallsPerTurn: emptyLatency(),
162
+ phases: emptyPhases(),
138
163
  });
139
164
 
140
165
  export const emptyMetrics = (): SessionMetrics => ({
@@ -179,6 +204,10 @@ function normaliseGrain(raw: unknown): MetricsGrain {
179
204
  grain.cacheHitPercent = normaliseLatency(r?.cacheHitPercent);
180
205
  grain.toolCallsPerTurn = normaliseLatency(r?.toolCallsPerTurn);
181
206
  grain.apiCallsPerTurn = normaliseLatency(r?.apiCallsPerTurn);
207
+ const phases = r?.phases as Partial<Record<TurnPhase, unknown>> | undefined;
208
+ for (const phase of TURN_PHASES) {
209
+ grain.phases[phase] = normaliseLatency(phases?.[phase]);
210
+ }
182
211
  if (r?.toolCallsByName && typeof r.toolCallsByName === "object") {
183
212
  for (const [key, value] of Object.entries(r.toolCallsByName)) {
184
213
  if (typeof value === "number" && Number.isFinite(value)) {
@@ -42,6 +42,8 @@ import {
42
42
  type SessionMetrics,
43
43
  type SessionState,
44
44
  type SessionUsage,
45
+ TURN_PHASES,
46
+ type TurnPhase,
45
47
  } from "./session-record.js";
46
48
  // In-memory cache over the sessions table. Reads serve live references
47
49
  // from here; writes go through persist() so each mutation commits.
@@ -365,6 +367,28 @@ export function recordSessionMetrics(
365
367
  persist(chatId, session);
366
368
  }
367
369
 
370
+ /**
371
+ * Fold one turn's phase timings (ms) into the session's lifetime grain and
372
+ * today's bucket. Phases the caller could not measure are simply absent.
373
+ */
374
+ export function recordSessionTurnPhases(
375
+ chatId: string,
376
+ phases: Partial<Record<TurnPhase, number>>,
377
+ day = todayUtc(),
378
+ ): void {
379
+ const session = getSession(chatId);
380
+ for (const grain of [
381
+ session.metrics.lifetime,
382
+ metricBucket(session.metrics, day),
383
+ ]) {
384
+ for (const phase of TURN_PHASES) {
385
+ const value = phases[phase];
386
+ if (value !== undefined) addLatency(grain.phases[phase], value);
387
+ }
388
+ }
389
+ persist(chatId, session);
390
+ }
391
+
368
392
  export function recordSessionMetricEvent(
369
393
  chatId: string,
370
394
  event: Partial<MetricsCounterSet> & {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Boot phase timer — one entry per awaited startup phase, reported once
3
+ * the daemon is serving, so "why did it take nine seconds to come up"
4
+ * has an answer in the log instead of a profiler session.
5
+ */
6
+
7
+ const phases: { label: string; ms: number }[] = [];
8
+
9
+ /** Run one startup phase and remember how long it took. */
10
+ export async function bootPhase<T>(
11
+ label: string,
12
+ fn: () => Promise<T> | T,
13
+ ): Promise<T> {
14
+ const startedAt = Date.now();
15
+ try {
16
+ return await fn();
17
+ } finally {
18
+ phases.push({ label, ms: Date.now() - startedAt });
19
+ }
20
+ }
21
+
22
+ /** `4210ms (plugins 120ms, stores 15ms, frontends 300ms, …)` — total is process uptime. */
23
+ export function bootReport(now = Math.round(process.uptime() * 1000)): string {
24
+ const parts = phases.map((phase) => `${phase.label} ${phase.ms}ms`);
25
+ return parts.length ? `${now}ms (${parts.join(", ")})` : `${now}ms`;
26
+ }
27
+
28
+ /** Test seam. */
29
+ export function resetBootPhases(): void {
30
+ phases.length = 0;
31
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Bounded-concurrency map: run `fn` over `items` with at most `limit`
3
+ * in flight, preserving result order. Rejections propagate after the
4
+ * batch drains so a single failure does not orphan in-flight work.
5
+ */
6
+ export async function mapConcurrent<T, R>(
7
+ items: readonly T[],
8
+ limit: number,
9
+ fn: (item: T, index: number) => Promise<R>,
10
+ ): Promise<R[]> {
11
+ const results: R[] = new Array(items.length);
12
+ const errors: unknown[] = [];
13
+ let next = 0;
14
+ const worker = async (): Promise<void> => {
15
+ while (next < items.length) {
16
+ const index = next++;
17
+ try {
18
+ results[index] = await fn(items[index] as T, index);
19
+ } catch (err) {
20
+ errors.push(err);
21
+ }
22
+ }
23
+ };
24
+ const width = Math.max(1, Math.min(limit, items.length));
25
+ await Promise.all(Array.from({ length: width }, worker));
26
+ if (errors.length > 0) throw errors[0];
27
+ return results;
28
+ }