@iamoberlin/chorus 1.1.4
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 +21 -0
- package/README.md +191 -0
- package/index.ts +724 -0
- package/logo.png +0 -0
- package/openclaw.plugin.json +117 -0
- package/package.json +41 -0
- package/src/choirs.ts +375 -0
- package/src/config.ts +105 -0
- package/src/daemon.ts +287 -0
- package/src/metrics.ts +241 -0
- package/src/purpose-research.ts +392 -0
- package/src/purposes.ts +178 -0
- package/src/salience.ts +160 -0
- package/src/scheduler.ts +241 -0
- package/src/security.ts +26 -0
- package/src/senses.ts +259 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Daemon
|
|
3
|
+
*
|
|
4
|
+
* Autonomous attention loop that monitors senses,
|
|
5
|
+
* filters for salience, and invokes cognition when needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
9
|
+
import type { Signal, Sense } from "./senses.js";
|
|
10
|
+
import { ALL_SENSES } from "./senses.js";
|
|
11
|
+
import { SalienceFilter, defaultFilter } from "./salience.js";
|
|
12
|
+
import { recordExecution, type ChoirExecution } from "./metrics.js";
|
|
13
|
+
|
|
14
|
+
export interface DaemonConfig {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
senses: {
|
|
17
|
+
inbox: boolean;
|
|
18
|
+
purposes: boolean;
|
|
19
|
+
time: boolean;
|
|
20
|
+
};
|
|
21
|
+
thinkThreshold: number; // Minimum priority to invoke cognition
|
|
22
|
+
pollIntervalMs: number; // How often to poll senses
|
|
23
|
+
minSleepMs: number; // Minimum sleep between cycles
|
|
24
|
+
maxSleepMs: number; // Maximum sleep (night/idle)
|
|
25
|
+
quietHoursStart: number; // Hour to start quiet mode (e.g., 23)
|
|
26
|
+
quietHoursEnd: number; // Hour to end quiet mode (e.g., 7)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_DAEMON_CONFIG: DaemonConfig = {
|
|
30
|
+
enabled: true,
|
|
31
|
+
senses: {
|
|
32
|
+
inbox: true,
|
|
33
|
+
purposes: true,
|
|
34
|
+
time: true,
|
|
35
|
+
},
|
|
36
|
+
thinkThreshold: 55,
|
|
37
|
+
pollIntervalMs: 5 * 60 * 1000, // 5 minutes
|
|
38
|
+
minSleepMs: 30 * 1000, // 30 seconds
|
|
39
|
+
maxSleepMs: 10 * 60 * 1000, // 10 minutes
|
|
40
|
+
quietHoursStart: 23,
|
|
41
|
+
quietHoursEnd: 7,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
interface AttentionItem extends Signal {
|
|
45
|
+
salienceScore: number;
|
|
46
|
+
rulesApplied: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createDaemon(
|
|
50
|
+
config: DaemonConfig,
|
|
51
|
+
log: PluginLogger,
|
|
52
|
+
api: any // OpenClawPluginApi
|
|
53
|
+
) {
|
|
54
|
+
const filter = new SalienceFilter([], config.thinkThreshold);
|
|
55
|
+
const attentionQueue: AttentionItem[] = [];
|
|
56
|
+
const cleanupFns: (() => void)[] = [];
|
|
57
|
+
|
|
58
|
+
let pollInterval: NodeJS.Timeout | null = null;
|
|
59
|
+
let running = false;
|
|
60
|
+
|
|
61
|
+
// Get enabled senses
|
|
62
|
+
function getEnabledSenses(): Sense[] {
|
|
63
|
+
return ALL_SENSES.filter(sense => {
|
|
64
|
+
if (sense.id === "inbox" && !config.senses.inbox) return false;
|
|
65
|
+
if (sense.id === "purposes" && !config.senses.purposes) return false;
|
|
66
|
+
if (sense.id === "time" && !config.senses.time) return false;
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Process a signal through salience filter
|
|
72
|
+
function processSignal(signal: Signal): void {
|
|
73
|
+
const result = filter.evaluate(signal);
|
|
74
|
+
|
|
75
|
+
log.debug(
|
|
76
|
+
`[daemon] Signal: "${signal.content.slice(0, 50)}..." ` +
|
|
77
|
+
`(${signal.priority} → ${result.finalPriority}, rules: ${result.rulesApplied.join(",")})`
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (result.shouldAttend) {
|
|
81
|
+
attentionQueue.push({
|
|
82
|
+
...signal,
|
|
83
|
+
salienceScore: result.finalPriority,
|
|
84
|
+
rulesApplied: result.rulesApplied,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
log.info(
|
|
88
|
+
`[daemon] 👁️ Queued: "${signal.content.slice(0, 60)}..." (priority: ${result.finalPriority})`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Poll all senses
|
|
94
|
+
async function pollSenses(): Promise<void> {
|
|
95
|
+
for (const sense of getEnabledSenses()) {
|
|
96
|
+
if (sense.poll) {
|
|
97
|
+
try {
|
|
98
|
+
const signals = await sense.poll();
|
|
99
|
+
for (const signal of signals) {
|
|
100
|
+
processSignal(signal);
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
log.error(`[daemon] Sense ${sense.id} poll failed: ${err}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Start watchers for event-based senses
|
|
110
|
+
function startWatchers(): void {
|
|
111
|
+
for (const sense of getEnabledSenses()) {
|
|
112
|
+
if (sense.watch) {
|
|
113
|
+
try {
|
|
114
|
+
const cleanup = sense.watch(processSignal);
|
|
115
|
+
cleanupFns.push(cleanup);
|
|
116
|
+
log.info(`[daemon] 👁️ Watching: ${sense.description}`);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.error(`[daemon] Sense ${sense.id} watch failed: ${err}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Process the highest priority item in queue
|
|
125
|
+
async function processQueue(): Promise<void> {
|
|
126
|
+
if (attentionQueue.length === 0) return;
|
|
127
|
+
|
|
128
|
+
// Sort by salience score (highest first)
|
|
129
|
+
attentionQueue.sort((a, b) => b.salienceScore - a.salienceScore);
|
|
130
|
+
|
|
131
|
+
const item = attentionQueue.shift()!;
|
|
132
|
+
const startTime = Date.now();
|
|
133
|
+
|
|
134
|
+
log.info(`[daemon] 🧠 Attending: "${item.content.slice(0, 80)}..." (priority: ${item.salienceScore})`);
|
|
135
|
+
|
|
136
|
+
// Build prompt for cognition
|
|
137
|
+
const prompt = buildAttentionPrompt(item);
|
|
138
|
+
|
|
139
|
+
const execution: ChoirExecution = {
|
|
140
|
+
choirId: "daemon",
|
|
141
|
+
timestamp: new Date().toISOString(),
|
|
142
|
+
durationMs: 0,
|
|
143
|
+
success: false,
|
|
144
|
+
outputLength: 0,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const result = await api.runAgentTurn?.({
|
|
149
|
+
sessionLabel: "chorus:daemon",
|
|
150
|
+
message: prompt,
|
|
151
|
+
isolated: true,
|
|
152
|
+
timeoutSeconds: 180,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
execution.durationMs = Date.now() - startTime;
|
|
156
|
+
execution.success = true;
|
|
157
|
+
execution.outputLength = result?.response?.length || 0;
|
|
158
|
+
execution.tokensUsed = result?.meta?.tokensUsed;
|
|
159
|
+
|
|
160
|
+
log.info(`[daemon] ✓ Completed in ${(execution.durationMs / 1000).toFixed(1)}s`);
|
|
161
|
+
|
|
162
|
+
} catch (err) {
|
|
163
|
+
execution.durationMs = Date.now() - startTime;
|
|
164
|
+
execution.success = false;
|
|
165
|
+
execution.error = String(err);
|
|
166
|
+
log.error(`[daemon] ✗ Failed: ${err}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Record metrics
|
|
170
|
+
recordExecution(execution);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildAttentionPrompt(item: AttentionItem): string {
|
|
174
|
+
const parts = [
|
|
175
|
+
"## DAEMON ATTENTION SIGNAL",
|
|
176
|
+
"",
|
|
177
|
+
`**Source:** ${item.source}`,
|
|
178
|
+
`**Priority:** ${item.salienceScore}/100`,
|
|
179
|
+
`**Time:** ${item.timestamp.toISOString()}`,
|
|
180
|
+
"",
|
|
181
|
+
"**Content:**",
|
|
182
|
+
item.content,
|
|
183
|
+
"",
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
if (item.metadata && Object.keys(item.metadata).length > 0) {
|
|
187
|
+
parts.push("**Metadata:**");
|
|
188
|
+
parts.push("```json");
|
|
189
|
+
parts.push(JSON.stringify(item.metadata, null, 2));
|
|
190
|
+
parts.push("```");
|
|
191
|
+
parts.push("");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
parts.push("---");
|
|
195
|
+
parts.push("");
|
|
196
|
+
parts.push("Evaluate this signal. Determine if action is needed.");
|
|
197
|
+
parts.push("");
|
|
198
|
+
parts.push("If action needed:");
|
|
199
|
+
parts.push("- Take the action directly (update files, send messages, etc.)");
|
|
200
|
+
parts.push("- Log what you did in today's memory file");
|
|
201
|
+
parts.push("");
|
|
202
|
+
parts.push("If no action needed:");
|
|
203
|
+
parts.push("- Briefly explain why and move on");
|
|
204
|
+
parts.push("");
|
|
205
|
+
parts.push("Be concise. This is autonomous processing, not a conversation.");
|
|
206
|
+
|
|
207
|
+
return parts.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Calculate adaptive sleep time
|
|
211
|
+
function calculateSleepTime(): number {
|
|
212
|
+
const hour = new Date().getHours();
|
|
213
|
+
const isQuietHours = hour >= config.quietHoursStart || hour < config.quietHoursEnd;
|
|
214
|
+
|
|
215
|
+
// Check queue pressure
|
|
216
|
+
const queuePressure = attentionQueue.length > 0
|
|
217
|
+
? Math.max(...attentionQueue.map(i => i.salienceScore))
|
|
218
|
+
: 0;
|
|
219
|
+
|
|
220
|
+
if (queuePressure > 80) return config.minSleepMs;
|
|
221
|
+
if (queuePressure > 60) return config.minSleepMs * 2;
|
|
222
|
+
if (isQuietHours) return config.maxSleepMs;
|
|
223
|
+
|
|
224
|
+
return config.pollIntervalMs;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Main daemon service
|
|
228
|
+
return {
|
|
229
|
+
id: "chorus-daemon",
|
|
230
|
+
|
|
231
|
+
start: () => {
|
|
232
|
+
if (!config.enabled) {
|
|
233
|
+
log.info("[daemon] Disabled in config");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
running = true;
|
|
238
|
+
log.info("[daemon] 🌅 Daemon starting...");
|
|
239
|
+
|
|
240
|
+
// Start event watchers
|
|
241
|
+
startWatchers();
|
|
242
|
+
|
|
243
|
+
// Initial poll
|
|
244
|
+
pollSenses().catch(err => log.error(`[daemon] Initial poll failed: ${err}`));
|
|
245
|
+
|
|
246
|
+
// Periodic polling
|
|
247
|
+
pollInterval = setInterval(async () => {
|
|
248
|
+
if (!running) return;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await pollSenses();
|
|
252
|
+
await processQueue();
|
|
253
|
+
} catch (err) {
|
|
254
|
+
log.error(`[daemon] Cycle error: ${err}`);
|
|
255
|
+
}
|
|
256
|
+
}, config.pollIntervalMs);
|
|
257
|
+
|
|
258
|
+
log.info(`[daemon] 👁️ Active — polling every ${config.pollIntervalMs / 1000}s, threshold: ${config.thinkThreshold}`);
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
stop: () => {
|
|
262
|
+
running = false;
|
|
263
|
+
log.info("[daemon] Stopping...");
|
|
264
|
+
|
|
265
|
+
if (pollInterval) {
|
|
266
|
+
clearInterval(pollInterval);
|
|
267
|
+
pollInterval = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const cleanup of cleanupFns) {
|
|
271
|
+
try {
|
|
272
|
+
cleanup();
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
cleanupFns.length = 0;
|
|
276
|
+
|
|
277
|
+
attentionQueue.length = 0;
|
|
278
|
+
log.info("[daemon] Stopped");
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
// Expose for CLI
|
|
282
|
+
getQueueSize: () => attentionQueue.length,
|
|
283
|
+
getQueue: () => [...attentionQueue],
|
|
284
|
+
forceProcess: () => processQueue(),
|
|
285
|
+
forcePoll: () => pollSenses(),
|
|
286
|
+
};
|
|
287
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Metrics System
|
|
3
|
+
*
|
|
4
|
+
* Tracks quantitative and qualitative metrics for choir executions.
|
|
5
|
+
* Persists to ~/.chorus/metrics.json
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
|
|
12
|
+
export interface ChoirExecution {
|
|
13
|
+
choirId: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
tokensUsed?: number;
|
|
17
|
+
success: boolean;
|
|
18
|
+
error?: string;
|
|
19
|
+
outputLength: number;
|
|
20
|
+
findings?: number; // For research choirs
|
|
21
|
+
alerts?: number; // For monitoring choirs
|
|
22
|
+
improvements?: string[]; // For RSI (Virtues)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DailyMetrics {
|
|
26
|
+
date: string;
|
|
27
|
+
executions: ChoirExecution[];
|
|
28
|
+
summary: {
|
|
29
|
+
totalRuns: number;
|
|
30
|
+
successfulRuns: number;
|
|
31
|
+
failedRuns: number;
|
|
32
|
+
totalDurationMs: number;
|
|
33
|
+
totalTokens: number;
|
|
34
|
+
totalFindings: number;
|
|
35
|
+
totalAlerts: number;
|
|
36
|
+
improvements: string[];
|
|
37
|
+
costEstimateUsd: number;
|
|
38
|
+
};
|
|
39
|
+
qualityScore?: number; // 1-5, set manually or by review
|
|
40
|
+
notes?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MetricsStore {
|
|
44
|
+
version: number;
|
|
45
|
+
days: Record<string, DailyMetrics>;
|
|
46
|
+
totals: {
|
|
47
|
+
allTimeRuns: number;
|
|
48
|
+
allTimeSuccesses: number;
|
|
49
|
+
allTimeFindings: number;
|
|
50
|
+
allTimeAlerts: number;
|
|
51
|
+
allTimeImprovements: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const METRICS_DIR = join(homedir(), ".chorus");
|
|
56
|
+
const METRICS_FILE = join(METRICS_DIR, "metrics.json");
|
|
57
|
+
const COST_PER_1K_TOKENS = 0.003; // Approximate for Claude Sonnet
|
|
58
|
+
|
|
59
|
+
function ensureMetricsDir(): void {
|
|
60
|
+
if (!existsSync(METRICS_DIR)) {
|
|
61
|
+
mkdirSync(METRICS_DIR, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadMetrics(): MetricsStore {
|
|
66
|
+
ensureMetricsDir();
|
|
67
|
+
if (existsSync(METRICS_FILE)) {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(METRICS_FILE, "utf-8"));
|
|
70
|
+
} catch {
|
|
71
|
+
// Corrupted file, start fresh
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
version: 1,
|
|
76
|
+
days: {},
|
|
77
|
+
totals: {
|
|
78
|
+
allTimeRuns: 0,
|
|
79
|
+
allTimeSuccesses: 0,
|
|
80
|
+
allTimeFindings: 0,
|
|
81
|
+
allTimeAlerts: 0,
|
|
82
|
+
allTimeImprovements: 0,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function saveMetrics(store: MetricsStore): void {
|
|
88
|
+
ensureMetricsDir();
|
|
89
|
+
writeFileSync(METRICS_FILE, JSON.stringify(store, null, 2));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getDateKey(date: Date = new Date()): string {
|
|
93
|
+
return date.toISOString().split("T")[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getOrCreateDay(store: MetricsStore, dateKey: string): DailyMetrics {
|
|
97
|
+
if (!store.days[dateKey]) {
|
|
98
|
+
store.days[dateKey] = {
|
|
99
|
+
date: dateKey,
|
|
100
|
+
executions: [],
|
|
101
|
+
summary: {
|
|
102
|
+
totalRuns: 0,
|
|
103
|
+
successfulRuns: 0,
|
|
104
|
+
failedRuns: 0,
|
|
105
|
+
totalDurationMs: 0,
|
|
106
|
+
totalTokens: 0,
|
|
107
|
+
totalFindings: 0,
|
|
108
|
+
totalAlerts: 0,
|
|
109
|
+
improvements: [],
|
|
110
|
+
costEstimateUsd: 0,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return store.days[dateKey];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function updateDaySummary(day: DailyMetrics): void {
|
|
118
|
+
const execs = day.executions;
|
|
119
|
+
day.summary = {
|
|
120
|
+
totalRuns: execs.length,
|
|
121
|
+
successfulRuns: execs.filter((e) => e.success).length,
|
|
122
|
+
failedRuns: execs.filter((e) => !e.success).length,
|
|
123
|
+
totalDurationMs: execs.reduce((sum, e) => sum + e.durationMs, 0),
|
|
124
|
+
totalTokens: execs.reduce((sum, e) => sum + (e.tokensUsed || 0), 0),
|
|
125
|
+
totalFindings: execs.reduce((sum, e) => sum + (e.findings || 0), 0),
|
|
126
|
+
totalAlerts: execs.reduce((sum, e) => sum + (e.alerts || 0), 0),
|
|
127
|
+
improvements: execs.flatMap((e) => e.improvements || []),
|
|
128
|
+
costEstimateUsd: execs.reduce((sum, e) => sum + ((e.tokensUsed || 0) / 1000) * COST_PER_1K_TOKENS, 0),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function recordExecution(execution: ChoirExecution): void {
|
|
133
|
+
const store = loadMetrics();
|
|
134
|
+
const dateKey = getDateKey(new Date(execution.timestamp));
|
|
135
|
+
const day = getOrCreateDay(store, dateKey);
|
|
136
|
+
|
|
137
|
+
day.executions.push(execution);
|
|
138
|
+
updateDaySummary(day);
|
|
139
|
+
|
|
140
|
+
// Update totals
|
|
141
|
+
store.totals.allTimeRuns++;
|
|
142
|
+
if (execution.success) store.totals.allTimeSuccesses++;
|
|
143
|
+
store.totals.allTimeFindings += execution.findings || 0;
|
|
144
|
+
store.totals.allTimeAlerts += execution.alerts || 0;
|
|
145
|
+
store.totals.allTimeImprovements += (execution.improvements || []).length;
|
|
146
|
+
|
|
147
|
+
saveMetrics(store);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getTodayMetrics(): DailyMetrics | null {
|
|
151
|
+
const store = loadMetrics();
|
|
152
|
+
return store.days[getDateKey()] || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function getMetricsForDate(date: string): DailyMetrics | null {
|
|
156
|
+
const store = loadMetrics();
|
|
157
|
+
return store.days[date] || null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function getRecentMetrics(days: number = 7): DailyMetrics[] {
|
|
161
|
+
const store = loadMetrics();
|
|
162
|
+
const result: DailyMetrics[] = [];
|
|
163
|
+
const now = new Date();
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < days; i++) {
|
|
166
|
+
const d = new Date(now);
|
|
167
|
+
d.setDate(d.getDate() - i);
|
|
168
|
+
const key = getDateKey(d);
|
|
169
|
+
if (store.days[key]) {
|
|
170
|
+
result.push(store.days[key]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getTotals(): MetricsStore["totals"] {
|
|
178
|
+
return loadMetrics().totals;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function setQualityScore(date: string, score: number, notes?: string): void {
|
|
182
|
+
const store = loadMetrics();
|
|
183
|
+
const day = store.days[date];
|
|
184
|
+
if (day) {
|
|
185
|
+
day.qualityScore = Math.max(1, Math.min(5, score));
|
|
186
|
+
if (notes) day.notes = notes;
|
|
187
|
+
saveMetrics(store);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function formatMetricsSummary(metrics: DailyMetrics): string {
|
|
192
|
+
const s = metrics.summary;
|
|
193
|
+
const successRate = s.totalRuns > 0 ? ((s.successfulRuns / s.totalRuns) * 100).toFixed(0) : "0";
|
|
194
|
+
const avgDuration = s.totalRuns > 0 ? (s.totalDurationMs / s.totalRuns / 1000).toFixed(1) : "0";
|
|
195
|
+
|
|
196
|
+
return `
|
|
197
|
+
📊 CHORUS Metrics — ${metrics.date}
|
|
198
|
+
${"═".repeat(40)}
|
|
199
|
+
|
|
200
|
+
Executions: ${s.totalRuns} runs (${successRate}% success)
|
|
201
|
+
Duration: ${(s.totalDurationMs / 1000).toFixed(0)}s total, ${avgDuration}s avg
|
|
202
|
+
Tokens: ${s.totalTokens.toLocaleString()} (~$${s.costEstimateUsd.toFixed(2)})
|
|
203
|
+
Findings: ${s.totalFindings}
|
|
204
|
+
Alerts: ${s.totalAlerts}
|
|
205
|
+
Improvements: ${s.improvements.length > 0 ? s.improvements.join(", ") : "none"}
|
|
206
|
+
Quality Score: ${metrics.qualityScore ? `${metrics.qualityScore}/5` : "not rated"}
|
|
207
|
+
${metrics.notes ? `Notes: ${metrics.notes}` : ""}
|
|
208
|
+
`.trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function formatWeeklySummary(): string {
|
|
212
|
+
const recent = getRecentMetrics(7);
|
|
213
|
+
const totals = getTotals();
|
|
214
|
+
|
|
215
|
+
if (recent.length === 0) {
|
|
216
|
+
return "No metrics recorded yet.";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const weekRuns = recent.reduce((sum, d) => sum + d.summary.totalRuns, 0);
|
|
220
|
+
const weekSuccesses = recent.reduce((sum, d) => sum + d.summary.successfulRuns, 0);
|
|
221
|
+
const weekFindings = recent.reduce((sum, d) => sum + d.summary.totalFindings, 0);
|
|
222
|
+
const weekCost = recent.reduce((sum, d) => sum + d.summary.costEstimateUsd, 0);
|
|
223
|
+
const avgQuality = recent.filter((d) => d.qualityScore).reduce((sum, d) => sum + (d.qualityScore || 0), 0) /
|
|
224
|
+
(recent.filter((d) => d.qualityScore).length || 1);
|
|
225
|
+
|
|
226
|
+
return `
|
|
227
|
+
📊 CHORUS Weekly Summary
|
|
228
|
+
${"═".repeat(40)}
|
|
229
|
+
|
|
230
|
+
Last 7 Days:
|
|
231
|
+
Runs: ${weekRuns} (${((weekSuccesses / weekRuns) * 100).toFixed(0)}% success)
|
|
232
|
+
Findings: ${weekFindings}
|
|
233
|
+
Cost: ~$${weekCost.toFixed(2)}
|
|
234
|
+
Avg Quality: ${avgQuality > 0 ? `${avgQuality.toFixed(1)}/5` : "not rated"}
|
|
235
|
+
|
|
236
|
+
All Time:
|
|
237
|
+
Total Runs: ${totals.allTimeRuns.toLocaleString()}
|
|
238
|
+
Findings: ${totals.allTimeFindings}
|
|
239
|
+
Improvements: ${totals.allTimeImprovements}
|
|
240
|
+
`.trim();
|
|
241
|
+
}
|