Files
orca/src/main/native-chat/transcript-opencode2-sqlite.ts
T
f199a20c3a Preserve OpenCode reasoning and recorded patches in native history (#24790)
* Use bounded OpenCode context for vault session continuation

OpenCode database and synthetic row paths are not text transcripts. Use the
vault preview or captured pane context, preserving actual transcript paths
containing a hash and supporting both OpenCode lanes and Windows paths.

Adapted the intent of #11859 and extended it to actual installed v2 vault rows.

Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com>

* Read real OpenCode sessions in terminal-backed native Chat

Reuse the bounded AI Vault SQLite worker for v1 and v2 session pages and live updates. Keep terminal input as the real execution path and pace OpenCode Stop through its two-Escape interrupt.

Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com>

* fix(opencode): publish approval cards for permission requests

* Send OpenCode native approval through its Enter selector

* Resolve mobile Chat readability for folder workspaces

* Bound OpenCode part batches and preserve v2 image attachments

* Prefer live migrated OpenCode sessions over legacy copies

* Consolidate mobile Chat eligibility test imports

* Consolidate OpenCode SQLite protocol type imports

* fix(native-chat): preserve OpenCode reasoning and patch parts

Separate genuine reasoning from answer blocks in both native SQLite schemas and retain recorded patches as completed patch tools. Keep each database row together at page boundaries so the existing raw-row cursors cannot drop half of a mixed row.

Adapted from @akhan157's OpenCode native history work in #13287 at bb661d10d716764fb472d824cd434678875b1947; retains the current bounded reader and account discovery instead of restoring the older capture and cursor implementation.

Verified against genuine private installed 2.0.16 and official 1.18.30 CLI ingestion.

Co-authored-by: Adnan Khan <adnank11427@gmail.com>

* fix(native-chat): keep split OpenCode rows intact on desktop and mobile

Preserve the native reader's bounded OpenCode row groups in paired reads and snapshot/replacement frames so a second presentation-count slice cannot drop reasoning while advancing the database cursor. Sort derived reasoning before its answer under the same provider timestamp while retaining journal order.

These two boundaries were reproduced with genuine installed 2.0.16 and official 1.18.30 sessions in a hidden desktop renderer and the current mobile view over an actual authenticated encrypted pairing.

Completes the semantic presentation from @akhan157's #13287 without importing its older clipping or cursor implementation.

Co-authored-by: Adnan Khan <adnank11427@gmail.com>

* fix(native-chat): keep reasoning and answers together in live windows

* Bound OpenCode transcript RPC pages and present omission notices

* Bound OpenCode transcript RPC pages and present omission notices

* Bound OpenCode transcript RPC pages and present omission notices

* Update native worker oversized-history notice contract

---------

Co-authored-by: mrcha033 <mrcha033@users.noreply.github.com>
Co-authored-by: xodmd45-ctrl <xodmd45-ctrl@users.noreply.github.com>
Co-authored-by: nwparker <nwparker@users.noreply.github.com>
2026-10-03 22:04:52 -07:00

239 lines
7.5 KiB
TypeScript

import { createHash } from 'node:crypto'
import type SyncDatabase from '../sqlite/sync-database'
import { openCodeTranscriptPageLimit } from '../../shared/opencode-transcript-page-limit'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
import { asRecord, parseJsonObject } from '../ai-vault/session-scanner-values'
import {
opencodeMessages,
OPENCODE_TRANSCRIPT_MAX_ROW_BYTES
} from './transcript-opencode-part-blocks'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import type {
OpenCodeTranscriptPage,
OpenCodeTranscriptSignal,
OpenCodeTranscriptItem
} from './transcript-opencode-sqlite-query'
const MAX_PAGE_BYTES = 16 * 1024 * 1024
const MAX_SCAN_ROWS = 10_000
function sessionExists(db: SyncDatabase, sessionId: string): boolean {
return (
tableExists(db, 'session_v2') &&
tableExists(db, 'session_message') &&
['id', 'session_id', 'type', 'seq', 'data', 'time_created', 'time_updated'].every((column) =>
columnExists(db, 'session_message', column)
) &&
db.prepare('SELECT 1 FROM session_v2 WHERE id = ?').get(sessionId) != null
)
}
export function readOpenCode2TranscriptSignal(
db: SyncDatabase,
sessionId: string
): OpenCodeTranscriptSignal | null {
if (!sessionExists(db, sessionId)) {
return null
}
const row = asRecord(
db
.prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(seq), 0) AS latest,
COALESCE(MAX(time_updated), 0) AS updated FROM session_message WHERE session_id = ?`)
.get(sessionId)
)
if (
typeof row?.count !== 'number' ||
typeof row.latest !== 'number' ||
typeof row.updated !== 'number'
) {
throw new Error('OpenCode transcript signal is invalid')
}
return {
messageCount: row.count,
partCount: 0,
maxMessageRowId: row.latest,
maxPartTimeUpdated: row.updated
}
}
function messageItems(value: unknown): OpenCodeTranscriptItem[] {
const row = asRecord(value)
if (
typeof row?.id !== 'string' ||
typeof row.cursor !== 'number' ||
typeof row.time_created !== 'number' ||
typeof row.time_updated !== 'number'
) {
throw new Error('OpenCode transcript message is invalid')
}
const messageCursor = row.cursor
const updatedAt = row.time_updated
if (
row.data === null ||
(typeof row.data === 'string' &&
Buffer.byteLength(row.data) > OPENCODE_TRANSCRIPT_MAX_ROW_BYTES)
) {
return opencodeMessages(
{
id: `opencode:${row.id}`,
role: 'system',
timestamp: row.time_created
},
[{ message_id: row.id, time_updated: row.time_updated, data: null }]
).map((message) => ({
rowid: messageCursor,
fingerprint: `${updatedAt}:omitted`,
message: { ...message, transcriptOffset: messageCursor }
}))
}
if (typeof row.data !== 'string') {
throw new Error('OpenCode transcript message is invalid')
}
const messageId = row.id
const record = parseJsonObject(row.data)
if (!record) {
throw new Error('OpenCode transcript message contains invalid JSON')
}
const content = Array.isArray(record.content)
? record.content
: typeof record.text === 'string'
? [{ type: 'text', text: record.text }]
: []
if (row.type === 'user' && Array.isArray(record.files)) {
for (const file of record.files) {
const attachment = asRecord(file)
if (!attachment) {
continue
}
const url =
typeof attachment.uri === 'string'
? attachment.uri
: typeof attachment.data === 'string' && typeof attachment.mime === 'string'
? `data:${attachment.mime};base64,${attachment.data}`
: null
if (url) {
content.push({ type: 'file', url, mime: attachment.mime, filename: attachment.name })
}
}
}
const parts = content.flatMap((value) => {
const item = asRecord(value)
if (!item) {
return []
}
if (item.type !== 'tool') {
return [{ message_id: messageId, time_updated: updatedAt, data: JSON.stringify(item) }]
}
const state = asRecord(item.state)
const output = Array.isArray(state?.content)
? state.content
.flatMap((value) => {
const part = asRecord(value)
return typeof part?.text === 'string' ? [part.text] : []
})
.join('\n')
: undefined
return [
{
message_id: messageId,
time_updated: updatedAt,
data: JSON.stringify({ ...item, tool: item.name, state: { ...state, output } })
}
]
})
const blocks: NativeChatBlock[] = []
const error = asRecord(record.error)
if (typeof error?.message === 'string') {
blocks.push({ type: 'text', text: error.message })
}
if (row.type === 'idle' && record.outcome === 'interrupted') {
blocks.push({ type: 'text', text: 'Conversation interrupted' })
}
if (
row.type !== 'user' &&
row.type !== 'assistant' &&
row.type !== 'system' &&
row.type !== 'idle'
) {
return []
}
const messages = opencodeMessages(
{
id: `opencode:${row.id}`,
role: row.type === 'idle' ? 'system' : row.type,
timestamp: row.time_created
},
parts,
blocks
)
const fingerprint = `${updatedAt}:${createHash('sha256').update(row.data).digest('hex')}`
return messages.toReversed().map((message) => ({
rowid: messageCursor,
fingerprint,
message: { ...message, transcriptOffset: messageCursor }
}))
}
export function readOpenCode2TranscriptPage(
db: SyncDatabase,
args: {
sessionId: string
limit: number
beforeMessageRowId?: number
}
): OpenCodeTranscriptPage | null {
if (!sessionExists(db, args.sessionId)) {
return null
}
const limit = openCodeTranscriptPageLimit(args.limit)
const statement = db.prepare(`SELECT seq AS cursor, id, type, time_created, time_updated,
CASE WHEN length(CAST(data AS BLOB)) <= ${OPENCODE_TRANSCRIPT_MAX_ROW_BYTES} THEN data ELSE NULL END AS data
FROM session_message WHERE session_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?`)
const items: OpenCodeTranscriptItem[] = []
let cursor = args.beforeMessageRowId ?? Number.MAX_SAFE_INTEGER
let scannedRows = 0
let bytes = 0
let pageBytes = 0
let hasMore = false
while (items.length < limit) {
const rows = statement.all(args.sessionId, cursor, Math.min(limit + 1, 8))
hasMore = rows.length === Math.min(limit + 1, 8)
if (rows.length === 0) {
break
}
for (const value of rows) {
const row = asRecord(value)
if (typeof row?.cursor !== 'number') {
throw new Error('OpenCode transcript cursor is invalid')
}
bytes += Object.values(row).reduce<number>(
(size, value) => size + (typeof value === 'string' ? Buffer.byteLength(value) : 0),
0
)
if (++scannedRows > MAX_SCAN_ROWS || bytes > MAX_PAGE_BYTES) {
throw new Error('OpenCode transcript page exceeds its read limit')
}
const mapped = messageItems(value)
cursor = row.cursor
for (const item of mapped) {
pageBytes += Buffer.byteLength(JSON.stringify(item))
if (pageBytes > MAX_PAGE_BYTES) {
throw new Error('OpenCode transcript page exceeds its read limit')
}
items.push(item)
}
if (items.length >= limit) {
hasMore =
db
.prepare('SELECT 1 FROM session_message WHERE session_id = ? AND seq < ? LIMIT 1')
.get(args.sessionId, cursor) != null
break
}
}
if (!hasMore) {
break
}
}
return { items: items.toReversed(), hasMore, beforeMessageRowId: scannedRows ? cursor : null }
}