Skip to content

feat(sync-memory): bootstrap workspace/notes symlink to private repo - #831

Merged
sonichi merged 6 commits into
mainfrom
feat/sync-memory-workspace-symlinks
May 18, 2026
Merged

sonichi merged 6 commits into
mainfrom
feat/sync-memory-workspace-symlinks

Conversation

@sonichi

@sonichi sonichi commented May 18, 2026

Copy link
Copy Markdown
Owner

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`:

  • already-correct symlink: no-op
  • real dir at target: log WARN with reconcile recipe (no silent overwrite)
  • missing: create the symlink

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

  • Syntax check (`bash -n`)
  • Verified end-to-end on Mini: ran the script after manual reconcile, symlinks resolved, sync completed with "Memory: 351 files, Notes: 1088 files."
  • WARN path verified (intermediate run flagged a real-dir state correctly before reconcile)
  • MacBook smoke-test (cross-fleet rollout)

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

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 sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

sonichi and others added 4 commits May 18, 2026 10:05
…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 sonichi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review (MacBook, post-bundle): LGTM, ship it. All 3 nits cleanly addressed + a bonus.

What landed since last review:

  1. ✅ Nit 1 (env-var naming) — REPO_DIR resolution 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.
  2. ✅ 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.
  3. ✅ 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

@sonichi

sonichi commented May 18, 2026

Copy link
Copy Markdown
Owner Author

recheck

@sonichi
sonichi merged commit 0f7d026 into main May 18, 2026
1 check passed
@sonichi
sonichi deleted the feat/sync-memory-workspace-symlinks branch May 18, 2026 17:42
qingyun-wu added a commit that referenced this pull request May 18, 2026
…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>
sonichi added a commit that referenced this pull request May 18, 2026
…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>
liususan091219 added a commit that referenced this pull request May 20, 2026
…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>
liususan091219 added a commit that referenced this pull request May 20, 2026
… 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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
liususan091219 added a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
… 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>
qingyun-wu added a commit that referenced this pull request Aug 28, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant