@columenlabs/lumen-ai 0.1.196 → 0.1.198

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
@@ -273,3 +273,13 @@ const routes = workspaceRoutes({ base: '/api/assistant', owner: callerOf, store:
273
273
  ```
274
274
 
275
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.
276
+
277
+ ### Web data and pushed datasets (no server code runs)
278
+
279
+ Three more ways rows reach a tab, all on a free Workers plan (no Dynamic Workers):
280
+
281
+ - **Fetch recipe** (`{kind: 'fetch', requests: [{url, method?, key?}], each?: {values}, rows?, pick?}`): declarative web reads the product runs on its server. `{field}` holes in a URL fill from each value of `each` (text fills `{value}`); `fetchCalls` expands them (at most `FETCH_CALLS`, 50 a run) and `fetchedRows` turns the answers into rows, the `each` value's fields first, then `pick`'s dotted paths (`current.temperature_2m`). A data key is named (`key`), never sent: the product adds it server side.
282
+ - **Browser script** (`{kind: 'script', runtime: 'browser', code, requests?}`): JavaScript that runs in the person's page. The kit's `runBrowserFrame` loads `<base>/sandbox` (this package's `SANDBOX_HTML`, served with `SANDBOX_CSP`: `connect-src 'none'`) in an iframe with `sandbox="allow-scripts"`, runs the code in a Web Worker there (stopped at `BROWSER_RUN_MS`, 30 s), and saves what it returns with `PUT /frames/:id/rows` (one write, the frame's caps checked). The code is the body of `async (fetched, hub) => rows`: `fetched` holds the answers to its `requests`, `hub.fetch(url, {key})` makes one more read through the product's proxy (`fetch` in `workspaceRoutes`, `POST <base>/fetch`). The server never runs it: `refresh` refuses it (409, open the tab), and `refreshAll` lists such tabs as `browser`.
283
+ - **Pushed dataset** (`store.pushFrame(owner, {name, rows, mode, by, key?})`): rows an outside agent wrangled (a CSV parsed by `parseCsv`, at most `CSV_BYTES` a call, the rest as `append` parts; with `key`, an appended row replaces the one with its key). Origin `harness`, no recipe; `by` names the agent and the tab's status bar reads "Pushed by Claude Code · 4 min ago". `removeFrame` deletes one with its tabs.
284
+
285
+ `workspaceRoutes` lets `call`, `fetch` and browser `script` recipes through to the product's `allow`; server `sql` and `script` are refused.
@@ -28,3 +28,13 @@ export declare function applyEdits(columns: FrameColumn[], data: FrameCell[][],
28
28
  data: FrameCell[][];
29
29
  edits: FrameEdit[];
30
30
  };
31
+ /** A frame's rows back as objects keyed by their column labels (what a CSV push appends to). */
32
+ export declare function asObjects(columns: FrameColumn[], data: FrameCell[][]): Row[];
33
+ /** Most CSV text one call takes (bigger goes as `append` parts). */
34
+ export declare const CSV_BYTES = 1000000;
35
+ /**
36
+ * CSV text (RFC 4180: quoted fields, `""` for a quote, CRLF or LF) as rows keyed by the header.
37
+ * A numeral becomes a number, but one with a leading zero (`02134`, a zip) stays text; `types`
38
+ * says a column's type outright. Empty cells are blank.
39
+ */
40
+ export declare function parseCsv(text: string, types?: Record<string, 'number' | 'text'>): Row[];
@@ -124,3 +124,80 @@ export function applyEdits(columns, data, edits) {
124
124
  }
125
125
  return { data: rows, edits: kept };
126
126
  }
127
+ /** A frame's rows back as objects keyed by their column labels (what a CSV push appends to). */
128
+ export function asObjects(columns, data) {
129
+ return data.map((r) => Object.fromEntries(columns.map((c, i) => [c.label, r[i] ?? null])));
130
+ }
131
+ /** Most CSV text one call takes (bigger goes as `append` parts). */
132
+ export const CSV_BYTES = 1_000_000;
133
+ const NUMERAL = /^[-−]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i;
134
+ /**
135
+ * CSV text (RFC 4180: quoted fields, `""` for a quote, CRLF or LF) as rows keyed by the header.
136
+ * A numeral becomes a number, but one with a leading zero (`02134`, a zip) stays text; `types`
137
+ * says a column's type outright. Empty cells are blank.
138
+ */
139
+ export function parseCsv(text, types = {}) {
140
+ if (text.length > CSV_BYTES)
141
+ throw new FrameError(`The CSV is ${Math.round(text.length / 1000)} KB: send at most ${CSV_BYTES / 1000} KB a call, the rest as append parts.`);
142
+ const records = [];
143
+ let field = '';
144
+ let rec = [];
145
+ let quoted = false;
146
+ for (let i = 0; i < text.length; i++) {
147
+ const ch = text[i];
148
+ if (quoted) {
149
+ if (ch === '"' && text[i + 1] === '"') {
150
+ field += '"';
151
+ i++;
152
+ }
153
+ else if (ch === '"')
154
+ quoted = false;
155
+ else
156
+ field += ch;
157
+ }
158
+ else if (ch === '"' && field === '')
159
+ quoted = true;
160
+ else if (ch === ',') {
161
+ rec.push(field);
162
+ field = '';
163
+ }
164
+ else if (ch === '\n' || ch === '\r') {
165
+ if (ch === '\r' && text[i + 1] === '\n')
166
+ i++;
167
+ rec.push(field);
168
+ field = '';
169
+ if (rec.length > 1 || rec[0] !== '')
170
+ records.push(rec);
171
+ rec = [];
172
+ }
173
+ else
174
+ field += ch;
175
+ }
176
+ if (quoted)
177
+ throw new FrameError('The CSV has a quote that never closes.');
178
+ if (field !== '' || rec.length) {
179
+ rec.push(field);
180
+ records.push(rec);
181
+ }
182
+ const [header, ...body] = records;
183
+ if (!header?.length)
184
+ throw new FrameError('The CSV has no header row.');
185
+ const names = header.map((h, i) => h.trim() || `Column ${i + 1}`);
186
+ return body.map((r) => Object.fromEntries(names.map((n, i) => {
187
+ const raw = (r[i] ?? '').trim();
188
+ if (raw === '')
189
+ return [n, null];
190
+ const want = types[n];
191
+ if (want === 'text')
192
+ return [n, raw];
193
+ const numeric = NUMERAL.test(raw) && !/^[-−]?0\d/.test(raw);
194
+ if (want === 'number' || numeric) {
195
+ const v = Number(raw.replace('−', '-').replace(/[,$%\s]/g, ''));
196
+ if (Number.isFinite(v))
197
+ return [n, v];
198
+ if (want === 'number')
199
+ throw new FrameError(`${n} holds numbers: "${raw}" is not one.`);
200
+ }
201
+ return [n, raw];
202
+ })));
203
+ }
@@ -2,9 +2,10 @@
2
2
  * Workspaces: a person's pages of tabs, each a table over rows a recipe pulled, run again on demand.
3
3
  * `createWorkspaces` is the store (any SQLite), `workspaceRoutes` the HTTP the kit's pages call.
4
4
  */
5
- export { FrameError, applyEdits, asRows, coerce, dedupeEdits, toFrame } from './frame.js';
5
+ export { CSV_BYTES, FrameError, applyEdits, asObjects, asRows, coerce, dedupeEdits, parseCsv, toFrame, } from './frame.js';
6
6
  export type { Row } from './frame.js';
7
7
  export { FRAME_BYTES, MAX_FRAMES, MAX_TABS, MAX_WORKSPACES, RUN_MS, createWorkspaces, d1Sql, doSql, recipeText, workspaceMigrations, } from './store.js';
8
8
  export type { RecipeRunner, SqlRunner, WorkspaceRead, WorkspaceTables, Workspaces, WorkspacesOptions, } from './store.js';
9
9
  export { workspaceRoutes } from './routes.js';
10
+ export { SANDBOX_CSP, SANDBOX_HTML, sandboxResponse } from './sandbox.js';
10
11
  export type { WorkspaceRoutesOptions } from './routes.js';
@@ -2,6 +2,7 @@
2
2
  * Workspaces: a person's pages of tabs, each a table over rows a recipe pulled, run again on demand.
3
3
  * `createWorkspaces` is the store (any SQLite), `workspaceRoutes` the HTTP the kit's pages call.
4
4
  */
5
- export { FrameError, applyEdits, asRows, coerce, dedupeEdits, toFrame } from './frame.js';
5
+ export { CSV_BYTES, FrameError, applyEdits, asObjects, asRows, coerce, dedupeEdits, parseCsv, toFrame, } from './frame.js';
6
6
  export { FRAME_BYTES, MAX_FRAMES, MAX_TABS, MAX_WORKSPACES, RUN_MS, createWorkspaces, d1Sql, doSql, recipeText, workspaceMigrations, } from './store.js';
7
7
  export { workspaceRoutes } from './routes.js';
8
+ export { SANDBOX_CSP, SANDBOX_HTML, sandboxResponse } from './sandbox.js';
@@ -8,7 +8,8 @@
8
8
  * GET /workspaces/:id one, with its frames' heads
9
9
  * PATCH /workspaces/:id {name?, app?}
10
10
  * DELETE /workspaces/:id
11
- * POST /workspaces/:id/tabs {name, recipe} a new tab from a recipe (runs it once)
11
+ * POST /workspaces/:id/tabs {name, recipe, start?} a new tab from a recipe (runs it once;
12
+ * `start.formulas`: formula columns its layout starts with)
12
13
  * POST /workspaces/:id/refresh runs every tab's recipe again, one after another
13
14
  * PUT /workspaces/:id/tabs/:tab {name?, config?}
14
15
  * DELETE /workspaces/:id/tabs/:tab
@@ -19,11 +20,15 @@
19
20
  * POST /frames/:id/refresh runs its recipe again
20
21
  * GET|PUT /frames/:id/script its recipe
21
22
  * PATCH /frames/:id/cells {edits} hand edits
23
+ * PUT /frames/:id/rows {rows | error, ms} a browser recipe's run: its rows, or why it failed
24
+ * GET /sandbox the page a browser recipe runs in (./sandbox.ts)
25
+ * POST /fetch {url, method?, body?, key?} one web read for a browser script, through the
26
+ * product's proxy (`fetch`; 404 when it has none)
22
27
  *
23
28
  * Every call needs a caller; writes must be same-origin JSON, since a signed-in browser session is
24
29
  * a cookie and a foreign page must not reach a person's data.
25
30
  */
26
- import { frameScriptSchema } from '@columenlabs/lumen-ui-types/frames';
31
+ import { type FrameFetch, frameScriptSchema } from '@columenlabs/lumen-ui-types/frames';
27
32
  import { z } from 'zod';
28
33
  import type { Workspaces } from './store.js';
29
34
  type Awaitable<T> = T | Promise<T>;
@@ -36,6 +41,16 @@ export interface WorkspaceRoutesOptions {
36
41
  owner: (req: Request) => Awaitable<string | null>;
37
42
  /** Which recipes this product runs; a recipe it refuses answers 422 with the reason. */
38
43
  allow?: (recipe: z.infer<typeof frameScriptSchema>, req: Request) => Awaitable<string | null>;
44
+ /**
45
+ * One web read for a browser script (`hub.fetch`): the product checks the host against its
46
+ * allowlist, adds a named data key server side, bounds the time and size, and answers the
47
+ * status and body. Left out, `/fetch` is 404 and browser scripts read nothing.
48
+ */
49
+ fetch?: (call: FrameFetch, req: Request) => Promise<{
50
+ status: number;
51
+ body: unknown;
52
+ error?: string;
53
+ }>;
39
54
  }
40
55
  export declare function workspaceRoutes(opts: WorkspaceRoutesOptions): (req: Request) => Promise<Response>;
41
56
  export {};
@@ -8,7 +8,8 @@
8
8
  * GET /workspaces/:id one, with its frames' heads
9
9
  * PATCH /workspaces/:id {name?, app?}
10
10
  * DELETE /workspaces/:id
11
- * POST /workspaces/:id/tabs {name, recipe} a new tab from a recipe (runs it once)
11
+ * POST /workspaces/:id/tabs {name, recipe, start?} a new tab from a recipe (runs it once;
12
+ * `start.formulas`: formula columns its layout starts with)
12
13
  * POST /workspaces/:id/refresh runs every tab's recipe again, one after another
13
14
  * PUT /workspaces/:id/tabs/:tab {name?, config?}
14
15
  * DELETE /workspaces/:id/tabs/:tab
@@ -19,26 +20,49 @@
19
20
  * POST /frames/:id/refresh runs its recipe again
20
21
  * GET|PUT /frames/:id/script its recipe
21
22
  * PATCH /frames/:id/cells {edits} hand edits
23
+ * PUT /frames/:id/rows {rows | error, ms} a browser recipe's run: its rows, or why it failed
24
+ * GET /sandbox the page a browser recipe runs in (./sandbox.ts)
25
+ * POST /fetch {url, method?, body?, key?} one web read for a browser script, through the
26
+ * product's proxy (`fetch`; 404 when it has none)
22
27
  *
23
28
  * Every call needs a caller; writes must be same-origin JSON, since a signed-in browser session is
24
29
  * a cookie and a foreign page must not reach a person's data.
25
30
  */
26
- import { frameEditSchema, frameName, frameScriptSchema } from '@columenlabs/lumen-ui-types/frames';
27
- import { customViewConfigSchema } from '@columenlabs/lumen-ui-types/views';
31
+ import { FRAME_ROWS, frameEditSchema, frameName, frameScriptSchema, runsInBrowser, } from '@columenlabs/lumen-ui-types/frames';
32
+ import { customViewConfigSchema, viewFormulaSchema, } from '@columenlabs/lumen-ui-types/views';
28
33
  import { z } from 'zod';
29
34
  import { FrameError } from './frame.js';
35
+ import { sandboxResponse } from './sandbox.js';
30
36
  const HEADERS = { 'content-type': 'application/json', 'cache-control': 'no-store' };
31
37
  const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: HEADERS });
32
38
  const fail = (error, status) => json({ error }, status);
33
39
  const createSchema = z.object({ name: frameName, app: z.string().max(40).optional() });
34
40
  const patchSchema = z.object({ name: frameName.optional(), app: z.string().max(40).optional() });
35
41
  const addTabSchema = z.object({ name: frameName, recipe: frameScriptSchema });
42
+ const newTabSchema = addTabSchema.extend({
43
+ /** Formula columns its default layout starts with (dew point over temperature and humidity). */
44
+ start: z.object({ formulas: z.array(viewFormulaSchema).max(10) }).optional(),
45
+ });
36
46
  const putTabSchema = z.object({
37
47
  name: frameName.optional(),
38
48
  config: customViewConfigSchema.nullable().optional(),
39
49
  });
40
50
  const cellsSchema = z.object({ edits: z.array(frameEditSchema).min(1).max(200) });
41
51
  const scriptSchema = z.object({ script: frameScriptSchema });
52
+ const rowsSchema = z.object({
53
+ rows: z.array(z.record(z.string(), z.unknown())).max(FRAME_ROWS).optional(),
54
+ error: z.string().max(2000).optional(),
55
+ ms: z.number().min(0).max(600_000).optional(),
56
+ });
57
+ const fetchSchema = z.object({
58
+ url: z.string().url().max(2000),
59
+ method: z.enum(['GET', 'POST']).optional(),
60
+ body: z.string().max(20_000).optional(),
61
+ key: z
62
+ .string()
63
+ .regex(/^[a-z0-9_-]{1,40}$/i)
64
+ .optional(),
65
+ });
42
66
  export function workspaceRoutes(opts) {
43
67
  const base = opts.base.replace(/\/$/, '');
44
68
  return async (req) => {
@@ -68,8 +92,9 @@ export function workspaceRoutes(opts) {
68
92
  return parsed.data;
69
93
  };
70
94
  const allowed = async (recipe) => {
71
- if (recipe.kind !== 'call')
72
- throw new FrameError('Recipes here are one call to an app tool.', 422);
95
+ // The server runs app calls and declarative web reads; code runs only in the browser.
96
+ if (recipe.kind === 'sql' || (recipe.kind === 'script' && !runsInBrowser(recipe)))
97
+ throw new FrameError('Recipes here are an app call, web reads, or a script that runs in your browser.', 422);
73
98
  const why = opts.allow ? await opts.allow(recipe, req) : null;
74
99
  if (why)
75
100
  throw new FrameError(why, 422);
@@ -102,9 +127,9 @@ export function workspaceRoutes(opts) {
102
127
  }
103
128
  else if (sub === 'tabs') {
104
129
  if (!tab && m === 'POST') {
105
- const p = await body(addTabSchema);
130
+ const p = await body(newTabSchema);
106
131
  await allowed(p.recipe);
107
- return json(await s.addTab(owner, id, { name: p.name, recipe: p.recipe }));
132
+ return json(await s.addTab(owner, id, { name: p.name, recipe: p.recipe, start: p.start }));
108
133
  }
109
134
  if (tab && m === 'PUT') {
110
135
  const p = await body(putTabSchema);
@@ -118,6 +143,13 @@ export function workspaceRoutes(opts) {
118
143
  return json(await s.removeTab(owner, id, tab));
119
144
  }
120
145
  }
146
+ if (root === 'sandbox' && !id && m === 'GET')
147
+ return sandboxResponse();
148
+ if (root === 'fetch' && !id && m === 'POST') {
149
+ if (!opts.fetch)
150
+ return fail('not_found', 404);
151
+ return json(await opts.fetch(await body(fetchSchema), req));
152
+ }
121
153
  if (root === 'frames') {
122
154
  if (!id) {
123
155
  if (m === 'GET')
@@ -149,6 +181,9 @@ export function workspaceRoutes(opts) {
149
181
  return json(await s.setScript(owner, id, p.script));
150
182
  }
151
183
  }
184
+ else if (sub === 'rows' && m === 'PUT') {
185
+ return json(await s.putRows(owner, id, await body(rowsSchema)));
186
+ }
152
187
  else if (sub === 'cells' && m === 'PATCH') {
153
188
  return json(await s.editCells(owner, id, (await body(cellsSchema)).edits));
154
189
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The page a browser recipe runs in (`GET <base>/sandbox`). The kit's runner loads it in an
3
+ * iframe with `sandbox="allow-scripts"`: an opaque origin, so no cookies, storage or hub calls,
4
+ * and this response's own policy reaches nothing (`connect-src 'none'`). The script itself runs
5
+ * in a Web Worker inside it, so the page can stop it (a loop that never ends) at its time limit.
6
+ * Its only way out is `hub.fetch(url)`, a message to the parent, which asks the product's fetch
7
+ * proxy (allowlisted hosts, keys added server side) and posts the answer back.
8
+ *
9
+ * Messages (parent ⇄ page): `run {code, fetched}` → `done {result} | {error}`;
10
+ * page → parent `fetch {id, url, init}` → parent → page `fetched {id, result} | {id, error}`.
11
+ */
12
+ /** The page: starts the worker and passes messages between it and the parent. */
13
+ export declare const SANDBOX_HTML: string;
14
+ /** Its policy: inline and eval for the person's code, a blob worker, and no network at all. */
15
+ export declare const SANDBOX_CSP = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' blob:; worker-src blob:; connect-src 'none'; img-src 'none'; frame-ancestors 'self'; base-uri 'none'; form-action 'none'";
16
+ /** The sandbox page as a response. */
17
+ export declare function sandboxResponse(): Response;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The page a browser recipe runs in (`GET <base>/sandbox`). The kit's runner loads it in an
3
+ * iframe with `sandbox="allow-scripts"`: an opaque origin, so no cookies, storage or hub calls,
4
+ * and this response's own policy reaches nothing (`connect-src 'none'`). The script itself runs
5
+ * in a Web Worker inside it, so the page can stop it (a loop that never ends) at its time limit.
6
+ * Its only way out is `hub.fetch(url)`, a message to the parent, which asks the product's fetch
7
+ * proxy (allowlisted hosts, keys added server side) and posts the answer back.
8
+ *
9
+ * Messages (parent ⇄ page): `run {code, fetched}` → `done {result} | {error}`;
10
+ * page → parent `fetch {id, url, init}` → parent → page `fetched {id, result} | {id, error}`.
11
+ */
12
+ /** The worker's code: runs the person's script as an async function of `fetched` and `hub`. */
13
+ const WORKER = `
14
+ const waiting = new Map();
15
+ let next = 0;
16
+ const hub = {
17
+ fetch(url, init) {
18
+ return new Promise((ok, no) => {
19
+ const id = ++next;
20
+ waiting.set(id, { ok, no });
21
+ postMessage({ type: 'fetch', id, url: String(url), init: init || {} });
22
+ });
23
+ },
24
+ };
25
+ onmessage = async (e) => {
26
+ const m = e.data || {};
27
+ if (m.type === 'fetched') {
28
+ const w = waiting.get(m.id);
29
+ waiting.delete(m.id);
30
+ if (w) m.error ? w.no(new Error(m.error)) : w.ok(m.result);
31
+ return;
32
+ }
33
+ if (m.type !== 'run') return;
34
+ try {
35
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
36
+ const out = await new AsyncFunction('fetched', 'hub', m.code)(m.fetched, hub);
37
+ postMessage({ type: 'done', result: JSON.parse(JSON.stringify(out === undefined ? null : out)) });
38
+ } catch (err) {
39
+ postMessage({ type: 'done', error: String((err && err.message) || err) });
40
+ }
41
+ };`;
42
+ /** The page: starts the worker and passes messages between it and the parent. */
43
+ export const SANDBOX_HTML = `<!doctype html><meta charset="utf-8"><title>Recipe</title><script>
44
+ const worker = new Worker(URL.createObjectURL(new Blob([${JSON.stringify(WORKER)}], { type: 'text/javascript' })));
45
+ worker.onmessage = (e) => parent.postMessage(e.data, '*');
46
+ worker.onerror = (e) => parent.postMessage({ type: 'done', error: e.message || 'The script failed.' }, '*');
47
+ addEventListener('message', (e) => { if (e.source === parent) worker.postMessage(e.data); });
48
+ parent.postMessage({ type: 'ready' }, '*');
49
+ </script>`;
50
+ /** Its policy: inline and eval for the person's code, a blob worker, and no network at all. */
51
+ export const SANDBOX_CSP = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' blob:; worker-src blob:; connect-src 'none'; img-src 'none'; frame-ancestors 'self'; base-uri 'none'; form-action 'none'";
52
+ /** The sandbox page as a response. */
53
+ export function sandboxResponse() {
54
+ return new Response(SANDBOX_HTML, {
55
+ headers: {
56
+ 'content-type': 'text/html; charset=utf-8',
57
+ 'content-security-policy': SANDBOX_CSP,
58
+ 'cache-control': 'private, max-age=3600',
59
+ 'x-content-type-options': 'nosniff',
60
+ 'referrer-policy': 'no-referrer',
61
+ // Framed by the app's own pages only (a product's default may be DENY).
62
+ 'x-frame-options': 'SAMEORIGIN',
63
+ },
64
+ });
65
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type FrameChoice, type FrameData, type FrameEditInput, type FrameMeta, type FrameScript } from '@columenlabs/lumen-ui-types/frames';
15
15
  import type { CustomViewConfig } from '@columenlabs/lumen-ui-types/views';
16
- import type { Workspace, WorkspaceSummary } from '@columenlabs/lumen-ui-types/workspaces';
16
+ import type { Workspace, WorkspaceSummary, WorkspaceTab } from '@columenlabs/lumen-ui-types/workspaces';
17
17
  type Row = Record<string, unknown>;
18
18
  /** The SQL a store needs: the same shape as `lumen-tasks`' `SqlRunner`, so one runner serves both. */
19
19
  export interface SqlRunner {
@@ -87,6 +87,7 @@ export declare function createWorkspaces(opts: WorkspacesOptions): {
87
87
  name: string;
88
88
  recipe: FrameScript;
89
89
  by?: 'you' | 'assistant';
90
+ start?: WorkspaceTab['start'];
90
91
  }): Promise<{
91
92
  workspace: WorkspaceRead;
92
93
  tab: string;
@@ -101,6 +102,7 @@ export declare function createWorkspaces(opts: WorkspacesOptions): {
101
102
  ran: number;
102
103
  failed: string[];
103
104
  rows: number;
105
+ browser: string[];
104
106
  }>;
105
107
  frames(owner: string): Promise<{
106
108
  frames: FrameChoice[];
@@ -120,6 +122,32 @@ export declare function createWorkspaces(opts: WorkspacesOptions): {
120
122
  }>;
121
123
  /** A new recipe for a frame: kept only if it runs. */
122
124
  setScript(owner: string, id: string, script: FrameScript): Promise<FrameData>;
125
+ /**
126
+ * A browser run's result: the rows its script gave (or why it failed), saved as the frame's
127
+ * rows with the hand edits put back. Only a frame whose recipe runs in the browser takes them.
128
+ */
129
+ putRows(owner: string, id: string, p: {
130
+ rows?: unknown[];
131
+ error?: string;
132
+ ms?: number;
133
+ }): Promise<FrameData>;
134
+ /**
135
+ * A dataset an outside agent pushes (a CSV it wrangled): rows only, no recipe. `replace`
136
+ * writes the rows; `append` adds them, and with `key` a row whose key is already there
137
+ * replaces it. One frame per name among pushed ones; the rows pass the same caps as a run.
138
+ */
139
+ pushFrame(owner: string, p: {
140
+ name: string;
141
+ rows: Row[];
142
+ mode: 'replace' | 'append';
143
+ by: string;
144
+ key?: string;
145
+ }): Promise<FrameData>;
146
+ /** Deletes a frame and every tab that shows it (a pushed dataset the agent takes away). */
147
+ removeFrame(owner: string, id: string): Promise<{
148
+ ok: true;
149
+ tabs: number;
150
+ }>;
123
151
  editCells(owner: string, id: string, edits: FrameEditInput[]): Promise<FrameData>;
124
152
  };
125
153
  export type Workspaces = ReturnType<typeof createWorkspaces>;
@@ -11,8 +11,8 @@
11
11
  * spaces: owner, id, name, app, tabs (JSON), created_at, updated_at
12
12
  * frames: owner, id, name, head (JSON: meta + recipe), rows (JSON), edits (JSON), at
13
13
  */
14
- import { FRAME_RUNS, } from '@columenlabs/lumen-ui-types/frames';
15
- import { FrameError, applyEdits, asRows, coerce, dedupeEdits, toFrame } from './frame.js';
14
+ import { FRAME_RUNS, runsInBrowser, } from '@columenlabs/lumen-ui-types/frames';
15
+ import { FrameError, applyEdits, asObjects, asRows, coerce, dedupeEdits, toFrame } from './frame.js';
16
16
  /** A Durable Object's SQLite (`ctx.storage.sql`). */
17
17
  export function doSql(sql) {
18
18
  return {
@@ -61,14 +61,21 @@ const parse = (v, d) => {
61
61
  return d;
62
62
  }
63
63
  };
64
- const metaOf = ({ script: _s, ...m }) => m;
64
+ /** A frame's meta as pages read it, with what makes it (the tab's icon, where ↻ runs). */
65
+ const metaOf = ({ script, ...m }) => ({
66
+ ...m,
67
+ recipe: script?.kind,
68
+ browser: runsInBrowser(script) || undefined,
69
+ });
65
70
  /** The recipe's text as the person reads it: `jobs.list` and its arguments. */
66
71
  export function recipeText(s) {
67
72
  if (!s)
68
73
  return 'nothing (rows written once)';
69
74
  if (s.kind === 'call')
70
75
  return `${s.fn} ${JSON.stringify(s.args)}`;
71
- return s.kind === 'sql' ? 'a SQL query' : 'a script';
76
+ if (s.kind === 'fetch')
77
+ return `web reads ${JSON.stringify(s.requests.map((r) => r.url))}`;
78
+ return s.kind === 'sql' ? 'a SQL query' : runsInBrowser(s) ? 'a browser script' : 'a script';
72
79
  }
73
80
  export function createWorkspaces(opts) {
74
81
  const t = opts.tables ?? TABLES;
@@ -147,9 +154,17 @@ export function createWorkspaces(opts) {
147
154
  const { head, data } = await getFrame(owner, id);
148
155
  if (!head.script)
149
156
  throw new FrameError('This tab was written once: it has no recipe to run.', 409);
157
+ if (runsInBrowser(head.script))
158
+ throw new FrameError('This tab runs in your browser: open the tab to refresh.', 409);
150
159
  return runInto(owner, head, data.edits ?? [], head.script, by, data.rows);
151
160
  }
152
161
  async function runInto(owner, head, edits, script, by, lastRows) {
162
+ if (runsInBrowser(script)) {
163
+ // No server code runs: the browser runs it on ↻ and saves its rows (`putRows`).
164
+ const next = { ...head, script, mode: 'asof', error: undefined };
165
+ await writeFrame(owner, next, lastRows, edits);
166
+ return { meta: metaOf(next), rows: lastRows, edits };
167
+ }
153
168
  const started = Date.now();
154
169
  const at = now();
155
170
  try {
@@ -190,19 +205,24 @@ export function createWorkspaces(opts) {
190
205
  }
191
206
  }
192
207
  /** A new frame from a recipe, run once (a failed run keeps the frame with its error). */
193
- async function newFrame(owner, name, recipe, by) {
208
+ async function roomForFrame(owner) {
194
209
  const [{ n } = { n: 0 }] = await db.all(`SELECT COUNT(*) AS n FROM (SELECT 1 FROM ${t.frames} WHERE owner = ? LIMIT ${MAX_FRAMES})`, owner);
195
210
  if (Number(n) >= MAX_FRAMES)
196
211
  throw new FrameError(`You keep ${MAX_FRAMES} tabs of data, the most: delete some first.`, 409);
212
+ }
213
+ async function newFrame(owner, name, recipe, by) {
214
+ await roomForFrame(owner);
215
+ const browser = runsInBrowser(recipe);
197
216
  const head = {
198
217
  id: newId('fr'),
199
218
  name,
200
219
  columns: [],
201
220
  rows: 0,
202
- mode: 'live',
221
+ // Web reads change on their own, so they run only when asked; app reads are live.
222
+ mode: browser || recipe.kind === 'fetch' ? 'asof' : 'live',
203
223
  kind: 'snapshot',
204
224
  at: now(),
205
- origin: 'assistant',
225
+ origin: browser ? 'script' : 'assistant',
206
226
  script: recipe,
207
227
  };
208
228
  await runInto(owner, head, [], recipe, by, []);
@@ -214,6 +234,9 @@ export function createWorkspaces(opts) {
214
234
  columns: h.columns,
215
235
  at: h.at,
216
236
  origin: h.origin ?? 'assistant',
237
+ recipe: h.script?.kind,
238
+ by: h.by,
239
+ rows: h.rows,
217
240
  });
218
241
  /** A frame no tab shows but a card links (a recipe added from + Data) still counts as used. */
219
242
  async function dropFrameIfUnused(owner, frame) {
@@ -291,6 +314,7 @@ export function createWorkspaces(opts) {
291
314
  name: p.name,
292
315
  source: { frame: head.id },
293
316
  config: null,
317
+ ...(p.start?.formulas.length ? { start: p.start } : {}),
294
318
  };
295
319
  const saved = await saveSpace(owner, { ...w, tabs: [...w.tabs, tab] });
296
320
  const hs = await heads(owner, saved.tabs.flatMap((x) => ('frame' in x.source ? [x.source.frame] : [])));
@@ -333,9 +357,19 @@ export function createWorkspaces(opts) {
333
357
  let ran = 0;
334
358
  let rows = 0;
335
359
  const failed = [];
360
+ // Tabs whose recipe runs in the browser: the page runs each one it shows.
361
+ const browser = [];
362
+ const hs = await heads(owner, w.tabs.flatMap((x) => ('frame' in x.source ? [x.source.frame] : [])));
336
363
  for (const x of w.tabs) {
337
364
  if (!('frame' in x.source))
338
365
  continue;
366
+ const h = hs.get(x.source.frame);
367
+ if (!h?.script)
368
+ continue;
369
+ if (runsInBrowser(h.script)) {
370
+ browser.push(x.name);
371
+ continue;
372
+ }
339
373
  try {
340
374
  const d = await refresh(owner, x.source.frame);
341
375
  ran++;
@@ -345,7 +379,7 @@ export function createWorkspaces(opts) {
345
379
  failed.push(x.name);
346
380
  }
347
381
  }
348
- return { ran, failed, rows };
382
+ return { ran, failed, rows, browser };
349
383
  },
350
384
  async frames(owner) {
351
385
  const rows = await db.all(`SELECT head FROM ${t.frames} WHERE owner = ? LIMIT ${MAX_FRAMES}`, owner);
@@ -377,6 +411,106 @@ export function createWorkspaces(opts) {
377
411
  const { head, data } = await getFrame(owner, id);
378
412
  return runInto(owner, head, data.edits ?? [], script, 'you', data.rows);
379
413
  },
414
+ /**
415
+ * A browser run's result: the rows its script gave (or why it failed), saved as the frame's
416
+ * rows with the hand edits put back. Only a frame whose recipe runs in the browser takes them.
417
+ */
418
+ async putRows(owner, id, p) {
419
+ const { head, data } = await getFrame(owner, id);
420
+ if (!runsInBrowser(head.script))
421
+ throw new FrameError('Only a browser recipe saves rows here.', 409);
422
+ const at = now();
423
+ const ms = Math.max(0, Math.round(p.ms ?? 0));
424
+ if (p.error !== undefined) {
425
+ const error = p.error.slice(0, 300) || 'The script failed.';
426
+ const failed = {
427
+ ...head,
428
+ error,
429
+ runs: [
430
+ { at, rows: 0, ms, ok: false, by: 'you', error },
431
+ ...(head.runs ?? []),
432
+ ].slice(0, FRAME_RUNS),
433
+ };
434
+ await writeFrame(owner, failed, data.rows, data.edits ?? []);
435
+ return { meta: metaOf(failed), rows: data.rows, edits: data.edits ?? [] };
436
+ }
437
+ const { columns, data: rows } = toFrame(asRows(p.rows ?? []));
438
+ const put = applyEdits(columns, rows, data.edits ?? []);
439
+ const next = {
440
+ ...head,
441
+ columns,
442
+ rows: put.data.length,
443
+ at,
444
+ by: 'you',
445
+ error: undefined,
446
+ edited: put.edits.length || undefined,
447
+ runs: [
448
+ { at, rows: put.data.length, ms, ok: true, by: 'you' },
449
+ ...(head.runs ?? []),
450
+ ].slice(0, FRAME_RUNS),
451
+ };
452
+ await writeFrame(owner, next, put.data, put.edits);
453
+ return { meta: metaOf(next), rows: put.data, edits: put.edits };
454
+ },
455
+ /**
456
+ * A dataset an outside agent pushes (a CSV it wrangled): rows only, no recipe. `replace`
457
+ * writes the rows; `append` adds them, and with `key` a row whose key is already there
458
+ * replaces it. One frame per name among pushed ones; the rows pass the same caps as a run.
459
+ */
460
+ async pushFrame(owner, p) {
461
+ const same = await db.all(`SELECT id, head FROM ${t.frames} WHERE owner = ? AND name = ? LIMIT ${MAX_FRAMES}`, owner, p.name);
462
+ const pushed = same.find((r) => parse(r.head, null)?.origin === 'harness');
463
+ const found = pushed ? await getFrame(owner, String(pushed.id)) : null;
464
+ if (!found)
465
+ await roomForFrame(owner);
466
+ let all = p.rows;
467
+ if (found && p.mode === 'append') {
468
+ const before = asObjects(found.head.columns, found.data.rows);
469
+ if (p.key) {
470
+ const k = p.key;
471
+ const fresh = new Set(p.rows.map((r) => String(r[k] ?? '')));
472
+ all = [...before.filter((r) => !fresh.has(String(r[k] ?? ''))), ...p.rows];
473
+ }
474
+ else
475
+ all = [...before, ...p.rows];
476
+ }
477
+ const { columns, data } = toFrame(all);
478
+ const at = now();
479
+ const head = {
480
+ ...(found?.head ?? {
481
+ id: newId('fr'),
482
+ kind: 'snapshot',
483
+ mode: 'static',
484
+ origin: 'harness',
485
+ script: null,
486
+ }),
487
+ name: p.name,
488
+ columns,
489
+ rows: data.length,
490
+ at,
491
+ by: p.by.slice(0, 60),
492
+ error: undefined,
493
+ edited: undefined,
494
+ };
495
+ await writeFrame(owner, head, data, []);
496
+ return { meta: metaOf(head), rows: data, edits: [] };
497
+ },
498
+ /** Deletes a frame and every tab that shows it (a pushed dataset the agent takes away). */
499
+ async removeFrame(owner, id) {
500
+ await getFrame(owner, id);
501
+ const rows = await db.all(`SELECT id, name, app, tabs, created_at, updated_at FROM ${t.spaces} WHERE owner = ? LIMIT ${MAX_WORKSPACES}`, owner);
502
+ let tabs = 0;
503
+ for (const r of rows) {
504
+ const w = spaceOf(r);
505
+ const keep = w.tabs.filter((x) => !('frame' in x.source && x.source.frame === id));
506
+ if (keep.length === w.tabs.length)
507
+ continue;
508
+ tabs += w.tabs.length - keep.length;
509
+ await saveSpace(owner, { ...w, tabs: keep });
510
+ }
511
+ await db.run(`DELETE FROM ${t.frames} WHERE owner = ? AND id = ?`, owner, id);
512
+ return { ok: true, tabs };
513
+ },
380
514
  async editCells(owner, id, edits) {
381
515
  const { head, data } = await getFrame(owner, id);
382
516
  const at = now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.196",
3
+ "version": "0.1.198",
4
4
  "description": "Lumen's assistant and agent parts: a chat assistant's server (Claude tiers, tools over MCP, the web, hand off, spend caps), serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,8 +52,8 @@
52
52
  "@ai-sdk/openai": "4.0.108",
53
53
  "@anthropic-ai/sdk": "0.133.0",
54
54
  "@cloudflare/think": "0.23.0",
55
- "@columenlabs/lumen-ops": "0.1.196",
56
- "@columenlabs/lumen-ui-types": "0.1.196",
55
+ "@columenlabs/lumen-ops": "0.1.198",
56
+ "@columenlabs/lumen-ui-types": "0.1.198",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",