quilltap 4.10.0-dev.117 → 4.10.0-dev.139

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
@@ -342,9 +342,13 @@ quilltap memories tree <id|prefix> [--depth N] [--max-nodes N] # ASCII w
342
342
  quilltap memories status [--character <name|id>] # Per-holder rollup + dangling-edge check
343
343
  quilltap memories validate [--character <name|id>] [--list] # Dangling-edge health check; exit 1 if any remain
344
344
  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)
345
347
  ```
346
348
 
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.
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.
350
+
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.
348
352
 
349
353
  ## Memory Extraction Dry-Run
350
354
 
@@ -365,9 +369,33 @@ Needs a running server (`--port`, default 3000) to reach the extraction pipeline
365
369
  quilltap recall-replay <chatId> # Replay the last turn
366
370
  quilltap recall-replay <chatId> --turn 42 # Replay at interchange 42 (its own clock)
367
371
  quilltap recall-replay <chatId> --json # Raw JSON for scripting
372
+ quilltap recall-replay <chatId> --memory-budget 4000 # Budget-sized dynamic head
373
+ quilltap recall-replay <chatId> --turn 42 --json > t42.json
374
+ quilltap recall-replay <chatId> --turn 42 --signals-from t42.json --as-of \
375
+ --tuning '{"boostGateAbs":0.45,"boostGateMargin":0.15,"boostGateRamp":0.1}'
376
+ ```
377
+
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`.
379
+
380
+ Tuning and comparable runs:
381
+
382
+ - `--tuning '<json>'` / `--tuning-file <path>` — retuning constants for the new path only (the old path stays the reference): `boostGateAbs`, `boostGateMargin`, `boostGateRamp` (relevance gate on boosts), `boostCap`, `freshBypassesGate`, `windowBypassesGate`, `multipliers` (`{ "freshEvent24h": 1.3, … }`), `specificAnchors`, `anchorMinHits`, `anchorOrder`, `backgroundReserve`. Unknown keys are rejected. The header's `Tuning` line shows what is active.
383
+ - `--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.
384
+ - `--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.
385
+
386
+ The server memoises the replay's embeddings by text until it restarts, so repeat runs embed identical vectors.
387
+
388
+ ## Anchor Probe
389
+
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).
391
+
392
+ ```bash
393
+ quilltap anchor-probe <characterId> # Sample the 50 most recent memories
394
+ quilltap anchor-probe <characterId> --limit 20 # Smaller, cheaper sample
395
+ quilltap anchor-probe <characterId> --json # Raw JSON for scripting
368
396
  ```
369
397
 
370
- Flags: `--turn <n>` (default: last), `--char <characterId>` (default: first LLM-controlled participant), `--limit <n>` (default 25), `--port <n>` (default 3000), `--json`.
398
+ Flags: `--limit <n>` (1–200, default 50), `--port <n>` (default 3000), `--json`.
371
399
 
372
400
  ## Maintenance & Cleanup
373
401
 
package/bin/quilltap.js CHANGED
@@ -103,6 +103,7 @@ Subcommands:
103
103
  file-verify Force-download cloud-evicted data files (iCloud, etc.)
104
104
  memory-diff <chatId> Dump existing memories and dry-run re-extraction for a chat
105
105
  recall-replay <chatId> Replay a turn's memory recall, old vs episodic ranking
106
+ anchor-probe <characterId> Probe whether episodic anchor lines suppress Memory Gate reinforcement
106
107
  completion <shell> Generate a shell completion script (bash / zsh / fish)
107
108
 
108
109
  Options:
@@ -1170,7 +1171,7 @@ async function dbCommand(args) {
1170
1171
  // to the subcommand. Each subcommand parses these flags position-independently,
1171
1172
  // so they behave the same before or after the verb.
1172
1173
  const SUBCOMMANDS = new Set([
1173
- 'db', 'themes', 'docs', 'sync', 'memories', 'instances', 'memory-diff', 'recall-replay', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
1174
+ 'db', 'themes', 'docs', 'sync', 'memories', 'instances', 'memory-diff', 'recall-replay', 'anchor-probe', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
1174
1175
  ]);
1175
1176
  // Global flags that consume the following token as their value.
1176
1177
  const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
@@ -1242,6 +1243,12 @@ if (subName === 'db') {
1242
1243
  console.error(`Error: ${err.message}`);
1243
1244
  process.exit(1);
1244
1245
  });
1246
+ } else if (subName === 'anchor-probe') {
1247
+ const { anchorProbeCommand } = require('../lib/anchor-probe-command');
1248
+ anchorProbeCommand(subArgs).catch(err => {
1249
+ console.error(`Error: ${err.message}`);
1250
+ process.exit(1);
1251
+ });
1245
1252
  } else if (subName === 'completion') {
1246
1253
  const { completionCommand } = require('../lib/completion-commands');
1247
1254
  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,66 @@
1
+ /**
2
+ * `quilltap memories consolidate`: flag parsing, request body, report rendering.
3
+ *
4
+ * @jest-environment node
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { parseFlags } = require('../memories-commands');
10
+ const { buildConsolidateBody, renderReport } = require('../memories-consolidate-command');
11
+
12
+ describe('memories consolidate flags', () => {
13
+ it('parses verb, character, dry-run, max, threshold and port', () => {
14
+ const { flags, positional } = parseFlags([
15
+ 'consolidate', '--instance', 'Friday', '--character', 'Friday',
16
+ '--dry-run', '--max', '12', '--threshold', '0.8', '--port', '3005', '--json',
17
+ ]);
18
+ expect(positional).toEqual(['consolidate']);
19
+ expect(flags).toMatchObject({
20
+ instance: 'Friday', character: 'Friday', dryRun: true, max: 12, threshold: 0.8, port: 3005, json: true,
21
+ });
22
+ });
23
+
24
+ it('defaults to a real run with no overrides', () => {
25
+ const { flags } = parseFlags(['consolidate', '--character', 'X']);
26
+ expect(flags.dryRun).toBe(false);
27
+ expect(buildConsolidateBody('id', flags)).toEqual({ characterId: 'id', dryRun: false });
28
+ });
29
+
30
+ it('builds the request body from the flags', () => {
31
+ const { flags } = parseFlags(['consolidate', '--dry-run', '--max', '5', '--threshold', '0.65']);
32
+ expect(buildConsolidateBody('abc', flags)).toEqual({
33
+ characterId: 'abc', dryRun: true, maxClustersPerRun: 5, clusterThreshold: 0.65,
34
+ });
35
+ });
36
+ });
37
+
38
+ describe('renderReport', () => {
39
+ it('prints cluster, members and the proposed digest', () => {
40
+ let out = '';
41
+ renderReport({
42
+ characterName: 'Ariadne', dryRun: true,
43
+ clusters: [{
44
+ bucket: { kind: 'other', subjectName: 'Calvin', subjectCharacterId: 'c' },
45
+ clusterKind: 'semantic', status: 'planned',
46
+ memberIds: ['aaaaaaaa-1', 'bbbbbbbb-2'], memberContents: ['Calvin likes tea', 'Calvin drinks tea daily'],
47
+ keepStandalone: ['bbbbbbbb-2'], contradictions: [{ olderId: 'aaaaaaaa-1', newerId: 'bbbbbbbb-2', note: 'tea vs coffee' }],
48
+ digests: [{ id: 'd1', action: 'create', content: 'Calvin is a devoted tea drinker', importance: 0.6, memberIds: ['aaaaaaaa-1'] }],
49
+ }],
50
+ stats: { candidates: 40, clustersFound: 3, clustersAttempted: 1, clustersDeferred: 2, durationMs: 1500 },
51
+ }, (s) => { out += s; });
52
+ expect(out).toContain('Ariadne');
53
+ expect(out).toContain('about Calvin');
54
+ expect(out).toContain('Calvin likes tea');
55
+ expect(out).toContain('[kept standalone]');
56
+ expect(out).toContain('Calvin is a devoted tea drinker');
57
+ expect(out).toContain('tea vs coffee');
58
+ expect(out).toContain('candidates 40');
59
+ });
60
+
61
+ it('reports a skipped run', () => {
62
+ let out = '';
63
+ renderReport({ dryRun: true, skippedReason: 'no-llm', clusters: [], stats: {} }, (s) => { out += s; });
64
+ expect(out).toContain('Skipped: no-llm');
65
+ });
66
+ });
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `quilltap anchor-probe <characterId>` — measure whether the episodic anchor
5
+ * line embedded with each memory suppresses Memory Gate reinforcement.
6
+ *
7
+ * Thin wrapper over POST /api/v1/memories?action=anchor-gate-probe.
8
+ * Read-only; nothing is persisted server-side, but it costs embedding calls.
9
+ */
10
+
11
+ const RESET = '\x1b[0m';
12
+ const BOLD = '\x1b[1m';
13
+ const DIM = '\x1b[2m';
14
+ const GREEN = '\x1b[32m';
15
+ const RED = '\x1b[31m';
16
+ const YELLOW = '\x1b[33m';
17
+ const CYAN = '\x1b[36m';
18
+
19
+ function printAnchorProbeHelp() {
20
+ console.log(`
21
+ Quilltap anchor-probe Tool
22
+
23
+ Usage: quilltap anchor-probe <characterId> [options]
24
+
25
+ Measures whether the episodic anchor line embedded with each memory suppresses
26
+ Memory Gate reinforcement. Re-embeds the character's most recent memories with
27
+ and without the anchor line, compares each against older rows, and reports how
28
+ many would cross the reinforce (0.85) and near-duplicate (0.90) thresholds
29
+ each way.
30
+
31
+ Read-only, but it costs embedding calls (roughly up to 7 per sampled row).
32
+
33
+ Options:
34
+ --limit <number> Recent memories to sample (default: 50, max: 200)
35
+ --port <number> Server port for API calls (default: 3000)
36
+ --json Print the raw JSON result instead of tables
37
+ -h, --help Show this help
38
+
39
+ Examples:
40
+ quilltap anchor-probe <characterId>
41
+ quilltap anchor-probe <characterId> --limit 20
42
+ quilltap anchor-probe <characterId> --json > probe.json
43
+ `);
44
+ }
45
+
46
+ function parseFlags(args) {
47
+ const flags = { limit: undefined, port: 3000, json: false, help: false };
48
+ const positional = [];
49
+ let i = 0;
50
+ while (i < args.length) {
51
+ const a = args[i];
52
+ switch (a) {
53
+ case '--limit': {
54
+ const n = parseInt(args[++i], 10);
55
+ if (isNaN(n) || n < 1 || n > 200) {
56
+ console.error('Error: --limit must be between 1 and 200');
57
+ process.exit(1);
58
+ }
59
+ flags.limit = n;
60
+ break;
61
+ }
62
+ case '--port': {
63
+ const p = parseInt(args[++i], 10);
64
+ if (isNaN(p) || p < 1 || p > 65535) {
65
+ console.error('Error: --port must be between 1 and 65535');
66
+ process.exit(1);
67
+ }
68
+ flags.port = p;
69
+ break;
70
+ }
71
+ case '--json':
72
+ flags.json = true;
73
+ break;
74
+ case '-h':
75
+ case '--help':
76
+ flags.help = true;
77
+ break;
78
+ default:
79
+ if (a.startsWith('-')) {
80
+ console.error(`Unknown option: ${a}`);
81
+ process.exit(1);
82
+ }
83
+ positional.push(a);
84
+ }
85
+ i++;
86
+ }
87
+ return { flags, positional };
88
+ }
89
+
90
+ function fmt(n, digits = 3) {
91
+ if (n === null || n === undefined) return DIM + '—' + RESET;
92
+ return n.toFixed(digits);
93
+ }
94
+
95
+ // Pad a possibly colour-wrapped string to a visible width.
96
+ function pad(s, width) {
97
+ const visible = String(s).replace(/\x1b\[[0-9;]*m/g, '').length;
98
+ return String(s) + ' '.repeat(Math.max(0, width - visible));
99
+ }
100
+
101
+ function printRows(rows, thresholds) {
102
+ console.log(`\n${BOLD}Rows${RESET} (${rows.length})`);
103
+ if (rows.length === 0) {
104
+ console.log(` ${DIM}(none)${RESET}`);
105
+ return;
106
+ }
107
+ console.log(
108
+ ` ${DIM}${'anchored'.padEnd(10)}${'free'.padEnd(10)}${'delta'.padEnd(9)}${'anchor'.padEnd(8)}summary${RESET}`
109
+ );
110
+ const reinforce = thresholds?.reinforce ?? 0.85;
111
+ for (const row of rows) {
112
+ const a = row.anchoredBest;
113
+ const f = row.anchorFreeBest;
114
+ const delta = typeof a === 'number' && typeof f === 'number' ? f - a : null;
115
+ const onlyFree = typeof f === 'number' && f >= reinforce && !(typeof a === 'number' && a >= reinforce);
116
+ const deltaStr = delta === null ? DIM + '—' + RESET : (delta >= 0 ? '+' : '') + delta.toFixed(3);
117
+ const anchorStr = row.hasAnchorLine ? 'yes' : `${DIM}no${RESET}`;
118
+ const summary = (row.summary || '').replace(/\s+/g, ' ').slice(0, 60);
119
+ const line = `${pad(fmt(a), 10)}${pad(fmt(f), 10)}${pad(deltaStr, 9)}${pad(anchorStr, 8)}${summary}`;
120
+ console.log(onlyFree ? `${YELLOW}▸ ${RESET}${line}` : ` ${line}`);
121
+ }
122
+ console.log(`\n ${YELLOW}▸${RESET} ${DIM}= crosses ${reinforce} only without the anchor line${RESET}`);
123
+ }
124
+
125
+ async function anchorProbeCommand(args) {
126
+ const { flags, positional } = parseFlags(args);
127
+
128
+ if (flags.help || positional.length === 0) {
129
+ printAnchorProbeHelp();
130
+ process.exit(flags.help ? 0 : 1);
131
+ }
132
+ if (positional.length > 1) {
133
+ console.error('Error: only one characterId may be specified');
134
+ process.exit(1);
135
+ }
136
+ const characterId = positional[0];
137
+
138
+ const url = `http://localhost:${flags.port}/api/v1/memories?action=anchor-gate-probe`;
139
+ const body = { characterId };
140
+ if (flags.limit !== undefined) body.limit = flags.limit;
141
+
142
+ process.stderr.write(`${BOLD}Probing anchor gate${RESET} for character ${DIM}${characterId}${RESET} via ${DIM}${url}${RESET}\n`);
143
+
144
+ let res;
145
+ try {
146
+ res = await fetch(url, {
147
+ method: 'POST',
148
+ headers: { 'content-type': 'application/json' },
149
+ body: JSON.stringify(body),
150
+ });
151
+ } catch (err) {
152
+ console.error(`${RED}Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}${RESET}`);
153
+ console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
154
+ process.exit(1);
155
+ }
156
+
157
+ let payload;
158
+ try {
159
+ payload = await res.json();
160
+ } catch {
161
+ console.error(`${RED}Server returned a non-JSON response (status ${res.status})${RESET}`);
162
+ process.exit(1);
163
+ }
164
+ if (!res.ok || payload?.success === false) {
165
+ console.error(`${RED}Probe failed (status ${res.status}): ${payload?.error || payload?.message || 'unknown error'}${RESET}`);
166
+ process.exit(1);
167
+ }
168
+
169
+ const result = payload.data ?? payload;
170
+ if (flags.json) {
171
+ console.log(JSON.stringify(result, null, 2));
172
+ return;
173
+ }
174
+
175
+ const t = result.thresholds || {};
176
+ const anchored = result.anchored || {};
177
+ const free = result.anchorFree || {};
178
+ console.log(`\n${BOLD}Character${RESET} ${result.characterId}`);
179
+ console.log(`${BOLD}Sampled${RESET} ${result.sampled} rows, ${result.anchoredRows} with an anchor line`);
180
+ console.log(`${BOLD}Embeddings${RESET} ${result.embeddingsGenerated} generated`);
181
+ console.log(`\n${DIM}${''.padEnd(22)}${'anchored'.padEnd(11)}anchor-free${RESET}`);
182
+ console.log(`${BOLD}${('reinforce ≥ ' + fmt(t.reinforce, 2)).padEnd(22)}${RESET}${String(anchored.reinforce).padEnd(11)}${CYAN}${free.reinforce}${RESET}`);
183
+ console.log(`${BOLD}${('near-dup ≥ ' + fmt(t.nearDuplicate, 2)).padEnd(22)}${RESET}${String(anchored.nearDuplicate).padEnd(11)}${CYAN}${free.nearDuplicate}${RESET}`);
184
+ const only = result.crossedOnlyWithoutAnchors;
185
+ console.log(`${BOLD}Crossed only without anchors${RESET} ${only > 0 ? YELLOW : GREEN}${only}${RESET}`);
186
+
187
+ printRows(result.rows || [], t);
188
+ console.log('');
189
+ }
190
+
191
+ module.exports = { anchorProbeCommand, printAnchorProbeHelp };
@@ -61,7 +61,7 @@ _quilltap_complete() {
61
61
  local global_opts="-d --data-dir -i --instance -p --port -o --open -v --version -h --help --update --passphrase"
62
62
 
63
63
  # Top-level subcommands
64
- local top_cmds="db docs sync themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion"
64
+ local top_cmds="db docs sync themes instances memories memory-diff recall-replay anchor-probe logs migrations maintenance file-verify completion"
65
65
 
66
66
  # Flags that swallow the word after them. A flat list will not do: -o is the
67
67
  # valueless global --open but themes' valued --output, and `memories`
@@ -73,10 +73,11 @@ _quilltap_complete() {
73
73
  local vf_docs=" --mount --folder --type --ext --limit --max --context --top --threshold --sort --depth --max-nodes --format "
74
74
  local vf_sync=" --direction --prefer "
75
75
  local vf_themes=" -o --output -k --key -n --name "
76
- local vf_memories=" -d --data-dir --instance --passphrase --port --character --about --source --chat --project --since --until --min-importance --min-reinforced --sort --limit --in --max --context --depth --max-nodes --top --threshold "
76
+ local vf_memories=" -d --data-dir --instance --passphrase --port --character --about --source --tier --chat --project --since --until --min-importance --min-reinforced --sort --limit --in --max --context --depth --max-nodes --top --threshold "
77
77
  local vf_logs=" --stream --tail --grep "
78
78
  local vf_memory_diff=" --concurrency --out "
79
- local vf_recall_replay=" --turn --char --limit --port "
79
+ local vf_recall_replay=" --turn --char --limit --memory-budget --tuning --tuning-file --signals-from --port "
80
+ local vf_anchor_probe=" --limit --port "
80
81
  local vf_file_verify=" --stall-ms "
81
82
 
82
83
  # Get the subcommand (first non-option word after quilltap)
@@ -100,6 +101,7 @@ _quilltap_complete() {
100
101
  logs) valued="$vf_global$vf_logs" ;;
101
102
  memory-diff) valued="$vf_global$vf_memory_diff" ;;
102
103
  recall-replay) valued="$vf_global$vf_recall_replay" ;;
