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..e41bd7d
--- /dev/null
+++ b/scripts/local-agent.mjs
@@ -0,0 +1,159 @@
+#!/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, 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 },
+ ],
+ }),
+ 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();
+ // 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) {
+ 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 || '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',
+ });
+}
diff --git a/test/local-agent.test.mjs b/test/local-agent.test.mjs
new file mode 100644
index 0000000..2355f99
--- /dev/null
+++ b/test/local-agent.test.mjs
@@ -0,0 +1,110 @@
+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('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' };
+ 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);
+});