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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
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",
@@ -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;
@@ -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
- * respecting double-quoted strings.
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 inQuote = false;
63
+ let quoteChar: '"' | "'" | null = null;
62
64
 
63
65
  for (const ch of raw) {
64
- if (ch === '"') {
65
- inQuote = !inQuote;
66
- } else if (!inQuote && /\s/.test(ch)) {
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 = tokenize(rawArgs);
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 = tokenize(rawArgs);
193
+ const tokens = tokenizeForSkill(rawArgs, skill);
127
194
  const missing: string[] = [];
128
195
  skill.arguments.forEach((argDef, i) => {
129
196
  if (!argDef.required) return;