@cortad/connector 0.6.2 → 0.6.3

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.
@@ -1,6 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
+ import messages from "../message-text.cjs";
2
3
  import { scrub } from "../redact.mjs";
3
4
 
5
+ const { messageText } = messages;
6
+
4
7
  // Conversations from before, as any source hands them over, made into exactly what Cortad is sent:
5
8
  // the person's and the app's messages, oldest first, with the app's own ids hashed on this machine.
6
9
  // Newest conversations first and whole, from the last 30 days, until 2,500 messages as a plan counts
@@ -11,6 +14,20 @@ const DAY_MS = 86_400_000;
11
14
  const MAX_MESSAGES = 200;
12
15
  const MAX_TEXT = 8_000;
13
16
  const ROLES = { user: "user", human: "user", assistant: "assistant", ai: "assistant", bot: "assistant" };
17
+ // What the app itself knew about the user for a conversation (a grade, a subject), as the export
18
+ // carries it: the server takes 16 at most, each name a word or two and each value a short string.
19
+ const MAX_ATTRIBUTES = 16;
20
+ const MAX_VALUE = 200;
21
+ const ATTRIBUTE_NAME = /^[\p{L}\p{N} _.-]{1,64}$/u;
22
+
23
+ export function attributesOf(raw) {
24
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
25
+ const kept = Object.entries(raw)
26
+ .filter(([name, value]) => ATTRIBUTE_NAME.test(name) && (typeof value === "string" || typeof value === "number" || typeof value === "boolean") && String(value).trim())
27
+ .slice(0, MAX_ATTRIBUTES)
28
+ .map(([name, value]) => [name, cut(String(value).trim(), MAX_VALUE)]);
29
+ return kept.length ? Object.fromEntries(kept) : null;
30
+ }
14
31
 
15
32
  export const sha256 = (text) => createHash("sha256").update(String(text)).digest("hex");
16
33
 
@@ -30,11 +47,6 @@ const cut = (text, max) => {
30
47
  return text.slice(0, end);
31
48
  };
32
49
 
33
- const textOf = (content) => {
34
- if (typeof content === "string") return content;
35
- if (Array.isArray(content)) return content.map((part) => (typeof part === "string" ? part : part?.type === "text" ? part.text : "[image]")).filter(Boolean).join("\n");
36
- return "";
37
- };
38
50
 
39
51
  // One conversation as the contract has it, or the reason it is left out.
