kritya 0.8.2-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { truncateResult } from "./common.js";
|
|
2
|
+
import { tavilyRaw } from "./webSearch.js";
|
|
3
|
+
import { fetchUrlText } from "./fetchUrl.js";
|
|
4
|
+
const MAX_QUERIES = 5;
|
|
5
|
+
const DEFAULT_PAGES_PER_QUERY = 2;
|
|
6
|
+
const MAX_PAGES_PER_QUERY = 4;
|
|
7
|
+
/** Keep each fetched page compact so a multi-source bundle still fits in context. */
|
|
8
|
+
const PER_PAGE_CHARS = 6_000;
|
|
9
|
+
/**
|
|
10
|
+
* Deep research: the caller (the model) breaks a big question into a handful of
|
|
11
|
+
* focused sub-queries; this tool runs a web search for each, then fetches the
|
|
12
|
+
* full text of the top result pages and returns one consolidated bundle. It is
|
|
13
|
+
* web_search + fetch_url in a loop — use it only for questions that genuinely
|
|
14
|
+
* need multiple sources, since it makes many external calls.
|
|
15
|
+
*/
|
|
16
|
+
async function runDeepResearch(queries, pagesPerQuery, recencyDays) {
|
|
17
|
+
const seenUrls = new Set();
|
|
18
|
+
const sections = [];
|
|
19
|
+
// When a recency window is requested, search the news topic so Tavily both
|
|
20
|
+
// filters to the last N days and returns publish dates the model can check.
|
|
21
|
+
const searchOpts = recencyDays ? { topic: "news", days: recencyDays } : {};
|
|
22
|
+
for (const query of queries) {
|
|
23
|
+
const block = [`## Sub-query: ${query}`];
|
|
24
|
+
let search;
|
|
25
|
+
try {
|
|
26
|
+
search = await tavilyRaw(query, 5, searchOpts);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
block.push(`(search failed: ${err instanceof Error ? err.message : String(err)})`);
|
|
30
|
+
sections.push(block.join("\n"));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (search.answer)
|
|
34
|
+
block.push(`Search summary: ${search.answer}`);
|
|
35
|
+
const results = (search.results ?? []).filter((r) => r.url && !seenUrls.has(r.url));
|
|
36
|
+
if (results.length === 0)
|
|
37
|
+
block.push("(no new sources found)");
|
|
38
|
+
let fetched = 0;
|
|
39
|
+
for (const r of results) {
|
|
40
|
+
if (fetched >= pagesPerQuery)
|
|
41
|
+
break;
|
|
42
|
+
seenUrls.add(r.url);
|
|
43
|
+
const date = r.published_date ? `published ${r.published_date}` : "publish date unknown";
|
|
44
|
+
block.push(`\n### Source: ${r.title ?? "(untitled)"} (${date})\n${r.url}`);
|
|
45
|
+
try {
|
|
46
|
+
const page = await fetchUrlText(r.url, PER_PAGE_CHARS);
|
|
47
|
+
block.push(page);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// A single unreachable page shouldn't sink the whole research run;
|
|
51
|
+
// fall back to the search snippet so the source still contributes.
|
|
52
|
+
block.push(`(could not fetch full page: ${err instanceof Error ? err.message : String(err)})`);
|
|
53
|
+
if (r.content)
|
|
54
|
+
block.push(`Snippet: ${r.content}`);
|
|
55
|
+
}
|
|
56
|
+
fetched++;
|
|
57
|
+
}
|
|
58
|
+
sections.push(block.join("\n"));
|
|
59
|
+
}
|
|
60
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
61
|
+
const window = recencyDays
|
|
62
|
+
? `The user asked for information from the last ${recencyDays} day(s) (today is ${today}). ` +
|
|
63
|
+
`Each source is labeled with its publish date. Check those dates: exclude or explicitly ` +
|
|
64
|
+
`flag any source that falls outside this window or whose date is unknown — do NOT present ` +
|
|
65
|
+
`older material as current. `
|
|
66
|
+
: `Today is ${today}; do not assume a source is recent unless its content indicates so. `;
|
|
67
|
+
const preamble = `Deep research bundle: ${queries.length} sub-quer${queries.length === 1 ? "y" : "ies"}, ` +
|
|
68
|
+
`${seenUrls.size} source page(s). ${window}` +
|
|
69
|
+
`Synthesize an answer strictly from these sources and cite the URLs used. ` +
|
|
70
|
+
`Attribute rumors or secondary reports rather than stating them as established fact, ` +
|
|
71
|
+
`and note where sources disagree.\n`;
|
|
72
|
+
return truncateResult(preamble + "\n" + sections.join("\n\n---\n\n"));
|
|
73
|
+
}
|
|
74
|
+
export const deepResearchTool = {
|
|
75
|
+
name: "deep_research",
|
|
76
|
+
description: "Research a topic in depth across multiple web sources. You supply 1-5 focused sub-queries " +
|
|
77
|
+
"that break the question apart; the tool runs a web search for each and reads the full text " +
|
|
78
|
+
"of the top result pages, returning one consolidated, cited bundle to synthesize from. " +
|
|
79
|
+
"Reserve this for broad, multi-source questions (comparisons, surveys, 'how do people do X') " +
|
|
80
|
+
"— for a single known URL use fetch_url, and for a quick fact use web_search. " +
|
|
81
|
+
"For recent or time-sensitive topics ('latest', 'this week', news), set recency_days so " +
|
|
82
|
+
"sources are restricted to that window and labeled with publish dates.",
|
|
83
|
+
parameters: {
|
|
84
|
+
type: "object",
|
|
85
|
+
properties: {
|
|
86
|
+
queries: {
|
|
87
|
+
type: "array",
|
|
88
|
+
items: { type: "string" },
|
|
89
|
+
description: `1-${MAX_QUERIES} focused sub-queries covering different angles of the question`,
|
|
90
|
+
},
|
|
91
|
+
pages_per_query: {
|
|
92
|
+
type: "number",
|
|
93
|
+
description: `Full pages to read per sub-query, 1-${MAX_PAGES_PER_QUERY} (default ${DEFAULT_PAGES_PER_QUERY})`,
|
|
94
|
+
},
|
|
95
|
+
recency_days: {
|
|
96
|
+
type: "number",
|
|
97
|
+
description: "Restrict sources to the last N days and return publish dates. Set this whenever the " +
|
|
98
|
+
"question is about recent/current events (e.g. 7 for 'past week'); omit for timeless topics.",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
required: ["queries"],
|
|
102
|
+
},
|
|
103
|
+
requiresPermission: true,
|
|
104
|
+
external: true,
|
|
105
|
+
summarize: (args) => {
|
|
106
|
+
const qs = Array.isArray(args.queries) ? args.queries : [];
|
|
107
|
+
return `Deep research (${qs.length} quer${qs.length === 1 ? "y" : "ies"}): ${qs.join("; ").slice(0, 80)}`;
|
|
108
|
+
},
|
|
109
|
+
async execute(args) {
|
|
110
|
+
const raw = Array.isArray(args.queries) ? args.queries : [];
|
|
111
|
+
const queries = raw
|
|
112
|
+
.map((q) => String(q).trim())
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.slice(0, MAX_QUERIES);
|
|
115
|
+
if (queries.length === 0) {
|
|
116
|
+
throw new Error("deep_research needs at least one non-empty query");
|
|
117
|
+
}
|
|
118
|
+
const pagesPerQuery = args.pages_per_query === undefined || args.pages_per_query === null
|
|
119
|
+
? DEFAULT_PAGES_PER_QUERY
|
|
120
|
+
: Math.min(Math.max(Number(args.pages_per_query), 1), MAX_PAGES_PER_QUERY);
|
|
121
|
+
const recencyDays = args.recency_days === undefined || args.recency_days === null
|
|
122
|
+
? undefined
|
|
123
|
+
: Math.max(1, Math.floor(Number(args.recency_days)));
|
|
124
|
+
return runDeepResearch(queries, pagesPerQuery, recencyDays);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const MAX_SIDE_LINES = 30;
|
|
2
|
+
/**
|
|
3
|
+
* Naive line diff: trims common leading/trailing lines, shows the changed
|
|
4
|
+
* middle as removals then additions. Good enough for a permission preview;
|
|
5
|
+
* not a real Myers diff.
|
|
6
|
+
*/
|
|
7
|
+
export function diffLines(oldText, newText) {
|
|
8
|
+
const oldLines = oldText === "" ? [] : oldText.split("\n");
|
|
9
|
+
const newLines = newText === "" ? [] : newText.split("\n");
|
|
10
|
+
let start = 0;
|
|
11
|
+
while (start < oldLines.length &&
|
|
12
|
+
start < newLines.length &&
|
|
13
|
+
oldLines[start] === newLines[start]) {
|
|
14
|
+
start++;
|
|
15
|
+
}
|
|
16
|
+
let endOld = oldLines.length;
|
|
17
|
+
let endNew = newLines.length;
|
|
18
|
+
while (endOld > start && endNew > start && oldLines[endOld - 1] === newLines[endNew - 1]) {
|
|
19
|
+
endOld--;
|
|
20
|
+
endNew--;
|
|
21
|
+
}
|
|
22
|
+
const out = [];
|
|
23
|
+
const context = oldLines.slice(Math.max(0, start - 2), start);
|
|
24
|
+
for (const line of context)
|
|
25
|
+
out.push(` ${line}`);
|
|
26
|
+
const removed = oldLines.slice(start, endOld);
|
|
27
|
+
const added = newLines.slice(start, endNew);
|
|
28
|
+
// For a single-line change, mark the exact characters that differ with «».
|
|
29
|
+
if (removed.length === 1 && added.length === 1) {
|
|
30
|
+
const [oldMarked, newMarked] = markIntraLine(removed[0], added[0]);
|
|
31
|
+
out.push(`- ${oldMarked}`);
|
|
32
|
+
out.push(`+ ${newMarked}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
for (const line of cap(removed))
|
|
36
|
+
out.push(`- ${line}`);
|
|
37
|
+
for (const line of cap(added))
|
|
38
|
+
out.push(`+ ${line}`);
|
|
39
|
+
}
|
|
40
|
+
const trailing = oldLines.slice(endOld, Math.min(oldLines.length, endOld + 2));
|
|
41
|
+
for (const line of trailing)
|
|
42
|
+
out.push(` ${line}`);
|
|
43
|
+
return out.join("\n");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Wrap the differing middle of two single lines in «» so the reader sees
|
|
47
|
+
* exactly which characters changed, not just that the whole line changed.
|
|
48
|
+
* Returns [oldMarked, newMarked]. If the lines share nothing, returns them
|
|
49
|
+
* unmarked.
|
|
50
|
+
*/
|
|
51
|
+
function markIntraLine(a, b) {
|
|
52
|
+
let pre = 0;
|
|
53
|
+
const maxPre = Math.min(a.length, b.length);
|
|
54
|
+
while (pre < maxPre && a[pre] === b[pre])
|
|
55
|
+
pre++;
|
|
56
|
+
let suf = 0;
|
|
57
|
+
while (suf < Math.min(a.length, b.length) - pre &&
|
|
58
|
+
a[a.length - 1 - suf] === b[b.length - 1 - suf]) {
|
|
59
|
+
suf++;
|
|
60
|
+
}
|
|
61
|
+
if (pre === 0 && suf === 0)
|
|
62
|
+
return [a, b];
|
|
63
|
+
const mark = (s) => `${s.slice(0, pre)}«${s.slice(pre, s.length - suf)}»${s.slice(s.length - suf)}`;
|
|
64
|
+
return [mark(a), mark(b)];
|
|
65
|
+
}
|
|
66
|
+
function cap(lines) {
|
|
67
|
+
if (lines.length <= MAX_SIDE_LINES)
|
|
68
|
+
return lines;
|
|
69
|
+
return [...lines.slice(0, MAX_SIDE_LINES), `… (${lines.length - MAX_SIDE_LINES} more lines)`];
|
|
70
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import mammoth from "mammoth";
|
|
2
|
+
import { Document, HeadingLevel, Packer, Paragraph, TextRun } from "docx";
|
|
3
|
+
import { validateBlocks } from "./types.js";
|
|
4
|
+
export async function readDocx(buf) {
|
|
5
|
+
const result = await mammoth.extractRawText({ buffer: buf });
|
|
6
|
+
return result.value;
|
|
7
|
+
}
|
|
8
|
+
const HEADING_LEVELS = {
|
|
9
|
+
heading1: HeadingLevel.HEADING_1,
|
|
10
|
+
heading2: HeadingLevel.HEADING_2,
|
|
11
|
+
heading3: HeadingLevel.HEADING_3,
|
|
12
|
+
};
|
|
13
|
+
export async function writeDocx(input) {
|
|
14
|
+
const children = validateBlocks(input).map((block) => {
|
|
15
|
+
if (block.type in HEADING_LEVELS) {
|
|
16
|
+
return new Paragraph({
|
|
17
|
+
heading: HEADING_LEVELS[block.type],
|
|
18
|
+
children: [new TextRun(block.text)],
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (block.type === "bullet") {
|
|
22
|
+
return new Paragraph({ bullet: { level: 0 }, children: [new TextRun(block.text)] });
|
|
23
|
+
}
|
|
24
|
+
if (block.type === "numbered") {
|
|
25
|
+
return new Paragraph({
|
|
26
|
+
numbering: { reference: "doc-numbering", level: 0 },
|
|
27
|
+
children: [new TextRun(block.text)],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return new Paragraph({ children: [new TextRun(block.text)] });
|
|
31
|
+
});
|
|
32
|
+
const doc = new Document({
|
|
33
|
+
numbering: {
|
|
34
|
+
config: [
|
|
35
|
+
{
|
|
36
|
+
reference: "doc-numbering",
|
|
37
|
+
levels: [{ level: 0, format: "decimal", text: "%1.", alignment: "start" }],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
sections: [{ children }],
|
|
42
|
+
});
|
|
43
|
+
return Packer.toBuffer(doc);
|
|
44
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
|
3
|
+
import { PDFDocument, StandardFonts, degrees } from "pdf-lib";
|
|
4
|
+
import { validateBlocks } from "./types.js";
|
|
5
|
+
// Points pdfjs at its own bundled standard-font metrics so it doesn't warn
|
|
6
|
+
// about missing font data when a PDF references one of the base 14 fonts.
|
|
7
|
+
// pdfjs-dist requires this to be a string ending in "/" (it does a literal
|
|
8
|
+
// `.endsWith("/")` check before reading via fs.readFile), but on Windows
|
|
9
|
+
// fileURLToPath yields backslash-separated paths — normalize to forward
|
|
10
|
+
// slashes, which fs.readFile accepts on every platform.
|
|
11
|
+
const STANDARD_FONT_DATA_URL = fileURLToPath(new URL("../../../node_modules/pdfjs-dist/standard_fonts/", import.meta.url)).replace(/\\/g, "/");
|
|
12
|
+
export async function readPdf(buf) {
|
|
13
|
+
const loadingTask = getDocument({
|
|
14
|
+
data: new Uint8Array(buf),
|
|
15
|
+
standardFontDataUrl: STANDARD_FONT_DATA_URL,
|
|
16
|
+
});
|
|
17
|
+
const doc = await loadingTask.promise;
|
|
18
|
+
const pageTexts = [];
|
|
19
|
+
for (let i = 1; i <= doc.numPages; i++) {
|
|
20
|
+
const page = await doc.getPage(i);
|
|
21
|
+
const content = await page.getTextContent();
|
|
22
|
+
const text = content.items
|
|
23
|
+
.map((item) => ("str" in item ? item.str : ""))
|
|
24
|
+
.join(" ");
|
|
25
|
+
pageTexts.push(text);
|
|
26
|
+
}
|
|
27
|
+
await loadingTask.destroy();
|
|
28
|
+
return pageTexts.join("\n\n");
|
|
29
|
+
}
|
|
30
|
+
const PAGE_WIDTH = 612; // US Letter, points
|
|
31
|
+
const PAGE_HEIGHT = 792;
|
|
32
|
+
const MARGIN = 54;
|
|
33
|
+
const LINE_GAP = 4;
|
|
34
|
+
const BLOCK_STYLE = {
|
|
35
|
+
heading1: { size: 22, bold: true, prefix: "" },
|
|
36
|
+
heading2: { size: 17, bold: true, prefix: "" },
|
|
37
|
+
heading3: { size: 14, bold: true, prefix: "" },
|
|
38
|
+
paragraph: { size: 11, bold: false, prefix: "" },
|
|
39
|
+
bullet: { size: 11, bold: false, prefix: "• " },
|
|
40
|
+
numbered: { size: 11, bold: false, prefix: "" },
|
|
41
|
+
};
|
|
42
|
+
// The base-14 fonts are WinAnsi-encoded, so pdf-lib throws on anything outside
|
|
43
|
+
// it (emoji, CJK, Greek, box drawing). Fold the characters that have an obvious
|
|
44
|
+
// ASCII equivalent and mark the rest, rather than failing the whole document.
|
|
45
|
+
const CHAR_FOLDS = [
|
|
46
|
+
[/[‘’‚‛]/g, "'"],
|
|
47
|
+
[/[“”„‟]/g, '"'],
|
|
48
|
+
[/[‐‑‒–]/g, "-"],
|
|
49
|
+
[/[—―]/g, "—"],
|
|
50
|
+
[/[•‣◦●▪]/g, "•"],
|
|
51
|
+
[/…/g, "..."],
|
|
52
|
+
[/[→⇒]/g, "->"],
|
|
53
|
+
[/[←⇐]/g, "<-"],
|
|
54
|
+
[/[\u00A0\u2002\u2003\u2007\u2009\u202F]/g, " "], // non-breaking and typographic spaces
|
|
55
|
+
// Alternation, not a character class: ZWJ in a class trips no-misleading-character-class.
|
|
56
|
+
[/\u00AD|\u200B|\u200C|\u200D|\uFEFF/g, ""], // soft hyphen and zero-width marks
|
|
57
|
+
[/\t/g, " "],
|
|
58
|
+
];
|
|
59
|
+
// WinAnsi: printable Latin-1 plus the named characters in the 0x80-0x9F block.
|
|
60
|
+
const WINANSI_OK = /[\u0020-\u007E\u00A1-\u00FF\u20AC\u201A\u0192\u201E\u2026\u2020\u2021\u02C6\u2030\u0160\u2039\u0152\u017D\u2018\u2019\u201C\u201D\u2022\u2013\u2014\u02DC\u2122\u0161\u203A\u0153\u017E\u0178]/;
|
|
61
|
+
/** Make one block of text safe for a WinAnsi-encoded standard font. */
|
|
62
|
+
function toWinAnsi(text) {
|
|
63
|
+
let out = text.normalize("NFC");
|
|
64
|
+
for (const [pattern, replacement] of CHAR_FOLDS)
|
|
65
|
+
out = out.replace(pattern, replacement);
|
|
66
|
+
return [...out].map((ch) => (WINANSI_OK.test(ch) ? ch : "?")).join("");
|
|
67
|
+
}
|
|
68
|
+
export async function writePdf(input) {
|
|
69
|
+
const blocks = validateBlocks(input);
|
|
70
|
+
const doc = await PDFDocument.create();
|
|
71
|
+
const regular = await doc.embedFont(StandardFonts.Helvetica);
|
|
72
|
+
const bold = await doc.embedFont(StandardFonts.HelveticaBold);
|
|
73
|
+
let page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
|
74
|
+
let y = PAGE_HEIGHT - MARGIN;
|
|
75
|
+
const maxWidth = PAGE_WIDTH - MARGIN * 2;
|
|
76
|
+
let numberedIndex = 0;
|
|
77
|
+
for (const block of blocks) {
|
|
78
|
+
const style = BLOCK_STYLE[block.type];
|
|
79
|
+
const font = style.bold ? bold : regular;
|
|
80
|
+
numberedIndex = block.type === "numbered" ? numberedIndex + 1 : 0;
|
|
81
|
+
const prefix = block.type === "numbered" ? `${numberedIndex}. ` : style.prefix;
|
|
82
|
+
const lines = wrapText(toWinAnsi(prefix + block.text), font, style.size, maxWidth);
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
if (y - style.size < MARGIN) {
|
|
85
|
+
page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
|
86
|
+
y = PAGE_HEIGHT - MARGIN;
|
|
87
|
+
}
|
|
88
|
+
page.drawText(line, { x: MARGIN, y: y - style.size, size: style.size, font });
|
|
89
|
+
y -= style.size + LINE_GAP;
|
|
90
|
+
}
|
|
91
|
+
y -= LINE_GAP; // extra space between blocks
|
|
92
|
+
}
|
|
93
|
+
// pdf-lib defaults to compressed object streams (PDF 1.5+), which the
|
|
94
|
+
// bundled pdfjs in pdf-parse (an old release) fails to parse. Plain
|
|
95
|
+
// xref tables keep round-trip reading working.
|
|
96
|
+
const bytes = await doc.save({ useObjectStreams: false });
|
|
97
|
+
return Buffer.from(bytes);
|
|
98
|
+
}
|
|
99
|
+
/** Ensure every entry of `pages` (1-based) is a real page in a `count`-page doc. */
|
|
100
|
+
function assertPagesInRange(pages, count) {
|
|
101
|
+
for (const p of pages) {
|
|
102
|
+
if (!Number.isInteger(p) || p < 1 || p > count) {
|
|
103
|
+
throw new Error(`Page ${p} is out of range (document has ${count} page(s))`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Apply one structural page operation to an existing PDF, preserving its
|
|
109
|
+
* content. PDF text is positioned glyphs with no reflow, so there is no
|
|
110
|
+
* reliable text find/replace — these operations act on whole pages instead.
|
|
111
|
+
* Returns the resulting file bytes and a short human-readable summary.
|
|
112
|
+
*/
|
|
113
|
+
export async function editPdf(buf, edit) {
|
|
114
|
+
const doc = await PDFDocument.load(new Uint8Array(buf));
|
|
115
|
+
const count = doc.getPageCount();
|
|
116
|
+
let summary;
|
|
117
|
+
let result = doc;
|
|
118
|
+
switch (edit.op) {
|
|
119
|
+
case "delete_pages": {
|
|
120
|
+
assertPagesInRange(edit.pages, count);
|
|
121
|
+
// Remove from the end so earlier indexes stay valid as we go.
|
|
122
|
+
const toRemove = [...new Set(edit.pages)].sort((a, b) => b - a);
|
|
123
|
+
if (toRemove.length >= count) {
|
|
124
|
+
throw new Error("Cannot delete every page — a PDF must keep at least one page");
|
|
125
|
+
}
|
|
126
|
+
for (const p of toRemove)
|
|
127
|
+
doc.removePage(p - 1);
|
|
128
|
+
summary = `${count} page(s) → ${count - toRemove.length} page(s)`;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "rotate_page": {
|
|
132
|
+
assertPagesInRange([edit.page], count);
|
|
133
|
+
if (edit.degrees % 90 !== 0) {
|
|
134
|
+
throw new Error(`Rotation must be a multiple of 90°, got ${edit.degrees}`);
|
|
135
|
+
}
|
|
136
|
+
const page = doc.getPage(edit.page - 1);
|
|
137
|
+
const next = (((page.getRotation().angle + edit.degrees) % 360) + 360) % 360;
|
|
138
|
+
page.setRotation(degrees(next));
|
|
139
|
+
summary = `Rotated page ${edit.page} to ${next}°`;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "reorder_pages": {
|
|
143
|
+
assertPagesInRange(edit.order, count);
|
|
144
|
+
const unique = new Set(edit.order);
|
|
145
|
+
if (edit.order.length !== count || unique.size !== count) {
|
|
146
|
+
throw new Error(`reorder_pages requires each page 1..${count} exactly once, got [${edit.order.join(", ")}]`);
|
|
147
|
+
}
|
|
148
|
+
result = await PDFDocument.create();
|
|
149
|
+
const copied = await result.copyPages(doc, edit.order.map((p) => p - 1));
|
|
150
|
+
for (const page of copied)
|
|
151
|
+
result.addPage(page);
|
|
152
|
+
summary = `Reordered ${count} pages`;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "extract_pages": {
|
|
156
|
+
assertPagesInRange(edit.pages, count);
|
|
157
|
+
if (edit.pages.length === 0)
|
|
158
|
+
throw new Error("extract_pages requires at least one page");
|
|
159
|
+
result = await PDFDocument.create();
|
|
160
|
+
const copied = await result.copyPages(doc, edit.pages.map((p) => p - 1));
|
|
161
|
+
for (const page of copied)
|
|
162
|
+
result.addPage(page);
|
|
163
|
+
summary = `Extracted ${edit.pages.length} page(s)`;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
default:
|
|
167
|
+
throw new Error(`Unknown PDF op "${edit.op}"`);
|
|
168
|
+
}
|
|
169
|
+
// Match writePdf: plain xref tables keep the bundled reader able to re-read the file.
|
|
170
|
+
const bytes = await result.save({ useObjectStreams: false });
|
|
171
|
+
return { buf: Buffer.from(bytes), summary };
|
|
172
|
+
}
|
|
173
|
+
function wrapText(text, font, size, maxWidth) {
|
|
174
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
175
|
+
const lines = [];
|
|
176
|
+
let current = "";
|
|
177
|
+
for (const word of words) {
|
|
178
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
179
|
+
if (font.widthOfTextAtSize(candidate, size) > maxWidth && current) {
|
|
180
|
+
lines.push(current);
|
|
181
|
+
current = word;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
current = candidate;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (current)
|
|
188
|
+
lines.push(current);
|
|
189
|
+
return lines.length ? lines : [""];
|
|
190
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import JSZip from "jszip";
|
|
3
|
+
// pptxgenjs is CommonJS-only and its bundled types don't resolve cleanly
|
|
4
|
+
// under NodeNext + esModuleInterop (the default import type-checks as the
|
|
5
|
+
// module namespace, not the constructor). Load it via require and type the
|
|
6
|
+
// handful of methods this module actually calls.
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const PptxGenJS = require("pptxgenjs");
|
|
9
|
+
// Matches text runs inside slide XML, e.g. <a:t>Hello</a:t>.
|
|
10
|
+
const TEXT_RUN_RE = /<a:t>([^<]*)<\/a:t>/g;
|
|
11
|
+
export async function readPptx(buf) {
|
|
12
|
+
const zip = await JSZip.loadAsync(buf);
|
|
13
|
+
const slideFiles = Object.keys(zip.files)
|
|
14
|
+
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
|
15
|
+
.sort((a, b) => slideNumber(a) - slideNumber(b));
|
|
16
|
+
const parts = [];
|
|
17
|
+
for (const name of slideFiles) {
|
|
18
|
+
const xml = await zip.files[name].async("text");
|
|
19
|
+
const texts = [...xml.matchAll(TEXT_RUN_RE)].map((m) => decodeXmlEntities(m[1]));
|
|
20
|
+
parts.push(`## Slide ${slideNumber(name)}`);
|
|
21
|
+
parts.push(texts.join("\n"));
|
|
22
|
+
parts.push("");
|
|
23
|
+
}
|
|
24
|
+
return parts.join("\n");
|
|
25
|
+
}
|
|
26
|
+
function slideNumber(name) {
|
|
27
|
+
return Number(name.match(/slide(\d+)\.xml$/)?.[1] ?? 0);
|
|
28
|
+
}
|
|
29
|
+
function decodeXmlEntities(s) {
|
|
30
|
+
return s
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/"/g, '"')
|
|
34
|
+
.replace(/'/g, "'")
|
|
35
|
+
.replace(/&/g, "&");
|
|
36
|
+
}
|
|
37
|
+
// Inches, matching pptxgenjs's default 16:9 layout (10 x 5.625).
|
|
38
|
+
const SLIDE_WIDTH = 10;
|
|
39
|
+
const SLIDE_HEIGHT = 5.625;
|
|
40
|
+
const MARGIN_X = 0.5;
|
|
41
|
+
const MARGIN_TOP = 0.4;
|
|
42
|
+
const MARGIN_BOTTOM = 0.4;
|
|
43
|
+
const TITLE_HEIGHT = 0.9;
|
|
44
|
+
const BODY_WIDTH = SLIDE_WIDTH - MARGIN_X * 2;
|
|
45
|
+
/**
|
|
46
|
+
* Field names models reach for when they mean `bullets`. Anything not listed
|
|
47
|
+
* here used to be dropped without a word, which is how a deck ends up with a
|
|
48
|
+
* title on every slide and nothing else.
|
|
49
|
+
*/
|
|
50
|
+
const BULLET_ALIASES = ["bullets", "content", "points", "body", "items", "lines", "text"];
|
|
51
|
+
const TITLE_ALIASES = ["title", "heading", "header", "name"];
|
|
52
|
+
/** Coerce one model-supplied bullet into display text, or null if there's nothing to show. */
|
|
53
|
+
function toBulletText(value) {
|
|
54
|
+
if (value == null)
|
|
55
|
+
return null;
|
|
56
|
+
if (typeof value === "string")
|
|
57
|
+
return value.trim() || null;
|
|
58
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
59
|
+
return String(value);
|
|
60
|
+
if (typeof value === "object") {
|
|
61
|
+
// Bullets sometimes arrive as {text: "..."} / {label: "..."} run objects.
|
|
62
|
+
for (const key of ["text", "label", "title", "value"]) {
|
|
63
|
+
const inner = value[key];
|
|
64
|
+
if (typeof inner === "string" && inner.trim())
|
|
65
|
+
return inner.trim();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/** Pull a slide's title and bullets out of whatever shape the model sent. */
|
|
71
|
+
function normalizeSlide(slide, index) {
|
|
72
|
+
const raw = (slide ?? {});
|
|
73
|
+
if (typeof raw !== "object") {
|
|
74
|
+
throw new Error(`write_document: slide ${index + 1} is not an object`);
|
|
75
|
+
}
|
|
76
|
+
let title;
|
|
77
|
+
for (const key of TITLE_ALIASES) {
|
|
78
|
+
const v = raw[key];
|
|
79
|
+
if (typeof v === "string" && v.trim()) {
|
|
80
|
+
title = v.trim();
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const bullets = [];
|
|
85
|
+
for (const key of BULLET_ALIASES) {
|
|
86
|
+
if (!(key in raw))
|
|
87
|
+
continue;
|
|
88
|
+
const v = raw[key];
|
|
89
|
+
// `title` may also be the source of `text`; don't repeat it as a bullet.
|
|
90
|
+
const entries = Array.isArray(v) ? v.flat() : [v];
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const text = toBulletText(entry);
|
|
93
|
+
if (text && text !== title)
|
|
94
|
+
bullets.push(text);
|
|
95
|
+
}
|
|
96
|
+
if (bullets.length)
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (!title && bullets.length === 0) {
|
|
100
|
+
const keys = Object.keys(raw);
|
|
101
|
+
throw new Error(`write_document: slide ${index + 1} has no title or bullets` +
|
|
102
|
+
(keys.length ? ` (got keys: ${keys.join(", ")})` : "") +
|
|
103
|
+
`. Each slide needs "title" and/or "bullets" (an array of strings).`);
|
|
104
|
+
}
|
|
105
|
+
return { title, bullets };
|
|
106
|
+
}
|
|
107
|
+
export async function writePptx(slides) {
|
|
108
|
+
const pptx = new PptxGenJS();
|
|
109
|
+
for (const [index, slide] of slides.entries()) {
|
|
110
|
+
const { title, bullets } = normalizeSlide(slide, index);
|
|
111
|
+
const s = pptx.addSlide();
|
|
112
|
+
let y = MARGIN_TOP;
|
|
113
|
+
if (title) {
|
|
114
|
+
// An explicit height matters: with none, pptxgenjs emits cy="0" and
|
|
115
|
+
// renderers other than PowerPoint clip the title away entirely.
|
|
116
|
+
s.addText(title, {
|
|
117
|
+
x: MARGIN_X,
|
|
118
|
+
y,
|
|
119
|
+
w: BODY_WIDTH,
|
|
120
|
+
h: TITLE_HEIGHT,
|
|
121
|
+
valign: "top",
|
|
122
|
+
fontSize: 28,
|
|
123
|
+
bold: true,
|
|
124
|
+
shrinkText: true,
|
|
125
|
+
});
|
|
126
|
+
y += TITLE_HEIGHT + 0.1;
|
|
127
|
+
}
|
|
128
|
+
if (bullets.length) {
|
|
129
|
+
// Claim the rest of the slide rather than a fixed 60% of its height, and
|
|
130
|
+
// let PowerPoint shrink the text so a long list stays on the slide.
|
|
131
|
+
s.addText(bullets.map((text) => ({ text, options: { bullet: true, breakLine: true } })), {
|
|
132
|
+
x: MARGIN_X,
|
|
133
|
+
y,
|
|
134
|
+
w: BODY_WIDTH,
|
|
135
|
+
h: SLIDE_HEIGHT - y - MARGIN_BOTTOM,
|
|
136
|
+
valign: "top",
|
|
137
|
+
fontSize: 18,
|
|
138
|
+
shrinkText: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const notes = slide?.notes;
|
|
142
|
+
if (typeof notes === "string" && notes.trim()) {
|
|
143
|
+
s.addNotes(notes);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const data = await pptx.write({ outputType: "nodebuffer" });
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const BLOCK_TYPES = [
|
|
2
|
+
"heading1",
|
|
3
|
+
"heading2",
|
|
4
|
+
"heading3",
|
|
5
|
+
"paragraph",
|
|
6
|
+
"bullet",
|
|
7
|
+
"numbered",
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Check every block before any writer indexes a style table with `block.type`.
|
|
11
|
+
* Models do stray outside the schema enum, and without this the docx writer
|
|
12
|
+
* silently demotes the block to a plain paragraph while the pdf writer dies
|
|
13
|
+
* with an unrelated "cannot read properties of undefined".
|
|
14
|
+
*/
|
|
15
|
+
export function validateBlocks(blocks) {
|
|
16
|
+
return blocks.map((block, i) => {
|
|
17
|
+
if (!block || typeof block !== "object") {
|
|
18
|
+
throw new Error(`write_document: block ${i + 1} is not an object`);
|
|
19
|
+
}
|
|
20
|
+
if (!BLOCK_TYPES.includes(block.type)) {
|
|
21
|
+
throw new Error(`write_document: block ${i + 1} has unknown type "${block.type}". ` +
|
|
22
|
+
`Valid types: ${BLOCK_TYPES.join(", ")}`);
|
|
23
|
+
}
|
|
24
|
+
return { type: block.type, text: block.text == null ? "" : String(block.text) };
|
|
25
|
+
});
|
|
26
|
+
}
|