@columenlabs/lumen-ai 0.1.253 → 0.1.255

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
@@ -111,7 +111,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
111
111
  - **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.
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
- - **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.
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
115
  - **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.
116
116
  - **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.
117
117
  - **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.
@@ -254,7 +254,7 @@ Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agen
254
254
 
255
255
  **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.
256
256
 
257
- **Tool lines.** Each tool call shows as a line with the tool's own `title` (an MCP tool's `title`, a plain tool's `title`), kept in the chat's storage so a chat read back words its lines the same; a tool with no title shows its name. An app that keeps its insides off the screen overrides `plainSteps()` to return `true`: every line reads its tool's title, else a word for what it did (Look something up, Make a change, Draft a change), with no tool id, row count or error text (a failed line says `didn't work`), and a hand-off section reads Take a closer look with no tier or task. The details go to the app's log instead: `stepFailed({ tool, error, ms })` hears each failed call with its whole error (override it to log it with the chat's ids), and `onStep` carries `error` and `caller` on a failed tool or hand-off. `stepTitleOf(name, kind)` gives a line's title for an app's own card answers.
257
+ **Tool lines.** Each tool call shows as a line with the tool's own `title` (an MCP tool's `title`, a plain tool's `title`), kept in the chat's storage so a chat read back words its lines the same; a tool with no title shows its name. An app that keeps its insides off the screen overrides `plainSteps()` to return `true`: every line reads its tool's title, else a word for what it did (Look something up, Make a change, Draft a change), with no tool id, row count or error text (a failed line says `didn't work`), and a hand-off section reads Take a closer look with no tier or task. The details go to the app's log instead: `stepFailed({ tool, error, ms })` hears each failed call with its whole error (override it to log it with the chat's ids), and `onStep` carries `error` and `caller` on a failed tool or hand-off. `stepTitleOf(name, kind)` gives a line's title for an app's own card answers. **Step lines say what a call did** (`stepLine(c)`): override it to word each call as a verb and what it acted on, from the call's own arguments and answer (`c.input`, `c.output`): present tense while it runs (`state: 'running'`, sent again once the arguments are in), past tense with a short result when it answered (`'done'`), and "Couldn't …" when it failed (`'failed'`, which then shows no `didn't work`). Return `undefined` to keep the tool's title. The kit words its own web lookup (Looking up / Looked up the question). A finished line is kept with the chat, so a chat read back reads the same. In a plain chat the tool search that turns more tools on shows no line.
258
258
 
259
259
  **A write in progress.** A tool line starts (`step_start`) as soon as the model starts the call, while it is still writing the arguments. For a Read + write tool it carries `write: true`, and `useAssistantChat` marks that running step `writing` until its result lands or the answer ends, so an app can show what the tool changes as being changed now (a pending edit is not ready to accept while the model rewrites it).
260
260
 
@@ -17,6 +17,7 @@ import { type ModelMessage, type ToolSet } from 'ai';
17
17
  import type { ModelDef } from '../assistant/models.js';
18
18
  import { type AgentConnector, type ConnectorAt } from './connectors.js';
19
19
  import { type ModelKey } from './models.js';
20
+ import { type StepCall } from './stream.js';
20
21
  import { type AiToolsOptions, type OpenedTools, type SourceAt } from './tools.js';
21
22
  import { UsageMeter } from './usage.js';
22
23
  /** What a plan's extra tools get: the open tools, a meter of their own and the cap. */
@@ -197,6 +198,16 @@ export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
197
198
  * (`false`) unless the app overrides it; the app logs the details from `onStep`.
198
199
  */
199
200
  protected plainSteps(): boolean;
201
+ /**
202
+ * A tool call's line in the person's words: a verb and what it acted on, from the call's own
203
+ * arguments and answer. `running` is present tense ("Editing Lid"), `done` past tense with a short
204
+ * result when it helps ("Edited Lid: wall 2 → 3 mm"), `failed` says it did not ("Couldn't edit
205
+ * Lid"). Return `undefined` to keep the tool's title. A plain chat's line names no tool id, model,
206
+ * engine or code. The kit words its own web lookup; the app words its tools by overriding this
207
+ * (and may call `super.stepLine(c)` for the kit's). A finished line is kept with the chat, so a
208
+ * chat read back shows the same words.
209
+ */
210
+ protected stepLine(c: StepCall): string | undefined;
200
211
  /**
201
212
  * A tool call this answer failed: its name and whole error (a plain line shows neither). Does
202
213
  * nothing unless the app overrides it, e.g. to log it with the chat's ids.
@@ -201,12 +201,45 @@ export class LumenAgent extends Think {
201
201
  plainSteps() {
202
202
  return false;
203
203
  }
204
+ /**
205
+ * A tool call's line in the person's words: a verb and what it acted on, from the call's own
206
+ * arguments and answer. `running` is present tense ("Editing Lid"), `done` past tense with a short
207
+ * result when it helps ("Edited Lid: wall 2 → 3 mm"), `failed` says it did not ("Couldn't edit
208
+ * Lid"). Return `undefined` to keep the tool's title. A plain chat's line names no tool id, model,
209
+ * engine or code. The kit words its own web lookup; the app words its tools by overriding this
210
+ * (and may call `super.stepLine(c)` for the kit's). A finished line is kept with the chat, so a
211
+ * chat read back shows the same words.
212
+ */
213
+ stepLine(c) {
214
+ if (c.tool !== 'web__lookup')
215
+ return undefined;
216
+ const q = c.input?.question;
217
+ const question = typeof q === 'string' ? q.trim() : '';
218
+ if (!question)
219
+ return undefined;
220
+ return c.state === 'running'
221
+ ? `Looking up ${question}`
222
+ : c.state === 'failed'
223
+ ? `Couldn't look up ${question}`
224
+ : `Looked up ${question}`;
225
+ }
204
226
  /** How this chat words its tool lines: plain or not, with the titles of every tool it offered. */
205
227
  #names() {
206
228
  return {
207
229
  plain: this.plainSteps(),
208
230
  titleOf: (n) => this.#turn?.tools.info.find((i) => i.name === n)?.title ??
209
231
  this.#get('titles')?.[n],
232
+ lineOf: (c) => {
233
+ if (c.state === 'running')
234
+ return this.stepLine(c);
235
+ const kept = this.#get(`line:${c.id}`);
236
+ if (kept)
237
+ return kept;
238
+ const line = this.stepLine(c);
239
+ if (line)
240
+ this.#put(`line:${c.id}`, line.slice(0, 200));
241
+ return line;
242
+ },
210
243
  };
211
244
  }
