@cortad/connector 0.6.0 → 0.6.1

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.
@@ -61,7 +61,11 @@ export function conversationOf(raw, count) {
61
61
 
62
62
  // The conversations to send, newest first, whole, from the window, until the plan's messages; and
63
63
  // why each one left out was left out.
64
- export function selected(raws, { now = Date.now(), secrets = [] } = {}) {
64
+ // `key`: the app's ingest key. Ids and users are hashed with it exactly as the SDK hashes them, so a
65
+ // person brought in from history is the same person when they write to the live app, and a guessable
66
+ // 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 }) {
68
+ const subject = (kind, value) => sha256(`${key}:${kind}:${value}`);
65
69
  const left = new Map();
66
70
  const count = (why) => { left.set(why, (left.get(why) ?? 0) + 1); return null; };
67
71
  const all = raws.map((raw) => conversationOf(raw, count)).filter(Boolean);
@@ -77,8 +81,8 @@ export function selected(raws, { now = Date.now(), secrets = [] } = {}) {
77
81
  if (replies > room) { count("past 2,500 messages"); continue; }
78
82
  room -= replies;
79
83
  out.push({
80
- id: sha256(c.id),
81
- ...(c.user ? { user: sha256(c.user) } : {}),
84
+ id: subject("session", c.id),
85
+ ...(c.user ? { user: subject("user", c.user) } : {}),
82
86
  messages: c.messages.map((m) => {
83
87
  const clean = scrub(m.text, secrets);
84
88
  scrubbed += clean.hits;
@@ -53,7 +53,7 @@ export function historyQuestion({ spec, sources, stores }) {
53
53
  "Step 1 of 2: your users' conversations",
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 most recent conversations to Cortad, up to 2,500 messages from the last 30 days, so it can show us our users?"`,
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?"`,
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.",
@@ -65,7 +65,7 @@ const storeName = (s) => `${s.where ? `the ${s.where} ${s.kind === "postgres" ?
65
65
 
66
66
  export const exportContract = (spec) => [
67
67
  "For our database: write a one-off script in this folder that reads the conversations with this app's own database client, through the connection this project's env on this computer already has. Do not commit it. Open a read-only transaction and look for no other credentials.",
68
- "Read at most the newest 2,500 conversations from the last 30 days: pick their ids first, then their messages. Cortad keeps whole conversations, newest first, up to 2,500 messages. Print first one line naming the database host it read, then one JSON line per conversation:",
68
+ "Read the newest conversations from the last 30 days, at most 2,500 of them: pick their ids first, then their messages. Cortad keeps them whole, newest first, until your app's replies reach 2,500. Print first one line naming the database host it read, then one JSON line per conversation:",
69
69
  ` {"source": {"kind": "postgres", "host": "<the database host from the connection>"}}`,
70
70
  ` {"id": "<the app's conversation id>", "user": "<the app's user id, if it has one>", "messages": [{"role": "user" or "assistant", "text": "...", "at": "2026-11-08T14:02:11Z"}]}`,
71
71
  `Times need a zone (Z or +03:00) or epoch milliseconds. Tell the person which database host it reads. Pipe it to npx -y ${spec} intelligence history --stdin, then delete the script.`,
@@ -67,7 +67,7 @@ export async function intelligence(args, ctx) {
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));
70
+ if (!connection.started) out.push(...codeStepFor({ ...keys, site: keys.site ?? connection.site?.origin ?? null }));
71
71
  const progress = await (wait ? waited(call) : call("/mcp/history"));
72
72
  if (progress.ok) out.push("", ...say.findings({ ...progress.data, done: progress.data.history.read >= progress.data.history.conversations }));
73
73
  if (progress.ok && progress.data.history.read < progress.data.history.conversations && !wait) out.push(say.next(spec, "--wait"));
@@ -135,7 +135,9 @@ export async function intelligence(args, ctx) {
135
135
  return { text: say.lines("Name where the conversations come from: --from <tool>, --stdin for your own export, or --skip."), code: 1 };
136
136
  }
137
137
  const label = SOURCES.find((s) => s.id === source)?.name ?? "your export";
138
- const picked = selected(raws, { secrets: secretValues(ENV_FILES.map((f) => join(root, f)).filter(existsSync)).filter(credentialShaped) });
138
+ const key = ingestKeyHere();
139
+ 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) });
139
141
  if (picked.conversations.length === 0) return { text: say.lines(say.historyNothing(label)), code: 1 };
140
142
  const sends = packed(source, picked.conversations);
141
143
  const file = join(homeOf(ctx.project), "history.json");
@@ -150,10 +152,16 @@ export async function intelligence(args, ctx) {
150
152
  return { text: say.lines(say.historySent({ result, picked, span, source: label, file }), say.next(spec)) };
151
153
  }
152
154
 
153
- function key(ctx_, host) {
155
+ // The ingest key as this folder holds it: in its env file, or in keys.env when a manager holds its settings.
156
+ function ingestKeyHere() {
154
157
  const file = settingsOf(root).file ?? join(homeOf(ctx.project), "keys.env");
155
158
  const path = file.startsWith("/") ? file : join(root, file);
156
- const value = parseEnv(existsSync(path) ? readFileSync(path, "utf8") : "")["CORTAD_INGEST_KEY"];
159
+ return parseEnv(existsSync(path) ? readFileSync(path, "utf8") : "")["CORTAD_INGEST_KEY"] ?? null;
160
+ }
161
+
162
+ function key(ctx_, host) {
163
+ const file = settingsOf(root).file ?? join(homeOf(ctx.project), "keys.env");
164
+ const value = ingestKeyHere();
157
165
  if (!value) return { text: say.lines(`There is no CORTAD_INGEST_KEY in ${file} yet. Run npx -y ${spec} intelligence first.`), code: 1 };
158
166
  if (!host || !HOSTS[host]) return { text: say.lines(`Name the host: --to ${Object.keys(HOSTS).filter((h) => h !== "other").join(", ")}.`), code: 1 };
159
167
  if (!canSet(root, host)) return { text: say.lines(say.keyNotSet(host, `its command is not installed, signed in or linked to this folder`, `${file} (line ${lineOf(root, file, "CORTAD_INGEST_KEY")})`)), code: 1 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortad/connector",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"