cortad 0.3.1-rc.1 → 0.3.1-rc.3

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.
@@ -84,6 +84,24 @@ def _install():
84
84
  return ""
85
85
  return ""
86
86
 
87
+ def items(v):
88
+ return [x for x in v if isinstance(x, dict)] if isinstance(v, list) else []
89
+
90
+ # Only what came after the person's latest message belongs to this turn: a thread the app
91
+ # resends whole carries every earlier turn's tool answers too.
92
+ def since(seq, is_person):
93
+ at = -1
94
+ for i, m in enumerate(seq):
95
+ if is_person(m):
96
+ at = i
97
+ return seq[at + 1:]
98
+
99
+ def person_said(m):
100
+ return m.get("role") == "user" and not any(isinstance(c, dict) and c.get("type") == "tool_result" for c in (m.get("content") if isinstance(m.get("content"), list) else []))
101
+
102
+ def person_asked(c):
103
+ return c.get("role") == "user" and not any(p.get("functionResponse") for p in items(c.get("parts")))
104
+
87
105
  def tools_in(sent):
88
106
  body = parsed(sent) if isinstance(sent, str) else None
89
107
  if not isinstance(body, dict):
@@ -95,39 +113,258 @@ def _install():
95
113
  if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
96
114
  out.append({"name": str(name or "")[:80], "text": t})
97
115
 
98
- messages = body.get("messages") if isinstance(body.get("messages"), list) else []
116
+ messages = items(body.get("messages"))
99
117
  for m in messages:
100
- if not isinstance(m, dict):
101
- continue
102
- for c in m.get("tool_calls") or []:
103
- if isinstance(c, dict) and c.get("id") and isinstance(c.get("function"), dict):
118
+ for c in items(m.get("tool_calls")):
119
+ if c.get("id") and isinstance(c.get("function"), dict):
104
120
  names[c["id"]] = c["function"].get("name")
105
- if isinstance(m.get("content"), list):
106
- for c in m["content"]:
107
- if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("id"):
108
- names[c["id"]] = c.get("name")
109
- for m in messages:
110
- if not isinstance(m, dict):
111
- continue
121
+ for c in items(m.get("content")):
122
+ if c.get("type") == "tool_use" and c.get("id"):
123
+ names[c["id"]] = c.get("name")
124
+ for m in since(messages, person_said):
112
125
  if m.get("role") in ("tool", "function"):
113
126
  add(m.get("name") or names.get(m.get("tool_call_id")), m.get("content"))
114
- if isinstance(m.get("content"), list):
115
- for c in m["content"]:
116
- if isinstance(c, dict) and c.get("type") == "tool_result":
117
- add(names.get(c.get("tool_use_id")), c.get("content"))
118
- items = body.get("input") if isinstance(body.get("input"), list) else []
119
- for it in items:
120
- if isinstance(it, dict) and it.get("type") == "function_call" and it.get("call_id"):
127
+ for c in items(m.get("content")):
128
+ if c.get("type") == "tool_result":
129
+ add(names.get(c.get("tool_use_id")), c.get("content"))
130
+ inputs = items(body.get("input"))
131
+ for it in inputs:
132
+ if it.get("type") == "function_call" and it.get("call_id"):
121
133
  names[it["call_id"]] = it.get("name")
122
- for it in items:
123
- if isinstance(it, dict) and it.get("type") == "function_call_output":
134
+ for it in since(inputs, lambda x: x.get("role") == "user"):
135
+ if it.get("type") == "function_call_output":
124
136
  add(names.get(it.get("call_id")), it.get("output"))
125
- for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
126
- for p in c.get("parts") if isinstance(c, dict) and isinstance(c.get("parts"), list) else []:
127
- if isinstance(p, dict) and isinstance(p.get("functionResponse"), dict):
137
+ for c in since(items(body.get("contents")), person_asked):
138
+ for p in items(c.get("parts")):
139
+ if isinstance(p.get("functionResponse"), dict):
128
140
  add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
129
141
  return out or None
130
142
 
