@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
|
@@ -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
|
+
}
|
package/src/purposes.ts
ADDED
|
@@ -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
|
+
}
|