@columenlabs/lumen-ai 0.1.72 → 0.1.74

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.
@@ -0,0 +1,1064 @@
1
+ /**
2
+ * The assistant: one turn loop for any app, ported from a production assistant.
3
+ *
4
+ * A turn streams the model's words and runs its tool calls until it answers (at most `MAX_ROUNDS`
5
+ * rounds). A write tool only drafts: a card the person saves or dismisses. Stop aborts the model
6
+ * and any running tool at once and keeps what was said. Past `COMPACT_AT` tokens (or on
7
+ * `/compact`) the history sent to the model becomes a short summary; the screen keeps every message.
8
+ *
9
+ * Hand off: an `escalate` tool hands one task to a higher tier with the same tools, in its own
10
+ * section of the chat, and its memo goes back. Advice: Anthropic's advisor tool lets a higher tier
11
+ * read the chat and send a plan (it runs nothing), at most `ADVICE_MAX` an answer.
12
+ *
13
+ * Spend: every call is priced at list price as it runs; past what its payer has left this month the
14
+ * model must answer from what it has.
15
+ */
16
+ import Anthropic from '@anthropic-ai/sdk';
17
+ import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
18
+ import { CLAUDE_MODELS, DEFAULT_OUT, Meter, effortParams, } from './models.js';
19
+ import { DEFAULT_CAP, State, WAIT_TEXT, keyState } from './state.js';
20
+ import { Docs } from './store.js';
21
+ import { ToolError, rowCount, toolName, withDeadline, } from './tools.js';
22
+ import { scrapeqlTools, webFetchTools } from './web.js';
23
+ /** A turn's failure the person can act on. */
24
+ export class AssistantError extends Error {
25
+ code;
26
+ status;
27
+ constructor(message, code, status = 400) {
28
+ super(message);
29
+ this.code = code;
30
+ this.status = status;
31
+ }
32
+ }
33
+ const MAX_ROUNDS = 8;
34
+ const COMPACT_AT = 150_000;
35
+ /** A tool result handed to the model is cut past this (about 4,000 tokens). */
36
+ const RESULT_CHARS = 16_000;
37
+ const MEMO_CHARS = 8000;
38
+ const ADVICE_MAX = 2;
39
+ const ADVICE_TOKENS = 2048;
40
+ const ADVISOR_BETA = 'advisor-tool-2026-03-01';
41
+ /** What later turns remember of a turn's tool results. */
42
+ const RAN_CHARS = 1500;
43
+ const RAN_TOTAL = 6000;
44
+ const CHATS_LISTED = 200;
45
+ const WHY_TEXT = {
46
+ asked: 'you asked',
47
+ judgement: 'judgement',
48
+ stuck: 'stuck',
49
+ check_failed: 'check failed',
50
+ };
51
+ const NO_USAGE = { calls: 0, in: 0, read: 0, out: 0, usd: 0, parts: [] };
52
+ function addUsage(u, parts) {
53
+ const out = { ...u, parts: u.parts.map((p) => ({ ...p })) };
54
+ for (const p of parts) {
55
+ if (!p.calls)
56
+ continue;
57
+ out.calls += p.calls;
58
+ out.in += p.in;
59
+ out.read += p.read;
60
+ out.out += p.out;
61
+ out.usd += p.usd;
62
+ const prev = out.parts.find((x) => x.label === p.label);
63
+ if (prev) {
64
+ prev.calls += p.calls;
65
+ prev.in += p.in;
66
+ prev.read += p.read;
67
+ prev.out += p.out;
68
+ prev.usd += p.usd;
69
+ }
70
+ else
71
+ out.parts.push({ ...p });
72
+ }
73
+ return out;
74
+ }
75
+ function withCacheMark(m) {
76
+ const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : [...m.content];
77
+ const last = blocks[blocks.length - 1];
78
+ if (!last || last.type === 'thinking' || last.type === 'redacted_thinking')
79
+ return m;
80
+ blocks[blocks.length - 1] = { ...last, cache_control: { type: 'ephemeral' } };
81
+ return { ...m, content: blocks };
82
+ }
83
+ /** A tool's result, or Stop's abort as soon as it comes. */
84
+ function untilStopped(work, signal) {
85
+ if (!signal)
86
+ return work;
87
+ signal.throwIfAborted();
88
+ return new Promise((done, fail) => {
89
+ const stop = () => fail(signal.reason);
90
+ signal.addEventListener('abort', stop, { once: true });
91
+ work.then(done, fail).finally(() => signal.removeEventListener('abort', stop));
92
+ });
93
+ }
94
+ const newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 20)}`;
95
+ /** Every draft in a chat, those in hand-off sections included. */
96
+ function draftsOf(items) {
97
+ return items.flatMap((i) => i.kind === 'draft' ? [i] : i.kind === 'handoff' ? draftsOf(i.items) : []);
98
+ }
99
+ /** A short line of what an error was, never an upstream body. */
100
+ function shownError(err) {
101
+ if (err instanceof AssistantError)
102
+ return { message: err.message, code: err.code };
103
+ if (err instanceof Anthropic.APIError) {
104
+ if (err.status === 401)
105
+ return { message: 'The Claude key was refused.', code: 'key_refused' };
106
+ if (err.status === 429)
107
+ return {
108
+ message: 'Claude is busy or the key hit its rate limit. Try again shortly.',
109
+ code: 'rate_limited',
110
+ };
111
+ if (err.status === 529 || (err.status ?? 0) >= 500)
112
+ return { message: 'Claude is overloaded. Try again shortly.', code: 'overloaded' };
113
+ return {
114
+ message: `Claude refused the request (${err.status ?? 'error'}).`,
115
+ code: 'model_error',
116
+ };
117
+ }
118
+ return { message: 'Something went wrong while answering.', code: 'internal' };
119
+ }
120
+ export class Assistant {
121
+ o;
122
+ name;
123
+ tiers;
124
+ tasks;
125
+ models;
126
+ state;
127
+ groups;
128
+ constructor(o) {
129
+ this.o = o;
130
+ if (!o.tiers.length)
131
+ throw new Error('An assistant needs at least one tier');
132
+ this.name = o.name;
133
+ this.tiers = o.tiers;
134
+ this.models = o.models ?? CLAUDE_MODELS;
135
+ this.tasks = o.tasks ?? [
136
+ { id: 'chat', label: 'Chat', about: 'The side panel', tier: o.tiers[0].id },
137
+ ];
138
+ this.groups = o.groups ?? [];
139
+ this.state = new State(new Docs(o.store, o.sealer), {
140
+ timeZone: o.timeZone ?? 'UTC',
141
+ defaultCap: o.defaultCap ?? DEFAULT_CAP,
142
+ ...(o.appKey ? { appKey: o.appKey } : {}),
143
+ });
144
+ }
145
+ client(apiKey) {
146
+ const f = this.o.fetch;
147
+ return new Anthropic({ apiKey, maxRetries: 1, ...(f ? { fetch: f } : {}) });
148
+ }
149
+ tierById(id) {
150
+ return this.tiers.find((t) => t.id === id);
151
+ }
152
+ above(main) {
153
+ return this.tiers.slice(this.tiers.indexOf(main) + 1);
154
+ }
155
+ modelById(id) {
156
+ return this.models.find((m) => m.id === id);
157
+ }
158
+ /** The person's model and effort for a tier, or the tier's own when unset or gone. */
159
+ async pick(caller, tier) {
160
+ const saved = (await this.state.person(caller.id)).tiers?.[tier.id];
161
+ const model = this.modelById(saved?.model) ?? this.modelById(tier.model) ?? this.models[0];
162
+ return { model, effort: saved?.effort ?? 'default' };
163
+ }
164
+ async session(caller, tier, label) {
165
+ const { model, effort } = await this.pick(caller, tier);
166
+ return {
167
+ tier,
168
+ model,
169
+ effort,
170
+ meter: new Meter(model, label ?? `${tier.label} · ${model.label}`),
171
+ };
172
+ }
173
+ /** The tier the person's chat runs on: their pick, else the chat task's start tier. */
174
+ async mainTier(caller, task = 'chat') {
175
+ const p = await this.state.person(caller.id);
176
+ const startId = p.tasks?.[task] ?? this.tasks.find((t) => t.id === task)?.tier;
177
+ return (this.tierById(task === 'chat' ? (p.tier ?? startId) : startId) ?? this.tiers[0]);
178
+ }
179
+ access(p, g) {
180
+ return p.access?.[g.id] ?? g.access ?? 'read';
181
+ }
182
+ // ---------------------------------------------------------------- status and settings
183
+ async status(caller) {
184
+ const [p, appDoc, appKey, own, scrapeql, spend, appCap, { paying, wait }, main] = await Promise.all([
185
+ this.state.person(caller.id),
186
+ this.state.appKey(),
187
+ this.state.appApiKey(),
188
+ this.state.ownKey(caller.id),
189
+ this.o.web?.scrapeql ? this.state.scrapeql() : Promise.resolve(null),
190
+ this.state.spend(caller.id),
191
+ this.state.appCap(),
192
+ this.state.paying(caller.id),
193
+ this.mainTier(caller),
194
+ ]);
195
+ const tiers = await Promise.all(this.tiers.map(async (t) => {
196
+ const { model, effort } = await this.pick(caller, t);
197
+ return {
198
+ id: t.id,
199
+ label: t.label,
200
+ about: t.about,
201
+ model: model.id,
202
+ model_label: model.label,
203
+ effort,
204
+ };
205
+ }));
206
+ const appState = caller.admin
207
+ ? keyState(appDoc, (d) => d.api_key)
208
+ : { set: !!appKey };
209
+ return {
210
+ name: this.name,
211
+ wait,
212
+ admin: caller.admin,
213
+ tier: main.id,
214
+ tiers,
215
+ models: this.models.map((m) => ({ id: m.id, label: m.label, group: m.group })),
216
+ tasks: this.tasks.map((t) => ({
217
+ id: t.id,
218
+ label: t.label,
219
+ about: t.about,
220
+ tier: this.tierById(p.tasks?.[t.id])?.id ?? t.tier,
221
+ })),
222
+ groups: this.groups.map((g) => ({
223
+ id: g.id,
224
+ label: g.label,
225
+ about: g.about,
226
+ access: this.access(p, g),
227
+ drafts: true,
228
+ })),
229
+ hand_off: p.hand_off ?? 'next',
230
+ advice: p.advice && (p.advice === 'off' || this.tierById(p.advice)) ? p.advice : 'off',
231
+ web: {
232
+ fetch: p.web ?? true,
233
+ scrapeql: this.o.web?.scrapeql
234
+ ? caller.admin
235
+ ? keyState(scrapeql, (d) => d.client_id)
236
+ : { set: !!scrapeql }
237
+ : null,
238
+ },
239
+ keys: {
240
+ app: { ...appState, cap: appCap, ...(appKey?.fromEnv ? { from_env: true } : {}) },
241
+ own: {
242
+ ...keyState(own, (d) => d.api_key),
243
+ cap: own?.cap_usd ?? this.o.defaultCap ?? DEFAULT_CAP,
244
+ },
245
+ },
246
+ pay: p.pay ?? 'app',
247
+ spend: {
248
+ month: this.state.month(),
249
+ usd: spend.rows.reduce((a, r) => a + r.usd, 0),
250
+ cap: paying?.cap ?? null,
251
+ payer: paying?.payer ?? null,
252
+ rows: spend.rows,
253
+ },
254
+ };
255
+ }
256
+ // ---------------------------------------------------------------- chats
257
+ async listChats(caller) {
258
+ const rows = await this.state.docs.list(caller.id, 'chat:', CHATS_LISTED);
259
+ return rows.map((r) => ({ id: r.key.slice(5), title: r.meta ?? '', at: r.at }));
260
+ }
261
+ async readChat(caller, id) {
262
+ const d = await this.state.docs.read(caller.id, `chat:${id}`);
263
+ if (!d)
264
+ return null;
265
+ return {
266
+ id,
267
+ title: d.title,
268
+ at: d.at ?? Date.now(),
269
+ items: d.items,
270
+ ...(d.usage ? { usage: d.usage } : {}),
271
+ };
272
+ }
273
+ removeChat(caller, id) {
274
+ return this.state.docs.remove(caller.id, `chat:${id}`);
275
+ }
276
+ /** Deletes chats past `keepDays` since their last message; run it once a day. */
277
+ purge() {
278
+ return this.o.store.purge('chat:', Date.now() - (this.o.keepDays ?? 30) * 86_400_000);
279
+ }
280
+ writeChat(caller, id, doc) {
281
+ return this.state.docs.write(caller.id, `chat:${id}`, { ...doc, at: Date.now() }, doc.title);
282
+ }
283
+ // ---------------------------------------------------------------- tools
284
+ /** Opens the tools the person allows: each group's, then the web's. */
285
+ async openTools(caller, p) {
286
+ const opened = [];
287
+ const sources = [];
288
+ for (const g of this.groups) {
289
+ const access = this.access(p, g);
290
+ if (access === 'off')
291
+ continue;
292
+ const s = g.tools(caller);
293
+ for (const source of Array.isArray(s) ? s : [s])
294
+ sources.push({ group: g.id, access, source });
295
+ }
296
+ if (p.web ?? true) {
297
+ if (this.o.web?.fetch !== false)
298
+ sources.push({
299
+ group: 'web',
300
+ access: 'read',
301
+ source: webFetchTools(this.o.web?.fetch || {}),
302
+ });
303
+ const sq = this.o.web?.scrapeql ? await this.state.scrapeql() : null;
304
+ if (sq && this.o.web?.scrapeql)
305
+ sources.push({
306
+ group: 'web',
307
+ access: 'read',
308
+ source: scrapeqlTools(this.o.web.scrapeql.url, sq, this.o.fetch),
309
+ });
310
+ }
311
+ const results = await Promise.allSettled(sources.map((s) => s.source()));
312
+ results.forEach((r, i) => {
313
+ const s = sources[i];
314
+ if (r.status === 'fulfilled' && s)
315
+ opened.push({ group: s.group, access: s.access, open: r.value });
316
+ });
317
+ const fns = new Map();
318
+ for (const o of opened)
319
+ for (const def of o.open.tools) {
320
+ if (!def.read && o.access !== 'draft')
321
+ continue;
322
+ fns.set(toolName(o.group, def.name), {
323
+ group: o.group,
324
+ def,
325
+ draft: !def.read,
326
+ open: o.open,
327
+ });
328
+ }
329
+ return {
330
+ fns,
331
+ failed: results.filter((r) => r.status === 'rejected').length,
332
+ close: () => Promise.all(opened.map((o) => o.open.close().catch(() => undefined))),
333
+ };
334
+ }
335
+ toolsFor(fns) {
336
+ return [...fns.entries()].map(([name, f]) => ({
337
+ name,
338
+ description: `${f.draft ? "DRAFT ONLY: this call changes nothing. It puts a card on the person's screen that they Save or Dismiss. " : ''}${f.def.title ? `${f.def.title}. ` : ''}${f.def.description ?? ''}`.slice(0, 4000),
339
+ input_schema: { type: 'object', ...f.def.inputSchema },
340
+ }));
341
+ }
342
+ // ---------------------------------------------------------------- the turn
343
+ /** One answer as server-sent events. Abort `signal` to stop it. */
344
+ turnStream(caller, req, signal) {
345
+ const enc = new TextEncoder();
346
+ let ctl;
347
+ let closed = false;
348
+ const stream = new ReadableStream({
349
+ start: (c) => {
350
+ ctl = c;
351
+ },
352
+ cancel: () => {
353
+ closed = true;
354
+ },
355
+ });
356
+ const emit = (event, data) => {
357
+ if (closed)
358
+ return;
359
+ try {
360
+ ctl.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
361
+ }
362
+ catch {
363
+ closed = true;
364
+ }
365
+ };
366
+ void this.turn(caller, req, emit, signal).finally(() => {
367
+ if (closed)
368
+ return;
369
+ closed = true;
370
+ try {
371
+ ctl.close();
372
+ }
373
+ catch { }
374
+ });
375
+ return stream;
376
+ }
377
+ async turn(caller, req, emit, signal) {
378
+ const t0 = performance.now();
379
+ const message = req.message.trim();
380
+ const chatId = req.chat_id ?? newId('ac');
381
+ const prev = req.chat_id
382
+ ? await this.state.docs.read(caller.id, `chat:${req.chat_id}`)
383
+ : null;
384
+ if (req.chat_id && !prev) {
385
+ emit('error', { message: 'That chat is gone.', code: 'not_found' });
386
+ return;
387
+ }
388
+ const doc = prev ?? {
389
+ title: message.replace(/\s+/g, ' ').slice(0, 60),
390
+ items: [],
391
+ history: [],
392
+ };
393
+ const compactOnly = message === '/compact';
394
+ if (!compactOnly)
395
+ doc.items.push({
396
+ kind: 'user',
397
+ text: message,
398
+ at: Date.now(),
399
+ ...(req.context?.label ? { context: req.context.label } : {}),
400
+ ...(req.refs?.length ? { refs: req.refs } : {}),
401
+ ...(req.tier ? { tier: req.tier } : {}),
402
+ });
403
+ const start = doc.items.length;
404
+ emit('chat', { id: chatId, title: doc.title });
405
+ const { paying, wait } = await this.state.paying(caller.id);
406
+ if (!paying) {
407
+ const text = WAIT_TEXT[wait ?? 'not_connected'];
408
+ doc.items.push({ kind: 'error', text, code: wait ?? 'not_connected' });
409
+ await this.writeChat(caller, chatId, doc);
410
+ emit('error', { message: text, code: wait });
411
+ emit('done', { id: chatId, usage: doc.usage ?? NO_USAGE });
412
+ return;
413
+ }
414
+ const p = await this.state.person(caller.id);
415
+ const main = await this.session(caller, await this.mainTier(caller));
416
+ const policy = p.hand_off ?? 'next';
417
+ const advisor = await this.advisorFor(caller, p.advice, main);
418
+ const client = this.client(paying.apiKey);
419
+ const live = [main.meter, ...(advisor ? [advisor.session.meter] : [])];
420
+ const before = doc.usage ?? NO_USAGE;
421
+ let context = before.context;
422
+ const spent = () => ({
423
+ ...addUsage(before, live.map((m) => m.part())),
424
+ ...(context ? { context } : {}),
425
+ });
426
+ let limited = false;
427
+ const over = () => {
428
+ if (live.reduce((a, m) => a + m.usd, 0) < paying.left)
429
+ return null;
430
+ limited = true;
431
+ return 'The spend cap for this month is reached: answer now from what you have, in a sentence or two. You can call no more tools.';
432
+ };
433
+ const handTo = req.tier ? this.above(main.tier).find((t) => t.id === req.tier) : undefined;
434
+ const tools = await this.openTools(caller, p);
435
+ const runs = [];
436
+ const counts = { steps: 0, handoffs: 0, advice: 0, end: main.tier };
437
+ let outcome = 'ok';
438
+ try {
439
+ const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
440
+ const kitRules = this.rules(main, policy, advisor, tools.fns, tools.failed);
441
+ const system = [
442
+ { type: 'text', text: appSystem },
443
+ { type: 'text', text: kitRules, cache_control: { type: 'ephemeral' } },
444
+ ];
445
+ const subSystem = [
446
+ { type: 'text', text: appSystem },
447
+ {
448
+ type: 'text',
449
+ text: this.rules(null, policy, null, tools.fns, tools.failed),
450
+ cache_control: { type: 'ephemeral' },
451
+ },
452
+ ];
453
+ if (compactOnly || (before.context ?? 0) > COMPACT_AT) {
454
+ const did = await this.compact(client, main, doc, emit);
455
+ if (did)
456
+ context = undefined;
457
+ if (compactOnly) {
458
+ if (!did) {
459
+ const text = 'Nothing to compact yet.';
460
+ doc.items.push({ kind: 'text', text });
461
+ emit('text', { delta: text });
462
+ emit('text_end', {});
463
+ }
464
+ doc.usage = spent();
465
+ await this.writeChat(caller, chatId, doc);
466
+ await this.state.addSpend(caller.id, paying.payer, live.map((m) => m.part()));
467
+ emit('done', { id: chatId, usage: doc.usage });
468
+ return;
469
+ }
470
+ }
471
+ const page = this.cardNews(doc) +
472
+ (req.refs?.length
473
+ ? `The person points at: ${JSON.stringify(req.refs).slice(0, 2000)}\n\n`
474
+ : '') +
475
+ (req.context
476
+ ? `Page: ${req.context.label}${req.context.note === undefined ? '' : `\n${JSON.stringify(req.context.note).slice(0, 4000)}`}\n\n`
477
+ : '');
478
+ const lead = handTo
479
+ ? `The person handed this message to ${handTo.label}: call escalate {tier: "${handTo.id}", why: "asked"} now, its task the message with every fact from this chat it needs, then answer from its memo.\n\n`
480
+ : '';
481
+ const messages = [
482
+ ...doc.history.map((t) => ({ role: t.role, content: t.content })),
483
+ { role: 'user', content: `${page}${lead}${message}` },
484
+ ];
485
+ const escalateTool = this.escalateTool(main.tier);
486
+ let answer = await this.loop({
487
+ client,
488
+ session: main,
489
+ system,
490
+ tools: [...this.toolsFor(tools.fns), ...(escalateTool ? [escalateTool] : [])],
491
+ messages,
492
+ sink: doc.items,
493
+ emit,
494
+ signal,
495
+ over,
496
+ ...(handTo && escalateTool ? { first: { type: 'tool', name: 'escalate' } } : {}),
497
+ ...(advisor ? { advisor } : {}),
498
+ context: (n) => {
499
+ context = n;
500
+ },
501
+ called: () => emit('usage', spent()),
502
+ tool: (use) => use.name === 'escalate'
503
+ ? this.handOff(use, {
504
+ caller,
505
+ client,
506
+ message,
507
+ handTo,
508
+ page,
509
+ policy,
510
+ main,
511
+ live,
512
+ sink: doc.items,
513
+ emit,
514
+ spent,
515
+ system: subSystem,
516
+ tools: tools.fns,
517
+ runs,
518
+ counts,
519
+ signal,
520
+ over,
521
+ })
522
+ : this.runTool(use, tools.fns, doc.items, emit, runs, counts, main, false, signal),
523
+ });
524
+ if (limited) {
525
+ doc.items.push({ kind: 'stopped', reason: 'cap' });
526
+ emit('stopped', { reason: 'cap' });
527
+ }
528
+ if (!answer) {
529
+ answer = 'I could not finish that. Try a narrower question.';
530
+ doc.items.push({ kind: 'text', text: answer });
531
+ emit('text', { delta: answer });
532
+ emit('text_end', {});
533
+ }
534
+ doc.history.push({ role: 'user', content: `${page}${message}` });
535
+ doc.history.push({ role: 'assistant', content: this.remember(answer, runs) });
536
+ }
537
+ catch (err) {
538
+ if (signal?.aborted) {
539
+ outcome = 'stopped';
540
+ doc.items.push({ kind: 'stopped' });
541
+ const said = doc.items
542
+ .slice(start)
543
+ .filter((i) => i.kind === 'text')
544
+ .map((i) => i.text)
545
+ .join('\n\n');
546
+ if (!compactOnly) {
547
+ doc.history.push({ role: 'user', content: message });
548
+ doc.history.push({ role: 'assistant', content: said || '(stopped before answering)' });
549
+ }
550
+ }
551
+ else {
552
+ outcome = 'error';
553
+ const shown = shownError(err);
554
+ if (!(err instanceof AssistantError) && !(err instanceof Anthropic.APIError))
555
+ console.error(JSON.stringify({
556
+ event: 'assistant_error',
557
+ error: String(err?.stack ?? err).slice(0, 2000),
558
+ }));
559
+ doc.items.push({ kind: 'error', text: shown.message, code: shown.code });
560
+ emit('error', shown);
561
+ }
562
+ }
563
+ finally {
564
+ await tools.close();
565
+ }
566
+ doc.usage = spent();
567
+ await this.writeChat(caller, chatId, doc).catch(() => undefined);
568
+ await this.state
569
+ .addSpend(caller.id, paying.payer, live.map((m) => m.part()))
570
+ .catch(() => undefined);
571
+ if (outcome !== 'stopped')
572
+ emit('done', { id: chatId, usage: doc.usage });
573
+ const total = live.reduce((a, m) => ({
574
+ calls: a.calls + m.calls,
575
+ in: a.in + m.in,
576
+ out: a.out + m.out,
577
+ usd: a.usd + m.usd,
578
+ }), { calls: 0, in: 0, out: 0, usd: 0 });
579
+ await Promise.resolve(this.o.onStep?.({
580
+ kind: 'turn',
581
+ outcome,
582
+ ms: Math.round(performance.now() - t0),
583
+ tier: main.tier.id,
584
+ end_tier: counts.end.id,
585
+ handoffs: counts.handoffs,
586
+ advice: counts.advice,
587
+ steps: counts.steps,
588
+ ...total,
589
+ })).catch(() => undefined);
590
+ }
591
+ /** What the kit adds to the app's instructions: tools, drafts, hand off, advice, limits. */
592
+ rules(main, policy, advisor, fns, failed) {
593
+ const lines = [
594
+ `You are ${this.name}. Today is ${new Date().toISOString().slice(0, 10)}.`,
595
+ 'Answer in short plain sentences. Use the tools for facts; never guess a number a tool can give you. Call several tools at once when they do not depend on each other.',
596
+ 'A tool marked DRAFT ONLY makes a card the person saves or dismisses; it changes nothing by itself. Say what the card will do; never claim it is done. The next message tells you how each card ended.',
597
+ ];
598
+ if (!fns.size)
599
+ lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
600
+ if (failed)
601
+ lines.push(`${failed} tool source${failed === 1 ? '' : 's'} could not be reached this turn.`);
602
+ if ([...fns.values()].some((f) => f.group === 'web'))
603
+ lines.push('Web: use web__fetch first for APIs and plain pages; the browser tools only for pages that need JavaScript or after a fetch fails.');
604
+ if (main) {
605
+ const reach = this.above(main.tier);
606
+ if (reach.length) {
607
+ const own = policy === 'any' ? reach : policy === 'next' ? reach.slice(0, 1) : [];
608
+ lines.push(`Tiers: you run on ${main.tier.label}. The escalate tool hands one task to a higher tier (${reach.map((t) => t.label).join(' or ')}) with the same tools; it works in its own section of the chat and returns a memo. It costs more than answering yourself.`, `Call it when the person names a tier or says "go deeper"${own.length ? `, and on your own (${own.map((t) => t.label).join(' or ')}) when the question needs judgement across many results (why "judgement"), after two of your tool calls failed (why "stuck"), or when a fix of a check still fails (why "check_failed")` : ''}. Lookups you answer yourself.`, 'Its task must carry every fact from this chat it needs: it does not see the chat. Then answer from its memo in your own short words.');
609
+ }
610
+ if (advisor)
611
+ lines.push(`Advisor: the advisor tool (no input) asks ${advisor.session.tier.label} to read this chat and send you a plan; it runs no tools. Call it before a question that needs judgement or after a tool failed, then do the work yourself. At most ${ADVICE_MAX} times an answer. Ask the advisor before you escalate.`);
612
+ }
613
+ return lines.join('\n\n');
614
+ }
615
+ /** The advisor for this answer, or null when off. Never weaker than the model it advises. */
616
+ async advisorFor(caller, setting, main) {
617
+ const tier = setting && setting !== 'off' ? this.tierById(setting) : undefined;
618
+ if (!tier)
619
+ return null;
620
+ const rank = (m) => this.models.indexOf(m);
621
+ let session = await this.session(caller, tier);
622
+ if (rank(session.model) < rank(main.model))
623
+ session = { ...main, meter: new Meter(main.model, '') };
624
+ session.meter = new Meter(session.model, `${session.tier.label} · advice`);
625
+ return { session, asked: 0 };
626
+ }
627
+ escalateTool(main) {
628
+ const reach = this.above(main);
629
+ if (!reach.length)
630
+ return null;
631
+ return {
632
+ name: 'escalate',
633
+ description: 'Hands one task to a higher tier with the same tools. It works in its own section and returns a memo of at most 200 words.',
634
+ input_schema: {
635
+ type: 'object',
636
+ properties: {
637
+ tier: { type: 'string', enum: reach.map((t) => t.id) },
638
+ why: { type: 'string', enum: [...HAND_OFF_WHYS] },
639
+ task: {
640
+ type: 'string',
641
+ description: 'The task, with every fact from the chat it needs.',
642
+ },
643
+ },
644
+ required: ['tier', 'why', 'task'],
645
+ },
646
+ };
647
+ }
648
+ /** The tiers the person's message asks for by name ("go deeper" is the next one up). */
649
+ askedFor(message, main) {
650
+ const reach = this.above(main);
651
+ const asked = new Set();
652
+ for (const t of reach)
653
+ if (new RegExp(`\\b(${t.id}|${t.label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'i').test(message))
654
+ asked.add(t.id);
655
+ if (/\b(deeper|escalate)\b/i.test(message) && reach[0])
656
+ asked.add(reach[0].id);
657
+ return asked;
658
+ }
659
+ async handOff(use, c) {
660
+ const reply = (content, is_error = false) => ({
661
+ type: 'tool_result',
662
+ tool_use_id: use.id,
663
+ content,
664
+ is_error,
665
+ });
666
+ const input = (use.input ?? {});
667
+ const reach = this.above(c.main.tier);
668
+ const tier = reach.find((t) => t.id === input.tier);
669
+ const why = HAND_OFF_WHYS.find((w) => w === input.why);
670
+ const task = typeof input.task === 'string' ? input.task.trim() : '';
671
+ if (!tier || !why || !task)
672
+ return reply(`Give {tier: ${reach.map((t) => `"${t.id}"`).join(' | ')}, why: ${HAND_OFF_WHYS.map((w) => `"${w}"`).join(' | ')}, task}.`, true);
673
+ const asked = this.askedFor(c.message, c.main.tier);
674
+ if (c.handTo)
675
+ asked.add(c.handTo.id);
676
+ const own = c.policy === 'any' ? reach : c.policy === 'next' ? reach.slice(0, 1) : [];
677
+ const may = new Set([...own.map((t) => t.id), ...asked]);
678
+ if (!may.has(tier.id))
679
+ return reply(may.size
680
+ ? `Not ${tier.id} now: you may call ${[...may].join(' or ')}.`
681
+ : 'The person has not asked for a higher tier: answer yourself.', true);
682
+ if (c.over())
683
+ return reply('No money left for a higher tier this answer: answer yourself.', true);
684
+ const sub = await this.session(c.caller, tier);
685
+ c.live.push(sub.meter);
686
+ c.counts.handoffs += 1;
687
+ if (this.tiers.indexOf(tier) > this.tiers.indexOf(c.counts.end))
688
+ c.counts.end = tier;
689
+ const item = {
690
+ kind: 'handoff',
691
+ tier: tier.id,
692
+ label: sub.meter.label,
693
+ task: task.replace(/\s+/g, ' ').slice(0, 200),
694
+ why: asked.has(tier.id) ? 'asked' : why,
695
+ items: [],
696
+ };
697
+ c.sink.push(item);
698
+ c.emit('handoff', {
699
+ kind: 'handoff',
700
+ tier: item.tier,
701
+ label: item.label,
702
+ task: item.task,
703
+ why: item.why,
704
+ });
705
+ const inside = (event, data) => c.emit(event, { ...data, handoff: true });
706
+ let memo = '';
707
+ let failed;
708
+ try {
709
+ memo = await this.loop({
710
+ client: c.client,
711
+ session: sub,
712
+ system: c.system,
713
+ tools: this.toolsFor(c.tools),
714
+ messages: [
715
+ {
716
+ role: 'user',
717
+ content: `${c.page}The person asked: ${c.message}\n\n${this.name} handed you this task (${WHY_TEXT[item.why]}): ${task}\n\nWork it out with the tools, then end with a memo for ${this.name} of at most 200 words: the answer and the facts it rests on. ${this.name} answers the person.`,
718
+ },
719
+ ],
720
+ sink: item.items,
721
+ emit: inside,
722
+ signal: c.signal,
723
+ over: c.over,
724
+ called: () => c.emit('usage', c.spent()),
725
+ tool: (u) => this.runTool(u, c.tools, item.items, inside, c.runs, c.counts, sub, true, c.signal),
726
+ });
727
+ }
728
+ catch (err) {
729
+ if (c.signal?.aborted)
730
+ throw err;
731
+ const shown = shownError(err);
732
+ failed = shown.message;
733
+ item.items.push({ kind: 'error', text: failed, code: shown.code });
734
+ inside('error', shown);
735
+ }
736
+ finally {
737
+ item.usage = sub.meter.part();
738
+ c.emit('handoff_done', { usage: item.usage });
739
+ }
740
+ return failed
741
+ ? reply(`${item.label} failed: ${failed}`, true)
742
+ : reply(`Memo from ${item.label}:\n${memo.slice(0, MEMO_CHARS)}`);
743
+ }
744
+ /** One tool call: a draft card for a write, else the call itself, timed. */
745
+ async runTool(use, fns, sink, emit, runs, counts, session, handoff, signal) {
746
+ const reply = (content, is_error = false) => ({
747
+ type: 'tool_result',
748
+ tool_use_id: use.id,
749
+ content,
750
+ is_error,
751
+ });
752
+ const f = fns.get(use.name);
753
+ if (!f)
754
+ return reply(`No tool ${use.name}.`, true);
755
+ const title = f.def.title ?? f.def.name;
756
+ if (f.draft) {
757
+ const item = {
758
+ kind: 'draft',
759
+ id: newId('d'),
760
+ tool: use.name,
761
+ title,
762
+ args: use.input,
763
+ status: 'open',
764
+ };
765
+ sink.push(item);
766
+ emit('draft', item);
767
+ await Promise.resolve(this.o.onStep?.({ kind: 'draft', tool: use.name, tier: session.tier.id, handoff })).catch(() => undefined);
768
+ return reply(`Drafted: a "${title}" card is on the person's screen. Nothing changed until they press Save.`);
769
+ }
770
+ const id = newId('s');
771
+ emit('step_start', { id, tool: use.name, title });
772
+ const t0 = performance.now();
773
+ let out;
774
+ let ok = true;
775
+ let error;
776
+ let rows;
777
+ try {
778
+ const result = await untilStopped(withDeadline(title, 60_000, f.open.call(f.def.name, use.input)), signal);
779
+ rows = rowCount(result);
780
+ out = typeof result === 'string' ? result : JSON.stringify(result);
781
+ }
782
+ catch (err) {
783
+ if (signal?.aborted)
784
+ throw err;
785
+ ok = false;
786
+ error =
787
+ err instanceof ToolError || err instanceof Error ? err.message.slice(0, 300) : 'failed';
788
+ out = error;
789
+ }
790
+ const ms = Math.round(performance.now() - t0);
791
+ const item = {
792
+ kind: 'step',
793
+ id,
794
+ tool: use.name,
795
+ title,
796
+ ok,
797
+ ms,
798
+ ...(rows !== undefined ? { rows } : {}),
799
+ ...(error ? { error } : {}),
800
+ };
801
+ sink.push(item);
802
+ emit('step', item);
803
+ counts.steps += 1;
804
+ runs.push({ tool: use.name, result: out.slice(0, RAN_CHARS) });
805
+ await Promise.resolve(this.o.onStep?.({
806
+ kind: 'tool',
807
+ tool: use.name,
808
+ ok,
809
+ ms,
810
+ ...(rows !== undefined ? { rows } : {}),
811
+ ...(error ? { error } : {}),
812
+ tier: session.tier.id,
813
+ handoff,
814
+ })).catch(() => undefined);
815
+ const cut = out.length > RESULT_CHARS ? `${out.slice(0, RESULT_CHARS)} …(cut: ask for less)` : out;
816
+ return reply(cut, !ok);
817
+ }
818
+ /** Model calls and tool rounds until the model answers; returns its words. */
819
+ async loop(o) {
820
+ const { client, session, system, tools, messages, sink, emit, signal } = o;
821
+ let answer = '';
822
+ let force = o.first;
823
+ for (let round = 0; round < MAX_ROUNDS; round++) {
824
+ signal?.throwIfAborted();
825
+ // The system prompt and tools are one cached prefix; the chat so far a second breakpoint.
826
+ const sent = messages.map((m, i) => (i === messages.length - 1 ? withCacheMark(m) : m));
827
+ const params = {
828
+ model: session.model.id,
829
+ max_tokens: session.tier.out ?? DEFAULT_OUT,
830
+ ...effortParams(session.effort),
831
+ system,
832
+ tools,
833
+ messages: sent,
834
+ ...(force ? { tool_choice: force } : {}),
835
+ };
836
+ const adv = o.advisor;
837
+ // A forced tool choice may not come with the advisor; the beta header stays while it is on.
838
+ const advise = adv && adv.asked < ADVICE_MAX && force?.type !== 'tool' && force?.type !== 'any';
839
+ const stream = adv
840
+ ? client.beta.messages.stream({
841
+ ...params,
842
+ tools: [
843
+ ...tools,
844
+ ...(advise
845
+ ? [
846
+ {
847
+ type: 'advisor_20260301',
848
+ name: 'advisor',
849
+ model: adv.session.model.id,
850
+ max_tokens: ADVICE_TOKENS,
851
+ caching: { type: 'ephemeral', ttl: '5m' },
852
+ },
853
+ ]
854
+ : []),
855
+ ],
856
+ betas: [ADVISOR_BETA],
857
+ }, { signal })
858
+ : client.messages.stream(params, signal ? { signal } : {});
859
+ let partial = '';
860
+ stream.on('text', (delta) => {
861
+ partial += delta;
862
+ emit('text', { delta });
863
+ });
864
+ if (adv)
865
+ stream.on('streamEvent', (e) => {
866
+ if (e.type !== 'content_block_start')
867
+ return;
868
+ const b = e.content_block;
869
+ const label = adv.session.meter.label;
870
+ if (b.type === 'server_tool_use' && b.name === 'advisor') {
871
+ adv.asked += 1;
872
+ emit('advice', { tier: adv.session.tier.id, label });
873
+ }
874
+ else if (b.type === 'advisor_tool_result') {
875
+ const error = b.content?.type === 'advisor_tool_result_error'
876
+ ? (b.content.error_code ?? 'error')
877
+ : undefined;
878
+ sink.push({
879
+ kind: 'advice',
880
+ tier: adv.session.tier.id,
881
+ label,
882
+ ...(error ? { error } : {}),
883
+ });
884
+ emit('advice_done', error ? { error } : {});
885
+ }
886
+ });
887
+ const msg = await stream.finalMessage().catch((err) => {
888
+ if (signal?.aborted && partial)
889
+ sink.push({ kind: 'text', text: partial });
890
+ throw err;
891
+ });
892
+ session.meter.add(msg.usage);
893
+ if (adv)
894
+ for (const it of msg.usage.iterations ?? [])
895
+ if (it.type === 'advisor_message')
896
+ adv.session.meter.add(it);
897
+ o.context?.(msg.usage.input_tokens +
898
+ (msg.usage.cache_read_input_tokens ?? 0) +
899
+ (msg.usage.cache_creation_input_tokens ?? 0) +
900
+ msg.usage.output_tokens);
901
+ o.called();
902
+ const cut = msg.stop_reason === 'max_tokens' ? ' …(cut)' : '';
903
+ if (cut)
904
+ emit('text', { delta: cut });
905
+ const text = msg.content
906
+ .filter((b) => b.type === 'text')
907
+ .map((b) => b.text)
908
+ .join('') + cut;
909
+ if (text) {
910
+ answer += (answer ? '\n\n' : '') + text;
911
+ sink.push({ kind: 'text', text });
912
+ emit('text_end', {});
913
+ }
914
+ messages.push({ role: 'assistant', content: msg.content });
915
+ if (msg.stop_reason === 'pause_turn')
916
+ continue;
917
+ const uses = msg.content.filter((b) => b.type === 'tool_use');
918
+ if (msg.stop_reason !== 'tool_use' || uses.length === 0)
919
+ break;
920
+ const results = await Promise.all(uses.map((use) => untilStopped(o.tool(use), signal)));
921
+ if (force === o.first)
922
+ force = undefined;
923
+ const limit = o.over();
924
+ if (limit) {
925
+ results.push({ type: 'text', text: limit });
926
+ force = { type: 'none' };
927
+ }
928
+ else if (round === MAX_ROUNDS - 2) {
929
+ results.push({ type: 'text', text: 'No more tool calls: answer now from what you have.' });
930
+ force = { type: 'none' };
931
+ }
932
+ messages.push({ role: 'user', content: results });
933
+ }
934
+ return answer;
935
+ }
936
+ /** Swaps the history for a short summary. False with nothing to compact. */
937
+ async compact(client, s, doc, emit) {
938
+ if (doc.history.length < 2)
939
+ return false;
940
+ const msg = await client.messages.create({
941
+ model: s.model.id,
942
+ max_tokens: 1500,
943
+ system: `Summarise this chat between a person and ${this.name}, their assistant, so ${this.name} can carry on from the summary alone. Keep what the person wants, the facts and numbers found, decisions made and cards drafted or saved, and anything still open. Plain bullets, at most 400 words. No preamble.`,
944
+ messages: [
945
+ {
946
+ role: 'user',
947
+ content: doc.history
948
+ .map((t) => `${t.role === 'user' ? 'Person' : this.name}: ${t.content}`)
949
+ .join('\n\n'),
950
+ },
951
+ ],
952
+ });
953
+ s.meter.add(msg.usage);
954
+ const summary = msg.content
955
+ .filter((b) => b.type === 'text')
956
+ .map((b) => b.text)
957
+ .join('');
958
+ doc.history = [
959
+ { role: 'user', content: `Summary of our chat so far (compacted):\n${summary}` },
960
+ { role: 'assistant', content: 'Noted. I carry on from this summary.' },
961
+ ];
962
+ doc.items.push({ kind: 'compacted' });
963
+ emit('compacted', {});
964
+ return true;
965
+ }
966
+ /** How the person's cards ended since the last answer, once each. */
967
+ cardNews(doc) {
968
+ const told = new Set(doc.told ?? []);
969
+ const news = draftsOf(doc.items).filter((d) => d.status !== 'open' && !told.has(d.id));
970
+ if (!news.length)
971
+ return '';
972
+ doc.told = [...told, ...news.map((d) => d.id)];
973
+ return `Cards since your last answer:\n${news
974
+ .map((d) => `- ${d.title} ${JSON.stringify(d.args).slice(0, 300)}: ${d.status}${d.error ? ` (${d.error})` : ''}${d.result !== undefined ? ` → ${JSON.stringify(d.result).slice(0, 300)}` : ''}`)
975
+ .join('\n')}\n\n`;
976
+ }
977
+ /** The answer as later turns remember it: with what its tools returned, cut. */
978
+ remember(answer, runs) {
979
+ if (!runs.length)
980
+ return answer;
981
+ let left = RAN_TOTAL;
982
+ const lines = [];
983
+ for (const r of runs) {
984
+ if (left <= 0)
985
+ break;
986
+ const line = `- ${r.tool} → ${r.result.slice(0, Math.min(RAN_CHARS, left))}`;
987
+ left -= line.length;
988
+ lines.push(line);
989
+ }
990
+ return `${answer}\n\n[Tools I used and what they returned]\n${lines.join('\n')}`;
991
+ }
992
+ // ---------------------------------------------------------------- keys
993
+ /** One free call (reading a model) that says whether Claude takes the key. */
994
+ async testKey(apiKey) {
995
+ const at = Date.now();
996
+ const model = this.modelById(this.tiers[0].model) ?? this.models[0];
997
+ try {
998
+ await this.client(apiKey).models.retrieve(model.id);
999
+ return { ok: true, detail: 'Claude took the key.', at };
1000
+ }
1001
+ catch (err) {
1002
+ return { ok: false, detail: shownError(err).message, at };
1003
+ }
1004
+ }
1005
+ /** Lists ScrapeQL's tools with the token: works or why not. */
1006
+ async testScrapeql(token) {
1007
+ const at = Date.now();
1008
+ const url = this.o.web?.scrapeql?.url;
1009
+ if (!url)
1010
+ return { ok: false, detail: 'ScrapeQL is not set up in this app.', at };
1011
+ try {
1012
+ const open = await scrapeqlTools(url, token, this.o.fetch)();
1013
+ const n = open.tools.length;
1014
+ await open.close();
1015
+ return { ok: true, detail: `ScrapeQL answered with ${n} tool${n === 1 ? '' : 's'}.`, at };
1016
+ }
1017
+ catch {
1018
+ return { ok: false, detail: 'ScrapeQL refused the token or could not be reached.', at };
1019
+ }
1020
+ }
1021
+ // ---------------------------------------------------------------- drafts
1022
+ /** Saves (runs, as the person) or dismisses a drafted card. Access is checked again first. */
1023
+ async settleDraft(caller, chatId, draftId, action) {
1024
+ const doc = await this.state.docs.read(caller.id, `chat:${chatId}`);
1025
+ if (!doc)
1026
+ throw new AssistantError('That chat is gone.', 'not_found', 404);
1027
+ const d = draftsOf(doc.items).find((x) => x.id === draftId);
1028
+ if (!d)
1029
+ throw new AssistantError('That card is gone.', 'not_found', 404);
1030
+ if (d.status !== 'open')
1031
+ throw new AssistantError(`That card is already ${d.status}.`, 'settled', 409);
1032
+ if (action === 'dismiss')
1033
+ d.status = 'dismissed';
1034
+ else {
1035
+ const p = await this.state.person(caller.id);
1036
+ const tools = await this.openTools(caller, p);
1037
+ try {
1038
+ const f = tools.fns.get(d.tool);
1039
+ if (!f?.draft) {
1040
+ d.status = 'failed';
1041
+ d.error = 'Its tools are no longer on Read + draft in Settings ▸ Tools.';
1042
+ }
1043
+ else {
1044
+ try {
1045
+ d.result = await withDeadline(d.title, 60_000, f.open.call(f.def.name, d.args));
1046
+ d.status = 'saved';
1047
+ }
1048
+ catch (err) {
1049
+ d.status = 'failed';
1050
+ d.error = err instanceof Error ? err.message.slice(0, 300) : 'failed';
1051
+ }
1052
+ }
1053
+ }
1054
+ finally {
1055
+ await tools.close();
1056
+ }
1057
+ }
1058
+ await this.writeChat(caller, chatId, doc);
1059
+ return d;
1060
+ }
1061
+ }
1062
+ export function createAssistant(o) {
1063
+ return new Assistant(o);
1064
+ }