auxilo-mcp 0.9.2 → 0.9.4
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 +4 -5
- package/bin/auxilo-cli.js +205 -31
- package/lib/installer.js +392 -12
- package/lib/sensitivity-filter.js +48 -1
- package/mcp-server.js +69 -25
- package/package.json +7 -3
- package/scripts/extract-local.js +128 -17
- package/scripts/review-notice.js +145 -0
- package/scripts/runner.js +192 -49
- package/scripts/sources/cline.js +160 -0
- package/scripts/sources/continue.js +140 -0
- package/scripts/sources/roo-code.js +35 -0
- package/scripts/sources/source.interface.js +84 -1
package/scripts/runner.js
CHANGED
|
@@ -36,11 +36,9 @@ const fs = require('fs');
|
|
|
36
36
|
const path = require('path');
|
|
37
37
|
const os = require('os');
|
|
38
38
|
const crypto = require('crypto');
|
|
39
|
+
const { spawn } = require('child_process');
|
|
39
40
|
const { scanText, SENSITIVITY_FILTER_VERSION } = require('../lib/sensitivity-filter.js');
|
|
40
|
-
const {
|
|
41
|
-
const { OpenClawSource } = require('./sources/openclaw.js');
|
|
42
|
-
const { GeminiCliSource } = require('./sources/gemini-cli.js');
|
|
43
|
-
const { AntigravitySource } = require('./sources/antigravity.js');
|
|
41
|
+
const { TranscriptSource } = require('./sources/source.interface.js');
|
|
44
42
|
const { GenericJsonlSource } = require('./sources/generic-jsonl.js');
|
|
45
43
|
|
|
46
44
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
@@ -87,14 +85,42 @@ const PENDING_DIR = path.join(AUXILO_DIR, 'pending-learnings');
|
|
|
87
85
|
const LEDGER_PATH = path.join(AUXILO_DIR, 'ledger.json');
|
|
88
86
|
const LOG_PATH = path.join(AUXILO_DIR, 'extract.log');
|
|
89
87
|
|
|
90
|
-
// ─── Source Registry (§4.4)
|
|
88
|
+
// ─── Source Registry (§4.4 / UC-3 dynamic) ──────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// UC-3: the registry is built by enumerating scripts/sources/*.js — a new
|
|
91
|
+
// adapter self-registers by dropping a file in that exports a TranscriptSource
|
|
92
|
+
// subclass with a static `id`. Excluded by name: the abstract interface, and
|
|
93
|
+
// generic-jsonl (the config-instantiated single-file FALLBACK, not a
|
|
94
|
+
// discoverable source — its detect() is deliberately inert).
|
|
95
|
+
//
|
|
96
|
+
// Per-file try/catch: one broken adapter must never kill the runner
|
|
97
|
+
// (fail-silent degradation, UC §5). Duplicate ids: first file wins
|
|
98
|
+
// (alphabetical), so a rogue copy can't shadow an established adapter.
|
|
91
99
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
];
|
|
100
|
+
const SOURCE_REGISTRY_EXCLUDE = new Set(['source.interface.js', 'generic-jsonl.js']);
|
|
101
|
+
|
|
102
|
+
function loadSources() {
|
|
103
|
+
const dir = path.join(__dirname, 'sources');
|
|
104
|
+
const classes = [];
|
|
105
|
+
let files = [];
|
|
106
|
+
try {
|
|
107
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.js') && !SOURCE_REGISTRY_EXCLUDE.has(f)).sort();
|
|
108
|
+
} catch { return classes; }
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
let mod;
|
|
111
|
+
try { mod = require(`./sources/${f}`); } catch { continue; } // broken adapter — skip silently
|
|
112
|
+
for (const exported of Object.values(mod || {})) {
|
|
113
|
+
if (typeof exported !== 'function') continue;
|
|
114
|
+
if (!(exported.prototype instanceof TranscriptSource)) continue;
|
|
115
|
+
if (!exported.id || exported.id === 'abstract') continue;
|
|
116
|
+
if (classes.some(c => c.id === exported.id)) continue;
|
|
117
|
+
classes.push(exported);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return classes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const SOURCES = loadSources();
|
|
98
124
|
|
|
99
125
|
async function enumerateActiveSources(filter) {
|
|
100
126
|
const active = [];
|
|
@@ -233,32 +259,43 @@ function listPendingFiles() {
|
|
|
233
259
|
|
|
234
260
|
// ─── Upload ─────────────────────────────────────────────────────────────────
|
|
235
261
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
262
|
+
/**
|
|
263
|
+
* Submit finished learnings to POST /learn, classifying each response
|
|
264
|
+
* truthfully (P1-13b / SPEC3 A2):
|
|
265
|
+
* 2xx + status 'approved' → published (seamless)
|
|
266
|
+
* 2xx + status 'pending_review' → held (the contributor review queue)
|
|
267
|
+
* anything else / network error → rejected
|
|
268
|
+
*
|
|
269
|
+
* Every submission carries `submission_channel: 'extraction'` UNCONDITIONALLY.
|
|
270
|
+
* SPEC3 §3.2 trust analysis: the marker is a BRAKE, never a gas pedal — a
|
|
271
|
+
* pre-B1 server destructures known fields only and ignores it; a post-B1
|
|
272
|
+
* server uses it to HOLD extraction-channel items behind standing consent.
|
|
273
|
+
* Shipping it dark means the brake is in the field before scoring ever arms
|
|
274
|
+
* (see the AUXILO_SCORE_EXTRACTION sequencing constraint in extract-local.js).
|
|
275
|
+
*
|
|
276
|
+
* `quality_self_assessment` passes through spread-if-present — extract-local
|
|
277
|
+
* only attaches it when the A1 score gate is on.
|
|
278
|
+
*
|
|
279
|
+
* @param {Array<object>} learnings
|
|
280
|
+
* @param {string} sourceType
|
|
281
|
+
* @param {object} [opts] { fetchImpl, baseUrl, apiKey } — injectable for tests
|
|
282
|
+
* @returns {Promise<{published:number, held:number, rejected:number}>}
|
|
283
|
+
*/
|
|
284
|
+
async function submitLearnings(learnings, sourceType, opts = {}) {
|
|
285
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
286
|
+
const baseUrl = opts.baseUrl || BASE_URL;
|
|
287
|
+
const apiKey = opts.apiKey !== undefined ? opts.apiKey : API_KEY;
|
|
252
288
|
|
|
253
289
|
let published = 0;
|
|
290
|
+
let held = 0;
|
|
254
291
|
let rejected = 0;
|
|
255
292
|
for (const l of learnings) {
|
|
256
293
|
try {
|
|
257
|
-
const res = await
|
|
294
|
+
const res = await fetchImpl(`${baseUrl}/learn`, {
|
|
258
295
|
method: 'POST',
|
|
259
296
|
headers: {
|
|
260
297
|
'Content-Type': 'application/json',
|
|
261
|
-
'X-API-Key':
|
|
298
|
+
'X-API-Key': apiKey,
|
|
262
299
|
'Idempotency-Key': crypto.randomUUID(),
|
|
263
300
|
},
|
|
264
301
|
body: JSON.stringify({
|
|
@@ -269,16 +306,69 @@ async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
|
|
|
269
306
|
task_context: l.task_context,
|
|
270
307
|
outcome: l.outcome,
|
|
271
308
|
contributor_agent: `auxilo-hook/${sourceType}`,
|
|
309
|
+
submission_channel: 'extraction',
|
|
310
|
+
...(l.quality_self_assessment && { quality_self_assessment: l.quality_self_assessment }),
|
|
272
311
|
}),
|
|
273
312
|
});
|
|
274
|
-
if (res.ok)
|
|
275
|
-
|
|
313
|
+
if (!res.ok) { rejected += 1; continue; }
|
|
314
|
+
let body = {};
|
|
315
|
+
try { body = await res.json(); } catch { /* tolerate non-JSON 2xx */ }
|
|
316
|
+
if (body && body.status === 'pending_review') held += 1;
|
|
317
|
+
else published += 1;
|
|
276
318
|
} catch (_) {
|
|
277
319
|
rejected += 1;
|
|
278
320
|
}
|
|
279
321
|
}
|
|
280
|
-
|
|
281
|
-
|
|
322
|
+
return { published, held, rejected };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function postExtract(transcript, sessionId, sourceType, _scrubReport) {
|
|
326
|
+
// CLIENT-SIDE extraction (2026-07-02). Server /extract is deprecated (410) — Auxilo
|
|
327
|
+
// does not pay to extract. The local model (via `claude -p`) extracts + self-screens
|
|
328
|
+
// the already-client-scrubbed transcript, and we submit finished learnings to /learn.
|
|
329
|
+
const { extractLocally } = require('./extract-local.js');
|
|
330
|
+
let learnings;
|
|
331
|
+
let skipped;
|
|
332
|
+
try {
|
|
333
|
+
({ learnings, skipped } = await extractLocally(transcript, sourceType));
|
|
334
|
+
} catch (err) {
|
|
335
|
+
throw new Error(`Local extraction failed: ${err.message}`);
|
|
336
|
+
}
|
|
337
|
+
if (skipped) {
|
|
338
|
+
log(`[runner] ${skipped}`);
|
|
339
|
+
return { learnings_published: 0, learnings_held: 0, learnings_rejected: 0, extraction_id: 'client-skip' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const { published, held, rejected } = await submitLearnings(learnings, sourceType);
|
|
343
|
+
// NOTE: token-free prose — the digest-parsed `published=`/`held=`/`rejected=`
|
|
344
|
+
// tokens live ONLY on the per-run caller log lines. This line previously
|
|
345
|
+
// carried `published=` too and the digest double-counted every extraction
|
|
346
|
+
// (its dedup only drops byte-identical lines). P1-13b.
|
|
347
|
+
log(`[runner] client-side extraction: ${learnings.length} candidate(s) → ${published} live, ${held} held for review, ${rejected} rejected`);
|
|
348
|
+
return { learnings_published: published, learnings_held: held, learnings_rejected: rejected, extraction_id: `client-${sessionId}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Held-items local notification (LW-18 layer 2) ──────────────────────────
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* macOS notification when a run holds items for review: COUNT ONLY, no titles,
|
|
355
|
+
* no content (pending bodies are adversarial by assumption — LW-18 threat
|
|
356
|
+
* model). Fail-silent: darwin-only, detached osascript spawn, every error
|
|
357
|
+
* swallowed; a broken notifier must never fail the run. AUXILO_NO_NOTIFY=1
|
|
358
|
+
* disables (tests, headless CI).
|
|
359
|
+
*/
|
|
360
|
+
function notifyHeld(count) {
|
|
361
|
+
try {
|
|
362
|
+
if (!count || count <= 0) return;
|
|
363
|
+
if (process.platform !== 'darwin') return;
|
|
364
|
+
if (process.env.AUXILO_NO_NOTIFY === '1') return;
|
|
365
|
+
const msg = `${count} learning(s) held for your review — run npx auxilo review`;
|
|
366
|
+
const child = spawn('/usr/bin/osascript', [
|
|
367
|
+
'-e', `display notification ${JSON.stringify(msg)} with title "Auxilo"`,
|
|
368
|
+
], { stdio: 'ignore', detached: true });
|
|
369
|
+
child.unref();
|
|
370
|
+
child.on('error', () => { /* fail-silent */ });
|
|
371
|
+
} catch { /* fail-silent */ }
|
|
282
372
|
}
|
|
283
373
|
|
|
284
374
|
// ─── Install Hooks (B15) ────────────────────────────────────────────────────
|
|
@@ -362,19 +452,38 @@ function installHooks() {
|
|
|
362
452
|
// Renamed 2026-07-15: the original label carried a dead pre-Fly host prefix (legacy-label purge).
|
|
363
453
|
const SWEEPER_LABEL = 'io.auxilo.sweeper';
|
|
364
454
|
|
|
455
|
+
/**
|
|
456
|
+
* The sweeper copy manifest. Sources rows are DERIVED by enumerating
|
|
457
|
+
* scripts/sources/*.js (UC-3): a new adapter file is automatically installed —
|
|
458
|
+
* the d8c7099 bug class (adapter added, manifest not, installed sweepers crash
|
|
459
|
+
* with MODULE_NOT_FOUND) is now structurally impossible. Exported for the
|
|
460
|
+
* manifest-closure test in test/wave3-client-funnel.test.js.
|
|
461
|
+
*/
|
|
462
|
+
function sweeperManifest(repoRoot = path.resolve(__dirname, '..')) {
|
|
463
|
+
const sourceRows = [];
|
|
464
|
+
try {
|
|
465
|
+
for (const f of fs.readdirSync(path.join(repoRoot, 'scripts', 'sources')).filter(f => f.endsWith('.js')).sort()) {
|
|
466
|
+
sourceRows.push([`scripts/sources/${f}`, `scripts/sources/${f}`, 0o644]);
|
|
467
|
+
}
|
|
468
|
+
} catch { /* missing dir surfaces as missing-file errors below */ }
|
|
469
|
+
return [
|
|
470
|
+
['scripts/auxilo-sweeper-wrapper.sh', 'auxilo-sweeper-wrapper.sh', 0o755],
|
|
471
|
+
['scripts/runner.js', 'scripts/runner.js', 0o755],
|
|
472
|
+
...sourceRows,
|
|
473
|
+
['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
|
|
474
|
+
// Client-side extraction (2026-07-02) — required by the sweep path since /extract went 410.
|
|
475
|
+
// Missing from this manifest until 2026-07-19: installed sweepers crashed with
|
|
476
|
+
// "Cannot find module './extract-local.js'" while queue files were retained.
|
|
477
|
+
['scripts/extract-local.js', 'scripts/extract-local.js', 0o644],
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
|
|
365
481
|
function installSweeper() {
|
|
366
482
|
const repoRoot = path.resolve(__dirname, '..');
|
|
367
483
|
const binRoot = path.join(AUXILO_DIR, 'bin');
|
|
368
484
|
|
|
369
485
|
// 1. Copy executable surface, preserving relative layout so requires resolve.
|
|
370
|
-
const filesToCopy =
|
|
371
|
-
['scripts/auxilo-sweeper-wrapper.sh', 'auxilo-sweeper-wrapper.sh', 0o755],
|
|
372
|
-
['scripts/runner.js', 'scripts/runner.js', 0o755],
|
|
373
|
-
['scripts/sources/source.interface.js', 'scripts/sources/source.interface.js', 0o644],
|
|
374
|
-
['scripts/sources/claude-code.js', 'scripts/sources/claude-code.js', 0o644],
|
|
375
|
-
['scripts/sources/openclaw.js', 'scripts/sources/openclaw.js', 0o644],
|
|
376
|
-
['lib/sensitivity-filter.js', 'lib/sensitivity-filter.js', 0o644],
|
|
377
|
-
];
|
|
486
|
+
const filesToCopy = sweeperManifest(repoRoot);
|
|
378
487
|
for (const [src, dest, mode] of filesToCopy) {
|
|
379
488
|
const srcPath = path.join(repoRoot, src);
|
|
380
489
|
const destPath = path.join(binRoot, dest);
|
|
@@ -672,9 +781,19 @@ async function main() {
|
|
|
672
781
|
|
|
673
782
|
let transcriptData;
|
|
674
783
|
try {
|
|
675
|
-
|
|
784
|
+
// N1: capped read — the base-adapter path enforces AUXILO_MAX_SESSION_BYTES.
|
|
785
|
+
transcriptData = await source.readSessionCapped(sessionRef);
|
|
676
786
|
} catch (err) {
|
|
677
|
-
|
|
787
|
+
if (err && err.code === 'SESSION_TOO_LARGE') {
|
|
788
|
+
// Oversize is a counted SKIP, and it must short-circuit BEFORE the raw
|
|
789
|
+
// readFileSync fallback below — that fallback is the exact unbounded
|
|
790
|
+
// read the cap exists to prevent.
|
|
791
|
+
console.error(`[runner] SKIPPED oversize session ${sessionRef.sessionId} (${err.bytes} bytes > cap ${err.maxBytes}; oversize_skipped=1)`);
|
|
792
|
+
log(`[runner] Skipped oversize session ${sessionRef.sessionId} (${err.bytes} bytes > cap ${err.maxBytes})`);
|
|
793
|
+
process.exit(0);
|
|
794
|
+
}
|
|
795
|
+
// Fallback: treat the file as a plain pre-formatted transcript. Safe to
|
|
796
|
+
// read raw here: the cap check above already passed (under-cap file).
|
|
678
797
|
try {
|
|
679
798
|
transcriptData = { transcript: fs.readFileSync(transcriptPath, 'utf-8') };
|
|
680
799
|
} catch (e2) {
|
|
@@ -734,7 +853,8 @@ async function main() {
|
|
|
734
853
|
|
|
735
854
|
try {
|
|
736
855
|
const result = await postExtract(cleaned, sessionId, sourceType, report);
|
|
737
|
-
log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
856
|
+
log(`[runner] ✓ published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
857
|
+
notifyHeld(result.learnings_held || 0); // LW-18 layer 2: count-only, fail-silent
|
|
738
858
|
// Mark only after a successful upload so a failed POST can be retried.
|
|
739
859
|
const ledger = loadLedger();
|
|
740
860
|
ledgerMark(ledger, sourceType, sessionId, contentSha, sessionRef.mtime);
|
|
@@ -751,13 +871,15 @@ async function main() {
|
|
|
751
871
|
const pending = listPendingFiles();
|
|
752
872
|
log(`[runner] Flushing ${pending.length} pending queue file(s)...`);
|
|
753
873
|
let flushed = 0;
|
|
874
|
+
let flushHeld = 0;
|
|
754
875
|
for (const qf of pending) {
|
|
755
876
|
try {
|
|
756
877
|
const payload = JSON.parse(fs.readFileSync(qf, 'utf-8'));
|
|
757
878
|
const result = await postExtract(
|
|
758
879
|
payload.transcript, payload.sessionId, payload.source, payload.scrubReport
|
|
759
880
|
);
|
|
760
|
-
log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
|
|
881
|
+
log(`[runner] ✓ Flushed ${path.basename(qf)}: published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT}`);
|
|
882
|
+
flushHeld += result.learnings_held || 0;
|
|
761
883
|
deleteQueueFile(qf);
|
|
762
884
|
flushed++;
|
|
763
885
|
} catch (err) {
|
|
@@ -765,6 +887,7 @@ async function main() {
|
|
|
765
887
|
}
|
|
766
888
|
}
|
|
767
889
|
log(`[runner] Flush complete: ${flushed}/${pending.length} succeeded`);
|
|
890
|
+
notifyHeld(flushHeld); // LW-18 layer 2: count-only, fail-silent
|
|
768
891
|
process.exit(0);
|
|
769
892
|
}
|
|
770
893
|
|
|
@@ -779,7 +902,9 @@ async function main() {
|
|
|
779
902
|
let totalDiscovered = 0;
|
|
780
903
|
let totalProcessed = 0;
|
|
781
904
|
let totalSkipped = 0;
|
|
905
|
+
let totalOversize = 0; // N1: oversize-cap skips (subset of totalSkipped)
|
|
782
906
|
let totalFailed = 0;
|
|
907
|
+
let totalHeld = 0;
|
|
783
908
|
|
|
784
909
|
for (const source of sources) {
|
|
785
910
|
log(`[runner] Discovering sessions from ${source.label} (${source.type})...`);
|
|
@@ -804,11 +929,23 @@ async function main() {
|
|
|
804
929
|
for (const sessionRef of sessions) {
|
|
805
930
|
log(`[runner] Processing ${sessionRef.sessionId} (${sessionRef.bytes} bytes)...`);
|
|
806
931
|
|
|
807
|
-
// Read transcript
|
|
932
|
+
// Read transcript (N1: capped — base-adapter path enforces
|
|
933
|
+
// AUXILO_MAX_SESSION_BYTES before any byte is read)
|
|
808
934
|
let transcriptData;
|
|
809
935
|
try {
|
|
810
|
-
transcriptData = await source.
|
|
936
|
+
transcriptData = await source.readSessionCapped(sessionRef);
|
|
811
937
|
} catch (err) {
|
|
938
|
+
if (err && err.code === 'SESSION_TOO_LARGE') {
|
|
939
|
+
// Counted skip, never a failure. stderr so constrained-sweeper logs
|
|
940
|
+
// surface it; ledger-marked (probe-refused idiom) so an immutable
|
|
941
|
+
// oversize file is not re-announced every sweep.
|
|
942
|
+
totalOversize++;
|
|
943
|
+
totalSkipped++;
|
|
944
|
+
console.error(`[runner] SKIPPED oversize session ${sessionRef.sessionId} (${err.bytes} bytes > cap ${err.maxBytes}; oversize_skipped=${totalOversize})`);
|
|
945
|
+
log(`[runner] Skipped oversize session ${sessionRef.sessionId} (${err.bytes} bytes > cap ${err.maxBytes})`);
|
|
946
|
+
ledgerMark(ledger, source.type, sessionRef.sessionId, 'oversize', sessionRef.mtime);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
812
949
|
log(`[runner] Read failed: ${err.message}`);
|
|
813
950
|
totalFailed++;
|
|
814
951
|
continue;
|
|
@@ -878,7 +1015,8 @@ async function main() {
|
|
|
878
1015
|
|
|
879
1016
|
try {
|
|
880
1017
|
const result = await postExtract(cleaned, sessionRef.sessionId, source.type, report);
|
|
881
|
-
log(`[runner] ✓ published=${result.learnings_published || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
1018
|
+
log(`[runner] ✓ published=${result.learnings_published || 0} held=${result.learnings_held || 0} rejected=${result.learnings_rejected || 0} ${DIGEST_ACCOUNT} (extraction: ${result.extraction_id})`);
|
|
1019
|
+
totalHeld += result.learnings_held || 0;
|
|
882
1020
|
ledgerMark(ledger, source.type, sessionRef.sessionId, sha, sessionRef.mtime);
|
|
883
1021
|
deleteQueueFile(queueFile);
|
|
884
1022
|
saveLedger(ledger);
|
|
@@ -892,7 +1030,11 @@ async function main() {
|
|
|
892
1030
|
}
|
|
893
1031
|
|
|
894
1032
|
saveLedger(ledger);
|
|
895
|
-
log(`[runner] Summary: ${totalDiscovered} discovered, ${totalProcessed} processed, ${totalSkipped} skipped, ${totalFailed} failed`);
|
|
1033
|
+
log(`[runner] Summary: ${totalDiscovered} discovered, ${totalProcessed} processed, ${totalSkipped} skipped (${totalOversize} oversize), ${totalFailed} failed`);
|
|
1034
|
+
if (totalOversize > 0) {
|
|
1035
|
+
console.error(`[runner] oversize_skipped=${totalOversize} (sessions above AUXILO_MAX_SESSION_BYTES were skipped, not read)`);
|
|
1036
|
+
}
|
|
1037
|
+
notifyHeld(totalHeld); // LW-18 layer 2: one notification per sweep, count-only
|
|
896
1038
|
process.exit(totalFailed > 0 ? 1 : 0);
|
|
897
1039
|
}
|
|
898
1040
|
|
|
@@ -902,6 +1044,7 @@ module.exports = {
|
|
|
902
1044
|
parseArgs, writeQueueFile, deleteQueueFile, listPendingFiles,
|
|
903
1045
|
loadLedger, saveLedger, ledgerHighWater, ledgerHas, ledgerMark,
|
|
904
1046
|
installHooks, installSweeper, installDigest, printStatus, scrubAndVerify, enumerateActiveSources,
|
|
1047
|
+
loadSources, SOURCES, sweeperManifest, submitLearnings, notifyHeld,
|
|
905
1048
|
KILL_SWITCH_PATH, PENDING_DIR, LEDGER_PATH,
|
|
906
1049
|
};
|
|
907
1050
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/sources/cline.js — Cline (VS Code extension) Transcript Source (UC-3)
|
|
3
|
+
*
|
|
4
|
+
* Discovers and reads Cline task conversation histories from VS Code global
|
|
5
|
+
* storage:
|
|
6
|
+
*
|
|
7
|
+
* <vscode-user-dir>/globalStorage/saoudrizwan.claude-dev/tasks/<taskId>/
|
|
8
|
+
* api_conversation_history.json ← read (Anthropic Messages format)
|
|
9
|
+
* ui_messages.json ← ignored (UI event stream)
|
|
10
|
+
*
|
|
11
|
+
* Candidate VS Code roots probed (any-of): Code, Code - Insiders, VSCodium —
|
|
12
|
+
* per-platform user-data locations (darwin: ~/Library/Application Support,
|
|
13
|
+
* linux: ~/.config, win32: %APPDATA%).
|
|
14
|
+
*
|
|
15
|
+
* KNOWN SHAPE (format probe accepts exactly this, refuses everything else):
|
|
16
|
+
* [ { role: "user"|"assistant", content: string | [ {type:"text", text} | ... ] } ]
|
|
17
|
+
* (tool_use / tool_result blocks and non-text parts are skipped)
|
|
18
|
+
*
|
|
19
|
+
* NOTE (2026-07-19): built from Cline's documented/observed storage layout.
|
|
20
|
+
* No Cline installation existed on the build machine to verify against
|
|
21
|
+
* (probed all globalStorage roots — absent). The strict probe + fail-silent
|
|
22
|
+
* contract means a drifted real-world format degrades to "skipped with one
|
|
23
|
+
* log line", never a mis-parse (UC §5 risk rule) — the same posture the
|
|
24
|
+
* gemini-cli adapter shipped with. Publicly labeled best-effort (UC-3).
|
|
25
|
+
*
|
|
26
|
+
* @module sources/cline
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
const { TranscriptSource } = require('./source.interface');
|
|
35
|
+
const { normalizeRole, extractText } = require('./generic-jsonl');
|
|
36
|
+
|
|
37
|
+
/** Per-platform VS Code-family user-data roots (any-of detection). */
|
|
38
|
+
function vscodeUserDirs(homeDir, platform, env) {
|
|
39
|
+
const apps = ['Code', 'Code - Insiders', 'VSCodium'];
|
|
40
|
+
if (platform === 'darwin') {
|
|
41
|
+
return apps.map(a => path.join(homeDir, 'Library', 'Application Support', a, 'User'));
|
|
42
|
+
}
|
|
43
|
+
if (platform === 'win32') {
|
|
44
|
+
const appData = (env && env.APPDATA) || path.join(homeDir, 'AppData', 'Roaming');
|
|
45
|
+
return apps.map(a => path.join(appData, a, 'User'));
|
|
46
|
+
}
|
|
47
|
+
return apps.map(a => path.join(homeDir, '.config', a, 'User'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class ClineSource extends TranscriptSource {
|
|
51
|
+
static id = 'cline';
|
|
52
|
+
static displayName = 'Cline (VS Code)';
|
|
53
|
+
static version = '1.0.0';
|
|
54
|
+
|
|
55
|
+
/** Extension publisher dir(s) under globalStorage — subclass override point. */
|
|
56
|
+
static publisherDirs = ['saoudrizwan.claude-dev'];
|
|
57
|
+
|
|
58
|
+
constructor(config = {}) {
|
|
59
|
+
super(config);
|
|
60
|
+
const homeDir = config.homeDir || os.homedir();
|
|
61
|
+
const platform = config.platform || process.platform;
|
|
62
|
+
const env = config.env || process.env;
|
|
63
|
+
/** @type {string[]} candidate tasks roots (first existing wins per discover) */
|
|
64
|
+
this.taskRoots = config.taskRoots || vscodeUserDirs(homeDir, platform, env).flatMap(
|
|
65
|
+
(userDir) => this.constructor.publisherDirs.map(
|
|
66
|
+
(pub) => path.join(userDir, 'globalStorage', pub, 'tasks')
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Detect: any candidate tasks root exists and is a directory. Fail-silent. */
|
|
72
|
+
async detect() {
|
|
73
|
+
return this.taskRoots.some((p) => {
|
|
74
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Discover task sessions: each task dir containing
|
|
80
|
+
* api_conversation_history.json modified after `since`.
|
|
81
|
+
*/
|
|
82
|
+
async discoverSessions({ since } = {}) {
|
|
83
|
+
const results = [];
|
|
84
|
+
const sinceMs = since ? new Date(since).getTime() : 0;
|
|
85
|
+
|
|
86
|
+
for (const root of this.taskRoots) {
|
|
87
|
+
let taskIds;
|
|
88
|
+
try { taskIds = fs.readdirSync(root); } catch { continue; }
|
|
89
|
+
for (const taskId of taskIds) {
|
|
90
|
+
const historyPath = path.join(root, taskId, 'api_conversation_history.json');
|
|
91
|
+
try {
|
|
92
|
+
const stat = fs.statSync(historyPath);
|
|
93
|
+
if (!stat.isFile()) continue;
|
|
94
|
+
if (stat.mtimeMs <= sinceMs) continue;
|
|
95
|
+
results.push({
|
|
96
|
+
sessionId: `${this.type}-${taskId}`,
|
|
97
|
+
path: historyPath,
|
|
98
|
+
mtime: stat.mtime.toISOString(),
|
|
99
|
+
bytes: stat.size,
|
|
100
|
+
});
|
|
101
|
+
} catch { /* task dir without a history file — skip */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read + normalize one task history. Returns null (single stderr log line)
|
|
109
|
+
* when the format probe refuses — never throws on bad format.
|
|
110
|
+
*/
|
|
111
|
+
async readSession(sessionRef) {
|
|
112
|
+
const filePath = sessionRef.path;
|
|
113
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
114
|
+
throw new Error(`Transcript file not found: ${filePath}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
120
|
+
} catch {
|
|
121
|
+
return this._refuse(filePath, 'not valid JSON');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Probe: an ARRAY of {role, content} message objects (Anthropic format).
|
|
125
|
+
if (!Array.isArray(parsed)) return this._refuse(filePath, 'not a messages array');
|
|
126
|
+
const messageLike = parsed.filter((m) => m && typeof m === 'object' && m.role !== undefined && m.content !== undefined);
|
|
127
|
+
if (parsed.length === 0 || messageLike.length === 0) {
|
|
128
|
+
return this._refuse(filePath, 'no role/content messages');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const turns = [];
|
|
132
|
+
for (const m of messageLike) {
|
|
133
|
+
const role = normalizeRole(m.role);
|
|
134
|
+
if (!role) continue; // system / tool roles — skip
|
|
135
|
+
const text = extractText(m.content);
|
|
136
|
+
if (text && text.trim().length > 0) turns.push(`[${role}]: ${text}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
transcript: turns.join('\n\n'),
|
|
141
|
+
metadata: {
|
|
142
|
+
sessionId: sessionRef.sessionId,
|
|
143
|
+
source: this.type,
|
|
144
|
+
mtime: sessionRef.mtime,
|
|
145
|
+
bytes: sessionRef.bytes,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Single log line + null per the UC §5 fail-silent rule. */
|
|
151
|
+
_refuse(filePath, reason) {
|
|
152
|
+
process.stderr.write(`[${this.type}] format probe refused ${filePath} (${reason}) — skipping\n`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** No hook surface — poll-based via the sweeper (UC-3 P-class). */
|
|
157
|
+
async registerSessionEndHook(cb) { return null; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { ClineSource, vscodeUserDirs };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/sources/continue.js — Continue.dev Transcript Source (UC-3)
|
|
3
|
+
*
|
|
4
|
+
* Discovers and reads Continue.dev saved sessions:
|
|
5
|
+
*
|
|
6
|
+
* ~/.continue/sessions/<sessionId>.json ← read (one file per session)
|
|
7
|
+
* ~/.continue/sessions/sessions.json ← index (metadata only; ignored)
|
|
8
|
+
*
|
|
9
|
+
* KNOWN SHAPES (format probe accepts exactly these, refuses everything else):
|
|
10
|
+
*
|
|
11
|
+
* { history: [ ...items ] , title?, sessionId?, workspaceDirectory? }
|
|
12
|
+
* where each item is either
|
|
13
|
+
* { message: { role: "user"|"assistant", content: string|[{...}] }, ... }
|
|
14
|
+
* or (older sessions)
|
|
15
|
+
* { role: "user"|"assistant", content: string|[{...}] }
|
|
16
|
+
*
|
|
17
|
+
* Tool/system roles and non-text content parts are skipped.
|
|
18
|
+
*
|
|
19
|
+
* NOTE (2026-07-19): built from Continue's documented session persistence
|
|
20
|
+
* (~/.continue/sessions). No Continue installation existed on the build
|
|
21
|
+
* machine to verify against (~/.continue absent). Strict probe + fail-silent
|
|
22
|
+
* degradation per UC §5 — a drifted format is skipped with one log line,
|
|
23
|
+
* never mis-parsed. Publicly labeled best-effort (UC-3).
|
|
24
|
+
*
|
|
25
|
+
* @module sources/continue
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const { TranscriptSource } = require('./source.interface');
|
|
34
|
+
const { normalizeRole, extractText } = require('./generic-jsonl');
|
|
35
|
+
|
|
36
|
+
class ContinueSource extends TranscriptSource {
|
|
37
|
+
static id = 'continue';
|
|
38
|
+
static displayName = 'Continue.dev';
|
|
39
|
+
static version = '1.0.0';
|
|
40
|
+
|
|
41
|
+
constructor(config = {}) {
|
|
42
|
+
super(config);
|
|
43
|
+
/** @type {string} Sessions dir */
|
|
44
|
+
this.sessionsDir = config.sessionsDir ||
|
|
45
|
+
path.join(config.homeDir || os.homedir(), '.continue', 'sessions');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Detect: ~/.continue/sessions exists and is a directory. Fail-silent. */
|
|
49
|
+
async detect() {
|
|
50
|
+
try {
|
|
51
|
+
return fs.statSync(this.sessionsDir).isDirectory();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Discover session JSON files (excluding the sessions.json index). */
|
|
58
|
+
async discoverSessions({ since } = {}) {
|
|
59
|
+
const results = [];
|
|
60
|
+
const sinceMs = since ? new Date(since).getTime() : 0;
|
|
61
|
+
|
|
62
|
+
let files;
|
|
63
|
+
try {
|
|
64
|
+
files = fs.readdirSync(this.sessionsDir)
|
|
65
|
+
.filter(f => f.endsWith('.json') && f !== 'sessions.json');
|
|
66
|
+
} catch {
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const filePath = path.join(this.sessionsDir, file);
|
|
72
|
+
try {
|
|
73
|
+
const stat = fs.statSync(filePath);
|
|
74
|
+
if (!stat.isFile()) continue;
|
|
75
|
+
if (stat.mtimeMs <= sinceMs) continue;
|
|
76
|
+
results.push({
|
|
77
|
+
sessionId: path.basename(file, '.json'),
|
|
78
|
+
path: filePath,
|
|
79
|
+
mtime: stat.mtime.toISOString(),
|
|
80
|
+
bytes: stat.size,
|
|
81
|
+
});
|
|
82
|
+
} catch { /* skip unreadable files */ }
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read + normalize one session. Returns null (single stderr log line) when
|
|
89
|
+
* the format probe refuses — never throws on bad format.
|
|
90
|
+
*/
|
|
91
|
+
async readSession(sessionRef) {
|
|
92
|
+
const filePath = sessionRef.path;
|
|
93
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
94
|
+
throw new Error(`Transcript file not found: ${filePath}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
100
|
+
} catch {
|
|
101
|
+
return this._refuse(filePath, 'not valid JSON');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.history)) {
|
|
105
|
+
return this._refuse(filePath, 'no history array');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const turns = [];
|
|
109
|
+
for (const item of parsed.history) {
|
|
110
|
+
if (!item || typeof item !== 'object') continue;
|
|
111
|
+
// New shape nests the message; older items ARE the message.
|
|
112
|
+
const msg = (item.message && typeof item.message === 'object') ? item.message : item;
|
|
113
|
+
const role = normalizeRole(msg.role);
|
|
114
|
+
if (!role) continue;
|
|
115
|
+
const text = extractText(msg.content);
|
|
116
|
+
if (text && text.trim().length > 0) turns.push(`[${role}]: ${text}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
transcript: turns.join('\n\n'),
|
|
121
|
+
metadata: {
|
|
122
|
+
sessionId: sessionRef.sessionId,
|
|
123
|
+
source: 'continue',
|
|
124
|
+
mtime: sessionRef.mtime,
|
|
125
|
+
bytes: sessionRef.bytes,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Single log line + null per the UC §5 fail-silent rule. */
|
|
131
|
+
_refuse(filePath, reason) {
|
|
132
|
+
process.stderr.write(`[continue] format probe refused ${filePath} (${reason}) — skipping\n`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** No hook surface — poll-based via the sweeper (UC-3 P-class). */
|
|
137
|
+
async registerSessionEndHook(cb) { return null; }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { ContinueSource };
|