104
+ anchor-probe) valued="$vf_global$vf_anchor_probe" ;;
103
105
  file-verify) valued="$vf_global$vf_file_verify" ;;
104
106
  esac
105
107
 
@@ -176,7 +178,11 @@ _quilltap_complete() {
176
178
  return
177
179
  ;;
178
180
  --source)
179
- COMPREPLY=($(compgen -W "AUTO MANUAL" -- "$cur"))
181
+ COMPREPLY=($(compgen -W "AUTO MANUAL CONSOLIDATED" -- "$cur"))
182
+ return
183
+ ;;
184
+ --tier)
185
+ COMPREPLY=($(compgen -W "hot cold" -- "$cur"))
180
186
  return
181
187
  ;;
182
188
  --sort)
@@ -311,12 +317,12 @@ _quilltap_complete() {
311
317
  fi
312
318
  ;;
313
319
  memories)
314
- local mem_verbs="ls find grep show tree status validate"
315
- local mem_flags="--character --about --source --chat --project --since --until \
320
+ local mem_verbs="ls find grep show tree status validate consolidate"
321
+ local mem_flags="--character --about --source --tier --chat --project --since --until \
316
322
  --min-importance --min-reinforced --has-embedding --no-embedding \
317
323
  --sort -r --reverse --limit --full-titles --in --no-related --list \
