diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88a781bd..8650a5d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,35 @@
# Changelog
+## [Unreleased]
+
+### fix: /version slash command + tool-call extraction for qwen2.5-coder
+
+Two reported issues fixed.
+
+- **Issue #40 — `/version` returns "Unknown"** — `/version` (and `/v`) now
+ print the SmallCode version, package description, and Node/platform info.
+ Sourced from `package.json` so the value can never drift. Added to the
+ `/help` listing too.
+- **Issue #36 — qwen2.5-coder:14b spills tool JSON into chat** — added a
+ defensive tool-call extractor (`src/tools/tool_call_extractor.js`) that
+ recovers tool invocations the model emitted as text instead of as a
+ structured `tool_calls` field. Wired into `bin/smallcode.js`,
+ `src/api/index.js`, and `src/session/parallel_executor.js` so all three
+ entry points benefit. Recognises four shapes:
+ - `{...}` (Hermes / qwen native template)
+ - `` ```json ... ``` `` and `` ```tool_call ... ``` `` fences
+ - Bare leading JSON object/array
+ - `{ "function": { "name", "arguments" } }` and `{ "tool", "args" }` forms
+ - Tolerates trailing commas, which qwen sometimes emits.
+
+ Conservative by design — calls referencing unknown tool names are left in
+ the chat content rather than executed.
+
+ Coverage: `.test-workspace/tool_call_extractor_test.js` (10 cases) and
+ `.test-workspace/version_command_test.js` (3 cases).
+
+---
+
## [1.0.2] - 2026-05-22
### fix: empty tools array + ~/.smallcode/skills/ support
diff --git a/README.md b/README.md
index 795e38b9..db1bf3c4 100644
--- a/README.md
+++ b/README.md
@@ -260,6 +260,7 @@ npm run bench:tools
| `/skill` | Manage reusable skills |
| `/plugin` | Install/manage plugins |
| `/sessions` | List/resume saved sessions |
+| `/version`, `/v` | Show SmallCode version + Node/platform |
| `/help` | Show all commands |
## Observability
diff --git a/bin/commands.js b/bin/commands.js
index f3ac3afb..2ee282a2 100644
--- a/bin/commands.js
+++ b/bin/commands.js
@@ -728,6 +728,24 @@ module.exports = function createCommandHandler(config, conversationHistory, impr
return;
}
+ case '/version':
+ case '/v': {
+ // Read version from package.json (single source of truth).
+ try {
+ const pkg = require('../package.json');
+ console.log('');
+ console.log(` ${chalk.bold('SmallCode')} ${chalk.cyan('v' + pkg.version)}`);
+ if (pkg.description) console.log(` ${chalk.gray(pkg.description)}`);
+ console.log(` ${chalk.gray('Node ' + process.version + ' on ' + process.platform + '/' + process.arch)}`);
+ console.log('');
+ } catch (e) {
+ console.log(chalk.gray(' Version info unavailable: ' + e.message));
+ console.log('');
+ }
+ rl.prompt();
+ return;
+ }
+
case '/help':
console.log('');
console.log(chalk.bold(' Commands'));
@@ -754,6 +772,7 @@ module.exports = function createCommandHandler(config, conversationHistory, impr
console.log(` ${chalk.cyan('/trace')} ${chalk.gray('View/export execution traces')}`);
console.log(` ${chalk.cyan('/eval')} ${chalk.gray('Run prompt evaluation')}`);
console.log(` ${chalk.cyan('/clear')} ${chalk.gray('Reset entire session')}`);
+ console.log(` ${chalk.cyan('/version')} ${chalk.gray('Show SmallCode version')}`);
console.log(` ${chalk.cyan('/quit')} ${chalk.gray('Exit SmallCode')}`);
console.log('');
rl.prompt();
diff --git a/bin/smallcode.js b/bin/smallcode.js
index b54908b1..f79f449c 100755
--- a/bin/smallcode.js
+++ b/bin/smallcode.js
@@ -938,6 +938,18 @@ async function runAgentLoop(userMessage, config) {
const message = response.choices?.[0]?.message;
if (!message) break;
+ // Defensive recovery: some local models (qwen2.5-coder, hermes, llama3
+ // GGUFs) emit tool calls as JSON inside `content` instead of populating
+ // structured `tool_calls`. Try to lift them into the proper field
+ // before any downstream logic looks at the message. Issue #36.
+ try {
+ const { extractFromMessage } = require('../src/tools/tool_call_extractor');
+ const r = extractFromMessage(message, getAllTools(config, currentToolCategory));
+ if (r.patched && _fullscreenRef) {
+ _fullscreenRef.addTool('tool_call', 'ok', `recovered ${r.addedCalls} from text content`);
+ }
+ } catch {}
+
// Truncate excessive thinking content before it enters conversation history.
// Reasoning models can ignore the soft budget and emit 50KB of thinking
// loops. We hard-cap it here so the next turn doesn't include a wall of
diff --git a/src/api/index.js b/src/api/index.js
index 3ddf8846..010e78b6 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -79,6 +79,13 @@ class SmallCode extends EventEmitter {
const message = response.choices?.[0]?.message;
if (!message) break;
+ // Recover tool calls embedded in text content (qwen2.5-coder etc.)
+ // See src/tools/tool_call_extractor.js + issue #36.
+ try {
+ const { extractFromMessage } = require('../tools/tool_call_extractor');
+ extractFromMessage(message, this._getTools());
+ } catch {}
+
// Track token usage
if (response.usage) {
result.tokensUsed.input += response.usage.prompt_tokens || 0;
diff --git a/src/session/parallel_executor.js b/src/session/parallel_executor.js
index cd8e72e9..e2377426 100644
--- a/src/session/parallel_executor.js
+++ b/src/session/parallel_executor.js
@@ -91,6 +91,14 @@ async function executeBatch(batch, planSteps, systemMessages, chatCompletionFn,
const choice = response?.choices?.[0]?.message;
if (!choice) break;
+ // Recover tool calls embedded in text content (qwen2.5-coder etc.).
+ // Issue #36 — the agent loop in bin/smallcode.js does the same.
+ try {
+ const { extractFromMessage } = require('../tools/tool_call_extractor');
+ const tools = (typeof ctx?.getAllTools === 'function' ? ctx.getAllTools(config) : null) || [];
+ extractFromMessage(choice, tools);
+ } catch {}
+
finalContent = choice.content || '';
const toolCalls = choice.tool_calls || [];
diff --git a/src/tools/tool_call_extractor.js b/src/tools/tool_call_extractor.js
new file mode 100644
index 00000000..88c75f08
--- /dev/null
+++ b/src/tools/tool_call_extractor.js
@@ -0,0 +1,187 @@
+// SmallCode — Tool Call Extractor
+//
+// Some local models (notably qwen2.5-coder, hermes, llama3-instruct via Ollama,
+// and various GGUFs through llama.cpp) do not always emit structured
+// `tool_calls` over the OpenAI-compatible endpoint. Instead, they paste the
+// tool invocation as JSON text inside `message.content`.
+//
+// Symptoms reported in the wild (e.g. issue #36 — qwen2.5-coder:14b on Ollama):
+// • User asks "what files are in this directory?"
+// • Model returns a chat message whose content is literally a JSON blob:
+// {"name":"shell","arguments":{"cmd":"ls"}}
+// or sometimes a bare ```json fenced block, or even just `{ "name": ... }`.
+// • The agent loop sees no `tool_calls`, treats the JSON as chat output,
+// and the user sees the raw JSON instead of the tool running.
+//
+// This module is a defensive recovery layer. Given the model's `message`
+// object and the list of available tool schemas, it tries to detect
+// embedded tool invocations and rewrite the message in-place to use the
+// proper `tool_calls` shape — so the rest of the agent loop can treat
+// every model the same way.
+//
+// Recognised formats (in priority order):
+// 1. {...} ← Hermes / qwen2.5 native
+// 2. ```json ... ``` (fenced code block) ← qwen-coder / generic
+// 3. ```tool_call ... ``` ← some llama3 fine-tunes
+// 4. Bare JSON object at the start of content
+//
+// All formats expect the JSON to be of shape:
+// { "name": "", "arguments":