opencode-manager 0.4.0 → 0.4.2
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/README.md +70 -9
- package/bun.lock +5 -0
- package/package.json +2 -1
- package/src/cli/commands/chat.ts +37 -23
- package/src/cli/commands/projects.ts +25 -9
- package/src/cli/commands/sessions.ts +52 -27
- package/src/cli/commands/tokens.ts +28 -16
- package/src/cli/index.ts +41 -1
- package/src/cli/resolvers.ts +34 -9
- package/src/lib/opencode-data-provider.ts +685 -0
- package/src/lib/opencode-data-sqlite.ts +1973 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Provider Abstraction for opencode data access.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a unified interface for accessing opencode session/project data
|
|
5
|
+
* from either the JSONL file-based storage or SQLite database backend.
|
|
6
|
+
*
|
|
7
|
+
* Provider pattern notes:
|
|
8
|
+
* - The DataProvider interface is the contract for all backends.
|
|
9
|
+
* - To add a new backend, implement its loader/writer functions, add a new
|
|
10
|
+
* StorageBackend value, and wire a createXProvider() branch in createProvider().
|
|
11
|
+
* - Keep behavior consistent with JSONL defaults (ordering, filters, error handling).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```ts
|
|
15
|
+
* // Create provider based on options
|
|
16
|
+
* const provider = createProvider({ backend: 'sqlite', dbPath: '/path/to/db' })
|
|
17
|
+
*
|
|
18
|
+
* // Use the same interface regardless of backend
|
|
19
|
+
* const projects = await provider.loadProjectRecords()
|
|
20
|
+
* const sessions = await provider.loadSessionRecords({ projectId: 'abc123' })
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import { resolve } from "node:path"
|
|
24
|
+
import type {
|
|
25
|
+
ProjectRecord,
|
|
26
|
+
SessionRecord,
|
|
27
|
+
ChatMessage,
|
|
28
|
+
ChatPart,
|
|
29
|
+
DeleteResult,
|
|
30
|
+
DeleteOptions,
|
|
31
|
+
TokenSummary,
|
|
32
|
+
AggregateTokenSummary,
|
|
33
|
+
ChatSearchResult,
|
|
34
|
+
} from "./opencode-data"
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_ROOT,
|
|
37
|
+
loadProjectRecords,
|
|
38
|
+
loadSessionRecords,
|
|
39
|
+
loadSessionChatIndex,
|
|
40
|
+
loadMessageParts,
|
|
41
|
+
hydrateChatMessageParts,
|
|
42
|
+
deleteProjectMetadata,
|
|
43
|
+
deleteSessionMetadata,
|
|
44
|
+
updateSessionTitle,
|
|
45
|
+
moveSession,
|
|
46
|
+
copySession,
|
|
47
|
+
computeSessionTokenSummary,
|
|
48
|
+
computeProjectTokenSummary,
|
|
49
|
+
computeGlobalTokenSummary,
|
|
50
|
+
searchSessionsChat,
|
|
51
|
+
} from "./opencode-data"
|
|
52
|
+
import {
|
|
53
|
+
DEFAULT_SQLITE_PATH,
|
|
54
|
+
loadProjectRecordsSqlite,
|
|
55
|
+
loadSessionRecordsSqlite,
|
|
56
|
+
loadSessionChatIndexSqlite,
|
|
57
|
+
loadMessagePartsSqlite,
|
|
58
|
+
deleteSessionMetadataSqlite,
|
|
59
|
+
deleteProjectMetadataSqlite,
|
|
60
|
+
updateSessionTitleSqlite,
|
|
61
|
+
moveSessionSqlite,
|
|
62
|
+
copySessionSqlite,
|
|
63
|
+
} from "./opencode-data-sqlite"
|
|
64
|
+
|
|
65
|
+
// ========================
|
|
66
|
+
// Types
|
|
67
|
+
// ========================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Storage backend type.
|
|
71
|
+
*/
|
|
72
|
+
export type StorageBackend = "jsonl" | "sqlite"
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Options for creating a data provider.
|
|
76
|
+
*/
|
|
77
|
+
export interface DataProviderOptions {
|
|
78
|
+
/**
|
|
79
|
+
* Storage backend to use. Defaults to "jsonl".
|
|
80
|
+
*/
|
|
81
|
+
backend?: StorageBackend
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Root directory for JSONL storage.
|
|
85
|
+
* Required when backend is "jsonl".
|
|
86
|
+
* Defaults to DEFAULT_ROOT (~/.local/share/opencode).
|
|
87
|
+
*/
|
|
88
|
+
root?: string
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Path to SQLite database file.
|
|
92
|
+
* Required when backend is "sqlite".
|
|
93
|
+
* Defaults to DEFAULT_SQLITE_PATH (~/.local/share/opencode/opencode.db).
|
|
94
|
+
*/
|
|
95
|
+
dbPath?: string
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fail fast on any SQLite error or malformed data.
|
|
99
|
+
* Only applies when backend is "sqlite".
|
|
100
|
+
*/
|
|
101
|
+
sqliteStrict?: boolean
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Wait for SQLite write locks to clear before failing.
|
|
105
|
+
* Only applies when backend is "sqlite".
|
|
106
|
+
*/
|
|
107
|
+
forceWrite?: boolean
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Optional warning sink for SQLite warnings.
|
|
111
|
+
*/
|
|
112
|
+
onWarning?: (warning: string) => void
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Options for loading sessions.
|
|
117
|
+
*/
|
|
118
|
+
export interface SessionLoadOptions {
|
|
119
|
+
projectId?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Unified data provider interface for both storage backends.
|
|
124
|
+
*
|
|
125
|
+
* This interface mirrors the existing JSONL loader functions but allows
|
|
126
|
+
* transparent switching between backends.
|
|
127
|
+
*/
|
|
128
|
+
export interface DataProvider {
|
|
129
|
+
/**
|
|
130
|
+
* The storage backend being used.
|
|
131
|
+
*/
|
|
132
|
+
readonly backend: StorageBackend
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Load all project records.
|
|
136
|
+
*/
|
|
137
|
+
loadProjectRecords(): Promise<ProjectRecord[]>
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Load session records, optionally filtered by project.
|
|
141
|
+
*/
|
|
142
|
+
loadSessionRecords(options?: SessionLoadOptions): Promise<SessionRecord[]>
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load chat message index for a session (metadata only, no parts).
|
|
146
|
+
*/
|
|
147
|
+
loadSessionChatIndex(sessionId: string): Promise<ChatMessage[]>
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Load all parts for a message.
|
|
151
|
+
*/
|
|
152
|
+
loadMessageParts(messageId: string): Promise<ChatPart[]>
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Hydrate a chat message with its parts.
|
|
156
|
+
*/
|
|
157
|
+
hydrateChatMessageParts(message: ChatMessage): Promise<ChatMessage>
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Delete project metadata files/records.
|
|
161
|
+
*/
|
|
162
|
+
deleteProjectMetadata(records: ProjectRecord[], options?: DeleteOptions): Promise<DeleteResult>
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Delete session metadata files/records.
|
|
166
|
+
*/
|
|
167
|
+
deleteSessionMetadata(records: SessionRecord[], options?: DeleteOptions): Promise<DeleteResult>
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Update session title.
|
|
171
|
+
*/
|
|
172
|
+
updateSessionTitle(session: SessionRecord, newTitle: string): Promise<void>
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Move a session to another project.
|
|
176
|
+
*/
|
|
177
|
+
moveSession(session: SessionRecord, targetProjectId: string): Promise<SessionRecord>
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Copy a session to another project.
|
|
181
|
+
*/
|
|
182
|
+
copySession(session: SessionRecord, targetProjectId: string): Promise<SessionRecord>
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Compute token summary for a single session.
|
|
186
|
+
*/
|
|
187
|
+
computeSessionTokenSummary(session: SessionRecord): Promise<TokenSummary>
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Compute aggregate token summary for a project.
|
|
191
|
+
*/
|
|
192
|
+
computeProjectTokenSummary(projectId: string, sessions: SessionRecord[]): Promise<AggregateTokenSummary>
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compute aggregate token summary for all sessions.
|
|
196
|
+
*/
|
|
197
|
+
computeGlobalTokenSummary(sessions: SessionRecord[]): Promise<AggregateTokenSummary>
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Search chat content across sessions.
|
|
201
|
+
*/
|
|
202
|
+
searchSessionsChat(
|
|
203
|
+
sessions: SessionRecord[],
|
|
204
|
+
query: string,
|
|
205
|
+
options?: { maxResults?: number }
|
|
206
|
+
): Promise<ChatSearchResult[]>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ========================
|
|
210
|
+
// JSONL Provider Implementation
|
|
211
|
+
// ========================
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Create a JSONL-backed data provider.
|
|
215
|
+
*/
|
|
216
|
+
function createJsonlProvider(root: string): DataProvider {
|
|
217
|
+
const normalizedRoot = resolve(root)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
backend: "jsonl",
|
|
221
|
+
|
|
222
|
+
async loadProjectRecords() {
|
|
223
|
+
return loadProjectRecords({ root: normalizedRoot })
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async loadSessionRecords(options?: SessionLoadOptions) {
|
|
227
|
+
return loadSessionRecords({ root: normalizedRoot, projectId: options?.projectId })
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async loadSessionChatIndex(sessionId: string) {
|
|
231
|
+
return loadSessionChatIndex(sessionId, normalizedRoot)
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async loadMessageParts(messageId: string) {
|
|
235
|
+
return loadMessageParts(messageId, normalizedRoot)
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
async hydrateChatMessageParts(message: ChatMessage) {
|
|
239
|
+
return hydrateChatMessageParts(message, normalizedRoot)
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
async deleteProjectMetadata(records: ProjectRecord[], options?: DeleteOptions) {
|
|
243
|
+
return deleteProjectMetadata(records, options)
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async deleteSessionMetadata(records: SessionRecord[], options?: DeleteOptions) {
|
|
247
|
+
return deleteSessionMetadata(records, options)
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async updateSessionTitle(session: SessionRecord, newTitle: string) {
|
|
251
|
+
return updateSessionTitle(session.filePath, newTitle)
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async moveSession(session: SessionRecord, targetProjectId: string) {
|
|
255
|
+
return moveSession(session, targetProjectId, normalizedRoot)
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
async copySession(session: SessionRecord, targetProjectId: string) {
|
|
259
|
+
return copySession(session, targetProjectId, normalizedRoot)
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async computeSessionTokenSummary(session: SessionRecord) {
|
|
263
|
+
return computeSessionTokenSummary(session, normalizedRoot)
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async computeProjectTokenSummary(projectId: string, sessions: SessionRecord[]) {
|
|
267
|
+
return computeProjectTokenSummary(projectId, sessions, normalizedRoot)
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async computeGlobalTokenSummary(sessions: SessionRecord[]) {
|
|
271
|
+
return computeGlobalTokenSummary(sessions, normalizedRoot)
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async searchSessionsChat(
|
|
275
|
+
sessions: SessionRecord[],
|
|
276
|
+
query: string,
|
|
277
|
+
options?: { maxResults?: number }
|
|
278
|
+
) {
|
|
279
|
+
return searchSessionsChat(sessions, query, normalizedRoot, options)
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ========================
|
|
285
|
+
// SQLite Provider Implementation
|
|
286
|
+
// ========================
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Hydrate a chat message with its parts (SQLite version).
|
|
290
|
+
*/
|
|
291
|
+
async function hydrateChatMessagePartsSqlite(
|
|
292
|
+
message: ChatMessage,
|
|
293
|
+
dbPath: string,
|
|
294
|
+
options?: { strict?: boolean; onWarning?: (warning: string) => void }
|
|
295
|
+
): Promise<ChatMessage> {
|
|
296
|
+
const parts = await loadMessagePartsSqlite({
|
|
297
|
+
db: dbPath,
|
|
298
|
+
messageId: message.messageId,
|
|
299
|
+
strict: options?.strict,
|
|
300
|
+
onWarning: options?.onWarning,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// Combine all part texts for total chars and preview
|
|
304
|
+
const combinedText = parts.map((p) => p.text).join("\n\n")
|
|
305
|
+
const totalChars = combinedText.length
|
|
306
|
+
|
|
307
|
+
const PREVIEW_CHARS = 200
|
|
308
|
+
let previewText: string
|
|
309
|
+
if (combinedText.length === 0) {
|
|
310
|
+
previewText = "[no content]"
|
|
311
|
+
} else if (combinedText.length <= PREVIEW_CHARS) {
|
|
312
|
+
previewText = combinedText.replace(/\n/g, " ").trim()
|
|
313
|
+
} else {
|
|
314
|
+
previewText = combinedText.slice(0, PREVIEW_CHARS).replace(/\n/g, " ").trim() + "..."
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
...message,
|
|
319
|
+
parts,
|
|
320
|
+
previewText,
|
|
321
|
+
totalChars,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Create a SQLite-backed data provider.
|
|
327
|
+
*/
|
|
328
|
+
function createSqliteProvider(
|
|
329
|
+
dbPath: string,
|
|
330
|
+
options?: { strict?: boolean; forceWrite?: boolean; onWarning?: (warning: string) => void }
|
|
331
|
+
): DataProvider {
|
|
332
|
+
const normalizedDbPath = resolve(dbPath)
|
|
333
|
+
const readOptions = {
|
|
334
|
+
db: normalizedDbPath,
|
|
335
|
+
strict: options?.strict,
|
|
336
|
+
onWarning: options?.onWarning,
|
|
337
|
+
}
|
|
338
|
+
const writeOptions = {
|
|
339
|
+
...readOptions,
|
|
340
|
+
forceWrite: options?.forceWrite,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
backend: "sqlite",
|
|
345
|
+
|
|
346
|
+
async loadProjectRecords() {
|
|
347
|
+
return loadProjectRecordsSqlite(readOptions)
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async loadSessionRecords(options?: SessionLoadOptions) {
|
|
351
|
+
return loadSessionRecordsSqlite({ ...readOptions, projectId: options?.projectId })
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
async loadSessionChatIndex(sessionId: string) {
|
|
355
|
+
return loadSessionChatIndexSqlite({ ...readOptions, sessionId })
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
async loadMessageParts(messageId: string) {
|
|
359
|
+
return loadMessagePartsSqlite({ ...readOptions, messageId })
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async hydrateChatMessageParts(message: ChatMessage) {
|
|
363
|
+
return hydrateChatMessagePartsSqlite(message, normalizedDbPath, readOptions)
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
// Write operations: SQLite implementations not yet available
|
|
367
|
+
// For now, these throw NotImplementedError to be clear about limitations
|
|
368
|
+
|
|
369
|
+
async deleteProjectMetadata(records: ProjectRecord[], options?: DeleteOptions) {
|
|
370
|
+
const projectIds = records.map(r => r.projectId)
|
|
371
|
+
return deleteProjectMetadataSqlite(projectIds, {
|
|
372
|
+
...writeOptions,
|
|
373
|
+
dryRun: options?.dryRun,
|
|
374
|
+
})
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
async deleteSessionMetadata(records: SessionRecord[], options?: DeleteOptions) {
|
|
378
|
+
const sessionIds = records.map(r => r.sessionId)
|
|
379
|
+
return deleteSessionMetadataSqlite(sessionIds, {
|
|
380
|
+
...writeOptions,
|
|
381
|
+
dryRun: options?.dryRun,
|
|
382
|
+
})
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
async updateSessionTitle(session: SessionRecord, newTitle: string) {
|
|
386
|
+
return updateSessionTitleSqlite({
|
|
387
|
+
...writeOptions,
|
|
388
|
+
sessionId: session.sessionId,
|
|
389
|
+
newTitle,
|
|
390
|
+
})
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
async moveSession(session: SessionRecord, targetProjectId: string) {
|
|
394
|
+
return moveSessionSqlite({
|
|
395
|
+
...writeOptions,
|
|
396
|
+
sessionId: session.sessionId,
|
|
397
|
+
targetProjectId,
|
|
398
|
+
})
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
async copySession(session: SessionRecord, targetProjectId: string) {
|
|
402
|
+
return copySessionSqlite({
|
|
403
|
+
...writeOptions,
|
|
404
|
+
sessionId: session.sessionId,
|
|
405
|
+
targetProjectId,
|
|
406
|
+
})
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
// Token computation: Use SQLite data loading but same computation logic
|
|
410
|
+
async computeSessionTokenSummary(session: SessionRecord) {
|
|
411
|
+
// Load messages from SQLite
|
|
412
|
+
const messages = await loadSessionChatIndexSqlite({
|
|
413
|
+
...readOptions,
|
|
414
|
+
sessionId: session.sessionId,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
if (messages.length === 0) {
|
|
418
|
+
return { kind: "unknown", reason: "no_messages" } as const
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Sum tokens from assistant messages
|
|
422
|
+
let totalInput = 0
|
|
423
|
+
let totalOutput = 0
|
|
424
|
+
let totalReasoning = 0
|
|
425
|
+
let totalCacheRead = 0
|
|
426
|
+
let totalCacheWrite = 0
|
|
427
|
+
let foundAnyAssistant = false
|
|
428
|
+
|
|
429
|
+
for (const message of messages) {
|
|
430
|
+
if (message.role !== "assistant") continue
|
|
431
|
+
foundAnyAssistant = true
|
|
432
|
+
|
|
433
|
+
if (!message.tokens) {
|
|
434
|
+
return { kind: "unknown", reason: "missing" } as const
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
totalInput += message.tokens.input
|
|
438
|
+
totalOutput += message.tokens.output
|
|
439
|
+
totalReasoning += message.tokens.reasoning
|
|
440
|
+
totalCacheRead += message.tokens.cacheRead
|
|
441
|
+
totalCacheWrite += message.tokens.cacheWrite
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (!foundAnyAssistant) {
|
|
445
|
+
return { kind: "unknown", reason: "no_messages" } as const
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
kind: "known",
|
|
450
|
+
tokens: {
|
|
451
|
+
input: totalInput,
|
|
452
|
+
output: totalOutput,
|
|
453
|
+
reasoning: totalReasoning,
|
|
454
|
+
cacheRead: totalCacheRead,
|
|
455
|
+
cacheWrite: totalCacheWrite,
|
|
456
|
+
total: totalInput + totalOutput + totalReasoning + totalCacheRead + totalCacheWrite,
|
|
457
|
+
},
|
|
458
|
+
} as const
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
async computeProjectTokenSummary(projectId: string, sessions: SessionRecord[]) {
|
|
462
|
+
const projectSessions = sessions.filter((s) => s.projectId === projectId)
|
|
463
|
+
return computeAggregateSqlite(projectSessions, this)
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async computeGlobalTokenSummary(sessions: SessionRecord[]) {
|
|
467
|
+
return computeAggregateSqlite(sessions, this)
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
// Search: Use SQLite data loading but same search logic
|
|
471
|
+
async searchSessionsChat(
|
|
472
|
+
sessions: SessionRecord[],
|
|
473
|
+
query: string,
|
|
474
|
+
options?: { maxResults?: number }
|
|
475
|
+
) {
|
|
476
|
+
const queryLower = query.toLowerCase().trim()
|
|
477
|
+
const maxResults = options?.maxResults ?? 100
|
|
478
|
+
const results: ChatSearchResult[] = []
|
|
479
|
+
|
|
480
|
+
if (!queryLower) {
|
|
481
|
+
return results
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const session of sessions) {
|
|
485
|
+
if (results.length >= maxResults) break
|
|
486
|
+
|
|
487
|
+
// Load messages for this session
|
|
488
|
+
const messages = await loadSessionChatIndexSqlite({
|
|
489
|
+
...readOptions,
|
|
490
|
+
sessionId: session.sessionId,
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
for (const message of messages) {
|
|
494
|
+
if (results.length >= maxResults) break
|
|
495
|
+
|
|
496
|
+
// Load parts to search content
|
|
497
|
+
const parts = await loadMessagePartsSqlite({
|
|
498
|
+
...readOptions,
|
|
499
|
+
messageId: message.messageId,
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
for (const part of parts) {
|
|
503
|
+
if (results.length >= maxResults) break
|
|
504
|
+
|
|
505
|
+
const textLower = part.text.toLowerCase()
|
|
506
|
+
const matchIndex = textLower.indexOf(queryLower)
|
|
507
|
+
|
|
508
|
+
if (matchIndex !== -1) {
|
|
509
|
+
// Create a snippet around the match
|
|
510
|
+
const snippetStart = Math.max(0, matchIndex - 50)
|
|
511
|
+
const snippetEnd = Math.min(part.text.length, matchIndex + query.length + 50)
|
|
512
|
+
let snippet = part.text.slice(snippetStart, snippetEnd)
|
|
513
|
+
if (snippetStart > 0) snippet = "..." + snippet
|
|
514
|
+
if (snippetEnd < part.text.length) snippet = snippet + "..."
|
|
515
|
+
|
|
516
|
+
results.push({
|
|
517
|
+
sessionId: session.sessionId,
|
|
518
|
+
sessionTitle: session.title || session.sessionId,
|
|
519
|
+
projectId: session.projectId,
|
|
520
|
+
messageId: message.messageId,
|
|
521
|
+
role: message.role,
|
|
522
|
+
matchedText: snippet.replace(/\n/g, " "),
|
|
523
|
+
fullText: part.text,
|
|
524
|
+
partType: part.type,
|
|
525
|
+
createdAt: message.createdAt,
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// Only one result per message to avoid duplicates
|
|
529
|
+
break
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return results
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Helper to compute aggregate token summary for SQLite provider.
|
|
542
|
+
*/
|
|
543
|
+
async function computeAggregateSqlite(
|
|
544
|
+
sessions: SessionRecord[],
|
|
545
|
+
provider: DataProvider
|
|
546
|
+
): Promise<AggregateTokenSummary> {
|
|
547
|
+
if (sessions.length === 0) {
|
|
548
|
+
return {
|
|
549
|
+
total: { kind: "unknown", reason: "no_messages" },
|
|
550
|
+
knownOnly: {
|
|
551
|
+
input: 0,
|
|
552
|
+
output: 0,
|
|
553
|
+
reasoning: 0,
|
|
554
|
+
cacheRead: 0,
|
|
555
|
+
cacheWrite: 0,
|
|
556
|
+
total: 0,
|
|
557
|
+
},
|
|
558
|
+
unknownSessions: 0,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const knownOnly = {
|
|
563
|
+
input: 0,
|
|
564
|
+
output: 0,
|
|
565
|
+
reasoning: 0,
|
|
566
|
+
cacheRead: 0,
|
|
567
|
+
cacheWrite: 0,
|
|
568
|
+
total: 0,
|
|
569
|
+
}
|
|
570
|
+
let unknownSessions = 0
|
|
571
|
+
|
|
572
|
+
for (const session of sessions) {
|
|
573
|
+
const summary = await provider.computeSessionTokenSummary(session)
|
|
574
|
+
if (summary.kind === "known") {
|
|
575
|
+
knownOnly.input += summary.tokens.input
|
|
576
|
+
knownOnly.output += summary.tokens.output
|
|
577
|
+
knownOnly.reasoning += summary.tokens.reasoning
|
|
578
|
+
knownOnly.cacheRead += summary.tokens.cacheRead
|
|
579
|
+
knownOnly.cacheWrite += summary.tokens.cacheWrite
|
|
580
|
+
knownOnly.total += summary.tokens.total
|
|
581
|
+
} else {
|
|
582
|
+
unknownSessions += 1
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// If all sessions are unknown, total is unknown
|
|
587
|
+
if (unknownSessions === sessions.length) {
|
|
588
|
+
return {
|
|
589
|
+
total: { kind: "unknown", reason: "missing" },
|
|
590
|
+
knownOnly: {
|
|
591
|
+
input: 0,
|
|
592
|
+
output: 0,
|
|
593
|
+
reasoning: 0,
|
|
594
|
+
cacheRead: 0,
|
|
595
|
+
cacheWrite: 0,
|
|
596
|
+
total: 0,
|
|
597
|
+
},
|
|
598
|
+
unknownSessions,
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
total: { kind: "known", tokens: { ...knownOnly } },
|
|
604
|
+
knownOnly,
|
|
605
|
+
unknownSessions,
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ========================
|
|
610
|
+
// Factory Function
|
|
611
|
+
// ========================
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Create a data provider based on the specified options.
|
|
615
|
+
*
|
|
616
|
+
* @param options - Configuration options for the provider.
|
|
617
|
+
* @returns A DataProvider instance for the specified backend.
|
|
618
|
+
* @throws Error if required options are missing.
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* ```ts
|
|
622
|
+
* // JSONL backend (default)
|
|
623
|
+
* const jsonlProvider = createProvider({ root: '~/.local/share/opencode' })
|
|
624
|
+
*
|
|
625
|
+
* // SQLite backend
|
|
626
|
+
* const sqliteProvider = createProvider({
|
|
627
|
+
* backend: 'sqlite',
|
|
628
|
+
* dbPath: '~/.local/share/opencode/opencode.db'
|
|
629
|
+
* })
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
export function createProvider(options: DataProviderOptions = {}): DataProvider {
|
|
633
|
+
const backend = options.backend ?? "jsonl"
|
|
634
|
+
|
|
635
|
+
// Validate backend value
|
|
636
|
+
if (backend !== "jsonl" && backend !== "sqlite") {
|
|
637
|
+
throw new Error(
|
|
638
|
+
`Invalid storage backend: "${backend}". Must be "jsonl" or "sqlite".`
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (backend === "sqlite") {
|
|
643
|
+
const dbPath = options.dbPath ?? DEFAULT_SQLITE_PATH
|
|
644
|
+
return createSqliteProvider(dbPath, {
|
|
645
|
+
strict: options.sqliteStrict,
|
|
646
|
+
forceWrite: options.forceWrite,
|
|
647
|
+
onWarning: options.onWarning,
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// JSONL backend (default)
|
|
652
|
+
const root = options.root ?? DEFAULT_ROOT
|
|
653
|
+
return createJsonlProvider(root)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Create a data provider from CLI global options.
|
|
658
|
+
*
|
|
659
|
+
* This is a convenience function for CLI commands to create a provider
|
|
660
|
+
* based on the parsed global options (experimentalSqlite, dbPath, root).
|
|
661
|
+
*
|
|
662
|
+
* @param globalOptions - Parsed CLI global options.
|
|
663
|
+
* @returns A DataProvider instance.
|
|
664
|
+
*/
|
|
665
|
+
export function createProviderFromGlobalOptions(globalOptions: {
|
|
666
|
+
experimentalSqlite?: boolean
|
|
667
|
+
dbPath?: string
|
|
668
|
+
root?: string
|
|
669
|
+
sqliteStrict?: boolean
|
|
670
|
+
forceWrite?: boolean
|
|
671
|
+
}): DataProvider {
|
|
672
|
+
if (globalOptions.experimentalSqlite || globalOptions.dbPath) {
|
|
673
|
+
return createProvider({
|
|
674
|
+
backend: "sqlite",
|
|
675
|
+
dbPath: globalOptions.dbPath,
|
|
676
|
+
sqliteStrict: globalOptions.sqliteStrict,
|
|
677
|
+
forceWrite: globalOptions.forceWrite,
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return createProvider({
|
|
682
|
+
backend: "jsonl",
|
|
683
|
+
root: globalOptions.root,
|
|
684
|
+
})
|
|
685
|
+
}
|