143
+ # What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
144
+ # legacy function_call, a responses function_call item, an Anthropic or Bedrock tool use, a
145
+ # Gemini functionCall. Read off the model's reply, and off this turn's earlier calls as the
146
+ # prompt resends them. Names and arguments only: every value clipped, a secret never written.
147
+ calls_max, value_max, args_max = 12, 200, 1200
148
+ secret_key = re.compile(r"(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$")
149
+ secret_value = re.compile(r"^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)")
150
+
151
+ def clipped(v, depth=0):
152
+ if isinstance(v, str):
153
+ return "[secret]" if secret_value.match(v) else (v[:value_max] + "…" if len(v) > value_max else v)
154
+ if isinstance(v, list):
155
+ return [] if depth > 4 else [clipped(x, depth + 1) for x in v[:20]]
156
+ if isinstance(v, dict):
157
+ if depth > 4:
158
+ return {}
159
+ return {k: ("[secret]" if secret_key.search(re.sub(r"([a-z])([A-Z])", r"\1_\2", str(k)).lower()) else clipped(x, depth + 1)) for k, x in list(v.items())[:40]}
160
+ return v
161
+
162
+ def args_text(raw):
163
+ v = raw
164
+ if isinstance(raw, str):
165
+ v = parsed(raw) if raw.strip() else {}
166
+ if v is None:
167
+ v = raw
168
+ return json.dumps(clipped({} if v is None else v), ensure_ascii=False, separators=(",", ":"))[:args_max]
169
+
170
+ def calls_of(events):
171
+ whole, parts, n = {}, {}, [0]
172
+
173
+ def put(key, name, args):
174
+ if name:
175
+ if not key:
176
+ n[0] += 1
177
+ key = "w%d" % n[0]
178
+ whole[key] = (name, args)
179
+
180
+ def piece(key, name, args):
181
+ p = parts.setdefault(key, ["", ""])
182
+ if name and not p[0]:
183
+ p[0] = name
184
+ if isinstance(args, str):
185
+ p[1] += args
186
+
187
+ def blocks(content):
188
+ for b in items(content):
189
+ if b.get("type") == "tool_use":
190
+ put(b.get("id"), b.get("name"), b.get("input"))
191
+ if isinstance(b.get("toolUse"), dict):
192
+ put(b["toolUse"].get("toolUseId"), b["toolUse"].get("name"), b["toolUse"].get("input"))
193
+
194
+ for e in events:
195
+ if not isinstance(e, dict):
196
+ continue
197
+ for c in items(e.get("choices")):
198
+ m = c.get("message") if isinstance(c.get("message"), dict) else None
199
+ if m:
200
+ for t in items(m.get("tool_calls")):
201
+ f = t.get("function") if isinstance(t.get("function"), dict) else None
202
+ if f:
203
+ put(t.get("id"), f.get("name"), f.get("arguments"))
204
+ if isinstance(m.get("function_call"), dict):
205
+ put(None, m["function_call"].get("name"), m["function_call"].get("arguments"))
206
+ d = c.get("delta") if isinstance(c.get("delta"), dict) else None
207
+ if d:
208
+ for t in items(d.get("tool_calls")):
209
+ f = t.get("function") if isinstance(t.get("function"), dict) else {}
210
+ piece("c%s.%s" % (c.get("index") or 0, t.get("index", t.get("id"))), f.get("name"), f.get("arguments"))
211
+ if isinstance(d.get("function_call"), dict):
212
+ piece("f%s" % (c.get("index") or 0), d["function_call"].get("name"), d["function_call"].get("arguments"))
213
+ resp = e.get("response") if isinstance(e.get("response"), dict) else {}
214
+ for it in items(e.get("output")) + items([e.get("item")]) + items(resp.get("output")):
215
+ if it.get("type") == "function_call":
216
+ put(it.get("call_id") or it.get("id"), it.get("name"), it.get("arguments"))
217
+ blocks(e.get("content"))
218
+ if isinstance(e.get("message"), dict):
219
+ blocks(e["message"].get("content"))
220
+ if isinstance(e.get("output"), dict) and isinstance(e["output"].get("message"), dict):
221
+ blocks(e["output"]["message"].get("content"))
222
+ cb = e.get("content_block") if isinstance(e.get("content_block"), dict) else {}
223
+ if e.get("type") == "content_block_start" and cb.get("type") == "tool_use":
224
+ piece("a%s" % e.get("index"), cb.get("name"), "")
225
+ delta = e.get("delta") if isinstance(e.get("delta"), dict) else {}
226
+ if e.get("type") == "content_block_delta" and delta.get("type") == "input_json_delta":
227
+ piece("a%s" % e.get("index"), "", delta.get("partial_json"))
228
+ for c in items(e.get("candidates")):
229
+ for p in items((c.get("content") or {}).get("parts") if isinstance(c.get("content"), dict) else None):
230
+ if isinstance(p.get("functionCall"), dict):
231
+ put(None, p["functionCall"].get("name"), p["functionCall"].get("args"))
232
+ found = list(whole.values()) + [tuple(p) for p in parts.values()]
233
+ return [{"name": str(name)[:80], "arguments": args_text(args)} for name, args in found if name]
234
+
235
+ # This turn's earlier calls, as the prompt resends them after the person's latest message.
236
+ def called_before(body):
237
+ events = []
238
+ for m in since(items(body.get("messages")), person_said):
239
+ if m.get("role") == "assistant":
240
+ events.append({"choices": [{"message": m}], "content": m.get("content")})
241
+ events.append({"output": since(items(body.get("input")), lambda x: x.get("role") == "user")})
242
+ for c in since(items(body.get("contents")), person_asked):
243
+ if c.get("role") == "model":
244
+ events.append({"candidates": [{"content": c}]})
245
+ return calls_of(events)
246
+
247
+ def called_in(sent, events):
248
+ body = parsed(sent) if isinstance(sent, str) else None
249
+ out = []
250
+ for c in (called_before(body) if isinstance(body, dict) else []) + calls_of(events):
251
+ if len(out) < calls_max and not any(o["name"] == c["name"] and o["arguments"] == c["arguments"] for o in out):
252
+ out.append(c)
253
+ return out or None
254
+
255
+ # The passages the prompt carried as retrieved context: a block the app itself labels as
256
+ # context, documents, knowledge, sources or search results, in its system prompt or from the
257
+ # person's latest message on, and Anthropic document and search_result blocks. The app's own
258
+ # instructions are not passages: only what sits under such a label is.
259
+ passage_text, passages_max = 3000, 6
260
+ material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b|检索|知识|参考资料|资料|上下文|文档|背景", re.I)
261
+ heading = re.compile(r"^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$")
262
+ # An inline label is a noun phrase that ends in the material word ("context:", "background:",
263
+ # "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
264
+ inline = re.compile(r"^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S")
265
+ named = re.compile(r"(?:" + material.pattern + r")\s*$", re.I)
266
+ tagged = re.compile(r"<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)</\1>")
267
+
268
+ def labelled(text, add):
269
+ def lift(m):
270
+ if material.search(m.group(1).replace("_", " ")):
271
+ add(m.group(1), m.group(2))
272
+ return ""
273
+ return m.group(0)
274
+ paras = re.split(r"\n\s*\n", tagged.sub(lift, str(text or "")))
275
+ i = 0
276
+ while i < len(paras):
277
+ lines = paras[i].split("\n")
278
+ head = lines[0]
279
+ if heading.match(head) and material.search(head):
280
+ body = "\n".join(lines[1:])
281
+ # A heading alone on its paragraph labels what follows, up to the next heading.
282
+ if not body.strip():
283
+ while i + 1 < len(paras) and len(body) < passage_text and not heading.match(paras[i + 1].split("\n")[0]):
284
+ i += 1
285
+ body += ("\n\n" if body else "") + paras[i]
286
+ add(re.sub(r"[#::\[\]=]", "", head).strip(), body)
287
+ else:
288
+ label = next((m for m in (inline.match(l) for l in lines) if m and named.search(m.group(1))), None)
289
+ if label:
290
+ add(label.group(1).strip(), paras[i])
291
+ i += 1
292
+
293
+ def passages_in(sent):
294
+ body = parsed(sent) if isinstance(sent, str) else None
295
+ if not isinstance(body, dict):
296
+ return None
297
+ out = []
298
+
299
+ def add(name, text):
300
+ t = str(text or "").strip()[:passage_text]
301
+ if len(t) >= 20 and len(out) < passages_max and all(o["text"] != t for o in out):
302
+ out.append({"name": str(name or "")[:80], "text": t})
303
+
304
+ def scan(content):
305
+ if isinstance(content, str):
306
+ return labelled(content, add)
307
+ for b in items(content):
308
+ if b.get("type") == "document":
309
+ src = b.get("source") if isinstance(b.get("source"), dict) else None
310
+ add(b.get("title") or "document", text_of(src.get("data") if src and src.get("data") is not None else (src or {}).get("content")) if src else text_of(b.get("content")))
311
+ elif b.get("type") == "search_result":
312
+ add(b.get("title") or b.get("source") or "search result", text_of(b.get("content")))
313
+ elif isinstance(b.get("text"), str):
314
+ labelled(b["text"], add)
315
+
316
+ messages = items(body.get("messages"))
317
+ for m in messages:
318
+ if m.get("role") in ("system", "developer"):
319
+ scan(m.get("content"))
320
+ last = max([i for i, m in enumerate(messages) if person_said(m)] or [0])
321
+ for m in messages[last:]:
322
+ if m.get("role") == "user":
323
+ scan(m.get("content"))
324
+ scan(body.get("system"))
325
+ scan(body.get("instructions"))
326
+ if isinstance(body.get("systemInstruction"), dict):
327
+ scan(body["systemInstruction"].get("parts"))
328
+ inputs = [{"role": "user", "content": body["input"]}] if isinstance(body.get("input"), str) else items(body.get("input"))
329
+ last = max([i for i, it in enumerate(inputs) if it.get("role") == "user"] or [0])
330
+ for it in inputs[last:]:
331
+ if it.get("role") in ("user", "system", "developer"):
332
+ scan(it.get("content"))
333
+ return out or None
334
+
335
+ # The passages a retrieval call answered with: a vector store, a search index or a web search,
336
+ # read off its reply when it is JSON. Text fields only, bounded like the rest.
337
+ retrieval_host = re.compile(r"pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai", re.I)
338
+ retrieval_path = re.compile(r"/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:/|$)|/points/(?:search|query)|/rpc/match\w*", re.I)
339
+ passage_key = re.compile(r"^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$", re.I)
340
+
341
+ def is_retrieval(url):
342
+ try:
343
+ parts = urlsplit(str(url))
344
+ except ValueError:
345
+ return False
346
+ return bool(retrieval_host.search(parts.hostname or "")) or bool(retrieval_path.search(parts.path or ""))
347
+
348
+ def passages_from(raw):
349
+ out, nodes = [], [0]
350
+
351
+ def walk(v, key, depth):
352
+ nodes[0] += 1
353
+ if len(out) >= passages_max or depth > 7 or nodes[0] > 4000:
354
+ return
355
+ if isinstance(v, str):
356
+ t = v.strip()[:passage_text]
357
+ if passage_key.match(key) and len(t) >= 20 and all(o["text"] != t for o in out):
358
+ out.append({"name": key, "text": t})
359
+ elif isinstance(v, list):
360
+ for x in v:
361
+ walk(x, key, depth + 1)
362
+ elif isinstance(v, dict):
363
+ for k, x in v.items():
364
+ walk(x, str(k), depth + 1)
365
+ walk(parsed(raw) if isinstance(raw, str) else None, "", 0)
366
+ return out or None
367
+
131
368
  # A framework that folds its tools' answers into the next prompt as plain text sends no tool