318
324
  --ignore-case -i --paths-only -l --max --context --depth --max-nodes \
319
- --semantic --top --threshold \
325
+ --semantic --top --threshold --dry-run \
320
326
  --instance --data-dir --passphrase --port --json --help"
321
327
  if [[ -z "$subverb" ]]; then
322
328
  if [[ "$cur" == -* ]]; then
@@ -333,9 +339,13 @@ _quilltap_complete() {
333
339
  COMPREPLY=($(compgen -W "$md_flags" -- "$cur"))
334
340
  ;;
335
341
  recall-replay)
336
- local rr_flags="--turn --char --limit --port --json --help"
342
+ local rr_flags="--turn --char --limit --memory-budget --tuning --tuning-file --signals-from --as-of --port --json --help"
337
343
  COMPREPLY=($(compgen -W "$rr_flags" -- "$cur"))
338
344
  ;;
345
+ anchor-probe)
346
+ local ap_flags="--limit --port --json --help"
347
+ COMPREPLY=($(compgen -W "$ap_flags" -- "$cur"))
348
+ ;;
339
349
  logs)
340
350
  local logs_flags="--stream --tail -f --follow --grep \
341
351
  --instance --data-dir --passphrase --help"
@@ -19,7 +19,7 @@ function __quilltap_no_subcommand
19
19
  end
20
20
  for i in (seq 2 (count $cmd))
21
21
  switch $cmd[$i]
22
- case db docs sync themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion
22
+ case db docs sync themes instances memories memory-diff recall-replay anchor-probe logs migrations maintenance file-verify completion
23
23
  return 1
24
24
  end
25
25
  end
@@ -31,7 +31,7 @@ function __quilltap_using_subcommand
31
31
  set -l cmd (commandline -opc)
32
32
  for i in (seq 2 (count $cmd))
33
33
  switch $cmd[$i]
34
- case db docs sync themes instances memories memory-diff recall-replay logs migrations maintenance file-verify completion
34
+ case db docs sync themes instances memories memory-diff recall-replay anchor-probe logs migrations maintenance file-verify completion
35
35
  test "$cmd[$i]" = "$target"
36
36
  return $status
37
37
  end
@@ -61,6 +61,7 @@ complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'instances' -d 'Registe
61
61
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'memories' -d 'Search and browse memories'
62
62
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'memory-diff' -d 'Memory extraction dry-run'
63
63
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'recall-replay' -d 'Replay memory recall for a turn'
64
+ complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'anchor-probe' -d 'Probe anchor-line effect on Memory Gate'
64
65
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'logs' -d 'Tail or print log files'
65
66
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'migrations' -d 'Inspect migration status'
66
67
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'maintenance' -d 'Run retention/cleanup sweeps'
@@ -112,7 +113,7 @@ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'grep' -d 'Substring
112
113
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'character' -d 'Character name or id' -x
113
114
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'project' -d 'Project name or id' -x
114
115
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'about' -d 'Subject character' -x
115
- complete -c quilltap -n '__quilltap_using_subcommand db' -l 'source' -d 'Memory source' -x -a 'AUTO MANUAL'
116
+ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'source' -d 'Memory source' -x -a 'AUTO MANUAL CONSOLIDATED'
116
117
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'chat' -d 'Chat name or id' -x
117
118
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'message' -d 'Message id' -x
118
119
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'rendered' -d 'Render rich content'
@@ -271,11 +272,14 @@ complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'show' -d '
271
272
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'tree' -d 'Graph walk'
272
273
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'status' -d 'Memory status'
273
274
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'validate' -d 'Health check'
275
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'consolidate' -d 'Fold memory clusters into digests'
274
276
 
