@juspay/shooter 1.16.0 → 1.18.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.
Files changed (175) hide show
  1. package/.claude/hooks/codex-hooks.example.json +75 -0
  2. package/.claude/hooks/notifier.cjs +158 -8
  3. package/build/client/_app/immutable/assets/{0.DEfoFaGR.css → 0.NV8k8wxG.css} +1 -1
  4. package/build/client/_app/immutable/assets/0.NV8k8wxG.css.br +0 -0
  5. package/build/client/_app/immutable/assets/0.NV8k8wxG.css.gz +0 -0
  6. package/build/client/_app/immutable/chunks/8lO1IL7u.js +1 -0
  7. package/build/client/_app/immutable/chunks/8lO1IL7u.js.br +0 -0
  8. package/build/client/_app/immutable/chunks/8lO1IL7u.js.gz +0 -0
  9. package/build/client/_app/immutable/chunks/B9WQy_3X.js +1 -0
  10. package/build/client/_app/immutable/chunks/B9WQy_3X.js.br +0 -0
  11. package/build/client/_app/immutable/chunks/B9WQy_3X.js.gz +0 -0
  12. package/build/client/_app/immutable/chunks/BdtLzPpO.js +1 -0
  13. package/build/client/_app/immutable/chunks/BdtLzPpO.js.br +0 -0
  14. package/build/client/_app/immutable/chunks/BdtLzPpO.js.gz +0 -0
  15. package/build/client/_app/immutable/chunks/{DlS3abGJ.js → DJvX78LW.js} +1 -1
  16. package/build/client/_app/immutable/chunks/DJvX78LW.js.br +0 -0
  17. package/build/client/_app/immutable/chunks/DJvX78LW.js.gz +0 -0
  18. package/build/client/_app/immutable/chunks/nWG9RHyB.js +3 -0
  19. package/build/client/_app/immutable/chunks/nWG9RHyB.js.br +0 -0
  20. package/build/client/_app/immutable/chunks/nWG9RHyB.js.gz +0 -0
  21. package/build/client/_app/immutable/entry/{app.CSJG7N9H.js → app.f46Ko1hu.js} +2 -2
  22. package/build/client/_app/immutable/entry/app.f46Ko1hu.js.br +0 -0
  23. package/build/client/_app/immutable/entry/app.f46Ko1hu.js.gz +0 -0
  24. package/build/client/_app/immutable/entry/start.BVDjNnXt.js +1 -0
  25. package/build/client/_app/immutable/entry/start.BVDjNnXt.js.br +2 -0
  26. package/build/client/_app/immutable/entry/start.BVDjNnXt.js.gz +0 -0
  27. package/build/client/_app/immutable/nodes/{0.qOL7xtFn.js → 0.D_9EwVmq.js} +1 -1
  28. package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.br +0 -0
  29. package/build/client/_app/immutable/nodes/0.D_9EwVmq.js.gz +0 -0
  30. package/build/client/_app/immutable/nodes/{1.Di708Ago.js → 1.C4eFlqSB.js} +1 -1
  31. package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.br +0 -0
  32. package/build/client/_app/immutable/nodes/1.C4eFlqSB.js.gz +0 -0
  33. package/build/client/_app/immutable/nodes/{2.DSM1znqa.js → 2.CdC092Za.js} +1 -1
  34. package/build/client/_app/immutable/nodes/2.CdC092Za.js.br +0 -0
  35. package/build/client/_app/immutable/nodes/2.CdC092Za.js.gz +0 -0
  36. package/build/client/_app/immutable/nodes/{3.BPa5fh75.js → 3.Dhf4ZWW0.js} +1 -1
  37. package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.br +0 -0
  38. package/build/client/_app/immutable/nodes/3.Dhf4ZWW0.js.gz +0 -0
  39. package/build/client/_app/immutable/nodes/6.B3SEB_li.js +1 -0
  40. package/build/client/_app/immutable/nodes/6.B3SEB_li.js.br +0 -0
  41. package/build/client/_app/immutable/nodes/6.B3SEB_li.js.gz +0 -0
  42. package/build/client/_app/immutable/nodes/{7.B7UJd8GQ.js → 7.DV8cJ1lX.js} +3 -3
  43. package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.br +0 -0
  44. package/build/client/_app/immutable/nodes/7.DV8cJ1lX.js.gz +0 -0
  45. package/build/client/_app/immutable/nodes/8.Bs362gyb.js +2 -0
  46. package/build/client/_app/immutable/nodes/8.Bs362gyb.js.br +0 -0
  47. package/build/client/_app/immutable/nodes/8.Bs362gyb.js.gz +0 -0
  48. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js +2 -0
  49. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.br +0 -0
  50. package/build/client/_app/immutable/nodes/9.Cf7_3uqT.js.gz +0 -0
  51. package/build/client/_app/version.json +1 -1
  52. package/build/client/_app/version.json.br +0 -0
  53. package/build/client/_app/version.json.gz +0 -0
  54. package/build/server/chunks/{0-D8uPamNd.js → 0-Cd7jY0a7.js} +3 -3
  55. package/build/server/chunks/{0-D8uPamNd.js.map → 0-Cd7jY0a7.js.map} +1 -1
  56. package/build/server/chunks/{1-DhtioHbs.js → 1-C4BOGoJY.js} +2 -2
  57. package/build/server/chunks/{1-DhtioHbs.js.map → 1-C4BOGoJY.js.map} +1 -1
  58. package/build/server/chunks/{2-Cgh7ZFgE.js → 2-Ba0mNwJ6.js} +2 -2
  59. package/build/server/chunks/{2-Cgh7ZFgE.js.map → 2-Ba0mNwJ6.js.map} +1 -1
  60. package/build/server/chunks/{3-I6hnjssH.js → 3-Pg8t1uJU.js} +2 -2
  61. package/build/server/chunks/{3-I6hnjssH.js.map → 3-Pg8t1uJU.js.map} +1 -1
  62. package/build/server/chunks/{6-bPDbH1_W.js → 6-D8xbnTSo.js} +2 -2
  63. package/build/server/chunks/6-D8xbnTSo.js.map +1 -0
  64. package/build/server/chunks/{7-CpBrOkxQ.js → 7-CkVK06S0.js} +2 -2
  65. package/build/server/chunks/7-CkVK06S0.js.map +1 -0
  66. package/build/server/chunks/{8-BRGAVfze.js → 8-C8qVhrds.js} +2 -2
  67. package/build/server/chunks/8-C8qVhrds.js.map +1 -0
  68. package/build/server/chunks/{9-C6xuAb_Y.js → 9-fL5zqN0T.js} +2 -2
  69. package/build/server/chunks/9-fL5zqN0T.js.map +1 -0
  70. package/build/server/chunks/{_server.ts-BrRZXr-8.js → _server.ts-BA_uWcPw.js} +9 -9
  71. package/build/server/chunks/_server.ts-BA_uWcPw.js.map +1 -0
  72. package/build/server/chunks/{_server.ts-C6xbNz6d.js → _server.ts-Bu3s5hfv.js} +3 -3
  73. package/build/server/chunks/{_server.ts-C6xbNz6d.js.map → _server.ts-Bu3s5hfv.js.map} +1 -1
  74. package/build/server/chunks/{_server.ts-Cq9_scaV.js → _server.ts-CwAjt91u.js} +18 -18
  75. package/build/server/chunks/_server.ts-CwAjt91u.js.map +1 -0
  76. package/build/server/chunks/{_server.ts-CFX-S_8q.js → _server.ts-DZ5naqSL.js} +2 -2
  77. package/build/server/chunks/{_server.ts-CFX-S_8q.js.map → _server.ts-DZ5naqSL.js.map} +1 -1
  78. package/build/server/chunks/{_server.ts-Dekgb6Hx.js → _server.ts-DZP2lhaY.js} +3 -3
  79. package/build/server/chunks/{_server.ts-Dekgb6Hx.js.map → _server.ts-DZP2lhaY.js.map} +1 -1
  80. package/build/server/chunks/_server.ts-DZgfQKiH.js +81 -0
  81. package/build/server/chunks/_server.ts-DZgfQKiH.js.map +1 -0
  82. package/build/server/chunks/{_server.ts-CjK0g9dO.js → _server.ts-MbnroWEF.js} +25 -16
  83. package/build/server/chunks/_server.ts-MbnroWEF.js.map +1 -0
  84. package/build/server/chunks/{pty-manager-aFpChJah.js → pty-manager-DmNSCKAr.js} +99 -2
  85. package/build/server/chunks/pty-manager-DmNSCKAr.js.map +1 -0
  86. package/build/server/chunks/qwen-reader-DGfUbKaJ.js +2112 -0
  87. package/build/server/chunks/qwen-reader-DGfUbKaJ.js.map +1 -0
  88. package/build/server/chunks/{_server.ts-D--_NXt2.js → registry-Kcw2UCMv.js} +132 -106
  89. package/build/server/chunks/registry-Kcw2UCMv.js.map +1 -0
  90. package/build/server/index.js +1 -1
  91. package/build/server/index.js.map +1 -1
  92. package/build/server/manifest.js +16 -16
  93. package/build/server/manifest.js.map +1 -1
  94. package/package.json +2 -2
  95. package/scripts/e2e-all-features.sh +165 -0
  96. package/scripts/e2e-cross-terminal.sh +168 -0
  97. package/server.ts +12 -0
  98. package/src/lib/modules/client/common/index.ts +1 -1
  99. package/src/lib/modules/client/common/provider.ts +11 -0
  100. package/src/lib/modules/client/terminal/ChatView.svelte +9 -2
  101. package/src/lib/modules/client/terminal/LaunchSheet.svelte +4 -0
  102. package/src/lib/modules/server/sessions/amp-reader.ts +439 -0
  103. package/src/lib/modules/server/sessions/codex-reader.ts +34 -33
  104. package/src/lib/modules/server/sessions/copilot-reader.ts +542 -0
  105. package/src/lib/modules/server/sessions/cursor-reader.ts +634 -0
  106. package/src/lib/modules/server/sessions/gemini-reader.ts +594 -0
  107. package/src/lib/modules/server/sessions/opencode-db-path.ts +19 -10
  108. package/src/lib/modules/server/sessions/opencode-reader.ts +13 -12
  109. package/src/lib/modules/server/sessions/process-detector.ts +39 -18
  110. package/src/lib/modules/server/sessions/provider-paths.ts +173 -0
  111. package/src/lib/modules/server/sessions/qwen-reader.ts +336 -0
  112. package/src/lib/modules/server/sessions/registry.ts +178 -0
  113. package/src/lib/modules/server/terminal/codex-watcher.ts +4 -1
  114. package/src/lib/modules/server/terminal/generic-session-watcher.ts +163 -0
  115. package/src/lib/modules/server/terminal/pty-manager.ts +51 -0
  116. package/src/lib/modules/server/ws/session-handler.ts +34 -20
  117. package/src/lib/theme.css +32 -0
  118. package/src/lib/types/gemini.ts +100 -0
  119. package/src/lib/types/generated/Sessions.ts +17 -1
  120. package/src/lib/types/index.ts +1 -0
  121. package/src/lib/types/server.ts +23 -6
  122. package/src/lib/types/sessions.ts +14 -2
  123. package/src/routes/api/sessions/+server.ts +5 -52
  124. package/src/routes/api/sessions/connect/+server.ts +18 -11
  125. package/src/routes/api/terminals/+server.ts +7 -5
  126. package/src/routes/terminals/+page.svelte +7 -2
  127. package/src/routes/terminals/[id]/+page.svelte +1 -2
  128. package/build/client/_app/immutable/assets/0.DEfoFaGR.css.br +0 -0
  129. package/build/client/_app/immutable/assets/0.DEfoFaGR.css.gz +0 -0
  130. package/build/client/_app/immutable/chunks/Bkqjn62J.js +0 -1
  131. package/build/client/_app/immutable/chunks/Bkqjn62J.js.br +0 -1
  132. package/build/client/_app/immutable/chunks/Bkqjn62J.js.gz +0 -0
  133. package/build/client/_app/immutable/chunks/DOHhmtDH.js +0 -3
  134. package/build/client/_app/immutable/chunks/DOHhmtDH.js.br +0 -0
  135. package/build/client/_app/immutable/chunks/DOHhmtDH.js.gz +0 -0
  136. package/build/client/_app/immutable/chunks/DlS3abGJ.js.br +0 -0
  137. package/build/client/_app/immutable/chunks/DlS3abGJ.js.gz +0 -0
  138. package/build/client/_app/immutable/chunks/Pw0jDB7M.js +0 -1
  139. package/build/client/_app/immutable/chunks/Pw0jDB7M.js.br +0 -0
  140. package/build/client/_app/immutable/chunks/Pw0jDB7M.js.gz +0 -0
  141. package/build/client/_app/immutable/entry/app.CSJG7N9H.js.br +0 -0
  142. package/build/client/_app/immutable/entry/app.CSJG7N9H.js.gz +0 -0
  143. package/build/client/_app/immutable/entry/start.CTt1901T.js +0 -1
  144. package/build/client/_app/immutable/entry/start.CTt1901T.js.br +0 -2
  145. package/build/client/_app/immutable/entry/start.CTt1901T.js.gz +0 -0
  146. package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.br +0 -0
  147. package/build/client/_app/immutable/nodes/0.qOL7xtFn.js.gz +0 -0
  148. package/build/client/_app/immutable/nodes/1.Di708Ago.js.br +0 -0
  149. package/build/client/_app/immutable/nodes/1.Di708Ago.js.gz +0 -0
  150. package/build/client/_app/immutable/nodes/2.DSM1znqa.js.br +0 -0
  151. package/build/client/_app/immutable/nodes/2.DSM1znqa.js.gz +0 -0
  152. package/build/client/_app/immutable/nodes/3.BPa5fh75.js.br +0 -0
  153. package/build/client/_app/immutable/nodes/3.BPa5fh75.js.gz +0 -0
  154. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js +0 -1
  155. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.br +0 -0
  156. package/build/client/_app/immutable/nodes/6.B1LwwEF-.js.gz +0 -0
  157. package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.br +0 -0
  158. package/build/client/_app/immutable/nodes/7.B7UJd8GQ.js.gz +0 -0
  159. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js +0 -2
  160. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.br +0 -0
  161. package/build/client/_app/immutable/nodes/8.CG0mrgBU.js.gz +0 -0
  162. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js +0 -2
  163. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.br +0 -0
  164. package/build/client/_app/immutable/nodes/9.KwzWaMHj.js.gz +0 -0
  165. package/build/server/chunks/6-bPDbH1_W.js.map +0 -1
  166. package/build/server/chunks/7-CpBrOkxQ.js.map +0 -1
  167. package/build/server/chunks/8-BRGAVfze.js.map +0 -1
  168. package/build/server/chunks/9-C6xuAb_Y.js.map +0 -1
  169. package/build/server/chunks/_server.ts-BrRZXr-8.js.map +0 -1
  170. package/build/server/chunks/_server.ts-CjK0g9dO.js.map +0 -1
  171. package/build/server/chunks/_server.ts-Cq9_scaV.js.map +0 -1
  172. package/build/server/chunks/_server.ts-D--_NXt2.js.map +0 -1
  173. package/build/server/chunks/opencode-db-path-CRgzBK5U.js +0 -402
  174. package/build/server/chunks/opencode-db-path-CRgzBK5U.js.map +0 -1
  175. package/build/server/chunks/pty-manager-aFpChJah.js.map +0 -1
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Gemini CLI session reader — listing + conversation retrieval.
3
+ *
4
+ * Mirrors codex-reader.ts (Codex) for structure/conventions: produces the
5
+ * same SessionInfo / ProjectGroup / ConversationMessage shapes so the rest of
6
+ * the app is provider-agnostic.
7
+ *
8
+ * Data sources (in preference order per session):
9
+ * 1. ~/.gemini/tmp/<projectHash>/chats/session-*.json
10
+ * Full ConversationRecord (user + model + tool calls + thoughts).
11
+ * Present only in newer gemini-cli versions; NOT present on older installs.
12
+ * 2. ~/.gemini/tmp/<projectHash>/logs.json
13
+ * User-messages-only JSON array (LogEntry[]). Always present.
14
+ *
15
+ * Project hash → CWD reverse-lookup:
16
+ * - Read ~/.gemini/projects.json if present (slug → absolute path, new format).
17
+ * - For SHA-256 hash dirs (old format), the hash is irreversible without
18
+ * brute-force; we surface the hash itself as the projectPath in that case.
19
+ */
20
+
21
+ import type {
22
+ ConversationMessage,
23
+ GeminiConversationRecord,
24
+ GeminiLogEntry,
25
+ GeminiMessageRecord,
26
+ GeminiProjectsJson,
27
+ MessagePart,
28
+ ProjectGroup,
29
+ SessionInfo,
30
+ } from '$lib/types';
31
+
32
+ import * as crypto from 'crypto';
33
+ import * as fs from 'fs';
34
+ import { homedir } from 'os';
35
+ import * as path from 'path';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Constants
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Files above this size are SKIPPED, not truncated — these are single JSON
43
+ * documents/arrays, so a partial read would be invalid JSON. Gemini session
44
+ * files are small in practice; this is only an OOM backstop.
45
+ */
46
+ const MAX_GEMINI_FILE_BYTES = 64 * 1024 * 1024; // 64 MB
47
+
48
+ /** Gemini tmp root. */
49
+ const GEMINI_TMP = path.join(homedir(), '.gemini', 'tmp');
50
+
51
+ /** Gemini projects registry (slug → absolute path). */
52
+ const GEMINI_PROJECTS_JSON = path.join(homedir(), '.gemini', 'projects.json');
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Public API
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Return sessions whose logs.json or chat file was written within `thresholdMs`
60
+ * of now — i.e. sessions that are currently (or very recently) active.
61
+ */
62
+ export function detectActiveGeminiSessions(
63
+ thresholdMs: number
64
+ ): { cwd: string; id: string; startedAt: number }[] {
65
+ const cutoff = Date.now() - thresholdMs;
66
+ const projectHashToCwd = buildHashToCwdMap();
67
+ const projectDirs = collectProjectHashDirs();
68
+ const out: { cwd: string; id: string; startedAt: number }[] = [];
69
+
70
+ for (const hashDir of projectDirs) {
71
+ const hash = path.basename(hashDir);
72
+ const cwd = projectHashToCwd.get(hash) ?? hash;
73
+
74
+ // Check chats/session-*.json files.
75
+ const chatFiles = collectChatFiles(hashDir);
76
+ for (const chatFile of chatFiles) {
77
+ try {
78
+ const stat = fs.statSync(chatFile);
79
+ if (stat.mtimeMs < cutoff) {
80
+ continue;
81
+ }
82
+ const record = readChatFile(chatFile);
83
+ if (record) {
84
+ out.push({
85
+ cwd,
86
+ id: record.sessionId,
87
+ startedAt: new Date(record.startTime).getTime(),
88
+ });
89
+ }
90
+ } catch {
91
+ // skip unreadable files
92
+ }
93
+ }
94
+
95
+ // Also check logs.json mtime (covers old-format sessions).
96
+ const logsPath = path.join(hashDir, 'logs.json');
97
+ try {
98
+ const stat = fs.statSync(logsPath);
99
+ if (stat.mtimeMs < cutoff) {
100
+ continue;
101
+ }
102
+ // Surface the most recent session in this logs.json.
103
+ const entries = readLogsJson(logsPath);
104
+ if (entries.length > 0) {
105
+ const last = entries[entries.length - 1];
106
+ if (last) {
107
+ // Only add if not already captured from a chat file.
108
+ const alreadyAdded = out.some((o) => o.id === last.sessionId && o.cwd === cwd);
109
+ if (!alreadyAdded) {
110
+ out.push({
111
+ cwd,
112
+ id: last.sessionId,
113
+ startedAt: new Date(entries[0]?.timestamp ?? last.timestamp).getTime(),
114
+ });
115
+ }
116
+ }
117
+ }
118
+ } catch {
119
+ // logs.json missing or unreadable — skip
120
+ }
121
+ }
122
+
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Return the conversation messages for a given Gemini session ID.
128
+ * Prefers chats/session-*.json (full record) over logs.json (user-only).
129
+ * Mirrors getCodexConversation() pagination behaviour.
130
+ */
131
+ export function getGeminiConversation(
132
+ sessionId: string,
133
+ offset = 0,
134
+ limit = 200
135
+ ): ConversationMessage[] {
136
+ const projectDirs = collectProjectHashDirs();
137
+
138
+ for (const hashDir of projectDirs) {
139
+ // Try full chat record first.
140
+ const chatFile = findChatFileForSession(hashDir, sessionId);
141
+ if (chatFile) {
142
+ return conversationFromChatFile(chatFile, offset, limit);
143
+ }
144
+ }
145
+
146
+ // Fall back to logs.json user-only messages.
147
+ for (const hashDir of projectDirs) {
148
+ const messages = conversationFromLogsJson(hashDir, sessionId);
149
+ if (messages.length > 0) {
150
+ if (offset === 0 && messages.length > limit) {
151
+ return messages.slice(messages.length - limit);
152
+ }
153
+ return messages.slice(offset, offset + limit);
154
+ }
155
+ }
156
+
157
+ return [];
158
+ }
159
+
160
+ /**
161
+ * List all Gemini CLI sessions grouped by project, sorted by most-recently-
162
+ * modified first. Mirrors listCodexProjects() from codex-reader.ts.
163
+ */
164
+ export function listGeminiProjects(): ProjectGroup[] {
165
+ const projectHashToCwd = buildHashToCwdMap();
166
+ const projectDirs = collectProjectHashDirs();
167
+
168
+ const byCwd = new Map<string, SessionInfo[]>();
169
+
170
+ for (const hashDir of projectDirs) {
171
+ const hash = path.basename(hashDir);
172
+ const cwd = projectHashToCwd.get(hash) ?? hash;
173
+
174
+ // Prefer full chat records if any exist; fall back to logs.json.
175
+ const chatSessions = collectChatSessions(hashDir, cwd);
176
+ if (chatSessions.length > 0) {
177
+ for (const session of chatSessions) {
178
+ appendToMap(byCwd, cwd, session);
179
+ }
180
+ continue;
181
+ }
182
+
183
+ // Fall back: derive sessions from logs.json.
184
+ const logSessions = sessionsFromLogsJson(hashDir, cwd);
185
+ for (const session of logSessions) {
186
+ appendToMap(byCwd, cwd, session);
187
+ }
188
+ }
189
+
190
+ const projects: ProjectGroup[] = [];
191
+ for (const [cwd, sessions] of byCwd) {
192
+ sessions.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
193
+ const segments = cwd.split('/').filter(Boolean);
194
+ // When the project hash couldn't be reverse-mapped to a real cwd, show a
195
+ // short friendly label instead of the raw 64-char SHA-256 hash.
196
+ const isRawHash = /^[0-9a-f]{64}$/i.test(cwd);
197
+ projects.push({
198
+ fullPath: cwd,
199
+ id: shortHash(cwd),
200
+ lastModified: sessions[0]?.modified ?? '',
201
+ name: isRawHash ? `Gemini (${cwd.slice(0, 8)})` : segments.slice(-2).join('/'),
202
+ sessionCount: sessions.length,
203
+ sessions,
204
+ });
205
+ }
206
+
207
+ return projects.sort(
208
+ (a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Parse the full conversation from a single chats/session-*.json file.
214
+ * Returns all messages with no pagination — suitable for bulk processing.
215
+ */
216
+ export function parseGeminiSessionFile(filePath: string): ConversationMessage[] {
217
+ try {
218
+ return conversationFromChatFile(filePath, 0, Number.MAX_SAFE_INTEGER);
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Resolve the absolute path of the chats/session-*.json file backing the
226
+ * given sessionId, or null if no chat file exists (e.g. logs.json-only session).
227
+ */
228
+ export function resolveGeminiSessionFile(sessionId: string): null | string {
229
+ for (const hashDir of collectProjectHashDirs()) {
230
+ const chatFile = findChatFileForSession(hashDir, sessionId);
231
+ if (chatFile !== null) {
232
+ return chatFile;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Session building helpers
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function appendToMap<V>(map: Map<string, V[]>, key: string, value: V): void {
243
+ let bucket = map.get(key);
244
+ if (!bucket) {
245
+ bucket = [];
246
+ map.set(key, bucket);
247
+ }
248
+ bucket.push(value);
249
+ }
250
+
251
+ /**
252
+ * Build a SHA-256 projectHash → cwd map by reading ~/.gemini/projects.json.
253
+ * Returns an empty map if the file is absent (old gemini-cli installs).
254
+ */
255
+ function buildHashToCwdMap(): Map<string, string> {
256
+ const map = new Map<string, string>();
257
+ try {
258
+ const raw = fs.readFileSync(GEMINI_PROJECTS_JSON, 'utf-8');
259
+ const data = JSON.parse(raw) as GeminiProjectsJson;
260
+ for (const [slugOrHash, absolutePath] of Object.entries(data)) {
261
+ // projects.json may use either the slug or the full SHA-256 hash as key.
262
+ map.set(slugOrHash, absolutePath);
263
+ // Pre-compute SHA-256(absolutePath) → absolutePath as well so that
264
+ // old hash-named directories match even when projects.json uses slugs.
265
+ const computed = crypto.createHash('sha256').update(absolutePath).digest('hex');
266
+ map.set(computed, absolutePath);
267
+ }
268
+ } catch {
269
+ // File absent or malformed — that's fine for old gemini-cli installs.
270
+ }
271
+ return map;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Conversation building helpers
276
+ // ---------------------------------------------------------------------------
277
+
278
+ function cleanTitle(text: string): string {
279
+ const first = text.replace(/\s+/g, ' ').trim().split('\n')[0]?.trim() ?? '';
280
+ if (!first) {
281
+ return 'Untitled Session';
282
+ }
283
+ return first.length > 80 ? `${first.slice(0, 77)}...` : first;
284
+ }
285
+
286
+ /** Enumerate all chats/session-*.json files under a project hash dir. */
287
+ function collectChatFiles(hashDir: string): string[] {
288
+ const chatsDir = path.join(hashDir, 'chats');
289
+ try {
290
+ return fs
291
+ .readdirSync(chatsDir, { withFileTypes: true })
292
+ .filter((e) => e.isFile() && e.name.startsWith('session-') && e.name.endsWith('.json'))
293
+ .map((e) => path.join(chatsDir, e.name));
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // ConversationRecord → ConversationMessage mapping
301
+ // ---------------------------------------------------------------------------
302
+
303
+ /**
304
+ * Build SessionInfo records for each distinct sessionId found in a
305
+ * chats/session-*.json file under hashDir.
306
+ */
307
+ function collectChatSessions(hashDir: string, cwd: string): SessionInfo[] {
308
+ const chatFiles = collectChatFiles(hashDir);
309
+ const sessions: SessionInfo[] = [];
310
+
311
+ for (const chatFile of chatFiles) {
312
+ try {
313
+ const stat = fs.statSync(chatFile);
314
+ const record = readChatFile(chatFile);
315
+ if (!record) {
316
+ continue;
317
+ }
318
+
319
+ const userMessages = record.messages.filter((m) => m.type === 'user');
320
+ const firstUserMsg = userMessages[0];
321
+ let title = 'Untitled Session';
322
+ if (firstUserMsg) {
323
+ title = cleanTitle(extractTextFromContent(firstUserMsg.content));
324
+ }
325
+
326
+ sessions.push({
327
+ created: record.startTime,
328
+ gitBranch: '',
329
+ id: record.sessionId,
330
+ messageCount: record.messages.length,
331
+ modified: stat.mtime.toISOString(),
332
+ projectPath: cwd,
333
+ source: 'gemini' as const,
334
+ summary: record.summary ?? '',
335
+ title,
336
+ });
337
+ } catch {
338
+ // skip unreadable chat files
339
+ }
340
+ }
341
+
342
+ return sessions;
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // File I/O helpers
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /** Enumerate all ~/.gemini/tmp/<hash>/ directories. */
350
+ function collectProjectHashDirs(): string[] {
351
+ try {
352
+ return fs
353
+ .readdirSync(GEMINI_TMP, { withFileTypes: true })
354
+ .filter((e) => e.isDirectory())
355
+ .map((e) => path.join(GEMINI_TMP, e.name));
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+
361
+ function conversationFromChatFile(
362
+ chatFile: string,
363
+ offset: number,
364
+ limit: number
365
+ ): ConversationMessage[] {
366
+ try {
367
+ const record = readChatFile(chatFile);
368
+ if (!record) {
369
+ return [];
370
+ }
371
+
372
+ const messages = record.messages
373
+ .filter((m) => m.type === 'user' || m.type === 'gemini')
374
+ .map(messageRecordToMessage);
375
+
376
+ if (offset === 0 && messages.length > limit) {
377
+ let startIdx = messages.length - limit;
378
+ while (startIdx > 0 && messages[startIdx]?.role !== 'user') {
379
+ startIdx--;
380
+ }
381
+ return messages.slice(startIdx);
382
+ }
383
+ return messages.slice(offset, offset + limit);
384
+ } catch (err) {
385
+ console.error('[gemini] Failed to read chat file:', err);
386
+ return [];
387
+ }
388
+ }
389
+
390
+ function conversationFromLogsJson(hashDir: string, sessionId: string): ConversationMessage[] {
391
+ const logsPath = path.join(hashDir, 'logs.json');
392
+ const entries = readLogsJson(logsPath);
393
+ return entries
394
+ .filter((e) => e.sessionId === sessionId)
395
+ .map(
396
+ (entry): ConversationMessage => ({
397
+ id: `${entry.sessionId}-${String(entry.messageId)}`,
398
+ parts: [{ content: entry.message, type: 'text' }],
399
+ role: 'user',
400
+ timestamp: entry.timestamp,
401
+ })
402
+ );
403
+ }
404
+
405
+ /** Extract plain-text from a GeminiMessageRecord content field. */
406
+ function extractTextFromContent(
407
+ content: GeminiConversationRecord['messages'][0]['content']
408
+ ): string {
409
+ if (typeof content === 'string') {
410
+ return content;
411
+ }
412
+ return content
413
+ .filter((p): p is { text: string } => 'text' in p)
414
+ .map((p) => p.text)
415
+ .join(' ');
416
+ }
417
+
418
+ /**
419
+ * Find the chats/session-*.json file for a specific sessionId. The filename
420
+ * embeds the first 8 chars of the UUID, so prefix matches are tried first, but
421
+ * the exact sessionId field is always verified before returning — a short/empty
422
+ * id or a prefix collision must never mis-resolve to the wrong session.
423
+ */
424
+ function findChatFileForSession(hashDir: string, sessionId: string): null | string {
425
+ if (!sessionId) {
426
+ return null;
427
+ }
428
+ const chatsDir = path.join(hashDir, 'chats');
429
+ let names: string[];
430
+ try {
431
+ names = fs
432
+ .readdirSync(chatsDir, { withFileTypes: true })
433
+ .filter((e) => e.isFile() && e.name.startsWith('session-') && e.name.endsWith('.json'))
434
+ .map((e) => e.name);
435
+ } catch {
436
+ return null; // chats dir missing or unreadable
437
+ }
438
+ const shortId = sessionId.slice(0, 8);
439
+ // Order prefix-matching filenames first (cheap), then verify the exact id.
440
+ names.sort((a, b) => Number(b.includes(shortId)) - Number(a.includes(shortId)));
441
+ for (const name of names) {
442
+ const full = path.join(chatsDir, name);
443
+ if (readChatFile(full)?.sessionId === sessionId) {
444
+ return full;
445
+ }
446
+ }
447
+ return null;
448
+ }
449
+
450
+ function isGeminiLogEntry(v: unknown): v is GeminiLogEntry {
451
+ if (typeof v !== 'object' || v === null) {
452
+ return false;
453
+ }
454
+ const obj = v as Record<string, unknown>;
455
+ return (
456
+ typeof obj.sessionId === 'string' &&
457
+ typeof obj.messageId === 'number' &&
458
+ typeof obj.message === 'string' &&
459
+ typeof obj.timestamp === 'string'
460
+ );
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Utility helpers
465
+ // ---------------------------------------------------------------------------
466
+
467
+ function messageRecordToMessage(record: GeminiMessageRecord): ConversationMessage {
468
+ const role: 'assistant' | 'user' = record.type === 'user' ? 'user' : 'assistant';
469
+ const parts: MessagePart[] = [];
470
+
471
+ // 1. Thought summaries (only on 'gemini'-type messages).
472
+ if (record.type === 'gemini' && record.thoughts) {
473
+ for (const thought of record.thoughts) {
474
+ parts.push({ content: thought.summary ?? '', type: 'thinking' });
475
+ }
476
+ }
477
+
478
+ // 2. Content parts.
479
+ const rawContent = record.content;
480
+ const contentParts = typeof rawContent === 'string' ? [{ text: rawContent }] : rawContent;
481
+ for (const part of contentParts) {
482
+ if ('functionCall' in part) {
483
+ parts.push({
484
+ id: part.functionCall.id ?? part.functionCall.name,
485
+ input: part.functionCall.args,
486
+ toolName: part.functionCall.name,
487
+ type: 'tool_use',
488
+ });
489
+ } else if ('thought' in part && part.thought === true) {
490
+ parts.push({ content: part.text, type: 'thinking' });
491
+ } else if ('text' in part) {
492
+ parts.push({ content: part.text, type: 'text' });
493
+ }
494
+ }
495
+
496
+ // 3. Tool calls array (avoid duplicating entries already in content parts).
497
+ if (record.type === 'gemini' && record.toolCalls) {
498
+ for (const tc of record.toolCalls) {
499
+ const alreadyInParts = parts.some((p) => p.type === 'tool_use' && p.id === tc.id);
500
+ if (!alreadyInParts) {
501
+ parts.push({
502
+ id: tc.id,
503
+ input: tc.args,
504
+ toolName: tc.name,
505
+ type: 'tool_use',
506
+ });
507
+ }
508
+ }
509
+ }
510
+
511
+ return {
512
+ id: record.id,
513
+ parts,
514
+ role,
515
+ timestamp: record.timestamp,
516
+ };
517
+ }
518
+
519
+ /** Read and parse a chats/session-*.json file (a single JSON object). */
520
+ function readChatFile(filePath: string): GeminiConversationRecord | null {
521
+ try {
522
+ if (fs.statSync(filePath).size > MAX_GEMINI_FILE_BYTES) {
523
+ return null; // too large to parse safely as one JSON document
524
+ }
525
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as GeminiConversationRecord;
526
+ } catch {
527
+ return null;
528
+ }
529
+ }
530
+
531
+ /** Read and parse ~/.gemini/tmp/<hash>/logs.json (a single JSON array). */
532
+ function readLogsJson(logsPath: string): GeminiLogEntry[] {
533
+ try {
534
+ if (fs.statSync(logsPath).size > MAX_GEMINI_FILE_BYTES) {
535
+ return [];
536
+ }
537
+ const parsed: unknown = JSON.parse(fs.readFileSync(logsPath, 'utf-8'));
538
+ return Array.isArray(parsed) ? parsed.filter(isGeminiLogEntry) : [];
539
+ } catch {
540
+ return [];
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Build SessionInfo records for each distinct sessionId found in logs.json.
546
+ */
547
+ function sessionsFromLogsJson(hashDir: string, cwd: string): SessionInfo[] {
548
+ const logsPath = path.join(hashDir, 'logs.json');
549
+ let stat: fs.Stats;
550
+ try {
551
+ stat = fs.statSync(logsPath);
552
+ } catch {
553
+ return [];
554
+ }
555
+
556
+ const entries = readLogsJson(logsPath);
557
+ if (entries.length === 0) {
558
+ return [];
559
+ }
560
+
561
+ // Group by sessionId; preserve insertion order (chronological).
562
+ const bySession = new Map<string, GeminiLogEntry[]>();
563
+ for (const entry of entries) {
564
+ let bucket = bySession.get(entry.sessionId);
565
+ if (!bucket) {
566
+ bucket = [];
567
+ bySession.set(entry.sessionId, bucket);
568
+ }
569
+ bucket.push(entry);
570
+ }
571
+
572
+ const sessions: SessionInfo[] = [];
573
+ for (const [sessionId, sessionEntries] of bySession) {
574
+ const first = sessionEntries[0];
575
+ const last = sessionEntries[sessionEntries.length - 1];
576
+ sessions.push({
577
+ created: first?.timestamp ?? stat.birthtime.toISOString(),
578
+ gitBranch: '',
579
+ id: sessionId,
580
+ messageCount: sessionEntries.length,
581
+ modified: last?.timestamp ?? stat.mtime.toISOString(),
582
+ projectPath: cwd,
583
+ source: 'gemini' as const,
584
+ summary: '',
585
+ title: cleanTitle(first?.message ?? ''),
586
+ });
587
+ }
588
+
589
+ return sessions;
590
+ }
591
+
592
+ function shortHash(input: string): string {
593
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
594
+ }
@@ -1,26 +1,35 @@
1
+ import { existsSync } from 'fs';
1
2
  import * as path from 'path';
2
3
 
3
4
  /**
4
5
  * Resolve the path to the OpenCode SQLite database.
5
6
  *
6
- * Checks in this order:
7
+ * Probes candidate locations and returns the first that EXISTS (the previous
8
+ * version returned the macOS path unconditionally on darwin, which hid the DB
9
+ * for installs that use the XDG ~/.local/share path — observed in the wild).
10
+ * Candidate order:
7
11
  * 1. XDG_DATA_HOME/opencode/opencode.db (honours XDG override)
8
- * 2. ~/Library/Application Support/opencode/opencode.db (legacy macOS path)
9
- * 3. ~/.local/share/opencode/opencode.db (XDG default)
12
+ * 2. ~/.local/share/opencode/opencode.db (XDG default — common even on macOS)
13
+ * 3. ~/Library/Application Support/opencode/opencode.db (legacy macOS path)
10
14
  */
11
15
  export function resolveOpenCodeDbPath(): string {
12
16
  const home = process.env.HOME || '';
17
+ const candidates: string[] = [];
13
18
 
14
- // 1. If XDG_DATA_HOME is explicitly set, use it
15
19
  if (process.env.XDG_DATA_HOME) {
16
- return path.join(process.env.XDG_DATA_HOME, 'opencode', 'opencode.db');
20
+ candidates.push(path.join(process.env.XDG_DATA_HOME, 'opencode', 'opencode.db'));
17
21
  }
18
-
19
- // 2. Legacy macOS path (~/Library/Application Support/opencode/)
22
+ candidates.push(path.join(home, '.local', 'share', 'opencode', 'opencode.db'));
20
23
  if (process.platform === 'darwin') {
21
- return path.join(home, 'Library', 'Application Support', 'opencode', 'opencode.db');
24
+ candidates.push(path.join(home, 'Library', 'Application Support', 'opencode', 'opencode.db'));
25
+ }
26
+
27
+ for (const candidate of candidates) {
28
+ if (existsSync(candidate)) {
29
+ return candidate;
30
+ }
22
31
  }
23
32
 
24
- // 3. XDG default (~/.local/share/opencode/)
25
- return path.join(home, '.local', 'share', 'opencode', 'opencode.db');
33
+ // None exist yet — return the preferred default; callers tolerate a missing file.
34
+ return candidates[0] ?? path.join(home, '.local', 'share', 'opencode', 'opencode.db');
26
35
  }
@@ -2,16 +2,6 @@ import Database from 'better-sqlite3';
2
2
  import * as crypto from 'crypto';
3
3
  import * as fs from 'fs';
4
4
 
5
- function shortHash(input: string): string {
6
- return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
7
- }
8
-
9
- import type { ConversationMessage, MessagePart, ProjectGroup, SessionInfo } from '$lib/types';
10
-
11
- import { resolveOpenCodeDbPath } from './opencode-db-path';
12
-
13
- const OPENCODE_DB_PATH = resolveOpenCodeDbPath();
14
-
15
5
  export function getOpenCodeConversation(
16
6
  sessionId: string,
17
7
  offset = 0,
@@ -127,6 +117,10 @@ export function getOpenCodeConversation(
127
117
  }
128
118
  }
129
119
 
120
+ import type { ConversationMessage, MessagePart, ProjectGroup, SessionInfo } from '$lib/types';
121
+
122
+ import { resolveOpenCodeDbPath } from './opencode-db-path';
123
+
130
124
  export function listOpenCodeProjects(): ProjectGroup[] {
131
125
  const db = getDb();
132
126
  if (!db) {
@@ -251,12 +245,19 @@ function convertOpenCodePart(data: Record<string, unknown>): MessagePart | null
251
245
  }
252
246
 
253
247
  function getDb(): Database.Database | null {
254
- if (!fs.existsSync(OPENCODE_DB_PATH)) {
248
+ // Resolve per call (not at module load) so a DB created after server start —
249
+ // or an XDG_DATA_HOME change — is still picked up.
250
+ const dbPath = resolveOpenCodeDbPath();
251
+ if (!fs.existsSync(dbPath)) {
255
252
  return null;
256
253
  }
257
254
  try {
258
- return new Database(OPENCODE_DB_PATH, { readonly: true });
255
+ return new Database(dbPath, { readonly: true });
259
256
  } catch {
260
257
  return null;
261
258
  }
262
259
  }
260
+
261
+ function shortHash(input: string): string {
262
+ return crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
263
+ }