quilltap 4.10.0-dev.146 → 4.10.0-dev.173

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
 
@@ -330,7 +330,7 @@ A character vault's **keystone files** — `properties.json`, the five required
330
330
 
331
331
  ## Memories
332
332
 
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.
333
+ `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.
334
334
 
335
335
  ```bash
336
336
  quilltap memories ls # All holders, default sort: reinforcedImportance DESC
@@ -339,16 +339,22 @@ quilltap memories find "concrete examples" # Substri
339
339
  quilltap memories grep -i --max 3 --context 1 "concrete examples" # Pattern search inside content, with snippets
340
340
  quilltap memories show <id|prefix> [--depth N] [--no-related] # Full record + related-memory neighbourhood
341
341
  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
342
+ quilltap memories status [--character <name|id>] [--port <n>] # Per-holder rollup, maintenance ledger + dangling-edge check
343
+ quilltap memories validate [--character <name|id>] [--list] # Graph health check; exit 1 if any dangling link remains
344
+ quilltap memories validate --repair [--write] # Rehearse (or, with --write, apply) the server's graph repair
344
345
  quilltap memories grep --semantic --character Ariadne "the argument" # Embedding search (server required, one holder)
345
- quilltap memories consolidate --character Ariadne --dry-run # Rehearse consolidation, print proposed digests (server required)
346
- quilltap memories consolidate --character Ariadne [--max N] # Queue a real consolidation job (server required)
346
+ quilltap memories maintain --character Ariadne --dry-run # Rehearse every maintenance phase (server required)
347
+ quilltap memories maintain --character Ariadne --phases consolidate # Queue a consolidation-only maintenance job
348
+ quilltap memories maintain --character all [--phases demote,retire] # Queue maintenance for every character
347
349
  ```
348
350
 
349
- 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` 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.
351
+ 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` 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; 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).
350
352
 
351
- `consolidate` folds clusters of near-duplicate hot memories into denser digests and sends the originals to the cold tier. It resolves `--character <name|id>` locally (required; `all` is rejected) and then asks the running server (`--port`, default 3000) — the model and embedding provider live there, not in the CLI. With `--dry-run` the server rehearses a bounded run (default 10 clusters, at most 40; `--max N` sets the bound, `--threshold 0..1` overrides the cluster similarity for this run), writes nothing, and the CLI prints each cluster as its member notes followed by the proposed digest(s), standalones, contradictions, and run stats. Without `--dry-run` it queues a background `MEMORY_CONSOLIDATION` job (`--max N` overrides `maxClustersPerRun`; a real run uses the stored settings, so `--threshold` is honoured only by dry runs) and prints the job id; manual runs ignore the master `enabled` switch. `--json` prints the raw API payload.
353
+ 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.
354
+
355
+ `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`.
356
+
357
+ `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.
352
358
 
353
359
  ## Memory Extraction Dry-Run
354
360
 
@@ -359,7 +365,9 @@ quilltap memory-diff <chatId> --instance Friday # Report to the current
359
365
  quilltap memory-diff <chatId> --out /tmp/diff --concurrency 8
360
366
  ```
361
367
 
362
- 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).
368
+ 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).
369
+
370
+ 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.
363
371
 
364
372
  ## Recall Replay
365
373
 
@@ -375,7 +383,7 @@ quilltap recall-replay <chatId> --turn 42 --signals-from t42.json --as-of \
375
383
  --tuning '{"boostGateAbs":0.45,"boostGateMargin":0.15,"boostGateRamp":0.1}'
376
384
  ```
377
385
 
378
- 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), `--port <n>` (default 3000), `--json`.
386
+ 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`.
379
387
 
380
388
  Tuning and comparable runs:
381
389
 
@@ -385,9 +393,17 @@ Tuning and comparable runs:
385
393
 
386
394
  The server memoises the replay's embeddings by text until it restarts, so repeat runs embed identical vectors.
387
395
 
396
+ After the two tables the replay prints:
397
+
398
+ - **TEXT FALLBACK** — what the new path returns with no usable vector (the text strategy over the same question).
399
+ - **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).
400
+ - **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.
401
+
402
+ `--json` adds `fallbackPath`, `trace`, `pools`, `ring` and `ringSource` to the result.
403
+
388
404
  ## Anchor Probe
389
405
 
390
- `quilltap anchor-probe <characterId>` measures whether the episodic anchor line embedded with each memory suppresses Memory Gate reinforcement. It re-embeds the character's most recent memories with and without the anchor line, compares each against older rows, and reports how many would cross the reinforce (0.85) and near-duplicate (0.90) thresholds each way, plus a per-row table (rows that cross 0.85 only without anchors are highlighted). Read-only, but it costs embedding calls (roughly up to 7 per sampled row).
406
+ `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).
391
407
 
392
408
  ```bash
393
409
  quilltap anchor-probe <characterId> # Sample the 50 most recent memories
@@ -395,7 +411,11 @@ quilltap anchor-probe <characterId> --limit 20 # Smaller, cheaper sample
395
411
  quilltap anchor-probe <characterId> --json # Raw JSON for scripting
396
412
  ```
397
413
 
398
- Flags: `--limit <n>` (1–200, default 50), `--port <n>` (default 3000), `--json`.
414
+ 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`.
415
+
416
+ ### Finding the server
417
+
418
+ `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.
399
419
 
400
420
  ## Maintenance & Cleanup
401
421
 
package/bin/quilltap.js CHANGED
@@ -592,6 +592,9 @@ function handleLockCommand(dataDir, opts) {
592
592
  console.log(` Environment: ${lock.environment || 'unknown'}`);
593
593
  console.log(` Process: ${lock.processTitle || 'unknown'}`);
594
594
  console.log(` Started: ${lock.startedAt || 'unknown'}`);
595
+ if (Number.isInteger(lock.port)) {
596
+ console.log(` Port: ${lock.port}`);
597
+ }
595
598
 
596
599
  // Show heartbeat age if available
597
600
  if (lock.lastHeartbeat) {
@@ -766,7 +769,10 @@ Subcommands (high-level shortcuts; auto-pick the right database):
766
769
  message <id> Full content of a single chat message
767
770
  log <id> Full request/response of a single LLM log
768
771
  memories --character <id> Memories held by a character
769
- (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)
770
776
  characters status Per-character vault readiness report:
771
777
  flag value, vault present, single-file count,
772
778
  Prompts/ and Scenarios/ folder counts, and any
@@ -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,122 @@
1
+ /**
2
+ * Universal-flag parsing and server-port resolution for the HTTP diagnostic
3
+ * tools (anchor-probe, recall-replay, memory-diff).
4
+ *
5
+ * @jest-environment node
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const { resolveServerPort } = require('../lock-helpers');
15
+ const tools = {
16
+ 'anchor-probe': require('../anchor-probe-command').parseFlags,
17
+ 'recall-replay': require('../recall-replay-command').parseFlags,
18
+ 'memory-diff': require('../memory-diff-command').parseFlags,
19
+ };
20
+
21
+ describe.each(Object.keys(tools))('%s flag parsing', (name) => {
22
+ const parseFlags = tools[name];
23
+
24
+ it.each([
25
+ [['--instance', 'V4test', 'ID']],
26
+ [['ID', '--instance', 'V4test']],
27
+ [['-i', 'V4test', 'ID']],
28
+ [['ID', '-i', 'V4test']],
29
+ ])('accepts the instance flag around the positional id (%j)', (args) => {
30
+ const { flags, positional } = parseFlags(args);
31
+ expect(flags.instance).toBe('V4test');
32
+ expect(positional).toEqual(['ID']);
33
+ });
34
+
35
+ it.each([
36
+ [['--data-dir', '/x/y', 'ID']],
37
+ [['ID', '-d', '/x/y']],
38
+ ])('accepts the data-dir flag (%j)', (args) => {
39
+ const { flags, positional } = parseFlags(args);
40
+ expect(flags.dataDir).toBe('/x/y');
41
+ expect(positional).toEqual(['ID']);
42
+ });
43
+
44
+ it('accepts --passphrase without treating its value as the id', () => {
45
+ const { positional } = parseFlags(['--passphrase', 'hunter2', 'ID']);
46
+ expect(positional).toEqual(['ID']);
47
+ expect(parseFlags(['ID', '--passphrase', 'hunter2']).positional).toEqual(['ID']);
48
+ });
49
+
50
+ it('leaves the port undefined unless --port is given', () => {
51
+ expect(parseFlags(['ID']).flags.port).toBeUndefined();
52
+ expect(parseFlags(['ID', '--port', '3005']).flags.port).toBe(3005);
53
+ });
54
+ });
55
+
56
+ describe('resolveServerPort', () => {
57
+ let root;
58
+ let lockPath;
59
+
60
+ function writeLock(overrides = {}) {
61
+ const now = new Date().toISOString();
62
+ fs.writeFileSync(lockPath, JSON.stringify({
63
+ pid: process.pid,
64
+ hostname: os.hostname(),
65
+ startedAt: now,
66
+ lastHeartbeat: now,
67
+ environment: 'local',
68
+ processTitle: 'node',
69
+ processArgv0: process.argv[0],
70
+ history: [],
71
+ ...overrides,
72
+ }));
73
+ }
74
+
75
+ beforeEach(() => {
76
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'qtap-port-test-'));
77
+ fs.mkdirSync(path.join(root, 'data'));
78
+ lockPath = path.join(root, 'data', 'quilltap.lock');
79
+ });
80
+
81
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
82
+
83
+ it('defaults to 3000 with no flags at all', () => {
84
+ expect(resolveServerPort({})).toMatchObject({ port: 3000, source: 'default', note: null });
85
+ });
86
+
87
+ it('lets an explicit --port win over a live lock', () => {
88
+ writeLock({ port: 3005 });
89
+ expect(resolveServerPort({ port: 4000, dataDir: root })).toMatchObject({ port: 4000, source: 'flag' });
90
+ });
91
+
92
+ it('uses the port from a live lock and says so', () => {
93
+ writeLock({ port: 3005 });
94
+ const r = resolveServerPort({ dataDir: root });
95
+ expect(r).toMatchObject({ port: 3005, source: 'lock' });
96
+ expect(r.note).toContain('3005');
97
+ });
98
+
99
+ it('falls back to 3000 when the live lock records no port', () => {
100
+ writeLock();
101
+ const r = resolveServerPort({ dataDir: root });
102
+ expect(r).toMatchObject({ port: 3000, source: 'default', note: null });
103
+ expect(r.hint).toContain('records no port');
104
+ });
105
+
106
+ it('ignores the port of a stale lock', () => {
107
+ writeLock({ pid: 2 ** 22 + 12345, port: 3005 });
108
+ const r = resolveServerPort({ dataDir: root });
109
+ expect(r.port).toBe(3000);
110
+ expect(r.hint).toContain('stale');
111
+ });
112
+
113
+ it('falls back to 3000 when there is no lock', () => {
114
+ const r = resolveServerPort({ dataDir: root });
115
+ expect(r.port).toBe(3000);
116
+ expect(r.hint).toContain('no instance lock');
117
+ });
118
+
119
+ it('rejects --instance together with --data-dir', () => {
120
+ expect(() => resolveServerPort({ instance: 'x', dataDir: root })).toThrow(/not both/);
121
+ });
122
+ });