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.
- package/LICENSE +21 -0
- package/README.md +19 -0
- package/bin/bone-linux-x64 +0 -0
- package/bin/bone.js +57 -0
- package/defaults/lua/commands/compact.lua +360 -0
- package/defaults/lua/commands/customize.lua +143 -0
- package/defaults/lua/commands/memory.lua +164 -0
- package/defaults/lua/commands/review.lua +31 -0
- package/defaults/lua/commands/usage.lua +118 -0
- package/defaults/lua/tools/ask_user.lua +306 -0
- package/defaults/lua/tools/cron.lua +253 -0
- package/defaults/lua/tools/subagent.lua +350 -0
- package/defaults/lua/tools/task_list.lua +189 -0
- package/defaults/lua/tools/web_search.lua +41 -0
- package/install.js +43 -0
- package/package.json +46 -0
|
@@ -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
|
+
})
|