132
369
  # message to find: within one turn, what a later call's prompt carries that the call before did
133
370
  # not, outside the model's own words, is that material. The first call holds the customer's
@@ -189,13 +426,14 @@ def _install():
189
426
 
190
427
  # Every other outbound call: the host and what it answered, never a byte of it. A search
191
428
  # provider over its limit for a whole run was invisible until this row existed.
192
- def dep(url, status, code=None):
429
+ # A store on this machine (a local Qdrant or Chroma) is said too when it answered with passages.
430
+ def dep(url, status, code=None, passages=None, turn=None):
193
431
  try:
194
432
  host = (urlsplit(str(url)).hostname or "").lower()
195
- if not host or host in ("localhost", "127.0.0.1", "::1"):
433
+ if not host or (host in ("localhost", "127.0.0.1", "::1") and not passages):
196
434
  return
197
- turn = turn_now()
198
- write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {})}})
435
+ turn = turn or turn_now()
436
+ write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {}), **({"passages": passages} if passages else {})}})
199
437
  except Exception:
200
438
  pass
201
439
  def is_model_call(url):
@@ -283,7 +521,7 @@ def _install():
283
521
  if isinstance(part, dict):
284
522
  usage.update(part)
285
523
  model = e.get("model") or msg.get("model") or resp.get("model") or e.get("modelVersion") or model
286
- return tokens_of(usage or None), model
524
+ return tokens_of(usage or None), model, events
287
525
 
288
526
  def asked_for(url, sent):
289
527
  body = parsed(sent) if isinstance(sent, str) else None
@@ -308,7 +546,7 @@ def _install():
308
546
 
309
547
  def meter(url, sent, status, kind, raw, turn=None):
310
548
  try:
311
- tokens, model = read_reply(kind, (raw or "")[:reply_max])
549
+ tokens, model, events = read_reply(kind, (raw or "")[:reply_max])
312
550
  parts = urlsplit(str(url))