40
52
  export function conversationOf(raw, count) {
@@ -47,7 +59,7 @@ export function conversationOf(raw, count) {
47
59
  if (!role) { count("not a person's or the app's message"); continue; }
48
60
  const at = timeOf(m.at ?? m.createdAt ?? m.created_at);
49
61
  if (!at) { count("a time with no zone"); continue; }
50
- const text = textOf(m.text ?? m.content).trim();
62
+ const text = messageText(m.text ?? m.content).trim();
51
63
  if (!text) continue;
52
64
  if (text.length > MAX_TEXT) count("message cut at 8,000 characters");
53
65
  messages.push({ role, text: cut(text, MAX_TEXT), at });
@@ -56,15 +68,17 @@ export function conversationOf(raw, count) {
56
68
  messages.sort((a, b) => a.at - b.at);
57
69
  if (messages.length > MAX_MESSAGES) count("conversation kept to its latest 200 messages");
58
70
  const kept = messages.slice(-MAX_MESSAGES);
59
- return { id, ...(raw.user ? { user: String(raw.user) } : {}), messages: kept, last: kept.at(-1).at };
71
+ const attributes = attributesOf(raw.attributes);
72
+ return { id, ...(raw.user ? { user: String(raw.user) } : {}), ...(attributes ? { attributes } : {}), messages: kept, last: kept.at(-1).at };
60
73
  }
61
74
 
62
- // The conversations to send, newest first, whole, from the window, until the plan's messages; and
63
- // why each one left out was left out.
75
+ // The conversations to send, newest first, whole, from the window, until the messages asked for
76
+ // (`cap`, never past the plan's); and why each one left out was left out.
64
77
  // `key`: the app's ingest key. Ids and users are hashed with it exactly as the SDK hashes them, so a
65
78
  // person brought in from history is the same person when they write to the live app, and a guessable
66
79
  // id (a first name, an email) cannot be read back from its hash without the key.
67
- export function selected(raws, { now = Date.now(), secrets = [], key }) {
80
+ export function selected(raws, { now = Date.now(), secrets = [], key, cap = HISTORY_MESSAGES }) {
81
+ const most = Math.min(cap, HISTORY_MESSAGES);
68
82
  const subject = (kind, value) => sha256(`${key}:${kind}:${value}`);
69
83
  const left = new Map();
70
84
  const count = (why) => { left.set(why, (left.get(why) ?? 0) + 1); return null; };
@@ -74,15 +88,17 @@ export function selected(raws, { now = Date.now(), secrets = [], key }) {
74
88
  const recent = [...byId.values()].filter((c) => c.last.getTime() >= now - HISTORY_DAYS * DAY_MS || count("older than 30 days"));
75
89
  recent.sort((a, b) => b.last - a.last);
76
90
  const out = [];
77
- let room = HISTORY_MESSAGES;
91
+ let room = most;
78
92
  let scrubbed = 0;
79
93
  for (const c of recent) {
80
94
  const replies = c.messages.filter((m) => m.role === "assistant").length;
81
- if (replies > room) { count("past 2,500 messages"); continue; }
95
+ if (replies > room) { count(`past ${most.toLocaleString("en-US")} messages`); continue; }
82
96
  room -= replies;
83
97
  out.push({
84
98
  id: subject("session", c.id),
85
99
  ...(c.user ? { user: subject("user", c.user) } : {}),
100
+ // Scrubbed like the messages: a value is the app's own text and could hold a key.
101
+ ...(c.attributes ? { attributes: Object.fromEntries(Object.entries(c.attributes).map(([k, v]) => { const clean = scrub(v, secrets); scrubbed += clean.hits; return [k, clean.text]; })) } : {}),
86
102
  messages: c.messages.map((m) => {
87
103
  const clean = scrub(m.text, secrets);
88
104
  scrubbed += clean.hits;
@@ -90,7 +106,7 @@ export function selected(raws, { now = Date.now(), secrets = [], key }) {
90
106
  }),
91
107
  });
92
108
  }
93
- return { conversations: out, messages: HISTORY_MESSAGES - room, total: out.reduce((n, c) => n + c.messages.length, 0), scrubbed, left: Object.fromEntries(left), users: new Set(recent.map((c) => c.user).filter(Boolean)).size };
109
+ return { conversations: out, messages: most - room, total: out.reduce((n, c) => n + c.messages.length, 0), scrubbed, left: Object.fromEntries(left), users: new Set(recent.map((c) => c.user).filter(Boolean)).size };
94
110
  }
95
111
 
96
112
  // The span of what will be sent, oldest message to newest.
@@ -0,0 +1,11 @@
1
+ import { BRAINTRUST } from "./braintrust.mjs";
2
+ import { HELICONE } from "./helicone.mjs";
3
+ import { LANGFUSE } from "./langfuse.mjs";
4
+ import { LANGSMITH } from "./langsmith.mjs";
5
+ import { OPIK } from "./opik.mjs";
6
+ import { PHOENIX } from "./phoenix.mjs";
7
+
8
+ // Every tool history can be read from, and which of them an env names every key of. Names only:
9
+ // a value is checked for being there, never read out or sent.
10
+ export const SOURCES = [LANGFUSE, LANGSMITH, HELICONE, BRAINTRUST, PHOENIX, OPIK];
11
+ export const sourcesIn = (env) => SOURCES.filter((s) => s.needs.every((n) => env[n]));
@@ -50,10 +50,10 @@ export function historyQuestion({ spec, sources, stores }) {
50
50
  ];
51
51
  return [
52
52
  "",
53
- "Step 1 of 2: your users' conversations",
53
+ "Step 1 of 2: your users' messages",
54
54
  ...sources.map((s) => `Found ${s.name} (${s.names.join(" and ")} in your env).`),
55
55
  ...(store ? [`Found where this app keeps its conversations: ${storeName(store)}.`] : []),
56
- `Ask the person, with your question tool: "Send our newest whole conversations from the last 30 days to Cortad, until our app's replies reach 2,500, so it can show us our users?"`,
56
+ `Ask the person, with your question tool: "Send our users' newest 2,500 messages from the last 30 days to Cortad, so it can show us our users?" Cortad counts your app's replies and takes each conversation whole.`,
57
57
  ...options.map((o, i) => ` ${i + 1}. ${o}`),
58
58
  ...sources.map((s) => `${s.name} is read from this computer at ${s.host} with the keys in your env; the keys go to ${s.name} only.`),
59
59
  "Before anything is sent, keys, card numbers, emails and phone numbers in the messages are replaced on this computer, and the app's conversation and user ids are hashed. What is sent goes to cortad.com, and a copy of exactly what was sent stays in ~/.cortad on this computer.",
@@ -99,39 +99,60 @@ export const historyNothing = (source) => [`${source} holds no conversations wit
99
99
  export const historySkipped = () => ["Skipped. Cortad starts with your live app's next deploy; history can still be sent later with the same command."];
100
100
 
101
101
  // Step 2: the code change, as lines for the agent to put in, and the one question that settles it.
102
- export function codeStep({ spec, stack, page, file, site, consent, host, settable }) {
103
- const install = stack.kind === "python" ? `${stack.add} cortad==${SDK}` : `${stack.add} @cortad/sdk@${SDK}`;
102
+ // An app in a language with no SDK of ours sends each conversation itself: one signed request, written
103
+ // by the agent in the app's own language. The contract is the backend's POST /v1/conversations.
104
+ const httpSteps = (address) => [
105
+ `1. No package to install: this app's language has no Cortad SDK, so the app sends each conversation in one HTTP request, written in its own language with its standard HTTP and crypto libraries.`,
106
+ `2. After each reply, from the server, never the browser: POST ${address}`,
107
+ ` Body (JSON): {"id": "<the app's own conversation id, the same on every turn>", "messages": [{"role": "user" or "assistant", "text": "<what was said>", "at": "<ISO 8601 time with its zone>"}, ...]}, every message so far, oldest first. A message that is not text (an image, a file, a tool call) goes in as a short marker such as "[image]".`,
108
+ ` Headers: content-type: application/json; x-cortad-key: the first 15 characters of CORTAD_INGEST_KEY; x-cortad-timestamp: now in milliseconds since 1970; x-cortad-nonce: 32 random hex characters, new on every request;`,
109
+ ` x-cortad-signature: "v1=" + hex(HMAC-SHA256(key = hex(SHA-256(CORTAD_INGEST_KEY)) as text, message = "v1." + timestamp + "." + nonce + "." + hex(SHA-256(the exact body bytes))))`,
110
+ `3. Send it only from the deployed app (skip it when CORTAD_ENV=development), off the reply's path so a slow send never holds the person's answer, and log a refusal's status without the key. 202 means it landed.`,
111
+ ];
112
+
113
+ export function codeStep({ spec, stack, page, file, site, consent, host, settable, address = "https://cortad.com/api/v1/conversations" }) {
114
+ if (stack.kind === "other") return stepFrame({ spec, page, file, site, consent, host, settable, steps: httpSteps(address), closing: "Then deploy it: the first conversation on the live app shows in Cortad within a minute." });
115
+ const install = stack.kind === "python" ? `${stack.add} cortad==${SDK.python}` : `${stack.add} @cortad/sdk@${SDK.node}`;
104
116
  const init = stack.kind === "python"
105
117
  ? [" from cortad import cortad", ' bl = cortad(key=os.environ["CORTAD_INGEST_KEY"], capture_content=True, only_run=True)', " bl.install()"]
106
118
  : [' import { cortad } from "@cortad/sdk";', " const bl = cortad({ key: process.env.CORTAD_INGEST_KEY, captureContent: true, onlyRun: true });", " bl.install();"];
107
119
  const wrap = stack.kind === "python"
108
120
  ? [" with bl.run(conversation_id):", " reply = <the call that writes the reply>"]
109
121
  : [" const reply = await bl.run({ id: conversationId }, () => <the call that writes the reply>);"];
110
- return [
111
- "",
112
- "Step 2 of 2: Cortad in your code",
113
- "Make this change yourself; this command edits nothing. Show the person the diff.",
122
+ return stepFrame({ spec, page, file, site, consent, host, settable, steps: [
114
123
  `1. Install the SDK: ${install}`,
115
124
  "2. Where the app starts, once:",
116
125
  ...init,
117
126
  "3. Around the one model call that writes the reply the person reads, with the app's own conversation id, the same on every turn:",
118
127
  ...wrap,
119
128
  " Routing, rewriting and title calls stay outside it. The SDK sends only from the deployed app; this computer's copy reads CORTAD_ENV=development and sends nothing but a hello.",
129
+ ] });
130
+ }
131
+
132
+ function stepFrame({ spec, page, file, site, consent, host, settable, steps, closing = "Then restart the app on this computer and send it one message, so Cortad sees the change work here." }) {
133
+ return [
134
+ "",
135
+ "Step 2 of 2: Cortad in your code",
136
+ "Make this change yourself; this command edits nothing. Show the person the diff.",
137
+ ...steps,
120
138
  ...(page ? [
121
139
  `4. In ${page.file ?? "the page every person loads"}, once: <script src="https://b.cortad.com/i/cortad.js" data-key="${page.key}"${consent ? " data-opt-out-by-default" : ""} async></script>`,
122
140
  " This key is public by design: it only names which site may send what people did around each answer.",
123
141
  ...(consent ? [` This app asks visitors for consent with ${consent}: where it records a yes, call window.cortad?.optIn().`] : []),
124
142
  ] : ["4. This app has no web page of its own, so there is no page tag."]),
125
- "Then restart the app on this computer and send it one message, so Cortad sees the change work here.",
143
+ closing,
126
144
  "",
127
- ...secondQuestion({ spec, host, settable, file, site }),
145
+ ...secondQuestion({ spec, host, settable, file, site, tagged: Boolean(page) }),
128
146
  ];
129
147
  }
130
- export const SDK = "0.3.3";
148
+ // The SDK each kind of app installs, one place for each package: the Node one is 0.3.5 (0.3.3 and 0.3.4
149
+ // crash an app that loads it with require), the Python one is at its own 0.3.4.
150
+ export const SDK = { node: "0.3.5", python: "0.3.4" };
131
151
 
132
- function secondQuestion({ spec, host, settable, file, site }) {
152
+ // The address is asked only of an app with a page tag: its key is what the address locks.
153
+ function secondQuestion({ spec, host, settable, file, site, tagged }) {
133
154
  const h = HOSTS[host] ?? HOSTS.other;
134
- const siteAsk = site ? [] : [`In the same form, ask: "Which address do people open your app at?" Then run npx -y ${spec} intelligence site <that address>, so the page key answers that site and no other.`];
155
+ const siteAsk = site || !tagged ? [] : [`In the same form, ask: "Which address do people open your app at?" Then run npx -y ${spec} intelligence site <that address>, so the page key answers that site and no other.`];
135
156
  if (settable) {
136
157
  return [
137
158
  `Ask the person: "Commit this on a new branch named cortad-intelligence, and add CORTAD_INGEST_KEY to ${h.name} production? Nothing live changes until you deploy."`,
@@ -156,11 +177,13 @@ export const siteLocked = (site) => [`The page key now answers ${site} and no ot
156
177
  export const workingHere = (at) => `Cortad started with your app on this computer at ${new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}. The change works here.`;
157
178
  export const live = (connection) => `Your live app started with Cortad${connection.started?.release ? ` on version ${connection.started.release.slice(0, 7)}` : ""}.`;
158
179
 
159
- export function findings({ history, problems, where, done }) {
180
+ // Said in messages, the number the person chose; in conversations where the server says only those.
181
+ export function findings({ history, replies, problems, where, done }) {
160
182
  if (history.conversations === 0) return [];
161
- const read = `${num(history.read)} of ${num(history.conversations)} conversations read`;
183
+ const [n, of, unit] = replies?.of ? [replies.read, replies.of, "messages"] : [history.read, history.conversations, "conversations"];
184
+ const read = `${num(n)} of ${num(of)} ${unit} read`;
162
185
  if (history.read === 0) return [`${read}. Reading has started.`];
163
- const head = done ? `All ${num(history.conversations)} conversations read.` : `${read} so far.`;
186
+ const head = done ? `All ${num(of)} ${unit} read.` : `${read} so far.`;
164
187
  if (problems.length === 0) return [head, "No behavioral question failed in what was read.", `Every conversation is at ${where}.`];
165
188
  return [
166
189
  head,
@@ -7,20 +7,16 @@ import { parseEnv } from "./env-files.mjs";
7
7
  import { homeOf } from "./home.mjs";
8
8
  import { pair } from "./intelligence-pair.mjs";
9
9
  import { canSet, hostOf, HOSTS, setKey } from "./hosts.mjs";
10
- import { looksLikeDevelopment, selected, spanOf } from "./history/contract.mjs";
11
- import { BRAINTRUST } from "./history/braintrust.mjs";
12
- import { HELICONE } from "./history/helicone.mjs";
13
- import { LANGFUSE } from "./history/langfuse.mjs";
14
- import { LANGSMITH } from "./history/langsmith.mjs";
15
- import { OPIK } from "./history/opik.mjs";
16
- import { PHOENIX } from "./history/phoenix.mjs";
10
+ import { HISTORY_MESSAGES, looksLikeDevelopment, selected, spanOf } from "./history/contract.mjs";
17
11
  import { packed, sendAll } from "./history/send.mjs";
12
+ import { SOURCES, sourcesIn } from "./history/sources.mjs";
13
+ import { changesIn, report } from "./setup-live.mjs";
18
14
  import * as say from "./intelligence-text.mjs";
19
15
 
20
16
  // `npx cortad intelligence`: Cortad Intelligence set up from this folder, one step per run. Each run
21
17
  // reads where things stand (Cortad's facts about this app, and this folder) and prints what is done
22
18
  // and the one next step, with the question for the person where one is theirs. It edits no code.
23
- export const SOURCES = [LANGFUSE, LANGSMITH, HELICONE, BRAINTRUST, PHOENIX, OPIK];
19
+ export { SOURCES };
24
20
  const ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local", ".dev.vars"];
25
21
  const WAIT_MS = 9 * 60_000;
26
22
  const POLL_MS = 5_000;
@@ -44,7 +40,11 @@ export async function intelligence(args, ctx) {
44
40
  const flag = (name) => rest.includes(name);
45
41
  const value = (name) => { const i = rest.indexOf(name); return i === -1 ? null : rest[i + 1] ?? null; };
46
42
 
47
- if (sub === "history") return history(ctx, api, { from: value("--from"), stdin: flag("--stdin"), skip: flag("--skip"), dry: flag("--dry-run"), anyway: flag("--anyway") });
43
+ if (sub === "history") {
44
+ const cap = value("--messages");
45
+ if (cap !== null && !capOk(cap)) return { text: say.lines(`--messages is a whole number from 1 to ${HISTORY_MESSAGES.toLocaleString("en-US")}: how many of your users' newest messages to bring in.`), code: 1 };
46
+ return history(ctx, api, { from: value("--from"), stdin: flag("--stdin"), skip: flag("--skip"), dry: flag("--dry-run"), anyway: flag("--anyway"), cap: cap === null ? HISTORY_MESSAGES : Number(cap) });
47
+ }
48
48
  if (sub === "key") return key(ctx, value("--to"));
49
49
  if (sub === "site") return site(ctx, api, rest[0]);
50
50
  return start(ctx, api, { wait: sub === "--wait" || flag("--wait") });
@@ -61,13 +61,16 @@ export async function intelligence(args, ctx) {
61
61
  const { connection, history: past, stores } = facts.data;
62
62
  if (past.state === "none") {
63
63
  const env = envHere();
64
- const sources = SOURCES.filter((s) => s.needs.every((n) => env[n])).map((s) => ({ ...s, names: s.needs, host: hostName(s.hostOf(env)) }));
64
+ const sources = sourcesIn(env).map((s) => ({ ...s, names: s.needs, host: hostName(s.hostOf(env)) }));
65
65
  out.push(...say.historyQuestion({ spec, sources, stores: stores ?? [] }));
66
66
  return { text: say.lines(out) };
67
67
  }
68
68
  if (connection.started) out.push(say.live(connection));
69
69
  else if (connection.here) out.push(say.workingHere(connection.here.at));
70
- if (!connection.started) out.push(...codeStepFor({ ...keys, site: keys.site ?? connection.site?.origin ?? null }));
70
+ if (!connection.started) {
71
+ out.push(...codeStepFor({ ...keys, site: keys.site ?? connection.site?.origin ?? null }));
72
+ await reportCode();
73
+ }
71
74
  const progress = await (wait ? waited(call) : call("/mcp/history"));
72
75
  if (progress.ok) out.push("", ...say.findings({ ...progress.data, done: progress.data.history.read >= progress.data.history.conversations }));
73
76
  if (progress.ok && progress.data.history.read < progress.data.history.conversations && !wait) out.push(say.next(spec, "--wait"));
@@ -96,14 +99,34 @@ export async function intelligence(args, ctx) {
96
99
  return { lines, file: file ?? join(homeOf(ctx.project), "keys.env"), site: answer.data.site ?? detected, page: answer.data.page ?? (file ? publicKeyIn(root, file) : null) };
97
100
  }
98
101
 
102
+ // Told to the agent just now, or already in the working tree: the card's code step reads it.
103
+ async function reportCode() {
104
+ const found = changesIn(root);
105
+ await live({ sdk: found.sdk ? "branch" : "busy", ...(pageFileOf(root) ? { tag: found.tag ? "branch" : "busy" } : { tag: "none" }) });
106
+ }
107
+
108
+ function live(body) {
109
+ return report({ apiBase: ctx.apiBase, token: token(), ...(ctx.fetchImpl ? { fetchImpl: ctx.fetchImpl } : {}) }, body);
110
+ }
111
+
99
112
  function codeStepFor(keys) {
100
113
  const pageFile = pageFileOf(root);
101
114
  const host = hostOf(root);
102
115
  const page = keys.page ? { file: pageFile, key: keys.page } : null;
103
- return say.codeStep({ spec, stack: stackOf(root), page: pageFile ? page : null, file: keys.file, site: keys.site, consent: consentOf(root, pageFile), host, settable: canSet(root, host) });
116
+ return say.codeStep({ spec, stack: stackOf(root), page: pageFile ? page : null, file: keys.file, site: keys.site, consent: consentOf(root, pageFile), host, settable: canSet(root, host), address: `${ctx.apiBase}/v1/conversations` });
104
117
  }
105
118
 
119
+ // The send as the card sees it: under way from the first read, done once Cortad has taken it, and
120
+ // back to not started when it stops short.
106
121
  async function history(ctx_, call, opts) {
122
+ if (opts.skip || opts.dry) return send(ctx_, call, opts);
123
+ await live({ history: "busy" });
124
+ const out = await send(ctx_, call, opts);
125
+ await live({ history: out.code ? "next" : out.sent ? "done" : "busy" });
126
+ return out;
127
+ }
128
+
129
+ async function send(ctx_, call, opts) {
107
130
  if (opts.skip) {
108
131
  const skipped = await call("/mcp/history/skip", { method: "POST" });
109
132
  if (!skipped.ok) return refused(skipped);
@@ -137,7 +160,7 @@ export async function intelligence(args, ctx) {
137
160
  const label = SOURCES.find((s) => s.id === source)?.name ?? "your export";
138
161
  const key = ingestKeyHere();
139
162
  if (!key) return { text: say.lines(`This folder has no CORTAD_INGEST_KEY yet. Run npx -y ${spec} intelligence first.`), code: 1 };
140
- const picked = selected(raws, { key, secrets: secretValues(ENV_FILES.map((f) => join(root, f)).filter(existsSync)).filter(credentialShaped) });
163
+ const picked = selected(raws, { key, cap: opts.cap, secrets: secretValues(ENV_FILES.map((f) => join(root, f)).filter(existsSync)).filter(credentialShaped) });
141
164
  if (picked.conversations.length === 0) return { text: say.lines(say.historyNothing(label)), code: 1 };
142
165
  const sends = packed(source, picked.conversations);
143
166
  const file = join(homeOf(ctx.project), "history.json");
@@ -149,7 +172,7 @@ export async function intelligence(args, ctx) {
149
172
  if (opts.dry) return { text: say.lines(say.historyDry({ picked, span, file })) };
150
173
  const result = await sendAll(sends, { api: ctx.apiBase, token: token() });
151
174
  if (result.refused) return { text: say.lines(`Cortad did not take the rest: ${result.refused}`, `${result.taken} conversations were taken before that.`), code: 1 };
152
- return { text: say.lines(say.historySent({ result, picked, span, source: label, file }), say.next(spec)) };
175
+ return { text: say.lines(say.historySent({ result, picked, span, source: label, file }), say.next(spec)), sent: true };
153
176
  }
154
177
 
155
178
  // The ingest key as this folder holds it: in its env file, or in keys.env when a manager holds its settings.
@@ -198,6 +221,7 @@ export async function intelligence(args, ctx) {
198
221
  }
199
222
  }
200
223
 
224
+ const capOk = (raw) => /^[1-9]\d{0,4}$/.test(raw) && Number(raw) <= HISTORY_MESSAGES;
201
225
  const hostName = (address) => { try { return new URL(address).host; } catch { return address; } };
202
226
 
203
227
  // Lines of JSON, one conversation each; a first line naming the source is optional. A line that is