From b10ea3eeabec785530f0334db6f1704c98de9c74 Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Wed, 15 Jul 2026 12:48:48 +0300 Subject: [PATCH 1/3] feat(agent): local-model room agent (Ollama -> GroupMind, cloud/relay failover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts an on-device (Ollama) model into a GroupMind room: polls the room, and on an @mention prompts the local model and posts the reply back — so a local model becomes a first-class room participant. Reads/writes cloud GroupMind when the net is up and the LAN GroupMind Local relay when it isn't, so point mm + mb agents at the same relay, flip the net off, and the local pair keeps talking. Zero deps. History-skip on first poll, never replies to itself, mention-gated by default. 5 unit tests (mention/self/history/respond-all + Ollama call). Suite 155 green. Verified live: qwen3.5:9b on the M4 Mini (16.6 tps) introduced itself in the room. Note: a 27B is too big for 24GB here (GGUF 4.9 tps + RAM-tight, MLX won't load). Co-Authored-By: Claude Opus 4.8 --- package.json | 5 +- scripts/local-agent.mjs | 150 ++++++++++++++++++++++++++++++++++++++ test/local-agent.test.mjs | 90 +++++++++++++++++++++++ 3 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 scripts/local-agent.mjs create mode 100644 test/local-agent.test.mjs diff --git a/package.json b/package.json index 2520146..db236f6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test": "node --test test/*.test.mjs packages/user-intent-kit/test/*.test.js", "start": "node bin/cli.mjs serve", "mcp": "node bin/iak-mcp.mjs", - "relay": "node scripts/local-relay.mjs" + "relay": "node scripts/local-relay.mjs", + "agent": "node scripts/local-agent.mjs" }, "keywords": [ "ide", @@ -50,4 +51,4 @@ "@modelcontextprotocol/sdk": "^1.29.0", "user-intent-kit": "file:packages/user-intent-kit" } -} \ No newline at end of file +} diff --git a/scripts/local-agent.mjs b/scripts/local-agent.mjs new file mode 100644 index 0000000..bf38eb7 --- /dev/null +++ b/scripts/local-agent.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +// +// iak-local-agent — put a local (Ollama) model into a GroupMind room. +// +// Runs a small poll loop: read the room, and when a message @mentions this +// agent's handle, prompt the local model over Ollama's HTTP API and post the +// reply back — so an on-device model becomes a first-class room participant. +// +// It fails over between endpoints: it reads/writes cloud GroupMind when the +// internet is up, and the LAN GroupMind Local relay when it isn't. Point mm and +// mb agents at the same relay, flip the net off, and the local pair keeps +// talking — no cloud, no API keys, models running on-device. +// +// Zero runtime deps (global fetch + built-ins), matching the rest of IAK. +// +// Config (via runAgent(config) or env for the CLI): +// handle this agent's @handle (it never replies to itself) +// room room slug +// cloud { baseUrl, apiKey } cloud GroupMind (preferred when up) +// relay { baseUrl, token? } LAN relay (failover) +// ollama { url, model } e.g. http://127.0.0.1:11434, gpt-oss:20b +// systemPrompt persona / instructions for the model +// respondTo 'mention' (default) or 'all' +// pollMs poll interval (default 4000) +// maxContext how many recent messages to feed the model (default 8) + +const MENTION = (handle) => new RegExp(`(^|[^\\w])@?${handle.replace(/^@/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'); + +export function mentions(body, handle) { + return MENTION(handle).test(String(body || '')); +} + +export function sameHandle(a, b) { + return String(a || '').replace(/^@/, '').toLowerCase() === String(b || '').replace(/^@/, '').toLowerCase(); +} + +// Ask the local Ollama model for a reply. Returns trimmed text ('' on empty). +export async function generateReply({ url, model, systemPrompt, context }) { + const res = await fetch(`${url.replace(/\/$/, '')}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + stream: false, + messages: [ + ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), + { role: 'user', content: context }, + ], + }), + signal: AbortSignal.timeout(180_000), + }); + if (!res.ok) throw new Error(`ollama HTTP ${res.status}: ${(await res.text()).slice(0, 120)}`); + const d = await res.json(); + return String(d?.message?.content || '').trim(); +} + +function headersFor(ep) { + const h = { 'Content-Type': 'application/json' }; + const key = ep.apiKey || ep.token; + if (key) { h['X-API-Key'] = key; h['Authorization'] = `Bearer ${key}`; } + return h; +} + +async function fetchMessages(ep, room, limit) { + const res = await fetch(`${ep.baseUrl.replace(/\/$/, '')}/rooms/${encodeURIComponent(room)}/messages?limit=${limit}`, + { headers: headersFor(ep), signal: AbortSignal.timeout(8000) }); + if (!res.ok) throw new Error(`fetch HTTP ${res.status}`); + const d = await res.json(); + return d?.messages || []; +} + +async function postMessage(ep, room, body) { + const res = await fetch(`${ep.baseUrl.replace(/\/$/, '')}/messages`, + { method: 'POST', headers: headersFor(ep), body: JSON.stringify({ room, body }), signal: AbortSignal.timeout(8000) }); + if (!res.ok) throw new Error(`post HTTP ${res.status}`); + return res.json().catch(() => ({})); +} + +// Try cloud first, fall back to relay. Returns { ep, messages } or null. +async function readWithFailover(cfg, log) { + const eps = [cfg.cloud, cfg.relay].filter((e) => e && e.baseUrl); + for (const ep of eps) { + try { + const messages = await fetchMessages(ep, cfg.room, cfg.limit); + return { ep, messages }; + } catch (e) { + log(`read via ${ep.baseUrl} failed (${e.message}); trying next`); + } + } + return null; +} + +// One poll cycle. `state` carries { seen:Set, primed:bool }. Injectable deps +// (read/generate/post) make it unit-testable without a live room or model. +export async function tick(cfg, state, deps) { + const read = deps?.read || (() => readWithFailover(cfg, deps.log)); + const generate = deps?.generate || generateReply; + const post = deps?.post || postMessage; + const log = deps?.log || (() => {}); + + const r = await read(); + if (!r || !r.messages) return; + const chronological = [...r.messages].reverse(); // room returns newest-first + for (const m of chronological) { + if (state.seen.has(m.id)) continue; + state.seen.add(m.id); + if (!state.primed) continue; // ignore history on the first cycle + if (sameHandle(m.from, cfg.handle)) continue; // never reply to ourselves + if ((cfg.respondTo || 'mention') === 'mention' && !mentions(m.body, cfg.handle)) continue; + try { + const context = `${m.from}: ${m.body}`; + const reply = await generate({ ...cfg.ollama, systemPrompt: cfg.systemPrompt, context }); + if (reply) { await post(r.ep, cfg.room, reply); log(`replied to ${m.from} (${reply.length} chars)`); } + } catch (e) { + log(`reply failed for ${m.id}: ${e.message}`); + } + } + state.primed = true; +} + +export async function runAgent(cfg) { + const log = cfg.logger || ((m) => process.stderr.write(`[iak-local-agent:${cfg.handle}] ${m}\n`)); + const full = { limit: cfg.maxContext || 8, pollMs: cfg.pollMs || 4000, ...cfg }; + const state = { seen: new Set(), primed: false }; + log(`up — model=${cfg.ollama?.model} room=${cfg.room} respondTo=${cfg.respondTo || 'mention'}`); + // eslint-disable-next-line no-constant-condition + while (true) { + try { await tick(full, state, { log }); } catch (e) { log(`tick error: ${e.message}`); } + await new Promise((r) => setTimeout(r, full.pollMs)); + } +} + +// CLI: config from IAK dogfood config + env overrides. +if (import.meta.url === `file://${process.argv[1]}`) { + const { readFileSync } = await import('node:fs'); + const cfgPath = process.env.IAK_CONFIG || '/Users/petrus/ide-agent-kit/config/dogfood.json'; + let base = {}; + try { base = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* fall back to env */ } + runAgent({ + handle: process.env.AGENT_HANDLE || 'mm-local', + room: process.env.AGENT_ROOM || base?.poller?.room || base?.mcp?.confirmations?.room || 'thinkoff-development', + cloud: { baseUrl: 'https://groupmind.one/api/v1', apiKey: process.env.AGENT_KEY || base?.poller?.api_key }, + relay: process.env.RELAY_URL ? { baseUrl: process.env.RELAY_URL, token: process.env.IAK_RELAY_TOKEN } : undefined, + ollama: { url: process.env.OLLAMA_URL || 'http://127.0.0.1:11434', model: process.env.OLLAMA_MODEL || 'gpt-oss:20b' }, + systemPrompt: process.env.AGENT_SYSTEM + || `You are ${process.env.AGENT_HANDLE || 'mm-local'}, a local on-device model running on a Mac mini in the ThinkOff fleet room. Be concise and useful. You run fully offline via Ollama.`, + respondTo: process.env.AGENT_RESPOND_TO || 'mention', + }); +} diff --git a/test/local-agent.test.mjs b/test/local-agent.test.mjs new file mode 100644 index 0000000..0f6e373 --- /dev/null +++ b/test/local-agent.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import http from 'node:http'; +import { once } from 'node:events'; +import { mentions, sameHandle, generateReply, tick } from '../scripts/local-agent.mjs'; + +test('mentions detects @handle and ignores non-mentions', () => { + assert.equal(mentions('hey @mm-local can you help', 'mm-local'), true); + assert.equal(mentions('hey @mm-local can you help', '@mm-local'), true); + assert.equal(mentions('MM-LOCAL please', 'mm-local'), true); + assert.equal(mentions('talking about mm-locality here', 'mm-local'), false); + assert.equal(mentions('nothing to see', 'mm-local'), false); +}); + +test('sameHandle is @- and case-insensitive', () => { + assert.equal(sameHandle('@MM-Local', 'mm-local'), true); + assert.equal(sameHandle('mm-local', '@mm-local'), true); + assert.equal(sameHandle('@other', 'mm-local'), false); +}); + +test('generateReply POSTs to ollama /api/chat and returns the content', async () => { + let received = null; + const server = http.createServer((req, res) => { + let b = ''; + req.on('data', (c) => (b += c)); + req.on('end', () => { + received = JSON.parse(b); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: { content: ' hi from the local model ' } })); + }); + }); + server.listen(0); + await once(server, 'listening'); + try { + const url = `http://127.0.0.1:${server.address().port}`; + const out = await generateReply({ url, model: 'qwen3.6:27b', systemPrompt: 'be nice', context: '@p: hello' }); + assert.equal(out, 'hi from the local model'); // trimmed + assert.equal(received.model, 'qwen3.6:27b'); + assert.equal(received.stream, false); + assert.equal(received.messages[0].role, 'system'); + assert.equal(received.messages[1].content, '@p: hello'); + } finally { + server.close(); + await once(server, 'close'); + } +}); + +test('tick: skips history, then replies to a mention (not self, not non-mention)', async () => { + const posts = []; + const cfg = { room: 'r', handle: 'mm-local', ollama: { url: 'x', model: 'm' }, respondTo: 'mention' }; + const deps = { + generate: async ({ context }) => `echo:${context}`, + post: async (ep, room, body) => { posts.push({ room, body }); }, + log: () => {}, + }; + const state = { seen: new Set(), primed: false }; + + // tick 1: one historical message that DOES mention us — must be skipped (priming) + deps.read = async () => ({ ep: { baseUrl: 'x' }, messages: [{ id: '1', from: '@petrus', body: '@mm-local hi' }] }); + await tick(cfg, state, deps); + assert.equal(posts.length, 0, 'history is not answered'); + + // tick 2: a NEW mention -> reply; plus self + non-mention that must be ignored + deps.read = async () => ({ + ep: { baseUrl: 'x' }, + messages: [ // newest-first, as the API returns + { id: '4', from: '@other', body: 'unrelated chatter' }, + { id: '3', from: '@mm-local', body: '@mm-local self echo loop' }, + { id: '2', from: '@petrus', body: '@mm-local what is 2+2' }, + { id: '1', from: '@petrus', body: '@mm-local hi' }, + ], + }); + await tick(cfg, state, deps); + assert.equal(posts.length, 1, 'exactly one reply (the new mention)'); + assert.equal(posts[0].body, 'echo:@petrus: @mm-local what is 2+2'); +}); + +test('tick: respondTo=all replies to any new non-self message', async () => { + const posts = []; + const cfg = { room: 'r', handle: 'mm-local', ollama: { url: 'x', model: 'm' }, respondTo: 'all' }; + const state = { seen: new Set(), primed: true }; // already primed + const deps = { + read: async () => ({ ep: { baseUrl: 'x' }, messages: [{ id: '9', from: '@petrus', body: 'no mention here' }] }), + generate: async () => 'sure', + post: async (ep, room, body) => { posts.push(body); }, + log: () => {}, + }; + await tick(cfg, state, deps); + assert.equal(posts.length, 1); +}); From 5ab0a57abc574bf226a67ad4796db64be0c6cd27 Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Wed, 15 Jul 2026 13:21:26 +0300 Subject: [PATCH 2/3] feat(agent): strip reasoning + default to the Mini's qwen3.6 MoE The fleet's local models are reasoning models (the Mini runs qwen3.6:35b-a3b MoE at UD-Q2_K_XL, load-tested at 31.7 tps on the M4), so generateReply now strips ... so room replies read clean. Default OLLAMA_MODEL updated to the MoE tag. +1 test. Co-Authored-By: Claude Opus 4.8 --- scripts/local-agent.mjs | 6 ++++-- test/local-agent.test.mjs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/local-agent.mjs b/scripts/local-agent.mjs index bf38eb7..3914c2f 100644 --- a/scripts/local-agent.mjs +++ b/scripts/local-agent.mjs @@ -52,7 +52,9 @@ export async function generateReply({ url, model, systemPrompt, context }) { }); if (!res.ok) throw new Error(`ollama HTTP ${res.status}: ${(await res.text()).slice(0, 120)}`); const d = await res.json(); - return String(d?.message?.content || '').trim(); + // Strip thinking-model output (...) so room replies read clean — + // the fleet's local models (e.g. qwen3.6 MoE) are reasoning models. + return String(d?.message?.content || '').replace(/[\s\S]*?<\/think>/gi, '').trim(); } function headersFor(ep) { @@ -142,7 +144,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { room: process.env.AGENT_ROOM || base?.poller?.room || base?.mcp?.confirmations?.room || 'thinkoff-development', cloud: { baseUrl: 'https://groupmind.one/api/v1', apiKey: process.env.AGENT_KEY || base?.poller?.api_key }, relay: process.env.RELAY_URL ? { baseUrl: process.env.RELAY_URL, token: process.env.IAK_RELAY_TOKEN } : undefined, - ollama: { url: process.env.OLLAMA_URL || 'http://127.0.0.1:11434', model: process.env.OLLAMA_MODEL || 'gpt-oss:20b' }, + ollama: { url: process.env.OLLAMA_URL || 'http://127.0.0.1:11434', model: process.env.OLLAMA_MODEL || 'hf.co/unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q2_K_XL' }, systemPrompt: process.env.AGENT_SYSTEM || `You are ${process.env.AGENT_HANDLE || 'mm-local'}, a local on-device model running on a Mac mini in the ThinkOff fleet room. Be concise and useful. You run fully offline via Ollama.`, respondTo: process.env.AGENT_RESPOND_TO || 'mention', diff --git a/test/local-agent.test.mjs b/test/local-agent.test.mjs index 0f6e373..2355f99 100644 --- a/test/local-agent.test.mjs +++ b/test/local-agent.test.mjs @@ -45,6 +45,26 @@ test('generateReply POSTs to ollama /api/chat and returns the content', async () } }); +test('generateReply strips reasoning from thinking models', async () => { + const server = http.createServer((req, res) => { + let b = ''; + req.on('data', (c) => (b += c)); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: { content: 'let me reason about this\nstep by step\n\nThe answer is 42.' } })); + }); + }); + server.listen(0); + await once(server, 'listening'); + try { + const out = await generateReply({ url: `http://127.0.0.1:${server.address().port}`, model: 'm', context: 'q' }); + assert.equal(out, 'The answer is 42.', 'thinking stripped, only the answer remains'); + } finally { + server.close(); + await once(server, 'close'); + } +}); + test('tick: skips history, then replies to a mention (not self, not non-mention)', async () => { const posts = []; const cfg = { room: 'r', handle: 'mm-local', ollama: { url: 'x', model: 'm' }, respondTo: 'mention' }; From 01fa385e7b84c22d64c6173846e64508506ed036 Mon Sep 17 00:00:00 2001 From: ThinkOff Date: Wed, 15 Jul 2026 15:12:58 +0300 Subject: [PATCH 3/3] feat(agent): default to qwen3.6 MoE Q3_K_M + keep_alive unload Q3_K_M is the Mini pick: clean output (fixes Q2's untagged reasoning-leak), 22.6 tps, fits 24GB (tight). Added a keep_alive (default 5m) so the 17GB model unloads when idle instead of pinning RAM the Mini's other services need. Co-Authored-By: Claude Opus 4.8 --- scripts/local-agent.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/local-agent.mjs b/scripts/local-agent.mjs index 3914c2f..e41bd7d 100644 --- a/scripts/local-agent.mjs +++ b/scripts/local-agent.mjs @@ -36,13 +36,16 @@ export function sameHandle(a, b) { } // Ask the local Ollama model for a reply. Returns trimmed text ('' on empty). -export async function generateReply({ url, model, systemPrompt, context }) { +export async function generateReply({ url, model, systemPrompt, context, keepAlive }) { const res = await fetch(`${url.replace(/\/$/, '')}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, stream: false, + // Unload the model after idle so a big local model (e.g. the Mini's 17GB + // MoE) doesn't permanently pin RAM the machine's other services need. + keep_alive: keepAlive || '5m', messages: [ ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), { role: 'user', content: context }, @@ -144,7 +147,11 @@ if (import.meta.url === `file://${process.argv[1]}`) { room: process.env.AGENT_ROOM || base?.poller?.room || base?.mcp?.confirmations?.room || 'thinkoff-development', cloud: { baseUrl: 'https://groupmind.one/api/v1', apiKey: process.env.AGENT_KEY || base?.poller?.api_key }, relay: process.env.RELAY_URL ? { baseUrl: process.env.RELAY_URL, token: process.env.IAK_RELAY_TOKEN } : undefined, - ollama: { url: process.env.OLLAMA_URL || 'http://127.0.0.1:11434', model: process.env.OLLAMA_MODEL || 'hf.co/unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q2_K_XL' }, + ollama: { + url: process.env.OLLAMA_URL || 'http://127.0.0.1:11434', + model: process.env.OLLAMA_MODEL || 'hf.co/unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q3_K_M', + keepAlive: process.env.OLLAMA_KEEP_ALIVE || '5m', + }, systemPrompt: process.env.AGENT_SYSTEM || `You are ${process.env.AGENT_HANDLE || 'mm-local'}, a local on-device model running on a Mac mini in the ThinkOff fleet room. Be concise and useful. You run fully offline via Ollama.`, respondTo: process.env.AGENT_RESPOND_TO || 'mention',