313
551
  row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
314
552
  "status": int(status or 0), "usage": tokens is not None}
@@ -323,6 +561,12 @@ def _install():
323
561
  tools = tools_in(as_text) or material_in(as_text, turn)
324
562
  if tools:
325
563
  row["tools"] = tools
564
+ called = called_in(as_text, events)
565
+ if called:
566
+ row["called"] = called
567
+ passages = passages_in(as_text)
568
+ if passages:
569
+ row["passages"] = passages
326
570
  write({"call": row})
327
571
  except Exception:
328
572
  pass
@@ -693,21 +937,31 @@ def _install():
693
937
  self.finish()
694
938
 
695
939
  def watch(request, response, sent):
696
- if not is_model_call(request.url):
940
+ model = is_model_call(request.url)
941
+ if not model and not is_retrieval(request.url):
697
942
  dep(request.url, response.status_code)
698
943
  return response
699
944
  kind = response.headers.get("content-type", "")
700
945
  if getattr(response, "_content", None) is not None:
701
- meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"))
946
+ if model:
947
+ meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"))
948
+ else:
949
+ dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")))
702
950
  return response
703
- done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")))
951
+ turn = turn_now()
952
+ if model:
953
+ done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn)
954
+ else:
955
+ done = lambda raw: dep(request.url, response.status_code, passages=passages_from(unpacked(raw, response.headers.get("content-encoding"))), turn=turn)
704
956
  stream = response.stream
705
957
  if hasattr(stream, "__aiter__") and hasattr(stream, "aclose"):
706
958
  response.stream = AsyncKept(stream, done)
707
959
  elif hasattr(stream, "__iter__"):
708
960
  response.stream = SyncKept(stream, done)
709
- else:
961
+ elif model:
710
962
  meter(request.url, sent, response.status_code, kind, "")
963
+ else:
964
+ dep(request.url, response.status_code)
711
965
  return response
712
966
 
713
967
  for cls in (module.Client, module.AsyncClient):
@@ -769,7 +1023,8 @@ def _install():
769
1023
  raise
770
1024
  try:
771
1025
  if not is_model_call(request.url):
772
- dep(request.url, response.status_code)
1026
+ read = is_retrieval(request.url) and not k.get("stream")
1027
+ dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")) if read else None)
773
1028
  if is_model_call(request.url):
774
1029
  # A streamed reply is counted as a call whose counts were not read.
775
1030
  raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
@@ -797,6 +1052,8 @@ def _install():
797
1052
  raise
798
1053
  if is_model_call(str_or_url):
799
1054
  response._cortad = (str_or_url, text(body), turn_now())
1055
+ elif is_retrieval(str_or_url):
1056
+ response._cortad_dep = (str_or_url, turn_now())
800
1057
  else:
801
1058
  dep(str_or_url, response.status)
802
1059
  return response
@@ -806,6 +1063,10 @@ def _install():
806
1063
 
807
1064
  async def read_kept(self, *a, **k):
808
1065
  raw = await read(self, *a, **k)
1066
+ store = getattr(self, "_cortad_dep", None)
1067
+ if store:
1068
+ self._cortad_dep = None
1069
+ dep(store[0], self.status, passages=passages_from((raw or b"").decode("utf-8", "replace")), turn=store[1])
809
1070
  call = getattr(self, "_cortad", None)
810
1071
  if call:
811
1072
  self._cortad = None
@@ -813,6 +1074,10 @@ def _install():
813
1074
  return raw
814
1075
 
815
1076
  def release_kept(self, *a, **k):
1077
+ store = getattr(self, "_cortad_dep", None)
1078
+ if store:
1079
+ self._cortad_dep = None
1080
+ dep(store[0], self.status, turn=store[1])
816
1081
  call = getattr(self, "_cortad", None)
817
1082
  if call:
818
1083
  self._cortad = None
package/lib/read-text.mjs CHANGED
@@ -36,6 +36,14 @@ const named = (label, group, max = 12) => {
36
36
  // "Journeys (6):" when every one is here, "Journeys (6), 4 listed:" when not.
37
37
  const counted = (label, count, n) => `${label} (${num(count ?? n)})${has(count) && n < count ? `, ${num(n)} listed` : ""}:`;
38
38
 
39
+ // The records the app already holds, read from its own data files, that a trial may name: an agent
40
+ // that sees a trial ask about SO20260810001 can tell it is a real order, not an invented one.
41
+ const recordsLine = (records) => {
42
+ const collections = (records?.collections ?? []).filter((c) => c?.name && c.examples?.length);
43
+ if (!collections.length) return null;
44
+ return `Records the trials can name: ${collections.map((c) => `${c.name} (${c.examples.slice(0, 3).map((e) => clip(e, 40)).join(", ")})`).join(", ")}.`;
45
+ };
46
+
39
47
  // The walkthrough, in the order the person is walked through it.
40
48
  export function readLines(r) {
41
49
  const lines = ["What Cortad read:"];
@@ -43,7 +51,7 @@ export function readLines(r) {
43
51
  const examples = (r.rules.examples ?? []).slice(0, 3);
44
52
  lines.push(`${rulesHead(r.rules)}${examples.length ? ` ${examples.length} of them:` : ""}`, ...examples.map(ruleLine));
45
53
  }
46
- for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles), named("Endpoints", r.doors)]) if (line) lines.push(line);
54
+ for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles), named("Endpoints", r.doors), recordsLine(r.records)]) if (line) lines.push(line);
47
55
  const m = r.machine;
