Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Ask the agent to call `read_rich_document` with a document path inside the curre

The tool returns structure-preserving Markdown for headings, lists, tables, links, sections, slide context, notes, and supported document content. It also returns a structured media index in tool metadata.

It appends an ordered embedded-media index containing each attachment's source name, MIME type, document section, and isolated temporary path.
For calls without `export`, it appends an ordered embedded-media index containing each attachment's source name, MIME type, document section, and isolated temporary path.

Use a media label from the index when vision inspection is relevant. The selected image is returned as a native OpenCode file attachment, while its isolated temporary path remains available for follow-up tools.

Expand All @@ -38,13 +38,33 @@ To attach selected image media directly to the next model turn, call the tool ag

Selectable image media supports `image/jpeg`, `image/png`, `image/gif`, `image/bmp`, `image/tiff`, `image/svg+xml`, and `image/webp` attachments.

### Durable export (opt-in)

Calls without `export` keep the existing ephemeral behavior. To explicitly save a durable copy beside the source document, pass an empty export request:

```json
{"path":"docs/architecture.docx","export":{}}
```

For `docs/architecture.docx`, the default sibling export directory is `docs/architecture.export/`. It contains `architecture.md`, a `media/` directory containing every extracted attachment, and `manifest.json`. The exported Markdown preserves the document structure and uses relative paths such as `media/media-1.png` in its media index.

Callers may choose another durable directory with a project-relative destination:

```json
{"path":"docs/architecture.docx","export":{"destination":"artifacts/architecture"}}
```

The destination is an output directory relative to the current project. Missing parent directories are created, but an existing destination is rejected and never overwritten; choose a new destination or remove the old export explicitly before retrying. Absolute destinations and paths that escape the project, including escaping symlinks, are rejected.

The result metadata includes `metadata.export.directoryPath`, `markdownPath`, `mediaDirectoryPath`, `manifestPath`, and durable paths for each exported media item. The manifest is JSON and repeats those paths and the source-relative path so later turns can use the saved artifacts without depending on the temporary extraction directory. Export is never automatic, and the source document is not changed.

## Safety and boundaries

The source document is read without modification.

Paths must resolve inside the current project, and symlinks that escape it are rejected.

Extracted files are written beneath a unique system temporary directory rather than beside the source document.
Without an explicit `export` request, extracted files are written beneath a unique system temporary directory rather than beside the source document, and the existing temporary media index is returned. Successful reads keep that directory available for the current agent workflow; failed reads clean up partial extraction. An explicit export additionally writes the durable directory described above and does not change the non-export contract.

The parser applies bounded archive, entry-count, and table-cell limits.

