@columenlabs/lumen-ai 0.1.209 → 0.1.210

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,8 @@ 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.
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). `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.
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`).
107
108
 
108
109
  ### Commands, `@` and `#`
109
110
 
@@ -243,7 +244,8 @@ class Chat extends LumenAgent<Env> {
243
244
  #settle?: (end: AgentTurnEnd) => Promise<void>;
244
245
  async plan(turn: TurnContext) {
245
246
  // this.input: what the person sent (message, page, refs, tier, time zone).
246
- const { plan, settle } = await assistant.agentTurn(caller, this.input ?? { message: '' });
247
+ // chat: this chat's id, so its use is one line of the person's usage history.
248
+ const { plan, settle } = await assistant.agentTurn(caller, { ...(this.input ?? { message: '' }), chat: this.name });
247
249
  this.#settle = settle;
248
250
  return plan;
249
251
  }
@@ -1,4 +1,4 @@
1
- import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantStatus } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantStatus, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import type { AgentTurnEnd, AgentTurnPlan } from '../agent/agent.js';
3
3
  import { type BearingsExtra, type BearingsNow } from './bearings.js';
4
4
  import { type ModelDef, type TaskDef, type TierDef } from './models.js';
@@ -186,6 +186,11 @@ export interface AgentTurnInput {
186
186
  time_zone?: string;
187
187
  /** When the chat's last message was, for "since you last asked". */
188
188
  last_at?: number;
189
+ /**
190
+ * The chat this turn is in (the agent's name), so its use shows as one line of the person's
191
+ * usage history (`GET /usage`). Left out, the turn counts toward spend only.
192
+ */
193
+ chat?: string;
189
194
  }
190
195
  export declare class Assistant {
191
196
  private readonly o;
@@ -297,11 +302,21 @@ export declare class Assistant {
297
302
  /** The answer's token cap, thinking included (default 1,000, at most COMPLETE_MAX_TOKENS). */
298
303
  maxTokens?: number;
299
304
  signal?: AbortSignal;
305
+ /**
306
+ * The run this call belongs to, as one line of the person's usage history (`GET /usage`):
307
+ * calls with the same `id` add up (every round of one rebuild). Left out, the task's calls
308
+ * that day share one line named after the task.
309
+ */
310
+ run?: {
311
+ id: string;
312
+ title: string;
313
+ };
300
314
  }): Promise<{
301
315
  text: string;
302
316
  usd: number;
303
317
  model: string;
304
318
  cut: boolean;
319
+ usage: UsagePart;
305
320
  }>;
