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); +});