pi-lens 2.0.43 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -0
- package/clients/agent-behavior-client.js +110 -0
- package/clients/agent-behavior-client.test.js +94 -0
- package/clients/agent-behavior-client.test.ts +116 -0
- package/clients/agent-behavior-client.ts +152 -0
- package/clients/architect-client.js +1 -0
- package/clients/architect-client.ts +1 -0
- package/clients/ast-grep-client.js +3 -2
- package/clients/ast-grep-client.ts +9 -4
- package/clients/auto-loop.js +5 -1
- package/clients/auto-loop.ts +23 -7
- package/clients/biome-client.js +3 -12
- package/clients/biome-client.test.js +3 -1
- package/clients/biome-client.test.ts +3 -1
- package/clients/biome-client.ts +3 -12
- package/clients/cache-manager.js +245 -0
- package/clients/cache-manager.test.js +197 -0
- package/clients/cache-manager.test.ts +299 -0
- package/clients/cache-manager.ts +331 -0
- package/clients/complexity-client.js +3 -7
- package/clients/complexity-client.ts +12 -13
- package/clients/dependency-checker.js +26 -5
- package/clients/dependency-checker.ts +32 -6
- package/clients/dispatch/dispatcher.js +242 -0
- package/clients/dispatch/dispatcher.test.js +115 -0
- package/clients/dispatch/dispatcher.test.ts +148 -0
- package/clients/dispatch/dispatcher.ts +333 -0
- package/clients/dispatch/integration.js +58 -0
- package/clients/dispatch/integration.ts +71 -0
- package/clients/dispatch/plan.js +131 -0
- package/clients/dispatch/plan.ts +145 -0
- package/clients/dispatch/runners/architect.js +69 -0
- package/clients/dispatch/runners/architect.ts +87 -0
- package/clients/dispatch/runners/ast-grep.js +115 -0
- package/clients/dispatch/runners/ast-grep.ts +139 -0
- package/clients/dispatch/runners/biome.js +69 -0
- package/clients/dispatch/runners/biome.ts +93 -0
- package/clients/dispatch/runners/go-vet.js +72 -0
- package/clients/dispatch/runners/go-vet.ts +92 -0
- package/clients/dispatch/runners/index.js +22 -0
- package/clients/dispatch/runners/index.ts +24 -0
- package/clients/dispatch/runners/ruff.js +68 -0
- package/clients/dispatch/runners/ruff.ts +88 -0
- package/clients/dispatch/runners/rust-clippy.js +106 -0
- package/clients/dispatch/runners/rust-clippy.ts +131 -0
- package/clients/dispatch/runners/ts-lsp.js +53 -0
- package/clients/dispatch/runners/ts-lsp.ts +71 -0
- package/clients/dispatch/runners/type-safety.js +142 -0
- package/clients/dispatch/runners/type-safety.ts +183 -0
- package/clients/dispatch/runners/utils.js +53 -0
- package/clients/dispatch/runners/utils.ts +60 -0
- package/clients/dispatch/types.js +13 -0
- package/clients/dispatch/types.ts +152 -0
- package/clients/file-kinds.js +177 -0
- package/clients/file-kinds.test.js +169 -0
- package/clients/file-kinds.test.ts +210 -0
- package/clients/file-kinds.ts +216 -0
- package/clients/fix-scanners.js +23 -4
- package/clients/fix-scanners.ts +25 -7
- package/clients/jscpd-client.js +22 -1
- package/clients/jscpd-client.test.js +1 -1
- package/clients/jscpd-client.test.ts +1 -1
- package/clients/jscpd-client.ts +25 -1
- package/clients/metrics-history.js +3 -2
- package/clients/metrics-history.ts +3 -2
- package/clients/ruff-client.js +3 -2
- package/clients/ruff-client.ts +3 -2
- package/clients/rules-scanner.js +97 -0
- package/clients/rules-scanner.ts +120 -0
- package/clients/sanitize.js +291 -0
- package/clients/sanitize.test.js +177 -0
- package/clients/sanitize.test.ts +223 -0
- package/clients/sanitize.ts +356 -0
- package/clients/sg-runner.js +10 -3
- package/clients/sg-runner.ts +19 -5
- package/clients/todo-scanner.js +1 -1
- package/clients/todo-scanner.ts +1 -1
- package/clients/tool-availability.js +213 -0
- package/clients/tool-availability.ts +256 -0
- package/commands/booboo.js +114 -48
- package/commands/booboo.ts +119 -53
- package/commands/fix.js +13 -5
- package/commands/fix.ts +70 -29
- package/commands/refactor.js +3 -5
- package/commands/refactor.ts +5 -6
- package/default-architect.yaml +8 -5
- package/index.ts +365 -414
- package/package.json +14 -1
- package/rules/ast-grep-rules/rules/large-class.yml +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,76 @@ pi install git:github.com/apmantza/pi-lens
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## What's New (v2.0)
|
|
20
|
+
|
|
21
|
+
### Declarative Dispatch System
|
|
22
|
+
|
|
23
|
+
The core linting engine has been redesigned from ~400 lines of nested `if/else` blocks into a clean, extensible dispatch architecture:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
file → detectFileKind() → getRunnersForKind() → run all runners → aggregate output
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Key improvements:**
|
|
30
|
+
- **Extensible**: Add new linters by dropping a runner file in `clients/dispatch/runners/` — no need to touch the core
|
|
31
|
+
- **Unified output**: All tools report through the same format (🔴 blocking, 🟡 warning, ✅ fixed)
|
|
32
|
+
- **Delta mode built-in**: Each runner supports baseline tracking to show only *new* violations
|
|
33
|
+
- **Conditional execution**: Runners can have `when` conditions (e.g., only run when `--autofix` is enabled)
|
|
34
|
+
|
|
35
|
+
**Runners:** `ts-lsp`, `biome`, `ruff`, `ast-grep`, `type-safety`, `architect`, `go-vet`, `rust-clippy`
|
|
36
|
+
|
|
37
|
+
### Asynchronous Session Start
|
|
38
|
+
|
|
39
|
+
Session initialization now runs all scans concurrently with caching:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
session_start
|
|
43
|
+
├─ TODO/FIXME scan (fast, uncached)
|
|
44
|
+
├─ Knip dead code (cached 30 min)
|
|
45
|
+
├─ jscpd duplicates (cached 30 min)
|
|
46
|
+
├─ Type coverage (cached 30 min)
|
|
47
|
+
└─ Export scanning (async, for duplicate export detection)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Each scan runs independently — expensive scans (jscpd, knip) are cached in `.pi-lens/cache/` with 30-minute TTL. The agent sees results immediately without waiting for slow tools.
|
|
51
|
+
|
|
52
|
+
### Inline Messaging
|
|
53
|
+
|
|
54
|
+
Every `write` or `edit` operation returns structured feedback directly in the tool result:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// tool_result handler runs dispatchLint() → formats output → appends to result
|
|
58
|
+
return {
|
|
59
|
+
content: [...event.content, { type: "text", text: lspOutput }],
|
|
60
|
+
};
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Message types:**
|
|
64
|
+
| Prefix | Meaning |
|
|
65
|
+
|--------|---------|
|
|
66
|
+
| 🔴 | Blocking error — must fix before continuing |
|
|
67
|
+
| 🟡 | Warning — should fix, but not blocking |
|
|
68
|
+
| ✅ | Auto-fixed issue — no action needed |
|
|
69
|
+
| 📊 | Silent metric — tracked but not shown |
|
|
70
|
+
| 📐 | Architectural rule — reference only |
|
|
71
|
+
|
|
72
|
+
### File Type Detection
|
|
73
|
+
|
|
74
|
+
Centralized file-kind detection (`clients/file-kinds.ts`) replaces scattered regex checks:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
const kind = detectFileKind(filePath); // "jsts" | "python" | "go" | "rust" | ...
|
|
78
|
+
const runners = getRunnersForKind(kind); // All applicable runners
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Project Rules Integration
|
|
82
|
+
|
|
83
|
+
pi-lens now scans for project-specific rule files (`.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md`) at session start. These are surfaced in the system prompt so the agent knows to read them when relevant.
|
|
84
|
+
|
|
85
|
+
This works **alongside** pi-lens architect rules — your project's markdown rules provide general guidance, while pi-lens handles automated regex-based checks on every write.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
19
89
|
## Features
|
|
20
90
|
|
|
21
91
|
### On every write / edit
|
|
@@ -80,6 +150,25 @@ On every new session, scans run silently in the background. Data is cached for r
|
|
|
80
150
|
| **jscpd** | Duplicate detection on write; `/lens-booboo` reports |
|
|
81
151
|
| **type-coverage** | `/lens-booboo` reports |
|
|
82
152
|
| **Complexity baselines** | Regressed/improved delta tracking via `/lens-metrics` |
|
|
153
|
+
| **Project rules** | Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` |
|
|
154
|
+
|
|
155
|
+
### Project Rules Integration
|
|
156
|
+
|
|
157
|
+
pi-lens scans for project-specific rule files at session start. If found, they're listed in the system prompt so the agent knows to read them when relevant:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
📋 Project rules found: 2 file(s) in .claude/rules, root. These apply alongside pi-lens defaults.
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Scanned locations:**
|
|
164
|
+
| Location | Description |
|
|
165
|
+
|----------|-------------|
|
|
166
|
+
| `.claude/rules/` | Claude Code rule files (recursive) |
|
|
167
|
+
| `.agents/rules/` | Generic agent rule files (recursive) |
|
|
168
|
+
| `CLAUDE.md` | Claude Code project context |
|
|
169
|
+
| `AGENTS.md` | Generic agent context |
|
|
170
|
+
|
|
171
|
+
These files provide **general project guidance** (coding conventions, workflow rules, architecture notes). They coexist with pi-lens architect rules — your rules inform the agent's behavior, while pi-lens provides automated regex-based checks on every write.
|
|
83
172
|
|
|
84
173
|
### On-demand commands
|
|
85
174
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentBehaviorClient for pi-lens
|
|
3
|
+
*
|
|
4
|
+
* Tracks tool call sequences and flags anti-patterns in real-time:
|
|
5
|
+
* - Blind writes: editing or writing without reading first
|
|
6
|
+
* - Thrashing: repeated identical tool calls with no progress
|
|
7
|
+
*
|
|
8
|
+
* No external dependencies — purely tracks tool call history.
|
|
9
|
+
*/
|
|
10
|
+
// --- Constants ---
|
|
11
|
+
const WRITE_OPS = new Set(["edit", "write", "multiedit"]);
|
|
12
|
+
const READ_OPS = new Set(["read", "bash", "grep", "glob", "find", "rg"]);
|
|
13
|
+
const BLIND_WRITE_WINDOW = 5; // Check last N tool calls for a read
|
|
14
|
+
const THRASH_THRESHOLD = 3; // Flag after N consecutive identical tools
|
|
15
|
+
const THRASH_TIMEOUT_MS = 30000; // Reset thrash counter if gap > 30s
|
|
16
|
+
// --- Client ---
|
|
17
|
+
export class AgentBehaviorClient {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.toolHistory = [];
|
|
20
|
+
this.consecutiveCount = 0;
|
|
21
|
+
this.lastToolName = null;
|
|
22
|
+
this.lastToolTimestamp = 0;
|
|
23
|
+
// Per-file tracking
|
|
24
|
+
this.fileEditCount = new Map();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Record a tool call and return any warnings triggered.
|
|
28
|
+
* Called from tool_result handler.
|
|
29
|
+
*/
|
|
30
|
+
recordToolCall(toolName, filePath) {
|
|
31
|
+
const warnings = [];
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
// Track consecutive identical tools (thrashing)
|
|
34
|
+
if (toolName === this.lastToolName &&
|
|
35
|
+
now - this.lastToolTimestamp < THRASH_TIMEOUT_MS) {
|
|
36
|
+
this.consecutiveCount++;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.consecutiveCount = 1;
|
|
40
|
+
}
|
|
41
|
+
this.lastToolName = toolName;
|
|
42
|
+
this.lastToolTimestamp = now;
|
|
43
|
+
// Check for thrashing
|
|
44
|
+
if (this.consecutiveCount === THRASH_THRESHOLD) {
|
|
45
|
+
warnings.push({
|
|
46
|
+
type: "thrashing",
|
|
47
|
+
message: `🔴 THRASHING — ${THRASH_THRESHOLD} consecutive \`${toolName}\` calls with no other action. Consider fixing the root cause instead of re-running.`,
|
|
48
|
+
severity: "error",
|
|
49
|
+
details: {
|
|
50
|
+
toolName,
|
|
51
|
+
callCount: this.consecutiveCount,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Check for blind writes
|
|
56
|
+
if (WRITE_OPS.has(toolName)) {
|
|
57
|
+
const recentWindow = this.toolHistory.slice(-BLIND_WRITE_WINDOW);
|
|
58
|
+
const hasRecentRead = recentWindow.some((r) => READ_OPS.has(r.toolName));
|
|
59
|
+
if (!hasRecentRead && recentWindow.length > 0) {
|
|
60
|
+
// Count how many writes in the window without reads
|
|
61
|
+
const writesWithoutRead = recentWindow.filter((r) => WRITE_OPS.has(r.toolName)).length;
|
|
62
|
+
if (writesWithoutRead >= 2) {
|
|
63
|
+
warnings.push({
|
|
64
|
+
type: "blind-write",
|
|
65
|
+
message: `⚠ BLIND WRITE — editing \`${filePath ?? "file"}\` without reading in the last ${BLIND_WRITE_WINDOW} tool calls. Read the file first to avoid assumptions.`,
|
|
66
|
+
severity: "warning",
|
|
67
|
+
details: {
|
|
68
|
+
filePath,
|
|
69
|
+
windowSize: recentWindow.length,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Track edits per file
|
|
75
|
+
if (filePath) {
|
|
76
|
+
this.fileEditCount.set(filePath, (this.fileEditCount.get(filePath) ?? 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Add to history (keep last 50 entries)
|
|
80
|
+
this.toolHistory.push({ toolName, filePath, timestamp: now });
|
|
81
|
+
if (this.toolHistory.length > 50) {
|
|
82
|
+
this.toolHistory = this.toolHistory.slice(-50);
|
|
83
|
+
}
|
|
84
|
+
return warnings;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Format warnings for LLM consumption.
|
|
88
|
+
*/
|
|
89
|
+
formatWarnings(warnings) {
|
|
90
|
+
if (warnings.length === 0)
|
|
91
|
+
return "";
|
|
92
|
+
return warnings.map((w) => w.message).join("\n");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Get edit count for a file in this session.
|
|
96
|
+
*/
|
|
97
|
+
getEditCount(filePath) {
|
|
98
|
+
return this.fileEditCount.get(filePath) ?? 0;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Reset state (e.g., on session start).
|
|
102
|
+
*/
|
|
103
|
+
reset() {
|
|
104
|
+
this.toolHistory = [];
|
|
105
|
+
this.consecutiveCount = 0;
|
|
106
|
+
this.lastToolName = null;
|
|
107
|
+
this.lastToolTimestamp = 0;
|
|
108
|
+
this.fileEditCount.clear();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { AgentBehaviorClient } from "./agent-behavior-client.js";
|
|
3
|
+
describe("AgentBehaviorClient", () => {
|
|
4
|
+
let client;
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
client = new AgentBehaviorClient();
|
|
7
|
+
client.reset();
|
|
8
|
+
});
|
|
9
|
+
describe("blind write detection", () => {
|
|
10
|
+
it("should NOT warn when read precedes write", () => {
|
|
11
|
+
client.recordToolCall("read", "src/file.ts");
|
|
12
|
+
client.recordToolCall("edit", "src/file.ts");
|
|
13
|
+
const warnings = client.recordToolCall("write", "src/file.ts");
|
|
14
|
+
expect(warnings).toHaveLength(0);
|
|
15
|
+
});
|
|
16
|
+
it("should warn when multiple writes happen without reads", () => {
|
|
17
|
+
// First write is OK (no history)
|
|
18
|
+
client.recordToolCall("write", "src/file1.ts");
|
|
19
|
+
// Second write - still in window, accumulates
|
|
20
|
+
client.recordToolCall("edit", "src/file2.ts");
|
|
21
|
+
// Third write without any read - now we have a pattern
|
|
22
|
+
const warnings = client.recordToolCall("edit", "src/file3.ts");
|
|
23
|
+
expect(warnings).toHaveLength(1);
|
|
24
|
+
expect(warnings[0].type).toBe("blind-write");
|
|
25
|
+
});
|
|
26
|
+
it("should not warn for single write with no history", () => {
|
|
27
|
+
const warnings = client.recordToolCall("write", "src/file.ts");
|
|
28
|
+
expect(warnings).toHaveLength(0);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("thrashing detection", () => {
|
|
32
|
+
it("should warn after 3 consecutive identical tool calls", () => {
|
|
33
|
+
client.recordToolCall("bash", undefined);
|
|
34
|
+
// Second call - no warning yet
|
|
35
|
+
let warnings = client.recordToolCall("bash", undefined);
|
|
36
|
+
expect(warnings).toHaveLength(0);
|
|
37
|
+
// Third consecutive - should warn
|
|
38
|
+
warnings = client.recordToolCall("bash", undefined);
|
|
39
|
+
expect(warnings).toHaveLength(1);
|
|
40
|
+
expect(warnings[0].type).toBe("thrashing");
|
|
41
|
+
expect(warnings[0].details.callCount).toBe(3);
|
|
42
|
+
});
|
|
43
|
+
it("should NOT warn for different tool calls", () => {
|
|
44
|
+
client.recordToolCall("read", "src/file.ts");
|
|
45
|
+
client.recordToolCall("bash", "npm test");
|
|
46
|
+
const warnings = client.recordToolCall("edit", "src/file.ts");
|
|
47
|
+
expect(warnings).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
it("should reset count when tool changes", () => {
|
|
50
|
+
client.recordToolCall("bash", undefined);
|
|
51
|
+
client.recordToolCall("bash", undefined);
|
|
52
|
+
// Different tool resets the count
|
|
53
|
+
client.recordToolCall("read", "src/file.ts");
|
|
54
|
+
// Now start new consecutive sequence
|
|
55
|
+
client.recordToolCall("bash", undefined);
|
|
56
|
+
const warnings = client.recordToolCall("bash", undefined);
|
|
57
|
+
expect(warnings).toHaveLength(0); // Only 2 consecutive, not 3
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe("edit counting", () => {
|
|
61
|
+
it("should track edit count per file", () => {
|
|
62
|
+
client.recordToolCall("edit", "src/a.ts");
|
|
63
|
+
client.recordToolCall("edit", "src/a.ts");
|
|
64
|
+
client.recordToolCall("edit", "src/b.ts");
|
|
65
|
+
expect(client.getEditCount("src/a.ts")).toBe(2);
|
|
66
|
+
expect(client.getEditCount("src/b.ts")).toBe(1);
|
|
67
|
+
expect(client.getEditCount("src/c.ts")).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe("formatWarnings", () => {
|
|
71
|
+
it("should format multiple warnings", () => {
|
|
72
|
+
const warnings = [
|
|
73
|
+
{
|
|
74
|
+
type: "blind-write",
|
|
75
|
+
message: "⚠ BLIND WRITE — editing file",
|
|
76
|
+
severity: "warning",
|
|
77
|
+
details: {},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: "thrashing",
|
|
81
|
+
message: "🔴 THRASHING — 3 consecutive calls",
|
|
82
|
+
severity: "error",
|
|
83
|
+
details: {},
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
const formatted = client.formatWarnings(warnings);
|
|
87
|
+
expect(formatted).toContain("BLIND WRITE");
|
|
88
|
+
expect(formatted).toContain("THRASHING");
|
|
89
|
+
});
|
|
90
|
+
it("should return empty string for no warnings", () => {
|
|
91
|
+
expect(client.formatWarnings([])).toBe("");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { AgentBehaviorClient } from "./agent-behavior-client.js";
|
|
3
|
+
|
|
4
|
+
describe("AgentBehaviorClient", () => {
|
|
5
|
+
let client: AgentBehaviorClient;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
client = new AgentBehaviorClient();
|
|
9
|
+
client.reset();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("blind write detection", () => {
|
|
13
|
+
it("should NOT warn when read precedes write", () => {
|
|
14
|
+
client.recordToolCall("read", "src/file.ts");
|
|
15
|
+
client.recordToolCall("edit", "src/file.ts");
|
|
16
|
+
|
|
17
|
+
const warnings = client.recordToolCall("write", "src/file.ts");
|
|
18
|
+
expect(warnings).toHaveLength(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should warn when multiple writes happen without reads", () => {
|
|
22
|
+
// First write is OK (no history)
|
|
23
|
+
client.recordToolCall("write", "src/file1.ts");
|
|
24
|
+
|
|
25
|
+
// Second write - still in window, accumulates
|
|
26
|
+
client.recordToolCall("edit", "src/file2.ts");
|
|
27
|
+
|
|
28
|
+
// Third write without any read - now we have a pattern
|
|
29
|
+
const warnings = client.recordToolCall("edit", "src/file3.ts");
|
|
30
|
+
expect(warnings).toHaveLength(1);
|
|
31
|
+
expect(warnings[0].type).toBe("blind-write");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should not warn for single write with no history", () => {
|
|
35
|
+
const warnings = client.recordToolCall("write", "src/file.ts");
|
|
36
|
+
expect(warnings).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("thrashing detection", () => {
|
|
41
|
+
it("should warn after 3 consecutive identical tool calls", () => {
|
|
42
|
+
client.recordToolCall("bash", undefined);
|
|
43
|
+
|
|
44
|
+
// Second call - no warning yet
|
|
45
|
+
let warnings = client.recordToolCall("bash", undefined);
|
|
46
|
+
expect(warnings).toHaveLength(0);
|
|
47
|
+
|
|
48
|
+
// Third consecutive - should warn
|
|
49
|
+
warnings = client.recordToolCall("bash", undefined);
|
|
50
|
+
expect(warnings).toHaveLength(1);
|
|
51
|
+
expect(warnings[0].type).toBe("thrashing");
|
|
52
|
+
expect(warnings[0].details.callCount).toBe(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should NOT warn for different tool calls", () => {
|
|
56
|
+
client.recordToolCall("read", "src/file.ts");
|
|
57
|
+
client.recordToolCall("bash", "npm test");
|
|
58
|
+
|
|
59
|
+
const warnings = client.recordToolCall("edit", "src/file.ts");
|
|
60
|
+
expect(warnings).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should reset count when tool changes", () => {
|
|
64
|
+
client.recordToolCall("bash", undefined);
|
|
65
|
+
client.recordToolCall("bash", undefined);
|
|
66
|
+
|
|
67
|
+
// Different tool resets the count
|
|
68
|
+
client.recordToolCall("read", "src/file.ts");
|
|
69
|
+
|
|
70
|
+
// Now start new consecutive sequence
|
|
71
|
+
client.recordToolCall("bash", undefined);
|
|
72
|
+
|
|
73
|
+
const warnings = client.recordToolCall("bash", undefined);
|
|
74
|
+
expect(warnings).toHaveLength(0); // Only 2 consecutive, not 3
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("edit counting", () => {
|
|
79
|
+
it("should track edit count per file", () => {
|
|
80
|
+
client.recordToolCall("edit", "src/a.ts");
|
|
81
|
+
client.recordToolCall("edit", "src/a.ts");
|
|
82
|
+
client.recordToolCall("edit", "src/b.ts");
|
|
83
|
+
|
|
84
|
+
expect(client.getEditCount("src/a.ts")).toBe(2);
|
|
85
|
+
expect(client.getEditCount("src/b.ts")).toBe(1);
|
|
86
|
+
expect(client.getEditCount("src/c.ts")).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("formatWarnings", () => {
|
|
91
|
+
it("should format multiple warnings", () => {
|
|
92
|
+
const warnings = [
|
|
93
|
+
{
|
|
94
|
+
type: "blind-write" as const,
|
|
95
|
+
message: "⚠ BLIND WRITE — editing file",
|
|
96
|
+
severity: "warning" as const,
|
|
97
|
+
details: {},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: "thrashing" as const,
|
|
101
|
+
message: "🔴 THRASHING — 3 consecutive calls",
|
|
102
|
+
severity: "error" as const,
|
|
103
|
+
details: {},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const formatted = client.formatWarnings(warnings);
|
|
108
|
+
expect(formatted).toContain("BLIND WRITE");
|
|
109
|
+
expect(formatted).toContain("THRASHING");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should return empty string for no warnings", () => {
|
|
113
|
+
expect(client.formatWarnings([])).toBe("");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentBehaviorClient for pi-lens
|
|
3
|
+
*
|
|
4
|
+
* Tracks tool call sequences and flags anti-patterns in real-time:
|
|
5
|
+
* - Blind writes: editing or writing without reading first
|
|
6
|
+
* - Thrashing: repeated identical tool calls with no progress
|
|
7
|
+
*
|
|
8
|
+
* No external dependencies — purely tracks tool call history.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// --- Types ---
|
|
12
|
+
|
|
13
|
+
export type BehaviorWarning = {
|
|
14
|
+
type: "blind-write" | "thrashing";
|
|
15
|
+
message: string;
|
|
16
|
+
severity: "warning" | "error";
|
|
17
|
+
details: {
|
|
18
|
+
filePath?: string;
|
|
19
|
+
callCount?: number;
|
|
20
|
+
toolName?: string;
|
|
21
|
+
windowSize?: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface ToolCallRecord {
|
|
26
|
+
toolName: string;
|
|
27
|
+
filePath?: string;
|
|
28
|
+
timestamp: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Constants ---
|
|
32
|
+
|
|
33
|
+
const WRITE_OPS = new Set(["edit", "write", "multiedit"]);
|
|
34
|
+
const READ_OPS = new Set(["read", "bash", "grep", "glob", "find", "rg"]);
|
|
35
|
+
|
|
36
|
+
const BLIND_WRITE_WINDOW = 5; // Check last N tool calls for a read
|
|
37
|
+
const THRASH_THRESHOLD = 3; // Flag after N consecutive identical tools
|
|
38
|
+
const THRASH_TIMEOUT_MS = 30_000; // Reset thrash counter if gap > 30s
|
|
39
|
+
|
|
40
|
+
// --- Client ---
|
|
41
|
+
|
|
42
|
+
export class AgentBehaviorClient {
|
|
43
|
+
private toolHistory: ToolCallRecord[] = [];
|
|
44
|
+
private consecutiveCount = 0;
|
|
45
|
+
private lastToolName: string | null = null;
|
|
46
|
+
private lastToolTimestamp = 0;
|
|
47
|
+
|
|
48
|
+
// Per-file tracking
|
|
49
|
+
private fileEditCount = new Map<string, number>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Record a tool call and return any warnings triggered.
|
|
53
|
+
* Called from tool_result handler.
|
|
54
|
+
*/
|
|
55
|
+
recordToolCall(toolName: string, filePath?: string): BehaviorWarning[] {
|
|
56
|
+
const warnings: BehaviorWarning[] = [];
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
|
|
59
|
+
// Track consecutive identical tools (thrashing)
|
|
60
|
+
if (
|
|
61
|
+
toolName === this.lastToolName &&
|
|
62
|
+
now - this.lastToolTimestamp < THRASH_TIMEOUT_MS
|
|
63
|
+
) {
|
|
64
|
+
this.consecutiveCount++;
|
|
65
|
+
} else {
|
|
66
|
+
this.consecutiveCount = 1;
|
|
67
|
+
}
|
|
68
|
+
this.lastToolName = toolName;
|
|
69
|
+
this.lastToolTimestamp = now;
|
|
70
|
+
|
|
71
|
+
// Check for thrashing
|
|
72
|
+
if (this.consecutiveCount === THRASH_THRESHOLD) {
|
|
73
|
+
warnings.push({
|
|
74
|
+
type: "thrashing",
|
|
75
|
+
message: `🔴 THRASHING — ${THRASH_THRESHOLD} consecutive \`${toolName}\` calls with no other action. Consider fixing the root cause instead of re-running.`,
|
|
76
|
+
severity: "error",
|
|
77
|
+
details: {
|
|
78
|
+
toolName,
|
|
79
|
+
callCount: this.consecutiveCount,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check for blind writes
|
|
85
|
+
if (WRITE_OPS.has(toolName)) {
|
|
86
|
+
const recentWindow = this.toolHistory.slice(-BLIND_WRITE_WINDOW);
|
|
87
|
+
const hasRecentRead = recentWindow.some((r) => READ_OPS.has(r.toolName));
|
|
88
|
+
|
|
89
|
+
if (!hasRecentRead && recentWindow.length > 0) {
|
|
90
|
+
// Count how many writes in the window without reads
|
|
91
|
+
const writesWithoutRead = recentWindow.filter((r) =>
|
|
92
|
+
WRITE_OPS.has(r.toolName),
|
|
93
|
+
).length;
|
|
94
|
+
|
|
95
|
+
if (writesWithoutRead >= 2) {
|
|
96
|
+
warnings.push({
|
|
97
|
+
type: "blind-write",
|
|
98
|
+
message: `⚠ BLIND WRITE — editing \`${filePath ?? "file"}\` without reading in the last ${BLIND_WRITE_WINDOW} tool calls. Read the file first to avoid assumptions.`,
|
|
99
|
+
severity: "warning",
|
|
100
|
+
details: {
|
|
101
|
+
filePath,
|
|
102
|
+
windowSize: recentWindow.length,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Track edits per file
|
|
109
|
+
if (filePath) {
|
|
110
|
+
this.fileEditCount.set(
|
|
111
|
+
filePath,
|
|
112
|
+
(this.fileEditCount.get(filePath) ?? 0) + 1,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Add to history (keep last 50 entries)
|
|
118
|
+
this.toolHistory.push({ toolName, filePath, timestamp: now });
|
|
119
|
+
if (this.toolHistory.length > 50) {
|
|
120
|
+
this.toolHistory = this.toolHistory.slice(-50);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return warnings;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Format warnings for LLM consumption.
|
|
128
|
+
*/
|
|
129
|
+
formatWarnings(warnings: BehaviorWarning[]): string {
|
|
130
|
+
if (warnings.length === 0) return "";
|
|
131
|
+
|
|
132
|
+
return warnings.map((w) => w.message).join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get edit count for a file in this session.
|
|
137
|
+
*/
|
|
138
|
+
getEditCount(filePath: string): number {
|
|
139
|
+
return this.fileEditCount.get(filePath) ?? 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reset state (e.g., on session start).
|
|
144
|
+
*/
|
|
145
|
+
reset(): void {
|
|
146
|
+
this.toolHistory = [];
|
|
147
|
+
this.consecutiveCount = 0;
|
|
148
|
+
this.lastToolName = null;
|
|
149
|
+
this.lastToolTimestamp = 0;
|
|
150
|
+
this.fileEditCount.clear();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -13,7 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { AstGrepParser } from "./ast-grep-parser.js";
|
|
14
14
|
import { AstGrepRuleManager } from "./ast-grep-rule-manager.js";
|
|
15
15
|
import { SgRunner } from "./sg-runner.js";
|
|
16
|
-
const
|
|
16
|
+
const _getExtensionDir = () => {
|
|
17
17
|
if (typeof __dirname !== "undefined") {
|
|
18
18
|
return __dirname;
|
|
19
19
|
}
|
|
@@ -107,7 +107,8 @@ message: found
|
|
|
107
107
|
if (!name)
|
|
108
108
|
continue;
|
|
109
109
|
const signature = this.normalizeFunction(item.text);
|
|
110
|
-
const line = (item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0) +
|
|
110
|
+
const line = (item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0) +
|
|
111
|
+
1;
|
|
111
112
|
const group = grouped.get(signature) ?? [];
|
|
112
113
|
group.push({ name, file: item.file, line });
|
|
113
114
|
grouped.set(signature, group);
|
|
@@ -13,9 +13,9 @@ import * as fs from "node:fs";
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { AstGrepParser } from "./ast-grep-parser.js";
|
|
15
15
|
import { AstGrepRuleManager } from "./ast-grep-rule-manager.js";
|
|
16
|
-
import {
|
|
16
|
+
import { type SgMatch, SgRunner } from "./sg-runner.js";
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const _getExtensionDir = () => {
|
|
19
19
|
if (typeof __dirname !== "undefined") {
|
|
20
20
|
return __dirname;
|
|
21
21
|
}
|
|
@@ -174,14 +174,19 @@ message: found
|
|
|
174
174
|
pattern: string;
|
|
175
175
|
functions: Array<{ name: string; file: string; line: number }>;
|
|
176
176
|
}> {
|
|
177
|
-
const grouped = new Map<
|
|
177
|
+
const grouped = new Map<
|
|
178
|
+
string,
|
|
179
|
+
Array<{ name: string; file: string; line: number }>
|
|
180
|
+
>();
|
|
178
181
|
|
|
179
182
|
for (const item of matches) {
|
|
180
183
|
const name = this.extractFunctionName(item.text);
|
|
181
184
|
if (!name) continue;
|
|
182
185
|
|
|
183
186
|
const signature = this.normalizeFunction(item.text);
|
|
184
|
-
const line =
|
|
187
|
+
const line =
|
|
188
|
+
(item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0) +
|
|
189
|
+
1;
|
|
185
190
|
|
|
186
191
|
const group = grouped.get(signature) ?? [];
|
|
187
192
|
group.push({ name, file: item.file, line });
|
package/clients/auto-loop.js
CHANGED
|
@@ -25,7 +25,11 @@ export function createAutoLoop(pi, config) {
|
|
|
25
25
|
};
|
|
26
26
|
const stop = (ctx, reason) => {
|
|
27
27
|
const wasActive = state.active;
|
|
28
|
-
state = {
|
|
28
|
+
state = {
|
|
29
|
+
active: false,
|
|
30
|
+
iteration: 0,
|
|
31
|
+
maxIterations: config.maxIterations,
|
|
32
|
+
};
|
|
29
33
|
updateStatus(ctx);
|
|
30
34
|
if (wasActive) {
|
|
31
35
|
ctx.ui.notify(`✅ ${config.name} loop ${reason}`, "info");
|