@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 +40 -1
- package/dist/assistant/engine.d.ts +39 -2
- package/dist/assistant/engine.js +88 -16
- package/dist/assistant/routes.js +3 -0
- package/package.json +3 -3
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 +
|
|
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
|
|
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 {};
|
package/dist/assistant/engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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}${
|
|
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}${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/assistant/routes.js
CHANGED
|
@@ -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.
|
|
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.
|
|
45
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
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
|
},
|