cortad 0.3.1-rc.9 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
package/lib/proxy.mjs ADDED
@@ -0,0 +1,184 @@
1
+ // For an app the hook cannot load into (Go, Ruby, Java, Rust, PHP, Deno, or a Node or Python app
2
+ // that would not take it): what the hook sees from inside the process is seen from outside it, on
3
+ // this machine. Two servers on 127.0.0.1. The front sits before the app's own port: the agent's
4
+ // request and every conversation of a run go through it, and it writes the exchange rows the hook
5
+ // writes. The model proxy is where the app's model settings point (OPENAI_BASE_URL and the others):
6
+ // it hands each call to the provider the app's own settings name and writes the call rows the hook
7
+ // writes. Both write into the same trace file, so the command reads them as it reads the hook's
8
+ // (lib/replay.mjs). The line of code that made a call is not known here. Header values are never
9
+ // written, and nothing goes anywhere the app would not have sent it.
10
+ import { appendFileSync } from "node:fs";
11
+ import { createServer, request } from "node:http";
12
+ import { connect } from "node:net";
13
+ import { once } from "node:events";
14
+ import wire from "./wire.cjs";
15
+ import tied from "./tied.cjs";
16
+
17
+ const { MAX, REPLY_MAX, MODEL_HOST, scrubbed, turnOf, decoded, cookieNames, makeRecorder } = wire;
18
+
19
+ // Where each SDK sends its calls when the app's settings name nowhere else.
20
+ const DEFAULTS = { OPENAI_BASE_URL: "https://api.openai.com/v1", ANTHROPIC_BASE_URL: "https://api.anthropic.com", GOOGLE_GEMINI_BASE_URL: "https://generativelanguage.googleapis.com" };
21
+ // Another setting of the app's that addresses a model: a URL on a model host, or a base URL named for one.
22
+ const MODEL_SETTING = /OPENAI|ANTHROPIC|GEMINI|LLM|OLLAMA|LITELLM|VLLM/i;
23
+ const ADDRESS_SETTING = /BASE|URL|ENDPOINT|HOST/i;
24
+ const SECRET_SETTING = /KEY|TOKEN|SECRET|PASSWORD/i;
25
+ // Said by one connection, never passed on. A provider's reply is passed on decoded, so its encoding goes too.
26
+ const HOP = /^(?:host|connection|keep-alive|transfer-encoding|upgrade|te|trailer|proxy-.*|content-length|accept-encoding)$/i;
27
+ const REPLY_HOP = /^(?:connection|keep-alive|transfer-encoding|content-length|content-encoding)$/i;
28
+
29
+ // The person's words in a request, a form's fields as much as JSON's.
30
+ const asked = (raw) => (/^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/.test(raw) ? [...new URLSearchParams(raw).values()].flatMap(tied.sentencesIn) : tied.sentencesIn(raw));
31
+ // A request whose reply said something of its own, to words the person sent, with no model call begun
32
+ // this long after it: an app that asks its model after the page answers (a job Rails or Celery runs)
33
+ // does so well within it.
34
+ // ponytail: a worker slower than this gets a request said unseen; the wait per app if one is seen.
35
+ const UNSEEN_MS = 3000;
36
+
37
+ const listen = async (server) => { server.listen(0, "127.0.0.1"); await once(server, "listening"); return server.address().port; };
38
+
39
+ // `file`: the trace file the command reads. `target()`: where the app answers now, { host, port };
40
+ // it moves when the app is started again on another port. `onSeen`: the first model call came through.
41
+ // `onUnseen(door)`: a request the app answered in words with no model call begun, said once, as "POST /chat".
42
+ export async function openProxy({ file, rulesFile, target, onSeen = () => {}, onUnseen = () => {} }) {
43
+ const row = (value) => { try { appendFileSync(file, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
44
+ const rec = makeRecorder({ row, rulesFile });
45
+ // The requests in flight through the front that a model call can be made for.
46
+ const open = new Set();
47
+ // Model calls begun: a call is counted as it arrives, before the app's reply that it feeds can end.
48
+ let calls = 0, unseen = false;
49
+ const unseenIf = (method, path, sent, reply) => {
50
+ if (calls || unseen || !(reply.status >= 200 && reply.status < 300)) return;
51
+ const words = asked(sent);
52
+ if (!words.length || !tied.sentencesIn(reply.text).some((w) => !words.includes(w))) return;
53
+ setTimeout(() => { if (!calls && !unseen) { unseen = true; onUnseen(`${method} ${path.split("?")[0]}`); } }, UNSEEN_MS).unref();
54
+ };
55
+
56
+ const front = createServer((req, res) => {
57
+ const { "x-cortad-turn": _turn, ...headers } = req.headers;
58
+ const ctx = /^(?:HEAD|OPTIONS)$/.test(req.method) ? null : rec.requestCtx(req.method, req.url || "/", headers, turnOf(req.headers));
59
+ if (ctx && req.method !== "GET") open.add(ctx);
60
+ const finish = (reply) => {
61
+ if (!ctx || ctx.ended) return;
62
+ open.delete(ctx);
63
+ rec.ended(ctx, reply);
64
+ if (req.method !== "GET") unseenIf(req.method, ctx.path, Buffer.concat(ctx.chunks).toString("utf8"), reply);
65
+ };
66
+ const { host, port } = target();
67
+ // The Host the caller used goes on unchanged: an app that writes its own address from it (a Rails
68
+ // redirect to sign in) then sends the caller back here, where the request is seen.
69
+ const up = request({ host, port, method: req.method, path: req.url, headers });
70
+ if (ctx && req.method !== "GET") req.on("data", (c) => { if (ctx.size < MAX) { ctx.chunks.push(c); ctx.size += c.length; } });
71
+ req.pipe(up);
72
+ res.on("close", () => up.destroy());
73
+ // Nothing answered: the caller sees the connection close, as it would at the app's own port.
74
+ up.on("error", () => { finish({ status: 0, type: "", text: "", cookies: [], cut: true }); res.destroy(); });
75
+ up.on("response", (r) => {
76
+ const got = [];
77
+ let size = 0, writes = 0;
78
+ r.on("data", (c) => { writes += 1; if (size < MAX) { got.push(c); size += c.length; } });
79
+ const text = () => decoded(Buffer.concat(got), r.headers["content-encoding"]);
80
+ r.on("end", () => { if (ctx) rec.tied.replied(ctx, r.statusCode, text); });
81
+ r.on("error", () => res.destroy());
82
+ const done = () => finish({ status: r.statusCode, type: String(r.headers["content-type"] || ""), writes: r.headers["content-length"] ? 1 : writes, text: text(), cookies: cookieNames(r.headers["set-cookie"]), cut: !res.writableFinished });
83
+ res.once("finish", done);
84
+ res.once("close", done);
85
+ res.writeHead(r.statusCode, r.statusMessage, r.rawHeaders);
86
+ r.pipe(res);
87
+ });
88
+ });
89
+ // A socket the page opens (live reload, a chat over a WebSocket) is passed through as it is, unrecorded.
90
+ front.on("upgrade", (req, socket, head) => {
91
+ const { host, port } = target();
92
+ const up = connect({ host, port }, () => {
93
+ const lines = [];
94
+ for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
95
+ up.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${lines.join("\r\n")}\r\n\r\n`);
96
+ if (head?.length) up.write(head);
97
+ socket.pipe(up).pipe(socket);
98
+ });
99
+ const end = () => { socket.destroy(); up.destroy(); };
100
+ up.on("error", end);
101
+ socket.on("error", end);
102
+ });
103
+
104
+ // Each upstream the app's settings name, by its place: /u/<n>/<the SDK's own path>.
105
+ const slots = [];
106
+ const relay = async (req, res) => {
107
+ const m = /^\/u\/(\d+)([/?][^]*)?$/.exec(req.url || "");
108
+ const base = m && slots[Number(m[1])];
109
+ if (!base) { res.writeHead(404, { "content-type": "application/json" }); res.end('{"error":{"message":"cortad: no model setting points here"}}'); return; }
110
+ // A listing of models answers nobody; a call that sends a prompt does.
111
+ const metered = !/^(?:GET|HEAD)$/.test(req.method);
112
+ if (metered && ++calls === 1) onSeen();
113
+ const parts = [];
114
+ for await (const c of req) parts.push(c);
115
+ const body = Buffer.concat(parts);
116
+ // Read as chat when the AI SDK sent it in its own format; the provider still gets `body` as sent.
117
+ const sent = wire.asChat(body.toString("utf8"), wire.GATEWAY_MODEL.map((h) => req.headers[h]).find(Boolean));
118
+ const url = new URL(base.replace(/\/+$/, "") + (m[2] ?? ""));
119
+ // ponytail: a call is pinned by the person's words, else to the one request open. Two at once that
120
+ // open with the same or short words ("refund?") leave both calls unpinned and their exchanges
121
+ // unwritten, and a chat sent as a GET (EventSource) is never pinned; never pinned wrong. The
122
+ // request's own context, as the hook has it, is what tells them apart.
123
+ const ctx = metered ? rec.note(undefined, sent, open.size === 1 ? [...open][0] : undefined) : undefined;
124
+ const t0 = Date.now();
125
+ const meter = (status, type, text) => { if (metered) rec.meter({ host: url.host, path: url.pathname, sent, status, type, body: text, ctx, t0 }); };
126
+ // The app stopped reading: the provider stops too, so the app's tokens stop with it.
127
+ const gone = new AbortController();
128
+ res.on("close", () => { if (!res.writableFinished) gone.abort(); });
129
+ let got;
130
+ try {
131
+ got = await fetch(url, { method: req.method, headers: Object.fromEntries(Object.entries(req.headers).filter(([k]) => !HOP.test(k))), body: metered && body.length ? body : undefined, redirect: "manual", signal: gone.signal });
132
+ } catch (e) {
133
+ meter(0, "", "");
134
+ if (gone.signal.aborted) return;
135
+ res.writeHead(502, { "content-type": "application/json" });
136
+ res.end(JSON.stringify({ error: { message: `cortad could not reach ${url.host}: ${e.cause?.code ?? e.message}` } }));
137
+ return;
138
+ }
139
+ if (ctx) ctx.heard = true;
140
+ const headers = {};
141
+ for (const [k, v] of got.headers) if (!REPLY_HOP.test(k) && k !== "set-cookie") headers[k] = v;
142
+ const cookies = got.headers.getSetCookie();
143
+ res.writeHead(got.status, cookies.length ? { ...headers, "set-cookie": cookies } : headers);
144
+ const kept = [];
145
+ let size = 0;
146
+ try {
147
+ for await (const c of got.body ?? []) {
148
+ res.write(c);
149
+ if (size < REPLY_MAX) { kept.push(Buffer.from(c)); size += c.length; }
150
+ }
151
+ } catch { /* broken off: what arrived stands */ }
152
+ res.end();
153
+ meter(got.status, got.headers.get("content-type") ?? "", Buffer.concat(kept).toString("utf8"));
154
+ };
155
+ // An app that hangs up while it sends a call ends that call, never this command.
156
+ const model = createServer((req, res) => { relay(req, res).catch(() => res.destroy()); });
157
+
158
+ const frontPort = await listen(front);
159
+ const modelPort = await listen(model);
160
+ row({ hello: "proxy", pid: process.pid });
161
+ const via = (upstream) => {
162
+ if (!slots.includes(upstream)) slots.push(upstream);
163
+ return `http://127.0.0.1:${modelPort}/u/${slots.indexOf(upstream)}`;
164
+ };
165
+ return {
166
+ port: frontPort,
167
+ modelPort,
168
+ // The app's model settings, pointed here: each SDK's own, and any other of the app's that addresses a model.
169
+ env(base) {
170
+ const out = {};
171
+ for (const [name, fallback] of Object.entries(DEFAULTS)) out[name] = via(base[name] || fallback);
172
+ for (const [name, value] of Object.entries(base)) {
173
+ if (name in out || SECRET_SETTING.test(name) || typeof value !== "string") continue;
174
+ let u;
175
+ try { u = new URL(value); } catch { continue; }
176
+ if (/^https?:$/.test(u.protocol) && (MODEL_HOST.test(u.hostname) || (MODEL_SETTING.test(name) && ADDRESS_SETTING.test(name)))) out[name] = via(value);
177
+ }
178
+ return out;
179
+ },
180
+ // Whether a model call has begun yet.
181
+ seen: () => calls > 0,
182
+ close: () => { for (const s of [front, model]) { s.closeAllConnections(); s.close(); } },
183
+ };
184
+ }
@@ -0,0 +1,218 @@
1
+ # Where in the app's own code each part of a reply was written, so a block the reply carried is placed
2
+ # at the line that sent it, not at the model call that ran beside it. A framework's send is never that
3
+ # line: for a streamed reply it is where the generator the framework iterates stopped to hand the chunk
4
+ # over, or, when that generator only passes on what the one it loops over gave it, where that one
5
+ # stopped. One entry per line and event name, with the first bytes written there.
6
+ import dis
7
+ import functools
8
+ import gc
9
+ import re
10
+ import sys
11
+ import types
12
+
13
+ SITES_MAX, TEXT_MAX = 16, 300
14
+ # ponytail: a reply's first thousand writes are traced; a leak later in a longer stream goes unplaced.
15
+ WRITES_MAX = 1000
16
+ # A framework puts a few frames between its send and the loop that iterates the reply, and a few
17
+ # generators between that loop and the app's own; never dozens.
18
+ FRAMES_MAX, DEPTH_MAX, WIDTH_MAX, NODES_MAX = 24, 8, 8, 256
19
+ GENERATORS = (types.GeneratorType, types.AsyncGeneratorType)
20
+ # What an object that holds the reply's iterator is made of: a response's attributes, a partial.
21
+ HOLDERS = (dict, list, tuple, functools.partial)
22
+ SSE_EVENT = re.compile(rb"^event:[ \t]*([\w.:-]{1,40})", re.M)
23
+ JSON_EVENT = re.compile(rb'"(?:type|event)"\s*:\s*"([\w.:-]{1,40})"')
24
+
25
+
26
+ def _event(chunk):
27
+ head = chunk[:400]
28
+ m = SSE_EVENT.search(head) or JSON_EVENT.search(head)
29
+ return m.group(1).decode("ascii") if m else None
30
+
31
+
32
+ def _frame(gen):
33
+ return getattr(gen, "ag_frame", None) or getattr(gen, "gi_frame", None)
34
+
35
+
36
+ def _live(o):
37
+ return isinstance(o, GENERATORS) and _frame(o) is not None
38
+
39
+
40
+ def _iterator(o):
41
+ return hasattr(type(o), "__next__") or hasattr(type(o), "__anext__")
42
+
43
+
44
+ # What a suspended generator holds, its locals and then its stack: the generator's own referents from
45
+ # Python 3.11, its frame's before that.
46
+ def _referents(gen):
47
+ return gc.get_referents(gen) if sys.version_info >= (3, 11) else gc.get_referents(_frame(gen))
48
+
49
+
50
+ # The suspended generators a generator holds in its locals and on its stack.
51
+ def _under(gen):
52
+ try:
53
+ return [g for g in _referents(gen) if _live(g)]
54
+ except Exception:
55
+ return []
56
+
57
+
58
+ def _same(v, chunk):
59
+ if isinstance(v, (bytes, bytearray)):
60
+ return v == chunk
61
+ return isinstance(v, str) and len(v) <= len(chunk) and v.encode("utf-8", "replace") == chunk
62
+
63
+
64
+ def _holds(frame, chunk):
65
+ return any(_same(v, chunk) for v in frame.f_locals.values())
66
+
67
+
68
+ # A send wrapper: the frame was called with the message, where the one that wrote it built it.
69
+ def _handed(frame, message):
70
+ code = frame.f_code
71
+ return any(frame.f_locals.get(name) is message for name in code.co_varnames[:code.co_argcount + code.co_kwonlyargcount])
72
+
73
+
74
+ # The local the last loop before `lasti` in `code` gives each item to: the first store after its header.
75
+ @functools.lru_cache(maxsize=512)
76
+ def _loop_target(code, lasti):
77
+ target, header = None, False
78
+ for ins in dis.get_instructions(code):
79
+ if ins.offset >= lasti:
80
+ break
81
+ if ins.opname in ("FOR_ITER", "GET_ANEXT"):
82
+ target, header = None, True
83
+ elif header and ins.opname.startswith("STORE_FAST"):
84
+ target, header = ins.argval[0] if isinstance(ins.argval, tuple) else ins.argval, False
85
+ return target
86
+
87
+
88
+ # The generator that handed `gen` the chunk it just yielded: the one it delegates to with `yield from`,
89
+ # else, when it yielded the item of the innermost loop it stopped in as the item came, that loop's
90
+ # iterator, which the loop keeps on the stack, the innermost last. A chunk it made from the item is its own.
91
+ def _fed_by(gen, chunk):
92
+ inner, frame = getattr(gen, "gi_yieldfrom", None), _frame(gen)
93
+ if inner is None and _same(frame.f_locals.get(_loop_target(frame.f_code, frame.f_lasti)), chunk):
94
+ mine = {id(v) for v in frame.f_locals.values()}
95
+ inner = next((o for o in reversed(_referents(gen)) if id(o) not in mine and _iterator(o)), None)
96
+ return inner if _live(inner) else None
97
+
98
+
99
+ def _followed(o):
100
+ return isinstance(o, HOLDERS) or (hasattr(o, "__dict__") and type(o).__module__ != "builtins" and not isinstance(o, (type, types.ModuleType)))
101
+
102
+
103
+ # The live generators reachable from `roots` through the objects that hold them, nearest first.
104
+ def _generators(roots):
105
+ seen, level, found = set(), list(roots), []
106
+ while level and len(seen) < NODES_MAX and not found:
107
+ deeper = []
108
+ for o in level:
109
+ if id(o) in seen:
110
+ continue
111
+ seen.add(id(o))
112
+ if isinstance(o, GENERATORS):
113
+ if _frame(o) is not None:
114
+ found.append(o)
115
+ elif _followed(o):
116
+ deeper.extend(gc.get_referents(o))
117
+ level = deeper
118
+ return found
119
+
120
+
121
+ class Sites:
122
+ # `own_at(frame)` is "file:line" for a frame of the app's own code, else None.
123
+ def __init__(self, own_at):
124
+ self.own_at = own_at
125
+
126
+ # The app's line that gave `chunk` out of the generator `gen` the framework iterates: the first of
127
+ # the app's generators under it, then down the generators that only passed the chunk on.
128
+ def _made_by(self, gen, chunk):
129
+ level = [gen]
130
+ for _ in range(DEPTH_MAX):
131
+ own = next((g for g in level if _frame(g) and self.own_at(_frame(g))), None)
132
+ if own is not None:
133
+ return self._handed_down(own, chunk)
134
+ level = [u for g in level for u in _under(g)][:WIDTH_MAX]
135
+ if not level:
136
+ return None
137
+ return None
138
+
139
+ # Down the generators that only handed the chunk on, to the one that made it where it stopped.
140
+ def _handed_down(self, gen, chunk):
141
+ at = self.own_at(_frame(gen))
142
+ for _ in range(DEPTH_MAX):
143
+ gen = _fed_by(gen, chunk)
144
+ if gen is None:
145
+ break
146
+ at = self.own_at(_frame(gen)) or at
147
+ return at
148
+
149
+ # An ASGI body message on its way out, from `frame`, the frame that called send, up to `stop`.
150
+ def sent(self, req, message, frame, stop):
151
+ chunk = bytes(message.get("body") or b"")
152
+ if self._room(req, chunk):
153
+ self._note(req, chunk, self._sender(req, message, chunk, frame, stop))
154
+
155
+ # A chunk a response is sending out of `iterator`, the reply it was made with, as it sends it.
156
+ def made(self, req, iterator, chunk):
157
+ chunk = bytes(chunk)
158
+ if self._room(req, chunk):
159
+ self._note(req, chunk, next(filter(None, (self._made_by(g, chunk) for g in _generators([iterator]))), None))
160
+
161
+ # The app's line that wrote the message: past the send wrappers that were handed it, the frame that
162
+ # sent it, when that is the app's, or an app frame above that holds the bytes. Else the reply's
163
+ # iterator, held by the framework frame that iterates it, which holds the chunk it was given, looked
164
+ # for once. An app frame above the sender that holds neither, such as a middleware's
165
+ # `await self.app(scope, receive, send)`, only called into what wrote it.
166
+ def _sender(self, req, message, chunk, frame, stop):
167
+ gen = req.get("iterated")
168
+ if gen:
169
+ return self._made_by(gen, chunk)
170
+ sender = True
171
+ for _ in range(FRAMES_MAX):
172
+ if frame is None or frame.f_code is stop:
173
+ break
174
+ if not _handed(frame, message):
175
+ at, held = self.own_at(frame), _holds(frame, chunk)
176
+ if at and (sender or held):
177
+ return at
178
+ sender = False
179
+ at = self._first_made(req, list(frame.f_locals.values()), chunk) if gen is None and held else None
180
+ if at:
181
+ return at
182
+ frame = frame.f_back
183
+ if gen is None:
184
+ req["iterated"] = False
185
+ return None
186
+
187
+ # The line the app made `chunk` at, in the first generator reachable from a frame's `values` that
188
+ # made it, which is kept as the reply's iterator.
189
+ def _first_made(self, req, values, chunk):
190
+ for g in _generators(values):
191
+ at = self._made_by(g, chunk)
192
+ if at:
193
+ req["iterated"] = g
194
+ return at
195
+ return None
196
+
197
+ # A WSGI reply's next item, out of the iterable the app returned.
198
+ def iterated(self, req, iterable, chunk):
199
+ chunk = bytes(chunk)
200
+ if not self._room(req, chunk):
201
+ return
202
+ gen = req.get("iterated")
203
+ if gen is None:
204
+ gen = req["iterated"] = next(iter(_generators([iterable])), False)
205
+ self._note(req, chunk, self._made_by(gen, chunk) if gen else None)
206
+
207
+ def _room(self, req, chunk):
208
+ req["traced"] = req.get("traced", 0) + 1
209
+ return bool(chunk) and req["traced"] <= WRITES_MAX and len(req.setdefault("sites", [])) < SITES_MAX
210
+
211
+ def _note(self, req, chunk, at):
212
+ if not at:
213
+ return
214
+ event = _event(chunk)
215
+ sites = req["sites"]
216
+ if any(s["at"] == at and s.get("event") == event for s in sites):
217
+ return
218
+ sites.append({"at": at, **({"event": event} if event else {}), "text": chunk[:TEXT_MAX].decode("utf-8", "replace")})