275
277
  # memories flags
276
278
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'character' -d 'Character name or id' -x
277
279
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'about' -d 'Subject of memory' -x
278
- complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'source' -d 'Memory source' -x -a 'AUTO MANUAL'
280
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'source' -d 'Memory source' -x -a 'AUTO MANUAL CONSOLIDATED'
281
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'tier' -d 'Memory tier' -x -a 'hot cold'
282
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'dry-run' -d 'Consolidate: rehearse and print the report, write nothing'
279
283
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'chat' -d 'Chat id or title' -x
280
284
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'project' -d 'Project name or id' -x
281
285
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'since' -d 'Since timestamp' -x
@@ -311,8 +315,18 @@ complete -c quilltap -n '__quilltap_using_subcommand memory-diff' -l 'out' -d 'O
311
315
  complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'turn' -d 'Interchange to replay (1-based)' -x
312
316
  complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'char' -d 'Character whose memories are searched' -x
313
317
  complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'limit' -d 'Candidate rows per path' -x
318
+ complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'memory-budget' -d 'Memory token budget' -x
319
+ complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'tuning' -d 'New-path tuning JSON' -x
320
+ complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'tuning-file' -d 'New-path tuning JSON file' -r -F
321
+ complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'signals-from' -d 'Reuse signals from a saved replay' -r -F
322
+ complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'as-of' -d 'Search the corpus as it stood at the turn'
314
323
  complete -c quilltap -n '__quilltap_using_subcommand recall-replay' -l 'port' -d 'Server port' -x
315
324
 
325
+ # ---------- anchor-probe flags ----------
326
+ complete -c quilltap -n '__quilltap_using_subcommand anchor-probe' -l 'limit' -d 'Recent memories to sample' -x
327
+ complete -c quilltap -n '__quilltap_using_subcommand anchor-probe' -l 'port' -d 'Server port' -x
328
+ complete -c quilltap -n '__quilltap_using_subcommand anchor-probe' -l 'json' -d 'Raw JSON output'
329
+
316
330
  # ---------- file-verify flags ----------
317
331
  complete -c quilltap -n '__quilltap_using_subcommand file-verify' -l 'all' -d 'Read every top-level file, not just dataless ones'
318
332
  complete -c quilltap -n '__quilltap_using_subcommand file-verify' -l 'stall-ms' -d 'Per-chunk stall threshold (ms)' -x
@@ -43,6 +43,7 @@ _quilltap() {
43
43
  'memories:Search, browse, and graph memories'
44
44
  'memory-diff:Dump existing memories and dry-run re-extraction'
45
45
  'recall-replay:Replay a turn'\''s memory recall, old vs episodic ranking'
46
+ 'anchor-probe:Probe whether episodic anchor lines suppress Memory Gate reinforcement'
46
47
  'logs:Tail or print an instance log file'
47
48
  'migrations:Inspect migration status'
48
49
  'maintenance:Run retention/cleanup sweeps'
@@ -99,6 +100,9 @@ _quilltap_subcommand() {
99
100
  recall-replay)
100
101
  _quilltap_recall_replay
101
102
  ;;
103
+ anchor-probe)
104
+ _quilltap_anchor_probe
105
+ ;;
102
106
  logs)
103
107
  _quilltap_logs
104
108
  ;;
