auxilo-mcp 0.9.17 → 0.9.19
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 +2 -2
- package/bin/auxilo-cli.js +58 -8
- package/lib/installer.js +71 -11
- package/mcp-server.js +1 -1
- package/package.json +1 -1
- package/scripts/extract-local.js +91 -37
- package/scripts/providers/index.js +93 -3
- package/scripts/sources/devin.js +303 -0
package/README.md
CHANGED
|
@@ -87,9 +87,9 @@ Add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claud
|
|
|
87
87
|
|
|
88
88
|
The same `mcpServers` block in `~/.cursor/mcp.json`.
|
|
89
89
|
|
|
90
|
-
**Windsurf
|
|
90
|
+
**Devin Desktop** (formerly Windsurf)
|
|
91
91
|
|
|
92
|
-
The same `mcpServers` block in `~/.codeium/windsurf/mcp_config.json
|
|
92
|
+
The same `mcpServers` block in `~/.config/devin/mcp_config.json` (legacy path: `~/.codeium/windsurf/mcp_config.json`).
|
|
93
93
|
|
|
94
94
|
**Any other MCP client**
|
|
95
95
|
|
package/bin/auxilo-cli.js
CHANGED
|
@@ -215,8 +215,17 @@ async function cmdSetup(flags) {
|
|
|
215
215
|
|
|
216
216
|
console.log('Detected clients:');
|
|
217
217
|
detected.forEach((c, i) => {
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
// CLI-CAPTURE-MODE-DISAGREE: was `c.hooks ? 'background extraction' : null`
|
|
219
|
+
// — true only for Claude Code, so the seven captureHook clients (cursor,
|
|
220
|
+
// windsurf, codex, gemini-cli, antigravity, factory, copilot-cli) printed
|
|
221
|
+
// "(MCP)" here even though setup wires their capture hook a few steps
|
|
222
|
+
// later. `installer.clientHasCaptureHookMode` is the same predicate
|
|
223
|
+
// `auxilo status` already uses (`c.captureHook`, cmdStatus below), so the
|
|
224
|
+
// two screens can no longer disagree about a client's capture mode.
|
|
225
|
+
const extras = [
|
|
226
|
+
c.mcp ? 'MCP' : 'poll-based source',
|
|
227
|
+
installer.clientHasCaptureHookMode(c) ? 'background extraction' : null,
|
|
228
|
+
].filter(Boolean).join(', ');
|
|
220
229
|
console.log(` ${i + 1}. ${c.name} (${extras})`);
|
|
221
230
|
});
|
|
222
231
|
|
|
@@ -586,7 +595,7 @@ async function cmdStatus() {
|
|
|
586
595
|
);
|
|
587
596
|
if (autoupdateLine) console.log(autoupdateLine);
|
|
588
597
|
}
|
|
589
|
-
console.log(extractionProviderLine(
|
|
598
|
+
console.log(extractionProviderLine(lastRecordedProviderResolution()));
|
|
590
599
|
// Lazy require: scripts/runner.js is a heavier module (sources, sensitivity
|
|
591
600
|
// filter, ops-alert) than this one status line needs at require-time for
|
|
592
601
|
// every CLI invocation.
|
|
@@ -625,18 +634,58 @@ function runnerSkewLine(skew) {
|
|
|
625
634
|
*/
|
|
626
635
|
const CLI_CLEAN_LANE_CALIBRATED_PROVIDERS = ['claude-code'];
|
|
627
636
|
|
|
637
|
+
/**
|
|
638
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1): `auxilo status` used to feed
|
|
639
|
+
* extractionProviderLine() a LIVE `providers.resolveProvider({})` call — a
|
|
640
|
+
* fresh detect() answering "what would run right now" (and, on a full scan,
|
|
641
|
+
* capable of writing ~/.auxilo/providers.json's `selected` field as a side
|
|
642
|
+
* effect of a status check), not "what actually ran". This function replaces
|
|
643
|
+
* that with two read-only, no-detect sources of TRUTH, in priority order:
|
|
644
|
+
* (1) AUXILO_EXTRACTION_PROVIDER, if set, is a certain fact about the
|
|
645
|
+
* current session's config — reading it is not a guess — validated
|
|
646
|
+
* against providers.PROVIDER_ORDER exactly as resolveProvider() itself
|
|
647
|
+
* validates an override, without calling it.
|
|
648
|
+
* (2) Otherwise, providers.json's `selected` field — the LAST provider a
|
|
649
|
+
* genuine resolveProvider() full-scan actually chose and persisted
|
|
650
|
+
* (scripts/providers/index.js's persistSelected(), only ever called
|
|
651
|
+
* after a real detect() succeeded) — read here with a plain
|
|
652
|
+
* fs.readFileSync, no detect() invoked, no possibility of writing.
|
|
653
|
+
* Neither source can misrepresent "would run" as "ran": (1) is what WILL
|
|
654
|
+
* run (an explicit operator override, not a probe), and (2) is what was
|
|
655
|
+
* last recorded to have been selected, honestly labeled as such below.
|
|
656
|
+
* `{ok:false}` (shown as "no recorded provider selection yet") when neither
|
|
657
|
+
* source has an answer — e.g. a fresh install that has never extracted.
|
|
658
|
+
*/
|
|
659
|
+
function lastRecordedProviderResolution() {
|
|
660
|
+
const override = process.env.AUXILO_EXTRACTION_PROVIDER;
|
|
661
|
+
if (override) {
|
|
662
|
+
if (providers.PROVIDER_ORDER.includes(override)) return { ok: true, id: override };
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
reason: `AUXILO_EXTRACTION_PROVIDER="${override}" is not a known provider (expected one of: ${providers.PROVIDER_ORDER.join(', ')})`,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
const raw = fs.readFileSync(providers.PROVIDERS_STATE_PATH, 'utf8');
|
|
670
|
+
const parsed = JSON.parse(raw);
|
|
671
|
+
const id = parsed && typeof parsed === 'object' && typeof parsed.selected === 'string' ? parsed.selected : null;
|
|
672
|
+
if (id) return { ok: true, id };
|
|
673
|
+
} catch { /* no recorded selection yet, or the file is unreadable/corrupt */ }
|
|
674
|
+
return { ok: false, reason: 'no recorded provider selection yet' };
|
|
675
|
+
}
|
|
676
|
+
|
|
628
677
|
/**
|
|
629
678
|
* EXTRACT-PER-CLIENT W1 PART A/C — one unconditional line naming which
|
|
630
|
-
* extraction model provider resolves, why (env override vs
|
|
631
|
-
* and (PART C) whether that provider's submissions can reach
|
|
632
|
-
* auto-publish path at all (server-side gate:
|
|
633
|
-
* CLEAN_LANE_CALIBRATED_PROVIDERS, mirrored above).
|
|
679
|
+
* extraction model provider resolves, why (env override vs last recorded
|
|
680
|
+
* selection), and (PART C) whether that provider's submissions can reach
|
|
681
|
+
* the clean-lane auto-publish path at all (server-side gate:
|
|
682
|
+
* lib/clean-lane.js's CLEAN_LANE_CALIBRATED_PROVIDERS, mirrored above).
|
|
634
683
|
*/
|
|
635
684
|
function extractionProviderLine(resolution) {
|
|
636
685
|
if (resolution && resolution.ok) {
|
|
637
686
|
const via = process.env.AUXILO_EXTRACTION_PROVIDER
|
|
638
687
|
? 'env override AUXILO_EXTRACTION_PROVIDER'
|
|
639
|
-
: '
|
|
688
|
+
: 'last recorded selection';
|
|
640
689
|
const calibration = CLI_CLEAN_LANE_CALIBRATED_PROVIDERS.includes(resolution.id)
|
|
641
690
|
? 'clean-lane calibrated'
|
|
642
691
|
: 'review-lane only';
|
|
@@ -1639,6 +1688,7 @@ module.exports = {
|
|
|
1639
1688
|
parseFlags,
|
|
1640
1689
|
runnerSkewLine,
|
|
1641
1690
|
extractionProviderLine,
|
|
1691
|
+
lastRecordedProviderResolution,
|
|
1642
1692
|
resolveBaseUrl,
|
|
1643
1693
|
shortFlags,
|
|
1644
1694
|
groupSummaryRows,
|
package/lib/installer.js
CHANGED
|
@@ -181,7 +181,11 @@ const RUNNER_STACK = Object.freeze([
|
|
|
181
181
|
* `captureEvent` (the client's event name), `captureConfigPath` (the hook
|
|
182
182
|
* config file registerCaptureHook patches), plus `sourceId` when the
|
|
183
183
|
* runner-side source id differs from the registry id (codex → codex-cli,
|
|
184
|
-
* copilot-cli → copilot
|
|
184
|
+
* copilot-cli → copilot, windsurf → devin — the id kept for ledger
|
|
185
|
+
* continuity vs. the scripts/sources/*.js poll adapter's own static id;
|
|
186
|
+
* model_config.json's source_allowlist is the separate, deprecated
|
|
187
|
+
* server-side-extraction gate and is NOT where this must match — see its
|
|
188
|
+
* own `_deprecated` note).
|
|
185
189
|
*
|
|
186
190
|
* @param {string} homeDir Explicit home directory (fixture dir in tests).
|
|
187
191
|
* @param {object} [opts]
|
|
@@ -240,9 +244,17 @@ function clientRegistry(homeDir, opts = {}) {
|
|
|
240
244
|
format: 'json-mcpServers',
|
|
241
245
|
mcp: true,
|
|
242
246
|
hooks: false,
|
|
243
|
-
// UC-1 capture hook (session-end → capture-core shim)
|
|
247
|
+
// UC-1 capture hook (session-end → capture-core shim).
|
|
248
|
+
// CURSOR-STOP-HOOK (0.9.19): was 'sessionEnd' — that event fires only
|
|
249
|
+
// on IDE quit, and by then Cursor's shell-exec service is already
|
|
250
|
+
// torn down ("MainThreadShellExec not initialized"), so the hook
|
|
251
|
+
// never ran; it also named the WRONG (previous) conversation. Cursor's
|
|
252
|
+
// 'stop' hook fires at agent-turn completion with shell-exec alive and
|
|
253
|
+
// carries transcript_path for the CURRENT conversation (verified real
|
|
254
|
+
// payload: {"hook_event_name":"stop","transcript_path":"...",
|
|
255
|
+
// "model_id":"grok-4.6",...}).
|
|
244
256
|
captureHook: true,
|
|
245
|
-
captureEvent: '
|
|
257
|
+
captureEvent: 'stop',
|
|
246
258
|
captureConfigPath: path.join(homeDir, '.cursor', 'hooks.json'),
|
|
247
259
|
},
|
|
248
260
|
{
|
|
@@ -272,17 +284,38 @@ function clientRegistry(homeDir, opts = {}) {
|
|
|
272
284
|
hooks: false,
|
|
273
285
|
},
|
|
274
286
|
// ── UC-0 additions (config paths web-verified June 2026, BUILD-SPEC-UNIVERSAL-CLIENTS §5) ──
|
|
287
|
+
// DEVIN-RENAME (0.9.19): Windsurf → Devin Desktop (vendor rename; Devin
|
|
288
|
+
// 1.126 migrates mcp_config.json to ~/.config/devin/ but still writes the
|
|
289
|
+
// legacy ~/.codeium/windsurf/ directory too — detect EITHER). `id` stays
|
|
290
|
+
// 'windsurf' for ledger continuity (unverified whether any stored state
|
|
291
|
+
// keys off it; keeping the id is the safe default per BUILD-SPEC-0919).
|
|
292
|
+
// The capture HOOK is DROPPED here on purpose: the legacy
|
|
293
|
+
// `post_cascade_response_with_transcript` hook delivered `tool_info:null`
|
|
294
|
+
// (verified useless) — scripts/sources/devin.js's poll adapter (source
|
|
295
|
+
// id 'devin', session-store capture from Devin's own SQLite acp-messages
|
|
296
|
+
// store) replaces it. No captureHook/captureEvent/captureConfigPath
|
|
297
|
+
// fields, matching the shape of every other pure-MCP, no-hook client
|
|
298
|
+
// (e.g. claude-desktop).
|
|
275
299
|
{
|
|
276
300
|
id: 'windsurf',
|
|
277
|
-
name: '
|
|
278
|
-
detectDir: path.join(homeDir, '.
|
|
279
|
-
|
|
301
|
+
name: 'Devin Desktop',
|
|
302
|
+
detectDir: path.join(homeDir, '.config', 'devin'),
|
|
303
|
+
detectDirs: [
|
|
304
|
+
path.join(homeDir, '.config', 'devin'),
|
|
305
|
+
path.join(homeDir, '.codeium', 'windsurf'),
|
|
306
|
+
],
|
|
307
|
+
configPath: path.join(homeDir, '.config', 'devin', 'mcp_config.json'),
|
|
280
308
|
format: 'json-mcpServers',
|
|
281
309
|
mcp: true,
|
|
282
310
|
hooks: false,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
311
|
+
// Runner-side captured-source id differs from the registry id (this
|
|
312
|
+
// entry keeps id:'windsurf' for ledger continuity) — the poll adapter
|
|
313
|
+
// scripts/sources/devin.js is source id 'devin'. Documentary only:
|
|
314
|
+
// no captureHook on this entry means nothing currently reads sourceId
|
|
315
|
+
// for it (the poll sweep pairs by each adapter's own detect(), not by
|
|
316
|
+
// this registry), but it keeps the mapping visible for a future
|
|
317
|
+
// reader, same convention as codex ('codex' → sourceId 'codex-cli').
|
|
318
|
+
sourceId: 'devin',
|
|
286
319
|
},
|
|
287
320
|
{
|
|
288
321
|
id: 'codex',
|
|
@@ -444,6 +477,28 @@ function detectClients(homeDir, opts = {}) {
|
|
|
444
477
|
(c.detectFiles || []).some((p) => fs.existsSync(p)));
|
|
445
478
|
}
|
|
446
479
|
|
|
480
|
+
/**
|
|
481
|
+
* CLI-CAPTURE-MODE-DISAGREE: single source of truth for "does this client
|
|
482
|
+
* get an automatic session-capture hook wired" — Claude Code's SessionEnd
|
|
483
|
+
* hook (`hooks:true`) or a UC-1 capture hook (`captureHook:true`) for the
|
|
484
|
+
* seven other hook clients (cursor, windsurf, codex, gemini-cli, antigravity,
|
|
485
|
+
* factory, copilot-cli). Before this helper existed, `auxilo setup`'s
|
|
486
|
+
* detected-clients screen checked `c.hooks` alone (bin/auxilo-cli.js's
|
|
487
|
+
* `cmdSetup`), so it printed "(MCP)" for all seven captureHook clients even
|
|
488
|
+
* though setup wires their capture hook a few steps later — while
|
|
489
|
+
* `auxilo status` (`cmdStatus`) already filtered on `c.captureHook` and got
|
|
490
|
+
* it right. Both screens must call this one function (see
|
|
491
|
+
* test/cli-capture-mode-parity.test.js) so they can never disagree again.
|
|
492
|
+
* Same predicate `test/ext-gate-closure.test.js` already uses independently
|
|
493
|
+
* (`c.captureHook || c.hooks`) to enumerate hook clients.
|
|
494
|
+
*
|
|
495
|
+
* @param {object} client A clientRegistry() / detectClients() entry.
|
|
496
|
+
* @returns {boolean}
|
|
497
|
+
*/
|
|
498
|
+
function clientHasCaptureHookMode(client) {
|
|
499
|
+
return Boolean(client && (client.hooks || client.captureHook));
|
|
500
|
+
}
|
|
501
|
+
|
|
447
502
|
// ─── MCP registration (spec §LW-12 step 1) ──────────────────────────────────
|
|
448
503
|
|
|
449
504
|
/**
|
|
@@ -1674,13 +1729,17 @@ function patchJsonHookConfig(configPath, mutate) {
|
|
|
1674
1729
|
* shim is handled by registerCaptureHook). Keyed by registry id.
|
|
1675
1730
|
*/
|
|
1676
1731
|
const CAPTURE_WRITERS = Object.freeze({
|
|
1677
|
-
// ~/.cursor/hooks.json — {"version":1,"hooks":{"
|
|
1732
|
+
// ~/.cursor/hooks.json — {"version":1,"hooks":{"stop":[{"command":...}]}}
|
|
1678
1733
|
// version:1 is REQUIRED by Cursor; other events/entries preserved.
|
|
1734
|
+
// CURSOR-STOP-HOOK (0.9.19): was hooks.sessionEnd — that event never
|
|
1735
|
+
// actually fires while shell-exec is alive (see the captureEvent comment
|
|
1736
|
+
// on the cursor registry entry above); the working event is 'stop', so
|
|
1737
|
+
// the key written here must match.
|
|
1679
1738
|
'cursor': (client, homeDir, shimPath) => {
|
|
1680
1739
|
const changed = patchJsonHookConfig(client.captureConfigPath, (config) => {
|
|
1681
1740
|
if (config.version === undefined) config.version = 1;
|
|
1682
1741
|
if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
|
|
1683
|
-
config.hooks.
|
|
1742
|
+
config.hooks.stop = patchFlatHookArray(config.hooks.stop, shimPath);
|
|
1684
1743
|
});
|
|
1685
1744
|
return { changed };
|
|
1686
1745
|
},
|
|
@@ -2318,6 +2377,7 @@ module.exports = {
|
|
|
2318
2377
|
RUNNER_STACK,
|
|
2319
2378
|
clientRegistry,
|
|
2320
2379
|
detectClients,
|
|
2380
|
+
clientHasCaptureHookMode,
|
|
2321
2381
|
registerMcp,
|
|
2322
2382
|
mcpRegistrationPresent,
|
|
2323
2383
|
mcpPinnedVersion,
|
package/mcp-server.js
CHANGED
|
@@ -198,7 +198,7 @@ async function postBulkChunks(headers, decisions) {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
const server = new Server(
|
|
201
|
-
{ name: 'auxilo', version: '0.9.
|
|
201
|
+
{ name: 'auxilo', version: '0.9.19' },
|
|
202
202
|
{
|
|
203
203
|
capabilities: { tools: {} },
|
|
204
204
|
instructions: `You are connected to Auxilo, a knowledge marketplace where AI agents buy and sell operational learnings.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auxilo-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.19",
|
|
4
4
|
"mcpName": "io.github.silent-architects/auxilo",
|
|
5
5
|
"description": "MCP server for Auxilo. Your agent stops solving the same problem twice: auto-extracted learnings, free self-unlocks, and earnings when other agents unlock yours.",
|
|
6
6
|
"main": "mcp-server.js",
|
package/scripts/extract-local.js
CHANGED
|
@@ -400,37 +400,37 @@ function judgeUsage(usage, prompt, completion) {
|
|
|
400
400
|
|
|
401
401
|
/**
|
|
402
402
|
* PART C — resolve the extraction_model identity for a runModel result.
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* never
|
|
403
|
+
*
|
|
404
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1): this function used to fall
|
|
405
|
+
* back to a FRESH, INDEPENDENT providers.resolveProvider() call whenever the
|
|
406
|
+
* result carried no `identity` — a re-detect decoupled from which provider
|
|
407
|
+
* actually produced `runModelResult`, which is what "what would run now"
|
|
408
|
+
* answers, not "what ran". That re-resolve could also silently rewrite
|
|
409
|
+
* `~/.auxilo/providers.json` (resolveProvider's full-scan path calls
|
|
410
|
+
* persistSelected) from what should have been a read-only identity lookup.
|
|
411
|
+
* Both are gone. `identity` is now ALWAYS attached by
|
|
412
|
+
* scripts/providers/index.js's runModel() itself — centrally, because that
|
|
413
|
+
* registry is the only thing that knows which module it actually invoked
|
|
414
|
+
* for this call (see its withIdentity()/deriveIdentity() — claude-code's own
|
|
415
|
+
* cliVersion is used there when present, richer than the null/null/null
|
|
416
|
+
* triple this function used to guess). This function's job shrinks to: use
|
|
417
|
+
* the identity the result actually carries, or admit the honest
|
|
418
|
+
* `provider:'unknown'` when none exists (the `no-usable-provider` /
|
|
419
|
+
* bad-override-name aggregate failures — nothing actually ran to
|
|
420
|
+
* completion, so there is nothing to attribute). Never re-derives, never
|
|
421
|
+
* writes, never blocks extraction on failure.
|
|
422
422
|
*/
|
|
423
|
-
|
|
424
|
-
if (
|
|
423
|
+
function resolveExtractionModelIdentity(runModelResult) {
|
|
424
|
+
if (
|
|
425
|
+
runModelResult
|
|
426
|
+
&& runModelResult.identity
|
|
427
|
+
&& typeof runModelResult.identity === 'object'
|
|
428
|
+
&& typeof runModelResult.identity.provider === 'string'
|
|
429
|
+
&& runModelResult.identity.provider
|
|
430
|
+
) {
|
|
425
431
|
return runModelResult.identity;
|
|
426
432
|
}
|
|
427
|
-
|
|
428
|
-
const resolved = await providers.resolveProvider(opts);
|
|
429
|
-
if (resolved && resolved.ok && resolved.id) {
|
|
430
|
-
return { provider: resolved.id, model: null, version: null, vendor: null };
|
|
431
|
-
}
|
|
432
|
-
} catch { /* identity is best-effort; never block extraction on it */ }
|
|
433
|
-
return null;
|
|
433
|
+
return { provider: 'unknown', model: null, version: null, vendor: null };
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
/**
|
|
@@ -456,7 +456,7 @@ async function defaultInvokeModel(transcript, invokeOpts, opts) {
|
|
|
456
456
|
reason: result.reason,
|
|
457
457
|
reasonCode: result.reasonCode,
|
|
458
458
|
authStatus: result.authStatus,
|
|
459
|
-
extractionModel:
|
|
459
|
+
extractionModel: resolveExtractionModelIdentity(result),
|
|
460
460
|
...(result.authDiscrepancy !== undefined && { authDiscrepancy: result.authDiscrepancy }),
|
|
461
461
|
// EXTRACTION-RUN-LOG (0.9.15): additive passthrough for the one-line-per-run
|
|
462
462
|
// provider summary logged at the end of extractLocally() below. Only
|
|
@@ -648,11 +648,18 @@ const EXTRACTABLE_SOURCE_IDS = Object.freeze([
|
|
|
648
648
|
'continue',
|
|
649
649
|
'copilot',
|
|
650
650
|
'cursor',
|
|
651
|
+
// DEVIN-RENAME (0.9.19): 'windsurf' → 'devin' — the windsurf registry
|
|
652
|
+
// client (id kept for ledger continuity) dropped its capture hook in
|
|
653
|
+
// favor of scripts/sources/devin.js's poll adapter, whose static id is
|
|
654
|
+
// 'devin'. This list is the union of adapter ids (scripts/sources/*.js)
|
|
655
|
+
// and installer hook-client source ids (test/ext-gate-closure.test.js is
|
|
656
|
+
// the authority) — devin.js contributes 'devin' via the adapter side now
|
|
657
|
+
// that windsurf no longer contributes 'windsurf' via the hook side.
|
|
658
|
+
'devin',
|
|
651
659
|
'factory',
|
|
652
660
|
'gemini-cli',
|
|
653
661
|
'openclaw',
|
|
654
662
|
'roo-code',
|
|
655
|
-
'windsurf',
|
|
656
663
|
]);
|
|
657
664
|
|
|
658
665
|
// Gate-A 2026-09-05: the exported set is IMMUTABLE. It stays a real Set (same
|
|
@@ -704,12 +711,53 @@ function formatArgvForLog(argv) {
|
|
|
704
711
|
* (spawned) this run, not whether it succeeded — a spawn that ran and then
|
|
705
712
|
* hit a model error still counts as "ran" (it happened; the failure is in
|
|
706
713
|
* `reason`, not in whether isolation applied). `hooks` is `claude-code`-
|
|
707
|
-
* specific
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
714
|
+
* specific and EVIDENCE-DERIVED (EXTRACT-LOG-HOOKS-EVIDENCE, PUNCH-LIST P2):
|
|
715
|
+
* it reads the argv this run actually captured rather than inferring
|
|
716
|
+
* isolation from the provider name plus the absence of a reason code —
|
|
717
|
+
* 'isolated' ONLY when that argv literally contains --setting-sources (the
|
|
718
|
+
* flag scripts/providers/claude-code.js's fail-closed gate never spawns
|
|
719
|
+
* without), 'unsupported' when the CLI was found not to support the flag at
|
|
720
|
+
* all (existing reason-code path, unchanged), 'unknown' when no argv was
|
|
721
|
+
* captured this run (finder skipped pre-spawn, or the run that actually
|
|
722
|
+
* produced this result fell through to a different/no provider — see the
|
|
723
|
+
* EXTRACT-LOG-HOOKS-EVIDENCE root-cause note below), and 'n/a' for a
|
|
724
|
+
* non-claude-code provider (codex-cli/byo-key isolate by a different
|
|
725
|
+
* mechanism entirely, out of this row's scope). Never 'isolated' without an
|
|
726
|
+
* argv carrying the flag in hand — a safety claim needs evidence, not an
|
|
727
|
+
* absence of contrary evidence.
|
|
728
|
+
*
|
|
729
|
+
* Root cause of the missing-argv runs (EXTRACT-LOG-HOOKS-EVIDENCE
|
|
730
|
+
* investigation): claude-code.js's own runExtractMode() never omits argv on
|
|
731
|
+
* a claude-code result that actually reached this line — every return after
|
|
732
|
+
* `const argv = EXTRACT_MODE_ARGV` carries it, and the two pre-spawn
|
|
733
|
+
* short-circuits that don't (cached --setting-sources-unsupported,
|
|
734
|
+
* cli-unauthenticated) both carry reasonCodes already in
|
|
735
|
+
* PRE_SPAWN_SKIP_REASON_CODES, so they render finder=skipped, not ran. The
|
|
736
|
+
* observed defect lines (finder=ran, flags=n/a) come from a DIFFERENT case:
|
|
737
|
+
* scripts/providers/index.js's runModel() falls through from claude-code to
|
|
738
|
+
* the next configured provider (e.g. codex-cli) whenever claude-code's own
|
|
739
|
+
* attempt fails with a NON_RETRYABLE_FOR_THIS_PROVIDER reasonCode, and
|
|
740
|
+
* returns that OTHER provider's result directly when it stops there. That
|
|
741
|
+
* provider's result carries no `argv` field at all (argv is a
|
|
742
|
+
* claude-code-only concept), so there is no shipped argv being hidden here —
|
|
743
|
+
* 'unknown' remains the correct, honest `hooks` value regardless of which
|
|
744
|
+
* provider is named.
|
|
745
|
+
*
|
|
746
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1) closed the mismatch this
|
|
747
|
+
* docblock used to describe as a known, deferred gap: a fallthrough
|
|
748
|
+
* provider's result used to reach this function carrying no `identity` on
|
|
749
|
+
* failure (codex-cli/byo-key only self-stamped on success), so
|
|
750
|
+
* resolveExtractionModelIdentity()'s old fallback re-resolved the label via
|
|
751
|
+
* a FRESH, INDEPENDENT providers.resolveProvider() call — decoupled from
|
|
752
|
+
* which provider's runModel() result was actually being logged, and prone to
|
|
753
|
+
* landing back on 'claude-code' (its detect() only checks the
|
|
754
|
+
* billing-helper gate + auth status, not whether the earlier attempt
|
|
755
|
+
* actually spawned). That fallback is gone. `identity` is now attached
|
|
756
|
+
* centrally by providers/index.js's runModel() to EVERY result it returns —
|
|
757
|
+
* success or failure, fallthrough or not — because that registry alone
|
|
758
|
+
* knows which module it actually invoked for a given attempt. The line this
|
|
759
|
+
* function renders now names the provider that actually ran (or 'unknown'
|
|
760
|
+
* only when nothing did), not a guess.
|
|
713
761
|
*/
|
|
714
762
|
function logProviderRunSummary(opts, runId, modelResult, judged) {
|
|
715
763
|
try {
|
|
@@ -725,8 +773,11 @@ function logProviderRunSummary(opts, runId, modelResult, judged) {
|
|
|
725
773
|
const cliVersion = modelResult.cliVersion || (judged && judged.judgeCliVersion) || null;
|
|
726
774
|
const finderUnsupported = modelResult.reasonCode === 'cli-settings-isolation-unsupported';
|
|
727
775
|
const judgeUnsupported = Boolean(judged && judged.judgeReasonCode === 'cli-settings-isolation-unsupported');
|
|
776
|
+
const hasSettingSourcesArgv = Array.isArray(argv) && argv.includes('--setting-sources');
|
|
728
777
|
const hooks = provider === 'claude-code'
|
|
729
|
-
? ((finderUnsupported || judgeUnsupported)
|
|
778
|
+
? ((finderUnsupported || judgeUnsupported)
|
|
779
|
+
? 'unsupported'
|
|
780
|
+
: (hasSettingSourcesArgv ? 'isolated' : 'unknown'))
|
|
730
781
|
: 'n/a';
|
|
731
782
|
log(
|
|
732
783
|
`[providers] run=${runId || 'unknown'} provider=${provider} cli=${cliVersion || '-'} ` +
|
|
@@ -875,4 +926,7 @@ module.exports = {
|
|
|
875
926
|
resolveClaudeBin: claudeCodeProvider.resolveClaudeBin,
|
|
876
927
|
// EXTRACTION-RUN-LOG (0.9.15) — exported for direct unit coverage.
|
|
877
928
|
formatArgvForLog, logProviderRunSummary, PRE_SPAWN_SKIP_REASON_CODES,
|
|
929
|
+
// EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1) — exported for direct unit
|
|
930
|
+
// coverage of the "never guess, fail closed to unknown" contract.
|
|
931
|
+
resolveExtractionModelIdentity,
|
|
878
932
|
};
|
|
@@ -245,6 +245,92 @@ const NON_RETRYABLE_FOR_THIS_PROVIDER = new Set([
|
|
|
245
245
|
'cli-settings-isolation-unsupported',
|
|
246
246
|
]);
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* EXTRACTION-MODEL-PROVENANCE (PUNCH-LIST P1, follow-up to the
|
|
250
|
+
* EXTRACT-LOG-HOOKS-EVIDENCE row): `identity` on a runModel() result is
|
|
251
|
+
* provenance — a record of which provider actually produced this text — not
|
|
252
|
+
* a guess. It must never be re-derived after the fact from a fresh detect(),
|
|
253
|
+
* because a fresh detect() answers "what would run now", not "what ran".
|
|
254
|
+
* This registry is the only thing that KNOWS which module's runModel() it
|
|
255
|
+
* just invoked for a given attempt, so identity is enforced HERE, centrally,
|
|
256
|
+
* rather than trusted to per-provider convention (the gap this row closes:
|
|
257
|
+
* before this fix, a provider `ok:true` return that forgot to attach
|
|
258
|
+
* `identity` would silently fall through to extract-local.js's
|
|
259
|
+
* resolveExtractionModelIdentity() re-detecting via resolveProvider() —
|
|
260
|
+
* decoupled from which module actually ran).
|
|
261
|
+
*/
|
|
262
|
+
function hasUsableIdentity(identity) {
|
|
263
|
+
return Boolean(
|
|
264
|
+
identity
|
|
265
|
+
&& typeof identity === 'object'
|
|
266
|
+
&& typeof identity.provider === 'string'
|
|
267
|
+
&& identity.provider
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Derive an identity for the module actually invoked as `id`, used only when
|
|
273
|
+
* that module's own result didn't already carry one. The derivation differs
|
|
274
|
+
* by outcome, because a SUCCESS identity can reach a published learning and
|
|
275
|
+
* the clean-lane calibration gate, while a FAILURE identity is purely
|
|
276
|
+
* diagnostic (extract-local.js returns before stamping anything onto a
|
|
277
|
+
* candidate on `ok:false` — see its `:824-834`):
|
|
278
|
+
*
|
|
279
|
+
* - claude-code: always gets a real, provider-specific identity — it
|
|
280
|
+
* already has `cliVersion` in hand on every returned result (success or
|
|
281
|
+
* failure), richer than the null/null/null triple this used to guess, and
|
|
282
|
+
* claude-code is the one provider documented to never self-stamp
|
|
283
|
+
* `identity` at all, so this is filling a KNOWN, structural gap, not
|
|
284
|
+
* papering over a violated contract.
|
|
285
|
+
* - Every other provider on a FAILURE: `{provider:id, model:null,
|
|
286
|
+
* version:null, vendor:null}` — naming the module we actually invoked is
|
|
287
|
+
* a plain structural fact (we chose to call it), not a guess, and it is
|
|
288
|
+
* what lets the per-run `[providers]` log name the provider that actually
|
|
289
|
+
* ran even when that provider's own failure return carries no identity
|
|
290
|
+
* (codex-cli and byo-key only self-stamp on their SINGLE success return —
|
|
291
|
+
* this is the exact fall-through failure case the investigation traced:
|
|
292
|
+
* claude-code skipped, codex-cli ran and failed, no identity of its own,
|
|
293
|
+
* the label used to be re-guessed as claude-code by the old fallback).
|
|
294
|
+
* - Every other provider on a SUCCESS: `{provider:'unknown', ...}` —
|
|
295
|
+
* byo-key.js and codex-cli.js both self-stamp `identity` on their only
|
|
296
|
+
* success return BY CONTRACT; a success with none means that contract was
|
|
297
|
+
* violated, so this registry has no provider-reported basis for the
|
|
298
|
+
* claim. Confidently naming the module here would look like provenance
|
|
299
|
+
* without being backed by anything the provider itself reported — since
|
|
300
|
+
* this stamp CAN reach a published learning, the fail-closed, honest
|
|
301
|
+
* answer is 'unknown', never a guess dressed up as a fact.
|
|
302
|
+
*/
|
|
303
|
+
function deriveIdentity(id, result) {
|
|
304
|
+
if (id === 'claude-code') {
|
|
305
|
+
return {
|
|
306
|
+
provider: 'claude-code',
|
|
307
|
+
model: null,
|
|
308
|
+
version: (result && result.cliVersion) || null,
|
|
309
|
+
vendor: 'anthropic',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (!(result && result.ok)) {
|
|
313
|
+
return { provider: id, model: null, version: null, vendor: null };
|
|
314
|
+
}
|
|
315
|
+
return { provider: 'unknown', model: null, version: null, vendor: null };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Attach a derived identity to `result` IFF it doesn't already carry a
|
|
320
|
+
* usable one — never overwrites a provider-reported identity (e.g.
|
|
321
|
+
* byo-key's real model name). Applied to every result this registry
|
|
322
|
+
* returns, success or failure, so the per-run `[providers]` log
|
|
323
|
+
* (scripts/extract-local.js's logProviderRunSummary) names the provider
|
|
324
|
+
* that actually ran even on a failure — that is the fall-through failure
|
|
325
|
+
* case this row's investigation traced (claude-code skipped, codex-cli ran
|
|
326
|
+
* and failed with no identity of its own, the stamp used to be re-guessed
|
|
327
|
+
* as claude-code by the caller).
|
|
328
|
+
*/
|
|
329
|
+
function withIdentity(id, result) {
|
|
330
|
+
if (hasUsableIdentity(result && result.identity)) return result;
|
|
331
|
+
return { ...result, identity: deriveIdentity(id, result) };
|
|
332
|
+
}
|
|
333
|
+
|
|
248
334
|
/**
|
|
249
335
|
* runModel(opts) — resolve a starting provider via resolveProvider(), then
|
|
250
336
|
* walk PROVIDER_ORDER from there, calling each candidate's OWN runModel()
|
|
@@ -262,7 +348,9 @@ const NON_RETRYABLE_FOR_THIS_PROVIDER = new Set([
|
|
|
262
348
|
* as-is. When every provider tried is exhausted, returns reasonCode
|
|
263
349
|
* 'no-usable-provider' with a bounded summary of every provider's reason in
|
|
264
350
|
* `reason` (no secrets — each provider's own reason string is already
|
|
265
|
-
* secret-free by contract)
|
|
351
|
+
* secret-free by contract) and NO identity — nothing actually ran to
|
|
352
|
+
* completion, so the caller (extract-local.js) stamps `provider:'unknown'`
|
|
353
|
+
* rather than have this registry guess one. Never throws.
|
|
266
354
|
*/
|
|
267
355
|
async function runModel(opts = {}) {
|
|
268
356
|
const mode = opts.mode === 'judge' ? 'judge' : 'extract';
|
|
@@ -281,7 +369,8 @@ async function runModel(opts = {}) {
|
|
|
281
369
|
authStatus: 'unknown',
|
|
282
370
|
};
|
|
283
371
|
}
|
|
284
|
-
|
|
372
|
+
const result = await resolved.module.runModel({ ...opts, mode });
|
|
373
|
+
return withIdentity(resolved.id, result);
|
|
285
374
|
}
|
|
286
375
|
|
|
287
376
|
const log = typeof opts.log === 'function' ? opts.log : console.error;
|
|
@@ -293,7 +382,8 @@ async function runModel(opts = {}) {
|
|
|
293
382
|
for (const id of order) {
|
|
294
383
|
const mod = PROVIDERS[id];
|
|
295
384
|
// eslint-disable-next-line no-await-in-loop
|
|
296
|
-
const
|
|
385
|
+
const rawResult = await mod.runModel({ ...opts, mode });
|
|
386
|
+
const result = withIdentity(id, rawResult);
|
|
297
387
|
if (result.ok) return result;
|
|
298
388
|
attempts.push({ id, reasonCode: result.reasonCode, reason: result.reason });
|
|
299
389
|
if (!NON_RETRYABLE_FOR_THIS_PROVIDER.has(result.reasonCode)) {
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/sources/devin.js — Devin Desktop Transcript Source (BUILD-SPEC-0919)
|
|
3
|
+
*
|
|
4
|
+
* Best-effort UC-3 poll adapter, modeled on scripts/sources/codex-cli.js.
|
|
5
|
+
* Replaces the pre-0.9.19 `windsurf` capture HOOK (the legacy
|
|
6
|
+
* `post_cascade_response_with_transcript` event, VERIFIED to deliver
|
|
7
|
+
* `tool_info:null` — useless) — Devin Desktop drops the hook entirely
|
|
8
|
+
* (lib/installer.js clientRegistry `windsurf` entry) and this poll adapter
|
|
9
|
+
* covers it instead.
|
|
10
|
+
*
|
|
11
|
+
* STORE (verified against a live install 2026-09-09):
|
|
12
|
+
* ~/Library/Application Support/Devin/User/acp-messages/<session-uuid>.db
|
|
13
|
+
* SQLite, schema: messages(position INTEGER PRIMARY KEY, kind TEXT NOT NULL,
|
|
14
|
+
* payload TEXT NOT NULL) + meta(key TEXT PRIMARY KEY, value TEXT NOT NULL).
|
|
15
|
+
* `.db-wal` / `.db-shm` siblings exist alongside an actively-open session —
|
|
16
|
+
* we only ever glob `*.db` and open read-only, which is safe to do
|
|
17
|
+
* concurrently with Devin's own WAL-mode writer.
|
|
18
|
+
*
|
|
19
|
+
* CRITICAL FILTER: many DBs in this directory are SUB-AGENT sessions —
|
|
20
|
+
* agent_message/agent_thought/tool_call rows only, never a user_message. An
|
|
21
|
+
* agent-only DB would yield an assistant-only transcript, so discoverSessions
|
|
22
|
+
* opens each candidate and skips any DB with zero `user_message` rows.
|
|
23
|
+
*
|
|
24
|
+
* PAYLOAD SHAPE (verified): each `messages.payload` is JSON
|
|
25
|
+
* {"kind":"user_message"|"agent_message","content":[{"sessionUpdate":
|
|
26
|
+
* "user_message_chunk"|"agent_message_chunk","content":{"type":"text",
|
|
27
|
+
* "text":"..."}}, ...]}
|
|
28
|
+
* A short message is one chunk; a long agent_message can be split into
|
|
29
|
+
* dozens of small streaming deltas that must be concatenated IN ORDER
|
|
30
|
+
* (join, not newline-join) to reconstruct the full text.
|
|
31
|
+
*
|
|
32
|
+
* SQLITE READ STRATEGY: PRIMARY `node:sqlite` (DatabaseSync, readOnly) —
|
|
33
|
+
* experimental, present on Node >=22.5, guarded in try/catch since it is
|
|
34
|
+
* absent on older runtimes. FALLBACK: the system `sqlite3` binary via
|
|
35
|
+
* `spawnSync(...,'-readonly','-json',...)`. When NEITHER is available,
|
|
36
|
+
* discoverSessions returns [] and logs one best-effort line — never throws.
|
|
37
|
+
* No new package.json dependency (no better-sqlite3).
|
|
38
|
+
*
|
|
39
|
+
* MODEL PROVENANCE: the store does not record which model produced the
|
|
40
|
+
* conversation — neither `meta` (schema_version/info/message_count) nor any
|
|
41
|
+
* per-message `payload` carries it. `meta.info` DOES carry a config-options
|
|
42
|
+
* UI schema that happens to include a "model" *setting* (e.g. the currently
|
|
43
|
+
* selected model in Devin's own settings panel at snapshot time) — that is
|
|
44
|
+
* an app-preference snapshot, not a per-message attribution, and using it
|
|
45
|
+
* would be exactly the kind of guess this field exists to forbid. Every
|
|
46
|
+
* emitted session is stamped `model_provider: 'unknown'`, never inferred.
|
|
47
|
+
*
|
|
48
|
+
* @module sources/devin
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
'use strict';
|
|
52
|
+
|
|
53
|
+
const fs = require('fs');
|
|
54
|
+
const path = require('path');
|
|
55
|
+
const os = require('os');
|
|
56
|
+
const { spawnSync } = require('child_process');
|
|
57
|
+
const { TranscriptSource } = require('./source.interface');
|
|
58
|
+
|
|
59
|
+
// Guarded require: node:sqlite is experimental and absent on Node <22.5.
|
|
60
|
+
let NODE_SQLITE = null;
|
|
61
|
+
try {
|
|
62
|
+
// eslint-disable-next-line global-require
|
|
63
|
+
NODE_SQLITE = require('node:sqlite');
|
|
64
|
+
} catch {
|
|
65
|
+
NODE_SQLITE = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Cached presence probe for the system `sqlite3` binary (one spawnSync per
|
|
69
|
+
// process, not per file/session).
|
|
70
|
+
let _cliProbed = false;
|
|
71
|
+
let _cliAvailable = false;
|
|
72
|
+
function sqliteCliAvailable() {
|
|
73
|
+
if (_cliProbed) return _cliAvailable;
|
|
74
|
+
_cliProbed = true;
|
|
75
|
+
try {
|
|
76
|
+
const res = spawnSync('sqlite3', ['-version'], { encoding: 'utf8', timeout: 5000 });
|
|
77
|
+
_cliAvailable = !res.error && res.status === 0;
|
|
78
|
+
} catch {
|
|
79
|
+
_cliAvailable = false;
|
|
80
|
+
}
|
|
81
|
+
return _cliAvailable;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True when either read path could plausibly open a database. */
|
|
85
|
+
function hasSqliteAccess() {
|
|
86
|
+
return Boolean(NODE_SQLITE) || sqliteCliAvailable();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Query `sql` (no params — every caller here uses a fixed literal, no user
|
|
91
|
+
* input) against a readonly-opened sqlite db. Tries node:sqlite first, then
|
|
92
|
+
* the `sqlite3 -json` CLI. Returns an array of row objects, or `null` when
|
|
93
|
+
* the file could not be queried at all (missing, corrupt, locked mid-write
|
|
94
|
+
* in a way that defeats even WAL-mode readers, or neither read method
|
|
95
|
+
* available). Callers treat `null` as "skip this file" — this function
|
|
96
|
+
* itself never throws.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} dbPath
|
|
99
|
+
* @param {string} sql
|
|
100
|
+
* @returns {Array<object>|null}
|
|
101
|
+
*/
|
|
102
|
+
function queryRows(dbPath, sql) {
|
|
103
|
+
if (NODE_SQLITE) {
|
|
104
|
+
let db = null;
|
|
105
|
+
try {
|
|
106
|
+
db = new NODE_SQLITE.DatabaseSync(dbPath, { readOnly: true });
|
|
107
|
+
return db.prepare(sql).all();
|
|
108
|
+
} catch {
|
|
109
|
+
// Fall through to the CLI fallback below.
|
|
110
|
+
} finally {
|
|
111
|
+
if (db) {
|
|
112
|
+
try { db.close(); } catch { /* best-effort */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (sqliteCliAvailable()) {
|
|
117
|
+
try {
|
|
118
|
+
const res = spawnSync('sqlite3', ['-readonly', '-json', dbPath, sql], {
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
timeout: 15000,
|
|
121
|
+
});
|
|
122
|
+
if (res.error || res.status !== 0) return null;
|
|
123
|
+
const trimmed = (res.stdout || '').trim();
|
|
124
|
+
if (!trimmed) return [];
|
|
125
|
+
const parsed = JSON.parse(trimmed);
|
|
126
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** True when the db at `dbPath` contains at least one user_message row. */
|
|
135
|
+
function hasUserMessage(dbPath, queryRowsFn) {
|
|
136
|
+
const rows = queryRowsFn(dbPath, "SELECT 1 AS x FROM messages WHERE kind = 'user_message' LIMIT 1");
|
|
137
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reassemble a message's full text by concatenating (NOT newline-joining)
|
|
142
|
+
* `chunk.content.text` across the payload's `content[]` streaming-chunk
|
|
143
|
+
* array, in order. A chunk missing a string `.content.text` contributes
|
|
144
|
+
* nothing (never throws on shape drift).
|
|
145
|
+
*/
|
|
146
|
+
function textFromChunks(content) {
|
|
147
|
+
if (!Array.isArray(content)) return '';
|
|
148
|
+
let text = '';
|
|
149
|
+
for (const chunk of content) {
|
|
150
|
+
if (chunk && chunk.content && typeof chunk.content.text === 'string') {
|
|
151
|
+
text += chunk.content.text;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return text;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class DevinSource extends TranscriptSource {
|
|
158
|
+
static id = 'devin';
|
|
159
|
+
static displayName = 'Devin Desktop';
|
|
160
|
+
static version = '1.0.0';
|
|
161
|
+
|
|
162
|
+
constructor(config = {}) {
|
|
163
|
+
super(config);
|
|
164
|
+
const homeDir = config.homeDir || os.homedir();
|
|
165
|
+
this.acpDir = config.acpDir ||
|
|
166
|
+
path.join(homeDir, 'Library', 'Application Support', 'Devin', 'User', 'acp-messages');
|
|
167
|
+
// Test seams (spec: "(can stub)") — default to the real implementations
|
|
168
|
+
// above. Injecting these lets tests exercise the discovery filter and
|
|
169
|
+
// the streaming-chunk reassembly against fixture data, and simulate the
|
|
170
|
+
// "neither sqlite method available" best-effort path, all without
|
|
171
|
+
// touching the module-level require/spawnSync probes.
|
|
172
|
+
this._queryRows = config.queryRows || queryRows;
|
|
173
|
+
this._hasSqliteAccess = config.hasSqliteAccess || hasSqliteAccess;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async detect() {
|
|
177
|
+
try {
|
|
178
|
+
return fs.statSync(this.acpDir).isDirectory();
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async discoverSessions({ since } = {}) {
|
|
185
|
+
let entries;
|
|
186
|
+
try {
|
|
187
|
+
entries = fs.readdirSync(this.acpDir);
|
|
188
|
+
} catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
// Glob *.db only — NOT the -wal/-shm siblings of an actively-open session.
|
|
192
|
+
const dbFiles = entries.filter((f) => f.endsWith('.db'));
|
|
193
|
+
if (dbFiles.length === 0) return [];
|
|
194
|
+
|
|
195
|
+
if (!this._hasSqliteAccess()) {
|
|
196
|
+
this._logNoSqliteAccess();
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const parsedSince = since ? Date.parse(since) : 0;
|
|
201
|
+
const sinceMs = Number.isFinite(parsedSince) ? parsedSince : 0;
|
|
202
|
+
const sessions = [];
|
|
203
|
+
|
|
204
|
+
for (const file of dbFiles) {
|
|
205
|
+
const filePath = path.join(this.acpDir, file);
|
|
206
|
+
let stat;
|
|
207
|
+
try {
|
|
208
|
+
stat = fs.statSync(filePath);
|
|
209
|
+
if (!stat.isFile()) continue;
|
|
210
|
+
} catch {
|
|
211
|
+
continue; // a session db can disappear while the sweep walks
|
|
212
|
+
}
|
|
213
|
+
if (stat.mtimeMs <= sinceMs) continue;
|
|
214
|
+
// CRITICAL FILTER: agent-only (sub-agent) DBs carry zero user_message
|
|
215
|
+
// rows and would yield an assistant-only transcript — skip them.
|
|
216
|
+
if (!hasUserMessage(filePath, this._queryRows)) continue;
|
|
217
|
+
sessions.push({
|
|
218
|
+
sessionId: path.basename(file, '.db'),
|
|
219
|
+
path: filePath,
|
|
220
|
+
mtime: stat.mtime.toISOString(),
|
|
221
|
+
bytes: stat.size,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return sessions.sort((a, b) =>
|
|
226
|
+
Date.parse(a.mtime) - Date.parse(b.mtime) || a.path.localeCompare(b.path)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async readSession(sessionRef) {
|
|
231
|
+
try {
|
|
232
|
+
return this._readSession(sessionRef);
|
|
233
|
+
} catch {
|
|
234
|
+
// Adapter contract is never-throw (matches codex-cli.js Gate-A F-A):
|
|
235
|
+
// an unexpected shape refuses the whole session rather than escaping
|
|
236
|
+
// into the runner as a failed read.
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_readSession(sessionRef) {
|
|
242
|
+
const filePath = sessionRef && sessionRef.path;
|
|
243
|
+
if (!filePath) return null;
|
|
244
|
+
|
|
245
|
+
const rows = this._queryRows(
|
|
246
|
+
filePath,
|
|
247
|
+
"SELECT position, kind, payload FROM messages WHERE kind IN ('user_message','agent_message') ORDER BY position"
|
|
248
|
+
);
|
|
249
|
+
if (!Array.isArray(rows)) return null; // unreadable/unqueryable — best-effort skip, not a failure
|
|
250
|
+
|
|
251
|
+
const turns = [];
|
|
252
|
+
for (const row of rows) {
|
|
253
|
+
let payload;
|
|
254
|
+
try {
|
|
255
|
+
payload = JSON.parse(row.payload);
|
|
256
|
+
} catch {
|
|
257
|
+
continue; // one malformed row is ignored, not fatal to the session
|
|
258
|
+
}
|
|
259
|
+
const text = textFromChunks(payload && payload.content);
|
|
260
|
+
if (!text) continue;
|
|
261
|
+
const label = row.kind === 'user_message' ? '[user]' : '[assistant]';
|
|
262
|
+
turns.push(`${label}: ${text}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (turns.length === 0) return null;
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
transcript: turns.join('\n\n'),
|
|
269
|
+
metadata: {
|
|
270
|
+
sessionId: sessionRef.sessionId,
|
|
271
|
+
source: 'devin',
|
|
272
|
+
mtime: sessionRef.mtime,
|
|
273
|
+
bytes: sessionRef.bytes,
|
|
274
|
+
// MODEL PROVENANCE: never inferred — see module header. Stamped
|
|
275
|
+
// explicitly rather than omitted so the field's absence is never
|
|
276
|
+
// mistaken for an oversight.
|
|
277
|
+
model_provider: 'unknown',
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Best-effort, never-throw single log line (spec: "logs one best-effort line"). */
|
|
283
|
+
_logNoSqliteAccess() {
|
|
284
|
+
try {
|
|
285
|
+
process.stderr.write(
|
|
286
|
+
'[devin] no SQLite access available (node:sqlite absent and sqlite3 CLI not found) — Devin capture skipped this sweep\n'
|
|
287
|
+
);
|
|
288
|
+
} catch {
|
|
289
|
+
/* best-effort */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async registerSessionEndHook(cb) {
|
|
294
|
+
return null; // poll-only source — no live hook (see module header)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
DevinSource,
|
|
300
|
+
hasSqliteAccess,
|
|
301
|
+
queryRows,
|
|
302
|
+
textFromChunks,
|
|
303
|
+
};
|