opencode-sessions-forever 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LinXuan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,170 @@
1
- # Temporary Holding Version
1
+ # Sessions Forever
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Have you ever been frustrated because a conversation context could not be
4
+ > passed between friends?
5
+ > Have you ever cried because you could not find an important context again?
6
+ > Have you ever suffered because a context could only ever exist as a single
7
+ > instance?
8
+
9
+ **Today, this plugin is here — built specifically to solve this problem.**
10
+
11
+ Sessions Forever exports a single conversation into one file that you can pass
12
+ around freely. One archive can be opened as multiple instances, it can live
13
+ right inside your project, and it never pollutes the main database.
14
+ **OpenCode v2 only.**
15
+
16
+ Concretely: save any [OpenCode](https://opencode.ai) conversation as a single
17
+ portable `.sf` file — and restore it later, or on another machine, as a real
18
+ native session that opens in its own tab with the complete history intact.
19
+
20
+ ## Why
21
+
22
+ OpenCode v2 stores **every** conversation of **every** project in one global
23
+ SQLite database (`~/.local/share/opencode/opencode.db`). Over time this creates
24
+ three problems:
25
+
26
+ - Old conversations get buried under newer ones and are hard to find.
27
+ - There is no per-conversation artifact you can keep, move, or back up.
28
+ - Sharing the full context of a conversation with someone else is practically
29
+ impossible.
30
+
31
+ Sessions Forever gives every conversation you care about a **single
32
+ self-contained file** with the `.sf` extension. Copy it anywhere, send it to
33
+ anyone, and load it back as a first-class OpenCode session whenever you want.
34
+
35
+ ## What is a `.sf` file?
36
+
37
+ A `.sf` file is a **genuine OpenCode database** — a standard SQLite file
38
+ containing exactly one session, written by OpenCode's own storage engine (not
39
+ by third-party code). You can open it with any SQLite tool and inspect the
40
+ `session_v2`, `session_message`, and `project` tables.
41
+
42
+ The custom `.sf` extension keeps archives instantly recognizable and prevents
43
+ unrelated `.db`/`.sqlite` files from interfering.
44
+
45
+ The archive contains the **complete projected transcript**: every user and
46
+ assistant message, code blocks, tool calls and their outputs, reasoning blocks,
47
+ token/cost statistics, and timestamps — byte-identical to what OpenCode itself
48
+ stores.
49
+
50
+ ## Features
51
+
52
+ - **`/session-save [name]`** — archive the current conversation into
53
+ `<session directory>/<name>.sf` (default name: `<title>-<YYYYMMDD-HHMM>.sf`).
54
+ - **`/session-load [index | filename]`** — scan the current directory for
55
+ `*.sf` archives and show a native picker dialog (title, message count, date).
56
+ An argument skips the dialog.
57
+ - **Native restore** — the selected archive is rebuilt as a real session in
58
+ your live OpenCode and **opens automatically in a new tab**, exactly like
59
+ picking a conversation from `/session`. You can keep chatting in it.
60
+ - **Portable** — archives work on any machine with OpenCode v2 and this
61
+ plugin. Re-importing the same archive twice (or on the original machine)
62
+ never conflicts: ids are regenerated on every load.
63
+ - **Full fidelity** — restore round-trips have been verified byte-identical.
64
+ - **Zero dependencies** — uses only OpenCode built-ins (`bun:sqlite`,
65
+ `node:child_process`) and official OpenCode mechanisms. Nothing to install.
66
+ - **Safe** — the live database is never touched directly; every write goes
67
+ through OpenCode's own import path (foreign keys, uniqueness constraints,
68
+ and cross-version migrations are all handled by official code). No model
69
+ calls are made, so saving/loading costs no tokens.
70
+
71
+ ## How it works
72
+
73
+ Everything is built from official mechanisms — the plugin never writes SQL to
74
+ your live database.
75
+
76
+ ### Save
77
+
78
+ ```
79
+ current session (router.current)
80
+ → client.session.export official transcript projection {info, messages}
81
+ → temp JSON file
82
+ → XDG_DATA_HOME=<tmp> opencode session import --standalone <json> --directory <dir>
83
+ (a disposable official instance builds a fresh database from scratch
84
+ and writes only this conversation into it)
85
+ → WAL checkpoint guard (data-loss protection on abnormal exit)
86
+ → the resulting opencode.db is moved to <name>.sf
87
+ ```
88
+
89
+ Key details:
90
+
91
+ - `XDG_DATA_HOME` redirection + `--standalone` = "the official automatic save,
92
+ triggered manually, at a path you choose". The disposable instance never
93
+ connects to your running service.
94
+ - The CLI reads/writes transcripts through **files and pipes**, so there is no
95
+ argument-size limit — archives of any size work.
96
+
97
+ ### Load
98
+
99
+ ```
100
+ scan *.sf in the current directory
101
+ → fast read-only SQLite peek for labels (id/title/count), file name as fallback
102
+ → native select dialog (or direct argument: index / file name)
103
+ → copy the .sf into a disposable instance's data dir
104
+ → opencode session export --standalone <id> (official projection reads it back;
105
+ old archives are auto-migrated by the disposable instance on startup)
106
+ → regenerate session + message ids (UNIQUE constraints are global) and
107
+ replace cross-references; drop parentID/fork links
108
+ → client.session.import into the live service (official code does the writing)
109
+ → router.navigate → the restored session opens in a new tab
110
+ ```
111
+
112
+ ## Installation
113
+
114
+ From npm (recommended):
115
+
116
+ ```
117
+ opencode plugin add opencode-sessions-forever
118
+ ```
119
+
120
+ Or drop this folder into either location manually, then restart OpenCode
121
+ (or run `opencode service restart`):
122
+
123
+ ```
124
+ ~/.config/opencode/plugins/sessions-forever/ # global (all projects)
125
+ <project>/.opencode/plugins/sessions-forever/ # per-project
126
+ ```
127
+
128
+ The package contains:
129
+
130
+ | File | Role |
131
+ | ---------- | ----------------------------------------------------------------- |
132
+ | `index.ts` | Server-side stub so the server loads the package (TUI part follows) |
133
+ | `tui.ts` | All logic: slash commands, dialogs, archives, restore |
134
+
135
+ ## Usage
136
+
137
+ ```
138
+ /session-save # archive as <title>-<timestamp>.sf in the session directory
139
+ /session-save release-plan # archive as release-plan.sf
140
+
141
+ /session-load # picker dialog listing all *.sf in the current directory
142
+ /session-load 2 # load the 2nd archive from the list
143
+ /session-load release-plan # load release-plan.sf (extension optional)
144
+ ```
145
+
146
+ Both commands are also available in the command palette.
147
+
148
+ ## Requirements
149
+
150
+ - OpenCode **v2** with the terminal UI (developed and tested against **2.1.3**).
151
+ - The `opencode` executable (the plugin reuses the running binary via
152
+ `process.execPath`).
153
+
154
+ ## Notes & limitations
155
+
156
+ - Each save/load spawns a disposable OpenCode instance: expect **1–3 seconds**
157
+ per operation. This is the price of 100% official writes.
158
+ - The quick label peek reads `session_v2`/`session_message` directly. If a
159
+ future OpenCode version renames those tables, the plugin degrades gracefully
160
+ (file-name labels + official `session list` fallback for the id).
161
+ - Archives are written to the **session's project directory**.
162
+ - Scratch directories are created under the system temp dir
163
+ (`sessions-forever-*`) and removed automatically after each operation.
164
+ - **Privacy:** a `.sf` contains the entire transcript, including reasoning
165
+ blocks and tool outputs (file contents, command results). Review an archive
166
+ before sharing it.
167
+
168
+ ## License
169
+
170
+ MIT — see [LICENSE](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "opencode-sessions-forever",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "OpenCode plugin: save any conversation as a single portable .sf archive (a real OpenCode database) and restore it later as a native session — on any machine, as many instances as you like. /session-save + /session-load with a native picker and auto tab open.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "LinXuan"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/linxuan-sys/opencode-session-forever.git"
13
+ },
14
+ "homepage": "https://github.com/linxuan-sys/opencode-session-forever#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/linxuan-sys/opencode-session-forever/issues"
17
+ },
18
+ "keywords": [
19
+ "opencode",
20
+ "opencode-plugin",
21
+ "tui",
22
+ "session",
23
+ "archive",
24
+ "export",
25
+ "import",
26
+ "sqlite",
27
+ "portable"
28
+ ],
29
+ "engines": {
30
+ "opencode": ">=2.1.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "exports": {
41
+ ".": "./src/index.ts",
42
+ "./tui": "./src/tui.ts"
43
+ },
44
+ "peerDependencies": {
45
+ "@opencode/plugin": ">=2.1.0"
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ // Server-side stub: makes the server load this package as a plugin so the
4
+ // TUI part (tui.ts) is loaded along with it. All logic lives in tui.ts.
5
+ export default Plugin.define({
6
+ id: "sessions-forever",
7
+ setup() {},
8
+ })
package/src/tui.ts ADDED
@@ -0,0 +1,275 @@
1
+ // Sessions Forever — TUI part.
2
+ // Saves the current conversation as a single portable .sf archive (a genuine
3
+ // OpenCode database written by official code) and restores archives as native
4
+ // sessions. All logic runs through official mechanisms: the session export /
5
+ // import transcript APIs, plus disposable `opencode --standalone` instances
6
+ // redirected via XDG_DATA_HOME. The main database is never touched directly.
7
+ import { Plugin } from "@opencode/plugin/tui"
8
+ import { Database } from "bun:sqlite"
9
+ import { execFile } from "node:child_process"
10
+ import { promisify } from "node:util"
11
+ import * as fs from "node:fs"
12
+ import * as fsp from "node:fs/promises"
13
+ import * as os from "node:os"
14
+ import * as path from "node:path"
15
+
16
+ const execFileAsync = promisify(execFile)
17
+ const B62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
18
+ const MAX_BUF = 512 * 1024 * 1024 // the official export streams through a stdout pipe; large sessions can reach tens of MB
19
+
20
+ function opencodeBin(): string {
21
+ // The TUI process itself is the opencode binary
22
+ try {
23
+ if (process.execPath && fs.existsSync(process.execPath)) return process.execPath
24
+ } catch {}
25
+ return "opencode"
26
+ }
27
+
28
+ // Random id with the same prefix and length as official ids (verified to pass format checks and uniqueness constraints)
29
+ function rid(old: string): string {
30
+ const i = old.indexOf("_")
31
+ const prefix = i >= 0 ? old.slice(0, i + 1) : ""
32
+ let s = ""
33
+ for (let j = 0; j < old.length - prefix.length; j++) s += B62[Math.floor(Math.random() * B62.length)]
34
+ return prefix + s
35
+ }
36
+
37
+ function sanitize(name: string): string {
38
+ return name.replace(/[\/\\:*?"<>|\s]+/g, "-").replace(/-{2,}/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session"
39
+ }
40
+
41
+ function stamp(d = new Date()): string {
42
+ const p = (n: number) => String(n).padStart(2, "0")
43
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`
44
+ }
45
+
46
+ // Spawn a disposable official instance (isolated via XDG_DATA_HOME + --standalone)
47
+ async function disposable<T>(fn: (env: NodeJS.ProcessEnv, bin: string) => Promise<T>): Promise<T> {
48
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "sessions-forever-"))
49
+ try {
50
+ return await fn({ ...process.env, XDG_DATA_HOME: path.join(tmp, "data") }, opencodeBin())
51
+ } finally {
52
+ // Only cleans up scratch directories the plugin itself created under the system temp dir
53
+ await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {})
54
+ }
55
+ }
56
+
57
+ // Read-only quick peek at the session label inside a .sf (id/title/count); falls back to the file name
58
+ function peek(file: string): { id?: string; title?: string; count?: number; created?: number } {
59
+ try {
60
+ const db = new Database(file, { readOnly: true, readonly: true } as any)
61
+ try {
62
+ const row: any = db.query("select id, title, time_created from session_v2 limit 1").get()
63
+ const cnt: any = db.query("select count(*) as n from session_message").get()
64
+ return { id: row?.id, title: row?.title, count: Number(cnt?.n ?? 0), created: Number(row?.time_created ?? 0) }
65
+ } finally {
66
+ db.close()
67
+ }
68
+ } catch {
69
+ return {}
70
+ }
71
+ }
72
+
73
+ export default Plugin.define({
74
+ id: "sessions-forever-tui",
75
+ setup(context: any) {
76
+ const toast = (message: string) => context.ui.toast.show({ title: "Sessions Forever", message })
77
+ const fail = async (e: any) => {
78
+ const msg = String(e?.message ?? e)
79
+ try {
80
+ await context.ui.dialog.alert({ title: "Sessions Forever error", message: msg })
81
+ } catch {
82
+ toast(msg)
83
+ }
84
+ }
85
+
86
+ async function currentSessionDir(): Promise<{ sessionID?: string; dir: string }> {
87
+ const route = context.ui.router.current()
88
+ if (route?.type === "session") {
89
+ try {
90
+ const info = await context.client.session.get({ sessionID: route.sessionID })
91
+ const dir = info?.location?.directory
92
+ if (dir) return { sessionID: route.sessionID, dir }
93
+ } catch {}
94
+ }
95
+ return { dir: context.location?.directory ?? process.cwd() }
96
+ }
97
+
98
+ async function save(input?: string) {
99
+ const route = context.ui.router.current()
100
+ if (route?.type !== "session") {
101
+ await context.ui.dialog.alert({ title: "Sessions Forever", message: "Open a session first before saving." })
102
+ return
103
+ }
104
+ const sessionID = route.sessionID
105
+ const info = await context.client.session.get({ sessionID })
106
+ const dir: string = info?.location?.directory ?? context.location?.directory ?? process.cwd()
107
+
108
+ // File name: argument wins, default is title-timestamp
109
+ const arg = (input ?? "").trim().replace(/\.sf$/i, "")
110
+ const name = arg ? sanitize(arg) : `${sanitize(info?.title ?? "session")}-${stamp()}`
111
+ const target = path.join(dir, `${name}.sf`)
112
+ if (fs.existsSync(target)) {
113
+ const ok = await context.ui.dialog.confirm({
114
+ title: "Sessions Forever",
115
+ message: `${name}.sf already exists. Overwrite it?`,
116
+ })
117
+ if (!ok) return
118
+ }
119
+
120
+ toast("Exporting conversation…")
121
+ const transcript = await context.client.session.export({ sessionID })
122
+
123
+ await disposable(async (env, bin) => {
124
+ const tmpRoot = path.dirname(env.XDG_DATA_HOME!) // mkdtemp root (XDG_DATA_HOME=tmp/data)
125
+ const tjson = path.join(tmpRoot, "transcript.json")
126
+ await fsp.writeFile(tjson, JSON.stringify({ info: transcript.info, messages: transcript.messages }))
127
+ // Official disposable instance: builds a fresh database from scratch and writes this conversation into it
128
+ await execFileAsync(bin, ["session", "import", "--standalone", tjson, "--directory", dir], {
129
+ env,
130
+ maxBuffer: MAX_BUF,
131
+ timeout: 180_000,
132
+ })
133
+ const dbPath = path.join(env.XDG_DATA_HOME!, "opencode", "opencode.db")
134
+ if (!fs.existsSync(dbPath)) throw new Error("The disposable instance did not produce a database file")
135
+ // Data-loss guard: merge any leftover WAL (abnormal exit) back into the main file
136
+ if (fs.existsSync(dbPath + "-wal")) {
137
+ const db = new Database(dbPath)
138
+ try {
139
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
140
+ } finally {
141
+ db.close()
142
+ }
143
+ }
144
+ await fsp.copyFile(dbPath, target)
145
+ })
146
+
147
+ const size = (fs.statSync(target).size / 1024).toFixed(0)
148
+ toast(`Saved ${transcript.messages.length} messages → ${target} (${size}KB)`)
149
+ }
150
+
151
+ async function load(input?: string) {
152
+ const { dir } = await currentSessionDir()
153
+ let files = (await fsp.readdir(dir)).filter((f) => f.toLowerCase().endsWith(".sf")).sort()
154
+ if (!files.length) {
155
+ await context.ui.dialog.alert({ title: "Sessions Forever", message: `No .sf archives in the current directory: ${dir}` })
156
+ return
157
+ }
158
+
159
+ const metas = files.map((f) => ({ file: f, ...peek(path.join(dir, f)) }))
160
+
161
+ // Direct selection by argument: index or file name
162
+ const arg = (input ?? "").trim()
163
+ let picked: (typeof metas)[number] | undefined
164
+ if (arg) {
165
+ if (/^\d+$/.test(arg)) {
166
+ const n = parseInt(arg, 10)
167
+ if (n < 1 || n > metas.length) {
168
+ await context.ui.dialog.alert({ title: "Sessions Forever", message: `Index out of range (1-${metas.length})` })
169
+ return
170
+ }
171
+ picked = metas[n - 1]
172
+ } else {
173
+ const want = arg.toLowerCase().endsWith(".sf") ? arg.toLowerCase() : arg.toLowerCase() + ".sf"
174
+ picked = metas.find((m) => m.file.toLowerCase() === want)
175
+ if (!picked) {
176
+ await context.ui.dialog.alert({ title: "Sessions Forever", message: `Archive not found: ${arg}` })
177
+ return
178
+ }
179
+ }
180
+ } else {
181
+ const value = await context.ui.dialog.select<string>({
182
+ title: "Choose an archive to load",
183
+ placeholder: "Type to filter…",
184
+ options: metas.map((m) => ({
185
+ value: m.file,
186
+ title: m.title ? `"${m.title}"` : m.file,
187
+ description: [m.count ? `${m.count} messages` : "", m.created ? new Date(m.created).toLocaleString() : "", m.file].filter(Boolean).join(" · "),
188
+ category: "Sessions Forever",
189
+ })),
190
+ })
191
+ if (!value) return
192
+ picked = metas.find((m) => m.file === value)
193
+ }
194
+ if (!picked) return
195
+
196
+ toast(`Reading ${picked.file} …`)
197
+ const sfPath = path.join(dir, picked.file)
198
+
199
+ // Mount the .sf into a disposable instance; the official export reads the transcript back
200
+ const transcript = await disposable(async (env, bin) => {
201
+ const dbDir = path.join(env.XDG_DATA_HOME!, "opencode")
202
+ await fsp.mkdir(dbDir, { recursive: true })
203
+ await fsp.copyFile(sfPath, path.join(dbDir, "opencode.db"))
204
+
205
+ let sid = picked!.id
206
+ if (!sid) {
207
+ // Official fallback when the quick peek fails: session list
208
+ const { stdout } = await execFileAsync(bin, ["session", "list", "--standalone", "--format", "json"], {
209
+ env,
210
+ cwd: dir,
211
+ maxBuffer: MAX_BUF,
212
+ timeout: 120_000,
213
+ })
214
+ const list = JSON.parse(stdout)
215
+ const arr = Array.isArray(list) ? list : list?.data ?? list?.sessions ?? []
216
+ sid = arr[0]?.id ?? arr[0]?.sessionID
217
+ if (!sid) throw new Error("No session found in the archive (session list is empty)")
218
+ }
219
+ const { stdout } = await execFileAsync(bin, ["session", "export", "--standalone", sid!], {
220
+ env,
221
+ cwd: dir,
222
+ maxBuffer: MAX_BUF,
223
+ timeout: 120_000,
224
+ })
225
+ return JSON.parse(stdout)
226
+ })
227
+
228
+ if (!transcript?.info || !Array.isArray(transcript?.messages)) throw new Error("The archive does not contain a valid session transcript")
229
+
230
+ // Regenerate session and message ids (including cross-references inside the JSON) and drop parent-session links
231
+ let blob = JSON.stringify(transcript.messages)
232
+ for (const m of transcript.messages) {
233
+ if (m?.id) blob = blob.split(`"${m.id}"`).join(`"${rid(m.id)}"`)
234
+ }
235
+ const messages = JSON.parse(blob)
236
+ const info: any = { ...transcript.info, id: rid(transcript.info.id) }
237
+ delete info.parentID
238
+ delete info.fork
239
+
240
+ const created = await context.client.session.import({
241
+ info,
242
+ messages,
243
+ location: { directory: dir },
244
+ })
245
+
246
+ // Automatically open the new tab, just like opening a conversation via /session
247
+ context.ui.router.navigate({ type: "session", sessionID: created.id })
248
+ toast(`Loaded "${created.title ?? picked.file}"`)
249
+ }
250
+
251
+ context.keymap.layer(() => ({
252
+ mode: "global",
253
+ commands: [
254
+ {
255
+ id: "sessions-forever.save",
256
+ title: "Save the current conversation as a .sf archive",
257
+ description: "Export the current session into a single .sf database file (official storage format); movable and shareable",
258
+ group: "Sessions Forever",
259
+ palette: true,
260
+ slash: { name: "session-save", arguments: true },
261
+ run: (input?: string) => save(input).catch(fail),
262
+ },
263
+ {
264
+ id: "sessions-forever.load",
265
+ title: "Load a .sf archive",
266
+ description: "List .sf archives in the current directory; the selected one is restored as a new session and opened automatically",
267
+ group: "Sessions Forever",
268
+ palette: true,
269
+ slash: { name: "session-load", arguments: true },
270
+ run: (input?: string) => load(input).catch(fail),
271
+ },
272
+ ],
273
+ }))
274
+ },
275
+ })