chatCache.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  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 messageID(value: Record<string, unknown>, fallback: string): string {
  46. const id = String(value.id ?? value.row_id ?? value.message_id ?? value.messageId ?? '').trim()
  47. return id || createHash('sha256').update(JSON.stringify(value) + fallback).digest('hex')
  48. }
  49. function bool(value: unknown): boolean {
  50. return value === true || value === 1 || value === '1' || value === 'true'
  51. }
  52. const PATH_PATTERN = /(?:file:\/\/)?(\/(?:Users|private|var|tmp)\/[^\s<>'"`)\]}]+\.[A-Za-z0-9]{1,12})/g
  53. function referencedPaths(value: unknown, output = new Set<string>()): Set<string> {
  54. if (typeof value === 'string') {
  55. for (const match of value.matchAll(PATH_PATTERN)) output.add(match[1]!)
  56. } else if (Array.isArray(value)) {
  57. for (const child of value) referencedPaths(child, output)
  58. } else {
  59. const record = object(value)
  60. if (record) for (const child of Object.values(record)) referencedPaths(child, output)
  61. }
  62. return output
  63. }
  64. export class ChatCacheStore {
  65. readonly db: DatabaseSync
  66. readonly assetsRoot: string
  67. constructor(home: string) {
  68. mkdirSync(home, { recursive: true, mode: 0o700 })
  69. const path = join(home, 'chat-cache.sqlite3')
  70. this.db = new DatabaseSync(path)
  71. chmodSync(path, 0o600)
  72. this.assetsRoot = join(home, 'chat-cache-assets')
  73. mkdirSync(this.assetsRoot, { recursive: true, mode: 0o700 })
  74. this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
  75. CREATE TABLE IF NOT EXISTS chat_sessions(
  76. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  77. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  78. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  79. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  80. PRIMARY KEY(owner,profile,session_id));
  81. CREATE TABLE IF NOT EXISTS chat_messages(
  82. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  83. message_id TEXT NOT NULL, position INTEGER NOT NULL, data TEXT NOT NULL,
  84. content_hash TEXT NOT NULL, PRIMARY KEY(owner,profile,session_id,message_id));
  85. CREATE INDEX IF NOT EXISTS chat_messages_position ON chat_messages(owner,profile,session_id,position);
  86. CREATE TABLE IF NOT EXISTS chat_events(
  87. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  88. event_seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL,
  89. received_at INTEGER NOT NULL, PRIMARY KEY(owner,profile,session_id,event_seq,event_type));
  90. CREATE TABLE IF NOT EXISTS chat_attachments(
  91. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  92. source_path TEXT NOT NULL, sha256 TEXT, mime_type TEXT, size INTEGER,
  93. local_path TEXT, state TEXT NOT NULL DEFAULT 'pending', updated_at INTEGER NOT NULL,
  94. PRIMARY KEY(owner,profile,session_id,source_path));
  95. CREATE INDEX IF NOT EXISTS chat_attachments_source ON chat_attachments(owner,source_path);
  96. CREATE TABLE IF NOT EXISTS chat_sync_cursors(
  97. owner TEXT NOT NULL, profile TEXT NOT NULL, generation TEXT,
  98. event_cursor INTEGER NOT NULL DEFAULT 0, sync_state TEXT NOT NULL DEFAULT 'stale',
  99. last_synced_at INTEGER NOT NULL, PRIMARY KEY(owner,profile));
  100. CREATE TABLE IF NOT EXISTS chat_snapshots(
  101. owner TEXT NOT NULL, cache_key TEXT NOT NULL, kind TEXT NOT NULL,
  102. profile TEXT NOT NULL, session_id TEXT, status INTEGER NOT NULL,
  103. headers TEXT NOT NULL, body TEXT NOT NULL, sync_state TEXT NOT NULL,
  104. saved_at INTEGER NOT NULL, PRIMARY KEY(owner,cache_key));`)
  105. }
  106. close(): void { this.db.close() }
  107. stats(): Record<string, number> {
  108. const count = (table: string) => Number((this.db.prepare(`SELECT COUNT(*) count FROM ${table}`).get() as { count: number }).count)
  109. const bytes = Number((this.db.prepare(`SELECT COALESCE(SUM(size),0) bytes FROM chat_attachments WHERE state='cached'`).get() as { bytes: number }).bytes)
  110. return {
  111. sessions: count('chat_sessions'),
  112. messages: count('chat_messages'),
  113. events: count('chat_events'),
  114. attachments: count('chat_attachments'),
  115. attachmentBytes: bytes,
  116. }
  117. }
  118. snapshot(owner: string, key: string): StoredSnapshot | undefined {
  119. const row = this.db.prepare('SELECT status,headers,body,sync_state FROM chat_snapshots WHERE owner=? AND cache_key=?')
  120. .get(owner, key) as Record<string, unknown> | undefined
  121. return row ? { response: responseFromRow(row), state: String(row.sync_state) as ChatCacheState } : undefined
  122. }
  123. putSnapshot(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
  124. response: UpstreamResponse, knownWeb = false): boolean {
  125. const payload = parseResponse(response)
  126. if (!payload) return false
  127. if (kind === 'list' && !knownWeb) return false
  128. const isWeb = knownWeb || this.payloadIsWeb(owner, profile, sessionID, payload)
  129. if (!isWeb) return false
  130. const now = Date.now()
  131. this.db.exec('BEGIN IMMEDIATE')
  132. try {
  133. this.ingestPayload(owner, profile, sessionID, payload, now)
  134. this.db.prepare(`INSERT INTO chat_snapshots(owner,cache_key,kind,profile,session_id,status,headers,body,sync_state,saved_at)
  135. VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,cache_key) DO UPDATE SET status=excluded.status,headers=excluded.headers,
  136. body=excluded.body,sync_state='current',saved_at=excluded.saved_at`)
  137. .run(owner, key, kind, profile, sessionID ?? null, response.status, headerRecord(response.headers),
  138. response.body.toString('base64'), 'current', now)
  139. this.db.exec('COMMIT')
  140. return true
  141. } catch (error) {
  142. this.db.exec('ROLLBACK')
  143. throw error
  144. }
  145. }
  146. messagePage(owner: string, profile: string, sessionID: string, offset: number, limit: number): MessagePage | undefined {
  147. const session = this.db.prepare(`SELECT data,sync_state,complete FROM chat_sessions
  148. WHERE owner=? AND profile=? AND session_id=? AND source='web'`).get(owner, profile, sessionID) as Record<string, unknown> | undefined
  149. if (!session || !bool(session.complete)) return undefined
  150. const totalRow = this.db.prepare('SELECT COUNT(*) count FROM chat_messages WHERE owner=? AND profile=? AND session_id=?')
  151. .get(owner, profile, sessionID) as { count: number }
  152. const total = Number(totalRow.count)
  153. const end = Math.max(0, total - offset)
  154. const start = Math.max(0, end - limit)
  155. const rows = this.db.prepare(`SELECT data FROM chat_messages WHERE owner=? AND profile=? AND session_id=?
  156. AND position>=? AND position<? ORDER BY position`).all(owner, profile, sessionID, start, end) as Array<{ data: string }>
  157. const messages = rows.map(row => JSON.parse(row.data))
  158. const body = Buffer.from(JSON.stringify({
  159. session_id: sessionID,
  160. session: JSON.parse(String(session.data)),
  161. messages,
  162. pagination: {
  163. total,
  164. returned: messages.length,
  165. offset,
  166. limit,
  167. has_more: start > 0,
  168. },
  169. }))
  170. return {
  171. response: { status: 200, headers: new Headers({ 'content-type': 'application/json; charset=utf-8' }), body },
  172. state: String(session.sync_state) as ChatCacheState,
  173. }
  174. }
  175. recordEvent(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
  176. const rawSeq = Number(frame.seq ?? frame.sequence ?? 0)
  177. const type = String(frame.type ?? 'unknown')
  178. const payload = JSON.stringify(frame.payload ?? {})
  179. const seq = Number.isSafeInteger(rawSeq) && rawSeq > 0
  180. ? rawSeq
  181. : Number.parseInt(createHash('sha256').update(`${type}:${payload}`).digest('hex').slice(0, 12), 16)
  182. const now = Date.now()
  183. const previous = this.db.prepare(`SELECT last_event_seq FROM chat_sessions
  184. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { last_event_seq?: number } | undefined
  185. const lastSeen = Number(previous?.last_event_seq ?? 0)
  186. this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
  187. VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, Number.isSafeInteger(seq) ? seq : 0, type, payload, now)
  188. const state: ChatCacheState = type === 'gateway.reset' || type.includes('reset')
  189. || (rawSeq > 0 && lastSeen > 0 && rawSeq > lastSeen + 1) ? 'gap' : 'stale'
  190. this.db.prepare(`UPDATE chat_sessions SET sync_state=?,last_event_seq=MAX(last_event_seq,?),last_synced_at=?
  191. WHERE owner=? AND profile=? AND session_id=?`).run(state, rawSeq > 0 ? rawSeq : lastSeen, now, owner, profile, sessionID)
  192. this.db.prepare(`UPDATE chat_snapshots SET sync_state=? WHERE owner=? AND (session_id=? OR kind='list')`)
  193. .run(state, owner, sessionID)
  194. this.db.prepare(`INSERT INTO chat_sync_cursors(owner,profile,event_cursor,sync_state,last_synced_at)
  195. VALUES(?,?,?,?,?) ON CONFLICT(owner,profile) DO UPDATE SET event_cursor=MAX(event_cursor,excluded.event_cursor),
  196. sync_state=excluded.sync_state,last_synced_at=excluded.last_synced_at`)
  197. .run(owner, profile, rawSeq > 0 ? rawSeq : lastSeen, state, now)
  198. }
  199. recordRoute(owner: string, profile: string, sessionID: string, runtimeID: string): void {
  200. const now = Date.now()
  201. const summary = { id: sessionID, profile, source: 'web', title: '新对话', started_at: now, last_active: now }
  202. this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at)
  203. VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET runtime_id=excluded.runtime_id,
  204. source='web',last_synced_at=excluded.last_synced_at`)
  205. .run(owner, profile, sessionID, runtimeID, 'web', JSON.stringify(summary), 'stale', 0, now)
  206. }
  207. recordCommand(owner: string, profile: string, sessionID: string, method: string,
  208. params: Record<string, unknown>): void {
  209. const now = Date.now()
  210. const summary = { id: sessionID, profile, source: 'web', title: '新对话', started_at: now, last_active: now }
  211. this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,source,data,sync_state,complete,last_synced_at)
  212. VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET sync_state='stale',complete=0`)
  213. .run(owner, profile, sessionID, 'web', JSON.stringify(summary), 'stale', 0, now)
  214. const payload = JSON.stringify(params)
  215. const seq = Number.parseInt(createHash('sha256').update(`${method}:${payload}`).digest('hex').slice(0, 12), 16)
  216. this.db.prepare(`INSERT OR IGNORE INTO chat_events(owner,profile,session_id,event_seq,event_type,payload,received_at)
  217. VALUES(?,?,?,?,?,?,?)`).run(owner, profile, sessionID, seq, `command:${method}`, payload, now)
  218. this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND (session_id=? OR kind='list')")
  219. .run(owner, sessionID)
  220. }
  221. markListsStale(owner: string): void {
  222. this.db.prepare("UPDATE chat_snapshots SET sync_state='stale' WHERE owner=? AND kind='list'").run(owner)
  223. }
  224. deleteSession(owner: string, profile: string, sessionID: string): void {
  225. const assets = this.db.prepare(`SELECT DISTINCT local_path FROM chat_attachments
  226. WHERE owner=? AND profile=? AND session_id=? AND local_path IS NOT NULL`)
  227. .all(owner, profile, sessionID) as Array<{ local_path: string }>
  228. this.db.exec('BEGIN IMMEDIATE')
  229. try {
  230. for (const table of ['chat_messages', 'chat_events', 'chat_attachments', 'chat_sessions']) {
  231. this.db.prepare(`DELETE FROM ${table} WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
  232. }
  233. this.db.prepare("DELETE FROM chat_snapshots WHERE owner=? AND (session_id=? OR kind='list')").run(owner, sessionID)
  234. this.db.exec('COMMIT')
  235. for (const asset of assets) {
  236. const referenced = this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE local_path=? LIMIT 1').get(asset.local_path)
  237. if (!referenced) try { unlinkSync(asset.local_path) } catch { /* Already absent. */ }
  238. }
  239. } catch (error) { this.db.exec('ROLLBACK'); throw error }
  240. }
  241. attachment(owner: string, sourcePath: string): { localPath: string; mimeType?: string } | undefined {
  242. const row = this.db.prepare(`SELECT local_path,mime_type FROM chat_attachments
  243. WHERE owner=? AND source_path=? AND state='cached' ORDER BY updated_at DESC LIMIT 1`).get(owner, sourcePath) as Record<string, unknown> | undefined
  244. const localPath = row?.local_path ? String(row.local_path) : ''
  245. return localPath && existsSync(localPath) ? { localPath, ...(row?.mime_type ? { mimeType: String(row.mime_type) } : {}) } : undefined
  246. }
  247. knowsAttachment(owner: string, sourcePath: string): boolean {
  248. return Boolean(this.db.prepare('SELECT 1 ok FROM chat_attachments WHERE owner=? AND source_path=? LIMIT 1').get(owner, sourcePath))
  249. }
  250. isWebSession(owner: string, profile: string, sessionID: string): boolean {
  251. return Boolean(this.db.prepare("SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND source='web'")
  252. .get(owner, profile, sessionID))
  253. }
  254. storeAttachment(owner: string, sourcePath: string, response: UpstreamResponse): string | undefined {
  255. if (response.status !== 200 || !response.body.length) return undefined
  256. const sha256 = createHash('sha256').update(response.body).digest('hex')
  257. const directory = join(this.assetsRoot, sha256.slice(0, 2))
  258. mkdirSync(directory, { recursive: true, mode: 0o700 })
  259. const target = join(directory, sha256)
  260. if (!existsSync(target)) {
  261. const temporary = `${target}.${process.pid}.tmp`
  262. writeFileSync(temporary, response.body, { mode: 0o600 })
  263. renameSync(temporary, target)
  264. }
  265. const mimeType = response.headers.get('content-type')?.split(';', 1)[0]
  266. this.db.prepare(`UPDATE chat_attachments SET sha256=?,mime_type=?,size=?,local_path=?,state='cached',updated_at=?
  267. WHERE owner=? AND source_path=?`).run(sha256, mimeType ?? null, response.body.length, target, Date.now(), owner, sourcePath)
  268. return target
  269. }
  270. assetStream(path: string): { stream: ReturnType<typeof createReadStream>; size: number } {
  271. return { stream: createReadStream(path), size: statSync(path).size }
  272. }
  273. private payloadIsWeb(owner: string, profile: string, sessionID: string | undefined, payload: Record<string, unknown>): boolean {
  274. const session = object(payload.session) ?? (sessionID ? payload : undefined)
  275. if (session && sessionSource(session) === 'web') return true
  276. const sessions = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
  277. if (sessions.some(value => object(value) && sessionSource(object(value)!) === 'web')) return true
  278. if (!sessionID) return false
  279. return Boolean(this.db.prepare("SELECT 1 ok FROM chat_sessions WHERE owner=? AND profile=? AND session_id=? AND source='web'")
  280. .get(owner, profile, sessionID))
  281. }
  282. private ingestPayload(owner: string, fallbackProfile: string, sessionID: string | undefined,
  283. payload: Record<string, unknown>, now: number): void {
  284. const candidates: Record<string, unknown>[] = []
  285. const direct = object(payload.session)
  286. if (direct) candidates.push(direct)
  287. const listed = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.sessions) ? payload.sessions : []
  288. for (const value of listed) if (object(value)) candidates.push(object(value)!)
  289. if (sessionID && !direct && sessionSource(payload)) candidates.push(payload)
  290. for (const session of candidates) {
  291. if (sessionSource(session) !== 'web') continue
  292. const id = sessionIDOf(session)
  293. if (!id) continue
  294. const profile = profileOf(session, fallbackProfile) || fallbackProfile || 'default'
  295. this.db.prepare(`INSERT INTO chat_sessions(owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_synced_at)
  296. VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id) DO UPDATE SET data=excluded.data,source='web',
  297. sync_state='current',last_synced_at=excluded.last_synced_at`)
  298. .run(owner, profile, id, null, 'web', JSON.stringify(session), 'current', 0, now)
  299. }
  300. const messages = Array.isArray(payload.messages) ? payload.messages.flatMap(value => object(value) ? [object(value)!] : []) : []
  301. if (!messages.length || !sessionID) return
  302. const profile = fallbackProfile || profileOf(direct ?? {}, 'default') || 'default'
  303. const pagination = object(payload.pagination) ?? {}
  304. const total = Math.max(messages.length, Number(pagination.total ?? payload.total ?? messages.length) || messages.length)
  305. const offset = Math.max(0, Number(pagination.offset ?? payload.offset ?? 0) || 0)
  306. const start = Math.max(0, total - offset - messages.length)
  307. messages.forEach((message, index) => {
  308. const data = JSON.stringify(message)
  309. this.db.prepare(`INSERT INTO chat_messages(owner,profile,session_id,message_id,position,data,content_hash)
  310. VALUES(?,?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,message_id) DO UPDATE SET position=excluded.position,
  311. data=excluded.data,content_hash=excluded.content_hash`)
  312. .run(owner, profile, sessionID, messageID(message, String(start + index)), start + index, data,
  313. createHash('sha256').update(data).digest('hex'))
  314. for (const sourcePath of referencedPaths(message)) {
  315. this.db.prepare(`INSERT INTO chat_attachments(owner,profile,session_id,source_path,state,updated_at)
  316. VALUES(?,?,?,?,?,?) ON CONFLICT(owner,profile,session_id,source_path) DO UPDATE SET updated_at=excluded.updated_at`)
  317. .run(owner, profile, sessionID, sourcePath, 'pending', now)
  318. }
  319. })
  320. const hasMore = bool(pagination.hasMore ?? pagination.has_more ?? payload.hasMore ?? payload.has_more)
  321. const complete = !hasMore && offset + messages.length >= total
  322. this.db.prepare(`UPDATE chat_sessions SET complete=MAX(complete,?),sync_state='current',last_synced_at=?
  323. WHERE owner=? AND profile=? AND session_id=?`).run(complete ? 1 : 0, now, owner, profile, sessionID)
  324. }
  325. }
  326. export class ChatCacheCoordinator {
  327. private reconciling = new Map<string, Promise<void>>()
  328. constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
  329. readonly mode: ChatCacheMode = 'prefer-local') {}
  330. async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
  331. load: () => Promise<UpstreamResponse>, knownWeb = false): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
  332. if (this.mode === 'upstream-only') return { response: await load(), source: 'upstream', state: 'current' }
  333. const local = this.store.snapshot(owner, key)
  334. if (this.mode === 'prefer-local' && local?.state === 'current') {
  335. return { response: local.response, source: 'local', state: local.state }
  336. }
  337. try {
  338. const response = await load()
  339. if (this.mode === 'prefer-local' && local && response.status >= 500) {
  340. return { response: local.response, source: 'local', state: 'stale' }
  341. }
  342. this.store.putSnapshot(owner, key, kind, profile, sessionID, response, knownWeb)
  343. return { response, source: 'upstream', state: 'current' }
  344. } catch (error) {
  345. if (this.mode === 'prefer-local' && local) return { response: local.response, source: 'local', state: 'stale' }
  346. throw error
  347. }
  348. }
  349. async messages(owner: string, key: string, profile: string, sessionID: string, offset: number, limit: number,
  350. load: () => Promise<UpstreamResponse>): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
  351. if (this.mode === 'prefer-local') {
  352. const page = this.store.messagePage(owner, profile, sessionID, offset, limit)
  353. if (page?.state === 'current') return { response: page.response, source: 'local', state: page.state }
  354. }
  355. const result = await this.read(owner, key, 'messages', profile, sessionID, load)
  356. if (result.source === 'upstream'
  357. && this.store.isWebSession(owner, profile, sessionID)
  358. && !this.store.messagePage(owner, profile, sessionID, 0, 1)) {
  359. void this.reconcile(owner, profile, sessionID)
  360. }
  361. return result
  362. }
  363. observe(owner: string, profile: string, sessionID: string, frame: Record<string, unknown>): void {
  364. if (this.mode === 'upstream-only') return
  365. this.store.recordEvent(owner, profile, sessionID, frame)
  366. if (String(frame.type) === 'message.complete') void this.reconcile(owner, profile, sessionID)
  367. }
  368. observeGlobal(owner: string, type: string): void {
  369. if (this.mode !== 'upstream-only' && type === 'sessions.changed') this.store.markListsStale(owner)
  370. }
  371. command(owner: string, profile: string, sessionID: string, method: string,
  372. params: Record<string, unknown>): void {
  373. if (this.mode !== 'upstream-only') this.store.recordCommand(owner, profile, sessionID, method, params)
  374. }
  375. route(owner: string, profile: string, sessionID: string, runtimeID: string): void {
  376. if (this.mode !== 'upstream-only') this.store.recordRoute(owner, profile, sessionID, runtimeID)
  377. }
  378. async reconcile(owner: string, profile: string, sessionID: string): Promise<void> {
  379. if (this.mode === 'upstream-only') return
  380. const key = JSON.stringify([owner, profile, sessionID])
  381. if (this.reconciling.has(key)) return this.reconciling.get(key)
  382. const task = (async () => {
  383. const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
  384. const detailSearch = new URLSearchParams({ profile })
  385. const detail = await this.upstream.request(detailPath, { search: detailSearch })
  386. this.store.putSnapshot(owner, `detail:${profile}:${sessionID}`, 'detail', profile, sessionID, detail)
  387. let offset = 0
  388. for (let page = 0; page < 200; page++) {
  389. const search = new URLSearchParams({ profile, offset: String(offset), limit: '500', order: 'latest', include_compacted: 'true' })
  390. const response = await this.upstream.request(`${detailPath}/messages`, { search })
  391. if (!this.store.putSnapshot(owner, `messages:${profile}:${sessionID}:${offset}:500`, 'messages', profile, sessionID, response)) return
  392. const payload = parseResponse(response)
  393. const messages = Array.isArray(payload?.messages) ? payload.messages : []
  394. const pagination = object(payload?.pagination) ?? {}
  395. if (!bool(pagination.hasMore ?? pagination.has_more ?? payload?.hasMore ?? payload?.has_more) || !messages.length) return
  396. offset += messages.length
  397. }
  398. })().catch(() => {}).finally(() => this.reconciling.delete(key))
  399. this.reconciling.set(key, task)
  400. return task
  401. }
  402. async cacheAttachment(owner: string, sourcePath: string): Promise<void> {
  403. if (this.mode === 'upstream-only' || !this.store.knowsAttachment(owner, sourcePath) || this.store.attachment(owner, sourcePath)) return
  404. try {
  405. const response = await this.upstream.request('/api/files/download', {
  406. search: new URLSearchParams({ path: sourcePath }), maxResponseBytes: 100 * 1_024 * 1_024,
  407. })
  408. this.store.storeAttachment(owner, sourcePath, response)
  409. } catch { /* The next authorized request can retry. */ }
  410. }
  411. }
  412. export function chatCacheKey(kind: string, profile: string, sessionID: string | undefined, search: URLSearchParams): string {
  413. const normalized = new URLSearchParams(search)
  414. normalized.sort()
  415. return [kind, profile, sessionID ?? '', normalized.toString()].join(':')
  416. }