@davidorex/pi-behavior-monitors 0.1.4 → 0.3.1
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/CHANGELOG.md +30 -66
- package/README.md +9 -1
- package/dist/index.d.ts +166 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1576 -0
- package/dist/index.js.map +1 -0
- package/examples/commit-hygiene/classify.md +29 -0
- package/examples/commit-hygiene.instructions.json +1 -0
- package/examples/commit-hygiene.monitor.json +33 -0
- package/examples/commit-hygiene.patterns.json +44 -0
- package/examples/fragility/classify.md +33 -0
- package/examples/fragility.monitor.json +61 -60
- package/examples/fragility.patterns.json +84 -84
- package/examples/hedge/classify.md +38 -0
- package/examples/hedge.monitor.json +33 -32
- package/examples/hedge.patterns.json +56 -8
- package/examples/unauthorized-action/classify.md +27 -0
- package/examples/unauthorized-action.instructions.json +1 -0
- package/examples/unauthorized-action.monitor.json +32 -0
- package/examples/unauthorized-action.patterns.json +9 -0
- package/examples/work-quality/classify.md +30 -0
- package/examples/work-quality.monitor.json +61 -60
- package/examples/work-quality.patterns.json +77 -11
- package/package.json +53 -48
- package/schemas/monitor-pattern.schema.json +36 -36
- package/schemas/monitor.schema.json +158 -154
- package/skills/pi-behavior-monitors/SKILL.md +330 -51
- package/skills/pi-behavior-monitors/references/bundled-resources.md +29 -0
- package/index.ts +0 -1284
package/dist/index.js
ADDED
|
@@ -0,0 +1,1576 @@
|
|
|
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 { execSync } from "node:child_process";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { complete } from "@mariozechner/pi-ai";
|
|
16
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { Box, Text } from "@mariozechner/pi-tui";
|
|
18
|
+
import { Type } from "@sinclair/typebox";
|
|
19
|
+
import nunjucks from "nunjucks";
|
|
20
|
+
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const EXAMPLES_DIR = path.join(EXTENSION_DIR, "examples");
|
|
22
|
+
export const COLLECTOR_DESCRIPTORS = [
|
|
23
|
+
{ name: "user_text", description: "Most recent user message text" },
|
|
24
|
+
{ name: "assistant_text", description: "Most recent assistant message text" },
|
|
25
|
+
{ name: "tool_results", description: "Tool results with tool name and error status", limits: "Last 5, truncated 2000 chars" },
|
|
26
|
+
{ name: "tool_calls", description: "Tool calls and results interleaved", limits: "Last 20, truncated 2000 chars" },
|
|
27
|
+
{ name: "custom_messages", description: "Custom extension messages since last user message" },
|
|
28
|
+
{ name: "project_vision", description: ".project/project.json vision, core_value, name" },
|
|
29
|
+
{ name: "project_conventions", description: ".project/conformance-reference.json principle names" },
|
|
30
|
+
{ name: "git_status", description: "Output of git status --porcelain", limits: "5s timeout" },
|
|
31
|
+
];
|
|
32
|
+
export const WHEN_CONDITIONS = [
|
|
33
|
+
{ name: "always", description: "Fire every time the event occurs", parameterized: false },
|
|
34
|
+
{ name: "has_tool_results", description: "Fire only if tool results present since last user message", parameterized: false },
|
|
35
|
+
{ name: "has_file_writes", description: "Fire only if write or edit tool called since last user message", parameterized: false },
|
|
36
|
+
{ name: "has_bash", description: "Fire only if bash tool called since last user message", parameterized: false },
|
|
37
|
+
{ name: "every(N)", description: "Fire every Nth activation (counter resets when user text changes)", parameterized: true },
|
|
38
|
+
{ name: "tool(name)", description: "Fire only if specific named tool called since last user message", parameterized: true },
|
|
39
|
+
];
|
|
40
|
+
export const VERDICT_TYPES = ["clean", "flag", "new"];
|
|
41
|
+
export const SCOPE_TARGETS = ["main", "subagent", "all", "workflow"];
|
|
42
|
+
export const VALID_EVENTS = new Set(["message_end", "turn_end", "agent_end", "command"]);
|
|
43
|
+
function isValidEvent(event) {
|
|
44
|
+
return VALID_EVENTS.has(event);
|
|
45
|
+
}
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Discovery
|
|
48
|
+
// =============================================================================
|
|
49
|
+
function discoverMonitors() {
|
|
50
|
+
const dirs = [];
|
|
51
|
+
// project-local
|
|
52
|
+
let cwd = process.cwd();
|
|
53
|
+
while (true) {
|
|
54
|
+
const candidate = path.join(cwd, ".pi", "monitors");
|
|
55
|
+
if (isDir(candidate)) {
|
|
56
|
+
dirs.push(candidate);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
const parent = path.dirname(cwd);
|
|
60
|
+
if (parent === cwd)
|
|
61
|
+
break;
|
|
62
|
+
cwd = parent;
|
|
63
|
+
}
|
|
64
|
+
// global
|
|
65
|
+
const globalDir = path.join(getAgentDir(), "monitors");
|
|
66
|
+
if (isDir(globalDir))
|
|
67
|
+
dirs.push(globalDir);
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
for (const dir of dirs) {
|
|
70
|
+
for (const file of listMonitorFiles(dir)) {
|
|
71
|
+
const monitor = parseMonitorJson(path.join(dir, file), dir);
|
|
72
|
+
if (monitor && !seen.has(monitor.name)) {
|
|
73
|
+
seen.set(monitor.name, monitor);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Array.from(seen.values());
|
|
78
|
+
}
|
|
79
|
+
function isDir(p) {
|
|
80
|
+
try {
|
|
81
|
+
return fs.statSync(p).isDirectory();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function listMonitorFiles(dir) {
|
|
88
|
+
try {
|
|
89
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".monitor.json"));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function parseMonitorJson(filePath, dir) {
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
let spec;
|
|
104
|
+
try {
|
|
105
|
+
spec = JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
console.error(`[monitors] Failed to parse ${filePath}`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const name = spec.name;
|
|
112
|
+
if (!name)
|
|
113
|
+
return null;
|
|
114
|
+
const event = String(spec.event ?? "message_end");
|
|
115
|
+
if (!isValidEvent(event)) {
|
|
116
|
+
console.error(`[${name}] Invalid event: ${event}. Must be one of: ${[...VALID_EVENTS].join(", ")}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const classify = spec.classify;
|
|
120
|
+
if (!classify?.prompt && !classify?.promptTemplate) {
|
|
121
|
+
console.error(`[${name}] Missing classify.prompt or classify.promptTemplate`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const patternsSpec = spec.patterns;
|
|
125
|
+
if (!patternsSpec?.path) {
|
|
126
|
+
console.error(`[${name}] Missing patterns.path`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const scope = spec.scope;
|
|
130
|
+
const instructions = spec.instructions;
|
|
131
|
+
const actions = spec.actions;
|
|
132
|
+
return {
|
|
133
|
+
name,
|
|
134
|
+
description: String(spec.description ?? ""),
|
|
135
|
+
event: event,
|
|
136
|
+
when: String(spec.when ?? "always"),
|
|
137
|
+
scope: scope ?? { target: "main" },
|
|
138
|
+
classify: {
|
|
139
|
+
model: classify.model ?? "claude-sonnet-4-20250514",
|
|
140
|
+
context: Array.isArray(classify.context) ? classify.context : ["tool_results", "assistant_text"],
|
|
141
|
+
excludes: Array.isArray(classify.excludes) ? classify.excludes : [],
|
|
142
|
+
prompt: classify.prompt ?? "",
|
|
143
|
+
promptTemplate: typeof classify.promptTemplate === "string" ? classify.promptTemplate : undefined,
|
|
144
|
+
},
|
|
145
|
+
patterns: {
|
|
146
|
+
path: patternsSpec.path,
|
|
147
|
+
learn: patternsSpec.learn !== false,
|
|
148
|
+
},
|
|
149
|
+
instructions: {
|
|
150
|
+
path: instructions?.path ?? `${name}.instructions.json`,
|
|
151
|
+
},
|
|
152
|
+
actions: actions ?? {},
|
|
153
|
+
ceiling: Number(spec.ceiling) || 5,
|
|
154
|
+
escalate: spec.escalate === "dismiss" ? "dismiss" : "ask",
|
|
155
|
+
dir,
|
|
156
|
+
resolvedPatternsPath: path.resolve(dir, patternsSpec.path),
|
|
157
|
+
resolvedInstructionsPath: path.resolve(dir, instructions?.path ?? `${name}.instructions.json`),
|
|
158
|
+
// runtime state
|
|
159
|
+
activationCount: 0,
|
|
160
|
+
whileCount: 0,
|
|
161
|
+
lastUserText: "",
|
|
162
|
+
dismissed: false,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// Example seeding
|
|
167
|
+
// =============================================================================
|
|
168
|
+
function resolveProjectMonitorsDir() {
|
|
169
|
+
let cwd = process.cwd();
|
|
170
|
+
while (true) {
|
|
171
|
+
const piDir = path.join(cwd, ".pi");
|
|
172
|
+
if (isDir(piDir))
|
|
173
|
+
return path.join(piDir, "monitors");
|
|
174
|
+
const parent = path.dirname(cwd);
|
|
175
|
+
if (parent === cwd)
|
|
176
|
+
break;
|
|
177
|
+
cwd = parent;
|
|
178
|
+
}
|
|
179
|
+
return path.join(process.cwd(), ".pi", "monitors");
|
|
180
|
+
}
|
|
181
|
+
function seedExamples() {
|
|
182
|
+
if (discoverMonitors().length > 0)
|
|
183
|
+
return 0;
|
|
184
|
+
if (!isDir(EXAMPLES_DIR))
|
|
185
|
+
return 0;
|
|
186
|
+
const targetDir = resolveProjectMonitorsDir();
|
|
187
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
188
|
+
if (listMonitorFiles(targetDir).length > 0)
|
|
189
|
+
return 0;
|
|
190
|
+
const files = fs.readdirSync(EXAMPLES_DIR).filter((f) => f.endsWith(".json"));
|
|
191
|
+
let copied = 0;
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const dest = path.join(targetDir, file);
|
|
194
|
+
if (!fs.existsSync(dest)) {
|
|
195
|
+
fs.copyFileSync(path.join(EXAMPLES_DIR, file), dest);
|
|
196
|
+
copied++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return copied;
|
|
200
|
+
}
|
|
201
|
+
// =============================================================================
|
|
202
|
+
// Context collection
|
|
203
|
+
// =============================================================================
|
|
204
|
+
const TRUNCATE = 2000;
|
|
205
|
+
function extractText(parts) {
|
|
206
|
+
return parts
|
|
207
|
+
.filter((b) => b.type === "text")
|
|
208
|
+
.map((b) => b.text)
|
|
209
|
+
.join("");
|
|
210
|
+
}
|
|
211
|
+
function extractUserText(parts) {
|
|
212
|
+
if (typeof parts === "string")
|
|
213
|
+
return parts;
|
|
214
|
+
if (!Array.isArray(parts))
|
|
215
|
+
return "";
|
|
216
|
+
return parts
|
|
217
|
+
.filter((b) => b.type === "text")
|
|
218
|
+
.map((b) => b.text)
|
|
219
|
+
.join("");
|
|
220
|
+
}
|
|
221
|
+
function trunc(text) {
|
|
222
|
+
return text.length <= TRUNCATE ? text : `${text.slice(0, TRUNCATE)} [TRUNCATED]`;
|
|
223
|
+
}
|
|
224
|
+
function isMessageEntry(entry) {
|
|
225
|
+
return entry.type === "message";
|
|
226
|
+
}
|
|
227
|
+
function collectUserText(branch) {
|
|
228
|
+
let foundAssistant = false;
|
|
229
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
230
|
+
const entry = branch[i];
|
|
231
|
+
if (!isMessageEntry(entry))
|
|
232
|
+
continue;
|
|
233
|
+
if (!foundAssistant) {
|
|
234
|
+
if (entry.message.role === "assistant")
|
|
235
|
+
foundAssistant = true;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (entry.message.role === "user")
|
|
239
|
+
return extractUserText(entry.message.content);
|
|
240
|
+
}
|
|
241
|
+
return "";
|
|
242
|
+
}
|
|
243
|
+
function collectAssistantText(branch) {
|
|
244
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
245
|
+
const entry = branch[i];
|
|
246
|
+
if (isMessageEntry(entry) && entry.message.role === "assistant") {
|
|
247
|
+
return extractText(entry.message.content);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
function collectToolResults(branch, limit = 5) {
|
|
253
|
+
const results = [];
|
|
254
|
+
for (let i = branch.length - 1; i >= 0 && results.length < limit; i--) {
|
|
255
|
+
const entry = branch[i];
|
|
256
|
+
if (!isMessageEntry(entry) || entry.message.role !== "toolResult")
|
|
257
|
+
continue;
|
|
258
|
+
const text = extractUserText(entry.message.content);
|
|
259
|
+
if (text)
|
|
260
|
+
results.push(`---\n[${entry.message.toolName}${entry.message.isError ? " ERROR" : ""}] ${trunc(text)}\n---`);
|
|
261
|
+
}
|
|
262
|
+
return results.reverse().join("\n");
|
|
263
|
+
}
|
|
264
|
+
function collectToolCalls(branch, limit = 20) {
|
|
265
|
+
const calls = [];
|
|
266
|
+
for (let i = branch.length - 1; i >= 0 && calls.length < limit; i--) {
|
|
267
|
+
const entry = branch[i];
|
|
268
|
+
if (!isMessageEntry(entry))
|
|
269
|
+
continue;
|
|
270
|
+
const msg = entry.message;
|
|
271
|
+
if (msg.role === "assistant") {
|
|
272
|
+
for (const part of msg.content) {
|
|
273
|
+
if (part.type === "toolCall") {
|
|
274
|
+
calls.push(`[call ${part.name}] ${trunc(JSON.stringify(part.arguments ?? {}))}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (msg.role === "toolResult") {
|
|
279
|
+
calls.push(`[result ${msg.toolName}${msg.isError ? " ERROR" : ""}] ${trunc(extractUserText(msg.content))}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return calls.reverse().join("\n");
|
|
283
|
+
}
|
|
284
|
+
function collectCustomMessages(branch) {
|
|
285
|
+
const msgs = [];
|
|
286
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
287
|
+
const entry = branch[i];
|
|
288
|
+
if (!isMessageEntry(entry))
|
|
289
|
+
continue;
|
|
290
|
+
if (entry.message.role === "user")
|
|
291
|
+
break;
|
|
292
|
+
const msg = entry.message;
|
|
293
|
+
if (msg.customType) {
|
|
294
|
+
msgs.unshift(`[${msg.customType}] ${msg.content ?? ""}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return msgs.join("\n");
|
|
298
|
+
}
|
|
299
|
+
function collectProjectVision(_branch) {
|
|
300
|
+
try {
|
|
301
|
+
const projectPath = path.join(process.cwd(), ".project", "project.json");
|
|
302
|
+
const raw = JSON.parse(fs.readFileSync(projectPath, "utf-8"));
|
|
303
|
+
const parts = [];
|
|
304
|
+
if (raw.vision)
|
|
305
|
+
parts.push(`Vision: ${raw.vision}`);
|
|
306
|
+
if (raw.core_value)
|
|
307
|
+
parts.push(`Core value: ${raw.core_value}`);
|
|
308
|
+
if (raw.name)
|
|
309
|
+
parts.push(`Project: ${raw.name}`);
|
|
310
|
+
return parts.join("\n");
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function collectProjectConventions(_branch) {
|
|
317
|
+
try {
|
|
318
|
+
const confPath = path.join(process.cwd(), ".project", "conformance-reference.json");
|
|
319
|
+
const raw = JSON.parse(fs.readFileSync(confPath, "utf-8"));
|
|
320
|
+
if (Array.isArray(raw.items)) {
|
|
321
|
+
return raw.items.map((item) => `- ${item.name ?? item.id}`).join("\n");
|
|
322
|
+
}
|
|
323
|
+
return "";
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return "";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function collectGitStatus(_branch) {
|
|
330
|
+
try {
|
|
331
|
+
return execSync("git status --porcelain", { cwd: process.cwd(), encoding: "utf-8", timeout: 5000 }).trim();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return "";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const collectors = {
|
|
338
|
+
user_text: collectUserText,
|
|
339
|
+
assistant_text: collectAssistantText,
|
|
340
|
+
tool_results: collectToolResults,
|
|
341
|
+
tool_calls: collectToolCalls,
|
|
342
|
+
custom_messages: collectCustomMessages,
|
|
343
|
+
project_vision: collectProjectVision,
|
|
344
|
+
project_conventions: collectProjectConventions,
|
|
345
|
+
git_status: collectGitStatus,
|
|
346
|
+
};
|
|
347
|
+
/** Collector names derived from the runtime registry — used for consistency testing. */
|
|
348
|
+
export const COLLECTOR_NAMES = Object.keys(collectors);
|
|
349
|
+
function hasToolResults(branch) {
|
|
350
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
351
|
+
const entry = branch[i];
|
|
352
|
+
if (!isMessageEntry(entry))
|
|
353
|
+
continue;
|
|
354
|
+
if (entry.message.role === "user")
|
|
355
|
+
break;
|
|
356
|
+
if (entry.message.role === "toolResult")
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
function hasToolNamed(branch, name) {
|
|
362
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
363
|
+
const entry = branch[i];
|
|
364
|
+
if (!isMessageEntry(entry))
|
|
365
|
+
continue;
|
|
366
|
+
if (entry.message.role === "user")
|
|
367
|
+
break;
|
|
368
|
+
if (entry.message.role === "assistant") {
|
|
369
|
+
for (const part of entry.message.content) {
|
|
370
|
+
if (part.type === "toolCall" && part.name === name)
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
// =============================================================================
|
|
378
|
+
// When evaluation
|
|
379
|
+
// =============================================================================
|
|
380
|
+
function evaluateWhen(monitor, branch) {
|
|
381
|
+
const w = monitor.when;
|
|
382
|
+
if (w === "always")
|
|
383
|
+
return true;
|
|
384
|
+
if (w === "has_tool_results")
|
|
385
|
+
return hasToolResults(branch);
|
|
386
|
+
if (w === "has_file_writes")
|
|
387
|
+
return hasToolNamed(branch, "write") || hasToolNamed(branch, "edit");
|
|
388
|
+
if (w === "has_bash")
|
|
389
|
+
return hasToolNamed(branch, "bash");
|
|
390
|
+
const everyMatch = w.match(/^every\((\d+)\)$/);
|
|
391
|
+
if (everyMatch) {
|
|
392
|
+
const n = parseInt(everyMatch[1]);
|
|
393
|
+
const userText = collectUserText(branch);
|
|
394
|
+
if (userText !== monitor.lastUserText) {
|
|
395
|
+
monitor.activationCount = 0;
|
|
396
|
+
monitor.lastUserText = userText;
|
|
397
|
+
}
|
|
398
|
+
monitor.activationCount++;
|
|
399
|
+
if (monitor.activationCount >= n) {
|
|
400
|
+
monitor.activationCount = 0;
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
const toolMatch = w.match(/^tool\((\w+)\)$/);
|
|
406
|
+
if (toolMatch)
|
|
407
|
+
return hasToolNamed(branch, toolMatch[1]);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
// =============================================================================
|
|
411
|
+
// Template rendering (JSON patterns → text for LLM prompt)
|
|
412
|
+
// =============================================================================
|
|
413
|
+
function loadPatterns(monitor) {
|
|
414
|
+
try {
|
|
415
|
+
const raw = fs.readFileSync(monitor.resolvedPatternsPath, "utf-8");
|
|
416
|
+
return JSON.parse(raw);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function formatPatternsForPrompt(patterns) {
|
|
423
|
+
return patterns.map((p, i) => `${i + 1}. [${p.severity ?? "warning"}] ${p.description}`).join("\n");
|
|
424
|
+
}
|
|
425
|
+
function loadInstructions(monitor) {
|
|
426
|
+
try {
|
|
427
|
+
const raw = fs.readFileSync(monitor.resolvedInstructionsPath, "utf-8");
|
|
428
|
+
return JSON.parse(raw);
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return [];
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function saveInstructions(monitor, instructions) {
|
|
435
|
+
const tmpPath = `${monitor.resolvedInstructionsPath}.${process.pid}.tmp`;
|
|
436
|
+
try {
|
|
437
|
+
fs.writeFileSync(tmpPath, JSON.stringify(instructions, null, 2) + "\n");
|
|
438
|
+
fs.renameSync(tmpPath, monitor.resolvedInstructionsPath);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
try {
|
|
443
|
+
fs.unlinkSync(tmpPath);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
/* cleanup */
|
|
447
|
+
}
|
|
448
|
+
return err instanceof Error ? err.message : String(err);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
export function parseMonitorsArgs(args, knownNames) {
|
|
452
|
+
const trimmed = args.trim();
|
|
453
|
+
if (!trimmed)
|
|
454
|
+
return { type: "list" };
|
|
455
|
+
const tokens = trimmed.split(/\s+/);
|
|
456
|
+
const first = tokens[0];
|
|
457
|
+
// global commands (only if not a monitor name)
|
|
458
|
+
if (!knownNames.has(first)) {
|
|
459
|
+
if (first === "on")
|
|
460
|
+
return { type: "on" };
|
|
461
|
+
if (first === "off")
|
|
462
|
+
return { type: "off" };
|
|
463
|
+
if (first === "help")
|
|
464
|
+
return { type: "help" };
|
|
465
|
+
return { type: "error", message: `Unknown monitor: ${first}\nAvailable: ${[...knownNames].join(", ")}` };
|
|
466
|
+
}
|
|
467
|
+
const name = first;
|
|
468
|
+
if (tokens.length === 1)
|
|
469
|
+
return { type: "inspect", name };
|
|
470
|
+
const verb = tokens[1];
|
|
471
|
+
if (verb === "rules") {
|
|
472
|
+
if (tokens.length === 2)
|
|
473
|
+
return { type: "rules-list", name };
|
|
474
|
+
const action = tokens[2];
|
|
475
|
+
if (action === "add") {
|
|
476
|
+
const text = tokens.slice(3).join(" ");
|
|
477
|
+
if (!text)
|
|
478
|
+
return { type: "error", message: "Usage: /monitors <name> rules add <text>" };
|
|
479
|
+
return { type: "rules-add", name, text };
|
|
480
|
+
}
|
|
481
|
+
if (action === "remove") {
|
|
482
|
+
const n = parseInt(tokens[3]);
|
|
483
|
+
if (isNaN(n) || n < 1)
|
|
484
|
+
return { type: "error", message: "Usage: /monitors <name> rules remove <number>" };
|
|
485
|
+
return { type: "rules-remove", name, index: n };
|
|
486
|
+
}
|
|
487
|
+
if (action === "replace") {
|
|
488
|
+
const n = parseInt(tokens[3]);
|
|
489
|
+
const text = tokens.slice(4).join(" ");
|
|
490
|
+
if (isNaN(n) || n < 1 || !text)
|
|
491
|
+
return { type: "error", message: "Usage: /monitors <name> rules replace <number> <text>" };
|
|
492
|
+
return { type: "rules-replace", name, index: n, text };
|
|
493
|
+
}
|
|
494
|
+
return { type: "error", message: `Unknown rules action: ${action}\nAvailable: add, remove, replace` };
|
|
495
|
+
}
|
|
496
|
+
if (verb === "patterns")
|
|
497
|
+
return { type: "patterns-list", name };
|
|
498
|
+
if (verb === "dismiss")
|
|
499
|
+
return { type: "dismiss", name };
|
|
500
|
+
if (verb === "reset")
|
|
501
|
+
return { type: "reset", name };
|
|
502
|
+
return { type: "error", message: `Unknown subcommand: ${verb}\nAvailable: rules, patterns, dismiss, reset` };
|
|
503
|
+
}
|
|
504
|
+
function handleList(monitors, ctx, enabled) {
|
|
505
|
+
const header = enabled ? "monitors: ON" : "monitors: OFF (all monitoring paused)";
|
|
506
|
+
const lines = monitors.map((m) => {
|
|
507
|
+
const state = m.dismissed ? "dismissed" : m.whileCount > 0 ? `engaged (${m.whileCount}/${m.ceiling})` : "idle";
|
|
508
|
+
const scope = m.scope.target !== "main" ? ` [scope:${m.scope.target}]` : "";
|
|
509
|
+
return ` ${m.name} [${m.event}${m.when !== "always" ? `, when: ${m.when}` : ""}]${scope} — ${state}`;
|
|
510
|
+
});
|
|
511
|
+
ctx.ui.notify(`${header}\n${lines.join("\n")}`, "info");
|
|
512
|
+
}
|
|
513
|
+
function handleInspect(monitor, ctx) {
|
|
514
|
+
const rules = loadInstructions(monitor);
|
|
515
|
+
const patterns = loadPatterns(monitor);
|
|
516
|
+
const state = monitor.dismissed
|
|
517
|
+
? "dismissed"
|
|
518
|
+
: monitor.whileCount > 0
|
|
519
|
+
? `engaged (${monitor.whileCount}/${monitor.ceiling})`
|
|
520
|
+
: "idle";
|
|
521
|
+
const lines = [
|
|
522
|
+
`[${monitor.name}] ${monitor.description}`,
|
|
523
|
+
`event: ${monitor.event}, when: ${monitor.when}, scope: ${monitor.scope.target}`,
|
|
524
|
+
`state: ${state}, ceiling: ${monitor.ceiling}, escalate: ${monitor.escalate}`,
|
|
525
|
+
`rules: ${rules.length}, patterns: ${patterns.length}`,
|
|
526
|
+
];
|
|
527
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
528
|
+
}
|
|
529
|
+
function handleRulesList(monitor, ctx) {
|
|
530
|
+
const rules = loadInstructions(monitor);
|
|
531
|
+
if (rules.length === 0) {
|
|
532
|
+
ctx.ui.notify(`[${monitor.name}] (no rules)`, "info");
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const lines = rules.map((r, i) => `${i + 1}. ${r.text}`);
|
|
536
|
+
ctx.ui.notify(`[${monitor.name}] rules:\n${lines.join("\n")}`, "info");
|
|
537
|
+
}
|
|
538
|
+
function handleRulesAdd(monitor, ctx, text) {
|
|
539
|
+
const rules = loadInstructions(monitor);
|
|
540
|
+
rules.push({ text, added_at: new Date().toISOString() });
|
|
541
|
+
const err = saveInstructions(monitor, rules);
|
|
542
|
+
if (err) {
|
|
543
|
+
ctx.ui.notify(`[${monitor.name}] Failed to save: ${err}`, "error");
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
ctx.ui.notify(`[${monitor.name}] Rule added: ${text}`, "info");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function handleRulesRemove(monitor, ctx, index) {
|
|
550
|
+
const rules = loadInstructions(monitor);
|
|
551
|
+
if (index < 1 || index > rules.length) {
|
|
552
|
+
ctx.ui.notify(`[${monitor.name}] Invalid index ${index}. Have ${rules.length} rules.`, "error");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const removed = rules.splice(index - 1, 1)[0];
|
|
556
|
+
const err = saveInstructions(monitor, rules);
|
|
557
|
+
if (err) {
|
|
558
|
+
ctx.ui.notify(`[${monitor.name}] Failed to save: ${err}`, "error");
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
ctx.ui.notify(`[${monitor.name}] Removed rule ${index}: ${removed.text}`, "info");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function handleRulesReplace(monitor, ctx, index, text) {
|
|
565
|
+
const rules = loadInstructions(monitor);
|
|
566
|
+
if (index < 1 || index > rules.length) {
|
|
567
|
+
ctx.ui.notify(`[${monitor.name}] Invalid index ${index}. Have ${rules.length} rules.`, "error");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const old = rules[index - 1].text;
|
|
571
|
+
rules[index - 1] = { text, added_at: new Date().toISOString() };
|
|
572
|
+
const err = saveInstructions(monitor, rules);
|
|
573
|
+
if (err) {
|
|
574
|
+
ctx.ui.notify(`[${monitor.name}] Failed to save: ${err}`, "error");
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
ctx.ui.notify(`[${monitor.name}] Replaced rule ${index}:\n was: ${old}\n now: ${text}`, "info");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function handlePatternsList(monitor, ctx) {
|
|
581
|
+
const patterns = loadPatterns(monitor);
|
|
582
|
+
if (patterns.length === 0) {
|
|
583
|
+
ctx.ui.notify(`[${monitor.name}] (no patterns — monitor will not classify)`, "info");
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const lines = patterns.map((p, i) => {
|
|
587
|
+
const source = p.source ? ` (${p.source})` : "";
|
|
588
|
+
return `${i + 1}. [${p.severity ?? "warning"}] ${p.description}${source}`;
|
|
589
|
+
});
|
|
590
|
+
ctx.ui.notify(`[${monitor.name}] patterns:\n${lines.join("\n")}`, "info");
|
|
591
|
+
}
|
|
592
|
+
function formatInstructionsForPrompt(instructions) {
|
|
593
|
+
if (instructions.length === 0)
|
|
594
|
+
return "";
|
|
595
|
+
const lines = instructions.map((i) => `- ${i.text}`).join("\n");
|
|
596
|
+
return `\nOperating instructions from the user (follow these strictly):\n${lines}\n`;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Create a Nunjucks environment for monitor prompt templates.
|
|
600
|
+
* Three-tier search: project monitors dir > user monitors dir > package examples.
|
|
601
|
+
*/
|
|
602
|
+
function createMonitorTemplateEnv() {
|
|
603
|
+
const projectDir = resolveProjectMonitorsDir();
|
|
604
|
+
const userDir = path.join(os.homedir(), ".pi", "agent", "monitors");
|
|
605
|
+
const searchPaths = [];
|
|
606
|
+
if (isDir(projectDir))
|
|
607
|
+
searchPaths.push(projectDir);
|
|
608
|
+
if (isDir(userDir))
|
|
609
|
+
searchPaths.push(userDir);
|
|
610
|
+
if (isDir(EXAMPLES_DIR))
|
|
611
|
+
searchPaths.push(EXAMPLES_DIR);
|
|
612
|
+
const loader = searchPaths.length > 0 ? new nunjucks.FileSystemLoader(searchPaths) : undefined;
|
|
613
|
+
return new nunjucks.Environment(loader, {
|
|
614
|
+
autoescape: false,
|
|
615
|
+
throwOnUndefined: false,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
/** Module-level template environment, initialized in extension entry point. */
|
|
619
|
+
let monitorTemplateEnv;
|
|
620
|
+
function renderClassifyPrompt(monitor, branch) {
|
|
621
|
+
const patterns = loadPatterns(monitor);
|
|
622
|
+
if (patterns.length === 0)
|
|
623
|
+
return null;
|
|
624
|
+
const instructions = loadInstructions(monitor);
|
|
625
|
+
const collected = {};
|
|
626
|
+
for (const key of monitor.classify.context) {
|
|
627
|
+
const fn = collectors[key];
|
|
628
|
+
if (fn)
|
|
629
|
+
collected[key] = fn(branch);
|
|
630
|
+
else
|
|
631
|
+
collected[key] = ""; // unknown collectors produce empty string (graceful degradation)
|
|
632
|
+
}
|
|
633
|
+
const context = {
|
|
634
|
+
patterns: formatPatternsForPrompt(patterns),
|
|
635
|
+
instructions: formatInstructionsForPrompt(instructions),
|
|
636
|
+
iteration: monitor.whileCount,
|
|
637
|
+
...collected,
|
|
638
|
+
};
|
|
639
|
+
if (monitor.classify.promptTemplate && monitorTemplateEnv) {
|
|
640
|
+
// Nunjucks template file
|
|
641
|
+
try {
|
|
642
|
+
return monitorTemplateEnv.render(monitor.classify.promptTemplate, context);
|
|
643
|
+
}
|
|
644
|
+
catch (err) {
|
|
645
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
646
|
+
console.error(`[${monitor.name}] Template render failed (${monitor.classify.promptTemplate}): ${msg}`);
|
|
647
|
+
// Fall through to inline prompt if available
|
|
648
|
+
if (!monitor.classify.prompt)
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Fallback: inline string with {placeholder} replacement
|
|
653
|
+
if (!monitor.classify.prompt)
|
|
654
|
+
return null;
|
|
655
|
+
return monitor.classify.prompt.replace(/\{(\w+)\}/g, (match, key) => {
|
|
656
|
+
return String(context[key] ?? match);
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// =============================================================================
|
|
660
|
+
// Classification
|
|
661
|
+
// =============================================================================
|
|
662
|
+
export function parseVerdict(raw) {
|
|
663
|
+
const text = raw.trim();
|
|
664
|
+
if (text.startsWith("CLEAN"))
|
|
665
|
+
return { verdict: "clean" };
|
|
666
|
+
if (text.startsWith("NEW:")) {
|
|
667
|
+
const rest = text.slice(4);
|
|
668
|
+
const pipe = rest.indexOf("|");
|
|
669
|
+
if (pipe !== -1)
|
|
670
|
+
return { verdict: "new", newPattern: rest.slice(0, pipe).trim(), description: rest.slice(pipe + 1).trim() };
|
|
671
|
+
return { verdict: "new", newPattern: rest.trim(), description: rest.trim() };
|
|
672
|
+
}
|
|
673
|
+
if (text.startsWith("FLAG:"))
|
|
674
|
+
return { verdict: "flag", description: text.slice(5).trim() };
|
|
675
|
+
return { verdict: "clean" };
|
|
676
|
+
}
|
|
677
|
+
export function parseModelSpec(spec) {
|
|
678
|
+
const slashIndex = spec.indexOf("/");
|
|
679
|
+
if (slashIndex !== -1) {
|
|
680
|
+
return { provider: spec.slice(0, slashIndex), modelId: spec.slice(slashIndex + 1) };
|
|
681
|
+
}
|
|
682
|
+
return { provider: "anthropic", modelId: spec };
|
|
683
|
+
}
|
|
684
|
+
async function classifyPrompt(ctx, monitor, prompt, signal) {
|
|
685
|
+
const { provider, modelId } = parseModelSpec(monitor.classify.model);
|
|
686
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
687
|
+
if (!model)
|
|
688
|
+
throw new Error(`Model ${monitor.classify.model} not found`);
|
|
689
|
+
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
690
|
+
if (!apiKey)
|
|
691
|
+
throw new Error(`No API key for ${monitor.classify.model}`);
|
|
692
|
+
const response = await complete(model, { messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }] }, { apiKey, maxTokens: 150, signal });
|
|
693
|
+
return parseVerdict(extractText(response.content));
|
|
694
|
+
}
|
|
695
|
+
// =============================================================================
|
|
696
|
+
// Pattern learning (JSON)
|
|
697
|
+
// =============================================================================
|
|
698
|
+
function learnPattern(monitor, description) {
|
|
699
|
+
const patterns = loadPatterns(monitor);
|
|
700
|
+
const id = description
|
|
701
|
+
.toLowerCase()
|
|
702
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
703
|
+
.slice(0, 60);
|
|
704
|
+
// dedup by description
|
|
705
|
+
if (patterns.some((p) => p.description === description))
|
|
706
|
+
return;
|
|
707
|
+
patterns.push({
|
|
708
|
+
id,
|
|
709
|
+
description,
|
|
710
|
+
severity: "warning",
|
|
711
|
+
source: "learned",
|
|
712
|
+
learned_at: new Date().toISOString(),
|
|
713
|
+
});
|
|
714
|
+
const tmpPath = `${monitor.resolvedPatternsPath}.${process.pid}.tmp`;
|
|
715
|
+
try {
|
|
716
|
+
fs.writeFileSync(tmpPath, JSON.stringify(patterns, null, 2) + "\n");
|
|
717
|
+
fs.renameSync(tmpPath, monitor.resolvedPatternsPath);
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
try {
|
|
721
|
+
fs.unlinkSync(tmpPath);
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
/* cleanup */
|
|
725
|
+
}
|
|
726
|
+
console.error(`[${monitor.name}] Failed to write pattern: ${err instanceof Error ? err.message : err}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
// =============================================================================
|
|
730
|
+
// Action execution — write findings to JSON files
|
|
731
|
+
// =============================================================================
|
|
732
|
+
export function generateFindingId(monitorName, _description) {
|
|
733
|
+
return `${monitorName}-${Date.now().toString(36)}`;
|
|
734
|
+
}
|
|
735
|
+
function executeWriteAction(monitor, action, result) {
|
|
736
|
+
if (!action.write)
|
|
737
|
+
return;
|
|
738
|
+
const writeCfg = action.write;
|
|
739
|
+
const filePath = path.isAbsolute(writeCfg.path) ? writeCfg.path : path.resolve(process.cwd(), writeCfg.path);
|
|
740
|
+
// Build the entry from template, substituting placeholders
|
|
741
|
+
const findingId = generateFindingId(monitor.name, result.description ?? "unknown");
|
|
742
|
+
const entry = {};
|
|
743
|
+
for (const [key, tmpl] of Object.entries(writeCfg.template)) {
|
|
744
|
+
entry[key] = String(tmpl)
|
|
745
|
+
.replace(/\{finding_id\}/g, findingId)
|
|
746
|
+
.replace(/\{description\}/g, result.description ?? "Issue detected")
|
|
747
|
+
.replace(/\{severity\}/g, "warning")
|
|
748
|
+
.replace(/\{monitor_name\}/g, monitor.name)
|
|
749
|
+
.replace(/\{timestamp\}/g, new Date().toISOString());
|
|
750
|
+
}
|
|
751
|
+
// Read existing file or create structure
|
|
752
|
+
let data = {};
|
|
753
|
+
try {
|
|
754
|
+
data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
755
|
+
}
|
|
756
|
+
catch {
|
|
757
|
+
// file doesn't exist or is invalid — create fresh
|
|
758
|
+
}
|
|
759
|
+
const arrayField = writeCfg.array_field;
|
|
760
|
+
if (!Array.isArray(data[arrayField])) {
|
|
761
|
+
data[arrayField] = [];
|
|
762
|
+
}
|
|
763
|
+
const arr = data[arrayField];
|
|
764
|
+
if (writeCfg.merge === "upsert") {
|
|
765
|
+
const idx = arr.findIndex((item) => item.id === entry.id);
|
|
766
|
+
if (idx !== -1) {
|
|
767
|
+
arr[idx] = entry;
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
arr.push(entry);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
arr.push(entry);
|
|
775
|
+
}
|
|
776
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
777
|
+
try {
|
|
778
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
779
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
|
|
780
|
+
fs.renameSync(tmpPath, filePath);
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
try {
|
|
784
|
+
fs.unlinkSync(tmpPath);
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
/* cleanup */
|
|
788
|
+
}
|
|
789
|
+
console.error(`[${monitor.name}] Failed to write to ${filePath}: ${err instanceof Error ? err.message : err}`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// =============================================================================
|
|
793
|
+
// Activation
|
|
794
|
+
// =============================================================================
|
|
795
|
+
let monitorsEnabled = true;
|
|
796
|
+
let loadedMonitors = [];
|
|
797
|
+
let invokeCtx;
|
|
798
|
+
/**
|
|
799
|
+
* Programmatic monitor invocation — runs classification and write actions for
|
|
800
|
+
* a named monitor, returning the verdict. Unlike activate(), this skips dedup,
|
|
801
|
+
* ceiling, steering, and buffering. Designed for synchronous pre-dispatch
|
|
802
|
+
* gating where callers need the ClassifyResult before proceeding.
|
|
803
|
+
*
|
|
804
|
+
* The monitor must be loaded (discovered at extension init). If the monitor
|
|
805
|
+
* has no patterns, returns CLEAN (nothing to classify against).
|
|
806
|
+
*
|
|
807
|
+
* @param name - Monitor name (matches .monitor.json `name` field)
|
|
808
|
+
* @param context - Optional key-value pairs injected as additional template
|
|
809
|
+
* variables alongside the standard collectors. Keys that collide with
|
|
810
|
+
* collector names override the collector output for this invocation.
|
|
811
|
+
*/
|
|
812
|
+
export async function invokeMonitor(name, context) {
|
|
813
|
+
const monitor = loadedMonitors.find((m) => m.name === name);
|
|
814
|
+
if (!monitor)
|
|
815
|
+
throw new Error(`Monitor "${name}" not found — check .pi/monitors/ or bundled examples`);
|
|
816
|
+
if (!invokeCtx)
|
|
817
|
+
throw new Error("Monitor extension not initialized — invokeMonitor requires an active session");
|
|
818
|
+
if (!monitorsEnabled)
|
|
819
|
+
return { verdict: "clean" };
|
|
820
|
+
if (monitor.dismissed)
|
|
821
|
+
return { verdict: "clean" };
|
|
822
|
+
const patterns = loadPatterns(monitor);
|
|
823
|
+
if (patterns.length === 0)
|
|
824
|
+
return { verdict: "clean" };
|
|
825
|
+
const instructions = loadInstructions(monitor);
|
|
826
|
+
// Build context: collectors + caller-supplied overrides
|
|
827
|
+
const collected = {};
|
|
828
|
+
const branch = invokeCtx.sessionManager.getBranch();
|
|
829
|
+
for (const key of monitor.classify.context) {
|
|
830
|
+
const fn = collectors[key];
|
|
831
|
+
if (fn)
|
|
832
|
+
collected[key] = fn(branch);
|
|
833
|
+
else
|
|
834
|
+
collected[key] = "";
|
|
835
|
+
}
|
|
836
|
+
if (context) {
|
|
837
|
+
for (const [key, value] of Object.entries(context)) {
|
|
838
|
+
collected[key] = value;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const templateContext = {
|
|
842
|
+
patterns: formatPatternsForPrompt(patterns),
|
|
843
|
+
instructions: formatInstructionsForPrompt(instructions),
|
|
844
|
+
iteration: 0,
|
|
845
|
+
...collected,
|
|
846
|
+
};
|
|
847
|
+
// Render prompt (same logic as renderClassifyPrompt but with injected context)
|
|
848
|
+
let prompt = null;
|
|
849
|
+
if (monitor.classify.promptTemplate && monitorTemplateEnv) {
|
|
850
|
+
try {
|
|
851
|
+
prompt = monitorTemplateEnv.render(monitor.classify.promptTemplate, templateContext);
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
855
|
+
console.error(`[${monitor.name}] Template render failed (${monitor.classify.promptTemplate}): ${msg}`);
|
|
856
|
+
if (!monitor.classify.prompt)
|
|
857
|
+
throw new Error(`Template render failed and no inline prompt fallback: ${msg}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (!prompt && monitor.classify.prompt) {
|
|
861
|
+
prompt = monitor.classify.prompt.replace(/\{(\w+)\}/g, (match, key) => {
|
|
862
|
+
return String(templateContext[key] ?? match);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
if (!prompt)
|
|
866
|
+
return { verdict: "clean" };
|
|
867
|
+
const result = await classifyPrompt(invokeCtx, monitor, prompt);
|
|
868
|
+
// Execute write actions (findings files) based on verdict
|
|
869
|
+
if (result.verdict === "clean") {
|
|
870
|
+
const cleanAction = monitor.actions.on_clean;
|
|
871
|
+
if (cleanAction)
|
|
872
|
+
executeWriteAction(monitor, cleanAction, result);
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
const action = result.verdict === "new" ? monitor.actions.on_new : monitor.actions.on_flag;
|
|
876
|
+
if (action) {
|
|
877
|
+
if (result.verdict === "new" && result.newPattern && action.learn_pattern) {
|
|
878
|
+
learnPattern(monitor, result.newPattern);
|
|
879
|
+
}
|
|
880
|
+
executeWriteAction(monitor, action, result);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return result;
|
|
884
|
+
}
|
|
885
|
+
async function activate(monitor, pi, ctx, branch, steeredThisTurn, updateStatus, pendingAgentEndSteers) {
|
|
886
|
+
if (!monitorsEnabled)
|
|
887
|
+
return;
|
|
888
|
+
if (monitor.dismissed)
|
|
889
|
+
return;
|
|
890
|
+
// check excludes
|
|
891
|
+
for (const ex of monitor.classify.excludes) {
|
|
892
|
+
if (steeredThisTurn.has(ex))
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (!evaluateWhen(monitor, branch))
|
|
896
|
+
return;
|
|
897
|
+
// dedup: skip if user text unchanged since last classification
|
|
898
|
+
const currentUserText = collectUserText(branch);
|
|
899
|
+
if (currentUserText && currentUserText === monitor.lastUserText)
|
|
900
|
+
return;
|
|
901
|
+
// ceiling check
|
|
902
|
+
if (monitor.whileCount >= monitor.ceiling) {
|
|
903
|
+
await escalate(monitor, pi, ctx);
|
|
904
|
+
updateStatus();
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const prompt = renderClassifyPrompt(monitor, branch);
|
|
908
|
+
if (!prompt)
|
|
909
|
+
return;
|
|
910
|
+
// create an abort controller so classification can be cancelled if the user aborts
|
|
911
|
+
const abortController = new AbortController();
|
|
912
|
+
const onAbort = () => abortController.abort();
|
|
913
|
+
const unsubAbort = pi.events.on("monitors:abort", onAbort);
|
|
914
|
+
let result;
|
|
915
|
+
try {
|
|
916
|
+
result = await classifyPrompt(ctx, monitor, prompt, abortController.signal);
|
|
917
|
+
}
|
|
918
|
+
catch (e) {
|
|
919
|
+
if (abortController.signal.aborted)
|
|
920
|
+
return;
|
|
921
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
922
|
+
if (ctx.hasUI) {
|
|
923
|
+
ctx.ui.notify(`[${monitor.name}] Classification failed: ${message}`, "error");
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
console.error(`[${monitor.name}] Classification failed: ${message}`);
|
|
927
|
+
}
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
finally {
|
|
931
|
+
unsubAbort();
|
|
932
|
+
}
|
|
933
|
+
// mark this user text as classified
|
|
934
|
+
monitor.lastUserText = currentUserText;
|
|
935
|
+
if (result.verdict === "clean") {
|
|
936
|
+
const cleanAction = monitor.actions.on_clean;
|
|
937
|
+
if (cleanAction) {
|
|
938
|
+
executeWriteAction(monitor, cleanAction, result);
|
|
939
|
+
}
|
|
940
|
+
// Command-invoked monitors always report their verdict — the user explicitly asked
|
|
941
|
+
if (monitor.event === "command") {
|
|
942
|
+
pi.sendMessage({ customType: "monitor-result", content: `[${monitor.name}] CLEAN — no issues detected.`, display: true }, { triggerTurn: false });
|
|
943
|
+
}
|
|
944
|
+
monitor.whileCount = 0;
|
|
945
|
+
updateStatus();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
// Determine which action to execute
|
|
949
|
+
const action = result.verdict === "new" ? monitor.actions.on_new : monitor.actions.on_flag;
|
|
950
|
+
if (!action)
|
|
951
|
+
return;
|
|
952
|
+
// Learn new pattern
|
|
953
|
+
if (result.verdict === "new" && result.newPattern && action.learn_pattern) {
|
|
954
|
+
learnPattern(monitor, result.newPattern);
|
|
955
|
+
}
|
|
956
|
+
// Execute write action (findings to JSON file)
|
|
957
|
+
executeWriteAction(monitor, action, result);
|
|
958
|
+
// Steer (inject message into conversation) — only for main scope
|
|
959
|
+
if (action.steer && monitor.scope.target === "main") {
|
|
960
|
+
const description = result.description ?? "Issue detected";
|
|
961
|
+
const annotation = result.verdict === "new" ? " — new pattern learned" : "";
|
|
962
|
+
const details = {
|
|
963
|
+
monitorName: monitor.name,
|
|
964
|
+
verdict: result.verdict,
|
|
965
|
+
description,
|
|
966
|
+
steer: action.steer,
|
|
967
|
+
whileCount: monitor.whileCount + 1,
|
|
968
|
+
ceiling: monitor.ceiling,
|
|
969
|
+
};
|
|
970
|
+
const content = `[${monitor.name}] ${description}${annotation}. ${action.steer}`;
|
|
971
|
+
if (monitor.event === "agent_end" || monitor.event === "command") {
|
|
972
|
+
// Already post-loop or command context: deliver immediately
|
|
973
|
+
pi.sendMessage({ customType: "monitor-steer", content, display: true, details }, { deliverAs: "steer", triggerTurn: true });
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
// message_end / turn_end: buffer for drain at agent_end
|
|
977
|
+
// (pi's async event queue means these handlers run after the agent loop
|
|
978
|
+
// has already checked getSteeringMessages — direct sendMessage misses
|
|
979
|
+
// the window and the steer arrives one response late)
|
|
980
|
+
pendingAgentEndSteers.push({ monitor, details, content });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
monitor.whileCount++;
|
|
984
|
+
steeredThisTurn.add(monitor.name);
|
|
985
|
+
updateStatus();
|
|
986
|
+
}
|
|
987
|
+
async function escalate(monitor, pi, ctx) {
|
|
988
|
+
if (monitor.escalate === "dismiss") {
|
|
989
|
+
monitor.dismissed = true;
|
|
990
|
+
monitor.whileCount = 0;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// In headless mode there is no way to prompt the user, so auto-dismiss
|
|
994
|
+
// to avoid an infinite classify-reset cycle that can never be resolved.
|
|
995
|
+
if (!ctx.hasUI) {
|
|
996
|
+
monitor.dismissed = true;
|
|
997
|
+
monitor.whileCount = 0;
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (ctx.hasUI) {
|
|
1001
|
+
const choice = await ctx.ui.confirm(`[${monitor.name}] Steered ${monitor.ceiling} times`, "Continue steering, or dismiss this monitor for the session?");
|
|
1002
|
+
if (!choice) {
|
|
1003
|
+
monitor.dismissed = true;
|
|
1004
|
+
monitor.whileCount = 0;
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
monitor.whileCount = 0;
|
|
1009
|
+
}
|
|
1010
|
+
// =============================================================================
|
|
1011
|
+
// Extension entry point
|
|
1012
|
+
// =============================================================================
|
|
1013
|
+
export default function (pi) {
|
|
1014
|
+
const seeded = seedExamples();
|
|
1015
|
+
const monitors = discoverMonitors();
|
|
1016
|
+
loadedMonitors = monitors;
|
|
1017
|
+
if (monitors.length === 0)
|
|
1018
|
+
return;
|
|
1019
|
+
// Initialize Nunjucks template environment for monitor prompt templates
|
|
1020
|
+
monitorTemplateEnv = createMonitorTemplateEnv();
|
|
1021
|
+
let statusCtx;
|
|
1022
|
+
function updateStatus() {
|
|
1023
|
+
if (!statusCtx?.hasUI)
|
|
1024
|
+
return;
|
|
1025
|
+
const theme = statusCtx.ui.theme;
|
|
1026
|
+
if (!monitorsEnabled) {
|
|
1027
|
+
statusCtx.ui.setStatus("monitors", `${theme.fg("dim", "monitors:")}${theme.fg("warning", "OFF")}`);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const engaged = monitors.filter((m) => m.whileCount > 0 && !m.dismissed);
|
|
1031
|
+
const dismissed = monitors.filter((m) => m.dismissed);
|
|
1032
|
+
if (engaged.length === 0 && dismissed.length === 0) {
|
|
1033
|
+
const count = theme.fg("dim", `${monitors.length}`);
|
|
1034
|
+
statusCtx.ui.setStatus("monitors", `${theme.fg("dim", "monitors:")}${count}`);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const parts = [];
|
|
1038
|
+
for (const m of engaged) {
|
|
1039
|
+
parts.push(theme.fg("warning", `${m.name}(${m.whileCount}/${m.ceiling})`));
|
|
1040
|
+
}
|
|
1041
|
+
if (dismissed.length > 0) {
|
|
1042
|
+
parts.push(theme.fg("dim", `${dismissed.length} dismissed`));
|
|
1043
|
+
}
|
|
1044
|
+
statusCtx.ui.setStatus("monitors", `${theme.fg("dim", "monitors:")}${parts.join(" ")}`);
|
|
1045
|
+
}
|
|
1046
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1047
|
+
try {
|
|
1048
|
+
statusCtx = ctx;
|
|
1049
|
+
invokeCtx = ctx;
|
|
1050
|
+
if (seeded > 0 && ctx.hasUI) {
|
|
1051
|
+
const dir = resolveProjectMonitorsDir();
|
|
1052
|
+
ctx.ui.notify(`Seeded ${seeded} example monitor files into ${dir}\nEdit or delete them to customize.`, "info");
|
|
1053
|
+
}
|
|
1054
|
+
updateStatus();
|
|
1055
|
+
}
|
|
1056
|
+
catch {
|
|
1057
|
+
/* startup errors should not block session */
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
1061
|
+
statusCtx = ctx;
|
|
1062
|
+
invokeCtx = ctx;
|
|
1063
|
+
for (const m of monitors) {
|
|
1064
|
+
m.whileCount = 0;
|
|
1065
|
+
m.dismissed = false;
|
|
1066
|
+
m.lastUserText = "";
|
|
1067
|
+
m.activationCount = 0;
|
|
1068
|
+
}
|
|
1069
|
+
monitorsEnabled = true;
|
|
1070
|
+
pendingAgentEndSteers = [];
|
|
1071
|
+
updateStatus();
|
|
1072
|
+
});
|
|
1073
|
+
// ── Tool: monitors-status ──────────────────────────────────────────────
|
|
1074
|
+
pi.registerTool({
|
|
1075
|
+
name: "monitors-status",
|
|
1076
|
+
label: "Monitors Status",
|
|
1077
|
+
description: "List all behavior monitors with their current state.",
|
|
1078
|
+
promptSnippet: "List all behavior monitors with their current state",
|
|
1079
|
+
parameters: Type.Object({}),
|
|
1080
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
1081
|
+
const status = monitors.map((m) => ({
|
|
1082
|
+
name: m.name,
|
|
1083
|
+
description: m.description,
|
|
1084
|
+
event: m.event,
|
|
1085
|
+
when: m.when,
|
|
1086
|
+
enabled: monitorsEnabled,
|
|
1087
|
+
dismissed: m.dismissed,
|
|
1088
|
+
whileCount: m.whileCount,
|
|
1089
|
+
ceiling: m.ceiling,
|
|
1090
|
+
}));
|
|
1091
|
+
return {
|
|
1092
|
+
details: undefined,
|
|
1093
|
+
content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
|
|
1094
|
+
};
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
// ── Tool: monitors-inspect ─────────────────────────────────────────────
|
|
1098
|
+
pi.registerTool({
|
|
1099
|
+
name: "monitors-inspect",
|
|
1100
|
+
label: "Monitors Inspect",
|
|
1101
|
+
description: "Inspect a monitor — config, state, pattern count, rule count.",
|
|
1102
|
+
promptSnippet: "Inspect a monitor — config, state, pattern count, rule count",
|
|
1103
|
+
parameters: Type.Object({
|
|
1104
|
+
monitor: Type.String({ description: "Monitor name" }),
|
|
1105
|
+
}),
|
|
1106
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1107
|
+
const monitor = monitors.find((m) => m.name === params.monitor);
|
|
1108
|
+
if (!monitor)
|
|
1109
|
+
throw new Error(`Unknown monitor: ${params.monitor}`);
|
|
1110
|
+
const patterns = loadPatterns(monitor);
|
|
1111
|
+
const instructions = loadInstructions(monitor);
|
|
1112
|
+
const state = monitor.dismissed
|
|
1113
|
+
? "dismissed"
|
|
1114
|
+
: monitor.whileCount > 0
|
|
1115
|
+
? `engaged (${monitor.whileCount}/${monitor.ceiling})`
|
|
1116
|
+
: "idle";
|
|
1117
|
+
const info = {
|
|
1118
|
+
name: monitor.name,
|
|
1119
|
+
description: monitor.description,
|
|
1120
|
+
event: monitor.event,
|
|
1121
|
+
when: monitor.when,
|
|
1122
|
+
scope: monitor.scope,
|
|
1123
|
+
classify: {
|
|
1124
|
+
model: monitor.classify.model,
|
|
1125
|
+
context: monitor.classify.context,
|
|
1126
|
+
excludes: monitor.classify.excludes,
|
|
1127
|
+
},
|
|
1128
|
+
patterns: { path: monitor.patterns.path, learn: monitor.patterns.learn, count: patterns.length },
|
|
1129
|
+
instructions: { path: monitor.instructions.path, count: instructions.length },
|
|
1130
|
+
actions: monitor.actions,
|
|
1131
|
+
ceiling: monitor.ceiling,
|
|
1132
|
+
escalate: monitor.escalate,
|
|
1133
|
+
state,
|
|
1134
|
+
enabled: monitorsEnabled,
|
|
1135
|
+
dismissed: monitor.dismissed,
|
|
1136
|
+
whileCount: monitor.whileCount,
|
|
1137
|
+
};
|
|
1138
|
+
return {
|
|
1139
|
+
details: undefined,
|
|
1140
|
+
content: [{ type: "text", text: JSON.stringify(info, null, 2) }],
|
|
1141
|
+
};
|
|
1142
|
+
},
|
|
1143
|
+
});
|
|
1144
|
+
// ── Tool: monitors-control ─────────────────────────────────────────────
|
|
1145
|
+
pi.registerTool({
|
|
1146
|
+
name: "monitors-control",
|
|
1147
|
+
label: "Monitors Control",
|
|
1148
|
+
description: "Control monitors — enable, disable, dismiss, or reset.",
|
|
1149
|
+
promptSnippet: "Control monitors — enable, disable, dismiss, or reset",
|
|
1150
|
+
parameters: Type.Object({
|
|
1151
|
+
action: Type.Union([Type.Literal("on"), Type.Literal("off"), Type.Literal("dismiss"), Type.Literal("reset")]),
|
|
1152
|
+
monitor: Type.Optional(Type.String({ description: "Monitor name (required for dismiss/reset)" })),
|
|
1153
|
+
}),
|
|
1154
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1155
|
+
if (params.action === "on") {
|
|
1156
|
+
monitorsEnabled = true;
|
|
1157
|
+
updateStatus();
|
|
1158
|
+
return {
|
|
1159
|
+
details: undefined,
|
|
1160
|
+
content: [{ type: "text", text: "Monitors enabled" }],
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
if (params.action === "off") {
|
|
1164
|
+
monitorsEnabled = false;
|
|
1165
|
+
updateStatus();
|
|
1166
|
+
return {
|
|
1167
|
+
details: undefined,
|
|
1168
|
+
content: [{ type: "text", text: "All monitors paused for this session" }],
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
if (params.action === "dismiss") {
|
|
1172
|
+
if (!params.monitor)
|
|
1173
|
+
throw new Error("Monitor name required for dismiss");
|
|
1174
|
+
const monitor = monitors.find((m) => m.name === params.monitor);
|
|
1175
|
+
if (!monitor)
|
|
1176
|
+
throw new Error(`Unknown monitor: ${params.monitor}`);
|
|
1177
|
+
monitor.dismissed = true;
|
|
1178
|
+
updateStatus();
|
|
1179
|
+
return {
|
|
1180
|
+
details: undefined,
|
|
1181
|
+
content: [{ type: "text", text: `[${monitor.name}] Dismissed for this session` }],
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
// reset
|
|
1185
|
+
if (!params.monitor)
|
|
1186
|
+
throw new Error("Monitor name required for reset");
|
|
1187
|
+
const monitor = monitors.find((m) => m.name === params.monitor);
|
|
1188
|
+
if (!monitor)
|
|
1189
|
+
throw new Error(`Unknown monitor: ${params.monitor}`);
|
|
1190
|
+
monitor.dismissed = false;
|
|
1191
|
+
monitor.whileCount = 0;
|
|
1192
|
+
updateStatus();
|
|
1193
|
+
return {
|
|
1194
|
+
details: undefined,
|
|
1195
|
+
content: [{ type: "text", text: `[${monitor.name}] Reset — dismissed=false, whileCount=0` }],
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
});
|
|
1199
|
+
// ── Tool: monitors-rules ───────────────────────────────────────────────
|
|
1200
|
+
pi.registerTool({
|
|
1201
|
+
name: "monitors-rules",
|
|
1202
|
+
label: "Monitors Rules",
|
|
1203
|
+
description: "Manage monitor rules — list, add, remove, or replace calibration rules.",
|
|
1204
|
+
promptSnippet: "Manage monitor rules — list, add, remove, or replace calibration rules",
|
|
1205
|
+
parameters: Type.Object({
|
|
1206
|
+
monitor: Type.String({ description: "Monitor name" }),
|
|
1207
|
+
action: Type.Union([Type.Literal("list"), Type.Literal("add"), Type.Literal("remove"), Type.Literal("replace")]),
|
|
1208
|
+
text: Type.Optional(Type.String({ description: "Rule text (for add/replace)" })),
|
|
1209
|
+
index: Type.Optional(Type.Number({ description: "Rule index, 1-based (for remove/replace)" })),
|
|
1210
|
+
}),
|
|
1211
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1212
|
+
const monitor = monitors.find((m) => m.name === params.monitor);
|
|
1213
|
+
if (!monitor)
|
|
1214
|
+
throw new Error(`Unknown monitor: ${params.monitor}`);
|
|
1215
|
+
if (params.action === "list") {
|
|
1216
|
+
const rules = loadInstructions(monitor);
|
|
1217
|
+
return {
|
|
1218
|
+
details: undefined,
|
|
1219
|
+
content: [{ type: "text", text: JSON.stringify(rules, null, 2) }],
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
if (params.action === "add") {
|
|
1223
|
+
if (!params.text)
|
|
1224
|
+
throw new Error("text parameter required for add");
|
|
1225
|
+
const rules = loadInstructions(monitor);
|
|
1226
|
+
rules.push({ text: params.text, added_at: new Date().toISOString() });
|
|
1227
|
+
const err = saveInstructions(monitor, rules);
|
|
1228
|
+
if (err)
|
|
1229
|
+
throw new Error(`Failed to save rules: ${err}`);
|
|
1230
|
+
return {
|
|
1231
|
+
details: undefined,
|
|
1232
|
+
content: [{ type: "text", text: `Rule added to [${monitor.name}]: ${params.text}` }],
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
if (params.action === "remove") {
|
|
1236
|
+
if (params.index === undefined)
|
|
1237
|
+
throw new Error("index parameter required for remove");
|
|
1238
|
+
const rules = loadInstructions(monitor);
|
|
1239
|
+
if (params.index < 1 || params.index > rules.length) {
|
|
1240
|
+
throw new Error(`Invalid index ${params.index}. Have ${rules.length} rules.`);
|
|
1241
|
+
}
|
|
1242
|
+
const removed = rules.splice(params.index - 1, 1)[0];
|
|
1243
|
+
const err = saveInstructions(monitor, rules);
|
|
1244
|
+
if (err)
|
|
1245
|
+
throw new Error(`Failed to save rules: ${err}`);
|
|
1246
|
+
return {
|
|
1247
|
+
details: undefined,
|
|
1248
|
+
content: [{ type: "text", text: `Removed rule ${params.index} from [${monitor.name}]: ${removed.text}` }],
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
// replace
|
|
1252
|
+
if (params.index === undefined)
|
|
1253
|
+
throw new Error("index parameter required for replace");
|
|
1254
|
+
if (!params.text)
|
|
1255
|
+
throw new Error("text parameter required for replace");
|
|
1256
|
+
const rules = loadInstructions(monitor);
|
|
1257
|
+
if (params.index < 1 || params.index > rules.length) {
|
|
1258
|
+
throw new Error(`Invalid index ${params.index}. Have ${rules.length} rules.`);
|
|
1259
|
+
}
|
|
1260
|
+
const old = rules[params.index - 1].text;
|
|
1261
|
+
rules[params.index - 1] = { text: params.text, added_at: new Date().toISOString() };
|
|
1262
|
+
const err = saveInstructions(monitor, rules);
|
|
1263
|
+
if (err)
|
|
1264
|
+
throw new Error(`Failed to save rules: ${err}`);
|
|
1265
|
+
return {
|
|
1266
|
+
details: undefined,
|
|
1267
|
+
content: [
|
|
1268
|
+
{
|
|
1269
|
+
type: "text",
|
|
1270
|
+
text: `Replaced rule ${params.index} in [${monitor.name}]:\n was: ${old}\n now: ${params.text}`,
|
|
1271
|
+
},
|
|
1272
|
+
],
|
|
1273
|
+
};
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
// ── Tool: monitors-patterns ────────────────────────────────────────────
|
|
1277
|
+
pi.registerTool({
|
|
1278
|
+
name: "monitors-patterns",
|
|
1279
|
+
label: "Monitors Patterns",
|
|
1280
|
+
description: "List patterns for a behavior monitor.",
|
|
1281
|
+
promptSnippet: "List patterns for a behavior monitor",
|
|
1282
|
+
parameters: Type.Object({
|
|
1283
|
+
monitor: Type.String({ description: "Monitor name" }),
|
|
1284
|
+
}),
|
|
1285
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1286
|
+
const monitor = monitors.find((m) => m.name === params.monitor);
|
|
1287
|
+
if (!monitor)
|
|
1288
|
+
throw new Error(`Unknown monitor: ${params.monitor}`);
|
|
1289
|
+
const patterns = loadPatterns(monitor);
|
|
1290
|
+
return {
|
|
1291
|
+
details: undefined,
|
|
1292
|
+
content: [{ type: "text", text: JSON.stringify(patterns, null, 2) }],
|
|
1293
|
+
};
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
// --- message renderer ---
|
|
1297
|
+
pi.registerMessageRenderer("monitor-steer", (message, { expanded }, theme) => {
|
|
1298
|
+
const details = message.details;
|
|
1299
|
+
if (!details) {
|
|
1300
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
1301
|
+
box.addChild(new Text(String(message.content), 0, 0));
|
|
1302
|
+
return box;
|
|
1303
|
+
}
|
|
1304
|
+
const verdictColor = details.verdict === "new" ? "warning" : "error";
|
|
1305
|
+
const prefix = theme.fg(verdictColor, `[${details.monitorName}]`);
|
|
1306
|
+
const desc = ` ${details.description}`;
|
|
1307
|
+
const counter = theme.fg("dim", ` (${details.whileCount}/${details.ceiling})`);
|
|
1308
|
+
let text = `${prefix}${desc}${counter}`;
|
|
1309
|
+
if (details.verdict === "new") {
|
|
1310
|
+
text += theme.fg("dim", " — new pattern learned");
|
|
1311
|
+
}
|
|
1312
|
+
text += `\n${theme.fg("muted", details.steer)}`;
|
|
1313
|
+
if (expanded) {
|
|
1314
|
+
text += `\n${theme.fg("dim", `verdict: ${details.verdict}`)}`;
|
|
1315
|
+
}
|
|
1316
|
+
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
1317
|
+
box.addChild(new Text(text, 0, 0));
|
|
1318
|
+
return box;
|
|
1319
|
+
});
|
|
1320
|
+
// --- abort support + buffered steer drain ---
|
|
1321
|
+
pi.on("agent_end", async () => {
|
|
1322
|
+
// NOTE: do NOT emit monitors:abort here. The abort signal is for user-initiated
|
|
1323
|
+
// cancellation only. Emitting it on agent_end kills agent_end monitor classifications
|
|
1324
|
+
// (commit-hygiene, etc.) because this handler runs before the per-monitor agent_end
|
|
1325
|
+
// handlers in the sequential event queue, and the abort signal is already set when
|
|
1326
|
+
// those monitors try to classify.
|
|
1327
|
+
// Drain buffered steers from message_end/turn_end monitors.
|
|
1328
|
+
// The _agentEventQueue guarantees this runs AFTER all turn_end/message_end
|
|
1329
|
+
// handlers complete (sequential promise chain), so the buffer is populated.
|
|
1330
|
+
// Deliver only the first — the corrected response will re-trigger monitors
|
|
1331
|
+
// if additional issues remain.
|
|
1332
|
+
if (pendingAgentEndSteers.length > 0) {
|
|
1333
|
+
const first = pendingAgentEndSteers[0];
|
|
1334
|
+
pendingAgentEndSteers = [];
|
|
1335
|
+
pi.sendMessage({ customType: "monitor-steer", content: first.content, display: true, details: first.details }, { deliverAs: "steer", triggerTurn: true });
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
// --- buffered steers for message_end/turn_end monitors ---
|
|
1339
|
+
// These monitors classify during the agent loop but can't inject steers in time
|
|
1340
|
+
// (pi's async event queue means extension handlers run after the agent loop checks
|
|
1341
|
+
// getSteeringMessages). Buffer steers here, drain at agent_end.
|
|
1342
|
+
let pendingAgentEndSteers = [];
|
|
1343
|
+
// --- per-turn exclusion tracking ---
|
|
1344
|
+
let steeredThisTurn = new Set();
|
|
1345
|
+
pi.on("turn_start", () => {
|
|
1346
|
+
steeredThisTurn = new Set();
|
|
1347
|
+
});
|
|
1348
|
+
// group monitors by validated event
|
|
1349
|
+
const byEvent = new Map();
|
|
1350
|
+
for (const m of monitors) {
|
|
1351
|
+
const list = byEvent.get(m.event) ?? [];
|
|
1352
|
+
list.push(m);
|
|
1353
|
+
byEvent.set(m.event, list);
|
|
1354
|
+
}
|
|
1355
|
+
// wire event handlers
|
|
1356
|
+
for (const [event, group] of byEvent) {
|
|
1357
|
+
if (event === "command") {
|
|
1358
|
+
for (const m of group) {
|
|
1359
|
+
pi.registerCommand(m.name, {
|
|
1360
|
+
description: m.description || `Run ${m.name} monitor`,
|
|
1361
|
+
handler: async (_args, ctx) => {
|
|
1362
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1363
|
+
await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus, pendingAgentEndSteers);
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
else if (event === "message_end") {
|
|
1369
|
+
pi.on("message_end", async (ev, ctx) => {
|
|
1370
|
+
if (ev.message.role !== "assistant")
|
|
1371
|
+
return;
|
|
1372
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1373
|
+
for (const m of group) {
|
|
1374
|
+
await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus, pendingAgentEndSteers);
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
else if (event === "turn_end") {
|
|
1379
|
+
pi.on("turn_end", async (_ev, ctx) => {
|
|
1380
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1381
|
+
for (const m of group) {
|
|
1382
|
+
await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus, pendingAgentEndSteers);
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
else if (event === "agent_end") {
|
|
1387
|
+
pi.on("agent_end", async (_ev, ctx) => {
|
|
1388
|
+
const branch = ctx.sessionManager.getBranch();
|
|
1389
|
+
for (const m of group) {
|
|
1390
|
+
await activate(m, pi, ctx, branch, steeredThisTurn, updateStatus, pendingAgentEndSteers);
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
// /monitors command — unified management interface
|
|
1396
|
+
const monitorNames = new Set(monitors.map((m) => m.name));
|
|
1397
|
+
const monitorsByName = new Map(monitors.map((m) => [m.name, m]));
|
|
1398
|
+
const monitorVerbs = ["rules", "patterns", "dismiss", "reset"];
|
|
1399
|
+
const rulesActions = ["add", "remove", "replace"];
|
|
1400
|
+
pi.registerCommand("monitors", {
|
|
1401
|
+
description: "Manage behavior monitors",
|
|
1402
|
+
getArgumentCompletions(argumentPrefix) {
|
|
1403
|
+
const tokens = argumentPrefix.split(/\s+/);
|
|
1404
|
+
const last = tokens[tokens.length - 1];
|
|
1405
|
+
// Level 0: no complete token yet — show global commands + monitor names
|
|
1406
|
+
if (tokens.length <= 1) {
|
|
1407
|
+
const items = [
|
|
1408
|
+
{ value: "on", label: "on", description: "Enable all monitoring" },
|
|
1409
|
+
{ value: "off", label: "off", description: "Pause all monitoring" },
|
|
1410
|
+
{ value: "help", label: "help", description: "Show available commands" },
|
|
1411
|
+
...Array.from(monitorNames).map((n) => ({
|
|
1412
|
+
value: n,
|
|
1413
|
+
label: n,
|
|
1414
|
+
description: `${monitorsByName.get(n)?.description ?? ""} → rules|patterns|dismiss|reset`,
|
|
1415
|
+
})),
|
|
1416
|
+
];
|
|
1417
|
+
return items.filter((i) => i.value.startsWith(last));
|
|
1418
|
+
}
|
|
1419
|
+
const name = tokens[0];
|
|
1420
|
+
// Level 1: monitor name entered — show verbs
|
|
1421
|
+
if (monitorNames.has(name) && tokens.length === 2) {
|
|
1422
|
+
return monitorVerbs
|
|
1423
|
+
.map((v) => ({ value: `${name} ${v}`, label: v, description: "" }))
|
|
1424
|
+
.filter((i) => i.label.startsWith(last));
|
|
1425
|
+
}
|
|
1426
|
+
// Level 2: monitor name + "rules" — show actions
|
|
1427
|
+
if (monitorNames.has(name) && tokens[1] === "rules" && tokens.length === 3) {
|
|
1428
|
+
return rulesActions
|
|
1429
|
+
.map((a) => ({ value: `${name} rules ${a}`, label: a, description: "" }))
|
|
1430
|
+
.filter((i) => i.label.startsWith(last));
|
|
1431
|
+
}
|
|
1432
|
+
return null;
|
|
1433
|
+
},
|
|
1434
|
+
handler: async (args, ctx) => {
|
|
1435
|
+
const cmd = parseMonitorsArgs(args, monitorNames);
|
|
1436
|
+
if (cmd.type === "error") {
|
|
1437
|
+
ctx.ui.notify(cmd.message, "warning");
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
if (cmd.type === "help") {
|
|
1441
|
+
const lines = [
|
|
1442
|
+
"Usage: /monitors <command>",
|
|
1443
|
+
"",
|
|
1444
|
+
" on Enable all monitoring",
|
|
1445
|
+
" off Pause all monitoring",
|
|
1446
|
+
" <name> Inspect a monitor",
|
|
1447
|
+
" <name> rules Manage rules (add, remove, replace)",
|
|
1448
|
+
" <name> patterns List known patterns",
|
|
1449
|
+
" <name> dismiss Silence for this session",
|
|
1450
|
+
" <name> reset Reset state and un-dismiss",
|
|
1451
|
+
"",
|
|
1452
|
+
`Active monitors: ${monitors.map((m) => m.name).join(", ") || "(none)"}`,
|
|
1453
|
+
];
|
|
1454
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (cmd.type === "list") {
|
|
1458
|
+
if (!ctx.hasUI) {
|
|
1459
|
+
handleList(monitors, ctx, monitorsEnabled);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
const options = [
|
|
1463
|
+
`on — Enable all monitoring`,
|
|
1464
|
+
`off — Pause all monitoring`,
|
|
1465
|
+
...monitors.map((m) => {
|
|
1466
|
+
const state = m.dismissed
|
|
1467
|
+
? "dismissed"
|
|
1468
|
+
: m.whileCount > 0
|
|
1469
|
+
? `engaged (${m.whileCount}/${m.ceiling})`
|
|
1470
|
+
: "idle";
|
|
1471
|
+
return `${m.name} — ${m.description} [${state}]`;
|
|
1472
|
+
}),
|
|
1473
|
+
];
|
|
1474
|
+
const selected = await ctx.ui.select("Monitors", options);
|
|
1475
|
+
if (!selected)
|
|
1476
|
+
return;
|
|
1477
|
+
const selectedName = selected.split(" ")[0];
|
|
1478
|
+
if (selectedName === "on") {
|
|
1479
|
+
monitorsEnabled = true;
|
|
1480
|
+
updateStatus();
|
|
1481
|
+
ctx.ui.notify("Monitors enabled", "info");
|
|
1482
|
+
}
|
|
1483
|
+
else if (selectedName === "off") {
|
|
1484
|
+
monitorsEnabled = false;
|
|
1485
|
+
updateStatus();
|
|
1486
|
+
ctx.ui.notify("All monitors paused for this session", "info");
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
const monitor = monitorsByName.get(selectedName);
|
|
1490
|
+
if (!monitor)
|
|
1491
|
+
return;
|
|
1492
|
+
const verbOptions = [
|
|
1493
|
+
`inspect — Show monitor state and config`,
|
|
1494
|
+
`rules — List and manage rules`,
|
|
1495
|
+
`patterns — List known patterns`,
|
|
1496
|
+
`dismiss — Silence for this session`,
|
|
1497
|
+
`reset — Reset state and un-dismiss`,
|
|
1498
|
+
];
|
|
1499
|
+
const verb = await ctx.ui.select(`[${monitor.name}]`, verbOptions);
|
|
1500
|
+
if (!verb)
|
|
1501
|
+
return;
|
|
1502
|
+
const verbName = verb.split(" ")[0];
|
|
1503
|
+
if (verbName === "inspect")
|
|
1504
|
+
handleInspect(monitor, ctx);
|
|
1505
|
+
else if (verbName === "rules")
|
|
1506
|
+
handleRulesList(monitor, ctx);
|
|
1507
|
+
else if (verbName === "patterns")
|
|
1508
|
+
handlePatternsList(monitor, ctx);
|
|
1509
|
+
else if (verbName === "dismiss") {
|
|
1510
|
+
monitor.dismissed = true;
|
|
1511
|
+
monitor.whileCount = 0;
|
|
1512
|
+
updateStatus();
|
|
1513
|
+
ctx.ui.notify(`[${monitor.name}] Dismissed for this session`, "info");
|
|
1514
|
+
}
|
|
1515
|
+
else if (verbName === "reset") {
|
|
1516
|
+
monitor.dismissed = false;
|
|
1517
|
+
monitor.whileCount = 0;
|
|
1518
|
+
updateStatus();
|
|
1519
|
+
ctx.ui.notify(`[${monitor.name}] Reset`, "info");
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
if (cmd.type === "on") {
|
|
1525
|
+
monitorsEnabled = true;
|
|
1526
|
+
updateStatus();
|
|
1527
|
+
ctx.ui.notify("Monitors enabled", "info");
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (cmd.type === "off") {
|
|
1531
|
+
monitorsEnabled = false;
|
|
1532
|
+
updateStatus();
|
|
1533
|
+
ctx.ui.notify("All monitors paused for this session", "info");
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
const monitor = monitorsByName.get(cmd.name);
|
|
1537
|
+
if (!monitor) {
|
|
1538
|
+
ctx.ui.notify(`Unknown monitor: ${cmd.name}`, "warning");
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
switch (cmd.type) {
|
|
1542
|
+
case "inspect":
|
|
1543
|
+
handleInspect(monitor, ctx);
|
|
1544
|
+
break;
|
|
1545
|
+
case "rules-list":
|
|
1546
|
+
handleRulesList(monitor, ctx);
|
|
1547
|
+
break;
|
|
1548
|
+
case "rules-add":
|
|
1549
|
+
handleRulesAdd(monitor, ctx, cmd.text);
|
|
1550
|
+
break;
|
|
1551
|
+
case "rules-remove":
|
|
1552
|
+
handleRulesRemove(monitor, ctx, cmd.index);
|
|
1553
|
+
break;
|
|
1554
|
+
case "rules-replace":
|
|
1555
|
+
handleRulesReplace(monitor, ctx, cmd.index, cmd.text);
|
|
1556
|
+
break;
|
|
1557
|
+
case "patterns-list":
|
|
1558
|
+
handlePatternsList(monitor, ctx);
|
|
1559
|
+
break;
|
|
1560
|
+
case "dismiss":
|
|
1561
|
+
monitor.dismissed = true;
|
|
1562
|
+
monitor.whileCount = 0;
|
|
1563
|
+
updateStatus();
|
|
1564
|
+
ctx.ui.notify(`[${monitor.name}] Dismissed for this session`, "info");
|
|
1565
|
+
break;
|
|
1566
|
+
case "reset":
|
|
1567
|
+
monitor.dismissed = false;
|
|
1568
|
+
monitor.whileCount = 0;
|
|
1569
|
+
updateStatus();
|
|
1570
|
+
ctx.ui.notify(`[${monitor.name}] Reset`, "info");
|
|
1571
|
+
break;
|
|
1572
|
+
}
|
|
1573
|
+
},
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
//# sourceMappingURL=index.js.map
|