quilltap 4.10.0-dev.99 → 4.11.0-dev.106

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
@@ -135,7 +135,7 @@ quilltap db logs --tail 20 # Recent LLM logs
135
135
 
136
136
  quilltap db message <id> # Full content of one message
137
137
  quilltap db log <id> [--field request|response|both]
138
- quilltap db memories --character Friday [--about Amy] [--source AUTO]
138
+ quilltap db memories --character Friday [--about Amy] [--source AUTO] [--tier hot|cold]
139
139
  quilltap db characters status # Per-character vault readiness (--id, --diverged, --blocked)
140
140
  ```
141
141
 
@@ -193,7 +193,7 @@ quilltap db --mount-points --tables # Target the mount index DB
193
193
 
194
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
195
 
196
- Compressed text columns (`chat_messages.content`, `llm_logs.request` / `response` and friends) are stored as BLOBs. Wrap them in `qt_text()` to read the text: `SELECT qt_text(content) …`.
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') …`.
197
197
 
198
198
  In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
199
199
 
@@ -234,7 +234,8 @@ quilltap docs list # All mounts
234
234
  quilltap docs show <mount> # One mount, with counts
235
235
  quilltap docs files <mount> [--folder <path>] # Flat file list for a mount
236
236
  quilltap docs ls <mount> [path] [--links] # POSIX-flavoured listing (alias: dir)
237
- 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)
238
239
  quilltap docs read [--rendered] <mount> <path> # File contents → stdout
239
240
  quilltap docs export <mount> <outputDir> # Mount → directory
240
241
  quilltap docs find <pattern> # Substring match on file names (--mount, --ext, --type, --limit)
@@ -258,6 +259,8 @@ quilltap docs rmdir <mount> <path> # Delete an empty
258
259
  quilltap docs mvdir <mount> <fromPath> <toPath> # Rename/move a folder (server-required)
259
260
  ```
260
261
 
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
+
261
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`.
262
265
 
263
266
  ### Addressing documents with `qtap://` URIs
@@ -330,21 +333,33 @@ A character vault's **keystone files** — `properties.json`, the five required
330
333
 
331
334
  ## Memories
332
335
 
333
- `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 three that change anything (`maintain`, `validate --repair --write`, `keystone --write`) ask the running server to do the work.
334
337
 
335
338
  ```bash
336
339
  quilltap memories ls # All holders, default sort: reinforcedImportance DESC
337
340
  quilltap memories ls --character Ariadne --sort created --limit 10 # One holder, newest first
338
341
  quilltap memories find "concrete examples" # Substring match on summary (--in content|both)
339
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)
340
344
  quilltap memories show <id|prefix> [--depth N] [--no-related] # Full record + related-memory neighbourhood
341
345
  quilltap memories tree <id|prefix> [--depth N] [--max-nodes N] # ASCII walk of the bidirectional related-memory graph
342
- quilltap memories status [--character <name|id>] # Per-holder rollup + dangling-edge check
343
- quilltap memories validate [--character <name|id>] [--list] # Dangling-edge health check; exit 1 if any remain
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
349
+ quilltap memories keystone <id|prefix> [--off] [--write] # Rehearse (or, with --write, apply) marking a keystone — a standing fact
344
350
  quilltap memories grep --semantic --character Ariadne "the argument" # Embedding search (server required, one holder)
