memhouse 0.3.0 → 0.3.2

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
@@ -31,13 +31,45 @@ memory-house v4. Start with `mem-house/DESIGN.md` for the four bets
31
31
  ## Quickstart — the `memhouse` CLI
32
32
 
33
33
  ```bash
34
- npx memhouse onboard # wizard: discover → configure → ship → start
34
+ npm install -g memhouse --allow-scripts=better-sqlite3
35
+ memhouse onboard # wizard: discover → configure → ship → start
35
36
  ```
36
37
 
37
- While the package is private, run it from the repo (`npm install` once, then
38
- `node bin/memhouse.js …` or `npm link` for a global `memhouse`), or
39
- `npx github:agent-realm/memhouse …` with git auth. The public npm
40
- name `memhouse` is reserved for release.
38
+ **Do not drop `--allow-scripts=better-sqlite3`.** Five adapters — cursor, goose,
39
+ opencode, zed, and antigravity — read sessions out of SQLite files, and
40
+ `better-sqlite3` builds its native binding from an install script. npm 12 blocks
41
+ install scripts by default, so without the flag those five read nothing and you
42
+ silently ship a partial history. `memhouse discover` and `memhouse doctor` both
43
+ say so when the binding is missing.
44
+
45
+ If that install fails with `EACCES`, `sudo` is the right answer for only one of the
46
+ two causes, and the thing that tells them apart is **who owns the prefix root** —
47
+ not the path, and not the failing file. The path proves nothing: Homebrew's prefix
48
+ is `/usr/local` on Intel and `/opt/homebrew` on Apple Silicon, and is yours in both
49
+ cases. The failing file proves nothing either: an earlier `sudo npm` leaves
50
+ root-owned files *inside* a prefix that is still yours.
51
+
52
+ ```bash
53
+ ls -ld "$(npm prefix -g)" # the prefix ROOT — this is the deciding one
54
+ ```
55
+
56
+ - **Prefix root owned by `root`** — a genuinely system-managed Node (distro
57
+ packages under `/usr`). Re-run with `sudo`.
58
+ - **Prefix root owned by you** — the prefix is yours (Homebrew, fnm, nvm, volta),
59
+ so whatever root-owned file npm tripped on is a stray from an earlier `sudo npm`.
60
+ Another `sudo` adds more of them. Repair just that path:
61
+ `sudo chown -R "$(id -u):$(id -g)" <the path npm named>`. The same applies to the
62
+ cache (`~/.npm`).
63
+
64
+ To try it without installing, npx takes the same flag — it has to come before the
65
+ package name:
66
+
67
+ ```bash
68
+ npx --allow-scripts=better-sqlite3 -y memhouse discover
69
+ ```
70
+
71
+ Working from a checkout instead: `npm install` (the repo's `allowScripts` field
72
+ covers the binding), then `node bin/memhouse.js …`.
41
73
 
42
74
  ```text
43
75
  memhouse onboard | install | setup | discover | uninstall | reset
package/bin/memhouse.js CHANGED
@@ -186,14 +186,30 @@ Agents plugins list | install claude [--target DIR] | remove
186
186
  Config: flags > MEMHOUSE_* env > ${ENV_FILE.replace(os.homedir(), '~')} > defaults.
187
187
  `;
188
188
 
189
+ // A skipped adapter and an editor the user does not have look identical — both
190
+ // contribute zero sessions. Say which happened, and how to fix the one that is fixable.
191
+ function printAdapterErrors(errors) {
192
+ if (!errors || errors.length === 0) return;
193
+ const blocked = errors.filter((e) => e.missingBinding);
194
+ const other = errors.filter((e) => !e.missingBinding);
195
+ if (blocked.length) {
196
+ console.log(warn(`${blocked.length} adapter${blocked.length > 1 ? 's' : ''} skipped — better-sqlite3 has no native binding: ${blocked.map((e) => e.source).join(', ')}`));
197
+ console.log(' their sessions are NOT being shipped. npm >= 12 blocks install scripts by default; rebuild with:');
198
+ console.log(' npm install -g memhouse --allow-scripts=better-sqlite3');
199
+ console.log(' (from a checkout: npm install --no-audit --no-fund, which package.json already allows)');
200
+ }
201
+ for (const e of other) console.log(warn(`${e.source.padEnd(16)} skipped: ${e.message}`));
202
+ }
203
+
189
204
  async function cmdDiscover() {
190
205
  const out = { editors: [], clickhouse: [], config: null, memoryHouse: false };
191
206
  process.stdout.write(JSON_OUT ? '' : 'Scanning editors (reading local session stores)…\n');
192
207
  try {
193
- const { getAllChats } = require(path.join(REPO_ROOT, 'editors'));
208
+ const { getAllChats, getAdapterErrors } = require(path.join(REPO_ROOT, 'editors'));
194
209
  const counts = {};
195
210
  for (const c of getAllChats()) counts[c.source] = (counts[c.source] || 0) + 1;
196
211
  out.editors = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([source, sessions]) => ({ source, sessions }));
212
+ out.adapterErrors = getAdapterErrors();
197
213
  } catch (e) { out.editorsError = e.message; }
