@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.
- package/lib/behavior-text.mjs +239 -0
- package/lib/cli.mjs +22 -2
- package/lib/dissection-text.mjs +1 -1
- package/lib/doors.mjs +61 -38
- package/lib/history/contract.mjs +29 -13
- package/lib/history/sources.mjs +11 -0
- package/lib/intelligence-text.mjs +39 -16
- package/lib/intelligence.mjs +38 -14
- package/lib/layer.mjs +293 -1026
- package/lib/mcp.mjs +4 -4
- package/lib/message-text.cjs +48 -0
- package/lib/proxy.mjs +25 -0
- package/lib/replay.mjs +3 -1
- package/lib/setup-live-text.mjs +19 -0
- package/lib/setup-live.mjs +35 -0
- package/lib/start.mjs +22 -0
- package/lib/text.mjs +1 -1
- package/lib/trace.cjs +7 -1
- package/lib/verbs.mjs +38 -7
- package/lib/wire.cjs +10 -2
- package/local.mjs +25 -11
- package/package.json +6 -1
- package/skill/SKILL.md +46 -71
- package/skill/references/results.md +54 -8
package/lib/history/contract.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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:
|
|
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'
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
package/lib/intelligence.mjs
CHANGED
|
@@ -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
|
|
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")
|
|
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 =
|
|
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)
|
|
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
|