diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fae79d..663a8b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Added + +- The agent now learns which external context directories are attached: the + message you send right after adding or removing one carries the current + list, so the agent can use those folders without you referencing every file. + ## [1.0.11] - 2026-09-16 ### Added diff --git a/src/core/runtime/types.ts b/src/core/runtime/types.ts index f656456..8a5c061 100644 --- a/src/core/runtime/types.ts +++ b/src/core/runtime/types.ts @@ -49,6 +49,8 @@ export interface ChatTurnRequest { browserSelection?: BrowserSelectionContext | null; canvasSelection?: CanvasSelectionContext | null; externalContextPaths?: string[]; + /** External context list to announce in the turn text when it just changed. */ + externalContextsNotice?: string[]; enabledMcpServers?: Set; } diff --git a/src/qoder/prompt/context/prompt-context.ts b/src/qoder/prompt/context/prompt-context.ts index a0d1250..4877c7d 100644 --- a/src/qoder/prompt/context/prompt-context.ts +++ b/src/qoder/prompt/context/prompt-context.ts @@ -8,6 +8,7 @@ import { escapeXmlClosingTag } from './xml-context'; const LINKED_NOTE_TAG = 'linked_note'; const NOTE_CONTEXT_TAG_PATTERN = '(linked_note|current_note)'; +const EXTERNAL_CONTEXT_TAG = 'external_context'; // Matches note context at the START of prompt (legacy placement) const NOTE_CONTEXT_PREFIX_REGEX = new RegExp(`^<${NOTE_CONTEXT_TAG_PATTERN}>\\n[\\s\\S]*?<\\/\\1>\\n\\n`); @@ -18,9 +19,9 @@ const NOTE_CONTEXT_SUFFIX_REGEX = new RegExp(`\\n\\n<${NOTE_CONTEXT_TAG_PATTERN} * Pattern to match XML context tags appended to prompts. * These tags are always preceded by \n\n separator. * Matches: linked_note/current_note, editor_selection (with attributes), editor_cursor (with attributes), - * context_files, canvas_selection, browser_selection + * context_files, canvas_selection, browser_selection, external_context */ -export const XML_CONTEXT_PATTERN = /\n\n<(?:linked_note|current_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection)[\s>]/; +export const XML_CONTEXT_PATTERN = /\n\n<(?:linked_note|current_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection|external_context)[\s>]/; const BRACKET_CONTEXT_PATTERN = /\n\[(?:Current note|Editor selection from|Browser selection from|Canvas selection from)\b/; export function formatCurrentNote(notePath: string): string { @@ -109,6 +110,7 @@ export function extractUserQuery(prompt: string): string { .replace(/[\s\S]*?<\/context_files>\s*/g, '') .replace(/\s*/g, '') .replace(/\s*/g, '') + .replace(/[\s\S]*?<\/external_context>\s*/g, '') .trim(); } @@ -120,3 +122,17 @@ function formatContextFilesLine(files: string[]): string { export function appendContextFiles(prompt: string, files: string[]): string { return `${prompt}\n\n${formatContextFilesLine(files)}`; } + +/** + * Formats the external context directory list for a turn. Appended only when + * the selection just changed, so the model learns about additions and + * removals once instead of re-reading the same list every turn. + */ +export function formatExternalContexts(paths: readonly string[]): string { + const body = paths.length > 0 ? paths.join('\n') : '(none)'; + return `<${EXTERNAL_CONTEXT_TAG}>\n${escapeXmlClosingTag(body, EXTERNAL_CONTEXT_TAG)}\n`; +} + +export function appendExternalContexts(prompt: string, paths: readonly string[]): string { + return `${prompt}\n\n${formatExternalContexts(paths)}`; +} diff --git a/src/qoder/prompt/qoder-turn-encoder.ts b/src/qoder/prompt/qoder-turn-encoder.ts index 6783386..cc4e845 100644 --- a/src/qoder/prompt/qoder-turn-encoder.ts +++ b/src/qoder/prompt/qoder-turn-encoder.ts @@ -3,7 +3,7 @@ import type { McpServerManager } from '../mcp/mcp-server-manager'; import { appendBrowserContext } from './context/browser-context'; import { appendCanvasContext } from './context/canvas-context'; import { appendEditorContext } from './context/editor-context'; -import { appendCurrentNote } from './context/prompt-context'; +import { appendCurrentNote, appendExternalContexts } from './context/prompt-context'; function isCompactCommand(text: string): boolean { return /^\/compact(\s|$)/i.test(text); @@ -32,6 +32,10 @@ export function encodeQoderTurn( if (request.canvasSelection) { persistedContent = appendCanvasContext(persistedContent, request.canvasSelection); } + + if (request.externalContextsNotice) { + persistedContent = appendExternalContexts(persistedContent, request.externalContextsNotice); + } } const mcpMentions = mcpManager.extractMentions(persistedContent); diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index bc6fc3b..14072fe 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -135,6 +135,7 @@ export class QoderChatRuntime implements ChatRuntime { private permissionModeSyncCallback: ((sdkMode: string) => void) | null = null; private vaultPath: string | null = null; private currentExternalContextPaths: string[] = []; + private announcedExternalContextPaths: string[] = []; private currentMcpServers: Record = {}; private readyStateListeners = new Set<(ready: boolean) => void>(); @@ -216,7 +217,25 @@ export class QoderChatRuntime implements ChatRuntime { } prepareTurn(request: ChatTurnRequest): PreparedChatTurn { - return encodeQoderTurn(request, this.mcpManager); + const notice = this.consumeExternalContextsNotice(request.externalContextPaths); + return encodeQoderTurn( + notice === undefined ? request : { ...request, externalContextsNotice: notice }, + this.mcpManager, + ); + } + + /** + * The agent cannot see the workspace roots otherwise: the CLI's own + * environment block is not part of Qoderian's system prompt. Announce the + * current list once per change (empty means every directory was removed). + */ + private consumeExternalContextsNotice(paths: string[] | undefined): string[] | undefined { + const current = paths ?? []; + const announced = this.announcedExternalContextPaths; + this.announcedExternalContextPaths = [...current]; + const unchanged = current.length === announced.length + && current.every((path) => announced.includes(path)); + return unchanged ? undefined : current; } consumeTurnMetadata(): ChatTurnMetadata { @@ -1275,6 +1294,8 @@ export class QoderChatRuntime implements ChatRuntime { if (sessionChanged) { void this.closePersistentQuery('session switch'); this.crashRecoveryAttempted = false; + // A restored session never saw the current directories; announce them. + this.announcedExternalContextPaths = []; } this.sessionManager.setSessionId(id, this.getScopedSettings().model); diff --git a/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts b/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts index 2349857..bf1915c 100644 --- a/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts +++ b/tests/unit/qoder/prompt/qoder-turn-encoder.test.ts @@ -113,6 +113,44 @@ describe('encodeQoderTurn', () => { expect(result.persistedContent).toContain('node-2'); }); + it('should append the external context notice when provided', () => { + const request: ChatTurnRequest = { + text: 'hello', + externalContextsNotice: ['/Users/me/Workspace', '/tmp/other'], + }; + const result = encodeQoderTurn(request, mcpManager); + + expect(result.persistedContent).toContain(''); + expect(result.persistedContent).toContain('/Users/me/Workspace'); + expect(result.persistedContent).toContain('/tmp/other'); + }); + + it('should not append the external context tag from the paths alone', () => { + const request: ChatTurnRequest = { + text: 'hello', + externalContextPaths: ['/Users/me/Workspace'], + }; + const result = encodeQoderTurn(request, mcpManager); + + expect(result.persistedContent).toBe('hello'); + }); + + it('should announce an emptied external context list', () => { + const result = encodeQoderTurn({ text: 'hello', externalContextsNotice: [] }, mcpManager); + + expect(result.persistedContent).toContain(''); + expect(result.persistedContent).toContain('(none)'); + }); + + it('should skip the external context notice for /compact', () => { + const result = encodeQoderTurn( + { text: '/compact', externalContextsNotice: ['/Users/me/Workspace'] }, + mcpManager, + ); + + expect(result.persistedContent).toBe('/compact'); + }); + it('should extract and transform MCP mentions', () => { const mentions = new Set(['server-a']); mcpManager.extractMentions.mockReturnValue(mentions); diff --git a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts index 1ca32d8..ea1200f 100644 --- a/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts +++ b/tests/unit/qoder/runtime/qoder-chat-runtime.test.ts @@ -141,6 +141,35 @@ describe('QoderChatRuntime', () => { const result = service.prepareTurn({ text: '@server-a hello' }); expect(result.mcpMentions).toEqual(new Set(['server-a'])); }); + + it('should announce external context paths only when they change', () => { + const first = service.prepareTurn({ text: 'hi', externalContextPaths: ['/tmp/a'] }); + expect(first.persistedContent).toContain(''); + expect(first.persistedContent).toContain('/tmp/a'); + + const unchanged = service.prepareTurn({ text: 'again', externalContextPaths: ['/tmp/a'] }); + expect(unchanged.persistedContent).not.toContain(''); + + const added = service.prepareTurn({ + text: 'more', + externalContextPaths: ['/tmp/b', '/tmp/a'], + }); + expect(added.persistedContent).toContain(''); + expect(added.persistedContent).toContain('/tmp/b'); + + const cleared = service.prepareTurn({ text: 'done' }); + expect(cleared.persistedContent).toContain('(none)'); + }); + + it('should announce external context paths again after a session switch', () => { + service.prepareTurn({ text: 'hi', externalContextPaths: ['/tmp/a'] }); + service.prepareTurn({ text: 'again', externalContextPaths: ['/tmp/a'] }); + + service.setSessionId('session-with-contexts', ['/tmp/a']); + + const afterSwitch = service.prepareTurn({ text: 'resumed', externalContextPaths: ['/tmp/a'] }); + expect(afterSwitch.persistedContent).toContain(''); + }); }); describe('query with PreparedChatTurn', () => {