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/README.md +22 -6
- package/bin/auxilo-cli.js +52 -3
- package/lib/installer.js +712 -28
- package/mcp-server.js +114 -19
- package/package.json +5 -7
- package/scripts/capture-core.js +218 -0
- package/scripts/runner.js +98 -29
- package/scripts/sources/antigravity.js +131 -0
- package/scripts/sources/gemini-cli.js +178 -0
- package/scripts/sources/generic-jsonl.js +157 -0
- package/prompts/auxilo-learning-claude-code.md +0 -250
- package/prompts/auxilo-learning-generic.md +0 -327
- package/prompts/auxilo-learning-schema.json +0 -98
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,
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
|
619
|
-
// claude-code since it can parse either
|
|
620
|
-
//
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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 };
|