@davidorex/pi-behavior-monitors 0.1.2

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/index.ts ADDED
@@ -0,0 +1,1166 @@
1
+ /**
2
+ * Behavior monitors for pi — watches agent activity, classifies against
3
+ * pattern libraries, steers corrections, and writes structured findings
4
+ * to JSON files for downstream consumption.
5
+ *
6
+ * Monitor definitions are JSON files (.monitor.json) with typed blocks:
7
+ * classify (LLM side-channel), patterns (JSON library), actions (steer + write).
8
+ * Patterns and instructions are JSON arrays conforming to schemas.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { complete } from "@mariozechner/pi-ai";
14
+ import type { Api, AssistantMessage, Model, TextContent, ToolCall } from "@mariozechner/pi-ai";
15
+ import type {
16
+ AgentEndEvent,
17
+ ExtensionAPI,
18
+ ExtensionContext,
19
+ MessageEndEvent,
20
+ SessionEntry,
21
+ SessionMessageEntry,
22
+ TurnEndEvent,
23
+ } from "@mariozechner/pi-coding-agent";
24
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
25
+ import { Box, Text } from "@mariozechner/pi-tui";
26
+
27
+ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
28
+ const EXAMPLES_DIR = path.join(EXTENSION_DIR, "examples");
29
+
30
+ // =============================================================================
31
+ // Types
32
+ // =============================================================================
33
+
34
+ export interface MonitorScope {
35
+ target: "main" | "subagent" | "all" | "workflow";
36
+ filter?: {
37
+ agent_type?: string[];
38
+ step_name?: string;
39
+ workflow?: string;
40
+ };
41
+ }
42
+
43
+ export interface MonitorAction {
44
+ steer?: string | null;
45
+ learn_pattern?: boolean;
46
+ write?: {
47
+ path: string;
48
+ schema?: string;
49
+ merge: "append" | "upsert";
50
+ array_field: string;
51
+ template: Record<string, string>;
52
+ };
53
+ }
54
+
55
+ export interface MonitorSpec {
56
+ name: string;
57
+ description: string;
58
+ event: MonitorEvent;
59
+ when: string;
60
+ scope: MonitorScope;
61
+ classify: {
62
+ model: string;
63
+ context: string[];
64
+ excludes: string[];
65
+ prompt: string;
66
+ };
67
+ patterns: {
68
+ path: string;
69
+ learn: boolean;
70
+ };
71
+ instructions: {
72
+ path: string;
73
+ };
74
+ actions: {
75
+ on_flag?: MonitorAction | null;
76
+ on_new?: MonitorAction | null;
77
+ on_clean?: MonitorAction | null;
78
+ };
79
+ ceiling: number;
80
+ escalate: "ask" | "dismiss";
81
+ }
82
+
83
+ export interface MonitorPattern {
84
+ id: string;
85
+ description: string;
86
+ severity?: string;
87
+ category?: string;
88
+ examples?: string[];
89
+ learned_at?: string;
90
+ source?: string;
91
+ }
92
+
93
+ export interface MonitorInstruction {
94
+ text: string;
95
+ added_at?: string;
96
+ }
97
+
98
+ export interface Monitor extends MonitorSpec {
99
+ dir: string;
100
+ resolvedPatternsPath: string;
101
+ resolvedInstructionsPath: string;
102
+ // runtime state
103
+ activationCount: number;
104
+ whileCount: number;
105
+ lastUserText: string;
106
+ dismissed: boolean;
107
+ }
108
+
109
+ export interface ClassifyResult {
110
+ verdict: "clean" | "flag" | "new";
111
+ description?: string;
112
+ newPattern?: string;
113
+ }
114
+
115
+ export interface MonitorMessageDetails {
116
+ monitorName: string;
117
+ verdict: "flag" | "new";
118
+ description: string;
119
+ steer: string;
120
+ whileCount: number;
121
+ ceiling: number;
122
+ }
123
+
124
+ type MonitorEvent = "message_end" | "turn_end" | "agent_end" | "command";
125
+
126
+ const VALID_EVENTS = new Set<string>(["message_end", "turn_end", "agent_end", "command"]);
127
+
128
+ function isValidEvent(event: string): event is MonitorEvent {
129
+ return VALID_EVENTS.has(event);
130
+ }
131
+
132
+ // =============================================================================
133
+ // Discovery
134
+ // =============================================================================
135
+
136
+ function discoverMonitors(): Monitor[] {
137
+ const dirs: string[] = [];
138
+
139
+ // project-local
140
+ let cwd = process.cwd();
141
+ while (true) {
142
+ const candidate = path.join(cwd, ".pi", "monitors");
143
+ if (isDir(candidate)) {
144
+ dirs.push(candidate);
145
+ break;
146
+ }
147
+ const parent = path.dirname(cwd);
148
+ if (parent === cwd) break;
149
+ cwd = parent;
150
+ }
151
+
152
+ // global
153
+ const globalDir = path.join(getAgentDir(), "monitors");
154
+ if (isDir(globalDir)) dirs.push(globalDir);
155
+
156
+ const seen = new Map<string, Monitor>();
157
+ for (const dir of dirs) {
158
+ for (const file of listMonitorFiles(dir)) {
159
+ const monitor = parseMonitorJson(path.join(dir, file), dir);
160
+ if (monitor && !seen.has(monitor.name)) {
161
+ seen.set(monitor.name, monitor);
162
+ }
163
+ }
164
+ }
165
+ return Array.from(seen.values());
166
+ }
167
+
168
+ function isDir(p: string): boolean {
169
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
170
+ }
171
+
172
+ function listMonitorFiles(dir: string): string[] {
173
+ try {
174
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".monitor.json"));
175
+ } catch { return []; }
176
+ }
177
+
178
+ function parseMonitorJson(filePath: string, dir: string): Monitor | null {
179
+ let raw: string;
180
+ try { raw = fs.readFileSync(filePath, "utf-8"); } catch { return null; }
181
+
182
+ let spec: Record<string, unknown>;
183
+ try { spec = JSON.parse(raw); } catch {
184
+ console.error(`[monitors] Failed to parse ${filePath}`);
185
+ return null;
186
+ }
187
+
188
+ const name = spec.name as string | undefined;
189
+ if (!name) return null;
190
+
191
+ const event = String(spec.event ?? "message_end");
192
+ if (!isValidEvent(event)) {
193
+ console.error(`[${name}] Invalid event: ${event}. Must be one of: ${[...VALID_EVENTS].join(", ")}`);
194
+ return null;
195
+ }
196
+
197
+ const classify = spec.classify as MonitorSpec["classify"] | undefined;
198
+ if (!classify?.prompt) {
199
+ console.error(`[${name}] Missing classify.prompt`);
200
+ return null;
201
+ }
202
+
203
+ const patternsSpec = spec.patterns as MonitorSpec["patterns"] | undefined;
204
+ if (!patternsSpec?.path) {
205
+ console.error(`[${name}] Missing patterns.path`);
206
+ return null;
207
+ }
208
+
209
+ const scope = spec.scope as MonitorScope | undefined;
210
+ const instructions = spec.instructions as MonitorSpec["instructions"] | undefined;
211
+ const actions = spec.actions as MonitorSpec["actions"] | undefined;
212
+
213
+ return {
214
+ name,
215
+ description: String(spec.description ?? ""),
216
+ event: event as MonitorEvent,
217
+ when: String(spec.when ?? "always"),
218
+ scope: scope ?? { target: "main" },
219
+ classify: {
220
+ model: classify.model ?? "claude-sonnet-4-20250514",
221
+ context: Array.isArray(classify.context) ? classify.context : ["tool_results", "assistant_text"],
222
+ excludes: Array.isArray(classify.excludes) ? classify.excludes : [],
223
+ prompt: classify.prompt,
224
+ },
225
+ patterns: {
226
+ path: patternsSpec.path,
227
+ learn: patternsSpec.learn !== false,
228
+ },
229
+ instructions: {
230
+ path: instructions?.path ?? `${name}.instructions.json`,
231
+ },
232
+ actions: actions ?? {},
233
+ ceiling: Number(spec.ceiling) || 5,
234
+ escalate: (spec.escalate === "dismiss" ? "dismiss" : "ask"),
235
+ dir,
236
+ resolvedPatternsPath: path.resolve(dir, patternsSpec.path),
237
+ resolvedInstructionsPath: path.resolve(dir, instructions?.path ?? `${name}.instructions.json`),
238
+ // runtime state
239
+ activationCount: 0,
240
+ whileCount: 0,
241
+ lastUserText: "",
242
+ dismissed: false,
243
+ };
244
+ }
245
+
246
+ // =============================================================================
247
+ // Example seeding
248
+ // =============================================================================
249
+
250
+ function resolveProjectMonitorsDir(): string {
251
+ let cwd = process.cwd();
252
+ while (true) {
253
+ const piDir = path.join(cwd, ".pi");
254
+ if (isDir(piDir)) return path.join(piDir, "monitors");
255
+ const parent = path.dirname(cwd);
256
+ if (parent === cwd) break;
257
+ cwd = parent;
258
+ }
259
+ return path.join(process.cwd(), ".pi", "monitors");
260
+ }
261
+
262
+ function seedExamples(): number {
263
+ if (discoverMonitors().length > 0) return 0;
264
+ if (!isDir(EXAMPLES_DIR)) return 0;
265
+
266
+ const targetDir = resolveProjectMonitorsDir();
267
+ fs.mkdirSync(targetDir, { recursive: true });
268
+
269
+ if (listMonitorFiles(targetDir).length > 0) return 0;
270
+
271
+ const files = fs.readdirSync(EXAMPLES_DIR).filter((f) => f.endsWith(".json"));
272
+ let copied = 0;
273
+ for (const file of files) {
274
+ const dest = path.join(targetDir, file);
275
+ if (!fs.existsSync(dest)) {
276
+ fs.copyFileSync(path.join(EXAMPLES_DIR, file), dest);
277
+ copied++;
278
+ }
279
+ }
280
+ return copied;
281
+ }
282
+
283
+ // =============================================================================
284
+ // Context collection
285
+ // =============================================================================
286
+
287
+ const TRUNCATE = 2000;
288
+
289
+ function extractText(parts: (TextContent | ToolCall)[]): string {
290
+ return parts.filter((b): b is TextContent => b.type === "text").map((b) => b.text).join("");
291
+ }
292
+
293
+ function extractUserText(parts: string | (TextContent | { type: string })[]): string {
294
+ if (typeof parts === "string") return parts;
295
+ if (!Array.isArray(parts)) return "";
296
+ return parts.filter((b): b is TextContent => b.type === "text").map((b) => b.text).join("");
297
+ }
298
+
299
+ function trunc(text: string): string {
300
+ return text.length <= TRUNCATE ? text : `${text.slice(0, TRUNCATE)} [TRUNCATED]`;
301
+ }
302
+
303
+ function isMessageEntry(entry: SessionEntry): entry is SessionMessageEntry {
304
+ return entry.type === "message";
305
+ }
306
+
307
+ function collectUserText(branch: SessionEntry[]): string {
308
+ let foundAssistant = false;
309
+ for (let i = branch.length - 1; i >= 0; i--) {
310
+ const entry = branch[i];
311
+ if (!isMessageEntry(entry)) continue;
312
+ if (!foundAssistant) {
313
+ if (entry.message.role === "assistant") foundAssistant = true;
314
+ continue;
315
+ }
316
+ if (entry.message.role === "user") return extractUserText(entry.message.content);
317
+ }
318
+ return "";
319
+ }
320
+
321
+ function collectAssistantText(branch: SessionEntry[]): string {
322
+ for (let i = branch.length - 1; i >= 0; i--) {
323
+ const entry = branch[i];
324
+ if (isMessageEntry(entry) && entry.message.role === "assistant") {
325
+ return extractText(entry.message.content);
326
+ }
327
+ }
328
+ return "";
329
+ }
330
+
331
+ function collectToolResults(branch: SessionEntry[], limit = 5): string {
332
+ const results: string[] = [];
333
+ for (let i = branch.length - 1; i >= 0 && results.length < limit; i--) {
334
+ const entry = branch[i];
335
+ if (!isMessageEntry(entry) || entry.message.role !== "toolResult") continue;
336
+ const text = extractUserText(entry.message.content);
337
+ if (text) results.push(`---\n[${entry.message.toolName}${entry.message.isError ? " ERROR" : ""}] ${trunc(text)}\n---`);
338
+ }
339
+ return results.reverse().join("\n");
340
+ }
341
+
342
+ function collectToolCalls(branch: SessionEntry[], limit = 20): string {
343
+ const calls: string[] = [];
344
+ for (let i = branch.length - 1; i >= 0 && calls.length < limit; i--) {
345
+ const entry = branch[i];
346
+ if (!isMessageEntry(entry)) continue;
347
+ const msg = entry.message;
348
+ if (msg.role === "assistant") {
349
+ for (const part of msg.content) {
350
+ if (part.type === "toolCall") {
351
+ calls.push(`[call ${part.name}] ${trunc(JSON.stringify(part.arguments ?? {}))}`);
352
+ }
353
+ }
354
+ }
355
+ if (msg.role === "toolResult") {
356
+ calls.push(`[result ${msg.toolName}${msg.isError ? " ERROR" : ""}] ${trunc(extractUserText(msg.content))}`);
357
+ }
358
+ }
359
+ return calls.reverse().join("\n");
360
+ }
361
+
362
+ function collectCustomMessages(branch: SessionEntry[]): string {
363
+ const msgs: string[] = [];
364
+ for (let i = branch.length - 1; i >= 0; i--) {
365
+ const entry = branch[i];
366
+ if (!isMessageEntry(entry)) continue;
367
+ if (entry.message.role === "user") break;
368
+ const msg = entry.message as Record<string, unknown>;
369
+ if (msg.customType) {
370
+ msgs.unshift(`[${msg.customType}] ${msg.content ?? ""}`);
371
+ }
372
+ }
373
+ return msgs.join("\n");
374
+ }
375
+
376
+ const collectors: Record<string, (branch: SessionEntry[]) => string> = {
377
+ user_text: collectUserText,
378
+ assistant_text: collectAssistantText,
379
+ tool_results: collectToolResults,
380
+ tool_calls: collectToolCalls,
381
+ custom_messages: collectCustomMessages,
382
+ };
383
+
384
+ function hasToolResults(branch: SessionEntry[]): boolean {
385
+ for (let i = branch.length - 1; i >= 0; i--) {
386
+ const entry = branch[i];
387
+ if (!isMessageEntry(entry)) continue;
388
+ if (entry.message.role === "user") break;
389
+ if (entry.message.role === "toolResult") return true;
390
+ }
391
+ return false;
392
+ }
393
+
394
+ function hasToolNamed(branch: SessionEntry[], name: string): boolean {
395
+ for (let i = branch.length - 1; i >= 0; i--) {
396
+ const entry = branch[i];
397
+ if (!isMessageEntry(entry)) continue;
398
+ if (entry.message.role === "user") break;
399
+ if (entry.message.role === "assistant") {
400
+ for (const part of entry.message.content) {
401
+ if (part.type === "toolCall" && part.name === name) return true;
402
+ }
403
+ }
404
+ }
405
+ return false;
406
+ }
407
+
408
+ // =============================================================================
409
+ // When evaluation
410
+ // =============================================================================
411
+
412
+ function evaluateWhen(monitor: Monitor, branch: SessionEntry[]): boolean {
413
+ const w = monitor.when;
414
+ if (w === "always") return true;
415
+ if (w === "has_tool_results") return hasToolResults(branch);
416
+ if (w === "has_file_writes") return hasToolNamed(branch, "write") || hasToolNamed(branch, "edit");
417
+ if (w === "has_bash") return hasToolNamed(branch, "bash");
418
+
419
+ const everyMatch = w.match(/^every\((\d+)\)$/);
420
+ if (everyMatch) {
421
+ const n = parseInt(everyMatch[1]);
422
+ const userText = collectUserText(branch);
423
+ if (userText !== monitor.lastUserText) {
424
+ monitor.activationCount = 0;
425
+ monitor.lastUserText = userText;
426
+ }
427
+ monitor.activationCount++;
428
+ if (monitor.activationCount >= n) {
429
+ monitor.activationCount = 0;
430
+ return true;
431
+ }
432
+ return false;
433
+ }
434
+
435
+ const toolMatch = w.match(/^tool\((\w+)\)$/);
436
+ if (toolMatch) return hasToolNamed(branch, toolMatch[1]);
437
+
438
+ return true;
439
+ }
440
+
441
+ // =============================================================================
442
+ // Template rendering (JSON patterns → text for LLM prompt)
443
+ // =============================================================================
444
+
445
+ function loadPatterns(monitor: Monitor): MonitorPattern[] {
446
+ try {
447
+ const raw = fs.readFileSync(monitor.resolvedPatternsPath, "utf-8");
448
+ return JSON.parse(raw);
449
+ } catch {
450
+ return [];
451
+ }
452
+ }
453
+
454
+ function formatPatternsForPrompt(patterns: MonitorPattern[]): string {
455
+ return patterns
456
+ .map((p, i) => `${i + 1}. [${p.severity ?? "warning"}] ${p.description}`)
457
+ .join("\n");
458
+ }
459
+
460
+ function loadInstructions(monitor: Monitor): MonitorInstruction[] {
461
+ try {
462
+ const raw = fs.readFileSync(monitor.resolvedInstructionsPath, "utf-8");
463
+ return JSON.parse(raw);
464
+ } catch {
465
+ return [];
466
+ }
467
+ }
468
+
469
+ function saveInstructions(monitor: Monitor, instructions: MonitorInstruction[]): string | null {
470
+ try {
471
+ fs.writeFileSync(monitor.resolvedInstructionsPath, JSON.stringify(instructions, null, 2) + "\n");
472
+ return null;
473
+ } catch (err) {
474
+ return err instanceof Error ? err.message : String(err);
475
+ }
476
+ }
477
+
478
+ // =============================================================================
479
+ // /monitors command — parsing and handlers
480
+ // =============================================================================
481
+
482
+ export type MonitorsCommand =
483
+ | { type: "list" }
484
+ | { type: "on" }
485
+ | { type: "off" }
486
+ | { type: "inspect"; name: string }
487
+ | { type: "rules-list"; name: string }
488
+ | { type: "rules-add"; name: string; text: string }
489
+ | { type: "rules-remove"; name: string; index: number }
490
+ | { type: "rules-replace"; name: string; index: number; text: string }
491
+ | { type: "patterns-list"; name: string }
492
+ | { type: "dismiss"; name: string }
493
+ | { type: "reset"; name: string }
494
+ | { type: "error"; message: string };
495
+
496
+ export function parseMonitorsArgs(args: string, knownNames: Set<string>): MonitorsCommand {
497
+ const trimmed = args.trim();
498
+ if (!trimmed) return { type: "list" };
499
+
500
+ const tokens = trimmed.split(/\s+/);
501
+ const first = tokens[0];
502
+
503
+ // global commands (only if not a monitor name)
504
+ if (!knownNames.has(first)) {
505
+ if (first === "on") return { type: "on" };
506
+ if (first === "off") return { type: "off" };
507
+ return { type: "error", message: `Unknown monitor: ${first}\nAvailable: ${[...knownNames].join(", ")}` };
508
+ }
509
+
510
+ const name = first;
511
+ if (tokens.length === 1) return { type: "inspect", name };
512
+
513
+ const verb = tokens[1];
514
+
515
+ if (verb === "rules") {
516
+ if (tokens.length === 2) return { type: "rules-list", name };
517
+ const action = tokens[2];
518
+ if (action === "add") {
519
+ const text = tokens.slice(3).join(" ");
520
+ if (!text) return { type: "error", message: "Usage: /monitors <name> rules add <text>" };
521
+ return { type: "rules-add", name, text };
522
+ }
523
+ if (action === "remove") {
524
+ const n = parseInt(tokens[3]);
525
+ if (isNaN(n) || n < 1) return { type: "error", message: "Usage: /monitors <name> rules remove <number>" };
526
+ return { type: "rules-remove", name, index: n };
527
+ }
528
+ if (action === "replace") {
529
+ const n = parseInt(tokens[3]);
530
+ const text = tokens.slice(4).join(" ");
531
+ if (isNaN(n) || n < 1 || !text) return { type: "error", message: "Usage: /monitors <name> rules replace <number> <text>" };
532
+ return { type: "rules-replace", name, index: n, text };
533
+ }
534
+ return { type: "error", message: `Unknown rules action: ${action}\nAvailable: add, remove, replace` };
535
+ }
536
+
537
+ if (verb === "patterns") return { type: "patterns-list", name };
538
+ if (verb === "dismiss") return { type: "dismiss", name };
539
+ if (verb === "reset") return { type: "reset", name };
540
+
541
+ return { type: "error", message: `Unknown subcommand: ${verb}\nAvailable: rules, patterns, dismiss, reset` };
542
+ }
543
+
544
+ function handleList(
545
+ monitors: Monitor[],
546
+ ctx: ExtensionContext,
547
+ enabled: boolean,
548
+ ): void {
549
+ const header = enabled ? "monitors: ON" : "monitors: OFF (all monitoring paused)";
550
+ const lines = monitors.map((m) => {
551
+ const state = m.dismissed
552
+ ? "dismissed"
553
+ : m.whileCount > 0
554
+ ? `engaged (${m.whileCount}/${m.ceiling})`
555
+ : "idle";
556
+ const scope = m.scope.target !== "main" ? ` [scope:${m.scope.target}]` : "";
557
+ return ` ${m.name} [${m.event}${m.when !== "always" ? `, when: ${m.when}` : ""}]${scope} — ${state}`;
558
+ });
559
+ ctx.ui.notify(`${header}\n${lines.join("\n")}`, "info");
560
+ }
561
+
562
+ function handleInspect(monitor: Monitor, ctx: ExtensionContext): void {
563
+ const rules = loadInstructions(monitor);
564
+ const patterns = loadPatterns(monitor);
565
+ const state = monitor.dismissed
566
+ ? "dismissed"
567
+ : monitor.whileCount > 0
568
+ ? `engaged (${monitor.whileCount}/${monitor.ceiling})`
569
+ : "idle";
570
+ const lines = [
571
+ `[${monitor.name}] ${monitor.description}`,
572
+ `event: ${monitor.event}, when: ${monitor.when}, scope: ${monitor.scope.target}`,
573
+ `state: ${state}, ceiling: ${monitor.ceiling}, escalate: ${monitor.escalate}`,
574
+ `rules: ${rules.length}, patterns: ${patterns.length}`,
575
+ ];
576
+ ctx.ui.notify(lines.join("\n"), "info");
577
+ }
578
+
579
+ function handleRulesList(monitor: Monitor, ctx: ExtensionContext): void {
580
+ const rules = loadInstructions(monitor);
581
+ if (rules.length === 0) {
582
+ ctx.ui.notify(`[${monitor.name}] (no rules)`, "info");
583
+ return;
584
+ }
585
+ const lines = rules.map((r, i) => `${i + 1}. ${r.text}`);
586
+ ctx.ui.notify(`[${monitor.name}] rules:\n${lines.join("\n")}`, "info");
587
+ }
588
+
589
+ function handleRulesAdd(monitor: Monitor, ctx: ExtensionContext, text: string): void {
590
+ const rules = loadInstructions(monitor);
591
+ rules.push({ text, added_at: new Date().toISOString() });
592
+ const err = saveInstructions(monitor, rules);
593
+ if (err) {
594
+ ctx.ui.notify(`[${monitor.name}] Failed to save: ${err}`, "error");
595
+ } else {
596
+ ctx.ui.notify(`[${monitor.name}] Rule added: ${text}`, "info");
597
+ }
598
+ }
599
+
600
+ function handleRulesRemove(monitor: Monitor, ctx: ExtensionContext, index: number): void {
601
+ const rules = loadInstructions(monitor);
602
+ if (index < 1 || index > rules.length) {
603
+ ctx.ui.notify(`[${monitor.name}] Invalid index ${index}. Have ${rules.length} rules.`, "error");
604
+ return;
605
+ }
606
+ const removed = rules.splice(index - 1, 1)[0];
607
+ const err = saveInstructions(monitor, rules);
608
+ if (err) {
609
+ ctx.ui.notify(`[${monitor.name}] Failed to save: ${err}`, "error");
610
+ } else {
611
+ ctx.ui.notify(`[${monitor.name}] Removed rule ${index}: ${removed.text}`, "info");
612
+ }
613
+ }
614
+
615
+ function handleRulesReplace(monitor: Monitor, ctx: ExtensionContext, index: number, text: string): void {
616
+ const rules = loadInstructions(monitor);
617
+ if (index < 1 || index > rules.length) {
618
+ ctx.ui.notify(`[${monitor.name}] Invalid index ${index}. Have ${rules.length} rules.`, "error");
619
+ return;
620
+ }
621
+ const old = rules[index - 1].text;
622
+ rules[index - 1] = { text, added_at: new Date().toISOString() };
623
+ const err = saveInstructions(monitor, rules);
624
+ if (err) {
625
+ ctx.ui.notify(`[${monitor.name}] Failed to save: ${err}`, "error");
626
+ } else {
627
+ ctx.ui.notify(`[${monitor.name}] Replaced rule ${index}:\n was: ${old}\n now: ${text}`, "info");
628
+ }
629
+ }
630
+
631
+ function handlePatternsList(monitor: Monitor, ctx: ExtensionContext): void {
632
+ const patterns = loadPatterns(monitor);
633
+ if (patterns.length === 0) {
634
+ ctx.ui.notify(`[${monitor.name}] (no patterns — monitor will not classify)`, "info");
635
+ return;
636
+ }
637
+ const lines = patterns.map((p, i) => {
638
+ const source = p.source ? ` (${p.source})` : "";
639
+ return `${i + 1}. [${p.severity ?? "warning"}] ${p.description}${source}`;
640
+ });
641
+ ctx.ui.notify(`[${monitor.name}] patterns:\n${lines.join("\n")}`, "info");
642
+ }
643
+
644
+ function formatInstructionsForPrompt(instructions: MonitorInstruction[]): string {
645
+ if (instructions.length === 0) return "";
646
+ const lines = instructions.map((i) => `- ${i.text}`).join("\n");
647
+ return `\nOperating instructions from the user (follow these strictly):\n${lines}\n`;
648
+ }
649
+
650
+ function renderTemplate(monitor: Monitor, branch: SessionEntry[]): string | null {
651
+ const patterns = loadPatterns(monitor);
652
+ if (patterns.length === 0) return null;
653
+
654
+ const instructions = loadInstructions(monitor);
655
+
656
+ const collected: Record<string, string> = {};
657
+ for (const key of monitor.classify.context) {
658
+ const fn = collectors[key];
659
+ if (fn) collected[key] = fn(branch);
660
+ }
661
+
662
+ return monitor.classify.prompt.replace(/\{(\w+)\}/g, (match, key: string) => {
663
+ if (key === "patterns") return formatPatternsForPrompt(patterns);
664
+ if (key === "instructions") return formatInstructionsForPrompt(instructions);
665
+ if (key === "iteration") return String(monitor.whileCount);
666
+ return collected[key] ?? match;
667
+ });
668
+ }
669
+
670
+ // =============================================================================
671
+ // Classification
672
+ // =============================================================================
673
+
674
+ export function parseVerdict(raw: string): ClassifyResult {
675
+ const text = raw.trim();
676
+ if (text.startsWith("CLEAN")) return { verdict: "clean" };
677
+ if (text.startsWith("NEW:")) {
678
+ const rest = text.slice(4);
679
+ const pipe = rest.indexOf("|");
680
+ if (pipe !== -1) return { verdict: "new", newPattern: rest.slice(0, pipe).trim(), description: rest.slice(pipe + 1).trim() };
681
+ return { verdict: "new", newPattern: rest.trim(), description: rest.trim() };
682
+ }
683
+ if (text.startsWith("FLAG:")) return { verdict: "flag", description: text.slice(5).trim() };
684
+ return { verdict: "clean" };
685
+ }
686
+
687
+ export function parseModelSpec(spec: string): { provider: string; modelId: string } {
688
+ const slashIndex = spec.indexOf("/");
689
+ if (slashIndex !== -1) {
690
+ return { provider: spec.slice(0, slashIndex), modelId: spec.slice(slashIndex + 1) };
691
+ }
692
+ return { provider: "anthropic", modelId: spec };
693
+ }
694
+
695
+ async function classifyPrompt(ctx: ExtensionContext, monitor: Monitor, prompt: string, signal?: AbortSignal): Promise<ClassifyResult> {
696
+ const { provider, modelId } = parseModelSpec(monitor.classify.model);
697
+ const model = ctx.modelRegistry.find(provider, modelId);
698
+ if (!model) throw new Error(`Model ${monitor.classify.model} not found`);
699
+
700
+ const apiKey = await ctx.modelRegistry.getApiKey(model);
701
+ if (!apiKey) throw new Error(`No API key for ${monitor.classify.model}`);
702
+
703
+ const response: AssistantMessage = await complete(
704
+ model as Model<Api>,
705
+ { messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }] },
706
+ { apiKey, maxTokens: 150, signal },
707
+ );
708
+
709
+ return parseVerdict(extractText(response.content));
710
+ }
711
+
712
+ // =============================================================================
713
+ // Pattern learning (JSON)
714
+ // =============================================================================
715
+
716
+ function learnPattern(monitor: Monitor, description: string): void {
717
+ const patterns = loadPatterns(monitor);
718
+ const id = description.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60);
719
+
720
+ // dedup by description
721
+ if (patterns.some((p) => p.description === description)) return;
722
+
723
+ patterns.push({
724
+ id,
725
+ description,
726
+ severity: "warning",
727
+ source: "learned",
728
+ learned_at: new Date().toISOString(),
729
+ });
730
+
731
+ try {
732
+ fs.writeFileSync(monitor.resolvedPatternsPath, JSON.stringify(patterns, null, 2) + "\n");
733
+ } catch (err) {
734
+ console.error(`[${monitor.name}] Failed to write pattern: ${err instanceof Error ? err.message : err}`);
735
+ }
736
+ }
737
+
738
+ // =============================================================================
739
+ // Action execution — write findings to JSON files
740
+ // =============================================================================
741
+
742
+ export function generateFindingId(monitorName: string, _description: string): string {
743
+ return `${monitorName}-${Date.now().toString(36)}`;
744
+ }
745
+
746
+ function executeWriteAction(
747
+ monitor: Monitor,
748
+ action: MonitorAction,
749
+ result: ClassifyResult,
750
+ ): void {
751
+ if (!action.write) return;
752
+
753
+ const writeCfg = action.write;
754
+ const filePath = path.isAbsolute(writeCfg.path)
755
+ ? writeCfg.path
756
+ : path.resolve(process.cwd(), writeCfg.path);
757
+
758
+ // Build the entry from template, substituting placeholders
759
+ const findingId = generateFindingId(monitor.name, result.description ?? "unknown");
760
+ const entry: Record<string, unknown> = {};
761
+ for (const [key, tmpl] of Object.entries(writeCfg.template)) {
762
+ entry[key] = String(tmpl)
763
+ .replace(/\{finding_id\}/g, findingId)
764
+ .replace(/\{description\}/g, result.description ?? "Issue detected")
765
+ .replace(/\{severity\}/g, "warning")
766
+ .replace(/\{monitor_name\}/g, monitor.name)
767
+ .replace(/\{timestamp\}/g, new Date().toISOString());
768
+ }
769
+
770
+ // Read existing file or create structure
771
+ let data: Record<string, unknown> = {};
772
+ try {
773
+ data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
774
+ } catch {
775
+ // file doesn't exist or is invalid — create fresh
776
+ }
777
+
778
+ const arrayField = writeCfg.array_field;
779
+ if (!Array.isArray(data[arrayField])) {
780
+ data[arrayField] = [];
781
+ }
782
+ const arr = data[arrayField] as Record<string, unknown>[];
783
+
784
+ if (writeCfg.merge === "upsert") {
785
+ const idx = arr.findIndex((item) => item.id === entry.id);
786
+ if (idx !== -1) {
787
+ arr[idx] = entry;
788
+ } else {
789
+ arr.push(entry);
790
+ }
791
+ } else {
792
+ arr.push(entry);
793
+ }
794
+
795
+ try {
796
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
797
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
798
+ } catch (err) {
799
+ console.error(`[${monitor.name}] Failed to write to ${filePath}: ${err instanceof Error ? err.message : err}`);
800
+ }
801
+ }
802
+
803
+ // =============================================================================
804
+ // Activation
805
+ // =============================================================================
806
+
807
+ let monitorsEnabled = true;
808
+
809
+ async function activate(
810
+ monitor: Monitor,
811
+ pi: ExtensionAPI,
812
+ ctx: ExtensionContext,
813
+ branch: SessionEntry[],
814
+ steeredThisTurn: Set<string>,
815
+ updateStatus: () => void,
816
+ ): Promise<void> {
817
+ if (!monitorsEnabled) return;
818
+ if (monitor.dismissed) return;
819
+
820
+ // check excludes
821
+ for (const ex of monitor.classify.excludes) {
822
+ if (steeredThisTurn.has(ex)) return;
823
+ }
824
+
825
+ if (!evaluateWhen(monitor, branch)) return;
826
+
827
+ // dedup: skip if user text unchanged since last classification
828
+ const currentUserText = collectUserText(branch);
829
+ if (currentUserText && currentUserText === monitor.lastUserText) return;
830
+
831
+ // ceiling check
832
+ if (monitor.whileCount >= monitor.ceiling) {
833
+ await escalate(monitor, pi, ctx);
834
+ updateStatus();
835
+ return;
836
+ }
837
+
838
+ const prompt = renderTemplate(monitor, branch);
839
+ if (!prompt) return;
840
+
841
+ // create an abort controller so classification can be cancelled if the user aborts
842
+ const abortController = new AbortController();
843
+ const onAbort = () => abortController.abort();
844
+ const unsubAbort = pi.events.on("monitors:abort", onAbort);
845
+
846
+ let result: ClassifyResult;
847
+ try {
848
+ result = await classifyPrompt(ctx, monitor, prompt, abortController.signal);
849
+ } catch (e: unknown) {
850
+ if (abortController.signal.aborted) return;
851
+ const message = e instanceof Error ? e.message : String(e);
852
+ if (ctx.hasUI) {
853
+ ctx.ui.notify(`[${monitor.name}] Classification failed: ${message}`, "error");
854
+ } else {
855
+ console.error(`[${monitor.name}] Classification failed: ${message}`);
856
+ }
857
+ return;
858
+ } finally {
859
+ unsubAbort();
860
+ }
861
+
862
+ // mark this user text as classified
863
+ monitor.lastUserText = currentUserText;
864
+
865
+ if (result.verdict === "clean") {
866
+ const cleanAction = monitor.actions.on_clean;
867
+ if (cleanAction) {
868
+ executeWriteAction(monitor, cleanAction, result);
869
+ }
870
+ monitor.whileCount = 0;
871
+ updateStatus();
872
+ return;
873
+ }
874
+
875
+ // Determine which action to execute
876
+ const action = result.verdict === "new" ? monitor.actions.on_new : monitor.actions.on_flag;
877
+ if (!action) return;
878
+
879
+ // Learn new pattern
880
+ if (result.verdict === "new" && result.newPattern && action.learn_pattern) {
881
+ learnPattern(monitor, result.newPattern);
882
+ }
883
+
884
+ // Execute write action (findings to JSON file)
885
+ executeWriteAction(monitor, action, result);
886
+
887
+ // Steer (inject message into conversation) — only for main scope
888
+ if (action.steer && monitor.scope.target === "main") {
889
+ const description = result.description ?? "Issue detected";
890
+ const annotation = result.verdict === "new" ? " — new pattern learned" : "";
891
+ const details: MonitorMessageDetails = {
892
+ monitorName: monitor.name,
893
+ verdict: result.verdict,
894
+ description,
895
+ steer: action.steer,
896
+ whileCount: monitor.whileCount + 1,
897
+ ceiling: monitor.ceiling,
898
+ };
899
+ pi.sendMessage<MonitorMessageDetails>(
900
+ {
901
+ customType: "monitor-steer",
902
+ content: `[${monitor.name}] ${description}${annotation}. ${action.steer}`,
903
+ display: true,
904
+ details,
905
+ },
906
+ { deliverAs: "steer", triggerTurn: true },
907
+ );
908
+ }
909
+
910
+ monitor.whileCount++;
911
+ steeredThisTurn.add(monitor.name);
912
+ updateStatus();
913
+ }
914
+
915
+ async function escalate(monitor: Monitor, pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
916
+ if (monitor.escalate === "dismiss") {
917
+ monitor.dismissed = true;
918
+ monitor.whileCount = 0;
919
+ return;
920
+ }
921
+
922
+ // In headless mode there is no way to prompt the user, so auto-dismiss
923
+ // to avoid an infinite classify-reset cycle that can never be resolved.
924
+ if (!ctx.hasUI) {
925
+ monitor.dismissed = true;
926
+ monitor.whileCount = 0;
927
+ return;
928
+ }
929
+
930
+ if (ctx.hasUI) {
931
+ const choice = await ctx.ui.confirm(
932
+ `[${monitor.name}] Steered ${monitor.ceiling} times`,
933
+ "Continue steering, or dismiss this monitor for the session?",
934
+ );
935
+ if (!choice) {
936
+ monitor.dismissed = true;
937
+ monitor.whileCount = 0;
938
+ return;
939
+ }
940
+ }
941
+ monitor.whileCount = 0;
942
+ }
943
+
944
+ // =============================================================================
945
+ // Extension entry point
946
+ // =============================================================================
947
+
948
+ export default function (pi: ExtensionAPI) {
949
+ const seeded = seedExamples();
950
+
951
+ const monitors = discoverMonitors();
952
+ if (monitors.length === 0) return;
953
+
954
+ let statusCtx: ExtensionContext | undefined;
955
+
956
+ function updateStatus(): void {
957
+ if (!statusCtx?.hasUI) return;
958
+ const theme = statusCtx.ui.theme;
959
+
960
+ if (!monitorsEnabled) {
961
+ statusCtx.ui.setStatus("monitors", `${theme.fg("dim", "monitors:")}${theme.fg("warning", "OFF")}`);
962
+ return;
963
+ }
964
+
965
+ const engaged = monitors.filter((m) => m.whileCount > 0 && !m.dismissed);
966
+ const dismissed = monitors.filter((m) => m.dismissed);
967
+
968
+ if (engaged.length === 0 && dismissed.length === 0) {
969
+ const count = theme.fg("dim", `${monitors.length}`);
970
+ statusCtx.ui.setStatus("monitors", `${theme.fg("dim", "monitors:")}${count}`);
971
+ return;
972
+ }
973
+
974
+ const parts: string[] = [];
975
+ for (const m of engaged) {
976
+ parts.push(theme.fg("warning", `${m.name}(${m.whileCount}/${m.ceiling})`));
977
+ }
978
+ if (dismissed.length > 0) {
979
+ parts.push(theme.fg("dim", `${dismissed.length} dismissed`));
980
+ }
981
+ statusCtx.ui.setStatus("monitors", `${theme.fg("dim", "monitors:")}${parts.join(" ")}`);
982
+ }
983
+
984
+ pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
985
+ statusCtx = ctx;
986
+ if (seeded > 0 && ctx.hasUI) {
987
+ const dir = resolveProjectMonitorsDir();
988
+ ctx.ui.notify(
989
+ `Seeded ${seeded} example monitor files into ${dir}\nEdit or delete them to customize.`,
990
+ "info",
991
+ );
992
+ }
993
+ updateStatus();
994
+ });
995
+
996
+ pi.on("session_switch", async (_event: unknown, ctx: ExtensionContext) => {
997
+ statusCtx = ctx;
998
+ for (const m of monitors) {
999
+ m.whileCount = 0;
1000
+ m.dismissed = false;
1001
+ m.lastUserText = "";
1002
+ m.activationCount = 0;
1003
+ }
1004
+ monitorsEnabled = true;
1005
+ updateStatus();
1006
+ });
1007
+
1008
+ // --- message renderer ---
1009
+ pi.registerMessageRenderer<MonitorMessageDetails>("monitor-steer", (message, { expanded }, theme) => {
1010
+ const details = message.details;
1011
+ if (!details) {
1012
+ const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
1013
+ box.addChild(new Text(String(message.content), 0, 0));
1014
+ return box;
1015
+ }
1016
+
1017
+ const verdictColor = details.verdict === "new" ? "warning" : "error";
1018
+ const prefix = theme.fg(verdictColor, `[${details.monitorName}]`);
1019
+ const desc = ` ${details.description}`;
1020
+ const counter = theme.fg("dim", ` (${details.whileCount}/${details.ceiling})`);
1021
+
1022
+ let text = `${prefix}${desc}${counter}`;
1023
+
1024
+ if (details.verdict === "new") {
1025
+ text += theme.fg("dim", " — new pattern learned");
1026
+ }
1027
+
1028
+ text += `\n${theme.fg("muted", details.steer)}`;
1029
+
1030
+ if (expanded) {
1031
+ text += `\n${theme.fg("dim", `verdict: ${details.verdict}`)}`;
1032
+ }
1033
+
1034
+ const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
1035
+ box.addChild(new Text(text, 0, 0));
1036
+ return box;
1037
+ });
1038
+
1039
+ // --- abort support ---
1040
+ pi.on("agent_end", async () => {
1041
+ pi.events.emit("monitors:abort", undefined);
1042
+ });
1043
+
1044
+ // --- per-turn exclusion tracking ---
1045
+ let steeredThisTurn = new Set<string>();
1046
+ pi.on("turn_start", () => { steeredThisTurn = new Set(); });
1047
+
1048
+ // group monitors by validated event
1049
+ const byEvent = new Map<MonitorEvent, Monitor[]>();
1050
+ for (const m of monitors) {
1051
+ const list = byEvent.get(m.event) ?? [];
1052
+ list.push(m);
1053
+ byEvent.set(m.event, list);
1054
+ }
1055
+
1056
+ // wire event handlers
1057
+ for (const [event, group] of byEvent) {
1058
+ if (event === "command") {
1059
+ for (const m of group) {
1060
+ pi.registerCommand(m.name, {
1061
+ description: m.description || `Run ${m.name} monitor`,
1062
+ handler: async (_args: string, ctx: ExtensionContext) => {
1063
+ const branch = ctx.sessionManager.getBranch();
1064
+ await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus);
1065
+ },
1066
+ });
1067
+ }
1068
+ } else if (event === "message_end") {
1069
+ pi.on("message_end", async (ev: MessageEndEvent, ctx: ExtensionContext) => {
1070
+ if (ev.message.role !== "assistant") return;
1071
+ const branch = ctx.sessionManager.getBranch();
1072
+ for (const m of group) {
1073
+ await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus);
1074
+ }
1075
+ });
1076
+ } else if (event === "turn_end") {
1077
+ pi.on("turn_end", async (_ev: TurnEndEvent, ctx: ExtensionContext) => {
1078
+ const branch = ctx.sessionManager.getBranch();
1079
+ for (const m of group) {
1080
+ await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus);
1081
+ }
1082
+ });
1083
+ } else if (event === "agent_end") {
1084
+ pi.on("agent_end", async (_ev: AgentEndEvent, ctx: ExtensionContext) => {
1085
+ const branch = ctx.sessionManager.getBranch();
1086
+ for (const m of group) {
1087
+ await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus);
1088
+ }
1089
+ });
1090
+ }
1091
+ }
1092
+
1093
+ // /monitors command — unified management interface
1094
+ const monitorNames = new Set(monitors.map((m) => m.name));
1095
+ const monitorsByName = new Map(monitors.map((m) => [m.name, m]));
1096
+
1097
+ pi.registerCommand("monitors", {
1098
+ description: "Manage behavior monitors",
1099
+ handler: async (args: string, ctx: ExtensionContext) => {
1100
+ const cmd = parseMonitorsArgs(args, monitorNames);
1101
+
1102
+ if (cmd.type === "error") {
1103
+ ctx.ui.notify(cmd.message, "error");
1104
+ return;
1105
+ }
1106
+
1107
+ if (cmd.type === "list") {
1108
+ handleList(monitors, ctx, monitorsEnabled);
1109
+ return;
1110
+ }
1111
+
1112
+ if (cmd.type === "on") {
1113
+ monitorsEnabled = true;
1114
+ updateStatus();
1115
+ ctx.ui.notify("Monitors enabled", "info");
1116
+ return;
1117
+ }
1118
+
1119
+ if (cmd.type === "off") {
1120
+ monitorsEnabled = false;
1121
+ updateStatus();
1122
+ ctx.ui.notify("All monitors paused for this session", "info");
1123
+ return;
1124
+ }
1125
+
1126
+ const monitor = monitorsByName.get(cmd.name);
1127
+ if (!monitor) {
1128
+ ctx.ui.notify(`Unknown monitor: ${cmd.name}`, "error");
1129
+ return;
1130
+ }
1131
+
1132
+ switch (cmd.type) {
1133
+ case "inspect":
1134
+ handleInspect(monitor, ctx);
1135
+ break;
1136
+ case "rules-list":
1137
+ handleRulesList(monitor, ctx);
1138
+ break;
1139
+ case "rules-add":
1140
+ handleRulesAdd(monitor, ctx, cmd.text);
1141
+ break;
1142
+ case "rules-remove":
1143
+ handleRulesRemove(monitor, ctx, cmd.index);
1144
+ break;
1145
+ case "rules-replace":
1146
+ handleRulesReplace(monitor, ctx, cmd.index, cmd.text);
1147
+ break;
1148
+ case "patterns-list":
1149
+ handlePatternsList(monitor, ctx);
1150
+ break;
1151
+ case "dismiss":
1152
+ monitor.dismissed = true;
1153
+ monitor.whileCount = 0;
1154
+ updateStatus();
1155
+ ctx.ui.notify(`[${monitor.name}] Dismissed for this session`, "info");
1156
+ break;
1157
+ case "reset":
1158
+ monitor.dismissed = false;
1159
+ monitor.whileCount = 0;
1160
+ updateStatus();
1161
+ ctx.ui.notify(`[${monitor.name}] Reset`, "info");
1162
+ break;
1163
+ }
1164
+ },
1165
+ });
1166
+ }