Repository navigation
feat(sync-memory): bootstrap workspace/notes symlink to private repo - #831
Conversation
Fixes the silent sync break from PR #769: the workspace migration moved notes/ to ~/.sutando/workspace/notes/ as a fresh real dir, leaving the pre-2026-05-11 symlink convention (workspace/notes → private repo notes) unwired. Result: edits to workspace/notes/ stopped propagating cross-machine. This adds an idempotent symlink-bootstrap block to sync-memory.sh: - already-correct symlink: no-op - real dir at target: log WARN with reconcile recipe (no silent overwrite) - missing: create the symlink Restores the pre-#769 architecture: edits to $SUTANDO_WORKSPACE/notes/ land in the private repo automatically via symlink, no rsync needed; git pull/push handles cross-machine. Same pattern as the script's existing private-repo auto-bootstrap (env var → first-run clone). Manual one-time reconcile of existing real-dir data into the private repo (rsync with build-artifact excludes, then mv + ln -s) is left to the operator because it touches local files and the safe defaults can't know what to keep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi
left a comment
There was a problem hiding this comment.
Cold review (MacBook): LGTM with 2 nits worth thinking about pre-merge.
Architecture: ✅ Idempotent, no destructive auto-action, WARN-first on real-dir state, inline reconcile recipe.
State check on MacBook (this host): ~/.sutando/workspace/notes is a real dir (PR #769 victim, matches Mini's flag). SUTANDO_WORKSPACE env var unset; default resolves correctly. Post-merge: bootstrap will hit the WARN branch and emit the manual reconcile recipe — exactly the intended flow for cross-fleet rollout.
Nit 1 — env-var name inconsistency (medium):
New block uses `SUTANDO_WORKSPACE_DIR` (line 142); rest of script + CLAUDE.md use `SUTANDO_WORKSPACE`. The `_DIR` suffix sidesteps the existing collision (line 78 has `SUTANDO_WORKSPACE → ~/Desktop/sutando` as the public-repo checkout, which contradicts CLAUDE.md's per-user-workspace semantic). Net: three names for workspace-ish concepts coexist, and anyone pinning `SUTANDO_WORKSPACE` per CLAUDE.md gets the legacy semantic on this script.
Cleaner shape: rename line-78 source to `SUTANDO_REPO_DIR` (matches what it actually is — the public-repo path), use canonical `SUTANDO_WORKSPACE` in the new block, treat `SUTANDO_WORKSPACE_DIR` as backcompat alias.
Nit 2 — WARN reconcile recipe missing exclude pattern (low):
Mini's task spelled out the exclude set (`node_modules/`, `.cache/`, `.remotion/`, `dist/`, rerun mp4s, ep[0-9]-v[0-9].mp4, etc.). The WARN echo just emits `rsync -au $src/ $tgt/` — blindly running that on a 19,845-file workspace/notes would sync build artifacts + iteration mp4s into the canonical store. Worth adding a hint:
`rsync -au --exclude='/node_modules/' --exclude='/.cache/' --exclude='/.remotion/' --exclude='/dist/' --exclude='-rerun.mp4' $src/ $tgt/`
Otherwise ship-it. Non-blocking on either nit if you'd rather land + clean up after.
🤖 cold review via Sutando-MacBook
…eview #831 nit 2) Without these excludes, copy-pasting the WARN'd reconcile recipe would sync all of workspace/notes/ (19,845 files on Mini's repro) into the canonical private-repo store — including node_modules/, remotion .cache, iteration mp4s, etc. Matches the exclude set used during Mini's manual reconcile. Nit 1 (env-var name SUTANDO_WORKSPACE_DIR vs canonical SUTANDO_WORKSPACE) deferred to a separate cleanup PR — it touches the line-78 legacy semantic and 19+ refs across the codebase, larger scope than this architecture fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#831 review nit 2) Without these excludes, copy-pasting the WARN'd reconcile recipe would sync all of workspace/notes/ (19,845 files on Mini's repro case) into the canonical private-repo store — including node_modules/, remotion .cache, iteration mp4s. Matches the exclude set used during Mini's manual reconcile. Nit 1 (env-var SUTANDO_WORKSPACE_DIR vs canonical SUTANDO_WORKSPACE) deferred to a separate cleanup PR — touches line-78 legacy semantic and 19+ refs across the codebase, larger scope than this architecture fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… nit 1) Per MacBook review: SUTANDO_WORKSPACE had drifted to two semantics in the codebase — CLAUDE.md + the new code (src/workspace_default.py, watch-tasks-stream, core_heartbeat, web-client, cross-node-sync, phone-conversation) treat it as the per-user workspace dir (~/.sutando/workspace/); but three legacy scripts (scripts/sync-memory.sh, src/session-handoff.sh, skills/loop-self-audit/scripts/audit.sh) used it to mean the public-repo checkout (~/Desktop/sutando/). Standardizing on the CLAUDE.md semantic (canonical workspace dir). The three legacy scripts now read SUTANDO_REPO_DIR as the canonical name for the public-repo path, falling back to SUTANDO_WORKSPACE for backcompat with a one-time deprecation hint. In sync-memory.sh specifically, the new workspace-symlink-bootstrap block now reads canonical SUTANDO_WORKSPACE (was the placeholder SUTANDO_WORKSPACE_DIR). A safety guard skips the bootstrap if SUTANDO_WORKSPACE points at a public-repo checkout (legacy semantic detected via .git/) — protects users mid-migration from creating wrong symlinks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (PR #831 cleanup) Per owner + MacBook-bot convergent review, three cleanups bundled: 1. Drop the SUTANDO_WORKSPACE legacy fallback from REPO_DIR resolution in sync-memory.sh and session-handoff.sh. CLAUDE.md reserves SUTANDO_WORKSPACE for the per-user workspace dir (~/.sutando/workspace/); using it as a REPO_DIR alias would silently pick the wrong path on CLAUDE.md-compliant hosts. Backcompat is moot: pre-#831 hosts get a clear "set SUTANDO_REPO_DIR" error and a 1-line .env edit. 2. Auto-detect REPO_DIR from $SCRIPT_PARENT when the parent dir carries a sutando-checkout signature (CLAUDE.md + skills/ + .git/). Zero-config for the common case (`bash <wherever>/sutando/scripts/sync-memory.sh`) — works regardless of where the user cloned. The ~/Desktop/sutando default only kicks in for the memory-sync-dir-copy invocation where SCRIPT_PARENT is the sync-dir, not the public repo. Owner flagged the hardcoded default as a personal-convention smell that breaks `~/Code/`, `~/src/`, etc. 3. Untrack skills/loop-self-audit/scripts/audit.sh — it was untracked locally and got swept into dc84ba3 by `git add`. Not in scope for the sync-memory cleanup PR; will be its own PR later. File stays on disk for the local skill to keep working. New resolution chain: 1. $SUTANDO_REPO_DIR (explicit override) 2. $SCRIPT_PARENT if signature matches (zero-config common case) 3. $HOME/Desktop/sutando (last-resort default) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sonichi
left a comment
There was a problem hiding this comment.
Re-review (MacBook, post-bundle): LGTM, ship it. All 3 nits cleanly addressed + a bonus.
What landed since last review:
- ✅ Nit 1 (env-var naming) —
REPO_DIRresolution is now: `SUTANDO_REPO_DIR` → `SCRIPT_PARENT` (signature: CLAUDE.md + skills/ + .git/) → `~/Desktop/sutando` last-resort. NO `SUTANDO_WORKSPACE` fallback for REPO_DIR — the semantic collision is gone. WS_DIR (workspace) uses canonical `SUTANDO_WORKSPACE`. Consistent rewrite in `src/session-handoff.sh` too. - ✅ Nit 2 (WARN exclude pattern) — Comprehensive exclude list now in the WARN echo (node_modules / .cache / .remotion / dist / rerun mp4s / versioned mp4s / ep-vN / sutando-wire-*). Users following the recipe won't pollute the canonical store.
- ✅ Scope creep fixed — `skills/loop-self-audit/scripts/audit.sh` no longer in the diff. PR is bounded: `notes/.gitkeep` delete + `scripts/sync-memory.sh` + `src/session-handoff.sh`. 144 lines total.
Bonus you added that I didn't ask for: the disambiguation guard at lines after WS_DIR resolution — detects when SUTANDO_WORKSPACE points at a public-repo checkout (legacy semantic) by checking for `.git/` and SKIPS the symlink bootstrap with a clear message. Catches the host-misconfig case that would otherwise create a broken symlink. Nice.
State check on this MacBook: workspace/notes is still a real dir (PR #769 victim). Post-merge run will hit the WARN branch and emit the comprehensive reconcile recipe. Cross-fleet rollout pending Chi's Y on the proactive DM (still in his queue from ~30min ago).
Ship-it. Non-blocking.
🤖 re-review via Sutando-MacBook
|
recheck |
…pe (#835) Followup to PR #831's auto-migration. When ~/.sutando-memory-sync → ~/.sutando/memory-sync moves, any pre-existing symlinks pointing at the legacy path are silently broken (target moved out from under them). The migration can't enumerate external symlinks itself, so this scan emits one WARN line per broken symlink with an exact rm+ln re-point recipe. Caught today on MacBook host: 10 personal-* skill symlinks + the workspace/notes symlink were broken post-merge. Each was a manual rm+ln fix; this scan would have surfaced all 11 in one pass. Scope: - ~/.sutando/ — workspace + sync conventions - ~/.claude/skills/ — symlinks to private-repo skills - ~/.config/ — user-installed convenience symlinks Full ~/ scan is too slow; these three dirs cover the known offenders. Only runs when __MIGRATED=1 (i.e. the script actually moved the dir this run). Idempotent: post-migration runs skip (__MIGRATED stays 0, no scan). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…845) Reduces CLAUDE.md from 399 → 246 lines (~38% trim) by moving content that agents don't need on every session-load into docs/, while keeping every section that's actually per-session operating instructions. What moved: 1. **Built-in tools (~143 lines)** — the bash recipe catalog for Calendar, Gmail, iMessage, X, Reminders, macos-use, Browser, meeting join, etc. → new `docs/built-in-tools.md`. Agents reach for it on demand when they need a tool, rather than carrying 150 lines of recipes on every turn. CLAUDE.md retains a 1-line link. 2. **Existing-repo migration story + stop-gap env + orphan-symlink scan** (~25 lines) — one-time setup troubleshooting, not per-session context. Appended to existing `docs/workspace-contract.md`. CLAUDE.md retains a 1-line link. What stayed in CLAUDE.md (per-session operating instructions): - Identity / Operating Style / Architecture rules / Repo rules - Workspace contract (resolution + helper bullets — agent needs to know which helper to call for every path computation) - Personal overrides / Work Status / Chat-path task tracking - Core liveness signal / Memory / Discord access control - Pending decisions / Task bridge / Tutorial - Learn from demonstration (promoted to its own ## section; previously buried as a paragraph under Built-in capabilities) - Session Continuity / Startup / Skills What was also fixed inline in the Workspace contract section: - TS helper bullet updated to point at `resolveWorkspace` (added in #821) instead of the pre-#821 inline expression. - Swift twin (added in #837) added to the helper list. - Disambiguated `SUTANDO_REPO_DIR` (#831) from `SUTANDO_WORKSPACE` so the next reader doesn't conflate them — they live in different dirs. No behavior change. No agent-instruction loss. Just a context-budget cut on every Claude Code session in this repo. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orkspace paths Addresses sutando#9708's review of #791: - conversation-store readers (getSecondsSinceLastTurn / getRecentConversation) revert to text-log-primary. The SQLite mirror is a best-effort parallel write (errors swallowed) so it can silently lag the log — trusting it could return stale data, contradicting the "text log is primary truth" contract. Drops the now-unused queryLastTurnTs / queryRecentTurns. - engines.node -> >=22.5.0 (node:sqlite / DatabaseSync floor); was >=22.0.0, which allowed 22.0-22.4 installs that fail at module load. - DB path resolved via resolveWorkspace() in conversation-store.ts and the import scripts + diagnose.py, so the sqlite mirror and conversation.log stay in the same tree after the #831 workspace move. - import-conversation-log.py / import-session-metrics.py: --reload aborts if voice/phone writer processes are live (--force overrides), and wraps delete+reimport in one BEGIN IMMEDIATE transaction so it cannot interleave with a writer and lose rows. - conversation index names table-prefixed (idx_conversation_*). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… rollups (#603 slice 1+3) (#791) * feat(conversation-store): sqlite mirror of conversation.log (#603 slice 1) Adds time-indexed, searchable sqlite mirror alongside the existing text conversation.log. Text log remains primary truth; sqlite is a derived mirror written best-effort in parallel — sqlite errors never propagate. Uses node:sqlite (built-in, no new dep). WAL mode + 1s busy_timeout for concurrent writers (task-bridge.ts and voice-agent.ts can run as separate processes). Schema: CREATE TABLE conversation ( ts_unix REAL NOT NULL, -- floating unix ts, sub-second role TEXT NOT NULL, -- 'user' | 'sutando' | 'core-agent' | 'SESSION_END' text TEXT NOT NULL, session_id TEXT -- nullable; future-proofed ); INDEX(ts_unix), INDEX(role, ts_unix), INDEX(session_id, ts_unix) Files: src/conversation-store.ts — DB helper with lazy init, init-failure latch src/task-bridge.ts — wires logConversation/logSessionBoundary to parallel-write the sqlite mirror scripts/query-conversation.sh — thin sqlite3 CLI with --since / --role / --last filters; LIKE search Use case (Susan's primary): time-recall queries — "what did we say around 5/4 9pm" becomes a single SQL by ts_unix range, instead of grepping a growing text log. Slice 2 (follow-up, separate PR): migrate voice/phone metrics tracker consumers (regression-search, call-diagnostics, dashboard) to read from SQL. Slice 3: remove the now-redundant voice-metrics.jsonl / call-metrics.jsonl writers. Slice 4: FTS5 virtual table for stemmed / phrase search. Tested: standalone smoke (write 3 rows → sqlite3 SELECT confirms; CLI LIKE search + --last filter both return expected rows). Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(conversation-store): replace voice-metrics.jsonl + call-metrics.jsonl writers with sqlite Per Susan ask: stop appending new per-session rollups to the legacy jsonl files; write to a unified `sessions` table in conversation.sqlite instead. Existing data/voice-metrics.jsonl + data/call-metrics.jsonl files on disk are preserved (historical data not lost) — only the writers are removed. Schema added to conversation-store.ts: CREATE TABLE sessions ( ts_unix REAL NOT NULL, source TEXT NOT NULL, -- 'voice' | 'phone' | future 'discord-voice' session_id TEXT, -- voice key call_sid TEXT, -- phone key (Twilio) caller TEXT, -- phone caller number is_owner INTEGER, -- phone access tier (0/1) is_meeting INTEGER, -- phone is_meeting (0/1) duration_ms INTEGER NOT NULL, transcript_lines INTEGER, tool_count INTEGER, pending_tasks INTEGER, tool_calls TEXT, -- JSON array events TEXT -- JSON array ); Unified table covers both source types — the few unique fields are NULL for the other type. Indexed on (ts_unix), (source, ts_unix), (call_sid). Wiring: - src/voice-agent.ts:867 — removed appendFileSync('data/voice-metrics.jsonl', ...); now calls recordSession({source:'voice', ...}). - skills/phone-conversation/scripts/conversation-server.ts:972 — removed appendFileSync('data/call-metrics.jsonl', ...); now calls recordSession({source:'phone', ...}). Best-effort writes; sqlite errors swallowed inside recordSession() — caller never blocked. Consistent with the pattern already used for the conversation table (slice 1 in df15a33). Consumer migration (skills/call-diagnostics/scripts/diagnose.py reading the jsonls; merge-voice-metrics.sh cross-node merge) intentionally NOT in this commit — those keep working against the preserved historical jsonl files for now, scheduled as separate slice 2 PR. Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(#603): clean up jsonl-support code around removed voice/call-metrics writers Per Susan: not just stop appending — remove the supporting jsonl code too. Existing data/voice-metrics.jsonl + data/call-metrics.jsonl on disk are still preserved (historical data not lost); only the code that revolved around them is gone or reframed. Deleted: - scripts/test-voice-metrics-reset.sh (POC for an old reconnect bug fix) - scripts/verify-voice-metrics-bug.sh (one-off verification script) Updated: - skills/call-diagnostics/scripts/diagnose.py — removed the "voice-metrics in path → voice; else phone" detection at line 748. voice-metrics.jsonl is no longer a fresh data source, so the tracker is phone-only now. - skills/cross-node-sync/SKILL.md — reframed `data/voice-metrics.jsonl` bullet as a frozen-archive note covering both voice/call-metrics. - skills/cross-node-sync/scripts/setup-rsync-sync.sh — comments reworded to deemphasize voice/call-metrics; replaced with subtitle-metrics + conversation.sqlite as primary jsonl/data examples. - skills/cross-node-sync/scripts/merge-voice-metrics.sh — script header rewritten ("generic jsonl union-merger, named for historical reasons"); removed the default voice-metrics.jsonl path args so callers must supply explicit paths. Script logic itself unchanged (still generic; still serves subtitle-metrics + future jsonls). Not touched (scoped out): - data/voice-metrics.jsonl + data/call-metrics.jsonl files themselves (kept as historical archives). - merge-voice-metrics.sh filename (rename is housekeeping; can be a separate PR if desired). - test-merge-voice-metrics.sh tests (still exercise the generic merge logic correctly; voice-metrics is just one test case). Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#603): mirror phone-server conversation.log writes to sqlite too Found via Susan's "show me the conversation.log schema" audit: the phone conversation-server bypasses task-bridge's logConversation() at the per-call finalization step (lines 944-952) and writes directly to conversation.log, which meant phone-agent + phone-caller turns landed in the text log but NOT in conversation.sqlite. Roles user / assistant / core-agent / SESSION_END were already covered via task-bridge.logConversation; phone roles were the gap. Fix: route the same lines through recordConversation(role, text, callSid) in addition to the existing appendFileSync. Best-effort (sqlite errors swallowed inside recordConversation, same pattern as the slice-1 wiring). callSid is passed as session_id so per-call rollup queries can JOIN conversation rows on session_id=call_sid (both are the Twilio callSid string). Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(#603): one-shot importer conversation.log → sqlite Adds scripts/import-conversation-log.py — parses the line-format text log (ISO_ts|role|text) and bulk-INSERTs into the conversation table. Use cases: - Backfill historical conversation.log into a fresh sqlite - One-time migration for users adopting this PR on existing installs - Idempotent reset via --reload (TRUNCATEs then re-imports) - --dry-run for safe inspection Tested: 1980 rows (assistant 1306 / user 304 / phone-agent 149 / phone-caller 136 / core-agent 84 / SESSION_END 1) imported in <1s. Time-recall query verified: WHERE text LIKE '%phantom%' returned the 5/7 phantom-voice-bug discussion turns instantly. Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(#603): tool_calls + session_events tables + jsonl backfill importer Per Susan ask: split the JSON-blob `tool_calls` + `events` columns in the `sessions` table into dedicated, indexed tables so SQL queries don't need json_extract for the common per-tool-call / per-event analytics. Schema (additive — `sessions.tool_calls` + `sessions.events` JSON kept for cross-version compat and at-a-glance row view): CREATE TABLE tool_calls ( ts_unix, source, session_id, call_sid, name, duration_ms ); CREATE TABLE session_events ( ts_unix, source, session_id, call_sid, event_name ); Both with indices on (ts_unix), (name|event_name, ts_unix), and (session_id, ts_unix). Live-write fan-out: recordSession() now ALSO populates tool_calls + session_events from the toolCalls/events arrays in the same call. Per-row ts_unix derived from each tool/event's own timestamp (falling back to session end-time if not present). Importer: scripts/import-session-metrics.py — backfills data/voice-metrics.jsonl + data/call-metrics.jsonl into all 3 session tables. Verified on real data: 825 sessions, 3723 tool_calls, 19847 events. Smoke queries verified end-to-end: - "top 5 tools by count + avg duration" → work(998, 131ms), summon(273, 14.9s), ... - "events by name" → tool_call:work(1003), session_started:client_reconnect(438), ... Now possible (vs json_extract'ing the old JSON columns): SELECT name, AVG(duration_ms), COUNT(*) FROM tool_calls WHERE ts_unix > strftime('%s','now')-86400 GROUP BY name ORDER BY 2 DESC; Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(#603): switch conversation.log readers to sqlite (O(log N) queries) Per Susan: even with session_id added, SQL is faster than re-grepping conversation.log on every call. The text log is still primary truth + tailable; readers move to the indexed sqlite mirror. New helpers in src/conversation-store.ts: - queryLastTurnTs() — latest user/assistant ts since most recent SESSION_END - queryRecentTurns(count) — recent rows since most recent SESSION_END Both use index lookups (idx_role_ts) — O(log N) vs the prior O(file_size) backwards-walk over the whole text file. Wired into 3 callers, all with text-log fallback for first-boot / sqlite-init-failure: - src/task-bridge.ts:getSecondsSinceLastTurn - src/task-bridge.ts:getRecentConversation - skills/phone-conversation/scripts/conversation-server.ts:getSafeContext Validated against the real 1980-row conversation.sqlite — queryLastTurnTs returns the expected last assistant ts; queryRecentTurns(3) returns the 3 most-recent rows oldest-first matching the prior text-log format. Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(#603): diagnose.py reads sqlite + sessionId plumbed into logConversation Two more cleanup pieces tying the sqlite migration together: 1. **diagnose.py → sqlite source** - Default load order is now: --metrics flag (back-compat) → data/conversation.sqlite `sessions` table → data/call-metrics.jsonl archive (last-resort fallback). - New --source flag: `voice` (default `phone` for back-compat) or `all`. Restores the per-source diagnostic-tracker mode that was lost when voice-metrics.jsonl writes stopped (commit 0b32107). voice path is fully alive again — recordSession({source:'voice'}) → sessions table → diagnose.py --source voice. - sqlite row → legacy dict shape (callSid/sessionId/timestamp/events/ toolCalls) so downstream functions (merge_timeline, diagnose, generate_tracker_html) don't change. 2. **sessionId plumbed into logConversation** - logConversation(role, text) now accepts optional sessionId, forwarded to recordConversation for the sqlite `conversation.session_id` column. - voice-agent's per-turn write at line 1136 passes SESSION_ID (module- level constant). Phone-server already passed callSid (commit 5ac0624). core-agent injections stay session-less (NULL) — they don't belong to any single owner session. This closes the slice-5 todo: ✓ jsonl writers + supporting code removed (earlier commits) ✓ tool_calls + session_events tables + JSON fan-out (de34c4b) ✓ import-session-metrics.py backfill (de34c4b) ✓ readers (getRecentConversation / getSecondsSinceLastTurn / getSafeContext) (c93d008) ✓ diagnose.py sqlite source (THIS) ✓ sessionId plumb into new conversation writes (THIS) Refs #603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#603): address review — text-log-primary readers, reload guard, workspace paths Addresses sutando#9708's review of #791: - conversation-store readers (getSecondsSinceLastTurn / getRecentConversation) revert to text-log-primary. The SQLite mirror is a best-effort parallel write (errors swallowed) so it can silently lag the log — trusting it could return stale data, contradicting the "text log is primary truth" contract. Drops the now-unused queryLastTurnTs / queryRecentTurns. - engines.node -> >=22.5.0 (node:sqlite / DatabaseSync floor); was >=22.0.0, which allowed 22.0-22.4 installs that fail at module load. - DB path resolved via resolveWorkspace() in conversation-store.ts and the import scripts + diagnose.py, so the sqlite mirror and conversation.log stay in the same tree after the #831 workspace move. - import-conversation-log.py / import-session-metrics.py: --reload aborts if voice/phone writer processes are live (--force overrides), and wraps delete+reimport in one BEGIN IMMEDIATE transaction so it cannot interleave with a writer and lose rows. - conversation index names table-prefixed (idx_conversation_*). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(init-script): expect workspace-rooted paths after #913 #913 moved init.sh's Tier-1 runtime dirs + placeholder files under $WORKSPACE instead of the repo root, but tests/init-script.test.ts was not updated — it still asserted the files at SUTANDO_REPO. 7 of its 18 cases fail on main as a result, which #791 inherited via rebase. The test now passes SUTANDO_WORKSPACE explicitly and checks Tier-1 dirs + placeholder files there. crons.json copy + preflight assertions stay repo-rooted — those artifacts genuinely live in the repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#603): drop queryRecentTurns import in phone conversation-server Follow-up to the review-fix commit (4ebcfbe), which removed queryLastTurnTs/queryRecentTurns from conversation-store.ts (readers revert to text-log-primary per review finding 1) — but missed that phone conversation-server.ts also imports queryRecentTurns. tsconfig only includes src/, so tsc never typechecks skills/, and the dangling ESM named import would throw at module load and crash the phone server. getSafeContext() now reads conversation.log directly, consistent with the same revert applied to task-bridge.ts's getRecentConversation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onichi#831) * feat(sync-memory): bootstrap workspace/notes symlink to private repo Fixes the silent sync break from PR sonichi#769: the workspace migration moved notes/ to ~/.sutando/workspace/notes/ as a fresh real dir, leaving the pre-2026-05-11 symlink convention (workspace/notes → private repo notes) unwired. Result: edits to workspace/notes/ stopped propagating cross-machine. This adds an idempotent symlink-bootstrap block to sync-memory.sh: - already-correct symlink: no-op - real dir at target: log WARN with reconcile recipe (no silent overwrite) - missing: create the symlink Restores the pre-sonichi#769 architecture: edits to $SUTANDO_WORKSPACE/notes/ land in the private repo automatically via symlink, no rsync needed; git pull/push handles cross-machine. Same pattern as the script's existing private-repo auto-bootstrap (env var → first-run clone). Manual one-time reconcile of existing real-dir data into the private repo (rsync with build-artifact excludes, then mv + ln -s) is left to the operator because it touches local files and the safe defaults can't know what to keep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync-memory): WARN recipe now includes build-artifact excludes (review sonichi#831 nit 2) Without these excludes, copy-pasting the WARN'd reconcile recipe would sync all of workspace/notes/ (19,845 files on Mini's repro) into the canonical private-repo store — including node_modules/, remotion .cache, iteration mp4s, etc. Matches the exclude set used during Mini's manual reconcile. Nit 1 (env-var name SUTANDO_WORKSPACE_DIR vs canonical SUTANDO_WORKSPACE) deferred to a separate cleanup PR — it touches the line-78 legacy semantic and 19+ refs across the codebase, larger scope than this architecture fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync-memory): WARN recipe now includes build-artifact excludes (PR sonichi#831 review nit 2) Without these excludes, copy-pasting the WARN'd reconcile recipe would sync all of workspace/notes/ (19,845 files on Mini's repro case) into the canonical private-repo store — including node_modules/, remotion .cache, iteration mp4s. Matches the exclude set used during Mini's manual reconcile. Nit 1 (env-var SUTANDO_WORKSPACE_DIR vs canonical SUTANDO_WORKSPACE) deferred to a separate cleanup PR — touches line-78 legacy semantic and 19+ refs across the codebase, larger scope than this architecture fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env-vars): standardize SUTANDO_WORKSPACE semantic (PR sonichi#831 review nit 1) Per MacBook review: SUTANDO_WORKSPACE had drifted to two semantics in the codebase — CLAUDE.md + the new code (src/workspace_default.py, watch-tasks-stream, core_heartbeat, web-client, cross-node-sync, phone-conversation) treat it as the per-user workspace dir (~/.sutando/workspace/); but three legacy scripts (scripts/sync-memory.sh, src/session-handoff.sh, skills/loop-self-audit/scripts/audit.sh) used it to mean the public-repo checkout (~/Desktop/sutando/). Standardizing on the CLAUDE.md semantic (canonical workspace dir). The three legacy scripts now read SUTANDO_REPO_DIR as the canonical name for the public-repo path, falling back to SUTANDO_WORKSPACE for backcompat with a one-time deprecation hint. In sync-memory.sh specifically, the new workspace-symlink-bootstrap block now reads canonical SUTANDO_WORKSPACE (was the placeholder SUTANDO_WORKSPACE_DIR). A safety guard skips the bootstrap if SUTANDO_WORKSPACE points at a public-repo checkout (legacy semantic detected via .git/) — protects users mid-migration from creating wrong symlinks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env-vars): auto-detect REPO_DIR + drop SUTANDO_WORKSPACE fallback (PR sonichi#831 cleanup) Per owner + MacBook-bot convergent review, three cleanups bundled: 1. Drop the SUTANDO_WORKSPACE legacy fallback from REPO_DIR resolution in sync-memory.sh and session-handoff.sh. CLAUDE.md reserves SUTANDO_WORKSPACE for the per-user workspace dir (~/.sutando/workspace/); using it as a REPO_DIR alias would silently pick the wrong path on CLAUDE.md-compliant hosts. Backcompat is moot: pre-sonichi#831 hosts get a clear "set SUTANDO_REPO_DIR" error and a 1-line .env edit. 2. Auto-detect REPO_DIR from $SCRIPT_PARENT when the parent dir carries a sutando-checkout signature (CLAUDE.md + skills/ + .git/). Zero-config for the common case (`bash <wherever>/sutando/scripts/sync-memory.sh`) — works regardless of where the user cloned. The ~/Desktop/sutando default only kicks in for the memory-sync-dir-copy invocation where SCRIPT_PARENT is the sync-dir, not the public repo. Owner flagged the hardcoded default as a personal-convention smell that breaks `~/Code/`, `~/src/`, etc. 3. Untrack skills/loop-self-audit/scripts/audit.sh — it was untracked locally and got swept into dc84ba3 by `git add`. Not in scope for the sync-memory cleanup PR; will be its own PR later. File stays on disk for the local skill to keep working. New resolution chain: 1. $SUTANDO_REPO_DIR (explicit override) 2. $SCRIPT_PARENT if signature matches (zero-config common case) 3. $HOME/Desktop/sutando (last-resort default) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pe (sonichi#835) Followup to PR sonichi#831's auto-migration. When ~/.sutando-memory-sync → ~/.sutando/memory-sync moves, any pre-existing symlinks pointing at the legacy path are silently broken (target moved out from under them). The migration can't enumerate external symlinks itself, so this scan emits one WARN line per broken symlink with an exact rm+ln re-point recipe. Caught today on MacBook host: 10 personal-* skill symlinks + the workspace/notes symlink were broken post-merge. Each was a manual rm+ln fix; this scan would have surfaced all 11 in one pass. Scope: - ~/.sutando/ — workspace + sync conventions - ~/.claude/skills/ — symlinks to private-repo skills - ~/.config/ — user-installed convenience symlinks Full ~/ scan is too slow; these three dirs cover the known offenders. Only runs when __MIGRATED=1 (i.e. the script actually moved the dir this run). Idempotent: post-migration runs skip (__MIGRATED stays 0, no scan). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
…onichi#845) Reduces CLAUDE.md from 399 → 246 lines (~38% trim) by moving content that agents don't need on every session-load into docs/, while keeping every section that's actually per-session operating instructions. What moved: 1. **Built-in tools (~143 lines)** — the bash recipe catalog for Calendar, Gmail, iMessage, X, Reminders, macos-use, Browser, meeting join, etc. → new `docs/built-in-tools.md`. Agents reach for it on demand when they need a tool, rather than carrying 150 lines of recipes on every turn. CLAUDE.md retains a 1-line link. 2. **Existing-repo migration story + stop-gap env + orphan-symlink scan** (~25 lines) — one-time setup troubleshooting, not per-session context. Appended to existing `docs/workspace-contract.md`. CLAUDE.md retains a 1-line link. What stayed in CLAUDE.md (per-session operating instructions): - Identity / Operating Style / Architecture rules / Repo rules - Workspace contract (resolution + helper bullets — agent needs to know which helper to call for every path computation) - Personal overrides / Work Status / Chat-path task tracking - Core liveness signal / Memory / Discord access control - Pending decisions / Task bridge / Tutorial - Learn from demonstration (promoted to its own ## section; previously buried as a paragraph under Built-in capabilities) - Session Continuity / Startup / Skills What was also fixed inline in the Workspace contract section: - TS helper bullet updated to point at `resolveWorkspace` (added in sonichi#821) instead of the pre-sonichi#821 inline expression. - Swift twin (added in sonichi#837) added to the helper list. - Disambiguated `SUTANDO_REPO_DIR` (sonichi#831) from `SUTANDO_WORKSPACE` so the next reader doesn't conflate them — they live in different dirs. No behavior change. No agent-instruction loss. Just a context-budget cut on every Claude Code session in this repo. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… rollups (sonichi#603 slice 1+3) (sonichi#791) * feat(conversation-store): sqlite mirror of conversation.log (sonichi#603 slice 1) Adds time-indexed, searchable sqlite mirror alongside the existing text conversation.log. Text log remains primary truth; sqlite is a derived mirror written best-effort in parallel — sqlite errors never propagate. Uses node:sqlite (built-in, no new dep). WAL mode + 1s busy_timeout for concurrent writers (task-bridge.ts and voice-agent.ts can run as separate processes). Schema: CREATE TABLE conversation ( ts_unix REAL NOT NULL, -- floating unix ts, sub-second role TEXT NOT NULL, -- 'user' | 'sutando' | 'core-agent' | 'SESSION_END' text TEXT NOT NULL, session_id TEXT -- nullable; future-proofed ); INDEX(ts_unix), INDEX(role, ts_unix), INDEX(session_id, ts_unix) Files: src/conversation-store.ts — DB helper with lazy init, init-failure latch src/task-bridge.ts — wires logConversation/logSessionBoundary to parallel-write the sqlite mirror scripts/query-conversation.sh — thin sqlite3 CLI with --since / --role / --last filters; LIKE search Use case (Susan's primary): time-recall queries — "what did we say around 5/4 9pm" becomes a single SQL by ts_unix range, instead of grepping a growing text log. Slice 2 (follow-up, separate PR): migrate voice/phone metrics tracker consumers (regression-search, call-diagnostics, dashboard) to read from SQL. Slice 3: remove the now-redundant voice-metrics.jsonl / call-metrics.jsonl writers. Slice 4: FTS5 virtual table for stemmed / phrase search. Tested: standalone smoke (write 3 rows → sqlite3 SELECT confirms; CLI LIKE search + --last filter both return expected rows). Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(conversation-store): replace voice-metrics.jsonl + call-metrics.jsonl writers with sqlite Per Susan ask: stop appending new per-session rollups to the legacy jsonl files; write to a unified `sessions` table in conversation.sqlite instead. Existing data/voice-metrics.jsonl + data/call-metrics.jsonl files on disk are preserved (historical data not lost) — only the writers are removed. Schema added to conversation-store.ts: CREATE TABLE sessions ( ts_unix REAL NOT NULL, source TEXT NOT NULL, -- 'voice' | 'phone' | future 'discord-voice' session_id TEXT, -- voice key call_sid TEXT, -- phone key (Twilio) caller TEXT, -- phone caller number is_owner INTEGER, -- phone access tier (0/1) is_meeting INTEGER, -- phone is_meeting (0/1) duration_ms INTEGER NOT NULL, transcript_lines INTEGER, tool_count INTEGER, pending_tasks INTEGER, tool_calls TEXT, -- JSON array events TEXT -- JSON array ); Unified table covers both source types — the few unique fields are NULL for the other type. Indexed on (ts_unix), (source, ts_unix), (call_sid). Wiring: - src/voice-agent.ts:867 — removed appendFileSync('data/voice-metrics.jsonl', ...); now calls recordSession({source:'voice', ...}). - skills/phone-conversation/scripts/conversation-server.ts:972 — removed appendFileSync('data/call-metrics.jsonl', ...); now calls recordSession({source:'phone', ...}). Best-effort writes; sqlite errors swallowed inside recordSession() — caller never blocked. Consistent with the pattern already used for the conversation table (slice 1 in df15a33). Consumer migration (skills/call-diagnostics/scripts/diagnose.py reading the jsonls; merge-voice-metrics.sh cross-node merge) intentionally NOT in this commit — those keep working against the preserved historical jsonl files for now, scheduled as separate slice 2 PR. Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(sonichi#603): clean up jsonl-support code around removed voice/call-metrics writers Per Susan: not just stop appending — remove the supporting jsonl code too. Existing data/voice-metrics.jsonl + data/call-metrics.jsonl on disk are still preserved (historical data not lost); only the code that revolved around them is gone or reframed. Deleted: - scripts/test-voice-metrics-reset.sh (POC for an old reconnect bug fix) - scripts/verify-voice-metrics-bug.sh (one-off verification script) Updated: - skills/call-diagnostics/scripts/diagnose.py — removed the "voice-metrics in path → voice; else phone" detection at line 748. voice-metrics.jsonl is no longer a fresh data source, so the tracker is phone-only now. - skills/cross-node-sync/SKILL.md — reframed `data/voice-metrics.jsonl` bullet as a frozen-archive note covering both voice/call-metrics. - skills/cross-node-sync/scripts/setup-rsync-sync.sh — comments reworded to deemphasize voice/call-metrics; replaced with subtitle-metrics + conversation.sqlite as primary jsonl/data examples. - skills/cross-node-sync/scripts/merge-voice-metrics.sh — script header rewritten ("generic jsonl union-merger, named for historical reasons"); removed the default voice-metrics.jsonl path args so callers must supply explicit paths. Script logic itself unchanged (still generic; still serves subtitle-metrics + future jsonls). Not touched (scoped out): - data/voice-metrics.jsonl + data/call-metrics.jsonl files themselves (kept as historical archives). - merge-voice-metrics.sh filename (rename is housekeeping; can be a separate PR if desired). - test-merge-voice-metrics.sh tests (still exercise the generic merge logic correctly; voice-metrics is just one test case). Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sonichi#603): mirror phone-server conversation.log writes to sqlite too Found via Susan's "show me the conversation.log schema" audit: the phone conversation-server bypasses task-bridge's logConversation() at the per-call finalization step (lines 944-952) and writes directly to conversation.log, which meant phone-agent + phone-caller turns landed in the text log but NOT in conversation.sqlite. Roles user / assistant / core-agent / SESSION_END were already covered via task-bridge.logConversation; phone roles were the gap. Fix: route the same lines through recordConversation(role, text, callSid) in addition to the existing appendFileSync. Best-effort (sqlite errors swallowed inside recordConversation, same pattern as the slice-1 wiring). callSid is passed as session_id so per-call rollup queries can JOIN conversation rows on session_id=call_sid (both are the Twilio callSid string). Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sonichi#603): one-shot importer conversation.log → sqlite Adds scripts/import-conversation-log.py — parses the line-format text log (ISO_ts|role|text) and bulk-INSERTs into the conversation table. Use cases: - Backfill historical conversation.log into a fresh sqlite - One-time migration for users adopting this PR on existing installs - Idempotent reset via --reload (TRUNCATEs then re-imports) - --dry-run for safe inspection Tested: 1980 rows (assistant 1306 / user 304 / phone-agent 149 / phone-caller 136 / core-agent 84 / SESSION_END 1) imported in <1s. Time-recall query verified: WHERE text LIKE '%phantom%' returned the 5/7 phantom-voice-bug discussion turns instantly. Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sonichi#603): tool_calls + session_events tables + jsonl backfill importer Per Susan ask: split the JSON-blob `tool_calls` + `events` columns in the `sessions` table into dedicated, indexed tables so SQL queries don't need json_extract for the common per-tool-call / per-event analytics. Schema (additive — `sessions.tool_calls` + `sessions.events` JSON kept for cross-version compat and at-a-glance row view): CREATE TABLE tool_calls ( ts_unix, source, session_id, call_sid, name, duration_ms ); CREATE TABLE session_events ( ts_unix, source, session_id, call_sid, event_name ); Both with indices on (ts_unix), (name|event_name, ts_unix), and (session_id, ts_unix). Live-write fan-out: recordSession() now ALSO populates tool_calls + session_events from the toolCalls/events arrays in the same call. Per-row ts_unix derived from each tool/event's own timestamp (falling back to session end-time if not present). Importer: scripts/import-session-metrics.py — backfills data/voice-metrics.jsonl + data/call-metrics.jsonl into all 3 session tables. Verified on real data: 825 sessions, 3723 tool_calls, 19847 events. Smoke queries verified end-to-end: - "top 5 tools by count + avg duration" → work(998, 131ms), summon(273, 14.9s), ... - "events by name" → tool_call:work(1003), session_started:client_reconnect(438), ... Now possible (vs json_extract'ing the old JSON columns): SELECT name, AVG(duration_ms), COUNT(*) FROM tool_calls WHERE ts_unix > strftime('%s','now')-86400 GROUP BY name ORDER BY 2 DESC; Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sonichi#603): switch conversation.log readers to sqlite (O(log N) queries) Per Susan: even with session_id added, SQL is faster than re-grepping conversation.log on every call. The text log is still primary truth + tailable; readers move to the indexed sqlite mirror. New helpers in src/conversation-store.ts: - queryLastTurnTs() — latest user/assistant ts since most recent SESSION_END - queryRecentTurns(count) — recent rows since most recent SESSION_END Both use index lookups (idx_role_ts) — O(log N) vs the prior O(file_size) backwards-walk over the whole text file. Wired into 3 callers, all with text-log fallback for first-boot / sqlite-init-failure: - src/task-bridge.ts:getSecondsSinceLastTurn - src/task-bridge.ts:getRecentConversation - skills/phone-conversation/scripts/conversation-server.ts:getSafeContext Validated against the real 1980-row conversation.sqlite — queryLastTurnTs returns the expected last assistant ts; queryRecentTurns(3) returns the 3 most-recent rows oldest-first matching the prior text-log format. Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sonichi#603): diagnose.py reads sqlite + sessionId plumbed into logConversation Two more cleanup pieces tying the sqlite migration together: 1. **diagnose.py → sqlite source** - Default load order is now: --metrics flag (back-compat) → data/conversation.sqlite `sessions` table → data/call-metrics.jsonl archive (last-resort fallback). - New --source flag: `voice` (default `phone` for back-compat) or `all`. Restores the per-source diagnostic-tracker mode that was lost when voice-metrics.jsonl writes stopped (commit 0b32107). voice path is fully alive again — recordSession({source:'voice'}) → sessions table → diagnose.py --source voice. - sqlite row → legacy dict shape (callSid/sessionId/timestamp/events/ toolCalls) so downstream functions (merge_timeline, diagnose, generate_tracker_html) don't change. 2. **sessionId plumbed into logConversation** - logConversation(role, text) now accepts optional sessionId, forwarded to recordConversation for the sqlite `conversation.session_id` column. - voice-agent's per-turn write at line 1136 passes SESSION_ID (module- level constant). Phone-server already passed callSid (commit 5ac0624). core-agent injections stay session-less (NULL) — they don't belong to any single owner session. This closes the slice-5 todo: ✓ jsonl writers + supporting code removed (earlier commits) ✓ tool_calls + session_events tables + JSON fan-out (de34c4b) ✓ import-session-metrics.py backfill (de34c4b) ✓ readers (getRecentConversation / getSecondsSinceLastTurn / getSafeContext) (c93d008) ✓ diagnose.py sqlite source (THIS) ✓ sessionId plumb into new conversation writes (THIS) Refs sonichi#603. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sonichi#603): address review — text-log-primary readers, reload guard, workspace paths Addresses sutando#9708's review of sonichi#791: - conversation-store readers (getSecondsSinceLastTurn / getRecentConversation) revert to text-log-primary. The SQLite mirror is a best-effort parallel write (errors swallowed) so it can silently lag the log — trusting it could return stale data, contradicting the "text log is primary truth" contract. Drops the now-unused queryLastTurnTs / queryRecentTurns. - engines.node -> >=22.5.0 (node:sqlite / DatabaseSync floor); was >=22.0.0, which allowed 22.0-22.4 installs that fail at module load. - DB path resolved via resolveWorkspace() in conversation-store.ts and the import scripts + diagnose.py, so the sqlite mirror and conversation.log stay in the same tree after the sonichi#831 workspace move. - import-conversation-log.py / import-session-metrics.py: --reload aborts if voice/phone writer processes are live (--force overrides), and wraps delete+reimport in one BEGIN IMMEDIATE transaction so it cannot interleave with a writer and lose rows. - conversation index names table-prefixed (idx_conversation_*). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(init-script): expect workspace-rooted paths after sonichi#913 sonichi#913 moved init.sh's Tier-1 runtime dirs + placeholder files under $WORKSPACE instead of the repo root, but tests/init-script.test.ts was not updated — it still asserted the files at SUTANDO_REPO. 7 of its 18 cases fail on main as a result, which sonichi#791 inherited via rebase. The test now passes SUTANDO_WORKSPACE explicitly and checks Tier-1 dirs + placeholder files there. crons.json copy + preflight assertions stay repo-rooted — those artifacts genuinely live in the repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sonichi#603): drop queryRecentTurns import in phone conversation-server Follow-up to the review-fix commit (4ebcfbe), which removed queryLastTurnTs/queryRecentTurns from conversation-store.ts (readers revert to text-log-primary per review finding 1) — but missed that phone conversation-server.ts also imports queryRecentTurns. tsconfig only includes src/, so tsc never typechecks skills/, and the dangling ESM named import would throw at module load and crash the phone server. getSafeContext() now reads conversation.log directly, consistent with the same revert applied to task-bridge.ts's getRecentConversation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sparrow): carry thread identity through ingress The backend now emits thread_root + source_room_id on the ag2space task envelope (ag2space-backend #831). Sparrow serializes gateway-sent fields through a whitelist, so a field absent from it is dropped in silence: the task file is written, nothing errors, and the agent never learns it was asked inside a thread. Ingress only. The backend inherits the outbound route from the original task by id, so a result must not echo these back — an echoed value would let a reply name a thread it was not asked in. The key list has FOUR implementations that a parity test holds in lockstep, and adding a header means adding it to all of them: KNOWN_HEADER_KEYS (both the src/ copy and the vendored ag2-sparrow one, byte-identical by a drift test), task-bridge.ts's _HEADER_KEYS, and conversation-server.ts's _CONF_HEADER_RE. Header status is what makes the body guard defang a forged copy, so a partial update would leave `thread_root:` trustable in untrusted content on the paths that were missed. * test(sparrow): drive the real gateway writer, not its source text Review named the regression test as blocking, correctly: it read _TASK_FIELDS as source text and then fed a hand-built file to parse_task_headers. Neither step runs the writer, so the suite passed regardless of whether the gateway consults the list, filters the values afterwards, or writes them at all — and a silent write-path drop is the entire defect under test. The test now calls remote_gateway_bridge._write_task against a temp tasks dir and reads the file it produced, with a control asserting a top-level task carries neither field. The mutation that proves the difference: leave _TASK_FIELDS intact and make the writer skip the two keys. The old source-text assertion passes; this one fails. * fix(test): the writer harness wrote to the real Sparrow state P1 from review, and correct. The harness redirected only TASKS_DIR, and did so AFTER importing remote_gateway_bridge — which binds task, result and state paths once at import. _write_task with a channel_id calls _record_task_room, which read-modify-writes TASK_ROOMS_FILE, so a test run merged synthetic task ids into the live routing ledger under ~/.ag2-sparrow. The reviewer's run only survived because the sandbox denied the write. _dirs.py already supports this properly: AGENT_CONNECT_{TASK,RESULT,STATE}_DIR are read at resolve time. The harness now sets all three to a temp tree and drops cached ag2_sparrow modules BEFORE import, so every bound path is temp. The control this needed took two attempts, and the first was worthless: fingerprinting ~/.ag2-sparrow/state/remote-task-rooms*.json passes on any host where that file does not exist — which is this one — so it reported success under the exact defect. Replaced with an assertion on where the module actually bound its paths, which is checkable anywhere. Re-introducing the defect now fails it and names the leaking bindings: ['results', 'state', 'rooms']. --------- Co-authored-by: qingyun-wu (sutando-001) <qingyun@ag2.ai>
Summary
Fixes the silent sync break introduced by PR #769. The workspace migration moved `notes/` from in-repo (`
/Desktop/sutando/notes/`) to workspace (`/.sutando/workspace/notes/`) as a fresh real dir, leaving the pre-2026-05-11 symlink convention (workspace/notes → private repo notes) unwired.Result observed today: edits to workspace/notes/ stopped propagating cross-machine. Mini had 19,845 files in workspace/notes/ vs 892 in private-repo notes/ vs 23 stragglers in in-repo notes/ — three real directories with diverging content.
What this PR does
Adds an idempotent symlink-bootstrap block to `scripts/sync-memory.sh`:
After install, `$SUTANDO_WORKSPACE/notes/` becomes a symlink to `$SYNC_DIR/notes/` — edits land in the private repo directly; git pull/push handles cross-machine. Same pattern as the script's existing private-repo auto-bootstrap (env var → first-run clone).
Test plan
Manual reconcile note for existing hosts
For hosts that already have `~/.sutando/workspace/notes/` as a real dir (the PR #769 victims), the WARN message in the script body documents the reconcile recipe inline:
```bash
rsync -au $src/ $tgt/ && rm -rf $src && ln -s $tgt $src
```
We don't auto-execute because rsync direction + exclude patterns are case-dependent (build artifacts vs canonical content).
🤖 Generated with Claude Code