auxilo-mcp 0.9.18 → 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 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
- const extras = [c.mcp ? 'MCP' : 'poll-based source', c.hooks ? 'background extraction' : null]
219
- .filter(Boolean).join(', ');
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
 
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; must match model_config.json source_allowlist).
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: 'sessionEnd',
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: 'Windsurf',
278
- detectDir: path.join(homeDir, '.codeium', 'windsurf'),
279
- configPath: path.join(homeDir, '.codeium', 'windsurf', 'mcp_config.json'),
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
- captureHook: true,
284
- captureEvent: 'post_cascade_response_with_transcript',
285
- captureConfigPath: path.join(homeDir, '.codeium', 'windsurf', 'hooks.json'),
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":{"sessionEnd":[{"command":...}]}}
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.sessionEnd = patchFlatHookArray(config.hooks.sessionEnd, shimPath);
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.18' },
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.18",
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",
@@ -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
@@ -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
+ };