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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ const { resolveServerPort } = require('./lock-helpers');
4
+
3
5
  /**
4
6
  * `quilltap anchor-probe <characterId>` — measure whether the episodic anchor
5
- * line embedded with each memory suppresses Memory Gate reinforcement.
7
+ * line embedded with each memory suppresses reinforcement at admission.
6
8
  *
7
9
  * Thin wrapper over POST /api/v1/memories?action=anchor-gate-probe.
8
10
  * Read-only; nothing is persisted server-side, but it costs embedding calls.
@@ -23,16 +25,21 @@ Quilltap anchor-probe Tool
23
25
  Usage: quilltap anchor-probe <characterId> [options]
24
26
 
25
27
  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.
28
+ reinforcement at admission. Re-embeds the character's most recent memories with
29
+ and without the anchor line, compares each against older rows, asks admission's
30
+ own decider what it would do each way, and reports how many rows would fold
31
+ into an older one (reinforce or better) and how many would be absorbed as
32
+ near-duplicates.
30
33
 
31
34
  Read-only, but it costs embedding calls (roughly up to 7 per sampled row).
32
35
 
33
36
  Options:
37
+ -d, --data-dir <path> Instance root; used to find the server's port
38
+ -i, --instance <name> Registered instance whose server to call (see 'quilltap instances')
39
+ --passphrase <pass> Accepted and ignored (this tool never opens the database)
34
40
  --limit <number> Recent memories to sample (default: 50, max: 200)
35
- --port <number> Server port for API calls (default: 3000)
41
+ --port <number> Server port for API calls. Default: the port recorded in the
42
+ --instance/--data-dir server's lock file, else 3000
36
43
  --json Print the raw JSON result instead of tables
37
44
  -h, --help Show this help
38
45
 
@@ -44,7 +51,7 @@ Examples:
44
51
  }
45
52
 
46
53
  function parseFlags(args) {
47
- const flags = { limit: undefined, port: 3000, json: false, help: false };
54
+ const flags = { limit: undefined, port: undefined, dataDir: '', instance: '', json: false, help: false };
48
55
  const positional = [];
49
56
  let i = 0;
50
57
  while (i < args.length) {
@@ -59,6 +66,19 @@ function parseFlags(args) {
59
66
  flags.limit = n;
60
67
  break;
61
68
  }
69
+ case '-d':
70
+ case '--data-dir':
71
+ flags.dataDir = args[++i];
72
+ break;
73
+ case '-i':
74
+ case '--instance':
75
+ flags.instance = args[++i];
76
+ break;
77
+ case '--passphrase':
78
+ // Accepted so universal flags never error; these tools talk to the
79
+ // running server and never open the database themselves.
80
+ i++;
81
+ break;
62
82
  case '--port': {
63
83
  const p = parseInt(args[++i], 10);
64
84
  if (isNaN(p) || p < 1 || p > 65535) {
@@ -107,19 +127,19 @@ function printRows(rows, thresholds) {
107
127
  console.log(
108
128
  ` ${DIM}${'anchored'.padEnd(10)}${'free'.padEnd(10)}${'delta'.padEnd(9)}${'anchor'.padEnd(8)}summary${RESET}`
109
129
  );
110
- const reinforce = thresholds?.reinforce ?? 0.85;
130
+ const folds = (kind) => kind === 'reinforce' || kind === 'absorb' || kind === 'promote-and-reinforce';
111
131
  for (const row of rows) {
112
132
  const a = row.anchoredBest;
113
133
  const f = row.anchorFreeBest;
114
134
  const delta = typeof a === 'number' && typeof f === 'number' ? f - a : null;
115
- const onlyFree = typeof f === 'number' && f >= reinforce && !(typeof a === 'number' && a >= reinforce);
135
+ const onlyFree = folds(row.anchorFreeAction) && !folds(row.anchoredAction);
116
136
  const deltaStr = delta === null ? DIM + '—' + RESET : (delta >= 0 ? '+' : '') + delta.toFixed(3);
117
137
  const anchorStr = row.hasAnchorLine ? 'yes' : `${DIM}no${RESET}`;
118
138
  const summary = (row.summary || '').replace(/\s+/g, ' ').slice(0, 60);
119
139
  const line = `${pad(fmt(a), 10)}${pad(fmt(f), 10)}${pad(deltaStr, 9)}${pad(anchorStr, 8)}${summary}`;
120
140
  console.log(onlyFree ? `${YELLOW}▸ ${RESET}${line}` : ` ${line}`);
121
141
  }
122
- console.log(`\n ${YELLOW}▸${RESET} ${DIM}= crosses ${reinforce} only without the anchor line${RESET}`);
142
+ console.log(`\n ${YELLOW}▸${RESET} ${DIM}= would fold into an older row only without the anchor line (reinforce ≥ ${fmt(thresholds?.reinforce, 2)})${RESET}`);
123
143
  }
124
144
 
125
145
  async function anchorProbeCommand(args) {
@@ -135,7 +155,17 @@ async function anchorProbeCommand(args) {
135
155
  }
136
156
  const characterId = positional[0];
137
157
 
138
- const url = `http://localhost:${flags.port}/api/v1/memories?action=anchor-gate-probe`;
158
+ let target;
159
+ try {
160
+ target = resolveServerPort({ port: flags.port, instance: flags.instance, dataDir: flags.dataDir });
161
+ } catch (err) {
162
+ console.error(`Error: ${err.message}`);
163
+ process.exit(1);
164
+ }
165
+ const port = target.port;
166
+ if (target.note) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
167
+
168
+ const url = `http://localhost:${port}/api/v1/memories?action=anchor-gate-probe`;
139
169
  const body = { characterId };
140
170
  if (flags.limit !== undefined) body.limit = flags.limit;
141
171
 
@@ -149,8 +179,8 @@ async function anchorProbeCommand(args) {
149
179
  body: JSON.stringify(body),
150
180
  });
151
181
  } 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.');
182
+ console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
183
+ console.error(target.hint);
154
184
  process.exit(1);
155
185
  }
156
186
 
@@ -178,14 +208,15 @@ async function anchorProbeCommand(args) {
178
208
  console.log(`\n${BOLD}Character${RESET} ${result.characterId}`);
179
209
  console.log(`${BOLD}Sampled${RESET} ${result.sampled} rows, ${result.anchoredRows} with an anchor line`);
180
210
  console.log(`${BOLD}Embeddings${RESET} ${result.embeddingsGenerated} generated`);
211
+ if (result.note) console.log(`${YELLOW}${result.note}${RESET}`);
181
212
  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}`);
213
+ console.log(`${BOLD}${'would fold'.padEnd(22)}${RESET}${String(anchored.reinforce).padEnd(11)}${CYAN}${free.reinforce}${RESET}`);
214
+ console.log(`${BOLD}${'near-dup absorbed'.padEnd(22)}${RESET}${String(anchored.nearDuplicate).padEnd(11)}${CYAN}${free.nearDuplicate}${RESET}`);
184
215
  const only = result.crossedOnlyWithoutAnchors;
185
- console.log(`${BOLD}Crossed only without anchors${RESET} ${only > 0 ? YELLOW : GREEN}${only}${RESET}`);
216
+ console.log(`${BOLD}Fold only without anchors${RESET} ${only > 0 ? YELLOW : GREEN}${only}${RESET}`);
186
217
 
187
218
  printRows(result.rows || [], t);
188
219
  console.log('');
189
220
  }
190
221
 
191
- module.exports = { anchorProbeCommand, printAnchorProbeHelp };
222
+ module.exports = { anchorProbeCommand, printAnchorProbeHelp, parseFlags };
@@ -69,11 +69,11 @@ _quilltap_complete() {
69
69
  # makes a flag's value look like the subcommand's verb, which is how
70
70
  # `quilltap docs --limit 5 <TAB>` used to lose the verb list.
71
71
  local vf_global=" -d --data-dir -i --instance -p --port --passphrase "
72
- local vf_db=" --limit --grep --character --project --about --source --chat --message --field --tail --last --from --type --out --id --count "
72
+ local vf_db=" --limit --grep --character --project --about --source --tier --chat --message --field --tail --last --from --type --out --id --count "
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 --tier --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 -p --port --phases --cap --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
79
  local vf_recall_replay=" --turn --char --limit --memory-budget --tuning --tuning-file --signals-from --port "
@@ -185,6 +185,10 @@ _quilltap_complete() {
185
185
  COMPREPLY=($(compgen -W "hot cold" -- "$cur"))
186
186
  return
187
187
  ;;
188
+ --phases)
189
+ COMPREPLY=($(compgen -W "fold-near-duplicates consolidate demote retire mirror" -- "$cur"))
190
+ return
191
+ ;;
188
192
  --sort)
189
193
  COMPREPLY=($(compgen -W "name time size links reinforced importance created accessed reinforcement-count" -- "$cur"))
190
194
  return
@@ -220,7 +224,7 @@ _quilltap_complete() {
220
224
  db)
221
225
  local db_verbs="schema find chats messages logs message log memories characters optimize backup integrity"
222
226
  local db_flags="--instance --data-dir --passphrase --json --limit --grep \
223
- --character --project --about --source --chat --message --rendered --field \
227
+ --character --project --about --source --tier --chat --message --rendered --field \
224
228
  --tail --last --full --from --type --out --id --diverged --blocked --help \
225
229
  --tables --count --repl --write --llm-logs --mount-points \
226
230
  --lock-status --lock-clean --lock-override"
@@ -317,13 +321,13 @@ _quilltap_complete() {
317
321
  fi
318
322
  ;;
319
323
  memories)
320
- local mem_verbs="ls find grep show tree status validate consolidate"
324
+ local mem_verbs="ls find grep show tree status validate maintain"
321
325
  local mem_flags="--character --about --source --tier --chat --project --since --until \
322
326
  --min-importance --min-reinforced --has-embedding --no-embedding \
323
327
  --sort -r --reverse --limit --full-titles --in --no-related --list \
324
328
  --ignore-case -i --paths-only -l --max --context --depth --max-nodes \
325
- --semantic --top --threshold --dry-run \
326
- --instance --data-dir --passphrase --port --json --help"
329
+ --semantic --top --threshold --dry-run --phases --cap --repair --write \
330
+ --instance --data-dir --passphrase -p --port --json --help"
327
331
  if [[ -z "$subverb" ]]; then
328
332
  if [[ "$cur" == -* ]]; then
329
333
  COMPREPLY=($(compgen -W "$mem_flags" -- "$cur"))
@@ -339,11 +343,11 @@ _quilltap_complete() {
339
343
  COMPREPLY=($(compgen -W "$md_flags" -- "$cur"))
340
344
  ;;
341
345
  recall-replay)
342
- local rr_flags="--turn --char --limit --memory-budget --tuning --tuning-file --signals-from --as-of --port --json --help"
346
+ local rr_flags="--instance --data-dir --passphrase --turn --char --limit --memory-budget --tuning --tuning-file --signals-from --as-of --port --json --help"
343
347
  COMPREPLY=($(compgen -W "$rr_flags" -- "$cur"))
344
348
  ;;
345
349
  anchor-probe)
346
- local ap_flags="--limit --port --json --help"
350
+ local ap_flags="--instance --data-dir --passphrase --limit --port --json --help"
347
351
  COMPREPLY=($(compgen -W "$ap_flags" -- "$cur"))
348
352
  ;;
349
353
  logs)
@@ -114,6 +114,7 @@ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'character' -d 'Char
114
114
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'project' -d 'Project name or id' -x
115
115
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'about' -d 'Subject character' -x
116
116
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'source' -d 'Memory source' -x -a 'AUTO MANUAL CONSOLIDATED'
117
+ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'tier' -d 'Memory tier' -x -a 'hot cold'
117
118
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'chat' -d 'Chat name or id' -x
118
119
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'message' -d 'Message id' -x
119
120
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'rendered' -d 'Render rich content'
@@ -272,14 +273,18 @@ complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'show' -d '
272
273
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'tree' -d 'Graph walk'
273
274
  complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'status' -d 'Memory status'
274
275
  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'
276
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'maintain' -d 'Run memory maintenance'
276
277
 
277
278
  # memories flags
278
279
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'character' -d 'Character name or id' -x
279
280
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'about' -d 'Subject of memory' -x
280
281
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'source' -d 'Memory source' -x -a 'AUTO MANUAL CONSOLIDATED'
281
282
  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'
283
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'dry-run' -d 'Maintain: rehearse and print the report, write nothing'
284
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'phases' -d 'Maintain: comma-separated phases' -x -a 'fold-near-duplicates consolidate demote retire mirror'
285
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'cap' -d 'Maintain: hot-row cap for demotion' -x
286
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'repair' -d 'Validate: repair the graph on the running server'
287
+ complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'write' -d 'Validate --repair: apply the repair'
283
288
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'chat' -d 'Chat id or title' -x
284
289
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'project' -d 'Project name or id' -x
285
290
  complete -c quilltap -n '__quilltap_using_subcommand memories' -l 'since' -d 'Since timestamp' -x
@@ -152,6 +152,7 @@ _quilltap_db() {
152
152
  '--project[Project name or id]:project:'
153
153
  '--about[Subject character]:character:'
154
154
  '--source[Memory source]:source:(AUTO MANUAL CONSOLIDATED)'
155
+ '--tier[Memory tier]:tier:(hot cold)'
155
156
  '--chat[Chat name or id]:chat:'
156
157
  '--message[Message id]:id:'
157
158
  '--rendered[Render rich content]'
@@ -538,20 +539,24 @@ _quilltap_memories() {
538
539
  'tree:ASCII walk of graph'
539
540
  'status:Per-character rollup'
540
541
  'validate:Health check'
541
- 'consolidate:Fold memory clusters into digests'
542
+ 'maintain:Run memory maintenance (fold, consolidate, demote, retire, mirror)'
542
543
  )
543
544
 
544
545
  mem_opts=(
545
546
  '--instance[Registered instance name]:instance:_quilltap_instance_names'
546
547
  '(-d --data-dir)'{-d,--data-dir}'[Data directory]:directory:_directories'
547
548
  '--passphrase[Database passphrase]:passphrase:'
548
- '--port[Server port]:port:'
549
+ '(-p --port)'{-p,--port}'[Server port]:port:'
549
550
  '--json[JSON output]'
550
551
  '--character[Character name or id]:character:'
551
552
  '--about[Subject of memory]:character:'
552
553
  '--source[Memory source]:source:(AUTO MANUAL CONSOLIDATED)'
553
554
  '--tier[Memory tier]:tier:(hot cold)'
554
- '--dry-run[Consolidate: rehearse and print the report, write nothing]'
555
+ '--dry-run[Maintain: rehearse and print the report, write nothing]'
556
+ '--phases[Maintain: comma-separated phases]:phases:_sequence compadd - fold-near-duplicates consolidate demote retire mirror'
557
+ '--cap[Maintain: hot-row cap for demotion]:n:'
558
+ '--repair[Validate: repair the graph on the running server (dry run without --write)]'
559
+ '--write[Validate --repair: apply the repair]'
555
560
  '--chat[Chat id or title]:chat:'
556
561
  '--project[Project name or id]:project:'
557
562
  '--since[Since timestamp]:date:'
@@ -658,10 +658,20 @@ function cmdMemories(args, ctx) {
658
658
  const { flags } = parseSubArgs(args);
659
659
  const json = asBool(flags.json);
660
660
  const limit = asInt(flags.limit, 50);
661
- if (!flags.character) throw new Error('Usage: quilltap db memories --character <id|name> [--about <id|name>] [--source AUTO|MANUAL] [--limit N]');
661
+ if (!flags.character) throw new Error('Usage: quilltap db memories --character <id|name> [--about <id|name>] [--source AUTO|MANUAL|CONSOLIDATED] [--tier hot|cold] [--limit N]');
662
+ const tier = flags.tier === undefined ? null : String(flags.tier).toLowerCase();
663
+ if (tier !== null && tier !== 'hot' && tier !== 'cold') {
664
+ throw new Error(`--tier must be hot or cold (got '${flags.tier === true ? '' : flags.tier}')`);
665
+ }
662
666
 
663
667
  const db = ctx.openMain();
664
668
  try {
669
+ // Instances that predate the memory tiers have no `tier` column: every
670
+ // row reads as hot there.
671
+ let hasTier = false;
672
+ try {
673
+ hasTier = db.prepare("PRAGMA table_info('memories')").all().some((c) => c.name === 'tier');
674
+ } catch { /* treat as absent */ }
665
675
  const holder = resolveCharacter(db, String(flags.character), ctx.openMounts);
