memhouse 0.0.1 → 0.3.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 (96) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +59 -3
  3. package/bin/memhouse.js +487 -0
  4. package/cache.js +850 -0
  5. package/ch-ingest.js +329 -0
  6. package/ch-schema.js +152 -0
  7. package/clickhouse.js +87 -0
  8. package/editors/antigravity.js +1019 -0
  9. package/editors/base.js +321 -0
  10. package/editors/claude.js +386 -0
  11. package/editors/codebuff.js +338 -0
  12. package/editors/codex.js +537 -0
  13. package/editors/commandcode.js +166 -0
  14. package/editors/copilot-jetbrains.js +207 -0
  15. package/editors/copilot.js +260 -0
  16. package/editors/cursor-agent.js +202 -0
  17. package/editors/cursor.js +438 -0
  18. package/editors/gemini.js +231 -0
  19. package/editors/goose.js +358 -0
  20. package/editors/gsd.js +366 -0
  21. package/editors/index.js +179 -0
  22. package/editors/kiro.js +315 -0
  23. package/editors/opencode.js +374 -0
  24. package/editors/vscode.js +413 -0
  25. package/editors/windsurf.js +634 -0
  26. package/editors/zed.js +213 -0
  27. package/index.js +424 -2
  28. package/mcp-server.js +294 -0
  29. package/mem-house/DESIGN.md +119 -0
  30. package/mem-house/delivery/AGENT-INSTALL.md +75 -0
  31. package/mem-house/delivery/PROMPT.md +42 -0
  32. package/mem-house/delivery/install.sh +181 -0
  33. package/mem-house/delivery/kernel-install.md +76 -0
  34. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  35. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  36. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  37. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  38. package/mem-house/rls.sql +17 -0
  39. package/mem-house/schema.sql +145 -0
  40. package/mem-house/server/queries.js +878 -0
  41. package/mem-house/server/server.js +228 -0
  42. package/mem-house/shipper/ship.js +402 -0
  43. package/misc/syntax-check.js +38 -0
  44. package/package.json +56 -7
  45. package/pricing.js +88 -0
  46. package/pricing.json +992 -0
  47. package/public/assets/index-BsgjOSTv.js +75 -0
  48. package/public/assets/index-DV6ONi_F.css +2 -0
  49. package/public/index.html +16 -0
  50. package/relay-client.js +306 -0
  51. package/relay-server.js +577 -0
  52. package/server.js +860 -0
  53. package/share-image.js +367 -0
  54. package/ui/eslint.config.js +29 -0
  55. package/ui/index.html +15 -0
  56. package/ui/package-lock.json +4229 -0
  57. package/ui/package.json +38 -0
  58. package/ui/src/App.jsx +364 -0
  59. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  60. package/ui/src/components/AiAuditCard.jsx +254 -0
  61. package/ui/src/components/AnimatedLoader.jsx +14 -0
  62. package/ui/src/components/AnimatedLogo.jsx +96 -0
  63. package/ui/src/components/ChatSidebar.jsx +226 -0
  64. package/ui/src/components/DateRangePicker.jsx +104 -0
  65. package/ui/src/components/EditorDot.jsx +11 -0
  66. package/ui/src/components/EditorIcon.jsx +117 -0
  67. package/ui/src/components/KpiCard.jsx +9 -0
  68. package/ui/src/components/LiveFeed.jsx +128 -0
  69. package/ui/src/components/LoginScreen.jsx +81 -0
  70. package/ui/src/components/MessageRenderer.jsx +148 -0
  71. package/ui/src/components/PageHeader.jsx +11 -0
  72. package/ui/src/components/SectionTitle.jsx +3 -0
  73. package/ui/src/components/ShareModal.jsx +305 -0
  74. package/ui/src/components/TokenTimeline.jsx +258 -0
  75. package/ui/src/hooks/useLive.jsx +15 -0
  76. package/ui/src/index.css +83 -0
  77. package/ui/src/lib/api.js +343 -0
  78. package/ui/src/lib/constants.js +88 -0
  79. package/ui/src/lib/theme.jsx +23 -0
  80. package/ui/src/main.jsx +16 -0
  81. package/ui/src/pages/Artifacts.jsx +575 -0
  82. package/ui/src/pages/Compare.jsx +232 -0
  83. package/ui/src/pages/CostAnalysis.jsx +354 -0
  84. package/ui/src/pages/Dashboard.jsx +529 -0
  85. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  86. package/ui/src/pages/GSD.jsx +726 -0
  87. package/ui/src/pages/MCPs.jsx +744 -0
  88. package/ui/src/pages/ProjectDetail.jsx +325 -0
  89. package/ui/src/pages/Projects.jsx +249 -0
  90. package/ui/src/pages/RelayDashboard.jsx +590 -0
  91. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  92. package/ui/src/pages/Sessions.jsx +535 -0
  93. package/ui/src/pages/Settings.jsx +241 -0
  94. package/ui/src/pages/SqlViewer.jsx +344 -0
  95. package/ui/src/pages/Subscriptions.jsx +411 -0
  96. package/ui/vite.config.js +18 -0
