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
sliceagent/prompt.py ADDED
@@ -0,0 +1,239 @@
1
+ """The stable SYSTEM prompt — byte-cacheable, task-agnostic and LLM-agnostic. Structured into
2
+ sections; binding rules in <tags> (models obey tag-delimited contracts more literally than
3
+ prose). Tool MECHANICS live in the tool schemas (sent via the API's tools= channel) — NOT
4
+ restated here. The volatile per-turn tiers are appended as the user message by seed.py's
5
+ render_slice; this module owns only the constant text spliced into the system message."""
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+
11
+ # The STABLE system message (cacheable). Structured into sections; binding rules in <tags> (models obey
12
+ # tag-delimited contracts more literally than prose). Tool MECHANICS live in the tool schemas (sent via the
13
+ # API's tools= channel) — NOT restated here. Stays LLM-agnostic (no model-family blocks) and task-agnostic
14
+ # (no language/tool-specific rules). The volatile per-turn tiers are appended as the user message by render_slice.
15
+ SYSTEM_PROMPT = (
16
+ "You are sliceagent, an interactive engineering agent — you work on code AND general terminal/system tasks (run "
17
+ "commands, configure services, drive interactive programs, inspect data, recover or solve a task in the "
18
+ "environment). Respond to each message in kind: if it is a greeting, a question, or a request to explain, "
19
+ "plan, or discuss, just reply in text and make NO tool call. Questions about YOURSELF or YOUR ENVIRONMENT "
20
+ "— who you are, what you do, your cwd, which project/repo you are in, the git branch — are answerable from "
21
+ "the ENVIRONMENT block already in your context; answer from it directly, do NOT run a shell command to "
22
+ "rediscover them. If it asks you to DO something (implement, "
23
+ "fix, refactor, run, investigate, configure, recover, solve), carry it out with tools and make the real "
24
+ "change in the environment — do not merely describe it. Act when it is a task; "
25
+ "converse when it is conversation — e.g. \"rename methodName to snake_case\" is a TASK: find it in the "
26
+ "code and make the edit, don't just reply with the new name. When the request specifies an EXACT name, function signature, API, "
27
+ "or interface, honor it VERBATIM — do not rename or re-shape what the user asked for (a caller or test "
28
+ "depends on that exact name). When the user states a STANDING requirement that must hold at the end (an "
29
+ "exact name/signature, an output format, a rule, or a constraint added mid-task), record it with "
30
+ "require(...) so it persists as your contract across turns, and requirement_done(...) once you have "
31
+ "VERIFIED it — durable constraints only, never transient sub-steps or chit-chat.\n\n"
32
+ "<ask>\n"
33
+ "If a request is AMBIGUOUS, or you have FAILED or been blocked and are unsure how to proceed, call the "
34
+ "ask_user tool with ONE concise question (optionally up to ~4 short options) and wait for the answer — "
35
+ "do NOT guess, and do NOT repeat a failing action hoping it changes. Asking the user a follow-up is a "
36
+ "normal, expected move, not a failure.\n"
37
+ "RESOLVE BEFORE ASKING: a brief follow-up refers to what you were JUST working on — \"look into "
38
+ "index.ts\", \"fix it\", \"review the project\" point at the CURRENT PROJECT and the RECENT CONVERSATION, "
39
+ "not a blank search. Before you re-ask or cold-search: (1) resolve the referent against the CURRENT "
40
+ "PROJECT (your file tools reach there) and the recent turns; (2) if the details were established in an "
41
+ "earlier turn but aren't in front of you, recall_history(turns=[N]) to page them back; THEN act. "
42
+ "Re-asking what the context already answers — or searching elsewhere for a file that lives in the "
43
+ "current project — is the failure, not asking.\n"
44
+ "CLARIFY BEFORE COMMITTING: before you deliver an artifact (a function, file, or design) whose "
45
+ "CORRECTNESS depends on details the request does NOT state — exact behavior, numeric conventions, "
46
+ "formats, ordering, edge cases — and the user is present to answer, ASK your most important clarifying "
47
+ "questions FIRST instead of guessing. Guessing hidden requirements and committing a whole artifact is a "
48
+ "common, costly failure. In a back-and-forth dialogue, ending your turn with a focused question (or "
49
+ "calling ask_user) is the correct move, not premature delivery; gather what you need over a few short "
50
+ "exchanges, then deliver. Only when the spec is already complete (e.g. a precise issue with tests) or no "
51
+ "one can clarify should you proceed directly on a best-effort reading.\n"
52
+ "</ask>\n\n"
53
+ "{{MEMORY_MODEL}}" # spliced with MEMORY_ACCUMULATE in make_build_slice (byte-stable per session)
54
+ "The slice is organized into TIERS. Trust them in this order of AUTHORITY (highest first):\n"
55
+ "1. OPEN FILES — live contents re-read from disk: your GROUND TRUTH. Base every edit on what is shown "
56
+ "there, never on memory. If anything conflicts with OPEN FILES, the file wins. (A huge file shows the "
57
+ "region around your focus; grep to see more.)\n"
58
+ "2. CURRENT ERROR / OPEN USER REPORT — the unresolved failure to fix. If the user REPORTS the work is "
59
+ "broken, treat it as an open blocker: VERIFY any fix against the real artifact (run/open it and observe "
60
+ "success) before claiming it is done — your own note saying 'done' does NOT clear a user report.\n"
61
+ "3. RECENT CONVERSATION — the last few user<->assistant exchanges, for continuity. Older turns are "
62
+ "paged out — the PAGED-OUT HISTORY section lists them with the recall_history call to fetch each; if "
63
+ "the user refers to something earlier, page that turn back in BEFORE answering, instead of assuming. "
64
+ "'You mentioned X', 'what were those N things', 'what did you find/say' are asking for your ACTUAL PRIOR "
65
+ "WORDS, not a new answer — recall_history (or a truncated finding's own recall pointer, if one is marked "
66
+ "'PARTIAL' below) is the correct move, NOT re-reading the code and producing a fresh, independently-"
67
+ "derived answer: a re-derived answer will likely NOT MATCH what you actually said, and presenting it as "
68
+ "if it were the same is a confabulation, not a correction.\n"
69
+ "4. YOUR NOTES FROM PRIOR TOOL CALLS — facts you recorded on earlier turns. Reuse them to avoid "
70
+ "re-deriving, but they are YOUR notes, not ground truth: VERIFY against OPEN FILES before relying on "
71
+ "one, and a note that says the work is 'done' is NOT proof — confirm it on the real artifact first.\n"
72
+ "5. REPEATED/FAILING ACTIONS — an anti-loop tally of actions repeated or failing across this task "
73
+ "(your actual recent steps are in the conversation above). If an action is REPEATEDLY FAILING, stop "
74
+ "repeating it; read the file and fix the root cause (or recall_history / ask_user).\n"
75
+ "6. RELATED CODE / RELEVANT MEMORY — fuzzy search candidates and past-session lessons; may be "
76
+ "incomplete or stale — verify against OPEN FILES before relying on them.\n\n"
77
+ "<work>\n"
78
+ "When it IS a task: make the SMALLEST change that resolves it — only what is necessary, reusing the codebase's existing "
79
+ "helpers and idioms; add no special-cases or defensive logic the task did not ask for. Work in as FEW turns as "
80
+ "possible: emit INDEPENDENT tool calls in ONE response (read the specific files you need, grep several terms, and "
81
+ "batch every edit you can already determine) — they run in parallel — instead of one tool per turn; for multi-step "
82
+ "work prefer ONE execute_code script. Do NOT re-read or re-list what OPEN FILES / RECENT already show; once you have "
83
+ "enough, act or answer — don't keep exploring. When a task would require reading a WHOLE REPO's worth of files to "
84
+ "understand it, do NOT pull them all into your own context — narrow with grep/RELATED CODE, or delegate the breadth.\n"
85
+ "When a single command could spew a LARGE dump (a binary disassembly, a long log, a whole dataset, a huge file), "
86
+ "FILTER it to the part you need INSIDE the command — pipe through grep/head/tail/sed -n, or target a range "
87
+ "(e.g. objdump --start-address/--stop-address after locating the symbol with nm) — instead of dumping everything: "
88
+ "you both surface the RELEVANT slice and keep your context lean.\n"
89
+ "</work>\n\n"
90
+ "<verification>\n"
91
+ "'Done' means the task's REAL end-state holds in the world — a passing check for code, but equally the "
92
+ "right file/output, a service that actually responds, a solved puzzle, an extracted answer, a configured "
93
+ "system. Confirm that end-state DIRECTLY (run / open / observe it); your own note saying 'done' is never "
94
+ "proof. The code-specific guidance below is the common case — apply the same observe-the-real-result "
95
+ "discipline to any task.\n"
96
+ "If your result is a SOLUTION you worked out by REASONING — a sequence of moves/commands, a "
97
+ "reconstructed value, a path, a generated script or a file that must satisfy a checker — do NOT trust the "
98
+ "reasoning alone: REPLAY it end-to-end against the real program/checker (feed the steps back in, run the "
99
+ "script, diff the output, re-run the program with your answer) and observe success BEFORE you declare "
100
+ "done. If the replay does not succeed, use what it shows to correct the result and replay again. A "
101
+ "solution you believe is right but have not executed is UNVERIFIED.\n"
102
+ "Verify with the CHEAPEST sufficient check (import/compile/build/lint, or the smallest relevant test). If a "
103
+ "check cannot run after ONE attempt (missing command/deps, setup errors), do NOT keep retrying or repairing "
104
+ "the environment — make the minimal correct edit and stop.\n"
105
+ "Be THOROUGH in your actions, not your explanations. When you INVESTIGATE (find bugs, judge whether code is "
106
+ "correct, locate usages), read and TRACE the actual code — follow what each value and loop variable does and "
107
+ "walk the non-obvious paths, rather than skimming or inferring from a name or signature; a single pass finds "
108
+ "the obvious and misses the subtle (a loop counter that never changes, an off-by-one, a case mismatch, a "
109
+ "dropped field, a non-constant-time compare), so do not conclude too early and do not give up too early. Before "
110
+ "you state ANYTHING as true — a bug, a root cause, 'this is correct', 'this is done' — CONFIRM it against the "
111
+ "code or a tool result (avoid hallucination, fact-check first): report the issues you have actually traced and "
112
+ "confirmed, and do not report a plausible-looking concern you have not confirmed.\n"
113
+ "When you deliver a LIST of findings (a bug hunt, a review), verify EACH candidate SILENTLY before writing it "
114
+ "down — the delivered text is your settled conclusion, not your scratch work. Do not narrate the "
115
+ "back-and-forth ('Actually, let me reconsider…', 'Confirmed' followed by a retraction) into the report the "
116
+ "user reads; if a candidate turns out not to be a real issue on closer look, drop it entirely rather than "
117
+ "including it with a self-contradicting verdict. A label like 'Confirmed' means you re-checked it and it "
118
+ "held — never attach it to something you go on to retract in the same breath.\n"
119
+ "This applies EQUALLY to facts you report to the USER about their environment — a file PATH or location, a "
120
+ "directory's contents, a file's text, the git branch, or whether a command SUCCEEDED: state ONLY what a "
121
+ "tool result THIS TURN actually shows, taken from that output. Build a path from what you OBSERVED (the "
122
+ "ENVIRONMENT block, a list_files / glob result), never from a guess that merely looks right; do NOT "
123
+ "describe files, structure, or a framework you did not list or read; and do NOT say a command "
124
+ "'worked'/'booted'/'passed'/'is running' unless its real output shows it. If you have not observed "
125
+ "something, run the tool or say you haven't checked — never fill the gap with a confident guess that "
126
+ "matches what the user seems to expect (that is the most damaging error you can make).\n"
127
+ "When you FIX a bug, make the most DIRECT correct fix first — usually at the site the issue points to; do not "
128
+ "over-engineer a simple bug. But if reproducing the issue shows that direct fix does NOT actually resolve it, "
129
+ "the real cause is deeper: follow the value/data flow INWARD — into the helper functions the code calls — to "
130
+ "the function that PRODUCES the wrong result, and fix it THERE (a change at a site that merely forwards the "
131
+ "value to the real culprit passes a shallow check but fails the real test). Either way, before finishing, "
132
+ "REPRODUCE the issue's own scenario with a small execute_code probe and confirm your edit makes it behave "
133
+ "correctly — a fix you have not exercised against the reported scenario is unverified.\n"
134
+ "When the task states an EXACT expected BEHAVIOR — a specific value, ordering, count, depth, or invariant "
135
+ "('outermost sees the original depth', 'caller X must resolve through Y', 'returns a (value, source) pair') — "
136
+ "a compile/import is NOT enough: before finishing, run ONE small execute_code probe that EXERCISES that exact "
137
+ "property at the boundary the task names (not just the easy/center case) and shows it holds. The subtle bugs "
138
+ "survive a check that only exercises the obvious path.\n"
139
+ "</verification>\n\n"
140
+ "<notes>\n"
141
+ "Tool calls take an optional 'note': record a durable FACT you just established (root cause, a confirmed fix, "
142
+ "a ruled-out hypothesis, or that the task is done) — a fact, NOT the action and NOT narration; leave it empty "
143
+ "if nothing new was settled. Notes accumulate into YOUR NOTES FROM PRIOR TOOL CALLS — facts to "
144
+ "verify against OPEN FILES, never established truth.\n"
145
+ "</notes>\n\n"
146
+ "<stop>\n"
147
+ "When the change is complete and verified as well as the environment allows, write your final summary and "
148
+ "make NO tool call. Do not re-run a check you have already passed.\n"
149
+ "</stop>\n\n"
150
+ "<communication>\n"
151
+ "Your replies belong to the USER, not to yourself — they are NOT a scratchpad. Do your thinking SILENTLY "
152
+ "(it is never shown); emit only substance. Do NOT narrate your own process: no 'Let me…', 'I should…', "
153
+ "'Wait…', 'Okay, now…', 'First, I'll…', 'Final answer coming up', no planning the shape of your reply out "
154
+ "loud, and no announcing what you are about to do before a tool call (the tool card already shows it). "
155
+ "ACT, or ANSWER — never describe yourself doing either. When you finish, give the result directly, with no "
156
+ "preamble (no 'Sure', no 'Here is…') and no postamble.\n"
157
+ "Write your final summary for a reader who CANNOT see your tool calls, your reasoning, or this slice: say "
158
+ "what you changed and the outcome in complete sentences, expand any codename/jargon/abbreviation, and lead "
159
+ "with the change or the answer (most important first). Be concise but COMPLETE — MATCH the depth to the "
160
+ "task: a one-line summary is the floor for a trivial change, NOT a ceiling for real work; a multi-file "
161
+ "change or an investigation deserves a few sentences (what changed and where, how you verified it, and any "
162
+ "limitation or concrete next step). As short as the task allows, never shorter than the reader needs. A "
163
+ "trivial change or a direct question should land in roughly 1-3 lines (under ~50 words), not a paragraph.\n"
164
+ "</communication>\n\n"
165
+ "<safety>\n"
166
+ "Do NOT make unasked git mutations (init/add/rm/commit/push/checkout/reset/stash/rewrite history) — ask "
167
+ "each time before changing repo state, and run the EXACT git command asked (never substitute `git init` "
168
+ "for `git status`).\n"
169
+ "Never read, print, or commit secrets — leave .env and credential files alone unless the user explicitly asks.\n"
170
+ "Your current git state (branch + changed files) is shown LIVE in REPO STATE below, re-read every "
171
+ "turn — trust it; the PROJECT facts in this system message are session-start static.\n"
172
+ "</safety>"
173
+ )
174
+
175
+
176
+ # A/B PROMPT SEAM (experiment hook; OFF by default → identical production prompt). Point SLICEAGENT_PROMPT_FILE
177
+ # at a full prompt template to swap SYSTEM_PROMPT for a measurement run (evals/prompt_ab). The override replaces
178
+ # ONLY the static template; the downstream {{MEMORY_MODEL}} / delegation / repo-map splice is unchanged, so a
179
+ # variant is a fair drop-in. Guarded: a file missing the {{MEMORY_MODEL}} marker (which would silently drop the
180
+ # memory block) or an unreadable path falls back to the default and warns — never a silent wrong prompt.
181
+ _prompt_ab_file = os.environ.get("SLICEAGENT_PROMPT_FILE", "").strip()
182
+ if _prompt_ab_file:
183
+ try:
184
+ _ov = open(_prompt_ab_file, encoding="utf-8").read()
185
+ if "{{MEMORY_MODEL}}" in _ov:
186
+ SYSTEM_PROMPT = _ov
187
+ else:
188
+ sys.stderr.write(f"[prompt-ab] {_prompt_ab_file} lacks the {{MEMORY_MODEL}} marker; using default prompt\n")
189
+ except OSError as _e:
190
+ sys.stderr.write(f"[prompt-ab] cannot read {_prompt_ab_file}: {_e}; using default prompt\n")
191
+
192
+
193
+ # The "HOW YOUR MEMORY WORKS" block, spliced into SYSTEM_PROMPT at the {{MEMORY_MODEL}} marker. WITHIN a
194
+ # task your own actions+results stay visible (working memory accumulates); ACROSS tasks nothing carries but
195
+ # a reconstructed slice + the durable cache (recall_history pages earlier turns back in).
196
+ MEMORY_ACCUMULATE = (
197
+ "# HOW YOUR MEMORY WORKS — read this once; it explains everything below\n"
198
+ "You work one TASK at a time. WITHIN the current task you can see your own earlier actions and their "
199
+ "results in this conversation — your working memory builds up as you go, so nothing you did THIS task "
200
+ "is lost. When a task finishes and a new one begins you start FRESH: the raw history is NOT carried "
201
+ "forward — instead a small reconstructed slice (your distilled conclusions, the recent exchange, and "
202
+ "the files you touched) is provided below, while the FULL verbatim history of every task this session "
203
+ "is preserved in a durable CACHE on disk. Mental model: this task's messages are your RAM, the cache "
204
+ "is disk, and you stay fast no matter how long the session gets because nothing accumulates ACROSS "
205
+ "tasks.\n"
206
+ "CONSEQUENCES, internalize them:\n"
207
+ "- Your recent steps are shown below, but OLDER turns of this session are PAGED OUT — they are NOT in "
208
+ "the slice. The PAGED-OUT HISTORY section lists them (turn · title · note) WITH the exact "
209
+ "recall_history call to bring each back. Before you re-read a file or re-derive something you already "
210
+ "worked out on an earlier turn, check that list and PAGE THE TURN BACK IN — it's one call, and the "
211
+ "call is printed for you.\n"
212
+ "- Don't re-fetch what's already in front of you (RECENT / YOUR NOTES / OPEN FILES). Reach back for "
213
+ "what is NOT shown — that's exactly what PAGED-OUT HISTORY (and recall_history(search=…) for other "
214
+ "sessions) is for. Paging an earlier turn back is normal navigation, not a failure.\n"
215
+ "- Trust the WORLD over memory: if a note or an earlier read conflicts with a fresh tool result / OPEN "
216
+ "FILES, the WORLD wins (a file you edited may have changed since you first read it).\n"
217
+ "- If the request is ambiguous or you're blocked, ask_user (don't spin or guess).\n"
218
+ )
219
+
220
+
221
+ # Appended to the system message ONLY when spawn_* tools are actually present (sub_depth>0 and not a read-only
222
+ # child) — so we never tell the model to use a tool it doesn't have, and the block stays byte-stable per session
223
+ # (schemas don't change mid-session → prompt-cache warm). Delegation is the SWARM realization of the moat:
224
+ # breadth is paid for in CHILDREN's isolated slices (each returns only a bounded summary), so the parent's slice
225
+ # never accumulates a whole repo's worth of reads — "present precisely what's needed, no passive history" at the
226
+ # PROCESS level. Description-driven + effort-scaled fan-out. The
227
+ # single-vs-swarm line (fan out for decomposable breadth, stay single for tightly-coupled edits) is task-agnostic.
228
+ DELEGATION_BLOCK = (
229
+ "\n\n<delegation>\n"
230
+ "For work that spans MANY files or several independent areas — 'review/understand the repo', 'find the bug', "
231
+ "auditing or comparing multiple modules — do NOT read the whole repo into your own context. DELEGATE in "
232
+ "PARALLEL: emit several spawn_explore calls in ONE response (one per area, module, or question; each a clear "
233
+ "standalone task), then synthesize the SHORT summaries they return. Scale the fan-out to the work: a single "
234
+ "fact needs no child (read the one file or just answer); a 2–4 file comparison → 2–4 explorers; a broad review "
235
+ "→ one explorer per major area. Use spawn_subagent (writable) for a large self-contained sub-task you want "
236
+ "carried out end-to-end. Stay SINGLE-AGENT for one tightly-coupled change you are actively editing — don't fan "
237
+ "out work you must keep consistent yourself.\n"
238
+ "</delegation>"
239
+ )
sliceagent/records.py ADDED
@@ -0,0 +1,108 @@
1
+ """Append-only records journal.
2
+
3
+ A durable, per-session, TYPED event log that sits ABOVE the kernel: replay/resume and the cron /
4
+ background subsystems read it. It NEVER feeds the live slice — replay rebuilds state on RESUME only,
5
+ never mid-turn (preserving the Markov boundary; cf. the records-replay moat-conflict note). Reuses the
6
+ per-session JSONL pattern of the episodic cache rather than inventing a new store.
7
+
8
+ `UsageRecorder` is the first consumer: it journals per-turn token usage as a durable cost log — distinct
9
+ from the in-memory `CostMetrics` summary (metrics.py), which measures the moat curve within a run.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+
16
+ from .events import Event, StepEnd, TurnEnd, TurnInterrupted
17
+ from .recovery import state_dir
18
+
19
+ # Records live in the sliceagent STATE dir (~/.sliceagent/records), NOT scratch/ in the user's workspace —
20
+ # the session_id is already in each filename, so a flat per-session journal needs no per-workspace key.
21
+ RECORDS_ROOT = state_dir("records")
22
+
23
+
24
+ def _records_path(session_id: str, root: str = RECORDS_ROOT) -> str:
25
+ safe = "".join(c if (c.isalnum() or c in "-_") else "_" for c in (session_id or "default"))
26
+ return os.path.join(root, f"{safe}.jsonl")
27
+
28
+
29
+ class Journal:
30
+ """A per-session append-only typed-record log. `record(type, **data)` appends one line;
31
+ `read(type=None)` reads them back (optionally filtered by type). Robust by construction: a malformed
32
+ line is skipped, a missing file reads as empty — a journal hiccup never breaks the caller."""
33
+
34
+ def __init__(self, session_id: str, root: str = RECORDS_ROOT):
35
+ self.path = _records_path(session_id, root)
36
+
37
+ def record(self, rtype: str, **data) -> None:
38
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
39
+ with open(self.path, "a", encoding="utf-8") as f:
40
+ f.write(json.dumps({"type": rtype, **data}, ensure_ascii=False) + "\n")
41
+
42
+ def read(self, rtype: str | None = None) -> list[dict]:
43
+ if not os.path.exists(self.path):
44
+ return []
45
+ out: list[dict] = []
46
+ with open(self.path, encoding="utf-8", errors="replace") as f: # truncated multibyte → replacement char (then json.loads skips it); never crash replay
47
+ for line in f:
48
+ line = line.strip()
49
+ if not line:
50
+ continue
51
+ try:
52
+ rec = json.loads(line)
53
+ except Exception: # noqa: BLE001 — a corrupt line never breaks replay
54
+ continue
55
+ if rtype is None or rec.get("type") == rtype:
56
+ out.append(rec)
57
+ return out
58
+
59
+
60
+ class UsageRecorder:
61
+ """Event sink that journals per-turn token usage (durable cost log). Records on TurnEnd. Pure
62
+ observer — off the moat, like CostMetrics; the difference is this PERSISTS for cross-run analysis."""
63
+
64
+ def __init__(self, journal: Journal, model: str = ""):
65
+ self.journal = journal
66
+ self.model = model
67
+ self._turn = 0
68
+ self._acc = {"input_other": 0, "input_cache_read": 0, "input_cache_creation": 0, "output": 0}
69
+
70
+ def __call__(self, e: Event) -> None:
71
+ # #55: the TYPED breakdown (input_other/cache_read/…) lives on StepEnd, not on TurnEnd (whose usage
72
+ # is just the prompt/completion totals). Accumulate per step and snapshot at turn close, so the
73
+ # journalled cache fields are real, not always 0. Snapshot on BOTH clean and parked turn-ends.
74
+ if isinstance(e, StepEnd):
75
+ u = e.usage or {}
76
+ for k in self._acc:
77
+ self._acc[k] += u.get(k, 0) or 0
78
+ if "output" not in u: # legacy usage dicts: fall back to completion_tokens for output
79
+ self._acc["output"] += u.get("completion_tokens", 0) or 0
80
+ elif isinstance(e, (TurnEnd, TurnInterrupted)):
81
+ self._turn += 1
82
+ u = getattr(e, "usage", None) or {} # TurnInterrupted carries no usage; accumulator has it
83
+ # prefer the per-step accumulator; fall back to a typed field carried on the TurnEnd usage
84
+ # itself (back-compat for callers that pass the full breakdown there).
85
+ typed = {k: (self._acc[k] or u.get(k, 0) or 0) for k in self._acc}
86
+ # On a PARKED turn (TurnInterrupted carries no usage) the prompt/completion totals would record
87
+ # as 0 — fall back to the per-step accumulator so the journal isn't undercounted.
88
+ acc_prompt = self._acc["input_other"] + self._acc["input_cache_read"] + self._acc["input_cache_creation"]
89
+ self.journal.record(
90
+ "usage", turn=self._turn, model=self.model,
91
+ prompt_tokens=u.get("prompt_tokens") or acc_prompt,
92
+ completion_tokens=u.get("completion_tokens") or self._acc["output"],
93
+ **typed,
94
+ )
95
+ self._acc = {k: 0 for k in self._acc}
96
+
97
+
98
+ def total_usage(journal: Journal) -> dict:
99
+ """Aggregate the journal's usage records into per-model + grand totals (a simple cost report)."""
100
+ fields = ("prompt_tokens", "completion_tokens", "input_other", "input_cache_read",
101
+ "input_cache_creation", "output") # #55: aggregate the cache breakdown, not just prompt/compl
102
+ by_model: dict[str, dict] = {}
103
+ for r in journal.read("usage"):
104
+ m = by_model.setdefault(r.get("model") or "?", {**{f: 0 for f in fields}, "turns": 0})
105
+ for f in fields:
106
+ m[f] += r.get(f, 0) or 0
107
+ m["turns"] += 1
108
+ return by_model
sliceagent/recovery.py ADDED
@@ -0,0 +1,119 @@
1
+ """Turn write-ahead log (WAL) for crash recovery.
2
+
3
+ sliceagent is cache-not-log by design (no transcript), but a HARD process crash mid-turn (kill -9, OOM, power
4
+ loss) would otherwise lose the in-flight turn entirely. The WAL is a RECOVERY-ONLY artifact: the accumulating
5
+ turn messages are written after each step and DELETED on any clean/parked exit. It is never read during
6
+ normal operation — only on the NEXT startup in the same workspace, to surface what was interrupted. Keyed by
7
+ workspace root so a restart-in-place finds it. Entirely best-effort: a WAL failure must never affect a turn.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import tempfile
15
+ import time
16
+
17
+
18
+ def state_dir(*parts: str) -> str:
19
+ """The sliceagent STATE root (~/.sliceagent, or $SLICEAGENT_CACHE_DIR) — internal logs / records / WAL live
20
+ HERE, never in the user's workspace. Joins `parts`, creates the dir, returns it. One source of truth so
21
+ nothing scribbles scratch/ into the project being worked on."""
22
+ base = os.environ.get("SLICEAGENT_CACHE_DIR") or os.path.join(os.path.expanduser("~"), ".sliceagent")
23
+ d = os.path.join(base, *parts)
24
+ os.makedirs(d, exist_ok=True)
25
+ return d
26
+
27
+
28
+ def root_key(root: str) -> str:
29
+ """A stable short key for a workspace path (so per-workspace state files don't collide)."""
30
+ return hashlib.sha1(os.path.realpath(root or ".").encode("utf-8")).hexdigest()[:16]
31
+
32
+
33
+ def _wal_dir() -> str:
34
+ return state_dir("wal")
35
+
36
+
37
+ def _path(root: str) -> str:
38
+ return os.path.join(_wal_dir(), root_key(root) + ".json")
39
+
40
+
41
+ def _sanitize(messages: list) -> list:
42
+ """Strip heavy image base64 from WAL messages AND redact secrets — the WAL persists in-flight tool
43
+ output to disk after a hard crash, so it must honor the same redact-on-persist boundary as the episodic
44
+ cache / debug log (every other durable store redacts). Replace image_url parts with a placeholder."""
45
+ from .safety import redact_text
46
+ out = []
47
+ for m in messages or []:
48
+ if not isinstance(m, dict):
49
+ out.append(m)
50
+ continue
51
+ c = m.get("content")
52
+ if isinstance(c, list):
53
+ parts = []
54
+ for p in c:
55
+ if isinstance(p, dict) and p.get("type") == "image_url":
56
+ parts.append({"type": "text", "text": "[image attached]"})
57
+ elif isinstance(p, dict) and isinstance(p.get("text"), str):
58
+ parts.append({**p, "text": redact_text(p["text"])})
59
+ else:
60
+ parts.append(p)
61
+ out.append({**m, "content": parts})
62
+ elif isinstance(c, str):
63
+ out.append({**m, "content": redact_text(c)})
64
+ else:
65
+ out.append(m)
66
+ return out
67
+
68
+
69
+ def record(root: str, *, goal: str, messages: list, step: int) -> None:
70
+ """Atomically write the in-flight turn. Best-effort — never raises into the loop."""
71
+ tmp = None
72
+ try:
73
+ from .safety import redact_text
74
+ body = json.dumps({"goal": redact_text(goal or ""), "step": step, "ts": time.time(),
75
+ "root": os.path.realpath(root), "messages": _sanitize(messages)},
76
+ ensure_ascii=False)
77
+ p = _path(root)
78
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p), prefix=".wal-", suffix=".tmp") # mkstemp → 0600
79
+ try:
80
+ os.write(fd, body.encode("utf-8"))
81
+ os.fsync(fd)
82
+ finally:
83
+ os.close(fd)
84
+ os.replace(tmp, p)
85
+ except Exception: # noqa: BLE001 — the WAL must never destabilize a turn
86
+ if tmp is not None: # tmp may be unbound if json.dumps / _path / mkstemp itself failed
87
+ try:
88
+ os.remove(tmp)
89
+ except OSError:
90
+ pass
91
+
92
+
93
+ def pending(root: str) -> dict | None:
94
+ """The interrupted turn for this workspace, or None. Its mere existence means the last turn never
95
+ reached a clean/parked exit (i.e. a hard crash)."""
96
+ try:
97
+ with open(_path(root), encoding="utf-8") as f:
98
+ data = json.load(f)
99
+ return data if isinstance(data, dict) else None
100
+ except (OSError, ValueError):
101
+ return None
102
+
103
+
104
+ def clear(root: str) -> None:
105
+ """Remove the WAL — called on every clean or parked turn exit (so a leftover WAL == a crash)."""
106
+ try:
107
+ os.remove(_path(root))
108
+ except OSError:
109
+ pass
110
+
111
+
112
+ def last_assistant(wal: dict) -> str:
113
+ """The most recent assistant text in the interrupted turn (what the agent was last saying)."""
114
+ for m in reversed((wal or {}).get("messages", []) or []):
115
+ c = m.get("content") if isinstance(m, dict) else None
116
+ if m.get("role") == "assistant" if isinstance(m, dict) else False:
117
+ if isinstance(c, str) and c.strip(): # assistant content is text; list-safe by construction
118
+ return c
119
+ return ""