@columenlabs/lumen-ai 0.1.224 → 0.1.226

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
@@ -103,7 +103,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
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.
106
- - **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` and `usage` (tokens in, cache reads, out, per model). `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.
106
+ - **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` and `usage` (tokens in, cache reads, out, per model). `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).
107
107
  - **Usage history.** Each chat (`agentTurn` with `chat`) and each run of background work is one line of the person's month (`usage:YYYY-MM`, the newest 300): its title, first and last use, tokens, cost and its parts per tier and model. `GET /usage?month=YYYY-MM` returns it beside that month's spend and the months that have any; `LumenAssistant`'s footer shows this month's total and opens it (`UsageHistoryView`).
108
108
 
109
109
  ### Commands, `@` and `#`
@@ -12,7 +12,7 @@
12
12
  * An `Assistant` gives both: `assistant.agentTurn(caller, input)` returns a plan and its settle.
13
13
  */
14
14
  import { type ChatResponseResult, type PrepareStepContext, type Session, type StepConfig, type StepContext, Think, type ThinkModel, type TurnConfig, type TurnContext } from '@cloudflare/think';
15
- import { type AssistantChat, type AssistantEffort, type AssistantEvent, type AssistantImage, type AssistantRefNote, type AssistantScreenMap, type AssistantScreenOutcome, type ChatUsage, type UsagePart } from '@columenlabs/lumen-ui-types/assistant';
15
+ import { type AssistantChat, type AssistantEffort, type AssistantEvent, type AssistantImage, type AssistantQuote, type AssistantRefNote, type AssistantScreenMap, type AssistantScreenOutcome, type ChatUsage, type UsagePart } from '@columenlabs/lumen-ui-types/assistant';
16
16
  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';
@@ -134,6 +134,8 @@ export interface AskInput {
134
134
  note?: unknown;
135
135
  };
136
136
  refs?: AssistantRefNote[];
137
+ /** The piece of the chat this message replies to (Reply on selected text). */
138
+ quote?: AssistantQuote;
137
139
  tier?: string;
138
140
  time_zone?: string;
139
141
  /** What the browser can show (Screen on): the `screen` tool and its rules join the turn. */
@@ -141,6 +143,14 @@ export interface AskInput {
141
143
  /** How earlier screen calls ended in the browser. */
142
144
  outcomes?: AssistantScreenOutcome[];
143
145
  }
