Files
kenn-io--agentsview/internal/parser/opencode.go
T
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1340 lines
32 KiB
Go

package parser
import (
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/tidwall/gjson"
)
const openCodeStorageFingerprintPrefix = "opencode-storage:v1:"
// OpenCodeSessionMeta is lightweight metadata for a session,
// used to detect changes without parsing messages or parts.
type OpenCodeSessionMeta struct {
SessionID string
VirtualPath string
FileMtime int64
}
// OpenCodeSQLiteSessionExists reports whether a session row with
// the given ID is present in the OpenCode SQLite database at
// dbPath. Returns false when the file is missing, the schema is
// unexpected, or no row matches. Used by the OpenCode-format
// provider's source lookup so callers can distinguish "this DB has
// the session" from
// "this DB exists but doesn't have it" — the latter must let
// resolution continue to other configured roots.
func OpenCodeSQLiteSessionExists(dbPath, sessionID string) bool {
if dbPath == "" || sessionID == "" {
return false
}
info, err := os.Stat(dbPath)
if err != nil || info.IsDir() {
return false
}
db, err := openOpenCodeDB(dbPath)
if err != nil {
return false
}
defer db.Close()
var found int
err = db.QueryRow(
"SELECT 1 FROM session WHERE id = ? LIMIT 1",
sessionID,
).Scan(&found)
return err == nil
}
// ListOpenCodeSessionMeta returns lightweight metadata for
// all sessions without parsing messages or parts. Used by
// the sync engine to detect which sessions have changed.
func ListOpenCodeSessionMeta(
dbPath string,
) ([]OpenCodeSessionMeta, error) {
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil
}
db, err := openOpenCodeDB(dbPath)
if err != nil {
return nil, err
}
defer db.Close()
rows, err := db.Query(
"SELECT id, time_updated FROM session",
)
if err != nil {
return nil, fmt.Errorf(
"listing opencode sessions: %w", err,
)
}
defer rows.Close()
var metas []OpenCodeSessionMeta
for rows.Next() {
var id string
var timeUpdated int64
if err := rows.Scan(
&id, &timeUpdated,
); err != nil {
return nil, fmt.Errorf(
"scanning opencode session meta: %w", err,
)
}
metas = append(metas, OpenCodeSessionMeta{
SessionID: id,
VirtualPath: dbPath + "#" + id,
FileMtime: timeUpdated * 1_000_000,
})
}
return metas, rows.Err()
}
// parseOpenCodeDBSession parses a single session by ID from the
// OpenCode SQLite database. The OpenCode-format provider owns this
// path; Kilo and MiMoCode reuse it and relabel the result.
func parseOpenCodeDBSession(
dbPath, sessionID, machine string,
) (*ParsedSession, []ParsedMessage, error) {
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil, fmt.Errorf(
"opencode db not found: %s", dbPath,
)
}
db, err := openOpenCodeDB(dbPath)
if err != nil {
return nil, nil, err
}
defer db.Close()
projects, err := loadOpenCodeProjectsCached(db, dbPath)
if err != nil {
return nil, nil, fmt.Errorf(
"loading opencode projects: %w", err,
)
}
s, err := loadOneOpenCodeSession(db, sessionID)
if err != nil {
return nil, nil, fmt.Errorf(
"loading opencode session %s: %w",
sessionID, err,
)
}
worktree := projects[s.projectID]
return buildOpenCodeSession(
db, s, worktree, dbPath, machine,
)
}
// parseOpenCodeStorageFile parses a file-backed OpenCode storage
// session rooted at storage/session/<project>/<session>.json. The
// OpenCode-format provider owns this path; Kilo and MiMoCode reuse it
// and relabel the result.
func parseOpenCodeStorageFile(
sessionPath, machine string,
) (*ParsedSession, []ParsedMessage, error) {
raw, err := os.ReadFile(sessionPath)
if err != nil {
return nil, nil, fmt.Errorf(
"reading opencode session file %s: %w",
sessionPath, err,
)
}
var sf openCodeStorageSessionFile
if err := json.Unmarshal(raw, &sf); err != nil {
return nil, nil, fmt.Errorf(
"decoding opencode session file %s: %w",
sessionPath, err,
)
}
if sf.ID == "" {
return nil, nil, fmt.Errorf(
"opencode session file %s missing id",
sessionPath,
)
}
root := filepath.Dir(filepath.Dir(filepath.Dir(
filepath.Dir(sessionPath),
)))
// OpenCode session sync replaces the full stored transcript.
// If a child JSON is truncated mid-write, skipping it here
// would silently drop previously persisted content until the
// next successful sync, so malformed children abort the parse.
msgs, err := loadOpenCodeStorageMessages(root, sf.ID)
if err != nil {
return nil, nil, err
}
parts, err := loadOpenCodeStorageParts(root, msgs)
if err != nil {
return nil, nil, err
}
fileMtime, err := OpenCodeSourceMtime(sessionPath)
if err != nil {
return nil, nil, err
}
sess, parsed, err := buildOpenCodeParsedSession(
openCodeSessionRow{
id: sf.ID,
parentID: sf.ParentID,
title: sf.Title,
timeCreated: sf.Time.Created,
timeUpdated: sf.Time.Updated,
},
sf.Directory,
sessionPath,
fileMtime,
machine,
msgs,
parts,
)
if err != nil || sess == nil {
return sess, parsed, err
}
sess.File.Hash = buildOpenCodeSessionFingerprint(
openCodeSessionRow{
id: sf.ID,
parentID: sf.ParentID,
title: sf.Title,
timeCreated: sf.Time.Created,
timeUpdated: sf.Time.Updated,
},
sf.Directory,
msgs,
parts,
)
return sess, parsed, nil
}
func openOpenCodeDB(dbPath string) (*sql.DB, error) {
dsn := "file:" + sqliteURIPath(dbPath) +
"?mode=ro&_busy_timeout=3000"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf(
"opening opencode db %s: %w", dbPath, err,
)
}
return db, nil
}
// openCodeProject is a row from the opencode project table.
type openCodeProject struct {
id string
worktree string
}
func loadOpenCodeProjects(
db *sql.DB,
) (map[string]string, error) {
rows, err := db.Query(
"SELECT id, worktree FROM project",
)
if err != nil {
return nil, err
}
defer rows.Close()
projects := make(map[string]string)
for rows.Next() {
var p openCodeProject
if err := rows.Scan(&p.id, &p.worktree); err != nil {
return nil, err
}
projects[p.id] = p.worktree
}
return projects, rows.Err()
}
type openCodeProjectsCacheEntry struct {
state SQLiteContainerState
projects map[string]string
}
// openCodeProjectsCache memoizes the project table per shared SQLite DB,
// keyed by the container's change-detection state. The engine parses each
// session of a container through an independent provider instance, so
// without this cache every parsed session re-queried the full project
// table — the dominant per-session cost when re-verifying a changed
// container. Entries hold only a handful of small maps (one per configured
// container path) and are replaced in place.
var (
openCodeProjectsCacheMu sync.Mutex
openCodeProjectsCache = map[string]openCodeProjectsCacheEntry{}
)
// loadOpenCodeProjectsCached returns the project→worktree map for the
// shared DB at dbPath, reusing the previous load while the container state
// is unchanged. The state is captured before the query, so a write racing
// the load can only make cached data newer than its key — the next capture
// then mismatches and reloads. The returned map is shared and must be
// treated as read-only.
func loadOpenCodeProjectsCached(
db *sql.DB, dbPath string,
) (map[string]string, error) {
state, ok := StatSQLiteContainerState(dbPath)
if !ok {
return loadOpenCodeProjects(db)
}
openCodeProjectsCacheMu.Lock()
entry, hit := openCodeProjectsCache[dbPath]
openCodeProjectsCacheMu.Unlock()
if hit && entry.state == state {
return entry.projects, nil
}
projects, err := loadOpenCodeProjects(db)
if err != nil {
return nil, err
}
openCodeProjectsCacheMu.Lock()
openCodeProjectsCache[dbPath] = openCodeProjectsCacheEntry{
state: state,
projects: projects,
}
openCodeProjectsCacheMu.Unlock()
return projects, nil
}
// openCodeSessionRow is a row from the opencode session table.
type openCodeSessionRow struct {
id string
projectID string
parentID string
title string
timeCreated int64
timeUpdated int64
}
func loadOneOpenCodeSession(
db *sql.DB, sessionID string,
) (openCodeSessionRow, error) {
row := db.QueryRow(`
SELECT s.id, s.project_id,
COALESCE(s.parent_id, ''),
COALESCE(s.title, ''),
s.time_created, s.time_updated
FROM session s
WHERE s.id = ?
`, sessionID)
var s openCodeSessionRow
err := row.Scan(
&s.id, &s.projectID, &s.parentID,
&s.title, &s.timeCreated, &s.timeUpdated,
)
return s, err
}
// openCodeMessageRow is a row from the opencode message table.
// The role is extracted from the JSON data column.
type openCodeMessageRow struct {
id string
data string
timeCreated int64
fileMtime int64
}
// openCodeMessageData holds the scalar fields we extract from
// the message data JSON blob. Token usage lives under `tokens`
// and is read separately via gjson so the parser can
// distinguish explicit zero fields from absent ones.
type openCodeMessageData struct {
Role string `json:"role"`
ModelID string `json:"modelID"`
ProviderID string `json:"providerID"`
Model struct {
ModelID string `json:"modelID"`
ProviderID string `json:"providerID"`
} `json:"model"`
}
// openCodePartRow is a row from the opencode part table.
// The part type is extracted from the JSON data column.
type openCodePartRow struct {
id string
messageID string
data string
timeCreated int64
fileMtime int64
}
type openCodeStorageFingerprint struct {
Session *openCodeStorageFingerprintSession `json:"session,omitempty"`
Messages []openCodeStorageFingerprintMessage `json:"messages"`
}
type openCodeStorageFingerprintSession struct {
ID string `json:"id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
Title string `json:"title,omitempty"`
Worktree string `json:"worktree,omitempty"`
TimeCreated int64 `json:"time_created,omitempty"`
TimeUpdated int64 `json:"time_updated,omitempty"`
}
type openCodeStorageFingerprintMessage struct {
ID string `json:"id"`
Time int64 `json:"time"`
Hash string `json:"hash,omitempty"`
Parts []openCodeStorageFingerprintPart `json:"parts,omitempty"`
}
type openCodeStorageFingerprintPart struct {
ID string `json:"id"`
Time int64 `json:"time"`
Hash string `json:"hash,omitempty"`
}
func loadOpenCodeMessages(
db *sql.DB, sessionID string,
) ([]openCodeMessageRow, error) {
rows, err := db.Query(`
SELECT id, data, time_created
FROM message
WHERE session_id = ?
ORDER BY time_created
`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var msgs []openCodeMessageRow
for rows.Next() {
var m openCodeMessageRow
if err := rows.Scan(
&m.id, &m.data, &m.timeCreated,
); err != nil {
return nil, err
}
msgs = append(msgs, m)
}
return msgs, rows.Err()
}
func loadOpenCodeParts(
db *sql.DB, sessionID string,
) (map[string][]openCodePartRow, error) {
rows, err := db.Query(`
SELECT p.id, p.message_id,
COALESCE(p.data, '{}'),
p.time_created
FROM part p
WHERE p.session_id = ?
ORDER BY p.time_created
`, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
parts := make(map[string][]openCodePartRow)
for rows.Next() {
var p openCodePartRow
if err := rows.Scan(
&p.id, &p.messageID,
&p.data, &p.timeCreated,
); err != nil {
return nil, err
}
parts[p.messageID] = append(
parts[p.messageID], p,
)
}
return parts, rows.Err()
}
func buildOpenCodeSession(
db *sql.DB,
s openCodeSessionRow,
worktree, dbPath, machine string,
) (*ParsedSession, []ParsedMessage, error) {
msgs, err := loadOpenCodeMessages(db, s.id)
if err != nil {
return nil, nil, fmt.Errorf(
"loading messages for %s: %w", s.id, err,
)
}
parts, err := loadOpenCodeParts(db, s.id)
if err != nil {
return nil, nil, fmt.Errorf(
"loading parts for %s: %w", s.id, err,
)
}
sess, parsed, err := buildOpenCodeParsedSession(
s,
worktree,
dbPath+"#"+s.id,
s.timeUpdated*1_000_000,
machine,
msgs,
parts,
)
if err != nil || sess == nil {
return sess, parsed, err
}
sess.File.Hash = buildOpenCodeSessionFingerprint(
s, worktree, msgs, parts,
)
return sess, parsed, nil
}
func buildOpenCodeParsedSession(
s openCodeSessionRow,
worktree, filePath string,
fileMtime int64,
machine string,
msgs []openCodeMessageRow,
parts map[string][]openCodePartRow,
) (*ParsedSession, []ParsedMessage, error) {
var (
parsed []ParsedMessage
firstMsg string
hasUserOrAst bool
ordinal int
)
// Prefer OpenCode's LLM-generated title when available.
// Skip default placeholders that match OpenCode's exact
// format: "New session - " or "Child session - " followed
// by an ISO-8601 timestamp.
if s.title != "" && !isOpenCodeDefaultTitle(s.title) {
firstMsg = truncate(s.title, 300)
}
for _, m := range msgs {
var md openCodeMessageData
if json.Unmarshal([]byte(m.data), &md) != nil {
continue
}
role := normalizeOpenCodeRole(md.Role)
if role == "" {
continue
}
hasUserOrAst = true
msgParts := parts[m.id]
sort.Slice(msgParts, func(a, b int) bool {
if msgParts[a].timeCreated ==
msgParts[b].timeCreated {
return msgParts[a].id < msgParts[b].id
}
return msgParts[a].timeCreated <
msgParts[b].timeCreated
})
pm := buildOpenCodeMessage(
ordinal, role, m.timeCreated, msgParts, worktree,
)
applyOpenCodeTokenUsage(&pm, md, m.data, msgParts)
if strings.TrimSpace(pm.Content) == "" &&
!pm.HasToolUse {
continue
}
if role == RoleUser && firstMsg == "" {
firstMsg = truncate(
strings.ReplaceAll(pm.Content, "\n", " "),
300,
)
}
parsed = append(parsed, pm)
ordinal++
}
if !hasUserOrAst || len(parsed) == 0 {
return nil, nil, nil
}
project := ExtractProjectFromCwd(worktree)
if project == "" {
project = "unknown"
}
parentID := ""
if s.parentID != "" {
parentID = "opencode:" + s.parentID
}
startedAt := millisToTime(s.timeCreated)
endedAt := millisToTime(s.timeUpdated)
userCount := 0
for _, m := range parsed {
if m.Role == RoleUser && m.Content != "" {
userCount++
}
}
sess := &ParsedSession{
ID: "opencode:" + s.id,
Project: project,
Machine: machine,
Agent: AgentOpenCode,
Cwd: worktree,
ParentSessionID: parentID,
FirstMessage: firstMsg,
StartedAt: startedAt,
EndedAt: endedAt,
MessageCount: len(parsed),
UserMessageCount: userCount,
File: FileInfo{
Path: filePath,
Mtime: fileMtime,
},
}
accumulateMessageTokenUsage(sess, parsed)
return sess, parsed, nil
}
// applyOpenCodeTokenUsage copies the assistant message's model
// id and per-message token counts into pm so the usage
// dashboard can attribute cost. OpenCode's token field names
// use a nested `cache.{read,write}` shape; this maps them onto
// the agentsview-native `cache_{read,creation}_input_tokens`
// keys that internal/db/usage.go expects.
//
// Coverage semantics match the claude parser contract: a field
// that is present at zero is preserved as "known zero" and
// sets its coverage flag, while a tokens object with no
// recognized fields (empty `{}` or a foreign schema) leaves
// TokenUsage empty so the usage query filter skips the row.
func applyOpenCodeTokenUsage(
pm *ParsedMessage,
md openCodeMessageData,
dataRaw string,
parts []openCodePartRow,
) {
if md.ModelID != "" {
pm.Model = md.ModelID
} else if md.Model.ModelID != "" {
pm.Model = md.Model.ModelID
}
raws := []string{dataRaw}
for _, part := range parts {
if extractOpenCodePartType(part.data) == "step-finish" {
raws = append(raws, part.data)
}
}
fields, ok := collectOpenCodeTokenFields(raws...)
if !ok {
return
}
normalized := map[string]int{
"input_tokens": fields.input,
"output_tokens": fields.output,
"cache_read_input_tokens": fields.cacheRead,
"cache_creation_input_tokens": fields.cacheCreate,
}
j, err := json.Marshal(normalized)
if err != nil {
return
}
pm.TokenUsage = j
pm.OutputTokens = fields.output
pm.HasOutputTokens = fields.hasOutput
pm.ContextTokens = fields.input +
fields.cacheRead + fields.cacheCreate
pm.HasContextTokens = fields.hasInput ||
fields.hasCacheRead || fields.hasCacheCreate
}
type openCodeTokenFields struct {
input int
output int
cacheRead int
cacheCreate int
hasInput bool
hasOutput bool
hasCacheRead bool
hasCacheCreate bool
}
func collectOpenCodeTokenFields(
raws ...string,
) (openCodeTokenFields, bool) {
var (
fields openCodeTokenFields
any bool
)
for _, raw := range raws {
tokens := gjson.Get(raw, "tokens")
if !tokens.Exists() {
continue
}
if field := tokens.Get("input"); field.Exists() {
fields.input = int(field.Int())
fields.hasInput = true
any = true
}
if field := tokens.Get("output"); field.Exists() {
fields.output = int(field.Int())
fields.hasOutput = true
any = true
}
if field := tokens.Get("cache.read"); field.Exists() {
fields.cacheRead = int(field.Int())
fields.hasCacheRead = true
any = true
}
if field := tokens.Get("cache.write"); field.Exists() {
fields.cacheCreate = int(field.Int())
fields.hasCacheCreate = true
any = true
}
}
return fields, any
}
// openCodeDefaultTitleRe matches the exact placeholder format
// OpenCode uses before the LLM generates a real title:
// "New session - 2026-03-22T10:00:00.000Z" or
// "Child session - 2026-03-22T10:00:00.000Z".
var openCodeDefaultTitleRe = regexp.MustCompile(
`^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`,
)
func isOpenCodeDefaultTitle(title string) bool {
return openCodeDefaultTitleRe.MatchString(title)
}
func normalizeOpenCodeRole(role string) RoleType {
switch role {
case "user":
return RoleUser
case "assistant":
return RoleAssistant
default:
return ""
}
}
func buildOpenCodeMessage(
ordinal int,
role RoleType,
timeCreatedMs int64,
parts []openCodePartRow,
cwd string,
) ParsedMessage {
var (
texts []string
toolCalls []ParsedToolCall
hasThinking bool
hasToolUse bool
)
for _, p := range parts {
partType := extractOpenCodePartType(p.data)
switch partType {
case "text":
text := extractOpenCodeText(p.data)
if text != "" {
texts = append(texts, text)
}
case "tool":
hasToolUse = true
tc := extractOpenCodeToolCall(p.data, cwd)
if tc.ToolName != "" {
toolCalls = append(toolCalls, tc)
}
case "reasoning":
text := extractOpenCodeText(p.data)
if text != "" {
hasThinking = true
texts = append(texts,
"[Thinking]\n"+text+"\n[/Thinking]")
}
}
// skip step-start, step-finish, patch, etc.
}
content := strings.Join(texts, "\n")
return ParsedMessage{
Ordinal: ordinal,
Role: role,
Content: content,
Timestamp: millisToTime(timeCreatedMs),
HasThinking: hasThinking,
HasToolUse: hasToolUse,
ContentLength: len(content),
ToolCalls: toolCalls,
}
}
// openCodePartTypeData extracts just the type from a part's
// JSON data blob.
type openCodePartTypeData struct {
Type string `json:"type"`
}
func extractOpenCodePartType(data string) string {
var d openCodePartTypeData
if json.Unmarshal([]byte(data), &d) != nil {
return ""
}
return d.Type
}
// openCodeTextData is the JSON structure for a text part's data.
type openCodeTextData struct {
Content string `json:"content"`
Text string `json:"text"`
}
func extractOpenCodeText(data string) string {
var d openCodeTextData
if err := json.Unmarshal([]byte(data), &d); err != nil {
return ""
}
if d.Content != "" {
return d.Content
}
return d.Text
}
// openCodeToolData is the JSON structure for a tool part's data.
type openCodeToolData struct {
ToolName string `json:"tool"`
CallID string `json:"callID"`
State json.RawMessage `json:"state"`
}
// openCodeToolState holds the nested state of a tool call.
type openCodeToolState struct {
Input json.RawMessage `json:"input"`
}
func extractOpenCodeToolCall(data, cwd string) ParsedToolCall {
var d openCodeToolData
if err := json.Unmarshal([]byte(data), &d); err != nil {
return ParsedToolCall{}
}
var inputJSON string
if len(d.State) > 0 {
var state openCodeToolState
if err := json.Unmarshal(d.State, &state); err == nil {
if len(state.Input) > 0 {
inputJSON = string(state.Input)
}
}
}
var skillName string
switch d.ToolName {
case "skill":
skillName = gjson.Get(inputJSON, "skill").Str
if skillName == "" {
skillName = gjson.Get(inputJSON, "name").Str
}
default:
skillName = inferOpenCodeSkillName(d.ToolName, inputJSON, cwd)
}
return ParsedToolCall{
ToolUseID: d.CallID,
ToolName: d.ToolName,
Category: NormalizeToolCategory(d.ToolName),
InputJSON: inputJSON,
SkillName: skillName,
}
}
func inferOpenCodeSkillName(toolName, inputJSON, cwd string) string {
if isCursorSkillReadTool(toolName) {
// OpenCode's read-tool input carries no cwd/workdir key, so
// inferSkillNameFromJSONPaths can't resolve relative SKILL.md
// paths and falls back to the parent directory name. Try the
// file_path directly against the session worktree first.
if fp := gjson.Get(inputJSON, "file_path").Str; fp != "" && cwd != "" {
if name := skillNameFromPath(fp, cwd); name != "" {
return name
}
}
return inferSkillNameFromJSONPaths(inputJSON)
}
return inferCodexSkillNameWithBase(toolName, inputJSON, cwd)
}
type openCodeStorageTime struct {
Created int64 `json:"created"`
Start int64 `json:"start"`
End int64 `json:"end"`
Updated int64 `json:"updated"`
}
func (t openCodeStorageTime) messageSortTime() int64 {
switch {
case t.Created != 0:
return t.Created
case t.Start != 0:
return t.Start
case t.End != 0:
return t.End
default:
return t.Updated
}
}
func (t openCodeStorageTime) partSortTime() int64 {
switch {
case t.Start != 0:
return t.Start
case t.Created != 0:
return t.Created
case t.End != 0:
return t.End
default:
return t.Updated
}
}
type openCodeStorageSessionFile struct {
ID string `json:"id"`
Directory string `json:"directory"`
ParentID string `json:"parentID"`
Title string `json:"title"`
Time openCodeStorageTime `json:"time"`
}
type openCodeStorageMessageFile struct {
ID string `json:"id"`
SessionID string `json:"sessionID"`
Role string `json:"role"`
ModelID string `json:"modelID"`
ProviderID string `json:"providerID"`
Model struct {
ModelID string `json:"modelID"`
ProviderID string `json:"providerID"`
} `json:"model"`
Time openCodeStorageTime `json:"time"`
}
type openCodeStoragePartFile struct {
ID string `json:"id"`
SessionID string `json:"sessionID"`
MessageID string `json:"messageID"`
Time openCodeStorageTime `json:"time"`
}
func loadOpenCodeStorageMessages(
root, sessionID string,
) ([]openCodeMessageRow, error) {
dir := filepath.Join(root, "storage", "message", sessionID)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf(
"reading opencode message dir %s: %w", dir, err,
)
}
var msgs []openCodeMessageRow
for _, entry := range entries {
if entry.IsDir() ||
!strings.HasSuffix(entry.Name(), ".json") {
continue
}
path := filepath.Join(dir, entry.Name())
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf(
"reading opencode message file %s: %w",
path, err,
)
}
var mf openCodeStorageMessageFile
if err := json.Unmarshal(raw, &mf); err != nil {
return nil, fmt.Errorf(
"decoding opencode message file %s: %w",
path, err,
)
}
if mf.ID == "" {
return nil, fmt.Errorf(
"opencode message file %s missing id",
path,
)
}
msgs = append(msgs, openCodeMessageRow{
id: mf.ID,
data: string(raw),
timeCreated: mf.Time.messageSortTime(),
fileMtime: mustEntryMtime(entry),
})
}
sort.Slice(msgs, func(i, j int) bool {
if msgs[i].timeCreated == msgs[j].timeCreated {
return msgs[i].id < msgs[j].id
}
return msgs[i].timeCreated < msgs[j].timeCreated
})
return msgs, nil
}
func loadOpenCodeStorageParts(
root string, msgs []openCodeMessageRow,
) (map[string][]openCodePartRow, error) {
parts := make(map[string][]openCodePartRow, len(msgs))
for _, msg := range msgs {
dir := filepath.Join(root, "storage", "part", msg.id)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf(
"reading opencode part dir %s: %w", dir, err,
)
}
for _, entry := range entries {
if entry.IsDir() ||
!strings.HasSuffix(entry.Name(), ".json") {
continue
}
path := filepath.Join(dir, entry.Name())
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf(
"reading opencode part file %s: %w",
path, err,
)
}
var pf openCodeStoragePartFile
if err := json.Unmarshal(raw, &pf); err != nil {
return nil, fmt.Errorf(
"decoding opencode part file %s: %w",
path, err,
)
}
if pf.ID == "" {
return nil, fmt.Errorf(
"opencode part file %s missing id",
path,
)
}
if pf.MessageID == "" {
pf.MessageID = msg.id
}
parts[msg.id] = append(parts[msg.id], openCodePartRow{
id: pf.ID,
messageID: pf.MessageID,
data: string(raw),
timeCreated: pf.Time.partSortTime(),
fileMtime: mustEntryMtime(entry),
})
}
}
return parts, nil
}
// OpenCodeSourceMtime returns a composite mtime for either an
// OpenCode storage session JSON path or a legacy SQLite virtual
// path in the form opencode.db#<sessionID>.
func OpenCodeSourceMtime(sourcePath string) (int64, error) {
if sourcePath == "" {
return 0, nil
}
if dbPath, sessionID, ok := parseOpenCodeFormatVirtualPath(
openCodeFmt.dbName, sourcePath,
); ok {
return openCodeSQLiteSessionMtime(dbPath, sessionID)
}
return openCodeStorageSessionMtime(sourcePath)
}
func OpenCodeStorageFingerprintMissing(
storedHash, currentHash string,
) bool {
stored, ok := decodeOpenCodeStorageFingerprint(storedHash)
if !ok {
return false
}
current, ok := decodeOpenCodeStorageFingerprint(currentHash)
if !ok {
return false
}
if stored.Session != nil {
if current.Session == nil ||
*current.Session != *stored.Session {
return true
}
}
currentMsgs := make(map[string]openCodeStorageFingerprintMessage, len(current.Messages))
for _, msg := range current.Messages {
currentMsgs[msg.ID] = msg
}
for _, storedMsg := range stored.Messages {
currentMsg, ok := currentMsgs[storedMsg.ID]
if !ok || currentMsg.Time < storedMsg.Time ||
currentMsg.Hash != storedMsg.Hash {
return true
}
currentParts := make(map[string]openCodeStorageFingerprintPart, len(currentMsg.Parts))
for _, part := range currentMsg.Parts {
currentParts[part.ID] = part
}
for _, storedPart := range storedMsg.Parts {
currentPart, ok := currentParts[storedPart.ID]
if !ok || currentPart.Time < storedPart.Time ||
currentPart.Hash != storedPart.Hash {
return true
}
}
}
return false
}
func HasOpenCodeStorageFingerprint(hash string) bool {
return strings.HasPrefix(hash, openCodeStorageFingerprintPrefix)
}
func buildOpenCodeStorageFingerprint(
msgs []openCodeMessageRow,
parts map[string][]openCodePartRow,
) string {
return buildOpenCodeStorageFingerprintWithSession(nil, msgs, parts)
}
func buildOpenCodeSessionFingerprint(
s openCodeSessionRow,
worktree string,
msgs []openCodeMessageRow,
parts map[string][]openCodePartRow,
) string {
return buildOpenCodeStorageFingerprintWithSession(
&openCodeStorageFingerprintSession{
ID: s.id,
ProjectID: s.projectID,
ParentID: s.parentID,
Title: s.title,
Worktree: worktree,
TimeCreated: s.timeCreated,
TimeUpdated: s.timeUpdated,
},
msgs,
parts,
)
}
func buildOpenCodeStorageFingerprintWithSession(
session *openCodeStorageFingerprintSession,
msgs []openCodeMessageRow,
parts map[string][]openCodePartRow,
) string {
fp := openCodeStorageFingerprint{
Session: session,
Messages: make(
[]openCodeStorageFingerprintMessage,
0, len(msgs),
),
}
for _, msg := range msgs {
partRows := append([]openCodePartRow(nil), parts[msg.id]...)
sort.Slice(partRows, func(i, j int) bool {
if partRows[i].timeCreated == partRows[j].timeCreated {
return partRows[i].id < partRows[j].id
}
return partRows[i].timeCreated < partRows[j].timeCreated
})
fpMsg := openCodeStorageFingerprintMessage{
ID: msg.id,
Time: msg.timeCreated,
Hash: openCodeStorageFingerprintHash(msg.data),
}
for _, part := range partRows {
fpMsg.Parts = append(fpMsg.Parts,
openCodeStorageFingerprintPart{
ID: part.id,
Time: part.timeCreated,
Hash: openCodeStorageFingerprintHash(part.data),
},
)
}
fp.Messages = append(fp.Messages, fpMsg)
}
raw, err := json.Marshal(fp)
if err != nil {
return ""
}
return openCodeStorageFingerprintPrefix + string(raw)
}
func decodeOpenCodeStorageFingerprint(
hash string,
) (openCodeStorageFingerprint, bool) {
if !strings.HasPrefix(hash, openCodeStorageFingerprintPrefix) {
return openCodeStorageFingerprint{}, false
}
raw := strings.TrimPrefix(hash, openCodeStorageFingerprintPrefix)
var fp openCodeStorageFingerprint
if err := json.Unmarshal([]byte(raw), &fp); err != nil {
return openCodeStorageFingerprint{}, false
}
return fp, true
}
func openCodeStorageFingerprintHash(raw string) string {
sum := sha256.Sum256([]byte(raw))
return fmt.Sprintf("%x", sum)
}
func openCodeSQLiteSessionMtime(
dbPath, sessionID string,
) (int64, error) {
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf(
"stat opencode db %s: %w", dbPath, err,
)
}
db, err := openOpenCodeDB(dbPath)
if err != nil {
return 0, err
}
defer db.Close()
row := db.QueryRow(
"SELECT time_updated FROM session WHERE id = ?",
sessionID,
)
var timeUpdated int64
if err := row.Scan(&timeUpdated); err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, fmt.Errorf(
"loading opencode session mtime %s#%s: %w",
dbPath, sessionID, err,
)
}
return timeUpdated * 1_000_000, nil
}
func openCodeStorageSessionMtime(
sessionPath string,
) (int64, error) {
info, err := os.Stat(sessionPath)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf(
"stat opencode session file %s: %w",
sessionPath, err,
)
}
root := filepath.Dir(filepath.Dir(filepath.Dir(
filepath.Dir(sessionPath),
)))
messageRoot := filepath.Join(root, "storage", "message")
partRoot := filepath.Join(root, "storage", "part")
sessionID := strings.TrimSuffix(
filepath.Base(sessionPath), filepath.Ext(sessionPath),
)
fileMtime := info.ModTime().UnixNano()
messageDir := filepath.Join(root, "storage", "message", sessionID)
fileMtime = max(fileMtime, statMtime(messageDir))
msgEntries, err := os.ReadDir(messageDir)
if err != nil {
if os.IsNotExist(err) {
fileMtime = max(fileMtime, statMtime(messageRoot))
return fileMtime, nil
}
return 0, fmt.Errorf(
"reading opencode message dir %s: %w",
messageDir, err,
)
}
for _, entry := range msgEntries {
if entry.IsDir() ||
!strings.HasSuffix(entry.Name(), ".json") {
continue
}
fileMtime = max(fileMtime, mustEntryMtime(entry))
messageID := strings.TrimSuffix(
entry.Name(), filepath.Ext(entry.Name()),
)
partDir := filepath.Join(root, "storage", "part", messageID)
fileMtime = max(fileMtime, statMtime(partDir))
partEntries, err := os.ReadDir(partDir)
if err != nil {
if os.IsNotExist(err) {
fileMtime = max(fileMtime, statMtime(partRoot))
continue
}
return 0, fmt.Errorf(
"reading opencode part dir %s: %w",
partDir, err,
)
}
for _, partEntry := range partEntries {
if partEntry.IsDir() ||
!strings.HasSuffix(partEntry.Name(), ".json") {
continue
}
fileMtime = max(fileMtime, mustEntryMtime(partEntry))
}
}
return fileMtime, nil
}
func mustEntryMtime(entry os.DirEntry) int64 {
info, err := entry.Info()
if err != nil {
return 0
}
return info.ModTime().UnixNano()
}
func statMtime(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.ModTime().UnixNano()
}
func millisToTime(ms int64) time.Time {
if ms == 0 {
return time.Time{}
}
return time.UnixMilli(ms)
}