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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- 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")
|