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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vincent Miranda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # bone-agent
2
+
3
+ A Rust+Lua terminal AI coding assistant.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g bone-agent
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ bone
15
+ ```
16
+
17
+ ## Config
18
+
19
+ User config lives in `~/.bone-rust/`. Lua files are extracted there on first install and persist across npm updates.
Binary file
package/bin/bone.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bone-rs - npm wrapper for the Rust binary
4
+ * Finds the correct platform binary and executes it.
5
+ */
6
+
7
+ const { spawnSync } = require('child_process');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+
12
+ // Resolve package directory from package.json location
13
+ let packageDir;
14
+ try {
15
+ packageDir = path.dirname(require.resolve('../package.json'));
16
+ } catch {
17
+ // Fallback: walk up from this file
18
+ packageDir = __dirname;
19
+ while (packageDir !== path.dirname(packageDir)) {
20
+ if (fs.existsSync(path.join(packageDir, 'package.json'))) break;
21
+ packageDir = path.dirname(packageDir);
22
+ }
23
+ }
24
+
25
+ function getBinaryPath() {
26
+ const plat = os.platform();
27
+ const arch = os.arch();
28
+ const ext = plat === 'win32' ? '.exe' : '';
29
+ const key = `${plat}-${arch}`;
30
+
31
+ const binPath = path.join(packageDir, 'bin', `bone-${key}${ext}`);
32
+ if (fs.existsSync(binPath)) return binPath;
33
+
34
+ // Fallback for arm64 on darwin (Apple Silicon) — try x64 Rosetta binary
35
+ if (plat === 'darwin' && arch === 'arm64') {
36
+ const x64Path = path.join(packageDir, 'bin', `bone-darwin-x64${ext}`);
37
+ if (fs.existsSync(x64Path)) return x64Path;
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ const binPath = getBinaryPath();
44
+ if (!binPath) {
45
+ console.error(
46
+ `bone-rs: no binary found for ${os.platform()}-${os.arch()}. ` +
47
+ `Supported: linux-x64, darwin-x64, darwin-arm64, win-x64`
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ const result = spawnSync(binPath, process.argv.slice(2), {
53
+ stdio: 'inherit',
54
+ cwd: process.cwd(),
55
+ });
56
+
57
+ process.exit(result.status ?? 1);
@@ -0,0 +1,360 @@
1
+ -- /compact — manual context compaction and automatic before-turn reduction.
2
+ --
3
+ -- Implemented entirely in Lua. Remove or edit this file to disable or
4
+ -- customize compaction behaviour.
5
+ --
6
+ -- Requires: ctx.conversation.history(), ctx.agent.run(), ctx.usage.snapshot(),
7
+ -- action = "conversation.replace", bone.on("before_turn", ...)
8
+
9
+ -- ---------------------------------------------------------------------------
10
+ -- Configuration — read from config/general.yaml.
11
+ -- ---------------------------------------------------------------------------
12
+
13
+ local function config_int(ctx, key)
14
+ if not ctx.config or not ctx.config.get then
15
+ return nil
16
+ end
17
+
18
+ local value = ctx.config.get("general", key)
19
+ if value == nil then
20
+ return nil
21
+ end
22
+ if type(value) == "string" then
23
+ value = value:gsub("^%s+", ""):gsub("%s+$", "")
24
+ if value == "" then
25
+ return nil
26
+ end
27
+ end
28
+
29
+ local number = tonumber(value)
30
+ if not number or number < 1 or number ~= math.floor(number) then
31
+ return nil
32
+ end
33
+ return number
34
+ end
35
+
36
+ local function compact_config(ctx)
37
+ return {
38
+ auto_tokens = config_int(ctx, "auto_compact_tokens"),
39
+ keep_messages = config_int(ctx, "auto_compact_keep_messages"),
40
+ }
41
+ end
42
+
43
+ -- ---------------------------------------------------------------------------
44
+ -- Helpers
45
+ -- ---------------------------------------------------------------------------
46
+
47
+ local function truncate_utf8(s, max_bytes)
48
+ if #s <= max_bytes then
49
+ return s
50
+ end
51
+ for cut = max_bytes, math.max(max_bytes - 4, 1), -1 do
52
+ local chunk = s:sub(1, cut)
53
+ local ok, len = pcall(utf8.len, chunk)
54
+ if ok and len then
55
+ return chunk .. "..."
56
+ end
57
+ end
58
+ return "..."
59
+ end
60
+
61
+ --- Build a summary prompt for the model to condense older messages.
62
+ local function summarization_prompt(older, recent_count)
63
+ local parts = {
64
+ "You are a context summarizer. Summarize the conversation below into a compact description.",
65
+ "",
66
+ "Instructions:",
67
+ "- Capture key facts, decisions, and user preferences.",
68
+ "- Include file paths, code changes, and errors when relevant.",
69
+ "- Write a concise summary in plain prose, no markdown headings.",
70
+ "",
71
+ "The last " .. recent_count .. " user/assistant messages, plus any matching tool results, are preserved verbatim and will follow this summary.",
72
+ "",
73
+ "--- Conversation to summarize ---",
74
+ }
75
+
76
+ for _, msg in ipairs(older) do
77
+ local role = msg.role or "unknown"
78
+ local content = truncate_utf8(msg.content or "", 1997)
79
+ parts[#parts + 1] = string.format("[%s] %s", role, content)
80
+ end
81
+
82
+ return table.concat(parts, "\n")
83
+ end
84
+
85
+ --- Count the approximate token count of a string (chars / 4).
86
+ local function estimate_tokens(s)
87
+ return math.ceil(#s / 4)
88
+ end
89
+
90
+ -- ---------------------------------------------------------------------------
91
+ -- Core compaction logic
92
+ -- ---------------------------------------------------------------------------
93
+
94
+ local function sanitize_tool_chains(messages)
95
+ -- Pass 1: collect tool_call_ids that have results.
96
+ local result_ids = {}
97
+ for _, msg in ipairs(messages) do
98
+ if msg.role == "tool" and msg.tool_call_id then
99
+ result_ids[msg.tool_call_id] = true
100
+ end
101
+ end
102
+
103
+ -- Pass 2: filter assistant tool_calls; collect which ids are kept.
104
+ local kept_call_ids = {}
105
+ local filtered = {}
106
+ for _, msg in ipairs(messages) do
107
+ if msg.role == "assistant" and msg.tool_calls then
108
+ local calls = {}
109
+ for _, call in ipairs(msg.tool_calls) do
110
+ if call.id and result_ids[call.id] then
111
+ calls[#calls + 1] = call
112
+ kept_call_ids[call.id] = true
113
+ end
114
+ end
115
+ if #calls > 0 then
116
+ local copy = {}
117
+ for k, v in pairs(msg) do copy[k] = v end
118
+ copy.tool_calls = calls
119
+ filtered[#filtered + 1] = copy
120
+ elseif msg.content and msg.content ~= "" then
121
+ local copy = {}
122
+ for k, v in pairs(msg) do copy[k] = v end
123
+ copy.tool_calls = nil
124
+ filtered[#filtered + 1] = copy
125
+ end
126
+ else
127
+ filtered[#filtered + 1] = msg
128
+ end
129
+ end
130
+
131
+ -- Pass 3: filter tool results to only those whose call id was kept.
132
+ local result = {}
133
+ for _, msg in ipairs(filtered) do
134
+ if msg.role == "tool" then
135
+ if msg.tool_call_id and kept_call_ids[msg.tool_call_id] then
136
+ result[#result + 1] = msg
137
+ end
138
+ else
139
+ result[#result + 1] = msg
140
+ end
141
+ end
142
+
143
+ return result
144
+ end
145
+
146
+ --- Run compaction on the current transcript. Returns the replacement messages
147
+ --- table, or nil on failure / when history is already small enough.
148
+ local function compact(history, ctx, keep_messages)
149
+ if not history or #history == 0 then
150
+ return nil
151
+ end
152
+
153
+ -- Filter to user+assistant for the keep window; tool messages between
154
+ -- user/assistant pairs are fragile to reorder, so for v1 we drop them
155
+ -- from the replacement and let the model see only user/assistant.
156
+ local keep = {}
157
+ local older = {}
158
+
159
+ -- Pass 1: walk backward to find which user/assistant messages are in the
160
+ -- keep window, and collect tool_call_ids from kept assistants so we can
161
+ -- correctly route tool results (a tool result should be kept only if its
162
+ -- matching assistant is in keep).
163
+ local keep_indices = {}
164
+ local kept_call_ids = {}
165
+ local kept = 0
166
+ for i = #history, 1, -1 do
167
+ local msg = history[i]
168
+ if msg.role == "user" or msg.role == "assistant" then
169
+ kept = kept + 1
170
+ if kept <= keep_messages then
171
+ keep_indices[i] = true
172
+ if msg.tool_calls then
173
+ for _, call in ipairs(msg.tool_calls) do
174
+ if call.id then
175
+ kept_call_ids[call.id] = true
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ -- Pass 2: assign messages to keep or older using the collected data.
184
+ for i = #history, 1, -1 do
185
+ local msg = history[i]
186
+ if keep_indices[i] then
187
+ keep[#keep + 1] = msg
188
+ elseif msg.role == "tool" and msg.tool_call_id and kept_call_ids[msg.tool_call_id] then
189
+ -- This tool result belongs to an assistant in keep. Keep it
190
+ -- regardless of its position (it may trail the last kept user msg).
191
+ keep[#keep + 1] = msg
192
+ else
193
+ table.insert(older, 1, msg)
194
+ end
195
+ end
196
+
197
+ -- If nothing to compact, skip.
198
+ if #older == 0 then
199
+ return nil
200
+ end
201
+
202
+ -- Build the summary via ctx.agent.run().
203
+ local prompt = summarization_prompt(older, keep_messages)
204
+ local run_result = ctx.agent.run(prompt, { timeout_ms = 120000 })
205
+ if not run_result.ok then
206
+ ctx.ui.notify("compact: summarization failed: " .. (run_result.error or "unknown"), "warn")
207
+ return nil
208
+ end
209
+
210
+ local summary = (run_result.content or ""):gsub("^%s+", ""):gsub("%s+$", "")
211
+ if #summary == 0 then
212
+ ctx.ui.notify("compact: empty summary, skipping", "warn")
213
+ return nil
214
+ end
215
+
216
+ -- Build replacement messages: synthetic user summary + preserved messages
217
+ -- (the keep array was built backward, so reverse it).
218
+ local messages = {}
219
+ messages[#messages + 1] = {
220
+ role = "user",
221
+ content = "[Context summary]\n" .. summary,
222
+ }
223
+ for i = #keep, 1, -1 do
224
+ messages[#messages + 1] = keep[i]
225
+ end
226
+
227
+ return sanitize_tool_chains(messages)
228
+ end
229
+
230
+ -- ---------------------------------------------------------------------------
231
+ -- Auto-compaction: before_turn hook
232
+ -- ---------------------------------------------------------------------------
233
+
234
+ local last_auto_context = {}
235
+
236
+ bone.on("before_turn", function(event, ctx)
237
+ -- Safety: skip if usage or conversation APIs are unavailable.
238
+ if not ctx.usage or not ctx.usage.snapshot then
239
+ return nil
240
+ end
241
+ if not ctx.conversation or not ctx.conversation.history then
242
+ return nil
243
+ end
244
+
245
+ -- Check that the compact command is enabled (respects /config toggle).
246
+ local compact_enabled = ctx.config.get("commands", "compact")
247
+ if compact_enabled ~= true then
248
+ return nil
249
+ end
250
+
251
+ local config = compact_config(ctx)
252
+ if not config.auto_tokens or not config.keep_messages then
253
+ return nil
254
+ end
255
+
256
+ local snapshot = ctx.usage.snapshot()
257
+ if not snapshot then
258
+ return nil
259
+ end
260
+
261
+ local context_length = snapshot.context_length or 0
262
+ if context_length < config.auto_tokens then
263
+ return nil
264
+ end
265
+
266
+ local conv = ctx.conversation.current and ctx.conversation.current() or nil
267
+ local context_key = conv and conv.id or "default"
268
+ local previous_context = last_auto_context[context_key]
269
+ -- Avoid repeated runs when context_length hasn't changed meaningfully.
270
+ if previous_context and math.abs(context_length - previous_context) < 50 then
271
+ return nil
272
+ end
273
+
274
+ local history = ctx.conversation.history()
275
+ if not history then
276
+ return nil
277
+ end
278
+
279
+ local messages = compact(history, ctx, config.keep_messages)
280
+ if not messages then
281
+ return nil
282
+ end
283
+
284
+ local compacted_tokens = estimate_tokens(cjson.encode(messages))
285
+ last_auto_context[context_key] = compacted_tokens
286
+
287
+ ctx.ui.notify(
288
+ string.format(
289
+ "compacting: %d messages → %d (context: %d → ~%d tokens)",
290
+ #history, #messages, context_length, compacted_tokens
291
+ ),
292
+ "info"
293
+ )
294
+
295
+ return {
296
+ action = "conversation.replace",
297
+ messages = messages,
298
+ }
299
+ end)
300
+
301
+ -- ---------------------------------------------------------------------------
302
+ -- Manual /compact command
303
+ -- ---------------------------------------------------------------------------
304
+
305
+ bone.register_command("compact", {
306
+ description = "Manually compact conversation context by summarizing older messages",
307
+ handler = function(_, ctx)
308
+ if not ctx.conversation or not ctx.conversation.history then
309
+ return {
310
+ display = "Conversation history not available in this context.",
311
+ submit = false,
312
+ }
313
+ end
314
+
315
+ local config = compact_config(ctx)
316
+ if not config.keep_messages then
317
+ return {
318
+ display = "Compaction requires auto_compact_keep_messages in general config.",
319
+ submit = false,
320
+ }
321
+ end
322
+
323
+ local history = ctx.conversation.history()
324
+ if not history or #history == 0 then
325
+ return { display = "Nothing to compact.", submit = false }
326
+ end
327
+
328
+ -- Check if there's enough to compact: need more than configured keep messages.
329
+ local user_assistant_count = 0
330
+ for _, msg in ipairs(history) do
331
+ if msg.role == "user" or msg.role == "assistant" then
332
+ user_assistant_count = user_assistant_count + 1
333
+ end
334
+ end
335
+ if user_assistant_count <= config.keep_messages then
336
+ return {
337
+ display = string.format(
338
+ "History is already small (%d user+assistant messages; threshold: %d).",
339
+ user_assistant_count, config.keep_messages
340
+ ),
341
+ submit = false,
342
+ }
343
+ end
344
+
345
+ local messages = compact(history, ctx, config.keep_messages)
346
+ if not messages then
347
+ return { display = "Compaction produced no changes.", submit = false }
348
+ end
349
+
350
+ return {
351
+ display = string.format(
352
+ "Compacted: %d messages → %d (~%d tokens).",
353
+ #history, #messages, estimate_tokens(cjson.encode(messages))
354
+ ),
355
+ action = "conversation.replace",
356
+ messages = messages,
357
+ submit = false,
358
+ }
359
+ end,
360
+ })
@@ -0,0 +1,143 @@
1
+ -- /customize — quick-start guide for asking bone to customize itself.
2
+
3
+ local guide = [[
4
+ Customize bone
5
+ ══════════════
6
+
7
+ Ask for the outcome you want in plain language. Bone can inspect the
8
+ config, explain the options, make the change, and tell you how to reload.
9
+
10
+ ── Configs ──────────────────────────────────────────────────────────
11
+
12
+ YAML files that control bone's behavior. Stored in your config
13
+ directory (~/.bone-rust/).
14
+
15
+ providers.yaml — LLM providers and models. Change which
16
+ provider to use, switch models, set defaults.
17
+ Example: swap "openai" for "anthropic", or
18
+ change the default model from gpt-4o to claude-sonnet-4-20250514.
19
+
20
+ command-policy.yaml — Shell command safety tiers. Controls which
21
+ commands auto-run vs. require approval.
22
+ Example: mark git commands as "safe" to skip
23
+ approval, or make all file writes require "danger" clearance.
24
+
25
+ config/*.yaml — Feature toggles and thresholds.
26
+ Example: set auto-compaction token limits, adjust
27
+ memory update frequency, or change the max tool
28
+ nesting depth.
29
+
30
+ ── Commands ─────────────────────────────────────────────────────────
31
+
32
+ Lua scripts in lua/commands/ that add slash commands like /compact
33
+ or /memory. Run on demand from the chat.
34
+
35
+ What you can change:
36
+ • Rename or remove bundled commands (e.g. drop /compact entirely).
37
+ • Change command behavior — tighten compaction thresholds, alter
38
+ what /memory summarizes, or change output formatting.
39
+ • Add new commands — a /release checklist, a /git-status summary,
40
+ a /find-dead-code helper.
41
+
42
+ Commands get a full ctx with shell access, file I/O, agent spawning,
43
+ and session history.
44
+
45
+ ── Tools ────────────────────────────────────────────────────────────
46
+
47
+ Lua scripts in lua/tools/ that extend what the LLM can do. Each
48
+ tool has a name, description, typed parameters, and an execute
49
+ function.
50
+
51
+ What you can change:
52
+ • Modify existing tools — tighten web_search result limits, add
53
+ filtering to task_list, change cron's default timeout.
54
+ • Add new tools — a GitHub issue search tool, a database query
55
+ wrapper, a project-specific linter runner.
56
+ • Change safety level — mark your custom tool "safe" for auto-run
57
+ or "danger" for approval-gated execution.
58
+ • Control TUI display — show/hide panes, customize what args and
59
+ results appear in the interface.
60
+
61
+ Tools are the LLM's primary interface to the outside world: shell,
62
+ filesystem, other tools, subagents, and more.
63
+
64
+ ── Lua (init.lua) ──────────────────────────────────────────────────
65
+
66
+ A startup script in the config directory that runs once when bone
67
+ launches. Use it to register custom tools, subagents, commands, and
68
+ event hooks.
69
+
70
+ What you can do:
71
+ • Register subagents — declare a researcher, reviewer, or test
72
+ verifier with its own system prompt, provider, and model.
73
+ • Register event hooks — run code before each turn, after errors,
74
+ or on other lifecycle events.
75
+ • Set up one-time initialization — create config files, seed
76
+ templates, or log startup diagnostics.
77
+
78
+ Errors in init.lua are non-fatal — bone logs a warning and continues
79
+ without Lua support.
80
+
81
+ ── ctx (the context object) ────────────────────────────────────────
82
+
83
+ Passed to every tool and command handler. Gives your Lua code access
84
+ to bone's internals. Not all fields are available everywhere.
85
+
86
+ Key capabilities:
87
+ • ctx.config — read values from YAML config files (read-only).
88
+ • ctx.fs, ctx.read_file, ctx.write_file — filesystem operations.
89
+ • ctx.shell, ctx.shell_streaming — run commands through the approval
90
+ pipeline.
91
+ • ctx.tools.call — invoke other registered tools by name.
92
+ • ctx.agent.run, ctx.agent.spawn — create and manage subagents.
93
+ • ctx.state — session-scoped key-value store for persisting data
94
+ across tool calls.
95
+ • ctx.conversation — read the active chat transcript.
96
+ • ctx.usage.snapshot — check token counts and costs.
97
+ • ctx.ui.notify, ctx.ui.status — send messages to the user.
98
+
99
+ Context availability varies:
100
+ • Tools get the full ctx (shell, files, tools, agents, etc.).
101
+ • Commands get most of the same, but no live event emission.
102
+ • Event hooks get a minimal ctx — only config_dir, ui.notify, and
103
+ config.dir. They cannot run shell commands or read files.
104
+
105
+ ── Prompt examples ─────────────────────────────────────────────────
106
+
107
+ Make reviews stricter about security and race conditions.
108
+ Add a command that prepares a release checklist.
109
+ Create a tool that searches my issue tracker.
110
+ Use a quieter, more direct assistant style.
111
+ Ask before running commands that modify files.
112
+ Show me the config files involved before changing anything.
113
+ Add a subagent for test verification.
114
+ Remember that I prefer small targeted fixes.
115
+
116
+ Helpful phrases:
117
+
118
+ Explain the current behavior first, then change it.
119
+ Keep this project-agnostic.
120
+ Make the smallest change that works.
121
+ Remove anything unused after the change.
122
+ Verify it with the right command when done.
123
+
124
+ Common areas to customize:
125
+
126
+ Providers and models
127
+ Tools and command approval
128
+ Slash commands
129
+ Subagents
130
+ Memory and assistant style
131
+ Status, usage, and UI settings
132
+
133
+ If you are unsure what to ask, start with:
134
+
135
+ Look at my bone config and suggest practical customizations for how I work.
136
+ ]]
137
+
138
+ bone.register_command("customize", {
139
+ description = "Quick-start guide to customizing bone",
140
+ handler = function()
141
+ return { display = guide, submit = false }
142
+ end,
143
+ })