opencode-session 0.1.0

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 gnitoahc
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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "opencode-session",
3
+ "version": "0.1.0",
4
+ "description": "CLI tool to manage OpenCode sessions",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "bin": {
8
+ "opencode-session": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "package.json"
13
+ ],
14
+ "scripts": {
15
+ "start": "bun run src/index.ts",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "keywords": [
19
+ "opencode",
20
+ "session",
21
+ "cli",
22
+ "terminal",
23
+ "tui",
24
+ "bun"
25
+ ],
26
+ "author": "gnitoahc",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/gnitoahc/opencode-session.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/gnitoahc/opencode-session/issues"
34
+ },
35
+ "homepage": "https://github.com/gnitoahc/opencode-session#readme",
36
+ "engines": {
37
+ "bun": ">=1.0.0"
38
+ },
39
+ "dependencies": {
40
+ "@opentui/core": "^0.1.74"
41
+ },
42
+ "devDependencies": {
43
+ "@types/bun": "latest",
44
+ "typescript": "^5.0.0"
45
+ }
46
+ }
package/src/config.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { homedir } from "os"
2
+ import { join } from "path"
3
+
4
+ const HOME = homedir()
5
+
6
+ // OpenCode data directories
7
+ export const OPENCODE_STATE_DIR = join(HOME, ".local", "state", "opencode")
8
+ export const OPENCODE_SHARE_DIR = join(HOME, ".local", "share", "opencode")
9
+
10
+ // State files
11
+ export const FRECENCY_FILE = join(OPENCODE_STATE_DIR, "frecency.jsonl")
12
+ export const PROMPT_HISTORY_FILE = join(OPENCODE_STATE_DIR, "prompt-history.jsonl")
13
+ export const KV_FILE = join(OPENCODE_STATE_DIR, "kv.json")
14
+ export const MODEL_FILE = join(OPENCODE_STATE_DIR, "model.json")
15
+
16
+ // Share directories
17
+ export const STORAGE_DIR = join(OPENCODE_SHARE_DIR, "storage")
18
+ export const SESSION_DIR = join(STORAGE_DIR, "session")
19
+ export const MESSAGE_DIR = join(STORAGE_DIR, "message")
20
+ export const PART_DIR = join(STORAGE_DIR, "part")
21
+ export const PROJECT_DIR = join(STORAGE_DIR, "project")
22
+ export const SESSION_DIFF_DIR = join(STORAGE_DIR, "session_diff")
23
+ export const TODO_DIR = join(STORAGE_DIR, "todo")
24
+ export const SNAPSHOT_DIR = join(OPENCODE_SHARE_DIR, "snapshot")
25
+ export const LOG_DIR = join(OPENCODE_SHARE_DIR, "log")
26
+
27
+ // App info
28
+ export const APP_NAME = "opencode-session"
29
+ export const APP_VERSION = "0.1.0"
@@ -0,0 +1,495 @@
1
+ import { readdir, stat, readFile } from "fs/promises"
2
+ import { join } from "path"
3
+ import { existsSync } from "fs"
4
+ import {
5
+ SESSION_DIR,
6
+ MESSAGE_DIR,
7
+ PART_DIR,
8
+ PROJECT_DIR,
9
+ SESSION_DIFF_DIR,
10
+ TODO_DIR,
11
+ LOG_DIR,
12
+ FRECENCY_FILE,
13
+ } from "../config"
14
+ import type {
15
+ Session,
16
+ SessionInfo,
17
+ Project,
18
+ ProjectInfo,
19
+ Message,
20
+ Part,
21
+ Todo,
22
+ FrecencyEntry,
23
+ LogFile,
24
+ ProjectStorageInfo,
25
+ } from "../types"
26
+
27
+ // ============================================================================
28
+ // Utility Functions
29
+ // ============================================================================
30
+
31
+ async function readJsonFile<T>(path: string): Promise<T | null> {
32
+ try {
33
+ const content = await readFile(path, "utf-8")
34
+ return JSON.parse(content) as T
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ async function getDirSize(dirPath: string): Promise<number> {
41
+ let totalSize = 0
42
+ try {
43
+ const entries = await readdir(dirPath, { withFileTypes: true })
44
+ for (const entry of entries) {
45
+ const entryPath = join(dirPath, entry.name)
46
+ if (entry.isFile()) {
47
+ const stats = await stat(entryPath)
48
+ totalSize += stats.size
49
+ } else if (entry.isDirectory()) {
50
+ totalSize += await getDirSize(entryPath)
51
+ }
52
+ }
53
+ } catch {
54
+ // Directory doesn't exist or can't be read
55
+ }
56
+ return totalSize
57
+ }
58
+
59
+ async function getFileSize(filePath: string): Promise<number> {
60
+ try {
61
+ const stats = await stat(filePath)
62
+ return stats.size
63
+ } catch {
64
+ return 0
65
+ }
66
+ }
67
+
68
+ function directoryExists(path: string): boolean {
69
+ try {
70
+ return existsSync(path)
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ // ============================================================================
77
+ // Project Loading
78
+ // ============================================================================
79
+
80
+ export async function loadProjects(): Promise<Map<string, Project>> {
81
+ const projects = new Map<string, Project>()
82
+
83
+ try {
84
+ const files = await readdir(PROJECT_DIR)
85
+ for (const file of files) {
86
+ if (!file.endsWith(".json")) continue
87
+ const projectPath = join(PROJECT_DIR, file)
88
+ const project = await readJsonFile<Project>(projectPath)
89
+ if (project) {
90
+ projects.set(project.id, project)
91
+ }
92
+ }
93
+ } catch {
94
+ // PROJECT_DIR doesn't exist
95
+ }
96
+
97
+ return projects
98
+ }
99
+
100
+ // ============================================================================
101
+ // Session Loading
102
+ // ============================================================================
103
+
104
+ async function getSessionMessageCount(sessionID: string): Promise<number> {
105
+ try {
106
+ const messageDir = join(MESSAGE_DIR, sessionID)
107
+ const files = await readdir(messageDir)
108
+ return files.filter((f) => f.endsWith(".json")).length
109
+ } catch {
110
+ return 0
111
+ }
112
+ }
113
+
114
+ async function getSessionSize(sessionID: string): Promise<number> {
115
+ let totalSize = 0
116
+
117
+ // Messages directory
118
+ const messageDir = join(MESSAGE_DIR, sessionID)
119
+ totalSize += await getDirSize(messageDir)
120
+
121
+ // Get message IDs to calculate part sizes
122
+ try {
123
+ const messageFiles = await readdir(messageDir)
124
+ for (const file of messageFiles) {
125
+ if (!file.endsWith(".json")) continue
126
+ const message = await readJsonFile<Message>(join(messageDir, file))
127
+ if (message) {
128
+ const partDir = join(PART_DIR, message.id)
129
+ totalSize += await getDirSize(partDir)
130
+ }
131
+ }
132
+ } catch {
133
+ // No messages
134
+ }
135
+
136
+ // Session diff
137
+ totalSize += await getFileSize(join(SESSION_DIFF_DIR, `${sessionID}.json`))
138
+
139
+ // Todo
140
+ totalSize += await getFileSize(join(TODO_DIR, `${sessionID}.json`))
141
+
142
+ return totalSize
143
+ }
144
+
145
+ export async function loadSessions(
146
+ projects: Map<string, Project>
147
+ ): Promise<SessionInfo[]> {
148
+ const sessions: SessionInfo[] = []
149
+
150
+ try {
151
+ // Session files are organized by projectID
152
+ const projectDirs = await readdir(SESSION_DIR)
153
+
154
+ for (const projectID of projectDirs) {
155
+ const projectSessionDir = join(SESSION_DIR, projectID)
156
+ const dirStat = await stat(projectSessionDir)
157
+ if (!dirStat.isDirectory()) continue
158
+
159
+ const sessionFiles = await readdir(projectSessionDir)
160
+ const project = projects.get(projectID)
161
+ const projectWorktree = project?.worktree ?? "/"
162
+
163
+ for (const file of sessionFiles) {
164
+ if (!file.endsWith(".json")) continue
165
+
166
+ const sessionPath = join(projectSessionDir, file)
167
+ const session = await readJsonFile<Session>(sessionPath)
168
+ if (!session) continue
169
+
170
+ // Get session file size
171
+ const sessionFileSize = await getFileSize(sessionPath)
172
+
173
+ // Calculate additional sizes and info
174
+ const [messageCount, additionalSize] = await Promise.all([
175
+ getSessionMessageCount(session.id),
176
+ getSessionSize(session.id),
177
+ ])
178
+
179
+ const isOrphan = !directoryExists(session.directory)
180
+
181
+ sessions.push({
182
+ ...session,
183
+ sizeBytes: sessionFileSize + additionalSize,
184
+ messageCount,
185
+ isOrphan,
186
+ projectWorktree,
187
+ })
188
+ }
189
+ }
190
+ } catch {
191
+ // SESSION_DIR doesn't exist
192
+ }
193
+
194
+ // Sort by updated time (most recent first)
195
+ sessions.sort((a, b) => b.time.updated - a.time.updated)
196
+
197
+ return sessions
198
+ }
199
+
200
+ // ============================================================================
201
+ // Project Info (with sessions)
202
+ // ============================================================================
203
+
204
+ export async function loadProjectInfos(
205
+ sessions: SessionInfo[],
206
+ projects: Map<string, Project>
207
+ ): Promise<ProjectInfo[]> {
208
+ const projectInfos: ProjectInfo[] = []
209
+
210
+ // Group sessions by project
211
+ const sessionsByProject = new Map<string, SessionInfo[]>()
212
+ for (const session of sessions) {
213
+ const projectSessions = sessionsByProject.get(session.projectID) ?? []
214
+ projectSessions.push(session)
215
+ sessionsByProject.set(session.projectID, projectSessions)
216
+ }
217
+
218
+ // Build project infos
219
+ for (const [projectID, projectSessions] of sessionsByProject) {
220
+ const project = projects.get(projectID)
221
+ if (!project) {
222
+ // Create a placeholder project for orphaned sessions
223
+ const totalSize = projectSessions.reduce((sum, s) => sum + s.sizeBytes, 0)
224
+ projectInfos.push({
225
+ id: projectID,
226
+ worktree: projectSessions[0]?.directory ?? "/unknown",
227
+ time: {
228
+ created: Math.min(...projectSessions.map((s) => s.time.created)),
229
+ updated: Math.max(...projectSessions.map((s) => s.time.updated)),
230
+ },
231
+ sessionCount: projectSessions.length,
232
+ totalSizeBytes: totalSize,
233
+ isOrphan: true,
234
+ sessions: projectSessions,
235
+ })
236
+ continue
237
+ }
238
+
239
+ const totalSize = projectSessions.reduce((sum, s) => sum + s.sizeBytes, 0)
240
+ const isOrphan = !directoryExists(project.worktree)
241
+
242
+ projectInfos.push({
243
+ ...project,
244
+ sessionCount: projectSessions.length,
245
+ totalSizeBytes: totalSize,
246
+ isOrphan,
247
+ sessions: projectSessions,
248
+ })
249
+ }
250
+
251
+ // Add projects with no sessions
252
+ for (const [projectID, project] of projects) {
253
+ // Skip if already added (has sessions)
254
+ if (sessionsByProject.has(projectID)) continue
255
+
256
+ const isOrphan = !directoryExists(project.worktree)
257
+
258
+ projectInfos.push({
259
+ ...project,
260
+ sessionCount: 0,
261
+ totalSizeBytes: 0,
262
+ isOrphan,
263
+ sessions: [],
264
+ })
265
+ }
266
+
267
+ // Sort by updated time (most recent first)
268
+ projectInfos.sort((a, b) => b.time.updated - a.time.updated)
269
+
270
+ return projectInfos
271
+ }
272
+
273
+ // ============================================================================
274
+ // Frecency Loading
275
+ // ============================================================================
276
+
277
+ export async function loadFrecency(): Promise<FrecencyEntry[]> {
278
+ const entries: FrecencyEntry[] = []
279
+
280
+ try {
281
+ const content = await readFile(FRECENCY_FILE, "utf-8")
282
+ const lines = content.trim().split("\n")
283
+ for (const line of lines) {
284
+ if (!line) continue
285
+ try {
286
+ const entry = JSON.parse(line) as FrecencyEntry
287
+ entries.push(entry)
288
+ } catch {
289
+ // Skip invalid lines
290
+ }
291
+ }
292
+ } catch {
293
+ // File doesn't exist
294
+ }
295
+
296
+ return entries
297
+ }
298
+
299
+ // ============================================================================
300
+ // Log Loading
301
+ // ============================================================================
302
+
303
+ export async function loadLogs(): Promise<LogFile[]> {
304
+ const logs: LogFile[] = []
305
+
306
+ try {
307
+ const files = await readdir(LOG_DIR)
308
+ for (const file of files) {
309
+ if (!file.endsWith(".log")) continue
310
+ const filePath = join(LOG_DIR, file)
311
+ const stats = await stat(filePath)
312
+
313
+ // Parse date from filename (e.g., "2026-01-17T071231.log")
314
+ const dateMatch = file.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})(\d{2})(\d{2})\.log$/)
315
+ let date = new Date()
316
+ if (dateMatch) {
317
+ const [, datePart, hour, minute, second] = dateMatch
318
+ date = new Date(`${datePart}T${hour}:${minute}:${second}`)
319
+ }
320
+
321
+ logs.push({
322
+ path: filePath,
323
+ filename: file,
324
+ date,
325
+ sizeBytes: stats.size,
326
+ })
327
+ }
328
+ } catch {
329
+ // LOG_DIR doesn't exist
330
+ }
331
+
332
+ // Sort by date (most recent first)
333
+ logs.sort((a, b) => b.date.getTime() - a.date.getTime())
334
+
335
+ return logs
336
+ }
337
+
338
+ // ============================================================================
339
+ // Message & Part Loading
340
+ // ============================================================================
341
+
342
+ export async function loadMessages(sessionID: string): Promise<Message[]> {
343
+ const messages: Message[] = []
344
+
345
+ try {
346
+ const messageDir = join(MESSAGE_DIR, sessionID)
347
+ const files = await readdir(messageDir)
348
+
349
+ for (const file of files) {
350
+ if (!file.endsWith(".json")) continue
351
+ const message = await readJsonFile<Message>(join(messageDir, file))
352
+ if (message) {
353
+ messages.push(message)
354
+ }
355
+ }
356
+ } catch {
357
+ // No messages for this session
358
+ }
359
+
360
+ // Sort by created time
361
+ messages.sort((a, b) => a.time.created - b.time.created)
362
+
363
+ return messages
364
+ }
365
+
366
+ export async function loadParts(messageID: string): Promise<Part[]> {
367
+ const parts: Part[] = []
368
+
369
+ try {
370
+ const partDir = join(PART_DIR, messageID)
371
+ const files = await readdir(partDir)
372
+
373
+ for (const file of files) {
374
+ if (!file.endsWith(".json")) continue
375
+ const part = await readJsonFile<Part>(join(partDir, file))
376
+ if (part) {
377
+ parts.push(part)
378
+ }
379
+ }
380
+ } catch {
381
+ // No parts for this message
382
+ }
383
+
384
+ // Sort by time if available, otherwise by ID
385
+ parts.sort((a, b) => {
386
+ const aTime = a.time?.start ?? 0
387
+ const bTime = b.time?.start ?? 0
388
+ if (aTime !== bTime) return aTime - bTime
389
+ return a.id.localeCompare(b.id)
390
+ })
391
+
392
+ return parts
393
+ }
394
+
395
+ export async function loadTodos(sessionID: string): Promise<Todo[]> {
396
+ try {
397
+ const todoPath = join(TODO_DIR, `${sessionID}.json`)
398
+ const content = await readFile(todoPath, "utf-8")
399
+ return JSON.parse(content) as Todo[]
400
+ } catch {
401
+ return [] // No todos for this session
402
+ }
403
+ }
404
+
405
+ export async function getProjectStorageInfo(projectID: string): Promise<ProjectStorageInfo> {
406
+ let sessionFiles = 0
407
+ let messageFiles = 0
408
+ let partFiles = 0
409
+ let diffSize = 0
410
+ let todoSize = 0
411
+ let totalMessages = 0
412
+ let totalParts = 0
413
+
414
+ try {
415
+ // Count session files
416
+ const sessionDir = join(SESSION_DIR, projectID)
417
+ const sessionEntries = await readdir(sessionDir)
418
+ sessionFiles = sessionEntries.filter((f) => f.endsWith(".json")).length
419
+
420
+ // For each session, count messages and parts
421
+ for (const sessionFile of sessionEntries) {
422
+ if (!sessionFile.endsWith(".json")) continue
423
+ const sessionPath = join(sessionDir, sessionFile)
424
+ const session = await readJsonFile<Session>(sessionPath)
425
+ if (!session) continue
426
+
427
+ // Count messages
428
+ try {
429
+ const messageDir = join(MESSAGE_DIR, session.id)
430
+ const messageEntries = await readdir(messageDir)
431
+ const msgFiles = messageEntries.filter((f) => f.endsWith(".json"))
432
+ messageFiles += msgFiles.length
433
+ totalMessages += msgFiles.length
434
+
435
+ // Count parts for each message
436
+ for (const msgFile of msgFiles) {
437
+ const msg = await readJsonFile<Message>(join(messageDir, msgFile))
438
+ if (!msg) continue
439
+ try {
440
+ const partDir = join(PART_DIR, msg.id)
441
+ const partEntries = await readdir(partDir)
442
+ const prtFiles = partEntries.filter((f) => f.endsWith(".json")).length
443
+ partFiles += prtFiles
444
+ totalParts += prtFiles
445
+ } catch {
446
+ // No parts
447
+ }
448
+ }
449
+ } catch {
450
+ // No messages
451
+ }
452
+
453
+ // Get diff size
454
+ diffSize += await getFileSize(join(SESSION_DIFF_DIR, `${session.id}.json`))
455
+
456
+ // Get todo size
457
+ todoSize += await getFileSize(join(TODO_DIR, `${session.id}.json`))
458
+ }
459
+ } catch {
460
+ // Project directory doesn't exist
461
+ }
462
+
463
+ return {
464
+ sessionFiles,
465
+ messageFiles,
466
+ partFiles,
467
+ diffSize,
468
+ todoSize,
469
+ totalMessages,
470
+ totalParts,
471
+ }
472
+ }
473
+
474
+ // ============================================================================
475
+ // Full Data Load
476
+ // ============================================================================
477
+
478
+ export interface LoadedData {
479
+ sessions: SessionInfo[]
480
+ projects: ProjectInfo[]
481
+ logs: LogFile[]
482
+ }
483
+
484
+ export async function loadAllData(): Promise<LoadedData> {
485
+ const projects = await loadProjects()
486
+ const sessions = await loadSessions(projects)
487
+ const projectInfos = await loadProjectInfos(sessions, projects)
488
+ const logs = await loadLogs()
489
+
490
+ return {
491
+ sessions,
492
+ projects: projectInfos,
493
+ logs,
494
+ }
495
+ }