quilltap 4.7.0-dev → 4.7.0-dev.117

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
@@ -141,12 +141,16 @@ Most subcommands accept `--json` (for piping) and `--limit N`. Names are case-in
141
141
  ```bash
142
142
  quilltap db --tables # List tables in active DB
143
143
  quilltap db --count chat_messages # Row count
144
- quilltap db "SELECT id FROM characters LIMIT 5" # Raw SQL
145
- quilltap db --repl # Interactive prompt
144
+ quilltap db "SELECT id FROM characters LIMIT 5" # Raw SQL (read-only)
145
+ quilltap db --repl # Interactive prompt (read-only)
146
+ quilltap db --write "UPDATE characters SET title = 'rival' WHERE id = '...'"
147
+ quilltap db --repl --write # Interactive, read-write
146
148
  quilltap db --llm-logs --tables # Target the LLM logs DB
147
149
  quilltap db --mount-points --tables # Target the mount index DB
148
150
  ```
149
151
 
152
+ The database is opened **read-only by default**. Add `--write` to make changes: it opens the database read-write, **claims the instance lock** (`<dataDir>/quilltap.lock`) for the duration, and releases it on exit. It **refuses — with no override — if a running server or another instance holds the lock**, so stop the server first. `--repl` is read-only unless combined with `--write`. Attempting a write without `--write` fails with a hint to re-run with the flag.
153
+
150
154
  In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
151
155
 
152
156
  ## Document Stores (Scriptorium)
@@ -169,14 +173,32 @@ quilltap docs status # Per-mount extraction + embeddi
169
173
  quilltap docs scan <mount> # Trigger a rescan
170
174
  quilltap docs reindex <mount> [path] [--force] # Re-extract + re-chunk
171
175
  quilltap docs embed <mount> [path] [--force] [--wait] # Enqueue embedding jobs
172
- quilltap docs write [--force] <mount> <path> [file] # Stdin or file → mount
176
+ quilltap docs write [--force] [--base64] <mount> <path> [file] # Stdin or file → mount
177
+ quilltap docs read [--rendered] [--base64] <mount> <path> # File contents → stdout
173
178
  quilltap docs delete <mount> <path> # Idempotent delete
174
179
  quilltap docs mkdir <mount> <path> # Idempotent folder create
175
180
  quilltap docs move <srcMount> <srcPath> <dstMount> <dstPath> # Move (hard-link when possible)
176
181
  quilltap docs copy [--force] <srcMount> <srcPath> <dstMount> <dstPath>
182
+ quilltap docs link <srcMount> <srcPath> <dstMount> <dstPath> # Hard-link (server-required; no byte copy)
183
+ quilltap docs rmdir <mount> <path> # Delete an empty folder (server-required)
184
+ quilltap docs mvdir <mount> <fromPath> <toPath> # Rename/move a folder (server-required)
177
185
  ```
178
186
 
179
- Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous names print candidates and exit non-zero. `--json` is supported by every verb; `reindex` and `embed` refuse to run without a reachable server.
187
+ Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous names print candidates and exit non-zero. `--json` is supported by every verb; `reindex`, `embed`, `link`, `rmdir`, and `mvdir` refuse to run without a reachable server.
188
+
189
+ ### `--base64` flag
190
+
191
+ `write --base64`: reads the source bytes and sends them to the server via `PUT /api/v1/mount-points/{mountId}/files/{path}` with `{content: <base64>, encoding: "base64"}`. This is the portable path the file browser uses and handles arbitrary binary files cleanly. Server-required; the direct filesystem fallback is not available with this flag.
192
+
193
+ `read --base64`: fetches raw bytes via `GET /api/v1/mount-points/{mountId}/files/{path}?encoding=base64` and emits the decoded bytes to stdout, suitable for binary round-trips. Server-required.
194
+
195
+ ### `link`, `rmdir`, `mvdir`
196
+
197
+ `link` calls `POST /api/v1/mount-points/{srcMountId}?action=link-file` with `{sourcePath, destMountPointId, destPath}`. Creates a true hard link with no byte copy; the server reports back a `strategy` field. Errors: `DEST_EXISTS` (exit 2), `UNSUPPORTED` (cross-storage or cross-device), `SOURCE_NOT_FOUND`.
198
+
199
+ `rmdir` calls `POST /api/v1/mount-points/{mountId}?action=delete-folder` with `{path}`. Fails with a clear message if the folder is not empty (`NOT_EMPTY` / `CONFLICT`).
200
+
201
+ `mvdir` calls `POST /api/v1/mount-points/{mountId}?action=move-folder` with `{fromPath, toPath}`. Fails with exit 2 if the destination already exists (`DEST_EXISTS`).
180
202
 
181
203
  ## Memories
182
204
 
@@ -194,6 +216,18 @@ quilltap memories status [--character <name|id>] # Per-hol
194
216
 
195
217
  Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source`, `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` to reverse) apply to `ls`, `find`, and `grep`. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. The legacy `quilltap db memories --character <name>` verb remains undisturbed.
