@columenlabs/lumen-ai 0.1.262 → 0.1.263

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
@@ -112,7 +112,8 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
112
112
  - **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`.
113
113
  - **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]).
114
114
  - **Web lookup (a subagent).** `web: { scrapeql, lookup: true }` swaps fetch and ScrapeQL's own tools for one tool, `web__lookup` (`webLookupTools` from `@columenlabs/lumen-ai/subagents`): given a question (and `urls` when the pages are known), it searches with ScrapeQL, has ScrapeQL's `ask` read the best pages (3 by default, `pages` up to 5) and answer in a few sentences each (`answerTokens`, default 300), and returns `{question, sources: [{url, title, answer}], failed}`: about a thousand tokens, never whole pages, so the assistant's context stays small. No model runs in the kit for it; ScrapeQL picks its own cheapest model. Its step carries `web.lookup` and `web.reads`, which the panel folds under a Looked up line and numbers as Sources chips. `fetch.blockHosts` still applies. Until ScrapeQL is reachable (no binding and no Access token saved yet), the web falls back to `fetch` and the lookup rule is left out, so turning lookup on never takes the web away.
115
- - **Connectors (outside MCP servers).** `connectors: true` lets an admin add any MCP server over Streamable HTTP in Settings (`assistantAdminSections({ connectors: true })`, routes `POST /connectors`, `PUT|DELETE /connectors/:id`, `POST /connectors/:id/test`): a name, an address and up to ten headers it signs in with (an Access service token, an API key; sealed with the app's other settings, never returned, only their names). It is kept only when its test lists its tools, and that list is kept: a turn offers the tools without calling the server, as group `ext_<id>` (`ext_scrapeql__search`), at the access the admin gives it: Off, Read (the tools the server marks read-only) or Read + draft (the rest make a card the person saves or dismisses; nothing runs at once). The server is called only when a tool is, over one connection for the rest of the answer. A connector's tools are always lazy: the model sees none of them until it finds them with `tool_search`, whose catalog names each connector and its tools, so a connector costs a line of the prompt until it is used. A connector's text is data, never instructions; its tool titles never become a step's words, and its tools that search or read a page (`search`, `scrape`, `extract`, `ask`, `screenshot`) carry `web` like the kit's own, so the panel shows their Sources.
115
+ - **Connectors (outside MCP servers).** `connectors: true` lets an admin add any MCP server over Streamable HTTP in Settings (`assistantAdminSections({ connectors: true })`, routes `POST /connectors`, `PUT|DELETE /connectors/:id`, `POST /connectors/:id/test`): a name, an address and up to ten headers it signs in with (an Access service token, an API key; sealed with the app's other settings, never returned, only their names). It is kept only when its test lists its tools, and that list is kept: a turn offers the tools without calling the server, as group `ext_<id>` (`ext_scrapeql__search`), at the access the admin gives it: Off, Read (the tools the server marks read-only) or Read + draft (the rest make a card the person saves or dismisses; nothing runs at once). The server is called only when a tool is, over one connection for the rest of the answer. A connector's tools are always lazy: the model sees none of them until it finds them with `tool_search`, whose catalog names each connector and its tools, so a connector costs a line of the prompt until it is used. A connector's text is data, never instructions; its tool titles never become a step's words, and its tools that search or read a page (`search`, `scrape`, `extract`, `ask`, `screenshot`) carry `web` like the kit's own, so the panel shows their Sources. A connector's optional fields tell the model to leave them out unless it has a real value, so it does not make up ids.
116
+ - **Fetch first, a browser when turned away.** `web.escalate: { connector, tool, args? }` (e.g. `{ connector: 'scrapeql', tool: 'scrape' }`) sends a `fetch` the site turns away (401, 403, 429, 503, no answer, or an HTML page with almost no text or a bot check) to that connector's page-reading tool with only `{ url }` and `args`; the page comes back as the fetch's own answer (`via: "browser"`), so the model never retries by hand. Off while that connector is off. A failed step in a plain chat says why in plain words (`plainFailure`: the site turned it away, took too long, page not found), never the raw error.
116
117
  - **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. A hand-off gives up after `HAND_OFF_MS` (4 minutes) and the turn goes on without it.
117
118
  - **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.
118
119
  - **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.
@@ -40,7 +40,9 @@ export interface StepCall {
40
40
  */
41
41
  export declare function hiddenStep(name: string, names?: StepNames): boolean;
42
42
  /** What a plain line says when a call failed: the assistant reads the error and goes on. */
43
- export declare const PLAIN_FAILED = "didn't work";
43
+ export declare const PLAIN_FAILED = "couldn't finish";
44
+ /** What a plain line says about why a call failed, in a few plain words, never the raw error. */
45
+ export declare function plainFailure(error: string): string;
44
46
  /** A plain hand-off's heading: a closer look, never the tier it went to. */
45
47
  export declare const PLAIN_HANDOFF = "Take a closer look";
46
48
  /** The line's title: the tool's own title, else its name (or, plain, a word for what it did). */
@@ -28,7 +28,23 @@ export function hiddenStep(name, names = {}) {
28
28
  return !!names.plain && name === TOOL_SEARCH;
29
29
  }
30
30
  /** What a plain line says when a call failed: the assistant reads the error and goes on. */
31
- export const PLAIN_FAILED = "didn't work";
31
+ export const PLAIN_FAILED = "couldn't finish";
32
+ /** What a plain line says about why a call failed, in a few plain words, never the raw error. */
33
+ export function plainFailure(error) {
34
+ if (/\b(401|403|406|451)\b|forbidden|denied|turned away|bot check|captcha/i.test(error))
35
+ return 'the site turned it away';
36
+ if (/\b429\b|rate.?limit|too many/i.test(error))
37
+ return 'too many requests, try again later';
38
+ if (/timed? ?out|timeout/i.test(error))
39
+ return 'took too long';
40
+ if (/\b404\b|\b410\b|page not found/i.test(error))
41
+ return 'page not found';
42
+ if (/could not be reached|ENOTFOUND|fetch failed|network/i.test(error))
43
+ return 'could not be reached';
44
+ if (/\b5\d\d\b/.test(error))
45
+ return 'the service had a problem';
46
+ return PLAIN_FAILED;
47
+ }
32
48
  /** A plain hand-off's heading: a closer look, never the tier it went to. */
33
49
  export const PLAIN_HANDOFF = 'Take a closer look';
34
50
  /** The line's title: the tool's own title, else its name (or, plain, a word for what it did). */
@@ -129,7 +145,7 @@ function settledCall(id, c, out, ms, cards, screens = new Map(), names = {}) {
129
145
  ms,
130
146
  ...(rows !== undefined && !error && !names.plain ? { rows } : {}),
131
147
  ...(error && !(line && names.plain)
132
- ? { error: names.plain ? PLAIN_FAILED : error.slice(0, 300) }
148
+ ? { error: names.plain ? plainFailure(error) : error.slice(0, 300) }
133
149
  : {}),
134
150
  ...(wrote
135
151
  ? { write: { undo: true, status: card?.status === 'undone' ? 'undone' : 'done' } }
@@ -113,6 +113,29 @@ export async function testConnector(url, headers, f) {
113
113
  await open?.close().catch(() => undefined);
114
114
  }
115
115
  }
116
+ const OPTIONAL = 'Optional: leave it out unless you have a real value; never make one up.';
117
+ /**
118
+ * A tool whose optional fields say so in their descriptions: a model tends to fill every field it
119
+ * sees, and an invented id (a sign-in, a session) makes the server fail the call.
120
+ */
121
+ function optionalSaid(t) {
122
+ const s = t.inputSchema;
123
+ if (!s?.properties || typeof s.properties !== 'object')
124
+ return t;
125
+ const required = new Set(Array.isArray(s.required) ? s.required : []);
126
+ const properties = {};
127
+ for (const [k, v] of Object.entries(s.properties)) {
128
+ const p = v && typeof v === 'object' ? v : null;
129
+ properties[k] =
130
+ p && !required.has(k)
131
+ ? {
132
+ ...p,
133
+ description: typeof p.description === 'string' ? `${p.description} ${OPTIONAL}` : OPTIONAL,
134
+ }
135
+ : v;
136
+ }
137
+ return { ...t, inputSchema: { ...s, properties } };
138
+ }
116
139
  /**
117
140
  * A connector's tools from its kept catalog: opening them calls nothing. The server is reached on
118
141
  * the first call, and that one connection serves the rest of the answer.
@@ -122,7 +145,7 @@ export function catalogTools(d, f) {
122
145
  let live = null;
123
146
  const remote = remoteMcpTools({ url: d.url, headers: d.headers, ...(f ? { fetch: f } : {}) });
124
147
  return {
125
- tools: d.catalog,
148
+ tools: d.catalog.map(optionalSaid),
126
149
  call: async (name, args) => {
127
150
  live ??= remote();
128
151
  try {
@@ -182,6 +182,16 @@ export interface AssistantOptions {
182
182
  * pages never reach the model. Needs `scrapeql`; without it the web is off.
183
183
  */
184
184
  lookup?: boolean | WebLookupOptions;
185
+ /**
186
+ * Where `fetch` goes when a site turns it away (a 403, a bot check, a page with no text): a
187
+ * connector's page-reading tool, called with only `{ url }` plus `args`. The answer comes back
188
+ * as the fetch's own, so the model never has to retry. Off while that connector is off.
189
+ */
190
+ escalate?: {
191
+ connector: string;
192
+ tool: string;
193
+ args?: Record<string, unknown>;
194
+ };
185
195
  };
186
196
  store: AssistantStore;
187
197
  sealer?: Sealer;
@@ -352,6 +362,8 @@ export declare class Assistant {
352
362
  /** Opens the tools the person allows: each group's, then the web's. */
353
363
  /** The tool groups (and the web) at the person's access, not opened yet. */
354
364
  private sourcesOf;
365
+ /** `fetch`'s second try, through the connector `web.escalate` names, when it is on. */
366
+ private escalation;
355
367
  /** The app's connectors, as stored; none when the app has them off. */
356
368
  connectorDocs(): Promise<ConnectorDoc[]>;
357
369
  /** Whether the app offers connectors (`connectors`). */
@@ -870,7 +870,10 @@ export class Assistant {
870
870
  sources.push({
871
871
  group: 'web',
872
872
  access: 'read',
873
- source: webFetchTools(this.o.web?.fetch || {}),
873
+ source: webFetchTools({
874
+ ...(this.o.web?.fetch || {}),
875
+ ...(await this.escalation()),
876
+ }),
874
877
  });
875
878
  if (scrapeql)
876
879
  sources.push({ group: 'web', access: 'read', source: scrapeql });
@@ -892,6 +895,37 @@ export class Assistant {
892
895
  return sources;
893
896
  }
894
897
  // ---------------------------------------------------------------- connectors
898
+ /** `fetch`'s second try, through the connector `web.escalate` names, when it is on. */
899
+ async escalation() {
900
+ const e = this.o.web?.escalate;
901
+ if (!e)
902
+ return {};
903
+ const c = (await this.connectorDocs()).find((d) => d.id === e.connector);
904
+ if (!c || c.access === 'off' || !c.catalog.some((t) => t.name === e.tool))
905
+ return {};
906
+ return {
907
+ escalate: async (url) => {
908
+ const open = await catalogTools(c, this.o.fetch)();
909
+ try {
910
+ const out = await open.call(e.tool, { ...e.args, url });
911
+ const text = typeof out === 'string' ? out : JSON.stringify(out);
912
+ return text.trim() ? text : null;
913
+ }
914
+ catch (err) {
915
+ console.error(JSON.stringify({
916
+ event: 'assistant_fetch_escalate',
917
+ connector: c.id,
918
+ url: url.slice(0, 300),
919
+ error: String(err?.message ?? err).slice(0, 300),
920
+ }));
921
+ return null;
922
+ }
923
+ finally {
924
+ await open.close().catch(() => undefined);
925
+ }
926
+ },
927
+ };
928
+ }
895
929
  /** The app's connectors, as stored; none when the app has them off. */
896
930
  async connectorDocs() {
897
931
  if (!this.o.connectors)
@@ -11,7 +11,20 @@ export interface WebFetchOptions {
11
11
  blockHosts?: string[];
12
12
  userAgent?: string;
13
13
  fetch?: typeof fetch;
14
+ /**
15
+ * Reads a page another way (a real browser) when the plain GET is turned away: refused (401, 403,
16
+ * 429, 503), never answered, or an HTML page with almost no text or a bot check. Text, or null.
17
+ */
18
+ escalate?: (url: string) => Promise<string | null>;
14
19
  }
20
+ /** Whether a plain GET's answer was a wall rather than the page. */
21
+ export declare function walled(r: {
22
+ ok: boolean;
23
+ status: number;
24
+ error?: string;
25
+ content_type?: string;
26
+ text?: string;
27
+ }): boolean;
15
28
  /** Why a URL is refused, or null. */
16
29
  export declare function refused(url: URL, block: string[]): string | null;
17
30
  /** One GET, never thrown: what it got, or why not. */
@@ -5,6 +5,26 @@ const TIMEOUT_MS = 10_000;
5
5
  /** Bytes of body read; past it the body is cut and `truncated` says so. */
6
6
  const MAX_BYTES = 2_000_000;
7
7
  const DESCRIPTION = `Plain HTTP GET of one URL from the server, no browser: fast and cheap, so use it first for APIs, JSON, CSV, RSS and plain HTML. Returns {ok, status, url (after redirects), content_type, json (when the body is JSON) or text, truncated}; a failed fetch (refused, timed out, not 2xx) returns ok false with status and error. Bodies over ${MAX_BYTES / 1_000_000} MB are cut; ${TIMEOUT_MS / 1000} s per fetch. Use the browser tools (scrape, extract, ask) only for pages built by JavaScript, bot walls, or after fetch fails.`;
8
+ /** The description when a turned-away page goes to a real browser by itself. */
9
+ const DESCRIPTION_ESCALATES = DESCRIPTION.replace(/Use the browser tools.*$/, 'A page that turns the server away (a bot wall, 403) is read again in a real browser by itself and comes back with via "browser": no need to retry it with another tool.');
10
+ /** Statuses a site answers a bot with, where a real browser often gets the page. */
11
+ const TURNED_AWAY = new Set([401, 403, 406, 429, 451, 503]);
12
+ const BOT_CHECK = /captcha|are you a (?:human|robot)|access denied|just a moment|enable javascript|verify you are human|unusual traffic/i;
13
+ /** Whether a plain GET's answer was a wall rather than the page. */
14
+ export function walled(r) {
15
+ if (r.error?.startsWith('refused:') || r.error?.startsWith('not a full URL'))
16
+ return false;
17
+ if (!r.ok)
18
+ return r.status === 0 || TURNED_AWAY.has(r.status);
19
+ if (!/html/i.test(r.content_type ?? '') || typeof r.text !== 'string')
20
+ return false;
21
+ const words = r.text
22
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
23
+ .replace(/<[^>]+>/g, ' ')
24
+ .replace(/\s+/g, ' ')
25
+ .trim();
26
+ return words.length < 400 || (words.length < 5000 && BOT_CHECK.test(words));
27
+ }
8
28
  /** Why a URL is refused, or null. */
9
29
  export function refused(url, block) {
10
30
  if (url.protocol !== 'https:' && url.protocol !== 'http:')
@@ -111,7 +131,7 @@ export function webFetchTools(o = {}) {
111
131
  {
112
132
  name: 'fetch',
113
133
  title: 'Read a web page',
114
- description: DESCRIPTION,
134
+ description: o.escalate ? DESCRIPTION_ESCALATES : DESCRIPTION,
115
135
  read: true,
116
136
  inputSchema: {
117
137
  type: 'object',
@@ -137,7 +157,18 @@ export function webFetchTools(o = {}) {
137
157
  String(v),
138
158
  ]))
139
159
  : {};
140
- return await webFetch(String(a.url ?? ''), headers, o);
160
+ const url = String(a.url ?? '');
161
+ const r = await webFetch(url, headers, o);
162
+ if (!o.escalate || !walled(r))
163
+ return r;
164
+ // Turned away: the same page through a real browser, with no question to anyone.
165
+ const text = await o.escalate(url).catch(() => null);
166
+ return text
167
+ ? { ok: true, status: 200, url, via: 'browser', text, truncated: false }
168
+ : {
169
+ ...r,
170
+ error: `${r.error ?? 'a bot check'}; a real browser could not read it either`,
171
+ };
141
172
  }
142
173
  finally {
143
174
  running--;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.262",
3
+ "version": "0.1.263",
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",
@@ -56,8 +56,8 @@
56
56
  "@ai-sdk/openai": "4.0.108",
57
57
  "@anthropic-ai/sdk": "0.133.0",
58
58
  "@cloudflare/think": "0.23.0",
59
- "@columenlabs/lumen-ops": "0.1.262",
60
- "@columenlabs/lumen-ui-types": "0.1.262",
59
+ "@columenlabs/lumen-ops": "0.1.263",
60
+ "@columenlabs/lumen-ui-types": "0.1.263",
61
61
  "agents": "0.32.0",
62
62
  "ai": "7.0.162",
63
63
  "just-bash": "3.6.0",