This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Repository navigation
Phase 5.20: conversation-store per-surface tables (sync OSS sutando) - #88
Merged
jgluck96 merged 1 commit intoJun 2, 2026
Merged
Conversation
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>
7 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces stando's single-table
conversationschema with per-surface tables (voice/phone/discord_voice) plus an idempotent legacy-schema migration, mirroring OSS sutando. The functional code insrc/conversation-store.tsis now identical to OSS modulo the documented stando-specific lazyrequire('node:sqlite')pattern (which the.appbundle 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,recordSessionkeep 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 sourcekindFromRole(role)— legacy role → per-surfacekindtaxonomyrecordToolCall(source, name, durationMs, sessionId)— per-tool-call sink, writeskind='tool_call'to the matching surface tableSchema:
voice/phone/discord_voice) — identical shape, hold utterances + tool calls in one chronological stream (id INTEGER PRIMARY KEY ordering).sessions/session_eventsretained — different concerns.sessions.tool_callsandsessions.eventsJSON columns (atoms now live in surface tables / session_events).v_voice,v_phone,v_discord_voice,v_sessions— thin wrappers with a human-readabletimecolumn for DB Browser.conversationVIEW that unions the three surface tables — external scripts (query-conversation.sh, regression-search) keep working without source edits.Migration (
migrateLegacyIfNeeded):conversation/tool_callstables are present.ts_unixrows are dropped (can't satisfy NOT NULL surface schema).discord_voicefromsessions.tool_callsJSON viajson_each(discord-voice never wrote to the standalonetool_callstable in the legacy schema).Stando-specific divergence from OSS (intentional, documented)
require('node:sqlite')ininit()— older bundled Node runtimes gatenode:sqlitebehind--experimental-sqlite; a static import would throwERR_UNKNOWN_BUILTIN_MODULEand crash every process that importsconversation-store. The lazy path lets the mirror degrade to a no-op while the textconversation.logkeeps working.Why this fits the "minimal divergence" rule
Test plan
20-test suite covers:
sourceFromRole+kindFromRolerouting contract (incl.core-agent→agentvia suffix-matching, and edge case:'shared-user'is not prefixed → voice)recordConversationroutes by role-prefix; rows don't leak across surface tablesrecordToolCallwriteskind='tool_call'to the named surface table;durationMs=nulllands as NULL (not 0)recordSessionBoundarywrites a SESSION_END row to the voice tablerecordSession'ssession_eventsfan-out filters duplicate-prefix events (user:/sutando:/tool_call:etc) — surface tables are canonicalrecordSessionrecords a row insessionstableEnd-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 conversationstill works post-migrationv_voice/v_phone/v_discord_voiceviews exist withtimecolumnSessionMetricsback-compat: legacytoolCallsfield 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 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