quilltap 4.10.0-dev.18 → 4.10.0-dev.184

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.
@@ -16,6 +16,7 @@ const {
16
16
  resolveProject,
17
17
  } = require('./db-helpers');
18
18
  const { getLockStatus } = require('./lock-helpers');
19
+ const { decodeText } = require('./text-codec');
19
20
 
20
21
  // Tables grouped by domain for `db schema` (with-no-arg) overview, and for
21
22
  // DB-routing when a verb names a specific table. Keep this list short — it's
@@ -477,6 +478,11 @@ function cmdMessages(args, ctx) {
477
478
  params.push(last);
478
479
  const rows = db.prepare(sql).all(...params).reverse(); // oldest first
479
480
 
481
+ // `content` may be a brotli BLOB (see lib/text-codec.js). Decode before
482
+ // anything prints or JSON-serializes it — a raw BLOB prints as binary and
483
+ // JSON.stringify turns it into a byte array.
484
+ for (const r of rows) r.content = decodeText(r.content);
485
+
480
486
  if (json) return printJson({ chat, total: totalRow.n, returned: rows.length, messages: rows });
481
487
 
482
488
  console.log(`Chat: ${chat.title} (${chat.id}) — showing ${rows.length} of ${totalRow.n} matching messages`);
@@ -557,6 +563,13 @@ function cmdMessage(args, ctx) {
557
563
  try {
558
564
  const row = db.prepare('SELECT * FROM chat_messages WHERE id = ?').get(id);
559
565
  if (!row) throw new Error(`No chat_message with id ${id}`);
566
+
567
+ // The four large text columns may be brotli BLOBs (see lib/text-codec.js).
568
+ // Decode before anything prints or JSON-serializes them.
569
+ for (const col of ['content', 'opaqueContent', 'description', 'context']) {
570
+ if (col in row) row[col] = decodeText(row[col]);
571
+ }
572
+
560
573
  if (json) return printJson(row);
561
574
 
562
575
  printRecord(`Message ${row.id}`, {
@@ -596,6 +609,13 @@ function cmdLog(args, ctx) {
596
609
  try {
597
610
  const row = db.prepare('SELECT * FROM llm_logs WHERE id = ?').get(id);
598
611
  if (!row) throw new Error(`No llm_log with id ${id}`);
612
+
613
+ // request/response may be brotli BLOBs (see lib/text-codec.js). Decode
614
+ // before anything reads, prints or JSON-serializes them — a raw BLOB
615
+ // would print as binary and JSON.stringify as a byte array.
616
+ row.request = decodeText(row.request);
617
+ row.response = decodeText(row.response);
618
+
599
619
  if (json) return printJson(row);
600
620
 
601
621
  let finishReason = null;
@@ -638,10 +658,20 @@ function cmdMemories(args, ctx) {
638
658
  const { flags } = parseSubArgs(args);
639
659
  const json = asBool(flags.json);
640
660
  const limit = asInt(flags.limit, 50);
641
- 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
+ }
642
666
 
643
667
  const db = ctx.openMain();
644
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 */ }
645
675
  const holder = resolveCharacter(db, String(flags.character), ctx.openMounts);
646
676
  const conditions = ['characterId = ?'];
647
677
  const params = [holder.id];
@@ -654,8 +684,13 @@ function cmdMemories(args, ctx) {
654
684
  conditions.push('source = ?');
655
685
  params.push(String(flags.source).toUpperCase());
656
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";
657
692
  const rows = db.prepare(`
658
- SELECT id, createdAt, source, importance, reinforcementCount,
693
+ SELECT id, createdAt, source, ${tierSelect}, importance, reinforcementCount,
659
694
  aboutCharacterId, chatId, summary
660
695
  FROM memories WHERE ${conditions.join(' AND ')}
661
696
  ORDER BY createdAt DESC LIMIT ?
@@ -665,11 +700,12 @@ function cmdMemories(args, ctx) {
665
700
 
666
701
  if (json) return printJson({ holder, total: totalRow.n, returned: rows.length, memories: rows });
667
702
 
668
- 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}`);
669
704
  printTable(rows.map(r => ({
670
705
  createdAt: r.createdAt,
671
706
  id: r.id,
672
707
  src: r.source,
708
+ tier: r.tier,
673
709
  imp: r.importance,
674
710
  rein: r.reinforcementCount,
675
711
  about: r.aboutCharacterId === holder.id ? 'self' : (r.aboutCharacterId || ''),
package/lib/db-helpers.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
+ const { registerTextCodecFunction } = require('./text-codec');
5
6
 
6
7
  const CTRL_C = String.fromCharCode(3);
7
8
  const CTRL_D = String.fromCharCode(4);
@@ -201,6 +202,17 @@ function openEncryptedDb(dbPath, pepper, { readonly = true, friendlyName = 'data
201
202
  'The database may be encrypted with a different key, or the .dbkey file may be missing.');
202
203
  }
203
204
 
205
+ // Compressed text columns (llm_logs.request/response, chat_messages.content
206
+ // and friends) are BLOBs; qt_text() lets raw SQL and the repl read inside
207
+ // them. It is also REQUIRED for any --write that touches chat_messages: the
208
+ // message search triggers call it, so a connection without it fails the
209
+ // write loudly rather than letting the index drift.
210
+ try {
211
+ registerTextCodecFunction(db);
212
+ } catch {
213
+ // An old better-sqlite3 without db.function must not block a read.
214
+ }
215
+
204
216
  return db;
205
217
  }
206
218
 
@@ -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
  };