@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,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();
@@ -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
+ }
@@ -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
+ }