@@ -0,0 +1,438 @@
1
+ const Database = require('better-sqlite3');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const { getAppDataPath } = require('./base');
6
+
7
+ const HOME = os.homedir();
8
+ const CURSOR_CHATS_DIR = path.join(HOME, '.cursor', 'chats');
9
+ const CURSOR_USER_DIR = path.join(getAppDataPath('Cursor'), 'User');
10
+ const WORKSPACE_STORAGE_DIR = path.join(CURSOR_USER_DIR, 'workspaceStorage');
11
+ const GLOBAL_STORAGE_DB = path.join(CURSOR_USER_DIR, 'globalStorage', 'state.vscdb');
12
+
13
+ // ============================================================
14
+ // Source 1: ~/.cursor/chats/<hash>/<chatId>/store.db (agent KV)
15
+ // ============================================================
16
+
17
+ function getAgentStoreChats() {
18
+ const results = [];
19
+ if (!fs.existsSync(CURSOR_CHATS_DIR)) return results;
20
+
21
+ for (const workspace of fs.readdirSync(CURSOR_CHATS_DIR)) {
22
+ const wsDir = path.join(CURSOR_CHATS_DIR, workspace);
23
+ if (!fs.statSync(wsDir).isDirectory()) continue;
24
+ for (const chat of fs.readdirSync(wsDir)) {
25
+ const dbPath = path.join(wsDir, chat, 'store.db');
26
+ if (fs.existsSync(dbPath)) {
27
+ results.push({ workspace, chatId: chat, dbPath });
28
+ }
29
+ }
30
+ }
31
+ return results;
32
+ }
33
+
34
+ function hexToString(hex) {
35
+ let str = '';
36
+ for (let i = 0; i < hex.length; i += 2) {
37
+ str += String.fromCharCode(parseInt(hex.substring(i, i + 2), 16));
38
+ }
39
+ return str;
40
+ }
41
+
42
+ function readStoreMeta(db) {
43
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('0');
44
+ if (!row) return null;
45
+ const hex = typeof row.value === 'string' ? row.value : Buffer.from(row.value).toString('hex');
46
+ try {
47
+ return JSON.parse(hexToString(hex));
48
+ } catch {
49
+ try { return JSON.parse(row.value); } catch { return null; }
50
+ }
51
+ }
52
+
53
+ function parseTreeBlob(data) {
54
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
55
+ const messageRefs = [];
56
+ const childRefs = [];
57
+ let offset = 0;
58
+ while (offset < buf.length) {
59
+ if (offset + 34 > buf.length) break;
60
+ const tag = buf[offset];
61
+ const len = buf[offset + 1];
62
+ if (len !== 0x20) break;
63
+ const hash = buf.slice(offset + 2, offset + 2 + 32).toString('hex');
64
+ if (tag === 0x0a) messageRefs.push(hash);
65
+ else if (tag === 0x12) childRefs.push(hash);
66
+ else break;
67
+ offset += 2 + 32;
68
+ }
69
+ return { messageRefs, childRefs };
70
+ }
71
+
72
+ function normalizeStoreMessage(json) {
73
+ const msg = { role: json.role, content: '', _toolCalls: [] };
74
+ // Content may be string or array of parts
75
+ if (typeof json.content === 'string') {
76
+ msg.content = json.content;
77
+ } else if (Array.isArray(json.content)) {
78
+ msg.content = json.content.map(p => typeof p === 'string' ? p : (p.text || '')).join('\n');
79
+ }
80
+ // Tool calls (OpenAI format)
81
+ if (json.tool_calls && Array.isArray(json.tool_calls)) {
82
+ for (const tc of json.tool_calls) {
83
+ const name = tc.function?.name || tc.name || 'unknown';
84
+ let args = {};
85
+ try {
86
+ args = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {});
87
+ } catch {}
88
+ const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
89
+ msg.content += `\n[tool-call: ${name}(${argKeys})]`;
90
+ msg._toolCalls.push({ name, args });
91
+ }
92
+ }
93
+ if (json.model) msg._model = json.model;
94
+ return msg;
95
+ }
96
+
97
+ function collectStoreMessages(db, rootBlobId) {
98
+ const allMessages = [];
99
+ const visited = new Set();
100
+ function walk(blobId) {
101
+ if (visited.has(blobId)) return;
102
+ visited.add(blobId);
103
+ const row = db.prepare('SELECT data FROM blobs WHERE id = ?').get(blobId);
104
+ if (!row) return;
105
+ const data = row.data;
106
+ try {
107
+ const json = typeof data === 'string' ? JSON.parse(data) : JSON.parse(data.toString('utf-8'));
108
+ if (json && json.role) { allMessages.push(normalizeStoreMessage(json)); return; }
109
+ } catch { /* tree blob */ }
110
+ const { messageRefs, childRefs } = parseTreeBlob(data);
111
+ for (const ref of messageRefs) walk(ref);
112
+ for (const ref of childRefs) walk(ref);
113
+ }
114
+ walk(rootBlobId);
115
+ return allMessages;
116
+ }
117
+
118
+ // ============================================================
119
+ // Source 2: workspaceStorage + globalStorage (composer bubbles)
120
+ // ============================================================
121
+
122
+ function getWorkspaceMap() {
123
+ const map = [];
124
+ if (!fs.existsSync(WORKSPACE_STORAGE_DIR)) return map;
125
+ for (const hash of fs.readdirSync(WORKSPACE_STORAGE_DIR)) {
126
+ const dir = path.join(WORKSPACE_STORAGE_DIR, hash);
127
+ const wsJson = path.join(dir, 'workspace.json');
128
+ const stateDb = path.join(dir, 'state.vscdb');
129
+ if (!fs.existsSync(wsJson) || !fs.existsSync(stateDb)) continue;
130
+ try {
131
+ const ws = JSON.parse(fs.readFileSync(wsJson, 'utf-8'));
132
+ const folder = (ws.folder || '').replace('file://', '');
133
+ map.push({ hash, folder, stateDb });
134
+ } catch { /* skip */ }
135
+ }
136
+ return map;
137
+ }
138
+
139
+ function getComposerHeaders(stateDbPath) {
140
+ try {
141
+ const db = new Database(stateDbPath, { readonly: true });
142
+ const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'composer.composerData'").get();
143
+ db.close();
144
+ if (!row) return [];
145
+ const data = JSON.parse(row.value);
146
+ return (data.allComposers || []).map((c) => ({
147
+ composerId: c.composerId,
148
+ name: c.name || null,
149
+ createdAt: c.createdAt || null,
150
+ lastUpdatedAt: c.lastUpdatedAt || null,
151
+ mode: c.unifiedMode || c.forceMode || 'unknown',
152
+ isAgentic: c.unifiedMode === 'agent',
153
+ }));
154
+ } catch { return []; }
155
+ }
156
+
157
+ function getModelPreference(globalDb) {
158
+ try {
159
+ const row = globalDb.prepare("SELECT value FROM ItemTable WHERE key = 'cursor/lastSingleModelPreference'").get();
160
+ if (!row) return null;
161
+ const pref = JSON.parse(row.value);
162
+ return pref.composer || pref.agent || null;
163
+ } catch { return null; }
164
+ }
165
+
166
+ function getComposerBubbles(globalDb, composerId) {
167
+ const prefix = `bubbleId:${composerId}:`;
168
+ const rows = globalDb.prepare(
169
+ "SELECT key, value FROM cursorDiskKV WHERE key LIKE ? ORDER BY key"
170
+ ).all(prefix + '%');
171
+
172
+ const bubbles = [];
173
+ for (const row of rows) {
174
+ try {
175
+ const obj = JSON.parse(row.value);
176
+ bubbles.push(obj);
177
+ } catch { /* binary blob, skip */ }
178
+ }
179
+ return bubbles;
180
+ }
181
+
182
+ function bubblesToMessages(bubbles) {
183
+ const messages = [];
184
+ for (const b of bubbles) {
185
+ if (!b) continue;
186
+ const type = b.type; // 1=user, 2=assistant
187
+ if (type === 1) {
188
+ const text = b.text || '';
189
+ if (text) {
190
+ messages.push({ role: 'user', content: text });
191
+ }
192
+ } else if (type === 2) {
193
+ const textParts = [];
194
+ // Thinking block
195
+ if (b.thinking && b.thinking.text) {
196
+ textParts.push(`[thinking] ${b.thinking.text}`);
197
+ }
198
+ // Main text
199
+ if (b.text) {
200
+ textParts.push(b.text);
201
+ }
202
+ // Tool calls — format as [tool-call: name(argKeys)] for analytics
203
+ const _toolCalls = [];
204
+ const tfd = b.toolFormerData;
205
+ if (tfd && tfd.name) {
206
+ let args = {};
207
+ try {
208
+ args = typeof tfd.rawArgs === 'string' && tfd.rawArgs !== '' ? JSON.parse(tfd.rawArgs) : (tfd.rawArgs || {});
209
+ } catch {}
210
+ // Fallback: use params field when rawArgs is empty
211
+ if (!args || (typeof args === 'object' && Object.keys(args).length === 0)) {
212
+ try {
213
+ const p = typeof tfd.params === 'string' ? JSON.parse(tfd.params) : tfd.params;
214
+ if (p && typeof p === 'object' && Object.keys(p).length > 0) args = p;
215
+ } catch {}
216
+ }
217
+ const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
218
+ textParts.push(`[tool-call: ${tfd.name}(${argKeys})]`);
219
+ _toolCalls.push({ name: tfd.name, args });
220
+ if (tfd.result) {
221
+ const preview = typeof tfd.result === 'string' ? tfd.result.substring(0, 300) : JSON.stringify(tfd.result).substring(0, 300);
222
+ textParts.push(`[tool-result: ${tfd.name}] ${preview}`);
223
+ }
224
+ }
225
+ // Code blocks
226
+ if (b.codeBlocks && b.codeBlocks.length > 0) {
227
+ for (const cb of b.codeBlocks) {
228
+ const filePath = cb.uri ? cb.uri.path : '';
229
+ if (filePath) textParts.push(`[file: ${filePath}]`);
230
+ }
231
+ }
232
+ if (textParts.length > 0) {
233
+ messages.push({
234
+ role: 'assistant',
235
+ content: textParts.join('\n'),
236
+ _model: b.modelId || b.model || null,
237
+ _inputTokens: b.tokenCount?.inputTokens,
238
+ _outputTokens: b.tokenCount?.outputTokens,
239
+ _toolCalls,
240
+ });
241
+ }
242
+ }
243
+ }
244
+ return messages;
245
+ }
246
+
247
+ // ============================================================
248
+ // Adapter interface
249
+ // ============================================================
250
+
251
+ const name = 'cursor';
252
+
253
+ function getChats() {
254
+ const chats = [];
255
+
256
+ // Source 1: ~/.cursor/chats store.db
257
+ for (const { workspace, chatId, dbPath } of getAgentStoreChats()) {
258
+ try {
259
+ const db = new Database(dbPath, { readonly: true });
260
+ const meta = readStoreMeta(db);
261
+ db.close();
262
+ if (meta) {
263
+ chats.push({
264
+ source: 'cursor',
265
+ composerId: chatId,
266
+ name: meta.name || null,
267
+ createdAt: meta.createdAt || null,
268
+ mode: meta.mode || null,
269
+ folder: null,
270
+ _dbPath: dbPath,
271
+ _rootBlobId: meta.latestRootBlobId,
272
+ _lastUsedModel: meta.lastUsedModel || null,
273
+ _type: 'agent-store',
274
+ });
275
+ }
276
+ } catch { /* skip */ }
277
+ }
278
+
279
+ // Source 2: workspaceStorage composers
280
+ let globalDb = null;
281
+ try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { /* no global db */ }
282
+
283
+ const modelPref = globalDb ? getModelPreference(globalDb) : null;
284
+
285
+ for (const { hash, folder, stateDb } of getWorkspaceMap()) {
286
+ const headers = getComposerHeaders(stateDb);
287
+ for (const h of headers) {
288
+ let bubbleCount = 0;
289
+ if (globalDb) {
290
+ try {
291
+ const countRow = globalDb.prepare(
292
+ "SELECT count(*) as cnt FROM cursorDiskKV WHERE key LIKE ?"
293
+ ).get(`bubbleId:${h.composerId}:%`);
294
+ bubbleCount = countRow ? countRow.cnt : 0;
295
+ } catch { /* skip */ }
296
+ }
297
+ chats.push({
298
+ source: 'cursor',
299
+ composerId: h.composerId,
300
+ name: h.name || null,
301
+ createdAt: h.createdAt || null,
302
+ lastUpdatedAt: h.lastUpdatedAt || null,
303
+ mode: h.mode,
304
+ folder,
305
+ bubbleCount,
306
+ _type: 'workspace',
307
+ _modelPref: modelPref,
308
+ });
309
+ }
310
+ }
311
+
312
+ if (globalDb) globalDb.close();
313
+ return chats;
314
+ }
315
+
316
+ function getMessages(chat) {
317
+ if (chat._type === 'agent-store') {
318
+ const db = new Database(chat._dbPath, { readonly: true });
319
+ const msgs = collectStoreMessages(db, chat._rootBlobId);
320
+ db.close();
321
+ // Use lastUsedModel as fallback for assistant messages without model info
322
+ if (chat._lastUsedModel) {
323
+ for (const m of msgs) {
324
+ if (m.role === 'assistant' && !m._model) m._model = chat._lastUsedModel;
325
+ }
326
+ }
327
+ return msgs;
328
+ }
329
+
330
+ let globalDb;
331
+ try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { return []; }
332
+ const bubbles = getComposerBubbles(globalDb, chat.composerId);
333
+ globalDb.close();
334
+ const msgs = bubblesToMessages(bubbles);
335
+ // Use model preference as fallback for messages without model info
336
+ if (chat._modelPref) {
337
+ for (const m of msgs) {
338
+ if (m.role === 'assistant' && !m._model) m._model = chat._modelPref;
339
+ }
340
+ }
341
+ return msgs;
342
+ }
343
+
344
+ // ============================================================
345
+ // Usage / quota data from Cursor REST API
346
+ // ============================================================
347
+
348
+ function getCursorAccessToken() {
349
+ try {
350
+ const db = new Database(GLOBAL_STORAGE_DB, { readonly: true });
351
+ const row = db.prepare("SELECT value FROM ItemTable WHERE key = 'cursorAuth/accessToken'").get();
352
+ db.close();
353
+ return row ? row.value : null;
354
+ } catch { return null; }
355
+ }
356
+
357
+ function cursorApiFetch(endpoint, token) {
358
+ return new Promise((resolve) => {
359
+ const https = require('https');
360
+ const url = `https://api2.cursor.sh/auth/${endpoint}`;
361
+ const req = https.get(url, {
362
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
363
+ timeout: 10000,
364
+ }, (res) => {
365
+ let data = '';
366
+ res.on('data', (chunk) => { data += chunk; });
367
+ res.on('end', () => {
368
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
369
+ });
370
+ });
371
+ req.on('error', () => resolve(null));
372
+ req.on('timeout', () => { req.destroy(); resolve(null); });
373
+ });
374
+ }
375
+
376
+ async function getUsage() {
377
+ const { isSubscriptionAccessAllowed } = require('./base');
378
+ if (!isSubscriptionAccessAllowed()) return null;
379
+ const token = getCursorAccessToken();
380
+ if (!token) return null;
381
+
382
+ const [profile, usage] = await Promise.all([
383
+ cursorApiFetch('full_stripe_profile', token),
384
+ cursorApiFetch('usage', token),
385
+ ]);
386
+
387
+ if (!profile && !usage) return null;
388
+
389
+ const result = {
390
+ source: 'cursor',
391
+ plan: {
392
+ name: profile?.individualMembershipType || profile?.membershipType || null,
393
+ status: profile?.subscriptionStatus || null,
394
+ isTeamMember: profile?.isTeamMember || false,
395
+ isYearlyPlan: profile?.isYearlyPlan || false,
396
+ },
397
+ usage: {},
398
+ startOfMonth: usage?.startOfMonth || null,
399
+ };
400
+
401
+ // Parse per-model usage from the usage endpoint
402
+ if (usage) {
403
+ for (const [model, data] of Object.entries(usage)) {
404
+ if (model === 'startOfMonth') continue;
405
+ result.usage[model] = {
406
+ numRequests: data.numRequests || 0,
407
+ numRequestsTotal: data.numRequestsTotal || 0,
408
+ numTokens: data.numTokens || 0,
409
+ maxRequestUsage: data.maxRequestUsage || null,
410
+ maxTokenUsage: data.maxTokenUsage || null,
411
+ };
412
+ }
413
+ }
414
+
415
+ return result;
416
+ }
417
+
418
+ const labels = { 'cursor': 'Cursor' };
419
+
420
+ function getArtifacts(folder) {
421
+ const { scanArtifacts } = require('./base');
422
+ return scanArtifacts(folder, {
423
+ editor: 'cursor',
424
+ label: 'Cursor',
425
+ files: ['.cursorrules', 'AGENTS.md'],
426
+ dirs: ['.cursor/rules', '.cursor/plans'],
427
+ });
428
+ }
429
+
430
+ function getMCPServers() {
431
+ const { parseMcpConfigFile } = require('./base');
432
+ const globalConfig = path.join(HOME, '.cursor', 'mcp.json');
433
+ return [
434
+ ...parseMcpConfigFile(globalConfig, { editor: 'cursor', label: 'Cursor', scope: 'global' }),
435
+ ];
436
+ }
437
+
438
+ module.exports = { name, labels, getChats, getMessages, getUsage, getArtifacts, getMCPServers };
@@ -0,0 +1,231 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+
5
+ const GEMINI_DIR = path.join(os.homedir(), '.gemini');
6
+ const TMP_DIR = path.join(GEMINI_DIR, 'tmp');
7
+ const PROJECTS_JSON = path.join(GEMINI_DIR, 'projects.json');
8
+
9
+ // ============================================================
10
+ // Adapter interface
11
+ // ============================================================
12
+
13
+ const name = 'gemini-cli';
14
+
15
+ /**
16
+ * Load project path mapping from ~/.gemini/projects.json
17
+ * Format: { "projects": { "/Users/dev/Code/myapp": "myapp" } }
18
+ * Returns Map<projectName, folderPath>
19
+ */
20
+ function loadProjectMap() {
21
+ const map = new Map();
22
+ try {
23
+ const data = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
24
+ if (data.projects) {
25
+ for (const [folderPath, projName] of Object.entries(data.projects)) {
26
+ map.set(projName, folderPath);
27
+ }
28
+ }
29
+ } catch {}
30
+ return map;
31
+ }
32
+
33
+ /**
34
+ * Load a Gemini session record, handling BOTH on-disk formats:
35
+ * - legacy single-object JSON: { sessionId, startTime, lastUpdated, messages: [...] }
36
+ * - newer .jsonl oplog: a header line, then interleaved message objects and
37
+ * {$set:{messages:[...]}} snapshots. Reconstruct = the latest messages snapshot
38
+ * (they accumulate) plus any message objects appended after it.
39
+ * Returns { sessionId, startTime, lastUpdated, messages }.
40
+ */
41
+ function loadGeminiRecord(filePath) {
42
+ const raw = fs.readFileSync(filePath, 'utf-8');
43
+ if (!filePath.endsWith('.jsonl')) {
44
+ const rec = JSON.parse(raw);
45
+ return { sessionId: rec.sessionId, startTime: rec.startTime, lastUpdated: rec.lastUpdated, messages: rec.messages || [] };
46
+ }
47
+ let header = {}, messages = null, tail = [];
48
+ for (const line of raw.split('\n')) {
49
+ if (!line.trim()) continue;
50
+ let o; try { o = JSON.parse(line); } catch { continue; }
51
+ if (o.$set) {
52
+ if (Array.isArray(o.$set.messages)) { messages = o.$set.messages; tail = []; }
53
+ if (o.$set.lastUpdated) header.lastUpdated = o.$set.lastUpdated;
54
+ } else if (o.type && o.content !== undefined) {
55
+ tail.push(o); // message appended after the last snapshot
56
+ } else if (o.sessionId || o.startTime) {
57
+ header = { ...header, ...o }; // header/meta line
58
+ }
59
+ }
60
+ return {
61
+ sessionId: header.sessionId,
62
+ startTime: header.startTime,
63
+ lastUpdated: header.lastUpdated,
64
+ messages: (messages || []).concat(tail),
65
+ };
66
+ }
67
+
68
+ function getChats() {
69
+ const chats = [];
70
+ if (!fs.existsSync(TMP_DIR)) return chats;
71
+
72
+ const projectMap = loadProjectMap();
73
+
74
+ // Each subdirectory under tmp/ is a project name (e.g. "codename-share")
75
+ let projectDirs;
76
+ try { projectDirs = fs.readdirSync(TMP_DIR); } catch { return chats; }
77
+
78
+ for (const projName of projectDirs) {
79
+ const projDir = path.join(TMP_DIR, projName);
80
+ try { if (!fs.statSync(projDir).isDirectory()) continue; } catch { continue; }
81
+
82
+ // Sessions are in <projDir>/chats/session-*.json
83
+ const chatsDir = path.join(projDir, 'chats');
84
+ if (!fs.existsSync(chatsDir)) continue;
85
+
86
+ let files;
87
+ try {
88
+ // Both formats: legacy session-*.json + newer session-*.jsonl (oplog).
89
+ // The chats/<uuid>/ checkpoint subdirs are skipped (not session- prefixed).
90
+ files = fs.readdirSync(chatsDir).filter(f => f.startsWith('session-') && (f.endsWith('.json') || f.endsWith('.jsonl')));
91
+ } catch { continue; }
92
+
93
+ // Resolve folder from projects.json mapping
94
+ const folder = projectMap.get(projName) || null;
95
+
96
+ for (const file of files) {
97
+ const fullPath = path.join(chatsDir, file);
98
+ try {
99
+ const record = loadGeminiRecord(fullPath);
100
+ if (!record || !record.messages || record.messages.length === 0) continue;
101
+
102
+ const sessionId = record.sessionId || file.replace(/\.jsonl?$/, '');
103
+ const messages = record.messages;
104
+
105
+ // Extract first user prompt for title
106
+ const firstUser = messages.find(m => m.type === 'user');
107
+ const firstPrompt = extractTextContent(firstUser?.content);
108
+
109
+ chats.push({
110
+ source: 'gemini-cli',
111
+ composerId: sessionId,
112
+ name: firstPrompt ? cleanPrompt(firstPrompt) : null,
113
+ createdAt: record.startTime ? new Date(record.startTime).getTime() : null,
114
+ lastUpdatedAt: record.lastUpdated ? new Date(record.lastUpdated).getTime() : null,
115
+ mode: 'gemini',
116
+ folder,
117
+ encrypted: false,
118
+ bubbleCount: messages.length,
119
+ _fullPath: fullPath,
120
+ });
121
+ } catch { /* skip malformed files */ }
122
+ }
123
+ }
124
+
125
+ return chats;
126
+ }
127
+
128
+ function cleanPrompt(text) {
129
+ if (!text) return null;
130
+ return text.replace(/\s+/g, ' ').trim().substring(0, 120) || null;
131
+ }
132
+
133
+ function extractTextContent(content) {
134
+ if (!content) return '';
135
+ if (typeof content === 'string') return content;
136
+ // Array of { text } parts (user messages)
137
+ if (Array.isArray(content)) {
138
+ return content
139
+ .filter(p => p.text)
140
+ .map(p => p.text)
141
+ .join('\n') || '';
142
+ }
143
+ if (content.text) return content.text;
144
+ return '';
145
+ }
146
+
147
+ function getMessages(chat) {
148
+ const filePath = chat._fullPath;
149
+ if (!filePath || !fs.existsSync(filePath)) return [];
150
+
151
+ let record;
152
+ try {
153
+ record = loadGeminiRecord(filePath);
154
+ } catch { return []; }
155
+
156
+ if (!record || !record.messages) return [];
157
+
158
+ const result = [];
159
+ for (const msg of record.messages) {
160
+ const type = msg.type;
161
+ const text = extractTextContent(msg.content || msg.displayContent);
162
+
163
+ if (type === 'user') {
164
+ if (text) result.push({ role: 'user', content: text });
165
+ } else if (type === 'gemini') {
166
+ const parts = [];
167
+ const toolCalls = [];
168
+
169
+ // Thoughts have { subject, description, timestamp }
170
+ if (msg.thoughts && Array.isArray(msg.thoughts)) {
171
+ for (const t of msg.thoughts) {
172
+ const thought = t.description || t.subject || '';
173
+ if (thought) parts.push(`[thinking] ${thought}`);
174
+ }
175
+ }
176
+
177
+ // Main text content (string for gemini messages)
178
+ if (text) parts.push(text);
179
+
180
+ // Tool calls
181
+ if (msg.toolCalls && Array.isArray(msg.toolCalls)) {
182
+ for (const tc of msg.toolCalls) {
183
+ const args = tc.args || {};
184
+ const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
185
+ parts.push(`[tool-call: ${tc.name || 'unknown'}(${argKeys})]`);
186
+ toolCalls.push({ name: tc.name || 'unknown', args });
187
+ }
188
+ }
189
+
190
+ if (parts.length > 0) {
191
+ const tokens = msg.tokens || {};
192
+ result.push({
193
+ role: 'assistant',
194
+ content: parts.join('\n'),
195
+ _model: msg.model,
196
+ _inputTokens: tokens.input,
197
+ _outputTokens: tokens.output,
198
+ _cacheRead: tokens.cached,
199
+ _toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
200
+ });
201
+ }
202
+ } else if (type === 'info' || type === 'error' || type === 'warning') {
203
+ if (text) result.push({ role: 'system', content: `[${type}] ${text}` });
204
+ }
205
+ }
206
+
207
+ return result;
208
+ }
209
+
210
+ const labels = { 'gemini-cli': 'Gemini CLI' };
211
+
212
+ function getArtifacts(folder) {
213
+ const { scanArtifacts } = require('./base');
214
+ return scanArtifacts(folder, {
215
+ editor: 'gemini-cli',
216
+ label: 'Gemini CLI',
217
+ files: ['GEMINI.md'],
218
+ dirs: [],
219
+ });
220
+ }
221
+
222
+ function getMCPServers() {
223
+ const { parseMcpConfigFile } = require('./base');
224
+ // Global: ~/.gemini/settings.json (mcpServers key)
225
+ const globalSettings = path.join(os.homedir(), '.gemini', 'settings.json');
226
+ return [
227
+ ...parseMcpConfigFile(globalSettings, { editor: 'gemini-cli', label: 'Gemini CLI', scope: 'global' }),
228
+ ];
229
+ }
230
+
231
+ module.exports = { name, labels, getChats, getMessages, getArtifacts, getMCPServers };