@columenlabs/lumen-ai 0.1.138 → 0.1.140

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.
@@ -214,6 +214,8 @@ export declare class Assistant {
214
214
  /** Opens the tools the person allows: each group's, then the web's. */
215
215
  private openTools;
216
216
  private toolsFor;
217
+ /** `batch`: any of the reads, up to BATCH_MAX at once, as one step. Null with no reads. */
218
+ private batchTool;
217
219
  /** One answer as server-sent events. Abort `signal` to stop it. */
218
220
  turnStream(caller: Caller, req: AssistantMessage, signal?: AbortSignal): ReadableStream<Uint8Array>;
219
221
  turn(caller: Caller, req: AssistantMessage, emit: Emit, signal?: AbortSignal): Promise<void>;
@@ -229,6 +231,8 @@ export declare class Assistant {
229
231
  private handOff;
230
232
  /** One tool call: a draft card for a write, else the call itself, timed. */
231
233
  private runTool;
234
+ /** A batch of reads: each runs as its own call (the same read twice runs once), in parallel, shown as one step. */
235
+ private runBatch;
232
236
  /** Model calls and tool rounds until the model answers; returns its words. */
233
237
  private loop;
234
238
  /** The loop on OpenAI's Responses API: the same tools, Stop, caps and rounds as Claude's. */
@@ -239,8 +243,6 @@ export declare class Assistant {
239
243
  private compact;
240
244
  /** How the person's cards ended since the last answer, once each. */
241
245
  private cardNews;
242
- /** The answer as later turns remember it: with what its tools returned, cut. */
243
- private remember;
244
246
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
245
247
  startSignin(caller: Caller, provider: AssistantProvider): Promise<{
246
248
  url: string;
@@ -45,12 +45,24 @@ const COMPACT_AT = 150_000;
45
45
  /** A tool result handed to the model is cut past this (about 4,000 tokens). */
46
46
  const RESULT_CHARS = 16_000;
47
47
  const MEMO_CHARS = 8000;
48
+ /** The built-in tool that runs several reads at once, as one step. */
49
+ const BATCH = 'batch';
50
+ const BATCH_MAX = 25;
51
+ /** What one batch returns at most, all its results together. */
52
+ const BATCH_CHARS = 48_000;
48
53
  const ADVICE_MAX = 2;
49
54
  const ADVICE_TOKENS = 2048;
50
55
  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;
56
+ /**
57
+ * What later turns remember of the tools' results, so a follow-up ("list them all", "which have no
58
+ * milk") answers from what was read instead of calling the same tools again. Each result is kept
59
+ * as the model saw it (`RAN_CHARS`), at most `RAN_TURN` a turn as stored, and the whole chat sends
60
+ * at most `RAN_CHAT`: newest first, a call repeated later kept only in its latest turn, and past
61
+ * the budget an older result is named but dropped, so a long chat costs no more than a short one.
62
+ */
63
+ const RAN_CHARS = RESULT_CHARS;
64
+ const RAN_TURN = 40_000;
65
+ const RAN_CHAT = 40_000;
54
66
  const CHATS_LISTED = 200;
55
67
  const WHY_TEXT = {
56
68
  asked: 'you asked',
@@ -58,6 +70,58 @@ const WHY_TEXT = {
58
70
  stuck: 'stuck',
59
71
  check_failed: 'check failed',
60
72
  };
73
+ /** JSON with object keys sorted, so the same input written in another order is the same call. */
74
+ function stableJson(v) {
75
+ return JSON.stringify(v, (_k, x) => x && typeof x === 'object' && !Array.isArray(x)
76
+ ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
77
+ : x);
78
+ }
79
+ /** A turn's tool results as stored: each cut to what the model saw, the turn to `RAN_TURN`. */
80
+ function ranOf(runs) {
81
+ let left = RAN_TURN;
82
+ const ran = [];
83
+ for (const r of runs) {
84
+ if (left <= 0)
85
+ break;
86
+ const result = r.result.slice(0, Math.min(RAN_CHARS, left));
87
+ left -= result.length;
88
+ ran.push({ ...r, result: result.length < r.result.length ? `${result} …(cut)` : result });
89
+ }
90
+ return ran.length ? { ran } : {};
91
+ }
92
+ /**
93
+ * The history as sent: each answer followed by what its tools returned, within `RAN_CHAT` for the
94
+ * whole chat, newest first. A call made again later is kept only there; one past the budget is
95
+ * named so the model knows to call it again. Chats stored before `ran` carry it in `content`.
96
+ */
97
+ function toolMemory(history) {
98
+ let left = RAN_CHAT;
99
+ const later = new Set();
100
+ const out = [];
101
+ for (let i = history.length - 1; i >= 0; i--) {
102
+ const t = history[i];
103
+ if (!t.ran?.length) {
104
+ out.push({ role: t.role, content: t.content });
105
+ continue;
106
+ }
107
+ const lines = t.ran.map((r) => {
108
+ if (r.key && later.has(r.key))
109
+ return `- ${r.tool} → (called again later; see there)`;
110
+ if (r.key)
111
+ later.add(r.key);
112
+ if (left < 200)
113
+ return `- ${r.tool} → (no longer kept; call it again if you need it)`;
114
+ const kept = r.result.slice(0, left);
115
+ left -= kept.length;
116
+ return `- ${r.tool} → ${kept}${kept.length < r.result.length ? ' …(cut)' : ''}`;
117
+ });
118
+ out.push({
119
+ role: t.role,
120
+ content: `${t.content}\n\n[Tools I used and what they returned]\n${lines.join('\n')}`,
121
+ });
122
+ }
123
+ return out.reverse();
124
+ }
61
125
  const NO_USAGE = { calls: 0, in: 0, read: 0, out: 0, usd: 0, parts: [] };
62
126
  function addUsage(u, parts) {
63
127
  const out = { ...u, parts: u.parts.map((p) => ({ ...p })) };
@@ -572,14 +636,49 @@ export class Assistant {
572
636
  };
573
637
  }
574
638
  toolsFor(fns) {
575
- return [...fns.entries()].map(([name, f]) => ({
576
- name,
577
- 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),
639
+ const batch = this.batchTool(fns);
640
+ return [
641
+ ...[...fns.entries()].map(([name, f]) => ({
642
+ name,
643
+ 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),
644
+ input_schema: {
645
+ type: 'object',
646
+ ...withoutMeta(f.def.inputSchema),
647
+ },
648
+ })),
649
+ ...(batch ? [batch] : []),
650
+ ];
651
+ }
652
+ /** `batch`: any of the reads, up to BATCH_MAX at once, as one step. Null with no reads. */
653
+ batchTool(fns) {
654
+ if (fns.has(BATCH))
655
+ return null;
656
+ const reads = [...fns.entries()].filter(([, f]) => !f.draft && !f.write).map(([n]) => n);
657
+ if (!reads.length)
658
+ return null;
659
+ return {
660
+ name: BATCH,
661
+ description: `Runs up to ${BATCH_MAX} read calls at once and returns each result, numbered in order. Use it to read several records (one tool with several inputs) or several tools that do not depend on each other, in one step instead of one call at a time. Reads only.`,
578
662
  input_schema: {
579
663
  type: 'object',
580
- ...withoutMeta(f.def.inputSchema),
664
+ properties: {
665
+ calls: {
666
+ type: 'array',
667
+ minItems: 1,
668
+ maxItems: BATCH_MAX,
669
+ items: {
670
+ type: 'object',
671
+ properties: {
672
+ tool: { type: 'string', enum: reads },
673
+ input: { type: 'object', description: "The tool's own input." },
674
+ },
675
+ required: ['tool', 'input'],
676
+ },
677
+ },
678
+ },
679
+ required: ['calls'],
581
680
  },
582
- }));
681
+ };
583
682
  }
584
683
  // ---------------------------------------------------------------- the turn
585
684
  /** One answer as server-sent events. Abort `signal` to stop it. */
@@ -684,7 +783,14 @@ export class Assistant {
684
783
  const tools = await this.openTools(caller, p);
685
784
  const runs = [];
686
785
  doc.writes ??= {};
687
- const counts = { steps: 0, handoffs: 0, advice: 0, end: main.tier, writes: doc.writes };
786
+ const counts = {
787
+ steps: 0,
788
+ handoffs: 0,
789
+ advice: 0,
790
+ end: main.tier,
791
+ writes: doc.writes,
792
+ seen: new Map(),
793
+ };
688
794
  let outcome = 'ok';
689
795
  try {
690
796
  const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
@@ -738,7 +844,7 @@ export class Assistant {
738
844
  ? `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
845
  : '';
740
846
  const messages = [
741
- ...doc.history.map((t) => ({ role: t.role, content: t.content })),
847
+ ...toolMemory(doc.history),
742
848
  { role: 'user', content: `${page}${lead}${asked}` },
743
849
  ];
744
850
  const escalateTool = this.escalateTool(main.tier);
@@ -791,7 +897,7 @@ export class Assistant {
791
897
  emit('text_end', {});
792
898
  }
793
899
  doc.history.push({ role: 'user', content: `${page}${asked}` });
794
- doc.history.push({ role: 'assistant', content: this.remember(answer, runs) });
900
+ doc.history.push({ role: 'assistant', content: answer, ...ranOf(runs) });
795
901
  }
796
902
  catch (err) {
797
903
  if (signal?.aborted) {
@@ -852,6 +958,11 @@ export class Assistant {
852
958
  const lines = [
853
959
  `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
960
  '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.',
961
+ ...([...fns.values()].some((f) => !f.draft && !f.write) && !fns.has(BATCH)
962
+ ? [
963
+ `To read several records, or one tool with several inputs, put the reads in one ${BATCH} call: one step for all of them, never one call at a time. Results you already have from earlier in the chat you use, without reading them again.`,
964
+ ]
965
+ : []),
855
966
  '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
967
  '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.',
857
968
  ];
@@ -867,7 +978,7 @@ export class Assistant {
867
978
  const reach = this.above(main.tier);
868
979
  if (reach.length) {
869
980
  const own = policy === 'any' ? reach : policy === 'next' ? reach.slice(0, 1) : [];
870
- lines.push(`Tiers: you run on ${main.tier.label}. The escalate tool hands one task to a higher tier (${reach.map((t) => t.label).join(' or ')}) with the same tools; it works in its own section of the chat and returns a memo. It costs more than answering yourself.`, `Call it when the person names a tier or says "go deeper"${own.length ? `, and on your own (${own.map((t) => t.label).join(' or ')}) when the question needs judgement across many results (why "judgement"), after two of your tool calls failed (why "stuck"), or when a fix of a check still fails (why "check_failed")` : ''}. Lookups you answer yourself.`, 'Its task must carry every fact from this chat it needs: it does not see the chat. Then answer from its memo in your own short words.');
981
+ lines.push(`Tiers: you run on ${main.tier.label}. The escalate tool hands one task to a higher tier (${reach.map((t) => t.label).join(' or ')}) with the same tools; it works in its own section of the chat and returns a memo. It costs more than answering yourself.`, `Call it when the person names a tier or says "go deeper"${own.length ? `, and on your own (${own.map((t) => t.label).join(' or ')}) when the question needs judgement a lookup cannot settle (why "judgement"), after two of your tool calls failed (why "stuck"), or when a fix of a check still fails (why "check_failed")` : ''}. Lookups you answer yourself: reading or checking many records against a plain rule (an ingredient, a date, a number, a name) is a lookup, done with ${BATCH}, never escalated.`, 'Its task must carry every fact from this chat it needs: it does not see the chat. Then answer from its memo in your own short words.');
871
982
  }
872
983
  if (advisor)
873
984
  lines.push(`Advisor: the advisor tool (no input) asks ${advisor.session.tier.label} to read this chat and send you a plan; it runs no tools. Call it before a question that needs judgement or after a tool failed, then do the work yourself. At most ${ADVICE_MAX} times an answer. Ask the advisor before you escalate.`);
@@ -1020,13 +1131,17 @@ export class Assistant {
1020
1131
  : reply(`Memo from ${item.label}:\n${memo.slice(0, MEMO_CHARS)}`);
1021
1132
  }
1022
1133
  /** One tool call: a draft card for a write, else the call itself, timed. */
1023
- async runTool(use, fns, sink, emit, runs, counts, session, handoff, signal) {
1134
+ async runTool(use, fns, sink, emit, runs, counts, session, handoff, signal,
1135
+ /** Part of a batch: runs and is remembered, but draws no step of its own. */
1136
+ quiet = false) {
1024
1137
  const reply = (content, is_error = false) => ({
1025
1138
  type: 'tool_result',
1026
1139
  tool_use_id: use.id,
1027
1140
  content,
1028
1141
  is_error,
1029
1142
  });
1143
+ if (use.name === BATCH && !fns.has(BATCH) && !quiet)
1144
+ return this.runBatch(use, fns, sink, emit, runs, counts, session, handoff, signal);
1030
1145
  const f = fns.get(use.name);
1031
1146
  if (!f)
1032
1147
  return reply(`No tool ${use.name}.`, true);
@@ -1045,8 +1160,20 @@ export class Assistant {
1045
1160
  await Promise.resolve(this.o.onStep?.({ kind: 'draft', tool: use.name, tier: session.tier.id, handoff })).catch(() => undefined);
1046
1161
  return reply(`Drafted: a "${title}" card is on the person's screen. Nothing changed until they press Save.`);
1047
1162
  }
1163
+ // The same read with the same input in one answer gives back what it gave, without running it
1164
+ // again or showing a second step: a model that lost track of a result reads it here, not twice.
1165
+ const key = f.write ? '' : `${use.name}\u0000${stableJson(use.input)}`;
1166
+ const again = key ? counts.seen.get(key) : undefined;
1167
+ if (again)
1168
+ return reply(`(Same call as earlier in this answer; its result again.)\n${await again}`);
1169
+ let settle = () => undefined;
1170
+ if (key)
1171
+ counts.seen.set(key, new Promise((done) => {
1172
+ settle = done;
1173
+ }));
1048
1174
  const id = newId('s');
1049
- emit('step_start', { id, tool: use.name, title });
1175
+ if (!quiet)
1176
+ emit('step_start', { id, tool: use.name, title });
1050
1177
  const t0 = performance.now();
1051
1178
  let out;
1052
1179
  let ok = true;
@@ -1060,8 +1187,10 @@ export class Assistant {
1060
1187
  out = typeof result === 'string' ? result : JSON.stringify(result);
1061
1188
  }
1062
1189
  catch (err) {
1063
- if (signal?.aborted)
1190
+ if (signal?.aborted) {
1191
+ settle('(stopped)');
1064
1192
  throw err;
1193
+ }
1065
1194
  ok = false;
1066
1195
  error =
1067
1196
  err instanceof ToolError || err instanceof Error ? err.message.slice(0, 300) : 'failed';
@@ -1081,10 +1210,15 @@ export class Assistant {
1081
1210
  ? { write: { undo: !!this.groupById(f.group)?.undo, status: 'done' } }
1082
1211
  : {}),
1083
1212
  };
1084
- sink.push(item);
1085
- emit('step', item);
1086
- counts.steps += 1;
1087
- runs.push({ tool: use.name, result: out.slice(0, RAN_CHARS) });
1213
+ if (!quiet) {
1214
+ sink.push(item);
1215
+ emit('step', item);
1216
+ counts.steps += 1;
1217
+ }
1218
+ // A change made at once can make an earlier read stale: reads after it run again.
1219
+ if (f.write)
1220
+ counts.seen.clear();
1221
+ runs.push({ tool: use.name, key, result: out });
1088
1222
  await Promise.resolve(this.o.onStep?.({
1089
1223
  kind: 'tool',
1090
1224
  tool: use.name,
@@ -1095,9 +1229,78 @@ export class Assistant {
1095
1229
  tier: session.tier.id,
1096
1230
  handoff,
1097
1231
  })).catch(() => undefined);
1098
- const cut = out.length > RESULT_CHARS ? `${out.slice(0, RESULT_CHARS)} …(cut: ask for less)` : out;
1232
+ const cut = out.length > RESULT_CHARS
1233
+ ? `${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)`
1234
+ : out;
1235
+ if (key) {
1236
+ settle(cut);
1237
+ if (!ok)
1238
+ counts.seen.delete(key);
1239
+ }
1099
1240
  return reply(cut, !ok);
1100
1241
  }
1242
+ /** A batch of reads: each runs as its own call (the same read twice runs once), in parallel, shown as one step. */
1243
+ async runBatch(use, fns, sink, emit, runs, counts, session, handoff, signal) {
1244
+ const reply = (content, is_error = false) => ({
1245
+ type: 'tool_result',
1246
+ tool_use_id: use.id,
1247
+ content,
1248
+ is_error,
1249
+ });
1250
+ const raw = use.input?.calls;
1251
+ const calls = (Array.isArray(raw) ? raw : []).map((c) => {
1252
+ const o = (c && typeof c === 'object' ? c : {});
1253
+ return {
1254
+ tool: typeof o.tool === 'string' ? o.tool : '',
1255
+ input: o.input && typeof o.input === 'object' ? o.input : {},
1256
+ };
1257
+ });
1258
+ if (!calls.length)
1259
+ return reply(`Give {calls: [{tool, input}]}: 1 to ${BATCH_MAX} reads.`, true);
1260
+ if (calls.length > BATCH_MAX)
1261
+ return reply(`At most ${BATCH_MAX} calls a batch: split them into batches.`, true);
1262
+ const titles = [...new Set(calls.map((c) => fns.get(c.tool)?.def.title ?? c.tool))];
1263
+ const title = titles.length === 1 ? `${titles[0]} ×${calls.length}` : `${calls.length} reads`;
1264
+ const id = newId('s');
1265
+ emit('step_start', { id, tool: BATCH, title });
1266
+ const t0 = performance.now();
1267
+ const done = await Promise.all(calls.map(async (c, i) => {
1268
+ const f = fns.get(c.tool);
1269
+ if (!f)
1270
+ return { ...c, ok: false, text: `No tool ${c.tool}.` };
1271
+ if (f.draft || f.write)
1272
+ return { ...c, ok: false, text: 'Only reads go in a batch: call this tool on its own.' };
1273
+ const r = await this.runTool({
1274
+ type: 'tool_use',
1275
+ id: `${use.id}_${i}`,
1276
+ name: c.tool,
1277
+ input: c.input,
1278
+ }, fns, sink, emit, runs, counts, session, handoff, signal, true);
1279
+ return { ...c, ok: !r.is_error, text: typeof r.content === 'string' ? r.content : '' };
1280
+ }));
1281
+ const failed = done.filter((d) => !d.ok).length;
1282
+ const item = {
1283
+ kind: 'step',
1284
+ id,
1285
+ tool: BATCH,
1286
+ title,
1287
+ ok: failed < done.length,
1288
+ ms: Math.round(performance.now() - t0),
1289
+ ...(failed ? { error: `${failed} of ${done.length} failed` } : {}),
1290
+ };
1291
+ sink.push(item);
1292
+ emit('step', item);
1293
+ counts.steps += 1;
1294
+ let left = BATCH_CHARS;
1295
+ const parts = done.map((d, i) => {
1296
+ const fits = d.text.length <= left;
1297
+ left -= fits ? d.text.length : 0;
1298
+ return `[${i + 1}] ${d.tool} ${stableJson(d.input)}${d.ok ? '' : ' (failed)'}\n${fits
1299
+ ? d.text
1300
+ : '(left out: the batch passed its size; read this one again in a smaller batch)'}`;
1301
+ });
1302
+ return reply(parts.join('\n\n'), failed === done.length);
1303
+ }
1101
1304
  /** Model calls and tool rounds until the model answers; returns its words. */
1102
1305
  loop(o) {
1103
1306
  const { provider } = o.session.model;
@@ -1493,7 +1696,7 @@ export class Assistant {
1493
1696
  if (doc.history.length < 2)
1494
1697
  return false;
1495
1698
  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
1699
+ const chat = toolMemory(doc.history)
1497
1700
  .map((t) => `${t.role === 'user' ? 'Person' : this.name}: ${t.content}`)
1498
1701
  .join('\n\n');
1499
1702
  let summary = '';
@@ -1554,21 +1757,6 @@ export class Assistant {
1554
1757
  .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
1758
  .join('\n')}\n\n`;
1556
1759
  }
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
1760
  // ---------------------------------------------------------------- keys
1573
1761
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
1574
1762
  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.138",
3
+ "version": "0.1.140",
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.138",
45
- "@columenlabs/lumen-ui-types": "0.1.138",
44
+ "@columenlabs/lumen-ops": "0.1.140",
45
+ "@columenlabs/lumen-ui-types": "0.1.140",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },