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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,8 @@ See `config/team-relay.example.json` for the full config shape. Key sections:
- `github.event_kinds` - which GitHub events to accept
- `poller.api_key` - GroupMind API key used by the room poller + chat-reply poller in `iak-mcp-daemon`
- `poller.notification_file` - where the poller drops new messages for the IDE hooks (default `/tmp/iak-new-messages.txt`); also read by `scripts/session-bootstrap.sh`
- `poller.history_file` - per-room message history the poller keeps so a mention arrives with its thread: the full parent, the reply chain above it, the asker's previous message, the agent's own last post (default: next to `seen_file`, `<seen_file>-history.json`; 400 messages per room). `poller.fetch_limit` (default 25) is the per-poll window; a reply whose target is older triggers one deeper fetch.
- **State of play.** Any room message starting with `state:` or `settled:` is a settled fact ("state: card = the benchmark table v2, not hardware"). Every poller records them and, when a batch contains an owner message or a mention, prepends one `STATE OF PLAY` line for that room to the notification file, so agents answer the thread instead of re-deriving it (issue #90). Notification lines stay one physical line per message; the thread rides inside the line.
- `poller.handle` - the agent's room handle; `scripts/session-bootstrap.sh` uses it to label the bootstrap instructions
- `mcp.sessions` - list of tmux sessions `wake_all` MCP tool targets
- `mcp.confirmations` - confirmation registry settings (used by `iak-mcp-daemon`):
Expand Down
5 changes: 3 additions & 2 deletions config/codex.desktop.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@
"lattice-qcd"
],
"api_key_file": "/Users/you/.iak/codexmb_api_key.txt",
"heartbeat_file": "/tmp/iak-poller.heartbeat",
"heartbeat_file": "/tmp/iak-poller.heartbeat",
"handle": "@CodexMB",
"interval_sec": 60,
"nudge_mode": "command",
"nudge_command": "/ABSOLUTE/PATH/ide-agent-kit/tools/codex_gui_nudge.sh",
"notification_file": "/tmp/codex-room-notifications.txt",
"seen_file": "/tmp/codex-room-seen.txt"
"seen_file": "/tmp/codex-room-seen.txt",
"history_file": "/tmp/codex-room-history.json"
},
"dm_poller": {
"enabled": true,
Expand Down
15 changes: 11 additions & 4 deletions src/common/reply-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ export function embeddedTargetOf(raw) {
* object form when the target is older than the window. Sets m._replyToId
* and m._replyTarget in place; returns the batch for chaining.
*/
export function resolveReplyTargets(msgs) {
export function resolveReplyTargets(msgs, lookup) {

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 Apply history to the unified GroupMind adapter

When GroupMind runs through the supported ide-agent-kit platform watch path, bin/cli.mjs creates a UnifiedPoller with groupmindAdapter, but src/adapters/groupmind.mjs:19-35 still fetches only 10 messages and invokes this function without the new lookup. Consequently poller.history_file, poller.fetch_limit, state/previous/own context, and old-parent resolution have no effect on that path, so replies outside its ten-message window retain the unresolved marker. Pass the history-aware lookup and formatting through the unified adapter as well.

Useful? React with 👍 / 👎.

const byId = new Map(msgs.map((m) => [m.id, m]));
for (const m of msgs) {
const replyId = replyIdOf(m.reply_to);
if (replyId) m._replyToId = replyId;
if (replyId && byId.has(replyId)) {
const t = byId.get(replyId);
m._replyTarget = { from: t.from || t.sender || '?', body: (t.body || '').slice(0, 120) };
if (!replyId) continue;
let t = byId.get(replyId) || null;
// Outside the poll window: ask the caller's history (issue #90). The
// room API has no single-message fetch, so this is the only way an
// older parent ever resolves.
if (!t && typeof lookup === 'function') {
try { t = lookup(replyId) || null; } catch { t = null; }
}
if (t) {
m._replyTarget = { from: t.from || t.sender || '?', body: (t.body || '').slice(0, 300), created_at: t.created_at || '' };
} else {
const embedded = embeddedTargetOf(m.reply_to);
if (embedded) m._replyTarget = embedded;
Expand Down
190 changes: 190 additions & 0 deletions src/common/room-history.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: AGPL-3.0-only

/**
* Per-room message history for the pollers: the thread an agent needs to
* understand a mention, kept locally and persisted across polls.
*
* Why (issue #90, 1-2 Sep 2026): a mention reached the agent with a
* 100-character preview of its parent, or "target not in recent window" when
* the parent was older than the 10-message fetch. hermes read "new card" as a
* GPU because the messages that had settled "card = benchmark table" were
* outside the window. The room API has no single-message fetch and ignores
* pagination, but every poller already sees every message as it passes, so
* remembering the last few hundred per room gives us: the full parent, the
* reply chain above it, the asker's previous message, the agent's own last
* post, and the room's settled facts ("state: ..." lines).
*
* File format: JSON { [room]: [ {id, from, body, created_at, reply_to} ] },
* newest last, capped per room. One file per poller (next to seen_file).
*/

import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { replyIdOf } from './reply-context.mjs';

const BODY_KEEP = 600;
export const STATE_PREFIX = /^\s*(?:state|settled)\s*:\s*(.+)$/i;

function oneLine(s, n) {
return String(s || '').replace(/\s+/g, ' ').trim().slice(0, n);
}

function hhmm(ts) {
const m = /T(\d{2}:\d{2})/.exec(ts || '');
return m ? m[1] : '';
}

export class RoomHistory {
constructor(path, { maxPerRoom = 400 } = {}) {
this.path = path;
this.maxPerRoom = maxPerRoom;
this.rooms = {};
this.load();
}

load() {
try {
const data = JSON.parse(readFileSync(this.path, 'utf8'));
if (data && typeof data === 'object') this.rooms = data;
} catch {
this.rooms = {};
}
}

save() {
try {
mkdirSync(dirname(this.path), { recursive: true });
const tmp = this.path + '.tmp';
writeFileSync(tmp, JSON.stringify(this.rooms));
renameSync(tmp, this.path);
return true;
} catch {
return false;
}
}

/** Add or refresh a fetched batch. Idempotent; keeps newest-last order. */
remember(room, msgs) {
if (!room || !Array.isArray(msgs) || msgs.length === 0) return;
const list = this.rooms[room] || [];
const byId = new Map(list.map((m) => [m.id, m]));
for (const m of msgs) {
if (!m || !m.id) continue;
byId.set(m.id, {
id: m.id,
from: m.from || m.sender || '?',
body: oneLine(m.body, BODY_KEEP),
created_at: m.created_at || '',
reply_to: replyIdOf(m.reply_to),
media: [m.image_url && 'image', m.audio_url && 'audio', m.file_url && 'file'].filter(Boolean)
});
}
const merged = [...byId.values()].sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
this.rooms[room] = merged.slice(-this.maxPerRoom);
}

get(room, id) {
if (!room || !id) return null;
return (this.rooms[room] || []).find((m) => m.id === id) || null;
}

/** The sender's latest message strictly before `beforeTs`, excluding ids. */
previousFrom(room, sender, beforeTs, exclude = []) {
const s = String(sender || '').toLowerCase().replace(/^@/, '');
const list = this.rooms[room] || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (exclude.includes(m.id)) continue;
if (beforeTs && (m.created_at || '') >= beforeTs) continue;
if (String(m.from || '').toLowerCase().replace(/^@/, '') === s) return m;
}
return null;
}

/** This agent's own most recent post in the room, if within `withinMs`. */
ownRecent(room, selfHandle, nowMs = Date.now(), withinMs = 2 * 3600 * 1000) {
const m = this.previousFrom(room, selfHandle, null, []);
if (!m) return null;
const t = Date.parse(m.created_at || '');
if (Number.isFinite(t) && nowMs - t > withinMs) return null;
return m;
}

/**
* Settled facts: any message whose body starts with "state:" or
* "settled:" (case-insensitive), from anyone. Deduplicated on the text,
* newest wins, at most `max` entries, oldest first.
*/
stateEntries(room, max = 10) {
const out = [];
const seenText = new Set();
const list = this.rooms[room] || [];
for (let i = list.length - 1; i >= 0 && out.length < max; i--) {
const m = list[i];
const hit = STATE_PREFIX.exec(m.body || '');
if (!hit) continue;
const text = oneLine(hit[1], 160);
const key = text.toLowerCase();
if (seenText.has(key)) continue;
seenText.add(key);
out.push({ from: m.from, created_at: m.created_at, text });
}
return out.reverse();
}
}

/**
* Single-line thread suffix for a message: the parent in full (well, 300
* chars) with its author and time, then up to `maxChain` further ancestors
* shorter. Falls back to the unresolved marker when neither the batch nor
* the history knows the target. Single line by contract: the notification
* file is one entry per physical line (see reply-context.mjs).
*/
export function threadSuffix(history, room, m, { maxChain = 2, firstLen = 300, chainLen = 150 } = {}) {
const replyId = m._replyToId || replyIdOf(m.reply_to);
if (!replyId) return '';
let parent = history.get(room, replyId);
let parts = [];
if (parent) {
parts.push(` ⤷ in reply to ${parent.from}${hhmm(parent.created_at) ? ' (' + hhmm(parent.created_at) + ')' : ''}: "${oneLine(parent.body, firstLen)}"`);
} else if (m._replyTarget) {
parts.push(` ⤷ in reply to ${m._replyTarget.from || '?'}: "${oneLine(m._replyTarget.body, firstLen)}"`);
} else {
return ` ⤷ a reply (target ${replyId} not in recent window)`;
}
let hops = 0;
let cur = parent;
while (cur && cur.reply_to && hops < maxChain) {
const up = history.get(room, cur.reply_to);
if (!up) break;
parts.push(` ⤷⤷ ${up.from}: "${oneLine(up.body, chainLen)}"`);
cur = up;
hops++;
}
return parts.join('');
}

/**
* The asker's previous message, when it is not already the reply target:
* "what did this person say just before" is half the context of a follow-up.
*/
export function previousSuffix(history, room, m, { len = 200 } = {}) {
const prev = history.previousFrom(room, m.from || m.sender, m.created_at, [m.id, m._replyToId].filter(Boolean));
if (!prev) return '';
return ` ⤷ ${prev.from}'s previous message${hhmm(prev.created_at) ? ' (' + hhmm(prev.created_at) + ')' : ''}: "${oneLine(prev.body, len)}"`;
}

/** One line per room per batch: the settled facts, or '' when there are none. */
export function stateOfPlayLine(history, room, nowIso = new Date().toISOString()) {
const entries = history.stateEntries(room);
if (entries.length === 0) return '';
const body = entries.map((e) => `${e.text} (${String(e.from || '?').replace(/^@/, '')})`).join(' | ');
return `[${nowIso.slice(0, 19)}] [${room}] STATE OF PLAY: ${body}`;
}

/** One line: what this agent itself last said here, so it does not re-answer. */
export function ownLastPostLine(history, room, selfHandle, nowIso = new Date().toISOString()) {
const m = history.ownRecent(room, selfHandle, Date.parse(nowIso));
if (!m) return '';
return `[${nowIso.slice(0, 19)}] [${room}] YOUR LAST POST HERE (${hhmm(m.created_at)}): "${oneLine(m.body, 200)}"`;
}
79 changes: 67 additions & 12 deletions src/team-relay/room-poller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { nudgeCommand } from '../utils.mjs';
import { shouldSuppressNudge } from '../intent.mjs';
import { resolveSelfHandle } from '../common/handles.mjs';
import { resolveReplyTargets, replyAnnotation } from '../common/reply-context.mjs';
import { RoomHistory, threadSuffix, previousSuffix, stateOfPlayLine, ownLastPostLine } from '../common/room-history.mjs';

/**
* Room Poller — polls GroupMind rooms and notifies IDE agent of new messages.
Expand Down Expand Up @@ -168,9 +169,29 @@ export function writeHeartbeat(path) {
}
}

/**
* First-run seed for one room: every current message is marked seen (do not
* answer the backlog) AND stored in the history (do not lose the thread when
* the next poll replies to one of them). Exported for the regression test.
*/
export function seedRoom({ seen, history, room, msgs }) {
let added = 0;
for (const m of msgs || []) {
if (m && m.id && !seen.has(m.id)) { seen.add(m.id); added++; }
}
if (history && typeof history.remember === 'function') history.remember(room, msgs || []);
return added;
}

export async function startRoomPoller({ rooms, apiKey, handle, interval, config, sessionOpt }) {
const seenFile = config?.poller?.seen_file || SEEN_FILE_DEFAULT;
const heartbeatFile = config?.poller?.heartbeat_file || HEARTBEAT_FILE_DEFAULT;
// Per-room message history: resolves reply targets older than the fetch
// window, supplies the asker's previous message, the agent's own last post
// and the room's "state:" facts (issue #90). One file per poller.
const historyFile = config?.poller?.history_file || seenFile.replace(/\.txt$/, '') + '-history.json';
const fetchLimit = parsePositiveInt(config?.poller?.fetch_limit, 25);
const history = new RoomHistory(historyFile);

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 Seed the new history from the startup fetch

When the history file is absent—on first launch or an upgrade—this initializes an empty history, while the existing startup seed fetches 50 messages only to mark their IDs seen. The initial poll then remembers just fetchLimit messages (25 by default), permanently discarding seeded messages 26–50 from history; a recent state: fact or the agent's own post in that range will therefore be missing from the next owner's non-reply notification. Remember and save each seed batch, or perform an equivalent initial history backfill.

Useful? React with 👍 / 👎.

const notifyFile = config?.poller?.notification_file || NOTIFY_FILE_DEFAULT;
const queuePath = config?.queue?.path || './ide-agent-queue.jsonl';
const session = sessionOpt || config?.tmux?.ide_session || config?.tmux?.default_session || 'claude';
Expand Down Expand Up @@ -221,6 +242,7 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config,
}
console.log(` seen file: ${seenFile}`);
console.log(` heartbeat: ${heartbeatFile}`);
console.log(` history: ${historyFile} (fetch ${fetchLimit}/poll)`);
if (dmEnabled) {
console.log(` direct messages: enabled`);
console.log(` dm handle: ${dmHandle}`);
Expand All @@ -235,16 +257,17 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config,
const seen = loadSeenIds(seenFile);
const dmSeen = dmEnabled ? loadSeenIds(dmSeenFile) : new Set();

// Seed: mark current messages as seen on first run
// Seed: mark current messages as seen on first run, and REMEMBER them, so
// a reply to one of these on the very next poll still gets its parent
// (codexmb, PR #92 review: seeding without history lost first-run context).
if (seen.size === 0) {
console.log(` seeding seen IDs from current messages...`);
for (const room of rooms) {
const msgs = await fetchRoomMessages(room, apiKey, 50);
for (const m of msgs) {
if (m.id) seen.add(m.id);
}
seedRoom({ seen, history, room, msgs });
}
saveSeenIds(seenFile, seen);
history.save();
console.log(` seeded ${seen.size} IDs`);
}

Expand Down Expand Up @@ -274,8 +297,21 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config,
const mentionNeedle = (selfHandle.startsWith('@') ? selfHandle : '@' + selfHandle).toLowerCase();
const newMessages = [];
for (const room of rooms) {
const msgs = await fetchRoomMessages(room, apiKey);
resolveReplyTargets(msgs);
let msgs = await fetchRoomMessages(room, apiKey, fetchLimit);
history.remember(room, msgs);
const lookup = (id) => history.get(room, id);
resolveReplyTargets(msgs, lookup);
// A reply whose target is older than both the window and our history:
// one deeper fetch per room per poll, then resolve again.
if (msgs.some((m) => m._replyToId && !m._replyTarget && !seen.has(m.id))) {
const deeper = await fetchRoomMessages(room, apiKey, 100);
if (deeper.length) {
history.remember(room, deeper);
resolveReplyTargets(msgs, lookup);
}
}
const roomLines = [];
let roomPriority = false;
for (const m of msgs) {
const mid = m.id;
if (!mid || seen.has(mid)) continue;
Expand All @@ -285,8 +321,9 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config,
const normalizedSender = normalizeHandle(sender);
// Skip own messages
if (normalizedSender === selfHandle) continue;
if (normalizedSender === ownerHandle) hasOwnerMessage = true;
if ((m.body || '').toLowerCase().includes(mentionNeedle)) hasMention = true;
const mentionsSelf = (m.body || '').toLowerCase().includes(mentionNeedle);
if (normalizedSender === ownerHandle) { hasOwnerMessage = true; roomPriority = true; }
if (mentionsSelf) { hasMention = true; roomPriority = true; }

let body = (m.body || '').slice(0, 500);
// Surface attachments (2026-07-19 lost-screenshot lesson): an
Expand Down Expand Up @@ -318,17 +355,35 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config,
const event = await enrichEvent(rawEvent, config);
appendFileSync(queuePath, JSON.stringify(event) + '\n');

// Collect for notification file
const line = `[${ts.slice(0, 19)}] [${room}] ${sender}: ${body.replace(/\n/g, ' ').slice(0, 200)}`
+ replyAnnotation(m._replyToId, m._replyTarget);
newMessages.push(line);
// Collect for notification file. ONE physical line per message
// (readers split on \n and count lines), so the thread rides inside
// the line: parent in full, chain above it, and for owner messages
// or mentions the asker's previous message (issue #90).
const thread = threadSuffix(history, room, m) || replyAnnotation(m._replyToId, m._replyTarget);
const prev = (normalizedSender === ownerHandle || mentionsSelf) ? previousSuffix(history, room, m) : '';
const line = `[${ts.slice(0, 19)}] [${room}] ${sender}: ${body.replace(/\n/g, ' ').slice(0, 400)}`
+ thread + prev;
roomLines.push(line);
newCount++;

console.log(` [${ts.slice(0, 19)}] ${sender} in ${room}: ${body.slice(0, 80)}...`);
}
if (roomLines.length) {
// Context header, only when this batch is worth a wake for this room:
// the settled facts and what we ourselves said last, so the agent
// answers the thread instead of re-verifying or re-answering.
if (roomPriority) {
const sop = stateOfPlayLine(history, room);
if (sop) newMessages.push(sop);
const own = ownLastPostLine(history, room, selfHandle);
if (own) newMessages.push(own);
}
newMessages.push(...roomLines);
}
}

saveSeenIds(seenFile, seen);
history.save();

if (newCount > 0) {
// Primary: write to notification file (always works)
Expand Down
Loading
Loading