cortad 0.3.2 → 0.3.4

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.
@@ -34,6 +34,10 @@ const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable"
34
34
  // so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
35
35
  const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
36
36
  const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
37
+ // A pair that opens a store or a mail server is never the app's own account: PG_DB_USER with
38
+ // PG_DB_PASSWORD signs in to Postgres, and offered to a sign-in form it sent a database password
39
+ // to the app's login route.
40
+ const SERVICE_PREFIX = /(?:^|_)(?:DB|DATABASE|SQL|PG|PSQL|POSTGRES(?:QL)?|MYSQL|MARIADB|MSSQL|MONGO(?:DB)?|REDIS|VALKEY|ELASTIC(?:SEARCH)?|OPENSEARCH|ES|QDRANT|NEO4J|CLICKHOUSE|RABBIT(?:MQ)?|AMQP|KAFKA|SMTP|MAIL|EMAIL|S3|MINIO|FTP|SFTP|LDAP|PROXY)(?:_|$)/;
37
41
  const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
38
42
  const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
39
43
  const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
@@ -586,7 +590,7 @@ export function credentialPairs(names) {
586
590
  const seen = new Set();
587
591
  for (const u of users) {
588
592
  const s = secrets.find((x) => x.prefix === u.prefix);
589
- if (s && !seen.has(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
593
+ if (s && !seen.has(u.prefix) && !SERVICE_PREFIX.test(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
590
594
  }
591
595
  return pairs;
592
596
  }
package/lib/keeping.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { createHash } from "node:crypto";
6
6
  import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
7
7
  import { keepData } from "./data.mjs";
8
- import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedLines } from "./stores.mjs";
8
+ import { bypassedOf, isStorePort, onThisMachine, storeId, storeKeeper, storesOf, unnamedHeld } from "./stores.mjs";
9
9
 
10
10
  // A line that opens with a setting's name keeps its case: "nEXT_PUBLIC_SUPABASE_URL" was printed.
11
11
  const lower = (line) => (/^[A-Z][A-Z0-9_]/.test(line) ? line : line.charAt(0).toLowerCase() + line.slice(1));
@@ -34,17 +34,26 @@ function blockedRows(blocked) {
34
34
  // folder and its source files. launch(plan): a service of the repository started the way the app
35
35
  // is. connections(): what the hook saw the app reach. hold(): the run's requests wait from here until
36
36
  // the caller has the app on the copies made, since until then it still writes into the originals.
37
- export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {} }) {
37
+ // `typed`: the person gave the start command. It is run as it is, with the settings and the stores
38
+ // that command gives the app: a legal research API typed ENV=staging and was started on copies of
39
+ // the test databases its default settings file names, where its library is empty.
40
+ const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
41
+ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {}, typed = false }) {
38
42
  let pending = null;
39
43
  let now = null;
40
44
  let backing = null;
41
45
  let backedUp = false;
42
46
  let seen = 0;
43
- const told = new Set();
44
47
  const tell = (lines) => { for (const line of lines) say(lower(line)); };
45
48
  const held = (blocked) => tell(blocked.map((b) => `Run waits for your yes on the card in the browser: ${lower(b.why)}`));
46
49
 
47
50
  async function keepAll(started) {
51
+ if (typed) {
52
+ const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
53
+ now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {} }, values: {}, started };
54
+ tell(now.notes);
55
+ return now;
56
+ }
48
57
  const { values, from, appDir, sources } = settings();
49
58
  const disk = keepData({ values, from, appDir, root, work, sources, started });
50
59
  const stores = storesOf(disk.values);
@@ -61,6 +70,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
61
70
 
62
71
  // Brings up what is wanted, copies each store that now answers, and says both.
63
72
  async function bringUp(wants) {
73
+ if (typed) return { changed: false, came: false, copied: false };
64
74
  const k = await once(true);
65
75
  hold();
66
76
  const up = await backing.up(wants);
@@ -136,14 +146,11 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
136
146
  const k = await pending;
137
147
  const live = [];
138
148
  for (const c of conns) if (!onThisMachine(c.host) || (await tcpOpen(c.host, c.port))) live.push(c);
139
- const fresh = unnamedLines(live, k.stores, told);
140
- k.risks.push(...fresh);
141
- tell(fresh);
142
- const bypassed = bypassedOf(live, k.stores, k.blocked);
149
+ const bypassed = [...bypassedOf(live, k.stores, k.blocked), ...unnamedHeld(live, k.stores, k.blocked)];
143
150
  k.blocked.push(...bypassed);
144
151
  held(bypassed);
145
- const got = await bringUp(reached(appPort));
146
- return { changed: fresh.length > 0 || bypassed.length > 0 || got.changed, copied: got.copied };
152
+ const got = typed ? { changed: false, copied: false } : await bringUp(reached(appPort));
153
+ return { changed: bypassed.length > 0 || got.changed, copied: got.copied };
147
154
  },
148
155
  // An app attached to instead of started keeps its own connections: its copies are of no use.
149
156
  async forget() {
@@ -26,6 +26,23 @@ RECENT_KEPT, RECENT_MS = 32, 300000
26
26
  # turn (a chat UI shows the message with one event, then answers it with the next).
27
27
  BEFORE_MS = 60000
28
28
  PROMPTS_KEPT, PROMPT_HEAD = 64, 500
29
+ # How much of a sentence the model wrote a fetched reply has to carry to be the answer.
30
+ CARRY = 60
31
+ # tied.cjs UNDER_WAY: a job's own word for work still going on.
32
+ UNDER_WAY = re.compile(r"^(?:queued|pending|waiting|scheduled|submitted|accepted|created|starting|started|running|processing|generating|working|thinking|searching|retrieving|streaming|in[_ -]?progress|not[_ -]?(?:started|ready))$", re.I)
33
+
34
+
35
+ def _under_way(v, depth=0):
36
+ if depth > 4:
37
+ return False
38
+ if isinstance(v, list):
39
+ return bool(v) and _under_way(v[-1], depth + 1)
40
+ if not isinstance(v, dict):
41
+ return False
42
+ state = v.get("status", v.get("state"))
43
+ if isinstance(state, str) and UNDER_WAY.match(state):
44
+ return True
45
+ return any(isinstance(x, (list, dict)) and _under_way(x, depth + 1) for x in v.values())
29
46
  # The ask the last call pinned in this handler's context was made for. A queue runs each handler in a
30
47
  # context of its own, so its later calls, which carry none of the person's words, are told apart by
31
48
  # it when several asks are open at once. Only a tiebreak: it never outranks what the rules decide.
@@ -68,10 +85,23 @@ def _address_ids(req):
68
85
  return {v for v in path.split("/") + [v for _, v in parse_qsl(query)] if ID.match(v)}
69
86
 
70
87
 
88
+ # tied.cjs saidSentences: a JSON answer the clip cut short is read by its strings.
89
+ def _said_sentences(text):
90
+ text = text.decode("utf-8", "replace") if isinstance(text, (bytes, bytearray)) else str(text or "")
91
+ v = _json(text)
92
+ whole = [s for s in (_scalars(v, []) if v is not None else [text]) if _sentence(s)]
93
+ if len(whole) != 1 or not re.match(r"\s*[\[{]", whole[0]):
94
+ return whole
95
+ return [m for m in re.findall(r'"((?:[^"\\]|\\.){8,})"', whole[0]) if _sentence(m)]
96
+
97
+
71
98
  class Tied:
72
99
  # `body_of(req)` and `reply_of(req)` read what a request carried and what the app answered.
73
- def __init__(self, norm, body_of, reply_of):
100
+ # `said_of(req)`: what the model calls made for an ask answered later have written, one entry a
101
+ # call and none before one has, None where the hook hears none (tied.cjs saidOf).
102
+ def __init__(self, norm, body_of, reply_of, said_of=None):
74
103
  self.norm, self.body_of, self.reply_of = norm, body_of, reply_of
104
+ self.said_of = said_of or (lambda req: None)
75
105
  self.open = {}
76
106
  self.recent = collections.deque(maxlen=RECENT_KEPT)
77
107
  # The heads of the prompts this process sent: one arriving at a server in the same process (an
@@ -127,7 +157,7 @@ class Tied:
127
157
  now = int(time.time() * 1000)
128
158
  for t in reversed(list(self.recent)) if mine else []:
129
159
  # A request answered later is fetched afterwards only by a read of it, never by a new message.
130
- if t["at"] <= req["at"] and now - t["at"] <= RECENT_MS and (t.get("pinned") or not pinned_only) and (not t.get("settled") or req["method"] == "GET"):
160
+ if t["at"] <= req["at"] and now - t.get("call_at", t["at"]) <= RECENT_MS and (t.get("pinned") or not pinned_only) and (not t.get("settled") or req["method"] == "GET"):
131
161
  shared = mine & self._ids(t)
132
162
  if shared:
133
163
  return t, shared
@@ -146,7 +176,7 @@ class Tied:
146
176
  def closed(self, req):
147
177
  self.open.pop(req["id"], None)
148
178
  asker = req.get("asker")
149
- if asker and not asker.get("written") and not asker.get("calls_open"):
179
+ if asker and not asker.get("written") and not asker.get("calls_open") and self._carries(req, asker):
150
180
  since = max([self.answered[i] for i in req["tie"] if self.answered.get(i, asker["at"]) < asker["at"]], default=0)
151
181
  before = [p for p in list(self.recent) if p is not asker and not p.get("settled") and since < p["at"] < asker["at"] and asker["at"] - p["at"] <= BEFORE_MS and self._ids(p) & req["tie"]]
152
182
  for s in before + [asker]:
@@ -161,6 +191,18 @@ class Tied:
161
191
  self.recent.append(req)
162
192
  return None
163
193
 
194
+ # tied.cjs carries: a page polling for an answer several calls build comes back many times
195
+ # between two of them, on a document with no answer in it yet.
196
+ def _carries(self, fetch, asker):
197
+ said = self.said_of(asker)
198
+ if said is None:
199
+ return True
200
+ raw = self.reply_of(fetch)
201
+ if _under_way(_json(raw)):
202
+ return False
203
+ reply = self.norm(raw)
204
+ return any(self.norm(w)[:CARRY] in reply for one in (said if isinstance(said, (list, tuple)) else [said]) for w in _said_sentences(one))
205
+
164
206
  def _answered(self, tie, at):
165
207
  for i in tie:
166
208
  self.answered.pop(i, None)
@@ -230,6 +272,7 @@ class Tied:
230
272
  asker["late"] = True
231
273
  asker["pinned"] = True
232
274
  asker["calls_open"] = asker.get("calls_open", 0) + 1
275
+ asker["call_at"] = int(time.time() * 1000)
233
276
  _HELD.set(asker)
234
277
  return asker
235
278
 
@@ -260,6 +303,7 @@ class Tied:
260
303
  if req.get("late"):
261
304
  req["pinned"] = True
262
305
  req["calls_open"] = req.get("calls_open", 0) + 1
306
+ req["call_at"] = int(time.time() * 1000)
263
307
  else:
264
308
  req["waiting"] = req.get("waiting", 0) + 1
265
309
  return req
@@ -285,7 +329,7 @@ class Tied:
285
329
  # request tied to it came to fetch the answer. It stays where a fetch that comes later can still
286
330
  # find it and complete the exchange on a second request, but no call is pinned to it by its words.
287
331
  def settled(self, req):
288
- if not req or not req.get("late") or not req.get("ended") or req.get("calls_open") or req.get("waiting") or req.get("written") or req.get("settled") or req.get("fetched"):
332
+ if not req or not req.get("late") or not req.get("ended") or req.get("calls_open") or req.get("waiting") or req.get("written") or req.get("settled") or (req.get("fetched") and self.said_of(req) is None):
289
333
  return False
290
334
  req["settled"] = req["answered"] = True
291
335
  return True
@@ -1548,7 +1548,7 @@ def _install():
1548
1548
 
1549
1549
  try:
1550
1550
  from cortad_tied import Tied
1551
- tied = Tied(norm, body_of, reply_of)
1551
+ tied = Tied(norm, body_of, reply_of, lambda req: req.get("said_all", []) if req.get("late") else None)
1552
1552
  except Exception:
1553
1553
  tied = None
1554
1554
 
@@ -1749,6 +1749,7 @@ def _install():
1749
1749
  if req and req.get("late"):
1750
1750
  if not row.get("embedding"):
1751
1751
  req["said"] = (row["status"], reply_text(events)[:model_words], now)
1752
+ req["said_all"] = (req.get("said_all", []) + [req["said"][1]])[-12:]
1752
1753
  settle(req)
1753
1754
  row.update(inside_of(req))
1754
1755
  if req and (req.get("kept") or req.get("turn")):
package/lib/stores.mjs CHANGED
@@ -21,7 +21,7 @@ export const PORT_OF = { postgres: 5432, mysql: 3306, mongo: 27017, redis: 6379,
21
21
  const ENGINE_AT = { ...Object.fromEntries(Object.entries(PORT_OF).filter(([e]) => e !== "chroma" && e !== "weaviate").map(([e, p]) => [p, e])), 6334: "qdrant" };
22
22
  export const isStorePort = (port) => Boolean(ENGINE_AT[port]);
23
23
  // Stores reached through their maker's SDK, which a setting rarely names: the hook sees the host.
24
- const HOSTED = [["supabase", /\.supabase\.(?:co|in|com)$/], ["mongo", /\.mongodb\.net$/], ["redis", /\.upstash\.io$|\.redis-cloud\.com$|\.redislabs\.com$/], ["postgres", /\.neon\.tech$/], ["pinecone", /\.pinecone\.io$/], ["qdrant", /\.qdrant\.io$/], ["firestore", /^firestore\.googleapis\.com$/]];
24
+ const HOSTED = [["supabase", /\.supabase\.(?:co|in|com)$/], ["mongo", /\.mongodb\.net$/], ["elasticsearch", /\.elastic-cloud\.com$|\.found\.io$|\.es\.amazonaws\.com$/], ["redis", /\.upstash\.io$|\.redis-cloud\.com$|\.redislabs\.com$/], ["postgres", /\.neon\.tech$/], ["pinecone", /\.pinecone\.io$/], ["qdrant", /\.qdrant\.io$/], ["firestore", /^firestore\.googleapis\.com$/]];
25
25
  // How each store we copy is copied and dropped: on its own server, under the name given.
26
26
  const COPY = {
27
27
  postgres: { copy: (s, clone) => clonePg(s.conn, s.db, clone), drop: (s, clone) => dropPg(s.conn, clone) },
@@ -39,6 +39,10 @@ const WORD_ENGINE = [["postgres", /^(?:PG|POSTGRES|PSQL)|POSTGRES/], ["mysql", /
39
39
  const SECRETISH = /(?:^|_)(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD)$/;
40
40
  // Every engine's word in a setting's name, so a setting that carries one is that engine's own.
41
41
  const ENGINE_WORD = new RegExp([...Object.keys(ENGINE), ...[...BY_NAME, ...WORD_ENGINE].map(([, re]) => re.source)].join("|"), "i");
42
+ // A settings file's values without the ones that name a store. An app the person started with
43
+ // their own command is handed the rest: which databases it uses is that command's to say.
44
+ const STORE_SETTING = new RegExp(`${ENGINE_WORD.source}|(?:^|_)(?:DB|DATABASE)(?:_|$)`, "i");
45
+ export const withoutStoreSettings = (values) => Object.fromEntries(Object.entries(values).filter(([name]) => !STORE_SETTING.test(name)));
42
46
  // Stores that hold no collections: a bare COLLECTION setting beside one of them is still Qdrant's.
43
47
  const NO_COLLECTIONS = new Set(["redis", "mysql", "sqlserver", "neo4j", "meilisearch", "pinecone"]);
44
48
  const LOOPBACK = /^(?:localhost|127(?:\.\d+){3}|::1|\[::1\]|0\.0\.0\.0|host\.docker\.internal)?$/i;
@@ -329,19 +333,19 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
329
333
  }
330
334
 
331
335
  // The stores the app reached that no setting names, as the hook saw each connection: a store written
332
- // into a config file rather than the environment, or one its maker's SDK finds by itself. One
333
- // sentence per store, said once.
334
- export function unnamedLines(connections, stores, told) {
335
- const lines = [];
336
+ // into a config file rather than the environment, one its maker's SDK finds by itself, or the ones an
337
+ // app started with the person's own command was given. No copy stands in for any of them, so each
338
+ // holds Run for the person's yes, once. `blocked`: the rows already holding it.
339
+ export function unnamedHeld(connections, stores, blocked) {
340
+ const rows = [];
336
341
  for (const { host, port } of connections) {
337
342
  const local = onThisMachine(host);
338
- const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0];
339
- const key = local ? `localhost:${port}` : `${engine}:${String(host).toLowerCase()}`;
340
- if (!engine || told.has(key) || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === key : s.engine === engine && !onThisMachine(s.host)))) continue;
341
- told.add(key);
342
- lines.push(local
343
- ? `Your app connects to a ${ENGINE[engine]} server on this machine that none of its settings names, so it cannot be pointed at a copy: what test conversations create there stays there.`
344
- : `Your app reaches ${ENGINE[engine]} on a server off this machine, and a copy is only made on this machine: what test conversations create there stays there.`);
343
+ const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0] ?? ENGINE_AT[port];
344
+ if (!engine || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
345
+ const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
346
+ if ([...blocked, ...rows].some((b) => b.id === id)) continue;
347
+ // Named by its engine where a setting's name would stand: the API takes a name, never an address.
348
+ rows.push({ id, names: [ENGINE[engine].replace(/\W+/g, "_")], why: `Your app reaches ${/^[AEIOU]/.test(ENGINE[engine]) ? "an" : "a"} ${ENGINE[engine]} server ${local ? "on" : "off"} this machine that it was not given a copy of, so the run would write what its test conversations create into it as it is. Start your app on a copy of it, then run the connect command again.` });
345
349
  }
346
- return lines;
350
+ return rows;
347
351
  }
package/lib/tied.cjs CHANGED
@@ -39,9 +39,32 @@ const sentence = (v) => v.length >= 8 && /\s|[^\x00-\x7F]/.test(v);
39
39
  // The sentences a body or a reply carries, read as the person's words are: an "ok" or an id says nothing.
40
40
  const sentencesIn = (text) => { const v = json(text); return (v === undefined ? [String(text || "")] : scalars(v)).filter(sentence); };
41
41
  const PROMPTS_KEPT = 64, PROMPT_HEAD = 500;
42
+ // How much of a sentence the model wrote a fetched reply has to carry to be the answer.
43
+ const CARRY = 60;
44
+ // A job's own word for work still going on, as its status or state says it: at the top of a document,
45
+ // in an object inside it, or on the newest entry of a list in it. The backend reads the same words
46
+ // (customer/endpoint.ts).
47
+ const UNDER_WAY = /^(?:queued|pending|waiting|scheduled|submitted|accepted|created|starting|started|running|processing|generating|working|thinking|searching|retrieving|streaming|in[_ -]?progress|not[_ -]?(?:started|ready))$/i;
48
+ const underWay = (v, depth = 0) => {
49
+ if (!v || typeof v !== "object" || depth > 4) return false;
50
+ if (Array.isArray(v)) return underWay(v.at(-1), depth + 1);
51
+ const state = v.status ?? v.state;
52
+ if (typeof state === "string" && UNDER_WAY.test(state)) return true;
53
+ return Object.values(v).some((x) => x && typeof x === "object" && underWay(x, depth + 1));
54
+ };
55
+ // The sentences a model wrote, read as the person's words are; a JSON answer the clip cut short is
56
+ // read by its strings.
57
+ const saidSentences = (text) => {
58
+ const whole = sentencesIn(text);
59
+ if (whole.length !== 1 || !/^\s*[[{]/.test(whole[0])) return whole;
60
+ return [...whole[0].matchAll(/"((?:[^"\\]|\\.){8,})"/g)].map((m) => m[1]).filter(sentence);
61
+ };
42
62
 
43
63
  // `bodyOf(req)` and `replyOf(req)` read what a request carried and what the app answered.
44
- function makeTied({ norm, bodyOf, replyOf }) {
64
+ // `saidOf(req)`: what the model calls made for an ask answered later have written, one entry a call
65
+ // and none before one has, undefined where the hook hears none. The answer a person reads is not
66
+ // always the last call's: a second look that changes nothing leaves the first reading standing.
67
+ function makeTied({ norm, bodyOf, replyOf, saidOf = () => undefined }) {
45
68
  const open = new Set();
46
69
  const recent = [];
47
70
  // The heads of the prompts this process sent: one arriving at a server in the same process (an
@@ -68,6 +91,17 @@ function makeTied({ norm, bodyOf, replyOf }) {
68
91
  return first[1] >= 0 && first[1] !== second[1] ? first[0] : null;
69
92
  };
70
93
  const drop = (req) => { const i = recent.indexOf(req); if (i >= 0) recent.splice(i, 1); };
94
+ // Whether a fetch carries the ask's answer. A page polling for an answer several calls build comes
95
+ // back many times between two of them, on a document with no answer in it yet, or with a first
96
+ // reading in it while the document itself still says running.
97
+ const carries = (fetch, asker) => {
98
+ const said = saidOf(asker);
99
+ if (said === undefined) return true;
100
+ const raw = replyOf(fetch);
101
+ if (underWay(json(raw))) return false;
102
+ const reply = norm(raw);
103
+ return [].concat(said).flatMap(saidSentences).some((w) => reply.includes(norm(w).slice(0, CARRY)));
104
+ };
71
105
  // Asks reached through one session are one client's turns, the newest the one being answered: a
72
106
  // client holds a second stream open on its session (a heartbeat) that points at an older ask.
73
107
  const newestOf = (found) => {
@@ -89,7 +123,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
89
123
  for (let i = mine.size ? recent.length - 1 : -1; i >= 0; i--) {
90
124
  const t = recent[i];
91
125
  // A request answered later is fetched afterwards only by a read of it, never by a new message.
92
- if (t.at > req.at || now - t.at > RECENT_MS || (pinnedOnly && !t.pinned) || (t.settled && req.method !== "GET")) continue;
126
+ if (t.at > req.at || now - (t.callAt ?? t.at) > RECENT_MS || (pinnedOnly && !t.pinned) || (t.settled && req.method !== "GET")) continue;
93
127
  const tie = [...mine].filter((v) => idsOf(t).has(v));
94
128
  if (tie.length) return { asker: t, tie: new Set(tie) };
95
129
  }
@@ -107,7 +141,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
107
141
  closed(req) {
108
142
  open.delete(req);
109
143
  const { asker } = req;
110
- if (asker && !asker.written && !asker.callsOpen) {
144
+ if (asker && !asker.written && !asker.callsOpen && carries(req, asker)) {
111
145
  const since = Math.max(0, ...[...req.tie].map((i) => answered.get(i) ?? asker.at).filter((at) => at < asker.at));
112
146
  const before = recent.filter((p) => p !== asker && !p.settled && since < p.at && p.at < asker.at && asker.at - p.at <= BEFORE_MS && meets(idsOf(p), req.tie));
113
147
  for (const s of [...before, asker]) drop(s);
@@ -140,7 +174,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
140
174
  inside(req, sent) {
141
175
  if (req.finished && (!req.noted || req.late) && !says(req, norm(sent))) return pinned(sent, req);
142
176
  if (!req.noted && req.finished) req.late = true;
143
- if (req.late) { req.pinned = true; req.callsOpen = (req.callsOpen || 0) + 1; }
177
+ if (req.late) { req.pinned = true; req.callsOpen = (req.callsOpen || 0) + 1; req.callAt = Date.now(); }
144
178
  else req.waiting = (req.waiting || 0) + 1;
145
179
  return req;
146
180
  },
@@ -166,7 +200,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
166
200
  // request tied to it came to fetch the answer. It stays where a fetch that comes later can still
167
201
  // find it and complete the exchange on a second request, but no call is pinned to it by its words.
168
202
  settled(req) {
169
- if (!req || !req.late || !req.ended || req.callsOpen || req.waiting || req.written || req.settled || req.fetched) return false;
203
+ if (!req || !req.late || !req.ended || req.callsOpen || req.waiting || req.written || req.settled || (req.fetched && saidOf(req) === undefined)) return false;
170
204
  req.settled = true;
171
205
  return true;
172
206
  },
@@ -210,6 +244,7 @@ function makeTied({ norm, bodyOf, replyOf }) {
210
244
  if (!asker.noted && asker.finished) asker.late = true;
211
245
  asker.pinned = true;
212
246
  asker.callsOpen = (asker.callsOpen || 0) + 1;
247
+ asker.callAt = Date.now();
213
248
  return asker;
214
249
  }
215
250
  }
package/lib/wire.cjs CHANGED
@@ -761,14 +761,14 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
761
761
  // of the person's is kept: their newest is the sign-in and the proof a run takes, and the command
762
762
  // keeps only the newest few per door.
763
763
  // ponytail: the trace file grows by one row per request of the person's; a cap per route if a person's own load test fills it.
764
- const EXCHANGES_PER_ROUTE = 4, CANARY_PER_ROUTE = 30, ROUTES_KEPT = 1000, SENT_MAX = 4, MODEL_WORDS = 4000;
764
+ const EXCHANGES_PER_ROUTE = 4, CANARY_PER_ROUTE = 30, ROUTES_KEPT = 1000, SENT_MAX = 4, MODEL_WORDS = 4000, SAID_KEPT = 12;
765
765
  const slotOf = (turn) => (!turn ? { cls: "", max: Infinity } : /^canary:/.test(turn) ? { cls: "canary ", max: CANARY_PER_ROUTE } : { cls: "ours ", max: EXCHANGES_PER_ROUTE });
766
766
  const perRoute = new Map();
767
767
  // One route whatever id its path carries: a conversation in the path is a new path every trial.
768
768
  const routeKey = (method, path) => `${method} ${path.split("?")[0].split("/").map((s) => (/^\d+$/.test(s) || (s.length >= 8 && /\d/.test(s)) ? "{id}" : s)).join("/")}`;
769
769
  let lastExchange = 0;
770
770
  const { makeTied, sentencesIn } = require("./tied.cjs");
771
- const tied = makeTied({ norm, bodyOf, replyOf: (ctx) => (ctx.reply ? ctx.reply.text.slice(0, MAX) : "") });
771
+ const tied = makeTied({ norm, bodyOf, replyOf: (ctx) => (ctx.reply ? ctx.reply.text.slice(0, MAX) : ""), saidOf: (ctx) => (ctx.late ? ctx.saidAll || [] : undefined) });
772
772
  const requestCtx = (method, path, headers, turn) => {
773
773
  if (turn) onTurn();
774
774
  const ctx = { id: `${process.pid}.${++lastExchange}`, at: Date.now(), method, path, headers, chunks: [], size: 0, noted: false, kept: false, sent: [], turn, sites: [], traced: 0 };
@@ -866,7 +866,10 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
866
866
  const told = asked && typeof asked === "object" && !EMBEDDING.test(path) ? toldOf(asked, ctx ? new Set([...heard, ...heardIn(ctx)]) : heard) : {};
867
867
  tied.done(ctx);
868
868
  if (ctx && ctx.late) {
869
- if (!EMBEDDING.test(path)) ctx.said = { status, words: replyText(reply.events).slice(0, MODEL_WORDS), at: Date.now() };
869
+ if (!EMBEDDING.test(path)) {
870
+ ctx.said = { status, words: replyText(reply.events).slice(0, MODEL_WORDS), at: Date.now() };
871
+ ctx.saidAll = [...(ctx.saidAll || []), ctx.said.words].slice(-SAID_KEPT);
872
+ }
870
873
  settle(ctx);
871
874
  }
872
875
  row({ call: { at: Date.now(), ms: Date.now() - t0, host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), ...answeredBy(askedFor(path, sent), reply.model), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(EMBEDDING.test(path) ? { embedding: true } : {}), ...(ctx ? { ex: ctx.id } : {}), ...(ctx && (ctx.kept || ctx.turn) ? { reply: replyText(reply.events).slice(0, MODEL_WORDS) } : {}), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}), ...(reply.limit ? { limit: { cap: capOf(asked), reasoning: reply.reasoning } } : {}), ...told } });
package/local.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  // on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
13
13
  // never leaves this machine. Ctrl-C ends everything.
14
14
 
15
- import { storesOf } from "./lib/stores.mjs";
15
+ import { storesOf, withoutStoreSettings } from "./lib/stores.mjs";
16
16
  import { notAnApp, withoutStores } from "./lib/app-port.mjs";
17
17
  import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
18
18
  import { spawn, execFile, execFileSync } from "node:child_process";
@@ -133,15 +133,10 @@ if (viaToken) await holdProject();
133
133
 
134
134
  // ---- what leaves the machine: the source git would commit, and nothing git is told to ignore
135
135
  const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode|secrets?|\.secrets?)$/i;
136
- // Env files by any name (scripts/books.env is one), keys, and data rather than code.
137
- const SKIP_FILE = /^\.env(\..*)?$|^\.envrc$|\.env$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|parquet|csv|tsv|jsonl|ndjson|xlsx?|numbers|DS_Store)$/i;
138
- const MAX_FILE = 1_000_000;
139
- // A JSON or YAML file this large is a dataset (exports, catalogues, fixtures), not code or a prompt,
140
- // and datasets are where projects keep what they would never paste into a chat.
141
- const MAX_DATA = 200_000;
142
- const DATA_FILE = /\.(json|ya?ml)$/i;
143
- const MANIFEST_FILE = /^(package|tsconfig|composer|app|manifest)\.json$/i;
144
- const MAX_TOTAL = 80_000_000;
136
+ // Env files by any name (scripts/books.env is one), keys, credentials, media and compiled output.
137
+ const SKIP_FILE = /^\.env(\..*)?$|^\.envrc$|\.env$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|numbers|DS_Store)$/i;
138
+ const MAX_FILE = 25_000_000;
139
+ const MAX_TOTAL = 256_000_000;
145
140
  const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
146
141
 
147
142
  function walk(dir, depth, out, envs, total) {
@@ -156,7 +151,6 @@ function walk(dir, depth, out, envs, total) {
156
151
  let size;
157
152
  try { size = statSync(full).size; } catch { continue; }
158
153
  if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
159
- if (size > MAX_DATA && DATA_FILE.test(e.name) && !MANIFEST_FILE.test(e.name)) continue;
160
154
  if (!shareable(relative(root, full), false)) continue;
161
155
  out.push(relative(root, full));
162
156
  total += size;
@@ -918,7 +912,7 @@ async function freed(cmd, lifted) {
918
912
  // was copied, started, or left where trials write into it is told in the terminal here and to the
919
913
  // run with the app (announce), so status says it beside Run and the report keeps it.
920
914
  const keeping = makeKeeping({
921
- root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold,
915
+ root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
922
916
  launch: (plan) => sidecar(plan.cmd, plan.cwd),
923
917
  connections: () => capture?.connections() ?? [],
924
918
  settings: () => {
@@ -947,7 +941,10 @@ async function start(waitMs, tries = 3) {
947
941
  loaded = note;
948
942
  const copies = await keeping.env(true);
949
943
  await allowOutbound();
950
- const base = { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
944
+ // A start command the person typed says which databases the app uses: the store settings of their
945
+ // settings files are left for the app to load, since handed over here they outrank the
946
+ // environment that command chose, and no copy is pointed at.
947
+ const base = flag("--start") ? { ...withoutStoreSettings(envExports(envFiles)), ...process.env, ...lifted } : { ...envExports(envFiles), ...process.env, ...lifted, ...copies };
951
948
  child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(base) : capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) } });
952
949
  const mine = child;
953
950
  become("starting", { app: mine.pid ?? null });
@@ -1150,8 +1147,8 @@ if (explain) {
1150
1147
  `talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
1151
1148
  `would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
1152
1149
  ...(kept.length ? [`kept here ${kept.length} file${kept.length === 1 ? " that holds" : "s that hold"} keys: ${kept.slice(0, 6).join(", ")}${kept.length > 6 ? ", ..." : ""}`] : []),
1153
- ...(left.length ? [`also not sent ${left.length} tracked data, media or lock file${left.length === 1 ? "" : "s"}: ${left.slice(0, 6).join(", ")}${left.length > 6 ? ", ..." : ""}`] : []),
1154
- `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, data files, node_modules, .git`,
1150
+ ...(left.length ? [`also not sent ${left.length} tracked media or lock file${left.length === 1 ? "" : "s"}: ${left.slice(0, 6).join(", ")}${left.length > 6 ? ", ..." : ""}`] : []),
1151
+ `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules, .git`,
1155
1152
  `env files values read here only: to hide them in replies, to sign in a test account, and to ask your providers what your keys reach. Variable names and whether a switch is on or off go up; no value does`,
1156
1153
  `would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan?.cmd ? `${plan.cmd} (in ${rel(plan.cwd)})` : plan?.noServer ? "nothing: this repository has no server to run" : "asks you how your app starts"}`,
1157
1154
  `would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (only after your yes, for this session only)`}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"