botholomew 0.11.3 → 0.11.5
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/package.json +1 -1
- package/src/chat/agent.ts +4 -1
- package/src/skills/commands.ts +58 -1
- package/src/skills/parser.ts +75 -8
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
|
|
30
30
|
registerAllTools();
|
|
31
31
|
|
|
32
|
-
/** Tools available in chat mode — no worker terminal tools, no destructive file tools */
|
|
32
|
+
/** Tools available in chat mode — no worker terminal tools (complete/fail/wait), no bulk-destructive file tools (delete, copy/move, dir ops) */
|
|
33
33
|
const CHAT_TOOL_NAMES = new Set([
|
|
34
34
|
"create_task",
|
|
35
35
|
"list_tasks",
|
|
@@ -39,6 +39,9 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
39
39
|
"context_refresh",
|
|
40
40
|
"context_tree",
|
|
41
41
|
"context_list_drives",
|
|
42
|
+
"context_read",
|
|
43
|
+
"context_write",
|
|
44
|
+
"context_edit",
|
|
42
45
|
"search_grep",
|
|
43
46
|
"search_semantic",
|
|
44
47
|
"list_threads",
|
package/src/skills/commands.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SkillDefinition } from "./parser.ts";
|
|
2
|
-
import { renderSkill, validateSkillArgs } from "./parser.ts";
|
|
2
|
+
import { renderSkill, tokenizeForSkill, validateSkillArgs } from "./parser.ts";
|
|
3
3
|
|
|
4
4
|
export interface SlashCommand {
|
|
5
5
|
name: string;
|
|
@@ -40,6 +40,58 @@ export function formatSkillUsage(skill: SkillDefinition): string {
|
|
|
40
40
|
return parts.join(" ");
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Detect when a multi-arg skill received unquoted whitespace-separated
|
|
45
|
+
* input that the greedy-last splitter has packed into the final slot.
|
|
46
|
+
* The user almost certainly intended one of the words to belong to a
|
|
47
|
+
* different slot (or the whole thing to be a single argument), so we
|
|
48
|
+
* surface a parse breakdown instead of silently committing to one
|
|
49
|
+
* interpretation.
|
|
50
|
+
*
|
|
51
|
+
* Returns null when the input is unambiguous and may proceed.
|
|
52
|
+
*/
|
|
53
|
+
export function detectAmbiguousSplit(
|
|
54
|
+
skill: SkillDefinition,
|
|
55
|
+
rawArgs: string,
|
|
56
|
+
): { tokens: string[] } | null {
|
|
57
|
+
if (skill.arguments.length < 2) return null;
|
|
58
|
+
if (rawArgs.includes('"') || rawArgs.includes("'")) return null;
|
|
59
|
+
const tokens = tokenizeForSkill(rawArgs, skill);
|
|
60
|
+
const last = tokens[tokens.length - 1];
|
|
61
|
+
if (!last || !/\s/.test(last)) return null;
|
|
62
|
+
return { tokens };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatAmbiguityHint(skill: SkillDefinition, tokens: string[]): string {
|
|
66
|
+
const slots: string[] = [];
|
|
67
|
+
const nameWidth = skill.arguments.reduce(
|
|
68
|
+
(m, a) => Math.max(m, a.name.length),
|
|
69
|
+
0,
|
|
70
|
+
);
|
|
71
|
+
skill.arguments.forEach((argDef, i) => {
|
|
72
|
+
const value =
|
|
73
|
+
tokens[i] !== undefined
|
|
74
|
+
? `"${tokens[i]}"`
|
|
75
|
+
: argDef.default !== undefined
|
|
76
|
+
? `"${argDef.default}" (default)`
|
|
77
|
+
: "(unset)";
|
|
78
|
+
slots.push(` ${argDef.name.padEnd(nameWidth)} = ${value}`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const firstWord = tokens[0] ?? "";
|
|
82
|
+
const restPreview = tokens.slice(1).join(" ");
|
|
83
|
+
const fullPreview = [firstWord, restPreview].filter(Boolean).join(" ");
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
`/${skill.name}: ambiguous input. Parsed as:`,
|
|
87
|
+
...slots,
|
|
88
|
+
"",
|
|
89
|
+
"Quote the multi-word argument to confirm, e.g.:",
|
|
90
|
+
` /${skill.name} "${fullPreview}"`,
|
|
91
|
+
` /${skill.name} '${firstWord}' '${restPreview}'`,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
43
95
|
/**
|
|
44
96
|
* Handle a slash-command input. Returns true if the command was consumed
|
|
45
97
|
* (recognized or errored), false if it should fall through.
|
|
@@ -96,6 +148,11 @@ export function handleSlashCommand(
|
|
|
96
148
|
);
|
|
97
149
|
return true;
|
|
98
150
|
}
|
|
151
|
+
const ambiguous = detectAmbiguousSplit(skill, rawArgs);
|
|
152
|
+
if (ambiguous) {
|
|
153
|
+
ctx.addSystemMessage(formatAmbiguityHint(skill, ambiguous.tokens));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
99
156
|
const rendered = renderSkill(skill, rawArgs);
|
|
100
157
|
ctx.queueUserMessage(rendered, { display: input });
|
|
101
158
|
return true;
|
package/src/skills/parser.ts
CHANGED
|
@@ -52,18 +52,22 @@ export function parseSkillFile(raw: string, filePath: string): SkillDefinition {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Split a raw argument string into positional tokens,
|
|
56
|
-
*
|
|
55
|
+
* Split a raw argument string into positional tokens, respecting both
|
|
56
|
+
* single- and double-quoted strings. A closing quote must match the
|
|
57
|
+
* opening quote; the other quote character is treated as a literal
|
|
58
|
+
* inside the run.
|
|
57
59
|
*/
|
|
58
60
|
export function tokenize(raw: string): string[] {
|
|
59
61
|
const tokens: string[] = [];
|
|
60
62
|
let current = "";
|
|
61
|
-
let
|
|
63
|
+
let quoteChar: '"' | "'" | null = null;
|
|
62
64
|
|
|
63
65
|
for (const ch of raw) {
|
|
64
|
-
if (ch === '"') {
|
|
65
|
-
|
|
66
|
-
} else if (
|
|
66
|
+
if (quoteChar === null && (ch === '"' || ch === "'")) {
|
|
67
|
+
quoteChar = ch;
|
|
68
|
+
} else if (quoteChar !== null && ch === quoteChar) {
|
|
69
|
+
quoteChar = null;
|
|
70
|
+
} else if (quoteChar === null && /\s/.test(ch)) {
|
|
67
71
|
if (current) {
|
|
68
72
|
tokens.push(current);
|
|
69
73
|
current = "";
|
|
@@ -77,12 +81,75 @@ export function tokenize(raw: string): string[] {
|
|
|
77
81
|
return tokens;
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Schema-aware tokenizer used by skill rendering. When a skill declares
|
|
86
|
+
* N >= 1 positional arguments, the first N - 1 tokens are split with
|
|
87
|
+
* `tokenize()` and the **last** token captures the entire remaining
|
|
88
|
+
* input verbatim (with surrounding whitespace trimmed and a single
|
|
89
|
+
* surrounding pair of matched quotes stripped). This makes the common
|
|
90
|
+
* case of an unquoted multi-word final argument "just work" — e.g.
|
|
91
|
+
* `/write-as-evan why are avocados good?` for a single-arg skill puts
|
|
92
|
+
* the whole sentence into `$1`.
|
|
93
|
+
*
|
|
94
|
+
* When N === 0 (no declared arguments), behaves exactly like
|
|
95
|
+
* `tokenize()`.
|
|
96
|
+
*/
|
|
97
|
+
export function tokenizeForSkill(
|
|
98
|
+
raw: string,
|
|
99
|
+
skill: SkillDefinition,
|
|
100
|
+
): string[] {
|
|
101
|
+
const n = skill.arguments.length;
|
|
102
|
+
if (n === 0) return tokenize(raw);
|
|
103
|
+
|
|
104
|
+
const tokens: string[] = [];
|
|
105
|
+
let current = "";
|
|
106
|
+
let quoteChar: '"' | "'" | null = null;
|
|
107
|
+
let i = 0;
|
|
108
|
+
|
|
109
|
+
for (; i < raw.length && tokens.length < n - 1; i++) {
|
|
110
|
+
const ch = raw[i] as string;
|
|
111
|
+
if (quoteChar === null && (ch === '"' || ch === "'")) {
|
|
112
|
+
quoteChar = ch;
|
|
113
|
+
} else if (quoteChar !== null && ch === quoteChar) {
|
|
114
|
+
quoteChar = null;
|
|
115
|
+
} else if (quoteChar === null && /\s/.test(ch)) {
|
|
116
|
+
if (current) {
|
|
117
|
+
tokens.push(current);
|
|
118
|
+
current = "";
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
current += ch;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Flush any in-progress token if we hit the N-1 cap mid-run.
|
|
126
|
+
if (current) {
|
|
127
|
+
tokens.push(current);
|
|
128
|
+
current = "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let remainder = raw.slice(i).trim();
|
|
132
|
+
if (remainder.length >= 2) {
|
|
133
|
+
const first = remainder[0];
|
|
134
|
+
const last = remainder[remainder.length - 1];
|
|
135
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
136
|
+
// Strip surrounding quotes only when the entire remainder is a
|
|
137
|
+
// single quoted string with no interior unescaped same-quote.
|
|
138
|
+
const inner = remainder.slice(1, -1);
|
|
139
|
+
if (!inner.includes(first)) remainder = inner;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (remainder.length > 0) tokens.push(remainder);
|
|
143
|
+
|
|
144
|
+
return tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
80
147
|
function escapeRegex(s: string): string {
|
|
81
148
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
82
149
|
}
|
|
83
150
|
|
|
84
151
|
export function renderSkill(skill: SkillDefinition, rawArgs: string): string {
|
|
85
|
-
const tokens =
|
|
152
|
+
const tokens = tokenizeForSkill(rawArgs, skill);
|
|
86
153
|
let result = skill.body;
|
|
87
154
|
|
|
88
155
|
// Replace $<argName> placeholders first, longest names first so a `$start`
|
|
@@ -123,7 +190,7 @@ export function validateSkillArgs(
|
|
|
123
190
|
skill: SkillDefinition,
|
|
124
191
|
rawArgs: string,
|
|
125
192
|
): { missing: string[] } {
|
|
126
|
-
const tokens =
|
|
193
|
+
const tokens = tokenizeForSkill(rawArgs, skill);
|
|
127
194
|
const missing: string[] = [];
|
|
128
195
|
skill.arguments.forEach((argDef, i) => {
|
|
129
196
|
if (!argDef.required) return;
|