@adecore/agents 0.46.1 → 0.46.3

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.
@@ -82,6 +82,7 @@ export interface ChatCoreOptions {
82
82
  };
83
83
  accounts?: AccountLaunches;
84
84
  hooks?: ChatCoreHooks;
85
+ unloadAfterMs?: number;
85
86
  }
86
87
  export declare class ChatCore {
87
88
  protected readonly bookmarks: BookmarkStore | null;
@@ -123,6 +124,9 @@ export declare class ChatCore {
123
124
  private readonly nameChat;
124
125
  private readonly onInterruptedRun;
125
126
  private readonly limitResume;
127
+ private readonly unloadAfterMs;
128
+ private readonly unloadTimers;
129
+ private readonly unloadedInfo;
126
130
  constructor(options: ChatCoreOptions);
127
131
  codexProcess(info: Pick<ChatInfo, 'cwd' | 'account'>): CodexProcessSpec;
128
132
  claudeProjectsDirOf(info: Pick<ChatInfo, 'account'>): string;
@@ -257,6 +261,11 @@ export declare class ChatCore {
257
261
  updatedAt: number;
258
262
  }[];
259
263
  list(): ChatInfo[];
264
+ unloadIdle(chatId: string): Promise<boolean>;
265
+ private unloadable;
266
+ private scheduleUnload;
267
+ private cancelUnload;
268
+ private forgetLoaded;
260
269
  get(chatId: string): ChatSession | undefined;
261
270
  resumeRun(chatId: string, turnId: string, attempt: number): Promise<void>;
262
271
  takeUpAfterLimit(chatId: string, turnId: string): Promise<OutboxOutcome>;
@@ -94,6 +94,10 @@ export class ChatCore {
94
94
  nameChat;
95
95
  onInterruptedRun;
96
96
  limitResume;
97
+ unloadAfterMs;
98
+ unloadTimers = new Map();
99
+ // The last info of each chat that left memory, so `list` still answers what it was doing.
100
+ unloadedInfo = new Map();
97
101
  constructor(options) {
98
102
  this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
99
103
  this.uiFenceLanguage = options.uiFenceLanguage;
@@ -111,6 +115,7 @@ export class ChatCore {
111
115
  this.nameChat = options.nameChat ?? null;
112
116
  this.onInterruptedRun = options.onInterruptedRun ?? null;
113
117
  this.limitResume = options.limitResume ?? null;
118
+ this.unloadAfterMs = options.store && options.unloadAfterMs !== undefined ? options.unloadAfterMs : null;
114
119
  this.hooks = options.hooks ?? {};
115
120
  this.commands = {
116
121
  ...(options.command ? { claude: options.command } : {}),
@@ -325,12 +330,15 @@ export class ChatCore {
325
330
  ...(this.limitResume ? { limitResume: limitHooks(this.limitResume, payload.chatId) } : {})
326
331
  });
327
332
  this.chats.set(session.id, session);
333
+ this.unloadedInfo.delete(session.id);
328
334
  if (stored) {
329
335
  session.settleStored(selection, resume);
330
336
  session.settleOwedResume();
331
337
  await session.settleOrphanedSubagents();
332
338
  }
333
339
  await this.hooks.opened?.(session, stored ?? null);
340
+ // A chat loaded for someone who never attaches, such as a host reading it, leaves again on its own.
341
+ this.scheduleUnload(session.id);
334
342
  return session.info;
335
343
  }
336
344
  async storedBackground(chatId) {
@@ -392,6 +400,7 @@ export class ChatCore {
392
400
  // client is on the list: the text is either in the snapshot or in the stream, never both.
393
401
  this.coalescers.get(chatId)?.flush();
394
402
  clients.add(clientId);
403
+ this.cancelUnload(chatId);
395
404
  const log = this.logs.get(chatId);
396
405
  const events = since === undefined ? null : log.after(since);
397
406
  if (events !== null) {
@@ -509,12 +518,14 @@ export class ChatCore {
509
518
  detach(chatId, clientId) {
510
519
  this.coalescers.get(chatId)?.flush();
511
520
  this.attached.get(chatId)?.delete(clientId);
521
+ this.scheduleUnload(chatId);
512
522
  }
513
523
  detachAll(clientId) {
514
524
  for (const [chatId, clients] of this.attached) {
515
525
  if (clients.has(clientId)) {
516
526
  this.coalescers.get(chatId)?.flush();
517
527
  clients.delete(clientId);
528
+ this.scheduleUnload(chatId);
518
529
  }
519
530
  }
520
531
  this.subagents.releaseClient(clientId);
@@ -613,6 +624,7 @@ export class ChatCore {
613
624
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
614
625
  return false;
615
626
  }
627
+ this.unloadedInfo.delete(chatId);
616
628
  await Promise.all([
617
629
  this.store?.delete(chatId),
618
630
  this.attachments.removeAll(chatId),
@@ -897,21 +909,9 @@ export class ChatCore {
897
909
  // A write already out would put the record back after the delete.
898
910
  const writing = this.writes.get(chatId);
899
911
  const ended = session.dispose();
900
- this.subagents.releaseChat(chatId);
901
- this.uiQueries?.forget(chatId);
902
- this.coalescers.get(chatId)?.dispose();
903
- this.coalescers.delete(chatId);
904
- this.logs.get(chatId)?.close();
905
- this.logs.delete(chatId);
906
- this.cancelWaiting(chatId);
907
- this.sizes.delete(chatId);
908
- this.unlogged.delete(chatId);
909
- this.writes.delete(chatId);
910
- this.activity.delete(chatId);
911
- this.chats.delete(chatId);
912
+ this.forgetLoaded(chatId);
912
913
  this.attached.delete(chatId);
913
- this.listChanges.delete(chatId);
914
- this.announced.delete(chatId);
914
+ this.unloadedInfo.delete(chatId);
915
915
  const snapshot = session.thread.snapshot();
916
916
  this.hooks.forgotten?.(chatId);
917
917
  await writing;
@@ -944,7 +944,75 @@ export class ChatCore {
944
944
  return targets;
945
945
  }
946
946
  list() {
947
- return [...this.chats.values()].map((session) => session.info);
947
+ return [...[...this.chats.values()].map((session) => session.info), ...this.unloadedInfo.values()];
948
+ }
949
+ /*
950
+ * Puts a chat nobody needs in memory back on disk alone: nobody reads it, it has no CLI, no turn,
951
+ * request, hold or move is under way, and no resume is owed. Answers whether it left. The next
952
+ * create, attach or note loads it again from its record, as after a restart.
953
+ */
954
+ async unloadIdle(chatId) {
955
+ this.cancelUnload(chatId);
956
+ if (!this.unloadable(chatId)) {
957
+ return false;
958
+ }
959
+ await this.persistNow(chatId, true);
960
+ const session = this.chats.get(chatId);
961
+ // Someone may have attached or sent while the record was written.
962
+ if (!session || !this.unloadable(chatId)) {
963
+ return false;
964
+ }
965
+ this.forgetLoaded(chatId);
966
+ this.attached.delete(chatId);
967
+ this.unloadedInfo.set(chatId, session.info);
968
+ await session.dispose();
969
+ return true;
970
+ }
971
+ unloadable(chatId) {
972
+ const session = this.chats.get(chatId);
973
+ return (session !== undefined &&
974
+ this.store !== null &&
975
+ (this.attached.get(chatId)?.size ?? 0) === 0 &&
976
+ !this.creating.has(chatId) &&
977
+ !this.relocations.has(chatId) &&
978
+ session.pid === null &&
979
+ session.relocatable &&
980
+ session.info.resumeAt === undefined &&
981
+ (session.info.requests?.length ?? 0) === 0);
982
+ }
983
+ scheduleUnload(chatId) {
984
+ if (this.unloadAfterMs === null || (this.attached.get(chatId)?.size ?? 0) > 0) {
985
+ return;
986
+ }
987
+ this.cancelUnload(chatId);
988
+ const timer = setTimeout(() => {
989
+ this.unloadTimers.delete(chatId);
990
+ void this.unloadIdle(chatId).catch((e) => console.error(`Unloading chat ${chatId} failed:`, errorText(e)));
991
+ }, this.unloadAfterMs);
992
+ timer.unref?.();
993
+ this.unloadTimers.set(chatId, timer);
994
+ }
995
+ cancelUnload(chatId) {
996
+ clearTimeout(this.unloadTimers.get(chatId));
997
+ this.unloadTimers.delete(chatId);
998
+ }
999
+ /* Drops what the core keeps of a loaded chat beside its session, which a kill and an unload share. */
1000
+ forgetLoaded(chatId) {
1001
+ this.subagents.releaseChat(chatId);
1002
+ this.uiQueries?.forget(chatId);
1003
+ this.coalescers.get(chatId)?.dispose();
1004
+ this.coalescers.delete(chatId);
1005
+ this.logs.get(chatId)?.close();
1006
+ this.logs.delete(chatId);
1007
+ this.cancelWaiting(chatId);
1008
+ this.cancelUnload(chatId);
1009
+ this.sizes.delete(chatId);
1010
+ this.unlogged.delete(chatId);
1011
+ this.writes.delete(chatId);
1012
+ this.activity.delete(chatId);
1013
+ this.chats.delete(chatId);
1014
+ this.listChanges.delete(chatId);
1015
+ this.announced.delete(chatId);
948
1016
  }
949
1017
  get(chatId) {
950
1018
  return this.chats.get(chatId);
@@ -1032,6 +1100,7 @@ export class ChatCore {
1032
1100
  }
1033
1101
  for (const chatId of this.chats.keys()) {
1034
1102
  this.cancelWaiting(chatId);
1103
+ this.cancelUnload(chatId);
1035
1104
  }
1036
1105
  // A thread that cannot be written still ends its CLI, whose process group would otherwise outlive the host.
1037
1106
  const written = await Promise.allSettled([...this.chats.keys()].map((chatId) => this.persistNow(chatId, true)));
@@ -1173,6 +1242,8 @@ export class ChatCore {
1173
1242
  for (const clientId of this.attached.get(chatId) ?? []) {
1174
1243
  this.sinks.to(clientId, { event: 'chat.event', payload });
1175
1244
  }
1245
+ // A chat nobody reads counts its time from what it said last, such as its CLI going down.
1246
+ this.scheduleUnload(chatId);
1176
1247
  // After the thread, so a client reading it has already had this info and the status is a no-op there.
1177
1248
  if (event.type === 'info' || event.type === 'reset') {
1178
1249
  this.announceStatus(chatId, event.info);
@@ -8,7 +8,7 @@ import { CHAT_PAYLOAD_INLINE_BYTES, ChatCheckpointDiffSchema } from '@adecore/ag
8
8
  import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.js';
9
9
  import { ChatError } from './errors.js';
10
10
  /* How much of a value stored apart the item keeps: what a timeline shows before anyone asks for the rest. */
11
- export const PAYLOAD_PREVIEW_BYTES = 8 * 1024;
11
+ export const PAYLOAD_PREVIEW_BYTES = 2 * 1024;
12
12
  const FOLDER_SUFFIX = '.payloads';
13
13
  const ID_PATTERN = /^[0-9a-f]{64}$/;
14
14
  const CONTINUATION_MASK = 0xc0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.46.1",
3
+ "version": "0.46.3",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -93,8 +93,8 @@
93
93
  "test": "bun --conditions=source test"
94
94
  },
95
95
  "dependencies": {
96
- "@adecore/agent-contracts": "0.46.1",
97
- "@adecore/intelligent-ui": "0.46.1",
96
+ "@adecore/agent-contracts": "0.46.3",
97
+ "@adecore/intelligent-ui": "0.46.3",
98
98
  "zod": "^4.6.7"
99
99
  },
100
100
  "devDependencies": {
@@ -215,6 +215,12 @@ export interface ChatCoreOptions {
215
215
  // The accounts a chat may run under; without them every chat runs under its CLI's default account.
216
216
  accounts?: AccountLaunches;
217
217
  hooks?: ChatCoreHooks;
218
+ /*
219
+ * How long a chat stays in memory once nobody reads it, its CLI is gone and nothing is under way;
220
+ * after that only its info stays, for `list`, until something loads it again. Without it, or
221
+ * without a store, a loaded chat stays until it is removed or the host stops.
222
+ */
223
+ unloadAfterMs?: number;
218
224
  }
219
225
 
220
226
  /*
@@ -272,6 +278,10 @@ export class ChatCore {
272
278
  private readonly nameChat: ChatCoreOptions['nameChat'] | null;
273
279
  private readonly onInterruptedRun: ChatCoreOptions['onInterruptedRun'] | null;
274
280
  private readonly limitResume: ChatCoreOptions['limitResume'] | null;
281
+ private readonly unloadAfterMs: number | null;
282
+ private readonly unloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
283
+ // The last info of each chat that left memory, so `list` still answers what it was doing.
284
+ private readonly unloadedInfo = new Map<string, ChatInfo>();
275
285
 
276
286
  constructor(options: ChatCoreOptions) {
277
287
  this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
@@ -290,6 +300,7 @@ export class ChatCore {
290
300
  this.nameChat = options.nameChat ?? null;
291
301
  this.onInterruptedRun = options.onInterruptedRun ?? null;
292
302
  this.limitResume = options.limitResume ?? null;
303
+ this.unloadAfterMs = options.store && options.unloadAfterMs !== undefined ? options.unloadAfterMs : null;
293
304
  this.hooks = options.hooks ?? {};
294
305
 
295
306
  this.commands = {
@@ -549,6 +560,7 @@ export class ChatCore {
549
560
  });
550
561
 
551
562
  this.chats.set(session.id, session);
563
+ this.unloadedInfo.delete(session.id);
552
564
 
553
565
  if (stored) {
554
566
  session.settleStored(selection, resume);
@@ -557,6 +569,8 @@ export class ChatCore {
557
569
  }
558
570
 
559
571
  await this.hooks.opened?.(session, stored ?? null);
572
+ // A chat loaded for someone who never attaches, such as a host reading it, leaves again on its own.
573
+ this.scheduleUnload(session.id);
560
574
 
561
575
  return session.info;
562
576
  }
@@ -642,6 +656,7 @@ export class ChatCore {
642
656
  // client is on the list: the text is either in the snapshot or in the stream, never both.
643
657
  this.coalescers.get(chatId)?.flush();
644
658
  clients.add(clientId);
659
+ this.cancelUnload(chatId);
645
660
  const log = this.logs.get(chatId)!;
646
661
  const events = since === undefined ? null : log.after(since);
647
662
 
@@ -797,6 +812,7 @@ export class ChatCore {
797
812
  detach(chatId: string, clientId: string): void {
798
813
  this.coalescers.get(chatId)?.flush();
799
814
  this.attached.get(chatId)?.delete(clientId);
815
+ this.scheduleUnload(chatId);
800
816
  }
801
817
 
802
818
  detachAll(clientId: string): void {
@@ -804,6 +820,7 @@ export class ChatCore {
804
820
  if (clients.has(clientId)) {
805
821
  this.coalescers.get(chatId)?.flush();
806
822
  clients.delete(clientId);
823
+ this.scheduleUnload(chatId);
807
824
  }
808
825
  }
809
826
 
@@ -931,6 +948,8 @@ export class ChatCore {
931
948
  return false;
932
949
  }
933
950
 
951
+ this.unloadedInfo.delete(chatId);
952
+
934
953
  await Promise.all([
935
954
  this.store?.delete(chatId),
936
955
  this.attachments.removeAll(chatId),
@@ -1295,21 +1314,9 @@ export class ChatCore {
1295
1314
  // A write already out would put the record back after the delete.
1296
1315
  const writing = this.writes.get(chatId);
1297
1316
  const ended = session.dispose();
1298
- this.subagents.releaseChat(chatId);
1299
- this.uiQueries?.forget(chatId);
1300
- this.coalescers.get(chatId)?.dispose();
1301
- this.coalescers.delete(chatId);
1302
- this.logs.get(chatId)?.close();
1303
- this.logs.delete(chatId);
1304
- this.cancelWaiting(chatId);
1305
- this.sizes.delete(chatId);
1306
- this.unlogged.delete(chatId);
1307
- this.writes.delete(chatId);
1308
- this.activity.delete(chatId);
1309
- this.chats.delete(chatId);
1317
+ this.forgetLoaded(chatId);
1310
1318
  this.attached.delete(chatId);
1311
- this.listChanges.delete(chatId);
1312
- this.announced.delete(chatId);
1319
+ this.unloadedInfo.delete(chatId);
1313
1320
  const snapshot = session.thread.snapshot();
1314
1321
  this.hooks.forgotten?.(chatId);
1315
1322
  await writing;
@@ -1349,7 +1356,91 @@ export class ChatCore {
1349
1356
  }
1350
1357
 
1351
1358
  list(): ChatInfo[] {
1352
- return [...this.chats.values()].map((session) => session.info);
1359
+ return [...[...this.chats.values()].map((session) => session.info), ...this.unloadedInfo.values()];
1360
+ }
1361
+
1362
+ /*
1363
+ * Puts a chat nobody needs in memory back on disk alone: nobody reads it, it has no CLI, no turn,
1364
+ * request, hold or move is under way, and no resume is owed. Answers whether it left. The next
1365
+ * create, attach or note loads it again from its record, as after a restart.
1366
+ */
1367
+ async unloadIdle(chatId: string): Promise<boolean> {
1368
+ this.cancelUnload(chatId);
1369
+
1370
+ if (!this.unloadable(chatId)) {
1371
+ return false;
1372
+ }
1373
+
1374
+ await this.persistNow(chatId, true);
1375
+ const session = this.chats.get(chatId);
1376
+
1377
+ // Someone may have attached or sent while the record was written.
1378
+ if (!session || !this.unloadable(chatId)) {
1379
+ return false;
1380
+ }
1381
+
1382
+ this.forgetLoaded(chatId);
1383
+ this.attached.delete(chatId);
1384
+ this.unloadedInfo.set(chatId, session.info);
1385
+ await session.dispose();
1386
+
1387
+ return true;
1388
+ }
1389
+
1390
+ private unloadable(chatId: string): boolean {
1391
+ const session = this.chats.get(chatId);
1392
+
1393
+ return (
1394
+ session !== undefined &&
1395
+ this.store !== null &&
1396
+ (this.attached.get(chatId)?.size ?? 0) === 0 &&
1397
+ !this.creating.has(chatId) &&
1398
+ !this.relocations.has(chatId) &&
1399
+ session.pid === null &&
1400
+ session.relocatable &&
1401
+ session.info.resumeAt === undefined &&
1402
+ (session.info.requests?.length ?? 0) === 0
1403
+ );
1404
+ }
1405
+
1406
+ private scheduleUnload(chatId: string): void {
1407
+ if (this.unloadAfterMs === null || (this.attached.get(chatId)?.size ?? 0) > 0) {
1408
+ return;
1409
+ }
1410
+
1411
+ this.cancelUnload(chatId);
1412
+
1413
+ const timer = setTimeout(() => {
1414
+ this.unloadTimers.delete(chatId);
1415
+ void this.unloadIdle(chatId).catch((e) => console.error(`Unloading chat ${chatId} failed:`, errorText(e)));
1416
+ }, this.unloadAfterMs);
1417
+
1418
+ timer.unref?.();
1419
+ this.unloadTimers.set(chatId, timer);
1420
+ }
1421
+
1422
+ private cancelUnload(chatId: string): void {
1423
+ clearTimeout(this.unloadTimers.get(chatId));
1424
+ this.unloadTimers.delete(chatId);
1425
+ }
1426
+
1427
+ /* Drops what the core keeps of a loaded chat beside its session, which a kill and an unload share. */
1428
+ private forgetLoaded(chatId: string): void {
1429
+ this.subagents.releaseChat(chatId);
1430
+ this.uiQueries?.forget(chatId);
1431
+ this.coalescers.get(chatId)?.dispose();
1432
+ this.coalescers.delete(chatId);
1433
+ this.logs.get(chatId)?.close();
1434
+ this.logs.delete(chatId);
1435
+ this.cancelWaiting(chatId);
1436
+ this.cancelUnload(chatId);
1437
+ this.sizes.delete(chatId);
1438
+ this.unlogged.delete(chatId);
1439
+ this.writes.delete(chatId);
1440
+ this.activity.delete(chatId);
1441
+ this.chats.delete(chatId);
1442
+ this.listChanges.delete(chatId);
1443
+ this.announced.delete(chatId);
1353
1444
  }
1354
1445
 
1355
1446
  get(chatId: string): ChatSession | undefined {
@@ -1460,6 +1551,7 @@ export class ChatCore {
1460
1551
 
1461
1552
  for (const chatId of this.chats.keys()) {
1462
1553
  this.cancelWaiting(chatId);
1554
+ this.cancelUnload(chatId);
1463
1555
  }
1464
1556
 
1465
1557
  // A thread that cannot be written still ends its CLI, whose process group would otherwise outlive the host.
@@ -1649,6 +1741,9 @@ export class ChatCore {
1649
1741
  this.sinks.to(clientId, { event: 'chat.event', payload });
1650
1742
  }
1651
1743
 
1744
+ // A chat nobody reads counts its time from what it said last, such as its CLI going down.
1745
+ this.scheduleUnload(chatId);
1746
+
1652
1747
  // After the thread, so a client reading it has already had this info and the status is a no-op there.
1653
1748
  if (event.type === 'info' || event.type === 'reset') {
1654
1749
  this.announceStatus(chatId, event.info);
@@ -9,7 +9,7 @@ import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.ts';
9
9
  import { ChatError } from './errors.ts';
10
10
 
11
11
  /* How much of a value stored apart the item keeps: what a timeline shows before anyone asks for the rest. */
12
- export const PAYLOAD_PREVIEW_BYTES = 8 * 1024;
12
+ export const PAYLOAD_PREVIEW_BYTES = 2 * 1024;
13
13
 
14
14
  const FOLDER_SUFFIX = '.payloads';
15
15
  const ID_PATTERN = /^[0-9a-f]{64}$/;