refactor(context): model Obsidian excerpts as references

This commit is contained in:
murashit 2026-07-18 10:52:25 +09:00
parent 4581ee4c5f
commit 9b2585b2bd
11 changed files with 177 additions and 59 deletions

View file

@ -14,6 +14,8 @@ The panel may acquire prompt context through an explicit user action. `/web` fet
Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context, while persisting only a small content-free descriptor needed to reconstruct panel display and archive metadata. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer.
Vault references must remain model-visible in that explicit context. App-server `mention` items with ordinary Vault paths are retained as panel display metadata, but Codex does not expose those arbitrary paths to the model as file context.
Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it.
## Sources of Truth

View file

@ -75,7 +75,9 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[
].join("\n"),
};
});
manifest.push(manifestEntry(item, id, partCount, sourceBytes, split.includedBytes));
if (item.attachment || split.includedBytes < sourceBytes) {
manifest.push(manifestEntry(item, id, partCount, sourceBytes, split.includedBytes));
}
}
return {
input: toAppServerUserInput(input, manifest),
@ -129,6 +131,9 @@ function manifestEntry(
includedBytes,
truncated: includedBytes < sourceBytes,
} as const;
if (item.attachment?.kind === "obsidian") {
return { ...common, kind: "obsidian", inlineExcerpts: item.attachment.inlineExcerpts };
}
if (item.attachment?.kind !== "referencedThread") return common;
return {
...common,

View file

@ -12,7 +12,7 @@ export type TurnContextAttachment =
truncated: boolean;
}
| { kind: "web" }
| { kind: "obsidian" };
| { kind: "obsidian"; inlineExcerpts: number };
export interface TurnContextManifestEntry {
kind: TurnContextAttachment["kind"];
@ -25,6 +25,7 @@ export interface TurnContextManifestEntry {
includedTurns?: number;
turnLimit?: number;
omittedTurns?: number;
inlineExcerpts?: number;
}
export interface TurnContextManifest {
@ -88,7 +89,15 @@ export function turnContextSubmissionId(value: string): string {
function manifestMatchesClientId(manifest: TurnContextManifest, clientId: string | null): boolean {
if (!clientId || !/^local-(?:user|steer)-\d+-[A-Za-z0-9_-]+-[a-z0-9]+-[a-z0-9]+$/.test(clientId)) return false;
const submissionId = turnContextSubmissionId(clientId);
return manifest.contexts.every((context, index) => context.id === `${submissionId}.${String(index).padStart(2, "0")}`);
const contextIds = manifest.contexts.map((context) => context.id);
return (
new Set(contextIds).size === contextIds.length &&
contextIds.every((contextId) => new RegExp(`^${escapeRegExp(submissionId)}\\.\\d{2}$`).test(contextId))
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function referencedThreadFromManifest(manifest: TurnContextManifest | null): ReferencedThreadMetadata | null {
@ -117,6 +126,19 @@ function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | nu
const truncated = value["truncated"];
if (!kind || !id || parts === null || sourceBytes === null || includedBytes === null || typeof truncated !== "boolean") return null;
if (kind === "obsidian") {
const inlineExcerpts = optionalNonNegativeInteger(value["inlineExcerpts"]);
if (inlineExcerpts === null) return null;
return {
kind,
id,
parts,
sourceBytes,
includedBytes,
truncated,
...(inlineExcerpts === undefined ? {} : { inlineExcerpts }),
};
}
if (kind !== "referencedThread") return { kind, id, parts, sourceBytes, includedBytes, truncated };
const threadId = stringValue(value["threadId"]);
const includedTurns = nonNegativeInteger(value["includedTurns"]);
@ -149,6 +171,10 @@ function nonNegativeInteger(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
}
function optionalNonNegativeInteger(value: unknown): number | null | undefined {
return value === undefined ? undefined : nonNegativeInteger(value);
}
function positiveInteger(value: unknown): number | null {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
}

View file

@ -10,7 +10,6 @@ import {
import {
type ActiveNoteContextReference,
type ComposerContextReferences,
formatComposerContextRange,
type SelectionContextReference,
selectionContextReferenceMarker,
} from "./context-references";
@ -22,6 +21,12 @@ interface ParsedWikiLink {
display: string;
}
interface ObsidianReference {
marker: string;
path: string;
excerpt?: string;
}
export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null;
const OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY = "codex_panel_obsidian_context";
@ -64,7 +69,7 @@ export function preparedUserInputWithWikiLinkMentionsSkillsAndContext(
const contextReplacement = textWithContextReferences(text, contextReferences);
const resolvedText = contextReplacement.text;
const mentions: RequestMention[] = [];
const wikilinkMappings: string[] = [];
const wikilinkReferences: ObsidianReference[] = [];
const seenPaths = new Set<string>();
const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? [];
@ -73,7 +78,7 @@ export function preparedUserInputWithWikiLinkMentionsSkillsAndContext(
if (!mention || seenPaths.has(mention.path)) continue;
seenPaths.add(mention.path);
mentions.push(mention);
wikilinkMappings.push(`- [[${link.raw}]] -> ${mention.path}`);
wikilinkReferences.push({ marker: `[[${link.raw}]]`, path: mention.path });
}
for (const selection of contextReplacement.selections) {
@ -101,7 +106,7 @@ export function preparedUserInputWithWikiLinkMentionsSkillsAndContext(
resolvedText,
mentions,
resolvedSkills,
additionalContext(wikilinkMappings, contextReplacement.selections, attachedActiveNote),
additionalContext(wikilinkReferences, contextReplacement.selections, attachedActiveNote),
),
};
}
@ -134,63 +139,68 @@ function selectionsReferencedByText(text: string, snapshots: readonly SelectionC
}
function additionalContext(
wikilinkMappings: readonly string[],
wikilinkReferences: readonly ObsidianReference[],
selections: readonly SelectionContextReference[],
activeNote: ActiveNoteContextReference | null,
): RequestAdditionalContext[] {
return obsidianContextAdditionalContext(wikilinkMappings, selections, activeNote);
return obsidianContextAdditionalContext(obsidianReferences(wikilinkReferences, selections, activeNote));
}
function obsidianContextAdditionalContext(
wikilinkMappings: readonly string[],
function obsidianReferences(
wikilinkReferences: readonly ObsidianReference[],
selections: readonly SelectionContextReference[],
activeNote: ActiveNoteContextReference | null,
): RequestAdditionalContext[] {
if (wikilinkMappings.length === 0 && selections.length === 0 && !activeNote) return [];
): ObsidianReference[] {
const selectionReferences = selections.map((selection) => ({
marker: selectionContextReferenceMarker(selection),
path: selection.path,
excerpt: selection.text,
}));
const selectedWikilinks = new Set(selections.map((selection) => referenceKey(`[[${selection.linktext}]]`, selection.path)));
return [
...wikilinkReferences.filter((reference) => !selectedWikilinks.has(referenceKey(reference.marker, reference.path))),
...selectionReferences,
...(activeNote ? [{ marker: ACTIVE_FILE_MENTION_NAME, path: activeNote.path }] : []),
];
}
function referenceKey(marker: string, path: string): string {
return `${marker}\u0000${path}`;
}
function obsidianContextAdditionalContext(references: readonly ObsidianReference[]): RequestAdditionalContext[] {
if (references.length === 0) return [];
const inlineExcerpts = references.filter((reference) => reference.excerpt !== undefined).length;
return [
{
key: OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY,
kind: "untrusted",
value: obsidianContextValue(wikilinkMappings, selections, activeNote),
value: obsidianContextValue(references),
...(inlineExcerpts > 0 ? { attachment: { kind: "obsidian" as const, inlineExcerpts } } : {}),
},
];
}
function obsidianContextValue(
wikilinkMappings: readonly string[],
selections: readonly SelectionContextReference[],
activeNote: ActiveNoteContextReference | null,
): string {
const sections: string[][] = [];
if (wikilinkMappings.length > 0) sections.push(["Resolved wikilinks:", ...wikilinkMappings]);
if (selections.length > 0) {
sections.push(["Referenced selections:", ...selectionReferenceLines(selections), "", ...selectionBodyLines(selections)]);
}
if (activeNote) sections.push(activeNoteContextLines(activeNote));
function obsidianContextValue(references: readonly ObsidianReference[]): string {
const excerpts = references.filter((reference): reference is ObsidianReference & { excerpt: string } => reference.excerpt !== undefined);
return [
"Obsidian context for the current user input:",
...sections.flatMap((section, index) => (index === 0 ? section : ["", ...section])),
"Obsidian references for the current user input:",
...references.map(referenceLine),
...(excerpts.length > 0 ? ["", "Inline excerpts:", ...excerptLines(excerpts)] : []),
].join("\n");
}
function selectionReferenceLines(selections: readonly SelectionContextReference[]): string[] {
return selections.map((selection) => {
const location = `${selection.path} ${formatComposerContextRange(selection.range)}`;
return `- ${selectionContextReferenceMarker(selection)} -> ${location}`;
});
function referenceLine(reference: ObsidianReference): string {
return `- ${reference.marker} -> ${reference.path}${reference.excerpt === undefined ? "" : " (inline excerpt below)"}`;
}
function selectionBodyLines(selections: readonly SelectionContextReference[]): string[] {
return selections.flatMap((selection, index) => {
function excerptLines(references: readonly (ObsidianReference & { excerpt: string })[]): string[] {
return references.flatMap((reference, index) => {
const prefix = index === 0 ? [] : [""];
return [...prefix, `${selectionContextReferenceMarker(selection)}:`, selection.text];
return [...prefix, `${reference.marker}:`, reference.excerpt];
});
}
function activeNoteContextLines(activeNote: ActiveNoteContextReference): string[] {
return ["Referenced active file:", `- ${ACTIVE_FILE_MENTION_NAME} -> ${activeNote.path}`];
}
function parsedSkillReferences(text: string): string[] {
const references: string[] = [];
const seen = new Set<string>();

View file

@ -19,8 +19,9 @@ export function contextAttachmentsFromManifest(manifest: TurnContextManifest | n
const source = visibleWebSource(visibleText);
attachments.push({ label: web.truncated ? "Web page (truncated)" : "Web page", ...(source ? { detail: source } : {}) });
}
if (manifest?.contexts.some((context) => context.kind === "obsidian" && context.truncated)) {
attachments.push({ label: "Obsidian context (truncated)" });
const obsidian = manifest?.contexts.find((context) => context.kind === "obsidian" && context.truncated);
if (obsidian) {
attachments.push({ label: obsidian.inlineExcerpts ? "Obsidian excerpt (truncated)" : "Obsidian context (truncated)" });
}
return attachments;
}

View file

@ -109,6 +109,37 @@ describe("app-server request input", () => {
expect(manifestInput?.type === "text" ? manifestInput.text : "").not.toContain("本文です");
});
it("keeps Obsidian reference metadata ahead of a truncated inline excerpt", () => {
const reference = "[[Note]] (L2:C1-L3:C1) -> Note.md";
const value = [
"Obsidian references for the current user input:",
`- ${reference} (inline excerpt below)`,
"",
"Inline excerpts:",
"[[Note]] (L2:C1-L3:C1):",
"本文".repeat(20_000),
].join("\n");
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "review it" },
{
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value,
attachment: { kind: "obsidian", inlineExcerpts: 1 },
},
],
"local-user",
);
const firstPart = Object.values(prepared.additionalContext ?? {})[0];
expect(firstPart?.value).toContain(reference);
const manifestInput = prepared.input.at(-1);
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text) : null;
expect(manifest?.contexts).toEqual([expect.objectContaining({ kind: "obsidian", inlineExcerpts: 1, truncated: true })]);
});
it("namespaces identical explicit context by submission", () => {
const input: CodexInput = [
{ type: "text", text: "read it" },
@ -134,6 +165,6 @@ describe("app-server request input", () => {
expect(entries.some(([key, entry]) => key.includes(".selection.") && entry.value.includes("selected text"))).toBe(true);
const manifestInput = prepared.input.at(-1);
const manifest = manifestInput?.type === "text" ? turnContextManifestFromText(manifestInput.text) : null;
expect(manifest?.contexts.map((context) => context.parts)).toEqual([7, 1]);
expect(manifest?.contexts.map((context) => context.parts)).toEqual([7]);
});
});

View file

@ -176,6 +176,41 @@ describe("turn item conversion preserves app-server semantics", () => {
});
});
it("accepts a persisted attachment descriptor after an unpersisted reference context", () => {
const clientId = "local-user-1-seed-1-1";
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "https://example.com/ compare with [[Note]]" },
{
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value: "- [[Note]] -> Note.md",
},
{
type: "additionalContext",
key: "codex_panel_web_context",
kind: "untrusted",
value: "Source: https://example.com/\n\npage",
attachment: { kind: "web" },
},
],
clientId,
);
expect(
threadStreamItemFromTurnItem({
type: "userMessage",
id: "u1",
clientId,
content: prepared.input,
}),
).toMatchObject({
text: "https://example.com/ compare with [[Note]]",
contextAttachments: [{ label: "Web page", detail: "https://example.com/" }],
});
});
it("does not hide a user-authored manifest-like message", () => {
const text =
'[Codex Panel context v2]{"version":2,"contexts":[{"kind":"web","id":"fake","parts":1,"sourceBytes":1,"includedBytes":1,"truncated":false}]}';
@ -205,12 +240,18 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(projected).not.toHaveProperty("contextAttachments");
});
it("surfaces truncated Obsidian context from the persisted manifest", () => {
it("surfaces a truncated Obsidian excerpt from the persisted manifest", () => {
const clientId = "local-user-1-seed-1-1";
const prepared = appServerTurnInputFromCodexInput(
[
{ type: "text", text: "review the selection" },
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "x".repeat(30_000) },
{
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value: "x".repeat(30_000),
attachment: { kind: "obsidian", inlineExcerpts: 1 },
},
],
clientId,
);
@ -224,7 +265,7 @@ describe("turn item conversion preserves app-server semantics", () => {
}),
).toMatchObject({
text: "review the selection",
contextAttachments: [{ label: "Obsidian context (truncated)" }],
contextAttachments: [{ label: "Obsidian excerpt (truncated)" }],
});
});

View file

@ -134,7 +134,6 @@ describe("chat app-server transports", () => {
{ type: "text", text, text_elements: [] },
{ type: "mention", name: "Alpha", path: "notes/Alpha.md" },
{ type: "mention", name: "<active>", path: "notes/Alpha.md" },
{ type: "text", text: expect.stringContaining("[Codex Panel context v2]"), text_elements: [] },
],
clientUserMessageId: "local-user",
});
@ -142,7 +141,7 @@ describe("chat app-server transports", () => {
"codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": {
kind: "untrusted",
value:
"Codex Panel context part 1/1.\nSource: codex_panel_obsidian_context\n\nObsidian context for the current user input:\nResolved wikilinks:\n- [[Alpha]] -> notes/Alpha.md\n\nReferenced active file:\n- <active> -> notes/Alpha.md",
"Codex Panel context part 1/1.\nSource: codex_panel_obsidian_context\n\nObsidian references for the current user input:\n- [[Alpha]] -> notes/Alpha.md\n- <active> -> notes/Alpha.md",
},
});
});

View file

@ -199,7 +199,7 @@ describe("composer suggestions", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value: "Obsidian context for the current user input:\nResolved wikilinks:\n- [[Assets/Diagram.png]] -> Assets/Diagram.png",
value: "Obsidian references for the current user input:\n- [[Assets/Diagram.png]] -> Assets/Diagram.png",
},
]);
});

View file

@ -12,10 +12,10 @@ const obsidianContext = (...sections: string[]) => ({
type: "additionalContext" as const,
key: "codex_panel_obsidian_context",
kind: "untrusted" as const,
value: ["Obsidian context for the current user input:", ...sections].join("\n"),
value: ["Obsidian references for the current user input:", ...sections].join("\n"),
});
const wikilinkContext = (...mappings: string[]) => obsidianContext("Resolved wikilinks:", ...mappings);
const wikilinkContext = (...mappings: string[]) => obsidianContext(...mappings);
function userInputWithWikiLinkMentionsAndSkills(
text: string,
@ -226,7 +226,7 @@ describe("wikilink context", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value: "Obsidian context for the current user input:\nReferenced active file:\n- <active> -> notes/Alpha.md",
value: "Obsidian references for the current user input:\n- <active> -> notes/Alpha.md",
},
]);
});
@ -253,8 +253,7 @@ describe("wikilink context", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value:
"Obsidian context for the current user input:\nResolved wikilinks:\n- [[Beta]] -> notes/Beta.md\n\nReferenced active file:\n- <active> -> notes/Alpha.md",
value: "Obsidian references for the current user input:\n- [[Beta]] -> notes/Beta.md\n- <active> -> notes/Alpha.md",
},
]);
});
@ -294,8 +293,9 @@ describe("wikilink context", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
attachment: { kind: "obsidian", inlineExcerpts: 1 },
value:
"Obsidian context for the current user input:\nResolved wikilinks:\n- [[Beta]] -> notes/Beta.md\n- [[Gamma]] -> notes/Gamma.md\n\nReferenced selections:\n- [[Gamma]] (L2:C1-L2:C6) -> notes/Gamma.md L2:C1-L2:C6\n\n[[Gamma]] (L2:C1-L2:C6):\nselected gamma\n\nReferenced active file:\n- <active> -> notes/Alpha.md",
"Obsidian references for the current user input:\n- [[Beta]] -> notes/Beta.md\n- [[Gamma]] (L2:C1-L2:C6) -> notes/Gamma.md (inline excerpt below)\n- <active> -> notes/Alpha.md\n\nInline excerpts:\n[[Gamma]] (L2:C1-L2:C6):\nselected gamma",
},
]);
});
@ -331,8 +331,9 @@ describe("wikilink context", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
attachment: { kind: "obsidian", inlineExcerpts: 1 },
value:
"Obsidian context for the current user input:\nResolved wikilinks:\n- [[notes/Alpha]] -> notes/Alpha.md\n\nReferenced selections:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md L42:C5-L47:C1\n\n[[notes/Alpha]] (L42:C5-L47:C1):\ncompleted selection",
"Obsidian references for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md (inline excerpt below)\n\nInline excerpts:\n[[notes/Alpha]] (L42:C5-L47:C1):\ncompleted selection",
});
});
});

