talon-agent 1.34.0 → 1.35.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -100,8 +100,8 @@
100
100
  "@grammyjs/transformer-throttler": "^1.2.1",
101
101
  "@kilocode/sdk": "^7.2.22",
102
102
  "@modelcontextprotocol/sdk": "^1.29.0",
103
- "@openai/agents": "^0.12.0",
104
- "@openai/codex-sdk": "^0.142.0",
103
+ "@openai/agents": "^0.13.0",
104
+ "@openai/codex-sdk": "^0.143.0",
105
105
  "@opencode-ai/sdk": "^1.17.4",
106
106
  "@playwright/mcp": "^0.0.77",
107
107
  "@types/cross-spawn": "^6.0.6",
package/src/bootstrap.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  initTriggers,
29
29
  resumeAfterRestart as resumeTriggersAfterRestart,
30
30
  } from "./core/background/triggers/index.js";
31
- import { initDream } from "./core/background/dream.js";
31
+ import { initDream, maybeStartDream } from "./core/background/dream.js";
32
32
  import { initHeartbeat } from "./core/background/heartbeat/index.js";
33
33
  import { log, logWarn, logDebug } from "./util/log.js";
34
34
  import type { TalonConfig } from "./util/config.js";
@@ -409,6 +409,10 @@ export async function initBackendAndDispatcher(
409
409
  chatId,
410
410
  ),
411
411
  onActivity: () => resetPulseTimer(),
412
+ // Turn-start hook: fire-and-forget dream (background memory
413
+ // consolidation) check. Wired here so the Weaver stays ignorant of
414
+ // the dream subsystem.
415
+ onTurnStart: maybeStartDream,
412
416
  });
413
417
 
414
418
  initPulse();
@@ -16,6 +16,7 @@
16
16
  * - `skills` — markdown workflows
17
17
  * - `plugins` — plugin hot-reload
18
18
  * - `models` — model / backend discovery
19
+ * - `mesh` — companion device mesh (presence + location)
19
20
  */
20
21
 
21
22
  import type { ActionResult } from "../../types.js";
@@ -30,6 +31,7 @@ import { scriptHandlers } from "./scripts.js";
30
31
  import { skillHandlers } from "./skills.js";
31
32
  import { pluginHandlers } from "./plugins.js";
32
33
  import { modelHandlers } from "./models.js";
34
+ import { meshHandlers } from "./mesh.js";
33
35
 
34
36
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
35
37
  // can't resolve an inherited Object.prototype method — `handlers[action]` only
@@ -44,6 +46,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
44
46
  ...skillHandlers,
45
47
  ...pluginHandlers,
46
48
  ...modelHandlers,
49
+ ...meshHandlers,
47
50
  });
48
51
 
