bone-agent 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.
@@ -0,0 +1,164 @@
1
+ bone.register_command("memory", {
2
+ description = "Incremental memory builder. Processes all conversations since last run and updates memory.md.",
3
+ handler = function(args, ctx)
4
+ local bone_dir = ctx.config_dir
5
+ local db = bone_dir .. "/data/conversations.db"
6
+ local state_file = bone_dir .. "/memory.last_run"
7
+
8
+ -- Check if database exists
9
+ if not ctx.fs.is_file(db) then
10
+ return [=[You are a memory builder running in "dream" mode — processing conversations that happened since your last run.
11
+
12
+ ## Context
13
+ No conversation database found. Nothing to process.
14
+
15
+ ## Your task
16
+ 1. Read the current memory.md from the bone config directory. If it doesn't exist, start fresh.
17
+ 2. Review each conversation above for user preferences, patterns, and context.
18
+ 3. Write an updated memory.md using write_file or edit_file.
19
+ 4. After updating memory.md (or deciding no changes are needed), write the value of NEXT_RUN (shown above) to `$HOME/.bone-rust/memory.last_run`. This advances the checkpoint so processed conversations aren't re-processed. Only do this last.
20
+
21
+ ## Rules
22
+ - Only add preferences clearly demonstrated (seen 2+ times across conversations), not one-off remarks.
23
+ - Remove anything contradicted by newer conversations.
24
+ - Keep the file under 400 tokens. Merge, compress, and drop lower-priority items to fit. Prefer short bullet points over prose. When the file exceeds this limit, consolidate by merging similar items and dropping the least important entries until it fits.
25
+ - Start the file with a metadata line: <!-- last_updated: YYYY-MM-DD -->
26
+ - Use these markdown sections (drop empty ones, add relevant ones):
27
+ - Communication — how the user likes to communicate, verbosity preferences, response format preferences
28
+ - Coding Style — language preferences, patterns, naming conventions, architecture tastes
29
+ - Tools & Workflow — preferred tools, workflows, development habits
30
+ - Dislikes — things the user consistently avoids or objects to
31
+ - Do NOT include project-specific context, task details, or one-off requirements. This file captures general preferences and habits, not what the user is working on right now.
32
+ - If no meaningful changes are needed, leave memory.md as-is and say "No changes."
33
+ - Output a brief summary of what you added, changed, or removed (or "No changes.").]=]
34
+ end
35
+
36
+ -- Read last run timestamp or default to epoch
37
+ local since = "1970-01-01T00:00:00Z"
38
+ if ctx.fs.is_file(state_file) then
39
+ local ok, state_content = pcall(ctx.read_file, state_file)
40
+ if ok and state_content then
41
+ since = state_content:gsub("%s+", "")
42
+ end
43
+ end
44
+
45
+ -- Get conversation IDs since last run
46
+ local cids_query = string.format(
47
+ "SELECT id FROM conversations WHERE started_at > '%s' ORDER BY started_at ASC;",
48
+ since
49
+ )
50
+ local cids_result = ctx.shell("sqlite3 " .. db .. " '" .. cids_query .. "'")
51
+ if cids_result.exit_code ~= 0 then
52
+ return [=[You are a memory builder running in "dream" mode — processing conversations that happened since your last run.
53
+
54
+ ## Context
55
+ Error querying conversations: ]=] .. cids_result.stderr .. [=[
56
+
57
+ ## Your task
58
+ 1. Read the current memory.md from the bone config directory. If it doesn't exist, start fresh.
59
+ 2. Review each conversation above for user preferences, patterns, and context.
60
+ 3. Write an updated memory.md using write_file or edit_file.
61
+ 4. After updating memory.md (or deciding no changes are needed), write the value of NEXT_RUN (shown above) to `$HOME/.bone-rust/memory.last_run`. This advances the checkpoint so processed conversations aren't re-processed. Only do this last.
62
+
63
+ ## Rules
64
+ - Only add preferences clearly demonstrated (seen 2+ times across conversations), not one-off remarks.
65
+ - Remove anything contradicted by newer conversations.
66
+ - Keep the file under 400 tokens. Merge, compress, and drop lower-priority items to fit. Prefer short bullet points over prose. When the file exceeds this limit, consolidate by merging similar items and dropping the least important entries until it fits.
67
+ - Start the file with a metadata line: <!-- last_updated: YYYY-MM-DD -->
68
+ - Use these markdown sections (drop empty ones, add relevant ones):
69
+ - Communication — how the user likes to communicate, verbosity preferences, response format preferences
70
+ - Coding Style — language preferences, patterns, naming conventions, architecture tastes
71
+ - Tools & Workflow — preferred tools, workflows, development habits
72
+ - Dislikes — things the user consistently avoids or objects to
73
+ - Do NOT include project-specific context, task details, or one-off requirements. This file captures general preferences and habits, not what the user is working on right now.
74
+ - If no meaningful changes are needed, leave memory.md as-is and say "No changes."
75
+ - Output a brief summary of what you added, changed, or removed (or "No changes.").]=]
76
+ end
77
+
78
+ local cids = cids_result.stdout:match("^%s*(.-)%s*$")
79
+ if cids == "" then
80
+ return [=[You are a memory builder running in "dream" mode — processing conversations that happened since your last run.
81
+
82
+ ## Context
83
+ No new conversations since ]=] .. since .. [=[:.
84
+
85
+ ## Your task
86
+ 1. Read the current memory.md from the bone config directory. If it doesn't exist, start fresh.
87
+ 2. Review each conversation above for user preferences, patterns, and context.
88
+ 3. Write an updated memory.md using write_file or edit_file.
89
+ 4. After updating memory.md (or deciding no changes are needed), write the value of NEXT_RUN (shown above) to `$HOME/.bone-rust/memory.last_run`. This advances the checkpoint so processed conversations aren't re-processed. Only do this last.
90
+
91
+ ## Rules
92
+ - Only add preferences clearly demonstrated (seen 2+ times across conversations), not one-off remarks.
93
+ - Remove anything contradicted by newer conversations.
94
+ - Keep the file under 400 tokens. Merge, compress, and drop lower-priority items to fit. Prefer short bullet points over prose. When the file exceeds this limit, consolidate by merging similar items and dropping the least important entries until it fits.
95
+ - Start the file with a metadata line: <!-- last_updated: YYYY-MM-DD -->
96
+ - Use these markdown sections (drop empty ones, add relevant ones):
97
+ - Communication — how the user likes to communicate, verbosity preferences, response format preferences
98
+ - Coding Style — language preferences, patterns, naming conventions, architecture tastes
99
+ - Tools & Workflow — preferred tools, workflows, development habits
100
+ - Dislikes — things the user consistently avoids or objects to
101
+ - Do NOT include project-specific context, task details, or one-off requirements. This file captures general preferences and habits, not what the user is working on right now.
102
+ - If no meaningful changes are needed, leave memory.md as-is and say "No changes."
103
+ - Output a brief summary of what you added, changed, or removed (or "No changes.").]=]
104
+ end
105
+
106
+ -- Count conversations
107
+ local count = 0
108
+ for _ in cids:gmatch("[^\n]+") do
109
+ count = count + 1
110
+ end
111
+
112
+ -- Get next run timestamp
113
+ local now_result = ctx.shell("date -u +\"%Y-%m-%dT%H:%M:%SZ\"")
114
+ local next_run = now_result.stdout:match("^%s*(.-)%s*$")
115
+ if next_run == "" then
116
+ next_run = "unknown"
117
+ end
118
+
119
+ -- Build conversation blocks
120
+ local conv_blocks = ""
121
+ for cid in cids:gmatch("[^\n]+") do
122
+ cid = cid:match("^%s*(.-)%s*$")
123
+ local msg_query = string.format(
124
+ "SELECT '[' || m.role || '] ' || m.content FROM messages WHERE m.conversation_id = %s AND m.role IN ('user', 'assistant') AND m.tool_name IS NULL ORDER BY m.seq ASC;",
125
+ cid
126
+ )
127
+ local msg_result = ctx.shell("sqlite3 " .. db .. " '" .. msg_query .. "'")
128
+ local block = "## Conversation " .. cid .. "\n"
129
+ if msg_result.exit_code == 0 then
130
+ block = block .. msg_result.stdout
131
+ else
132
+ block = block .. "(failed to read conversation " .. cid .. ")"
133
+ end
134
+ block = block .. "\n"
135
+ conv_blocks = conv_blocks .. block
136
+ end
137
+
138
+ return [=[You are a memory builder running in "dream" mode — processing conversations that happened since your last run.
139
+
140
+ ## Context
141
+ Conversations since ]=] .. since .. ": " .. count .. [=[
142
+ ---
143
+ NEXT_RUN=]=] .. next_run .. conv_blocks .. [=[
144
+ ## Your task
145
+ 1. Read the current memory.md from the bone config directory. If it doesn't exist, start fresh.
146
+ 2. Review each conversation above for user preferences, patterns, and context.
147
+ 3. Write an updated memory.md using write_file or edit_file.
148
+ 4. After updating memory.md (or deciding no changes are needed), write the value of NEXT_RUN (shown above) to `$HOME/.bone-rust/memory.last_run`. This advances the checkpoint so processed conversations aren't re-processed. Only do this last.
149
+
150
+ ## Rules
151
+ - Only add preferences clearly demonstrated (seen 2+ times across conversations), not one-off remarks.
152
+ - Remove anything contradicted by newer conversations.
153
+ - Keep the file under 400 tokens. Merge, compress, and drop lower-priority items to fit. Prefer short bullet points over prose. When the file exceeds this limit, consolidate by merging similar items and dropping the least important entries until it fits.
154
+ - Start the file with a metadata line: <!-- last_updated: YYYY-MM-DD -->
155
+ - Use these markdown sections (drop empty ones, add relevant ones):
156
+ - Communication — how the user likes to communicate, verbosity preferences, response format preferences
157
+ - Coding Style — language preferences, patterns, naming conventions, architecture tastes
158
+ - Tools & Workflow — preferred tools, workflows, development habits
159
+ - Dislikes — things the user consistently avoids or objects to
160
+ - Do NOT include project-specific context, task details, or one-off requirements. This file captures general preferences and habits, not what the user is working on right now.
161
+ - If no meaningful changes are needed, leave memory.md as-is and say "No changes."
162
+ - Output a brief summary of what you added, changed, or removed (or "No changes.").]=]
163
+ end,
164
+ })
@@ -0,0 +1,31 @@
1
+ -- /review — review unstaged changes for code smells, bugs, logic errors,
2
+ -- dead code, and clean code.
3
+ --
4
+ -- Requires: ctx.shell("git diff --no-color"), submit=true to trigger LLM.
5
+
6
+ bone.register_command("review", {
7
+ description = "Review unstaged changes for bugs, smells, dead code, and clean code",
8
+ handler = function(_, ctx)
9
+ local result = ctx.shell("git diff --no-color")
10
+ if result.exit_code ~= 0 or (result.stdout:gsub("^%s*", "") == "") then
11
+ return { display = "No unstaged changes to review.", submit = false }
12
+ end
13
+
14
+ local diff = result.stdout
15
+ -- Truncate very large diffs to avoid blowing the prompt
16
+ if #diff > 50000 then
17
+ diff = diff:sub(1, 50000) .. "\n... (truncated, diff exceeds 50k chars)"
18
+ end
19
+
20
+ local prompt = [[Review these unstaged changes for code smells, bugs, logic errors, dead code, and clean code:
21
+ - Point out any bugs or potential bugs (null dereferences, off-by-one, resource leaks, etc.)
22
+ - Identify code smells (long functions, deep nesting, duplicated logic, God classes, etc.)
23
+ - Flag dead or unreachable code
24
+ - Suggest specific improvements for readability and maintainability
25
+ - Note any security concerns
26
+
27
+ Be thorough but organized. Group findings by file, and quote relevant code snippets.]]
28
+
29
+ return { display = prompt .. "\n\n" .. diff, submit = true }
30
+ end,
31
+ })
@@ -0,0 +1,118 @@
1
+ local function comma(n)
2
+ n = math.floor(tonumber(n) or 0)
3
+ local s = tostring(n)
4
+ local sign = ""
5
+ if s:sub(1, 1) == "-" then
6
+ sign = "-"
7
+ s = s:sub(2)
8
+ end
9
+ local out = s
10
+ while true do
11
+ local next_out, changed = out:gsub("^(-?%d+)(%d%d%d)", "%1,%2")
12
+ out = next_out
13
+ if changed == 0 then break end
14
+ end
15
+ return sign .. out
16
+ end
17
+
18
+ local function tokens(n)
19
+ return comma(n)
20
+ end
21
+
22
+ local function money(n)
23
+ n = tonumber(n) or 0
24
+ if n <= 0 then return nil end
25
+ return string.format("$%.4f", n)
26
+ end
27
+
28
+ local DIM = "\x1b[2m"
29
+ local CYAN = "\x1b[36m"
30
+ local WHITE = "\x1b[37m"
31
+ local RESET = "\x1b[0m"
32
+
33
+ local function header(title)
34
+ return string.format("%s%s%s", CYAN, title, RESET)
35
+ end
36
+
37
+ local function section(title)
38
+ return string.format("%s%s%s", CYAN, title, RESET)
39
+ end
40
+
41
+ local function klabel(label)
42
+ return string.format("%s%-12s%s", DIM, label .. ":", RESET)
43
+ end
44
+
45
+ local function kvalue(v)
46
+ return string.format("%s%s%s", WHITE, v, RESET)
47
+ end
48
+
49
+ local function kdim(v)
50
+ return string.format("%s%s%s", DIM, v, RESET)
51
+ end
52
+
53
+ local function sep()
54
+ return string.format("%s%s%s", DIM, string.rep("─", 52), RESET)
55
+ end
56
+
57
+ bone.register_command("usage", {
58
+ description = "Show token usage for current conversation",
59
+ handler = function(_, ctx)
60
+ local usage = ctx.usage and ctx.usage.snapshot and ctx.usage.snapshot() or nil
61
+ if not usage then
62
+ return { display = "Usage data is unavailable in this context.", submit = false }
63
+ end
64
+
65
+ local total = (usage.sent or 0) + (usage.received or 0)
66
+ local lines = {
67
+ header("Conversation usage"),
68
+ sep(),
69
+ klabel("Requests") .. kvalue(comma(usage.request_count)),
70
+ klabel("Tokens") .. kvalue(tokens(total) .. " total"),
71
+ klabel("Input") .. kvalue(tokens(usage.sent)),
72
+ klabel("Output") .. kvalue(tokens(usage.received)),
73
+ klabel("Context") .. kvalue(tokens(usage.context_length) .. " current"),
74
+ }
75
+
76
+ if (usage.cached or 0) > 0 then
77
+ table.insert(lines, klabel("Cached") .. kvalue(tokens(usage.cached)))
78
+ end
79
+ local cost = money(usage.cost)
80
+ if cost then
81
+ table.insert(lines, klabel("Cost") .. kvalue(cost))
82
+ end
83
+ if (usage.request_count or 0) > 0 then
84
+ table.insert(lines, klabel("Avg/req") .. kvalue(tokens((usage.sent or 0) / usage.request_count) .. " in / " .. tokens((usage.received or 0) / usage.request_count) .. " out"))
85
+ end
86
+
87
+ table.insert(lines, "")
88
+ table.insert(lines, section("Prompt overhead"))
89
+ table.insert(lines, sep())
90
+ table.insert(lines, klabel("Tools") .. kvalue(comma(usage.tool_count) .. " tools, ~" .. tokens(usage.tool_schema_tokens) .. " tokens (" .. kdim(comma(usage.tool_schema_chars) .. " chars)") .. ")"))
91
+ table.insert(lines, klabel("System") .. kvalue("~" .. tokens(usage.system_prompt_tokens) .. " tokens (" .. kdim(comma(usage.system_prompt_chars) .. " chars)") .. ")"))
92
+
93
+ if usage.by_provider and #usage.by_provider > 1 then
94
+ table.insert(lines, "")
95
+ table.insert(lines, section("By provider/model"))
96
+ table.insert(lines, sep())
97
+ for _, p in ipairs(usage.by_provider) do
98
+ local row = string.format(
99
+ " %s / %s — %s in / %s out",
100
+ kdim(p.provider or "unknown"),
101
+ kvalue(p.model or "unknown"),
102
+ tokens(p.prompt_tokens),
103
+ tokens(p.completion_tokens)
104
+ )
105
+ if (p.cached_tokens or 0) > 0 then
106
+ row = row .. " / " .. tokens(p.cached_tokens) .. " cached"
107
+ end
108
+ local provider_cost = money(p.cost)
109
+ if provider_cost then
110
+ row = row .. " / " .. kvalue(provider_cost)
111
+ end
112
+ table.insert(lines, row)
113
+ end
114
+ end
115
+
116
+ return { display = table.concat(lines, "\n"), submit = false }
117
+ end,
118
+ })
@@ -0,0 +1,306 @@
1
+ -- ask_user — interactive question tool using ctx.ui.interact()
2
+ --
3
+ -- Supports single_select, multi_select, and text_input question types.
4
+ -- Questions are rendered in the bottom pane with keyboard-driven
5
+ -- selection and optional custom text input.
6
+ --
7
+ -- Two calling modes:
8
+ -- 1. Single question: { question, options, allow_custom, type, default }
9
+ -- 2. Multi-question: { questions = { {question, options, allow_custom, type, default}, ... } }
10
+ -- Asks each question sequentially with backtracking navigation.
11
+ -- After answering, user can go back to previous questions or proceed.
12
+
13
+ local function format_answer(result)
14
+ if result.values then
15
+ local parts = {}
16
+ for _, v in ipairs(result.values) do
17
+ table.insert(parts, " - " .. v)
18
+ end
19
+ if result.custom and result.custom ~= "" then
20
+ table.insert(parts, " Custom: " .. result.custom)
21
+ end
22
+ return table.concat(parts, "\n")
23
+ elseif result.value then
24
+ if result.custom then
25
+ return "Custom answer: " .. result.value
26
+ else
27
+ return result.value
28
+ end
29
+ end
30
+ return "(no response)"
31
+ end
32
+
33
+ local function get_qtype(q)
34
+ local qtype = q.type
35
+ if not qtype then
36
+ local options = q.options or {}
37
+ local allow_custom = q.allow_custom or false
38
+ if #options > 0 then
39
+ if allow_custom or #options > 5 then
40
+ qtype = "multi_select"
41
+ else
42
+ qtype = "single_select"
43
+ end
44
+ else
45
+ qtype = "text_input"
46
+ end
47
+ end
48
+ return qtype
49
+ end
50
+
51
+ -- Flatten object-form options ({label, description}) to plain strings.
52
+ -- ctx.ui.interact only accepts Vec<String>; rich entries are reduced to
53
+ -- their label so the model still sees the original text as the answer.
54
+ local function flatten_options(options)
55
+ local flat = {}
56
+ for i, opt in ipairs(options) do
57
+ if type(opt) == "table" then
58
+ flat[i] = opt.label or tostring(opt)
59
+ else
60
+ flat[i] = opt
61
+ end
62
+ end
63
+ return flat
64
+ end
65
+
66
+ local function ask_one(q, ctx)
67
+ local question = q.question
68
+ local options = flatten_options(q.options or {})
69
+ local allow_custom = q.allow_custom or false
70
+ local qtype = get_qtype(q)
71
+
72
+ local ok, result = pcall(ctx.ui.interact, {
73
+ question = question,
74
+ type = qtype,
75
+ options = options,
76
+ default = q.default,
77
+ allow_custom = allow_custom,
78
+ })
79
+
80
+ if not ok then
81
+ return nil, "interact failed: " .. tostring(result)
82
+ end
83
+
84
+ if result.cancelled then
85
+ return nil, "cancelled"
86
+ end
87
+
88
+ return format_answer(result)
89
+ end
90
+
91
+ -- Build navigation options after answering a question in multi-question mode
92
+ -- Returns: list of option strings for the nav interact call
93
+ local function build_nav_options(questions, answers, current_idx)
94
+ local opts = {}
95
+
96
+ -- Show summary of answered questions as pickable options to revise
97
+ for i = 1, #questions do
98
+ local short_q = questions[i].question
99
+ if #short_q > 50 then
100
+ short_q = short_q:sub(1, 47) .. "..."
101
+ end
102
+ if answers[i] then
103
+ local short_a = answers[i]
104
+ if #short_a > 40 then
105
+ short_a = short_a:sub(1, 37) .. "..."
106
+ end
107
+ table.insert(opts, string.format("Q%d: %s → %s", i, short_q, short_a))
108
+ else
109
+ table.insert(opts, string.format("Q%d: %s (unanswered)", i, short_q))
110
+ end
111
+ end
112
+
113
+ table.insert(opts, "✓ Submit all answers")
114
+
115
+ return opts
116
+ end
117
+
118
+ -- Parse what the user picked from navigation
119
+ -- Returns: "submit", or a number (question index to jump to)
120
+ local function parse_nav_choice(result, num_questions)
121
+ if result.custom then
122
+ local val = tonumber(result.value)
123
+ if val and val >= 1 and val <= num_questions then
124
+ return val
125
+ end
126
+ return "submit" -- fallback
127
+ end
128
+
129
+ local selected = result.value
130
+ if not selected then return "submit" end
131
+
132
+ if selected == "✓ Submit all answers" then
133
+ return "submit"
134
+ end
135
+
136
+ -- Extract question number from "Q%d: ..."
137
+ local qnum = selected:match("^Q(%d+):")
138
+ if qnum then
139
+ return tonumber(qnum)
140
+ end
141
+
142
+ return "submit"
143
+ end
144
+
145
+ local function ask_multi_with_backtrack(questions, ctx)
146
+ local answers = {}
147
+ local total = #questions
148
+
149
+ -- Fill answers table with nils
150
+ for _ = 1, total do table.insert(answers, nil) end
151
+
152
+ -- Start from first unanswered question
153
+ local function find_first_unanswered(start)
154
+ for i = start, total do
155
+ if not answers[i] then return i end
156
+ end
157
+ return nil
158
+ end
159
+
160
+ -- Main loop: ask questions, then show nav, repeat until submit
161
+ local ask_from = 1
162
+ while true do
163
+ -- Ask all unanswered questions from ask_from forward
164
+ local idx = find_first_unanswered(ask_from)
165
+ while idx do
166
+ local answer, err = ask_one(questions[idx], ctx)
167
+ if err == "cancelled" then
168
+ -- Treat cancellation as skip (nil answer)
169
+ answers[idx] = nil
170
+ elseif err then
171
+ answers[idx] = "error: " .. err
172
+ else
173
+ answers[idx] = answer
174
+ end
175
+ idx = find_first_unanswered(idx + 1)
176
+ end
177
+
178
+ -- Show navigation pane with summary
179
+ local nav_opts = build_nav_options(questions, answers, nil)
180
+ local ok, result = pcall(ctx.ui.interact, {
181
+ question = "Review your answers. Pick a question to revise, or submit.",
182
+ type = "single_select",
183
+ options = nav_opts,
184
+ allow_custom = false,
185
+ })
186
+
187
+ if not ok or (result and result.cancelled) then
188
+ -- Submit on cancel/escape
189
+ break
190
+ end
191
+
192
+ local choice = parse_nav_choice(result, total)
193
+ if choice == "submit" then
194
+ break
195
+ else
196
+ -- Jump back to revise that question
197
+ answers[choice] = nil
198
+ ask_from = choice
199
+ end
200
+ end
201
+
202
+ return answers
203
+ end
204
+
205
+ local function execute(params, ctx)
206
+ if not params.question and not (params.questions and #params.questions > 0) then
207
+ return "error: provide either 'question' or 'questions' parameter"
208
+ end
209
+
210
+ -- Multi-question mode
211
+ if params.questions and #params.questions > 0 then
212
+ local answers = ask_multi_with_backtrack(params.questions, ctx)
213
+
214
+ local parts = {}
215
+ for i, answer in ipairs(answers) do
216
+ if answer then
217
+ table.insert(parts, "Q" .. i .. ": " .. answer)
218
+ else
219
+ table.insert(parts, "Q" .. i .. ": [skipped]")
220
+ end
221
+ end
222
+
223
+ local result = table.concat(parts, "\n")
224
+ -- Clear pane after all questions are done
225
+ pcall(ctx.ui.pane, { source = "interact", title = "", lines = {} })
226
+ ctx.ui.notify(result, "info")
227
+ return result
228
+ end
229
+
230
+ -- Single-question mode (backward compat)
231
+ local answer, err = ask_one(params, ctx)
232
+ -- Clear pane after the question is answered
233
+ pcall(ctx.ui.pane, { source = "interact", title = "", lines = {} })
234
+ if err == "cancelled" then
235
+ return "[user cancelled]"
236
+ elseif err then
237
+ return err
238
+ end
239
+
240
+ local display = answer
241
+ if answer:sub(1, 1) == " " then
242
+ display = "Selected:\n" .. answer
243
+ end
244
+ ctx.ui.notify(display, "info")
245
+ return display
246
+ end
247
+
248
+ bone.register_tool({
249
+ name = "ask_user",
250
+ description = "Ask the user one or more questions with selectable options or custom answers. Use the 'questions' array to ask several questions back-to-back in a single call, or use top-level 'question' + 'options' for a single question.",
251
+ parameters = {
252
+ type = "object",
253
+ properties = {
254
+ question = {
255
+ type = "string",
256
+ description = "The question to ask (single-question mode).",
257
+ },
258
+ options = {
259
+ type = "array",
260
+ description = "List of options for the user to choose from (single-question mode).",
261
+ items = { type = "string" },
262
+ },
263
+ allow_custom = {
264
+ type = "boolean",
265
+ description = "Whether the user can type their own answer (single-question mode).",
266
+ },
267
+ questions = {
268
+ type = "array",
269
+ description = "Multiple questions to ask sequentially with backtracking. After answering all, you can revise any question. Each item is an object with {question, options, allow_custom, type, default}.",
270
+ items = {
271
+ type = "object",
272
+ properties = {
273
+ question = { type = "string", description = "The question to ask." },
274
+ options = {
275
+ type = "array",
276
+ items = { type = "string" },
277
+ description = "List of options to choose from.",
278
+ },
279
+ allow_custom = {
280
+ type = "boolean",
281
+ description = "Whether the user can type their own answer.",
282
+ },
283
+ type = {
284
+ type = "string",
285
+ enum = { "single_select", "multi_select", "text_input" },
286
+ description = "Question type. Auto-detected if omitted.",
287
+ },
288
+ default = {
289
+ type = "number",
290
+ description = "Default selected option index (1-based).",
291
+ },
292
+ },
293
+ required = { "question" },
294
+ additionalProperties = false,
295
+ },
296
+ },
297
+ },
298
+ additionalProperties = false,
299
+ },
300
+ safety = "read_only",
301
+ display = {
302
+ show = false,
303
+ args = { "question", "questions" },
304
+ },
305
+ execute = execute,
306
+ })