mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Send resolved wikilinks as additional context
This commit is contained in:
parent
71a888fe4d
commit
a7cf377e1c
9 changed files with 138 additions and 10 deletions
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue