@columenlabs/lumen-ai 0.1.189 → 0.1.191

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/README.md CHANGED
@@ -102,6 +102,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
102
102
  - **Web.** `fetch` is a plain GET from the Worker (10 s, 2 MB, never the app's own hosts or private addresses). ScrapeQL's own tools join over its MCP API once an admin pastes a Cloudflare Access service token (`PUT /web/scrapeql`). A Worker on ScrapeQL's own Cloudflare account passes its service binding instead, `scrapeql: { url: 'https://scrapeql/mcp', fetch: env.SCRAPEQL.fetch.bind(env.SCRAPEQL) }`: no token is stored, the tools are always on and Settings shows the link with a Test.
103
103
  - **Hand off and advice.** The `escalate` tool hands one task to a higher tier (the person sets next, any or only when asked); Anthropic's advisor tool lets a higher tier send a plan, at most two an answer.
104
104
  - **Chats** run on the agent (`./agent`, `LumenAgent`, below): the app routes `POST /chat` and each chat's reads, cards and Undo to its own agent, and `assistantRoutes` answers `POST /chat` with 410 `moved`. Chats saved by the old turn loop stay readable (`GET /chats`, `GET|DELETE /chats/:id`) until `assistant.purge()` drops them 30 days after their last message.
105
+ - **Screen moves**: while the person's Settings ▸ Screen is Move (the default), the panel sends its page map (`AssistantMessage.screen`: routes, nav pages, Settings sections, cards on screen, rows to open) and `LumenAgent` gives the model a `screen` tool (`./agent` `screen.ts`). Each call's moves (go, settings, chart, open, point) are checked against the map, kept in the chat and streamed as a `screen` event; the panel runs them through `AssistantScreenProvider` (mounted by `LumenShell`) with an Undo chip, and their outcomes go back with the next message (`outcomes`). Nothing is saved by a move.
105
106
  - **Background work** an app runs for a person (a score, a summary) is `assistant.complete(caller, { task, system, prompt, images?, maxTokens })`: one answer on the task's tier (`tasks`, so the person picks its model in Settings ▸ Models), no chat and no tools (`images`, base64 with their media type, are read with the prompt: a scanned page, a screenshot), billed to their payer and counted in their spend and allowance like a chat turn. It throws `AssistantError` `not_connected`, `app_cap` or `own_cap` when nobody can pay, so the caller waits and tries later.
106
107
 
107
108
  ### Commands, `@` and `#`
@@ -251,3 +252,24 @@ class Chat extends LumenAgent<Env> {
251
252
  protected undoWrite(card: AgentCard) { return assistant.undoChange(caller, card); }
252
253
  }
253
254
  ```
255
+
256
+ ## Workspaces: tabs over recipes, run again on demand
257
+
258
+ `@columenlabs/lumen-ai/workspaces` is the server the kit's `WorkspacesSection` and `WorkspacePage` call. A workspace is a person's page of tabs; each tab is a table over a **frame**: the rows one **recipe** pulled, the recipe itself and the person's hand edits. A recipe is one call to one of the product's read tools (`{kind: 'call', fn: 'jobs.list', args}`), so running it again needs no model. Nothing runs on a timer: ↻ on a tab runs its recipe, ↻ in the header runs every tab one after another, each within 15 s (`RUN_MS`); a failed run keeps the last rows and says why.
259
+
260
+ ```ts
261
+ import { createWorkspaces, doSql, workspaceMigrations, workspaceRoutes } from '@columenlabs/lumen-ai/workspaces';
262
+
263
+ // In the store that holds the person's data (a Durable Object's SQLite, or D1 with d1Sql):
264
+ for (const q of workspaceMigrations({ spaces: 'app_ws_spaces', frames: 'app_ws_frames' })) sql.exec(q);
265
+ const store = createWorkspaces({
266
+ db: doSql(sql),
267
+ tables: { spaces: 'app_ws_spaces', frames: 'app_ws_frames' },
268
+ run: (recipe, { owner, signal }) => callReadTool(owner, recipe, signal), // the product's own tools, as the person
269
+ });
270
+
271
+ // In the Worker, at the assistant's api:
272
+ const routes = workspaceRoutes({ base: '/api/assistant', owner: callerOf, store: () => store });
273
+ ```
274
+
275
+ Budget: one frame is one row (at most 1.5 MB of rows, 10,000 rows, 50 columns), so a run writes one row; every read has a LIMIT (30 workspaces, 12 tabs each, 100 frames). Columns are typed from their values (number, date, text); a list of words, or short text from a small set, becomes a tags column (chips). The kit's `WorkspacesProvider` takes `recipes` to offer + New tab from a recipe in the tab bar.
@@ -12,7 +12,7 @@
12
12
  * An `Assistant` gives both: `assistant.agentTurn(caller, input)` returns a plan and its settle.
13
13
  */
14
14
  import { type ChatResponseResult, type PrepareStepContext, type Session, type StepConfig, type StepContext, Think, type ThinkModel, type TurnConfig, type TurnContext } from '@cloudflare/think';
15
- import type { AssistantChat, AssistantEffort, AssistantEvent, AssistantRefNote, ChatUsage, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
15
+ import type { AssistantChat, AssistantEffort, AssistantEvent, AssistantRefNote, AssistantScreenMap, AssistantScreenOutcome, ChatUsage, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
16
16
  import { type ModelMessage, type ToolSet } from 'ai';
17
17
  import type { ModelDef } from '../assistant/models.js';
18
18
  import { type AgentConnector, type ConnectorAt } from './connectors.js';
@@ -72,6 +72,8 @@ export interface AgentTurnPlan {
72
72
  };
73
73
  /** Dollars the payer has left; at or past it the model gets no more tools. */
74
74
  left?: number;
75
+ /** The person lets the assistant move their screen: the `screen` tool joins when the browser sent its map. */
76
+ screen?: boolean;
75
77
  /** Most model calls in one answer; 8. */
76
78
  maxSteps?: number;
77
79
  /** Fetch for provider calls (a metered one). */
@@ -121,6 +123,10 @@ export interface AskInput {
121
123
  refs?: AssistantRefNote[];
122
124
  tier?: string;
123
125
  time_zone?: string;
126
+ /** What the browser can show (Screen on): the `screen` tool and its rules join the turn. */
127
+ screen?: AssistantScreenMap;
128
+ /** How earlier screen calls ended in the browser. */
129
+ outcomes?: AssistantScreenOutcome[];
124
130
  }
125
131
  /** Where an answer's events go: the app's route, over RPC. */
126
132
  export interface AskSink {
@@ -17,6 +17,7 @@ import { createCompactFunction } from 'agents/sessions';
17
17
  import { generateText } from 'ai';
18
18
  import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connectorSource, } from './connectors.js';
19
19
  import { aiModel, planFallback, turnSettings } from './models.js';
20
+ import { screenMapOf, screenNews, screenOutcomesOf, screenRules, screenTool, } from './screen.js';
20
21
  import { LAZY_AT, TOOL_SEARCH, lazyTools } from './search.js';
21
22
  import { chunkEvents, itemsOf } from './stream.js';
22
23
  import { openAiTools, } from './tools.js';
@@ -136,7 +137,7 @@ export class LumenAgent extends Think {
136
137
  id,
137
138
  title: this.#get('title') ?? '',
138
139
  at: this.#get('at') ?? 0,
139
- items: itemsOf(await this.getMessages(), this.cards()),
140
+ items: itemsOf(await this.getMessages(), this.cards(), this.#screens()),
140
141
  usage: this.usage(),
141
142
  };
142
143
  }
@@ -158,18 +159,23 @@ export class LumenAgent extends Think {
158
159
  }
159
160
  this.#put('at', at);
160
161
  await sink.event({ event: 'chat', data: { id: this.name, title } });
162
+ this.#outcomes(screenOutcomesOf(input.outcomes));
161
163
  const meta = {
162
164
  at,
163
165
  ...(input.context ? { context: { label: input.context.label } } : {}),
164
166
  ...(input.refs?.length ? { refs: input.refs } : {}),
165
167
  ...(input.tier ? { tier: input.tier } : {}),
166
168
  };
167
- const map = chunkEvents({
169
+ const toEvents = chunkEvents({
168
170
  ...(this.#last?.plan.advisor ? { advisor: this.#last.plan.advisor.label } : {}),
169
171
  cards: () => new Map(this.cards().map((c) => [c.id, c])),
172
+ screens: () => this.#screens(),
170
173
  kindOf: (n) => this.#turn?.tools.info.find((i) => i.name === n)?.kind,
171
174
  });
172
- this.#input = input;
175
+ const map = screenMapOf(input.screen);
176
+ this.#input = { ...input, ...(map ? { screen: map } : {}) };
177
+ if (!map)
178
+ delete this.#input.screen;
173
179
  let failed = false;
174
180
  try {
175
181
  await this.chat({
@@ -187,7 +193,7 @@ export class LumenAgent extends Think {
187
193
  catch {
188
194
  return;
189
195
  }
190
- for (const e of map(chunk))
196
+ for (const e of toEvents(chunk))
191
197
  await sink.event(e);
192
198
  },
193
199
  onDone: () => undefined,
@@ -240,6 +246,54 @@ export class LumenAgent extends Think {
240
246
  this
241
247
  .sql `UPDATE lumen_cards SET status = ${status}, result = COALESCE(${o.result === undefined ? null : JSON.stringify(o.result).slice(0, 20_000)}, result), error = ${o.error ?? null}, told = 0 WHERE id = ${id}`;
242
248
  }
249
+ #screensTabled = false;
250
+ #screenTable() {
251
+ if (!this.#screensTabled) {
252
+ this
253
+ .sql `CREATE TABLE IF NOT EXISTS lumen_screens (id TEXT PRIMARY KEY, label TEXT NOT NULL, moves TEXT NOT NULL, status TEXT NOT NULL, text TEXT, told INTEGER NOT NULL DEFAULT 1, at INTEGER NOT NULL)`;
254
+ this.#screensTabled = true;
255
+ }
256
+ }
257
+ /** The chat's screen calls (at most 200), by id. */
258
+ #screens() {
259
+ this.#screenTable();
260
+ const rows = this.sql `SELECT id, label, moves, status, text FROM lumen_screens ORDER BY at DESC LIMIT 200`;
261
+ return new Map(rows.map((r) => [
262
+ r.id,
263
+ {
264
+ id: r.id,
265
+ label: r.label,
266
+ moves: JSON.parse(r.moves),
267
+ status: r.status,
268
+ ...(r.text ? { text: r.text } : {}),
269
+ },
270
+ ]));
271
+ }
272
+ /** How screen calls ended in the browser: kept, and told to the model with its next turn. */
273
+ #outcomes(list) {
274
+ if (!list.length)
275
+ return;
276
+ this.#screenTable();
277
+ for (const o of list)
278
+ this
279
+ .sql `UPDATE lumen_screens SET status = ${o.status}, text = ${o.text ?? null}, told = 0 WHERE id = ${o.id} AND (status != ${o.status} OR COALESCE(text, '') != ${o.text ?? ''})`;
280
+ }
281
+ /** Screen calls whose end the model has not heard, as its note; marks them told. */
282
+ #screenNews() {
283
+ this.#screenTable();
284
+ const rows = this.sql `SELECT id, label, moves, status, text FROM lumen_screens WHERE told = 0 ORDER BY at LIMIT 20`;
285
+ if (!rows.length)
286
+ return '';
287
+ for (const r of rows)
288
+ this.sql `UPDATE lumen_screens SET told = 1 WHERE id = ${r.id}`;
289
+ return screenNews(rows.map((r) => ({
290
+ id: r.id,
291
+ label: r.label,
292
+ moves: JSON.parse(r.moves),
293
+ status: r.status,
294
+ ...(r.text ? { text: r.text } : {}),
295
+ })));
296
+ }
243
297
  #connectorClient() {
244
298
  return {
245
299
  listTools: () => this.getMcpServers().tools,
@@ -436,7 +490,19 @@ export class LumenAgent extends Think {
436
490
  maxUses: plan.advisor.maxUses,
437
491
  maxTokens: plan.advisor.maxTokens,
438
492
  });
439
- const tools = { ...opened.tools, ...more, ...advisor };
493
+ const shown = plan.screen ? this.#input?.screen : undefined;
494
+ const screen = shown
495
+ ? screenTool(shown, (c) => {
496
+ this.#screenTable();
497
+ this
498
+ .sql `INSERT OR REPLACE INTO lumen_screens (id, label, moves, status, told, at) VALUES (${c.id}, ${c.label}, ${JSON.stringify(c.moves)}, 'sent', 1, ${Date.now()})`;
499
+ })
500
+ : {};
501
+ if (shown) {
502
+ system = `${system}\n\n${screenRules(shown)}`;
503
+ this.#turn.system = system;
504
+ }
505
+ const tools = { ...opened.tools, ...more, ...advisor, ...screen };
440
506
  let lazy;
441
507
  if (Object.keys(tools).length > (plan.lazy?.at ?? LAZY_AT)) {
442
508
  const groups = new Map(opened.info.map((i) => [i.name, i.group]));
@@ -444,6 +510,7 @@ export class LumenAgent extends Think {
444
510
  ...opened.info.filter((i) => i.find).map((i) => i.name),
445
511
  ...Object.keys(more ?? {}),
446
512
  ...Object.keys(advisor),
513
+ ...Object.keys(screen),
447
514
  ];
448
515
  if (plan.first && !core.includes(plan.first))
449
516
  core.push(plan.first);
@@ -464,7 +531,7 @@ export class LumenAgent extends Think {
464
531
  turn.meters.push(turn.main);
465
532
  }),
466
533
  instructions: system,
467
- messages: withLead(ctx.messages, `${this.#news()}${plan.lead ?? ''}`),
534
+ messages: withLead(ctx.messages, `${this.#news()}${this.#screenNews()}${plan.lead ?? ''}`),
468
535
  tools,
469
536
  ...(lazy ? { activeTools: lazy.active() } : {}),
470
537
  ...(plan.first ? { toolChoice: { type: 'tool', toolName: plan.first } } : {}),
@@ -7,6 +7,7 @@ export * from './agent.js';
7
7
  export * from './connectors.js';
8
8
  export * from './models.js';
9
9
  export * from './respond.js';
10
+ export * from './screen.js';
10
11
  export * from './search.js';
11
12
  export * from './stream.js';
12
13
  export * from './tools.js';
@@ -7,6 +7,7 @@ export * from './agent.js';
7
7
  export * from './connectors.js';
8
8
  export * from './models.js';
9
9
  export * from './respond.js';
10
+ export * from './screen.js';
10
11
  export * from './search.js';
11
12
  export * from './stream.js';
12
13
  export * from './tools.js';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The assistant moves the person's screen: one tool, `screen`, takes a short list of moves the
3
+ * browser runs (`@columenlabs/lumen-ui`'s `AssistantScreenProvider`): a page of the app, Settings
4
+ * at a section (pulsing a row, never changing it), a symbol's chart, a row's details, a card
5
+ * pointed at. The browser sends its page map with each message; every move is checked against it
6
+ * here before the call reaches the screen, and the browser checks again. How each call ended comes
7
+ * back with the next message, so the model never guesses. Settings ▸ Screen: Off leaves the tool out.
8
+ */
9
+ import type { AssistantScreenMap, AssistantScreenMove, AssistantScreenOutcome } from '@columenlabs/lumen-ui-types/assistant';
10
+ import { type ToolSet } from 'ai';
11
+ import { z } from 'zod';
12
+ export declare const screenInput: z.ZodObject<{
13
+ label: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
14
+ moves: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
15
+ go: z.ZodString;
16
+ }, z.core.$strict>, z.ZodObject<{
17
+ settings: z.ZodString;
18
+ row: z.ZodOptional<z.ZodString>;
19
+ }, z.core.$strict>, z.ZodObject<{
20
+ chart: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
21
+ indicators: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
22
+ label: z.ZodString;
23
+ f: z.ZodString;
24
+ where: z.ZodOptional<z.ZodEnum<{
25
+ overlay: "overlay";
26
+ pane: "pane";
27
+ }>>;
28
+ refs: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
29
+ }, z.core.$strict>]>>>;
30
+ }, z.core.$strict>, z.ZodObject<{
31
+ open: z.ZodString;
32
+ }, z.core.$strict>, z.ZodObject<{
33
+ point: z.ZodString;
34
+ }, z.core.$strict>]>>;
35
+ }, z.core.$strip>;
36
+ /** Bounds on a page map from the browser, so a bad one costs nothing. */
37
+ export declare function screenMapOf(v: unknown): AssistantScreenMap | undefined;
38
+ /** Outcomes from the browser, checked. */
39
+ export declare function screenOutcomesOf(v: unknown): AssistantScreenOutcome[];
40
+ /**
41
+ * Each move checked against the page map: an unknown page, Settings section or card is an error
42
+ * back to the model in the same turn. A card after `go` is checked by the browser alone.
43
+ */
44
+ export declare function checkMoves(moves: AssistantScreenMove[], map: AssistantScreenMap): string | null;
45
+ /** The rules and this app's page map, for the instructions. */
46
+ export declare function screenRules(map: AssistantScreenMap): string;
47
+ /** One screen call as the chat keeps it. */
48
+ export interface ScreenCall {
49
+ id: string;
50
+ label: string;
51
+ moves: AssistantScreenMove[];
52
+ status: 'sent' | AssistantScreenOutcome['status'];
53
+ text?: string;
54
+ }
55
+ /** The calls the model has not heard about since the browser ran them or the person pressed Undo. */
56
+ export declare function screenNews(calls: ScreenCall[]): string;
57
+ export declare const screenId: () => string;
58
+ /** The `screen` tool for one turn: checks the moves, keeps the call and answers with its id. */
59
+ export declare function screenTool(map: AssistantScreenMap, keep: (call: {
60
+ id: string;
61
+ label: string;
62
+ moves: AssistantScreenMove[];
63
+ }) => void): ToolSet;
@@ -0,0 +1,178 @@
1
+ import { indicatorSpecIssue, indicatorSpecSchema } from '@columenlabs/lumen-ui-types/chart-formula';
2
+ import { tool } from 'ai';
3
+ import { z } from 'zod';
4
+ const ticker = z
5
+ .string()
6
+ .trim()
7
+ .regex(/^[A-Za-z0-9.^$=:/-]{1,24}$/);
8
+ const move = z.union([
9
+ z.object({ go: z.string().trim().min(1).max(600) }).strict(),
10
+ z
11
+ .object({
12
+ settings: z.string().trim().min(1).max(80),
13
+ row: z.string().trim().min(1).max(120).optional(),
14
+ })
15
+ .strict(),
16
+ z
17
+ .object({
18
+ chart: z.union([ticker, z.array(ticker).min(1).max(8)]),
19
+ indicators: z.array(indicatorSpecSchema).max(8).optional(),
20
+ })
21
+ .strict(),
22
+ z.object({ open: z.string().trim().min(1).max(200) }).strict(),
23
+ z.object({ point: z.string().trim().min(1).max(200) }).strict(),
24
+ ]);
25
+ export const screenInput = z.object({
26
+ label: z
27
+ .string()
28
+ .describe('What the screen will show, in a few words, e.g. "Settings · Email".')
29
+ .transform((s) => s.replace(/\s+/g, ' ').trim().slice(0, 80)),
30
+ moves: z
31
+ .array(move)
32
+ .min(1)
33
+ .max(8)
34
+ .describe('In order. Each is one of: {go: "/path"} (a page; fill path params), {settings: "<section id>", row?: "<row label>"}, {chart: "SYM"} or {chart: ["A", "B"]} (up to 8 on one chart), {open: "<row title>"} (the row\'s details in the view on screen), {point: "<card id>"} (scroll to a card and highlight it).'),
35
+ });
36
+ /** Bounds on a page map from the browser, so a bad one costs nothing. */
37
+ export function screenMapOf(v) {
38
+ if (!v || typeof v !== 'object')
39
+ return undefined;
40
+ const o = v;
41
+ const list = (x, n, f) => (Array.isArray(x) ? x : [])
42
+ .slice(0, n)
43
+ .flatMap((i) => (i && typeof i === 'object' ? [f(i)] : []))
44
+ .filter((i) => i !== null);
45
+ const str = (x, n) => (typeof x === 'string' && x ? x.slice(0, n) : null);
46
+ return {
47
+ routes: (Array.isArray(o.routes) ? o.routes : [])
48
+ .slice(0, 200)
49
+ .flatMap((r) => (typeof r === 'string' ? [r.slice(0, 300)] : [])),
50
+ pages: list(o.pages, 300, (p) => {
51
+ const path = str(p.path, 300);
52
+ const title = str(p.title, 120);
53
+ const section = str(p.section, 80);
54
+ return path && title ? { path, title, ...(section ? { section } : {}) } : null;
55
+ }),
56
+ settings: list(o.settings, 60, (p) => {
57
+ const id = str(p.id, 80);
58
+ const title = str(p.title, 120);
59
+ return id && title ? { id, title } : null;
60
+ }),
61
+ cards: list(o.cards, 60, (p) => {
62
+ const id = str(p.id, 200);
63
+ const title = str(p.title, 200) ?? '';
64
+ const kind = str(p.kind, 40);
65
+ return id ? { id, title, ...(kind ? { kind } : {}) } : null;
66
+ }),
67
+ rows: (Array.isArray(o.rows) ? o.rows : [])
68
+ .slice(0, 30)
69
+ .flatMap((r) => (typeof r === 'string' && r ? [r.slice(0, 120)] : [])),
70
+ };
71
+ }
72
+ /** Outcomes from the browser, checked. */
73
+ export function screenOutcomesOf(v) {
74
+ return (Array.isArray(v) ? v : []).slice(0, 50).flatMap((o) => {
75
+ const x = o;
76
+ if (!x || typeof x.id !== 'string' || !/^sc_[a-f0-9]{16}$/.test(x.id))
77
+ return [];
78
+ if (!['done', 'failed', 'waiting', 'undone'].includes(String(x.status)))
79
+ return [];
80
+ return [
81
+ {
82
+ id: x.id,
83
+ status: x.status,
84
+ ...(typeof x.text === 'string' && x.text ? { text: x.text.slice(0, 1200) } : {}),
85
+ },
86
+ ];
87
+ });
88
+ }
89
+ /** The pattern's segments match the path's: `$param` matches any one segment. */
90
+ function matches(pattern, path) {
91
+ const a = pattern.split('/').filter(Boolean);
92
+ const b = path.split('/').filter(Boolean);
93
+ if (a.length !== b.length)
94
+ return false;
95
+ return a.every((s, i) => s.startsWith('$') || s === b[i]);
96
+ }
97
+ /**
98
+ * Each move checked against the page map: an unknown page, Settings section or card is an error
99
+ * back to the model in the same turn. A card after `go` is checked by the browser alone.
100
+ */
101
+ export function checkMoves(moves, map) {
102
+ let moved = false;
103
+ for (const m of moves) {
104
+ if ('go' in m) {
105
+ const path = m.go.split(/[?#]/)[0] ?? '';
106
+ if (!path.startsWith('/') || /\$/.test(path))
107
+ return `go "${m.go}": give a path of this app with its params filled`;
108
+ if (!map.routes.some((r) => matches(r, path)))
109
+ return `go "${m.go}": no such page. Routes: ${map.routes.join(', ')}`;
110
+ moved = true;
111
+ }
112
+ else if ('chart' in m) {
113
+ for (const spec of m.indicators ?? []) {
114
+ const wrong = indicatorSpecIssue(spec);
115
+ if (wrong)
116
+ return `chart indicators: ${wrong}`;
117
+ }
118
+ }
119
+ else if ('settings' in m) {
120
+ if (!map.settings.some((s) => s.id === m.settings))
121
+ return `settings "${m.settings}": no such section. Sections: ${map.settings.map((s) => s.id).join(', ')}`;
122
+ }
123
+ else if ('point' in m && !moved) {
124
+ const id = m.point.replace(/^card:/, '');
125
+ if (!map.cards.some((c) => c.id === id))
126
+ return `point "${m.point}": no such card on this page. Cards: ${map.cards.map((c) => c.id).join(', ') || 'none'}`;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ /** The rules and this app's page map, for the instructions. */
132
+ export function screenRules(map) {
133
+ const pages = map.pages
134
+ .map((p) => `${p.path} (${p.section ? `${p.section} ▸ ` : ''}${p.title})`)
135
+ .join('; ');
136
+ return `Screen: with the screen tool you move the person's screen in their browser.
137
+ - Use it when the person asks to see, open, show, go to or be taken somewhere ("show me", "take me to", "where do I set X"). Otherwise answer in words.
138
+ - One call with every move, in order. After go, the next moves act on the new page; open and point act on the page on screen after the moves before them.
139
+ - Moves only change the screen: nothing is saved. Never say something was saved or changed. Say in one line what you put on screen.
140
+ - Settings: you open a section and point at a row; you never flip a switch or change a value. Say which one the person taps.
141
+ - A line "Your screen moves since your last answer" tells you how each call ended (done, failed and why, waiting for the person's tap, or undone) and what the view shows now: answer from it.
142
+ Routes: ${map.routes.join(', ') || '(none)'}
143
+ Pages: ${pages || '(none listed)'}
144
+ Settings sections: ${map.settings.map((s) => `${s.id} (${s.title})`).join(', ') || '(none)'}
145
+ Cards on this page: ${map.cards.map((c) => `${c.id} (${c.title})`).join(', ') || '(none)'}${map.rows?.length ? `\nRows you can open here: ${map.rows.join('; ')}` : ''}`;
146
+ }
147
+ const SAID = {
148
+ sent: 'sent; no word back yet',
149
+ done: 'done',
150
+ failed: 'failed',
151
+ waiting: 'not run yet: the person was mid-edit; it runs when they tap it',
152
+ undone: 'undone by the person',
153
+ };
154
+ /** The calls the model has not heard about since the browser ran them or the person pressed Undo. */
155
+ export function screenNews(calls) {
156
+ if (!calls.length)
157
+ return '';
158
+ const lines = calls.map((c) => `- "${c.label}" ${JSON.stringify(c.moves).slice(0, 300)}: ${SAID[c.status]}${c.text ? `. ${c.status === 'failed' ? 'Why' : 'On screen now'}: ${c.text}` : ''}`);
159
+ return `Your screen moves since your last answer:\n${lines.join('\n')}\n\n`;
160
+ }
161
+ export const screenId = () => `sc_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
162
+ /** The `screen` tool for one turn: checks the moves, keeps the call and answers with its id. */
163
+ export function screenTool(map, keep) {
164
+ return {
165
+ screen: tool({
166
+ description: "Move the person's screen: go to a page, open Settings at a section, open a symbol's chart, open a row's details or point at a card. Moves run in order in their browser at once, with an Undo. Use the routes, pages, Settings sections and cards listed under Screen. The outcome comes with the person's next message.",
167
+ inputSchema: screenInput,
168
+ execute: async ({ label, moves }) => {
169
+ const wrong = checkMoves(moves, map);
170
+ if (wrong)
171
+ return { error: wrong };
172
+ const id = screenId();
173
+ keep({ id, label, moves: moves });
174
+ return { screen: id, label, note: 'Sent to the screen. Say in one line what it shows.' };
175
+ },
176
+ }),
177
+ };
178
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import type { AssistantEvent, AssistantRefNote, ChatItem } from '@columenlabs/lumen-ui-types/assistant';
7
7
  import type { AgentCard } from './agent.js';
8
+ import type { ScreenCall } from './screen.js';
8
9
  /** A tool's name as a line's title: `notes__find_notes` → `find notes`. */
9
10
  export declare function stepTitle(name: string): string;
10
11
  /**
@@ -14,6 +15,8 @@ export declare function stepTitle(name: string): string;
14
15
  export declare function chunkEvents(o?: {
15
16
  advisor?: string;
16
17
  cards?: () => Map<string, AgentCard>;
18
+ /** The chat's screen calls by id: a `screen` tool's result names its call. */
19
+ screens?: () => Map<string, ScreenCall>;
17
20
  /** A tool's kind this turn: a draft shows as its card only, with no line before it. */
18
21
  kindOf?: (name: string) => 'read' | 'draft' | 'write' | undefined;
19
22
  }): (chunk: {
@@ -39,5 +42,5 @@ export interface AskMeta {
39
42
  tier?: string;
40
43
  }
41
44
  /** A stored chat as the panel's items: the messages in order, each tool call as it ended. */
42
- export declare function itemsOf(messages: StoredMessage[], cards: AgentCard[]): ChatItem[];
45
+ export declare function itemsOf(messages: StoredMessage[], cards: AgentCard[], screens?: Map<string, ScreenCall>): ChatItem[];
43
46
  export {};
@@ -6,8 +6,24 @@ export function stepTitle(name) {
6
6
  }
7
7
  const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
8
8
  /** What one finished tool call shows as: a line, a card, a hand-off section or nothing. */
9
- function settledCall(id, c, out, ms, cards) {
9
+ function settledCall(id, c, out, ms, cards, screens = new Map()) {
10
10
  const o = obj(out.output);
11
+ if (c.name === 'screen') {
12
+ // A call the page map refused goes back to the model only; the person sees the one that ran.
13
+ const call = typeof o?.screen === 'string' ? screens.get(o.screen) : undefined;
14
+ if (!call)
15
+ return [];
16
+ const item = {
17
+ kind: 'screen',
18
+ id: call.id,
19
+ label: call.label,
20
+ moves: call.moves,
21
+ ...(call.status !== 'sent'
22
+ ? { outcome: { status: call.status, ...(call.text ? { text: call.text } : {}) } }
23
+ : {}),
24
+ };
25
+ return [{ event: 'screen', data: item }];
26
+ }
11
27
  const card = cards.get(id);
12
28
  if (card?.kind === 'draft' || (o && typeof o.card === 'string')) {
13
29
  const draft = {
@@ -73,7 +89,7 @@ export function chunkEvents(o = {}) {
73
89
  advising = true;
74
90
  return [{ event: 'advice', data: { tier: 'advisor', label: o.advisor ?? 'Advice' } }];
75
91
  }
76
- if (name === 'escalate' || o.kindOf?.(name) === 'draft')
92
+ if (name === 'escalate' || name === 'screen' || o.kindOf?.(name) === 'draft')
77
93
  return [];
78
94
  return [{ event: 'step_start', data: { id, tool: name, title: stepTitle(name) } }];
79
95
  }
@@ -93,7 +109,7 @@ export function chunkEvents(o = {}) {
93
109
  advising = false;
94
110
  return [{ event: 'advice_done', data: error ? { error } : {} }];
95
111
  }
96
- return settledCall(id, c, error !== undefined ? { error } : { output: chunk.output }, Date.now() - c.t0, o.cards?.() ?? new Map());
112
+ return settledCall(id, c, error !== undefined ? { error } : { output: chunk.output }, Date.now() - c.t0, o.cards?.() ?? new Map(), c.name === 'screen' ? (o.screens?.() ?? new Map()) : undefined);
97
113
  }
98
114
  case 'error':
99
115
  return [
@@ -112,7 +128,7 @@ export function chunkEvents(o = {}) {
112
128
  };
113
129
  }
114
130
  /** A stored chat as the panel's items: the messages in order, each tool call as it ended. */
115
- export function itemsOf(messages, cards) {
131
+ export function itemsOf(messages, cards, screens = new Map()) {
116
132
  const byId = new Map(cards.map((c) => [c.id, c]));
117
133
  const items = [];
118
134
  for (const m of messages) {
@@ -161,7 +177,7 @@ export function itemsOf(messages, cards) {
161
177
  : null;
162
178
  if (!ended)
163
179
  continue;
164
- for (const e of settledCall(p.toolCallId, { name: tool, input: p.input, t0: 0 }, ended, 0, byId)) {
180
+ for (const e of settledCall(p.toolCallId, { name: tool, input: p.input, t0: 0 }, ended, 0, byId, screens)) {
165
181
  if (e.event === 'handoff')
166
182
  items.push({ ...e.data, items: [] });
167
183
  else if (e.event === 'handoff_done') {
@@ -169,7 +185,7 @@ export function itemsOf(messages, cards) {
169
185
  if (h?.kind === 'handoff')
170
186
  h.usage = e.data.usage;
171
187
  }
172
- else if (e.event === 'step' || e.event === 'draft') {
188
+ else if (e.event === 'step' || e.event === 'draft' || e.event === 'screen') {
173
189
  const { handoff: _h, ...item } = e.data;
174
190
  items.push(item);
175
191
  }
@@ -284,6 +284,7 @@ export class Assistant {
284
284
  ...(fallback ? { fallback } : {}),
285
285
  left: paying.left,
286
286
  maxSteps: MAX_ROUNDS,
287
+ screen: (p.screen ?? 'move') === 'move',
287
288
  ...(this.o.fetch ? { fetch: this.o.fetch } : {}),
288
289
  ...(handTo && reach.length ? { first: 'escalate' } : {}),
289
290
  ...(advisor
@@ -551,6 +552,7 @@ export class Assistant {
551
552
  })),
552
553
  hand_off: p.hand_off ?? 'next',
553
554
  advice: p.advice && (p.advice === 'off' || this.tierById(p.advice)) ? p.advice : 'off',
555
+ screen: p.screen ?? 'move',
554
556
  web: {
555
557
  fetch: p.web ?? true,
556
558
  scrapeql: this.o.web?.scrapeql
@@ -5,7 +5,7 @@
5
5
  * app.all('/api/assistant/*', (c) => assistant(c.req.raw)); // Hono
6
6
  *
7
7
  * GET /status what the panel and Settings show
8
- * PUT /settings the person's tiers, tasks, hand off, advice, tool access, web, payer
8
+ * PUT /settings the person's tiers, tasks, hand off, advice, screen, tool access, web, payer
9
9
  * PUT /app {cap_usd} admin: what each person may spend a month on the app's keys
10
10
  * PUT /key {api_key} admin: the app's key of `?provider=` (anthropic by default,
11
11
  * or openai), tested before it is kept
@@ -30,7 +30,7 @@
30
30
  * form or fetch is refused before it reaches a rule. Keys and tokens are write-only: no route
31
31
  * returns one, only a hint (`sk-ant-…abcd`).
32
32
  */
33
- import { ASSISTANT_ACCESS, ASSISTANT_EFFORTS, ASSISTANT_OWN_WAYS, ASSISTANT_PAYERS, HAND_OFF_POLICIES, } from '@columenlabs/lumen-ui-types/assistant';
33
+ import { ASSISTANT_ACCESS, ASSISTANT_EFFORTS, ASSISTANT_OWN_WAYS, ASSISTANT_PAYERS, ASSISTANT_SCREEN, HAND_OFF_POLICIES, } from '@columenlabs/lumen-ui-types/assistant';
34
34
  import { AssistantError } from './engine.js';
35
35
  import { PROVIDERS } from './models.js';
36
36
  import { keyDocName, planDocName, } from './state.js';
@@ -89,6 +89,8 @@ function settingsChange(a, groups, b) {
89
89
  return 'No such hand off.';
90
90
  if (p.advice !== undefined && p.advice !== 'off' && !tierIds.has(p.advice))
91
91
  return 'Advice is off or a tier.';
92
+ if (p.screen !== undefined && !oneOf(ASSISTANT_SCREEN, p.screen))
93
+ return 'screen is off or move.';
92
94
  if (p.access !== undefined) {
93
95
  if (!p.access || typeof p.access !== 'object')
94
96
  return 'access is {group: off | read | draft}.';
@@ -127,6 +129,7 @@ function settingsChange(a, groups, b) {
127
129
  ...(p.tasks !== undefined ? { tasks: { ...(d.tasks ?? {}), ...p.tasks } } : {}),
128
130
  ...(p.hand_off !== undefined ? { hand_off: p.hand_off } : {}),
129
131
  ...(p.advice !== undefined ? { advice: p.advice } : {}),
132
+ ...(p.screen !== undefined ? { screen: p.screen } : {}),
130
133
  ...(p.access !== undefined ? { access: { ...(d.access ?? {}), ...p.access } } : {}),
131
134
  ...(p.web !== undefined ? { web: p.web.fetch } : {}),
132
135
  ...(p.pay !== undefined ? { pay: p.pay } : {}),
@@ -19,7 +19,7 @@
19
19
  * tier runs on and money left this month, else the other; with neither the assistant waits and says
20
20
  * why. A cap covers every provider of its payer.
21
21
  */
22
- import type { AllowanceRequest, AssistantAccess, AssistantEffort, AssistantOwnWay, AssistantPayer, AssistantProvider, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
22
+ import type { AllowanceRequest, AssistantAccess, AssistantEffort, AssistantOwnWay, AssistantPayer, AssistantProvider, AssistantScreenSetting, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
23
23
  import { type ChatgptTokens } from './chatgpt.js';
24
24
  import { type Docs } from './store.js';
25
25
  export interface KeyTest {
@@ -59,6 +59,8 @@ export interface PersonDoc {
59
59
  tasks?: Record<string, string>;
60
60
  hand_off?: HandOffPolicy;
61
61
  advice?: string;
62
+ /** Whether the assistant may move the screen; Move when unset. */
63
+ screen?: AssistantScreenSetting;
62
64
  access?: Record<string, AssistantAccess>;
63
65
  web?: boolean;
64
66
  pay?: AssistantPayer;