quilltap 4.10.0-dev.18 → 4.10.0-dev.184

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.
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `quilltap memories maintain` — run the memory maintenance job by hand:
5
+ * fold near-duplicates → consolidate → demote → retire → mirror, any subset.
6
+ *
7
+ * The CLI's database access is read-only and cannot call a model, so this
8
+ * resolves the character locally and asks the running server
9
+ * (POST /api/v1/memories?action=maintain). A dry run is rehearsed in the
10
+ * server process and prints what each phase would do; a real run is queued
11
+ * as one MEMORY_MAINTENANCE background job per character.
12
+ */
13
+
14
+ const { postMemoriesAction } = require('./memories-server');
15
+
16
+ // The phase names, the cap's bounds and maxClustersPerRun's bounds are the
17
+ // server's (lib/memory/maintenance/types.ts, lib/schemas/settings.types.ts).
18
+ // The CLI keeps no copy of them: the request goes as typed, and the server's
19
+ // 400 — whose details postMemoriesAction prints — says what it refused.
20
+
21
+ const RESET = '\x1b[0m';
22
+ const BOLD = '\x1b[1m';
23
+ const DIM = '\x1b[2m';
24
+ const GREEN = '\x1b[32m';
25
+ const YELLOW = '\x1b[33m';
26
+ const CYAN = '\x1b[36m';
27
+
28
+ function clip(text, n) {
29
+ const flat = String(text || '').replace(/\s+/g, ' ').trim();
30
+ return flat.length > n ? flat.slice(0, n - 1) + '…' : flat;
31
+ }
32
+
33
+ function short(id) {
34
+ return String(id || '').slice(0, 8);
35
+ }
36
+
37
+ function bucketLabel(bucket) {
38
+ if (!bucket) return 'about no one';
39
+ if (bucket.kind === 'self') return 'about self';
40
+ if (bucket.kind === 'other') return `about ${bucket.subjectName}`;
41
+ return 'about no one';
42
+ }
43
+
44
+ /**
45
+ * Parse `--phases a,b,…` into a list (trimmed, duplicates dropped). Empty or
46
+ * absent means "every phase" and returns null. The server checks the names
47
+ * and always runs them in its own order.
48
+ */
49
+ function parsePhases(value) {
50
+ if (value == null || value === '') return null;
51
+ const asked = [...new Set(String(value).split(',').map((p) => p.trim()).filter(Boolean))];
52
+ return asked.length === 0 ? null : asked;
53
+ }
54
+
55
+ /**
56
+ * Build the `?action=maintain` request body from parsed CLI flags.
57
+ * `characterId` null means every character the user owns.
58
+ */
59
+ function buildMaintainBody(characterId, flags) {
60
+ const body = {};
61
+ if (characterId) body.characterId = characterId;
62
+ body.dryRun = flags.dryRun === true;
63
+ const phases = parsePhases(flags.phases);
64
+ if (phases) body.phases = phases;
65
+
66
+ const settings = {};
67
+ if (flags.max > 0) settings.maxClustersPerRun = flags.max;
68
+ if (flags.threshold >= 0) settings.consolidation = { clusterThreshold: flags.threshold };
69
+ if (flags.cap != null) settings.cap = flags.cap;
70
+ if (Object.keys(settings).length > 0) body.settings = settings;
71
+ return body;
72
+ }
73
+
74
+ // ---------- rendering ----------
75
+
76
+ /** Pretty-print a ConsolidationReport as cluster → members → digests. */
77
+ function renderConsolidation(report, write) {
78
+ const stats = report.stats || {};
79
+ if (report.skippedReason) {
80
+ write(` ${YELLOW}Skipped: ${report.skippedReason}${RESET}\n`);
81
+ return;
82
+ }
83
+ const clusters = report.clusters || [];
84
+ clusters.forEach((cluster, idx) => {
85
+ write(`\n ${BOLD}Cluster ${idx + 1}${RESET} ${DIM}(${bucketLabel(cluster.bucket)}, ${cluster.clusterKind}, ${cluster.status})${RESET}\n`);
86
+ if (cluster.error) write(` ${YELLOW}${cluster.error}${RESET}\n`);
87
+ const standalone = new Set(cluster.keepStandalone || []);
88
+ (cluster.memberIds || []).forEach((id, i) => {
89
+ const tag = standalone.has(id) ? ` ${DIM}[kept standalone]${RESET}` : '';
90
+ const content = (cluster.memberContents || [])[i];
91
+ write(` ${DIM}-${RESET} ${DIM}${short(id)}${RESET} ${clip(content, 160)}${tag}\n`);
92
+ });
93
+ for (const d of cluster.digests || []) {
94
+ write(` ${GREEN}=>${RESET} ${CYAN}${d.action === 'update' ? 'update digest' : 'new digest'}${RESET} ` +
95
+ `${DIM}(imp ${Number(d.importance).toFixed(2)}, from ${(d.memberIds || []).length})${RESET}\n`);
96
+ write(` ${clip(d.content, 400)}\n`);
97
+ }
98
+ for (const c of cluster.contradictions || []) {
99
+ write(` ${YELLOW}contradiction${RESET}: ${clip(c.note, 200)} ${DIM}(${short(c.olderId)} -> ${short(c.newerId)})${RESET}\n`);
100
+ }
101
+ });
102
+ if (clusters.length === 0) write(' (no clusters worth folding)\n');
103
+ write(` ${DIM}candidates ${stats.candidates ?? 0}, clusters found ${stats.clustersFound ?? 0}, ` +
104
+ `attempted ${stats.clustersAttempted ?? 0}, deferred ${stats.clustersDeferred ?? 0}, ` +
105
+ `${((stats.durationMs ?? 0) / 1000).toFixed(1)}s${stats.budgetExhausted ? ', time budget exhausted' : ''}${RESET}\n`);
106
+ }
107
+
108
+ function renderFold(fold, write) {
109
+ write(` eligible ${fold.eligible ?? 0}, groups ${(fold.groups || []).length}, folded ${fold.folded ?? 0}` +
110
+ `${fold.deferred ? `, ${fold.deferred} rows deferred` : ''}` +
111
+ `${fold.groupsDeferred ? `, ${fold.groupsDeferred} groups deferred` : ''}` +
112
+ `${fold.groupsFailed ? `, ${YELLOW}${fold.groupsFailed} groups failed${RESET}` : ''}\n`);
113
+ for (const g of fold.groups || []) {
114
+ const absorbed = (g.absorbedIds || []).map(short).join(', ');
115
+ write(` ${short(g.survivorId)} ${GREEN}<=${RESET} ${absorbed} ${DIM}(min cosine ${Number(g.minCosine).toFixed(3)})${RESET}\n`);
116
+ }
117
+ }
118
+
119
+ function renderDemote(d, write) {
120
+ write(` cap ${d.cap}, hot ${d.hotTotal} (demotable ${d.demotable}, exempt ${d.protected}), ` +
121
+ `excess ${d.excess}, demoted ${d.demoted}` +
122
+ `${d.highestScoreDemoted != null ? ` ${DIM}(highest score demoted ${Number(d.highestScoreDemoted).toFixed(3)})${RESET}` : ''}\n`);
123
+ }
124
+
125
+ function renderRetire(r, write) {
126
+ write(` retention ${r.retentionDays} days, retired ${r.retired}\n`);
127
+ }
128
+
129
+ function renderMirror(m, write) {
130
+ const subjects = m.subjects || [];
131
+ write(` ${subjects.length} subject${subjects.length === 1 ? '' : 's'}${subjects.length ? `: ${subjects.join(', ')}` : ''}` +
132
+ `, files written ${m.filesWritten ?? 0}${m.deferredToCommit ? ' (deferred to the parent commit)' : ''}\n`);
133
+ }
134
+
135
+ /** Pretty-print one character's MaintenanceReport: phase lines, then per-phase detail. */
136
+ function renderMaintenanceReport(report, write = (s) => process.stdout.write(s)) {
137
+ const who = report.characterName || short(report.characterId);
138
+ write(`${BOLD}Maintenance ${report.dryRun ? 'dry run' : 'report'}${RESET}${who ? ` for ${who}` : ''}\n`);
139
+ if (report.skippedReason) {
140
+ write(`${YELLOW}Skipped: ${report.skippedReason}${RESET}\n`);
141
+ return;
142
+ }
143
+ for (const p of report.phases || []) {
144
+ const status = p.status === 'ran' ? `${GREEN}ran${RESET} ` : `${DIM}skipped${RESET}`;
145
+ const why = p.skippedBecause ? ` ${DIM}(${p.skippedBecause})${RESET}` : '';
146
+ write(` ${p.phase.padEnd(22)} ${status}${why} ${DIM}${p.durationMs ?? 0}ms${RESET}\n`);
147
+ }
148
+ if (report.fold) { write(`\n${BOLD}Fold near-duplicates${RESET}\n`); renderFold(report.fold, write); }
149
+ if (report.consolidation) { write(`\n${BOLD}Consolidate${RESET}\n`); renderConsolidation(report.consolidation, write); }
150
+ if (report.demote) { write(`\n${BOLD}Demote${RESET}\n`); renderDemote(report.demote, write); }
151
+ if (report.retire) { write(`\n${BOLD}Retire${RESET}\n`); renderRetire(report.retire, write); }
152
+ if (report.mirror) { write(`\n${BOLD}Mirror${RESET}\n`); renderMirror(report.mirror, write); }
153
+ if (report.durationMs != null) write(`\n${DIM}${(report.durationMs / 1000).toFixed(1)}s${RESET}\n`);
154
+ }
155
+
156
+ /** One table row per character for a user-wide dry run. */
157
+ function renderReportsTable(reports, write = (s) => process.stdout.write(s)) {
158
+ const dash = '-';
159
+ const rows = (reports || []).map((r) => {
160
+ if (r.skippedReason) {
161
+ return [r.characterName || short(r.characterId), `skipped: ${r.skippedReason}`, '', '', '', ''];
162
+ }
163
+ const ran = (r.phases || []).filter((p) => p.status === 'ran').map((p) => p.phase).join(',') || 'none';
164
+ return [
165
+ r.characterName || short(r.characterId),
166
+ ran,
167
+ r.fold ? `${(r.fold.groups || []).length}/${r.fold.folded ?? 0}` : dash,
168
+ r.demote ? `${r.demote.demotable}/${r.demote.cap} -${r.demote.demoted}` : dash,
169
+ r.retire ? String(r.retire.retired) : dash,
170
+ r.mirror ? String((r.mirror.subjects || []).length) : dash,
171
+ ];
172
+ });
173
+ const header = ['character', 'phases ran', 'fold grp/folded', 'demote demotable/cap -n', 'retire', 'mirror subj'];
174
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => String(row[i]).length)));
175
+ const line = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ').trimEnd() + '\n';
176
+ write(`${BOLD}Maintenance dry run across ${rows.length} character${rows.length === 1 ? '' : 's'}${RESET}\n`);
177
+ write(line(header));
178
+ write(line(widths.map((w) => '-'.repeat(w))));
179
+ for (const row of rows) write(line(row));
180
+ if (rows.length === 0) write('(no characters)\n');
181
+ }
182
+
183
+ // ---------- command ----------
184
+
185
+ /**
186
+ * @param {object} flags parsed memories flags
187
+ * @param {(flags: object) => Promise<string>} resolveCharacterId resolves --character to a UUID
188
+ */
189
+ async function cmdMaintain(flags, resolveCharacterId) {
190
+ if (!flags.character) {
191
+ throw new Error("memories maintain requires --character <name|id|all> ('all' for every character).");
192
+ }
193
+ let characterId = null;
194
+ if (flags.character !== 'all') {
195
+ characterId = await resolveCharacterId(flags);
196
+ if (!/^[0-9a-fA-F-]{36}$/.test(characterId)) {
197
+ throw new Error(`could not resolve --character to a UUID (got "${characterId}").`);
198
+ }
199
+ }
200
+ const body = buildMaintainBody(characterId, flags);
201
+
202
+ const payload = await postMemoriesAction(flags, 'maintain', body, {
203
+ why: 'Maintenance runs inside the server (the model, the embeddings and the only database writer live there).',
204
+ announce: flags.json ? undefined : (url) => process.stderr.write(
205
+ `${BOLD}${body.dryRun ? 'Rehearsing' : 'Queuing'} maintenance${RESET}` +
206
+ `${body.phases ? ` [${body.phases.join(',')}]` : ''} via ${DIM}${url}${RESET}\n`
207
+ ),
208
+ });
209
+
210
+ if (flags.json) {
211
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
212
+ return;
213
+ }
214
+
215
+ if (body.dryRun) {
216
+ if (payload && payload.report) renderMaintenanceReport(payload.report);
217
+ else renderReportsTable(payload && payload.reports);
218
+ return;
219
+ }
220
+
221
+ const jobIds = (payload && payload.jobIds) || (payload && payload.jobId ? [payload.jobId] : []);
222
+ if (jobIds.length === 0) {
223
+ console.log('No maintenance jobs were queued (no characters to maintain).');
224
+ return;
225
+ }
226
+ console.log(`Maintenance queued as ${jobIds.length} job${jobIds.length === 1 ? '' : 's'}:`);
227
+ for (const id of jobIds) console.log(` ${id}`);
228
+ console.log("They run in the background; watch logs/combined.log or 'quilltap memories status'.");
229
+ }
230
+
231
+ module.exports = {
232
+ cmdMaintain,
233
+ parsePhases,
234
+ buildMaintainBody,
235
+ renderMaintenanceReport,
236
+ renderReportsTable,
237
+ renderConsolidation,
238
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one way a `quilltap memories` verb asks the running server to do what
5
+ * the CLI cannot: anything that calls a model, computes an embedding or
6
+ * writes. The CLI's own database access is read-only; `maintain` and
7
+ * `validate --repair` resolve what they can locally and POST the rest to
8
+ * `/api/v1/memories?action=<action>`.
9
+ *
10
+ * The port comes from `resolveServerPort` (lock-helpers): an explicit
11
+ * `--port`, else the port recorded in the `--instance` / `--data-dir`
12
+ * instance's lock, else 3000.
13
+ */
14
+
15
+ const { resolveServerPort } = require('./lock-helpers');
16
+
17
+ const DIM = '\x1b[2m';
18
+ const RESET = '\x1b[0m';
19
+
20
+ /** Resolve the server port for a memories verb, printing the lock note (if any) to stderr. */
21
+ function resolveMemoriesServerPort(flags, { quiet = false } = {}) {
22
+ const target = resolveServerPort({
23
+ port: flags.port,
24
+ instance: flags.instance || undefined,
25
+ dataDir: flags.dataDir || undefined,
26
+ });
27
+ if (target.note && !quiet) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
28
+ return target;
29
+ }
30
+
31
+ /**
32
+ * POST `body` to `/api/v1/memories?action=<action>` and return the parsed
33
+ * payload. Throws an Error with a readable message on a connection failure
34
+ * or a non-2xx response.
35
+ *
36
+ * @param {object} flags parsed memories flags (port / instance / dataDir)
37
+ * @param {string} action the `?action=` name
38
+ * @param {object} body JSON request body
39
+ * @param {object} [opts]
40
+ * @param {string} [opts.why] one line on why the server is needed
41
+ * @param {(msg: string) => void} [opts.announce] called with the URL before the request
42
+ * @param {typeof fetch} [opts.fetchImpl] for tests
43
+ */
44
+ async function postMemoriesAction(flags, action, body, opts = {}) {
45
+ const { why, announce, fetchImpl = fetch } = opts;
46
+ const target = resolveMemoriesServerPort(flags);
47
+ const url = `http://localhost:${target.port}/api/v1/memories?action=${encodeURIComponent(action)}`;
48
+ if (announce) announce(url);
49
+
50
+ let res;
51
+ try {
52
+ res = await fetchImpl(url, {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify(body),
56
+ });
57
+ } catch (err) {
58
+ throw new Error(
59
+ `Could not reach the Quilltap server at http://localhost:${target.port}: ${err.message}\n` +
60
+ (why ? `${why}\n` : '') +
61
+ target.hint
62
+ );
63
+ }
64
+
65
+ let payload = null;
66
+ try {
67
+ const text = await res.text();
68
+ payload = text ? JSON.parse(text) : null;
69
+ } catch { /* leave payload null */ }
70
+
71
+ if (!res.ok) {
72
+ let msg = (payload && payload.error) || `HTTP ${res.status}`;
73
+ if (payload && Array.isArray(payload.details) && payload.details.length > 0) {
74
+ msg += ': ' + payload.details
75
+ .map((d) => `${Array.isArray(d.path) && d.path.length ? d.path.join('.') + ' ' : ''}${d.message || ''}`.trim())
76
+ .join('; ');
77
+ }
78
+ if (res.status === 400 && /unknown action|invalid action/i.test(msg)) {
79
+ msg += ` (is the server older than this CLI? '${action}' needs a matching Quilltap server)`;
80
+ }
81
+ throw new Error(msg);
82
+ }
83
+ return payload;
84
+ }
85
+
86
+ module.exports = { postMemoriesAction, resolveMemoriesServerPort };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { resolveServerPort } = require('./lock-helpers');
4
+
3
5
  /**
4
6
  * `quilltap memory-diff <chatId>` — dump existing memories for a chat and
5
7
  * stream a dry-run re-extraction from the running server, writing both to
@@ -45,7 +47,8 @@ Options:
45
47
  -d, --data-dir <path> Override data directory (instance root)
46
48
  -i, --instance <name> Use a registered instance (see 'quilltap instances')
47
49
  --passphrase <pass> Decrypt .dbkey if peppered
48
- --port <number> Server port for API calls (default: 3000)
50
+ --port <number> Server port for API calls. Default: the port recorded in the
51
+ --instance/--data-dir server's lock file, else 3000
49
52
  --out <dir> Output directory (default: cwd)
50
53
  --concurrency <number> Parallel turns to process (default: 4, max: 32).
51
54
  Cloud cheap-LLMs can handle 8–16; keep this low
@@ -65,7 +68,7 @@ function parseFlags(args) {
65
68
  dataDir: '',
66
69
  instance: '',
67
70
  passphrase: '',
68
- port: 3000,
71
+ port: undefined,
69
72
  out: process.cwd(),
70
73
  concurrency: 4,
71
74
  help: false,
@@ -256,6 +259,16 @@ async function memoryDiffCommand(args) {
256
259
 
257
260
  const chatId = positional[0];
258
261
 
262
+ let target;
263
+ try {
264
+ target = resolveServerPort({ port: flags.port, instance: flags.instance, dataDir: flags.dataDir });
265
+ } catch (err) {
266
+ console.error(`Error: ${err.message}`);
267
+ process.exit(1);
268
+ }
269
+ const port = target.port;
270
+ if (target.note) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
271
+
259
272
  if (!fs.existsSync(flags.out)) {
260
273
  try {
261
274
  fs.mkdirSync(flags.out, { recursive: true });
@@ -276,7 +289,7 @@ async function memoryDiffCommand(args) {
276
289
 
277
290
  // -------- 2. Stream dry-run re-extraction from the server ----------------
278
291
  const url =
279
- `http://localhost:${flags.port}/api/v1/chats/${encodeURIComponent(chatId)}` +
292
+ `http://localhost:${port}/api/v1/chats/${encodeURIComponent(chatId)}` +
280
293
  `?action=extract-memories-dry-run&concurrency=${flags.concurrency}`;
281
294
  process.stderr.write(
282
295
  `${BOLD}Streaming re-extraction${RESET} (concurrency ${CYAN}${flags.concurrency}${RESET}) from ${DIM}${url}${RESET}\n`
@@ -286,8 +299,8 @@ async function memoryDiffCommand(args) {
286
299
  try {
287
300
  res = await fetch(url, { method: 'POST' });
288
301
  } catch (err) {
289
- console.error(`${RED}Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}${RESET}`);
290
- console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
302
+ console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
303
+ console.error(target.hint);
291
304
  process.exit(1);
292
305
  }
293
306
 
@@ -328,6 +341,15 @@ async function memoryDiffCommand(args) {
328
341
  `${GREEN}${event.candidatesAdded}${RESET} candidate(s)\n`
329
342
  );
330
343
  break;
344
+ case 'fold':
345
+ // The fold passes run after every turn, over the history a regenerate
346
+ // would rebuild; their candidates arrive with turnIndex null.
347
+ process.stderr.write(
348
+ ` ${CYAN}[${event.pass}]${RESET} ${event.slices} slice(s): ` +
349
+ `${GREEN}${event.candidatesAdded}${RESET} candidate(s)` +
350
+ `${event.skippedReason ? ` ${DIM}(${event.skippedReason})${RESET}` : ''}\n`
351
+ );
352
+ break;
331
353
  case 'turn-error':
332
354
  process.stderr.write(
333
355
  ` ${RED}[${event.index + 1}/${turnCount}] FAILED${RESET}: ${event.error}\n`
@@ -368,4 +390,4 @@ async function memoryDiffCommand(args) {
368
390
  if (fatal) process.exit(2);
369
391
  }
370
392
 
371
- module.exports = { memoryDiffCommand };
393
+ module.exports = { memoryDiffCommand, parseFlags };
@@ -43,6 +43,19 @@ function parseFlags(args) {
43
43
 
44
44
  // ---------- migration list parsing ----------
45
45
 
46
+ /**
47
+ * A migration's id is either a string literal on the object (`id: 'foo-v1'`)
48
+ * or a module constant (`id: MIGRATION_ID` beside `const MIGRATION_ID = 'foo-v1'`).
49
+ * Missing the constant form drops those migrations from "source", which then
50
+ * report as retired on every instance that ran them.
51
+ */
52
+ function extractMigrationId(fileContent) {
53
+ const constMatch = fileContent.match(/^\s*const\s+MIGRATION_ID\s*(?::\s*\w+\s*)?=\s*['"]([^'"]+)['"]/m);
54
+ if (constMatch && /^\s*id:\s*MIGRATION_ID\b/m.test(fileContent)) return constMatch[1];
55
+ const literalMatch = fileContent.match(/^\s*id:\s*['"]([^'"]+)['"]/m);
56
+ return literalMatch ? literalMatch[1] : null;
57
+ }
58
+
46
59
  // Reads the registered migration list from migrations/scripts/index.ts and resolves each
47
60
  // entry's real `id:` value by reading the imported source file. Heuristic camelCase→kebab
48
61
  // conversion is unreliable (some migrations omit `-v1`, others use `-v2`), so we always
@@ -95,11 +108,11 @@ function extractMigrationsFromSource() {
95
108
  const filePath = path.join(scriptsDir, info.filePath + '.ts');
96
109
  if (!fs.existsSync(filePath)) continue;
97
110
  const fileContent = fs.readFileSync(filePath, 'utf-8');
98
- const idMatch = fileContent.match(/^\s*id:\s*['"]([^'"]+)['"]/m);
99
- if (!idMatch) continue;
111
+ const id = extractMigrationId(fileContent);
112
+ if (!id) continue;
100
113
  migrations.push({
101
- id: idMatch[1],
102
- description: info.comment || idMatch[1],
114
+ id,
115
+ description: info.comment || id,
103
116
  });
104
117
  }
105
118
 
@@ -321,4 +334,5 @@ async function migrationsCommand(args) {
321
334
 
322
335
  module.exports = {
323
336
  migrationsCommand,
337
+ extractMigrationId,
324
338
  };
@@ -62,6 +62,84 @@ function betterSqlite3NeedsRebuild() {
62
62
  }
63
63
  }
64
64
 
65
+ // Locate an executable installed by a dependency, given the package directory
66
+ // that needs it. Checks the package's own `.bin` first, then the `.bin` beside
67
+ // it (the hoisted case, which is the usual one). Returns null when absent.
68
+ function findBinFor(pkgDir, tool) {
69
+ const fs = require('fs');
70
+ const names = process.platform === 'win32' ? [`${tool}.cmd`, `${tool}.exe`, tool] : [tool];
71
+ const dirs = [
72
+ path.join(pkgDir, 'node_modules', '.bin'), // nested install
73
+ path.join(pkgDir, '..', '.bin'), // hoisted alongside the package
74
+ ];
75
+ for (const dir of dirs) {
76
+ for (const name of names) {
77
+ const candidate = path.join(dir, name);
78
+ if (fs.existsSync(candidate)) return candidate;
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Rebuild ONE native package in place, addressed by its directory.
85
+ //
86
+ // Deliberately does NOT go through `npm rebuild <name>`, which fails this job
87
+ // two different ways:
88
+ //
89
+ // 1. npm refuses install scripts for any package not listed in the root
90
+ // package.json `allowScripts` map (keyed `name@version`). The root
91
+ // SQLCipher copy is installed under the ALIAS `better-sqlite3`, which is
92
+ // not a key there, so the rebuild dies with EALLOWSCRIPTS.
93
+ // 2. Asking for the real name (`better-sqlite3-multiple-ciphers`) at the root
94
+ // instead resolves a phantom directory: npm reports "rebuilt dependencies
95
+ // successfully" and the binding on disk is untouched. A success message is
96
+ // worse than an error.
97
+ //
98
+ // Running the package's own build chain (`prebuild-install || node-gyp rebuild`,
99
+ // exactly what its `install` script does) in its own directory sidesteps both:
100
+ // no name resolution, no npm script policy. `prebuild-install` downloads the
101
+ // prebuilt binary for the running ABI and needs network; node-gyp compiles and
102
+ // needs a toolchain.
103
+ //
104
+ // Returns { ok, reason }. Never throws. Verifies the result rather than trusting
105
+ // the exit code — see (2): the whole failure mode here is a rebuild that claims
106
+ // to have worked.
107
+ function rebuildNativePackage(pkgDir, bindingPath) {
108
+ const fs = require('fs');
109
+ if (!fs.existsSync(pkgDir)) return { ok: false, reason: `no package at ${pkgDir}` };
110
+
111
+ const before = fs.existsSync(bindingPath) ? readCompiledAbi(bindingPath) : null;
112
+ const attempts = [];
113
+
114
+ const prebuild = findBinFor(pkgDir, 'prebuild-install');
115
+ if (prebuild) attempts.push({ label: 'prebuild-install', cmd: `"${prebuild}"` });
116
+
117
+ const nodeGyp = findBinFor(pkgDir, 'node-gyp');
118
+ if (nodeGyp) attempts.push({ label: 'node-gyp rebuild', cmd: `"${nodeGyp}" rebuild --release` });
119
+
120
+ if (attempts.length === 0) {
121
+ return { ok: false, reason: 'neither prebuild-install nor node-gyp is installed' };
122
+ }
123
+
124
+ const failures = [];
125
+ for (const attempt of attempts) {
126
+ try {
127
+ execSync(attempt.cmd, { cwd: pkgDir, stdio: 'inherit' });
128
+ } catch (err) {
129
+ failures.push(`${attempt.label}: ${err.message.split('\n')[0]}`);
130
+ continue;
131
+ }
132
+ // Trust nothing but the binary. A tool can exit 0 having done nothing.
133
+ const after = fs.existsSync(bindingPath) ? readCompiledAbi(bindingPath) : null;
134
+ if (after === process.versions.modules) return { ok: true, reason: attempt.label };
135
+ failures.push(
136
+ `${attempt.label}: exited 0 but the binding is still ${after ?? 'missing'} ` +
137
+ `(wanted ${process.versions.modules}${before ? `, was ${before}` : ''})`,
138
+ );
139
+ }
140
+ return { ok: false, reason: failures.join('; ') };
141
+ }
142
+
65
143
  // Rebuild the named native modules against the current Node ABI. Prints a
66
144
  // friendly notice rather than throwing; returns true on success, false on
67
145
  // failure. Backfills node-pty's spawn-helper afterward.
@@ -96,7 +174,27 @@ function ensureDatabaseNativeModule() {
96
174
  } catch {
97
175
  return true; // detection hiccup — let the real load be the source of truth
98
176
  }
99
- return rebuildModules(['better-sqlite3-multiple-ciphers']);
177
+
178
+ const bindingPath = betterSqlite3BindingPath();
179
+ if (!bindingPath) {
180
+ console.error(' Warning: could not locate the SQLCipher binding to rebuild.');
181
+ return false;
182
+ }
183
+ // build/Release/better_sqlite3.node → the package directory above it.
184
+ const pkgDir = path.resolve(path.dirname(bindingPath), '..', '..');
185
+
186
+ console.log(` Rebuilding the SQLCipher binding for Node.js ${process.version}...`);
187
+ const result = rebuildNativePackage(pkgDir, bindingPath);
188
+ if (result.ok) {
189
+ console.log(` Done (${result.reason}).`);
190
+ console.log('');
191
+ return true;
192
+ }
193
+ console.error('');
194
+ console.error(` Warning: failed to rebuild the SQLCipher binding — ${result.reason}`);
195
+ console.error(` Try running: (cd ${pkgDir} && ./node_modules/.bin/prebuild-install)`);
196
+ console.error('');
197
+ return false;
100
198
  }
101
199
 
102
200
  // node-pty needs a `spawn-helper` executable beside the pty.node it loads, or
@@ -194,7 +292,10 @@ function ensureNativeModules() {
194
292
  module.exports = {
195
293
  resolveModuleDir,
196
294
  readCompiledAbi,
295
+ betterSqlite3BindingPath,
197
296
  betterSqlite3NeedsRebuild,
297
+ findBinFor,
298
+ rebuildNativePackage,
198
299
  ensureDatabaseNativeModule,
199
300
  ensureNativeModules,
200
301
  reconcileNodePtySpawnHelper,