cortad 0.3.0-rc.7 → 0.3.0-rc.8
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/pyhook/sitecustomize.py +236 -3
- package/lib/replay.mjs +25 -0
- package/lib/text.mjs +3 -1
- package/lib/trace.cjs +222 -2
- package/local.mjs +4 -1
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/results.md +3 -3
|
@@ -128,6 +128,50 @@ def _install():
|
|
|
128
128
|
add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
|
|
129
129
|
return out or None
|
|
130
130
|
|
|
131
|
+
# A framework that folds its tools' answers into the next prompt as plain text sends no tool
|
|
132
|
+
# message to find: within one turn, what a later call's prompt carries that the call before did
|
|
133
|
+
# not, outside the model's own words, is that material. The first call holds the customer's
|
|
134
|
+
# message and the system prompt, present in every later call, so neither is ever counted.
|
|
135
|
+
seen_by_turn = {}
|
|
136
|
+
|
|
137
|
+
def texts_of(body):
|
|
138
|
+
out = set()
|
|
139
|
+
|
|
140
|
+
def take(v):
|
|
141
|
+
t = text_of(v).strip()
|
|
142
|
+
if t:
|
|
143
|
+
out.add(t[:tool_text])
|
|
144
|
+
for m in body.get("messages") if isinstance(body.get("messages"), list) else []:
|
|
145
|
+
if isinstance(m, dict) and m.get("role") != "assistant":
|
|
146
|
+
take(m.get("content"))
|
|
147
|
+
for it in body.get("input") if isinstance(body.get("input"), list) else []:
|
|
148
|
+
if isinstance(it, dict) and it.get("role") != "assistant" and it.get("type") != "function_call":
|
|
149
|
+
take(it.get("content") if it.get("content") is not None else it.get("output"))
|
|
150
|
+
if isinstance(body.get("system"), str):
|
|
151
|
+
take(body["system"])
|
|
152
|
+
for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
|
|
153
|
+
if isinstance(c, dict) and c.get("role") != "model":
|
|
154
|
+
for p in c.get("parts") if isinstance(c.get("parts"), list) else []:
|
|
155
|
+
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
|
156
|
+
take(p["text"])
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
def material_in(sent, turn):
|
|
160
|
+
if not turn:
|
|
161
|
+
return None
|
|
162
|
+
body = parsed(sent) if isinstance(sent, str) else None
|
|
163
|
+
if not isinstance(body, dict):
|
|
164
|
+
return None
|
|
165
|
+
now = texts_of(body)
|
|
166
|
+
before = seen_by_turn.get(turn)
|
|
167
|
+
seen_by_turn[turn] = now
|
|
168
|
+
if len(seen_by_turn) > 200:
|
|
169
|
+
seen_by_turn.pop(next(iter(seen_by_turn)))
|
|
170
|
+
if before is None:
|
|
171
|
+
return None
|
|
172
|
+
fresh = [{"name": "", "text": t} for t in now if t not in before][:tools_max]
|
|
173
|
+
return fresh or None
|
|
174
|
+
|
|
131
175
|
def rules_in(sent):
|
|
132
176
|
found = rules_now()
|
|
133
177
|
if found is None:
|
|
@@ -276,7 +320,7 @@ def _install():
|
|
|
276
320
|
found = rules_in(as_text)
|
|
277
321
|
if found is not None:
|
|
278
322
|
row["rules"] = found
|
|
279
|
-
tools = tools_in(as_text)
|
|
323
|
+
tools = tools_in(as_text) or material_in(as_text, turn)
|
|
280
324
|
if tools:
|
|
281
325
|
row["tools"] = tools
|
|
282
326
|
write({"call": row})
|
|
@@ -382,16 +426,154 @@ def _install():
|
|
|
382
426
|
wrapped._brainsless = True
|
|
383
427
|
return wrapped
|
|
384
428
|
|
|
429
|
+
# The app's own route table, read off the app object the server was handed, so the run knows
|
|
430
|
+
# every door the app has before a message is sent. Read only: nothing here changes the app.
|
|
431
|
+
route_max, openapi_max = 400, 8 * 1024 * 1024
|
|
432
|
+
verbs = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
433
|
+
served_port = [None]
|
|
434
|
+
|
|
435
|
+
def template(path):
|
|
436
|
+
# One syntax for every framework's path parameters: OpenAPI's {name}.
|
|
437
|
+
p = re.sub(r"\(\?P<(\w+)>(?:[^()]|\([^()]*\))*\)", r"{\1}", str(path))
|
|
438
|
+
p = re.sub(r"<(?:[^<>:]+:)?(\w+)>", r"{\1}", p)
|
|
439
|
+
p = re.sub(r"\{(\w+):[^{}]*\}", r"{\1}", p)
|
|
440
|
+
p = re.sub(r"(?:\$|\\Z)$", "", re.sub(r"^\^", "", p))
|
|
441
|
+
return p.replace("\\.", ".").replace("/?", "/")
|
|
442
|
+
|
|
443
|
+
def source_of(fn):
|
|
444
|
+
import inspect
|
|
445
|
+
try:
|
|
446
|
+
fn = inspect.unwrap(fn)
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
code = getattr(fn, "__code__", None)
|
|
450
|
+
path = code.co_filename if code else getattr(sys.modules.get(getattr(fn, "__module__", None) or ""), "__file__", None) or ""
|
|
451
|
+
rel = os.path.relpath(path, os.getcwd()) if path else ""
|
|
452
|
+
theirs = rel and not rel.startswith("..") and "site-packages" not in rel and "dist-packages" not in rel
|
|
453
|
+
return (rel if theirs else ""), str(getattr(fn, "__name__", "") or "")
|
|
454
|
+
|
|
455
|
+
def add(out, methods, path, fn):
|
|
456
|
+
# Methods unknown: the two a door is asked with.
|
|
457
|
+
file, handler = source_of(fn) if fn is not None else ("", "")
|
|
458
|
+
for m in ("POST", "GET") if methods is None else methods:
|
|
459
|
+
m = str(m).upper()
|
|
460
|
+
if m not in ("HEAD", "OPTIONS") and len(out) < route_max:
|
|
461
|
+
out.append({"method": m, "path": ("/" + template(path).lstrip("/"))[:1024], "file": file[:512], "handler": handler[:200]})
|
|
462
|
+
|
|
463
|
+
def class_methods(cls):
|
|
464
|
+
return [m for m in verbs if callable(getattr(cls, m.lower(), None))]
|
|
465
|
+
|
|
466
|
+
def starlette_routes(routes, prefix, out):
|
|
467
|
+
for r in routes or []:
|
|
468
|
+
path = prefix + (getattr(r, "path", "") or "")
|
|
469
|
+
sub = getattr(r, "routes", None)
|
|
470
|
+
# FastAPI 0.14x keeps an included router whole and resolves its routes on demand.
|
|
471
|
+
if callable(getattr(r, "effective_route_contexts", None)):
|
|
472
|
+
for c in r.effective_route_contexts():
|
|
473
|
+
if c.starlette_route is not None:
|
|
474
|
+
starlette_routes([c.starlette_route], prefix, out)
|
|
475
|
+
else:
|
|
476
|
+
add(out, c.methods, prefix + c.path, c.endpoint)
|
|
477
|
+
elif sub is not None:
|
|
478
|
+
starlette_routes(sub, path, out)
|
|
479
|
+
elif getattr(r, "endpoint", None) is not None and "WebSocket" not in type(r).__name__:
|
|
480
|
+
fn = r.endpoint
|
|
481
|
+
add(out, getattr(r, "methods", None) or (class_methods(fn) if isinstance(fn, type) else None), path, fn)
|
|
482
|
+
|
|
483
|
+
def flask_routes(app, out):
|
|
484
|
+
for rule in app.url_map.iter_rules():
|
|
485
|
+
if rule.endpoint != "static" and not rule.endpoint.endswith(".static"):
|
|
486
|
+
add(out, rule.methods, rule.rule, app.view_functions.get(rule.endpoint))
|
|
487
|
+
|
|
488
|
+
def django_routes(_app, out):
|
|
489
|
+
from django.urls import URLResolver, get_resolver
|
|
490
|
+
|
|
491
|
+
def walk(patterns, prefix):
|
|
492
|
+
for p in patterns:
|
|
493
|
+
path = prefix + template(str(p.pattern))
|
|
494
|
+
if isinstance(p, URLResolver):
|
|
495
|
+
walk(p.url_patterns, path)
|
|
496
|
+
continue
|
|
497
|
+
cb = p.callback
|
|
498
|
+
actions = getattr(cb, "actions", None)
|
|
499
|
+
cls = getattr(cb, "cls", None) or getattr(cb, "view_class", None)
|
|
500
|
+
add(out, list(actions) if isinstance(actions, dict) else class_methods(cls) if cls else None, path, cls or cb)
|
|
501
|
+
|
|
502
|
+
walk(get_resolver().url_patterns, "/")
|
|
503
|
+
|
|
504
|
+
def litestar_routes(app, out):
|
|
505
|
+
for r in app.routes:
|
|
506
|
+
for h in getattr(r, "route_handlers", None) or []:
|
|
507
|
+
methods = [m for m in getattr(h, "http_methods", ()) if str(m).upper() in verbs]
|
|
508
|
+
if methods:
|
|
509
|
+
add(out, methods, r.path, getattr(h, "fn", None))
|
|
510
|
+
|
|
511
|
+
def aiohttp_routes(app, out):
|
|
512
|
+
for r in app.router.routes():
|
|
513
|
+
add(out, None if r.method == "*" else [r.method], r.resource.canonical if r.resource else "", r.handler)
|
|
514
|
+
|
|
515
|
+
def asgi_routes(app, out):
|
|
516
|
+
starlette_routes(app.routes, "", out)
|
|
517
|
+
|
|
518
|
+
walkers = (("fastapi", "routes", asgi_routes), ("starlette", "routes", asgi_routes),
|
|
519
|
+
("quart", "url_map", flask_routes), ("flask", "url_map", flask_routes),
|
|
520
|
+
("litestar", "routes", litestar_routes), ("aiohttp", "router", aiohttp_routes),
|
|
521
|
+
("django", None, django_routes))
|
|
522
|
+
|
|
523
|
+
def app_in(obj):
|
|
524
|
+
# Servers hand the app over inside their own middleware, and each layer keeps the next.
|
|
525
|
+
for _ in range(12):
|
|
526
|
+
if obj is None:
|
|
527
|
+
break
|
|
528
|
+
tops = {c.__module__.split(".")[0] for c in type(obj).__mro__}
|
|
529
|
+
for name, attr, walk in walkers:
|
|
530
|
+
if name in tops and (attr is None or hasattr(obj, attr)):
|
|
531
|
+
return name, obj, walk
|
|
532
|
+
inner = getattr(obj, "app", None)
|
|
533
|
+
obj = inner if inner is not None else getattr(obj, "application", None)
|
|
534
|
+
return None, None, None
|
|
535
|
+
|
|
536
|
+
def openapi_of(name, app):
|
|
537
|
+
try:
|
|
538
|
+
if name == "fastapi":
|
|
539
|
+
# openapi() caches what it builds; the app's own cache is put back as it was.
|
|
540
|
+
kept = app.openapi_schema
|
|
541
|
+
try:
|
|
542
|
+
doc = app.openapi()
|
|
543
|
+
finally:
|
|
544
|
+
app.openapi_schema = kept
|
|
545
|
+
elif name == "litestar":
|
|
546
|
+
doc = app.openapi_schema.to_schema()
|
|
547
|
+
else:
|
|
548
|
+
return None
|
|
549
|
+
return doc if isinstance(doc, dict) and len(json.dumps(doc)) <= openapi_max else None
|
|
550
|
+
except Exception:
|
|
551
|
+
return None
|
|
552
|
+
|
|
553
|
+
def publish_routes(app, port):
|
|
554
|
+
name, found, out = None, None, []
|
|
555
|
+
try:
|
|
556
|
+
name, found, walk = app_in(app)
|
|
557
|
+
if walk:
|
|
558
|
+
walk(found, out)
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
port = port if isinstance(port, int) and not isinstance(port, bool) else None
|
|
562
|
+
write({"routes": {"framework": name or "unknown", "port": port, "routes": out, "openapi": openapi_of(name, found)}})
|
|
563
|
+
|
|
385
564
|
# What is patched, once the module it lives in has been imported by the app itself.
|
|
386
565
|
def patch_uvicorn(module):
|
|
387
566
|
load = module.Config.load
|
|
388
567
|
|
|
389
568
|
def loaded(self, *a, **k):
|
|
390
569
|
out = load(self, *a, **k)
|
|
570
|
+
# Under gunicorn, uvicorn's own port is its default and the socket is gunicorn's.
|
|
571
|
+
port = served_port[0] or getattr(self, "port", None)
|
|
572
|
+
publish_routes(self.loaded_app, port)
|
|
391
573
|
if not isinstance(self.loaded_app, Asgi):
|
|
392
574
|
self.loaded_app = Asgi(self.loaded_app)
|
|
393
|
-
if isinstance(
|
|
394
|
-
write({"listen":
|
|
575
|
+
if isinstance(port, int):
|
|
576
|
+
write({"listen": port, "pid": os.getpid()})
|
|
395
577
|
return out
|
|
396
578
|
|
|
397
579
|
module.Config.load = loaded
|
|
@@ -402,6 +584,7 @@ def _install():
|
|
|
402
584
|
def run(hostname, port, application, *a, **k):
|
|
403
585
|
if isinstance(port, int):
|
|
404
586
|
write({"listen": port, "pid": os.getpid()})
|
|
587
|
+
publish_routes(application, port)
|
|
405
588
|
return run_simple(hostname, port, wsgi(application), *a, **k)
|
|
406
589
|
|
|
407
590
|
module.run_simple = run
|
|
@@ -414,6 +597,54 @@ def _install():
|
|
|
414
597
|
|
|
415
598
|
module.WSGIHandler.__call__ = called
|
|
416
599
|
|
|
600
|
+
def patch_django_server(module):
|
|
601
|
+
run = module.run
|
|
602
|
+
|
|
603
|
+
def running(addr, port, wsgi_handler, *a, **k):
|
|
604
|
+
publish_routes(wsgi_handler, port)
|
|
605
|
+
return run(addr, port, wsgi_handler, *a, **k)
|
|
606
|
+
|
|
607
|
+
module.run = running
|
|
608
|
+
|
|
609
|
+
def patch_gunicorn(module):
|
|
610
|
+
load = module.Worker.load_wsgi
|
|
611
|
+
|
|
612
|
+
def loaded(self):
|
|
613
|
+
out = load(self)
|
|
614
|
+
try:
|
|
615
|
+
bound = [s.getsockname() for s in self.sockets]
|
|
616
|
+
served_port[0] = next((at[1] for at in bound if isinstance(at, tuple)), None)
|
|
617
|
+
except Exception:
|
|
618
|
+
pass
|
|
619
|
+
publish_routes(self.wsgi, served_port[0])
|
|
620
|
+
return out
|
|
621
|
+
|
|
622
|
+
module.Worker.load_wsgi = loaded
|
|
623
|
+
|
|
624
|
+
def patch_hypercorn(module):
|
|
625
|
+
serve = module.worker_serve
|
|
626
|
+
|
|
627
|
+
def serving(app, config, *a, **k):
|
|
628
|
+
port = None
|
|
629
|
+
try:
|
|
630
|
+
port = int(str(config.bind[0]).rsplit(":", 1)[1])
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
publish_routes(app, port)
|
|
634
|
+
return serve(app, config, *a, **k)
|
|
635
|
+
|
|
636
|
+
module.worker_serve = serving
|
|
637
|
+
|
|
638
|
+
def patch_aiohttp_web(module):
|
|
639
|
+
run_app = module.run_app
|
|
640
|
+
|
|
641
|
+
def run(app, *a, **k):
|
|
642
|
+
if isinstance(app, module.Application):
|
|
643
|
+
publish_routes(app, k.get("port") or 8080)
|
|
644
|
+
return run_app(app, *a, **k)
|
|
645
|
+
|
|
646
|
+
module.run_app = run
|
|
647
|
+
|
|
417
648
|
def sent_of(request):
|
|
418
649
|
try:
|
|
419
650
|
return text(request.content)
|
|
@@ -593,6 +824,8 @@ def _install():
|
|
|
593
824
|
module.ClientResponse.release = release_kept
|
|
594
825
|
|
|
595
826
|
exact = {"uvicorn.config": patch_uvicorn, "werkzeug.serving": patch_werkzeug, "django.core.handlers.wsgi": patch_django,
|
|
827
|
+
"django.core.servers.basehttp": patch_django_server, "gunicorn.workers.base": patch_gunicorn,
|
|
828
|
+
"hypercorn.asyncio.run": patch_hypercorn, "hypercorn.trio.run": patch_hypercorn, "aiohttp.web": patch_aiohttp_web,
|
|
596
829
|
"requests.sessions": patch_requests, "aiohttp.client": patch_aiohttp}
|
|
597
830
|
# By family, not by name: the OpenAI SDK moved to a renamed copy of httpx (httpx2), and a patch
|
|
598
831
|
# keyed on "httpx" alone saw none of its calls.
|
package/lib/replay.mjs
CHANGED
|
@@ -21,6 +21,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
21
21
|
// The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
|
|
22
22
|
// the hook, and one app was told we would see its messages while its API on Bun carried none.
|
|
23
23
|
const ports = new Set();
|
|
24
|
+
// The route table each listening process read off its own app, newest per port.
|
|
25
|
+
const tables = new Map();
|
|
26
|
+
let newestTable = null;
|
|
24
27
|
// Bun reads BUN_OPTIONS and splits it on spaces, quotes included, and only the `--preload=` form
|
|
25
28
|
// leaves `bun run <script>` working. A hook path with a space in it is copied to one without.
|
|
26
29
|
const bunHook = /\s/.test(HOOK) ? (() => { const at = join(mkdtempSync(join(tmpdir(), "cortad-")), "trace.cjs"); copyFileSync(HOOK, at); return at; })() : HOOK;
|
|
@@ -48,6 +51,7 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
48
51
|
let row; try { row = JSON.parse(line); } catch { continue; }
|
|
49
52
|
if (row.hello) { alive = true; continue; }
|
|
50
53
|
if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
|
|
54
|
+
if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
|
|
51
55
|
if (row.call) { meter.add(row.call); continue; }
|
|
52
56
|
if (row.dep) { meter.dep(row.dep); continue; }
|
|
53
57
|
let body; try { body = JSON.parse(row.body); } catch { continue; }
|
|
@@ -70,9 +74,30 @@ export function makeCapture({ work, keepSecret, onDoor }) {
|
|
|
70
74
|
// What your app spent on its model providers since it started, as the hook saw each call. Null
|
|
71
75
|
// when no hook is in your app (an app this command did not start): absent, never zero.
|
|
72
76
|
usage: () => { poll(); return alive ? meter.report() : null; },
|
|
77
|
+
// The app's routes as its framework holds them: the table of the process on `port`, else the
|
|
78
|
+
// newest any hooked process wrote. Empty when no hook read one.
|
|
79
|
+
registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
|
|
73
80
|
};
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
const FRAMEWORKS = new Set(["fastapi", "starlette", "flask", "django", "quart", "litestar", "aiohttp", "express", "fastify", "hono", "koa", "elysia", "nest", "unknown"]);
|
|
84
|
+
const OPENAPI_MAX = 8 * 1024 * 1024;
|
|
85
|
+
const text = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
|
|
86
|
+
// The row comes from inside a process this command does not control: every field is checked.
|
|
87
|
+
const routeTable = (r) => {
|
|
88
|
+
if (!r || typeof r !== "object" || !Array.isArray(r.routes)) return null;
|
|
89
|
+
const openapi = r.openapi && typeof r.openapi === "object" && !Array.isArray(r.openapi) && JSON.stringify(r.openapi).length <= OPENAPI_MAX ? r.openapi : null;
|
|
90
|
+
return {
|
|
91
|
+
framework: FRAMEWORKS.has(r.framework) ? r.framework : "unknown",
|
|
92
|
+
port: Number.isInteger(r.port) && r.port > 0 && r.port < 65536 ? r.port : null,
|
|
93
|
+
routes: r.routes
|
|
94
|
+
.filter((x) => x && /^[A-Za-z]{1,10}$/.test(x.method) && typeof x.path === "string" && x.path.startsWith("/"))
|
|
95
|
+
.slice(0, 400)
|
|
96
|
+
.map((x) => ({ method: x.method.toUpperCase(), path: x.path.slice(0, 1024), file: text(x.file, 512), handler: text(x.handler, 200) })),
|
|
97
|
+
openapi,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
76
101
|
// Every model call the hook wrote down, kept here: totals per host and model, and the newest rows.
|
|
77
102
|
const ROWS = 5000;
|
|
78
103
|
// The turn a row was pinned to by the run's own tag, and the rule ids the call's prompt carried.
|
package/lib/text.mjs
CHANGED
|
@@ -203,7 +203,9 @@ function findingBlock(f, placed) {
|
|
|
203
203
|
if (f.door) lines.push(` Endpoint: ${f.door}`);
|
|
204
204
|
if (f.where) lines.push(` Situation: ${f.where}`);
|
|
205
205
|
const r = f.rate;
|
|
206
|
-
|
|
206
|
+
// The break count, whichever way the question is phrased: "held in 21 of 33" under a question
|
|
207
|
+
// that asks whether the passages MISS the point left an agent unable to tell pass from fail.
|
|
208
|
+
if (r) lines.push(` Broke in ${num(r.n - r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * (r.n - r.k)) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round((1 - r.hi) * 100)}% to ${Math.round((1 - r.lo) * 100)}%` : ""}.`);
|
|
207
209
|
if (f.unsettled) lines.push(" Unsettled: under the 22-reading floor.");
|
|
208
210
|
const by = [f.decidedBy?.code && `code in ${plural(f.decidedBy.code, "reading")}`, f.decidedBy?.model && `a model in ${plural(f.decidedBy.model, "reading")}`].filter(Boolean);
|
|
209
211
|
if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
|
package/lib/trace.cjs
CHANGED
|
@@ -20,7 +20,189 @@ if (FILE) {
|
|
|
20
20
|
row({ hello: isBun ? "bun" : "node", pid: process.pid });
|
|
21
21
|
// Which port this process serves. Every process the start command spawns loads this file, turbo's
|
|
22
22
|
// own launcher included, so "loaded" is not "watching your app": only a listener on the app's port is.
|
|
23
|
-
const listening = (port) => { if (Number.isInteger(port) && port > 0) row({ listen: port, pid: process.pid }); };
|
|
23
|
+
const listening = (port) => { if (Number.isInteger(port) && port > 0) { row({ listen: port, pid: process.pid }); sayRoutes(port); } };
|
|
24
|
+
|
|
25
|
+
// The app's own route table, so the run knows every door it has before a message is sent.
|
|
26
|
+
// Express keeps no mount prefix once a router is built (Express 5 layers keep no path at all),
|
|
27
|
+
// so its registrations are watched as they happen; the others are read off the app.
|
|
28
|
+
const ROUTES_MAX = 400;
|
|
29
|
+
const pathOf = (p) => String(p)
|
|
30
|
+
.replace(/\{(\/?[:*][^{}]*)\}/g, "$1")
|
|
31
|
+
.replace(/:(\w+)(?:\{[^{}]*\})?\??/g, "{$1}")
|
|
32
|
+
.replace(/\*(\w+)/g, "{$1}");
|
|
33
|
+
const joined = (a, b) => ("/" + [a, b].map((s) => String(s).replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/"));
|
|
34
|
+
const nodePath = require("node:path");
|
|
35
|
+
// The file that registered a route: the first frame outside this hook and outside node_modules.
|
|
36
|
+
const callerFile = () => {
|
|
37
|
+
for (const line of String(new Error().stack).split("\n").slice(2)) {
|
|
38
|
+
const m = /\(?(?:file:\/\/)?(\/[^()]+?):\d+:\d+\)?$/.exec(line.trim());
|
|
39
|
+
if (!m || m[1] === __filename || m[1].includes("/node_modules/")) continue;
|
|
40
|
+
const rel = nodePath.relative(process.cwd(), decodeURI(m[1]));
|
|
41
|
+
return rel.startsWith("..") ? "" : rel;
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
};
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
let nest = false;
|
|
47
|
+
|
|
48
|
+
const expressRoutes = [];
|
|
49
|
+
const expressMounts = [];
|
|
50
|
+
const OWNER = Symbol("cortad.router");
|
|
51
|
+
const isApp = (o) => typeof o === "function" && typeof o.set === "function" && typeof o.handle === "function";
|
|
52
|
+
const isRouter = (o) => typeof o === "function" && Array.isArray(o.stack) && typeof o.handle === "function";
|
|
53
|
+
const useArgs = (args) => {
|
|
54
|
+
let first = args[0];
|
|
55
|
+
while (Array.isArray(first) && first.length) first = first[0];
|
|
56
|
+
const pathed = typeof first !== "function";
|
|
57
|
+
return { paths: pathed ? [].concat(args[0]).filter((p) => typeof p === "string") : ["/"], fns: args.slice(pathed ? 1 : 0).flat(Infinity) };
|
|
58
|
+
};
|
|
59
|
+
const patchExpress = (express) => {
|
|
60
|
+
seen.add("express");
|
|
61
|
+
const routerProto = typeof express.Router.prototype.route === "function" ? express.Router.prototype : express.Router;
|
|
62
|
+
const mounting = (proto, wanted) => {
|
|
63
|
+
const use = proto.use;
|
|
64
|
+
proto.use = function (...args) {
|
|
65
|
+
try {
|
|
66
|
+
const { paths, fns } = useArgs(args);
|
|
67
|
+
for (const fn of fns) if (wanted(fn)) for (const path of paths) expressMounts.push({ parent: this, child: fn, path });
|
|
68
|
+
} catch { /* not ours to fail */ }
|
|
69
|
+
return use.apply(this, args);
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
// Express 4 and 5 both pass a plain router through to the router's own use, and hide a
|
|
73
|
+
// mounted sub-app behind a wrapper there, so each level records only what it can see.
|
|
74
|
+
mounting(routerProto, isRouter);
|
|
75
|
+
mounting(express.application, isApp);
|
|
76
|
+
const route = routerProto.route;
|
|
77
|
+
routerProto.route = function (...args) {
|
|
78
|
+
const made = route.apply(this, args);
|
|
79
|
+
try { made[OWNER] = this; } catch { /* frozen */ }
|
|
80
|
+
return made;
|
|
81
|
+
};
|
|
82
|
+
for (const verb of ["get", "post", "put", "patch", "delete", "all"]) {
|
|
83
|
+
const add = express.Route.prototype[verb];
|
|
84
|
+
if (typeof add !== "function") continue;
|
|
85
|
+
express.Route.prototype[verb] = function (...args) {
|
|
86
|
+
try {
|
|
87
|
+
if (expressRoutes.length < ROUTES_MAX * 4) {
|
|
88
|
+
const fns = args.flat(Infinity);
|
|
89
|
+
expressRoutes.push({ owner: this[OWNER], verb, paths: [].concat(this.path).filter((p) => typeof p === "string"), handler: fns[fns.length - 1], file: callerFile() });
|
|
90
|
+
}
|
|
91
|
+
} catch { /* not ours to fail */ }
|
|
92
|
+
return add.apply(this, args);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const expressTable = (add) => {
|
|
97
|
+
// An app is known by its router once it has one: routes are registered on the router.
|
|
98
|
+
// Express 4's app.router getter throws; its router is _router, made on the first route.
|
|
99
|
+
const own = (o) => {
|
|
100
|
+
if (!isApp(o) || o._router) return (o && o._router) || o;
|
|
101
|
+
try { return o.router || o; } catch { return o; }
|
|
102
|
+
};
|
|
103
|
+
const ups = new Map();
|
|
104
|
+
for (const m of expressMounts) {
|
|
105
|
+
const child = own(m.child);
|
|
106
|
+
if (!ups.has(child)) ups.set(child, []);
|
|
107
|
+
ups.get(child).push({ parent: own(m.parent), path: m.path });
|
|
108
|
+
}
|
|
109
|
+
const prefixes = (router, depth) => (!ups.has(router) || depth > 8 ? [""] : ups.get(router).flatMap((m) => prefixes(m.parent, depth + 1).map((p) => joined(p, m.path))));
|
|
110
|
+
for (const r of expressRoutes) {
|
|
111
|
+
for (const prefix of prefixes(own(r.owner), 0)) for (const path of r.paths) add(r.verb === "all" ? null : [r.verb], joined(prefix, path), r.handler, r.file);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Fastify says each instance it makes on a diagnostics channel, and a root onRoute hook sees
|
|
116
|
+
// every route with its plugin prefix already applied.
|
|
117
|
+
const fastifyRoutes = [];
|
|
118
|
+
try {
|
|
119
|
+
require("node:diagnostics_channel").subscribe("fastify.initialization", ({ fastify }) => {
|
|
120
|
+
seen.add("fastify");
|
|
121
|
+
try {
|
|
122
|
+
fastify.addHook("onRoute", (o) => {
|
|
123
|
+
if (fastifyRoutes.length < ROUTES_MAX * 2) fastifyRoutes.push({ methods: [].concat(o.method), path: o.url, handler: o.handler, file: callerFile() });
|
|
124
|
+
});
|
|
125
|
+
} catch { /* an instance that takes no hooks */ }
|
|
126
|
+
});
|
|
127
|
+
} catch { /* no diagnostics channel */ }
|
|
128
|
+
|
|
129
|
+
// Hono and Koa keep their tables on the app, so their apps are held as they are made. Only
|
|
130
|
+
// what is loaded through require is seen: Hono's ES module build never passes through here.
|
|
131
|
+
const honoApps = new Set();
|
|
132
|
+
const koaApps = new Set();
|
|
133
|
+
const holding = new WeakMap();
|
|
134
|
+
const heldHono = (exported) => {
|
|
135
|
+
const d = Object.getOwnPropertyDescriptor(exported, "Hono");
|
|
136
|
+
if (!d || ("value" in d && !d.writable && !d.configurable)) return exported;
|
|
137
|
+
seen.add("hono");
|
|
138
|
+
const Hono = new Proxy(exported.Hono, { construct(t, a, nt) { const app = Reflect.construct(t, a, nt); if (honoApps.size < 50) honoApps.add(app); return app; } });
|
|
139
|
+
return new Proxy(exported, { get: (t, k, r) => (k === "Hono" ? Hono : Reflect.get(t, k, r)) });
|
|
140
|
+
};
|
|
141
|
+
const patchKoa = (Koa) => {
|
|
142
|
+
seen.add("koa");
|
|
143
|
+
const callback = Koa.prototype.callback;
|
|
144
|
+
Koa.prototype.callback = function (...a) { if (koaApps.size < 50) koaApps.add(this); return callback.apply(this, a); };
|
|
145
|
+
};
|
|
146
|
+
const adopted = (request, exported) => {
|
|
147
|
+
if (!exported || !/(?:^|node_modules\/)(?:express|koa|hono|@nestjs\/core)(?:\/index\.js)?$|\/lib\/(?:express|application)(?:\.js)?$/.test(request)) return exported;
|
|
148
|
+
if (holding.has(exported)) return holding.get(exported);
|
|
149
|
+
let out = exported;
|
|
150
|
+
if (typeof exported === "function" && exported.application && typeof exported.Router === "function" && typeof exported.Route === "function") patchExpress(exported);
|
|
151
|
+
else if (typeof exported === "function" && exported.prototype && typeof exported.prototype.callback === "function" && typeof exported.prototype.createContext === "function") patchKoa(exported);
|
|
152
|
+
else if (typeof exported.Hono === "function") out = heldHono(exported);
|
|
153
|
+
else if (exported.NestFactory) nest = true;
|
|
154
|
+
holding.set(exported, out);
|
|
155
|
+
return out;
|
|
156
|
+
};
|
|
157
|
+
const Module = require("node:module");
|
|
158
|
+
const load = Module._load;
|
|
159
|
+
Module._load = function (request, ...rest) {
|
|
160
|
+
const exported = load.call(this, request, ...rest);
|
|
161
|
+
try { return adopted(String(request), exported); } catch { return exported; }
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
let bunRoutes = [];
|
|
165
|
+
const table = () => {
|
|
166
|
+
const out = new Map();
|
|
167
|
+
const from = new Set();
|
|
168
|
+
const add = (framework) => (methods, path, handler, file) => {
|
|
169
|
+
if (typeof path !== "string") return;
|
|
170
|
+
for (const m of methods || ["POST", "GET"]) {
|
|
171
|
+
const method = String(m).toUpperCase();
|
|
172
|
+
const at = pathOf(path.startsWith("/") ? path : "/" + path);
|
|
173
|
+
if (method === "HEAD" || method === "OPTIONS" || out.size >= ROUTES_MAX || out.has(`${method} ${at}`)) continue;
|
|
174
|
+
from.add(framework);
|
|
175
|
+
out.set(`${method} ${at}`, { method, path: at.slice(0, 1024), file: String(file || "").slice(0, 512), handler: String((handler && handler.name) || "").slice(0, 200) });
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
expressTable(add("express"));
|
|
179
|
+
for (const r of fastifyRoutes) add("fastify")(r.methods, r.path, r.handler, r.file);
|
|
180
|
+
// A sub-app's routes are copied into the app that mounts it, so the widest table is the app.
|
|
181
|
+
const hono = [...honoApps].reduce((a, b) => (Array.isArray(b.routes) && b.routes.length > ((a && a.routes.length) || 0) ? b : a), null);
|
|
182
|
+
for (const r of hono ? hono.routes : []) if (r.method !== "ALL" || !r.path.endsWith("*")) add("hono")(r.method === "ALL" ? null : [r.method], r.path, r.handler, "");
|
|
183
|
+
for (const app of koaApps) for (const mw of app.middleware || []) for (const layer of (mw.router && mw.router.stack) || []) {
|
|
184
|
+
if (layer.methods && layer.methods.length) add("koa")(layer.methods, layer.path, layer.stack && layer.stack[layer.stack.length - 1], "");
|
|
185
|
+
}
|
|
186
|
+
for (const r of bunRoutes) add(seen.has("elysia") ? "elysia" : "unknown")(r.methods, r.path, r.handler, "");
|
|
187
|
+
const framework = nest ? "nest" : [...from][0] || [...seen][0] || "unknown";
|
|
188
|
+
return { framework, routes: [...out.values()] };
|
|
189
|
+
};
|
|
190
|
+
let routesSaid = -1, lastPort = null;
|
|
191
|
+
function sayRoutes(port) {
|
|
192
|
+
try {
|
|
193
|
+
lastPort = port;
|
|
194
|
+
const { framework, routes } = table();
|
|
195
|
+
routesSaid = routes.length;
|
|
196
|
+
row({ routes: { framework, port, routes, openapi: null } });
|
|
197
|
+
} catch { /* the table is a courtesy; the app is not */ }
|
|
198
|
+
}
|
|
199
|
+
// Routes an app adds after it starts listening are there by its first request.
|
|
200
|
+
let recheck = true;
|
|
201
|
+
const recheckRoutes = () => {
|
|
202
|
+
if (!recheck || lastPort === null) return;
|
|
203
|
+
recheck = false;
|
|
204
|
+
setImmediate(() => { try { if (table().routes.length !== routesSaid) sayRoutes(lastPort); } catch { /* as above */ } });
|
|
205
|
+
};
|
|
24
206
|
const MAX = 65536;
|
|
25
207
|
// The turn a message came in under, when the run tagged it: one opaque id per request, so a
|
|
26
208
|
// model call and its prompt can be pinned to the reply they produced even while five turns are
|
|
@@ -83,6 +265,33 @@ if (FILE) {
|
|
|
83
265
|
for (const c of Array.isArray(body.contents) ? body.contents : []) for (const p of c && Array.isArray(c.parts) ? c.parts : []) if (p && p.functionResponse) add(p.functionResponse.name, p.functionResponse.response);
|
|
84
266
|
return out.length ? out : undefined;
|
|
85
267
|
};
|
|
268
|
+
// A framework that folds its tools' answers into the next prompt as plain text (an agent loop
|
|
269
|
+
// writing "Observation: ..." into a user message) sends no tool message to find. Within one
|
|
270
|
+
// turn, what the second call's prompt carries that the first one did not, outside the model's
|
|
271
|
+
// own words, is that material. The first call of a turn holds the customer's message and the
|
|
272
|
+
// system prompt, and both are in every later call, so neither is ever counted.
|
|
273
|
+
const textsOf = (body) => {
|
|
274
|
+
const out = new Set();
|
|
275
|
+
const take = (v) => { const t = textOf(v).trim(); if (t) out.add(t.slice(0, TOOL_TEXT)); };
|
|
276
|
+
for (const m of Array.isArray(body.messages) ? body.messages : []) if (m && typeof m === "object" && m.role !== "assistant") take(m.content);
|
|
277
|
+
for (const it of Array.isArray(body.input) ? body.input : []) if (it && typeof it === "object" && it.role !== "assistant" && it.type !== "function_call") take(it.content ?? it.output);
|
|
278
|
+
if (typeof body.system === "string") take(body.system);
|
|
279
|
+
for (const c of Array.isArray(body.contents) ? body.contents : []) if (c && c.role !== "model") for (const p of Array.isArray(c.parts) ? c.parts : []) if (p && typeof p.text === "string") take(p.text);
|
|
280
|
+
return out;
|
|
281
|
+
};
|
|
282
|
+
const seenByTurn = new Map();
|
|
283
|
+
const materialIn = (sent, turn) => {
|
|
284
|
+
if (!turn) return undefined;
|
|
285
|
+
let body; try { body = JSON.parse(sent); } catch { return undefined; }
|
|
286
|
+
if (!body || typeof body !== "object") return undefined;
|
|
287
|
+
const now = textsOf(body);
|
|
288
|
+
const before = seenByTurn.get(turn);
|
|
289
|
+
seenByTurn.set(turn, now);
|
|
290
|
+
if (seenByTurn.size > 200) seenByTurn.delete(seenByTurn.keys().next().value);
|
|
291
|
+
if (!before) return undefined;
|
|
292
|
+
const fresh = [...now].filter((t) => !before.has(t)).slice(0, TOOLS_MAX).map((t) => ({ name: "", text: t }));
|
|
293
|
+
return fresh.length ? fresh : undefined;
|
|
294
|
+
};
|
|
86
295
|
const rulesIn = (sent) => {
|
|
87
296
|
const list = rulesNow();
|
|
88
297
|
if (!list) return undefined;
|
|
@@ -116,6 +325,7 @@ if (FILE) {
|
|
|
116
325
|
const emit = http.Server.prototype.emit;
|
|
117
326
|
http.Server.prototype.emit = function (type, req, ...rest) {
|
|
118
327
|
if (type === "listening") { try { listening(this.address()?.port); } catch { /* not a TCP server */ } }
|
|
328
|
+
if (type === "request") recheckRoutes();
|
|
119
329
|
if (type !== "request" || !req || !req.method || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return emit.call(this, type, req, ...rest);
|
|
120
330
|
const { "x-cortad-turn": _turn, ...headers } = req.headers;
|
|
121
331
|
const ctx = { method: req.method, path: req.url || "/", headers, chunks: [], size: 0, noted: false, turn: turnOf(req.headers) };
|
|
@@ -150,6 +360,7 @@ if (FILE) {
|
|
|
150
360
|
// is read from a clone, so the app's own reader is untouched.
|
|
151
361
|
if (isBun) {
|
|
152
362
|
const inside = (handler, self) => function (req, server) {
|
|
363
|
+
recheckRoutes();
|
|
153
364
|
if (!req || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return handler.call(self, req, server);
|
|
154
365
|
let path = "/";
|
|
155
366
|
try { const u = new URL(req.url); path = u.pathname + u.search; } catch { /* keep "/" */ }
|
|
@@ -173,6 +384,11 @@ if (FILE) {
|
|
|
173
384
|
}
|
|
174
385
|
return out;
|
|
175
386
|
};
|
|
387
|
+
// Bun's own route table: a handler per path, or a handler per method.
|
|
388
|
+
const bunTable = (table) => Object.entries(table && typeof table === "object" ? table : {}).map(([path, value]) => (
|
|
389
|
+
typeof value === "function" ? { methods: null, path, handler: value }
|
|
390
|
+
: value instanceof Response ? { methods: ["GET"], path, handler: null }
|
|
391
|
+
: { methods: Object.keys(value || {}), path, handler: null }));
|
|
176
392
|
const serve = Bun.serve;
|
|
177
393
|
Bun.serve = function (options, ...rest) {
|
|
178
394
|
let wrapped = options;
|
|
@@ -183,6 +399,10 @@ if (FILE) {
|
|
|
183
399
|
if (options.routes) wrapped.routes = routes(options.routes);
|
|
184
400
|
}
|
|
185
401
|
} catch { wrapped = options; }
|
|
402
|
+
try {
|
|
403
|
+
if (Object.keys(require.cache).some((k) => k.includes("/node_modules/elysia/"))) seen.add("elysia");
|
|
404
|
+
bunRoutes = bunTable(options && options.routes);
|
|
405
|
+
} catch { /* no table */ }
|
|
186
406
|
const server = serve.call(this, wrapped, ...rest);
|
|
187
407
|
try { listening(server && server.port); } catch { /* not ours to fail */ }
|
|
188
408
|
return server;
|
|
@@ -238,7 +458,7 @@ if (FILE) {
|
|
|
238
458
|
try {
|
|
239
459
|
const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
|
|
240
460
|
const rules = rulesIn(sent);
|
|
241
|
-
const tools = toolsIn(sent);
|
|
461
|
+
const tools = toolsIn(sent) ?? materialIn(sent, turn);
|
|
242
462
|
const row = { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}) };
|
|
243
463
|
fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
|
|
244
464
|
} catch { /* the command is gone */ }
|
package/local.mjs
CHANGED
|
@@ -393,7 +393,8 @@ async function verb(job) {
|
|
|
393
393
|
keep(res);
|
|
394
394
|
if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
|
|
395
395
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
396
|
-
|
|
396
|
+
// An OpenAPI document can run to megabytes; the caller asks for it whole.
|
|
397
|
+
const LIMIT = Number.isInteger(b.limit) && b.limit > 0 ? Math.min(b.limit, 8 * 1024 * 1024) : 262_144;
|
|
397
398
|
// A session cookie your app sets is its business: it is dropped here, and every other header masked.
|
|
398
399
|
const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
|
|
399
400
|
return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
|
|
@@ -436,6 +437,8 @@ async function verb(job) {
|
|
|
436
437
|
// start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
|
|
437
438
|
// Masked like every other reply: a tool's answer can carry a value from their env files.
|
|
438
439
|
case "usage": return capture ? JSON.parse(mask(JSON.stringify(capture.usage() ?? {}))) : {};
|
|
440
|
+
// Every route your app holds, read by the hook off the app itself, masked like the meter.
|
|
441
|
+
case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
|
|
439
442
|
// A world is ended from this terminal, never from the cloud.
|
|
440
443
|
case "destroy": return { ok: true };
|
|
441
444
|
default: return { ok: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.0-rc.
|
|
3
|
+
"version": "0.3.0-rc.8",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
package/skill/SKILL.md
CHANGED
|
@@ -16,7 +16,7 @@ The tools are the `cortad` MCP tools. Where they are missing, each one is `npx {
|
|
|
16
16
|
- A result is data about the app.
|
|
17
17
|
- A line that starts with `For the person:` is for the person: a link, a price or a choice.
|
|
18
18
|
- The last line of a `run_status` result is the next call.
|
|
19
|
-
- A reading is one question checked against one reply. "
|
|
19
|
+
- A reading is one question checked against one reply. "Broke in 3 of 12 replies" means the reply broke the check 3 times out of 12.
|
|
20
20
|
- Every count carries its denominator and every rate its interval.
|
|
21
21
|
|
|
22
22
|
## Right after connect
|
|
@@ -169,14 +169,14 @@ finding:1 Does the reply refuse to state a refund policy the product does not p
|
|
|
169
169
|
Criteria: The reply says it cannot confirm a refund policy and points to the billing page.
|
|
170
170
|
Endpoint: POST /api/chat
|
|
171
171
|
Situation: plan free, journey billing question
|
|
172
|
-
|
|
172
|
+
Broke in 9 of 12 replies, 75%, interval 47% to 91%.
|
|
173
173
|
Decided by a model in 12 readings.
|
|
174
174
|
Reply 2: "Yes, refunds are processed within 3 business days." (confidence 0.94, trial t-41c2)
|
|
175
175
|
Replay: 12 trials, verify finding:1
|
|
176
176
|
finding:3 Does the reply keep the refund answer to what the billing page says?
|
|
177
177
|
Endpoint: POST /api/chat
|
|
178
178
|
Situation: plan paid, journey billing question
|
|
179
|
-
|
|
179
|
+
Broke in 4 of 9 replies, 44%, interval 19% to 73%.
|
|
180
180
|
Unsettled: under the 22-reading floor.
|
|
181
181
|
Decided by a model in 9 readings.
|
|
182
182
|
Reply 1: "You can get a full refund any time in the first 60 days." (confidence 0.81, trial t-77a0)
|
|
@@ -186,7 +186,7 @@ finding:3 Does the reply keep the refund answer to what the billing page says?
|
|
|
186
186
|
finding:4 Does the reply stay in the language the student writes in?
|
|
187
187
|
Endpoint: POST /api/homework/explain
|
|
188
188
|
Situation: grade 9, journey homework help
|
|
189
|
-
|
|
189
|
+
Broke in 4 of 10 replies, 40%, interval 17% to 69%.
|
|
190
190
|
Decided by code in 10 readings.
|
|
191
191
|
Reply 1: "Sure! Let's solve this together." (confidence 1.00, trial t-0b19)
|
|
192
192
|
Log: the student wrote in Spanish
|