rcs-context 0.0.0-stage → 0.1.39

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.
@@ -0,0 +1,533 @@
1
+ // Copyright (c) 2026 ScreamingMaggot. This source code is licensed under the MIT License.
2
+ // memory-trace.mjs — Phase 4 retrieval traces (audit only, never a fact source).
3
+ //
4
+ // A trace records *how* a retrieval decision was made (scope, stages, candidates,
5
+ // selected) — never memory bodies, prompts or vectors. Writes go through a
6
+ // process-local queue on a separate SQLite connection; every failure is swallowed
7
+ // so tracing can never break a tool call or the injection path.
8
+
9
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
10
+ import { createHash } from 'node:crypto'
11
+ import { dirname, join } from 'node:path'
12
+ import { DatabaseSync } from 'node:sqlite'
13
+
14
+ export const TRACE_TRIGGERS = new Set(['tool', 'ls', 'recall', 'inject', 'handover'])
15
+
16
+ const sha1 = (s) => createHash('sha1').update(String(s ?? '')).digest('hex')
17
+ const hash8 = (s) => '#' + sha1(s).slice(0, 8)
18
+ const clip = (s, n) => {
19
+ const t = String(s ?? '')
20
+ return t.length > n ? t.slice(0, Math.max(1, n - 1)) + '…' : t
21
+ }
22
+ const int = (v) => (Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0)
23
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
24
+ const clean = (s, n) => clip(String(s ?? '').replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(), n)
25
+
26
+ export const TRACE_SCHEMA = `
27
+ CREATE TABLE IF NOT EXISTS retrieval_traces (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ ts INTEGER NOT NULL,
30
+ sid TEXT NOT NULL,
31
+ workspace TEXT NOT NULL DEFAULT '',
32
+ trigger TEXT NOT NULL,
33
+ uri TEXT NOT NULL DEFAULT '',
34
+ query_hash TEXT NOT NULL DEFAULT '',
35
+ query_text TEXT NOT NULL DEFAULT '',
36
+ query_len INTEGER NOT NULL DEFAULT 0,
37
+ mode TEXT NOT NULL DEFAULT '',
38
+ widened TEXT NOT NULL DEFAULT '[]',
39
+ steps TEXT NOT NULL DEFAULT '[]',
40
+ candidates TEXT NOT NULL DEFAULT '[]',
41
+ selected TEXT NOT NULL DEFAULT '[]',
42
+ cand_total INTEGER NOT NULL DEFAULT 0,
43
+ sel_total INTEGER NOT NULL DEFAULT 0,
44
+ ms REAL NOT NULL DEFAULT 0,
45
+ model TEXT NOT NULL DEFAULT '',
46
+ error TEXT NOT NULL DEFAULT '',
47
+ truncated INTEGER NOT NULL DEFAULT 0,
48
+ bytes INTEGER NOT NULL DEFAULT 0
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_traces_sid_ts ON retrieval_traces(sid, ts DESC);
51
+ CREATE INDEX IF NOT EXISTS idx_traces_ws_ts ON retrieval_traces(workspace, ts DESC);
52
+ CREATE INDEX IF NOT EXISTS idx_traces_trigger_ts ON retrieval_traces(trigger, ts DESC);
53
+ `
54
+
55
+ export function ensureTraceSchema(db) {
56
+ db.exec(TRACE_SCHEMA)
57
+ return true
58
+ }
59
+
60
+ const SECRET_KV = /((?:password|passwd|pwd|token|api[_-]?key|secret)\s*[=:]\s*)([^\s,;]+)/gi
61
+
62
+ // Single redaction entry point. Idempotent: applying it twice is a no-op.
63
+ export function redactQuery(text) {
64
+ let s = String(text ?? '').replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
65
+ s = s.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
66
+ s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, 'sk-[REDACTED]')
67
+ s = s.replace(SECRET_KV, (_m, p) => p + '[REDACTED]')
68
+ s = s.replace(/\b([a-z][a-z0-9+.-]*):\/\/([^/\s:@]+):([^/\s@]+)@/gi,
69
+ (_m, sch, user, pass) => `${sch}://<cred:${hash8(user + ':' + pass)}>@`)
70
+ s = s.replace(/(?<![A-Za-z0-9+])(?:[A-Za-z]):[\\/][^\s"'<>|,。;、:))\]}]*/g, (m) => `<path:${hash8(m)}>`)
71
+ s = s.replace(/\\\\[^\s"'<>|,。;、:))\]}]+/g, (m) => `<path:${hash8(m)}>`)
72
+ s = s.replace(/(?<![\w:])\/(?:home|Users|var|tmp|opt|etc|root|usr|mnt|d)\/[^\s"'<>|,。;、:))\]}]*/g,
73
+ (m) => `<path:${hash8(m)}>`)
74
+ s = s.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, (m) => `<email:${hash8(m)}>`)
75
+ s = s.replace(/\b[A-Za-z0-9_-]{32,}\b/g, (m) => `<blob:${hash8(m)}>`)
76
+ s = s.replace(SECRET_KV, (_m, p) => p + '[REDACTED]')
77
+ return s.length > 240 ? s.slice(0, 239) + '…' : s
78
+ }
79
+
80
+ function sanitizeSid(sid) {
81
+ const s = clean(sid, 120)
82
+ if (!s || s === 'unknown') return ''
83
+ return s
84
+ }
85
+ function normalizeWorkspace(w) {
86
+ return clean(String(w ?? '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(), 300)
87
+ }
88
+ function cleanStep(s) {
89
+ if (!s || typeof s !== 'object') return null
90
+ const out = { stage: clean(s.stage, 24), n: int(s.n), ms: Math.round(num(s.ms) * 1000) / 1000 }
91
+ if (s.why) out.why = clean(s.why, 48)
92
+ if (s.uri) out.uri = clean(s.uri, 200)
93
+ return out.stage ? out : null
94
+ }
95
+ function cleanCandidate(c) {
96
+ if (!c || typeof c !== 'object') return null
97
+ const out = { uri: clean(c.uri, 200), kind: clean(c.kind, 24), rank: int(c.rank) }
98
+ if (Number.isFinite(Number(c.score))) out.score = Math.round(Number(c.score) * 10000) / 10000
99
+ if (c.src) out.src = clean(c.src, 16)
100
+ if (c.why) out.why = clean(c.why, 48)
101
+ return out.uri ? out : null
102
+ }
103
+ function cleanSelected(c) {
104
+ if (!c || typeof c !== 'object') return null
105
+ const out = { uri: clean(c.uri, 200), kind: clean(c.kind, 24), rank: int(c.rank) }
106
+ if (c.why) out.why = clean(c.why, 48)
107
+ return out.uri ? out : null
108
+ }
109
+ function jsonBytes(v) {
110
+ try { return Buffer.byteLength(JSON.stringify(v)) } catch { return 0 }
111
+ }
112
+
113
+ // Build a DB-shaped row with all caps applied. Returns null for missing session
114
+ // or an unknown trigger; never throws on odd input.
115
+ export function buildTrace(raw = {}) {
116
+ const sid = sanitizeSid(raw.sid)
117
+ const trigger = clean(raw.trigger, 16)
118
+ if (!sid || !TRACE_TRIGGERS.has(trigger)) return null
119
+ const qRaw = String(raw.query ?? '')
120
+ const queryText = process.env.DSH_MEMORY_TRACE_TEXT === '0' ? '' : redactQuery(qRaw)
121
+ const ts = Number.isFinite(Number(raw.ts)) ? Math.max(0, int(raw.ts)) : Date.now()
122
+ let truncated = 0
123
+
124
+ let steps = (Array.isArray(raw.steps) ? raw.steps : []).map(cleanStep).filter(Boolean)
125
+ if (steps.length > 16) { steps = steps.slice(0, 16); truncated |= 4 }
126
+ while (steps.length && jsonBytes(steps) > 4096) { steps.pop(); truncated |= 4 }
127
+
128
+ let candidates = (Array.isArray(raw.candidates) ? raw.candidates : []).map(cleanCandidate).filter(Boolean)
129
+ const candTotal = Number.isFinite(Number(raw.candTotal)) ? int(raw.candTotal) : candidates.length
130
+ if (candidates.length > 40) { candidates = candidates.slice(0, 40); truncated |= 1 }
131
+ while (candidates.length && jsonBytes(candidates) > 8192) { candidates.pop(); truncated |= 1 }
132
+
133
+ let selected = (Array.isArray(raw.selected) ? raw.selected : []).map(cleanSelected).filter(Boolean)
134
+ const selTotal = Number.isFinite(Number(raw.selTotal)) ? int(raw.selTotal) : selected.length
135
+ if (selected.length > 20) { selected = selected.slice(0, 20); truncated |= 2 }
136
+ while (selected.length && jsonBytes(selected) > 4096) { selected.pop(); truncated |= 2 }
137
+
138
+ const widened = (Array.isArray(raw.widened) ? raw.widened : []).map((x) => clean(x, 32)).filter(Boolean).slice(0, 16)
139
+ const row = {
140
+ ts,
141
+ sid,
142
+ workspace: normalizeWorkspace(raw.workspace),
143
+ trigger,
144
+ uri: clean(raw.uri, 300),
145
+ query_hash: qRaw.trim() ? sha1(qRaw.trim()).slice(0, 16) : '',
146
+ query_text: queryText,
147
+ query_len: qRaw.length,
148
+ mode: clean(raw.mode, 32),
149
+ widened: JSON.stringify(widened),
150
+ steps: JSON.stringify(steps),
151
+ candidates: JSON.stringify(candidates),
152
+ selected: JSON.stringify(selected),
153
+ cand_total: candTotal,
154
+ sel_total: selTotal,
155
+ ms: Math.round(num(raw.ms) * 1000) / 1000,
156
+ model: clean(raw.model, 64),
157
+ error: clean(redactQuery(raw.error ?? ''), 200),
158
+ truncated,
159
+ bytes: 0,
160
+ }
161
+ // Whole-row cap: drop tails of the three JSON arrays; if still oversized,
162
+ // clear them and mark bit 8 so the UI can say "详情已裁剪".
163
+ const fit = () => jsonBytes({ ...row, bytes: 0 })
164
+ while (fit() > 16384 && (candidates.length || selected.length || steps.length)) {
165
+ if (candidates.length) { candidates.pop(); row.truncated |= 1 }
166
+ else if (selected.length) { selected.pop(); row.truncated |= 2 }
167
+ else { steps.pop(); row.truncated |= 4 }
168
+ row.candidates = JSON.stringify(candidates)
169
+ row.selected = JSON.stringify(selected)
170
+ row.steps = JSON.stringify(steps)
171
+ }
172
+ if (fit() > 16384) {
173
+ row.steps = '[]'; row.candidates = '[]'; row.selected = '[]'
174
+ row.truncated |= 8
175
+ }
176
+ row.bytes = fit()
177
+ return row
178
+ }
179
+
180
+ function traceEnabledFor(store) {
181
+ const env = String(process.env.DSH_MEMORY_TRACE ?? '').trim()
182
+ if (env === '0' || env.toLowerCase() === 'false') return false
183
+ if (env === '1' || env.toLowerCase() === 'true') return true
184
+ try {
185
+ const c = JSON.parse(readFileSync(join(store.stateDir, 'control.json'), 'utf8')) ?? {}
186
+ if (c.memoryTrace === false) return false
187
+ if (c.memoryTrace === true) return true
188
+ } catch { /* default on */ }
189
+ return true
190
+ }
191
+
192
+ export function traceDbPath(store) {
193
+ if (process.env.DSH_MEMORY_TRACE_DB) return process.env.DSH_MEMORY_TRACE_DB
194
+ let loc = ''
195
+ try { loc = String(store?.db?.location?.() ?? '') } catch { /* ignore */ }
196
+ if (loc && loc !== ':memory:') return loc
197
+ return join(store.stateDir || '.', 'memory', 'traces.sqlite')
198
+ }
199
+
200
+ const MAX_ROWS = () => Math.max(100, Number(process.env.DSH_MEMORY_TRACE_ROWS) || 5000)
201
+ const MAX_DAYS = () => Math.max(1, Number(process.env.DSH_MEMORY_TRACE_DAYS) || 30)
202
+
203
+ class TraceRecorder {
204
+ constructor(dbFile, { sync = false, maxQueue = 256, batch = 32, intervalMs = 250 } = {}) {
205
+ this.dbFile = dbFile
206
+ this.sync = sync
207
+ this.maxQueue = maxQueue
208
+ this.batch = batch
209
+ this.queue = []
210
+ this.writing = false
211
+ this.closed = false
212
+ this.fail = 0
213
+ this.dropped = 0
214
+ this.written = 0
215
+ this.lastError = ''
216
+ this.lastRetain = Date.now()
217
+ this.sinceRetain = 0
218
+ this.db = new DatabaseSync(dbFile)
219
+ this.db.exec('PRAGMA busy_timeout=50')
220
+ try { this.db.exec('PRAGMA synchronous=NORMAL') } catch { /* connection-level */ }
221
+ ensureTraceSchema(this.db)
222
+ this.insert = this.db.prepare(`INSERT INTO retrieval_traces
223
+ (ts, sid, workspace, trigger, uri, query_hash, query_text, query_len, mode, widened, steps,
224
+ candidates, selected, cand_total, sel_total, ms, model, error, truncated, bytes)
225
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
226
+ this.timer = setInterval(() => { try { this.flush() } catch { /* fail-open */ } }, intervalMs)
227
+ this.timer.unref?.()
228
+ this.onExit = () => { try { this.flush() } catch { /* fail-open */ } }
229
+ try { process.once('beforeExit', this.onExit) } catch { /* ignore */ }
230
+ }
231
+
232
+ record(row) {
233
+ if (!row || this.closed) return false
234
+ if (this.sync) {
235
+ this.queue.push(row)
236
+ return this.flush(1).ok
237
+ }
238
+ this.queue.push(row)
239
+ if (this.queue.length > this.maxQueue) {
240
+ this.queue.shift()
241
+ this.dropped++
242
+ }
243
+ if (this.queue.length >= this.batch) this.flush()
244
+ return true
245
+ }
246
+
247
+ flush(max = Infinity) {
248
+ if (this.writing || this.closed || !this.queue.length) return { ok: true, written: 0 }
249
+ this.writing = true
250
+ const items = this.queue.splice(0, Math.min(this.queue.length, max))
251
+ try {
252
+ this.db.exec('BEGIN')
253
+ for (const r of items) {
254
+ this.insert.run(r.ts, r.sid, r.workspace, r.trigger, r.uri, r.query_hash, r.query_text,
255
+ r.query_len, r.mode, r.widened, r.steps, r.candidates, r.selected, r.cand_total,
256
+ r.sel_total, r.ms, r.model, r.error, r.truncated, r.bytes)
257
+ }
258
+ this.db.exec('COMMIT')
259
+ this.written += items.length
260
+ this.fail = 0
261
+ this.sinceRetain += items.length
262
+ if (this.sinceRetain >= 500 || Date.now() - this.lastRetain > 6 * 3600_000) this.retain()
263
+ return { ok: true, written: items.length }
264
+ } catch (err) {
265
+ try { this.db.exec('ROLLBACK') } catch { /* ignore */ }
266
+ this.fail++
267
+ this.lastError = String(err?.message ?? err).slice(0, 200)
268
+ if (this.fail <= 3) this.queue.unshift(...items)
269
+ else this.dropped += items.length
270
+ while (this.queue.length > this.maxQueue) { this.queue.shift(); this.dropped++ }
271
+ return { ok: false, error: this.lastError }
272
+ } finally {
273
+ this.writing = false
274
+ }
275
+ }
276
+
277
+ retain() {
278
+ const days = MAX_DAYS()
279
+ const rows = MAX_ROWS()
280
+ try {
281
+ this.db.exec('BEGIN')
282
+ try {
283
+ this.db.prepare('DELETE FROM retrieval_traces WHERE id NOT IN (SELECT id FROM retrieval_traces ORDER BY id DESC LIMIT ?)').run(rows)
284
+ this.db.prepare('DELETE FROM retrieval_traces WHERE ts < ?').run(Date.now() - days * 86400_000)
285
+ this.db.exec('COMMIT')
286
+ } catch (err) {
287
+ try { this.db.exec('ROLLBACK') } catch { /* ignore */ }
288
+ throw err
289
+ }
290
+ this.sinceRetain = 0
291
+ this.lastRetain = Date.now()
292
+ return true
293
+ } catch {
294
+ return false
295
+ }
296
+ }
297
+
298
+ close() {
299
+ if (this.closed) return
300
+ try { this.flush() } catch { /* ignore */ }
301
+ this.closed = true
302
+ try { clearInterval(this.timer) } catch { /* ignore */ }
303
+ try { process.removeListener('beforeExit', this.onExit) } catch { /* ignore */ }
304
+ try { this.db.close() } catch { /* ignore */ }
305
+ }
306
+ }
307
+
308
+ const recorders = new Map()
309
+
310
+ export function traceRecorderFor(store, opts = {}) {
311
+ if (!store) return null
312
+ const dbFile = traceDbPath(store)
313
+ const sync = opts.sync ?? String(process.env.DSH_MEMORY_TRACE_SYNC ?? '') === '1'
314
+ const key = dbFile + '|' + (sync ? 's' : 'a')
315
+ if (recorders.has(key)) return recorders.get(key)
316
+ try {
317
+ const dir = dirname(dbFile)
318
+ if (dir && dir !== '.' && dir !== ':memory:') mkdirSync(dir, { recursive: true })
319
+ const rec = new TraceRecorder(dbFile, { sync })
320
+ recorders.set(key, rec)
321
+ return rec
322
+ } catch {
323
+ recorders.set(key, null)
324
+ return null
325
+ }
326
+ }
327
+
328
+ export function recordTrace(store, raw) {
329
+ try {
330
+ if (!store || !traceEnabledFor(store)) return null
331
+ const row = buildTrace(raw)
332
+ if (!row) return null
333
+ const rec = traceRecorderFor(store)
334
+ if (!rec) return null
335
+ rec.record(row)
336
+ return row
337
+ } catch {
338
+ return null
339
+ }
340
+ }
341
+
342
+ export function flushTraces(store) {
343
+ try {
344
+ const rec = store ? traceRecorderFor(store) : null
345
+ return rec ? rec.flush() : { ok: true, written: 0 }
346
+ } catch { return { ok: false, written: 0 } }
347
+ }
348
+
349
+ export function closeTraceRecorder(store) {
350
+ try {
351
+ if (!store) return
352
+ const dbFile = traceDbPath(store)
353
+ const sync = String(process.env.DSH_MEMORY_TRACE_SYNC ?? '') === '1'
354
+ const key = dbFile + '|' + (sync ? 's' : 'a')
355
+ const rec = recorders.get(key)
356
+ rec?.close()
357
+ recorders.delete(key)
358
+ } catch { /* fail-open */ }
359
+ }
360
+
361
+ export function closeAllTraceRecorders() {
362
+ for (const rec of recorders.values()) { try { rec?.close() } catch { /* ignore */ } }
363
+ recorders.clear()
364
+ }
365
+
366
+ function mapRow(r) {
367
+ if (!r) return null
368
+ const parse = (s, d) => { try { const v = JSON.parse(s); return v ?? d } catch { return d } }
369
+ return {
370
+ id: Number(r.id), ts: Number(r.ts), sid: r.sid, workspace: r.workspace, trigger: r.trigger,
371
+ uri: r.uri, queryHash: r.query_hash, preview: clip(r.query_text, 80), queryLen: Number(r.query_len),
372
+ mode: r.mode, candTotal: Number(r.cand_total), selTotal: Number(r.sel_total),
373
+ ms: Number(r.ms), error: r.error, truncated: Number(r.truncated),
374
+ }
375
+ }
376
+
377
+ function openRead(dbFile) {
378
+ if (!dbFile || !existsSync(dbFile)) return null
379
+ try { return new DatabaseSync(dbFile, { readOnly: true }) } catch { return null }
380
+ }
381
+
382
+ export function listTraces(dbFile, { sid, since = 0, until = 0, limit = 50, cursor = null, trigger = '' } = {}) {
383
+ const db = openRead(dbFile)
384
+ if (!db) return { available: false, traces: [], nextCursor: null, hasMore: false }
385
+ try {
386
+ const exists = db.prepare("SELECT 1 x FROM sqlite_master WHERE type='table' AND name='retrieval_traces'").get()
387
+ if (!exists) return { available: false, traces: [], nextCursor: null, hasMore: false }
388
+ const lim = Math.max(1, Math.min(200, Number(limit) || 50))
389
+ const where = ['sid=?']
390
+ const args = [String(sid)]
391
+ if (since > 0) { where.push('ts>=?'); args.push(Number(since)) }
392
+ if (until > 0) { where.push('ts<=?'); args.push(Number(until)) }
393
+ if (trigger && TRACE_TRIGGERS.has(trigger)) { where.push('trigger=?'); args.push(trigger) }
394
+ if (cursor && Number.isFinite(cursor.ts) && Number.isFinite(cursor.id)) {
395
+ where.push('(ts<? OR (ts=? AND id<?))')
396
+ args.push(cursor.ts, cursor.ts, cursor.id)
397
+ }
398
+ const rows = db.prepare(`SELECT id, ts, sid, workspace, trigger, uri, query_hash, query_text, query_len,
399
+ mode, cand_total, sel_total, ms, error, truncated
400
+ FROM retrieval_traces WHERE ${where.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`)
401
+ .all(...args, lim + 1)
402
+ const hasMore = rows.length > lim
403
+ const page = rows.slice(0, lim)
404
+ const last = page[page.length - 1]
405
+ return {
406
+ available: true,
407
+ traces: page.map(mapRow),
408
+ nextCursor: hasMore && last ? `${last.ts}:${last.id}` : null,
409
+ hasMore,
410
+ limit: lim,
411
+ retention: { maxRows: MAX_ROWS(), maxDays: MAX_DAYS() },
412
+ }
413
+ } catch {
414
+ return { available: false, traces: [], nextCursor: null, hasMore: false }
415
+ } finally {
416
+ try { db.close() } catch { /* ignore */ }
417
+ }
418
+ }
419
+
420
+ export function getTrace(dbFile, id) {
421
+ const db = openRead(dbFile)
422
+ if (!db) return { available: false, trace: null }
423
+ try {
424
+ const exists = db.prepare("SELECT 1 x FROM sqlite_master WHERE type='table' AND name='retrieval_traces'").get()
425
+ if (!exists) return { available: false, trace: null }
426
+ const r = db.prepare('SELECT * FROM retrieval_traces WHERE id=?').get(Number(id))
427
+ if (!r) return { available: true, trace: null }
428
+ const parse = (s, d) => { try { const v = JSON.parse(s); return v ?? d } catch { return d } }
429
+ return {
430
+ available: true,
431
+ trace: {
432
+ id: Number(r.id), ts: Number(r.ts), sid: r.sid, workspace: r.workspace, trigger: r.trigger,
433
+ uri: r.uri,
434
+ query: { hash: r.query_hash, text: r.query_text, len: Number(r.query_len) },
435
+ mode: r.mode,
436
+ widened: parse(r.widened, []),
437
+ steps: parse(r.steps, []),
438
+ candidates: parse(r.candidates, []),
439
+ selected: parse(r.selected, []),
440
+ candTotal: Number(r.cand_total), selTotal: Number(r.sel_total),
441
+ ms: Number(r.ms), model: r.model, error: r.error,
442
+ truncated: Number(r.truncated), bytes: Number(r.bytes),
443
+ ref: `rcs-trace://${r.sid}/${r.id}`,
444
+ },
445
+ }
446
+ } catch {
447
+ return { available: false, trace: null }
448
+ } finally {
449
+ try { db.close() } catch { /* ignore */ }
450
+ }
451
+ }
452
+
453
+ function hashUri(uri) {
454
+ const s = String(uri ?? '')
455
+ const i = s.lastIndexOf('/')
456
+ if (i < 0 || i === s.length - 1) return s
457
+ return s.slice(0, i + 1) + '<key:' + hash8(s.slice(i + 1)) + '>'
458
+ }
459
+
460
+ export function exportTraces(dbFile, { sid, since = 0, until = 0, limit = 5000 } = {}) {
461
+ const db = openRead(dbFile)
462
+ if (!db) return { available: false, rows: [] }
463
+ try {
464
+ const exists = db.prepare("SELECT 1 x FROM sqlite_master WHERE type='table' AND name='retrieval_traces'").get()
465
+ if (!exists) return { available: false, rows: [] }
466
+ const lim = Math.max(1, Math.min(5000, Number(limit) || 5000))
467
+ const where = ['sid=?']
468
+ const args = [String(sid)]
469
+ if (since > 0) { where.push('ts>=?'); args.push(Number(since)) }
470
+ if (until > 0) { where.push('ts<=?'); args.push(Number(until)) }
471
+ const rows = db.prepare(`SELECT * FROM retrieval_traces WHERE ${where.join(' AND ')} ORDER BY ts ASC, id ASC LIMIT ?`)
472
+ .all(...args, lim)
473
+ const parse = (s, d) => { try { const v = JSON.parse(s); return v ?? d } catch { return d } }
474
+ const out = rows.map((r) => ({
475
+ ts: Number(r.ts), sid: `<sid:${hash8(r.sid)}>`, workspace: `<ws:${hash8(r.workspace)}>`,
476
+ trigger: r.trigger, uri: hashUri(r.uri), query: { hash: r.query_hash, text: redactQuery(r.query_text), len: Number(r.query_len) },
477
+ mode: r.mode, widened: parse(r.widened, []),
478
+ steps: parse(r.steps, []).map((s) => ({ ...s, uri: s.uri ? hashUri(s.uri) : undefined })),
479
+ candidates: parse(r.candidates, []).map((c) => ({ ...c, uri: hashUri(c.uri) })),
480
+ selected: parse(r.selected, []).map((c) => ({ ...c, uri: hashUri(c.uri) })),
481
+ candTotal: Number(r.cand_total), selTotal: Number(r.sel_total), ms: Number(r.ms),
482
+ model: r.model, error: r.error, truncated: Number(r.truncated),
483
+ }))
484
+ return { available: true, rows: out }
485
+ } catch {
486
+ return { available: false, rows: [] }
487
+ } finally {
488
+ try { db.close() } catch { /* ignore */ }
489
+ }
490
+ }
491
+
492
+ export function clearTraces(dbFile, sid) {
493
+ if (!dbFile || !existsSync(dbFile)) return { ok: true, deleted: 0 }
494
+ let db = null
495
+ try {
496
+ db = new DatabaseSync(dbFile)
497
+ db.exec('PRAGMA busy_timeout=5000')
498
+ const exists = db.prepare("SELECT 1 x FROM sqlite_master WHERE type='table' AND name='retrieval_traces'").get()
499
+ if (!exists) return { ok: true, deleted: 0 }
500
+ const r = db.prepare('DELETE FROM retrieval_traces WHERE sid=?').run(String(sid))
501
+ return { ok: true, deleted: Number(r.changes) || 0 }
502
+ } catch {
503
+ return { ok: false, deleted: 0 }
504
+ } finally {
505
+ try { db?.close() } catch { /* ignore */ }
506
+ }
507
+ }
508
+
509
+ export function traceStats(dbFile) {
510
+ if (!dbFile || !existsSync(dbFile)) return { available: false, rows: 0, bytes: 0 }
511
+ const db = openRead(dbFile)
512
+ if (!db) return { available: false, rows: 0, bytes: 0 }
513
+ let bytes = 0
514
+ try { bytes = statSync(dbFile).size } catch { /* ignore */ }
515
+ try {
516
+ const exists = db.prepare("SELECT 1 x FROM sqlite_master WHERE type='table' AND name='retrieval_traces'").get()
517
+ if (!exists) return { available: false, rows: 0, bytes }
518
+ const rows = Number(db.prepare('SELECT COUNT(*) c FROM retrieval_traces').get()?.c ?? 0)
519
+ return { available: true, rows, bytes, maxRows: MAX_ROWS(), maxDays: MAX_DAYS() }
520
+ } catch {
521
+ return { available: false, rows: 0, bytes }
522
+ } finally {
523
+ try { db.close() } catch { /* ignore */ }
524
+ }
525
+ }
526
+
527
+ export function traceHealth() {
528
+ const out = []
529
+ for (const [key, rec] of recorders) {
530
+ out.push({ key, queued: rec?.queue.length ?? 0, written: rec?.written ?? 0, dropped: rec?.dropped ?? 0, fail: rec?.fail ?? 0, lastError: rec?.lastError ?? '' })
531
+ }
532
+ return out
533
+ }