Support skill mentions in composer input

This commit is contained in:
murashit 2026-05-16 23:53:45 +09:00
parent d08a46becf
commit 79bc1b458b
11 changed files with 192 additions and 18 deletions

View file

@ -248,10 +248,10 @@ export class AppServerClient {
});
}
listSkills(cwd: string): Promise<SkillsListResponse> {
listSkills(cwd: string, forceReload = false): Promise<SkillsListResponse> {
return this.request("skills/list", {
cwds: [cwd],
forceReload: false,
forceReload,
});
}

View file

@ -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<string>();
@ -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<string>();
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<string>();
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<string, SkillMetadata> {
const byName = new Map<string, SkillMetadata>();
for (const skill of skills) {
if (!skill.enabled) continue;
const key = skill.name.toLowerCase();
if (!byName.has(key)) byName.set(key, skill);
}
return byName;
}

View file

@ -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 {

View file

@ -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") {

View file

@ -68,11 +68,11 @@ export class PanelSessionController {
}
}
async refreshSkills(): Promise<void> {
async refreshSkills(forceReload = false): Promise<void> {
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 = [];

View file

@ -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<void> {
this.client = this.connection.currentClient();
if (!this.client) return;
await this.session.refreshSkills(forceReload);
this.render();
}
private async resumeThread(threadId: string): Promise<void> {
if (this.state.busy && threadId !== this.state.activeThreadId) {
this.addSystemMessage("Finish or interrupt the current turn before switching threads.");

View file

@ -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)

View file

@ -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;
});

View file

@ -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");

View file

@ -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<ServerNotification, { method: "skills/changed" }>);
expect(refreshSkills).toHaveBeenCalledWith(true);
});
it("stores the latest aggregated turn diff for the active turn", () => {
const state = createPanelState();
state.activeThreadId = "thread-active";

View file

@ -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" },
]);
});
});