From 80bd6dfc28624254345daef9fbaece58eedaf9f0 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Fri, 4 Sep 2026 18:31:07 +0000 Subject: [PATCH 1/2] fix(mind): stop opening every wake with a recap, and show the mind its own task queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things made the Persistent Mind's wakes noisy and repetitive. The trajectory rollup (`mind.summary`) — the internal summary that compacts older history into context — was rendered as a Chief of Staff chat bubble, so each wake opened with a long first-person recap of everything the mind already remembered. It now sits with the other bookkeeping events behind the Activity toggle, leaving the conversation to working notes and replies. The turn prompt also asks for what is new this turn rather than a restatement of prior context. The mind could also see its own trajectory but not the CoS queue, so it re-derived ideas it had already shipped and queued the same work again. `addTask` already refuses a duplicate of an open task; the missing half was completed work. The task-capability prompt now carries a bounded, newest-first view of the recent internal queue — id, status, queue label, app — and tells the mind to check it before requesting a task. --- client/src/components/cos/tabs/MindTab.jsx | 6 +- .../src/components/cos/tabs/MindTab.test.jsx | 14 +++++ server/services/persistentMindAdapter.js | 14 +++-- server/services/persistentMindAdapter.test.js | 3 + .../services/persistentMindTaskCapability.js | 55 +++++++++++++++++-- .../persistentMindTaskCapability.test.js | 45 +++++++++++++++ 6 files changed, 128 insertions(+), 9 deletions(-) diff --git a/client/src/components/cos/tabs/MindTab.jsx b/client/src/components/cos/tabs/MindTab.jsx index cc8e34b4d0..618ad49fa0 100644 --- a/client/src/components/cos/tabs/MindTab.jsx +++ b/client/src/components/cos/tabs/MindTab.jsx @@ -35,8 +35,12 @@ const MIND_PANEL_TABS = [ { id: 'settings', label: 'Settings', icon: Settings2 }, ]; +// Bookkeeping the conversation does not need. `mind.summary` is the rollup that +// compacts older trajectory into context — it recaps the mind's own history, so +// rendering it as a bubble makes every wake open with a wall of recap. It stays +// reachable through the Activity toggle and the event detail panel. const ACTIVITY_KINDS = new Set([ - 'mind.wake', 'mind.model.request', 'mind.model.result', 'mind.turn.completed', + 'mind.wake', 'mind.model.request', 'mind.model.result', 'mind.turn.completed', 'mind.summary', ]); const EVENT_LABELS = { diff --git a/client/src/components/cos/tabs/MindTab.test.jsx b/client/src/components/cos/tabs/MindTab.test.jsx index a72243112c..ef1d2b103f 100644 --- a/client/src/components/cos/tabs/MindTab.test.jsx +++ b/client/src/components/cos/tabs/MindTab.test.jsx @@ -517,6 +517,20 @@ describe('MindTab', () => { expect(screen.getAllByRole('button', { name: /chief of staff/i })).toHaveLength(1); }); + it('keeps the trajectory rollup recap out of the conversation until Activity is on', async () => { + api.getPersistentMind.mockResolvedValue(response({ events: [ + event({ eventId: 'summary-1', kind: 'mind.summary', sequence: 2, data: { summaryText: 'Earlier I confirmed the provider switch and queued follow-up wakes.' } }), + event({ eventId: 'reply-1', kind: 'mind.reply', turnId: 'mind-turn-1', sequence: 3, data: { displayText: 'Here is the recommendation.' } }), + ] })); + renderTab(); + + expect(await screen.findByText('Here is the recommendation.')).toBeInTheDocument(); + expect(screen.queryByText(/Earlier I confirmed the provider switch/)).not.toBeInTheDocument(); + + await userEvent.setup().click(screen.getByRole('checkbox', { name: 'Activity' })); + expect(await screen.findByText(/Earlier I confirmed the provider switch/)).toBeInTheDocument(); + }); + it('shows a typing indicator in the chat header while the mind is thinking', async () => { api.getPersistentMind.mockResolvedValue(response({ state: { enabled: true, started: true, status: 'thinking', pauseReason: null, activeTurnId: 'mind-turn-1' }, diff --git a/server/services/persistentMindAdapter.js b/server/services/persistentMindAdapter.js index 0ab486eaa5..5cbbc07728 100644 --- a/server/services/persistentMindAdapter.js +++ b/server/services/persistentMindAdapter.js @@ -32,6 +32,7 @@ import { buildPersistentMindTaskCapabilityPrompt, executePersistentMindTaskRequests, readPersistentMindTaskCatalog, + readPersistentMindTaskInventory, } from './persistentMindTaskCapability.js'; import { buildPersistentMindCallCapabilityPrompt, @@ -247,7 +248,8 @@ Return ONLY one JSON object with this shape: "selfWake": { "reason": "Why another wake would be useful", "delayMinutes": 60 }, "callRequest": { "reason": "Why this cannot wait for a screen", "openingLine": "What to say the moment they answer" } } -Use empty arrays when there is no durable memory candidate, task request, or tool call, and null for selfWake and callRequest when neither is needed. Memory candidates are durable memories to save automatically; only include information that is worth retaining. Never put the same CoS task in both taskRequests and toolCalls. This lane cannot mutate files directly, call arbitrary routes, contact anyone other than the configured PortOS user, or exceed the semantic tool catalog.`; +Use empty arrays when there is no durable memory candidate, task request, or tool call, and null for selfWake and callRequest when neither is needed. Memory candidates are durable memories to save automatically; only include information that is worth retaining. Never put the same CoS task in both taskRequests and toolCalls. This lane cannot mutate files directly, call arbitrary routes, contact anyone other than the configured PortOS user, or exceed the semantic tool catalog. +Do not open with a recap. The human already sees the trajectory, the memories, and every earlier reply, so summarizing prior turns or listing what you remember is wasted output. Say only what is new this turn: what you are thinking now, what you decided, and what you need from them. Reference prior context only where it changes the decision you are stating.`; } const summaryEventLines = (events) => (Array.isArray(events) ? events : []).map((event) => { @@ -343,12 +345,16 @@ export function createPersistentMindTurnAdapter() { prompt, provider, }); - const taskCatalog = taskAccess.createTasks - ? await readPersistentMindTaskCatalog({ allowedAppIds: taskAccess.allowedAppIds }) - : undefined; + const [taskCatalog, taskInventory] = taskAccess.createTasks + ? await Promise.all([ + readPersistentMindTaskCatalog({ allowedAppIds: taskAccess.allowedAppIds }), + readPersistentMindTaskInventory(), + ]) + : [undefined, []]; const taskCapabilityPrompt = buildPersistentMindTaskCapabilityPrompt({ enabled: taskAccess.createTasks, catalog: taskCatalog, + inventory: taskInventory, }); const visibilityPrompt = buildPersistentMindVisibilityPrompt(visibility); // Deterministic and always included (epic #5593 decision 14): bounded, diff --git a/server/services/persistentMindAdapter.test.js b/server/services/persistentMindAdapter.test.js index 6ddb9eee5a..5760cee51e 100644 --- a/server/services/persistentMindAdapter.test.js +++ b/server/services/persistentMindAdapter.test.js @@ -7,6 +7,7 @@ const mock = vi.hoisted(() => ({ stopRun: vi.fn(), assertVision: vi.fn(), readTaskCatalog: vi.fn(), + readTaskInventory: vi.fn(), executeTaskRequests: vi.fn(), executeToolCall: vi.fn(), readVisibility: vi.fn(), @@ -35,6 +36,7 @@ vi.mock('./runner.js', () => ({ stopRun: (...args) => mock.stopRun(...args) })); vi.mock('./persistentMindTaskCapability.js', () => ({ buildPersistentMindTaskCapabilityPrompt: ({ enabled }) => `Task access: ${enabled ? 'ON' : 'OFF'}`, readPersistentMindTaskCatalog: (...args) => mock.readTaskCatalog(...args), + readPersistentMindTaskInventory: (...args) => mock.readTaskInventory(...args), executePersistentMindTaskRequests: (...args) => mock.executeTaskRequests(...args), })); vi.mock('./persistentMindVisibility.js', () => ({ @@ -62,6 +64,7 @@ beforeEach(() => { vi.clearAllMocks(); mock.root.config.persistentMindCapabilities = { createTasks: true }; mock.readTaskCatalog.mockResolvedValue({ apps: [{ id: 'portos' }], providers: [{ id: 'codex' }] }); + mock.readTaskInventory.mockResolvedValue([]); mock.readVisibility.mockResolvedValue({ readiness: 'ready', workspaces: [] }); mock.executeTaskRequests.mockResolvedValue([]); mock.executeCallRequest.mockResolvedValue(null); diff --git a/server/services/persistentMindTaskCapability.js b/server/services/persistentMindTaskCapability.js index fba71266c0..796fac93ec 100644 --- a/server/services/persistentMindTaskCapability.js +++ b/server/services/persistentMindTaskCapability.js @@ -22,7 +22,7 @@ import { MANAGED_ASSESSMENT_BACKENDS, localRuntimeKind } from '../lib/localProvi import { resolveAppWorkTracker } from '../lib/workTracker.js'; import { getActiveApps, getAppWorkTracker } from './apps.js'; import { loadState } from './cosState.js'; -import { addTask, getTaskById } from './cosTaskStore.js'; +import { addTask, firstLine, getCosTasks, getTaskById } from './cosTaskStore.js'; import { getProviderPrerequisiteReadinessMap } from './providerPrerequisites.js'; import { listManagedBackendModels } from './localLlm.js'; import { listProviders } from './providers.js'; @@ -36,6 +36,10 @@ const MAX_CATALOG_PROVIDERS = 50; const MAX_CATALOG_MODELS = 60; const MAX_CATALOG_PROMPT_CHARS = 16_000; const MAX_CATALOG_APP_PROMPT_CHARS = 4_000; +const MIND_TASK_ID_PREFIX = 'sys-mind-'; +const MAX_INVENTORY_TASKS = 25; +const MAX_INVENTORY_PROMPT_CHARS = 4_000; +const MAX_INVENTORY_DESCRIPTION_CHARS = 160; const APP_TRACKER_CACHE_TTL_MS = 30_000; const ISSUE_TRACKERS = new Set(['github', 'gitlab']); const appTrackerCache = new Map(); @@ -225,7 +229,47 @@ const boundedPromptCatalog = (catalog) => { return bounded; }; -export function buildPersistentMindTaskCapabilityPrompt({ enabled, catalog = { apps: [], providers: [] } } = {}) { +/** + * The internal CoS queue a mind task request lands in, newest first. + * + * `addTask` already refuses a duplicate of an OPEN task, so the value here is + * the part it cannot cover: work that is already **completed**. Without it a + * wake only sees its own trajectory, re-derives an idea it already shipped, and + * queues the same task again. Descriptions are the machine-local queue labels + * the mind itself wrote, so nothing new crosses a privacy boundary. + */ +export async function readPersistentMindTaskInventory() { + const { tasks } = await getCosTasks(); + // A copy, not the store's array: `getCosTasks` serves a cached parse. + return [...(Array.isArray(tasks) ? tasks : [])] + // `metadata.updatedAt` is stamped at creation and on every content edit, so + // it orders the queue by recency. An unstamped legacy task sorts oldest + // rather than dropping out of the list. + .sort((a, b) => String(b?.metadata?.updatedAt || '').localeCompare(String(a?.metadata?.updatedAt || ''))) + .slice(0, MAX_INVENTORY_TASKS) + .map((task) => ({ + id: String(task?.id || ''), + status: String(task?.status || 'unknown'), + description: firstLine(task?.description).slice(0, MAX_INVENTORY_DESCRIPTION_CHARS), + appId: typeof task?.metadata?.app === 'string' ? task.metadata.app : null, + queuedByMind: typeof task?.id === 'string' && task.id.startsWith(MIND_TASK_ID_PREFIX), + })) + .filter((entry) => entry.id && entry.description); +} + +const boundedPromptInventory = (inventory) => { + const bounded = []; + for (const entry of Array.isArray(inventory) ? inventory : []) { + bounded.push(entry); + if (JSON.stringify(bounded).length > MAX_INVENTORY_PROMPT_CHARS) { + bounded.pop(); + break; + } + } + return bounded; +}; + +export function buildPersistentMindTaskCapabilityPrompt({ enabled, catalog = { apps: [], providers: [] }, inventory = [] } = {}) { if (!enabled) { return `# CoS agent task capability Task creation access is OFF. Return an empty taskRequests array. You may recommend a task conversationally, but must not claim it was queued.`; @@ -251,7 +295,10 @@ keeps absent dependencies advisory, which is appropriate for docs-only work. Configured choices (ids are authoritative; do not invent ids): ${JSON.stringify(promptCatalog)} -Use taskRequests only for specific, non-duplicate work. Put the complete agent instructions in prompt and a concise queue label in description. In your conversational message describe the request as pending; do not claim the task was created or completed because the capability outcome is recorded only after inference.`; +Recent CoS queue (newest first; 'completed' work already shipped, so do not re-queue it): +${JSON.stringify(boundedPromptInventory(inventory))} + +Use taskRequests only for specific, non-duplicate work. Before requesting a task, check the recent queue above and the trajectory: if the same work is already pending, in progress, or completed, say so instead of queueing it again. Put the complete agent instructions in prompt and a concise queue label in description. In your conversational message describe the request as pending; do not claim the task was created or completed because the capability outcome is recorded only after inference.`; } const wakeIdentity = (wake, turnId) => ( @@ -261,7 +308,7 @@ const wakeIdentity = (wake, turnId) => ( const requestFingerprint = (request) => sha256Text(canonicalStringify(request)); const taskIdFor = (wakeId, fingerprint) => ( - `sys-mind-${sha256Text(`${PERSISTENT_MIND_ID}:${wakeId}:${fingerprint}`).slice(0, 24)}` + `${MIND_TASK_ID_PREFIX}${sha256Text(`${PERSISTENT_MIND_ID}:${wakeId}:${fingerprint}`).slice(0, 24)}` ); const boundedError = (error) => String(error?.message || error || 'Task creation failed').slice(0, 300); diff --git a/server/services/persistentMindTaskCapability.test.js b/server/services/persistentMindTaskCapability.test.js index 8a683e40b8..0905d95534 100644 --- a/server/services/persistentMindTaskCapability.test.js +++ b/server/services/persistentMindTaskCapability.test.js @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ defaultModel: 'gpt-5', models: ['gpt-5', 'gpt-5-mini', 'gpt-5.6-sol', 'gpt-5.6-luna'], }], existing: null, + cosTasks: [], addTask: vi.fn(), getTaskById: vi.fn(), getAppWorkTracker: vi.fn(), @@ -25,6 +26,8 @@ vi.mock('./apps.js', () => ({ vi.mock('./cosState.js', () => ({ loadState: vi.fn(async () => mocks.root) })); vi.mock('./cosTaskStore.js', () => ({ addTask: (...args) => mocks.addTask(...args), + firstLine: (value) => (value || '').split('\n').map((line) => line.trim()).find(Boolean) || '', + getCosTasks: vi.fn(async () => ({ tasks: mocks.cosTasks })), getTaskById: (...args) => mocks.getTaskById(...args), })); vi.mock('./providers.js', () => ({ listProviders: vi.fn(async () => mocks.providers) })); @@ -46,6 +49,7 @@ const { buildPersistentMindTaskCapabilityPrompt, executePersistentMindTaskRequests, readPersistentMindTaskCatalog, + readPersistentMindTaskInventory, } = await import('./persistentMindTaskCapability.js'); const taskRequest = (overrides = {}) => ({ @@ -69,6 +73,7 @@ beforeEach(() => { defaultModel: 'gpt-5', models: ['gpt-5', 'gpt-5-mini', 'gpt-5.6-sol', 'gpt-5.6-luna'], }]; mocks.existing = null; + mocks.cosTasks = []; mocks.getAppWorkTracker.mockResolvedValue({ resolved: 'plan' }); mocks.resolveAppWorkTracker.mockResolvedValue({ resolved: 'plan' }); mocks.getTaskById.mockImplementation(async () => mocks.existing); @@ -258,6 +263,46 @@ describe('persistent mind CoS-task capability', () => { expect(prompt).toContain('provider-0'); }); + it('shows the mind what the CoS queue already holds, newest first, including completed work', async () => { + mocks.cosTasks = [ + { + id: 'sys-mind-older', + status: 'completed', + description: 'Unify the tool-calling interface\nsecond line ignored', + metadata: { app: 'portos', updatedAt: '2026-09-01T00:00:00.000Z' }, + }, + { + id: 'sys-newer', + status: 'in_progress', + description: 'Fix the npm engine mismatch', + metadata: { app: 'portos', updatedAt: '2026-09-03T00:00:00.000Z' }, + }, + ]; + + const inventory = await readPersistentMindTaskInventory(); + expect(inventory).toEqual([ + { id: 'sys-newer', status: 'in_progress', description: 'Fix the npm engine mismatch', appId: 'portos', queuedByMind: false }, + { id: 'sys-mind-older', status: 'completed', description: 'Unify the tool-calling interface', appId: 'portos', queuedByMind: true }, + ]); + + const prompt = buildPersistentMindTaskCapabilityPrompt({ enabled: true, catalog: await readPersistentMindTaskCatalog(), inventory }); + expect(prompt).toContain('Unify the tool-calling interface'); + expect(prompt).toContain('do not re-queue it'); + }); + + it('bounds a long CoS queue before it enters the reasoning prompt', async () => { + const inventory = Array.from({ length: 200 }, (_, index) => ({ + id: `sys-mind-${index}`, + status: 'completed', + description: 'D'.repeat(160), + appId: 'portos', + queuedByMind: true, + })); + const prompt = buildPersistentMindTaskCapabilityPrompt({ enabled: true, catalog: { apps: [], providers: [] }, inventory }); + expect(prompt.length).toBeLessThan(20_000); + expect(prompt).toContain('sys-mind-0'); + }); + it('reuses tracker resolution for repeated catalog reads', async () => { mocks.apps = [{ id: 'cached-app', name: 'Cached App', repoPath: '/example/cached-app' }]; await readPersistentMindTaskCatalog(); From 88239416031ec2e7d629c13b68836d55a753b2d5 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Fri, 4 Sep 2026 18:31:52 +0000 Subject: [PATCH 2/2] address review (local): task-queue prompt wording matches what a completed task means --- server/services/persistentMindTaskCapability.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/services/persistentMindTaskCapability.js b/server/services/persistentMindTaskCapability.js index 796fac93ec..0611b19483 100644 --- a/server/services/persistentMindTaskCapability.js +++ b/server/services/persistentMindTaskCapability.js @@ -295,10 +295,10 @@ keeps absent dependencies advisory, which is appropriate for docs-only work. Configured choices (ids are authoritative; do not invent ids): ${JSON.stringify(promptCatalog)} -Recent CoS queue (newest first; 'completed' work already shipped, so do not re-queue it): +Recent CoS queue (newest first; a 'completed' entry already ran, so do not re-queue it): ${JSON.stringify(boundedPromptInventory(inventory))} -Use taskRequests only for specific, non-duplicate work. Before requesting a task, check the recent queue above and the trajectory: if the same work is already pending, in progress, or completed, say so instead of queueing it again. Put the complete agent instructions in prompt and a concise queue label in description. In your conversational message describe the request as pending; do not claim the task was created or completed because the capability outcome is recorded only after inference.`; +Use taskRequests only for specific, non-duplicate work. Before requesting a task, check the recent queue above and the trajectory: if the same work is already there in any state, say so instead of queueing it again. Put the complete agent instructions in prompt and a concise queue label in description. In your conversational message describe the request as pending; do not claim the task was created or completed because the capability outcome is recorded only after inference.`; } const wakeIdentity = (wake, turnId) => (