@adecore/agents 0.46.2 → 0.47.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.
@@ -1,4 +1,4 @@
1
- import { type AgentKind, type ChatAttachment, type ChatAttachResult, type ChatAttachmentUpload, type ChatBookmark, type ChatCheckpointDiff, type ChatConfigurePayload, type ChatCreatePayload, type ChatEvent, type ChatHistoryResult, type ChatGoalAction, type ChatInfo, type ChatItem, type ChatQueuedMessage, type ChatUiChoicePayload, type ChatUiQueryPayload, type ChatUiLinkPayload, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
1
+ import { type AgentKind, type ChatAttachment, type ChatAttachResult, type ChatAttachmentUpload, type ChatBookmark, type ChatCheckpointDiff, type ChatConfigurePayload, type ChatCreatePayload, type ChatEvent, type ChatHistoryResult, type ChatGoalAction, type ChatInfo, type ChatItem, type ChatNoteMessage, type ChatQueuedMessage, type ChatUiChoicePayload, type ChatUiQueryPayload, type ChatUiLinkPayload, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { AgentEvent, AgentSink } from '../events.ts';
3
3
  import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
4
4
  import { type AccountLaunches } from '../providers/accounts/launch.ts';
@@ -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;
@@ -176,7 +180,7 @@ export declare class ChatCore {
176
180
  writeRecord(chatId: string, info: ChatInfo, items: ChatItem[], preambles?: string[]): Promise<void>;
177
181
  deleteRecord(chatId: string): Promise<boolean>;
178
182
  lastTurn(chatId: string): Promise<ChatTurnItem | null>;
179
- addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void>;
183
+ addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): Promise<void>;
180
184
  deliverNote(chatId: string, delivery: {
181
185
  noteId: string;
182
186
  note: 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),
@@ -631,7 +643,7 @@ export class ChatCore {
631
643
  return stored?.items.findLast((item) => item.kind === 'turn') ?? null;
632
644
  }
633
645
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
634
- async addNote(chatId, level, text) {
646
+ async addNote(chatId, level, text, message) {
635
647
  await this.loaded(chatId);
636
648
  if (!(await this.hasStored(chatId))) {
637
649
  return;
@@ -639,7 +651,7 @@ export class ChatCore {
639
651
  if (!this.chats.has(chatId)) {
640
652
  await this.create({ chatId });
641
653
  }
642
- this.chats.get(chatId)?.addNote(level, text);
654
+ this.chats.get(chatId)?.addNote(level, text, message);
643
655
  }
644
656
  /*
645
657
  * Leaves a note and its preamble in a chat, loading that chat when nobody has; false when it was
@@ -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);
@@ -1,4 +1,4 @@
1
- import type { ChatAttachment, ChatCheckpointDiff, ChatEvent, ChatGoalAction, ChatInfo, ChatItem, ChatQueuedMessage, ChatQuoteSource, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
1
+ import type { ChatAttachment, ChatCheckpointDiff, ChatEvent, ChatGoalAction, ChatInfo, ChatItem, ChatNoteMessage, ChatQueuedMessage, ChatQuoteSource, ChatSkill, ChatUiChoiceOrigin, ChatUiChoicePayload, ChatUiQueryState, ModelSelection, RuntimeMode } from '@adecore/agent-contracts';
2
2
  import type { ChatProvider } from '../providers/provider.ts';
3
3
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
4
4
  import type { BackendLaunch } from './backend.ts';
@@ -211,7 +211,7 @@ export declare class ChatSession {
211
211
  preamble: string;
212
212
  }): boolean;
213
213
  upsertItems(items: readonly ChatItem[]): void;
214
- addNote(level: 'info' | 'warning' | 'error', text: string): void;
214
+ addNote(level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): void;
215
215
  end(reason: string): void;
216
216
  restart(): Promise<void>;
217
217
  dropProcess(): Promise<void>;
@@ -1057,8 +1057,18 @@ export class ChatSession {
1057
1057
  this.options.persistSoon();
1058
1058
  }
1059
1059
  /* A line in the thread outside any turn, for something the host has to say about work it gave up on. */
1060
- addNote(level, text) {
1061
- this.emit([this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: Date.now(), turnId: null, level, text })]);
1060
+ addNote(level, text, message) {
1061
+ this.emit([
1062
+ this.thread.upsert({
1063
+ id: newId('note'),
1064
+ kind: 'note',
1065
+ createdAt: Date.now(),
1066
+ turnId: null,
1067
+ level,
1068
+ text,
1069
+ ...(message === undefined ? {} : { message })
1070
+ })
1071
+ ]);
1062
1072
  this.options.persist();
1063
1073
  }
1064
1074
  /*
@@ -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;
@@ -1,4 +1,4 @@
1
- import type { ChatItem } from '@adecore/agent-contracts';
1
+ import type { ChatItem, ChatNoteMessage } from '@adecore/agent-contracts';
2
2
  import type { ChatCore } from '../chat/chat-core.ts';
3
3
  import type { Notice, NoticeStore } from './notice-store.ts';
4
4
  export interface MessageWords {
@@ -20,6 +20,7 @@ export declare function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStor
20
20
  export declare function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery>;
21
21
  export interface NoticeChat {
22
22
  has(id: string): Promise<boolean>;
23
- note(id: string, text: string): Promise<void>;
23
+ note(id: string, text: string, message: ChatNoteMessage): Promise<void>;
24
24
  }
25
+ export declare function noticeMessage(notice: Notice): ChatNoteMessage;
25
26
  export declare function showNotices(store: Pick<NoticeStore, 'show'>, chat: NoticeChat, words: Pick<MessageWords, 'shown'>, targetId: string): Promise<void>;
@@ -57,6 +57,10 @@ export async function deliverToChat(store, targets, notice) {
57
57
  }
58
58
  return { outcome: 'wake', waiting };
59
59
  }
60
+ /* The message itself on the line in the thread, so a client can draw it as said to the chat. */
61
+ export function noticeMessage(notice) {
62
+ return { from: notice.from, title: notice.fromTitle, text: notice.text };
63
+ }
60
64
  /*
61
65
  * Shows a person the message in the chat's thread the moment it lands, not when the model gets round
62
66
  * to it. An id no chat holds shows and marks nothing, so a chat that opens on it later still has all of it.
@@ -66,6 +70,6 @@ export async function showNotices(store, chat, words, targetId) {
66
70
  return;
67
71
  }
68
72
  for (const notice of await store.show(targetId)) {
69
- await chat.note(targetId, words.shown(notice));
73
+ await chat.note(targetId, words.shown(notice), noticeMessage(notice));
70
74
  }
71
75
  }
@@ -1,5 +1,6 @@
1
+ import type { ChatNoteMessage } from '@adecore/agent-contracts';
1
2
  import type { PromptNotes } from '../chat/chat-session.ts';
2
- import type { MessageWords } from './deliver-notice.ts';
3
+ import { type MessageWords } from './deliver-notice.ts';
3
4
  import type { NoticeStore } from './notice-store.ts';
4
5
  export declare class NoticeNotes implements PromptNotes {
5
6
  private readonly store;
@@ -12,4 +13,7 @@ export declare class NoticeNotes implements PromptNotes {
12
13
  };
13
14
  reset(): void;
14
15
  }
15
- export declare function unshownNotes(store: Pick<NoticeStore, 'show'>, chatId: string, words: Pick<MessageWords, 'shown'>): Promise<string[]>;
16
+ export declare function unshownNotes(store: Pick<NoticeStore, 'show'>, chatId: string, words: Pick<MessageWords, 'shown'>): Promise<{
17
+ text: string;
18
+ message: ChatNoteMessage;
19
+ }[]>;
@@ -1,3 +1,4 @@
1
+ import { noticeMessage } from './deliver-notice.js';
1
2
  /*
2
3
  * What another node left for a chat, taken from the queue in front of its next prompt so the model
3
4
  * hears it once. Nothing of it is shown: a person read each message in the thread as it landed
@@ -22,5 +23,5 @@ export class NoticeNotes {
22
23
  * reload writes none again. The model still hears the messages themselves from the queue.
23
24
  */
24
25
  export async function unshownNotes(store, chatId, words) {
25
- return (await store.show(chatId)).map((notice) => words.shown(notice));
26
+ return (await store.show(chatId)).map((notice) => ({ text: words.shown(notice), message: noticeMessage(notice) }));
26
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.46.2",
3
+ "version": "0.47.0",
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.2",
97
- "@adecore/intelligent-ui": "0.46.2",
96
+ "@adecore/agent-contracts": "0.47.0",
97
+ "@adecore/intelligent-ui": "0.47.0",
98
98
  "zod": "^4.6.7"
99
99
  },
100
100
  "devDependencies": {
@@ -17,6 +17,7 @@ import {
17
17
  type ChatGoalAction,
18
18
  type ChatInfo,
19
19
  type ChatItem,
20
+ type ChatNoteMessage,
20
21
  type ChatQueuedMessage,
21
22
  type ChatUiChoicePayload,
22
23
  type ChatUiQueryPayload,
@@ -215,6 +216,12 @@ export interface ChatCoreOptions {
215
216
  // The accounts a chat may run under; without them every chat runs under its CLI's default account.
216
217
  accounts?: AccountLaunches;
217
218
  hooks?: ChatCoreHooks;
219
+ /*
220
+ * How long a chat stays in memory once nobody reads it, its CLI is gone and nothing is under way;
221
+ * after that only its info stays, for `list`, until something loads it again. Without it, or
222
+ * without a store, a loaded chat stays until it is removed or the host stops.
223
+ */
224
+ unloadAfterMs?: number;
218
225
  }
219
226
 
220
227
  /*
@@ -272,6 +279,10 @@ export class ChatCore {
272
279
  private readonly nameChat: ChatCoreOptions['nameChat'] | null;
273
280
  private readonly onInterruptedRun: ChatCoreOptions['onInterruptedRun'] | null;
274
281
  private readonly limitResume: ChatCoreOptions['limitResume'] | null;
282
+ private readonly unloadAfterMs: number | null;
283
+ private readonly unloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
284
+ // The last info of each chat that left memory, so `list` still answers what it was doing.
285
+ private readonly unloadedInfo = new Map<string, ChatInfo>();
275
286
 
276
287
  constructor(options: ChatCoreOptions) {
277
288
  this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
@@ -290,6 +301,7 @@ export class ChatCore {
290
301
  this.nameChat = options.nameChat ?? null;
291
302
  this.onInterruptedRun = options.onInterruptedRun ?? null;
292
303
  this.limitResume = options.limitResume ?? null;
304
+ this.unloadAfterMs = options.store && options.unloadAfterMs !== undefined ? options.unloadAfterMs : null;
293
305
  this.hooks = options.hooks ?? {};
294
306
 
295
307
  this.commands = {
@@ -549,6 +561,7 @@ export class ChatCore {
549
561
  });
550
562
 
551
563
  this.chats.set(session.id, session);
564
+ this.unloadedInfo.delete(session.id);
552
565
 
553
566
  if (stored) {
554
567
  session.settleStored(selection, resume);
@@ -557,6 +570,8 @@ export class ChatCore {
557
570
  }
558
571
 
559
572
  await this.hooks.opened?.(session, stored ?? null);
573
+ // A chat loaded for someone who never attaches, such as a host reading it, leaves again on its own.
574
+ this.scheduleUnload(session.id);
560
575
 
561
576
  return session.info;
562
577
  }
@@ -642,6 +657,7 @@ export class ChatCore {
642
657
  // client is on the list: the text is either in the snapshot or in the stream, never both.
643
658
  this.coalescers.get(chatId)?.flush();
644
659
  clients.add(clientId);
660
+ this.cancelUnload(chatId);
645
661
  const log = this.logs.get(chatId)!;
646
662
  const events = since === undefined ? null : log.after(since);
647
663
 
@@ -797,6 +813,7 @@ export class ChatCore {
797
813
  detach(chatId: string, clientId: string): void {
798
814
  this.coalescers.get(chatId)?.flush();
799
815
  this.attached.get(chatId)?.delete(clientId);
816
+ this.scheduleUnload(chatId);
800
817
  }
801
818
 
802
819
  detachAll(clientId: string): void {
@@ -804,6 +821,7 @@ export class ChatCore {
804
821
  if (clients.has(clientId)) {
805
822
  this.coalescers.get(chatId)?.flush();
806
823
  clients.delete(clientId);
824
+ this.scheduleUnload(chatId);
807
825
  }
808
826
  }
809
827
 
@@ -931,6 +949,8 @@ export class ChatCore {
931
949
  return false;
932
950
  }
933
951
 
952
+ this.unloadedInfo.delete(chatId);
953
+
934
954
  await Promise.all([
935
955
  this.store?.delete(chatId),
936
956
  this.attachments.removeAll(chatId),
@@ -955,7 +975,7 @@ export class ChatCore {
955
975
  }
956
976
 
957
977
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
958
- async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void> {
978
+ async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): Promise<void> {
959
979
  await this.loaded(chatId);
960
980
 
961
981
  if (!(await this.hasStored(chatId))) {
@@ -966,7 +986,7 @@ export class ChatCore {
966
986
  await this.create({ chatId });
967
987
  }
968
988
 
969
- this.chats.get(chatId)?.addNote(level, text);
989
+ this.chats.get(chatId)?.addNote(level, text, message);
970
990
  }
971
991
 
972
992
  /*
@@ -1295,21 +1315,9 @@ export class ChatCore {
1295
1315
  // A write already out would put the record back after the delete.
1296
1316
  const writing = this.writes.get(chatId);
1297
1317
  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);
1318
+ this.forgetLoaded(chatId);
1310
1319
  this.attached.delete(chatId);
1311
- this.listChanges.delete(chatId);
1312
- this.announced.delete(chatId);
1320
+ this.unloadedInfo.delete(chatId);
1313
1321
  const snapshot = session.thread.snapshot();
1314
1322
  this.hooks.forgotten?.(chatId);
1315
1323
  await writing;
@@ -1349,7 +1357,91 @@ export class ChatCore {
1349
1357
  }
1350
1358
 
1351
1359
  list(): ChatInfo[] {
1352
- return [...this.chats.values()].map((session) => session.info);
1360
+ return [...[...this.chats.values()].map((session) => session.info), ...this.unloadedInfo.values()];
1361
+ }
1362
+
1363
+ /*
1364
+ * Puts a chat nobody needs in memory back on disk alone: nobody reads it, it has no CLI, no turn,
1365
+ * request, hold or move is under way, and no resume is owed. Answers whether it left. The next
1366
+ * create, attach or note loads it again from its record, as after a restart.
1367
+ */
1368
+ async unloadIdle(chatId: string): Promise<boolean> {
1369
+ this.cancelUnload(chatId);
1370
+
1371
+ if (!this.unloadable(chatId)) {
1372
+ return false;
1373
+ }
1374
+
1375
+ await this.persistNow(chatId, true);
1376
+ const session = this.chats.get(chatId);
1377
+
1378
+ // Someone may have attached or sent while the record was written.
1379
+ if (!session || !this.unloadable(chatId)) {
1380
+ return false;
1381
+ }
1382
+
1383
+ this.forgetLoaded(chatId);
1384
+ this.attached.delete(chatId);
1385
+ this.unloadedInfo.set(chatId, session.info);
1386
+ await session.dispose();
1387
+
1388
+ return true;
1389
+ }
1390
+
1391
+ private unloadable(chatId: string): boolean {
1392
+ const session = this.chats.get(chatId);
1393
+
1394
+ return (
1395
+ session !== undefined &&
1396
+ this.store !== null &&
1397
+ (this.attached.get(chatId)?.size ?? 0) === 0 &&
1398
+ !this.creating.has(chatId) &&
1399
+ !this.relocations.has(chatId) &&
1400
+ session.pid === null &&
1401
+ session.relocatable &&
1402
+ session.info.resumeAt === undefined &&
1403
+ (session.info.requests?.length ?? 0) === 0
1404
+ );
1405
+ }
1406
+
1407
+ private scheduleUnload(chatId: string): void {
1408
+ if (this.unloadAfterMs === null || (this.attached.get(chatId)?.size ?? 0) > 0) {
1409
+ return;
1410
+ }
1411
+
1412
+ this.cancelUnload(chatId);
1413
+
1414
+ const timer = setTimeout(() => {
1415
+ this.unloadTimers.delete(chatId);
1416
+ void this.unloadIdle(chatId).catch((e) => console.error(`Unloading chat ${chatId} failed:`, errorText(e)));
1417
+ }, this.unloadAfterMs);
1418
+
1419
+ timer.unref?.();
1420
+ this.unloadTimers.set(chatId, timer);
1421
+ }
1422
+
1423
+ private cancelUnload(chatId: string): void {
1424
+ clearTimeout(this.unloadTimers.get(chatId));
1425
+ this.unloadTimers.delete(chatId);
1426
+ }
1427
+
1428
+ /* Drops what the core keeps of a loaded chat beside its session, which a kill and an unload share. */
1429
+ private forgetLoaded(chatId: string): void {
1430
+ this.subagents.releaseChat(chatId);
1431
+ this.uiQueries?.forget(chatId);
1432
+ this.coalescers.get(chatId)?.dispose();
1433
+ this.coalescers.delete(chatId);
1434
+ this.logs.get(chatId)?.close();
1435
+ this.logs.delete(chatId);
1436
+ this.cancelWaiting(chatId);
1437
+ this.cancelUnload(chatId);
1438
+ this.sizes.delete(chatId);
1439
+ this.unlogged.delete(chatId);
1440
+ this.writes.delete(chatId);
1441
+ this.activity.delete(chatId);
1442
+ this.chats.delete(chatId);
1443
+ this.listChanges.delete(chatId);
1444
+ this.announced.delete(chatId);
1353
1445
  }
1354
1446
 
1355
1447
  get(chatId: string): ChatSession | undefined {
@@ -1460,6 +1552,7 @@ export class ChatCore {
1460
1552
 
1461
1553
  for (const chatId of this.chats.keys()) {
1462
1554
  this.cancelWaiting(chatId);
1555
+ this.cancelUnload(chatId);
1463
1556
  }
1464
1557
 
1465
1558
  // A thread that cannot be written still ends its CLI, whose process group would otherwise outlive the host.
@@ -1649,6 +1742,9 @@ export class ChatCore {
1649
1742
  this.sinks.to(clientId, { event: 'chat.event', payload });
1650
1743
  }
1651
1744
 
1745
+ // A chat nobody reads counts its time from what it said last, such as its CLI going down.
1746
+ this.scheduleUnload(chatId);
1747
+
1652
1748
  // After the thread, so a client reading it has already had this info and the status is a no-op there.
1653
1749
  if (event.type === 'info' || event.type === 'reset') {
1654
1750
  this.announceStatus(chatId, event.info);
@@ -6,6 +6,7 @@ import type {
6
6
  ChatGoalAction,
7
7
  ChatInfo,
8
8
  ChatItem,
9
+ ChatNoteMessage,
9
10
  ChatQueuedMessage,
10
11
  ChatQuoteSource,
11
12
  ChatSkill,
@@ -1470,8 +1471,18 @@ export class ChatSession {
1470
1471
  }
1471
1472
 
1472
1473
  /* A line in the thread outside any turn, for something the host has to say about work it gave up on. */
1473
- addNote(level: 'info' | 'warning' | 'error', text: string): void {
1474
- this.emit([this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: Date.now(), turnId: null, level, text })]);
1474
+ addNote(level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): void {
1475
+ this.emit([
1476
+ this.thread.upsert({
1477
+ id: newId('note'),
1478
+ kind: 'note',
1479
+ createdAt: Date.now(),
1480
+ turnId: null,
1481
+ level,
1482
+ text,
1483
+ ...(message === undefined ? {} : { message })
1484
+ })
1485
+ ]);
1475
1486
  this.options.persist();
1476
1487
  }
1477
1488
 
@@ -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}$/;
@@ -1,4 +1,4 @@
1
- import type { ChatItem } from '@adecore/agent-contracts';
1
+ import type { ChatItem, ChatNoteMessage } from '@adecore/agent-contracts';
2
2
  import type { ChatCore } from '../chat/chat-core.ts';
3
3
  import type { Notice, NoticeStore } from './notice-store.ts';
4
4
 
@@ -111,7 +111,12 @@ export interface NoticeChat {
111
111
  /* Whether the host holds a chat under the id, running or on disk. */
112
112
  has(id: string): Promise<boolean>;
113
113
  /* Puts a line in that chat's thread, loading the chat when nobody has. */
114
- note(id: string, text: string): Promise<void>;
114
+ note(id: string, text: string, message: ChatNoteMessage): Promise<void>;
115
+ }
116
+
117
+ /* The message itself on the line in the thread, so a client can draw it as said to the chat. */
118
+ export function noticeMessage(notice: Notice): ChatNoteMessage {
119
+ return { from: notice.from, title: notice.fromTitle, text: notice.text };
115
120
  }
116
121
 
117
122
  /*
@@ -124,6 +129,6 @@ export async function showNotices(store: Pick<NoticeStore, 'show'>, chat: Notice
124
129
  }
125
130
 
126
131
  for (const notice of await store.show(targetId)) {
127
- await chat.note(targetId, words.shown(notice));
132
+ await chat.note(targetId, words.shown(notice), noticeMessage(notice));
128
133
  }
129
134
  }
@@ -1,5 +1,6 @@
1
+ import type { ChatNoteMessage } from '@adecore/agent-contracts';
1
2
  import type { PromptNotes } from '../chat/chat-session.ts';
2
- import type { MessageWords } from './deliver-notice.ts';
3
+ import { noticeMessage, type MessageWords } from './deliver-notice.ts';
3
4
  import type { NoticeStore } from './notice-store.ts';
4
5
 
5
6
  /*
@@ -29,6 +30,10 @@ export class NoticeNotes implements PromptNotes {
29
30
  * The lines a chat that just loaded owes a person for what landed while nobody held it, marked so a
30
31
  * reload writes none again. The model still hears the messages themselves from the queue.
31
32
  */
32
- export async function unshownNotes(store: Pick<NoticeStore, 'show'>, chatId: string, words: Pick<MessageWords, 'shown'>): Promise<string[]> {
33
- return (await store.show(chatId)).map((notice) => words.shown(notice));
33
+ export async function unshownNotes(
34
+ store: Pick<NoticeStore, 'show'>,
35
+ chatId: string,
36
+ words: Pick<MessageWords, 'shown'>
37
+ ): Promise<{ text: string; message: ChatNoteMessage }[]> {
38
+ return (await store.show(chatId)).map((notice) => ({ text: words.shown(notice), message: noticeMessage(notice) }));
34
39
  }