@columenlabs/lumen-ai 0.1.77 → 0.1.79

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.
@@ -8,15 +8,20 @@
8
8
  *
9
9
  * Hand off: an `escalate` tool hands one task to a higher tier with the same tools, in its own
10
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.
11
+ * read the chat and send a plan (it runs nothing), at most `ADVICE_MAX` an answer (Claude only).
12
+ *
13
+ * Providers: a tier runs a Claude model (Anthropic's Messages API) or an OpenAI model (the
14
+ * Responses API), each on its payer's key of that provider. A hand-off may cross providers.
12
15
  *
13
16
  * Spend: every call is priced at list price as it runs; past what its payer has left this month the
14
17
  * model must answer from what it has.
15
18
  */
16
19
  import Anthropic from '@anthropic-ai/sdk';
17
20
  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';
21
+ import OpenAI from 'openai';
22
+ import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
23
+ import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, effortParams, } from './models.js';
24
+ import { DEFAULT_CAP, State, keyDocName, keyState, waitText, } from './state.js';
20
25
  import { Docs } from './store.js';
21
26
  import { ToolError, rowCount, toolName, withDeadline, } from './tools.js';
22
27
  import { scrapeqlTools, webFetchTools } from './web.js';
@@ -92,6 +97,10 @@ function untilStopped(work, signal) {
92
97
  });
93
98
  }
