@dungle-scrubs/tallow 0.8.13 → 0.8.15
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/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/interactive-mode-patch.d.ts +14 -4
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +103 -2
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/sdk.d.ts +80 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +481 -31
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/context-budget-guard.test.ts +236 -0
- package/extensions/_shared/context-budget-interop.ts +162 -0
- package/extensions/ask-user-question-tool/__tests__/render-regression.test.ts +203 -0
- package/extensions/ask-user-question-tool/index.ts +70 -9
- package/extensions/background-task-tool/index.ts +10 -2
- package/extensions/bash-tool-enhanced/index.ts +10 -2
- package/extensions/plan-mode-tool/__tests__/utils.test.ts +180 -0
- package/extensions/plan-mode-tool/extension.json +1 -0
- package/extensions/plan-mode-tool/index.ts +33 -0
- package/extensions/plan-mode-tool/utils.ts +60 -0
- package/extensions/web-fetch-tool/__tests__/adaptive-cap.test.ts +148 -0
- package/extensions/web-fetch-tool/index.ts +140 -9
- package/extensions/wezterm-pane-control/__tests__/index.test.ts +23 -2
- package/extensions/wezterm-pane-control/index.ts +65 -1
- package/package.json +4 -4
- package/skills/tallow-expert/SKILL.md +1 -0
|
@@ -3,6 +3,66 @@
|
|
|
3
3
|
* Extracted for testability — no extension API dependencies.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
// ── Natural language plan intent detection ──────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Curated patterns that unambiguously express "enter planning mode".
|
|
10
|
+
* Each pattern uses word boundaries to avoid matching plan-as-noun usage
|
|
11
|
+
* (e.g. "make a plan for X") or questions about plan mode.
|
|
12
|
+
*/
|
|
13
|
+
export const PLAN_INTENT_PATTERNS: readonly RegExp[] = [
|
|
14
|
+
// Composite patterns first — avoids partial stripping of overlapping phrases
|
|
15
|
+
/\bthis\s+is\s+plan(ning)?(\s+only)?\b/i,
|
|
16
|
+
/\bplan[\s-]only\b/i,
|
|
17
|
+
/\bjust\s+plan\b/i,
|
|
18
|
+
/\bonly\s+plan\b/i,
|
|
19
|
+
/\bplan\s+mode\b(?!\s*(\?|do|work|mean))/i,
|
|
20
|
+
/\bplanning\s+mode\b(?!\s*(\?|do|work|mean))/i,
|
|
21
|
+
/\bdon['\u2019]?t\s+(implement|code|execute|make\s+changes)\b/i,
|
|
22
|
+
/\bdo\s+not\s+(implement|code|execute|make\s+changes)\b/i,
|
|
23
|
+
/\bno\s+(implementation|changes|coding)\s+(yet|first|for\s+now)\b/i,
|
|
24
|
+
/\bread[\s-]only\s+mode\b/i,
|
|
25
|
+
/\bplan\s+(first|before)\b/i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detects whether user input contains a strong planning-intent directive.
|
|
30
|
+
*
|
|
31
|
+
* Only matches unambiguous directives like "plan only" or "don't implement".
|
|
32
|
+
* Does NOT match noun usage ("the plan is…") or questions ("what does plan mode do?").
|
|
33
|
+
*
|
|
34
|
+
* @param text - Raw user input
|
|
35
|
+
* @returns true when any plan-intent pattern matches
|
|
36
|
+
*/
|
|
37
|
+
export function detectPlanIntent(text: string): boolean {
|
|
38
|
+
return PLAN_INTENT_PATTERNS.some((pattern) => pattern.test(text));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Strips plan-intent phrases from user input, preserving the actual request.
|
|
43
|
+
*
|
|
44
|
+
* If stripping leaves an empty string (the entire message was just "plan only"),
|
|
45
|
+
* returns the original text so the model has something to work with.
|
|
46
|
+
*
|
|
47
|
+
* @param text - Raw user input
|
|
48
|
+
* @returns Cleaned text with plan-intent phrases removed, or original if nothing remains
|
|
49
|
+
*/
|
|
50
|
+
export function stripPlanIntent(text: string): string {
|
|
51
|
+
let stripped = text;
|
|
52
|
+
for (const pattern of PLAN_INTENT_PATTERNS) {
|
|
53
|
+
stripped = stripped.replace(pattern, "");
|
|
54
|
+
}
|
|
55
|
+
// Clean up artifacts: leading/trailing punctuation, double spaces, dangling commas
|
|
56
|
+
stripped = stripped
|
|
57
|
+
.replace(/^[\s,.\-—;:]+/, "")
|
|
58
|
+
.replace(/[\s,.\-—;:]+$/, "")
|
|
59
|
+
.replace(/\s{2,}/g, " ")
|
|
60
|
+
.trim();
|
|
61
|
+
return stripped || text;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Bash safety ─────────────────────────────────────────────────────────
|
|
65
|
+
|
|
6
66
|
/** Patterns for destructive commands that are blocked in plan mode */
|
|
7
67
|
const DESTRUCTIVE_PATTERNS = [
|
|
8
68
|
/\brm\b/i,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
|
|
3
|
+
import {
|
|
4
|
+
CONTEXT_BUDGET_API_CHANNELS,
|
|
5
|
+
type ContextBudgetEnvelope,
|
|
6
|
+
} from "../../_shared/context-budget-interop.js";
|
|
7
|
+
import webFetchExtension, { type CapResolutionInput, resolveAdaptiveCap } from "../index.js";
|
|
8
|
+
|
|
9
|
+
/** Build a CapResolutionInput with test defaults. */
|
|
10
|
+
function makeInput(overrides: Partial<CapResolutionInput> = {}): CapResolutionInput {
|
|
11
|
+
return {
|
|
12
|
+
defaultMaxBytes: 32 * 1024,
|
|
13
|
+
envelope: undefined,
|
|
14
|
+
policyMax: 512 * 1024,
|
|
15
|
+
policyMin: 4 * 1024,
|
|
16
|
+
userMaxBytes: undefined,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build a planner envelope. */
|
|
22
|
+
function makeEnvelope(maxBytes: number, batchSize = 1): ContextBudgetEnvelope {
|
|
23
|
+
return { batchSize, maxBytes };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const originalFetch = globalThis.fetch;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
globalThis.fetch = originalFetch;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("resolveAdaptiveCap", () => {
|
|
33
|
+
test("uses strict fallback when no envelope exists", () => {
|
|
34
|
+
const result = resolveAdaptiveCap(makeInput());
|
|
35
|
+
expect(result.effectiveMaxBytes).toBe(32 * 1024);
|
|
36
|
+
expect(result.budgetLimited).toBe(false);
|
|
37
|
+
expect(result.budgetReason).toContain("strict fallback");
|
|
38
|
+
expect(result.batchSize).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("marks budgetLimited when envelope reduces cap", () => {
|
|
42
|
+
const result = resolveAdaptiveCap(makeInput({ envelope: makeEnvelope(8 * 1024, 3) }));
|
|
43
|
+
expect(result.effectiveMaxBytes).toBe(8 * 1024);
|
|
44
|
+
expect(result.budgetLimited).toBe(true);
|
|
45
|
+
expect(result.batchSize).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("clamps envelope to policy max", () => {
|
|
49
|
+
const result = resolveAdaptiveCap(makeInput({ envelope: makeEnvelope(900 * 1024) }));
|
|
50
|
+
expect(result.effectiveMaxBytes).toBe(512 * 1024);
|
|
51
|
+
expect(result.budgetReason).toContain("policy max");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("user maxBytes is a hard upper bound", () => {
|
|
55
|
+
const result = resolveAdaptiveCap(
|
|
56
|
+
makeInput({
|
|
57
|
+
envelope: makeEnvelope(20 * 1024),
|
|
58
|
+
userMaxBytes: 2 * 1024,
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
expect(result.effectiveMaxBytes).toBe(2 * 1024);
|
|
62
|
+
expect(result.budgetReason).toContain("user maxBytes");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("web_fetch planner handshake", () => {
|
|
67
|
+
test("requests planner API and consumes envelope by toolCallId", async () => {
|
|
68
|
+
const harness = ExtensionHarness.create();
|
|
69
|
+
const takeCalls: string[] = [];
|
|
70
|
+
const envelopes = new Map<string, ContextBudgetEnvelope>([["tc-1", makeEnvelope(7 * 1024, 2)]]);
|
|
71
|
+
|
|
72
|
+
harness.eventBus.on(CONTEXT_BUDGET_API_CHANNELS.budgetApiRequest, () => {
|
|
73
|
+
harness.eventBus.emit(CONTEXT_BUDGET_API_CHANNELS.budgetApi, {
|
|
74
|
+
api: {
|
|
75
|
+
take(toolCallId: string): ContextBudgetEnvelope | undefined {
|
|
76
|
+
takeCalls.push(toolCallId);
|
|
77
|
+
const envelope = envelopes.get(toolCallId);
|
|
78
|
+
envelopes.delete(toolCallId);
|
|
79
|
+
return envelope;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
globalThis.fetch = async () =>
|
|
86
|
+
new Response("x".repeat(20 * 1024), {
|
|
87
|
+
headers: { "content-type": "text/html" },
|
|
88
|
+
status: 200,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await harness.loadExtension(webFetchExtension);
|
|
92
|
+
const tool = harness.tools.get("web_fetch");
|
|
93
|
+
if (!tool) throw new Error("web_fetch tool missing");
|
|
94
|
+
|
|
95
|
+
const result = await tool.execute("tc-1", { url: "https://example.com" }, undefined, () => {});
|
|
96
|
+
const details = result.details as {
|
|
97
|
+
effectiveMaxBytes?: number;
|
|
98
|
+
batchSize?: number;
|
|
99
|
+
budgetLimited?: boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
expect(takeCalls).toEqual(["tc-1"]);
|
|
103
|
+
expect(details.effectiveMaxBytes).toBe(7 * 1024);
|
|
104
|
+
expect(details.batchSize).toBe(2);
|
|
105
|
+
expect(details.budgetLimited).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("consumed envelope falls back on next call", async () => {
|
|
109
|
+
const harness = ExtensionHarness.create();
|
|
110
|
+
const envelopes = new Map<string, ContextBudgetEnvelope>([["tc-1", makeEnvelope(6 * 1024, 2)]]);
|
|
111
|
+
|
|
112
|
+
harness.eventBus.on(CONTEXT_BUDGET_API_CHANNELS.budgetApiRequest, () => {
|
|
113
|
+
harness.eventBus.emit(CONTEXT_BUDGET_API_CHANNELS.budgetApi, {
|
|
114
|
+
api: {
|
|
115
|
+
take(toolCallId: string): ContextBudgetEnvelope | undefined {
|
|
116
|
+
const envelope = envelopes.get(toolCallId);
|
|
117
|
+
envelopes.delete(toolCallId);
|
|
118
|
+
return envelope;
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
globalThis.fetch = async () =>
|
|
125
|
+
new Response("x".repeat(80 * 1024), {
|
|
126
|
+
headers: { "content-type": "text/html" },
|
|
127
|
+
status: 200,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await harness.loadExtension(webFetchExtension);
|
|
131
|
+
const tool = harness.tools.get("web_fetch");
|
|
132
|
+
if (!tool) throw new Error("web_fetch tool missing");
|
|
133
|
+
|
|
134
|
+
const first = await tool.execute("tc-1", { url: "https://example.com/1" }, undefined, () => {});
|
|
135
|
+
const second = await tool.execute(
|
|
136
|
+
"tc-1",
|
|
137
|
+
{ url: "https://example.com/2" },
|
|
138
|
+
undefined,
|
|
139
|
+
() => {}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const firstDetails = first.details as { effectiveMaxBytes?: number };
|
|
143
|
+
const secondDetails = second.details as { effectiveMaxBytes?: number; budgetReason?: string };
|
|
144
|
+
expect(firstDetails.effectiveMaxBytes).toBe(6 * 1024);
|
|
145
|
+
expect(secondDetails.effectiveMaxBytes).toBe(32 * 1024);
|
|
146
|
+
expect(secondDetails.budgetReason).toContain("strict fallback");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -1,28 +1,135 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WebFetch Extension for Pi
|
|
3
3
|
*
|
|
4
|
-
* Fetches web content via plain HTTP. Returns page text truncated
|
|
4
|
+
* Fetches web content via plain HTTP. Returns page text truncated by context-budget caps.
|
|
5
5
|
* For JS-rendered pages, full-page scraping, or structured extraction,
|
|
6
6
|
* use a dedicated scraping tool (e.g. Firecrawl) instead.
|
|
7
|
+
*
|
|
8
|
+
* Supports adaptive context-budget caps when a planner extension publishes
|
|
9
|
+
* envelopes via the shared context-budget interop.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
10
13
|
import { Text } from "@mariozechner/pi-tui";
|
|
11
14
|
import { Type } from "@sinclair/typebox";
|
|
12
15
|
import { getIcon } from "../_icons/index.js";
|
|
16
|
+
import {
|
|
17
|
+
CONTEXT_BUDGET_DEFAULTS,
|
|
18
|
+
type ContextBudgetEnvelope,
|
|
19
|
+
subscribeToBudgetApi,
|
|
20
|
+
} from "../_shared/context-budget-interop.js";
|
|
13
21
|
import { renderLines } from "../tool-display/index.js";
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
/** Strict fallback cap when no planner envelope is available. */
|
|
24
|
+
const DEFAULT_MAX_BYTES = CONTEXT_BUDGET_DEFAULTS.unknownUsageFallbackCapBytes;
|
|
25
|
+
|
|
26
|
+
/** Policy floor — planner/tool logic should never allocate below this by default. */
|
|
27
|
+
const POLICY_MIN_BYTES = CONTEXT_BUDGET_DEFAULTS.minPerToolBytes;
|
|
28
|
+
|
|
29
|
+
/** Policy ceiling — never exceed this regardless of envelope. */
|
|
30
|
+
const POLICY_MAX_BYTES = CONTEXT_BUDGET_DEFAULTS.maxPerToolBytes;
|
|
31
|
+
|
|
32
|
+
// ── Adaptive cap resolution (pure, exported for tests) ──────────────
|
|
33
|
+
|
|
34
|
+
/** Input parameters for resolving the effective byte cap. */
|
|
35
|
+
export interface CapResolutionInput {
|
|
36
|
+
/** Explicit maxBytes from the user's tool-call parameters. */
|
|
37
|
+
userMaxBytes: number | undefined;
|
|
38
|
+
/** Budget envelope consumed for this tool call (undefined = no planner). */
|
|
39
|
+
envelope: ContextBudgetEnvelope | undefined;
|
|
40
|
+
/** Hard policy floor in bytes. */
|
|
41
|
+
policyMin: number;
|
|
42
|
+
/** Hard policy ceiling in bytes. */
|
|
43
|
+
policyMax: number;
|
|
44
|
+
/** Fallback cap when no envelope is present. */
|
|
45
|
+
defaultMaxBytes: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolved cap with diagnostics. */
|
|
49
|
+
export interface CapResolutionResult {
|
|
50
|
+
/** Final byte cap to apply to the fetch response. */
|
|
51
|
+
effectiveMaxBytes: number;
|
|
52
|
+
/** True when the planner envelope reduced the cap below what the user would have gotten. */
|
|
53
|
+
budgetLimited: boolean;
|
|
54
|
+
/** Human-readable explanation of how the cap was chosen. */
|
|
55
|
+
budgetReason: string;
|
|
56
|
+
/** Batch size from the envelope (1 when no envelope). */
|
|
57
|
+
batchSize: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the effective maxBytes cap from all inputs.
|
|
62
|
+
*
|
|
63
|
+
* Priority chain:
|
|
64
|
+
* 1. Start from envelope maxBytes (or defaultMaxBytes when absent).
|
|
65
|
+
* 2. User maxBytes is a hard upper bound — cap cannot exceed it.
|
|
66
|
+
* 3. Clamp into [policyMin, policyMax].
|
|
67
|
+
*
|
|
68
|
+
* @param input - Cap resolution parameters
|
|
69
|
+
* @returns Resolved cap with diagnostic metadata
|
|
70
|
+
*/
|
|
71
|
+
export function resolveAdaptiveCap(input: CapResolutionInput): CapResolutionResult {
|
|
72
|
+
const { userMaxBytes, envelope, policyMin, policyMax, defaultMaxBytes } = input;
|
|
73
|
+
|
|
74
|
+
const batchSize = envelope?.batchSize ?? 1;
|
|
75
|
+
const userCap = userMaxBytes ?? Number.POSITIVE_INFINITY;
|
|
76
|
+
|
|
77
|
+
// Step 1: base cap comes from planner envelope or strict fallback.
|
|
78
|
+
const base = envelope?.maxBytes ?? defaultMaxBytes;
|
|
79
|
+
let reason = envelope
|
|
80
|
+
? `planner envelope (${base} bytes, batch ${batchSize})`
|
|
81
|
+
: `strict fallback (${defaultMaxBytes} bytes)`;
|
|
82
|
+
|
|
83
|
+
// Step 2: clamp planner/fallback cap into policy bounds.
|
|
84
|
+
let effective = Math.min(policyMax, Math.max(policyMin, base));
|
|
85
|
+
if (effective !== base) {
|
|
86
|
+
reason +=
|
|
87
|
+
effective < base
|
|
88
|
+
? ` → capped by policy max (${policyMax})`
|
|
89
|
+
: ` → raised to policy min (${policyMin})`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Step 3: explicit user maxBytes is a hard upper bound.
|
|
93
|
+
if (Number.isFinite(userCap) && effective > userCap) {
|
|
94
|
+
effective = userCap;
|
|
95
|
+
reason += ` → capped by user maxBytes (${userCap})`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const withoutEnvelope = Number.isFinite(userCap)
|
|
99
|
+
? Math.min(userCap, Math.min(policyMax, Math.max(policyMin, defaultMaxBytes)))
|
|
100
|
+
: Math.min(policyMax, Math.max(policyMin, defaultMaxBytes));
|
|
101
|
+
const budgetLimited = envelope !== undefined && effective < withoutEnvelope;
|
|
102
|
+
|
|
103
|
+
return { effectiveMaxBytes: effective, budgetLimited, budgetReason: reason, batchSize };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Truncate text to a maximum UTF-8 byte length.
|
|
108
|
+
*
|
|
109
|
+
* @param text - Source text
|
|
110
|
+
* @param maxBytes - Maximum number of UTF-8 bytes to keep
|
|
111
|
+
* @returns Truncated text at a valid character boundary
|
|
112
|
+
*/
|
|
113
|
+
function truncateTextToBytes(text: string, maxBytes: number): string {
|
|
114
|
+
if (Buffer.byteLength(text, "utf-8") <= maxBytes) return text;
|
|
115
|
+
let end = Math.min(text.length, maxBytes);
|
|
116
|
+
while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf-8") > maxBytes) {
|
|
117
|
+
end -= 1;
|
|
118
|
+
}
|
|
119
|
+
return text.slice(0, end);
|
|
120
|
+
}
|
|
16
121
|
|
|
17
122
|
/**
|
|
18
123
|
* Registers the web_fetch tool.
|
|
19
124
|
* @param pi - Extension API for registering tools
|
|
20
125
|
*/
|
|
21
126
|
export default function (pi: ExtensionAPI) {
|
|
127
|
+
const getBudgetApi = subscribeToBudgetApi(pi.events);
|
|
128
|
+
|
|
22
129
|
pi.registerTool({
|
|
23
130
|
name: "web_fetch",
|
|
24
131
|
label: "web_fetch",
|
|
25
|
-
description: `Fetch content from a URL. Returns
|
|
132
|
+
description: `Fetch content from a URL. Returns page text truncated by context-budget policy (conservative fallback when budget is unknown).
|
|
26
133
|
|
|
27
134
|
WHEN TO USE:
|
|
28
135
|
- Need to read web page content
|
|
@@ -31,7 +138,10 @@ WHEN TO USE:
|
|
|
31
138
|
parameters: Type.Object({
|
|
32
139
|
url: Type.String({ description: "URL to fetch" }),
|
|
33
140
|
maxBytes: Type.Optional(
|
|
34
|
-
Type.Number({
|
|
141
|
+
Type.Number({
|
|
142
|
+
description:
|
|
143
|
+
"Max bytes before truncation (hard upper bound; may be reduced by context budget)",
|
|
144
|
+
})
|
|
35
145
|
),
|
|
36
146
|
format: Type.Optional(
|
|
37
147
|
Type.Union([Type.Literal("text"), Type.Literal("markdown"), Type.Literal("html")], {
|
|
@@ -40,8 +150,20 @@ WHEN TO USE:
|
|
|
40
150
|
),
|
|
41
151
|
}),
|
|
42
152
|
|
|
43
|
-
async execute(
|
|
44
|
-
|
|
153
|
+
async execute(toolCallId, params, signal, _onUpdate) {
|
|
154
|
+
// ── Adaptive cap ────────────────────────────────────
|
|
155
|
+
const budgetApi = getBudgetApi();
|
|
156
|
+
const envelope = budgetApi?.take(toolCallId) ?? undefined;
|
|
157
|
+
|
|
158
|
+
const { effectiveMaxBytes, budgetLimited, budgetReason, batchSize } = resolveAdaptiveCap({
|
|
159
|
+
userMaxBytes: params.maxBytes,
|
|
160
|
+
envelope,
|
|
161
|
+
policyMin: POLICY_MIN_BYTES,
|
|
162
|
+
policyMax: POLICY_MAX_BYTES,
|
|
163
|
+
defaultMaxBytes: DEFAULT_MAX_BYTES,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const maxBytes = effectiveMaxBytes;
|
|
45
167
|
|
|
46
168
|
try {
|
|
47
169
|
const response = await fetch(params.url, {
|
|
@@ -62,10 +184,10 @@ WHEN TO USE:
|
|
|
62
184
|
|
|
63
185
|
const contentType = response.headers.get("content-type") || "";
|
|
64
186
|
const fullText = await response.text();
|
|
65
|
-
const totalBytes =
|
|
187
|
+
const totalBytes = Buffer.byteLength(fullText, "utf-8");
|
|
66
188
|
const truncated = totalBytes > maxBytes;
|
|
67
189
|
|
|
68
|
-
let content = truncated ? fullText
|
|
190
|
+
let content = truncated ? truncateTextToBytes(fullText, maxBytes) : fullText;
|
|
69
191
|
|
|
70
192
|
if (truncated) {
|
|
71
193
|
content += `\n\n[Truncated: showing ${(maxBytes / 1024).toFixed(1)}KB of ${(totalBytes / 1024).toFixed(1)}KB]`;
|
|
@@ -79,6 +201,10 @@ WHEN TO USE:
|
|
|
79
201
|
contentType,
|
|
80
202
|
totalBytes,
|
|
81
203
|
truncated,
|
|
204
|
+
effectiveMaxBytes,
|
|
205
|
+
budgetLimited,
|
|
206
|
+
budgetReason,
|
|
207
|
+
batchSize,
|
|
82
208
|
},
|
|
83
209
|
};
|
|
84
210
|
} catch (error: unknown) {
|
|
@@ -106,6 +232,10 @@ WHEN TO USE:
|
|
|
106
232
|
isError?: boolean;
|
|
107
233
|
totalBytes?: number;
|
|
108
234
|
truncated?: boolean;
|
|
235
|
+
effectiveMaxBytes?: number;
|
|
236
|
+
budgetLimited?: boolean;
|
|
237
|
+
budgetReason?: string;
|
|
238
|
+
batchSize?: number;
|
|
109
239
|
}
|
|
110
240
|
| undefined;
|
|
111
241
|
if (!details) {
|
|
@@ -120,10 +250,11 @@ WHEN TO USE:
|
|
|
120
250
|
} else {
|
|
121
251
|
const size = details.totalBytes ? ` (${(details.totalBytes / 1024).toFixed(1)}KB)` : "";
|
|
122
252
|
const truncNote = details.truncated ? " [truncated]" : "";
|
|
253
|
+
const budgetNote = details.budgetLimited ? " [budget-limited]" : "";
|
|
123
254
|
footer =
|
|
124
255
|
theme.fg("success", `${getIcon("success")} `) +
|
|
125
256
|
theme.fg("dim", details.url ?? "") +
|
|
126
|
-
theme.fg("muted", size + truncNote);
|
|
257
|
+
theme.fg("muted", size + truncNote + budgetNote);
|
|
127
258
|
}
|
|
128
259
|
|
|
129
260
|
// Expanded: content preview first, footer last
|
|
@@ -5,6 +5,8 @@ import weztermPaneControl, {
|
|
|
5
5
|
buildWeztermPaneGuidance,
|
|
6
6
|
executeWeztermAction,
|
|
7
7
|
filterPanesToCurrentTab,
|
|
8
|
+
hasExplicitPaneRequest,
|
|
9
|
+
isPaneCreatingAction,
|
|
8
10
|
type WeztermCliResult,
|
|
9
11
|
type WeztermPaneInfo,
|
|
10
12
|
} from "../index.js";
|
|
@@ -91,17 +93,36 @@ describe("wezterm-pane-control registration", () => {
|
|
|
91
93
|
});
|
|
92
94
|
|
|
93
95
|
describe("buildWeztermPaneGuidance", () => {
|
|
94
|
-
it("includes privacy and
|
|
96
|
+
it("includes privacy and explicit-pane-request guidance", () => {
|
|
95
97
|
const guidance = buildWeztermPaneGuidance(116);
|
|
96
98
|
expect(guidance).toContain("Do not run or read commands likely to reveal private secrets");
|
|
99
|
+
expect(guidance).toContain("Do not split panes or spawn tabs unless the user explicitly asks");
|
|
100
|
+
expect(guidance).toContain("prefer bg_bash in the current session");
|
|
97
101
|
expect(guidance).toContain("Default behavior: if you prefill a command");
|
|
98
102
|
expect(guidance).toContain("Only leave a command unexecuted");
|
|
99
|
-
expect(guidance).toContain("user
|
|
103
|
+
expect(guidance).toContain("user explicitly asks to monitor output in another pane");
|
|
100
104
|
expect(guidance).toContain("newline (\\n) via send_text");
|
|
101
105
|
expect(guidance).toContain("WezTerm pane 116");
|
|
102
106
|
});
|
|
103
107
|
});
|
|
104
108
|
|
|
109
|
+
describe("explicit pane-request guards", () => {
|
|
110
|
+
it("detects explicit pane/tab intent in prompt text", () => {
|
|
111
|
+
expect(hasExplicitPaneRequest("split a pane to the right and run pnpm dev")).toBe(true);
|
|
112
|
+
expect(hasExplicitPaneRequest("open a new tab for logs")).toBe(true);
|
|
113
|
+
expect(hasExplicitPaneRequest("start dev server")).toBe(false);
|
|
114
|
+
expect(hasExplicitPaneRequest("run tests in background")).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("identifies pane-creating actions", () => {
|
|
118
|
+
expect(isPaneCreatingAction("split")).toBe(true);
|
|
119
|
+
expect(isPaneCreatingAction("spawn_tab")).toBe(true);
|
|
120
|
+
expect(isPaneCreatingAction("move_to_tab")).toBe(true);
|
|
121
|
+
expect(isPaneCreatingAction("read_text")).toBe(false);
|
|
122
|
+
expect(isPaneCreatingAction(undefined)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
105
126
|
describe("filterPanesToCurrentTab", () => {
|
|
106
127
|
it("returns only panes from the active tab", () => {
|
|
107
128
|
const panes = [
|
|
@@ -110,6 +110,50 @@ const DIRECTIONS: readonly WeztermDirection[] = [
|
|
|
110
110
|
|
|
111
111
|
const ZOOM_STATES: readonly WeztermZoomState[] = ["zoom", "unzoom", "toggle"] as const;
|
|
112
112
|
|
|
113
|
+
const PANE_CREATING_ACTIONS: readonly WeztermAction[] = [
|
|
114
|
+
"split",
|
|
115
|
+
"spawn_tab",
|
|
116
|
+
"move_to_tab",
|
|
117
|
+
] as const;
|
|
118
|
+
|
|
119
|
+
const EXPLICIT_PANE_REQUEST_PATTERNS: readonly RegExp[] = [
|
|
120
|
+
/\bwezterm\b/i,
|
|
121
|
+
/\bpane(?:s)?\b/i,
|
|
122
|
+
/\btab(?:s)?\b/i,
|
|
123
|
+
/\bsplit\b/i,
|
|
124
|
+
/\bspawn\b/i,
|
|
125
|
+
/\bwindow\b/i,
|
|
126
|
+
/\bleft\b/i,
|
|
127
|
+
/\bright\b/i,
|
|
128
|
+
/\btop\b/i,
|
|
129
|
+
/\bbottom\b/i,
|
|
130
|
+
] as const;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check whether an action creates or rehomes panes/tabs.
|
|
134
|
+
*
|
|
135
|
+
* @param action - Candidate action string
|
|
136
|
+
* @returns True when action can open/split/move panes or tabs
|
|
137
|
+
*/
|
|
138
|
+
export function isPaneCreatingAction(action: unknown): action is WeztermAction {
|
|
139
|
+
if (typeof action !== "string") return false;
|
|
140
|
+
return PANE_CREATING_ACTIONS.includes(action as WeztermAction);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Determine whether the user explicitly requested pane/tab management.
|
|
145
|
+
*
|
|
146
|
+
* This acts as a conservative guardrail: opening/splitting panes should only
|
|
147
|
+
* happen when the current turn clearly references pane/tab/window controls.
|
|
148
|
+
*
|
|
149
|
+
* @param prompt - Current user prompt text
|
|
150
|
+
* @returns True when prompt contains explicit pane/tab intent
|
|
151
|
+
*/
|
|
152
|
+
export function hasExplicitPaneRequest(prompt: string): boolean {
|
|
153
|
+
if (prompt.trim().length === 0) return false;
|
|
154
|
+
return EXPLICIT_PANE_REQUEST_PATTERNS.some((pattern) => pattern.test(prompt));
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
/**
|
|
114
158
|
* Parse WEZTERM_PANE to a valid pane ID.
|
|
115
159
|
*
|
|
@@ -405,9 +449,11 @@ export function buildWeztermPaneGuidance(currentPaneId: number): string {
|
|
|
405
449
|
"",
|
|
406
450
|
"Use best judgment before controlling panes:",
|
|
407
451
|
"- Do not run or read commands likely to reveal private secrets (keys, tokens, credentials) unless the user explicitly asks.",
|
|
452
|
+
"- Do not split panes or spawn tabs unless the user explicitly asks for pane/tab management.",
|
|
453
|
+
"- For long-running commands (dev servers, watchers), prefer bg_bash in the current session unless the user explicitly asks to run them in another pane/tab.",
|
|
408
454
|
"- Default behavior: if you prefill a command for the user, execute it automatically by sending Enter (newline, \\n).",
|
|
409
455
|
"- Only leave a command unexecuted when the user explicitly asks to review/edit it before running.",
|
|
410
|
-
"- If the user
|
|
456
|
+
"- If the user explicitly asks to monitor output in another pane, execute the command and let them watch there.",
|
|
411
457
|
"- Enter can be pressed by sending a newline (\\n) via send_text (either appended to the command or as a second send_text call).",
|
|
412
458
|
].join("\n");
|
|
413
459
|
}
|
|
@@ -597,6 +643,23 @@ export default function weztermPaneControl(pi: ExtensionAPI): void {
|
|
|
597
643
|
}
|
|
598
644
|
|
|
599
645
|
const runCli = createWeztermRunner(weztermExecutable);
|
|
646
|
+
let currentTurnPrompt = "";
|
|
647
|
+
|
|
648
|
+
pi.on("tool_call", async (event) => {
|
|
649
|
+
if (event.toolName !== "wezterm_pane") return;
|
|
650
|
+
|
|
651
|
+
const input = (event.input ?? {}) as Record<string, unknown>;
|
|
652
|
+
const action = input.action;
|
|
653
|
+
if (!isPaneCreatingAction(action)) return;
|
|
654
|
+
if (hasExplicitPaneRequest(currentTurnPrompt)) return;
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
block: true,
|
|
658
|
+
reason:
|
|
659
|
+
"Opening/splitting WezTerm panes requires an explicit pane/tab request. " +
|
|
660
|
+
"Use bg_bash for dev servers/watchers unless the user asks for another pane.",
|
|
661
|
+
};
|
|
662
|
+
});
|
|
600
663
|
|
|
601
664
|
pi.registerTool({
|
|
602
665
|
name: "wezterm_pane",
|
|
@@ -648,6 +711,7 @@ export default function weztermPaneControl(pi: ExtensionAPI): void {
|
|
|
648
711
|
});
|
|
649
712
|
|
|
650
713
|
pi.on("before_agent_start", async (event) => {
|
|
714
|
+
currentTurnPrompt = event.prompt;
|
|
651
715
|
return {
|
|
652
716
|
systemPrompt: `${event.systemPrompt}\n\n${buildWeztermPaneGuidance(currentPaneId)}`,
|
|
653
717
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dungle-scrubs/tallow",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.15",
|
|
4
4
|
"description": "An opinionated coding agent. Built on pi.",
|
|
5
5
|
"piConfig": {
|
|
6
6
|
"name": "tallow",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@clack/prompts": "^1.0.0",
|
|
69
69
|
"@dungle-scrubs/synapse": "0.1.6",
|
|
70
|
-
"@mariozechner/pi-coding-agent": "^0.55.
|
|
70
|
+
"@mariozechner/pi-coding-agent": "^0.55.4",
|
|
71
71
|
"@sinclair/typebox": "0.34.48",
|
|
72
72
|
"ai": "^6.0.86",
|
|
73
73
|
"commander": "^14.0.3",
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"@biomejs/biome": "2.4.2",
|
|
79
|
-
"@mariozechner/pi-agent-core": "^0.55.
|
|
80
|
-
"@mariozechner/pi-ai": "^0.55.
|
|
79
|
+
"@mariozechner/pi-agent-core": "^0.55.4",
|
|
80
|
+
"@mariozechner/pi-ai": "^0.55.4",
|
|
81
81
|
"@mariozechner/pi-tui": "workspace:*",
|
|
82
82
|
"@types/node": "25.2.3",
|
|
83
83
|
"husky": "^9.1.7",
|
|
@@ -87,6 +87,7 @@ Extensions export a default function receiving `ExtensionAPI` (conventionally na
|
|
|
87
87
|
- `setModel(model: Model<any>)` — Set the current model.
|
|
88
88
|
- `getThinkingLevel()` — Get current thinking level.
|
|
89
89
|
- `setThinkingLevel(level: ThinkingLevel)` — Set thinking level (clamped to model capabilities).
|
|
90
|
+
- `unregisterProvider(name: string)` — Unregister a previously registered provider.
|
|
90
91
|
- `events` — Shared event bus for extension communication.
|
|
91
92
|
|
|
92
93
|
### Events (`pi.on(event, handler)`)
|