-
Notifications
You must be signed in to change notification settings - Fork 3
poller: per-room history so a mention arrives with its thread; state-of-play line (#90) #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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)}"`; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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'; | ||
|
|
@@ -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}`); | ||
|
|
@@ -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`); | ||
| } | ||
|
|
||
|
|
@@ -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; | ||
|
|
@@ -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 | ||
|
|
@@ -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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When GroupMind runs through the supported
ide-agent-kit platform watchpath,bin/cli.mjscreates aUnifiedPollerwithgroupmindAdapter, butsrc/adapters/groupmind.mjs:19-35still fetches only 10 messages and invokes this function without the new lookup. Consequentlypoller.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 👍 / 👎.