quilltap 4.10.0-dev.117 → 4.10.0-dev.140
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 +30 -2
- package/bin/quilltap.js +8 -1
- package/lib/__tests__/completion-coverage.test.js +1 -0
- package/lib/__tests__/memories-consolidate-command.test.js +66 -0
- package/lib/anchor-probe-command.js +191 -0
- package/lib/completion/bash.template +18 -8
- package/lib/completion/fish.template +18 -4
- package/lib/completion/zsh.template +26 -2
- package/lib/memories-commands.js +86 -11
- package/lib/memories-consolidate-command.js +131 -0
- package/lib/recall-replay-command.js +129 -6
- package/package.json +1 -1
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
|
|
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: `--
|
|
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
|
'*: :'
|
package/lib/memories-commands.js
CHANGED
|
@@ -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
|
|
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
|
|
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 = `${
|
|
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(
|
|
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(
|
|
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 = `${
|
|
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 = `${
|
|
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(`${
|
|
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
|
|
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:
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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