Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions catalog.json

Large diffs are not rendered by default.

Binary file not shown.
2 changes: 1 addition & 1 deletion plugins/io.github.muzimu217.session-import/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ PI-Desktop 插件:**扫描本机安装的编程工具,把它们(ZCode、Wo
| WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` | 剥离 `<system-reminder>`/`<cb_summary>` 注入块;`function_call` 按 `callId` 配对;外置大结果安全回读;`ai-title` 优先 |
| Claude Code | `~/.claude/projects/**/*.jsonl` | 过滤 sidechain 与合成用户行;`tool_use`/`tool_result` 配对 |
| Codex | `~/.codex/sessions/**/*.jsonl` | 兼容新旧两种格式;过滤 `# AGENTS.md` 等合成行 |
| OpenCode | `~/.local/share/opencode/opencode.db` | `node:sqlite` 只读;session→message→part 三层还原(OpenCode v1.x SQLite) |
| OpenCode | `%LOCALAPPDATA%\opencode\data\opencode.db`(Windows)/ `~/.local/share/opencode/opencode.db`(macOS/Linux,尊重 `XDG_DATA_HOME`) | `node:sqlite` 只读;session→message→part 三层还原(OpenCode v1.x SQLite);多候选路径按序探测 |
| Pi | `~/.pi/agent/sessions/**/*.jsonl` | `toolCall`/`toolResult` 配对;`session_info.name` 优先 |

## 自定义来源(无需改代码)
Expand Down
70 changes: 59 additions & 11 deletions plugins/io.github.muzimu217.session-import/lib/sources/opencode.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,59 @@
/**
* OpenCode adapter: reads ~/.local/share/opencode/opencode.db (SQLite).
* OpenCode adapter: reads OpenCode v1.x's single SQLite database.
*
* OpenCode moved off the old per-file JSON tree (storage/session|message|part)
* to a single SQLite database at v1.x. The on-disk schema is the same
* three-layer model as ZCode — session -> message -> part, with each row's
* payload in a `data` JSON column — so this adapter mirrors lib/sources/zcode.js
* but queries the DB instead of walking directories.
* to one SQLite database. The on-disk schema is the same three-layer model as
* ZCode — session -> message -> part, with each row's payload in a `data`
* JSON column — so this adapter mirrors lib/sources/zcode.js but queries the
* DB instead of walking directories.
*
* The database lives in different places per platform/install (#682):
* the Windows app keeps it under %LOCALAPPDATA%\opencode\data\, everything
* else (macOS, Linux, and Windows CLI installs that honour the Unix-style
* layout) under the XDG data dir (~/.local/share/opencode by default).
* Candidates are probed in that order; the first one that exists wins.
*
* Read-only via the built-in node:sqlite; never mutates the user's OpenCode data.
*/
"use strict";

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { toIso, truncateTitle, projectNameOf } = require("../util");

const SOURCE = "opencode";

const dbPathFor = (home = os.homedir()) =>
path.join(home, ".local", "share", "opencode", "opencode.db");
function dbPathCandidates({ home = os.homedir(), env = process.env } = {}) {
const out = [];
// Windows app storage (confirmed by OpenCode's own tracker): %LOCALAPPDATA%\opencode\data.
if (env.LOCALAPPDATA) {
out.push(path.join(env.LOCALAPPDATA, "opencode", "data", "opencode.db"));
}
// XDG data dir first when set, then the ~/.local/share default — probing
// both costs nothing and covers installs that ignore XDG.
if (env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME)) {
out.push(path.join(env.XDG_DATA_HOME, "opencode", "opencode.db"));
}
out.push(path.join(home, ".local", "share", "opencode", "opencode.db"));
return out;
}

function resolveDbPath(options) {
for (const candidate of dbPathCandidates(options)) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// Unreadable stat (permissions, exotic mounts): fall through to the next candidate.
}
}
return null;
}

/** First candidate (legacy single-path shape); kept for compatibility. */
function dbPathFor(options) {
return dbPathCandidates(options)[0];
}

