@adecore/agents 0.17.0-beta.2 → 0.18.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/dist/chat/attachment-store.d.ts +1 -1
- package/dist/chat/attachment-store.js +4 -3
- package/dist/chat/chat-core.d.ts +8 -1
- package/dist/chat/chat-core.js +64 -14
- package/dist/chat/chat-store.js +3 -2
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/visual-store.d.ts +35 -0
- package/dist/chat/visual-store.js +230 -0
- package/dist/host/handlers.js +1 -0
- package/dist/host/wiring.js +2 -0
- package/package.json +2 -2
- package/src/chat/attachment-store.ts +4 -3
- package/src/chat/chat-core.ts +72 -14
- package/src/chat/chat-store.ts +5 -2
- package/src/chat/errors.ts +4 -1
- package/src/chat/visual-store.ts +278 -0
- package/src/host/handlers.ts +1 -0
- package/src/host/wiring.ts +3 -1
|
@@ -5,6 +5,6 @@ export declare class AttachmentStore {
|
|
|
5
5
|
constructor(home: string);
|
|
6
6
|
save(chatId: string, upload: ChatAttachmentUpload): Promise<ChatAttachment>;
|
|
7
7
|
removeAll(chatId: string): Promise<void>;
|
|
8
|
-
|
|
8
|
+
folderOf(chatId: string): string;
|
|
9
9
|
}
|
|
10
10
|
export declare function migrateInlineAttachments(chatId: string, record: unknown, store: AttachmentStore): Promise<unknown | null>;
|
|
@@ -44,7 +44,7 @@ export class AttachmentStore {
|
|
|
44
44
|
const bytes = Buffer.from(upload.data, 'base64');
|
|
45
45
|
const mime = attachmentImageMime(upload) ?? upload.mime;
|
|
46
46
|
const id = randomBytes(8).toString('hex');
|
|
47
|
-
const folder = this.
|
|
47
|
+
const folder = this.folderOf(chatId);
|
|
48
48
|
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
49
49
|
const path = join(folder, `${id}.${extensionFor(upload.name, mime)}`);
|
|
50
50
|
await writeFile(path, bytes, { mode: 0o600 });
|
|
@@ -52,9 +52,10 @@ export class AttachmentStore {
|
|
|
52
52
|
}
|
|
53
53
|
/* Everything a chat attached; used when the chat itself is deleted. */
|
|
54
54
|
async removeAll(chatId) {
|
|
55
|
-
await rm(this.
|
|
55
|
+
await rm(this.folderOf(chatId), { recursive: true, force: true });
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
/* Where the files of one chat are; a store that keeps more of a chat's files beside them writes them here. */
|
|
58
|
+
folderOf(chatId) {
|
|
58
59
|
return join(this.dir, encodeURIComponent(chatId));
|
|
59
60
|
}
|
|
60
61
|
}
|
package/dist/chat/chat-core.d.ts
CHANGED
|
@@ -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 ChatInfo, type ChatItem, type ChatQueuedMessage, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, 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 ChatInfo, type ChatItem, type ChatQueuedMessage, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
|
|
2
2
|
import { ClientSinks } from '../client-sinks.ts';
|
|
3
3
|
import type { AgentEvent, AgentSink } from '../events.ts';
|
|
4
4
|
import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
|
|
@@ -18,6 +18,7 @@ import { ComposerPreferences } from './composer-preferences.ts';
|
|
|
18
18
|
import { DeltaCoalescer } from './delta-coalescer.ts';
|
|
19
19
|
import { type BackgroundWork } from './background-work.ts';
|
|
20
20
|
import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
|
|
21
|
+
import type { VisualInput, VisualStore } from './visual-store.ts';
|
|
21
22
|
export interface InterruptedRun {
|
|
22
23
|
chatId: string;
|
|
23
24
|
turnId: string;
|
|
@@ -36,6 +37,7 @@ export interface ChatCoreOptions {
|
|
|
36
37
|
skills?: SkillIndex;
|
|
37
38
|
attachments: AttachmentStore;
|
|
38
39
|
bookmarks?: BookmarkStore;
|
|
40
|
+
visuals?: VisualStore;
|
|
39
41
|
onLimits?: (update: LimitsUpdate) => void;
|
|
40
42
|
claudeTitles?: {
|
|
41
43
|
forSession(agentSessionId: string, projectsDir: string): Promise<string | null>;
|
|
@@ -56,6 +58,7 @@ export declare class ChatCore {
|
|
|
56
58
|
protected readonly providers: ProviderRegistry;
|
|
57
59
|
protected readonly store: ChatStore | null;
|
|
58
60
|
protected readonly bookmarks: BookmarkStore | null;
|
|
61
|
+
protected readonly visuals: VisualStore | null;
|
|
59
62
|
protected readonly accounts: AccountLaunches | null;
|
|
60
63
|
protected readonly attachments: AttachmentStore;
|
|
61
64
|
protected readonly env: Record<string, string>;
|
|
@@ -224,6 +227,10 @@ export declare class ChatCore {
|
|
|
224
227
|
renameBookmark(chatId: string, itemId: string, name: string): Promise<ChatBookmark[]>;
|
|
225
228
|
removeBookmark(chatId: string, itemId: string): Promise<ChatBookmark[]>;
|
|
226
229
|
private requireBookmarks;
|
|
230
|
+
publishVisual(chatId: string, input: VisualInput): Promise<ChatVisual>;
|
|
231
|
+
listVisuals(chatId: string): Promise<ChatVisual[]>;
|
|
232
|
+
removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]>;
|
|
233
|
+
private requireVisuals;
|
|
227
234
|
history(chatId: string, cursor: string, limit?: number): ChatHistoryResult;
|
|
228
235
|
detach(chatId: string, clientId: string): void;
|
|
229
236
|
detachAll(clientId: string): void;
|
package/dist/chat/chat-core.js
CHANGED
|
@@ -46,6 +46,7 @@ export class ChatCore {
|
|
|
46
46
|
providers;
|
|
47
47
|
store;
|
|
48
48
|
bookmarks;
|
|
49
|
+
visuals;
|
|
49
50
|
accounts;
|
|
50
51
|
attachments;
|
|
51
52
|
env;
|
|
@@ -86,6 +87,7 @@ export class ChatCore {
|
|
|
86
87
|
this.providers = options.providers;
|
|
87
88
|
this.store = options.store ?? null;
|
|
88
89
|
this.bookmarks = options.bookmarks ?? null;
|
|
90
|
+
this.visuals = options.visuals ?? null;
|
|
89
91
|
this.accounts = options.accounts ?? null;
|
|
90
92
|
this.attachments = options.attachments;
|
|
91
93
|
this.checkpoints = options.checkpoints ?? null;
|
|
@@ -103,12 +105,17 @@ export class ChatCore {
|
|
|
103
105
|
this.spawn = options.spawn ?? null;
|
|
104
106
|
this.codexClient = options.codexClient ?? null;
|
|
105
107
|
this.env = definedEnv(options.env ?? process.env);
|
|
106
|
-
// Only whoever reads the thread has anything to point a bookmark at, so the
|
|
108
|
+
// Only whoever reads the thread has anything to point a bookmark at or to place a visual in, so the lists go where the thread goes.
|
|
107
109
|
this.bookmarks?.listen((chatId, bookmarks) => {
|
|
108
110
|
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
109
111
|
this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
|
|
110
112
|
}
|
|
111
113
|
});
|
|
114
|
+
this.visuals?.listen((chatId, visuals) => {
|
|
115
|
+
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
116
|
+
this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
112
119
|
this.claudeProjectsDir =
|
|
113
120
|
options.subagents?.claudeProjectsDir ?? usageRoots(options.env ?? process.env).find((root) => root.provider === 'claude')?.path ?? '';
|
|
114
121
|
this.subagents = new SubagentReader({
|
|
@@ -362,18 +369,22 @@ export class ChatCore {
|
|
|
362
369
|
: { info: session.info, ...session.thread.history(historyLimit), pending: session.thread.pending(), seq: log.seq };
|
|
363
370
|
}
|
|
364
371
|
/*
|
|
365
|
-
* `attach` with the chat's bookmarks. They are read before the client joins, so a
|
|
366
|
-
* after the read reaches it as `chat.bookmarks` and never falls
|
|
367
|
-
* cannot be read leaves
|
|
372
|
+
* `attach` with the chat's bookmarks and visuals. They are read before the client joins, so a
|
|
373
|
+
* change written after the read reaches it as `chat.bookmarks` or `chat.visuals` and never falls
|
|
374
|
+
* between the two. A file that cannot be read leaves its list out rather than the thread.
|
|
368
375
|
*/
|
|
369
376
|
async attachWithBookmarks(chatId, clientId, historyLimit, since) {
|
|
370
377
|
this.require(chatId);
|
|
371
|
-
const
|
|
372
|
-
console.error(`The
|
|
378
|
+
const unreadable = (what) => (e) => {
|
|
379
|
+
console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
|
|
373
380
|
return undefined;
|
|
374
|
-
}
|
|
381
|
+
};
|
|
382
|
+
const [bookmarks, visuals] = await Promise.all([
|
|
383
|
+
this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
|
|
384
|
+
this.visuals?.list(chatId).catch(unreadable('visuals'))
|
|
385
|
+
]);
|
|
375
386
|
const result = this.attach(chatId, clientId, historyLimit, since);
|
|
376
|
-
return bookmarks === undefined ?
|
|
387
|
+
return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
|
|
377
388
|
}
|
|
378
389
|
/* Marks a message of the chat's own thread; a message that already has one keeps it. */
|
|
379
390
|
addBookmark(chatId, itemId, name) {
|
|
@@ -397,6 +408,33 @@ export class ChatCore {
|
|
|
397
408
|
}
|
|
398
409
|
return this.bookmarks;
|
|
399
410
|
}
|
|
411
|
+
/*
|
|
412
|
+
* Publishes a page an agent made in a chat, for an app's own agent command. Without a `turnId` it
|
|
413
|
+
* belongs to the turn that is running, if one is. The chat may be one nobody loaded.
|
|
414
|
+
*/
|
|
415
|
+
async publishVisual(chatId, input) {
|
|
416
|
+
const visuals = this.requireVisuals();
|
|
417
|
+
await this.loaded(chatId);
|
|
418
|
+
if (!(await this.hasStored(chatId))) {
|
|
419
|
+
throw new ChatError('chat-not-found', `No chat ${chatId}`);
|
|
420
|
+
}
|
|
421
|
+
const turnId = input.turnId ?? this.chats.get(chatId)?.info.activeTurnId ?? undefined;
|
|
422
|
+
return visuals.publish(chatId, turnId === undefined ? input : { ...input, turnId });
|
|
423
|
+
}
|
|
424
|
+
/* The visuals of a chat in the order they were published. */
|
|
425
|
+
listVisuals(chatId) {
|
|
426
|
+
return this.requireVisuals().list(chatId);
|
|
427
|
+
}
|
|
428
|
+
removeVisual(chatId, visualId) {
|
|
429
|
+
this.require(chatId);
|
|
430
|
+
return this.requireVisuals().remove(chatId, visualId);
|
|
431
|
+
}
|
|
432
|
+
requireVisuals() {
|
|
433
|
+
if (!this.visuals) {
|
|
434
|
+
throw new ChatError('chat-unsupported', 'This host keeps no visuals');
|
|
435
|
+
}
|
|
436
|
+
return this.visuals;
|
|
437
|
+
}
|
|
400
438
|
history(chatId, cursor, limit) {
|
|
401
439
|
const session = this.require(chatId);
|
|
402
440
|
this.coalescers.get(chatId)?.flush();
|
|
@@ -510,7 +548,7 @@ export class ChatCore {
|
|
|
510
548
|
const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
|
|
511
549
|
return session.send(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
|
|
512
550
|
}
|
|
513
|
-
/* The file behind an attachment id: what this chat's thread or queue says it is. */
|
|
551
|
+
/* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
|
|
514
552
|
attachment(chatId, id) {
|
|
515
553
|
const session = this.chats.get(chatId);
|
|
516
554
|
if (!session) {
|
|
@@ -528,7 +566,7 @@ export class ChatCore {
|
|
|
528
566
|
return found;
|
|
529
567
|
}
|
|
530
568
|
}
|
|
531
|
-
return null;
|
|
569
|
+
return this.visuals?.attachment(chatId, id) ?? null;
|
|
532
570
|
}
|
|
533
571
|
unqueue(chatId, messageId) {
|
|
534
572
|
const message = this.require(chatId).unqueue(messageId);
|
|
@@ -554,7 +592,7 @@ export class ChatCore {
|
|
|
554
592
|
}
|
|
555
593
|
session.compact();
|
|
556
594
|
}
|
|
557
|
-
/* Empties the thread and drops the CLI's session and the chat's bookmarks; `force` stops a turn that is in the way. */
|
|
595
|
+
/* 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. */
|
|
558
596
|
async clear(chatId, force = false) {
|
|
559
597
|
const session = this.require(chatId);
|
|
560
598
|
if (session.busy && !force) {
|
|
@@ -567,7 +605,13 @@ export class ChatCore {
|
|
|
567
605
|
// A debounced write still waiting holds the old thread and must not land after the empty one.
|
|
568
606
|
this.cancelWaiting(chatId);
|
|
569
607
|
// Folded right away: every line before the reset describes a thread that is gone.
|
|
570
|
-
await Promise.all([
|
|
608
|
+
await Promise.all([
|
|
609
|
+
this.persistNow(chatId, true),
|
|
610
|
+
this.attachments.removeAll(chatId),
|
|
611
|
+
this.bookmarks?.removeChat(chatId),
|
|
612
|
+
this.visuals?.removeChat(chatId),
|
|
613
|
+
cleared
|
|
614
|
+
]);
|
|
571
615
|
}
|
|
572
616
|
/* Stops the running turn; with `subagents` also marks the CLI's own subagents stopped, which that turn no longer waits on. */
|
|
573
617
|
cancel(chatId, subagents = false) {
|
|
@@ -596,7 +640,7 @@ export class ChatCore {
|
|
|
596
640
|
throw new ChatError('request-not-found', `No question to dismiss under ${itemId}`);
|
|
597
641
|
}
|
|
598
642
|
}
|
|
599
|
-
/* Removes a chat: its CLI, its record, its log, its attachments and its
|
|
643
|
+
/* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
|
|
600
644
|
async kill(chatId) {
|
|
601
645
|
await this.loaded(chatId);
|
|
602
646
|
const session = this.require(chatId);
|
|
@@ -619,7 +663,13 @@ export class ChatCore {
|
|
|
619
663
|
const snapshot = session.thread.snapshot();
|
|
620
664
|
this.forgotten(chatId);
|
|
621
665
|
await writing;
|
|
622
|
-
await Promise.all([
|
|
666
|
+
await Promise.all([
|
|
667
|
+
this.store?.delete(chatId),
|
|
668
|
+
this.attachments.removeAll(chatId),
|
|
669
|
+
this.bookmarks?.removeChat(chatId),
|
|
670
|
+
this.visuals?.removeChat(chatId),
|
|
671
|
+
this.removed(chatId, snapshot)
|
|
672
|
+
]);
|
|
623
673
|
}
|
|
624
674
|
/*
|
|
625
675
|
* Ends the CLI of a chat and keeps its thread. A chat nobody loaded has no CLI to end, and a host
|
package/dist/chat/chat-store.js
CHANGED
|
@@ -8,6 +8,7 @@ import { isBookmarkFileName } from './bookmark-store.js';
|
|
|
8
8
|
import { migrateInlineAttachments } from './attachment-store.js';
|
|
9
9
|
import { parseLog } from './chat-log.js';
|
|
10
10
|
import { ChatThread } from './thread.js';
|
|
11
|
+
import { isVisualFileName } from './visual-store.js';
|
|
11
12
|
import { recordFileName } from '../record-directory.js';
|
|
12
13
|
// zod strips what it does not know inside the info and the items, so a file written by an older build
|
|
13
14
|
// (with `interactionMode`, say) still parses and loses only the dropped field. A record without `seq`
|
|
@@ -62,8 +63,8 @@ export class ChatStore {
|
|
|
62
63
|
throw e;
|
|
63
64
|
}
|
|
64
65
|
const ids = names
|
|
65
|
-
// A chat's bookmarks, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
|
|
66
|
-
.filter((name) => (name.endsWith('.json') && !isBookmarkFileName(name) && !this.isSidecar(name)) || name.endsWith('.log'))
|
|
66
|
+
// A chat's bookmarks and visuals, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
|
|
67
|
+
.filter((name) => (name.endsWith('.json') && !isBookmarkFileName(name) && !isVisualFileName(name) && !this.isSidecar(name)) || name.endsWith('.log'))
|
|
67
68
|
.map((name) => decodeURIComponent(name.replace(/\.(json|log)$/, '')));
|
|
68
69
|
return [...new Set(ids)];
|
|
69
70
|
}
|
package/dist/chat/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { CodedError } from '../coded-error.ts';
|
|
2
|
-
export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks';
|
|
2
|
+
export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
|
|
3
3
|
export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
|
|
4
4
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ChatAttachment, type ChatVisual } from '@adecore/agent-contracts';
|
|
2
|
+
import type { AttachmentStore } from './attachment-store.ts';
|
|
3
|
+
export declare function visualFileName(chatId: string): string;
|
|
4
|
+
export declare function isVisualFileName(name: string): boolean;
|
|
5
|
+
export interface VisualInput {
|
|
6
|
+
title: string;
|
|
7
|
+
html: string;
|
|
8
|
+
maxHeight?: number;
|
|
9
|
+
heights?: ReadonlyArray<readonly [number, number]>;
|
|
10
|
+
turnId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface VisualStoreOptions {
|
|
13
|
+
now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
export type VisualListener = (chatId: string, visuals: ChatVisual[]) => void;
|
|
16
|
+
export declare class VisualStore {
|
|
17
|
+
readonly dir: string;
|
|
18
|
+
private readonly attachments;
|
|
19
|
+
private readonly now;
|
|
20
|
+
private readonly writes;
|
|
21
|
+
private readonly listeners;
|
|
22
|
+
private readonly known;
|
|
23
|
+
constructor(home: string, attachments: AttachmentStore, options?: VisualStoreOptions);
|
|
24
|
+
listen(listener: VisualListener): () => void;
|
|
25
|
+
list(chatId: string): Promise<ChatVisual[]>;
|
|
26
|
+
publish(chatId: string, input: VisualInput): Promise<ChatVisual>;
|
|
27
|
+
remove(chatId: string, visualId: string): Promise<ChatVisual[]>;
|
|
28
|
+
removeChat(chatId: string): Promise<void>;
|
|
29
|
+
copyChat(fromChatId: string, toChatId: string, keep?: (visual: ChatVisual) => boolean): Promise<ChatVisual[]>;
|
|
30
|
+
attachment(chatId: string, visualId: string): ChatAttachment | null;
|
|
31
|
+
pagePath(chatId: string, visualId: string): string;
|
|
32
|
+
private tell;
|
|
33
|
+
private readFile;
|
|
34
|
+
private writeFile;
|
|
35
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ChatVisualsSchema, injectVisualBootstrap, VISUAL_LIMITS } from '@adecore/agent-contracts';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { isNotFound, writeAtomic } from '../fs.js';
|
|
7
|
+
import { KeyedSerializer } from '../serializer.js';
|
|
8
|
+
import { ChatError } from './errors.js';
|
|
9
|
+
const SUFFIX = '.visuals.json';
|
|
10
|
+
const VisualFileSchema = z.object({ version: z.literal(1), visuals: ChatVisualsSchema });
|
|
11
|
+
export function visualFileName(chatId) {
|
|
12
|
+
return `${encodeURIComponent(chatId)}${SUFFIX}`;
|
|
13
|
+
}
|
|
14
|
+
/* Whether a name in the chats folder is a visuals file, which the chat records beside it must not count as a chat. */
|
|
15
|
+
export function isVisualFileName(name) {
|
|
16
|
+
return name.endsWith(SUFFIX);
|
|
17
|
+
}
|
|
18
|
+
const MIB = 1024 * 1024;
|
|
19
|
+
function mebibytes(bytes) {
|
|
20
|
+
return `${(bytes / MIB).toFixed(1)} MiB`;
|
|
21
|
+
}
|
|
22
|
+
function refuse(code, message) {
|
|
23
|
+
throw new ChatError(code, message);
|
|
24
|
+
}
|
|
25
|
+
function isSize(value) {
|
|
26
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
27
|
+
}
|
|
28
|
+
/* The input as it is kept, or a refusal that tells the agent what to change. */
|
|
29
|
+
function checked(input) {
|
|
30
|
+
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
|
31
|
+
if (title === '') {
|
|
32
|
+
refuse('visual-invalid', 'A visual needs a title; give it a short one that says what the page shows');
|
|
33
|
+
}
|
|
34
|
+
if (title.length > VISUAL_LIMITS.title) {
|
|
35
|
+
refuse('visual-invalid', `The title has ${title.length} characters; keep it to ${VISUAL_LIMITS.title} or fewer`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof input.html !== 'string' || input.html.trim() === '') {
|
|
38
|
+
refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
|
|
39
|
+
}
|
|
40
|
+
const maxHeight = input.maxHeight ?? VISUAL_LIMITS.maxHeight;
|
|
41
|
+
if (!isSize(maxHeight) || maxHeight < VISUAL_LIMITS.minHeight || maxHeight > VISUAL_LIMITS.maxHeight) {
|
|
42
|
+
refuse('visual-invalid', `The maximum height must be from ${VISUAL_LIMITS.minHeight} to ${VISUAL_LIMITS.maxHeight} CSS pixels; leave it out for ${VISUAL_LIMITS.maxHeight}`);
|
|
43
|
+
}
|
|
44
|
+
const heights = input.heights ?? [];
|
|
45
|
+
if (!Array.isArray(heights) ||
|
|
46
|
+
heights.length > VISUAL_LIMITS.heights ||
|
|
47
|
+
heights.some((pair) => !Array.isArray(pair) || !isSize(pair[0]) || !isSize(pair[1]) || pair[0] <= 0 || pair[1] < 0)) {
|
|
48
|
+
refuse('visual-invalid', `Measured heights are at most ${VISUAL_LIMITS.heights} pairs of [width, height] in CSS pixels, with a positive width`);
|
|
49
|
+
}
|
|
50
|
+
// One measurement per width, the last one given, ascending as `visualFrameHeight` reads them.
|
|
51
|
+
const byWidth = new Map(heights.map((pair) => [Math.max(1, Math.round(pair[0])), Math.ceil(pair[1])]));
|
|
52
|
+
const sorted = [...byWidth].sort((a, b) => a[0] - b[0]);
|
|
53
|
+
return { title, html: input.html, maxHeight: Math.round(maxHeight), heights: sorted.length === 0 ? undefined : sorted };
|
|
54
|
+
}
|
|
55
|
+
function tooLarge(bytes) {
|
|
56
|
+
return refuse('visual-too-large', `The page is ${mebibytes(bytes)} and a visual may be at most ${mebibytes(VISUAL_LIMITS.bytes)}; ` +
|
|
57
|
+
'load libraries from a public CDN URL and refer to large images by their http(s) URL instead of inlining them');
|
|
58
|
+
}
|
|
59
|
+
/*
|
|
60
|
+
* The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
|
|
61
|
+
* each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
|
|
62
|
+
* serves a page by its id unchanged. One chain per chat puts a publish and a removal after each other,
|
|
63
|
+
* and every change is told to the listeners from inside that chain.
|
|
64
|
+
*/
|
|
65
|
+
export class VisualStore {
|
|
66
|
+
dir;
|
|
67
|
+
attachments;
|
|
68
|
+
now;
|
|
69
|
+
writes = new KeyedSerializer();
|
|
70
|
+
listeners = new Set();
|
|
71
|
+
// The lists last read or written, so an attachment lookup, which cannot wait, finds a page.
|
|
72
|
+
known = new Map();
|
|
73
|
+
constructor(home, attachments, options = {}) {
|
|
74
|
+
this.dir = join(home, 'chats');
|
|
75
|
+
this.attachments = attachments;
|
|
76
|
+
this.now = options.now ?? Date.now;
|
|
77
|
+
}
|
|
78
|
+
listen(listener) {
|
|
79
|
+
this.listeners.add(listener);
|
|
80
|
+
return () => {
|
|
81
|
+
this.listeners.delete(listener);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/* The visuals of one chat in the order they were published, after any write still on its way. */
|
|
85
|
+
list(chatId) {
|
|
86
|
+
return this.writes.run(chatId, () => this.readFile(chatId));
|
|
87
|
+
}
|
|
88
|
+
/* Stores a page with its bootstrap and adds it to the chat at the host's current time. */
|
|
89
|
+
async publish(chatId, input) {
|
|
90
|
+
const { title, html, maxHeight, heights } = checked(input);
|
|
91
|
+
// Before the bootstrap is added, so a page far too large is never copied first.
|
|
92
|
+
if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
|
|
93
|
+
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
|
|
96
|
+
if (page.byteLength > VISUAL_LIMITS.bytes) {
|
|
97
|
+
tooLarge(page.byteLength);
|
|
98
|
+
}
|
|
99
|
+
const turnId = typeof input.turnId === 'string' && input.turnId !== '' ? input.turnId : undefined;
|
|
100
|
+
return this.writes.run(chatId, async () => {
|
|
101
|
+
const visuals = await this.readFile(chatId);
|
|
102
|
+
const visual = {
|
|
103
|
+
id: randomBytes(8).toString('hex'),
|
|
104
|
+
title,
|
|
105
|
+
at: this.now(),
|
|
106
|
+
maxHeight,
|
|
107
|
+
...(heights === undefined ? {} : { heights }),
|
|
108
|
+
size: page.byteLength,
|
|
109
|
+
...(turnId === undefined ? {} : { turnId })
|
|
110
|
+
};
|
|
111
|
+
await mkdir(this.attachments.folderOf(chatId), { recursive: true, mode: 0o700 });
|
|
112
|
+
await writeAtomic(this.pagePath(chatId, visual.id), page);
|
|
113
|
+
const next = [...visuals, visual];
|
|
114
|
+
try {
|
|
115
|
+
await this.writeFile(chatId, next);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
await rm(this.pagePath(chatId, visual.id), { force: true });
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
this.tell(chatId, next);
|
|
122
|
+
return visual;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/* Takes a visual and its page away; one that is already gone changes nothing. */
|
|
126
|
+
remove(chatId, visualId) {
|
|
127
|
+
return this.writes.run(chatId, async () => {
|
|
128
|
+
const visuals = await this.readFile(chatId);
|
|
129
|
+
if (!visuals.some((visual) => visual.id === visualId)) {
|
|
130
|
+
return visuals;
|
|
131
|
+
}
|
|
132
|
+
const next = visuals.filter((visual) => visual.id !== visualId);
|
|
133
|
+
await this.writeFile(chatId, next);
|
|
134
|
+
await rm(this.pagePath(chatId, visualId), { force: true });
|
|
135
|
+
this.tell(chatId, next);
|
|
136
|
+
return next;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/* The visuals go with the chat or with a clear of it. A list that no longer parses goes too, since nothing could show it again. */
|
|
140
|
+
removeChat(chatId) {
|
|
141
|
+
return this.writes.run(chatId, async () => {
|
|
142
|
+
const visuals = await this.readFile(chatId).catch(() => null);
|
|
143
|
+
await rm(join(this.dir, visualFileName(chatId)), { force: true });
|
|
144
|
+
await Promise.all((visuals ?? []).map((visual) => rm(this.pagePath(chatId, visual.id), { force: true })));
|
|
145
|
+
this.known.delete(chatId);
|
|
146
|
+
if (visuals === null || visuals.length > 0) {
|
|
147
|
+
this.tell(chatId, []);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/*
|
|
152
|
+
* Gives a fork the visuals `keep` picks, every one without it, each with a page of its own, so
|
|
153
|
+
* removing either chat leaves the other's pages where they are. A page that is gone is left out.
|
|
154
|
+
*/
|
|
155
|
+
async copyChat(fromChatId, toChatId, keep = () => true) {
|
|
156
|
+
const kept = (await this.list(fromChatId)).filter(keep);
|
|
157
|
+
if (kept.length === 0) {
|
|
158
|
+
return this.list(toChatId);
|
|
159
|
+
}
|
|
160
|
+
return this.writes.run(toChatId, async () => {
|
|
161
|
+
const copied = [];
|
|
162
|
+
await mkdir(this.attachments.folderOf(toChatId), { recursive: true, mode: 0o700 });
|
|
163
|
+
for (const visual of kept) {
|
|
164
|
+
const page = await readFile(this.pagePath(fromChatId, visual.id)).catch((e) => {
|
|
165
|
+
if (isNotFound(e)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
throw e;
|
|
169
|
+
});
|
|
170
|
+
if (page !== null) {
|
|
171
|
+
await writeAtomic(this.pagePath(toChatId, visual.id), page);
|
|
172
|
+
copied.push(visual);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const existing = await this.readFile(toChatId);
|
|
176
|
+
const ids = new Set(existing.map((visual) => visual.id));
|
|
177
|
+
const next = [...existing, ...copied.filter((visual) => !ids.has(visual.id))];
|
|
178
|
+
await this.writeFile(toChatId, next);
|
|
179
|
+
this.tell(toChatId, next);
|
|
180
|
+
return next;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/* A page as an attachment of its chat, from the lists this store has seen; null for one it does not know. */
|
|
184
|
+
attachment(chatId, visualId) {
|
|
185
|
+
const visual = this.known.get(chatId)?.find((candidate) => candidate.id === visualId);
|
|
186
|
+
if (!visual) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return { id: visual.id, name: `${visual.title}.html`, mime: 'text/html', size: visual.size, path: this.pagePath(chatId, visual.id) };
|
|
190
|
+
}
|
|
191
|
+
pagePath(chatId, visualId) {
|
|
192
|
+
return join(this.attachments.folderOf(chatId), `${visualId}.html`);
|
|
193
|
+
}
|
|
194
|
+
tell(chatId, visuals) {
|
|
195
|
+
for (const listener of this.listeners) {
|
|
196
|
+
listener(chatId, visuals);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async readFile(chatId) {
|
|
200
|
+
let raw;
|
|
201
|
+
try {
|
|
202
|
+
raw = await readFile(join(this.dir, visualFileName(chatId)), 'utf8');
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
if (isNotFound(e)) {
|
|
206
|
+
this.known.delete(chatId);
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
// A file that does not parse is refused rather than read as empty, which the next write would make true.
|
|
212
|
+
const parsed = VisualFileSchema.safeParse(JSON.parse(raw));
|
|
213
|
+
if (!parsed.success) {
|
|
214
|
+
throw new Error(`The visuals file of chat ${chatId} is not valid: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`);
|
|
215
|
+
}
|
|
216
|
+
this.known.set(chatId, parsed.data.visuals);
|
|
217
|
+
return parsed.data.visuals;
|
|
218
|
+
}
|
|
219
|
+
async writeFile(chatId, visuals) {
|
|
220
|
+
const path = join(this.dir, visualFileName(chatId));
|
|
221
|
+
if (visuals.length === 0) {
|
|
222
|
+
await rm(path, { force: true });
|
|
223
|
+
this.known.delete(chatId);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
227
|
+
await writeAtomic(path, JSON.stringify({ version: 1, visuals }));
|
|
228
|
+
this.known.set(chatId, [...visuals]);
|
|
229
|
+
}
|
|
230
|
+
}
|
package/dist/host/handlers.js
CHANGED
|
@@ -70,6 +70,7 @@ export function chatHandlers(chats, providers) {
|
|
|
70
70
|
'chat.addBookmark': async (payload) => ({ bookmarks: await chats.addBookmark(payload.chatId, payload.itemId, payload.name) }),
|
|
71
71
|
'chat.renameBookmark': async (payload) => ({ bookmarks: await chats.renameBookmark(payload.chatId, payload.itemId, payload.name) }),
|
|
72
72
|
'chat.removeBookmark': async (payload) => ({ bookmarks: await chats.removeBookmark(payload.chatId, payload.itemId) }),
|
|
73
|
+
'chat.removeVisual': async (payload) => ({ visuals: await chats.removeVisual(payload.chatId, payload.visualId) }),
|
|
73
74
|
'chat.kill': async (payload) => {
|
|
74
75
|
await chats.kill(payload.chatId);
|
|
75
76
|
return {};
|
package/dist/host/wiring.js
CHANGED
|
@@ -3,6 +3,7 @@ import { BookmarkStore } from '../chat/bookmark-store.js';
|
|
|
3
3
|
import { ChatCore } from '../chat/chat-core.js';
|
|
4
4
|
import { ChatStore } from '../chat/chat-store.js';
|
|
5
5
|
import { suggestChatTitle } from '../chat/chat-title.js';
|
|
6
|
+
import { VisualStore } from '../chat/visual-store.js';
|
|
6
7
|
import { ClaudeTitleReader } from '../chat/claude-title.js';
|
|
7
8
|
import { DEFAULT_CODEX_CLIENT } from '../chat/codex-transport.js';
|
|
8
9
|
import { definedEnv } from '../providers/accounts/launch.js';
|
|
@@ -58,6 +59,7 @@ export function wireAgents(options) {
|
|
|
58
59
|
store: new ChatStore(options.dataDir, { attachments }),
|
|
59
60
|
attachments,
|
|
60
61
|
bookmarks: new BookmarkStore(options.dataDir),
|
|
62
|
+
visuals: new VisualStore(options.dataDir, attachments),
|
|
61
63
|
env,
|
|
62
64
|
...(options.systemNote === undefined ? {} : { instructions: options.systemNote }),
|
|
63
65
|
...(options.command ? { command: options.command } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"test": "bun --conditions=source test"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@adecore/agent-contracts": "0.
|
|
23
|
+
"@adecore/agent-contracts": "0.18.0",
|
|
24
24
|
"zod": "^4.6.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
@@ -50,7 +50,7 @@ export class AttachmentStore {
|
|
|
50
50
|
const bytes = Buffer.from(upload.data, 'base64');
|
|
51
51
|
const mime = attachmentImageMime(upload) ?? upload.mime;
|
|
52
52
|
const id = randomBytes(8).toString('hex');
|
|
53
|
-
const folder = this.
|
|
53
|
+
const folder = this.folderOf(chatId);
|
|
54
54
|
await mkdir(folder, { recursive: true, mode: 0o700 });
|
|
55
55
|
const path = join(folder, `${id}.${extensionFor(upload.name, mime)}`);
|
|
56
56
|
await writeFile(path, bytes, { mode: 0o600 });
|
|
@@ -59,10 +59,11 @@ export class AttachmentStore {
|
|
|
59
59
|
|
|
60
60
|
/* Everything a chat attached; used when the chat itself is deleted. */
|
|
61
61
|
async removeAll(chatId: string): Promise<void> {
|
|
62
|
-
await rm(this.
|
|
62
|
+
await rm(this.folderOf(chatId), { recursive: true, force: true });
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
/* Where the files of one chat are; a store that keeps more of a chat's files beside them writes them here. */
|
|
66
|
+
folderOf(chatId: string): string {
|
|
66
67
|
return join(this.dir, encodeURIComponent(chatId));
|
|
67
68
|
}
|
|
68
69
|
}
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ChatSubagentPayload,
|
|
20
20
|
type ChatSubagentResult,
|
|
21
21
|
type ChatTurnItem,
|
|
22
|
+
type ChatVisual,
|
|
22
23
|
type ModelSelection,
|
|
23
24
|
type RuntimeMode
|
|
24
25
|
} from '@adecore/agent-contracts';
|
|
@@ -54,6 +55,7 @@ import { DeltaCoalescer } from './delta-coalescer.ts';
|
|
|
54
55
|
import { ChatError } from './errors.ts';
|
|
55
56
|
import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
|
|
56
57
|
import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
|
|
58
|
+
import type { VisualInput, VisualStore } from './visual-store.ts';
|
|
57
59
|
|
|
58
60
|
/* A turn that was running when the host went down, and the attempt that would take it up again. */
|
|
59
61
|
export interface InterruptedRun {
|
|
@@ -115,6 +117,8 @@ export interface ChatCoreOptions {
|
|
|
115
117
|
// Where the files people attach are written.
|
|
116
118
|
attachments: AttachmentStore;
|
|
117
119
|
bookmarks?: BookmarkStore;
|
|
120
|
+
// Pages agents publish in a chat; their files go in the same attachment folders.
|
|
121
|
+
visuals?: VisualStore;
|
|
118
122
|
// What a running turn says about the plan its CLI runs on; the usage monitor takes it from here.
|
|
119
123
|
onLimits?: (update: LimitsUpdate) => void;
|
|
120
124
|
// Where Claude Code's own name for a session is read, in the projects folder of the chat's account; the other CLIs write none down.
|
|
@@ -151,6 +155,7 @@ export class ChatCore {
|
|
|
151
155
|
protected readonly providers: ProviderRegistry;
|
|
152
156
|
protected readonly store: ChatStore | null;
|
|
153
157
|
protected readonly bookmarks: BookmarkStore | null;
|
|
158
|
+
protected readonly visuals: VisualStore | null;
|
|
154
159
|
protected readonly accounts: AccountLaunches | null;
|
|
155
160
|
protected readonly attachments: AttachmentStore;
|
|
156
161
|
protected readonly env: Record<string, string>;
|
|
@@ -192,6 +197,7 @@ export class ChatCore {
|
|
|
192
197
|
this.providers = options.providers;
|
|
193
198
|
this.store = options.store ?? null;
|
|
194
199
|
this.bookmarks = options.bookmarks ?? null;
|
|
200
|
+
this.visuals = options.visuals ?? null;
|
|
195
201
|
this.accounts = options.accounts ?? null;
|
|
196
202
|
this.attachments = options.attachments;
|
|
197
203
|
this.checkpoints = options.checkpoints ?? null;
|
|
@@ -209,12 +215,17 @@ export class ChatCore {
|
|
|
209
215
|
this.spawn = options.spawn ?? null;
|
|
210
216
|
this.codexClient = options.codexClient ?? null;
|
|
211
217
|
this.env = definedEnv(options.env ?? process.env);
|
|
212
|
-
// Only whoever reads the thread has anything to point a bookmark at, so the
|
|
218
|
+
// Only whoever reads the thread has anything to point a bookmark at or to place a visual in, so the lists go where the thread goes.
|
|
213
219
|
this.bookmarks?.listen((chatId, bookmarks) => {
|
|
214
220
|
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
215
221
|
this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
|
|
216
222
|
}
|
|
217
223
|
});
|
|
224
|
+
this.visuals?.listen((chatId, visuals) => {
|
|
225
|
+
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
226
|
+
this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
218
229
|
this.claudeProjectsDir =
|
|
219
230
|
options.subagents?.claudeProjectsDir ?? usageRoots(options.env ?? process.env).find((root) => root.provider === 'claude')?.path ?? '';
|
|
220
231
|
this.subagents = new SubagentReader({
|
|
@@ -492,18 +503,22 @@ export class ChatCore {
|
|
|
492
503
|
}
|
|
493
504
|
|
|
494
505
|
/*
|
|
495
|
-
* `attach` with the chat's bookmarks. They are read before the client joins, so a
|
|
496
|
-
* after the read reaches it as `chat.bookmarks` and never falls
|
|
497
|
-
* cannot be read leaves
|
|
506
|
+
* `attach` with the chat's bookmarks and visuals. They are read before the client joins, so a
|
|
507
|
+
* change written after the read reaches it as `chat.bookmarks` or `chat.visuals` and never falls
|
|
508
|
+
* between the two. A file that cannot be read leaves its list out rather than the thread.
|
|
498
509
|
*/
|
|
499
510
|
async attachWithBookmarks(chatId: string, clientId: string, historyLimit?: number, since?: number): Promise<ChatAttachResult> {
|
|
500
511
|
this.require(chatId);
|
|
501
|
-
const
|
|
502
|
-
console.error(`The
|
|
512
|
+
const unreadable = (what: string) => (e: unknown) => {
|
|
513
|
+
console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
|
|
503
514
|
return undefined;
|
|
504
|
-
}
|
|
515
|
+
};
|
|
516
|
+
const [bookmarks, visuals] = await Promise.all([
|
|
517
|
+
this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
|
|
518
|
+
this.visuals?.list(chatId).catch(unreadable('visuals'))
|
|
519
|
+
]);
|
|
505
520
|
const result = this.attach(chatId, clientId, historyLimit, since);
|
|
506
|
-
return bookmarks === undefined ?
|
|
521
|
+
return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
|
|
507
522
|
}
|
|
508
523
|
|
|
509
524
|
/* Marks a message of the chat's own thread; a message that already has one keeps it. */
|
|
@@ -532,6 +547,37 @@ export class ChatCore {
|
|
|
532
547
|
return this.bookmarks;
|
|
533
548
|
}
|
|
534
549
|
|
|
550
|
+
/*
|
|
551
|
+
* Publishes a page an agent made in a chat, for an app's own agent command. Without a `turnId` it
|
|
552
|
+
* belongs to the turn that is running, if one is. The chat may be one nobody loaded.
|
|
553
|
+
*/
|
|
554
|
+
async publishVisual(chatId: string, input: VisualInput): Promise<ChatVisual> {
|
|
555
|
+
const visuals = this.requireVisuals();
|
|
556
|
+
await this.loaded(chatId);
|
|
557
|
+
if (!(await this.hasStored(chatId))) {
|
|
558
|
+
throw new ChatError('chat-not-found', `No chat ${chatId}`);
|
|
559
|
+
}
|
|
560
|
+
const turnId = input.turnId ?? this.chats.get(chatId)?.info.activeTurnId ?? undefined;
|
|
561
|
+
return visuals.publish(chatId, turnId === undefined ? input : { ...input, turnId });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* The visuals of a chat in the order they were published. */
|
|
565
|
+
listVisuals(chatId: string): Promise<ChatVisual[]> {
|
|
566
|
+
return this.requireVisuals().list(chatId);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]> {
|
|
570
|
+
this.require(chatId);
|
|
571
|
+
return this.requireVisuals().remove(chatId, visualId);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private requireVisuals(): VisualStore {
|
|
575
|
+
if (!this.visuals) {
|
|
576
|
+
throw new ChatError('chat-unsupported', 'This host keeps no visuals');
|
|
577
|
+
}
|
|
578
|
+
return this.visuals;
|
|
579
|
+
}
|
|
580
|
+
|
|
535
581
|
history(chatId: string, cursor: string, limit?: number): ChatHistoryResult {
|
|
536
582
|
const session = this.require(chatId);
|
|
537
583
|
this.coalescers.get(chatId)?.flush();
|
|
@@ -657,7 +703,7 @@ export class ChatCore {
|
|
|
657
703
|
return session.send(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
|
|
658
704
|
}
|
|
659
705
|
|
|
660
|
-
/* The file behind an attachment id: what this chat's thread or queue says it is. */
|
|
706
|
+
/* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
|
|
661
707
|
attachment(chatId: string, id: string): ChatAttachment | null {
|
|
662
708
|
const session = this.chats.get(chatId);
|
|
663
709
|
if (!session) {
|
|
@@ -675,7 +721,7 @@ export class ChatCore {
|
|
|
675
721
|
return found;
|
|
676
722
|
}
|
|
677
723
|
}
|
|
678
|
-
return null;
|
|
724
|
+
return this.visuals?.attachment(chatId, id) ?? null;
|
|
679
725
|
}
|
|
680
726
|
|
|
681
727
|
unqueue(chatId: string, messageId: string): ChatQueuedMessage {
|
|
@@ -706,7 +752,7 @@ export class ChatCore {
|
|
|
706
752
|
session.compact();
|
|
707
753
|
}
|
|
708
754
|
|
|
709
|
-
/* Empties the thread and drops the CLI's session and the chat's bookmarks; `force` stops a turn that is in the way. */
|
|
755
|
+
/* 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. */
|
|
710
756
|
async clear(chatId: string, force = false): Promise<void> {
|
|
711
757
|
const session = this.require(chatId);
|
|
712
758
|
if (session.busy && !force) {
|
|
@@ -719,7 +765,13 @@ export class ChatCore {
|
|
|
719
765
|
// A debounced write still waiting holds the old thread and must not land after the empty one.
|
|
720
766
|
this.cancelWaiting(chatId);
|
|
721
767
|
// Folded right away: every line before the reset describes a thread that is gone.
|
|
722
|
-
await Promise.all([
|
|
768
|
+
await Promise.all([
|
|
769
|
+
this.persistNow(chatId, true),
|
|
770
|
+
this.attachments.removeAll(chatId),
|
|
771
|
+
this.bookmarks?.removeChat(chatId),
|
|
772
|
+
this.visuals?.removeChat(chatId),
|
|
773
|
+
cleared
|
|
774
|
+
]);
|
|
723
775
|
}
|
|
724
776
|
|
|
725
777
|
/* Stops the running turn; with `subagents` also marks the CLI's own subagents stopped, which that turn no longer waits on. */
|
|
@@ -754,7 +806,7 @@ export class ChatCore {
|
|
|
754
806
|
}
|
|
755
807
|
}
|
|
756
808
|
|
|
757
|
-
/* Removes a chat: its CLI, its record, its log, its attachments and its
|
|
809
|
+
/* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
|
|
758
810
|
async kill(chatId: string): Promise<void> {
|
|
759
811
|
await this.loaded(chatId);
|
|
760
812
|
const session = this.require(chatId);
|
|
@@ -777,7 +829,13 @@ export class ChatCore {
|
|
|
777
829
|
const snapshot = session.thread.snapshot();
|
|
778
830
|
this.forgotten(chatId);
|
|
779
831
|
await writing;
|
|
780
|
-
await Promise.all([
|
|
832
|
+
await Promise.all([
|
|
833
|
+
this.store?.delete(chatId),
|
|
834
|
+
this.attachments.removeAll(chatId),
|
|
835
|
+
this.bookmarks?.removeChat(chatId),
|
|
836
|
+
this.visuals?.removeChat(chatId),
|
|
837
|
+
this.removed(chatId, snapshot)
|
|
838
|
+
]);
|
|
781
839
|
}
|
|
782
840
|
|
|
783
841
|
/*
|
package/src/chat/chat-store.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { isBookmarkFileName } from './bookmark-store.ts';
|
|
|
8
8
|
import { migrateInlineAttachments, type AttachmentStore } from './attachment-store.ts';
|
|
9
9
|
import { parseLog, type ChatLogLine } from './chat-log.ts';
|
|
10
10
|
import { ChatThread } from './thread.ts';
|
|
11
|
+
import { isVisualFileName } from './visual-store.ts';
|
|
11
12
|
import { recordFileName } from '../record-directory.ts';
|
|
12
13
|
|
|
13
14
|
// zod strips what it does not know inside the info and the items, so a file written by an older build
|
|
@@ -98,8 +99,10 @@ export class ChatStore {
|
|
|
98
99
|
throw e;
|
|
99
100
|
}
|
|
100
101
|
const ids = names
|
|
101
|
-
// A chat's bookmarks, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
|
|
102
|
-
.filter(
|
|
102
|
+
// A chat's bookmarks and visuals, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
|
|
103
|
+
.filter(
|
|
104
|
+
(name) => (name.endsWith('.json') && !isBookmarkFileName(name) && !isVisualFileName(name) && !this.isSidecar(name)) || name.endsWith('.log')
|
|
105
|
+
)
|
|
103
106
|
.map((name) => decodeURIComponent(name.replace(/\.(json|log)$/, '')));
|
|
104
107
|
return [...new Set(ids)];
|
|
105
108
|
}
|
package/src/chat/errors.ts
CHANGED
|
@@ -19,7 +19,10 @@ export type ChatErrorCode =
|
|
|
19
19
|
// What a bookmark is refused with.
|
|
20
20
|
| 'item-not-found'
|
|
21
21
|
| 'bookmark-not-found'
|
|
22
|
-
| 'too-many-bookmarks'
|
|
22
|
+
| 'too-many-bookmarks'
|
|
23
|
+
// What publishing a visual is refused with; the message says what to change.
|
|
24
|
+
| 'visual-invalid'
|
|
25
|
+
| 'visual-too-large';
|
|
23
26
|
|
|
24
27
|
/* A host that refuses more than a chat does widens the codes with its own. */
|
|
25
28
|
export class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ChatVisualsSchema, injectVisualBootstrap, VISUAL_LIMITS, type ChatAttachment, type ChatVisual, type VisualHeight } from '@adecore/agent-contracts';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { isNotFound, writeAtomic } from '../fs.ts';
|
|
7
|
+
import { KeyedSerializer } from '../serializer.ts';
|
|
8
|
+
import type { AttachmentStore } from './attachment-store.ts';
|
|
9
|
+
import { ChatError } from './errors.ts';
|
|
10
|
+
|
|
11
|
+
const SUFFIX = '.visuals.json';
|
|
12
|
+
|
|
13
|
+
const VisualFileSchema = z.object({ version: z.literal(1), visuals: ChatVisualsSchema });
|
|
14
|
+
|
|
15
|
+
export function visualFileName(chatId: string): string {
|
|
16
|
+
return `${encodeURIComponent(chatId)}${SUFFIX}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Whether a name in the chats folder is a visuals file, which the chat records beside it must not count as a chat. */
|
|
20
|
+
export function isVisualFileName(name: string): boolean {
|
|
21
|
+
return name.endsWith(SUFFIX);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* What an agent hands over: the page as it wrote it, which gets its bootstrap here. */
|
|
25
|
+
export interface VisualInput {
|
|
26
|
+
title: string;
|
|
27
|
+
html: string;
|
|
28
|
+
// CSS pixels; absent, the largest a frame may be.
|
|
29
|
+
maxHeight?: number;
|
|
30
|
+
// `[width, height]` in CSS pixels, from a host that measured the page before publishing it.
|
|
31
|
+
heights?: ReadonlyArray<readonly [number, number]>;
|
|
32
|
+
turnId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface VisualStoreOptions {
|
|
36
|
+
// The clock `at` is stamped with, which has to be the one the thread's `createdAt` comes from.
|
|
37
|
+
now?: () => number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type VisualListener = (chatId: string, visuals: ChatVisual[]) => void;
|
|
41
|
+
|
|
42
|
+
const MIB = 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
function mebibytes(bytes: number): string {
|
|
45
|
+
return `${(bytes / MIB).toFixed(1)} MiB`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function refuse(code: 'visual-invalid' | 'visual-too-large', message: string): never {
|
|
49
|
+
throw new ChatError(code, message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isSize(value: unknown): value is number {
|
|
53
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* The input as it is kept, or a refusal that tells the agent what to change. */
|
|
57
|
+
function checked(input: VisualInput): { title: string; html: string; maxHeight: number; heights: VisualHeight[] | undefined } {
|
|
58
|
+
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
|
59
|
+
if (title === '') {
|
|
60
|
+
refuse('visual-invalid', 'A visual needs a title; give it a short one that says what the page shows');
|
|
61
|
+
}
|
|
62
|
+
if (title.length > VISUAL_LIMITS.title) {
|
|
63
|
+
refuse('visual-invalid', `The title has ${title.length} characters; keep it to ${VISUAL_LIMITS.title} or fewer`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof input.html !== 'string' || input.html.trim() === '') {
|
|
66
|
+
refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
|
|
67
|
+
}
|
|
68
|
+
const maxHeight = input.maxHeight ?? VISUAL_LIMITS.maxHeight;
|
|
69
|
+
if (!isSize(maxHeight) || maxHeight < VISUAL_LIMITS.minHeight || maxHeight > VISUAL_LIMITS.maxHeight) {
|
|
70
|
+
refuse(
|
|
71
|
+
'visual-invalid',
|
|
72
|
+
`The maximum height must be from ${VISUAL_LIMITS.minHeight} to ${VISUAL_LIMITS.maxHeight} CSS pixels; leave it out for ${VISUAL_LIMITS.maxHeight}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const heights = input.heights ?? [];
|
|
76
|
+
if (
|
|
77
|
+
!Array.isArray(heights) ||
|
|
78
|
+
heights.length > VISUAL_LIMITS.heights ||
|
|
79
|
+
heights.some((pair) => !Array.isArray(pair) || !isSize(pair[0]) || !isSize(pair[1]) || pair[0] <= 0 || pair[1] < 0)
|
|
80
|
+
) {
|
|
81
|
+
refuse('visual-invalid', `Measured heights are at most ${VISUAL_LIMITS.heights} pairs of [width, height] in CSS pixels, with a positive width`);
|
|
82
|
+
}
|
|
83
|
+
// One measurement per width, the last one given, ascending as `visualFrameHeight` reads them.
|
|
84
|
+
const byWidth = new Map(heights.map((pair): [number, number] => [Math.max(1, Math.round(pair[0])), Math.ceil(pair[1])]));
|
|
85
|
+
const sorted = [...byWidth].sort((a, b) => a[0] - b[0]);
|
|
86
|
+
return { title, html: input.html, maxHeight: Math.round(maxHeight), heights: sorted.length === 0 ? undefined : sorted };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tooLarge(bytes: number): never {
|
|
90
|
+
return refuse(
|
|
91
|
+
'visual-too-large',
|
|
92
|
+
`The page is ${mebibytes(bytes)} and a visual may be at most ${mebibytes(VISUAL_LIMITS.bytes)}; ` +
|
|
93
|
+
'load libraries from a public CDN URL and refer to large images by their http(s) URL instead of inlining them'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
|
|
99
|
+
* each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
|
|
100
|
+
* serves a page by its id unchanged. One chain per chat puts a publish and a removal after each other,
|
|
101
|
+
* and every change is told to the listeners from inside that chain.
|
|
102
|
+
*/
|
|
103
|
+
export class VisualStore {
|
|
104
|
+
readonly dir: string;
|
|
105
|
+
private readonly attachments: AttachmentStore;
|
|
106
|
+
private readonly now: () => number;
|
|
107
|
+
private readonly writes = new KeyedSerializer();
|
|
108
|
+
private readonly listeners = new Set<VisualListener>();
|
|
109
|
+
// The lists last read or written, so an attachment lookup, which cannot wait, finds a page.
|
|
110
|
+
private readonly known = new Map<string, ChatVisual[]>();
|
|
111
|
+
|
|
112
|
+
constructor(home: string, attachments: AttachmentStore, options: VisualStoreOptions = {}) {
|
|
113
|
+
this.dir = join(home, 'chats');
|
|
114
|
+
this.attachments = attachments;
|
|
115
|
+
this.now = options.now ?? Date.now;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
listen(listener: VisualListener): () => void {
|
|
119
|
+
this.listeners.add(listener);
|
|
120
|
+
return () => {
|
|
121
|
+
this.listeners.delete(listener);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* The visuals of one chat in the order they were published, after any write still on its way. */
|
|
126
|
+
list(chatId: string): Promise<ChatVisual[]> {
|
|
127
|
+
return this.writes.run(chatId, () => this.readFile(chatId));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* Stores a page with its bootstrap and adds it to the chat at the host's current time. */
|
|
131
|
+
async publish(chatId: string, input: VisualInput): Promise<ChatVisual> {
|
|
132
|
+
const { title, html, maxHeight, heights } = checked(input);
|
|
133
|
+
// Before the bootstrap is added, so a page far too large is never copied first.
|
|
134
|
+
if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
|
|
135
|
+
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
136
|
+
}
|
|
137
|
+
const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
|
|
138
|
+
if (page.byteLength > VISUAL_LIMITS.bytes) {
|
|
139
|
+
tooLarge(page.byteLength);
|
|
140
|
+
}
|
|
141
|
+
const turnId = typeof input.turnId === 'string' && input.turnId !== '' ? input.turnId : undefined;
|
|
142
|
+
return this.writes.run(chatId, async () => {
|
|
143
|
+
const visuals = await this.readFile(chatId);
|
|
144
|
+
const visual: ChatVisual = {
|
|
145
|
+
id: randomBytes(8).toString('hex'),
|
|
146
|
+
title,
|
|
147
|
+
at: this.now(),
|
|
148
|
+
maxHeight,
|
|
149
|
+
...(heights === undefined ? {} : { heights }),
|
|
150
|
+
size: page.byteLength,
|
|
151
|
+
...(turnId === undefined ? {} : { turnId })
|
|
152
|
+
};
|
|
153
|
+
await mkdir(this.attachments.folderOf(chatId), { recursive: true, mode: 0o700 });
|
|
154
|
+
await writeAtomic(this.pagePath(chatId, visual.id), page);
|
|
155
|
+
const next = [...visuals, visual];
|
|
156
|
+
try {
|
|
157
|
+
await this.writeFile(chatId, next);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
await rm(this.pagePath(chatId, visual.id), { force: true });
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
this.tell(chatId, next);
|
|
163
|
+
return visual;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Takes a visual and its page away; one that is already gone changes nothing. */
|
|
168
|
+
remove(chatId: string, visualId: string): Promise<ChatVisual[]> {
|
|
169
|
+
return this.writes.run(chatId, async () => {
|
|
170
|
+
const visuals = await this.readFile(chatId);
|
|
171
|
+
if (!visuals.some((visual) => visual.id === visualId)) {
|
|
172
|
+
return visuals;
|
|
173
|
+
}
|
|
174
|
+
const next = visuals.filter((visual) => visual.id !== visualId);
|
|
175
|
+
await this.writeFile(chatId, next);
|
|
176
|
+
await rm(this.pagePath(chatId, visualId), { force: true });
|
|
177
|
+
this.tell(chatId, next);
|
|
178
|
+
return next;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* The visuals go with the chat or with a clear of it. A list that no longer parses goes too, since nothing could show it again. */
|
|
183
|
+
removeChat(chatId: string): Promise<void> {
|
|
184
|
+
return this.writes.run(chatId, async () => {
|
|
185
|
+
const visuals = await this.readFile(chatId).catch(() => null);
|
|
186
|
+
await rm(join(this.dir, visualFileName(chatId)), { force: true });
|
|
187
|
+
await Promise.all((visuals ?? []).map((visual) => rm(this.pagePath(chatId, visual.id), { force: true })));
|
|
188
|
+
this.known.delete(chatId);
|
|
189
|
+
if (visuals === null || visuals.length > 0) {
|
|
190
|
+
this.tell(chatId, []);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/*
|
|
196
|
+
* Gives a fork the visuals `keep` picks, every one without it, each with a page of its own, so
|
|
197
|
+
* removing either chat leaves the other's pages where they are. A page that is gone is left out.
|
|
198
|
+
*/
|
|
199
|
+
async copyChat(fromChatId: string, toChatId: string, keep: (visual: ChatVisual) => boolean = () => true): Promise<ChatVisual[]> {
|
|
200
|
+
const kept = (await this.list(fromChatId)).filter(keep);
|
|
201
|
+
if (kept.length === 0) {
|
|
202
|
+
return this.list(toChatId);
|
|
203
|
+
}
|
|
204
|
+
return this.writes.run(toChatId, async () => {
|
|
205
|
+
const copied: ChatVisual[] = [];
|
|
206
|
+
await mkdir(this.attachments.folderOf(toChatId), { recursive: true, mode: 0o700 });
|
|
207
|
+
for (const visual of kept) {
|
|
208
|
+
const page = await readFile(this.pagePath(fromChatId, visual.id)).catch((e: unknown) => {
|
|
209
|
+
if (isNotFound(e)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
throw e;
|
|
213
|
+
});
|
|
214
|
+
if (page !== null) {
|
|
215
|
+
await writeAtomic(this.pagePath(toChatId, visual.id), page);
|
|
216
|
+
copied.push(visual);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const existing = await this.readFile(toChatId);
|
|
220
|
+
const ids = new Set(existing.map((visual) => visual.id));
|
|
221
|
+
const next = [...existing, ...copied.filter((visual) => !ids.has(visual.id))];
|
|
222
|
+
await this.writeFile(toChatId, next);
|
|
223
|
+
this.tell(toChatId, next);
|
|
224
|
+
return next;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* A page as an attachment of its chat, from the lists this store has seen; null for one it does not know. */
|
|
229
|
+
attachment(chatId: string, visualId: string): ChatAttachment | null {
|
|
230
|
+
const visual = this.known.get(chatId)?.find((candidate) => candidate.id === visualId);
|
|
231
|
+
if (!visual) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return { id: visual.id, name: `${visual.title}.html`, mime: 'text/html', size: visual.size, path: this.pagePath(chatId, visual.id) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
pagePath(chatId: string, visualId: string): string {
|
|
238
|
+
return join(this.attachments.folderOf(chatId), `${visualId}.html`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private tell(chatId: string, visuals: ChatVisual[]): void {
|
|
242
|
+
for (const listener of this.listeners) {
|
|
243
|
+
listener(chatId, visuals);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async readFile(chatId: string): Promise<ChatVisual[]> {
|
|
248
|
+
let raw: string;
|
|
249
|
+
try {
|
|
250
|
+
raw = await readFile(join(this.dir, visualFileName(chatId)), 'utf8');
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if (isNotFound(e)) {
|
|
253
|
+
this.known.delete(chatId);
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
258
|
+
// A file that does not parse is refused rather than read as empty, which the next write would make true.
|
|
259
|
+
const parsed = VisualFileSchema.safeParse(JSON.parse(raw));
|
|
260
|
+
if (!parsed.success) {
|
|
261
|
+
throw new Error(`The visuals file of chat ${chatId} is not valid: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`);
|
|
262
|
+
}
|
|
263
|
+
this.known.set(chatId, parsed.data.visuals);
|
|
264
|
+
return parsed.data.visuals;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async writeFile(chatId: string, visuals: readonly ChatVisual[]): Promise<void> {
|
|
268
|
+
const path = join(this.dir, visualFileName(chatId));
|
|
269
|
+
if (visuals.length === 0) {
|
|
270
|
+
await rm(path, { force: true });
|
|
271
|
+
this.known.delete(chatId);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
275
|
+
await writeAtomic(path, JSON.stringify({ version: 1, visuals }));
|
|
276
|
+
this.known.set(chatId, [...visuals]);
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/host/handlers.ts
CHANGED
|
@@ -99,6 +99,7 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
|
|
|
99
99
|
'chat.addBookmark': async (payload) => ({ bookmarks: await chats.addBookmark(payload.chatId, payload.itemId, payload.name) }),
|
|
100
100
|
'chat.renameBookmark': async (payload) => ({ bookmarks: await chats.renameBookmark(payload.chatId, payload.itemId, payload.name) }),
|
|
101
101
|
'chat.removeBookmark': async (payload) => ({ bookmarks: await chats.removeBookmark(payload.chatId, payload.itemId) }),
|
|
102
|
+
'chat.removeVisual': async (payload) => ({ visuals: await chats.removeVisual(payload.chatId, payload.visualId) }),
|
|
102
103
|
'chat.kill': async (payload) => {
|
|
103
104
|
await chats.kill(payload.chatId);
|
|
104
105
|
return {};
|
package/src/host/wiring.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { ChatCore, type ChatCoreOptions } from '../chat/chat-core.ts';
|
|
|
5
5
|
import type { SpawnChatProcess } from '../chat/chat-process.ts';
|
|
6
6
|
import { ChatStore } from '../chat/chat-store.ts';
|
|
7
7
|
import { suggestChatTitle } from '../chat/chat-title.ts';
|
|
8
|
+
import { VisualStore } from '../chat/visual-store.ts';
|
|
8
9
|
import type { ClaudeBackendOptions } from '../chat/claude-backend.ts';
|
|
9
10
|
import { ClaudeTitleReader } from '../chat/claude-title.ts';
|
|
10
11
|
import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../chat/codex-transport.ts';
|
|
@@ -24,7 +25,7 @@ import { cliEnvironment, type CliEnvironmentPolicy } from './environment.ts';
|
|
|
24
25
|
import { accountHandlers, chatHandlers, usageHandlers, type AgentHandlers } from './handlers.ts';
|
|
25
26
|
|
|
26
27
|
export interface AgentWiringOptions<Core extends ChatCore = ChatCore> {
|
|
27
|
-
// Where the chats, their attachments and
|
|
28
|
+
// Where the chats, their attachments, bookmarks and visuals, the accounts and the usage index are kept.
|
|
28
29
|
dataDir: string;
|
|
29
30
|
// The environment the CLIs start in; absent, this process's own through `cliEnvironment`.
|
|
30
31
|
env?: Record<string, string | undefined>;
|
|
@@ -103,6 +104,7 @@ export function wireAgents<Core extends ChatCore = ChatCore>(options: AgentWirin
|
|
|
103
104
|
store: new ChatStore(options.dataDir, { attachments }),
|
|
104
105
|
attachments,
|
|
105
106
|
bookmarks: new BookmarkStore(options.dataDir),
|
|
107
|
+
visuals: new VisualStore(options.dataDir, attachments),
|
|
106
108
|
env,
|
|
107
109
|
...(options.systemNote === undefined ? {} : { instructions: options.systemNote }),
|
|
108
110
|
...(options.command ? { command: options.command } : {}),
|