diff --git a/src/memory/extract.test.ts b/src/memory/extract.test.ts
index df741c13..ea531fb2 100644
--- a/src/memory/extract.test.ts
+++ b/src/memory/extract.test.ts
@@ -67,6 +67,27 @@ test('buildExtractPrompt invokes MemoryWrite and renders existing memories', ()
assert.match(prompt, /project-style\.md/)
})
+test('buildExtractPrompt strips background-task-result blocks but keeps surrounding turns', () => {
+ const bgBlock = [
+ '',
+ 'Agentic RAG is the dominant pattern for personal knowledge management agents.',
+ '',
+ ].join('\n')
+ const prompt = buildExtractPrompt(
+ [
+ createUserMessage(bgBlock, null, 10),
+ createUserMessage('thanks, please also note I prefer metric units', null, 20),
+ ],
+ [],
+ )
+ // The worker that produced the bg result already extracted it into its own
+ // L3; the manager must not re-extract the same finding.
+ assert.doesNotMatch(prompt, /Agentic RAG is the dominant pattern/)
+ assert.match(prompt, /background-task result omitted/)
+ // The manager's own surrounding turn is still in the extraction window.
+ assert.match(prompt, /prefer metric units/)
+})
+
test('memoryExtractor system prompt bans JSON-text output (regression guard)', () => {
// Output discipline lives in the role's system prompt, not in the
// per-call user message (which previously duplicated this rule). The
diff --git a/src/memory/extract.ts b/src/memory/extract.ts
index 838d7a3f..d2b69eb0 100644
--- a/src/memory/extract.ts
+++ b/src/memory/extract.ts
@@ -172,6 +172,27 @@ function newestTimestamp(messages: Message[], fallback: number): number {
return Math.max(fallback, ...messages.map(message => message.timestamp))
}
+const BACKGROUND_RESULT_BLOCK_RE =
+ /]*>[\s\S]*?<\/background-task-result>/g
+
+/**
+ * A `` block is a finished worker's deliverable
+ * injected into the manager's prompt when a background dispatch completes.
+ * The worker that produced it already ran its own extraction over its fork
+ * transcript, so re-extracting the same finding from the manager's window
+ * would duplicate it into the manager's memory tier (2026-05-20 dogfood
+ * Bug 3: one PKM research topic landed 3 copies in the user-level root on
+ * top of the worker's own L3 entries). Replace each block with a one-line
+ * marker so the manager's surrounding turns still read coherently — the
+ * manager's own synthesis lives in its assistant messages, which are kept.
+ */
+export function stripBackgroundResultBlocks(text: string): string {
+ return text.replace(
+ BACKGROUND_RESULT_BLOCK_RE,
+ '[background-task result omitted — already extracted by the worker that produced it]',
+ )
+}
+
export function buildExtractPrompt(
newMessages: Message[],
existingMemories: MemoryEntry[],
@@ -183,10 +204,9 @@ export function buildExtractPrompt(
.join('\n')
: '[none]'
- const conversationText = newMessages
- .map(message => messageToText(message))
- .join('\n\n')
- .slice(0, 100_000)
+ const conversationText = stripBackgroundResultBlocks(
+ newMessages.map(message => messageToText(message)).join('\n\n'),
+ ).slice(0, 100_000)
return [
'Extract durable memories from the conversation segment below by calling the MemoryWrite tool. Follow the workflow and conventions from your system prompt.',