quilltap 4.10.0-dev.18 → 4.10.0-dev.183

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
@@ -100,15 +100,19 @@ quilltap instances default Friday # Make it the fall-thro
100
100
  quilltap instances rename Friday Weekday # Rename, preserving the stored passphrase
101
101
  quilltap instances remove Friday # Unregister
102
102
  quilltap instances restore-key Friday # Rebuild a lost or passphrase-locked .dbkey
103
+ quilltap instances default --clear # Revert to the OS platform default
104
+ quilltap instances list --json # The registry as JSON, for scripts
103
105
  ```
104
106
 
105
- If an instance's `quilltap.dbkey` goes missing — or its passphrase does — `instances restore-key` rebuilds it. The file only *wraps* the pepper; the pepper itself is the database key, so an operator who kept the one printed at first-run setup can get back in. The pepper is read from `ENCRYPTION_MASTER_PEPPER` or prompted for hidden, never passed as a flag, and it is proved against the encrypted databases on disk before anything is written. Run it with the server down — the command refuses while the instance lock is held. Flags: `--passphrase <pass>` / `--no-passphrase` (the new wrapping), `-d, --data-dir <path>`, `--force`, `-y, --yes`.
107
+ If an instance's `quilltap.dbkey` goes missing — or its passphrase does — `instances restore-key` rebuilds it. The file only *wraps* the pepper; the pepper itself is the database key, so an operator who kept the one printed at first-run setup can get back in. The pepper is read from `ENCRYPTION_MASTER_PEPPER` or prompted for hidden, never passed as a flag, and it is proved against the encrypted databases on disk before anything is written. Run it with the server down — the command refuses while the instance lock is held. Flags: `--passphrase <pass>` / `--no-passphrase` (the new wrapping), `-d, --data-dir <path>`, `--force` (proceed when there is no encrypted database to prove against — a fresh or still-plaintext instance), `-y, --yes`. An existing key file is backed up to `quilltap.dbkey.bak-<timestamp>` first, and a registered instance's stored passphrase is updated to match.
106
108
 
107
- Every subcommand then accepts `--instance <name>` in place of `--data-dir`. The registry lives at `<app-support>/Quilltap/instances.json` (mode 0600; e.g. `~/Library/Application Support/Quilltap/instances.json` on macOS). **Resolution precedence:** `--data-dir` > `--instance` > registered default > `QUILLTAP_DATA_DIR` > the OS platform default. Pass the **instance root** (e.g. `~/iCloud/Quilltap/Friday`), not its `data/` subdirectory.
109
+ **It does not re-encrypt character archive bundles.** Those are keyed on the *passphrase*, not the pepper; only the server's Change Passphrase card rewrites them. Bundles made under a passphrase you have just replaced still want the old one.
110
+
111
+ Every subcommand then accepts `--instance <name>` in place of `--data-dir`. The registry lives at `<app-support>/Quilltap/instances.json` (mode 0600; e.g. `~/Library/Application Support/Quilltap/instances.json` on macOS). **Resolution precedence:** `--data-dir` > `--instance` > registered default > `QUILLTAP_DATA_DIR` > the OS platform default. Pass the **instance root** (e.g. `~/iCloud/Quilltap/Friday`), not its `data/` subdirectory — the CLI appends `data/quilltap.db` itself. `instances list --json` emits the registry (`name`, `path`, `hasPassphrase`, `isDefault`) for scripting.
108
112
 
109
113
  ## Database Tool
110
114
 
111
- The encrypted SQLite databases (main, LLM logs, mount index) can be queried directly via `quilltap db`. There are two modes: high-level subcommands that auto-pick the right database and resolve characters/chats/projects by name, and a low-level path for arbitrary SQL.
115
+ The encrypted SQLite databases (main, LLM logs, mount index) can be queried directly via `quilltap db`. They are SQLCipher-encrypted, so the stock `sqlite3` binary **cannot** open them — this is the way in. There are two modes: high-level subcommands that auto-pick the right database and resolve characters/chats/projects by name, and a low-level path for arbitrary SQL.
112
116
 
113
117
  ### Subcommands
114
118
 
@@ -131,10 +135,31 @@ quilltap db logs --tail 20 # Recent LLM logs
131
135
 
132
136
  quilltap db message <id> # Full content of one message
133
137
  quilltap db log <id> [--field request|response|both]
134
- quilltap db memories --character Friday [--about Amy] [--source AUTO]
138
+ quilltap db memories --character Friday [--about Amy] [--source AUTO] [--tier hot|cold]
135
139
  quilltap db characters status # Per-character vault readiness (--id, --diverged, --blocked)
136
140
  ```
137
141
 
