@xynogen/pix-core 0.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/LICENSE +21 -0
- package/README.md +36 -0
- package/package.json +42 -0
- package/skills/ask-user/SKILL.md +48 -0
- package/src/commands/clear/clear.ts +32 -0
- package/src/commands/copy-all/copy-all.ts +89 -0
- package/src/commands/diff/diff.ts +138 -0
- package/src/commands/lg/lg.ts +32 -0
- package/src/commands/models/models.test.ts +95 -0
- package/src/commands/models/models.ts +362 -0
- package/src/commands/tools.test.ts +15 -0
- package/src/commands/update/update.test.ts +112 -0
- package/src/commands/update/update.ts +271 -0
- package/src/commands/yeet/yeet.ts +29 -0
- package/src/index.ts +49 -0
- package/src/lib/data.ts +241 -0
- package/src/nudge/capability.test.ts +198 -0
- package/src/nudge/capability.ts +152 -0
- package/src/nudge/index.ts +17 -0
- package/src/nudge/tools.test.ts +145 -0
- package/src/nudge/tools.ts +214 -0
- package/src/tool/ask/ask.test.ts +232 -0
- package/src/tool/ask/ask.ts +1081 -0
- package/src/tool/ask/single-select-layout.test.ts +108 -0
- package/src/tool/ask/single-select-layout.ts +203 -0
- package/src/tool/todo/todo.test.ts +602 -0
- package/src/tool/todo/todo.ts +194 -0
- package/src/tool/toolbox/toolbox.test.ts +312 -0
- package/src/tool/toolbox/toolbox.ts +563 -0
- package/src/ui/diagnostics.ts +148 -0
- package/src/ui/footer.ts +513 -0
- package/src/ui/welcome.test.ts +124 -0
- package/src/ui/welcome.ts +369 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildOrientation,
|
|
4
|
+
CAPABILITY_REMINDER,
|
|
5
|
+
countInvocableSkills,
|
|
6
|
+
partitionTools,
|
|
7
|
+
} from "./capability.ts";
|
|
8
|
+
|
|
9
|
+
// Minimal Skill-shaped fixtures (only fields the builder reads).
|
|
10
|
+
const skill = (
|
|
11
|
+
name: string,
|
|
12
|
+
description: string,
|
|
13
|
+
disableModelInvocation = false,
|
|
14
|
+
) =>
|
|
15
|
+
({
|
|
16
|
+
name,
|
|
17
|
+
description,
|
|
18
|
+
disableModelInvocation,
|
|
19
|
+
filePath: "",
|
|
20
|
+
baseDir: "",
|
|
21
|
+
// biome-ignore lint: test fixture
|
|
22
|
+
sourceInfo: {} as never,
|
|
23
|
+
}) as never;
|
|
24
|
+
|
|
25
|
+
// Minimal ToolInfo-shaped fixture.
|
|
26
|
+
const tool = (name: string, source: string) =>
|
|
27
|
+
({
|
|
28
|
+
name,
|
|
29
|
+
description: `${name} desc.`,
|
|
30
|
+
parameters: {},
|
|
31
|
+
sourceInfo: { source, path: "", scope: "user", origin: "package" },
|
|
32
|
+
// biome-ignore lint: test fixture
|
|
33
|
+
}) as never;
|
|
34
|
+
|
|
35
|
+
describe("CAPABILITY_REMINDER", () => {
|
|
36
|
+
test("is terse — fires every turn, must stay cheap", () => {
|
|
37
|
+
expect(CAPABILITY_REMINDER.split(/\s+/).length).toBeLessThanOrEqual(28);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("names the core capability surfaces", () => {
|
|
41
|
+
for (const cap of ["skill", "tool", "MCP", "web", "user"]) {
|
|
42
|
+
expect(CAPABILITY_REMINDER).toContain(cap);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("steers away from improvising", () => {
|
|
47
|
+
expect(CAPABILITY_REMINDER.toLowerCase()).toContain("improvis");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("points at the toolbox tool for discovery", () => {
|
|
51
|
+
expect(CAPABILITY_REMINDER).toContain("toolbox");
|
|
52
|
+
expect(CAPABILITY_REMINDER).toContain("function definitions");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("countInvocableSkills", () => {
|
|
57
|
+
test("zero for undefined / empty", () => {
|
|
58
|
+
expect(countInvocableSkills(undefined)).toBe(0);
|
|
59
|
+
expect(countInvocableSkills([])).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("excludes user-only skills", () => {
|
|
63
|
+
const n = countInvocableSkills([
|
|
64
|
+
skill("a", "."),
|
|
65
|
+
skill("b", ".", true),
|
|
66
|
+
skill("c", "."),
|
|
67
|
+
]);
|
|
68
|
+
expect(n).toBe(2);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("partitionTools", () => {
|
|
73
|
+
test("splits MCP-sourced from other tools by source", () => {
|
|
74
|
+
const { mcp, other } = partitionTools([
|
|
75
|
+
tool("read", "builtin"),
|
|
76
|
+
tool("context7-docs", "mcp:context7"),
|
|
77
|
+
tool("notion-search", "MCP server notion"),
|
|
78
|
+
tool("grep", "extension:pix-core"),
|
|
79
|
+
]);
|
|
80
|
+
expect(mcp).toBe(2);
|
|
81
|
+
expect(other).toBe(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("handles undefined", () => {
|
|
85
|
+
expect(partitionTools(undefined)).toEqual({
|
|
86
|
+
mcp: 0,
|
|
87
|
+
other: 0,
|
|
88
|
+
active: 0,
|
|
89
|
+
gated: 0,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("without an active set, every tool counts as active (gated 0)", () => {
|
|
94
|
+
const { active, gated } = partitionTools([
|
|
95
|
+
tool("read", "builtin"),
|
|
96
|
+
tool("ast_grep_search", "builtin"),
|
|
97
|
+
]);
|
|
98
|
+
expect(active).toBe(2);
|
|
99
|
+
expect(gated).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("with an active set, splits active vs gated", () => {
|
|
103
|
+
const { active, gated } = partitionTools(
|
|
104
|
+
[
|
|
105
|
+
tool("read", "builtin"),
|
|
106
|
+
tool("grep", "builtin"),
|
|
107
|
+
tool("ast_grep_search", "builtin"),
|
|
108
|
+
tool("ctx_search", "builtin"),
|
|
109
|
+
],
|
|
110
|
+
["read", "grep"],
|
|
111
|
+
);
|
|
112
|
+
expect(active).toBe(2);
|
|
113
|
+
expect(gated).toBe(2);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("buildOrientation", () => {
|
|
118
|
+
test("summarizes counts of tools, MCP tools, and skills", () => {
|
|
119
|
+
const out = buildOrientation(
|
|
120
|
+
[tool("read", "builtin"), tool("ctx", "mcp:context7")],
|
|
121
|
+
[skill("commit", "Commit changes."), skill("plan", "Plan work.")],
|
|
122
|
+
);
|
|
123
|
+
expect(out).toContain("1 tool");
|
|
124
|
+
expect(out).toContain("1 MCP tool");
|
|
125
|
+
expect(out).toContain("2 skills");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("explains how to explore via the toolbox tool", () => {
|
|
129
|
+
const out = buildOrientation([tool("read", "builtin")], []);
|
|
130
|
+
expect(out).toContain("toolbox(query");
|
|
131
|
+
expect(out.toLowerCase()).toMatch(/fuzzy|search|discover/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("calls out gated tools and points at toolbox to enable them", () => {
|
|
135
|
+
const out = buildOrientation(
|
|
136
|
+
[
|
|
137
|
+
tool("read", "builtin"),
|
|
138
|
+
tool("grep", "builtin"),
|
|
139
|
+
tool("ast_grep_search", "builtin"),
|
|
140
|
+
tool("ctx_search", "builtin"),
|
|
141
|
+
],
|
|
142
|
+
[],
|
|
143
|
+
["read", "grep"], // active set: 2 gated out
|
|
144
|
+
);
|
|
145
|
+
expect(out).toContain("2 are gated");
|
|
146
|
+
expect(out).toContain("enable via toolbox");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("singular phrasing when exactly one tool is gated", () => {
|
|
150
|
+
const out = buildOrientation(
|
|
151
|
+
[tool("read", "builtin"), tool("ast_grep_search", "builtin")],
|
|
152
|
+
[],
|
|
153
|
+
["read"],
|
|
154
|
+
);
|
|
155
|
+
expect(out).toContain("1 is gated");
|
|
156
|
+
expect(out).toContain("enable via toolbox");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("no gate line when nothing is gated", () => {
|
|
160
|
+
const out = buildOrientation(
|
|
161
|
+
[tool("read", "builtin"), tool("grep", "builtin")],
|
|
162
|
+
[],
|
|
163
|
+
["read", "grep"],
|
|
164
|
+
);
|
|
165
|
+
expect(out).not.toContain("gated out of the prompt");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("no gate line when active set is unknown", () => {
|
|
169
|
+
const out = buildOrientation(
|
|
170
|
+
[tool("read", "builtin"), tool("ast_grep_search", "builtin")],
|
|
171
|
+
[],
|
|
172
|
+
);
|
|
173
|
+
expect(out).not.toContain("gated out of the prompt");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("lists invocable skill names, sorted, excluding user-only", () => {
|
|
177
|
+
const out = buildOrientation(
|
|
178
|
+
[],
|
|
179
|
+
[
|
|
180
|
+
skill("zebra", "z."),
|
|
181
|
+
skill("alpha", "a."),
|
|
182
|
+
skill("hidden", "h.", true),
|
|
183
|
+
],
|
|
184
|
+
);
|
|
185
|
+
expect(out).toContain("Skills: alpha, zebra.");
|
|
186
|
+
expect(out).not.toContain("hidden");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("omits the skills line when no invocable skills", () => {
|
|
190
|
+
const out = buildOrientation([tool("read", "builtin")], [skill("x", ".", true)]);
|
|
191
|
+
expect(out).not.toContain("Skills:");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("steers away from improvising", () => {
|
|
195
|
+
const out = buildOrientation([tool("read", "builtin")], []);
|
|
196
|
+
expect(out.toLowerCase()).toContain("improvis");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
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`, emitted as ONE hidden message (`display: false`).
|
|
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 on demand via the `toolbox` tool. We deliberately
|
|
13
|
+
* do NOT dump the whole inventory every turn — that is what `toolbox`
|
|
14
|
+
* (fuzzy search over names + descriptions) is for.
|
|
15
|
+
* 2. EVERY subsequent prompt — the terse one-line CAPABILITY_REMINDER, a
|
|
16
|
+
* cheap (~40 tok) reinforcement that points back at toolbox.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
BuildSystemPromptOptions,
|
|
21
|
+
ExtensionAPI,
|
|
22
|
+
ToolInfo,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
type LoadedSkill = NonNullable<BuildSystemPromptOptions["skills"]>[number];
|
|
26
|
+
|
|
27
|
+
/** The standing per-turn reminder. Kept terse — it ships on every turn. */
|
|
28
|
+
export const CAPABILITY_REMINDER =
|
|
29
|
+
"Reminder — always check your knowledge resources " +
|
|
30
|
+
"(skills/tools/MCP/web/user) before improvising. " +
|
|
31
|
+
"`toolbox(query)` finds the right one; enable gated tools via toolbox. " +
|
|
32
|
+
"All tools are always callable via function definitions.";
|
|
33
|
+
|
|
34
|
+
/** Count model-invocable skills (excludes user-only /skill:name entries). */
|
|
35
|
+
export function countInvocableSkills(skills: LoadedSkill[] | undefined): number {
|
|
36
|
+
return (skills ?? []).filter((s) => !s.disableModelInvocation).length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split tools by source (MCP vs other) and, when an `active` set is supplied,
|
|
41
|
+
* by gate status (active = schema in the prompt now; gated = registered but
|
|
42
|
+
* gated out, reachable only via toolbox). Without `active`, everything counts
|
|
43
|
+
* as active (gated = 0) so callers that don't track the gate are unaffected.
|
|
44
|
+
*/
|
|
45
|
+
export function partitionTools(
|
|
46
|
+
tools: ToolInfo[] | undefined,
|
|
47
|
+
active?: Iterable<string>,
|
|
48
|
+
): {
|
|
49
|
+
mcp: number;
|
|
50
|
+
other: number;
|
|
51
|
+
active: number;
|
|
52
|
+
gated: number;
|
|
53
|
+
} {
|
|
54
|
+
const activeSet = active ? new Set(active) : undefined;
|
|
55
|
+
let mcp = 0;
|
|
56
|
+
let other = 0;
|
|
57
|
+
let activeCount = 0;
|
|
58
|
+
let gated = 0;
|
|
59
|
+
for (const t of tools ?? []) {
|
|
60
|
+
if (/mcp/i.test(t.sourceInfo?.source ?? "")) mcp++;
|
|
61
|
+
else other++;
|
|
62
|
+
// A tool is "gated" only when we have an active set and it's absent from it.
|
|
63
|
+
if (activeSet && !activeSet.has(t.name)) gated++;
|
|
64
|
+
else activeCount++;
|
|
65
|
+
}
|
|
66
|
+
return { mcp, other, active: activeCount, gated };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the one-time orientation block shown on the FIRST prompt. Describes the
|
|
71
|
+
* shape of the toolbelt (counts) and how to explore it via `toolbox`, plus the
|
|
72
|
+
* sorted skill names so the model knows what exists by name without a dump of
|
|
73
|
+
* descriptions (those live in the system prompt / are searchable via toolbox).
|
|
74
|
+
*/
|
|
75
|
+
export function buildOrientation(
|
|
76
|
+
tools: ToolInfo[] | undefined,
|
|
77
|
+
skills: LoadedSkill[] | undefined,
|
|
78
|
+
/** Currently-active tool names; absent ⇒ gate not tracked (all treated active). */
|
|
79
|
+
activeToolNames?: Iterable<string>,
|
|
80
|
+
): string {
|
|
81
|
+
const { mcp, other, gated } = partitionTools(tools, activeToolNames);
|
|
82
|
+
const skillNames = (skills ?? [])
|
|
83
|
+
.filter((s) => !s.disableModelInvocation)
|
|
84
|
+
.map((s) => s.name)
|
|
85
|
+
.sort();
|
|
86
|
+
|
|
87
|
+
const inventory: string[] = [];
|
|
88
|
+
if (other) inventory.push(`${other} tool${other === 1 ? "" : "s"}`);
|
|
89
|
+
if (mcp) inventory.push(`${mcp} MCP tool${mcp === 1 ? "" : "s"}`);
|
|
90
|
+
if (skillNames.length)
|
|
91
|
+
inventory.push(
|
|
92
|
+
`${skillNames.length} skill${skillNames.length === 1 ? "" : "s"}`,
|
|
93
|
+
);
|
|
94
|
+
const summary = inventory.length ? inventory.join(", ") : "your toolbelt";
|
|
95
|
+
|
|
96
|
+
// Lead with the gate — tools not described in the prompt are still callable
|
|
97
|
+
// via function definitions, but the model doesn't know about them.
|
|
98
|
+
const gateLine = gated
|
|
99
|
+
? `${gated} ${gated === 1 ? "is" : "are"} gated (kept out of the prompt to save context) — enable via toolbox to discover. All tools are always callable via function definitions.`
|
|
100
|
+
: undefined;
|
|
101
|
+
|
|
102
|
+
const lines = [
|
|
103
|
+
`Toolbelt: ${summary}, plus LSP, MCP (context7 docs), web search/fetch, and the user.`,
|
|
104
|
+
];
|
|
105
|
+
if (gateLine) lines.push(gateLine);
|
|
106
|
+
lines.push(
|
|
107
|
+
"Don't improvise what a capability covers — ask the user, search the web, or pull docs first.",
|
|
108
|
+
"toolbox is the gateway: `toolbox(query)` searches tools/MCP/skills/commands; `toolbox(action:'enable'|'disable', name)` toggles a gated tool prompt-visible or gated. Empty query lists all.",
|
|
109
|
+
);
|
|
110
|
+
if (skillNames.length) {
|
|
111
|
+
lines.push(`Skills: ${skillNames.join(", ")}.`);
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default function registerCapabilityNudge(pi: ExtensionAPI): void {
|
|
117
|
+
let orientationSent = false;
|
|
118
|
+
|
|
119
|
+
pi.on("before_agent_start", async (event) => {
|
|
120
|
+
const skills = event.systemPromptOptions?.skills;
|
|
121
|
+
|
|
122
|
+
let content: string;
|
|
123
|
+
if (!orientationSent) {
|
|
124
|
+
orientationSent = true;
|
|
125
|
+
let tools: ToolInfo[] | undefined;
|
|
126
|
+
try {
|
|
127
|
+
tools = pi.getAllTools();
|
|
128
|
+
} catch {
|
|
129
|
+
tools = undefined;
|
|
130
|
+
}
|
|
131
|
+
// Active set lets the orientation distinguish callable-now from gated.
|
|
132
|
+
// getActiveTools() returns string[] (tool NAMES) — not ToolInfo[]. Use as-is.
|
|
133
|
+
let activeToolNames: string[] | undefined;
|
|
134
|
+
try {
|
|
135
|
+
activeToolNames = pi.getActiveTools();
|
|
136
|
+
} catch {
|
|
137
|
+
activeToolNames = undefined;
|
|
138
|
+
}
|
|
139
|
+
content = buildOrientation(tools, skills, activeToolNames);
|
|
140
|
+
} else {
|
|
141
|
+
content = CAPABILITY_REMINDER;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
message: {
|
|
146
|
+
customType: "pix-capability-nudge",
|
|
147
|
+
content,
|
|
148
|
+
display: false,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 registerToolsNudge from "./tools.ts";
|
|
12
|
+
import registerCapabilityNudge from "./capability.ts";
|
|
13
|
+
|
|
14
|
+
export default function registerNudges(pi: ExtensionAPI): void {
|
|
15
|
+
registerToolsNudge(pi);
|
|
16
|
+
registerCapabilityNudge(pi);
|
|
17
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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("Searching file contents via bash grep/rg.", "grep", true);
|
|
119
|
+
expect(msg).toContain("Use `grep` instead");
|
|
120
|
+
expect(msg).toContain("function definitions");
|
|
121
|
+
expect(msg).not.toContain("toolbox");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("gated tool: route through toolbox enable, not a direct call", () => {
|
|
125
|
+
const msg = nudgeReason("Listing a directory via bash ls/tree.", "ls", false);
|
|
126
|
+
expect(msg).toContain('toolbox(action:"enable", name:"ls")');
|
|
127
|
+
expect(msg).toContain("prompt-hidden");
|
|
128
|
+
expect(msg).toContain("function definitions");
|
|
129
|
+
// Must NOT imply the tool is directly callable by name (it's prompt-hidden).
|
|
130
|
+
expect(msg).not.toContain("Use `ls` instead");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("gated find tool names itself in the enable hint", () => {
|
|
134
|
+
expect(nudgeReason("Locating files via bash find/fd.", "find", false)).toContain(
|
|
135
|
+
'toolbox(action:"enable", name:"find")',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("is a single short line — no inventory dump, no newlines", () => {
|
|
140
|
+
const msg = nudgeReason("Listing a directory via bash ls/tree.", "ls", false);
|
|
141
|
+
expect(msg).not.toContain("\n");
|
|
142
|
+
expect(msg).not.toContain("Available tools");
|
|
143
|
+
expect(msg.length).toBeLessThan(400);
|
|
144
|
+
});
|
|
145
|
+
});
|