cortad 0.3.8 → 0.3.10
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/auto-reach.mjs +54 -8
- package/lib/calls.mjs +25 -13
- package/lib/contact-text.mjs +23 -37
- package/lib/keeping.mjs +9 -4
- package/lib/mcp.mjs +11 -8
- package/lib/pyhook/sitecustomize.py +219 -5
- package/lib/qdrant.mjs +3 -2
- package/lib/reach.mjs +13 -6
- package/lib/read-text.mjs +15 -15
- package/lib/replay.mjs +6 -1
- package/lib/spec.mjs +12 -2
- package/lib/stores.mjs +31 -4
- package/lib/text.mjs +34 -18
- package/lib/trace.cjs +118 -15
- package/lib/verbs.mjs +8 -5
- package/lib/wire.cjs +25 -1
- package/local.mjs +18 -10
- package/package.json +1 -1
- package/skill/SKILL.md +30 -17
- package/skill/references/results.md +18 -18
|
@@ -118,7 +118,7 @@ def _install():
|
|
|
118
118
|
import re
|
|
119
119
|
import sys
|
|
120
120
|
import time
|
|
121
|
-
from urllib.parse import urlsplit
|
|
121
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
122
122
|
|
|
123
123
|
ctx = contextvars.ContextVar("cortad_request", default=None)
|
|
124
124
|
limit = 65536
|
|
@@ -882,6 +882,31 @@ def _install():
|
|
|
882
882
|
return False
|
|
883
883
|
return bool(retrieval_host.search(parts.hostname or "")) or bool(retrieval_path.search(parts.path or ""))
|
|
884
884
|
|
|
885
|
+
# A call to one Qdrant collection, by Qdrant's own REST shape (lib/wire.cjs qdrantCall): every
|
|
886
|
+
# PUT, PATCH and DELETE writes, and a POST does unless it is one of Qdrant's reads spelled as a
|
|
887
|
+
# POST. /collections/aliases names no collection: a write of the server itself.
|
|
888
|
+
qdrant_collection = re.compile(r"^/collections/([^/]+)(/.*)?$")
|
|
889
|
+
qdrant_read_post = re.compile(r"^/points(?:/(?:search|query|recommend|discover)(?:/(?:batch|groups))?|/scroll|/count|/facet|/search/matrix/(?:pairs|offsets))?$")
|
|
890
|
+
qdrant_points = re.compile(r"^/points(?:/(?:delete|payload|payload/delete|payload/clear|vectors|vectors/delete|batch))?$")
|
|
891
|
+
|
|
892
|
+
def qdrant_call(url, method):
|
|
893
|
+
try:
|
|
894
|
+
parts = urlsplit(str(url))
|
|
895
|
+
except ValueError:
|
|
896
|
+
return None
|
|
897
|
+
if parts.scheme not in ("http", "https"):
|
|
898
|
+
return None
|
|
899
|
+
m = qdrant_collection.match(parts.path or "")
|
|
900
|
+
if not m:
|
|
901
|
+
return None
|
|
902
|
+
name, rest = m.group(1), m.group(2) or ""
|
|
903
|
+
verb = str(method or "GET").upper()
|
|
904
|
+
write = verb in ("PUT", "PATCH", "DELETE") or (verb == "POST" and not qdrant_read_post.match(rest))
|
|
905
|
+
# "points" changes what the collection holds and a copy stands in for it; "create" makes it;
|
|
906
|
+
# "schema" (an index, an alias, a snapshot, its settings or its deletion) is held, never copied.
|
|
907
|
+
kind = None if not write else "create" if rest == "" and verb == "PUT" else "points" if qdrant_points.match(rest) and verb != "DELETE" else "schema"
|
|
908
|
+
return {"collection": None if name == "aliases" else unquote(name), "rest": rest, "write": write, "kind": kind, "parts": parts}
|
|
909
|
+
|
|
885
910
|
def passages_from(raw):
|
|
886
911
|
out, nodes = [], [0]
|
|
887
912
|
|
|
@@ -1344,6 +1369,11 @@ def _install():
|
|
|
1344
1369
|
return False
|
|
1345
1370
|
elif run_seen["at"] is None or time.monotonic() - run_seen["at"] > run_live_s:
|
|
1346
1371
|
return False
|
|
1372
|
+
# A read of a Qdrant collection, however its server is named, and any call to a copy this
|
|
1373
|
+
# hook made (copy_of below), go out.
|
|
1374
|
+
q = qdrant_call(url, method)
|
|
1375
|
+
if q and (not q["write"] or (q["collection"] and our_copy.search(q["collection"]))):
|
|
1376
|
+
return False
|
|
1347
1377
|
try:
|
|
1348
1378
|
parts = urlsplit(str(url))
|
|
1349
1379
|
host = (parts.hostname or "").lower()
|
|
@@ -1353,6 +1383,150 @@ def _install():
|
|
|
1353
1383
|
except Exception:
|
|
1354
1384
|
return True
|
|
1355
1385
|
|
|
1386
|
+
# A write to a Qdrant collection made inside a run goes to a copy of that collection on the same
|
|
1387
|
+
# server, made at the first such write: the collection is read page by page and written under
|
|
1388
|
+
# `<name>_cortad_<pid>`, and from then on every call a run makes to that collection, reads too,
|
|
1389
|
+
# goes to the copy, so the app finds what it wrote. The original is never written. A copy that
|
|
1390
|
+
# cannot be made (over the size a run copies, a server that refused) leaves the write held like
|
|
1391
|
+
# any other. Each copy is reported as a `copy` row and the command deletes them when it ends.
|
|
1392
|
+
# The person's own calls, outside a run, go where they were sent (lib/trace.cjs does the same).
|
|
1393
|
+
import threading
|
|
1394
|
+
our_copy = re.compile(r"_cortad_\d+$")
|
|
1395
|
+
copies = {}
|
|
1396
|
+
copies_lock = threading.Lock()
|
|
1397
|
+
qdrant_cap = 200000
|
|
1398
|
+
# What one process copies at most: collections, and points across them; past it a write is held.
|
|
1399
|
+
copies_max = 8
|
|
1400
|
+
points_max = 300000
|
|
1401
|
+
copied = {"collections": 0, "points": 0}
|
|
1402
|
+
copy_said = set()
|
|
1403
|
+
|
|
1404
|
+
def copy_say(host, collection, why):
|
|
1405
|
+
if (host, collection) in copy_said:
|
|
1406
|
+
return
|
|
1407
|
+
copy_said.add((host, collection))
|
|
1408
|
+
try:
|
|
1409
|
+
sys.stderr.write("cortad: a request Cortad sent made this app write into the Qdrant collection %s at %s. Cortad could not copy that collection on its server (%s), so the write was kept on this machine and answered with a stand-in success.\n" % (collection, host, why))
|
|
1410
|
+
except Exception:
|
|
1411
|
+
pass
|
|
1412
|
+
|
|
1413
|
+
def in_run():
|
|
1414
|
+
req = ctx.get()
|
|
1415
|
+
if req:
|
|
1416
|
+
return bool(req.get("turn"))
|
|
1417
|
+
return run_seen["at"] is not None and time.monotonic() - run_seen["at"] <= run_live_s
|
|
1418
|
+
|
|
1419
|
+
# None to leave the call alone; hold for a write of the server itself; else the collection's
|
|
1420
|
+
# copy when there is one, or none yet for a write, which makes it.
|
|
1421
|
+
def guarded(url, method):
|
|
1422
|
+
call = qdrant_call(url, method)
|
|
1423
|
+
if not call:
|
|
1424
|
+
return None
|
|
1425
|
+
if not call["collection"]:
|
|
1426
|
+
return {"hold": True} if call["write"] and in_run() else None
|
|
1427
|
+
if our_copy.search(call["collection"]) or not in_run():
|
|
1428
|
+
return None
|
|
1429
|
+
key = "%s|%s" % (call["parts"].netloc, call["collection"])
|
|
1430
|
+
copy = copies.get(key)
|
|
1431
|
+
if copy:
|
|
1432
|
+
return {"key": key, "call": call, "copy": copy}
|
|
1433
|
+
if not call["write"]:
|
|
1434
|
+
return None
|
|
1435
|
+
if call["kind"] == "schema":
|
|
1436
|
+
return {"hold": True}
|
|
1437
|
+
if copied["collections"] >= copies_max or copied["points"] >= points_max:
|
|
1438
|
+
copy_say(call["parts"].netloc, call["collection"], "this run already copied %d collections, %d points" % (copied["collections"], copied["points"]))
|
|
1439
|
+
return {"hold": True}
|
|
1440
|
+
return {"key": key, "call": call, "copy": None}
|
|
1441
|
+
|
|
1442
|
+
# Qdrant's own API over urllib, which this file leaves unpatched: never through the app's clients.
|
|
1443
|
+
def qdrant_http(base, key, method, path, body=None):
|
|
1444
|
+
import urllib.error
|
|
1445
|
+
import urllib.request
|
|
1446
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
1447
|
+
headers = {"content-type": "application/json"}
|
|
1448
|
+
if key:
|
|
1449
|
+
headers["api-key"] = key
|
|
1450
|
+
req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
|
|
1451
|
+
try:
|
|
1452
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
1453
|
+
return json.loads(r.read().decode("utf-8", "replace") or "null"), r.status
|
|
1454
|
+
except urllib.error.HTTPError as e:
|
|
1455
|
+
return None, e.code
|
|
1456
|
+
|
|
1457
|
+
# The copy made: "empty" when the server holds no such collection yet (the app makes it, under
|
|
1458
|
+
# the copy's name), "points" once every point is across, None when none could be made.
|
|
1459
|
+
def clone_qdrant(base, key, source, clone):
|
|
1460
|
+
at = lambda name: "/collections/" + quote(name, safe="")
|
|
1461
|
+
info, status = qdrant_http(base, key, "GET", at(source))
|
|
1462
|
+
if status == 404:
|
|
1463
|
+
return ("empty", 0)
|
|
1464
|
+
result = (info or {}).get("result") if status == 200 else None
|
|
1465
|
+
if not result:
|
|
1466
|
+
return None
|
|
1467
|
+
if (result.get("points_count") or 0) > qdrant_cap:
|
|
1468
|
+
return None
|
|
1469
|
+
params = (result.get("config") or {}).get("params") or {}
|
|
1470
|
+
made = {"vectors": params.get("vectors")}
|
|
1471
|
+
if params.get("sparse_vectors"):
|
|
1472
|
+
made["sparse_vectors"] = params["sparse_vectors"]
|
|
1473
|
+
if "on_disk_payload" in params:
|
|
1474
|
+
made["on_disk_payload"] = params["on_disk_payload"]
|
|
1475
|
+
if qdrant_http(base, key, "PUT", at(clone), made)[1] != 200:
|
|
1476
|
+
return None
|
|
1477
|
+
try:
|
|
1478
|
+
for field, schema in (result.get("payload_schema") or {}).items():
|
|
1479
|
+
qdrant_http(base, key, "PUT", at(clone) + "/index?wait=true", {"field_name": field, "field_schema": schema.get("params") or schema.get("data_type")})
|
|
1480
|
+
offset = None
|
|
1481
|
+
while True:
|
|
1482
|
+
page, status = qdrant_http(base, key, "POST", at(source) + "/points/scroll", {"limit": 256, "with_payload": True, "with_vector": True, **({"offset": offset} if offset is not None else {})})
|
|
1483
|
+
if status != 200:
|
|
1484
|
+
raise RuntimeError(status)
|
|
1485
|
+
got = (page or {}).get("result") or {}
|
|
1486
|
+
points = got.get("points") or []
|
|
1487
|
+
if points and qdrant_http(base, key, "PUT", at(clone) + "/points?wait=true", {"points": [{"id": p.get("id"), "vector": p.get("vector"), "payload": p.get("payload")} for p in points]})[1] != 200:
|
|
1488
|
+
raise RuntimeError("upsert")
|
|
1489
|
+
offset = got.get("next_page_offset")
|
|
1490
|
+
if offset is None:
|
|
1491
|
+
return ("points", result.get("points_count") or 0)
|
|
1492
|
+
except Exception:
|
|
1493
|
+
qdrant_http(base, key, "DELETE", at(clone))
|
|
1494
|
+
return None
|
|
1495
|
+
|
|
1496
|
+
# The copy's name, made now when none exists; None when none could be made. The key the app
|
|
1497
|
+
# sent is the key the copy is made with, and never leaves this process.
|
|
1498
|
+
def copy_of(g, header_of):
|
|
1499
|
+
with copies_lock:
|
|
1500
|
+
have = copies.get(g["key"])
|
|
1501
|
+
if have:
|
|
1502
|
+
return have
|
|
1503
|
+
parts = g["call"]["parts"]
|
|
1504
|
+
base = "%s://%s" % (parts.scheme, parts.netloc)
|
|
1505
|
+
auth = str(header_of("authorization") or "")
|
|
1506
|
+
bearer = re.match(r"^Bearer\s+(\S+)", auth, re.I)
|
|
1507
|
+
key = str(header_of("api-key") or "") or (bearer.group(1) if bearer else "")
|
|
1508
|
+
clone = "%s_cortad_%d" % (g["call"]["collection"][:40], os.getpid())
|
|
1509
|
+
made = clone_qdrant(base, key, g["call"]["collection"], clone)
|
|
1510
|
+
if not made:
|
|
1511
|
+
copy_say(parts.netloc, g["call"]["collection"], "the copy did not go through")
|
|
1512
|
+
return None
|
|
1513
|
+
copied["collections"] += 1
|
|
1514
|
+
copied["points"] += made[1]
|
|
1515
|
+
copies[g["key"]] = clone
|
|
1516
|
+
write({"copy": {"host": parts.hostname or "", "base": base, "collection": g["call"]["collection"], "clone": clone}})
|
|
1517
|
+
return clone
|
|
1518
|
+
|
|
1519
|
+
def to_copy(url, clone, call):
|
|
1520
|
+
parts = call["parts"]
|
|
1521
|
+
return urlunsplit((parts.scheme, parts.netloc, "/collections/" + quote(clone, safe="") + call["rest"], parts.query, parts.fragment))
|
|
1522
|
+
|
|
1523
|
+
# The copy a call goes to, waited for off the event loop: None to hold the call instead.
|
|
1524
|
+
async def copy_for(g, header_of):
|
|
1525
|
+
import asyncio
|
|
1526
|
+
if g.get("hold"):
|
|
1527
|
+
return None
|
|
1528
|
+
return g["copy"] or await asyncio.get_running_loop().run_in_executor(None, copy_of, g, header_of)
|
|
1529
|
+
|
|
1356
1530
|
# A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
|
|
1357
1531
|
# where a SELECT can write through a function) is held.
|
|
1358
1532
|
# ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
|
|
@@ -2468,19 +2642,34 @@ def _install():
|
|
|
2468
2642
|
dep(request.url, response.status_code, req=req, caller=caller)
|
|
2469
2643
|
return response
|
|
2470
2644
|
|
|
2645
|
+
def held_answer(request, sent):
|
|
2646
|
+
kind = request.headers.get("content-type", "")
|
|
2647
|
+
held_row(request.url, request.method, sent, kind, ctx.get())
|
|
2648
|
+
return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
|
|
2649
|
+
|
|
2471
2650
|
# A held call that cannot be answered fails in the app; it is never sent instead.
|
|
2472
2651
|
def held_httpx(request, sent):
|
|
2473
2652
|
if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
|
|
2474
2653
|
return None
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2654
|
+
return held_answer(request, sent)
|
|
2655
|
+
|
|
2656
|
+
# A call to a Qdrant collection goes to the run's copy of it, `clone`; with none, it is held.
|
|
2657
|
+
def routed_httpx(request, g, clone, sent):
|
|
2658
|
+
if clone is None:
|
|
2659
|
+
return held_answer(request, sent)
|
|
2660
|
+
request.url = module.URL(to_copy(request.url, clone, g["call"]))
|
|
2661
|
+
return None
|
|
2478
2662
|
|
|
2479
2663
|
for cls in (module.Client, module.AsyncClient):
|
|
2480
2664
|
send = cls.send
|
|
2481
2665
|
if cls is module.Client:
|
|
2482
2666
|
def sync_send(self, request, *a, _send=send, **k):
|
|
2483
2667
|
sent = sent_of(request)
|
|
2668
|
+
g = guarded(request.url, request.method)
|
|
2669
|
+
if g is not None:
|
|
2670
|
+
answered = routed_httpx(request, g, None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get)), sent)
|
|
2671
|
+
if answered is not None:
|
|
2672
|
+
return answered
|
|
2484
2673
|
held = held_httpx(request, sent)
|
|
2485
2674
|
if held is not None:
|
|
2486
2675
|
return held
|
|
@@ -2504,6 +2693,11 @@ def _install():
|
|
|
2504
2693
|
else:
|
|
2505
2694
|
async def async_send(self, request, *a, _send=send, **k):
|
|
2506
2695
|
sent = sent_of(request)
|
|
2696
|
+
g = guarded(request.url, request.method)
|
|
2697
|
+
if g is not None:
|
|
2698
|
+
answered = routed_httpx(request, g, await copy_for(g, request.headers.get), sent)
|
|
2699
|
+
if answered is not None:
|
|
2700
|
+
return answered
|
|
2507
2701
|
held = held_httpx(request, sent)
|
|
2508
2702
|
if held is not None:
|
|
2509
2703
|
return held
|
|
@@ -2535,6 +2729,9 @@ def _install():
|
|
|
2535
2729
|
raw = text(request.body, reply_max)
|
|
2536
2730
|
if reads_only(raw, request.url, request.headers.get):
|
|
2537
2731
|
return None
|
|
2732
|
+
return held_answer(request, raw)
|
|
2733
|
+
|
|
2734
|
+
def held_answer(request, raw):
|
|
2538
2735
|
import datetime
|
|
2539
2736
|
models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
|
|
2540
2737
|
kind = request.headers.get("content-type", "")
|
|
@@ -2546,6 +2743,12 @@ def _install():
|
|
|
2546
2743
|
return response
|
|
2547
2744
|
|
|
2548
2745
|
def sent(self, request, *a, **k):
|
|
2746
|
+
g = guarded(request.url, request.method)
|
|
2747
|
+
if g is not None:
|
|
2748
|
+
clone = None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get))
|
|
2749
|
+
if clone is None:
|
|
2750
|
+
return held_answer(request, text(request.body, reply_max))
|
|
2751
|
+
request.url = to_copy(request.url, clone, g["call"])
|
|
2549
2752
|
answered = held(request)
|
|
2550
2753
|
if answered is not None:
|
|
2551
2754
|
return answered
|
|
@@ -2601,7 +2804,18 @@ def _install():
|
|
|
2601
2804
|
async def requested(self, method, str_or_url, *a, **k):
|
|
2602
2805
|
body = k.get("json") if k.get("json") is not None else k.get("data")
|
|
2603
2806
|
url = whole(self, str_or_url)
|
|
2604
|
-
|
|
2807
|
+
g = guarded(url, method)
|
|
2808
|
+
hold = False
|
|
2809
|
+
if g is not None:
|
|
2810
|
+
clone = await copy_for(g, lambda name: header_of(self, k, name))
|
|
2811
|
+
if clone is None:
|
|
2812
|
+
hold = True
|
|
2813
|
+
else:
|
|
2814
|
+
parts = g["call"]["parts"]
|
|
2815
|
+
url = to_copy(url, clone, g["call"])
|
|
2816
|
+
# A session with a base address takes the path alone.
|
|
2817
|
+
str_or_url = url if getattr(self, "_base_url", None) is None else url[len("%s://%s" % (parts.scheme, parts.netloc)):]
|
|
2818
|
+
if hold or (held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name))):
|
|
2605
2819
|
port = held_port()
|
|
2606
2820
|
if port is None:
|
|
2607
2821
|
raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
|
package/lib/qdrant.mjs
CHANGED
|
@@ -5,8 +5,9 @@ export const QDRANT_CAP = 200_000;
|
|
|
5
5
|
const BATCH = 256;
|
|
6
6
|
const at = (name) => `/collections/${encodeURIComponent(name)}`;
|
|
7
7
|
|
|
8
|
+
// `conn.fetch`: the fetch to use, when the caller's global one is wrapped (the hook in the app).
|
|
8
9
|
async function call(conn, method, path, body) {
|
|
9
|
-
const res = await fetch(`${conn.base}${path}`, {
|
|
10
|
+
const res = await (conn.fetch ?? fetch)(`${conn.base}${path}`, {
|
|
10
11
|
method,
|
|
11
12
|
headers: { "content-type": "application/json", ...(conn.apiKey ? { "api-key": conn.apiKey } : {}) },
|
|
12
13
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -34,7 +35,7 @@ export async function cloneQdrant(conn, source, clone) {
|
|
|
34
35
|
const page = await call(conn, "POST", `${at(source)}/points/scroll`, { limit: BATCH, with_payload: true, with_vector: true, ...(offset === undefined ? {} : { offset }) });
|
|
35
36
|
if (page.points.length) await call(conn, "PUT", `${at(clone)}/points?wait=true`, { points: page.points.map(({ id, vector, payload }) => ({ id, vector, payload })) });
|
|
36
37
|
offset = page.next_page_offset;
|
|
37
|
-
if (offset === null || offset === undefined) return { how: "points" };
|
|
38
|
+
if (offset === null || offset === undefined) return { how: "points", points: info.points_count ?? 0 };
|
|
38
39
|
}
|
|
39
40
|
} catch (e) {
|
|
40
41
|
if (made) await dropQdrant(conn, clone);
|
package/lib/reach.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { plural, upper } from "./words.mjs";
|
|
|
4
4
|
|
|
5
5
|
// One request to each endpoint status lists as not reached, from this machine to the app on its port.
|
|
6
6
|
// Each body is the one Cortad read that endpoint's client sending (the server's `request`).
|
|
7
|
-
// The connect command sends these by itself,
|
|
7
|
+
// The connect command sends these by itself, up to four at a time, each tagged x-cortad-turn: reach:auto:<n>
|
|
8
8
|
// (sendOurs), signed in only as a test account this machine made. Headers are never the agent's: they
|
|
9
9
|
// go from here to the app on this machine and nowhere else, and only their names are printed. These
|
|
10
10
|
// are real requests with real effects.
|
|
@@ -31,7 +31,7 @@ export function notUp(runner) {
|
|
|
31
31
|
// The rows a call sends and the ones it leaves: the named paths, else every row with a ready request
|
|
32
32
|
// that is not marked by hand.
|
|
33
33
|
export function picked(contact, receipts, paths = []) {
|
|
34
|
-
const rows = unreached(contact, receipts);
|
|
34
|
+
const rows = unreached(contact, receipts, { retests: true });
|
|
35
35
|
const named = new Set(Array.isArray(paths) ? paths : []);
|
|
36
36
|
const chosen = named.size ? rows.filter((m) => named.has(m.path) || named.has(keyOf(m))) : rows.filter((m) => !m.unsure);
|
|
37
37
|
const sent = chosen.filter((m) => m.request);
|
|
@@ -72,15 +72,15 @@ export function reachText({ rows, sent, left, unknown }, answers, how) {
|
|
|
72
72
|
].filter(Boolean).join("\n");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// Cortad's own test requests, sent by the connect command
|
|
76
|
-
// belongs to one of them, each with the app's own origin where one is known. An endpoint that answers
|
|
75
|
+
// Cortad's own test requests, sent by the connect command, each with the app's own origin where one is known. An endpoint that answers
|
|
77
76
|
// 401 or 403 is asked once more as the test account this machine signed in, when there is one
|
|
78
77
|
// (`signIn(row)` gives { name, value } or null); the token never leaves this machine.
|
|
79
78
|
// `stop()`: true when the rest should wait, for the agent's ask; the answers so far come back.
|
|
80
79
|
// Four at a time: one each in turn left a 22-endpoint app waiting nearly two minutes behind its two
|
|
81
80
|
// slowest replies (ulaim, 2026-10-06). Answers keep the rows' order; a row is not sent once stop() says so.
|
|
81
|
+
// `onAnswer(answer, i)`: called as each row's answer lands, so one slow endpoint holds back no other's.
|
|
82
82
|
const AT_ONCE = 4;
|
|
83
|
-
export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, fetchImpl = fetch, atOnce = AT_ONCE }) {
|
|
83
|
+
export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, onAnswer = () => {}, fetchImpl = fetch, atOnce = AT_ONCE }) {
|
|
84
84
|
const app = appUrl(runner);
|
|
85
85
|
const base = () => ({ ...(origin ? { origin, referer: `${origin}/` } : {}), [RUN_TURN]: `reach:auto:${next()}` });
|
|
86
86
|
const answers = [];
|
|
@@ -101,6 +101,7 @@ export async function sendOurs(rows, { runner, origin, signIn = async () => null
|
|
|
101
101
|
if (stopped || stop()) { stopped = true; return; }
|
|
102
102
|
const i = at++;
|
|
103
103
|
answers[i] = await one(rows[i]);
|
|
104
|
+
try { onAnswer(answers[i], i); } catch { /* the answer stands whatever its listener did */ }
|
|
104
105
|
}
|
|
105
106
|
};
|
|
106
107
|
await Promise.all(Array.from({ length: Math.min(atOnce, rows.length) }, lane));
|
|
@@ -179,7 +180,13 @@ export async function sendOne(m, app, headers, fetchImpl, secrets = []) {
|
|
|
179
180
|
res = await fetchImpl(url, { method, headers: json && !typed ? { "content-type": "application/json", ...headers } : headers,
|
|
180
181
|
...(json ? { body: JSON.stringify(body) } : {}), redirect: "manual", signal: AbortSignal.timeout(REACH_MS) });
|
|
181
182
|
} catch (err) {
|
|
182
|
-
|
|
183
|
+
const code = err?.cause?.code ?? err?.code ?? "";
|
|
184
|
+
// Refused, nothing reached the app, and a GET cut off changes nothing: both are sent again
|
|
185
|
+
// (`down`). A POST whose connection was reset may have been taken by the app before it went, so
|
|
186
|
+
// it is an answer nobody saw (`unknown`) and is never sent again by itself.
|
|
187
|
+
const unknown = code === "ECONNRESET" && method !== "GET";
|
|
188
|
+
return { line: `${label}: ${failedSaid(err, app.port || 80)}${unknown ? " It may have reached your app, so Cortad does not send it again by itself." : ""}`, ok: false, status: 0,
|
|
189
|
+
down: !unknown && /^(?:ECONNREFUSED|ECONNRESET)$/.test(code), ...(unknown ? { unknown: true } : {}) };
|
|
183
190
|
}
|
|
184
191
|
let text;
|
|
185
192
|
try { text = await res.text(); } catch { return { line: `${label}: ${res.status}, and the answer had not finished after ${REACH_MS / 1000} seconds.`, ok: false, status: res.status }; }
|
package/lib/read-text.mjs
CHANGED
|
@@ -87,30 +87,30 @@ const listed = (names) => (names.length > 1 ? `${names.slice(0, -1).join(", ")}
|
|
|
87
87
|
|
|
88
88
|
// What the connect command does on this machine, from the facts it keeps in the runner's record:
|
|
89
89
|
// what it started and stops, the database copies, the settings its app runs with, what it loads,
|
|
90
|
-
// what a run holds and puts back, and what it keeps
|
|
91
|
-
//
|
|
90
|
+
// what a run holds and puts back, and what it keeps outside this repository. The first line is the
|
|
91
|
+
// one the terminal says at connect (connectedText); the rest is read when someone asks.
|
|
92
92
|
export function machineText(f) {
|
|
93
93
|
if (!f) return ["No connect command is running for this repository now, so nothing Cortad started is running here."];
|
|
94
94
|
const lines = [
|
|
95
95
|
f.started
|
|
96
|
-
? `
|
|
97
|
-
: `
|
|
98
|
-
f.copies === "made"
|
|
99
|
-
? "For the person: Where Cortad could copy a database, your app runs on the copy, made from your own at every connect and deleted when this command ends, so records created in a copy are gone at the next connect; keep records every run needs in your own database. Each Data line in status says what happens to one store."
|
|
100
|
-
: "For the person: No copy of a database was made for this session, so what test conversations create stays where your app keeps its data; each Data line in status names one store.",
|
|
96
|
+
? `Cortad started your app in ${f.dir === "." ? "this folder" : f.dir} and stops it when this command stops. Your code and settings files are never edited.`
|
|
97
|
+
: `Cortad uses your app already running on port ${f.port} and leaves it running. Your code and settings files are never edited.`,
|
|
101
98
|
];
|
|
99
|
+
if (f.copies === "made") lines.push("Test conversations save into a copy of your database, made fresh at each connect and deleted when this command stops. Your own data is not changed.");
|
|
102
100
|
// Env values reach only a process the command started itself.
|
|
103
|
-
if (f.started) lines.push(
|
|
104
|
-
lines.push(f.hooked ? "
|
|
105
|
-
: f.proxy ? `A local proxy on port ${f.proxy} sees each request sent to it and the model calls your app makes through it
|
|
106
|
-
: "
|
|
101
|
+
if (f.started) lines.push(`Your env values stay on this machine${f.changed?.length ? `, with ${listed(f.changed)} set for this session only` : ""}. None is sent to Cortad.`);
|
|
102
|
+
lines.push(f.hooked ? "Cortad sees each request that reaches your model and the model calls it makes. Request headers stay on this machine and are deleted when the command stops."
|
|
103
|
+
: f.proxy ? `A local proxy on port ${f.proxy} sees each request sent to it and the model calls your app makes through it. Request headers stay on this machine and are deleted when the command stops.`
|
|
104
|
+
: "Cortad did not start your app, so it does not see the calls your app makes to its model.");
|
|
107
105
|
// Only the hook holds a write or records a file: through the proxy, or with nothing loaded, both go as the app does them.
|
|
108
|
-
lines.push(f.hooked ? "During a run,
|
|
109
|
-
: "During a run,
|
|
110
|
-
lines.push(f.hooked ? "Files your app writes in its own folder during a run are put back when
|
|
111
|
-
lines.push("Kept
|
|
106
|
+
lines.push(f.hooked ? "During a run, anything your app would send to an outside service, such as a payment or an email, is answered inside your app and never sent, unless it carries a test key, goes to a provider's sandbox, or goes to a host you allowed on the card. A call that only reads goes out."
|
|
107
|
+
: "During a run, what your app sends to an outside service, such as a payment or an email, is sent for real.");
|
|
108
|
+
lines.push(f.hooked ? "Files your app writes in its own folder during a run are put back when the run ends. Its log files are left as they are." : "Files your app writes in its own folder stay as it writes them.");
|
|
109
|
+
lines.push("Kept in ~/.cortad on this machine between connects: this repository's key, the key the test accounts' passwords are made from, what to put back after a run, and your app's output from the last session with your env values masked.");
|
|
112
110
|
return lines;
|
|
113
111
|
}
|
|
112
|
+
// What the terminal says once the app answers: the one line the person needs.
|
|
113
|
+
export const connectedText = (f) => machineText(f).slice(0, 1);
|
|
114
114
|
|
|
115
115
|
export function showText(d, show, page = 1) {
|
|
116
116
|
if (!d.read || (d.read.complete === false && show !== "reviews")) return "Cortad is still reading this repository.";
|
package/lib/replay.mjs
CHANGED
|
@@ -37,6 +37,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
37
37
|
let newestTable = null;
|
|
38
38
|
// Each server a hooked process opened a connection to, by host and port.
|
|
39
39
|
const conns = new Map();
|
|
40
|
+
// The Qdrant collections the hook copied for a run's writes, by server and name (lib/stores.mjs drops them).
|
|
41
|
+
const copies = new Map();
|
|
40
42
|
// The model's last words and status for each of a run's turns on an endpoint the app answers
|
|
41
43
|
// later, by turn (lib/wire.cjs answeredLater).
|
|
42
44
|
const answers = new Map();
|
|
@@ -48,7 +50,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
48
50
|
// requires, to one without.
|
|
49
51
|
const bunHook = /\s/.test(HOOK) ? (() => {
|
|
50
52
|
const dir = mkdtempSync(join(tmpdir(), "cortad-"));
|
|
51
|
-
for (const name of ["trace.cjs", "wire.cjs", "tied.cjs"]) copyFileSync(join(HERE, name), join(dir, name));
|
|
53
|
+
for (const name of ["trace.cjs", "wire.cjs", "tied.cjs", "qdrant.mjs"]) copyFileSync(join(HERE, name), join(dir, name));
|
|
52
54
|
return join(dir, "trace.cjs");
|
|
53
55
|
})() : HOOK;
|
|
54
56
|
// The hooks are handed to whatever is started: a Node app loads the first, a Bun app the same file
|
|
@@ -96,6 +98,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
96
98
|
if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
|
|
97
99
|
if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
|
|
98
100
|
if (row.conn) { const { host, port } = row.conn; if (typeof host === "string" && Number.isInteger(port)) conns.set(`${host}:${port}`, { host, port }); continue; }
|
|
101
|
+
if (row.copy) { const { base, collection, clone } = row.copy; if ([base, collection, clone].every((v) => typeof v === "string" && v)) copies.set(`${base}|${collection}`, { base, collection, clone }); continue; }
|
|
99
102
|
if (row.answer) {
|
|
100
103
|
const { turn, status, reply } = row.answer;
|
|
101
104
|
if (TURN.test(turn ?? "")) answers.set(turn, { status: Number.isInteger(status) ? status : 0, reply: typeof reply === "string" ? reply : "" });
|
|
@@ -158,6 +161,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
158
161
|
registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
|
|
159
162
|
// The servers the app has connected to so far (lib/stores.mjs names the ones no setting does).
|
|
160
163
|
connections: () => { poll(); return [...conns.values()]; },
|
|
164
|
+
// The collections the hook copied on a Qdrant server for a run's writes, to be deleted at the end.
|
|
165
|
+
copies: () => { poll(); return [...copies.values()]; },
|
|
161
166
|
// The model's last words for a run's turn on an endpoint the app answers later, once the hook
|
|
162
167
|
// has written them; null at `until`, or once `signal` aborts. `forget` drops a turn sent again.
|
|
163
168
|
answered: async (turn, until, signal) => {
|
package/lib/spec.mjs
CHANGED
|
@@ -11,10 +11,20 @@ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import
|
|
|
11
11
|
export const SPEC = "(?:cortad(?:@[A-Za-z0-9._-]+)?|/[A-Za-z0-9._/-]+)";
|
|
12
12
|
const SAFE = new RegExp(`^${SPEC}$`);
|
|
13
13
|
|
|
14
|
+
// A pin older than this very version loses to it: a connect run as cortad@0.3.8 wrote a skill and an
|
|
15
|
+
// MCP server pinned to 0.3.7, and the agent asked whether the two were meant to mix.
|
|
14
16
|
export function cliSpec({ env = process.env, version = VERSION } = {}) {
|
|
15
|
-
|
|
16
|
-
return `cortad@${version}`;
|
|
17
|
+
const pinned = env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC) ? env.CORTAD_CLI_SPEC : null;
|
|
18
|
+
if (!pinned) return `cortad@${version}`;
|
|
19
|
+
const released = /^cortad@(\d+\.\d+\.\d+)$/.exec(pinned)?.[1];
|
|
20
|
+
return released && newer(version, released) ? `cortad@${version}` : pinned;
|
|
17
21
|
}
|
|
22
|
+
const newer = (a, b) => {
|
|
23
|
+
if (!/^\d+\.\d+\.\d+$/.test(a)) return false;
|
|
24
|
+
const [x, y] = [a, b].map((v) => v.split(".").map(Number));
|
|
25
|
+
for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] > y[i];
|
|
26
|
+
return false;
|
|
27
|
+
};
|
|
18
28
|
|
|
19
29
|
// npx needs -y to fetch a package without asking; a local folder is not fetched.
|
|
20
30
|
export const npxArgs = (spec) => (isAbsolute(spec) ? [spec] : ["-y", spec]);
|
package/lib/stores.mjs
CHANGED
|
@@ -232,7 +232,7 @@ const HELD = {
|
|
|
232
232
|
"neon-key": (s) => ["there is no NEON_API_KEY in its settings to make a branch with", `Set NEON_API_KEY, or point ${s.names[0]} at a database on this machine`],
|
|
233
233
|
neon: (s) => ["Neon did not make a branch of it", `Check NEON_API_KEY, or point ${s.names[0]} at a database on this machine`],
|
|
234
234
|
supabase: (s) => ["a local Supabase needs supabase/config.toml, Docker and the supabase command", `Add them, or point ${s.names[0]} at a local Supabase`],
|
|
235
|
-
engine: (s) => [`
|
|
235
|
+
engine: (s) => [`no copy of a hosted ${ENGINE[s.engine]} is made`, `Point ${s.names[0]} at a ${ENGINE[s.engine]} on this machine`],
|
|
236
236
|
parts: (s) => ["its settings name it in parts, which cannot be pointed at a copy", `Put its whole address in one setting, or point ${s.names[0]} at a database on this machine`],
|
|
237
237
|
"no-migrations": (s) => ["no migrations were found to build a copy on this machine from", `Point ${s.names[0]} at a database on this machine`],
|
|
238
238
|
"no-local": (s) => ["no Postgres on this machine let us make a database to build a copy in", "Start Postgres on this machine"],
|
|
@@ -247,8 +247,14 @@ const HELD = {
|
|
|
247
247
|
// card told the owner, on the card, that "the owner can say yes to it on the card in the browser".
|
|
248
248
|
const heldWhy = (s, why, tool) => {
|
|
249
249
|
const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
|
|
250
|
-
return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)}
|
|
250
|
+
return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)} that is not on this machine, and ${reason}, so test conversations would save into it. ${fix}, then run the connect command again.`;
|
|
251
251
|
};
|
|
252
|
+
// A Qdrant server the app writes into is kept by the hook in the app instead of by a copy made here:
|
|
253
|
+
// the first write a run makes into a collection copies that collection on the same server and the
|
|
254
|
+
// app's later calls go to the copy (lib/trace.cjs, lib/pyhook/sitecustomize.py). So a hosted Qdrant
|
|
255
|
+
// holds nothing, and a local one whose collection no setting names is not a risk either.
|
|
256
|
+
export const GUARDED_ENGINES = new Set(["qdrant"]);
|
|
257
|
+
const guardedKept = (s) => `Your app's ${ENGINE[s.engine]} collections at ${listed(s.names)} are not written into by a run: the first time a run writes into one, that collection is copied on the same server and the app's calls go to the copy, which is deleted when this command ends.`;
|
|
252
258
|
const hostedKept = (s, got) => (got.how === "neon"
|
|
253
259
|
? `Your app runs against a Neon branch of its ${whatOf(s)}, made for this session through ${listed(s.names)}; the original is not touched, and the branch is deleted when this command ends and made again from the original at the next connect.`
|
|
254
260
|
: got.how === "supabase"
|
|
@@ -259,7 +265,7 @@ export const storeId = (s) => createHash("sha256").update(s.key).digest("hex").s
|
|
|
259
265
|
// A store off this machine the app was pointed at a copy of and reached anyway (dotenv's override):
|
|
260
266
|
// trials write into the original, so it holds Run. `blocked`: the rows already holding it.
|
|
261
267
|
export const bypassedOf = (connections, stores, blocked) => stores
|
|
262
|
-
.filter((s) => !onThisMachine(s.host) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
|
|
268
|
+
.filter((s) => !onThisMachine(s.host) && !GUARDED_ENGINES.has(s.engine) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
|
|
263
269
|
.map((s) => ({ id: storeId(s), names: s.names, why: heldWhy(s, "bypassed") }));
|
|
264
270
|
|
|
265
271
|
// A copy that throws is one that did not go through, said as such; it never stops the app starting.
|
|
@@ -292,6 +298,12 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
|
|
|
292
298
|
const pass = [];
|
|
293
299
|
for (const s of stores) {
|
|
294
300
|
if (done.has(s.key)) continue;
|
|
301
|
+
if (GUARDED_ENGINES.has(s.engine) && (!onThisMachine(s.host) || !s.points.length || !s.db)) {
|
|
302
|
+
if (await down(s)) continue;
|
|
303
|
+
done.add(s.key);
|
|
304
|
+
copies.push(guardedKept(s));
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
295
307
|
if (!onThisMachine(s.host)) {
|
|
296
308
|
done.add(s.key);
|
|
297
309
|
const got = started ? await hosted.keep(s).catch(() => ({ why: ["other"] })) : { why: ["started"] };
|
|
@@ -329,6 +341,21 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
|
|
|
329
341
|
for (const { s, row } of made.splice(0)) if (await drop(s, row.made, row.marker)) book.remove(row);
|
|
330
342
|
await hosted.drop();
|
|
331
343
|
},
|
|
344
|
+
// The collections the hook in the app copied for a run's writes (lib/trace.cjs copyOf), deleted
|
|
345
|
+
// with the key the settings hold for that server. Each is in the ledger from the moment it is
|
|
346
|
+
// seen, so one a killed session left is deleted by the next.
|
|
347
|
+
// ponytail: a server no setting names a key for is asked without one; a copy that refuses stays
|
|
348
|
+
// under its _cortad_ name for the person to see. The key the app sent never leaves the app.
|
|
349
|
+
async dropCopies(copies, stores) {
|
|
350
|
+
const rows = copies.map((c) => ({ kind: "qdrant-copy", key: c.base, made: c.clone }));
|
|
351
|
+
for (const row of rows) book.add(row);
|
|
352
|
+
const hostOf = (base) => { try { return new URL(base).hostname; } catch { return base; } };
|
|
353
|
+
for (const row of [...rows, ...book.orphans("qdrant-copy")]) {
|
|
354
|
+
// The hook names the server with its port and QDRANT_URL may not: the key is the host's.
|
|
355
|
+
const conn = { base: row.key, apiKey: stores.find((s) => s.engine === "qdrant" && s.conn?.base && hostOf(s.conn.base) === hostOf(row.key))?.conn.apiKey };
|
|
356
|
+
if (await dropQdrant(conn, row.made)) book.remove(row);
|
|
357
|
+
}
|
|
358
|
+
},
|
|
332
359
|
};
|
|
333
360
|
}
|
|
334
361
|
|
|
@@ -341,7 +368,7 @@ export function unnamedHeld(connections, stores, blocked) {
|
|
|
341
368
|
for (const { host, port } of connections) {
|
|
342
369
|
const local = onThisMachine(host);
|
|
343
370
|
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;
|
|
371
|
+
if (!engine || GUARDED_ENGINES.has(engine) || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
|
|
345
372
|
const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
|
|
346
373
|
if ([...blocked, ...rows].some((b) => b.id === id)) continue;
|
|
347
374
|
// Named by its engine where a setting's name would stand: the API takes a name, never an address.
|