@columenlabs/lumen-ai 0.1.208 → 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. 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';
@@ -56,6 +56,8 @@ export interface AllowanceNotice {
56
56
  request: AllowanceRequest;
57
57
  unit: AllowanceUnit;
58
58
  }
59
+ /** The largest answer `complete` asks for, tokens (thinking included). Streamed, so minutes are fine. */
60
+ export declare const COMPLETE_MAX_TOKENS = 32000;
59
61
  /** A change a write tool made at once, as `undo` gets it. */
60
62
  export interface AssistantWrite {
61
63
  /** The tool's own name (not the `group__tool` the model calls). */
@@ -184,6 +186,11 @@ export interface AgentTurnInput {
184
186
  time_zone?: string;
185
187
  /** When the chat's last message was, for "since you last asked". */
186
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;
187
194
  }
188
195
  export declare class Assistant {
189
196
  private readonly o;
@@ -292,12 +299,24 @@ export declare class Assistant {
292
299
  prompt: string;
293
300
  /** Photos read with the prompt (a scanned page, a screenshot). */
294
301
  images?: CompleteImage[];
302
+ /** The answer's token cap, thinking included (default 1,000, at most COMPLETE_MAX_TOKENS). */
295
303
  maxTokens?: number;
296
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
+ };
297
314
  }): Promise<{
298
315
  text: string;
299
316
  usd: number;
300
317
  model: string;
318
+ cut: boolean;
319
+ usage: UsagePart;
301
320
  }>;
302
321
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
303
322
  startSignin(caller: Caller, provider: AssistantProvider): Promise<{
@@ -23,6 +23,8 @@ const DOLLARS = { one: 'dollar', many: 'dollars', usd: 1 };
23
23
  const round2 = (n) => Math.round(n * 100) / 100;
24
24
  /** A request may ask for at most this many times the starter or monthly cap. */
25
25
  const REQUEST_MAX_TIMES = 100;
26
+ /** The largest answer `complete` asks for, tokens (thinking included). Streamed, so minutes are fine. */
27
+ export const COMPLETE_MAX_TOKENS = 32_000;
26
28
  /** A turn's failure the person can act on. */
27
29
  export class AssistantError extends Error {
28
30
  code;
@@ -330,6 +332,10 @@ export class Assistant {
330
332
  };
331
333
  const settle = async (end) => {
332
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);
333
339
  const total = end.parts.reduce((a, m) => ({
334
340
  calls: a.calls + m.calls,
335
341
  in: a.in + m.in,
@@ -887,7 +893,10 @@ export class Assistant {
887
893
  cache_read_input_tokens: read,
888
894
  }, plan);
889
895
  }
890
- return text || done?.output_text || '';
896
+ return {
897
+ text: text || done?.output_text || '',
898
+ cut: done?.status === 'incomplete' && done.incomplete_details?.reason === 'max_output_tokens',
899
+ };
891
900
  }
892
901
  // ---------------------------------------------------------------- one-shot work
893
902
  /**
@@ -904,15 +913,20 @@ export class Assistant {
904
913
  throw new AssistantError(waitText(code, s.model.provider, !!this.o.allowance), code, 402);
905
914
  }
906
915
  const clients = this.clients(paying);
907
- const max = Math.min(o.maxTokens ?? 1000, 8000);
916
+ const max = Math.min(o.maxTokens ?? 1000, COMPLETE_MAX_TOKENS);
908
917
  let text = '';
918
+ // The answer stopped at `max` before it ended (its thinking counts toward the cap too).
919
+ let cut = false;
909
920
  try {
910
921
  if ((s.model.provider === 'openai' && clients.openai) ||
911
922
  (s.model.provider === 'xai' && clients.xai)) {
912
- text = await this.openaiOnce(clients, s, o.system, o.prompt, max, o.signal, o.images);
923
+ ({ text, cut } = await this.openaiOnce(clients, s, o.system, o.prompt, max, o.signal, o.images));
913
924
  }
914
925
  else if (clients.anthropic) {
915
- const msg = await clients.anthropic.messages.create({
926
+ // Streamed and read whole: a long answer (minutes at a high cap) keeps its connection alive,
927
+ // and the SDK refuses a long answer as one unstreamed request.
928
+ const msg = await clients.anthropic.messages
929
+ .stream({
916
930
  model: s.model.id,
917
931
  max_tokens: max,
918
932
  system: o.system,
@@ -935,8 +949,10 @@ export class Assistant {
935
949
  },
936
950
  ],
937
951
  ...effortParams(s.effort),
938
- }, o.signal ? { signal: o.signal } : undefined);
952
+ }, o.signal ? { signal: o.signal } : undefined)
953
+ .finalMessage();
939
954
  s.meter.add(msg.usage);
955
+ cut = msg.stop_reason === 'max_tokens';
940
956
  text = msg.content
941
957
  .filter((b) => b.type === 'text')
942
958
  .map((b) => b.text)
@@ -951,9 +967,19 @@ export class Assistant {
951
967
  throw new AssistantError(`${s.model.label} did not answer: ${String(e?.message ?? e).slice(0, 200)}`, 'upstream', 502);
952
968
  }
953
969
  finally {
954
- 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);
955
981
  }
956
- return { text, usd: s.meter.usd, model: s.model.id };
982
+ return { text, usd: s.meter.usd, model: s.model.id, cut, usage: s.meter.part() };
957
983
  }
958
984
  // ---------------------------------------------------------------- keys
959
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.208",
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.208",
56
- "@columenlabs/lumen-ui-types": "0.1.208",
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",