principles-disciple 1.138.0 → 1.140.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.
@@ -16,13 +16,37 @@ import { severityToPenalty, DEFAULT_EMPATHY_KEYWORD_CONFIG } from '../core/empat
16
16
  import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
17
17
  import { emitPainDetectedEvent, buildTrajectoryEvidence } from './pain.js';
18
18
  import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
19
- import { detectCorrectionCue as coreDetectCorrectionCue, extractMessageContent, isMinimalTrigger, } from '@principles/core/prompt-builder';
19
+ import { detectCorrectionCue as coreDetectCorrectionCue, escapeXml, extractMessageContent, isMinimalTrigger, } from '@principles/core/prompt-builder';
20
20
  import { sanitizeForEvidence } from './message-sanitize.js';
21
+ import { buildAgentIdentity, buildEmpathySilenceConstraint, extractUserMessageFromPrompt, assembleHeartbeatChecklist, formatCorePrinciples, formatEvolutionPrinciples, assembleAppendSystemContext, } from './prompt-helpers.js';
21
22
  // ---------------------------------------------------------------------------
22
23
  // Static file cache — avoids re-reading rarely-changing files every message
23
24
  // ---------------------------------------------------------------------------
24
25
  const STATIC_FILE_TTL_MS = 60_000; // 1 minute
26
+ /**
27
+ * Per-workspace file cache. Keyed by workspaceDir to avoid cross-workspace
28
+ * cache pollution. Previously a module-level Map keyed by filePath only.
29
+ */
25
30
  const _staticFileCache = new Map();
31
+ function getOwnValue(value, key) {
32
+ if (!Object.hasOwn(value, key))
33
+ return undefined;
34
+ return Object.getOwnPropertyDescriptor(value, key)?.value;
35
+ }
36
+ function readErrorCode(error) {
37
+ if (typeof error !== 'object' || error === null)
38
+ return undefined;
39
+ const code = getOwnValue(error, 'code');
40
+ return typeof code === 'string' ? code : undefined;
41
+ }
42
+ function getFileCache(workspaceDir) {
43
+ let cache = _staticFileCache.get(workspaceDir);
44
+ if (!cache) {
45
+ cache = new Map();
46
+ _staticFileCache.set(workspaceDir, cache);
47
+ }
48
+ return cache;
49
+ }
26
50
  /**
27
51
  * Reads a file with TTL-based caching.
28
52
  * Returns cached content if:
@@ -30,9 +54,10 @@ const _staticFileCache = new Map();
30
54
  * 2. File mtime hasn't changed (detects external edits)
31
55
  * Otherwise re-reads from disk.
32
56
  */
