sliceagent 0.1.0__py3-none-any.whl

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 (71) hide show
  1. sliceagent/__init__.py +3 -0
  2. sliceagent/__main__.py +6 -0
  3. sliceagent/access.py +93 -0
  4. sliceagent/agents.py +173 -0
  5. sliceagent/background_review.py +146 -0
  6. sliceagent/binsniff.py +89 -0
  7. sliceagent/cli.py +890 -0
  8. sliceagent/clock.py +32 -0
  9. sliceagent/code_grep.py +329 -0
  10. sliceagent/code_index.py +417 -0
  11. sliceagent/config.py +240 -0
  12. sliceagent/context_overflow.py +227 -0
  13. sliceagent/envspec.py +129 -0
  14. sliceagent/errors.py +167 -0
  15. sliceagent/events.py +96 -0
  16. sliceagent/finding_types.py +70 -0
  17. sliceagent/flags.py +63 -0
  18. sliceagent/fuzzy.py +135 -0
  19. sliceagent/guardrails.py +438 -0
  20. sliceagent/guidance.py +69 -0
  21. sliceagent/hippocampus.py +581 -0
  22. sliceagent/hooks.py +334 -0
  23. sliceagent/interfaces.py +144 -0
  24. sliceagent/llm.py +695 -0
  25. sliceagent/loop.py +548 -0
  26. sliceagent/mcp_client.py +255 -0
  27. sliceagent/mcp_security.py +77 -0
  28. sliceagent/memory.py +428 -0
  29. sliceagent/metrics.py +103 -0
  30. sliceagent/model_catalog.py +124 -0
  31. sliceagent/monitor.py +615 -0
  32. sliceagent/neocortex.py +436 -0
  33. sliceagent/onboarding.py +323 -0
  34. sliceagent/oracle.py +36 -0
  35. sliceagent/pagetable.py +255 -0
  36. sliceagent/pfc.py +449 -0
  37. sliceagent/plugins.py +127 -0
  38. sliceagent/policy.py +234 -0
  39. sliceagent/procman.py +187 -0
  40. sliceagent/prompt.py +239 -0
  41. sliceagent/records.py +108 -0
  42. sliceagent/recovery.py +119 -0
  43. sliceagent/regions.py +678 -0
  44. sliceagent/registry.py +128 -0
  45. sliceagent/retriever.py +19 -0
  46. sliceagent/safety.py +332 -0
  47. sliceagent/sandbox.py +143 -0
  48. sliceagent/scheduler.py +92 -0
  49. sliceagent/search_index.py +289 -0
  50. sliceagent/seed.py +465 -0
  51. sliceagent/sensory_cortex.py +500 -0
  52. sliceagent/session.py +222 -0
  53. sliceagent/skill_provenance.py +71 -0
  54. sliceagent/skill_usage.py +123 -0
  55. sliceagent/skills.py +209 -0
  56. sliceagent/subagent.py +332 -0
  57. sliceagent/subdir_hints.py +222 -0
  58. sliceagent/swap.py +182 -0
  59. sliceagent/taskstate.py +57 -0
  60. sliceagent/telemetry.py +59 -0
  61. sliceagent/terminal.py +240 -0
  62. sliceagent/text_utils.py +56 -0
  63. sliceagent/tool_summary.py +93 -0
  64. sliceagent/tools.py +1194 -0
  65. sliceagent/tui.py +1377 -0
  66. sliceagent/web.py +354 -0
  67. sliceagent-0.1.0.dist-info/METADATA +262 -0
  68. sliceagent-0.1.0.dist-info/RECORD +71 -0
  69. sliceagent-0.1.0.dist-info/WHEEL +4 -0
  70. sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
  71. sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,581 @@
