@goodandready/dsh-moa 0.2.3 → 0.2.5

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/lib/history.js ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * MoA Run History & Analytics Module
3
+ * Records every MoA pipeline execution, tracks model win rates and costs,
4
+ * and provides history search and run replay capabilities.
5
+ */
6
+
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import os from 'node:os'
10
+ import crypto from 'node:crypto'
11
+
12
+ const DEFAULT_HISTORY_DIR = path.join(os.homedir(), '.dsh')
13
+ const DEFAULT_HISTORY_FILE = path.join(DEFAULT_HISTORY_DIR, 'moa-history.jsonl')
14
+
15
+ export function getHistoryFilePath(customDir) {
16
+ if (customDir) {
17
+ return path.join(customDir, '.moa-history.jsonl')
18
+ }
19
+ return DEFAULT_HISTORY_FILE
20
+ }
21
+
22
+ /**
23
+ * Appends a completed MoA run record to the history file.
24
+ */
25
+ export function recordMoaRun(record, filePath = DEFAULT_HISTORY_FILE) {
26
+ try {
27
+ const dir = path.dirname(filePath)
28
+ if (!fs.existsSync(dir)) {
29
+ fs.mkdirSync(dir, { recursive: true })
30
+ }
31
+
32
+ const entry = {
33
+ id: record.id || crypto.randomUUID(),
34
+ timestamp: record.timestamp || new Date().toISOString(),
35
+ prompt: record.prompt || '',
36
+ preset: record.preset || 'default',
37
+ isRefinement: Boolean(record.isRefinement),
38
+ candidates: Array.isArray(record.candidates)
39
+ ? record.candidates.map((c) => ({
40
+ provider: c.provider || '',
41
+ model: c.model || '',
42
+ filesCount: Array.isArray(c.files) ? c.files.length : (c.filesCount || 0),
43
+ usage: c.usage || { inputTokens: 0, outputTokens: 0 },
44
+ costUsd: Number(c.costUsd || 0),
45
+ }))
46
+ : [],
47
+ aggregator: record.aggregator
48
+ ? {
49
+ provider: record.aggregator.provider || '',
50
+ model: record.aggregator.model || '',
51
+ usage: record.aggregator.usage || { inputTokens: 0, outputTokens: 0 },
52
+ costUsd: Number(record.aggregator.costUsd || 0),
53
+ }
54
+ : null,
55
+ winnerIndex: typeof record.winnerIndex === 'number' ? record.winnerIndex : -1,
56
+ winnerModel: record.winnerModel || '',
57
+ promotedFiles: Array.isArray(record.promotedFiles) ? record.promotedFiles : [],
58
+ totalTokens: Number(record.totalTokens || 0),
59
+ totalCostUsd: Number(record.totalCostUsd || 0),
60
+ durationMs: Number(record.durationMs || 0),
61
+ }
62
+
63
+ const line = JSON.stringify(entry) + '\n'
64
+ fs.appendFileSync(filePath, line, 'utf8')
65
+ return entry
66
+ } catch (err) {
67
+ console.warn('[dsh-moa] Failed to record run to history:', err)
68
+ return null
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Reads history runs with pagination (latest first).
74
+ */
75
+ export function getMoaHistory(limit = 20, offset = 0, filePath = DEFAULT_HISTORY_FILE) {
76
+ try {
77
+ if (!fs.existsSync(filePath)) {
78
+ return { total: 0, runs: [] }
79
+ }
80
+
81
+ const raw = fs.readFileSync(filePath, 'utf8')
82
+ const lines = raw.trim().split('\n').filter(Boolean)
83
+ const total = lines.length
84
+
85
+ const sliced = lines
86
+ .slice(Math.max(0, total - offset - limit), total - offset)
87
+ .reverse()
88
+ .map((line) => {
89
+ try {
90
+ return JSON.parse(line)
91
+ } catch {
92
+ return null
93
+ }
94
+ })
95
+ .filter(Boolean)
96
+
97
+ return { total, runs: sliced }
98
+ } catch (err) {
99
+ console.warn('[dsh-moa] Failed to read history:', err)
100
+ return { total: 0, runs: [] }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Calculates win-rate leaderboard and model performance statistics across history.
106
+ */
107
+ export function getMoaLeaderboard(filePath = DEFAULT_HISTORY_FILE) {
108
+ try {
109
+ if (!fs.existsSync(filePath)) {
110
+ return { totalRuns: 0, models: [] }
111
+ }
112
+
113
+ const raw = fs.readFileSync(filePath, 'utf8')
114
+ const lines = raw.trim().split('\n').filter(Boolean)
115
+ const totalRuns = lines.length
116
+
117
+ const modelStats = new Map()
118
+
119
+ for (const line of lines) {
120
+ try {
121
+ const item = JSON.parse(line)
122
+ if (!item) continue
123
+
124
+ const winner = item.winnerModel ? String(item.winnerModel).trim() : null
125
+
126
+ if (Array.isArray(item.candidates)) {
127
+ for (const cand of item.candidates) {
128
+ const key = cand.provider && cand.model ? `${cand.provider}:${cand.model}` : (cand.model || 'unknown')
129
+ if (!modelStats.has(key)) {
130
+ modelStats.set(key, {
131
+ modelKey: key,
132
+ provider: cand.provider || '',
133
+ model: cand.model || key,
134
+ runs: 0,
135
+ wins: 0,
136
+ totalTokens: 0,
137
+ totalCostUsd: 0,
138
+ })
139
+ }
140
+ const st = modelStats.get(key)
141
+ st.runs += 1
142
+ const inTok = cand.usage?.inputTokens || 0
143
+ const outTok = cand.usage?.outputTokens || 0
144
+ st.totalTokens += inTok + outTok
145
+ st.totalCostUsd += Number(cand.costUsd || 0)
146
+
147
+ if (winner && (winner === key || winner === cand.model || winner.includes(cand.model))) {
148
+ st.wins += 1
149
+ }
150
+ }
151
+ }
152
+ } catch {
153
+ // ignore malformed line
154
+ }
155
+ }
156
+
157
+ const models = Array.from(modelStats.values())
158
+ .map((st) => ({
159
+ ...st,
160
+ winRate: st.runs > 0 ? Number(((st.wins / st.runs) * 100).toFixed(1)) : 0,
161
+ avgTokens: st.runs > 0 ? Math.round(st.totalTokens / st.runs) : 0,
162
+ avgCostUsd: st.runs > 0 ? Number((st.totalCostUsd / st.runs).toFixed(4)) : 0,
163
+ }))
164
+ .sort((a, b) => b.wins - a.wins || b.winRate - a.winRate)
165
+
166
+ return { totalRuns, models }
167
+ } catch (err) {
168
+ console.warn('[dsh-moa] Failed to calculate leaderboard:', err)
169
+ return { totalRuns: 0, models: [] }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Retrieves a single history run by ID.
175
+ */
176
+ export function getMoaRunById(runId, filePath = DEFAULT_HISTORY_FILE) {
177
+ try {
178
+ if (!fs.existsSync(filePath)) return null
179
+ const raw = fs.readFileSync(filePath, 'utf8')
180
+ const lines = raw.trim().split('\n').filter(Boolean)
181
+ for (let i = lines.length - 1; i >= 0; i--) {
182
+ try {
183
+ const item = JSON.parse(lines[i])
184
+ if (item && item.id === runId) return item
185
+ } catch {}
186
+ }
187
+ return null
188
+ } catch {
189
+ return null
190
+ }
191
+ }