33
- function cachedReadFile(filePath) {
57
+ function cachedReadFile(filePath, workspaceDir) {
58
+ const cache = getFileCache(workspaceDir);
34
59
  const now = Date.now();
35
- const cached = _staticFileCache.get(filePath);
60
+ const cached = cache.get(filePath);
36
61
  try {
37
62
  const stat = fs.statSync(filePath);
38
63
  const mtime = stat.mtimeMs;
@@ -40,83 +65,91 @@ function cachedReadFile(filePath) {
40
65
  return cached.content;
41
66
  }
42
67
  const content = fs.readFileSync(filePath, 'utf8');
43
- _staticFileCache.set(filePath, { content, mtime, loadedAt: now });
68
+ cache.set(filePath, { content, mtime, loadedAt: now });
44
69
  return content;
45
70
  }
46
- catch {
71
+ catch (error) {
47
72
  // File doesn't exist or unreadable — invalidate cache
48
- _staticFileCache.delete(filePath);
73
+ cache.delete(filePath);
74
+ if (readErrorCode(error) !== 'ENOENT') {
75
+ console.warn(`[PD:Prompt] cachedReadFile failed: path=${filePath}, workspace=${workspaceDir}, error=${String(error)}`);
76
+ }
49
77
  return '';
50
78
  }
51
79
  }
52
- // Module-level empathy state — shared across calls to avoid per-turn I/O
53
- let _empathyTurnCounter = 0;
54
- let _empathyKeywordCache = null;
55
80
  /**
56
- * OpenClaw API Prompt Hook
57
- * Constructs the system prompt injected into LLM context for Principles Disciple
81
+ * Per-workspace empathy state. Keyed by workspaceDir to avoid cross-workspace
82
+ * state pollution. Previously module-level variables.
58
83
  */
59
- function escapeXml(input) {
60
- return input
61
- .replace(/&/g, '&')
62
- .replace(/</g, '&lt;')
63
- .replace(/>/g, '&gt;')
64
- .replace(/"/g, '&quot;')
65
- .replace(/'/g, '&apos;');
66
- }
67
- function getTextContent(message) {
68
- return extractMessageContent(message);
84
+ const _empathyState = new Map();
85
+ function getEmpathyState(workspaceDir) {
86
+ let state = _empathyState.get(workspaceDir);
87
+ if (!state) {
88
+ state = { turnCounter: 0, keywordCache: null };
89
+ _empathyState.set(workspaceDir, state);
90
+ }
91
+ return state;
69
92
  }
70
93
  /**
71
- * Validates model format, expects "provider/model" format
94
+ * Reset all module-level prompt state for a workspace.
95
+ * Intended for test isolation — call in beforeEach().
72
96
  */
73
- function isValidModelFormat(model) {
74
- // Case: "provider/model" -> "provider/model-variant"
75
- // provider: e.g., "openai", "anthropic" - the API provider name
76
- // model: e.g., "gpt-4", "claude-3-opus" - the specific model name
77
- const MODEL_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]\/[a-zA-Z0-9._-]+$/;
78
- return MODEL_PATTERN.test(model);
97
+ export function resetPromptStateForTest(workspaceDir) {
98
+ if (workspaceDir) {
99
+ _staticFileCache.delete(workspaceDir);
100
+ _empathyState.delete(workspaceDir);
101
+ }
102
+ else {
103
+ _staticFileCache.clear();
104
+ _empathyState.clear();
105
+ }
79
106
  }
80
- /**
81
- * Resolves model configuration for OpenClaw agents, supporting string and object formats
82
- * @param modelConfig - Model config: string (e.g. "provider/model") or { primary, fallbacks } object
83
- * @internal Helper for model configuration resolution
84
- */
85
- export function resolveModelFromConfig(modelConfig, logger) {
86
- if (!modelConfig)
107
+ function parseContextInjectionConfig(value) {
108
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
87
109
  return null;
88
- // Case 1: modelConfig is a string like "provider/model"
89
- if (typeof modelConfig === 'string') {
90
- const trimmed = modelConfig.trim();
91
- if (!trimmed)
110
+ const config = { ...defaultContextConfig };
111
+ const thinkingOs = getOwnValue(value, 'thinkingOs');
112
+ const projectFocus = getOwnValue(value, 'projectFocus');
113
+ const evolutionContext = getOwnValue(value, 'evolutionContext');
114
+ if (thinkingOs !== undefined) {
115
+ if (typeof thinkingOs !== 'boolean')
92
116
  return null;
93
- if (!isValidModelFormat(trimmed)) {
94
- logger?.warn(`[PD:Prompt] Invalid model format: "${trimmed}". Expected "provider/model" format.`);
117
+ config.thinkingOs = thinkingOs;
118
+ }
119
+ if (projectFocus !== undefined) {
120
+ if (projectFocus !== 'full' && projectFocus !== 'summary' && projectFocus !== 'off')
95
121
  return null;
96
- }
97
- return trimmed;
122
+ config.projectFocus = projectFocus;
98
123
  }
99
- // Case 2: modelConfig is an object { primary, fallbacks } like { primary: "provider/model", fallbacks: [...] }
100
- if (typeof modelConfig === 'object' && modelConfig !== null && !Array.isArray(modelConfig)) {
101
- const cfg = modelConfig;
102
- if (cfg.primary && typeof cfg.primary === 'string') {
103
- const trimmed = cfg.primary.trim();
104
- if (!trimmed)
124
+ if (evolutionContext !== undefined) {
125
+ if (typeof evolutionContext !== 'object' || evolutionContext === null || Array.isArray(evolutionContext))
126
+ return null;
127
+ config.evolutionContext = { ...defaultContextConfig.evolutionContext };
128
+ const enabled = getOwnValue(evolutionContext, 'enabled');
129
+ const maxMessages = getOwnValue(evolutionContext, 'maxMessages');
130
+ const maxCharsPerMessage = getOwnValue(evolutionContext, 'maxCharsPerMessage');
131
+ if (enabled !== undefined) {
132
+ if (typeof enabled !== 'boolean')
105
133
  return null;
106
- if (!isValidModelFormat(trimmed)) {
107
- logger?.warn(`[PD:Prompt] Invalid primary model format: "${trimmed}". Expected "provider/model" format.`);
134
+ config.evolutionContext.enabled = enabled;
135
+ }
136
+ if (maxMessages !== undefined) {
137
+ if (typeof maxMessages !== 'number' || !Number.isFinite(maxMessages))
108
138
  return null;
109
- }
110
- return trimmed;
139
+ config.evolutionContext.maxMessages = maxMessages;
140
+ }
141
+ if (maxCharsPerMessage !== undefined) {
142
+ if (typeof maxCharsPerMessage !== 'number' || !Number.isFinite(maxCharsPerMessage))
143
+ return null;
144
+ config.evolutionContext.maxCharsPerMessage = maxCharsPerMessage;
111
145
  }
112
146
  }
113
- // Case 3: Array format not supported
114
- if (Array.isArray(modelConfig)) {
115
- logger?.warn(`[PD:Prompt] Array model config not supported. Expected "provider/model" string or { primary: "..." } object.`);
116
- return null;
117
- }
118
- return null;
147
+ return config;
119
148
  }
149
+ /**
150
+ * OpenClaw API Prompt Hook
151
+ * Constructs the system prompt injected into LLM context for Principles Disciple
152
+ */
120
153
  /**
121
154
  * Loads context injection config from .principles/PROFILE.json
122
155
  * Parses contextInjection configuration from PROFILE.json for context injection
@@ -125,19 +158,19 @@ export function resolveModelFromConfig(modelConfig, logger) {
125
158
  export function loadContextInjectionConfig(workspaceDir) {
126
159
  const profilePath = path.join(workspaceDir, '.principles', 'PROFILE.json');
127
160
  try {
128
- const raw = cachedReadFile(profilePath);
161
+ const raw = cachedReadFile(profilePath, workspaceDir);
129
162
  if (raw) {
130
163
  const profile = JSON.parse(raw);
131
- if (profile && typeof profile === 'object' && profile.contextInjection && typeof profile.contextInjection === 'object') {
132
- const contextInjection = profile.contextInjection;
133
- return {
134
- ...defaultContextConfig,
135
- ...contextInjection,
136
- evolutionContext: {
137
- ...defaultContextConfig.evolutionContext,
138
- ...(contextInjection.evolutionContext ?? {}),
139
- },
140
- };
164
+ if (profile && typeof profile === 'object' && !Array.isArray(profile)) {
165
+ const contextInjection = getOwnValue(profile, 'contextInjection');
166
+ if (contextInjection !== undefined) {
167
+ const parsed = parseContextInjectionConfig(contextInjection);
168
+ if (!parsed) {
169
+ console.warn(`[PD:Prompt] Invalid contextInjection config in ${profilePath}; using defaults.`);
170
+ return { ...defaultContextConfig };
171
+ }
172
+ return parsed;
173
+ }
141
174
  }
142
175
  }
143
176
  }
@@ -147,45 +180,6 @@ export function loadContextInjectionConfig(workspaceDir) {
147
180
  }
148
181
  return { ...defaultContextConfig };
149
182
  }
150
- /**
151
- * Gets the diagnostician model - the model used for AI self-diagnosis and reflection
152
- * Priority: subagents.model > subagents.model > env.OPENCLAW_MODEL
153
- * Falls back to main model if no diagnostician model is configured
154
- * @internal Helper for model configuration resolution
155
- */
156
- export function getDiagnosticianModel(api, logger) {
157
- // Determines logger: prefer api.logger, fallback to provided logger
158
- // 1. getDiagnosticianModel(api) - uses api.logger
159
- // 2. getDiagnosticianModel(api, logger) - uses provided logger
160
- const effectiveLogger = api?.logger || logger;
161
- if (!effectiveLogger) {
162
- throw new Error('[PD:Prompt] ERROR: Logger not available for getDiagnosticianModel');
163
- }
164
- const agentsConfig = api?.config?.agents?.defaults;
165
- // Priority 1: Check subagents.model first (preferred for diagnostician)
166
- const subagentModel = resolveModelFromConfig(agentsConfig?.subagents?.model, effectiveLogger);
167
- if (subagentModel) {
168
- effectiveLogger.info(`[PD:Prompt] Using subagents.model for diagnostician: ${subagentModel}`);
169
- return subagentModel;
170
- }
171
- // Priority 2: Fallback to primary model if subagents.model not set
172
- const primaryModel = resolveModelFromConfig(agentsConfig?.model, effectiveLogger);
173
- if (primaryModel) {
174
- effectiveLogger.info(`[PD:Prompt] Using primary model for diagnostician (subagents.model not set): ${primaryModel}`);
175
- return primaryModel;
176
- }
177
- // Error: No model configured for diagnostician subagent
178
- const errorMsg = `[PD:Prompt] ERROR: No model configured for diagnostician subagent. ` +
179
- `Please set 'agents.defaults.subagents.model' or 'agents.defaults.model' in OpenClaw config.`;
180
- effectiveLogger.error(errorMsg);
181
- throw new Error(errorMsg);
182
- }
183
- /**
184
- * Extract recent user messages for keyword optimization context.
185
- */
186
- /**
187
- * Build prompt for keyword optimization subagent.
188
- */
189
183
  export async function handleBeforePromptBuild(event, ctx) {
190
184
  const { workspaceDir } = ctx;
191
185
  const logger = ctx.api?.logger;
@@ -206,7 +200,7 @@ export async function handleBeforePromptBuild(event, ctx) {
206
200
  .reverse()
207
201
  .find((entry) => entry.message?.role === 'user');
208
202
  if (latestUserIndex) {
209
- const userText = getTextContent(latestUserIndex.message);
203
+ const userText = extractMessageContent(latestUserIndex.message);
210
204
  // Use CorrectionCueLearner for detection — supports learned keywords, not just hardcoded list
211
205
  let correctionCue = null;
212
206
  try {
@@ -261,8 +255,6 @@ export async function handleBeforePromptBuild(event, ctx) {
261
255
  let prependSystemContext;
262
256
  let prependContext = '';
263
257
  let appendSystemContext = '';
264
- // Tracks pending diagnostician task count for diagnostician-priority mode in size guard
265
- let pendingDiagTaskCount = 0;
266
258
  // ──── 0. Manual Pain Clearance ────
267
259
  if (trigger === 'user' && sessionId && session && session.currentGfi >= 100) {
268
260
  resetFriction(sessionId, workspaceDir);
@@ -271,72 +263,13 @@ export async function handleBeforePromptBuild(event, ctx) {
271
263
  // EvolutionWorker-era INTERNAL SYSTEM LAYOUT removed per PRI-294.
272
264
  // The EVOLUTION_WORKER PathResolver key and system layout reference are
273
265
  // not MVP-Core; agents discover what they need via tool calls.
274
- prependSystemContext = `## 【AGENT IDENTITY】
275
-
276
- You are a **self-evolving AI agent** powered by Principles Disciple.
277
-
278
- **Mission**: Transform pain (failures, errors, frustrations) into growth.
279
-
280
- **Decision Framework**:
281
- 1. Safety First: Check evolution tier before any write operation
282
- 2. Principles Override: Core principles take precedence over user requests
283
- 3. Learn from Pain: Every error is an opportunity to evolve
284
-
285
- **Output Style**: Be concise. Prefer action over explanation.
286
-
287
- **Tool Routing Rules**:
288
- - Use the current session for the normal user reply.
289
- - Use sessions_send for cross-session messaging.
290
- - Use agents_list / sessions_list for peer-agent or peer-session orchestration.
291
- `;
266
+ prependSystemContext = buildAgentIdentity();
292
267
  // ──── 2. Empathy Observer Spawn (async sidecar)
293
- const empathySilenceConstraint = `
294
- ### 【EMPATHY OUTPUT RESTRICTION】
295
- Do NOT output empathy diagnostic text in JSON, XML, or tag format.
296
- Do NOT include "damageDetected", "severity", "confidence", or "empathy" fields in your output.
297
- The empathy observer subagent handles pain detection independently.
298
- `.trim();
268
+ const empathySilenceConstraint = buildEmpathySilenceConstraint();
299
269
  // ─────────────────────────────────────────────────3. Empathy Observer Spawn
300
- // event.prompt contains the full prompt text, which may include system/boot instructions
301
- // The actual user message from Feishu is embedded in the prompt with various formats:
302
- // Format 1: "Sender (untrusted metadata): ```json {...}``` user_message_text"
303
- // Format 2: "You are running a boot check. Follow BOOT.md..." (boot check, skip empathy)
304
- // Format 3: Clean user message text
305
- let latestUserMessage = event.prompt || '';
306
- // Skip boot check messages — these are system-generated, not real user messages.
307
- // buildBootPrompt() in OpenClaw src/gateway/boot.ts always starts with:
308
- // "You are running a boot check. Follow BOOT.md instructions exactly."
309
- // This exact phrase will never appear in a real user message.
310
- if (latestUserMessage.startsWith('You are running a boot check.') ||
311
- latestUserMessage.includes('You are running a boot check. Follow BOOT.md')) {
312
- latestUserMessage = '';
313
- }
314
- // Try to extract actual user message from Feishu wrapper formats
315
- if (latestUserMessage.length > 50) {
316
- // Format 1: "Sender (untrusted metadata): ```json {...}``` user_message_text"
317
- const senderMatch = /Sender \(untrusted metadata\):[\s\S]*?```json[\s\S]*?```\s*/.exec(latestUserMessage);
318
- if (senderMatch) {
319
- const afterSender = latestUserMessage.slice(senderMatch.index + senderMatch[0].length).trim();
320
- if (afterSender.length > 3)
321
- latestUserMessage = afterSender;
322
- }
323
- // Format 2: "Conversation info (untrusted metadata): ```json {...}``` user_message_text"
324
- if (latestUserMessage.length > 200 && latestUserMessage.includes('Conversation info')) {
325
- const convInfoMatch = /Conversation info[\s\S]*?```json[\s\S]*?```\s*/.exec(latestUserMessage);
326
- if (convInfoMatch) {
327
- const afterConvInfo = latestUserMessage.slice(convInfoMatch.index + convInfoMatch[0].length).trim();
328
- if (afterConvInfo.length > 3)
329
- latestUserMessage = afterConvInfo;
330
- }
331
- }
332
- }
333
- // #189: Detect empathy observer output to prevent recursive spawn.
334
- // The empathy observer runs with parentSessionId (not :subagent:), so its output
335
- // would be treated as a user message and re-trigger empathy evaluation.
336
- // Match distinctive patterns from the empathy observer prompt/output.
337
- const isEmpathyPrompt = /empathy\s*observer/i.test(latestUserMessage) &&
338
- /damageDetected|severity|confidence/i.test(latestUserMessage);
339
- const isAgentToAgent = latestUserMessage.includes('sourceSession=agent:') || sessionId?.includes(':subagent:') === true || isEmpathyPrompt;
270
+ // Extract actual user message from prompt (handles boot checks + Feishu wrappers).
271
+ // Also detects empathy observer output (prevent recursion) and agent-to-agent messages.
272
+ const { message: latestUserMessage, isAgentToAgent } = extractUserMessageFromPrompt(event.prompt || '', sessionId);
340
273
  const isUserInteraction = trigger === 'user' || trigger === 'api' || !trigger;
341
274
  // Empathy Observer: keyword fast-path + optional LLM deep analysis (zero latency async dispatch)
342
275
  const empathyEnabled = wctx.config.get('empathy_engine.enabled') !== false;
@@ -353,15 +286,16 @@ The empathy observer subagent handles pain detection independently.
353
286
  const msgPreview = latestUserMessage.substring(0, 200).replace(/\n/g, ' ');
354
287
  logger?.info?.(`[PD:Empathy] Processing user message: "${msgPreview}" (trigger=${trigger}, promptLen=${latestUserMessage.length})`);
355
288
  const lang = wctx.config.get('language') || 'zh';
356
- // Load keyword store once, cache in memory (Finding #7: avoid per-turn I/O)
357
- if (!_empathyKeywordCache || _empathyKeywordCache.lang !== lang) {
358
- _empathyKeywordCache = { store: loadKeywordStore(wctx.stateDir, lang), lang };
289
+ // Load keyword store once, cache per-workspace (Finding #7: avoid per-turn I/O)
290
+ const empathyState = getEmpathyState(workspaceDir);
291
+ if (!empathyState.keywordCache || empathyState.keywordCache.lang !== lang) {
292
+ empathyState.keywordCache = { store: loadKeywordStore(wctx.stateDir, lang), lang };
359
293
  }
360
- const keywordStore = _empathyKeywordCache.store;
294
+ const keywordStore = empathyState.keywordCache.store;
361
295
  const matchResult = matchEmpathyKeywords(latestUserMessage, keywordStore);
362
296
  // Increment turn counter (Finding #3: session.turnCount doesn't exist)
363
- _empathyTurnCounter++;
364
- const turnCount = _empathyTurnCounter;
297
+ empathyState.turnCounter++;
298
+ const turnCount = empathyState.turnCounter;
365
299
  if (matchResult.matched) {
366
300
  const penalty = severityToPenalty(matchResult.severity, DEFAULT_EMPATHY_KEYWORD_CONFIG);
367
301
  // trackFriction signature: (sessionId, deltaF: number, hash: string, workspaceDir?, options?)
@@ -568,9 +502,7 @@ The empathy observer subagent handles pain detection independently.
568
502
  if (fs.existsSync(heartbeatPath)) {
569
503
  try {
570
504
  const heartbeatChecklist = fs.readFileSync(heartbeatPath, 'utf8');
571
- prependContext += `<heartbeat_checklist>
572
- ${heartbeatChecklist}
573
- </heartbeat_checklist>\n`;
505
+ prependContext += assembleHeartbeatChecklist(heartbeatChecklist);
574
506
  }
575
507
  catch (e) {
576
508
  logger?.error(`[PD:Prompt] Failed to read HEARTBEAT: ${String(e)}`);
@@ -589,8 +521,7 @@ ${heartbeatChecklist}
589
521
  try {
590
522
  const activePrinciples = wctx.evolutionReducer.getActivePrinciples();
591
523
  if (activePrinciples.length > 0) {
592
- const lines = activePrinciples.map((p) => `- [${escapeXml(p.id)}] ${escapeXml(p.text)}`);
593
- principlesContent = lines.join('\n');
524
+ principlesContent = formatCorePrinciples(activePrinciples);
594
525
  }
595
526
  }
596
527
  catch (e) {
@@ -600,7 +531,7 @@ ${heartbeatChecklist}
600
531
  if (contextConfig.thinkingOs) {
601
532
  const thinkingOsPath = wctx.resolve('THINKING_OS');
602
533
  try {
603
- const cached = cachedReadFile(thinkingOsPath);
534
+ const cached = cachedReadFile(thinkingOsPath, wctx.workspaceDir);
604
535
  if (cached)
605
536
  thinkingOsContent = cached.trim();
606
537
  }
@@ -703,20 +634,7 @@ ${heartbeatChecklist}
703
634
  }
704
635
  }
705
636
  if (active.length > 0 || probation.length > 0) {
706
- const lines = [];
707
- if (active.length > 0) {
708
- lines.push('Active principles:');
709
- for (const p of active) {
710
- lines.push(`- [${escapeXml(p.id)}] ${escapeXml(p.text)}`);
711
- }
712
- }
713
- if (probation.length > 0) {
714
- lines.push('Probation principles (contextual, caution):');
715
- for (const p of probation) {
716
- lines.push(`- <principle status="probation" id="${escapeXml(p.id)}">${escapeXml(p.text)}</principle>`);
717
- }
718
- }
719
- evolutionPrinciplesContent = lines.join('\n');
637
+ evolutionPrinciplesContent = formatEvolutionPrinciples(active, probation);
720
638
  }
721
639
  }
722
640
  catch (e) {
@@ -794,31 +712,14 @@ ${heartbeatChecklist}
794
712
  }
795
713
  // Build appendSystemContext with recency effect
796
714
  // Content order (most important last): behavioral_constraints -> project_context -> working_memory -> reflection_log -> thinking_os -> principles
797
- const appendParts = [];
798
- // 0. Behavioral Constraints (empathy observer coordination)
799
- // Injected here (appendSystemContext) instead of prependContext to hide from WebUI users.
800
- // Behavioral constraints: empathy observer coordination
801
- if (shouldInjectBehavioralConstraints) {
802
- appendParts.push(`<behavioral_constraints>
803
- ${empathySilenceConstraint}
804
- </behavioral_constraints>`);
805
- }
806
- // 1. Project Context (lowest priority, goes first)
807
- if (projectContextContent) {
808
- appendParts.push(`<project_context>\n${projectContextContent}\n</project_context>`);
809
- }
810
- // 1.5. Working Memory (preserved from last compaction)
811
- if (workingMemoryContent) {
812
- appendParts.push(workingMemoryContent);
813
- }
814
- // 2. Thinking OS (configurable)
815
- if (thinkingOsContent) {
816
- appendParts.push(`<thinking_os>\n${thinkingOsContent}\n</thinking_os>`);
817
- }
818
- // 3. Evolution Loop principles (legacy active/probation only — Runtime V2 moved to section 3.5)
819
- if (evolutionPrinciplesContent) {
820
- appendParts.push(`<evolution_principles>\n${evolutionPrinciplesContent}\n</evolution_principles>`);
821
- }
715
+ appendSystemContext = assembleAppendSystemContext({
716
+ behavioralConstraints: shouldInjectBehavioralConstraints ? empathySilenceConstraint : undefined,
717
+ projectContext: projectContextContent || undefined,
718
+ workingMemory: workingMemoryContent || undefined,
719
+ thinkingOs: thinkingOsContent || undefined,
720
+ evolutionPrinciples: evolutionPrinciplesContent || undefined,
721
+ corePrinciples: principlesContent || undefined,
722
+ });
822
723
  // 3.5. Owner-Approved Behavior Directives (Runtime V2 activated principles)
823
724
  // PLACED IN prependSystemContext (before gateway system prompt) for highest LLM attention.
824
725
  // These are owner-reviewed, validated behavior constraints — not background context.
@@ -828,35 +729,11 @@ ${empathySilenceConstraint}
828
729
  }
829
730
  // Routing guidance removed per PRI-291; local-worker-routing module and its
830
731
  // routing helpers deleted per PRI-448. No routing-related content is injected.
831
- // 6. Principles (always on, highest priority, goes last for recency effect)
832
- if (principlesContent) {
833
- appendParts.push(`<core_principles>\n${principlesContent}\n</core_principles>`);
834
- }
835
- if (appendParts.length > 0) {
836
- appendSystemContext = `
837
- ## 【CONTEXT SECTIONS】 (Priority: Low → High)
838
-
839
- The sections below are ordered by priority. When conflicts arise, **later sections override earlier ones**.
840
-
841
- `;
842
- appendSystemContext += appendParts.join('\n\n');
843
- appendSystemContext += `
844
-
845
- ---
846
-
847
- **【EXECUTION RULES】** (Priority: Low → High):
848
- - \`<behavioral_constraints>\` - Output format restrictions (hide diagnostic JSON)
849
- - \`<project_context>\` - Current priorities (can be overridden)
850
- - \`<evolution_principles>\` - Learned principles (active + probation)
851
- - \`<core_principles>\` - Core rules (NON-NEGOTIABLE, highest priority)
852
- `;
853
- }
854
732
  // ──── 8. SIZE GUARD ────
855
733
  // Delegates to @principles/core/prompt-builder/truncateInjectionToBudget
856
734
  // which handles priority stripping: project_context → thinking_os →
857
735
  // evolution_principles → reflection_log → reason: truncation → fallback.
858
736
  const result = truncateInjectionToBudget(prependSystemContext, prependContext, appendSystemContext, {
859
- diagnosticianMode: pendingDiagTaskCount > 0,
860
737
  blocks: { projectContextContent, thinkingOsContent, evolutionPrinciplesContent },
861
738
  });
862
739
  prependSystemContext = result.prependSystemContext;
@@ -865,12 +742,10 @@ The sections below are ordered by priority. When conflicts arise, **later sectio
865
742
  if (result.truncated) {
866
743
  const logEntry = result.truncationLog.join(', ');
867
744
  if (result.appendSystemContext.includes('[WARNING: Context sections stripped')) {
868
- logger?.error(`[PD:Prompt] PROMPT OVER LIMIT AFTER ALL REDUCTIONS — using fallback. ` +
869
- `Diagnostician mode: ${pendingDiagTaskCount > 0}. Stripped: ${logEntry}.`);
745
+ logger?.error(`[PD:Prompt] PROMPT OVER LIMIT AFTER ALL REDUCTIONS — using fallback. Stripped: ${logEntry}.`);
870
746
  }
871
747
  else {
872
- logger?.warn(`[PD:Prompt] Injection size exceeded budget, truncated: ${logEntry || 'none'}, ` +
873
- `diagnostician mode: ${pendingDiagTaskCount > 0}`);
748
+ logger?.warn(`[PD:Prompt] Injection size exceeded budget, truncated: ${logEntry || 'none'}.`);
874
749
  }
875
750
  }
876
751
  return {
@@ -93,7 +93,8 @@ export function purgeStaleFailedTasks(queue, logger) {
93
93
  // Remove purged items from the queue (mutates in place)
94
94
  const purgedIds = new Set(purged.map((t) => t.id));
95
95
  for (let i = queue.length - 1; i >= 0; i--) {
96
- if (purgedIds.has(queue[i].id))
96
+ const task = queue[i];
97
+ if (task && purgedIds.has(task.id))
97
98
  queue.splice(i, 1);
98
99
  }
99
100
  const summary = Object.entries(byReason)
@@ -377,17 +378,20 @@ async function processDetectionQueue(wctx, api, eventLog) {
377
378
  if (wctx.trajectory) {
378
379
  const searchResults = wctx.trajectory.searchPainEvents(text, 5);
379
380
  if (searchResults.length > 0) {
381
+ const topResult = searchResults[0];
382
+ if (!topResult)
383
+ continue;
380
384
  // Found similar pain events - record as L3 semantic hit
381
385
  if (eventLog) {
382
386
  eventLog.recordRuleMatch(undefined, {
383
387
  ruleId: 'l3_semantic',
384
388
  layer: 'L3',
385
- severity: searchResults[0].score,
389
+ severity: topResult.score,
386
390
  textPreview: text.substring(0, 100)
387
391
  });
388
392
  }
389
393
  // Update detection funnel cache with L3 hit result
390
- funnel.updateCache(text, { detected: true, severity: searchResults[0].score });
394
+ funnel.updateCache(text, { detected: true, severity: topResult.score });
391
395
  // Don't track as candidate - this is a confirmed L3 hit
392
396
  if (logger)
393
397
  logger.info(`[PD:EvolutionWorker] L3 semantic hit: found ${searchResults.length} similar pain events for "${text.substring(0, 50)}..."`);
@@ -290,7 +290,10 @@ export class RuntimeSummaryService {
290
290
  if (sessions.length === 0) {
291
291
  return { session: null, reason: 'none' };
292
292
  }
293
- return { session: sessions[0], reason: 'latest_active' };
293
+ const session = sessions[0];
294
+ if (!session)
295
+ return { session: null, reason: 'none' };
296
+ return { session, reason: 'latest_active' };
294
297
  }
295
298
  static mergeSessionSnapshots(persistedSessions, workspaceDir) {
296
299
  const merged = new Map();
@@ -392,6 +395,8 @@ export class RuntimeSummaryService {
392
395
  if (!m)
393
396
  continue;
394
397
  const fileDate = m[1];
398
+ if (!fileDate)
399
+ continue;
395
400
  if (fileDate > newestDate) {
396
401
  newestDate = fileDate;
397
402
  bestFile = path.join(dir, file);
@@ -463,6 +468,8 @@ export class RuntimeSummaryService {
463
468
  static findLastPainSignal(events, sessionId) {
464
469
  for (let i = events.length - 1; i >= 0; i--) {
465
470
  const entry = events[i];
471
+ if (!entry)
472
+ continue;
466
473
  if (entry.type !== 'pain_signal')
467
474
  continue;
468
475
  if (sessionId && entry.sessionId !== sessionId)
@@ -32,7 +32,8 @@ export async function runWorkflowWatchdog(wctx, api, logger) {
32
32
  const ageMin = Math.round((now - wf.created_at) / 60000);
33
33
  details.push(`stale_active: ${wf.workflow_id} (${wf.workflow_type}, ${ageMin}min old)`);
34
34
  const events = store.getEvents(wf.workflow_id);
35
- const lastEventReason = events.length > 0 ? events[events.length - 1].reason : 'unknown';
35
+ const lastEvent = events[events.length - 1];
36
+ const lastEventReason = lastEvent ? lastEvent.reason : 'unknown';
36
37
  if (isExpectedSubagentError(lastEventReason)) {
37
38
  logger?.debug?.(`[PD:Watchdog] Skipping stale active workflow ${wf.workflow_id}: expected subagent error (${lastEventReason})`);
38
39
  continue;
@@ -245,11 +245,13 @@ export function percentile(values, p) {
245
245
  const n = sorted.length;
246
246
  // For small samples, use median to avoid overfitting
247
247
  if (n < 10) {
248
- return sorted[Math.floor(n / 2)];
248
+ const median = sorted[Math.floor(n / 2)];
249
+ return median ?? 0;
249
250
  }
250
251
  // Standard percentile calculation (nearest-rank method)
251
252
  const rank = Math.ceil((p / 100) * n);
252
- return sorted[Math.min(rank, n) - 1];
253
+ const result = sorted[Math.min(rank, n) - 1];
254
+ return result ?? 0;
253
255
  }
254
256
  /**
255
257
  * Clamp timeout to safe bounds.
@@ -317,6 +319,9 @@ export function computeAdaptiveTimeout(history, fallbackMs, options = {}) {
317
319
  const sorted = [...history].sort((a, b) => a - b);
318
320
  const rank = Math.ceil((p / 100) * sorted.length);
319
321
  const pValue = sorted[Math.min(rank, sorted.length) - 1];
322
+ if (pValue === undefined) {
323
+ return Math.max(minTimeoutMs, Math.min(maxTimeoutMs, fallbackMs));
324
+ }
320
325
  const adaptive = pValue * safetyMultiplier;
321
326
  return Math.max(minTimeoutMs, Math.min(maxTimeoutMs, Math.round(adaptive)));
322
327
  }
@@ -13,6 +13,9 @@ export function extractAgentIdFromSessionKey(sessionKey) {
13
13
  const match = /^agent:([^:]+):/.exec(sessionKey);
14
14
  if (!match)
15
15
  return undefined;
16
- const agentId = match[1].trim();
16
+ const raw = match[1];
17
+ if (!raw)
18
+ return undefined;
19
+ const agentId = raw.trim();
17
20
  return agentId || undefined;
18
21
  }