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.
- package/README.md +183 -15
- package/bin/quilltap.js +61 -26
- package/lib/__tests__/completion-coverage.test.js +2 -0
- package/lib/__tests__/memories-maintain-command.test.js +276 -0
- package/lib/__tests__/migrations-source-ids.test.js +54 -0
- package/lib/__tests__/server-port-resolution.test.js +122 -0
- package/lib/__tests__/sync-report.test.js +158 -0
- package/lib/anchor-probe-command.js +222 -0
- package/lib/completion/bash.template +47 -11
- package/lib/completion/fish.template +40 -4
- package/lib/completion/zsh.template +68 -3
- package/lib/db-commands.js +39 -3
- package/lib/db-helpers.js +12 -0
- package/lib/graph-integrity.js +108 -5
- package/lib/lock-helpers.js +66 -0
- package/lib/memories-commands.js +396 -76
- package/lib/memories-maintain-command.js +238 -0
- package/lib/memories-server.js +86 -0
- package/lib/memory-diff-command.js +28 -6
- package/lib/migrations-commands.js +18 -4
- package/lib/native-modules.js +102 -1
- package/lib/recall-replay-command.js +193 -11
- package/lib/sync-command.js +323 -0
- package/lib/sync-report.js +167 -0
- package/lib/text-codec.js +90 -0
- package/package.json +1 -1
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { resolveServerPort } = require('./lock-helpers');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* `quilltap recall-replay <chatId>` — replay a turn's memory recall against
|
|
5
7
|
* the running server and print the candidate table, old path (episodic
|
|
6
8
|
* signals inert) vs. new path (retrospective flip + time window + entity
|
|
7
9
|
* anchors + multi-probe) side by side.
|
|
8
10
|
*
|
|
11
|
+
* Then the text fallback's table and the stage trace (query, gather, hydrate,
|
|
12
|
+
* score, select, assemble): what the archive removed, what the budget cut, what
|
|
13
|
+
* the inter-character section took.
|
|
14
|
+
*
|
|
9
15
|
* Thin wrapper over POST /api/v1/chats/<chatId>?action=recall-replay.
|
|
10
16
|
* Read-only; nothing is persisted server-side.
|
|
11
17
|
*/
|
|
@@ -28,25 +34,62 @@ Replays the per-turn memory recall for a chat turn against the running
|
|
|
28
34
|
Quilltap server and prints the full candidate table twice — the pre-overhaul
|
|
29
35
|
ranking and the episodic (retrospective/time-window/entity) ranking — so the
|
|
30
36
|
recall constants can be tuned against real "the character forgot" turns.
|
|
37
|
+
After them: what the text fallback would have returned, and the stage trace
|
|
38
|
+
(query → gather → hydrate → score → select → assemble). The anti-repetition
|
|
39
|
+
ring is the one in force at that turn, read from the chat's recall records.
|
|
31
40
|
|
|
32
41
|
Options:
|
|
42
|
+
-d, --data-dir <path> Instance root; used to find the server's port
|
|
43
|
+
-i, --instance <name> Registered instance whose server to call (see 'quilltap instances')
|
|
44
|
+
--passphrase <pass> Accepted and ignored (this tool never opens the database)
|
|
33
45
|
--turn <number> 1-based interchange to replay at (default: last)
|
|
34
46
|
--char <characterId> Character whose memories are searched
|
|
35
47
|
(default: first LLM-controlled participant)
|
|
36
|
-
--limit <number> Candidate rows per path (default:
|
|
37
|
-
|
|
48
|
+
--limit <number> Candidate rows per path (default: the larger of 25
|
|
49
|
+
and the new path's head size + 10; max: 100)
|
|
50
|
+
--memory-budget <tokens>
|
|
51
|
+
Memory token budget (positive integer). The new
|
|
52
|
+
path's selected rows then follow the budget-sized
|
|
53
|
+
dynamic head (the head scales with the model's
|
|
54
|
+
memory budget); the old path keeps the historical
|
|
55
|
+
fixed 5-entry head.
|
|
56
|
+
--tuning '<json>' Retuning constants for the NEW path only, e.g.
|
|
57
|
+
'{"boostGateAbs":0.45,"boostGateMargin":0.15,
|
|
58
|
+
"boostGateRamp":0.1,"boostCap":1.6}'. Keys:
|
|
59
|
+
boostGateAbs, boostGateMargin, boostGateRamp,
|
|
60
|
+
boostCap, freshBypassesGate, windowBypassesGate,
|
|
61
|
+
multipliers
|
|
62
|
+
{RECALL_MULTIPLIERS key:
|
|
63
|
+
value}, specificAnchors, anchorMinHits,
|
|
64
|
+
anchorOrder, backgroundReserve.
|
|
65
|
+
Unknown keys are rejected.
|
|
66
|
+
--tuning-file <path> Read the --tuning JSON from a file
|
|
67
|
+
--signals-from <path> Reuse the signals saved in a previous --json
|
|
68
|
+
replay of the same chat and turn, instead of a
|
|
69
|
+
fresh (non-deterministic) distillation
|
|
70
|
+
--as-of Search only memories created before the turn's
|
|
71
|
+
opening message (the corpus as it stood then)
|
|
72
|
+
--port <number> Server port for API calls. Default: the port recorded in the
|
|
73
|
+
--instance/--data-dir server's lock file, else 3000
|
|
38
74
|
--json Print the raw JSON result instead of tables
|
|
39
75
|
-h, --help Show this help
|
|
40
76
|
|
|
41
77
|
Examples:
|
|
42
78
|
quilltap recall-replay <chatId>
|
|
43
79
|
quilltap recall-replay <chatId> --turn 42
|
|
80
|
+
quilltap recall-replay <chatId> --turn 42 --memory-budget 4000
|
|
44
81
|
quilltap recall-replay <chatId> --turn 42 --json > replay.json
|
|
82
|
+
quilltap recall-replay <chatId> --turn 42 --signals-from replay.json --as-of \
|
|
83
|
+
--tuning-file tuning-r1.json
|
|
45
84
|
`);
|
|
46
85
|
}
|
|
47
86
|
|
|
48
87
|
function parseFlags(args) {
|
|
49
|
-
const flags = {
|
|
88
|
+
const flags = {
|
|
89
|
+
turn: undefined, char: undefined, limit: undefined, memoryBudget: undefined,
|
|
90
|
+
tuning: undefined, tuningFile: undefined, signalsFrom: undefined, asOf: false,
|
|
91
|
+
port: undefined, dataDir: '', instance: '', json: false, help: false,
|
|
92
|
+
};
|
|
50
93
|
const positional = [];
|
|
51
94
|
let i = 0;
|
|
52
95
|
while (i < args.length) {
|
|
@@ -73,6 +116,40 @@ function parseFlags(args) {
|
|
|
73
116
|
flags.limit = n;
|
|
74
117
|
break;
|
|
75
118
|
}
|
|
119
|
+
case '--memory-budget': {
|
|
120
|
+
const n = Number(args[++i]);
|
|
121
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
122
|
+
console.error('Error: --memory-budget must be a positive integer');
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
flags.memoryBudget = n;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case '--tuning':
|
|
129
|
+
flags.tuning = args[++i];
|
|
130
|
+
break;
|
|
131
|
+
case '--tuning-file':
|
|
132
|
+
flags.tuningFile = args[++i];
|
|
133
|
+
break;
|
|
134
|
+
case '--signals-from':
|
|
135
|
+
flags.signalsFrom = args[++i];
|
|
136
|
+
break;
|
|
137
|
+
case '--as-of':
|
|
138
|
+
flags.asOf = true;
|
|
139
|
+
break;
|
|
140
|
+
case '-d':
|
|
141
|
+
case '--data-dir':
|
|
142
|
+
flags.dataDir = args[++i];
|
|
143
|
+
break;
|
|
144
|
+
case '-i':
|
|
145
|
+
case '--instance':
|
|
146
|
+
flags.instance = args[++i];
|
|
147
|
+
break;
|
|
148
|
+
case '--passphrase':
|
|
149
|
+
// Accepted so universal flags never error; these tools talk to the
|
|
150
|
+
// running server and never open the database themselves.
|
|
151
|
+
i++;
|
|
152
|
+
break;
|
|
76
153
|
case '--port': {
|
|
77
154
|
const p = parseInt(args[++i], 10);
|
|
78
155
|
if (isNaN(p) || p < 1 || p > 65535) {
|
|
@@ -106,8 +183,9 @@ function fmt(n, digits = 3) {
|
|
|
106
183
|
return n.toFixed(digits);
|
|
107
184
|
}
|
|
108
185
|
|
|
109
|
-
function printPath(label, rows) {
|
|
110
|
-
|
|
186
|
+
function printPath(label, rows, headSize) {
|
|
187
|
+
const head = typeof headSize === 'number' ? `, head ${headSize}` : '';
|
|
188
|
+
console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates${head})`);
|
|
111
189
|
if (rows.length === 0) {
|
|
112
190
|
console.log(` ${DIM}(none)${RESET}`);
|
|
113
191
|
return;
|
|
@@ -127,6 +205,73 @@ function printPath(label, rows) {
|
|
|
127
205
|
}
|
|
128
206
|
}
|
|
129
207
|
|
|
208
|
+
/** The stage trace: one line per stage entry (query → gather → … → assemble). */
|
|
209
|
+
function printTrace(trace) {
|
|
210
|
+
if (!Array.isArray(trace) || trace.length === 0) return;
|
|
211
|
+
console.log(`\n${BOLD}STAGE TRACE${RESET}`);
|
|
212
|
+
for (const entry of trace) {
|
|
213
|
+
const label = entry.label ? `${DIM}(${entry.label})${RESET} ` : '';
|
|
214
|
+
const detail = Object.entries(entry.detail || {})
|
|
215
|
+
.map(([k, v]) => `${k}=${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`)
|
|
216
|
+
.join(' ');
|
|
217
|
+
console.log(` ${CYAN}${String(entry.stage).padEnd(9)}${RESET}${label}${detail}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function readJson(text, what) {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(text);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(`${RED}Could not parse ${what} as JSON: ${err.message}${RESET}`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readJsonFile(path, what) {
|
|
231
|
+
const fs = require('fs');
|
|
232
|
+
let text;
|
|
233
|
+
try {
|
|
234
|
+
text = fs.readFileSync(path, 'utf8');
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error(`${RED}Could not read ${what} ${path}: ${err.message}${RESET}`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
return readJson(text, `${what} ${path}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The tuning object from --tuning or --tuning-file (never both). */
|
|
243
|
+
function loadTuning(flags) {
|
|
244
|
+
if (flags.tuning !== undefined && flags.tuningFile !== undefined) {
|
|
245
|
+
console.error('Error: pass --tuning or --tuning-file, not both');
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
if (flags.tuning !== undefined) return readJson(flags.tuning, '--tuning');
|
|
249
|
+
if (flags.tuningFile !== undefined) return readJsonFile(flags.tuningFile, '--tuning-file');
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The signals saved in a previous --json replay. Refuses a file from another
|
|
255
|
+
* chat or turn — reusing its signals would replay a different question.
|
|
256
|
+
*/
|
|
257
|
+
function loadSignals(path, chatId, turn) {
|
|
258
|
+
const saved = readJsonFile(path, '--signals-from');
|
|
259
|
+
const result = saved.data ?? saved;
|
|
260
|
+
if (!result.signals) {
|
|
261
|
+
console.error(`${RED}${path} has no saved signals (its distillation failed); replay without --signals-from${RESET}`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
if (result.chatId && result.chatId !== chatId) {
|
|
265
|
+
console.error(`${RED}${path} is a replay of chat ${result.chatId}, not ${chatId}${RESET}`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
if (turn !== undefined && result.turnIndex !== undefined && result.turnIndex !== turn) {
|
|
269
|
+
console.error(`${RED}${path} is a replay of turn ${result.turnIndex}, not ${turn}${RESET}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
return result.signals;
|
|
273
|
+
}
|
|
274
|
+
|
|
130
275
|
async function recallReplayCommand(args) {
|
|
131
276
|
const { flags, positional } = parseFlags(args);
|
|
132
277
|
|
|
@@ -140,11 +285,26 @@ async function recallReplayCommand(args) {
|
|
|
140
285
|
}
|
|
141
286
|
const chatId = positional[0];
|
|
142
287
|
|
|
143
|
-
|
|
288
|
+
let target;
|
|
289
|
+
try {
|
|
290
|
+
target = resolveServerPort({ port: flags.port, instance: flags.instance, dataDir: flags.dataDir });
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error(`Error: ${err.message}`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
const port = target.port;
|
|
296
|
+
if (target.note) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
|
|
297
|
+
|
|
298
|
+
const url = `http://localhost:${port}/api/v1/chats/${encodeURIComponent(chatId)}?action=recall-replay`;
|
|
144
299
|
const body = {};
|
|
145
300
|
if (flags.turn !== undefined) body.turnIndex = flags.turn;
|
|
146
301
|
if (flags.char) body.characterId = flags.char;
|
|
147
302
|
if (flags.limit !== undefined) body.limit = flags.limit;
|
|
303
|
+
if (flags.memoryBudget !== undefined) body.memoryBudget = flags.memoryBudget;
|
|
304
|
+
const tuning = loadTuning(flags);
|
|
305
|
+
if (tuning !== undefined) body.tuning = tuning;
|
|
306
|
+
if (flags.signalsFrom !== undefined) body.signals = loadSignals(flags.signalsFrom, chatId, flags.turn);
|
|
307
|
+
if (flags.asOf) body.asOf = true;
|
|
148
308
|
|
|
149
309
|
process.stderr.write(`${BOLD}Replaying recall${RESET} for chat ${DIM}${chatId}${RESET} via ${DIM}${url}${RESET}\n`);
|
|
150
310
|
|
|
@@ -156,8 +316,8 @@ async function recallReplayCommand(args) {
|
|
|
156
316
|
body: JSON.stringify(body),
|
|
157
317
|
});
|
|
158
318
|
} catch (err) {
|
|
159
|
-
console.error(`${RED}Could not reach Quilltap server at http://localhost:${
|
|
160
|
-
console.error(
|
|
319
|
+
console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
|
|
320
|
+
console.error(target.hint);
|
|
161
321
|
process.exit(1);
|
|
162
322
|
}
|
|
163
323
|
|
|
@@ -170,6 +330,11 @@ async function recallReplayCommand(args) {
|
|
|
170
330
|
}
|
|
171
331
|
if (!res.ok || payload?.success === false) {
|
|
172
332
|
console.error(`${RED}Replay failed (status ${res.status}): ${payload?.error || payload?.message || 'unknown error'}${RESET}`);
|
|
333
|
+
if (Array.isArray(payload?.details)) {
|
|
334
|
+
for (const issue of payload.details) {
|
|
335
|
+
console.error(` ${(issue.path || []).join('.') || '(body)'}: ${issue.message}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
173
338
|
process.exit(1);
|
|
174
339
|
}
|
|
175
340
|
|
|
@@ -193,9 +358,26 @@ async function recallReplayCommand(args) {
|
|
|
193
358
|
console.log(`${BOLD}Signals${RESET} ${YELLOW}distillation failed — new path ran inert${RESET}`);
|
|
194
359
|
}
|
|
195
360
|
|
|
196
|
-
|
|
197
|
-
|
|
361
|
+
const pinned = result.signalsPinned ? `${CYAN}pinned${RESET}` : `${DIM}fresh distillation${RESET}`;
|
|
362
|
+
const asOf = result.asOf
|
|
363
|
+
? `${CYAN}as of ${result.asOf}${RESET} ${DIM}(${result.excludedAfterAsOf} later memories left out)${RESET}`
|
|
364
|
+
: `${DIM}whole corpus${RESET}`;
|
|
365
|
+
console.log(`${BOLD}Harness${RESET} signals ${pinned} · ${asOf}`);
|
|
366
|
+
console.log(`${BOLD}Tuning${RESET} ${result.tuning === 'defaults' || !result.tuning ? `${DIM}defaults${RESET}` : `${YELLOW}${result.tuning}${RESET}`} ${DIM}(new path only)${RESET}`);
|
|
367
|
+
if (result.ringSource) {
|
|
368
|
+
const source = result.ringSource === 'debug-log'
|
|
369
|
+
? `${DIM}as of the turn, from its recall records${RESET}`
|
|
370
|
+
: `${YELLOW}the chat's current ring (no recall records that early)${RESET}`;
|
|
371
|
+
console.log(`${BOLD}Ring${RESET} ${(result.ring || []).length} recently whispered · ${source}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
printPath('OLD PATH (episodic signals inert)', result.oldPath || [], result.oldHeadSize);
|
|
375
|
+
printPath('NEW PATH (retrospective/window/entities live)', result.newPath || [], result.newHeadSize);
|
|
376
|
+
if (Array.isArray(result.fallbackPath)) {
|
|
377
|
+
printPath('TEXT FALLBACK (what the new path returns with no usable vector)', result.fallbackPath, result.newHeadSize);
|
|
378
|
+
}
|
|
379
|
+
printTrace(result.trace);
|
|
198
380
|
console.log('');
|
|
199
381
|
}
|
|
200
382
|
|
|
201
|
-
module.exports = { recallReplayCommand };
|
|
383
|
+
module.exports = { recallReplayCommand, parseFlags };
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `quilltap sync <store> <path>` — keep a database-backed document store and a
|
|
5
|
+
* directory on disk in step with each other.
|
|
6
|
+
*
|
|
7
|
+
* This file is a thin client. Every decision the sync makes — which side wins,
|
|
8
|
+
* what counts as a deletion, when to refuse — is made by the engine in the
|
|
9
|
+
* server (`lib/mount-index/sync/`), and every flag is validated by the route's
|
|
10
|
+
* own schema, which is the single source of truth. The CLI resolves the store
|
|
11
|
+
* name to a UUID (the one thing it opens the database for, read-only), posts
|
|
12
|
+
* the request, and prints what came back.
|
|
13
|
+
*
|
|
14
|
+
* The engine lives in the server rather than here because the sync writes
|
|
15
|
+
* through the store's own chokepoints — `linkDocumentContent`, the folder-row
|
|
16
|
+
* helper, the hard-link fan-out, the post-write re-chunk — and those are
|
|
17
|
+
* TypeScript in `lib/`, unreachable from this plain-JS package. A direct
|
|
18
|
+
* SQLite writer would be a second copy of all of them, and a lock-gated one
|
|
19
|
+
* could not re-chunk at all. `docs write` on a database store already requires
|
|
20
|
+
* the server for exactly this reason; `deconvert` already takes a server-local
|
|
21
|
+
* target path. This is that shape, not a new one.
|
|
22
|
+
*
|
|
23
|
+
* `<path>` is therefore resolved on the SERVER. Running under Docker, it must
|
|
24
|
+
* sit inside a bind mount — `quilltap docs docker-mounts` plans those.
|
|
25
|
+
*
|
|
26
|
+
* @module sync-command
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const os = require('os');
|
|
31
|
+
const {
|
|
32
|
+
resolveDataDirAndPassphrase,
|
|
33
|
+
printDefaultInstanceHint,
|
|
34
|
+
loadDbKey,
|
|
35
|
+
openMountIndexDb,
|
|
36
|
+
UUID_RE,
|
|
37
|
+
} = require('./db-helpers');
|
|
38
|
+
const { isQtapUri, parseQtapUri } = require('./qtap-uri');
|
|
39
|
+
const { formatActionLines, formatSummary, exitCodeFor } = require('./sync-report');
|
|
40
|
+
|
|
41
|
+
const RESET = '\x1b[0m';
|
|
42
|
+
const DIM = '\x1b[2m';
|
|
43
|
+
const YELLOW = '\x1b[33m';
|
|
44
|
+
|
|
45
|
+
function printSyncHelp() {
|
|
46
|
+
console.log(`
|
|
47
|
+
Quilltap Document Store Sync
|
|
48
|
+
|
|
49
|
+
Usage: quilltap sync <store|qtap://store/> <path> [options]
|
|
50
|
+
|
|
51
|
+
Mirrors a database-backed document store and a directory on disk in both
|
|
52
|
+
directions. The two sides are compared by SHA-256 first and by modification
|
|
53
|
+
time second: equal bytes with unequal clocks are re-stamped, not re-copied.
|
|
54
|
+
Whichever side changed is copied to the other; when both changed since the
|
|
55
|
+
last run it is reported as a conflict and left alone.
|
|
56
|
+
|
|
57
|
+
The directory is created if it is missing. A document store is never created.
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
--dry-run Plan and print; change nothing on either side
|
|
61
|
+
--direction <which> both (default), to-disk, or to-store
|
|
62
|
+
--prefer <which> newer (default), store, or disk — resolves conflicts
|
|
63
|
+
--no-delete Never propagate a deletion to the other side
|
|
64
|
+
--no-manifest Ignore .quilltap-sync.json (first-run rules every time)
|
|
65
|
+
--json Machine-readable plan and results on stdout
|
|
66
|
+
-p, --port <number> Server port (default: 3000)
|
|
67
|
+
-d, --data-dir <path> Override data directory
|
|
68
|
+
-i, --instance <name> Use a registered instance
|
|
69
|
+
--passphrase <pass> Decrypt .dbkey if peppered
|
|
70
|
+
-h, --help Show this help
|
|
71
|
+
|
|
72
|
+
Exit codes: 0 clean, 1 an error or a failed action, 2 an unresolved conflict.
|
|
73
|
+
--dry-run uses the same codes, so a script can gate on a clean plan.
|
|
74
|
+
|
|
75
|
+
Files and folders whose names begin with a dot are INVISIBLE to the sync in
|
|
76
|
+
both directions — never copied, never deleted, on either side. The one
|
|
77
|
+
exception is .quilltap-sync.json, the record the verb keeps of what the last
|
|
78
|
+
run left; it lives in the directory and never enters the store.
|
|
79
|
+
|
|
80
|
+
A binary's description travels beside it as <file>.description.md. Editing
|
|
81
|
+
that file changes the caption in the store; deleting it clears the caption.
|
|
82
|
+
A text document's description is not synced.
|
|
83
|
+
|
|
84
|
+
The server must be running: a database-backed store's writes go through it,
|
|
85
|
+
as they already do for 'quilltap docs write'. <path> is resolved on the
|
|
86
|
+
server — under Docker it must sit inside a bind mount (see
|
|
87
|
+
'quilltap docs docker-mounts').
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
quilltap sync Lore ~/Documents/lore --dry-run
|
|
91
|
+
quilltap sync Lore ~/Documents/lore
|
|
92
|
+
quilltap sync Lore ~/Documents/lore --prefer disk
|
|
93
|
+
quilltap sync qtap://Lore/ ~/Documents/lore --direction to-disk
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseFlags(args) {
|
|
98
|
+
const flags = {
|
|
99
|
+
dataDir: '',
|
|
100
|
+
instance: '',
|
|
101
|
+
passphrase: '',
|
|
102
|
+
port: 3000,
|
|
103
|
+
json: false,
|
|
104
|
+
dryRun: false,
|
|
105
|
+
direction: 'both',
|
|
106
|
+
prefer: 'newer',
|
|
107
|
+
noDelete: false,
|
|
108
|
+
noManifest: false,
|
|
109
|
+
help: false,
|
|
110
|
+
};
|
|
111
|
+
const positional = [];
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < args.length; i++) {
|
|
114
|
+
const arg = args[i];
|
|
115
|
+
switch (arg) {
|
|
116
|
+
case '-d': case '--data-dir': flags.dataDir = args[++i] || ''; break;
|
|
117
|
+
case '-i': case '--instance': flags.instance = args[++i] || ''; break;
|
|
118
|
+
case '--passphrase': flags.passphrase = args[++i] || ''; break;
|
|
119
|
+
case '-p': case '--port': flags.port = parseInt(args[++i], 10) || 3000; break;
|
|
120
|
+
case '--json': flags.json = true; break;
|
|
121
|
+
case '--dry-run': flags.dryRun = true; break;
|
|
122
|
+
case '--direction': flags.direction = args[++i] || ''; break;
|
|
123
|
+
case '--prefer': flags.prefer = args[++i] || ''; break;
|
|
124
|
+
case '--no-delete': flags.noDelete = true; break;
|
|
125
|
+
case '--no-manifest': flags.noManifest = true; break;
|
|
126
|
+
case '-h': case '--help': flags.help = true; break;
|
|
127
|
+
default:
|
|
128
|
+
if (arg.startsWith('-')) {
|
|
129
|
+
console.error(`Unknown option: ${arg}`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
positional.push(arg);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { flags, positional };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `~/x` → an absolute path. The server sees only what we send it. */
|
|
139
|
+
function expandPath(input) {
|
|
140
|
+
let expanded = input;
|
|
141
|
+
if (expanded === '~') expanded = os.homedir();
|
|
142
|
+
else if (expanded.startsWith('~/')) expanded = path.join(os.homedir(), expanded.slice(2));
|
|
143
|
+
return path.resolve(expanded);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A store name or UUID, or a `qtap://store/` URI with an empty path. This is
|
|
148
|
+
* the only thing the verb opens the database for, and it opens it read-only.
|
|
149
|
+
*/
|
|
150
|
+
function resolveStoreSpec(spec) {
|
|
151
|
+
if (!isQtapUri(spec)) return spec;
|
|
152
|
+
const parsed = parseQtapUri(spec);
|
|
153
|
+
if (parsed.scope !== 'document_store') {
|
|
154
|
+
throw new Error(
|
|
155
|
+
'sync addresses document stores only; qtap://project/… and qtap://general/… are not CLI-addressable.'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (parsed.path) {
|
|
159
|
+
throw new Error(`sync takes a whole store, not a path inside one: ${spec}`);
|
|
160
|
+
}
|
|
161
|
+
if (!parsed.mountPoint || parsed.mountPoint.toLowerCase() === 'self') {
|
|
162
|
+
throw new Error('"self" requires a character context and is not resolvable from the CLI.');
|
|
163
|
+
}
|
|
164
|
+
return parsed.mountPoint;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Name-first, UUID fallback — the same resolution the `docs` verbs use. */
|
|
168
|
+
function requireMount(db, spec) {
|
|
169
|
+
if (UUID_RE.test(spec)) {
|
|
170
|
+
const row = db.prepare('SELECT * FROM doc_mount_points WHERE id = ?').get(spec);
|
|
171
|
+
if (!row) {
|
|
172
|
+
console.error(`No document store found with id ${spec}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
return row;
|
|
176
|
+
}
|
|
177
|
+
const rows = db.prepare(
|
|
178
|
+
`SELECT * FROM doc_mount_points WHERE LOWER(name) = LOWER(?) ORDER BY name COLLATE NOCASE`
|
|
179
|
+
).all(spec);
|
|
180
|
+
if (rows.length === 0) {
|
|
181
|
+
console.error(`No document store found with name "${spec}"`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
if (rows.length > 1) {
|
|
185
|
+
console.error(`Ambiguous store name "${spec}" matches multiple stores:`);
|
|
186
|
+
for (const r of rows) console.error(` ${r.id} ${r.name} (${r.mountType})`);
|
|
187
|
+
console.error('Pass the UUID instead.');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
return rows[0];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isConnectionRefused(err) {
|
|
194
|
+
if (!err) return false;
|
|
195
|
+
const code = err.cause && err.cause.code ? err.cause.code : err.code;
|
|
196
|
+
return code === 'ECONNREFUSED' || code === 'ENOTFOUND' ||
|
|
197
|
+
code === 'EHOSTUNREACH' || code === 'ECONNRESET';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function syncCommand(args) {
|
|
201
|
+
const { flags, positional } = parseFlags(args);
|
|
202
|
+
if (flags.help || positional.length === 0) {
|
|
203
|
+
printSyncHelp();
|
|
204
|
+
process.exit(flags.help ? 0 : 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const [storeSpec, targetSpec] = positional;
|
|
208
|
+
if (!targetSpec) {
|
|
209
|
+
console.error('Usage: quilltap sync <store> <path> [options]');
|
|
210
|
+
console.error("Run 'quilltap sync --help' for the full list of options.");
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
if (positional.length > 2) {
|
|
214
|
+
console.error(`sync takes one store and one path; got ${positional.length} arguments.`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let storeName;
|
|
219
|
+
try {
|
|
220
|
+
storeName = resolveStoreSpec(storeSpec);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`Error: ${err.message}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const targetPath = expandPath(targetSpec);
|
|
227
|
+
|
|
228
|
+
// Resolve the store from the local database — read-only, and the only thing
|
|
229
|
+
// the CLI opens it for.
|
|
230
|
+
const resolved = resolveDataDirAndPassphrase({
|
|
231
|
+
dataDir: flags.dataDir,
|
|
232
|
+
instance: flags.instance,
|
|
233
|
+
passphrase: flags.passphrase,
|
|
234
|
+
});
|
|
235
|
+
printDefaultInstanceHint(resolved);
|
|
236
|
+
const pepper = await loadDbKey(resolved.dataDir, resolved.passphrase);
|
|
237
|
+
const db = openMountIndexDb(resolved.dataDir, pepper, { readonly: true });
|
|
238
|
+
let mount;
|
|
239
|
+
try {
|
|
240
|
+
mount = requireMount(db, storeName);
|
|
241
|
+
} finally {
|
|
242
|
+
db.close();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (mount.mountType !== 'database') {
|
|
246
|
+
console.error(
|
|
247
|
+
`"${mount.name}" is a ${mount.mountType} store — it already IS a directory` +
|
|
248
|
+
(mount.basePath ? ` (${mount.basePath})` : '') + '.'
|
|
249
|
+
);
|
|
250
|
+
console.error('sync mirrors database-backed stores only.');
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const url =
|
|
255
|
+
`http://localhost:${flags.port}/api/v1/mount-points/${encodeURIComponent(mount.id)}?action=sync`;
|
|
256
|
+
|
|
257
|
+
// The route's schema is the single source of truth for these; the CLI does
|
|
258
|
+
// not re-validate, so a bad --direction is refused by the server with the
|
|
259
|
+
// server's own wording.
|
|
260
|
+
const body = {
|
|
261
|
+
targetPath,
|
|
262
|
+
dryRun: flags.dryRun,
|
|
263
|
+
direction: flags.direction,
|
|
264
|
+
prefer: flags.prefer,
|
|
265
|
+
propagateDeletes: !flags.noDelete,
|
|
266
|
+
useManifest: !flags.noManifest,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
let res;
|
|
270
|
+
try {
|
|
271
|
+
res = await fetch(url, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify(body),
|
|
275
|
+
});
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (isConnectionRefused(err)) {
|
|
278
|
+
console.error(
|
|
279
|
+
`Cannot sync database-backed store "${mount.name}" without the Quilltap server.`
|
|
280
|
+
);
|
|
281
|
+
console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const payload = await res.json().catch(() => null);
|
|
288
|
+
if (!res.ok) {
|
|
289
|
+
const message = payload && payload.error ? payload.error : `HTTP ${res.status}`;
|
|
290
|
+
console.error(`Error: ${message}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const report = payload && payload.data !== undefined ? payload.data : payload;
|
|
295
|
+
|
|
296
|
+
if (flags.json) {
|
|
297
|
+
console.log(JSON.stringify(report, null, 2));
|
|
298
|
+
process.exit(exitCodeFor(report.summary));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const colour = Boolean(process.stdout.isTTY);
|
|
302
|
+
for (const line of formatActionLines(report.actions, colour)) {
|
|
303
|
+
console.log(line);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Advisory text goes to stderr so stdout stays greppable.
|
|
307
|
+
for (const warning of report.warnings || []) {
|
|
308
|
+
console.error(colour ? `${YELLOW}warning:${RESET} ${warning}` : `warning: ${warning}`);
|
|
309
|
+
}
|
|
310
|
+
const summary = formatSummary(report.summary, report.elapsedMs, report.dryRun);
|
|
311
|
+
console.error(colour ? `${DIM}${summary}${RESET}` : summary);
|
|
312
|
+
if (report.summary.conflicts > 0) {
|
|
313
|
+
console.error(
|
|
314
|
+
colour
|
|
315
|
+
? `${DIM}Re-run with --prefer store or --prefer disk to resolve the conflicts.${RESET}`
|
|
316
|
+
: 'Re-run with --prefer store or --prefer disk to resolve the conflicts.'
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
process.exit(exitCodeFor(report.summary));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = { syncCommand, printSyncHelp, expandPath, resolveStoreSpec };
|