146
+ /** A quote as sent, checked: its text cut to `ASSISTANT_QUOTE_MAX`, and whose message it came from. */
147
+ export declare function quoteOf(q: unknown): AssistantQuote | undefined;
148
+ /**
149
+ * The words the model reads before a message that replies to a quote: which message it came from
150
+ * and the quote itself. It is the message's first text part; the panel shows the quote from the
151
+ * message's metadata instead, so `itemsOf` leaves this part out.
152
+ */
153
+ export declare function quoteLead(q: AssistantQuote): string;
144
154
  /**
145
155
  * The images a message may carry, as file parts: allowed types only, at most `ASSISTANT_IMAGES_MAX`,
146
156
  * and no more than `ASSISTANT_IMAGE_CHARS` of base64 together; the rest are left out.
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { anthropic } from '@ai-sdk/anthropic';
15
15
  import { Think, } from '@cloudflare/think';
16
- import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, ASSISTANT_IMAGE_TYPES, } from '@columenlabs/lumen-ui-types/assistant';
16
+ import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, ASSISTANT_IMAGE_TYPES, ASSISTANT_QUOTE_MAX, } from '@columenlabs/lumen-ui-types/assistant';
17
17
  import { createCompactFunction } from 'agents/sessions';
18
18
  import { generateText } from 'ai';
19
19
  import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connectorSource, } from './connectors.js';
@@ -73,6 +73,27 @@ export function withLead(messages, lead) {
73
73
  : [{ type: 'text', text: lead }, ...m.content];
74
74
  return [...messages.slice(0, i), { ...m, content }, ...messages.slice(i + 1)];
75
75
  }
76
+ /** A quote as sent, checked: its text cut to `ASSISTANT_QUOTE_MAX`, and whose message it came from. */
77
+ export function quoteOf(q) {
78
+ const o = q;
79
+ const text = typeof o?.text === 'string' ? o.text.trim().slice(0, ASSISTANT_QUOTE_MAX) : '';
80
+ if (!text)
81
+ return undefined;
82
+ return { text, from: o?.from === 'user' ? 'user' : 'assistant' };
83
+ }
84
+ /**
85
+ * The words the model reads before a message that replies to a quote: which message it came from
86
+ * and the quote itself. It is the message's first text part; the panel shows the quote from the
87
+ * message's metadata instead, so `itemsOf` leaves this part out.
88
+ */
89
+ export function quoteLead(q) {
90
+ const whose = q.from === 'user' ? 'their own earlier message' : 'your earlier answer';
91
+ const lines = q.text
92
+ .split('\n')
93
+ .map((l) => `> ${l}`)
94
+ .join('\n');
95
+ return `The person replies to this part of ${whose}:\n${lines}\n\n`;
96
+ }
76
97
  /**
77
98
  * The images a message may carry, as file parts: allowed types only, at most `ASSISTANT_IMAGES_MAX`,
78
99
  * and no more than `ASSISTANT_IMAGE_CHARS` of base64 together; the rest are left out.
@@ -209,10 +230,12 @@ export class LumenAgent extends Think {
209
230
  this.#put('at', at);
210
231
  await sink.event({ event: 'chat', data: { id: this.name, title } });
211
232
  this.#outcomes(screenOutcomesOf(input.outcomes));
233
+ const quote = quoteOf(input.quote);
212
234
  const meta = {
213
235
  at,
214
236
  ...(input.context ? { context: { label: input.context.label } } : {}),
215
237
  ...(input.refs?.length ? { refs: input.refs } : {}),
238
+ ...(quote ? { quote } : {}),
216
239
  ...(input.tier ? { tier: input.tier } : {}),
217
240
  };
218
241
  const toEvents = chunkEvents({
@@ -237,7 +260,11 @@ export class LumenAgent extends Think {
237
260
  await this.chat({
238
261
  id: userId,
239
262
  role: 'user',
240
- parts: [{ type: 'text', text: input.message }, ...imageParts(input.images)],
263
+ parts: [
264
+ ...(quote ? [{ type: 'text', text: quoteLead(quote) }] : []),
265
+ { type: 'text', text: input.message },
266
+ ...imageParts(input.images),
267
+ ],
241
268
  metadata: meta,
242
269
  }, {
243
270
  onStart: () => undefined,
@@ -3,7 +3,7 @@
3
3
  * panel's `AssistantEvent`s, and a stored chat's messages and cards as its `ChatItem`s. The panel
4
4
  * (`@columenlabs/lumen-ui`'s `LumenAssistant`) reads a Think chat the same way as before.
5
5
  */
6
- import type { AssistantEvent, AssistantRefNote, ChatItem } from '@columenlabs/lumen-ui-types/assistant';
6
+ import type { AssistantEvent, AssistantQuote, AssistantRefNote, ChatItem } from '@columenlabs/lumen-ui-types/assistant';
7
7
  import type { AgentCard } from './agent.js';
8
8
  import type { ScreenCall } from './screen.js';
9
9
  /** A tool's name as a line's title: `notes__find_notes` → `find notes`. */
@@ -40,6 +40,8 @@ export interface AskMeta {
40
40
  label: string;
41
41
  };
42
42
  refs?: AssistantRefNote[];
43
+ /** The piece of the chat the message replies to; its words to the model are the first text part. */
44
+ quote?: AssistantQuote;
43
45
  tier?: string;
44
46
  }
45
47
  /** A stored chat as the panel's items: the messages in order, each tool call as it ended. */
