@columenlabs/lumen-ai 0.1.275 → 0.1.277
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 +1 -1
- package/dist/agent/agent.d.ts +23 -0
- package/dist/agent/agent.js +72 -5
- package/dist/agent/models.d.ts +11 -4
- package/dist/agent/models.js +63 -5
- package/dist/assistant/engine.js +5 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ The panel's composer lists what can follow three keys as they are typed. The kit
|
|
|
164
164
|
|
|
165
165
|
### Bearings
|
|
166
166
|
|
|
167
|
-
Each message the model gets starts with a few lines of bearings: the time now in the person's zone (the panel sends the browser's), UTC, how long since the chat's last message and the app's name. They go in the message, not the system prompt, so the prompt cache holds. A turn's lead (bearings, `plan.lead`, news)
|
|
167
|
+
Each message the model gets starts with a few lines of bearings: the time now in the person's zone (the panel sends the browser's), UTC, how long since the chat's last message and the app's name. They go in the message, not the system prompt, so the prompt cache holds. A turn's lead (bearings, `plan.lead`, news) stays on the message it went with, so every earlier message is sent exactly as before and read from the prompt cache (a lead taken off the last message changed it, and that message and its tool steps were paid again: 0% read before, 97 to 99% after, on OpenAI). The newest lead says only what changed since the last (`stickyLeads`, `leadDelta`): one line when nothing did, the new and gone lines when some did, and the whole lead the first time, after compaction, or when the change is most of it. A chat's OpenAI calls carry the chat's id as their cache key. `/compact` in the panel summarizes the chat's older part at once (as past `compactAt()`), without asking the model. An app adds its own lines with `bearings`; each call gets 300 ms, and when it is late or throws its lines are left out.
|
|
168
168
|
|
|
169
169
|
```ts
|
|
170
170
|
createAssistant({
|
package/dist/agent/agent.d.ts
CHANGED
|
@@ -132,6 +132,29 @@ export declare function cardNews(cards: AgentCard[]): string;
|
|
|
132
132
|
export declare const COMPACT_COMMAND = "/compact";
|
|
133
133
|
/** The messages with `lead` put before the newest message the person wrote. */
|
|
134
134
|
export declare function withLead(messages: ModelMessage[], lead: string | undefined): ModelMessage[];
|
|
135
|
+
/** The leads a chat has sent, kept so each earlier message is sent again exactly as it was. */
|
|
136
|
+
export interface SentLeads {
|
|
137
|
+
/** Each person's message (`leadKey`) and the lead it went with. */
|
|
138
|
+
sent: Record<string, string>;
|
|
139
|
+
/** The last whole lead, which the next one is told against. */
|
|
140
|
+
last?: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The lead for the newest message, told against the last one: the whole lead the first time, a line
|
|
144
|
+
* when nothing changed, else only the lines that are new and the lines that went (the whole lead
|
|
145
|
+
* when that is not much shorter).
|
|
146
|
+
*/
|
|
147
|
+
export declare function leadDelta(last: string | undefined, next: string): string;
|
|
148
|
+
/**
|
|
149
|
+
* The chat's messages with every lead where it was first sent, and the newest message's lead told
|
|
150
|
+
* against the last. A provider's prompt cache reuses only earlier messages sent exactly as before:
|
|
151
|
+
* a lead moved off the last message changed it, and that message and every step after it were
|
|
152
|
+
* paid again on the next turn. Unchanged context costs one line, changed context only what changed.
|
|
153
|
+
*/
|
|
154
|
+
export declare function stickyLeads(messages: ModelMessage[], kept: SentLeads, lead: string): {
|
|
155
|
+
messages: ModelMessage[];
|
|
156
|
+
kept: SentLeads;
|
|
157
|
+
};
|
|
135
158
|
/** One message the person sent, with the page, refs and tier the composer added. */
|
|
136
159
|
export interface AskInput {
|
|
137
160
|
message: string;
|
package/dist/agent/agent.js
CHANGED
|
@@ -18,7 +18,7 @@ import { ASSISTANT_IMAGES_MAX, ASSISTANT_IMAGE_CHARS, ASSISTANT_IMAGE_TYPES, ASS
|
|
|
18
18
|
import { createCompactFunction } from 'agents/sessions';
|
|
19
19
|
import { generateText } from 'ai';
|
|
20
20
|
import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connectorSource, } from './connectors.js';
|
|
21
|
-
import { aiModel, cachedTurn, planFallback, turnSettings } from './models.js';
|
|
21
|
+
import { aiModel, cachedTurn, planFallback, prefixCacheKey, turnSettings, } from './models.js';
|
|
22
22
|
import { screenMapOf, screenNews, screenOutcomesOf, screenRules, screenTool, } from './screen.js';
|
|
23
23
|
import { LAZY_AT, TOOL_SEARCH, lazyTools, usedTools } from './search.js';
|
|
24
24
|
import { chunkEvents, itemsOf, shownTitle, } from './stream.js';
|
|
@@ -70,6 +70,68 @@ export function withLead(messages, lead) {
|
|
|
70
70
|
i -= 1;
|
|
71
71
|
return i < 0 ? messages : leadAt(messages, i, lead);
|
|
72
72
|
}
|
|
73
|
+
/** Leads kept per chat; the oldest go first (a compacted chat no longer has their messages). */
|
|
74
|
+
const LEADS_KEPT = 60;
|
|
75
|
+
const LEAD_SAME = 'Context: as in my last message.\n\n';
|
|
76
|
+
const LEAD_CHANGED = 'Context: what changed since my last message (the rest is as it was).';
|
|
77
|
+
/** A person's message's place among theirs and a hash of its words: the same message turn after turn. */
|
|
78
|
+
function leadKey(n, m) {
|
|
79
|
+
const text = typeof m.content === 'string'
|
|
80
|
+
? m.content
|
|
81
|
+
: m.content.map((p) => (p.type === 'text' ? p.text : p.type)).join('\n');
|
|
82
|
+
let h = 0x811c9dc5;
|
|
83
|
+
for (let i = 0; i < text.length; i++)
|
|
84
|
+
h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
85
|
+
return `${n}:${(h >>> 0).toString(36)}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The lead for the newest message, told against the last one: the whole lead the first time, a line
|
|
89
|
+
* when nothing changed, else only the lines that are new and the lines that went (the whole lead
|
|
90
|
+
* when that is not much shorter).
|
|
91
|
+
*/
|
|
92
|
+
export function leadDelta(last, next) {
|
|
93
|
+
if (!next || last === undefined)
|
|
94
|
+
return next;
|
|
95
|
+
if (last === next)
|
|
96
|
+
return LEAD_SAME;
|
|
97
|
+
const was = new Set(last.split('\n'));
|
|
98
|
+
const is = new Set(next.split('\n'));
|
|
99
|
+
const added = next.split('\n').filter((l) => l.trim() && !was.has(l));
|
|
100
|
+
const gone = last.split('\n').filter((l) => l.trim() && !is.has(l));
|
|
101
|
+
const delta = `${LEAD_CHANGED}\n${added.length ? `Now:\n${added.join('\n')}\n` : ''}${gone.length ? `No longer:\n${gone.join('\n')}\n` : ''}\n`;
|
|
102
|
+
return delta.length < next.length * 0.6 ? delta : next;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The chat's messages with every lead where it was first sent, and the newest message's lead told
|
|
106
|
+
* against the last. A provider's prompt cache reuses only earlier messages sent exactly as before:
|
|
107
|
+
* a lead moved off the last message changed it, and that message and every step after it were
|
|
108
|
+
* paid again on the next turn. Unchanged context costs one line, changed context only what changed.
|
|
109
|
+
*/
|
|
110
|
+
export function stickyLeads(messages, kept, lead) {
|
|
111
|
+
const users = messages.flatMap((m, i) => (m.role === 'user' ? [i] : []));
|
|
112
|
+
const newest = users.at(-1);
|
|
113
|
+
const sent = { ...kept.sent };
|
|
114
|
+
// Told against the last lead only when an earlier message still carries one (not after compaction).
|
|
115
|
+
let last = users.some((i, n) => i !== newest && sent[leadKey(n, messages[i])])
|
|
116
|
+
? kept.last
|
|
117
|
+
: undefined;
|
|
118
|
+
let out = messages;
|
|
119
|
+
users.forEach((i, n) => {
|
|
120
|
+
const m = messages[i];
|
|
121
|
+
const key = leadKey(n, m);
|
|
122
|
+
if (i === newest && sent[key] === undefined && lead) {
|
|
123
|
+
sent[key] = leadDelta(last, lead);
|
|
124
|
+
last = lead;
|
|
125
|
+
}
|
|
126
|
+
const l = sent[key];
|
|
127
|
+
if (l)
|
|
128
|
+
out = leadAt(out, i, l);
|
|
129
|
+
});
|
|
130
|
+
const keys = Object.keys(sent);
|
|
131
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - LEADS_KEPT)))
|
|
132
|
+
delete sent[k];
|
|
133
|
+
return { messages: out, kept: { sent, ...(last !== undefined && { last }) } };
|
|
134
|
+
}
|
|
73
135
|
/** The message at `i` (one the person wrote) with `lead` put before what it says. */
|
|
74
136
|
function leadAt(messages, i, lead) {
|
|
75
137
|
const m = messages[i];
|
|
@@ -766,9 +828,11 @@ export class LumenAgent extends Think {
|
|
|
766
828
|
this.#turn.lazy = lazy;
|
|
767
829
|
}
|
|
768
830
|
const turn = this.#turn;
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
const
|
|
831
|
+
// Each lead stays on the message it went with, so earlier messages are sent as before and read
|
|
832
|
+
// from the prompt cache; the newest says only what changed since the last (`stickyLeads`).
|
|
833
|
+
const led = stickyLeads(ctx.messages, this.#get('leads') ?? { sent: {} }, `${this.#news()}${this.#screenNews()}${plan.lead ?? ''}`);
|
|
834
|
+
this.#put('leads', led.kept);
|
|
835
|
+
const cached = cachedTurn(system, led.messages);
|
|
772
836
|
return {
|
|
773
837
|
model: this.#model(plan, () => {
|
|
774
838
|
const fb = plan.fallback;
|
|
@@ -790,7 +854,10 @@ export class LumenAgent extends Think {
|
|
|
790
854
|
effort: plan.effort ?? 'default',
|
|
791
855
|
...(plan.out ? { out: plan.out } : {}),
|
|
792
856
|
plan: free,
|
|
793
|
-
|
|
857
|
+
// Named for how the prompt starts, so a new chat reads its instructions and tools from the cache.
|
|
858
|
+
cacheKey: prefixCacheKey(plan.model.id, system, Object.keys(tools)
|
|
859
|
+
.filter((n) => !lazy || lazy.active().includes(n))
|
|
860
|
+
.join(',')),
|
|
794
861
|
}),
|
|
795
862
|
};
|
|
796
863
|
}
|
package/dist/agent/models.d.ts
CHANGED
|
@@ -35,14 +35,21 @@ export declare function cachedTurn(instructions: string, messages: ModelMessage[
|
|
|
35
35
|
messages: ModelMessage[];
|
|
36
36
|
};
|
|
37
37
|
/**
|
|
38
|
-
* OpenAI's prompt cache key
|
|
39
|
-
*
|
|
40
|
-
*
|
|
38
|
+
* OpenAI's prompt cache key: calls with the same key go to the same cache, so a long prompt's start
|
|
39
|
+
* is read from it instead of paid again. A ChatGPT plan sign-in caches nothing without one. At most
|
|
40
|
+
* 64 characters.
|
|
41
41
|
*/
|
|
42
42
|
export declare const promptCacheKey: (key: string) => string;
|
|
43
|
+
/**
|
|
44
|
+
* A cache key named for what a prompt starts with (its model, instructions and tools), not for
|
|
45
|
+
* one chat or run: a new chat or run that starts the same way reads that start from the cache
|
|
46
|
+
* (a key per chat read 0 of a new chat's 5.9k-token start; one key for the start read 99.7%, our
|
|
47
|
+
* key, 2026-11-14). The key only routes; what is read is still only the exact same start.
|
|
48
|
+
*/
|
|
49
|
+
export declare function prefixCacheKey(...parts: (string | undefined)[]): string;
|
|
43
50
|
/**
|
|
44
51
|
* A tier's effort and output cap as a turn's settings, with the prompt cache on: Claude's
|
|
45
|
-
* breakpoint, and OpenAI's cache key when `cacheKey`
|
|
52
|
+
* breakpoint, and OpenAI's cache key (`prefixCacheKey`) when `cacheKey` is given.
|
|
46
53
|
*/
|
|
47
54
|
export declare function turnSettings(model: ModelDef, o: {
|
|
48
55
|
effort: AssistantEffort;
|
package/dist/agent/models.js
CHANGED
|
@@ -71,9 +71,51 @@ export function planFetch(base = fetch, name = 'Assistant') {
|
|
|
71
71
|
status: res.status,
|
|
72
72
|
body: (await res.clone().text()).slice(0, 500),
|
|
73
73
|
}));
|
|
74
|
-
|
|
74
|
+
if (!res.ok || !res.body)
|
|
75
|
+
return res;
|
|
76
|
+
// The plan's own usage numbers, as it sent them (cache reads included), one line a call.
|
|
77
|
+
const [mine, theirs] = res.body.tee();
|
|
78
|
+
void logPlanUsage(theirs, String(body.model ?? ''), typeof body.prompt_cache_key === 'string');
|
|
79
|
+
const seen = new Response(mine, {
|
|
80
|
+
status: res.status,
|
|
81
|
+
statusText: res.statusText,
|
|
82
|
+
headers: res.headers,
|
|
83
|
+
});
|
|
84
|
+
return streamed ? seen : whole(seen, init?.signal);
|
|
75
85
|
};
|
|
76
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Reads a plan's streamed answer for its final usage and logs it as sent (`assistant_plan_usage`):
|
|
89
|
+
* whether a plan reads its prompt from the cache shows in its own numbers. Never throws.
|
|
90
|
+
*/
|
|
91
|
+
async function logPlanUsage(stream, model, keyed) {
|
|
92
|
+
try {
|
|
93
|
+
const reader = stream.getReader();
|
|
94
|
+
const text = new TextDecoder();
|
|
95
|
+
let buf = '';
|
|
96
|
+
let usage;
|
|
97
|
+
for (;;) {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done)
|
|
100
|
+
break;
|
|
101
|
+
buf += text.decode(value, { stream: true });
|
|
102
|
+
const lines = buf.split('\n');
|
|
103
|
+
buf = lines.pop() ?? '';
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (!line.startsWith('data:') || !line.includes('"usage"'))
|
|
106
|
+
continue;
|
|
107
|
+
const e = JSON.parse(line.slice(5));
|
|
108
|
+
if (e.type === 'response.completed' || e.type === 'response.incomplete')
|
|
109
|
+
usage = e.response?.usage;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (usage)
|
|
113
|
+
console.log(JSON.stringify({ event: 'assistant_plan_usage', model, keyed, usage }));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// A log line is never worth a failed answer.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
77
119
|
/**
|
|
78
120
|
* A streamed Responses answer as the one JSON response a request that was not streamed expects:
|
|
79
121
|
* the final `response.completed` (or `response.incomplete`), with the items from each
|
|
@@ -178,14 +220,30 @@ export function cachedTurn(instructions, messages) {
|
|
|
178
220
|
};
|
|
179
221
|
}
|
|
180
222
|
/**
|
|
181
|
-
* OpenAI's prompt cache key
|
|
182
|
-
*
|
|
183
|
-
*
|
|
223
|
+
* OpenAI's prompt cache key: calls with the same key go to the same cache, so a long prompt's start
|
|
224
|
+
* is read from it instead of paid again. A ChatGPT plan sign-in caches nothing without one. At most
|
|
225
|
+
* 64 characters.
|
|
184
226
|
*/
|
|
185
227
|
export const promptCacheKey = (key) => key.slice(0, 64);
|
|
228
|
+
/**
|
|
229
|
+
* A cache key named for what a prompt starts with (its model, instructions and tools), not for
|
|
230
|
+
* one chat or run: a new chat or run that starts the same way reads that start from the cache
|
|
231
|
+
* (a key per chat read 0 of a new chat's 5.9k-token start; one key for the start read 99.7%, our
|
|
232
|
+
* key, 2026-11-14). The key only routes; what is read is still only the exact same start.
|
|
233
|
+
*/
|
|
234
|
+
export function prefixCacheKey(...parts) {
|
|
235
|
+
const of = parts.filter(Boolean).join('\u0000');
|
|
236
|
+
let h = 0x811c9dc5;
|
|
237
|
+
let g = 0x01000193;
|
|
238
|
+
for (let i = 0; i < of.length; i++) {
|
|
239
|
+
h = Math.imul(h ^ of.charCodeAt(i), 0x01000193);
|
|
240
|
+
g = Math.imul(g ^ of.charCodeAt(i), 0x85ebca6b);
|
|
241
|
+
}
|
|
242
|
+
return `lumen-${(h >>> 0).toString(36)}${(g >>> 0).toString(36)}`;
|
|
243
|
+
}
|
|
186
244
|
/**
|
|
187
245
|
* A tier's effort and output cap as a turn's settings, with the prompt cache on: Claude's
|
|
188
|
-
* breakpoint, and OpenAI's cache key when `cacheKey`
|
|
246
|
+
* breakpoint, and OpenAI's cache key (`prefixCacheKey`) when `cacheKey` is given.
|
|
189
247
|
*/
|
|
190
248
|
export function turnSettings(model, o) {
|
|
191
249
|
const out = o.plan || !o.out ? {} : { maxOutputTokens: o.out };
|
package/dist/assistant/engine.js
CHANGED
|
@@ -66,7 +66,7 @@ function claudeHistory(history) {
|
|
|
66
66
|
};
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
|
-
/** OpenAI's prompt cache key
|
|
69
|
+
/** OpenAI's prompt cache key: a short, stable hash of `of` (how the prompt starts). */
|
|
70
70
|
function cacheKey(of) {
|
|
71
71
|
let h = 0x811c9dc5;
|
|
72
72
|
let g = 0x01000193;
|
|
@@ -1276,7 +1276,10 @@ export class Assistant {
|
|
|
1276
1276
|
try {
|
|
1277
1277
|
if ((s.model.provider === 'openai' && clients.openai) ||
|
|
1278
1278
|
(s.model.provider === 'xai' && clients.xai)) {
|
|
1279
|
-
({ text, cut } = await this.openaiOnce(clients, s, o.system, parts, max, o.signal, o.images, o.effort,
|
|
1279
|
+
({ text, cut } = await this.openaiOnce(clients, s, o.system, parts, max, o.signal, o.images, o.effort,
|
|
1280
|
+
// Named for how the prompt starts (the person, task and instructions), not the run, so
|
|
1281
|
+
// the next run of the same task reads that start from the cache.
|
|
1282
|
+
cacheKey(`${caller.id}:${o.task}:${s.model.id}:${o.system ?? ''}`), o.history));
|
|
1280
1283
|
}
|
|
1281
1284
|
else if (clients.anthropic) {
|
|
1282
1285
|
// Streamed and read whole: a long answer (minutes at a high cap) keeps its connection alive,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ai",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.277",
|
|
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",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"@anthropic-ai/sdk": "0.133.0",
|
|
58
58
|
"@cloudflare/computer": "0.8.0",
|
|
59
59
|
"@cloudflare/think": "0.23.0",
|
|
60
|
-
"@columenlabs/lumen-ops": "0.1.
|
|
61
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
60
|
+
"@columenlabs/lumen-ops": "0.1.277",
|
|
61
|
+
"@columenlabs/lumen-ui-types": "0.1.277",
|
|
62
62
|
"agents": "0.32.0",
|
|
63
63
|
"ai": "7.0.162",
|
|
64
64
|
"just-bash": "3.6.0",
|