48
56
  if (m) {
49
57
  const misses = m.misses ?? [];
package/lib/replay.mjs CHANGED
@@ -106,9 +106,15 @@ const RULE_ID = /^[\w:-]{1,64}$/;
106
106
  const turnOf = (v) => (typeof v === "string" && TURN.test(v) ? { turn: v } : {});
107
107
  const rulesOf = (v) => (Array.isArray(v) ? { rules: v.filter((id) => typeof id === "string" && RULE_ID.test(id)).slice(0, 300) } : {});
108
108
  // What the app's tools answered behind a call, as the hook read them off the next prompt.
109
- const toolsOf = (v) => (Array.isArray(v) && v.length
110
- ? { tools: v.filter((t) => t && typeof t.text === "string" && t.text.trim()).slice(0, 12).map((t) => ({ name: String(t.name ?? "").slice(0, 80), text: t.text.slice(0, 3000) })) }
111
- : {});
109
+ const texts = (v) => (Array.isArray(v) ? v.filter((t) => t && typeof t.text === "string" && t.text.trim()).slice(0, 12).map((t) => ({ name: String(t.name ?? "").slice(0, 80), text: t.text.slice(0, 3000) })) : []);
110
+ const toolsOf = (v) => (texts(v).length ? { tools: texts(v) } : {});
111
+ // The tools the model asked for behind a call, names and clipped arguments, and the passages its
112
+ // prompt was handed as retrieved context, or a retrieval call answered with.
113
+ const calledOf = (v) => {
114
+ const called = Array.isArray(v) ? v.filter((c) => c && typeof c.name === "string" && c.name && typeof c.arguments === "string").slice(0, 12).map((c) => ({ name: c.name.slice(0, 80), arguments: c.arguments.slice(0, 1200) })) : [];
115
+ return called.length ? { called } : {};
116
+ };
117
+ const passagesOf = (v) => (texts(v).length ? { passages: texts(v).slice(0, 6) } : {});
112
118
  const meter = (() => {
113
119
  const rows = [];
114
120
  const deps = [];
@@ -119,7 +125,7 @@ const meter = (() => {
119
125
  rows.push({
120
126
  at: count(call.at), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), status: count(call.status),
121
127
  promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
122
- usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools),
128
+ usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools), ...calledOf(call.called), ...passagesOf(call.passages),
123
129
  });
124
130
  if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
125
131
  },
@@ -127,7 +133,7 @@ const meter = (() => {
127
133
  dep(d) {
128
134
  if (!d || typeof d.host !== "string" || !d.host) return;
129
135
  const env = typeof d.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(d.env) ? d.env : undefined;
130
- deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn) });
136
+ deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn), ...passagesOf(d.passages) });
131
137
  if (deps.length > ROWS) deps.splice(0, deps.length - ROWS);
132
138
  },
133
139
  report() {
@@ -145,7 +151,7 @@ const meter = (() => {
145
151
  }
146
152
  return {
147
153
  totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
148
- rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}) })),
154
+ rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools, called, passages }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) })),
149
155
  deps: deps.slice(-400).reverse(),
150
156
  };
151
157
  },
package/lib/text.mjs CHANGED
@@ -57,7 +57,10 @@ function scoreLine(d) {
57
57
  // The number never stands without its denominator: "93 of 100" read as broad assurance to four
58
58
  // agents whose runs had measured a quarter of the checks.
59
59
  const on = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0 ? ` on the ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured` : "";
60
- return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${on}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}.`;
60
+ // The replies that failed stand beside the number: a 100 over the replies that answered, with 62 of
61
+ // 89 error replies unsaid, read as a clean bill.
62
+ const crashed = d.replies && d.replies.crashed > 0 ? `; ${num(d.replies.crashed)} of ${plural(d.replies.total, "reply", "replies")} were failures and are not in it` : "";
63
+ return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${on}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}${crashed}.`;
61
64
  }
62
65
  // A row the run held back names a door and why, or, when no door could take it, only the reason.
63
66
  const heldBackLine = (h) => h.why === "not testable here"
package/lib/trace.cjs CHANGED
@@ -3,8 +3,10 @@
3
3
  // your AI's door, with the exact body it takes and the sign-in it carried, learned from a message you
4
4
  // sent yourself rather than guessed from code. Second, every model call your app makes: the host, the
5
5
  // model it asked for, the status and the token counts the provider sent back, so a run can say which
6
- // model answered and what it cost you. Reply text is never written down. Both go to a file only you
7
- // can read, in the command's own folder on this machine. Nothing here talks to a network.
6
+ // model answered and what it cost you, which tools it asked for (names and clipped arguments, never a
7
+ // secret) and the passages its prompt was handed as retrieved context. The words of a reply are never
8
+ // written down. Both go to a file only you can read, in the command's own folder on this machine.
9
+ // Nothing here talks to a network.
8
10
  "use strict";
9
11
  const FILE = process.env.CORTAD_TRACE_FILE;
