talon-agent 3.9.1 → 3.10.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": "3.9.1",
3
+ "version": "3.10.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",
@@ -27,6 +27,14 @@ export type ChatEntry = {
27
27
  createdAt: number;
28
28
  lastActive: number;
29
29
  preview: string;
30
+ /**
31
+ * False until a message actually lands in this chat (see {@link
32
+ * NativeChats.touch}). "New chat" creates a real chat the moment it's
33
+ * tapped, so a client that opens one and backs out leaves a permanent empty
34
+ * row in every list — this flag is what lets the sweeper tell "never used"
35
+ * apart from "used, then reset" (a reset chat has no history either).
36
+ */
37
+ used: boolean;
30
38
  };
31
39
 
32
40
  /** The placeholder title a chat carries until it earns a real one. */
@@ -55,6 +63,9 @@ export class NativeChats {
55
63
  createdAt: info.createdAt || Date.now(),
56
64
  lastActive: info.lastActive || Date.now(),
57
65
  preview: recent.length ? previewOf(recent[recent.length - 1].text) : "",
66
+ // Restored from a persisted session: it has carried a turn, even if a
67
+ // later reset cleared its history.
68
+ used: true,
58
69
  };
59
70
  this.index(entry);
60
71
  }
@@ -70,6 +81,7 @@ export class NativeChats {
70
81
  createdAt: Date.now(),
71
82
  lastActive: Date.now(),
72
83
  preview: "",
84
+ used: false,
73
85
  };
74
86
  this.index(entry);
75
87
  if (trimmed) setSessionName(id, trimmed);
@@ -87,6 +99,7 @@ export class NativeChats {
87
99
  createdAt: Date.now(),
88
100
  lastActive: Date.now(),
89
101
  preview: "",
102
+ used: false,
90
103
  };
91
104
  this.index(entry);
92
105
  return entry;
@@ -135,10 +148,26 @@ export class NativeChats {
135
148
  const entry = this.byId.get(id);
136
149
  if (!entry) return undefined;
137
150
  entry.lastActive = Date.now();
151
+ entry.used = true;
138
152
  if (preview !== undefined) entry.preview = previewOf(preview);
139
153
  return entry;
140
154
  }
141
155
 
