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 2efa82e8b..9ccb58b4a 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 @@ -83,14 +83,11 @@ beforeEach(() => { }); }); -import { personIdentitySlug } from '../brain-collectors/identity'; import { brainCollectorsJob, brainOutboxDrainJob, buildFastMemoryPage, buildPullRequestFactPage, - buildMemoryPage, - resolveTaskMemoryRequest, callBrainWriteTool, isBrainUnreachable, drainBrainHistoricalIngestion, @@ -99,7 +96,6 @@ import { isBrainRateLimited, postToBrain, redactBrainText, - summarizePullRequestOutcome, } from '../brain-outbox-drain'; describe('PR fact resume cursor', () => { @@ -439,307 +435,6 @@ describe('historical ingestion continuation', () => { }); }); -describe('task memory page identity', () => { - const base = { - taskId: 'task-1', - taskTitle: 'Remember the fix', - 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 }); - - expect(first.slug).toBe('tasks/task-1/runs/101'); - expect(followUp.slug).toBe('tasks/task-1/runs/102'); - }); - - it('dates live and backfilled memories by task completion', () => { - const page = buildMemoryPage({ ...base, runId: 101 }); - - expect(page.content).toContain('\ndate: 2026-08-13\n'); - expect(page.content).toContain( - '\ncompleted_at: 2026-08-13T10:00:00.000Z\n', - ); - }); - - it('does not emit an invalid date when legacy completion time is missing', () => { - const page = buildMemoryPage({ - ...base, - completedAt: null, - runId: 101, - }); - - expect(page.content).not.toContain('\ndate:'); - expect(page.content).not.toContain('\ncreated:'); - expect(page.content).toContain('\ncompleted_at: unknown\n'); - }); - - it('stamps type, title, and a stable created on memory pages', () => { - const page = buildMemoryPage({ ...base, runId: 101 }); - - expect(page.content).toMatch( - /^---\ntype: task-memory\ntitle: "[^"]+"\ncreated: 2026-08-13T10:00:00\.000Z\n/, - ); - }); -}); - -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, - taskId: 'task-1', - taskTitle: 'Ship the fix', - 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', - prNumber: 42, - prTitle: 'Serialize the writer', - prUrl: 'https://example.test/owner/repo/pull/42', - }; - - it('stamps a merged outcome the completion-time summary could not know', () => { - const page = buildMemoryPage({ - ...base, - pullRequests: [{ ...pr, status: 'merged' }], - }); - - expect(page.content).toContain('\npr_outcome: merged\n'); - expect(page.content).toContain( - '- owner/repo#42: Serialize the writer (https://example.test/owner/repo/pull/42): merged', - ); - expect(page.content).toContain( - 'Outcome: the pull request was merged, so this work shipped.', - ); - }); - - it('records work that did not ship when every PR closed unmerged', () => { - const page = buildMemoryPage({ - ...base, - pullRequests: [ - { ...pr, status: 'closed' }, - { ...pr, prNumber: 43, status: 'closed' }, - ], - }); - - expect(page.content).toContain('\npr_outcome: closed\n'); - expect(page.content).toContain( - 'Outcome: the pull requests closed without merging, so this work did not ship as written.', - ); - }); - - it('treats any merge as shipped even when a sibling PR was closed', () => { - expect( - summarizePullRequestOutcome([{ status: 'closed' }, { status: 'merged' }]), - ).toBe('merged'); - }); - - it('reports open and draft PRs as not yet an outcome', () => { - expect(summarizePullRequestOutcome([{ status: 'open' }])).toBe('open'); - expect(summarizePullRequestOutcome([{ status: 'draft' }])).toBe('open'); - expect( - summarizePullRequestOutcome([{ status: 'closed' }, { status: 'open' }]), - ).toBe('open'); - - const page = buildMemoryPage({ - ...base, - pullRequests: [{ ...pr, status: 'open' }], - }); - - expect(page.content).toContain('\npr_outcome: open\n'); - expect(page.content).toContain( - 'Outcome: the pull request was still open when this memory was last refreshed.', - ); - }); - - it('keeps a never-observed status unknown instead of calling it open', () => { - // A failed details fetch leaves the association's status null. That PR - // may already be merged or closed, so the page must not claim otherwise. - expect(summarizePullRequestOutcome([{ status: null }])).toBeNull(); - expect(summarizePullRequestOutcome([{}])).toBeNull(); - expect( - summarizePullRequestOutcome([{ status: 'closed' }, { status: null }]), - ).toBeNull(); - expect( - summarizePullRequestOutcome([{ status: null }, { status: 'merged' }]), - ).toBe('merged'); - expect(summarizePullRequestOutcome([])).toBeNull(); - - const page = buildMemoryPage({ - ...base, - pullRequests: [{ ...pr, status: null }], - }); - - expect(page.content).not.toContain('pr_outcome'); - expect(page.content).toContain(': status unknown'); - expect(page.content).not.toContain('Outcome:'); - expect(page.content).toContain('## Pull requests'); - }); - - it('omits the outcome field entirely when the task opened no PR', () => { - const page = buildMemoryPage({ ...base, pullRequests: [] }); - - expect(page.content).not.toContain('pr_outcome'); - expect(page.content).not.toContain('## Pull requests'); - }); -}); - describe('redactBrainText', () => { it.each([ ['GitHub PAT', 'token ghp_abcdefghijklmnopqrstuvwxyz012345 here'], diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/task-memory-projection.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/task-memory-projection.test.ts new file mode 100644 index 000000000..6766e4852 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/task-memory-projection.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from 'vitest'; + +import { personIdentitySlug } from '../brain-collectors/identity'; +import { + buildTaskMemoryPage, + resolveTaskMemoryRequest, + summarizePullRequestOutcome, +} from '../task-memory-projection'; + +describe('task memory page identity', () => { + const base = { + taskId: 'task-1', + taskTitle: 'Remember the fix', + 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 = buildTaskMemoryPage({ ...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 = buildTaskMemoryPage({ + ...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 = buildTaskMemoryPage({ + ...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 = buildTaskMemoryPage({ + ...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 = buildTaskMemoryPage({ + ...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 = buildTaskMemoryPage({ + ...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 = buildTaskMemoryPage({ ...base, runId: 101 }); + const followUp = buildTaskMemoryPage({ ...base, runId: 102 }); + + expect(first.slug).toBe('tasks/task-1/runs/101'); + expect(followUp.slug).toBe('tasks/task-1/runs/102'); + }); + + it('dates live and backfilled memories by task completion', () => { + const page = buildTaskMemoryPage({ ...base, runId: 101 }); + + expect(page.content).toContain('\ndate: 2026-08-13\n'); + expect(page.content).toContain( + '\ncompleted_at: 2026-08-13T10:00:00.000Z\n', + ); + }); + + it('does not emit an invalid date when legacy completion time is missing', () => { + const page = buildTaskMemoryPage({ + ...base, + completedAt: null, + runId: 101, + }); + + expect(page.content).not.toContain('\ndate:'); + expect(page.content).not.toContain('\ncreated:'); + expect(page.content).toContain('\ncompleted_at: unknown\n'); + }); + + it('stamps type, title, and a stable created on memory pages', () => { + const page = buildTaskMemoryPage({ ...base, runId: 101 }); + + expect(page.content).toMatch( + /^---\ntype: task-memory\ntitle: "[^"]+"\ncreated: 2026-08-13T10:00:00\.000Z\n/, + ); + }); +}); + +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, + taskId: 'task-1', + taskTitle: 'Ship the fix', + 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', + prNumber: 42, + prTitle: 'Serialize the writer', + prUrl: 'https://example.test/owner/repo/pull/42', + }; + + it('stamps a merged outcome the completion-time summary could not know', () => { + const page = buildTaskMemoryPage({ + ...base, + pullRequests: [{ ...pr, status: 'merged' }], + }); + + expect(page.content).toContain('\npr_outcome: merged\n'); + expect(page.content).toContain( + '- owner/repo#42: Serialize the writer (https://example.test/owner/repo/pull/42): merged', + ); + expect(page.content).toContain( + 'Outcome: the pull request was merged, so this work shipped.', + ); + }); + + it('records work that did not ship when every PR closed unmerged', () => { + const page = buildTaskMemoryPage({ + ...base, + pullRequests: [ + { ...pr, status: 'closed' }, + { ...pr, prNumber: 43, status: 'closed' }, + ], + }); + + expect(page.content).toContain('\npr_outcome: closed\n'); + expect(page.content).toContain( + 'Outcome: the pull requests closed without merging, so this work did not ship as written.', + ); + }); + + it('treats any merge as shipped even when a sibling PR was closed', () => { + expect( + summarizePullRequestOutcome([{ status: 'closed' }, { status: 'merged' }]), + ).toBe('merged'); + }); + + it('reports open and draft PRs as not yet an outcome', () => { + expect(summarizePullRequestOutcome([{ status: 'open' }])).toBe('open'); + expect(summarizePullRequestOutcome([{ status: 'draft' }])).toBe('open'); + expect( + summarizePullRequestOutcome([{ status: 'closed' }, { status: 'open' }]), + ).toBe('open'); + + const page = buildTaskMemoryPage({ + ...base, + pullRequests: [{ ...pr, status: 'open' }], + }); + + expect(page.content).toContain('\npr_outcome: open\n'); + expect(page.content).toContain( + 'Outcome: the pull request was still open when this memory was last refreshed.', + ); + }); + + it('keeps a never-observed status unknown instead of calling it open', () => { + expect(summarizePullRequestOutcome([{ status: null }])).toBeNull(); + expect(summarizePullRequestOutcome([{}])).toBeNull(); + expect( + summarizePullRequestOutcome([{ status: 'closed' }, { status: null }]), + ).toBeNull(); + expect( + summarizePullRequestOutcome([{ status: null }, { status: 'merged' }]), + ).toBe('merged'); + expect(summarizePullRequestOutcome([])).toBeNull(); + + const page = buildTaskMemoryPage({ + ...base, + pullRequests: [{ ...pr, status: null }], + }); + + expect(page.content).not.toContain('pr_outcome'); + expect(page.content).toContain(': status unknown'); + expect(page.content).not.toContain('Outcome:'); + expect(page.content).toContain('## Pull requests'); + }); + + it('omits the outcome field entirely when the task opened no PR', () => { + const page = buildTaskMemoryPage({ ...base, pullRequests: [] }); + + expect(page.content).not.toContain('pr_outcome'); + expect(page.content).not.toContain('## Pull requests'); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts index 96c3fb799..afe3791b5 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts @@ -37,28 +37,24 @@ import { import { BRAIN_COLLECTOR_IDS, 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, runSlackDayPageInventoryMaintenance, } from './brain-collectors/slack-day-page-inventory'; import { slackPublicChannelsCollector } from './brain-collectors/slack-public-channels'; +import { + buildTaskMemoryPage, + resolveTaskMemoryRequest, + type TaskMemoryInitiator, +} from './task-memory-projection'; const LOG_PREFIX = '[brainOutboxDrain]'; /** Sync-state key for the one-time task-history backfill. */ @@ -76,12 +72,6 @@ const SUPERSEDED_TASK_MEMORY_COLLECTOR_IDS = ['task-memory:effective-date-v2']; * 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. @@ -262,295 +252,6 @@ export async function postToBrain( }); } -/** - * One word for what became of a task's pull requests, for the page - * frontmatter. Merged wins because shipped work is what later recall should - * weight; a task whose every PR closed unmerged is the failure worth - * remembering; anything still open is not an outcome yet. A status that was - * never observed (the details fetch failed and the row kept its null) is not - * "open": the page says nothing about the outcome until one is known, so a PR - * that was already terminal when it went unfetched is never recorded as open. - */ -type TaskPullRequestOutcome = 'merged' | 'closed' | 'open'; - -export function summarizePullRequestOutcome( - pullRequests: Array<{ status?: PullRequestStatus | null }>, -): TaskPullRequestOutcome | null { - if (pullRequests.length === 0) { - return null; - } - - if (pullRequests.some((pr) => pr.status === 'merged')) { - return 'merged'; - } - - if (pullRequests.some((pr) => pr.status == null)) { - return null; - } - - if (pullRequests.every((pr) => pr.status === 'closed')) { - return 'closed'; - } - - return 'open'; -} - -function describePullRequestStatus( - status: PullRequestStatus | null | undefined, -): string { - switch (status) { - case 'merged': - return 'merged'; - case 'closed': - return 'closed without merging'; - case 'draft': - return 'still open as a draft'; - case 'open': - return 'still open'; - default: - return 'status unknown'; - } -} - -function describePullRequestOutcome( - outcome: TaskPullRequestOutcome, - count: number, -): string { - const noun = count === 1 ? 'the pull request' : 'the pull requests'; - - switch (outcome) { - case 'merged': - return count === 1 - ? 'Outcome: the pull request was merged, so this work shipped.' - : 'Outcome: at least one pull request was merged, so this work shipped.'; - case 'closed': - return `Outcome: ${noun} closed without merging, so this work did not ship as written. Treat the approach with that in mind.`; - case 'open': - return `Outcome: ${noun} ${count === 1 ? 'was' : 'were'} still open when this memory was last refreshed.`; - } -} - -/** - * 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; - taskTitle: string; - 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; - prTitle: string | null; - prUrl: string; - status?: PullRequestStatus | null; - }>; -}): IngestPage { - const completedAtIso = input.completedAt?.toISOString(); - 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 - ? `${pr.repository}#${pr.prNumber}` - : pr.prUrl; - - return `- ${label}${pr.prTitle ? `: ${pr.prTitle}` : ''} (${pr.prUrl}): ${describePullRequestStatus(pr.status)}`; - }); - - const content = [ - ...renderBrainFrontmatter({ - type: BRAIN_PAGE_TYPES.taskMemory, - title: input.taskTitle, - // Legacy completed runs can lack a completion time; `completed` is the - // literal "unknown" then, which is no date at all. - created: completedAtIso ?? null, - 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 - // was ingested. - completedDate && `date: ${completedDate}`, - `completed_at: ${completed}`, - // Environment stamp: costs nothing now, enables environment-scoped - // retrieval (gbrain sources) or admin triage later without - // re-ingesting. - input.environmentName && `environment: ${input.environmentName}`, - // What became of the work, as of the latest ingestion. The agent's - // summary is written at completion and cannot know this; the page is - // re-put when a linked pull request merges or closes so recall can - // tell shipped work from abandoned work. - outcome && `pr_outcome: ${outcome}`, - 'provenance: roomote-task-memory', - ], - }), - '', - `# ${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 - ? [input.agentSummary, ''] - : ['## Outcome', '', `Task completed at ${completed}.`, '']), - ...(prLines.length > 0 - ? [ - '## Pull requests', - '', - ...prLines, - '', - ...(outcome - ? [ - describePullRequestOutcome(outcome, input.pullRequests.length), - '', - ] - : []), - ] - : []), - ].join('\n'); - - return { - slug: `${brainNamespacePrefix('tasks')}${input.taskId}/runs/${input.runId}`, - title: input.taskTitle, - content: redactBrainText(content), - }; -} - /** * Drain the brain_memory_events and fast_agent_memory_events transactional * outboxes. Runs on the shared scheduler queue; claims use FOR UPDATE SKIP @@ -843,7 +544,7 @@ async function drainOneBatch(connection: { name: task.initiatorUser?.name ?? task.actorDisplayName, }; - const page = buildMemoryPage({ + const page = buildTaskMemoryPage({ environmentName, agentSummary: event.agentSummary, runId: run.id, diff --git a/apps/bullmq/src/scheduled-jobs/task-memory-projection.ts b/apps/bullmq/src/scheduled-jobs/task-memory-projection.ts new file mode 100644 index 000000000..f914cefe3 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/task-memory-projection.ts @@ -0,0 +1,283 @@ +import { redactBrainText } from '@roomote/communication/redact-brain-text'; +import { + ACP_ENVELOPE_EVENT_TYPES, + BRAIN_PAGE_TYPES, + brainNamespacePrefix, + isSystemInjectedAcpPromptText, + normalizeTranscriptUserText, + renderBrainFrontmatter, + type PullRequestStatus, + type TaskWorkflow, +} from '@roomote/types'; + +import { + brainSafeIdentityValue, + personIdentitySlug, +} from './brain-collectors/identity'; + +const TASK_REQUEST_CHAR_CAP = 1_500; + +type TaskMemoryPage = { + slug: string; + title: string; + content: string; +}; + +export 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 }; + +type TaskMemoryProjectionInput = { + runId: number; + taskId: string; + taskTitle: string; + 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; + prTitle: string | null; + prUrl: string; + status?: PullRequestStatus | null; + }>; +}; + +/** + * 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; +} + +type TaskPullRequestOutcome = 'merged' | 'closed' | 'open'; + +export function summarizePullRequestOutcome( + pullRequests: Array<{ status?: PullRequestStatus | null }>, +): TaskPullRequestOutcome | null { + if (pullRequests.length === 0) { + return null; + } + + if (pullRequests.some((pr) => pr.status === 'merged')) { + return 'merged'; + } + + if (pullRequests.some((pr) => pr.status == null)) { + return null; + } + + if (pullRequests.every((pr) => pr.status === 'closed')) { + return 'closed'; + } + + return 'open'; +} + +function describePullRequestStatus( + status: PullRequestStatus | null | undefined, +): string { + switch (status) { + case 'merged': + return 'merged'; + case 'closed': + return 'closed without merging'; + case 'draft': + return 'still open as a draft'; + case 'open': + return 'still open'; + default: + return 'status unknown'; + } +} + +function describePullRequestOutcome( + outcome: TaskPullRequestOutcome, + count: number, +): string { + const noun = count === 1 ? 'the pull request' : 'the pull requests'; + + switch (outcome) { + case 'merged': + return count === 1 + ? 'Outcome: the pull request was merged, so this work shipped.' + : 'Outcome: at least one pull request was merged, so this work shipped.'; + case 'closed': + return `Outcome: ${noun} closed without merging, so this work did not ship as written. Treat the approach with that in mind.`; + case 'open': + return `Outcome: ${noun} ${count === 1 ? 'was' : 'were'} still open when this memory was last refreshed.`; + } +} + +/** + * 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 keeps only the + * reported display name; an automation names its key. Other workflows never + * link a person because the human who triggered a review is not its author. + */ +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}`, + `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 }; +} + +/** Build the deterministic Brain page projection for a completed task run. */ +export function buildTaskMemoryPage( + input: TaskMemoryProjectionInput, +): TaskMemoryPage { + const completedAtIso = input.completedAt?.toISOString(); + 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 + ? `${pr.repository}#${pr.prNumber}` + : pr.prUrl; + + return `- ${label}${pr.prTitle ? `: ${pr.prTitle}` : ''} (${pr.prUrl}): ${describePullRequestStatus(pr.status)}`; + }); + + const content = [ + ...renderBrainFrontmatter({ + type: BRAIN_PAGE_TYPES.taskMemory, + title: input.taskTitle, + created: completedAtIso ?? null, + fields: [ + `roomote_task_id: ${input.taskId}`, + `roomote_run_id: ${input.runId}`, + ...initiator.fields, + completedDate && `date: ${completedDate}`, + `completed_at: ${completed}`, + input.environmentName && `environment: ${input.environmentName}`, + outcome && `pr_outcome: ${outcome}`, + 'provenance: roomote-task-memory', + ], + }), + '', + `# ${input.taskTitle}`, + '', + ...(initiator.line ? [initiator.line, ''] : []), + ...(input.request ? ['## Request', '', input.request, ''] : []), + ...(input.agentSummary + ? [input.agentSummary, ''] + : ['## Outcome', '', `Task completed at ${completed}.`, '']), + ...(prLines.length > 0 + ? [ + '## Pull requests', + '', + ...prLines, + '', + ...(outcome + ? [ + describePullRequestOutcome(outcome, input.pullRequests.length), + '', + ] + : []), + ] + : []), + ].join('\n'); + + return { + slug: `${brainNamespacePrefix('tasks')}${input.taskId}/runs/${input.runId}`, + title: input.taskTitle, + content: redactBrainText(content), + }; +}