10
12
  if (FILE) {
@@ -243,26 +245,181 @@ if (FILE) {
243
245
  // facts rest on, and without them a real ticket id read as invented. Bounded per call.
244
246
  const TOOL_TEXT = 3000, TOOLS_MAX = 12;
245
247
  const textOf = (v) => (typeof v === "string" ? v : Array.isArray(v) ? v.map((p) => (p && typeof p === "object" ? (p.text || p.content || "") : String(p || ""))).filter(Boolean).join("\n") : v && typeof v === "object" ? JSON.stringify(v) : "");
248
+ // Only what came after the person's latest message belongs to this turn: a thread the app
249
+ // resends whole carries every earlier turn's tool answers too.
250
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : []);
251
+ const since = (items, isPerson) => { let at = -1; items.forEach((m, i) => { if (isPerson(m)) at = i; }); return items.slice(at + 1); };
252
+ const personSaid = (m) => m.role === "user" && !(Array.isArray(m.content) && m.content.some((c) => c && c.type === "tool_result"));
253
+ const personAsked = (c) => c.role === "user" && !list(c.parts).some((p) => p.functionResponse);
246
254
  const toolsIn = (sent) => {
247
255
  let body; try { body = JSON.parse(sent); } catch { return undefined; }
248
256
  if (!body || typeof body !== "object") return undefined;
249
257
  const out = [];
250
258
  const names = new Map();
251
259
  const add = (name, text) => { const t = textOf(text).slice(0, TOOL_TEXT); if (t.trim() && out.length < TOOLS_MAX && !out.some((o) => o.text === t)) out.push({ name: String(name || "").slice(0, 80), text: t }); };
252
- for (const m of Array.isArray(body.messages) ? body.messages : []) {
253
- if (!m || typeof m !== "object") continue;
254
- for (const c of Array.isArray(m.tool_calls) ? m.tool_calls : []) if (c && c.id && c.function) names.set(c.id, c.function.name);
255
- if (Array.isArray(m.content)) for (const c of m.content) if (c && c.type === "tool_use" && c.id) names.set(c.id, c.name);
260
+ for (const m of list(body.messages)) {
261
+ for (const c of list(m.tool_calls)) if (c.id && c.function) names.set(c.id, c.function.name);
262
+ for (const c of list(m.content)) if (c.type === "tool_use" && c.id) names.set(c.id, c.name);
256
263
  }
257
- for (const m of Array.isArray(body.messages) ? body.messages : []) {
258
- if (!m || typeof m !== "object") continue;
264
+ for (const m of since(list(body.messages), personSaid)) {
259
265
  if (m.role === "tool" || m.role === "function") add(m.name || names.get(m.tool_call_id), m.content);
260
- if (Array.isArray(m.content)) for (const c of m.content) if (c && c.type === "tool_result") add(names.get(c.tool_use_id), c.content);
266
+ for (const c of list(m.content)) if (c.type === "tool_result") add(names.get(c.tool_use_id), c.content);
261
267
  }
262
- const items = Array.isArray(body.input) ? body.input : [];
263
- for (const it of items) if (it && it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
264
- for (const it of items) if (it && it.type === "function_call_output") add(names.get(it.call_id), it.output);
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);
268
+ const items = list(body.input);
269
+ for (const it of items) if (it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
270
+ for (const it of since(items, (x) => x.role === "user")) if (it.type === "function_call_output") add(names.get(it.call_id), it.output);
271
+ for (const c of since(list(body.contents), personAsked)) for (const p of list(c.parts)) if (p.functionResponse) add(p.functionResponse.name, p.functionResponse.response);
272
+ return out.length ? out : undefined;
273
+ };
274
+
275
+ // What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
276
+ // legacy function_call, a responses function_call item, an Anthropic or Bedrock tool use, a
277
+ // Gemini functionCall. Read off the model's reply, and off this turn's earlier calls as the
278
+ // prompt resends them. Names and arguments only: every value clipped, a secret never written.
279
+ const CALLS_MAX = 12, VALUE_MAX = 200, ARGS_MAX = 1200;
280
+ const SECRET_KEY = /(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$/;
281
+ const SECRET_VALUE = /^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)/;
282
+ const clipped = (v, depth) => {
283
+ if (typeof v === "string") return SECRET_VALUE.test(v) ? "[secret]" : v.length > VALUE_MAX ? v.slice(0, VALUE_MAX) + "…" : v;
284
+ if (Array.isArray(v)) return depth > 4 ? [] : v.slice(0, 20).map((x) => clipped(x, depth + 1));
285
+ if (!v || typeof v !== "object") return v;
286
+ const o = {};
287
+ if (depth > 4) return o;
288
+ for (const [k, x] of Object.entries(v).slice(0, 40)) o[k] = SECRET_KEY.test(k.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()) ? "[secret]" : clipped(x, depth + 1);
289
+ return o;
290
+ };
291
+ const argsText = (raw) => {
292
+ let v = raw;
293
+ if (typeof raw === "string") { try { v = raw.trim() ? JSON.parse(raw) : {}; } catch { v = raw; } }
294
+ return JSON.stringify(clipped(v === undefined || v === null ? {} : v, 0)).slice(0, ARGS_MAX);
295
+ };
296
+ const callsOf = (events) => {
297
+ const whole = new Map();
298
+ const parts = new Map();
299
+ let n = 0;
300
+ const put = (key, name, args) => { if (name) whole.set(key || `w${n++}`, { name, args }); };
301
+ const piece = (key, name, args) => {
302
+ const p = parts.get(key) || { name: "", args: "" };
303
+ if (name && !p.name) p.name = name;
304
+ if (typeof args === "string") p.args += args;
305
+ parts.set(key, p);
306
+ };
307
+ const blocks = (content) => {
308
+ for (const b of list(content)) {
309
+ if (b.type === "tool_use") put(b.id, b.name, b.input);
310
+ if (b.toolUse) put(b.toolUse.toolUseId, b.toolUse.name, b.toolUse.input);
311
+ }
312
+ };
313
+ for (const e of events) {
314
+ if (!e || typeof e !== "object") continue;
315
+ for (const c of list(e.choices)) {
316
+ const m = c.message;
317
+ if (m) {
318
+ for (const t of list(m.tool_calls)) if (t.function) put(t.id, t.function.name, t.function.arguments);
319
+ if (m.function_call) put(null, m.function_call.name, m.function_call.arguments);
320
+ }
321
+ const d = c.delta;
322
+ if (d) {
323
+ for (const t of list(d.tool_calls)) piece(`c${c.index || 0}.${t.index ?? t.id}`, t.function && t.function.name, t.function && t.function.arguments);
324
+ if (d.function_call) piece(`f${c.index || 0}`, d.function_call.name, d.function_call.arguments);
325
+ }
326
+ }
327
+ for (const it of [...list(e.output), ...list(e.item ? [e.item] : []), ...list(e.response && e.response.output)]) {
328
+ if (it.type === "function_call") put(it.call_id || it.id, it.name, it.arguments);
329
+ }
330
+ blocks(e.content);
331
+ blocks(e.message && e.message.content);
332
+ blocks(e.output && e.output.message && e.output.message.content);
333
+ if (e.type === "content_block_start" && e.content_block && e.content_block.type === "tool_use") piece(`a${e.index}`, e.content_block.name, "");
334
+ if (e.type === "content_block_delta" && e.delta && e.delta.type === "input_json_delta") piece(`a${e.index}`, "", e.delta.partial_json);
335
+ for (const c of list(e.candidates)) for (const p of list(c.content && c.content.parts)) if (p.functionCall) put(null, p.functionCall.name, p.functionCall.args);
336
+ }
337
+ return [...whole.values(), ...parts.values()].filter((p) => p.name).map((p) => ({ name: String(p.name).slice(0, 80), arguments: argsText(p.args) }));
338
+ };
339
+ // This turn's earlier calls, as the prompt resends them after the person's latest message.
340
+ const calledBefore = (body) => callsOf([
341
+ ...since(list(body.messages), personSaid).filter((m) => m.role === "assistant").map((m) => ({ choices: [{ message: m }], content: m.content })),
342
+ { output: since(list(body.input), (x) => x.role === "user") },
343
+ ...since(list(body.contents), personAsked).filter((c) => c.role === "model").map((c) => ({ candidates: [{ content: c }] })),
344
+ ]);
345
+ const calledIn = (sent, events) => {
346
+ const body = parse(sent);
347
+ const out = [];
348
+ for (const c of [...(body && typeof body === "object" ? calledBefore(body) : []), ...callsOf(events)]) {
349
+ if (out.length < CALLS_MAX && !out.some((o) => o.name === c.name && o.arguments === c.arguments)) out.push(c);
350
+ }
351
+ return out.length ? out : undefined;
352
+ };
353
+
354
+ // The passages the prompt carried as retrieved context: a block the app itself labels as
355
+ // context, documents, knowledge, sources or search results, in its system prompt or from the
356
+ // person's latest message on, and Anthropic document and search_result blocks. The app's own
357
+ // instructions are not passages: only what sits under such a label is.
358
+ const PASSAGE_TEXT = 3000, PASSAGES_MAX = 6;
359
+ const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b|检索|知识|参考资料|资料|上下文|文档|背景/i;
360
+ const HEADING = /^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$/;
361
+ // An inline label is a noun phrase that ends in the material word ("context:", "background:",
362
+ // "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
363
+ const INLINE = /^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S/;
364
+ const NAMED = new RegExp(`(?:${MATERIAL.source})\\s*$`, "i");
365
+ const TAGGED = /<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)<\/\1>/g;
366
+ const labelled = (text, add) => {
367
+ const rest = String(text || "").replace(TAGGED, (all, tag, inner) => { if (MATERIAL.test(tag.replace(/_/g, " "))) { add(tag, inner); return ""; } return all; });
368
+ const paras = rest.split(/\n\s*\n/);
369
+ for (let i = 0; i < paras.length; i++) {
370
+ const lines = paras[i].split("\n");
371
+ const head = lines[0];
372
+ if (HEADING.test(head) && MATERIAL.test(head)) {
373
+ let body = lines.slice(1).join("\n");
374
+ // A heading alone on its paragraph labels what follows, up to the next heading.
375
+ if (!body.trim()) while (i + 1 < paras.length && body.length < PASSAGE_TEXT && !HEADING.test(paras[i + 1].split("\n")[0])) body += (body ? "\n\n" : "") + paras[++i];
376
+ add(head.replace(/[#::[\]=]/g, "").trim(), body);
377
+ continue;
378
+ }
379
+ const inline = lines.map((l) => INLINE.exec(l)).find((m) => m && NAMED.test(m[1]));
380
+ if (inline) add(inline[1].trim(), paras[i]);
381
+ }
382
+ };
383
+ const passagesIn = (sent) => {
384
+ const body = parse(sent);
385
+ if (!body || typeof body !== "object") return undefined;
386
+ const out = [];
387
+ const add = (name, text) => { const t = String(text || "").trim().slice(0, PASSAGE_TEXT); if (t.length >= 20 && out.length < PASSAGES_MAX && !out.some((o) => o.text === t)) out.push({ name: String(name || "").slice(0, 80), text: t }); };
388
+ const scan = (content) => {
389
+ if (typeof content === "string") return labelled(content, add);
390
+ for (const b of list(content)) {
391
+ if (b.type === "document") add(b.title || "document", b.source ? textOf(b.source.data ?? b.source.content) : textOf(b.content));
392
+ else if (b.type === "search_result") add(b.title || b.source || "search result", textOf(b.content));
393
+ else if (typeof b.text === "string") labelled(b.text, add);
394
+ }
395
+ };
396
+ const messages = list(body.messages);
397
+ for (const m of messages) if (m.role === "system" || m.role === "developer") scan(m.content);
398
+ for (const m of messages.slice(Math.max(0, messages.findLastIndex(personSaid)))) if (m.role === "user") scan(m.content);
399
+ scan(body.system);
400
+ scan(body.instructions);
401
+ scan(body.systemInstruction && body.systemInstruction.parts);
402
+ const input = typeof body.input === "string" ? [{ role: "user", content: body.input }] : list(body.input);
403
+ for (const it of input.slice(Math.max(0, input.findLastIndex((x) => x.role === "user")))) if (it.role === "user" || it.role === "system" || it.role === "developer") scan(it.content);
404
+ return out.length ? out : undefined;
405
+ };
406
+ // The passages a retrieval call answered with: a vector store, a search index or a web search,
407
+ // read off its reply when it is JSON. Text fields only, bounded like the rest.
408
+ const RETRIEVAL_HOST = /pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai/i;
409
+ const RETRIEVAL_PATH = /\/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:\/|$)|\/points\/(?:search|query)|\/rpc\/match\w*/i;
410
+ const isRetrieval = (u) => RETRIEVAL_HOST.test(u.hostname) || RETRIEVAL_PATH.test(u.pathname);
411
+ const PASSAGE_KEY = /^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$/i;
412
+ const passagesFrom = (raw) => {
413
+ const out = [];
414
+ let nodes = 0;
415
+ const walk = (v, key, depth) => {
416
+ if (out.length >= PASSAGES_MAX || depth > 7 || ++nodes > 4000) return;
417
+ const t = typeof v === "string" ? v.trim().slice(0, PASSAGE_TEXT) : "";
418
+ if (t) { if (PASSAGE_KEY.test(key) && t.length >= 20 && !out.some((o) => o.text === t)) out.push({ name: key, text: t }); return; }
419
+ if (Array.isArray(v)) { for (const x of v) walk(x, key, depth + 1); return; }
420
+ if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) walk(x, k, depth + 1);
421
+ };
422
+ walk(parse(raw), "", 0);
266
423
  return out.length ? out : undefined;
267
424
  };
268
425
  // A framework that folds its tools' answers into the next prompt as plain text (an agent loop
@@ -445,7 +602,7 @@ if (FILE) {
445
602
  if (part && typeof part === "object") Object.assign(usage, part);
446
603
  model = e.model || (e.message && e.message.model) || (e.response && e.response.model) || e.modelVersion || model;
447
604
  }
448
- return { tokens: tokensOf(Object.keys(usage).length ? usage : null), model };
605
+ return { tokens: tokensOf(Object.keys(usage).length ? usage : null), model, events };
449
606
  };
450
607
  // The model asked for, from what was sent; Gemini and Bedrock put it in the path instead.
451
608
  const askedFor = (path, sent) => {
@@ -459,7 +616,9 @@ if (FILE) {
459
616
  const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
460
617
  const rules = rulesIn(sent);
461
618
  const tools = toolsIn(sent) ?? materialIn(sent, turn);
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 } : {}) };
619
+ const called = calledIn(sent, reply.events);
620
+ const passages = passagesIn(sent);
621
+ 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 } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) };
463
622
  fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
