@columenlabs/lumen-ai 0.1.197 → 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 +10 -0
- package/dist/workspaces/frame.d.ts +10 -0
- package/dist/workspaces/frame.js +77 -0
- package/dist/workspaces/index.d.ts +2 -1
- package/dist/workspaces/index.js +2 -1
- package/dist/workspaces/routes.d.ts +17 -2
- package/dist/workspaces/routes.js +42 -7
- package/dist/workspaces/sandbox.d.ts +17 -0
- package/dist/workspaces/sandbox.js +65 -0
- package/dist/workspaces/store.d.ts +29 -1
- package/dist/workspaces/store.js +142 -8
- package/package.json +3 -3
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[];
|
package/dist/workspaces/frame.js
CHANGED
|
@@ -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';
|
package/dist/workspaces/index.js
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
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(
|
|
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>;
|
package/dist/workspaces/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
56
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
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",
|