142
+ SQLite columns are **camelCase**, mirroring the Zod/TypeScript types — `createdAt`, `updatedAt`, `chatType`, `messageCount`, `projectId`, *not* `created_at`. When in doubt, run `quilltap db schema <table>`.
143
+
144
+ ### Character Archive
145
+
146
+ Archiving prunes a character down to a tombstone and packs everything else into an encrypted `ARCHIVE` bundle in the file library; rehydrating puts it all back at its original ids.
147
+
148
+ ```bash
149
+ quilltap db characters archives # Archived characters + the bundles on the shelf
150
+ quilltap db characters archive Ariadne --write # Archive her (server must be running)
151
+ quilltap db characters rehydrate Ariadne --write # Wake her again
152
+ quilltap db characters export Ariadne --out /tmp # Plaintext .qtap, archived or live
153
+ ```
154
+
155
+ `archives` is read-only, and flags **loose** bundles — survivors of a "keep archived bundles" wipe, which are importable but not rehydratable.
156
+
157
+ `archive` and `rehydrate` run **through the running server's API** (`--port`, default 3000), because the export pipeline and the unlocked passphrase live only in the server process; the server, not the CLI, holds the instance lock for the duration. `--write` is still required as the explicit opt-in. A rehydrate restores the pruned material at its original ids (skip-if-present), clears the tombstone, and queues re-embedding; the bundle stays in the file library afterwards as a spare copy. On failure — wrong-era passphrase, missing bundle, import refusal — the character stays archived and re-running is safe.
158
+
159
+ `export` writes a **plaintext** `.qtap` and takes no `--write`. For an **archived** character it decrypts the bundle straight off the disk, offline (it tries the internal no-passphrase key, then `QUILLTAP_DB_PASSPHRASE`, then prompts) — the only way to reach packed-away mail, photographs, and summaries without rehydrating. For a **live** character it runs the server's export pipeline, so the server must be up.
160
+
161
+ **Pre-emptive, not recovery:** exporting an archive needs an instance that can still decrypt it. It is no help to someone holding only a restored backup and a forgotten passphrase — which is also why changing an instance's passphrase rewrites every archive bundle. A bundle reported left behind by a partial rewrite still wants the old one.
162
+
138
163
  ### Maintenance and Snapshots
139
164
 
140
165
  ```bash
@@ -149,7 +174,7 @@ quilltap db integrity # cipher_integrity_check + integrity
149
174
  quilltap db integrity llm-logs # one DB; exit 0 ok, 1 issues, 2 open failure
150
175
  ```
151
176
 
152
- `backup` and `integrity` are safe to run while the server is up; `optimize` refuses while a live lock is held. Backups default to `<dataDir>/backups/<timestamp>/` and inherit the source's encryption key transparently.
177
+ `backup` and `integrity` are safe to run while the server is up; `optimize` refuses while a live lock is held (see [Locking](#locking)). Backups default to `<dataDir>/backups/<timestamp>/` and inherit the source's encryption key transparently.
153
178
 
154
179
  Most subcommands accept `--json` (for piping) and `--limit N`. Names are case-insensitive; aliases are searched alongside character names. Ambiguous matches print all candidates and exit non-zero.
155
180
 
@@ -166,10 +191,39 @@ quilltap db --llm-logs --tables # Target the LLM logs DB
166
191
  quilltap db --mount-points --tables # Target the mount index DB
167
192
  ```
168
193
 
169
- 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.
194
+ 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. What "held" means, and how to tell a live lock from a stale one, is [below](#locking).
195
+
196
+ Compressed text columns (`chat_messages.content`, `llm_logs.request` / `response` and friends) are stored as BLOBs. A raw query's output decodes them for display, so `SELECT content …` prints text. Inside SQL — `WHERE`, `LIKE`, `substr`, `json_extract` — wrap the column in `qt_text()` to work on the text: `SELECT json_extract(qt_text(response), '$.error') …`.
170
197
 
171
198
  In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
172
199
 
200
+ ## Locking
201
+
202
+ Everything that writes to an instance claims `<dataDir>/quilltap.lock` — the same lockfile the server itself uses — for the duration, and releases it on exit. That is `db --write` (including `db --repl --write`), `db optimize`, `maintenance run`, and `instances restore-key`. All four **refuse while the lock is held**: stop the server first. Read-only work — plain `db`, the `docs` read verbs, `memories`, `logs`, `migrations`, `maintenance status`, `db backup`, `db integrity` — never touches the lock and is safe alongside a running instance.
203
+
204
+ ### The five-minute heartbeat window
205
+
206
+ A lock counts as held until its heartbeat is **five minutes** stale, whether or not the process that set it is still alive. Freshness is the fallback for every environment, not just containers: a PID check is not reliable everywhere, and cleaning a *live* instance's lock is much the worse failure.
207
+
208
+ So for up to five minutes after stopping the server, `--write`, `optimize`, `maintenance run`, `restore-key`, and `--lock-clean` all still refuse. That is correct behaviour, not a stale lock. Wait it out; the next startup reclaims the lock regardless.
209
+
210
+ ```bash
211
+ quilltap db --lock-status # Who holds it, and how old the heartbeat is
212
+ quilltap db --lock-clean # Remove a lock whose heartbeat has gone stale
213
+ ```
214
+
215
+ `--lock-status` shows the heartbeat age, which is the tell. `--lock-clean` says so explicitly rather than muttering about liveness, because that arm is only reached once the PID check has come back dead:
216
+
217
+ ```
218
+ Lock heartbeat is still fresh (82s ago). Cannot clean.
219
+ A lock counts as held until its heartbeat is 5 minutes stale, even if its process has gone.
220
+ Wait it out, or use --lock-override to force.
221
+ ```
222
+
223
+ When a live process really does hold the lock, it refuses with "Lock is held by a live Quilltap process" instead.
224
+
225
+ **`--lock-override` exists and is almost never the right answer** — it defeats the protection the lock provides. Reach for `--lock-status` first.
226
+
173
227
  ## Document Stores (Scriptorium)
174
228
 
175
229
  `quilltap docs` exposes the document-store machinery from the command line. Read-only verbs open the mount-index DB directly and work without the server; write and pipeline verbs talk to the running server via `/api/v1/mount-points/[id]`.
@@ -178,8 +232,10 @@ In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
178
232
  # Read
179
233
  quilltap docs list # All mounts
180
234
  quilltap docs show <mount> # One mount, with counts
235
+ quilltap docs files <mount> [--folder <path>] # Flat file list for a mount
181
236
  quilltap docs ls <mount> [path] [--links] # POSIX-flavoured listing (alias: dir)
182
- quilltap docs tree <mount> [path] # ASCII tree of a folder hierarchy (--depth, --max-nodes)
237
+ quilltap docs ls <mount> [path] -R --sort time -r # Recursive, grouped by folder, sorted by time and reversed
238
+ quilltap docs tree <mount> [path] # ASCII tree of a folder hierarchy (--depth, --max-nodes, --long)
183
239
  quilltap docs read [--rendered] <mount> <path> # File contents → stdout
184
240
  quilltap docs export <mount> <outputDir> # Mount → directory
185
241
  quilltap docs find <pattern> # Substring match on file names (--mount, --ext, --type, --limit)
@@ -203,7 +259,26 @@ quilltap docs rmdir <mount> <path> # Delete an empty
203
259
  quilltap docs mvdir <mount> <fromPath> <toPath> # Rename/move a folder (server-required)
204
260
  ```
205
261
 
206
- 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.
262
+ `ls` flags: `--recursive` / `-R` lists every file below the path, grouped by folder; `--sort name|time|size|links` (default `name`) with `--reverse` / `-r` to flip it; `--links` lists the other mount/path entries under each file that has more than one hard link. `tree` takes `--depth N` (default 20), `--max-nodes N` (default 1000) and `--long`, which is accepted but reserved — it does not yet add columns.
263
+
264
+ 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. `grep --semantic` goes through `POST /api/v1/mount-points?action=semantic-search`, because the embedding provider lives in the server; it defaults to `--top 20`, `--threshold 0.5`, `--port 3000`.
265
+
266
+ ### Addressing documents with `qtap://` URIs
267
+
268
+ Anywhere a verb takes a positional `<mount> <relativePath>` pair — `read`, `write`, `delete`, `mkdir`, `ls`/`dir`, `tree`, `files`, `move`, `copy`, `link`, `rmdir`, `mvdir` — you may pass a single `qtap://…` URI in its place:
269
+
270
+ ```bash
271
+ quilltap docs read qtap://notes/today.md
272
+ quilltap docs move qtap://drafts/foo.md qtap://notes/2026/foo.md
273
+ quilltap docs grep --mount qtap://notes/ "TODO"
274
+ quilltap docs find --uri Manifesto
275
+ ```
276
+
277
+ The URI authority is matched name-first, UUID as fallback — the same rule as a bare `<mount>` (`qtap://<store name>/…` or `qtap://<uuid>/…`). Two-target verbs (`move` / `copy` / `link` / `mvdir`) take either two `qtap://` URIs or the four legacy positionals; `find` and `grep` take one via `--mount`.
278
+
279
+ **CLI limitation:** the CLI addresses document stores only. `qtap://self/…` needs a character context, and there is none at a shell prompt, so it is rejected with guidance; `qtap://project/…` and `qtap://general/…` are likewise not CLI-addressable — pass a store name or UUID instead.
280
+
281
+ **Emitting URIs:** `--json` output for `find`, `grep`, `ls`, `files`, and `tree` carries a `uri` field on every row or node. `--uri` switches the text output of `find`, `grep`, and `files` to show the canonical `qtap://` URI as the locator (name form, UUID when the store name is ambiguous).
207
282
 
208
283
  ### `--base64` flag
209
284
 
@@ -213,28 +288,77 @@ Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous na
213
288
 
214
289
  ### `link`, `rmdir`, `mvdir`
215
290
 
291
+ **`link` vs `copy`:** `docs link` makes two addresses into one document — it shares the content row *and* enrols both link rows in a `linkGroupId`, so a later write through either path repoints both and re-chunks the sibling. `docs copy` produces an independent document that merely shares a deduplicated content row until the first write. The `links` column in `ls` counts group members, not rows that happen to share identical bytes.
292
+
216
293
  `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`.
217
294
 
218
295
  `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`).
219
296
 
220
297
  `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`).
221
298
 
