principles-disciple 1.51.0 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Universal PainSignal schema for the Evolution SDK.
3
+ *
4
+ * This module defines a framework-agnostic pain signal that any AI agent
5
+ * framework can produce. It extends the existing PainFlagData format with
6
+ * additional structured fields (domain, severity, context) needed for
7
+ * cross-workspace evolution and multi-domain support.
8
+ *
9
+ * Validation uses @sinclair/typebox to match existing project patterns.
10
+ */
11
+ import { Type, type Static } from '@sinclair/typebox';
12
+ import { Value } from '@sinclair/typebox/value';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // PainSignal Schema
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Severity levels derived from pain score thresholds.
20
+ * - low: 0-39 (minor issue, informational)
21
+ * - medium: 40-69 (moderate error)
22
+ * - high: 70-89 (severe violation)
23
+ * - critical: 90-100 (systemic failure, spiral detected)
24
+ */
25
+ export const PainSeverity = Type.Union([
26
+ Type.Literal('low'),
27
+ Type.Literal('medium'),
28
+ Type.Literal('high'),
29
+ Type.Literal('critical'),
30
+ ]);
31
+
32
+ export type PainSeverity = Static<typeof PainSeverity>;
33
+
34
+ /**
35
+ * TypeBox schema for a universal pain signal.
36
+ *
37
+ * Every signal MUST have: source, score, timestamp, reason, sessionId,
38
+ * agentId, traceId, triggerTextPreview. Optional fields (domain, severity,
39
+ * context) default during validation.
40
+ */
41
+ export const PainSignalSchema = Type.Object({
42
+ /** What triggered this pain signal (e.g., 'tool_failure', 'human_intervention') */
43
+ source: Type.String({ minLength: 1 }),
44
+ /** Pain score 0-100 */
45
+ score: Type.Number({ minimum: 0, maximum: 100 }),
46
+ /** ISO 8601 timestamp */
47
+ timestamp: Type.String({ minLength: 1 }),
48
+ /** Human-readable reason / error description */
49
+ reason: Type.String({ minLength: 1 }),
50
+ /** Session ID — identifies which conversation this happened in */
51
+ sessionId: Type.String({ minLength: 1 }),
52
+ /** Agent ID — identifies which agent (main, builder, diagnostician, etc.) */
53
+ agentId: Type.String({ minLength: 1 }),
54
+ /** Correlation trace ID for linking events across the pipeline */
55
+ traceId: Type.String({ minLength: 1 }),
56
+ /** Preview of the text that triggered this pain */
57
+ triggerTextPreview: Type.String(),
58
+ /** Domain context (e.g., 'coding', 'writing', 'analysis') */
59
+ domain: Type.String({ default: 'coding' }),
60
+ /** Severity level derived from score */
61
+ severity: PainSeverity,
62
+ /** Additional structured context payload */
63
+ context: Type.Record(Type.String(), Type.Unknown()),
64
+ });
65
+
66
+ export type PainSignal = Static<typeof PainSignalSchema>;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Default Derivation
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Derives severity from a numeric pain score.
74
+ */
75
+ export function deriveSeverity(score: number): PainSeverity {
76
+ if (score >= 90) return 'critical';
77
+ if (score >= 70) return 'high';
78
+ if (score >= 40) return 'medium';
79
+ return 'low';
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Validation
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface PainSignalValidationResult {
87
+ valid: boolean;
88
+ errors: string[];
89
+ signal?: PainSignal;
90
+ }
91
+
92
+ /**
93
+ * Validates an arbitrary object against the PainSignal schema.
94
+ *
95
+ * Returns a structured result with:
96
+ * - `valid`: whether the input conforms to the schema
97
+ * - `errors`: human-readable list of validation failures
98
+ * - `signal`: the typed signal (only present when valid)
99
+ *
100
+ * Missing optional fields (domain, severity, context) are filled with defaults
101
+ * before validation so callers get a fully-formed signal back.
102
+ */
103
+ export function validatePainSignal(input: unknown): PainSignalValidationResult {
104
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
105
+ return { valid: false, errors: ['Input must be a non-null object'] };
106
+ }
107
+
108
+ const raw = input as Record<string, unknown>;
109
+
110
+ // Apply defaults for optional fields before validation
111
+ const hydrated = {
112
+ ...raw,
113
+ domain: raw.domain ?? 'coding',
114
+ severity: raw.severity ?? deriveSeverity(
115
+ typeof raw.score === 'number' ? raw.score : 0,
116
+ ),
117
+ context: raw.context ?? {},
118
+ };
119
+
120
+ // Collect TypeBox errors
121
+ const errors = [...Value.Errors(PainSignalSchema, hydrated)];
122
+ if (errors.length > 0) {
123
+ return {
124
+ valid: false,
125
+ errors: errors.map(
126
+ (e) => `${e.path ? `${e.path}: ` : ''}${e.message}`,
127
+ ),
128
+ };
129
+ }
130
+
131
+ return {
132
+ valid: true,
133
+ errors: [],
134
+ signal: Value.Cast(PainSignalSchema, hydrated) as PainSignal,
135
+ };
136
+ }
@@ -144,7 +144,7 @@ function diff(declared: PDTaskSpec[], actual: CronJob[]): DiffAction[] {
144
144
  function buildCronJob(
145
145
  task: PDTaskSpec,
146
146
  nowMs: number,
147
-
147
+ workspaceDir: string,
148
148
  logger?: { info?: (_: string) => void },
149
149
  ): CronJob {
150
150
  logger?.info?.(`[PD:Reconciler] Building cron job: ${task.name} (id=${task.id}, interval=${task.schedule.everyMs}ms)`);
@@ -159,9 +159,7 @@ function buildCronJob(
159
159
  wakeMode: 'now',
160
160
  payload: {
161
161
  kind: 'agentTurn',
162
-
163
-
164
- message: buildTaskPrompt(task, logger),
162
+ message: buildTaskPrompt(task, workspaceDir, logger),
165
163
  lightContext: task.execution.lightContext ?? true,
166
164
  timeoutSeconds: task.execution.timeoutSeconds ?? 120,
167
165
  toolsAllow: task.execution.toolsAllow,
@@ -180,7 +178,12 @@ function buildCronJob(
180
178
  }
181
179
 
182
180
 
183
- function buildTaskPrompt(task: PDTaskSpec, logger?: { info?: (_: string) => void }): string {
181
+ function buildTaskPrompt(task: PDTaskSpec, workspaceDir: string, logger?: { info?: (_: string) => void }): string {
182
+ // Resolve paths dynamically from workspaceDir instead of hardcoding
183
+ const stateDir = path.join(workspaceDir, '.state');
184
+ const empathyKeywordsPath = path.join(stateDir, 'empathy_keywords.json');
185
+ const eventsJsonlPath = path.join(stateDir, 'logs', 'events.jsonl');
186
+
184
187
  if (task.id === 'empathy-optimizer') {
185
188
  logger?.info?.(`[PD:Reconciler] Building empathy optimizer prompt`);
186
189
  return `You are the Principles Disciple Empathy Keyword Optimizer.
@@ -195,7 +198,7 @@ Analyze the current empathy keyword store and recent user message logs to:
195
198
 
196
199
  ### Step 1: Read current keyword store
197
200
  Use read_file to load:
198
- \`~/.openclaw/workspace-main/.state/empathy_keywords.json\`
201
+ \`${empathyKeywordsPath}\`
199
202
 
200
203
  Examine the "terms" object. For each term note:
201
204
  - weight (0.1-0.9): higher = stronger frustration signal
@@ -204,7 +207,7 @@ Examine the "terms" object. For each term note:
204
207
 
205
208
  ### Step 2: Read recent message logs
206
209
  Use search_file_content to scan:
207
- \`~/.openclaw/workspace-main/.state/logs/events.jsonl\`
210
+ \`${eventsJsonlPath}\`
208
211
 
209
212
  Look for user messages containing frustration signals:
210
213
  - Negation: "不对", "错了", "不行", "重做"
@@ -214,7 +217,7 @@ Look for user messages containing frustration signals:
214
217
 
215
218
  ### Step 3: Write updated keyword store
216
219
  Use write_file to save the updated store back to:
217
- \`~/.openclaw/workspace-main/.state/empathy_keywords.json\`
220
+ \`${empathyKeywordsPath}\`
218
221
 
219
222
  The file format is:
220
223
  \`\`\`json
@@ -304,7 +307,7 @@ export async function reconcilePDTasks(
304
307
  case 'CREATE':
305
308
  if (action.task) {
306
309
  if (!dryRun) {
307
- const job = buildCronJob(action.task, nowMs, logger);
310
+ const job = buildCronJob(action.task, nowMs, workspaceDir, logger);
308
311
  cronStore.jobs.push(job);
309
312
  logger.info?.(`[PD:Reconciler] Created job: ${action.task.name}`);
310
313
  }
@@ -315,7 +318,7 @@ export async function reconcilePDTasks(
315
318
  if (action.task && action.job) {
316
319
  if (!dryRun) {
317
320
  const idx = cronStore.jobs.indexOf(action.job);
318
- const newJob = buildCronJob(action.task, nowMs, logger);
321
+ const newJob = buildCronJob(action.task, nowMs, workspaceDir, logger);
319
322
  newJob.id = action.job.id;
320
323
  // Preserve original state — only CronService should recalculate nextRunAtMs
321
324
  newJob.state = {
@@ -449,7 +452,7 @@ export async function trigger(
449
452
  existingJob.deleteAfterRun = undefined;
450
453
  } else {
451
454
  log(`Creating new job for manual trigger: ${task.name}`);
452
- const newJob = buildCronJob(task, nowMs, { info: log });
455
+ const newJob = buildCronJob(task, nowMs, workspaceDir, { info: log });
453
456
  newJob.enabled = true;
454
457
  newJob.state.nextRunAtMs = nowMs;
455
458
  cronStore.jobs.push(newJob);
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Principle Injection — Budget-Aware Principle Selection
3
+ * ========================================================
4
+ *
5
+ * PURPOSE: Select principles for prompt injection within a character budget,
6
+ * prioritizing by priority tier (P0 > P1 > P2) and recency, while ensuring
7
+ * at least one P0 principle is included when available.
8
+ *
9
+ * DESIGN:
10
+ * - Sorts principles by priority (P0 first, then P1, then P2)
11
+ * - Within same priority, sorts by recency (createdAt descending)
12
+ * - Selects principles until the cumulative character budget is exceeded
13
+ * - Guarantees at least one P0 principle is included if any exist
14
+ * - Returns the selected principles and total character usage
15
+ *
16
+ * This replaces the previous hardcoded slice(-3)/slice(0,5) approach in
17
+ * prompt.ts with a budget-aware, priority-respecting selection algorithm.
18
+ */
19
+
20
+ import type { PrinciplePriority } from '../types/principle-tree-schema.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Minimal principle shape required for injection selection.
28
+ * Accepts both evolution-types.Principle and principle-tree-schema.Principle.
29
+ */
30
+ export interface InjectablePrinciple {
31
+ id: string;
32
+ text: string;
33
+ /** Priority level. Defaults to 'P1' if not set by the source. */
34
+ priority?: PrinciplePriority;
35
+ createdAt: string;
36
+ }
37
+
38
+ /**
39
+ * Result of principle selection for injection.
40
+ */
41
+ export interface PrincipleSelectionResult {
42
+ /** Selected principles in injection order (priority-first, then recency) */
43
+ selected: InjectablePrinciple[];
44
+ /** Total character count of selected principles' formatted output */
45
+ totalChars: number;
46
+ /** Number of principles by priority tier */
47
+ breakdown: {
48
+ p0: number;
49
+ p1: number;
50
+ p2: number;
51
+ };
52
+ /** Whether at least one P0 principle was included */
53
+ hasP0: boolean;
54
+ /** Whether the selection was truncated due to budget */
55
+ wasTruncated: boolean;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Priority Ordering
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const PRIORITY_ORDER: Record<PrinciplePriority, number> = {
63
+ P0: 0,
64
+ P1: 1,
65
+ P2: 2,
66
+ };
67
+
68
+ /**
69
+ * Compare two principles for sorting.
70
+ * Primary: priority (P0 < P1 < P2 — lower is higher priority).
71
+ * Secondary: recency (newer createdAt first).
72
+ */
73
+ function comparePrinciples(a: InjectablePrinciple, b: InjectablePrinciple): number {
74
+ const priorityA = PRIORITY_ORDER[a.priority ?? 'P1'] ?? 99;
75
+ const priorityB = PRIORITY_ORDER[b.priority ?? 'P1'] ?? 99;
76
+
77
+ if (priorityA !== priorityB) {
78
+ return priorityA - priorityB;
79
+ }
80
+
81
+ // Same priority: sort by recency (newer first)
82
+ return b.createdAt.localeCompare(a.createdAt);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Formatting
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Format a single principle for injection.
91
+ * Returns the formatted string including ID and text.
92
+ *
93
+ * Format: "- [ID] text" (matches existing prompt.ts format)
94
+ */
95
+ export function formatPrinciple(p: InjectablePrinciple): string {
96
+ return `- [${p.id}] ${p.text}`;
97
+ }
98
+
99
+ /**
100
+ * Calculate the character length of a formatted principle, including newline.
101
+ */
102
+ function formattedLength(p: InjectablePrinciple): number {
103
+ return formatPrinciple(p).length + 1; // +1 for newline separator
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Selection Algorithm
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Select principles for prompt injection within a character budget.
112
+ *
113
+ * Algorithm:
114
+ * 1. Sort all principles by priority (P0 > P1 > P2), then by recency
115
+ * 2. Iterate through sorted principles, accumulating character count
116
+ * 3. Stop when adding the next principle would exceed budgetChars
117
+ * 4. Ensure at least one P0 principle is included (even if it exceeds budget)
118
+ *
119
+ * @param principles - All available principles to select from
120
+ * @param budgetChars - Maximum character budget for formatted output
121
+ * @returns Selection result with chosen principles and metadata
122
+ */
123
+ export function selectPrinciplesForInjection(
124
+ principles: InjectablePrinciple[],
125
+ budgetChars: number,
126
+ ): PrincipleSelectionResult {
127
+ if (principles.length === 0) {
128
+ return {
129
+ selected: [],
130
+ totalChars: 0,
131
+ breakdown: { p0: 0, p1: 0, p2: 0 },
132
+ hasP0: false,
133
+ wasTruncated: false,
134
+ };
135
+ }
136
+
137
+ // Sort by priority then recency
138
+ const sorted = [...principles].sort(comparePrinciples);
139
+
140
+ const selected: InjectablePrinciple[] = [];
141
+ let totalChars = 0;
142
+ let p0Included = false;
143
+ let wasTruncated = false;
144
+
145
+ for (const principle of sorted) {
146
+ const cost = formattedLength(principle);
147
+
148
+ // Check if adding this principle would exceed budget
149
+ if (totalChars + cost > budgetChars) {
150
+ // Special case: if no P0 has been included yet, force-include the first P0
151
+ // even if it exceeds the budget (P0 principles are critical)
152
+ if (!p0Included && principle.priority === 'P0') {
153
+ selected.push(principle);
154
+ totalChars += cost;
155
+ p0Included = true;
156
+ wasTruncated = true;
157
+ // Continue to try to fit more principles after this forced inclusion
158
+ continue;
159
+ }
160
+
161
+ wasTruncated = true;
162
+ break;
163
+ }
164
+
165
+ selected.push(principle);
166
+ totalChars += cost;
167
+ if (principle.priority === 'P0') {
168
+ p0Included = true;
169
+ }
170
+ }
171
+
172
+ // Safety net: if we went through all principles and still no P0 included
173
+ // (because P0 was beyond budget threshold), force-include the first P0
174
+ if (!p0Included) {
175
+ const firstP0 = sorted.find(p => p.priority === 'P0');
176
+ if (firstP0 && !selected.includes(firstP0)) {
177
+ // Insert P0 at the beginning of selected (highest priority)
178
+ selected.unshift(firstP0);
179
+ totalChars += formattedLength(firstP0);
180
+ p0Included = true;
181
+ }
182
+ }
183
+
184
+ const breakdown = {
185
+ p0: selected.filter(p => (p.priority ?? 'P1') === 'P0').length,
186
+ p1: selected.filter(p => (p.priority ?? 'P1') === 'P1').length,
187
+ p2: selected.filter(p => (p.priority ?? 'P1') === 'P2').length,
188
+ };
189
+
190
+ return {
191
+ selected,
192
+ totalChars,
193
+ breakdown,
194
+ hasP0: p0Included,
195
+ wasTruncated,
196
+ };
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Default Budget
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Default character budget for principle injection.
205
+ * 4000 characters is ~800 tokens, leaving ample room for other prompt sections
206
+ * within the 10K character injection limit.
207
+ */
208
+ export const DEFAULT_PRINCIPLE_BUDGET = 4000;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * PrincipleInjector interface for the Evolution SDK.
3
+ *
4
+ * Wraps the existing principle injection logic into a framework-agnostic
5
+ * contract. Per D-05, this delegates to selectPrinciplesForInjection and
6
+ * formatPrinciple without any behavioral changes.
7
+ *
8
+ * Per D-06, InjectionContext contains only generic fields (domain, sessionId,
9
+ * budgetChars) -- no framework-specific fields.
10
+ */
11
+ import type { InjectablePrinciple } from './principle-injection.js';
12
+ import { selectPrinciplesForInjection, formatPrinciple } from './principle-injection.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // InjectionContext
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Generic injection context -- no framework-specific fields. */
19
+ export interface InjectionContext {
20
+ /** Domain context (e.g., 'coding', 'writing', 'analysis') */
21
+ domain: string;
22
+ /** Session identifier */
23
+ sessionId: string;
24
+ /** Maximum characters allowed for injected principles */
25
+ budgetChars: number;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // PrincipleInjector Interface
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Framework-agnostic principle injection interface.
34
+ *
35
+ * Wraps the existing budget-aware selection and formatting logic.
36
+ * Framework adapters convert their context to InjectionContext before calling.
37
+ */
38
+ export interface PrincipleInjector {
39
+ /**
40
+ * Select principles relevant for injection within a character budget.
41
+ * Delegates to selectPrinciplesForInjection from principle-injection.ts.
42
+ *
43
+ * @param principles - All available principles to select from
44
+ * @param context - Generic injection context with budget constraint
45
+ * @returns Selected principles in injection order
46
+ */
47
+ getRelevantPrinciples(
48
+ principles: InjectablePrinciple[],
49
+ context: InjectionContext,
50
+ ): InjectablePrinciple[];
51
+
52
+ /**
53
+ * Format a single principle for prompt injection.
54
+ * Delegates to formatPrinciple from principle-injection.ts.
55
+ *
56
+ * Format: "- [ID] text"
57
+ *
58
+ * @param principle - The principle to format
59
+ * @returns Formatted string for prompt injection
60
+ */
61
+ formatForInjection(principle: InjectablePrinciple): string;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Default Implementation
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Default implementation that delegates to existing functions.
70
+ * Zero rewrite risk -- behavior is identical to calling the functions directly.
71
+ */
72
+ export class DefaultPrincipleInjector implements PrincipleInjector {
73
+ getRelevantPrinciples(
74
+ principles: InjectablePrinciple[],
75
+ context: InjectionContext,
76
+ ): InjectablePrinciple[] {
77
+ const result = selectPrinciplesForInjection(principles, context.budgetChars);
78
+ return result.selected;
79
+ }
80
+
81
+ formatForInjection(principle: InjectablePrinciple): string {
82
+ return formatPrinciple(principle);
83
+ }
84
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * StorageAdapter interface for the Evolution SDK.
3
+ *
4
+ * This interface decouples the evolution engine from specific persistence
5
+ * implementations (file system, SQLite, remote API). All higher-level
6
+ * modules that need to read or mutate the principle ledger should depend
7
+ * on this interface rather than importing principle-tree-ledger directly.
8
+ *
9
+ * The interface uses HybridLedgerStore from principle-tree-ledger as the
10
+ * canonical store shape, but future adapters can map alternative backends
11
+ * to this shape.
12
+ */
13
+ import type { HybridLedgerStore } from './principle-tree-ledger.js';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // StorageAdapter Interface
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Abstract storage adapter for the principle ledger.
21
+ *
22
+ * Implementations must guarantee:
23
+ * - Atomic writes (no partial/corrupted state on crash)
24
+ * - Thread-safe concurrent access (locking or equivalent)
25
+ * - Consistent read-after-write visibility
26
+ *
27
+ * The `mutateLedger` method is the preferred way to perform read-modify-write
28
+ * cycles. It encapsulates locking so callers never need to manage it.
29
+ */
30
+ export interface StorageAdapter {
31
+ /**
32
+ * Load the current ledger state from persistence.
33
+ *
34
+ * If no persisted state exists (first run), returns an empty store.
35
+ */
36
+ loadLedger(): Promise<HybridLedgerStore>;
37
+
38
+ /**
39
+ * Persist the full ledger state.
40
+ *
41
+ * Must be atomic — partial writes must never be visible to readers.
42
+ */
43
+ saveLedger(store: HybridLedgerStore): Promise<void>;
44
+
45
+ /**
46
+ * Perform a read-modify-write cycle with automatic locking.
47
+ *
48
+ * The `mutate` function receives the current store and may return a
49
+ * value synchronously or via Promise. The store is persisted after
50
+ * the mutate function resolves, regardless of its return value.
51
+ *
52
+ * If two concurrent `mutateLedger` calls overlap, one must wait for
53
+ * the other to complete (pessimistic locking) or retry on conflict
54
+ * (optimistic locking). The choice is left to the implementation.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const count = await adapter.mutateLedger((store) => {
59
+ * store.tree.principles['p-1'] = newPrinciple;
60
+ * return Object.keys(store.tree.principles).length;
61
+ * });
62
+ * ```
63
+ */
64
+ mutateLedger<T>(mutate: (store: HybridLedgerStore) => T | Promise<T>): Promise<T>;
65
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * TelemetryEvent schema for the Evolution SDK.
3
+ *
4
+ * TypeBox schema describing the shape of in-process evolution events.
5
+ * Per D-07, this is a documentation artifact -- the existing EvolutionLogger
6
+ * output should conform to this schema. No new TelemetryService is created.
7
+ *
8
+ * Per D-08, covers the 3 core events aligned with EvolutionHook:
9
+ * - pain_detected (maps to EvolutionStage 'pain_detected')
10
+ * - principle_candidate_created (maps to EvolutionStage 'principle_generated')
11
+ * - principle_promoted (maps to EvolutionStage 'completed')
12
+ *
13
+ * Injection and storage events are out of scope for this phase.
14
+ */
15
+ import { Type, type Static } from '@sinclair/typebox';
16
+ import { Value } from '@sinclair/typebox/value';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Event Type Union
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * The 3 core telemetry event types, aligned with EvolutionHook methods.
24
+ *
25
+ * Mapping to existing EvolutionLogger stages:
26
+ * - pain_detected -> EvolutionStage 'pain_detected'
27
+ * - principle_candidate_created -> EvolutionStage 'principle_generated'
28
+ * - principle_promoted -> EvolutionStage 'completed'
29
+ */
30
+ export const TelemetryEventType = Type.Union([
31
+ Type.Literal('pain_detected'),
32
+ Type.Literal('principle_candidate_created'),
33
+ Type.Literal('principle_promoted'),
34
+ ]);
35
+
36
+ export type TelemetryEventType = Static<typeof TelemetryEventType>;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // TelemetryEvent Schema
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Schema for an in-process telemetry event.
44
+ *
45
+ * Fields align with existing EvolutionLogEntry:
46
+ * - traceId <-> EvolutionLogEntry.traceId
47
+ * - timestamp <-> EvolutionLogEntry.timestamp
48
+ * - sessionId <-> EvolutionLogEntry.sessionId
49
+ * - payload <-> EvolutionLogEntry.metadata
50
+ *
51
+ * No PII fields. The agentId field is optional and contains only
52
+ * system identifiers (e.g., 'main', 'builder'), never user data.
53
+ */
54
+ export const TelemetryEventSchema = Type.Object({
55
+ /** Event type (one of the 3 core types) */
56
+ eventType: TelemetryEventType,
57
+ /** Correlation trace ID for linking events across the pipeline */
58
+ traceId: Type.String({ minLength: 1 }),
59
+ /** ISO 8601 timestamp */
60
+ timestamp: Type.String({ minLength: 1 }),
61
+ /** Session identifier */
62
+ sessionId: Type.String(),
63
+ /** Agent identifier (system identifier only, no PII) */
64
+ agentId: Type.Optional(Type.String()),
65
+ /** Event-specific payload */
66
+ payload: Type.Record(Type.String(), Type.Unknown()),
67
+ });
68
+
69
+ export type TelemetryEvent = Static<typeof TelemetryEventSchema>;
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Validation
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export interface TelemetryEventValidationResult {
76
+ valid: boolean;
77
+ errors: string[];
78
+ event?: TelemetryEvent;
79
+ }
80
+
81
+ /**
82
+ * Validates an arbitrary object against the TelemetryEvent schema.
83
+ *
84
+ * Returns a structured result with:
85
+ * - `valid`: whether the input conforms to the schema
86
+ * - `errors`: human-readable list of validation failures
87
+ * - `event`: the typed event (only present when valid)
88
+ */
89
+ export function validateTelemetryEvent(input: unknown): TelemetryEventValidationResult {
90
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
91
+ return { valid: false, errors: ['Input must be a non-null object'] };
92
+ }
93
+
94
+ const errors = [...Value.Errors(TelemetryEventSchema, input)];
95
+ if (errors.length > 0) {
96
+ return {
97
+ valid: false,
98
+ errors: errors.map(
99
+ (e) => `${e.path ? `${e.path}: ` : ''}${e.message}`,
100
+ ),
101
+ };
102
+ }
103
+
104
+ return {
105
+ valid: true,
106
+ errors: [],
107
+ event: Value.Cast(TelemetryEventSchema, input) as TelemetryEvent,
108
+ };
109
+ }