464
623
  } catch { /* the command is gone */ }
465
624
  };
@@ -485,9 +644,8 @@ if (FILE) {
485
644
  return name ? { env: name, host: u.hostname } : null;
486
645
  } catch { return null; }
487
646
  };
488
- const depRow = (dep, status, code) => {
489
- const turn = (als.getStore() || {}).turn;
490
- try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(turn ? { turn } : {}) } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
647
+ const depRow = (dep, status, code, passages, turn = (als.getStore() || {}).turn) => {
648
+ try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(turn ? { turn } : {}), ...(passages ? { passages } : {}) } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
491
649
  };
492
650
 
493
651
  // Outbound: the model call your app makes while it handles that request.
@@ -504,10 +662,21 @@ if (FILE) {
504
662
  // Every outbound host, named by the setting that points at it when one does, and by the
505
663
  // host alone otherwise: the search provider behind TAVILY_API_KEY has no URL setting.
506
664
  let dep = depOf(input);
507
- if (!dep) { try { const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input)); if (/^https?:$/.test(u.protocol) && !/^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname)) dep = { host: u.hostname }; } catch { /* not a URL */ } }
665
+ let retrieval = false;
666
+ try {
667
+ const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
668
+ // A store on this machine (a local Qdrant or Chroma) is still where the passages came from.
669
+ retrieval = /^https?:$/.test(u.protocol) && isRetrieval(u);
670
+ if (!dep && /^https?:$/.test(u.protocol) && (retrieval || !/^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname))) dep = { host: u.hostname };
671
+ } catch { /* not a URL */ }
508
672
  if (!dep) return realFetch.apply(this, arguments);