299
+ ### Docker binds
300
+
301
+ `quilltap docs docker-mounts` reports the bind mounts an instance's filesystem and Obsidian stores need in order to be reachable inside a container. Binds are **path-identical** (`-v /host/vault:/host/vault`), so the `basePath` recorded in the database resolves the same inside and out. Stores sharing a path collapse to a single bind, paths nested inside another bind are dropped, and a path that does not exist is **skipped** rather than handed to Docker to fabricate as an empty directory. It warns about macOS paths outside Docker Desktop's default shares and about a Linux uid mismatch, and refuses on Windows, where path-identical binds are not possible. `--format args` puts only the flags on stdout and all advice on stderr, which is what makes it pipeable into a `docker run`.
302
+
303
+ ## Sync a Store to a Directory
304
+
305
+ `quilltap sync <store|qtap://store/> <path>` mirrors a **database-backed** document store and a directory on disk in both directions. Edit a file in your own editor and the next run carries it into the store; edit it in the Scriptorium and the next run carries it out.
306
+
307
+ ```bash
308
+ quilltap sync Lore ~/Documents/lore --dry-run # plan only
309
+ quilltap sync Lore ~/Documents/lore # apply
310
+ quilltap sync Lore ~/Documents/lore --prefer disk
311
+ ```
312
+
313
+ ```text
314
+ --dry-run Plan and print; change nothing on either side
315
+ --direction <which> both (default), to-disk, or to-store
316
+ --prefer <which> newer (default), store, or disk — resolves conflicts
317
+ --no-delete Never propagate a deletion
318
+ --no-manifest Ignore .quilltap-sync.json (first-run rules every time)
319
+ --json Machine-readable plan and results
320
+ -p, --port <n> Server port (default 3000)
321
+ -i, -d, --passphrase The usual instance plumbing (used only to resolve <store>)
322
+ ```
323
+
324
+ Compares by SHA-256 first and modification time second, so equal bytes with unequal clocks are re-stamped rather than re-copied. The side that changed wins; when both changed since the last run it is a `conflict` and nothing happens. After a content action both sides carry the winner's `lastModified` and the **older** of the two `createdAt`s. Deletions propagate only when `.quilltap-sync.json` — a manifest the verb keeps in the directory — proves the entry was there at the last run; on a first run an entry present on one side is created on the other, never deleted.
325
+
326
+ Files and folders whose names begin with a dot are **invisible in both directions**, the manifest being the one exception. A binary's description travels as `<file>.description.md` beside it. Bytes are preserved verbatim: a `.png` pushed from disk stays a `.png`, unlike a Scriptorium upload. Chunks and embedding vectors are never touched by the sync — the store's own post-write hooks re-index.
327
+
328
+ Exit codes: `0` clean, `1` error or failed action, `2` unresolved conflict; `--dry-run` uses the same codes. Report lines go to stdout, warnings and the summary to stderr.
329
+
330
+ Server-required (as `docs write` already is for database stores), and the path is resolved **on the server** — under Docker it must sit inside a bind mount (`quilltap docs docker-mounts`). Refused for a filesystem or Obsidian store (one pointed at its own `basePath`), an archived character's vault, a store mid-conversion or mid-scan, a second concurrent run, and a manifest belonging to another store.
331
+
332
+ A character vault's **keystone files** — `properties.json`, the five required `.md` files, and `Wardrobe/instructions.md` — are never deleted from the store by a sync. The planner reports a `conflict` instead.
333
+
222
334
  ## Memories
223
335
 
224
- `quilltap memories` exposes the same Commonplace Book that each character carries — searchable, sortable, graphable, but never writable. All verbs open the main encrypted DB read-only.
336
+ `quilltap memories` exposes the same Commonplace Book that each character carries — searchable, sortable, graphable. All verbs open the main encrypted DB read-only; the two that change anything (`maintain`, `validate --repair --write`) ask the running server to do the work.
225
337
 
226
338
  ```bash
227
339
  quilltap memories ls # All holders, default sort: reinforcedImportance DESC
228
340
  quilltap memories ls --character Ariadne --sort created --limit 10 # One holder, newest first
229
341
  quilltap memories find "concrete examples" # Substring match on summary (--in content|both)
230
342
  quilltap memories grep -i --max 3 --context 1 "concrete examples" # Pattern search inside content, with snippets
343
+ quilltap memories grep -l "concrete examples" # Matching memory IDs only, one per line (--paths-only)
231
344
  quilltap memories show <id|prefix> [--depth N] [--no-related] # Full record + related-memory neighbourhood
232
345
  quilltap memories tree <id|prefix> [--depth N] [--max-nodes N] # ASCII walk of the bidirectional related-memory graph
233
- quilltap memories status [--character <name|id>] # Per-holder rollup + dangling-edge check
346
+ quilltap memories status [--character <name|id>] [--port <n>] # Per-holder rollup, maintenance ledger + dangling-edge check
347
+ quilltap memories validate [--character <name|id>] [--list] # Graph health check; exit 1 if any dangling link remains
348
+ quilltap memories validate --repair [--write] # Rehearse (or, with --write, apply) the server's graph repair
234
349
  quilltap memories grep --semantic --character Ariadne "the argument" # Embedding search (server required, one holder)
350
+ quilltap memories maintain --character Ariadne --dry-run # Rehearse every maintenance phase (server required)
351
+ quilltap memories maintain --character Ariadne --phases consolidate # Queue a consolidation-only maintenance job
352
+ quilltap memories maintain --character all [--phases demote,retire] # Queue maintenance for every character
235
353
  ```
236
354
 
237
- 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.
355
+ Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source` (AUTO / MANUAL / CONSOLIDATED), `--tier` (hot / cold; both by default, cold rows are starred in the `src` column), `--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` / `--reverse` to flip it, `--limit N` (default 50) and `--full-titles` to stop chat titles being truncated to 32 characters) apply to `ls`, `find`, and `grep`. `grep -l` / `--paths-only` prints only the IDs of matching memories, one per line. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. `validate` is `status`'s terse twin — read-only; it counts dangling `relatedMemoryIds` edges, dangling `consolidatedFrom` members (digests naming notes that are gone), dangling `supersededById` pointers, and rows with no embedding. It exits 1 if any dangling link remains (a missing embedding only warns), and `--list` prints the offending IDs. `grep --semantic` defaults to `--top 20`, `--threshold 0.5`, and scopes to **one holder at a time**: `--character all` is rejected. The legacy `quilltap db memories --character <name>` verb remains, now with `--tier hot|cold` (rows with no tier count as hot).
356
+
357
+ Verbs that call the server (`grep --semantic`, `status`'s ledger, `maintain`, `validate --repair`) take `--port`; without it they use the port recorded in the `--instance` / `--data-dir` instance's lock, else 3000.
358
+
359
+ `maintain` runs the memory maintenance job by hand: `fold-near-duplicates`, `consolidate`, `demote`, `retire`, `mirror`, always in that order. `--character <name|id|all>` is required (`all` is every character you own); `--phases a,b,…` picks a subset (checked by the server, which answers an unknown name with a 400; naming `consolidate` brings `mirror` along, since the Commonplace mirror follows consolidation). The CLI resolves the character locally and asks the running server — the model, the embeddings and the only database writer live there. With `--dry-run` the server rehearses the run and writes nothing: the CLI prints each phase's status (ran / skipped, and why), then the fold groups, the consolidation clusters (member notes, proposed digests, standalones, contradictions, run stats), the demotion arithmetic (cap, hot total, demotable, protected, excess, demoted), the retire count and the mirror subjects. A dry run over `all` gives one table row per character and may not include `consolidate` (each would call the model). Without `--dry-run` the server queues one `MEMORY_MAINTENANCE` job per character and the CLI prints the job ids; manual runs ignore the master `enabled` switches. `--max N` caps clusters consolidated (`maxClustersPerRun`, 1–1000; a dry run defaults to 10, at most 40), `--threshold 0..1` overrides the cluster similarity (neural embedding profiles), `--cap N` overrides the hot-row cap for demotion (100–100000). `--json` prints the raw API payload. There is no `consolidate` verb any more: use `maintain --phases consolidate`.
360
+
361
+ `validate --repair` asks the running server to repair what `validate` finds. Without `--write` it is a dry run that prints what would be repaired and changes nothing; with `--write` the server scrubs dangling `relatedMemoryIds`, `consolidatedFrom` and `supersededById` links and queues embedding jobs for rows with none (up to 2000 per run; it needs a default embedding profile). As with `db characters archive`, the write runs inside the server, which holds the instance lock, so the server must be up. `status` also shows the character's maintenance ledger — when each phase last ran and whether the last demotion was effective — when it can reach the running server, and the last three maintenance job rows either way.
238
362
 
239
363
  ## Memory Extraction Dry-Run
240
364
 
@@ -245,7 +369,9 @@ quilltap memory-diff <chatId> --instance Friday # Report to the current
245
369
  quilltap memory-diff <chatId> --out /tmp/diff --concurrency 8
246
370
  ```
247
371
 
248
- Needs a running server (`--port`, default 3000) to reach the extraction pipeline. `--out <dir>` sets the report destination (default: cwd); `--concurrency N` bounds parallel turns (default 4, max 32).
372
+ Needs a running server to reach the extraction pipeline: pass `--port`, or name the server's instance with `--instance`/`--data-dir` and the port is read from its lock file (see [Finding the server](#finding-the-server)); otherwise 3000. `--out <dir>` sets the report destination (default: cwd); `--concurrency N` bounds parallel turns (default 4, max 32).
373
+
374
+ The dry run is the live pipeline with nothing written: every turn, then both fold passes (`other-fold`, `episode-fold`) over the history a regenerate would rebuild, autonomous rooms included. Each re-extracted candidate records `extractionPass` and `decision` — what admission would do with it (`insert`, `reinforce`, `absorb`, …). Admission embeds each candidate to decide, so a dry run makes embedding calls.
249
375
 
250
376
  ## Recall Replay
251
377
 
@@ -255,9 +381,45 @@ Needs a running server (`--port`, default 3000) to reach the extraction pipeline
255
381
  quilltap recall-replay <chatId> # Replay the last turn
256
382
  quilltap recall-replay <chatId> --turn 42 # Replay at interchange 42 (its own clock)
257
383
  quilltap recall-replay <chatId> --json # Raw JSON for scripting