198
214
 
199
215
  for (const url of [...new Set([resolveConfig().url, 'http://localhost:8123'])]) {
@@ -220,6 +236,7 @@ async function cmdDiscover() {
220
236
  console.log('\nEditors with sessions on this machine:');
221
237
  if (out.editors.length === 0) console.log(warn('none found' + (out.editorsError ? ` (${out.editorsError})` : '')));
222
238
  for (const e of out.editors) console.log(ok(`${e.source.padEnd(16)} ${e.sessions} sessions`));
239
+ printAdapterErrors(out.adapterErrors);
223
240
  console.log('\nClickHouse endpoints:');
224
241
  for (const p of out.clickhouse) {
225
242
  if (!p.reachable) console.log(bad(`${p.url} — unreachable`));
@@ -359,9 +376,64 @@ async function cmdDoctor() {
359
376
  add((u[0]?.u ?? '') !== '' || (await chRows(cfg, 'SELECT count() AS c FROM sessions'))[0].c === 0,
360
377
  `identity stamping (user_id='${u[0]?.u ?? ''}')`, 'writers must use async_insert=0');
361
378
  } catch { add(false, 'identity stamping', 'schema missing?'); }
379
+ let adapterErrors = [];
362
380
  try {
363
- const { getAllChats } = require(path.join(REPO_ROOT, 'editors'));
364
- add(true, `adapters: ${getAllChats().length} sessions visible locally`);
381
+ const { getAllChats, getAdapterErrors, getMessages } = require(path.join(REPO_ROOT, 'editors'));
382
+ const chats = getAllChats();
383
+ const seen = chats.length;
384
+ // Listing sessions is not the same as being able to read them. Goose, for one,
385
+ // can query `sessions` while its `messages` rows use a schema this parser cannot
386
+ // decode — the shipper would then withhold every one of those sessions while
387
+ // doctor reported a clean bill of health. Read the newest session per source so
388
+ // the message readers actually run; that is one parse per editor, not a full scan.
389
+ // One chat per source is not enough coverage: several adapters mix storage
390
+ // paths, and a probe only exercises the one the sampled chat happens to use.
391
+ // Each such adapter declares which store a chat came from — Cursor as _type
392
+ // (agent-store vs workspace), Goose as _storage (sqlite vs jsonl), OpenCode as
393
+ // _storageType (file vs sqlite) — so probing one chat per declared variant
394
+ // covers every path deterministically.
395
+ //
396
+ // The newest/middle/oldest spread is kept on top of that as a cheap hedge for
397
+ // any split an adapter does NOT declare.
398
+ const picks = new Map();
399
+ const bySource = new Map();
400
+ for (const chat of chats) {
401
+ if (!bySource.has(chat.source)) bySource.set(chat.source, []);
402
+ bySource.get(chat.source).push(chat);
403
+ }
404
+ for (const [source, list] of bySource) {
405
+ for (const i of new Set([0, Math.floor(list.length / 2), list.length - 1])) {
406
+ picks.set(`${source}#${i}`, list[i]);
407
+ }
408
+ for (const chat of list) {
409
+ const variant = `${source}|${chat._type || ''}|${chat._storage || ''}|${chat._storageType || ''}`;
410
+ if (!picks.has(variant)) picks.set(variant, chat);
411
+ }
412
+ }
413
+
414
+ const thrown = [];
415
+ for (const chat of picks.values()) {
416
+ // Not every reader reports through the sink — some throw instead, e.g.
417
+ // Cursor's agent-store path, where discovery reads `meta` fine but
418
+ // collectStoreMessages() can throw on a corrupt `blobs` table. Swallowing
419
+ // that here would discard the only signal and leave doctor green while the
420
+ // shipper withholds every one of those sessions.
421
+ try { getMessages(chat); }
422
+ catch (e) { thrown.push({ source: chat.source, message: ((e && e.message) || String(e)).split('\n')[0] }); }
423
+ }
424
+ adapterErrors = getAdapterErrors();
425
+ for (const t of thrown) {
426
+ if (!adapterErrors.some((e) => e.source === t.source)) adapterErrors.push({ ...t, missingBinding: false });
427
+ }
428
+ // Any adapter that could not be read is a failure, whatever the cause — a
429
+ // locked or corrupt store loses just as many sessions as a missing binding.
430
+ const blocked = adapterErrors.filter((e) => e.missingBinding).map((e) => e.source);
431
+ const failed = adapterErrors.map((e) => e.source);
432
+ add(adapterErrors.length === 0,
433
+ `adapters: ${seen} sessions visible locally${failed.length ? ` (${failed.length} skipped: ${failed.join(', ')})` : ''}`,
434
+ blocked.length ? 'npm install -g memhouse --allow-scripts=better-sqlite3'
435
+ : adapterErrors.length ? adapterErrors.map((e) => `${e.source}: ${e.message}`).join('; ')
436
+ : undefined);
365
437
  } catch (e) { add(false, 'adapters', e.message); }
366
438
  add(!!pidOf('shipper'), 'shipper daemon', 'memhouse start');
367
439
  add(!!pidOf('dashboard'), 'dashboard daemon', 'memhouse start');
@@ -0,0 +1,52 @@
1
+ // A place for adapters to report failures they otherwise swallow.
2
+ //
3
+ // Every SQLite-backed adapter wraps its database access in `try { … } catch { return [] }`
4
+ // so that one unreadable store cannot take down a whole scan. That is the right
5
+ // behaviour, but it makes a broken store indistinguishable from an editor the user
6
+ // does not have: both contribute zero sessions and neither says anything. Sessions
7
+ // then go missing from the house silently, forever.
8
+ //
9
+ // Adapters call record() from those catch blocks. index.js drains it per scan and
10
+ // reports through discover, doctor, and the shipper.
11
+ //
12
+ // This lives in its own module rather than in index.js because the adapters are
13
+ // required *by* index.js — importing it back would be circular.
14
+
15
+ let errors = [];
16
+
17
+ /** Report a failure that would otherwise be swallowed. `source` is the adapter name. */
18
+ function record(source, err, detail) {
19
+ const message = ((err && err.message) || String(err)).split('\n')[0];
20
+ errors.push({ source, message, detail: detail || null });
21
+ }
22
+
23
+ /** All failures recorded since the last reset. */
24
+ function recorded() {
25
+ return errors.slice();
26
+ }
27
+
28
+ /** Current position, for scoping a retraction. See dropRange(). */
29
+ function mark() {
30
+ return errors.length;
31
+ }
32
+
33
+ /**
34
+ * Retract the failures recorded between two marks.
35
+ *
36
+ * For readers with a fallback: opencode tries its file store, then opencode.db. A
37
+ * failure on the first path is not a failure of the read if the second path returns
38
+ * the full transcript, and leaving it recorded would make the shipper withhold a
39
+ * session it can read perfectly well. Scoped by range rather than truncating to a
40
+ * mark, so failures recorded by the fallback itself — which are real truncation —
41
+ * survive.
42
+ */
43
+ function dropRange(start, end) {
44
+ if (end <= start) return;
45
+ errors.splice(start, end - start);
46
+ }
47
+
48
+ function reset() {
49
+ errors = [];
50
+ }
51
+
52
+ module.exports = { record, recorded, mark, dropRange, reset };
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const Database = require('better-sqlite3');
6
6
  const { getAppDataPath } = require('./base');
7
+ const adapterErrors = require('./adapter-errors');
7
8
 
8
9
  const HOME = os.homedir();
9
10
  const ANTIGRAVITY_USER_DIR = path.join(getAppDataPath('Antigravity'), 'User');
@@ -241,7 +242,10 @@ function readGlobalStateValue(key) {
241
242
  if (typeof v === 'string') return v;
242
243
  if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.from(v).toString('utf-8');
243
244
  return v == null ? null : String(v);
244
- } catch {
245
+ } catch (e) {
246
+ // Only the offline half of getChats() reads this store; live cascades come over
247
+ // RPC. So a failure here costs some sessions, not all of them.
248
+ adapterErrors.record('antigravity', e, ANTIGRAVITY_GLOBAL_STORAGE_DB);
245
249
  return null;
246
250
  } finally {
247
251
  if (db) db.close();
@@ -613,6 +617,10 @@ function callRpc(method, body) {
613
617
  );
614
618
  return JSON.parse(result);
615
619
  } catch { return null; }
620
+ // Deliberately does NOT record. One failed call is not a failed read: getSteps()
621
+ // falls back from GetCascadeTrajectorySteps to GetCascadeTrajectory, and the tail
622
+ // lookup is optional. Recording per attempt would reject chats whose fallback
623
+ // succeeded. The read paths record once their fallbacks are exhausted.
616
624
  }
617
625
 
618
626
  // ============================================================
@@ -657,8 +665,29 @@ function getSteps(chat) {
657
665
 
658
666
  // Fallback to old method
659
667
  const resp2 = callRpc('GetCascadeTrajectory', { cascadeId: chat.composerId });
660
- if (resp2 && resp2.trajectory && resp2.trajectory.steps) return resp2.trajectory.steps;
661
-
668
+ const fallbackSteps = (resp2 && resp2.trajectory && Array.isArray(resp2.trajectory.steps))
669
+ ? resp2.trajectory.steps
670
+ : null;
671
+ if (fallbackSteps && fallbackSteps.length > 0) return fallbackSteps;
672
+
673
+ // Nothing produced steps. Two distinct ways that happens, and both must be
674
+ // reported, because either one yields an empty transcript that would overwrite
675
+ // the stored one on a re-ship:
676
+ //
677
+ // - Both calls returned null: the calls themselves failed. The language server
678
+ // is stopped or neither endpoint is supported. This matters even when
679
+ // Antigravity is closed, because getChats() still lists offline cascades out
680
+ // of SQLite, so the chat looks live while its messages are unreachable.
681
+ // - A call answered but carried no steps, for a cascade the listing said HAS
682
+ // steps. getChats() preserves summary.stepCount as _stepCount, which is what
683
+ // separates this from a cascade that is legitimately empty.
684
+ const rpcFailed = resp === null && resp2 === null;
685
+ const expected = Number(chat._stepCount) || 0;
686
+ if (rpcFailed) {
687
+ adapterErrors.record('antigravity', new Error('language server unreachable — cannot read messages'), chat.composerId);
688
+ } else if (expected > 0) {
689
+ adapterErrors.record('antigravity', new Error(`no steps returned for a cascade reporting ${expected} steps`), chat.composerId);
690
+ }
662
691
  return [];
663
692
  }
664
693
 
package/editors/cursor.js CHANGED
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const { getAppDataPath } = require('./base');
6
+ const adapterErrors = require('./adapter-errors');
6
7
 
7
8
  const HOME = os.homedir();
8
9
  const CURSOR_CHATS_DIR = path.join(HOME, '.cursor', 'chats');
@@ -131,7 +132,15 @@ function getWorkspaceMap() {
131
132
  const ws = JSON.parse(fs.readFileSync(wsJson, 'utf-8'));
132
133
  const folder = (ws.folder || '').replace('file://', '');
133
134
  map.push({ hash, folder, stateDb });
134
- } catch { /* skip */ }
135
+ } catch (e) {
136
+ // The descriptor is only where the folder NAME comes from — state.vscdb holds
137
+ // the sessions and was already confirmed present. Dropping the whole workspace
138
+ // over an unreadable workspace.json would take every composer in it out of
139
+ // discovery, where nothing downstream can notice them missing. Keep the
140
+ // workspace with no folder, and record why the name is absent.
141
+ adapterErrors.record('cursor', e, wsJson);
142
+ map.push({ hash, folder: '', stateDb });
143
+ }
135
144
  }
136
145
  return map;
137
146
  }
@@ -151,7 +160,14 @@ function getComposerHeaders(stateDbPath) {
151
160
  mode: c.unifiedMode || c.forceMode || 'unknown',
152
161
  isAgentic: c.unifiedMode === 'agent',
153
162
  }));
154
- } catch { return []; }
163
+ } catch (e) {
164
+ // One workspace's state.vscdb being locked, corrupt, or on an incompatible
165
+ // ItemTable removes every composer in that workspace. The global database still
166
+ // opens, so without this Cursor looks healthy while a whole workspace of
167
+ // sessions quietly disappears.
168
+ adapterErrors.record('cursor', e, stateDbPath);
169
+ return [];
170
+ }
155
171
  }
156
172
 
157
173
  function getModelPreference(globalDb) {
@@ -174,7 +190,12 @@ function getComposerBubbles(globalDb, composerId) {
174
190
  try {
175
191
  const obj = JSON.parse(row.value);
176
192
  bubbles.push(obj);
177
- } catch { /* binary blob, skip */ }
193
+ } catch (e) {
194
+ // Historically treated as "binary blob, skip", but a row that will not decode
195
+ // is a message missing from the transcript either way. Unreported, the
196
+ // shortened result overwrites the stored one on a re-ship.
197
+ adapterErrors.record('cursor', e, `bubble ${row.key || '(unknown)'}`);
198
+ }
178
199
  }
179
200
  return bubbles;
180
201
  }
@@ -260,25 +281,46 @@ function getChats() {
260
281
  const meta = readStoreMeta(db);
261
282
  db.close();
262
283
  if (meta) {
284
+ // The agent store carries no per-chat updated time and no message count, so
285
+ // the shipper had nothing that changes as the conversation grows: it
286
+ // compared a fixed createdAt against itself and an absent count against 0,
287
+ // and skipped every intact session forever. store.db's mtime is the real
288
+ // change signal — it moves when blobs are appended. If it cannot be read,
289
+ // fall back to declaring the count unknown so the skip stands down instead
290
+ // of silently freezing the transcript.
291
+ let storeMtime = null;
292
+ try { storeMtime = fs.statSync(dbPath).mtimeMs; } catch { /* fall back below */ }
263
293
  chats.push({
264
294
  source: 'cursor',
265
295
  composerId: chatId,
266
296
  name: meta.name || null,
267
297
  createdAt: meta.createdAt || null,
298
+ lastUpdatedAt: storeMtime,
268
299
  mode: meta.mode || null,
269
300
  folder: null,
301
+ _countUnknown: storeMtime === null,
270
302
  _dbPath: dbPath,
271
303
  _rootBlobId: meta.latestRootBlobId,
272
304
  _lastUsedModel: meta.lastUsedModel || null,
273
305
  _type: 'agent-store',
274
306
  });
307
+ } else {
308
+ // The database opened but its meta row would not decode, so this session is
309
+ // dropped here at discovery — before doctor's probes or the shipper ever see
310
+ // it. Nothing downstream can notice a chat that was never listed, which is
311
+ // why it has to be recorded at the point of omission.
312
+ adapterErrors.record('cursor', new Error('agent-store meta row could not be decoded'), dbPath);
275
313
  }
276
- } catch { /* skip */ }
314
+ } catch (e) { adapterErrors.record('cursor', e, dbPath); }
277
315
  }
278
316
 
279
317
  // Source 2: workspaceStorage composers
280
318
  let globalDb = null;
281
- try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { /* no global db */ }
319
+ try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); }
320
+ catch (e) {
321
+ // Absent is normal — Cursor may simply not be installed. Unreadable is not.
322
+ if (fs.existsSync(GLOBAL_STORAGE_DB)) adapterErrors.record('cursor', e, GLOBAL_STORAGE_DB);
323
+ }
282
324
 
283
325
  const modelPref = globalDb ? getModelPreference(globalDb) : null;
284
326
 
@@ -286,13 +328,24 @@ function getChats() {
286
328
  const headers = getComposerHeaders(stateDb);
287
329
  for (const h of headers) {
288
330
  let bubbleCount = 0;
331
+ let countUnknown = false;
289
332
  if (globalDb) {
290
333
  try {
291
334
  const countRow = globalDb.prepare(
292
335
  "SELECT count(*) as cnt FROM cursorDiskKV WHERE key LIKE ?"
293
336
  ).get(`bubbleId:${h.composerId}:%`);
294
337
  bubbleCount = countRow ? countRow.cnt : 0;
295
- } catch { /* skip */ }
338
+ } catch (e) {
339
+ // A failed count is NOT a count of zero, and the difference decides
340
+ // whether this session ever updates again. bubbleCount feeds the shipper's
341
+ // skip predicate (prev.bc >= chat.bubbleCount); left at 0 that is
342
+ // trivially true, so a grown transcript stays stale on every incremental
343
+ // pass. Recording alone does not save it either — the skip is evaluated
344
+ // before any per-chat error guard, so the chat is dropped before the guard
345
+ // can see it. Flag it instead, and let the predicate refuse to skip.
346
+ adapterErrors.record('cursor', e, `bubble count for ${h.composerId}`);
347
+ countUnknown = true;
348
+ }
296
349
  }
297
350
  chats.push({
298
351
  source: 'cursor',
@@ -303,6 +356,7 @@ function getChats() {
303
356
  mode: h.mode,
304
357
  folder,
305
358
  bubbleCount,
359
+ _countUnknown: countUnknown,
306
360
  _type: 'workspace',
307
361
  _modelPref: modelPref,
308
362
  });
@@ -328,7 +382,13 @@ function getMessages(chat) {
328
382
  }
329
383
 
330
384
  let globalDb;
331
- try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { return []; }
385
+ try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); }
386
+ catch (e) {
387
+ // Returning [] unreported would let a re-ship overwrite this session's stored
388
+ // transcript with nothing.
389
+ adapterErrors.record('cursor', e, GLOBAL_STORAGE_DB);
390
+ return [];
391
+ }
332
392
  const bubbles = getComposerBubbles(globalDb, chat.composerId);
333
393
  globalDb.close();
334
394
  const msgs = bubblesToMessages(bubbles);
package/editors/goose.js CHANGED
@@ -2,6 +2,8 @@ const path = require('path');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
4
 
5
+ const adapterErrors = require('./adapter-errors');
6
+
5
7
  const GOOSE_DIR = path.join(os.homedir(), '.local', 'share', 'goose', 'sessions');
6
8
  const DB_PATH = path.join(GOOSE_DIR, 'sessions.db');
7
9
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'goose', 'config.yaml');
@@ -25,13 +27,19 @@ function getDatabase() {
25
27
  function queryDb(sql) {
26
28
  if (!fs.existsSync(DB_PATH)) return [];
27
29
  const Db = getDatabase();
28
- if (!Db) return []; // Fallback if better-sqlite3 not available
30
+ if (!Db) {
31
+ adapterErrors.record('goose', new Error('better-sqlite3 unavailable'), DB_PATH);
32
+ return [];
33
+ }
29
34
  try {
30
35
  const db = new Db(DB_PATH, { readonly: true });
31
36
  const rows = db.prepare(sql).all();
32
37
  db.close();
33
38
  return rows;
34
- } catch { return []; }
39
+ } catch (e) {
40
+ adapterErrors.record('goose', e, DB_PATH);
41
+ return [];
42
+ }
35
43
  }
36
44
 
37
45
  // ============================================================
@@ -89,7 +97,11 @@ function getChats() {
89
97
  // --- Legacy JSONL files ---
90
98
  if (fs.existsSync(GOOSE_DIR)) {
91
99
  let files;
92
- try { files = fs.readdirSync(GOOSE_DIR).filter(f => f.endsWith('.jsonl')); } catch { files = []; }
100
+ // The directory exists (checked above); if it will not enumerate, every legacy
101
+ // jsonl session disappears at discovery and nothing downstream can miss what it
102
+ // never saw.
103
+ try { files = fs.readdirSync(GOOSE_DIR).filter(f => f.endsWith('.jsonl')); }
104
+ catch (e) { adapterErrors.record('goose', e, GOOSE_DIR); files = []; }
93
105
 
94
106
  for (const file of files) {
95
107
  const sessionId = file.replace('.jsonl', '');
@@ -112,7 +124,12 @@ function getChats() {
112
124
  _fullPath: fullPath,
113
125
  _model: configModel,
114
126
  });
115
- } catch { /* skip */ }
127
+ } catch (e) {
128
+ // Same shape one level down: an unstattable or unparseable session file is
129
+ // omitted from the listing entirely, so it is invisible to doctor and the
130
+ // shipper alike.
131
+ adapterErrors.record('goose', e, fullPath);
132
+ }
116
133
  }
117
134
  }
118
135
 
@@ -181,8 +198,15 @@ function getMessagesFromDb(chat) {
181
198
  const result = [];
182
199
  for (const row of rows) {
183
200
  let parts;
184
- try { parts = JSON.parse(row.content_json); } catch { continue; }
185
- if (!Array.isArray(parts)) continue;
201
+ // A row we cannot decode drops a message from the transcript. Silently, that is
202
+ // a truncation that looks complete, and a re-ship would replace the stored
203
+ // transcript with it. Report so the shipper withholds and retries instead.
204
+ try { parts = JSON.parse(row.content_json); }
205
+ catch (e) { adapterErrors.record('goose', e, `session ${chat.composerId}`); continue; }
206
+ if (!Array.isArray(parts)) {
207
+ adapterErrors.record('goose', new Error('message content_json is not an array'), `session ${chat.composerId}`);
208
+ continue;
209
+ }
186
210
 
187
211
  const role = row.role;
188
212
  const contentParts = [];
@@ -234,7 +258,11 @@ function getMessagesFromJsonl(chat) {
234
258
 
235
259
  for (const line of lines) {
236
260
  let obj;
237
- try { obj = JSON.parse(line); } catch { continue; }
261
+ // The legacy JSONL store needs the same protection as the SQLite one: a corrupt
262
+ // line drops a message, and the truncated transcript would overwrite the stored
263
+ // one when the file's mtime changes or --full is used.
264
+ try { obj = JSON.parse(line); }
265
+ catch (e) { adapterErrors.record('goose', e, filePath); continue; }
238
266
 
239
267
  if (!obj.role) continue;
240
268
 
package/editors/index.js CHANGED
@@ -14,6 +14,7 @@ const commandcode = require('./commandcode');
14
14
  const goose = require('./goose');
15
15
  const kiro = require('./kiro');
16
16
  const codebuff = require('./codebuff');
17
+ const adapterErrorSink = require('./adapter-errors');
17
18
 
18
19
  const editors = [cursor, devin, antigravity, claude, vscode, zed, opencode, codex, gemini, copilot, copilotJetbrains, cursorAgent, commandcode, goose, kiro, codebuff];
19
20
 
@@ -23,16 +24,80 @@ for (const editor of editors) {
23
24
  if (editor.labels) Object.assign(editorLabels, editor.labels);
24
25
  }
25
26
 
27
+ // Per-adapter failures from the last getAllChats() pass. An adapter that throws is
28
+ // skipped so one broken editor cannot take down a scan, but the failure is recorded
29
+ // here rather than discarded: a missing better-sqlite3 native binding and an editor
30
+ // the user simply does not have both produce zero sessions, and only this tells them
31
+ // apart. See getAdapterErrors().
32
+ let adapterErrors = [];
33
+
34
+ // better-sqlite3 ships a prebuilt binary via an install script. npm >= 12 blocks
35
+ // install scripts by default, so a plain `npm i -g memhouse` leaves no binding and
36
+ // every SQLite-backed adapter reads zero sessions.
37
+ const MISSING_BINDING = /Could not locate the bindings file|Cannot find module 'better-sqlite3'/;
38
+
39
+ // Adapters that read sessions out of a SQLite store, so a dead binding costs
40
+ // sessions. Membership is narrower than `grep -l better-sqlite3 editors/*.js`:
41
+ // windsurf/devin also requires better-sqlite3, but only in getDevinApiKey() for
42
+ // usage — its getChats() is pure language-server RPC, so its sessions survive a
43
+ // broken binding and listing it here would raise a false alarm. antigravity is
44
+ // listed because its *offline* chats come from SQLite, though its live cascades,
45
+ // like devin's, come over RPC — a failure there is partial.
46
+ // Held as module references, not strings, so the reported name always matches the
47
+ // adapter's own `name`.
48
+ const SQLITE_BACKED = [antigravity, cursor, goose, opencode, zed].map((m) => m.name);
49
+
50
+ // Construct an in-memory database. Loading the module is not enough — better-sqlite3
51
+ // resolves the native binding lazily, on first open.
52
+ //
53
+ // This only answers "can SQLite work at all". It deliberately cannot tell whether a
54
+ // given editor's real store is readable: permissions, locking, corruption, and
55
+ // SQLCipher all pass this probe and fail later. Those are reported by the adapters
56
+ // themselves through adapter-errors.
57
+ function probeSqlite() {
58
+ try {
59
+ const Database = require('better-sqlite3');
60
+ const db = new Database(':memory:');
61
+ db.close();
62
+ return { available: true };
63
+ } catch (e) {
64
+ const message = ((e && e.message) || String(e)).split('\n')[0];
65
+ return { available: false, message, missingBinding: MISSING_BINDING.test(message) };
66
+ }
67
+ }
68
+
69
+ // Cache only success. A failed probe must be retried: a long-running shipper daemon
70
+ // that warned about a missing binding should notice once the user installs it,
71
+ // rather than repeating the warning for the life of the process.
72
+ let sqliteProbe = null;
73
+ function sqliteStatus() {
74
+ if (sqliteProbe === null) {
75
+ const result = probeSqlite();
76
+ if (result.available) sqliteProbe = result;
77
+ return result;
78
+ }
79
+ return sqliteProbe;
80
+ }
81
+
26
82
  /**
27
83
  * Get all chats from all editor adapters, sorted by most recent first.
28
84
  */
29
85
  function getAllChats() {
30
86
  const chats = [];
87
+ adapterErrors = [];
88
+ adapterErrorSink.reset();
31
89
  for (const editor of editors) {
32
90
  try {
33
91
  const editorChats = editor.getChats();
34
92
  chats.push(...editorChats);
35
- } catch { /* skip broken adapters */ }
93
+ } catch (e) {
94
+ const message = (e && e.message) || String(e);
95
+ adapterErrors.push({
96
+ source: editor.name,
97
+ message: message.split('\n')[0],
98
+ missingBinding: MISSING_BINDING.test(message),
99
+ });
100
+ }
36
101
  }
37
102
 
38
103
  chats.sort((a, b) => {
@@ -44,6 +109,36 @@ function getAllChats() {
44
109
  return chats;
45
110
  }
46
111
 
112
+ /**
113
+ * Adapters that could not report their sessions: [{ source, message, missingBinding }].
114
+ * Empty when every adapter ran clean.
115
+ *
116
+ * Two sources, because there are two ways an adapter goes quiet. One throws out of
117
+ * getChats() and is recorded during the pass. The other is a dead SQLite binding,
118
+ * which throws nowhere at all — the adapters catch it internally — so it is probed
119
+ * for directly and reported against every SQLite-backed adapter at once.
120
+ */
121
+ function getAdapterErrors() {
122
+ const errors = adapterErrors.slice();
123
+ for (const e of adapterErrorSink.recorded()) {
124
+ errors.push({ source: e.source, message: e.message, detail: e.detail, missingBinding: MISSING_BINDING.test(e.message) });
125
+ }
126
+ const sqlite = sqliteStatus();
127
+ if (!sqlite.available) {
128
+ const already = new Set(errors.map((e) => e.source));
129
+ for (const source of SQLITE_BACKED) {
130
+ if (!already.has(source)) {
131
+ errors.push({ source, message: sqlite.message, missingBinding: sqlite.missingBinding });
132
+ }
133
+ }
134
+ }
135
+ // One line per adapter, not one per failed query — a broken store is usually hit
136
+ // many times in a single scan.
137
+ const bySource = new Map();
138
+ for (const e of errors) if (!bySource.has(e.source)) bySource.set(e.source, e);
139
+ return Array.from(bySource.values());
140
+ }
141
+
47
142
  /**
48
143
  * Get messages for a chat object, dispatching to the right editor adapter.
49
144
  */
@@ -176,4 +271,4 @@ function getAllMCPServers(projectFolders = []) {
176
271
  return Array.from(seen.values());
177
272
  }
178
273
 
179
- module.exports = { getAllChats, getMessages, editors, editorLabels, resetCaches, getAllUsage, getAllArtifacts, getAllMCPServers };
274
+ module.exports = { getAllChats, getAdapterErrors, getMessages, editors, editorLabels, resetCaches, getAllUsage, getAllArtifacts, getAllMCPServers };