196
218
 
219
+ ## Maintenance & Cleanup
220
+
221
+ `quilltap maintenance` is the manual trigger for the retention sweeps that otherwise run on the server's daily maintenance tick. It reaps data with no bearing on characters, stories, or memories.
222
+
223
+ ```bash
224
+ quilltap maintenance status # Read-only: last sweep time + dry-run counts of what would be reaped
225
+ quilltap maintenance status --instance Friday --json
226
+ quilltap maintenance run --instance Friday # Run the sweeps once (lock-gated; refuses while the server is up)
227
+ ```
228
+
229
+ `maintenance run` is a DB writer: it claims `<dataDir>/quilltap.lock` and **refuses while a running Quilltap server holds it** — stop the server first. Because it can only run with the server down, it performs the sweeps expressible as direct SQL/filesystem work: reaping finished background jobs (COMPLETED after 7 days, DEAD after 30, keyed off `completedAt`), closed terminal sessions older than 30 days plus their transcript files, and orphaned mount-index files. The **stale-chat asset collapse** (superseded story-backgrounds and wardrobe avatars) needs the server's file-storage machinery and runs only on the server's daily tick — `status` reports a stale-chat count so you can see the backlog. Retention windows mirror `lib/background-jobs/maintenance/retention-constants.ts`.
230
+
197
231
  ## Theme Management
198
232
 
199
233
  The CLI includes theme management commands:
package/bin/quilltap.js CHANGED
@@ -13,7 +13,7 @@ const {
13
13
  loadDbKey,
14
14
  } = require('../lib/db-helpers');
15
15
  const { resolveInstance } = require('../lib/instances');
16
- const { resolveModuleDir, ensureNativeModules } = require('../lib/native-modules');
16
+ const { resolveModuleDir, ensureNativeModules, ensureDatabaseNativeModule } = require('../lib/native-modules');
17
17
 
18
18
  const PACKAGE_DIR = path.resolve(__dirname, '..');
19
19
 
@@ -730,9 +730,15 @@ Subcommands (high-level shortcuts; auto-pick the right database):
730
730
  Most subcommands also accept --json and --limit N.
731
731
 
732
732
  Low-level options (legacy; still supported):
733
+ The database is opened READ-ONLY by default. Add --write to make changes.
733
734
  --tables List all tables in the active database
734
735
  --count <table> Show row count for a table
735
- --repl Interactive SQL prompt (extras: .cols, .find)
736
+ --repl Interactive SQL prompt (extras: .cols, .find).
737
+ Read-only unless combined with --write.
738
+ --write Open the database read-write. Claims the instance lock
739
+ for the duration and releases it on exit. Refuses (no
740
+ override) if a running server or another instance holds
741
+ the lock — stop it first. Works with raw SQL and --repl.
736
742
  --json Emit machine-readable JSON instead of a table
737
743
  (works with --tables, --count, and raw SQL)
738
744
  --llm-logs Target the LLM logs database
@@ -769,6 +775,8 @@ Examples:
769
775
  quilltap db "SELECT count(*) FROM characters"
770
776
  quilltap db --count messages
771
777
  quilltap db --repl
778
+ quilltap db --write "UPDATE characters SET title = 'rival' WHERE id = '...'"
779
+ quilltap db --repl --write # interactive, read-write
772
780
  quilltap db --lock-status
773
781
  QUILLTAP_DB_PASSPHRASE=secret quilltap db --tables
