@columenlabs/lumen-ai 0.1.137 → 0.1.139

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.
@@ -86,7 +86,7 @@ export async function bearings(input = {}) {
86
86
  };
87
87
  const utc = new Date(now).toISOString().slice(11, 16);
88
88
  const lines = [
89
- "Bearings (the server's clock: use it for today, now and how long ago; never guess the date):",
89
+ "<bearings> (the server's clock, for you only: use it for today, now and how long ago; never guess the date, and never repeat it in your answer)",
90
90
  `Now: ${b.at(now)} (${timeZone}) · ${utc} UTC`,
91
91
  ];
92
92
  if (input.lastAt && input.lastAt < now)
@@ -97,5 +97,6 @@ export async function bearings(input = {}) {
97
97
  const more = await Promise.all(extras.map((e) => within(e, b, input.extrasMs ?? EXTRAS_MS)));
98
98
  for (const l of more.flat())
99
99
  lines.push(l.slice(0, 400));
100
+ lines.push('</bearings>');
100
101
  return `${lines.join('\n')}\n\n`;
101
102
  }
@@ -239,8 +239,6 @@ export declare class Assistant {
239
239
  private compact;
240
240
  /** How the person's cards ended since the last answer, once each. */
241
241
  private cardNews;
242
- /** The answer as later turns remember it: with what its tools returned, cut. */
243
- private remember;
244
242
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
245
243
  startSignin(caller: Caller, provider: AssistantProvider): Promise<{
246
244
  url: string;
@@ -48,9 +48,16 @@ const MEMO_CHARS = 8000;
48
48
  const ADVICE_MAX = 2;
49
49
  const ADVICE_TOKENS = 2048;
50
50
  const ADVISOR_BETA = 'advisor-tool-2026-03-01';
51
- /** What later turns remember of a turn's tool results. */
52
- const RAN_CHARS = 1500;
53
- const RAN_TOTAL = 6000;
51
+ /**
52
+ * What later turns remember of the tools' results, so a follow-up ("list them all", "which have no
53
+ * milk") answers from what was read instead of calling the same tools again. Each result is kept
54
+ * as the model saw it (`RAN_CHARS`), at most `RAN_TURN` a turn as stored, and the whole chat sends
55
+ * at most `RAN_CHAT`: newest first, a call repeated later kept only in its latest turn, and past
56
+ * the budget an older result is named but dropped, so a long chat costs no more than a short one.
57
+ */
58
+ const RAN_CHARS = RESULT_CHARS;
59
+ const RAN_TURN = 40_000;
60
+ const RAN_CHAT = 40_000;
54
61
  const CHATS_LISTED = 200;
55
62
  const WHY_TEXT = {
56
63
  asked: 'you asked',
@@ -58,6 +65,58 @@ const WHY_TEXT = {
58
65
  stuck: 'stuck',
59
66
  check_failed: 'check failed',
60
67
  };
68
+ /** JSON with object keys sorted, so the same input written in another order is the same call. */
69
+ function stableJson(v) {
70
+ return JSON.stringify(v, (_k, x) => x && typeof x === 'object' && !Array.isArray(x)
71
+ ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
72
+ : x);
73
+ }
74
+ /** A turn's tool results as stored: each cut to what the model saw, the turn to `RAN_TURN`. */
75
+ function ranOf(runs) {
76
+ let left = RAN_TURN;
77
+ const ran = [];
78
+ for (const r of runs) {
79
+ if (left <= 0)
80
+ break;
81
+ const result = r.result.slice(0, Math.min(RAN_CHARS, left));
82
+ left -= result.length;
83
+ ran.push({ ...r, result: result.length < r.result.length ? `${result} …(cut)` : result });
84
+ }
85
+ return ran.length ? { ran } : {};
86
+ }
87
+ /**
88
+ * The history as sent: each answer followed by what its tools returned, within `RAN_CHAT` for the
89
+ * whole chat, newest first. A call made again later is kept only there; one past the budget is
90
+ * named so the model knows to call it again. Chats stored before `ran` carry it in `content`.
91
+ */
92
+ function toolMemory(history) {
93
+ let left = RAN_CHAT;
94
+ const later = new Set();
95
+ const out = [];
96
+ for (let i = history.length - 1; i >= 0; i--) {
97
+ const t = history[i];
98
+ if (!t.ran?.length) {
99
+ out.push({ role: t.role, content: t.content });
100
+ continue;
101
+ }
102
+ const lines = t.ran.map((r) => {
103
+ if (r.key && later.has(r.key))
104
+ return `- ${r.tool} → (called again later; see there)`;
105
+ if (r.key)
106
+ later.add(r.key);
107
+ if (left < 200)
108
+ return `- ${r.tool} → (no longer kept; call it again if you need it)`;
109
+ const kept = r.result.slice(0, left);
110
+ left -= kept.length;
111
+ return `- ${r.tool} → ${kept}${kept.length < r.result.length ? ' …(cut)' : ''}`;
112
+ });
113
+ out.push({
114
+ role: t.role,
115
+ content: `${t.content}\n\n[Tools I used and what they returned]\n${lines.join('\n')}`,
116
+ });
117
+ }
118
+ return out.reverse();
119
+ }
61
120
  const NO_USAGE = { calls: 0, in: 0, read: 0, out: 0, usd: 0, parts: [] };
62
121
  function addUsage(u, parts) {
63
122
  const out = { ...u, parts: u.parts.map((p) => ({ ...p })) };
@@ -684,7 +743,14 @@ export class Assistant {
684
743
  const tools = await this.openTools(caller, p);
685
744
  const runs = [];
686
745
  doc.writes ??= {};
687
- const counts = { steps: 0, handoffs: 0, advice: 0, end: main.tier, writes: doc.writes };
746
+ const counts = {
747
+ steps: 0,
748
+ handoffs: 0,
749
+ advice: 0,
750
+ end: main.tier,
751
+ writes: doc.writes,
752
+ seen: new Map(),
753
+ };
688
754
  let outcome = 'ok';
689
755
  try {
690
756
  const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
@@ -738,7 +804,7 @@ export class Assistant {
738
804
  ? `The person handed this message to ${handTo.label}: call escalate {tier: "${handTo.id}", why: "asked"} now, its task the message with every fact from this chat it needs, then answer from its memo.\n\n`
739
805
  : '';
740
806
  const messages = [
741
- ...doc.history.map((t) => ({ role: t.role, content: t.content })),
807
+ ...toolMemory(doc.history),
742
808
  { role: 'user', content: `${page}${lead}${asked}` },
743
809
  ];
744
810
  const escalateTool = this.escalateTool(main.tier);
@@ -791,7 +857,7 @@ export class Assistant {
791
857
  emit('text_end', {});
792
858
  }
793
859
  doc.history.push({ role: 'user', content: `${page}${asked}` });
794
- doc.history.push({ role: 'assistant', content: this.remember(answer, runs) });
860
+ doc.history.push({ role: 'assistant', content: answer, ...ranOf(runs) });
795
861
  }
796
862
  catch (err) {
797
863
  if (signal?.aborted) {
@@ -850,7 +916,7 @@ export class Assistant {
850
916
  /** What the kit adds to the app's instructions: tools, drafts, hand off, advice, limits. */
851
917
  rules(main, policy, advisor, fns, failed) {
852
918
  const lines = [
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.`,
919
+ `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. It is for you only: never quote it or start an answer with it.`,
854
920
  '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.',
855
921
  '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.',
856
922
  '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.',
@@ -1045,6 +1111,17 @@ export class Assistant {
1045
1111
  await Promise.resolve(this.o.onStep?.({ kind: 'draft', tool: use.name, tier: session.tier.id, handoff })).catch(() => undefined);
1046
1112
  return reply(`Drafted: a "${title}" card is on the person's screen. Nothing changed until they press Save.`);
1047
1113
  }
1114
+ // The same read with the same input in one answer gives back what it gave, without running it
1115
+ // again or showing a second step: a model that lost track of a result reads it here, not twice.
1116
+ const key = f.write ? '' : `${use.name}\u0000${stableJson(use.input)}`;
1117
+ const again = key ? counts.seen.get(key) : undefined;
1118
+ if (again)
1119
+ return reply(`(Same call as earlier in this answer; its result again.)\n${await again}`);
1120
+ let settle = () => undefined;
1121
+ if (key)
1122
+ counts.seen.set(key, new Promise((done) => {
1123
+ settle = done;
1124
+ }));
1048
1125
  const id = newId('s');
1049
1126
  emit('step_start', { id, tool: use.name, title });
1050
1127
  const t0 = performance.now();
@@ -1060,8 +1137,10 @@ export class Assistant {
1060
1137
  out = typeof result === 'string' ? result : JSON.stringify(result);
1061
1138
  }
1062
1139
  catch (err) {
1063
- if (signal?.aborted)
1140
+ if (signal?.aborted) {
1141
+ settle('(stopped)');
1064
1142
  throw err;
1143
+ }
1065
1144
  ok = false;
1066
1145
  error =
1067
1146
  err instanceof ToolError || err instanceof Error ? err.message.slice(0, 300) : 'failed';
@@ -1084,7 +1163,10 @@ export class Assistant {
1084
1163
  sink.push(item);
1085
1164
  emit('step', item);
1086
1165
  counts.steps += 1;
1087
- runs.push({ tool: use.name, result: out.slice(0, RAN_CHARS) });
1166
+ // A change made at once can make an earlier read stale: reads after it run again.
1167
+ if (f.write)
1168
+ counts.seen.clear();
1169
+ runs.push({ tool: use.name, key, result: out });
1088
1170
  await Promise.resolve(this.o.onStep?.({
1089
1171
  kind: 'tool',
1090
1172
  tool: use.name,
@@ -1095,7 +1177,14 @@ export class Assistant {
1095
1177
  tier: session.tier.id,
1096
1178
  handoff,
1097
1179
  })).catch(() => undefined);
1098
- const cut = out.length > RESULT_CHARS ? `${out.slice(0, RESULT_CHARS)} …(cut: ask for less)` : out;
1180
+ const cut = out.length > RESULT_CHARS
1181
+ ? `${out.slice(0, RESULT_CHARS)} …(cut at ${RESULT_CHARS.toLocaleString('en-US')} of ${out.length.toLocaleString('en-US')} characters: the rest was not read; ask again for less)`
1182
+ : out;
1183
+ if (key) {
1184
+ settle(cut);
1185
+ if (!ok)
1186
+ counts.seen.delete(key);
1187
+ }
1099
1188
  return reply(cut, !ok);
1100
1189
  }
1101
1190
  /** Model calls and tool rounds until the model answers; returns its words. */
@@ -1493,7 +1582,7 @@ export class Assistant {
1493
1582
  if (doc.history.length < 2)
1494
1583
  return false;
1495
1584
  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.`;
1496
- const chat = doc.history
1585
+ const chat = toolMemory(doc.history)
1497
1586
  .map((t) => `${t.role === 'user' ? 'Person' : this.name}: ${t.content}`)
1498
1587
  .join('\n\n');
1499
1588
  let summary = '';
@@ -1554,21 +1643,6 @@ export class Assistant {
1554
1643
  .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)}` : ''}`)
1555
1644
  .join('\n')}\n\n`;
1556
1645
  }
1557
- /** The answer as later turns remember it: with what its tools returned, cut. */
1558
- remember(answer, runs) {
1559
- if (!runs.length)
1560
- return answer;
1561
- let left = RAN_TOTAL;
1562
- const lines = [];
1563
- for (const r of runs) {
1564
- if (left <= 0)
1565
- break;
1566
- const line = `- ${r.tool} → ${r.result.slice(0, Math.min(RAN_CHARS, left))}`;
1567
- left -= line.length;
1568
- lines.push(line);
1569
- }
1570
- return `${answer}\n\n[Tools I used and what they returned]\n${lines.join('\n')}`;
1571
- }
1572
1646
  // ---------------------------------------------------------------- keys
1573
1647
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
1574
1648
  async startSignin(caller, provider) {
@@ -45,5 +45,5 @@ export interface PlainTool extends ToolDef {
45
45
  export declare function plainTools(tools: PlainTool[]): ToolSource;
46
46
  /** A tool name the model may use: letters, digits, `_` and `-`, at most 64. */
47
47
  export declare function toolName(group: string, name: string): string;
48
- /** Rows in a list result: an array, or the first array field of an object. */
48
+ /** Rows in a list result: an array, an object's `count`, or the one array an object holds. */
49
49
  export declare function rowCount(v: unknown): number | undefined;
@@ -98,13 +98,17 @@ export function plainTools(tools) {
98
98
  export function toolName(group, name) {
99
99
  return `${group}__${name}`.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
100
100
  }
101
- /** Rows in a list result: an array, or the first array field of an object. */
101
+ /** Rows in a list result: an array, an object's `count`, or the one array an object holds. */
102
102
  export function rowCount(v) {
103
103
  if (Array.isArray(v))
104
104
  return v.length;
105
- if (v && typeof v === 'object')
106
- for (const x of Object.values(v))
107
- if (Array.isArray(x))
108
- return x.length;
109
- return undefined;
105
+ if (!v || typeof v !== 'object')
106
+ return undefined;
107
+ // A list with its total says so; else the one list it holds. A record with several lists (a
108
+ // recipe's ingredients and steps) is one thing, not rows.
109
+ const o = v;
110
+ if (typeof o.count === 'number' && Number.isFinite(o.count))
111
+ return o.count;
112
+ const lists = Object.values(o).filter(Array.isArray);
113
+ return lists.length === 1 ? lists[0]?.length : undefined;
110
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.137",
3
+ "version": "0.1.139",
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.137",
45
- "@columenlabs/lumen-ui-types": "0.1.137",
44
+ "@columenlabs/lumen-ops": "0.1.139",
45
+ "@columenlabs/lumen-ui-types": "0.1.139",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },