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 +21 -0
- package/README.md +169 -2
- package/package.json +45 -4
- package/src/index.ts +8 -0
- package/src/tui.ts +275 -0
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
|
-
#
|
|
1
|
+
# Sessions Forever
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
})
|