fix(tui): route /save through session.save JSON-RPC

The cherry-picked approach serialized the UI-shaped transcript on the Node
side, producing a third JSON format alongside cli.py save_conversation and
tui_gateway session.save. Simpler to call the existing session.save method,
which already writes the canonical agent history (raw OpenAI messages +
model) to an absolute-path file.

- /save still short-circuits before the slash worker
- Empty transcript -> 'no conversation yet'
- No active session -> 'no active session - nothing to save'
- Otherwise: rpc('session.save', {session_id}) and echo back the file path
- Tests updated to assert RPC contract; new test covers the no-sid case
This commit is contained in:
Teknium 2026-04-25 18:04:09 -07:00 committed by Teknium
parent 1b8ca9254f
commit 2536a36f6f
3 changed files with 76 additions and 88 deletions

View file

@ -1,5 +1,3 @@
import { writeFileSync } from 'node:fs'
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
@ -7,6 +5,7 @@ import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from
import type {
ConfigGetValueResponse,
ConfigSetResponse,
SessionSaveResponse,
SessionSteerResponse,
SessionUndoResponse
} from '../../../gatewayTypes.js'
@ -48,24 +47,6 @@ const DETAILS_USAGE =
const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]'
const pad2 = (n: number): string => String(n).padStart(2, '0')
const saveTimestamp = (d = new Date()): string =>
`${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(
d.getMinutes()
)}${pad2(d.getSeconds())}`
const serializableTranscript = (items: Msg[]) =>
items
.filter(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool')
.filter(m => m.text.trim() || m.thinking?.trim() || m.tools?.length)
.map(m => ({
role: m.role,
text: m.text,
...(m.thinking ? { thinking: m.thinking } : {}),
...(m.tools?.length ? { tools: m.tools } : {})
}))
export const coreCommands: SlashCommand[] = [
{
help: 'list commands + hotkeys',
@ -375,33 +356,32 @@ export const coreCommands: SlashCommand[] = [
help: 'save the current transcript to JSON',
name: 'save',
run: (_arg, ctx) => {
const messages = serializableTranscript(ctx.local.getHistoryItems())
const hasConversation = ctx.local
.getHistoryItems()
.some(m => m.role === 'user' || m.role === 'assistant' || m.role === 'tool')
if (!messages.length) {
if (!hasConversation) {
return ctx.transcript.sys('no conversation yet')
}
const filename = `hermes_conversation_${saveTimestamp()}.json`
try {
writeFileSync(
filename,
`${JSON.stringify(
{
model: ctx.ui.info?.model ?? null,
saved_at: new Date().toISOString(),
session_id: ctx.sid,
messages
},
null,
2
)}\n`,
'utf8'
)
ctx.transcript.sys(`conversation saved to: ${filename}`)
} catch (error) {
ctx.transcript.sys(`failed to save: ${String(error)}`)
if (!ctx.sid) {
return ctx.transcript.sys('no active session — nothing to save')
}
ctx.gateway
.rpc<SessionSaveResponse>('session.save', { session_id: ctx.sid })
.then(
ctx.guarded<SessionSaveResponse>(r => {
const file = r?.file
if (file) {
ctx.transcript.sys(`conversation saved to: ${file}`)
} else {
ctx.transcript.sys('failed to save')
}
})
)
.catch(ctx.guardedErr)
}
},