673
+ const turn = (als.getStore() || {}).turn;
509
674
  return realFetch.apply(this, arguments).then(
510
- (res) => { depRow(dep, res.status); return res; },
675
+ (res) => {
676
+ if (!retrieval) { depRow(dep, res.status, undefined, undefined, turn); return res; }
677
+ try { res.clone().text().then((t) => depRow(dep, res.status, undefined, passagesFrom(t), turn), () => depRow(dep, res.status, undefined, undefined, turn)); } catch { depRow(dep, res.status, undefined, undefined, turn); }
678
+ return res;
679
+ },
511
680
  (err) => { depRow(dep, 0, err && err.cause && err.cause.code); throw err; },
512
681
  );
513
682
  }
package/local.mjs CHANGED
@@ -813,11 +813,20 @@ async function restartNow() {
813
813
  if (!launched || !child) return { error: "You started this app yourself, so restart it in your own terminal. Most dev servers reload on save." };
814
814
  const port = app.port;
815
815
  await stopApp(child.pid);
816
- for (let i = 0; i < 25 && (await answers(port)); i++) await new Promise((r) => setTimeout(r, 200));
816
+ // Until the port is free, not merely silent: a listener still closing does not answer and still
817
+ // holds the bind, and the restarted app was moved to the next port while the run kept knocking on
818
+ // this one (resumeforge's verify played seven trials against nothing).
819
+ for (let i = 0; i < 75 && (await listenerOn(port)); i++) await new Promise((r) => setTimeout(r, 200));
817
820
  const up = await launch(90_000);
818
821
  if (!up.port) return { error: up.exited !== null ? `Your app exited ${up.exited} on restart.\n${up.tail}` : "Your app was restarted but did not answer within 90 seconds." };
819
- if (up.port !== port) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
820
- return { restarted: true, port };
822
+ if (up.port !== port) {
823
+ // It moved anyway: the world follows the app, never the other way round.
824
+ app = up;
825
+ const told = await announce().catch(() => null);
826
+ if (!told?.ok) return { error: `Your app came back on port ${up.port}, not ${port}. Run the command again to reconnect.` };
827
+ say(`your app came back on port ${up.port}; the run follows it there`);
828
+ }
829
+ return { restarted: true, port: up.port };
821
830
  }
822
831
  // TCP ports your app is listening on: every process descended from the one this program started.
823
832
  // By descent, not by process group: nodemon, pm2 and concurrently put the real server in a group of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.1",
3
+ "version": "0.3.1-rc.3",
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"