View file

@ -1148,7 +1148,7 @@ describe("ChatComposerController", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value: "Obsidian context for the current user input:\nReferenced active file:\n- <active> -> notes/Alpha.md",
value: "Obsidian references for the current user input:\n- <active> -> notes/Alpha.md",
},
]);
});
@ -1220,8 +1220,9 @@ describe("ChatComposerController", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
attachment: { kind: "obsidian", inlineExcerpts: 1 },
value:
"Obsidian context for the current user input:\nResolved wikilinks:\n- [[notes/Alpha]] -> notes/Alpha.md\n\nReferenced selections:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md L42:C5-L47:C1\n\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection",
"Obsidian references for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md (inline excerpt below)\n\nInline excerpts:\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection",
});
controller.setDraft("", { clearSuggestions: true });
@ -1229,14 +1230,15 @@ describe("ChatComposerController", () => {
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
attachment: { kind: "obsidian", inlineExcerpts: 1 },
value:
"Obsidian context for the current user input:\nResolved wikilinks:\n- [[notes/Alpha]] -> notes/Alpha.md\n\nReferenced selections:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md L42:C5-L47:C1\n\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection",
"Obsidian references for the current user input:\n- [[notes/Alpha]] (L42:C5-L47:C1) -> notes/Alpha.md (inline excerpt below)\n\nInline excerpts:\n[[notes/Alpha]] (L42:C5-L47:C1):\ninitial selection",
});
expect(controller.preparedInput(completedSelectionReference).input).toContainEqual({
type: "additionalContext",
key: "codex_panel_obsidian_context",
kind: "untrusted",
value: "Obsidian context for the current user input:\nResolved wikilinks:\n- [[notes/Alpha]] -> notes/Alpha.md",
value: "Obsidian references for the current user input:\n- [[notes/Alpha]] -> notes/Alpha.md",
});
});