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.
- package/LICENSE.txt +21 -0
- package/README.md +194 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +66 -4
- package/toolfold/toolfold.mjs +513 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -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
|
+
}
|