351
+ quilltap memories maintain --character Ariadne --dry-run # Rehearse every maintenance phase (server required)
352
+ quilltap memories maintain --character Ariadne --phases consolidate # Queue a consolidation-only maintenance job
353
+ quilltap memories maintain --character all [--phases demote,retire] # Queue maintenance for every character
345
354
  ```
346
355
 
347
- 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. `validate` is `status`'s terse twin — read-only, exit 1 if any dangling related-memory edge remains, `--list` to print the offending source IDs and their dangling targets. `grep --semantic` defaults to `--top 20`, `--threshold 0.5`, `--port 3000`, and scopes to **one holder at a time**: `--character all` is rejected. The legacy `quilltap db memories --character <name>` verb remains undisturbed.
356
+ 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), `--keystone` (keystones only; a keystone's summary is prefixed `◆` in tables, and `show` prints a `Keystone:` line), `--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).
357
+
358
+ Verbs that call the server (`grep --semantic`, `status`'s ledger, `maintain`, `validate --repair`, `keystone --write`) take `--port`; without it they use the port recorded in the `--instance` / `--data-dir` instance's lock, else 3000.
359
+
360
+ `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`.
361
+
362
+ `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.
348
363
 
349
364
  ## Memory Extraction Dry-Run
350
365
 
@@ -355,7 +370,9 @@ quilltap memory-diff <chatId> --instance Friday # Report to the current
355
370
  quilltap memory-diff <chatId> --out /tmp/diff --concurrency 8
356
371
  ```
357
372
 
358
- 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).
373
+ 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).
374
+
375
+ 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.
359
376
 
360
377
  ## Recall Replay
361
378
 
@@ -365,9 +382,45 @@ Needs a running server (`--port`, default 3000) to reach the extraction pipeline
365
382
  quilltap recall-replay <chatId> # Replay the last turn
366
383
  quilltap recall-replay <chatId> --turn 42 # Replay at interchange 42 (its own clock)
367
384
  quilltap recall-replay <chatId> --json # Raw JSON for scripting
385
+ quilltap recall-replay <chatId> --memory-budget 4000 # Budget-sized dynamic head
386
+ quilltap recall-replay <chatId> --turn 42 --json > t42.json
387
+ quilltap recall-replay <chatId> --turn 42 --signals-from t42.json --as-of \
388
+ --tuning '{"boostGateAbs":0.45,"boostGateMargin":0.15,"boostGateRamp":0.1}'
389
+ ```
390
+
391
+ 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`.
392
+
393
+ Tuning and comparable runs:
394
+
395
+ - `--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`, `innerCircleShare` (share of the frozen archive the group inner circle may take, default `0.333…`), `innerCircleAllowance` (digests per circle member by tier, tightest group first, default `[3, 2, 1]`). Unknown keys are rejected. The header's `Tuning` line shows what is active.
396
+ - `--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.
397
+ - `--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.
398
+
399
+ The server memoises the replay's embeddings by text until it restarts, so repeat runs embed identical vectors.
400
+
401
+ After the two tables the replay prints:
402
+
403
+ - **TEXT FALLBACK** — what the new path returns with no usable vector (the text strategy over the same question).
404
+ - **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).
405
+ - **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.
406
+
407
+ `--json` adds `fallbackPath`, `trace`, `pools`, `ring` and `ringSource` to the result.
408
+
409
+ ## Anchor Probe
410
+
411
+ `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).
412
+
413
+ ```bash
414
+ quilltap anchor-probe <characterId> # Sample the 50 most recent memories
415
+ quilltap anchor-probe <characterId> --limit 20 # Smaller, cheaper sample
416
+ quilltap anchor-probe <characterId> --json # Raw JSON for scripting
368
417
  ```
369
418
 
370
- Flags: `--turn <n>` (default: last), `--char <characterId>` (default: first LLM-controlled participant), `--limit <n>` (default 25), `--port <n>` (default 3000), `--json`.
419
+ 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`.
420
+
421
+ ### Finding the server
422
+
423
+ `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.
371
424
 
372
425
  ## Maintenance & Cleanup
373
426
 
@@ -432,7 +485,13 @@ quilltap themes create sunset # Scaffold a new theme
432
485
  quilltap themes search "dark" # Search registries
433
486
  quilltap themes update # Check for theme updates
434
487
  quilltap themes registry list # List configured registries
435
- quilltap themes registry add <url> # Add a registry source
488
+ quilltap themes registry add <url> # Add a registry source (--key <pubkey>, --name <name>)
489
+ quilltap themes registry remove <name> # Remove a registry source
490
+ quilltap themes registry refresh # Refresh all registry indexes
491
+
492
+ # For registry operators
493
+ quilltap themes registry keygen [--output <dir>] # Generate an Ed25519 keypair
494
+ quilltap themes registry sign <dir> --key <private> # Sign a registry directory
436
495
  ```