49
52
  export async function handleSharedAction(
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Device mesh — read tools (list_devices, get_device_location,
3
+ * get_device_history) and command tools (ring, live status).
4
+ *
5
+ * Shared actions, so the model has full mesh access from every frontend
6
+ * (Telegram, Discord, Teams, terminal, native) — not just chats running
7
+ * through the native bridge. The mesh itself is daemon-wide state
8
+ * (core/mesh); the native bridge is merely the transport companions
9
+ * register through and commands travel over.
10
+ */
11
+
12
+ import { getMeshService } from "../../mesh/index.js";
13
+ import type { SharedActionHandlers } from "./types.js";
14
+
15
+ export const meshHandlers: SharedActionHandlers = {
16
+ list_devices: () => getMeshService().describeDevices(),
17
+ get_device_location: (body) => getMeshService().locateDevice(body.device),
18
+ get_device_history: (body) =>
19
+ getMeshService().deviceHistory(body.device, body.hours),
20
+ ring_device: (body) => getMeshService().ringDevice(body.device, body.message),
21
+ get_device_status: (body) => getMeshService().getDeviceStatus(body.device),
22
+ };
@@ -16,7 +16,7 @@ import {
16
16
  import pRetry, { AbortError } from "p-retry";
17
17
  import { classify } from "../errors.js";
18
18
  import { getActiveCount } from "./dispatcher.js";
19
- import { Loom, getActiveLoom } from "../weaver/index.js";
19
+ import { Loom, getActiveLoom, type ContextRegistry } from "../weaver/index.js";
20
20
  import { getHealthStatus } from "../../util/watchdog.js";
21
21
  import { getActiveSessionCount } from "../../storage/sessions.js";
22
22
  import { log, logError, logDebug } from "../../util/log.js";
@@ -89,13 +89,14 @@ export async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
89
89
  export class Gateway {
90
90
  /**
91
91
  * Per-chat live state is owned by the Weaver's Loom. The gateway holds no
92
- * registry of its own — it delegates here. `getActiveLoom()` returns the
93
- * Weaver's Loom once the dispatcher is wired; the standalone fallback covers
94
- * unit tests and the brief startup window before init (when no turn — and so
95
- * no context can exist anyway).
92
+ * registry of its own — it delegates here, and only through the
93
+ * `ContextRegistry` face (the gateway never creates or evicts Threads).
94
+ * `getActiveLoom()` returns the Weaver's Loom once the dispatcher is wired;
95
+ * the standalone fallback covers unit tests and the brief startup window
96
+ * before init (when no turn — and so no context — can exist anyway).
96
97
  */
97
98
  private readonly ownLoom = new Loom();
98
- private get loom(): Loom {
99
+ private get loom(): ContextRegistry {
99
100
  return getActiveLoom() ?? this.ownLoom;
100
101
  }
101
102
 
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Device mesh — core module barrel.
3
+ *
4
+ * Registry (persistence) + service (policy, locate fan-out, tool surface) +
5
+ * canonical device/location types. Transports and gateway actions import
6
+ * from here.
7
+ */
8
+
9
+ export { MeshRegistry } from "./registry.js";
10
+ export {
11
+ MeshService,
12
+ getMeshService,
13
+ setMeshService,
14
+ type MeshTransport,
15
+ type MeshServiceOptions,
16
+ type MeshToolResult,
17
+ } from "./service.js";
18
+ export {
19
+ sanitizeCapabilities,
20
+ toDeviceInfo,
21
+ toDeviceLocation,
22
+ type DeviceCommand,
23
+ type DeviceCommandResult,
24
+ type DeviceInfo,
25
+ type DeviceLocation,
26
+ type DevicePlatform,
27
+ } from "./types.js";
@@ -0,0 +1,272 @@
1
+ /**
2
+ * MeshRegistry — persistent storage for companion devices, last-known
3
+ * locations, and a bounded per-device location/battery history.
4
+ *
5
+ * Pure state: validate, upsert, persist (0600 JSON sidecars under the Talon
6
+ * root). No transport and no waiting logic — that policy lives in
7
+ * MeshService, so the registry stays trivially testable.
8
+ */
9
+
10
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
11
+ import { dirname, resolve } from "node:path";
12
+ import { dirs } from "../../util/paths.js";
13
+ import {
14
+ sanitizeCapabilities,
15
+ toDeviceInfo,
16
+ toDeviceLocation,
17
+ type DeviceInfo,
18
+ type DeviceLocation,
19
+ type DevicePlatform,
20
+ } from "./types.js";
21
+
22
+ const PRESENCE_TIMEOUT_MS = 90_000;
23
+ const DEVICE_FILE = resolve(dirs.root, "mesh-devices.json");
24
+ const LOCATION_FILE = resolve(dirs.root, "mesh-locations.json");
25
+ const HISTORY_FILE = resolve(dirs.root, "mesh-history.json");
26
+ /** Newest fixes kept per device (~a day of 5-minute periodic reports). */
27
+ const MAX_HISTORY_PER_DEVICE = 500;
28
+ const PLATFORMS = new Set<DevicePlatform>([
29
+ "android",
30
+ "macos",
31
+ "windows",
32
+ "linux",
33
+ "ios",
34
+ ]);
35
+
36
+ export class MeshRegistry {
37
+ private devices = new Map<string, DeviceInfo>();
38
+ private locations = new Map<string, DeviceLocation>();
39
+ private history = new Map<string, DeviceLocation[]>();
40
+
41
+ constructor(
42
+ private readonly files = {
43
+ devices: DEVICE_FILE,
44
+ locations: LOCATION_FILE,
45
+ history: HISTORY_FILE,
46
+ },
47
+ ) {}
48
+
49
+ async load(): Promise<void> {
50
+ this.devices = new Map(
51
+ (await readArray<DeviceInfo>(this.files.devices))
52
+ .map((d) => sanitizeDevice(d, d.lastSeen, false))
53
+ .filter((d): d is DeviceInfo => d !== null)
54
+ .map((d) => [d.id, d]),
55
+ );
56
+ this.locations = new Map(
57
+ (await readArray<DeviceLocation>(this.files.locations))
58
+ .map(sanitizeLocation)
59
+ .filter((l): l is DeviceLocation => l !== null)
60
+ .map((l) => [l.deviceId, l]),
61
+ );
62
+ this.history = new Map();
63
+ for (const fix of (await readArray<DeviceLocation>(this.files.history))
64
+ .map(sanitizeLocation)
65
+ .filter((l): l is DeviceLocation => l !== null)) {
66
+ this.appendHistory(fix);
67
+ }
68
+ }
69
+
70
+ async register(
71
+ body: Record<string, unknown>,
72
+ now = Date.now(),
73
+ ): Promise<DeviceInfo> {
74
+ const next = sanitizeDevice(body, now, true);
75
+ if (!next) throw new Error("Invalid device registration");
76
+ const prev = this.devices.get(next.id);
77
+ const device = toDeviceInfo(
78
+ {
79
+ ...prev,
80
+ ...next,
81
+ online: true,
82
+ lastSeen: now,
83
+ },
84
+ now,
85
+ );
86
+ this.devices.set(device.id, device);
87
+ await this.persistDevices();
88
+ return device;
89
+ }
90
+
91
+ async storeLocation(
92
+ body: Record<string, unknown>,
93
+ now = Date.now(),
94
+ ): Promise<DeviceLocation> {
95
+ const loc = sanitizeLocation(body);
96
+ if (!loc) throw new Error("Invalid device location");
97
+ this.locations.set(loc.deviceId, loc);
98
+ const device = this.devices.get(loc.deviceId);
99
+ if (device) {
100
+ this.devices.set(
101
+ loc.deviceId,
102
+ toDeviceInfo(
103
+ {
104
+ ...device,
105
+ online: true,
106
+ lastSeen: now,
107
+ ...(typeof loc.batteryPct === "number"
108
+ ? { battery: loc.batteryPct }
109
+ : {}),
110
+ },
111
+ now,
112
+ ),
113
+ );
114
+ await this.persistDevices();
115
+ }
116
+ this.appendHistory(loc);
117
+ await this.persistLocations();
118
+ await this.persistHistory();
119
+ return loc;
120
+ }
121
+
122
+ /** Fixes for one device since `sinceTs`, oldest first. */
123
+ getHistory(deviceId: string, sinceTs = 0): DeviceLocation[] {
124
+ return (this.history.get(deviceId) ?? [])
125
+ .filter((l) => l.ts >= sinceTs)
126
+ .map(toDeviceLocation);
127
+ }
128
+
129
+ /** Append one fix to the device's rolling history (dedup on timestamp,
130
+ * chronological order, bounded to MAX_HISTORY_PER_DEVICE). */
131
+ private appendHistory(fix: DeviceLocation): void {
132
+ const list = this.history.get(fix.deviceId) ?? [];
133
+ if (list.some((l) => l.ts === fix.ts)) return;
134
+ list.push(toDeviceLocation(fix));
135
+ list.sort((a, b) => a.ts - b.ts);
136
+ if (list.length > MAX_HISTORY_PER_DEVICE) {
137
+ list.splice(0, list.length - MAX_HISTORY_PER_DEVICE);
138
+ }
139
+ this.history.set(fix.deviceId, list);
140
+ }
141
+
142
+ list(now = Date.now()): {
143
+ devices: DeviceInfo[];
144
+ locations: DeviceLocation[];
145
+ } {
146
+ return {
147
+ devices: [...this.devices.values()]
148
+ .map((d) => toDeviceInfo(d, now, PRESENCE_TIMEOUT_MS))
149
+ .sort((a, b) => b.lastSeen - a.lastSeen),
150
+ locations: [...this.locations.values()]
151
+ .map(toDeviceLocation)
152
+ .sort((a, b) => b.ts - a.ts),
153
+ };
154
+ }
155
+
156
+ getLocation(deviceId: string): DeviceLocation | undefined {
157
+ const loc = this.locations.get(deviceId);
158
+ return loc ? toDeviceLocation(loc) : undefined;
159
+ }
160
+
161
+ private async persistDevices(): Promise<void> {
162
+ await writePrivateJson(this.files.devices, [...this.devices.values()]);
163
+ }
164
+
165
+ private async persistLocations(): Promise<void> {
166
+ await writePrivateJson(this.files.locations, [...this.locations.values()]);
167
+ }
168
+
169
+ private async persistHistory(): Promise<void> {
170
+ await writePrivateJson(
171
+ this.files.history,
172
+ [...this.history.values()].flat(),
173
+ );
174
+ }
175
+ }
176
+
177
+ async function readArray<T>(path: string): Promise<T[]> {
178
+ try {
179
+ const raw = await readFile(path, "utf8");
180
+ const parsed = JSON.parse(raw) as unknown;
181
+ return Array.isArray(parsed) ? (parsed as T[]) : [];
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
186
+
187
+ async function writePrivateJson(path: string, value: unknown): Promise<void> {
188
+ await mkdir(dirname(path), { recursive: true });
189
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
190
+ }
191
+
192
+ function sanitizeDevice(
193
+ body: Record<string, unknown>,
194
+ now: number,
195
+ requireCore: boolean,
196
+ ): DeviceInfo | null {
197
+ const id = stringField(body.id);
198
+ const name = stringField(body.name);
199
+ const appVersion = stringField(body.appVersion);
200
+ const platform = stringField(body.platform) as DevicePlatform | undefined;
201
+ if ((requireCore || id !== undefined) && !id) return null;
202
+ if ((requireCore || name !== undefined) && !name) return null;
203
+ if ((requireCore || appVersion !== undefined) && !appVersion) return null;
204
+ if (
205
+ (requireCore || platform !== undefined) &&
206
+ (!platform || !PLATFORMS.has(platform))
207
+ ) {
208
+ return null;
209
+ }
210
+ return {
211
+ id: id ?? "",
212
+ name: name ?? "",
213
+ platform: platform ?? "linux",
214
+ appVersion: appVersion ?? "",
215
+ online: body.online === true,
216
+ lastSeen: numberField(body.lastSeen) ?? now,
217
+ ...(numberField(body.battery) !== undefined
218
+ ? { battery: numberField(body.battery) }
219
+ : {}),
220
+ ...(typeof body.charging === "boolean" ? { charging: body.charging } : {}),
221
+ ...(sanitizeCapabilities(body.capabilities)
222
+ ? { capabilities: sanitizeCapabilities(body.capabilities) }
223
+ : {}),
224
+ };
225
+ }
226
+
227
+ function sanitizeLocation(
228
+ body: Record<string, unknown>,
229
+ ): DeviceLocation | null {
230
+ const deviceId = stringField(body.deviceId);
231
+ const lat = numberField(body.lat);
232
+ const lon = numberField(body.lon);
233
+ const ts = numberField(body.ts);
234
+ if (!deviceId || !Number.isFinite(lat) || !Number.isFinite(lon) || !ts) {
235
+ return null;
236
+ }
237
+ if (lat! < -90 || lat! > 90 || lon! < -180 || lon! > 180) return null;
238
+ return toDeviceLocation({
239
+ deviceId,
240
+ lat: lat!,
241
+ lon: lon!,
242
+ ...(numberField(body.accuracyM) !== undefined
243
+ ? { accuracyM: numberField(body.accuracyM) }
244
+ : {}),
245
+ ...(numberField(body.altitudeM) !== undefined
246
+ ? { altitudeM: numberField(body.altitudeM) }
247
+ : {}),
248
+ ...(numberField(body.speedMps) !== undefined
249
+ ? { speedMps: numberField(body.speedMps) }
250
+ : {}),
251
+ ...(numberField(body.headingDeg) !== undefined
252
+ ? { headingDeg: numberField(body.headingDeg) }
253
+ : {}),
254
+ ts,
255
+ ...(stringField(body.provider)
256
+ ? { provider: stringField(body.provider) }
257
+ : {}),
258
+ ...(numberField(body.batteryPct) !== undefined
259
+ ? { batteryPct: numberField(body.batteryPct) }
260
+ : {}),
261
+ });
262
+ }
263
+
264
+ function stringField(value: unknown): string | undefined {
265
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
266
+ }
267
+
268
+ function numberField(value: unknown): number | undefined {
269
+ return typeof value === "number" && Number.isFinite(value)
270
+ ? value
271
+ : undefined;
272
+ }