384
+ quilltap recall-replay <chatId> --memory-budget 4000 # Budget-sized dynamic head
385
+ quilltap recall-replay <chatId> --turn 42 --json > t42.json
386
+ quilltap recall-replay <chatId> --turn 42 --signals-from t42.json --as-of \
387
+ --tuning '{"boostGateAbs":0.45,"boostGateMargin":0.15,"boostGateRamp":0.1}'
258
388
  ```
259
389
 
260
- Flags: `--turn <n>` (default: last), `--char <characterId>` (default: first LLM-controlled participant), `--limit <n>` (default 25), `--port <n>` (default 3000), `--json`.
390
+ Flags: `--turn <n>` (default: last), `--char <characterId>` (default: first LLM-controlled participant), `--limit <n>` (default: the larger of 25 and the new path's head size + 10), `--memory-budget <tokens>` (positive integer; the new path's selected rows then follow the budget-sized dynamic head, which scales with the model's memory budget, while the old path keeps the historical fixed 5-entry head; each path header prints its head size), `--instance <name>` / `--data-dir <path>` (find the server's port, see [Finding the server](#finding-the-server)), `--port <n>` (default: from the instance lock, else 3000), `--json`.
391
+
392
+ Tuning and comparable runs:
393
+
394
+ - `--tuning '<json>'` / `--tuning-file <path>` — retuning constants for the new path only (the old path stays the reference): `boostGateAbs`, `boostGateMargin`, `boostGateRamp` (relevance gate on boosts), `boostCap`, `freshBypassesGate`, `windowBypassesGate`, `multipliers` (`{ "freshEvent24h": 1.3, … }`), `specificAnchors`, `anchorMinHits`, `anchorOrder`, `backgroundReserve`. Unknown keys are rejected. The header's `Tuning` line shows what is active.
395
+ - `--signals-from <replay.json>` — reuse the signals saved in an earlier `--json` replay of the same chat and turn. The distillation is a cheap-LLM call and words the same turn differently each time, so runs you mean to compare should share it.
396
+ - `--as-of` — search only memories created before the turn's opening message, with weights decayed to the turn's own clock: the corpus as it stood then. The header's `Harness` line shows the cutoff and how many later memories were left out.
397
+
398
+ The server memoises the replay's embeddings by text until it restarts, so repeat runs embed identical vectors.
399
+
400
+ After the two tables the replay prints:
401
+
402
+ - **TEXT FALLBACK** — what the new path returns with no usable vector (the text strategy over the same question).
403
+ - **STAGE TRACE** — one line per recall stage: `query` (distilled or pinned, embedding calls), `gather` / `hydrate` / `score` / `select` for the head search (pool size, floor, window mode, expansion, the cut), and `assemble` per pool (what the archive held and its budget cut, how many pool rows the archive removed from the head, what the inter-character section took and skipped as already delivered).
404
+ - **Ring** — the anti-repetition ring the replay ranked under: the one in force at that turn, rebuilt from the `[Recall:ring]` lines each reply's `debugMemoryLogs` carries, or the chat's current ring for a chat older than those records.
405
+
406
+ `--json` adds `fallbackPath`, `trace`, `pools`, `ring` and `ringSource` to the result.
407
+
408
+ ## Anchor Probe
409
+
410
+ `quilltap anchor-probe <characterId>` measures whether the episodic anchor line embedded with each memory suppresses reinforcement at admission. It re-embeds the character's most recent memories with and without the anchor line, compares each against older rows, asks admission's own decider (the same subject, kind, source and date-guard rules a new memory meets) what it would do each way, and reports how many rows would fold into an older one and how many would be absorbed as near-duplicates, plus a per-row table (rows that would fold only without anchors are highlighted). Read-only, but it costs embedding calls (roughly up to 7 per sampled row).
411
+
412
+ ```bash
413
+ quilltap anchor-probe <characterId> # Sample the 50 most recent memories
414
+ quilltap anchor-probe <characterId> --limit 20 # Smaller, cheaper sample
415
+ quilltap anchor-probe <characterId> --json # Raw JSON for scripting
416
+ ```
417
+
418
+ Flags: `--limit <n>` (1–200, default 50), `--instance <name>` / `--data-dir <path>` (find the server's port, see [Finding the server](#finding-the-server)), `--port <n>` (default: from the instance lock, else 3000), `--json`.
419
+
420
+ ### Finding the server
421
+
422
+ `memory-diff`, `recall-replay` and `anchor-probe` call a running server over HTTP. The server records the port it listens on in its instance lock (`<dataDir>/data/quilltap.lock`, field `port`; shown by `quilltap db --lock-status`). Resolution order: an explicit `--port` wins; otherwise, if `--instance` or `--data-dir` is given and that lock is live and records a port, that port is used (a dim note says so); otherwise 3000. Giving both `--instance` and `--data-dir` is an error. `--passphrase` is accepted by all three for uniformity; `recall-replay` and `anchor-probe` ignore it. Locks written before this field existed carry no port, so restart the server once to record it.
261
423
 
262
424
  ## Maintenance & Cleanup
263
425
 
@@ -322,7 +484,13 @@ quilltap themes create sunset # Scaffold a new theme
322
484
  quilltap themes search "dark" # Search registries
323
485
  quilltap themes update # Check for theme updates
324
486
  quilltap themes registry list # List configured registries
325
- quilltap themes registry add <url> # Add a registry source
487
+ quilltap themes registry add <url> # Add a registry source (--key <pubkey>, --name <name>)
488
+ quilltap themes registry remove <name> # Remove a registry source
489
+ quilltap themes registry refresh # Refresh all registry indexes
490
+
491
+ # For registry operators
492
+ quilltap themes registry keygen [--output <dir>] # Generate an Ed25519 keypair
493
+ quilltap themes registry sign <dir> --key <private> # Sign a registry directory
326
494
  ```
327
495
 
328
496
  ## Shell Completion
@@ -401,7 +569,7 @@ Fish picks new completion files up automatically — no shell restart needed.
401
569
 
402
570
  Completions **parse the line rather than counting words**, so a flag typed anywhere the CLI itself accepts one does not derail them: `quilltap docs --instance Friday <TAB>` still offers the `docs` verbs. bash and zsh also reuse the `-i` / `-d` / `--passphrase` already on the line when looking store names up, so the names offered come from the instance you are addressing rather than the default one. fish completes `--mount` but not the positionals, and always reads the default instance.
403
571
 
404
- Dynamic completions shell out to `quilltap`'s own subcommands. If the active instance is encrypted and no passphrase is reachable, the completion silently returns nothing rather than prompting in the middle of a tab.
572
+ Dynamic completions shell out to `quilltap`'s own subcommands — `quilltap instances list --names-only` and `quilltap docs list --names-only`, which print one name per line and are handy in scripts too. If the active instance is encrypted and no passphrase is reachable, the completion silently returns nothing rather than prompting in the middle of a tab.
405
573
 
406
574
  ## Requirements
407
575
 
package/bin/quilltap.js CHANGED
@@ -11,8 +11,10 @@ const {
11
11
  printDefaultInstanceHint,
12
12
  promptPassphrase,
13
13
  loadDbKey,
14
+ openEncryptedDb,
14
15
  } = require('../lib/db-helpers');
15
16
  const { resolveInstance } = require('../lib/instances');
17
+ const { decodeCompressedTextInRows } = require('../lib/text-codec');
16
18
  const { resolveModuleDir, ensureNativeModules, ensureDatabaseNativeModule } = require('../lib/native-modules');
17
19
 
18
20
  const PACKAGE_DIR = path.resolve(__dirname, '..');
