principles-disciple 1.52.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.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/bootstrap-rules.ts +41 -4
- package/src/core/evolution-hook.ts +74 -0
- package/src/core/file-storage-adapter.ts +203 -0
- package/src/core/init.ts +29 -2
- package/src/core/nocturnal-trinity.ts +230 -0
- package/src/core/observability.ts +242 -0
- package/src/core/pain-signal-adapter.ts +42 -0
- package/src/core/pain-signal.ts +136 -0
- package/src/core/principle-injection.ts +208 -0
- package/src/core/principle-injector.ts +84 -0
- package/src/core/storage-adapter.ts +65 -0
- package/src/core/telemetry-event.ts +109 -0
- package/src/hooks/prompt.ts +18 -3
- package/src/service/evolution-worker.ts +52 -2
- package/tests/core/evolution-hook.test.ts +123 -0
- package/tests/core/file-storage-adapter.test.ts +285 -0
- package/tests/core/nocturnal-trinity.test.ts +236 -0
- package/tests/core/observability.test.ts +383 -0
- package/tests/core/pain-signal-adapter.test.ts +116 -0
- package/tests/core/pain-signal.test.ts +190 -0
- package/tests/core/principle-injection.test.ts +223 -0
- package/tests/core/principle-injector.test.ts +90 -0
- package/tests/core/storage-conformance.test.ts +429 -0
- package/tests/core/telemetry-event.test.ts +119 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability Baselines for the Evolution SDK.
|
|
3
|
+
*
|
|
4
|
+
* Provides calculateBaselines() which measures the current state of the
|
|
5
|
+
* principle evolution system across four dimensions:
|
|
6
|
+
*
|
|
7
|
+
* 1. Principle Stock: total count of principles in the ledger
|
|
8
|
+
* 2. Structure: average sub-principles (rules) and implementations per principle
|
|
9
|
+
* 3. Association Rate: principles created / total pain events recorded
|
|
10
|
+
* 4. Internalization Rate: internalized principles / total principles
|
|
11
|
+
*
|
|
12
|
+
* Results are logged via SystemLogger and persisted to .state/baselines.json.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { loadLedger } from './principle-tree-ledger.js';
|
|
17
|
+
import { SystemLogger } from './system-logger.js';
|
|
18
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface ObservabilityBaselines {
|
|
25
|
+
/** ISO 8601 timestamp when baselines were calculated */
|
|
26
|
+
calculatedAt: string;
|
|
27
|
+
|
|
28
|
+
/** Principle Stock: total count of principles in the ledger */
|
|
29
|
+
principleStock: number;
|
|
30
|
+
|
|
31
|
+
/** Total rules across all principles */
|
|
32
|
+
totalRules: number;
|
|
33
|
+
|
|
34
|
+
/** Total implementations across all rules */
|
|
35
|
+
totalImplementations: number;
|
|
36
|
+
|
|
37
|
+
/** Structure: average rules per principle (0 if no principles) */
|
|
38
|
+
avgRulesPerPrinciple: number;
|
|
39
|
+
|
|
40
|
+
/** Structure: average implementations per rule (0 if no rules) */
|
|
41
|
+
avgImplementationsPerRule: number;
|
|
42
|
+
|
|
43
|
+
/** Total pain events from trajectory DB (0 if DB unavailable) */
|
|
44
|
+
totalPainEvents: number;
|
|
45
|
+
|
|
46
|
+
/** Association Rate: principles / total pain events (0 if no pain events) */
|
|
47
|
+
associationRate: number;
|
|
48
|
+
|
|
49
|
+
/** Count of principles with internalizationStatus = 'internalized' */
|
|
50
|
+
internalizedCount: number;
|
|
51
|
+
|
|
52
|
+
/** Internalization Rate: internalized / total principles (0 if no principles) */
|
|
53
|
+
internalizationRate: number;
|
|
54
|
+
|
|
55
|
+
/** Distribution of principle statuses */
|
|
56
|
+
statusDistribution: Record<string, number>;
|
|
57
|
+
|
|
58
|
+
/** Distribution of principle priorities */
|
|
59
|
+
priorityDistribution: Record<string, number>;
|
|
60
|
+
|
|
61
|
+
/** Distribution of internalization statuses from training store */
|
|
62
|
+
internalizationDistribution: Record<string, number>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Constants
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
const BASELINES_FILE = 'baselines.json';
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Implementation
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Calculate observability baselines for the principle evolution system.
|
|
77
|
+
*
|
|
78
|
+
* Reads the principle ledger from stateDir, computes metrics across four
|
|
79
|
+
* dimensions (Stock, Structure, Association, Internalization), logs a summary
|
|
80
|
+
* via SystemLogger, and persists results to .state/baselines.json.
|
|
81
|
+
*
|
|
82
|
+
* @param stateDir - The .state directory containing the principle ledger
|
|
83
|
+
* @param workspaceDir - Optional workspace dir for SystemLogger routing
|
|
84
|
+
* @returns The computed baselines
|
|
85
|
+
*/
|
|
86
|
+
export function calculateBaselines(
|
|
87
|
+
stateDir: string,
|
|
88
|
+
workspaceDir?: string,
|
|
89
|
+
): ObservabilityBaselines {
|
|
90
|
+
const ledger = loadLedger(stateDir);
|
|
91
|
+
const { tree, trainingStore } = ledger;
|
|
92
|
+
|
|
93
|
+
const principles = Object.values(tree.principles);
|
|
94
|
+
const rules = Object.values(tree.rules);
|
|
95
|
+
const implementations = Object.values(tree.implementations);
|
|
96
|
+
|
|
97
|
+
const principleStock = principles.length;
|
|
98
|
+
const totalRules = rules.length;
|
|
99
|
+
const totalImplementations = implementations.length;
|
|
100
|
+
|
|
101
|
+
// Structure metrics
|
|
102
|
+
const avgRulesPerPrinciple = principleStock > 0
|
|
103
|
+
? totalRules / principleStock
|
|
104
|
+
: 0;
|
|
105
|
+
const avgImplementationsPerRule = totalRules > 0
|
|
106
|
+
? totalImplementations / totalRules
|
|
107
|
+
: 0;
|
|
108
|
+
|
|
109
|
+
// Count pain events from trajectory DB
|
|
110
|
+
const totalPainEvents = countPainEvents(stateDir);
|
|
111
|
+
|
|
112
|
+
// Association Rate: how many principles were created per pain event
|
|
113
|
+
const associationRate = totalPainEvents > 0
|
|
114
|
+
? principleStock / totalPainEvents
|
|
115
|
+
: 0;
|
|
116
|
+
|
|
117
|
+
// Internalization Rate from training store
|
|
118
|
+
// Filter to only entries whose principleId still exists in the ledger tree
|
|
119
|
+
// to avoid orphaned/deleted entries inflating the ratio
|
|
120
|
+
const trainingEntries = Object.values(trainingStore);
|
|
121
|
+
const activePrincipleIds = new Set(Object.keys(tree.principles));
|
|
122
|
+
const activeEntries = trainingEntries.filter(
|
|
123
|
+
(entry) => activePrincipleIds.has(entry.principleId),
|
|
124
|
+
);
|
|
125
|
+
const internalizedCount = activeEntries.filter(
|
|
126
|
+
(entry) => entry.internalizationStatus === 'internalized',
|
|
127
|
+
).length;
|
|
128
|
+
const internalizationRate = principleStock > 0
|
|
129
|
+
? internalizedCount / principleStock
|
|
130
|
+
: 0;
|
|
131
|
+
|
|
132
|
+
// Status distribution
|
|
133
|
+
const statusDistribution: Record<string, number> = {};
|
|
134
|
+
for (const p of principles) {
|
|
135
|
+
statusDistribution[p.status] = (statusDistribution[p.status] ?? 0) + 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Priority distribution
|
|
139
|
+
const priorityDistribution: Record<string, number> = {};
|
|
140
|
+
for (const p of principles) {
|
|
141
|
+
priorityDistribution[p.priority] = (priorityDistribution[p.priority] ?? 0) + 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Internalization status distribution from training store
|
|
145
|
+
const internalizationDistribution: Record<string, number> = {};
|
|
146
|
+
for (const entry of trainingEntries) {
|
|
147
|
+
internalizationDistribution[entry.internalizationStatus] =
|
|
148
|
+
(internalizationDistribution[entry.internalizationStatus] ?? 0) + 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const baselines: ObservabilityBaselines = {
|
|
152
|
+
calculatedAt: new Date().toISOString(),
|
|
153
|
+
principleStock,
|
|
154
|
+
totalRules,
|
|
155
|
+
totalImplementations,
|
|
156
|
+
avgRulesPerPrinciple: roundTo3(avgRulesPerPrinciple),
|
|
157
|
+
avgImplementationsPerRule: roundTo3(avgImplementationsPerRule),
|
|
158
|
+
totalPainEvents,
|
|
159
|
+
associationRate: roundTo3(associationRate),
|
|
160
|
+
internalizedCount,
|
|
161
|
+
internalizationRate: roundTo3(internalizationRate),
|
|
162
|
+
statusDistribution,
|
|
163
|
+
priorityDistribution,
|
|
164
|
+
internalizationDistribution,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Log summary
|
|
168
|
+
SystemLogger.log(
|
|
169
|
+
workspaceDir,
|
|
170
|
+
'OBSERVABILITY_BASELINES',
|
|
171
|
+
formatBaselineSummary(baselines),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// Persist to .state/baselines.json
|
|
175
|
+
persistBaselines(stateDir, baselines);
|
|
176
|
+
|
|
177
|
+
return baselines;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Internal helpers
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
function roundTo3(n: number): number {
|
|
185
|
+
return Math.round(n * 1000) / 1000;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatBaselineSummary(b: ObservabilityBaselines): string {
|
|
189
|
+
return [
|
|
190
|
+
`Principle Stock: ${b.principleStock}`,
|
|
191
|
+
`Structure: ${b.avgRulesPerPrinciple} rules/principle, ${b.avgImplementationsPerRule} impls/rule`,
|
|
192
|
+
`Association Rate: ${b.associationRate} (${b.principleStock} principles / ${b.totalPainEvents} pain events)`,
|
|
193
|
+
`Internalization Rate: ${b.internalizationRate} (${b.internalizedCount}/${b.principleStock})`,
|
|
194
|
+
].join(' | ');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Count pain events from the trajectory SQLite database.
|
|
199
|
+
* Returns 0 if the database is unavailable or the table doesn't exist.
|
|
200
|
+
*/
|
|
201
|
+
function countPainEvents(stateDir: string): number {
|
|
202
|
+
const dbPath = path.join(stateDir, 'trajectory.db');
|
|
203
|
+
if (!fs.existsSync(dbPath)) {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
// Use dynamic import for better-sqlite3 to avoid hard dependency
|
|
209
|
+
// at module load time. If not available, return 0.
|
|
210
|
+
|
|
211
|
+
const Database = require('better-sqlite3') as typeof import('better-sqlite3');
|
|
212
|
+
const db = new Database(dbPath, { readonly: true });
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const row = db.prepare('SELECT COUNT(*) as count FROM pain_events').get() as { count: number } | undefined;
|
|
216
|
+
return row?.count ?? 0;
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
// better-sqlite3 not available, or table doesn't exist — log and return 0
|
|
222
|
+
SystemLogger.log(stateDir, 'OBSERVABILITY_SQL_ERROR', `countPainEvents failed: ${String(err)}`);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Persist baselines to .state/baselines.json atomically.
|
|
229
|
+
*/
|
|
230
|
+
function persistBaselines(stateDir: string, baselines: ObservabilityBaselines): void {
|
|
231
|
+
try {
|
|
232
|
+
const filePath = path.join(stateDir, BASELINES_FILE);
|
|
233
|
+
const dir = path.dirname(filePath);
|
|
234
|
+
if (!fs.existsSync(dir)) {
|
|
235
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
236
|
+
}
|
|
237
|
+
atomicWriteFileSync(filePath, JSON.stringify(baselines, null, 2));
|
|
238
|
+
} catch (err) {
|
|
239
|
+
// Baselines persistence is best-effort — don't crash the caller
|
|
240
|
+
// (the SystemLogger call above already logged the values)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PainSignalAdapter interface for the Evolution SDK.
|
|
3
|
+
*
|
|
4
|
+
* This interface decouples the evolution engine from specific AI agent
|
|
5
|
+
* frameworks (OpenClaw, Claude Code, etc.). All modules that need to
|
|
6
|
+
* capture pain signals from tool failures should depend on this interface
|
|
7
|
+
* rather than importing framework-specific event types directly.
|
|
8
|
+
*
|
|
9
|
+
* The interface uses a generic type parameter for the raw framework event,
|
|
10
|
+
* so each framework implementation provides its own concrete type.
|
|
11
|
+
*/
|
|
12
|
+
import type { PainSignal } from './pain-signal.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// PainSignalAdapter Interface
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Framework-agnostic adapter for capturing pain signals.
|
|
20
|
+
*
|
|
21
|
+
* @typeParam TRawEvent - The framework-specific event type
|
|
22
|
+
* (e.g., PluginHookAfterToolCallEvent for OpenClaw)
|
|
23
|
+
*/
|
|
24
|
+
export interface PainSignalAdapter<TRawEvent> {
|
|
25
|
+
/**
|
|
26
|
+
* Translate a framework-specific event into a universal PainSignal.
|
|
27
|
+
*
|
|
28
|
+
* Returns null when the event does not produce a pain signal (e.g., the
|
|
29
|
+
* event type is not a failure, or the event lacks required fields).
|
|
30
|
+
*
|
|
31
|
+
* This method performs pure translation only. Trigger decision logic
|
|
32
|
+
* (e.g., GFI threshold checks, tool name filtering) stays in the
|
|
33
|
+
* framework-side hook logic. Per D-02, capture() only translates.
|
|
34
|
+
*
|
|
35
|
+
* Translation failures (malformed events, missing required fields)
|
|
36
|
+
* return null rather than throwing. This keeps the adapter resilient.
|
|
37
|
+
*
|
|
38
|
+
* @param rawEvent - The framework-specific event to translate
|
|
39
|
+
* @returns A valid PainSignal, or null if the event does not produce one
|
|
40
|
+
*/
|
|
41
|
+
capture(rawEvent: TRawEvent): PainSignal | null;
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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;
|