@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/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
+ }