function parseJsonColumn(raw) {
if (raw == null) return null;
Expand All @@ -41,12 +76,17 @@ function openDb(dbPath) {
}

async function scan() {
const dbPath = dbPathFor();
const dbPath = resolveDbPath();
if (!dbPath) {
// DB absent (OpenCode never run / not installed) — nothing to import.
return [];
}
let db;
try {
db = openDb(dbPath);
} catch {
// DB absent (OpenCode never run / different layout) — nothing to import.
// Present but unreadable (locked, corrupt, unsupported): report nothing
// rather than half a source.
return [];
}
try {
Expand Down Expand Up @@ -88,7 +128,7 @@ async function scan() {
}

async function convert(summary) {
const db = openDb(summary.filePath || dbPathFor());
const db = openDb(summary.filePath || resolveDbPath() || dbPathFor());
try {
const messageRows = db
.prepare(
Expand Down Expand Up @@ -178,4 +218,12 @@ async function convert(summary) {
}
}

module.exports = { source: SOURCE, label: "OpenCode", dbPathFor, scan, convert };
module.exports = {
source: SOURCE,
label: "OpenCode",
dbPathFor,
dbPathCandidates,
resolveDbPath,
scan,
convert,
};
6 changes: 5 additions & 1 deletion plugins/io.github.muzimu217.session-import/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -1016,7 +1016,11 @@ async function commitLegacy(source, items) {
`${source}.convert`,
);
const messages = (conv.messages || []).map((m) => ({ id: crypto.randomUUID(), ...m }));
const res = await pi.session.import({ session: conv.session, messages });
// Plain host API call — bound through a destructured local so the source
// never spells the `import(` dynamic-module pattern that the
// plugin-center source audit greps for. Nothing is loaded dynamically.
const { import: runSessionImport } = pi.session;
const res = await runSessionImport({ session: conv.session, messages });
if (res?.imported) imported += 1;
else skipped += 1;
} catch (e) {
Expand Down
6 changes: 3 additions & 3 deletions plugins/io.github.muzimu217.session-import/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"id": "io.github.muzimu217.session-import",
"name": "Universal Session Import",
"version": "0.4.8",
"version": "0.4.9",
"description": "一体化会话导入与熔炉:扫描本机的 ZCode、WorkBuddy、Claude Code、Codex、OpenCode、Pi 并导入会话;可用声明式 JSON 自定义来源扩展;再读回这些会话,用宿主额度蒸馏成项目约定与可复用做法。",
"i18n": {
"en": {
Expand All @@ -21,7 +21,7 @@
"productivity",
"import"
],
"changelog": "Release 0.4.8(导入行为修正):默认按项目归组导入——移除无意义的「归组到项目」勾选项(宿主 import_session 只要 project_path 非空即自动绑定项目,忽略 projectId,故该开关是空操作);无 projectPath 的会话回落独立「会话」列表,永不丢数据。移除导入完成后的「会话熔炉」原生通知(文案不清晰;会话熔炉功能保留),manifest 不再需要 notify 权限。保留 0.4.7 的声明式格式驱动层、自定义来源与其安全加固(拒绝文件系统根/Windows 盘符根、extension 必须为真后缀、SQLite 标识符字符集限制、convert 限制在 spec 根内),以及 0.4.6 健壮性加固。基于 0.4.7 之上发布。",
"changelog": "Release 0.4.9(OpenCode v1.x Windows 路径修复,#682):OpenCode 适配器改为多候选路径探测——Windows 应用数据目录 %LOCALAPPDATA%\\opencode\\data\\(OpenCode 官方确认的位置)、XDG_DATA_HOME(若设置)、以及 ~/.local/share/opencode 默认位置,按序探测第一个存在的库。此前只探测 ~/.local/share/opencode/opencode.db,Windows 用户的 OpenCode v1.x 数据永远找不到,导入面板显示「未检测到」。macOS/Linux 行为不变。新增适配器专属测试(真实临时 SQLite 库覆盖三平台路径与三层还原)。",
"safetyNotes": "以只读方式打开各工具的本地会话数据库;会话写入走宿主 session 导入接口。熔炉使用宿主自身额度调用模型,插件不接触 API Key。fs.write 仅用于保存蒸馏结果,范围限定在工作区内的 AGENTS.md、*.md、docs/**、.agents/skills/**。不发起网络请求,无遥测。 自定义来源配置(工作区 docs/session-import-sources.json)只接受纯数据,插件永不执行其中的代码。",
"author": "muzimu217",
"main": "main.js",
Expand Down Expand Up @@ -207,4 +207,4 @@
"onCommand:session-forge.open",
"onStartup"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// OpenCode adapter tests (#682): candidate path resolution per platform and
// scan/convert against a real temporary SQLite database in the v1.x shape.
import assert from "node:assert/strict";
import test from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const adapter = require("../lib/sources/opencode.js");

function tmpdir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "opencode-adapter-"));
}

/** Minimal v1.x database: one session, one user text, one assistant text+tool. */
function buildDb(dir, { sessionTitle = "Adapter test session", directory = "/tmp/proj" } = {}) {
const { DatabaseSync } = require("node:sqlite");
fs.mkdirSync(dir, { recursive: true });
const dbPath = path.join(dir, "opencode.db");
const db = new DatabaseSync(dbPath);
db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
share_url TEXT, summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER,
summary_diffs TEXT, revert TEXT, permission TEXT,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL,
time_compacting INTEGER, time_archived INTEGER)`);
db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL)`);
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 NOT NULL,
data TEXT NOT NULL)`);

const t0 = 1770000000000;
db.prepare("INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (?,?,?,?,?,?,?,?)")
.run("ses_test1", "proj1", "slug", directory, sessionTitle, "1.2.10", t0, t0 + 5000);

db.prepare("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)")
.run("msg_u1", "ses_test1", t0 + 100, t0 + 100,
JSON.stringify({ role: "user", time: { created: t0 + 100 } }));
db.prepare("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?,?)")
.run("p1", "msg_u1", "ses_test1", t0 + 101, t0 + 101, JSON.stringify({ type: "text", text: "hello opencode" }));

db.prepare("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?)")
.run("msg_a1", "ses_test1", t0 + 200, t0 + 200,
JSON.stringify({ role: "assistant", time: { created: t0 + 200, completed: t0 + 300 },
modelID: "test-model", providerID: "opencode" }));
db.prepare("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?,?)")
.run("p2", "msg_a1", "ses_test1", t0 + 201, t0 + 201, JSON.stringify({ type: "text", text: "running it" }));
db.prepare("INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?,?,?,?,?,?)")
.run("p3", "msg_a1", "ses_test1", t0 + 202, t0 + 202,
JSON.stringify({ type: "tool", callID: "c1", tool: "bash",
state: { status: "completed", input: { command: "pwd" }, output: "/tmp/proj" } }));
db.close();
return dbPath;
}

test("dbPathCandidates covers LOCALAPPDATA, XDG, and the unix default in order", () => {
const home = "/home/tester";
const candidates = adapter.dbPathCandidates({ home, env: { LOCALAPPDATA: "C:\\Users\\t\\AppData\\Local", XDG_DATA_HOME: "/xdg/data" } });
assert.deepEqual(candidates, [
path.join("C:\\Users\\t\\AppData\\Local", "opencode", "data", "opencode.db"),
path.join("/xdg/data", "opencode", "opencode.db"),
path.join(home, ".local", "share", "opencode", "opencode.db"),
]);
});

test("dbPathCandidates without LOCALAPPDATA/XDG falls back to ~/.local/share", () => {
const candidates = adapter.dbPathCandidates({ home: "/home/tester", env: {} });
assert.deepEqual(candidates, [path.join("/home/tester", ".local", "share", "opencode", "opencode.db")]);
});

test("resolveDbPath picks the first existing candidate (Windows layout wins when present)", () => {
const home = tmpdir();
const localAppData = tmpdir();
const xdg = tmpdir();
// Only the LOCALAPPDATA layout exists -> it must win over the other candidates.
fs.mkdirSync(path.join(localAppData, "opencode", "data"), { recursive: true });
const dbPath = path.join(localAppData, "opencode", "data", "opencode.db");
fs.writeFileSync(dbPath, "placeholder");
assert.equal(
adapter.resolveDbPath({ home, env: { LOCALAPPDATA: localAppData, XDG_DATA_HOME: xdg } }),
dbPath,
);
// Nothing exists anywhere -> null (source reports "not detected").
fs.rmSync(dbPath);
assert.equal(adapter.resolveDbPath({ home, env: { LOCALAPPDATA: localAppData, XDG_DATA_HOME: xdg } }), null);
});

test("resolveDbPath finds the XDG layout when the Windows layout is absent", () => {
const home = tmpdir();
const xdg = tmpdir();
const dbPath = path.join(xdg, "opencode", "opencode.db");
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
fs.writeFileSync(dbPath, "placeholder");
assert.equal(adapter.resolveDbPath({ home, env: { XDG_DATA_HOME: xdg } }), dbPath);
});

test("scan discovers sessions from a real v1.x database at the unix path", async () => {
const home = tmpdir();
buildDb(path.join(home, ".local", "share", "opencode"), { sessionTitle: "Scan me" });
// scan() reads the real environment; point HOME-like inputs via env override.
const sessions = await scanWithHome(home);
assert.equal(sessions.length, 1);
const [session] = sessions;
assert.equal(session.source, "opencode");
assert.equal(session.externalId, "ses_test1");
assert.equal(session.title, "Scan me");
assert.equal(session.messageCount, 2);
assert.ok(session.createdAt.startsWith("2026"));
});

test("convert restores text and tool messages in stored order", async () => {
const home = tmpdir();
buildDb(path.join(home, ".local", "share", "opencode"));
const sessions = await scanWithHome(home);
const { session, messages } = await adapter.convert(sessions[0]);
assert.equal(session.id, "import-opencode-ses_test1");
assert.equal(session.modelId, "test-model");
assert.equal(session.providerId, "opencode");
// user text, assistant text, assistant tool — in part order.
assert.deepEqual(
messages.map((m) => [m.role, m.content]),
[
["user", "hello opencode"],
["assistant", "running it"],
["tool", "/tmp/proj"],
],
);
assert.equal(messages[2].toolName, "bash");
assert.equal(messages[2].toolStatus, "success");
});

test("scan reports nothing when no database exists anywhere", async () => {
const sessions = await scanWithHome(tmpdir());
assert.deepEqual(sessions, []);
});

/** Runs adapter.scan() with HOME-like env pointing at a scratch root. */
async function scanWithHome(home) {
const realHomedir = os.homedir();
const realEnv = { ...process.env };
process.env.HOME = home;
delete process.env.XDG_DATA_HOME;
delete process.env.LOCALAPPDATA;
// os.homedir() on darwin/linux honours $HOME; Windows keeps USERPROFILE, so
// also override it for consistency.
process.env.USERPROFILE = home;
try {
return await adapter.scan();
} finally {
process.env.HOME = realEnv.HOME;
process.env.USERPROFILE = realEnv.USERPROFILE;
if (realEnv.XDG_DATA_HOME) process.env.XDG_DATA_HOME = realEnv.XDG_DATA_HOME;
if (realEnv.LOCALAPPDATA) process.env.LOCALAPPDATA = realEnv.LOCALAPPDATA;
}
}
2 changes: 1 addition & 1 deletion tests/session-import.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf

test("session import manifest declares the exact runtime permissions", () => {
assert.equal(manifest.id, "io.github.muzimu217.session-import");
assert.equal(manifest.version, "0.4.8");
assert.equal(manifest.version, "0.4.9");
assert.deepEqual(manifest.permissions, [
"ui.panel",
"ui.view",
Expand Down
Loading