chatCache.ts 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
  1. import { createHash } from 'node:crypto'
  2. import { chmodSync, createReadStream, existsSync, mkdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { DatabaseSync } from 'node:sqlite'
  5. import type { UpstreamResponse } from './upstream.js'
  6. import type { UpstreamServiceSession } from './localAuth.js'
  7. export type ChatCacheMode = 'upstream-only' | 'shadow' | 'prefer-local'
  8. export type ChatCacheState = 'current' | 'stale' | 'syncing' | 'gap'
  9. export type ChatCacheSource = 'local' | 'upstream'
  10. interface StoredSnapshot {
  11. response: UpstreamResponse
  12. state: ChatCacheState
  13. }
  14. interface MessagePage {
  15. response: UpstreamResponse
  16. state: ChatCacheState
  17. }
  18. function object(value: unknown): Record<string, unknown> | undefined {
  19. return value && typeof value === 'object' && !Array.isArray(value)
  20. ? value as Record<string, unknown> : undefined
  21. }
  22. function parseResponse(response: UpstreamResponse): Record<string, unknown> | undefined {
  23. if (response.status < 200 || response.status >= 300) return undefined
  24. try { return object(JSON.parse(response.body.toString('utf8'))) } catch { return undefined }
  25. }
  26. function headerRecord(headers: Headers): string {
  27. return JSON.stringify(Object.fromEntries([...headers.entries()].filter(([name]) => name.toLowerCase() !== 'set-cookie')))
  28. }
  29. function responseFromRow(row: Record<string, unknown>): UpstreamResponse {
  30. return {
  31. status: Number(row.status),
  32. headers: new Headers(JSON.parse(String(row.headers)) as Record<string, string>),
  33. body: Buffer.from(String(row.body), 'base64'),
  34. }
  35. }
  36. function profileOf(value: Record<string, unknown>, fallback = ''): string {
  37. return String(value.profile ?? value.profile_name ?? fallback).trim()
  38. }
  39. function sessionIDOf(value: Record<string, unknown>): string {
  40. return String(value.id ?? value.session_id ?? value.sessionId ?? '').trim()
  41. }
  42. function sessionSource(value: Record<string, unknown>): string {
  43. return String(value.source ?? '').trim()
  44. }
  45. function sessionTitle(value: Record<string, unknown>): string {
  46. return String(value.title ?? '').trim()
  47. }
  48. // Hermes uses Unix seconds; local route registration uses milliseconds. Keep
  49. // the public summary in seconds so Web and iOS compare the same activity time.
  50. function timestampSeconds(value: unknown): number | undefined {
  51. if (typeof value !== 'number' && typeof value !== 'string') return undefined
  52. const numeric = Number(value)
  53. const seconds = Number.isFinite(numeric)
  54. ? numeric > 10_000_000_000 ? numeric / 1_000 : numeric
  55. : typeof value === 'string' ? Date.parse(value) / 1_000 : NaN
  56. return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined
  57. }
  58. function firstTimestamp(value: Record<string, unknown>, keys: string[]): number | undefined {
  59. for (const key of keys) {
  60. const timestamp = timestampSeconds(value[key])
  61. if (timestamp !== undefined) return timestamp
  62. }
  63. return undefined
  64. }
  65. const START_TIME_KEYS = ['started_at', 'startedAt', 'session_started', 'created_at', 'createdAt']
  66. const ACTIVITY_TIME_KEYS = ['last_active', 'last_active_at', 'last_activity_at', 'lastActive', 'updated_at', 'updatedAt']
  67. function mergeSessionMetadata(previous: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
  68. const summary = { ...previous, ...incoming }
  69. // A messages response can carry title/model metadata, but its envelope is
  70. // not a replacement for the session detail.
  71. delete summary.messages
  72. delete summary.pagination
  73. const started = firstTimestamp(incoming, START_TIME_KEYS) ?? firstTimestamp(previous, START_TIME_KEYS)
  74. const activity = firstTimestamp(incoming, ACTIVITY_TIME_KEYS) ?? firstTimestamp(previous, ACTIVITY_TIME_KEYS)
  75. if (started !== undefined) summary.started_at = started
  76. if (activity !== undefined) summary.last_active = activity
  77. return summary
  78. }
  79. function messageTimeBounds(messages: Record<string, unknown>[]): { first?: number; last?: number } {
  80. let first: number | undefined
  81. let last: number | undefined
  82. for (const message of messages) {
  83. const time = firstTimestamp(message, ['timestamp', 'created_at', 'createdAt'])
  84. if (time === undefined) continue
  85. first = Math.min(first ?? time, time)
  86. last = Math.max(last ?? time, time)
  87. }
  88. return { first, last }
  89. }
  90. function isPlaceholderTitle(value: string): boolean {
  91. return new Set([
  92. '', '新对话', '新会话', '未命名对话', '未命名会话', 'new conversation', 'new session', 'untitled',
  93. ]).has(value.trim().toLowerCase())
  94. }
  95. function messageID(value: Record<string, unknown>, fallback: string): string {
  96. const id = String(value.id ?? value.row_id ?? value.message_id ?? value.messageId ?? '').trim()
  97. return id || createHash('sha256').update(JSON.stringify(value) + fallback).digest('hex')
  98. }
  99. function bool(value: unknown): boolean {
  100. return value === true || value === 1 || value === '1' || value === 'true'
  101. }
  102. const PATH_PATTERN = /(?:file:\/\/)?(\/(?:Users|private|var|tmp)\/[^\s<>'"`)\]}]+\.[A-Za-z0-9]{1,12})/g
  103. function referencedPaths(value: unknown, output = new Set<string>()): Set<string> {
  104. if (typeof value === 'string') {
  105. for (const match of value.matchAll(PATH_PATTERN)) output.add(match[1]!)
  106. } else if (Array.isArray(value)) {
  107. for (const child of value) referencedPaths(child, output)
  108. } else {
  109. const record = object(value)
  110. if (record) for (const child of Object.values(record)) referencedPaths(child, output)
  111. }
  112. return output
  113. }
  114. export class ChatCacheStore {
  115. readonly db: DatabaseSync
  116. readonly assetsRoot: string
  117. constructor(home: string, legacySingleUserID?: string) {
  118. mkdirSync(home, { recursive: true, mode: 0o700 })
  119. const path = join(home, 'chat-cache.sqlite3')
  120. this.db = new DatabaseSync(path)
  121. chmodSync(path, 0o600)
  122. this.assetsRoot = join(home, 'chat-cache-assets')
  123. mkdirSync(this.assetsRoot, { recursive: true, mode: 0o700 })
  124. this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
  125. CREATE TABLE IF NOT EXISTS chat_sessions(
  126. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  127. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL, authoritative_title TEXT,
  128. authoritative_title_at INTEGER NOT NULL DEFAULT 0,
  129. owned_at INTEGER, ownership_evidence TEXT,
  130. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  131. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  132. PRIMARY KEY(owner,profile,session_id));
  133. CREATE TABLE IF NOT EXISTS chat_messages(
  134. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  135. message_id TEXT NOT NULL, position INTEGER NOT NULL, data TEXT NOT NULL,
  136. content_hash TEXT NOT NULL, PRIMARY KEY(owner,profile,session_id,message_id));
  137. CREATE INDEX IF NOT EXISTS chat_messages_position ON chat_messages(owner,profile,session_id,position);
  138. CREATE TABLE IF NOT EXISTS chat_events(
  139. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  140. event_seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL,
  141. received_at INTEGER NOT NULL, PRIMARY KEY(owner,profile,session_id,event_seq,event_type));
  142. CREATE TABLE IF NOT EXISTS chat_attachments(
  143. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  144. source_path TEXT NOT NULL, sha256 TEXT, mime_type TEXT, size INTEGER,
  145. local_path TEXT, state TEXT NOT NULL DEFAULT 'pending', updated_at INTEGER NOT NULL,
  146. PRIMARY KEY(owner,profile,session_id,source_path));
  147. CREATE INDEX IF NOT EXISTS chat_attachments_source ON chat_attachments(owner,source_path);
  148. CREATE TABLE IF NOT EXISTS chat_sync_cursors(
  149. owner TEXT NOT NULL, profile TEXT NOT NULL, generation TEXT,
  150. event_cursor INTEGER NOT NULL DEFAULT 0, sync_state TEXT NOT NULL DEFAULT 'stale',
  151. last_synced_at INTEGER NOT NULL, PRIMARY KEY(owner,profile));
  152. CREATE TABLE IF NOT EXISTS chat_snapshots(
  153. owner TEXT NOT NULL, cache_key TEXT NOT NULL, kind TEXT NOT NULL,
  154. profile TEXT NOT NULL, session_id TEXT, status INTEGER NOT NULL,
  155. headers TEXT NOT NULL, body TEXT NOT NULL, sync_state TEXT NOT NULL,
  156. saved_at INTEGER NOT NULL, PRIMARY KEY(owner,cache_key));
  157. CREATE TABLE IF NOT EXISTS chat_meta(
  158. key TEXT PRIMARY KEY, value TEXT NOT NULL);`)
  159. const sessionColumns = this.db.prepare('PRAGMA table_info(chat_sessions)').all() as Array<{ name: string }>
  160. this.db.exec('BEGIN IMMEDIATE')
  161. try {
  162. if (!sessionColumns.some(column => column.name === 'sync_revision')) {
  163. this.db.exec('ALTER TABLE chat_sessions ADD COLUMN sync_revision INTEGER NOT NULL DEFAULT 0')
  164. }
  165. if (!sessionColumns.some(column => column.name === 'message_total')) {
  166. this.db.exec('ALTER TABLE chat_sessions ADD COLUMN message_total INTEGER')
  167. }
  168. if (!sessionColumns.some(column => column.name === 'metadata_complete')) {
  169. this.db.exec('ALTER TABLE chat_sessions ADD COLUMN metadata_complete INTEGER NOT NULL DEFAULT 0')
  170. this.db.exec("UPDATE chat_sessions SET metadata_complete=1 WHERE json_valid(data) AND (json_type(data,'$.message_count') IS NOT NULL OR json_type(data,'$.model') IS NOT NULL)")
  171. }
  172. if (!sessionColumns.some(column => column.name === 'authoritative_title')) {
  173. this.db.exec('ALTER TABLE chat_sessions ADD COLUMN authoritative_title TEXT')
  174. }
  175. if (!sessionColumns.some(column => column.name === 'authoritative_title_at')) {
  176. this.db.exec('ALTER TABLE chat_sessions ADD COLUMN authoritative_title_at INTEGER NOT NULL DEFAULT 0')
  177. }
  178. if (!sessionColumns.some(column => column.name === 'owned_at')) {
  179. this.db.exec('ALTER TABLE chat_sessions ADD COLUMN owned_at INTEGER')
  180. }
  181. if (!sessionColumns.some(column => column.name === 'ownership_evidence')) {
  182. this.db.exec('ALTER TABLE chat_sessions ADD COLUMN ownership_evidence TEXT')
  183. }
  184. this.db.exec(`CREATE INDEX IF NOT EXISTS chat_sessions_owned
  185. ON chat_sessions(owner,profile,owned_at,last_synced_at)`)
  186. const ownershipMigration = this.db.prepare(
  187. "SELECT value FROM chat_meta WHERE key='ownership-registry-v1'",
  188. ).get() as { value?: string } | undefined
  189. if (!ownershipMigration) {
  190. // A single cached owner is trusted only when the persisted local user
  191. // database independently confirms the same sole identity.
  192. const legacyOwners = this.db.prepare(
  193. 'SELECT DISTINCT owner FROM chat_sessions ORDER BY owner',
  194. ).all() as Array<{ owner: string }>
  195. if (legacyOwners.length === 1 && legacyOwners[0]!.owner === legacySingleUserID) {
  196. this.db.exec(`UPDATE chat_sessions SET
  197. owned_at=COALESCE(owned_at,last_synced_at),
  198. ownership_evidence=COALESCE(ownership_evidence,'legacy-single-user')`)
  199. }
  200. }
  201. // Recover interrupted/new-schema writes only from explicit command proof.
  202. this.db.exec(`UPDATE chat_sessions SET
  203. owned_at=COALESCE(owned_at,(
  204. SELECT MIN(chat_events.received_at) FROM chat_events
  205. WHERE chat_events.owner=chat_sessions.owner
  206. AND chat_events.profile=chat_sessions.profile
  207. AND chat_events.session_id=chat_sessions.session_id
  208. AND chat_events.event_type IN ('command:session.create','command:session.branch')
  209. )),
  210. ownership_evidence=COALESCE(ownership_evidence,'command-migration')
  211. WHERE EXISTS (
  212. SELECT 1 FROM chat_events
  213. WHERE chat_events.owner=chat_sessions.owner
  214. AND chat_events.profile=chat_sessions.profile
  215. AND chat_events.session_id=chat_sessions.session_id
  216. AND chat_events.event_type IN ('command:session.create','command:session.branch')
  217. )`)
  218. const legacyTitles = this.db.prepare(`SELECT owner,profile,session_id,data FROM chat_sessions
  219. WHERE authoritative_title IS NULL`).all() as Array<{
  220. owner: string; profile: string; session_id: string; data: string
  221. }>
  222. const backfillTitle = this.db.prepare(`UPDATE chat_sessions SET authoritative_title=?
  223. WHERE owner=? AND profile=? AND session_id=? AND authoritative_title IS NULL`)
  224. for (const row of legacyTitles) {
  225. let summary: Record<string, unknown> = {}
  226. try { summary = object(JSON.parse(row.data)) ?? {} } catch { continue }
  227. const title = sessionTitle(summary)
  228. if (!isPlaceholderTitle(title)) backfillTitle.run(title, row.owner, row.profile, row.session_id)
  229. }
  230. if (!this.db.prepare("SELECT 1 FROM chat_meta WHERE key='session-activity-v1'").get()) {
  231. const rows = this.db.prepare(`SELECT owner,profile,session_id,source,data,owned_at,message_total
  232. FROM chat_sessions WHERE owned_at IS NOT NULL`).all() as Array<{
  233. owner: string; profile: string; session_id: string; source: string; data: string
  234. owned_at: number; message_total: number | null
  235. }>
  236. const update = this.db.prepare(`UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?`)
  237. for (const row of rows) {
  238. let stored: Record<string, unknown>
  239. try { stored = object(JSON.parse(row.data)) ?? {} } catch { continue }
  240. const summary = mergeSessionMetadata({}, stored)
  241. if (Array.isArray(stored.messages) || !firstTimestamp(summary, START_TIME_KEYS)
  242. || !firstTimestamp(summary, ACTIVITY_TIME_KEYS)) {
  243. const messages = this.db.prepare(`SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=?`)
  244. .all(row.owner, row.profile, row.session_id) as Array<{ data: string }>
  245. const bounds = messageTimeBounds(messages.flatMap(message => {
  246. try { return [object(JSON.parse(message.data)) ?? {}] } catch { return [] }
  247. }))
  248. summary.started_at = firstTimestamp(summary, START_TIME_KEYS) ?? bounds.first ?? timestampSeconds(row.owned_at)
  249. summary.last_active = Math.max(firstTimestamp(summary, ACTIVITY_TIME_KEYS) ?? 0,
  250. bounds.last ?? 0) || summary.started_at
  251. if (row.message_total !== null) summary.message_count = row.message_total
  252. }
  253. summary.id = row.session_id
  254. summary.profile = row.profile
  255. summary.source = sessionSource(summary) || row.source
  256. update.run(JSON.stringify(summary), row.owner, row.profile, row.session_id)
  257. }
  258. this.db.prepare("INSERT INTO chat_meta(key,value) VALUES('session-activity-v1','complete')").run()
  259. }
  260. // List bodies written before ownership became an explicit projection do
  261. // not carry `owned` and may have been filtered by `source`. Rebuild them on
  262. // first use after every restart instead of serving a semantically old page.
  263. this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE kind='list'").run()
  264. this.db.prepare(`INSERT INTO chat_meta(key,value) VALUES('ownership-registry-v1','complete')
  265. ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run()
  266. this.db.exec('COMMIT')
  267. } catch (error) {
  268. this.db.exec('ROLLBACK')
  269. throw error
  270. }
  271. }
  272. close(): void { this.db.close() }
  273. stats(): Record<string, number> {
  274. const count = (table: string) => Number((this.db.prepare(`SELECT COUNT(*) count FROM ${table}`).get() as { count: number }).count)
  275. const bytes = Number((this.db.prepare(`SELECT COALESCE(SUM(size),0) bytes FROM chat_attachments WHERE state='cached'`).get() as { bytes: number }).bytes)
  276. return {
  277. sessions: Number((this.db.prepare(`SELECT COUNT(*) count FROM chat_sessions
  278. WHERE owned_at IS NOT NULL`).get() as { count: number }).count),
  279. messages: count('chat_messages'),
  280. events: count('chat_events'),
  281. attachments: count('chat_attachments'),
  282. attachmentBytes: bytes,
  283. }
  284. }
  285. snapshot(owner: string, key: string): StoredSnapshot | undefined {
  286. const row = this.db.prepare('SELECT status,headers,body,sync_state FROM chat_snapshots WHERE owner=? AND cache_key=?')
  287. .get(owner, key) as Record<string, unknown> | undefined
  288. return row ? { response: responseFromRow(row), state: String(row.sync_state) as ChatCacheState } : undefined
  289. }
  290. localList(owner: string, profile: string, page: { offset?: number; limit?: number; archived?: string }): UpstreamResponse {
  291. return this.mergeOwnedSessionsIntoList(owner, profile, {
  292. status: 200, headers: new Headers({ 'content-type': 'application/json' }),
  293. body: Buffer.from('{"sessions":[]}'),
  294. }, page)
  295. }
  296. needsListMetadata(owner: string, profile: string): boolean {
  297. return Boolean(this.db.prepare(`SELECT 1 FROM chat_sessions WHERE owner=? AND owned_at IS NOT NULL
  298. AND (?='' OR profile=?) AND metadata_complete=0 LIMIT 1`).get(owner, profile, profile))
  299. }
  300. localDetail(owner: string, profile: string, sessionID: string): StoredSnapshot | undefined {
  301. const row = this.db.prepare(`SELECT data,sync_state FROM chat_sessions
  302. WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
  303. .get(owner, profile, sessionID) as { data: string; sync_state: ChatCacheState } | undefined
  304. if (!row) return undefined
  305. return { response: { status: 200, headers: new Headers({ 'content-type': 'application/json' }),
  306. body: Buffer.from(row.data) }, state: row.sync_state }
  307. }
  308. revision(owner: string, profile: string, sessionID: string): number {
  309. return Number((this.db.prepare('SELECT sync_revision FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
  310. .get(owner, profile, sessionID) as { sync_revision?: number } | undefined)?.sync_revision ?? 0)
  311. }
  312. hasMessage(owner: string, profile: string, sessionID: string, message: Record<string, unknown>, position: number): boolean {
  313. const id = messageID(message, String(position))
  314. const row = this.db.prepare('SELECT position FROM chat_messages WHERE owner=? AND profile=? AND session_id=? AND message_id=?')
  315. .get(owner, profile, sessionID, id) as { position: number } | undefined
  316. if (row && row.position !== position) throw new Error('Stored history changed; explicit refresh is required')
  317. return Boolean(row)
  318. }
  319. putSnapshot(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
  320. response: UpstreamResponse, ownedList = false, requestStartedAt = Date.now(),
  321. metadataResponse: UpstreamResponse = response): boolean {
  322. const payload = parseResponse(response)
  323. if (!payload) return false
  324. if (kind === 'list' && !ownedList) return false
  325. const isOwnedChat = ownedList || this.payloadBelongsToOwnedChat(owner, profile, sessionID, payload)
  326. if (!isOwnedChat) return false
  327. const now = Date.now()
  328. this.db.exec('SAVEPOINT chat_snapshot')
  329. try {
  330. // Hydrate all registered sessions from the unsliced upstream list, while
  331. // persisting only the owner-filtered page as the public response snapshot.
  332. this.ingestPayload(owner, profile, sessionID, parseResponse(metadataResponse) ?? payload, now, kind, requestStartedAt)
  333. this.db.prepare(`INSERT INTO chat_snapshots(owner,cache_key,kind,profile,session_id,status,headers,body,sync_state,saved_at)
  334. VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,cache_key) DO UPDATE SET status=excluded.status,headers=excluded.headers,
  335. body=excluded.body,sync_state='current',saved_at=excluded.saved_at`)
  336. .run(owner, key, kind, profile, sessionID ?? null, response.status, headerRecord(response.headers),
  337. response.body.toString('base64'), 'current', now)
  338. this.db.exec('RELEASE chat_snapshot')
  339. return true
  340. } catch (error) {
  341. this.db.exec('ROLLBACK TO chat_snapshot; RELEASE chat_snapshot')
  342. throw error
  343. }
  344. }
  345. applySync(owner: string, profile: string, sessionID: string, revision: number,
  346. pages: Array<{ key: string; kind: string; response: UpstreamResponse; startedAt: number }>, force: boolean): boolean {
  347. if (this.revision(owner, profile, sessionID) !== revision) return false
  348. this.db.exec('SAVEPOINT history_sync')
  349. try {
  350. if (force) this.db.prepare('DELETE FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').run(owner, profile, sessionID)
  351. this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND profile=? AND session_id=? AND kind='messages'").run(owner, profile, sessionID)
  352. for (const page of pages) this.putSnapshot(owner, page.key, page.kind, profile, sessionID, page.response, false, page.startedAt)
  353. this.db.exec('RELEASE history_sync')
  354. return true
  355. } catch (error) {
  356. this.db.exec('ROLLBACK TO history_sync; RELEASE history_sync')
  357. throw error
  358. }
  359. }
  360. messagePage(owner: string, profile: string, sessionID: string, offset: number, limit: number): MessagePage | undefined {
  361. const session = this.db.prepare(`SELECT data,sync_state,complete,message_total FROM chat_sessions
  362. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
  363. if (!session) return undefined
  364. const totalRow = this.db.prepare('SELECT COUNT(*) count, MAX(position)+1 extent FROM chat_messages WHERE owner=? AND profile=? AND session_id=?')
  365. .get(owner, profile, sessionID) as { count: number; extent: number | null }
  366. if (session.message_total == null && !bool(session.complete)) return undefined
  367. const total = Number(session.message_total ?? totalRow.extent ?? 0)
  368. const end = Math.max(0, total - offset)
  369. const start = Math.max(0, end - limit)
  370. const rows = this.db.prepare(`SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=?
  371. AND position>=? AND position<? ORDER BY position`).all(owner, profile, sessionID, start, end) as Array<{ data: string }>
  372. const messages = rows.map(row => JSON.parse(row.data))
  373. if (messages.length !== end - start) return undefined // Fetch only an actually missing page.
  374. const body = Buffer.from(JSON.stringify({
  375. session_id: sessionID,
  376. session: JSON.parse(String(session.data)),
  377. messages,
  378. pagination: {
  379. total,
  380. returned: messages.length,
  381. offset,
  382. limit,
  383. has_more: start > 0,
  384. },
  385. }))
  386. return {
  387. response: { status: 200, headers: new Headers({ 'content-type': 'application/json; charset=utf-8' }), body },
  388. state: String(session.sync_state) as ChatCacheState,
  389. }
  390. }
  391. recordEvent(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): boolean {
  392. const rawSeq = Number(frame.seq ?? frame.sequence ?? 0)
  393. const type = String(frame.type ?? 'unknown')
  394. const payload = JSON.stringify(frame.payload ?? {})
  395. const seq = Number.isSafeInteger(rawSeq) && rawSeq > 0
  396. ? rawSeq
  397. : Number.parseInt(createHash('sha256').update(`${type}:${payload}`).digest('hex').slice(0, 12), 16)
  398. const now = Date.now()
  399. const previous = this.db.prepare(`SELECT last_event_seq FROM chat_sessions
  400. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { last_event_seq?: number } | undefined
  401. const lastSeen = Number(previous?.last_event_seq ?? 0)
  402. const inserted = this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
  403. VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, Number.isSafeInteger(seq) ? seq : 0, type, payload, now)
  404. if (!inserted.changes) return false
  405. const state: ChatCacheState = type === 'gateway.reset' || type.includes('reset')
  406. || (rawSeq > 0 && lastSeen > 0 && rawSeq > lastSeen + 1) ? 'gap' : 'stale'
  407. const metadataOnly = ['session.info', 'session.title', 'session.title.updated', 'session.usage', 'gateway.ready'].includes(type) && state !== 'gap'
  408. this.db.prepare(`UPDATE chat_sessions SET sync_state=CASE WHEN ? THEN sync_state ELSE ? END,
  409. sync_revision=sync_revision+?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
  410. WHERE owner=? AND profile=? AND session_id=?`).run(metadataOnly ? 1 : 0, state, metadataOnly ? 0 : 1, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
  411. if (type === 'session.info') {
  412. const payload = object(frame.payload) ?? {}
  413. const info = object(payload.info) ?? payload
  414. const row = this.db.prepare('SELECT data FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
  415. .get(owner, profile, sessionID) as { data: string } | undefined
  416. if (row) {
  417. const summary = object(JSON.parse(row.data)) ?? {}
  418. for (const field of ['model', 'provider', 'fast']) if (info[field] !== undefined) summary[field] = info[field]
  419. this.db.prepare('UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?')
  420. .run(JSON.stringify(summary), owner, profile, sessionID)
  421. }
  422. }
  423. if (type === 'session.title' || type === 'session.title.updated') {
  424. const title = String(object(frame.payload)?.title ?? frame.title ?? '').trim()
  425. if (title) this.updateOwnedSessionTitle(owner, profile, sessionID, title, now)
  426. }
  427. this.db.prepare(`UPDATE chat_snapshots SET sync_state=? WHERE owner=? AND (session_id=? OR kind='list')
  428. AND (?=0 OR kind<>'messages')`).run(state, owner, sessionID, metadataOnly ? 1 : 0)
  429. this.db.prepare(`INSERT INTO chat_sync_cursors(owner,profile,event_cursor,sync_state,last_synced_at)
  430. VALUES(?,?,?,?,?) ON CONFLICT(owner,profile) DO UPDATE SET event_cursor=MAX(event_cursor,excluded.event_cursor),
  431. sync_state=excluded.sync_state,last_synced_at=excluded.last_synced_at`)
  432. .run(owner, profile, rawSeq > 0 ? rawSeq : lastSeen, state, now)
  433. return true
  434. }
  435. recordRoute(owner: string, profile: string, sessionID: string, runtimeID: string): void {
  436. const now = Date.now()
  437. const summary = { id: sessionID, profile, source: 'unknown', title: '新对话', started_at: now, last_active: now }
  438. this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
  439. VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET runtime_id=excluded.runtime_id,
  440. last_synced_at=excluded.last_synced_at,owned_at=COALESCE(chat_sessions.owned_at,excluded.owned_at),
  441. ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
  442. .run(owner, profile, sessionID, runtimeID, 'unknown', JSON.stringify(summary), 'stale', 0, now, now, 'route-confirmed')
  443. this.markListsStale(owner)
  444. }
  445. recordCommand(owner: string, profile: string, sessionID: string, method: string,
  446. params: Record<string, unknown>): void {
  447. const now = Date.now()
  448. const source = sessionSource(params) || 'unknown'
  449. const establishesOwnership = method === 'session.create' || method === 'session.branch'
  450. const summary = { id: sessionID, profile, source, title: '新对话', started_at: now, last_active: now }
  451. this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at,owned_at,ownership_evidence)
  452. VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET
  453. source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
  454. sync_state='stale',sync_revision=chat_sessions.sync_revision+1,last_synced_at=excluded.last_synced_at,
  455. owned_at=COALESCE(chat_sessions.owned_at,excluded.owned_at),
  456. ownership_evidence=COALESCE(chat_sessions.ownership_evidence,excluded.ownership_evidence)`)
  457. .run(owner, profile, sessionID, source, JSON.stringify(summary), 'stale', 0, now,
  458. establishesOwnership ? now : null, establishesOwnership ? `command:${method}` : null)
  459. const payload = JSON.stringify(params)
  460. const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
  461. this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
  462. VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, seq, `command:${method}`, payload, now)
  463. this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND (session_id=? OR kind='list')")
  464. .run(owner, sessionID)
  465. }
  466. markListsStale(owner: string): void {
  467. this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND kind='list'").run(owner)
  468. }
  469. recordAuthoritativeTitle(owner: string, profile: string, sessionID: string, title: string): void {
  470. const normalized = title.trim()
  471. if (!normalized) return
  472. this.updateOwnedSessionTitle(owner, profile, sessionID, normalized, Date.now())
  473. this.db.prepare(`UPDATE chat_snapshots SET sync_state='stale'
  474. WHERE owner=? AND (session_id=? OR kind='list')`).run(owner, sessionID)
  475. }
  476. deleteSession(owner: string, profile: string, sessionID: string): void {
  477. const assets = this.db.prepare(`SELECT DISTINCT local_path FROM chat_attachments
  478. WHERE owner=? AND profile=? AND session_id=? AND local_path IS NOT NULL`)
  479. .all(owner, profile, sessionID) as Array<{ local_path: string }>
  480. this.db.exec('BEGIN IMMEDIATE')
  481. try {
  482. for (const table of ['chat_messages', 'chat_events', 'chat_attachments', 'chat_sessions']) {
  483. this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
  484. }
  485. this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND ((profile=? AND session_id=?) OR kind='list')")
  486. .run(owner, profile, sessionID)
  487. this.db.exec('COMMIT')
  488. for (const asset of assets) {
  489. const referenced = this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE local_path=? LIMIT 1').get(asset.local_path)
  490. if (!referenced) try { unlinkSync(asset.local_path) } catch { /* Already absent. */ }
  491. }
  492. } catch (error) { this.db.exec('ROLLBACK'); throw error }
  493. }
  494. attachment(owner: string, sourcePath: string): { localPath: string; mimeType?: string } | undefined {
  495. const row = this.db.prepare(`SELECT local_path,mime_type FROM chat_attachments
  496. WHERE owner=? AND source_path=? AND state='cached' ORDER BY updated_at DESC LIMIT 1`).get(owner, sourcePath) as Record<string, unknown> | undefined
  497. const localPath = row?.local_path ? String(row.local_path) : ''
  498. return localPath && existsSync(localPath) ? { localPath, ...(row?.mime_type ? { mimeType: String(row.mime_type) } : {}) } : undefined
  499. }
  500. knowsAttachment(owner: string, sourcePath: string): boolean {
  501. return Boolean(this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE owner=? AND source_path=? LIMIT 1').get(owner, sourcePath))
  502. }
  503. ownsSession(owner: string, profile: string, sessionID: string): boolean {
  504. return Boolean(this.db.prepare(
  505. 'SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL',
  506. ).get(owner, profile, sessionID))
  507. }
  508. ownedSessionMetadata(owner: string, profile: string, sessionID: string): {
  509. owned: boolean
  510. authoritativeTitle?: string
  511. } {
  512. const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
  513. WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`)
  514. .get(owner, profile, sessionID) as { authoritative_title?: string | null } | undefined
  515. const authoritativeTitle = String(row?.authoritative_title ?? '').trim()
  516. return {
  517. owned: Boolean(row),
  518. ...(authoritativeTitle ? { authoritativeTitle } : {}),
  519. }
  520. }
  521. mergeOwnedSessionsIntoList(
  522. owner: string,
  523. fallbackProfile: string,
  524. response: UpstreamResponse,
  525. page: { offset?: number; limit?: number; archived?: string } = {},
  526. ): UpstreamResponse {
  527. const payload = parseResponse(response)
  528. if (!payload) return response
  529. const collectionKey = Array.isArray(payload.items)
  530. ? 'items'
  531. : Array.isArray(payload.sessions) ? 'sessions' : undefined
  532. if (!collectionKey) return response
  533. const rows = (fallbackProfile
  534. ? this.db.prepare(`SELECT profile,session_id,source,data,authoritative_title,last_synced_at FROM chat_sessions
  535. WHERE owner=? AND profile=? AND owned_at IS NOT NULL`)
  536. .all(owner, fallbackProfile)
  537. : this.db.prepare(`SELECT profile,session_id,source,data,authoritative_title,last_synced_at FROM chat_sessions
  538. WHERE owner=? AND owned_at IS NOT NULL`).all(owner)) as Array<{
  539. profile: string
  540. session_id: string
  541. source: string
  542. data: string
  543. authoritative_title: string | null
  544. last_synced_at: number
  545. }>
  546. const owned = new Map(rows.map(row => [
  547. `${row.profile}\u0000${row.session_id}`,
  548. row,
  549. ]))
  550. const merged = new Map<string, Record<string, unknown>>()
  551. for (const [key, row] of owned) {
  552. let stored: Record<string, unknown> = {}
  553. try { stored = object(JSON.parse(row.data)) ?? {} } catch { /* Ignore damaged display metadata. */ }
  554. const storedSource = sessionSource(stored)
  555. merged.set(key, {
  556. ...stored,
  557. id: row.session_id,
  558. profile: row.profile,
  559. source: storedSource && storedSource !== 'unknown' ? storedSource : row.source,
  560. ...(row.authoritative_title?.trim() ? { title: row.authoritative_title.trim() } : {}),
  561. _yaoyao_registry_activity: row.last_synced_at,
  562. owned: true,
  563. })
  564. }
  565. for (const value of payload[collectionKey] as unknown[]) {
  566. const session = object(value)
  567. if (!session) continue
  568. const id = sessionIDOf(session)
  569. const profile = profileOf(session, fallbackProfile) || 'default'
  570. if (!id) continue
  571. const key = `${profile}\u0000${id}`
  572. const row = owned.get(key)
  573. if (row) {
  574. const title = row.authoritative_title?.trim()
  575. merged.set(key, {
  576. ...mergeSessionMetadata(merged.get(key) ?? {}, session),
  577. ...(title ? { title } : {}),
  578. _yaoyao_registry_activity: row.last_synced_at,
  579. owned: true,
  580. })
  581. }
  582. }
  583. const activity = (session: Record<string, unknown>): number =>
  584. firstTimestamp(session, ACTIVITY_TIME_KEYS) ?? firstTimestamp(session, START_TIME_KEYS) ?? 0
  585. const archived = (session: Record<string, unknown>): boolean => bool(
  586. session.is_archived ?? session.isArchived ?? session.archived,
  587. )
  588. const pinned = (session: Record<string, unknown>): boolean => bool(
  589. session.is_pinned ?? session.isPinned ?? session.pinned,
  590. )
  591. const archivedMode = page.archived ?? 'exclude'
  592. const allSessions = [...merged.values()]
  593. .filter(session => archivedMode === 'include'
  594. || (archivedMode === 'only' ? archived(session) : !archived(session)))
  595. .sort((left, right) => Number(pinned(right)) - Number(pinned(left))
  596. || activity(right) - activity(left))
  597. const offset = Math.max(0, Math.trunc(page.offset ?? 0))
  598. const limit = Math.max(1, Math.min(500, Math.trunc(page.limit ?? 100)))
  599. const sessions = allSessions.slice(offset, offset + limit).map((session) => {
  600. const publicSession = mergeSessionMetadata({}, session)
  601. delete publicSession._yaoyao_registry_activity
  602. return publicSession
  603. })
  604. const body = Buffer.from(JSON.stringify({
  605. ...payload,
  606. [collectionKey]: sessions,
  607. total: allSessions.length,
  608. offset,
  609. limit,
  610. }))
  611. const headers = new Headers(response.headers)
  612. headers.delete('content-length')
  613. return { ...response, headers, body }
  614. }
  615. excludeOwnedSessionsFromList(
  616. owner: string,
  617. fallbackProfile: string,
  618. response: UpstreamResponse,
  619. page: { offset?: number; limit?: number } = {},
  620. ): UpstreamResponse {
  621. const payload = parseResponse(response)
  622. if (!payload) return response
  623. const collectionKey = Array.isArray(payload.items)
  624. ? 'items'
  625. : Array.isArray(payload.sessions) ? 'sessions' : undefined
  626. if (!collectionKey) return response
  627. const allSessions = (payload[collectionKey] as unknown[]).filter((value) => {
  628. const session = object(value)
  629. if (!session) return true
  630. const id = sessionIDOf(session)
  631. const profile = profileOf(session, fallbackProfile) || 'default'
  632. return !id || !this.ownsSession(owner, profile, id)
  633. })
  634. const offset = Math.max(0, Math.trunc(page.offset ?? 0))
  635. const limit = Math.max(1, Math.min(500, Math.trunc(page.limit ?? 100)))
  636. const sessions = allSessions.slice(offset, offset + limit)
  637. const headers = new Headers(response.headers)
  638. headers.delete('content-length')
  639. return {
  640. ...response,
  641. headers,
  642. body: Buffer.from(JSON.stringify({
  643. ...payload,
  644. [collectionKey]: sessions,
  645. total: allSessions.length,
  646. offset,
  647. limit,
  648. })),
  649. }
  650. }
  651. markSessionOwnership(
  652. owner: string,
  653. fallbackProfile: string,
  654. sessionID: string,
  655. response: UpstreamResponse,
  656. ): UpstreamResponse {
  657. const payload = parseResponse(response)
  658. if (!payload) return response
  659. const session = object(payload.session)
  660. const candidate = session ?? payload
  661. const id = sessionIDOf(candidate) || sessionID
  662. const profile = profileOf(candidate, fallbackProfile) || fallbackProfile || 'default'
  663. const row = this.db.prepare(`SELECT authoritative_title FROM chat_sessions
  664. WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`).get(owner, profile, id) as { authoritative_title?: string | null } | undefined
  665. const owned = Boolean(row)
  666. const title = String(row?.authoritative_title ?? '').trim()
  667. const body = session
  668. ? Buffer.from(JSON.stringify({
  669. ...payload,
  670. session: { ...session, ...(title ? { title } : {}), owned },
  671. owned,
  672. }))
  673. : Buffer.from(JSON.stringify({ ...payload, ...(title ? { title } : {}), owned }))
  674. const headers = new Headers(response.headers)
  675. headers.delete('content-length')
  676. return { ...response, headers, body }
  677. }
  678. markOwnedSearchResults(
  679. owner: string,
  680. fallbackProfile: string,
  681. response: UpstreamResponse,
  682. view?: 'chat' | 'history',
  683. limit = 100,
  684. ): UpstreamResponse {
  685. const payload = parseResponse(response)
  686. if (!payload || !Array.isArray(payload.results)) return response
  687. const marked = payload.results.map((value) => {
  688. const session = object(value)
  689. if (!session) return value
  690. const id = sessionIDOf(session)
  691. const profile = profileOf(session, fallbackProfile) || fallbackProfile || 'default'
  692. const metadata = id
  693. ? this.ownedSessionMetadata(owner, profile, id)
  694. : { owned: false }
  695. return {
  696. ...session,
  697. ...(metadata.authoritativeTitle ? { title: metadata.authoritativeTitle } : {}),
  698. owned: metadata.owned,
  699. }
  700. })
  701. const projected = marked.filter((value) => {
  702. if (!view) return true
  703. const session = object(value)
  704. if (!session) return false
  705. return view === 'chat' ? session.owned === true : session.owned !== true
  706. })
  707. const results = projected.slice(0, Math.max(1, Math.min(100, Math.trunc(limit))))
  708. const headers = new Headers(response.headers)
  709. headers.delete('content-length')
  710. return {
  711. ...response,
  712. headers,
  713. body: Buffer.from(JSON.stringify({ ...payload, results, total: projected.length })),
  714. }
  715. }
  716. storeAttachment(owner: string, sourcePath: string, response: UpstreamResponse): string | undefined {
  717. if (response.status !== 200 || !response.body.length) return undefined
  718. const sha256 = createHash('sha256').update(response.body).digest('hex')
  719. const directory = join(this.assetsRoot, sha256.slice(0, 2))
  720. mkdirSync(directory, { recursive: true, mode: 0o700 })
  721. const target = join(directory, sha256)
  722. if (!existsSync(target)) {
  723. const temporary = `${target}.${process.pid}.tmp`
  724. writeFileSync(temporary, response.body, { mode: 0o600 })
  725. renameSync(temporary, target)
  726. }
  727. const mimeType = response.headers.get('content-type')?.split(';', 1)[0]
  728. this.db.prepare(`UPDATE chat_attachments SET sha256=?,mime_type=?,size=?,local_path=?,state='cached',updated_at=?
  729. WHERE owner=? AND source_path=?`).run(sha256, mimeType ?? null, response.body.length, target, Date.now(), owner, sourcePath)
  730. return target
  731. }
  732. assetStream(path: string): { stream: ReturnType<typeof createReadStream>; size: number } {
  733. return { stream: createReadStream(path), size: statSync(path).size }
  734. }
  735. private payloadBelongsToOwnedChat(owner: string, profile: string, sessionID: string | undefined,
  736. payload: Record<string, unknown>): boolean {
  737. const session = object(payload.session) ?? (sessionID ? payload : undefined)
  738. if (session) {
  739. const id = sessionIDOf(session) || sessionID || ''
  740. const sessionProfile = profileOf(session, profile) || profile || 'default'
  741. if (id && this.ownsSession(owner, sessionProfile, id)) return true
  742. }
  743. const sessions = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
  744. if (sessions.some(value => {
  745. const candidate = object(value)
  746. if (!candidate) return false
  747. const id = sessionIDOf(candidate)
  748. const sessionProfile = profileOf(candidate, profile) || profile || 'default'
  749. return Boolean(id && this.ownsSession(owner, sessionProfile, id))
  750. })) return true
  751. return Boolean(sessionID && this.ownsSession(owner, profile || 'default', sessionID))
  752. }
  753. private ingestPayload(owner: string, fallbackProfile: string, sessionID: string | undefined,
  754. payload: Record<string, unknown>, now: number, kind: string, requestStartedAt: number): void {
  755. const candidates: Record<string, unknown>[] = []
  756. const direct = object(payload.session)
  757. if (direct) candidates.push(direct)
  758. const listed = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
  759. for (const value of listed) if (object(value)) candidates.push(object(value)!)
  760. if (sessionID && !direct && (sessionIDOf(payload) || sessionSource(payload) || typeof payload.title === 'string')) {
  761. candidates.push(payload)
  762. }
  763. for (const session of candidates) {
  764. const id = sessionIDOf(session) || sessionID || ''
  765. if (!id) continue
  766. const profile = profileOf(session, fallbackProfile) || fallbackProfile || 'default'
  767. const registered = this.ownsSession(owner, profile, id)
  768. const source = sessionSource(session)
  769. if (!registered) continue
  770. const titleRow = this.db.prepare(`SELECT data,authoritative_title,authoritative_title_at FROM chat_sessions
  771. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, id) as {
  772. data: string; authoritative_title?: string | null; authoritative_title_at?: number
  773. } | undefined
  774. const authoritativeTitle = String(titleRow?.authoritative_title ?? '').trim()
  775. const authoritativeTitleAt = Number(titleRow?.authoritative_title_at ?? 0)
  776. const incomingTitle = sessionTitle(session)
  777. const preservesAuthoritativeTitle = Boolean(authoritativeTitle)
  778. && (kind !== 'detail' || isPlaceholderTitle(incomingTitle)
  779. || requestStartedAt <= authoritativeTitleAt)
  780. let previous: Record<string, unknown> = {}
  781. try { previous = object(JSON.parse(titleRow?.data ?? '{}')) ?? {} } catch { /* Recover from incoming metadata. */ }
  782. const storedSession = mergeSessionMetadata(previous, session)
  783. if (preservesAuthoritativeTitle) storedSession.title = authoritativeTitle
  784. const metadataComplete = kind !== 'messages' || direct ? 1 : 0
  785. this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at,metadata_complete)
  786. VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,
  787. metadata_complete=MAX(chat_sessions.metadata_complete,excluded.metadata_complete),
  788. source=CASE WHEN excluded.source='unknown' THEN chat_sessions.source ELSE excluded.source END,
  789. sync_state='current',last_synced_at=excluded.last_synced_at`)
  790. .run(owner, profile, id, null, source || 'unknown', JSON.stringify(storedSession), 'current', 0, now, metadataComplete)
  791. const acceptedTitle = sessionTitle(storedSession)
  792. if (acceptedTitle && !isPlaceholderTitle(acceptedTitle)
  793. && (!authoritativeTitle || kind === 'detail')) {
  794. this.db.prepare(`UPDATE chat_sessions SET authoritative_title=?,authoritative_title_at=?
  795. WHERE owner=? AND profile=? AND session_id=?`).run(
  796. acceptedTitle,
  797. Math.max(requestStartedAt, authoritativeTitleAt),
  798. owner,
  799. profile,
  800. id,
  801. )
  802. }
  803. }
  804. const messages = Array.isArray(payload.messages) ? payload.messages.flatMap(value => object(value) ? [object(value)!] : []) : []
  805. if (!Array.isArray(payload.messages) || !sessionID) return
  806. const profile = fallbackProfile || profileOf(direct ?? {}, 'default') || 'default'
  807. const pagination = object(payload.pagination) ?? {}
  808. const offset = Math.max(0, Number(pagination.offset ?? payload.offset ?? 0) || 0)
  809. const storedDetail = this.localDetail(owner, profile, sessionID)
  810. const storedSummary = storedDetail ? parseResponse(storedDetail.response) ?? {} : {}
  811. const total = Math.max(offset + messages.length,
  812. Number(pagination.total ?? payload.total ?? storedSummary.message_count ?? storedSummary.messageCount ?? 0) || 0)
  813. const start = Math.max(0, total - offset - messages.length)
  814. messages.forEach((message, index) => {
  815. const data = JSON.stringify(message)
  816. this.db.prepare(`INSERT INTO chat_messages(owner,profile,session_id,message_id,position,data,content_hash)
  817. VALUES(?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,message_id) DO UPDATE SET position=excluded.position,
  818. data=excluded.data,content_hash=excluded.content_hash
  819. WHERE chat_messages.content_hash<>excluded.content_hash OR chat_messages.position<>excluded.position`)
  820. .run(owner, profile, sessionID, messageID(message, String(start + index)), start + index, data,
  821. createHash('sha256').update(data).digest('hex'))
  822. for (const sourcePath of referencedPaths(message)) {
  823. this.db.prepare(`INSERT INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at)
  824. VALUES(?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source_path) DO UPDATE SET updated_at=excluded.updated_at`)
  825. .run(owner, profile, sessionID, sourcePath, 'pending', now)
  826. }
  827. })
  828. const coverage = this.db.prepare(`SELECT COUNT(*) count, COUNT(DISTINCT position) positions, MIN(position) first, MAX(position) last
  829. FROM chat_messages WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as Record<string, number>
  830. const complete = total === 0 ? coverage.count === 0
  831. : coverage.count === total && coverage.positions === total && coverage.first === 0 && coverage.last === total - 1
  832. const row = this.db.prepare('SELECT data FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
  833. .get(owner, profile, sessionID) as { data: string }
  834. const summary = mergeSessionMetadata({}, object(JSON.parse(row.data)) ?? {})
  835. const bounds = messageTimeBounds(messages)
  836. if (!firstTimestamp(summary, START_TIME_KEYS) && bounds.first !== undefined) summary.started_at = bounds.first
  837. if (bounds.last !== undefined) {
  838. summary.last_active = Math.max(firstTimestamp(summary, ACTIVITY_TIME_KEYS) ?? 0, bounds.last)
  839. }
  840. summary.message_count = total
  841. this.db.prepare('UPDATE chat_sessions SET data=? WHERE owner=? AND profile=? AND session_id=?')
  842. .run(JSON.stringify(summary), owner, profile, sessionID)
  843. this.db.prepare(`UPDATE chat_sessions SET complete=?,message_total=?,sync_state='current',last_synced_at=?
  844. WHERE owner=? AND profile=? AND session_id=?`).run(complete ? 1 : 0, total, now, owner, profile, sessionID)
  845. }
  846. private updateOwnedSessionTitle(owner: string, profile: string, sessionID: string, title: string, now: number): void {
  847. const row = this.db.prepare(`SELECT source,data FROM chat_sessions
  848. WHERE owner=? AND profile=? AND session_id=? AND owned_at IS NOT NULL`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
  849. if (!row) return
  850. let summary: Record<string, unknown> = {}
  851. try { summary = object(JSON.parse(String(row.data))) ?? {} } catch { /* Rebuild damaged display metadata below. */ }
  852. const storedSource = sessionSource(summary)
  853. const source = storedSource && storedSource !== 'unknown'
  854. ? storedSource
  855. : String(row.source || 'unknown')
  856. this.db.prepare(`UPDATE chat_sessions SET data=?,authoritative_title=?,authoritative_title_at=?,sync_state='stale',last_synced_at=?
  857. WHERE owner=? AND profile=? AND session_id=?`).run(JSON.stringify({
  858. ...summary,
  859. id: sessionID,
  860. profile,
  861. source,
  862. title,
  863. }), title, now, now, owner, profile, sessionID)
  864. }
  865. }
  866. export class ChatCacheCoordinator {
  867. private reconciling = new Map<string, Promise<void>>()
  868. onSynchronized: (owner: string, profile: string, sessionID: string) => void = () => {}
  869. constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
  870. readonly mode: ChatCacheMode = 'prefer-local') {}
  871. async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
  872. load: () => Promise<UpstreamResponse>, ownedList = false,
  873. listPage: { offset?: number; limit?: number; archived?: string } = {}): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
  874. if (this.mode === 'upstream-only') {
  875. const upstreamResponse = await load()
  876. return {
  877. response: kind === 'list' && ownedList
  878. ? this.store.mergeOwnedSessionsIntoList(owner, profile, upstreamResponse, listPage)
  879. : upstreamResponse,
  880. source: 'upstream',
  881. state: 'current',
  882. }
  883. }
  884. if (this.mode === 'prefer-local' && kind === 'list' && ownedList && !this.store.needsListMetadata(owner, profile)) {
  885. return { response: this.store.localList(owner, profile, listPage), source: 'local', state: 'current' }
  886. }
  887. const local = kind === 'list' && ownedList && this.store.needsListMetadata(owner, profile) ? undefined : kind === 'detail' && sessionID
  888. ? this.store.localDetail(owner, profile, sessionID) ?? this.store.snapshot(owner, key)
  889. : this.store.snapshot(owner, key)
  890. const projectedLocal = local && kind === 'list' && ownedList
  891. ? {
  892. ...local,
  893. response: this.store.mergeOwnedSessionsIntoList(owner, profile, local.response, listPage),
  894. }
  895. : local
  896. if (this.mode === 'prefer-local' && local) {
  897. return { response: projectedLocal!.response, source: 'local', state: local.state }
  898. }
  899. try {
  900. const requestStartedAt = Date.now()
  901. const upstreamResponse = await load()
  902. if (this.mode === 'prefer-local' && local && upstreamResponse.status >= 500) {
  903. return { response: projectedLocal!.response, source: 'local', state: 'stale' }
  904. }
  905. const response = kind === 'list' && ownedList
  906. ? this.store.mergeOwnedSessionsIntoList(
  907. owner,
  908. profile,
  909. upstreamResponse,
  910. listPage,
  911. )
  912. : upstreamResponse
  913. this.store.putSnapshot(owner, key, kind, profile, sessionID, response, ownedList, requestStartedAt, upstreamResponse)
  914. return { response, source: 'upstream', state: 'current' }
  915. } catch (error) {
  916. if (this.mode === 'prefer-local' && local) return { response: projectedLocal!.response, source: 'local', state: 'stale' }
  917. throw error
  918. }
  919. }
  920. async messages(owner: string, key: string, profile: string, sessionID: string, offset: number, limit: number,
  921. load: () => Promise<UpstreamResponse>): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
  922. if (this.mode === 'prefer-local') {
  923. // A completion may still be committing its authoritative tail. Join it for
  924. // the latest page; older, already stored pages never wait for the network.
  925. if (offset === 0) await this.reconciling.get(JSON.stringify([owner, profile, sessionID]))?.catch(() => {})
  926. const page = this.store.messagePage(owner, profile, sessionID, offset, limit)
  927. if (page) return { response: page.response, source: 'local', state: page.state }
  928. }
  929. return this.read(owner, key, 'messages', profile, sessionID, load)
  930. }
  931. observe(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
  932. if (!this.store.recordEvent(owner, profile, sessionID, frame)) return
  933. if (['message.complete', 'gateway.reset'].includes(String(frame.type))) {
  934. void this.reconcile(owner, profile, sessionID).catch(() => {})
  935. }
  936. }
  937. observeGlobal(owner: string, type: string): void {
  938. if (type === 'sessions.changed') this.store.markListsStale(owner)
  939. }
  940. command(owner: string, profile: string, sessionID: string, method: string,
  941. params: Record<string, unknown>): void {
  942. this.store.recordCommand(owner, profile, sessionID, method, params)
  943. }
  944. route(owner: string, profile: string, sessionID: string, runtimeID: string): void {
  945. this.store.recordRoute(owner, profile, sessionID, runtimeID)
  946. }
  947. async reconcile(owner: string, profile: string, sessionID: string, force = false): Promise<void> {
  948. if (this.mode === 'upstream-only' || !this.store.ownsSession(owner, profile, sessionID)) return
  949. const key = JSON.stringify([owner, profile, sessionID])
  950. const pending = this.reconciling.get(key)
  951. if (pending) {
  952. await pending
  953. if (force || this.store.localDetail(owner, profile, sessionID)?.state !== 'current') {
  954. return this.reconcile(owner, profile, sessionID, force)
  955. }
  956. return
  957. }
  958. const task = (async () => {
  959. const revision = this.store.revision(owner, profile, sessionID)
  960. const previous = this.store.messagePage(owner, profile, sessionID, 0, 1)
  961. const previousTotal = previous ? Number(object(parseResponse(previous.response)?.pagination)?.total ?? 0) : 0
  962. const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
  963. const detailStartedAt = Date.now()
  964. const detail = await this.upstream.request(detailPath, { search: new URLSearchParams({ profile }), cache: 'reload' })
  965. if (detail.status !== 200) throw new Error('Unable to synchronize conversation metadata')
  966. const detailPayload = parseResponse(detail) ?? {}
  967. const detailSummary = object(detailPayload.session) ?? detailPayload
  968. const summaryTotal = Math.max(0, Number(detailSummary.message_count ?? detailSummary.messageCount ?? previousTotal) || 0)
  969. const pages = [{ key: `detail:${profile}:${sessionID}`, kind: 'detail', response: detail, startedAt: detailStartedAt, offset: 0 }]
  970. let offset = 0
  971. let done = false
  972. let expectedTotal: number | undefined
  973. let resolvedTotal = summaryTotal
  974. // Stop as soon as the authoritative tail overlaps durable messages. Only
  975. // explicit refresh (or uncached history) walks all the way to the beginning.
  976. for (let page = 0; page < 1_000; page++) {
  977. const search = new URLSearchParams({ profile, offset: String(offset), limit: '100', order: 'latest', include_compacted: 'true' })
  978. const startedAt = Date.now()
  979. const response = await this.upstream.request(`${detailPath}/messages`, { search, cache: 'reload' })
  980. const payload = parseResponse(response)
  981. if (response.status !== 200 || !Array.isArray(payload?.messages)) throw new Error('Unable to synchronize conversation messages')
  982. const messages = payload.messages.flatMap(value => object(value) ? [object(value)!] : [])
  983. const pagination = object(payload.pagination) ?? {}
  984. const explicitTotal = pagination.total ?? payload.total
  985. const total = Math.max(offset + messages.length, Number(explicitTotal ?? summaryTotal) || 0)
  986. if (!force && total < previousTotal) throw new Error('Stored history changed; explicit refresh is required')
  987. if (explicitTotal !== undefined) {
  988. if (expectedTotal !== undefined && expectedTotal !== total) throw new Error('Conversation changed during synchronization')
  989. expectedTotal = total
  990. }
  991. const anchors = !force ? messages.map((message, index) =>
  992. this.store.hasMessage(owner, profile, sessionID, message, total - offset - messages.length + index)) : []
  993. const overlap = anchors.some(Boolean)
  994. pages.push({ key: `messages:${profile}:${sessionID}:${offset}:100`, kind: 'messages', response, startedAt, offset })
  995. const more = pagination.hasMore ?? pagination.has_more ?? payload.hasMore ?? payload.has_more
  996. const hasMore = more === undefined ? messages.length >= 100 : bool(more)
  997. if (overlap || !hasMore) {
  998. if (!overlap && expectedTotal !== undefined && expectedTotal !== offset + messages.length) throw new Error('Conversation pagination ended before its declared total')
  999. resolvedTotal = overlap ? total : offset + messages.length
  1000. done = true
  1001. break
  1002. }
  1003. if (!messages.length) break
  1004. offset += messages.length
  1005. }
  1006. if (!done) throw new Error('Conversation synchronization is incomplete')
  1007. // Newer Hermes omits total/has_more. Once the end is known, project the
  1008. // same verified total onto every page before assigning absolute positions.
  1009. for (let index = 0; index < pages.length; index++) {
  1010. const entry = pages[index]!
  1011. const payload = parseResponse(entry.response)!
  1012. if (entry.kind === 'detail') {
  1013. const summary = object(payload.session) ?? payload
  1014. summary.message_count = resolvedTotal
  1015. } else {
  1016. const pagination = object(payload.pagination) ?? {}
  1017. const pageOffset = entry.offset
  1018. payload.pagination = { ...pagination, total: resolvedTotal, offset: pageOffset,
  1019. has_more: pageOffset + (payload.messages as unknown[]).length < resolvedTotal }
  1020. }
  1021. const headers = new Headers(entry.response.headers)
  1022. headers.delete('content-length')
  1023. entry.response = { ...entry.response, headers, body: Buffer.from(JSON.stringify(payload)) }
  1024. }
  1025. if (!this.store.applySync(owner, profile, sessionID, revision, pages, force)) {
  1026. // Preserve the invalidation when a newer event arrived during the read.
  1027. // Its completion joins this flight and schedules the next tail read.
  1028. if (force) throw new Error('Conversation changed during refresh; retry after the reply completes')
  1029. } else {
  1030. this.onSynchronized(owner, profile, sessionID)
  1031. }
  1032. })().finally(() => { if (this.reconciling.get(key) === task) this.reconciling.delete(key) })
  1033. this.reconciling.set(key, task)
  1034. return task
  1035. }
  1036. async cacheAttachment(owner: string, sourcePath: string): Promise<void> {
  1037. if (this.mode === 'upstream-only' || !this.store.knowsAttachment(owner, sourcePath) || this.store.attachment(owner, sourcePath)) return
  1038. try {
  1039. const response = await this.upstream.request('/api/files/download', {
  1040. search: new URLSearchParams({ path: sourcePath }), maxResponseBytes: 100 * 1_024 * 1_024,
  1041. })
  1042. this.store.storeAttachment(owner, sourcePath, response)
  1043. } catch { /* The next authorized request can retry. */ }
  1044. }
  1045. }
  1046. export function chatCacheKey(kind: string, profile: string, sessionID: string | undefined, search: URLSearchParams): string {
  1047. const normalized = new URLSearchParams(search)
  1048. normalized.sort()
  1049. return [kind, profile, sessionID ?? '', normalized.toString()].join(':')
  1050. }