quilltap 4.10.0-dev.149 → 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 +32 -12
- package/bin/quilltap.js +7 -1
- package/lib/__tests__/memories-maintain-command.test.js +276 -0
- package/lib/__tests__/server-port-resolution.test.js +122 -0
- package/lib/anchor-probe-command.js +48 -17
- package/lib/completion/bash.template +12 -8
- package/lib/completion/fish.template +7 -2
- package/lib/completion/zsh.template +8 -3
- package/lib/db-commands.js +19 -3
- package/lib/graph-integrity.js +108 -5
- package/lib/lock-helpers.js +66 -0
- package/lib/memories-commands.js +320 -79
- package/lib/memories-maintain-command.js +238 -0
- package/lib/memories-server.js +86 -0
- package/lib/memory-diff-command.js +28 -6
- package/lib/recall-replay-command.js +65 -6
- package/package.json +1 -1
- package/lib/__tests__/memories-consolidate-command.test.js +0 -66
- package/lib/memories-consolidate-command.js +0 -131
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
|
|
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>]
|
|
343
|
-
quilltap memories validate [--character <name|id>] [--list] #
|
|
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
|
|
346
|
-
quilltap memories
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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>
|
|
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
|
+
});
|