@xynogen/pix-core 0.2.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/README.md +24 -21
- package/package.json +11 -17
- package/skills/ask-user/SKILL.md +0 -48
- package/src/commands/agent-sop/agent-sop.ts +0 -58
- package/src/commands/clear/clear.ts +0 -32
- package/src/commands/diff/diff.ts +0 -32
- package/src/commands/models/models.test.ts +0 -95
- package/src/commands/models/models.ts +0 -367
- package/src/commands/models/patch-builtin.test.ts +0 -66
- package/src/commands/models/patch-builtin.ts +0 -120
- package/src/commands/tools.test.ts +0 -15
- package/src/commands/update/update.test.ts +0 -112
- package/src/commands/update/update.ts +0 -271
- package/src/index.ts +0 -45
- package/src/lib/data.ts +0 -33
- package/src/nudge/capability.test.ts +0 -258
- package/src/nudge/capability.ts +0 -189
- package/src/nudge/index.ts +0 -17
- package/src/nudge/tools.test.ts +0 -157
- package/src/nudge/tools.ts +0 -212
- package/src/tool/ask/ask.test.ts +0 -243
- package/src/tool/ask/components.ts +0 -55
- package/src/tool/ask/helpers.ts +0 -77
- package/src/tool/ask/index.ts +0 -130
- package/src/tool/ask/questionnaire.ts +0 -693
- package/src/tool/ask/rpc.ts +0 -84
- package/src/tool/ask/schema.ts +0 -69
- package/src/tool/ask/single-select-layout.test.ts +0 -124
- package/src/tool/ask/single-select-layout.ts +0 -237
- package/src/tool/ask/types.ts +0 -17
- package/src/tool/todo/todo.test.ts +0 -646
- package/src/tool/todo/todo.ts +0 -218
- package/src/tool/toolbox/toolbox.test.ts +0 -314
- package/src/tool/toolbox/toolbox.ts +0 -570
- package/src/ui/diagnostics.ts +0 -145
- package/src/ui/footer.ts +0 -513
- package/src/ui/welcome.test.ts +0 -124
- package/src/ui/welcome.ts +0 -369
package/src/nudge/capability.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* capability-nudge.ts — orient the model to its full toolbelt
|
|
3
|
-
*
|
|
4
|
-
* Models drift toward improvising mid-session, forgetting they can ask the
|
|
5
|
-
* user, search the web, pull library docs (context7), use LSP, or invoke an
|
|
6
|
-
* Agent Skill instead of guessing. Fires on EVERY user prompt via
|
|
7
|
-
* `before_agent_start`, injected into the system prompt for the turn.
|
|
8
|
-
*
|
|
9
|
-
* Two modes:
|
|
10
|
-
* 1. FIRST prompt of the session — an orientation block: a high-level
|
|
11
|
-
* description of WHAT is available (counts of tools / MCP tools / skills)
|
|
12
|
-
* and HOW to explore it. We deliberately do NOT dump the whole inventory
|
|
13
|
-
* every turn — the model should call read_skills() for skills and use /toolbox
|
|
14
|
-
* (slash command, user-facing) to discover/enable gated tools.
|
|
15
|
-
* 2. EVERY subsequent prompt — the terse one-line CAPABILITY_REMINDER, a
|
|
16
|
-
* cheap (~40 tok) reinforcement that steers toward read_skills() and /toolbox.
|
|
17
|
-
*
|
|
18
|
-
* NOTE: `toolbox` is a slash command only (/toolbox) — NOT a model-callable
|
|
19
|
-
* function tool. The model cannot call toolbox() in function definitions.
|
|
20
|
-
* The `read_skills` tool IS model-callable: read_skills() lists/loads bundled skills.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { existsSync } from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import type {
|
|
26
|
-
BuildSystemPromptOptions,
|
|
27
|
-
ExtensionAPI,
|
|
28
|
-
ToolInfo,
|
|
29
|
-
} from "@earendil-works/pi-coding-agent";
|
|
30
|
-
|
|
31
|
-
type LoadedSkill = NonNullable<BuildSystemPromptOptions["skills"]>[number];
|
|
32
|
-
|
|
33
|
-
/** The standing per-turn reminder. Kept terse — it ships on every turn. */
|
|
34
|
-
/** How often (in turns after the first) to re-send the capability reminder. */
|
|
35
|
-
const REMINDER_INTERVAL = 10;
|
|
36
|
-
|
|
37
|
-
export const CAPABILITY_REMINDER =
|
|
38
|
-
"Reminder — check knowledge resources " +
|
|
39
|
-
"(skills/tools/MCP/web/user) before improvising. " +
|
|
40
|
-
"Matching skill? Call read_skills() first.";
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Build the optional graphify hint line.
|
|
44
|
-
* Returns a string if graphify-out/graph.json exists in cwd, else undefined.
|
|
45
|
-
*/
|
|
46
|
-
export function graphifyHint(cwd: string): string | undefined {
|
|
47
|
-
if (existsSync(join(cwd, "graphify-out", "graph.json"))) {
|
|
48
|
-
return (
|
|
49
|
-
"graphify-out/graph.json exists — for codebase questions (how does X work, " +
|
|
50
|
-
'where is Y, trace Z) run `graphify query "<question>"` before reading files.'
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Count model-invocable skills (excludes user-only /skill:name entries). */
|
|
57
|
-
export function countInvocableSkills(
|
|
58
|
-
skills: LoadedSkill[] | undefined,
|
|
59
|
-
): number {
|
|
60
|
-
return (skills ?? []).filter((s) => !s.disableModelInvocation).length;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Split tools by source (MCP vs other) and, when an `active` set is supplied,
|
|
65
|
-
* by gate status (active = schema in the prompt now; gated = registered but
|
|
66
|
-
* gated out, reachable only via toolbox). Without `active`, everything counts
|
|
67
|
-
* as active (gated = 0) so callers that don't track the gate are unaffected.
|
|
68
|
-
*/
|
|
69
|
-
export function partitionTools(
|
|
70
|
-
tools: ToolInfo[] | undefined,
|
|
71
|
-
active?: Iterable<string>,
|
|
72
|
-
): {
|
|
73
|
-
mcp: number;
|
|
74
|
-
other: number;
|
|
75
|
-
active: number;
|
|
76
|
-
gated: number;
|
|
77
|
-
} {
|
|
78
|
-
const activeSet = active ? new Set(active) : undefined;
|
|
79
|
-
let mcp = 0;
|
|
80
|
-
let other = 0;
|
|
81
|
-
let activeCount = 0;
|
|
82
|
-
let gated = 0;
|
|
83
|
-
for (const t of tools ?? []) {
|
|
84
|
-
if (/mcp/i.test(t.sourceInfo?.source ?? "")) mcp++;
|
|
85
|
-
else other++;
|
|
86
|
-
// A tool is "gated" only when we have an active set and it's absent from it.
|
|
87
|
-
if (activeSet && !activeSet.has(t.name)) gated++;
|
|
88
|
-
else activeCount++;
|
|
89
|
-
}
|
|
90
|
-
return { mcp, other, active: activeCount, gated };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Build the one-time orientation block shown on the FIRST prompt. Describes the
|
|
95
|
-
* shape of the toolbelt (counts) and how to explore it via `toolbox`, plus the
|
|
96
|
-
* sorted skill names so the model knows what exists by name without a dump of
|
|
97
|
-
* descriptions (those live in the system prompt / are searchable via tools).
|
|
98
|
-
*/
|
|
99
|
-
export function buildOrientation(
|
|
100
|
-
tools: ToolInfo[] | undefined,
|
|
101
|
-
skills: LoadedSkill[] | undefined,
|
|
102
|
-
/** Currently-active tool names; absent ⇒ gate not tracked (all treated active). */
|
|
103
|
-
activeToolNames?: Iterable<string>,
|
|
104
|
-
): string {
|
|
105
|
-
const { mcp, other, gated } = partitionTools(tools, activeToolNames);
|
|
106
|
-
const skillNames = (skills ?? [])
|
|
107
|
-
.filter((s) => !s.disableModelInvocation)
|
|
108
|
-
.map((s) => s.name)
|
|
109
|
-
.sort();
|
|
110
|
-
|
|
111
|
-
const inventory: string[] = [];
|
|
112
|
-
if (other) inventory.push(`${other} tool${other === 1 ? "" : "s"}`);
|
|
113
|
-
if (mcp) inventory.push(`${mcp} MCP tool${mcp === 1 ? "" : "s"}`);
|
|
114
|
-
if (skillNames.length)
|
|
115
|
-
inventory.push(
|
|
116
|
-
`${skillNames.length} skill${skillNames.length === 1 ? "" : "s"}`,
|
|
117
|
-
);
|
|
118
|
-
const summary = inventory.length ? inventory.join(", ") : "your toolbelt";
|
|
119
|
-
|
|
120
|
-
// Lead with the gate — tools not described in the prompt are still callable
|
|
121
|
-
// via function definitions, but the model doesn't know about them.
|
|
122
|
-
const gateLine = gated
|
|
123
|
-
? `${gated} ${gated === 1 ? "is" : "are"} gated (kept out of the prompt to save context). All tools are always callable via function definitions.`
|
|
124
|
-
: undefined;
|
|
125
|
-
|
|
126
|
-
const lines = [
|
|
127
|
-
`Toolbelt: ${summary}, plus LSP, MCP (context7 docs), web search/fetch, and the user.`,
|
|
128
|
-
];
|
|
129
|
-
if (gateLine) lines.push(gateLine);
|
|
130
|
-
lines.push(
|
|
131
|
-
"Don't improvise what a capability covers — ask the user, search the web, or pull docs first.",
|
|
132
|
-
"`read_skills()` lists/loads bundled skills — call it when a skill matches your task.",
|
|
133
|
-
);
|
|
134
|
-
if (skillNames.length) {
|
|
135
|
-
lines.push(`Skills: ${skillNames.join(", ")}.`);
|
|
136
|
-
}
|
|
137
|
-
// Graphify hint — only when a graph is already built for this project
|
|
138
|
-
const gHint = graphifyHint(process.cwd());
|
|
139
|
-
if (gHint) lines.push(gHint);
|
|
140
|
-
// Framing — this block is orientation context, not a task. Without it the
|
|
141
|
-
// model can mistake the first-turn orientation for the prompt and reply
|
|
142
|
-
// "Ready, waiting for task" instead of acting on the user's request.
|
|
143
|
-
lines.push(
|
|
144
|
-
"(Orientation only — not a task. Act on the user's request now; do not reply to this notice.)",
|
|
145
|
-
);
|
|
146
|
-
return lines.join("\n");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export default function registerCapabilityNudge(pi: ExtensionAPI): void {
|
|
150
|
-
let turnCount = 0;
|
|
151
|
-
|
|
152
|
-
pi.on("before_agent_start", async (event) => {
|
|
153
|
-
const skills = event.systemPromptOptions?.skills;
|
|
154
|
-
turnCount++;
|
|
155
|
-
|
|
156
|
-
let content: string;
|
|
157
|
-
if (turnCount === 1) {
|
|
158
|
-
let tools: ToolInfo[] | undefined;
|
|
159
|
-
try {
|
|
160
|
-
tools = pi.getAllTools();
|
|
161
|
-
} catch {
|
|
162
|
-
tools = undefined;
|
|
163
|
-
}
|
|
164
|
-
// Active set lets the orientation distinguish callable-now from gated.
|
|
165
|
-
// getActiveTools() returns string[] (tool NAMES) — not ToolInfo[]. Use as-is.
|
|
166
|
-
let activeToolNames: string[] | undefined;
|
|
167
|
-
try {
|
|
168
|
-
activeToolNames = pi.getActiveTools();
|
|
169
|
-
} catch {
|
|
170
|
-
activeToolNames = undefined;
|
|
171
|
-
}
|
|
172
|
-
content = buildOrientation(tools, skills, activeToolNames);
|
|
173
|
-
} else if (turnCount % REMINDER_INTERVAL === 1) {
|
|
174
|
-
// Fire reminder every REMINDER_INTERVAL turns (turn 11, 21, ...)
|
|
175
|
-
const cwd = process.cwd();
|
|
176
|
-
const gHint = graphifyHint(cwd);
|
|
177
|
-
content = gHint
|
|
178
|
-
? `${CAPABILITY_REMINDER}\n${gHint}`
|
|
179
|
-
: CAPABILITY_REMINDER;
|
|
180
|
-
} else {
|
|
181
|
-
return; // no nudge this turn
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const existing = event.systemPrompt ?? "";
|
|
185
|
-
const systemPrompt = existing ? `${existing}\n\n${content}` : content;
|
|
186
|
-
|
|
187
|
-
return { systemPrompt };
|
|
188
|
-
});
|
|
189
|
-
}
|
package/src/nudge/index.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* nudge/ — model-steering reminders, wired by one registerNudges(pi).
|
|
3
|
-
*
|
|
4
|
-
* - tools — block raw bash that stands in for a native tool (reactive,
|
|
5
|
-
* per tool_call, once per category). See tools.ts.
|
|
6
|
-
* - capability — full-toolbelt one-liner + dynamic skill-name list on every
|
|
7
|
-
* prompt (one hidden message). See capability.ts.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import registerCapabilityNudge from "./capability.ts";
|
|
12
|
-
import registerToolsNudge from "./tools.ts";
|
|
13
|
-
|
|
14
|
-
export default function registerNudges(pi: ExtensionAPI): void {
|
|
15
|
-
registerToolsNudge(pi);
|
|
16
|
-
registerCapabilityNudge(pi);
|
|
17
|
-
}
|
package/src/nudge/tools.test.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
classify,
|
|
4
|
-
classifyCompound,
|
|
5
|
-
nudgeReason,
|
|
6
|
-
splitSegments,
|
|
7
|
-
} from "./tools.ts";
|
|
8
|
-
|
|
9
|
-
describe("classify", () => {
|
|
10
|
-
test("maps cat/head/tail/less to read", () => {
|
|
11
|
-
for (const c of ["cat foo.ts", "head -5 a", "tail -f log", "less x"]) {
|
|
12
|
-
expect(classify(c)?.category).toBe("read");
|
|
13
|
-
}
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("maps ls/tree to ls", () => {
|
|
17
|
-
expect(classify("ls -la")?.category).toBe("ls");
|
|
18
|
-
expect(classify("tree src")?.category).toBe("ls");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("maps grep/rg to grep", () => {
|
|
22
|
-
expect(classify("grep foo bar.ts")?.category).toBe("grep");
|
|
23
|
-
expect(classify("rg pattern")?.category).toBe("grep");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("maps find/fd to find", () => {
|
|
27
|
-
expect(classify("find . -name x")?.category).toBe("find");
|
|
28
|
-
expect(classify("fd '*.ts'")?.category).toBe("find");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("maps sed -i to edit, but plain sed is allowed", () => {
|
|
32
|
-
expect(classify("sed -i 's/a/b/' f")?.category).toBe("edit");
|
|
33
|
-
expect(classify("sed 's/a/b/' f")).toBeUndefined();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("strips env-var prefix before matching", () => {
|
|
37
|
-
expect(classify("FOO=bar grep x y")?.category).toBe("grep");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("unaliases leading backslash", () => {
|
|
41
|
-
expect(classify("\\grep x y")?.category).toBe("grep");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("ignores compound / piped / redirected commands", () => {
|
|
45
|
-
expect(classify("cat a | grep b")).toBeUndefined();
|
|
46
|
-
expect(classify("grep x y > out")).toBeUndefined();
|
|
47
|
-
expect(classify("ls && echo done")).toBeUndefined();
|
|
48
|
-
expect(classify("cat $(ls)")).toBeUndefined();
|
|
49
|
-
expect(classify("ls; pwd")).toBeUndefined();
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("ignores non-tool commands and empty input", () => {
|
|
53
|
-
expect(classify("git status")).toBeUndefined();
|
|
54
|
-
expect(classify("npm test")).toBeUndefined();
|
|
55
|
-
expect(classify("")).toBeUndefined();
|
|
56
|
-
expect(classify(" ")).toBeUndefined();
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe("splitSegments", () => {
|
|
61
|
-
test("records the operator following each segment", () => {
|
|
62
|
-
expect(splitSegments("cat x | jq .")).toEqual([
|
|
63
|
-
{ text: "cat x ", followedBy: "|" },
|
|
64
|
-
{ text: " jq .", followedBy: "" },
|
|
65
|
-
]);
|
|
66
|
-
expect(splitSegments("cat x || ls y")).toEqual([
|
|
67
|
-
{ text: "cat x ", followedBy: "||" },
|
|
68
|
-
{ text: " ls y", followedBy: "" },
|
|
69
|
-
]);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("drops empty segments", () => {
|
|
73
|
-
expect(splitSegments("ls;")).toEqual([{ text: "ls", followedBy: ";" }]);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("classifyCompound", () => {
|
|
78
|
-
test("still classifies simple commands", () => {
|
|
79
|
-
expect(classifyCompound("cat foo")?.category).toBe("read");
|
|
80
|
-
expect(classifyCompound("git status")).toBeUndefined();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("catches the chaining-dodge (|| && ;)", () => {
|
|
84
|
-
// seg1 `cat x 2>/dev/null` has a redirect (opaque per-segment); seg2
|
|
85
|
-
// `ls -la` is a clean stand-in — still nudged, as `ls`.
|
|
86
|
-
expect(classifyCompound("cat x 2>/dev/null || ls -la")?.category).toBe(
|
|
87
|
-
"ls",
|
|
88
|
-
);
|
|
89
|
-
// Pure clean read stand-in chained with another clean cmd.
|
|
90
|
-
expect(classifyCompound("cat x || ls -la")?.category).toBe("read");
|
|
91
|
-
expect(classifyCompound("ls -la && echo done")?.category).toBe("ls");
|
|
92
|
-
expect(classifyCompound("ls; pwd")?.category).toBe("ls");
|
|
93
|
-
expect(classifyCompound("true && grep foo bar.ts")?.category).toBe("grep");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("exempts a segment that feeds a pipe (legit producer)", () => {
|
|
97
|
-
expect(classifyCompound("cat x | jq .")).toBeUndefined();
|
|
98
|
-
expect(classifyCompound("grep foo a | head")).toBeUndefined();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("exempts redirects per-segment", () => {
|
|
102
|
-
expect(classifyCompound("grep x y > out")).toBeUndefined();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("exempts when no segment is a leading stand-in", () => {
|
|
106
|
-
expect(classifyCompound("git log | cat")).toBeUndefined();
|
|
107
|
-
expect(classifyCompound("npm test && npm run build")).toBeUndefined();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("empty / whitespace", () => {
|
|
111
|
-
expect(classifyCompound("")).toBeUndefined();
|
|
112
|
-
expect(classifyCompound(" ")).toBeUndefined();
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe("nudgeReason", () => {
|
|
117
|
-
test("active tool: point straight at it, no toolbox", () => {
|
|
118
|
-
const msg = nudgeReason(
|
|
119
|
-
"Searching file contents via bash grep/rg.",
|
|
120
|
-
"grep",
|
|
121
|
-
true,
|
|
122
|
-
);
|
|
123
|
-
expect(msg).toContain("Use `grep` instead");
|
|
124
|
-
expect(msg).toContain("function definitions");
|
|
125
|
-
expect(msg).not.toContain("toolbox");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("gated tool: route through toolbox enable, not a direct call", () => {
|
|
129
|
-
const msg = nudgeReason(
|
|
130
|
-
"Listing a directory via bash ls/tree.",
|
|
131
|
-
"ls",
|
|
132
|
-
false,
|
|
133
|
-
);
|
|
134
|
-
expect(msg).toContain('toolbox(action:"enable", name:"ls")');
|
|
135
|
-
expect(msg).toContain("prompt-hidden");
|
|
136
|
-
expect(msg).toContain("function definitions");
|
|
137
|
-
// Must NOT imply the tool is directly callable by name (it's prompt-hidden).
|
|
138
|
-
expect(msg).not.toContain("Use `ls` instead");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("gated find tool names itself in the enable hint", () => {
|
|
142
|
-
expect(
|
|
143
|
-
nudgeReason("Locating files via bash find/fd.", "find", false),
|
|
144
|
-
).toContain('toolbox(action:"enable", name:"find")');
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("is a single short line — no inventory dump, no newlines", () => {
|
|
148
|
-
const msg = nudgeReason(
|
|
149
|
-
"Listing a directory via bash ls/tree.",
|
|
150
|
-
"ls",
|
|
151
|
-
false,
|
|
152
|
-
);
|
|
153
|
-
expect(msg).not.toContain("\n");
|
|
154
|
-
expect(msg).not.toContain("Available tools");
|
|
155
|
-
expect(msg.length).toBeLessThan(400);
|
|
156
|
-
});
|
|
157
|
-
});
|
package/src/nudge/tools.ts
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tools-nudge.ts — nudge the model toward dedicated tools over raw bash
|
|
3
|
-
*
|
|
4
|
-
* Hooks `tool_call` for the built-in `bash` tool. When a bash command merely
|
|
5
|
-
* reimplements a first-class tool (read/ls/grep/find/edit/write), it blocks the
|
|
6
|
-
* call ONCE per command-category per session with a guidance reason. The model
|
|
7
|
-
* retries using the proper tool — improving token usage and enforcing stricter,
|
|
8
|
-
* more predictable behavior. After the first nudge for a category, subsequent
|
|
9
|
-
* bash calls in that category are allowed (no nag loop). Bash stays available
|
|
10
|
-
* for everything else (pipes, compound commands, real shell work).
|
|
11
|
-
*
|
|
12
|
-
* IMPORTANT: some target tools (e.g. `ls`, `find`) are GATED from the system
|
|
13
|
-
* prompt — registered but prompt-hidden until the model discovers them via
|
|
14
|
-
* toolbox. They ARE still in the function-calling definitions (always callable).
|
|
15
|
-
* The nudge checks `pi.getActiveTools()` first: if the tool is prompt-visible,
|
|
16
|
-
* recommend it directly; if it is gated, tell the model to enable it via toolbox
|
|
17
|
-
* but note that all tools are always callable via function definitions.
|
|
18
|
-
*
|
|
19
|
-
* The nudge is deliberately SURGICAL: it names only the single tool that maps
|
|
20
|
-
* to the command just blocked. It does NOT dump a full tool inventory — that
|
|
21
|
-
* floods the model with gated tools it can't call and is the opposite of the
|
|
22
|
-
* lazy-gate's purpose (a lean prompt). Point at the one right tool; don't list
|
|
23
|
-
* the menu.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import {
|
|
27
|
-
type ExtensionAPI,
|
|
28
|
-
isToolCallEventType,
|
|
29
|
-
} from "@earendil-works/pi-coding-agent";
|
|
30
|
-
|
|
31
|
-
/** Categories that map a raw shell command to a dedicated Pi tool. */
|
|
32
|
-
type Category = "read" | "ls" | "grep" | "find" | "edit";
|
|
33
|
-
|
|
34
|
-
interface Rule {
|
|
35
|
-
category: Category;
|
|
36
|
-
/** Match the *leading* command word(s). */
|
|
37
|
-
test: (cmd: string) => boolean;
|
|
38
|
-
tool: string;
|
|
39
|
-
reason: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** First bare word of a command segment (strips env-var prefixes like FOO=bar). */
|
|
43
|
-
const leadWord = (segment: string): string => {
|
|
44
|
-
const toks = segment.trim().split(/\s+/);
|
|
45
|
-
let i = 0;
|
|
46
|
-
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++;
|
|
47
|
-
return (toks[i] ?? "").replace(/^\\/, ""); // unalias e.g. \grep
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const RULES: Rule[] = [
|
|
51
|
-
{
|
|
52
|
-
category: "read",
|
|
53
|
-
test: (c) => /^(cat|head|tail|less|more|bat)$/.test(leadWord(c)),
|
|
54
|
-
tool: "read",
|
|
55
|
-
reason: "Reading a file via bash cat/head/tail.",
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
category: "ls",
|
|
59
|
-
test: (c) => /^(ls|tree|exa|eza)$/.test(leadWord(c)),
|
|
60
|
-
tool: "ls",
|
|
61
|
-
reason: "Listing a directory via bash ls/tree.",
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
category: "grep",
|
|
65
|
-
test: (c) => /^(grep|rg|ag|ack)$/.test(leadWord(c)),
|
|
66
|
-
tool: "grep",
|
|
67
|
-
reason: "Searching file contents via bash grep/rg.",
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
category: "find",
|
|
71
|
-
test: (c) => /^(find|fd|fdfind)$/.test(leadWord(c)),
|
|
72
|
-
tool: "find",
|
|
73
|
-
reason: "Locating files via bash find/fd.",
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
category: "edit",
|
|
77
|
-
test: (c) => /^sed$/.test(leadWord(c)) && /\s-i\b/.test(c),
|
|
78
|
-
tool: "edit",
|
|
79
|
-
reason: "Editing a file in place via bash `sed -i`.",
|
|
80
|
-
},
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Pick a rule only when the command is a *simple* single-command invocation
|
|
85
|
-
* standing in for a tool. Skip anything with pipes, redirects, command
|
|
86
|
-
* chaining, or subshells — those are legitimate shell work bash should handle.
|
|
87
|
-
*/
|
|
88
|
-
export function classify(command: string): Rule | undefined {
|
|
89
|
-
const cmd = command.trim();
|
|
90
|
-
if (!cmd) return undefined;
|
|
91
|
-
// Bail on compound / piped / redirected commands — real shell work.
|
|
92
|
-
if (/[|&;]|\$\(|`|<|>|\n/.test(cmd)) return undefined;
|
|
93
|
-
return RULES.find((r) => r.test(cmd));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* A segment of a compound command, paired with the operator that *follows* it.
|
|
98
|
-
* The trailing operator distinguishes a stand-in (`cat x ||`, `cat x ;`) from a
|
|
99
|
-
* genuine pipe producer (`cat x |`) whose output feeds a downstream consumer.
|
|
100
|
-
*/
|
|
101
|
-
interface Segment {
|
|
102
|
-
text: string;
|
|
103
|
-
/** Operator immediately after this segment: "|", ";", "&&", "||", "&", or "". */
|
|
104
|
-
followedBy: string;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Split a command line into segments on shell control operators, recording the
|
|
109
|
-
* operator that follows each segment. Subshells and redirects are NOT split —
|
|
110
|
-
* a segment containing `$(...)`, backticks, or a redirect is treated as opaque
|
|
111
|
-
* shell work and will fail the single-command `classify` check below.
|
|
112
|
-
*/
|
|
113
|
-
export function splitSegments(command: string): Segment[] {
|
|
114
|
-
const out: Segment[] = [];
|
|
115
|
-
const re = /(\|\||&&|[|;&\n])/g;
|
|
116
|
-
let last = 0;
|
|
117
|
-
let m: RegExpExecArray | null;
|
|
118
|
-
while ((m = re.exec(command)) !== null) {
|
|
119
|
-
out.push({ text: command.slice(last, m.index), followedBy: m[1] });
|
|
120
|
-
last = m.index + m[1].length;
|
|
121
|
-
}
|
|
122
|
-
out.push({ text: command.slice(last), followedBy: "" });
|
|
123
|
-
return out.filter((s) => s.text.trim().length > 0);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Classify a possibly-compound command. Returns the FIRST rule matching a
|
|
128
|
-
* segment that is a *pure tool stand-in*: a simple single-command invocation
|
|
129
|
-
* whose output is not piped into a downstream consumer.
|
|
130
|
-
*
|
|
131
|
-
* - `cat x || ls y` → nudge (read) — chaining-dodge, not real shell work.
|
|
132
|
-
* - `ls; pwd` → nudge (ls) — sequential stand-in alongside other cmd.
|
|
133
|
-
* - `cat x | jq .` → undefined — `cat` feeds a pipe; legitimate.
|
|
134
|
-
* - `grep x y > out` → undefined — redirect; opaque per-segment.
|
|
135
|
-
* - `git log | cat` → undefined — no segment is a leading stand-in.
|
|
136
|
-
*/
|
|
137
|
-
export function classifyCompound(command: string): Rule | undefined {
|
|
138
|
-
const cmd = command.trim();
|
|
139
|
-
if (!cmd) return undefined;
|
|
140
|
-
|
|
141
|
-
// Fast path: a truly simple command (no operators/subshell/redirect).
|
|
142
|
-
const simple = classify(cmd);
|
|
143
|
-
if (simple) return simple;
|
|
144
|
-
|
|
145
|
-
// Otherwise inspect each segment of the compound command. A segment is a
|
|
146
|
-
// legitimate part of a pipeline — not a stand-in — when it either feeds a
|
|
147
|
-
// pipe (producer) or is fed by one (consumer). Only segments outside any
|
|
148
|
-
// pipe relationship are candidate tool stand-ins.
|
|
149
|
-
const segs = splitSegments(cmd);
|
|
150
|
-
for (let i = 0; i < segs.length; i++) {
|
|
151
|
-
const seg = segs[i];
|
|
152
|
-
if (seg.followedBy === "|") continue; // producer feeding a pipe
|
|
153
|
-
if (i > 0 && segs[i - 1].followedBy === "|") continue; // consumer of a pipe
|
|
154
|
-
// Per-segment we reuse the strict single-command classifier, which itself
|
|
155
|
-
// rejects redirects/subshells/nested operators inside the segment.
|
|
156
|
-
const rule = classify(seg.text);
|
|
157
|
-
if (rule) return rule;
|
|
158
|
-
}
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Build the one-line guidance for a nudge. When the target tool is active, point
|
|
164
|
-
* straight at it. When it's gated out, point at toolbox to enable it — never
|
|
165
|
-
* imply a gated tool is callable now. Pure + exported for unit testing.
|
|
166
|
-
*/
|
|
167
|
-
export function nudgeReason(
|
|
168
|
-
baseReason: string,
|
|
169
|
-
tool: string,
|
|
170
|
-
toolActive: boolean,
|
|
171
|
-
): string {
|
|
172
|
-
const how = toolActive
|
|
173
|
-
? `Use \`${tool}\` instead — it's in the function definitions, call it directly.`
|
|
174
|
-
: `\`${tool}\` is prompt-hidden — enable it via toolbox(action:"enable", name:"${tool}") to make it known, then call it; bash is fine until then. (All tools are always callable via function definitions.)`;
|
|
175
|
-
return `${baseReason} ${how} (Fires once per category; bash stays available for pipes & compound commands.)`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export default function registerToolsNudge(pi: ExtensionAPI): void {
|
|
179
|
-
// Categories already nudged this session — block once, then allow.
|
|
180
|
-
const nudged = new Set<Category>();
|
|
181
|
-
|
|
182
|
-
/** Is `name` in the host's current active set (i.e. callable right now)? */
|
|
183
|
-
function isActive(name: string): boolean {
|
|
184
|
-
try {
|
|
185
|
-
// getActiveTools() returns string[] (tool NAMES), not ToolInfo[].
|
|
186
|
-
return (pi.getActiveTools() ?? []).includes(name);
|
|
187
|
-
} catch {
|
|
188
|
-
// If we can't tell, assume active so we don't suppress a valid nudge.
|
|
189
|
-
return true;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
pi.on("tool_call", async (event) => {
|
|
194
|
-
if (!isToolCallEventType("bash", event)) return;
|
|
195
|
-
const command = event.input.command;
|
|
196
|
-
if (typeof command !== "string") return;
|
|
197
|
-
|
|
198
|
-
const rule = classifyCompound(command);
|
|
199
|
-
if (!rule) return;
|
|
200
|
-
if (nudged.has(rule.category)) return; // already taught — allow.
|
|
201
|
-
|
|
202
|
-
nudged.add(rule.category);
|
|
203
|
-
|
|
204
|
-
// One short, targeted line: name only the tool that maps to THIS command,
|
|
205
|
-
// and route through toolbox when it's gated rather than implying it's
|
|
206
|
-
// callable now. No full-inventory dump — that's what confused the model.
|
|
207
|
-
return {
|
|
208
|
-
block: true,
|
|
209
|
-
reason: nudgeReason(rule.reason, rule.tool, isActive(rule.tool)),
|
|
210
|
-
};
|
|
211
|
-
});
|
|
212
|
-
}
|