principles-disciple 1.29.0 → 1.31.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 +4 -4
- package/package.json +1 -1
- package/src/core/nocturnal-trajectory-extractor.ts +16 -0
- package/src/core/trajectory.ts +19 -0
- package/src/index.ts +20 -0
- package/src/service/evolution-worker.ts +58 -13
- package/src/service/nocturnal-config.ts +72 -0
- package/src/service/nocturnal-runtime.ts +5 -3
- package/src/service/nocturnal-service.ts +15 -4
- package/src/service/nocturnal-target-selector.ts +6 -2
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +26 -3
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "principles-disciple",
|
|
3
3
|
"name": "Principles Disciple",
|
|
4
4
|
"description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.31.0",
|
|
6
6
|
"skills": [
|
|
7
7
|
"./skills"
|
|
8
8
|
],
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
78
|
"buildFingerprint": {
|
|
79
|
-
"gitSha": "
|
|
80
|
-
"bundleMd5": "
|
|
81
|
-
"builtAt": "2026-04-
|
|
79
|
+
"gitSha": "15c19a4dc3f2",
|
|
80
|
+
"bundleMd5": "684e47fd5c521d722150a93813fddd02",
|
|
81
|
+
"builtAt": "2026-04-14T03:01:11.396Z"
|
|
82
82
|
}
|
|
83
83
|
}
|
package/package.json
CHANGED
|
@@ -95,6 +95,14 @@ export interface NocturnalGateBlock {
|
|
|
95
95
|
createdAt: string;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* User correction sample for nocturnal snapshot.
|
|
100
|
+
* #268: Wire correction_samples into nocturnal pipeline.
|
|
101
|
+
*/
|
|
102
|
+
export interface NocturnalUserCorrection {
|
|
103
|
+
correctionCue: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
/**
|
|
99
107
|
* A structured nocturnal session snapshot.
|
|
100
108
|
* Contains all information needed for a reflector to generate decision-point samples.
|
|
@@ -114,6 +122,8 @@ export interface NocturnalSessionSnapshot {
|
|
|
114
122
|
toolCalls: NocturnalToolCall[];
|
|
115
123
|
painEvents: NocturnalPainEvent[];
|
|
116
124
|
gateBlocks: NocturnalGateBlock[];
|
|
125
|
+
/** #268: User corrections from correction_samples table */
|
|
126
|
+
userCorrections: NocturnalUserCorrection[];
|
|
117
127
|
/**
|
|
118
128
|
* Summary statistics for quick triage.
|
|
119
129
|
* #246: All fields are now number (never null).
|
|
@@ -281,6 +291,8 @@ export class NocturnalTrajectoryExtractor {
|
|
|
281
291
|
const toolCalls = this.trajectory.listToolCallsForSession(sessionId);
|
|
282
292
|
const painEvents = this.trajectory.listPainEventsForSession(sessionId);
|
|
283
293
|
const gateBlocks = this.trajectory.listGateBlocksForSession(sessionId);
|
|
294
|
+
// #268: Fetch correction samples for this session
|
|
295
|
+
const correctionSamples = this.trajectory.listCorrectionSamplesForSession(sessionId);
|
|
284
296
|
|
|
285
297
|
// Map to sanitized structures
|
|
286
298
|
// SECURITY: Only sanitizedText from assistant turns
|
|
@@ -346,6 +358,10 @@ export class NocturnalTrajectoryExtractor {
|
|
|
346
358
|
toolCalls: nocturnalToolCalls,
|
|
347
359
|
painEvents: nocturnalPainEvents,
|
|
348
360
|
gateBlocks: nocturnalGateBlocks,
|
|
361
|
+
// #268: Map correction samples to nocturnal format
|
|
362
|
+
userCorrections: correctionSamples.map((cs: { correctionCue: string | null }) => ({
|
|
363
|
+
correctionCue: cs.correctionCue,
|
|
364
|
+
})),
|
|
349
365
|
stats: {
|
|
350
366
|
totalAssistantTurns: sanitizedAssistantTurns.length,
|
|
351
367
|
totalToolCalls: nocturnalToolCalls.length,
|
package/src/core/trajectory.ts
CHANGED
|
@@ -935,6 +935,25 @@ export class TrajectoryDatabase {
|
|
|
935
935
|
}));
|
|
936
936
|
}
|
|
937
937
|
|
|
938
|
+
/**
|
|
939
|
+
* List correction samples for a specific session.
|
|
940
|
+
* Returns minimal fields for nocturnal use — correction cue only.
|
|
941
|
+
* #268: Wire correction_samples into nocturnal pipeline.
|
|
942
|
+
*/
|
|
943
|
+
listCorrectionSamplesForSession(sessionId: string): { correctionCue: string | null }[] {
|
|
944
|
+
const rows = this.db.prepare(`
|
|
945
|
+
SELECT cs.sample_id, ut.correction_cue
|
|
946
|
+
FROM correction_samples cs
|
|
947
|
+
LEFT JOIN user_turns ut ON ut.id = cs.user_correction_turn_id
|
|
948
|
+
WHERE cs.session_id = ?
|
|
949
|
+
ORDER BY cs.created_at DESC
|
|
950
|
+
`).all(sessionId) as Record<string, unknown>[];
|
|
951
|
+
|
|
952
|
+
return rows.map((row) => ({
|
|
953
|
+
correctionCue: row.correction_cue ? String(row.correction_cue) : null,
|
|
954
|
+
}));
|
|
955
|
+
}
|
|
956
|
+
|
|
938
957
|
reviewCorrectionSample(sampleId: string, status: Exclude<CorrectionSampleReviewStatus, 'pending'>, note?: string): CorrectionSampleRecord {
|
|
939
958
|
const updatedAt = nowIso();
|
|
940
959
|
const updated = this.withWrite(() => {
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
PluginHookSubagentContext,
|
|
18
18
|
} from './openclaw-sdk.js';
|
|
19
19
|
import * as crypto from 'crypto';
|
|
20
|
+
import * as path from 'path';
|
|
20
21
|
import type { WorkerProfile } from './core/model-deployment-registry.js';
|
|
21
22
|
import { classifyTask } from './core/local-worker-routing.js';
|
|
22
23
|
import { completeShadowObservation, recordShadowRouting } from './core/shadow-observation-registry.js';
|
|
@@ -66,6 +67,8 @@ import { extractAgentIdFromSessionKey } from './utils/session-key.js';
|
|
|
66
67
|
|
|
67
68
|
// Track initialization to avoid repeated calls
|
|
68
69
|
let workspaceInitialized = false;
|
|
70
|
+
// Track started evolution workers — one per workspace
|
|
71
|
+
const startedWorkspaces = new Set<string>();
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* Resolve workspaceDir for slash commands.
|
|
@@ -171,6 +174,23 @@ const plugin = {
|
|
|
171
174
|
SystemLogger.log(workspaceDir, 'SYSTEM_BOOT', `Principles Disciple online. Language: ${language}`);
|
|
172
175
|
workspaceInitialized = true;
|
|
173
176
|
}
|
|
177
|
+
|
|
178
|
+
// ── Start EvolutionWorker for THIS workspace ──
|
|
179
|
+
// Each agent has its own heartbeat task. When before_prompt_build fires,
|
|
180
|
+
// it fires for the current agent's workspaceDir. Start one EvolutionWorker
|
|
181
|
+
// per workspace so each agent's pain signals are processed independently.
|
|
182
|
+
if (!startedWorkspaces.has(workspaceDir)) {
|
|
183
|
+
startedWorkspaces.add(workspaceDir);
|
|
184
|
+
EvolutionWorkerService.api = api;
|
|
185
|
+
EvolutionWorkerService.start({
|
|
186
|
+
config: api.config,
|
|
187
|
+
workspaceDir,
|
|
188
|
+
stateDir: path.join(workspaceDir, '.state'),
|
|
189
|
+
logger: api.logger,
|
|
190
|
+
});
|
|
191
|
+
api.logger.info(`[PD] EvolutionWorker started for workspace: ${workspaceDir}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
174
194
|
const result = await handleBeforePromptBuild(event, { ...ctx, api, workspaceDir });
|
|
175
195
|
|
|
176
196
|
// Record success
|
|
@@ -17,6 +17,7 @@ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
|
17
17
|
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
18
18
|
import { LockUnavailableError } from '../config/index.js';
|
|
19
19
|
import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
|
|
20
|
+
import { loadNocturnalConfig } from './nocturnal-config.js';
|
|
20
21
|
import { WorkflowStore } from './subagent-workflow/workflow-store.js';
|
|
21
22
|
import type { WorkflowRow } from './subagent-workflow/types.js';
|
|
22
23
|
import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
|
|
@@ -407,6 +408,8 @@ function buildFallbackNocturnalSnapshot(
|
|
|
407
408
|
toolCalls: [],
|
|
408
409
|
painEvents: fallbackPainEvents,
|
|
409
410
|
gateBlocks: [],
|
|
411
|
+
// #268: Empty corrections in fallback path (no trajectory data available)
|
|
412
|
+
userCorrections: [],
|
|
410
413
|
stats: {
|
|
411
414
|
totalAssistantTurns: realStats?.totalAssistantTurns ?? 0,
|
|
412
415
|
totalToolCalls: realStats?.totalToolCalls ?? 0,
|
|
@@ -485,14 +488,13 @@ function findRecentDuplicateTask(
|
|
|
485
488
|
now: number,
|
|
486
489
|
reason?: string
|
|
487
490
|
): EvolutionQueueItem | undefined {
|
|
488
|
-
|
|
489
491
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
490
492
|
const key = normalizePainDedupKey(source, preview, reason);
|
|
491
493
|
return queue.find((task) => {
|
|
492
494
|
if (task.status === 'completed') return false;
|
|
495
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
493
496
|
const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
|
|
494
497
|
if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
|
|
495
|
-
|
|
496
498
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
497
499
|
return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
|
|
498
500
|
});
|
|
@@ -547,6 +549,7 @@ function normalizePainDedupKey(source: string, preview: string, reason?: string)
|
|
|
547
549
|
}
|
|
548
550
|
|
|
549
551
|
|
|
552
|
+
|
|
550
553
|
// eslint-disable-next-line @typescript-eslint/max-params
|
|
551
554
|
export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
|
|
552
555
|
return !!findRecentDuplicateTask(queue, source, preview, now, reason);
|
|
@@ -675,7 +678,7 @@ function shouldSkipForDedup(
|
|
|
675
678
|
* Load and migrate the evolution queue. Returns empty array if file doesn't exist.
|
|
676
679
|
*/
|
|
677
680
|
function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
|
|
678
|
-
// eslint-disable-next-line no-useless-assignment
|
|
681
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations, no-useless-assignment
|
|
679
682
|
let rawQueue: RawQueueItem[] = [];
|
|
680
683
|
try {
|
|
681
684
|
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
@@ -689,6 +692,7 @@ function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
|
|
|
689
692
|
/**
|
|
690
693
|
* Build and persist a new sleep_reflection task.
|
|
691
694
|
*/
|
|
695
|
+
|
|
692
696
|
// eslint-disable-next-line @typescript-eslint/max-params
|
|
693
697
|
function enqueueNewSleepReflectionTask(
|
|
694
698
|
queue: EvolutionQueueItem[],
|
|
@@ -1608,10 +1612,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1608
1612
|
|
|
1609
1613
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
1610
1614
|
let workflowId: string | undefined;
|
|
1611
|
-
|
|
1612
1615
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
1613
1616
|
let nocturnalManager: NocturnalWorkflowManager;
|
|
1614
|
-
|
|
1615
1617
|
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
1616
1618
|
let snapshotData: NocturnalSessionSnapshot | undefined;
|
|
1617
1619
|
|
|
@@ -2014,6 +2016,7 @@ export async function registerEvolutionTaskSession(
|
|
|
2014
2016
|
export interface ExtendedEvolutionWorkerService {
|
|
2015
2017
|
id: string;
|
|
2016
2018
|
api: OpenClawPluginApi | null;
|
|
2019
|
+
_startedWorkspaces: Set<string>;
|
|
2017
2020
|
start: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
|
|
2018
2021
|
stop?: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
|
|
2019
2022
|
}
|
|
@@ -2085,17 +2088,27 @@ async function processEvolutionQueueWithResult(
|
|
|
2085
2088
|
export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
2086
2089
|
id: 'principles-evolution-worker',
|
|
2087
2090
|
api: null,
|
|
2091
|
+
_startedWorkspaces: new Set<string>(),
|
|
2088
2092
|
|
|
2089
2093
|
start(ctx: OpenClawPluginServiceContext): void {
|
|
2094
|
+
const workspaceDir = ctx?.workspaceDir;
|
|
2090
2095
|
const logger = ctx?.logger || console;
|
|
2091
2096
|
const {api} = this;
|
|
2092
|
-
const workspaceDir = ctx?.workspaceDir;
|
|
2093
2097
|
|
|
2094
2098
|
if (!workspaceDir) {
|
|
2095
2099
|
if (logger) logger.warn('[PD:EvolutionWorker] workspaceDir not found in service config. Evolution cycle disabled.');
|
|
2096
2100
|
return;
|
|
2097
2101
|
}
|
|
2098
2102
|
|
|
2103
|
+
// Guard: prevent duplicate starts for the SAME workspace
|
|
2104
|
+
const started = EvolutionWorkerService._startedWorkspaces;
|
|
2105
|
+
if (started.has(workspaceDir)) {
|
|
2106
|
+
ctx?.logger?.info?.(`[PD:EvolutionWorker] Already started for ${workspaceDir}, skipping`);
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
started.add(workspaceDir);
|
|
2111
|
+
|
|
2099
2112
|
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
|
|
2100
2113
|
if (logger) logger.info(`[PD:EvolutionWorker] Starting with workspaceDir=${wctx.workspaceDir}, stateDir=${wctx.stateDir}`);
|
|
2101
2114
|
|
|
@@ -2109,12 +2122,16 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
|
2109
2122
|
const initialDelay = 5000;
|
|
2110
2123
|
const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
|
|
2111
2124
|
|
|
2125
|
+
// Periodic trigger tracking
|
|
2126
|
+
let heartbeatCounter = 0;
|
|
2127
|
+
|
|
2112
2128
|
async function runCycle(): Promise<void> {
|
|
2113
2129
|
const cycleStart = Date.now();
|
|
2130
|
+
heartbeatCounter++;
|
|
2114
2131
|
|
|
2115
2132
|
// ──── DEBUG: Verify subagent availability in heartbeat context ────
|
|
2116
2133
|
const hbSubagent = api?.runtime?.subagent;
|
|
2117
|
-
logger?.info?.(`[PD:DEBUG:SubagentCheck:Heartbeat] api_exists=${!!api}, subagent_exists=${!!hbSubagent}, subagent.run_exists=${!!hbSubagent?.run}`);
|
|
2134
|
+
logger?.info?.(`[PD:DEBUG:SubagentCheck:Heartbeat] api_exists=${!!api}, subagent_exists=${!!hbSubagent}, subagent.run_exists=${!!hbSubagent?.run}, heartbeatCounter=${heartbeatCounter}`);
|
|
2118
2135
|
if (hbSubagent?.run) {
|
|
2119
2136
|
logger?.info?.('[PD:DEBUG:SubagentCheck:Heartbeat] run entrypoint is callable');
|
|
2120
2137
|
}
|
|
@@ -2135,18 +2152,46 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
|
2135
2152
|
};
|
|
2136
2153
|
|
|
2137
2154
|
try {
|
|
2155
|
+
// Load config on each cycle (supports runtime updates)
|
|
2156
|
+
const sleepConfig = loadNocturnalConfig(wctx.stateDir);
|
|
2157
|
+
|
|
2138
2158
|
const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
|
|
2139
|
-
logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt}`);
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2159
|
+
logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt} triggerMode=${sleepConfig.trigger_mode}`);
|
|
2160
|
+
|
|
2161
|
+
let shouldTrySleepReflection = false;
|
|
2162
|
+
|
|
2163
|
+
// Path 1: Idle-based trigger (default mode)
|
|
2164
|
+
if (idleResult.isIdle && sleepConfig.trigger_mode === 'idle') {
|
|
2165
|
+
logger?.info?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
|
|
2166
|
+
shouldTrySleepReflection = true;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// Path 2: Periodic trigger (fires regardless of idle state)
|
|
2170
|
+
if (sleepConfig.trigger_mode === 'periodic') {
|
|
2171
|
+
if (heartbeatCounter >= sleepConfig.period_heartbeats) {
|
|
2172
|
+
logger?.info?.(`[PD:EvolutionWorker] Periodic trigger: heartbeatCounter=${heartbeatCounter} >= period_heartbeats=${sleepConfig.period_heartbeats}`);
|
|
2173
|
+
shouldTrySleepReflection = true;
|
|
2174
|
+
heartbeatCounter = 0; // Reset counter
|
|
2175
|
+
} else {
|
|
2176
|
+
logger?.info?.(`[PD:EvolutionWorker] Periodic: ${heartbeatCounter}/${sleepConfig.period_heartbeats} heartbeats — waiting`);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
if (shouldTrySleepReflection) {
|
|
2181
|
+
const cooldown = checkCooldown(wctx.stateDir, undefined, {
|
|
2182
|
+
globalCooldownMs: sleepConfig.cooldown_ms,
|
|
2183
|
+
maxRunsPerWindow: sleepConfig.max_runs_per_day,
|
|
2184
|
+
quotaWindowMs: 24 * 60 * 60 * 1000,
|
|
2185
|
+
});
|
|
2186
|
+
logger?.info?.(`[PD:EvolutionWorker] Cooldown check: globalCooldownActive=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted} runsRemaining=${cooldown.runsRemaining}`);
|
|
2143
2187
|
if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
|
|
2188
|
+
logger?.info?.('[PD:EvolutionWorker] Attempting to enqueue sleep_reflection task...');
|
|
2144
2189
|
enqueueSleepReflectionTask(wctx, logger).catch((err) => {
|
|
2145
2190
|
logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
|
|
2146
2191
|
});
|
|
2192
|
+
} else {
|
|
2193
|
+
logger?.info?.(`[PD:EvolutionWorker] Skipping sleep_reflection: globalCooldown=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted}`);
|
|
2147
2194
|
}
|
|
2148
|
-
} else {
|
|
2149
|
-
logger?.debug?.(`[PD:EvolutionWorker] Workspace active (last activity ${idleResult.idleForMs}ms ago)`);
|
|
2150
2195
|
}
|
|
2151
2196
|
|
|
2152
2197
|
const painCheckResult = await checkPainFlag(wctx, logger);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface SleepReflectionConfig {
|
|
5
|
+
/** Trigger mode: "idle" (default) or "periodic" */
|
|
6
|
+
trigger_mode: 'idle' | 'periodic';
|
|
7
|
+
/** In periodic mode, trigger every N heartbeat cycles */
|
|
8
|
+
period_heartbeats: number;
|
|
9
|
+
/** Minimum time between runs (ms) */
|
|
10
|
+
cooldown_ms: number;
|
|
11
|
+
/** Maximum runs per 24-hour window */
|
|
12
|
+
max_runs_per_day: number;
|
|
13
|
+
/** Whether sleep_reflection is enabled */
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface NocturnalConfig {
|
|
18
|
+
sleep_reflection?: Partial<SleepReflectionConfig>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_SLEEP_REFLECTION: SleepReflectionConfig = {
|
|
22
|
+
trigger_mode: 'idle',
|
|
23
|
+
period_heartbeats: 4, // ~1 hour at 15-min heartbeat interval
|
|
24
|
+
cooldown_ms: 30 * 60 * 1000, // 30 minutes
|
|
25
|
+
max_runs_per_day: 3,
|
|
26
|
+
enabled: true,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const CONFIG_FILENAME = 'nocturnal-config.json';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the nocturnal config file path.
|
|
33
|
+
*/
|
|
34
|
+
function resolveConfigPath(stateDir: string): string {
|
|
35
|
+
return path.join(stateDir, CONFIG_FILENAME);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load nocturnal config from .state/nocturnal-config.json.
|
|
40
|
+
* Returns default config if file doesn't exist or is malformed.
|
|
41
|
+
*/
|
|
42
|
+
export function loadNocturnalConfig(stateDir: string): SleepReflectionConfig {
|
|
43
|
+
const configPath = resolveConfigPath(stateDir);
|
|
44
|
+
let fileConfig: NocturnalConfig = {};
|
|
45
|
+
|
|
46
|
+
if (fs.existsSync(configPath)) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
49
|
+
fileConfig = JSON.parse(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
// Malformed config — continue with defaults
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fileSleep = fileConfig.sleep_reflection || {};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
trigger_mode: fileSleep.trigger_mode === 'periodic' ? 'periodic' : DEFAULT_SLEEP_REFLECTION.trigger_mode,
|
|
59
|
+
period_heartbeats: typeof fileSleep.period_heartbeats === 'number' && fileSleep.period_heartbeats > 0
|
|
60
|
+
? fileSleep.period_heartbeats
|
|
61
|
+
: DEFAULT_SLEEP_REFLECTION.period_heartbeats,
|
|
62
|
+
cooldown_ms: typeof fileSleep.cooldown_ms === 'number' && fileSleep.cooldown_ms >= 0
|
|
63
|
+
? fileSleep.cooldown_ms
|
|
64
|
+
: DEFAULT_SLEEP_REFLECTION.cooldown_ms,
|
|
65
|
+
max_runs_per_day: typeof fileSleep.max_runs_per_day === 'number' && fileSleep.max_runs_per_day > 0
|
|
66
|
+
? fileSleep.max_runs_per_day
|
|
67
|
+
: DEFAULT_SLEEP_REFLECTION.max_runs_per_day,
|
|
68
|
+
enabled: typeof fileSleep.enabled === 'boolean'
|
|
69
|
+
? fileSleep.enabled
|
|
70
|
+
: DEFAULT_SLEEP_REFLECTION.enabled,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -436,10 +436,12 @@ export function checkCooldown(
|
|
|
436
436
|
*
|
|
437
437
|
* @param stateDir - State directory
|
|
438
438
|
* @param principleId - Target principle ID for this run
|
|
439
|
+
* @param cooldownMs - Global cooldown duration in ms (default: 1 hour)
|
|
439
440
|
*/
|
|
440
441
|
export async function recordRunStart(
|
|
441
442
|
stateDir: string,
|
|
442
|
-
principleId: string
|
|
443
|
+
principleId: string,
|
|
444
|
+
cooldownMs: number = DEFAULT_GLOBAL_COOLDOWN_MS
|
|
443
445
|
): Promise<void> {
|
|
444
446
|
const state = await readState(stateDir);
|
|
445
447
|
const now = new Date().toISOString();
|
|
@@ -450,8 +452,8 @@ export async function recordRunStart(
|
|
|
450
452
|
status: 'skipped', // Will be updated on completion
|
|
451
453
|
};
|
|
452
454
|
|
|
453
|
-
// Set global cooldown
|
|
454
|
-
const cooldownUntil = new Date(Date.now() +
|
|
455
|
+
// Set global cooldown (use configured value, not hardcoded default)
|
|
456
|
+
const cooldownUntil = new Date(Date.now() + cooldownMs).toISOString();
|
|
455
457
|
state.globalCooldownUntil = cooldownUntil;
|
|
456
458
|
|
|
457
459
|
// Add to recent runs for quota tracking
|
|
@@ -96,6 +96,7 @@ import {
|
|
|
96
96
|
type IdleCheckResult,
|
|
97
97
|
type PreflightCheckResult,
|
|
98
98
|
} from './nocturnal-runtime.js';
|
|
99
|
+
import { loadNocturnalConfig } from './nocturnal-config.js';
|
|
99
100
|
import { NocturnalPathResolver } from '../core/nocturnal-paths.js';
|
|
100
101
|
import { registerSample } from '../core/nocturnal-dataset.js';
|
|
101
102
|
import { getPrincipleState, setPrincipleState } from '../core/principle-training-state.js';
|
|
@@ -721,7 +722,8 @@ export function executeNocturnalReflection(
|
|
|
721
722
|
if (!preflight.canRun) {
|
|
722
723
|
return {
|
|
723
724
|
success: false,
|
|
724
|
-
noTargetSelected:
|
|
725
|
+
noTargetSelected: true,
|
|
726
|
+
skipReason: 'preflight_blocked',
|
|
725
727
|
validationFailed: false,
|
|
726
728
|
validationFailures: [],
|
|
727
729
|
diagnostics,
|
|
@@ -784,7 +786,8 @@ export function executeNocturnalReflection(
|
|
|
784
786
|
// -------------------------------------------------------------------------
|
|
785
787
|
// Note: We use a sync approximation here since this is called from sync context
|
|
786
788
|
// The async version would be used in real worker integration
|
|
787
|
-
|
|
789
|
+
const config = loadNocturnalConfig(stateDir);
|
|
790
|
+
void recordRunStart(stateDir, selectedPrincipleId, config.cooldown_ms).catch((err) => {
|
|
788
791
|
console.warn(`[nocturnal-service] Failed to record run start: ${String(err)}`);
|
|
789
792
|
});
|
|
790
793
|
|
|
@@ -1195,7 +1198,14 @@ async function executeNocturnalReflectionWithAdapter(
|
|
|
1195
1198
|
diagnostics.preflight = preflight;
|
|
1196
1199
|
|
|
1197
1200
|
if (!preflight.canRun) {
|
|
1198
|
-
return {
|
|
1201
|
+
return {
|
|
1202
|
+
success: false,
|
|
1203
|
+
noTargetSelected: true,
|
|
1204
|
+
skipReason: 'preflight_blocked',
|
|
1205
|
+
validationFailed: false,
|
|
1206
|
+
validationFailures: [],
|
|
1207
|
+
diagnostics
|
|
1208
|
+
};
|
|
1199
1209
|
}
|
|
1200
1210
|
|
|
1201
1211
|
// Step 2: Target selection (or use override to skip)
|
|
@@ -1317,7 +1327,8 @@ async function executeNocturnalReflectionWithAdapter(
|
|
|
1317
1327
|
}
|
|
1318
1328
|
|
|
1319
1329
|
// Step 3: Record run start
|
|
1320
|
-
|
|
1330
|
+
const config = loadNocturnalConfig(stateDir);
|
|
1331
|
+
void recordRunStart(stateDir, selectedPrincipleId, config.cooldown_ms).catch((err) => {
|
|
1321
1332
|
console.warn(`[nocturnal-service] Failed to record run start: ${String(err)}`);
|
|
1322
1333
|
});
|
|
1323
1334
|
|
|
@@ -63,7 +63,8 @@ export type SkipReason =
|
|
|
63
63
|
| 'workspace_not_idle' // Workspace is active, nocturnal not allowed
|
|
64
64
|
| 'quota_exhausted' // Max runs per quota window reached
|
|
65
65
|
| 'insufficient_snapshot_data' // Sessions exist but lack tool calls / events
|
|
66
|
-
| 'global_cooldown_active'
|
|
66
|
+
| 'global_cooldown_active' // Global cooldown is in effect
|
|
67
|
+
| 'preflight_blocked'; // Preflight check blocked (idle/cooldown/quota)
|
|
67
68
|
|
|
68
69
|
export interface SelectionDiagnostics {
|
|
69
70
|
/** Total evaluable principles found */
|
|
@@ -474,7 +475,10 @@ export class NocturnalTargetSelector {
|
|
|
474
475
|
toolName: gb.toolName,
|
|
475
476
|
reason: gb.reason,
|
|
476
477
|
})),
|
|
477
|
-
|
|
478
|
+
// #268: Use actual correction samples from snapshot instead of empty array
|
|
479
|
+
userCorrections: snapshot.userCorrections.map((uc) => ({
|
|
480
|
+
correctionCue: uc.correctionCue ?? undefined,
|
|
481
|
+
})),
|
|
478
482
|
planApprovals: [],
|
|
479
483
|
});
|
|
480
484
|
hasViolation = violationResult.violated;
|
|
@@ -262,8 +262,10 @@ export class NocturnalWorkflowManager implements WorkflowManager {
|
|
|
262
262
|
painContext,
|
|
263
263
|
// #244: Only skip preflight idle gate for manual/test triggers.
|
|
264
264
|
// Automatic triggers must go through normal idle check.
|
|
265
|
+
// #292: Periodic triggers (source='nocturnal') also bypass idle check for debugging
|
|
265
266
|
...(((options.metadata)?.triggerSource === 'manual' ||
|
|
266
|
-
(options.metadata)?.triggerSource === 'test'
|
|
267
|
+
(options.metadata)?.triggerSource === 'test' ||
|
|
268
|
+
(options.metadata)?.triggerSource === 'nocturnal')
|
|
267
269
|
? {
|
|
268
270
|
idleCheckOverride: {
|
|
269
271
|
isIdle: true,
|
|
@@ -272,7 +274,7 @@ export class NocturnalWorkflowManager implements WorkflowManager {
|
|
|
272
274
|
userActiveSessions: 0,
|
|
273
275
|
abandonedSessionIds: [],
|
|
274
276
|
trajectoryGuardrailConfirmsIdle: true,
|
|
275
|
-
reason: 'manual
|
|
277
|
+
reason: `${(options.metadata)?.triggerSource ?? 'manual'}.test override`,
|
|
276
278
|
},
|
|
277
279
|
}
|
|
278
280
|
: {}),
|
|
@@ -293,7 +295,28 @@ export class NocturnalWorkflowManager implements WorkflowManager {
|
|
|
293
295
|
this.completedWorkflows.set(workflowId, Date.now());
|
|
294
296
|
} else {
|
|
295
297
|
const reason = result.noTargetSelected ? 'no_target_selected' : 'validation_failed';
|
|
296
|
-
|
|
298
|
+
const failuresSummary = result.validationFailures?.length > 0
|
|
299
|
+
? result.validationFailures.join('; ')
|
|
300
|
+
: (result.skipReason ?? 'none');
|
|
301
|
+
this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Pipeline failed: reason=${reason}, noTargetSelected=${result.noTargetSelected}, skipReason=${result.skipReason ?? 'none'}, validationFailures=${result.validationFailures?.length ?? 0}, details=${failuresSummary}`);
|
|
302
|
+
|
|
303
|
+
// Log full result structure for debugging
|
|
304
|
+
this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Full result: success=${result.success}, validationFailed=${result.validationFailed}, noTargetSelected=${result.noTargetSelected}`);
|
|
305
|
+
|
|
306
|
+
// Log diagnostics for debugging
|
|
307
|
+
if (result.diagnostics?.trinityResult) {
|
|
308
|
+
this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Trinity result: success=${result.diagnostics.trinityResult.success}, chainMode=${result.diagnostics.chainModeUsed ?? 'unknown'}`);
|
|
309
|
+
if (!result.diagnostics.trinityResult.success) {
|
|
310
|
+
this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Trinity failures: ${result.diagnostics.trinityResult.failures.map(f => `${f.stage}: ${f.reason}`).join('; ')}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (result.diagnostics?.arbiterResult) {
|
|
314
|
+
this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Arbiter result: passed=${result.diagnostics.arbiterResult.passed}, failures=${result.diagnostics.arbiterResult.failures.map(f => f.reason).join('; ')}`);
|
|
315
|
+
}
|
|
316
|
+
if (result.diagnostics?.selection) {
|
|
317
|
+
this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Selection: decision=${result.diagnostics.selection.decision}, principleId=${result.diagnostics.selection.selectedPrincipleId ?? 'none'}, sessionId=${result.diagnostics.selection.selectedSessionId ?? 'none'}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
297
320
|
this.store.updateWorkflowState(workflowId, 'terminal_error');
|
|
298
321
|
this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', reason, {
|
|
299
322
|
failures: result.validationFailures,
|