@directive-run/claude-plugin 1.16.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/.claude-plugin/plugin.json +6 -0
- package/LICENSE +26 -0
- package/README.md +113 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/skills/README.md +9 -0
- package/skills/building-ai-agents/SKILL.md +414 -0
- package/skills/building-ai-agents/ai-adapters.md +257 -0
- package/skills/building-ai-agents/ai-agents-streaming.md +307 -0
- package/skills/building-ai-agents/ai-communication.md +304 -0
- package/skills/building-ai-agents/api-skeleton.md +5 -0
- package/skills/building-ai-agents/examples.md +849 -0
- package/skills/building-ai-orchestrators/SKILL.md +401 -0
- package/skills/building-ai-orchestrators/ai-multi-agent.md +375 -0
- package/skills/building-ai-orchestrators/ai-orchestrator.md +466 -0
- package/skills/building-ai-orchestrators/ai-tasks.md +268 -0
- package/skills/building-ai-orchestrators/api-skeleton.md +5 -0
- package/skills/building-ai-orchestrators/examples.md +1609 -0
- package/skills/building-directive-systems/SKILL.md +499 -0
- package/skills/building-directive-systems/api-skeleton.md +5 -0
- package/skills/building-directive-systems/examples.md +4149 -0
- package/skills/building-directive-systems/multi-module.md +327 -0
- package/skills/building-directive-systems/plugins.md +361 -0
- package/skills/building-directive-systems/react-adapter.md +376 -0
- package/skills/building-directive-systems/system-api.md +463 -0
- package/skills/getting-started-with-directive/SKILL.md +198 -0
- package/skills/getting-started-with-directive/api-skeleton.md +5 -0
- package/skills/getting-started-with-directive/core-patterns.md +240 -0
- package/skills/getting-started-with-directive/examples.md +87 -0
- package/skills/getting-started-with-directive/sitemap.md +202 -0
- package/skills/hardening-ai-systems/SKILL.md +377 -0
- package/skills/hardening-ai-systems/ai-budget-resilience.md +408 -0
- package/skills/hardening-ai-systems/ai-guardrails-memory.md +370 -0
- package/skills/hardening-ai-systems/ai-security.md +400 -0
- package/skills/hardening-ai-systems/api-skeleton.md +5 -0
- package/skills/hardening-ai-systems/examples.md +628 -0
- package/skills/migrating-to-directive/SKILL.md +249 -0
- package/skills/migrating-to-directive/anti-patterns.md +382 -0
- package/skills/migrating-to-directive/api-skeleton.md +5 -0
- package/skills/migrating-to-directive/core-patterns.md +240 -0
- package/skills/migrating-to-directive/examples.md +511 -0
- package/skills/migrating-to-directive/schema-types.md +273 -0
- package/skills/reviewing-directive-code/SKILL.md +232 -0
- package/skills/reviewing-directive-code/anti-patterns.md +382 -0
- package/skills/reviewing-directive-code/api-skeleton.md +5 -0
- package/skills/reviewing-directive-code/core-patterns.md +240 -0
- package/skills/reviewing-directive-code/examples.md +463 -0
- package/skills/reviewing-directive-code/naming.md +321 -0
- package/skills/scaffolding-directive-modules/SKILL.md +218 -0
- package/skills/scaffolding-directive-modules/api-skeleton.md +5 -0
- package/skills/scaffolding-directive-modules/core-patterns.md +240 -0
- package/skills/scaffolding-directive-modules/examples.md +1386 -0
- package/skills/scaffolding-directive-modules/naming.md +321 -0
- package/skills/scaffolding-directive-modules/schema-types.md +273 -0
- package/skills/testing-ai-systems/SKILL.md +398 -0
- package/skills/testing-ai-systems/ai-debug-observability.md +329 -0
- package/skills/testing-ai-systems/ai-mcp-rag.md +293 -0
- package/skills/testing-ai-systems/ai-testing-evals.md +485 -0
- package/skills/testing-ai-systems/api-skeleton.md +5 -0
- package/skills/testing-ai-systems/examples.md +1261 -0
- package/skills/testing-directive-code/SKILL.md +479 -0
- package/skills/testing-directive-code/api-skeleton.md +5 -0
- package/skills/testing-directive-code/examples.md +156 -0
- package/skills/testing-directive-code/history.md +351 -0
- package/skills/testing-directive-code/testing.md +412 -0
- package/skills/writing-directive-constraints/SKILL.md +486 -0
- package/skills/writing-directive-constraints/api-skeleton.md +5 -0
- package/skills/writing-directive-constraints/constraints.md +274 -0
- package/skills/writing-directive-constraints/error-boundaries.md +330 -0
- package/skills/writing-directive-constraints/examples.md +1361 -0
- package/skills/writing-directive-constraints/resolvers.md +367 -0
- package/skills/writing-directive-modules/SKILL.md +342 -0
- package/skills/writing-directive-modules/anti-patterns.md +382 -0
- package/skills/writing-directive-modules/api-skeleton.md +5 -0
- package/skills/writing-directive-modules/core-patterns.md +240 -0
- package/skills/writing-directive-modules/examples.md +1639 -0
- package/skills/writing-directive-modules/naming.md +321 -0
- package/skills/writing-directive-modules/schema-types.md +273 -0
|
@@ -0,0 +1,1609 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
> Auto-generated from extracted examples. Do not edit manually.
|
|
4
|
+
|
|
5
|
+
## checkers
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// Example: checkers
|
|
9
|
+
// Source: examples/checkers/src/ai-orchestrator.ts
|
|
10
|
+
// Pure module file — no DOM wiring
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Checkers AI Orchestrator
|
|
14
|
+
*
|
|
15
|
+
* Composes directive AI adapter features via explicit wiring:
|
|
16
|
+
*
|
|
17
|
+
* 1. Agent Orchestrator — Manages Claude API via generic AgentRunner + guardrails
|
|
18
|
+
* 2. Memory — Sliding window (30 messages) with auto-summarization
|
|
19
|
+
* 3. Output Guardrail — Validates move JSON schema before accepting
|
|
20
|
+
* 4. Rate Limiter — 10 requests/min to prevent runaway API calls
|
|
21
|
+
* 5. Circuit Breaker — After 3 failures, falls back to local minimax for 30s
|
|
22
|
+
* 6. Cost Tracking — Token count + estimated cost at Haiku rates
|
|
23
|
+
* 7. Streaming — Token-by-token chat delivery with length guardrail
|
|
24
|
+
* 8. Multi-Agent — Parallel move + analysis agents
|
|
25
|
+
* 9. Communication Bus — Agent-to-agent INFORM messages for move/chat events
|
|
26
|
+
* 10. Semantic Cache — Hash-based position caching (0.98 threshold)
|
|
27
|
+
* 11. Observability — Metrics, tracing, alerting dashboard
|
|
28
|
+
* 12. OTLP Exporter — Periodic export to OpenTelemetry collector
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
type AgentLike,
|
|
33
|
+
type InputGuardrailData,
|
|
34
|
+
type NamedGuardrail,
|
|
35
|
+
type RunResult,
|
|
36
|
+
createAgentMemory,
|
|
37
|
+
createAgentOrchestrator,
|
|
38
|
+
createLengthStreamingGuardrail,
|
|
39
|
+
createMessageBus,
|
|
40
|
+
createSemanticCache,
|
|
41
|
+
createSlidingWindowStrategy,
|
|
42
|
+
createStreamingRunner,
|
|
43
|
+
createTestEmbedder,
|
|
44
|
+
estimateCost,
|
|
45
|
+
} from "@directive-run/ai";
|
|
46
|
+
import type { CacheStats } from "@directive-run/ai";
|
|
47
|
+
import { createOutputSchemaGuardrail } from "@directive-run/ai/guardrails";
|
|
48
|
+
import {
|
|
49
|
+
createMultiAgentOrchestrator,
|
|
50
|
+
parallel,
|
|
51
|
+
} from "@directive-run/ai/multi-agent";
|
|
52
|
+
import {
|
|
53
|
+
CircuitBreakerOpenError,
|
|
54
|
+
type CircuitState,
|
|
55
|
+
createAgentMetrics,
|
|
56
|
+
createCircuitBreaker,
|
|
57
|
+
createOTLPExporter,
|
|
58
|
+
createObservability,
|
|
59
|
+
} from "@directive-run/core/plugins";
|
|
60
|
+
import {
|
|
61
|
+
analysisAgent,
|
|
62
|
+
chatAgent,
|
|
63
|
+
formatLegalMoves,
|
|
64
|
+
moveAgent,
|
|
65
|
+
renderBoardForClaude,
|
|
66
|
+
runClaude,
|
|
67
|
+
runClaudeWithCallbacks,
|
|
68
|
+
} from "./claude-adapter.js";
|
|
69
|
+
import type { Board, Move, Player } from "./rules.js";
|
|
70
|
+
import { pickAiMove } from "./rules.js";
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Types
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
export interface MoveResult {
|
|
77
|
+
from: number;
|
|
78
|
+
to: number;
|
|
79
|
+
reasoning: string;
|
|
80
|
+
chat: string;
|
|
81
|
+
analysis: string | null;
|
|
82
|
+
isLocalFallback: boolean;
|
|
83
|
+
isCached: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MoveWithAnalysis {
|
|
87
|
+
move: { from: number; to: number; reasoning: string; chat: string };
|
|
88
|
+
analysis: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CheckersAI {
|
|
92
|
+
requestMove(
|
|
93
|
+
board: Board,
|
|
94
|
+
player: Player,
|
|
95
|
+
legalMoves: Move[],
|
|
96
|
+
humanMoveDesc?: string,
|
|
97
|
+
): Promise<MoveResult>;
|
|
98
|
+
sendChat(
|
|
99
|
+
message: string,
|
|
100
|
+
onToken?: (token: string) => void,
|
|
101
|
+
): Promise<string | null>;
|
|
102
|
+
reset(): void;
|
|
103
|
+
getState(): {
|
|
104
|
+
isThinking: boolean;
|
|
105
|
+
totalTokens: number;
|
|
106
|
+
estimatedCost: number;
|
|
107
|
+
circuitState: CircuitState;
|
|
108
|
+
memoryMessageCount: number;
|
|
109
|
+
cacheStats: CacheStats;
|
|
110
|
+
busMessageCount: number;
|
|
111
|
+
};
|
|
112
|
+
destroy(): void;
|
|
113
|
+
/** Escape hatch for dashboard rendering */
|
|
114
|
+
readonly observability: ReturnType<typeof createObservability> | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Move Schema Validation
|
|
119
|
+
// ============================================================================
|
|
120
|
+
|
|
121
|
+
function validateMoveOutput(value: unknown): {
|
|
122
|
+
valid: boolean;
|
|
123
|
+
errors?: string[];
|
|
124
|
+
} {
|
|
125
|
+
if (typeof value !== "object" || value === null) {
|
|
126
|
+
return { valid: false, errors: ["Expected an object"] };
|
|
127
|
+
}
|
|
128
|
+
const obj = value as Record<string, unknown>;
|
|
129
|
+
const errors: string[] = [];
|
|
130
|
+
if (typeof obj.from !== "number") errors.push("'from' must be a number");
|
|
131
|
+
if (typeof obj.to !== "number") errors.push("'to' must be a number");
|
|
132
|
+
if (typeof obj.reasoning !== "string")
|
|
133
|
+
errors.push("'reasoning' must be a string");
|
|
134
|
+
if (typeof obj.chat !== "string") errors.push("'chat' must be a string");
|
|
135
|
+
|
|
136
|
+
return errors.length > 0 ? { valid: false, errors } : { valid: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// Merge function for parallel move + analysis
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
function mergeResults(results: RunResult<unknown>[]): MoveWithAnalysis {
|
|
144
|
+
const moveResult = results[0]?.output as
|
|
145
|
+
| { from: number; to: number; reasoning: string; chat: string }
|
|
146
|
+
| undefined;
|
|
147
|
+
const analysisResult = results[1]?.output as string | undefined;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
move: moveResult ?? {
|
|
151
|
+
from: -1,
|
|
152
|
+
to: -1,
|
|
153
|
+
reasoning: "No result",
|
|
154
|
+
chat: "Something went wrong",
|
|
155
|
+
},
|
|
156
|
+
analysis: analysisResult ?? null,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// Factory
|
|
162
|
+
// ============================================================================
|
|
163
|
+
|
|
164
|
+
export function createCheckersAI(): CheckersAI {
|
|
165
|
+
let isThinking = false;
|
|
166
|
+
let totalTokens = 0;
|
|
167
|
+
const costRatePerMillion = 2.4;
|
|
168
|
+
|
|
169
|
+
// --- Features ---
|
|
170
|
+
|
|
171
|
+
const memory = createAgentMemory({
|
|
172
|
+
strategy: createSlidingWindowStrategy(),
|
|
173
|
+
strategyConfig: { maxMessages: 30, preserveRecentCount: 6 },
|
|
174
|
+
autoManage: true,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const circuitBreaker = createCircuitBreaker({
|
|
178
|
+
failureThreshold: 3,
|
|
179
|
+
recoveryTimeMs: 30000,
|
|
180
|
+
name: "checkers-ai",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const cache = createSemanticCache({
|
|
184
|
+
embedder: createTestEmbedder(),
|
|
185
|
+
similarityThreshold: 0.98,
|
|
186
|
+
maxCacheSize: 200,
|
|
187
|
+
ttlMs: 600_000,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const obs = createObservability({
|
|
191
|
+
serviceName: "checkers-ai",
|
|
192
|
+
metrics: { enabled: true },
|
|
193
|
+
tracing: { enabled: true, sampleRate: 1.0 },
|
|
194
|
+
alerts: [
|
|
195
|
+
{ metric: "agent.errors", threshold: 5, operator: ">", action: "warn" },
|
|
196
|
+
{
|
|
197
|
+
metric: "agent.latency",
|
|
198
|
+
threshold: 10000,
|
|
199
|
+
operator: ">",
|
|
200
|
+
action: "warn",
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const metrics = createAgentMetrics(obs);
|
|
206
|
+
|
|
207
|
+
const otlpExporter = createOTLPExporter({
|
|
208
|
+
endpoint: "http://localhost:4318",
|
|
209
|
+
serviceName: "checkers-ai",
|
|
210
|
+
onError: (err) => {
|
|
211
|
+
console.debug(
|
|
212
|
+
"[OTLP] export failed (collector not running?):",
|
|
213
|
+
err.message,
|
|
214
|
+
);
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const otlpInterval = setInterval(() => {
|
|
219
|
+
try {
|
|
220
|
+
const data = obs.export();
|
|
221
|
+
if (data.metrics.length > 0) otlpExporter.exportMetrics(data.metrics);
|
|
222
|
+
if (data.traces.length > 0) otlpExporter.exportTraces(data.traces);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.debug("[OTLP] periodic export error:", err);
|
|
225
|
+
}
|
|
226
|
+
}, 15_000);
|
|
227
|
+
|
|
228
|
+
const bus = createMessageBus({ maxHistory: 100 });
|
|
229
|
+
|
|
230
|
+
// --- Rate limiter as input guardrail ---
|
|
231
|
+
const rateLimitTimestamps: number[] = [];
|
|
232
|
+
let rateLimitStartIdx = 0;
|
|
233
|
+
const MAX_PER_MINUTE = 10;
|
|
234
|
+
|
|
235
|
+
const rateLimitGuardrail: NamedGuardrail<InputGuardrailData> = {
|
|
236
|
+
name: "rate-limit",
|
|
237
|
+
fn: () => {
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
const windowStart = now - 60_000;
|
|
240
|
+
while (
|
|
241
|
+
rateLimitStartIdx < rateLimitTimestamps.length &&
|
|
242
|
+
rateLimitTimestamps[rateLimitStartIdx]! < windowStart
|
|
243
|
+
) {
|
|
244
|
+
rateLimitStartIdx++;
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
rateLimitStartIdx > rateLimitTimestamps.length / 2 &&
|
|
248
|
+
rateLimitStartIdx > 100
|
|
249
|
+
) {
|
|
250
|
+
rateLimitTimestamps.splice(0, rateLimitStartIdx);
|
|
251
|
+
rateLimitStartIdx = 0;
|
|
252
|
+
}
|
|
253
|
+
const active = rateLimitTimestamps.length - rateLimitStartIdx;
|
|
254
|
+
if (active >= MAX_PER_MINUTE) {
|
|
255
|
+
return {
|
|
256
|
+
passed: false,
|
|
257
|
+
reason: `Rate limit exceeded (${MAX_PER_MINUTE}/min)`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
rateLimitTimestamps.push(now);
|
|
261
|
+
|
|
262
|
+
return { passed: true };
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const moveSchemaGuardrail = createOutputSchemaGuardrail({
|
|
267
|
+
validate: validateMoveOutput,
|
|
268
|
+
errorPrefix: "Invalid move response",
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// --- Core orchestrator ---
|
|
272
|
+
const orchestrator = createAgentOrchestrator({
|
|
273
|
+
runner: runClaude,
|
|
274
|
+
maxTokenBudget: 50000,
|
|
275
|
+
memory,
|
|
276
|
+
circuitBreaker,
|
|
277
|
+
guardrails: {
|
|
278
|
+
input: [rateLimitGuardrail],
|
|
279
|
+
output: [moveSchemaGuardrail],
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// --- Multi-agent ---
|
|
284
|
+
const agentRegistry = {
|
|
285
|
+
move: {
|
|
286
|
+
agent: moveAgent,
|
|
287
|
+
description: "Selects the best move",
|
|
288
|
+
capabilities: ["move"] as string[],
|
|
289
|
+
},
|
|
290
|
+
chat: {
|
|
291
|
+
agent: chatAgent,
|
|
292
|
+
description: "Free-form chat",
|
|
293
|
+
capabilities: ["chat"] as string[],
|
|
294
|
+
},
|
|
295
|
+
analysis: {
|
|
296
|
+
agent: analysisAgent,
|
|
297
|
+
description: "Strategic analysis",
|
|
298
|
+
capabilities: ["analysis"] as string[],
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const multi = createMultiAgentOrchestrator({
|
|
303
|
+
runner: runClaude,
|
|
304
|
+
agents: agentRegistry,
|
|
305
|
+
patterns: {
|
|
306
|
+
moveWithAnalysis: parallel<MoveWithAnalysis>(
|
|
307
|
+
["move", "analysis"],
|
|
308
|
+
mergeResults,
|
|
309
|
+
{ minSuccess: 1, timeout: 15000 },
|
|
310
|
+
),
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// --- Streaming runner ---
|
|
315
|
+
const streamingRunner = createStreamingRunner(runClaudeWithCallbacks, {
|
|
316
|
+
streamingGuardrails: [createLengthStreamingGuardrail({ maxTokens: 500 })],
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// --- Helpers ---
|
|
320
|
+
|
|
321
|
+
function resolveAgent(agentId: string): AgentLike {
|
|
322
|
+
const reg = agentRegistry[agentId as keyof typeof agentRegistry];
|
|
323
|
+
if (!reg) {
|
|
324
|
+
throw new Error(`[CheckersAI] Agent "${agentId}" not found`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return reg.agent;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function buildMoveInput(
|
|
331
|
+
board: Board,
|
|
332
|
+
_player: Player,
|
|
333
|
+
legalMoves: Move[],
|
|
334
|
+
humanMoveDesc?: string,
|
|
335
|
+
): string {
|
|
336
|
+
const boardStr = renderBoardForClaude(board);
|
|
337
|
+
const movesStr = formatLegalMoves(legalMoves);
|
|
338
|
+
let input = "";
|
|
339
|
+
if (humanMoveDesc) {
|
|
340
|
+
input += `Human's move: ${humanMoveDesc}\n\n`;
|
|
341
|
+
}
|
|
342
|
+
input += `Current board:\n${boardStr}\n\nYour legal moves (you MUST pick one):\n${movesStr}\n\nPick your move.`;
|
|
343
|
+
|
|
344
|
+
return input;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function localFallback(
|
|
348
|
+
board: Board,
|
|
349
|
+
player: Player,
|
|
350
|
+
legalMoves: Move[],
|
|
351
|
+
reason: string,
|
|
352
|
+
): MoveResult {
|
|
353
|
+
const move = pickAiMove(board, player) ?? legalMoves[0];
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
from: move.from,
|
|
357
|
+
to: move.to,
|
|
358
|
+
reasoning: `Local AI: ${reason}`,
|
|
359
|
+
chat: reason,
|
|
360
|
+
analysis: null,
|
|
361
|
+
isLocalFallback: true,
|
|
362
|
+
isCached: false,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// --- Public API ---
|
|
367
|
+
|
|
368
|
+
async function requestMove(
|
|
369
|
+
board: Board,
|
|
370
|
+
player: Player,
|
|
371
|
+
legalMoves: Move[],
|
|
372
|
+
humanMoveDesc?: string,
|
|
373
|
+
): Promise<MoveResult> {
|
|
374
|
+
if (legalMoves.length === 0) {
|
|
375
|
+
return {
|
|
376
|
+
from: -1,
|
|
377
|
+
to: -1,
|
|
378
|
+
reasoning: "No moves",
|
|
379
|
+
chat: "No moves!",
|
|
380
|
+
analysis: null,
|
|
381
|
+
isLocalFallback: true,
|
|
382
|
+
isCached: false,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
isThinking = true;
|
|
387
|
+
const input = buildMoveInput(board, player, legalMoves, humanMoveDesc);
|
|
388
|
+
|
|
389
|
+
// Cache check
|
|
390
|
+
try {
|
|
391
|
+
const cached = await cache.lookup(input, "moveWithAnalysis");
|
|
392
|
+
if (cached.hit && cached.entry) {
|
|
393
|
+
obs.incrementCounter("cache.hits");
|
|
394
|
+
try {
|
|
395
|
+
const parsed = JSON.parse(cached.entry.response) as MoveWithAnalysis;
|
|
396
|
+
isThinking = false;
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
from: parsed.move.from,
|
|
400
|
+
to: parsed.move.to,
|
|
401
|
+
reasoning: parsed.move.reasoning,
|
|
402
|
+
chat: parsed.move.chat,
|
|
403
|
+
analysis: parsed.analysis,
|
|
404
|
+
isLocalFallback: false,
|
|
405
|
+
isCached: true,
|
|
406
|
+
};
|
|
407
|
+
} catch {
|
|
408
|
+
// Invalid cache entry — fall through
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
obs.incrementCounter("cache.misses");
|
|
412
|
+
} catch {
|
|
413
|
+
// Cache lookup failed — treat as miss
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const span = obs.startSpan("pattern.moveWithAnalysis");
|
|
417
|
+
const startTime = Date.now();
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const result = await multi.runPattern<MoveWithAnalysis>(
|
|
421
|
+
"moveWithAnalysis",
|
|
422
|
+
input,
|
|
423
|
+
);
|
|
424
|
+
const latencyMs = Date.now() - startTime;
|
|
425
|
+
const parsed = result.move;
|
|
426
|
+
|
|
427
|
+
// Track metrics
|
|
428
|
+
obs.endSpan(span.spanId, "ok");
|
|
429
|
+
metrics.trackRun("moveWithAnalysis", { success: true, latencyMs });
|
|
430
|
+
|
|
431
|
+
// Cache store
|
|
432
|
+
try {
|
|
433
|
+
await cache.store(input, JSON.stringify(result), "moveWithAnalysis");
|
|
434
|
+
} catch {
|
|
435
|
+
// Non-fatal
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Bus publish
|
|
439
|
+
bus.publish({
|
|
440
|
+
type: "INFORM",
|
|
441
|
+
from: "moveWithAnalysis",
|
|
442
|
+
to: "*",
|
|
443
|
+
topic: "moveWithAnalysis.completed",
|
|
444
|
+
content: {},
|
|
445
|
+
} as Parameters<typeof bus.publish>[0]);
|
|
446
|
+
|
|
447
|
+
// Validate the move is legal
|
|
448
|
+
const isLegal = legalMoves.some(
|
|
449
|
+
(m) => m.from === parsed.from && m.to === parsed.to,
|
|
450
|
+
);
|
|
451
|
+
if (!isLegal) {
|
|
452
|
+
console.warn(
|
|
453
|
+
"[CheckersAI] Illegal move returned, falling back",
|
|
454
|
+
parsed,
|
|
455
|
+
);
|
|
456
|
+
isThinking = false;
|
|
457
|
+
|
|
458
|
+
return localFallback(
|
|
459
|
+
board,
|
|
460
|
+
player,
|
|
461
|
+
legalMoves,
|
|
462
|
+
"Hmm, I tried an illegal move. Let me pick again!",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
isThinking = false;
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
from: parsed.from,
|
|
470
|
+
to: parsed.to,
|
|
471
|
+
reasoning: parsed.reasoning,
|
|
472
|
+
chat: parsed.chat,
|
|
473
|
+
analysis: result.analysis,
|
|
474
|
+
isLocalFallback: false,
|
|
475
|
+
isCached: false,
|
|
476
|
+
};
|
|
477
|
+
} catch (err) {
|
|
478
|
+
isThinking = false;
|
|
479
|
+
const latencyMs = Date.now() - startTime;
|
|
480
|
+
obs.endSpan(span.spanId, "error");
|
|
481
|
+
metrics.trackRun("moveWithAnalysis", { success: false, latencyMs });
|
|
482
|
+
|
|
483
|
+
if (err instanceof CircuitBreakerOpenError) {
|
|
484
|
+
return localFallback(
|
|
485
|
+
board,
|
|
486
|
+
player,
|
|
487
|
+
legalMoves,
|
|
488
|
+
"Circuit breaker open — using local AI while I recover.",
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
console.error("[CheckersAI] Move error:", err);
|
|
493
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
494
|
+
|
|
495
|
+
return localFallback(
|
|
496
|
+
board,
|
|
497
|
+
player,
|
|
498
|
+
legalMoves,
|
|
499
|
+
`Error: ${msg}. Using local AI.`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function sendChat(
|
|
505
|
+
message: string,
|
|
506
|
+
onToken?: (token: string) => void,
|
|
507
|
+
): Promise<string | null> {
|
|
508
|
+
try {
|
|
509
|
+
const agent = resolveAgent("chat");
|
|
510
|
+
let reply: string;
|
|
511
|
+
|
|
512
|
+
if (onToken && streamingRunner) {
|
|
513
|
+
// Streaming: token-by-token delivery
|
|
514
|
+
const { stream, result } = streamingRunner<string>(agent, message);
|
|
515
|
+
for await (const chunk of stream) {
|
|
516
|
+
if (chunk.type === "token" && chunk.data) onToken(chunk.data);
|
|
517
|
+
}
|
|
518
|
+
const finalResult = await result;
|
|
519
|
+
totalTokens += finalResult.totalTokens;
|
|
520
|
+
reply =
|
|
521
|
+
typeof finalResult.output === "string"
|
|
522
|
+
? finalResult.output
|
|
523
|
+
: String(finalResult.output);
|
|
524
|
+
} else {
|
|
525
|
+
// Non-streaming: skip output guardrails for chat
|
|
526
|
+
const result = await orchestrator.run<string>(agent, message, {
|
|
527
|
+
outputGuardrails: [],
|
|
528
|
+
});
|
|
529
|
+
totalTokens += result.totalTokens;
|
|
530
|
+
reply = result.output;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return reply;
|
|
534
|
+
} catch (err) {
|
|
535
|
+
if (err instanceof CircuitBreakerOpenError) {
|
|
536
|
+
return "I'm having trouble connecting right now. Try again in a bit!";
|
|
537
|
+
}
|
|
538
|
+
console.error("[CheckersAI] Chat error:", err);
|
|
539
|
+
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function reset(): void {
|
|
545
|
+
memory.clear();
|
|
546
|
+
circuitBreaker.reset();
|
|
547
|
+
cache.clear();
|
|
548
|
+
obs.clear();
|
|
549
|
+
bus.clear();
|
|
550
|
+
multi.reset();
|
|
551
|
+
orchestrator.reset();
|
|
552
|
+
rateLimitTimestamps.length = 0;
|
|
553
|
+
rateLimitStartIdx = 0;
|
|
554
|
+
totalTokens = 0;
|
|
555
|
+
isThinking = false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function getState() {
|
|
559
|
+
return {
|
|
560
|
+
isThinking,
|
|
561
|
+
totalTokens,
|
|
562
|
+
estimatedCost:
|
|
563
|
+
costRatePerMillion > 0
|
|
564
|
+
? estimateCost(totalTokens, costRatePerMillion)
|
|
565
|
+
: 0,
|
|
566
|
+
circuitState: circuitBreaker.getState(),
|
|
567
|
+
memoryMessageCount: memory.getState()?.messages?.length ?? 0,
|
|
568
|
+
cacheStats: cache.getStats(),
|
|
569
|
+
busMessageCount: bus.getHistory()?.length ?? 0,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function destroy(): void {
|
|
574
|
+
clearInterval(otlpInterval);
|
|
575
|
+
// Flush OTLP one final time
|
|
576
|
+
try {
|
|
577
|
+
const data = obs.export();
|
|
578
|
+
if (data.metrics.length > 0) otlpExporter.exportMetrics(data.metrics);
|
|
579
|
+
if (data.traces.length > 0) otlpExporter.exportTraces(data.traces);
|
|
580
|
+
} catch {
|
|
581
|
+
// Best-effort flush on dispose
|
|
582
|
+
}
|
|
583
|
+
orchestrator.destroy();
|
|
584
|
+
multi.destroy();
|
|
585
|
+
void obs.destroy();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
requestMove,
|
|
590
|
+
sendChat,
|
|
591
|
+
reset,
|
|
592
|
+
getState,
|
|
593
|
+
destroy,
|
|
594
|
+
get observability() {
|
|
595
|
+
return obs;
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
## goal-heist
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
// Example: goal-heist
|
|
605
|
+
// Source: examples/goal-heist/src/agents.ts
|
|
606
|
+
// Pure module file — no DOM wiring
|
|
607
|
+
|
|
608
|
+
import { createRunner } from "@directive-run/ai";
|
|
609
|
+
import type { GoalNode } from "@directive-run/ai";
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// API key management (localStorage)
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
const STORAGE_KEY = "goal-heist-api-key";
|
|
616
|
+
|
|
617
|
+
export function getApiKey(): string | null {
|
|
618
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function setApiKey(key: string): void {
|
|
622
|
+
localStorage.setItem(STORAGE_KEY, key);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
// Agent metadata
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
export interface HeistAgent {
|
|
630
|
+
id: string;
|
|
631
|
+
name: string;
|
|
632
|
+
emoji: string;
|
|
633
|
+
title: string;
|
|
634
|
+
produces: string[];
|
|
635
|
+
requires: string[];
|
|
636
|
+
instruction: string;
|
|
637
|
+
mockResponse: Record<string, unknown>;
|
|
638
|
+
mockDelay: number;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export const AGENTS: Record<string, HeistAgent> = {
|
|
642
|
+
gigi: {
|
|
643
|
+
id: "gigi",
|
|
644
|
+
name: "Gigi",
|
|
645
|
+
emoji: "\uD83D\uDC84",
|
|
646
|
+
title: "The Grifter",
|
|
647
|
+
produces: ["guard_schedule"],
|
|
648
|
+
requires: [],
|
|
649
|
+
instruction:
|
|
650
|
+
'You are Gigi "The Grifter", a master of social engineering. You sweet-talked the night guard and obtained their patrol schedule. Respond with JSON: { "guard_schedule": "<brief schedule description>" }',
|
|
651
|
+
mockResponse: {
|
|
652
|
+
guard_schedule:
|
|
653
|
+
"Guards rotate every 45min. East wing unpatrolled 2:15-3:00 AM. Shift change at 3 AM — 4min blind spot.",
|
|
654
|
+
},
|
|
655
|
+
mockDelay: 800,
|
|
656
|
+
},
|
|
657
|
+
felix: {
|
|
658
|
+
id: "felix",
|
|
659
|
+
name: "Felix",
|
|
660
|
+
emoji: "\uD83D\uDD8A\uFE0F",
|
|
661
|
+
title: "The Forger",
|
|
662
|
+
produces: ["blueprints"],
|
|
663
|
+
requires: [],
|
|
664
|
+
instruction:
|
|
665
|
+
'You are Felix "The Forger", an expert document forger. You acquired the museum floor plans from the city records archive. Respond with JSON: { "blueprints": "<brief blueprint description>" }',
|
|
666
|
+
mockResponse: {
|
|
667
|
+
blueprints:
|
|
668
|
+
"Floor plan secured. Vault in sub-basement B2, access via service elevator. Air ducts too narrow — main corridor only.",
|
|
669
|
+
},
|
|
670
|
+
mockDelay: 1000,
|
|
671
|
+
},
|
|
672
|
+
vince: {
|
|
673
|
+
id: "vince",
|
|
674
|
+
name: "Vince",
|
|
675
|
+
emoji: "\uD83D\uDE97",
|
|
676
|
+
title: "The Wheelman",
|
|
677
|
+
produces: ["escape_route"],
|
|
678
|
+
requires: [],
|
|
679
|
+
instruction:
|
|
680
|
+
'You are Vince "The Wheelman", the fastest driver in the city. You scouted three escape routes and picked the best one. Respond with JSON: { "escape_route": "<brief route description>" }',
|
|
681
|
+
mockResponse: {
|
|
682
|
+
escape_route:
|
|
683
|
+
"Primary: loading dock → alley → I-90 on-ramp. Backup: north exit → parking garage swap. ETA to safe house: 8 minutes.",
|
|
684
|
+
},
|
|
685
|
+
mockDelay: 600,
|
|
686
|
+
},
|
|
687
|
+
h4x: {
|
|
688
|
+
id: "h4x",
|
|
689
|
+
name: "H4X",
|
|
690
|
+
emoji: "\uD83D\uDCBB",
|
|
691
|
+
title: "The Hacker",
|
|
692
|
+
produces: ["cameras_disabled"],
|
|
693
|
+
requires: ["guard_schedule"],
|
|
694
|
+
instruction:
|
|
695
|
+
'You are H4X "The Hacker". Using the guard schedule, you found the perfect window to loop the security cameras. Respond with JSON: { "cameras_disabled": "<brief description>" }',
|
|
696
|
+
mockResponse: {
|
|
697
|
+
cameras_disabled:
|
|
698
|
+
"Cameras on loop from 2:15 AM. Feed shows empty corridors on repeat. Motion sensors in east wing bypassed.",
|
|
699
|
+
},
|
|
700
|
+
mockDelay: 1200,
|
|
701
|
+
},
|
|
702
|
+
luca: {
|
|
703
|
+
id: "luca",
|
|
704
|
+
name: "Luca",
|
|
705
|
+
emoji: "\uD83D\uDD13",
|
|
706
|
+
title: "The Locksmith",
|
|
707
|
+
produces: ["vault_cracked"],
|
|
708
|
+
requires: ["cameras_disabled", "blueprints"],
|
|
709
|
+
instruction:
|
|
710
|
+
'You are Luca "The Locksmith". With cameras down and blueprints in hand, you cracked the vault. Respond with JSON: { "vault_cracked": "<brief description>" }',
|
|
711
|
+
mockResponse: {
|
|
712
|
+
vault_cracked:
|
|
713
|
+
"Vault open. Biometric bypass took 90 seconds. Package secured. No alarms triggered.",
|
|
714
|
+
},
|
|
715
|
+
mockDelay: 1500,
|
|
716
|
+
},
|
|
717
|
+
ollie: {
|
|
718
|
+
id: "ollie",
|
|
719
|
+
name: "Ollie",
|
|
720
|
+
emoji: "\uD83D\uDC41\uFE0F",
|
|
721
|
+
title: "The Lookout",
|
|
722
|
+
produces: ["all_clear"],
|
|
723
|
+
requires: ["vault_cracked", "escape_route"],
|
|
724
|
+
instruction:
|
|
725
|
+
'You are Ollie "The Lookout". The vault is cracked and the escape route is ready. Confirm all clear for extraction. Respond with JSON: { "all_clear": "<brief confirmation>" }',
|
|
726
|
+
mockResponse: {
|
|
727
|
+
all_clear:
|
|
728
|
+
"All clear. No police activity within 2 miles. Team converging on loading dock. Go go go.",
|
|
729
|
+
},
|
|
730
|
+
mockDelay: 700,
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// Ordered list for rendering
|
|
735
|
+
export const AGENT_ORDER = ["gigi", "felix", "vince", "h4x", "luca", "ollie"];
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Satisfaction weights
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
export const WEIGHTS: Record<string, number> = {
|
|
742
|
+
guard_schedule: 0.1,
|
|
743
|
+
blueprints: 0.1,
|
|
744
|
+
escape_route: 0.05,
|
|
745
|
+
cameras_disabled: 0.2,
|
|
746
|
+
vault_cracked: 0.35,
|
|
747
|
+
all_clear: 0.2,
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
export function computeSatisfaction(facts: Record<string, unknown>): number {
|
|
751
|
+
let score = 0;
|
|
752
|
+
|
|
753
|
+
for (const [key, weight] of Object.entries(WEIGHTS)) {
|
|
754
|
+
if (facts[key] != null) {
|
|
755
|
+
score += weight;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return Math.min(score, 1);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---------------------------------------------------------------------------
|
|
763
|
+
// Goal nodes (used by runGoal)
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
export function buildGoalNodes(): Record<string, GoalNode> {
|
|
767
|
+
const nodes: Record<string, GoalNode> = {};
|
|
768
|
+
|
|
769
|
+
for (const agent of Object.values(AGENTS)) {
|
|
770
|
+
nodes[agent.id] = {
|
|
771
|
+
agent: agent.id,
|
|
772
|
+
produces: agent.produces,
|
|
773
|
+
requires: agent.requires.length > 0 ? agent.requires : undefined,
|
|
774
|
+
buildInput: (facts) => {
|
|
775
|
+
const relevantFacts: Record<string, unknown> = {};
|
|
776
|
+
|
|
777
|
+
for (const key of agent.requires) {
|
|
778
|
+
if (facts[key] != null) {
|
|
779
|
+
relevantFacts[key] = facts[key];
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return JSON.stringify(relevantFacts);
|
|
784
|
+
},
|
|
785
|
+
extractOutput: (result) => {
|
|
786
|
+
try {
|
|
787
|
+
const parsed =
|
|
788
|
+
typeof result.output === "string"
|
|
789
|
+
? JSON.parse(result.output)
|
|
790
|
+
: result.output;
|
|
791
|
+
const extracted: Record<string, unknown> = {};
|
|
792
|
+
|
|
793
|
+
for (const key of agent.produces) {
|
|
794
|
+
if (parsed[key] != null) {
|
|
795
|
+
extracted[key] = parsed[key];
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return extracted;
|
|
800
|
+
} catch {
|
|
801
|
+
return {};
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return nodes;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
// Runner factory (real Claude or mock)
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
export function createHeistRunner(apiKey: string | null) {
|
|
815
|
+
if (apiKey) {
|
|
816
|
+
return createRunner({
|
|
817
|
+
buildRequest: (agent, input) => ({
|
|
818
|
+
url: "/api/claude",
|
|
819
|
+
init: {
|
|
820
|
+
method: "POST",
|
|
821
|
+
headers: {
|
|
822
|
+
"Content-Type": "application/json",
|
|
823
|
+
"x-api-key": apiKey,
|
|
824
|
+
},
|
|
825
|
+
body: JSON.stringify({
|
|
826
|
+
model: "claude-haiku-4-5-20251001",
|
|
827
|
+
max_tokens: 256,
|
|
828
|
+
system: agent.instructions ?? "",
|
|
829
|
+
messages: [{ role: "user", content: input }],
|
|
830
|
+
}),
|
|
831
|
+
},
|
|
832
|
+
}),
|
|
833
|
+
parseResponse: async (res) => {
|
|
834
|
+
const data = await res.json();
|
|
835
|
+
const text = data.content?.[0]?.text ?? "";
|
|
836
|
+
const inputTokens = data.usage?.input_tokens ?? 0;
|
|
837
|
+
const outputTokens = data.usage?.output_tokens ?? 0;
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
text,
|
|
841
|
+
totalTokens: inputTokens + outputTokens,
|
|
842
|
+
};
|
|
843
|
+
},
|
|
844
|
+
parseOutput: (text) => {
|
|
845
|
+
try {
|
|
846
|
+
return JSON.parse(text);
|
|
847
|
+
} catch {
|
|
848
|
+
return text;
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Mock runner — configurable delays, supports failure injection
|
|
855
|
+
return createMockRunner();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ---------------------------------------------------------------------------
|
|
859
|
+
// Mock runner with failure injection
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
|
|
862
|
+
let failHacker = false;
|
|
863
|
+
let failForger = false;
|
|
864
|
+
let hackerFailCount = 0;
|
|
865
|
+
|
|
866
|
+
export function setFailHacker(v: boolean): void {
|
|
867
|
+
failHacker = v;
|
|
868
|
+
hackerFailCount = 0;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function setFailForger(v: boolean): void {
|
|
872
|
+
failForger = v;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function createMockRunner() {
|
|
876
|
+
return createRunner({
|
|
877
|
+
buildRequest: (agent, input) => ({
|
|
878
|
+
url: "mock://local",
|
|
879
|
+
init: {
|
|
880
|
+
method: "POST",
|
|
881
|
+
headers: { "Content-Type": "application/json" },
|
|
882
|
+
body: JSON.stringify({ agent: agent.name, input }),
|
|
883
|
+
},
|
|
884
|
+
}),
|
|
885
|
+
parseResponse: async (res) => {
|
|
886
|
+
const data = await res.json();
|
|
887
|
+
const text = data.content?.[0]?.text ?? "";
|
|
888
|
+
const tokens = data.usage?.total_tokens ?? 0;
|
|
889
|
+
|
|
890
|
+
return { text, totalTokens: tokens };
|
|
891
|
+
},
|
|
892
|
+
parseOutput: (text) => {
|
|
893
|
+
try {
|
|
894
|
+
return JSON.parse(text);
|
|
895
|
+
} catch {
|
|
896
|
+
return text;
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
// Mock fetch — adds delay, failure injection, returns Anthropic-shaped response
|
|
900
|
+
fetch: async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
901
|
+
const body = JSON.parse((init?.body as string) ?? "{}");
|
|
902
|
+
const agentName = (body.agent as string)?.toLowerCase() ?? "";
|
|
903
|
+
|
|
904
|
+
const agentDef = Object.values(AGENTS).find(
|
|
905
|
+
(a) => a.name.toLowerCase() === agentName,
|
|
906
|
+
);
|
|
907
|
+
const delay = agentDef?.mockDelay ?? 800;
|
|
908
|
+
|
|
909
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
910
|
+
|
|
911
|
+
// Failure injection
|
|
912
|
+
if (agentName === "h4x" && failHacker) {
|
|
913
|
+
hackerFailCount++;
|
|
914
|
+
|
|
915
|
+
if (hackerFailCount <= 3) {
|
|
916
|
+
return new Response(
|
|
917
|
+
JSON.stringify({ error: "Firewall upgraded! Intrusion detected." }),
|
|
918
|
+
{ status: 500 },
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (agentName === "felix" && failForger) {
|
|
924
|
+
return new Response(
|
|
925
|
+
JSON.stringify({ error: "Felix arrested at the archive!" }),
|
|
926
|
+
{ status: 500 },
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const mockResp = agentDef?.mockResponse ?? {};
|
|
931
|
+
const tokens = Math.floor(Math.random() * 40) + 20;
|
|
932
|
+
|
|
933
|
+
const responseBody = {
|
|
934
|
+
content: [{ text: JSON.stringify(mockResp) }],
|
|
935
|
+
usage: { total_tokens: tokens },
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
return new Response(JSON.stringify(responseBody), {
|
|
939
|
+
status: 200,
|
|
940
|
+
headers: { "Content-Type": "application/json" },
|
|
941
|
+
});
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
## fraud-analysis
|
|
948
|
+
|
|
949
|
+
```typescript
|
|
950
|
+
// Example: fraud-analysis
|
|
951
|
+
// Source: examples/fraud-analysis/src/fraud-analysis.ts
|
|
952
|
+
// Pure module file — no DOM wiring
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Fraud Case Analysis — Directive Module
|
|
956
|
+
*
|
|
957
|
+
* Multi-stage fraud detection pipeline showcasing every major Directive feature:
|
|
958
|
+
* - 6 constraints with priority + `after` ordering (including competing constraints)
|
|
959
|
+
* - 6 resolvers with retry policies and custom dedup keys
|
|
960
|
+
* - 3 effects with explicit deps
|
|
961
|
+
* - 9 derivations with composition
|
|
962
|
+
* - Local PII detection + checkpoint store
|
|
963
|
+
* - DevTools panel with time-travel debugging
|
|
964
|
+
*/
|
|
965
|
+
|
|
966
|
+
import {
|
|
967
|
+
type ModuleSchema,
|
|
968
|
+
createModule,
|
|
969
|
+
createSystem,
|
|
970
|
+
t,
|
|
971
|
+
} from "@directive-run/core";
|
|
972
|
+
import { devtoolsPlugin } from "@directive-run/core/plugins";
|
|
973
|
+
import { InMemoryCheckpointStore } from "./checkpoint.js";
|
|
974
|
+
import { detectPII, redactPII } from "./pii.js";
|
|
975
|
+
|
|
976
|
+
import {
|
|
977
|
+
type CheckpointEntry,
|
|
978
|
+
type Disposition,
|
|
979
|
+
type FlagEvent,
|
|
980
|
+
type FraudCase,
|
|
981
|
+
type PipelineStage,
|
|
982
|
+
type Severity,
|
|
983
|
+
type TimelineEntry,
|
|
984
|
+
getMockEnrichment,
|
|
985
|
+
} from "./mock-data.js";
|
|
986
|
+
|
|
987
|
+
// ============================================================================
|
|
988
|
+
// Timeline (external mutable array, same pattern as ai-checkpoint)
|
|
989
|
+
// ============================================================================
|
|
990
|
+
|
|
991
|
+
export const timeline: TimelineEntry[] = [];
|
|
992
|
+
|
|
993
|
+
export function addTimeline(
|
|
994
|
+
type: TimelineEntry["type"],
|
|
995
|
+
message: string,
|
|
996
|
+
): void {
|
|
997
|
+
timeline.push({
|
|
998
|
+
time: new Date().toLocaleTimeString(),
|
|
999
|
+
type,
|
|
1000
|
+
message,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ============================================================================
|
|
1005
|
+
// Checkpoint Store
|
|
1006
|
+
// ============================================================================
|
|
1007
|
+
|
|
1008
|
+
export const checkpointStore = new InMemoryCheckpointStore();
|
|
1009
|
+
|
|
1010
|
+
// ============================================================================
|
|
1011
|
+
// Analysis Helpers
|
|
1012
|
+
// ============================================================================
|
|
1013
|
+
|
|
1014
|
+
interface AnalysisResult {
|
|
1015
|
+
riskScore: number;
|
|
1016
|
+
severity: Severity;
|
|
1017
|
+
disposition: Disposition;
|
|
1018
|
+
analysisNotes: string;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** Deterministic risk scoring formula */
|
|
1022
|
+
function analyzeWithFormula(fraudCase: FraudCase): AnalysisResult {
|
|
1023
|
+
const avgSignalRisk =
|
|
1024
|
+
fraudCase.signals.length > 0
|
|
1025
|
+
? fraudCase.signals.reduce((sum, s) => sum + s.risk, 0) /
|
|
1026
|
+
fraudCase.signals.length
|
|
1027
|
+
: 50;
|
|
1028
|
+
|
|
1029
|
+
const totalAmount = fraudCase.events.reduce((sum, e) => sum + e.amount, 0);
|
|
1030
|
+
const amountFactor = Math.min(totalAmount / 10000, 1) * 30;
|
|
1031
|
+
const eventFactor = Math.min(fraudCase.events.length / 10, 1) * 20;
|
|
1032
|
+
const piiFactor = fraudCase.events.some((e) => e.piiFound) ? 15 : 0;
|
|
1033
|
+
|
|
1034
|
+
const riskScore = Math.min(
|
|
1035
|
+
100,
|
|
1036
|
+
Math.round(avgSignalRisk * 0.5 + amountFactor + eventFactor + piiFactor),
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
let severity: Severity = "low";
|
|
1040
|
+
if (riskScore >= 80) {
|
|
1041
|
+
severity = "critical";
|
|
1042
|
+
} else if (riskScore >= 60) {
|
|
1043
|
+
severity = "high";
|
|
1044
|
+
} else if (riskScore >= 40) {
|
|
1045
|
+
severity = "medium";
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
let disposition: Disposition = "pending";
|
|
1049
|
+
let notes = `Risk: ${riskScore}/100. Signals: ${fraudCase.signals.map((s) => s.source).join(", ")}.`;
|
|
1050
|
+
|
|
1051
|
+
if (riskScore <= 30) {
|
|
1052
|
+
disposition = "cleared";
|
|
1053
|
+
notes += " Auto-cleared: low risk.";
|
|
1054
|
+
} else if (riskScore <= 50) {
|
|
1055
|
+
disposition = "flagged";
|
|
1056
|
+
notes += " Flagged for monitoring.";
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return { riskScore, severity, disposition, analysisNotes: notes };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ============================================================================
|
|
1063
|
+
// Schema
|
|
1064
|
+
// ============================================================================
|
|
1065
|
+
|
|
1066
|
+
export const fraudSchema = {
|
|
1067
|
+
facts: {
|
|
1068
|
+
stage: t.string<PipelineStage>(),
|
|
1069
|
+
flagEvents: t.array<FlagEvent>(),
|
|
1070
|
+
cases: t.array<FraudCase>(),
|
|
1071
|
+
isRunning: t.boolean(),
|
|
1072
|
+
totalEventsProcessed: t.number(),
|
|
1073
|
+
totalPiiDetections: t.number(),
|
|
1074
|
+
analysisBudget: t.number(),
|
|
1075
|
+
maxAnalysisBudget: t.number(),
|
|
1076
|
+
riskThreshold: t.number(),
|
|
1077
|
+
lastError: t.string(),
|
|
1078
|
+
checkpoints: t.array<CheckpointEntry>(),
|
|
1079
|
+
selectedScenario: t.string(),
|
|
1080
|
+
},
|
|
1081
|
+
derivations: {
|
|
1082
|
+
ungroupedCount: t.number(),
|
|
1083
|
+
caseCount: t.number(),
|
|
1084
|
+
criticalCaseCount: t.number(),
|
|
1085
|
+
pendingAnalysisCount: t.number(),
|
|
1086
|
+
needsHumanReview: t.boolean(),
|
|
1087
|
+
budgetExhausted: t.boolean(),
|
|
1088
|
+
completionPercentage: t.number(),
|
|
1089
|
+
averageRiskScore: t.number(),
|
|
1090
|
+
dispositionSummary: t.object<Record<string, number>>(),
|
|
1091
|
+
},
|
|
1092
|
+
events: {
|
|
1093
|
+
ingestEvents: { events: t.array<FlagEvent>() },
|
|
1094
|
+
setRiskThreshold: { value: t.number() },
|
|
1095
|
+
setBudget: { value: t.number() },
|
|
1096
|
+
selectScenario: { key: t.string() },
|
|
1097
|
+
reset: {},
|
|
1098
|
+
},
|
|
1099
|
+
requirements: {
|
|
1100
|
+
NORMALIZE_EVENTS: {},
|
|
1101
|
+
GROUP_EVENTS: {},
|
|
1102
|
+
ENRICH_CASE: { caseId: t.string() },
|
|
1103
|
+
ANALYZE_CASE: { caseId: t.string() },
|
|
1104
|
+
HUMAN_REVIEW: { caseId: t.string() },
|
|
1105
|
+
ESCALATE: { caseId: t.string() },
|
|
1106
|
+
},
|
|
1107
|
+
} satisfies ModuleSchema;
|
|
1108
|
+
|
|
1109
|
+
// ============================================================================
|
|
1110
|
+
// Module
|
|
1111
|
+
// ============================================================================
|
|
1112
|
+
|
|
1113
|
+
export const fraudAnalysisModule = createModule("fraud", {
|
|
1114
|
+
schema: fraudSchema,
|
|
1115
|
+
|
|
1116
|
+
init: (facts) => {
|
|
1117
|
+
facts.stage = "idle";
|
|
1118
|
+
facts.flagEvents = [];
|
|
1119
|
+
facts.cases = [];
|
|
1120
|
+
facts.isRunning = false;
|
|
1121
|
+
facts.totalEventsProcessed = 0;
|
|
1122
|
+
facts.totalPiiDetections = 0;
|
|
1123
|
+
facts.analysisBudget = 300;
|
|
1124
|
+
facts.maxAnalysisBudget = 300;
|
|
1125
|
+
facts.riskThreshold = 70;
|
|
1126
|
+
facts.lastError = "";
|
|
1127
|
+
facts.checkpoints = [];
|
|
1128
|
+
facts.selectedScenario = "card-skimming";
|
|
1129
|
+
},
|
|
1130
|
+
|
|
1131
|
+
// ============================================================================
|
|
1132
|
+
// Derivations (9)
|
|
1133
|
+
// ============================================================================
|
|
1134
|
+
|
|
1135
|
+
derive: {
|
|
1136
|
+
ungroupedCount: (facts) => {
|
|
1137
|
+
return facts.flagEvents.filter((e) => !e.grouped).length;
|
|
1138
|
+
},
|
|
1139
|
+
|
|
1140
|
+
caseCount: (facts) => {
|
|
1141
|
+
return facts.cases.length;
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
criticalCaseCount: (facts) => {
|
|
1145
|
+
return facts.cases.filter((c) => c.severity === "critical").length;
|
|
1146
|
+
},
|
|
1147
|
+
|
|
1148
|
+
pendingAnalysisCount: (facts) => {
|
|
1149
|
+
return facts.cases.filter((c) => c.enriched && !c.analyzed).length;
|
|
1150
|
+
},
|
|
1151
|
+
|
|
1152
|
+
needsHumanReview: (facts) => {
|
|
1153
|
+
return facts.cases.some(
|
|
1154
|
+
(c) => c.riskScore > facts.riskThreshold && c.disposition === "pending",
|
|
1155
|
+
);
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
budgetExhausted: (facts) => {
|
|
1159
|
+
return facts.analysisBudget <= 0;
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
completionPercentage: (facts) => {
|
|
1163
|
+
const stages: PipelineStage[] = [
|
|
1164
|
+
"idle",
|
|
1165
|
+
"ingesting",
|
|
1166
|
+
"normalizing",
|
|
1167
|
+
"grouping",
|
|
1168
|
+
"enriching",
|
|
1169
|
+
"analyzing",
|
|
1170
|
+
"complete",
|
|
1171
|
+
];
|
|
1172
|
+
const idx = stages.indexOf(facts.stage);
|
|
1173
|
+
if (idx < 0) {
|
|
1174
|
+
return 0;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
return Math.round((idx / (stages.length - 1)) * 100);
|
|
1178
|
+
},
|
|
1179
|
+
|
|
1180
|
+
averageRiskScore: (facts) => {
|
|
1181
|
+
if (facts.cases.length === 0) {
|
|
1182
|
+
return 0;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const sum = facts.cases.reduce((acc, c) => acc + c.riskScore, 0);
|
|
1186
|
+
|
|
1187
|
+
return Math.round(sum / facts.cases.length);
|
|
1188
|
+
},
|
|
1189
|
+
|
|
1190
|
+
// Composition: derives from cases (same source as caseCount)
|
|
1191
|
+
dispositionSummary: (facts) => {
|
|
1192
|
+
const summary: Record<string, number> = {};
|
|
1193
|
+
for (const c of facts.cases) {
|
|
1194
|
+
summary[c.disposition] = (summary[c.disposition] || 0) + 1;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return summary;
|
|
1198
|
+
},
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
// ============================================================================
|
|
1202
|
+
// Events
|
|
1203
|
+
// ============================================================================
|
|
1204
|
+
|
|
1205
|
+
events: {
|
|
1206
|
+
ingestEvents: (facts, { events }) => {
|
|
1207
|
+
facts.flagEvents = [...facts.flagEvents, ...events];
|
|
1208
|
+
facts.totalEventsProcessed = facts.totalEventsProcessed + events.length;
|
|
1209
|
+
facts.stage = "ingesting";
|
|
1210
|
+
facts.isRunning = true;
|
|
1211
|
+
facts.lastError = "";
|
|
1212
|
+
},
|
|
1213
|
+
|
|
1214
|
+
setRiskThreshold: (facts, { value }) => {
|
|
1215
|
+
facts.riskThreshold = Math.max(50, Math.min(90, value));
|
|
1216
|
+
},
|
|
1217
|
+
|
|
1218
|
+
setBudget: (facts, { value }) => {
|
|
1219
|
+
facts.analysisBudget = Math.max(0, Math.min(500, value));
|
|
1220
|
+
facts.maxAnalysisBudget = Math.max(facts.maxAnalysisBudget, value);
|
|
1221
|
+
},
|
|
1222
|
+
|
|
1223
|
+
selectScenario: (facts, { key }) => {
|
|
1224
|
+
facts.selectedScenario = key;
|
|
1225
|
+
},
|
|
1226
|
+
|
|
1227
|
+
reset: (facts) => {
|
|
1228
|
+
facts.stage = "idle";
|
|
1229
|
+
facts.flagEvents = [];
|
|
1230
|
+
facts.cases = [];
|
|
1231
|
+
facts.isRunning = false;
|
|
1232
|
+
facts.totalEventsProcessed = 0;
|
|
1233
|
+
facts.totalPiiDetections = 0;
|
|
1234
|
+
facts.lastError = "";
|
|
1235
|
+
facts.checkpoints = [];
|
|
1236
|
+
timeline.length = 0;
|
|
1237
|
+
},
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
// ============================================================================
|
|
1241
|
+
// Constraints (6 with priority + after ordering)
|
|
1242
|
+
// ============================================================================
|
|
1243
|
+
|
|
1244
|
+
constraints: {
|
|
1245
|
+
normalizeNeeded: {
|
|
1246
|
+
priority: 100,
|
|
1247
|
+
when: (facts) => {
|
|
1248
|
+
return facts.stage === "ingesting" && facts.flagEvents.length > 0;
|
|
1249
|
+
},
|
|
1250
|
+
require: { type: "NORMALIZE_EVENTS" },
|
|
1251
|
+
},
|
|
1252
|
+
|
|
1253
|
+
groupingNeeded: {
|
|
1254
|
+
priority: 90,
|
|
1255
|
+
after: ["normalizeNeeded"],
|
|
1256
|
+
when: (facts) => {
|
|
1257
|
+
return facts.flagEvents.some((e) => !e.grouped);
|
|
1258
|
+
},
|
|
1259
|
+
require: { type: "GROUP_EVENTS" },
|
|
1260
|
+
},
|
|
1261
|
+
|
|
1262
|
+
enrichmentNeeded: {
|
|
1263
|
+
priority: 80,
|
|
1264
|
+
after: ["groupingNeeded"],
|
|
1265
|
+
when: (facts) => {
|
|
1266
|
+
return facts.cases.some((c) => !c.enriched && c.signals.length < 3);
|
|
1267
|
+
},
|
|
1268
|
+
require: (facts) => {
|
|
1269
|
+
const target = facts.cases.find(
|
|
1270
|
+
(c) => !c.enriched && c.signals.length < 3,
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
return { type: "ENRICH_CASE", caseId: target?.id ?? "" };
|
|
1274
|
+
},
|
|
1275
|
+
},
|
|
1276
|
+
|
|
1277
|
+
analysisNeeded: {
|
|
1278
|
+
priority: 70,
|
|
1279
|
+
after: ["enrichmentNeeded"],
|
|
1280
|
+
when: (facts) => {
|
|
1281
|
+
return (
|
|
1282
|
+
facts.analysisBudget > 0 &&
|
|
1283
|
+
facts.cases.some((c) => c.enriched && !c.analyzed)
|
|
1284
|
+
);
|
|
1285
|
+
},
|
|
1286
|
+
require: (facts) => {
|
|
1287
|
+
const target = facts.cases.find((c) => c.enriched && !c.analyzed);
|
|
1288
|
+
|
|
1289
|
+
return { type: "ANALYZE_CASE", caseId: target?.id ?? "" };
|
|
1290
|
+
},
|
|
1291
|
+
},
|
|
1292
|
+
|
|
1293
|
+
humanReviewNeeded: {
|
|
1294
|
+
priority: 65,
|
|
1295
|
+
after: ["analysisNeeded"],
|
|
1296
|
+
when: (facts) => {
|
|
1297
|
+
return facts.cases.some(
|
|
1298
|
+
(c) =>
|
|
1299
|
+
c.analyzed &&
|
|
1300
|
+
c.riskScore > facts.riskThreshold &&
|
|
1301
|
+
c.disposition === "pending",
|
|
1302
|
+
);
|
|
1303
|
+
},
|
|
1304
|
+
require: (facts) => {
|
|
1305
|
+
const target = facts.cases.find(
|
|
1306
|
+
(c) =>
|
|
1307
|
+
c.analyzed &&
|
|
1308
|
+
c.riskScore > facts.riskThreshold &&
|
|
1309
|
+
c.disposition === "pending",
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
return { type: "HUMAN_REVIEW", caseId: target?.id ?? "" };
|
|
1313
|
+
},
|
|
1314
|
+
},
|
|
1315
|
+
|
|
1316
|
+
budgetEscalation: {
|
|
1317
|
+
priority: 60,
|
|
1318
|
+
when: (facts) => {
|
|
1319
|
+
return (
|
|
1320
|
+
facts.analysisBudget <= 0 &&
|
|
1321
|
+
facts.cases.some(
|
|
1322
|
+
(c) => c.enriched && !c.analyzed && c.disposition === "pending",
|
|
1323
|
+
)
|
|
1324
|
+
);
|
|
1325
|
+
},
|
|
1326
|
+
require: (facts) => {
|
|
1327
|
+
const target = facts.cases.find(
|
|
1328
|
+
(c) => c.enriched && !c.analyzed && c.disposition === "pending",
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
return { type: "ESCALATE", caseId: target?.id ?? "" };
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
},
|
|
1335
|
+
|
|
1336
|
+
// ============================================================================
|
|
1337
|
+
// Resolvers (6)
|
|
1338
|
+
// ============================================================================
|
|
1339
|
+
|
|
1340
|
+
resolvers: {
|
|
1341
|
+
normalizeEvents: {
|
|
1342
|
+
requirement: "NORMALIZE_EVENTS",
|
|
1343
|
+
resolve: async (_req, context) => {
|
|
1344
|
+
addTimeline("stage", "normalizing events");
|
|
1345
|
+
|
|
1346
|
+
const events = [...context.facts.flagEvents];
|
|
1347
|
+
let piiCount = 0;
|
|
1348
|
+
|
|
1349
|
+
for (let i = 0; i < events.length; i++) {
|
|
1350
|
+
const event = events[i];
|
|
1351
|
+
|
|
1352
|
+
// Run PII detection on merchant + memo fields
|
|
1353
|
+
const merchantResult = await detectPII(event.merchant, {
|
|
1354
|
+
types: ["credit_card", "bank_account", "ssn"],
|
|
1355
|
+
});
|
|
1356
|
+
const memoResult = await detectPII(event.memo, {
|
|
1357
|
+
types: ["credit_card", "bank_account", "ssn"],
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
const hasPii = merchantResult.detected || memoResult.detected;
|
|
1361
|
+
if (hasPii) {
|
|
1362
|
+
piiCount++;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
events[i] = {
|
|
1366
|
+
...event,
|
|
1367
|
+
piiFound: hasPii,
|
|
1368
|
+
redactedMerchant: merchantResult.detected
|
|
1369
|
+
? redactPII(event.merchant, merchantResult.items, "typed")
|
|
1370
|
+
: event.merchant,
|
|
1371
|
+
redactedMemo: memoResult.detected
|
|
1372
|
+
? redactPII(event.memo, memoResult.items, "typed")
|
|
1373
|
+
: event.memo,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Simulate processing delay (before fact mutations to avoid
|
|
1378
|
+
// mid-resolver reconcile canceling this resolver)
|
|
1379
|
+
await delay(300);
|
|
1380
|
+
|
|
1381
|
+
// All fact mutations at the end — no more awaits after this
|
|
1382
|
+
context.facts.stage = "normalizing";
|
|
1383
|
+
context.facts.flagEvents = events;
|
|
1384
|
+
context.facts.totalPiiDetections =
|
|
1385
|
+
context.facts.totalPiiDetections + piiCount;
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1388
|
+
|
|
1389
|
+
groupEvents: {
|
|
1390
|
+
requirement: "GROUP_EVENTS",
|
|
1391
|
+
resolve: async (_req, context) => {
|
|
1392
|
+
addTimeline("stage", "grouping events into cases");
|
|
1393
|
+
|
|
1394
|
+
const events = [...context.facts.flagEvents];
|
|
1395
|
+
const existingCases = [...context.facts.cases];
|
|
1396
|
+
|
|
1397
|
+
// Group by accountId
|
|
1398
|
+
const groups = new Map<string, FlagEvent[]>();
|
|
1399
|
+
for (const event of events) {
|
|
1400
|
+
if (event.grouped) {
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const existing = groups.get(event.accountId) ?? [];
|
|
1405
|
+
existing.push(event);
|
|
1406
|
+
groups.set(event.accountId, existing);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Create cases from groups
|
|
1410
|
+
let caseNum = existingCases.length;
|
|
1411
|
+
for (const [accountId, groupEvents] of groups) {
|
|
1412
|
+
caseNum++;
|
|
1413
|
+
const newCase: FraudCase = {
|
|
1414
|
+
id: `case-${String(caseNum).padStart(3, "0")}`,
|
|
1415
|
+
accountId,
|
|
1416
|
+
events: groupEvents,
|
|
1417
|
+
signals: [],
|
|
1418
|
+
enriched: false,
|
|
1419
|
+
analyzed: false,
|
|
1420
|
+
riskScore: 0,
|
|
1421
|
+
severity: "low",
|
|
1422
|
+
disposition: "pending",
|
|
1423
|
+
};
|
|
1424
|
+
existingCases.push(newCase);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Mark all events as grouped
|
|
1428
|
+
const markedEvents = events.map((e) => ({ ...e, grouped: true }));
|
|
1429
|
+
|
|
1430
|
+
await delay(200);
|
|
1431
|
+
|
|
1432
|
+
// All fact mutations at the end — no more awaits after this
|
|
1433
|
+
context.facts.stage = "grouping";
|
|
1434
|
+
context.facts.flagEvents = markedEvents;
|
|
1435
|
+
context.facts.cases = existingCases;
|
|
1436
|
+
},
|
|
1437
|
+
},
|
|
1438
|
+
|
|
1439
|
+
enrichCase: {
|
|
1440
|
+
requirement: "ENRICH_CASE",
|
|
1441
|
+
key: (req) => `enrich-${req.caseId}`,
|
|
1442
|
+
retry: { attempts: 2, backoff: "exponential" },
|
|
1443
|
+
resolve: async (req, context) => {
|
|
1444
|
+
addTimeline("stage", `enriching ${req.caseId}`);
|
|
1445
|
+
|
|
1446
|
+
const cases = [...context.facts.cases];
|
|
1447
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
1448
|
+
if (idx < 0) {
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const signals = getMockEnrichment(cases[idx].accountId);
|
|
1453
|
+
|
|
1454
|
+
// Simulate API call
|
|
1455
|
+
await delay(400);
|
|
1456
|
+
|
|
1457
|
+
// All fact mutations at the end — no more awaits after this
|
|
1458
|
+
cases[idx] = {
|
|
1459
|
+
...cases[idx],
|
|
1460
|
+
signals,
|
|
1461
|
+
enriched: true,
|
|
1462
|
+
};
|
|
1463
|
+
context.facts.stage = "enriching";
|
|
1464
|
+
context.facts.cases = cases;
|
|
1465
|
+
},
|
|
1466
|
+
},
|
|
1467
|
+
|
|
1468
|
+
analyzeCase: {
|
|
1469
|
+
requirement: "ANALYZE_CASE",
|
|
1470
|
+
key: (req) => `analyze-${req.caseId}`,
|
|
1471
|
+
retry: { attempts: 1, backoff: "none" },
|
|
1472
|
+
resolve: async (req, context) => {
|
|
1473
|
+
addTimeline("stage", `analyzing ${req.caseId}`);
|
|
1474
|
+
|
|
1475
|
+
const cases = [...context.facts.cases];
|
|
1476
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
1477
|
+
if (idx < 0) {
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const fraudCase = cases[idx];
|
|
1482
|
+
|
|
1483
|
+
// Consume budget
|
|
1484
|
+
const cost = 25 + Math.floor(fraudCase.events.length * 5);
|
|
1485
|
+
|
|
1486
|
+
// Deterministic analysis
|
|
1487
|
+
await delay(500);
|
|
1488
|
+
const result = analyzeWithFormula(fraudCase);
|
|
1489
|
+
if (
|
|
1490
|
+
result.disposition === "pending" &&
|
|
1491
|
+
result.riskScore <= context.facts.riskThreshold
|
|
1492
|
+
) {
|
|
1493
|
+
result.disposition = "flagged";
|
|
1494
|
+
result.analysisNotes +=
|
|
1495
|
+
" Auto-flagged: below human review threshold.";
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// All fact mutations at the end — no more awaits after this
|
|
1499
|
+
cases[idx] = { ...fraudCase, ...result, analyzed: true };
|
|
1500
|
+
context.facts.stage = "analyzing";
|
|
1501
|
+
context.facts.analysisBudget = Math.max(
|
|
1502
|
+
0,
|
|
1503
|
+
context.facts.analysisBudget - cost,
|
|
1504
|
+
);
|
|
1505
|
+
context.facts.cases = cases;
|
|
1506
|
+
},
|
|
1507
|
+
},
|
|
1508
|
+
|
|
1509
|
+
humanReview: {
|
|
1510
|
+
requirement: "HUMAN_REVIEW",
|
|
1511
|
+
resolve: async (req, context) => {
|
|
1512
|
+
addTimeline("info", `${req.caseId} sent to human review`);
|
|
1513
|
+
|
|
1514
|
+
const cases = [...context.facts.cases];
|
|
1515
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
1516
|
+
if (idx < 0) {
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
await delay(100);
|
|
1521
|
+
|
|
1522
|
+
cases[idx] = {
|
|
1523
|
+
...cases[idx],
|
|
1524
|
+
disposition: "human_review",
|
|
1525
|
+
dispositionReason: "Risk score exceeds threshold",
|
|
1526
|
+
};
|
|
1527
|
+
context.facts.cases = cases;
|
|
1528
|
+
},
|
|
1529
|
+
},
|
|
1530
|
+
|
|
1531
|
+
escalate: {
|
|
1532
|
+
requirement: "ESCALATE",
|
|
1533
|
+
resolve: async (req, context) => {
|
|
1534
|
+
addTimeline("info", `${req.caseId} escalated (budget exhausted)`);
|
|
1535
|
+
|
|
1536
|
+
const cases = [...context.facts.cases];
|
|
1537
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
1538
|
+
if (idx < 0) {
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
await delay(100);
|
|
1543
|
+
|
|
1544
|
+
cases[idx] = {
|
|
1545
|
+
...cases[idx],
|
|
1546
|
+
disposition: "escalated",
|
|
1547
|
+
dispositionReason: "Analysis budget exhausted",
|
|
1548
|
+
};
|
|
1549
|
+
context.facts.cases = cases;
|
|
1550
|
+
},
|
|
1551
|
+
},
|
|
1552
|
+
},
|
|
1553
|
+
|
|
1554
|
+
// ============================================================================
|
|
1555
|
+
// Effects (3)
|
|
1556
|
+
// ============================================================================
|
|
1557
|
+
|
|
1558
|
+
effects: {
|
|
1559
|
+
logStageChange: {
|
|
1560
|
+
deps: ["stage"],
|
|
1561
|
+
run: (facts, prev) => {
|
|
1562
|
+
if (prev && prev.stage !== facts.stage) {
|
|
1563
|
+
addTimeline("stage", `${prev.stage} → ${facts.stage}`);
|
|
1564
|
+
}
|
|
1565
|
+
},
|
|
1566
|
+
},
|
|
1567
|
+
|
|
1568
|
+
logPiiDetection: {
|
|
1569
|
+
deps: ["totalPiiDetections"],
|
|
1570
|
+
run: (facts, prev) => {
|
|
1571
|
+
if (prev && facts.totalPiiDetections !== prev.totalPiiDetections) {
|
|
1572
|
+
addTimeline(
|
|
1573
|
+
"pii",
|
|
1574
|
+
`PII guardrail fired (${facts.totalPiiDetections} total detections)`,
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
},
|
|
1579
|
+
|
|
1580
|
+
logBudgetWarning: {
|
|
1581
|
+
deps: ["analysisBudget"],
|
|
1582
|
+
run: (facts, prev) => {
|
|
1583
|
+
if (prev && prev.analysisBudget > 0 && facts.analysisBudget <= 0) {
|
|
1584
|
+
addTimeline("budget", "analysis budget exhausted");
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
},
|
|
1588
|
+
},
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
// ============================================================================
|
|
1592
|
+
// System
|
|
1593
|
+
// ============================================================================
|
|
1594
|
+
|
|
1595
|
+
export const system = createSystem({
|
|
1596
|
+
module: fraudAnalysisModule,
|
|
1597
|
+
plugins: [devtoolsPlugin({ name: "fraud-analysis", panel: true })],
|
|
1598
|
+
history: { maxSnapshots: 50 },
|
|
1599
|
+
trace: { maxRuns: 100 },
|
|
1600
|
+
});
|
|
1601
|
+
|
|
1602
|
+
// ============================================================================
|
|
1603
|
+
// Helpers
|
|
1604
|
+
// ============================================================================
|
|
1605
|
+
|
|
1606
|
+
export function delay(ms: number): Promise<void> {
|
|
1607
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1608
|
+
}
|
|
1609
|
+
```
|