diff --git a/src/domain/markdown/code-ranges.ts b/src/domain/markdown/code-ranges.ts new file mode 100644 index 00000000..066076ed --- /dev/null +++ b/src/domain/markdown/code-ranges.ts @@ -0,0 +1,19 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; +import { visit } from "unist-util-visit"; + +export type MarkdownCodeRange = readonly [start: number, end: number]; + +export function markdownCodeRanges(markdown: string): MarkdownCodeRange[] { + const ranges: MarkdownCodeRange[] = []; + visit(fromMarkdown(markdown), (node) => { + if (node.type !== "code" && node.type !== "inlineCode") return; + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start !== undefined && end !== undefined) ranges.push([start, end]); + }); + return ranges; +} + +export function markdownCodeRangeContainsOffset(ranges: readonly MarkdownCodeRange[], offset: number): boolean { + return ranges.some(([start, end]) => offset >= start && offset < end); +} diff --git a/src/features/chat/application/composer/wikilink-context.ts b/src/features/chat/application/composer/wikilink-context.ts index 61c57774..960d0a4b 100644 --- a/src/features/chat/application/composer/wikilink-context.ts +++ b/src/features/chat/application/composer/wikilink-context.ts @@ -8,6 +8,7 @@ import { type SkillReference, type VaultFileReference, } from "../../../../domain/chat/input"; +import { type MarkdownCodeRange, markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../domain/markdown/code-ranges"; import { type ActiveNoteContextReference, type ComposerContextReferences, @@ -43,10 +44,11 @@ interface PreparedComposerInputOptions { referenceActiveNoteOnSend: boolean; } -function parsedWikiLinks(text: string): ParsedWikiLink[] { +function parsedWikiLinks(text: string, codeRanges: readonly MarkdownCodeRange[]): ParsedWikiLink[] { const links: ParsedWikiLink[] = []; const seen = new Set(); for (const match of text.matchAll(WIKILINK_PATTERN)) { + if (markdownCodeRangeContainsOffset(codeRanges, match.index)) continue; const rawValue = match[1]; if (rawValue === undefined) continue; const raw = rawValue.trim(); @@ -67,14 +69,15 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext( contextReferences: ComposerContextReferences, options: PreparedComposerInputOptions, ): PreparedComposerInput { - const contextReplacement = textWithContextReferences(text, contextReferences); + const codeRanges = markdownReferenceCodeRanges(text); + const contextReplacement = textWithContextReferences(text, contextReferences, codeRanges); const resolvedText = contextReplacement.text; const fileReferences: VaultFileReference[] = []; const wikilinkReferences: ObsidianReference[] = []; const seenPaths = new Set(); const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? []; - for (const link of parsedWikiLinks(resolvedText)) { + for (const link of parsedWikiLinks(resolvedText, codeRanges)) { const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target); if (!fileReference || seenPaths.has(fileReference.path)) continue; seenPaths.add(fileReference.path); @@ -94,7 +97,7 @@ export function preparedUserInputWithWikiLinkReferencesSkillsAndContext( const skillByName = firstEnabledSkillByName(skills); const resolvedSkills: SkillReference[] = []; const seenSkillPaths = new Set(); - for (const reference of parsedSkillReferences(resolvedText)) { + for (const reference of parsedSkillReferences(resolvedText, codeRanges)) { const skill = skillByName.get(reference.toLowerCase()); if (!skill || seenSkillPaths.has(skill.path)) continue; seenSkillPaths.add(skill.path); @@ -120,19 +123,24 @@ function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonl function textWithContextReferences( text: string, contextReferences: ComposerContextReferences, + codeRanges: readonly MarkdownCodeRange[], ): { text: string; selections: SelectionContextReference[] } { return { text, - selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? []), + selections: selectionsReferencedByText(text, contextReferences.selectionSnapshots ?? [], codeRanges), }; } -function selectionsReferencedByText(text: string, snapshots: readonly SelectionContextReference[]): SelectionContextReference[] { +function selectionsReferencedByText( + text: string, + snapshots: readonly SelectionContextReference[], + codeRanges: readonly MarkdownCodeRange[], +): SelectionContextReference[] { const selections: SelectionContextReference[] = []; const seen = new Set(); for (const snapshot of snapshots) { const marker = selectionContextReferenceMarker(snapshot); - if (!text.includes(marker) || seen.has(marker)) continue; + if (!includesOutsideMarkdownCode(text, marker, codeRanges) || seen.has(marker)) continue; seen.add(marker); selections.push(snapshot); } @@ -200,10 +208,12 @@ function excerptLines(references: readonly (ObsidianReference & { excerpt: strin }); } -function parsedSkillReferences(text: string): string[] { +function parsedSkillReferences(text: string, codeRanges: readonly MarkdownCodeRange[]): string[] { const references: string[] = []; const seen = new Set(); for (const match of text.matchAll(SKILL_REFERENCE_PATTERN)) { + const prefix = match[1] ?? ""; + if (markdownCodeRangeContainsOffset(codeRanges, match.index + prefix.length)) continue; const name = match[2]; if (name === undefined) continue; const key = name.toLowerCase(); @@ -214,6 +224,19 @@ function parsedSkillReferences(text: string): string[] { return references; } +function markdownReferenceCodeRanges(text: string): MarkdownCodeRange[] { + return text.includes("[[") || text.includes("$") ? markdownCodeRanges(text) : []; +} + +function includesOutsideMarkdownCode(text: string, value: string, codeRanges: readonly MarkdownCodeRange[]): boolean { + let index = text.indexOf(value); + while (index >= 0) { + if (!markdownCodeRangeContainsOffset(codeRanges, index)) return true; + index = text.indexOf(value, index + value.length); + } + return false; +} + function parseWikiLink(raw: string): ParsedWikiLink | null { const trimmed = raw.trim(); if (!trimmed) return null; diff --git a/src/features/chat/domain/thread-stream/format/user-message-text.ts b/src/features/chat/domain/thread-stream/format/user-message-text.ts index 2e2a66c3..2e8ff414 100644 --- a/src/features/chat/domain/thread-stream/format/user-message-text.ts +++ b/src/features/chat/domain/thread-stream/format/user-message-text.ts @@ -1,4 +1,5 @@ -type TextRange = [number, number]; +import { markdownCodeRangeContainsOffset, markdownCodeRanges } from "../../../../../domain/markdown/code-ranges"; + interface UserMessageDisplayInputItem { type: string; name?: string; @@ -12,7 +13,7 @@ export function userMessageDisplayText(text: string, input: readonly UserMessage const codeRanges = markdownCodeRanges(text); return text.replace(pattern, (match: string, prefix: string, name: string, offset: number) => { const dollarIndex = offset + prefix.length; - return isIndexInRanges(dollarIndex, codeRanges) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`; + return markdownCodeRangeContainsOffset(codeRanges, dollarIndex) ? match : `${prefix}${markdownCodeSpan(`$${name}`)}`; }); } @@ -36,64 +37,6 @@ function markdownCodeSpan(text: string): string { return `${delimiter} ${text} ${delimiter}`; } -function markdownCodeRanges(text: string): TextRange[] { - return [...markdownFenceRanges(text), ...markdownInlineCodeRanges(text)].sort((a, b) => a[0] - b[0]); -} - -function markdownFenceRanges(text: string): TextRange[] { - const ranges: TextRange[] = []; - let active: { marker: string; start: number } | null = null; - let offset = 0; - for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) { - const value = line[0]; - if (value.length === 0) break; - const fence = /^(?: {0,3})(`{3,}|~{3,})/.exec(value); - if (fence) { - const marker = fence[1]; - if (!marker) continue; - if (!active) { - active = { marker, start: offset }; - } else if (marker.startsWith(active.marker.charAt(0)) && marker.length >= active.marker.length) { - ranges.push([active.start, offset + value.length]); - active = null; - } - } - offset += value.length; - } - if (active) ranges.push([active.start, text.length]); - return ranges; -} - -function markdownInlineCodeRanges(text: string): TextRange[] { - const ranges: TextRange[] = []; - const fenceRanges = markdownFenceRanges(text); - let index = 0; - while (index < text.length) { - if (isIndexInRanges(index, fenceRanges) || text[index] !== "`") { - index += 1; - continue; - } - const match = /`+/.exec(text.slice(index)); - if (!match) { - index += 1; - continue; - } - const delimiter = match[0]; - const end = text.indexOf(delimiter, index + delimiter.length); - if (end < 0) { - index += delimiter.length; - continue; - } - ranges.push([index, end + delimiter.length]); - index = end + delimiter.length; - } - return ranges; -} - -function isIndexInRanges(index: number, ranges: readonly TextRange[]): boolean { - return ranges.some(([start, end]) => index >= start && index < end); -} - function escapeRegExp(value: string): string { return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); } diff --git a/tests/features/chat/app-server/mappers/thread-stream.test.ts b/tests/features/chat/app-server/mappers/thread-stream.test.ts index 4246bfae..454f7b4c 100644 --- a/tests/features/chat/app-server/mappers/thread-stream.test.ts +++ b/tests/features/chat/app-server/mappers/thread-stream.test.ts @@ -278,7 +278,19 @@ describe("turn item conversion preserves app-server semantics", () => { content: [ { type: "text", - text: "Use `$obsidian-codex-panel-maintain`.\n\n```\n$obsidian-codex-panel-maintain\n```\n\nThen $obsidian-codex-panel-maintain.", + text: [ + "Use `$obsidian-codex-panel-maintain`.", + "", + "```text", + "$obsidian-codex-panel-maintain", + "```not-a-closing-fence", + "$obsidian-codex-panel-maintain", + "```", + "", + " $obsidian-codex-panel-maintain", + "", + "Then $obsidian-codex-panel-maintain.", + ].join("\n"), text_elements: [], }, { type: "skill", name: "obsidian-codex-panel-maintain", path: "/skills/obsidian-codex-panel-maintain/SKILL.md" }, @@ -286,7 +298,19 @@ describe("turn item conversion preserves app-server semantics", () => { }; expect(threadStreamItemFromTurnItem(item)).toMatchObject({ - text: "Use `$obsidian-codex-panel-maintain`.\n\n```\n$obsidian-codex-panel-maintain\n```\n\nThen `$obsidian-codex-panel-maintain`.", + text: [ + "Use `$obsidian-codex-panel-maintain`.", + "", + "```text", + "$obsidian-codex-panel-maintain", + "```not-a-closing-fence", + "$obsidian-codex-panel-maintain", + "```", + "", + " $obsidian-codex-panel-maintain", + "", + "Then `$obsidian-codex-panel-maintain`.", + ].join("\n"), }); }); diff --git a/tests/features/chat/application/composer/wikilink-context.test.ts b/tests/features/chat/application/composer/wikilink-context.test.ts index fd3f8547..59d00dab 100644 --- a/tests/features/chat/application/composer/wikilink-context.test.ts +++ b/tests/features/chat/application/composer/wikilink-context.test.ts @@ -170,6 +170,79 @@ describe("wikilink context", () => { ]); }); + it("does not resolve wikilinks or skills written as Markdown code", () => { + const text = [ + "Use [[Visible]] and $Visible.", + "", + "`[[Inline]] $Inline`", + "", + "```text", + "[[Fenced]] $Fenced", + "```", + "", + " [[Indented]] $Indented", + ].join("\n"); + const skills = ["Visible", "Inline", "Fenced", "Indented"].map( + (name) => + ({ + name, + description: name, + path: `/skills/${name.toLowerCase()}/SKILL.md`, + scope: "repo", + enabled: true, + }) as never, + ); + + const input = userInputWithWikiLinkReferencesAndSkills(text, (target) => ({ name: target, path: `${target}.md` }), skills); + + expect(input).toEqual([ + { type: "text", text }, + { type: "fileReference", name: "Visible", path: "Visible.md" }, + { type: "skill", name: "Visible", path: "/skills/visible/SKILL.md" }, + wikilinkContext("- [[Visible]] -> Visible.md"), + ]); + }); + + it("does not attach selection snapshots referenced only inside Markdown code", () => { + const text = "```text\n[[Alpha]] (L42:C5-L47:C1)\n```"; + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( + text, + () => null, + [], + { + activeNote: null, + selection: null, + selectionSnapshots: [ + { + name: "Alpha", + path: "notes/Alpha.md", + linktext: "Alpha", + range: { from: { line: 41, ch: 4 }, to: { line: 46, ch: 0 } }, + text: "selected text", + }, + ], + }, + { referenceActiveNoteOnSend: false }, + ); + + expect(prepared.input).toEqual([{ type: "text", text }]); + }); + + it("keeps raw Obsidian wikilinks intact when CommonMark would split their contents", () => { + const text = "Read [[A *strange* note]]."; + const input = userInputWithWikiLinkReferencesAndSkills( + text, + (target) => (target === "A *strange* note" ? { name: target, path: "notes/Strange.md" } : null), + [], + ); + + expect(input).toEqual([ + { type: "text", text }, + { type: "fileReference", name: "A *strange* note", path: "notes/Strange.md" }, + wikilinkContext("- [[A *strange* note]] -> notes/Strange.md"), + ]); + }); + it("leaves bare context references as raw text", () => { const text = "整理して @active and @selection"; const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext(