principles-disciple 1.51.0 → 1.53.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/compile-principles.mjs +11 -1
- package/scripts/sync-plugin.mjs +183 -19
- package/src/core/bootstrap-rules.ts +41 -4
- package/src/core/evolution-hook.ts +74 -0
- package/src/core/file-storage-adapter.ts +203 -0
- package/src/core/init.ts +29 -2
- package/src/core/nocturnal-trinity.ts +230 -0
- package/src/core/observability.ts +242 -0
- package/src/core/pain-signal-adapter.ts +42 -0
- package/src/core/pain-signal.ts +136 -0
- package/src/core/pd-task-reconciler.ts +14 -11
- package/src/core/principle-injection.ts +208 -0
- package/src/core/principle-injector.ts +84 -0
- package/src/core/storage-adapter.ts +65 -0
- package/src/core/telemetry-event.ts +109 -0
- package/src/hooks/prompt.ts +33 -5
- package/src/service/event-log-auditor.ts +52 -39
- package/src/service/evolution-worker.ts +52 -2
- package/tests/core/evolution-hook.test.ts +123 -0
- package/tests/core/file-storage-adapter.test.ts +285 -0
- package/tests/core/nocturnal-trinity.test.ts +236 -0
- package/tests/core/observability.test.ts +383 -0
- package/tests/core/pain-signal-adapter.test.ts +116 -0
- package/tests/core/pain-signal.test.ts +190 -0
- package/tests/core/principle-injection.test.ts +223 -0
- package/tests/core/principle-injector.test.ts +90 -0
- package/tests/core/storage-conformance.test.ts +429 -0
- package/tests/core/telemetry-event.test.ts +119 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -18,10 +18,20 @@ import { fileURLToPath } from 'url';
|
|
|
18
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
19
|
const __dirname = dirname(__filename);
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Cross-platform home directory (Linux: HOME, Windows: USERPROFILE/HOMEDRIVE+HOMEPATH)
|
|
23
|
+
*/
|
|
24
|
+
function getHomeDir() {
|
|
25
|
+
return process.env.HOME
|
|
26
|
+
|| process.env.USERPROFILE
|
|
27
|
+
|| (process.env.HOMEDRIVE && process.env.HOMEPATH ? process.env.HOMEDRIVE + process.env.HOMEPATH : null)
|
|
28
|
+
|| '.';
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
// Resolve workspace directory: CLI arg > env var > default
|
|
22
32
|
const WORKSPACE_DIR = process.argv[2]
|
|
23
33
|
|| process.env.WORKSPACE_DIR
|
|
24
|
-
|| join(
|
|
34
|
+
|| join(getHomeDir(), '.openclaw', 'workspace');
|
|
25
35
|
|
|
26
36
|
const STATE_DIR = join(WORKSPACE_DIR, '.state');
|
|
27
37
|
|
package/scripts/sync-plugin.mjs
CHANGED
|
@@ -27,8 +27,36 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
27
27
|
const __dirname = dirname(__filename);
|
|
28
28
|
|
|
29
29
|
const SOURCE_DIR = join(__dirname, '..');
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cross-platform home directory resolution.
|
|
33
|
+
* Linux/macOS: HOME=/home/user
|
|
34
|
+
* Windows: USERPROFILE=C:\Users\user or HOMEDRIVE/HOMEPATH
|
|
35
|
+
*/
|
|
36
|
+
function getHomeDir() {
|
|
37
|
+
return process.env.HOME
|
|
38
|
+
|| process.env.USERPROFILE
|
|
39
|
+
|| (process.env.HOMEDRIVE && process.env.HOMEPATH ? process.env.HOMEDRIVE + process.env.HOMEPATH : null)
|
|
40
|
+
|| '.';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const OPENCLAW_DIR = join(getHomeDir(), '.openclaw');
|
|
31
44
|
const INSTALL_DIR = join(OPENCLAW_DIR, 'extensions', 'principles-disciple');
|
|
45
|
+
function getConfiguredWorkspaceDir() {
|
|
46
|
+
const configPath = join(OPENCLAW_DIR, 'openclaw.json');
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
49
|
+
const config = JSON.parse(raw);
|
|
50
|
+
const workspace = config?.agents?.defaults?.workspace;
|
|
51
|
+
if (workspace && existsSync(workspace)) {
|
|
52
|
+
return workspace;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Fall through to fallback
|
|
56
|
+
}
|
|
57
|
+
// Fallback: try workspace-main (legacy default)
|
|
58
|
+
return join(OPENCLAW_DIR, 'workspace-main');
|
|
59
|
+
}
|
|
32
60
|
|
|
33
61
|
// Files and directories to sync
|
|
34
62
|
const SYNC_ITEMS = [
|
|
@@ -177,7 +205,11 @@ function checkPrerequisites() {
|
|
|
177
205
|
// Check for global package conflicts that cause module resolution traps
|
|
178
206
|
console.log('🔍 Checking for global package conflicts...');
|
|
179
207
|
try {
|
|
180
|
-
|
|
208
|
+
// Cross-platform: use stdio: 'pipe' to capture stderr, then check output
|
|
209
|
+
const globalConflict = execSync('npm list -g principles-disciple --depth=0', {
|
|
210
|
+
encoding: 'utf-8',
|
|
211
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Capture all streams
|
|
212
|
+
});
|
|
181
213
|
if (globalConflict.includes('principles-disciple')) {
|
|
182
214
|
console.error('\n❌ CONFLICT DETECTED: A version of "principles-disciple" is installed globally via npm.');
|
|
183
215
|
console.error('This will block OpenClaw from loading the extension version you are trying to install.');
|
|
@@ -187,7 +219,17 @@ function checkPrerequisites() {
|
|
|
187
219
|
}
|
|
188
220
|
} catch (e) {
|
|
189
221
|
// npm list returns non-zero if not found, which is what we want
|
|
190
|
-
|
|
222
|
+
// Check if the error output contains the package name
|
|
223
|
+
const output = e.stdout || e.stderr || '';
|
|
224
|
+
if (!output.includes('principles-disciple')) {
|
|
225
|
+
console.log('✅ No global package conflicts detected.');
|
|
226
|
+
} else {
|
|
227
|
+
console.error('\n❌ CONFLICT DETECTED: A version of "principles-disciple" is installed globally via npm.');
|
|
228
|
+
console.error('This will block OpenClaw from loading the extension version you are trying to install.');
|
|
229
|
+
console.error('\nACTION REQUIRED: Please run the following command first:');
|
|
230
|
+
console.error(' npm uninstall -g principles-disciple\n');
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
191
233
|
}
|
|
192
234
|
} catch {
|
|
193
235
|
console.error('❌ npm not found. Please install Node.js with npm.');
|
|
@@ -481,7 +523,7 @@ function verifyInstalledFingerprint() {
|
|
|
481
523
|
}
|
|
482
524
|
|
|
483
525
|
/**
|
|
484
|
-
* Remove existing installation directory.
|
|
526
|
+
* Remove existing installation directory with Windows-friendly retry logic.
|
|
485
527
|
*/
|
|
486
528
|
function cleanTargetDir(force) {
|
|
487
529
|
if (!existsSync(INSTALL_DIR)) return;
|
|
@@ -495,7 +537,34 @@ function cleanTargetDir(force) {
|
|
|
495
537
|
}
|
|
496
538
|
|
|
497
539
|
console.log('\n🗑️ Removing existing installation...');
|
|
498
|
-
|
|
540
|
+
|
|
541
|
+
// Windows often returns EPERM due to file locks, add retry logic
|
|
542
|
+
const maxRetries = isWindows() ? 3 : 1;
|
|
543
|
+
let lastError = null;
|
|
544
|
+
|
|
545
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
546
|
+
try {
|
|
547
|
+
rmSync(INSTALL_DIR, { recursive: true, force: true });
|
|
548
|
+
console.log(' ✅ Removed successfully.');
|
|
549
|
+
return;
|
|
550
|
+
} catch (err) {
|
|
551
|
+
lastError = err;
|
|
552
|
+
if (err.code === 'EPERM' && attempt < maxRetries) {
|
|
553
|
+
console.log(` ⚠️ Attempt ${attempt}/${maxRetries} failed (EPERM), retrying in 2s...`);
|
|
554
|
+
// Synchronous sleep for retry
|
|
555
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// If all retries failed on Windows, try graceful fallback
|
|
561
|
+
if (isWindows() && lastError?.code === 'EPERM') {
|
|
562
|
+
console.log(' ⚠️ Windows file lock detected, skipping removal.');
|
|
563
|
+
console.log(' 📁 Will overwrite files in place.');
|
|
564
|
+
return; // Continue with overwrite installation
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
throw lastError;
|
|
499
568
|
}
|
|
500
569
|
|
|
501
570
|
/**
|
|
@@ -573,17 +642,115 @@ function cleanStaleBackups() {
|
|
|
573
642
|
}
|
|
574
643
|
|
|
575
644
|
/**
|
|
576
|
-
*
|
|
645
|
+
* Check if running on Windows.
|
|
646
|
+
*/
|
|
647
|
+
function isWindows() {
|
|
648
|
+
return process.platform === 'win32';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Get temporary directory path (cross-platform).
|
|
653
|
+
*/
|
|
654
|
+
function getTempDir() {
|
|
655
|
+
if (isWindows()) {
|
|
656
|
+
return process.env.TEMP || process.env.TMP || 'C:\\Windows\\Temp';
|
|
657
|
+
}
|
|
658
|
+
return '/tmp';
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Restart OpenClaw Gateway (cross-platform).
|
|
577
663
|
*/
|
|
578
664
|
function restartGateway() {
|
|
579
665
|
console.log('\n🔄 Restarting OpenClaw Gateway...');
|
|
666
|
+
|
|
667
|
+
if (isWindows()) {
|
|
668
|
+
return restartGatewayWindows();
|
|
669
|
+
} else {
|
|
670
|
+
return restartGatewayLinux();
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Restart Gateway on Windows using PowerShell.
|
|
676
|
+
*/
|
|
677
|
+
function restartGatewayWindows() {
|
|
678
|
+
const logPath = join(getTempDir(), 'openclaw-auto-restart.log');
|
|
679
|
+
|
|
580
680
|
try {
|
|
681
|
+
// Step 1: Find and terminate existing gateway processes
|
|
682
|
+
console.log(' Looking for existing gateway processes...');
|
|
683
|
+
try {
|
|
684
|
+
// PowerShell command to find and kill openclaw gateway processes
|
|
685
|
+
// Note: Use single quotes inside -like pattern for proper escaping
|
|
686
|
+
const findCmd = "Get-Process -Name 'node' -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*openclaw*' } | Select-Object -ExpandProperty Id";
|
|
687
|
+
const pids = execSync(`powershell -NoProfile -Command "${findCmd}"`, { encoding: 'utf-8' }).trim();
|
|
688
|
+
|
|
689
|
+
if (pids) {
|
|
690
|
+
console.log(` Terminating existing gateway process(es): ${pids.replace(/\n/g, ', ')}...`);
|
|
691
|
+
// Kill by PID
|
|
692
|
+
const pidList = pids.split('\n').filter(p => p.trim());
|
|
693
|
+
for (const pid of pidList) {
|
|
694
|
+
try {
|
|
695
|
+
execSync(`taskkill /PID ${pid.trim()} /F`, { stdio: 'pipe' });
|
|
696
|
+
} catch { /* ignore if process already gone */ }
|
|
697
|
+
}
|
|
698
|
+
// Wait a moment for process to terminate
|
|
699
|
+
execSync('timeout /t 3 /nobreak > nul', { shell: true, stdio: 'ignore' });
|
|
700
|
+
}
|
|
701
|
+
} catch { /* no existing processes */ }
|
|
702
|
+
|
|
703
|
+
// Step 2: Start new gateway process in background
|
|
704
|
+
console.log(` Starting new gateway (logs: ${logPath})...`);
|
|
705
|
+
|
|
706
|
+
// Use openclaw CLI to start gateway (more reliable than direct node invocation)
|
|
707
|
+
const gatewayCmd = join(getHomeDir(), '.openclaw', 'gateway.cmd');
|
|
708
|
+
const startCmd = `Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${gatewayCmd}' -WindowStyle Hidden -RedirectStandardOutput '${logPath}' -RedirectStandardError '${join(getTempDir(), 'openclaw-auto-restart.err')}'`;
|
|
709
|
+
execSync(`powershell -NoProfile -Command "${startCmd}"`, { stdio: 'inherit' });
|
|
710
|
+
console.log('✅ Gateway restart triggered.');
|
|
711
|
+
|
|
712
|
+
// Step 3: Wait and verify
|
|
713
|
+
setTimeout(() => {
|
|
714
|
+
try {
|
|
715
|
+
if (existsSync(logPath)) {
|
|
716
|
+
const logs = readFileSync(logPath, 'utf-8');
|
|
717
|
+
if (logs.includes('Principles Disciple Plugin registered')) {
|
|
718
|
+
console.log('✅ SUCCESS: Principles Disciple plugin registered successfully!');
|
|
719
|
+
} else if (logs.includes('failed to load') || logs.includes('Error: Cannot find module')) {
|
|
720
|
+
console.error('\n❌ CRITICAL: Gateway started but PD plugin FAILED to load!');
|
|
721
|
+
console.error(' Check logs at: ' + logPath);
|
|
722
|
+
process.exit(1);
|
|
723
|
+
} else {
|
|
724
|
+
console.warn('⚠️ Gateway started but PD registration not confirmed in recent logs.');
|
|
725
|
+
console.log(' Check logs at: ' + logPath);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} catch (e) {
|
|
729
|
+
console.warn(`⚠️ Post-restart verification skipped: ${e.message}`);
|
|
730
|
+
}
|
|
731
|
+
}, 8000);
|
|
732
|
+
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error(`\n❌ Failed to restart gateway: ${error.message}`);
|
|
735
|
+
console.error(' You may need to manually restart OpenClaw Gateway.');
|
|
736
|
+
process.exit(1);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Restart Gateway on Linux using systemctl or process management.
|
|
742
|
+
*/
|
|
743
|
+
function restartGatewayLinux() {
|
|
744
|
+
const logPath = '/tmp/openclaw-auto-restart.log';
|
|
745
|
+
|
|
746
|
+
try {
|
|
747
|
+
// Try systemctl first (Linux systemd)
|
|
581
748
|
try {
|
|
582
749
|
execSync('systemctl --user is-active openclaw-gateway.service', { stdio: 'pipe' });
|
|
583
750
|
console.log(' Restarting via systemctl...');
|
|
584
751
|
execSync('systemctl --user restart openclaw-gateway.service', { stdio: 'inherit' });
|
|
585
752
|
console.log('✅ Gateway restarted via systemctl.');
|
|
586
|
-
|
|
753
|
+
|
|
587
754
|
console.log(' Waiting for Gateway to initialize and load PD plugin (8s)...');
|
|
588
755
|
setTimeout(() => {
|
|
589
756
|
try {
|
|
@@ -608,8 +775,9 @@ function restartGateway() {
|
|
|
608
775
|
}
|
|
609
776
|
}, 8000);
|
|
610
777
|
return;
|
|
611
|
-
} catch { /*
|
|
778
|
+
} catch { /* systemctl not available, fall through to manual restart */ }
|
|
612
779
|
|
|
780
|
+
// Manual process management
|
|
613
781
|
const pids = execSync('pgrep -f "openclaw-gateway|openclaw gateway"', { encoding: 'utf-8' }).trim();
|
|
614
782
|
if (pids) {
|
|
615
783
|
console.log(` Terminating existing gateway process(es)...`);
|
|
@@ -617,11 +785,10 @@ function restartGateway() {
|
|
|
617
785
|
execSync('sleep 3');
|
|
618
786
|
}
|
|
619
787
|
|
|
620
|
-
const logPath = '/tmp/openclaw-auto-restart.log';
|
|
621
788
|
console.log(` Starting new gateway (logs: ${logPath})...`);
|
|
622
789
|
execSync(`nohup openclaw gateway --force > ${logPath} 2>&1 &`, { stdio: 'ignore' });
|
|
623
790
|
console.log('✅ Gateway restart triggered.');
|
|
624
|
-
|
|
791
|
+
|
|
625
792
|
setTimeout(() => {
|
|
626
793
|
if (existsSync(logPath)) {
|
|
627
794
|
const logs = readFileSync(logPath, 'utf-8');
|
|
@@ -699,9 +866,10 @@ function main() {
|
|
|
699
866
|
if (existsSync(bootstrapScript)) {
|
|
700
867
|
console.log('\n🧠 Synchronizing principles to active rules (Bootstrap)...');
|
|
701
868
|
try {
|
|
702
|
-
const
|
|
869
|
+
const workspaceDir = getConfiguredWorkspaceDir();
|
|
870
|
+
const targetStateDir = join(workspaceDir, '.state');
|
|
703
871
|
if (existsSync(targetStateDir)) {
|
|
704
|
-
execSync(`
|
|
872
|
+
execSync(`node scripts/bootstrap-rules.mjs`, { cwd: SOURCE_DIR, stdio: 'inherit', env: { ...process.env, STATE_DIR: targetStateDir, BOOTSTRAP_LIMIT: '100' } });
|
|
705
873
|
console.log('✅ Principles synchronized.');
|
|
706
874
|
}
|
|
707
875
|
} catch (e) {
|
|
@@ -713,7 +881,8 @@ function main() {
|
|
|
713
881
|
if (existsSync(compileScript)) {
|
|
714
882
|
console.log('\n⚙️ Compiling pain-derived principles into rules...');
|
|
715
883
|
try {
|
|
716
|
-
const
|
|
884
|
+
const workspaceDir = getConfiguredWorkspaceDir();
|
|
885
|
+
const targetWorkspaceDir = workspaceDir;
|
|
717
886
|
if (existsSync(targetWorkspaceDir)) {
|
|
718
887
|
execSync(`node scripts/compile-principles.mjs ${targetWorkspaceDir}`, { cwd: SOURCE_DIR, stdio: 'inherit' });
|
|
719
888
|
console.log('✅ Principle compilation complete.');
|
|
@@ -732,12 +901,6 @@ function main() {
|
|
|
732
901
|
verifyInstalledFingerprint();
|
|
733
902
|
if (args.dev || args.restart) cleanStaleBackups();
|
|
734
903
|
|
|
735
|
-
try {
|
|
736
|
-
const reloadSignal = join(OPENCLAW_DIR, '.plugin_reload_signal');
|
|
737
|
-
writeFileSync(reloadSignal, new Date().toISOString(), 'utf-8');
|
|
738
|
-
console.log(`\n🔔 Reload signal sent to ${reloadSignal}`);
|
|
739
|
-
} catch { /* ignore */ }
|
|
740
|
-
|
|
741
904
|
console.log('\n╔════════════════════════════════════════════════════════════╗');
|
|
742
905
|
console.log('║ ✅ Installation Complete ║');
|
|
743
906
|
console.log('╚════════════════════════════════════════════════════════════╝');
|
|
@@ -746,6 +909,7 @@ function main() {
|
|
|
746
909
|
restartGateway();
|
|
747
910
|
} else {
|
|
748
911
|
console.log('\n💡 Restart OpenClaw Gateway to load the new version.');
|
|
912
|
+
console.log(' (Plugin code changes require a full gateway restart)');
|
|
749
913
|
}
|
|
750
914
|
}
|
|
751
915
|
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* npm run bootstrap-rules (production)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { loadLedger, createRule, updatePrinciple } from './principle-tree-ledger.js';
|
|
16
|
+
import { loadLedger, createRule, updatePrinciple, addPrincipleToLedger } from './principle-tree-ledger.js';
|
|
17
|
+
import type { LedgerPrinciple } from './principle-tree-ledger.js';
|
|
17
18
|
import { loadStore } from './principle-training-state.js';
|
|
19
|
+
import { CORE_THINKING_MODELS } from './init.js';
|
|
18
20
|
|
|
19
21
|
export interface BootstrapResult {
|
|
20
22
|
principleId: string;
|
|
@@ -77,12 +79,47 @@ export function selectPrinciplesForBootstrap(stateDir: string, limit = 3): strin
|
|
|
77
79
|
* @throws Error if no deterministic principles found
|
|
78
80
|
*/
|
|
79
81
|
export function bootstrapRules(stateDir: string, limit = 3): BootstrapResult[] {
|
|
82
|
+
// Migration: if T-01..T-10 exist in Training Store but not in Ledger Tree, backfill.
|
|
83
|
+
// This handles workspaces initialized before Ledger Tree was added.
|
|
84
|
+
const store = loadStore(stateDir);
|
|
85
|
+
const ledger = loadLedger(stateDir);
|
|
86
|
+
const hasTrainingT = Object.keys(store).some((id) => id.startsWith('T-'));
|
|
87
|
+
const hasAnyLedgerT = Object.keys(ledger.tree.principles).some((id) => id.startsWith('T-'));
|
|
88
|
+
if (hasTrainingT && !hasAnyLedgerT) {
|
|
89
|
+
console.warn('[bootstrap] Migrating T-01..T-10 from Training Store to Ledger Tree');
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
for (const [id, entry] of Object.entries(store)) {
|
|
92
|
+
if (!id.startsWith('T-')) continue;
|
|
93
|
+
const model = CORE_THINKING_MODELS.find((m) => m.id === id);
|
|
94
|
+
if (!model) continue;
|
|
95
|
+
const lp: LedgerPrinciple = {
|
|
96
|
+
id,
|
|
97
|
+
version: 1,
|
|
98
|
+
text: model.description,
|
|
99
|
+
coreAxiomId: id,
|
|
100
|
+
triggerPattern: '',
|
|
101
|
+
action: '',
|
|
102
|
+
status: 'active',
|
|
103
|
+
priority: 'P1',
|
|
104
|
+
scope: 'general',
|
|
105
|
+
evaluability: entry.evaluability,
|
|
106
|
+
valueScore: 0,
|
|
107
|
+
adherenceRate: 0,
|
|
108
|
+
painPreventedCount: 0,
|
|
109
|
+
derivedFromPainIds: [],
|
|
110
|
+
ruleIds: [],
|
|
111
|
+
conflictsWithPrincipleIds: [],
|
|
112
|
+
createdAt: now,
|
|
113
|
+
updatedAt: now,
|
|
114
|
+
suggestedRules: [],
|
|
115
|
+
};
|
|
116
|
+
addPrincipleToLedger(stateDir, lp);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
80
120
|
// Select principles for bootstrap
|
|
81
121
|
const selectedPrincipleIds = selectPrinciplesForBootstrap(stateDir, limit);
|
|
82
122
|
|
|
83
|
-
// Load current ledger state
|
|
84
|
-
const ledger = loadLedger(stateDir);
|
|
85
|
-
|
|
86
123
|
const results: BootstrapResult[] = [];
|
|
87
124
|
|
|
88
125
|
for (const principleId of selectedPrincipleIds) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EvolutionHook interface for the Evolution SDK.
|
|
3
|
+
*
|
|
4
|
+
* Provides a callback-based interface for observing evolution lifecycle
|
|
5
|
+
* events: pain detection, principle creation, and principle promotion.
|
|
6
|
+
*
|
|
7
|
+
* Per D-03, this interface contains only the 3 core event methods.
|
|
8
|
+
* Per D-04, consumers implement the interface directly (no EventEmitter).
|
|
9
|
+
* Hooks not needed can use the provided noOpEvolutionHook and override
|
|
10
|
+
* individual methods.
|
|
11
|
+
*/
|
|
12
|
+
import type { PainSignal } from './pain-signal.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Event Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Event payload for principle creation lifecycle events. */
|
|
19
|
+
export interface PrincipleCreatedEvent {
|
|
20
|
+
/** Unique principle identifier */
|
|
21
|
+
id: string;
|
|
22
|
+
/** Principle text ("When X, then Y.") */
|
|
23
|
+
text: string;
|
|
24
|
+
/** What triggered this principle's creation */
|
|
25
|
+
trigger: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Event payload for principle promotion lifecycle events. */
|
|
29
|
+
export interface PrinciplePromotedEvent {
|
|
30
|
+
/** Unique principle identifier */
|
|
31
|
+
id: string;
|
|
32
|
+
/** Previous status tier */
|
|
33
|
+
from: string;
|
|
34
|
+
/** New status tier */
|
|
35
|
+
to: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// EvolutionHook Interface
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Callback interface for observing evolution lifecycle events.
|
|
44
|
+
*
|
|
45
|
+
* Implement all 3 methods, or spread noOpEvolutionHook and override
|
|
46
|
+
* only the methods you need:
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* const myHook: EvolutionHook = {
|
|
51
|
+
* ...noOpEvolutionHook,
|
|
52
|
+
* onPainDetected(signal) { console.log(signal); },
|
|
53
|
+
* };
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export interface EvolutionHook {
|
|
57
|
+
/** Called when a pain signal is detected and recorded. */
|
|
58
|
+
onPainDetected(signal: PainSignal): void;
|
|
59
|
+
/** Called when a new principle candidate is created. */
|
|
60
|
+
onPrincipleCreated(event: PrincipleCreatedEvent): void;
|
|
61
|
+
/** Called when a principle is promoted to a higher tier. */
|
|
62
|
+
onPrinciplePromoted(event: PrinciplePromotedEvent): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// No-op Helper
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** No-op implementation -- consumers can spread and override individual methods. */
|
|
70
|
+
export const noOpEvolutionHook: EvolutionHook = {
|
|
71
|
+
onPainDetected(_signal: PainSignal): void {},
|
|
72
|
+
onPrincipleCreated(_event: PrincipleCreatedEvent): void {},
|
|
73
|
+
onPrinciplePromoted(_event: PrinciplePromotedEvent): void {},
|
|
74
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileStorageAdapter — file-backed implementation of StorageAdapter.
|
|
3
|
+
*
|
|
4
|
+
* Wraps principle-tree-ledger functions with the async StorageAdapter
|
|
5
|
+
* contract. Uses withLockAsync for thread-safe mutateLedger with
|
|
6
|
+
* retry with exponential backoff for lock acquisition (5 retries).
|
|
7
|
+
* Write failures are logged via SystemLogger and re-thrown.
|
|
8
|
+
*
|
|
9
|
+
* Guarantees:
|
|
10
|
+
* - Atomic writes via atomicWriteFileSync (temp + rename)
|
|
11
|
+
* - Thread-safe concurrent access via file locks
|
|
12
|
+
* - Consistent read-after-write visibility
|
|
13
|
+
* - Write failures logged to SystemLogger and re-thrown
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import type { StorageAdapter } from './storage-adapter.js';
|
|
18
|
+
import type { HybridLedgerStore } from './principle-tree-ledger.js';
|
|
19
|
+
import { TREE_NAMESPACE } from './principle-tree-ledger.js';
|
|
20
|
+
import {
|
|
21
|
+
loadLedger as loadLedgerFromFile,
|
|
22
|
+
saveLedgerAsync,
|
|
23
|
+
} from './principle-tree-ledger.js';
|
|
24
|
+
import { withLockAsync, type LockOptions, LockAcquisitionError } from '../utils/file-lock.js';
|
|
25
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
26
|
+
import { SystemLogger } from './system-logger.js';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Configuration
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** Maximum retries for lock acquisition in mutateLedger. */
|
|
33
|
+
const MUTATE_RETRY_COUNT = 5;
|
|
34
|
+
|
|
35
|
+
/** Base delay in ms for exponential backoff between retries. */
|
|
36
|
+
const MUTATE_BACKOFF_BASE_MS = 50;
|
|
37
|
+
|
|
38
|
+
/** Maximum backoff delay in ms. */
|
|
39
|
+
const MUTATE_BACKOFF_MAX_MS = 500;
|
|
40
|
+
|
|
41
|
+
const PRINCIPLE_TRAINING_FILE = 'principle_training_state.json';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Internal helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Serialize the hybrid ledger store to JSON.
|
|
49
|
+
* Mirrors the unexported serializeLedger from principle-tree-ledger.ts.
|
|
50
|
+
*/
|
|
51
|
+
function serializeStore(store: HybridLedgerStore): string {
|
|
52
|
+
return JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
...store.trainingStore,
|
|
55
|
+
[TREE_NAMESPACE]: {
|
|
56
|
+
...store.tree,
|
|
57
|
+
lastUpdated: new Date().toISOString(),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
null,
|
|
61
|
+
2,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Ensure the parent directory exists before writing. */
|
|
66
|
+
function ensureParentDir(filePath: string): void {
|
|
67
|
+
const dir = path.dirname(filePath);
|
|
68
|
+
if (!fs.existsSync(dir)) {
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// FileStorageAdapter
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* File-system backed storage adapter for the principle ledger.
|
|
79
|
+
*
|
|
80
|
+
* Delegates read/write operations to principle-tree-ledger while providing
|
|
81
|
+
* the async StorageAdapter interface. The mutateLedger method uses
|
|
82
|
+
* withLockAsync with exponential backoff retry for robust concurrent access.
|
|
83
|
+
*/
|
|
84
|
+
export class FileStorageAdapter implements StorageAdapter {
|
|
85
|
+
private readonly stateDir: string;
|
|
86
|
+
private readonly workspaceDir: string | undefined;
|
|
87
|
+
|
|
88
|
+
constructor(stateDir: string, workspaceDir?: string) {
|
|
89
|
+
this.stateDir = stateDir;
|
|
90
|
+
this.workspaceDir = workspaceDir;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve the ledger file path for this state directory. */
|
|
94
|
+
private get filePath(): string {
|
|
95
|
+
return path.join(this.stateDir, PRINCIPLE_TRAINING_FILE);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load the current ledger state from the file system.
|
|
100
|
+
*
|
|
101
|
+
* Returns an empty store if no persisted state exists (first run).
|
|
102
|
+
* Uses the synchronous loadLedger from principle-tree-ledger which
|
|
103
|
+
* handles missing/corrupted files gracefully.
|
|
104
|
+
*/
|
|
105
|
+
async loadLedger(): Promise<HybridLedgerStore> {
|
|
106
|
+
return loadLedgerFromFile(this.stateDir);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Persist the full ledger state atomically.
|
|
111
|
+
*
|
|
112
|
+
* Delegates to principle-tree-ledger's saveLedgerAsync which uses
|
|
113
|
+
* withLockAsync internally. Logs failures via SystemLogger.
|
|
114
|
+
*/
|
|
115
|
+
async saveLedger(store: HybridLedgerStore): Promise<void> {
|
|
116
|
+
try {
|
|
117
|
+
await saveLedgerAsync(this.stateDir, store);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
SystemLogger.log(
|
|
120
|
+
this.workspaceDir,
|
|
121
|
+
'STORAGE_WRITE_FAILED',
|
|
122
|
+
`FileStorageAdapter.saveLedger failed: ${String(err)}`,
|
|
123
|
+
);
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Perform a read-modify-write cycle with automatic locking and retry.
|
|
130
|
+
*
|
|
131
|
+
* Uses withLockAsync to acquire a file lock, reads the current store,
|
|
132
|
+
* applies the mutate function, then writes the modified store atomically.
|
|
133
|
+
* On lock acquisition failure, retries up to MUTATE_RETRY_COUNT (5) times
|
|
134
|
+
* with exponential backoff + jitter to reduce contention.
|
|
135
|
+
*
|
|
136
|
+
* Write failures are logged to SystemLogger and re-thrown so callers
|
|
137
|
+
* can decide how to handle persistence errors.
|
|
138
|
+
*/
|
|
139
|
+
async mutateLedger<T>(mutate: (store: HybridLedgerStore) => T | Promise<T>): Promise<T> {
|
|
140
|
+
let lastError: Error | undefined;
|
|
141
|
+
|
|
142
|
+
for (let attempt = 0; attempt < MUTATE_RETRY_COUNT; attempt++) {
|
|
143
|
+
try {
|
|
144
|
+
const lockOptions: LockOptions = {
|
|
145
|
+
maxRetries: 3,
|
|
146
|
+
baseRetryDelayMs: 10,
|
|
147
|
+
maxRetryDelayMs: 200,
|
|
148
|
+
lockStaleMs: 10_000,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const ledgerPath = this.filePath;
|
|
152
|
+
|
|
153
|
+
return await withLockAsync(ledgerPath, async () => {
|
|
154
|
+
const store = loadLedgerFromFile(this.stateDir);
|
|
155
|
+
const result = await mutate(store);
|
|
156
|
+
|
|
157
|
+
// Write directly — we already hold the lock, so we must NOT
|
|
158
|
+
// call saveLedger/saveLedgerAsync (they try to acquire the same lock).
|
|
159
|
+
try {
|
|
160
|
+
ensureParentDir(ledgerPath);
|
|
161
|
+
atomicWriteFileSync(ledgerPath, serializeStore(store));
|
|
162
|
+
} catch (writeErr) {
|
|
163
|
+
SystemLogger.log(
|
|
164
|
+
this.workspaceDir,
|
|
165
|
+
'STORAGE_WRITE_FAILED',
|
|
166
|
+
`FileStorageAdapter.mutateLedger write failed: ${String(writeErr)}`,
|
|
167
|
+
);
|
|
168
|
+
throw writeErr;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}, lockOptions);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
lastError = err as Error;
|
|
175
|
+
|
|
176
|
+
// Only retry on lock acquisition errors
|
|
177
|
+
if (err instanceof LockAcquisitionError && attempt < MUTATE_RETRY_COUNT - 1) {
|
|
178
|
+
const delay = Math.min(
|
|
179
|
+
MUTATE_BACKOFF_BASE_MS * Math.pow(2, attempt),
|
|
180
|
+
MUTATE_BACKOFF_MAX_MS,
|
|
181
|
+
);
|
|
182
|
+
// Add jitter (0-20%) to avoid thundering herd
|
|
183
|
+
const jitter = delay * 0.2 * Math.random();
|
|
184
|
+
const totalDelay = Math.floor(delay + jitter);
|
|
185
|
+
|
|
186
|
+
await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Non-retryable error or exhausted retries
|
|
191
|
+
SystemLogger.log(
|
|
192
|
+
this.workspaceDir,
|
|
193
|
+
'STORAGE_MUTATE_FAILED',
|
|
194
|
+
`FileStorageAdapter.mutateLedger failed after ${attempt + 1} attempts: ${String(err)}`,
|
|
195
|
+
);
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Should not reach here, but satisfy the type checker
|
|
201
|
+
throw lastError ?? new Error('FileStorageAdapter.mutateLedger: unexpected state');
|
|
202
|
+
}
|
|
203
|
+
}
|