auxilo-mcp 0.8.2 → 0.9.1

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/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) {
@@ -228,30 +233,52 @@ function listPendingFiles() {
228
233
 
229
234
  // ─── Upload ─────────────────────────────────────────────────────────────────
230
235
 
231
- async function postExtract(transcript, sessionId, sourceType, scrubReport) {
232
- const transcriptSha256 = crypto.createHash('sha256').update(transcript).digest('hex');
233
- const idempotencyKey = crypto.randomUUID();
234
-
235
- const res = await fetch(`${BASE_URL}/extract`, {
236
- method: 'POST',
237
- headers: {
238
- 'Content-Type': 'application/json',
239
- 'X-API-Key': API_KEY,
240
- 'Idempotency-Key': idempotencyKey,
241
- },
242
- body: JSON.stringify({
243
- source: { type: sourceType, session_id: sessionId },
244
- transcript,
245
- transcript_sha256: transcriptSha256,
246
- client_scrub_report: scrubReport,
247
- }),
248
- });
236
+ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
237
+ // CLIENT-SIDE extraction (2026-07-02). Server /extract is deprecated (410) — Auxilo
238
+ // does not pay to extract. The local model (via `claude -p`) extracts + self-screens
239
+ // the already-client-scrubbed transcript, and we submit finished learnings to /learn.
240
+ const { extractLocally } = require('./extract-local.js');
241
+ let learnings;
242
+ let skipped;
243
+ try {
244
+ ({ learnings, skipped } = await extractLocally(transcript, sourceType));
245
+ } catch (err) {
246
+ throw new Error(`Local extraction failed: ${err.message}`);
247
+ }
248
+ if (skipped) {
249
+ log(`[runner] ${skipped}`);
250
+ return { learnings_published: 0, learnings_rejected: 0, extraction_id: 'client-skip' };
251
+ }
249
252
 
250
- const data = await res.json();
251
- if (!res.ok) {
252
- throw new Error(`Upload failed (${res.status}): ${data.error || JSON.stringify(data)}`);
253
+ let published = 0;
254
+ let rejected = 0;
255
+ for (const l of learnings) {
256
+ try {
257
+ const res = await fetch(`${BASE_URL}/learn`, {
258
+ method: 'POST',
259
+ headers: {
260
+ 'Content-Type': 'application/json',
261
+ 'X-API-Key': API_KEY,
262
+ 'Idempotency-Key': crypto.randomUUID(),
263
+ },
264
+ body: JSON.stringify({
265
+ title: l.title,
266
+ body: l.body,
267
+ category: l.category,
268
+ tags: l.tags,
269
+ task_context: l.task_context,
270
+ outcome: l.outcome,
271
+ contributor_agent: `auxilo-hook/${sourceType}`,
272
+ }),
273
+ });
274
+ if (res.ok) published += 1;
275
+ else rejected += 1;
276
+ } catch (_) {
277
+ rejected += 1;
278
+ }
253
279
  }
254
- return { ...data, transcript_sha256: transcriptSha256 };
280
+ log(`[runner] client-side extraction: ${learnings.length} candidate(s), published=${published} rejected=${rejected}`);
281
+ return { learnings_published: published, learnings_rejected: rejected, extraction_id: `client-${sessionId}` };
255
282
  }
256
283
 
257
284
  // ─── Install Hooks (B15) ────────────────────────────────────────────────────
@@ -615,16 +642,21 @@ async function main() {
615
642
  process.exit(1);
616
643
  }
617
644
 
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.
645
+ // Pick the right source adapter capture-core forwards the client id
646
+ // via --source (UC-1). Default to claude-code since it can parse either
647
+ // JSONL or plain text. Unknown ids (cursor, windsurf, codex, copilot,
648
+ // factory, ...) fall back to the generic Claude-style JSONL normalizer,
649
+ // which tags uploads with the actual client id and refuses (fail-silent)
650
+ // anything that doesn't probe as JSONL.
621
651
  const sourceType = args.source || 'claude-code';
622
652
  const SourceClass = SOURCES.find(S => S.id === sourceType);
623
- if (!SourceClass) {
624
- log(`[runner] Unknown source type: ${sourceType}`);
625
- process.exit(1);
653
+ let source;
654
+ if (SourceClass) {
655
+ source = new SourceClass();
656
+ } else {
657
+ log(`[runner] No dedicated adapter for source "${sourceType}" — using generic-jsonl fallback`);
658
+ source = new GenericJsonlSource({ id: sourceType });
626
659
  }
627
- const source = new SourceClass();
628
660
  const sessionId = path.basename(transcriptPath, path.extname(transcriptPath));
629
661
  const stat = fs.statSync(transcriptPath);
630
662
  const sessionRef = {
@@ -649,6 +681,14 @@ async function main() {
649
681
  }
650
682
  }
651
683
 
684
+ // UC-1 format-probe refusal: adapters return null (never throw) when a
685
+ // file doesn't match their expected shape. Skip — do NOT fall back to
686
+ // raw text, that would mis-parse garbage into a transcript (UC §5).
687
+ if (!transcriptData || typeof transcriptData.transcript !== 'string') {
688
+ log(`[runner] Format probe refused ${transcriptPath} (source=${source.type}). Exiting.`);
689
+ process.exit(0);
690
+ }
691
+
652
692
  let transcript = transcriptData.transcript;
653
693
  if (transcript.length < MIN_CHARS) {
654
694
  log(`[runner] Too short (${transcript.length} chars, min ${MIN_CHARS}). Exiting.`);
@@ -668,6 +708,23 @@ async function main() {
668
708
  log(`[runner] Scrubbed ${report.patterns_matched.length} sensitive pattern(s): ${[...new Set(report.patterns_matched)].join(', ')} ${DIGEST_ACCOUNT}`);
669
709
  }
670
710
 
711
+ // GOV-3 M2: content-sha ledger dedup for single-file mode. Some clients
712
+ // fire their capture hook PER RESPONSE / PER TURN, not at session end
713
+ // (Windsurf post_cascade_response_with_transcript; Codex/Antigravity
714
+ // Stop). Without this, each firing re-uploads the whole transcript-so-far
715
+ // as a fresh extraction (new idempotency key per call), burning rate
716
+ // limit and re-running the LLM on growing partials. Keyed on the cleaned
717
+ // content sha so identical/already-seen content is skipped. --force
718
+ // bypasses (targeted re-test).
719
+ const contentSha = crypto.createHash('sha256').update(cleaned).digest('hex');
720
+ if (!args.force) {
721
+ const ledger = loadLedger();
722
+ if (ledgerHas(ledger, sourceType, sessionId, contentSha)) {
723
+ log(`[runner] Already uploaded this content (source=${sourceType} session=${sessionId}). Skipping.`);
724
+ process.exit(0);
725
+ }
726
+ }
727
+
671
728
  if (args.dryRun) {
672
729
  log(`[runner] [DRY RUN] Would upload ${cleaned.length} chars to ${BASE_URL}/extract`);
673
730
  process.exit(0);
@@ -676,6 +733,10 @@ async function main() {
676
733
  try {
677
734
  const result = await postExtract(cleaned, sessionId, sourceType, report);
678
735
  log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
736
+ // Mark only after a successful upload so a failed POST can be retried.
737
+ const ledger = loadLedger();
738
+ ledgerMark(ledger, sourceType, sessionId, contentSha, sessionRef.mtime);
739
+ saveLedger(ledger);
679
740
  process.exit(0);
680
741
  } catch (err) {
681
742
  log(`[runner] ✗ Upload failed: ${err.message}`);
@@ -751,6 +812,14 @@ async function main() {
751
812
  continue;
752
813
  }
753
814
 
815
+ // UC-1 format-probe refusal: null = skip silently (not a failure).
816
+ if (!transcriptData || typeof transcriptData.transcript !== 'string') {
817
+ log(`[runner] Skipped — format probe refused (source=${source.type})`);
818
+ totalSkipped++;
819
+ ledgerMark(ledger, source.type, sessionRef.sessionId, 'probe-refused', sessionRef.mtime);
820
+ continue;
821
+ }
822
+
754
823
  let transcript = transcriptData.transcript;
755
824
 
756
825
  // 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 };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * scripts/sources/generic-jsonl.js — Generic Claude-style JSONL Source (UC-1)
3
+ *
4
+ * Best-effort normalizer for clients that speak a Claude-style JSONL dialect
5
+ * (one JSON object per line carrying `role`/`content` or
6
+ * `message.role`/`message.content`) but don't have a dedicated adapter yet
7
+ * (cursor, windsurf, codex, copilot, factory). capture-core forwards the
8
+ * client id via `--source <id>`; runner.js falls back to this adapter for
9
+ * unknown ids in single-file mode and tags uploads with that id.
10
+ *
11
+ * FORMAT PROBE (UC §5 risk rule): at least 80% of non-empty lines must parse
12
+ * as JSON objects, or readSession() refuses (returns null with a single
13
+ * stderr line). Never throws on bad format, never mis-parses garbage into a
14
+ * transcript.
15
+ *
16
+ * Poll-mode methods are inert: there is no canonical on-disk location for a
17
+ * "generic" client, so detect() → false and discoverSessions() → [].
18
+ *
19
+ * @module sources/generic-jsonl
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const { TranscriptSource } = require('./source.interface');
26
+
27
+ /** Minimum fraction of non-empty lines that must parse as JSON objects. */
28
+ const PROBE_MIN_JSON_RATIO = 0.8;
29
+
30
+ /** Map dialect role names to our normalized two-role vocabulary. */
31
+ function normalizeRole(role) {
32
+ const r = String(role || '').toLowerCase();
33
+ if (r === 'user' || r === 'human') return 'user';
34
+ if (r === 'assistant' || r === 'model' || r === 'gemini' || r === 'ai') return 'assistant';
35
+ return null; // system / tool / function / unknown → skip
36
+ }
37
+
38
+ /** Extract plain text from a string | block-array | {text} content value. */
39
+ function extractText(raw) {
40
+ if (raw == null) return '';
41
+ if (typeof raw === 'string') return raw;
42
+ if (Array.isArray(raw)) {
43
+ return raw
44
+ .filter(b => b && (b.type === 'text' || b.type === 'input_text' || typeof b.text === 'string'))
45
+ .map(b => b.text || '')
46
+ .filter(Boolean)
47
+ .join('\n');
48
+ }
49
+ if (typeof raw === 'object' && typeof raw.text === 'string') return raw.text;
50
+ return '';
51
+ }
52
+
53
+ /**
54
+ * Normalize an array of raw JSONL lines into `[user]:`/`[assistant]:` turns.
55
+ * Shared with the antigravity adapter. Skips tool/function internals and any
56
+ * line whose role doesn't normalize to user/assistant.
57
+ *
58
+ * @param {string[]} lines - non-empty JSONL lines
59
+ * @returns {string} normalized transcript (may be empty)
60
+ */
61
+ function normalizeJsonlLines(lines) {
62
+ const turns = [];
63
+ for (const line of lines) {
64
+ let entry;
65
+ try { entry = JSON.parse(line); } catch { continue; }
66
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
67
+
68
+ // Skip explicit tool/function internals regardless of role fields.
69
+ const t = String(entry.type || '').toLowerCase();
70
+ if (['tool_use', 'tool_result', 'function_call', 'function_response', 'tool'].includes(t)) continue;
71
+
72
+ const role = normalizeRole(entry.message?.role ?? entry.role ?? entry.sender ?? entry.type);
73
+ if (!role) continue;
74
+
75
+ const text = extractText(entry.message?.content ?? entry.content ?? entry.text);
76
+ if (text.trim().length > 0) turns.push(`[${role}]: ${text}`);
77
+ }
78
+ return turns.join('\n\n');
79
+ }
80
+
81
+ /**
82
+ * Format probe: true when the file looks like JSONL (≥80% of non-empty lines
83
+ * parse as JSON objects).
84
+ *
85
+ * @param {string[]} lines - non-empty lines
86
+ * @returns {boolean}
87
+ */
88
+ function probeJsonl(lines) {
89
+ if (lines.length === 0) return false;
90
+ let objects = 0;
91
+ for (const line of lines) {
92
+ try {
93
+ const v = JSON.parse(line);
94
+ if (v && typeof v === 'object' && !Array.isArray(v)) objects++;
95
+ } catch { /* not JSON */ }
96
+ }
97
+ return objects / lines.length >= PROBE_MIN_JSON_RATIO;
98
+ }
99
+
100
+ class GenericJsonlSource extends TranscriptSource {
101
+ static id = 'generic-jsonl';
102
+ static displayName = 'Generic Claude-style JSONL';
103
+ static version = '1.0.0';
104
+
105
+ /**
106
+ * @param {object} [config]
107
+ * @param {string} [config.id] - actual client id this instance represents
108
+ * (e.g. "cursor", "windsurf"); used as the upload source tag.
109
+ */
110
+ constructor(config = {}) {
111
+ super(config);
112
+ // The marketplace source tag is the CLIENT id, not "generic-jsonl".
113
+ this.type = config.id || GenericJsonlSource.id;
114
+ this.label = config.label || `${this.type} (generic JSONL)`;
115
+ }
116
+
117
+ /** No canonical data dir for a generic client — never auto-detected. */
118
+ async detect() { return false; }
119
+
120
+ /** Poll discovery unsupported — hook-fired single-file mode only. */
121
+ async discoverSessions({ since } = {}) { return []; }
122
+
123
+ /**
124
+ * Read + normalize a JSONL transcript. Returns null (single stderr log
125
+ * line) when the format probe refuses — never throws on bad format.
126
+ */
127
+ async readSession(sessionRef) {
128
+ const filePath = sessionRef.path;
129
+ if (!filePath || !fs.existsSync(filePath)) {
130
+ throw new Error(`Transcript file not found: ${filePath}`);
131
+ }
132
+ const content = fs.readFileSync(filePath, 'utf-8');
133
+ const lines = content.split('\n').filter(l => l.trim());
134
+
135
+ if (!probeJsonl(lines)) {
136
+ process.stderr.write(`[generic-jsonl] format probe refused ${filePath} (source=${this.type}) — skipping\n`);
137
+ return null;
138
+ }
139
+
140
+ const transcript = normalizeJsonlLines(lines);
141
+ return {
142
+ transcript,
143
+ metadata: {
144
+ sessionId: sessionRef.sessionId,
145
+ source: this.type,
146
+ adapter: GenericJsonlSource.id,
147
+ mtime: sessionRef.mtime,
148
+ bytes: sessionRef.bytes,
149
+ },
150
+ };
151
+ }
152
+
153
+ /** Hook wiring is the installer's job (UC-1). */
154
+ async registerSessionEndHook(cb) { return null; }
155
+ }
156
+
157
+ module.exports = { GenericJsonlSource, normalizeJsonlLines, probeJsonl, normalizeRole, extractText };