@rezti/dsh-rez-suite 0.1.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.
package/src/routes.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * /api/dsh-rez-suite route family: config read/update, connectivity test,
3
+ * live status, and the audit ledger. Loopback-only trust fence plus browser
4
+ * same-origin markers — these endpoints read/write credentials on the host.
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
9
+ import type { RezConfig, RezMcpServerSpec, RezRoleId } from './protocol.ts'
10
+ import { REZ_API } from './protocol.ts'
11
+ import type { McpHost } from './mcp-host.ts'
12
+ import type { TokenManager } from './token-manager.ts'
13
+ import { publicConfig } from './store.ts'
14
+
15
+ const MAX_JSON_BODY_BYTES = 256 * 1024
16
+ const SECRET_MASK = '********'
17
+
18
+ /** Loopback literal check plus browser same-origin markers. */
19
+ function isLoopbackRequest(request: IncomingMessage): boolean {
20
+ const address = request.socket.remoteAddress
21
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
22
+ const host = request.headers.host
23
+ if (typeof host !== 'string') return false
24
+ let hostUrl: URL
25
+ try {
26
+ hostUrl = new URL('http://' + host)
27
+ } catch {
28
+ return false
29
+ }
30
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
31
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
32
+ const origin = request.headers.origin
33
+ if (origin === undefined) return true
34
+ try {
35
+ return new URL(origin).host === hostUrl.host
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
42
+ const payload = JSON.stringify(body)
43
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
44
+ res.end(payload)
45
+ }
46
+
47
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
48
+ const chunks: Buffer[] = []
49
+ let size = 0
50
+ for await (const chunk of req) {
51
+ const buffer = chunk as Buffer
52
+ size += buffer.length
53
+ if (size > MAX_JSON_BODY_BYTES) return undefined
54
+ chunks.push(buffer)
55
+ }
56
+ try {
57
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
58
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ /** Merge record values while keeping masked secrets. */
65
+ function mergeMaskedRecord(base: Record<string, string> | undefined, incoming: unknown): Record<string, string> {
66
+ const next: Record<string, string> = { ...(base ?? {}) }
67
+ if (typeof incoming !== 'object' || incoming === null) return next
68
+ for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
69
+ if (typeof value !== 'string') continue
70
+ if (value === SECRET_MASK) continue
71
+ next[key] = value
72
+ }
73
+ return next
74
+ }
75
+
76
+ /** Apply a browser-safe public patch without overwriting masked secrets. */
77
+ function applyPublicPatch(current: RezConfig, patch: Record<string, unknown>): RezConfig {
78
+ const next: RezConfig = JSON.parse(JSON.stringify(current)) as RezConfig
79
+ if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled
80
+ if (patch.role === 'engineer' || patch.role === 'sales' || patch.role === 'operations' || patch.role === 'all') next.role = patch.role as RezRoleId
81
+
82
+ if (typeof patch.servers === 'object' && patch.servers !== null) {
83
+ for (const [id, raw] of Object.entries(patch.servers as Record<string, unknown>)) {
84
+ if (typeof raw !== 'object' || raw === null) continue
85
+ const incoming = raw as Record<string, unknown>
86
+ const existing = next.servers[id] ?? { enabled: false, transport: 'stdio' as const }
87
+ const merged: RezMcpServerSpec = { ...existing }
88
+ if (typeof incoming.enabled === 'boolean') merged.enabled = incoming.enabled
89
+ if (incoming.transport === 'stdio' || incoming.transport === 'streamable-http') merged.transport = incoming.transport
90
+ if (typeof incoming.command === 'string') merged.command = incoming.command
91
+ if (Array.isArray(incoming.args)) merged.args = incoming.args.map(String)
92
+ if (typeof incoming.cwd === 'string') merged.cwd = incoming.cwd
93
+ if (typeof incoming.url === 'string') merged.url = incoming.url
94
+ if (typeof incoming.toolCallTimeoutMs === 'number') merged.toolCallTimeoutMs = incoming.toolCallTimeoutMs
95
+ if (incoming.env !== undefined) merged.env = mergeMaskedRecord(existing.env, incoming.env)
96
+ if (incoming.headers !== undefined) merged.headers = mergeMaskedRecord(existing.headers, incoming.headers)
97
+ next.servers[id] = merged
98
+ }
99
+ }
100
+
101
+ if (typeof patch.billing === 'object' && patch.billing !== null) {
102
+ const value = patch.billing as Record<string, unknown>
103
+ if (typeof value.inputCostPer1k === 'number') next.billing.inputCostPer1k = value.inputCostPer1k
104
+ if (typeof value.outputCostPer1k === 'number') next.billing.outputCostPer1k = value.outputCostPer1k
105
+ if (typeof value.monthlyBudget === 'number') next.billing.monthlyBudget = value.monthlyBudget
106
+ }
107
+ return next
108
+ }
109
+
110
+ export interface RezRoutesDeps {
111
+ getConfig: () => RezConfig
112
+ /** Persist a full/patched config and re-sync the MCP host. */
113
+ updateConfig: (next: RezConfig) => Promise<RezConfig>
114
+ host: McpHost
115
+ tokens: TokenManager
116
+ }
117
+
118
+ /** Build every /api/dsh-rez-suite route (exact paths). */
119
+ export function makeRoutes(deps: RezRoutesDeps): WebRoute[] {
120
+ const { getConfig, updateConfig, host, tokens } = deps
121
+
122
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
123
+ if (!isLoopbackRequest(req)) {
124
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
125
+ return false
126
+ }
127
+ if (req.method !== method) {
128
+ writeJson(res, 405, { error: 'method not allowed: ' + (req.method ?? '') })
129
+ return false
130
+ }
131
+ return true
132
+ }
133
+
134
+ return [
135
+ {
136
+ kind: 'exact',
137
+ path: REZ_API.config,
138
+ handler: async (req, res) => {
139
+ if (!guard(req, res, req.method === 'GET' ? 'GET' : 'PUT')) return
140
+ if (req.method === 'GET') {
141
+ writeJson(res, 200, { config: publicConfig(getConfig()) })
142
+ return
143
+ }
144
+ const body = await readJsonBody(req)
145
+ if (body === undefined) {
146
+ writeJson(res, 400, { error: 'invalid JSON body' })
147
+ return
148
+ }
149
+ const next = applyPublicPatch(getConfig(), body)
150
+ const saved = await updateConfig(next)
151
+ writeJson(res, 200, { config: publicConfig(saved) })
152
+ },
153
+ },
154
+ {
155
+ kind: 'exact',
156
+ path: REZ_API.test,
157
+ handler: async (req, res) => {
158
+ if (!guard(req, res, 'POST')) return
159
+ const results = await host.test(getConfig())
160
+ writeJson(res, 200, { results })
161
+ },
162
+ },
163
+ {
164
+ kind: 'exact',
165
+ path: REZ_API.status,
166
+ handler: async (req, res) => {
167
+ if (!guard(req, res, 'GET')) return
168
+ const servers = host.status()
169
+ const totalRegisteredTools = servers.reduce((sum, server) => sum + server.registeredTools, 0)
170
+ writeJson(res, 200, { role: getConfig().role, servers, totalRegisteredTools })
171
+ },
172
+ },
173
+ {
174
+ kind: 'exact',
175
+ path: REZ_API.audit,
176
+ handler: async (req, res) => {
177
+ if (!guard(req, res, 'GET')) return
178
+ writeJson(res, 200, { audit: tokens.summary(getConfig().billing.monthlyBudget) })
179
+ },
180
+ },
181
+ {
182
+ kind: 'exact',
183
+ path: REZ_API.auditReset,
184
+ handler: async (req, res) => {
185
+ if (!guard(req, res, 'POST')) return
186
+ tokens.reset()
187
+ writeJson(res, 200, { ok: true })
188
+ },
189
+ },
190
+ ]
191
+ }
package/src/store.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Host config store: one JSON file (~/.dsh/dsh-rez-suite.json) holding the
3
+ * suite configuration. Server env/header values (API keys, passwords) live in
4
+ * this user-owned file in plaintext — same trust model as dsh-ssh; document
5
+ * it, never log it.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join, resolve } from 'node:path'
11
+ import type { RezConfig, RezMcpServerSpec, RezPublicConfig } from './protocol.ts'
12
+
13
+ /** File format version. */
14
+ const FORMAT_VERSION = 1
15
+
16
+ /** Config store location: <home>/.dsh/dsh-rez-suite.json. */
17
+ export function rezStorePath(): string {
18
+ return join(homedir(), '.dsh', 'dsh-rez-suite.json')
19
+ }
20
+
21
+ /** Audit SQLite location: <home>/.dsh/dsh-rez-suite.sqlite. */
22
+ export function rezDbPath(): string {
23
+ return join(homedir(), '.dsh', 'dsh-rez-suite.sqlite')
24
+ }
25
+
26
+ /** Default bundled filesystem root. */
27
+ export function rezFsRoot(): string {
28
+ return join(homedir(), '.dsh', 'dsh-rez-suite', 'fs')
29
+ }
30
+
31
+ /** Expand a leading ~ (or empty string) to an absolute path. */
32
+ export function expandHome(path: string): string {
33
+ if (path === '') return ''
34
+ if (path === '~') return homedir()
35
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
36
+ return resolve(path)
37
+ }
38
+
39
+ /** Defaults: panel-only. Company MCP rows live in cordis.patch.yml. */
40
+ export function defaultConfig(): RezConfig {
41
+ return {
42
+ enabled: true,
43
+ announceToAgent: true,
44
+ role: 'all',
45
+ servers: {},
46
+ billing: {
47
+ inputCostPer1k: 0.001,
48
+ outputCostPer1k: 0.002,
49
+ monthlyBudget: 0,
50
+ },
51
+ }
52
+ }
53
+
54
+ /** Mask every env/header value before it leaves the host. */
55
+ function maskRecord(value: Record<string, string> | undefined): Record<string, string> {
56
+ const out: Record<string, string> = {}
57
+ for (const [key, item] of Object.entries(value ?? {})) {
58
+ out[key] = item === '' ? '' : '********'
59
+ }
60
+ return out
61
+ }
62
+
63
+ function maskServer(server: RezMcpServerSpec): RezMcpServerSpec {
64
+ return {
65
+ ...server,
66
+ env: maskRecord(server.env),
67
+ headers: maskRecord(server.headers),
68
+ }
69
+ }
70
+
71
+ /** Project a full config to a browser-safe shape. */
72
+ export function publicConfig(config: RezConfig): RezPublicConfig {
73
+ const servers: Record<string, RezMcpServerSpec> = {}
74
+ for (const [id, server] of Object.entries(config.servers)) {
75
+ servers[id] = maskServer(server)
76
+ }
77
+ return {
78
+ enabled: config.enabled,
79
+ role: config.role,
80
+ servers,
81
+ billing: { ...config.billing },
82
+ }
83
+ }
84
+
85
+ interface StoreFile {
86
+ version: number
87
+ config: RezConfig
88
+ }
89
+
90
+ /** Merge a user-edited store file over the built-in defaults. */
91
+ export function mergeConfig(base: RezConfig, override: unknown): RezConfig {
92
+ if (typeof override !== 'object' || override === null || Array.isArray(override)) return base
93
+ const value = override as Record<string, unknown>
94
+ const result: RezConfig = JSON.parse(JSON.stringify(base)) as RezConfig
95
+ if (typeof value.enabled === 'boolean') result.enabled = value.enabled
96
+ if (typeof value.announceToAgent === 'boolean') result.announceToAgent = value.announceToAgent
97
+ if (value.role === 'engineer' || value.role === 'sales' || value.role === 'operations' || value.role === 'all') result.role = value.role
98
+
99
+ if (typeof value.servers === 'object' && value.servers !== null) {
100
+ for (const [id, raw] of Object.entries(value.servers as Record<string, unknown>)) {
101
+ if (typeof raw !== 'object' || raw === null) continue
102
+ const incoming = raw as Record<string, unknown>
103
+ const existing = result.servers[id] ?? { enabled: false, transport: 'stdio' as const }
104
+ const merged: RezMcpServerSpec = { ...existing }
105
+ if (typeof incoming.enabled === 'boolean') merged.enabled = incoming.enabled
106
+ if (incoming.transport === 'stdio' || incoming.transport === 'streamable-http') merged.transport = incoming.transport
107
+ if (typeof incoming.command === 'string') merged.command = incoming.command
108
+ if (Array.isArray(incoming.args)) merged.args = incoming.args.map(String)
109
+ if (typeof incoming.cwd === 'string') merged.cwd = incoming.cwd
110
+ if (typeof incoming.url === 'string') merged.url = incoming.url
111
+ if (typeof incoming.toolCallTimeoutMs === 'number') merged.toolCallTimeoutMs = incoming.toolCallTimeoutMs
112
+ if (typeof incoming.env === 'object' && incoming.env !== null) {
113
+ merged.env = { ...(existing.env ?? {}), ...(incoming.env as Record<string, string>) }
114
+ }
115
+ if (typeof incoming.headers === 'object' && incoming.headers !== null) {
116
+ merged.headers = { ...(existing.headers ?? {}), ...(incoming.headers as Record<string, string>) }
117
+ }
118
+ result.servers[id] = merged
119
+ }
120
+ }
121
+
122
+ const billing = value.billing
123
+ if (typeof billing === 'object' && billing !== null) {
124
+ result.billing = { ...result.billing, ...(billing as object) }
125
+ }
126
+ return result
127
+ }
128
+
129
+ /** Load the config, or defaults when the file is absent/invalid. */
130
+ export function loadConfig(): RezConfig {
131
+ const base = defaultConfig()
132
+ const path = rezStorePath()
133
+ if (!existsSync(path)) return base
134
+ try {
135
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
136
+ if (typeof parsed !== 'object' || parsed === null) return base
137
+ const file = parsed as Partial<StoreFile>
138
+ return mergeConfig(base, file.config)
139
+ } catch {
140
+ return base
141
+ }
142
+ }
143
+
144
+ /** Atomically persist the config (tmp + rename) with 0600 permissions. */
145
+ export function saveConfig(config: RezConfig): void {
146
+ const path = rezStorePath()
147
+ mkdirSync(dirname(path), { recursive: true })
148
+ const payload: StoreFile = { version: FORMAT_VERSION, config }
149
+ const tmp = path + '.tmp'
150
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 })
151
+ renameSync(tmp, path)
152
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Token / audit manager: a SQLite ledger under ~/.dsh/dsh-rez-suite.sqlite.
3
+ * Token counts are estimates (JSON/text length / 4); cost uses the panel's
4
+ * per-1k-token rates. The manager never throws into the tool pipeline — a
5
+ * storage failure logs and continues in memory-only mode.
6
+ */
7
+
8
+ import { DatabaseSync } from 'node:sqlite'
9
+ import { mkdirSync } from 'node:fs'
10
+ import { dirname } from 'node:path'
11
+ import type { RezAuditRecord, RezAuditResponse, RezRoleId } from './protocol.ts'
12
+
13
+ export interface AuditEntry {
14
+ server: string
15
+ tool: string
16
+ role: RezRoleId | string
17
+ inputTokens: number
18
+ outputTokens: number
19
+ cost: number
20
+ durationMs: number
21
+ ok: boolean
22
+ error?: string
23
+ }
24
+
25
+ /** Rough token estimate: 4 chars ≈ 1 token, with a minimum of 1. */
26
+ export function estimateTokens(value: unknown): number {
27
+ let text = ''
28
+ try {
29
+ text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
30
+ } catch {
31
+ text = String(value)
32
+ }
33
+ const length = text.trim().length
34
+ return length === 0 ? 0 : Math.max(1, Math.ceil(length / 4))
35
+ }
36
+
37
+ export class TokenManager {
38
+ private db: DatabaseSync | null = null
39
+ private memory: RezAuditRecord[] = []
40
+ private nextId = 1
41
+ private readonly dbPath: string
42
+
43
+ constructor(dbPath: string) {
44
+ this.dbPath = dbPath
45
+ try {
46
+ mkdirSync(dirname(dbPath), { recursive: true })
47
+ const db = new DatabaseSync(dbPath)
48
+ db.exec([
49
+ 'CREATE TABLE IF NOT EXISTS usage_log (',
50
+ ' id INTEGER PRIMARY KEY AUTOINCREMENT,',
51
+ ' ts INTEGER NOT NULL,',
52
+ ' server TEXT NOT NULL,',
53
+ ' tool TEXT NOT NULL,',
54
+ ' role TEXT NOT NULL,',
55
+ ' input_tokens INTEGER NOT NULL,',
56
+ ' output_tokens INTEGER NOT NULL,',
57
+ ' cost REAL NOT NULL,',
58
+ ' duration_ms INTEGER NOT NULL,',
59
+ ' ok INTEGER NOT NULL,',
60
+ ' error TEXT',
61
+ ');',
62
+ 'CREATE INDEX IF NOT EXISTS idx_usage_log_ts ON usage_log(ts);',
63
+ ].join('\n'))
64
+ this.db = db
65
+ } catch (error) {
66
+ console.warn('[dsh-rez-suite] sqlite unavailable, audit falls back to memory:', error)
67
+ }
68
+ }
69
+
70
+ record(entry: AuditEntry): void {
71
+ const row: RezAuditRecord = {
72
+ id: this.nextId++,
73
+ ts: Date.now(),
74
+ server: entry.server,
75
+ tool: entry.tool,
76
+ role: entry.role,
77
+ inputTokens: Math.max(0, entry.inputTokens),
78
+ outputTokens: Math.max(0, entry.outputTokens),
79
+ cost: Math.max(0, entry.cost),
80
+ durationMs: Math.max(0, entry.durationMs),
81
+ ok: entry.ok,
82
+ error: entry.error,
83
+ }
84
+ if (this.db !== null) {
85
+ try {
86
+ const statement = this.db.prepare(
87
+ 'INSERT INTO usage_log (ts, server, tool, role, input_tokens, output_tokens, cost, duration_ms, ok, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
88
+ )
89
+ statement.run(
90
+ row.ts,
91
+ row.server,
92
+ row.tool,
93
+ row.role,
94
+ row.inputTokens,
95
+ row.outputTokens,
96
+ row.cost,
97
+ row.durationMs,
98
+ row.ok ? 1 : 0,
99
+ row.error ?? null,
100
+ )
101
+ row.id = Number(this.lastInsertRowId())
102
+ return
103
+ } catch (error) {
104
+ console.warn('[dsh-rez-suite] audit write failed, using memory:', error)
105
+ }
106
+ }
107
+ this.memory.push(row)
108
+ }
109
+
110
+ private lastInsertRowId(): number | bigint {
111
+ if (this.db === null) return 0
112
+ const row = this.db.prepare('SELECT last_insert_rowid() AS id').get() as { id?: number | bigint } | undefined
113
+ return row?.id ?? 0
114
+ }
115
+
116
+ private rows(): RezAuditRecord[] {
117
+ if (this.db === null) return [...this.memory]
118
+ try {
119
+ const rows = this.db.prepare([
120
+ 'SELECT id, ts, server, tool, role, input_tokens AS inputTokens, output_tokens AS outputTokens,',
121
+ ' cost, duration_ms AS durationMs, ok, error',
122
+ 'FROM usage_log',
123
+ 'ORDER BY id DESC',
124
+ 'LIMIT 500',
125
+ ].join('\n')).all() as Array<Record<string, unknown>>
126
+ return rows.map(row => ({
127
+ id: Number(row.id),
128
+ ts: Number(row.ts),
129
+ server: String(row.server),
130
+ tool: String(row.tool),
131
+ role: String(row.role),
132
+ inputTokens: Number(row.inputTokens),
133
+ outputTokens: Number(row.outputTokens),
134
+ cost: Number(row.cost),
135
+ durationMs: Number(row.durationMs),
136
+ ok: Boolean(row.ok),
137
+ error: row.error === null || row.error === undefined ? undefined : String(row.error),
138
+ }))
139
+ } catch (error) {
140
+ console.warn('[dsh-rez-suite] audit read failed:', error)
141
+ return [...this.memory]
142
+ }
143
+ }
144
+
145
+ private recent(limit: number): RezAuditRecord[] {
146
+ if (this.db === null) return this.memory.slice(-limit).reverse()
147
+ try {
148
+ const rows = this.db.prepare(
149
+ 'SELECT id, ts, server, tool, role, input_tokens AS inputTokens, output_tokens AS outputTokens, cost, duration_ms AS durationMs, ok, error FROM usage_log ORDER BY id DESC LIMIT ?',
150
+ ).all(limit) as Array<Record<string, unknown>>
151
+ return rows.map(row => ({
152
+ id: Number(row.id),
153
+ ts: Number(row.ts),
154
+ server: String(row.server),
155
+ tool: String(row.tool),
156
+ role: String(row.role),
157
+ inputTokens: Number(row.inputTokens),
158
+ outputTokens: Number(row.outputTokens),
159
+ cost: Number(row.cost),
160
+ durationMs: Number(row.durationMs),
161
+ ok: Boolean(row.ok),
162
+ error: row.error === null || row.error === undefined ? undefined : String(row.error),
163
+ }))
164
+ } catch (error) {
165
+ console.warn('[dsh-rez-suite] audit recent read failed:', error)
166
+ return this.memory.slice(-limit).reverse()
167
+ }
168
+ }
169
+
170
+ summary(monthlyBudget: number): RezAuditResponse {
171
+ const monthStart = new Date()
172
+ monthStart.setDate(1)
173
+ monthStart.setHours(0, 0, 0, 0)
174
+ const monthStartMs = monthStart.getTime()
175
+
176
+ if (this.db !== null) {
177
+ try {
178
+ const totals = this.db.prepare(
179
+ 'SELECT COUNT(*) AS calls, COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(cost),0) AS cost, COALESCE(SUM(CASE WHEN ts >= ? THEN cost ELSE 0 END),0) AS monthCost FROM usage_log',
180
+ ).get(monthStartMs) as Record<string, unknown>
181
+ const serverRows = this.db.prepare(
182
+ 'SELECT server, COUNT(*) AS calls, COALESCE(SUM(input_tokens + output_tokens),0) AS tokens, COALESCE(SUM(cost),0) AS cost FROM usage_log GROUP BY server ORDER BY server',
183
+ ).all() as Array<Record<string, unknown>>
184
+ const byServer: Record<string, { calls: number; tokens: number; cost: number }> = {}
185
+ for (const row of serverRows) {
186
+ byServer[String(row.server)] = { calls: Number(row.calls), tokens: Number(row.tokens), cost: Number(row.cost) }
187
+ }
188
+ return {
189
+ totalCalls: Number(totals.calls),
190
+ totalInputTokens: Number(totals.inputTokens),
191
+ totalOutputTokens: Number(totals.outputTokens),
192
+ totalCost: Number(totals.cost),
193
+ monthlyBudget,
194
+ monthCost: Number(totals.monthCost),
195
+ byServer,
196
+ recent: this.recent(50),
197
+ }
198
+ } catch (error) {
199
+ console.warn('[dsh-rez-suite] audit summary read failed, using memory:', error)
200
+ }
201
+ }
202
+
203
+ const rows = [...this.memory].reverse()
204
+ let totalCalls = 0
205
+ let totalInputTokens = 0
206
+ let totalOutputTokens = 0
207
+ let totalCost = 0
208
+ let monthCost = 0
209
+ const byServer: Record<string, { calls: number; tokens: number; cost: number }> = {}
210
+ for (const row of rows) {
211
+ totalCalls += 1
212
+ totalInputTokens += row.inputTokens
213
+ totalOutputTokens += row.outputTokens
214
+ totalCost += row.cost
215
+ if (row.ts >= monthStartMs) monthCost += row.cost
216
+ const bucket = byServer[row.server] ?? (byServer[row.server] = { calls: 0, tokens: 0, cost: 0 })
217
+ bucket.calls += 1
218
+ bucket.tokens += row.inputTokens + row.outputTokens
219
+ bucket.cost += row.cost
220
+ }
221
+ return {
222
+ totalCalls,
223
+ totalInputTokens,
224
+ totalOutputTokens,
225
+ totalCost,
226
+ monthlyBudget,
227
+ monthCost,
228
+ byServer,
229
+ recent: rows.slice(0, 50),
230
+ }
231
+ }
232
+
233
+ reset(): void {
234
+ if (this.db !== null) {
235
+ try {
236
+ this.db.exec('DELETE FROM usage_log')
237
+ } catch (error) {
238
+ console.warn('[dsh-rez-suite] audit reset failed:', error)
239
+ }
240
+ }
241
+ this.memory = []
242
+ }
243
+
244
+ close(): void {
245
+ try {
246
+ this.db?.close()
247
+ } catch {
248
+ // ignore
249
+ }
250
+ this.db = null
251
+ }
252
+ }