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 +21 -0
- package/package.json +46 -0
- package/src/config.ts +29 -0
- package/src/data/loader.ts +495 -0
- package/src/data/manager.ts +312 -0
- package/src/index.ts +56 -0
- package/src/types.ts +173 -0
- package/src/ui/app.ts +245 -0
- package/src/ui/components/confirm-dialog.ts +117 -0
- package/src/ui/components/detail-viewer.ts +307 -0
- package/src/ui/components/header.ts +62 -0
- package/src/ui/components/index.ts +8 -0
- package/src/ui/components/list-container.ts +97 -0
- package/src/ui/components/log-viewer.ts +217 -0
- package/src/ui/components/status-bar.ts +99 -0
- package/src/ui/components/tabbar.ts +79 -0
- package/src/ui/controllers/confirm-controller.ts +57 -0
- package/src/ui/controllers/index.ts +92 -0
- package/src/ui/controllers/log-controller.ts +173 -0
- package/src/ui/controllers/log-viewer-controller.ts +52 -0
- package/src/ui/controllers/main-controller.ts +142 -0
- package/src/ui/controllers/message-viewer-controller.ts +176 -0
- package/src/ui/controllers/orphan-controller.ts +125 -0
- package/src/ui/controllers/project-viewer-controller.ts +113 -0
- package/src/ui/controllers/session-viewer-controller.ts +181 -0
- package/src/ui/keybindings.ts +158 -0
- package/src/ui/state.ts +299 -0
- package/src/ui/views/base-view.ts +92 -0
- package/src/ui/views/index.ts +45 -0
- package/src/ui/views/log-list.ts +81 -0
- package/src/ui/views/main-view.ts +242 -0
- package/src/ui/views/orphan-list.ts +241 -0
- package/src/utils.ts +118 -0
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
|
+
}
|