/** * Seed `chat_session_models` for conversations that existed before the table did. * * Runs from `postdb:push`, so every machine gets it on the next schema push rather than needing a manual * step per server. Bun fires `pre`/`post` hooks for colon-named scripts — verified on 1.3.14 against * `db:push`, because a `post` hook that silently never runs is the worst outcome for a migration. * * ── Why a family guess is good enough here ── * * A transcript records the API model (`claude-opus-5`), and Officer's picker offers CLI aliases * (`claude-code/opus`, `claude-code/opus[1m]`). The mapping is one-way: the 1M variant and `opusplan` are * not recoverable after the fact. So this seeds the FAMILY, which is right about which model answered and * silent about the variant — and the first real turn afterwards replaces it with the exact alias. * * Compare the alternative, which is what this exists to prevent: with no row at all, resuming a session * falls back to the first entry in the catalogue. That looked correct for as long as the first entry * happened to be `opus`, and started showing every old conversation as `fable` the day `fable` was added * above it. * * ── Rules it follows ── * * INSERT ONLY. `seedMissingChatSessionModels` never overwrites, so a session that has already recorded its * exact alias keeps it, and re-running this is a no-op rather than a downgrade. * * NEVER FAILS THE PUSH. Every failure path exits 0 with a printed reason. A schema push that has already * applied must not report failure because a cosmetic backfill could not read a transcript — and `postdb:push` * failing would make `bun db:push` look like it had not worked. * * OWNER ONLY, for now. Members' transcripts are readable (`listTranscriptsAs` goes through `sudo`), but the * platform's own user is not guaranteed to have that permission from a bare CLI invocation, and a member's * sessions fill in on their next turn anyway. Stated here rather than silently scoped. */ import { listTranscriptModels } from '../src/servers/api/chat/claude-sessions'; import { officerModelForApiModel } from '../src/servers/api/chat/model-aliases'; import { chatSessionModelsTableExists, seedMissingChatSessionModels } from 'officerdb'; import { homedir } from 'node:os'; const done = (message: string): never => { console.log(`[backfill:session-models] ${message}`); process.exit(0); }; try { // The push may have been a no-op, or aimed at a database where this table is commented out of // `src/schema.ts`. Checking beats assuming — and beats a confusing "relation does not exist". if (!(await chatSessionModelsTableExists())) done('chat_session_models is not there yet — nothing to do'); const owner = { email: '', home: homedir(), osUser: null, isOwner: true }; const transcripts = listTranscriptModels(owner); if (!transcripts.length) done('no transcripts found — nothing to do'); // Drop what cannot be named honestly: `` is filtered upstream, and mythos and OpenCode models // have no Officer id. A dropped session shows no model rather than a wrong one. const entries = transcripts.flatMap(({ sessionId, apiModel }) => { const model = officerModelForApiModel(apiModel); return model ? [{ sessionId, model }] : []; }); const unmapped = transcripts.length - entries.length; if (!entries.length) done(`${transcripts.length} transcript(s), none with a model Officer offers`); const inserted = await seedMissingChatSessionModels(entries); const skipped = entries.length - inserted; done( `${inserted} seeded` + (skipped ? `, ${skipped} already recorded` : '') + (unmapped ? `, ${unmapped} with no Officer equivalent` : '') + ` (of ${transcripts.length} transcript(s))`, ); } catch (err) { // Deliberately exit 0. See the header: the push has already applied, and this is bookkeeping. done(`skipped — ${err instanceof Error ? err.message : String(err)}`); }