@columenlabs/lumen-ai 0.1.78 → 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.
package/README.md CHANGED
@@ -98,7 +98,46 @@ app.all('/api/assistant/*', (c) => routes(c.req.raw));
98
98
  - **Keys.** Each provider has its own keys (`?provider=anthropic|openai` on the key routes). An admin pastes the shared key everyone uses (`PUT /key`); it is tested with one free call before it is kept, then sealed, and no route returns it. A person may add their own (`PUT /my-key`), with one monthly cap on their own keys (`PUT /my-key/cap`).
99
99
  - **ChatGPT sign-in.** In place of their own OpenAI key a person may sign in with their ChatGPT plan (`POST /my-signin`, then `POST /my-signin/finish {url}`): the sign-in opens in a new tab and ends on `http://127.0.0.1:1455/auth/callback`, a page that does not load, whose address the person pastes back. The tokens are sealed as their own key and refreshed before they run out; answers on the plan count tokens and no dollars.
100
100
  - **Who pays.** The person's pick (the shared keys by default) while it has a key for the tier's provider and money left this month under its cap (an admin sets the shared keys' cap per person, `PUT /app`), else the other; with neither the panel says why.
101
- - **Tools.** Each group is Off, Read or Read + draft per person. A tool is a read when its MCP `readOnlyHint` is set; a write only drafts a card, and runs as the person when they press Save, after access is checked again. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
101
+ - **Tools.** Each group is Off, Read, Read + draft or Read + write per person. A tool is a read when its MCP `readOnlyHint` is set. On Read + draft a write only drafts a card, and runs as the person when they press Save, after access is checked again. Read + write is offered only for a group with `writes: true`: its writes run at once, and with `undo` set each one's line in the chat has Undo, which calls `undo(caller, { tool, args, result })` (`POST /chats/:id/writes/:step/undo`); the model is told what was undone. Cards drafted one after another show as one stack with Save all. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
102
102
  - **Web.** `fetch` is a plain GET from the Worker (10 s, 2 MB, never the app's own hosts or private addresses). ScrapeQL's own tools join over its MCP API once an admin pastes a Cloudflare Access service token (`PUT /web/scrapeql`).
103
103
  - **Hand off and advice.** The `escalate` tool hands one task to a higher tier (the person sets next, any or only when asked); Anthropic's advisor tool lets a higher tier send a plan, at most two an answer.
104
104
  - **Chats** stream as server-sent events (`POST /chat`); closing the request stops the answer and keeps what was said. Past 150k tokens, or on `/compact`, the history sent to the model becomes a summary. Run `assistant.purge()` once a day to drop chats 30 days after their last message.
105
+
106
+ ### Commands, `@` and `#`
107
+
108
+ The panel's composer lists what can follow three keys as they are typed. The kit's own entries are on for every app; an app only adds its own.
109
+
110
+ - **`/` commands.** Built in: `/compact` (summarise the chat), `/clear` (a new chat), `/model <tier>` (answer on another tier from now on) and `/escalate <task>` (hand one task to the next tier up). An app adds its own on the server with `commands`: the chat shows what the person typed and the model gets `prompt`, with `{args}` replaced by the words after the name (or what the function returns). An app's command wins a built-in's name.
111
+
112
+ ```ts
113
+ createAssistant({
114
+ // ...
115
+ commands: [
116
+ { name: 'story', about: 'Find stories for a question', args: '<question>',
117
+ prompt: 'Search my stories for: {args}. List the best three with why each fits.' },
118
+ { name: 'prep', about: 'Prep for an interview', args: '<company>',
119
+ prompt: async (company, caller) => prepPrompt(caller.id, company) },
120
+ ],
121
+ });
122
+ ```
123
+
124
+ A command that acts in the browser (opens a page, sets a filter) goes on the panel instead: `LumenAssistant` (or `useLumenAssistantDock`) takes `commands`, each `{ name, about, args?, options?, run }`; `run` returns text to send, or nothing when it acted itself, and `options` lists choices for its words as they are typed.
125
+ - **`@` a tier.** `@` lists the app's tiers; the pick is a chip and goes with that message only (`tier`). A higher tier takes it as a task in its own section of the chat; the same or a lower one answers it.
126
+ - **`#` a thing.** `#` lists what the person can point at, a group per source; each pick is a chip and goes with the message as a ref (`kind`, `label` and whatever finds it), which the model reads before the message. Standing sources go on the panel (`sources`); a page adds its own while it is shown:
127
+
128
+ ```tsx
129
+ import { useAssistantSource } from '@columenlabs/lumen-ui/assistant';
130
+
131
+ useAssistantSource({
132
+ id: 'page',
133
+ label: 'On this page',
134
+ items: (q) =>
135
+ rows
136
+ .filter((r) => r.title.toLowerCase().includes(q.toLowerCase()))
137
+ .map((r) => ({ id: r.id, label: r.title, target: `card:${r.id}`, ref: { kind: 'story', label: r.title, id: r.id } })),
138
+ });
139
+ ```
140
+
141
+ `target` lights the thing on screen while its row is hovered. A page's source replaces a standing one with the same `id`.
142
+
143
+ Text the person highlights on the page is offered first under `#` (Selected text), with where it sits from the nearest `data-assistant-where` attribute. To hand a highlight over from the page, an Ask button calls `composeAssistant({ refs: [selectionRef({ text, where })], text: '' })` (or any refs and text): the panel opens with the chips in the composer, focused for the person to finish.
@@ -1,21 +1,47 @@
1
- import type { AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantProvider, AssistantStatus, DraftItem } from '@columenlabs/lumen-ui-types/assistant';
1
+ import type { AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantProvider, AssistantStatus, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
2
2
  import { type ModelDef, type TaskDef, type TierDef } from './models.js';
3
3
  import { State } from './state.js';
4
4
  import { type AssistantStore, type Sealer } from './store.js';
5
5
  import { type ToolSource } from './tools.js';
6
6
  import { type AccessToken, type WebFetchOptions } from './web.js';
7
+ /** An app's slash command (`/story <words>`). */
8
+ export interface AssistantCommandDef {
9
+ /** Typed after the slash, letters, digits and `-`: `story`. */
10
+ name: string;
11
+ /** One line in the list. */
12
+ about: string;
13
+ /** What follows the name, shown in the list (`<words>`). Without it, picking it sends it. */
14
+ args?: string;
15
+ /** What the model is asked: `{args}` is the words after the name. */
16
+ prompt: string | ((args: string, caller: Caller) => string | Promise<string>);
17
+ }
7
18
  /** Who is asking: their id (the store's scope for their things) and whether they are an admin. */
8
19
  export interface Caller {
9
20
  id: string;
10
21
  admin: boolean;
11
22
  }
12
- /** A group of the app's tools the person sets Off, Read or Read + draft. */
23
+ /** A change a write tool made at once, as `undo` gets it. */
24
+ export interface AssistantWrite {
25
+ /** The tool's own name (not the `group__tool` the model calls). */
26
+ tool: string;
27
+ args: unknown;
28
+ /** What the tool returned. */
29
+ result: unknown;
30
+ }
31
+ /** A group of the app's tools the person sets Off, Read, Read + draft or Read + write. */
13
32
  export interface ToolGroup {
14
33
  id: string;
15
34
  label: string;
16
35
  about: string;
17
36
  /** Where a person starts: `read` by default. */
18
37
  access?: AssistantAccess;
38
+ /**
39
+ * Offers Read + write: its write tools then run at once, without a card, each with Undo in the
40
+ * chat when `undo` is set. Off by default, so a group only drafts.
41
+ */
42
+ writes?: boolean;
43
+ /** Takes back one change a write made at once (Undo on its line in the chat). */
44
+ undo?: (caller: Caller, write: AssistantWrite) => Promise<void>;
19
45
  tools: (caller: Caller) => ToolSource | ToolSource[];
20
46
  }
21
47
  /** What one step or answer was, for the app's meter: shapes only, never content. */
@@ -52,6 +78,12 @@ export interface AssistantOptions {
52
78
  name: string;
53
79
  /** The app's instructions: what it is, what its tools are for. */
54
80
  system: string | ((caller: Caller) => string | Promise<string>);
81
+ /**
82
+ * The app's slash commands, listed in the composer when the person types `/`. A message
83
+ * `/name words` is sent to the model as `prompt` with `{args}` replaced by the words (or what the
84
+ * function returns); the chat shows what the person typed.
85
+ */
86
+ commands?: AssistantCommandDef[];
55
87
  /** The app's tiers, smallest first. */
56
88
  tiers: TierDef[];
57
89
  /** Tasks a person picks a starting tier for; the chat on the smallest tier by default. */
@@ -111,6 +143,7 @@ export declare class Assistant {
111
143
  /** The tier the person's chat runs on: their pick, else the chat task's start tier. */
112
144
  private mainTier;
113
145
  private access;
146
+ private groupById;
114
147
  status(caller: Caller): Promise<AssistantStatus>;
115
148
  listChats(caller: Caller): Promise<AssistantChatRow[]>;
116
149
  readChat(caller: Caller, id: string): Promise<AssistantChat | null>;
@@ -129,6 +162,8 @@ export declare class Assistant {
129
162
  /** The advisor for this answer, or null when off. Never weaker than the model it advises. */
130
163
  private advisorFor;
131
164
  private escalateTool;
165
+ /** What the model is asked: an app command's prompt, else the message as typed. */
166
+ private expand;
132
167
  /** The tiers the person's message asks for by name ("go deeper" is the next one up). */
133
168
  private askedFor;
134
169
  private handOff;
@@ -169,6 +204,8 @@ export declare class Assistant {
169
204
  }>;
170
205
  /** Saves (runs, as the person) or dismisses a drafted card. Access is checked again first. */
171
206
  settleDraft(caller: Caller, chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
207
+ /** Takes back one change a write made at once, through its group's `undo`. */
208
+ undoWrite(caller: Caller, chatId: string, stepId: string): Promise<StepItem>;
172
209
  }
173
210
  export declare function createAssistant(o: AssistantOptions): Assistant;
174
211
  export {};
@@ -97,6 +97,10 @@ function untilStopped(work, signal) {
97
97
  });
98
98
  }
99
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
+ }
100
104
  /** Every draft in a chat, those in hand-off sections included. */
101
105
  function draftsOf(items) {
102
106
  return items.flatMap((i) => i.kind === 'draft' ? [i] : i.kind === 'handoff' ? draftsOf(i.items) : []);
@@ -205,7 +209,11 @@ export class Assistant {
205
209
  return (this.tierById(task === 'chat' ? (p.tier ?? startId) : startId) ?? this.tiers[0]);
206
210
  }
207
211
  access(p, g) {
208
- 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);
209
217
  }
210
218
  // ---------------------------------------------------------------- status and settings
211
219
  async status(caller) {
@@ -252,6 +260,11 @@ export class Assistant {
252
260
  }));
253
261
  return {
254
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
+ })),
255
268
  wait,
256
269
  ...(wait ? { wait_provider: mainModel.provider } : {}),
257
270
  admin: caller.admin,
@@ -275,6 +288,7 @@ export class Assistant {
275
288
  about: g.about,
276
289
  access: this.access(p, g),
277
290
  drafts: true,
291
+ writes: !!g.writes,
278
292
  })),
279
293
  hand_off: p.hand_off ?? 'next',
280
294
  advice: p.advice && (p.advice === 'off' || this.tierById(p.advice)) ? p.advice : 'off',
@@ -362,12 +376,15 @@ export class Assistant {
362
376
  const fns = new Map();
363
377
  for (const o of opened)
364
378
  for (const def of o.open.tools) {
365
- if (!def.read && o.access !== 'draft')
379
+ if (!def.read && o.access !== 'draft' && o.access !== 'write')
366
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;
367
383
  fns.set(toolName(o.group, def.name), {
368
384
  group: o.group,
369
385
  def,
370
- draft: !def.read,
386
+ draft: !def.read && !write,
387
+ write,
371
388
  open: o.open,
372
389
  });
373
390
  }
@@ -380,7 +397,7 @@ export class Assistant {
380
397
  toolsFor(fns) {
381
398
  return [...fns.entries()].map(([name, f]) => ({
382
399
  name,
383
- 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),
384
401
  input_schema: { type: 'object', ...f.def.inputSchema },
385
402
  }));
386
403
  }
@@ -436,6 +453,7 @@ export class Assistant {
436
453
  history: [],
437
454
  };
438
455
  const compactOnly = message === '/compact';
456
+ const asked = await this.expand(caller, message);
439
457
  if (!compactOnly)
440
458
  doc.items.push({
441
459
  kind: 'user',
@@ -447,7 +465,13 @@ export class Assistant {
447
465
  });
448
466
  const start = doc.items.length;
449
467
  emit('chat', { id: chatId, title: doc.title });
450
- const main = await this.session(caller, await this.mainTier(caller));
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);
451
475
  const { paying, wait } = await this.state.paying(caller.id, main.model.provider);
452
476
  if (!paying) {
453
477
  const code = wait ?? 'not_connected';
@@ -479,7 +503,8 @@ export class Assistant {
479
503
  const handTo = req.tier ? this.above(main.tier).find((t) => t.id === req.tier) : undefined;
480
504
  const tools = await this.openTools(caller, p);
481
505
  const runs = [];
482
- 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 };
483
508
  let outcome = 'ok';
484
509
  try {
485
510
  const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
@@ -516,7 +541,7 @@ export class Assistant {
516
541
  }
517
542
  const page = this.cardNews(doc) +
518
543
  (req.refs?.length
519
- ? `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`
520
545
  : '') +
521
546
  (req.context
522
547
  ? `Page: ${req.context.label}${req.context.note === undefined ? '' : `\n${JSON.stringify(req.context.note).slice(0, 4000)}`}\n\n`
@@ -526,7 +551,7 @@ export class Assistant {
526
551
  : '';
527
552
  const messages = [
528
553
  ...doc.history.map((t) => ({ role: t.role, content: t.content })),
529
- { role: 'user', content: `${page}${lead}${message}` },
554
+ { role: 'user', content: `${page}${lead}${asked}` },
530
555
  ];
531
556
  const escalateTool = this.escalateTool(main.tier);
532
557
  let answer = await this.loop({
@@ -549,7 +574,7 @@ export class Assistant {
549
574
  ? this.handOff(use, {
550
575
  caller,
551
576
  clients,
552
- message,
577
+ message: asked,
553
578
  handTo,
554
579
  page,
555
580
  policy,
@@ -577,7 +602,7 @@ export class Assistant {
577
602
  emit('text', { delta: answer });
578
603
  emit('text_end', {});
579
604
  }
580
- doc.history.push({ role: 'user', content: `${page}${message}` });
605
+ doc.history.push({ role: 'user', content: `${page}${asked}` });
581
606
  doc.history.push({ role: 'assistant', content: this.remember(answer, runs) });
582
607
  }
583
608
  catch (err) {
@@ -590,7 +615,7 @@ export class Assistant {
590
615
  .map((i) => i.text)
591
616
  .join('\n\n');
592
617
  if (!compactOnly) {
593
- doc.history.push({ role: 'user', content: message });
618
+ doc.history.push({ role: 'user', content: asked });
594
619
  doc.history.push({ role: 'assistant', content: said || '(stopped before answering)' });
595
620
  }
596
621
  }
@@ -640,6 +665,7 @@ export class Assistant {
640
665
  `You are ${this.name}. Today is ${new Date().toISOString().slice(0, 10)}.`,
641
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.',
642
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.',
643
669
  ];
644
670
  if (!fns.size)
645
671
  lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
@@ -694,6 +720,17 @@ export class Assistant {
694
720
  },
695
721
  };
696
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
+ }
697
734
  /** The tiers the person's message asks for by name ("go deeper" is the next one up). */
698
735
  askedFor(message, main) {
699
736
  const reach = this.above(main);
@@ -827,6 +864,8 @@ export class Assistant {
827
864
  let rows;
828
865
  try {
829
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 };
830
869
  rows = rowCount(result);
831
870
  out = typeof result === 'string' ? result : JSON.stringify(result);
832
871
  }
@@ -848,6 +887,9 @@ export class Assistant {
848
887
  ms,
849
888
  ...(rows !== undefined ? { rows } : {}),
850
889
  ...(error ? { error } : {}),
890
+ ...(f.write && ok
891
+ ? { write: { undo: !!this.groupById(f.group)?.undo, status: 'done' } }
892
+ : {}),
851
893
  };
852
894
  sink.push(item);
853
895
  emit('step', item);
@@ -1186,10 +1228,16 @@ export class Assistant {
1186
1228
  cardNews(doc) {
1187
1229
  const told = new Set(doc.told ?? []);
1188
1230
  const news = draftsOf(doc.items).filter((d) => d.status !== 'open' && !told.has(d.id));
1189
- 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)
1190
1233
  return '';
1191
- doc.told = [...told, ...news.map((d) => d.id)];
1192
- 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
1193
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)}` : ''}`)
1194
1242
  .join('\n')}\n\n`;
1195
1243
  }
@@ -1303,9 +1351,9 @@ export class Assistant {
1303
1351
  const tools = await this.openTools(caller, p);
1304
1352
  try {
1305
1353
  const f = tools.fns.get(d.tool);
1306
- if (!f?.draft) {
1354
+ if (!f || f.def.read) {
1307
1355
  d.status = 'failed';
1308
- 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.';
1309
1357
  }
1310
1358
  else {
1311
1359
  try {
@@ -1325,6 +1373,30 @@ export class Assistant {
1325
1373
  await this.writeChat(caller, chatId, doc);
1326
1374
  return d;
1327
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
+ }
1328
1400
  }
1329
1401
  export function createAssistant(o) {
1330
1402
  return new Assistant(o);
@@ -18,6 +18,7 @@
18
18
  * POST /chat one answer as server-sent events; closing the request stops it
19
19
  * GET /chats, GET|DELETE /chats/:id
20
20
  * POST /chats/:id/drafts/:draft {action: save | dismiss}
21
+ * POST /chats/:id/writes/:step/undo
21
22
  *
22
23
  * Writes must be same-origin JSON: a signed-in browser session is a cookie, so a foreign page's
23
24
  * form or fetch is refused before it reaches a rule. Keys and tokens are write-only: no route
@@ -316,6 +317,8 @@ export function assistantRoutes(a, o) {
316
317
  return fail('action is save or dismiss.');
317
318
  return json(await a.settleDraft(caller, id, parts[3], action));
318
319
  }
320
+ if (parts.length === 5 && parts[2] === 'writes' && parts[4] === 'undo' && m === 'POST')
321
+ return json(await a.undoWrite(caller, id, parts[3]));
319
322
  }
320
323
  return fail('Not found.', 404);
321
324
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.78",
3
+ "version": "0.1.79",
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",
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@anthropic-ai/sdk": "0.133.0",
44
- "@columenlabs/lumen-ops": "0.1.78",
45
- "@columenlabs/lumen-ui-types": "0.1.78",
44
+ "@columenlabs/lumen-ops": "0.1.79",
45
+ "@columenlabs/lumen-ui-types": "0.1.79",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },