@iamoberlin/chorus 2.2.0 → 2.3.0
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/README.md +7 -1
- package/idl/chorus_prayers.json +1 -1
- package/index.ts +99 -46
- package/openclaw.plugin.json +81 -2
- package/package.json +1 -1
- package/src/choirs.ts +54 -25
- package/src/config.ts +91 -0
- package/src/daemon.ts +18 -13
- package/src/delivery.ts +96 -0
- package/src/economics.ts +166 -0
- package/src/prayers/solana.ts +1 -1
- package/src/purpose-research.ts +78 -24
- package/src/scheduler.ts +89 -5
package/src/config.ts
CHANGED
|
@@ -20,6 +20,22 @@ export interface ChorusConfig {
|
|
|
20
20
|
dailyRunCap: number;
|
|
21
21
|
defaultFrequency: number;
|
|
22
22
|
defaultMaxFrequency: number;
|
|
23
|
+
researchTimeoutMs: number;
|
|
24
|
+
checkIntervalMs: number;
|
|
25
|
+
};
|
|
26
|
+
daemon: {
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
senses: {
|
|
29
|
+
inbox: boolean;
|
|
30
|
+
purposes: boolean;
|
|
31
|
+
time: boolean;
|
|
32
|
+
};
|
|
33
|
+
thinkThreshold: number;
|
|
34
|
+
pollIntervalMs: number;
|
|
35
|
+
minSleepMs: number;
|
|
36
|
+
maxSleepMs: number;
|
|
37
|
+
quietHoursStart: number;
|
|
38
|
+
quietHoursEnd: number;
|
|
23
39
|
};
|
|
24
40
|
prayers: {
|
|
25
41
|
enabled: boolean;
|
|
@@ -45,6 +61,23 @@ export interface ChorusPluginConfig {
|
|
|
45
61
|
dailyRunCap?: number;
|
|
46
62
|
defaultFrequency?: number;
|
|
47
63
|
defaultMaxFrequency?: number;
|
|
64
|
+
researchTimeoutMs?: number;
|
|
65
|
+
checkIntervalMs?: number;
|
|
66
|
+
};
|
|
67
|
+
/** Daemon config */
|
|
68
|
+
daemon?: {
|
|
69
|
+
enabled?: boolean;
|
|
70
|
+
senses?: {
|
|
71
|
+
inbox?: boolean;
|
|
72
|
+
purposes?: boolean;
|
|
73
|
+
time?: boolean;
|
|
74
|
+
};
|
|
75
|
+
thinkThreshold?: number;
|
|
76
|
+
pollIntervalMs?: number;
|
|
77
|
+
minSleepMs?: number;
|
|
78
|
+
maxSleepMs?: number;
|
|
79
|
+
quietHoursStart?: number;
|
|
80
|
+
quietHoursEnd?: number;
|
|
48
81
|
};
|
|
49
82
|
/** On-chain prayer config */
|
|
50
83
|
prayers?: {
|
|
@@ -72,6 +105,22 @@ const DEFAULT_CONFIG: ChorusConfig = {
|
|
|
72
105
|
dailyRunCap: 50,
|
|
73
106
|
defaultFrequency: 6,
|
|
74
107
|
defaultMaxFrequency: 24,
|
|
108
|
+
researchTimeoutMs: 300000,
|
|
109
|
+
checkIntervalMs: 60000,
|
|
110
|
+
},
|
|
111
|
+
daemon: {
|
|
112
|
+
enabled: true,
|
|
113
|
+
senses: {
|
|
114
|
+
inbox: true,
|
|
115
|
+
purposes: true,
|
|
116
|
+
time: true,
|
|
117
|
+
},
|
|
118
|
+
thinkThreshold: 55,
|
|
119
|
+
pollIntervalMs: 5 * 60 * 1000,
|
|
120
|
+
minSleepMs: 30 * 1000,
|
|
121
|
+
maxSleepMs: 10 * 60 * 1000,
|
|
122
|
+
quietHoursStart: 23,
|
|
123
|
+
quietHoursEnd: 7,
|
|
75
124
|
},
|
|
76
125
|
prayers: {
|
|
77
126
|
enabled: true,
|
|
@@ -146,6 +195,48 @@ export function loadChorusConfig(pluginConfig?: ChorusPluginConfig): ChorusConfi
|
|
|
146
195
|
if (pluginConfig.purposeResearch.defaultMaxFrequency !== undefined) {
|
|
147
196
|
config.purposeResearch.defaultMaxFrequency = pluginConfig.purposeResearch.defaultMaxFrequency;
|
|
148
197
|
}
|
|
198
|
+
if (pluginConfig.purposeResearch.researchTimeoutMs !== undefined) {
|
|
199
|
+
config.purposeResearch.researchTimeoutMs = pluginConfig.purposeResearch.researchTimeoutMs;
|
|
200
|
+
}
|
|
201
|
+
if (pluginConfig.purposeResearch.checkIntervalMs !== undefined) {
|
|
202
|
+
config.purposeResearch.checkIntervalMs = pluginConfig.purposeResearch.checkIntervalMs;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Daemon
|
|
207
|
+
if (pluginConfig.daemon) {
|
|
208
|
+
if (pluginConfig.daemon.enabled !== undefined) {
|
|
209
|
+
config.daemon.enabled = pluginConfig.daemon.enabled;
|
|
210
|
+
}
|
|
211
|
+
if (pluginConfig.daemon.senses) {
|
|
212
|
+
if (pluginConfig.daemon.senses.inbox !== undefined) {
|
|
213
|
+
config.daemon.senses.inbox = pluginConfig.daemon.senses.inbox;
|
|
214
|
+
}
|
|
215
|
+
if (pluginConfig.daemon.senses.purposes !== undefined) {
|
|
216
|
+
config.daemon.senses.purposes = pluginConfig.daemon.senses.purposes;
|
|
217
|
+
}
|
|
218
|
+
if (pluginConfig.daemon.senses.time !== undefined) {
|
|
219
|
+
config.daemon.senses.time = pluginConfig.daemon.senses.time;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (pluginConfig.daemon.thinkThreshold !== undefined) {
|
|
223
|
+
config.daemon.thinkThreshold = pluginConfig.daemon.thinkThreshold;
|
|
224
|
+
}
|
|
225
|
+
if (pluginConfig.daemon.pollIntervalMs !== undefined) {
|
|
226
|
+
config.daemon.pollIntervalMs = pluginConfig.daemon.pollIntervalMs;
|
|
227
|
+
}
|
|
228
|
+
if (pluginConfig.daemon.minSleepMs !== undefined) {
|
|
229
|
+
config.daemon.minSleepMs = pluginConfig.daemon.minSleepMs;
|
|
230
|
+
}
|
|
231
|
+
if (pluginConfig.daemon.maxSleepMs !== undefined) {
|
|
232
|
+
config.daemon.maxSleepMs = pluginConfig.daemon.maxSleepMs;
|
|
233
|
+
}
|
|
234
|
+
if (pluginConfig.daemon.quietHoursStart !== undefined) {
|
|
235
|
+
config.daemon.quietHoursStart = pluginConfig.daemon.quietHoursStart;
|
|
236
|
+
}
|
|
237
|
+
if (pluginConfig.daemon.quietHoursEnd !== undefined) {
|
|
238
|
+
config.daemon.quietHoursEnd = pluginConfig.daemon.quietHoursEnd;
|
|
239
|
+
}
|
|
149
240
|
}
|
|
150
241
|
|
|
151
242
|
return config;
|
package/src/daemon.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
9
9
|
import type { Signal, Sense } from "./senses.js";
|
|
10
10
|
import { ALL_SENSES } from "./senses.js";
|
|
11
|
-
import { SalienceFilter
|
|
11
|
+
import { SalienceFilter } from "./salience.js";
|
|
12
12
|
import { recordExecution, type ChoirExecution } from "./metrics.js";
|
|
13
13
|
|
|
14
14
|
export interface DaemonConfig {
|
|
@@ -55,7 +55,7 @@ export function createDaemon(
|
|
|
55
55
|
const attentionQueue: AttentionItem[] = [];
|
|
56
56
|
const cleanupFns: (() => void)[] = [];
|
|
57
57
|
|
|
58
|
-
let
|
|
58
|
+
let cycleTimer: NodeJS.Timeout | null = null;
|
|
59
59
|
let running = false;
|
|
60
60
|
|
|
61
61
|
// Get enabled senses
|
|
@@ -146,7 +146,7 @@ export function createDaemon(
|
|
|
146
146
|
|
|
147
147
|
try {
|
|
148
148
|
const result = await api.runAgentTurn?.({
|
|
149
|
-
sessionLabel: "chorus
|
|
149
|
+
sessionLabel: "chorus-daemon",
|
|
150
150
|
message: prompt,
|
|
151
151
|
isolated: true,
|
|
152
152
|
timeoutSeconds: 180,
|
|
@@ -240,11 +240,7 @@ export function createDaemon(
|
|
|
240
240
|
// Start event watchers
|
|
241
241
|
startWatchers();
|
|
242
242
|
|
|
243
|
-
|
|
244
|
-
pollSenses().catch(err => log.error(`[daemon] Initial poll failed: ${err}`));
|
|
245
|
-
|
|
246
|
-
// Periodic polling
|
|
247
|
-
pollInterval = setInterval(async () => {
|
|
243
|
+
const runCycle = async (): Promise<void> => {
|
|
248
244
|
if (!running) return;
|
|
249
245
|
|
|
250
246
|
try {
|
|
@@ -253,18 +249,27 @@ export function createDaemon(
|
|
|
253
249
|
} catch (err) {
|
|
254
250
|
log.error(`[daemon] Cycle error: ${err}`);
|
|
255
251
|
}
|
|
256
|
-
}, config.pollIntervalMs);
|
|
257
252
|
|
|
258
|
-
|
|
253
|
+
if (!running) return;
|
|
254
|
+
cycleTimer = setTimeout(() => {
|
|
255
|
+
runCycle().catch(err => log.error(`[daemon] Cycle scheduler error: ${err}`));
|
|
256
|
+
}, calculateSleepTime());
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
runCycle().catch(err => log.error(`[daemon] Initial cycle failed: ${err}`));
|
|
260
|
+
|
|
261
|
+
log.info(
|
|
262
|
+
`[daemon] 👁️ Active — adaptive sleep ${config.minSleepMs / 1000}s..${config.maxSleepMs / 1000}s, threshold: ${config.thinkThreshold}`
|
|
263
|
+
);
|
|
259
264
|
},
|
|
260
265
|
|
|
261
266
|
stop: () => {
|
|
262
267
|
running = false;
|
|
263
268
|
log.info("[daemon] Stopping...");
|
|
264
269
|
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
270
|
+
if (cycleTimer) {
|
|
271
|
+
clearTimeout(cycleTimer);
|
|
272
|
+
cycleTimer = null;
|
|
268
273
|
}
|
|
269
274
|
|
|
270
275
|
for (const cleanup of cleanupFns) {
|
package/src/delivery.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
|
|
3
|
+
interface DeliverableChoir {
|
|
4
|
+
name: string;
|
|
5
|
+
delivers?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface LoggerLike {
|
|
9
|
+
info?: (msg: string) => void;
|
|
10
|
+
warn?: (msg: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toPlainTextIfNeeded(text: string, channel?: string): string {
|
|
14
|
+
let deliveryText = text.slice(0, 4000);
|
|
15
|
+
if (channel !== "imessage") return deliveryText;
|
|
16
|
+
|
|
17
|
+
return deliveryText
|
|
18
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
19
|
+
.replace(/\*(.+?)\*/g, "$1")
|
|
20
|
+
.replace(/__(.+?)__/g, "$1")
|
|
21
|
+
.replace(/_(.+?)_/g, "$1")
|
|
22
|
+
.replace(/`(.+?)`/g, "$1")
|
|
23
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
24
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
25
|
+
.replace(/^\s*[-*+]\s+/gm, "• ")
|
|
26
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveDeliveryTarget(api: any): { target?: string; channel?: string } {
|
|
30
|
+
const channels = api?.config?.channels as Record<string, any> | undefined;
|
|
31
|
+
if (!channels) return {};
|
|
32
|
+
|
|
33
|
+
for (const [channel, cfg] of Object.entries(channels)) {
|
|
34
|
+
if (cfg?.enabled && cfg?.allowFrom?.[0]) {
|
|
35
|
+
return { target: cfg.allowFrom[0], channel };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function deliverChoirOutput(
|
|
43
|
+
api: any,
|
|
44
|
+
choir: DeliverableChoir,
|
|
45
|
+
text: string,
|
|
46
|
+
log?: LoggerLike
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
if (!choir.delivers || !text || text === "HEARTBEAT_OK" || text === "NO_REPLY") return;
|
|
49
|
+
|
|
50
|
+
const { target, channel } = resolveDeliveryTarget(api);
|
|
51
|
+
if (!target) {
|
|
52
|
+
log?.warn?.(`[chorus] ⚠ No delivery target found in OpenClaw config for ${choir.name}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = [
|
|
57
|
+
"message", "send",
|
|
58
|
+
"--target", target,
|
|
59
|
+
"--message", toPlainTextIfNeeded(text, channel),
|
|
60
|
+
];
|
|
61
|
+
if (channel) args.push("--channel", channel);
|
|
62
|
+
|
|
63
|
+
await new Promise<void>((resolve) => {
|
|
64
|
+
try {
|
|
65
|
+
const child = spawn("openclaw", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
66
|
+
let stderr = "";
|
|
67
|
+
child.stderr.on("data", (d: Buffer) => {
|
|
68
|
+
if (stderr.length < 1024) stderr += d.toString();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
child.kill("SIGTERM");
|
|
73
|
+
}, 30000);
|
|
74
|
+
|
|
75
|
+
child.on("close", (code) => {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
if (code === 0) {
|
|
78
|
+
log?.info?.(`[chorus] 📨 ${choir.name} output delivered via ${channel || "default"}`);
|
|
79
|
+
} else {
|
|
80
|
+
const err = stderr.trim().slice(0, 160);
|
|
81
|
+
log?.warn?.(`[chorus] ⚠ ${choir.name} delivery failed${err ? `: ${err}` : ""}`);
|
|
82
|
+
}
|
|
83
|
+
resolve();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
child.on("error", (err: any) => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
log?.warn?.(`[chorus] ⚠ ${choir.name} delivery error: ${(err?.message || "").slice(0, 160)}`);
|
|
89
|
+
resolve();
|
|
90
|
+
});
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
log?.warn?.(`[chorus] ⚠ ${choir.name} delivery error: ${(err?.message || "").slice(0, 160)}`);
|
|
93
|
+
resolve();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/economics.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Attention Economics — Phase 1: Observe
|
|
3
|
+
*
|
|
4
|
+
* Tracks inference cost and value signals per choir/purpose.
|
|
5
|
+
* No frequency adjustments yet — just measurement.
|
|
6
|
+
*
|
|
7
|
+
* Cost: captured from each choir run (tokens × model rate)
|
|
8
|
+
* Value: attributed from downstream outcomes (calibration wins, alerts acted on, etc.)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, appendFileSync, readFileSync, mkdirSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
|
|
15
|
+
const ECONOMICS_DIR = join(homedir(), ".chorus", "economics");
|
|
16
|
+
|
|
17
|
+
// Model cost rates (USD per 1K tokens)
|
|
18
|
+
const MODEL_RATES: Record<string, { input: number; output: number }> = {
|
|
19
|
+
"claude-sonnet-4": { input: 0.003, output: 0.015 },
|
|
20
|
+
"claude-opus-4": { input: 0.015, output: 0.075 },
|
|
21
|
+
"claude-haiku-3.5": { input: 0.0008, output: 0.004 },
|
|
22
|
+
default: { input: 0.003, output: 0.015 }, // assume sonnet
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── Cost Tracking ────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface RunCost {
|
|
28
|
+
choirId: string;
|
|
29
|
+
purposeId?: string;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
inputTokens: number;
|
|
32
|
+
outputTokens: number;
|
|
33
|
+
model: string;
|
|
34
|
+
inferenceMs: number;
|
|
35
|
+
costUsd: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureDir(): void {
|
|
39
|
+
if (!existsSync(ECONOMICS_DIR)) {
|
|
40
|
+
mkdirSync(ECONOMICS_DIR, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function recordCost(cost: RunCost): void {
|
|
45
|
+
ensureDir();
|
|
46
|
+
const line = JSON.stringify(cost) + "\n";
|
|
47
|
+
appendFileSync(join(ECONOMICS_DIR, "costs.jsonl"), line);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function estimateCost(
|
|
51
|
+
inputTokens: number,
|
|
52
|
+
outputTokens: number,
|
|
53
|
+
model: string = "default"
|
|
54
|
+
): number {
|
|
55
|
+
const rate = MODEL_RATES[model] || MODEL_RATES.default;
|
|
56
|
+
return (inputTokens / 1000) * rate.input + (outputTokens / 1000) * rate.output;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Value Tracking ───────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export type ValueKind =
|
|
62
|
+
| "thesis_win" // calibration record: prediction correct
|
|
63
|
+
| "thesis_save" // powers caught a flaw → loss avoided
|
|
64
|
+
| "config_improve" // virtues change → measurable improvement
|
|
65
|
+
| "alert_acted" // archangels alert → human acted
|
|
66
|
+
| "pnl_attribution" // research led to profitable trade
|
|
67
|
+
| "knowledge_cited"; // cherubim insight reused in later decision
|
|
68
|
+
|
|
69
|
+
export interface ValueEvent {
|
|
70
|
+
choirId: string;
|
|
71
|
+
purposeId?: string;
|
|
72
|
+
timestamp: string;
|
|
73
|
+
kind: ValueKind;
|
|
74
|
+
estimatedValueUsd: number;
|
|
75
|
+
evidence: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function recordValue(event: ValueEvent): void {
|
|
79
|
+
ensureDir();
|
|
80
|
+
const line = JSON.stringify(event) + "\n";
|
|
81
|
+
appendFileSync(join(ECONOMICS_DIR, "value.jsonl"), line);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── ROI Calculation ──────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
interface ChoirROI {
|
|
87
|
+
choirId: string;
|
|
88
|
+
windowDays: number;
|
|
89
|
+
totalCostUsd: number;
|
|
90
|
+
totalValueUsd: number;
|
|
91
|
+
roi: number; // value / cost, or Infinity if cost is 0
|
|
92
|
+
runCount: number;
|
|
93
|
+
costPerRun: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readJsonl<T>(filename: string): T[] {
|
|
97
|
+
const path = join(ECONOMICS_DIR, filename);
|
|
98
|
+
if (!existsSync(path)) return [];
|
|
99
|
+
try {
|
|
100
|
+
return readFileSync(path, "utf-8")
|
|
101
|
+
.trim()
|
|
102
|
+
.split("\n")
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map((line) => JSON.parse(line));
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function calculateROI(choirId: string, windowDays: number = 7): ChoirROI {
|
|
111
|
+
const cutoff = Date.now() - windowDays * 86400000;
|
|
112
|
+
|
|
113
|
+
const costs = readJsonl<RunCost>("costs.jsonl").filter(
|
|
114
|
+
(c) => c.choirId === choirId && new Date(c.timestamp).getTime() > cutoff
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const values = readJsonl<ValueEvent>("value.jsonl").filter(
|
|
118
|
+
(v) => v.choirId === choirId && new Date(v.timestamp).getTime() > cutoff
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const totalCost = costs.reduce((sum, c) => sum + c.costUsd, 0);
|
|
122
|
+
const totalValue = values.reduce((sum, v) => sum + v.estimatedValueUsd, 0);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
choirId,
|
|
126
|
+
windowDays,
|
|
127
|
+
totalCostUsd: totalCost,
|
|
128
|
+
totalValueUsd: totalValue,
|
|
129
|
+
roi: totalCost > 0 ? totalValue / totalCost : values.length > 0 ? Infinity : 0,
|
|
130
|
+
runCount: costs.length,
|
|
131
|
+
costPerRun: costs.length > 0 ? totalCost / costs.length : 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function calculateAllROI(windowDays: number = 7): ChoirROI[] {
|
|
136
|
+
const choirIds = [
|
|
137
|
+
"seraphim", "cherubim", "thrones",
|
|
138
|
+
"dominions", "virtues", "powers",
|
|
139
|
+
"principalities", "archangels", "angels",
|
|
140
|
+
];
|
|
141
|
+
return choirIds.map((id) => calculateROI(id, windowDays));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Summary ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
export function formatEconomicsSummary(windowDays: number = 7): string {
|
|
147
|
+
const allROI = calculateAllROI(windowDays);
|
|
148
|
+
const totalCost = allROI.reduce((s, r) => s + r.totalCostUsd, 0);
|
|
149
|
+
const totalValue = allROI.reduce((s, r) => s + r.totalValueUsd, 0);
|
|
150
|
+
const totalRuns = allROI.reduce((s, r) => s + r.runCount, 0);
|
|
151
|
+
|
|
152
|
+
const lines = [
|
|
153
|
+
`Attention Economics — ${windowDays}d window`,
|
|
154
|
+
"=".repeat(50),
|
|
155
|
+
"",
|
|
156
|
+
`Total: ${totalRuns} runs | $${totalCost.toFixed(2)} cost | $${totalValue.toFixed(2)} value | ROI ${totalCost > 0 ? (totalValue / totalCost).toFixed(1) : "N/A"}x`,
|
|
157
|
+
"",
|
|
158
|
+
"Per Choir:",
|
|
159
|
+
...allROI.map((r) => {
|
|
160
|
+
const roi = r.totalCostUsd > 0 ? `${r.roi.toFixed(1)}x` : "N/A";
|
|
161
|
+
return ` ${r.choirId.padEnd(16)} ${r.runCount.toString().padStart(3)} runs $${r.totalCostUsd.toFixed(2).padStart(6)} cost $${r.totalValueUsd.toFixed(2).padStart(8)} value ${roi.padStart(6)} ROI`;
|
|
162
|
+
}),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
package/src/prayers/solana.ts
CHANGED
|
@@ -27,7 +27,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
27
27
|
const __dirname = path.dirname(__filename);
|
|
28
28
|
|
|
29
29
|
// Program ID (deployed to devnet)
|
|
30
|
-
export const PROGRAM_ID = new PublicKey("
|
|
30
|
+
export const PROGRAM_ID = new PublicKey("Af61jGnh2AceK3E8FAxCh9j7Jt6JWtJz6PUtbciDjVJS");
|
|
31
31
|
|
|
32
32
|
// Max plaintext size that fits in a Solana transaction after encryption overhead
|
|
33
33
|
// Encrypted blob = plaintext + 40 bytes (24 nonce + 16 Poly1305 tag)
|
package/src/purpose-research.ts
CHANGED
|
@@ -35,6 +35,9 @@ export const DEFAULT_PURPOSE_RESEARCH_CONFIG: PurposeResearchConfig = {
|
|
|
35
35
|
checkIntervalMs: 60000,
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// Guard: purposes currently running research (prevents duplicate triggers)
|
|
39
|
+
const runningPurposes = new Set<string>();
|
|
40
|
+
|
|
38
41
|
interface DailyRunTracker {
|
|
39
42
|
date: string;
|
|
40
43
|
count: number;
|
|
@@ -71,6 +74,36 @@ function countAlerts(output: string): number {
|
|
|
71
74
|
return 1;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
function extractTextFromApiResult(result: any): string {
|
|
78
|
+
if (!result) return "";
|
|
79
|
+
const payloadText = (result.payloads || [])
|
|
80
|
+
.map((p: any) => p?.text || "")
|
|
81
|
+
.filter((t: string) => Boolean(t))
|
|
82
|
+
.pop();
|
|
83
|
+
return payloadText || result.text || result.result?.text || result.response || result.content || "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractTextFromCliStdout(stdout: string): string {
|
|
87
|
+
if (!stdout) return "";
|
|
88
|
+
|
|
89
|
+
// Find the last top-level JSON object to skip any log noise before payload.
|
|
90
|
+
for (let i = stdout.length - 1; i >= 0; i--) {
|
|
91
|
+
if (stdout[i] !== "{") continue;
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(stdout.slice(i));
|
|
94
|
+
const payloadText = (parsed.result?.payloads || [])
|
|
95
|
+
.map((p: any) => p?.text || "")
|
|
96
|
+
.filter((t: string) => Boolean(t))
|
|
97
|
+
.pop();
|
|
98
|
+
return payloadText || parsed.result?.text || parsed.text || parsed.response || parsed.content || "";
|
|
99
|
+
} catch {
|
|
100
|
+
// Keep scanning backward for valid JSON.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
74
107
|
const STATE_DIR = join(homedir(), ".chorus");
|
|
75
108
|
const STATE_FILE = join(STATE_DIR, "research-state.json");
|
|
76
109
|
|
|
@@ -160,6 +193,8 @@ export function createPurposeResearchScheduler(
|
|
|
160
193
|
if (purpose.progress >= 100) return false;
|
|
161
194
|
if (purpose.research?.enabled === false) return false;
|
|
162
195
|
if (!purpose.criteria?.length && !purpose.research?.domains?.length) return false;
|
|
196
|
+
// Guard: skip if already running (prevents duplicate triggers while agent is executing)
|
|
197
|
+
if (runningPurposes.has(purpose.id)) return false;
|
|
163
198
|
|
|
164
199
|
const lastRun = purpose.research?.lastRun ?? 0;
|
|
165
200
|
const frequency = calculateFrequency(purpose);
|
|
@@ -214,33 +249,43 @@ PURPOSE RESEARCH: ${purpose.name}
|
|
|
214
249
|
You are researching for the following purpose:
|
|
215
250
|
${purpose.description || purpose.name}
|
|
216
251
|
|
|
217
|
-
|
|
252
|
+
YOU HAVE FULL TOOL ACCESS. Use exec to run shell commands, web_search to search the web, web_fetch to read URLs. If criteria below include "run:" commands, EXECUTE THEM — do not just describe what you would do.
|
|
253
|
+
|
|
254
|
+
Working directory: ${WORKSPACE_PATH}
|
|
218
255
|
|
|
219
|
-
|
|
256
|
+
Criteria (follow these — if they say "run:", actually run the command):
|
|
220
257
|
${criteria}
|
|
221
258
|
|
|
222
259
|
Tasks:
|
|
223
|
-
1.
|
|
224
|
-
2.
|
|
225
|
-
3.
|
|
226
|
-
4.
|
|
260
|
+
1. EXECUTE any commands specified in criteria above
|
|
261
|
+
2. Use web_search for at least 2 queries on recent developments
|
|
262
|
+
3. Assess impact on purpose progress or timeline
|
|
263
|
+
4. Surface actionable insights with measurable specifics (metrics, thresholds, dates)
|
|
227
264
|
|
|
228
265
|
Alert threshold: ${alertThreshold}
|
|
229
266
|
${alertGuidance[alertThreshold]}
|
|
230
267
|
|
|
231
268
|
Output format:
|
|
232
|
-
-
|
|
269
|
+
- EXECUTED: Commands run and their results (summarized)
|
|
270
|
+
- FINDINGS: Key discoveries (bullet points with numbers)
|
|
233
271
|
- IMPACT: How this affects the purpose (progress/timeline/risk)
|
|
234
272
|
- ALERTS: Anything requiring immediate attention (or "none")
|
|
235
273
|
- NEXT: What to research next time
|
|
236
274
|
|
|
237
|
-
Your output will be saved automatically. Focus on
|
|
275
|
+
Your output will be saved automatically. Focus on actionable content, not analysis of what you could do.
|
|
238
276
|
|
|
239
|
-
CRITICAL: If sending alerts
|
|
277
|
+
CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no markdown).
|
|
240
278
|
`.trim();
|
|
241
279
|
}
|
|
242
280
|
|
|
243
|
-
async function runResearch(purpose: Purpose): Promise<
|
|
281
|
+
async function runResearch(purpose: Purpose): Promise<boolean> {
|
|
282
|
+
// Guard: prevent concurrent runs of the same purpose
|
|
283
|
+
if (runningPurposes.has(purpose.id)) {
|
|
284
|
+
log.debug(`[purpose-research] ⏭️ "${purpose.name}" already running, skipping`);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
runningPurposes.add(purpose.id);
|
|
288
|
+
|
|
244
289
|
const startTime = Date.now();
|
|
245
290
|
log.info(`[purpose-research] 🔬 Running research for "${purpose.name}"`);
|
|
246
291
|
|
|
@@ -261,12 +306,16 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
261
306
|
if (typeof api.runAgentTurn === "function") {
|
|
262
307
|
try {
|
|
263
308
|
result = await api.runAgentTurn({
|
|
264
|
-
sessionLabel: `chorus
|
|
309
|
+
sessionLabel: `chorus-purpose-${purpose.id}`,
|
|
265
310
|
message: prompt,
|
|
266
311
|
isolated: true,
|
|
267
312
|
timeoutSeconds: config.researchTimeoutMs / 1000,
|
|
268
313
|
});
|
|
269
|
-
output = result
|
|
314
|
+
output = extractTextFromApiResult(result);
|
|
315
|
+
if (!output.trim()) {
|
|
316
|
+
log.debug(`[purpose-research] API returned empty output, falling back to CLI for "${purpose.name}"`);
|
|
317
|
+
result = null;
|
|
318
|
+
}
|
|
270
319
|
} catch (apiErr) {
|
|
271
320
|
log.debug(`[purpose-research] API runAgentTurn failed, falling back to CLI: ${apiErr}`);
|
|
272
321
|
result = null;
|
|
@@ -279,7 +328,7 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
279
328
|
result = await new Promise<any>((resolve) => {
|
|
280
329
|
const child = spawn("openclaw", [
|
|
281
330
|
"agent",
|
|
282
|
-
"--session-id", `chorus
|
|
331
|
+
"--session-id", `chorus-purpose-${purpose.id}`,
|
|
283
332
|
"--message", prompt,
|
|
284
333
|
"--json",
|
|
285
334
|
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
@@ -296,16 +345,15 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
296
345
|
});
|
|
297
346
|
|
|
298
347
|
if (result.status === 0 && result.stdout) {
|
|
299
|
-
|
|
300
|
-
const json = JSON.parse(result.stdout);
|
|
301
|
-
output = json.result?.payloads?.[0]?.text || json.response || "";
|
|
302
|
-
} catch {
|
|
303
|
-
output = result.stdout;
|
|
304
|
-
}
|
|
348
|
+
output = extractTextFromCliStdout(result.stdout);
|
|
305
349
|
} else if (result.stderr) {
|
|
306
350
|
log.error(`[purpose-research] CLI error: ${result.stderr}`);
|
|
307
351
|
}
|
|
308
352
|
}
|
|
353
|
+
|
|
354
|
+
if (!output.trim()) {
|
|
355
|
+
throw new Error("No research output produced");
|
|
356
|
+
}
|
|
309
357
|
execution.durationMs = Date.now() - startTime;
|
|
310
358
|
execution.success = true;
|
|
311
359
|
execution.outputLength = output.length;
|
|
@@ -343,16 +391,19 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
343
391
|
runCount: (purpose.research?.runCount ?? 0) + 1,
|
|
344
392
|
},
|
|
345
393
|
});
|
|
394
|
+
dailyRuns.count++;
|
|
395
|
+
persistState();
|
|
396
|
+
return true;
|
|
346
397
|
} catch (err) {
|
|
347
398
|
execution.durationMs = Date.now() - startTime;
|
|
348
399
|
execution.success = false;
|
|
349
400
|
execution.error = String(err);
|
|
350
401
|
log.error(`[purpose-research] ✗ "${purpose.name}" failed: ${err}`);
|
|
402
|
+
return false;
|
|
403
|
+
} finally {
|
|
404
|
+
runningPurposes.delete(purpose.id);
|
|
405
|
+
recordExecution(execution);
|
|
351
406
|
}
|
|
352
|
-
|
|
353
|
-
recordExecution(execution);
|
|
354
|
-
dailyRuns.count++;
|
|
355
|
-
persistState();
|
|
356
407
|
}
|
|
357
408
|
|
|
358
409
|
async function checkAndRun(): Promise<void> {
|
|
@@ -442,7 +493,10 @@ CRITICAL: If sending alerts via iMessage, use PLAIN TEXT ONLY (no markdown).
|
|
|
442
493
|
const purposes = await loadPurposes();
|
|
443
494
|
const purpose = purposes.find((p) => p.id === purposeId);
|
|
444
495
|
if (!purpose) throw new Error(`Purpose "${purposeId}" not found`);
|
|
445
|
-
await runResearch(purpose);
|
|
496
|
+
const success = await runResearch(purpose);
|
|
497
|
+
if (!success) {
|
|
498
|
+
throw new Error(`Research run failed for "${purposeId}"`);
|
|
499
|
+
}
|
|
446
500
|
},
|
|
447
501
|
|
|
448
502
|
getStatus: () => {
|