sick-agent 0.2.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 (45) hide show
  1. sick/__init__.py +5 -0
  2. sick/agent.py +477 -0
  3. sick/audit.py +102 -0
  4. sick/cli.py +184 -0
  5. sick/config.py +59 -0
  6. sick/design_check.py +66 -0
  7. sick/learn.py +137 -0
  8. sick/lifecycle.py +338 -0
  9. sick/logging_conf.py +53 -0
  10. sick/lsp/__init__.py +1 -0
  11. sick/lsp/server.py +110 -0
  12. sick/memory.py +108 -0
  13. sick/preflight.py +137 -0
  14. sick/providers.py +244 -0
  15. sick/skills.py +106 -0
  16. sick/templates/cli/cookiecutter.json +4 -0
  17. sick/templates/cli/{{cookiecutter.project_slug}}/pyproject.toml +8 -0
  18. sick/templates/fastapi/cookiecutter.json +5 -0
  19. sick/templates/fastapi/{{cookiecutter.project_slug}}/app/main.py +5 -0
  20. sick/templates/fastapi/{{cookiecutter.project_slug}}/pyproject.toml +8 -0
  21. sick/templates/remotion/cookiecutter.json +4 -0
  22. sick/templates/remotion/{{cookiecutter.project_slug}}/src/Root.tsx +1 -0
  23. sick/templates.py +68 -0
  24. sick/tools/__init__.py +12 -0
  25. sick/tools/base.py +86 -0
  26. sick/tools/exec.py +89 -0
  27. sick/tools/files.py +89 -0
  28. sick/tools/git.py +101 -0
  29. sick/tools/pdf.py +67 -0
  30. sick/tools/registry.py +86 -0
  31. sick/tools/research.py +380 -0
  32. sick/tools/search.py +75 -0
  33. sick/tools/self.py +21 -0
  34. sick/tools/web.py +68 -0
  35. sick/tui/__init__.py +8 -0
  36. sick/tui/app.py +556 -0
  37. sick/tui/chat.py +39 -0
  38. sick/tui/commands.py +858 -0
  39. sick/tui/host.py +80 -0
  40. sick/tui/inputbar.py +147 -0
  41. sick_agent-0.2.0.dist-info/METADATA +308 -0
  42. sick_agent-0.2.0.dist-info/RECORD +45 -0
  43. sick_agent-0.2.0.dist-info/WHEEL +4 -0
  44. sick_agent-0.2.0.dist-info/entry_points.txt +3 -0
  45. sick_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
