@adecore/agents 0.18.4 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ChatAttachment, type ChatVisual } from '@adecore/agent-contracts';
|
|
1
|
+
import { type ChatAttachment, type ChatVisual, type VisualLayout } from '@adecore/agent-contracts';
|
|
2
2
|
import type { AttachmentStore } from './attachment-store.ts';
|
|
3
3
|
export declare function visualFileName(chatId: string): string;
|
|
4
4
|
export declare function isVisualFileName(name: string): boolean;
|
|
@@ -6,6 +6,7 @@ export interface VisualInput {
|
|
|
6
6
|
title: string;
|
|
7
7
|
html: string;
|
|
8
8
|
maxHeight?: number;
|
|
9
|
+
layout?: VisualLayout;
|
|
9
10
|
heights?: ReadonlyArray<readonly [number, number]>;
|
|
10
11
|
turnId?: string;
|
|
11
12
|
}
|
|
@@ -23,12 +24,17 @@ export declare class VisualStore {
|
|
|
23
24
|
constructor(home: string, attachments: AttachmentStore, options?: VisualStoreOptions);
|
|
24
25
|
listen(listener: VisualListener): () => void;
|
|
25
26
|
list(chatId: string): Promise<ChatVisual[]>;
|
|
27
|
+
workspacePath(chatId: string): string;
|
|
28
|
+
prepareWorkspace(chatId: string): Promise<string>;
|
|
29
|
+
writeSource(chatId: string, name: string, html: string): Promise<string>;
|
|
30
|
+
writePreview(chatId: string, png: Uint8Array): Promise<string>;
|
|
26
31
|
publish(chatId: string, input: VisualInput): Promise<ChatVisual>;
|
|
27
32
|
remove(chatId: string, visualId: string): Promise<ChatVisual[]>;
|
|
28
33
|
removeChat(chatId: string): Promise<void>;
|
|
29
34
|
copyChat(fromChatId: string, toChatId: string, keep?: (visual: ChatVisual) => boolean): Promise<ChatVisual[]>;
|
|
30
35
|
attachment(chatId: string, visualId: string): ChatAttachment | null;
|
|
31
36
|
pagePath(chatId: string, visualId: string): string;
|
|
37
|
+
private writeWorkspaceFile;
|
|
32
38
|
private tell;
|
|
33
39
|
private readFile;
|
|
34
40
|
private writeFile;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { ChatVisualsSchema, injectVisualBootstrap, VISUAL_LIMITS } from '@adecore/agent-contracts';
|
|
4
|
+
import { ChatVisualsSchema, injectVisualBootstrap, VISUAL_LIMITS, VisualLayoutSchema } from '@adecore/agent-contracts';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { isNotFound, writeAtomic } from '../fs.js';
|
|
7
7
|
import { KeyedSerializer } from '../serializer.js';
|
|
@@ -41,6 +41,9 @@ function checked(input) {
|
|
|
41
41
|
if (!isSize(maxHeight) || maxHeight < VISUAL_LIMITS.minHeight || maxHeight > VISUAL_LIMITS.maxHeight) {
|
|
42
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
43
|
}
|
|
44
|
+
if (input.layout !== undefined && !VisualLayoutSchema.safeParse(input.layout).success) {
|
|
45
|
+
refuse('visual-invalid', 'The layout must be inline or wide; leave it out for the reply column');
|
|
46
|
+
}
|
|
44
47
|
const heights = input.heights ?? [];
|
|
45
48
|
if (!Array.isArray(heights) ||
|
|
46
49
|
heights.length > VISUAL_LIMITS.heights ||
|
|
@@ -50,7 +53,7 @@ function checked(input) {
|
|
|
50
53
|
// One measurement per width, the last one given, ascending as `visualFrameHeight` reads them.
|
|
51
54
|
const byWidth = new Map(heights.map((pair) => [Math.max(1, Math.round(pair[0])), Math.ceil(pair[1])]));
|
|
52
55
|
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 };
|
|
56
|
+
return { title, html: input.html, maxHeight: Math.round(maxHeight), layout: input.layout, heights: sorted.length === 0 ? undefined : sorted };
|
|
54
57
|
}
|
|
55
58
|
function tooLarge(bytes) {
|
|
56
59
|
return refuse('visual-too-large', `The page is ${mebibytes(bytes)} and a visual may be at most ${mebibytes(VISUAL_LIMITS.bytes)}; ` +
|
|
@@ -85,9 +88,33 @@ export class VisualStore {
|
|
|
85
88
|
list(chatId) {
|
|
86
89
|
return this.writes.run(chatId, () => this.readFile(chatId));
|
|
87
90
|
}
|
|
91
|
+
workspacePath(chatId) {
|
|
92
|
+
return join(this.dir, `${encodeURIComponent(chatId)}.visuals`);
|
|
93
|
+
}
|
|
94
|
+
async prepareWorkspace(chatId) {
|
|
95
|
+
const path = this.workspacePath(chatId);
|
|
96
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
97
|
+
return path;
|
|
98
|
+
}
|
|
99
|
+
// Only a filename is accepted; an agent cannot make the host write outside its own chat.
|
|
100
|
+
async writeSource(chatId, name, html) {
|
|
101
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}\.html$/.test(name)) {
|
|
102
|
+
refuse('visual-invalid', 'Use an HTML filename such as chart.html, with letters, numbers, dots, hyphens or underscores and no directory');
|
|
103
|
+
}
|
|
104
|
+
if (html.trim() === '') {
|
|
105
|
+
refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
|
|
106
|
+
}
|
|
107
|
+
if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
|
|
108
|
+
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
109
|
+
}
|
|
110
|
+
return this.writeWorkspaceFile(chatId, name, html);
|
|
111
|
+
}
|
|
112
|
+
writePreview(chatId, png) {
|
|
113
|
+
return this.writeWorkspaceFile(chatId, `preview-${randomBytes(8).toString('hex')}.png`, png);
|
|
114
|
+
}
|
|
88
115
|
/* Stores a page with its bootstrap and adds it to the chat at the host's current time. */
|
|
89
116
|
async publish(chatId, input) {
|
|
90
|
-
const { title, html, maxHeight, heights } = checked(input);
|
|
117
|
+
const { title, html, maxHeight, layout, heights } = checked(input);
|
|
91
118
|
// Before the bootstrap is added, so a page far too large is never copied first.
|
|
92
119
|
if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
|
|
93
120
|
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
@@ -104,6 +131,7 @@ export class VisualStore {
|
|
|
104
131
|
title,
|
|
105
132
|
at: this.now(),
|
|
106
133
|
maxHeight,
|
|
134
|
+
...(layout === undefined ? {} : { layout }),
|
|
107
135
|
...(heights === undefined ? {} : { heights }),
|
|
108
136
|
size: page.byteLength,
|
|
109
137
|
...(turnId === undefined ? {} : { turnId })
|
|
@@ -141,6 +169,7 @@ export class VisualStore {
|
|
|
141
169
|
return this.writes.run(chatId, async () => {
|
|
142
170
|
const visuals = await this.readFile(chatId).catch(() => null);
|
|
143
171
|
await rm(join(this.dir, visualFileName(chatId)), { force: true });
|
|
172
|
+
await rm(this.workspacePath(chatId), { recursive: true, force: true });
|
|
144
173
|
await Promise.all((visuals ?? []).map((visual) => rm(this.pagePath(chatId, visual.id), { force: true })));
|
|
145
174
|
this.known.delete(chatId);
|
|
146
175
|
if (visuals === null || visuals.length > 0) {
|
|
@@ -191,6 +220,13 @@ export class VisualStore {
|
|
|
191
220
|
pagePath(chatId, visualId) {
|
|
192
221
|
return join(this.attachments.folderOf(chatId), `${visualId}.html`);
|
|
193
222
|
}
|
|
223
|
+
writeWorkspaceFile(chatId, name, content) {
|
|
224
|
+
return this.writes.run(chatId, async () => {
|
|
225
|
+
const path = join(await this.prepareWorkspace(chatId), name);
|
|
226
|
+
await writeAtomic(path, content);
|
|
227
|
+
return path;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
194
230
|
tell(chatId, visuals) {
|
|
195
231
|
for (const listener of this.listeners) {
|
|
196
232
|
listener(chatId, visuals);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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.20.0",
|
|
24
24
|
"zod": "^4.6.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
package/src/chat/visual-store.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ChatVisualsSchema,
|
|
6
|
+
injectVisualBootstrap,
|
|
7
|
+
VISUAL_LIMITS,
|
|
8
|
+
VisualLayoutSchema,
|
|
9
|
+
type ChatAttachment,
|
|
10
|
+
type ChatVisual,
|
|
11
|
+
type VisualHeight,
|
|
12
|
+
type VisualLayout
|
|
13
|
+
} from '@adecore/agent-contracts';
|
|
5
14
|
import { z } from 'zod';
|
|
6
15
|
import { isNotFound, writeAtomic } from '../fs.ts';
|
|
7
16
|
import { KeyedSerializer } from '../serializer.ts';
|
|
@@ -27,6 +36,7 @@ export interface VisualInput {
|
|
|
27
36
|
html: string;
|
|
28
37
|
// CSS pixels; absent, the largest a frame may be.
|
|
29
38
|
maxHeight?: number;
|
|
39
|
+
layout?: VisualLayout;
|
|
30
40
|
// `[width, height]` in CSS pixels, from a host that measured the page before publishing it.
|
|
31
41
|
heights?: ReadonlyArray<readonly [number, number]>;
|
|
32
42
|
turnId?: string;
|
|
@@ -54,7 +64,13 @@ function isSize(value: unknown): value is number {
|
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/* The input as it is kept, or a refusal that tells the agent what to change. */
|
|
57
|
-
function checked(input: VisualInput): {
|
|
67
|
+
function checked(input: VisualInput): {
|
|
68
|
+
title: string;
|
|
69
|
+
html: string;
|
|
70
|
+
maxHeight: number;
|
|
71
|
+
layout: VisualLayout | undefined;
|
|
72
|
+
heights: VisualHeight[] | undefined;
|
|
73
|
+
} {
|
|
58
74
|
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
|
59
75
|
if (title === '') {
|
|
60
76
|
refuse('visual-invalid', 'A visual needs a title; give it a short one that says what the page shows');
|
|
@@ -72,6 +88,9 @@ function checked(input: VisualInput): { title: string; html: string; maxHeight:
|
|
|
72
88
|
`The maximum height must be from ${VISUAL_LIMITS.minHeight} to ${VISUAL_LIMITS.maxHeight} CSS pixels; leave it out for ${VISUAL_LIMITS.maxHeight}`
|
|
73
89
|
);
|
|
74
90
|
}
|
|
91
|
+
if (input.layout !== undefined && !VisualLayoutSchema.safeParse(input.layout).success) {
|
|
92
|
+
refuse('visual-invalid', 'The layout must be inline or wide; leave it out for the reply column');
|
|
93
|
+
}
|
|
75
94
|
const heights = input.heights ?? [];
|
|
76
95
|
if (
|
|
77
96
|
!Array.isArray(heights) ||
|
|
@@ -83,7 +102,7 @@ function checked(input: VisualInput): { title: string; html: string; maxHeight:
|
|
|
83
102
|
// One measurement per width, the last one given, ascending as `visualFrameHeight` reads them.
|
|
84
103
|
const byWidth = new Map(heights.map((pair): [number, number] => [Math.max(1, Math.round(pair[0])), Math.ceil(pair[1])]));
|
|
85
104
|
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 };
|
|
105
|
+
return { title, html: input.html, maxHeight: Math.round(maxHeight), layout: input.layout, heights: sorted.length === 0 ? undefined : sorted };
|
|
87
106
|
}
|
|
88
107
|
|
|
89
108
|
function tooLarge(bytes: number): never {
|
|
@@ -127,9 +146,37 @@ export class VisualStore {
|
|
|
127
146
|
return this.writes.run(chatId, () => this.readFile(chatId));
|
|
128
147
|
}
|
|
129
148
|
|
|
149
|
+
workspacePath(chatId: string): string {
|
|
150
|
+
return join(this.dir, `${encodeURIComponent(chatId)}.visuals`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async prepareWorkspace(chatId: string): Promise<string> {
|
|
154
|
+
const path = this.workspacePath(chatId);
|
|
155
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
156
|
+
return path;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Only a filename is accepted; an agent cannot make the host write outside its own chat.
|
|
160
|
+
async writeSource(chatId: string, name: string, html: string): Promise<string> {
|
|
161
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}\.html$/.test(name)) {
|
|
162
|
+
refuse('visual-invalid', 'Use an HTML filename such as chart.html, with letters, numbers, dots, hyphens or underscores and no directory');
|
|
163
|
+
}
|
|
164
|
+
if (html.trim() === '') {
|
|
165
|
+
refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
|
|
166
|
+
}
|
|
167
|
+
if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
|
|
168
|
+
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
169
|
+
}
|
|
170
|
+
return this.writeWorkspaceFile(chatId, name, html);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
writePreview(chatId: string, png: Uint8Array): Promise<string> {
|
|
174
|
+
return this.writeWorkspaceFile(chatId, `preview-${randomBytes(8).toString('hex')}.png`, png);
|
|
175
|
+
}
|
|
176
|
+
|
|
130
177
|
/* Stores a page with its bootstrap and adds it to the chat at the host's current time. */
|
|
131
178
|
async publish(chatId: string, input: VisualInput): Promise<ChatVisual> {
|
|
132
|
-
const { title, html, maxHeight, heights } = checked(input);
|
|
179
|
+
const { title, html, maxHeight, layout, heights } = checked(input);
|
|
133
180
|
// Before the bootstrap is added, so a page far too large is never copied first.
|
|
134
181
|
if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
|
|
135
182
|
tooLarge(Buffer.byteLength(html, 'utf8'));
|
|
@@ -146,6 +193,7 @@ export class VisualStore {
|
|
|
146
193
|
title,
|
|
147
194
|
at: this.now(),
|
|
148
195
|
maxHeight,
|
|
196
|
+
...(layout === undefined ? {} : { layout }),
|
|
149
197
|
...(heights === undefined ? {} : { heights }),
|
|
150
198
|
size: page.byteLength,
|
|
151
199
|
...(turnId === undefined ? {} : { turnId })
|
|
@@ -184,6 +232,7 @@ export class VisualStore {
|
|
|
184
232
|
return this.writes.run(chatId, async () => {
|
|
185
233
|
const visuals = await this.readFile(chatId).catch(() => null);
|
|
186
234
|
await rm(join(this.dir, visualFileName(chatId)), { force: true });
|
|
235
|
+
await rm(this.workspacePath(chatId), { recursive: true, force: true });
|
|
187
236
|
await Promise.all((visuals ?? []).map((visual) => rm(this.pagePath(chatId, visual.id), { force: true })));
|
|
188
237
|
this.known.delete(chatId);
|
|
189
238
|
if (visuals === null || visuals.length > 0) {
|
|
@@ -238,6 +287,14 @@ export class VisualStore {
|
|
|
238
287
|
return join(this.attachments.folderOf(chatId), `${visualId}.html`);
|
|
239
288
|
}
|
|
240
289
|
|
|
290
|
+
private writeWorkspaceFile(chatId: string, name: string, content: string | Uint8Array): Promise<string> {
|
|
291
|
+
return this.writes.run(chatId, async () => {
|
|
292
|
+
const path = join(await this.prepareWorkspace(chatId), name);
|
|
293
|
+
await writeAtomic(path, content);
|
|
294
|
+
return path;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
241
298
|
private tell(chatId: string, visuals: ChatVisual[]): void {
|
|
242
299
|
for (const listener of this.listeners) {
|
|
243
300
|
listener(chatId, visuals);
|