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
|
@@ -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
|
-
|
|
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(
|
|
375
|
-
|
|
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);
|