437
496
 
438
497
  ## Shell Completion
@@ -511,7 +570,7 @@ Fish picks new completion files up automatically — no shell restart needed.
511
570
 
512
571
  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.
513
572
 
514
- 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.
573
+ 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.
515
574
 
516
575
  ## Requirements
517
576
 
package/bin/quilltap.js CHANGED
@@ -14,6 +14,7 @@ const {
14
14
  openEncryptedDb,
15
15
  } = require('../lib/db-helpers');
16
16
  const { resolveInstance } = require('../lib/instances');
17
+ const { decodeCompressedTextInRows } = require('../lib/text-codec');
17
18
  const { resolveModuleDir, ensureNativeModules, ensureDatabaseNativeModule } = require('../lib/native-modules');
18
19
 
19
20
  const PACKAGE_DIR = path.resolve(__dirname, '..');
@@ -102,6 +103,7 @@ Subcommands:
102
103
  file-verify Force-download cloud-evicted data files (iCloud, etc.)
103
104
  memory-diff <chatId> Dump existing memories and dry-run re-extraction for a chat
104
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
105
107
  completion <shell> Generate a shell completion script (bash / zsh / fish)
106
108
 
107
109
  Options:
@@ -590,6 +592,9 @@ function handleLockCommand(dataDir, opts) {
590
592
  console.log(` Environment: ${lock.environment || 'unknown'}`);
591
593
  console.log(` Process: ${lock.processTitle || 'unknown'}`);
592
594
  console.log(` Started: ${lock.startedAt || 'unknown'}`);
595
+ if (Number.isInteger(lock.port)) {
596
+ console.log(` Port: ${lock.port}`);
597
+ }
593
598
 
594
599
  // Show heartbeat age if available
595
600
  if (lock.lastHeartbeat) {
@@ -764,7 +769,10 @@ Subcommands (high-level shortcuts; auto-pick the right database):
764
769
  message <id> Full content of a single chat message
765
770
  log <id> Full request/response of a single LLM log
766
771
  memories --character <id> Memories held by a character
767
- (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)
768
776
  characters status Per-character vault readiness report:
769
777
  flag value, vault present, single-file count,
770
778
  Prompts/ and Scenarios/ folder counts, and any
@@ -1037,11 +1045,13 @@ async function dbCommand(args) {
1037
1045
  } else if (countTable) {
1038
1046
  const row = db.prepare(`SELECT count(*) as count FROM "${countTable}"`).get();
1039
1047
  if (asJson) console.log(JSON.stringify({ table: countTable, count: row.count }, null, 2));
1040
- else console.log(row.count);
1048
+ else console.log(String(row.count));
1041
1049
  } else if (sql) {
1042
1050
  const stmt = db.prepare(sql);
1043
1051
  if (stmt.reader) {
1044
1052
  const rows = stmt.all();
1053
+ // Compressed text columns arrive as Buffers; print them as text (bug 173).
1054
+ decodeCompressedTextInRows(rows);
1045
1055
  if (asJson) {
1046
1056
  console.log(JSON.stringify(rows, null, 2));
1047
1057
  } else if (rows.length === 0) {
@@ -1110,6 +1120,7 @@ async function dbCommand(args) {
1110
1120
  const stmt = db.prepare(trimmed);
1111
1121
  if (stmt.reader) {
1112
1122
  const rows = stmt.all();
1123
+ decodeCompressedTextInRows(rows);
1113
1124
  if (rows.length === 0) console.log('(no results)');
1114
1125
  else console.table(rows);
1115
1126
  } else {
@@ -1166,7 +1177,7 @@ async function dbCommand(args) {
1166
1177
  // to the subcommand. Each subcommand parses these flags position-independently,
1167
1178
  // so they behave the same before or after the verb.
1168
1179
  const SUBCOMMANDS = new Set([
1169
- 'db', 'themes', 'docs', 'sync', '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',
1170
1181
  ]);
1171
1182
  // Global flags that consume the following token as their value.
1172
1183
  const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
@@ -1238,6 +1249,12 @@ if (subName === 'db') {
1238
1249
  console.error(`Error: ${err.message}`);
1239
1250
  process.exit(1);
1240
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
+ });
1241
1258
  } else if (subName === 'completion') {
1242
1259
  const { completionCommand } = require('../lib/completion-commands');
1243
1260
  completionCommand(subArgs).catch(err => {
@@ -118,6 +118,7 @@ const HELP_SOURCES = {
118
118
  'file-verify': ['lib/file-verify-commands.js', 'printHelp'],
119
119
  'memory-diff': ['lib/memory-diff-command.js', 'printMemoryDiffHelp'],
120
120
  'recall-replay': ['lib/recall-replay-command.js', 'printRecallReplayHelp'],
121
+ 'anchor-probe': ['lib/anchor-probe-command.js', 'printAnchorProbeHelp'],
121
122
  completion: ['lib/completion-commands.js', 'printCompletionHelp'],
122
123
  };
123
124
 
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `quilltap memories maintain` and `validate --repair`: flag parsing, phase
3
+ * validation, request bodies, report rendering, and the integrity scan.
4
+ *
5
+ * @jest-environment node
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { parseFlags, renderRepairText } = require('../memories-commands');
11
+ const {
12
+ parsePhases,
13
+ buildMaintainBody,
14
+ renderMaintenanceReport,
15
+ renderReportsTable,
16
+ } = require('../memories-maintain-command');
17
+ const { scanDigestIntegrity } = require('../graph-integrity');
18
+
19
+ const ID = '11111111-2222-3333-4444-555555555555';
20
+
21
+ describe('memories maintain flags', () => {
22
+ it('parses verb, character, phases, dry-run, max, threshold, cap and port', () => {
23
+ const { flags, positional } = parseFlags([
24
+ 'maintain', '--instance', 'Friday', '--character', 'Friday', '--phases', 'consolidate,demote',
25
+ '--dry-run', '--max', '12', '--threshold', '0.8', '--cap', '500', '--port', '3005', '--json',
26
+ ]);
27
+ expect(positional).toEqual(['maintain']);
28
+ expect(flags).toMatchObject({
29
+ instance: 'Friday', character: 'Friday', phases: 'consolidate,demote', dryRun: true,
30
+ max: 12, threshold: 0.8, cap: 500, port: 3005, json: true,
31
+ });
32
+ });
33
+
34
+ it('leaves the port undefined so the instance lock can supply it', () => {
35
+ expect(parseFlags(['maintain', '--character', 'X']).flags.port).toBeUndefined();
36
+ expect(parseFlags(['maintain', '-p', '3007']).flags.port).toBe(3007);
37
+ });
38
+
39
+ it('parses --repair and --write for validate', () => {
40
+ const { flags } = parseFlags(['validate', '--repair', '--write']);
41
+ expect(flags).toMatchObject({ repair: true, write: true });
42
+ });
43
+ });
44
+
45
+ describe('parsePhases', () => {
46
+ it('returns null for every phase', () => {
47
+ expect(parsePhases('')).toBeNull();
48
+ expect(parsePhases(undefined)).toBeNull();
49
+ });
50
+
51
+ it('trims and drops duplicates, leaving names and order to the server', () => {
52
+ expect(parsePhases('mirror, demote,fold-near-duplicates,demote')).toEqual(['mirror', 'demote', 'fold-near-duplicates']);
53
+ // No local phase list: an unknown name is the server's 400 to report.
54
+ expect(parsePhases('consolidate,housekeep')).toEqual(['consolidate', 'housekeep']);
55
+ });
56
+ });
57
+
58
+ describe('buildMaintainBody', () => {
59
+ it('defaults to a real run of every phase with no overrides', () => {
60
+ const { flags } = parseFlags(['maintain', '--character', 'X']);
61
+ expect(buildMaintainBody(ID, flags)).toEqual({ characterId: ID, dryRun: false });
62
+ });
63
+
64
+ it('maps the knobs onto settings', () => {
65
+ const { flags } = parseFlags([
66
+ 'maintain', '--dry-run', '--phases', 'consolidate', '--max', '5', '--threshold', '0.65', '--cap', '300',
67
+ ]);
68
+ expect(buildMaintainBody(ID, flags)).toEqual({
69
+ characterId: ID,
70
+ dryRun: true,
71
+ phases: ['consolidate'],
72
+ settings: { maxClustersPerRun: 5, consolidation: { clusterThreshold: 0.65 }, cap: 300 },
73
+ });
74
+ });
75
+
76
+ it('omits characterId for a user-wide run', () => {
77
+ const { flags } = parseFlags(['maintain', '--character', 'all']);
78
+ expect(buildMaintainBody(null, flags)).toEqual({ dryRun: false });
79
+ });
80
+
81
+ it('allows a user-wide dry run of the model-free phases', () => {
82
+ const { flags } = parseFlags(['maintain', '--dry-run', '--phases', 'demote,retire']);
83
+ expect(buildMaintainBody(null, flags)).toEqual({ dryRun: true, phases: ['demote', 'retire'] });
84
+ });
85
+
86
+ it('passes bounds through for the server to judge (no local copy of its limits)', () => {
87
+ expect(buildMaintainBody(ID, parseFlags(['maintain', '--cap', '50']).flags)).toMatchObject({ settings: { cap: 50 } });
88
+ expect(buildMaintainBody(ID, parseFlags(['maintain', '--max', '5000']).flags)).toMatchObject({ settings: { maxClustersPerRun: 5000 } });
89
+ expect(buildMaintainBody(null, parseFlags(['maintain', '--dry-run']).flags)).toEqual({ dryRun: true });
90
+ });
91
+ });
92
+
93
+ describe('renderMaintenanceReport', () => {
94
+ function capture(fn) {
95
+ let out = '';
96
+ fn((s) => { out += s; });
97
+ return out.replace(/\x1b\[[0-9;]*m/g, '');
98
+ }
99
+
100
+ it('prints phase lines and every phase detail', () => {
101
+ const out = capture((write) => renderMaintenanceReport({
102
+ characterId: ID, characterName: 'Ariadne', dryRun: true, durationMs: 2500,
103
+ phases: [
104
+ { phase: 'fold-near-duplicates', status: 'ran', durationMs: 10 },
105
+ { phase: 'consolidate', status: 'ran', durationMs: 2000 },
106
+ { phase: 'demote', status: 'ran', durationMs: 5 },
107
+ { phase: 'retire', status: 'skipped', skippedBecause: 'no-retention-window', durationMs: 0 },
108
+ { phase: 'mirror', status: 'ran', durationMs: 3 },
109
+ ],
110
+ fold: {
111
+ eligible: 80, deferred: 0, groupsDeferred: 0, folded: 2, groupsFailed: 0,
112
+ groups: [{ survivorId: 'aaaaaaaa-1', absorbedIds: ['bbbbbbbb-2', 'cccccccc-3'], minCosine: 0.97 }],
113
+ },
114
+ consolidation: {
115
+ dryRun: true,
116
+ clusters: [{
117
+ bucket: { kind: 'other', subjectName: 'Calvin', subjectCharacterId: 'c' },
118
+ clusterKind: 'semantic', status: 'planned',
119
+ memberIds: ['aaaaaaaa-1', 'bbbbbbbb-2'], memberContents: ['Calvin likes tea', 'Calvin drinks tea daily'],
120
+ keepStandalone: ['bbbbbbbb-2'], contradictions: [{ olderId: 'aaaaaaaa-1', newerId: 'bbbbbbbb-2', note: 'tea vs coffee' }],
121
+ digests: [{ id: 'd1', action: 'create', content: 'Calvin is a devoted tea drinker', importance: 0.6, memberIds: ['aaaaaaaa-1'] }],
122
+ }],
123
+ stats: { candidates: 40, clustersFound: 3, clustersAttempted: 1, clustersDeferred: 2, durationMs: 1500 },
124
+ },
125
+ demote: {
126
+ cap: 1000, hotTotal: 1300, demotable: 1200, protected: 100, excess: 200, demoted: 200,
127
+ demotedIds: [], highestScoreDemoted: 0.4,
128
+ },
129
+ mirror: { subjects: ['self', 'Calvin'], filesWritten: 0, deferredToCommit: false },
130
+ }, write));
131
+ expect(out).toContain('Maintenance dry run');
132
+ expect(out).toContain('Ariadne');
133
+ expect(out).toContain('no-retention-window');
134
+ expect(out).toContain('aaaaaaaa <=');
135
+ expect(out).toContain('folded 2');
136
+ expect(out).toContain('about Calvin');
137
+ expect(out).toContain('Calvin likes tea');
138
+ expect(out).toContain('[kept standalone]');
139
+ expect(out).toContain('Calvin is a devoted tea drinker');
140
+ expect(out).toContain('tea vs coffee');
141
+ expect(out).toContain('candidates 40');
142
+ expect(out).toContain('cap 1000');
143
+ expect(out).toContain('demotable 1200');
144
+ expect(out).toContain('excess 200');
145
+ expect(out).toContain('self, Calvin');
146
+ });
147
+
148
+ it('reports a skipped run', () => {
149
+ const out = capture((write) => renderMaintenanceReport({
150
+ characterId: ID, characterName: null, dryRun: true, skippedReason: 'archived', phases: [],
151
+ }, write));
152
+ expect(out).toContain('Skipped: archived');
153
+ });
154
+
155
+ it('reports a skipped consolidation', () => {
156
+ const out = capture((write) => renderMaintenanceReport({
157
+ characterId: ID, characterName: 'A', dryRun: true, phases: [],
158
+ consolidation: { dryRun: true, skippedReason: 'no-llm', clusters: [], stats: {} },
159
+ }, write));
160
+ expect(out).toContain('Skipped: no-llm');
161
+ });
162
+ });
163
+
164
+ describe('renderReportsTable', () => {
165
+ it('prints one row per character', () => {
166
+ let out = '';
167
+ renderReportsTable([
168
+ {
169
+ characterId: ID, characterName: 'Ariadne', dryRun: true,
170
+ phases: [{ phase: 'demote', status: 'ran', durationMs: 1 }, { phase: 'retire', status: 'ran', durationMs: 1 }],
171
+ demote: { cap: 1000, hotTotal: 10, demotable: 10, protected: 0, excess: 0, demoted: 0, demotedIds: [], highestScoreDemoted: null },
172
+ retire: { retentionDays: 90, retired: 3, retiredIds: [] },
173
+ },
174
+ { characterId: 'ffffffff-0', characterName: null, dryRun: true, skippedReason: 'archived', phases: [] },
175
+ ], (s) => { out += s; });
176
+ out = out.replace(/\x1b\[[0-9;]*m/g, '');
177
+ expect(out).toContain('across 2 characters');
178
+ expect(out).toMatch(/Ariadne\s+demote,retire\s+-\s+10\/1000 -0\s+3/);
179
+ expect(out).toContain('ffffffff');
180
+ expect(out).toContain('skipped: archived');
181
+ });
182
+ });
183
+
184
+ describe('scanDigestIntegrity', () => {
185
+ // better-sqlite3 is mocked under jest, so drive the scanner with a fake
186
+ // that answers the handful of queries it issues.
187
+ function fakeDb(rows, columns) {
188
+ const has = new Set(columns);
189
+ const scoped = (sql, params) => (sql.includes('characterId = ?') ? rows.filter((r) => r.characterId === params[0]) : rows);
190
+ return {
191
+ prepare(sql) {
192
+ return {
193
+ all: (...params) => {
194
+ if (sql.startsWith('PRAGMA')) return [...has].map((name) => ({ name }));
195
+ if (sql.trim() === 'SELECT id FROM memories') return rows.map((r) => ({ id: r.id }));
196
+ if (sql.includes('consolidatedFrom FROM')) {
197
+ return scoped(sql, params).filter((r) => r.consolidatedFrom && r.consolidatedFrom !== '[]');
198
+ }
199
+ if (sql.includes('supersededById FROM')) return scoped(sql, params).filter((r) => r.supersededById != null);
200
+ throw new Error(`unexpected query: ${sql}`);
201
+ },
202
+ get: (...params) => {
203
+ if (sql.includes('embedding IS NULL')) return { n: scoped(sql, params).filter((r) => r.embedding == null).length };
204
+ throw new Error(`unexpected query: ${sql}`);
205
+ },
206
+ };
207
+ },
208
+ };
209
+ }
210
+
211
+ const ALL_COLUMNS = ['id', 'characterId', 'relatedMemoryIds', 'consolidatedFrom', 'supersededById', 'embedding'];
212
+
213
+ it('counts dangling digest members, dangling supersededById and missing embeddings', () => {
214
+ const db = fakeDb([
215
+ { id: 'digest', characterId: 'c1', consolidatedFrom: JSON.stringify(['m1', 'gone1', 'gone2']), supersededById: null, embedding: 'x' },
216
+ { id: 'm1', characterId: 'c1', consolidatedFrom: '[]', supersededById: 'digest', embedding: 'x' },
217
+ { id: 'm2', characterId: 'c1', consolidatedFrom: '[]', supersededById: 'gone-digest', embedding: null },
218
+ { id: 'other', characterId: 'c2', consolidatedFrom: JSON.stringify(['gone3']), supersededById: null, embedding: null },
219
+ ], ALL_COLUMNS);
220
+
221
+ const one = scanDigestIntegrity(db, { characterId: 'c1', includePairs: true });
222
+ expect(one.consolidatedFrom).toEqual({ links: 2, digests: 1 });
223
+ expect(one.supersededBy).toBe(1);
224
+ expect(one.missingEmbeddings).toBe(1);
225
+ expect(one.consolidatedFromPairs).toEqual([{ digestId: 'digest', characterId: 'c1', memberIds: ['gone1', 'gone2'] }]);
226
+ expect(one.supersededByPairs).toEqual([{ memberId: 'm2', characterId: 'c1', digestId: 'gone-digest' }]);
227
+
228
+ const all = scanDigestIntegrity(db);
229
+ expect(all.consolidatedFrom).toEqual({ links: 3, digests: 2 });
230
+ expect(all.missingEmbeddings).toBe(2);
231
+ });
232
+
233
+ it('reads an instance without the tier-era columns as nothing to scan', () => {
234
+ const db = fakeDb([{ id: 'm1', characterId: 'c1', embedding: null }], ['id', 'characterId', 'relatedMemoryIds', 'embedding']);
235
+ expect(scanDigestIntegrity(db)).toEqual({
236
+ consolidatedFrom: { links: 0, digests: 0 }, supersededBy: 0, missingEmbeddings: 1,
237
+ });
238
+ });
239
+ });
240
+
241
+ describe('renderRepairText', () => {
242
+ function captureLog(fn) {
243
+ const lines = [];
244
+ const spy = jest.spyOn(console, 'log').mockImplementation((...args) => { lines.push(args.join(' ')); });
245
+ try { fn(); } finally { spy.mockRestore(); }
246
+ return lines.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
247
+ }
248
+ const found = {
249
+ related: { links: 3, rows: 2 }, consolidatedFrom: { links: 1, digests: 1 }, supersededBy: 2, missingEmbeddings: 4,
250
+ };
251
+
252
+ it('prints a dry run with the --write hint', () => {
253
+ const out = captureLog(() => renderRepairText({ dryRun: true, characterId: null, found }));
254
+ expect(out).toContain('dry run, every memory');
255
+ expect(out).toContain('3 dangling related link(s) on 2 row(s)');
256
+ expect(out).toContain('1 dangling consolidatedFrom member(s) on 1 digest(s)');
257
+ expect(out).toContain('2 dangling supersededById');
258
+ expect(out).toContain('4 row(s) without an embedding');
259
+ expect(out).toContain('Add --write');
260
+ });
261
+
262
+ it('prints the repaired counts and a skipped embedding pass', () => {
263
+ const out = captureLog(() => renderRepairText({
264
+ dryRun: false, characterId: ID, found,
265
+ repaired: {
266
+ relatedRowsTouched: 2, digestsTouched: 1, membersCut: 2, charactersAffected: 1,
267
+ embeddingJobsQueued: 0, embeddingSkippedReason: 'no-embedding-profile',
268
+ },
269
+ }));
270
+ expect(out).toContain('(applied)');
271
+ expect(out).toContain('2 row(s) relinked, 1 digest(s) touched, 2 member(s) cut loose');
272
+ expect(out).toContain('0 embedding job(s) queued');
273
+ expect(out).toContain('no-embedding-profile');
274
+ expect(out).not.toContain('Add --write');
275
+ });
276
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `quilltap migrations status` compares the ledger against the ids it reads
3
+ * out of `migrations/scripts/*.ts`. Most migrations declare their id through a
4
+ * `MIGRATION_ID` constant rather than a string literal; when the reader only
5
+ * understood literals, every one of those vanished from "source" and showed up
6
+ * as "retired" on any instance that had run it.
7
+ *
8
+ * @jest-environment node
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const { extractMigrationId } = require('../migrations-commands');
17
+
18
+ describe('extractMigrationId', () => {
19
+ it('reads a string-literal id', () => {
20
+ expect(extractMigrationId(`export const m = {\n id: 'foo-v1',\n};`)).toBe('foo-v1');
21
+ });
22
+
23
+ it('resolves an id declared through MIGRATION_ID', () => {
24
+ const src = `const MIGRATION_ID = 'bar-v1';\nexport const m = {\n id: MIGRATION_ID,\n};`;
25
+ expect(extractMigrationId(src)).toBe('bar-v1');
26
+ });
27
+
28
+ it('accepts a typed MIGRATION_ID constant', () => {
29
+ const src = `const MIGRATION_ID: string = 'baz-v1'\nexport const m = {\n id: MIGRATION_ID,\n};`;
30
+ expect(extractMigrationId(src)).toBe('baz-v1');
31
+ });
32
+
33
+ it('returns null when no id is declared', () => {
34
+ expect(extractMigrationId('export const nothing = 1;')).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('every migration script yields an id', () => {
39
+ const scriptsDir = path.join(__dirname, '..', '..', '..', '..', 'migrations', 'scripts');
40
+ const files = fs.existsSync(scriptsDir)
41
+ ? fs.readdirSync(scriptsDir).filter((f) => f.endsWith('.ts') && f !== 'index.ts')
42
+ : [];
43
+
44
+ it('finds the migration scripts', () => {
45
+ expect(files.length).toBeGreaterThan(100);
46
+ });
47
+
48
+ it('extracts an id from each one', () => {
49
+ const missing = files.filter(
50
+ (f) => !extractMigrationId(fs.readFileSync(path.join(scriptsDir, f), 'utf8'))
51
+ );
52
+ expect(missing).toEqual([]);
53
+ });
54
+ });