774
782
  `);
@@ -837,6 +845,7 @@ async function dbCommand(args) {
837
845
  let showTables = false;
838
846
  let countTable = '';
839
847
  let repl = false;
848
+ let writable = false;
840
849
  let sql = '';
841
850
  let showHelp = false;
842
851
  let lockStatus = false;
@@ -852,6 +861,7 @@ async function dbCommand(args) {
852
861
  case '--tables': showTables = true; break;
853
862
  case '--count': countTable = cleaned[++i]; break;
854
863
  case '--repl': repl = true; break;
864
+ case '--write': writable = true; break;
855
865
  case '--json': asJson = true; break;
856
866
  case '--help': case '-h': showHelp = true; break;
857
867
  case '--lock-status': lockStatus = true; break;
@@ -919,6 +929,19 @@ async function dbCommand(args) {
919
929
  process.exit(1);
920
930
  }
921
931
 
932
+ // Read-write opens (`--write`, optionally with `--repl`) must claim the
933
+ // instance lock first so a server starting mid-operation refuses to run.
934
+ // Refuses (no override) if a live instance already holds it.
935
+ if (writable) {
936
+ const { acquireWriteLock } = require('../lib/lock-helpers');
937
+ try {
938
+ acquireWriteLock(dataDir);
939
+ } catch (err) {
940
+ console.error(err.message);
941
+ process.exit(1);
942
+ }
943
+ }
944
+
922
945
  // Open database — prefer SQLCipher-capable build
923
946
  let Database;
924
947
  try {
@@ -926,7 +949,7 @@ async function dbCommand(args) {
926
949
  } catch {
927
950
  Database = require('better-sqlite3');
928
951
  }
929
- const db = new Database(dbPath, { readonly: !repl });
952
+ const db = new Database(dbPath, { readonly: !writable });
930
953
 
931
954
  if (pepper) {
932
955
  const keyHex = Buffer.from(pepper, 'base64').toString('hex');
@@ -971,7 +994,7 @@ async function dbCommand(args) {
971
994
  } else if (repl) {
972
995
  const readline = require('readline');
973
996
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'quilltap> ' });
974
- console.log(`Connected to ${dbPath}`);
997
+ console.log(`Connected to ${dbPath}${writable ? ' (read-write — instance lock held)' : ' (read-only)'}`);
975
998
  console.log('Type .tables, .schema <table>, .cols <table>, .find <text>, or SQL. Ctrl+D to exit.\n');
976
999
  rl.prompt();
977
1000
  rl.on('line', (line) => {
@@ -1033,19 +1056,40 @@ async function dbCommand(args) {
1033
1056
  }
1034
1057
  } catch (err) {
1035
1058
  console.error(`Error: ${err.message}`);
1059
+ if (!writable && /readonly|read-only/i.test(err.message)) {
1060
+ console.error('This REPL is read-only. Exit and re-run as `quilltap db --repl --write` to make changes.');
1061
+ }
1036
1062
  }
1037
1063
  rl.prompt();
1038
1064
  });
1039
1065
  rl.on('close', () => {
1040
1066
  db.close();
1067
+ if (writable) {
1068
+ const { releaseWriteLock } = require('../lib/lock-helpers');
1069
+ releaseWriteLock(dataDir);
1070
+ }
1041
1071
  process.exit(0);
1042
1072
  });
1043
1073
  return; // Don't close db yet — REPL is interactive
1044
1074
  } else {
1045
1075
  printDbHelp();
1046
1076
  }
1077
+ } catch (err) {
1078
+ if (!writable && /readonly|read-only/i.test(err.message)) {
1079
+ console.error('This database was opened read-only, so it cannot be modified.');
1080
+ console.error('Re-run with --write to make changes — it claims the instance lock while it runs');
1081
+ console.error('and refuses if another Quilltap instance is using the database. For example:');
1082
+ console.error(` quilltap db --write ${sql ? JSON.stringify(sql) : '"UPDATE ..."'}`);
1083
+ } else {
1084
+ console.error(`Error: ${err.message}`);
1085
+ }
1086
+ process.exitCode = 1;
1047
1087
  } finally {
1048
1088
  if (!repl) db.close();
1089
+ if (writable && !repl) {
1090
+ const { releaseWriteLock } = require('../lib/lock-helpers');
1091
+ releaseWriteLock(dataDir);
1092
+ }
1049
1093
  }
1050
1094
  }
1051
1095
 
@@ -1059,7 +1103,7 @@ async function dbCommand(args) {
1059
1103
  // to the subcommand. Each subcommand parses these flags position-independently,
1060
1104
  // so they behave the same before or after the verb.
1061
1105
  const SUBCOMMANDS = new Set([
1062
- 'db', 'themes', 'docs', 'memories', 'instances', 'memory-diff', 'completion', 'logs', 'migrations',
1106
+ 'db', 'themes', 'docs', 'memories', 'instances', 'memory-diff', 'completion', 'logs', 'migrations', 'maintenance',
1063
1107
  ]);
1064
1108
  // Global flags that consume the following token as their value.
1065
1109
  const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
@@ -1080,6 +1124,14 @@ const subName = subIdx >= 0 ? cliArgs[subIdx] : '';
1080
1124
  // Everything except the subcommand token itself (leading global flags kept).
1081
1125
  const subArgs = subIdx >= 0 ? [...cliArgs.slice(0, subIdx), ...cliArgs.slice(subIdx + 1)] : [];
1082
1126
 
1127
+ // Subcommands load the SQLCipher binding directly and never reach main()'s
1128
+ // native-module heal, so self-heal the database ABI here first. Cheap no-op when
1129
+ // healthy; rebuilds (with a friendly notice, not an error) only on a real
1130
+ // Node-ABI mismatch — e.g. after the user upgrades Node under a cached install.
1131
+ if (subName) {
1132
+ ensureDatabaseNativeModule();
1133
+ }
1134
+
1083
1135
  if (subName === 'db') {
1084
1136
  dbCommand(subArgs);
1085
1137
  } else if (subName === 'themes') {
@@ -1127,6 +1179,12 @@ if (subName === 'db') {
1127
1179
  console.error(`Error: ${err.message}`);
1128
1180
  process.exit(1);
1129
1181
  });
1182
+ } else if (subName === 'maintenance') {
1183
+ const { maintenanceCommand } = require('../lib/maintenance-commands');
1184
+ maintenanceCommand(subArgs).catch(err => {
1185
+ console.error(`Error: ${err.message}`);
1186
+ process.exit(1);
1187
+ });
1130
1188
  } else {
1131
1189
  main();
1132
1190
  }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Unit tests for the CLI-local qtap:// codec (packages/quilltap/lib/qtap-uri.js).
3
+ * Mirrors the server codec's tests (§3.4 / §2.3) — same grammar, same encoding.
4
+ *
5
+ * @jest-environment node
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const {
11
+ isQtapUri,
12
+ parseQtapUri,
13
+ formatQtapUri,
14
+ formatDocStoreUri,
15
+ QtapUriError,
16
+ } = require('../qtap-uri');
17
+
18
+ describe('CLI isQtapUri', () => {
19
+ it('accepts qtap:// (case-insensitive), rejects others', () => {
20
+ expect(isQtapUri('qtap://self/x.md')).toBe(true);
21
+ expect(isQtapUri('QTAP://self/x.md')).toBe(true);
22
+ expect(isQtapUri('https://x')).toBe(false);
23
+ expect(isQtapUri('self/x')).toBe(false);
24
+ expect(isQtapUri(undefined)).toBe(false);
25
+ });
26
+ });
27
+
28
+ describe('CLI parseQtapUri — worked examples', () => {
29
+ it('self vault', () => {
30
+ expect(parseQtapUri('qtap://self/Mail/a.md')).toEqual({
31
+ scope: 'document_store',
32
+ mountPoint: 'self',
33
+ path: 'Mail/a.md',
34
+ });
35
+ });
36
+
37
+ it('encoded name with space + colon', () => {
38
+ const p = parseQtapUri('qtap://Project%20Files%3A%20Voyages/Knowledge/x.md');
39
+ expect(p.mountPoint).toBe('Project Files: Voyages');
40
+ expect(p.path).toBe('Knowledge/x.md');
41
+ });
42
+
43
+ it('UUID authority', () => {
44
+ expect(parseQtapUri('qtap://550e8400-e29b-41d4-a716-446655440000/n/t.md').mountPoint).toBe(
45
+ '550e8400-e29b-41d4-a716-446655440000'
46
+ );
47
+ });
48
+
49
+ it('project + general scopes', () => {
50
+ expect(parseQtapUri('qtap://project/Outline.md')).toEqual({ scope: 'project', path: 'Outline.md' });
51
+ expect(parseQtapUri('qtap://general/S/intro.md')).toEqual({ scope: 'general', path: 'S/intro.md' });
52
+ });
53
+
54
+ it('fragment heading + level', () => {
55
+ expect(parseQtapUri('qtap://self/Backstory.md#Childhood:2')).toEqual({
56
+ scope: 'document_store',
57
+ mountPoint: 'self',
58
+ path: 'Backstory.md',
59
+ heading: 'Childhood',
60
+ level: 2,
61
+ });
62
+ });
63
+
64
+ it('store root, with and without trailing slash', () => {
65
+ expect(parseQtapUri('qtap://self/').path).toBe('');
66
+ expect(parseQtapUri('qtap://self').path).toBe('');
67
+ });
68
+
69
+ it('reserved words win, case-insensitively', () => {
70
+ expect(parseQtapUri('qtap://SELF/x').mountPoint).toBe('self');
71
+ expect(parseQtapUri('qtap://Project/x').scope).toBe('project');
72
+ });
73
+ });
74
+
75
+ describe('CLI parseQtapUri — errors', () => {
76
+ it('NOT_A_QTAP_URI / EMPTY_AUTHORITY / BAD_LEVEL', () => {
77
+ expect(() => parseQtapUri('https://x')).toThrow(QtapUriError);
78
+ expect(() => parseQtapUri('qtap:///foo')).toThrow(/empty authority/i);
79
+ expect(() => parseQtapUri('qtap://self/x#H:0')).toThrow(/level/i);
80
+ expect(() => parseQtapUri('qtap://self/x#H:x')).toThrow(/level/i);
81
+ });
82
+ });
83
+
84
+ describe('CLI formatQtapUri — canonical', () => {
85
+ it('encodes colon as %3A and spaces as %20', () => {
86
+ expect(
87
+ formatQtapUri({ scope: 'document_store', mountPoint: 'A: B', path: 'c d.md' })
88
+ ).toBe('qtap://A%3A%20B/c%20d.md');
89
+ });
90
+
91
+ it('store root has trailing slash', () => {
92
+ expect(formatQtapUri({ scope: 'document_store', mountPoint: 'self', path: '' })).toBe('qtap://self/');
93
+ });
94
+
95
+ it('formatDocStoreUri builds a store URI', () => {
96
+ expect(formatDocStoreUri('My Store', 'a/b.md')).toBe('qtap://My%20Store/a/b.md');
97
+ expect(formatDocStoreUri('My Store', '')).toBe('qtap://My%20Store/');
98
+ });
99
+ });
100
+
101
+ describe('CLI round-trip', () => {
102
+ const cases = [
103
+ ['qtap://self/Mail/a.md', 'qtap://self/Mail/a.md'],
104
+ ['qtap://Project%20Files:%20Voyages/k.md', 'qtap://Project%20Files%3A%20Voyages/k.md'],
105
+ ['qtap://project/Outline.md', 'qtap://project/Outline.md'],
106
+ ['qtap://self', 'qtap://self/'],
107
+ ];
108
+ it.each(cases)('%s → %s', (input, expected) => {
109
+ expect(formatQtapUri(parseQtapUri(input))).toBe(expected);
110
+ });
111
+ });
@@ -113,7 +113,7 @@ _quilltap_complete() {
113
113
  local db_flags="--instance --data-dir --passphrase --json --limit --grep \
114
114
  --character --project --about --source --chat --message --rendered --field \
115
115
  --tail --last --full --from --type --out --id --diverged --blocked --help \
116
- --tables --count --repl --llm-logs --mount-points \
116
+ --tables --count --repl --write --llm-logs --mount-points \
117
117
  --lock-status --lock-clean --lock-override"
118
118
  if [[ -z "$subverb" ]]; then
119
119
  if [[ "$cur" == -* ]]; then
@@ -109,7 +109,8 @@ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'type' -d 'Filter by
109
109
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'out' -d 'Output directory' -r -F
110
110
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'tables' -d 'List tables (low-level)'
111
111
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'count' -d 'Count rows in table' -x
112
- complete -c quilltap -n '__quilltap_using_subcommand db' -l 'repl' -d 'Open SQL REPL'
112
+ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'repl' -d 'Open SQL REPL (read-only unless --write)'
113
+ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'write' -d 'Open database read-write (lock-gated)'
113
114
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'llm-logs' -d 'Target llm-logs database'
114
115
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'mount-points' -d 'Target mount-index database'
115
116
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'lock-status' -d 'Show instance lock status'
@@ -125,7 +125,8 @@ _quilltap_db() {
125
125
  '--blocked[Only characters with vault issues]'
126
126
  '--tables[List tables (low-level)]'
127
127
  '--count[Count rows in table]:table:'
128
- '--repl[Open SQL REPL]'
128
+ '--repl[Open SQL REPL (read-only unless --write)]'
129
+ '--write[Open database read-write (lock-gated; refuses if an instance holds the lock)]'
129
130
  '--llm-logs[Target llm-logs database]'
130
131
  '--mount-points[Target mount-index database]'
131
132
  '--lock-status[Show instance lock status]'