principles-disciple 1.34.0 → 1.34.2
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/src/core/correction-cue-learner.ts +131 -7
- package/src/core/trajectory-types.ts +1 -1
- package/src/service/correction-observer-types.ts +11 -0
- package/src/service/correction-observer-workflow-manager.ts +32 -3
- package/src/service/evolution-worker.ts +362 -149
- package/src/service/keyword-optimization-service.ts +140 -0
- package/src/service/nocturnal-runtime.ts +30 -3
- package/src/service/nocturnal-service.ts +10 -4
- package/tests/service/keyword-optimization-service.test.ts +121 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyword Optimization Service
|
|
3
|
+
*
|
|
4
|
+
* Applies LLM optimization results (ADD/UPDATE/REMOVE mutations) to the
|
|
5
|
+
* correction keyword store. Called by evolution-worker.ts after the
|
|
6
|
+
* keyword_optimization workflow completes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
10
|
+
import type { CorrectionObserverResult } from './correction-observer-types.js';
|
|
11
|
+
import type { PluginLogger } from '../openclaw-sdk.js';
|
|
12
|
+
import { TrajectoryRegistry } from '../core/trajectory.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Applies CorrectionObserverResult mutations to the keyword store.
|
|
16
|
+
* - ADD: calls learner.add() with new keyword
|
|
17
|
+
* - UPDATE: updates weight on existing keyword
|
|
18
|
+
* - REMOVE: removes keyword from store
|
|
19
|
+
*/
|
|
20
|
+
export class KeywordOptimizationService {
|
|
21
|
+
private readonly stateDir: string;
|
|
22
|
+
private readonly workspaceDir: string;
|
|
23
|
+
private readonly logger: PluginLogger;
|
|
24
|
+
|
|
25
|
+
constructor(stateDir: string, workspaceDir: string, logger: PluginLogger) {
|
|
26
|
+
this.stateDir = stateDir;
|
|
27
|
+
this.workspaceDir = workspaceDir;
|
|
28
|
+
this.logger = logger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
applyResult(result: CorrectionObserverResult): void {
|
|
32
|
+
const learner = CorrectionCueLearner.get(this.stateDir);
|
|
33
|
+
|
|
34
|
+
if (!result.updated || !result.updates) {
|
|
35
|
+
this.logger?.info?.('[KeywordOptimizationService] No updates to apply');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const [term, update] of Object.entries(result.updates)) {
|
|
40
|
+
try {
|
|
41
|
+
switch (update.action) {
|
|
42
|
+
case 'add': {
|
|
43
|
+
const weight = update.weight !== undefined
|
|
44
|
+
? Math.max(0.1, Math.min(0.9, update.weight))
|
|
45
|
+
: 0.5;
|
|
46
|
+
learner.add({ term, weight, source: 'llm' });
|
|
47
|
+
this.logger?.info?.(`[KeywordOptimizationService] ADD term="${term}" weight=${weight}`);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
case 'update': {
|
|
51
|
+
if (update.weight !== undefined) {
|
|
52
|
+
learner.updateWeight(term, update.weight);
|
|
53
|
+
this.logger?.info?.(`[KeywordOptimizationService] UPDATE term="${term}" weight=${update.weight}`);
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case 'remove': {
|
|
58
|
+
learner.remove(term);
|
|
59
|
+
this.logger?.info?.(`[KeywordOptimizationService] REMOVE term="${term}"`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (opErr) {
|
|
64
|
+
// Log and skip individual operation failures — don't fail the whole batch
|
|
65
|
+
this.logger?.warn?.(`[KeywordOptimizationService] ${update.action.toUpperCase()} failed for term="${term}": ${String(opErr)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Builds trajectory history for payload:
|
|
72
|
+
* - Calls TrajectoryDatabase.listUserTurnsForSession() for recent sessions
|
|
73
|
+
* - Filters to turns where correctionDetected=true
|
|
74
|
+
* - Returns last 50 correction events
|
|
75
|
+
*/
|
|
76
|
+
async buildTrajectoryHistory(sessionIds: string[]): Promise<TrajectoryHistoryEntry[]> {
|
|
77
|
+
const history: TrajectoryHistoryEntry[] = [];
|
|
78
|
+
const db = TrajectoryRegistry.get(this.workspaceDir);
|
|
79
|
+
|
|
80
|
+
for (const sessionId of sessionIds.slice(0, 10)) { // Last 10 sessions
|
|
81
|
+
try {
|
|
82
|
+
const turns = db.listUserTurnsForSession(sessionId);
|
|
83
|
+
for (const turn of turns) {
|
|
84
|
+
if (turn.correctionDetected) {
|
|
85
|
+
history.push({
|
|
86
|
+
sessionId,
|
|
87
|
+
timestamp: turn.createdAt,
|
|
88
|
+
term: turn.correctionCue ?? 'unknown',
|
|
89
|
+
userMessage: turn.correctionCue ?? '',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (history.length >= 50) break; // Cap at 50 events
|
|
93
|
+
}
|
|
94
|
+
} catch (dbErr) {
|
|
95
|
+
// Skip individual session DB errors — try remaining sessions
|
|
96
|
+
this.logger?.warn?.(`[KeywordOptimizationService] Failed to read trajectory for session ${sessionId}: ${String(dbErr)}`);
|
|
97
|
+
}
|
|
98
|
+
if (history.length >= 50) break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return history;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Singleton factory ───────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
private static _instance: KeywordOptimizationService | null = null;
|
|
107
|
+
private static _lastStateDir: string | null = null;
|
|
108
|
+
private static _lastWorkspaceDir: string | null = null;
|
|
109
|
+
|
|
110
|
+
static get(stateDir: string, workspaceDir: string, logger: PluginLogger): KeywordOptimizationService {
|
|
111
|
+
if (!KeywordOptimizationService._instance || KeywordOptimizationService._lastStateDir !== stateDir || KeywordOptimizationService._lastWorkspaceDir !== workspaceDir) {
|
|
112
|
+
KeywordOptimizationService._instance = new KeywordOptimizationService(stateDir, workspaceDir, logger);
|
|
113
|
+
KeywordOptimizationService._lastStateDir = stateDir;
|
|
114
|
+
KeywordOptimizationService._lastWorkspaceDir = workspaceDir;
|
|
115
|
+
}
|
|
116
|
+
return KeywordOptimizationService._instance;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static reset(): void {
|
|
120
|
+
KeywordOptimizationService._instance = null;
|
|
121
|
+
KeywordOptimizationService._lastStateDir = null;
|
|
122
|
+
KeywordOptimizationService._lastWorkspaceDir = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Types ─────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Internal type mirroring the trajectoryHistory field in CorrectionObserverPayload.
|
|
130
|
+
* Exported here so buildTrajectoryHistory can reference it without a circular import.
|
|
131
|
+
*/
|
|
132
|
+
export type TrajectoryHistoryEntry = {
|
|
133
|
+
sessionId: string;
|
|
134
|
+
timestamp: string;
|
|
135
|
+
term: string;
|
|
136
|
+
userMessage: string;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Re-export CorrectionObserverPayload for convenience */
|
|
140
|
+
export type { CorrectionObserverPayload } from './correction-observer-types.js';
|
|
@@ -321,7 +321,7 @@ export function checkWorkspaceIdle(
|
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
|
|
324
|
-
|
|
324
|
+
|
|
325
325
|
let reason: string;
|
|
326
326
|
if (mostRecentActivityAt === 0) {
|
|
327
327
|
reason = 'No active sessions found — workspace is idle';
|
|
@@ -390,7 +390,7 @@ export function checkCooldown(
|
|
|
390
390
|
if (cooldownEnd > now) {
|
|
391
391
|
globalCooldownActive = true;
|
|
392
392
|
globalCooldownRemainingMs = cooldownEnd - now;
|
|
393
|
-
|
|
393
|
+
|
|
394
394
|
globalCooldownUntil = state.globalCooldownUntil;
|
|
395
395
|
}
|
|
396
396
|
}
|
|
@@ -430,6 +430,33 @@ export function checkCooldown(
|
|
|
430
430
|
};
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Records a cooldown event for quota tracking (keyword_optimization etc.).
|
|
435
|
+
* Adds a timestamp to recentRunTimestamps and prunes entries outside the window.
|
|
436
|
+
* Does NOT set globalCooldownUntil — callers that need it should call recordRunStart.
|
|
437
|
+
*
|
|
438
|
+
* @param stateDir - State directory
|
|
439
|
+
* @param quotaWindowMs - Window size in ms (default: 24 hours)
|
|
440
|
+
*/
|
|
441
|
+
export async function recordCooldown(
|
|
442
|
+
stateDir: string,
|
|
443
|
+
quotaWindowMs: number = 24 * 60 * 60 * 1000
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
const state = await readState(stateDir);
|
|
446
|
+
const now = new Date().toISOString();
|
|
447
|
+
|
|
448
|
+
state.recentRunTimestamps.push(now);
|
|
449
|
+
|
|
450
|
+
// Prune old timestamps outside the window
|
|
451
|
+
const windowStart = Date.now() - quotaWindowMs;
|
|
452
|
+
state.recentRunTimestamps = state.recentRunTimestamps
|
|
453
|
+
.map(ts => new Date(ts).getTime())
|
|
454
|
+
.filter(ts => ts > windowStart)
|
|
455
|
+
.map(ts => new Date(ts).toISOString());
|
|
456
|
+
|
|
457
|
+
await writeState(stateDir, state);
|
|
458
|
+
}
|
|
459
|
+
|
|
433
460
|
/**
|
|
434
461
|
* Record that a nocturnal run has started.
|
|
435
462
|
* Updates global cooldown and quota tracking.
|
|
@@ -562,7 +589,7 @@ export interface PreflightCheckResult {
|
|
|
562
589
|
* @param idleCheckOverride - Optional override for idle check result (for testing)
|
|
563
590
|
*/
|
|
564
591
|
|
|
565
|
-
|
|
592
|
+
|
|
566
593
|
export function checkPreflight(
|
|
567
594
|
workspaceDir: string,
|
|
568
595
|
stateDir: string,
|
|
@@ -103,6 +103,15 @@ import { getPrincipleState, setPrincipleState } from '../core/principle-training
|
|
|
103
103
|
import type { Implementation } from '../types/principle-tree-schema.js';
|
|
104
104
|
import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Atomic file write — write to temp then rename to prevent partial writes on crash.
|
|
108
|
+
*/
|
|
109
|
+
function atomicWriteFileSync(filePath: string, data: string): void {
|
|
110
|
+
const tmpPath = filePath + '.tmp';
|
|
111
|
+
fs.writeFileSync(tmpPath, data, 'utf8');
|
|
112
|
+
fs.renameSync(tmpPath, filePath);
|
|
113
|
+
}
|
|
114
|
+
|
|
106
115
|
// ---------------------------------------------------------------------------
|
|
107
116
|
// #251: Sync trainingStore sample counts after registration
|
|
108
117
|
// ---------------------------------------------------------------------------
|
|
@@ -385,10 +394,7 @@ function persistArtifact(
|
|
|
385
394
|
fs.mkdirSync(dir, { recursive: true });
|
|
386
395
|
}
|
|
387
396
|
|
|
388
|
-
|
|
389
|
-
const tmpPath = artifactPath + '.tmp';
|
|
390
|
-
fs.writeFileSync(tmpPath, JSON.stringify(sampleRecord, null, 2), 'utf8');
|
|
391
|
-
fs.renameSync(tmpPath, artifactPath);
|
|
397
|
+
atomicWriteFileSync(artifactPath, JSON.stringify(sampleRecord, null, 2));
|
|
392
398
|
return artifactPath;
|
|
393
399
|
}
|
|
394
400
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { CorrectionObserverResult } from '../../src/service/subagent-workflow/correction-observer-types.js';
|
|
3
|
+
|
|
4
|
+
// Shared mock objects so tests can mutate them after vi.mock runs
|
|
5
|
+
const mockLearner = { add: vi.fn(), updateWeight: vi.fn(), remove: vi.fn(), getStore: vi.fn(() => ({ keywords: [] })) };
|
|
6
|
+
const mockDb = { listUserTurnsForSession: vi.fn(() => []), listRecentSessions: vi.fn(() => []) };
|
|
7
|
+
|
|
8
|
+
// Mock the CorrectionCueLearner dependency
|
|
9
|
+
vi.mock('../../src/core/correction-cue-learner.js', () => ({
|
|
10
|
+
CorrectionCueLearner: { get: vi.fn(() => mockLearner) },
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock the trajectory dependency — return shared mock objects so tests can configure them
|
|
14
|
+
vi.mock('../../src/core/trajectory.js', () => ({
|
|
15
|
+
TrajectoryRegistry: { get: vi.fn(() => mockDb) },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { KeywordOptimizationService } from '../../src/service/keyword-optimization-service.js';
|
|
19
|
+
|
|
20
|
+
describe('KeywordOptimizationService', () => {
|
|
21
|
+
let service: KeywordOptimizationService;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
KeywordOptimizationService.reset();
|
|
26
|
+
service = KeywordOptimizationService.get('/tmp/test-state', '/tmp/test-state', {
|
|
27
|
+
info: vi.fn(),
|
|
28
|
+
debug: vi.fn(),
|
|
29
|
+
warn: vi.fn(),
|
|
30
|
+
error: vi.fn(),
|
|
31
|
+
} as any);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('applyResult()', () => {
|
|
35
|
+
it('ADD: calls learner.add() with new keyword and weight', () => {
|
|
36
|
+
const result: CorrectionObserverResult = {
|
|
37
|
+
updated: true,
|
|
38
|
+
updates: { 'test-term': { action: 'add', weight: 0.6, reasoning: 'test' } },
|
|
39
|
+
} as any;
|
|
40
|
+
service.applyResult(result);
|
|
41
|
+
expect(mockLearner.add).toHaveBeenCalledWith({ term: 'test-term', weight: 0.6, source: 'llm' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('UPDATE: calls learner.updateWeight() with clamped weight', () => {
|
|
45
|
+
const result: CorrectionObserverResult = {
|
|
46
|
+
updated: true,
|
|
47
|
+
updates: { 'existing-term': { action: 'update', weight: 0.85, reasoning: 'test' } },
|
|
48
|
+
} as any;
|
|
49
|
+
service.applyResult(result);
|
|
50
|
+
expect(mockLearner.updateWeight).toHaveBeenCalledWith('existing-term', 0.85);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('REMOVE: calls learner.remove() with term', () => {
|
|
54
|
+
const result: CorrectionObserverResult = {
|
|
55
|
+
updated: true,
|
|
56
|
+
updates: { 'old-term': { action: 'remove', reasoning: 'test' } },
|
|
57
|
+
} as any;
|
|
58
|
+
service.applyResult(result);
|
|
59
|
+
expect(mockLearner.remove).toHaveBeenCalledWith('old-term');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('skips when result.updated is false', () => {
|
|
63
|
+
const result: CorrectionObserverResult = { updated: false, updates: {}, summary: '' } as any;
|
|
64
|
+
service.applyResult(result);
|
|
65
|
+
expect(mockLearner.add).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('skips when result.updates is undefined', () => {
|
|
69
|
+
const result: CorrectionObserverResult = { updated: true, updates: undefined as any, summary: '' };
|
|
70
|
+
service.applyResult(result);
|
|
71
|
+
expect(mockLearner.add).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('updateWeight() clamp behavior', () => {
|
|
76
|
+
it('clamps weight to 0.1-0.9 range via CorrectionCueLearner', () => {
|
|
77
|
+
const result: CorrectionObserverResult = {
|
|
78
|
+
updated: true,
|
|
79
|
+
updates: { 'existing-term': { action: 'update', weight: 1.5, reasoning: 'test' } },
|
|
80
|
+
} as any;
|
|
81
|
+
service.applyResult(result);
|
|
82
|
+
expect(mockLearner.updateWeight).toHaveBeenCalledWith('existing-term', 1.5);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('buildTrajectoryHistory()', () => {
|
|
87
|
+
it('returns empty array when no sessions exist', async () => {
|
|
88
|
+
const history = await service.buildTrajectoryHistory([]);
|
|
89
|
+
expect(history).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('filters to correctionDetected=true turns only', async () => {
|
|
93
|
+
mockDb.listUserTurnsForSession = vi.fn(() => [
|
|
94
|
+
{ id: 1, turnIndex: 0, correctionDetected: false, correctionCue: null, createdAt: '2024-01-01T00:00:00Z' },
|
|
95
|
+
{ id: 2, turnIndex: 1, correctionDetected: true, correctionCue: 'wrong', createdAt: '2024-01-01T00:01:00Z' },
|
|
96
|
+
{ id: 3, turnIndex: 2, correctionDetected: true, correctionCue: 'error', createdAt: '2024-01-01T00:02:00Z' },
|
|
97
|
+
] as any);
|
|
98
|
+
|
|
99
|
+
const history = await service.buildTrajectoryHistory(['session-1']);
|
|
100
|
+
|
|
101
|
+
expect(history).toHaveLength(2);
|
|
102
|
+
expect(history[0].term).toBe('wrong');
|
|
103
|
+
expect(history[1].term).toBe('error');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('caps at 50 events', async () => {
|
|
107
|
+
const manyTurns = Array.from({ length: 60 }, (_, i) => ({
|
|
108
|
+
id: i,
|
|
109
|
+
turnIndex: i,
|
|
110
|
+
correctionDetected: true,
|
|
111
|
+
correctionCue: `term-${i}`,
|
|
112
|
+
createdAt: new Date(i * 1000).toISOString(),
|
|
113
|
+
}));
|
|
114
|
+
mockDb.listUserTurnsForSession = vi.fn(() => manyTurns as any);
|
|
115
|
+
|
|
116
|
+
const history = await service.buildTrajectoryHistory(['session-1']);
|
|
117
|
+
|
|
118
|
+
expect(history).toHaveLength(50);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|