chatTranscriptStore.ts 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195
  1. import { randomUUID } from 'node:crypto'
  2. import type { DatabaseSync } from 'node:sqlite'
  3. import {
  4. CHAT_TRANSCRIPT_FEATURE,
  5. type TranscriptEvent,
  6. type TranscriptControlState,
  7. type TranscriptMessage,
  8. type TranscriptSnapshot,
  9. } from '../shared/chatTranscript.js'
  10. import { toolStatus } from '../shared/chatTools.js'
  11. import { isFinalChatResult } from './chatUnread.js'
  12. type Scope = [string, string, string]
  13. type Data = Record<string, any>
  14. interface TurnHead {
  15. turn?: string
  16. current?: string
  17. preview?: boolean
  18. terminal?: boolean
  19. running: boolean
  20. queued: boolean
  21. queue: Array<{ turn: string; user: string; delivery: string }>
  22. tools: Record<string, string>
  23. pending: Record<string, { user: string; turn: string; previousRunning: boolean }>
  24. pendingApproval?: Data | null
  25. pendingClarification?: Data | null
  26. liveStatus?: string | null
  27. resyncing?: boolean
  28. error?: string | null
  29. stagedAttachments?: Data[]
  30. localReadEstablished?: boolean
  31. importedReadPosition?: number
  32. }
  33. const emptyHead = (): TurnHead => ({ running: false, queued: false, queue: [], tools: {}, pending: {} })
  34. const text = (p: Data): string => String(p.output ?? p.text ?? p.content ?? '')
  35. const controlState = (head: TurnHead): TranscriptControlState => ({
  36. running: head.running,
  37. queued: head.queued,
  38. pendingApproval: head.pendingApproval ?? null,
  39. pendingClarification: head.pendingClarification ?? null,
  40. liveStatus: head.liveStatus ?? null,
  41. error: head.error ?? null,
  42. })
  43. /** The sole ordinary-chat writer. Raw history is an import source, never a live projection. */
  44. export class ChatTranscriptStore {
  45. readonly enabled = true
  46. readonly epoch: string
  47. private listeners = new Set<() => void>()
  48. private scheduled = false
  49. private closed = false
  50. constructor(
  51. readonly db: DatabaseSync,
  52. readonly enrich: (
  53. owner: string,
  54. profile: string,
  55. sessionId: string,
  56. rows: Record<string, any>[],
  57. ) => Record<string, any>[] = (_o, _p, _s, rows) => rows,
  58. ) {
  59. db.exec(`CREATE TABLE IF NOT EXISTS chat_transcript_meta(key TEXT PRIMARY KEY,value TEXT NOT NULL);
  60. CREATE TABLE IF NOT EXISTS chat_transcript_scopes(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id));
  61. CREATE TABLE IF NOT EXISTS chat_transcript_execution(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,data TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id));
  62. CREATE TABLE IF NOT EXISTS chat_transcript_messages(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,
  63. id TEXT NOT NULL,seq INTEGER NOT NULL,revision INTEGER NOT NULL,data TEXT NOT NULL,deleted INTEGER NOT NULL DEFAULT 0,
  64. PRIMARY KEY(owner,profile,session_id,id));
  65. CREATE INDEX IF NOT EXISTS chat_transcript_order ON chat_transcript_messages(owner,profile,session_id,seq);
  66. CREATE TABLE IF NOT EXISTS chat_transcript_aliases(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,
  67. source TEXT NOT NULL,id TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id,source));
  68. CREATE TABLE IF NOT EXISTS chat_transcript_events(cursor INTEGER PRIMARY KEY AUTOINCREMENT,owner TEXT NOT NULL,
  69. profile TEXT NOT NULL,session_id TEXT NOT NULL,type TEXT NOT NULL,data TEXT NOT NULL,created_at INTEGER NOT NULL);
  70. CREATE INDEX IF NOT EXISTS chat_transcript_replay ON chat_transcript_events(owner,profile,session_id,cursor);`)
  71. db.exec(`CREATE TABLE IF NOT EXISTS chat_transcript_heads(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,epoch TEXT NOT NULL,data TEXT NOT NULL,
  72. PRIMARY KEY(owner,profile,session_id));
  73. CREATE TABLE IF NOT EXISTS chat_transcript_archives(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,epoch TEXT NOT NULL,data TEXT NOT NULL,created_at INTEGER NOT NULL,
  74. PRIMARY KEY(owner,profile,session_id,epoch));
  75. CREATE TABLE IF NOT EXISTS chat_transcript_reads(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,id TEXT NOT NULL,PRIMARY KEY(owner,profile,session_id,id));
  76. CREATE TABLE IF NOT EXISTS chat_transcript_source_cursors(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,runtime TEXT NOT NULL,epoch TEXT NOT NULL,seq INTEGER NOT NULL,
  77. PRIMARY KEY(owner,profile,session_id,runtime,epoch));
  78. CREATE TABLE IF NOT EXISTS chat_transcript_repairs(owner TEXT NOT NULL,profile TEXT NOT NULL,session_id TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'pending',
  79. PRIMARY KEY(owner,profile,session_id));`)
  80. db.prepare("INSERT OR IGNORE INTO chat_transcript_meta VALUES('epoch',?)").run(randomUUID())
  81. this.epoch = String(db.prepare("SELECT value FROM chat_transcript_meta WHERE key='epoch'").get()!.value)
  82. }
  83. close() {
  84. this.closed = true
  85. this.listeners.clear()
  86. }
  87. subscribe(listener: () => void) {
  88. this.listeners.add(listener)
  89. return () => {
  90. this.listeners.delete(listener)
  91. }
  92. }
  93. private notify() {
  94. if (this.scheduled || this.closed) return
  95. this.scheduled = true
  96. // All enclosing cache savepoints finish synchronously. Read committed log
  97. // rows in subscribers; a rolled-back notification therefore emits nothing.
  98. queueMicrotask(() => {
  99. this.scheduled = false
  100. if (!this.closed)
  101. for (const listener of this.listeners) {
  102. try {
  103. listener()
  104. } catch {}
  105. }
  106. })
  107. }
  108. private event(
  109. owner: string,
  110. profile: string,
  111. sessionId: string,
  112. type: TranscriptEvent['type'],
  113. data: unknown,
  114. ) {
  115. const previousCursor = this.cursor(owner, profile, sessionId)
  116. const envelope = {
  117. data,
  118. previousCursor,
  119. epoch: this.epochFor(owner, profile, sessionId),
  120. total: this.total(owner, profile, sessionId),
  121. }
  122. this.db
  123. .prepare(
  124. 'INSERT INTO chat_transcript_events(owner,profile,session_id,type,data,created_at) VALUES(?,?,?,?,?,?)',
  125. )
  126. .run(owner, profile, sessionId, type, JSON.stringify(envelope), Date.now())
  127. this.notify()
  128. }
  129. private alias(owner: string, profile: string, sessionId: string, key: string): string | undefined {
  130. return (
  131. this.db
  132. .prepare(
  133. 'SELECT id FROM chat_transcript_aliases WHERE owner=? AND profile=? AND session_id=? AND source=?',
  134. )
  135. .get(owner, profile, sessionId, key) as { id: string } | undefined
  136. )?.id
  137. }
  138. promote(owner: string, profile: string, sessionId: string, oldSource: string, newSource: string) {
  139. const id = this.alias(owner, profile, sessionId, `source:${oldSource}`)
  140. if (id && !this.alias(owner, profile, sessionId, `source:${newSource}`))
  141. this.db
  142. .prepare('INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?)')
  143. .run(owner, profile, sessionId, `source:${newSource}`, id)
  144. }
  145. upsert(
  146. owner: string,
  147. profile: string,
  148. sessionId: string,
  149. source: Record<string, any>,
  150. position: number,
  151. ): TranscriptMessage | undefined {
  152. const sourceID = String(source.id ?? source.message_id ?? '')
  153. if (!sourceID) return
  154. const keys = [`source:${sourceID}`]
  155. if (source.role === 'user' && source.client_message_id) keys.unshift(`client:${source.client_message_id}`)
  156. if (source.role === 'tool' && source.tool_call_id)
  157. keys.unshift(`tool:${source.turn_id ?? ''}:${source.tool_call_id}`)
  158. const id =
  159. this.message(owner, profile, sessionId, sourceID)?.id ??
  160. keys.map((key) => this.alias(owner, profile, sessionId, key)).find(Boolean) ??
  161. randomUUID()
  162. for (const duplicate of new Set(
  163. keys
  164. .map((key) => this.alias(owner, profile, sessionId, key))
  165. .filter((value): value is string => !!value && value !== id),
  166. )) {
  167. const old = this.db
  168. .prepare(
  169. 'SELECT revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?',
  170. )
  171. .get(owner, profile, sessionId, duplicate) as { revision: number; deleted: number } | undefined
  172. this.db
  173. .prepare(
  174. 'UPDATE chat_transcript_aliases SET id=? WHERE owner=? AND profile=? AND session_id=? AND id=?',
  175. )
  176. .run(id, owner, profile, sessionId, duplicate)
  177. if (old && !old.deleted) {
  178. this.db
  179. .prepare(
  180. 'UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?',
  181. )
  182. .run(owner, profile, sessionId, duplicate)
  183. this.event(owner, profile, sessionId, 'message.deleted', {
  184. id: duplicate,
  185. revision: old.revision + 1,
  186. })
  187. }
  188. }
  189. const row = this.db
  190. .prepare(
  191. 'SELECT data,revision,deleted FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=?',
  192. )
  193. .get(owner, profile, sessionId, id) as { data: string; revision: number; deleted: number } | undefined
  194. const old: TranscriptMessage | undefined = row ? JSON.parse(row.data) : undefined
  195. const next: TranscriptMessage = {
  196. ...source,
  197. id,
  198. source_message_id: source.source_message_id ?? old?.source_message_id ?? sourceID,
  199. seq: old?.seq ?? position + 1,
  200. revision: row?.revision ?? 0,
  201. role: String(source.role ?? 'assistant'),
  202. content: source.content ?? '',
  203. reasoning: String(source.reasoning ?? source.reasoning_content ?? ''),
  204. }
  205. if (!next.client_message_id && old?.client_message_id) next.client_message_id = old.client_message_id
  206. for (const key of keys)
  207. this.db
  208. .prepare(
  209. 'INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source) DO UPDATE SET id=excluded.id',
  210. )
  211. .run(owner, profile, sessionId, key, id)
  212. if (old && !row?.deleted && JSON.stringify(old) === JSON.stringify(next)) return old
  213. next.revision++
  214. this.db
  215. .prepare(
  216. 'INSERT INTO chat_transcript_messages VALUES(?,?,?,?,?,?,?,0) ON CONFLICT(owner,profile,session_id,id) DO UPDATE SET seq=excluded.seq,revision=excluded.revision,data=excluded.data,deleted=0',
  217. )
  218. .run(owner, profile, sessionId, id, next.seq, next.revision, JSON.stringify(next))
  219. let patch: Record<string, unknown> | undefined
  220. if (
  221. old &&
  222. !row?.deleted &&
  223. old.status === 'streaming' &&
  224. next.status === 'streaming' &&
  225. typeof old.content === 'string' &&
  226. typeof next.content === 'string' &&
  227. next.content.startsWith(old.content) &&
  228. next.reasoning!.startsWith(String(old.reasoning ?? ''))
  229. ) {
  230. const { content: a, reasoning: b, revision: c, ...oldMeta } = old
  231. const { content: d, reasoning: e, revision: f, ...newMeta } = next
  232. if (JSON.stringify(oldMeta) === JSON.stringify(newMeta))
  233. patch = {
  234. id,
  235. seq: next.seq,
  236. baseRevision: old.revision,
  237. revision: next.revision,
  238. contentAppend: next.content.slice(old.content.length),
  239. reasoningAppend: next.reasoning!.slice(String(old.reasoning ?? '').length),
  240. }
  241. }
  242. this.event(owner, profile, sessionId, patch ? 'message.patch' : 'message.upsert', patch ?? next)
  243. return next
  244. }
  245. reconcile(owner: string, profile: string, sessionId: string) {
  246. const scope: Scope = [owner, profile, sessionId]
  247. this.seed(...scope)
  248. if (this.head(...scope).running) throw new Error('Conversation is running; defer history repair')
  249. this.importHistory(scope, true)
  250. }
  251. seed(owner: string, profile: string, sessionId: string) {
  252. const scope: Scope = [owner, profile, sessionId]
  253. if (
  254. this.db
  255. .prepare('SELECT 1 FROM chat_transcript_heads WHERE owner=? AND profile=? AND session_id=?')
  256. .get(...scope)
  257. )
  258. return
  259. this.db.exec('SAVEPOINT transcript_seed')
  260. try {
  261. this.archive(scope)
  262. // v1 records were projections of the legacy writer. Preserve their source
  263. // for verification; do not mix their IDs/checkpoints with a v2 generation.
  264. for (const table of [
  265. 'chat_transcript_messages',
  266. 'chat_transcript_aliases',
  267. 'chat_transcript_execution',
  268. ])
  269. this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(...scope)
  270. this.db
  271. .prepare('INSERT INTO chat_transcript_heads VALUES(?,?,?,?,?)')
  272. .run(...scope, randomUUID(), JSON.stringify(emptyHead()))
  273. this.db.prepare('INSERT OR IGNORE INTO chat_transcript_scopes VALUES(?,?,?)').run(...scope)
  274. this.importHistory(scope, false)
  275. const active = this.all(...scope)
  276. .reverse()
  277. .find((m) => m.status === 'streaming' && m.role === 'assistant')
  278. if (active)
  279. this.db
  280. .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
  281. .run(
  282. JSON.stringify({ ...emptyHead(), turn: active.turn_id, current: active.id, running: true }),
  283. ...scope,
  284. )
  285. if (this.total(...scope))
  286. this.db
  287. .prepare('INSERT OR IGNORE INTO chat_transcript_repairs VALUES(?,?,?,?)')
  288. .run(...scope, 'pending')
  289. this.db.exec('RELEASE transcript_seed')
  290. } catch (error) {
  291. this.db.exec('ROLLBACK TO transcript_seed; RELEASE transcript_seed')
  292. throw error
  293. }
  294. }
  295. sessionChanged(owner: string, profile: string, sessionId: string, data: Record<string, unknown>) {
  296. this.reduce(owner, profile, sessionId, String(data.eventType), (data.payload ?? {}) as Data)
  297. }
  298. epochFor(...scope: Scope): string {
  299. return String(
  300. this.db
  301. .prepare('SELECT epoch FROM chat_transcript_heads WHERE owner=? AND profile=? AND session_id=?')
  302. .get(...scope)?.epoch ?? this.epoch,
  303. )
  304. }
  305. head(...scope: Scope): TurnHead {
  306. const row = this.db
  307. .prepare('SELECT data FROM chat_transcript_heads WHERE owner=? AND profile=? AND session_id=?')
  308. .get(...scope)
  309. return row ? JSON.parse(String(row.data)) : emptyHead()
  310. }
  311. control(...scope: Scope): TranscriptControlState {
  312. return controlState(this.head(...scope))
  313. }
  314. message(owner: string, profile: string, sessionId: string, id: string): TranscriptMessage | undefined {
  315. const row = this.db
  316. .prepare(
  317. 'SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND id=? AND deleted=0',
  318. )
  319. .get(owner, profile, sessionId, id)
  320. return row ? JSON.parse(String(row.data)) : undefined
  321. }
  322. resolveMessage(
  323. owner: string,
  324. profile: string,
  325. sessionId: string,
  326. id: string,
  327. ): TranscriptMessage | undefined {
  328. return (
  329. this.message(owner, profile, sessionId, id) ??
  330. this.message(owner, profile, sessionId, this.alias(owner, profile, sessionId, `source:${id}`) ?? '')
  331. )
  332. }
  333. all(...scope: Scope): TranscriptMessage[] {
  334. return this.db
  335. .prepare(
  336. 'SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0 ORDER BY seq,id',
  337. )
  338. .all(...scope)
  339. .map((row) => JSON.parse(String(row.data)))
  340. }
  341. total(...scope: Scope): number {
  342. return Number(
  343. this.db
  344. .prepare(
  345. 'SELECT COUNT(*) n FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0',
  346. )
  347. .get(...scope)!.n,
  348. )
  349. }
  350. private parkOpenAnswer(scope: Scope, state: TurnHead) {
  351. if (!state.current) return
  352. const active = this.message(...scope, state.current)
  353. const visible = Boolean(
  354. active &&
  355. (String(active.content ?? '').trim() ||
  356. String(active.reasoning ?? '').trim() ||
  357. (Array.isArray(active.tool_calls) && active.tool_calls.length)),
  358. )
  359. if (active && visible) this.put(scope, { ...active, status: 'complete', final_result: false })
  360. else if (active) {
  361. const revision = active.revision + 1
  362. this.db
  363. .prepare(
  364. 'UPDATE chat_transcript_messages SET deleted=1,revision=? WHERE owner=? AND profile=? AND session_id=? AND id=?',
  365. )
  366. .run(revision, ...scope, active.id)
  367. this.event(...scope, 'message.deleted', { id: active.id, revision })
  368. }
  369. state.current = undefined
  370. state.preview = false
  371. }
  372. /** A later completion often repeats the whole turn. Keep only the part written after this row. */
  373. private placedOutput(scope: Scope, row: TranscriptMessage | undefined, turn: string | undefined, output: unknown, fallback: unknown): unknown {
  374. if (typeof output !== 'string') return output ?? fallback
  375. if (!output || !turn) return output || fallback
  376. const prefix = this.all(...scope)
  377. .filter((message) => message.role === 'assistant' && message.turn_id === turn && message.id !== row?.id && (!row || message.seq < row.seq))
  378. .sort((left, right) => left.seq - right.seq)
  379. .map((message) => String(message.content ?? ''))
  380. .join('')
  381. if (!prefix || !output.startsWith(prefix)) return output
  382. return output.slice(prefix.length) || fallback
  383. }
  384. private put(scope: Scope, value: Data): TranscriptMessage {
  385. const position = Number(
  386. this.db
  387. .prepare(
  388. 'SELECT COALESCE(MAX(seq),0) n FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=?',
  389. )
  390. .get(...scope)!.n,
  391. )
  392. return this.upsert(
  393. ...scope,
  394. { timestamp: Date.now() / 1000, ...value, id: value.id ?? randomUUID() },
  395. position,
  396. )!
  397. }
  398. private bind(scope: Scope, source: string, id: string) {
  399. if (source)
  400. this.db
  401. .prepare(
  402. 'INSERT INTO chat_transcript_aliases VALUES(?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source) DO UPDATE SET id=excluded.id',
  403. )
  404. .run(...scope, `source:${source}`, id)
  405. }
  406. archive(scope: Scope) {
  407. const data = {
  408. messages: this.all(...scope),
  409. head: this.head(...scope),
  410. rawMessages: this.db
  411. .prepare(
  412. 'SELECT message_id,position,data FROM chat_messages WHERE owner=? AND profile=? AND session_id=? ORDER BY position',
  413. )
  414. .all(...scope),
  415. aliases: this.db
  416. .prepare('SELECT source,id FROM chat_transcript_aliases WHERE owner=? AND profile=? AND session_id=?')
  417. .all(...scope),
  418. }
  419. if (data.messages.length || data.rawMessages.length)
  420. this.db
  421. .prepare('INSERT OR IGNORE INTO chat_transcript_archives VALUES(?,?,?,?,?,?)')
  422. .run(...scope, this.epochFor(...scope), JSON.stringify(data), Date.now())
  423. }
  424. private importHistory(scope: Scope, verified: boolean) {
  425. if (verified) this.archive(scope)
  426. const raw = this.db
  427. .prepare(
  428. 'SELECT data,position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? ORDER BY position',
  429. )
  430. .all(...scope) as { data: string; position: number }[]
  431. const incoming = this.enrich(
  432. ...scope,
  433. raw.map((row) => JSON.parse(row.data)),
  434. )
  435. const existing = this.all(...scope),
  436. retained = new Set<string>(),
  437. wasRead = new Set(
  438. this.db
  439. .prepare('SELECT id FROM chat_transcript_reads WHERE owner=? AND profile=? AND session_id=?')
  440. .all(...scope)
  441. .map((row) => String(row.id)),
  442. )
  443. if (
  444. verified &&
  445. existing.some(
  446. (m) =>
  447. m.role === 'user' &&
  448. m.status === 'complete' &&
  449. m.client_message_id &&
  450. m.source_message_id.startsWith('user:') &&
  451. !incoming.some(
  452. (source) =>
  453. source.role === 'user' &&
  454. (source.client_message_id === m.client_message_id ||
  455. String(source.id ?? source.message_id) === m.source_message_id),
  456. ),
  457. )
  458. ) {
  459. this.db
  460. .prepare(
  461. "INSERT INTO chat_transcript_repairs VALUES(?,?,?,'pending') ON CONFLICT(owner,profile,session_id) DO UPDATE SET status='pending'",
  462. )
  463. .run(...scope)
  464. return // The archive is available for verification; ambiguous live turns remain untouched.
  465. }
  466. const legacyRead = this.db
  467. .prepare(
  468. 'SELECT COALESCE(final_read_count,read_count) n,read_initialized FROM chat_local_state WHERE owner=? AND profile=? AND session_id=?',
  469. )
  470. .get(...scope)
  471. const boundary = legacyRead?.read_initialized ? Number(legacyRead.n) : Infinity
  472. let turn = 'history:orphan',
  473. ordinal = 0
  474. for (const [index, source] of incoming.entries()) {
  475. const sourceID = String(source.id ?? source.message_id ?? raw[index]!.position)
  476. let known = this.resolveMessage(...scope, sourceID)
  477. if (source.role === 'user' && source.client_message_id)
  478. known ??= existing.find((m) => m.client_message_id === source.client_message_id)
  479. if (source.role === 'user') turn = known?.turn_id ?? `history:${sourceID}`
  480. const nextUser = incoming.findIndex((m, i) => i > index && m.role === 'user')
  481. const rest = incoming.slice(index + 1, nextUser < 0 ? undefined : nextUser)
  482. const final =
  483. isFinalChatResult(source) &&
  484. (source.final_result === true || !rest.some((message) => isFinalChatResult(message)))
  485. // Only a proven user identity can associate an upstream final with the
  486. // one live final of that turn. Content similarity is never an identity.
  487. if (!known && verified && final) {
  488. const candidates = existing.filter(
  489. (m) => m.turn_id === turn && m.final_result === true && !retained.has(m.id),
  490. )
  491. if (candidates.length === 1) known = candidates[0]
  492. }
  493. const value = this.upsert(
  494. ...scope,
  495. {
  496. ...source,
  497. id: known?.id ?? sourceID,
  498. source_message_id: sourceID,
  499. turn_id: turn,
  500. segment_kind:
  501. source.role === 'user'
  502. ? 'input'
  503. : source.role === 'tool'
  504. ? 'tool'
  505. : final
  506. ? 'answer'
  507. : 'commentary',
  508. final_result: final,
  509. },
  510. verified ? ordinal++ : raw[index]!.position,
  511. )!
  512. this.bind(scope, sourceID, value.id)
  513. retained.add(value.id)
  514. if (wasRead.has(known?.id ?? '') || raw[index]!.position < boundary)
  515. this.db.prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)').run(...scope, value.id)
  516. }
  517. if (!verified) return
  518. // An unconfirmed local send is not disproven by a history snapshot.
  519. for (const message of existing)
  520. if (message.role === 'user' && message.status === 'pending') retained.add(message.id)
  521. let structural = [...retained].some((id) => !existing.some((message) => message.id === id))
  522. for (const message of existing)
  523. if (!retained.has(message.id)) {
  524. structural = true
  525. this.db
  526. .prepare(
  527. 'UPDATE chat_transcript_messages SET deleted=1,revision=revision+1 WHERE owner=? AND profile=? AND session_id=? AND id=?',
  528. )
  529. .run(...scope, message.id)
  530. this.event(...scope, 'message.deleted', { id: message.id, revision: message.revision + 1 })
  531. }
  532. // Rebuild order only at an explicit generation boundary. Old pages/events
  533. // cannot be merged into this generation, even while a client is paging.
  534. const ordered = [...retained]
  535. for (const [index, id] of ordered.entries()) {
  536. const message = this.message(...scope, id)!
  537. if (message.seq !== index + 1) {
  538. structural = true
  539. this.db
  540. .prepare(
  541. 'UPDATE chat_transcript_messages SET seq=?,revision=revision+1,data=? WHERE owner=? AND profile=? AND session_id=? AND id=?',
  542. )
  543. .run(
  544. index + 1,
  545. JSON.stringify({ ...message, seq: index + 1, revision: message.revision + 1 }),
  546. ...scope,
  547. id,
  548. )
  549. }
  550. }
  551. if (structural) {
  552. this.db
  553. .prepare('UPDATE chat_transcript_heads SET epoch=? WHERE owner=? AND profile=? AND session_id=?')
  554. .run(randomUUID(), ...scope)
  555. this.event(...scope, 'session.changed', {
  556. eventType: 'history.rebuilt',
  557. ...controlState(this.head(...scope)),
  558. })
  559. }
  560. this.db
  561. .prepare(
  562. "UPDATE chat_transcript_repairs SET status='complete' WHERE owner=? AND profile=? AND session_id=?",
  563. )
  564. .run(...scope)
  565. }
  566. markRead(owner: string, profile: string, sessionId: string, count = Infinity) {
  567. const head = this.head(owner, profile, sessionId)
  568. head.localReadEstablished = true
  569. this.db
  570. .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
  571. .run(JSON.stringify(head), owner, profile, sessionId)
  572. for (const m of this.all(owner, profile, sessionId))
  573. if (m.seq <= count && isFinalChatResult(m))
  574. this.db
  575. .prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)')
  576. .run(owner, profile, sessionId, m.id)
  577. }
  578. unread(...scope: Scope): number {
  579. // Legacy read cursors can finish importing after the initial local snapshot.
  580. // Map only persisted source rows, never consume a live streaming result.
  581. const legacy = this.db
  582. .prepare(
  583. 'SELECT COALESCE(final_read_count,read_count) n FROM chat_local_state WHERE owner=? AND profile=? AND session_id=? AND read_initialized=1',
  584. )
  585. .get(...scope)
  586. const head = this.head(...scope)
  587. if (legacy && !head.localReadEstablished && Number(legacy.n) > (head.importedReadPosition ?? -1)) {
  588. for (const row of this.db
  589. .prepare(
  590. 'SELECT message_id FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND position<?',
  591. )
  592. .all(...scope, Number(legacy.n))) {
  593. const m = this.resolveMessage(...scope, String(row.message_id))
  594. if (m && isFinalChatResult(m))
  595. this.db.prepare('INSERT OR IGNORE INTO chat_transcript_reads VALUES(?,?,?,?)').run(...scope, m.id)
  596. }
  597. head.importedReadPosition = Number(legacy.n)
  598. this.db
  599. .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
  600. .run(JSON.stringify(head), ...scope)
  601. }
  602. return this.db
  603. .prepare(
  604. `SELECT m.data FROM chat_transcript_messages m LEFT JOIN chat_transcript_reads r USING(owner,profile,session_id,id)
  605. WHERE m.owner=? AND m.profile=? AND m.session_id=? AND m.deleted=0 AND r.id IS NULL AND json_extract(m.data,'$.final_result')=1`,
  606. )
  607. .all(...scope)
  608. .filter((row) => isFinalChatResult(JSON.parse(String(row.data)))).length
  609. }
  610. sourceCursor(owner: string, profile: string, sessionId: string, runtime: string, epoch: string): number {
  611. return Number(
  612. this.db
  613. .prepare(
  614. 'SELECT seq FROM chat_transcript_source_cursors WHERE owner=? AND profile=? AND session_id=? AND runtime=? AND epoch=?',
  615. )
  616. .get(owner, profile, sessionId, runtime, epoch)?.seq ?? 0,
  617. )
  618. }
  619. recordSource(scope: Scope, frame: Data) {
  620. if (frame.epoch && frame.session_id && Number.isSafeInteger(frame.seq))
  621. this.db
  622. .prepare(
  623. 'INSERT INTO chat_transcript_source_cursors VALUES(?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,runtime,epoch) DO UPDATE SET seq=MAX(seq,excluded.seq)',
  624. )
  625. .run(...scope, frame.session_id, frame.epoch, frame.seq)
  626. }
  627. /** Caller holds the transaction containing the input receipt and raw event. */
  628. reduce(owner: string, profile: string, sessionId: string, type: string, p: Data) {
  629. type =
  630. (
  631. {
  632. 'message.started': 'message.start',
  633. 'message.completed': 'message.complete',
  634. 'run.complete': 'run.completed',
  635. 'reasoning.completed': 'reasoning.complete',
  636. 'peer.user.message': 'message.user',
  637. peer_user_message: 'message.user',
  638. 'run.peer_user_message': 'message.user',
  639. } as Record<string, string>
  640. )[type] ?? type
  641. const scope: Scope = [owner, profile, sessionId]
  642. this.seed(...scope)
  643. const state = this.head(...scope)
  644. const before = JSON.stringify(state)
  645. const sourceID = String(p.message_id ?? p.message?.id ?? '')
  646. const upstreamRun = String(p.run_id ?? p.runId ?? p.inflight?.run_id ?? '')
  647. const knownTurn = upstreamRun ? this.alias(...scope, `source:turn:${upstreamRun}`) : undefined
  648. if (
  649. knownTurn &&
  650. (knownTurn !== state.turn || state.terminal) &&
  651. ['run.started', 'run.start', 'message.start'].includes(type)
  652. )
  653. return
  654. const known =
  655. (sourceID ? this.resolveMessage(...scope, sourceID) : undefined) ??
  656. (knownTurn && knownTurn !== state.turn
  657. ? this.all(...scope)
  658. .reverse()
  659. .find((m) => m.turn_id === knownTurn && m.role === 'assistant')
  660. : undefined)
  661. if (
  662. known &&
  663. state.turn &&
  664. known.turn_id !== state.turn &&
  665. ['message.complete', 'run.completed', 'message.delta', 'message.interim'].includes(type)
  666. ) {
  667. const terminal = ['message.complete', 'run.completed'].includes(type)
  668. this.put(scope, {
  669. ...known,
  670. content:
  671. type === 'message.delta'
  672. ? String(known.content ?? '') + String(p.delta ?? p.text ?? '')
  673. : text(p) || known.content,
  674. ...(terminal ? { status: 'complete', final_result: true } : {}),
  675. })
  676. return
  677. }
  678. const current = () => (state.current ? this.message(...scope, state.current) : undefined)
  679. const begin = () => {
  680. if (!state.turn || state.terminal) {
  681. const queued = state.queue.shift()
  682. state.turn = queued?.turn ?? randomUUID()
  683. state.current = undefined
  684. state.preview = false
  685. state.terminal = false
  686. state.tools = {}
  687. state.error = null
  688. }
  689. if (upstreamRun) this.bind(scope, `turn:${upstreamRun}`, state.turn!)
  690. state.running = true
  691. state.queued = state.queue.length > 0
  692. }
  693. const assistant = (): TranscriptMessage => {
  694. begin()
  695. const old = current()
  696. if (old) return old
  697. const created = this.put(scope, {
  698. role: 'assistant',
  699. turn_id: state.turn,
  700. segment_kind: 'answer',
  701. content: '',
  702. reasoning: '',
  703. status: 'streaming',
  704. final_result: false,
  705. })
  706. state.current = created.id
  707. return created
  708. }
  709. const saveAssistant = (value: Data) => {
  710. const old = assistant(),
  711. source = String(p.message_id ?? p.message?.id ?? '')
  712. const attachments = p.attachments ?? p.message?.attachments
  713. const saved = this.put(scope, {
  714. ...old,
  715. ...value,
  716. ...(attachments ? { attachments } : {}),
  717. ...(source ? { source_message_id: source } : {}),
  718. })
  719. this.bind(scope, source, saved.id)
  720. return saved
  721. }
  722. if (type === 'command.submitted') {
  723. const delivery = String(p.delivery_id),
  724. client = String(p.client_message_id ?? delivery.replace(/^(web|ios|android):prompt:/, ''))
  725. if (!this.alias(...scope, `client:${client}`)) {
  726. const previousRunning = state.running,
  727. steer = p.method === 'session.steer' && state.running
  728. // The open answer already has a sequence. Parking it here lets the
  729. // inserted user row land at this moment, and the rest of the reply
  730. // continues in a later row.
  731. if (steer) this.parkOpenAnswer(scope, state)
  732. const turn = steer ? state.turn! : randomUUID()
  733. const user = this.put(scope, {
  734. id: `user:${delivery}`,
  735. role: 'user',
  736. turn_id: turn,
  737. segment_kind: 'input',
  738. client_message_id: client,
  739. content: p.text ?? '',
  740. status: 'pending',
  741. ...(state.stagedAttachments?.length ? { attachments: state.stagedAttachments } : {}),
  742. })
  743. state.stagedAttachments = []
  744. state.error = null
  745. state.pending[delivery] = { user: user.id, turn, previousRunning }
  746. if (previousRunning && !steer) {
  747. state.queue.push({ turn, user: user.id, delivery })
  748. state.queued = true
  749. } else if (!steer) {
  750. state.turn = turn
  751. state.current = undefined
  752. state.preview = false
  753. state.terminal = false
  754. state.tools = {}
  755. }
  756. state.running = true
  757. }
  758. } else if (type === 'command.confirmed' || type === 'command.rejected') {
  759. const pending = state.pending[String(p.delivery_id)]
  760. if (pending) {
  761. const user = this.message(...scope, pending.user)
  762. if (user)
  763. this.put(scope, {
  764. ...user,
  765. status: type === 'command.rejected' ? 'failed' : 'complete',
  766. ...(p.error ? { error: p.error } : {}),
  767. })
  768. if (type === 'command.rejected') {
  769. state.queue = state.queue.filter((q) => q.delivery !== p.delivery_id)
  770. if (state.turn === pending.turn && !state.current) state.terminal = true
  771. state.running = !state.terminal || state.queue.length > 0
  772. } else if (p.status !== 'queued') {
  773. // The upstream accepted this prompt for immediate execution. A local
  774. // queue entry only exists because `state.running` looked busy at
  775. // submit time (e.g. a stale background flag or a missed terminal);
  776. // the upstream knows nothing about that entry, so nothing would ever
  777. // drain it except a later `session.resume`. Drop it now and adopt
  778. // its turn so the imminent run events attach to this user row.
  779. const queued = state.queue.filter((q) => q.delivery !== p.delivery_id)
  780. if (queued.length !== state.queue.length) {
  781. state.queue = queued
  782. if (state.terminal) {
  783. state.turn = pending.turn
  784. state.current = undefined
  785. state.preview = false
  786. state.terminal = false
  787. state.tools = {}
  788. }
  789. }
  790. }
  791. delete state.pending[String(p.delivery_id)]
  792. state.queued = state.queue.length > 0
  793. }
  794. } else if (type === 'message.user') {
  795. const client = String(p.client_message_id ?? p.queue_id ?? '').replace(/^(web|ios|android):prompt:/, '')
  796. const source = String(p.message_id ?? p.id ?? '')
  797. const old =
  798. (client
  799. ? this.all(...scope).find((m) => m.role === 'user' && m.client_message_id === client)
  800. : undefined) ?? (source ? this.resolveMessage(...scope, source) : undefined)
  801. if (source || client) {
  802. const turn = old?.turn_id ?? (p.steer ? state.turn : undefined) ?? randomUUID()
  803. const user = this.put(scope, {
  804. ...old,
  805. id: old?.id || source || randomUUID(),
  806. role: 'user',
  807. turn_id: turn,
  808. segment_kind: 'input',
  809. content: p.text ?? p.content ?? '',
  810. status: p.status === 'failed' ? 'failed' : 'complete',
  811. ...(client ? { client_message_id: client } : {}),
  812. })
  813. if (!old && p.status !== 'failed') {
  814. if (state.running && !p.steer) {
  815. state.queue.push({ turn, user: user.id, delivery: client || source })
  816. state.queued = true
  817. } else if (!p.steer) {
  818. state.turn = turn
  819. state.current = undefined
  820. state.terminal = false
  821. state.preview = false
  822. }
  823. }
  824. }
  825. } else if (['message.start', 'run.started', 'run.start'].includes(type)) {
  826. const old = current()
  827. if (
  828. type === 'message.start' &&
  829. sourceID &&
  830. old &&
  831. old.source_message_id !== sourceID &&
  832. !this.resolveMessage(...scope, sourceID) &&
  833. (old.content || old.reasoning || old.tool_calls)
  834. ) {
  835. this.put(scope, { ...old, status: 'complete', segment_kind: 'commentary', final_result: false })
  836. state.current = undefined
  837. state.preview = false
  838. }
  839. const row = assistant()
  840. if (sourceID) {
  841. this.bind(scope, sourceID, row.id)
  842. this.put(scope, { ...row, source_message_id: sourceID })
  843. }
  844. } else if (['message.delta', 'content.delta', 'assistant.delta'].includes(type) && !state.resyncing) {
  845. if (state.preview) {
  846. state.current = undefined
  847. state.preview = false
  848. }
  849. const old = assistant()
  850. saveAssistant({
  851. content:
  852. String(old.content ?? '') +
  853. String(p.delta ?? p.text_delta ?? p.content_delta ?? p.text ?? p.content ?? ''),
  854. status: 'streaming',
  855. })
  856. } else if (type === 'message.interim') {
  857. saveAssistant({
  858. content: text(p) || current()?.content || '',
  859. status: 'streaming',
  860. segment_kind: 'commentary',
  861. final_result: false,
  862. })
  863. state.preview = true
  864. } else if (type.startsWith('reasoning.') || type === 'thinking.delta' || type === 'thinking.complete') {
  865. const old = assistant(),
  866. full = p.reasoning ?? p.text ?? p.content ?? p.delta ?? ''
  867. saveAssistant({
  868. reasoning: type.endsWith('delta')
  869. ? String(old.reasoning ?? '') + String(p.delta ?? full)
  870. : String(full),
  871. })
  872. } else if (type.startsWith('tool.')) {
  873. const toolID = String(p.tool_call_id ?? p.tool_id ?? p.id ?? '')
  874. if (toolID) {
  875. let target = state.tools[toolID] ? this.message(...scope, state.tools[toolID]!) : undefined
  876. if (!target && /complete|failed/.test(type)) {
  877. const candidates = this.all(...scope).filter((m) =>
  878. (m.tool_calls as Data[] | undefined)?.some((t) => t.id === toolID),
  879. )
  880. if (candidates.length === 1) target = candidates[0]
  881. }
  882. if (!target) {
  883. if (state.preview) {
  884. const old = current()
  885. if (old) this.put(scope, { ...old, status: 'complete' })
  886. state.current = undefined
  887. state.preview = false
  888. }
  889. target = assistant()
  890. state.tools[toolID] = target.id
  891. }
  892. const tools = Array.isArray(target.tool_calls) ? ([...target.tool_calls] as Data[]) : [],
  893. index = tools.findIndex((t) => t.id === toolID)
  894. const old = index < 0 ? {} : tools[index]!,
  895. status = toolStatus(type, p.result ?? p.output, p.error)
  896. const tool = {
  897. ...old,
  898. ...p,
  899. id: toolID,
  900. status: ['completed', 'failed'].includes(old.status) && status === 'running' ? old.status : status,
  901. }
  902. if (index < 0) tools.push(tool)
  903. else tools[index] = tool
  904. this.put(scope, { ...target, tool_calls: tools })
  905. } else state.liveStatus = p.name ? `正在准备 ${p.name}` : state.liveStatus
  906. } else if (['message.complete', 'run.completed', 'run.complete', 'run.failed', 'error'].includes(type)) {
  907. // A replayed terminal snapshot updates the terminal slot. It never starts
  908. // another turn (the next run.started/delta owns advancing the queue).
  909. // Hermes also closes each model segment with message.complete, including
  910. // a tool round. That is not the end of the user-visible turn.
  911. const old = current()
  912. const wasTerminal = state.terminal
  913. const failed =
  914. ['run.failed', 'error'].includes(type) || p.error || ['failed', 'error'].includes(p.status)
  915. const interrupted = ['interrupted', 'cancelled', 'canceled'].includes(p.status)
  916. const finish = String(
  917. p.finish_reason ?? p.finishReason ?? p.message?.finish_reason ?? p.message?.finishReason ?? '',
  918. ).toLowerCase()
  919. const payloadCalls = p.tool_calls ?? p.message?.tool_calls
  920. const openTools =
  921. !!state.turn &&
  922. this.all(...scope).some(
  923. (m) =>
  924. m.turn_id === state.turn &&
  925. (m.tool_calls as Data[] | undefined)?.some((t) =>
  926. ['running', 'pending'].includes(String(t.status)),
  927. ),
  928. )
  929. const explicitStop = ['stop', 'end', 'end_turn', 'length', 'content_filter'].includes(finish)
  930. const toolRound =
  931. ['tool_calls', 'tool_use', 'function_call'].includes(finish) ||
  932. (Array.isArray(payloadCalls) && payloadCalls.length > 0)
  933. const continues =
  934. type === 'message.complete' && !failed && !interrupted && !explicitStop && (toolRound || openTools)
  935. const output = p.output ?? p.text ?? p.content ?? p.message?.content
  936. if (continues) {
  937. const placed = this.placedOutput(scope, old, state.turn, output, old?.content ?? '')
  938. const hasPlaced = typeof placed === 'string' ? placed.length > 0 : Boolean(placed)
  939. if (old || hasPlaced || p.attachments)
  940. saveAssistant({
  941. content: placed,
  942. status: 'streaming',
  943. final_result: false,
  944. })
  945. state.running = true
  946. state.terminal = false
  947. state.queued = state.queue.length > 0 || Number(p.queue_remaining ?? 0) > 0
  948. } else {
  949. state.terminal = false
  950. const placed = this.placedOutput(scope, old, state.turn, output, old?.content ?? '')
  951. const hasPlaced = typeof placed === 'string' ? placed.length > 0 : Boolean(placed)
  952. if (old || hasPlaced || p.attachments)
  953. saveAssistant({
  954. content: placed,
  955. status: failed ? 'failed' : interrupted ? 'interrupted' : 'complete',
  956. segment_kind: 'answer',
  957. final_result: !failed && !interrupted,
  958. ...(failed ? { error: p.error ?? p.message ?? '运行失败' } : {}),
  959. })
  960. state.error = failed ? String(p.error ?? p.message ?? '运行失败') : null
  961. for (const m of this.all(...scope))
  962. if (m.turn_id === state.turn) {
  963. const calls = (m.tool_calls as Data[] | undefined)?.map((t) =>
  964. ['running', 'pending'].includes(t.status) ? { ...t, status: 'interrupted' } : t,
  965. )
  966. if (m.status === 'streaming' || calls)
  967. this.put(scope, {
  968. ...m,
  969. status: m.status === 'streaming' ? 'complete' : m.status,
  970. ...(calls ? { tool_calls: calls } : {}),
  971. })
  972. }
  973. state.terminal = true
  974. state.preview = false
  975. state.queued = state.queue.length > 0 || Number(p.queue_remaining ?? 0) > 0
  976. state.running = state.queued || Number(p.background_pending ?? 0) > 0
  977. state.resyncing = false
  978. state.pendingApproval = null
  979. state.pendingClarification = null
  980. state.liveStatus = null
  981. if (wasTerminal && state.queue.length === 0) state.running = false
  982. }
  983. } else if (type === 'route.resumed') {
  984. const inflight = p.inflight ?? {},
  985. running = p.running === true || p.info?.running === true || inflight.streaming === true
  986. if (running) {
  987. const hadCurrent = !!current()
  988. begin()
  989. if (typeof inflight.assistant === 'string' && (!hadCurrent || state.resyncing))
  990. saveAssistant({ content: inflight.assistant, status: 'streaming' })
  991. else assistant()
  992. } else if (p.running === false) {
  993. const old = current()
  994. if (old && typeof inflight.assistant === 'string' && inflight.assistant)
  995. this.put(scope, {
  996. ...old,
  997. content: inflight.assistant,
  998. status: 'complete',
  999. final_result: true,
  1000. segment_kind: 'answer',
  1001. })
  1002. state.running = false
  1003. state.terminal = true
  1004. state.resyncing = false
  1005. state.queued = Boolean(p.queued?.user) || Number(p.queue_length ?? p.info?.queue_length ?? 0) > 0
  1006. if (!state.queued) state.queue = []
  1007. for (const m of this.all(...scope))
  1008. if (m.status === 'streaming') this.put(scope, { ...m, status: 'complete' })
  1009. }
  1010. state.pendingApproval = p.pending_approval ?? inflight.pending_approval ?? null
  1011. state.pendingClarification = p.pending_clarify ?? inflight.pending_clarify ?? null
  1012. } else if (type === 'attachment.staged') {
  1013. const attachment = p.attachment ?? p.file ?? p
  1014. if (attachment.path || attachment.url)
  1015. state.stagedAttachments = [...(state.stagedAttachments ?? []), attachment]
  1016. } else if (type === 'gateway.reset') {
  1017. // Without a replay barrier, a resume body can overlap deferred deltas.
  1018. // Accept full snapshots until the terminal boundary, never append bytes
  1019. // whose position cannot be established. The idle history job fills gaps.
  1020. state.resyncing = true
  1021. state.liveStatus = '正在恢复回复'
  1022. } else if (['approval.request', 'approval.requested'].includes(type)) {
  1023. state.pendingApproval = p
  1024. state.running = true
  1025. } else if (type === 'approval.resolved') state.pendingApproval = null
  1026. else if (['clarify.request', 'clarify.requested'].includes(type)) {
  1027. state.pendingClarification = p
  1028. state.running = true
  1029. } else if (['clarify.resolved', 'clarify.expire'].includes(type)) state.pendingClarification = null
  1030. else if (type === 'status.update') {
  1031. state.liveStatus = text(p)
  1032. if (p.kind === 'warn') {
  1033. const source = `status:${state.turn ?? 'session'}:warning`
  1034. this.put(scope, {
  1035. id: this.alias(...scope, `source:${source}`) ?? source,
  1036. role: 'system',
  1037. turn_id: state.turn,
  1038. segment_kind: 'system',
  1039. content: text(p),
  1040. status: 'complete',
  1041. display_kind: 'live_warning',
  1042. final_result: false,
  1043. })
  1044. }
  1045. } else if (
  1046. [
  1047. 'compression.started',
  1048. 'compression.completed',
  1049. 'session.command',
  1050. 'session.workspace.updated',
  1051. ].includes(type)
  1052. ) {
  1053. const source = `system:${state.turn ?? 'session'}:${type.startsWith('compression.') ? 'compression' : type}`
  1054. const content =
  1055. type === 'compression.started'
  1056. ? '正在压缩上下文…'
  1057. : type === 'compression.completed'
  1058. ? '上下文压缩完成,继续回复'
  1059. : text(p)
  1060. if (content)
  1061. this.put(scope, {
  1062. id: this.alias(...scope, `source:${source}`) ?? source,
  1063. role: 'system',
  1064. turn_id: state.turn,
  1065. segment_kind: 'system',
  1066. content,
  1067. status: type.endsWith('started') ? 'streaming' : 'complete',
  1068. display_kind: type.startsWith('compression.') ? 'live_compression' : 'live_status',
  1069. final_result: false,
  1070. })
  1071. }
  1072. if (type === 'session.info') state.liveStatus = state.liveStatus ?? null
  1073. this.db
  1074. .prepare('UPDATE chat_transcript_heads SET data=? WHERE owner=? AND profile=? AND session_id=?')
  1075. .run(JSON.stringify(state), ...scope)
  1076. if (before !== JSON.stringify(state) || ['session.info', 'history.synced'].includes(type))
  1077. this.event(...scope, 'session.changed', {
  1078. eventType: type,
  1079. payload: type === 'session.info' ? p : {},
  1080. ...controlState(state),
  1081. })
  1082. }
  1083. migrate(owner: string, profile: string, oldID: string, newID: string) {
  1084. this.event(owner, profile, oldID, 'session.migrated', { sessionId: newID })
  1085. for (const table of [
  1086. 'chat_transcript_messages',
  1087. 'chat_transcript_aliases',
  1088. 'chat_transcript_scopes',
  1089. 'chat_transcript_execution',
  1090. 'chat_transcript_heads',
  1091. 'chat_transcript_reads',
  1092. 'chat_transcript_source_cursors',
  1093. 'chat_transcript_repairs',
  1094. 'chat_transcript_archives',
  1095. ])
  1096. this.db
  1097. .prepare(`UPDATE ${table} SET session_id=? WHERE owner=? AND profile=? AND session_id=?`)
  1098. .run(newID, owner, profile, oldID)
  1099. }
  1100. remove(owner: string, profile: string, sessionId: string) {
  1101. this.event(owner, profile, sessionId, 'session.deleted', { sessionId })
  1102. for (const table of [
  1103. 'chat_transcript_messages',
  1104. 'chat_transcript_aliases',
  1105. 'chat_transcript_scopes',
  1106. 'chat_transcript_execution',
  1107. 'chat_transcript_heads',
  1108. 'chat_transcript_reads',
  1109. 'chat_transcript_source_cursors',
  1110. 'chat_transcript_repairs',
  1111. 'chat_transcript_archives',
  1112. ])
  1113. this.db
  1114. .prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`)
  1115. .run(owner, profile, sessionId)
  1116. }
  1117. cursor(owner: string, profile: string, sessionId: string): number {
  1118. return Number(
  1119. this.db
  1120. .prepare(
  1121. 'SELECT COALESCE(MAX(cursor),0) cursor FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=?',
  1122. )
  1123. .get(owner, profile, sessionId)!.cursor,
  1124. )
  1125. }
  1126. events(owner: string, profile: string, sessionId: string, after: number, limit = 250): TranscriptEvent[] {
  1127. return (
  1128. this.db
  1129. .prepare(
  1130. 'SELECT cursor,type,data FROM chat_transcript_events WHERE owner=? AND profile=? AND session_id=? AND cursor>? ORDER BY cursor LIMIT ?',
  1131. )
  1132. .all(owner, profile, sessionId, after, limit) as {
  1133. cursor: number
  1134. type: TranscriptEvent['type']
  1135. data: string
  1136. }[]
  1137. ).map((row) => ({ ...row, profile, sessionId, ...JSON.parse(row.data) }))
  1138. }
  1139. snapshot(
  1140. owner: string,
  1141. profile: string,
  1142. sessionId: string,
  1143. session: Record<string, unknown>,
  1144. state: string,
  1145. before = Number.MAX_SAFE_INTEGER,
  1146. limit = 150,
  1147. ): TranscriptSnapshot {
  1148. const run = this.control(owner, profile, sessionId)
  1149. const coverage = this.db
  1150. .prepare(
  1151. 'SELECT complete,message_total FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?',
  1152. )
  1153. .get(owner, profile, sessionId)
  1154. const incomplete = coverage?.complete === 0
  1155. const rows = this.db
  1156. .prepare(
  1157. 'SELECT data FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=0 AND seq<? ORDER BY seq DESC,id DESC LIMIT ?',
  1158. )
  1159. .all(owner, profile, sessionId, before, limit + 1) as { data: string }[]
  1160. return {
  1161. protocol: CHAT_TRANSCRIPT_FEATURE,
  1162. epoch: this.epochFor(owner, profile, sessionId),
  1163. cursor: this.cursor(owner, profile, sessionId),
  1164. profile,
  1165. sessionId,
  1166. running: run.running,
  1167. queued: run.queued,
  1168. pendingApproval: run.pendingApproval,
  1169. pendingClarification: run.pendingClarification,
  1170. liveStatus: run.liveStatus,
  1171. error: run.error,
  1172. messages: rows
  1173. .slice(0, limit)
  1174. .reverse()
  1175. .map((row) => JSON.parse(row.data)),
  1176. session,
  1177. state,
  1178. deletedIds: (
  1179. this.db
  1180. .prepare(
  1181. 'SELECT id FROM chat_transcript_messages WHERE owner=? AND profile=? AND session_id=? AND deleted=1',
  1182. )
  1183. .all(owner, profile, sessionId) as { id: string }[]
  1184. ).map((row) => row.id),
  1185. hasOlder: rows.length > limit || (incomplete && before > 1),
  1186. total: Math.max(
  1187. this.total(owner, profile, sessionId),
  1188. incomplete ? Number(coverage?.message_total ?? 0) : 0,
  1189. ),
  1190. }
  1191. }
  1192. }