@adecore/agents 0.18.3 → 0.19.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.18.3",
3
+ "version": "0.19.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.18.3",
23
+ "@adecore/agent-contracts": "0.19.0",
24
24
  "zod": "^4.6.5"
25
25
  },
26
26
  "devDependencies": {
@@ -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 { ChatVisualsSchema, injectVisualBootstrap, VISUAL_LIMITS, type ChatAttachment, type ChatVisual, type VisualHeight } from '@adecore/agent-contracts';
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): { title: string; html: string; maxHeight: number; heights: VisualHeight[] | undefined } {
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);