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/bin/auxilo-cli.js +52 -3
- package/lib/installer.js +712 -28
- package/package.json +4 -3
- package/scripts/capture-core.js +218 -0
- package/scripts/runner.js +54 -7
- package/scripts/sources/antigravity.js +131 -0
- package/scripts/sources/gemini-cli.js +178 -0
- package/scripts/sources/generic-jsonl.js +157 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auxilo-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for Auxilo
|
|
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
|
|
619
|
-
// claude-code since it can parse either
|
|
620
|
-
//
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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 };
|