Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/core/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
}

Expand Down
20 changes: 18 additions & 2 deletions src/qoder/prompt/context/prompt-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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 {
Expand Down Expand Up @@ -109,6 +110,7 @@ export function extractUserQuery(prompt: string): string {
.replace(/<context_files>[\s\S]*?<\/context_files>\s*/g, '')
.replace(/<canvas_selection[\s\S]*?<\/canvas_selection>\s*/g, '')
.replace(/<browser_selection[\s\S]*?<\/browser_selection>\s*/g, '')
.replace(/<external_context>[\s\S]*?<\/external_context>\s*/g, '')
.trim();
}

Expand All @@ -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</${EXTERNAL_CONTEXT_TAG}>`;
}

export function appendExternalContexts(prompt: string, paths: readonly string[]): string {
return `${prompt}\n\n${formatExternalContexts(paths)}`;
}
6 changes: 5 additions & 1 deletion src/qoder/prompt/qoder-turn-encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
23 changes: 22 additions & 1 deletion src/qoder/runtime/qoder-chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, McpServerConfig> = {};
private readyStateListeners = new Set<(ready: boolean) => void>();

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/qoder/prompt/qoder-turn-encoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<external_context>');
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('<external_context>');
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);
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/qoder/runtime/qoder-chat-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<external_context>');
expect(first.persistedContent).toContain('/tmp/a');

const unchanged = service.prepareTurn({ text: 'again', externalContextPaths: ['/tmp/a'] });
expect(unchanged.persistedContent).not.toContain('<external_context>');

const added = service.prepareTurn({
text: 'more',
externalContextPaths: ['/tmp/b', '/tmp/a'],
});
expect(added.persistedContent).toContain('<external_context>');
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('<external_context>');
});
});

describe('query with PreparedChatTurn', () => {
Expand Down
Loading