@senpi/runtime 3.0.187 → 3.0.190

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 (103) hide show
  1. package/dist/cli/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +6 -0
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/senpi-commands.d.ts.map +1 -1
  5. package/dist/cli/senpi-commands.js +4 -2
  6. package/dist/cli/senpi-commands.js.map +1 -1
  7. package/dist/deploy/box-cap.d.ts +7 -0
  8. package/dist/deploy/box-cap.d.ts.map +1 -0
  9. package/dist/deploy/box-cap.js +13 -0
  10. package/dist/deploy/box-cap.js.map +1 -0
  11. package/dist/deploy/funding.d.ts +13 -1
  12. package/dist/deploy/funding.d.ts.map +1 -1
  13. package/dist/deploy/funding.js +13 -4
  14. package/dist/deploy/funding.js.map +1 -1
  15. package/dist/deploy/job.d.ts +2 -0
  16. package/dist/deploy/job.d.ts.map +1 -1
  17. package/dist/deploy/job.js +2 -2
  18. package/dist/deploy/job.js.map +1 -1
  19. package/dist/deploy/messages.d.ts +14 -0
  20. package/dist/deploy/messages.d.ts.map +1 -1
  21. package/dist/deploy/messages.js +59 -25
  22. package/dist/deploy/messages.js.map +1 -1
  23. package/dist/deploy/orchestrator.d.ts +23 -1
  24. package/dist/deploy/orchestrator.d.ts.map +1 -1
  25. package/dist/deploy/orchestrator.js +87 -30
  26. package/dist/deploy/orchestrator.js.map +1 -1
  27. package/dist/deploy/package.d.ts +14 -1
  28. package/dist/deploy/package.d.ts.map +1 -1
  29. package/dist/deploy/package.js +37 -2
  30. package/dist/deploy/package.js.map +1 -1
  31. package/dist/deploy/register.d.ts +2 -0
  32. package/dist/deploy/register.d.ts.map +1 -1
  33. package/dist/deploy/register.js +48 -32
  34. package/dist/deploy/register.js.map +1 -1
  35. package/dist/index.d.ts +0 -4
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +43 -12
  38. package/dist/index.js.map +1 -1
  39. package/dist/runtime/boot-skip.d.ts +18 -0
  40. package/dist/runtime/boot-skip.d.ts.map +1 -1
  41. package/dist/runtime/boot-skip.js +25 -0
  42. package/dist/runtime/boot-skip.js.map +1 -1
  43. package/dist/runtime/lifecycle-events.d.ts +2 -2
  44. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  45. package/dist/runtime/lifecycle-events.js.map +1 -1
  46. package/dist/runtime/lifecycle-reaper.d.ts +11 -6
  47. package/dist/runtime/lifecycle-reaper.d.ts.map +1 -1
  48. package/dist/runtime/lifecycle-reaper.js +25 -15
  49. package/dist/runtime/lifecycle-reaper.js.map +1 -1
  50. package/dist/runtime/notifications.d.ts +10 -0
  51. package/dist/runtime/notifications.d.ts.map +1 -1
  52. package/dist/runtime/notifications.js +17 -6
  53. package/dist/runtime/notifications.js.map +1 -1
  54. package/dist/runtime/runtime.d.ts.map +1 -1
  55. package/dist/runtime/runtime.js +4 -1
  56. package/dist/runtime/runtime.js.map +1 -1
  57. package/dist/scanners/external/scaffold/preflight.py +492 -12
  58. package/dist/senpi/__tests__/helpers/fake-paper-mcp.d.ts +44 -0
  59. package/dist/senpi/__tests__/helpers/fake-paper-mcp.d.ts.map +1 -0
  60. package/dist/senpi/__tests__/helpers/fake-paper-mcp.js +129 -0
  61. package/dist/senpi/__tests__/helpers/fake-paper-mcp.js.map +1 -0
  62. package/dist/senpi/client.d.ts +35 -1
  63. package/dist/senpi/client.d.ts.map +1 -1
  64. package/dist/senpi/client.js +62 -8
  65. package/dist/senpi/client.js.map +1 -1
  66. package/dist/senpi/paper.d.ts +28 -0
  67. package/dist/senpi/paper.d.ts.map +1 -0
  68. package/dist/senpi/paper.js +35 -0
  69. package/dist/senpi/paper.js.map +1 -0
  70. package/dist/senpi/strategy-status.d.ts.map +1 -1
  71. package/dist/senpi/strategy-status.js +2 -0
  72. package/dist/senpi/strategy-status.js.map +1 -1
  73. package/dist/senpi/types.d.ts +8 -1
  74. package/dist/senpi/types.d.ts.map +1 -1
  75. package/dist/telemetry/box-memory.d.ts +29 -0
  76. package/dist/telemetry/box-memory.d.ts.map +1 -0
  77. package/dist/telemetry/box-memory.js +113 -0
  78. package/dist/telemetry/box-memory.js.map +1 -0
  79. package/dist/telemetry/metrics.d.ts +21 -0
  80. package/dist/telemetry/metrics.d.ts.map +1 -1
  81. package/dist/telemetry/metrics.js +82 -0
  82. package/dist/telemetry/metrics.js.map +1 -1
  83. package/dist/telemetry/spans/span-hooks.d.ts.map +1 -1
  84. package/dist/telemetry/spans/span-hooks.js +4 -1
  85. package/dist/telemetry/spans/span-hooks.js.map +1 -1
  86. package/dist/telemetry/trace-identity.d.ts.map +1 -1
  87. package/dist/telemetry/trace-identity.js +2 -0
  88. package/dist/telemetry/trace-identity.js.map +1 -1
  89. package/dist/universe/package-universe.js +1 -1
  90. package/dist/universe/package-universe.js.map +1 -1
  91. package/dist/utils/event-catalog.d.ts +7 -3
  92. package/dist/utils/event-catalog.d.ts.map +1 -1
  93. package/dist/utils/event-catalog.js.map +1 -1
  94. package/dist/validate/codes.d.ts +2 -0
  95. package/dist/validate/codes.d.ts.map +1 -1
  96. package/dist/validate/codes.js +6 -0
  97. package/dist/validate/codes.js.map +1 -1
  98. package/dist/validate/live-stage.d.ts +12 -0
  99. package/dist/validate/live-stage.d.ts.map +1 -1
  100. package/dist/validate/live-stage.js +16 -0
  101. package/dist/validate/live-stage.js.map +1 -1
  102. package/openclaw.plugin.json +1 -1
  103. package/package.json +1 -1
