diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts index b28f6d091f..2efa82e8b8 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts @@ -61,6 +61,7 @@ vi.mock('@roomote/db/server', async (importOriginal) => { releaseFastAgentMemoryEvents: mockReleaseFastEvents, getBrainSyncState: mockGetSyncState, upsertBrainSyncState: vi.fn(), + deleteBrainSyncStateFamily: vi.fn(), }; }); @@ -82,12 +83,14 @@ beforeEach(() => { }); }); +import { personIdentitySlug } from '../brain-collectors/identity'; import { brainCollectorsJob, brainOutboxDrainJob, buildFastMemoryPage, buildPullRequestFactPage, buildMemoryPage, + resolveTaskMemoryRequest, callBrainWriteTool, isBrainUnreachable, drainBrainHistoricalIngestion, @@ -443,9 +446,92 @@ describe('task memory page identity', () => { completedAt: new Date('2026-08-13T10:00:00Z'), environmentName: null, agentSummary: 'Used the durable approach.', + initiator: { kind: 'user' as const, userId: 'user-1', name: 'Sam Lee' }, + workflow: 'standard' as const, + request: null, pullRequests: [], }; + it('links a linked member to their person page', () => { + const page = buildMemoryPage({ ...base, runId: 101 }); + const slug = personIdentitySlug('user-1'); + + expect(slug).toMatch(/^people\/roomote-member-[0-9a-f]{16}$/); + expect(page.content).toContain('\ninitiated_by: "Sam Lee"\n'); + expect(page.content).toContain('\nroomote_user_id: user-1\n'); + expect(page.content).toContain( + `\ninitiated_by_person: ${JSON.stringify(slug)}\n`, + ); + expect(page.content).toContain(`\nInitiated by [Sam Lee](${slug}).\n`); + }); + + it('names an unlinked human without inventing a person page', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + initiator: { kind: 'user', userId: null, name: 'octocat' }, + }); + + expect(page.content).toContain('\ninitiated_by: "octocat"\n'); + expect(page.content).not.toContain('roomote_user_id'); + expect(page.content).not.toContain('initiated_by_person'); + expect(page.content).toContain('\nInitiated by octocat.\n'); + }); + + it('names the automation that started a task', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + initiator: { kind: 'automation', automation: 'issue_fixer' }, + }); + + expect(page.content).toContain('\ninitiated_by_automation: issue_fixer\n'); + expect(page.content).not.toContain('initiated_by:'); + expect(page.content).toContain( + '\nInitiated by the issue_fixer automation.\n', + ); + }); + + it('never links a person to a review the member merely triggered', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + workflow: 'pr_review', + }); + + expect(page.content).not.toContain('Initiated by'); + expect(page.content).not.toContain('initiated_by'); + expect(page.content).not.toContain('roomote_user_id'); + }); + + it('carries the request the member made, ahead of the outcome', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + request: 'Make the flaky upload test deterministic.', + }); + + const request = page.content.indexOf('## Request'); + const summary = page.content.indexOf('Used the durable approach.'); + + expect(page.content).toContain( + '## Request\n\nMake the flaky upload test deterministic.\n', + ); + expect(request).toBeGreaterThan(-1); + expect(request).toBeLessThan(summary); + }); + + it('omits the initiator line when nothing is known about them', () => { + const page = buildMemoryPage({ + ...base, + runId: 101, + initiator: { kind: 'user', userId: null, name: null }, + }); + + expect(page.content).not.toContain('Initiated by'); + expect(page.content).not.toContain('initiated_by'); + }); + it('keeps separate runs of the same task distinct', () => { const first = buildMemoryPage({ ...base, runId: 101 }); const followUp = buildMemoryPage({ ...base, runId: 102 }); @@ -484,6 +570,71 @@ describe('task memory page identity', () => { }); }); +describe('resolveTaskMemoryRequest', () => { + it('reads the visible prompt from the launch payload', () => { + expect( + resolveTaskMemoryRequest( + { description: ' Fix the login redirect loop. ' }, + 'standard', + ), + ).toBe('Fix the login redirect loop.'); + expect( + resolveTaskMemoryRequest({ text: 'Ship the banner.' }, 'standard'), + ).toBe('Ship the banner.'); + }); + + it('reads a Linear-launched request the way the agent prompt did', () => { + const issue = { + issueTitle: 'Login redirect loop', + issueDescription: 'Users bounce between /login and /home.', + }; + + expect( + resolveTaskMemoryRequest( + { ...issue, commentBody: '@roomote please fix this' }, + 'standard', + ), + ).toBe('@roomote please fix this'); + expect(resolveTaskMemoryRequest(issue, 'standard')).toBe( + 'Users bounce between /login and /home.', + ); + expect( + resolveTaskMemoryRequest({ issueTitle: issue.issueTitle }, 'standard'), + ).toBe('Login redirect loop'); + }); + + it('leaves out generated, hidden, and non-standard prompts', () => { + expect( + resolveTaskMemoryRequest({ description: 'Review this PR.' }, 'pr_review'), + ).toBeNull(); + expect( + resolveTaskMemoryRequest( + { description: 'Set up.', visibleInTranscript: false }, + 'standard', + ), + ).toBeNull(); + expect( + resolveTaskMemoryRequest( + { description: 'bootstrap go' }, + 'standard', + ), + ).toBeNull(); + expect(resolveTaskMemoryRequest({}, 'standard')).toBeNull(); + }); + + it('bounds a long request and says where the rest lives', () => { + const request = resolveTaskMemoryRequest( + { description: 'x'.repeat(2_000) }, + 'standard', + ); + + expect(request).toHaveLength( + 1_500 + '\n\n_Request truncated; open the task for the rest._'.length, + ); + expect(request?.endsWith('open the task for the rest._')).toBe(true); + }); +}); + describe('task memory pull request outcomes', () => { const base = { runId: 7, @@ -492,6 +643,9 @@ describe('task memory pull request outcomes', () => { completedAt: new Date('2026-08-13T10:00:00Z'), environmentName: null, agentSummary: 'Opened a PR with the durable approach.', + initiator: { kind: 'automation' as const, automation: 'issue_fixer' }, + workflow: 'standard' as const, + request: null, }; const pr = { repository: 'owner/repo', diff --git a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts index 0ca6cf9c76..96c3fb7991 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts @@ -13,6 +13,7 @@ import { settleBrainMemoryEvent, releaseBrainMemoryEvents, releaseFastAgentMemoryEvents, + deleteBrainSyncStateFamily, settleFastAgentMemoryEvent, pullRequestFacts, taskPullRequests, @@ -38,12 +39,20 @@ import { BRAIN_PAGE_TYPES, type PullRequestStatus, RunStatus, + type TaskWorkflow, + ACP_ENVELOPE_EVENT_TYPES, + isSystemInjectedAcpPromptText, + normalizeTranscriptUserText, brainNamespacePrefix, getLinkedEnvironmentIdFromPayload, renderBrainFrontmatter, } from '@roomote/types'; import { runBrainCollectors } from './brain-collectors'; +import { + brainSafeIdentityValue, + personIdentitySlug, +} from './brain-collectors/identity'; import { drainMemoryOutboxBatch } from './memory-outbox-drain'; import { runSlackDayPageCensus, @@ -54,6 +63,25 @@ import { slackPublicChannelsCollector } from './brain-collectors/slack-public-ch const LOG_PREFIX = '[brainOutboxDrain]'; /** Sync-state key for the one-time task-history backfill. */ const TASK_MEMORY_COLLECTOR_ID = BRAIN_COLLECTOR_IDS.taskMemories; +/** + * Task-memory sync-state rows left behind by version bumps. A bump replays + * history under the new id (the backfill checkpoint lives on the row), and + * the old row would otherwise linger and count as the source's history + * cutoff forever. Extend when bumping again. + */ +const SUPERSEDED_TASK_MEMORY_COLLECTOR_IDS = ['task-memory:effective-date-v2']; +/** + * How far back a version bump re-puts memories that already reached the + * Brain. Older pages keep correct content and pick the new shape up if a + * linked pull request later changes state. + */ +const LINKABLE_REPLAY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; +/** + * Bound on the request excerpt a task memory carries. The ask is usually a + * few sentences; a pasted log or spec should not dominate the page's + * embedding, and the task itself remains the place to read the rest. + */ +const TASK_REQUEST_CHAR_CAP = 1_500; const CLAIM_BATCH_SIZE = 10; // Backfill can enqueue a deployment's whole task history at once; drain up // to this many batches per tick so the backlog clears in minutes, not hours. @@ -302,12 +330,132 @@ function describePullRequestOutcome( } } +/** + * The user's own request, as the web transcript would show it: the launch + * payload's visible prompt with Roomote's surface wrappers stripped. Only + * standard-workflow tasks carry one; a review or conflict-resolution run's + * prompt is generated, not asked. Bootstrap prompts the harness injected and + * prompts the launch path marked hidden are not the user's words and are + * left out. Treated as evidence like every other ingested text, never as + * instructions. + */ +export function resolveTaskMemoryRequest( + payload: Record, + workflow: TaskWorkflow, +): string | null { + if (workflow !== 'standard') { + return null; + } + + if (payload.visibleInTranscript === false) { + return null; + } + + // Same precedence as the prompt the agent actually received + // (getInitialTaskPrompt): web and chat launches carry `description` or + // `text`; a Linear-launched task carries the triggering comment, else the + // issue body, else its title. + const raw = + [ + payload.description, + payload.text, + payload.commentBody, + payload.issueDescription, + payload.issueTitle, + ].find( + (value): value is string => + typeof value === 'string' && value.trim() !== '', + ) ?? null; + + if (!raw || isSystemInjectedAcpPromptText(raw)) { + return null; + } + + const text = normalizeTranscriptUserText( + raw, + ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + )?.trim(); + + if (!text) { + return null; + } + + return text.length > TASK_REQUEST_CHAR_CAP + ? `${text.slice(0, TASK_REQUEST_CHAR_CAP)}\n\n_Request truncated; open the task for the rest._` + : text; +} + /** * Build the memory page for a completed run. Deliberately deterministic and * conservative: only structured, known-safe fields (title, repos, PRs, * timestamps, provenance). LLM distillation of decisions/rationale layers on * top of this later; it must never widen what raw data can reach the brain. */ +/** + * Who started the task. On a standard-workflow task, a linked Roomote member + * gets a link to their person page so recall can answer "what has X been + * working on"; an unlinked human from an integration surface (a Slack or + * GitHub user with no Roomote account) keeps only the display name the + * surface reported; an automation names its key. Other workflows (PR + * reviews, conflict resolution, scans, snapshots) never link a person: the + * human who happened to trigger a review is not its author, and a person + * page full of reviews says nothing about what they worked on. + */ +type TaskMemoryInitiator = + | { + kind: 'user'; + userId: string | null; + /** The Roomote member's name, or the surface-reported display name. */ + name: string | null; + } + | { kind: 'automation'; automation: string }; + +function describeInitiator( + initiator: TaskMemoryInitiator, + workflow: TaskWorkflow, +): { + fields: string[]; + line: string | null; +} { + if (initiator.kind === 'automation') { + return { + fields: [`initiated_by_automation: ${initiator.automation}`], + line: `Initiated by the ${initiator.automation} automation.`, + }; + } + + if (workflow !== 'standard') { + return { fields: [], line: null }; + } + + const name = initiator.name ? brainSafeIdentityValue(initiator.name) : ''; + + if (initiator.userId) { + const slug = personIdentitySlug(initiator.userId); + const title = name || 'Roomote member'; + + return { + fields: [ + `initiated_by: ${JSON.stringify(title)}`, + `roomote_user_id: ${initiator.userId}`, + // Same convention as person aliases: the person page's slug, so the + // Brain can walk from the task to the member and back. + `initiated_by_person: ${JSON.stringify(slug)}`, + ], + line: `Initiated by [${title}](${slug}).`, + }; + } + + if (name) { + return { + fields: [`initiated_by: ${JSON.stringify(name)}`], + line: `Initiated by ${name}.`, + }; + } + + return { fields: [], line: null }; +} + export function buildMemoryPage(input: { runId: number; taskId: string; @@ -315,6 +463,10 @@ export function buildMemoryPage(input: { completedAt: Date | null; environmentName: string | null; agentSummary: string | null; + initiator: TaskMemoryInitiator; + workflow: TaskWorkflow; + /** Already bounded and workflow-gated; see resolveTaskMemoryRequest. */ + request: string | null; pullRequests: Array<{ repository: string | null; prNumber: number | null; @@ -327,6 +479,7 @@ export function buildMemoryPage(input: { const completed = completedAtIso ?? 'unknown'; const completedDate = completedAtIso?.slice(0, 10); const outcome = summarizePullRequestOutcome(input.pullRequests); + const initiator = describeInitiator(input.initiator, input.workflow); const prLines = input.pullRequests.map((pr) => { const label = pr.repository && pr.prNumber @@ -346,6 +499,7 @@ export function buildMemoryPage(input: { fields: [ `roomote_task_id: ${input.taskId}`, `roomote_run_id: ${input.runId}`, + ...initiator.fields, // GBrain derives effective_date from this conventional field. Keep // the full timestamp below as provenance, but make backfilled pages // sort and filter by when the task completed rather than when it @@ -367,6 +521,8 @@ export function buildMemoryPage(input: { '', `# ${input.taskTitle}`, '', + ...(initiator.line ? [initiator.line, ''] : []), + ...(input.request ? ['## Request', '', input.request, ''] : []), // The agent that did the work writes the substance when it can; the // deterministic completion line is the floor, not the ceiling. ...(input.agentSummary @@ -470,6 +626,10 @@ async function resolveReadyBrain(): Promise<{ * restarts (there is no connect action to hang this off anymore). */ async function backfillTaskHistoryOnce(): Promise { + for (const collectorId of SUPERSEDED_TASK_MEMORY_COLLECTOR_IDS) { + await deleteBrainSyncStateFamily(db, collectorId); + } + const state = await getBrainSyncState(db, TASK_MEMORY_COLLECTOR_ID); if (state?.backfillCompletedAt) { @@ -477,7 +637,9 @@ async function backfillTaskHistoryOnce(): Promise { } const enqueued = await backfillBrainMemoryEvents(db, { - requeueCompleted: true, + requeueLinkable: { + completedAfter: new Date(Date.now() - LINKABLE_REPLAY_WINDOW_MS), + }, }); await upsertBrainSyncState(db, TASK_MEMORY_COLLECTOR_ID, { @@ -613,7 +775,7 @@ async function drainOneBatch(connection: { async prepare(event) { const run = await db.query.taskRuns.findFirst({ where: eq(taskRuns.id, event.runId), - with: { task: true }, + with: { task: { with: { initiatorUser: true } } }, }); if (!run) { @@ -671,13 +833,29 @@ async function drainOneBatch(connection: { environmentName = environment?.name ?? null; } + const task = run.task; + const initiator: TaskMemoryInitiator = + task.initiatorKind === 'automation' && task.initiatorAutomation + ? { kind: 'automation', automation: task.initiatorAutomation } + : { + kind: 'user', + userId: task.initiatorUser?.id ?? null, + name: task.initiatorUser?.name ?? task.actorDisplayName, + }; + const page = buildMemoryPage({ environmentName, agentSummary: event.agentSummary, runId: run.id, taskId: run.taskId, - taskTitle: run.task.title, + taskTitle: task.title, completedAt: run.completedAt, + initiator, + workflow: task.workflow, + request: resolveTaskMemoryRequest( + run.payload as Record, + task.workflow, + ), pullRequests: prRows.map((pr) => ({ repository: pr.repository, prNumber: pr.prNumber, diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index 0926547d21..bfba7dfa41 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -17,11 +17,16 @@ Roomote task. Roomote fills Memory from what it can already see: -- **completed Roomote tasks**, including a short memory the agent writes about - its own work: what it decided, why, and what is still open. When a pull +- **completed Roomote tasks**, including the request that started the task + and a short memory the agent writes about its own work: what it decided, + why, and what is still open. The request is bounded and only recorded for + tasks a person asked for, never for generated work such as reviews. When a pull request the task opened later merges or closes unmerged, the task's memory is refreshed with that outcome, so recall can tell work that shipped from work - that was abandoned + that was abandoned. Each memory also records who started the task: a linked + Roomote member is connected to their person page, so recall can answer what + someone has been working on. Automated work such as pull request reviews + names the automation instead of linking a person - **pull requests** from your connected source-control provider - **public Slack channels** the Roomote bot has been added to - **public Discord server channels and active public threads** the Roomote bot diff --git a/packages/db/src/lib/__tests__/brain.test.ts b/packages/db/src/lib/__tests__/brain.test.ts index 088e06f24c..3615526801 100644 --- a/packages/db/src/lib/__tests__/brain.test.ts +++ b/packages/db/src/lib/__tests__/brain.test.ts @@ -15,6 +15,7 @@ import { tasks, taskRuns, taskFactory, + userFactory, brainMemoryEvents, brainCollectorItems, brainSyncState, @@ -47,8 +48,11 @@ import { runMemoryOutboxLifecycleContract } from './memory-outbox-lifecycle.cont const createdTaskIds: string[] = []; -async function makeCompletedRun(completedAt?: Date) { - const task = await taskFactory.create({ state: 'active' }); +async function makeCompletedRun( + completedAt?: Date, + taskParams: Parameters[0] = {}, +) { + const task = await taskFactory.create({ state: 'active', ...taskParams }); createdTaskIds.push(task.id); const [run] = await db @@ -723,25 +727,53 @@ describe('backfillBrainMemoryEvents', () => { expect(runningEvents).toHaveLength(0); }); - it('requeues completed memories for a one-time metadata replay', async () => { - const completed = await makeCompletedRun(); - await saveBrainAgentSummary(db, completed.id, 'Keep this summary.'); - const claimed = await claimPendingBrainMemoryEvents(db, 10); - const event = claimed.find((row) => row.runId === completed.id); - await settleBrainMemoryEvent(db, event!.id, event!.revision, 'done'); + it('requeues only linkable recent memories for a one-time metadata replay', async () => { + const user = await userFactory.create(); + const now = Date.now(); + const recent = new Date(now - 24 * 60 * 60 * 1000); + const stale = new Date(now - 400 * 24 * 60 * 60 * 1000); + const linkable = await makeCompletedRun(recent, { + initiatorUserId: user.id, + }); + const tooOld = await makeCompletedRun(stale, { + initiatorUserId: user.id, + }); + const review = await makeCompletedRun(recent, { + initiatorUserId: user.id, + workflow: 'pr_review', + }); + const unlinked = await makeCompletedRun(recent); + await saveBrainAgentSummary(db, linkable.id, 'Keep this summary.'); + // Every fixture run reaches the Brain once before the replay is asked for. + await backfillBrainMemoryEvents(db); + const claimed = await claimPendingBrainMemoryEvents(db, 1_000); + for (const event of claimed) { + await settleBrainMemoryEvent(db, event.id, event.revision, 'done'); + } + + await backfillBrainMemoryEvents(db, { + requeueLinkable: { + completedAfter: new Date(now - 90 * 24 * 60 * 60 * 1000), + }, + }); - await backfillBrainMemoryEvents(db, { requeueCompleted: true }); + const statusOf = async (runId: number) => { + const [row] = await db + .select() + .from(brainMemoryEvents) + .where(eq(brainMemoryEvents.runId, runId)); + return row; + }; - const [requeued] = await db - .select() - .from(brainMemoryEvents) - .where(eq(brainMemoryEvents.runId, completed.id)); - expect(requeued).toMatchObject({ + expect(await statusOf(linkable.id)).toMatchObject({ status: 'pending', attempts: 0, lastError: null, agentSummary: 'Keep this summary.', }); + expect((await statusOf(tooOld.id))?.status).toBe('done'); + expect((await statusOf(review.id))?.status).toBe('done'); + expect((await statusOf(unlinked.id))?.status).toBe('done'); }); }); diff --git a/packages/db/src/lib/brain.ts b/packages/db/src/lib/brain.ts index f45570ccb0..c264d3aa53 100644 --- a/packages/db/src/lib/brain.ts +++ b/packages/db/src/lib/brain.ts @@ -25,6 +25,7 @@ import { brainMemoryEvents, brainSyncState, taskRuns, + tasks, } from '../schema'; import { runInTransactionIfAvailable } from './transaction-utils'; import { createMemoryOutboxLifecycle } from './memory-outbox-lifecycle'; @@ -465,19 +466,31 @@ export async function requeueBrainMemoryEventsForTasks( * connecting the brain sucks in the deployment's task history rather than * only learning from tasks completed after enablement. Idempotent via the * unique(runId) constraint; the drainer distills the backlog batch by batch. + * + * `requeueLinkable` re-puts memories already in the Brain so a page-shape + * change reaches them. It is deliberately narrow: only standard-workflow + * tasks a linked Roomote member started, completed after the given time. + * Reviews, conflict resolution, scans, and automation-started work gain + * nothing from the replay, and a deployment's whole history re-embedded at + * once is a burst every managed tenant would land on the shared embedder + * together. */ export async function backfillBrainMemoryEvents( database: DatabaseOrTransaction, - options: { requeueCompleted?: boolean } = {}, + options: { requeueLinkable?: { completedAfter: Date } } = {}, ): Promise { - const requeued = options.requeueCompleted + const requeued = options.requeueLinkable ? ((await database.execute( sql`UPDATE ${brainMemoryEvents} AS event SET status = 'pending', attempts = 0, last_error = NULL, updated_at = now() FROM ${taskRuns} AS run + JOIN ${tasks} AS task ON task.id = run.task_id WHERE event.run_id = run.id AND event.status = 'done' AND run.status = 'completed' + AND run.completed_at > ${options.requeueLinkable.completedAfter.toISOString()}::timestamptz + AND task.workflow = 'standard' + AND task.initiator_user_id IS NOT NULL RETURNING event.id`, )) as unknown as Array<{ id: string }>) : []; diff --git a/packages/types/src/brain.test.ts b/packages/types/src/brain.test.ts index 7e799ca8f9..1cc57b8bac 100644 --- a/packages/types/src/brain.test.ts +++ b/packages/types/src/brain.test.ts @@ -91,9 +91,9 @@ describe('resolveBrainSourceIdForCollector', () => { }); it('maps the outbox-fed checkpoints back to their sources', () => { - expect( - resolveBrainSourceIdForCollector('task-memory:effective-date-v2'), - ).toBe('task-memories'); + expect(resolveBrainSourceIdForCollector('task-memory:initiator-v3')).toBe( + 'task-memories', + ); expect( resolveBrainSourceIdForCollector('pull-request-facts:occurrence-date-v3'), ).toBe('pull-request-facts'); diff --git a/packages/types/src/brain.ts b/packages/types/src/brain.ts index 6686faee78..4a43de4497 100644 --- a/packages/types/src/brain.ts +++ b/packages/types/src/brain.ts @@ -114,7 +114,7 @@ export function brainNamespaceLabel(id: BrainNamespaceBucketId): string { * superseded version's rows. */ export const BRAIN_COLLECTOR_IDS = { - taskMemories: 'task-memory:effective-date-v2', + taskMemories: 'task-memory:initiator-v3', pullRequestFacts: 'pull-request-facts:occurrence-date-v3', personIdentities: 'person-identities:members:occurrence-date-v2', ripplingWorkers: 'rippling-workers',