@columenlabs/lumen-ai 0.1.216 → 0.1.217

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
@@ -99,7 +99,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
99
99
  - **ChatGPT sign-in.** In place of their own OpenAI key a person may sign in with their ChatGPT plan (`POST /my-signin`, then `POST /my-signin/finish {url}`): the sign-in opens in a new tab and ends on `http://127.0.0.1:1455/auth/callback`, a page that does not load, whose address the person pastes back. The tokens are sealed as their own key and refreshed before they run out; answers on the plan count tokens and no dollars.
100
100
  - **Who pays.** The person's pick (the shared keys by default) while it has a key for the tier's provider and money left this month under its cap (an admin sets the shared keys' cap per person, `PUT /app`), else the other; with neither the panel says why.
101
101
  - **Tools.** Each group is Off, Read, Read + draft or Read + write per person. A tool is a read when its MCP `readOnlyHint` is set. On Read + draft a write only drafts a card, and runs as the person when they press Save, after access is checked again. A group with `readOnly: true` (read tools only) is offered Off or Read alone. Read + write is offered only for a group with `writes: true`: its writes run at once, and with `undo` set each one's line in the chat has Undo, which calls `undo(caller, { tool, args, result })`; the model is told what was undone. Cards drafted one after another show as one stack with Save all. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
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.
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. Each web step carries `web` (`StepWeb`: a search's `query` and `results`, or the page it `read`), so the panel folds a search's results under its line and numbers each page read under the answer as Sources chips; the model marks facts with those numbers ([1]).
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
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.
@@ -1,4 +1,5 @@
1
1
  import { rowCount } from '../assistant/tools.js';
2
+ import { webStep } from '../assistant/web.js';
2
3
  /** A tool's name as a line's title: `notes__find_notes` → `find notes`. */
3
4
  export function stepTitle(name) {
4
5
  const own = name.includes('__') ? name.slice(name.indexOf('__') + 2) : name;
@@ -51,6 +52,7 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
51
52
  }
52
53
  const error = out.error ?? (o && typeof o.error === 'string' ? o.error : undefined);
53
54
  const rows = rowCount(o && 'result' in o ? o.result : out.output);
55
+ const web = webStep(c.name, c.input, out.output, !!error || obj(out.output)?.ok === false);
54
56
  const step = {
55
57
  kind: 'step',
56
58
  id,
@@ -63,6 +65,7 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
63
65
  ...(card?.kind === 'write' || (o && typeof o.change === 'string')
64
66
  ? { write: { undo: true, status: card?.status === 'undone' ? 'undone' : 'done' } }
65
67
  : {}),
68
+ ...(web ? { web } : {}),
66
69
  };
67
70
  return [{ event: 'step', data: step }];
68
71
  }
@@ -775,7 +775,7 @@ export class Assistant {
775
775
  if (failed)
776
776
  lines.push(`${failed} tool source${failed === 1 ? '' : 's'} could not be reached this turn.`);
777
777
  if (fns.web)
778
- lines.push('Web: use web__fetch first for APIs and plain pages; the browser tools only for pages that need JavaScript or after a fetch fails.');
778
+ lines.push('Web: use web__fetch first for APIs and plain pages; the browser tools only for pages that need JavaScript or after a fetch fails. To find pages, search, then read the one to three that answer the question. The person sees each page you read numbered under your answer, in the order you read them (1 for the first page read in this answer): mark a fact you took from a page with its number, like [1], and never cite a page you did not read.');
779
779
  if (main) {
780
780
  const reach = this.above(main.tier);
781
781
  if (reach.length) {
@@ -4,6 +4,7 @@
4
4
  * for pages that need a real browser or after a fetch fails. ScrapeQL sits behind Cloudflare Access,
5
5
  * so the app reaches it with one Access service token an admin pastes in Settings.
6
6
  */
7
+ import type { StepWeb } from '@columenlabs/lumen-ui-types/assistant';
7
8
  import { type ToolSource } from './tools.js';
8
9
  export interface WebFetchOptions {
9
10
  /** Hosts never fetched (the app's own, behind sign-in), each with its subdomains. */
@@ -46,3 +47,10 @@ export interface AccessToken {
46
47
  * binding (`f` is then the binding's fetch). Every one of them reads.
47
48
  */
48
49
  export declare function scrapeqlTools(url: string, token: AccessToken | null, f?: typeof fetch): ToolSource;
50
+ /**
51
+ * What a finished web tool call shows the person, or undefined for any other tool: a search's
52
+ * words and the pages it listed, or the page a read opened (`web__fetch` after redirects; ScrapeQL's
53
+ * `scrape`, `extract`, `ask` and `screenshot` by the address they were given). A failed read names
54
+ * the page it tried, never a page read.
55
+ */
56
+ export declare function webStep(name: string, input: unknown, output: unknown, failed: boolean): StepWeb | undefined;
@@ -1,9 +1,3 @@
1
- /**
2
- * The web: `fetch`, a plain GET from the Worker (no browser, 10 s, 2 MB), tried first for APIs, JSON
3
- * and plain pages; and ScrapeQL's own MCP tools (`search`, `scrape`, `extract`, `ask`) over its API
4
- * for pages that need a real browser or after a fetch fails. ScrapeQL sits behind Cloudflare Access,
5
- * so the app reaches it with one Access service token an admin pastes in Settings.
6
- */
7
1
  import { plainTools, remoteMcpTools } from './tools.js';
8
2
  /** Fetches one answer may have in flight at once; more wait their turn. */
9
3
  const PARALLEL = 20;
@@ -177,3 +171,68 @@ export function scrapeqlTools(url, token, f) {
177
171
  return { ...open, tools: open.tools.map((t) => ({ ...t, read: true })) };
178
172
  };
179
173
  }
174
+ const LINK = /\[([^\]\n]{0,300})\]\((https?:\/\/[^)\s]{1,2000})\)/g;
175
+ /** Results a search step lists; more are cut. */
176
+ const RESULTS_MAX = 10;
177
+ /** Text a tool's output carries: a plain string, or the `result` string it was wrapped in. */
178
+ function textOf(v) {
179
+ if (typeof v === 'string')
180
+ return v;
181
+ const r = v && typeof v === 'object' ? v.result : undefined;
182
+ return typeof r === 'string' ? r : '';
183
+ }
184
+ /** A page's title from the HTML a fetch read, when it has one. */
185
+ function htmlTitle(text) {
186
+ const m = /<title[^>]*>([^<]{1,300})<\/title>/i.exec(text.slice(0, 50_000));
187
+ const t = m?.[1]?.replace(/\s+/g, ' ').trim();
188
+ return t || undefined;
189
+ }
190
+ /**
191
+ * What a finished web tool call shows the person, or undefined for any other tool: a search's
192
+ * words and the pages it listed, or the page a read opened (`web__fetch` after redirects; ScrapeQL's
193
+ * `scrape`, `extract`, `ask` and `screenshot` by the address they were given). A failed read names
194
+ * the page it tried, never a page read.
195
+ */
196
+ export function webStep(name, input, output, failed) {
197
+ if (!name.startsWith('web__'))
198
+ return undefined;
199
+ const tool = name.slice(5);
200
+ const args = (input && typeof input === 'object' ? input : {});
201
+ if (tool === 'search') {
202
+ const query = typeof args.query === 'string' ? args.query.slice(0, 300) : undefined;
203
+ const results = [];
204
+ for (const m of textOf(output).matchAll(LINK)) {
205
+ if (results.length >= RESULTS_MAX)
206
+ break;
207
+ const url = m[2];
208
+ if (results.some((r) => r.url === url))
209
+ continue;
210
+ const title = m[1].trim();
211
+ results.push(title ? { url, title } : { url });
212
+ }
213
+ return { ...(query ? { query } : {}), results };
214
+ }
215
+ if (failed) {
216
+ const url = typeof args.url === 'string' ? args.url : '';
217
+ return /^https?:\/\//.test(url) ? { tried: { url } } : undefined;
218
+ }
219
+ if (tool === 'fetch') {
220
+ // A long answer reaches the model cut, as text: read its fields from that text.
221
+ const cut = typeof output === 'string' ? output : '';
222
+ const o = (output && typeof output === 'object' ? output : {});
223
+ if (o.ok === false || cut.startsWith('{"ok":false'))
224
+ return undefined;
225
+ const said = cut ? /"url":"(https?:\/\/[^"]{1,2000})"/.exec(cut)?.[1] : undefined;
226
+ const url = typeof o.url === 'string' ? o.url : (said ?? (typeof args.url === 'string' ? args.url : ''));
227
+ if (!/^https?:\/\//.test(url))
228
+ return undefined;
229
+ const body = typeof o.text === 'string' ? o.text : cut;
230
+ const title = body ? htmlTitle(body) : undefined;
231
+ return { read: title ? { url, title } : { url } };
232
+ }
233
+ if (['scrape', 'extract', 'ask', 'screenshot'].includes(tool)) {
234
+ const url = typeof args.url === 'string' ? args.url : '';
235
+ return /^https?:\/\//.test(url) ? { read: { url } } : undefined;
236
+ }
237
+ return undefined;
238
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.216",
3
+ "version": "0.1.217",
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.216",
56
- "@columenlabs/lumen-ui-types": "0.1.216",
55
+ "@columenlabs/lumen-ops": "0.1.217",
56
+ "@columenlabs/lumen-ui-types": "0.1.217",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",