@columenlabs/lumen-ai 0.1.135 → 0.1.137

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
@@ -141,3 +141,18 @@ The panel's composer lists what can follow three keys as they are typed. The kit
141
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
142
 
143
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.
144
+
145
+ ### Bearings
146
+
147
+ 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. 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.
148
+
149
+ ```ts
150
+ createAssistant({
151
+ // ...
152
+ app: 'Notes',
153
+ timeZone: 'America/Los_Angeles', // when the browser sends none
154
+ bearings: ({ now, at, span }) => `Next backup: ${at(nextBackup(now))} (in ${span(nextBackup(now) - now)})`,
155
+ });
156
+ ```
157
+
158
+ An app with its own assistant loop builds the same block with `bearings({ timeZone, lastAt, app, extras })` and puts it before the message.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Bearings: a few lines at the top of each message telling the model when and where it is (the time
3
+ * in the person's zone, how long since the chat's last message, the app), so it never guesses
4
+ * "today". Kept out of the system prompt so the prompt cache holds. An app adds its own lines (a
5
+ * market's session, a billing day) through `extras`; each gets `extrasMs` and is left out when it is
6
+ * late or throws, so bearings never hold a turn.
7
+ */
8
+ /** What an app's extra lines are worked out from. */
9
+ export interface BearingsNow {
10
+ /** The moment, ms since the epoch. */
11
+ now: number;
12
+ /** The person's IANA zone (checked). */
13
+ timeZone: string;
14
+ /** A moment in the person's zone: `Tue, Oct 27, 2026, 3:05 PM PDT`. */
15
+ at: (ms: number) => string;
16
+ /** A span as the model reads it: `1h 55m`, `2d 3h`. */
17
+ span: (ms: number) => string;
18
+ }
19
+ /** An app's own bearings: lines (or one line) to add, nothing to add none. */
20
+ export type BearingsExtra = (b: BearingsNow) => string | string[] | null | undefined | Promise<string | string[] | null | undefined>;
21
+ export interface BearingsInput {
22
+ /** The moment; now by default. */
23
+ now?: number;
24
+ /** The person's IANA zone (the browser's); `fallbackZone` when missing or unknown. */
25
+ timeZone?: string;
26
+ /** UTC by default. */
27
+ fallbackZone?: string;
28
+ /** When this chat's last message was, ms; left out on a new chat. */
29
+ lastAt?: number;
30
+ /** The app's name (`App: …`). */
31
+ app?: string;
32
+ extras?: BearingsExtra | BearingsExtra[];
33
+ /** How long each extra may take; 300 ms. */
34
+ extrasMs?: number;
35
+ }
36
+ /** `zone` when Intl knows it, else undefined. */
37
+ export declare function knownZone(zone: string | undefined): string | undefined;
38
+ /** A moment in `timeZone`: `Tue, Oct 27, 2026, 3:05 PM PDT`. */
39
+ export declare function bearingTime(ms: number, timeZone: string): string;
40
+ /** A span: `45s`, `12m`, `1h 55m`, `2d 3h`. */
41
+ export declare function bearingSpan(ms: number): string;
42
+ /** The bearings block, ending in a blank line, to put before the person's message. */
43
+ export declare function bearings(input?: BearingsInput): Promise<string>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Bearings: a few lines at the top of each message telling the model when and where it is (the time
3
+ * in the person's zone, how long since the chat's last message, the app), so it never guesses
4
+ * "today". Kept out of the system prompt so the prompt cache holds. An app adds its own lines (a
5
+ * market's session, a billing day) through `extras`; each gets `extrasMs` and is left out when it is
6
+ * late or throws, so bearings never hold a turn.
7
+ */
8
+ const EXTRAS_MS = 300;
9
+ /** `zone` when Intl knows it, else undefined. */
10
+ export function knownZone(zone) {
11
+ if (!zone || zone.length > 64)
12
+ return undefined;
13
+ try {
14
+ new Intl.DateTimeFormat('en-US', { timeZone: zone });
15
+ return zone;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ /** A moment in `timeZone`: `Tue, Oct 27, 2026, 3:05 PM PDT`. */
22
+ export function bearingTime(ms, timeZone) {
23
+ return new Intl.DateTimeFormat('en-US', {
24
+ timeZone,
25
+ weekday: 'short',
26
+ month: 'short',
27
+ day: 'numeric',
28
+ year: 'numeric',
29
+ hour: 'numeric',
30
+ minute: '2-digit',
31
+ timeZoneName: 'short',
32
+ }).format(ms);
33
+ }
34
+ /** A span: `45s`, `12m`, `1h 55m`, `2d 3h`. */
35
+ export function bearingSpan(ms) {
36
+ const m = Math.round(Math.abs(ms) / 60_000);
37
+ if (m < 1)
38
+ return `${Math.round(Math.abs(ms) / 1000)}s`;
39
+ if (m < 60)
40
+ return `${m}m`;
41
+ const h = Math.floor(m / 60);
42
+ if (h < 24)
43
+ return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
44
+ const d = Math.floor(h / 24);
45
+ return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
46
+ }
47
+ function ago(ms) {
48
+ const m = ms / 60_000;
49
+ if (m < 1)
50
+ return 'just now';
51
+ if (m < 60)
52
+ return `${Math.round(m)} min ago`;
53
+ const h = m / 60;
54
+ if (h < 24)
55
+ return `${Math.round(h)} h ago`;
56
+ const d = Math.round(h / 24);
57
+ return `${d} day${d === 1 ? '' : 's'} ago`;
58
+ }
59
+ async function within(extra, b, ms) {
60
+ let timer;
61
+ const late = new Promise((done) => {
62
+ timer = setTimeout(() => done(undefined), ms);
63
+ });
64
+ try {
65
+ const got = await Promise.race([Promise.resolve().then(() => extra(b)), late]);
66
+ if (!got)
67
+ return [];
68
+ return (Array.isArray(got) ? got : [got]).filter((l) => typeof l === 'string' && l.trim());
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ /** The bearings block, ending in a blank line, to put before the person's message. */
78
+ export async function bearings(input = {}) {
79
+ const now = input.now ?? Date.now();
80
+ const timeZone = knownZone(input.timeZone) ?? knownZone(input.fallbackZone) ?? 'UTC';
81
+ const b = {
82
+ now,
83
+ timeZone,
84
+ at: (ms) => bearingTime(ms, timeZone),
85
+ span: bearingSpan,
86
+ };
87
+ const utc = new Date(now).toISOString().slice(11, 16);
88
+ const lines = [
89
+ "Bearings (the server's clock: use it for today, now and how long ago; never guess the date):",
90
+ `Now: ${b.at(now)} (${timeZone}) · ${utc} UTC`,
91
+ ];
92
+ if (input.lastAt && input.lastAt < now)
93
+ lines.push(`Last message in this chat: ${ago(now - input.lastAt)} (${b.at(input.lastAt)})`);
94
+ if (input.app)
95
+ lines.push(`App: ${input.app}`);
96
+ const extras = input.extras ? (Array.isArray(input.extras) ? input.extras : [input.extras]) : [];
97
+ const more = await Promise.all(extras.map((e) => within(e, b, input.extrasMs ?? EXTRAS_MS)));
98
+ for (const l of more.flat())
99
+ lines.push(l.slice(0, 400));
100
+ return `${lines.join('\n')}\n\n`;
101
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantStatus, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
2
+ import { type BearingsExtra, type BearingsNow } from './bearings.js';
2
3
  import { type ModelDef, type TaskDef, type TierDef } from './models.js';
3
4
  import { State } from './state.js';
4
5
  import { type AssistantStore, type Sealer } from './store.js';
@@ -140,8 +141,15 @@ export interface AssistantOptions {
140
141
  defaultCap?: number;
141
142
  /** An allowance on the shared keys a person can ask to raise (Request more). */
142
143
  allowance?: AllowanceOptions;
143
- /** Months and dates in this zone; UTC by default. */
144
+ /** Months and dates in this zone, and a person's zone when their browser sends none; UTC by default. */
144
145
  timeZone?: string;
146
+ /** The app's name, told to the model in its bearings (`App: …`). */
147
+ app?: string;
148
+ /**
149
+ * The app's own lines for the bearings at the top of each message (a market's session, a billing
150
+ * day). Given 300 ms; late or failing, its lines are left out.
151
+ */
152
+ bearings?: (b: BearingsNow, caller: Caller) => ReturnType<BearingsExtra>;
145
153
  /** Chats are deleted this many days after their last message (`purge`); 30. */
146
154
  keepDays?: number;
147
155
  /** The fetch the model providers and ScrapeQL calls go through. */
@@ -19,6 +19,7 @@
19
19
  import Anthropic from '@anthropic-ai/sdk';
20
20
  import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
21
21
  import OpenAI from 'openai';
22
+ import { bearings } from './bearings.js';
22
23
  import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
23
24
  import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, effortParams, } from './models.js';
24
25
  import { DEFAULT_CAP, State, keyDocName, keyState, planDocName, waitText, } from './state.js';
@@ -275,6 +276,8 @@ export class Assistant {
275
276
  async status(caller) {
276
277
  const main = await this.mainTier(caller);
277
278
  const mainModel = (await this.pick(caller, main)).model;
279
+ // The binding's last test has no token doc to sit in; it is read beside the rest.
280
+ const linkTest = this.o.web?.scrapeql?.fetch ? this.state.scrapeqlLink() : null;
278
281
  const [p, providers, scrapeql, spend, appCap, ownCap, { paying, wait }] = await Promise.all([
279
282
  this.state.person(caller.id),
280
283
  Promise.all(this.providers.map(async (id) => {
@@ -308,6 +311,7 @@ export class Assistant {
308
311
  this.state.ownCap(caller.id),
309
312
  this.state.paying(caller.id, mainModel.provider),
310
313
  ]);
314
+ const link = await linkTest;
311
315
  const tiers = await Promise.all(this.tiers.map(async (t) => {
312
316
  const { model, effort } = await this.pick(caller, t);
313
317
  return {
@@ -358,7 +362,11 @@ export class Assistant {
358
362
  fetch: p.web ?? true,
359
363
  scrapeql: this.o.web?.scrapeql
360
364
  ? this.o.web.scrapeql.fetch
361
- ? { set: true, bound: true }
365
+ ? {
366
+ set: true,
367
+ bound: true,
368
+ ...(link?.test ? { test: link.test } : {}),
369
+ }
362
370
  : caller.admin
363
371
  ? keyState(scrapeql, (d) => d.client_id)
364
372
  : { set: !!scrapeql }
@@ -711,7 +719,15 @@ export class Assistant {
711
719
  return;
712
720
  }
713
721
  }
714
- const page = this.cardNews(doc) +
722
+ const told = await bearings({
723
+ timeZone: req.time_zone,
724
+ fallbackZone: this.o.timeZone,
725
+ lastAt: prev?.at,
726
+ app: this.o.app,
727
+ ...(this.o.bearings ? { extras: (b) => this.o.bearings?.(b, caller) } : {}),
728
+ });
729
+ const page = told +
730
+ this.cardNews(doc) +
715
731
  (req.refs?.length
716
732
  ? `The person points at these parts: they are what "this" means, and when the person asks for a change they are the only parts to change. Read anything else you need (the rest of the page, other records, your tools) for context, but leave it as it is unless they ask.\n${JSON.stringify(req.refs).slice(0, 16000)}\n\n`
717
733
  : '') +
@@ -834,7 +850,7 @@ export class Assistant {
834
850
  /** What the kit adds to the app's instructions: tools, drafts, hand off, advice, limits. */
835
851
  rules(main, policy, advisor, fns, failed) {
836
852
  const lines = [
837
- `You are ${this.name}. Today is ${new Date().toISOString().slice(0, 10)}.`,
853
+ `You are ${this.name}. Each message starts with Bearings: the time now in the person's zone and where they are. Take today, now and how long ago from it; never guess the date.`,
838
854
  '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.',
839
855
  '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.',
840
856
  '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.',
@@ -12,6 +12,7 @@
12
12
  * The ChatGPT plan sign-in's parts are exported on their own too, for an app that keeps the tokens and
13
13
  * calls OpenAI itself (`LumenChatgptSignin` is the panel's part for it).
14
14
  */
15
+ export * from './bearings.js';
15
16
  export * from './chatgpt.js';
16
17
  export * from './engine.js';
17
18
  export * from './models.js';
@@ -12,6 +12,7 @@
12
12
  * The ChatGPT plan sign-in's parts are exported on their own too, for an app that keeps the tokens and
13
13
  * calls OpenAI itself (`LumenChatgptSignin` is the panel's part for it).
14
14
  */
15
+ export * from './bearings.js';
15
16
  export * from './chatgpt.js';
16
17
  export * from './engine.js';
17
18
  export * from './models.js';
@@ -33,7 +33,7 @@
33
33
  import { ASSISTANT_ACCESS, ASSISTANT_EFFORTS, ASSISTANT_OWN_WAYS, ASSISTANT_PAYERS, HAND_OFF_POLICIES, } from '@columenlabs/lumen-ui-types/assistant';
34
34
  import { AssistantError } from './engine.js';
35
35
  import { PROVIDERS } from './models.js';
36
- import { keyDocName, planDocName } from './state.js';
36
+ import { keyDocName, planDocName, } from './state.js';
37
37
  import { APP_SCOPE } from './store.js';
38
38
  const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
39
39
  const MAX_MESSAGE = 20_000;
@@ -302,8 +302,11 @@ export function assistantRoutes(app, o) {
302
302
  const no = admin();
303
303
  if (no)
304
304
  return no;
305
- if (a.boundScrapeql)
306
- return json(await a.testScrapeql(null));
305
+ if (a.boundScrapeql) {
306
+ const test = await a.testScrapeql(null);
307
+ await docs.write(APP_SCOPE, 'scrapeql:link', { test });
308
+ return json(test);
309
+ }
307
310
  const d = await a.state.scrapeql();
308
311
  if (!d)
309
312
  return fail('No ScrapeQL token is set.', 404);
@@ -181,6 +181,10 @@ export declare class State {
181
181
  /** USD a month on the person's own keys. */
182
182
  ownCap(id: string): Promise<number>;
183
183
  scrapeql(): Promise<TokenDoc | null>;
184
+ /** The last test of ScrapeQL over the app's service binding (no token to keep it beside). */
185
+ scrapeqlLink(): Promise<{
186
+ test: KeyTest;
187
+ } | null>;
184
188
  appCap(): Promise<number>;
185
189
  spend(id: string, month?: string): Promise<SpendDoc>;
186
190
  /** Adds an answer's use to this month's spend, by label and payer. */
@@ -183,6 +183,10 @@ export class State {
183
183
  scrapeql() {
184
184
  return this.docs.read(APP_SCOPE, 'scrapeql');
185
185
  }
186
+ /** The last test of ScrapeQL over the app's service binding (no token to keep it beside). */
187
+ scrapeqlLink() {
188
+ return this.docs.read(APP_SCOPE, 'scrapeql:link');
189
+ }
186
190
  async appCap() {
187
191
  return (await this.app()).cap_usd ?? this.o.defaultCap;
188
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.135",
3
+ "version": "0.1.137",
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.135",
45
- "@columenlabs/lumen-ui-types": "0.1.135",
44
+ "@columenlabs/lumen-ops": "0.1.137",
45
+ "@columenlabs/lumen-ui-types": "0.1.137",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },