memhouse 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -5
- package/bin/memhouse.js +75 -3
- package/editors/adapter-errors.js +52 -0
- package/editors/antigravity.js +32 -3
- package/editors/cursor.js +67 -7
- package/editors/goose.js +35 -7
- package/editors/index.js +97 -2
- package/editors/opencode.js +71 -13
- package/editors/zed.js +20 -4
- package/mem-house/delivery/AGENT-INSTALL.md +35 -23
- package/mem-house/delivery/kernel-install.md +15 -8
- package/mem-house/shipper/ship.js +63 -4
- package/package.json +5 -2
- package/public/assets/{index-BsgjOSTv.js → index-CCPh7a2Z.js} +2 -2
- package/public/index.html +1 -1
- package/ui/src/pages/SqlViewer.jsx +35 -9
package/README.md
CHANGED
|
@@ -31,13 +31,26 @@ 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
|
-
|
|
34
|
+
npm install -g memhouse --allow-scripts=better-sqlite3
|
|
35
|
+
memhouse onboard # wizard: discover → configure → ship → start
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
`
|
|
40
|
-
|
|
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
|
+
To try it without installing, npx takes the same flag — it has to come before the
|
|
46
|
+
package name:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx --allow-scripts=better-sqlite3 -y memhouse discover
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Working from a checkout instead: `npm install` (the repo's `allowScripts` field
|
|
53
|
+
covers the binding), then `node bin/memhouse.js …`.
|
|
41
54
|
|
|
42
55
|
```text
|
|
43
56
|
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
|
-
|
|
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 };
|
package/editors/antigravity.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 }); }
|
|
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 {
|
|
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 }); }
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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 {
|
|
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 };
|