logancyang_obsidian-copilot/src/contextProcessor.ts
Logan Yang 8cea1a6f17
Audit context envelope, tag alignment, artifact dedup, and logging (#2164)
* fix: align context block tags and make parseContextIntoSegments registry-driven

The context envelope L2 layer was silently dropping URL/media context across
turns due to three compounding issues:

1. Tag mismatch: Mention.ts created <youtube_transcript> blocks but the
   contextBlockRegistry expected <youtube_video_context>. Fixed the tag and
   inner element (<transcript> → <content>) to match the registry.

2. Missing registration: twitter_content blocks had no registry entry, so
   they were invisible to compaction and segment parsing. Added the entry.

3. Hardcoded parser: parseContextIntoSegments only matched <note_context>,
   <active_note>, and <prior_context> via hardcoded regexes, silently
   ignoring all URL/media block types. Rewrote it to dynamically build its
   regex from CONTEXT_BLOCK_TYPES, ensuring any future block type added to
   the registry is automatically handled.

Extracted parseContextIntoSegments into src/context/parseContextSegments.ts
as a standalone pure function for direct testability.

Added 19 new tests covering all registered block types, mixed blocks,
stable/unstable flags, and a registry completeness guard test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: context envelope improvements - regeneration, dedup, per-artifact IDs, XML escaping

Phase A: Fix regeneration from loaded chats by lazily reprocessing context
when contextEnvelope is missing (e.g., messages loaded from disk).

Phase B: Replace static segment IDs ("urls", "selected_text", "web_tabs")
with per-artifact IDs from parseContextIntoSegments, enabling accurate
smart referencing in LayerToMessagesConverter.

Phase C: Deduplicate L2 content by segment ID (last-write-wins) to prevent
linear growth when the same artifact appears across multiple turns.

Phase D: Escape note title/basename in XML context blocks for consistency
with URL/YouTube content. Keep path unescaped as it's used as identifier.

Additional fixes from Codex review:
- Unique IDs for blocks without source extractors (selected_text counter)
- Keep note.path raw to avoid dedup mismatch with TFile.path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent context artifact duplication in LLM payload and L4 memory

CopilotPlusChainRunner was re-injecting processedText (which includes
context XML artifacts) into the user message via ensureUserQueryLabel,
bypassing the envelope's clean L2/L3/L5 separation. This caused context
like YouTube transcripts to appear 3x in the LLM payload.

Fix: use L5_USER envelope text (expanded user query without context XML)
instead of processedText for cleanedUserMessage, trimmedQuestion fallback,
and messageForAnalysis. Also fix BaseChainRunner.handleResponse to save
L5 text to L4 memory instead of processedText.

Other changes:
- Remove dead updateMemoryWithLoadedMessages from chainManager
- Update CONTEXT_ENGINEERING.md with example walkthrough, L4 behavior
  docs, and Phase 6 integration test suite roadmap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add chain runner envelope usage section and fix Step 0 log

Document per-runner envelope behavior, CopilotPlus/Agent tool flows,
and token efficiency audit in CONTEXT_ENGINEERING.md. Fix Step 0 log
to show L5 user query instead of processedText with context XML.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: trim trailing whitespace from chat input display text

Trailing newlines from Lexical editor were preserved in displayText
and rendered as empty lines in chat bubbles due to whitespace-pre-wrap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 21:18:25 -08:00

1070 lines
36 KiB
TypeScript

import { getSelectedTextContexts } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { RESTRICTION_MESSAGES } from "@/constants";
import { logWarn, logInfo, logError } from "@/logger";
import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import { WebViewerTimeoutError } from "@/services/webViewerService/webViewerServiceTypes";
import { FileParserManager } from "@/tools/FileParserManager";
import { isPlusChain } from "@/utils";
import { normalizeUrlString } from "@/utils/urlNormalization";
import { TFile, Vault, Notice } from "obsidian";
import {
NOTE_CONTEXT_PROMPT_TAG,
EMBEDDED_PDF_TAG,
EMBEDDED_NOTE_TAG,
SELECTED_TEXT_TAG,
WEB_SELECTED_TEXT_TAG,
DATAVIEW_BLOCK_TAG,
WEB_TAB_CONTEXT_TAG,
ACTIVE_WEB_TAB_CONTEXT_TAG,
YOUTUBE_VIDEO_CONTEXT_TAG,
} from "./constants";
interface EmbeddedLinkTarget {
path: string | null;
heading?: string;
blockId?: string;
}
interface MarkdownSegment {
content: string;
found: boolean;
}
export class ContextProcessor {
private static instance: ContextProcessor;
private constructor() {}
static getInstance(): ContextProcessor {
if (!ContextProcessor.instance) {
ContextProcessor.instance = new ContextProcessor();
}
return ContextProcessor.instance;
}
async processEmbeddedPDFs(
content: string,
vault: Vault,
fileParserManager: FileParserManager
): Promise<string> {
const pdfRegex = /!\[\[(.*?\.pdf)\]\]/g;
const matches = [...content.matchAll(pdfRegex)];
for (const match of matches) {
const pdfName = match[1];
const pdfFile = vault.getAbstractFileByPath(pdfName);
if (pdfFile instanceof TFile) {
try {
const pdfContent = await fileParserManager.parseFile(pdfFile, vault);
content = content.replace(
match[0],
`\n\n<${EMBEDDED_PDF_TAG}>\n<name>${pdfName}</name>\n<content>\n${pdfContent}\n</content>\n</${EMBEDDED_PDF_TAG}>\n\n`
);
} catch (error) {
logError(`Error processing embedded PDF ${pdfName}:`, error);
content = content.replace(
match[0],
`\n\n<${EMBEDDED_PDF_TAG}>\n<name>${pdfName}</name>\n<error>Could not process PDF</error>\n</${EMBEDDED_PDF_TAG}>\n\n`
);
}
}
}
return content;
}
/**
* Process Dataview blocks in content, executing queries and replacing them with structured results
*/
async processDataviewBlocks(content: string, sourcePath: string): Promise<string> {
// Check if Dataview plugin is available
const dataviewPlugin = (app as any).plugins?.plugins?.dataview;
if (!dataviewPlugin) {
return content; // Dataview not installed, return content as-is
}
const dataviewApi = dataviewPlugin.api;
if (!dataviewApi) {
return content; // API not available
}
// Match dataview and dataviewjs code blocks
// Fixed regex: \s* handles trailing spaces and different line endings
const blockRegex = /```(dataview|dataviewjs)\s*\n([\s\S]*?)```/g;
const matches = [...content.matchAll(blockRegex)];
// Process matches in reverse order to avoid position shifts when replacing
// This also handles multiple identical blocks correctly
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const queryType = match[1]; // 'dataview' or 'dataviewjs'
const query = match[2].trim();
const matchStart = match.index!;
const matchEnd = matchStart + match[0].length;
try {
// Execute query with timeout
const result = await Promise.race([
this.executeDataviewQuery(dataviewApi, query, queryType, sourcePath),
new Promise((_, reject) => setTimeout(() => reject(new Error("Query timeout")), 5000)),
]);
// Replace block with structured output using slice (position-based, handles duplicates)
const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n<query_type>${queryType}</query_type>\n<original_query>\n${query}\n</original_query>\n<executed_result>\n${result}\n</executed_result>\n</${DATAVIEW_BLOCK_TAG}>\n\n`;
content = content.slice(0, matchStart) + replacement + content.slice(matchEnd);
} catch (error) {
logError(`Error executing Dataview query:`, error);
// On error, include query with error message
const replacement = `\n\n<${DATAVIEW_BLOCK_TAG}>\n<query_type>${queryType}</query_type>\n<original_query>\n${query}\n</original_query>\n<error>${error instanceof Error ? error.message : "Query execution failed"}</error>\n</${DATAVIEW_BLOCK_TAG}>\n\n`;
content = content.slice(0, matchStart) + replacement + content.slice(matchEnd);
}
}
return content;
}
/**
* Execute a Dataview query and format the results
*/
private async executeDataviewQuery(
dataviewApi: any,
query: string,
queryType: string,
sourcePath: string
): Promise<string> {
if (queryType === "dataviewjs") {
// DataviewJS requires more complex handling - for now, return a message
return "[DataviewJS execution not yet supported - showing original query]";
}
// Parse and execute DQL query
const result = await dataviewApi.query(query, sourcePath);
if (!result.successful) {
throw new Error(result.error || "Query failed");
}
// Format results based on type
return this.formatDataviewResult(result.value);
}
/**
* Format Dataview query results into readable text
*/
private formatDataviewResult(result: any): string {
if (!result) {
return "No results";
}
// Handle different result types
if (result.type === "list") {
return this.formatDataviewList(result.values);
} else if (result.type === "table") {
return this.formatDataviewTable(result.headers, result.values);
} else if (result.type === "task") {
return this.formatDataviewTasks(result.values);
} else if (Array.isArray(result)) {
return result.map((item) => this.formatDataviewValue(item)).join("\n");
}
return String(result);
}
/**
* Format Dataview list results
*/
private formatDataviewList(values: any[]): string {
if (!values || values.length === 0) {
return "No results";
}
return values.map((item) => `- ${this.formatDataviewValue(item)}`).join("\n");
}
/**
* Format Dataview table results
*/
private formatDataviewTable(headers: string[], rows: any[][]): string {
if (!rows || rows.length === 0) {
return "No results";
}
// Create markdown table
let table = `| ${headers.join(" | ")} |\n`;
table += `| ${headers.map(() => "---").join(" | ")} |\n`;
for (const row of rows) {
table += `| ${row.map((cell) => this.formatDataviewValue(cell)).join(" | ")} |\n`;
}
return table;
}
/**
* Format Dataview task results
*/
private formatDataviewTasks(tasks: any[]): string {
if (!tasks || tasks.length === 0) {
return "No results";
}
return tasks
.map((task) => {
const checkbox = task.completed ? "[x]" : "[ ]";
return `- ${checkbox} ${this.formatDataviewValue(task.text || task)}`;
})
.join("\n");
}
/**
* Format individual Dataview values
*/
private formatDataviewValue(value: any): string {
if (value === null || value === undefined) {
return "";
}
// Handle links
if (value && typeof value === "object" && value.path) {
return `[[${value.path}]]`;
}
// Handle arrays
if (Array.isArray(value)) {
return value.map((v) => this.formatDataviewValue(v)).join(", ");
}
return String(value);
}
/**
* Build markdown content for context inclusion by resolving embeds, PDFs, and Dataview blocks.
*/
private async buildMarkdownContextContent(
note: TFile,
vault: Vault,
fileParserManager: FileParserManager,
chainType: ChainType
): Promise<string> {
let content = await fileParserManager.parseFile(note, vault);
content = await this.processEmbeddedNotes(content, note, vault, fileParserManager, chainType);
if (isPlusChain(chainType)) {
content = await this.processEmbeddedPDFs(content, vault, fileParserManager);
}
return await this.processDataviewBlocks(content, note.path);
}
/**
* Replace embedded note syntax within markdown content.
*
* Scans the content for all `![[...]]` embed patterns and expands them once
* into structured `<embedded_note>` blocks. Nested embeds are left as-is to
* keep processing predictable and lightweight.
*
* @param content - The markdown content to process
* @param sourceNote - The note containing this content (for relative link resolution)
* @param vault - Obsidian vault instance
* @param fileParserManager - Manager for parsing different file types
* @param chainType - Current chain type (affects feature availability)
* @returns Content with top-level embeds replaced by structured blocks
*/
private async processEmbeddedNotes(
content: string,
sourceNote: TFile,
vault: Vault,
fileParserManager: FileParserManager,
chainType: ChainType
): Promise<string> {
const embedRegex = /!\[\[([^\]]+)\]\]/g;
let match: RegExpExecArray | null;
let lastIndex = 0;
let result = "";
while ((match = embedRegex.exec(content)) !== null) {
result += content.slice(lastIndex, match.index);
const rawTarget = match[1].trim();
const replacement = await this.buildEmbeddedNoteBlock(
rawTarget,
match[0],
sourceNote,
vault,
fileParserManager,
chainType
);
result += replacement;
lastIndex = match.index + match[0].length;
}
result += content.slice(lastIndex);
return result;
}
/**
* Build a rendered embedded note block for the given target.
*/
private async buildEmbeddedNoteBlock(
rawTarget: string,
rawMatch: string,
sourceNote: TFile,
vault: Vault,
fileParserManager: FileParserManager,
chainType: ChainType
): Promise<string> {
const target = this.parseEmbeddedLinkTarget(rawTarget);
if (!target) {
return rawMatch;
}
const resolvedFile =
target.path === null
? sourceNote
: app.metadataCache.getFirstLinkpathDest(target.path, sourceNote.path);
if (!(resolvedFile instanceof TFile)) {
return this.formatEmbeddedNoteBlock({
title: target.path ?? sourceNote.basename,
path: target.path ?? sourceNote.path,
heading: target.heading,
blockId: target.blockId,
error: "Embedded note not found",
});
}
if (resolvedFile.extension !== "md") {
return rawMatch;
}
try {
let embeddedContent = await fileParserManager.parseFile(resolvedFile, vault);
if (target.heading || target.blockId) {
const segment = this.extractMarkdownSegment(resolvedFile, embeddedContent, target);
if (!segment.found) {
const targetDescription = target.blockId
? `block reference "${target.blockId}"`
: `heading "${target.heading ?? ""}"`;
throw new Error(`Embedded note ${targetDescription} not found in ${resolvedFile.path}`);
}
embeddedContent = segment.content;
}
if (isPlusChain(chainType)) {
embeddedContent = await this.processEmbeddedPDFs(embeddedContent, vault, fileParserManager);
}
embeddedContent = await this.processDataviewBlocks(embeddedContent, resolvedFile.path);
return this.formatEmbeddedNoteBlock({
title: resolvedFile.basename,
path: resolvedFile.path,
heading: target.heading,
blockId: target.blockId,
content: embeddedContent,
});
} catch (error) {
logWarn("Failed to process embedded note", error);
const message = error instanceof Error ? error.message : "Could not process embedded note";
return this.formatEmbeddedNoteBlock({
title: resolvedFile.basename,
path: resolvedFile.path,
heading: target.heading,
blockId: target.blockId,
error: message,
});
}
}
/**
* Parse embedded note syntax into a structured target.
*/
private parseEmbeddedLinkTarget(rawTarget: string): EmbeddedLinkTarget | null {
if (!rawTarget) {
return null;
}
const aliasIndex = rawTarget.indexOf("|");
const linkTarget = aliasIndex >= 0 ? rawTarget.slice(0, aliasIndex) : rawTarget;
let cleanedTarget = linkTarget.trim();
if (!cleanedTarget) {
return { path: null };
}
let blockId: string | undefined;
let heading: string | undefined;
const blockIndex = cleanedTarget.indexOf("#^");
if (blockIndex !== -1) {
blockId = cleanedTarget.slice(blockIndex + 2).trim();
cleanedTarget = cleanedTarget.slice(0, blockIndex);
}
const headingIndex = cleanedTarget.indexOf("#");
if (headingIndex !== -1) {
heading = cleanedTarget.slice(headingIndex + 1).trim();
cleanedTarget = cleanedTarget.slice(0, headingIndex);
}
const path = cleanedTarget.length > 0 ? cleanedTarget : null;
return {
path,
heading: heading && heading.length > 0 ? heading : undefined,
blockId: blockId && blockId.length > 0 ? blockId : undefined,
};
}
/**
* Extract a markdown segment representing a heading section or block reference.
*/
private extractMarkdownSegment(
note: TFile,
fileContent: string,
focus: EmbeddedLinkTarget
): MarkdownSegment {
const cache = app.metadataCache.getFileCache(note);
if (focus.blockId) {
const block = cache?.blocks?.[focus.blockId];
const startOffset = block?.position?.start?.offset;
const endOffset = block?.position?.end?.offset;
if (startOffset === undefined || endOffset === undefined) {
return { content: "", found: false };
}
return {
content: fileContent.slice(startOffset, endOffset),
found: true,
};
}
if (focus.heading) {
const headings = cache?.headings ?? [];
const normalizedTarget = this.normalizeHeadingForMatch(focus.heading);
const targetIndex = headings.findIndex(
(headingCache) => this.normalizeHeadingForMatch(headingCache.heading) === normalizedTarget
);
if (targetIndex === -1) {
return { content: "", found: false };
}
const currentHeading = headings[targetIndex];
const startOffset = currentHeading.position?.start?.offset ?? 0;
let endOffset = fileContent.length;
for (let i = targetIndex + 1; i < headings.length; i++) {
if (headings[i].level <= currentHeading.level) {
endOffset = headings[i].position?.start?.offset ?? endOffset;
break;
}
}
return {
content: fileContent.slice(startOffset, endOffset),
found: true,
};
}
return { content: fileContent, found: true };
}
/**
* Normalize heading text for comparison.
*/
private normalizeHeadingForMatch(heading: string): string {
return heading.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Format an embedded note payload using the shared XML-like structure.
*/
private formatEmbeddedNoteBlock(params: {
title: string;
path: string;
heading?: string;
blockId?: string;
content?: string;
error?: string;
}): string {
const { title, path, heading, blockId, content, error } = params;
let block = `\n\n<${EMBEDDED_NOTE_TAG}>\n<title>${title}</title>\n<path>${path}</path>`;
if (heading) {
block += `\n<heading>${heading}</heading>`;
}
if (blockId) {
block += `\n<block_id>${blockId}</block_id>`;
}
if (error) {
block += `\n<error>${error}</error>`;
} else {
block += `\n<content>\n${content ?? ""}\n</content>`;
}
block += `\n</${EMBEDDED_NOTE_TAG}>\n\n`;
return block;
}
/**
* Processes context notes, excluding any already handled by custom prompts.
*
* NOTE: This method reads and includes note content as-is. URLs within note content
* are NOT extracted or processed with url4llm. Only URLs directly typed in the user's
* chat input are processed, not URLs that happen to be in the content of context notes.
*
* @param excludedNotePaths A set of file paths that should be skipped.
* @param fileParserManager
* @param vault
* @param contextNotes
* @param includeActiveNote
* @param activeNote
* @param currentChain
* @returns The combined content string of the processed context notes.
*/
async processContextNotes(
excludedNotePaths: Set<string>,
fileParserManager: FileParserManager,
vault: Vault,
contextNotes: TFile[],
includeActiveNote: boolean,
activeNote: TFile | null,
currentChain: ChainType
): Promise<string> {
let additionalContext = "";
const processNote = async (note: TFile, prompt_tag: string = NOTE_CONTEXT_PROMPT_TAG) => {
try {
// Check if this note was already processed (via custom prompt)
if (excludedNotePaths.has(note.path)) {
logInfo(`Skipping note ${note.path} as it was included via custom prompt.`);
return;
}
// 1. Check if the file extension is supported by any parser
if (!fileParserManager.supportsExtension(note.extension)) {
logWarn(`Unsupported file type: ${note.extension}`);
return;
}
// 2. Apply chain restrictions only to supported files that are NOT md or canvas
if (!isPlusChain(currentChain) && note.extension !== "md" && note.extension !== "canvas") {
// This file type is supported, but requires Plus mode (e.g., PDF)
logWarn(`File type ${note.extension} requires Copilot Plus mode for context processing.`);
// Show user-facing notice about the restriction
new Notice(RESTRICTION_MESSAGES.NON_MARKDOWN_FILES_RESTRICTED);
return;
}
// 3. If we reach here, parse the file (md, canvas, or other supported type in Plus mode)
const content =
note.extension === "md"
? await this.buildMarkdownContextContent(note, vault, fileParserManager, currentChain)
: await fileParserManager.parseFile(note, vault);
// Get file metadata
const stats = await vault.adapter.stat(note.path);
const ctime = stats ? new Date(stats.ctime).toISOString() : "Unknown";
const mtime = stats ? new Date(stats.mtime).toISOString() : "Unknown";
additionalContext += `\n\n<${prompt_tag}>\n<title>${escapeXml(note.basename)}</title>\n<path>${note.path}</path>\n<ctime>${ctime}</ctime>\n<mtime>${mtime}</mtime>\n<content>\n${content}\n</content>\n</${prompt_tag}>`;
} catch (error) {
logError(`Error processing file ${note.path}:`, error);
additionalContext += `\n\n<${prompt_tag}_error>\n<title>${escapeXml(note.basename)}</title>\n<path>${note.path}</path>\n<error>[Error: Could not process file]</error>\n</${prompt_tag}_error>`;
}
};
const includedFilePaths = new Set<string>();
// Process active note if included
if (includeActiveNote && activeNote) {
await processNote(activeNote, "active_note");
includedFilePaths.add(activeNote.path);
}
// Process context notes
for (const note of contextNotes) {
if (includedFilePaths.has(note.path)) {
continue;
}
await processNote(note);
includedFilePaths.add(note.path);
}
return additionalContext;
}
async hasEmbeddedPDFs(content: string): Promise<boolean> {
const pdfRegex = /!\[\[(.*?\.pdf)\]\]/g;
return pdfRegex.test(content);
}
async addNoteToContext(
note: TFile,
vault: Vault,
contextNotes: TFile[],
activeNote: TFile | null,
setContextNotes: (notes: TFile[] | ((prev: TFile[]) => TFile[])) => void,
setIncludeActiveNote: (include: boolean) => void
): Promise<void> {
// Only check if the note exists in contextNotes
if (contextNotes.some((existing) => existing.path === note.path)) {
return; // Note already exists in context
}
// Read the note content
const content = await vault.read(note);
const hasEmbeddedPDFs = await this.hasEmbeddedPDFs(content);
// Set includeActiveNote if it's the active note
if (activeNote && note.path === activeNote.path) {
setIncludeActiveNote(true);
}
// Add to contextNotes with wasAddedViaReference flag
setContextNotes((prev: TFile[]) => [
...prev,
Object.assign(note, {
wasAddedViaReference: true,
hasEmbeddedPDFs,
}),
]);
}
processSelectedTextContexts(): string {
const selectedTextContexts = getSelectedTextContexts();
if (!selectedTextContexts || selectedTextContexts.length === 0) {
return "";
}
let additionalContext = "";
for (const selectedText of selectedTextContexts) {
if (selectedText.sourceType === "web") {
// Web selected text context
additionalContext += `\n\n<${WEB_SELECTED_TEXT_TAG}>\n<title>${escapeXml(selectedText.title)}</title>\n<url>${escapeXml(selectedText.url)}</url>\n<content>\n${escapeXml(selectedText.content)}\n</content>\n</${WEB_SELECTED_TEXT_TAG}>`;
} else {
// Note selected text context (default for backward compatibility)
additionalContext += `\n\n<${SELECTED_TEXT_TAG}>\n<title>${escapeXml(selectedText.noteTitle)}</title>\n<path>${escapeXml(selectedText.notePath)}</path>\n<start_line>${selectedText.startLine.toString()}</start_line>\n<end_line>${selectedText.endLine.toString()}</end_line>\n<content>\n${selectedText.content}\n</content>\n</${SELECTED_TEXT_TAG}>`;
}
}
return additionalContext;
}
/**
* Process web tab contexts and return formatted content for LLM.
* Uses WebViewerService to fetch reader mode markdown from each tab.
* Handles cases where webview content is not yet loaded (e.g., after Obsidian restart).
*
* Performance optimizations:
* - Deduplicates tabs by URL
* - Uses bounded concurrency to avoid UI blocking and resource exhaustion
*
* Design notes (potential future enhancements):
* - Tab count limit: Currently no limit on number of tabs. Could add MAX_WEB_TABS similar to
* how activeNote is handled (single note vs multiple). For now, users self-limit by only
* adding tabs they need.
* - Content length limit: Currently no truncation of long pages. Could add MAX_CHARS_PER_TAB
* similar to how large notes are handled. For now, reader mode already strips most bloat.
* - Total context budget: Could implement a shared budget across all tabs. For now, the LLM's
* context window and token costs naturally discourage excessive context.
*/
async processContextWebTabs(
webTabs: Array<{ url: string; title?: string; faviconUrl?: string; isActive?: boolean }>
): Promise<string> {
if (!webTabs || webTabs.length === 0) {
return "";
}
const WEBVIEW_READY_TIMEOUT_MS = 2_500;
// Timeout for reader mode content extraction to avoid hanging the message send
const READER_MODE_CONTENT_TIMEOUT_MS = 8_000;
// Timeout for YouTube transcript extraction (longer due to DOM manipulation)
const YOUTUBE_TRANSCRIPT_TIMEOUT_MS = 15_000;
// Limit concurrent webview operations to avoid UI blocking and IPC congestion
const MAX_CONCURRENCY = 2;
/**
* Build a web tab context XML block.
* Centralizes XML generation to avoid duplication and ensure consistent escaping.
* @param tagName - The XML tag name to use (active_web_tab or web_tab_context)
*/
const buildWebTabBlock = (
tagName: string,
options: {
title: string;
url: string;
mode?: string;
content?: string;
error?: string;
}
): string => {
const parts = [
`\n\n<${tagName}>`,
`\n<title>${escapeXml(options.title)}</title>`,
`\n<url>${escapeXml(options.url)}</url>`,
];
if (options.mode) {
parts.push(`\n<mode>${escapeXml(options.mode)}</mode>`);
}
if (options.error) {
parts.push(`\n<error>${escapeXml(options.error)}</error>`);
} else if (options.content !== undefined) {
// Content is markdown; escape to prevent XML/prompt injection
parts.push(`\n<content>\n${escapeXml(options.content)}\n</content>`);
}
parts.push(`\n</${tagName}>`);
return parts.join("");
};
/**
* Build a YouTube video context XML block.
* All content is properly escaped to prevent XML/prompt injection.
*/
const buildYouTubeBlock = (options: {
title: string;
url: string;
videoId: string;
channel?: string;
description?: string;
uploadDate?: string;
duration?: string;
genre?: string;
transcript?: string;
error?: string;
isActive?: boolean;
}): string => {
const parts = [
`\n\n<${YOUTUBE_VIDEO_CONTEXT_TAG}>`,
`\n<title>${escapeXml(options.title)}</title>`,
`\n<url>${escapeXml(options.url)}</url>`,
`\n<video_id>${escapeXml(options.videoId)}</video_id>`,
];
if (options.isActive) {
parts.push(`\n<is_active>true</is_active>`);
}
if (options.channel) {
parts.push(`\n<channel>${escapeXml(options.channel)}</channel>`);
}
if (options.uploadDate) {
parts.push(`\n<upload_date>${escapeXml(options.uploadDate)}</upload_date>`);
}
if (options.duration) {
parts.push(`\n<duration>${escapeXml(options.duration)}</duration>`);
}
if (options.genre) {
parts.push(`\n<genre>${escapeXml(options.genre)}</genre>`);
}
if (options.description) {
parts.push(`\n<description>${escapeXml(options.description)}</description>`);
}
// Error for real failures (tab closed, extraction failed, etc.)
if (options.error) {
parts.push(`\n<error>${escapeXml(options.error)}</error>`);
}
// Content: transcript if available, otherwise a message
const content = options.transcript || "No transcript available for this video";
parts.push(`\n<content>\n${escapeXml(content)}\n</content>`);
parts.push(`\n</${YOUTUBE_VIDEO_CONTEXT_TAG}>`);
return parts.join("");
};
// Separate active tab from normal tabs and deduplicate by URL (and videoId for YouTube)
let activeTab: { url: string; title?: string; faviconUrl?: string } | null = null;
const normalTabs: Array<{ url: string; title?: string; faviconUrl?: string }> = [];
const seenUrls = new Set<string>();
const seenVideoIds = new Set<string>(); // Deduplicate YouTube videos by videoId
const service = getWebViewerService(app);
/**
* Check if a tab should be skipped due to deduplication.
* For YouTube videos, deduplicate by videoId (handles youtu.be vs youtube.com/watch).
* For other URLs, deduplicate by normalized URL string.
*/
const isDuplicate = (url: string): boolean => {
const videoId = service.getYouTubeVideoId(url);
if (videoId) {
if (seenVideoIds.has(videoId)) return true;
seenVideoIds.add(videoId);
return false;
}
if (seenUrls.has(url)) return true;
seenUrls.add(url);
return false;
};
// First pass: find active tab
for (const tab of webTabs) {
const url = normalizeUrlString(tab.url);
if (!url) continue;
if (tab.isActive && !activeTab) {
activeTab = { ...tab, url };
isDuplicate(url); // Mark as seen
}
}
// Second pass: collect normal tabs (excluding duplicates)
for (const tab of webTabs) {
const url = normalizeUrlString(tab.url);
if (!url || isDuplicate(url)) continue;
normalTabs.push({ ...tab, url });
}
// Check if we have any tabs to process
if (!activeTab && normalTabs.length === 0) {
return "";
}
// Check Web Viewer availability first
const availability = service.getAvailability();
if (!availability.supported || !availability.available) {
const reason =
availability.reason ??
(availability.supported
? "Web Viewer is not available."
: "Web Viewer is not supported on this platform.");
const blocks: string[] = [];
if (activeTab) {
blocks.push(
buildWebTabBlock(ACTIVE_WEB_TAB_CONTEXT_TAG, {
title: activeTab.title || "Unknown",
url: activeTab.url,
error: reason,
})
);
}
for (const tab of normalTabs) {
blocks.push(
buildWebTabBlock(WEB_TAB_CONTEXT_TAG, {
title: tab.title || "Unknown",
url: tab.url,
error: reason,
})
);
}
return blocks.join("");
}
/**
* Process a single tab and return its XML block.
* YouTube videos are handled specially to extract transcript.
*/
const processTab = async (
tab: { url: string; title?: string; faviconUrl?: string },
tagName: string
): Promise<string> => {
try {
const url = tab.url;
// Check if this is a YouTube video - handle specially
const videoId = service.getYouTubeVideoId(url);
if (videoId) {
const isActive = tagName === ACTIVE_WEB_TAB_CONTEXT_TAG;
return await processYouTubeTab(tab, videoId, isActive);
}
const leaf = service.findLeafByUrl(url, { title: tab.title });
if (!leaf) {
return buildWebTabBlock(tagName, {
title: tab.title || "Unknown",
url,
error: "Web tab not found or closed",
});
}
// Get initial page info (available even if webview not ready)
let pageInfo = service.getPageInfo(leaf);
// Check if webview is ready (content loaded)
// Note: webviewMounted/webviewFirstLoadFinished are internal Obsidian fields
// If they don't exist (undefined), assume ready (fallback for older versions)
const view = leaf.view as { webviewMounted?: boolean; webviewFirstLoadFinished?: boolean };
const webviewReady =
view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined
? true
: Boolean(view.webviewMounted && view.webviewFirstLoadFinished);
if (!webviewReady) {
try {
await service.waitForWebviewReady(leaf, WEBVIEW_READY_TIMEOUT_MS);
pageInfo = service.getPageInfo(leaf);
} catch (err) {
logWarn(`Web tab content not loaded yet for ${url}:`, err);
return buildWebTabBlock(tagName, {
title: pageInfo.title || tab.title || "Untitled",
url: pageInfo.url || url,
mode: pageInfo.mode,
error: "Web tab content not loaded yet",
});
}
}
// Use AbortSignal for cancellable timeout
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort();
}, READER_MODE_CONTENT_TIMEOUT_MS);
try {
const content = await service.getReaderModeMarkdown(leaf, {
signal: abortController.signal,
});
pageInfo = service.getPageInfo(leaf);
return buildWebTabBlock(tagName, {
title: pageInfo.title || tab.title || "Untitled",
url: pageInfo.url || url,
mode: pageInfo.mode,
content,
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
logError(`Error processing web tab ${tab.url}:`, error);
return buildWebTabBlock(tagName, {
title: tab.title || "Unknown",
url: tab.url,
error:
error instanceof WebViewerTimeoutError
? "Web tab content extraction timed out"
: "Could not process web tab",
});
}
};
/**
* Process a YouTube video tab and extract transcript.
* Automatically clicks the transcript button, extracts content, and closes the panel.
*/
const processYouTubeTab = async (
tab: { url: string; title?: string; faviconUrl?: string },
videoId: string,
isActive: boolean
): Promise<string> => {
try {
// Find leaf by videoId (handles all URL formats and redirects)
// This is more reliable than URL string matching because:
// - youtu.be/xxx redirects to youtube.com/watch?v=xxx
// - URL may have different query params (t=, list=, etc.)
// - www vs non-www differences
let leaf = null;
let actualUrl = tab.url;
for (const l of service.getLeaves()) {
const leafUrl = service.getPageInfo(l).url;
if (service.getYouTubeVideoId(leafUrl) === videoId) {
leaf = l;
actualUrl = leafUrl; // Use the actual URL from the leaf
break;
}
}
if (!leaf) {
return buildYouTubeBlock({
title: tab.title || "YouTube Video",
url: tab.url,
videoId,
isActive,
error: "Web tab not found or closed",
});
}
// Wait for webview to be ready
const view = leaf.view as { webviewMounted?: boolean; webviewFirstLoadFinished?: boolean };
const webviewReady =
view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined
? true
: Boolean(view.webviewMounted && view.webviewFirstLoadFinished);
if (!webviewReady) {
try {
await service.waitForWebviewReady(leaf, WEBVIEW_READY_TIMEOUT_MS);
} catch (err) {
logWarn(`YouTube tab content not loaded yet for ${actualUrl}:`, err);
return buildYouTubeBlock({
title: tab.title || "YouTube Video",
url: actualUrl,
videoId,
isActive,
error: "Web tab content not loaded yet",
});
}
}
// Extract video metadata and transcript
const result = await service.getYouTubeTranscript(leaf, {
timeoutMs: YOUTUBE_TRANSCRIPT_TIMEOUT_MS,
});
// Format transcript (may be empty if no transcript available)
const transcriptText =
result.transcript.length > 0
? result.transcript.map((seg) => `${seg.timestamp}: ${seg.text}`).join("\n")
: undefined;
return buildYouTubeBlock({
title: result.title || tab.title || "YouTube Video",
url: actualUrl,
videoId: result.videoId,
channel: result.channel,
description: result.description,
uploadDate: result.uploadDate,
duration: result.duration,
genre: result.genre,
transcript: transcriptText,
isActive,
});
} catch (err) {
logWarn(`YouTube transcript extraction failed for ${tab.url}:`, err);
return buildYouTubeBlock({
title: tab.title || "YouTube Video",
url: tab.url,
videoId,
isActive,
error: err instanceof Error ? err.message : "Failed to extract video info",
});
}
};
// Process active tab first (if exists)
const blocks: string[] = [];
if (activeTab) {
const activeBlock = await processTab(activeTab, ACTIVE_WEB_TAB_CONTEXT_TAG);
blocks.push(activeBlock);
}
// Process normal tabs with bounded concurrency
for (let i = 0; i < normalTabs.length; i += MAX_CONCURRENCY) {
const chunk = normalTabs.slice(i, i + MAX_CONCURRENCY);
const chunkResults = await Promise.all(
chunk.map((tab) => processTab(tab, WEB_TAB_CONTEXT_TAG))
);
blocks.push(...chunkResults);
}
return blocks.join("");
}
}