@columenlabs/lumen-ai 0.1.249 → 0.1.251

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
- - **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.
114
+ - **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.
115
115
  - **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.
116
116
  - **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.
117
117
  - **Background work** an app runs for a person (a score, a summary) is `assistant.complete(caller, { task, system, prompt, images?, maxTokens })`: one answer on the task's tier (`tasks`, so the person picks its model in Settings ▸ Models), no chat and no tools (`images`, base64 with their media type, are read with the prompt: a scanned page, a screenshot), billed to their payer and counted in their spend and allowance like a chat turn. `maxTokens` (default 1,000) goes up to `COMPLETE_MAX_TOKENS` (32,000, thinking included); the answer is streamed, so a long one keeps its connection, and `cut: true` says it stopped at the cap before it ended (the app can ask again, shorter, or continue). It throws `AssistantError` `not_connected`, `app_cap` or `own_cap` when nobody can pay, so the caller waits and tries later. It returns the answer's `usd`, `model`, `usage` (tokens in, cache reads, out, per model) and `payer` (`app` or `own`: who paid, so the app can show a cost on the person's own key as money and the rest as credits). `effort` sets how hard the model thinks on that one call (`low` to `max`; on an OpenAI or xAI model it is the request's reasoning effort), over what the person saved for the tier. `run: { id, title }` makes the calls that share an `id` one line of the person's usage history (every round of one rebuild); without it a task's calls that day share a line. The month's spend also adds up per task and model (`spend:YYYY-MM` `tasks`: answers, calls, tokens, cost and time, a chat answer as `chat`), so an app's admin page can show what each kind of work costs; `tag` counts one call under another name when a task does more than one kind of work (a first try, a repair).