@@ -147,7 +151,7 @@ _quilltap_db() {
147
151
  '--character[Character name or id]:character:'
148
152
  '--project[Project name or id]:project:'
149
153
  '--about[Subject character]:character:'
150
- '--source[Memory source]:source:(AUTO MANUAL)'
154
+ '--source[Memory source]:source:(AUTO MANUAL CONSOLIDATED)'
151
155
  '--chat[Chat name or id]:chat:'
152
156
  '--message[Message id]:id:'
153
157
  '--rendered[Render rich content]'
@@ -534,6 +538,7 @@ _quilltap_memories() {
534
538
  'tree:ASCII walk of graph'
535
539
  'status:Per-character rollup'
536
540
  'validate:Health check'
541
+ 'consolidate:Fold memory clusters into digests'
537
542
  )
538
543
 
539
544
  mem_opts=(
@@ -544,7 +549,9 @@ _quilltap_memories() {
544
549
  '--json[JSON output]'
545
550
  '--character[Character name or id]:character:'
546
551
  '--about[Subject of memory]:character:'
547
- '--source[Memory source]:source:(AUTO MANUAL)'
552
+ '--source[Memory source]:source:(AUTO MANUAL CONSOLIDATED)'
553
+ '--tier[Memory tier]:tier:(hot cold)'
554
+ '--dry-run[Consolidate: rehearse and print the report, write nothing]'
548
555
  '--chat[Chat id or title]:chat:'
549
556
  '--project[Project name or id]:project:'
550
557
  '--since[Since timestamp]:date:'
@@ -604,6 +611,23 @@ _quilltap_recall_replay() {
604
611
  '--turn[interchange to replay]:turn:' \
605
612
  '--char[character id]:char:' \
606
613
  '--limit[rows per path]:limit:' \
614
+ '--memory-budget[memory token budget]:tokens:' \
615
+ '--tuning[new-path tuning JSON]:json:' \
616
+ '--tuning-file[new-path tuning JSON file]:file:_files' \
617
+ '--signals-from[reuse signals from a saved replay]:file:_files' \
618
+ '--as-of[search the corpus as it stood at the turn]' \
619
+ '--port[server port]:port:' \
620
+ '--json[raw JSON output]' \
621
+ '*: :'
622
+ }
623
+
624
+ _quilltap_anchor_probe() {
625
+ _arguments \
626
+ '(-h --help)'{-h,--help}'[Show help]' \
627
+ '(-i --instance)'{-i,--instance}'[Use instance]:instance:_quilltap_instance_names' \
628
+ '(-d --data-dir)'{-d,--data-dir}'[Data directory]:directory:_directories' \
629
+ '--passphrase[Database passphrase]:passphrase:' \
630
+ '--limit[recent memories to sample]:limit:' \
607
631
  '--port[server port]:port:' \
608
632
  '--json[raw JSON output]' \
609
633
  '*: :'
@@ -12,6 +12,7 @@ const {
12
12
  resolveProject,
13
13
  } = require('./db-helpers');
14
14
  const { scanDanglingEdges } = require('./graph-integrity');
15
+ const { cmdConsolidate } = require('./memories-consolidate-command');
15
16
 
16
17
  // ---------- colour & marker helpers ----------
17
18
 
@@ -93,6 +94,8 @@ function parseFlags(args) {
93
94
  top: 0,
94
95
  threshold: -1,
95
96
  port: 3000,
97
+ // consolidate
98
+ dryRun: false,
96
99
  };
97
100
  const positional = [];
98
101
  let i = 0;
@@ -108,6 +111,7 @@ function parseFlags(args) {
108
111
  case '--character': flags.character = args[++i]; break;
109
112
  case '--about': flags.about = args[++i]; break;
110
113
  case '--source': flags.source = args[++i]; break;
114
+ case '--tier': flags.tier = args[++i]; break;
111
115
  case '--chat': flags.chat = args[++i]; break;
112
116
  case '--project': flags.project = args[++i]; break;
113
117
  case '--since': flags.since = args[++i]; break;
@@ -136,6 +140,7 @@ function parseFlags(args) {
136
140
  case '--list': flags.list = true; break;
137
141
 
138
142
  case '--semantic': flags.semantic = true; break;
143
+ case '--dry-run': flags.dryRun = true; break;
139
144
  case '--top': flags.top = parseInt(args[++i], 10) || 0; break;
140
145
  case '--threshold': {
141
146
  const v = parseFloat(args[++i]);
@@ -220,13 +225,22 @@ function buildWhereClause(db, flags, openMounts = null) {
220
225
 
221
226
  if (flags.source) {
222
227
  const s = String(flags.source).toUpperCase();
223
- if (s !== 'AUTO' && s !== 'MANUAL') {
224
- throw new Error(`--source must be AUTO or MANUAL (got '${flags.source}')`);
228
+ if (s !== 'AUTO' && s !== 'MANUAL' && s !== 'CONSOLIDATED') {
229
+ throw new Error(`--source must be AUTO, MANUAL or CONSOLIDATED (got '${flags.source}')`);
225
230
  }
226
231
  clauses.push('m.source = ?');
227
232
  params.push(s);
228
233
  }
229
234
 
235
+ if (flags.tier) {
236
+ const t = String(flags.tier).toLowerCase();
237
+ if (t !== 'hot' && t !== 'cold') {
238
+ throw new Error(`--tier must be hot or cold (got '${flags.tier}')`);
239
+ }
240
+ // Legacy instances without the column read every row as hot.
241
+ clauses.push(t === 'hot' ? "COALESCE(m.tier, 'hot') = 'hot'" : "m.tier = 'cold'");
242
+ }
243
+
230
244
  if (flags.chat) {
231
245
  if (flags.chat === 'none') {
232
246
  clauses.push('m.chatId IS NULL');
@@ -313,7 +327,7 @@ function buildOrderBy(sortFlag, reverse) {
313
327
  // SELECT fragment that pulls every column the renderers need, including the
314
328
  // joined-in character / chat names. `m.*` is followed by aliases for the joins
315
329
  // so callers don't have to re-resolve UUIDs.
316
- const SELECT_BASE = `
330
+ const SELECT_BASE_TEMPLATE = `
317
331
  SELECT
318
332
  m.id,
319
333
  m.characterId,
@@ -331,6 +345,7 @@ const SELECT_BASE = `
331
345
  m.lastReinforcedAt,
332
346
  m.lastAccessedAt,
333
347
  m.source,
348
+ __TIER_COLUMNS__
334
349
  m.relatedMemoryIds,
335
350
  m.createdAt,
336
351
  m.updatedAt,
@@ -346,6 +361,20 @@ const SELECT_BASE = `
346
361
  LEFT JOIN projects ON projects.id = m.projectId
347
362
  `;
348
363
 
364
+ // Instances that haven't yet run add-memory-tiers-v1 lack the tier columns;
365
+ // substitute constants so the CLI still reads them (rows read as hot).
366
+ function selectBase(db) {
367
+ let hasTier = false;
368
+ try {
369
+ hasTier = db.prepare("PRAGMA table_info('memories')").all().some((c) => c.name === 'tier');
370
+ } catch { /* fall through: treat as legacy */ }
371
+ const cols = hasTier
372
+ ? 'm.tier, m.supersededById, m.consolidatedFrom, m.consolidatedAt,'
373
+ : "'hot' AS tier, NULL AS supersededById, '[]' AS consolidatedFrom, NULL AS consolidatedAt,";
374
+ return SELECT_BASE_TEMPLATE.replace('__TIER_COLUMNS__', cols);
375
+ }
376
+
377
+
349
378
  // ---------- output helpers ----------
350
379
 
351
380
  function truncate(str, n) {
@@ -404,7 +433,7 @@ async function cmdLs(flags) {
404
433
  const { where, params, meta } = buildWhereClause(db, flags, openMounts);
405
434
  const { order, impField } = buildOrderBy(flags.sort, flags.reverse);
406
435
  const limit = flags.limit > 0 ? flags.limit : 50;
407
- const sql = `${SELECT_BASE} ${where} ORDER BY ${order} LIMIT ?`;
436
+ const sql = `${selectBase(db)} ${where} ORDER BY ${order} LIMIT ?`;
408
437
  const rows = db.prepare(sql).all(...params, limit);
409
438
 
410
439
  if (flags.json) {
@@ -441,6 +470,10 @@ function rowToJson(row) {
441
470
  projectName: row.projectName,
442
471
  sourceMessageId: row.sourceMessageId,
443
472
  source: row.source,
473
+ tier: row.tier || 'hot',
474
+ supersededById: row.supersededById || null,
475
+ consolidatedFrom: (() => { try { return JSON.parse(row.consolidatedFrom || '[]'); } catch { return []; } })(),
476
+ consolidatedAt: row.consolidatedAt || null,
444
477
  importance: row.importance,
445
478
  reinforcedImportance: row.reinforcedImportance,
446
479
  reinforcementCount: row.reinforcementCount,
@@ -469,7 +502,7 @@ function renderTable(rows, { showHolder, impField, fullTitles }) {
469
502
  showHolder ? 'holder'.padEnd(14) : null,
470
503
  impHeader.padStart(4),
471
504
  'rein'.padStart(4),
472
- 'src'.padEnd(6),
505
+ 'src'.padEnd(7),
473
506
  'about'.padEnd(20),
474
507
  'chat'.padEnd(fullTitles ? 24 : 32),
475
508
  'links'.padStart(5),
@@ -483,7 +516,7 @@ function renderTable(rows, { showHolder, impField, fullTitles }) {
483
516
  const impVal = impField === 'importance' ? row.importance : row.reinforcedImportance;
484
517
  const impStr = colorize(formatImportance(impVal), importanceColor(impVal));
485
518
  const rein = String(row.reinforcementCount ?? 0).padStart(4);
486
- const src = (row.source || '').padEnd(6);
519
+ const src = ((row.source || '') + (row.tier === 'cold' ? '*' : '')).padEnd(7);
487
520
  const about = truncate(aboutLabel(row), 20).padEnd(20);
488
521
  const chat = truncate(chatLabel(row, fullTitles), fullTitles ? 24 : 32).padEnd(fullTitles ? 24 : 32);
489
522
  const links = String(linkCount(row)).padStart(5);
@@ -556,7 +589,7 @@ async function cmdFind(flags, positional) {
556
589
 
557
590
  const limit = flags.limit > 0 ? flags.limit : 50;
558
591
  const fullWhere = where ? `${where} AND ${matchSql}` : `WHERE ${matchSql}`;
559
- const sql = `${SELECT_BASE} ${fullWhere} ORDER BY ${order} LIMIT ?`;
592
+ const sql = `${selectBase(db)} ${fullWhere} ORDER BY ${order} LIMIT ?`;
560
593
  const allParams = [...params, ...matchParams, ...orderParams, limit];
561
594
  const rows = db.prepare(sql).all(...allParams);
562
595
 
@@ -691,7 +724,7 @@ async function cmdGrep(flags, positional) {
691
724
  const fullWhere = where ? `${where} AND ${contentClause}` : `WHERE ${contentClause}`;
692
725
 
693
726
  const limit = flags.limit > 0 ? flags.limit : 50;
694
- const sql = `${SELECT_BASE} ${fullWhere}
727
+ const sql = `${selectBase(db)} ${fullWhere}
695
728
  ORDER BY m.reinforcedImportance DESC, m.createdAt DESC
696
729
  LIMIT ?`;
697
730
  const rows = db.prepare(sql).all(...params, likeNeedle, limit);
@@ -805,7 +838,7 @@ async function cmdShow(flags, positional) {
805
838
  const { db } = await openDb(flags);
806
839
  try {
807
840
  const id = resolveMemoryId(db, idArg);
808
- const row = db.prepare(`${SELECT_BASE} WHERE m.id = ?`).get(id);
841
+ const row = db.prepare(`${selectBase(db)} WHERE m.id = ?`).get(id);
809
842
  if (!row) {
810
843
  throw new Error(`Memory ${id} not found`);
811
844
  }
@@ -878,6 +911,7 @@ function renderShowText(row, graph, depth) {
878
911
  console.log(` Holder: ${holderName} (${shortId(row.characterId)})`);
879
912
  console.log(` About: ${aboutName}${row.aboutCharacterId && row.aboutCharacterId !== row.characterId ? ` (${shortId(row.aboutCharacterId)})` : ''}`);
880
913
  console.log(` Source: ${row.source || '(?)'}`);
914
+ console.log(` Tier: ${row.tier || 'hot'}${row.supersededById ? ` (superseded by ${shortId(row.supersededById)})` : ''}`);
881
915
  const reinf = row.reinforcedImportance != null ? Number(row.reinforcedImportance).toFixed(2) : '?';
882
916
  const baseImp = row.importance != null ? Number(row.importance).toFixed(2) : '?';
883
917
  console.log(` Importance: ${reinf} (reinforced from ${baseImp}, count: ${row.reinforcementCount ?? 0})`);
@@ -1115,6 +1149,19 @@ function computeHolderStats(db, characterId) {
1115
1149
  WHERE characterId = ?
1116
1150
  `).get(characterId);
1117
1151
 
1152
+ // Tier rollup — guarded so pre-tier instances still report.
1153
+ let tiers = { hot: counts.total || 0, cold: 0, consolidated: 0 };
1154
+ try {
1155
+ const t = db.prepare(`
1156
+ SELECT
1157
+ SUM(CASE WHEN COALESCE(tier, 'hot') = 'hot' THEN 1 ELSE 0 END) AS hot,
1158
+ SUM(CASE WHEN tier = 'cold' THEN 1 ELSE 0 END) AS cold,
1159
+ SUM(CASE WHEN source = 'CONSOLIDATED' THEN 1 ELSE 0 END) AS consolidated
1160
+ FROM memories WHERE characterId = ?
1161
+ `).get(characterId);
1162
+ tiers = { hot: t.hot || 0, cold: t.cold || 0, consolidated: t.consolidated || 0 };
1163
+ } catch { /* tier column absent: everything is hot */ }
1164
+
1118
1165
  const graph = scanDanglingEdges(db, { characterId });
1119
1166
 
1120
1167
  const topMemories = db.prepare(`
@@ -1131,6 +1178,7 @@ function computeHolderStats(db, characterId) {
1131
1178
  auto: counts.auto || 0,
1132
1179
  manual: counts.manual || 0,
1133
1180
  },
1181
+ tiers,
1134
1182
  aboutDistribution: {
1135
1183
  selfReferential: counts.selfRef || 0,
1136
1184
  aboutOthers: counts.aboutOthers || 0,
@@ -1156,6 +1204,10 @@ function renderStatusBlock(holder, stats) {
1156
1204
  console.log(` Total memories: ${stats.counts.total}`);
1157
1205
  console.log(` AUTO: ${stats.counts.auto}`);
1158
1206
  console.log(` MANUAL: ${stats.counts.manual}`);
1207
+ console.log(` Tiers:`);
1208
+ console.log(` hot: ${stats.tiers.hot}`);
1209
+ console.log(` cold: ${stats.tiers.cold}`);
1210
+ console.log(` digests (CONSOLIDATED): ${stats.tiers.consolidated}`);
1159
1211
  console.log(` About-distribution:`);
1160
1212
  console.log(` self-referential: ${stats.aboutDistribution.selfReferential}`);
1161
1213
  console.log(` about-others: ${stats.aboutDistribution.aboutOthers}`);
@@ -1292,11 +1344,19 @@ Subcommands:
1292
1344
  validate [--character <name|id>] [--list] Read-only memory-graph health
1293
1345
  [--json] check. Exits 1 if any
1294
1346
  dangling edges remain.
1347
+ consolidate --character <name|id> [--dry-run] [--max N] [--threshold 0..1]
1348
+ [--port N] Fold clusters of hot memories
1349
+ into digests (cold originals).
1350
+ Asks the running server: a dry
1351
+ run prints the proposed digests
1352
+ and writes nothing; otherwise a
1353
+ background job is queued.
1295
1354
 
1296
1355
  Shared filter flags:
1297
1356
  --character <name|id|all> Holder. Default: all.
1298
1357
  --about <name|id|self|none> Subject (aboutCharacterId).
1299
- --source AUTO|MANUAL Restrict by source.
1358
+ --source AUTO|MANUAL|CONSOLIDATED Restrict by source.
1359
+ --tier hot|cold Restrict by recall tier (default: both).
1300
1360
  --chat <id|title|none> Source chat ('none' for manual memories).
1301
1361
  --project <id|name> Project context.
1302
1362
  --since <date> ISO date floor on createdAt.
@@ -1324,7 +1384,8 @@ Note: -i is reserved here for 'grep --ignore-case'. Use the long --instance
1324
1384
  form to target a registered instance.
1325
1385
 
1326
1386
  All memories verbs are read-only. They open the main encrypted database
1327
- (quilltap.db) and never write to it.
1387
+ (quilltap.db) and never write to it; 'consolidate' leaves the writing to the
1388
+ running server.
1328
1389
 
1329
1390
  Examples:
1330
1391
  quilltap memories ls
@@ -1337,11 +1398,23 @@ Examples:
1337
1398
  quilltap memories status --character Ariadne
1338
1399
  quilltap memories validate
1339
1400
  quilltap memories validate --list
1401
+ quilltap memories consolidate --instance Friday --character Friday --dry-run
1402
+ quilltap memories consolidate --instance Friday --character Friday --max 100
1340
1403
  `);
1341
1404
  }
1342
1405
 
1343
1406
  // ---------- dispatcher ----------
1344
1407
 
1408
+ /** Resolve --character locally to a UUID, for verbs that call the running server. */
1409
+ async function resolveCharacterIdForServer(flags) {
1410
+ const { db, openMounts } = await openDb(flags);
1411
+ try {
1412
+ return resolveCharacter(db, flags.character, openMounts).id;
1413
+ } finally {
1414
+ db.close();
1415
+ }
1416
+ }
1417
+
1345
1418
  async function memoriesCommand(args) {
1346
1419
  if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
1347
1420
  printMemoriesHelp();
@@ -1365,6 +1438,7 @@ async function memoriesCommand(args) {
1365
1438
  case 'tree': await cmdTree(flags, positional); break;
1366
1439
  case 'status': await cmdStatus(flags); break;
1367
1440
  case 'validate': await cmdValidate(flags); break;
1441
+ case 'consolidate': await cmdConsolidate(flags, resolveCharacterIdForServer); break;
1368
1442
  default:
1369
1443
  console.error(`Unknown memories subcommand: ${verb}`);
1370
1444
  console.error("Run 'quilltap memories --help' for a list.");
@@ -1376,6 +1450,7 @@ module.exports = {
1376
1450
  memoriesCommand,
1377
1451
  // Exported for unit tests:
1378
1452
  parseFlags,
1453
+ resolveCharacterIdForServer,
1379
1454
  buildWhereClause,
1380
1455
  buildOrderBy,
1381
1456
  traverseMemoryGraph,
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `quilltap memories consolidate` — fold clusters of hot memories into
5
+ * digests. The CLI's database access is read-only and cannot call a model, so
6
+ * this resolves the character locally and then asks the running server
7
+ * (POST /api/v1/memories?action=consolidate). A dry run is rehearsed in the
8
+ * server process and prints the proposed digests; a real run is queued as a
9
+ * background job.
10
+ */
11
+
12
+ const RESET = '\x1b[0m';
13
+ const BOLD = '\x1b[1m';
14
+ const DIM = '\x1b[2m';
15
+ const GREEN = '\x1b[32m';
16
+ const YELLOW = '\x1b[33m';
17
+ const CYAN = '\x1b[36m';
18
+
19
+ function clip(text, n) {
20
+ const flat = String(text || '').replace(/\s+/g, ' ').trim();
21
+ return flat.length > n ? flat.slice(0, n - 1) + '…' : flat;
22
+ }
23
+
24
+ function bucketLabel(bucket) {
25
+ if (bucket.kind === 'self') return 'about self';
26
+ if (bucket.kind === 'other') return `about ${bucket.subjectName}`;
27
+ return 'about no one';
28
+ }
29
+
30
+ /** Pretty-print a ConsolidationReport as cluster → members → digests. */
31
+ function renderReport(report, write = (s) => process.stdout.write(s)) {
32
+ const stats = report.stats || {};
33
+ write(`${BOLD}Consolidation ${report.dryRun ? 'dry run' : 'report'}${RESET}` +
34
+ `${report.characterName ? ` for ${report.characterName}` : ''}\n`);
35
+ if (report.skippedReason) {
36
+ write(`${YELLOW}Skipped: ${report.skippedReason}${RESET}\n`);
37
+ return;
38
+ }
39
+ const clusters = report.clusters || [];
40
+ clusters.forEach((cluster, idx) => {
41
+ write(`\n${BOLD}Cluster ${idx + 1}${RESET} ${DIM}(${bucketLabel(cluster.bucket)}, ${cluster.clusterKind}, ${cluster.status})${RESET}\n`);
42
+ if (cluster.error) write(` ${YELLOW}${cluster.error}${RESET}\n`);
43
+ const standalone = new Set(cluster.keepStandalone || []);
44
+ (cluster.memberIds || []).forEach((id, i) => {
45
+ const tag = standalone.has(id) ? ` ${DIM}[kept standalone]${RESET}` : '';
46
+ write(` ${DIM}-${RESET} ${DIM}${String(id).slice(0, 8)}${RESET} ${clip(cluster.memberContents[i], 160)}${tag}\n`);
47
+ });
48
+ for (const d of cluster.digests || []) {
49
+ write(` ${GREEN}=>${RESET} ${CYAN}${d.action === 'update' ? 'update digest' : 'new digest'}${RESET} ` +
50
+ `${DIM}(imp ${Number(d.importance).toFixed(2)}, from ${(d.memberIds || []).length})${RESET}\n`);
51
+ write(` ${clip(d.content, 400)}\n`);
52
+ }
53
+ for (const c of cluster.contradictions || []) {
54
+ write(` ${YELLOW}contradiction${RESET}: ${clip(c.note, 200)} ${DIM}(${String(c.olderId).slice(0, 8)} -> ${String(c.newerId).slice(0, 8)})${RESET}\n`);
55
+ }
56
+ });
57
+ if (clusters.length === 0) write('\n(no clusters worth folding)\n');
58
+ write(`\n${DIM}candidates ${stats.candidates ?? 0}, clusters found ${stats.clustersFound ?? 0}, ` +
59
+ `attempted ${stats.clustersAttempted ?? 0}, deferred ${stats.clustersDeferred ?? 0}, ` +
60
+ `${((stats.durationMs ?? 0) / 1000).toFixed(1)}s${stats.budgetExhausted ? ', time budget exhausted' : ''}${RESET}\n`);
61
+ }
62
+
63
+ /** Build the request body for the consolidate action from parsed CLI flags. */
64
+ function buildConsolidateBody(characterId, flags) {
65
+ const body = { characterId, dryRun: flags.dryRun === true };
66
+ if (flags.max > 0) body.maxClustersPerRun = flags.max;
67
+ if (flags.threshold >= 0) body.clusterThreshold = flags.threshold;
68
+ return body;
69
+ }
70
+
71
+ /**
72
+ * @param {object} flags parsed memories flags
73
+ * @param {(flags: object) => Promise<string>} resolveCharacterId resolves --character to a UUID
74
+ */
75
+ async function cmdConsolidate(flags, resolveCharacterId) {
76
+ if (!flags.character || flags.character === 'all') {
77
+ console.error('Error: memories consolidate requires --character <name|id>.');
78
+ process.exit(1);
79
+ }
80
+ const characterId = await resolveCharacterId(flags);
81
+ if (!/^[0-9a-fA-F-]{36}$/.test(characterId)) {
82
+ console.error(`Error: could not resolve --character to a UUID (got "${characterId}").`);
83
+ process.exit(1);
84
+ }
85
+
86
+ const port = flags.port || 3000;
87
+ const url = `http://localhost:${port}/api/v1/memories?action=consolidate`;
88
+ const body = buildConsolidateBody(characterId, flags);
89
+
90
+ if (!flags.json) {
91
+ process.stderr.write(`${BOLD}${body.dryRun ? 'Rehearsing' : 'Queuing'} consolidation${RESET} via ${DIM}${url}${RESET}\n`);
92
+ }
93
+
94
+ let res;
95
+ try {
96
+ res = await fetch(url, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify(body),
100
+ });
101
+ } catch (err) {
102
+ console.error(`Could not reach Quilltap server at http://localhost:${port}: ${err.message}`);
103
+ console.error('Consolidation requires the running server (the model and embeddings live there).');
104
+ console.error('Start it with: npm run dev');
105
+ process.exit(1);
106
+ }
107
+
108
+ let payload = null;
109
+ try {
110
+ const text = await res.text();
111
+ payload = text ? JSON.parse(text) : null;
112
+ } catch { /* leave payload null */ }
113
+
114
+ if (!res.ok) {
115
+ console.error(`Error: ${(payload && payload.error) || `HTTP ${res.status}`}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ if (flags.json) {
120
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
121
+ return;
122
+ }
123
+
124
+ if (body.dryRun) {
125
+ renderReport(payload.report);
126
+ } else {
127
+ console.log(`Consolidation queued as job ${payload.jobId}. It runs in the background; watch logs/combined.log.`);
128
+ }
129
+ }
130
+
131
+ module.exports = { cmdConsolidate, renderReport, buildConsolidateBody };
@@ -33,7 +33,30 @@ Options:
33
33
  --turn <number> 1-based interchange to replay at (default: last)
34
34
  --char <characterId> Character whose memories are searched
35
35
  (default: first LLM-controlled participant)
36
- --limit <number> Candidate rows per path (default: 25, max: 100)
36
+ --limit <number> Candidate rows per path (default: the larger of 25
37
+ and the new path's head size + 10; max: 100)
38
+ --memory-budget <tokens>
39
+ Memory token budget (positive integer). The new
40
+ path's selected rows then follow the budget-sized
41
+ dynamic head (the head scales with the model's
42
+ memory budget); the old path keeps the historical
43
+ fixed 5-entry head.
44
+ --tuning '<json>' Retuning constants for the NEW path only, e.g.
45
+ '{"boostGateAbs":0.45,"boostGateMargin":0.15,
46
+ "boostGateRamp":0.1,"boostCap":1.6}'. Keys:
47
+ boostGateAbs, boostGateMargin, boostGateRamp,
48
+ boostCap, freshBypassesGate, windowBypassesGate,
49
+ multipliers
50
+ {RECALL_MULTIPLIERS key:
51
+ value}, specificAnchors, anchorMinHits,
52
+ anchorOrder, backgroundReserve.
53
+ Unknown keys are rejected.
54
+ --tuning-file <path> Read the --tuning JSON from a file
55
+ --signals-from <path> Reuse the signals saved in a previous --json
56
+ replay of the same chat and turn, instead of a
57
+ fresh (non-deterministic) distillation
58
+ --as-of Search only memories created before the turn's
59
+ opening message (the corpus as it stood then)
37
60
  --port <number> Server port for API calls (default: 3000)
38
61
  --json Print the raw JSON result instead of tables
39
62
  -h, --help Show this help
@@ -41,12 +64,19 @@ Options:
41
64
  Examples:
42
65
  quilltap recall-replay <chatId>
43
66
  quilltap recall-replay <chatId> --turn 42
67
+ quilltap recall-replay <chatId> --turn 42 --memory-budget 4000
44
68
  quilltap recall-replay <chatId> --turn 42 --json > replay.json
69
+ quilltap recall-replay <chatId> --turn 42 --signals-from replay.json --as-of \
70
+ --tuning-file tuning-r1.json
45
71
  `);
46
72
  }
47
73
 
48
74
  function parseFlags(args) {
49
- const flags = { turn: undefined, char: undefined, limit: undefined, port: 3000, json: false, help: false };
75
+ const flags = {
76
+ turn: undefined, char: undefined, limit: undefined, memoryBudget: undefined,
77
+ tuning: undefined, tuningFile: undefined, signalsFrom: undefined, asOf: false,
78
+ port: 3000, json: false, help: false,
79
+ };
50
80
  const positional = [];
51
81
  let i = 0;
52
82
  while (i < args.length) {
@@ -73,6 +103,27 @@ function parseFlags(args) {
73
103
  flags.limit = n;
74
104
  break;
75
105
  }
106
+ case '--memory-budget': {
107
+ const n = Number(args[++i]);
108
+ if (!Number.isInteger(n) || n < 1) {
109
+ console.error('Error: --memory-budget must be a positive integer');
110
+ process.exit(1);
111
+ }
112
+ flags.memoryBudget = n;
113
+ break;
114
+ }
115
+ case '--tuning':
116
+ flags.tuning = args[++i];
117
+ break;
118
+ case '--tuning-file':
119
+ flags.tuningFile = args[++i];
120
+ break;
121
+ case '--signals-from':
122
+ flags.signalsFrom = args[++i];
123
+ break;
124
+ case '--as-of':
125
+ flags.asOf = true;
126
+ break;
76
127
  case '--port': {
77
128
  const p = parseInt(args[++i], 10);
78
129
  if (isNaN(p) || p < 1 || p > 65535) {
@@ -106,8 +157,9 @@ function fmt(n, digits = 3) {
106
157
  return n.toFixed(digits);
107
158
  }
108
159
 
109
- function printPath(label, rows) {
110
- console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates)`);
160
+ function printPath(label, rows, headSize) {
161
+ const head = typeof headSize === 'number' ? `, head ${headSize}` : '';
162
+ console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates${head})`);
111
163
  if (rows.length === 0) {
112
164
  console.log(` ${DIM}(none)${RESET}`);
113
165
  return;
@@ -127,6 +179,60 @@ function printPath(label, rows) {
127
179
  }
128
180
  }
129
181
 
182
+ function readJson(text, what) {
183
+ try {
184
+ return JSON.parse(text);
185
+ } catch (err) {
186
+ console.error(`${RED}Could not parse ${what} as JSON: ${err.message}${RESET}`);
187
+ process.exit(1);
188
+ }
189
+ }
190
+
191
+ function readJsonFile(path, what) {
192
+ const fs = require('fs');
193
+ let text;
194
+ try {
195
+ text = fs.readFileSync(path, 'utf8');
196
+ } catch (err) {
197
+ console.error(`${RED}Could not read ${what} ${path}: ${err.message}${RESET}`);
198
+ process.exit(1);
199
+ }
200
+ return readJson(text, `${what} ${path}`);
201
+ }
202
+
203
+ /** The tuning object from --tuning or --tuning-file (never both). */
204
+ function loadTuning(flags) {
205
+ if (flags.tuning !== undefined && flags.tuningFile !== undefined) {
206
+ console.error('Error: pass --tuning or --tuning-file, not both');
207
+ process.exit(1);
208
+ }
209
+ if (flags.tuning !== undefined) return readJson(flags.tuning, '--tuning');
210
+ if (flags.tuningFile !== undefined) return readJsonFile(flags.tuningFile, '--tuning-file');
211
+ return undefined;
212
+ }
213
+
214
+ /**
215
+ * The signals saved in a previous --json replay. Refuses a file from another
216
+ * chat or turn — reusing its signals would replay a different question.
217
+ */
218
+ function loadSignals(path, chatId, turn) {
219
+ const saved = readJsonFile(path, '--signals-from');
220
+ const result = saved.data ?? saved;
221
+ if (!result.signals) {
222
+ console.error(`${RED}${path} has no saved signals (its distillation failed); replay without --signals-from${RESET}`);
223
+ process.exit(1);
224
+ }
225
+ if (result.chatId && result.chatId !== chatId) {
226
+ console.error(`${RED}${path} is a replay of chat ${result.chatId}, not ${chatId}${RESET}`);
227
+ process.exit(1);
228
+ }
229
+ if (turn !== undefined && result.turnIndex !== undefined && result.turnIndex !== turn) {
230
+ console.error(`${RED}${path} is a replay of turn ${result.turnIndex}, not ${turn}${RESET}`);
231
+ process.exit(1);
232
+ }
233
+ return result.signals;
234
+ }
235
+
130
236
  async function recallReplayCommand(args) {
131
237
  const { flags, positional } = parseFlags(args);
132
238
 
@@ -145,6 +251,11 @@ async function recallReplayCommand(args) {
145
251
  if (flags.turn !== undefined) body.turnIndex = flags.turn;
146
252
  if (flags.char) body.characterId = flags.char;
147
253
  if (flags.limit !== undefined) body.limit = flags.limit;
254
+ if (flags.memoryBudget !== undefined) body.memoryBudget = flags.memoryBudget;
255
+ const tuning = loadTuning(flags);
256
+ if (tuning !== undefined) body.tuning = tuning;
257
+ if (flags.signalsFrom !== undefined) body.signals = loadSignals(flags.signalsFrom, chatId, flags.turn);
258
+ if (flags.asOf) body.asOf = true;
148
259
 
149
260
  process.stderr.write(`${BOLD}Replaying recall${RESET} for chat ${DIM}${chatId}${RESET} via ${DIM}${url}${RESET}\n`);
150
261
 
@@ -170,6 +281,11 @@ async function recallReplayCommand(args) {
170
281
  }
171
282
  if (!res.ok || payload?.success === false) {
172
283
  console.error(`${RED}Replay failed (status ${res.status}): ${payload?.error || payload?.message || 'unknown error'}${RESET}`);
284
+ if (Array.isArray(payload?.details)) {
285
+ for (const issue of payload.details) {
286
+ console.error(` ${(issue.path || []).join('.') || '(body)'}: ${issue.message}`);
287
+ }
288
+ }
173
289
  process.exit(1);
174
290
  }
175
291
 
@@ -193,8 +309,15 @@ async function recallReplayCommand(args) {
193
309
  console.log(`${BOLD}Signals${RESET} ${YELLOW}distillation failed — new path ran inert${RESET}`);
194
310
  }
195
311
 
196
- printPath('OLD PATH (episodic signals inert)', result.oldPath || []);
197
- printPath('NEW PATH (retrospective/window/entities live)', result.newPath || []);
312
+ const pinned = result.signalsPinned ? `${CYAN}pinned${RESET}` : `${DIM}fresh distillation${RESET}`;
313
+ const asOf = result.asOf
314
+ ? `${CYAN}as of ${result.asOf}${RESET} ${DIM}(${result.excludedAfterAsOf} later memories left out)${RESET}`
315
+ : `${DIM}whole corpus${RESET}`;
316
+ console.log(`${BOLD}Harness${RESET} signals ${pinned} · ${asOf}`);
317
+ console.log(`${BOLD}Tuning${RESET} ${result.tuning === 'defaults' || !result.tuning ? `${DIM}defaults${RESET}` : `${YELLOW}${result.tuning}${RESET}`} ${DIM}(new path only)${RESET}`);
318
+
319
+ printPath('OLD PATH (episodic signals inert)', result.oldPath || [], result.oldHeadSize);
320
+ printPath('NEW PATH (retrospective/window/entities live)', result.newPath || [], result.newHeadSize);
198
321
  console.log('');
199
322
  }
200
323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.10.0-dev.117",
3
+ "version": "4.10.0-dev.139",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",