666
676
  const conditions = ['characterId = ?'];
667
677
  const params = [holder.id];
@@ -674,8 +684,13 @@ function cmdMemories(args, ctx) {
674
684
  conditions.push('source = ?');
675
685
  params.push(String(flags.source).toUpperCase());
676
686
  }
687
+ if (tier) {
688
+ if (hasTier) conditions.push(tier === 'hot' ? "COALESCE(tier, 'hot') = 'hot'" : "tier = 'cold'");
689
+ else if (tier === 'cold') conditions.push('0');
690
+ }
691
+ const tierSelect = hasTier ? "COALESCE(tier, 'hot') AS tier" : "'hot' AS tier";
677
692
  const rows = db.prepare(`
678
- SELECT id, createdAt, source, importance, reinforcementCount,
693
+ SELECT id, createdAt, source, ${tierSelect}, importance, reinforcementCount,
679
694
  aboutCharacterId, chatId, summary
680
695
  FROM memories WHERE ${conditions.join(' AND ')}
681
696
  ORDER BY createdAt DESC LIMIT ?
@@ -685,11 +700,12 @@ function cmdMemories(args, ctx) {
685
700
 
686
701
  if (json) return printJson({ holder, total: totalRow.n, returned: rows.length, memories: rows });
687
702
 
688
- console.log(`Memories held by ${holder.name} (${holder.id}) — ${rows.length} of ${totalRow.n}`);
703
+ console.log(`Memories held by ${holder.name} (${holder.id})${tier ? ` [${tier}]` : ''} — ${rows.length} of ${totalRow.n}`);
689
704
  printTable(rows.map(r => ({
690
705
  createdAt: r.createdAt,
691
706
  id: r.id,
692
707
  src: r.source,
708
+ tier: r.tier,
693
709
  imp: r.importance,
694
710
  rein: r.reinforcementCount,
695
711
  about: r.aboutCharacterId === holder.id ? 'self' : (r.aboutCharacterId || ''),
@@ -3,6 +3,11 @@
3
3
  // Shared graph-integrity scanner used by `memories status` (per-holder
4
4
  // rollup) and `memories validate` (read-only dangling-edge check).
5
5
  //
6
+ // `scanDanglingEdges` covers `relatedMemoryIds`; `scanDigestIntegrity`
7
+ // covers the consolidation links (`consolidatedFrom`, `supersededById`) and
8
+ // rows with no embedding — the same four things the server's graph repair
9
+ // (`POST /api/v1/memories?action=repair`) finds and fixes.
10
+ //
6
11
  // "Dangling edges" are UUIDs in `memories.relatedMemoryIds` that no longer
7
12
  // resolve to a row in the `memories` table. Cross-character links are
8
13
  // legitimate — a memory's links may point at memories owned by other holders
@@ -34,10 +39,7 @@ function scanDanglingEdges(db, opts = {}) {
34
39
  // The valid set is *every* memory ID in the table. Computed once even
35
40
  // when restricted to a single holder, because cross-character links are
36
41
  // legitimate and we don't want them counted as dangling.
37
- const allIds = new Set();
38
- for (const row of db.prepare('SELECT id FROM memories').all()) {
39
- allIds.add(row.id);
40
- }
42
+ const allIds = opts.allIds || loadMemoryIds(db);
41
43
 
42
44
  const scanRows = characterId
43
45
  ? db.prepare('SELECT id, characterId, relatedMemoryIds FROM memories WHERE characterId = ?').all(characterId)
@@ -102,4 +104,105 @@ function scanDanglingEdges(db, opts = {}) {
102
104
  return result;
103
105
  }
104
106
 
105
- module.exports = { scanDanglingEdges };
107
+ /** Every memory id in the table — the universe a link may legitimately name. */
108
+ function loadMemoryIds(db) {
109
+ const allIds = new Set();
110
+ for (const row of db.prepare('SELECT id FROM memories').all()) {
111
+ allIds.add(row.id);
112
+ }
113
+ return allIds;
114
+ }
115
+
116
+ /** The memories table's column names (old instances lack the tier-era columns). */
117
+ function memoryColumns(db) {
118
+ try {
119
+ return new Set(db.prepare("PRAGMA table_info('memories')").all().map((c) => c.name));
120
+ } catch {
121
+ return new Set();
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Scan the consolidation links and embeddings for one holder (or every row).
127
+ *
128
+ * - dangling `consolidatedFrom`: member ids a digest names that no longer exist
129
+ * - dangling `supersededById`: members pointing at a digest that is gone
130
+ * - missing embeddings: rows with `embedding IS NULL` (not recallable by
131
+ * meaning until re-embedded; a warning, not graph damage)
132
+ *
133
+ * Columns absent on very old instances read as nothing to scan.
134
+ *
135
+ * @param {import('better-sqlite3').Database} db Open readonly DB handle.
136
+ * @param {object} [opts]
137
+ * @param {string} [opts.characterId] Restrict the scan to one holder.
138
+ * @param {boolean} [opts.includePairs] Return the offending rows (for --list).
139
+ * @param {Set<string>} [opts.allIds] Reuse a precomputed id universe.
140
+ * @returns {{
141
+ * consolidatedFrom: { links: number, digests: number },
142
+ * supersededBy: number,
143
+ * missingEmbeddings: number,
144
+ * consolidatedFromPairs?: Array<{ digestId: string, characterId: string, memberIds: string[] }>,
145
+ * supersededByPairs?: Array<{ memberId: string, characterId: string, digestId: string }>
146
+ * }}
147
+ */
148
+ function scanDigestIntegrity(db, opts = {}) {
149
+ const { characterId, includePairs = false } = opts;
150
+ const cols = opts.columns || memoryColumns(db);
151
+ const allIds = opts.allIds || loadMemoryIds(db);
152
+ const where = characterId ? ' AND characterId = ?' : '';
153
+ const params = characterId ? [characterId] : [];
154
+
155
+ const result = {
156
+ consolidatedFrom: { links: 0, digests: 0 },
157
+ supersededBy: 0,
158
+ missingEmbeddings: 0,
159
+ };
160
+ if (includePairs) {
161
+ result.consolidatedFromPairs = [];
162
+ result.supersededByPairs = [];
163
+ }
164
+
165
+ if (cols.has('consolidatedFrom')) {
166
+ const rows = db.prepare(
167
+ `SELECT id, characterId, consolidatedFrom FROM memories
168
+ WHERE consolidatedFrom IS NOT NULL AND consolidatedFrom != '[]'${where}`
169
+ ).all(...params);
170
+ for (const row of rows) {
171
+ let arr;
172
+ try { arr = JSON.parse(row.consolidatedFrom || '[]'); } catch { arr = []; }
173
+ if (!Array.isArray(arr)) continue;
174
+ const missing = arr.filter((id) => !allIds.has(id));
175
+ if (missing.length === 0) continue;
176
+ result.consolidatedFrom.links += missing.length;
177
+ result.consolidatedFrom.digests++;
178
+ if (includePairs) {
179
+ result.consolidatedFromPairs.push({ digestId: row.id, characterId: row.characterId, memberIds: missing });
180
+ }
181
+ }
182
+ }
183
+
184
+ if (cols.has('supersededById')) {
185
+ const rows = db.prepare(
186
+ `SELECT id, characterId, supersededById FROM memories
187
+ WHERE supersededById IS NOT NULL${where}`
188
+ ).all(...params);
189
+ for (const row of rows) {
190
+ if (allIds.has(row.supersededById)) continue;
191
+ result.supersededBy++;
192
+ if (includePairs) {
193
+ result.supersededByPairs.push({ memberId: row.id, characterId: row.characterId, digestId: row.supersededById });
194
+ }
195
+ }
196
+ }
197
+
198
+ if (cols.has('embedding')) {
199
+ const row = db.prepare(
200
+ `SELECT COUNT(*) AS n FROM memories WHERE embedding IS NULL${where}`
201
+ ).get(...params);
202
+ result.missingEmbeddings = row ? row.n || 0 : 0;
203
+ }
204
+
205
+ return result;
206
+ }
207
+
208
+ module.exports = { scanDanglingEdges, scanDigestIntegrity, loadMemoryIds, memoryColumns };
@@ -110,6 +110,70 @@ function getLockStatus(dataDir) {
110
110
  };
111
111
  }
112
112
 
113
+ // ============================================================================
114
+ // Server port resolution
115
+ //
116
+ // CLI tools that talk to a RUNNING server over HTTP (memory-diff, recall-replay,
117
+ // anchor-probe, memories consolidate) need its port. The server records the
118
+ // port it listens on in the instance lock, so naming an instance (or a data
119
+ // dir) is enough to find it. An explicit --port always wins.
120
+ // ============================================================================
121
+
122
+ const DEFAULT_SERVER_PORT = 3000;
123
+
124
+ /**
125
+ * Decide which port to call.
126
+ *
127
+ * @param {{ port?: number, instance?: string, dataDir?: string }} opts
128
+ * `port` is the explicit --port (undefined when not given).
129
+ * @returns {{ port: number, source: 'flag'|'lock'|'default', note: string|null,
130
+ * hint: string }}
131
+ * `note` is a one-line message to print when the lock supplied the port;
132
+ * `hint` is the connection-failure advice.
133
+ * @throws if both instance and dataDir are given, or the instance is unknown.
134
+ */
135
+ function resolveServerPort({ port, instance, dataDir } = {}) {
136
+ const baseHint = 'Start the server (npm run dev) or pass --port to match a non-default port.';
137
+ if (port !== undefined && port !== null) {
138
+ return { port, source: 'flag', note: null, hint: baseHint };
139
+ }
140
+ if (!instance && !dataDir) {
141
+ return { port: DEFAULT_SERVER_PORT, source: 'default', note: null, hint: baseHint };
142
+ }
143
+
144
+ // Lazy require: db-helpers is heavier and nothing else here needs it.
145
+ const { resolveDataDirAndPassphrase } = require('./db-helpers');
146
+ const resolved = resolveDataDirAndPassphrase({ instance, dataDir });
147
+ const label = resolved.instanceName || resolved.dataDir;
148
+ const status = getLockStatus(resolved.dataDir);
149
+ const lockPort = status.lock && Number.isInteger(status.lock.port) &&
150
+ status.lock.port >= 1 && status.lock.port <= 65535
151
+ ? status.lock.port
152
+ : null;
153
+
154
+ if (status.state === 'active' && lockPort !== null) {
155
+ return {
156
+ port: lockPort,
157
+ source: 'lock',
158
+ note: `Using port ${lockPort} from the ${label} instance lock`,
159
+ hint: baseHint,
160
+ };
161
+ }
162
+
163
+ let why;
164
+ if (status.state === 'active') why = 'its lock records no port (server predates port recording)';
165
+ else if (status.state === 'absent') why = 'it has no instance lock, so no server appears to be running';
166
+ else if (status.state === 'stale') why = `its lock is stale (${status.reason})`;
167
+ else if (status.state === 'suspect') why = `its lock looks suspect (${status.reason})`;
168
+ else why = 'its lock file is unreadable';
169
+ return {
170
+ port: DEFAULT_SERVER_PORT,
171
+ source: 'default',
172
+ note: null,
173
+ hint: `${baseHint}\nNo port found for the ${label} instance: ${why}; fell back to ${DEFAULT_SERVER_PORT}.`,
174
+ };
175
+ }
176
+
113
177
  // ============================================================================
114
178
  // Write-lock acquire / release
115
179
  //
@@ -354,4 +418,6 @@ module.exports = {
354
418
  acquireWriteLock,
355
419
  releaseWriteLock,
356
420
  detectEnvironmentType,
421
+ resolveServerPort,
422
+ DEFAULT_SERVER_PORT,
357
423
  };