@@ -137,11 +137,12 @@ export function itemsOf(messages, cards, screens = new Map(), steers = {}) {
137
137
  for (const m of messages) {
138
138
  if (m.role === 'user') {
139
139
  asked = m.id;
140
+ const meta = (obj(m.metadata) ?? {});
140
141
  const text = m.parts
141
142
  .filter((p) => p.type === 'text')
143
+ .slice(meta.quote ? 1 : 0)
142
144
  .map((p) => String(p.text ?? ''))
143
145
  .join('');
144
- const meta = (obj(m.metadata) ?? {});
145
146
  const images = m.parts
146
147
  .filter((p) => p.type === 'file' && typeof p.url === 'string' && p.url.startsWith('data:image/'))
147
148
  .map((p) => p.url);
@@ -152,6 +153,7 @@ export function itemsOf(messages, cards, screens = new Map(), steers = {}) {
152
153
  ...(images.length ? { images } : {}),
153
154
  ...(meta.context?.label ? { context: meta.context.label } : {}),
154
155
  ...(meta.refs?.length ? { refs: meta.refs } : {}),
156
+ ...(meta.quote ? { quote: meta.quote } : {}),
155
157
  ...(meta.tier ? { tier: meta.tier } : {}),
156
158
  });
157
159
  continue;
@@ -322,6 +322,11 @@ export declare class Assistant {
322
322
  id: string;
323
323
  title: string;
324
324
  };
325
+ /**
326
+ * What this call counts as in the month's spend per task (`spend:YYYY-MM` `tasks`), when one
327
+ * task does more than one kind of work (a first try, a repair). Left out, it is the task.
328
+ */
329
+ tag?: string;
325
330
  }): Promise<{
326
331
  text: string;
327
332
  usd: number;
@@ -332,7 +332,9 @@ export class Assistant {
332
332
  : {},
333
333
  };
334
334
  const settle = async (end) => {
335
- await this.state.addSpend(caller.id, paying.payer, end.parts).catch(() => undefined);
335
+ await this.state
336
+ .addSpend(caller.id, paying.payer, end.parts, { task: 'chat', ms: end.ms })
337
+ .catch(() => undefined);
336
338
  if (input.chat)
337
339
  await this.state
338
340
  .addUsage(caller.id, { id: `chat:${input.chat}`, kind: 'chat', title: message.split('\n')[0] || 'Chat' }, end.parts)
@@ -909,6 +911,7 @@ export class Assistant {
909
911
  * `not_connected`, `app_cap` or `own_cap` when nobody can pay, or `upstream` when the model fails.
910
912
  */
911
913
  async complete(caller, o) {
914
+ const began = Date.now();
912
915
  const s = await this.session(caller, await this.mainTier(caller, o.task));
913
916
  const { paying, wait } = await this.state.paying(caller.id, s.model.provider);
914
917
  if (!paying) {
@@ -971,7 +974,10 @@ export class Assistant {
971
974
  }
972
975
  finally {
973
976
  const part = s.meter.part();
974
- await this.state.addSpend(caller.id, paying.payer, [part]);
977
+ await this.state.addSpend(caller.id, paying.payer, [part], {
978
+ task: o.tag ?? o.task,
979
+ ms: Date.now() - began,
980
+ });
975
981
  const task = this.tasks.find((t) => t.id === o.task);
976
982
  await this.state
977
983
  .addUsage(caller.id, {
@@ -19,7 +19,7 @@ export * from './find.js';
19
19
  export * from './meaning.js';
20
20
  export * from './models.js';
21
21
  export * from './routes.js';
22
- export { DEFAULT_CAP, type KeyDoc, keyDocName, type KeyTest, monthIn, type Paying, type PersonDoc, type SpendDoc, State, type TokenDoc, waitText, } from './state.js';
22
+ export { DEFAULT_CAP, type KeyDoc, keyDocName, type KeyTest, monthIn, type Paying, type PersonDoc, type SpendDoc, type TaskSpend, State, type TokenDoc, waitText, } from './state.js';
23
23
  export * from './store.js';
24
24
  export * from './tools.js';
25
25
  export * from './web.js';
@@ -97,8 +97,30 @@ export interface RequestsDoc {
97
97
  export type SpendRow = UsagePart & {
98
98
  payer: AssistantPayer;
99
99
  };
100
+ /**
101
+ * One task's use of one model this month: what a kind of work cost (a chat answer, a background
102
+ * task), beside `rows`, which stay per tier and payer.
103
+ */
104
+ export interface TaskSpend {
105
+ /** The task's id (`chat`, or a `complete` call's `tag` or `task`). */
106
+ task: string;
107
+ /** The model's id. */
108
+ model: string;
109
+ /** Answers: chat turns, or `complete` calls. */
110
+ runs: number;
111
+ /** Model calls. */
112
+ calls: number;
113
+ in: number;
114
+ read: number;
115
+ out: number;
116
+ usd: number;
117
+ /** Time the answers took in all, in ms (`ms / runs` is the average). */
118
+ ms: number;
119
+ }
100
120
  export interface SpendDoc {
101
121
  rows: SpendRow[];
122
+ /** Per task and model, counted since the kit began tagging (older spend is only in `rows`). */
123
+ tasks?: TaskSpend[];
102
124
  }
103
125
  /** Chats and runs kept in one month's usage history: the newest this many. */
104
126
  export declare const USAGE_KEPT = 300;
@@ -210,8 +232,14 @@ export declare class State {
210
232
  } | null>;
211
233
  appCap(): Promise<number>;
212
234
  spend(id: string, month?: string): Promise<SpendDoc>;
213
- /** Adds an answer's use to this month's spend, by label and payer. */
214
- addSpend(id: string, payer: AssistantPayer, parts: UsagePart[]): Promise<void>;
235
+ /**
236
+ * Adds an answer's use to this month's spend, by label and payer. `at` names the task the answer
237
+ * was for and how long it took, so the month also adds up per task and model (`tasks`).
238
+ */
239
+ addSpend(id: string, payer: AssistantPayer, parts: UsagePart[], at?: {
240
+ task: string;
241
+ ms?: number;
242
+ }): Promise<void>;
215
243
  /**
216
244
  * Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
217
245
  * chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only.
@@ -227,12 +227,43 @@ export class State {
227
227
  spend(id, month = this.month()) {
228
228
  return this.docs.read(id, `spend:${month}`).then((d) => d ?? { rows: [] });
229
229
  }
230
- /** Adds an answer's use to this month's spend, by label and payer. */
231
- async addSpend(id, payer, parts) {
230
+ /**
231
+ * Adds an answer's use to this month's spend, by label and payer. `at` names the task the answer
232
+ * was for and how long it took, so the month also adds up per task and model (`tasks`).
233
+ */
234
+ async addSpend(id, payer, parts, at) {
232
235
  if (!parts.some((p) => p.calls))
233
236
  return;
234
237
  await this.docs.update(id, `spend:${this.month()}`, (d) => {
235
238
  const rows = [...(d?.rows ?? [])];
239
+ const tasks = [...(d?.tasks ?? [])];
240
+ if (at) {
241
+ // The answer and its time count once, on its first model.
242
+ let first = true;
243
+ for (const p of parts) {
244
+ if (!p.calls)
245
+ continue;
246
+ const model = p.model ?? p.label;
247
+ const i = tasks.findIndex((t) => t.task === at.task && t.model === model);
248
+ const prev = i >= 0 ? tasks[i] : undefined;
249
+ const row = {
250
+ task: at.task,
251
+ model,
252
+ runs: (prev?.runs ?? 0) + (first ? 1 : 0),
253
+ calls: (prev?.calls ?? 0) + p.calls,
254
+ in: (prev?.in ?? 0) + p.in,
255
+ read: (prev?.read ?? 0) + p.read,
256
+ out: (prev?.out ?? 0) + p.out,
257
+ usd: (prev?.usd ?? 0) + p.usd,
258
+ ms: (prev?.ms ?? 0) + (first ? (at.ms ?? 0) : 0),
259
+ };
260
+ first = false;
261
+ if (i >= 0)
262
+ tasks[i] = row;
263
+ else
264
+ tasks.push(row);
265
+ }
266
+ }
236
267
  for (const p of parts) {
237
268
  if (!p.calls)
238
269
  continue;
@@ -253,7 +284,7 @@ export class State {
253
284
  else
254
285
  rows.push(row);
255
286
  }
256
- return { rows };
287
+ return tasks.length ? { rows, tasks } : { rows };
257
288
  });
258
289
  if (payer === 'app' && this.o.allowance?.period === 'once') {
259
290
  const usd = parts.reduce((a, p) => a + p.usd, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.224",
3
+ "version": "0.1.226",
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.224",
56
- "@columenlabs/lumen-ui-types": "0.1.224",
55
+ "@columenlabs/lumen-ops": "0.1.226",
56
+ "@columenlabs/lumen-ui-types": "0.1.226",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",