mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
* 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>
239 lines
7.5 KiB
TypeScript
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 }
|
|
}
|