mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
898c31ac1b
commit
4e8c604efb
12 changed files with 160 additions and 15 deletions
|
|
@ -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)}`;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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/");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 \`\` format.`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
67
src/agentMode/session/resolveActiveNoteToken.test.ts
Normal file
67
src/agentMode/session/resolveActiveNoteToken.test.ts
Normal 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]]"
|
||||
);
|
||||
});
|
||||
});
|
||||
13
src/agentMode/session/resolveActiveNoteToken.ts
Normal file
13
src/agentMode/session/resolveActiveNoteToken.ts
Normal 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}]]`);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
39
src/agentMode/skills/pillSyntaxDirective.test.ts
Normal file
39
src/agentMode/skills/pillSyntaxDirective.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
20
src/agentMode/skills/pillSyntaxDirective.ts
Normal file
20
src/agentMode/skills/pillSyntaxDirective.ts
Normal 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.`
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue