Send resolved wikilinks as additional context

This commit is contained in:
murashit 2026-06-16 11:01:21 +09:00
parent 71a888fe4d
commit a7cf377e1c
9 changed files with 138 additions and 10 deletions

View file

@ -45,7 +45,7 @@ import type { ServerNotification } from "../../generated/app-server/ServerNotifi
import type { ServerRequest } from "../../generated/app-server/ServerRequest";
import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue";
import type { CodexInput } from "../../domain/chat/input";
import { toAppServerUserInput } from "../protocol/request-input";
import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input";
import { appServerThreadGoalUpdate, type ThreadGoalUpdate } from "../protocol/thread-goal";
import { appServerRuntimeSettingsPatch, type RuntimeServiceTierRequest, type RuntimeSettingsPatch } from "../protocol/thread-settings";
@ -157,6 +157,11 @@ function toUserInput(input: string | CodexInput): UserInput[] {
return toAppServerUserInput([{ type: "text", text: input }]);
}
function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined {
if (typeof input === "string") return undefined;
return additionalContextFromCodexInput(input);
}
function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams {
const params: AppServerTurnRuntimeParams = {};
if (runtime?.serviceTier !== undefined) params.serviceTier = runtime.serviceTier;
@ -413,10 +418,12 @@ export class AppServerClient {
startTurn(options: AppServerStartTurnOptions): Promise<TurnStartResponse> {
const { threadId, cwd, input, clientUserMessageId, runtime } = options;
const additionalContext = toAdditionalContext(input);
const params: ClientRequestParams<"turn/start"> = {
threadId,
cwd,
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
...appServerTurnRuntimeParams(runtime),
input: toUserInput(input),
};
@ -447,11 +454,13 @@ export class AppServerClient {
input: string | CodexInput,
clientUserMessageId?: string | null,
): Promise<TurnSteerResponse> {
const additionalContext = toAdditionalContext(input);
return this.request("turn/steer", {
threadId,
expectedTurnId,
input: toUserInput(input),
...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}),
...(additionalContext !== undefined ? { additionalContext } : {}),
});
}

View file

@ -1,11 +1,23 @@
import type { AdditionalContextEntry } from "../../generated/app-server/v2/AdditionalContextEntry";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
import type { CodexInputItem } from "../../domain/chat/input";
export type { CodexInput, CodexInputItem } from "../../domain/chat/input";
export function toAppServerUserInput(input: readonly CodexInputItem[]): UserInput[] {
return input.map((item) => {
return input.flatMap((item) => {
if (item.type === "text") return { type: "text", text: item.text, text_elements: [] };
if (item.type === "additionalContext") return [];
return { ...item };
});
}
export function additionalContextFromCodexInput(input: readonly CodexInputItem[]): Record<string, AdditionalContextEntry> | undefined {
const additionalContext: Record<string, AdditionalContextEntry> = {};
for (const item of input) {
if (item.type !== "additionalContext") continue;
if (!item.key || !item.value) continue;
additionalContext[item.key] = { value: item.value, kind: item.kind };
}
return Object.keys(additionalContext).length > 0 ? additionalContext : undefined;
}

View file

@ -3,12 +3,19 @@ export interface RequestMention {
path: string;
}
export interface RequestAdditionalContext {
key: string;
value: string;
kind: "untrusted" | "application";
}
export type CodexInputItem =
| { type: "text"; text: string }
| { type: "image"; url: string; detail?: UserInputImageDetail }
| { type: "localImage"; path: string; detail?: UserInputImageDetail }
| { type: "skill"; name: string; path: string }
| { type: "mention"; name: string; path: string };
| { type: "mention"; name: string; path: string }
| { type: "additionalContext"; key: string; value: string; kind: RequestAdditionalContext["kind"] };
export type CodexInput = CodexInputItem[];
type UserInputImageDetail = "auto" | "low" | "high" | "original";
@ -17,11 +24,18 @@ export function codexTextInputWithMentions(
text: string,
mentions: readonly RequestMention[],
skills: readonly RequestMention[] = [],
additionalContext: readonly RequestAdditionalContext[] = [],
): CodexInput {
return [
...codexTextInput(text),
...mentions.map((mention) => ({ type: "mention" as const, name: mention.name, path: mention.path })),
...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })),
...additionalContext.map((context) => ({
type: "additionalContext" as const,
key: context.key,
value: context.value,
kind: context.kind,
})),
];
}

View file

@ -1,4 +1,4 @@
import { codexTextInputWithMentions, type RequestMention } from "../../../../domain/chat/input";
import { codexTextInputWithMentions, type RequestAdditionalContext, type RequestMention } from "../../../../domain/chat/input";
import type { SkillMetadata } from "../../../../domain/catalog/metadata";
import { parseObsidianWikiLink } from "../../../../shared/obsidian/wikilinks";
@ -11,6 +11,7 @@ export interface ParsedWikiLink {
export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null;
const WIKILINK_ADDITIONAL_CONTEXT_KEY = "codex_panel_wikilinks";
const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g;
const SKILL_REFERENCE_PATTERN = /(^|[\s([{])\$([^\s\])}.,;!?]{1,120})(?=$|[\s\])}.,;!?])/g;
@ -37,6 +38,7 @@ export function userInputWithWikiLinkMentionsAndSkills(
skills: readonly SkillMetadata[],
) {
const mentions: RequestMention[] = [];
const wikilinkMappings: string[] = [];
const seenPaths = new Set<string>();
for (const link of parsedWikiLinks(text)) {
@ -44,6 +46,7 @@ export function userInputWithWikiLinkMentionsAndSkills(
if (!mention || seenPaths.has(mention.path)) continue;
seenPaths.add(mention.path);
mentions.push(mention);
wikilinkMappings.push(`- [[${link.raw}]] -> ${mention.path}`);
}
const skillByName = firstEnabledSkillByName(skills);
@ -56,7 +59,18 @@ export function userInputWithWikiLinkMentionsAndSkills(
resolvedSkills.push({ name: skill.name, path: skill.path });
}
return codexTextInputWithMentions(text, mentions, resolvedSkills);
return codexTextInputWithMentions(text, mentions, resolvedSkills, wikilinkAdditionalContext(wikilinkMappings));
}
function wikilinkAdditionalContext(mappings: readonly string[]): RequestAdditionalContext[] {
if (mappings.length === 0) return [];
return [
{
key: WIKILINK_ADDITIONAL_CONTEXT_KEY,
kind: "untrusted",
value: ["Resolved Obsidian wikilinks for the current user input:", ...mappings].join("\n"),
},
];
}
function parsedSkillReferences(text: string): string[] {

View file

@ -428,7 +428,23 @@ describe("AppServerClient", () => {
const input = [
{ type: "text" as const, text: "Read [[Alpha]].", text_elements: [] },
{ type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" },
{
type: "additionalContext" as const,
key: "codex_panel_wikilinks",
kind: "untrusted" as const,
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md",
},
];
const serializedInput = [
{ type: "text" as const, text: "Read [[Alpha]].", text_elements: [] },
{ type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" },
];
const additionalContext = {
codex_panel_wikilinks: {
kind: "untrusted",
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md",
},
};
const startingTurn = client.startTurn({ threadId: "thread-1", cwd: "/vault", input });
expect(transport.sent[2]).toMatchObject({
@ -436,7 +452,8 @@ describe("AppServerClient", () => {
params: {
threadId: "thread-1",
cwd: "/vault",
input,
input: serializedInput,
additionalContext,
},
});
transport.emitLine({ id: 2, result: { turn: { id: "turn-1" } } });
@ -448,7 +465,8 @@ describe("AppServerClient", () => {
params: {
threadId: "thread-1",
expectedTurnId: "turn-1",
input,
input: serializedInput,
additionalContext,
},
});
transport.emitLine({ id: 3, result: { turnId: "turn-1" } });

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { codexTextInputWithAttachments, codexTextInputWithMentions, type CodexInput } from "../../src/domain/chat/input";
import { toAppServerUserInput } from "../../src/app-server/protocol/request-input";
import { additionalContextFromCodexInput, toAppServerUserInput } from "../../src/app-server/protocol/request-input";
describe("app-server request input", () => {
it("builds text input with mentions and skills", () => {
@ -10,11 +10,18 @@ describe("app-server request input", () => {
"Use [[Note]] and $Skill",
[{ name: "Note", path: "Note.md" }],
[{ name: "Skill", path: ".codex/skills/skill/SKILL.md" }],
[{ key: "codex_panel_wikilinks", kind: "untrusted", value: "Resolved Obsidian wikilinks:\n- [[Note]] -> Note.md" }],
),
).toEqual([
{ type: "text", text: "Use [[Note]] and $Skill" },
{ type: "mention", name: "Note", path: "Note.md" },
{ type: "skill", name: "Skill", path: ".codex/skills/skill/SKILL.md" },
{
type: "additionalContext",
key: "codex_panel_wikilinks",
kind: "untrusted",
value: "Resolved Obsidian wikilinks:\n- [[Note]] -> Note.md",
},
]);
});
@ -22,18 +29,30 @@ describe("app-server request input", () => {
const input: CodexInput = [
{ type: "text", text: "visible request" },
{ type: "mention", name: "Note", path: "Note.md" },
{ type: "additionalContext", key: "codex_panel_wikilinks", kind: "untrusted", value: "- [[Note]] -> Note.md" },
];
expect(codexTextInputWithAttachments("rewritten prompt", input)).toEqual([
{ type: "text", text: "rewritten prompt" },
{ type: "mention", name: "Note", path: "Note.md" },
{ type: "additionalContext", key: "codex_panel_wikilinks", kind: "untrusted", value: "- [[Note]] -> Note.md" },
]);
});
it("serializes text input for app-server requests", () => {
expect(toAppServerUserInput(codexTextInputWithMentions("Use [[Note]]", [{ name: "Note", path: "Note.md" }]))).toEqual([
const input = codexTextInputWithMentions(
"Use [[Note]]",
[{ name: "Note", path: "Note.md" }],
[],
[{ key: "codex_panel_wikilinks", kind: "untrusted", value: "- [[Note]] -> Note.md" }],
);
expect(toAppServerUserInput(input)).toEqual([
{ type: "text", text: "Use [[Note]]", text_elements: [] },
{ type: "mention", name: "Note", path: "Note.md" },
]);
expect(additionalContextFromCodexInput(input)).toEqual({
codex_panel_wikilinks: { kind: "untrusted", value: "- [[Note]] -> Note.md" },
});
});
});

View file

@ -181,6 +181,12 @@ describe("composer suggestions", () => {
expect(input).toEqual([
{ type: "text", text },
{ type: "mention", name: "Diagram", path: "Assets/Diagram.png" },
{
type: "additionalContext",
key: "codex_panel_wikilinks",
kind: "untrusted",
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Assets/Diagram.png]] -> Assets/Diagram.png",
},
]);
});

View file

@ -2,6 +2,13 @@ import { describe, expect, it } from "vitest";
import { userInputWithWikiLinkMentionsAndSkills } from "../../../../../src/features/chat/application/composer/wikilink-context";
const wikilinkContext = (...mappings: string[]) => ({
type: "additionalContext" as const,
key: "codex_panel_wikilinks",
kind: "untrusted" as const,
value: ["Resolved Obsidian wikilinks for the current user input:", ...mappings].join("\n"),
});
describe("wikilink context", () => {
it("parses aliases, subpaths, and duplicate links", () => {
const text = "See [[Alpha|label]], [[Beta#Heading]], [[Gamma^block]], and [[Alpha]].";
@ -12,6 +19,7 @@ describe("wikilink context", () => {
{ type: "mention", name: "Alpha", path: "Alpha.md" },
{ type: "mention", name: "Beta", path: "Beta.md" },
{ type: "mention", name: "Gamma", path: "Gamma.md" },
wikilinkContext("- [[Alpha|label]] -> Alpha.md", "- [[Beta#Heading]] -> Beta.md", "- [[Gamma^block]] -> Gamma.md"),
]);
});
@ -26,8 +34,9 @@ describe("wikilink context", () => {
expect(input).toEqual([
{ type: "text", text },
{ type: "mention", name: "Alpha", path: "thoughts/Alpha.md" },
wikilinkContext("- [[Alpha#Heading|A]] -> thoughts/Alpha.md"),
]);
expect(input).toHaveLength(2);
expect(input).toHaveLength(3);
});
it("resolves aliases and subpaths from non-markdown wikilinks by target", () => {
@ -49,6 +58,11 @@ describe("wikilink context", () => {
{ type: "mention", name: "Projects", path: "Bases/Projects.base" },
{ type: "mention", name: "Paper", path: "References/Paper.pdf" },
{ type: "mention", name: "Diagram", path: "Assets/Diagram.png" },
wikilinkContext(
"- [[Bases/Projects.base|Projects]] -> Bases/Projects.base",
"- [[References/Paper.pdf]] -> References/Paper.pdf",
"- [[Assets/Diagram.png#crop|Diagram]] -> Assets/Diagram.png",
),
]);
});
@ -63,6 +77,7 @@ describe("wikilink context", () => {
expect(input).toEqual([
{ type: "text", text },
{ type: "mention", name: "Alpha", path: "thoughts/Alpha.md" },
wikilinkContext("- [[Alpha]] -> thoughts/Alpha.md"),
]);
});
@ -90,6 +105,7 @@ describe("wikilink context", () => {
name: "obsidian-codex-panel-maintain",
path: "/vault/___/skills/obsidian-codex-panel-maintain/SKILL.md",
},
wikilinkContext("- [[Alpha]] -> thoughts/Alpha.md"),
]);
});

View file

@ -35,6 +35,26 @@ describe("optimistic turn start helpers", () => {
});
});
it("keeps additional context out of optimistic user message text", () => {
const text = "Read [[Note]].";
const input = [
{ type: "text" as const, text },
{ type: "mention" as const, name: "Note", path: "Note.md" },
{
type: "additionalContext" as const,
key: "codex_panel_wikilinks",
kind: "untrusted" as const,
value: "Resolved Obsidian wikilinks for the current user input:\n- [[Note]] -> Note.md",
},
];
expect(localUserMessageItemFromInput({ id: "local-user", text, codexInput: input })).toMatchObject({
text,
copyText: text,
mentionedFiles: [{ name: "Note", path: "Note.md" }],
});
});
it("formats resolved skill references in optimistic user messages only for display", () => {
const text = "Use $obsidian-codex-panel-maintain and $missing.";
const input = [