Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

Phase 5.20: conversation-store per-surface tables (sync OSS sutando) - #88

Merged
jgluck96 merged 1 commit into
dev-clientfrom
josh/phase-5.20-conversation-store-per-surface
Jun 2, 2026
Merged

jgluck96 merged 1 commit into
dev-clientfrom
josh/phase-5.20-conversation-store-per-surface

Conversation

@jgluck96

@jgluck96 jgluck96 commented Jun 2, 2026

Copy link
Copy Markdown

Summary

Replaces stando's single-table conversation schema with per-surface tables (voice / phone / discord_voice) plus an idempotent legacy-schema migration, mirroring OSS sutando. The functional code in src/conversation-store.ts is now identical to OSS modulo the documented stando-specific lazy require('node:sqlite') pattern (which the .app bundle runtime needs).

This is the largest port we've done in Phase 5, and the highest single-PR divergence reduction yet — the 670-line shared file effectively shrinks to ~80 lines of intentional, documented divergence.

What changes

Public API: unchanged. recordConversation, recordSessionBoundary, recordSession keep their signatures. Existing callers (voice-agent.ts, task-bridge.ts, skills/phone-conversation/.../conversation-server.ts) work without rewiring.

New exports (additive):

  • sourceFromRole(role) — role-prefix → table-keyed source
  • kindFromRole(role) — legacy role → per-surface kind taxonomy
  • recordToolCall(source, name, durationMs, sessionId) — per-tool-call sink, writes kind='tool_call' to the matching surface table

Schema:

  • Surface tables (voice / phone / discord_voice) — identical shape, hold utterances + tool calls in one chronological stream (id INTEGER PRIMARY KEY ordering).
  • sessions / session_events retained — different concerns.
  • Dropped: sessions.tool_calls and sessions.events JSON columns (atoms now live in surface tables / session_events).
  • New views: v_voice, v_phone, v_discord_voice, v_sessions — thin wrappers with a human-readable time column for DB Browser.
  • Backwards-compat conversation VIEW that unions the three surface tables — external scripts (query-conversation.sh, regression-search) keep working without source edits.

