quilltap 4.10.0-dev.99 → 4.10.0

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.
@@ -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
  };