Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -50,4 +51,4 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"user-intent-kit": "file:packages/user-intent-kit"
}
}
}
159 changes: 159 additions & 0 deletions scripts/local-agent.mjs
Original file line number Diff line number Diff line change
@@ -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 (<think>...</think>) so room replies read clean —
// the fleet's local models (e.g. qwen3.6 MoE) are reasoning models.
return String(d?.message?.content || '').replace(/<think>[\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) });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the sender when posting relay replies

In offline/relay mode with respondTo: 'all', this POST omits from: cfg.handle, so scripts/local-relay.mjs stores the agent's own replies as from: "unknown"; on the next poll they no longer match sameHandle(m.from, cfg.handle) and the agent replies to its own previous relay message indefinitely. Include the configured handle when posting to the local relay so the self guard still works.

Useful? React with 👍 / 👎.

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',
});
}
110 changes: 110 additions & 0 deletions test/local-agent.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <think> 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: '<think>let me reason about this\nstep by step</think>\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);
});
Loading