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),
+ };
+}