@columenlabs/lumen-ai 0.1.216 → 0.1.218

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.
@@ -241,6 +241,8 @@ Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agen
241
241
 
242
242
  **The panel.** `LumenAssistant` reads a Think chat with no change. `ask(input, sink)` runs one answer and sends the panel's own events (`AssistantEvent`: the chat, the text, each tool line, cards, hand-off sections, advice, the use) to `sink`. The app's `POST /chat` returns `askResponse((sink) => chat.ask(input, sink), { signal, stop: () => chat.stop() })`, the same server-sent events `assistantRoutes` sends. `view()` gives a stored chat as the panel's items, and `usage()` gives the chat's use so far. A card's id is its tool call's, so the panel's Save, Dismiss and Undo go to `saveCard`, `dismissCard` and `undoCard`. The app keeps the list of chats, and each chat is its own agent, such as a facet of the person's store. `chunkEvents` and `itemsOf` are the mapping, for an app that streams some other way.
243
243
 
244
+ **Steps.** One answer makes at most `plan.maxSteps` model calls (`MAX_STEPS`, 8). The last one runs with no tools and `LAST_STEP_NOTE`, so an answer always ends in words: what it found, and what is left when the task did not fit.
245
+
244
246
  **Steer and queue.** While an answer runs, the panel's box stays open: Enter steers the message into the answer (`steer(message)` on the agent; the app routes `POST /chats/:id/steer` `{ message }` to it and answers `{ steered }`), Alt+Enter queues it for after. A steer joins at the answer's next step, after the tool it is on, with `STEER_NOTE` before it for the model; the panel shows it there marked Steered (event `steered`), and `view()` keeps it in place (`SteerNote`, under the message the answer is to). A steer the answer ends before reading comes back as `unsteered` and goes first in the queue. `plan.steer: false` turns it off for a model; an app without the route (404) shows Queue only. Queued messages each go as their own message when the answer before them ends; each has Steer now, Edit and Remove.
245
247
 
246
248
  ```ts
@@ -74,7 +74,7 @@ export interface AgentTurnPlan {
74
74
  left?: number;
75
75
  /** The person lets the assistant move their screen: the `screen` tool joins when the browser sent its map. */
76
76
  screen?: boolean;
77
- /** Most model calls in one answer; 8. */
77
+ /** Most model calls in one answer (`MAX_STEPS`, 8); the last one answers in words, with no tools. */
78
78
  maxSteps?: number;
79
79
  /**
80
80
  * A message sent while the answer runs joins it at its next step (Steer); on unless `false`,
@@ -99,6 +99,10 @@ export interface AgentTurnEnd {
99
99
  capped: boolean;
100
100
  }
101
101
  export declare const CAP_NOTE = "The spend cap for this month is reached: answer now from what you have, in a sentence or two. You can call no more tools.";
102
+ /** The last call of an answer: no tools, so the answer never ends on a tool's result with nothing said. */
103
+ export declare const LAST_STEP_NOTE = "This is the answer's last call and you can call no more tools: answer now from what you found. If the task is not finished, say what is done, what is left and that you can carry on when asked.";
104
+ /** Most model calls in one answer when the plan names none. */
105
+ export declare const MAX_STEPS = 8;
102
106
  /** Put before a message the person steered into a running answer, for the model only. */
103
107
  export declare const STEER_NOTE = "The person sent this while you were working. Take it into account from here on; it does not cancel what you were asked before unless it says so.";
104
108
  /** A card in the chat: a draft the person saves or dismisses, or a write made at once (Undo). */
@@ -24,6 +24,10 @@ import { chunkEvents, itemsOf } from './stream.js';
24
24
  import { openAiTools, } from './tools.js';
25
25
  import { UsageMeter } from './usage.js';
26
26
  export const CAP_NOTE = 'The spend cap for this month is reached: answer now from what you have, in a sentence or two. You can call no more tools.';
27
+ /** The last call of an answer: no tools, so the answer never ends on a tool's result with nothing said. */
28
+ export const LAST_STEP_NOTE = "This is the answer's last call and you can call no more tools: answer now from what you found. If the task is not finished, say what is done, what is left and that you can carry on when asked.";
29
+ /** Most model calls in one answer when the plan names none. */
30
+ export const MAX_STEPS = 8;
27
31
  /** Put before a message the person steered into a running answer, for the model only. */
28
32
  export const STEER_NOTE = 'The person sent this while you were working. Take it into account from here on; it does not cancel what you were asked before unless it says so.';
29
33
  /** Past this many tokens in the chat Think summarizes its older part. */
@@ -596,7 +600,7 @@ export class LumenAgent extends Think {
596
600
  tools,
597
601
  ...(lazy ? { activeTools: lazy.active() } : {}),
598
602
  ...(plan.first ? { toolChoice: { type: 'tool', toolName: plan.first } } : {}),
599
- maxSteps: plan.maxSteps ?? 8,
603
+ maxSteps: plan.maxSteps ?? MAX_STEPS,
600
604
  ...turnSettings(plan.model, {
601
605
  effort: plan.effort ?? 'default',
602
606
  ...(plan.out ? { out: plan.out } : {}),
@@ -620,6 +624,17 @@ export class LumenAgent extends Think {
620
624
  };
621
625
  return capped;
622
626
  }
627
+ // The last call answers in words: past it the loop stops, so a tool call there would end the
628
+ // answer on a tool's result with nothing said.
629
+ if (ctx.stepNumber >= (t.plan.maxSteps ?? MAX_STEPS) - 1) {
630
+ const last = {
631
+ ...messages,
632
+ activeTools: [],
633
+ toolChoice: 'none',
634
+ instructions: `${t.system}\n\n${LAST_STEP_NOTE}`,
635
+ };
636
+ return last;
637
+ }
623
638
  // A forced first call is for the first step only; tools a search found join each step.
624
639
  const auto = t.plan.first && ctx.stepNumber > 0;
625
640
  if (!auto && !t.lazy && !steered)
@@ -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.218",
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.218",
56
+ "@columenlabs/lumen-ui-types": "0.1.218",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",