feat(ollama): enforce JSON logits & add nvim_buffer edit action

- Added format parameter to Ollama generate for JSON logit enforcement.

- Implemented safe edit action in nvim_buffer MCP tool to replace raw Lua.
This commit is contained in:
Riz Ashraf committed 2026-10-08 23:41:57 +01:00
1 parent 003b3cb2bf
commit 410b0c42ca
6 files changed
+56 -8

No files matched your search

+46 -4
View File
@@ -1088,16 +1088,17 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
"properties": { "properties": {
"action": { "action": {
"type": "string", "type": "string",
"enum": ["get_active", "read", "open", "create_scratch", "save", "reload", "close", "list", "search"], "enum": ["get_active", "read", "open", "create_scratch", "save", "reload", "close", "list", "search", "edit"],
"description": "Action to perform on the buffer" "description": "Action to perform on the buffer"
}, },
"file": { "type": "string", "description": "Target file path (for open, read, search)" }, "file": { "type": "string", "description": "Target file path (for open, read, search, edit)" },
"buf_id": { "type": "integer", "description": "Buffer ID (for close, reload, or split)" }, "buf_id": { "type": "integer", "description": "Buffer ID (for close, reload, or split)" },
"content": { "type": "string", "description": "Initial text content (for create_scratch)" }, "content": { "type": "string", "description": "Initial text content (for create_scratch)" },
"name": { "type": "string", "description": "Buffer display name (for create_scratch)" }, "name": { "type": "string", "description": "Buffer display name (for create_scratch)" },
"filetype": { "type": "string", "description": "Filetype syntax (for open, create_scratch)" }, "filetype": { "type": "string", "description": "Filetype syntax (for open, create_scratch)" },
"start_line": { "type": "integer", "description": "1-indexed start line (for read)" }, "start_line": { "type": "integer", "description": "1-indexed start line (for read, edit)" },
"end_line": { "type": "integer", "description": "1-indexed end line (for read)" }, "end_line": { "type": "integer", "description": "1-indexed end line (for read, edit)" },
"replacement_content": { "type": "string", "description": "New content for the specified line range (for edit)" },
"pattern": { "type": "string", "description": "Regex or substring pattern to search for (for search)" }, "pattern": { "type": "string", "description": "Regex or substring pattern to search for (for search)" },
"force": { "type": "boolean", "description": "Force reload or close (for reload, close)" } "force": { "type": "boolean", "description": "Force reload or close (for reload, close)" }
}, },
@@ -1324,6 +1325,47 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
execute_nvim_lua_with_args(code, vec![args_rmp]) execute_nvim_lua_with_args(code, vec![args_rmp])
) )
} }
"edit" => {
let args_rmp = json_to_rmpv(args);
let code = "
local args = ...
local file = args.file
if not file or file == '' or file == vim.NIL then
error(\"Missing 'file'\")
end
local start_line = tonumber(args.start_line)
if not start_line then
error(\"Missing 'start_line'\")
end
local end_line = tonumber(args.end_line)
if not end_line then
error(\"Missing 'end_line'\")
end
local content = args.replacement_content
if not content or content == vim.NIL then
error(\"Missing 'replacement_content'\")
end
local bufnr = vim.fn.bufnr(file, true)
if vim.fn.bufloaded(bufnr) == 0 then
vim.fn.bufload(bufnr)
end
local lines = vim.split(content, '\\n', { plain = true })
vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, lines)
local win = vim.fn.bufwinid(bufnr)
if win ~= -1 then
pcall(vim.api.nvim_win_set_cursor, win, {start_line, 0})
end
return 'Successfully edited lines ' .. tostring(start_line) .. ' to ' .. tostring(end_line) .. ' in ' .. file
";
handle_lua_result!(
id,
execute_nvim_lua_with_args(code, vec![args_rmp])
)
}
"open" => { "open" => {
let args_rmp = json_to_rmpv(args); let args_rmp = json_to_rmpv(args);
let code = " let code = "
+1 -1
View File
@@ -824,7 +824,7 @@ impl McpTool for CondenseEntityHandler {
req.entity_name, req.entity_name,
unique_obs.join("\n- ") unique_obs.join("\n- ")
); );
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await { if let Ok(summary) = state.ollama.generate(&prompt, None, None, None).await {
let lines: Vec<String> = summary let lines: Vec<String> = summary
.lines() .lines()
.map(|l| l.trim().trim_start_matches('-').trim().to_string()) .map(|l| l.trim().trim_start_matches('-').trim().to_string())
+2 -2
View File
@@ -33,7 +33,7 @@ impl McpTool for LogErrorFixHandler {
); );
if let Ok(summary) = state if let Ok(summary) = state
.ollama .ollama
.generate(&prompt, Some(&state.ollama.reasoning_model), None) .generate(&prompt, Some(&state.ollama.reasoning_model), None, None)
.await .await
{ {
let clean = summary.trim(); let clean = summary.trim();
@@ -207,7 +207,7 @@ impl McpTool for LogCodeChangeHandler {
"Summarize in 1 concise sentence the architectural impact of changing file '{}': {}", "Summarize in 1 concise sentence the architectural impact of changing file '{}': {}",
req.file_path, description req.file_path, description
); );
if let Ok(summary) = state.ollama.generate(&prompt, None, None).await { if let Ok(summary) = state.ollama.generate(&prompt, None, None, None).await {
let clean = summary.trim(); let clean = summary.trim();
if !clean.is_empty() { if !clean.is_empty() {
description = format!("{} (AI Summary: {})", description, clean); description = format!("{} (AI Summary: {})", description, clean);
+1 -1
View File
@@ -359,7 +359,7 @@ impl McpTool for SemanticCodeSearchHandler {
); );
if let Ok(summary) = state if let Ok(summary) = state
.ollama .ollama
.generate(&prompt, None, Some("Respond clearly and concisely.")) .generate(&prompt, None, Some("Respond clearly and concisely."), None)
.await .await
{ {
out.push_str("\n\n--- Local GraphRAG Summary ---\n"); out.push_str("\n\n--- Local GraphRAG Summary ---\n");
+1
View File
@@ -308,6 +308,7 @@ pub async fn memory_consolidation_worker(state: Arc<MemoryState>) {
&prompt, &prompt,
None, None,
Some("You are a helpful JSON-only data deduplication assistant. Output only JSON."), Some("You are a helpful JSON-only data deduplication assistant. Output only JSON."),
Some("json"),
) )
.await .await
{ {
+5
View File
@@ -27,6 +27,8 @@ struct GenerateRequest<'a> {
options: Option<serde_json::Value>, options: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
keep_alive: Option<&'a str>, keep_alive: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<&'a str>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -113,6 +115,7 @@ impl OllamaClient {
prompt: &str, prompt: &str,
model_override: Option<&str>, model_override: Option<&str>,
system: Option<&str>, system: Option<&str>,
format: Option<&str>,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
let model = model_override.unwrap_or(&self.coder_model); let model = model_override.unwrap_or(&self.coder_model);
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/')); let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
@@ -128,6 +131,7 @@ impl OllamaClient {
"num_predict": 4096 // Give reasoning models plenty of output room "num_predict": 4096 // Give reasoning models plenty of output room
})), })),
keep_alive: Some("1h"), keep_alive: Some("1h"),
format,
}; };
let res = self let res = self
@@ -172,6 +176,7 @@ impl OllamaClient {
"num_predict": 1024 "num_predict": 1024
})), })),
keep_alive: Some("1h"), keep_alive: Some("1h"),
format: None,
}; };
let res = self let res = self