diff --git a/.agent/harness/hooks/on_failure.py b/.agent/harness/hooks/on_failure.py index 5312bc0..277e057 100644 --- a/.agent/harness/hooks/on_failure.py +++ b/.agent/harness/hooks/on_failure.py @@ -12,7 +12,7 @@ def _count_recent_failures(skill_name): if not os.path.exists(EPISODIC): return 0 - cutoff = datetime.datetime.now() - datetime.timedelta(days=WINDOW_DAYS) + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=WINDOW_DAYS) count = 0 for line in open(EPISODIC): line = line.strip() @@ -25,7 +25,10 @@ def _count_recent_failures(skill_name): if e.get("skill") != skill_name or e.get("result") != "failure": continue try: - if datetime.datetime.fromisoformat(e["timestamp"]) > cutoff: + ts = datetime.datetime.fromisoformat(e["timestamp"]) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=datetime.timezone.utc) + if ts > cutoff: count += 1 except (KeyError, ValueError): continue @@ -48,7 +51,7 @@ def on_failure(skill_name, action, error, context="", confidence=0.9, # schema migration is recorded with its true importance and pain score; # the dream-cycle salience can't distinguish failure severity otherwise. entry = { - "timestamp": datetime.datetime.now().isoformat(), + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), "skill": skill_name, "action": action[:200], "result": "failure", diff --git a/.agent/harness/hooks/post_execution.py b/.agent/harness/hooks/post_execution.py index 6f587f0..2727b97 100644 --- a/.agent/harness/hooks/post_execution.py +++ b/.agent/harness/hooks/post_execution.py @@ -19,7 +19,7 @@ def log_execution(skill_name, action, result, success, reflection="", if pain_score is None: pain_score = 2 if success else 7 entry = { - "timestamp": datetime.datetime.now().isoformat(), + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(), "skill": skill_name, "action": action[:200], "result": "success" if success else "failure", diff --git a/.agent/harness/salience.py b/.agent/harness/salience.py index 1214b49..b432e45 100644 --- a/.agent/harness/salience.py +++ b/.agent/harness/salience.py @@ -8,12 +8,17 @@ def salience_score(entry: dict) -> float: if not ts: return 0.0 try: - age_days = (datetime.datetime.now() - - datetime.datetime.fromisoformat(ts)).days + parsed = datetime.datetime.fromisoformat(ts) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + # Negative age can happen during the naive-→-UTC migration window + # if a legacy naive-local timestamp now reads as a few hours in the + # future. Floor at 0 so recency stays in [0, 10] instead of inflating. + age_days = max(0, (datetime.datetime.now(datetime.timezone.utc) - parsed).days) except ValueError: age_days = 999 pain = entry.get("pain_score", 5) importance = entry.get("importance", 5) recurrence = entry.get("recurrence_count", 1) - recency = max(0.0, 10.0 - age_days * 0.3) + recency = max(0.0, min(10.0, 10.0 - age_days * 0.3)) return recency * (pain / 10.0) * (importance / 10.0) * min(recurrence, 3) diff --git a/.agent/memory/archive.py b/.agent/memory/archive.py index 4659c68..1fe8125 100644 --- a/.agent/memory/archive.py +++ b/.agent/memory/archive.py @@ -8,11 +8,12 @@ def archive_stale_workspace(working_dir, archive_dir): workspace = os.path.join(working_dir, "WORKSPACE.md") if not os.path.exists(workspace): return False - mtime = datetime.datetime.fromtimestamp(os.path.getmtime(workspace)) - if (datetime.datetime.now() - mtime).days < STALE_DAYS: + mtime = datetime.datetime.fromtimestamp(os.path.getmtime(workspace), + tz=datetime.timezone.utc) + if (datetime.datetime.now(datetime.timezone.utc) - mtime).days < STALE_DAYS: return False os.makedirs(archive_dir, exist_ok=True) dest = os.path.join(archive_dir, - f"workspace_{datetime.date.today().isoformat()}.md") + f"workspace_{datetime.datetime.now(datetime.timezone.utc).date().isoformat()}.md") shutil.move(workspace, dest) return True diff --git a/.agent/memory/auto_dream.py b/.agent/memory/auto_dream.py index 1e6ffb0..af3a87b 100644 --- a/.agent/memory/auto_dream.py +++ b/.agent/memory/auto_dream.py @@ -13,13 +13,21 @@ - promotion to LESSONS.md (graduate.py does that) - git commit (unattended repo writes are dangerous on a host hook) """ -import json, os +import contextlib, json, os from promote import cluster_and_extract, write_candidates from validate import heuristic_check from review_state import mark_rejected, write_review_queue_summary from decay import decay_old_entries from archive import archive_stale_workspace +# fcntl is POSIX-only. On Windows the dream cycle is best-effort: concurrent +# writers there are rare (no shutdown hook = no parallel exits), and the lack +# of locking matches the existing _episodic_io.py fallback. +try: + import fcntl # type: ignore[import-not-found] +except ImportError: # pragma: no cover — Windows + fcntl = None # type: ignore[assignment] + ROOT = os.path.abspath(os.path.dirname(__file__)) EPISODIC = os.path.join(ROOT, "episodic/AGENT_LEARNINGS.jsonl") CANDIDATES = os.path.join(ROOT, "candidates") @@ -29,11 +37,56 @@ CLUSTER_SIMILARITY = 0.3 -def _load_entries(): - if not os.path.exists(EPISODIC): - return [] +@contextlib.contextmanager +def _episodic_locked(): + """Hold an exclusive flock on AGENT_LEARNINGS.jsonl across the entire + dream-cycle read-modify-write window. + + Without a window-spanning lock, an `append_jsonl()` call that lands + between `_load_entries_locked()` and `_write_entries_locked(kept)` is + silently truncated away by the rewrite. With this context manager, + every appender (`_episodic_io.append_jsonl`, which takes LOCK_EX on + the same file) blocks until the dream cycle releases the lock. + + Yields the open file descriptor so callers can read/write without + racing on a second open(). On Windows (no fcntl) yields None and + falls back to the historical best-effort behavior. + """ + if fcntl is None: + yield None + return + os.makedirs(os.path.dirname(EPISODIC), exist_ok=True) + fd = os.open(EPISODIC, os.O_RDWR | os.O_CREAT, 0o644) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield fd + finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def _load_entries_locked(fd): + """Read all entries from the locked fd, or fall back to plain read on + Windows (fd is None when fcntl is unavailable). + """ entries = [] - for line in open(EPISODIC): + if fd is None: + if not os.path.exists(EPISODIC): + return entries + with open(EPISODIC) as f: + stream = f.read() + else: + os.lseek(fd, 0, os.SEEK_SET) + chunks = [] + while True: + buf = os.read(fd, 65536) + if not buf: + break + chunks.append(buf) + stream = b"".join(chunks).decode("utf-8", errors="replace") + for line in stream.splitlines(): line = line.strip() if not line: continue @@ -44,10 +97,34 @@ def _load_entries(): return entries +def _write_entries_locked(fd, entries): + """Truncate-and-rewrite under the same lock _load_entries_locked used. + + Holding one fd across read+write is what makes the operation atomic + against concurrent `append_jsonl()` calls. + """ + payload = "".join(json.dumps(e) + "\n" for e in entries).encode("utf-8") + if fd is None: + # Windows: best-effort, matches _episodic_io fallback. + with open(EPISODIC, "w") as f: + f.write(payload.decode("utf-8")) + return + os.ftruncate(fd, 0) + os.lseek(fd, 0, os.SEEK_SET) + os.write(fd, payload) + + +# Compatibility shims for any external caller that still imports the +# pre-refactor names. Internal callers in run_dream_cycle use the locked +# helpers directly so the lock spans the full cycle. +def _load_entries(): + with _episodic_locked() as fd: + return _load_entries_locked(fd) + + def _write_entries(entries): - with open(EPISODIC, "w") as f: - for e in entries: - f.write(json.dumps(e) + "\n") + with _episodic_locked() as fd: + _write_entries_locked(fd, entries) def _heuristic_prefilter(candidates_dir, semantic_dir): @@ -86,30 +163,36 @@ def _heuristic_prefilter(candidates_dir, semantic_dir): def run_dream_cycle(): - entries = _load_entries() - if not entries: - # Still refresh the review queue — candidates may have been staged in - # a previous cycle and the host agent loads REVIEW_QUEUE.md into every - # session via build_context, so a stale/missing file hides real work. - pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE) - print(f"dream cycle: no entries (queue has {pending} pending)") - return - - patterns = cluster_and_extract(entries, threshold=CLUSTER_SIMILARITY) - promotable = {k: p for k, p in patterns.items() - if p.get("canonical_salience", 0) >= PROMOTION_THRESHOLD} + # Hold the lock across the FULL read-modify-write window. Any + # append_jsonl() call from another harness blocks until we release. + # Without this, an append landing between read and rewrite would be + # truncated away. + with _episodic_locked() as fd: + entries = _load_entries_locked(fd) + if not entries: + # Still refresh the review queue — candidates may have been staged + # in a previous cycle and the host agent loads REVIEW_QUEUE.md + # into every session via build_context, so a stale/missing file + # hides real work. + pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE) + print(f"dream cycle: no entries (queue has {pending} pending)") + return + + patterns = cluster_and_extract(entries, threshold=CLUSTER_SIMILARITY) + promotable = {k: p for k, p in patterns.items() + if p.get("canonical_salience", 0) >= PROMOTION_THRESHOLD} + + staged = write_candidates(promotable, CANDIDATES) + prefiltered = _heuristic_prefilter(CANDIDATES, SEMANTIC) + + kept, archived = decay_old_entries( + entries, archive_dir=os.path.join(ROOT, "episodic/snapshots")) + _write_entries_locked(fd, kept) + archive_stale_workspace( + working_dir=os.path.join(ROOT, "working"), + archive_dir=os.path.join(ROOT, "episodic/snapshots")) - staged = write_candidates(promotable, CANDIDATES) - prefiltered = _heuristic_prefilter(CANDIDATES, SEMANTIC) - - kept, archived = decay_old_entries( - entries, archive_dir=os.path.join(ROOT, "episodic/snapshots")) - _write_entries(kept) - archive_stale_workspace( - working_dir=os.path.join(ROOT, "working"), - archive_dir=os.path.join(ROOT, "episodic/snapshots")) - - pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE) + pending = write_review_queue_summary(CANDIDATES, REVIEW_QUEUE) print( f"dream cycle: patterns={len(patterns)} staged={staged} " diff --git a/.agent/memory/decay.py b/.agent/memory/decay.py index c23eb89..6b785e4 100644 --- a/.agent/memory/decay.py +++ b/.agent/memory/decay.py @@ -10,12 +10,15 @@ def decay_old_entries(entries, archive_dir): - cutoff = datetime.datetime.now() - datetime.timedelta(days=DECAY_DAYS) + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=DECAY_DAYS) kept, archived = [], [] for e in entries: ts_str = e.get("timestamp", "") try: ts = datetime.datetime.fromisoformat(ts_str) + # Normalise to UTC — entries may be naive (no tz) or aware. + if ts.tzinfo is None: + ts = ts.replace(tzinfo=datetime.timezone.utc) except ValueError: kept.append(e) continue @@ -26,7 +29,9 @@ def decay_old_entries(entries, archive_dir): if archived: os.makedirs(archive_dir, exist_ok=True) - path = os.path.join(archive_dir, f"archive_{datetime.date.today()}.jsonl") + # UTC date so archive filenames align with the UTC cutoff above. + today_utc = datetime.datetime.now(datetime.timezone.utc).date() + path = os.path.join(archive_dir, f"archive_{today_utc}.jsonl") with open(path, "a") as f: for e in archived: f.write(json.dumps(e) + "\n") diff --git a/.agent/memory/promote.py b/.agent/memory/promote.py index 0f50e21..b225734 100644 --- a/.agent/memory/promote.py +++ b/.agent/memory/promote.py @@ -138,7 +138,7 @@ def write_candidates(patterns, candidates_dir): if not evidence_changed and blocker_still_present: continue - now = datetime.datetime.now().isoformat() + now = datetime.datetime.now(datetime.timezone.utc).isoformat() decisions = prev.get("decisions", []) decisions.append({"ts": now, "action": "staged", "reviewer": "auto_dream"}) diff --git a/.agent/memory/render_lessons.py b/.agent/memory/render_lessons.py index 7eba4a3..04e4414 100644 --- a/.agent/memory/render_lessons.py +++ b/.agent/memory/render_lessons.py @@ -198,9 +198,9 @@ def migrate_legacy_bullets(semantic_dir): for L in load_lessons(semantic_dir)} try: accepted_at = datetime.datetime.fromtimestamp( - os.path.getmtime(md_path)).isoformat() + os.path.getmtime(md_path), tz=datetime.timezone.utc).isoformat() except OSError: - accepted_at = datetime.datetime.now().isoformat() + accepted_at = datetime.datetime.now(datetime.timezone.utc).isoformat() migrated = 0 for claim in bullets: diff --git a/.agent/memory/review_state.py b/.agent/memory/review_state.py index 7eee543..ec0a389 100644 --- a/.agent/memory/review_state.py +++ b/.agent/memory/review_state.py @@ -13,7 +13,7 @@ def _now(): - return datetime.datetime.now().isoformat() + return datetime.datetime.now(datetime.timezone.utc).isoformat() def _touch(candidate, action, reviewer, notes="", **fields): @@ -177,7 +177,9 @@ def _age_factor(staged_at): staged = datetime.datetime.fromisoformat(staged_at) except (ValueError, TypeError): return 1.0 - age_days = (datetime.datetime.now() - staged).days + if staged.tzinfo is None: + staged = staged.replace(tzinfo=datetime.timezone.utc) + age_days = (datetime.datetime.now(datetime.timezone.utc) - staged).days return 1.0 + min(1.0, age_days / 14.0) diff --git a/.agent/tools/graduate.py b/.agent/tools/graduate.py index a877885..edaa713 100644 --- a/.agent/tools/graduate.py +++ b/.agent/tools/graduate.py @@ -154,7 +154,7 @@ def main(): # If we crash mid-graduation, the staged candidate remains and the # reviewer can retry. The retry-safety block above catches the # specific "lesson appended but candidate not moved" scenario. - accepted_at = datetime.datetime.now().isoformat() + accepted_at = datetime.datetime.now(datetime.timezone.utc).isoformat() lesson = { "id": lesson_id, "claim": cand.get("claim"), diff --git a/.agent/tools/learn.py b/.agent/tools/learn.py index 1718e08..0a63b07 100644 --- a/.agent/tools/learn.py +++ b/.agent/tools/learn.py @@ -58,7 +58,7 @@ def _lesson_already_appended(cid): def stage(claim, conditions, source="learn", importance=7): os.makedirs(CANDIDATES, exist_ok=True) cid = pattern_id(claim, conditions) - now = datetime.datetime.now().isoformat() + now = datetime.datetime.now(datetime.timezone.utc).isoformat() candidate = { "id": cid, "key": f"manual_{cid[:6]}", @@ -123,7 +123,7 @@ def main(): print("\n(stopping here — run graduate.py to accept)") return - rationale = args.rationale or f"manual via learn.py at {datetime.datetime.now().isoformat()}" + rationale = args.rationale or f"manual via learn.py at {datetime.datetime.now(datetime.timezone.utc).isoformat()}" grad_args = [ sys.executable, os.path.join(BASE, "tools", "graduate.py"), diff --git a/.agent/tools/show.py b/.agent/tools/show.py index 7df36ea..8609578 100644 --- a/.agent/tools/show.py +++ b/.agent/tools/show.py @@ -94,7 +94,9 @@ def _human_age(ts_iso): t = datetime.datetime.fromisoformat(ts_iso) except (TypeError, ValueError): return "unknown" - delta = datetime.datetime.now() - t + if t.tzinfo is None: + t = t.replace(tzinfo=datetime.timezone.utc) + delta = datetime.datetime.now(datetime.timezone.utc) - t if delta.days >= 7: return f"{delta.days // 7}w ago" if delta.days >= 1: @@ -124,15 +126,22 @@ def _load_episodic(): def _daily_counts(entries, days=14): - """Return list of (date_str, count) for the last `days` days, oldest first.""" - today = datetime.date.today() + """Return list of (date_str, count) for the last `days` days, oldest first. + + Buckets on UTC dates so the activity graph aligns with the UTC + timestamps every writer now emits. + """ + today = datetime.datetime.now(datetime.timezone.utc).date() buckets = {today - datetime.timedelta(days=i): 0 for i in range(days)} for e in entries: ts = e.get("timestamp", "") try: - d = datetime.datetime.fromisoformat(ts).date() + parsed = datetime.datetime.fromisoformat(ts) except ValueError: continue + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + d = parsed.astimezone(datetime.timezone.utc).date() if d in buckets: buckets[d] += 1 return [(d, buckets[d]) for d in sorted(buckets)] @@ -165,14 +174,17 @@ def episodic_stats(): entries = _load_episodic() failures_14d = 0 latest = None - cutoff = datetime.datetime.now() - datetime.timedelta(days=14) + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=14) for e in entries: ts = e.get("timestamp", "") if ts > (latest or ""): latest = ts if e.get("result") == "failure": try: - if datetime.datetime.fromisoformat(ts) > cutoff: + parsed = datetime.datetime.fromisoformat(ts) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + if parsed > cutoff: failures_14d += 1 except ValueError: pass @@ -242,19 +254,24 @@ def skill_stats(): def last_dream_cycle(): if not os.path.exists(DREAM_LOG): return None - return datetime.datetime.fromtimestamp(os.path.getmtime(DREAM_LOG)).isoformat() + # UTC so _human_age (which now compares against UTC) reads it correctly. + return datetime.datetime.fromtimestamp( + os.path.getmtime(DREAM_LOG), tz=datetime.timezone.utc).isoformat() def failing_skills(threshold=3, window_days=14): if not os.path.exists(EPISODIC): return [] - cutoff = datetime.datetime.now() - datetime.timedelta(days=window_days) + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=window_days) by_skill = {} for e in _load_episodic(): if e.get("result") != "failure": continue try: - if datetime.datetime.fromisoformat(e.get("timestamp", "")) <= cutoff: + parsed = datetime.datetime.fromisoformat(e.get("timestamp", "")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.timezone.utc) + if parsed <= cutoff: continue except ValueError: continue diff --git a/Formula/agentic-stack.rb b/Formula/agentic-stack.rb index e96cfd5..95dbaf7 100644 --- a/Formula/agentic-stack.rb +++ b/Formula/agentic-stack.rb @@ -8,7 +8,7 @@ class AgenticStack < Formula def install # install the brain + adapters alongside install.sh so relative paths hold - pkgshare.install ".agent", "adapters", "install.sh", + pkgshare.install ".agent", "adapters", "harness_manager", "install.sh", "onboard.py", "onboard_ui.py", "onboard_widgets.py", "onboard_render.py", "onboard_write.py", "onboard_features.py" diff --git a/adapters/pi/AGENTS.md b/adapters/pi/AGENTS.md index dfe4a0b..c8aa25b 100644 --- a/adapters/pi/AGENTS.md +++ b/adapters/pi/AGENTS.md @@ -11,11 +11,26 @@ it at the portable brain in `.agent/`. 4. `.agent/protocols/permissions.md` — hard rules ## Skills -Pi scans `.pi/skills/` and `.agents/skills/` for skill packages. The -install script symlinks `.pi/skills` → `.agent/skills` so every skill -under the portable brain is visible to pi without duplication. Pi's -skill format (frontmatter + body) is compatible with ours out of the -box. +Pi scans `.pi/skills/` at startup. The install script symlinks +`.pi/skills` → `.agent/skills` so every skill under the portable brain +is visible to pi without duplication. Customize under `.agent/skills/`; +pi sees it immediately on `/reload`. + +## Automatic memory (no manual calls needed) +`.pi/extensions/memory-hook.ts` is installed by the adapter and +auto-discovered by pi at startup. It: + +- Logs every `bash`, `edit`, and `write` tool call to + `.agent/memory/episodic/AGENT_LEARNINGS.jsonl` automatically — + same signal Claude Code captures via `PostToolUse`. +- Skips `read`, `find`, `ls`, `grep` and low-importance bash calls + (grep, cat, echo, etc.) to keep the log signal-rich. +- Runs `auto_dream.py` when the session ends (quit / new session / + resume) so the dream cycle fires without a cron job. + +For deploy / ship / migration / schema tasks the extension scores +importance automatically — no manual `memory_reflect.py` calls needed +for individual tool actions. ## Recall before non-trivial tasks For deploy / ship / migration / schema / timestamp / date / failing test / @@ -41,10 +56,8 @@ them. - No force push to `main`, `production`, `staging`. - No modification of `.agent/protocols/permissions.md`. -## Pi-specific extensions -- System prompt override: put `.pi/SYSTEM.md` at project root if you - want to replace pi's default system prompt entirely. -- Prompt templates go in `.pi/prompts/`. -- TypeScript extensions go in `.pi/extensions/` (advanced). This adapter - installs `memory-hook.ts`, which logs `tool_result` events to episodic - memory automatically. +## Pi-specific +- System prompt override: `.pi/SYSTEM.md` replaces pi's default system + prompt entirely. +- Prompt templates: `.pi/prompts/`. +- TypeScript extensions: `.pi/extensions/` (auto-discovered at startup). diff --git a/adapters/pi/README.md b/adapters/pi/README.md index 69fedd1..eed01dd 100644 --- a/adapters/pi/README.md +++ b/adapters/pi/README.md @@ -2,8 +2,9 @@ [Pi Coding Agent](https://github.com/badlogic/pi-mono) by Mario Zechner is a minimalist terminal coding harness with multi-provider LLM support -and an extension system. Our adapter layers the portable `.agent/` brain -on top so you keep one knowledge base even if you later swap harnesses. +and a TypeScript extension system. Our adapter layers the portable +`.agent/` brain on top so you keep one knowledge base even if you later +swap harnesses. ## Install ```bash @@ -21,37 +22,48 @@ npm install -g @mariozechner/pi-coding-agent ``` ## What it wires up -- `AGENTS.md` — pi reads this natively as workspace-level context. - Points at `.agent/`. **Skipped if `AGENTS.md` already exists** - (e.g. from the hermes or opencode adapter — pi reads the same file, - so you don't need a second copy). -- `.pi/skills/` → symlink to `.agent/skills/`. Pi scans this path at - startup. Symlink means there's one source of truth; customize under - `.agent/skills/` and pi sees it immediately. -- `.pi/extensions/memory-hook.ts` — project-local extension that listens - to Pi's `tool_result` event and appends episodic entries via the shared - agentic-stack hook path. -- `.pi/` directory is created for skills, extensions, prompt templates, - and optional `.pi/SYSTEM.md` overrides. +- **`AGENTS.md`** — pi reads this natively as workspace-level context. + Points at `.agent/`. Skipped if `AGENTS.md` already exists (e.g. from + the hermes or opencode adapter — pi reads the same file). +- **`.pi/skills/`** → symlink to `.agent/skills/`. Pi scans this path at + startup. Customize under `.agent/skills/`; pi sees it immediately via + `/reload`. +- **`.pi/extensions/memory-hook.ts`** — project-local TypeScript + extension auto-discovered by pi at startup. It: + - Logs `bash`, `edit`, and `write` tool results directly to + `AGENT_LEARNINGS.jsonl` (no Python subprocess per call — all + scoring is inline TypeScript). + - Skips `read`/`find`/`ls`/`grep` and noise-level bash calls to keep + the episodic log signal-rich. + - Runs `auto_dream.py` when the session ends (quit, new session, or + resume) — mirrors Claude Code's `Stop` hook. ## Coexisting with other adapters Pi, hermes, and opencode all read `AGENTS.md`. You can install any combination — only the first one to run writes the root `AGENTS.md`; -subsequent installs are no-ops on that file. +subsequent installs skip it. ## Verify -In pi: ask "what's in my LESSONS file?" — it should read -`.agent/memory/semantic/LESSONS.md`. - -Run one tool call, then inspect the episodic log: +Start pi and run any bash command or edit a file. Then: ```bash tail -1 .agent/memory/episodic/AGENT_LEARNINGS.jsonl ``` -You should see a `skill` of `pi` and an `action` derived from the tool -that just ran. +You should see a JSON entry with `"skill": "pi"`. The `action` field +reflects the tool that ran and the `reflection` field is what the dream +cycle clusters on. + +In pi, ask "what's in my LESSONS file?" — it should read +`.agent/memory/semantic/LESSONS.md`. ## Optional -If pi's default system prompt doesn't fit your workflow, drop a -`.pi/SYSTEM.md` at project root. Pi uses it as a complete override. +- Drop `.pi/SYSTEM.md` at project root to replace pi's default system + prompt entirely. +- Prompt templates go in `.pi/prompts/`. +- For a fallback dream cycle (e.g. if you kill pi rather than quitting + cleanly), add a cron entry: + ``` + 0 3 * * * python3 /path/to/project/.agent/memory/auto_dream.py \ + >> /path/to/project/.agent/memory/dream.log 2>&1 + ``` diff --git a/adapters/pi/adapter.json b/adapters/pi/adapter.json index fe124bc..6adbe4b 100644 --- a/adapters/pi/adapter.json +++ b/adapters/pi/adapter.json @@ -1,6 +1,6 @@ { "name": "pi", - "description": "Pi Coding Agent — AGENTS.md + .pi/skills symlink + .pi/extensions/memory-hook.ts (TS extension that calls .agent/harness/hooks/pi_post_tool.py for tool_result episodic logging).", + "description": "Pi Coding Agent — AGENTS.md + .pi/skills symlink + .pi/extensions/memory-hook.ts. The extension subscribes to tool_result (bash/edit/write only) for episodic logging and runs auto_dream.py on session shutdown. No Python subprocess per tool call — all scoring is inline TypeScript.", "files": [ { "src": "AGENTS.md", @@ -11,12 +11,6 @@ "src": "memory-hook.ts", "dst": ".pi/extensions/memory-hook.ts", "merge_policy": "overwrite" - }, - { - "src": ".agent/harness/hooks/pi_post_tool.py", - "dst": ".agent/harness/hooks/pi_post_tool.py", - "merge_policy": "overwrite", - "from_stack": true } ], "skills_link": { diff --git a/adapters/pi/memory-hook.ts b/adapters/pi/memory-hook.ts index 1b1c108..5e4d6f5 100644 --- a/adapters/pi/memory-hook.ts +++ b/adapters/pi/memory-hook.ts @@ -1,183 +1,346 @@ -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { existsSync } from "node:fs"; -import { spawn } from "node:child_process"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url)); -const PROJECT_ROOT = path.resolve(EXTENSION_DIR, "..", ".."); -const HOOK_SCRIPT = path.join( - PROJECT_ROOT, - ".agent", - "harness", - "hooks", - "pi_post_tool.py", -); - -// Timeout for the Python child. If the hook hangs (bad import, stuck I/O), -// Pi's tool_result handler stays blocked because the extension awaits -// runHook(). Override via $AGENT_HOOK_TIMEOUT_MS for slow machines. -const HOOK_TIMEOUT_MS = (() => { - const raw = process.env.AGENT_HOOK_TIMEOUT_MS?.trim(); - const n = raw ? Number.parseInt(raw, 10) : NaN; - return Number.isFinite(n) && n > 0 ? n : 3000; -})(); - -let warnedMissingHook = false; -let warnedMissingPython = false; -let warnedHookFailure = false; -let warnedHookTimeout = false; - -type PythonCandidate = { - command: string; - args: string[]; -}; - -type HookResult = - | { kind: "ok" } - | { kind: "spawn-error" } - | { kind: "hook-failure"; stderr: string; exitCode: number | null } - | { kind: "timeout" }; - -function pythonCandidates(): PythonCandidate[] { - const envPy = process.env.AGENT_PYTHON?.trim(); - const out: PythonCandidate[] = []; - if (envPy) out.push({ command: envPy, args: [] }); - out.push({ command: "python3", args: [] }); - out.push({ command: "python", args: [] }); - out.push({ command: "py", args: ["-3"] }); +/** + * memory-hook.ts — agentic-stack episodic logger for Pi Coding Agent + * + * Pi has no settings.json hook file like Claude Code, but it has a full + * TypeScript extension system. This extension: + * + * - Listens to `tool_result` and writes episodic entries to + * AGENT_LEARNINGS.jsonl after bash / edit / write calls (same signals + * Claude Code's PostToolUse hook captures — read/find/ls are noise and + * are intentionally skipped). + * - Runs `auto_dream.py` once on `session_shutdown` (process exit) so the + * dream cycle fires at the natural end of a work session, exactly like + * Claude Code's `Stop` hook. Pi's SessionShutdownEvent has no `reason` + * payload — earlier versions of this hook tried to filter on + * event.reason and rejected every event; the dream cycle never ran. + * + * Place: .pi/extensions/memory-hook.ts (project-local, auto-discovered) + * Reload: /reload inside pi, or restart pi. + * + * Design decisions + * ───────────────── + * • process.cwd() for all paths — avoids import.meta.url which jiti can + * leave undefined in CJS-transform mode. + * • All scoring / reflection logic is inline TypeScript — no Python + * subprocess per tool call, no spawn overhead, no timeout complexity. + * • Direct fs.appendFileSync — single atomic write per entry; POSIX + * O_APPEND is atomic for payloads < PIPE_BUF (typically 4 KB). Entries + * are well under that limit. + */ + +import type { + ExtensionAPI, + ToolResultEvent, +} from "@mariozechner/pi-coding-agent"; +import { + isBashToolResult, + isEditToolResult, + isWriteToolResult, +} from "@mariozechner/pi-coding-agent"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execSync } from "node:child_process"; + +// ── Paths ──────────────────────────────────────────────────────────────────── + +const CWD = process.cwd(); +const AGENT_ROOT = path.join(CWD, ".agent"); +const EPISODIC = path.join(AGENT_ROOT, "memory", "episodic", "AGENT_LEARNINGS.jsonl"); +const DREAM_SCRIPT = path.join(AGENT_ROOT, "memory", "auto_dream.py"); +const PATTERNS_CFG = path.join(AGENT_ROOT, "protocols", "hook_patterns.json"); + +// ── Importance patterns ─────────────────────────────────────────────────────── +// Mirrors claude_code_post_tool.py's _UNIVERSAL_HIGH / _UNIVERSAL_MEDIUM so +// both harnesses score identically. + +const HIGH_RE = /\b(deploy(?:ment)?|release|rollback|migrat(?:e|ion)|schema|alter\s+table|drop\s+table|create\s+table|truncate|prod(?:uction)?|staging|force.?push|push\s+--force|secret|credential)\b/i; +const MED_RE = /\b(commit|push|merge|rebase|test|spec|build|bundle|compile|install|upgrade|uninstall|delete|remove|unlink|chmod|chown|cron|systemctl)\b/i; + +// Validate each fragment individually so one bad regex doesn't disable every +// custom rule. Mirrors claude_code_post_tool.py's _filter_valid + incremental +// merge so the two harnesses behave identically on malformed user patterns. +function _validFragments(frags: unknown): string[] { + if (!Array.isArray(frags)) return []; + const out: string[] = []; + for (const raw of frags) { + if (typeof raw !== "string" || !raw) continue; + try { + new RegExp(raw); + out.push(raw); + } catch { + // Bad fragment — skip it, keep the rest. + } + } return out; } -function tryRun( - candidate: PythonCandidate, - payload: Record, -): Promise { - return new Promise((resolve) => { - let settled = false; - const settle = (r: HookResult) => { - if (settled) return; - settled = true; - resolve(r); - }; - - let stderrBuf = ""; - const child = spawn( - candidate.command, - [...candidate.args, HOOK_SCRIPT], - { - cwd: PROJECT_ROOT, - // Capture stderr so a "hook-failure" notification can include - // the actual error instead of being undiagnosable. - stdio: ["pipe", "ignore", "pipe"], - }, - ); - - const timer = setTimeout(() => { - try { child.kill("SIGKILL"); } catch { /* already dead */ } - settle({ kind: "timeout" }); - }, HOOK_TIMEOUT_MS); - - child.on("error", () => { - clearTimeout(timer); - settle({ kind: "spawn-error" }); - }); - child.on("spawn", () => { +function _mergePattern(frags: string[]): RegExp | null { + if (!frags.length) return null; + // Try the merged form first; fall back to first-wins if two fragments + // conflict only when combined (e.g., duplicate named groups). + try { + return new RegExp(`\\b(${frags.join("|")})\\b`, "i"); + } catch { + const surviving: string[] = []; + for (const frag of frags) { try { - child.stdin.end(JSON.stringify(payload)); + new RegExp(`\\b(${[...surviving, frag].join("|")})\\b`, "i"); + surviving.push(frag); } catch { - // stdin closed before we could write — handled by close/error + // Drop this fragment; keep what we have. } - }); - if (child.stderr) { - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { - // bound stderr buffer to avoid memory blowup on a wedged hook - if (stderrBuf.length < 4096) stderrBuf += chunk; - }); } - child.on("close", (code) => { - clearTimeout(timer); - if (code === 0) { - settle({ kind: "ok" }); - } else { - settle({ kind: "hook-failure", stderr: stderrBuf.trim(), exitCode: code }); - } - }); - }); + return surviving.length + ? new RegExp(`\\b(${surviving.join("|")})\\b`, "i") + : null; + } +} + +function _loadUserPatterns(): { high: RegExp | null; medium: RegExp | null } { + if (!fs.existsSync(PATTERNS_CFG)) return { high: null, medium: null }; + let cfg: { high_stakes?: unknown; medium_stakes?: unknown }; + try { + cfg = JSON.parse(fs.readFileSync(PATTERNS_CFG, "utf8")); + } catch { + return { high: null, medium: null }; + } + return { + high: _mergePattern(_validFragments(cfg.high_stakes)), + medium: _mergePattern(_validFragments(cfg.medium_stakes)), + }; +} + +const { high: userHigh, medium: userMed } = _loadUserPatterns(); + +function _importance(toolName: string, subject: string): number { + if (HIGH_RE.test(subject) || userHigh?.test(subject)) return 9; + if (toolName === "edit" || toolName === "write") { + return MED_RE.test(subject) || userMed?.test(subject) ? 6 : 5; + } + if (MED_RE.test(subject) || userMed?.test(subject)) return 6; + return 3; +} + +function _painScore(importance: number, success: boolean): number { + if (!success) return importance >= 9 ? 10 : 8; + if (importance >= 8) return 5; + if (importance >= 6) return 3; + return 2; +} + +// ── Action label ───────────────────────────────────────────────────────────── + +function _actionLabel(event: ToolResultEvent): string { + if (isBashToolResult(event)) { + const cmd = event.input.command.replace(/\s+/g, " ").slice(0, 80); + return `bash: ${cmd}`; + } + if (isEditToolResult(event)) return `edit: ${event.input.path}`; + if (isWriteToolResult(event)) return `write: ${event.input.path}`; + return `tool:${event.toolName}`; +} + +// ── Reflection (what the dream cycle clusters on) ──────────────────────────── + +function _reflection(event: ToolResultEvent, success: boolean): string { + if (isBashToolResult(event)) { + const cmd = event.input.command.replace(/\s+/g, " ").slice(0, 100); + const m = HIGH_RE.exec(cmd) ?? userHigh?.exec(cmd); + if (m) { + const domain = m[0].toLowerCase().replace(/\s+/g, "-"); + return success + ? `High-stakes bash completed (${domain}): ${cmd}` + : `High-stakes bash FAILED (${domain}): ${cmd}`; + } + return success ? `Ran: ${cmd}` : `Command failed: ${cmd}`; + } + + if (isEditToolResult(event)) { + const p = event.input.path; + if (!success) return `Edit failed on ${p}`; + // Pi's EditToolInput is flat: { path, oldText, newText }. There is no + // `edits` array — that's Claude Code's MultiEdit shape. + const oldText = (event.input as { oldText?: unknown }).oldText; + const newText = (event.input as { newText?: unknown }).newText; + if (typeof oldText === "string" && typeof newText === "string") { + const old = oldText.slice(0, 40).replace(/\n/g, "↵"); + const neu = newText.slice(0, 40).replace(/\n/g, "↵"); + return `Edited ${p}: replaced '${old}' with '${neu}'`; + } + return `Edited ${p}`; + } + + if (isWriteToolResult(event)) { + const p = event.input.path; + return success ? `Wrote ${p}` : `Write failed on ${p}`; + } + + return `Tool ${event.toolName} ${success ? "completed" : "failed"}`; +} + +// ── Commit SHA (module-level cache, invalidated on HEAD-changing bash) ────── +// Caching avoids forking git on every tool call; invalidating on commit-style +// commands keeps the recorded SHA accurate across long pi sessions where the +// user commits / merges / rebases mid-flight. + +let _cachedSha: string | undefined; + +// Match `git ` where subcommand is one we know moves HEAD. +// `[^|;&]*?` allows option flags or porcelain wrappers between `git` and +// the subcommand (e.g. `git -c advice.detachedHead=false checkout main`, +// `git -C path switch dev`). The lazy quantifier + the shell-separator +// negative class keep us inside a single command — we don't want +// `git status; git commit` to match if the subcommand never reaches us. +const _SHA_INVALIDATING = /\bgit\b[^|;&]*?\b(commit|reset|checkout|switch|merge|rebase|cherry-pick|revert|pull|fetch|clone)\b/; + +function _commitSha(): string { + if (_cachedSha !== undefined) return _cachedSha; + try { + _cachedSha = execSync("git rev-parse HEAD", { + cwd: CWD, + timeout: 2000, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + } catch { + _cachedSha = ""; + } + return _cachedSha; } -async function runHook( - payload: Record, -): Promise< - | "ok" - | "missing-hook" - | "missing-python" - | "timeout" - | { kind: "hook-failure"; stderr: string; exitCode: number | null } -> { - if (!existsSync(HOOK_SCRIPT)) return "missing-hook"; - for (const candidate of pythonCandidates()) { - const result = await tryRun(candidate, payload); - if (result.kind === "ok") return "ok"; - if (result.kind === "timeout") return "timeout"; - if (result.kind === "hook-failure") return result; - // spawn-error → try the next python candidate +function _maybeInvalidateSha(event: ToolResultEvent): void { + if (!isBashToolResult(event)) return; + const cmd = event.input.command; + if (typeof cmd === "string" && _SHA_INVALIDATING.test(cmd)) { + _cachedSha = undefined; } - return "missing-python"; } +// ── Episodic write ─────────────────────────────────────────────────────────── + +function _appendEntry(entry: Record): void { + fs.mkdirSync(path.dirname(EPISODIC), { recursive: true }); + fs.appendFileSync(EPISODIC, JSON.stringify(entry) + "\n", "utf8"); +} + +// ── Auto-dream helpers ──────────────────────────────────────────────────────── + +// session_shutdown is fired exactly once on process exit (see pi-coding-agent +// agent-session.ts: `emit({ type: "session_shutdown" })`). The event has no +// `reason` field — earlier versions of this hook filtered on event.reason and +// rejected every event, so the dream cycle never ran. Keep this handler simple. +let _dreamRunning = false; + +async function _runDream(pi: ExtensionAPI, hasUI: boolean): Promise { + if (!fs.existsSync(DREAM_SCRIPT)) return; + // Re-entrancy guard: if pi fires session_shutdown twice during teardown + // (or if the user opens two pi sessions that exit at the same instant in + // the same project), only run the dream cycle once. auto_dream.py rewrites + // AGENT_LEARNINGS.jsonl whole-file, so concurrent runs would clobber each + // other. + if (_dreamRunning) return; + _dreamRunning = true; + + try { + // Try python3 then python — mirrors the TypeScript hook's pythonCandidates() + // from the old subprocess approach, kept here for Windows / pyenv compat. + for (const py of ["python3", "python"]) { + try { + const { code, stderr } = await pi.exec(py, [DREAM_SCRIPT], { + cwd: CWD, + timeout: 30_000, + }); + if (code === 0) return; + // Non-zero exit from python (not a spawn error): surface once and bail. + if (hasUI) { + const firstLine = (stderr ?? "").split(/\r?\n/)[0] || `exit ${code}`; + pi.sendMessage({ + customType: "agentic-stack", + content: `dream cycle failed: ${firstLine}`, + display: true, + }); + } + return; + } catch { + // spawn error for this candidate → try next + } + } + // Both candidates failed to spawn — python not on PATH, silently skip. + } finally { + _dreamRunning = false; + } +} + +// ── Extension entry point ──────────────────────────────────────────────────── + export default function (pi: ExtensionAPI) { - pi.on("tool_result", async (event, ctx) => { - const payload = { - tool_name: event.toolName, - tool_input: event.input ?? {}, - content: event.content ?? [], - details: event.details ?? {}, - isError: event.isError ?? false, + + // ── tool_result: episodic logging ──────────────────────────────────────── + + pi.on("tool_result", (_event, _ctx) => { + const event = _event; + + // Only log the three tool types that carry meaningful signal. + // read / find / ls / grep are noise — same filter as Claude Code's + // "^(Bash|Edit|Write)$" PostToolUse matcher. + if ( + !isBashToolResult(event) && + !isEditToolResult(event) && + !isWriteToolResult(event) + ) return; + + // Invalidate the cached commit SHA when bash mutates HEAD so subsequent + // entries record the post-commit SHA, not the stale session-start one. + _maybeInvalidateSha(event); + + const success = !event.isError; + + // Subject string for pattern matching. + const subject = isBashToolResult(event) + ? event.input.command + : (event as { input: { path: string } }).input.path; + + const imp = _importance(event.toolName, subject); + + // Skip routine low-importance bash successes (grep, ls, cat, echo, etc.) + // to keep the episodic log signal-rich. Failures always get logged so + // the failure-threshold rewrite flag fires correctly. + if (event.toolName === "bash" && imp <= 3 && success) return; + + const entry: Record = { + timestamp: new Date().toISOString(), + skill: "pi", + action: _actionLabel(event).slice(0, 200), + result: success ? "success" : "failure", + detail: subject.slice(0, 500), + pain_score: _painScore(imp, success), + importance: imp, + reflection: _reflection(event, success), + confidence: 0.7, + source: { + skill: "pi", + run_id: `pi-${process.pid}`, + commit_sha: _commitSha(), + }, + evidence_ids: [], }; try { - const result = await runHook(payload); - if (result === "missing-hook" && !warnedMissingHook) { - warnedMissingHook = true; - ctx.ui.notify( - "agentic-stack pi memory hook missing; automatic episodic logging disabled.", - "warning", - ); - } else if (result === "missing-python" && !warnedMissingPython) { - warnedMissingPython = true; - ctx.ui.notify( - "agentic-stack pi memory hook: python3/python not found; automatic episodic logging disabled.", - "warning", - ); - } else if (result === "timeout" && !warnedHookTimeout) { - warnedHookTimeout = true; - ctx.ui.notify( - `agentic-stack pi memory hook timed out (>${HOOK_TIMEOUT_MS}ms); subsequent calls may be skipped. Override with $AGENT_HOOK_TIMEOUT_MS.`, - "warning", - ); - } else if ( - typeof result === "object" && - result.kind === "hook-failure" && - !warnedHookFailure - ) { - warnedHookFailure = true; - // Surface the first line of stderr so the failure is diagnosable. - const firstLine = result.stderr.split(/\r?\n/, 1)[0] || `(exit ${result.exitCode})`; - ctx.ui.notify( - `agentic-stack pi memory hook failed: ${firstLine}`, - "warning", - ); - } + _appendEntry(entry); } catch { - if (!warnedHookFailure) { - warnedHookFailure = true; - ctx.ui.notify( - "agentic-stack pi memory hook errored unexpectedly; continuing without automatic episodic logging.", - "warning", - ); - } + // Never let a memory write crash pi. } }); + + // ── session_shutdown: dream cycle ──────────────────────────────────────── + // Pi's SessionShutdownEvent fires once on process exit and carries no + // payload (see pi-coding-agent agent-session.ts:emit({type:"session_shutdown"})). + // Earlier versions of this hook tried to filter on event.reason — that + // field doesn't exist, so the filter rejected every event and the dream + // cycle never ran. Just always run. + + pi.on("session_shutdown", async (_event, ctx) => { + await _runDream(pi, ctx.hasUI); + }); } diff --git a/docs/per-harness/pi.md b/docs/per-harness/pi.md index 51ff9e4..49eca08 100644 --- a/docs/per-harness/pi.md +++ b/docs/per-harness/pi.md @@ -6,13 +6,11 @@ and a TypeScript extension system. Our adapter layers the portable `.agent/` brain on top so you keep one knowledge base across harnesses. ## What the adapter installs -- `AGENTS.md` at project root (pi reads this natively). Skipped if one - already exists, since pi/hermes/opencode share this file. -- `.pi/` directory -- `.pi/skills` symlinked to `.agent/skills` (falls back to copy on - platforms without symlinks, e.g. Windows without developer mode) -- `.pi/extensions/memory-hook.ts`, auto-discovered by pi at startup and - wired to the `tool_result` event for episodic logging +| Path | What | +|------|------| +| `AGENTS.md` | Root-level context file pi reads natively. Skipped if one already exists (pi / hermes / opencode share this file). | +| `.pi/skills` | Symlink → `.agent/skills`. Falls back to copy on platforms without symlink support (e.g. Windows without developer mode). | +| `.pi/extensions/memory-hook.ts` | Project-local extension auto-discovered by pi at startup. Logs `bash`/`edit`/`write` tool results to `.agent/memory/episodic/AGENT_LEARNINGS.jsonl` and runs `auto_dream.py` on session end. | ## Install ```bash @@ -28,26 +26,54 @@ npm install -g @mariozechner/pi-coding-agent pi ``` -## How it works -- Pi loads `AGENTS.md` (or `CLAUDE.md`) from `~/.pi/agent/` and walks - the current directory up to the filesystem root, aggregating - context. -- Skills at `.pi/skills//SKILL.md` use the same frontmatter-plus - -body shape as agentskills.io and our `.agent/skills/` layout. -- Pi extensions live in `.pi/extensions/` (TypeScript). The adapter's - `memory-hook.ts` listens to `tool_result` and forwards each tool result - to `.agent/harness/hooks/pi_post_tool.py`, which reuses the same - scoring / reflection logic as the Claude Code hook. +## How episodic logging works +The extension subscribes to pi's `tool_result` event (the equivalent of +Claude Code's `PostToolUse`). It: + +1. **Filters** — only `bash`, `edit`, `write` are logged. `read`, `find`, + `ls`, `grep` are skipped (noise). Routine low-importance bash calls + (cat, echo, ls, grep) are also skipped. +2. **Scores** — importance (1–10) and pain_score are computed inline from + the command / path using the same regex patterns as + `claude_code_post_tool.py`. User-defined patterns in + `.agent/protocols/hook_patterns.json` are also loaded. +3. **Writes** — a structured JSONL entry is appended directly via + `fs.appendFileSync`. No Python subprocess is spawned per tool call. +4. **Dreams** — on session shutdown (quit / new session / resume) the + extension runs `python3 .agent/memory/auto_dream.py` to cluster and + stage episodic entries. This mirrors Claude Code's `Stop` hook. + +## Verify +After installing, run pi and execute any bash command. Then: + +```bash +tail -1 .agent/memory/episodic/AGENT_LEARNINGS.jsonl +``` + +You should see a JSON entry with `"skill": "pi"` and an `action` derived +from the tool that just ran. + +In pi: ask "what's in my LESSONS file?" — it should read +`.agent/memory/semantic/LESSONS.md`. ## Troubleshooting -- If pi doesn't see your skills, run `pi skills list` — it should - print entries from `.pi/skills/`. If the directory is a broken - symlink, re-run `./install.sh pi` to rebuild. -- If episodic logging stays empty, make sure Python is available as - `python3`, `python`, or via `AGENT_PYTHON`, since the extension shells - out to `.agent/harness/hooks/pi_post_tool.py`. -- On Windows without symlink support, the installer copies - `.agent/skills/` instead. Changes to `.agent/skills/` won't - propagate — re-run the installer to sync. -- Pi's multi-provider gateway is independent of the brain. The - portable `.agent/` doesn't care which model you use. +- **Skills not visible** — run `pi skills list`. If `.pi/skills` is a + broken symlink, re-run `./install.sh pi` to rebuild it. +- **AGENT_LEARNINGS.jsonl stays empty** — check that the extension + loaded: pi's startup header lists loaded extensions. If + `memory-hook.ts` is absent, re-run `./install.sh pi`. If it's listed + but entries are missing, make sure you ran `bash`/`edit`/`write` tools + (read-only sessions produce no entries by design). +- **Dream cycle never runs** — the extension runs `auto_dream.py` on + `session_shutdown`. If you killed pi with SIGKILL instead of a clean + exit, the shutdown event won't fire. Add a fallback cron for those + cases: + ```bash + crontab -e + # add: + 0 3 * * * python3 /path/to/project/.agent/memory/auto_dream.py \ + >> /path/to/project/.agent/memory/dream.log 2>&1 + ``` +- **Windows without symlink support** — the installer copies + `.agent/skills/` instead. Changes to `.agent/skills/` won't propagate + automatically; re-run `.\install.ps1 pi` to sync. diff --git a/tests/test_decay_timezone.py b/tests/test_decay_timezone.py new file mode 100644 index 0000000..09bea45 --- /dev/null +++ b/tests/test_decay_timezone.py @@ -0,0 +1,98 @@ +"""Regression tests for the decay.py timezone fix. + +PR #24 fixed an `aware vs naive datetime` crash on every clean pi exit (the +new `session_shutdown` hook surfaced it). This test pins the four shapes +decay needs to handle so the next refactor doesn't regress. +""" +import datetime +import os +import sys +import tempfile +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(REPO_ROOT, ".agent", "memory")) +sys.path.insert(0, os.path.join(REPO_ROOT, ".agent", "harness")) + +from decay import decay_old_entries # noqa: E402 + + +def _utc_iso(dt): + return dt.isoformat() + + +class DecayTimezoneTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.addCleanup(lambda: __import__("shutil").rmtree(self.tmp, ignore_errors=True)) + # Anchors: now, yesterday, 200d ago. 200d > DECAY_DAYS (90). + self.now_utc = datetime.datetime.now(datetime.timezone.utc) + self.recent_utc = self.now_utc - datetime.timedelta(days=1) + self.old_utc = self.now_utc - datetime.timedelta(days=200) + + def _entry(self, ts_str, *, salience_low=True): + # pain_score / importance / recurrence drive salience_score(). + # Defaults here keep score < SALIENCE_FLOOR (2.0) so old entries + # actually decay. Override for the "high-salience old entry" case. + return { + "timestamp": ts_str, + "pain_score": 1 if salience_low else 9, + "importance": 1 if salience_low else 9, + "recurrence_count": 1, + } + + def test_aware_utc_old_entry_archives(self): + entries = [self._entry(_utc_iso(self.old_utc))] + kept, archived = decay_old_entries(entries, self.tmp) + self.assertEqual(len(kept), 0) + self.assertEqual(len(archived), 1) + + def test_aware_utc_recent_entry_keeps(self): + entries = [self._entry(_utc_iso(self.recent_utc))] + kept, archived = decay_old_entries(entries, self.tmp) + self.assertEqual(len(kept), 1) + self.assertEqual(len(archived), 0) + + def test_naive_old_entry_treated_as_utc(self): + # Pre-PR Python writers emitted naive timestamps. Decay must treat + # them as UTC and archive correctly without crashing on aware-vs-naive. + naive_old = self.old_utc.replace(tzinfo=None).isoformat() + entries = [self._entry(naive_old)] + kept, archived = decay_old_entries(entries, self.tmp) + self.assertEqual(len(kept), 0) + self.assertEqual(len(archived), 1) + + def test_mixed_naive_and_aware_no_crash(self): + # The exact crash this PR fixes: comparing naive cutoff to aware + # entry. Flip into the same pass to make sure both shapes coexist. + entries = [ + self._entry(_utc_iso(self.old_utc)), + self._entry(self.old_utc.replace(tzinfo=None).isoformat()), + self._entry(_utc_iso(self.recent_utc)), + self._entry(self.recent_utc.replace(tzinfo=None).isoformat()), + ] + kept, archived = decay_old_entries(entries, self.tmp) + self.assertEqual(len(archived), 2) + self.assertEqual(len(kept), 2) + + def test_malformed_timestamp_kept(self): + # ValueError on fromisoformat → keep the entry, don't crash, don't archive. + entries = [self._entry("not-a-date"), self._entry("")] + kept, archived = decay_old_entries(entries, self.tmp) + self.assertEqual(len(archived), 0) + self.assertEqual(len(kept), 2) + + def test_archive_filename_uses_utc_date(self): + # Pre-PR the archive filename used `datetime.date.today()` (local + # date) while the cutoff above used UTC. Asymmetric. Filename now + # tracks UTC so a tz-jumping user gets a deterministic path. + entries = [self._entry(_utc_iso(self.old_utc))] + kept, archived = decay_old_entries(entries, self.tmp) + files = os.listdir(self.tmp) + self.assertEqual(len(files), 1) + expected_date = datetime.datetime.now(datetime.timezone.utc).date().isoformat() + self.assertEqual(files[0], f"archive_{expected_date}.jsonl") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_install_e2e.py b/tests/test_install_e2e.py index e36a6bc..f80c4d0 100644 --- a/tests/test_install_e2e.py +++ b/tests/test_install_e2e.py @@ -144,19 +144,19 @@ def test_install_three_adapters_independent(self): [".cursor/rules/agentic-stack.mdc"], ) - # ---- pi skills_link + from_stack --------------------------------- + # ---- pi skills_link + extension ---------------------------------- - def test_pi_install_creates_symlink_and_syncs_hook(self): + def test_pi_install_wires_extension_and_skills(self): self._install("pi") # AGENTS.md self.assertTrue((self.target / "AGENTS.md").is_file()) - # memory-hook.ts (adapter-local file) + # memory-hook.ts is a self-contained TypeScript extension: all + # scoring + reflection logic inline, no Python subprocess per tool + # call. The old `from_stack` sync of pi_post_tool.py was removed + # when this hook was rewritten (the .py file still ships in the + # brain template at .agent/harness/hooks/ for standalone use, but + # the pi adapter no longer manages it). self.assertTrue((self.target / ".pi" / "extensions" / "memory-hook.ts").is_file()) - # pi_post_tool.py (from_stack: true — synced from agentic-stack source) - self.assertTrue( - (self.target / ".agent" / "harness" / "hooks" / "pi_post_tool.py").is_file(), - "from_stack file pi_post_tool.py was not synced", - ) # skills symlink skills_dst = self.target / ".pi" / "skills" self.assertTrue(skills_dst.is_symlink() or skills_dst.is_dir())