You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ku...@apache.org on 2026/08/24 05:42:18 UTC
(maka) branch main updated: feat(storage): import opencode sessions through ExternalSessionAdapter (#3671)
This is an automated email from the ASF dual-hosted git repository.
likun666661 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 04836d3b8 feat(storage): import opencode sessions through ExternalSessionAdapter (#3671)
04836d3b8 is described below
commit 04836d3b8053c68b8d0b5e4101608bf66f5d1020
Author: Yunpei Zhou <Jo...@outlook.com>
AuthorDate: Mon Aug 24 13:42:13 2026 +0800
feat(storage): import opencode sessions through ExternalSessionAdapter (#3671)
* feat(storage): import opencode sessions through ExternalSessionAdapter
A user with history in opencode had no way to bring it into Maka. Adds
`OpenCodeSessionAdapter` and registers it, so the source-agnostic
importer gains a third source without the importer or the persistence
path changing.
The issue documented a `storage/session/{info,message,part}` JSON tree
and a migration between two directory layouts. Neither exists on
opencode 1.18.21: state is one SQLite database at
`~/.local/share/opencode/opencode.db`, which the CLI will name with
`opencode db path`. A conversation is `session` plus `message` and
`part`, each keeping its payload in an opaque `data` JSON column.
Turn state is derivable from the record, so this adapter does not
inherit the Ledger constraint discussed on #3142. Read from captured
sessions rather than from the binary:
- `step-finish` is durably persisted, carrying `reason`, tokens and cost;
- the field is `time.completed`, not `time.end`, and it is set on every
assistant message including an aborted one;
- `finish` is `stop` on a closing step and `tool-calls` on an
intermediate one, and is absent on a message that carries
`error.name: "MessageAbortedError"`.
The mapping therefore closes a turn as completed only on `stop`, as
aborted on an abort error, and as aborted when the last step asked for
tools and no answer followed — a run stopped between a call and its
result did not complete.
Two shapes had no captured sample, and both fail closed rather than
guess: a `tool` part whose `state.status` is not `completed` yields a
call with no synthesised result, and a child session is neither listed
nor readable as a conversation.
The fixture is a real 1.18.21 session — reads, a command, a write, and a
final aborted message — with paths rewritten and long tool output
truncated. Record shapes are verbatim.
Verified: 11 new tests pass, and the mapping's three load-bearing rules
were mutation-checked — dropping the non-completed tool filter, the
abort detection, or the child-session filter each turns the suite red.
Full @maka/storage suite: 931 tests, 0 failures.
Part of #2499. Fixes #3403
Generated-by: Claude Opus 5 via Claude Code
* fix(storage): report an unreadable opencode database instead of an empty one
Self-review of the adapter found three defects, each confirmed by
probing the built code before it was changed.
`#withDatabase` flattened every failure into `undefined`, and both
callers turned that into an empty result. Two consequences:
- a corrupt or locked database listed no sessions and answered
`readSession` with "opencode session not found", sending a user to
look for a session that exists in a database this could not open;
- a readable `session` row whose transcript tables were missing —
what a future opencode that renames them would produce — imported
successfully with zero messages. The user is told the import worked
and gets an empty conversation.
Failure is now reported. Discovery still answers an empty catalog when
the database file is absent, because an opencode that was installed and
never used is a normal state rather than a failure.
A user message carrying no text part also opened a turn, so a session
that began with one emitted a `turn_state` for a turn holding no
messages — a terminal verdict on a conversation that is not there. The
boundary still closes the previous turn; an assistant message that
follows opens its own.
Three tests added, one per defect. Reverting either fix turns them red:
restoring the swallowed read error fails 2, restoring the empty-prompt
turn fails 1.
@maka/storage: 934 tests, 0 failures.
Generated-by: Claude Opus 5 via Claude Code
* fix(storage): address the opencode adapter review findings
All three P2 findings from @M4n5ter's review are real. Two were new to
me; the third overlapped a defect I had already fixed but did not fix
completely.
**Terminal tool failures were dropped.** `status !== 'completed'` was
treated as "still in flight", but opencode's tool state union is
`pending | running | completed | error`, and `error` is terminal —
confirmed against the installed 1.18.21 binary, which records
`{ status: "error", input, error: <string>, metadata, time }`. A failed
call therefore became a call with no answer inside a turn that a later
`finish: "stop"` marked completed: a transcript asserting the tool never
replied when it replied with a failure. `error` now emits a
`tool_result` with `isError: true` carrying the recorded message;
`pending` and `running` still get none, because those are the calls that
genuinely had no answer.
**Source part order was rewritten.** The mapping ran three passes —
reasoning, then text, then tool — so a message opencode recorded as
`text -> reasoning -> tool` imported as `reasoning -> text -> tool`.
opencode accepts that order and its own replay preserves it. Parts are
now walked once, in the order the session recorded them.
**Undecodable rows were silently skipped.** `9a0e526` made a failed read
throw, but a row that would not decode was still filtered out, so a
transcript could import a message or a part short while reporting
success. A selected import now carries what the session recorded or
fails. Discovery stays best-effort.
Four tests added. Reverting any of the three fixes turns the suite red:
dropping terminal failures fails 1, restoring the silent row filter
fails 1, and re-bucketing parts by type fails 1.
@maka/storage: 938 tests, 0 failures.
Generated-by: Claude Opus 5 via Claude Code
---
.../fixtures/opencode-session-1.18.21.json | 1129 ++++++++++++++++++++
.../src/__tests__/opencode-session-adapter.test.ts | 525 +++++++++
packages/storage/src/external-session-adapters.ts | 6 +
packages/storage/src/opencode-session-adapter.ts | 545 ++++++++++
4 files changed, 2205 insertions(+)
diff --git a/packages/storage/src/__tests__/fixtures/opencode-session-1.18.21.json b/packages/storage/src/__tests__/fixtures/opencode-session-1.18.21.json
new file mode 100644
index 000000000..fa0b12f11
--- /dev/null
+++ b/packages/storage/src/__tests__/fixtures/opencode-session-1.18.21.json
@@ -0,0 +1,1129 @@
+{
+ "session": {
+ "id": "ses_fce2f4711ffe9T93zuJ3omP6ls",
+ "title": "一句话介绍项目用途",
+ "directory": "/workspace/project",
+ "time_created": 1787542157550,
+ "time_updated": 1787542238118,
+ "time_archived": null,
+ "parent_id": null
+ },
+ "messages": [
+ {
+ "id": "msg_031d0b8fd001Nbw6n5AL4sQmd3",
+ "time_created": 1787542157565,
+ "data": {
+ "role": "user",
+ "time": {
+ "created": 1787542157565
+ },
+ "agent": "build",
+ "model": {
+ "providerID": "opencode",
+ "modelID": "big-pickle"
+ },
+ "summary": {
+ "diffs": []
+ }
+ }
+ },
+ {
+ "id": "msg_031d0b905001dnnA5x2JmR388S",
+ "time_created": 1787542157573,
+ "data": {
+ "parentID": "msg_031d0b8fd001Nbw6n5AL4sQmd3",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 12647,
+ "input": 12549,
+ "output": 27,
+ "reasoning": 7,
+ "cache": {
+ "write": 0,
+ "read": 64
+ }
+ },
+ "modelID": "big-pickle",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542157573,
+ "completed": 1787542161533
+ },
+ "finish": "tool-calls"
+ }
+ },
+ {
+ "id": "msg_031d0c87e001RuyvmjLFMox2fU",
+ "time_created": 1787542161534,
+ "data": {
+ "parentID": "msg_031d0b8fd001Nbw6n5AL4sQmd3",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 12795,
+ "input": 12635,
+ "output": 32,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 128
+ }
+ },
+ "modelID": "big-pickle",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542161534,
+ "completed": 1787542163984
+ },
+ "finish": "tool-calls"
+ }
+ },
+ {
+ "id": "msg_031d0d2110010x83MsLIrJR7nL",
+ "time_created": 1787542163985,
+ "data": {
+ "parentID": "msg_031d0b8fd001Nbw6n5AL4sQmd3",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 13890,
+ "input": 1078,
+ "output": 69,
+ "reasoning": 7,
+ "cache": {
+ "write": 0,
+ "read": 12736
+ }
+ },
+ "modelID": "big-pickle",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542163985,
+ "completed": 1787542167284
+ },
+ "finish": "stop"
+ }
+ },
+ {
+ "id": "msg_031d1592b001N3oiZ93Zp1Juwn",
+ "time_created": 1787542198571,
+ "data": {
+ "role": "user",
+ "time": {
+ "created": 1787542198571
+ },
+ "agent": "build",
+ "model": {
+ "providerID": "opencode",
+ "modelID": "x-preview-f-free"
+ },
+ "summary": {
+ "diffs": []
+ }
+ }
+ },
+ {
+ "id": "msg_031d15931001tdbFlaRtVq49tw",
+ "time_created": 1787542198577,
+ "data": {
+ "parentID": "msg_031d1592b001N3oiZ93Zp1Juwn",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 13932,
+ "input": 13774,
+ "output": 30,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 128
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542198577,
+ "completed": 1787542200929
+ },
+ "finish": "tool-calls"
+ }
+ },
+ {
+ "id": "msg_031d16263001s2RyQc8nAjbFa1",
+ "time_created": 1787542200931,
+ "data": {
+ "parentID": "msg_031d1592b001N3oiZ93Zp1Juwn",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 14228,
+ "input": 6436,
+ "output": 112,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 7680
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542200931,
+ "completed": 1787542204723
+ },
+ "finish": "stop"
+ }
+ },
+ {
+ "id": "msg_031d18893001PLwWuotY418nRE",
+ "time_created": 1787542210707,
+ "data": {
+ "role": "user",
+ "time": {
+ "created": 1787542210707
+ },
+ "agent": "build",
+ "model": {
+ "providerID": "opencode",
+ "modelID": "x-preview-f-free"
+ },
+ "summary": {
+ "diffs": []
+ }
+ }
+ },
+ {
+ "id": "msg_031d18898001WfWk31s0klN8Jz",
+ "time_created": 1787542210712,
+ "data": {
+ "parentID": "msg_031d18893001PLwWuotY418nRE",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 14380,
+ "input": 167,
+ "output": 100,
+ "reasoning": 33,
+ "cache": {
+ "write": 0,
+ "read": 14080
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542210712,
+ "completed": 1787542219108
+ },
+ "finish": "tool-calls"
+ }
+ },
+ {
+ "id": "msg_031d1a965001aIRUX8NsnuvCrE",
+ "time_created": 1787542219109,
+ "data": {
+ "parentID": "msg_031d18893001PLwWuotY418nRE",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 14408,
+ "input": 181,
+ "output": 19,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 14208
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542219109,
+ "completed": 1787542221294
+ },
+ "finish": "stop"
+ }
+ },
+ {
+ "id": "msg_031d1b69a001JXYQS9MBoAkYtB",
+ "time_created": 1787542222490,
+ "data": {
+ "role": "user",
+ "time": {
+ "created": 1787542222490
+ },
+ "agent": "build",
+ "model": {
+ "providerID": "opencode",
+ "modelID": "x-preview-f-free"
+ },
+ "summary": {
+ "diffs": []
+ }
+ }
+ },
+ {
+ "id": "msg_031d1b6a2001eeHxt4Q9Co2NXI",
+ "time_created": 1787542222498,
+ "data": {
+ "parentID": "msg_031d1b69a001JXYQS9MBoAkYtB",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 14475,
+ "input": 89,
+ "output": 39,
+ "reasoning": 11,
+ "cache": {
+ "write": 0,
+ "read": 14336
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542222498,
+ "completed": 1787542225144
+ },
+ "finish": "tool-calls"
+ }
+ },
+ {
+ "id": "msg_031d1c0fa001ZfuecZ1jZ0G6qu",
+ "time_created": 1787542225146,
+ "data": {
+ "parentID": "msg_031d1b69a001JXYQS9MBoAkYtB",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 14571,
+ "input": 106,
+ "output": 65,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 14400
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542225146,
+ "completed": 1787542230115
+ },
+ "finish": "tool-calls"
+ }
+ },
+ {
+ "id": "msg_031d1d464001yrIcOwKEu31W78",
+ "time_created": 1787542230116,
+ "data": {
+ "parentID": "msg_031d1b69a001JXYQS9MBoAkYtB",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "total": 14807,
+ "input": 166,
+ "output": 165,
+ "reasoning": 12,
+ "cache": {
+ "write": 0,
+ "read": 14464
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542230116,
+ "completed": 1787542235194
+ },
+ "finish": "stop"
+ }
+ },
+ {
+ "id": "msg_031d1f380001T2EOrAi91N51p8",
+ "time_created": 1787542238080,
+ "data": {
+ "role": "user",
+ "time": {
+ "created": 1787542238080
+ },
+ "agent": "build",
+ "model": {
+ "providerID": "opencode",
+ "modelID": "x-preview-f-free"
+ },
+ "summary": {
+ "diffs": []
+ }
+ }
+ },
+ {
+ "id": "msg_031d1f384001Ib3k0DfJJadMo9",
+ "time_created": 1787542238084,
+ "data": {
+ "parentID": "msg_031d1f380001T2EOrAi91N51p8",
+ "role": "assistant",
+ "mode": "build",
+ "agent": "build",
+ "path": {
+ "cwd": "/workspace/project",
+ "root": "/workspace/project"
+ },
+ "cost": 0,
+ "tokens": {
+ "input": 0,
+ "output": 0,
+ "reasoning": 0,
+ "cache": {
+ "read": 0,
+ "write": 0
+ }
+ },
+ "modelID": "x-preview-f-free",
+ "providerID": "opencode",
+ "time": {
+ "created": 1787542238084,
+ "completed": 1787542255224
+ },
+ "error": {
+ "name": "MessageAbortedError",
+ "data": {
+ "message": "Aborted"
+ }
+ }
+ }
+ }
+ ],
+ "parts": [
+ {
+ "id": "prt_031d0b8fe001D5XNc14kaZDeJs",
+ "message_id": "msg_031d0b8fd001Nbw6n5AL4sQmd3",
+ "time_created": 1787542157568,
+ "data": {
+ "type": "text",
+ "text": "用一句话说说这个项目是干什么的"
+ }
+ },
+ {
+ "id": "prt_031d0c59d001m4KSHYYbvOAzVl",
+ "message_id": "msg_031d0b905001dnnA5x2JmR388S",
+ "time_created": 1787542160797,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d0c5a1001GAGCjK2gbOzS1J",
+ "message_id": "msg_031d0b905001dnnA5x2JmR388S",
+ "time_created": 1787542160802,
+ "data": {
+ "type": "reasoning",
+ "text": "The user is asking me to describe what this project does in one sentence. Let me explore the project structure first.",
+ "time": {
+ "start": 1787542160801,
+ "end": 1787542161292
+ }
+ }
+ },
+ {
+ "id": "prt_031d0c78e001kIqYf6SVWZOA7E",
+ "message_id": "msg_031d0b905001dnnA5x2JmR388S",
+ "time_created": 1787542161294,
+ "data": {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "call_9f32cef56089435d894b8d17",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "ls"
+ },
+ "output": "apps\nARCHITECTURE.md\nARCHITECTURE.zh-CN.md\nbiome.jsonc\nCHANGELOG.md\nCONTRIBUTING.md\nCONTRIBUTING.zh-CN.md\nDESIGN.md\nDISCLAIMER-WIP\ndocs\nexperiments\nknip.json\nLICENSE\nmaka-proposal-zh-review.txt\nnode_modules\nNOTICE\npackage-lock.json\npackage.json\npackages\npatches\nREADME.md\nREADME.zh-CN.md\nscripts\nSECURITY.md\nskills\ntsconfig.base.json\ntsconfig.lib.json\n",
+ "metadata": {
+ "output": "apps\nARCHITECTURE.md\nARCHITECTURE.zh-CN.md\nbiome.jsonc\nCHANGELOG.md\nCONTRIBUTING.md\nCONTRIBUTING.zh-CN.md\nDESIGN.md\nDISCLAIMER-WIP\ndocs\nexperiments\nknip.json\nLICENSE\nmaka-proposal-zh-review.txt\nnode_modules\nNOTICE\npackage-lock.json\npackage.json\npackages\npatches\nREADME.md\nREADME.zh-CN.md\nscripts\nSECURITY.md\nskills\ntsconfig.base.json\ntsconfig.lib.json\n",
+ "exit": 0,
+ "truncated": false
+ },
+ "title": "ls",
+ "time": {
+ "start": 1787542161458,
+ "end": 1787542161460
+ }
+ }
+ }
+ },
+ {
+ "id": "prt_031d0c859001mWD04w2n748NJf",
+ "message_id": "msg_031d0b905001dnnA5x2JmR388S",
+ "time_created": 1787542161497,
+ "data": {
+ "reason": "tool-calls",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 12647,
+ "input": 12549,
+ "output": 27,
+ "reasoning": 7,
+ "cache": {
+ "write": 0,
+ "read": 64
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d0cfbd001dyfFy1EKN37KCG",
+ "message_id": "msg_031d0c87e001RuyvmjLFMox2fU",
+ "time_created": 1787542163389,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d0cfc1001RNn15cdTPNeWgW",
+ "message_id": "msg_031d0c87e001RuyvmjLFMox2fU",
+ "time_created": 1787542163393,
+ "data": {
+ "type": "tool",
+ "tool": "read",
+ "callID": "call_906348e66fb247369ac6937a",
+ "state": {
+ "status": "completed",
+ "input": {
+ "filePath": "/workspace/project/README.zh-CN.md",
+ "limit": 50
+ },
+ "output": "<path>/workspace/project/README.zh-CN.md</path>\n<type>file</type>\n<content>\n1: <!--\n2: Licensed to the Apache Software Foundation (ASF) under one\n3: or more contributor license agreements. See the NOTICE file\n4: distributed with this work for additional information\n5: regarding copyright ownership. The ASF licenses this file\n6: to you under the Apache License, Version 2.0 (the\n7: \"Li\n… truncated for the fixture …",
+ "metadata": {
+ "preview": "<!--\n Licensed to the Apache Software Foundation (ASF) under one\n or more contributor license agreements. See the NOTICE file\n distributed with this work for additional information\n regarding copyright ownership. The ASF licenses this file\n to you under the Apache License, Version 2.0 (the\n \"License\"); you may not use this file except in compliance\n with the License. You may obtain a cop\n… truncated for the fixture …",
+ "truncated": true,
+ "loaded": [],
+ "display": {
+ "type": "file",
+ "path": "/workspace/project/README.zh-CN.md",
+ "text": "<!--\n Licensed to the Apache Software Foundation (ASF) under one\n or more contributor license agreements. See the NOTICE file\n distributed with this work for additional information\n regarding copyright ownership. The ASF licenses this file\n to you under the Apache License, Version 2.0 (the\n \"License\"); you may not use this file except in compliance\n with the License. You may obtain a copy of the License at\n\n http://www.apache.org/licenses/ [...]
+ "lineStart": 1,
+ "lineEnd": 50,
+ "totalLines": 273,
+ "truncated": true
+ }
+ },
+ "title": "README.zh-CN.md",
+ "time": {
+ "start": 1787542163899,
+ "end": 1787542163904
+ }
+ }
+ }
+ },
+ {
+ "id": "prt_031d0d1ed001BSbQDk4NHejek5",
+ "message_id": "msg_031d0c87e001RuyvmjLFMox2fU",
+ "time_created": 1787542163949,
+ "data": {
+ "reason": "tool-calls",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 12795,
+ "input": 12635,
+ "output": 32,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 128
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d0d889001Ne8TGtoGtv5rr5",
+ "message_id": "msg_031d0d2110010x83MsLIrJR7nL",
+ "time_created": 1787542165641,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d0d88b0014RoFEuQZpOBsX1",
+ "message_id": "msg_031d0d2110010x83MsLIrJR7nL",
+ "time_created": 1787542165644,
+ "data": {
+ "type": "reasoning",
+ "text": "The README says it's a local-first Agent workbench for real work. Let me give a one-sentence answer.",
+ "time": {
+ "start": 1787542165643,
+ "end": 1787542166123
+ }
+ }
+ },
+ {
+ "id": "prt_031d0da6d001OOEgw51AMbMh6C",
+ "message_id": "msg_031d0d2110010x83MsLIrJR7nL",
+ "time_created": 1787542166125,
+ "data": {
+ "type": "text",
+ "text": "Maka 是一个本地优先(local-first)的 Agent 工作台桌面应用:它在沙箱中阅读项目、执行工具,并把模型消息和工具调用保存为可恢复的运行事实,所有数据都留在本机。",
+ "time": {
+ "start": 1787542166125,
+ "end": 1787542167213
+ }
+ }
+ },
+ {
+ "id": "prt_031d0decf001eyTm5mo2Q1GPQw",
+ "message_id": "msg_031d0d2110010x83MsLIrJR7nL",
+ "time_created": 1787542167247,
+ "data": {
+ "reason": "stop",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 13890,
+ "input": 1078,
+ "output": 69,
+ "reasoning": 7,
+ "cache": {
+ "write": 0,
+ "read": 12736
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d1592e001oX44tmTYjdJaPq",
+ "message_id": "msg_031d1592b001N3oiZ93Zp1Juwn",
+ "time_created": 1787542198574,
+ "data": {
+ "type": "text",
+ "text": "读一下 README.md 的前 10 行"
+ }
+ },
+ {
+ "id": "prt_031d160ba001Pqsw3VcUe1K4FE",
+ "message_id": "msg_031d15931001tdbFlaRtVq49tw",
+ "time_created": 1787542200506,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d160bc001ebZI75romr2And",
+ "message_id": "msg_031d15931001tdbFlaRtVq49tw",
+ "time_created": 1787542200508,
+ "data": {
+ "type": "tool",
+ "tool": "read",
+ "callID": "call_14c3d72f312a4fe28dfd44e2",
+ "state": {
+ "status": "completed",
+ "input": {
+ "filePath": "/workspace/project/README.md",
+ "limit": 10
+ },
+ "output": "<path>/workspace/project/README.md</path>\n<type>file</type>\n<content>\n1: <!--\n2: Licensed to the Apache Software Foundation (ASF) under one\n3: or more contributor license agreements. See the NOTICE file\n4: distributed with this work for additional information\n5: regarding copyright ownership. The ASF licenses this file\n6: to you under the Apache License, Version 2.0 (the\n7: \"License\"\n… truncated for the fixture …",
+ "metadata": {
+ "preview": "<!--\n Licensed to the Apache Software Foundation (ASF) under one\n or more contributor license agreements. See the NOTICE file\n distributed with this work for additional information\n regarding copyright ownership. The ASF licenses this file\n to you under the Apache License, Version 2.0 (the\n \"License\"); you may not use this file except in compliance\n with the License. You may obtain a cop\n… truncated for the fixture …",
+ "truncated": true,
+ "loaded": [],
+ "display": {
+ "type": "file",
+ "path": "/workspace/project/README.md",
+ "text": "<!--\n Licensed to the Apache Software Foundation (ASF) under one\n or more contributor license agreements. See the NOTICE file\n distributed with this work for additional information\n regarding copyright ownership. The ASF licenses this file\n to you under the Apache License, Version 2.0 (the\n \"License\"); you may not use this file except in compliance\n with the License. You may obtain a copy of the License at\n\n http://www.apache.org/licenses/ [...]
+ "lineStart": 1,
+ "lineEnd": 10,
+ "totalLines": 277,
+ "truncated": true
+ }
+ },
+ "title": "README.md",
+ "time": {
+ "start": 1787542200839,
+ "end": 1787542200845
+ }
+ }
+ }
+ },
+ {
+ "id": "prt_031d1623d00120UTGs3MWp004b",
+ "message_id": "msg_031d15931001tdbFlaRtVq49tw",
+ "time_created": 1787542200893,
+ "data": {
+ "reason": "tool-calls",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 13932,
+ "input": 13774,
+ "output": 30,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 128
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d16a2e0017u8i7JZPuDTSGA",
+ "message_id": "msg_031d16263001s2RyQc8nAjbFa1",
+ "time_created": 1787542202927,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d16a32001e4asvNOm1nhvrj",
+ "message_id": "msg_031d16263001s2RyQc8nAjbFa1",
+ "time_created": 1787542202930,
+ "data": {
+ "type": "text",
+ "text": "```\n<!--\n Licensed to the Apache Software Foundation (ASF) under one\n or more contributor license agreements. See the NOTICE file\n distributed with this work for additional information\n regarding copyright ownership. The ASF licenses this file\n to you under the Apache License, Version 2.0 (the\n \"License\"); you may not use this file except in compliance\n with the License. You may obtain a copy of the License at\n\n http://www.apache.org/licenses/L [...]
+ "time": {
+ "start": 1787542202930,
+ "end": 1787542204651
+ }
+ }
+ },
+ {
+ "id": "prt_031d17111001MFBGtuwNj6WC9a",
+ "message_id": "msg_031d16263001s2RyQc8nAjbFa1",
+ "time_created": 1787542204689,
+ "data": {
+ "reason": "stop",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 14228,
+ "input": 6436,
+ "output": 112,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 7680
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d18893002LVYt5oZROkANKe",
+ "message_id": "msg_031d18893001PLwWuotY418nRE",
+ "time_created": 1787542210709,
+ "data": {
+ "type": "text",
+ "text": "在 /tmp 建一个 opencode-probe.txt,写一行 hello\""
+ }
+ },
+ {
+ "id": "prt_031d18e27001wXOps7uogLAj0G",
+ "message_id": "msg_031d18898001WfWk31s0klN8Jz",
+ "time_created": 1787542212135,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d18e29001HCx9aLsjKFF37L",
+ "message_id": "msg_031d18898001WfWk31s0klN8Jz",
+ "time_created": 1787542212137,
+ "data": {
+ "type": "reasoning",
+ "text": "The user wants me to create a file at /tmp/opencode-probe.txt with one line \"hello\". The system prompt mentions using /var/folders/n7/_0nbsnr54mb71c35vndm8wd80000gn/T/opencode for temporary work, but the user explicitly asked for /tmp. On macOS, /tmp is a symlink to /private/tmp. The user explicitly requested /tmp/opencode-probe.txt, so I'll do exactly that.\n\nI should use the write tool to create the file.",
+ "time": {
+ "start": 1787542212137,
+ "end": 1787542214269
+ }
+ }
+ },
+ {
+ "id": "prt_031d1967f0011O5EZnJCKsPskp",
+ "message_id": "msg_031d18898001WfWk31s0klN8Jz",
+ "time_created": 1787542214271,
+ "data": {
+ "type": "tool",
+ "tool": "write",
+ "callID": "call_b933747bfa8642599a0abab4",
+ "state": {
+ "status": "completed",
+ "input": {
+ "filePath": "/tmp/opencode-probe.txt",
+ "content": "hello\n"
+ },
+ "output": "Wrote file successfully.",
+ "metadata": {
+ "diagnostics": {},
+ "filepath": "/tmp/opencode-probe.txt",
+ "exists": false,
+ "truncated": false
+ },
+ "title": "../../../../tmp/opencode-probe.txt",
+ "time": {
+ "start": 1787542214584,
+ "end": 1787542219038
+ }
+ }
+ }
+ },
+ {
+ "id": "prt_031d1a93f001UtW1xKwBJU0goa",
+ "message_id": "msg_031d18898001WfWk31s0klN8Jz",
+ "time_created": 1787542219071,
+ "data": {
+ "reason": "tool-calls",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 14380,
+ "input": 167,
+ "output": 100,
+ "reasoning": 33,
+ "cache": {
+ "write": 0,
+ "read": 14080
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d1af78001WARl35IoDZw1XS",
+ "message_id": "msg_031d1a965001aIRUX8NsnuvCrE",
+ "time_created": 1787542220664,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d1af7b001Ef6aZucz3nPq7Y",
+ "message_id": "msg_031d1a965001aIRUX8NsnuvCrE",
+ "time_created": 1787542220667,
+ "data": {
+ "type": "text",
+ "text": "已写入 `/tmp/opencode-probe.txt`,内容为 `hello`。",
+ "time": {
+ "start": 1787542220667,
+ "end": 1787542221215
+ }
+ }
+ },
+ {
+ "id": "prt_031d1b1cc001SEJkjUMfEBCXLY",
+ "message_id": "msg_031d1a965001aIRUX8NsnuvCrE",
+ "time_created": 1787542221261,
+ "data": {
+ "reason": "stop",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 14408,
+ "input": 181,
+ "output": 19,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 14208
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d1b69b001lfbD8LwEKWfRyS",
+ "message_id": "msg_031d1b69a001JXYQS9MBoAkYtB",
+ "time_created": 1787542222493,
+ "data": {
+ "type": "text",
+ "text": "统计 packages 下每个包有多少个 ts 文件,然后告诉我最大的三个"
+ }
+ },
+ {
+ "id": "prt_031d1bd29001Mm17IAoSxJmSnO",
+ "message_id": "msg_031d1b6a2001eeHxt4Q9Co2NXI",
+ "time_created": 1787542224169,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d1bd2e0012HTFZecdNb97ng",
+ "message_id": "msg_031d1b6a2001eeHxt4Q9Co2NXI",
+ "time_created": 1787542224174,
+ "data": {
+ "type": "reasoning",
+ "text": "The user wants me to count the number of .ts files in each package under packages/, then tell them the top 3 largest.\n\nLet me first look at the structure of packages/.",
+ "time": {
+ "start": 1787542224174,
+ "end": 1787542224914
+ }
+ }
+ },
+ {
+ "id": "prt_031d1c013001aVPd4Ho2IgOr2b",
+ "message_id": "msg_031d1b6a2001eeHxt4Q9Co2NXI",
+ "time_created": 1787542224915,
+ "data": {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "call_11b0a7c3f3724e669956b84a",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "ls packages/"
+ },
+ "output": "cli\ncode-mode\ncomputer-use\ncore\neval\nheadless\nmcp\nruntime\nruntime-host\nstorage\nui\n",
+ "metadata": {
+ "output": "cli\ncode-mode\ncomputer-use\ncore\neval\nheadless\nmcp\nruntime\nruntime-host\nstorage\nui\n",
+ "exit": 0,
+ "truncated": false
+ },
+ "title": "ls packages/",
+ "time": {
+ "start": 1787542225066,
+ "end": 1787542225068
+ }
+ }
+ }
+ },
+ {
+ "id": "prt_031d1c0d50013Gb0DPjHok9H5x",
+ "message_id": "msg_031d1b6a2001eeHxt4Q9Co2NXI",
+ "time_created": 1787542225109,
+ "data": {
+ "reason": "tool-calls",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 14475,
+ "input": 89,
+ "output": 39,
+ "reasoning": 11,
+ "cache": {
+ "write": 0,
+ "read": 14336
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d1cf76001kTqthTyf2hXAXL",
+ "message_id": "msg_031d1c0fa001ZfuecZ1jZ0G6qu",
+ "time_created": 1787542228854,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d1cf78001zcKKmv7YBJCWWP",
+ "message_id": "msg_031d1c0fa001ZfuecZ1jZ0G6qu",
+ "time_created": 1787542228856,
+ "data": {
+ "type": "tool",
+ "tool": "bash",
+ "callID": "call_77f09d87414245c98fe28d4f",
+ "state": {
+ "status": "completed",
+ "input": {
+ "command": "for pkg in packages/*/; do echo \"$(find \"$pkg\" -name '*.ts' -not -path '*/node_modules/*' -not -path '*/dist/*' | wc -l | tr -d ' ') $pkg\"; done | sort -rn"
+ },
+ "output": "484 packages/runtime/\n358 packages/runtime-host/\n236 packages/core/\n221 packages/storage/\n88 packages/ui/\n84 packages/cli/\n33 packages/eval/\n27 packages/mcp/\n17 packages/computer-use/\n0 packages/headless/\n0 packages/code-mode/\n",
+ "metadata": {
+ "output": "484 packages/runtime/\n358 packages/runtime-host/\n236 packages/core/\n221 packages/storage/\n88 packages/ui/\n84 packages/cli/\n33 packages/eval/\n27 packages/mcp/\n17 packages/computer-use/\n0 packages/headless/\n0 packages/code-mode/\n",
+ "exit": 0,
+ "truncated": false
+ },
+ "title": "for pkg in packages/*/; do echo \"$(find \"$pkg\" -name '*.ts' -not -path '*/node_modules/*' -not -path '*/dist/*' | wc -l | tr -d ' ') $pkg\"; done | sort -rn",
+ "time": {
+ "start": 1787542230033,
+ "end": 1787542230035
+ }
+ }
+ }
+ },
+ {
+ "id": "prt_031d1d437001DURaDpB2X29f26",
+ "message_id": "msg_031d1c0fa001ZfuecZ1jZ0G6qu",
+ "time_created": 1787542230072,
+ "data": {
+ "reason": "tool-calls",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 14571,
+ "input": 106,
+ "output": 65,
+ "reasoning": 0,
+ "cache": {
+ "write": 0,
+ "read": 14400
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d1da600011aY6RMo4YZ2SLK",
+ "message_id": "msg_031d1d464001yrIcOwKEu31W78",
+ "time_created": 1787542231648,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d1da620015XHkaxBbWejFTV",
+ "message_id": "msg_031d1d464001yrIcOwKEu31W78",
+ "time_created": 1787542231650,
+ "data": {
+ "type": "reasoning",
+ "text": "The counts are done. Top 3: runtime (484), runtime-host (358), core (236). I excluded node_modules and dist to count source files.",
+ "time": {
+ "start": 1787542231650,
+ "end": 1787542232528
+ }
+ }
+ },
+ {
+ "id": "prt_031d1ddd0001KQiKcZYOMSRaxm",
+ "message_id": "msg_031d1d464001yrIcOwKEu31W78",
+ "time_created": 1787542232529,
+ "data": {
+ "type": "text",
+ "text": "排除 `node_modules` 和 `dist` 后,各包 `.ts` 文件数:\n\n| 包 | 数量 |\n|---|---|\n| **runtime** | **484** |\n| **runtime-host** | **358** |\n| **core** | **236** |\n| storage | 221 |\n| ui | 88 |\n| cli | 84 |\n| eval | 33 |\n| mcp | 27 |\n| computer-use | 17 |\n| headless / code-mode | 0 |\n\n最大的三个:`packages/runtime`(484)、`packages/runtime-host`(358)、`packages/core`(236)。",
+ "time": {
+ "start": 1787542232528,
+ "end": 1787542235125
+ }
+ }
+ },
+ {
+ "id": "prt_031d1e818001MXmRWESzjhU2OS",
+ "message_id": "msg_031d1d464001yrIcOwKEu31W78",
+ "time_created": 1787542235160,
+ "data": {
+ "reason": "stop",
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-finish",
+ "tokens": {
+ "total": 14807,
+ "input": 166,
+ "output": 165,
+ "reasoning": 12,
+ "cache": {
+ "write": 0,
+ "read": 14464
+ }
+ },
+ "cost": 0
+ }
+ },
+ {
+ "id": "prt_031d1f3810011l6BPRi9IS1kcY",
+ "message_id": "msg_031d1f380001T2EOrAi91N51p8",
+ "time_created": 1787542238081,
+ "data": {
+ "type": "text",
+ "text": "把 packages 下所有 ts 文件逐个读一遍并总结"
+ }
+ },
+ {
+ "id": "prt_031d1f8f8001Pst0J59dryL4Sd",
+ "message_id": "msg_031d1f384001Ib3k0DfJJadMo9",
+ "time_created": 1787542239480,
+ "data": {
+ "snapshot": "776cfbaf03c37ce6372fef259b8e7b27c6891e5c",
+ "type": "step-start"
+ }
+ },
+ {
+ "id": "prt_031d1f8fa001YLSHQVgbZmRtQg",
+ "message_id": "msg_031d1f384001Ib3k0DfJJadMo9",
+ "time_created": 1787542239482,
+ "data": {
+ "type": "reasoning",
+ "text": "The user is asking me to read all TypeScript files under packages/ one by one and summarize them. That's 484 + 358 + 236 + 221 + 88 + 84 + 33 + 27 + 17 = 1,748 files. This is a massive request that would be impractical to do literally — reading 1,748 files individually would consume enormous context and time.\n\nLet me think about what the user actually wants: they probably want a comprehensive summary of the codebase. The reasonable approach:\n\n1. I should push back ge [...]
+ "time": {
+ "start": 1787542239482,
+ "end": 1787542255223
+ }
+ }
+ }
+ ]
+}
diff --git a/packages/storage/src/__tests__/opencode-session-adapter.test.ts b/packages/storage/src/__tests__/opencode-session-adapter.test.ts
new file mode 100644
index 000000000..056d8ed90
--- /dev/null
+++ b/packages/storage/src/__tests__/opencode-session-adapter.test.ts
@@ -0,0 +1,525 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { DatabaseSync } from 'node:sqlite';
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { decodeCanonicalMessage } from '@maka/core/session';
+import { createExternalSessionAdapterRegistry } from '../external-session-adapters.js';
+import {
+ OPENCODE_SESSION_ADAPTER_ID,
+ OpenCodeSessionAdapter,
+} from '../opencode-session-adapter.js';
+
+// Captured from opencode 1.18.21 with `opencode db`: one session that reads
+// files, runs commands, writes one, and ends on an aborted message. Paths are
+// rewritten and long tool output truncated; the record shapes are verbatim.
+// Resolved against `src` rather than the compiled location: the fixture is
+// data, so it is not emitted into `dist` beside the test that reads it.
+const FIXTURE = fileURLToPath(
+ new URL('../../src/__tests__/fixtures/opencode-session-1.18.21.json', import.meta.url),
+);
+
+interface Fixture {
+ session: {
+ id: string;
+ title: string;
+ directory: string;
+ time_created: number;
+ time_updated: number;
+ time_archived: number | null;
+ parent_id: string | null;
+ };
+ messages: { id: string; time_created: number; data: unknown }[];
+ parts: { id: string; message_id: string; time_created: number; data: unknown }[];
+}
+
+describe('OpenCodeSessionAdapter', () => {
+ test('reports absent when no database exists', async () => {
+ await withOpenCodeHome(async (home) => {
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ assert.equal(await adapter.detect(), false);
+ assert.deepEqual(await adapter.listSessions(), []);
+ });
+ });
+
+ test('an unreadable database is reported, not answered as an empty catalog', async () => {
+ await withOpenCodeHome(async (home) => {
+ await writeFile(join(home, 'opencode.db'), 'not a database');
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ // The file is there, so the source is present; what fails is reading it.
+ assert.equal(await adapter.detect(), true);
+ await assert.rejects(adapter.listSessions(), /could not be (opened|read)/u);
+ // Reporting "not found" here would send a user looking for a session
+ // that exists in a database this could not open.
+ await assert.rejects(adapter.readSession('ses_anything'), /could not be (opened|read)/u);
+ });
+ });
+
+ test('a session whose transcript tables are missing fails instead of importing empty', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home, undefined, { omitPartTable: true });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ // The session row still reads, so the catalog offers it...
+ assert.equal((await adapter.listSessions()).length, 1);
+ // ...and the import must not answer with a conversation of nothing. A
+ // future opencode that renames these tables would otherwise silently
+ // import every session as empty and report success.
+ await assert.rejects(adapter.readSession(fixture.session.id), /could not be read/u);
+ });
+ });
+
+ test('a user message with no prompt does not produce a turn holding no messages', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home, (f) => {
+ // One user message, no parts at all.
+ f.messages = [{ id: 'msg_empty', time_created: 1, data: { role: 'user' } }];
+ f.parts = [];
+ return f;
+ });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+ assert.deepEqual(
+ session.messages,
+ [],
+ 'no turn_state is emitted for a turn that holds nothing',
+ );
+ });
+ });
+
+ test('lists a captured session with its directory and title', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home);
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ assert.equal(await adapter.detect(), true);
+ const sessions = await adapter.listSessions();
+ assert.equal(sessions.length, 1);
+ assert.equal(sessions[0]?.id, fixture.session.id);
+ assert.equal(sessions[0]?.cwd, fixture.session.directory);
+ assert.equal(sessions[0]?.name, fixture.session.title);
+ assert.equal(sessions[0]?.createdAt, fixture.session.time_created);
+ });
+ });
+
+ test('a cwd query selects by the session directory', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home);
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ assert.equal((await adapter.listSessions({ cwd: fixture.session.directory })).length, 1);
+ assert.equal((await adapter.listSessions({ cwd: '/somewhere/else' })).length, 0);
+ });
+ });
+
+ test('child sessions are neither listed nor readable as conversations', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home, (f) => {
+ f.session.parent_id = 'ses_parent';
+ return f;
+ });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ assert.deepEqual(await adapter.listSessions(), []);
+ await assert.rejects(adapter.readSession(fixture.session.id), /child of another session/u);
+ });
+ });
+
+ test('converts the captured session into canonical Maka messages', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home);
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+
+ assert.equal(session.sourceSessionId, fixture.session.id);
+ assert.equal(session.metadata.cwd, fixture.session.directory);
+ // Every emitted message has to survive Maka's own decoder, or the
+ // import would be rejected at the persistence boundary rather than here.
+ for (const message of session.messages) {
+ assert.ok(decodeCanonicalMessage(message), `undecodable: ${message.type}`);
+ }
+
+ const kinds = new Set(session.messages.map((message) => message.type));
+ assert.ok(kinds.has('user'));
+ assert.ok(kinds.has('assistant'));
+ assert.ok(kinds.has('tool_call'));
+ assert.ok(kinds.has('tool_result'));
+ assert.ok(kinds.has('turn_state'));
+ });
+ });
+
+ test('pairs every tool result with the call it answers', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home);
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+
+ const callIds = new Set(
+ session.messages.filter((m) => m.type === 'tool_call').map((m) => m.id),
+ );
+ const results = session.messages.filter((m) => m.type === 'tool_result');
+ assert.ok(results.length > 0, 'the capture contains completed tool calls');
+ for (const result of results) {
+ assert.ok(
+ callIds.has((result as { toolUseId: string }).toolUseId),
+ 'every result names a call that was emitted',
+ );
+ }
+ });
+ });
+
+ test('reasoning is carried as thinking rather than as reply text', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home);
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+
+ const thinking = session.messages.filter(
+ (m) => m.type === 'assistant' && (m as { thinking?: unknown }).thinking !== undefined,
+ );
+ assert.ok(thinking.length > 0, 'the capture contains reasoning parts');
+ for (const message of thinking) {
+ assert.equal((message as { text: string }).text, '');
+ }
+ });
+ });
+
+ test('an aborted final message closes its turn as aborted, not completed', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home);
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+
+ const states = session.messages.filter((m) => m.type === 'turn_state') as {
+ status: string;
+ }[];
+ assert.ok(states.length > 0);
+ // The capture ends on a message carrying MessageAbortedError.
+ assert.equal(states.at(-1)?.status, 'aborted');
+ // Earlier turns finished on `finish: "stop"` and must not be dragged
+ // into the last one's verdict.
+ assert.ok(
+ states.slice(0, -1).some((state) => state.status === 'completed'),
+ 'completed turns are recorded as completed',
+ );
+ });
+ });
+
+ test('a turn left waiting on a tool call is aborted rather than completed', async () => {
+ await withOpenCodeHome(async (home) => {
+ // Drop everything after the first tool-calls message, which is what a
+ // run killed between a call and its answer leaves behind.
+ const fixture = await seed(home, (f) => {
+ const cut = f.messages.findIndex(
+ (m) => (m.data as { finish?: string }).finish === 'tool-calls',
+ );
+ assert.ok(cut > 0, 'the capture has a tool-calls message');
+ const kept = f.messages.slice(0, cut + 1);
+ const keptIds = new Set(kept.map((m) => m.id));
+ f.messages = kept;
+ f.parts = f.parts.filter((p) => keptIds.has(p.message_id));
+ return f;
+ });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+ const states = session.messages.filter((m) => m.type === 'turn_state') as {
+ status: string;
+ }[];
+ assert.equal(states.at(-1)?.status, 'aborted');
+ });
+ });
+
+ test('an in-flight tool call is imported without a synthesised result', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home, (f) => {
+ for (const part of f.parts) {
+ const data = part.data as { type?: string; state?: { status?: string } };
+ if (data.type === 'tool' && data.state) data.state.status = 'running';
+ }
+ return f;
+ });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+ assert.ok(session.messages.some((m) => m.type === 'tool_call'));
+ assert.equal(
+ session.messages.filter((m) => m.type === 'tool_result').length,
+ 0,
+ 'no result is invented for a call that never answered',
+ );
+ });
+ });
+
+ test('a terminal tool failure is imported as an errored result, not a dangling call', async () => {
+ await withOpenCodeHome(async (home) => {
+ // The reviewer's reproduction: a failed call inside a step that a later
+ // `finish: "stop"` closes. Without a result the transcript asserts the
+ // tool never replied, inside a turn recorded as completed.
+ const fixture = await seed(home, (f) => {
+ f.messages = [
+ { id: 'm_user', time_created: 1, data: { role: 'user' } },
+ {
+ id: 'm_call',
+ time_created: 2,
+ data: { role: 'assistant', finish: 'tool-calls', modelID: 'm' },
+ },
+ {
+ id: 'm_stop',
+ time_created: 3,
+ data: { role: 'assistant', finish: 'stop', modelID: 'm' },
+ },
+ ];
+ f.parts = [
+ {
+ id: 'p_prompt',
+ message_id: 'm_user',
+ time_created: 1,
+ data: { type: 'text', text: 'go' },
+ },
+ {
+ id: 'p_tool',
+ message_id: 'm_call',
+ time_created: 2,
+ data: {
+ type: 'tool',
+ tool: 'bash',
+ callID: 'call_failed',
+ state: { status: 'error', input: { command: 'false' }, error: 'exit 1' },
+ },
+ },
+ {
+ id: 'p_text',
+ message_id: 'm_stop',
+ time_created: 3,
+ data: { type: 'text', text: 'done' },
+ },
+ ];
+ return f;
+ });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+
+ const result = session.messages.find((m) => m.type === 'tool_result') as
+ | { toolUseId: string; isError: boolean; content: { text: string } }
+ | undefined;
+ assert.ok(result, 'a failed call still answered, and the answer is a failure');
+ assert.equal(result?.toolUseId, 'call_failed');
+ assert.equal(result?.isError, true);
+ assert.equal(result?.content.text, 'exit 1');
+ });
+ });
+
+ test('a call still running when the session was written gets no result', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home, (f) => {
+ f.messages = [
+ { id: 'm_user', time_created: 1, data: { role: 'user' } },
+ {
+ id: 'm_call',
+ time_created: 2,
+ data: { role: 'assistant', finish: 'tool-calls', modelID: 'm' },
+ },
+ ];
+ f.parts = [
+ {
+ id: 'p_prompt',
+ message_id: 'm_user',
+ time_created: 1,
+ data: { type: 'text', text: 'go' },
+ },
+ {
+ id: 'p_tool',
+ message_id: 'm_call',
+ time_created: 2,
+ data: {
+ type: 'tool',
+ tool: 'bash',
+ callID: 'call_running',
+ state: { status: 'running', input: {} },
+ },
+ },
+ ];
+ return f;
+ });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+ assert.ok(session.messages.some((m) => m.type === 'tool_call'));
+ assert.equal(session.messages.filter((m) => m.type === 'tool_result').length, 0);
+ });
+ });
+
+ test('parts keep the order the session recorded them in', async () => {
+ await withOpenCodeHome(async (home) => {
+ // opencode accepts text -> reasoning -> tool and its own replay keeps
+ // that order. Bucketing by type would emit reasoning first.
+ const fixture = await seed(home, (f) => {
+ f.messages = [
+ { id: 'm_user', time_created: 1, data: { role: 'user' } },
+ {
+ id: 'm_reply',
+ time_created: 2,
+ data: { role: 'assistant', finish: 'stop', modelID: 'm' },
+ },
+ ];
+ f.parts = [
+ {
+ id: 'p_prompt',
+ message_id: 'm_user',
+ time_created: 1,
+ data: { type: 'text', text: 'go' },
+ },
+ {
+ id: 'p1',
+ message_id: 'm_reply',
+ time_created: 2,
+ data: { type: 'text', text: 'first' },
+ },
+ {
+ id: 'p2',
+ message_id: 'm_reply',
+ time_created: 3,
+ data: { type: 'reasoning', text: 'second' },
+ },
+ {
+ id: 'p3',
+ message_id: 'm_reply',
+ time_created: 4,
+ data: {
+ type: 'tool',
+ tool: 'bash',
+ callID: 'call_third',
+ state: { status: 'completed', input: {}, output: 'ok' },
+ },
+ },
+ ];
+ return f;
+ });
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ const session = await adapter.readSession(fixture.session.id);
+ const reply = session.messages.filter((m) => m.type !== 'user');
+ assert.deepEqual(
+ reply.slice(0, 4).map((m) => {
+ if (m.type !== 'assistant') return m.type;
+ return (m as { thinking?: unknown }).thinking !== undefined ? 'thinking' : 'text';
+ }),
+ ['text', 'thinking', 'tool_call', 'tool_result'],
+ );
+ });
+ });
+
+ test('an undecodable transcript row fails the import rather than truncating it', async () => {
+ await withOpenCodeHome(async (home) => {
+ const fixture = await seed(home, (f) => {
+ f.messages = [{ id: 'm_user', time_created: 1, data: { role: 'user' } }];
+ f.parts = [];
+ return f;
+ });
+ // Replace the message payload with something that is not JSON.
+ const db = new DatabaseSync(join(home, 'opencode.db'));
+ try {
+ db.prepare('UPDATE message SET data = ? WHERE id = ?').run('{not json', 'm_user');
+ } finally {
+ db.close();
+ }
+ const adapter = new OpenCodeSessionAdapter({ opencodeHome: home });
+ await assert.rejects(adapter.readSession(fixture.session.id), /could not be read/u);
+ });
+ });
+
+ test('the registry exposes the adapter under its own id', async () => {
+ const registry = createExternalSessionAdapterRegistry();
+ const adapter = registry.get(OPENCODE_SESSION_ADAPTER_ID);
+ assert.ok(adapter);
+ assert.equal(adapter?.id, OPENCODE_SESSION_ADAPTER_ID);
+ });
+});
+
+async function withOpenCodeHome(run: (home: string) => Promise<void>): Promise<void> {
+ const home = await mkdtemp(join(tmpdir(), 'maka-opencode-adapter-'));
+ try {
+ await run(home);
+ } finally {
+ await rm(home, { recursive: true, force: true });
+ }
+}
+
+async function seed(
+ home: string,
+ mutate?: (fixture: Fixture) => Fixture,
+ options: { omitPartTable?: boolean } = {},
+): Promise<Fixture> {
+ const raw = JSON.parse(await readFile(FIXTURE, 'utf8')) as Fixture;
+ const fixture = mutate ? mutate(raw) : raw;
+ const db = new DatabaseSync(join(home, 'opencode.db'));
+ try {
+ db.exec(`
+ CREATE TABLE session (
+ id text PRIMARY KEY, project_id text, workspace_id text, parent_id text,
+ slug text, directory text NOT NULL, path text, title text,
+ version text, time_created integer, time_updated integer,
+ time_compacting integer, time_archived integer
+ );
+ CREATE TABLE message (
+ id text PRIMARY KEY, session_id text NOT NULL,
+ time_created integer NOT NULL, time_updated integer, data text NOT NULL
+ );
+ `);
+ if (!options.omitPartTable) {
+ db.exec(`
+ CREATE TABLE part (
+ id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
+ time_created integer NOT NULL, time_updated integer, data text NOT NULL
+ );
+ `);
+ }
+ db.prepare(
+ 'INSERT INTO session (id, parent_id, directory, title, time_created, time_updated, time_archived) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ ).run(
+ fixture.session.id,
+ fixture.session.parent_id,
+ fixture.session.directory,
+ fixture.session.title,
+ fixture.session.time_created,
+ fixture.session.time_updated,
+ fixture.session.time_archived,
+ );
+ const message = db.prepare(
+ 'INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)',
+ );
+ for (const row of fixture.messages) {
+ message.run(row.id, fixture.session.id, row.time_created, JSON.stringify(row.data));
+ }
+ if (options.omitPartTable) return fixture;
+ const part = db.prepare(
+ 'INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)',
+ );
+ for (const row of fixture.parts) {
+ part.run(
+ row.id,
+ row.message_id,
+ fixture.session.id,
+ row.time_created,
+ JSON.stringify(row.data),
+ );
+ }
+ } finally {
+ db.close();
+ }
+ return fixture;
+}
diff --git a/packages/storage/src/external-session-adapters.ts b/packages/storage/src/external-session-adapters.ts
index 580d72110..b4bbdabc3 100644
--- a/packages/storage/src/external-session-adapters.ts
+++ b/packages/storage/src/external-session-adapters.ts
@@ -23,10 +23,15 @@ import {
type ClaudeCodeSessionAdapterOptions,
} from './claude-code-session-adapter.js';
import { CodexSessionAdapter, type CodexSessionAdapterOptions } from './codex-session-adapter.js';
+import {
+ OpenCodeSessionAdapter,
+ type OpenCodeSessionAdapterOptions,
+} from './opencode-session-adapter.js';
export interface ExternalSessionAdapterOptions {
codex?: CodexSessionAdapterOptions;
claudeCode?: ClaudeCodeSessionAdapterOptions;
+ opencode?: OpenCodeSessionAdapterOptions;
}
/** Default source registry shared by product-facing external Session import surfaces. */
@@ -36,5 +41,6 @@ export function createExternalSessionAdapterRegistry(
return new ExternalSessionAdapterRegistry([
new CodexSessionAdapter(options.codex),
new ClaudeCodeSessionAdapter(options.claudeCode),
+ new OpenCodeSessionAdapter(options.opencode),
]);
}
diff --git a/packages/storage/src/opencode-session-adapter.ts b/packages/storage/src/opencode-session-adapter.ts
new file mode 100644
index 000000000..4df11ac32
--- /dev/null
+++ b/packages/storage/src/opencode-session-adapter.ts
@@ -0,0 +1,545 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// opencode sessions as Maka Sessions.
+//
+// State lives in one SQLite database, `~/.local/share/opencode/opencode.db`,
+// which the CLI itself will name (`opencode db path`). Earlier releases wrote
+// a `storage/session/{info,message,part}` JSON tree; that layout is gone, so
+// this reads the database and nothing else. Verified against 1.18.21.
+//
+// A conversation is three tables. `session` holds identity and `directory`,
+// which is the cwd a project-scoped query reads. `message` and `part` each
+// keep their payload in an opaque `data` JSON column — the schema names the
+// container, the column names the shape.
+//
+// Turn state is derivable here, unlike a Claude Code transcript: every
+// assistant message records `time.completed`, and `finish` is `stop` on a
+// closing step, `tool-calls` on an intermediate one, and absent on a message
+// that was aborted, which also carries `error.name`.
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { externalSessionMatchesQuery } from '@maka/core/external-session';
+import type {
+ ExternalMakaSession,
+ ExternalSessionAdapter,
+ ExternalSessionQuery,
+ ExternalSessionSummary,
+} from '@maka/core/external-session';
+import { sanitizeForeignTitle } from '@maka/core/foreign-session';
+import type { StoredMessage } from '@maka/core/session';
+
+export const OPENCODE_SESSION_ADAPTER_ID = 'opencode';
+
+const EXTERNAL_SNAPSHOT_ABORT_SOURCE = 'external_session_snapshot';
+
+/** Guards the value interpolated into no SQL, but read back out of one. */
+const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u;
+
+export interface OpenCodeSessionAdapterOptions {
+ /** Overrides `~/.local/share/opencode`. */
+ opencodeHome?: string;
+}
+
+interface SessionRow {
+ readonly id: string;
+ readonly title: string;
+ readonly directory: string;
+ readonly timeCreated?: number;
+ readonly timeUpdated?: number;
+ readonly archived: boolean;
+ readonly parentId?: string;
+}
+
+interface MessageRow {
+ readonly id: string;
+ readonly timeCreated: number;
+ readonly data: Record<string, unknown>;
+}
+
+interface PartRow {
+ readonly messageId: string;
+ readonly data: Record<string, unknown>;
+}
+
+export class OpenCodeSessionAdapter implements ExternalSessionAdapter {
+ readonly id = OPENCODE_SESSION_ADAPTER_ID;
+ readonly #home: string;
+
+ constructor(options: OpenCodeSessionAdapterOptions = {}) {
+ this.#home = options.opencodeHome ?? join(homedir(), '.local', 'share', 'opencode');
+ }
+
+ async detect(): Promise<boolean> {
+ return existsSync(this.#databasePath());
+ }
+
+ async listSessions(query?: ExternalSessionQuery): Promise<readonly ExternalSessionSummary[]> {
+ const rows = await this.#readSessions();
+ const summaries: ExternalSessionSummary[] = [];
+ for (const row of rows) {
+ // A child session is one operator's leg of a parent conversation, not a
+ // conversation a user started. Listing it offers an import of half a
+ // dialogue whose other half is a separate entry.
+ if (row.parentId !== undefined) continue;
+ const summary = toSummary(row);
+ if (externalSessionMatchesQuery(summary, query)) summaries.push(summary);
+ }
+ summaries.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0));
+ return summaries;
+ }
+
+ async readSession(sessionId: string): Promise<ExternalMakaSession> {
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
+ throw new Error(`opencode session id is not usable: ${sessionId}`);
+ }
+ const rows = await this.#readSessions();
+ const row = rows.find((candidate) => candidate.id === sessionId);
+ if (!row) throw new Error(`opencode session not found: ${sessionId}`);
+ if (row.parentId !== undefined) {
+ throw new Error(`opencode session is a child of another session: ${sessionId}`);
+ }
+ const { messages, parts } = await this.#readTranscript(sessionId);
+ return {
+ sourceSessionId: sessionId,
+ metadata: { name: row.title || sessionId, cwd: row.directory },
+ messages: convertTranscript(sessionId, messages, parts),
+ };
+ }
+
+ #databasePath(): string {
+ return join(this.#home, 'opencode.db');
+ }
+
+ /**
+ * Opens the database and runs one read.
+ *
+ * Failure is reported, not flattened into an empty result. An unreadable
+ * database and an opencode install with no sessions are different facts, and
+ * a caller that cannot tell them apart reports the wrong one: "no sessions
+ * here" for a database that is locked, corrupt, or written by a version
+ * whose tables this does not recognise.
+ */
+ async #withDatabase<T>(read: (db: OpenCodeDatabase) => T): Promise<T> {
+ const path = this.#databasePath();
+ let sqlite: typeof import('node:sqlite');
+ try {
+ sqlite = await import('node:sqlite');
+ } catch (cause) {
+ throw new Error('opencode sessions need node:sqlite, which is unavailable', { cause });
+ }
+ let db: OpenCodeDatabase;
+ try {
+ db = new sqlite.DatabaseSync(path, { readOnly: true }) as OpenCodeDatabase;
+ } catch (cause) {
+ throw new Error(`opencode database could not be opened: ${path}`, { cause });
+ }
+ try {
+ return read(db);
+ } catch (cause) {
+ throw new Error(`opencode database could not be read: ${path}`, { cause });
+ } finally {
+ try {
+ db.close();
+ } catch {
+ // A close that fails leaves nothing for a reader to do; the process
+ // releases the handle either way, and throwing here would replace a
+ // usable result with an error about cleanup.
+ }
+ }
+ }
+
+ async #readSessions(): Promise<readonly SessionRow[]> {
+ // Discovery is allowed to come up empty — the catalog lists whatever
+ // sources are present, and an opencode that was installed but never used
+ // is a normal state rather than a failure to report.
+ if (!existsSync(this.#databasePath())) return [];
+ return await this.#withDatabase((db) => {
+ const columns = tableColumns(db, 'session');
+ if (!columns.has('id') || !columns.has('directory')) {
+ throw new Error('opencode `session` table does not carry `id` and `directory`');
+ }
+ const selected = [
+ 'id',
+ 'title',
+ 'directory',
+ 'time_created',
+ 'time_updated',
+ 'time_archived',
+ 'parent_id',
+ ].filter((column) => columns.has(column));
+ const raw = db.prepare(`SELECT ${selected.join(', ')} FROM session`).all();
+ return raw.map(toSessionRow).filter((row): row is SessionRow => row !== undefined);
+ });
+ }
+
+ async #readTranscript(
+ sessionId: string,
+ ): Promise<{ messages: readonly MessageRow[]; parts: readonly PartRow[] }> {
+ return await this.#withDatabase((db) => {
+ // A row that will not decode is not skipped. Dropping one silently
+ // yields a transcript missing a message or a part while the import
+ // reports success — a history that reads as complete and is not. A
+ // selected import either carries what the session recorded or fails.
+ const messages = db
+ .prepare('SELECT id, time_created, data FROM message WHERE session_id = ?')
+ .all(sessionId)
+ .map((row, index) => requireRow(toMessageRow(row), 'message', index));
+ // Ordered by the message they belong to and then by their own id, which
+ // is how the writer orders them; `time_created` ties within one step.
+ const parts = db
+ .prepare('SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id')
+ .all(sessionId)
+ .map((row, index) => requireRow(toPartRow(row), 'part', index));
+ return { messages, parts };
+ });
+ }
+}
+
+interface OpenCodeDatabase {
+ prepare(sql: string): { all(...params: unknown[]): unknown[] };
+ close(): void;
+}
+
+function tableColumns(db: OpenCodeDatabase, table: string): Set<string> {
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name?: unknown }[];
+ return new Set(
+ rows.map((column) => (typeof column.name === 'string' ? column.name : '')).filter(Boolean),
+ );
+}
+
+function toSummary(row: SessionRow): ExternalSessionSummary {
+ return {
+ id: row.id,
+ name: sanitizeForeignTitle(row.title) || row.id,
+ cwd: row.directory,
+ ...(row.timeCreated !== undefined ? { createdAt: row.timeCreated } : {}),
+ ...(row.timeUpdated !== undefined ? { updatedAt: row.timeUpdated } : {}),
+ ...(row.archived ? { archived: true } : {}),
+ };
+}
+
+/**
+ * Turns one opencode conversation into Maka messages.
+ *
+ * Exported for the fixture tests, which exercise the mapping without a
+ * database: the conversion is where the source format is interpreted, and it
+ * is the part worth pinning.
+ */
+export function convertTranscript(
+ sessionId: string,
+ messages: readonly MessageRow[],
+ parts: readonly PartRow[],
+): readonly StoredMessage[] {
+ const partsByMessage = new Map<string, Record<string, unknown>[]>();
+ for (const part of parts) {
+ const existing = partsByMessage.get(part.messageId);
+ if (existing) existing.push(part.data);
+ else partsByMessage.set(part.messageId, [part.data]);
+ }
+
+ const ordered = [...messages].sort((left, right) =>
+ left.timeCreated === right.timeCreated
+ ? left.id.localeCompare(right.id)
+ : left.timeCreated - right.timeCreated,
+ );
+
+ const out: StoredMessage[] = [];
+ let sequence = 0;
+ const id = (kind: string): string => `opencode:${sessionId}:${kind}:${sequence++}`;
+ let turnSequence = 0;
+
+ interface Turn {
+ turnId: string;
+ lastTs: number;
+ aborted: boolean;
+ closed: boolean;
+ errorName?: string;
+ }
+ let turn: Turn | undefined;
+
+ const closeTurn = (): void => {
+ if (!turn) return;
+ if (turn.errorName !== undefined && !turn.aborted) {
+ out.push({
+ type: 'turn_state',
+ id: id('turn-state'),
+ turnId: turn.turnId,
+ ts: turn.lastTs,
+ status: 'failed',
+ errorClass: 'opencode_error',
+ partialOutputRetained: true,
+ });
+ } else if (turn.aborted) {
+ out.push({
+ type: 'turn_state',
+ id: id('turn-state'),
+ turnId: turn.turnId,
+ ts: turn.lastTs,
+ status: 'aborted',
+ abortedAt: turn.lastTs,
+ abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE,
+ partialOutputRetained: true,
+ });
+ } else if (turn.closed) {
+ out.push({
+ type: 'turn_state',
+ id: id('turn-state'),
+ turnId: turn.turnId,
+ ts: turn.lastTs,
+ status: 'completed',
+ partialOutputRetained: true,
+ });
+ } else {
+ // A turn whose last assistant step asked for tools and never came back:
+ // the run stopped between a call and its answer. Recording it as
+ // completed would assert a reply the session never produced.
+ out.push({
+ type: 'turn_state',
+ id: id('turn-state'),
+ turnId: turn.turnId,
+ ts: turn.lastTs,
+ status: 'aborted',
+ abortedAt: turn.lastTs,
+ abortSource: EXTERNAL_SNAPSHOT_ABORT_SOURCE,
+ partialOutputRetained: true,
+ });
+ }
+ turn = undefined;
+ };
+
+ for (const message of ordered) {
+ const data = message.data;
+ const role = stringOf(data.role);
+ const ts =
+ numberOf((data.time as Record<string, unknown> | undefined)?.created) ?? message.timeCreated;
+ const messageParts = partsByMessage.get(message.id) ?? [];
+
+ if (role === 'user') {
+ // A user message closes whatever came before it either way: it is the
+ // boundary, whether or not it carries a prompt this import can use.
+ closeTurn();
+ const text = messageParts
+ .filter((part) => stringOf(part.type) === 'text')
+ .map((part) => stringOf(part.text) ?? '')
+ .filter((part) => part.length > 0)
+ .join('\n\n');
+ // No text part means no prompt to import. Opening a turn for it would
+ // produce a `turn_state` describing a turn that holds no messages —
+ // a terminal verdict on a conversation that is not there. An assistant
+ // message that follows opens its own turn.
+ if (text.length === 0) continue;
+ turn = {
+ turnId: `opencode:${sessionId}:turn:${turnSequence++}`,
+ lastTs: ts,
+ aborted: false,
+ closed: false,
+ };
+ out.push({ type: 'user', id: id('user'), turnId: turn.turnId, ts, text });
+ continue;
+ }
+
+ if (role !== 'assistant') continue;
+
+ if (!turn) {
+ // An assistant message with no preceding user message: a resumed
+ // session whose opening prompt is not in this transcript. Give it a turn
+ // rather than dropping the content.
+ turn = {
+ turnId: `opencode:${sessionId}:turn:${turnSequence++}`,
+ lastTs: ts,
+ aborted: false,
+ closed: false,
+ };
+ }
+ turn.lastTs = Math.max(turn.lastTs, ts);
+
+ const errorName = stringOf((data.error as Record<string, unknown> | undefined)?.name);
+ if (errorName !== undefined) {
+ if (errorName === 'MessageAbortedError') turn.aborted = true;
+ else turn.errorName = errorName;
+ }
+ const finish = stringOf(data.finish);
+ // `stop` is the only finish that closes a turn. `tool-calls` means the
+ // step handed off to a tool and another assistant message follows.
+ if (finish === 'stop') turn.closed = true;
+ else if (finish !== undefined) turn.closed = false;
+
+ const modelId = stringOf(data.modelID) ?? 'opencode';
+
+ // Parts are walked in the order the session recorded them rather than
+ // bucketed by type. opencode accepts `text` before `reasoning`, and its
+ // own replay keeps that order; emitting all reasoning first would move a
+ // model's thinking across text it actually wrote after.
+ for (const part of messageParts) {
+ const kind = stringOf(part.type);
+
+ if (kind === 'reasoning') {
+ const thinking = stringOf(part.text);
+ if (thinking === undefined) continue;
+ out.push({
+ type: 'assistant',
+ id: id('thinking'),
+ turnId: turn.turnId,
+ ts,
+ text: '',
+ thinking: { text: thinking },
+ contentOrder: ['thinking'],
+ modelId,
+ });
+ continue;
+ }
+
+ if (kind === 'text') {
+ const text = stringOf(part.text);
+ if (text === undefined) continue;
+ out.push({
+ type: 'assistant',
+ id: id('assistant'),
+ turnId: turn.turnId,
+ ts,
+ text,
+ contentOrder: ['text'],
+ modelId,
+ });
+ continue;
+ }
+
+ if (kind !== 'tool') continue;
+ const callId = stringOf(part.callID);
+ // A call with no id cannot be paired with its result. Minting one
+ // produces a row guaranteed not to match anything, which reads as a
+ // detached call rather than an absent one.
+ if (callId === undefined) continue;
+ const state = asRecord(part.state);
+ const status = stringOf(state?.status);
+ out.push({
+ type: 'tool_call',
+ id: callId,
+ turnId: turn.turnId,
+ ts,
+ toolName: stringOf(part.tool) ?? 'unknown',
+ args: asRecord(state?.input) ?? {},
+ });
+ // `completed` and `error` are both terminal: opencode records a failed
+ // call as `{ status: 'error', error: <message> }` and replays it as an
+ // errored output. Dropping the failure would leave a call with no
+ // answer inside a turn a later `finish: "stop"` marks completed — a
+ // transcript asserting the tool never replied when it replied with a
+ // failure.
+ //
+ // `pending` and `running` are the calls that genuinely had no answer
+ // when the session was written, and they get no result.
+ if (status === 'completed') {
+ out.push({
+ type: 'tool_result',
+ id: id('tool-result'),
+ turnId: turn.turnId,
+ ts,
+ toolUseId: callId,
+ isError: false,
+ content: { kind: 'text', text: stringOf(state?.output) ?? '' },
+ });
+ continue;
+ }
+ if (status === 'error') {
+ out.push({
+ type: 'tool_result',
+ id: id('tool-result'),
+ turnId: turn.turnId,
+ ts,
+ toolUseId: callId,
+ isError: true,
+ content: { kind: 'text', text: stringOf(state?.error) ?? 'opencode tool call failed' },
+ });
+ }
+ }
+ }
+
+ closeTurn();
+ return out;
+}
+
+function requireRow<T>(row: T | undefined, table: string, index: number): T {
+ if (row === undefined) {
+ throw new Error(`opencode \`${table}\` row ${index} could not be decoded`);
+ }
+ return row;
+}
+
+function toSessionRow(value: unknown): SessionRow | undefined {
+ const row = asRecord(value);
+ const id = stringOf(row?.id);
+ if (id === undefined) return undefined;
+ const directory = stringOf(row?.directory) ?? '';
+ const parentId = stringOf(row?.parent_id);
+ return {
+ id,
+ title: stringOf(row?.title) ?? '',
+ directory,
+ ...(numberOf(row?.time_created) !== undefined
+ ? { timeCreated: numberOf(row?.time_created) }
+ : {}),
+ ...(numberOf(row?.time_updated) !== undefined
+ ? { timeUpdated: numberOf(row?.time_updated) }
+ : {}),
+ archived: numberOf(row?.time_archived) !== undefined,
+ ...(parentId !== undefined ? { parentId } : {}),
+ };
+}
+
+function toMessageRow(value: unknown): MessageRow | undefined {
+ const row = asRecord(value);
+ const id = stringOf(row?.id);
+ const data = parseJsonRecord(row?.data);
+ if (id === undefined || data === undefined) return undefined;
+ return { id, timeCreated: numberOf(row?.time_created) ?? 0, data };
+}
+
+function toPartRow(value: unknown): PartRow | undefined {
+ const row = asRecord(value);
+ const messageId = stringOf(row?.message_id);
+ const data = parseJsonRecord(row?.data);
+ if (messageId === undefined || data === undefined) return undefined;
+ return { messageId, data };
+}
+
+function parseJsonRecord(value: unknown): Record<string, unknown> | undefined {
+ if (typeof value !== 'string') return undefined;
+ try {
+ return asRecord(JSON.parse(value));
+ } catch {
+ return undefined;
+ }
+}
+
+function asRecord(value: unknown): Record<string, unknown> | undefined {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? (value as Record<string, unknown>)
+ : undefined;
+}
+
+function stringOf(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+function numberOf(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org