1
+ """HIPPOCAMPUS — the episodic memory: explicit, cue-dependent recall of this session's own past
2
+ turns. Two complementary sides live in this one module: the WRITE side (EpisodeSink, buffers one
3
+ turn's events and flushes a lossless record via memory.append_episode when the turn closes) and
4
+ the READ side (recall_history, the model's first-class tool to page a past turn back in). Neither
5
+ ever touches the Slice directly — the cache can never enter the LLM context except through an
6
+ explicit recall_history call (Markov by construction); this is what distinguishes HIPPOCAMPUS from
7
+ PFC (pfc.py, the carried working memory) and from NEOCORTEX (neocortex.py, the auto-surfaced,
8
+ distilled lessons vault) — episodic recall is precise, verbatim, and only happens on request.
9
+
10
+ WRITE SIDE — episodic cache, the lossless side (MEMORY-SPEC step 1).
11
+ An output-only event sink (sibling of LessonMiner): it buffers one turn's events and flushes ONE
12
+ record via `memory.append_episode` when the turn closes. It NEVER touches the Slice, so the cache
13
+ can never enter the LLM context — Markov by construction. Record shape:
14
+ `{steps: [{slice, action:[{name,args,failing}], observation:[...]}], note, meta}` — the SEED slice is
15
+ captured once (step 1) plus the turn's accumulated (action, observation) units; lossless for turn recall.
16
+
17
+ READ SIDE — recall_history, the model's first-class read into the episodic cache (a NORMAL navigation
18
+ move). The cache is never part of the slice (Markov by construction); this tool pages a past turn back
19
+ IN. It is the read verb for the PAGED-OUT HISTORY manifest in the slice: that manifest lists each
20
+ earlier turn (turn · title · note) WITH the exact call to fetch it, so reaching back is copy-paste, not
21
+ a blind guess — a cache the model can't see is a cache it never calls (the manifest is the trigger).
22
+ recall_history() -> the full TIMESTAMPED/TITLED index (turns older than the manifest)
23
+ recall_history(last=N | turns=[]) -> a specific turn's compact trace (action/observation/note)
24
+ recall_history(..., full=true) -> a turn's FULL stored slice (exact past state)
25
+ recall_history(search="…") -> FTS5 content search: THIS session's long tail + other sessions
26
+ Reaching back is expected, not a failure: the slice is bounded, so an earlier turn genuinely is not in
27
+ front of the model, and there is no automatic mechanism that can guess WHICH past turn the current
28
+ reasoning needs — only the model can. NON-ACCUMULATION (moat): a fetched turn is TRANSIENT — it enters
29
+ context for this loop only and is never written back into slice state (the slice is rebuilt from the
30
+ durable stores each turn); a single turn's fetches are bounded by DISTINCT_PER_TURN + the exact-repeat
31
+ redirect, so 'encourage recall' can never rebuild the transcript. Registered when memory is durable.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import threading
38
+ from collections import deque
39
+
40
+ from .events import AssistantText, Event, SliceBuilt, ToolResult, TurnEnd, TurnInterrupted
41
+ from .pfc import edited_paths_in_code, paths_in_code # noqa: F401 (paths_in_code kept for back-compat callers)
42
+ from .safety import redact_text
43
+ from .text_utils import format_ts, now_iso
44
+
45
+ _EDIT_TOOL_NAMES = ("edit_file", "append_to_file", "str_replace", "write_file")
46
+
47
+
48
+ def _files_of(event: ToolResult) -> list[str]:
49
+ """CHANGED files for meta['files'] — mirror slice_sink: only SUCCESSFUL edit tools (and the mutated
50
+ paths of a successful execute_code). A read, a dir-scope grep, or a FAILED edit changed nothing, so
51
+ labeling those 'changed/edited' misled recall + mis-classified consolidated lessons (FILE_TOUCHED)."""
52
+ if event.failing:
53
+ return []
54
+ out = []
55
+ args = event.args if isinstance(event.args, dict) else {} # raw model args may be a non-dict (list/str/number)
56
+ if event.name in _EDIT_TOOL_NAMES:
57
+ p = args.get("path")
58
+ if isinstance(p, str) and p:
59
+ out.append(p)
60
+ if event.name == "execute_code":
61
+ code = args.get("code", "")
62
+ out += edited_paths_in_code(code if isinstance(code, str) else "") # mutate-only (write/open-w), not reads
63
+ return out
64
+
65
+
66
+ _OBS_KEEP_WHOLE = 2000 # keep an observation WHOLE up to this (covers a ~120-line config / a page of
67
+ _OBS_HEAD = 1200 # output), so a value in the MIDDLE survives; beyond it, a generous head+tail.
68
+ _OBS_TAIL = 600 # Bounded — the archive is L2 (on disk, not the slice), and recall caps what it serves.
69
+
70
+
71
+ def _obs_excerpt(obs: str) -> str:
72
+ """A BOUNDED excerpt of a tool observation, indented under its trace line, so the actual DATA a turn
73
+ SAW (a value, a grep match, an error) survives into the recallable markdown. The one-line summary
74
+ ('read_file x -> 250 chars, 8 lines') cannot answer 'what was the value?' later — this is the precision
75
+ the cross-slice recall channel needs. Keep the WHOLE observation up to _OBS_KEEP_WHOLE (so a value in
76
+ the MIDDLE of a normal file/output is not lost — the measured gap); only a truly large observation is
77
+ reduced to head+tail. Bounded: the archive is L2 (on disk, not in context until recalled) and
78
+ recall_history caps the total it serves; page-out (#74) already bounds huge tool outputs upstream."""
79
+ o = (obs or "").strip()
80
+ if not o:
81
+ return ""
82
+ body = o if len(o) <= _OBS_KEEP_WHOLE else (o[:_OBS_HEAD] + "\n…⋯…\n" + o[-_OBS_TAIL:])
83
+ return " " + body.replace("\n", "\n ") # indent the excerpt under its "- " trace bullet
84
+
85
+
86
+ def turn_markdown(title: str, steps: list[dict], note: str, meta: dict) -> str:
87
+ """Render a SEALED turn as a clean, self-contained MARKDOWN snapshot — the readable artifact the
88
+ cache holds and the next loop pages back via recall_history (the slice saved into the cache as
89
+ markdown). Distilled, not a raw dump: heading, changed files, outcome, the action→result trace WITH
90
+ a bounded excerpt of each observation (so the data the turn saw is recallable), and the conclusion.
91
+ Built from the buffered turn data alone (no Slice coupling — Markov)."""
92
+ from .tool_summary import summarize_tool_result
93
+ files = meta.get("files") or []
94
+ out = [f"# {title or '(turn)'}"]
95
+ if files:
96
+ out.append(f"**changed files:** {', '.join(files)}")
97
+ if meta.get("stop_reason"):
98
+ out.append(f"**outcome:** {meta['stop_reason']}")
99
+ trace = []
100
+ for st in steps:
101
+ for a, o in zip(st.get("action", []), st.get("observation", [])):
102
+ trace.append("- " + summarize_tool_result(a.get("name", ""), a.get("args", {}), o,
103
+ failing=bool(a.get("failing"))))
104
+ ex = _obs_excerpt(o) # keep the actual observed DATA, bounded, so recall is USEFUL
105
+ if ex:
106
+ trace.append(ex)
107
+ if trace:
108
+ out.append("\n## what happened\n" + "\n".join(trace))
109
+ if note:
110
+ out.append(f"\n## conclusion\n{note}")
111
+ return "\n".join(out)
112
+
113
+
114
+ class EpisodeSink:
115
+ """Buffers a turn's events; flushes one lossless record on TurnEnd OR TurnInterrupted."""
116
+
117
+ def __init__(self, memory, *, session_id: str, task_id_fn, title_fn=lambda: "", outcome_fn=lambda: {}):
118
+ self.memory = memory
119
+ self.session_id = session_id
120
+ self.task_id_fn = task_id_fn # () -> current task_id (host supplies; Step 3 seam)
121
+ self.title_fn = title_fn # () -> human title (goal one-liner) for cheap trace-back
122
+ self.outcome_fn = outcome_fn # () -> {} of task-OUTCOME signals (e.g. requirements_open) for meta
123
+ self._turn = 0
124
+ self._reset()
125
+
126
+ def _reset(self) -> None:
127
+ self._steps: list[dict] = []
128
+ self._note = ""
129
+ self._meta = {"failing": False, "files": []}
130
+
131
+ def _cur(self) -> dict:
132
+ if not self._steps:
133
+ self._steps.append({"slice": "", "action": [], "observation": []})
134
+ return self._steps[-1]
135
+
136
+ def __call__(self, event: Event) -> None:
137
+ if isinstance(event, SliceBuilt):
138
+ # the loop dispatches SliceBuilt for the seed → opens a new step segment
139
+ self._steps.append({"slice": event.rendered, "action": [], "observation": []})
140
+ elif isinstance(event, AssistantText):
141
+ if event.content and event.content.strip(): # content-emitting models' note
142
+ self._note = event.content.strip()
143
+ elif isinstance(event, ToolResult):
144
+ st = self._cur()
145
+ args = event.args if isinstance(event.args, dict) else {} # coerce: persist a dict so downstream
146
+ st["action"].append({"name": event.name, "args": args, "failing": event.failing}) # (search_index/consolidate) never read a non-dict from the episode
147
+ st["observation"].append(event.output) # VERBATIM — lossless (not observe()'d)
148
+ note = args.get("note", "") # reasoning models' note (empty content)
149
+ if note:
150
+ self._note = note
151
+ if event.failing:
152
+ self._meta["failing"] = True
153
+ self._meta["files"] += _files_of(event)
154
+ elif isinstance(event, TurnEnd):
155
+ self._flush(event.stop_reason, event.usage) # usage = per-turn TOTAL
156
+ elif isinstance(event, TurnInterrupted):
157
+ self._flush(event.reason, {}) # abort path: loop returns WITHOUT TurnEnd
158
+
159
+ def _flush(self, stop_reason: str, usage: dict) -> None:
160
+ if not self._steps and not self._note and not self._meta["files"]:
161
+ return # nothing buffered (e.g. the empty TurnEnd right after a TurnInterrupted)
162
+ self._turn += 1
163
+ try:
164
+ try:
165
+ title = self.title_fn() or ""
166
+ except Exception: # noqa: BLE001 — a title hiccup must not lose the record
167
+ title = ""
168
+ try:
169
+ outcome = self.outcome_fn() or {} # task-OUTCOME signals (requirements_open) — what
170
+ except Exception: # noqa: BLE001 # consolidation gates promotion on; never lose a record
171
+ outcome = {}
172
+ meta = {**self._meta, "stop_reason": stop_reason,
173
+ "ptok": usage.get("prompt_tokens", 0),
174
+ "ctok": usage.get("completion_tokens", 0),
175
+ "files": sorted(set(self._meta["files"])),
176
+ **outcome}
177
+ record = {
178
+ "title": title, # human breadcrumb for cheap trace-back (topic is task_id)
179
+ "steps": self._steps, # lossless raw events (full=true / step recall)
180
+ "note": self._note,
181
+ # the SEAL artifact: the turn's slice as a clean MARKDOWN snapshot — what recall_history
182
+ # returns by default, so paging a past turn back reads like opening a readable doc.
183
+ "markdown": turn_markdown(title, self._steps, self._note, meta),
184
+ "meta": meta,
185
+ }
186
+ self.memory.append_episode(self.session_id, self.task_id_fn(), self._turn, record)
187
+ finally:
188
+ self._reset() # reset regardless, so a turn can never bleed into the next
189
+
190
+
191
+ def make_episode_sink(memory, *, session_id: str, task_id_fn, title_fn=lambda: "", outcome_fn=lambda: {}):
192
+ """None for non-durable memory (NullMemory) → host skips it → evals untouched."""
193
+ if not getattr(memory, "is_durable", False):
194
+ return None
195
+ return EpisodeSink(memory, session_id=session_id, task_id_fn=task_id_fn, title_fn=title_fn,
196
+ outcome_fn=outcome_fn)
197
+
198
+
199
+ _MAX_RECORD_VALUE_BYTES = 256 * 1024 # per-value disk safety valve (one pathological output)
200
+
201
+
202
+ class HippocampusMixin:
203
+ """The durable episodic-cache STORAGE side (lossless turn log on disk). Mixed into MememMemory
204
+ (memory.py) alongside NeocortexMixin (neocortex.py) — `self` at runtime is a concrete MememMemory
205
+ instance, so `self._vault`/`self._idx_lock` (set by MememMemory.__init__) resolve normally via the
206
+ MRO. This is the counterpart EpisodeSink (above) writes through and recall_history's handler
207
+ (below, via `memory.read_episodes`) reads through."""
208
+
209
+ def _clamp(self, v):
210
+ if isinstance(v, str) and len(v.encode("utf-8")) > _MAX_RECORD_VALUE_BYTES:
211
+ b = v.encode("utf-8"); h = _MAX_RECORD_VALUE_BYTES // 2 # slice on BYTES (cap is a byte budget);
212
+ # errors="replace" (not "ignore"): a byte cut mid-multibyte-char marks it U+FFFD instead of
213
+ # silently deleting bytes — visible, lossless-ish boundary rather than a quiet corruption.
214
+ head = b[:h].decode("utf-8", "replace"); tail = b[-h:].decode("utf-8", "replace")
215
+ return redact_text(head + f"\n…[truncated {len(v)} chars]…\n" + tail)
216
+ if isinstance(v, str):
217
+ return redact_text(v) # (c) redact every persisted episodic string on its way to the cache
218
+ # #35: recurse into structured (non-string) tool outputs so str leaves inside a dict/list are
219
+ # still byte-bounded + redacted — a huge or secret-bearing nested payload can't slip past.
220
+ if isinstance(v, dict):
221
+ return {k: self._clamp(x) for k, x in v.items()}
222
+ if isinstance(v, (list, tuple)):
223
+ return [self._clamp(x) for x in v]
224
+ return v
225
+
226
+ def _clamp_record(self, rec: dict) -> dict:
227
+ # Redact + byte-bound EVERY string leaf of the WHOLE record, not just steps. Earlier this clamped
228
+ # only steps[*].observation/action — so the top-level title/note/markdown/meta (markdown is rendered
229
+ # from the RAW steps + note) reached the durable cache UNREDACTED and could be surfaced by
230
+ # recall_history. _clamp recurses through dict/list, so one call covers the entire record uniformly.
231
+ return self._clamp(rec)
232
+
233
+ def append_episode(self, session_id: str, task_id: str, turn: int, record: dict) -> None:
234
+ try:
235
+ d = os.path.join(self._vault, "episodic")
236
+ os.makedirs(d, exist_ok=True)
237
+ ts = now_iso()
238
+ clamped = self._clamp_record(record)
239
+ line = {"v": 1, "session_id": session_id, "task_id": task_id, "turn": turn,
240
+ "ts": ts, "record": clamped}
241
+ with open(os.path.join(d, f"{session_id}.jsonl"), "a", encoding="utf-8") as f:
242
+ # #36: default=str — a non-serializable value in a tool output must STRINGIFY, never raise
243
+ # and silently drop the whole turn (the except below would eat it = lost episode + index).
244
+ f.write(json.dumps(line, ensure_ascii=False, default=str) + "\n")
245
+ except Exception:
246
+ return # a cache write must never break a session
247
+ self._index_episode(session_id, task_id, turn, ts, clamped) # additive FTS5 mirror (item 12)
248
+
249
+ # --- cross-session FTS5 episode index (item 12; additive, degrades to no-op) ---
250
+ def _episode_index(self):
251
+ """Lazily open the FTS5 episode index (cached). Returns None if FTS5 is
252
+ unavailable — every caller treats None as 'index off' and falls back."""
253
+ idx = getattr(self, "_idx", "unset")
254
+ if idx != "unset":
255
+ return idx # fast path: already opened (lock-free)
256
+ with self._idx_lock: # double-checked: exactly ONE connection is opened + tracked by close()
257
+ idx = getattr(self, "_idx", "unset")
258
+ if idx == "unset":
259
+ try:
260
+ from .search_index import make_episode_index
261
+ idx = make_episode_index()
262
+ if not idx.is_active:
263
+ idx = None
264
+ except Exception:
265
+ idx = None
266
+ self._idx = idx
267
+ return idx
268
+
269
+ def close(self) -> None:
270
+ """#33: close the cached FTS5 episode-index connection (WAL checkpoint + release the fd) at
271
+ session end. Idempotent — safe before the index was ever opened ('unset') or after close."""
272
+ idx = getattr(self, "_idx", None)
273
+ if idx is not None and idx != "unset":
274
+ try:
275
+ idx.close()
276
+ except Exception: # noqa: BLE001
277
+ pass
278
+ self._idx = None
279
+
280
+ def _index_episode(self, session_id, task_id, turn, ts, record) -> None:
281
+ idx = self._episode_index()
282
+ if idx is None:
283
+ return
284
+ try:
285
+ from .search_index import episode_searchable_text
286
+ idx.index_episode(session_id=session_id, task_id=task_id, turn=turn, ts=ts,
287
+ title=record.get("title", ""), note=record.get("note", ""),
288
+ text=episode_searchable_text(record))
289
+ except Exception:
290
+ pass
291
+
292
+ def search_episodes(self, query: str, *, limit: int = 5,
293
+ exclude_session: str | None = None,
294
+ only_session: str | None = None) -> list[dict]:
295
+ """Episode discovery (FTS5). `exclude_session` => cross-session recall; `only_session` =>
296
+ within-session content recall of the long tail (turns past the manifest/index window).
297
+ Returns bounded hit dicts (see search_index.EpisodeIndex.search) or [] when unavailable."""
298
+ idx = self._episode_index()
299
+ if idx is None:
300
+ return []
301
+ try:
302
+ return idx.search(query, limit=limit, exclude_session=exclude_session,
303
+ only_session=only_session)
304
+ except Exception:
305
+ return []
306
+
307
+ def read_episodes(self, session_id: str, *, limit: int | None = None) -> list[dict]:
308
+ """Read the session's episodic cache (the read side of the recall_history tool). Returns the
309
+ raw line dicts in turn order; `limit` keeps only the most recent N. Never raises."""
310
+ try:
311
+ path = os.path.join(self._vault, "episodic", f"{session_id}.jsonl")
312
+ if not os.path.exists(path):
313
+ return []
314
+ # limit set → keep only the last N parsed dicts via a bounded deque (don't hold the whole
315
+ # session in memory just to slice the tail); limit unset (consolidate) reads all by design.
316
+ out = deque(maxlen=limit) if limit is not None else [] # limit=0 ⇒ deque(maxlen=0) keeps ZERO (was: read all)
317
+ with open(path, encoding="utf-8") as f:
318
+ for ln in f:
319
+ ln = ln.strip()
320
+ if ln:
321
+ try:
322
+ out.append(json.loads(ln))
323
+ except ValueError:
324
+ continue
325
+ return list(out)
326
+ except Exception:
327
+ return []
328
+
329
+ def episode_manifest(self, session_id: str, k: int) -> tuple[list[dict], int]:
330
+ """(last_k_dicts, total_count) for the PAGED-OUT HISTORY manifest. Reads only the file TAIL and
331
+ parses only ~k records, so the per-turn slice build stays O(k) instead of re-parsing the whole
332
+ session JSONL every turn (which was O(n²) over a long session). Never raises."""
333
+ try:
334
+ path = os.path.join(self._vault, "episodic", f"{session_id}.jsonl")
335
+ if not os.path.exists(path):
336
+ return [], 0
337
+ size = os.path.getsize(path)
338
+ total = 0
339
+ window = min(size, max(65536, (k + 1) * 4096))
340
+ with open(path, "rb") as f:
341
+ for line in f: # cheap newline count (no JSON parse) for the '…older' flag
342
+ if line.strip():
343
+ total += 1
344
+ f.seek(max(0, size - window))
345
+ tail = f.read()
346
+ rows = tail.splitlines()
347
+ if size > window and rows:
348
+ rows = rows[1:] # the window may start mid-line → drop the partial leader
349
+ out: list[dict] = []
350
+ for ln in rows:
351
+ ln = ln.strip()
352
+ if not ln:
353
+ continue
354
+ try:
355
+ out.append(json.loads(ln.decode("utf-8", "replace")))
356
+ except ValueError:
357
+ continue
358
+ return out[-k:], total
359
+ except Exception:
360
+ return [], 0
361
+
362
+
363
+ INDEX_LIMIT = 40 # breadcrumbs shown by the bare index (a LOCATOR bound — titles/notes, not content)
364
+ OBS_TAIL = 300 # legacy-record fallback ONLY: per-observation tail when there is no stored markdown
365
+ DISTINCT_PER_TURN = 8 # backstop on DISTINCT turn-fetches per turn; repeats are redirected for free
366
+ # NO read-side CONTENT cap: a fetched turn is returned IN FULL. The bound is the SEAL, not a second cut at
367
+ # read — the archive already excerpts observations at SAVE time (_obs_excerpt above), a fetched turn is
368
+ # TRANSIENT (enters context for this loop only, never written back to slice state, so recall can't rebuild
369
+ # the transcript across loops), and the physical context window + overflow is the size backstop for a
370
+ # deliberate sweep. The old 4000/8000 caps cut the distilled CONCLUSION — the one thing recall exists to
371
+ # return — because the conclusion is appended LAST in the markdown (bound = the seal, not a within-loop cut).
372
+
373
+ CAPTURE_BACK = ("\n\n↳ Now in context. Record what you need with a `note`, then continue — and "
374
+ "fetch another turn from PAGED-OUT HISTORY whenever you need more.")
375
+
376
+
377
+ def _sig(args: dict):
378
+ """Identity of a fetch, so an exact REPEAT can be redirected (the loop) while DISTINCT fetches
379
+ (a real search — each returns new info) are allowed."""
380
+ if args.get("turns"):
381
+ turns = args["turns"]
382
+ if isinstance(turns, (str, int)):
383
+ turns = [turns] # a scalar/string turn id is ONE number, not a char/digit iterable
384
+ nums = set()
385
+ for t in turns:
386
+ try:
387
+ nums.add(int(t))
388
+ except (TypeError, ValueError):
389
+ pass
390
+ return ("turns", frozenset(nums), bool(args.get("full")))
391
+ if args.get("last"):
392
+ try:
393
+ return ("last", int(args["last"]), bool(args.get("full")))
394
+ except (TypeError, ValueError):
395
+ return ("last", 5, bool(args.get("full"))) # MATCH the handler's non-numeric fallback (n=5) — else
396
+ # a malformed `last` records sig ('index',) and poisons
397
+ # the real index-fetch slot for the rest of the turn
398
+ return ("index",)
399
+
400
+
401
+ def _short_ts(ts: str) -> str:
402
+ return format_ts(ts) # "06-16 12:30"
403
+
404
+
405
+ def _tail(s: str, n: int) -> str:
406
+ s = s or ""
407
+ return s if len(s) <= n else "…" + s[-n:]
408
+
409
+
410
+ def render_index(lines: list[dict]) -> str:
411
+ from .finding_types import badge, classify_finding # item 14a: typed note badge in the index
412
+ out = ["# CACHED HISTORY (index — fetch a turn with recall_history(turns=[N]) or last=N)"]
413
+ for ln in lines:
414
+ rec = ln.get("record", {})
415
+ meta = rec.get("meta", {})
416
+ failing = bool(meta.get("failing"))
417
+ flag = " FAIL" if failing else ""
418
+ nsteps = len(rec.get("steps", []))
419
+ title = rec.get("title") or "(no title)"
420
+ note = rec.get("note") or ""
421
+ # type the breadcrumb's note so the model scans by KIND (decision / ruled-out / …)
422
+ edited = bool(meta.get("files"))
423
+ tag = badge(classify_finding(note, edited=edited, had_error=failing,
424
+ resolved=not failing and meta.get("stop_reason") == "end_turn"))
425
+ out.append(f"- turn {ln.get('turn')} · {_short_ts(ln.get('ts',''))} · [{ln.get('task_id','')}] "
426
+ f"{title[:60]} · {nsteps}st{flag}" + (f" — {tag}{note[:80]}" if note else ""))
427
+ return "\n".join(out)
428
+
429
+
430
+ def render_trace(lines: list[dict]) -> str:
431
+ """Page sealed turns back as their clean MARKDOWN snapshot (the seal artifact) — returned IN FULL, no
432
+ read-side size cap (see the constants note: the bound is the seal + transience, not a second read cut;
433
+ a cap here truncated the distilled conclusion at the markdown tail). Falls back to a computed
434
+ action→result trace for older records that predate the stored markdown (per-observation tail only — a
435
+ legacy raw-obs guard; the conclusion/note is kept whole)."""
436
+ from .tool_summary import summarize_tool_result # fallback path only
437
+ out = []
438
+ for ln in lines:
439
+ rec = ln.get("record", {})
440
+ head = f"\n── turn {ln.get('turn')} · {_short_ts(ln.get('ts',''))} · {rec.get('title') or ''}"
441
+ md = rec.get("markdown")
442
+ if md: # the SEAL artifact — return it directly, in full
443
+ out.append(head + "\n" + md)
444
+ else: # older record without a stored markdown → compute a trace
445
+ block = [head]
446
+ for st in rec.get("steps", []):
447
+ for a, o in zip(st.get("action", []), st.get("observation", [])):
448
+ summary = summarize_tool_result(a.get("name", ""), a.get("args", {}), o,
449
+ failing=bool(a.get("failing")))
450
+ block.append(f" • {summary} → {_tail(o, OBS_TAIL)}")
451
+ if rec.get("note"):
452
+ block.append(f" ↳ note: {rec['note']}") # conclusion in full
453
+ out.append("\n".join(block))
454
+ return "\n".join(out).strip() or "(no trace)"
455
+
456
+
457
+ def render_full(lines: list[dict]) -> str:
458
+ out = []
459
+ for ln in lines:
460
+ rec = ln.get("record", {})
461
+ slices = [st.get("slice", "") for st in rec.get("steps", []) if st.get("slice")]
462
+ body = (slices[-1] if slices else "") # the turn's last reconstructed slice = its end state
463
+ note = rec.get("note") or ""
464
+ chunk = f"\n══ turn {ln.get('turn')} · {_short_ts(ln.get('ts',''))} · {rec.get('title') or ''}\n{body}"
465
+ if note: # the agent's REPLY/conclusion lives in the note, NOT the seed
466
+ chunk += f"\n\n## conclusion\n{note}" # slice — without this, 'full' never returned the findings
467
+ out.append(chunk)
468
+ return "\n".join(out).strip() or "(no slice stored)"
469
+
470
+
471
+ def render_search(mine, cross) -> str:
472
+ """Render a content search. THIS session's matching turns come WITH the exact fetch call — the
473
+ model searched by content and now has the turn number, so the long tail past the manifest/index
474
+ window is reachable without guessing a number. PAST sessions' FTS5 hits follow as read-only
475
+ context (no in-session turn to fetch)."""
476
+ out = []
477
+ if mine:
478
+ out.append("# THIS SESSION — content matches (page the full turn with the call shown)")
479
+ for r in mine:
480
+ out.append(f"- turn {r.handle}: {r.preview} → recall_history(turns=[{r.handle}])")
481
+ if cross:
482
+ out.append("# CROSS-SESSION RECALL (past sessions — FTS5 over the durable episode index)")
483
+ for r in cross:
484
+ out.append(f"- [{r.handle}] {r.preview}")
485
+ return "\n".join(out) if out else "No content matches found."
486
+
487
+
488
+ def make_history_tool(memory, session_id: str):
489
+ """ToolEntry for recall_history, reading `memory`'s episodic cache for this session.
490
+
491
+ Guardrail reins on REPETITION, not count — so a genuine search (distinct fetches, each returning
492
+ new info) is never blocked, only the useless loop (re-fetching the same thing) is. An exact repeat
493
+ gets a one-line redirect (no re-dump); distinct turn-fetches are allowed up to a generous backstop,
494
+ past which the message points back at the cheap INDEX (which already lists every turn's title+note)
495
+ rather than hard-blocking. Turn boundaries need no plumbing: the cache grows one record per turn,
496
+ so a change in episode count resets the rein. The index fetch is free (the locator); only data
497
+ drills (turns=/last=) count toward the backstop. Each served result carries a capture-back nudge."""
498
+ from .pagetable import PageTable
499
+ from .registry import ToolEntry
500
+ _guards: dict = {} # thread_id -> rein state; parallel explorers share this closure → isolate per thread
501
+ # The ONE cross-session read path: PageTable's episode-xsession backend wraps
502
+ # memory.search_episodes (the this-session read_episodes drill stays in this handler).
503
+ pages = PageTable(memory=memory, session_id=session_id)
504
+
505
+ def _handler(args: dict) -> str:
506
+ guard = _guards.setdefault(threading.get_ident(), {"seen": -1, "served": set(), "distinct": 0})
507
+ # content-search shape: search=... runs FTS5 over THIS session's long tail (turns past the
508
+ # manifest/index window — reachable by content, not just by a turn number nobody knows) AND
509
+ # past sessions. Checked first, no rein (each query is a real search returning new info).
510
+ q = args.get("search")
511
+ if isinstance(q, str) and q.strip():
512
+ mine = pages.lookup(q.strip(), kind="episode-search-thissession", k=6)
513
+ cross = pages.lookup(q.strip(), kind="episode-xsession", k=6)
514
+ if not mine and not cross:
515
+ return ("No content matches in this or past sessions for that query. Try different "
516
+ "keywords, or recall_history() (no args) for this session's full index.")
517
+ return render_search(mine, cross) + CAPTURE_BACK
518
+ lines = memory.read_episodes(session_id)
519
+ if len(lines) != guard["seen"]: # cache grew (or first call) → new turn → reset rein
520
+ guard["seen"] = len(lines)
521
+ guard["served"] = set()
522
+ guard["distinct"] = 0
523
+ if not lines:
524
+ return "No cached history yet (this is an early turn)."
525
+ sig = _sig(args)
526
+ if sig in guard["served"]: # exact repeat → redirect, don't re-dump (kills the loop)
527
+ return ("You already pulled this earlier this turn (it's above). Fetch a DIFFERENT turn, "
528
+ "use last=N to sweep several at once, or act on what you have (record a note).")
529
+ is_drill = sig[0] != "index"
530
+ if is_drill and guard["distinct"] >= DISTINCT_PER_TURN: # examined plenty → point at the index
531
+ return (f"You've examined {guard['distinct']} different turns this turn. Use the index "
532
+ "(recall_history() — titles + notes for ALL turns) to pinpoint the right one, then "
533
+ "fetch just that turn — or proceed with what you have.")
534
+ turns, last, full = args.get("turns"), args.get("last"), bool(args.get("full"))
535
+ if not turns and not last:
536
+ guard["served"].add(sig)
537
+ return render_index(lines[-INDEX_LIMIT:]) + CAPTURE_BACK
538
+ if turns:
539
+ if isinstance(turns, (str, int)):
540
+ turns = [turns] # a scalar/string turn id is ONE number, never split into its digits ("23"→23)
541
+ want = set()
542
+ for t in turns:
543
+ try:
544
+ want.add(int(t))
545
+ except (TypeError, ValueError):
546
+ pass
547
+ sel = [ln for ln in lines if ln.get("turn") in want]
548
+ else:
549
+ try:
550
+ n = int(last)
551
+ except (TypeError, ValueError):
552
+ n = 5 # a non-numeric `last` must not raise — fall back to a small recent window
553
+ sel = lines[-max(1, n):] # clamp: a negative `last` would slice a too-broad window
554
+ if not sel:
555
+ return "No matching turns. Call recall_history() with no args for the index."
556
+ guard["served"].add(sig)
557
+ guard["distinct"] += 1
558
+ return (render_full(sel) if full else render_trace(sel)) + CAPTURE_BACK
559
+
560
+ schema = {"type": "function", "function": {
561
+ "name": "recall_history",
562
+ "description": (
563
+ "Page an earlier turn of THIS session back into context — normal navigation, since the slice "
564
+ "is bounded. The PAGED-OUT HISTORY section of your slice lists each earlier turn's number, "
565
+ "title and note WITH the exact call to fetch it: copy that — {\"turns\":[N,...]} for the "
566
+ "turn's actions/observations/notes (add {\"full\":true} for its full stored state), or "
567
+ "{\"last\":N} for the most recent N. Call with NO args for the full index of turns older than "
568
+ "the manifest. To find an old turn by CONTENT (this session or past ones) when you don't know "
569
+ "its number, {\"search\":\"keywords\"} (FTS5 — AND/OR/quoted/prefix*). Reach back whenever an "
570
+ "earlier turn holds something you need instead of re-deriving it; record what you find with a note."),
571
+ "parameters": {"type": "object", "properties": {
572
+ "last": {"type": "integer", "description": "fetch the most recent N turns (this session)"},
573
+ "turns": {"type": "array", "items": {"type": "integer"},
574
+ "description": "fetch these specific turn numbers (from the index, this session)"},
575
+ "full": {"type": "boolean", "description": "return the full stored slice instead of the compact trace"},
576
+ "search": {"type": "string",
577
+ "description": "Content search (FTS5) over THIS session's earlier turns AND past "
578
+ "sessions — find an old turn by what it was ABOUT when you don't know "
579
+ "its number; this-session matches come with the call to page them back"},
580
+ }}}}
581
+ return ToolEntry(name="recall_history", schema=schema, handler=_handler, source="builtin")