sick/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from sick.agent import SickAgent
2
+ from sick.memory import Experience, ExperienceMemory
3
+ from sick.providers import detect
4
+
5
+ __all__ = ["SickAgent", "detect", "Experience", "ExperienceMemory"]
sick/agent.py ADDED
@@ -0,0 +1,477 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from nooa import Skill, hidden, spec
6
+ from nooa.interactive import InteractiveAgent, RespondReason, RespondResult
7
+
8
+ from sick.audit import AuditLog
9
+ from sick.config import load_config
10
+ from sick.lifecycle import Lifecycle, Phase, Run
11
+ from sick.memory import Experience, ExperienceMemory
12
+ from sick.tools.base import Tool, ToolResult
13
+ from sick.tools.exec import Bash
14
+ from sick.tools.files import EditFile, ReadFile, WriteFile
15
+ from sick.tools.git import Checkpoint, ListCheckpoints, Restore
16
+ from sick.tools.pdf import ParsePdf
17
+ from sick.tools.registry import ToolRegistry
18
+ from sick.tools.research import CodeResearch
19
+ from sick.tools.search import Glob, Grep
20
+ from sick.tools.self import SelfRead, SelfWrite
21
+ from sick.tools.web import FetchUrl
22
+
23
+ PONYTAIL_PROMPT = """## ponytail (always active)
24
+ - Shortest correct solution. No over-engineering.
25
+ - Stdlib before dependencies. Native features before libraries.
26
+ - Deletion over addition. Boring over clever.
27
+ - Mark deliberate shortcuts with `ponytail:` comment.
28
+ - Evidence before assertions — run tests after every change.
29
+ - Always use `uv` for Python operations in this project."""
30
+
31
+
32
+ PDF_PROMPT = """## pdf_handling
33
+ PDF files CANNOT be read with read_file() — it returns raw binary.
34
+ Use parse_pdf(path) instead — returns clean Markdown via Docling.
35
+ When a user attaches a PDF, its content is already parsed and in context."""
36
+
37
+
38
+ # ponytail: empty env var means unset (`SICK_REMOTION_DIR=` in .env must not
39
+ # resolve to Path("."), which silently disabled the remotion skills)
40
+ REMOTION_SKILL_DIR = Path(os.environ.get("SICK_REMOTION_DIR") or (Path.home() / ".agents" / "skills"))
41
+ REMOTION_REFERENCE_HINT = (
42
+ "\n\nReference guides (markup, render, captions, maps, studio, saas) live in "
43
+ "~/.agents/skills/remotion-best-practices/ — when a guide is relevant, read it "
44
+ "with `bash cat ~/.agents/skills/remotion-best-practices/remotion-markup/REFERENCE.md` "
45
+ "(adjust the subdirectory) and follow its guidance."
46
+ )
47
+ REMOTION_FALLBACK = """## remotion
48
+ You create explanatory videos with Remotion (https://remotion.dev).
49
+ The remotion skill files are missing. Install them with:
50
+ npx skills add remotion-dev/skills@remotion-create -g -y
51
+ npx skills add remotion-dev/skills@remotion-best-practices -g -y
52
+ Then scaffold with `npx create-video@latest --yes --blank --no-tailwind <name>`
53
+ and render with `npx remotion render`."""
54
+
55
+
56
+ def _load_remotion_skill(base: Path = REMOTION_SKILL_DIR) -> str:
57
+ parts = []
58
+ for name in ("remotion-best-practices", "remotion-create"):
59
+ try:
60
+ parts.append((base / name / "SKILL.md").read_text())
61
+ except OSError:
62
+ continue
63
+ if not parts:
64
+ return REMOTION_FALLBACK
65
+ return "\n\n".join(parts) + REMOTION_REFERENCE_HINT
66
+
67
+
68
+ def _get_remotion_prompt() -> str:
69
+ try:
70
+ return _load_remotion_skill()
71
+ except Exception:
72
+ return REMOTION_FALLBACK
73
+
74
+
75
+ class _LazyRemotionSkill(Skill):
76
+ context_block = ("remotion", "")
77
+
78
+ def attach(self, agent):
79
+ prompt = _get_remotion_prompt()
80
+ agent.context["remotion"] = prompt
81
+ self.context_block = ("remotion", prompt)
82
+
83
+
84
+ SDLC_PROMPT = """## sdlc_and_oo
85
+ SDLC is runtime-enforced by sick.lifecycle (see the live `sdlc_state` context
86
+ block): phases are observed from tool results, and delivery is blocked
87
+ without verification evidence after the last edit. Prefer OO structure where
88
+ it earns its keep (deep modules, small interfaces); flat code wins otherwise."""
89
+
90
+
91
+ class PonytailSkill(Skill):
92
+ context_block = ("ponytail", PONYTAIL_PROMPT)
93
+
94
+ def attach(self, agent):
95
+ agent.context["ponytail"] = PONYTAIL_PROMPT
96
+
97
+
98
+ class PDFSkill(Skill):
99
+ context_block = ("pdf_handling", PDF_PROMPT)
100
+
101
+ def attach(self, agent):
102
+ agent.context["pdf_handling"] = PDF_PROMPT
103
+
104
+
105
+ class RemotionSkill(_LazyRemotionSkill):
106
+ pass
107
+
108
+
109
+ class SdlcSkill(Skill):
110
+ context_block = ("sdlc", SDLC_PROMPT)
111
+
112
+ def attach(self, agent):
113
+ agent.context["sdlc"] = SDLC_PROMPT
114
+
115
+
116
+ class SickAgent(InteractiveAgent):
117
+ """You are Sick, a coding agent who modifies itself to get better over time.
118
+
119
+ Core philosophy (ponytail, always active):
120
+ - Shortest correct solution. No over-engineering.
121
+ - Stdlib before dependencies. Native features before libraries.
122
+ - Deletion over addition. Boring over clever.
123
+ - Mark deliberate shortcuts with `ponytail:` comment.
124
+ - Evidence before assertions — run tests after every change.
125
+ - Always use `uv` for Python operations in this project.
126
+
127
+ Available tools:
128
+ {doc(self)}
129
+
130
+ Past experiences relevant to this task:
131
+ {self._recall_for_prompt}
132
+ """
133
+
134
+ _tools: dict[str, Tool]
135
+ memory: ExperienceMemory
136
+
137
+ def __init__(
138
+ self,
139
+ attachments: list[str] | None = None,
140
+ skills: list[Skill] | None = None,
141
+ workspace: str | Path = ".",
142
+ **kwargs: Any,
143
+ ):
144
+ super().__init__(**kwargs)
145
+ self.memory = ExperienceMemory()
146
+ self._skills: list[Skill] = skills or []
147
+ self.workspace = Path(workspace).resolve()
148
+ if not self.workspace.is_dir():
149
+ raise ValueError(f"workspace is not a directory: {workspace}")
150
+
151
+ cfg = load_config(self.workspace)
152
+ self.audit = AuditLog(self.workspace)
153
+ self.lifecycle = Lifecycle(self.workspace)
154
+ self.current_run: Run | None = None
155
+ self.registry = ToolRegistry(audit=self.audit, on_result=self._record_lifecycle)
156
+ self._tools = self.registry.tools
157
+ self._tool_counts = self.registry.counts()
158
+ self.turns = 0
159
+
160
+ from sick.tools.base import EXCLUDED_DIRS as _EXCL
161
+
162
+ merged_excluded = set(_EXCL) | set(cfg.get("excluded") or [])
163
+ # CodeResearch builds index on init; ensure it uses merged excluded
164
+ # by setting excluded before build (rebuild if needed)
165
+ code_research_tool = CodeResearch(str(self.workspace))
166
+ if getattr(code_research_tool.index, "excluded", None) != merged_excluded:
167
+ code_research_tool.index.excluded = merged_excluded
168
+ # rebuild if config adds exclusions (cheap: uses cache when possible)
169
+ code_research_tool.index.build()
170
+ code_research_tool.chunk_count = len(code_research_tool.index.chunks)
171
+
172
+ for t in [
173
+ ReadFile(self.workspace), WriteFile(self.workspace), EditFile(self.workspace),
174
+ Bash(self.workspace), Grep(self.workspace), Glob(self.workspace),
175
+ ParsePdf(self.workspace), code_research_tool, SelfRead(), SelfWrite(),
176
+ FetchUrl(), Checkpoint(self.workspace), Restore(self.workspace),
177
+ ListCheckpoints(self.workspace),
178
+ ]:
179
+ t.excluded = merged_excluded
180
+ self.registry.register(t)
181
+
182
+ self._register_skill(PonytailSkill())
183
+ self._register_skill(PDFSkill())
184
+ self._register_skill(RemotionSkill())
185
+ self._register_skill(SdlcSkill())
186
+ for skill in self._skills:
187
+ self._register_skill(skill)
188
+ self._register_discovered_skills()
189
+
190
+ self._handle_attachments(attachments or [])
191
+
192
+ spec(self, "memory", hidden=False)
193
+
194
+ def _register_skill(self, skill: Skill) -> None:
195
+ attr_name = type(skill).__name__.lower().replace("skill", "")
196
+ setattr(self, attr_name, skill)
197
+ skill.attach(self)
198
+ spec(self, attr_name, hidden=False)
199
+
200
+ def _register_discovered_skills(self) -> None:
201
+ """Index every installed SKILL.md (name + description) for on-demand use."""
202
+ try:
203
+ from sick.skills import discover_skills, skills_index_prompt
204
+
205
+ self.discovered_skills = discover_skills(self.workspace)
206
+ if self.discovered_skills:
207
+ self.context["skills"] = skills_index_prompt(self.discovered_skills)
208
+ except Exception:
209
+ self.discovered_skills = []
210
+
211
+ def _handle_attachments(self, attachments: list[str]) -> None:
212
+ MAX_ATTACH_BYTES = 100_000
213
+ for a in attachments:
214
+ p = Path(a)
215
+ if not p.exists() or p.is_dir():
216
+ continue
217
+ if p.suffix.lower() == ".pdf":
218
+ tool = self._tools.get("parse_pdf")
219
+ if tool:
220
+ md = self._call("parse_pdf", path=str(p))
221
+ self.context[f"attachment_{p.stem}"] = md
222
+ else:
223
+ try:
224
+ # Reuse workspace confinement: try read_file, fallback raw read with bound
225
+ result = self.registry.call("read_file", path=str(p))
226
+ content = str(result.output)
227
+ if not result.ok:
228
+ content = p.read_text(errors="replace")[:MAX_ATTACH_BYTES]
229
+ else:
230
+ content = content[:MAX_ATTACH_BYTES]
231
+ if len(content) >= MAX_ATTACH_BYTES:
232
+ content += f"\n[truncated after {MAX_ATTACH_BYTES} bytes]"
233
+ self.context[f"attachment_{p.stem}"] = content
234
+ except Exception as e:
235
+ self.context[f"attachment_{p.stem}"] = f"[unreadable: {e}]"
236
+
237
+ # ── lifecycle ──
238
+
239
+ def _record_lifecycle(self, result: ToolResult, args: dict) -> None:
240
+ if self.current_run is not None:
241
+ self.lifecycle.record(self.current_run, result, args)
242
+
243
+ def begin_run(self, task: str, *, planned: bool = False, approved: bool = False) -> Run:
244
+ """Start an evidence-gated SDLC run and route phase skills.
245
+
246
+ `approved=True` is for CLI one-shot/modify/learn invocations: typing
247
+ the command is the human's approval to execute. Interactive TUI runs
248
+ leave it False, so RISKY runs there need /plan -> /approve.
249
+ """
250
+ run = self.lifecycle.start(task)
251
+ if planned:
252
+ self.lifecycle.create_plan(run)
253
+ if approved:
254
+ self.lifecycle.approve(run)
255
+ self.current_run = run
256
+ for phase in (Phase.REQUIREMENTS, Phase.DESIGN, Phase.VERIFY):
257
+ routed = self.lifecycle.route_skill(run, phase)
258
+ if routed:
259
+ name, body = routed
260
+ self.context[f"skill_{phase.value}"] = body
261
+ self.context["sdlc_state"] = self.lifecycle.prompt(run)
262
+ return run
263
+
264
+ def finish_run(self, summary: str = "") -> Any:
265
+ """Apply the delivery gate to the current run. Returns the verdict."""
266
+ if self.current_run is None:
267
+ return None
268
+ verdict = self.lifecycle.finish(self.current_run, summary)
269
+ self.context["sdlc_state"] = self.lifecycle.prompt(self.current_run)
270
+ self.context["sdlc_result"] = (
271
+ "DELIVERED" if verdict.allowed else "BLOCKED: " + "; ".join(verdict.reasons)
272
+ )
273
+ return verdict
274
+
275
+ # ── tool proxy methods (rendered output for the LLM; ToolResult inside) ──
276
+
277
+ def _call_result(self, tool: str, **kwargs: Any) -> ToolResult:
278
+ return self.registry.call(tool, **kwargs)
279
+
280
+ def _call(self, tool: str, **kwargs: Any) -> Any:
281
+ return self.registry.call(tool, **kwargs).output
282
+
283
+ def read_file(self, path: str, offset: int = 0, limit: int | None = None) -> str:
284
+ return str(self._call("read_file", path=path, offset=offset, limit=limit))
285
+
286
+ def write_file(self, path: str, content: str) -> str:
287
+ return str(self._call("write_file", path=path, content=content))
288
+
289
+ def edit_file(self, path: str, old: str, new: str) -> bool:
290
+ return bool(self._call_result("edit_file", path=path, old=old, new=new).ok)
291
+
292
+ def bash(self, command: str, timeout: int = 60) -> str:
293
+ return str(self._call("bash", command=command, timeout=timeout))
294
+
295
+ def grep(self, pattern: str, include: str | None = None, path: str = ".") -> list[str]:
296
+ out = self._call("grep", pattern=pattern, include=include, path=path)
297
+ return out if isinstance(out, list) else [str(out)]
298
+
299
+ def glob(self, pattern: str, path: str = ".") -> list[str]:
300
+ out = self._call("glob", pattern=pattern, path=path)
301
+ return out if isinstance(out, list) else [str(out)]
302
+
303
+ def parse_pdf(self, path: str) -> str:
304
+ return str(self._call("parse_pdf", path=path))
305
+
306
+ def code_research(self, query: str, k: int = 8) -> str:
307
+ return str(self._call("code_research", query=query, k=k))
308
+
309
+ def fetch_url(self, url: str) -> str:
310
+ return str(self._call("fetch_url", url=url))
311
+
312
+ def load_skill(self, name: str) -> str:
313
+ """Load the full instructions of an installed skill by name (see the skills index)."""
314
+ from sick.skills import load_skill_body
315
+
316
+ body = load_skill_body(name, self.workspace)
317
+ if body is not None and self.current_run is not None:
318
+ self.lifecycle.record(self.current_run, ToolResult.success(body[:200], tool="load_skill"), {"name": name})
319
+ return body if body is not None else f"Error: unknown skill: {name}"
320
+
321
+ def checkpoint(self, message: str = "checkpoint") -> str:
322
+ return str(self._call("checkpoint", message=message))
323
+
324
+ def restore(self, n: int = 1) -> str:
325
+ return str(self._call("restore", n=n))
326
+
327
+ def checkpoints(self) -> str:
328
+ return str(self._call("checkpoints"))
329
+
330
+ # ── self-modification helpers ──
331
+
332
+ def self_read(self, path: str) -> str:
333
+ return str(self._call("self_read", path=path))
334
+
335
+ def self_write(self, path: str, content: str) -> str:
336
+ return str(self._call("self_write", path=path, content=content))
337
+
338
+ # ── memory ──
339
+
340
+ def _recall_for_prompt(self) -> str:
341
+ results = self.memory.recall("")
342
+ if not results:
343
+ return "No past experiences yet."
344
+ lines = []
345
+ for e in results:
346
+ lines.append(f"- {e.task}: {e.outcome} (pattern: {e.pattern}, tools: {', '.join(e.tools)})")
347
+ return "\n".join(lines)
348
+
349
+ def _record_experience(self, task: str, outcome: str, pattern: str) -> None:
350
+ tools = sorted(self._tool_counts.keys())
351
+ self.memory.record(Experience(task=task, outcome=outcome, pattern=pattern, tools=tools))
352
+
353
+ def learn_brief(self, focus: str = "") -> str:
354
+ """Mine audit + memory for lessons, record one, return the brief.
355
+
356
+ Deterministic half of `/learn`; the generative half (actually editing
357
+ sick's source) runs from `sick.learn.build_learn_prompt`'s prompt.
358
+ """
359
+ from sick.learn import analyze_audit, blocked_run_lessons, summarize
360
+
361
+ report = analyze_audit(self.audit.entries(100), focus=focus, turns=self.turns)
362
+ report.lessons = blocked_run_lessons(self.workspace) + report.lessons
363
+ brief = summarize(report, self.memory.recall(""))
364
+ lesson = report.lessons[0] if report.lessons else "session reviewed"
365
+ self.memory.record(
366
+ Experience(
367
+ task=f"learn: {focus or 'session review'}",
368
+ outcome="lesson recorded",
369
+ pattern=lesson,
370
+ tools=sorted(self._tool_counts.keys()),
371
+ )
372
+ )
373
+ return brief
374
+
375
+ # ── interactive turn protocol ──
376
+
377
+ @hidden
378
+ async def handle(self, notification: dict[str, list]) -> RespondResult:
379
+ msgs = notification.get("user_messages", [])
380
+ if msgs:
381
+ task = msgs[-1]
382
+ # Reuse a planned run: awaiting approval = planning turn (do not
383
+ # finish it); approved = execution turn (gate it). Else start fresh.
384
+ run = self.current_run
385
+ planning_turn = bool(
386
+ run is not None and run.plan_created and not run.approved
387
+ and run.phase in (Phase.DESIGN, Phase.REQUIREMENTS)
388
+ )
389
+ if run is None or not (planning_turn or (run.approved and run.phase == Phase.IMPLEMENT)):
390
+ run = self.begin_run(str(task))
391
+ planning_turn = False
392
+ self.context["user_request"] = task
393
+ self.turns += 1
394
+ await self.respond()
395
+ if planning_turn:
396
+ run.phase = Phase.DESIGN # type: ignore[union-attr]
397
+ self.lifecycle.persist(run)
398
+ self._record_experience(str(task)[:80], "planned", f"sdlc:{run.phase.value}:{run.tier.value}") # type: ignore[union-attr]
399
+ return RespondResult(
400
+ kind=RespondReason.DONE,
401
+ explanation="plan produced; awaiting /approve or /reject",
402
+ )
403
+ verdict = self.finish_run("chat turn")
404
+ delivered = bool(verdict and verdict.allowed)
405
+ outcome = "success" if delivered else "blocked"
406
+ pattern = f"sdlc:{run.phase.value}:{run.tier.value}" if run else "chat turn"
407
+ self._record_experience(str(task)[:80], outcome, pattern)
408
+ return RespondResult(
409
+ kind=RespondReason.DONE,
410
+ explanation=(
411
+ "delivered; waiting for the next user message" if delivered
412
+ else f"blocked by SDLC gate: {'; '.join(verdict.reasons) if verdict else 'no run'}"
413
+ ),
414
+ )
415
+ for cmd in notification.get("slash_commands", []):
416
+ value = getattr(cmd, "value", cmd)
417
+ text = getattr(cmd, "text", str(cmd))
418
+ self.context["user_request"] = f"Slash command result: {text}"
419
+ await self.respond()
420
+ return RespondResult(
421
+ kind=RespondReason.DONE,
422
+ explanation=str(value or text),
423
+ )
424
+ for sysmsg in notification.get("system_messages", []):
425
+ self.context["user_request"] = str(sysmsg)
426
+ await self.respond()
427
+ return RespondResult(
428
+ kind=RespondReason.DONE,
429
+ explanation="processed system message",
430
+ )
431
+ return RespondResult(
432
+ kind=RespondReason.DONE,
433
+ explanation="no pending input",
434
+ )
435
+
436
+ async def respond(self) -> str:
437
+ """Respond to the user's latest message (in the `user_request` context block).
438
+
439
+ This is a chat session. Do ALL the work before responding: explore,
440
+ implement, test, iterate. Then reply to the user in Markdown with a
441
+ concise summary of what you did. Call `message()` to send the reply.
442
+
443
+ ponytail: always apply the lazy philosophy.
444
+ """
445
+ ...
446
+
447
+ # ── one-shot generation methods (CLI mode) ──
448
+
449
+ async def run(self, task: str) -> str:
450
+ """Complete a coding task.
451
+
452
+ 1. Understand the task
453
+ 2. Research existing code if needed (grep, read)
454
+ 3. Make changes (write_file, edit_file)
455
+ 4. Verify with tests/bash
456
+ 5. Record experience
457
+
458
+ ponytail: always apply the lazy philosophy
459
+ """
460
+ ...
461
+
462
+ async def modify_self(self, instruction: str) -> str:
463
+ """Modify sick's own source code to improve capabilities.
464
+
465
+ Steps:
466
+ 1. checkpoint('pre-modify') first — self_write makes this a RISKY run
467
+ and the SDLC gate requires a checkpoint before delivery
468
+ 2. self_read('agent.py') — understand current code
469
+ 3. Make changes via self_write/edit
470
+ 4. bash('uv run python -c "from sick.agent import SickAgent"') — verify import
471
+ 5. bash('uv run python -m pytest tests/ -x') — verify tests pass
472
+ (verification must come after the last edit or delivery is blocked)
473
+ 6. Record the improvement as an experience
474
+
475
+ You can modify: src/sick/agent.py, tools/*.py, skills/*.py, pyproject.toml, README.md
476
+ """
477
+ ...
sick/audit.py ADDED
@@ -0,0 +1,102 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+
6
+ try:
7
+ import portalocker # type: ignore
8
+ except Exception: # pragma: no cover
9
+ portalocker = None # type: ignore
10
+
11
+ MAX_AUDIT_BYTES = 5_000_000
12
+
13
+
14
+ class AuditLog:
15
+ """Append-only JSONL record of every tool call for a workspace."""
16
+
17
+ def __init__(self, workspace: str | Path) -> None:
18
+ self._path = Path(workspace).resolve() / ".sick" / "audit.jsonl"
19
+
20
+ def _rotate_if_needed(self) -> None:
21
+ try:
22
+ if self._path.exists() and self._path.stat().st_size > MAX_AUDIT_BYTES:
23
+ ts = int(time.time())
24
+ rot = self._path.with_suffix(f".{ts}.jsonl")
25
+ self._path.rename(rot)
26
+ olds = sorted(self._path.parent.glob(self._path.name + ".*.jsonl"))
27
+ for p in olds[:-3]:
28
+ try:
29
+ p.unlink()
30
+ except OSError:
31
+ pass
32
+ except OSError:
33
+ pass
34
+
35
+ def record(
36
+ self,
37
+ tool: str,
38
+ ok: bool,
39
+ duration_ms: int,
40
+ **args: object,
41
+ ) -> None:
42
+ import re
43
+
44
+ redacted = {}
45
+ for k, v in args.items():
46
+ s = str(v) if not isinstance(v, str) else v
47
+ s = re.sub(r"(?i)(api[_-]?key\s*[:=]\s*)([^\s\"']+)", r"\1[REDACTED]", s)
48
+ s = re.sub(r"(?i)(sk-[a-z0-9\-]{10,})", "[REDACTED]", s)
49
+ # ponytail: bound audit growth — full file contents don't belong in the log
50
+ if len(s) > 1000:
51
+ s = s[:1000] + f"…[truncated, {len(s)} chars total]"
52
+ redacted[k] = s
53
+ entry = {
54
+ "t": time.time(),
55
+ "tool": tool,
56
+ "ok": ok,
57
+ "duration_ms": duration_ms,
58
+ "args": redacted,
59
+ }
60
+ self._path.parent.mkdir(parents=True, exist_ok=True)
61
+ self._rotate_if_needed()
62
+ line = json.dumps(entry, default=str) + "\n"
63
+ if portalocker:
64
+ try:
65
+ with portalocker.Lock(str(self._path), "a", timeout=5) as f: # type: ignore
66
+ f.write(line)
67
+ try:
68
+ f.flush()
69
+ os.fsync(f.fileno())
70
+ except OSError:
71
+ pass
72
+ return
73
+ except Exception:
74
+ pass
75
+ with open(self._path, "a") as f:
76
+ f.write(line)
77
+
78
+ def entries(self, n: int | None = None) -> list[dict]:
79
+ if not self._path.exists():
80
+ return []
81
+ rows = []
82
+ data = ""
83
+ if portalocker:
84
+ try:
85
+ with portalocker.Lock(str(self._path), "r", flags=portalocker.LOCK_SH) as f: # type: ignore
86
+ data = f.read()
87
+ except Exception:
88
+ data = ""
89
+ if not data:
90
+ try:
91
+ with open(self._path) as f:
92
+ data = f.read()
93
+ except OSError:
94
+ return []
95
+ for line in data.splitlines():
96
+ try:
97
+ rows.append(json.loads(line))
98
+ except json.JSONDecodeError:
99
+ continue
100
+ if n is not None:
101
+ rows = rows[-n:]
102
+ return rows