@@ -92,6 +94,7 @@ Subcommands:
92
94
  db Query encrypted databases
93
95
  themes Manage theme bundles
94
96
  docs Inspect, read, and export document mounts
97
+ sync <store> <path> Mirror a database-backed store to a directory
95
98
  memories Search, browse, and graph memories
96
99
  instances Register / inspect named Quilltap instances
97
100
  logs Tail or print an instance log file
@@ -100,6 +103,7 @@ Subcommands:
100
103
  file-verify Force-download cloud-evicted data files (iCloud, etc.)
101
104
  memory-diff <chatId> Dump existing memories and dry-run re-extraction for a chat
102
105
  recall-replay <chatId> Replay a turn's memory recall, old vs episodic ranking
106
+ anchor-probe <characterId> Probe whether episodic anchor lines suppress reinforcement at admission
103
107
  completion <shell> Generate a shell completion script (bash / zsh / fish)
104
108
 
105
109
  Options:
@@ -486,6 +490,18 @@ function verifyPidIsNode(pid, expectedArgv0) {
486
490
 
487
491
  const HEARTBEAT_FRESH_MS = 5 * 60 * 1000;
488
492
 
493
+ /**
494
+ * The freshness window in words, derived from the constant the check actually
495
+ * uses — so the sentence `--lock-clean` prints cannot drift away from the
496
+ * behaviour it describes.
497
+ */
498
+ function describeFreshWindow() {
499
+ const seconds = Math.round(HEARTBEAT_FRESH_MS / 1000);
500
+ if (seconds % 60 !== 0) return `${seconds} seconds`;
501
+ const minutes = seconds / 60;
502
+ return minutes === 1 ? '1 minute' : `${minutes} minutes`;
503
+ }
504
+
489
505
  /**
490
506
  * Decide whether a lock record belongs to a live process.
491
507
  *
@@ -576,6 +592,9 @@ function handleLockCommand(dataDir, opts) {
576
592
  console.log(` Environment: ${lock.environment || 'unknown'}`);
577
593
  console.log(` Process: ${lock.processTitle || 'unknown'}`);
578
594
  console.log(` Started: ${lock.startedAt || 'unknown'}`);
595
+ if (Number.isInteger(lock.port)) {
596
+ console.log(` Port: ${lock.port}`);
597
+ }
579
598
 
580
599
  // Show heartbeat age if available
581
600
  if (lock.lastHeartbeat) {
@@ -628,9 +647,17 @@ function handleLockCommand(dataDir, opts) {
628
647
  console.log('Stop the running instance first, or use --lock-override to force.');
629
648
  process.exit(1);
630
649
  } else if (heartbeatFresh) {
650
+ // Reached only when `alive` is false — the arm above has already claimed
651
+ // every confirmed-live case — so this must not assert that anything is
652
+ // running. Freshness alone is the deliberate fallback (bug 126) for
653
+ // environments where PID checks are unreliable; the refusal is right,
654
+ // and what it says is simply what was tested.
631
655
  const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
632
- console.log(`Lock is still being refreshed (heartbeat ${ageStr} ago) — its holder is alive. Cannot clean.`);
633
- console.log('Stop the running instance first, or use --lock-override to force.');
656
+ console.log(`Lock heartbeat is still fresh (${ageStr} ago). Cannot clean.`);
657
+ console.log(
658
+ `A lock counts as held until its heartbeat is ${describeFreshWindow()} stale, even if its process has gone. ` +
659
+ 'Wait it out, or use --lock-override to force.'
660
+ );
634
661
  process.exit(1);
635
662
  } else if (alive && !isNode) {
636
663
  console.log(`Lock references PID ${lock.pid} which is alive but does NOT look like a Quilltap process.`);
@@ -742,7 +769,10 @@ Subcommands (high-level shortcuts; auto-pick the right database):
742
769
  message <id> Full content of a single chat message
743
770
  log <id> Full request/response of a single LLM log
744
771
  memories --character <id> Memories held by a character
745
- (flags: --about <id|name> --source AUTO|MANUAL)
772
+ (flags: --about <id|name>
773
+ --source AUTO|MANUAL|CONSOLIDATED
774
+ --tier hot|cold (NULL tier counts as hot)
775
+ --limit N)
746
776
  characters status Per-character vault readiness report:
747
777
  flag value, vault present, single-file count,
748
778
  Prompts/ and Scenarios/ folder counts, and any
@@ -961,9 +991,10 @@ async function dbCommand(args) {
961
991
  }
962
992
 
963
993
  let dbFilename;
964
- if (useLlmLogs) dbFilename = 'quilltap-llm-logs.db';
965
- else if (useMountPoints) dbFilename = 'quilltap-mount-index.db';
966
- else dbFilename = 'quilltap.db';
994
+ let dbFriendlyName;
995
+ if (useLlmLogs) { dbFilename = 'quilltap-llm-logs.db'; dbFriendlyName = 'LLM logs database'; }
996
+ else if (useMountPoints) { dbFilename = 'quilltap-mount-index.db'; dbFriendlyName = 'mount index database'; }
997
+ else { dbFilename = 'quilltap.db'; dbFriendlyName = 'main database'; }
967
998
  const dbPath = path.join(dataDir, dbFilename);
968
999
 
969
1000
  if (!fs.existsSync(dbPath)) {
@@ -993,27 +1024,16 @@ async function dbCommand(args) {
993
1024
  }
994
1025
  }
995
1026
 
996
- // Open database — prefer SQLCipher-capable build
997
- let Database;
1027
+ // Open through the one shared opener. It picks the SQLCipher-capable build,
1028
+ // keys the connection, verifies it, and — the reason the low-level path may
1029
+ // not roll its own — registers qt_text(). Without it raw SQL cannot read
1030
+ // inside a compressed text column, and a --write to chat_messages fails
1031
+ // outright because the search-index triggers call it (bug 162).
1032
+ let db;
998
1033
  try {
999
- Database = require('better-sqlite3-multiple-ciphers');
1000
- } catch {
1001
- Database = require('better-sqlite3');
1002
- }
1003
- const db = new Database(dbPath, { readonly: !writable });
1004
-
1005
- if (pepper) {
1006
- const keyHex = Buffer.from(pepper, 'base64').toString('hex');
1007
- db.pragma(`key = "x'${keyHex}'"`);
1008
- }
1009
-
1010
- try {
1011
- // Verify database is readable
1012
- db.prepare('SELECT 1').get();
1034
+ db = openEncryptedDb(dbPath, pepper, { readonly: !writable, friendlyName: dbFriendlyName });
1013
1035
  } catch (err) {
1014
- console.error(`Cannot open database: ${err.message}`);
1015
- console.error('The database may be encrypted with a different key, or the .dbkey file may be missing.');
1016
- db.close();
1036
+ console.error(err.message);
1017
1037
  process.exit(1);
1018
1038
  }
1019
1039
 
@@ -1030,6 +1050,8 @@ async function dbCommand(args) {
1030
1050
  const stmt = db.prepare(sql);
1031
1051
  if (stmt.reader) {
1032
1052
  const rows = stmt.all();
1053
+ // Compressed text columns arrive as Buffers; print them as text (bug 173).
1054
+ decodeCompressedTextInRows(rows);
1033
1055
  if (asJson) {
1034
1056
  console.log(JSON.stringify(rows, null, 2));
1035
1057
  } else if (rows.length === 0) {
@@ -1098,6 +1120,7 @@ async function dbCommand(args) {
1098
1120
  const stmt = db.prepare(trimmed);
1099
1121
  if (stmt.reader) {
1100
1122
  const rows = stmt.all();
1123
+ decodeCompressedTextInRows(rows);
1101
1124
  if (rows.length === 0) console.log('(no results)');
1102
1125
  else console.table(rows);
1103
1126
  } else {
@@ -1154,7 +1177,7 @@ async function dbCommand(args) {
1154
1177
  // to the subcommand. Each subcommand parses these flags position-independently,
1155
1178
  // so they behave the same before or after the verb.
1156
1179
  const SUBCOMMANDS = new Set([
1157
- 'db', 'themes', 'docs', 'memories', 'instances', 'memory-diff', 'recall-replay', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
1180
+ 'db', 'themes', 'docs', 'sync', 'memories', 'instances', 'memory-diff', 'recall-replay', 'anchor-probe', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
1158
1181
  ]);
1159
1182
  // Global flags that consume the following token as their value.
1160
1183
  const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
@@ -1193,6 +1216,12 @@ if (subName === 'db') {
1193
1216
  } else if (subName === 'docs') {
1194
1217
  const { docsCommand } = require('../lib/docs-commands');
1195
1218
  docsCommand(subArgs);
1219
+ } else if (subName === 'sync') {
1220
+ const { syncCommand } = require('../lib/sync-command');
1221
+ syncCommand(subArgs).catch(err => {
1222
+ console.error(`Error: ${err.message}`);
1223
+ process.exit(1);
1224
+ });
1196
1225
  } else if (subName === 'memories') {
1197
1226
  const { memoriesCommand } = require('../lib/memories-commands');
1198
1227
  memoriesCommand(subArgs).catch(err => {
@@ -1220,6 +1249,12 @@ if (subName === 'db') {
1220
1249
  console.error(`Error: ${err.message}`);
1221
1250
  process.exit(1);
1222
1251
  });
1252
+ } else if (subName === 'anchor-probe') {
1253
+ const { anchorProbeCommand } = require('../lib/anchor-probe-command');
1254
+ anchorProbeCommand(subArgs).catch(err => {
1255
+ console.error(`Error: ${err.message}`);
1256
+ process.exit(1);
1257
+ });
1223
1258
  } else if (subName === 'completion') {
1224
1259
  const { completionCommand } = require('../lib/completion-commands');
1225
1260
  completionCommand(subArgs).catch(err => {
@@ -108,6 +108,7 @@ describe('every subcommand has its own completion arm', () => {
108
108
  const HELP_SOURCES = {
109
109
  db: ['bin/quilltap.js', 'printDbHelp'],
110
110
  docs: ['lib/docs-commands.js', 'printDocsHelp'],
111
+ sync: ['lib/sync-command.js', 'printSyncHelp'],
111
112
  memories: ['lib/memories-commands.js', 'printMemoriesHelp'],
112
113
  themes: ['lib/theme-commands.js', 'printHelp'],
113
114
  instances: ['lib/instances-commands.js', 'printHelp'],
@@ -117,6 +118,7 @@ const HELP_SOURCES = {
117
118
  'file-verify': ['lib/file-verify-commands.js', 'printHelp'],
118
119
  'memory-diff': ['lib/memory-diff-command.js', 'printMemoryDiffHelp'],
119
120
  'recall-replay': ['lib/recall-replay-command.js', 'printRecallReplayHelp'],
121
+ 'anchor-probe': ['lib/anchor-probe-command.js', 'printAnchorProbeHelp'],
120
122
  completion: ['lib/completion-commands.js', 'printCompletionHelp'],
121
123
  };
122
124