@@ -233,7 +233,7 @@ ctx.waitUntil(meaningStep({ def: notesFind, sql: d1Sql(env.DB), embed, index: en
233
233
  |---|---|
234
234
  | `LumenAgent` | An abstract Think agent. The app answers `plan(turn)` (model, key, effort, instructions, tool sources, money left) and hears `settle(end)` (outcome, usage parts, context size, time) when the turn ends |
235
235
  | `aiModel(model, key, opts?)` | The AI SDK model for a tier on an API key, or on a ChatGPT plan sign-in (`key.plan`, through `planFetch`) |
236
- | `planFetch(fetch?, name?)` | Rewrites Responses calls to a plan sign-in's rules: tools in one namespace, no output cap, a forced call as the only tool |
236
+ | `planFetch(fetch?, name?)` | Rewrites Responses calls to a plan sign-in's rules: tools in one namespace, no output cap, a forced call as the only tool, every call streamed (a call that is not streamed, such as a hand-off or a compaction, gets back the one response it asked for) |
237
237
  | `planFallback(primary, fallback, onSwitch)` | A plan's model that hands the call, and the rest of the turn, to `fallback` when the plan reaches its usage limit (a 429 or a usage code, before any output) |
238
238
  | `turnSettings(model, opts)` | Output cap and effort as the provider takes them |
239
239
  | `openAiTools(sources, opts)` | The app's tool groups as AI SDK tools: reads always, drafts and writes by access, results cut at 16k characters |
@@ -17,9 +17,11 @@ export interface AiModelOptions {
17
17
  * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
18
18
  * limitations). It also keeps nothing (`store: false`, so earlier reasoning rides along encrypted)
19
19
  * and takes the system prompt as `instructions`, as the assistant's own OpenAI loop sends it (the
20
- * AI SDK's request without these came back 400 Bad Request on a plan, 2026-11-04). This fetch
21
- * rewrites each Responses request to those rules, so the AI SDK's OpenAI provider runs on a plan
22
- * unchanged.
20
+ * AI SDK's request without these came back 400 Bad Request on a plan, 2026-11-04). A plan also
21
+ * answers only streamed requests ("Stream must be set to true", 400): a call that is not streamed
22
+ * (`generateText`: a hand-off, a compaction) is sent streamed and its events are put back together
23
+ * into the one response it asked for. This fetch rewrites each Responses request to those rules, so
24
+ * the AI SDK's OpenAI provider runs on a plan unchanged.
23
25
  */
24
26
  export declare function planFetch(base?: typeof fetch, name?: string): typeof fetch;
25
27
  /** The AI SDK model a tier runs: its provider's client on the payer's key. */
@@ -5,15 +5,18 @@
5
5
  */
6
6
  import { createAnthropic } from '@ai-sdk/anthropic';
7
7
  import { createOpenAI } from '@ai-sdk/openai';
8
+ import { Stream } from 'openai/core/streaming';
8
9
  import { XAI_BASE_URL } from '../assistant/models.js';
9
10
  /**
10
11
  * A ChatGPT plan sign-in takes function tools only inside one namespace and no output cap, and a
11
12
  * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
12
13
  * limitations). It also keeps nothing (`store: false`, so earlier reasoning rides along encrypted)
13
14
  * and takes the system prompt as `instructions`, as the assistant's own OpenAI loop sends it (the
14
- * AI SDK's request without these came back 400 Bad Request on a plan, 2026-11-04). This fetch
15
- * rewrites each Responses request to those rules, so the AI SDK's OpenAI provider runs on a plan
16
- * unchanged.
15
+ * AI SDK's request without these came back 400 Bad Request on a plan, 2026-11-04). A plan also
16
+ * answers only streamed requests ("Stream must be set to true", 400): a call that is not streamed
17
+ * (`generateText`: a hand-off, a compaction) is sent streamed and its events are put back together
18
+ * into the one response it asked for. This fetch rewrites each Responses request to those rules, so
19
+ * the AI SDK's OpenAI provider runs on a plan unchanged.
17
20
  */
18
21
  export function planFetch(base = fetch, name = 'Assistant') {
19
22
  return async (input, init) => {
@@ -57,6 +60,8 @@ export function planFetch(base = fetch, name = 'Assistant') {
57
60
  ...rest,
58
61
  { type: 'namespace', name: 'app', description: `${name}'s tools`, tools: fns },
59
62
  ];
63
+ const streamed = body.stream === true;
64
+ body.stream = true;
60
65
  const res = await base(input, { ...init, body: JSON.stringify(body) });
61
66
  // A refusal is logged with OpenAI's own reason; the person sees a plain sentence.
62
67
  if (!res.ok)
@@ -65,9 +70,54 @@ export function planFetch(base = fetch, name = 'Assistant') {
65
70
  status: res.status,
66
71
  body: (await res.clone().text()).slice(0, 500),
67
72
  }));
68
- return res;
73
+ return res.ok && !streamed ? whole(res, init?.signal) : res;
69
74
  };
70
75
  }
76
+ /**
77
+ * A streamed Responses answer as the one JSON response a request that was not streamed expects:
78
+ * the final `response.completed` (or `response.incomplete`), with the items from each
79
+ * `response.output_item.done` when the final event leaves its output empty. A failed answer comes
80
+ * back as an error response with OpenAI's own error, so the caller fails as it would have.
81
+ */
82
+ async function whole(res, signal) {
83
+ const controller = new AbortController();
84
+ if (signal) {
85
+ if (signal.aborted)
86
+ controller.abort(signal.reason);
87
+ else
88
+ signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
89
+ }
90
+ const failed = (error) => new Response(JSON.stringify({ error: error ?? { message: 'The answer failed.' } }), {
91
+ status: 500,
92
+ headers: { 'content-type': 'application/json' },
93
+ });
94
+ const items = [];
95
+ let done;
96
+ try {
97
+ const events = Stream.fromSSEResponse(res, controller);
98
+ for await (const e of events) {
99
+ if (e.type === 'response.output_item.done' && e.item)
100
+ items.push(e.item);
101
+ else if (e.type === 'response.completed' || e.type === 'response.incomplete')
102
+ done = e.response;
103
+ else if (e.type === 'response.failed')
104
+ return failed(e.response?.error);
105
+ }
106
+ }
107
+ catch (err) {
108
+ if (signal?.aborted)
109
+ throw err;
110
+ const e = err;
111
+ return failed(e.error ?? { message: e.message ?? String(err) });
112
+ }
113
+ if (!done)
114
+ return failed({ message: 'The answer ended before it was complete.' });
115
+ const output = Array.isArray(done.output) && done.output.length ? done.output : items;
116
+ return new Response(JSON.stringify({ ...done, output }), {
117
+ status: 200,
118
+ headers: { 'content-type': 'application/json' },
119
+ });
120
+ }
71
121
  /** A message's text, whether a string or a list of text parts. */
72
122
  function textOf(content) {
73
123
  if (typeof content === 'string')
@@ -227,6 +227,11 @@ export declare class AssistantError extends Error {
227
227
  readonly status: number;
228
228
  constructor(message: string, code: string, status?: number);
229
229
  }
230
+ /**
231
+ * The most a hand-off may take. A chat turn waits for it with no stream of its own, so an app that
232
+ * times out a silent turn (Think's `chatStreamStallTimeoutMs`) sets that above this.
233
+ */
234
+ export declare const HAND_OFF_MS = 240000;
230
235
  /** What a Think turn calls this turn: what one turn asks besides the message itself. */
231
236
  export interface AgentTurnInput {
232
237
  message: string;
@@ -15,7 +15,7 @@ import { aiModel, turnSettings } from '../agent/models.js';
15
15
  import { bearings } from './bearings.js';
16
16
  import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
17
17
  import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, XAI_BASE_URL, effortParams, } from './models.js';
18
- import { DEFAULT_CAP, State, keyDocName, keyState, ownKeyUsd, planDocName, waitText, withProvider, } from './state.js';
18
+ import { DEFAULT_CAP, State, keyDocName, keyState, ownKeyUsd, ownPlanCalls, planDocName, waitText, withProvider, } from './state.js';
19
19
  import { Docs } from './store.js';
20
20
  import { toolName, withDeadline } from './tools.js';
21
21
  import { scrapeqlTools, webFetchTools } from './web.js';
@@ -44,6 +44,11 @@ export class AssistantError extends Error {
44
44
  }
45
45
  const MAX_ROUNDS = 8;
46
46
  const MEMO_CHARS = 8000;
47
+ /**
48
+ * The most a hand-off may take. A chat turn waits for it with no stream of its own, so an app that
49
+ * times out a silent turn (Think's `chatStreamStallTimeoutMs`) sets that above this.
50
+ */
51
+ export const HAND_OFF_MS = 240_000;
47
52
  /** The built-in tool that runs several reads at once, as one step. */
48
53
  const BATCH = 'batch';
49
54
  const ADVICE_MAX = 2;
@@ -403,6 +408,7 @@ export class Assistant {
403
408
  kind: 'chat',
404
409
  title: message.split('\n')[0] || 'Chat',
405
410
  own_usd: ownKeyUsd(paying, onPayer),
411
+ own_plan_calls: ownPlanCalls(paying, onPayer),
406
412
  }, end.parts)
407
413
  .catch(() => undefined);
408
414
  const total = end.parts.reduce((a, m) => ({
@@ -526,7 +532,9 @@ export class Assistant {
526
532
  onStepFinish: (s) => {
527
533
  meter.add(s.usage);
528
534
  },
529
- ...(abortSignal ? { abortSignal } : {}),
535
+ abortSignal: abortSignal
536
+ ? AbortSignal.any([abortSignal, AbortSignal.timeout(HAND_OFF_MS)])
537
+ : AbortSignal.timeout(HAND_OFF_MS),
530
538
  ...turnSettings(sub.model, {
531
539
  effort: sub.effort,
532
540
  out: tier.out ?? DEFAULT_OUT,
@@ -1069,6 +1077,7 @@ export class Assistant {
1069
1077
  task: o.task,
1070
1078
  title: o.run?.title ?? task?.label ?? o.task,
1071
1079
  own_usd: ownKeyUsd(paying, [part]),
1080
+ own_plan_calls: ownPlanCalls(paying, [part]),
1072
1081
  }, [part])
1073
1082
  .catch(() => undefined);
1074
1083
  }
@@ -1157,6 +1166,7 @@ export class Assistant {
1157
1166
  task: o.task,
1158
1167
  title: o.run?.title ?? task?.label ?? o.task,
1159
1168
  own_usd: ownKeyUsd(paying, [part]),
1169
+ own_plan_calls: ownPlanCalls(paying, [part]),
1160
1170
  }, [part])
1161
1171
  .catch(() => undefined);
1162
1172
  }
@@ -111,6 +111,11 @@ export declare function ownKeyUsd(paying: {
111
111
  payer: AssistantPayer;
112
112
  plans: Paying['plans'];
113
113
  }, parts: UsagePart[]): number;
114
+ /** Calls in `parts` a plan sign-in of the person's answered (their own payer, a plan provider). */
115
+ export declare function ownPlanCalls(paying: {
116
+ payer: AssistantPayer;
117
+ plans: Paying['plans'];
118
+ }, parts: UsagePart[]): number;
114
119
  /**
115
120
  * What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
116
121
  * nothing per call), on a provider they can still pay with. A row from before ways were recorded
@@ -276,7 +281,8 @@ export declare class State {
276
281
  /**
277
282
  * Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
278
283
  * chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only;
279
- * `own_usd` is what of this answer the person's own key or plan paid, added to the entry's.
284
+ * `own_usd` is what of this answer the person's own key or plan paid, added to the entry's;
285
+ * `own_plan_calls` the calls their plan sign-in answered (free to them, so counted, not priced).
280
286
  */
281
287
  addUsage(id: string, e: {
282
288
  id: string;
@@ -284,6 +290,7 @@ export declare class State {
284
290
  task?: string;
285
291
  title: string;
286
292
  own_usd?: number;
293
+ own_plan_calls?: number;
287
294
  }, parts: UsagePart[]): Promise<void>;
288
295
  /** One month's history (this month by default) beside its spend, and the months that have one. */
289
296
  usage(id: string, month?: string): Promise<UsageHistory>;
@@ -32,6 +32,17 @@ export function ownKeyUsd(paying, parts) {
32
32
  })
33
33
  .reduce((a, p) => a + p.usd, 0);
34
34
  }
35
+ /** Calls in `parts` a plan sign-in of the person's answered (their own payer, a plan provider). */
36
+ export function ownPlanCalls(paying, parts) {
37
+ if (paying.payer !== 'own')
38
+ return 0;
39
+ return parts
40
+ .filter((p) => {
41
+ const prov = providerOfPart(p);
42
+ return !!(prov && paying.plans[prov]);
43
+ })
44
+ .reduce((a, p) => a + p.calls, 0);
45
+ }
35
46
  /**
36
47
  * What counts toward the person's own cap: own rows paid by an API key (a plan sign-in costs
37
48
  * nothing per call), on a provider they can still pay with. A row from before ways were recorded
@@ -352,7 +363,8 @@ export class State {
352
363
  /**
353
364
  * Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
354
365
  * chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only;
355
- * `own_usd` is what of this answer the person's own key or plan paid, added to the entry's.
366
+ * `own_usd` is what of this answer the person's own key or plan paid, added to the entry's;
367
+ * `own_plan_calls` the calls their plan sign-in answered (free to them, so counted, not priced).
356
368
  */
357
369
  async addUsage(id, e, parts) {
358
370
  if (!parts.some((p) => p.calls))
@@ -365,6 +377,7 @@ export class State {
365
377
  const merged = mergeParts(prev?.parts ?? [], parts);
366
378
  const sum = (k) => merged.reduce((a, p) => a + p[k], 0);
367
379
  const own = (prev?.own_usd ?? 0) + (e.own_usd ?? 0);
380
+ const plan = (prev?.own_plan_calls ?? 0) + (e.own_plan_calls ?? 0);
368
381
  entries.unshift({
369
382
  id: e.id,
370
383
  kind: e.kind,
@@ -378,6 +391,7 @@ export class State {
378
391
  out: sum('out'),
379
392
  usd: sum('usd'),
380
393
  ...(own > 0 ? { own_usd: own } : {}),
394
+ ...(plan > 0 ? { own_plan_calls: plan } : {}),
381
395
  parts: merged,
382
396
  });
383
397
  return { entries: entries.slice(0, USAGE_KEPT) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.249",
3
+ "version": "0.1.251",
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.249",
56
- "@columenlabs/lumen-ui-types": "0.1.249",
55
+ "@columenlabs/lumen-ops": "0.1.251",
56
+ "@columenlabs/lumen-ui-types": "0.1.251",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",