94
99
  const newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 20)}`;
100
+ /** Every tool line in a chat, those in hand-off sections included. */
101
+ function stepsOf(items) {
102
+ return items.flatMap((i) => i.kind === 'step' ? [i] : i.kind === 'handoff' ? stepsOf(i.items) : []);
103
+ }
95
104
  /** Every draft in a chat, those in hand-off sections included. */
96
105
  function draftsOf(items) {
97
106
  return items.flatMap((i) => i.kind === 'draft' ? [i] : i.kind === 'handoff' ? draftsOf(i.items) : []);
@@ -100,23 +109,27 @@ function draftsOf(items) {
100
109
  function shownError(err) {
101
110
  if (err instanceof AssistantError)
102
111
  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)
112
+ const api = err instanceof Anthropic.APIError
113
+ ? { who: 'Claude', status: err.status }
114
+ : err instanceof OpenAI.APIError
115
+ ? { who: 'OpenAI', status: err.status }
116
+ : null;
117
+ if (api) {
118
+ const { who, status } = api;
119
+ if (status === 401)
120
+ return { message: `The ${who} key was refused.`, code: 'key_refused' };
121
+ if (status === 429)
107
122
  return {
108
- message: 'Claude is busy or the key hit its rate limit. Try again shortly.',
123
+ message: `${who} is busy or the key hit its rate limit. Try again shortly.`,
109
124
  code: 'rate_limited',
110
125
  };
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
- };
126
+ if (status === 529 || (status ?? 0) >= 500)
127
+ return { message: `${who} is overloaded. Try again shortly.`, code: 'overloaded' };
128
+ return { message: `${who} refused the request (${status ?? 'error'}).`, code: 'model_error' };
117
129
  }
118
130
  return { message: 'Something went wrong while answering.', code: 'internal' };
119
131
  }
132
+ const isApiError = (err) => err instanceof Anthropic.APIError || err instanceof OpenAI.APIError;
120
133
  export class Assistant {
121
134
  o;
122
135
  name;
@@ -131,7 +144,7 @@ export class Assistant {
131
144
  throw new Error('An assistant needs at least one tier');
132
145
  this.name = o.name;
133
146
  this.tiers = o.tiers;
134
- this.models = o.models ?? CLAUDE_MODELS;
147
+ this.models = o.models ?? ALL_MODELS;
135
148
  this.tasks = o.tasks ?? [
136
149
  { id: 'chat', label: 'Chat', about: 'The side panel', tier: o.tiers[0].id },
137
150
  ];
@@ -140,12 +153,31 @@ export class Assistant {
140
153
  timeZone: o.timeZone ?? 'UTC',
141
154
  defaultCap: o.defaultCap ?? DEFAULT_CAP,
142
155
  ...(o.appKey ? { appKey: o.appKey } : {}),
156
+ ...(o.fetch ? { fetch: o.fetch } : {}),
143
157
  });
144
158
  }
145
- client(apiKey) {
159
+ anthropic(apiKey) {
146
160
  const f = this.o.fetch;
147
161
  return new Anthropic({ apiKey, maxRetries: 1, ...(f ? { fetch: f } : {}) });
148
162
  }
163
+ openai(apiKey, plan = false) {
164
+ const f = this.o.fetch;
165
+ // A plan sign-in's calls are not retried: a usage limit is final for now.
166
+ return new OpenAI({ apiKey, maxRetries: plan ? 0 : 1, ...(f ? { fetch: f } : {}) });
167
+ }
168
+ clients(paying) {
169
+ const { keys, plans } = paying;
170
+ return {
171
+ ...(keys.anthropic ? { anthropic: this.anthropic(keys.anthropic) } : {}),
172
+ ...(keys.openai
173
+ ? { openai: this.openai(keys.openai, !!plans.openai), openaiPlan: !!plans.openai }
174
+ : {}),
175
+ };
176
+ }
177
+ /** The providers the app's models run on, in the order the models list them. */
178
+ get providers() {
179
+ return [...new Set(this.models.map((m) => m.provider))];
180
+ }
149
181
  tierById(id) {
150
182
  return this.tiers.find((t) => t.id === id);
151
183
  }
@@ -177,20 +209,42 @@ export class Assistant {
177
209
  return (this.tierById(task === 'chat' ? (p.tier ?? startId) : startId) ?? this.tiers[0]);
178
210
  }
179
211
  access(p, g) {
180
- return p.access?.[g.id] ?? g.access ?? 'read';
212
+ const a = p.access?.[g.id] ?? g.access ?? 'read';
213
+ return a === 'write' && !g.writes ? 'draft' : a;
214
+ }
215
+ groupById(id) {
216
+ return this.groups.find((g) => g.id === id);
181
217
  }
182
218
  // ---------------------------------------------------------------- status and settings
183
219
  async status(caller) {
184
- const [p, appDoc, appKey, own, scrapeql, spend, appCap, { paying, wait }, main] = await Promise.all([
220
+ const main = await this.mainTier(caller);
221
+ const mainModel = (await this.pick(caller, main)).model;
222
+ const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }] = await Promise.all([
185
223
  this.state.person(caller.id),
186
- this.state.appKey(),
187
- this.state.appApiKey(),
188
- this.state.ownKey(caller.id),
224
+ Promise.all(this.providers.map(async (id) => {
225
+ const [doc, key, own] = await Promise.all([
226
+ this.state.appKey(id),
227
+ this.state.appApiKey(id),
228
+ this.state.ownKey(caller.id, id),
229
+ ]);
230
+ const app = caller.admin
231
+ ? keyState(doc, (d) => d.api_key)
232
+ : { set: !!key };
233
+ return {
234
+ id,
235
+ label: PROVIDERS[id].label,
236
+ prefix: PROVIDERS[id].prefix,
237
+ keys_url: PROVIDERS[id].keysUrl,
238
+ ...(PROVIDERS[id].signin ? { signin: PROVIDERS[id].signin } : {}),
239
+ app: { ...app, ...(key?.fromEnv ? { from_env: true } : {}) },
240
+ own: keyState(own, (d) => d.api_key),
241
+ };
242
+ })),
189
243
  this.o.web?.scrapeql ? this.state.scrapeql() : Promise.resolve(null),
190
244
  this.state.spend(caller.id),
191
245
  this.state.appCap(),
192
- this.state.paying(caller.id),
193
- this.mainTier(caller),
246
+ this.state.ownCap(caller.id),
247
+ this.state.paying(caller.id, mainModel.provider),
194
248
  ]);
195
249
  const tiers = await Promise.all(this.tiers.map(async (t) => {
196
250
  const { model, effort } = await this.pick(caller, t);
@@ -200,19 +254,28 @@ export class Assistant {
200
254
  about: t.about,
201
255
  model: model.id,
202
256
  model_label: model.label,
257
+ provider: model.provider,
203
258
  effort,
204
259
  };
205
260
  }));
206
- const appState = caller.admin
207
- ? keyState(appDoc, (d) => d.api_key)
208
- : { set: !!appKey };
209
261
  return {
210
262
  name: this.name,
263
+ commands: (this.o.commands ?? []).map((c) => ({
264
+ name: c.name,
265
+ about: c.about,
266
+ ...(c.args ? { args: c.args } : {}),
267
+ })),
211
268
  wait,
269
+ ...(wait ? { wait_provider: mainModel.provider } : {}),
212
270
  admin: caller.admin,
213
271
  tier: main.id,
214
272
  tiers,
215
- models: this.models.map((m) => ({ id: m.id, label: m.label, group: m.group })),
273
+ models: this.models.map((m) => ({
274
+ id: m.id,
275
+ label: m.label,
276
+ group: m.group,
277
+ provider: m.provider,
278
+ })),
216
279
  tasks: this.tasks.map((t) => ({
217
280
  id: t.id,
218
281
  label: t.label,
@@ -225,6 +288,7 @@ export class Assistant {
225
288
  about: g.about,
226
289
  access: this.access(p, g),
227
290
  drafts: true,
291
+ writes: !!g.writes,
228
292
  })),
229
293
  hand_off: p.hand_off ?? 'next',
230
294
  advice: p.advice && (p.advice === 'off' || this.tierById(p.advice)) ? p.advice : 'off',
@@ -236,13 +300,8 @@ export class Assistant {
236
300
  : { set: !!scrapeql }
237
301
  : null,
238
302
  },
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
- },
303
+ providers,
304
+ caps: { app: appCap, own: ownCap },
246
305
  pay: p.pay ?? 'app',
247
306
  spend: {
248
307
  month: this.state.month(),
@@ -317,12 +376,15 @@ export class Assistant {
317
376
  const fns = new Map();
318
377
  for (const o of opened)
319
378
  for (const def of o.open.tools) {
320
- if (!def.read && o.access !== 'draft')
379
+ if (!def.read && o.access !== 'draft' && o.access !== 'write')
321
380
  continue;
381
+ // Read + write on a group that offers no writes drafts.
382
+ const write = !def.read && o.access === 'write' && !!this.groupById(o.group)?.writes;
322
383
  fns.set(toolName(o.group, def.name), {
323
384
  group: o.group,
324
385
  def,
325
- draft: !def.read,
386
+ draft: !def.read && !write,
387
+ write,
326
388
  open: o.open,
327
389
  });
328
390
  }
@@ -335,7 +397,7 @@ export class Assistant {
335
397
  toolsFor(fns) {
336
398
  return [...fns.entries()].map(([name, f]) => ({
337
399
  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),
400
+ description: `${f.draft ? "DRAFT ONLY: this call changes nothing. It puts a card on the person's screen that they Save or Dismiss. " : f.write ? 'WRITES AT ONCE: this call makes the change now; the person can undo it. ' : ''}${f.def.title ? `${f.def.title}. ` : ''}${f.def.description ?? ''}`.slice(0, 4000),
339
401
  input_schema: { type: 'object', ...f.def.inputSchema },
340
402
  }));
341
403
  }
@@ -391,6 +453,7 @@ export class Assistant {
391
453
  history: [],
392
454
  };
393
455
  const compactOnly = message === '/compact';
456
+ const asked = await this.expand(caller, message);
394
457
  if (!compactOnly)
395
458
  doc.items.push({
396
459
  kind: 'user',
@@ -402,20 +465,27 @@ export class Assistant {
402
465
  });
403
466
  const start = doc.items.length;
404
467
  emit('chat', { id: chatId, title: doc.title });
405
- const { paying, wait } = await this.state.paying(caller.id);
468
+ // `tier` on a message: a higher one takes it as a task in its own section (handTo below); the
469
+ // same or a lower one answers it as the main tier, for this message only.
470
+ const picked = await this.mainTier(caller);
471
+ const lower = req.tier && !this.above(picked).some((t) => t.id === req.tier)
472
+ ? this.tierById(req.tier)
473
+ : undefined;
474
+ const main = await this.session(caller, lower ?? picked);
475
+ const { paying, wait } = await this.state.paying(caller.id, main.model.provider);
406
476
  if (!paying) {
407
- const text = WAIT_TEXT[wait ?? 'not_connected'];
408
- doc.items.push({ kind: 'error', text, code: wait ?? 'not_connected' });
477
+ const code = wait ?? 'not_connected';
478
+ const text = waitText(code, main.model.provider);
479
+ doc.items.push({ kind: 'error', text, code });
409
480
  await this.writeChat(caller, chatId, doc);
410
- emit('error', { message: text, code: wait });
481
+ emit('error', { message: text, code });
411
482
  emit('done', { id: chatId, usage: doc.usage ?? NO_USAGE });
412
483
  return;
413
484
  }
414
485
  const p = await this.state.person(caller.id);
415
- const main = await this.session(caller, await this.mainTier(caller));
416
486
  const policy = p.hand_off ?? 'next';
417
487
  const advisor = await this.advisorFor(caller, p.advice, main);
418
- const client = this.client(paying.apiKey);
488
+ const clients = this.clients(paying);
419
489
  const live = [main.meter, ...(advisor ? [advisor.session.meter] : [])];
420
490
  const before = doc.usage ?? NO_USAGE;
421
491
  let context = before.context;
@@ -433,7 +503,8 @@ export class Assistant {
433
503
  const handTo = req.tier ? this.above(main.tier).find((t) => t.id === req.tier) : undefined;
434
504
  const tools = await this.openTools(caller, p);
435
505
  const runs = [];
436
- const counts = { steps: 0, handoffs: 0, advice: 0, end: main.tier };
506
+ doc.writes ??= {};
507
+ const counts = { steps: 0, handoffs: 0, advice: 0, end: main.tier, writes: doc.writes };
437
508
  let outcome = 'ok';
438
509
  try {
439
510
  const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
@@ -451,7 +522,7 @@ export class Assistant {
451
522
  },
452
523
  ];
453
524
  if (compactOnly || (before.context ?? 0) > COMPACT_AT) {
454
- const did = await this.compact(client, main, doc, emit);
525
+ const did = await this.compact(clients, main, doc, emit);
455
526
  if (did)
456
527
  context = undefined;
457
528
  if (compactOnly) {
@@ -470,7 +541,7 @@ export class Assistant {
470
541
  }
471
542
  const page = this.cardNews(doc) +
472
543
  (req.refs?.length
473
- ? `The person points at: ${JSON.stringify(req.refs).slice(0, 2000)}\n\n`
544
+ ? `The person points at: ${JSON.stringify(req.refs).slice(0, 8000)}\n\n`
474
545
  : '') +
475
546
  (req.context
476
547
  ? `Page: ${req.context.label}${req.context.note === undefined ? '' : `\n${JSON.stringify(req.context.note).slice(0, 4000)}`}\n\n`
@@ -480,11 +551,11 @@ export class Assistant {
480
551
  : '';
481
552
  const messages = [
482
553
  ...doc.history.map((t) => ({ role: t.role, content: t.content })),
483
- { role: 'user', content: `${page}${lead}${message}` },
554
+ { role: 'user', content: `${page}${lead}${asked}` },
484
555
  ];
485
556
  const escalateTool = this.escalateTool(main.tier);
486
557
  let answer = await this.loop({
487
- client,
558
+ clients,
488
559
  session: main,
489
560
  system,
490
561
  tools: [...this.toolsFor(tools.fns), ...(escalateTool ? [escalateTool] : [])],
@@ -502,8 +573,8 @@ export class Assistant {
502
573
  tool: (use) => use.name === 'escalate'
503
574
  ? this.handOff(use, {
504
575
  caller,
505
- client,
506
- message,
576
+ clients,
577
+ message: asked,
507
578
  handTo,
508
579
  page,
509
580
  policy,
@@ -531,7 +602,7 @@ export class Assistant {
531
602
  emit('text', { delta: answer });
532
603
  emit('text_end', {});
533
604
  }
534
- doc.history.push({ role: 'user', content: `${page}${message}` });
605
+ doc.history.push({ role: 'user', content: `${page}${asked}` });
535
606
  doc.history.push({ role: 'assistant', content: this.remember(answer, runs) });
536
607
  }
537
608
  catch (err) {
@@ -544,14 +615,14 @@ export class Assistant {
544
615
  .map((i) => i.text)
545
616
  .join('\n\n');
546
617
  if (!compactOnly) {
547
- doc.history.push({ role: 'user', content: message });
618
+ doc.history.push({ role: 'user', content: asked });
548
619
  doc.history.push({ role: 'assistant', content: said || '(stopped before answering)' });
549
620
  }
550
621
  }
551
622
  else {
552
623
  outcome = 'error';
553
624
  const shown = shownError(err);
554
- if (!(err instanceof AssistantError) && !(err instanceof Anthropic.APIError))
625
+ if (!(err instanceof AssistantError) && !isApiError(err))
555
626
  console.error(JSON.stringify({
556
627
  event: 'assistant_error',
557
628
  error: String(err?.stack ?? err).slice(0, 2000),
@@ -594,6 +665,7 @@ export class Assistant {
594
665
  `You are ${this.name}. Today is ${new Date().toISOString().slice(0, 10)}.`,
595
666
  '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
667
  '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.',
668
+ '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.',
597
669
  ];
598
670
  if (!fns.size)
599
671
  lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
@@ -615,10 +687,13 @@ export class Assistant {
615
687
  /** The advisor for this answer, or null when off. Never weaker than the model it advises. */
616
688
  async advisorFor(caller, setting, main) {
617
689
  const tier = setting && setting !== 'off' ? this.tierById(setting) : undefined;
618
- if (!tier)
690
+ if (!tier || main.model.provider !== 'anthropic')
619
691
  return null;
620
692
  const rank = (m) => this.models.indexOf(m);
621
693
  let session = await this.session(caller, tier);
694
+ // Anthropic's advisor tool runs a Claude model: another provider's tier advises nothing.
695
+ if (session.model.provider !== 'anthropic')
696
+ session = { ...main };
622
697
  if (rank(session.model) < rank(main.model))
623
698
  session = { ...main, meter: new Meter(main.model, '') };
624
699
  session.meter = new Meter(session.model, `${session.tier.label} · advice`);
@@ -645,6 +720,17 @@ export class Assistant {
645
720
  },
646
721
  };
647
722
  }
723
+ /** What the model is asked: an app command's prompt, else the message as typed. */
724
+ async expand(caller, message) {
725
+ const m = /^\/([\w-]+)(?:\s+([\s\S]*))?$/.exec(message);
726
+ const c = m && this.o.commands?.find((x) => x.name === m[1]);
727
+ if (!c)
728
+ return message;
729
+ const args = (m?.[2] ?? '').trim();
730
+ return typeof c.prompt === 'function'
731
+ ? c.prompt(args, caller)
732
+ : c.prompt.replaceAll('{args}', args);
733
+ }
648
734
  /** The tiers the person's message asks for by name ("go deeper" is the next one up). */
649
735
  askedFor(message, main) {
650
736
  const reach = this.above(main);
@@ -682,6 +768,8 @@ export class Assistant {
682
768
  if (c.over())
683
769
  return reply('No money left for a higher tier this answer: answer yourself.', true);
684
770
  const sub = await this.session(c.caller, tier);
771
+ if (!c.clients[sub.model.provider])
772
+ return reply(`${tier.label} runs on ${PROVIDERS[sub.model.provider].label} and no ${PROVIDERS[sub.model.provider].label} key is set for whoever pays this answer: answer yourself.`, true);
685
773
  c.live.push(sub.meter);
686
774
  c.counts.handoffs += 1;
687
775
  if (this.tiers.indexOf(tier) > this.tiers.indexOf(c.counts.end))
@@ -707,7 +795,7 @@ export class Assistant {
707
795
  let failed;
708
796
  try {
709
797
  memo = await this.loop({
710
- client: c.client,
798
+ clients: c.clients,
711
799
  session: sub,
712
800
  system: c.system,
713
801
  tools: this.toolsFor(c.tools),
@@ -776,6 +864,8 @@ export class Assistant {
776
864
  let rows;
777
865
  try {
778
866
  const result = await untilStopped(withDeadline(title, 60_000, f.open.call(f.def.name, use.input)), signal);
867
+ if (f.write && counts.writes)
868
+ counts.writes[id] = { group: f.group, tool: f.def.name, args: use.input, result };
779
869
  rows = rowCount(result);
780
870
  out = typeof result === 'string' ? result : JSON.stringify(result);
781
871
  }
@@ -797,6 +887,9 @@ export class Assistant {
797
887
  ms,
798
888
  ...(rows !== undefined ? { rows } : {}),
799
889
  ...(error ? { error } : {}),
890
+ ...(f.write && ok
891
+ ? { write: { undo: !!this.groupById(f.group)?.undo, status: 'done' } }
892
+ : {}),
800
893
  };
801
894
  sink.push(item);
802
895
  emit('step', item);
@@ -816,7 +909,156 @@ export class Assistant {
816
909
  return reply(cut, !ok);
817
910
  }
818
911
  /** Model calls and tool rounds until the model answers; returns its words. */
819
- async loop(o) {
912
+ loop(o) {
913
+ const { provider } = o.session.model;
914
+ const label = PROVIDERS[provider].label;
915
+ if (provider === 'openai') {
916
+ if (!o.clients.openai)
917
+ throw new AssistantError(`No ${label} key is set for this answer.`, 'not_connected');
918
+ return this.loopOpenAI({ ...o, client: o.clients.openai, plan: !!o.clients.openaiPlan });
919
+ }
920
+ if (!o.clients.anthropic)
921
+ throw new AssistantError(`No ${label} key is set for this answer.`, 'not_connected');
922
+ return this.loopClaude({ ...o, client: o.clients.anthropic });
923
+ }
924
+ /** The loop on OpenAI's Responses API: the same tools, Stop, caps and rounds as Claude's. */
925
+ async loopOpenAI(o) {
926
+ const { client, session, tools, sink, emit, signal, plan } = o;
927
+ const instructions = o.system.map((b) => b.text).join('\n\n');
928
+ const fnTools = tools.map((t) => ({
929
+ type: 'function',
930
+ name: t.name,
931
+ description: t.description ?? null,
932
+ parameters: t.input_schema,
933
+ strict: false,
934
+ }));
935
+ // The chat so far is plain text turns; this answer's calls are appended as OpenAI items.
936
+ const input = o.messages.map((m) => ({
937
+ role: m.role,
938
+ content: typeof m.content === 'string'
939
+ ? m.content
940
+ : m.content
941
+ .map((b) => (b.type === 'text' ? b.text : ''))
942
+ .filter(Boolean)
943
+ .join('\n'),
944
+ }));
945
+ let answer = '';
946
+ let force = o.first?.type === 'tool' ? { type: 'function', name: o.first.name } : undefined;
947
+ // A plan sign-in takes function tools only inside a namespace and no output cap.
948
+ const sent = (only) => {
949
+ const fns = only ? fnTools.filter((t) => t.name === only) : fnTools;
950
+ if (!plan)
951
+ return { tools: fns, max_output_tokens: session.tier.out ?? DEFAULT_OUT };
952
+ return {
953
+ tools: [
954
+ {
955
+ type: 'namespace',
956
+ name: 'app',
957
+ description: `${this.name}'s tools`,
958
+ tools: fns.map(({ strict: _, ...t }) => t),
959
+ },
960
+ ],
961
+ };
962
+ };
963
+ for (let round = 0; round < MAX_ROUNDS; round++) {
964
+ signal?.throwIfAborted();
965
+ const effort = session.effort === 'default' ? undefined : session.effort;
966
+ // On a plan a forced call is the one tool offered, with any call required.
967
+ const only = plan && typeof force === 'object' ? force.name : undefined;
968
+ const choice = only ? 'required' : force;
969
+ const stream = client.responses.stream({
970
+ model: session.model.id,
971
+ instructions,
972
+ input,
973
+ ...sent(only),
974
+ store: false,
975
+ stream: true,
976
+ include: ['reasoning.encrypted_content'],
977
+ ...(effort ? { reasoning: { effort } } : {}),
978
+ ...(choice ? { tool_choice: choice } : {}),
979
+ }, signal ? { signal } : {});
980
+ let partial = '';
981
+ stream.on('response.output_text.delta', (e) => {
982
+ partial += e.delta;
983
+ emit('text', { delta: e.delta });
984
+ });
985
+ const res = await stream.finalResponse().catch((err) => {
986
+ if (signal?.aborted && partial)
987
+ sink.push({ kind: 'text', text: partial });
988
+ throw err;
989
+ });
990
+ if (res.status === 'failed') {
991
+ if (partial)
992
+ sink.push({ kind: 'text', text: partial });
993
+ const code = res.error?.code ?? '';
994
+ throw new AssistantError(/usage_limit/.test(code)
995
+ ? 'Your ChatGPT plan reached its usage limit for now: try again later, or pick another model.'
996
+ : 'OpenAI could not finish the answer.', 'upstream');
997
+ }
998
+ const u = res.usage;
999
+ if (u) {
1000
+ const read = u.input_tokens_details?.cached_tokens ?? 0;
1001
+ const written = u.input_tokens_details?.cache_write_tokens ?? 0;
1002
+ session.meter.add({
1003
+ input_tokens: Math.max(0, u.input_tokens - read - written),
1004
+ output_tokens: u.output_tokens,
1005
+ cache_read_input_tokens: read,
1006
+ cache_creation_input_tokens: written,
1007
+ }, plan);
1008
+ o.context?.(u.input_tokens + u.output_tokens);
1009
+ }
1010
+ o.called();
1011
+ const cut = res.incomplete_details?.reason === 'max_output_tokens' ? ' …(cut)' : '';
1012
+ if (cut)
1013
+ emit('text', { delta: cut });
1014
+ const text = (res.output_text ?? '') + cut;
1015
+ if (text) {
1016
+ answer += (answer ? '\n\n' : '') + text;
1017
+ sink.push({ kind: 'text', text });
1018
+ emit('text_end', {});
1019
+ }
1020
+ // Reasoning, words and calls go back as they came, so the model keeps its own thread.
1021
+ input.push(...res.output);
1022
+ const calls = res.output.filter((b) => b.type === 'function_call');
1023
+ if (calls.length === 0)
1024
+ break;
1025
+ const results = await Promise.all(calls.map((c) => {
1026
+ let args = {};
1027
+ try {
1028
+ args = c.arguments ? JSON.parse(c.arguments) : {};
1029
+ }
1030
+ catch { }
1031
+ const use = {
1032
+ type: 'tool_use',
1033
+ id: c.call_id,
1034
+ name: c.name,
1035
+ input: args,
1036
+ };
1037
+ return untilStopped(o.tool(use), signal);
1038
+ }));
1039
+ for (const r of results) {
1040
+ const body = typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? '');
1041
+ input.push({
1042
+ type: 'function_call_output',
1043
+ call_id: r.tool_use_id,
1044
+ output: r.is_error ? `Error: ${body}` : body,
1045
+ });
1046
+ }
1047
+ force = undefined;
1048
+ const limit = o.over();
1049
+ const last = round === MAX_ROUNDS - 2;
1050
+ if (limit || last) {
1051
+ input.push({
1052
+ role: 'user',
1053
+ content: limit ?? 'No more tool calls: answer now from what you have.',
1054
+ });
1055
+ force = 'none';
1056
+ }
1057
+ }
1058
+ return answer;
1059
+ }
1060
+ /** The loop on Anthropic's Messages API, with the advisor and prompt caching. */
1061
+ async loopClaude(o) {
820
1062
  const { client, session, system, tools, messages, sink, emit, signal } = o;
821
1063
  let answer = '';
822
1064
  let force = o.first;
@@ -934,27 +1176,46 @@ export class Assistant {
934
1176
  return answer;
935
1177
  }
936
1178
  /** Swaps the history for a short summary. False with nothing to compact. */
937
- async compact(client, s, doc, emit) {
1179
+ async compact(clients, s, doc, emit) {
938
1180
  if (doc.history.length < 2)
939
1181
  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('');
1182
+ const 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.`;
1183
+ const chat = doc.history
1184
+ .map((t) => `${t.role === 'user' ? 'Person' : this.name}: ${t.content}`)
1185
+ .join('\n\n');
1186
+ let summary = '';
1187
+ if (s.model.provider === 'openai' && clients.openai) {
1188
+ const res = await clients.openai.responses.create({
1189
+ model: s.model.id,
1190
+ instructions: system,
1191
+ input: chat,
1192
+ store: false,
1193
+ max_output_tokens: 1500,
1194
+ });
1195
+ const u = res.usage;
1196
+ if (u)
1197
+ s.meter.add({
1198
+ input_tokens: u.input_tokens - (u.input_tokens_details?.cached_tokens ?? 0),
1199
+ output_tokens: u.output_tokens,
1200
+ cache_read_input_tokens: u.input_tokens_details?.cached_tokens ?? 0,
1201
+ });
1202
+ summary = res.output_text ?? '';
1203
+ }
1204
+ else if (clients.anthropic) {
1205
+ const msg = await clients.anthropic.messages.create({
1206
+ model: s.model.id,
1207
+ max_tokens: 1500,
1208
+ system,
1209
+ messages: [{ role: 'user', content: chat }],
1210
+ });
1211
+ s.meter.add(msg.usage);
1212
+ summary = msg.content
1213
+ .filter((b) => b.type === 'text')
1214
+ .map((b) => b.text)
1215
+ .join('');
1216
+ }
1217
+ else
1218
+ return false;
958
1219
  doc.history = [
959
1220
  { role: 'user', content: `Summary of our chat so far (compacted):\n${summary}` },
960
1221
  { role: 'assistant', content: 'Noted. I carry on from this summary.' },
@@ -967,10 +1228,16 @@ export class Assistant {
967
1228
  cardNews(doc) {
968
1229
  const told = new Set(doc.told ?? []);
969
1230
  const news = draftsOf(doc.items).filter((d) => d.status !== 'open' && !told.has(d.id));
970
- if (!news.length)
1231
+ const undone = stepsOf(doc.items).filter((x) => x.write?.status === 'undone' && !told.has(x.id));
1232
+ if (!news.length && !undone.length)
971
1233
  return '';
972
- doc.told = [...told, ...news.map((d) => d.id)];
973
- return `Cards since your last answer:\n${news
1234
+ doc.told = [...told, ...news.map((d) => d.id), ...undone.map((x) => x.id)];
1235
+ const back = undone.length
1236
+ ? `The person undid these changes you made: ${undone.map((x) => `${x.title} ${JSON.stringify(doc.writes?.[x.id]?.args ?? {}).slice(0, 300)}`).join('; ')}. Do not make them again unless asked.\n\n`
1237
+ : '';
1238
+ if (!news.length)
1239
+ return back;
1240
+ return `${back}Cards since your last answer:\n${news
974
1241
  .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
1242
  .join('\n')}\n\n`;
976
1243
  }
@@ -990,13 +1257,61 @@ export class Assistant {
990
1257
  return `${answer}\n\n[Tools I used and what they returned]\n${lines.join('\n')}`;
991
1258
  }
992
1259
  // ---------------------------------------------------------------- keys
993
- /** One free call (reading a model) that says whether Claude takes the key. */
994
- async testKey(apiKey) {
1260
+ /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
1261
+ async startSignin(caller, provider) {
1262
+ if (PROVIDERS[provider].signin !== 'chatgpt')
1263
+ throw new AssistantError(`${PROVIDERS[provider].label} has no sign-in.`, 'no_signin', 404);
1264
+ const name = `signin:${provider}`;
1265
+ const { url, reg } = await startChatgpt(await this.state.docs.read(caller.id, name), this.name);
1266
+ await this.state.docs.write(caller.id, name, reg);
1267
+ return { url, redirect: CHATGPT_REDIRECT };
1268
+ }
1269
+ /** Finishes it from the address it ended on; the plan then stands in for the person's own key. */
1270
+ async finishSignin(caller, provider, pasted) {
1271
+ const name = `signin:${provider}`;
1272
+ try {
1273
+ const { tokens, reg } = await finishChatgpt(await this.state.docs.read(caller.id, name), pasted, this.o.fetch ?? fetch);
1274
+ await this.state.docs.write(caller.id, name, reg);
1275
+ const test = await testChatgpt(tokens, this.o.fetch ?? fetch);
1276
+ await this.state.docs.write(caller.id, keyDocName(provider), {
1277
+ api_key: tokens.access_token,
1278
+ saved_at: Date.now(),
1279
+ test,
1280
+ chatgpt: tokens,
1281
+ });
1282
+ }
1283
+ catch (err) {
1284
+ if (err instanceof SigninError)
1285
+ throw new AssistantError(err.message, 'signin_failed');
1286
+ throw err;
1287
+ }
1288
+ }
1289
+ /** Tests a person's own key, refreshing a plan sign-in first. */
1290
+ async testOwn(caller, provider) {
1291
+ const d = await this.state.ownKey(caller.id, provider);
1292
+ if (!d)
1293
+ return null;
1294
+ if (!d.chatgpt)
1295
+ return this.testKey(provider, d.api_key);
1296
+ const fresh = await this.state.ownApiKey(caller.id, provider);
1297
+ const now = await this.state.ownKey(caller.id, provider);
1298
+ if (!fresh || !now?.chatgpt)
1299
+ return now?.test ?? { ok: false, detail: 'Sign in again.', at: Date.now() };
1300
+ return testChatgpt(now.chatgpt, this.o.fetch ?? fetch);
1301
+ }
1302
+ /** One free call (reading a model) that says whether the provider takes the key. */
1303
+ async testKey(provider, apiKey) {
995
1304
  const at = Date.now();
996
- const model = this.modelById(this.tiers[0].model) ?? this.models[0];
1305
+ const model = this.models.find((m) => m.provider === provider);
1306
+ const label = PROVIDERS[provider].label;
1307
+ if (!model)
1308
+ return { ok: false, detail: `This app runs no ${label} models.`, at };
997
1309
  try {
998
- await this.client(apiKey).models.retrieve(model.id);
999
- return { ok: true, detail: 'Claude took the key.', at };
1310
+ if (provider === 'openai')
1311
+ await this.openai(apiKey).models.retrieve(model.id);
1312
+ else
1313
+ await this.anthropic(apiKey).models.retrieve(model.id);
1314
+ return { ok: true, detail: `${label} took the key.`, at };
1000
1315
  }
1001
1316
  catch (err) {
1002
1317
  return { ok: false, detail: shownError(err).message, at };
@@ -1036,9 +1351,9 @@ export class Assistant {
1036
1351
  const tools = await this.openTools(caller, p);
1037
1352
  try {
1038
1353
  const f = tools.fns.get(d.tool);
1039
- if (!f?.draft) {
1354
+ if (!f || f.def.read) {
1040
1355
  d.status = 'failed';
1041
- d.error = 'Its tools are no longer on Read + draft in Settings ▸ Tools.';
1356
+ d.error = 'Its tools are no longer on Read + draft or Read + write in Settings ▸ Tools.';
1042
1357
  }
1043
1358
  else {
1044
1359
  try {
@@ -1058,6 +1373,30 @@ export class Assistant {
1058
1373
  await this.writeChat(caller, chatId, doc);
1059
1374
  return d;
1060
1375
  }
1376
+ /** Takes back one change a write made at once, through its group's `undo`. */
1377
+ async undoWrite(caller, chatId, stepId) {
1378
+ const doc = await this.state.docs.read(caller.id, `chat:${chatId}`);
1379
+ if (!doc)
1380
+ throw new AssistantError('That chat is gone.', 'not_found', 404);
1381
+ const step = stepsOf(doc.items).find((x) => x.id === stepId);
1382
+ const w = doc.writes?.[stepId];
1383
+ if (!step?.write || !w)
1384
+ throw new AssistantError('That change is gone.', 'not_found', 404);
1385
+ if (step.write.status === 'undone')
1386
+ throw new AssistantError('That change is already undone.', 'settled', 409);
1387
+ const g = this.groupById(w.group);
1388
+ if (!g?.undo)
1389
+ throw new AssistantError('That change cannot be undone here.', 'no_undo', 400);
1390
+ try {
1391
+ await withDeadline(`Undo ${step.title}`, 60_000, g.undo(caller, w));
1392
+ step.write = { ...step.write, status: 'undone' };
1393
+ }
1394
+ catch (err) {
1395
+ throw new AssistantError(err instanceof Error ? err.message.slice(0, 300) : 'Undo failed.', 'undo_failed', 502);
1396
+ }
1397
+ await this.writeChat(caller, chatId, doc);
1398
+ return step;
1399
+ }
1061
1400
  }
1062
1401
  export function createAssistant(o) {
1063
1402
  return new Assistant(o);