principles-disciple 1.40.0 → 1.42.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/.planning/codebase/ARCHITECTURE.md +157 -0
- package/.planning/codebase/CONCERNS.md +145 -0
- package/.planning/codebase/CONVENTIONS.md +148 -0
- package/.planning/codebase/INTEGRATIONS.md +81 -0
- package/.planning/codebase/STACK.md +87 -0
- package/.planning/codebase/STRUCTURE.md +193 -0
- package/.planning/codebase/TESTING.md +243 -0
- package/esbuild.config.js +32 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/compile-principles.mjs +94 -0
- package/scripts/sync-plugin.mjs +96 -281
- package/src/commands/pain.ts +12 -5
- package/src/commands/promote-impl.ts +13 -7
- package/src/commands/rollback.ts +10 -3
- package/src/core/event-log.ts +8 -6
- package/src/core/evolution-types.ts +33 -1
- package/src/core/principle-compiler/code-validator.ts +120 -0
- package/src/core/principle-compiler/compiler.ts +242 -0
- package/src/core/principle-compiler/index.ts +10 -0
- package/src/core/principle-compiler/ledger-registrar.ts +107 -0
- package/src/core/principle-compiler/template-generator.ts +108 -0
- package/src/core/reflection/reflection-context.ts +228 -0
- package/src/hooks/message-sanitize.ts +18 -5
- package/src/hooks/prompt.ts +15 -4
- package/src/hooks/subagent.ts +2 -3
- package/src/http/principles-console-route.ts +21 -4
- package/src/service/evolution-worker.ts +89 -365
- package/src/service/queue-io.ts +375 -0
- package/src/service/queue-migration.ts +122 -0
- package/src/service/sleep-cycle.ts +157 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
- package/src/service/workflow-watchdog.ts +168 -0
- package/src/tools/deep-reflect.ts +22 -11
- package/src/types/event-payload.ts +80 -0
- package/src/types/queue.ts +70 -0
- package/src/utils/file-lock.ts +2 -2
- package/src/utils/io.ts +11 -3
- package/tests/core/code-validator.test.ts +197 -0
- package/tests/core/evolution-migration.test.ts +325 -1
- package/tests/core/ledger-registrar.test.ts +232 -0
- package/tests/core/principle-compiler.test.ts +348 -0
- package/tests/core/queue-purge.test.ts +337 -0
- package/tests/core/reflection-context.test.ts +356 -0
- package/tests/core/template-generator.test.ts +101 -0
- package/tests/fixtures/legacy-queue-v1.json +74 -0
- package/tests/integration/principle-compiler-e2e.test.ts +335 -0
- package/tests/queue/async-lock.test.ts +200 -0
- package/tests/service/evolution-worker.queue.test.ts +296 -0
- package/tests/service/queue-io.test.ts +229 -0
- package/tests/service/queue-migration.test.ts +147 -0
- package/tests/service/workflow-watchdog.test.ts +372 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ledger Registrar (Task 4)
|
|
3
|
+
*
|
|
4
|
+
* Registers a compiled rule into the principle tree ledger:
|
|
5
|
+
* 1. Creates a LedgerRule with type 'gate', enforcement 'block', status 'proposed'
|
|
6
|
+
* 2. Creates an Implementation with type 'code', lifecycleState 'candidate'
|
|
7
|
+
*
|
|
8
|
+
* IDEMPOTENCY: If the rule already exists, returns existing registration.
|
|
9
|
+
* ROLLBACK: If implementation creation fails after rule creation, attempts cleanup.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRule, createImplementation, loadLedger, deleteRule, type LedgerRule } from '../principle-tree-ledger.js';
|
|
13
|
+
|
|
14
|
+
export interface RegisterInput {
|
|
15
|
+
principleId: string;
|
|
16
|
+
codeContent: string;
|
|
17
|
+
coversCondition: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RegisterResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
ruleId: string;
|
|
23
|
+
implementationId: string;
|
|
24
|
+
codePath: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a compiled rule for a principle in the ledger.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: if rule already exists, returns existing registration.
|
|
31
|
+
* Atomic: if implementation creation fails, rolls back the rule.
|
|
32
|
+
*/
|
|
33
|
+
export function registerCompiledRule(stateDir: string, input: RegisterInput): RegisterResult {
|
|
34
|
+
const { principleId, codeContent, coversCondition } = input;
|
|
35
|
+
|
|
36
|
+
const ruleId = `R_${principleId}_auto`;
|
|
37
|
+
const implementationId = `IMPL_${principleId}_auto`;
|
|
38
|
+
const codePath = `compiled-rules/${principleId}/rule.ts`;
|
|
39
|
+
|
|
40
|
+
// Idempotency: skip if rule already exists
|
|
41
|
+
const existingLedger = loadLedger(stateDir);
|
|
42
|
+
if (existingLedger.tree.rules[ruleId]) {
|
|
43
|
+
const existingRule = existingLedger.tree.rules[ruleId];
|
|
44
|
+
return {
|
|
45
|
+
success: true,
|
|
46
|
+
ruleId,
|
|
47
|
+
implementationId: existingRule.implementationIds[0] ?? implementationId,
|
|
48
|
+
codePath,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
|
|
54
|
+
// Step 1: Create the rule
|
|
55
|
+
const rule: LedgerRule = {
|
|
56
|
+
id: ruleId,
|
|
57
|
+
version: 1,
|
|
58
|
+
name: `Auto-compiled rule for ${principleId}`,
|
|
59
|
+
description: `Automatically compiled gate rule generated from principle ${principleId}`,
|
|
60
|
+
type: 'gate',
|
|
61
|
+
triggerCondition: coversCondition,
|
|
62
|
+
enforcement: 'block',
|
|
63
|
+
action: codeContent,
|
|
64
|
+
principleId,
|
|
65
|
+
status: 'proposed',
|
|
66
|
+
coverageRate: 0,
|
|
67
|
+
falsePositiveRate: 0,
|
|
68
|
+
implementationIds: [],
|
|
69
|
+
createdAt: now,
|
|
70
|
+
updatedAt: now,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
createRule(stateDir, rule);
|
|
74
|
+
|
|
75
|
+
// Step 2: Create the implementation (with rollback on failure)
|
|
76
|
+
try {
|
|
77
|
+
const implementation = {
|
|
78
|
+
id: implementationId,
|
|
79
|
+
ruleId,
|
|
80
|
+
type: 'code' as const,
|
|
81
|
+
path: codePath,
|
|
82
|
+
version: '1',
|
|
83
|
+
coversCondition,
|
|
84
|
+
coveragePercentage: 100,
|
|
85
|
+
lifecycleState: 'active' as const,
|
|
86
|
+
createdAt: now,
|
|
87
|
+
updatedAt: now,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
createImplementation(stateDir, implementation);
|
|
91
|
+
} catch (implError) {
|
|
92
|
+
// Rollback: remove the orphaned rule
|
|
93
|
+
try {
|
|
94
|
+
deleteRule(stateDir, ruleId);
|
|
95
|
+
} catch {
|
|
96
|
+
// Best-effort rollback — log but don't mask the original error
|
|
97
|
+
}
|
|
98
|
+
throw implError;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
success: true,
|
|
103
|
+
ruleId,
|
|
104
|
+
implementationId,
|
|
105
|
+
codePath,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template Generator for Principle Compiler
|
|
3
|
+
*
|
|
4
|
+
* Generates RuleHost sandbox code from PainPattern descriptors.
|
|
5
|
+
* Produces self-contained JS modules with `export const meta` and
|
|
6
|
+
* `export function evaluate(input)` that can be loaded at runtime.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: All interpolated values use JSON.stringify or safe helpers
|
|
9
|
+
* to prevent code injection through principleId, coversCondition, or regex patterns.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PainPattern {
|
|
13
|
+
toolName: string;
|
|
14
|
+
pathRegex?: string;
|
|
15
|
+
commandRegex?: string;
|
|
16
|
+
contentRegex?: string;
|
|
17
|
+
errorType?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Derives the auto-rule display name from a principle ID.
|
|
22
|
+
*/
|
|
23
|
+
function toAutoName(principleId: string): string {
|
|
24
|
+
return `Auto_${principleId}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derives the auto-rule ID from a principle ID.
|
|
29
|
+
* Must match ledger-registrar convention: "P_066" => "R_P_066_auto"
|
|
30
|
+
*/
|
|
31
|
+
function toAutoRuleId(principleId: string): string {
|
|
32
|
+
return `R_${principleId}_auto`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds a single `if` branch for a pain pattern.
|
|
37
|
+
*
|
|
38
|
+
* SECURITY: Uses `new RegExp(JSON.stringify(...))` instead of regex literals
|
|
39
|
+
* to prevent code injection through pathRegex/commandRegex/contentRegex values.
|
|
40
|
+
* principleId in reason uses JSON.stringify to prevent string breakout.
|
|
41
|
+
*/
|
|
42
|
+
function buildBranch(principleId: string, pattern: PainPattern): string {
|
|
43
|
+
const conditions: string[] = [];
|
|
44
|
+
|
|
45
|
+
conditions.push(`input.action.toolName === ${JSON.stringify(pattern.toolName)}`);
|
|
46
|
+
|
|
47
|
+
if (pattern.pathRegex) {
|
|
48
|
+
conditions.push(`new RegExp(${JSON.stringify(pattern.pathRegex)}).test(input.action.normalizedPath || '')`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (pattern.commandRegex) {
|
|
52
|
+
conditions.push(`new RegExp(${JSON.stringify(pattern.commandRegex)}).test(input.action.paramsSummary.command || '')`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (pattern.contentRegex) {
|
|
56
|
+
conditions.push(
|
|
57
|
+
`new RegExp(${JSON.stringify(pattern.contentRegex)}).test(input.action.paramsSummary.content || input.action.paramsSummary.new_string || '')`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const guard = conditions.join(' && ');
|
|
62
|
+
const reason = `[${principleId}] Blocked by auto-generated rule`;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
` if (${guard}) {\n` +
|
|
66
|
+
` return { decision: 'block', matched: true, reason: ${JSON.stringify(reason)} };\n` +
|
|
67
|
+
` }`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generates sandbox-ready JS code from a principle ID and pain patterns.
|
|
73
|
+
*
|
|
74
|
+
* Returns `null` when `patterns` is empty.
|
|
75
|
+
*/
|
|
76
|
+
export function generateFromTemplate(
|
|
77
|
+
principleId: string,
|
|
78
|
+
coversCondition: string,
|
|
79
|
+
patterns: PainPattern[],
|
|
80
|
+
): string | null {
|
|
81
|
+
if (patterns.length === 0) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const name = toAutoName(principleId);
|
|
86
|
+
const ruleId = toAutoRuleId(principleId);
|
|
87
|
+
const compiledAt = new Date().toISOString();
|
|
88
|
+
|
|
89
|
+
const branches = patterns
|
|
90
|
+
.map((p) => buildBranch(principleId, p))
|
|
91
|
+
.join('\n');
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
`// Auto-generated by Principle Compiler\n` +
|
|
95
|
+
`export const meta = {\n` +
|
|
96
|
+
` name: ${JSON.stringify(name)},\n` +
|
|
97
|
+
` version: '1.0.0',\n` +
|
|
98
|
+
` ruleId: ${JSON.stringify(ruleId)},\n` +
|
|
99
|
+
` coversCondition: ${JSON.stringify(coversCondition)},\n` +
|
|
100
|
+
` compiledAt: ${JSON.stringify(compiledAt)},\n` +
|
|
101
|
+
` sourcePrincipleId: ${JSON.stringify(principleId)},\n` +
|
|
102
|
+
`};\n\n` +
|
|
103
|
+
`export function evaluate(input) {\n` +
|
|
104
|
+
`${branches}\n` +
|
|
105
|
+
` return { matched: false };\n` +
|
|
106
|
+
`}\n`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReflectionContextCollector — Unified Pipeline Input
|
|
3
|
+
* ====================================================
|
|
4
|
+
*
|
|
5
|
+
* PURPOSE: Collect all grounding context for a principle into a single
|
|
6
|
+
* ReflectionContext object. This is the input to the nocturnal reflection
|
|
7
|
+
* pipeline: principle + painEvents + sessionSnapshot + lineage.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN DECISIONS:
|
|
10
|
+
* - If a principle has no derivedFromPainIds, collect() returns null
|
|
11
|
+
* (nothing to ground code on).
|
|
12
|
+
* - painId -> sessionId resolution is a known gap. For now, we attempt
|
|
13
|
+
* best-effort lookup but return what we have with sessionSnapshot = null
|
|
14
|
+
* if we can't resolve.
|
|
15
|
+
*
|
|
16
|
+
* REUSES:
|
|
17
|
+
* - principle-tree-ledger: loadLedger() for principle lookup
|
|
18
|
+
* - nocturnal-trajectory-extractor: for session snapshots
|
|
19
|
+
* - trajectory: TrajectoryDatabase for pain event queries
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { loadLedger, type LedgerPrinciple } from '../principle-tree-ledger.js';
|
|
23
|
+
import {
|
|
24
|
+
NocturnalTrajectoryExtractor,
|
|
25
|
+
type NocturnalPainEvent,
|
|
26
|
+
type NocturnalSessionSnapshot,
|
|
27
|
+
} from '../nocturnal-trajectory-extractor.js';
|
|
28
|
+
import type { TrajectoryDatabase } from '../trajectory.js';
|
|
29
|
+
import type { Principle } from '../../types/principle-tree-schema.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unified reflection context for the nocturnal pipeline.
|
|
37
|
+
*/
|
|
38
|
+
export interface ReflectionContext {
|
|
39
|
+
/** The principle being reflected upon */
|
|
40
|
+
principle: Principle;
|
|
41
|
+
/** Pain events associated with this principle (via derivedFromPainIds) */
|
|
42
|
+
painEvents: NocturnalPainEvent[];
|
|
43
|
+
/** Session snapshot if resolvable, null otherwise */
|
|
44
|
+
sessionSnapshot: NocturnalSessionSnapshot | null;
|
|
45
|
+
/** Lineage metadata connecting principle to source pain signals */
|
|
46
|
+
lineage: {
|
|
47
|
+
sourcePainIds: string[];
|
|
48
|
+
sessionId: string | null;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Collector
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Collects ReflectionContext for principles by joining ledger data with
|
|
58
|
+
* trajectory data.
|
|
59
|
+
*/
|
|
60
|
+
export class ReflectionContextCollector {
|
|
61
|
+
private readonly stateDir: string;
|
|
62
|
+
private readonly trajectory: TrajectoryDatabase;
|
|
63
|
+
|
|
64
|
+
constructor(stateDir: string, trajectory: TrajectoryDatabase) {
|
|
65
|
+
this.stateDir = stateDir;
|
|
66
|
+
this.trajectory = trajectory;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Collect full reflection context for a single principle.
|
|
71
|
+
*
|
|
72
|
+
* Returns null if:
|
|
73
|
+
* - The principle is not found in the ledger
|
|
74
|
+
* - The principle has no derivedFromPainIds (nothing to ground on)
|
|
75
|
+
*/
|
|
76
|
+
collect(principleId: string): ReflectionContext | null {
|
|
77
|
+
const ledger = loadLedger(this.stateDir);
|
|
78
|
+
const principle = ledger.tree.principles[principleId];
|
|
79
|
+
|
|
80
|
+
if (!principle) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!principle.derivedFromPainIds || principle.derivedFromPainIds.length === 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return this.buildContext(principle);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Collect reflection contexts for multiple principles, optionally filtered.
|
|
93
|
+
*
|
|
94
|
+
* Skips principles without derivedFromPainIds.
|
|
95
|
+
*/
|
|
96
|
+
collectBatch(filter?: { status?: string }): ReflectionContext[] {
|
|
97
|
+
const ledger = loadLedger(this.stateDir);
|
|
98
|
+
const principles = Object.values(ledger.tree.principles);
|
|
99
|
+
|
|
100
|
+
const results: ReflectionContext[] = [];
|
|
101
|
+
|
|
102
|
+
for (const principle of principles) {
|
|
103
|
+
// Apply status filter if provided
|
|
104
|
+
if (filter?.status && principle.status !== filter.status) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Skip principles without pain grounding
|
|
109
|
+
if (!principle.derivedFromPainIds || principle.derivedFromPainIds.length === 0) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ctx = this.buildContext(principle);
|
|
114
|
+
if (ctx) {
|
|
115
|
+
results.push(ctx);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -----------------------------------------------------------------------
|
|
123
|
+
// Private helpers
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a ReflectionContext from a principle.
|
|
128
|
+
*
|
|
129
|
+
* Attempts to resolve painIds to sessions via best-effort lookup.
|
|
130
|
+
* Since painId -> sessionId mapping is a known gap, we may return
|
|
131
|
+
* empty painEvents and null sessionSnapshot.
|
|
132
|
+
*/
|
|
133
|
+
private buildContext(principle: LedgerPrinciple): ReflectionContext {
|
|
134
|
+
const sourcePainIds = principle.derivedFromPainIds;
|
|
135
|
+
|
|
136
|
+
// Best-effort: try to find a session containing pain events related to this principle.
|
|
137
|
+
// The pain_events table uses auto-increment IDs, not the string painIds stored in
|
|
138
|
+
// derivedFromPainIds. This is the known gap — for now we attempt session resolution
|
|
139
|
+
// but gracefully handle the case where we can't match.
|
|
140
|
+
const { painEvents, sessionId } = this.resolvePainEvents(sourcePainIds);
|
|
141
|
+
|
|
142
|
+
// If we found a session, get the snapshot
|
|
143
|
+
let sessionSnapshot: NocturnalSessionSnapshot | null = null;
|
|
144
|
+
if (sessionId) {
|
|
145
|
+
const extractor = new NocturnalTrajectoryExtractor(this.trajectory);
|
|
146
|
+
sessionSnapshot = extractor.getNocturnalSessionSnapshot(sessionId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
principle,
|
|
151
|
+
painEvents,
|
|
152
|
+
sessionSnapshot,
|
|
153
|
+
lineage: {
|
|
154
|
+
sourcePainIds,
|
|
155
|
+
sessionId,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Attempt to resolve painIds to actual pain events and a session.
|
|
162
|
+
*
|
|
163
|
+
* Two-phase strategy:
|
|
164
|
+
* 1. Exact ID match: sourcePainIds are stringified pain_events row IDs.
|
|
165
|
+
* If any match String(pe.id) exactly, use those and stop.
|
|
166
|
+
* 2. Heuristic fallback: substring match on reason/origin fields.
|
|
167
|
+
* Only used when no exact matches are found.
|
|
168
|
+
*/
|
|
169
|
+
private resolvePainEvents(sourcePainIds: string[]): {
|
|
170
|
+
painEvents: NocturnalPainEvent[];
|
|
171
|
+
sessionId: string | null;
|
|
172
|
+
} {
|
|
173
|
+
const sessions = this.trajectory.listRecentSessions({ limit: 100 });
|
|
174
|
+
const sourcePainIdSet = new Set(sourcePainIds);
|
|
175
|
+
|
|
176
|
+
const exactMatches: NocturnalPainEvent[] = [];
|
|
177
|
+
const heuristicMatches: NocturnalPainEvent[] = [];
|
|
178
|
+
let exactSessionId: string | null = null;
|
|
179
|
+
let heuristicSessionId: string | null = null;
|
|
180
|
+
|
|
181
|
+
for (const session of sessions) {
|
|
182
|
+
const sessionPainEvents = this.trajectory.listPainEventsForSession(session.sessionId);
|
|
183
|
+
|
|
184
|
+
for (const pe of sessionPainEvents) {
|
|
185
|
+
// Phase 1: exact ID match
|
|
186
|
+
if (sourcePainIdSet.has(String(pe.id))) {
|
|
187
|
+
exactMatches.push({
|
|
188
|
+
source: pe.source,
|
|
189
|
+
score: pe.score,
|
|
190
|
+
severity: pe.severity,
|
|
191
|
+
reason: pe.reason,
|
|
192
|
+
createdAt: pe.createdAt,
|
|
193
|
+
});
|
|
194
|
+
if (!exactSessionId) {
|
|
195
|
+
exactSessionId = session.sessionId;
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Phase 2: heuristic substring match on reason/origin only
|
|
201
|
+
const peText = [pe.reason, pe.origin].filter(Boolean);
|
|
202
|
+
const isMatch = sourcePainIds.some((painId) =>
|
|
203
|
+
peText.some((field) => field?.includes(painId)),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (isMatch) {
|
|
207
|
+
heuristicMatches.push({
|
|
208
|
+
source: pe.source,
|
|
209
|
+
score: pe.score,
|
|
210
|
+
severity: pe.severity,
|
|
211
|
+
reason: pe.reason,
|
|
212
|
+
createdAt: pe.createdAt,
|
|
213
|
+
});
|
|
214
|
+
if (!heuristicSessionId) {
|
|
215
|
+
heuristicSessionId = session.sessionId;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Prefer exact matches over heuristic matches
|
|
222
|
+
if (exactMatches.length > 0) {
|
|
223
|
+
return { painEvents: exactMatches, sessionId: exactSessionId };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { painEvents: heuristicMatches, sessionId: heuristicSessionId };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -6,6 +6,21 @@ const INTERNAL_TAG_PATTERNS = [
|
|
|
6
6
|
/<empathy\s+[^>]*\/?>(?:<\/empathy>)?/gi,
|
|
7
7
|
];
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Type predicate: true if msg is an assistant message with content.
|
|
11
|
+
* Used for safe narrowing after spread operations on message union.
|
|
12
|
+
*/
|
|
13
|
+
function isAssistantMessageWithContent(
|
|
14
|
+
msg: unknown
|
|
15
|
+
): msg is { role: 'assistant'; content: string } {
|
|
16
|
+
return (
|
|
17
|
+
typeof msg === 'object' &&
|
|
18
|
+
msg !== null &&
|
|
19
|
+
(msg as { role?: string }).role === 'assistant' &&
|
|
20
|
+
typeof (msg as { content?: unknown }).content === 'string'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
export function sanitizeAssistantText(text: string): string {
|
|
10
25
|
let result = text;
|
|
11
26
|
for (const pattern of INTERNAL_TAG_PATTERNS) {
|
|
@@ -23,11 +38,10 @@ export function handleBeforeMessageWrite(
|
|
|
23
38
|
const msg = event.message as { role?: string; content?: unknown } | undefined;
|
|
24
39
|
if (!msg || msg.role !== 'assistant') return;
|
|
25
40
|
|
|
26
|
-
if (
|
|
41
|
+
if (isAssistantMessageWithContent(msg)) {
|
|
27
42
|
const sanitized = sanitizeAssistantText(msg.content);
|
|
28
43
|
if (sanitized !== msg.content) {
|
|
29
|
-
|
|
30
|
-
return { message: { ...msg, content: sanitized } as any };
|
|
44
|
+
return { message: { ...msg, content: sanitized } };
|
|
31
45
|
}
|
|
32
46
|
return;
|
|
33
47
|
}
|
|
@@ -39,8 +53,7 @@ export function handleBeforeMessageWrite(
|
|
|
39
53
|
}
|
|
40
54
|
return part;
|
|
41
55
|
});
|
|
42
|
-
|
|
43
|
-
return { message: { ...msg, content: next } as any };
|
|
56
|
+
return { message: { ...msg, content: next } };
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
return;
|
package/src/hooks/prompt.ts
CHANGED
|
@@ -20,6 +20,17 @@ import {
|
|
|
20
20
|
} from '../core/empathy-keyword-matcher.js';
|
|
21
21
|
import { severityToPenalty, DEFAULT_EMPATHY_KEYWORD_CONFIG } from '../core/empathy-types.js';
|
|
22
22
|
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
23
|
+
import type { PluginRuntimeSubagent } from '../service/subagent-workflow/runtime-direct-driver.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Type assertion: OpenClaw SDK subagent -> workflow manager subagent type.
|
|
27
|
+
* Both types are structurally identical but come from different import paths.
|
|
28
|
+
*/
|
|
29
|
+
function toWorkflowSubagent(
|
|
30
|
+
subagent: NonNullable<OpenClawPluginApi['runtime']>['subagent']
|
|
31
|
+
): PluginRuntimeSubagent {
|
|
32
|
+
return subagent as unknown as PluginRuntimeSubagent;
|
|
33
|
+
}
|
|
23
34
|
|
|
24
35
|
// ---------------------------------------------------------------------------
|
|
25
36
|
// Static file cache — avoids re-reading rarely-changing files every message
|
|
@@ -590,8 +601,8 @@ The empathy observer subagent handles pain detection independently.
|
|
|
590
601
|
const empathyManager = new EmpathyObserverWorkflowManager({
|
|
591
602
|
workspaceDir,
|
|
592
603
|
logger: api.logger ?? console,
|
|
593
|
-
|
|
594
|
-
subagent: runtimeSubagent
|
|
604
|
+
|
|
605
|
+
subagent: toWorkflowSubagent(runtimeSubagent),
|
|
595
606
|
});
|
|
596
607
|
empathyManager.startWorkflow(empathyObserverWorkflowSpec, {
|
|
597
608
|
parentSessionId: sessionId,
|
|
@@ -626,8 +637,8 @@ The empathy observer subagent handles pain detection independently.
|
|
|
626
637
|
const empathyManager = new EmpathyObserverWorkflowManager({
|
|
627
638
|
workspaceDir,
|
|
628
639
|
logger: api.logger ?? console,
|
|
629
|
-
|
|
630
|
-
subagent: api.runtime.subagent
|
|
640
|
+
|
|
641
|
+
subagent: toWorkflowSubagent(api.runtime.subagent),
|
|
631
642
|
});
|
|
632
643
|
|
|
633
644
|
empathyManager.startWorkflow(empathyObserverWorkflowSpec, {
|
package/src/hooks/subagent.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { WorkflowManager } from '../service/subagent-workflow/types.js';
|
|
|
14
14
|
* Used by the subagent_ended hook to dispatch lifecycle recovery to the right manager.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
function createWorkflowManagerForType(
|
|
19
19
|
workflowType: string,
|
|
20
20
|
workspaceDir: string,
|
|
@@ -25,9 +25,8 @@ function createWorkflowManagerForType(
|
|
|
25
25
|
info: (m: string) => logger.info(String(m)),
|
|
26
26
|
warn: (m: string) => logger.warn(String(m)),
|
|
27
27
|
error: (m: string) => logger.error(String(m)),
|
|
28
|
-
|
|
29
28
|
debug: () => { /* no-op */ },
|
|
30
|
-
}
|
|
29
|
+
};
|
|
31
30
|
|
|
32
31
|
switch (workflowType) {
|
|
33
32
|
case 'empathy-observer':
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
1
2
|
import fs from 'fs';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
@@ -96,7 +97,7 @@ function createService(api: OpenClawPluginApi): ControlUiQueryService {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
|
|
100
101
|
function handleApiRoute(
|
|
101
102
|
api: OpenClawPluginApi,
|
|
102
103
|
pathname: string,
|
|
@@ -105,13 +106,13 @@ function handleApiRoute(
|
|
|
105
106
|
): Promise<boolean> | boolean {
|
|
106
107
|
// Check authentication for API routes
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
|
|
109
110
|
if (!validateGatewayAuth(req)) {
|
|
110
111
|
json(res, 401, { error: 'unauthorized', message: 'Valid Gateway token required.' });
|
|
111
112
|
return true;
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
+
|
|
115
116
|
let service: ControlUiQueryService;
|
|
116
117
|
try {
|
|
117
118
|
service = createService(api);
|
|
@@ -566,7 +567,23 @@ function validateGatewayAuth(req: IncomingMessage): boolean {
|
|
|
566
567
|
const authHeader = (req.headers?.authorization as string) || '';
|
|
567
568
|
const tokenMatch = /^Bearer\s+(.+)$/i.exec(authHeader);
|
|
568
569
|
const providedToken = tokenMatch?.[1];
|
|
569
|
-
|
|
570
|
+
|
|
571
|
+
if (!providedToken) {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Constant-time comparison to prevent timing attacks (per D-07)
|
|
576
|
+
// Use Buffer comparison — both tokens must be same length for timingSafeEqual
|
|
577
|
+
const providedBuffer = Buffer.from(providedToken, 'utf8');
|
|
578
|
+
const expectedBuffer = Buffer.from(gatewayToken, 'utf8');
|
|
579
|
+
|
|
580
|
+
if (providedBuffer.length !== expectedBuffer.length) {
|
|
581
|
+
// Length mismatch — fail fast but without timing leak
|
|
582
|
+
// Return false immediately rather than letting timingSafeEqual throw
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return crypto.timingSafeEqual(providedBuffer, expectedBuffer);
|
|
570
587
|
}
|
|
571
588
|
|
|
572
589
|
/**
|