cortad 0.3.3 → 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.
- package/lib/identities.mjs +5 -1
- package/lib/keeping.mjs +16 -9
- package/lib/pyhook/cortad_tied.py +48 -4
- package/lib/pyhook/sitecustomize.py +2 -1
- package/lib/stores.mjs +17 -13
- package/lib/tied.cjs +40 -5
- package/lib/wire.cjs +6 -3
- package/local.mjs +6 -3
- package/package.json +1 -1
package/lib/identities.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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,
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
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
|
-
|
|
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))
|
|
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";
|
|
@@ -912,7 +912,7 @@ async function freed(cmd, lifted) {
|
|
|
912
912
|
// was copied, started, or left where trials write into it is told in the terminal here and to the
|
|
913
913
|
// run with the app (announce), so status says it beside Run and the report keeps it.
|
|
914
914
|
const keeping = makeKeeping({
|
|
915
|
-
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")),
|
|
916
916
|
launch: (plan) => sidecar(plan.cmd, plan.cwd),
|
|
917
917
|
connections: () => capture?.connections() ?? [],
|
|
918
918
|
settings: () => {
|
|
@@ -941,7 +941,10 @@ async function start(waitMs, tries = 3) {
|
|
|
941
941
|
loaded = note;
|
|
942
942
|
const copies = await keeping.env(true);
|
|
943
943
|
await allowOutbound();
|
|
944
|
-
|
|
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 };
|
|
945
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 ?? ""}` } : {}) } });
|
|
946
949
|
const mine = child;
|
|
947
950
|
become("starting", { app: mine.pid ?? null });
|
package/package.json
CHANGED