@@ -31,14 +31,18 @@ Author stdout is captured as evidence and never parsed. Redirecting `sys.stdout`
31
31
  cover `print()` — `os.write(1, ...)`, a subprocess, or a C extension write to the same descriptor.
32
32
  """
33
33
 
34
+ import ast
35
+ import dis
34
36
  import io
37
+ import itertools
35
38
  import json
36
39
  import math
37
40
  import os
38
41
  import sys
42
+ import threading
39
43
  import time
40
44
  import traceback
41
- from typing import Any, Dict, List, Optional
45
+ from typing import Any, Dict, List, Optional, Tuple
42
46
 
43
47
  from . import http_post
44
48
  from .signal_envelope import candidate_to_signal
@@ -57,6 +61,390 @@ def _bounded(text: str, limit: int = _STREAM_LIMIT) -> str:
57
61
  return "{}\n… truncated {} characters …\n{}".format(text[:half], dropped, text[-half:])
58
62
 
59
63
 
64
+ #: Longest key name echoed into a record. Key names come from the response.
65
+ _KEY_LIMIT = 64
66
+
67
+ #: Where an expression ends, (end_lineno, end_col_offset). The compiler can move a wrapped method
68
+ #: call's start onto the `.get` line, but never its end, so the AST and `co_positions()` agree on it,
69
+ #: and it is distinct for every call and subscript.
70
+ Key = Tuple[int, int]
71
+
72
+ _FILES: Dict[str, "_FileReads"] = {}
73
+ _POSITIONS: Dict[Any, List[Any]] = {}
74
+ _RESUMES: Dict[Any, int] = {}
75
+ _SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
76
+ _GENERATOR = 0x20 | 0x80 | 0x200 # CO_GENERATOR | CO_COROUTINE | CO_ASYNC_GENERATOR
77
+ #: Literal defaults that stand in for a value rather than being one.
78
+ _STAND_INS = (None, 0, 0.0, "", "0", "0.0")
79
+
80
+
81
+ def _key(node: ast.AST) -> Key:
82
+ return (node.end_lineno, node.end_col_offset) # type: ignore[attr-defined]
83
+
84
+
85
+ def _keyed(node: ast.AST) -> bool:
86
+ return getattr(node, "end_col_offset", None) is not None
87
+
88
+
89
+ def _is_get(node: ast.AST) -> bool:
90
+ return isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "get"
91
+
92
+
93
+ def _is_or(node: ast.AST) -> bool:
94
+ return isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or)
95
+
96
+
97
+ def _assigned(node: ast.AST) -> Optional[Tuple[str, ast.AST]]:
98
+ """(name, value) for `x = ...`, `x: T = ...` and `(x := ...)`."""
99
+ if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
100
+ return node.targets[0].id, node.value
101
+ if isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and isinstance(node.target, ast.Name) and node.value:
102
+ return node.target.id, node.value
103
+ return None
104
+
105
+
106
+ def _own_nodes(scope: ast.AST) -> Any:
107
+ """The nodes under `scope`, not those of the functions and classes inside it."""
108
+ stack = list(ast.iter_child_nodes(scope))
109
+ while stack:
110
+ node = stack.pop()
111
+ yield node
112
+ if not isinstance(node, _SCOPES):
113
+ stack.extend(ast.iter_child_nodes(node))
114
+
115
+
116
+ def _within(stmts: List[ast.stmt]) -> Any:
117
+ for stmt in stmts:
118
+ yield stmt
119
+ yield from _own_nodes(stmt)
120
+
121
+
122
+ def _reads_name(node: ast.AST, name: str) -> bool:
123
+ return any(isinstance(n, ast.Name) and n.id == name for n in ast.walk(node))
124
+
125
+
126
+ def _truth_test(node: ast.AST, parents: Dict[Any, Any]) -> bool:
127
+ """Whether a read's result is only tested: `if row.get(k):`, `not row.get(k)`,
128
+ `row.get(k) is None`. Such a read checks an optional key; it does not take a value."""
129
+ parent = parents.get(node)
130
+ while _is_or(parent) or (isinstance(parent, ast.BoolOp) and isinstance(parent.op, ast.And)):
131
+ node, parent = parent, parents.get(parent)
132
+ if isinstance(parent, (ast.If, ast.While, ast.IfExp, ast.Assert)) and parent.test is node:
133
+ return True
134
+ if isinstance(parent, ast.UnaryOp) and isinstance(parent.op, ast.Not):
135
+ return True
136
+ return (isinstance(parent, ast.Compare) and len(parent.ops) == 1
137
+ and isinstance(parent.ops[0], (ast.Is, ast.IsNot))
138
+ and any(isinstance(c, ast.Constant) and c.value is None for c in [parent.left, *parent.comparators]))
139
+
140
+
141
+ class _FileReads:
142
+ """How each `.get(...)` and subscript in one scanner file takes part in fallback chains.
143
+
144
+ `roots` maps each read to its chain. One chain: `a.get(k, b.get(k2, 0))`, `a.get(k) or b[k2]`,
145
+ `a.get(k) if ... else b.get(k2)`, helper calls as alternatives (`_f(a, k) or _f(b, k)`), and an
146
+ assignment with its fallback: `x = c.get(k)` then `if x is None: x = ...`, `x = x or ...`, the
147
+ same name in an `except`, or `x or ...` later. A miss in a chain is answered by a hit anywhere in it.
148
+
149
+ `receivers` are reads on the way to an alternative (`i["context"]` in `i["context"]["volume"]`,
150
+ a ternary's test). They join the chain; finding them does not answer it.
151
+
152
+ `solo` are `.get`s alone in their chain. Their caller's expression may be the chain.
153
+
154
+ `defaults` says, per `.get`, how a miss is judged from its default as written: "keep" (absent, or
155
+ a literal stand-in such as 0, None, "" or "0"), "drop" (a real value, a computed fallback such as
156
+ `a + b`, a bool, or a read only tested), or "value" (a name or another read: judged by its value).
157
+ """
158
+
159
+ def __init__(self, filename: str) -> None:
160
+ self.roots: Dict[Key, Any] = {}
161
+ self.receivers: set = set()
162
+ self.solo: set = set()
163
+ self.defaults: Dict[Key, str] = {}
164
+ self._labels = itertools.count()
165
+ try:
166
+ with open(filename, encoding="utf-8") as fh:
167
+ tree = ast.parse(fh.read(), filename)
168
+ except (OSError, SyntaxError, ValueError):
169
+ return
170
+ self._chains(tree)
171
+ for scope in [tree] + [n for n in ast.walk(tree) if isinstance(n, _SCOPES[:2])]:
172
+ self._join_assignments(scope)
173
+ counts: Dict[Any, int] = {}
174
+ for key, root in self.roots.items():
175
+ if key not in self.receivers:
176
+ counts[root] = counts.get(root, 0) + 1
177
+ self.solo = {k for k, r in self.roots.items() if k not in self.receivers and counts[r] == 1}
178
+ parents = {c: p for p in ast.walk(tree) for c in ast.iter_child_nodes(p)}
179
+ for node in ast.walk(tree):
180
+ if _is_get(node):
181
+ self.defaults[_key(node)] = "drop" if _truth_test(node, parents) else self._judge(node)
182
+
183
+ def _chains(self, tree: ast.AST) -> None:
184
+ claimed = set()
185
+
186
+ def receiver(node: ast.AST, root: Any) -> None:
187
+ claimed.add(id(node))
188
+ self.roots[_key(node)] = root
189
+ self.receivers.add(_key(node))
190
+
191
+ def through(node: ast.AST, root: Any) -> None:
192
+ while _is_get(node) or isinstance(node, ast.Subscript):
193
+ receiver(node, root)
194
+ node = node.func.value if _is_get(node) else node.value # type: ignore[attr-defined]
195
+
196
+ def mark(node: ast.AST, root: Any) -> None:
197
+ claimed.add(id(node))
198
+ if _is_get(node):
199
+ self.roots[_key(node)] = root
200
+ through(node.func.value, root) # type: ignore[attr-defined]
201
+ for alt in node.args[1:2]: # type: ignore[attr-defined]
202
+ mark(alt, root)
203
+ elif isinstance(node, ast.Subscript):
204
+ self.roots[_key(node)] = root
205
+ through(node.value, root)
206
+ elif _is_or(node):
207
+ for alt in node.values: # type: ignore[attr-defined]
208
+ mark(alt, root)
209
+ elif isinstance(node, ast.IfExp):
210
+ for read in ast.walk(node.test):
211
+ if _is_get(read) or isinstance(read, ast.Subscript):
212
+ receiver(read, root)
213
+ mark(node.body, root)
214
+ mark(node.orelse, root)
215
+ elif isinstance(node, ast.Call): # `_f(a, k) or _f(b, k)`, `float(a.get(k)) or ...`
216
+ self.roots[_key(node)] = root
217
+ for arg in node.args:
218
+ mark(arg, root)
219
+
220
+ # Breadth-first, so a chain is claimed from its outermost node before its inner ones.
221
+ for node in ast.walk(tree):
222
+ if id(node) not in claimed and (
223
+ _is_get(node) or _is_or(node) or isinstance(node, (ast.IfExp, ast.Subscript))
224
+ ):
225
+ mark(node, _key(node))
226
+
227
+ def _join(self, *groups: set) -> None:
228
+ members = set().union(*groups)
229
+ if len(members) > 1:
230
+ label = ("join", next(self._labels))
231
+ for key, root in list(self.roots.items()):
232
+ if root in members:
233
+ self.roots[key] = label
234
+
235
+ def _chains_in(self, node: ast.AST) -> set:
236
+ return {self.roots[_key(n)] for n in ast.walk(node) if _keyed(n) and _key(n) in self.roots}
237
+
238
+ def _join_assignments(self, scope: ast.AST) -> None:
239
+ parents = {c: p for p in ast.walk(scope) for c in ast.iter_child_nodes(p)}
240
+ own = list(_own_nodes(scope))
241
+ assigns = sorted((n for n in own if _assigned(n) and self._chains_in(_assigned(n)[1])),
242
+ key=lambda n: (n.lineno, n.col_offset))
243
+ latest: Dict[str, ast.AST] = {}
244
+ for node in assigns:
245
+ name, value = _assigned(node) # type: ignore[misc]
246
+ if name in latest and self._falls_back(node, name, value, parents, scope):
247
+ self._join(self._chains_in(latest[name]), self._chains_in(value))
248
+ latest[name] = value
249
+ for node in own:
250
+ if isinstance(node, ast.Try): # `try: x = a.get(k) ...` / `except ...: x = b[k]`
251
+ tried = {a[0]: a[1] for a in map(_assigned, _within(node.body)) if a}
252
+ for handler in node.handlers:
253
+ for a in filter(None, map(_assigned, _within(handler.body))):
254
+ if a[0] in tried:
255
+ self._join(self._chains_in(tried[a[0]]), self._chains_in(a[1]))
256
+ elif _is_or(node): # `legacy = c.get(k)` ... `legacy or c.get(k2)`
257
+ for operand in node.values: # type: ignore[attr-defined]
258
+ if isinstance(operand, ast.Name):
259
+ before = [n for n in assigns if _assigned(n)[0] == operand.id # type: ignore[index]
260
+ and (n.lineno, n.col_offset) < (node.lineno, node.col_offset)]
261
+ if before:
262
+ self._join(self._chains_in(_assigned(before[-1])[1]), self._chains_in(node)) # type: ignore[index]
263
+
264
+ @staticmethod
265
+ def _falls_back(assign: ast.AST, name: str, value: ast.AST, parents: Dict[Any, Any], scope: ast.AST) -> bool:
266
+ """Whether an assignment to `name` is the fallback of the one before it: `x = x or ...`, or it
267
+ sits in the body of an `if` that tests `x`. A walrus in that test is the first assignment.
268
+ `x = x + ...` accumulates; it is not a fallback."""
269
+ if _is_or(value) and isinstance(value.values[0], ast.Name) and value.values[0].id == name: # type: ignore[attr-defined]
270
+ return True
271
+ child, node = assign, parents.get(assign)
272
+ while node is not None and node is not scope:
273
+ if isinstance(node, ast.If) and child is not node.test and _reads_name(node.test, name):
274
+ return True
275
+ child, node = node, parents.get(node)
276
+ return False
277
+
278
+ def _judge(self, call: ast.Call) -> str:
279
+ if len(call.args) < 2:
280
+ return "keep"
281
+ default = call.args[1]
282
+ if isinstance(default, ast.Constant):
283
+ value = default.value
284
+ return "keep" if not isinstance(value, bool) and value in _STAND_INS else "drop"
285
+ if isinstance(default, (ast.Dict, ast.List, ast.Tuple, ast.Set)):
286
+ return "drop" if (getattr(default, "keys", None) or getattr(default, "elts", None)) else "keep"
287
+ if _is_get(default) or _is_or(default) or isinstance(default, (ast.Name, ast.IfExp, ast.Subscript)):
288
+ return "value"
289
+ return "drop"
290
+
291
+
292
+ def _file_reads(filename: str) -> _FileReads:
293
+ if filename not in _FILES:
294
+ _FILES[filename] = _FileReads(filename)
295
+ return _FILES[filename]
296
+
297
+
298
+ def _position(frame: Any) -> Optional[Tuple[int, int, int, int]]:
299
+ """(lineno, end_lineno, col, end_col) of the expression a frame is running. 3.11+."""
300
+ code = frame.f_code
301
+ try:
302
+ positions = _POSITIONS.get(code)
303
+ if positions is None:
304
+ positions = _POSITIONS[code] = list(code.co_positions())
305
+ span = positions[frame.f_lasti // 2]
306
+ except (AttributeError, IndexError):
307
+ return None
308
+ return None if None in span else span
309
+
310
+
311
+ def _first_resume(code: Any) -> int:
312
+ if code not in _RESUMES:
313
+ _RESUMES[code] = next((i.offset for i in dis.get_instructions(code) if i.opname == "RESUME"), -1)
314
+ return _RESUMES[code]
315
+
316
+
317
+ def _placeholder(value: Any) -> bool:
318
+ """Whether a default's value stands in for one (0, None, "", {}, []). A bool is a flag's answer."""
319
+ if isinstance(value, bool):
320
+ return False
321
+ try:
322
+ return not value
323
+ except Exception: # noqa: BLE001 — a value whose truth raises is not a placeholder
324
+ return False
325
+
326
+
327
+ class _Reads:
328
+ """Row-key reads across every response of one tick: how often each (tool, path) was found, and
329
+ the misses that nothing else in their fallback chain answered.
330
+
331
+ Chains come from the source (`_FileReads`). A read alone in its chain in a helper is judged at
332
+ its caller's expression when that is a chain (`_f(a, k) or _f(b, k)`). A loop over spellings —
333
+ `for k in ("traderAddress", "address"): v = t.get(k)` — runs one `.get` once per spelling, so a
334
+ miss is also dropped when the same expression, in the same call, finds a key on the same row (or
335
+ a dict inside it), before or after it.
336
+
337
+ Calls are told apart by the number the preflight tracer gives each one as it starts, so no frame
338
+ is kept alive. Chains are per thread, so a scanner scoring rows on a pool does not interleave them.
339
+ """
340
+
341
+ def __init__(self, calls: Optional[Dict[int, int]] = None) -> None:
342
+ self.found: Dict[Tuple[str, str], int] = {}
343
+ self.missed: Dict[Tuple[str, str], Dict[str, Any]] = {}
344
+ self._calls = calls if calls is not None else {}
345
+ self._lock = threading.Lock()
346
+ self._chains: Dict[int, Dict[Any, Dict[str, Any]]] = {} # per thread: open chains by (file, root)
347
+ # Rows are held for the tick, so a row's id is never reused. They are reachable from the
348
+ # response already, so this keeps nothing alive that would not be.
349
+ self._rows: Dict[int, Any] = {}
350
+ # Per row id: the (call, read) pairs that found a key there, and the misses each made.
351
+ self._row_hits: Dict[int, set] = {}
352
+ self._row_misses: Dict[int, Dict[Any, List[Tuple[str, str]]]] = {}
353
+
354
+ def read(self, tool: str, path: str, found: bool, default: Any, carried: Optional[List[str]], row: Any) -> None:
355
+ frame = sys._getframe(2) # noqa: SLF001 — 0 is here, 1 the accessor, 2 the scanner
356
+ with self._lock:
357
+ if found:
358
+ self.found[(tool, path)] = self.found.get((tool, path), 0) + 1
359
+ span = _position(frame)
360
+ if span is None:
361
+ return # nothing to tell a fallback from a guess by, so record no miss
362
+ file = frame.f_code.co_filename
363
+ reads = _file_reads(file)
364
+ key = (span[1], span[3])
365
+ rule = reads.defaults.get(key)
366
+ keep = not found and (rule == "keep" or (rule != "drop" and _placeholder(default)))
367
+ if key not in reads.roots or key in reads.solo:
368
+ outer = frame.f_back
369
+ while outer is not None and id(outer) in self._calls:
370
+ outer_span = _position(outer)
371
+ if outer_span is not None:
372
+ outer_reads = _file_reads(outer.f_code.co_filename)
373
+ outer_key = (outer_span[1], outer_span[3])
374
+ if outer_key in outer_reads.roots and outer_key not in outer_reads.solo:
375
+ frame, span, reads, key = outer, outer_span, outer_reads, outer_key
376
+ file = outer.f_code.co_filename
377
+ break
378
+ outer = outer.f_back
379
+ owner = row._owner
380
+ call = (id(frame), self._calls.get(id(frame), 0))
381
+ here = (call, key)
382
+ self._rows.setdefault(id(owner), owner)
383
+ if found:
384
+ self._row_hits.setdefault(id(owner), set()).add(here)
385
+ for missed in self._row_misses.get(id(owner), {}).pop(here, []):
386
+ self._forget(missed)
387
+ root = reads.roots.get(key, key)
388
+ # Every chain stays open until its own expression comes round again (the next row), its
389
+ # call changes, or the tick ends: an unrelated read between two halves of a fallback
390
+ # (`c = inst.get("context", {})`) does not end it.
391
+ open_chains = self._chains.setdefault(threading.get_ident(), {})
392
+ slot = (file, root)
393
+ chain = open_chains.get(slot)
394
+ if chain is not None and (chain["call"] != call or key in chain["keys"]):
395
+ self._close(open_chains.pop(slot))
396
+ chain = None
397
+ if chain is None:
398
+ chain = open_chains[slot] = {"call": call, "keys": set(), "found": False, "misses": []}
399
+ chain["keys"].add(key)
400
+ if found:
401
+ if key not in reads.receivers:
402
+ chain["found"] = True
403
+ elif keep:
404
+ chain["misses"].append((tool, path, carried, (file, span[0]), id(owner), here, row._path))
405
+ if key == root: # the outermost `.get` runs last, after every alternative
406
+ self._close(open_chains.pop(slot))
407
+
408
+ def _close(self, chain: Dict[str, Any]) -> None:
409
+ if chain["found"]:
410
+ return
411
+ for tool, path, carried, where, owner_id, here, row_path in chain["misses"]:
412
+ if here in self._row_hits.get(owner_id, ()):
413
+ continue # this expression, in this call, found another spelling on this row
414
+ entry = self.missed.setdefault(
415
+ (tool, path), {"reads": 0, "carried": carried, "where": where, "row": row_path})
416
+ entry["reads"] += 1
417
+ self._row_misses.setdefault(owner_id, {}).setdefault(here, []).append((tool, path))
418
+
419
+ def _forget(self, missed: Tuple[str, str]) -> None:
420
+ entry = self.missed.get(missed)
421
+ if entry is not None:
422
+ entry["reads"] -= 1
423
+ if entry["reads"] <= 0:
424
+ del self.missed[missed]
425
+
426
+ def never_found(self, root: str) -> Dict[str, List[Dict[str, Any]]]:
427
+ """Per tool, keys read on rows of a list that no row of any of its responses carried.
428
+ Top-level keys are left out: an optional `error` or cursor is routinely absent."""
429
+ with self._lock:
430
+ for open_chains in self._chains.values():
431
+ for chain in open_chains.values():
432
+ self._close(chain)
433
+ self._chains.clear()
434
+ prefix = os.path.abspath(root).rstrip(os.sep) + os.sep
435
+ out: Dict[str, List[Dict[str, Any]]] = {}
436
+ for (tool, path), entry in self.missed.items():
437
+ if "[]" not in path or self.found.get((tool, path)):
438
+ continue
439
+ item: Dict[str, Any] = {
440
+ "path": path, "row": entry["row"], "reads": entry["reads"], "carried": entry["carried"]}
441
+ file, line = entry["where"]
442
+ if os.path.abspath(file).startswith(prefix):
443
+ item.update(file=os.path.relpath(file, root), line=line)
444
+ out.setdefault(tool, []).append(item)
445
+ return {tool: items[:10] for tool, items in out.items()}
446
+
447
+
60
448
  class _Watched(dict):
61
449
  """A response that remembers whether the scanner looked inside it.
62
450
 
@@ -64,22 +452,56 @@ class _Watched(dict):
64
452
  expects — a renamed key, a nesting level missed. That failure is silent: the read succeeded, the
65
453
  scanner carried on, and the score was computed from a default. Recording access is what turns it
66
454
  into something reportable.
455
+
456
+ The same failure one level down is a row key that does not exist: `resp["data"]` was read, so
457
+ the response counts as used, while every `row.get("volume", 0)` returned its default. Nested
458
+ dicts and lists are watched too, and every key read is reported to the tick's `_Reads`.
67
459
  """
68
460
 
69
- def __init__(self, source: dict) -> None:
461
+ def __init__(self, source: Any = (), tool: str = "", path: str = "", reads: Optional[_Reads] = None,
462
+ owner: Any = None) -> None:
70
463
  super().__init__(source)
71
464
  self.touched = False
465
+ self._tool = tool
466
+ self._path = path
467
+ self._reads = reads if reads is not None else _Reads()
468
+ # The list row this dict belongs to (itself for a row): `row["context"]` answers for `row`.
469
+ self._owner = owner if owner is not None else self
72
470
 
73
471
  def _touch(self) -> None:
74
472
  self.touched = True
75
473
 
474
+ def _at(self, key: Any) -> str:
475
+ key = str(key)[:_KEY_LIMIT]
476
+ return f"{self._path}.{key}" if self._path else key
477
+
478
+ def _child(self, key: Any, path: str, value: Any) -> Any:
479
+ # Wrapped once and stored back, so the scanner holds one object however often it looks.
480
+ if isinstance(value, dict) and not isinstance(value, _Watched):
481
+ value = _Watched(value, self._tool, path, self._reads, self._owner if "[]" in self._path else None)
482
+ elif isinstance(value, list) and not isinstance(value, _WatchedList):
483
+ value = _WatchedList(value, self._tool, path + "[]", self._reads)
484
+ else:
485
+ return value
486
+ dict.__setitem__(self, key, value)
487
+ return value
488
+
76
489
  def __getitem__(self, key: Any) -> Any:
77
490
  self._touch()
78
- return super().__getitem__(key)
491
+ value = super().__getitem__(key) # a missing key raises, which is already visible
492
+ path = self._at(key)
493
+ self._reads.read(self._tool, path, True, None, None, self)
494
+ return self._child(key, path, value)
79
495
 
80
- def get(self, key: Any, default: Any = None) -> Any:
496
+ def get(self, key: Any, default: Any = None, /) -> Any: # positional, as `dict.get` is
81
497
  self._touch()
82
- return super().get(key, default)
498
+ path = self._at(key)
499
+ if dict.__contains__(self, key):
500
+ self._reads.read(self._tool, path, True, None, None, self)
501
+ return self._child(key, path, dict.__getitem__(self, key))
502
+ carried = sorted(str(k)[:_KEY_LIMIT] for k in dict.keys(self))[:20]
503
+ self._reads.read(self._tool, path, False, default, carried, self)
504
+ return default
83
505
 
84
506
  def __contains__(self, key: Any) -> bool:
85
507
  self._touch()
@@ -101,6 +523,47 @@ class _Watched(dict):
101
523
  self._touch()
102
524
  return super().items()
103
525
 
526
+ def __reduce_ex__(self, protocol: Any) -> Any:
527
+ # A copy or pickle is plain data, not the tick's bookkeeping.
528
+ return (dict, (dict(self),))
529
+
530
+
531
+ class _WatchedList(list):
532
+ """A list inside a watched response. Its rows (dicts, and lists of rows) are watched as they are
533
+ handed out, and stored back, so a slice or a loop holds the same objects as the list."""
534
+
535
+ def __init__(self, source: Any = (), tool: str = "", path: str = "", reads: Optional[_Reads] = None) -> None:
536
+ super().__init__(source)
537
+ self._tool = tool
538
+ self._path = path
539
+ self._reads = reads if reads is not None else _Reads()
540
+
541
+ def _row(self, index: int, value: Any) -> Any:
542
+ if isinstance(value, dict) and not isinstance(value, _Watched):
543
+ value = _Watched(value, self._tool, self._path, self._reads)
544
+ elif isinstance(value, list) and not isinstance(value, _WatchedList):
545
+ value = _WatchedList(value, self._tool, self._path + "[]", self._reads)
546
+ else:
547
+ return value
548
+ list.__setitem__(self, index, value)
549
+ return value
550
+
551
+ def __getitem__(self, index: Any) -> Any:
552
+ if isinstance(index, slice): # `rows[:20]` is how a universe gets capped
553
+ for i in range(*index.indices(len(self))):
554
+ self._row(i, list.__getitem__(self, i))
555
+ return _WatchedList(list.__getitem__(self, index), self._tool, self._path, self._reads)
556
+ return self._row(index, list.__getitem__(self, index))
557
+
558
+ def __iter__(self) -> Any:
559
+ index = 0
560
+ while index < len(self): # a list iterator's own rule, so mid-loop removes and appends match
561
+ yield self._row(index, list.__getitem__(self, index))
562
+ index += 1
563
+
564
+ def __reduce_ex__(self, protocol: Any) -> Any:
565
+ return (list, (list(list.__iter__(self)),))
566
+
104
567
 
105
568
  class CountingMcp:
106
569
  """Records every call and forwards it unchanged.
@@ -114,10 +577,11 @@ class CountingMcp:
114
577
  place for it, since the same boundary protects production.
115
578
  """
116
579
 
117
- def __init__(self, inner: Any) -> None:
580
+ def __init__(self, inner: Any, call_ids: Optional[Dict[int, int]] = None) -> None:
118
581
  self._inner = inner
119
582
  self.calls: List[Dict[str, Any]] = []
120
583
  self._watched: List[Dict[str, Any]] = []
584
+ self._reads = _Reads(call_ids)
121
585
 
122
586
  def call_tool(self, name: str, args: Optional[dict] = None) -> Any:
123
587
  started = time.monotonic()
@@ -163,16 +627,22 @@ class CountingMcp:
163
627
  self.calls.append(record)
164
628
 
165
629
  if isinstance(result, dict) and record["ok"]:
166
- watched = _Watched(result)
630
+ watched = _Watched(result, name, "", self._reads)
167
631
  self._watched.append({"record": record, "response": watched})
168
632
  return watched
169
633
  return result
170
634
 
171
- def settle_unread(self) -> None:
172
- """Mark the responses nothing ever looked inside. Called once the tick is over."""
635
+ def settle(self, root: str) -> None:
636
+ """Mark the responses nothing looked inside, and the row keys looked for and never found.
637
+ Called once the tick is over. Missing keys are judged per tool, across all its responses,
638
+ and land on that tool's first record."""
639
+ missing = self._reads.never_found(root)
173
640
  for entry in self._watched:
641
+ record = entry["record"]
174
642
  if not entry["response"].touched:
175
- entry["record"]["unread"] = True
643
+ record["unread"] = True
644
+ if record["tool"] in missing:
645
+ record["missing_keys"] = missing.pop(record["tool"])
176
646
 
177
647
  def __getattr__(self, name: str) -> Any:
178
648
  # Keeps the wrapper transparent for anything beyond the documented surface.
@@ -282,6 +752,10 @@ class _Tracer:
282
752
  # pin a chain of tracebacks.
283
753
  self._live_exc: Any = None
284
754
  self._group = 0
755
+ # The call each live scanner frame belongs to, numbered as calls start. A frame's id is
756
+ # reused once it returns; this tells two calls apart without keeping either frame alive.
757
+ self.call_ids: Dict[int, int] = {}
758
+ self._call_seq = itertools.count(1)
285
759
 
286
760
  def _own(self, frame: Any) -> bool:
287
761
  return os.path.abspath(frame.f_code.co_filename).startswith(self._prefix)
@@ -292,6 +766,11 @@ class _Tracer:
292
766
  return None
293
767
  # Exceptions and returns still fire; per-line events do not.
294
768
  frame.f_trace_lines = False
769
+ # A generator fires 'call' on every resume; it keeps the number of its first entry.
770
+ code = frame.f_code
771
+ if not (code.co_flags & _GENERATOR and id(frame) in self.call_ids
772
+ and frame.f_lasti != _first_resume(code)):
773
+ self.call_ids[id(frame)] = next(self._call_seq)
295
774
  return self
296
775
  if event == "exception":
297
776
  exc_type, exc_value, _tb = arg
@@ -378,6 +857,7 @@ def run_preflight(config: Dict[str, Any]) -> Dict[str, Any]:
378
857
  from . import scaffold as scaffold_mod
379
858
 
380
859
  root = config["path"]
860
+ _FILES.clear() # the scanner's source is read fresh for each tick
381
861
  sink = _CollectingSink()
382
862
  tracer = _Tracer(root)
383
863
 
@@ -388,7 +868,7 @@ def run_preflight(config: Dict[str, Any]) -> Dict[str, Any]:
388
868
  "reason": "no_credentials",
389
869
  "detail": "no data-client credentials are configured in this environment",
390
870
  }
391
- counter = CountingMcp(inner)
871
+ counter = CountingMcp(inner, tracer.call_ids)
392
872
 
393
873
  captured_out = io.StringIO()
394
874
  captured_err = io.StringIO()
@@ -430,7 +910,7 @@ def run_preflight(config: Dict[str, Any]) -> Dict[str, Any]:
430
910
  sys.settrace(None)
431
911
  sys.stdout, sys.stderr = real_stdout, real_stderr
432
912
  http_post.PREFLIGHT = False
433
- counter.settle_unread()
913
+ counter.settle(root)
434
914
  tracer.release()
435
915
 
436
916
  # A tick that failed was reported through the sink rather than raised. The loop's error record
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A fake Senpi MCP that holds a user's live strategies and simulations and answers `strategy_list`
3
+ * and `strategy_create_custom_strategy` the way the paper router does (SPEC §6.3, §6.5): one world
4
+ * per call, `paper_routing: true` at the head of every paper reply, `paper: true` on every paper
5
+ * row, and `PAPER_IDENTIFIER_MISMATCH` for a flag that disagrees with its filter.
6
+ *
7
+ * `router: false` plays an MCP without the router: it drops the unknown `paper` key and answers
8
+ * from the live world with no marker, and its strict UUID check refuses a `paper-` id.
9
+ *
10
+ * Every call is recorded so a test can assert what reached the MCP.
11
+ */
12
+ import type { McpTransport } from "../../types.js";
13
+ export interface FakeStrategyRow {
14
+ id: string;
15
+ strategyWalletAddress: string;
16
+ status: string;
17
+ strategyName: string;
18
+ skillName?: string;
19
+ totalFunded?: number;
20
+ }
21
+ export interface FakePaperMcpOptions {
22
+ router?: boolean;
23
+ live?: FakeStrategyRow[];
24
+ paper?: FakeStrategyRow[];
25
+ /** Statuses a new simulation reports on successive reads; the last one sticks. */
26
+ paperCreateStatuses?: string[];
27
+ /** The wallet a new simulation gets. Defaults to a hypaper-format address. */
28
+ paperCreateWallet?: string;
29
+ /** Rows the router forgot to flag `paper: true` (a broken router, for the row filter). */
30
+ unflaggedPaperRows?: boolean;
31
+ }
32
+ export interface FakeCall {
33
+ tool: string;
34
+ args: Record<string, unknown>;
35
+ }
36
+ export interface FakePaperMcp {
37
+ transport: McpTransport;
38
+ calls: FakeCall[];
39
+ callsTo(tool: string): Array<Record<string, unknown>>;
40
+ live: FakeStrategyRow[];
41
+ paper: FakeStrategyRow[];
42
+ }
43
+ export declare function createFakePaperMcp(options?: FakePaperMcpOptions): FakePaperMcp;
44
+ //# sourceMappingURL=fake-paper-mcp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-paper-mcp.d.ts","sourceRoot":"","sources":["../../../../src/senpi/__tests__/helpers/fake-paper-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAGnD,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,qBAAqB,EAAE,MAAM,CAAC;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,eAAe,EAAE,CAAC;IACzB,KAAK,CAAC,EAAE,eAAe,EAAE,CAAC;IAC1B,kFAAkF;IAClF,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,8EAA8E;IAC9E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0FAA0F;IAC1F,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,YAAY,CAAC;IACxB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,IAAI,EAAE,eAAe,EAAE,CAAC;IACxB,KAAK,EAAE,eAAe,EAAE,CAAC;CAC1B;AA2BD,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,YAAY,CA0GlF"}