156
+ /**
157
+ * Chats that were created but never carried a message, and have been that
158
+ * way for at least [minAgeMs] — the "New chat" someone opened and left.
159
+ * Oldest first, so a sweep drops the stalest rows first.
160
+ *
161
+ * Emptiness is judged by {@link ChatEntry.used}, not by history: a chat
162
+ * whose session was reset has no history either, and deleting THAT would
163
+ * throw away a conversation the user deliberately kept.
164
+ */
165
+ unused(minAgeMs: number, now: number = Date.now()): ChatEntry[] {
166
+ return [...this.byId.values()]
167
+ .filter((e) => !e.used && now - e.createdAt >= minAgeMs)
168
+ .sort((a, b) => a.createdAt - b.createdAt);
169
+ }
170
+
142
171
  private index(entry: ChatEntry): void {
143
172
  this.byId.set(entry.id, entry);
144
173
  this.byNum.set(entry.numericId, entry);
@@ -332,12 +332,17 @@ export function createNativeFrontend(
332
332
  */
333
333
  async function computeContext(
334
334
  chatId: string,
335
+ opts?: { resolveModel?: boolean },
335
336
  ): Promise<ContextInfo | undefined> {
336
337
  try {
337
338
  const info = getSessionInfo(chatId);
338
339
  const u = info.usage;
339
340
  let ctxMax = u.contextWindow;
340
- if (!ctxMax) {
341
+ // The model fallback touches the backend pool, which is fine for one
342
+ // chat the user just opened and wrong for every chat at boot — the
343
+ // startup warm asks for the cheap path and lets anything it can't
344
+ // resolve fill in when that chat is next opened.
345
+ if (!ctxMax && opts?.resolveModel !== false) {
341
346
  try {
342
347
  const backend = getBackendForChat(chatId);
343
348
  const backendId = getBackendIdForChat(chatId);
@@ -371,8 +376,11 @@ export function createNativeFrontend(
371
376
  }
372
377
 
373
378
  /** Recompute a chat's context fill and, if it changed, push it to clients. */
374
- async function refreshContext(entry: ChatEntry): Promise<void> {
375
- const next = await computeContext(entry.id);
379
+ async function refreshContext(
380
+ entry: ChatEntry,
381
+ opts?: { resolveModel?: boolean },
382
+ ): Promise<void> {
383
+ const next = await computeContext(entry.id, opts);
376
384
  if (!next) return;
377
385
  const prev = contextByChat.get(entry.id);
378
386
  if (prev && prev.used === next.used && prev.max === next.max) return;
@@ -380,6 +388,26 @@ export function createNativeFrontend(
380
388
  broadcastChatUpdated(entry);
381
389
  }
382
390
 
391
+ /**
392
+ * Fill the context cache for chats restored at startup.
393
+ *
394
+ * The readout is served from an in-memory map that was only ever written at
395
+ * turn end, so after a restart every existing chat reported no context at
396
+ * all — the header chip vanished until that chat ran another turn, which
397
+ * read as "context usage doesn't save". The numbers themselves are
398
+ * persisted with the session; only the cache was cold. This re-reads them
399
+ * for the most recently active chats (bounded, and on the cheap path that
400
+ * never touches the backend pool); anything it skips or can't resolve is
401
+ * filled in the moment the chat is opened.
402
+ */
403
+ const CONTEXT_WARM_LIMIT = 40;
404
+
405
+ async function warmContextCache(): Promise<void> {
406
+ for (const entry of chats.list().slice(0, CONTEXT_WARM_LIMIT)) {
407
+ await refreshContext(entry, { resolveModel: false });
408
+ }
409
+ }
410
+
383
411
  const broadcast = (event: BridgeEvent): void => server.broadcast(event);
384
412
 
385
413
  // The most recent assistant message id per chat — turn_end attaches the
@@ -1006,6 +1034,34 @@ export function createNativeFrontend(
1006
1034
  }
1007
1035
  }
1008
1036
 
1037
+ // ── Empty-chat sweeper ───────────────────────────────────────────────────
1038
+ //
1039
+ // "New chat" creates a real chat on the daemon the instant it's tapped, so
1040
+ // opening one and backing out of it leaves an empty row in every client's
1041
+ // list forever. The app deletes an untouched chat as soon as you leave it,
1042
+ // which covers the normal case immediately; this is the backstop for the
1043
+ // ones no client got to clean up — the app was killed, the network dropped,
1044
+ // or the chat was created by something that never came back.
1045
+ //
1046
+ // Deliberately conservative: only chats that never carried a single message
1047
+ // (see ChatEntry.used, which a reset does NOT clear), only after an hour,
1048
+ // never one with a turn running or a message queued, and never one whose
1049
+ // history says otherwise. Deleting via the same handler every client uses
1050
+ // means the removal is broadcast, so open lists update in place.
1051
+ const EMPTY_CHAT_MIN_AGE_MS = 60 * 60_000;
1052
+ const EMPTY_CHAT_SWEEP_INTERVAL_MS = 30 * 60_000;
1053
+ let emptyChatSweep: ReturnType<typeof setInterval> | null = null;
1054
+
1055
+ function sweepEmptyChats(): void {
1056
+ for (const entry of chats.unused(EMPTY_CHAT_MIN_AGE_MS)) {
1057
+ if (isBusy(entry.id) || queuedByChat.has(entry.id)) continue;
1058
+ if (getRecentHistory(entry.id, 1).length > 0) continue;
1059
+ if (handlers.deleteChat(entry.id)) {
1060
+ log("native", `Swept empty chat ${entry.id}`);
1061
+ }
1062
+ }
1063
+ }
1064
+
1009
1065
  // ── Bridge server wiring ─────────────────────────────────────────────────
1010
1066
 
1011
1067
  const handlers: BridgeServerHandlers = {
@@ -1035,6 +1091,14 @@ export function createNativeFrontend(
1035
1091
  return ok;
1036
1092
  },
1037
1093
  history: (id, opts) => {
1094
+ // Opening a chat is the one moment we know a client wants its numbers:
1095
+ // refresh the context readout on the full path (model fallback and
1096
+ // all), which covers every chat the startup warm skipped or couldn't
1097
+ // resolve. Fire-and-forget — the figure arrives as a chat_updated.
1098
+ const entry = chats.get(id);
1099
+ if (entry && opts?.before === undefined) {
1100
+ void refreshContext(entry).catch(() => {});
1101
+ }
1038
1102
  const limit = Math.min(Math.max(opts?.limit ?? 200, 1), 500);
1039
1103
  const rows =
1040
1104
  opts?.before !== undefined
@@ -1292,6 +1356,19 @@ export function createNativeFrontend(
1292
1356
  const gatewayPort = await gateway.start(19876);
1293
1357
  log("native", `Gateway on :${gatewayPort}`);
1294
1358
  chats.restore();
1359
+ // Non-blocking: a cold cache costs a missing chip, not a broken boot.
1360
+ void warmContextCache().catch((err) =>
1361
+ logError("native", "Context cache warm failed", err),
1362
+ );
1363
+ emptyChatSweep = setInterval(() => {
1364
+ try {
1365
+ sweepEmptyChats();
1366
+ } catch (err) {
1367
+ logError("native", "Empty-chat sweep failed", err);
1368
+ }
1369
+ }, EMPTY_CHAT_SWEEP_INTERVAL_MS);
1370
+ // Housekeeping must never be the reason the process stays alive.
1371
+ emptyChatSweep.unref?.();
1295
1372
  await server.start();
1296
1373
  const fingerprint = server.getFingerprint();
1297
1374
  // Tell the mesh how this bridge is reachable — everything a generated
@@ -1320,6 +1397,10 @@ export function createNativeFrontend(
1320
1397
  },
1321
1398
 
1322
1399
  async stop() {
1400
+ if (emptyChatSweep) {
1401
+ clearInterval(emptyChatSweep);
1402
+ emptyChatSweep = null;
1403
+ }
1323
1404
  unregisterMeshTransport?.();
1324
1405
  unregisterMeshTransport = null;
1325
1406
  mesh.setBridgeInfo(null);