212
245
  /**
@@ -17,7 +17,28 @@ export interface StepNames {
17
17
  plain?: boolean;
18
18
  /** The tool's own title, when it has one. */
19
19
  titleOf?: (name: string) => string | undefined;
20
+ /**
21
+ * A line that says what this call did and to what (`LumenAgent.stepLine`): present tense while it
22
+ * runs, past tense with a short result when it answered, "Couldn't …" when it failed. Left out
23
+ * (or `undefined`), the line reads the tool's title.
24
+ */
25
+ lineOf?: (c: StepCall) => string | undefined;
20
26
  }
27
+ /** One tool call as its line is worded: its name, arguments, answer and where it stands. */
28
+ export interface StepCall {
29
+ id: string;
30
+ /** The tool's name as the model called it (`group__tool`). */
31
+ tool: string;
32
+ input?: unknown;
33
+ /** Its answer, once it answered. */
34
+ output?: unknown;
35
+ state: 'running' | 'done' | 'failed';
36
+ }
37
+ /**
38
+ * Whether a call shows no line at all: in a plain chat, the tool search that turns more tools on is
39
+ * the assistant finding its own tools, not something it did for the person.
40
+ */
41
+ export declare function hiddenStep(name: string, names?: StepNames): boolean;
21
42
  /** What a plain line says when a call failed: the assistant reads the error and goes on. */
22
43
  export declare const PLAIN_FAILED = "didn't work";
23
44
  /** A plain hand-off's heading: a closer look, never the tier it went to. */
@@ -1,10 +1,32 @@
1
1
  import { rowCount } from '../assistant/tools.js';
2
2
  import { webStep } from '../assistant/web.js';
3
+ import { TOOL_SEARCH } from './search.js';
3
4
  /** A tool's name as a line's title: `notes__find_notes` → `find notes`. */
4
5
  export function stepTitle(name) {
5
6
  const own = name.includes('__') ? name.slice(name.indexOf('__') + 2) : name;
6
7
  return own.replace(/_/g, ' ');
7
8
  }
