@adecore/agents 0.24.2 → 0.25.1
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/dist/chat/chat-core.d.ts +7 -0
- package/dist/chat/chat-core.js +71 -0
- package/dist/chat/chat-session.d.ts +8 -0
- package/dist/chat/chat-session.js +50 -4
- package/dist/chat/index.d.ts +2 -2
- package/dist/chat/index.js +2 -2
- package/dist/chat/thread.d.ts +1 -0
- package/dist/chat/thread.js +3 -0
- package/dist/chat/ui-queries.d.ts +8 -1
- package/dist/chat/ui-queries.js +137 -7
- package/dist/chat/wake-chat.d.ts +1 -0
- package/dist/chat/wake-chat.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lineage.d.ts +1 -0
- package/dist/lineage.js +8 -0
- package/dist/messages/deliver-message.js +6 -2
- package/dist/record-directory.js +6 -3
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +2 -2
- package/package.json +3 -3
- package/src/chat/chat-core.ts +77 -0
- package/src/chat/chat-session.ts +58 -4
- package/src/chat/index.ts +2 -2
- package/src/chat/thread.ts +4 -0
- package/src/chat/ui-queries.ts +155 -8
- package/src/chat/wake-chat.ts +3 -0
- package/src/index.ts +1 -1
- package/src/lineage.ts +9 -0
- package/src/messages/deliver-message.ts +6 -2
- package/src/record-directory.ts +6 -3
- package/src/testing/index.ts +2 -2
|
@@ -8,8 +8,9 @@ export const DeliverMessageWorkSchema = z.object({
|
|
|
8
8
|
* Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
|
|
9
9
|
* takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
|
|
10
10
|
*
|
|
11
|
-
* One attempt
|
|
12
|
-
*
|
|
11
|
+
* One attempt: a chat already in a turn reads the message in front of its next one, and holding the
|
|
12
|
+
* entry would start turns nobody asked for long after the news mattered. The one exception is a chat
|
|
13
|
+
* being moved to another folder, which has no turn and turns every wake away until the move ends.
|
|
13
14
|
*/
|
|
14
15
|
export function deliverMessageHandler(deps) {
|
|
15
16
|
return async (entry) => {
|
|
@@ -25,6 +26,9 @@ export function deliverMessageHandler(deps) {
|
|
|
25
26
|
if (chat === null) {
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
29
|
+
if (chat.relocating?.() === true) {
|
|
30
|
+
return 'wait';
|
|
31
|
+
}
|
|
28
32
|
// No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
|
|
29
33
|
// and the label on the turn names who it came from.
|
|
30
34
|
chat.wake({
|
package/dist/record-directory.js
CHANGED
|
@@ -101,9 +101,12 @@ export class RecordDirectory {
|
|
|
101
101
|
return rm(this.pathOf(id), { force: true });
|
|
102
102
|
}
|
|
103
103
|
async prune(drop) {
|
|
104
|
-
for (const
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
for (const listed of this.all()) {
|
|
105
|
+
const id = this.idOf(listed);
|
|
106
|
+
// Read again after each await: a record written meanwhile may no longer be one to drop.
|
|
107
|
+
const current = this.records.get(id);
|
|
108
|
+
if (current !== undefined && drop(current)) {
|
|
109
|
+
await this.remove(id);
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type FakeIo, type FakeProgram, type FakeCli, type StartedFake, type InProcessCli, inProcess, runOverStdio } from './fake-cli.ts';
|
|
2
2
|
export { fakeClaude, claudeStoppingOnResume } from './fake-claude.ts';
|
|
3
3
|
export { FAKE_CHILD_STEPS, fakeCodexForks, type FakeCodexOptions, fakeCodexWith, fakeCodex } from './fake-codex.ts';
|
|
4
|
-
export {
|
|
4
|
+
export { testAccounts } from './accounts.ts';
|
|
5
5
|
export { ManualClock } from './manual-clock.ts';
|
|
6
|
-
export {
|
|
6
|
+
export { FakeWatch, EventLog } from './watch.ts';
|
package/dist/testing/index.js
CHANGED
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
export { inProcess, runOverStdio } from './fake-cli.js';
|
|
3
3
|
export { fakeClaude, claudeStoppingOnResume } from './fake-claude.js';
|
|
4
4
|
export { FAKE_CHILD_STEPS, fakeCodexForks, fakeCodexWith, fakeCodex } from './fake-codex.js';
|
|
5
|
-
export {
|
|
5
|
+
export { testAccounts } from './accounts.js';
|
|
6
6
|
export { ManualClock } from './manual-clock.js';
|
|
7
|
-
export {
|
|
7
|
+
export { FakeWatch, EventLog } from './watch.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"test": "bun --conditions=source test"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@adecore/agent-contracts": "0.
|
|
85
|
-
"@adecore/intelligent-ui": "0.
|
|
84
|
+
"@adecore/agent-contracts": "0.25.1",
|
|
85
|
+
"@adecore/intelligent-ui": "0.25.1",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -227,6 +227,8 @@ export class ChatCore {
|
|
|
227
227
|
private readonly hooks: ChatCoreHooks;
|
|
228
228
|
private readonly chats = new Map<string, ChatSession>();
|
|
229
229
|
private readonly creating = new Map<string, Promise<ChatInfo>>();
|
|
230
|
+
// The relocations under way, settled (never rejected) when each ends.
|
|
231
|
+
private readonly relocations = new Map<string, Promise<void>>();
|
|
230
232
|
private readonly sinks = new ClientSinks<AgentEvent>((clientId) => this.composerPreferences.forget(clientId));
|
|
231
233
|
private readonly attached = new Map<string, Set<string>>();
|
|
232
234
|
// How often a chat's bookmarks or visuals changed, so an attach can tell that its read went stale before it joined.
|
|
@@ -540,6 +542,7 @@ export class ChatCore {
|
|
|
540
542
|
configure(payload: ChatConfigurePayload): ChatInfo {
|
|
541
543
|
const session = this.require(payload.chatId);
|
|
542
544
|
if (payload.account !== undefined) {
|
|
545
|
+
this.refuseWhileRelocating(payload.chatId);
|
|
543
546
|
this.switchAccount(session, payload.account);
|
|
544
547
|
}
|
|
545
548
|
return session.configure(payload);
|
|
@@ -942,6 +945,7 @@ export class ChatCore {
|
|
|
942
945
|
/* Ends the chat's CLI and keeps its conversation; the next message resumes it in a process that reads its skills and plugins again. */
|
|
943
946
|
async restart(chatId: string): Promise<void> {
|
|
944
947
|
const session = this.require(chatId);
|
|
948
|
+
this.refuseWhileRelocating(chatId);
|
|
945
949
|
const ended = session.restart();
|
|
946
950
|
this.skillIndex.forget(session.info.provider, session.info.cwd);
|
|
947
951
|
await ended;
|
|
@@ -949,6 +953,7 @@ export class ChatCore {
|
|
|
949
953
|
|
|
950
954
|
compact(chatId: string): void {
|
|
951
955
|
const session = this.require(chatId);
|
|
956
|
+
this.refuseWhileRelocating(chatId);
|
|
952
957
|
if (session.busy) {
|
|
953
958
|
throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
|
|
954
959
|
}
|
|
@@ -958,6 +963,7 @@ export class ChatCore {
|
|
|
958
963
|
/* Empties the thread and drops the CLI's session and the chat's bookmarks and visuals; `force` stops a turn that is in the way. */
|
|
959
964
|
async clear(chatId: string, force = false): Promise<void> {
|
|
960
965
|
const session = this.require(chatId);
|
|
966
|
+
this.refuseWhileRelocating(chatId);
|
|
961
967
|
if (session.busy && !force) {
|
|
962
968
|
throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
|
|
963
969
|
}
|
|
@@ -1020,9 +1026,80 @@ export class ChatCore {
|
|
|
1020
1026
|
this.chats.get(chatId)?.releaseHold(key);
|
|
1021
1027
|
}
|
|
1022
1028
|
|
|
1029
|
+
/*
|
|
1030
|
+
* Moves a loaded chat to another working folder. `during` is the host's own work and runs with the CLI
|
|
1031
|
+
* already ended; messages sent meanwhile queue and go out in the new folder. Any throw keeps the old folder.
|
|
1032
|
+
*/
|
|
1033
|
+
async relocate(chatId: string, cwd: string, during?: () => Promise<void>): Promise<ChatInfo> {
|
|
1034
|
+
const session = this.require(chatId);
|
|
1035
|
+
this.refuseUnlessRelocatable(session);
|
|
1036
|
+
const running = this.relocateNow(session, cwd, during);
|
|
1037
|
+
const tracked = running.then(
|
|
1038
|
+
() => undefined,
|
|
1039
|
+
() => undefined
|
|
1040
|
+
);
|
|
1041
|
+
this.relocations.set(chatId, tracked);
|
|
1042
|
+
try {
|
|
1043
|
+
return await running;
|
|
1044
|
+
} finally {
|
|
1045
|
+
if (this.relocations.get(chatId) === tracked) {
|
|
1046
|
+
this.relocations.delete(chatId);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/* Whether a relocation of the chat is under way, which a host that forks or copies the chat has to refuse. */
|
|
1052
|
+
isRelocating(chatId: string): boolean {
|
|
1053
|
+
return this.relocations.has(chatId);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private async relocateNow(session: ChatSession, cwd: string, during: (() => Promise<void>) | undefined): Promise<ChatInfo> {
|
|
1057
|
+
const chatId = session.id;
|
|
1058
|
+
await this.hooks.admit?.(chatId, cwd);
|
|
1059
|
+
// A message sent while the folder was asked about may have opened a turn.
|
|
1060
|
+
if (!session.relocatable) {
|
|
1061
|
+
throw this.busyRelocating(chatId);
|
|
1062
|
+
}
|
|
1063
|
+
const previous = session.info.cwd;
|
|
1064
|
+
session.beginRelocate();
|
|
1065
|
+
try {
|
|
1066
|
+
await session.dropProcess();
|
|
1067
|
+
await during?.();
|
|
1068
|
+
session.setCwd(cwd);
|
|
1069
|
+
await this.persistNow(chatId, true);
|
|
1070
|
+
} catch (e) {
|
|
1071
|
+
session.setCwd(previous);
|
|
1072
|
+
throw e;
|
|
1073
|
+
} finally {
|
|
1074
|
+
session.endRelocate();
|
|
1075
|
+
}
|
|
1076
|
+
this.skillIndex.forget(session.info.provider, previous);
|
|
1077
|
+
return session.info;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private refuseUnlessRelocatable(session: ChatSession): void {
|
|
1081
|
+
if (this.relocations.has(session.id) || !session.relocatable) {
|
|
1082
|
+
throw this.busyRelocating(session.id);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
private busyRelocating(chatId: string): ChatError {
|
|
1087
|
+
return new ChatError('chat-busy', `Chat ${chatId} is still working; stop it before moving it to another folder`);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
private refuseWhileRelocating(chatId: string): void {
|
|
1091
|
+
if (this.relocations.has(chatId)) {
|
|
1092
|
+
throw new ChatError('chat-busy', `Chat ${chatId} is being moved to another folder`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1023
1096
|
/* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
|
|
1024
1097
|
async kill(chatId: string): Promise<void> {
|
|
1025
1098
|
await this.loaded(chatId);
|
|
1099
|
+
const moving = this.relocations.get(chatId);
|
|
1100
|
+
if (moving) {
|
|
1101
|
+
await moving;
|
|
1102
|
+
}
|
|
1026
1103
|
const session = this.require(chatId);
|
|
1027
1104
|
// A write already out would put the record back after the delete.
|
|
1028
1105
|
const writing = this.writes.get(chatId);
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
ModelSelection,
|
|
20
20
|
RuntimeMode
|
|
21
21
|
} from '@adecore/agent-contracts';
|
|
22
|
-
import { HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
|
|
22
|
+
import { ENDED_NOTE_PREFIX, HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
|
|
23
23
|
import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure } from '@adecore/intelligent-ui';
|
|
24
24
|
import type { ChatProvider } from '../providers/provider.ts';
|
|
25
25
|
import type { LimitsUpdate } from '../usage/limits/normalize.ts';
|
|
@@ -179,6 +179,9 @@ export class ChatSession {
|
|
|
179
179
|
private launchedSelection: ModelSelection | null = null;
|
|
180
180
|
// The account the running CLI was started under, which what it reports about a plan belongs to.
|
|
181
181
|
private launchedAccount: string | undefined = undefined;
|
|
182
|
+
private launchedCwd: string | null = null;
|
|
183
|
+
// Set while the host moves the chat's folder: messages queue and nothing starts a process until it ends.
|
|
184
|
+
private relocating = false;
|
|
182
185
|
private launchedRuntimeMode: RuntimeMode | null = null;
|
|
183
186
|
private launchedFolders: readonly string[] = [];
|
|
184
187
|
// The checkpoint of the turn in flight; everything queued for that turn waits for it.
|
|
@@ -327,6 +330,46 @@ export class ChatSession {
|
|
|
327
330
|
return this.thread.info;
|
|
328
331
|
}
|
|
329
332
|
|
|
333
|
+
/* The next process starts in this folder; the host moves the chat through `ChatCore.relocate`, which has already ended the old one. */
|
|
334
|
+
setCwd(cwd: string): ChatInfo {
|
|
335
|
+
if (cwd === this.thread.info.cwd) {
|
|
336
|
+
return this.thread.info;
|
|
337
|
+
}
|
|
338
|
+
this.emit([this.thread.patchInfo({ cwd })]);
|
|
339
|
+
this.options.persist();
|
|
340
|
+
return this.thread.info;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
get relocatable(): boolean {
|
|
344
|
+
return (
|
|
345
|
+
!this.frozen &&
|
|
346
|
+
!this.relocating &&
|
|
347
|
+
this.thread.info.activeTurnId === null &&
|
|
348
|
+
!this.processInUse() &&
|
|
349
|
+
this.pendingStart === null &&
|
|
350
|
+
!this.thread.held
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
get isRelocating(): boolean {
|
|
355
|
+
return this.relocating;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* Messages sent from here on queue, and the queue stays put until `endRelocate`. */
|
|
359
|
+
beginRelocate(): void {
|
|
360
|
+
this.relocating = true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/*
|
|
364
|
+
* Lets the queue go, in whichever folder the chat has now. A wake that found the chat relocating
|
|
365
|
+
* was turned away and waits for an event of this chat, so one goes out even when the queue was empty.
|
|
366
|
+
*/
|
|
367
|
+
endRelocate(): void {
|
|
368
|
+
this.relocating = false;
|
|
369
|
+
this.drainQueue();
|
|
370
|
+
this.emit([this.thread.patchInfo({})]);
|
|
371
|
+
}
|
|
372
|
+
|
|
330
373
|
/*
|
|
331
374
|
* Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
|
|
332
375
|
*/
|
|
@@ -334,7 +377,7 @@ export class ChatSession {
|
|
|
334
377
|
const extras = this.vetted(sent);
|
|
335
378
|
const turnId = newId('turn');
|
|
336
379
|
// Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
|
|
337
|
-
if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
|
|
380
|
+
if (this.busy || this.queue.length > 0 || this.replacementWaits() || this.relocating) {
|
|
338
381
|
if (this.busy && this.info.queuePaused) {
|
|
339
382
|
this.sendAfterStop = true;
|
|
340
383
|
}
|
|
@@ -579,6 +622,7 @@ export class ChatSession {
|
|
|
579
622
|
!next ||
|
|
580
623
|
this.steering.size > 0 ||
|
|
581
624
|
this.busy ||
|
|
625
|
+
this.relocating ||
|
|
582
626
|
(!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))
|
|
583
627
|
) {
|
|
584
628
|
return;
|
|
@@ -969,7 +1013,7 @@ export class ChatSession {
|
|
|
969
1013
|
* with `origin: 'agent'` and close the wake it just made.
|
|
970
1014
|
*/
|
|
971
1015
|
wake(wake: { text: string; label: string; note?: string; taskIds: string[]; summaryFor?: string; messageFrom?: string[] }): string | null {
|
|
972
|
-
if (this.frozen || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
|
|
1016
|
+
if (this.frozen || this.relocating || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
|
|
973
1017
|
return null;
|
|
974
1018
|
}
|
|
975
1019
|
this.turnOpened();
|
|
@@ -1200,7 +1244,7 @@ export class ChatSession {
|
|
|
1200
1244
|
}
|
|
1201
1245
|
}
|
|
1202
1246
|
if (turnId !== null) {
|
|
1203
|
-
events.push(this.thread.upsert({ id: newId(
|
|
1247
|
+
events.push(this.thread.upsert({ id: newId(`${ENDED_NOTE_PREFIX}note`), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason }));
|
|
1204
1248
|
}
|
|
1205
1249
|
events.push(
|
|
1206
1250
|
this.thread.patchInfo({
|
|
@@ -1226,6 +1270,14 @@ export class ChatSession {
|
|
|
1226
1270
|
if (this.thread.info.activeTurnId !== null || this.processInUse()) {
|
|
1227
1271
|
throw new ChatError('chat-busy', `Chat ${this.id} is still working; stop it before restarting its agent session`);
|
|
1228
1272
|
}
|
|
1273
|
+
return this.dropProcess();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/*
|
|
1277
|
+
* Ends the CLI and keeps the conversation, like `restart` without its refusal: the caller has
|
|
1278
|
+
* checked `relocatable` and holds the queue. Settles once the process exited or was sent the SIGKILL.
|
|
1279
|
+
*/
|
|
1280
|
+
dropProcess(): Promise<void> {
|
|
1229
1281
|
this.clearStartWarning();
|
|
1230
1282
|
this.clearWorkflowWarnings();
|
|
1231
1283
|
const backend = this.backend;
|
|
@@ -1707,6 +1759,7 @@ export class ChatSession {
|
|
|
1707
1759
|
(JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
|
|
1708
1760
|
this.info.runtimeMode !== this.launchedRuntimeMode ||
|
|
1709
1761
|
this.info.account !== this.launchedAccount ||
|
|
1762
|
+
this.info.cwd !== this.launchedCwd ||
|
|
1710
1763
|
!sameFolders(this.options.folders?.() ?? [], this.launchedFolders))
|
|
1711
1764
|
);
|
|
1712
1765
|
}
|
|
@@ -1748,6 +1801,7 @@ export class ChatSession {
|
|
|
1748
1801
|
const info = this.thread.info;
|
|
1749
1802
|
this.launchedSelection = info.selection;
|
|
1750
1803
|
this.launchedRuntimeMode = info.runtimeMode;
|
|
1804
|
+
this.launchedCwd = info.cwd;
|
|
1751
1805
|
const folders = this.options.folders?.() ?? [];
|
|
1752
1806
|
this.launchedFolders = folders;
|
|
1753
1807
|
const launch: BackendLaunch = {
|
package/src/chat/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ export { type ChatRecordExtras, type ChatRecord, ChatStore, type ChatSeq, type C
|
|
|
30
30
|
export { suggestChatTitle, type ChatTitleInput } from './chat-title.ts';
|
|
31
31
|
export type { ClaudeBackendOptions } from './claude-backend.ts';
|
|
32
32
|
export { ClaudeTitleReader } from './claude-title.ts';
|
|
33
|
-
export {
|
|
33
|
+
export { claudeProjectSlug, findClaudeSession } from './claude-transcript.ts';
|
|
34
34
|
export type { CodexBackendOptions } from './codex-backend.ts';
|
|
35
35
|
export { forkThreadOnce, type ThreadItemsParams, type CodexProcessSpec, type ThreadForkParams } from './codex-thread.ts';
|
|
36
36
|
export type { CodexClientInfo } from './codex-transport.ts';
|
|
@@ -46,4 +46,4 @@ export { ChatThread } from './thread.ts';
|
|
|
46
46
|
export { type ChatUiSource, type ChatUiHost, ChatUiRefusal } from './ui-queries.ts';
|
|
47
47
|
export { visualFileName, VisualStore, type VisualInput, type VisualStoreOptions, type VisualListener } from './visual-store.ts';
|
|
48
48
|
export { type WakeChat, chatOpener, type ChatOpenerDeps } from './wake-chat.ts';
|
|
49
|
-
export { type SkillRootOptions
|
|
49
|
+
export { type SkillRootOptions } from '../skills.ts';
|
package/src/chat/thread.ts
CHANGED
|
@@ -79,6 +79,10 @@ export class ChatThread {
|
|
|
79
79
|
return { type: 'info', info: this.info };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
get held(): boolean {
|
|
83
|
+
return this.holds.size > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
/* False when the key held the chat already. */
|
|
83
87
|
hold(key: string): boolean {
|
|
84
88
|
if (this.holds.has(key)) {
|
package/src/chat/ui-queries.ts
CHANGED
|
@@ -3,13 +3,17 @@ import type { z } from 'zod';
|
|
|
3
3
|
import type {
|
|
4
4
|
ChatUiLinkPayload,
|
|
5
5
|
ChatUiLinkReading,
|
|
6
|
+
ChatUiPlaceReading,
|
|
7
|
+
ChatUiQueryState,
|
|
6
8
|
ChatAssistantItem,
|
|
7
9
|
ChatInfo,
|
|
8
10
|
ChatUiChoicePayload,
|
|
9
11
|
ChatUiQueryPayload,
|
|
10
12
|
ChatUiQueryReading
|
|
11
13
|
} from '@adecore/agent-contracts';
|
|
14
|
+
import { ChatUiPlaceReadingSchema } from '@adecore/agent-contracts';
|
|
12
15
|
import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost, type UiValue } from '@adecore/intelligent-ui';
|
|
16
|
+
import { uiPlaceQueries, type UiPlaceQuery } from '@adecore/intelligent-ui/places';
|
|
13
17
|
import { UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
|
|
14
18
|
import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
|
|
15
19
|
import type { UiBlock } from '@adecore/intelligent-ui/compiler';
|
|
@@ -27,6 +31,12 @@ export interface ChatUiHost {
|
|
|
27
31
|
capture(info: ChatInfo): Promise<unknown>;
|
|
28
32
|
sources: Readonly<Record<string, ChatUiSource>>;
|
|
29
33
|
link?(info: ChatInfo, access: unknown, target: UiLinkTarget): Promise<ChatUiLinkReading>;
|
|
34
|
+
/*
|
|
35
|
+
* Looks up the point of a Place that names an address, once, when its reply settles; the reading is
|
|
36
|
+
* frozen beside the block for every client. `signal` aborts after five seconds. A host without it
|
|
37
|
+
* freezes nothing, and such a place reads as not found.
|
|
38
|
+
*/
|
|
39
|
+
place?(info: ChatInfo, query: UiPlaceQuery, signal: AbortSignal): Promise<Omit<ChatUiPlaceReading, 'address'>>;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
interface CachedReading {
|
|
@@ -63,13 +73,20 @@ export class ChatUiRefusal extends UiQueryError {
|
|
|
63
73
|
const MAX_QUERY_BYTES = 64 * 1024;
|
|
64
74
|
const MAX_SNAPSHOT_BYTES = 128 * 1024;
|
|
65
75
|
const READ_TIMEOUT_MS = 8_000;
|
|
76
|
+
const PLACE_TIMEOUT_MS = 5_000;
|
|
77
|
+
// Lookups run a few at a time, share one budget per block, and stop trying once the host keeps timing out.
|
|
78
|
+
const PLACE_PARALLEL = 4;
|
|
79
|
+
const PLACE_BLOCK_BUDGET_MS = 15_000;
|
|
80
|
+
const PLACE_TIMEOUTS_IN_A_ROW = 3;
|
|
66
81
|
// A changed input reads at once, but a query never reads more often than this across its inputs.
|
|
67
82
|
const INPUT_READ_MS = 250;
|
|
68
83
|
|
|
69
84
|
export class ChatUiQueries {
|
|
70
85
|
readonly schemas: Record<string, z.ZodType>;
|
|
71
86
|
private readonly captures = new Map<string, Promise<unknown>>();
|
|
72
|
-
private readonly freezing = new
|
|
87
|
+
private readonly freezing = new Map<string, Promise<void>>();
|
|
88
|
+
// Ends with the chat, so a lookup that is under way stops when its chat does.
|
|
89
|
+
private readonly lives = new Map<string, AbortController>();
|
|
73
90
|
private readonly cache = new Map<string, CachedReading>();
|
|
74
91
|
// The read id of the newest reading per cache key, which a remount or a second client is answered with.
|
|
75
92
|
private readonly latest = new Map<string, string>();
|
|
@@ -101,8 +118,7 @@ export class ChatUiQueries {
|
|
|
101
118
|
if (this.freezing.has(key)) {
|
|
102
119
|
return;
|
|
103
120
|
}
|
|
104
|
-
|
|
105
|
-
void capture
|
|
121
|
+
const frozen = capture
|
|
106
122
|
.then(async () => {
|
|
107
123
|
for (const block of (item.ui ?? []).slice(0, 16)) {
|
|
108
124
|
const current = session.thread.get(item.id);
|
|
@@ -139,6 +155,10 @@ export class ChatUiQueries {
|
|
|
139
155
|
}
|
|
140
156
|
}
|
|
141
157
|
}
|
|
158
|
+
const { places, found, ended } = await this.lookUpPlaces(session, item.id, block, values);
|
|
159
|
+
if (ended) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
142
162
|
const latest = session.thread.get(item.id);
|
|
143
163
|
if (latest?.kind !== 'assistant' || !latest.uiQueries || latest.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
|
|
144
164
|
return;
|
|
@@ -150,11 +170,12 @@ export class ChatUiQueries {
|
|
|
150
170
|
.filter(([, reading]) => reading.state !== 'fresh')
|
|
151
171
|
.map(([name, reading]) => `${name}: ${reading.reason}`)
|
|
152
172
|
.join('\n');
|
|
153
|
-
const frozen = {
|
|
173
|
+
const frozen: ChatUiQueryState['blocks'][string] = {
|
|
154
174
|
revision: block.revision!,
|
|
155
175
|
readings,
|
|
156
176
|
links,
|
|
157
|
-
|
|
177
|
+
...(Object.keys(places).length > 0 ? { places } : {}),
|
|
178
|
+
fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}${found.length > 0 ? `\nPlaces: ${found.join('; ')}` : ''}`
|
|
158
179
|
};
|
|
159
180
|
const blocks = { ...latest.uiQueries.blocks, [block.id]: frozen };
|
|
160
181
|
if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
|
|
@@ -167,16 +188,140 @@ export class ChatUiQueries {
|
|
|
167
188
|
frozen.links = Object.fromEntries(
|
|
168
189
|
Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }])
|
|
169
190
|
);
|
|
191
|
+
if (frozen.places) {
|
|
192
|
+
frozen.places = Object.fromEntries(
|
|
193
|
+
Object.entries(places).map(([id, reading]) => [
|
|
194
|
+
id,
|
|
195
|
+
{ state: 'missing', address: reading.address, code: 'snapshot-too-large', reason: 'The frozen places are too large.' }
|
|
196
|
+
])
|
|
197
|
+
);
|
|
198
|
+
}
|
|
170
199
|
frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
|
|
171
200
|
}
|
|
172
201
|
if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
|
|
173
|
-
|
|
202
|
+
// The block stays marked as frozen with nothing in it, so a reader stops waiting for readings that will not come.
|
|
203
|
+
blocks[block.id] = { revision: block.revision!, readings: {}, fallback: '' };
|
|
174
204
|
}
|
|
175
205
|
session.updateUiQueries(item.id, { ...latest.uiQueries, blocks });
|
|
176
206
|
}
|
|
177
207
|
})
|
|
178
208
|
.catch(() => {})
|
|
179
209
|
.finally(() => this.freezing.delete(key));
|
|
210
|
+
this.freezing.set(key, frozen);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Settles once the reply's readings, links and places are frozen, or at once where nothing is being frozen. */
|
|
214
|
+
async whenFrozen(session: ChatSession, itemId: string): Promise<void> {
|
|
215
|
+
await this.freezing.get(`${session.id}:${itemId}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/*
|
|
219
|
+
* The places of a block that name an address, looked up four at a time within one budget for the block.
|
|
220
|
+
* Past the budget, or after the host timed out three times in a row, the rest freeze as missing without
|
|
221
|
+
* asking. Nothing is asked once the chat ended, the reply is gone or the block changed; the caller then
|
|
222
|
+
* drops the write.
|
|
223
|
+
*/
|
|
224
|
+
private async lookUpPlaces(
|
|
225
|
+
session: ChatSession,
|
|
226
|
+
itemId: string,
|
|
227
|
+
block: UiBlock,
|
|
228
|
+
values: Record<string, unknown>
|
|
229
|
+
): Promise<{ places: Record<string, ChatUiPlaceReading>; found: string[]; ended: boolean }> {
|
|
230
|
+
const places: Record<string, ChatUiPlaceReading> = Object.create(null);
|
|
231
|
+
const found: string[] = [];
|
|
232
|
+
if (!this.host.place) {
|
|
233
|
+
return { places, found, ended: false };
|
|
234
|
+
}
|
|
235
|
+
let queries: Array<[string, UiPlaceQuery]>;
|
|
236
|
+
try {
|
|
237
|
+
queries = Object.entries(uiPlaceQueries(block, {}, values)).slice(0, UI_HOST_LIMITS.places);
|
|
238
|
+
} catch {
|
|
239
|
+
return { places, found, ended: false };
|
|
240
|
+
}
|
|
241
|
+
const life = this.lifeOf(session.id).signal;
|
|
242
|
+
const budget = new AbortController();
|
|
243
|
+
const timer = setTimeout(() => budget.abort(), PLACE_BLOCK_BUDGET_MS);
|
|
244
|
+
timer.unref?.();
|
|
245
|
+
const signal = AbortSignal.any([life, budget.signal]);
|
|
246
|
+
const readings = new Map<string, ChatUiPlaceReading>();
|
|
247
|
+
let timeouts = 0;
|
|
248
|
+
let next = 0;
|
|
249
|
+
const stillThere = (): boolean => {
|
|
250
|
+
const current = session.thread.get(itemId);
|
|
251
|
+
return !life.aborted && current?.kind === 'assistant' && current.ui?.find((entry) => entry.id === block.id)?.revision === block.revision;
|
|
252
|
+
};
|
|
253
|
+
const worker = async (): Promise<void> => {
|
|
254
|
+
for (let index = next++; index < queries.length; index = next++) {
|
|
255
|
+
const [id, query] = queries[index]!;
|
|
256
|
+
if (!stillThere()) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (budget.signal.aborted || timeouts >= PLACE_TIMEOUTS_IN_A_ROW) {
|
|
260
|
+
readings.set(id, { state: 'missing', address: query.address, code: 'timed-out', reason: 'The place lookup timed out.' });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const reading = await this.placeReading(session.info, query, signal);
|
|
264
|
+
timeouts = reading.code === 'timed-out' ? timeouts + 1 : 0;
|
|
265
|
+
readings.set(id, reading);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
await Promise.all(Array.from({ length: Math.min(PLACE_PARALLEL, queries.length) }, worker));
|
|
270
|
+
} finally {
|
|
271
|
+
clearTimeout(timer);
|
|
272
|
+
}
|
|
273
|
+
for (const [id, query] of queries) {
|
|
274
|
+
const reading = readings.get(id);
|
|
275
|
+
if (reading === undefined) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
places[id] = reading;
|
|
279
|
+
if (reading.state === 'found') {
|
|
280
|
+
found.push(`${query.name || query.address}: ${reading.lat}, ${reading.lon}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { places, found, ended: life.aborted };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private lifeOf(chatId: string): AbortController {
|
|
287
|
+
let life = this.lives.get(chatId);
|
|
288
|
+
if (life === undefined) {
|
|
289
|
+
life = new AbortController();
|
|
290
|
+
this.lives.set(chatId, life);
|
|
291
|
+
}
|
|
292
|
+
return life;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/*
|
|
296
|
+
* One lookup, cut off after five seconds or when `outer` aborts. Whatever goes wrong is a reading that says
|
|
297
|
+
* the place is missing. Only a refusal the host chose its words for (`ChatUiRefusal`) carries them: any
|
|
298
|
+
* other error stays out of the shared record, which every client reads.
|
|
299
|
+
*/
|
|
300
|
+
private async placeReading(info: ChatInfo, query: UiPlaceQuery, outer: AbortSignal): Promise<ChatUiPlaceReading> {
|
|
301
|
+
const timeout = new AbortController();
|
|
302
|
+
const timer = setTimeout(() => timeout.abort(), PLACE_TIMEOUT_MS);
|
|
303
|
+
timer.unref?.();
|
|
304
|
+
const signal = AbortSignal.any([timeout.signal, outer]);
|
|
305
|
+
try {
|
|
306
|
+
const reading = await Promise.race([
|
|
307
|
+
this.host.place!(info, query, signal),
|
|
308
|
+
new Promise<never>((_, reject) =>
|
|
309
|
+
signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The place lookup timed out.')), {
|
|
310
|
+
once: true
|
|
311
|
+
})
|
|
312
|
+
)
|
|
313
|
+
]);
|
|
314
|
+
const parsed = ChatUiPlaceReadingSchema.parse({ ...(copyUiValue(reading, new UiBudget()) as object), address: query.address });
|
|
315
|
+
return parsed.state === 'found' && (parsed.lat === undefined || parsed.lon === undefined)
|
|
316
|
+
? { state: 'missing', address: query.address, code: 'place-unreadable', reason: 'The host found no point for this address.' }
|
|
317
|
+
: parsed;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return error instanceof UiQueryError
|
|
320
|
+
? { state: 'missing', address: query.address, ...this.failure(error) }
|
|
321
|
+
: { state: 'missing', address: query.address, code: 'lookup-failed', reason: 'The place could not be looked up.' };
|
|
322
|
+
} finally {
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
}
|
|
180
325
|
}
|
|
181
326
|
|
|
182
327
|
/* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
|
|
@@ -408,6 +553,8 @@ export class ChatUiQueries {
|
|
|
408
553
|
}
|
|
409
554
|
|
|
410
555
|
forget(chatId: string): void {
|
|
556
|
+
this.lives.get(chatId)?.abort();
|
|
557
|
+
this.lives.delete(chatId);
|
|
411
558
|
for (const [id, cached] of this.cache) {
|
|
412
559
|
if (cached.chatId === chatId) {
|
|
413
560
|
this.evict(id);
|
|
@@ -451,7 +598,7 @@ export class ChatUiQueries {
|
|
|
451
598
|
}
|
|
452
599
|
|
|
453
600
|
private failure(error: unknown): { reason: string; code?: string } {
|
|
454
|
-
|
|
455
|
-
return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
|
|
601
|
+
// The reason is frozen into a record every client reads, so a message the host did not choose for that stays out of it.
|
|
602
|
+
return error instanceof UiQueryError ? { reason: error.message.slice(0, 240), code: error.reason } : { reason: 'This query could not be read.' };
|
|
456
603
|
}
|
|
457
604
|
}
|
package/src/chat/wake-chat.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface WakeChat {
|
|
|
10
10
|
items(): ChatItem[];
|
|
11
11
|
persist?(): Promise<void>;
|
|
12
12
|
wake(wake: { text: string; label: string; note?: string; taskIds: string[]; messageFrom?: string[] }): boolean;
|
|
13
|
+
/* Whether the host is moving the chat to another folder, which turns every wake away until it ends. */
|
|
14
|
+
relocating?(): boolean;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface ChatOpenerDeps {
|
|
@@ -39,6 +41,7 @@ export function chatOpener(deps: ChatOpenerDeps) {
|
|
|
39
41
|
return session
|
|
40
42
|
? {
|
|
41
43
|
items: () => session.thread.list(),
|
|
44
|
+
relocating: () => session.isRelocating,
|
|
42
45
|
persist: () => deps.chats.save(chatId),
|
|
43
46
|
// A turn before the reset would stop on the same limit and spend one of the resume's tries, so it waits for the resume turn.
|
|
44
47
|
wake: (wake) => session.info.resumeAt === undefined && session.wake(wake) !== null
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { wait, withTimeout } from './async.ts';
|
|
|
3
3
|
export { CodedError } from './coded-error.ts';
|
|
4
4
|
export { describeError, setErrorStacks, errorText } from './error-text.ts';
|
|
5
5
|
export { tempNameFor, writeAtomic, replaceSync, writeAtomicSync, isNotFound, fileExists, type WriteAtomicOptions } from './fs.ts';
|
|
6
|
-
export {
|
|
6
|
+
export { RecordDirectory, type RecordDirectoryOptions } from './record-directory.ts';
|
|
7
7
|
export { runProcess, type ProcessResult, type RunProcessOptions } from './run-process.ts';
|
|
8
8
|
export { Serializer, KeyedSerializer } from './serializer.ts';
|
|
9
9
|
export { cleanTitle, readLines } from './title-file.ts';
|
package/src/lineage.ts
CHANGED
|
@@ -70,6 +70,15 @@ export class AgentLineageStore {
|
|
|
70
70
|
return found;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/* Moves a node's record to another project with its dates as they were; false when nothing was written down for it or a prune took it meanwhile. */
|
|
74
|
+
async rehome(nodeId: string, projectId: string): Promise<boolean> {
|
|
75
|
+
const entry = this.opened.get(nodeId);
|
|
76
|
+
if (entry === undefined) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return this.opened.write({ ...entry, projectId });
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
/* The project a node was made in, for work owed about it after the node that opened it is gone. */
|
|
74
83
|
projectOf(nodeId: string): string | null {
|
|
75
84
|
return this.opened.get(nodeId)?.projectId ?? null;
|