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.
@@ -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
- // eslint-disable-next-line @typescript-eslint/init-declarations
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
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
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
- // eslint-disable-next-line @typescript-eslint/max-params -- complexity 12, refactor candidate
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
- // Atomic write: temp file + rename prevents corruption on crash
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
+ });