Expand All @@ -62,7 +82,7 @@ npm run check
npm test
```

The test suite creates minimal DOCX, ODT, and PPTX fixtures, verifies structure and media extraction, and exercises path and malformed-document errors.
The test suite creates minimal DOCX, ODT, and PPTX fixtures, verifies structure and media extraction, exercises durable export contents and path/collision behavior, and covers malformed-document errors.

## License

Expand Down
130 changes: 130 additions & 0 deletions src/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path";
import type {
DurableExportMedia,
DurableExportMetadata,
MediaRecord,
ResolvedExportDestination,
} from "./types.ts";

export class DurableExportError extends Error {
readonly code: "EXPORT_EXISTS" | "EXPORT_FAILED";

constructor(code: DurableExportError["code"], message: string, options?: ErrorOptions) {
super(message, options);
this.name = "DurableExportError";
this.code = code;
}
}

function errorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return String(error);
}

function fileStem(sourcePath: string): string {
const stem = basename(sourcePath, extname(sourcePath));
return stem || basename(sourcePath);
}

export interface DurableExportOptions {
destination: ResolvedExportDestination;
sourcePath: string;
format: string;
markdown: string;
records: readonly MediaRecord[];
abortSignal: AbortSignal;
}

export async function writeDurableExport({
destination,
sourcePath,
format,
markdown,
records,
abortSignal,
}: DurableExportOptions): Promise<DurableExportMetadata> {
const directoryPath = destination.absolutePath;
const mediaDirectoryPath = join(directoryPath, "media");
const markdownPath = join(directoryPath, `${fileStem(sourcePath)}.md`);
const manifestPath = join(directoryPath, "manifest.json");
let createdDirectory = false;

try {
abortSignal.throwIfAborted();
await mkdir(dirname(directoryPath), { recursive: true, mode: 0o700 });
await mkdir(directoryPath, { recursive: false, mode: 0o700 });
createdDirectory = true;
await mkdir(mediaDirectoryPath, { mode: 0o700 });

const media: DurableExportMedia[] = [];
for (const { entry } of records) {
abortSignal.throwIfAborted();
const fileName = basename(entry.temporaryPath);
const path = join(mediaDirectoryPath, fileName);
await copyFile(entry.temporaryPath, path);
media.push({
label: entry.label,
type: entry.type,
originalName: entry.originalName,
mimeType: entry.mimeType,
location: entry.location,
path,
relativePath: `media/${fileName}`,
});
}

abortSignal.throwIfAborted();
await writeFile(markdownPath, markdown, { encoding: "utf8", mode: 0o600 });
const metadata: DurableExportMetadata = {
directoryPath,
markdownPath,
mediaDirectoryPath,
manifestPath,
media,
};
await writeFile(
manifestPath,
`${JSON.stringify(
{
version: 1,
sourcePath,
format,
exportDirectory: directoryPath,
exportDirectoryRelativePath: destination.relativePath,
markdownPath,
markdownRelativePath: basename(markdownPath),
mediaDirectoryPath,
mediaDirectoryRelativePath: "media",
manifestPath,
manifestRelativePath: "manifest.json",
media,
},
null,
2,
)}\n`,
{ encoding: "utf8", mode: 0o600 },
);
abortSignal.throwIfAborted();
return metadata;
} catch (error) {
if (createdDirectory) {
await rm(directoryPath, { recursive: true, force: true }).catch(() => undefined);
}
if (abortSignal.aborted) abortSignal.throwIfAborted();
if (error instanceof DurableExportError) throw error;
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
if (code === "EEXIST") {
throw new DurableExportError(
"EXPORT_EXISTS",
`Export destination already exists and was not overwritten: ${directoryPath}`,
{ cause: error },
);
}
throw new DurableExportError(
"EXPORT_FAILED",
`Could not write durable export to ${directoryPath}: ${errorMessage(error)}`,
{ cause: error },
);
}
}
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@ export const RichDocumentReaderPlugin: Plugin = async () => ({
tool: {
read_rich_document: tool({
description:
"Read a DOCX, ODT, or PPTX file as structure-preserving Markdown and list its extracted media. Select image labels only when vision inspection is needed.",
"Read a DOCX, ODT, or PPTX file as structure-preserving Markdown and list its extracted media. Select image labels only when vision inspection is needed. Pass export: {} to save a durable sibling export, or export.destination for another project-relative directory.",
args: {
path: tool.schema.string().describe("Path to a DOCX, ODT, or PPTX inside the current project."),
media: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional media labels from the embedded media index to attach as images."),
export: tool.schema
.object({
destination: tool.schema
.string()
.optional()
.describe("Optional project-relative destination directory; omit for a sibling .export directory."),
})
.optional()
.describe(
"Explicitly persist the Markdown, all extracted media, and a manifest. Omit to keep the current temporary behavior.",
),
},
async execute(args, context) {
return readRichDocument(args, context);
Expand Down
13 changes: 10 additions & 3 deletions src/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { basename, extname, join } from "node:path";
import type { OfficeAttachment, OfficeContentNode, OfficeParserAST } from "officeparser";
import type { MediaIndexEntry, MediaRecord } from "./types.ts";

export interface MediaTableOptions {
pathFor?: (entry: MediaIndexEntry) => string;
pathHeading?: string;
}

interface LocationContext {
heading?: string;
role?: string;
Expand Down Expand Up @@ -169,16 +174,18 @@ export async function writeMedia(
return records;
}

export function mediaTable(records: MediaRecord[]): string {
export function mediaTable(records: MediaRecord[], options: MediaTableOptions = {}): string {
const cell = (value: string) => value.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
const pathHeading = options.pathHeading ?? "Temporary path";
const pathFor = options.pathFor ?? ((entry: MediaIndexEntry) => entry.temporaryPath);
const rows = records.map(({ entry }) =>
`| \`${cell(entry.label)}\` | ${cell(entry.type)} | ${cell(entry.originalName)} | ${cell(entry.mimeType)} | ${cell(entry.temporaryPath)} | ${cell(entry.location)} |`,
`| \`${cell(entry.label)}\` | ${cell(entry.type)} | ${cell(entry.originalName)} | ${cell(entry.mimeType)} | ${cell(pathFor(entry))} | ${cell(entry.location)} |`,
);

