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.
- package/README.md +183 -15
- package/bin/quilltap.js +61 -26
- package/lib/__tests__/completion-coverage.test.js +2 -0
- package/lib/__tests__/memories-maintain-command.test.js +276 -0
- package/lib/__tests__/migrations-source-ids.test.js +54 -0
- package/lib/__tests__/server-port-resolution.test.js +122 -0
- package/lib/__tests__/sync-report.test.js +158 -0
- package/lib/anchor-probe-command.js +222 -0
- package/lib/completion/bash.template +47 -11
- package/lib/completion/fish.template +40 -4
- package/lib/completion/zsh.template +68 -3
- package/lib/db-commands.js +39 -3
- package/lib/db-helpers.js +12 -0
- package/lib/graph-integrity.js +108 -5
- package/lib/lock-helpers.js +66 -0
- package/lib/memories-commands.js +396 -76
- package/lib/memories-maintain-command.js +238 -0
- package/lib/memories-server.js +86 -0
- package/lib/memory-diff-command.js +28 -6
- package/lib/migrations-commands.js +18 -4
- package/lib/native-modules.js +102 -1
- package/lib/recall-replay-command.js +193 -11
- package/lib/sync-command.js +323 -0
- package/lib/sync-report.js +167 -0
- package/lib/text-codec.js +90 -0
- package/package.json +1 -1
package/lib/db-commands.js
CHANGED
|
@@ -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
|
|
package/lib/graph-integrity.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 };
|
package/lib/lock-helpers.js
CHANGED
|
@@ -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
|
};
|