auxilo-mcp 0.8.2 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "auxilo-mcp",
3
- "version": "0.8.2",
4
- "description": "MCP server for Auxilo Agent Capability Discovery & Knowledge Marketplace",
3
+ "version": "0.9.0",
4
+ "description": "MCP server for Auxilo \u2014 Agent Capability Discovery & Knowledge Marketplace",
5
5
  "main": "mcp-server.js",
6
6
  "bin": {
7
7
  "auxilo-mcp": "mcp-server.js",
@@ -14,6 +14,7 @@
14
14
  "lib/review.js",
15
15
  "lib/sensitivity-filter.js",
16
16
  "scripts/runner.js",
17
+ "scripts/capture-core.js",
17
18
  "scripts/sources/",
18
19
  "scripts/hooks/auxilo-extract.sh",
19
20
  "README.md",
@@ -49,4 +50,4 @@
49
50
  "devDependencies": {
50
51
  "proxyquire": "^2.1.3"
51
52
  }
52
- }
53
+ }
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * scripts/capture-core.js — Shared hook capture core (UC-1)
5
+ *
6
+ * The single entry point invoked by every per-client generated hook shim:
7
+ *
8
+ * <client session-end hook> | node capture-core.js --source <id>
9
+ *
10
+ * Reads the client's session-end JSON from stdin, extracts the transcript
11
+ * path tolerantly across hook dialects (Claude Code / Cursor / Gemini CLI
12
+ * send `transcript_path`; Antigravity sends `transcriptPath`; Windsurf nests
13
+ * it as `tool_info.transcript_path`; others use `transcript` or `chat_file`),
14
+ * enforces the same guards as the proven
15
+ * LW-12/LW-17 Claude Code hook script (lib/installer.js renderHookScript):
16
+ *
17
+ * 1. Consent sentinel <home>/.auxilo/autonomous-enabled must exist
18
+ * 2. Recursion guard AUXILO_EXTRACTING must not be "1" — and is NEVER
19
+ * exported into the spawned runner (P1-13 lesson:
20
+ * exporting it trips the runner's own guard and
21
+ * silently no-ops every run)
22
+ * 3. Transcript file must exist
23
+ *
24
+ * then spawns the extraction runner detached so the host client's session
25
+ * teardown is never blocked:
26
+ *
27
+ * node <dir-of-this-file>/runner.js --transcript <path> --source <id>
28
+ *
29
+ * The runner path resolves relative to THIS file's __dirname (P1-13:
30
+ * installed copies under ~/.auxilo/bin/scripts/ must run the installed
31
+ * runner, never reach back into ~/Documents).
32
+ *
33
+ * stdout of the runner goes to /dev/null (LW-17: runner.js log() already
34
+ * appends every line to extract.log itself — redirecting stdout there too
35
+ * double-wrote each line and the daily digest double-counted). stderr is
36
+ * appended to <home>/.auxilo/extract.log to capture crashes.
37
+ *
38
+ * FAIL-SILENT CONTRACT: a broken hook must never break the host client.
39
+ * Every non-happy path exits 0 with no output. No exceptions escape main().
40
+ *
41
+ * Flags:
42
+ * --source <id> source type forwarded to the runner (default claude-code)
43
+ * --home <dir> home dir override (tests; default env HOME / os.homedir())
44
+ * --runner <path> runner script override (tests; default __dirname/runner.js)
45
+ *
46
+ * @module capture-core
47
+ */
48
+
49
+ 'use strict';
50
+
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+ const os = require('os');
54
+ const { spawn } = require('child_process');
55
+
56
+ /**
57
+ * Flat transcript-path keys tried in order, covering known hook dialects.
58
+ * One NESTED dialect exists too: Windsurf wraps the path as
59
+ * `tool_info.transcript_path` — extractTranscriptPath probes it after
60
+ * `transcriptPath` and before `transcript` (UC-1).
61
+ */
62
+ const TRANSCRIPT_KEYS = ['transcript_path', 'transcriptPath', 'transcript', 'chat_file'];
63
+
64
+ /**
65
+ * GOV-3 M3: refuse to hand the runner anything implausibly large — the
66
+ * adapters read the whole file into memory, so an untrusted hook payload
67
+ * naming a multi-GB file is a trivial local OOM. Real transcripts top out
68
+ * in the single-digit MB.
69
+ */
70
+ const MAX_TRANSCRIPT_BYTES = 25 * 1024 * 1024;
71
+
72
+ /** GOV-3 M4: transcripts are text artifacts — gate on plausible extensions. */
73
+ const TRANSCRIPT_EXTS = Object.freeze(['.jsonl', '.json', '.md', '.txt']);
74
+
75
+ /**
76
+ * GOV-3 M4: the hook payload is UNTRUSTED third-party input, and this process
77
+ * will read+upload whatever file it names. Constrain the (symlink-resolved)
78
+ * path to known client transcript roots instead of "any file on disk" —
79
+ * otherwise a malicious payload exfiltrates arbitrary JSONL-shaped files and
80
+ * the only defenses left are the format probe and the scrubber (defense in
81
+ * depth, not a boundary).
82
+ */
83
+ function transcriptRoots(homeDir) {
84
+ return [
85
+ path.join(homeDir, '.claude'),
86
+ path.join(homeDir, '.cursor'),
87
+ path.join(homeDir, '.gemini'),
88
+ path.join(homeDir, '.codex'),
89
+ path.join(homeDir, '.factory'),
90
+ path.join(homeDir, '.copilot'),
91
+ path.join(homeDir, '.codeium'),
92
+ path.join(homeDir, '.windsurf'),
93
+ path.join(homeDir, '.openclaw'),
94
+ path.join(homeDir, '.auxilo'),
95
+ path.join(homeDir, 'Library', 'Application Support'),
96
+ ];
97
+ }
98
+
99
+ /** True iff realPath sits under a known transcript root AND has a text extension.
100
+ * Roots are symlink-resolved too, so the comparison is realpath-vs-realpath
101
+ * (macOS /var → /private/var would otherwise defeat a correct path). */
102
+ function transcriptPathAllowed(realPath, homeDir) {
103
+ if (!TRANSCRIPT_EXTS.includes(path.extname(realPath).toLowerCase())) return false;
104
+ return transcriptRoots(homeDir).some((root) => {
105
+ let realRoot = root;
106
+ try { realRoot = fs.realpathSync(root); } catch { /* root may not exist yet */ }
107
+ return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
108
+ });
109
+ }
110
+
111
+ function parseArgs(argv) {
112
+ const args = { source: 'claude-code', home: null, runner: null };
113
+ for (let i = 2; i < argv.length; i++) {
114
+ if (argv[i] === '--source' && argv[i + 1]) args.source = argv[++i];
115
+ else if (argv[i] === '--home' && argv[i + 1]) args.home = argv[++i];
116
+ else if (argv[i] === '--runner' && argv[i + 1]) args.runner = argv[++i];
117
+ }
118
+ return args;
119
+ }
120
+
121
+ /** Extract a transcript path from a parsed hook payload. Returns null if absent. */
122
+ function extractTranscriptPath(payload) {
123
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
124
+ const nested = payload.tool_info && typeof payload.tool_info === 'object' &&
125
+ !Array.isArray(payload.tool_info) ? payload.tool_info.transcript_path : undefined;
126
+ const candidates = [
127
+ payload.transcript_path,
128
+ payload.transcriptPath,
129
+ nested, // Windsurf nests it under tool_info (UC-1)
130
+ payload.transcript,
131
+ payload.chat_file,
132
+ ];
133
+ for (const v of candidates) {
134
+ if (typeof v === 'string' && v.trim().length > 0) return v.trim();
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function readStdin() {
140
+ return new Promise((resolve) => {
141
+ let data = '';
142
+ process.stdin.setEncoding('utf-8');
143
+ process.stdin.on('data', (c) => { data += c; });
144
+ process.stdin.on('end', () => resolve(data));
145
+ process.stdin.on('error', () => resolve(data));
146
+ });
147
+ }
148
+
149
+ async function main() {
150
+ const args = parseArgs(process.argv);
151
+ const homeDir = args.home || process.env.HOME || os.homedir();
152
+ const auxiloDir = path.join(homeDir, '.auxilo');
153
+
154
+ // 1. Consent sentinel — kill-switch shared by every capture class (UC §6).
155
+ if (!fs.existsSync(path.join(auxiloDir, 'autonomous-enabled'))) return;
156
+
157
+ // 2. Recursion guard — bail if we're already inside an extraction chain.
158
+ if (process.env.AUXILO_EXTRACTING === '1') return;
159
+
160
+ // 3. Hook payload → transcript path (tolerant across dialects).
161
+ const raw = await readStdin();
162
+ let payload;
163
+ try { payload = JSON.parse(raw); } catch { return; }
164
+ const namedPath = extractTranscriptPath(payload);
165
+ if (!namedPath) return;
166
+
167
+ // GOV-3 M4: resolve symlinks BEFORE validating, and use the resolved path
168
+ // from here on — a symlink under an allowed root must not reach outside it.
169
+ let transcriptPath;
170
+ try { transcriptPath = fs.realpathSync(namedPath); } catch { return; }
171
+ if (!transcriptPathAllowed(transcriptPath, homeDir)) return;
172
+
173
+ let stat;
174
+ try { stat = fs.statSync(transcriptPath); } catch { return; }
175
+ if (!stat.isFile()) return;
176
+ if (stat.size > MAX_TRANSCRIPT_BYTES) return; // GOV-3 M3: local-OOM guard
177
+
178
+ // 4. Runner resolution — relative to OUR location (P1-13).
179
+ const runnerPath = args.runner || path.join(__dirname, 'runner.js');
180
+ if (!fs.existsSync(runnerPath)) return;
181
+
182
+ // 5. Spawn detached. stdout → /dev/null (LW-17 double-write), stderr →
183
+ // extract.log for crash capture. Never export AUXILO_EXTRACTING.
184
+ const env = { ...process.env };
185
+ delete env.AUXILO_EXTRACTING;
186
+ if (args.home) env.HOME = args.home; // keep test homes hermetic
187
+
188
+ let stderrFd = 'ignore';
189
+ try {
190
+ fs.mkdirSync(auxiloDir, { recursive: true });
191
+ stderrFd = fs.openSync(path.join(auxiloDir, 'extract.log'), 'a');
192
+ } catch { /* fall back to ignore — fail-silent */ }
193
+
194
+ const child = spawn(process.execPath,
195
+ [runnerPath, '--transcript', transcriptPath, '--source', args.source],
196
+ { detached: true, stdio: ['ignore', 'ignore', stderrFd], env });
197
+ child.on('error', () => { /* fail-silent */ });
198
+ child.unref();
199
+
200
+ if (typeof stderrFd === 'number') {
201
+ try { fs.closeSync(stderrFd); } catch { /* already closed */ }
202
+ }
203
+ }
204
+
205
+ module.exports = {
206
+ parseArgs,
207
+ extractTranscriptPath,
208
+ transcriptPathAllowed,
209
+ transcriptRoots,
210
+ TRANSCRIPT_KEYS,
211
+ TRANSCRIPT_EXTS,
212
+ MAX_TRANSCRIPT_BYTES,
213
+ };
214
+
215
+ if (require.main === module) {
216
+ // Fail-silent: exit 0 on every path, swallow everything.
217
+ main().then(() => process.exit(0)).catch(() => process.exit(0));
218
+ }
package/scripts/runner.js CHANGED
@@ -39,6 +39,9 @@ const crypto = require('crypto');
39
39
  const { scanText, SENSITIVITY_FILTER_VERSION } = require('../lib/sensitivity-filter.js');
40
40
  const { ClaudeCodeSource } = require('./sources/claude-code.js');
41
41
  const { OpenClawSource } = require('./sources/openclaw.js');
42
+ const { GeminiCliSource } = require('./sources/gemini-cli.js');
43
+ const { AntigravitySource } = require('./sources/antigravity.js');
44
+ const { GenericJsonlSource } = require('./sources/generic-jsonl.js');
42
45
 
43
46
  // ─── Constants ──────────────────────────────────────────────────────────────
44
47
 
@@ -89,6 +92,8 @@ const LOG_PATH = path.join(AUXILO_DIR, 'extract.log');
89
92
  const SOURCES = [
90
93
  ClaudeCodeSource,
91
94
  OpenClawSource,
95
+ GeminiCliSource,
96
+ AntigravitySource,
92
97
  ];
93
98
 
94
99
  async function enumerateActiveSources(filter) {
@@ -615,16 +620,21 @@ async function main() {
615
620
  process.exit(1);
616
621
  }
617
622
 
618
- // Pick the right source adapter based on file location default to
619
- // claude-code since it can parse either JSONL or plain text, and
620
- // works for most hook-fired single-file invocations.
623
+ // Pick the right source adapter capture-core forwards the client id
624
+ // via --source (UC-1). Default to claude-code since it can parse either
625
+ // JSONL or plain text. Unknown ids (cursor, windsurf, codex, copilot,
626
+ // factory, ...) fall back to the generic Claude-style JSONL normalizer,
627
+ // which tags uploads with the actual client id and refuses (fail-silent)
628
+ // anything that doesn't probe as JSONL.
621
629
  const sourceType = args.source || 'claude-code';
622
630
  const SourceClass = SOURCES.find(S => S.id === sourceType);
623
- if (!SourceClass) {
624
- log(`[runner] Unknown source type: ${sourceType}`);
625
- process.exit(1);
631
+ let source;
632
+ if (SourceClass) {
633
+ source = new SourceClass();
634
+ } else {
635
+ log(`[runner] No dedicated adapter for source "${sourceType}" — using generic-jsonl fallback`);
636
+ source = new GenericJsonlSource({ id: sourceType });
626
637
  }
627
- const source = new SourceClass();
628
638
  const sessionId = path.basename(transcriptPath, path.extname(transcriptPath));
629
639
  const stat = fs.statSync(transcriptPath);
630
640
  const sessionRef = {
@@ -649,6 +659,14 @@ async function main() {
649
659
  }
650
660
  }
651
661
 
662
+ // UC-1 format-probe refusal: adapters return null (never throw) when a
663
+ // file doesn't match their expected shape. Skip — do NOT fall back to
664
+ // raw text, that would mis-parse garbage into a transcript (UC §5).
665
+ if (!transcriptData || typeof transcriptData.transcript !== 'string') {
666
+ log(`[runner] Format probe refused ${transcriptPath} (source=${source.type}). Exiting.`);
667
+ process.exit(0);
668
+ }
669
+
652
670
  let transcript = transcriptData.transcript;
653
671
  if (transcript.length < MIN_CHARS) {
654
672
  log(`[runner] Too short (${transcript.length} chars, min ${MIN_CHARS}). Exiting.`);
@@ -668,6 +686,23 @@ async function main() {
668
686
  log(`[runner] Scrubbed ${report.patterns_matched.length} sensitive pattern(s): ${[...new Set(report.patterns_matched)].join(', ')} ${DIGEST_ACCOUNT}`);
669
687
  }
670
688
 
689
+ // GOV-3 M2: content-sha ledger dedup for single-file mode. Some clients
690
+ // fire their capture hook PER RESPONSE / PER TURN, not at session end
691
+ // (Windsurf post_cascade_response_with_transcript; Codex/Antigravity
692
+ // Stop). Without this, each firing re-uploads the whole transcript-so-far
693
+ // as a fresh extraction (new idempotency key per call), burning rate
694
+ // limit and re-running the LLM on growing partials. Keyed on the cleaned
695
+ // content sha so identical/already-seen content is skipped. --force
696
+ // bypasses (targeted re-test).
697
+ const contentSha = crypto.createHash('sha256').update(cleaned).digest('hex');
698
+ if (!args.force) {
699
+ const ledger = loadLedger();
700
+ if (ledgerHas(ledger, sourceType, sessionId, contentSha)) {
701
+ log(`[runner] Already uploaded this content (source=${sourceType} session=${sessionId}). Skipping.`);
702
+ process.exit(0);
703
+ }
704
+ }
705
+
671
706
  if (args.dryRun) {
672
707
  log(`[runner] [DRY RUN] Would upload ${cleaned.length} chars to ${BASE_URL}/extract`);
673
708
  process.exit(0);
@@ -676,6 +711,10 @@ async function main() {
676
711
  try {
677
712
  const result = await postExtract(cleaned, sessionId, sourceType, report);
678
713
  log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
714
+ // Mark only after a successful upload so a failed POST can be retried.
715
+ const ledger = loadLedger();
716
+ ledgerMark(ledger, sourceType, sessionId, contentSha, sessionRef.mtime);
717
+ saveLedger(ledger);
679
718
  process.exit(0);
680
719
  } catch (err) {
681
720
  log(`[runner] ✗ Upload failed: ${err.message}`);
@@ -751,6 +790,14 @@ async function main() {
751
790
  continue;
752
791
  }
753
792
 
793
+ // UC-1 format-probe refusal: null = skip silently (not a failure).
794
+ if (!transcriptData || typeof transcriptData.transcript !== 'string') {
795
+ log(`[runner] Skipped — format probe refused (source=${source.type})`);
796
+ totalSkipped++;
797
+ ledgerMark(ledger, source.type, sessionRef.sessionId, 'probe-refused', sessionRef.mtime);
798
+ continue;
799
+ }
800
+
754
801
  let transcript = transcriptData.transcript;
755
802
 
756
803
  // Size check
@@ -0,0 +1,131 @@
1
+ /**
2
+ * scripts/sources/antigravity.js — Antigravity Transcript Source (UC-1)
3
+ *
4
+ * Discovers and reads Antigravity per-conversation JSONL transcript logs.
5
+ * Default location:
6
+ * ~/.gemini/antigravity/brain/<conversation-id>/.system_generated/logs/*.jsonl
7
+ *
8
+ * Expected line shape: Claude-style JSONL dialect — one JSON object per line
9
+ * carrying `role`/`content`, `message.role`/`message.content`, or
10
+ * `sender`/`content`. Tool/function-call internals are skipped.
11
+ *
12
+ * GROUND-TRUTH NOTE (verified on this machine, 2026-06-12): the local
13
+ * Antigravity install stores conversations as binary protobuf
14
+ * (~/.gemini/antigravity/conversations/*.pb) and its brain dirs contained NO
15
+ * .system_generated/logs/*.jsonl — only click_feedback PNGs and inter-agent
16
+ * messages/*.json ({id, recipient, sender, priority, timestamp, hideFromUser,
17
+ * content}). The logs/*.jsonl path comes from the UC research pass (hook
18
+ * payloads hand a `transcriptPath` pointing there on hook-enabled builds).
19
+ * The strict JSONL probe + fail-silent contract means that if the real format
20
+ * differs, this adapter degrades to "skipped with one log line" — it never
21
+ * mis-parses (UC §5 risk rule).
22
+ *
23
+ * @module sources/antigravity
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const os = require('os');
31
+ const { TranscriptSource } = require('./source.interface');
32
+ const { normalizeJsonlLines, probeJsonl } = require('./generic-jsonl');
33
+
34
+ class AntigravitySource extends TranscriptSource {
35
+ static id = 'antigravity';
36
+ static displayName = 'Antigravity (Google)';
37
+ static version = '1.0.0';
38
+
39
+ constructor(config = {}) {
40
+ super(config);
41
+ /** @type {string} Root brain dir holding per-conversation artifacts */
42
+ this.dataDir = config.dataDir || path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
43
+ }
44
+
45
+ /** Detect: the brain directory exists and is readable. */
46
+ async detect() {
47
+ try {
48
+ return fs.statSync(this.dataDir).isDirectory();
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Discover transcript logs under
56
+ * <dataDir>/<conversation-id>/.system_generated/logs/*.jsonl
57
+ * modified after `since`.
58
+ */
59
+ async discoverSessions({ since } = {}) {
60
+ const results = [];
61
+ const sinceMs = since ? new Date(since).getTime() : 0;
62
+
63
+ let conversations;
64
+ try {
65
+ conversations = fs.readdirSync(this.dataDir).filter(c => {
66
+ try { return fs.statSync(path.join(this.dataDir, c)).isDirectory(); }
67
+ catch { return false; }
68
+ });
69
+ } catch {
70
+ return results;
71
+ }
72
+
73
+ for (const conv of conversations) {
74
+ const logsDir = path.join(this.dataDir, conv, '.system_generated', 'logs');
75
+ let files;
76
+ try { files = fs.readdirSync(logsDir).filter(f => f.endsWith('.jsonl')); }
77
+ catch { continue; } // most brain dirs have no logs — fine
78
+
79
+ for (const file of files) {
80
+ const filePath = path.join(logsDir, file);
81
+ try {
82
+ const stat = fs.statSync(filePath);
83
+ if (!stat.isFile()) continue;
84
+ if (stat.mtimeMs <= sinceMs) continue;
85
+ results.push({
86
+ sessionId: `${conv}-${path.basename(file, '.jsonl')}`,
87
+ path: filePath,
88
+ mtime: stat.mtime.toISOString(),
89
+ bytes: stat.size,
90
+ });
91
+ } catch { /* skip unreadable files */ }
92
+ }
93
+ }
94
+
95
+ return results;
96
+ }
97
+
98
+ /**
99
+ * Read + normalize one JSONL transcript. Returns null (single stderr log
100
+ * line) when the format probe refuses — never throws on bad format.
101
+ */
102
+ async readSession(sessionRef) {
103
+ const filePath = sessionRef.path;
104
+ if (!filePath || !fs.existsSync(filePath)) {
105
+ throw new Error(`Transcript file not found: ${filePath}`);
106
+ }
107
+
108
+ const content = fs.readFileSync(filePath, 'utf-8');
109
+ const lines = content.split('\n').filter(l => l.trim());
110
+
111
+ if (!probeJsonl(lines)) {
112
+ process.stderr.write(`[antigravity] format probe refused ${filePath} — skipping\n`);
113
+ return null;
114
+ }
115
+
116
+ return {
117
+ transcript: normalizeJsonlLines(lines),
118
+ metadata: {
119
+ sessionId: sessionRef.sessionId,
120
+ source: 'antigravity',
121
+ mtime: sessionRef.mtime,
122
+ bytes: sessionRef.bytes,
123
+ },
124
+ };
125
+ }
126
+
127
+ /** Hook wiring (Antigravity hooks.json, Stop event) is the installer's job. */
128
+ async registerSessionEndHook(cb) { return null; }
129
+ }
130
+
131
+ module.exports = { AntigravitySource };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * scripts/sources/gemini-cli.js — Gemini CLI Transcript Source (UC-1)
3
+ *
4
+ * Discovers and reads auto-saved Gemini CLI session JSON files.
5
+ * Default location: ~/.gemini/tmp/<project_hash>/chats/*.json
6
+ *
7
+ * KNOWN SHAPES (format probe accepts exactly these, refuses everything else):
8
+ *
9
+ * A. Session auto-save object:
10
+ * { sessionId?, startTime?, lastUpdated?,
11
+ * messages: [ { id?, type: "user"|"gemini", content: string, ... } ] }
12
+ *
13
+ * B. Checkpoint (`/chat save`) — Gemini API Content list, either bare or
14
+ * wrapped:
15
+ * [ { role: "user"|"model", parts: [ {text} | {functionCall} | {functionResponse} ] } ]
16
+ * { history: [ ...same... ] }
17
+ *
18
+ * NOTE (2026-06-12): built from the documented Gemini CLI session formats.
19
+ * No ~/.gemini/tmp data existed on the build machine to verify against —
20
+ * only Antigravity uses ~/.gemini here. The strict probe + fail-silent
21
+ * contract means a drifted real-world format degrades to "skipped with one
22
+ * log line", never a mis-parse (UC §5 risk rule).
23
+ *
24
+ * Normalization: user/model turns concatenated as `[user]:` / `[assistant]:`
25
+ * blocks (same convention as the claude-code adapter). Tool/function-call
26
+ * internals and thinking parts are skipped.
27
+ *
28
+ * @module sources/gemini-cli
29
+ */
30
+
31
+ 'use strict';
32
+
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+ const os = require('os');
36
+ const { TranscriptSource } = require('./source.interface');
37
+ const { normalizeRole } = require('./generic-jsonl');
38
+
39
+ /** Extract user-visible text from a Gemini Content `parts` array. */
40
+ function textFromParts(parts) {
41
+ if (!Array.isArray(parts)) return '';
42
+ return parts
43
+ .filter(p => p && typeof p.text === 'string' && !p.thought) // skip functionCall/functionResponse/thought parts
44
+ .map(p => p.text)
45
+ .filter(Boolean)
46
+ .join('\n');
47
+ }
48
+
49
+ class GeminiCliSource extends TranscriptSource {
50
+ static id = 'gemini-cli';
51
+ static displayName = 'Gemini CLI (Google)';
52
+ static version = '1.0.0';
53
+
54
+ constructor(config = {}) {
55
+ super(config);
56
+ /** @type {string} Root dir for Gemini CLI per-project temp data */
57
+ this.dataDir = config.dataDir || path.join(os.homedir(), '.gemini', 'tmp');
58
+ }
59
+
60
+ /** Detect: ~/.gemini/tmp exists and is a readable directory. */
61
+ async detect() {
62
+ try {
63
+ return fs.statSync(this.dataDir).isDirectory();
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Discover session JSON files under <dataDir>/<project_hash>/chats/*.json
71
+ * modified after `since`.
72
+ */
73
+ async discoverSessions({ since } = {}) {
74
+ const results = [];
75
+ const sinceMs = since ? new Date(since).getTime() : 0;
76
+
77
+ let hashes;
78
+ try {
79
+ hashes = fs.readdirSync(this.dataDir).filter(h => {
80
+ try { return fs.statSync(path.join(this.dataDir, h)).isDirectory(); }
81
+ catch { return false; }
82
+ });
83
+ } catch {
84
+ return results;
85
+ }
86
+
87
+ for (const hash of hashes) {
88
+ const chatsDir = path.join(this.dataDir, hash, 'chats');
89
+ let files;
90
+ try { files = fs.readdirSync(chatsDir).filter(f => f.endsWith('.json')); }
91
+ catch { continue; } // no chats dir for this project — fine
92
+
93
+ for (const file of files) {
94
+ const filePath = path.join(chatsDir, file);
95
+ try {
96
+ const stat = fs.statSync(filePath);
97
+ if (!stat.isFile()) continue;
98
+ if (stat.mtimeMs <= sinceMs) continue;
99
+ results.push({
100
+ sessionId: `${hash}-${path.basename(file, '.json')}`,
101
+ path: filePath,
102
+ mtime: stat.mtime.toISOString(),
103
+ bytes: stat.size,
104
+ });
105
+ } catch { /* skip unreadable files */ }
106
+ }
107
+ }
108
+
109
+ return results;
110
+ }
111
+
112
+ /**
113
+ * Read + normalize one session file. Returns null (single stderr log line)
114
+ * when the format probe refuses — never throws on bad format, never
115
+ * mis-parses garbage into a transcript.
116
+ */
117
+ async readSession(sessionRef) {
118
+ const filePath = sessionRef.path;
119
+ if (!filePath || !fs.existsSync(filePath)) {
120
+ throw new Error(`Transcript file not found: ${filePath}`);
121
+ }
122
+
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
126
+ } catch {
127
+ return this._refuse(filePath, 'not valid JSON');
128
+ }
129
+
130
+ const turns = [];
131
+
132
+ if (parsed && !Array.isArray(parsed) && Array.isArray(parsed.messages)) {
133
+ // Shape A: session auto-save { messages: [{ type, content }] }
134
+ for (const m of parsed.messages) {
135
+ if (!m || typeof m !== 'object') continue;
136
+ const role = normalizeRole(m.type ?? m.role);
137
+ if (!role) continue; // tool/info/error entries — skip
138
+ const text = typeof m.content === 'string' ? m.content : textFromParts(m.content);
139
+ if (text && text.trim().length > 0) turns.push(`[${role}]: ${text}`);
140
+ }
141
+ } else {
142
+ // Shape B: checkpoint Content list — bare array or { history: [...] }
143
+ const history = Array.isArray(parsed) ? parsed
144
+ : (parsed && Array.isArray(parsed.history)) ? parsed.history
145
+ : null;
146
+ if (!history) return this._refuse(filePath, 'no messages/history array');
147
+
148
+ for (const c of history) {
149
+ if (!c || typeof c !== 'object') continue;
150
+ const role = normalizeRole(c.role);
151
+ if (!role) continue;
152
+ const text = textFromParts(c.parts);
153
+ if (text && text.trim().length > 0) turns.push(`[${role}]: ${text}`);
154
+ }
155
+ }
156
+
157
+ return {
158
+ transcript: turns.join('\n\n'),
159
+ metadata: {
160
+ sessionId: sessionRef.sessionId,
161
+ source: 'gemini-cli',
162
+ mtime: sessionRef.mtime,
163
+ bytes: sessionRef.bytes,
164
+ },
165
+ };
166
+ }
167
+
168
+ /** Single log line + null per the UC §5 fail-silent rule. */
169
+ _refuse(filePath, reason) {
170
+ process.stderr.write(`[gemini-cli] format probe refused ${filePath} (${reason}) — skipping\n`);
171
+ return null;
172
+ }
173
+
174
+ /** Hook wiring (Gemini settings.json hooks) is the installer's job. */
175
+ async registerSessionEndHook(cb) { return null; }
176
+ }
177
+
178
+ module.exports = { GeminiCliSource };