clementine-agent 1.0.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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- package/vault/06-Templates/_People-Template.md +22 -0
|
@@ -0,0 +1,1706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Self-Improvement Loop Engine.
|
|
3
|
+
*
|
|
4
|
+
* Implements Karpathy's autoresearch iterative loop for autonomous self-improvement:
|
|
5
|
+
* hypothesize → execute → evaluate → keep/revert → repeat.
|
|
6
|
+
*
|
|
7
|
+
* Evaluates Clementine's own outputs (transcripts, feedback, cron logs) and proposes
|
|
8
|
+
* improvements to system prompts, cron job prompts, workflows, and memory settings.
|
|
9
|
+
* All proposed changes require Discord approval before being applied.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
12
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
13
|
+
import matter from 'gray-matter';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import pino from 'pino';
|
|
16
|
+
import { BASE_DIR, SELF_IMPROVE_DIR, SOUL_FILE, AGENTS_FILE, CRON_FILE, WORKFLOWS_DIR, VAULT_DIR, MEMORY_DB_PATH, AGENTS_DIR, PKG_DIR, CRON_REFLECTIONS_DIR, GOALS_DIR, } from '../config.js';
|
|
17
|
+
const logger = pino({ name: 'clementine.self-improve' });
|
|
18
|
+
// ── Defaults ─────────────────────────────────────────────────────────
|
|
19
|
+
const DEFAULT_CONFIG = {
|
|
20
|
+
maxIterations: 6,
|
|
21
|
+
iterationBudgetMs: 300_000, // 5 min
|
|
22
|
+
maxDurationMs: 3_600_000, // 1 hour
|
|
23
|
+
acceptThreshold: 0.7,
|
|
24
|
+
plateauLimit: 3,
|
|
25
|
+
areas: ['soul', 'cron', 'workflow', 'memory', 'agent', 'source', 'communication', 'goal'],
|
|
26
|
+
autoApply: true,
|
|
27
|
+
sourceMode: 'propose-only',
|
|
28
|
+
};
|
|
29
|
+
// ── Paths ────────────────────────────────────────────────────────────
|
|
30
|
+
const EXPERIMENT_LOG = path.join(SELF_IMPROVE_DIR, 'experiment-log.jsonl');
|
|
31
|
+
const STATE_FILE = path.join(SELF_IMPROVE_DIR, 'state.json');
|
|
32
|
+
const PENDING_DIR = path.join(SELF_IMPROVE_DIR, 'pending-changes');
|
|
33
|
+
const APPROVAL_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
34
|
+
const IMPACT_CHECKS_FILE = path.join(SELF_IMPROVE_DIR, 'impact-checks.jsonl');
|
|
35
|
+
const EVOLUTION_VERSIONS_FILE = path.join(SELF_IMPROVE_DIR, 'evolution-versions.json');
|
|
36
|
+
const SOUL_BASELINE_FILE = path.join(SELF_IMPROVE_DIR, 'soul-baseline.md');
|
|
37
|
+
/** Minimum Jaccard similarity between a proposed SOUL.md and the baseline.
|
|
38
|
+
* Below this threshold, the change is rejected as identity drift. */
|
|
39
|
+
const DRIFT_SIMILARITY_THRESHOLD = 0.55;
|
|
40
|
+
/** If post-change metrics drop by more than this ratio, auto-rollback triggers. */
|
|
41
|
+
const REGRESSION_ROLLBACK_THRESHOLD = 0.10;
|
|
42
|
+
/** Max consecutive infrastructure errors before aborting the loop. */
|
|
43
|
+
const MAX_INFRA_ERRORS = 2;
|
|
44
|
+
/** Classify a self-improve error to determine if it's infrastructure (don't retry)
|
|
45
|
+
* or hypothesis-related (safe to try a different approach). */
|
|
46
|
+
function classifyError(err) {
|
|
47
|
+
const msg = String(err);
|
|
48
|
+
// Tool schema validation errors from the API
|
|
49
|
+
if (msg.includes('input_schema') || msg.includes('Input should be')) {
|
|
50
|
+
return {
|
|
51
|
+
category: 'infra_schema',
|
|
52
|
+
message: msg.slice(0, 300),
|
|
53
|
+
diagnostic: 'An MCP server is exposing a tool with a malformed input_schema (type must be "object"). ' +
|
|
54
|
+
'This is an infrastructure issue — no iteration can succeed until the broken MCP server is fixed or excluded. ' +
|
|
55
|
+
'Check external MCP servers in claude_desktop_config.json and Claude Code settings for recently updated packages.',
|
|
56
|
+
retryFutile: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Auth / API key errors
|
|
60
|
+
if (msg.includes('401') || msg.includes('403') || msg.includes('authentication') || msg.includes('Unauthorized')) {
|
|
61
|
+
return {
|
|
62
|
+
category: 'infra_auth',
|
|
63
|
+
message: msg.slice(0, 300),
|
|
64
|
+
diagnostic: 'API authentication failed. Check that the Anthropic API key is valid and not expired.',
|
|
65
|
+
retryFutile: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Rate limits
|
|
69
|
+
if (msg.includes('429') || msg.includes('rate_limit') || msg.includes('Too many requests')) {
|
|
70
|
+
return {
|
|
71
|
+
category: 'infra_rate_limit',
|
|
72
|
+
message: msg.slice(0, 300),
|
|
73
|
+
diagnostic: 'Hit API rate limit. Subsequent iterations will likely fail too. Wait and retry next cycle.',
|
|
74
|
+
retryFutile: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Timeouts
|
|
78
|
+
if (msg.includes('timeout') || msg.includes('ETIMEDOUT') || msg.includes('AbortError')) {
|
|
79
|
+
return {
|
|
80
|
+
category: 'infra_timeout',
|
|
81
|
+
message: msg.slice(0, 300),
|
|
82
|
+
diagnostic: 'LLM call timed out. This may be transient — worth retrying once.',
|
|
83
|
+
retryFutile: false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Everything else — likely a hypothesis or parsing error, safe to retry with a different approach
|
|
87
|
+
return {
|
|
88
|
+
category: 'unknown',
|
|
89
|
+
message: msg.slice(0, 300),
|
|
90
|
+
diagnostic: 'Unexpected error during iteration. May be transient.',
|
|
91
|
+
retryFutile: false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// ── Drift detection ─────────────────────────────────────────────────
|
|
95
|
+
/** Tokenize text into a word set for Jaccard similarity. */
|
|
96
|
+
function tokenizeForDrift(text) {
|
|
97
|
+
return new Set(text.toLowerCase()
|
|
98
|
+
.replace(/[^\w\s]/g, ' ')
|
|
99
|
+
.split(/\s+/)
|
|
100
|
+
.filter(w => w.length > 2));
|
|
101
|
+
}
|
|
102
|
+
/** Jaccard similarity between two token sets. */
|
|
103
|
+
function jaccardSimilarity(a, b) {
|
|
104
|
+
if (a.size === 0 && b.size === 0)
|
|
105
|
+
return 1;
|
|
106
|
+
let intersection = 0;
|
|
107
|
+
for (const token of a) {
|
|
108
|
+
if (b.has(token))
|
|
109
|
+
intersection++;
|
|
110
|
+
}
|
|
111
|
+
const union = a.size + b.size - intersection;
|
|
112
|
+
return union === 0 ? 0 : intersection / union;
|
|
113
|
+
}
|
|
114
|
+
/** Check if a proposed change drifts too far from the baseline identity.
|
|
115
|
+
* Only applies to 'soul' area changes. Returns { ok, similarity }. */
|
|
116
|
+
function checkDrift(proposedContent) {
|
|
117
|
+
if (!existsSync(SOUL_BASELINE_FILE)) {
|
|
118
|
+
// First run: snapshot current SOUL.md as baseline
|
|
119
|
+
if (existsSync(SOUL_FILE)) {
|
|
120
|
+
mkdirSync(path.dirname(SOUL_BASELINE_FILE), { recursive: true });
|
|
121
|
+
writeFileSync(SOUL_BASELINE_FILE, readFileSync(SOUL_FILE, 'utf-8'));
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, similarity: 1 };
|
|
124
|
+
}
|
|
125
|
+
const baseline = readFileSync(SOUL_BASELINE_FILE, 'utf-8');
|
|
126
|
+
const baseTokens = tokenizeForDrift(baseline);
|
|
127
|
+
const proposedTokens = tokenizeForDrift(proposedContent);
|
|
128
|
+
const similarity = jaccardSimilarity(baseTokens, proposedTokens);
|
|
129
|
+
return { ok: similarity >= DRIFT_SIMILARITY_THRESHOLD, similarity };
|
|
130
|
+
}
|
|
131
|
+
/** Classify the risk level of a proposed change.
|
|
132
|
+
* - low: agent prompts, individual cron job prompts — auto-apply safe
|
|
133
|
+
* - medium: SOUL.md, AGENTS.md, MEMORY.md — needs owner approval
|
|
134
|
+
* - high: source code — stays blocked
|
|
135
|
+
*/
|
|
136
|
+
function classifyRisk(area) {
|
|
137
|
+
switch (area) {
|
|
138
|
+
case 'agent': return 'low'; // Agent-scoped, easily reversible
|
|
139
|
+
case 'cron': return 'low'; // Cron prompt tweaks, low blast radius
|
|
140
|
+
case 'workflow': return 'low'; // Workflow definitions, scoped
|
|
141
|
+
case 'soul': return 'medium'; // Core personality — needs approval
|
|
142
|
+
case 'communication': return 'medium'; // Global operating instructions
|
|
143
|
+
case 'memory': return 'medium'; // Memory config
|
|
144
|
+
case 'source': return 'high'; // Code changes — always blocked in auto mode
|
|
145
|
+
case 'goal': return 'medium'; // New goals need owner review before activating
|
|
146
|
+
default: return 'high';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ── SelfImproveLoop ──────────────────────────────────────────────────
|
|
150
|
+
export class SelfImproveLoop {
|
|
151
|
+
config;
|
|
152
|
+
assistant;
|
|
153
|
+
constructor(assistant, config) {
|
|
154
|
+
this.assistant = assistant;
|
|
155
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
156
|
+
ensureDirs();
|
|
157
|
+
}
|
|
158
|
+
// ── Main entry point ──────────────────────────────────────────────
|
|
159
|
+
async run(onProposal) {
|
|
160
|
+
this.reconcileState();
|
|
161
|
+
this.expireStaleProposals();
|
|
162
|
+
await this.checkAppliedImpact();
|
|
163
|
+
// Capture SOUL.md baseline on first run (for drift detection)
|
|
164
|
+
if (!existsSync(SOUL_BASELINE_FILE) && existsSync(SOUL_FILE)) {
|
|
165
|
+
mkdirSync(path.dirname(SOUL_BASELINE_FILE), { recursive: true });
|
|
166
|
+
writeFileSync(SOUL_BASELINE_FILE, readFileSync(SOUL_FILE, 'utf-8'));
|
|
167
|
+
logger.info('Captured SOUL.md baseline for drift detection');
|
|
168
|
+
}
|
|
169
|
+
const state = this.loadState();
|
|
170
|
+
state.status = 'running';
|
|
171
|
+
state.lastRunAt = new Date().toISOString();
|
|
172
|
+
state.currentIteration = 0;
|
|
173
|
+
this.saveState(state);
|
|
174
|
+
const loopStart = Date.now();
|
|
175
|
+
const history = this.loadExperimentLog();
|
|
176
|
+
let consecutiveLow = 0;
|
|
177
|
+
try {
|
|
178
|
+
// Step 1: Gather baseline metrics
|
|
179
|
+
const metrics = await this.gatherMetrics();
|
|
180
|
+
state.baselineMetrics = {
|
|
181
|
+
feedbackPositiveRatio: metrics.feedbackStats.total > 0
|
|
182
|
+
? metrics.feedbackStats.positive / metrics.feedbackStats.total
|
|
183
|
+
: 1,
|
|
184
|
+
cronSuccessRate: metrics.cronSuccessRate,
|
|
185
|
+
avgResponseQuality: 0, // Updated as we evaluate
|
|
186
|
+
};
|
|
187
|
+
// Synthesize feedback patterns and update user model before experiment loop
|
|
188
|
+
await this.synthesizeFeedbackPatterns();
|
|
189
|
+
await this.updateUserModel();
|
|
190
|
+
for (let i = 1; i <= this.config.maxIterations; i++) {
|
|
191
|
+
// Check time budget
|
|
192
|
+
if (Date.now() - loopStart > this.config.maxDurationMs) {
|
|
193
|
+
logger.info('Self-improve loop hit time limit — stopping');
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
// Check plateau
|
|
197
|
+
if (consecutiveLow >= this.config.plateauLimit) {
|
|
198
|
+
logger.info({ consecutiveLow }, 'Plateau detected — stopping');
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
state.currentIteration = i;
|
|
202
|
+
this.saveState(state);
|
|
203
|
+
const iterStart = Date.now();
|
|
204
|
+
const id = randomBytes(4).toString('hex');
|
|
205
|
+
try {
|
|
206
|
+
// Step 2-3: Diagnose + hypothesize
|
|
207
|
+
const proposal = await this.withTimeout(this.hypothesize(metrics, history), this.config.iterationBudgetMs);
|
|
208
|
+
if (!proposal) {
|
|
209
|
+
logger.info({ iteration: i }, 'No hypothesis generated — skipping');
|
|
210
|
+
consecutiveLow++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Diversity safety net: skip if hypothesis targets an over-represented area:target
|
|
214
|
+
const proposalKey = `${proposal.area}:${proposal.target}`;
|
|
215
|
+
const proposalCount = history.filter(e => `${e.area}:${e.target}` === proposalKey).length
|
|
216
|
+
+ this.getPendingChanges().filter(p => `${p.area}:${p.target}` === proposalKey).length;
|
|
217
|
+
if (proposalCount >= 3) {
|
|
218
|
+
logger.warn({ area: proposal.area, target: proposal.target, count: proposalCount }, 'Hypothesis over-targeted — skipping');
|
|
219
|
+
consecutiveLow++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const validation = this.validateProposal(proposal.area, proposal.target, proposal.proposedChange);
|
|
223
|
+
if (!validation.valid) {
|
|
224
|
+
logger.warn({ area: proposal.area, target: proposal.target, error: validation.error }, 'Proposed change failed validation — skipping');
|
|
225
|
+
consecutiveLow++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Drift detection: reject SOUL.md changes that stray too far from baseline identity
|
|
229
|
+
if (proposal.area === 'soul') {
|
|
230
|
+
const drift = checkDrift(proposal.proposedChange);
|
|
231
|
+
if (!drift.ok) {
|
|
232
|
+
logger.warn({ similarity: drift.similarity.toFixed(3), threshold: DRIFT_SIMILARITY_THRESHOLD }, 'Soul drift detected — proposed change deviates too far from baseline identity');
|
|
233
|
+
consecutiveLow++;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
logger.debug({ similarity: drift.similarity.toFixed(3) }, 'Soul drift check passed');
|
|
237
|
+
}
|
|
238
|
+
// Step 4: Read current state
|
|
239
|
+
const before = await this.readCurrentState(proposal.area, proposal.target);
|
|
240
|
+
// Step 5: Evaluate
|
|
241
|
+
const evaluation = await this.withTimeout(this.evaluate(before, proposal.proposedChange, proposal.hypothesis), 60_000);
|
|
242
|
+
const score = evaluation?.score ?? 0;
|
|
243
|
+
const normalizedScore = score / 10; // Convert 0-10 to 0-1
|
|
244
|
+
const accepted = normalizedScore >= this.config.acceptThreshold;
|
|
245
|
+
const priorScores = history
|
|
246
|
+
.filter(e => e.area === proposal.area && e.target === proposal.target && e.score > 0)
|
|
247
|
+
.map(e => e.score);
|
|
248
|
+
const baselineScore = priorScores.length > 0
|
|
249
|
+
? priorScores.reduce((a, b) => a + b, 0) / priorScores.length
|
|
250
|
+
: 0.5;
|
|
251
|
+
const experiment = {
|
|
252
|
+
id,
|
|
253
|
+
iteration: i,
|
|
254
|
+
startedAt: new Date(iterStart).toISOString(),
|
|
255
|
+
finishedAt: new Date().toISOString(),
|
|
256
|
+
durationMs: Date.now() - iterStart,
|
|
257
|
+
area: proposal.area,
|
|
258
|
+
target: proposal.target,
|
|
259
|
+
hypothesis: proposal.hypothesis,
|
|
260
|
+
proposedChange: proposal.proposedChange,
|
|
261
|
+
baselineScore,
|
|
262
|
+
score: normalizedScore,
|
|
263
|
+
accepted,
|
|
264
|
+
approvalStatus: accepted ? 'pending' : 'denied',
|
|
265
|
+
reason: accepted
|
|
266
|
+
? `Score ${score}/10 exceeds threshold — pending approval`
|
|
267
|
+
: `Score ${score}/10 below threshold (${this.config.acceptThreshold * 10}/10)`,
|
|
268
|
+
};
|
|
269
|
+
// Step 7: Log
|
|
270
|
+
this.appendExperimentLog(experiment);
|
|
271
|
+
history.push(experiment);
|
|
272
|
+
state.totalExperiments++;
|
|
273
|
+
// Step 6: Gate — save pending change + notify (tiered by risk)
|
|
274
|
+
if (accepted) {
|
|
275
|
+
const risk = classifyRisk(proposal.area);
|
|
276
|
+
if (this.config.autoApply && risk === 'low') {
|
|
277
|
+
// Low-risk + auto-apply enabled: apply immediately without approval
|
|
278
|
+
const targetPath = this.resolveTargetPath(proposal.area, proposal.target);
|
|
279
|
+
if (targetPath) {
|
|
280
|
+
// Validate before auto-applying
|
|
281
|
+
const autoValidation = this.validateProposal(proposal.area, proposal.target, proposal.proposedChange);
|
|
282
|
+
if (autoValidation.valid) {
|
|
283
|
+
writeFileSync(targetPath, proposal.proposedChange);
|
|
284
|
+
experiment.approvalStatus = 'approved';
|
|
285
|
+
this.updateExperimentStatus(id, 'approved');
|
|
286
|
+
// Record version for rollback lineage
|
|
287
|
+
this.recordVersion(id, proposal.area, proposal.target, proposal.hypothesis, before);
|
|
288
|
+
// Schedule impact check
|
|
289
|
+
try {
|
|
290
|
+
appendFileSync(IMPACT_CHECKS_FILE, JSON.stringify({
|
|
291
|
+
experimentId: id,
|
|
292
|
+
area: proposal.area,
|
|
293
|
+
target: proposal.target,
|
|
294
|
+
appliedAt: new Date().toISOString(),
|
|
295
|
+
checkAfterMs: 24 * 60 * 60 * 1000,
|
|
296
|
+
}) + '\n');
|
|
297
|
+
}
|
|
298
|
+
catch { /* non-fatal */ }
|
|
299
|
+
logger.info({ id, area: proposal.area, target: proposal.target, risk }, 'Auto-applied low-risk change');
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
logger.warn({ id, error: autoValidation.error }, 'Auto-apply blocked by validation');
|
|
303
|
+
await this.savePendingChange(experiment, before);
|
|
304
|
+
state.pendingApprovals++;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
await this.savePendingChange(experiment, before);
|
|
309
|
+
state.pendingApprovals++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else if (this.config.autoApply && risk === 'high') {
|
|
313
|
+
// High-risk: behavior depends on sourceMode config
|
|
314
|
+
if (this.config.sourceMode === 'skip') {
|
|
315
|
+
logger.info({ id, area: proposal.area, risk }, 'Skipped high-risk proposal in auto mode');
|
|
316
|
+
experiment.approvalStatus = 'denied';
|
|
317
|
+
experiment.reason = 'High-risk area blocked in autonomous mode (sourceMode=skip)';
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
// propose-only: save for human review, never auto-apply
|
|
321
|
+
await this.savePendingChange(experiment, before);
|
|
322
|
+
state.pendingApprovals++;
|
|
323
|
+
if (onProposal) {
|
|
324
|
+
await onProposal(experiment);
|
|
325
|
+
}
|
|
326
|
+
logger.info({ id, area: proposal.area, risk }, 'Saved high-risk proposal for human review');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
// Medium-risk or manual mode: save as pending for approval
|
|
331
|
+
await this.savePendingChange(experiment, before);
|
|
332
|
+
state.pendingApprovals++;
|
|
333
|
+
if (onProposal) {
|
|
334
|
+
await onProposal(experiment);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
consecutiveLow = 0;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
consecutiveLow++;
|
|
341
|
+
}
|
|
342
|
+
logger.info({
|
|
343
|
+
iteration: i,
|
|
344
|
+
id,
|
|
345
|
+
area: proposal.area,
|
|
346
|
+
score,
|
|
347
|
+
accepted,
|
|
348
|
+
}, `Iteration ${i} complete`);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
const classified = classifyError(err);
|
|
352
|
+
const experiment = {
|
|
353
|
+
id,
|
|
354
|
+
iteration: i,
|
|
355
|
+
startedAt: new Date(iterStart).toISOString(),
|
|
356
|
+
finishedAt: new Date().toISOString(),
|
|
357
|
+
durationMs: Date.now() - iterStart,
|
|
358
|
+
area: this.config.areas[0],
|
|
359
|
+
target: 'unknown',
|
|
360
|
+
hypothesis: `[${classified.category}] ${classified.diagnostic.slice(0, 120)}`,
|
|
361
|
+
proposedChange: '',
|
|
362
|
+
baselineScore: 0,
|
|
363
|
+
score: 0,
|
|
364
|
+
accepted: false,
|
|
365
|
+
approvalStatus: 'denied',
|
|
366
|
+
reason: `Error: ${classified.category}`,
|
|
367
|
+
error: classified.message,
|
|
368
|
+
};
|
|
369
|
+
this.appendExperimentLog(experiment);
|
|
370
|
+
history.push(experiment);
|
|
371
|
+
state.totalExperiments++;
|
|
372
|
+
consecutiveLow++;
|
|
373
|
+
logger.error({
|
|
374
|
+
err,
|
|
375
|
+
iteration: i,
|
|
376
|
+
errorCategory: classified.category,
|
|
377
|
+
diagnostic: classified.diagnostic,
|
|
378
|
+
retryFutile: classified.retryFutile,
|
|
379
|
+
}, `Iteration ${i} failed: ${classified.category}`);
|
|
380
|
+
// If this is an infrastructure error that can't be fixed by retrying,
|
|
381
|
+
// check how many consecutive infra errors we've hit. If >= MAX_INFRA_ERRORS,
|
|
382
|
+
// abort the loop — every remaining iteration will fail the same way.
|
|
383
|
+
if (classified.retryFutile) {
|
|
384
|
+
const recentInfraErrors = history.slice(-MAX_INFRA_ERRORS)
|
|
385
|
+
.filter(e => e.reason?.startsWith('Error: infra_'));
|
|
386
|
+
if (recentInfraErrors.length >= MAX_INFRA_ERRORS) {
|
|
387
|
+
logger.warn({
|
|
388
|
+
category: classified.category,
|
|
389
|
+
diagnostic: classified.diagnostic,
|
|
390
|
+
consecutiveInfraErrors: recentInfraErrors.length,
|
|
391
|
+
}, 'Aborting self-improve loop — infrastructure error is persistent and cannot be fixed by retrying');
|
|
392
|
+
state.infraError = {
|
|
393
|
+
category: classified.category,
|
|
394
|
+
diagnostic: classified.diagnostic,
|
|
395
|
+
};
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
this.saveState(state);
|
|
401
|
+
}
|
|
402
|
+
// Update avgResponseQuality from this run's scores
|
|
403
|
+
const runScores = history.filter(e => e.iteration >= 1 && e.score > 0).map(e => e.score);
|
|
404
|
+
if (runScores.length > 0) {
|
|
405
|
+
state.baselineMetrics.avgResponseQuality = runScores.reduce((a, b) => a + b, 0) / runScores.length;
|
|
406
|
+
}
|
|
407
|
+
state.status = 'completed';
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
state.status = 'failed';
|
|
411
|
+
logger.error({ err }, 'Self-improve loop failed');
|
|
412
|
+
}
|
|
413
|
+
this.saveState(state);
|
|
414
|
+
// Memory cleanup at end of nightly run
|
|
415
|
+
await this.runMemoryCleanup();
|
|
416
|
+
return state;
|
|
417
|
+
}
|
|
418
|
+
// ── Per-agent focused cycle ────────────────────────────────────────
|
|
419
|
+
/** Run a focused self-improvement cycle for a specific agent. */
|
|
420
|
+
async runForAgent(agentSlug, onProposal) {
|
|
421
|
+
// Override config for agent-focused run
|
|
422
|
+
this.config = {
|
|
423
|
+
...this.config,
|
|
424
|
+
maxIterations: 5, // Fewer iterations for focused run
|
|
425
|
+
maxDurationMs: 600_000, // 10 min max
|
|
426
|
+
areas: ['agent', 'cron'], // Only agent-scoped areas
|
|
427
|
+
autoApply: true, // Auto-apply for agent changes
|
|
428
|
+
agentSlug,
|
|
429
|
+
};
|
|
430
|
+
return this.run(onProposal);
|
|
431
|
+
}
|
|
432
|
+
// ── Step 1: Gather performance data ──────────────────────────────
|
|
433
|
+
async gatherMetrics() {
|
|
434
|
+
const { MemoryStore } = await import('../memory/store.js');
|
|
435
|
+
const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
436
|
+
store.initialize();
|
|
437
|
+
const feedbackStats = store.getFeedbackStats();
|
|
438
|
+
const negativeFeedback = store.getRecentFeedback(20)
|
|
439
|
+
.filter(f => f.rating === 'negative');
|
|
440
|
+
store.close();
|
|
441
|
+
// Gather cron errors from run logs
|
|
442
|
+
const { CronRunLog } = await import('../gateway/heartbeat.js');
|
|
443
|
+
const runLog = new CronRunLog();
|
|
444
|
+
const cronErrors = [];
|
|
445
|
+
let cronTotal = 0;
|
|
446
|
+
let cronOk = 0;
|
|
447
|
+
const runsDir = path.join(BASE_DIR, 'cron', 'runs');
|
|
448
|
+
if (existsSync(runsDir)) {
|
|
449
|
+
const files = readdirSync(runsDir).filter(f => f.endsWith('.jsonl'));
|
|
450
|
+
for (const file of files) {
|
|
451
|
+
// Filename is the sanitized job name — pass as-is to readRecent
|
|
452
|
+
// (readRecent applies the same sanitization internally)
|
|
453
|
+
const sanitizedName = file.replace('.jsonl', '');
|
|
454
|
+
const entries = runLog.readRecent(sanitizedName, 20);
|
|
455
|
+
for (const entry of entries) {
|
|
456
|
+
cronTotal++;
|
|
457
|
+
if (entry.status === 'ok') {
|
|
458
|
+
cronOk++;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
cronErrors.push(entry);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Gather cron reflections (quality ratings from post-cron reflection passes)
|
|
467
|
+
const cronReflections = [];
|
|
468
|
+
try {
|
|
469
|
+
if (existsSync(CRON_REFLECTIONS_DIR)) {
|
|
470
|
+
const reflFiles = readdirSync(CRON_REFLECTIONS_DIR).filter(f => f.endsWith('.jsonl'));
|
|
471
|
+
for (const file of reflFiles) {
|
|
472
|
+
const lines = readFileSync(path.join(CRON_REFLECTIONS_DIR, file), 'utf-8').trim().split('\n');
|
|
473
|
+
// Take the most recent 5 reflections per job
|
|
474
|
+
for (const line of lines.slice(-5)) {
|
|
475
|
+
try {
|
|
476
|
+
cronReflections.push(JSON.parse(line));
|
|
477
|
+
}
|
|
478
|
+
catch { /* skip malformed */ }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
catch { /* non-fatal */ }
|
|
484
|
+
// Gather goal health data
|
|
485
|
+
const goalHealth = [];
|
|
486
|
+
try {
|
|
487
|
+
if (existsSync(GOALS_DIR)) {
|
|
488
|
+
const goalFiles = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
const DAY_MS = 86_400_000;
|
|
491
|
+
for (const file of goalFiles) {
|
|
492
|
+
try {
|
|
493
|
+
const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, file), 'utf-8'));
|
|
494
|
+
const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
|
|
495
|
+
const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
|
|
496
|
+
const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
|
|
497
|
+
goalHealth.push({
|
|
498
|
+
id: goal.id,
|
|
499
|
+
title: goal.title,
|
|
500
|
+
status: goal.status,
|
|
501
|
+
owner: goal.owner,
|
|
502
|
+
priority: goal.priority,
|
|
503
|
+
daysSinceUpdate,
|
|
504
|
+
reviewFrequency: goal.reviewFrequency,
|
|
505
|
+
isStale: goal.status === 'active' && daysSinceUpdate > staleThreshold,
|
|
506
|
+
linkedCronJobs: goal.linkedCronJobs || [],
|
|
507
|
+
progressCount: goal.progressNotes?.length ?? 0,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
catch { /* skip malformed */ }
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch { /* non-fatal */ }
|
|
515
|
+
// Gather execution advisor insights (if available)
|
|
516
|
+
const advisorInsights = [];
|
|
517
|
+
try {
|
|
518
|
+
const advisorLog = path.join(BASE_DIR, 'cron', 'advisor-decisions.jsonl');
|
|
519
|
+
if (existsSync(advisorLog)) {
|
|
520
|
+
const advisorLines = readFileSync(advisorLog, 'utf-8').trim().split('\n').filter(Boolean);
|
|
521
|
+
const outcomes = advisorLines.slice(-100)
|
|
522
|
+
.map(l => { try {
|
|
523
|
+
return JSON.parse(l);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return null;
|
|
527
|
+
} })
|
|
528
|
+
.filter((d) => d?.type === 'outcome');
|
|
529
|
+
// Summarize per-job intervention effectiveness
|
|
530
|
+
const byJob = new Map();
|
|
531
|
+
for (const o of outcomes) {
|
|
532
|
+
if (!byJob.has(o.jobName))
|
|
533
|
+
byJob.set(o.jobName, { interventions: new Map() });
|
|
534
|
+
const jm = byJob.get(o.jobName);
|
|
535
|
+
for (const [key, val] of Object.entries(o.interventions ?? {})) {
|
|
536
|
+
if (!val)
|
|
537
|
+
continue;
|
|
538
|
+
if (!jm.interventions.has(key))
|
|
539
|
+
jm.interventions.set(key, { ok: 0, total: 0 });
|
|
540
|
+
const stats = jm.interventions.get(key);
|
|
541
|
+
stats.total++;
|
|
542
|
+
if (o.outcome === 'ok')
|
|
543
|
+
stats.ok++;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
for (const [job, data] of byJob) {
|
|
547
|
+
for (const [intervention, stats] of data.interventions) {
|
|
548
|
+
if (stats.total >= 2) {
|
|
549
|
+
advisorInsights.push(`${job}: ${intervention} — ${((stats.ok / stats.total) * 100).toFixed(0)}% success (n=${stats.total})`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch { /* non-fatal */ }
|
|
556
|
+
// Filter to target agent if running per-agent cycle
|
|
557
|
+
let filteredReflections = cronReflections;
|
|
558
|
+
let filteredErrors = cronErrors;
|
|
559
|
+
if (this.config.agentSlug) {
|
|
560
|
+
filteredReflections = cronReflections.filter(r => r.agentSlug === this.config.agentSlug);
|
|
561
|
+
filteredErrors = cronErrors.filter(e => e.agentSlug === this.config.agentSlug);
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
feedbackStats,
|
|
565
|
+
negativeFeedback,
|
|
566
|
+
cronErrors: filteredErrors.slice(0, 10),
|
|
567
|
+
cronSuccessRate: cronTotal > 0 ? cronOk / cronTotal : 1,
|
|
568
|
+
cronReflections: filteredReflections.slice(-20),
|
|
569
|
+
goalHealth,
|
|
570
|
+
advisorInsights,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
// ── Steps 2-3: Diagnose + Hypothesize ────────────────────────────
|
|
574
|
+
async hypothesize(metrics, history) {
|
|
575
|
+
// Read targeted triggers (written by cron scheduler when jobs fail repeatedly)
|
|
576
|
+
let targetedTriggers = '';
|
|
577
|
+
const triggersDir = path.join(SELF_IMPROVE_DIR, 'triggers');
|
|
578
|
+
if (existsSync(triggersDir)) {
|
|
579
|
+
const triggerFiles = readdirSync(triggersDir).filter(f => f.endsWith('.json'));
|
|
580
|
+
if (triggerFiles.length > 0) {
|
|
581
|
+
const triggers = triggerFiles.slice(0, 3).map(f => {
|
|
582
|
+
try {
|
|
583
|
+
const t = JSON.parse(readFileSync(path.join(triggersDir, f), 'utf-8'));
|
|
584
|
+
// Clean up trigger after reading
|
|
585
|
+
unlinkSync(path.join(triggersDir, f));
|
|
586
|
+
return t;
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}).filter(Boolean);
|
|
592
|
+
if (triggers.length > 0) {
|
|
593
|
+
targetedTriggers = `\n\n## PRIORITY: Failing Jobs Needing Attention\n` +
|
|
594
|
+
`These jobs have been failing repeatedly and need prompt/config fixes:\n` +
|
|
595
|
+
triggers.map((t) => `- **${t.jobName}**: ${t.consecutiveErrors} consecutive errors. Recent: ${(t.recentErrors ?? []).join('; ')}`).join('\n') +
|
|
596
|
+
`\n\nFocus your improvement hypothesis on fixing these jobs first.\n`;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Format experiment history for the prompt
|
|
601
|
+
const historyText = history.slice(-20).map(e => `#${e.iteration} | ${e.area} | "${e.hypothesis.slice(0, 60)}" | ${(e.score * 10).toFixed(1)}/10 ${e.accepted ? '✅' : '❌'}`).join('\n') || '(no prior experiments)';
|
|
602
|
+
// Enforce diversity: count recent proposals per area:target AND per area
|
|
603
|
+
const recentTargets = new Map();
|
|
604
|
+
const recentAreas = new Map();
|
|
605
|
+
for (const e of history.slice(-10)) {
|
|
606
|
+
const key = `${e.area}:${e.target}`;
|
|
607
|
+
recentTargets.set(key, (recentTargets.get(key) ?? 0) + 1);
|
|
608
|
+
recentAreas.set(e.area, (recentAreas.get(e.area) ?? 0) + 1);
|
|
609
|
+
}
|
|
610
|
+
for (const p of this.getPendingChanges()) {
|
|
611
|
+
const key = `${p.area}:${p.target}`;
|
|
612
|
+
recentTargets.set(key, (recentTargets.get(key) ?? 0) + 1);
|
|
613
|
+
recentAreas.set(p.area, (recentAreas.get(p.area) ?? 0) + 1);
|
|
614
|
+
}
|
|
615
|
+
// Block area:target pairs with >= 2 recent proposals
|
|
616
|
+
const overTargeted = [...recentTargets.entries()]
|
|
617
|
+
.filter(([, count]) => count >= 2)
|
|
618
|
+
.map(([key]) => key);
|
|
619
|
+
// Block entire areas with >= 3 recent proposals
|
|
620
|
+
const overTargetedAreas = [...recentAreas.entries()]
|
|
621
|
+
.filter(([, count]) => count >= 3)
|
|
622
|
+
.map(([area]) => area);
|
|
623
|
+
// Build area coverage stats to nudge the LLM toward unexplored areas
|
|
624
|
+
const allAreas = this.config.areas;
|
|
625
|
+
const areaCoverage = allAreas.map(area => {
|
|
626
|
+
const count = recentAreas.get(area) ?? 0;
|
|
627
|
+
return `- ${area}: ${count} recent proposals`;
|
|
628
|
+
}).join('\n');
|
|
629
|
+
const diversityConstraint = `\n\n## AREA COVERAGE (target under-explored areas)\n${areaCoverage}\n` +
|
|
630
|
+
(overTargeted.length > 0 || overTargetedAreas.length > 0
|
|
631
|
+
? `\n## DIVERSITY CONSTRAINT\n` +
|
|
632
|
+
(overTargetedAreas.length > 0
|
|
633
|
+
? `These AREAS have been over-targeted and MUST NOT be chosen:\n${overTargetedAreas.map(a => `- ${a} (${recentAreas.get(a)} proposals)`).join('\n')}\n`
|
|
634
|
+
: '') +
|
|
635
|
+
(overTargeted.length > 0
|
|
636
|
+
? `These specific targets MUST NOT be re-targeted:\n${overTargeted.map(t => `- ${t}`).join('\n')}\n`
|
|
637
|
+
: '') +
|
|
638
|
+
`Choose a DIFFERENT area/target. If no other improvement is needed, output { "area": null }.\n`
|
|
639
|
+
: '');
|
|
640
|
+
const patternAnalysis = this.analyzeExperimentPatterns(history);
|
|
641
|
+
// Format negative feedback
|
|
642
|
+
const negativeFeedbackText = metrics.negativeFeedback.slice(0, 5).map(f => `- Rating: ${f.rating} | Message: "${(f.messageSnippet ?? '').slice(0, 100)}" | Response: "${(f.responseSnippet ?? '').slice(0, 100)}"${f.comment ? ` | Comment: "${f.comment}"` : ''}`).join('\n') || '(no negative feedback)';
|
|
643
|
+
// Format cron errors
|
|
644
|
+
const cronErrorsText = metrics.cronErrors.slice(0, 5).map(e => `- Job: ${e.jobName} | Error: ${(e.error ?? 'unknown').slice(0, 200)} | At: ${e.startedAt}`).join('\n') || '(no cron errors)';
|
|
645
|
+
// Format cron reflections (quality ratings from automated reflection passes)
|
|
646
|
+
const cronReflectionsText = metrics.cronReflections.slice(-10).map(r => `- Job: ${r.jobName}${r.agentSlug ? ` (${r.agentSlug})` : ''} | Quality: ${r.quality}/5 | ` +
|
|
647
|
+
`Exist: ${r.existence ?? '?'} Substance: ${r.substance ?? '?'} Actionable: ${r.actionable ?? '?'} ` +
|
|
648
|
+
`Comm: ${r.communication ?? '?'} | ` +
|
|
649
|
+
`Gap: "${r.gap?.slice(0, 80) ?? ''}"${r.commNote ? ` | CommNote: "${r.commNote.slice(0, 80)}"` : ''} | At: ${r.timestamp}`).join('\n') || '(no cron reflections yet)';
|
|
650
|
+
// Compute per-agent metrics from reflections
|
|
651
|
+
const agentMetrics = new Map();
|
|
652
|
+
for (const r of metrics.cronReflections) {
|
|
653
|
+
const slug = r.agentSlug || 'clementine';
|
|
654
|
+
if (!agentMetrics.has(slug)) {
|
|
655
|
+
agentMetrics.set(slug, { total: 0, qualitySum: 0, emptyCount: 0, gaps: [] });
|
|
656
|
+
}
|
|
657
|
+
const m = agentMetrics.get(slug);
|
|
658
|
+
m.total++;
|
|
659
|
+
m.qualitySum += r.quality ?? 0;
|
|
660
|
+
if (r.existence === false || r.substance === false)
|
|
661
|
+
m.emptyCount++;
|
|
662
|
+
if (r.gap && r.gap !== 'none')
|
|
663
|
+
m.gaps.push(r.gap);
|
|
664
|
+
}
|
|
665
|
+
const perAgentText = agentMetrics.size > 0
|
|
666
|
+
? Array.from(agentMetrics.entries()).map(([slug, m]) => {
|
|
667
|
+
const avgQ = (m.qualitySum / m.total).toFixed(1);
|
|
668
|
+
const emptyPct = ((m.emptyCount / m.total) * 100).toFixed(0);
|
|
669
|
+
const topGaps = m.gaps.slice(-3).map(g => g.slice(0, 60)).join('; ') || 'none';
|
|
670
|
+
return `- ${slug}: avg quality ${avgQ}/5, ${emptyPct}% empty outputs, common gaps: "${topGaps}"`;
|
|
671
|
+
}).join('\n')
|
|
672
|
+
: '(no per-agent data yet)';
|
|
673
|
+
// Format goal health data
|
|
674
|
+
const goalHealthText = metrics.goalHealth.length > 0
|
|
675
|
+
? metrics.goalHealth.map(g => {
|
|
676
|
+
const staleTag = g.isStale ? ' ⚠ STALE' : '';
|
|
677
|
+
const linkedTag = g.linkedCronJobs.length > 0 ? ` | Linked crons: ${g.linkedCronJobs.join(', ')}` : ' | No linked crons';
|
|
678
|
+
return `- [${g.status.toUpperCase()}] ${g.title} (${g.priority}) — owner: ${g.owner} | ${g.daysSinceUpdate}d since update | ${g.progressCount} progress notes${linkedTag}${staleTag}`;
|
|
679
|
+
}).join('\n')
|
|
680
|
+
: '(no goals defined)';
|
|
681
|
+
const advisorText = metrics.advisorInsights.length > 0
|
|
682
|
+
? metrics.advisorInsights.map(a => `- ${a}`).join('\n')
|
|
683
|
+
: '(no advisor data yet)';
|
|
684
|
+
const areas = this.config.areas.map(a => `'${a}'`).join(', ');
|
|
685
|
+
const agentFocusText = this.config.agentSlug
|
|
686
|
+
? `\n\n## AGENT FOCUS: ${this.config.agentSlug}\nThis is a focused improvement cycle for agent "${this.config.agentSlug}" ONLY.\n` +
|
|
687
|
+
`- You MUST target area "agent" with target "${this.config.agentSlug}", OR area "cron" targeting a cron job that this agent runs.\n` +
|
|
688
|
+
`- Do NOT propose changes to SOUL.md, AGENTS.md, source code, or other agents.\n` +
|
|
689
|
+
`- Focus on improving this agent's personality, instructions, and task execution quality.\n`
|
|
690
|
+
: '';
|
|
691
|
+
// Read SOUL.md evolution candidates from FEEDBACK.md (written by synthesizeFeedbackPatterns)
|
|
692
|
+
let soulCandidatesText = '';
|
|
693
|
+
try {
|
|
694
|
+
const feedbackFile = path.join(VAULT_DIR, '00-System', 'FEEDBACK.md');
|
|
695
|
+
if (existsSync(feedbackFile)) {
|
|
696
|
+
const parsed = matter(readFileSync(feedbackFile, 'utf-8'));
|
|
697
|
+
if (parsed.data?.soul_candidates) {
|
|
698
|
+
soulCandidatesText = `\n\n## Pending SOUL.md Evolution Candidates (from feedback synthesis)\n` +
|
|
699
|
+
`These are evidence-backed personality changes identified from user interactions. ` +
|
|
700
|
+
`Prioritize these when considering "soul" area improvements:\n${parsed.data.soul_candidates}\n`;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch { /* non-fatal */ }
|
|
705
|
+
// ── Step 1: Analysis — identify top opportunities from metrics (no config dumps) ──
|
|
706
|
+
const analysisPrompt = `You are Clementine's self-improvement strategist. Analyze the performance data below and identify the top 3 improvement opportunities.\n\n` +
|
|
707
|
+
`## Recent Performance Data (last 7 days)\n` +
|
|
708
|
+
`- Feedback: ${metrics.feedbackStats.positive} positive, ${metrics.feedbackStats.negative} negative, ${metrics.feedbackStats.mixed} mixed (${metrics.feedbackStats.total} total)\n` +
|
|
709
|
+
`- Cron success rate: ${(metrics.cronSuccessRate * 100).toFixed(1)}%\n\n` +
|
|
710
|
+
`### Negative feedback examples:\n${negativeFeedbackText}\n\n` +
|
|
711
|
+
`### Cron job quality reflections (automated self-evaluation):\n${cronReflectionsText}\n\n` +
|
|
712
|
+
`### Per-agent cron performance:\n${perAgentText}\n\n` +
|
|
713
|
+
`### Goal health:\n${goalHealthText}\n\n` +
|
|
714
|
+
`### Execution advisor intervention outcomes:\n${advisorText}\n\n` +
|
|
715
|
+
`### Cron job errors:\n${cronErrorsText}\n\n` +
|
|
716
|
+
targetedTriggers +
|
|
717
|
+
`## Experiment History (avoid repeating failed approaches):\n${historyText}\n\n` +
|
|
718
|
+
(patternAnalysis ? `${patternAnalysis}\n\n` : '') +
|
|
719
|
+
diversityConstraint +
|
|
720
|
+
agentFocusText +
|
|
721
|
+
soulCandidatesText +
|
|
722
|
+
`\n## Instructions\n` +
|
|
723
|
+
`Rank these by expected impact. For each opportunity, specify:\n` +
|
|
724
|
+
`- area: ${areas}\n` +
|
|
725
|
+
`- target: the file/agent slug that should change\n` +
|
|
726
|
+
`- what: a 1-sentence description of what specifically should change\n` +
|
|
727
|
+
`- why: which metric this should improve\n\n` +
|
|
728
|
+
`Area notes:\n` +
|
|
729
|
+
`- For "goal": target = "{owner}/{goal-slug}" (e.g. "clementine/improve-reply-rates" or "ross-the-sdr/book-demos"). ` +
|
|
730
|
+
`Propose when you observe a pattern in completed tasks or cron runs that suggests a missing or stale goal. ` +
|
|
731
|
+
`The proposedChange must be a JSON goal object with at minimum: title, description, priority, reviewFrequency.\n\n` +
|
|
732
|
+
`Output ONLY a JSON array of 1-3 objects (no markdown, no explanation):\n` +
|
|
733
|
+
`[{ "area": "...", "target": "...", "what": "...", "why": "..." }]\n` +
|
|
734
|
+
`If no improvement is needed, output: []`;
|
|
735
|
+
const analysisResult = await this.assistant.runPlanStep('si-analyze', analysisPrompt, {
|
|
736
|
+
tier: 2,
|
|
737
|
+
maxTurns: 3,
|
|
738
|
+
disableTools: true,
|
|
739
|
+
outputFormat: {
|
|
740
|
+
type: 'json_schema',
|
|
741
|
+
schema: {
|
|
742
|
+
type: 'object',
|
|
743
|
+
properties: {
|
|
744
|
+
results: {
|
|
745
|
+
type: 'array',
|
|
746
|
+
items: {
|
|
747
|
+
type: 'object',
|
|
748
|
+
properties: {
|
|
749
|
+
area: { type: 'string' },
|
|
750
|
+
target: { type: 'string' },
|
|
751
|
+
what: { type: 'string' },
|
|
752
|
+
why: { type: 'string' },
|
|
753
|
+
},
|
|
754
|
+
required: ['area', 'target', 'what', 'why'],
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
required: ['results'],
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
const rawParsed = this.parseJsonResponse(analysisResult);
|
|
763
|
+
// Handle both wrapped {results:[...]} and bare array responses
|
|
764
|
+
const opportunities = Array.isArray(rawParsed)
|
|
765
|
+
? rawParsed
|
|
766
|
+
: Array.isArray(rawParsed?.results) ? rawParsed.results
|
|
767
|
+
: rawParsed ? [rawParsed] : [];
|
|
768
|
+
if (opportunities.length === 0)
|
|
769
|
+
return null;
|
|
770
|
+
// Pick the first opportunity that isn't over-targeted
|
|
771
|
+
const selected = opportunities.find((o) => {
|
|
772
|
+
const key = `${o.area}:${o.target}`;
|
|
773
|
+
return !overTargeted.includes(key) && !overTargetedAreas.includes(o.area);
|
|
774
|
+
}) ?? opportunities[0];
|
|
775
|
+
// ── Step 2: Proposal — load only the target file, generate specific change ──
|
|
776
|
+
const currentContent = await this.readCurrentState(selected.area, selected.target);
|
|
777
|
+
const proposalPrompt = `You identified this as the highest-impact improvement:\n` +
|
|
778
|
+
`- Area: ${selected.area}\n` +
|
|
779
|
+
`- Target: ${selected.target}\n` +
|
|
780
|
+
`- What: ${selected.what}\n` +
|
|
781
|
+
`- Why: ${selected.why}\n\n` +
|
|
782
|
+
`## Current file content:\n${currentContent.slice(0, 5000)}\n\n` +
|
|
783
|
+
`## Instructions\n` +
|
|
784
|
+
`- Generate a SPECIFIC, MINIMAL change (not a full rewrite)\n` +
|
|
785
|
+
`- Explain WHY this change should improve the metric\n` +
|
|
786
|
+
`- IMPORTANT: "proposedChange" must be the COMPLETE updated file content (not just the diff), because it will replace the entire file\n` +
|
|
787
|
+
`- For source code changes: preserve all imports, exports, and function signatures. Only modify implementation details.\n\n` +
|
|
788
|
+
`Output ONLY a JSON object (no markdown, no explanation):\n` +
|
|
789
|
+
`{ "area": "${selected.area}", "target": "${selected.target}", "hypothesis": "what will improve and why", "proposedChange": "the complete updated file content with your minimal change applied" }`;
|
|
790
|
+
const result = await this.assistant.runPlanStep('si-hypothesize', proposalPrompt, {
|
|
791
|
+
tier: 2,
|
|
792
|
+
maxTurns: 5,
|
|
793
|
+
disableTools: true,
|
|
794
|
+
});
|
|
795
|
+
return this.parseJsonResponse(result);
|
|
796
|
+
}
|
|
797
|
+
// ── Step 4: Read current state ───────────────────────────────────
|
|
798
|
+
async readCurrentState(area, target) {
|
|
799
|
+
switch (area) {
|
|
800
|
+
case 'soul':
|
|
801
|
+
return existsSync(SOUL_FILE) ? readFileSync(SOUL_FILE, 'utf-8') : '';
|
|
802
|
+
case 'cron':
|
|
803
|
+
return existsSync(CRON_FILE) ? readFileSync(CRON_FILE, 'utf-8') : '';
|
|
804
|
+
case 'workflow': {
|
|
805
|
+
const wfFile = path.join(WORKFLOWS_DIR, target.endsWith('.md') ? target : `${target}.md`);
|
|
806
|
+
return existsSync(wfFile) ? readFileSync(wfFile, 'utf-8') : '';
|
|
807
|
+
}
|
|
808
|
+
case 'agent': {
|
|
809
|
+
const agentFile = path.join(AGENTS_DIR, target, 'agent.md');
|
|
810
|
+
return existsSync(agentFile) ? readFileSync(agentFile, 'utf-8') : '';
|
|
811
|
+
}
|
|
812
|
+
case 'source': {
|
|
813
|
+
const srcFile = path.join(PKG_DIR, 'src', target);
|
|
814
|
+
return existsSync(srcFile) ? readFileSync(srcFile, 'utf-8') : '';
|
|
815
|
+
}
|
|
816
|
+
case 'communication':
|
|
817
|
+
return existsSync(AGENTS_FILE) ? readFileSync(AGENTS_FILE, 'utf-8') : '';
|
|
818
|
+
case 'memory': {
|
|
819
|
+
const memoryFile = path.join(VAULT_DIR, '00-System', 'MEMORY.md');
|
|
820
|
+
return existsSync(memoryFile) ? readFileSync(memoryFile, 'utf-8') : '';
|
|
821
|
+
}
|
|
822
|
+
case 'goal': {
|
|
823
|
+
// target = "{owner}" e.g. "clementine" or an agent slug
|
|
824
|
+
const owner = target.split('/')[0];
|
|
825
|
+
const goalDir = owner === 'clementine'
|
|
826
|
+
? GOALS_DIR
|
|
827
|
+
: path.join(AGENTS_DIR, owner, 'goals');
|
|
828
|
+
if (!existsSync(goalDir))
|
|
829
|
+
return '(no goals yet for this owner)';
|
|
830
|
+
const files = readdirSync(goalDir).filter(f => f.endsWith('.json') && !readdirSync(goalDir).includes(f + '.bak'));
|
|
831
|
+
const goals = files.map(f => {
|
|
832
|
+
try {
|
|
833
|
+
return JSON.parse(readFileSync(path.join(goalDir, f), 'utf-8'));
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
}).filter(Boolean);
|
|
839
|
+
if (goals.length === 0)
|
|
840
|
+
return '(no goals yet for this owner)';
|
|
841
|
+
return goals.map((g) => `[${g.status ?? 'unknown'}] ${g.title}: ${(g.description ?? '').slice(0, 120)}`).join('\n');
|
|
842
|
+
}
|
|
843
|
+
default:
|
|
844
|
+
return '';
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
// ── Step 5: LLM judge evaluation ─────────────────────────────────
|
|
848
|
+
async evaluate(before, after, hypothesis) {
|
|
849
|
+
const prompt = `Score this proposed change using the structured rubric below.\n\n` +
|
|
850
|
+
`## Current text (before):\n${before.slice(0, 3000)}\n\n` +
|
|
851
|
+
`## Proposed change (after):\n${after.slice(0, 3000)}\n\n` +
|
|
852
|
+
`## Hypothesis:\n${hypothesis}\n\n` +
|
|
853
|
+
`## Rubric (score each criterion 0, 1, or 2):\n` +
|
|
854
|
+
`1. Specificity: 0=vague/generic, 1=somewhat specific, 2=precise and actionable\n` +
|
|
855
|
+
`2. Evidence: 0=no data backing, 1=some metric reference, 2=directly addresses a measured weakness\n` +
|
|
856
|
+
`3. Safety: 0=breaks guardrails or removes constraints, 1=minor concern, 2=clean, maintains all constraints\n` +
|
|
857
|
+
`4. Impact: 0=unlikely to help, 1=plausible improvement, 2=high-confidence improvement\n` +
|
|
858
|
+
`5. Novelty: 0=repeat of a failed approach, 1=incremental variation, 2=fresh angle\n\n` +
|
|
859
|
+
`Sum the 5 scores for a total 0-10.\n\n` +
|
|
860
|
+
`Output ONLY a JSON object (no markdown, no explanation):\n` +
|
|
861
|
+
`{ "specificity": <0-2>, "evidence": <0-2>, "safety": <0-2>, "impact": <0-2>, "novelty": <0-2>, "score": <0-10>, "reasoning": "brief explanation" }`;
|
|
862
|
+
const result = await this.assistant.runPlanStep('si-evaluate', prompt, {
|
|
863
|
+
tier: 2,
|
|
864
|
+
maxTurns: 3,
|
|
865
|
+
disableTools: true,
|
|
866
|
+
outputFormat: {
|
|
867
|
+
type: 'json_schema',
|
|
868
|
+
schema: {
|
|
869
|
+
type: 'object',
|
|
870
|
+
properties: {
|
|
871
|
+
specificity: { type: 'number' },
|
|
872
|
+
evidence: { type: 'number' },
|
|
873
|
+
safety: { type: 'number' },
|
|
874
|
+
impact: { type: 'number' },
|
|
875
|
+
novelty: { type: 'number' },
|
|
876
|
+
score: { type: 'number' },
|
|
877
|
+
reasoning: { type: 'string' },
|
|
878
|
+
},
|
|
879
|
+
required: ['score', 'reasoning'],
|
|
880
|
+
},
|
|
881
|
+
},
|
|
882
|
+
});
|
|
883
|
+
return this.parseJsonResponse(result);
|
|
884
|
+
}
|
|
885
|
+
// ── Step 6: Save pending change ──────────────────────────────────
|
|
886
|
+
async savePendingChange(experiment, before) {
|
|
887
|
+
ensureDirs();
|
|
888
|
+
const filePath = path.join(PENDING_DIR, `${experiment.id}.json`);
|
|
889
|
+
const pending = {
|
|
890
|
+
...experiment,
|
|
891
|
+
before,
|
|
892
|
+
createdAt: new Date().toISOString(),
|
|
893
|
+
};
|
|
894
|
+
writeFileSync(filePath, JSON.stringify(pending, null, 2));
|
|
895
|
+
logger.info({ id: experiment.id, area: experiment.area }, 'Saved pending change');
|
|
896
|
+
}
|
|
897
|
+
// ── Apply approved change ────────────────────────────────────────
|
|
898
|
+
async applyApprovedChange(experimentId) {
|
|
899
|
+
const pendingFile = path.join(PENDING_DIR, `${experimentId}.json`);
|
|
900
|
+
if (!existsSync(pendingFile)) {
|
|
901
|
+
return `Pending change not found: ${experimentId}`;
|
|
902
|
+
}
|
|
903
|
+
const pending = JSON.parse(readFileSync(pendingFile, 'utf-8'));
|
|
904
|
+
const targetPath = this.resolveTargetPath(pending.area, pending.target);
|
|
905
|
+
if (!targetPath) {
|
|
906
|
+
return `Cannot resolve target path for area=${pending.area}, target=${pending.target}`;
|
|
907
|
+
}
|
|
908
|
+
// Route source changes through the safe pipeline
|
|
909
|
+
if (pending.area === 'source') {
|
|
910
|
+
const { safeSourceEdit } = await import('./safe-restart.js');
|
|
911
|
+
const result = await safeSourceEdit(PKG_DIR, [
|
|
912
|
+
{ relativePath: `src/${pending.target}`, content: pending.proposedChange },
|
|
913
|
+
], { experimentId, reason: `self-improve: ${pending.hypothesis.slice(0, 60)}`, description: pending.hypothesis });
|
|
914
|
+
if (!result.success) {
|
|
915
|
+
return `Source edit failed: ${result.error}${result.preflightErrors ? '\n' + result.preflightErrors.join('\n') : ''}`;
|
|
916
|
+
}
|
|
917
|
+
// Update experiment log — mark as approved
|
|
918
|
+
this.updateExperimentStatus(experimentId, 'approved');
|
|
919
|
+
try {
|
|
920
|
+
unlinkSync(pendingFile);
|
|
921
|
+
}
|
|
922
|
+
catch { /* ignore */ }
|
|
923
|
+
const state = this.loadState();
|
|
924
|
+
state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
|
|
925
|
+
this.saveState(state);
|
|
926
|
+
// Schedule impact measurement for 24h later
|
|
927
|
+
try {
|
|
928
|
+
appendFileSync(IMPACT_CHECKS_FILE, JSON.stringify({
|
|
929
|
+
experimentId,
|
|
930
|
+
area: pending.area,
|
|
931
|
+
target: pending.target,
|
|
932
|
+
appliedAt: new Date().toISOString(),
|
|
933
|
+
checkAfterMs: 24 * 60 * 60 * 1000,
|
|
934
|
+
}) + '\n');
|
|
935
|
+
}
|
|
936
|
+
catch (err) {
|
|
937
|
+
logger.warn({ err }, 'Failed to schedule impact check');
|
|
938
|
+
}
|
|
939
|
+
return `Applied source change to ${pending.target} — restart triggered.`;
|
|
940
|
+
}
|
|
941
|
+
// Goal area: parse JSON, inject required fields, ensure parent dir exists
|
|
942
|
+
if (pending.area === 'goal') {
|
|
943
|
+
try {
|
|
944
|
+
const goalData = JSON.parse(pending.proposedChange);
|
|
945
|
+
const [owner, goalSlug] = pending.target.split('/');
|
|
946
|
+
if (!goalSlug)
|
|
947
|
+
return `Invalid goal target (need "owner/slug"): ${pending.target}`;
|
|
948
|
+
const goalDir = owner === 'clementine'
|
|
949
|
+
? GOALS_DIR
|
|
950
|
+
: path.join(AGENTS_DIR, owner, 'goals');
|
|
951
|
+
mkdirSync(goalDir, { recursive: true });
|
|
952
|
+
const now = new Date().toISOString();
|
|
953
|
+
const goalJson = JSON.stringify({
|
|
954
|
+
id: goalSlug,
|
|
955
|
+
owner,
|
|
956
|
+
status: 'active',
|
|
957
|
+
createdAt: now,
|
|
958
|
+
progressNotes: [],
|
|
959
|
+
...goalData,
|
|
960
|
+
updatedAt: now,
|
|
961
|
+
}, null, 2);
|
|
962
|
+
writeFileSync(targetPath, goalJson);
|
|
963
|
+
this.recordVersion(experimentId, pending.area, pending.target, pending.hypothesis, pending.before);
|
|
964
|
+
this.updateExperimentStatus(experimentId, 'approved');
|
|
965
|
+
try {
|
|
966
|
+
unlinkSync(pendingFile);
|
|
967
|
+
}
|
|
968
|
+
catch { /* ignore */ }
|
|
969
|
+
const state = this.loadState();
|
|
970
|
+
state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
|
|
971
|
+
this.saveState(state);
|
|
972
|
+
logger.info({ id: experimentId, target: pending.target }, 'Goal created from self-improve proposal');
|
|
973
|
+
return `Goal created: ${goalData.title ?? goalSlug}`;
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
return `Failed to create goal: ${err instanceof Error ? err.message : String(err)}`;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// Final validation before writing
|
|
980
|
+
const validation = this.validateProposal(pending.area, pending.target, pending.proposedChange);
|
|
981
|
+
if (!validation.valid) {
|
|
982
|
+
return `Cannot apply change — validation failed: ${validation.error}`;
|
|
983
|
+
}
|
|
984
|
+
// Drift check for soul changes — even approved changes must not drift too far
|
|
985
|
+
if (pending.area === 'soul') {
|
|
986
|
+
const drift = checkDrift(pending.proposedChange);
|
|
987
|
+
if (!drift.ok) {
|
|
988
|
+
return `Cannot apply change — identity drift too high (similarity: ${drift.similarity.toFixed(3)}, threshold: ${DRIFT_SIMILARITY_THRESHOLD})`;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// Write the change (non-source areas)
|
|
992
|
+
writeFileSync(targetPath, pending.proposedChange);
|
|
993
|
+
// Record version for rollback lineage
|
|
994
|
+
this.recordVersion(experimentId, pending.area, pending.target, pending.hypothesis, pending.before);
|
|
995
|
+
logger.info({ id: experimentId, area: pending.area, target: pending.target }, 'Applied approved change');
|
|
996
|
+
// Update experiment log — mark as approved
|
|
997
|
+
this.updateExperimentStatus(experimentId, 'approved');
|
|
998
|
+
// Remove pending file
|
|
999
|
+
try {
|
|
1000
|
+
unlinkSync(pendingFile);
|
|
1001
|
+
}
|
|
1002
|
+
catch { /* ignore */ }
|
|
1003
|
+
// Update state
|
|
1004
|
+
const state = this.loadState();
|
|
1005
|
+
state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
|
|
1006
|
+
this.saveState(state);
|
|
1007
|
+
// Schedule impact measurement for 24h later
|
|
1008
|
+
try {
|
|
1009
|
+
appendFileSync(IMPACT_CHECKS_FILE, JSON.stringify({
|
|
1010
|
+
experimentId,
|
|
1011
|
+
area: pending.area,
|
|
1012
|
+
target: pending.target,
|
|
1013
|
+
appliedAt: new Date().toISOString(),
|
|
1014
|
+
checkAfterMs: 24 * 60 * 60 * 1000,
|
|
1015
|
+
}) + '\n');
|
|
1016
|
+
}
|
|
1017
|
+
catch (err) {
|
|
1018
|
+
logger.warn({ err }, 'Failed to schedule impact check');
|
|
1019
|
+
}
|
|
1020
|
+
return `Applied change to ${pending.area}/${pending.target}`;
|
|
1021
|
+
}
|
|
1022
|
+
/** Deny a pending change without applying it. */
|
|
1023
|
+
denyChange(experimentId) {
|
|
1024
|
+
const pendingFile = path.join(PENDING_DIR, `${experimentId}.json`);
|
|
1025
|
+
if (!existsSync(pendingFile)) {
|
|
1026
|
+
return `Pending change not found: ${experimentId}`;
|
|
1027
|
+
}
|
|
1028
|
+
this.updateExperimentStatus(experimentId, 'denied');
|
|
1029
|
+
try {
|
|
1030
|
+
unlinkSync(pendingFile);
|
|
1031
|
+
}
|
|
1032
|
+
catch { /* ignore */ }
|
|
1033
|
+
const state = this.loadState();
|
|
1034
|
+
state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
|
|
1035
|
+
this.saveState(state);
|
|
1036
|
+
return `Denied change: ${experimentId}`;
|
|
1037
|
+
}
|
|
1038
|
+
// ── Memory cleanup ───────────────────────────────────────────────
|
|
1039
|
+
async runMemoryCleanup() {
|
|
1040
|
+
try {
|
|
1041
|
+
const { MemoryStore } = await import('../memory/store.js');
|
|
1042
|
+
const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
1043
|
+
store.initialize();
|
|
1044
|
+
store.decaySalience(30);
|
|
1045
|
+
store.pruneStaleData({
|
|
1046
|
+
maxAgeDays: 90,
|
|
1047
|
+
salienceThreshold: 0.01,
|
|
1048
|
+
accessLogRetentionDays: 60,
|
|
1049
|
+
transcriptRetentionDays: 90,
|
|
1050
|
+
});
|
|
1051
|
+
store.close();
|
|
1052
|
+
logger.info('Memory cleanup complete');
|
|
1053
|
+
}
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
logger.error({ err }, 'Memory cleanup failed');
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// ── Feedback synthesis ───────────────────────────────────────────
|
|
1059
|
+
async synthesizeFeedbackPatterns() {
|
|
1060
|
+
try {
|
|
1061
|
+
const { MemoryStore } = await import('../memory/store.js');
|
|
1062
|
+
const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
1063
|
+
store.initialize();
|
|
1064
|
+
// Gather from multiple sources
|
|
1065
|
+
const recentFeedback = store.getRecentFeedback(50);
|
|
1066
|
+
const reflections = store.getRecentReflections(20);
|
|
1067
|
+
const behavioralPatterns = store.getBehavioralPatterns(2);
|
|
1068
|
+
const corrections = store.getRecentCorrections(10);
|
|
1069
|
+
store.close();
|
|
1070
|
+
const totalSignals = recentFeedback.length + reflections.length + behavioralPatterns.length;
|
|
1071
|
+
if (totalSignals < 3) {
|
|
1072
|
+
logger.info({ totalSignals }, 'Not enough data to synthesize (need 3+)');
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// Format feedback by rating
|
|
1076
|
+
const grouped = {};
|
|
1077
|
+
for (const f of recentFeedback) {
|
|
1078
|
+
(grouped[f.rating] ??= []).push(f);
|
|
1079
|
+
}
|
|
1080
|
+
const feedbackLines = [];
|
|
1081
|
+
for (const [rating, items] of Object.entries(grouped)) {
|
|
1082
|
+
feedbackLines.push(`### ${rating.toUpperCase()} (${items.length})`);
|
|
1083
|
+
for (const f of items.slice(0, 15)) {
|
|
1084
|
+
const snippet = f.messageSnippet ? ` | Message: "${f.messageSnippet.slice(0, 100)}"` : '';
|
|
1085
|
+
const comment = f.comment ? ` | Comment: "${f.comment}"` : '';
|
|
1086
|
+
feedbackLines.push(`- ${f.channel}${snippet}${comment}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// Format session reflections
|
|
1090
|
+
const reflectionLines = reflections.map(r => {
|
|
1091
|
+
const friction = r.frictionSignals.length > 0 ? ` | Friction: ${r.frictionSignals.join('; ')}` : '';
|
|
1092
|
+
const corrections = r.behavioralCorrections.length > 0
|
|
1093
|
+
? ` | Corrections: ${r.behavioralCorrections.map(c => `${c.correction} [${c.category}]`).join('; ')}`
|
|
1094
|
+
: '';
|
|
1095
|
+
const prefs = r.preferencesLearned.length > 0
|
|
1096
|
+
? ` | Preferences: ${r.preferencesLearned.map(p => `${p.preference} [${p.confidence}]`).join('; ')}`
|
|
1097
|
+
: '';
|
|
1098
|
+
return `- Session ${r.sessionKey.slice(0, 30)} | Quality: ${r.qualityScore}/5 | ${r.exchangeCount} exchanges${friction}${corrections}${prefs}`;
|
|
1099
|
+
});
|
|
1100
|
+
// Format recurring behavioral patterns
|
|
1101
|
+
const patternLines = behavioralPatterns.map(p => `- "${p.correction}" [${p.category}] — ${p.count} occurrences (last: ${p.lastSeen})`);
|
|
1102
|
+
const prompt = `Analyze the multi-source data below about an AI assistant's interactions and produce TWO outputs.\n\n` +
|
|
1103
|
+
`## 1. Feedback Entries (${recentFeedback.length})\n${feedbackLines.join('\n') || '(none)'}\n\n` +
|
|
1104
|
+
`## 2. Session Reflections (${reflections.length})\n${reflectionLines.join('\n') || '(none)'}\n\n` +
|
|
1105
|
+
`## 3. Recurring Behavioral Corrections (appeared 2+ times)\n${patternLines.join('\n') || '(none)'}\n\n` +
|
|
1106
|
+
`## 4. Recent Fact Corrections (${corrections.length})\n` +
|
|
1107
|
+
corrections.map(c => `- ${c.correction}`).join('\n') + '\n\n' +
|
|
1108
|
+
`## OUTPUT 1: Communication Preferences\n` +
|
|
1109
|
+
`Synthesize 5-10 specific, actionable behavioral rules from the evidence.\n` +
|
|
1110
|
+
`Each rule should be:\n` +
|
|
1111
|
+
`- Specific enough to follow ("Be concise" is bad; "Keep responses under 3 sentences unless asked for detail" is good)\n` +
|
|
1112
|
+
`- Evidence-based (mention the signal count or pattern that supports it)\n` +
|
|
1113
|
+
`- Categorized in brackets: [tone], [format], [verbosity], [proactivity], [workflow], [scope]\n\n` +
|
|
1114
|
+
`## OUTPUT 2: SOUL.md Evolution Candidates\n` +
|
|
1115
|
+
`Identify 0-3 patterns strong enough (3+ occurrences or high confidence) to warrant a change to the agent's\n` +
|
|
1116
|
+
`core personality (SOUL.md). These should be durable behavioral shifts, not one-off preferences.\n` +
|
|
1117
|
+
`For each candidate, output:\n` +
|
|
1118
|
+
`- What trait should change\n` +
|
|
1119
|
+
`- Current behavior → Desired behavior\n` +
|
|
1120
|
+
`- Evidence count\n` +
|
|
1121
|
+
`- Confidence: high (5+ signals) / medium (3-4 signals)\n\n` +
|
|
1122
|
+
`Format your response as:\n` +
|
|
1123
|
+
`## Communication Preferences\n` +
|
|
1124
|
+
`- [category] Specific rule (evidence: N signals)\n` +
|
|
1125
|
+
`...\n\n` +
|
|
1126
|
+
`## SOUL.md Candidates\n` +
|
|
1127
|
+
`- **Trait**: Current → Desired (evidence: N signals, confidence: high/medium)\n` +
|
|
1128
|
+
`...\n\n` +
|
|
1129
|
+
`If there are no SOUL.md candidates, write "No candidates — current personality is well-calibrated."`;
|
|
1130
|
+
const result = await this.assistant.runPlanStep('si-feedback-synthesis', prompt, {
|
|
1131
|
+
tier: 2,
|
|
1132
|
+
maxTurns: 1,
|
|
1133
|
+
disableTools: true,
|
|
1134
|
+
});
|
|
1135
|
+
// Extract communication preferences
|
|
1136
|
+
const prefSection = result.match(/## Communication Preferences\s*\n([\s\S]*?)(?=\n## SOUL|$)/i);
|
|
1137
|
+
const bullets = prefSection
|
|
1138
|
+
? prefSection[1].split('\n').map(l => l.trim()).filter(l => l.startsWith('- '))
|
|
1139
|
+
: result.split('\n').map(l => l.trim()).filter(l => l.startsWith('- '));
|
|
1140
|
+
// Extract SOUL.md candidates
|
|
1141
|
+
const soulSection = result.match(/## SOUL\.md Candidates\s*\n([\s\S]*?)$/i);
|
|
1142
|
+
const soulCandidates = soulSection
|
|
1143
|
+
? soulSection[1].split('\n').map(l => l.trim()).filter(l => l.startsWith('- '))
|
|
1144
|
+
: [];
|
|
1145
|
+
if (bullets.length === 0 && soulCandidates.length === 0) {
|
|
1146
|
+
logger.warn('Feedback synthesis returned no output');
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
const patternsSummary = bullets.join('\n');
|
|
1150
|
+
const soulCandidatesSummary = soulCandidates.length > 0
|
|
1151
|
+
? soulCandidates.join('\n')
|
|
1152
|
+
: 'No candidates — current personality is well-calibrated.';
|
|
1153
|
+
const feedbackDir = path.join(VAULT_DIR, '00-System');
|
|
1154
|
+
if (!existsSync(feedbackDir))
|
|
1155
|
+
mkdirSync(feedbackDir, { recursive: true });
|
|
1156
|
+
const feedbackFile = path.join(feedbackDir, 'FEEDBACK.md');
|
|
1157
|
+
const content = matter.stringify(`\n## Communication Preferences\n\n${patternsSummary}\n\n## Pending SOUL.md Candidates\n\n${soulCandidatesSummary}\n`, {
|
|
1158
|
+
patterns_summary: patternsSummary,
|
|
1159
|
+
soul_candidates: soulCandidatesSummary,
|
|
1160
|
+
last_synthesized: new Date().toISOString(),
|
|
1161
|
+
feedback_count: recentFeedback.length,
|
|
1162
|
+
reflection_count: reflections.length,
|
|
1163
|
+
behavioral_correction_count: behavioralPatterns.length,
|
|
1164
|
+
});
|
|
1165
|
+
writeFileSync(feedbackFile, content);
|
|
1166
|
+
// Write agent-specific PREFERENCES.md for agents with enough data
|
|
1167
|
+
const agentReflections = new Map();
|
|
1168
|
+
for (const r of reflections) {
|
|
1169
|
+
if (r.agentSlug && r.agentSlug !== 'clementine') {
|
|
1170
|
+
if (!agentReflections.has(r.agentSlug))
|
|
1171
|
+
agentReflections.set(r.agentSlug, []);
|
|
1172
|
+
agentReflections.get(r.agentSlug).push(r);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
for (const [slug, agentRefls] of agentReflections) {
|
|
1176
|
+
if (agentRefls.length < 2)
|
|
1177
|
+
continue; // Need enough data
|
|
1178
|
+
const agentCorrections = agentRefls.flatMap(r => r.behavioralCorrections);
|
|
1179
|
+
if (agentCorrections.length === 0)
|
|
1180
|
+
continue;
|
|
1181
|
+
const agentPrefs = agentCorrections.map(c => `- [${c.category}] ${c.correction} (${c.strength})`).join('\n');
|
|
1182
|
+
const agentDir = path.join(AGENTS_DIR, slug);
|
|
1183
|
+
if (existsSync(agentDir)) {
|
|
1184
|
+
const prefsFile = path.join(agentDir, 'PREFERENCES.md');
|
|
1185
|
+
const prefsContent = matter.stringify(`\n## Agent Preferences\n\n${agentPrefs}\n`, {
|
|
1186
|
+
preferences: agentPrefs,
|
|
1187
|
+
last_synthesized: new Date().toISOString(),
|
|
1188
|
+
reflection_count: agentRefls.length,
|
|
1189
|
+
});
|
|
1190
|
+
writeFileSync(prefsFile, prefsContent);
|
|
1191
|
+
logger.info({ slug, corrections: agentCorrections.length }, 'Agent-specific preferences written');
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
logger.info({
|
|
1195
|
+
bullets: bullets.length,
|
|
1196
|
+
soulCandidates: soulCandidates.length,
|
|
1197
|
+
feedbackCount: recentFeedback.length,
|
|
1198
|
+
reflectionCount: reflections.length,
|
|
1199
|
+
}, 'Feedback patterns + SOUL.md candidates synthesized to FEEDBACK.md');
|
|
1200
|
+
}
|
|
1201
|
+
catch (err) {
|
|
1202
|
+
logger.error({ err }, 'Feedback synthesis failed');
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
// ── User Theory of Mind ──────────────────────────────────────────
|
|
1206
|
+
/** Update the structured user model from interaction data. */
|
|
1207
|
+
async updateUserModel() {
|
|
1208
|
+
try {
|
|
1209
|
+
const { MemoryStore } = await import('../memory/store.js');
|
|
1210
|
+
const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
|
|
1211
|
+
store.initialize();
|
|
1212
|
+
const reflections = store.getRecentReflections(30);
|
|
1213
|
+
const feedback = store.getRecentFeedback(30);
|
|
1214
|
+
const patterns = store.getBehavioralPatterns(1);
|
|
1215
|
+
store.close();
|
|
1216
|
+
if (reflections.length + feedback.length < 5) {
|
|
1217
|
+
logger.info('Not enough interaction data for user model update');
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
// Read existing model
|
|
1221
|
+
const modelFile = path.join(VAULT_DIR, '00-System', 'USER_MODEL.md');
|
|
1222
|
+
let existingModel = '';
|
|
1223
|
+
if (existsSync(modelFile)) {
|
|
1224
|
+
existingModel = readFileSync(modelFile, 'utf-8');
|
|
1225
|
+
}
|
|
1226
|
+
const reflectionSummary = reflections.slice(0, 15).map(r => {
|
|
1227
|
+
const corrections = r.behavioralCorrections.map(c => `${c.correction} [${c.category}]`).join('; ');
|
|
1228
|
+
return `- Quality: ${r.qualityScore}/5, ${r.exchangeCount} exchanges${corrections ? `, corrections: ${corrections}` : ''}`;
|
|
1229
|
+
}).join('\n');
|
|
1230
|
+
const feedbackSummary = feedback.slice(0, 15).map(f => `- [${f.rating}] ${f.channel}: ${f.comment || f.messageSnippet || '(no detail)'}`.slice(0, 120)).join('\n');
|
|
1231
|
+
const patternSummary = patterns.map(p => `- "${p.correction}" [${p.category}] x${p.count}`).join('\n');
|
|
1232
|
+
const prompt = `You are updating a structured user model based on interaction data. The model tracks the owner's expertise, priorities, communication preferences, and behavioral patterns.\n\n` +
|
|
1233
|
+
`## Current Model\n${existingModel || '(empty — first synthesis)'}\n\n` +
|
|
1234
|
+
`## Recent Session Reflections (${reflections.length})\n${reflectionSummary || '(none)'}\n\n` +
|
|
1235
|
+
`## Recent Feedback (${feedback.length})\n${feedbackSummary || '(none)'}\n\n` +
|
|
1236
|
+
`## Recurring Behavioral Patterns\n${patternSummary || '(none)'}\n\n` +
|
|
1237
|
+
`## Instructions\n` +
|
|
1238
|
+
`Output a YAML frontmatter block for USER_MODEL.md. Include these sections:\n` +
|
|
1239
|
+
`- expertise: map of domain → level (beginner/intermediate/expert) based on how they interact\n` +
|
|
1240
|
+
`- priorities: list of current focus areas with priority level\n` +
|
|
1241
|
+
`- communication: style, verbosity, decision_making, time_sensitivity\n` +
|
|
1242
|
+
`- patterns: morning/afternoon/evening behavioral patterns\n` +
|
|
1243
|
+
`- confidence_scores: how confident each section is (0-1)\n\n` +
|
|
1244
|
+
`Preserve existing data that's still accurate. Only update fields where new evidence supports a change.\n` +
|
|
1245
|
+
`Output ONLY the YAML frontmatter block (--- delimited), no other text.`;
|
|
1246
|
+
const result = await this.assistant.runPlanStep('si-user-model', prompt, {
|
|
1247
|
+
tier: 1,
|
|
1248
|
+
maxTurns: 1,
|
|
1249
|
+
disableTools: true,
|
|
1250
|
+
});
|
|
1251
|
+
// Extract YAML frontmatter from response
|
|
1252
|
+
const yamlMatch = result.match(/---\s*\n([\s\S]*?)\n---/);
|
|
1253
|
+
if (!yamlMatch) {
|
|
1254
|
+
logger.warn('User model synthesis returned no YAML block');
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const modelDir = path.join(VAULT_DIR, '00-System');
|
|
1258
|
+
if (!existsSync(modelDir))
|
|
1259
|
+
mkdirSync(modelDir, { recursive: true });
|
|
1260
|
+
const content = `---\n${yamlMatch[1].trim()}\nlast_updated: "${new Date().toISOString()}"\n---\n\n# User Model\n\nThis file is auto-generated by the self-improvement loop. It captures a structured understanding of the owner based on interaction patterns, feedback, and behavioral corrections.\n`;
|
|
1261
|
+
writeFileSync(modelFile, content);
|
|
1262
|
+
logger.info('User model updated: USER_MODEL.md');
|
|
1263
|
+
}
|
|
1264
|
+
catch (err) {
|
|
1265
|
+
logger.error({ err }, 'User model update failed');
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
// ── JSONL log management ─────────────────────────────────────────
|
|
1269
|
+
loadExperimentLog() {
|
|
1270
|
+
if (!existsSync(EXPERIMENT_LOG))
|
|
1271
|
+
return [];
|
|
1272
|
+
try {
|
|
1273
|
+
return readFileSync(EXPERIMENT_LOG, 'utf-8')
|
|
1274
|
+
.trim()
|
|
1275
|
+
.split('\n')
|
|
1276
|
+
.filter(Boolean)
|
|
1277
|
+
.map(line => JSON.parse(line));
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
return [];
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
appendExperimentLog(entry) {
|
|
1284
|
+
ensureDirs();
|
|
1285
|
+
appendFileSync(EXPERIMENT_LOG, JSON.stringify(entry) + '\n');
|
|
1286
|
+
}
|
|
1287
|
+
updateExperimentStatus(experimentId, status) {
|
|
1288
|
+
const experiments = this.loadExperimentLog();
|
|
1289
|
+
const updated = experiments.map(e => e.id === experimentId ? { ...e, approvalStatus: status } : e);
|
|
1290
|
+
writeFileSync(EXPERIMENT_LOG, updated.map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
1291
|
+
}
|
|
1292
|
+
// ── State management ─────────────────────────────────────────────
|
|
1293
|
+
loadState() {
|
|
1294
|
+
if (existsSync(STATE_FILE)) {
|
|
1295
|
+
try {
|
|
1296
|
+
return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
|
|
1297
|
+
}
|
|
1298
|
+
catch { /* fall through to default */ }
|
|
1299
|
+
}
|
|
1300
|
+
return {
|
|
1301
|
+
status: 'idle',
|
|
1302
|
+
lastRunAt: '',
|
|
1303
|
+
currentIteration: 0,
|
|
1304
|
+
totalExperiments: 0,
|
|
1305
|
+
baselineMetrics: {
|
|
1306
|
+
feedbackPositiveRatio: 0,
|
|
1307
|
+
cronSuccessRate: 0,
|
|
1308
|
+
avgResponseQuality: 0,
|
|
1309
|
+
},
|
|
1310
|
+
pendingApprovals: 0,
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
/** Reconcile pendingApprovals counter with actual pending-changes/ directory. */
|
|
1314
|
+
reconcileState() {
|
|
1315
|
+
const state = this.loadState();
|
|
1316
|
+
const actualPending = this.getPendingChanges().length;
|
|
1317
|
+
if (state.pendingApprovals !== actualPending) {
|
|
1318
|
+
logger.warn({ stored: state.pendingApprovals, actual: actualPending }, 'Pending approvals counter drift — reconciling');
|
|
1319
|
+
state.pendingApprovals = actualPending;
|
|
1320
|
+
this.saveState(state);
|
|
1321
|
+
}
|
|
1322
|
+
return state;
|
|
1323
|
+
}
|
|
1324
|
+
/** Expire pending proposals older than APPROVAL_TTL_MS. */
|
|
1325
|
+
expireStaleProposals() {
|
|
1326
|
+
const pending = this.getPendingChanges();
|
|
1327
|
+
let expired = 0;
|
|
1328
|
+
const now = Date.now();
|
|
1329
|
+
for (const p of pending) {
|
|
1330
|
+
const createdAt = p.createdAt
|
|
1331
|
+
? new Date(p.createdAt).getTime()
|
|
1332
|
+
: new Date(p.finishedAt).getTime();
|
|
1333
|
+
if (now - createdAt > APPROVAL_TTL_MS) {
|
|
1334
|
+
this.updateExperimentStatus(p.id, 'expired');
|
|
1335
|
+
try {
|
|
1336
|
+
unlinkSync(path.join(PENDING_DIR, `${p.id}.json`));
|
|
1337
|
+
}
|
|
1338
|
+
catch { /* ignore */ }
|
|
1339
|
+
expired++;
|
|
1340
|
+
logger.info({ id: p.id, area: p.area, target: p.target }, 'Expired stale proposal');
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (expired > 0) {
|
|
1344
|
+
const state = this.loadState();
|
|
1345
|
+
state.pendingApprovals = Math.max(0, state.pendingApprovals - expired);
|
|
1346
|
+
this.saveState(state);
|
|
1347
|
+
}
|
|
1348
|
+
return expired;
|
|
1349
|
+
}
|
|
1350
|
+
/** Check impact of previously applied changes. Triggers auto-rollback on regression. */
|
|
1351
|
+
async checkAppliedImpact() {
|
|
1352
|
+
if (!existsSync(IMPACT_CHECKS_FILE))
|
|
1353
|
+
return;
|
|
1354
|
+
try {
|
|
1355
|
+
const lines = readFileSync(IMPACT_CHECKS_FILE, 'utf-8').trim().split('\n').filter(Boolean);
|
|
1356
|
+
const remaining = [];
|
|
1357
|
+
const now = Date.now();
|
|
1358
|
+
const state = this.loadState();
|
|
1359
|
+
for (const line of lines) {
|
|
1360
|
+
try {
|
|
1361
|
+
const check = JSON.parse(line);
|
|
1362
|
+
const appliedAt = new Date(check.appliedAt).getTime();
|
|
1363
|
+
if (now - appliedAt < check.checkAfterMs) {
|
|
1364
|
+
remaining.push(line);
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
// Measure current state for this area
|
|
1368
|
+
const metrics = await this.gatherMetrics();
|
|
1369
|
+
const currentFeedbackRatio = metrics.feedbackStats.total > 0
|
|
1370
|
+
? metrics.feedbackStats.positive / metrics.feedbackStats.total : 1;
|
|
1371
|
+
const impact = {
|
|
1372
|
+
type: 'impact',
|
|
1373
|
+
experimentId: check.experimentId,
|
|
1374
|
+
area: check.area,
|
|
1375
|
+
target: check.target,
|
|
1376
|
+
measuredAt: new Date().toISOString(),
|
|
1377
|
+
cronSuccessRate: metrics.cronSuccessRate,
|
|
1378
|
+
feedbackPositiveRatio: currentFeedbackRatio,
|
|
1379
|
+
};
|
|
1380
|
+
appendFileSync(EXPERIMENT_LOG, JSON.stringify(impact) + '\n');
|
|
1381
|
+
logger.info({ ...impact }, 'Impact measurement recorded');
|
|
1382
|
+
// Auto-rollback: check if metrics regressed significantly
|
|
1383
|
+
const baselineCron = state.baselineMetrics?.cronSuccessRate ?? 0;
|
|
1384
|
+
const baselineFeedback = state.baselineMetrics?.feedbackPositiveRatio ?? 0;
|
|
1385
|
+
const cronDrop = baselineCron > 0
|
|
1386
|
+
? (baselineCron - metrics.cronSuccessRate) / baselineCron : 0;
|
|
1387
|
+
const feedbackDrop = baselineFeedback > 0
|
|
1388
|
+
? (baselineFeedback - currentFeedbackRatio) / baselineFeedback : 0;
|
|
1389
|
+
if (cronDrop > REGRESSION_ROLLBACK_THRESHOLD || feedbackDrop > REGRESSION_ROLLBACK_THRESHOLD) {
|
|
1390
|
+
logger.warn({
|
|
1391
|
+
experimentId: check.experimentId,
|
|
1392
|
+
cronDrop: `${(cronDrop * 100).toFixed(1)}%`,
|
|
1393
|
+
feedbackDrop: `${(feedbackDrop * 100).toFixed(1)}%`,
|
|
1394
|
+
}, 'Regression detected — initiating auto-rollback');
|
|
1395
|
+
this.rollbackVersion(check.experimentId);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
catch {
|
|
1399
|
+
remaining.push(line);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
writeFileSync(IMPACT_CHECKS_FILE, remaining.length > 0 ? remaining.join('\n') + '\n' : '');
|
|
1403
|
+
}
|
|
1404
|
+
catch (err) {
|
|
1405
|
+
logger.warn({ err }, 'Impact check failed');
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
// ── Version Lineage ───────────────────────────────────────────────
|
|
1409
|
+
/** Load evolution version history. */
|
|
1410
|
+
loadVersions() {
|
|
1411
|
+
if (!existsSync(EVOLUTION_VERSIONS_FILE))
|
|
1412
|
+
return [];
|
|
1413
|
+
try {
|
|
1414
|
+
return JSON.parse(readFileSync(EVOLUTION_VERSIONS_FILE, 'utf-8'));
|
|
1415
|
+
}
|
|
1416
|
+
catch {
|
|
1417
|
+
return [];
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
/** Save evolution version history. */
|
|
1421
|
+
saveVersions(versions) {
|
|
1422
|
+
ensureDirs();
|
|
1423
|
+
writeFileSync(EVOLUTION_VERSIONS_FILE, JSON.stringify(versions, null, 2));
|
|
1424
|
+
}
|
|
1425
|
+
/** Record a version when a change is applied. */
|
|
1426
|
+
recordVersion(experimentId, area, target, rationale, beforeSnapshot) {
|
|
1427
|
+
const versions = this.loadVersions();
|
|
1428
|
+
// Find parent: most recent non-rolled-back version for the same target
|
|
1429
|
+
const parent = versions
|
|
1430
|
+
.filter(v => v.area === area && v.target === target && !v.rolledBack)
|
|
1431
|
+
.sort((a, b) => new Date(b.appliedAt).getTime() - new Date(a.appliedAt).getTime())[0];
|
|
1432
|
+
versions.push({
|
|
1433
|
+
experimentId,
|
|
1434
|
+
area,
|
|
1435
|
+
target,
|
|
1436
|
+
appliedAt: new Date().toISOString(),
|
|
1437
|
+
parentVersion: parent?.experimentId,
|
|
1438
|
+
rationale,
|
|
1439
|
+
beforeSnapshot,
|
|
1440
|
+
});
|
|
1441
|
+
// Keep at most 50 versions to prevent unbounded growth
|
|
1442
|
+
if (versions.length > 50) {
|
|
1443
|
+
versions.splice(0, versions.length - 50);
|
|
1444
|
+
}
|
|
1445
|
+
this.saveVersions(versions);
|
|
1446
|
+
}
|
|
1447
|
+
/** Rollback a specific version by restoring its beforeSnapshot. */
|
|
1448
|
+
rollbackVersion(experimentId) {
|
|
1449
|
+
const versions = this.loadVersions();
|
|
1450
|
+
const version = versions.find(v => v.experimentId === experimentId && !v.rolledBack);
|
|
1451
|
+
if (!version) {
|
|
1452
|
+
logger.warn({ experimentId }, 'No version found to rollback');
|
|
1453
|
+
return false;
|
|
1454
|
+
}
|
|
1455
|
+
const targetPath = this.resolveTargetPath(version.area, version.target);
|
|
1456
|
+
if (!targetPath) {
|
|
1457
|
+
logger.warn({ area: version.area, target: version.target }, 'Cannot resolve target path for rollback');
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
try {
|
|
1461
|
+
writeFileSync(targetPath, version.beforeSnapshot);
|
|
1462
|
+
version.rolledBack = true;
|
|
1463
|
+
version.rolledBackAt = new Date().toISOString();
|
|
1464
|
+
this.saveVersions(versions);
|
|
1465
|
+
this.updateExperimentStatus(experimentId, 'denied');
|
|
1466
|
+
// Log the rollback event
|
|
1467
|
+
appendFileSync(EXPERIMENT_LOG, JSON.stringify({
|
|
1468
|
+
type: 'rollback',
|
|
1469
|
+
experimentId,
|
|
1470
|
+
area: version.area,
|
|
1471
|
+
target: version.target,
|
|
1472
|
+
rolledBackAt: version.rolledBackAt,
|
|
1473
|
+
reason: 'Regression detected in post-change metrics',
|
|
1474
|
+
}) + '\n');
|
|
1475
|
+
logger.info({ experimentId, area: version.area, target: version.target }, 'Auto-rollback completed');
|
|
1476
|
+
return true;
|
|
1477
|
+
}
|
|
1478
|
+
catch (err) {
|
|
1479
|
+
logger.error({ err, experimentId }, 'Auto-rollback failed');
|
|
1480
|
+
return false;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
saveState(state) {
|
|
1484
|
+
ensureDirs();
|
|
1485
|
+
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
1486
|
+
}
|
|
1487
|
+
// ── Pending changes ──────────────────────────────────────────────
|
|
1488
|
+
getPendingChanges() {
|
|
1489
|
+
ensureDirs();
|
|
1490
|
+
if (!existsSync(PENDING_DIR))
|
|
1491
|
+
return [];
|
|
1492
|
+
return readdirSync(PENDING_DIR)
|
|
1493
|
+
.filter(f => f.endsWith('.json'))
|
|
1494
|
+
.map(f => {
|
|
1495
|
+
try {
|
|
1496
|
+
return JSON.parse(readFileSync(path.join(PENDING_DIR, f), 'utf-8'));
|
|
1497
|
+
}
|
|
1498
|
+
catch {
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
})
|
|
1502
|
+
.filter(Boolean);
|
|
1503
|
+
}
|
|
1504
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
1505
|
+
/** Analyze experiment history for success patterns and failed approaches. */
|
|
1506
|
+
analyzeExperimentPatterns(history) {
|
|
1507
|
+
if (history.length < 3)
|
|
1508
|
+
return '';
|
|
1509
|
+
const byArea = new Map();
|
|
1510
|
+
for (const e of history) {
|
|
1511
|
+
if (e.type === 'impact')
|
|
1512
|
+
continue; // skip impact records
|
|
1513
|
+
const entry = byArea.get(e.area) ?? { total: 0, accepted: 0, scoreSum: 0 };
|
|
1514
|
+
entry.total++;
|
|
1515
|
+
if (e.accepted)
|
|
1516
|
+
entry.accepted++;
|
|
1517
|
+
entry.scoreSum += e.score;
|
|
1518
|
+
byArea.set(e.area, entry);
|
|
1519
|
+
}
|
|
1520
|
+
const lines = ['## Experiment Pattern Analysis'];
|
|
1521
|
+
for (const [area, stats] of byArea) {
|
|
1522
|
+
const avg = (stats.scoreSum / stats.total * 10).toFixed(1);
|
|
1523
|
+
const rate = ((stats.accepted / stats.total) * 100).toFixed(0);
|
|
1524
|
+
lines.push(`- ${area}: ${stats.total} experiments, ${rate}% acceptance, avg ${avg}/10`);
|
|
1525
|
+
}
|
|
1526
|
+
// Read impact records from experiment log
|
|
1527
|
+
try {
|
|
1528
|
+
if (existsSync(EXPERIMENT_LOG)) {
|
|
1529
|
+
const allLines = readFileSync(EXPERIMENT_LOG, 'utf-8').trim().split('\n').filter(Boolean);
|
|
1530
|
+
const impacts = allLines
|
|
1531
|
+
.map(l => { try {
|
|
1532
|
+
return JSON.parse(l);
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
return null;
|
|
1536
|
+
} })
|
|
1537
|
+
.filter((r) => r?.type === 'impact')
|
|
1538
|
+
.slice(-5);
|
|
1539
|
+
if (impacts.length > 0) {
|
|
1540
|
+
lines.push('\n### Measured Impact of Past Applied Changes');
|
|
1541
|
+
for (const ir of impacts) {
|
|
1542
|
+
lines.push(`- ${ir.area}/${ir.target}: cron ${(ir.cronSuccessRate * 100).toFixed(0)}%, feedback ${(ir.feedbackPositiveRatio * 100).toFixed(0)}% positive (measured ${ir.measuredAt})`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
catch { /* non-fatal */ }
|
|
1548
|
+
// Identify consistently failed approaches
|
|
1549
|
+
const failedHypotheses = history
|
|
1550
|
+
.filter(e => !e.accepted && e.score < 0.3 && !e.type)
|
|
1551
|
+
.map(e => e.hypothesis.slice(0, 80));
|
|
1552
|
+
if (failedHypotheses.length > 0) {
|
|
1553
|
+
lines.push('\n### Approaches That Scored Poorly (avoid these)');
|
|
1554
|
+
for (const h of [...new Set(failedHypotheses)].slice(0, 5)) {
|
|
1555
|
+
lines.push(`- "${h}"`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return lines.join('\n');
|
|
1559
|
+
}
|
|
1560
|
+
/** Validate that a proposed change has valid syntax for its target area. */
|
|
1561
|
+
validateProposal(area, target, proposedChange) {
|
|
1562
|
+
return validateProposal(area, target, proposedChange);
|
|
1563
|
+
}
|
|
1564
|
+
resolveTargetPath(area, target) {
|
|
1565
|
+
switch (area) {
|
|
1566
|
+
case 'soul':
|
|
1567
|
+
return SOUL_FILE;
|
|
1568
|
+
case 'cron':
|
|
1569
|
+
return CRON_FILE;
|
|
1570
|
+
case 'workflow': {
|
|
1571
|
+
const name = target.endsWith('.md') ? target : `${target}.md`;
|
|
1572
|
+
return path.join(WORKFLOWS_DIR, name);
|
|
1573
|
+
}
|
|
1574
|
+
case 'agent': {
|
|
1575
|
+
return path.join(AGENTS_DIR, target, 'agent.md');
|
|
1576
|
+
}
|
|
1577
|
+
case 'source': {
|
|
1578
|
+
return path.join(PKG_DIR, 'src', target);
|
|
1579
|
+
}
|
|
1580
|
+
case 'communication':
|
|
1581
|
+
return AGENTS_FILE;
|
|
1582
|
+
case 'memory':
|
|
1583
|
+
return path.join(VAULT_DIR, '00-System', 'MEMORY.md');
|
|
1584
|
+
case 'goal': {
|
|
1585
|
+
// target = "{owner}/{goalSlug}" e.g. "clementine/book-10-demos-q2" or "ross-the-sdr/expand-pool"
|
|
1586
|
+
const [owner, goalSlug] = target.split('/');
|
|
1587
|
+
if (!goalSlug)
|
|
1588
|
+
return null; // need both owner and slug
|
|
1589
|
+
if (owner === 'clementine')
|
|
1590
|
+
return path.join(GOALS_DIR, `${goalSlug}.json`);
|
|
1591
|
+
return path.join(AGENTS_DIR, owner, 'goals', `${goalSlug}.json`);
|
|
1592
|
+
}
|
|
1593
|
+
default:
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
parseJsonResponse(text) {
|
|
1598
|
+
// Try to extract JSON from the response (may be wrapped in markdown code blocks)
|
|
1599
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
1600
|
+
if (!jsonMatch)
|
|
1601
|
+
return null;
|
|
1602
|
+
try {
|
|
1603
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1604
|
+
// Check for "no improvement needed" signal
|
|
1605
|
+
if (parsed.area === null)
|
|
1606
|
+
return null;
|
|
1607
|
+
return parsed;
|
|
1608
|
+
}
|
|
1609
|
+
catch {
|
|
1610
|
+
logger.warn({ text: text.slice(0, 200) }, 'Failed to parse JSON response');
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
async withTimeout(promise, ms) {
|
|
1615
|
+
let timer;
|
|
1616
|
+
const timeout = new Promise((resolve) => {
|
|
1617
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
1618
|
+
});
|
|
1619
|
+
try {
|
|
1620
|
+
const result = await Promise.race([promise, timeout]);
|
|
1621
|
+
return result;
|
|
1622
|
+
}
|
|
1623
|
+
finally {
|
|
1624
|
+
clearTimeout(timer);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
// ── Utility ──────────────────────────────────────────────────────────
|
|
1629
|
+
/** Validate that a proposed change has valid syntax for its target area. */
|
|
1630
|
+
/** Files that must never be modified by self-improvement (catastrophic blast radius or self-referential). */
|
|
1631
|
+
const SOURCE_BLOCKLIST = new Set([
|
|
1632
|
+
'config.ts',
|
|
1633
|
+
'types.ts',
|
|
1634
|
+
'gateway/router.ts',
|
|
1635
|
+
'gateway/lanes.ts',
|
|
1636
|
+
'gateway/heartbeat-scheduler.ts',
|
|
1637
|
+
'gateway/cron-scheduler.ts',
|
|
1638
|
+
'gateway/security-scanner.ts',
|
|
1639
|
+
'agent/self-improve.ts',
|
|
1640
|
+
'agent/safe-restart.ts',
|
|
1641
|
+
'agent/source-mods.ts',
|
|
1642
|
+
'cli/index.ts',
|
|
1643
|
+
'cli/dashboard.ts',
|
|
1644
|
+
'security/scanner.ts',
|
|
1645
|
+
]);
|
|
1646
|
+
export function validateProposal(area, target, proposedChange) {
|
|
1647
|
+
if (!proposedChange.trim()) {
|
|
1648
|
+
return { valid: false, error: 'Proposed change is empty' };
|
|
1649
|
+
}
|
|
1650
|
+
if (['soul', 'cron', 'workflow', 'agent', 'communication'].includes(area)) {
|
|
1651
|
+
try {
|
|
1652
|
+
matter(proposedChange);
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
return { valid: false, error: `YAML frontmatter parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
if (area === 'goal') {
|
|
1659
|
+
try {
|
|
1660
|
+
const parsed = JSON.parse(proposedChange);
|
|
1661
|
+
if (!parsed.title)
|
|
1662
|
+
return { valid: false, error: 'Goal proposal missing required "title" field' };
|
|
1663
|
+
}
|
|
1664
|
+
catch (err) {
|
|
1665
|
+
return { valid: false, error: `Goal proposal must be valid JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
if (area === 'cron') {
|
|
1669
|
+
try {
|
|
1670
|
+
const parsed = matter(proposedChange);
|
|
1671
|
+
if (parsed.data?.jobs && !Array.isArray(parsed.data.jobs)) {
|
|
1672
|
+
return { valid: false, error: 'CRON.md jobs field must be an array' };
|
|
1673
|
+
}
|
|
1674
|
+
if (Array.isArray(parsed.data?.jobs)) {
|
|
1675
|
+
for (const job of parsed.data.jobs) {
|
|
1676
|
+
if (!job.name || !job.schedule || !job.prompt) {
|
|
1677
|
+
return { valid: false, error: `Cron job missing required fields (name/schedule/prompt): ${JSON.stringify(job).slice(0, 100)}` };
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
catch (err) {
|
|
1683
|
+
return { valid: false, error: `CRON.md validation failed: ${err}` };
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (area === 'source') {
|
|
1687
|
+
// Check blocklist
|
|
1688
|
+
if (SOURCE_BLOCKLIST.has(target)) {
|
|
1689
|
+
return { valid: false, error: `Source file '${target}' is in the blocklist and cannot be modified by self-improvement` };
|
|
1690
|
+
}
|
|
1691
|
+
// Size sanity: reject wholesale rewrites (proposed content > 2x original would be caught by caller)
|
|
1692
|
+
// Check basic TypeScript structure: must contain at least one import or export
|
|
1693
|
+
if (!proposedChange.includes('import ') && !proposedChange.includes('export ')) {
|
|
1694
|
+
return { valid: false, error: 'Source proposal missing import/export statements — likely not valid TypeScript' };
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return { valid: true };
|
|
1698
|
+
}
|
|
1699
|
+
function ensureDirs() {
|
|
1700
|
+
for (const dir of [SELF_IMPROVE_DIR, PENDING_DIR]) {
|
|
1701
|
+
if (!existsSync(dir)) {
|
|
1702
|
+
mkdirSync(dir, { recursive: true });
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
//# sourceMappingURL=self-improve.js.map
|