shakers-cli-beta 0.8.0-beta.5 → 0.9.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.
@@ -4,6 +4,13 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { getHomeDir } = require('./env-paths');
6
6
  const { scrubSecrets } = require('./agent-synthesis');
7
+ const {
8
+ normalizeCodexRecord,
9
+ normalizeGeminiRecord,
10
+ normalizeContinueRecord,
11
+ normalizeClineRecord,
12
+ parseAiderHistory,
13
+ } = require('./session-normalizers');
7
14
 
8
15
  // GLOBAL, machine-wide inventory of AI coding-tool SESSIONS (hybrid evaluation, Slice 1).
9
16
 
@@ -73,16 +80,39 @@ const FILE_TOOL_NAMES = new Set([
73
80
  ]);
74
81
  const PLANNING_TOOL_NAMES = new Set(['ExitPlanMode', 'TodoWrite']);
75
82
 
83
+ // Only logs.json + session-*.jsonl under ~/.gemini/tmp are transcripts (skip checkpoints/config).
84
+ function isGeminiTranscriptFile(name) {
85
+ return name === 'logs.json' || (name.startsWith('session-') && name.endsWith('.jsonl'));
86
+ }
87
+
88
+ // Cline / Roo Code conversation file (both forks use this filename under tasks/<id>/).
89
+ function isClineHistoryFile(name) {
90
+ return name === 'api_conversation_history.json';
91
+ }
92
+
93
+ // VS Code globalStorage root per OS (Cline/Roo live under <ext-id>/tasks/<id>/ here).
94
+ function vscodeGlobalStorageDir(home) {
95
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage');
96
+ if (process.platform === 'win32') return path.join(home, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage');
97
+ return path.join(home, '.config', 'Code', 'User', 'globalStorage');
98
+ }
99
+
76
100
  function toolSources(env = process.env) {
77
101
  const home = getHomeDir(env);
78
102
  return [
79
103
  { tool: 'claude', dir: path.join(home, '.claude', 'projects') },
80
- { tool: 'codex', dir: path.join(home, '.codex', 'sessions') },
104
+ { tool: 'codex', dir: path.join(home, '.codex', 'sessions'), normalize: normalizeCodexRecord },
105
+ { tool: 'gemini', dir: path.join(home, '.gemini', 'tmp'), fileFilter: isGeminiTranscriptFile, normalize: normalizeGeminiRecord },
106
+ // Kept by decision: ~/.cursor/chats is OBSOLETE (real Cursor chat is SQLite in App Support); awaiting a future SQLite extractor, no tool removed.
81
107
  { tool: 'cursor', dir: path.join(home, '.cursor', 'chats') },
108
+ { tool: 'continue', dir: path.join(home, '.continue', 'sessions'), normalize: normalizeContinueRecord },
109
+ { tool: 'cline', dir: vscodeGlobalStorageDir(home), fileFilter: isClineHistoryFile, normalize: normalizeClineRecord },
110
+ // Repo-level: .aider.chat.history.md lives INSIDE each git repo, so cwd = the repo toplevel.
111
+ { tool: 'aider', repoLevel: true, file: '.aider.chat.history.md', parseText: parseAiderHistory },
82
112
  ];
83
113
  }
84
114
 
85
- function listSessionFiles(root) {
115
+ function listSessionFiles(root, fileFilter = isTranscriptFile) {
86
116
  const out = [];
87
117
  const stack = [root];
88
118
  while (stack.length > 0) {
@@ -97,7 +127,7 @@ function listSessionFiles(root) {
97
127
  const full = path.join(dir, entry.name);
98
128
  if (entry.isDirectory()) {
99
129
  stack.push(full);
100
- } else if (entry.isFile() && isTranscriptFile(entry.name)) {
130
+ } else if (entry.isFile() && fileFilter(entry.name)) {
101
131
  out.push(full);
102
132
  if (out.length >= MAX_FILES_PER_TOOL) return out;
103
133
  }
@@ -106,6 +136,77 @@ function listSessionFiles(root) {
106
136
  return out;
107
137
  }
108
138
 
139
+ // Whole-file JSON reader (bounded) for `.json` transcripts (e.g. Gemini logs.json = array).
140
+ function readJsonArrayFile(file) {
141
+ let fd;
142
+ try {
143
+ fd = fs.openSync(file, 'r');
144
+ } catch {
145
+ return [];
146
+ }
147
+ try {
148
+ const { size } = fs.fstatSync(fd);
149
+ const readLen = Math.min(size, MAX_BYTES_PER_FILE);
150
+ const buf = Buffer.allocUnsafe(readLen);
151
+ fs.readSync(fd, buf, 0, readLen, 0);
152
+ const parsed = JSON.parse(buf.toString('utf8'));
153
+ if (Array.isArray(parsed)) return parsed;
154
+ return parsed && typeof parsed === 'object' ? [parsed] : [];
155
+ } catch {
156
+ return [];
157
+ } finally {
158
+ try { fs.closeSync(fd); } catch { /* already closed */ }
159
+ }
160
+ }
161
+
162
+ // Parse a file to raw records by extension (.jsonl = per line, .json = whole array).
163
+ function parseRawRecords(file) {
164
+ return file.endsWith('.jsonl') ? readSessionObjects(file) : readJsonArrayFile(file);
165
+ }
166
+
167
+ // Read a source's file and normalize its records into the Claude-shaped internal model.
168
+ // A normalizer may return an object, an array (expanded), or null/[] (fail-closed skip).
169
+ function readSourceObjects(source, file) {
170
+ const raw = parseRawRecords(file);
171
+ if (typeof source.normalize !== 'function') return raw;
172
+ const out = [];
173
+ for (const r of raw) {
174
+ const o = source.normalize(r);
175
+ if (Array.isArray(o)) out.push(...o);
176
+ else if (o) out.push(o);
177
+ }
178
+ return out;
179
+ }
180
+
181
+ // Bounded whole-file text read (for repo-level markdown sources, e.g. Aider).
182
+ function readTextFile(file) {
183
+ let fd;
184
+ try {
185
+ fd = fs.openSync(file, 'r');
186
+ } catch {
187
+ return null;
188
+ }
189
+ try {
190
+ const { size } = fs.fstatSync(fd);
191
+ const readLen = Math.min(size, MAX_BYTES_PER_FILE);
192
+ const buf = Buffer.allocUnsafe(readLen);
193
+ fs.readSync(fd, buf, 0, readLen, 0);
194
+ return buf.toString('utf8');
195
+ } catch {
196
+ return null;
197
+ } finally {
198
+ try { fs.closeSync(fd); } catch { /* already closed */ }
199
+ }
200
+ }
201
+
202
+ // Repo-level source: read <toplevel>/<source.file> and parse it with cwd = the toplevel.
203
+ function readRepoLevelObjects(source, toplevel) {
204
+ const text = readTextFile(path.join(toplevel, source.file));
205
+ if (text === null) return [];
206
+ const objects = source.parseText(text, toplevel);
207
+ return Array.isArray(objects) ? objects : [];
208
+ }
209
+
109
210
  function pushCommand(acc, command) {
110
211
  if (typeof command !== 'string') return;
111
212
  acc.bashCommandsCount += 1;
@@ -198,16 +299,31 @@ function fileInScope(cwd, selectedCwds) {
198
299
 
199
300
  // Yields every parsed session object across every known AI-tool session directory on this machine, tagged with the tool it came from.
200
301
  function* iterateSessionObjects(env = process.env) {
201
- for (const source of toolSources(env)) {
202
- const files = listSessionFiles(source.dir);
302
+ const sources = toolSources(env);
303
+ // Phase 1: home-dir sources; collect the repo toplevels (cwds) they reveal.
304
+ const seenCwds = new Set();
305
+ for (const source of sources) {
306
+ if (source.repoLevel) continue;
307
+ const files = listSessionFiles(source.dir, source.fileFilter || isTranscriptFile);
203
308
  for (const file of files) {
204
- const objects = readSessionObjects(file);
309
+ const objects = readSourceObjects(source, file);
205
310
  const cwd = resolveFileCwd(objects);
311
+ if (cwd) seenCwds.add(cwd);
206
312
  for (const obj of objects) {
207
313
  yield { tool: source.tool, obj, cwd, filePath: file };
208
314
  }
209
315
  }
210
316
  }
317
+ // Phase 2: repo-level sources (e.g. Aider) over the repos just discovered — cwd is known.
318
+ for (const source of sources) {
319
+ if (!source.repoLevel) continue;
320
+ for (const top of seenCwds) {
321
+ const objects = readRepoLevelObjects(source, top);
322
+ for (const obj of objects) {
323
+ yield { tool: source.tool, obj, cwd: top, filePath: path.join(top, source.file) };
324
+ }
325
+ }
326
+ }
211
327
  }
212
328
 
213
329
  // Scan every known AI-tool session directory on this machine.
@@ -303,13 +419,19 @@ function makeSessionsAccumulator() {
303
419
 
304
420
  function walkSessionSignals(env = process.env, selectedCwds = null, onHumanObject = null) {
305
421
  const sessions = makeSessionsAccumulator();
306
- for (const source of toolSources(env)) {
307
- const files = listSessionFiles(source.dir);
422
+ const sources = toolSources(env);
423
+ const seenCwds = new Set();
424
+ // Phase 1: home-dir sources; remember the repo toplevels (cwds) they reveal.
425
+ for (const source of sources) {
426
+ if (source.repoLevel) continue;
427
+ const files = listSessionFiles(source.dir, source.fileFilter || isTranscriptFile);
308
428
  if (files.length === 0) continue;
309
429
  sessions.startTool(source.tool);
310
430
  for (const f of files) {
311
- const objects = readSessionObjects(f);
312
- if (!fileInScope(resolveFileCwd(objects), selectedCwds)) continue;
431
+ const objects = readSourceObjects(source, f);
432
+ const cwd = resolveFileCwd(objects);
433
+ if (cwd) seenCwds.add(cwd);
434
+ if (!fileInScope(cwd, selectedCwds)) continue;
313
435
  sessions.addFile(f, objects);
314
436
  if (onHumanObject) {
315
437
  for (const o of objects) onHumanObject(o);
@@ -317,6 +439,23 @@ function walkSessionSignals(env = process.env, selectedCwds = null, onHumanObjec
317
439
  }
318
440
  sessions.endTool();
319
441
  }
442
+ // Phase 2: repo-level sources (e.g. Aider) — candidate repos = the selected scope, else
443
+ // the repos seen in phase 1. cwd is the repo toplevel, so attribution is exact.
444
+ const toplevels = selectedCwds instanceof Set ? selectedCwds : seenCwds;
445
+ for (const source of sources) {
446
+ if (!source.repoLevel) continue;
447
+ sessions.startTool(source.tool);
448
+ for (const top of toplevels) {
449
+ if (!fileInScope(top, selectedCwds)) continue;
450
+ const objects = readRepoLevelObjects(source, top);
451
+ if (objects.length === 0) continue;
452
+ sessions.addFile(path.join(top, source.file), objects);
453
+ if (onHumanObject) {
454
+ for (const o of objects) onHumanObject(o);
455
+ }
456
+ }
457
+ sessions.endTool();
458
+ }
320
459
  return sessions.result();
321
460
  }
322
461
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { execFileSync } = require('child_process');
4
4
  const { isGitRepo } = require('./git-activity');
5
+ const { takePrefetchedGitLog } = require('./git-log-prefetch');
5
6
 
6
7
  // Deterministic WORK-STREAM structure for the project where `shakers ai-usage` runs (hybrid evaluation, Slice 2).
7
8
 
@@ -9,11 +10,15 @@ const GIT_TIMEOUT_MS = 15000;
9
10
  const SESSION_GAP_SECONDS = 4 * 3600;
10
11
  const SECONDS_PER_DAY = 86400;
11
12
 
13
+ const WORK_STREAMS_LOG_ARGS = ['log', '--no-merges', '--pretty=format:%at'];
14
+
12
15
  function runGitTimestamps(root) {
16
+ const prefetched = takePrefetchedGitLog(root, WORK_STREAMS_LOG_ARGS);
17
+ if (prefetched !== undefined) return prefetched;
13
18
  try {
14
19
  return execFileSync(
15
20
  'git',
16
- ['-C', root, 'log', '--no-merges', '--pretty=format:%at'],
21
+ ['-C', root, ...WORK_STREAMS_LOG_ARGS],
17
22
  {
18
23
  encoding: 'utf8',
19
24
  timeout: GIT_TIMEOUT_MS,
@@ -71,4 +76,4 @@ function collectWorkStreams(root) {
71
76
  };
72
77
  }
73
78
 
74
- module.exports = { collectWorkStreams, SESSION_GAP_SECONDS };
79
+ module.exports = { collectWorkStreams, SESSION_GAP_SECONDS, WORK_STREAMS_LOG_ARGS };