306
321
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
307
322
  startSignin(caller: Caller, provider: AssistantProvider): Promise<{
@@ -332,6 +332,10 @@ export class Assistant {
332
332
  };
333
333
  const settle = async (end) => {
334
334
  await this.state.addSpend(caller.id, paying.payer, end.parts).catch(() => undefined);
335
+ if (input.chat)
336
+ await this.state
337
+ .addUsage(caller.id, { id: `chat:${input.chat}`, kind: 'chat', title: message.split('\n')[0] || 'Chat' }, end.parts)
338
+ .catch(() => undefined);
335
339
  const total = end.parts.reduce((a, m) => ({
336
340
  calls: a.calls + m.calls,
337
341
  in: a.in + m.in,
@@ -963,9 +967,19 @@ export class Assistant {
963
967
  throw new AssistantError(`${s.model.label} did not answer: ${String(e?.message ?? e).slice(0, 200)}`, 'upstream', 502);
964
968
  }
965
969
  finally {
966
- await this.state.addSpend(caller.id, paying.payer, [s.meter.part()]);
970
+ const part = s.meter.part();
971
+ await this.state.addSpend(caller.id, paying.payer, [part]);
972
+ const task = this.tasks.find((t) => t.id === o.task);
973
+ await this.state
974
+ .addUsage(caller.id, {
975
+ id: `task:${o.task}:${o.run?.id ?? new Date().toISOString().slice(0, 10)}`,
976
+ kind: 'task',
977
+ task: o.task,
978
+ title: o.run?.title ?? task?.label ?? o.task,
979
+ }, [part])
980
+ .catch(() => undefined);
967
981
  }
968
- return { text, usd: s.meter.usd, model: s.model.id, cut };
982
+ return { text, usd: s.meter.usd, model: s.model.id, cut, usage: s.meter.part() };
969
983
  }
970
984
  // ---------------------------------------------------------------- keys
971
985
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
@@ -126,7 +126,7 @@ export class Meter {
126
126
  }
127
127
  part() {
128
128
  const { label, calls, read, out, usd } = this;
129
- return { label, calls, in: this.in, read, out, usd };
129
+ return { label, calls, in: this.in, read, out, usd, model: this.model.id };
130
130
  }
131
131
  }
132
132
  /** The request's effort setting: nothing at the model's own default. */
@@ -20,6 +20,7 @@
20
20
  * PUT /web/scrapeql {client_id, client_secret}, DELETE /web/scrapeql, POST /web/test admin
21
21
  * POST /chat one answer as server-sent events; closing the request stops it
22
22
  * GET /chats, GET|DELETE /chats/:id
23
+ * GET /usage?month=YYYY-MM the person's use that month: total, per tier and model, each chat and run
23
24
  * POST /chats/:id/drafts/:draft {action: save | dismiss}
24
25
  * POST /chats/:id/writes/:step/undo
25
26
  * POST /allowance/request {amount, why} asks for more allowance (one open at a time)
@@ -321,6 +322,13 @@ export function assistantRoutes(app, o) {
321
322
  return fail('Chats run on the agent now. Start a new chat.', 410, 'moved');
322
323
  case 'GET /chats':
323
324
  return json(await a.listChats(caller));
325
+ // The person's own use: one month's total, per tier and model, and each chat and run.
326
+ case 'GET /usage': {
327
+ const month = url.searchParams.get('month') ?? undefined;
328
+ if (month !== undefined && !/^\d{4}-\d{2}$/.test(month))
329
+ return fail('month is YYYY-MM.');
330
+ return json(await a.state.usage(caller.id, month));
331
+ }
324
332
  case 'POST /allowance/request': {
325
333
  const b = await body(req);
326
334
  const amount = typeof b.amount === 'number' && Number.isFinite(b.amount) ? b.amount : 0;
@@ -19,7 +19,7 @@
19
19
  * tier runs on and money left this month, else the other; with neither the assistant waits and says
20
20
  * why. A cap covers every provider of its payer.
21
21
  */
22
- import type { AllowanceRequest, AssistantAccess, AssistantEffort, AssistantOwnWay, AssistantPayer, AssistantProvider, AssistantScreenSetting, AssistantWait, HandOffPolicy, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
22
+ import type { AllowanceRequest, AssistantAccess, AssistantEffort, AssistantOwnWay, AssistantPayer, AssistantProvider, AssistantScreenSetting, AssistantWait, HandOffPolicy, UsageEntry, UsageHistory, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
23
23
  import { type ChatgptTokens } from './chatgpt.js';
24
24
  import { type Docs } from './store.js';
25
25
  export interface KeyTest {
@@ -98,6 +98,22 @@ export type SpendRow = UsagePart & {
98
98
  export interface SpendDoc {
99
99
  rows: SpendRow[];
100
100
  }
101
+ /** Chats and runs kept in one month's usage history: the newest this many. */
102
+ export declare const USAGE_KEPT = 300;
103
+ export interface UsageDoc {
104
+ entries: UsageEntry[];
105
+ }
106
+ /** Adds `more` to `parts`, merged by label (the model kept from the first). */
107
+ export declare function mergeParts(parts: UsagePart[], more: UsagePart[]): {
108
+ label: string;
109
+ calls: number;
110
+ in: number;
111
+ read: number;
112
+ out: number;
113
+ usd: number;
114
+ runs?: number;
115
+ model?: string;
116
+ }[];
101
117
  /** USD a month when nobody set a cap. */
102
118
  export declare const DEFAULT_CAP = 10;
103
119
  /** `2026-10` in the app's time zone. */
@@ -191,6 +207,18 @@ export declare class State {
191
207
  spend(id: string, month?: string): Promise<SpendDoc>;
192
208
  /** Adds an answer's use to this month's spend, by label and payer. */
193
209
  addSpend(id: string, payer: AssistantPayer, parts: UsagePart[]): Promise<void>;
210
+ /**
211
+ * Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
212
+ * chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only.
213
+ */
214
+ addUsage(id: string, e: {
215
+ id: string;
216
+ kind: UsageEntry['kind'];
217
+ task?: string;
218
+ title: string;
219
+ }, parts: UsagePart[]): Promise<void>;
220
+ /** One month's history (this month by default) beside its spend, and the months that have one. */
221
+ usage(id: string, month?: string): Promise<UsageHistory>;
194
222
  /** The payer's key of each provider, those set, and which are plan sign-ins. */
195
223
  keysOf(id: string, payer: AssistantPayer): Promise<{
196
224
  keys: Partial<Record<"anthropic" | "openai" | "xai", string>>;
@@ -7,6 +7,33 @@ export const keyDocName = (provider) => provider === 'anthropic' ? 'key' : `key:
7
7
  export const planDocName = (provider) => `plan:${provider}`;
8
8
  /** Requests kept for approvers: the newest this many. */
9
9
  const REQUESTS_KEPT = 300;
10
+ /** Chats and runs kept in one month's usage history: the newest this many. */
11
+ export const USAGE_KEPT = 300;
12
+ /** Months of usage history offered (`usage:YYYY-MM` documents are listed this far back). */
13
+ const USAGE_MONTHS = 24;
14
+ /** Adds `more` to `parts`, merged by label (the model kept from the first). */
15
+ export function mergeParts(parts, more) {
16
+ const out = parts.map((p) => ({ ...p }));
17
+ for (const p of more) {
18
+ if (!p.calls)
19
+ continue;
20
+ const prev = out.find((r) => r.label === p.label);
21
+ if (!prev)
22
+ out.push({ ...p });
23
+ else {
24
+ prev.calls += p.calls;
25
+ prev.in += p.in;
26
+ prev.read += p.read;
27
+ prev.out += p.out;
28
+ prev.usd += p.usd;
29
+ if (p.runs)
30
+ prev.runs = (prev.runs ?? 0) + p.runs;
31
+ if (!prev.model && p.model)
32
+ prev.model = p.model;
33
+ }
34
+ }
35
+ return out;
36
+ }
10
37
  /** USD a month when nobody set a cap. */
11
38
  export const DEFAULT_CAP = 10;
12
39
  /** `2026-10` in the app's time zone. */
@@ -227,6 +254,53 @@ export class State {
227
254
  await this.setAllowance(id, (d) => ({ ...d, spent_usd: (d.spent_usd ?? 0) + usd }));
228
255
  }
229
256
  }
257
+ /**
258
+ * Adds one answer's use to a chat or run in this month's history (`usage:YYYY-MM`): one entry a
259
+ * chat or run, its parts merged, the newest `USAGE_KEPT` kept. `title` names a new entry only.
260
+ */
261
+ async addUsage(id, e, parts) {
262
+ if (!parts.some((p) => p.calls))
263
+ return;
264
+ const now = Date.now();
265
+ await this.docs.update(id, `usage:${this.month()}`, (d) => {
266
+ const entries = [...(d?.entries ?? [])];
267
+ const i = entries.findIndex((x) => x.id === e.id);
268
+ const prev = i >= 0 ? entries.splice(i, 1)[0] : undefined;
269
+ const merged = mergeParts(prev?.parts ?? [], parts);
270
+ const sum = (k) => merged.reduce((a, p) => a + p[k], 0);
271
+ entries.unshift({
272
+ id: e.id,
273
+ kind: e.kind,
274
+ ...(e.task ? { task: e.task } : {}),
275
+ title: prev?.title || e.title.slice(0, 120),
276
+ first: prev?.first ?? now,
277
+ at: now,
278
+ calls: sum('calls'),
279
+ in: sum('in'),
280
+ read: sum('read'),
281
+ out: sum('out'),
282
+ usd: sum('usd'),
283
+ parts: merged,
284
+ });
285
+ return { entries: entries.slice(0, USAGE_KEPT) };
286
+ });
287
+ }
288
+ /** One month's history (this month by default) beside its spend, and the months that have one. */
289
+ async usage(id, month = this.month()) {
290
+ const [doc, spend, listed] = await Promise.all([
291
+ this.docs.read(id, `usage:${month}`),
292
+ this.spend(id, month),
293
+ this.docs.list(id, 'usage:', USAGE_MONTHS),
294
+ ]);
295
+ const months = [...new Set([this.month(), ...listed.map((r) => r.key.slice(6))])].sort((a, b) => b.localeCompare(a));
296
+ return {
297
+ month,
298
+ months,
299
+ usd: spend.rows.reduce((a, r) => a + r.usd, 0),
300
+ rows: spend.rows,
301
+ entries: doc?.entries ?? [],
302
+ };
303
+ }
230
304
  /** The payer's key of each provider, those set, and which are plan sign-ins. */
231
305
  async keysOf(id, payer) {
232
306
  const ids = Object.keys(PROVIDERS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.209",
3
+ "version": "0.1.210",
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.209",
56
- "@columenlabs/lumen-ui-types": "0.1.209",
55
+ "@columenlabs/lumen-ops": "0.1.210",
56
+ "@columenlabs/lumen-ui-types": "0.1.210",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",