return [
"## Embedded media",
"",
"| Label | Type | Original attachment | MIME type | Temporary path | Location |",
`| Label | Type | Original attachment | MIME type | ${pathHeading} | Location |`,
"| --- | --- | --- | --- | --- | --- |",
...(rows.length ? rows : ["| _none_ | | | | | |"]),
].join("\n");
Expand Down
93 changes: 91 additions & 2 deletions src/path-safety.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { access, lstat, realpath, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
import type { ProjectPaths, ResolvedDocument } from "./types.ts";
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
import type { ProjectPaths, ResolvedDocument, ResolvedExportDestination } from "./types.ts";

export class DocumentPathError extends Error {
readonly code:
Expand All @@ -20,6 +20,20 @@ export class DocumentPathError extends Error {
}
}

export class ExportPathError extends Error {
readonly code:
| "INVALID_EXPORT_DESTINATION"
| "EXPORT_PATH_ESCAPE"
| "EXPORT_SYMLINK_ESCAPE"
| "EXPORT_DESTINATION_UNREADABLE";

constructor(code: ExportPathError["code"], message: string) {
super(message);
this.name = "ExportPathError";
this.code = code;
}
}

function isWithin(root: string, candidate: string): boolean {
const distance = relative(root, candidate);
return distance === "" || (distance !== ".." && !distance.startsWith(`..${sep}`) && !isAbsolute(distance));
Expand All @@ -33,6 +47,81 @@ async function canonicalProjectRoot(root: string): Promise<string> {
}
}

function exportStem(sourcePath: string): string {
const extension = extname(sourcePath);
const stem = basename(sourcePath, extension);
return stem || basename(sourcePath);
}

function pathErrorCode(error: unknown): string | undefined {
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
}

async function assertExportPathInsideProject(projectRoot: string, candidate: string): Promise<void> {
let current = candidate;
while (true) {
try {
const canonical = await realpath(current);
if (!isWithin(projectRoot, canonical)) {
throw new ExportPathError(
"EXPORT_SYMLINK_ESCAPE",
`Export destination symlink escapes the current project: ${candidate}`,
);
}
return;
} catch (error) {
if (error instanceof ExportPathError) throw error;
if (pathErrorCode(error) !== "ENOENT" && pathErrorCode(error) !== "ENOTDIR") {
throw new ExportPathError(
"EXPORT_DESTINATION_UNREADABLE",
`Export destination cannot be inspected: ${candidate}`,
);
}
const parent = dirname(current);
if (parent === current) {
throw new ExportPathError(
"EXPORT_DESTINATION_UNREADABLE",
`Export destination cannot be inspected: ${candidate}`,
);
}
current = parent;
}
}
}

export async function resolveExportDestination(
destination: string | undefined,
rootInput: string,
sourcePath: string,
): Promise<ResolvedExportDestination> {
if (destination !== undefined && (!destination.trim() || destination.includes("\0"))) {
throw new ExportPathError("INVALID_EXPORT_DESTINATION", "Export destination must be a non-empty path.");
}

const projectRoot = await canonicalProjectRoot(rootInput);
const candidate =
destination === undefined
? join(dirname(sourcePath), `${exportStem(sourcePath)}.export`)
: resolve(projectRoot, destination);

if (destination !== undefined && (isAbsolute(destination) || win32.isAbsolute(destination))) {
throw new ExportPathError(
"EXPORT_PATH_ESCAPE",
`Export destination must be project-relative, not absolute: ${destination}`,
);
}
if (!isWithin(projectRoot, candidate)) {
throw new ExportPathError(
"EXPORT_PATH_ESCAPE",
`Export destination escapes the current project: ${destination ?? candidate}`,
);
}

await assertExportPathInsideProject(projectRoot, candidate);
const relativePath = relative(projectRoot, candidate).split(sep).join("/") || ".";
return { projectRoot, absolutePath: candidate, relativePath };
}

export async function resolveDocumentPath(documentInput: string, rootInput: string): Promise<ResolvedDocument> {
if (!documentInput.trim() || documentInput.includes("\0")) {
throw new DocumentPathError("INVALID_PATH", "A non-empty document path is required.");
Expand Down
Loading
Loading