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.
@@ -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
+ }