Migration (migrateLegacyIfNeeded):

  • Runs once on first init when legacy conversation / tool_calls tables are present.
  • Single transaction; failure rolls back + logs (best-effort).
  • NULL ts_unix rows are dropped (can't satisfy NOT NULL surface schema).
  • Idempotent — second open is a no-op (legacy tables already dropped).
  • Also backfills discord_voice from sessions.tool_calls JSON via json_each (discord-voice never wrote to the standalone tool_calls table in the legacy schema).

Stando-specific divergence from OSS (intentional, documented)

  1. Lazy require('node:sqlite') in init() — older bundled Node runtimes gate node:sqlite behind --experimental-sqlite; a static import would throw ERR_UNKNOWN_BUILTIN_MODULE and crash every process that imports conversation-store. The lazy path lets the mirror degrade to a no-op while the text conversation.log keeps working.
  2. Issue-number wording — OSS-specific PR ids ([conversation-store] remove triple-encoded redundancy after per-surface tables (#1051 follow-up) sonichi/sutando#1052 etc.) replaced with neutral references ("the per-surface refactor") so comments don't carry OSS-internal context into stando.

Why this fits the "minimal divergence" rule

  • Convergence: functional code is now byte-identical to OSS (modulo the two documented divergences above). Future OSS changes to this file should merge cleanly.
  • Additive only at the API surface: 3 new exports; zero existing-caller rewiring.
  • Single file + one new test: no fan-out across the codebase.

Test plan

20-test suite covers:

  • sourceFromRole + kindFromRole routing contract (incl. core-agent → agent via suffix-matching, and edge case: 'shared-user' is not prefixed → voice)

  • recordConversation routes by role-prefix; rows don't leak across surface tables

  • recordToolCall writes kind='tool_call' to the named surface table; durationMs=null lands as NULL (not 0)

  • recordSessionBoundary writes a SESSION_END row to the voice table

  • recordSession's session_events fan-out filters duplicate-prefix events (user: / sutando: / tool_call: etc) — surface tables are canonical

  • recordSession records a row in sessions table

  • End-to-end migration via child-process runner: realistic pre-refactor seed (conversation + tool_calls + sessions w/ JSON cols) → assert per-surface row counts, kinds, post-migration view shape

  • Migration idempotency: second open adds only the trigger row, no duplicate backfill

  • Backwards-compat view: SELECT FROM conversation still works post-migration

  • v_voice / v_phone / v_discord_voice views exist with time column

  • SessionMetrics back-compat: legacy toolCalls field is silently accepted (interface stability)

  • 264/264 TS tests pass (244 prior + 20 new)

  • 61/61 Python tests pass

  • TS typecheck clean

Phase context

Phase Subject PR
5.15 dashboard outbox reader #83
5.16 discord-bridge outbox writes #84
5.17 telegram-bridge outbox writes #85
5.18 dm-result outbox writes #86
5.19 pending-questions no-status-field fix #87
5.20 conversation-store per-surface tables + migration (this)

Phase 5.19 closed a behavioral bug. Phase 5.20 takes on the biggest unaddressed shared-file divergence (670 line diff → ~80 line intentional divergence) — a schema refactor with public-API stability + built-in migration. The migration path is exercised by an end-to-end child-process test against a realistic pre-refactor seed.

Made with Cursor

Replaces the single-table conversation-store schema with per-surface
tables (voice / phone / discord_voice) plus an idempotent migration
from the legacy schema, mirroring OSS sutando. Stando's `.app`-bundle-
compatible lazy `require('node:sqlite')` pattern is preserved (OSS
uses a static import).

Public API is unchanged — `recordConversation(role, text, sessionId)`,
`recordSessionBoundary(reason, sessionId)`, and `recordSession(metrics)`
keep identical signatures so all existing callers (voice-agent.ts,
task-bridge.ts, skills/phone-conversation/.../conversation-server.ts)
continue to work without rewiring. Three additive exports land:
  - sourceFromRole(role)       — role-prefix → table-keyed source
  - kindFromRole(role)         — legacy role → per-surface kind taxonomy
  - recordToolCall(source, name, durationMs, sessionId)  — per-tool-call
    sink so tool invocations land in the surface table immediately
    (kind='tool_call') instead of being batched at session end via
    sessions.tool_calls JSON. Survives crashes, kill -9, ngrok drops.

Schema details:
  - Surface tables (voice/phone/discord_voice): identical shape, each
    holds utterances + tool calls in one chronological stream
    (id INTEGER PRIMARY KEY ordering; kind discriminates row type).
  - sessions/session_events tables retained — different concerns.
  - sessions.tool_calls and sessions.events JSON columns dropped post-
    init (the atom rows now live in surface tables / session_events).
  - New views: v_voice, v_phone, v_discord_voice, v_sessions — thin
    wrappers adding a human-readable `time` column for DB Browser.
  - Backwards-compat `conversation` VIEW that unions the three surface
    tables under the legacy `role` column name so external scripts
    (query-conversation.sh, regression-search) keep working without
    source edits.

Migration:
  - Runs once on first init when legacy `conversation` or `tool_calls`
    tables are present and any surface table is empty.
  - Single transaction; failure rolls back and logs (best-effort).
  - NULL ts_unix rows are dropped (can't satisfy NOT NULL surface
    schema; no other column carries time).
  - Idempotent — second open is a no-op (legacy tables already dropped).
  - Also backfills discord_voice from sessions.tool_calls JSON via
    json_each (discord-voice never wrote to the standalone tool_calls
    table in the legacy schema).

Stando-specific divergence from OSS (intentional, documented):
  - `import type { DatabaseSync }` + lazy `require('node:sqlite')` in
    init(). Older bundled Node runtimes gate node:sqlite behind
    --experimental-sqlite; a static import would throw
    ERR_UNKNOWN_BUILTIN_MODULE and crash every process that imports
    conversation-store. The lazy path lets the mirror degrade to a
    no-op while the text conversation.log keeps working.
  - Issue numbers (sonichi#1052 etc.) replaced with neutral references
    ("the per-surface refactor") so comments don't carry OSS-specific
    PR ids into stando.

Test coverage (tests/conversation-store-per-surface.test.ts, 20 cases):
  - sourceFromRole + kindFromRole routing contract (incl. core-agent
    → agent suffix-matching, edge: 'shared-user' is not prefixed).
  - recordConversation routes by role-prefix; rows don't leak across
    surface tables.
  - recordToolCall writes kind='tool_call' to the named surface table;
    durationMs=null lands as NULL.
  - recordSessionBoundary writes SESSION_END row.
  - recordSession's session_events fan-out filters duplicate-prefix
    events (user:/sutando:/tool_call:/etc) — surface tables are canonical.
  - End-to-end migration via child-process runner: realistic pre-
    refactor seed (conversation + tool_calls + sessions w/ JSON cols)
    → assert per-surface row counts, kinds, post-migration view shape.
  - Migration idempotency: second open adds only the trigger row, no
    duplicate backfill.
  - Backwards-compat view: SELECT FROM conversation still works.
  - SessionMetrics back-compat: legacy toolCalls field is silently
    accepted (interface stability for existing callers).

Verification: 264/264 TS tests pass (244 prior + 20 new), 61/61 Python
tests pass, TS typecheck clean.

Why minimal divergence: the functional code in src/conversation-store.ts
is now identical to OSS sutando modulo the documented lazy-require
divergence + neutral-issue-number wording. Future OSS changes to this
file should merge cleanly without semantic conflicts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant