diff --git a/src/agentMode/backends/claude/descriptor.ts b/src/agentMode/backends/claude/descriptor.ts index 5ab25300..e3b5ecb1 100644 --- a/src/agentMode/backends/claude/descriptor.ts +++ b/src/agentMode/backends/claude/descriptor.ts @@ -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)}`; }, }); }, diff --git a/src/agentMode/backends/codex/CodexBackend.test.ts b/src/agentMode/backends/codex/CodexBackend.test.ts index a220012f..512f03f3 100644 --- a/src/agentMode/backends/codex/CodexBackend.test.ts +++ b/src/agentMode/backends/codex/CodexBackend.test.ts @@ -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//SKILL.md"); expect(value).toContain(".claude/skills/"); diff --git a/src/agentMode/backends/codex/CodexBackend.ts b/src/agentMode/backends/codex/CodexBackend.ts index ac3d67a8..23204790 100644 --- a/src/agentMode/backends/codex/CodexBackend.ts +++ b/src/agentMode/backends/codex/CodexBackend.ts @@ -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", diff --git a/src/agentMode/backends/opencode/OpencodeBackend.test.ts b/src/agentMode/backends/opencode/OpencodeBackend.test.ts index 3787fffe..3550d8c8 100644 --- a/src/agentMode/backends/opencode/OpencodeBackend.test.ts +++ b/src/agentMode/backends/opencode/OpencodeBackend.test.ts @@ -326,12 +326,11 @@ describe("buildOpencodeConfig", () => { const cfg = (await buildOpencodeConfig()) as { agent: Record; }; - // 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"' ); diff --git a/src/agentMode/backends/opencode/OpencodeBackend.ts b/src/agentMode/backends/opencode/OpencodeBackend.ts index 1f22f0f8..cb2ced28 100644 --- a/src/agentMode/backends/opencode/OpencodeBackend.ts +++ b/src/agentMode/backends/opencode/OpencodeBackend.ts @@ -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> { 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, diff --git a/src/agentMode/backends/opencode/prompts.ts b/src/agentMode/backends/opencode/prompts.ts index b4a6550f..52e1871f 100644 --- a/src/agentMode/backends/opencode/prompts.ts +++ b/src/agentMode/backends/opencode/prompts.ts @@ -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.`; /** diff --git a/src/agentMode/session/resolveActiveNoteToken.test.ts b/src/agentMode/session/resolveActiveNoteToken.test.ts new file mode 100644 index 00000000..6b418da4 --- /dev/null +++ b/src/agentMode/session/resolveActiveNoteToken.test.ts @@ -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]]" + ); + }); +}); diff --git a/src/agentMode/session/resolveActiveNoteToken.ts b/src/agentMode/session/resolveActiveNoteToken.ts new file mode 100644 index 00000000..af0b0030 --- /dev/null +++ b/src/agentMode/session/resolveActiveNoteToken.ts @@ -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}]]`); +} diff --git a/src/agentMode/skills/index.ts b/src/agentMode/skills/index.ts index 793606d8..467c9c4e 100644 --- a/src/agentMode/skills/index.ts +++ b/src/agentMode/skills/index.ts @@ -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"; diff --git a/src/agentMode/skills/pillSyntaxDirective.test.ts b/src/agentMode/skills/pillSyntaxDirective.test.ts new file mode 100644 index 00000000..da8e6166 --- /dev/null +++ b/src/agentMode/skills/pillSyntaxDirective.test.ts @@ -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()); + }); +}); diff --git a/src/agentMode/skills/pillSyntaxDirective.ts b/src/agentMode/skills/pillSyntaxDirective.ts new file mode 100644 index 00000000..e6b91273 --- /dev/null +++ b/src/agentMode/skills/pillSyntaxDirective.ts @@ -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.` + ); +} diff --git a/src/agentMode/ui/AgentChat.tsx b/src/agentMode/ui/AgentChat.tsx index 2112bdfa..8a062132 100644 --- a/src/agentMode/ui/AgentChat.tsx +++ b/src/agentMode/ui/AgentChat.tsx @@ -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 = ({ 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 = ({ 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 = ({ const item: QueuedAgentMessage = { id: `queued-${uuidv4()}`, - text: expanded.text, + text: resolvedText, rawInput, context: buildMessageContext(notes, selectedTextContexts), promptContent: content.length > 0 ? content : undefined,