@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/salience.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Salience Filter
|
|
3
|
+
*
|
|
4
|
+
* Cheap, rule-based scoring to decide what's worth attending to.
|
|
5
|
+
* No LLM calls — this runs constantly and must be fast.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Signal } from "./senses.js";
|
|
9
|
+
|
|
10
|
+
export interface SalienceRule {
|
|
11
|
+
id: string;
|
|
12
|
+
match?: RegExp; // Content pattern
|
|
13
|
+
source?: string; // Source type filter
|
|
14
|
+
sourceMatch?: RegExp; // Source pattern
|
|
15
|
+
boost?: number; // Add to priority
|
|
16
|
+
penalty?: number; // Subtract from priority
|
|
17
|
+
minPriority?: number; // Set floor
|
|
18
|
+
maxPriority?: number; // Set ceiling
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Default rules — can be extended via config
|
|
22
|
+
const DEFAULT_RULES: SalienceRule[] = [
|
|
23
|
+
// Urgency keywords
|
|
24
|
+
{ id: "urgent", match: /\b(urgent|asap|emergency|critical|immediately)\b/i, boost: 40 },
|
|
25
|
+
{ id: "important", match: /\b(important|priority|attention)\b/i, boost: 20 },
|
|
26
|
+
|
|
27
|
+
// Source boosts
|
|
28
|
+
{ id: "purpose-source", source: "purpose", boost: 15 },
|
|
29
|
+
{ id: "inbox-source", source: "inbox", boost: 10 },
|
|
30
|
+
|
|
31
|
+
// Overdue purposes are critical
|
|
32
|
+
{ id: "overdue", match: /\boverdue\b/i, boost: 30 },
|
|
33
|
+
|
|
34
|
+
// Time-based signals are moderate priority
|
|
35
|
+
{ id: "time-source", source: "time", maxPriority: 65 },
|
|
36
|
+
|
|
37
|
+
// Curiosity is low priority (background exploration)
|
|
38
|
+
{ id: "curiosity-source", source: "curiosity", maxPriority: 45 },
|
|
39
|
+
|
|
40
|
+
// Spam/noise penalties
|
|
41
|
+
{ id: "unsubscribe", match: /\b(unsubscribe|newsletter|promo|marketing)\b/i, penalty: 50 },
|
|
42
|
+
{ id: "automated", match: /\b(automated|no-reply|noreply)\b/i, penalty: 30 },
|
|
43
|
+
|
|
44
|
+
// Stalled projects need attention but aren't urgent
|
|
45
|
+
{ id: "stalled", match: /\bstalled\b/i, boost: 10, maxPriority: 55 },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export interface SalienceResult {
|
|
49
|
+
originalPriority: number;
|
|
50
|
+
finalPriority: number;
|
|
51
|
+
rulesApplied: string[];
|
|
52
|
+
shouldAttend: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class SalienceFilter {
|
|
56
|
+
private rules: SalienceRule[];
|
|
57
|
+
private threshold: number;
|
|
58
|
+
private seenSignals: Map<string, number> = new Map(); // Dedup within time window
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
customRules: SalienceRule[] = [],
|
|
62
|
+
threshold: number = 55
|
|
63
|
+
) {
|
|
64
|
+
this.rules = [...DEFAULT_RULES, ...customRules];
|
|
65
|
+
this.threshold = threshold;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
evaluate(signal: Signal): SalienceResult {
|
|
69
|
+
let priority = signal.priority;
|
|
70
|
+
const rulesApplied: string[] = [];
|
|
71
|
+
|
|
72
|
+
// Check for duplicate signals (same id within 1 hour)
|
|
73
|
+
const lastSeen = this.seenSignals.get(signal.id);
|
|
74
|
+
if (lastSeen && Date.now() - lastSeen < 60 * 60 * 1000) {
|
|
75
|
+
return {
|
|
76
|
+
originalPriority: signal.priority,
|
|
77
|
+
finalPriority: 0,
|
|
78
|
+
rulesApplied: ["dedup"],
|
|
79
|
+
shouldAttend: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Apply rules
|
|
84
|
+
for (const rule of this.rules) {
|
|
85
|
+
let applies = true;
|
|
86
|
+
|
|
87
|
+
// Source filter
|
|
88
|
+
if (rule.source && signal.source !== rule.source) {
|
|
89
|
+
applies = false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Source pattern
|
|
93
|
+
if (rule.sourceMatch && !rule.sourceMatch.test(signal.source)) {
|
|
94
|
+
applies = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Content pattern
|
|
98
|
+
if (rule.match && !rule.match.test(signal.content)) {
|
|
99
|
+
applies = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (applies) {
|
|
103
|
+
rulesApplied.push(rule.id);
|
|
104
|
+
|
|
105
|
+
if (rule.boost) priority += rule.boost;
|
|
106
|
+
if (rule.penalty) priority -= rule.penalty;
|
|
107
|
+
if (rule.minPriority !== undefined) priority = Math.max(priority, rule.minPriority);
|
|
108
|
+
if (rule.maxPriority !== undefined) priority = Math.min(priority, rule.maxPriority);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Clamp to 0-100
|
|
113
|
+
priority = Math.max(0, Math.min(100, priority));
|
|
114
|
+
|
|
115
|
+
const shouldAttend = priority >= this.threshold;
|
|
116
|
+
|
|
117
|
+
// Mark as seen if we're attending
|
|
118
|
+
if (shouldAttend) {
|
|
119
|
+
this.seenSignals.set(signal.id, Date.now());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Cleanup old entries periodically
|
|
123
|
+
if (this.seenSignals.size > 1000) {
|
|
124
|
+
const cutoff = Date.now() - 60 * 60 * 1000;
|
|
125
|
+
for (const [id, time] of this.seenSignals) {
|
|
126
|
+
if (time < cutoff) this.seenSignals.delete(id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
originalPriority: signal.priority,
|
|
132
|
+
finalPriority: priority,
|
|
133
|
+
rulesApplied,
|
|
134
|
+
shouldAttend,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Add custom rules at runtime
|
|
139
|
+
addRule(rule: SalienceRule): void {
|
|
140
|
+
this.rules.push(rule);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Update threshold
|
|
144
|
+
setThreshold(threshold: number): void {
|
|
145
|
+
this.threshold = threshold;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Get current threshold
|
|
149
|
+
getThreshold(): number {
|
|
150
|
+
return this.threshold;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// List all rules
|
|
154
|
+
getRules(): SalienceRule[] {
|
|
155
|
+
return [...this.rules];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Singleton instance with defaults
|
|
160
|
+
export const defaultFilter = new SalienceFilter();
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Choir Scheduler
|
|
3
|
+
*
|
|
4
|
+
* Executes choirs on schedule, manages illumination flow.
|
|
5
|
+
* Each choir runs at its defined frequency, with context passing between them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OpenClawPluginService, PluginLogger } from "openclaw/plugin-sdk";
|
|
9
|
+
import type { ChorusConfig } from "./config.js";
|
|
10
|
+
import { CHOIRS, shouldRunChoir, CASCADE_ORDER, type Choir } from "./choirs.js";
|
|
11
|
+
import { recordExecution, type ChoirExecution } from "./metrics.js";
|
|
12
|
+
|
|
13
|
+
interface ChoirContext {
|
|
14
|
+
choirId: string;
|
|
15
|
+
output: string;
|
|
16
|
+
timestamp: Date;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ChoirRunState {
|
|
20
|
+
lastRun?: Date;
|
|
21
|
+
lastOutput?: string;
|
|
22
|
+
runCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createChoirScheduler(
|
|
26
|
+
config: ChorusConfig,
|
|
27
|
+
log: PluginLogger,
|
|
28
|
+
api: any // OpenClawPluginApi
|
|
29
|
+
): OpenClawPluginService {
|
|
30
|
+
let checkInterval: NodeJS.Timeout | null = null;
|
|
31
|
+
const contextStore: Map<string, ChoirContext> = new Map();
|
|
32
|
+
const runState: Map<string, ChoirRunState> = new Map();
|
|
33
|
+
|
|
34
|
+
// Initialize run state for all choirs
|
|
35
|
+
for (const choirId of Object.keys(CHOIRS)) {
|
|
36
|
+
runState.set(choirId, { runCount: 0 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Build the prompt with context injected
|
|
40
|
+
function buildPrompt(choir: Choir): string {
|
|
41
|
+
let prompt = choir.prompt;
|
|
42
|
+
|
|
43
|
+
// Replace context placeholders
|
|
44
|
+
for (const upstreamId of choir.receivesFrom) {
|
|
45
|
+
const placeholder = `{${upstreamId}_context}`;
|
|
46
|
+
const ctx = contextStore.get(upstreamId);
|
|
47
|
+
const contextText = ctx ? ctx.output : "(awaiting context from " + upstreamId + ")";
|
|
48
|
+
prompt = prompt.replace(placeholder, contextText);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return prompt;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Execute a choir
|
|
55
|
+
async function executeChoir(choir: Choir): Promise<void> {
|
|
56
|
+
const state = runState.get(choir.id) || { runCount: 0 };
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
|
|
59
|
+
log.info(`[chorus] ${choir.emoji} Executing ${choir.name} (run #${state.runCount + 1})`);
|
|
60
|
+
|
|
61
|
+
const execution: ChoirExecution = {
|
|
62
|
+
choirId: choir.id,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
durationMs: 0,
|
|
65
|
+
success: false,
|
|
66
|
+
outputLength: 0,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const prompt = buildPrompt(choir);
|
|
71
|
+
|
|
72
|
+
// Use OpenClaw's session system to run an agent turn
|
|
73
|
+
const result = await api.runAgentTurn?.({
|
|
74
|
+
sessionLabel: `chorus:${choir.id}`,
|
|
75
|
+
message: prompt,
|
|
76
|
+
isolated: true,
|
|
77
|
+
timeoutSeconds: 300, // 5 min max
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const output = result?.response || "(no response)";
|
|
81
|
+
execution.durationMs = Date.now() - startTime;
|
|
82
|
+
execution.success = true;
|
|
83
|
+
execution.outputLength = output.length;
|
|
84
|
+
execution.tokensUsed = result?.meta?.tokensUsed || estimateTokens(output);
|
|
85
|
+
|
|
86
|
+
// Parse output for metrics (findings, alerts, improvements)
|
|
87
|
+
execution.findings = countFindings(output);
|
|
88
|
+
execution.alerts = countAlerts(output);
|
|
89
|
+
execution.improvements = extractImprovements(output, choir.id);
|
|
90
|
+
|
|
91
|
+
// Store context for downstream choirs
|
|
92
|
+
contextStore.set(choir.id, {
|
|
93
|
+
choirId: choir.id,
|
|
94
|
+
output: output.slice(0, 2000), // Truncate for context passing
|
|
95
|
+
timestamp: new Date(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Update run state
|
|
99
|
+
runState.set(choir.id, {
|
|
100
|
+
lastRun: new Date(),
|
|
101
|
+
lastOutput: output.slice(0, 500),
|
|
102
|
+
runCount: state.runCount + 1,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
log.info(`[chorus] ${choir.emoji} ${choir.name} completed (${(execution.durationMs/1000).toFixed(1)}s)`);
|
|
106
|
+
|
|
107
|
+
// Log illumination flow
|
|
108
|
+
if (choir.passesTo.length > 0) {
|
|
109
|
+
log.debug(`[chorus] Illumination ready for: ${choir.passesTo.join(", ")}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
} catch (error) {
|
|
113
|
+
execution.durationMs = Date.now() - startTime;
|
|
114
|
+
execution.success = false;
|
|
115
|
+
execution.error = String(error);
|
|
116
|
+
log.error(`[chorus] ${choir.name} failed: ${error}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Record metrics
|
|
120
|
+
recordExecution(execution);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Estimate tokens from output length (rough: 1 token ≈ 4 chars)
|
|
124
|
+
function estimateTokens(text: string): number {
|
|
125
|
+
return Math.ceil(text.length / 4);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Count research findings in output
|
|
129
|
+
function countFindings(output: string): number {
|
|
130
|
+
const patterns = [
|
|
131
|
+
/found\s+(\d+)\s+(?:papers?|articles?|findings?)/gi,
|
|
132
|
+
/(\d+)\s+(?:new|notable)\s+(?:papers?|findings?)/gi,
|
|
133
|
+
/key\s+findings?:/gi,
|
|
134
|
+
/\*\*finding/gi,
|
|
135
|
+
];
|
|
136
|
+
let count = 0;
|
|
137
|
+
for (const pattern of patterns) {
|
|
138
|
+
const matches = output.match(pattern);
|
|
139
|
+
if (matches) count += matches.length;
|
|
140
|
+
}
|
|
141
|
+
return count;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Count alerts in output
|
|
145
|
+
function countAlerts(output: string): number {
|
|
146
|
+
const patterns = [
|
|
147
|
+
/\balert\b/gi,
|
|
148
|
+
/\bnotif(?:y|ied|ication)\b/gi,
|
|
149
|
+
/\burgent\b/gi,
|
|
150
|
+
/\bimmediate\s+attention\b/gi,
|
|
151
|
+
];
|
|
152
|
+
let count = 0;
|
|
153
|
+
for (const pattern of patterns) {
|
|
154
|
+
const matches = output.match(pattern);
|
|
155
|
+
if (matches) count += matches.length;
|
|
156
|
+
}
|
|
157
|
+
return Math.min(count, 5); // Cap at 5 to avoid false positives
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Extract improvements from RSI (Virtues) output
|
|
161
|
+
function extractImprovements(output: string, choirId: string): string[] {
|
|
162
|
+
if (choirId !== "virtues") return [];
|
|
163
|
+
const improvements: string[] = [];
|
|
164
|
+
const patterns = [
|
|
165
|
+
/implemented[:\s]+([^\n.]+)/gi,
|
|
166
|
+
/improved[:\s]+([^\n.]+)/gi,
|
|
167
|
+
/created[:\s]+([^\n.]+)/gi,
|
|
168
|
+
/updated[:\s]+([^\n.]+)/gi,
|
|
169
|
+
];
|
|
170
|
+
for (const pattern of patterns) {
|
|
171
|
+
let match;
|
|
172
|
+
while ((match = pattern.exec(output)) !== null) {
|
|
173
|
+
const item = match[1].trim().slice(0, 50);
|
|
174
|
+
if (item.length > 5) improvements.push(item);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return improvements.slice(0, 5); // Cap at 5
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check and run due choirs
|
|
181
|
+
async function checkAndRunChoirs(): Promise<void> {
|
|
182
|
+
const now = new Date();
|
|
183
|
+
|
|
184
|
+
// Check choirs in cascade order (important for illumination flow)
|
|
185
|
+
for (const choirId of CASCADE_ORDER) {
|
|
186
|
+
const choir = CHOIRS[choirId];
|
|
187
|
+
if (!choir) continue;
|
|
188
|
+
|
|
189
|
+
// Check if enabled
|
|
190
|
+
if (config.choirs.overrides[choirId] === false) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Check if due based on interval
|
|
195
|
+
const state = runState.get(choirId);
|
|
196
|
+
if (shouldRunChoir(choir, now, state?.lastRun)) {
|
|
197
|
+
await executeChoir(choir);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
id: "chorus-scheduler",
|
|
204
|
+
|
|
205
|
+
start: () => {
|
|
206
|
+
if (!config.choirs.enabled) {
|
|
207
|
+
log.info("[chorus] Choir scheduler disabled (enable in openclaw.yaml)");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
log.info("[chorus] 🎵 Starting Nine Choirs scheduler");
|
|
212
|
+
log.info("[chorus] Frequencies: Seraphim 1×/day → Angels 48×/day");
|
|
213
|
+
|
|
214
|
+
// Check for due choirs every minute
|
|
215
|
+
checkInterval = setInterval(() => {
|
|
216
|
+
checkAndRunChoirs().catch((err) => {
|
|
217
|
+
log.error(`[chorus] Scheduler error: ${err}`);
|
|
218
|
+
});
|
|
219
|
+
}, 60 * 1000);
|
|
220
|
+
|
|
221
|
+
// Run initial check after a short delay
|
|
222
|
+
setTimeout(() => {
|
|
223
|
+
log.info("[chorus] Running initial choir check...");
|
|
224
|
+
checkAndRunChoirs().catch((err) => {
|
|
225
|
+
log.error(`[chorus] Initial check error: ${err}`);
|
|
226
|
+
});
|
|
227
|
+
}, 5000);
|
|
228
|
+
|
|
229
|
+
log.info("[chorus] 🎵 Scheduler active");
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
stop: () => {
|
|
233
|
+
log.info("[chorus] Stopping choir scheduler");
|
|
234
|
+
if (checkInterval) {
|
|
235
|
+
clearInterval(checkInterval);
|
|
236
|
+
checkInterval = null;
|
|
237
|
+
}
|
|
238
|
+
contextStore.clear();
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Security Integration
|
|
3
|
+
*
|
|
4
|
+
* Security in CHORUS is handled by the Powers choir (8×/day adversarial review).
|
|
5
|
+
* Real-time input validation is handled by core OpenClaw's security layer.
|
|
6
|
+
*
|
|
7
|
+
* Enable in openclaw.yaml:
|
|
8
|
+
* security:
|
|
9
|
+
* inputValidation:
|
|
10
|
+
* enabled: true
|
|
11
|
+
* onThreat: block
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
15
|
+
import type { ChorusConfig } from "./config.js";
|
|
16
|
+
|
|
17
|
+
export function createSecurityHooks(
|
|
18
|
+
api: OpenClawPluginApi,
|
|
19
|
+
_config: ChorusConfig
|
|
20
|
+
) {
|
|
21
|
+
// Security is handled by:
|
|
22
|
+
// 1. Core OpenClaw security.inputValidation (real-time)
|
|
23
|
+
// 2. Powers choir (8×/day adversarial review)
|
|
24
|
+
// No additional hooks needed.
|
|
25
|
+
api.logger.debug("[chorus] Security delegated to Powers choir + core OpenClaw");
|
|
26
|
+
}
|
package/src/senses.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHORUS Senses System
|
|
3
|
+
*
|
|
4
|
+
* Input streams that feed the daemon's attention.
|
|
5
|
+
* Each sense can poll periodically or watch for events.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { watch, existsSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
9
|
+
import { readFile, mkdir } from "fs/promises";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
|
|
13
|
+
export interface Signal {
|
|
14
|
+
id: string;
|
|
15
|
+
source: string;
|
|
16
|
+
content: string;
|
|
17
|
+
priority: number;
|
|
18
|
+
timestamp: Date;
|
|
19
|
+
metadata?: Record<string, any>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Sense {
|
|
23
|
+
id: string;
|
|
24
|
+
description: string;
|
|
25
|
+
poll?(): Promise<Signal[]>;
|
|
26
|
+
watch?(callback: (signal: Signal) => void): () => void; // Returns cleanup function
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CHORUS_DIR = join(homedir(), ".chorus");
|
|
30
|
+
const INBOX_DIR = join(CHORUS_DIR, "inbox");
|
|
31
|
+
const PURPOSES_FILE = join(CHORUS_DIR, "purposes.json");
|
|
32
|
+
|
|
33
|
+
// Ensure directories exist
|
|
34
|
+
async function ensureDirs() {
|
|
35
|
+
await mkdir(INBOX_DIR, { recursive: true }).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* File Inbox Sense
|
|
40
|
+
* Drop files into ~/.chorus/inbox/ to trigger attention
|
|
41
|
+
*/
|
|
42
|
+
export const inboxSense: Sense = {
|
|
43
|
+
id: "inbox",
|
|
44
|
+
description: "Watches ~/.chorus/inbox/ for new files",
|
|
45
|
+
|
|
46
|
+
watch(callback) {
|
|
47
|
+
ensureDirs();
|
|
48
|
+
|
|
49
|
+
// Process existing files on startup
|
|
50
|
+
if (existsSync(INBOX_DIR)) {
|
|
51
|
+
for (const file of readdirSync(INBOX_DIR)) {
|
|
52
|
+
const path = join(INBOX_DIR, file);
|
|
53
|
+
const stat = statSync(path);
|
|
54
|
+
if (stat.isFile()) {
|
|
55
|
+
processInboxFile(path, file, callback);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Watch for new files
|
|
61
|
+
const watcher = watch(INBOX_DIR, async (event, filename) => {
|
|
62
|
+
if (event === "rename" && filename) {
|
|
63
|
+
const path = join(INBOX_DIR, filename);
|
|
64
|
+
if (existsSync(path)) {
|
|
65
|
+
processInboxFile(path, filename, callback);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return () => watcher.close();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
async function processInboxFile(
|
|
75
|
+
path: string,
|
|
76
|
+
filename: string,
|
|
77
|
+
callback: (signal: Signal) => void
|
|
78
|
+
) {
|
|
79
|
+
try {
|
|
80
|
+
const content = await readFile(path, "utf-8");
|
|
81
|
+
|
|
82
|
+
callback({
|
|
83
|
+
id: `inbox:${filename}:${Date.now()}`,
|
|
84
|
+
source: "inbox",
|
|
85
|
+
content: content.trim() || `New file: ${filename}`,
|
|
86
|
+
priority: 50, // Base priority, salience filter will adjust
|
|
87
|
+
timestamp: new Date(),
|
|
88
|
+
metadata: { filename, path },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Remove file after processing (it's been ingested)
|
|
92
|
+
unlinkSync(path);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// File might have been removed already
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Purposes Sense
|
|
100
|
+
* Monitors ~/.chorus/purposes.json for approaching deadlines
|
|
101
|
+
*/
|
|
102
|
+
export const purposesSense: Sense = {
|
|
103
|
+
id: "purposes",
|
|
104
|
+
description: "Monitors purposes and deadlines",
|
|
105
|
+
|
|
106
|
+
async poll() {
|
|
107
|
+
const signals: Signal[] = [];
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (!existsSync(PURPOSES_FILE)) return signals;
|
|
111
|
+
|
|
112
|
+
const data = await readFile(PURPOSES_FILE, "utf-8");
|
|
113
|
+
const purposes = JSON.parse(data);
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
|
|
116
|
+
for (const purpose of purposes) {
|
|
117
|
+
// Skip completed purposes
|
|
118
|
+
if (purpose.progress >= 100) continue;
|
|
119
|
+
|
|
120
|
+
// Deadline pressure
|
|
121
|
+
if (purpose.deadline) {
|
|
122
|
+
const deadline = typeof purpose.deadline === "string"
|
|
123
|
+
? Date.parse(purpose.deadline)
|
|
124
|
+
: purpose.deadline;
|
|
125
|
+
const msLeft = deadline - now;
|
|
126
|
+
const daysLeft = msLeft / (1000 * 60 * 60 * 24);
|
|
127
|
+
|
|
128
|
+
if (daysLeft <= 0) {
|
|
129
|
+
// Overdue!
|
|
130
|
+
signals.push({
|
|
131
|
+
id: `purpose:${purpose.id}:overdue`,
|
|
132
|
+
source: "purpose",
|
|
133
|
+
content: `OVERDUE: "${purpose.name}" was due ${Math.abs(daysLeft).toFixed(0)} days ago! Progress: ${purpose.progress}%`,
|
|
134
|
+
priority: 95,
|
|
135
|
+
timestamp: new Date(),
|
|
136
|
+
metadata: { purposeId: purpose.id, daysLeft, overdue: true },
|
|
137
|
+
});
|
|
138
|
+
} else if (daysLeft <= 1) {
|
|
139
|
+
signals.push({
|
|
140
|
+
id: `purpose:${purpose.id}:urgent`,
|
|
141
|
+
source: "purpose",
|
|
142
|
+
content: `URGENT: "${purpose.name}" due in ${(daysLeft * 24).toFixed(0)} hours. Progress: ${purpose.progress}%`,
|
|
143
|
+
priority: 85,
|
|
144
|
+
timestamp: new Date(),
|
|
145
|
+
metadata: { purposeId: purpose.id, daysLeft },
|
|
146
|
+
});
|
|
147
|
+
} else if (daysLeft <= 3) {
|
|
148
|
+
signals.push({
|
|
149
|
+
id: `purpose:${purpose.id}:soon`,
|
|
150
|
+
source: "purpose",
|
|
151
|
+
content: `"${purpose.name}" due in ${daysLeft.toFixed(0)} days. Progress: ${purpose.progress}%`,
|
|
152
|
+
priority: 70,
|
|
153
|
+
timestamp: new Date(),
|
|
154
|
+
metadata: { purposeId: purpose.id, daysLeft },
|
|
155
|
+
});
|
|
156
|
+
} else if (daysLeft <= 7) {
|
|
157
|
+
signals.push({
|
|
158
|
+
id: `purpose:${purpose.id}:upcoming`,
|
|
159
|
+
source: "purpose",
|
|
160
|
+
content: `"${purpose.name}" due in ${daysLeft.toFixed(0)} days. Progress: ${purpose.progress}%`,
|
|
161
|
+
priority: 50,
|
|
162
|
+
timestamp: new Date(),
|
|
163
|
+
metadata: { purposeId: purpose.id, daysLeft },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Stalled progress (no deadline but hasn't been worked on)
|
|
169
|
+
if (purpose.lastWorkedOn) {
|
|
170
|
+
const lastWorked = typeof purpose.lastWorkedOn === "string"
|
|
171
|
+
? Date.parse(purpose.lastWorkedOn)
|
|
172
|
+
: purpose.lastWorkedOn;
|
|
173
|
+
const daysSince = (now - lastWorked) / (1000 * 60 * 60 * 24);
|
|
174
|
+
|
|
175
|
+
if (daysSince > 3 && purpose.progress < 100 && purpose.progress > 0) {
|
|
176
|
+
signals.push({
|
|
177
|
+
id: `purpose:${purpose.id}:stalled`,
|
|
178
|
+
source: "purpose",
|
|
179
|
+
content: `"${purpose.name}" stalled — no progress in ${daysSince.toFixed(0)} days (${purpose.progress}% complete)`,
|
|
180
|
+
priority: 40,
|
|
181
|
+
timestamp: new Date(),
|
|
182
|
+
metadata: { purposeId: purpose.id, daysSince },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Curiosity-driven (optional field)
|
|
188
|
+
if (purpose.curiosity && purpose.curiosity > 60 && !purpose.deadline) {
|
|
189
|
+
signals.push({
|
|
190
|
+
id: `purpose:${purpose.id}:curiosity`,
|
|
191
|
+
source: "curiosity",
|
|
192
|
+
content: `Curious about: "${purpose.name}"`,
|
|
193
|
+
priority: Math.min(40, purpose.curiosity * 0.5),
|
|
194
|
+
timestamp: new Date(),
|
|
195
|
+
metadata: { purposeId: purpose.id, curiosity: purpose.curiosity },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// No purposes file or parse error — that's fine
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return signals;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Time Sense
|
|
209
|
+
* Generates signals based on time of day
|
|
210
|
+
*/
|
|
211
|
+
export const timeSense: Sense = {
|
|
212
|
+
id: "time",
|
|
213
|
+
description: "Time-based signals (morning, evening, etc.)",
|
|
214
|
+
|
|
215
|
+
async poll() {
|
|
216
|
+
const signals: Signal[] = [];
|
|
217
|
+
const now = new Date();
|
|
218
|
+
const hour = now.getHours();
|
|
219
|
+
const minute = now.getMinutes();
|
|
220
|
+
|
|
221
|
+
// Morning window (6-7 AM, first poll only)
|
|
222
|
+
if (hour === 6 && minute < 30) {
|
|
223
|
+
signals.push({
|
|
224
|
+
id: `time:morning:${now.toDateString()}`,
|
|
225
|
+
source: "time",
|
|
226
|
+
content: "Good morning. Time for morning briefing.",
|
|
227
|
+
priority: 60,
|
|
228
|
+
timestamp: now,
|
|
229
|
+
metadata: { trigger: "morning" },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Evening window (9-10 PM)
|
|
234
|
+
if (hour === 21 && minute < 30) {
|
|
235
|
+
signals.push({
|
|
236
|
+
id: `time:evening:${now.toDateString()}`,
|
|
237
|
+
source: "time",
|
|
238
|
+
content: "Evening. Time for daily wrap-up and reflection.",
|
|
239
|
+
priority: 55,
|
|
240
|
+
timestamp: now,
|
|
241
|
+
metadata: { trigger: "evening" },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return signals;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Export all senses
|
|
250
|
+
export const ALL_SENSES: Sense[] = [inboxSense, purposesSense, timeSense];
|
|
251
|
+
|
|
252
|
+
// Utility to get purposes file path
|
|
253
|
+
export function getPurposesPath(): string {
|
|
254
|
+
return PURPOSES_FILE;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function getInboxPath(): string {
|
|
258
|
+
return INBOX_DIR;
|
|
259
|
+
}
|