From 79bc1b458b83f2f58f7c7f01a76c78138536bbc2 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 16 May 2026 23:53:45 +0900 Subject: [PATCH] Support skill mentions in composer input --- src/app-server/client.ts | 4 +- src/composer/wikilink-context.ts | 42 +++++++++++++++++ src/panel/composer-controller.ts | 8 +++- src/panel/controller.ts | 3 ++ src/panel/session-controller.ts | 4 +- src/panel/view.ts | 8 ++++ src/utils.ts | 2 +- tests/app-server-client.test.ts | 29 ++++++++---- tests/display-model.test.ts | 16 +++++++ tests/panel-controller.test.ts | 13 +++++ tests/wikilink-context.test.ts | 81 +++++++++++++++++++++++++++++++- 11 files changed, 192 insertions(+), 18 deletions(-) diff --git a/src/app-server/client.ts b/src/app-server/client.ts index 0cc89c16..ab907d7b 100644 --- a/src/app-server/client.ts +++ b/src/app-server/client.ts @@ -248,10 +248,10 @@ export class AppServerClient { }); } - listSkills(cwd: string): Promise { + listSkills(cwd: string, forceReload = false): Promise { return this.request("skills/list", { cwds: [cwd], - forceReload: false, + forceReload, }); } diff --git a/src/composer/wikilink-context.ts b/src/composer/wikilink-context.ts index 852f99e2..a230ab20 100644 --- a/src/composer/wikilink-context.ts +++ b/src/composer/wikilink-context.ts @@ -1,3 +1,4 @@ +import type { SkillMetadata } from "../generated/app-server/v2/SkillMetadata"; import type { UserInput } from "../generated/app-server/v2/UserInput"; export interface ParsedWikiLink { @@ -10,6 +11,7 @@ export interface ParsedWikiLink { export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null; const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g; +const SKILL_REFERENCE_PATTERN = /(^|[\s([{])\$([^\s\])}.,;!?]{1,120})(?=$|[\s\])}.,;!?])/g; export function parsedWikiLinks(text: string): ParsedWikiLink[] { const links: ParsedWikiLink[] = []; @@ -27,6 +29,14 @@ export function parsedWikiLinks(text: string): ParsedWikiLink[] { } export function userInputWithWikiLinkMentions(text: string, resolveMention: WikiLinkMentionResolver): UserInput[] { + return userInputWithWikiLinkMentionsAndSkills(text, resolveMention, []); +} + +export function userInputWithWikiLinkMentionsAndSkills( + text: string, + resolveMention: WikiLinkMentionResolver, + skills: SkillMetadata[], +): UserInput[] { const input: UserInput[] = [{ type: "text", text, text_elements: [] }]; const seenPaths = new Set(); @@ -37,9 +47,31 @@ export function userInputWithWikiLinkMentions(text: string, resolveMention: Wiki input.push({ type: "mention", name: mention.name, path: mention.path }); } + const skillByName = firstEnabledSkillByName(skills); + const seenSkillPaths = new Set(); + for (const reference of parsedSkillReferences(text)) { + const skill = skillByName.get(reference.toLowerCase()); + if (!skill || seenSkillPaths.has(skill.path)) continue; + seenSkillPaths.add(skill.path); + input.push({ type: "skill", name: skill.name, path: skill.path }); + } + return input; } +export function parsedSkillReferences(text: string): string[] { + const references: string[] = []; + const seen = new Set(); + for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) { + const name = match[2] ?? ""; + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + references.push(name); + } + return references; +} + function parseWikiLink(raw: string): ParsedWikiLink | null { const separator = raw.indexOf("|"); const linkPart = (separator === -1 ? raw : raw.slice(0, separator)).trim(); @@ -60,3 +92,13 @@ function firstSubpathIndex(linkPart: string): number { if (blockIndex === -1) return headingIndex; return Math.min(headingIndex, blockIndex); } + +function firstEnabledSkillByName(skills: SkillMetadata[]): Map { + const byName = new Map(); + for (const skill of skills) { + if (!skill.enabled) continue; + const key = skill.name.toLowerCase(); + if (!byName.has(key)) byName.set(key, skill); + } + return byName; +} diff --git a/src/panel/composer-controller.ts b/src/panel/composer-controller.ts index 92da8668..cc416de5 100644 --- a/src/panel/composer-controller.ts +++ b/src/panel/composer-controller.ts @@ -11,7 +11,7 @@ import { type ComposerSuggestion, type NoteCandidate, } from "../composer/suggestions"; -import { userInputWithWikiLinkMentions } from "../composer/wikilink-context"; +import { userInputWithWikiLinkMentionsAndSkills } from "../composer/wikilink-context"; import type { UserInput } from "../generated/app-server/v2/UserInput"; import type { SendShortcut } from "../settings/model"; import { renderComposerShell, renderComposerSuggestions, syncComposerControls, syncComposerHeight } from "../ui/composer"; @@ -102,7 +102,11 @@ export class PanelComposerController { } codexInput(text: string): UserInput[] { - return userInputWithWikiLinkMentions(text, (target) => resolveAppWikiLinkMention(this.options.app, target)); + return userInputWithWikiLinkMentionsAndSkills( + text, + (target) => resolveAppWikiLinkMention(this.options.app, target), + this.options.state.availableSkills, + ); } private handleSuggestionKeydown(event: KeyboardEvent): boolean { diff --git a/src/panel/controller.ts b/src/panel/controller.ts index 1f91347f..8a51660b 100644 --- a/src/panel/controller.ts +++ b/src/panel/controller.ts @@ -33,6 +33,7 @@ import { clearUserInputDrafts, createApprovalResultItem, createUserInputResultIt export interface PanelControllerActions { refreshThreads: () => void; + refreshSkills: (forceReload?: boolean) => void; maybeNameThread: (threadId: string, turn: Turn) => void; respondToServerRequest: (requestId: RequestId, result: unknown) => boolean; rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean; @@ -111,6 +112,8 @@ export class PanelController { this.state.tokenUsage = params.tokenUsage ?? null; } else if (method === "account/rateLimits/updated") { this.state.rateLimit = params.rateLimits ?? null; + } else if (method === "skills/changed") { + this.actions.refreshSkills(true); } else if (method === "hook/started") { this.upsertHookRun(params.run, params.turnId, "running"); } else if (method === "hook/completed") { diff --git a/src/panel/session-controller.ts b/src/panel/session-controller.ts index e0157b40..a741f13d 100644 --- a/src/panel/session-controller.ts +++ b/src/panel/session-controller.ts @@ -68,11 +68,11 @@ export class PanelSessionController { } } - async refreshSkills(): Promise { + async refreshSkills(forceReload = false): Promise { const client = this.host.currentClient(); if (!client) return; try { - const response = await client.listSkills(this.host.vaultPath); + const response = await client.listSkills(this.host.vaultPath, forceReload); this.host.state.availableSkills = response.data.flatMap((entry) => entry.skills).filter((skill) => skill.enabled); } catch (error) { this.host.state.availableSkills = []; diff --git a/src/panel/view.ts b/src/panel/view.ts index 41c9611e..ffba7d03 100644 --- a/src/panel/view.ts +++ b/src/panel/view.ts @@ -135,6 +135,7 @@ export class CodexPanelView extends ItemView { }); this.controller = new PanelController(this.state, { refreshThreads: () => void this.refreshThreads(), + refreshSkills: (forceReload) => void this.refreshSkills(forceReload), maybeNameThread: (threadId, turn) => this.threadRename.maybeAutoNameThread(threadId, turn), respondToServerRequest: (requestId, result) => this.respondToServerRequest(requestId, result), rejectServerRequest: (requestId, code, message) => this.rejectServerRequest(requestId, code, message), @@ -274,6 +275,13 @@ export class CodexPanelView extends ItemView { } } + private async refreshSkills(forceReload = false): Promise { + this.client = this.connection.currentClient(); + if (!this.client) return; + await this.session.refreshSkills(forceReload); + this.render(); + } + private async resumeThread(threadId: string): Promise { if (this.state.busy && threadId !== this.state.activeThreadId) { this.addSystemMessage("Finish or interrupt the current turn before switching threads."); diff --git a/src/utils.ts b/src/utils.ts index b6657cac..68f074f8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,7 +20,7 @@ export function inputToText(content: UserInput[]): string { if (item.type === "localImage") return `[local image] ${item.path}`; if (item.type === "image") return `[image] ${item.url}`; if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`; - if (item.type === "skill") return `[$${item.name}] ${item.path}`; + if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`; return ""; }) .filter(Boolean) diff --git a/tests/app-server-client.test.ts b/tests/app-server-client.test.ts index b9aa82d0..9fc7817a 100644 --- a/tests/app-server-client.test.ts +++ b/tests/app-server-client.test.ts @@ -351,31 +351,40 @@ describe("AppServerClient", () => { transport.emitLine({ id: 5, result: { data: [] } }); await skills; - const turns = client.threadTurnsList("thread-1", "cursor-1", 10); + const reloadedSkills = client.listSkills("/vault", true); expect(transport.sent[6]).toMatchObject({ id: 6, - method: "thread/turns/list", - params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" }, + method: "skills/list", + params: { cwds: ["/vault"], forceReload: true }, }); - transport.emitLine({ id: 6, result: { data: [], nextCursor: null } }); - await turns; + transport.emitLine({ id: 6, result: { data: [] } }); + await reloadedSkills; - const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc"); + const turns = client.threadTurnsList("thread-1", "cursor-1", 10); expect(transport.sent[7]).toMatchObject({ id: 7, method: "thread/turns/list", - params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" }, + params: { threadId: "thread-1", cursor: "cursor-1", limit: 10, sortDirection: "desc", itemsView: "full" }, }); transport.emitLine({ id: 7, result: { data: [], nextCursor: null } }); + await turns; + + const firstTurn = client.threadTurnsList("thread-1", null, 1, "asc"); + expect(transport.sent[8]).toMatchObject({ + id: 8, + method: "thread/turns/list", + params: { threadId: "thread-1", cursor: null, limit: 1, sortDirection: "asc", itemsView: "full" }, + }); + transport.emitLine({ id: 8, result: { data: [], nextCursor: null } }); await firstTurn; const rollback = client.rollbackThread("thread-1"); - expect(transport.sent[8]).toMatchObject({ - id: 8, + expect(transport.sent[9]).toMatchObject({ + id: 9, method: "thread/rollback", params: { threadId: "thread-1", numTurns: 1 }, }); - transport.emitLine({ id: 8, result: { thread: { id: "thread-1", turns: [] } } }); + transport.emitLine({ id: 9, result: { thread: { id: "thread-1", turns: [] } } }); await rollback; }); diff --git a/tests/display-model.test.ts b/tests/display-model.test.ts index 2a74a0f8..08b970e5 100644 --- a/tests/display-model.test.ts +++ b/tests/display-model.test.ts @@ -60,6 +60,22 @@ describe("display model", () => { expect(displayItemFromThreadItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world", markdown: true }); }); + it("keeps structured skill metadata out of displayed user text when a prompt body exists", () => { + const item: ThreadItem = { + type: "userMessage", + id: "u1", + content: [ + { type: "text", text: "Use $obsidian-codex-panel-maintain.", text_elements: [] }, + { type: "skill", name: "obsidian-codex-panel-maintain", path: "/skills/obsidian-codex-panel-maintain/SKILL.md" }, + ], + }; + + expect(displayItemFromThreadItem(item)).toMatchObject({ + text: "Use $obsidian-codex-panel-maintain.", + copyText: "Use $obsidian-codex-panel-maintain.", + }); + }); + it("preserves reasoning text", () => { const item: ThreadItem = { type: "reasoning", id: "r1", summary: ["summary"], content: ["detail"] }; expect(displayItemFromThreadItem(item)?.text).toBe("summary\n\ndetail"); diff --git a/tests/panel-controller.test.ts b/tests/panel-controller.test.ts index ffd10fd8..98762daf 100644 --- a/tests/panel-controller.test.ts +++ b/tests/panel-controller.test.ts @@ -13,6 +13,7 @@ function controllerForState( ): PanelController { return new PanelController(state, { refreshThreads: vi.fn(), + refreshSkills: vi.fn(), maybeNameThread: vi.fn(), respondToServerRequest: vi.fn(() => true), rejectServerRequest: vi.fn(() => true), @@ -125,6 +126,18 @@ describe("PanelController", () => { ]); }); + it("refreshes skills from disk when app-server reports skill changes", () => { + const refreshSkills = vi.fn(); + const controller = controllerForState(createPanelState(), { refreshSkills }); + + controller.handleNotification({ + method: "skills/changed", + params: {}, + } satisfies Extract); + + expect(refreshSkills).toHaveBeenCalledWith(true); + }); + it("stores the latest aggregated turn diff for the active turn", () => { const state = createPanelState(); state.activeThreadId = "thread-active"; diff --git a/tests/wikilink-context.test.ts b/tests/wikilink-context.test.ts index b543cb3b..deb61542 100644 --- a/tests/wikilink-context.test.ts +++ b/tests/wikilink-context.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { parsedWikiLinks, userInputWithWikiLinkMentions } from "../src/composer/wikilink-context"; +import { + parsedSkillReferences, + parsedWikiLinks, + userInputWithWikiLinkMentions, + userInputWithWikiLinkMentionsAndSkills, +} from "../src/composer/wikilink-context"; describe("wikilink context", () => { it("parses aliases, subpaths, and duplicate links", () => { @@ -34,4 +39,78 @@ describe("wikilink context", () => { { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, ]); }); + + it("parses complete skill references", () => { + expect(parsedSkillReferences("Use $obsidian-codex-panel-maintain and ($github:yeet), but not $missing.")).toEqual([ + "obsidian-codex-panel-maintain", + "github:yeet", + "missing", + ]); + }); + + it("adds resolved skill input without changing the visible prompt body", () => { + const text = "Please use $obsidian-codex-panel-maintain with [[Alpha]]."; + const input = userInputWithWikiLinkMentionsAndSkills( + text, + (target) => (target === "Alpha" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null), + [ + { + name: "obsidian-codex-panel-maintain", + description: "Maintain Codex Panel", + path: "/vault/___/skills/obsidian-codex-panel-maintain/SKILL.md", + scope: "repo", + enabled: true, + } as never, + ], + ); + + expect(input).toEqual([ + { type: "text", text, text_elements: [] }, + { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, + { + type: "skill", + name: "obsidian-codex-panel-maintain", + path: "/vault/___/skills/obsidian-codex-panel-maintain/SKILL.md", + }, + ]); + }); + + it("ignores unresolved skills and deduplicates resolved skills by path", () => { + const text = "Use $First, $missing, $first, and $Alias."; + const input = userInputWithWikiLinkMentionsAndSkills(text, () => null, [ + { + name: "First", + description: "First skill", + path: "/skills/first/SKILL.md", + scope: "user", + enabled: true, + } as never, + { + name: "first", + description: "Duplicate name should not win", + path: "/skills/duplicate/SKILL.md", + scope: "user", + enabled: true, + } as never, + { + name: "Alias", + description: "Same path alias", + path: "/skills/first/SKILL.md", + scope: "user", + enabled: true, + } as never, + { + name: "missing", + description: "Disabled skill", + path: "/skills/missing/SKILL.md", + scope: "user", + enabled: false, + } as never, + ]); + + expect(input).toEqual([ + { type: "text", text, text_elements: [] }, + { type: "skill", name: "First", path: "/skills/first/SKILL.md" }, + ]); + }); });