feat(agent-mode): teach backends about @-mention pill syntax (#2498)

* feat(agent-mode): teach backends about @-mention pill syntax

The chat editor serializes @-mention pills to `[[note]]`, `{folder}`, and
`{activeNote}` tokens, but only `[[note]]` was understood — the others
were literal strings to the agent. Add a shared pill-syntax directive
appended to opencode, codex, and claude SDK spawn-time instructions
(mirroring the existing skill-creation directive plumbing), and resolve
`{activeNote}` to the real `[[Note Title]]` at send time since the active
file is a client-side concept the agent can't query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent-mode): prune narration comments from pill-syntax change

Cut multi-line JSDoc and inline rationale that explained WHAT or referenced
callers, per the project's "one short line max, only WHY" rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-mode): preserve extension when resolving {activeNote}

Non-markdown active files (.pdf, .canvas) lost their extension when
{activeNote} was rewritten via activeFile.basename, leaving the agent
with an ambiguous [[name]] that didn't match the path the pill envelope
carries. Mirror NotePillNode.getTextContent's extension-aware
serialization so the inline token matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-20 23:32:49 -07:00 committed by GitHub
parent 898c31ac1b
commit 4e8c604efb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 160 additions and 15 deletions

View file

@ -16,6 +16,7 @@ import { resolveClaudeBinary } from "./claudeBinaryResolver";
import { ClaudeSdkBackendProcess } from "@/agentMode/sdk/ClaudeSdkBackendProcess";
import { getCachedSdkCatalog, synthesizeEffortConfigOption } from "@/agentMode/sdk/effortOption";
import {
buildPillSyntaxDirective,
buildSkillCreationDirective,
DEFAULT_SKILLS_FOLDER,
SkillManager,
@ -206,7 +207,7 @@ export const ClaudeBackendDescriptor: BackendDescriptor = {
getSkillCreationDirective: () => {
const folder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel());
return buildSkillCreationDirective("claude", folder, dirs);
return `${buildPillSyntaxDirective()}\n\n${buildSkillCreationDirective("claude", folder, dirs)}`;
},
});
},

View file

@ -42,7 +42,7 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
});
});
it("injects the skill-creation directive via -c developer_instructions", async () => {
it("injects the pill-syntax + skill-creation directives via -c developer_instructions", async () => {
const backend = new CodexBackend();
const desc = await backend.buildSpawnDescriptor({ vaultBasePath: "/vault" });
expect(desc.command).toBe("/usr/local/bin/codex-acp");
@ -50,7 +50,8 @@ describe("CodexBackend.buildSpawnDescriptor", () => {
expect(cIdx).toBeGreaterThanOrEqual(0);
const value = desc.args[cIdx + 1];
expect(value.startsWith("developer_instructions=")).toBe(true);
// The TOML value carries the directive text (newlines escaped as \n).
expect(value).toContain("{folder_name}");
expect(value).toContain("{activeNote}");
expect(value).toContain('metadata.copilot-enabled-agents: \\"codex\\"');
expect(value).toContain("copilot/skills/<name>/SKILL.md");
expect(value).toContain(".claude/skills/");

View file

@ -2,6 +2,7 @@ import { getSettings } from "@/settings/model";
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import { buildSimpleSpawnDescriptor } from "@/agentMode/backends/shared/simpleBinaryBackend";
import {
buildPillSyntaxDirective,
buildSkillCreationDirective,
DEFAULT_SKILLS_FOLDER,
SkillManager,
@ -37,7 +38,7 @@ export class CodexBackend implements AcpBackend {
// session. See the Skills Management spec.
const skillsFolder = getSettings().agentMode?.skills?.folder ?? DEFAULT_SKILLS_FOLDER;
const dirs = Object.values(SkillManager.getInstance().getAgentDirsProjectRel());
const directive = buildSkillCreationDirective("codex", skillsFolder, dirs);
const directive = `${buildPillSyntaxDirective()}\n\n${buildSkillCreationDirective("codex", skillsFolder, dirs)}`;
descriptor.args = [
...descriptor.args,
"-c",

View file

@ -326,12 +326,11 @@ describe("buildOpencodeConfig", () => {
const cfg = (await buildOpencodeConfig()) as {
agent: Record<string, { prompt?: string; permission?: unknown; mode?: string }>;
};
// Prompt now starts with the COPILOT_PROMPT_BASE and ends with the
// spawn-time skill-creation directive (see the Skills Management
// spec). Assert the base is the prefix so future directive changes
// don't break this test.
expect(cfg.agent["copilot-build"].prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true);
expect(cfg.agent.build.prompt?.startsWith(COPILOT_PROMPT_BASE)).toBe(true);
expect(cfg.agent["copilot-build"].prompt).toContain("{folder_name}");
expect(cfg.agent["copilot-build"].prompt).toContain("{activeNote}");
expect(cfg.agent.build.prompt).toContain("{folder_name}");
expect(cfg.agent["copilot-build"].prompt).toContain(
'metadata.copilot-enabled-agents: "opencode"'
);

View file

@ -5,6 +5,7 @@ import { getSettings } from "@/settings/model";
import { AcpBackend, AcpSpawnDescriptor } from "@/agentMode/acp/types";
import type { CopilotMode } from "@/agentMode/session/types";
import {
buildPillSyntaxDirective,
buildSkillCreationDirective,
composeDenyList,
DEFAULT_SKILLS_FOLDER,
@ -233,7 +234,7 @@ export async function buildOpencodeConfig(): Promise<Record<string, unknown>> {
const skillsDirs = skillManagerReady
? Object.values(SkillManager.getInstance().getAgentDirsProjectRel())
: [];
const prompt = `${basePrompt}\n\n${buildSkillCreationDirective("opencode", skillsFolder, skillsDirs)}`;
const prompt = `${basePrompt}\n\n${buildPillSyntaxDirective()}\n\n${buildSkillCreationDirective("opencode", skillsFolder, skillsDirs)}`;
config.agent = {
[OPENCODE_BUILTIN_BUILD_AGENT_ID]: {
prompt,

View file

@ -49,7 +49,6 @@ export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant th
- Use \`$...$\` for LaTeX equations, never \`\\[...\\]\` or \`\\(...\\)\`.
- For markdown lists, always use \`- \` (hyphen followed by exactly one space) for bullet points. Never use \`*\` for bullets.
- For tables, use GitHub-flavored markdown.
- When referring to an Obsidian note in your written reply, use \`[[title]]\` format (no backticks around it). To actually read or modify a note, call the \`read\` or \`edit\` tool — don't infer note contents from a wikilink title alone.
- For Obsidian-internal image links, use \`![[link]]\` format. For web image links, use \`![alt](url)\` format.`;
/**

View file

@ -0,0 +1,67 @@
import { mockTFile } from "@/__tests__/mockObsidian";
import { resolveActiveNoteToken } from "./resolveActiveNoteToken";
jest.mock("obsidian", () => ({
TFile: jest.fn(),
}));
const mockFile = (basename: string, extension = "md") =>
mockTFile({ basename, extension, path: `${basename}.${extension}` });
describe("resolveActiveNoteToken", () => {
it("replaces {activeNote} with the active file's wikilink form", () => {
const out = resolveActiveNoteToken(
"Summarize {activeNote} in 3 bullets.",
mockFile("Today's Standup")
);
expect(out).toBe("Summarize [[Today's Standup]] in 3 bullets.");
});
it("replaces every occurrence", () => {
const out = resolveActiveNoteToken(
"Compare {activeNote} against {activeNote}.",
mockFile("Notes")
);
expect(out).toBe("Compare [[Notes]] against [[Notes]].");
});
it("leaves the token untouched when there is no active file", () => {
expect(resolveActiveNoteToken("Summarize {activeNote}", null)).toBe("Summarize {activeNote}");
});
it("is a no-op when the token is absent", () => {
const text = "Summarize [[Some Other Note]] please.";
expect(resolveActiveNoteToken(text, mockFile("Active"))).toBe(text);
});
it("does not touch folder tokens or other curly-brace content", () => {
const out = resolveActiveNoteToken(
"Look in {Projects} and summarize {activeNote}.",
mockFile("Daily")
);
expect(out).toBe("Look in {Projects} and summarize [[Daily]].");
});
it("treats `{ActiveNote}` (wrong case) as a non-match — only the reserved literal is replaced", () => {
const text = "Mention {ActiveNote} and {activeNote}.";
expect(resolveActiveNoteToken(text, mockFile("Daily"))).toBe(
"Mention {ActiveNote} and [[Daily]]."
);
});
// split/join (not String.prototype.replace) avoids `$&`/`$1` interpretation in the basename.
it("preserves `$` characters in the basename (no regex-replacement surprises)", () => {
expect(resolveActiveNoteToken("ref {activeNote} here", mockFile("Q1 $revenue"))).toBe(
"ref [[Q1 $revenue]] here"
);
});
it("keeps the extension on non-markdown active files (matches NotePillNode serialization)", () => {
expect(resolveActiveNoteToken("Summarize {activeNote}", mockFile("Spec", "pdf"))).toBe(
"Summarize [[Spec.pdf]]"
);
expect(resolveActiveNoteToken("Open {activeNote}", mockFile("Mindmap", "canvas"))).toBe(
"Open [[Mindmap.canvas]]"
);
});
});

View file

@ -0,0 +1,13 @@
import type { TFile } from "obsidian";
/** Rewrites `{activeNote}` to `[[Title]]` — the agent has no workspace context to resolve the token itself. */
export function resolveActiveNoteToken(text: string, activeFile: TFile | null): string {
if (!activeFile) return text;
if (!text.includes("{activeNote}")) return text;
// Mirror NotePillNode.getTextContent: keep the extension for non-markdown attachments
// (pdf/canvas) so the agent resolves the same path the pill envelope carries.
const ext = activeFile.extension?.toLowerCase();
const display =
ext === "pdf" || ext === "canvas" ? `${activeFile.basename}.${ext}` : activeFile.basename;
return text.split("{activeNote}").join(`[[${display}]]`);
}

View file

@ -28,6 +28,7 @@ export { reconcile, getAgentDirs } from "./reconcile";
export type { ReconcileFs, ReconcileOptions, ReconcileReport } from "./reconcile";
export { agentSkillsDirAbs, DEFAULT_SKILLS_FOLDER } from "./agentPaths";
export { buildSkillCreationDirective } from "./spawnDirective";
export { buildPillSyntaxDirective } from "./pillSyntaxDirective";
export { composeDenyList } from "./denyListComposer";
export { DeleteConfirmModal } from "./ui/DeleteConfirmDialog";
export { PropertiesModal } from "./ui/PropertiesDialog";

View file

@ -0,0 +1,39 @@
import { buildPillSyntaxDirective } from "./pillSyntaxDirective";
describe("buildPillSyntaxDirective", () => {
const directive = buildPillSyntaxDirective();
it("documents the three pill token shapes", () => {
expect(directive).toContain("[[note_title]]");
expect(directive).toContain("{folder_name}");
expect(directive).toContain("{activeNote}");
});
it("frames the tokens as concrete references, not template placeholders", () => {
expect(directive).toMatch(/concrete references/i);
expect(directive).toMatch(/NOT as template placeholders/);
});
it("gives the agent a usable folder-scoping pattern", () => {
expect(directive).toContain("folder_name/**");
expect(directive).toMatch(/\bglob\b/);
expect(directive).toMatch(/\bgrep\b/);
});
it("tells the agent to use `read`/`edit` for notes, not infer from title", () => {
expect(directive).toMatch(/\bread\b/);
expect(directive).toMatch(/never infer/i);
});
it("instructs the agent to cite notes with `[[title]]` in replies", () => {
expect(directive).toMatch(/\[\[title\]\]/);
});
it("takes no arguments and produces a stable string", () => {
expect(buildPillSyntaxDirective()).toBe(directive);
});
it("has no leading or trailing whitespace", () => {
expect(directive).toBe(directive.trim());
});
});

View file

@ -0,0 +1,20 @@
/** Teaches backends how to interpret the literal @-mention pill tokens (`[[title]]`, `{folder}`, `{activeNote}`) the chat editor emits. */
export function buildPillSyntaxDirective(): string {
return (
`The user composes messages in a rich editor that supports @-mentions of vault items.\n` +
`Mentioned items appear inline in your input as the following literal tokens — treat\n` +
`them as concrete references the user picked, NOT as template placeholders to substitute.\n` +
`\n` +
`- \`[[note_title]]\` — a specific note in the vault. To read or modify it, call \`read\`\n` +
` or \`edit\` with the resolved path; never infer a note's contents from its title alone.\n` +
` When you cite a note in your written reply, use the same \`[[title]]\` form (no backticks).\n` +
`- \`{folder_name}\` — a vault folder the user wants you to focus on. To scope work to that\n` +
` folder, pass \`folder_name/**\` to \`glob\`, or include \`folder_name/\` as a path prefix\n` +
` when calling \`read\`, \`grep\`, or other path-aware tools.\n` +
`- \`{activeNote}\` — the user's currently active note (reserved special token). Resolve it\n` +
` the same way as \`[[note_title]]\`.\n` +
`\n` +
`Any other \`{...}\` token in the user's message refers to a folder by that name, not a\n` +
`placeholder to fill in.`
);
}

View file

@ -13,6 +13,7 @@ import { useChatFileDrop } from "@/hooks/useChatFileDrop";
import type { AgentChatBackend } from "@/agentMode/session/AgentChatBackend";
import type { AgentSessionManager } from "@/agentMode/session/AgentSessionManager";
import { expandCustomCommandPrefix } from "@/agentMode/session/expandCustomCommandPrefix";
import { resolveActiveNoteToken } from "@/agentMode/session/resolveActiveNoteToken";
import type {
AgentChatMessage,
CurrentPlan,
@ -273,10 +274,11 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
const hasWebExcerpt = selectedTextContexts.some(isWebSelectedTextContext);
const hadUnsupportedAttachments = includeActiveWebTab || hasWebExcerpt;
const activeFile = app.workspace.getActiveFile();
const candidateNotes: TFile[] = [];
if (includeActiveNote) {
const active = app.workspace.getActiveFile();
if (active) candidateNotes.push(active);
if (includeActiveNote && activeFile) {
candidateNotes.push(activeFile);
}
candidateNotes.push(...contextNotes);
const notes = dedupeBy(candidateNotes, (n) => n.path);
@ -292,11 +294,12 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
getCachedCustomCommands(),
app.vault,
noteSelection?.content ?? "",
app.workspace.getActiveFile()
activeFile
);
if (expanded.matched) {
void CustomCommandManager.getInstance().recordUsage(expanded.matched);
}
const resolvedText = resolveActiveNoteToken(expanded.text, activeFile);
const content: PromptContent[] = [];
@ -308,7 +311,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
const item: QueuedAgentMessage = {
id: `queued-${uuidv4()}`,
text: expanded.text,
text: resolvedText,
rawInput,
context: buildMessageContext(notes, selectedTextContexts),
promptContent: content.length > 0 ? content : undefined,