@columenlabs/lumen-ai 0.1.226 → 0.1.228

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
@@ -71,6 +71,7 @@ import {
71
71
  const assistant = createAssistant({
72
72
  name: 'Helper',
73
73
  system: 'You help people with their notes. Use the notes tools for facts.',
74
+ hideModel: true, // never names its model, provider or tier; asked, it is just "Helper"
74
75
  // Smallest first. The app names them; a person picks each tier's model and effort.
75
76
  tiers: [
76
77
  { id: 'fast', label: 'Fast', about: 'Quick lookups', model: 'claude-haiku-5-5' },
@@ -104,6 +105,7 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
104
105
  - **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
106
  - **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
107
  - **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).
108
+ - **Images** an app makes for a person are `assistant.image(caller, { task, prompt, images?, size?, quality?, run? })`: one image on the payer's OpenAI key (`IMAGE_MODEL`, gpt-image-2; a ChatGPT plan sign-in cannot make one), from `images` when given (a reference, a sketch), billed and capped like `complete` at the model's list price per token. It returns the PNG as base64 with its `usd`. A chat tool that returns `image: { url, alt?, cost? }` shows as an image card in the panel (`StepItem.image`); `LumenAssistant` `imageTool` names that tool (its "Making the image" box while it runs, Make another) and `modes` adds composer toggles (Make an image) whose id goes with the message as `use`.
107
109
  - **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
110
 
109
111
  ### Commands, `@` and `#`
@@ -136,6 +136,8 @@ export interface AskInput {
136
136
  refs?: AssistantRefNote[];
137
137
  /** The piece of the chat this message replies to (Reply on selected text). */
138
138
  quote?: AssistantQuote;
139
+ /** A composer mode the person turned on for this message, by id; the app's plan reads it. */
140
+ use?: string;
139
141
  tier?: string;
140
142
  time_zone?: string;
141
143
  /** What the browser can show (Screen on): the `screen` tool and its rules join the turn. */
@@ -6,6 +6,18 @@ export function stepTitle(name) {
6
6
  return own.replace(/_/g, ' ');
7
7
  }
8
8
  const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
9
+ /** A tool's `image` output as the panel's card: a same-site path or an https address only. */
10
+ function stepImage(v) {
11
+ const i = obj(v);
12
+ const url = typeof i?.url === 'string' ? i.url : '';
13
+ if (!/^(\/[^/\\]|https:\/\/)/.test(url))
14
+ return undefined;
15
+ return {
16
+ url,
17
+ ...(typeof i?.alt === 'string' ? { alt: i.alt.slice(0, 300) } : {}),
18
+ ...(typeof i?.cost === 'string' ? { cost: i.cost.slice(0, 40) } : {}),
19
+ };
20
+ }
9
21
  /** What one finished tool call shows as: a line, a card, a hand-off section or nothing. */
10
22
  function settledCall(id, c, out, ms, cards, screens = new Map()) {
11
23
  const o = obj(out.output);
@@ -52,6 +64,7 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
52
64
  }
53
65
  const error = out.error ?? (o && typeof o.error === 'string' ? o.error : undefined);
54
66
  const rows = rowCount(o && 'result' in o ? o.result : out.output);
67
+ const image = stepImage(o?.image);
55
68
  const web = webStep(c.name, c.input, out.output, !!error || obj(out.output)?.ok === false);
56
69
  const step = {
57
70
  kind: 'step',
@@ -66,6 +79,7 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
66
79
  ? { write: { undo: true, status: card?.status === 'undone' ? 'undone' : 'done' } }
67
80
  : {}),
68
81
  ...(web ? { web } : {}),
82
+ ...(image && !error ? { image } : {}),
69
83
  };
70
84
  return [{ event: 'step', data: step }];
71
85
  }
@@ -19,6 +19,17 @@ export interface AssistantCommandDef {
19
19
  }
20
20
  /** Who is asking: their id (the store's scope for their things) and whether they are an admin. */
21
21
  /** A photo `complete` reads with its prompt: base64 bytes and their media type (`image/jpeg`). */
22
+ /**
23
+ * The model `image` runs and its list price per million tokens (OpenAI, gpt-image-2: text in $5,
24
+ * image in $8, image out $30). `fallbackUsd` is a medium square image, for an answer with no usage.
25
+ */
26
+ export declare const IMAGE_MODEL: {
27
+ id: string;
28
+ text: number;
29
+ image: number;
30
+ out: number;
31
+ fallbackUsd: number;
32
+ };
22
33
  export interface CompleteImage {
23
34
  mediaType: string;
24
35
  data: string;
@@ -126,6 +137,11 @@ export interface AssistantOptions {
126
137
  name: string;
127
138
  /** The app's instructions: what it is, what its tools are for. */
128
139
  system: string | ((caller: Caller) => string | Promise<string>);
140
+ /**
141
+ * Keep the model behind the name: the assistant never says which model, provider or tier runs
142
+ * it, and answers "which model are you?" with its own `name`. Off by default (it names them).
143
+ */
144
+ hideModel?: boolean;
129
145
  /**
130
146
  * The app's slash commands, listed in the composer when the person types `/`. A message
131
147
  * `/name words` is sent to the model as `prompt` with `{args}` replaced by the words (or what the
@@ -334,6 +350,31 @@ export declare class Assistant {
334
350
  cut: boolean;
335
351
  usage: UsagePart;
336
352
  }>;
353
+ /**
354
+ * One image made as the person, on the payer's OpenAI key (a plan sign-in cannot make one),
355
+ * billed and capped like `complete`: the price is the model's list price per token, read from the
356
+ * answer's usage. With `images` the model works from them (a reference, a sketch). Throws
357
+ * `AssistantError` with `not_connected`, `app_cap` or `own_cap` when nobody can pay, or `upstream`
358
+ * when the model fails. Returns the PNG as base64.
359
+ */
360
+ image(caller: Caller, o: {
361
+ task: string;
362
+ prompt: string;
363
+ images?: CompleteImage[];
364
+ /** Wide, tall or square (default). */
365
+ size?: '1024x1024' | '1536x1024' | '1024x1536';
366
+ quality?: 'low' | 'medium' | 'high';
367
+ signal?: AbortSignal;
368
+ run?: {
369
+ id: string;
370
+ title: string;
371
+ };
372
+ }): Promise<{
373
+ data: string;
374
+ mediaType: 'image/png';
375
+ usd: number;
376
+ model: string;
377
+ }>;
337
378
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
338
379
  startSignin(caller: Caller, provider: AssistantProvider): Promise<{
339
380
  url: string;
@@ -10,7 +10,7 @@
10
10
  import Anthropic from '@anthropic-ai/sdk';
11
11
  import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
12
12
  import { generateText, jsonSchema, stepCountIs, tool } from 'ai';
13
- import OpenAI from 'openai';
13
+ import OpenAI, { toFile } from 'openai';
14
14
  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';
@@ -19,6 +19,13 @@ import { DEFAULT_CAP, State, keyDocName, keyState, planDocName, waitText, } from
19
19
  import { Docs } from './store.js';
20
20
  import { toolName, withDeadline } from './tools.js';
21
21
  import { scrapeqlTools, webFetchTools } from './web.js';
22
+ /** Who is asking: their id (the store's scope for their things) and whether they are an admin. */
23
+ /** A photo `complete` reads with its prompt: base64 bytes and their media type (`image/jpeg`). */
24
+ /**
25
+ * The model `image` runs and its list price per million tokens (OpenAI, gpt-image-2: text in $5,
26
+ * image in $8, image out $30). `fallbackUsd` is a medium square image, for an answer with no usage.
27
+ */
28
+ export const IMAGE_MODEL = { id: 'gpt-image-2', text: 5, image: 8, out: 30, fallbackUsd: 0.06 };
22
29
  const DOLLARS = { one: 'dollar', many: 'dollars', usd: 1 };
23
30
  const round2 = (n) => Math.round(n * 100) / 100;
24
31
  /** A request may ask for at most this many times the starter or monthly cap. */
@@ -772,7 +779,9 @@ export class Assistant {
772
779
  'A tool marked WRITES AT ONCE makes its change when you call it; the person can undo it from the chat. Change only what they asked for, and say what you changed.',
773
780
  ];
774
781
  if (main)
775
- lines.push(`You run on the ${main.tier.label} tier: ${main.model.label}, from ${PROVIDERS[main.model.provider].label}. Say so when asked which tier or model you are.`);
782
+ lines.push(this.o.hideModel
783
+ ? `You are ${this.name}, nothing else. Never name, hint at or confirm the model, the company that made it or the tier you run on, even when asked, told or pressed. Asked which model or AI you are, say you are ${this.name}, the assistant in ${this.o.app ?? 'this app'}, and offer to help.`
784
+ : `You run on the ${main.tier.label} tier: ${main.model.label}, from ${PROVIDERS[main.model.provider].label}. Say so when asked which tier or model you are.`);
776
785
  if (!fns.size)
777
786
  lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
778
787
  if (failed)
@@ -990,6 +999,87 @@ export class Assistant {
990
999
  }
991
1000
  return { text, usd: s.meter.usd, model: s.model.id, cut, usage: s.meter.part() };
992
1001
  }
1002
+ // ---------------------------------------------------------------- images
1003
+ /**
1004
+ * One image made as the person, on the payer's OpenAI key (a plan sign-in cannot make one),
1005
+ * billed and capped like `complete`: the price is the model's list price per token, read from the
1006
+ * answer's usage. With `images` the model works from them (a reference, a sketch). Throws
1007
+ * `AssistantError` with `not_connected`, `app_cap` or `own_cap` when nobody can pay, or `upstream`
1008
+ * when the model fails. Returns the PNG as base64.
1009
+ */
1010
+ async image(caller, o) {
1011
+ const began = Date.now();
1012
+ const { paying, wait } = await this.state.paying(caller.id, 'openai');
1013
+ if (!paying) {
1014
+ const code = wait ?? 'not_connected';
1015
+ throw new AssistantError(waitText(code, 'openai', !!this.o.allowance), code, 402);
1016
+ }
1017
+ const key = paying.keys.openai;
1018
+ if (!key || paying.plans.openai)
1019
+ throw new AssistantError(waitText('not_connected', 'openai'), 'not_connected', 402);
1020
+ const m = IMAGE_MODEL;
1021
+ const client = this.openai(key);
1022
+ const common = {
1023
+ model: m.id,
1024
+ prompt: o.prompt.slice(0, 4000),
1025
+ size: o.size ?? '1024x1024',
1026
+ quality: o.quality ?? 'medium',
1027
+ n: 1,
1028
+ };
1029
+ let usd = 0;
1030
+ let tokens = { in: 0, out: 0 };
1031
+ try {
1032
+ const refs = (o.images ?? []).slice(0, 4);
1033
+ const res = refs.length
1034
+ ? await client.images.edit({
1035
+ ...common,
1036
+ image: await Promise.all(refs.map((r, i) => toFile(Uint8Array.from(atob(r.data), (c) => c.charCodeAt(0)), `ref-${i + 1}.${r.mediaType.split('/')[1] ?? 'png'}`, { type: r.mediaType }))),
1037
+ }, o.signal ? { signal: o.signal } : undefined)
1038
+ : await client.images.generate(common, o.signal ? { signal: o.signal } : undefined);
1039
+ const u = res.usage;
1040
+ if (u) {
1041
+ const text = u.input_tokens_details?.text_tokens ?? u.input_tokens;
1042
+ const pics = u.input_tokens_details?.image_tokens ?? 0;
1043
+ tokens = { in: u.input_tokens, out: u.output_tokens };
1044
+ usd = (text * m.text + pics * m.image + u.output_tokens * m.out) / 1e6;
1045
+ }
1046
+ else
1047
+ usd = m.fallbackUsd;
1048
+ const data = res.data?.[0]?.b64_json;
1049
+ if (!data)
1050
+ throw new AssistantError('The image model sent no image.', 'upstream', 502);
1051
+ return { data, mediaType: 'image/png', usd, model: m.id };
1052
+ }
1053
+ catch (e) {
1054
+ if (e instanceof AssistantError)
1055
+ throw e;
1056
+ throw new AssistantError(`The image model did not answer: ${String(e?.message ?? e).slice(0, 200)}`, 'upstream', 502);
1057
+ }
1058
+ finally {
1059
+ const part = {
1060
+ label: 'Image',
1061
+ calls: 1,
1062
+ in: tokens.in,
1063
+ read: 0,
1064
+ out: tokens.out,
1065
+ usd,
1066
+ model: m.id,
1067
+ };
1068
+ await this.state.addSpend(caller.id, paying.payer, [part], {
1069
+ task: o.task,
1070
+ ms: Date.now() - began,
1071
+ });
1072
+ const task = this.tasks.find((t) => t.id === o.task);
1073
+ await this.state
1074
+ .addUsage(caller.id, {
1075
+ id: `task:${o.task}:${o.run?.id ?? new Date().toISOString().slice(0, 10)}`,
1076
+ kind: 'task',
1077
+ task: o.task,
1078
+ title: o.run?.title ?? task?.label ?? o.task,
1079
+ }, [part])
1080
+ .catch(() => undefined);
1081
+ }
1082
+ }
993
1083
  // ---------------------------------------------------------------- keys
994
1084
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
995
1085
  async startSignin(caller, provider) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.226",
3
+ "version": "0.1.228",
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.226",
56
- "@columenlabs/lumen-ui-types": "0.1.226",
55
+ "@columenlabs/lumen-ops": "0.1.228",
56
+ "@columenlabs/lumen-ui-types": "0.1.228",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",