cortad 0.4.0 → 0.4.2
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/env-secrets.mjs +6 -0
- package/lib/proof.mjs +10 -1
- package/lib/pyhook/sitecustomize.py +38 -8
- package/lib/replay.mjs +31 -18
- package/lib/start.mjs +4 -3
- package/local.mjs +16 -4
- package/package.json +1 -1
package/lib/env-secrets.mjs
CHANGED
|
@@ -33,6 +33,12 @@ export function secretValues(envFiles) {
|
|
|
33
33
|
|
|
34
34
|
export const maskWith = (text, values) => { let s = text; for (const v of values) s = s.split(v).join("[masked]"); return s; };
|
|
35
35
|
|
|
36
|
+
// Whether a value reads as a credential rather than configuration. A database or project name is a
|
|
37
|
+
// route word too: masking it inside the app's route table turns the path into one no request can
|
|
38
|
+
// replay, and every trial of that door fails. A plain word under 24 characters is configuration;
|
|
39
|
+
// anything key-like, mixed with digits or symbols, or long stays masked everywhere.
|
|
40
|
+
export const credentialShaped = (v) => KEY_LIKE.test(v) || !/^[A-Za-z ._-]+$/.test(v) || v.length >= 24;
|
|
41
|
+
|
|
36
42
|
// A process's output as it may be kept: masked with `values()`, read at each chunk since values are
|
|
37
43
|
// learned while the app runs, and never cut inside one. A line goes to `write` once it ends; a line
|
|
38
44
|
// longer than `max` goes on early, all but its last characters, as many as the longest value less
|
package/lib/proof.mjs
CHANGED
|
@@ -185,7 +185,7 @@ export const replyOf = once((ex) => {
|
|
|
185
185
|
.sort((a, b) => b.score - a.score || b.l.value.length - a.l.value.length)[0];
|
|
186
186
|
// The leaf names the field the run reads; the reply is the document it sits in, when it sits in one.
|
|
187
187
|
const text = best ? documentAt(whole, best.l.at) ?? readable(best.l.value) : "";
|
|
188
|
-
return { text, path: best ? best.l.at.join(".") : null, stream: false };
|
|
188
|
+
return { text, path: best ? newestReply(whole, best.l.at).join(".") : null, stream: false };
|
|
189
189
|
}
|
|
190
190
|
if (!stream) return { text: readable(typeof whole === "string" ? whole : ex.reply), path: null, stream: false };
|
|
191
191
|
const { events, loose } = eventsOf(ex.reply);
|
|
@@ -249,6 +249,15 @@ const at = (v, path) => path.reduce((o, k) => (o === null || typeof o !== "objec
|
|
|
249
249
|
// A concrete path with the last entry of a list of turns written -1, so it still names the newest
|
|
250
250
|
// turn when the list holds more of them. A list of anything else is positional and keeps its index.
|
|
251
251
|
const newest = (v, path) => path.map((k, i) => { const list = at(v, path.slice(0, i)); return typeof k === "number" && isTurns(list) && k === list.length - 1 ? -1 : k; });
|
|
252
|
+
// A reply's place in a thread the app answers with is its newest turn, also where the turns carry no
|
|
253
|
+
// side: Nitham's {turns: [{n, kind, request, markdown}]} was read at turns.0.markdown, the first answer,
|
|
254
|
+
// on every later turn. Only a reply's path; a request's history keeps the role rule.
|
|
255
|
+
const TURNS_NAME = /^(?:turns|messages|history|exchanges|replies)$/i;
|
|
256
|
+
const newestReply = (v, path) => path.map((k, i) => {
|
|
257
|
+
const list = at(v, path.slice(0, i));
|
|
258
|
+
const named = i > 0 && TURNS_NAME.test(String(path[i - 1])) && Array.isArray(list) && list.every((e) => e && typeof e === "object" && !Array.isArray(e));
|
|
259
|
+
return typeof k === "number" && (named || isTurns(list)) && k === list.length - 1 ? -1 : k;
|
|
260
|
+
});
|
|
252
261
|
|
|
253
262
|
// A stream whose events carry the whole thread (a queue's completed event holds every turn so far)
|
|
254
263
|
// has the reply at one entry of a list, not spread over the events: the leaf the model's words match
|
|
@@ -1315,10 +1315,31 @@ def _install():
|
|
|
1315
1315
|
# `later`, in the time its model took to answer it; a run's turn also gets the model's last words,
|
|
1316
1316
|
# the answer the command hands the run. Only an answer settles it: a call that came back 2xx with
|
|
1317
1317
|
# no words (a model asking for a tool) is a step, and the next call is waited for (lib/trace.cjs).
|
|
1318
|
-
# ponytail: a fixed settle; after a step that answers in words, more
|
|
1319
|
-
# work before the next call cuts the answer at that step.
|
|
1320
|
-
#
|
|
1318
|
+
# ponytail: a fixed settle where the work runs in threads; after a step that answers in words, more
|
|
1319
|
+
# than this of the app's own work before the next call cuts the answer at that step. On asyncio the
|
|
1320
|
+
# task that made the calls says when the work is done (working, below).
|
|
1321
1321
|
settle_s = 5.0
|
|
1322
|
+
# How long a task that made a request's calls may stay open after its last words before the turn is
|
|
1323
|
+
# answered anyway: a worker the request started and never ends holds no run for longer.
|
|
1324
|
+
open_task_s = 120.0
|
|
1325
|
+
|
|
1326
|
+
# The asyncio task a request's model call ran in, kept so the turn is answered when that work ends:
|
|
1327
|
+
# Nitham's quick answer searches between its first reading and its check, past the settle, and the
|
|
1328
|
+
# first reading was handed back while the check still ran. Only a task the request itself started,
|
|
1329
|
+
# which carries its context: a worker started at boot serves every request and never ends.
|
|
1330
|
+
def working(req):
|
|
1331
|
+
aio = sys.modules.get("asyncio")
|
|
1332
|
+
try:
|
|
1333
|
+
task = aio.current_task() if aio else None
|
|
1334
|
+
except RuntimeError:
|
|
1335
|
+
task = None
|
|
1336
|
+
if task is None or ctx.get() is not req or any(t is task for t in req.setdefault("tasks", [])):
|
|
1337
|
+
return
|
|
1338
|
+
req["tasks"].append(task)
|
|
1339
|
+
task.add_done_callback(lambda _t: settle(req))
|
|
1340
|
+
|
|
1341
|
+
def open_tasks(req):
|
|
1342
|
+
return [t for t in req.get("tasks", ()) if not t.done()]
|
|
1322
1343
|
|
|
1323
1344
|
# The turn's answer is written once the calls are quiet even while a read the hook took for a fetch
|
|
1324
1345
|
# of it is open (the person's page polling the sender): the answer is the model's words either way.
|
|
@@ -1337,10 +1358,20 @@ def _install():
|
|
|
1337
1358
|
def settle(req):
|
|
1338
1359
|
import threading
|
|
1339
1360
|
|
|
1361
|
+
def arm(seconds):
|
|
1362
|
+
req["settling"] = threading.Timer(seconds, quiet)
|
|
1363
|
+
req["settling"].daemon = True
|
|
1364
|
+
req["settling"].start()
|
|
1365
|
+
|
|
1340
1366
|
def quiet():
|
|
1341
1367
|
try:
|
|
1342
|
-
if
|
|
1343
|
-
|
|
1368
|
+
if req.get("calls_open") or req.get("waiting"):
|
|
1369
|
+
return
|
|
1370
|
+
left = open_task_s - (time.time() * 1000 - req["said"][2]) / 1000
|
|
1371
|
+
if open_tasks(req) and left > 0:
|
|
1372
|
+
arm(left)
|
|
1373
|
+
return
|
|
1374
|
+
answered_later(req)
|
|
1344
1375
|
except Exception:
|
|
1345
1376
|
pass
|
|
1346
1377
|
was = req.get("settling")
|
|
@@ -1349,9 +1380,7 @@ def _install():
|
|
|
1349
1380
|
said = req.get("said")
|
|
1350
1381
|
if not said or (not said[1] and 200 <= said[0] < 300):
|
|
1351
1382
|
return
|
|
1352
|
-
req
|
|
1353
|
-
req["settling"].daemon = True
|
|
1354
|
-
req["settling"].start()
|
|
1383
|
+
arm(0.5 if req.get("tasks") and not open_tasks(req) else settle_s)
|
|
1355
1384
|
|
|
1356
1385
|
# Where the app's own code wrote the parts of this reply, under the id its exchange is written by.
|
|
1357
1386
|
def written_at(req, steps):
|
|
@@ -1583,6 +1612,7 @@ def _install():
|
|
|
1583
1612
|
if not row.get("embedding"):
|
|
1584
1613
|
req["said"] = (row["status"], reply_text(events)[:model_words], now)
|
|
1585
1614
|
req["said_all"] = (req.get("said_all", []) + [req["said"][1]])[-12:]
|
|
1615
|
+
working(req)
|
|
1586
1616
|
settle(req)
|
|
1587
1617
|
row.update(inside_of(req))
|
|
1588
1618
|
if req and (req.get("kept") or req.get("turn")):
|
package/lib/replay.mjs
CHANGED
|
@@ -152,7 +152,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
152
152
|
// What your app spent on its model providers since it started, as the hook saw each call. Null
|
|
153
153
|
// when no hook is in your app (an app this command did not start): absent, never zero. With a
|
|
154
154
|
// `turn`, the calls pinned to that turn also say what their model was told.
|
|
155
|
-
usage: (turn) => { poll(); return alive ? meter.report(TURN.test(turn ?? "") ? turn : undefined) : null; },
|
|
155
|
+
usage: (turn, totalsOnly = false) => { poll(); return alive ? meter.report(TURN.test(turn ?? "") ? turn : undefined, totalsOnly) : null; },
|
|
156
156
|
// The app's routes as its framework holds them: the table of the process on `port`, else the
|
|
157
157
|
// newest any hooked process wrote. Empty when no hook read one.
|
|
158
158
|
registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
|
|
@@ -339,14 +339,36 @@ const toldOf = (call) => {
|
|
|
339
339
|
// The model's words behind a call, and the lines of the app that wrote the reply of the request it
|
|
340
340
|
// was made in, go out with the rows pinned to the turn asked about, as what the model was told does.
|
|
341
341
|
const MODEL_WORDS = 4000, SITES_KEPT = 500;
|
|
342
|
+
function tally(totals, call, count) {
|
|
343
|
+
const host = call.host.slice(0, 253);
|
|
344
|
+
const model = String(call.model ?? "").slice(0, 160);
|
|
345
|
+
const answered = typeof call.answered === "string" && call.answered ? call.answered.slice(0, 160) : undefined;
|
|
346
|
+
const key = `${host}|${model}`;
|
|
347
|
+
const t = totals.get(key) ?? { host, model, calls: 0, promptTokens: 0, cachedTokens: 0, completionTokens: 0, unmetered: 0, answered, mixed: false };
|
|
348
|
+
t.calls += 1;
|
|
349
|
+
// The model that answered for this one, when every call of it was answered by one other model.
|
|
350
|
+
if (t.answered !== answered) t.mixed = true;
|
|
351
|
+
t.promptTokens += count(call.promptTokens);
|
|
352
|
+
t.cachedTokens += count(call.cachedTokens);
|
|
353
|
+
t.completionTokens += count(call.completionTokens);
|
|
354
|
+
// An answered call the provider sent no counts for: it was spent, and what it cost is not known.
|
|
355
|
+
const status = count(call.status);
|
|
356
|
+
if (call.usage !== true && status > 0 && status < 400) t.unmetered += 1;
|
|
357
|
+
totals.set(key, t);
|
|
358
|
+
}
|
|
359
|
+
|
|
342
360
|
const meter = (() => {
|
|
343
361
|
const rows = [];
|
|
344
362
|
const deps = [];
|
|
363
|
+
// What the app has spent since it started, per host and model, kept as it goes: the rows are
|
|
364
|
+
// trimmed to the newest ROWS, and totals summed from them lost every call before those.
|
|
365
|
+
const totals = new Map();
|
|
345
366
|
const sitesOfEx = new Map();
|
|
346
367
|
const count = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
|
|
347
368
|
return {
|
|
348
369
|
add(call) {
|
|
349
370
|
if (!call || typeof call.host !== "string" || !call.host) return;
|
|
371
|
+
tally(totals, call, count);
|
|
350
372
|
rows.push({
|
|
351
373
|
at: count(call.at), ms: count(call.ms), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), ...(typeof call.answered === "string" && call.answered ? { answered: call.answered.slice(0, 160) } : {}), status: count(call.status),
|
|
352
374
|
promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
|
|
@@ -373,24 +395,15 @@ const meter = (() => {
|
|
|
373
395
|
},
|
|
374
396
|
// `turn`: the rows pinned to it carry what their model was told. The instructions go out with the
|
|
375
397
|
// reply they are read with, never with every call of the run at every turn.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// The model that answered for this one, when every call of it was answered by one other model.
|
|
383
|
-
if (t.answered !== r.answered) t.answered = undefined;
|
|
384
|
-
t.promptTokens += r.promptTokens;
|
|
385
|
-
t.cachedTokens += r.cachedTokens;
|
|
386
|
-
t.completionTokens += r.completionTokens;
|
|
387
|
-
// An answered call the provider sent no counts for: it was spent, and what it cost is not known.
|
|
388
|
-
if (!r.usage && r.status > 0 && r.status < 400) t.unmetered += 1;
|
|
389
|
-
totals.set(key, t);
|
|
390
|
-
}
|
|
398
|
+
// `totalsOnly`: the spend alone, for the meter read while a run plays and at its end. The rows
|
|
399
|
+
// carry what each call was handed, and on ulaim's run the whole report was 2.9 MB, past what the
|
|
400
|
+
// server takes, so the bill never arrived.
|
|
401
|
+
report(turn, totalsOnly = false) {
|
|
402
|
+
const spent = [...totals.values()].sort((a, b) => b.calls - a.calls).map(({ answered, mixed, ...t }) => (answered && !mixed ? { ...t, answered } : t));
|
|
403
|
+
if (totalsOnly) return { totals: spent };
|
|
391
404
|
return {
|
|
392
|
-
totals:
|
|
393
|
-
rows: rows.slice(-REPORTED).reverse().map(({ at, ms, host, model, status, turn: pinned, rules, tools, called, passages, caller, ex, told, said }) => ({ at, ms, host, model, status, ...(pinned ? { turn: pinned } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}),
|
|
405
|
+
totals: spent,
|
|
406
|
+
rows: rows.slice(-REPORTED).reverse().map(({ at, ms, host, model, status, turn: pinned, rules, tools, called, passages, caller, ex, told, said }) => ({ at, ms, host, model, status, ...(pinned ? { turn: pinned } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages && turn !== undefined && pinned === turn ? { passages } : {}), ...(caller ? { caller } : {}),
|
|
394
407
|
...(turn !== undefined && pinned === turn ? { ...told, ...(said !== undefined ? { said } : {}), ...(sitesOfEx.get(ex)?.length ? { sites: sitesOfEx.get(ex) } : {}) } : {}) })),
|
|
395
408
|
deps: deps.slice(-REPORTED).reverse(),
|
|
396
409
|
};
|
package/lib/start.mjs
CHANGED
|
@@ -125,10 +125,11 @@ const KNOWN = ["package.json", "pyproject.toml", "requirements.txt", "manage.py"
|
|
|
125
125
|
// from its environment whatever folder it runs in; a container, a Go or Java binary does not, and is
|
|
126
126
|
// watched from outside (lib/proxy.mjs). Judged by the command when there is one, else by the folder.
|
|
127
127
|
const HOOKED = /^(?:python3?(?:\.\d+)?|node|nodemon|tsx|ts-node|bun|bunx|deno|npm|npx|pnpm|yarn|uvicorn|gunicorn|hypercorn|flask|fastapi|poetry|uv|pipenv)$/;
|
|
128
|
-
// The program a command runs, past any PORT=3000 in front of it
|
|
128
|
+
// The program a command runs, past any PORT=3000 in front of it, an `env` or `exec` that only sets
|
|
129
|
+
// those, and the quotes or folder around it.
|
|
129
130
|
const runtimeOf = (cmd) => {
|
|
130
|
-
const words = cmd.trim().split(/\s+/).filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
|
|
131
|
-
return (
|
|
131
|
+
const words = cmd.trim().split(/\s+/).filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)).map((w) => w.replace(/^["']|["']$/g, "").split("/").pop() ?? "");
|
|
132
|
+
return words.find((w) => w !== "env" && w !== "exec" && !w.startsWith("-")) ?? "";
|
|
132
133
|
};
|
|
133
134
|
export const hookable = (dir, cmd = "") => (cmd ? HOOKED.test(runtimeOf(cmd)) : KNOWN.some((f) => existsSync(join(dir, f))));
|
|
134
135
|
|
package/local.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, wri
|
|
|
32
32
|
import { registerAll } from "./lib/register.mjs";
|
|
33
33
|
import { connectedText } from "./lib/read-text.mjs";
|
|
34
34
|
import { VERSION } from "./lib/spec.mjs";
|
|
35
|
-
import { maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
|
|
35
|
+
import { credentialShaped, maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
|
|
36
36
|
import { finished, unseenText } from "./lib/text.mjs";
|
|
37
37
|
import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
38
38
|
import { AS_HEADER, CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
@@ -226,6 +226,12 @@ let secrets = [];
|
|
|
226
226
|
// with how long the run waits for this reply; a reply with no answer says in the second why none came.
|
|
227
227
|
// The wait here ends a few seconds before the run's own and the wire's hold, so the reason reaches it.
|
|
228
228
|
const LATER_HEADER = "x-cortad-answer", LATER_MISSED = "x-cortad-later", LATER_MARGIN_MS = 5_000;
|
|
229
|
+
// The app's own reply to a message it answers later, when it is a small JSON object: a receipt.
|
|
230
|
+
const RECEIPT_MAX = 16_384;
|
|
231
|
+
const receiptOf = (got) => {
|
|
232
|
+
if (got.truncated || !got.body || got.body.length > RECEIPT_MAX) return null;
|
|
233
|
+
try { const v = JSON.parse(got.body); return v && typeof v === "object" && !Array.isArray(v) ? v : null; } catch { return null; }
|
|
234
|
+
};
|
|
229
235
|
// The cookies each trial's own turns were handed, newest trials kept.
|
|
230
236
|
const trialJars = new Map();
|
|
231
237
|
// Endpoints that refused a new visitor: their trials carry the person's own session.
|
|
@@ -261,6 +267,9 @@ let gate = null;
|
|
|
261
267
|
const hold = () => { if (!gate) { let open; gate = new Promise((r) => { open = r; }); gate.open = () => { gate = null; open(); }; } };
|
|
262
268
|
const release = () => gate?.open();
|
|
263
269
|
const mask = (text) => maskWith(String(text ?? ""), secrets);
|
|
270
|
+
// The route table is replayed, so a configuration word that is also a route word stays readable;
|
|
271
|
+
// anything credential-shaped is masked here the same as everywhere.
|
|
272
|
+
const maskRoutes = (text) => maskWith(String(text ?? ""), secrets.filter(credentialShaped));
|
|
264
273
|
|
|
265
274
|
// ---- the wire
|
|
266
275
|
let box = "";
|
|
@@ -494,7 +503,10 @@ async function verb(job) {
|
|
|
494
503
|
const said = capture ? await capture.answered(turn || headers["x-cortad-turn"], until, letGo) : null;
|
|
495
504
|
if (letGo.aborted) return { error: "cancelled by the run" };
|
|
496
505
|
if (!said?.reply.trim()) return { ...got, headers: { ...got.headers, [LATER_MISSED]: JSON.stringify(said ? { model: said.status } : { waited: Math.round((Date.now() - began) / 1000) }) } };
|
|
497
|
-
|
|
506
|
+
// The app's own reply rides beside the answer: the thread it opened is named there and nowhere
|
|
507
|
+
// else, and a run's next message on that conversation sends it back.
|
|
508
|
+
const own = receiptOf(got);
|
|
509
|
+
return { ...got, headers: { "content-type": "application/json" }, body: mask(JSON.stringify({ reply: said.reply, ...(own ? { receipt: own } : {}) })), truncated: false };
|
|
498
510
|
};
|
|
499
511
|
try { return await answered(await counted()); }
|
|
500
512
|
catch (e) {
|
|
@@ -572,9 +584,9 @@ async function verb(job) {
|
|
|
572
584
|
// calls of the `turn` asked about also say what their model was told.
|
|
573
585
|
// `toolsRecorded: false`: the app was started before the read's list of tools, so a tool that ran
|
|
574
586
|
// was not seen; a run says "not recorded", never that none ran.
|
|
575
|
-
case "usage": return capture ? { ...JSON.parse(mask(JSON.stringify(capture.usage(b.turn) ?? {}))), ...(launched && toolsKnown() ? { toolsRecorded: toolsRecorded() } : {}) } : {};
|
|
587
|
+
case "usage": return capture ? { ...JSON.parse(mask(JSON.stringify(capture.usage(b.turn, b.totals === true) ?? {}))), ...(launched && toolsKnown() ? { toolsRecorded: toolsRecorded() } : {}) } : {};
|
|
576
588
|
// Every route your app holds, read by the hook off the app itself, masked like the meter.
|
|
577
|
-
case "routes": return capture ? JSON.parse(
|
|
589
|
+
case "routes": return capture ? JSON.parse(maskRoutes(JSON.stringify(capture.registry(app?.port)))) : {};
|
|
578
590
|
// A world is ended from this terminal, never from the cloud.
|
|
579
591
|
case "destroy": return { ok: true };
|
|
580
592
|
default: return { ok: true };
|
package/package.json
CHANGED