From 9252ab03ca5123bf37f438d9d3568c7d458f1496 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Wed, 2 Sep 2026 02:41:42 +0200 Subject: [PATCH 1/2] poller: per-room history so a mention arrives with its thread; state-of-play line (#90 items 2, 6, 7) Why: a mention reached the agent with a 100-char preview of its parent, or "target not in recent window" when the parent was older than the 10-message fetch; settled facts lived in messages the agent never saw. hermes read "new card" as a GPU card for exactly this reason (issue #90). What: - src/common/room-history.mjs: RoomHistory (JSON file next to seen_file, 400 messages per room, persisted every poll) plus threadSuffix (parent in full with author and time, up to two ancestors), previousSuffix (the asker's previous message), stateOfPlayLine ("state:" / "settled:" messages, deduped, newest wins) and ownLastPostLine. - reply-context.resolveReplyTargets(msgs, lookup): resolves targets outside the batch through the history; snippet 120 -> 300 chars. - room-poller: fetch 25 per poll (poller.fetch_limit), remember every batch, one deeper fetch (100) when a reply target is still unknown, and the notification line carries body (400) + thread + previous message for owner messages and mentions. When a room's batch has an owner message or a mention, one STATE OF PLAY line and one YOUR LAST POST HERE line are prepended for that room. - Notification-file contract kept: one physical line per message. - README: poller.history_file, poller.fetch_limit, the state: convention. Tests: 7 new in test/room-history.test.mjs; suite 304/304. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 + config/codex.desktop.example.json | 5 +- src/common/reply-context.mjs | 15 ++- src/common/room-history.mjs | 190 ++++++++++++++++++++++++++++++ src/team-relay/room-poller.mjs | 56 +++++++-- test/room-history.test.mjs | 112 ++++++++++++++++++ 6 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 src/common/room-history.mjs create mode 100644 test/room-history.test.mjs diff --git a/README.md b/README.md index 101ff23..4d23fdb 100644 --- a/README.md +++ b/README.md @@ -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`, `-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`): diff --git a/config/codex.desktop.example.json b/config/codex.desktop.example.json index b2c6365..6652ede 100644 --- a/config/codex.desktop.example.json +++ b/config/codex.desktop.example.json @@ -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, diff --git a/src/common/reply-context.mjs b/src/common/reply-context.mjs index 9a9a849..d7d59ac 100644 --- a/src/common/reply-context.mjs +++ b/src/common/reply-context.mjs @@ -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) { 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; diff --git a/src/common/room-history.mjs b/src/common/room-history.mjs new file mode 100644 index 0000000..8f89d96 --- /dev/null +++ b/src/common/room-history.mjs @@ -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)}"`; +} diff --git a/src/team-relay/room-poller.mjs b/src/team-relay/room-poller.mjs index 903e54f..8891c80 100644 --- a/src/team-relay/room-poller.mjs +++ b/src/team-relay/room-poller.mjs @@ -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. @@ -171,6 +172,12 @@ export function writeHeartbeat(path) { 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); 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 +228,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}`); @@ -274,8 +282,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 +306,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 +340,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) diff --git a/test/room-history.test.mjs b/test/room-history.test.mjs new file mode 100644 index 0000000..6da2e88 --- /dev/null +++ b/test/room-history.test.mjs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { RoomHistory, threadSuffix, previousSuffix, stateOfPlayLine, ownLastPostLine } from '../src/common/room-history.mjs'; +import { resolveReplyTargets } from '../src/common/reply-context.mjs'; + +const R = 'thinkoff-development'; +function msg(id, from, body, created_at, reply_to) { + return { id, from, body, created_at, reply_to }; +} +function fresh() { + return new RoomHistory(join(mkdtempSync(join(tmpdir(), 'iak-hist-')), 'h.json'), { maxPerRoom: 5 }); +} + +describe('room-history (issue #90: the thread, not the window)', () => { + it('remembers across polls, persists, dedupes by id and caps per room', () => { + const h = fresh(); + h.remember(R, [msg('a', 'petrus', 'one', '2026-09-01T19:00:00Z')]); + h.remember(R, [msg('a', 'petrus', 'one edited', '2026-09-01T19:00:00Z'), msg('b', '@x', 'two', '2026-09-01T19:01:00Z')]); + assert.equal(h.get(R, 'a').body, 'one edited'); + assert.ok(h.save()); + const again = new RoomHistory(h.path); + assert.equal(again.get(R, 'b').from, '@x'); + for (let i = 0; i < 10; i++) h.remember(R, [msg('m' + i, '@x', 'n' + i, `2026-09-01T20:0${i}:00Z`.slice(0, 20))]); + assert.equal(h.rooms[R].length, 5); + assert.equal(h.get(R, 'a'), null, 'oldest evicted'); + }); + + it('resolves a reply target older than the fetch window via lookup', () => { + const h = fresh(); + // poll 1: the parent passes by + h.remember(R, [msg('card', '@claudeMB', 'Card v2 for your review, nothing published by me. Changes: M5 solo column re-measured...', '2026-09-01T15:46:19Z')]); + // poll 2: only the reply is in the window + const batch = [msg('reply', 'petrus', 'Yeah you have a new card which I cant post', '2026-09-01T19:04:46Z', 'card')]; + resolveReplyTargets(batch, (id) => h.get(R, id)); + assert.equal(batch[0]._replyTarget.from, '@claudeMB'); + assert.ok(batch[0]._replyTarget.body.startsWith('Card v2 for your review')); + const suffix = threadSuffix(h, R, batch[0]); + assert.ok(suffix.includes('in reply to @claudeMB (15:46)'), suffix); + assert.ok(suffix.includes('Card v2 for your review, nothing published by me'), suffix); + assert.ok(!suffix.includes('\n'), 'single line by contract'); + }); + + it('walks the chain above the parent, bounded, and stays single-line', () => { + const h = fresh(); + h.remember(R, [ + msg('g', 'petrus', 'grand\nparent', '2026-09-01T10:00:00Z'), + msg('p', '@x', 'parent', '2026-09-01T10:01:00Z', 'g'), + msg('c', 'petrus', 'child', '2026-09-01T10:02:00Z', 'p') + ]); + const s = threadSuffix(h, R, h.get(R, 'c') && { id: 'c', from: 'petrus', reply_to: 'p', _replyToId: 'p' }); + assert.ok(s.includes('in reply to @x (10:01): "parent"'), s); + assert.ok(s.includes('⤷⤷ petrus: "grand parent"'), s); + assert.ok(!s.includes('\n')); + }); + + it('falls back to the unresolved marker when nobody knows the target', () => { + const h = fresh(); + const s = threadSuffix(h, R, { id: 'z', reply_to: 'ghost', _replyToId: 'ghost' }); + assert.ok(s.includes('target ghost not in recent window'), s); + }); + + it("gives the asker's previous message, not the reply target, and skips self", () => { + const h = fresh(); + h.remember(R, [ + msg('p1', 'petrus', 'I found only these what more shall i order', '2026-09-01T20:56:03Z'), + msg('a1', '@claudeMB', 'Found on the table...', '2026-09-01T20:59:15Z'), + msg('p2', 'petrus', 'I reorder this one?', '2026-09-01T21:14:53Z') + ]); + const m = { id: 'p2', from: 'petrus', created_at: '2026-09-01T21:14:53Z' }; + const s = previousSuffix(h, R, m); + assert.ok(s.includes("petrus's previous message (20:56)"), s); + assert.ok(s.includes('what more shall i order'), s); + assert.equal(previousSuffix(h, R, { id: 'p1', from: 'petrus', created_at: '2026-09-01T20:56:03Z' }), ''); + }); + + it('collects state: lines, dedupes, caps, renders one line', () => { + const h = fresh(); + h.remember(R, [ + msg('s1', '@claudeMB', 'state: "card" means the benchmark table v2, not hardware', '2026-09-01T19:37:00Z'), + msg('n1', 'hermes', 'Got it, I cannot see the card either', '2026-09-01T19:38:00Z'), + msg('s2', '@claudemm', 'Settled: BIOS carve fix waits for Petrus in Helsinki', '2026-09-01T19:40:00Z'), + msg('s3', 'petrus', 'state: "card" means the benchmark table v2, not hardware', '2026-09-01T19:41:00Z') + ]); + const e = h.stateEntries(R); + assert.equal(e.length, 2, 'duplicate text collapsed'); + // newest occurrence of a duplicate wins, entries are oldest-first + assert.deepEqual(e.map((x) => x.text), [ + 'BIOS carve fix waits for Petrus in Helsinki', + '"card" means the benchmark table v2, not hardware' + ]); + assert.equal(e[1].from, 'petrus', 'the newest sender of the duplicated fact'); + const line = stateOfPlayLine(h, R, '2026-09-01T20:00:00Z'); + assert.ok(line.startsWith(`[2026-09-01T20:00:00] [${R}] STATE OF PLAY: `), line); + assert.ok(line.includes('BIOS carve fix waits for Petrus in Helsinki (claudemm)'), line); + assert.ok(!line.includes('\n')); + assert.equal(stateOfPlayLine(fresh(), R), ''); + }); + + it("renders the agent's own last post when recent, nothing when stale or absent", () => { + const h = fresh(); + h.remember(R, [msg('o1', '@claudeMB', 'PR 24 is ready to merge', '2026-09-01T21:36:24Z')]); + const line = ownLastPostLine(h, R, '@claudemb', '2026-09-01T21:50:00Z'); + assert.ok(line.includes('YOUR LAST POST HERE (21:36): "PR 24 is ready to merge"'), line); + assert.equal(ownLastPostLine(h, R, '@claudemb', '2026-09-02T09:00:00Z'), '', 'older than two hours'); + assert.equal(ownLastPostLine(h, R, '@nobody', '2026-09-01T21:50:00Z'), ''); + }); +}); From fa043ae65c7cf6d270d11c23b5acebdff5d6925b Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Wed, 2 Sep 2026 02:49:16 +0200 Subject: [PATCH 2/2] poller: first-run seed also fills the room history (codexmb #92 review); seedRoom() exported with a regression test Co-Authored-By: Claude Fable 5.1 --- src/team-relay/room-poller.mjs | 23 +++++++++++++++++++---- test/room-history.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/team-relay/room-poller.mjs b/src/team-relay/room-poller.mjs index 8891c80..40a1c56 100644 --- a/src/team-relay/room-poller.mjs +++ b/src/team-relay/room-poller.mjs @@ -169,6 +169,20 @@ 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; @@ -243,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`); } diff --git a/test/room-history.test.mjs b/test/room-history.test.mjs index 6da2e88..d038ed9 100644 --- a/test/room-history.test.mjs +++ b/test/room-history.test.mjs @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { RoomHistory, threadSuffix, previousSuffix, stateOfPlayLine, ownLastPostLine } from '../src/common/room-history.mjs'; import { resolveReplyTargets } from '../src/common/reply-context.mjs'; +import { seedRoom } from '../src/team-relay/room-poller.mjs'; const R = 'thinkoff-development'; function msg(id, from, body, created_at, reply_to) { @@ -110,3 +111,23 @@ describe('room-history (issue #90: the thread, not the window)', () => { assert.equal(ownLastPostLine(h, R, '@nobody', '2026-09-01T21:50:00Z'), ''); }); }); + + +describe('first-run seed keeps the thread (codexmb, PR #92)', () => { + it('seeded messages are both seen and remembered, so a reply to one resolves on the next poll', () => { + const h = fresh(); + const seen = new Set(); + const seeded = [ + msg('old1', '@claudeMB', 'Card v2 for your review', '2026-09-01T15:46:19Z'), + msg('old2', 'petrus', 'But these people get 15 tps?', '2026-09-01T18:53:35Z') + ]; + assert.equal(seedRoom({ seen, history: h, room: R, msgs: seeded }), 2); + assert.ok(seen.has('old1') && seen.has('old2')); + assert.equal(seedRoom({ seen, history: h, room: R, msgs: seeded }), 0, 'idempotent'); + // next poll: only the reply is in the window + const batch = [msg('new', 'petrus', 'you have a new card which I cant post', '2026-09-01T19:04:46Z', 'old1')]; + resolveReplyTargets(batch, (id) => h.get(R, id)); + assert.equal(batch[0]._replyTarget.from, '@claudeMB'); + assert.ok(threadSuffix(h, R, batch[0]).includes('Card v2 for your review')); + }); +});