@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.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * CHORUS Purpose Research Scheduler
3
+ *
4
+ * Runs research for active purposes based on adaptive frequency.
5
+ * Separate from choir-scheduler (fixed 9) and daemon (attention response).
6
+ */
7
+
8
+ import type { OpenClawPluginService, PluginLogger } from "openclaw/plugin-sdk";
9
+ import { loadPurposes, updatePurpose, type Purpose } from "./purposes.js";
10
+ import { recordExecution, type ChoirExecution } from "./metrics.js";
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
12
+ import { join } from "path";
13
+ import { homedir } from "os";
14
+
15
+ export interface PurposeResearchConfig {
16
+ enabled: boolean;
17
+ dailyRunCap: number;
18
+ defaultFrequency: number;
19
+ defaultMaxFrequency: number;
20
+ researchTimeoutMs: number;
21
+ checkIntervalMs: number;
22
+ }
23
+
24
+ export const DEFAULT_PURPOSE_RESEARCH_CONFIG: PurposeResearchConfig = {
25
+ enabled: true,
26
+ dailyRunCap: 50,
27
+ defaultFrequency: 6,
28
+ defaultMaxFrequency: 24,
29
+ researchTimeoutMs: 300000,
30
+ checkIntervalMs: 60000,
31
+ };
32
+
33
+ interface DailyRunTracker {
34
+ date: string;
35
+ count: number;
36
+ }
37
+
38
+ interface ResearchState {
39
+ dailyRuns: DailyRunTracker;
40
+ activePurposeCount: number;
41
+ }
42
+
43
+ function getTodayKey(): string {
44
+ return new Date().toISOString().split("T")[0];
45
+ }
46
+
47
+ function estimateTokens(text: string): number {
48
+ return Math.ceil(text.length / 4);
49
+ }
50
+
51
+ function countFindings(output: string): number {
52
+ const patterns = [/FINDINGS:/gi, /\*\*finding/gi, /discovered/gi, /found that/gi];
53
+ let count = 0;
54
+ for (const pattern of patterns) {
55
+ const matches = output.match(pattern);
56
+ if (matches) count += matches.length;
57
+ }
58
+ return Math.max(1, Math.min(count, 10));
59
+ }
60
+
61
+ function countAlerts(output: string): number {
62
+ const alertSection = output.match(/ALERTS?:\s*([^\n]+(?:\n(?!-|\*|[A-Z]+:)[^\n]+)*)/i);
63
+ if (!alertSection) return 0;
64
+ const alertText = alertSection[1].toLowerCase();
65
+ if (alertText.includes("none") || alertText.includes("no alert")) return 0;
66
+ return 1;
67
+ }
68
+
69
+ const STATE_DIR = join(homedir(), ".chorus");
70
+ const STATE_FILE = join(STATE_DIR, "research-state.json");
71
+
72
+ function loadState(): ResearchState {
73
+ try {
74
+ if (existsSync(STATE_FILE)) {
75
+ const data = JSON.parse(readFileSync(STATE_FILE, "utf-8"));
76
+ return {
77
+ dailyRuns: data.dailyRuns || { date: getTodayKey(), count: 0 },
78
+ activePurposeCount: data.activePurposeCount || 0,
79
+ };
80
+ }
81
+ } catch {}
82
+ return {
83
+ dailyRuns: { date: getTodayKey(), count: 0 },
84
+ activePurposeCount: 0,
85
+ };
86
+ }
87
+
88
+ function saveState(state: ResearchState): void {
89
+ try {
90
+ if (!existsSync(STATE_DIR)) {
91
+ mkdirSync(STATE_DIR, { recursive: true });
92
+ }
93
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
94
+ } catch {}
95
+ }
96
+
97
+ export function createPurposeResearchScheduler(
98
+ config: PurposeResearchConfig,
99
+ log: PluginLogger,
100
+ api: any
101
+ ): OpenClawPluginService & {
102
+ getDailyRunCount: () => number;
103
+ getDailyCap: () => number;
104
+ forceRun: (purposeId: string) => Promise<void>;
105
+ getStatus: () => { enabled: boolean; dailyRuns: number; dailyCap: number; activePurposes: number };
106
+ } {
107
+ let checkInterval: NodeJS.Timeout | null = null;
108
+
109
+ // Load persisted state
110
+ const state = loadState();
111
+ let dailyRuns: DailyRunTracker = state.dailyRuns;
112
+ let cachedActivePurposeCount: number = state.activePurposeCount;
113
+
114
+ function checkDayRollover(): void {
115
+ const today = getTodayKey();
116
+ if (dailyRuns.date !== today) {
117
+ log.info(`[purpose-research] New day — resetting run counter`);
118
+ dailyRuns = { date: today, count: 0 };
119
+ persistState();
120
+ }
121
+ }
122
+
123
+ function persistState(): void {
124
+ saveState({
125
+ dailyRuns,
126
+ activePurposeCount: cachedActivePurposeCount,
127
+ });
128
+ }
129
+
130
+ function calculateFrequency(purpose: Purpose): number {
131
+ const base = purpose.research?.frequency ?? config.defaultFrequency;
132
+ const max = purpose.research?.maxFrequency ?? config.defaultMaxFrequency;
133
+
134
+ if (!purpose.deadline) return base;
135
+
136
+ const deadline =
137
+ typeof purpose.deadline === "string" ? Date.parse(purpose.deadline) : purpose.deadline;
138
+ const daysRemaining = (deadline - Date.now()) / (24 * 60 * 60 * 1000);
139
+
140
+ let frequency: number;
141
+ if (daysRemaining <= 0) {
142
+ frequency = max;
143
+ } else if (daysRemaining <= 7) {
144
+ frequency = base * 3;
145
+ } else if (daysRemaining <= 30) {
146
+ frequency = base * 1.5;
147
+ } else {
148
+ frequency = base;
149
+ }
150
+
151
+ return Math.min(frequency, max);
152
+ }
153
+
154
+ function isResearchDue(purpose: Purpose): boolean {
155
+ if (purpose.progress >= 100) return false;
156
+ if (purpose.research?.enabled === false) return false;
157
+ if (!purpose.criteria?.length && !purpose.research?.domains?.length) return false;
158
+
159
+ const lastRun = purpose.research?.lastRun ?? 0;
160
+ const frequency = calculateFrequency(purpose);
161
+ const intervalMs = (24 * 60 * 60 * 1000) / frequency;
162
+
163
+ return Date.now() - lastRun >= intervalMs;
164
+ }
165
+
166
+ function generatePrompt(purpose: Purpose): string {
167
+ const domains = purpose.research?.domains?.join(", ") || "relevant sources";
168
+ const criteria = purpose.criteria?.map((c) => `- ${c}`).join("\n") || "(no specific criteria)";
169
+ const isCurious = (purpose.curiosity ?? 0) > 70;
170
+
171
+ if (isCurious) {
172
+ return `
173
+ PURPOSE RESEARCH (EXPLORATION MODE): ${purpose.name}
174
+
175
+ You are exploring ideas related to:
176
+ ${purpose.description || purpose.name}
177
+
178
+ This is curiosity-driven research. Be open to unexpected connections.
179
+
180
+ Starting points:
181
+ ${criteria}
182
+
183
+ Tasks:
184
+ 1. Search broadly for interesting developments
185
+ 2. Look for unexpected connections or adjacent ideas
186
+ 3. Note anything surprising or counterintuitive
187
+ 4. Identify rabbit holes worth exploring later
188
+
189
+ Output format:
190
+ - DISCOVERIES: What you found (can be tangential)
191
+ - CONNECTIONS: Links to other domains or ideas
192
+ - QUESTIONS: New questions raised
193
+ - RABBIT_HOLES: Topics worth deeper exploration
194
+
195
+ Write findings to: research/purpose-${purpose.id}-$(date +%Y-%m-%d-%H%M).md
196
+ `.trim();
197
+ }
198
+
199
+ const alertThreshold = purpose.research?.alertThreshold ?? "medium";
200
+ const alertGuidance: Record<string, string> = {
201
+ low: "Alert only for critical, time-sensitive findings",
202
+ medium: "Alert for significant developments affecting the purpose",
203
+ high: "Alert for any notable findings",
204
+ };
205
+
206
+ return `
207
+ PURPOSE RESEARCH: ${purpose.name}
208
+
209
+ You are researching for the following purpose:
210
+ ${purpose.description || purpose.name}
211
+
212
+ Search domains: ${domains}
213
+
214
+ Success criteria to inform research:
215
+ ${criteria}
216
+
217
+ Tasks:
218
+ 1. Search for recent developments relevant to this purpose
219
+ 2. Assess impact on purpose progress or timeline
220
+ 3. Flag anything that challenges or validates current assumptions
221
+ 4. Note actionable insights
222
+
223
+ Alert threshold: ${alertThreshold}
224
+ ${alertGuidance[alertThreshold]}
225
+
226
+ Output format:
227
+ - FINDINGS: Key discoveries (bullet points)
228
+ - IMPACT: How this affects the purpose (progress/timeline/risk)
229
+ - ALERTS: Anything requiring immediate attention (or "none")
230
+ - NEXT: What to research next time
231
+
232
+ Write findings to: research/purpose-${purpose.id}-$(date +%Y-%m-%d-%H%M).md
233
+
234
+ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
235
+ `.trim();
236
+ }
237
+
238
+ async function runResearch(purpose: Purpose): Promise<void> {
239
+ const startTime = Date.now();
240
+ log.info(`[purpose-research] 🔬 Running research for "${purpose.name}"`);
241
+
242
+ const execution: ChoirExecution = {
243
+ choirId: `purpose:${purpose.id}`,
244
+ timestamp: new Date().toISOString(),
245
+ durationMs: 0,
246
+ success: false,
247
+ outputLength: 0,
248
+ };
249
+
250
+ try {
251
+ const prompt = generatePrompt(purpose);
252
+
253
+ const result = await api.runAgentTurn?.({
254
+ sessionLabel: `chorus:purpose:${purpose.id}`,
255
+ message: prompt,
256
+ isolated: true,
257
+ timeoutSeconds: config.researchTimeoutMs / 1000,
258
+ });
259
+
260
+ const output = result?.response || "";
261
+ execution.durationMs = Date.now() - startTime;
262
+ execution.success = true;
263
+ execution.outputLength = output.length;
264
+ execution.tokensUsed = result?.meta?.tokensUsed || estimateTokens(output);
265
+ execution.findings = countFindings(output);
266
+ execution.alerts = countAlerts(output);
267
+
268
+ log.info(
269
+ `[purpose-research] ✓ "${purpose.name}" complete ` +
270
+ `(${(execution.durationMs / 1000).toFixed(1)}s, ${execution.findings} findings)`
271
+ );
272
+
273
+ await updatePurpose(purpose.id, {
274
+ research: {
275
+ ...purpose.research,
276
+ enabled: purpose.research?.enabled ?? true,
277
+ lastRun: Date.now(),
278
+ runCount: (purpose.research?.runCount ?? 0) + 1,
279
+ },
280
+ });
281
+ } catch (err) {
282
+ execution.durationMs = Date.now() - startTime;
283
+ execution.success = false;
284
+ execution.error = String(err);
285
+ log.error(`[purpose-research] ✗ "${purpose.name}" failed: ${err}`);
286
+ }
287
+
288
+ recordExecution(execution);
289
+ dailyRuns.count++;
290
+ persistState();
291
+ }
292
+
293
+ async function checkAndRun(): Promise<void> {
294
+ checkDayRollover();
295
+
296
+ if (dailyRuns.count >= config.dailyRunCap) {
297
+ log.debug(`[purpose-research] Daily cap reached (${dailyRuns.count}/${config.dailyRunCap})`);
298
+ return;
299
+ }
300
+
301
+ const purposes = await loadPurposes();
302
+
303
+ // Update cached active purpose count
304
+ cachedActivePurposeCount = purposes.filter(
305
+ (p) =>
306
+ p.progress < 100 &&
307
+ p.research?.enabled !== false &&
308
+ (p.criteria?.length || p.research?.domains?.length)
309
+ ).length;
310
+
311
+ const duePurposes = purposes.filter(isResearchDue);
312
+
313
+ if (duePurposes.length === 0) return;
314
+
315
+ duePurposes.sort((a, b) => {
316
+ const aDeadline = a.deadline
317
+ ? typeof a.deadline === "string"
318
+ ? Date.parse(a.deadline)
319
+ : a.deadline
320
+ : Infinity;
321
+ const bDeadline = b.deadline
322
+ ? typeof b.deadline === "string"
323
+ ? Date.parse(b.deadline)
324
+ : b.deadline
325
+ : Infinity;
326
+ return aDeadline - bDeadline;
327
+ });
328
+
329
+ const purpose = duePurposes[0];
330
+
331
+ if (dailyRuns.count < config.dailyRunCap) {
332
+ await runResearch(purpose);
333
+ }
334
+ }
335
+
336
+ return {
337
+ id: "chorus-purpose-research",
338
+
339
+ start: () => {
340
+ if (!config.enabled) {
341
+ log.info("[purpose-research] Disabled in config");
342
+ return;
343
+ }
344
+
345
+ log.info("[purpose-research] 🔬 Starting purpose research scheduler");
346
+ log.info(
347
+ `[purpose-research] Daily cap: ${config.dailyRunCap}, check interval: ${config.checkIntervalMs / 1000}s`
348
+ );
349
+
350
+ checkInterval = setInterval(() => {
351
+ checkAndRun().catch((err) => {
352
+ log.error(`[purpose-research] Check failed: ${err}`);
353
+ });
354
+ }, config.checkIntervalMs);
355
+
356
+ setTimeout(() => {
357
+ checkAndRun().catch((err) => {
358
+ log.error(`[purpose-research] Initial check failed: ${err}`);
359
+ });
360
+ }, 5000);
361
+
362
+ log.info("[purpose-research] 🔬 Scheduler active");
363
+ },
364
+
365
+ stop: () => {
366
+ log.info("[purpose-research] Stopping");
367
+ if (checkInterval) {
368
+ clearInterval(checkInterval);
369
+ checkInterval = null;
370
+ }
371
+ },
372
+
373
+ getDailyRunCount: () => dailyRuns.count,
374
+ getDailyCap: () => config.dailyRunCap,
375
+
376
+ forceRun: async (purposeId: string) => {
377
+ const purposes = await loadPurposes();
378
+ const purpose = purposes.find((p) => p.id === purposeId);
379
+ if (!purpose) throw new Error(`Purpose "${purposeId}" not found`);
380
+ await runResearch(purpose);
381
+ },
382
+
383
+ getStatus: () => {
384
+ return {
385
+ enabled: config.enabled,
386
+ dailyRuns: dailyRuns.count,
387
+ dailyCap: config.dailyRunCap,
388
+ activePurposes: cachedActivePurposeCount,
389
+ };
390
+ },
391
+ };
392
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * CHORUS Purposes System
3
+ *
4
+ * Manage purposes that drive autonomous behavior.
5
+ * Biblical framing: The choirs serve the Purposes.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir } from "fs/promises";
9
+ import { existsSync } from "fs";
10
+ import { dirname } from "path";
11
+ import { getPurposesPath } from "./senses.js";
12
+
13
+ export interface PurposeResearchConfig {
14
+ enabled: boolean;
15
+ domains?: string[];
16
+ frequency?: number;
17
+ maxFrequency?: number;
18
+ alertThreshold?: "low" | "medium" | "high";
19
+ lastRun?: number;
20
+ runCount?: number;
21
+ }
22
+
23
+ export interface Purpose {
24
+ id: string;
25
+ name: string;
26
+ description?: string;
27
+ deadline?: number | string; // Unix ms or ISO string
28
+ progress: number; // 0-100
29
+ criteria?: string[]; // Success criteria
30
+ lastWorkedOn?: number | string;
31
+ curiosity?: number; // 0-100, for exploration purposes
32
+ tags?: string[];
33
+ notes?: string;
34
+ research?: PurposeResearchConfig;
35
+ }
36
+
37
+ async function ensurePurposesFile(): Promise<void> {
38
+ const path = getPurposesPath();
39
+ if (!existsSync(path)) {
40
+ await mkdir(dirname(path), { recursive: true });
41
+ await writeFile(path, "[]");
42
+ }
43
+ }
44
+
45
+ export async function loadPurposes(): Promise<Purpose[]> {
46
+ await ensurePurposesFile();
47
+ try {
48
+ const data = await readFile(getPurposesPath(), "utf-8");
49
+ return JSON.parse(data);
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ export async function savePurposes(purposes: Purpose[]): Promise<void> {
56
+ await ensurePurposesFile();
57
+ await writeFile(getPurposesPath(), JSON.stringify(purposes, null, 2));
58
+ }
59
+
60
+ export async function addPurpose(purpose: Partial<Purpose> & { id: string; name: string }): Promise<Purpose> {
61
+ const purposes = await loadPurposes();
62
+
63
+ // Check for duplicate id
64
+ if (purposes.find(p => p.id === purpose.id)) {
65
+ throw new Error(`Purpose with id "${purpose.id}" already exists`);
66
+ }
67
+
68
+ const newPurpose: Purpose = {
69
+ id: purpose.id,
70
+ name: purpose.name,
71
+ description: purpose.description,
72
+ deadline: purpose.deadline,
73
+ progress: purpose.progress ?? 0,
74
+ criteria: purpose.criteria,
75
+ curiosity: purpose.curiosity,
76
+ tags: purpose.tags,
77
+ notes: purpose.notes,
78
+ research: purpose.research,
79
+ };
80
+
81
+ purposes.push(newPurpose);
82
+ await savePurposes(purposes);
83
+ return newPurpose;
84
+ }
85
+
86
+ export async function updatePurpose(id: string, updates: Partial<Purpose>): Promise<Purpose | null> {
87
+ const purposes = await loadPurposes();
88
+ const index = purposes.findIndex(p => p.id === id);
89
+
90
+ if (index === -1) return null;
91
+
92
+ // Update lastWorkedOn if progress changed
93
+ if (updates.progress !== undefined && updates.progress !== purposes[index].progress) {
94
+ updates.lastWorkedOn = Date.now();
95
+ }
96
+
97
+ purposes[index] = { ...purposes[index], ...updates };
98
+ await savePurposes(purposes);
99
+ return purposes[index];
100
+ }
101
+
102
+ export async function removePurpose(id: string): Promise<boolean> {
103
+ const purposes = await loadPurposes();
104
+ const index = purposes.findIndex(p => p.id === id);
105
+
106
+ if (index === -1) return false;
107
+
108
+ purposes.splice(index, 1);
109
+ await savePurposes(purposes);
110
+ return true;
111
+ }
112
+
113
+ export async function getPurpose(id: string): Promise<Purpose | null> {
114
+ const purposes = await loadPurposes();
115
+ return purposes.find(p => p.id === id) || null;
116
+ }
117
+
118
+ export function formatPurpose(purpose: Purpose): string {
119
+ const lines: string[] = [];
120
+
121
+ // Progress bar
122
+ const filled = Math.round(purpose.progress / 5);
123
+ const bar = "█".repeat(filled) + "░".repeat(20 - filled);
124
+
125
+ lines.push(`${purpose.name} [${bar}] ${purpose.progress}%`);
126
+
127
+ if (purpose.deadline) {
128
+ const deadline = typeof purpose.deadline === "string"
129
+ ? new Date(purpose.deadline)
130
+ : new Date(purpose.deadline);
131
+ const daysLeft = (deadline.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
132
+
133
+ if (daysLeft < 0) {
134
+ lines.push(` ⚠️ OVERDUE by ${Math.abs(daysLeft).toFixed(0)} days`);
135
+ } else if (daysLeft < 1) {
136
+ lines.push(` ⏰ Due in ${(daysLeft * 24).toFixed(0)} hours`);
137
+ } else {
138
+ lines.push(` 📅 Due in ${daysLeft.toFixed(0)} days (${deadline.toLocaleDateString()})`);
139
+ }
140
+ }
141
+
142
+ if (purpose.description) {
143
+ lines.push(` ${purpose.description}`);
144
+ }
145
+
146
+ if (purpose.criteria && purpose.criteria.length > 0) {
147
+ lines.push(" Criteria:");
148
+ for (const c of purpose.criteria) {
149
+ lines.push(` • ${c}`);
150
+ }
151
+ }
152
+
153
+ return lines.join("\n");
154
+ }
155
+
156
+ export function formatPurposesList(purposes: Purpose[]): string {
157
+ if (purposes.length === 0) {
158
+ return "No purposes set. Use `openclaw chorus purpose add` to create one.";
159
+ }
160
+
161
+ // Sort by deadline (soonest first), then by progress (lowest first)
162
+ const sorted = [...purposes].sort((a, b) => {
163
+ const aDeadline = a.deadline ? (typeof a.deadline === "string" ? Date.parse(a.deadline) : a.deadline) : Infinity;
164
+ const bDeadline = b.deadline ? (typeof b.deadline === "string" ? Date.parse(b.deadline) : b.deadline) : Infinity;
165
+
166
+ if (aDeadline !== bDeadline) return aDeadline - bDeadline;
167
+ return a.progress - b.progress;
168
+ });
169
+
170
+ const lines: string[] = ["✝️ Purposes", "═".repeat(50), ""];
171
+
172
+ for (const purpose of sorted) {
173
+ lines.push(formatPurpose(purpose));
174
+ lines.push("");
175
+ }
176
+
177
+ return lines.join("\n");
178
+ }