9
+ /** A line's text kept to one short line. */
10
+ const oneLine = (t) => {
11
+ const s = (t ?? '').replace(/\s+/g, ' ').trim();
12
+ return s ? s.slice(0, 120) : undefined;
13
+ };
14
+ /** The line the app gave for this call, when it gave one; never throws. */
15
+ function lineFor(names, c) {
16
+ try {
17
+ return oneLine(names.lineOf?.(c));
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ /**
24
+ * Whether a call shows no line at all: in a plain chat, the tool search that turns more tools on is
25
+ * the assistant finding its own tools, not something it did for the person.
26
+ */
27
+ export function hiddenStep(name, names = {}) {
28
+ return !!names.plain && name === TOOL_SEARCH;
29
+ }
8
30
  /** What a plain line says when a call failed: the assistant reads the error and goes on. */
9
31
  export const PLAIN_FAILED = "didn't work";
10
32
  /** A plain hand-off's heading: a closer look, never the tier it went to. */
@@ -88,15 +110,27 @@ function settledCall(id, c, out, ms, cards, screens = new Map(), names = {}) {
88
110
  const image = stepImage(o?.image);
89
111
  const web = webStep(c.name, c.input, out.output, !!error || obj(out.output)?.ok === false);
90
112
  const wrote = card?.kind === 'write' || (o && typeof o.change === 'string');
113
+ // The app's own line says what the call did; a failed one already says it did not ("Couldn't …").
114
+ const line = lineFor(names, {
115
+ id,
116
+ tool: c.name,
117
+ input: c.input,
118
+ ...(error
119
+ ? {}
120
+ : { output: o && 'result' in o && o.change !== undefined ? o.result : out.output }),
121
+ state: error ? 'failed' : 'done',
122
+ });
91
123
  const step = {
92
124
  kind: 'step',
93
125
  id,
94
126
  tool: c.name,
95
- title: shownTitle(c.name, names, wrote ? 'write' : 'read'),
127
+ title: line ?? shownTitle(c.name, names, wrote ? 'write' : 'read'),
96
128
  ok: !error,
97
129
  ms,
98
130
  ...(rows !== undefined && !error && !names.plain ? { rows } : {}),
99
- ...(error ? { error: names.plain ? PLAIN_FAILED : error.slice(0, 300) } : {}),
131
+ ...(error && !(line && names.plain)
132
+ ? { error: names.plain ? PLAIN_FAILED : error.slice(0, 300) }
133
+ : {}),
100
134
  ...(wrote
101
135
  ? { write: { undo: true, status: card?.status === 'undone' ? 'undone' : 'done' } }
102
136
  : {}),
@@ -129,7 +163,10 @@ export function chunkEvents(o = {}) {
129
163
  advising = true;
130
164
  return [{ event: 'advice', data: { tier: 'advisor', label: o.advisor ?? 'Advice' } }];
131
165
  }
132
- if (name === 'escalate' || name === 'screen' || o.kindOf?.(name) === 'draft')
166
+ if (name === 'escalate' ||
167
+ name === 'screen' ||
168
+ o.kindOf?.(name) === 'draft' ||
169
+ hiddenStep(name, names))
133
170
  return [];
134
171
  // A tool that makes a change at once says so as it starts, so an app can show that what it
135
172
  // changes is being changed now, before the model has finished writing the call.
@@ -150,7 +187,23 @@ export function chunkEvents(o = {}) {
150
187
  case 'tool-input-available': {
151
188
  const c = calls.get(id) ?? { name: String(chunk.toolName ?? ''), t0: Date.now() };
152
189
  calls.set(id, { ...c, input: chunk.input });
153
- return [];
190
+ // Its arguments are in: the running line can say what it is doing and to what.
191
+ if (c.name === 'advisor' ||
192
+ c.name === 'escalate' ||
193
+ c.name === 'screen' ||
194
+ o.kindOf?.(c.name) === 'draft' ||
195
+ hiddenStep(c.name, names))
196
+ return [];
197
+ const line = lineFor(names, { id, tool: c.name, input: chunk.input, state: 'running' });
198
+ if (!line)
199
+ return [];
200
+ const write = o.kindOf?.(c.name) === 'write';
201
+ return [
202
+ {
203
+ event: 'step_start',
204
+ data: { id, tool: c.name, title: line, ...(write ? { write } : {}) },
205
+ },
206
+ ];
154
207
  }
155
208
  case 'tool-output-available':
156
209
  case 'tool-output-error': {
@@ -164,6 +217,8 @@ export function chunkEvents(o = {}) {
164
217
  return [{ event: 'advice_done', data: error ? { error } : {} }];
165
218
  }
166
219
  const ms = Date.now() - c.t0;
220
+ if (hiddenStep(c.name, names))
221
+ return [];
167
222
  const out = obj(chunk.output);
168
223
  const said = error ?? (out && typeof out.error === 'string' ? out.error : undefined);
169
224
  if (said !== undefined) {
@@ -256,6 +311,8 @@ export function itemsOf(messages, cards, screens = new Map(), steers = {}, names
256
311
  items.push({ kind: 'advice', tier: 'advisor', label: 'Advice' });
257
312
  continue;
258
313
  }
314
+ if (hiddenStep(tool, names))
315
+ continue;
259
316
  const ended = p.state === 'output-available'
260
317
  ? { output: p.output }
261
318
  : p.state === 'output-error'
@@ -118,6 +118,7 @@ function openedSummary(t) {
118
118
  reads: t.reads.length > 0,
119
119
  batch: true,
120
120
  web: t.info.some((i) => i.group === 'web'),
121
+ lookup: t.info.some((i) => i.name === 'web__lookup'),
121
122
  };
122
123
  }
123
124
  export class Assistant {
@@ -827,36 +828,33 @@ export class Assistant {
827
828
  for (const source of Array.isArray(s) ? s : [s])
828
829
  sources.push({ group: g.id, access, source, ...(g.writes ? { writes: true } : {}) });
829
830
  }
830
- const lookup = this.o.web?.lookup;
831
- const blockHosts = this.o.web?.fetch ? this.o.web.fetch.blockHosts : undefined;
832
- if ((p.web ?? true) && lookup) {
831
+ if (p.web ?? true) {
833
832
  const sq = this.o.web?.scrapeql;
834
833
  const token = sq && !sq.fetch ? await this.state.scrapeql() : null;
835
- if (sq && (sq.fetch || token))
834
+ const scrapeql = sq && (sq.fetch || token) ? scrapeqlTools(sq.url, token, sq.fetch ?? this.o.fetch) : null;
835
+ const lookup = this.o.web?.lookup;
836
+ if (lookup && scrapeql) {
837
+ // The one lookup tool. Without ScrapeQL (no binding, no token yet) the web falls back to fetch.
838
+ const blockHosts = this.o.web?.fetch ? this.o.web.fetch.blockHosts : undefined;
836
839
  sources.push({
837
840
  group: 'web',
838
841
  access: 'read',
839
- source: webLookupTools(scrapeqlTools(sq.url, token, sq.fetch ?? this.o.fetch), {
842
+ source: webLookupTools(scrapeql, {
840
843
  ...(blockHosts ? { blockHosts } : {}),
841
844
  ...(typeof lookup === 'object' ? lookup : {}),
842
845
  }),
843
846
  });
844
- }
845
- else if (p.web ?? true) {
846
- if (this.o.web?.fetch !== false)
847
- sources.push({
848
- group: 'web',
849
- access: 'read',
850
- source: webFetchTools(this.o.web?.fetch || {}),
851
- });
852
- const sq = this.o.web?.scrapeql;
853
- const token = sq && !sq.fetch ? await this.state.scrapeql() : null;
854
- if (sq && (sq.fetch || token))
855
- sources.push({
856
- group: 'web',
857
- access: 'read',
858
- source: scrapeqlTools(sq.url, token, sq.fetch ?? this.o.fetch),
859
- });
847
+ }
848
+ else {
849
+ if (this.o.web?.fetch !== false)
850
+ sources.push({
851
+ group: 'web',
852
+ access: 'read',
853
+ source: webFetchTools(this.o.web?.fetch || {}),
854
+ });
855
+ if (scrapeql)
856
+ sources.push({ group: 'web', access: 'read', source: scrapeql });
857
+ }
860
858
  }
861
859
  return sources;
862
860
  }
@@ -887,7 +885,7 @@ export class Assistant {
887
885
  lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
888
886
  if (failed)
889
887
  lines.push(`${failed} tool source${failed === 1 ? '' : 's'} could not be reached this turn.`);
890
- if (fns.web && this.o.web?.lookup)
888
+ if (fns.lookup)
891
889
  lines.push("Web: web__lookup answers one question from the web: give the full question (and urls when you already know the pages); it returns a short answer from each page it read. Look up again with a sharper question rather than asking for whole pages. The person sees each page a lookup read numbered under your answer, in the order returned (the first lookup's sources first, a later lookup's after them): mark a fact with its number, like [1], and never cite a page that is not among the sources.");
892
890
  else if (fns.web)
893
891
  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.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.253",
3
+ "version": "0.1.255",
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.253",
60
- "@columenlabs/lumen-ui-types": "0.1.253",
59
+ "@columenlabs/lumen-ops": "0.1.255",
60
+ "@columenlabs/lumen-ui-types": "0.1.255",
61
61
  "agents": "0.32.0",
62
62
  "ai": "7.0.162",
63
63
  "just-bash": "3.6.0",