@youweichen/pi-harness 1.0.0 → 1.2.0
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 +1 -1
- package/README.md +14 -0
- package/dist/server/agent-service.js +87 -7
- package/dist/server/control-socket.js +1 -0
- package/dist/server/document-conversion/extension.js +164 -0
- package/dist/server/document-conversion/python/bridge.py +424 -0
- package/dist/server/document-conversion/python/fixtures.py +201 -0
- package/dist/server/document-conversion/python/native_code.py +149 -0
- package/dist/server/document-conversion/runtime.js +321 -0
- package/dist/server/document-conversion/service.js +340 -0
- package/dist/server/document-conversion/settings.js +63 -0
- package/dist/server/extensions-worker.js +3 -2
- package/dist/server/files-service.js +8 -16
- package/dist/server/index.js +9 -3
- package/dist/server/native-tools.js +4 -0
- package/dist/server/node-agent.js +262 -0
- package/dist/server/node-workbench.js +62 -1
- package/dist/server/okf/extension.js +118 -0
- package/dist/server/okf/markdown-sources.js +58 -0
- package/dist/server/okf/service.js +713 -0
- package/dist/server/okf/storage.js +165 -0
- package/dist/server/okf/types.js +1 -0
- package/dist/server/plan/extension.js +4 -4
- package/dist/server/plan/state.js +11 -1
- package/dist/server/slash-commands.js +6 -0
- package/dist/server/wiki-links.js +24 -0
- package/dist/server/wiki-routes.js +8 -1
- package/dist/server/wiki-service.js +50 -5
- package/package.json +2 -2
- package/web/dist/assets/NodeWorkbench-DM6YTS5e.js +3 -0
- package/web/dist/assets/{TerminalPanel-CWh6vw_N.js → TerminalPanel-aFJylyxg.js} +1 -1
- package/web/dist/assets/{abnfDiagram-VCTEODGH-D5Ti0Kpa.js → abnfDiagram-VCTEODGH-CcM7f7oX.js} +1 -1
- package/web/dist/assets/{arc-DKJilLd6.js → arc-Bv7vS5l5.js} +1 -1
- package/web/dist/assets/{architectureDiagram-5GKGNRK7-Cf0w38g-.js → architectureDiagram-5GKGNRK7-Cl8QcB3w.js} +1 -1
- package/web/dist/assets/{blockDiagram-I7D4REHJ-BOTLWLIL.js → blockDiagram-I7D4REHJ-DarS2-4G.js} +1 -1
- package/web/dist/assets/{c4Diagram-7LVT6UL2-CLCCoJs3.js → c4Diagram-7LVT6UL2-BY0_ttBt.js} +1 -1
- package/web/dist/assets/channel-DJGMgVTh.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-BHjga2Ge.js → chunk-2Q5K7J3B-Cz-zS7_X.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-CQJ5VfDZ.js → chunk-5VM5RSS4-B7AyqkmI.js} +1 -1
- package/web/dist/assets/{chunk-F27PBJKO-C67qoPJH.js → chunk-F27PBJKO-B7hVduzL.js} +1 -1
- package/web/dist/assets/{chunk-IMKFNOWR-CgXXGwI8.js → chunk-IMKFNOWR-DNGJqYn5.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-D0RPyByP.js → chunk-JWPE2WC7-B5iJgXvl.js} +1 -1
- package/web/dist/assets/{chunk-POPQ4Y6H-BeYC9s9x.js → chunk-POPQ4Y6H-BvWCkriR.js} +1 -1
- package/web/dist/assets/{chunk-SVP7TREG-eQPl4UBQ.js → chunk-SVP7TREG-BcdlBuOQ.js} +1 -1
- package/web/dist/assets/{chunk-TICWLB2K-DT73arPU.js → chunk-TICWLB2K-B1igqjQd.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-B4FwQcP-.js → chunk-XXDRQBXY-DKGDquaw.js} +1 -1
- package/web/dist/assets/classDiagram-ZZMXUADV-D9nxgF0M.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-D9nxgF0M.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-Bnn5nlQN.js → cose-bilkent-JH36ORCC-BU_9nu88.js} +1 -1
- package/web/dist/assets/{cynefin-OW5HDTMX-CStWX9kE.js → cynefin-OW5HDTMX-DY0BDLjc.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-5FMLGOSQ-BMVHC03f.js → cynefinDiagram-5FMLGOSQ-B-zM25Fk.js} +1 -1
- package/web/dist/assets/{dagre-GXQ25YYZ-ClSaO4ml.js → dagre-GXQ25YYZ-BxooDyBc.js} +1 -1
- package/web/dist/assets/{diagram-S7CK7UJ4-C43LL08C.js → diagram-S7CK7UJ4-CCO-8de9.js} +1 -1
- package/web/dist/assets/{diagram-UQ7AKVKN-BPVn_ySW.js → diagram-UQ7AKVKN-CmGsH525.js} +1 -1
- package/web/dist/assets/{diagram-VSXAHHWV-C34FFhRi.js → diagram-VSXAHHWV-CSQJYo3m.js} +1 -1
- package/web/dist/assets/{diagram-VX7I27RA-BRk4bncf.js → diagram-VX7I27RA-CwuJsF66.js} +1 -1
- package/web/dist/assets/{diagram-Z3DM3KII-O5hYPLEd.js → diagram-Z3DM3KII-BF13T_lj.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-PWID7BFC-DVGEBm99.js → ebnfDiagram-PWID7BFC-ZtoDLro6.js} +1 -1
- package/web/dist/assets/{erDiagram-RLTQ6QDP-M-BLpTEV.js → erDiagram-RLTQ6QDP-BBo-qmNH.js} +1 -1
- package/web/dist/assets/{flowDiagram-HODETNUW-BwdLopEx.js → flowDiagram-HODETNUW-DAq9qdP8.js} +1 -1
- package/web/dist/assets/{ganttDiagram-EL5Y4UJY-DYxx2ppA.js → ganttDiagram-EL5Y4UJY-C53eRlMu.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-WWUBYQGX-DUU0wMYB.js → gitGraphDiagram-WWUBYQGX-BviBMJxj.js} +1 -1
- package/web/dist/assets/index-Bab44wX8.js +276 -0
- package/web/dist/assets/index-C3vDFSEV.css +10 -0
- package/web/dist/assets/{infoDiagram-27XIBGKW-CFaW5C0C.js → infoDiagram-27XIBGKW-DI95SO3j.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-5VMMS53U--ENP1MTB.js → ishikawaDiagram-5VMMS53U-Cl8IDXwf.js} +1 -1
- package/web/dist/assets/{journeyDiagram-3NMN7TZE-DgnAzSTj.js → journeyDiagram-3NMN7TZE-Co6aJeTZ.js} +1 -1
- package/web/dist/assets/{kanban-definition-UXKFOSKX-_STZN1vU.js → kanban-definition-UXKFOSKX-CknQQpwW.js} +1 -1
- package/web/dist/assets/{layout-CkUClkXt.js → layout-CFtUmtTW.js} +1 -1
- package/web/dist/assets/{linear-sYHiUOxa.js → linear-mRlGUAKy.js} +1 -1
- package/web/dist/assets/{markdown-vNTCZJ6d.js → markdown-BhJ_LInD.js} +1 -1
- package/web/dist/assets/{mermaid.core-B8fKGWsc.js → mermaid.core-CvQEO3ue.js} +5 -5
- package/web/dist/assets/{mindmap-definition-YA3MSWOX-DP0BYOEx.js → mindmap-definition-YA3MSWOX-DpqhsxUl.js} +1 -1
- package/web/dist/assets/{pegDiagram-XKGWAZYB-DkWTmZre.js → pegDiagram-XKGWAZYB-C9eawB5u.js} +1 -1
- package/web/dist/assets/{pieDiagram-E7YTZNPT-DfZgMV4u.js → pieDiagram-E7YTZNPT-8vaqXVD6.js} +1 -1
- package/web/dist/assets/{quadrantDiagram-AXDQQJYC-Djl4TT7q.js → quadrantDiagram-AXDQQJYC-Diwr8ynm.js} +1 -1
- package/web/dist/assets/{railroadDiagram-O6MQD6OU-DbKF6zXn.js → railroadDiagram-O6MQD6OU-krW50Cgi.js} +1 -1
- package/web/dist/assets/{react-BNj3n7jX.js → react-BddUxV-9.js} +1 -1
- package/web/dist/assets/{requirementDiagram-BXWQKSXE-idola5qb.js → requirementDiagram-BXWQKSXE-BPLI6eMG.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-P5KCCOFB-KA0hQOle.js → sankeyDiagram-P5KCCOFB-Bix_elmH.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-WJ2MYXX4-BXSKvxzP.js → sequenceDiagram-WJ2MYXX4-0m0xaUKi.js} +1 -1
- package/web/dist/assets/{sizeCapture-INFHLROL-C_Ewv3S5.js → sizeCapture-INFHLROL-BtzJ_oOR.js} +1 -1
- package/web/dist/assets/{stateDiagram-D77RDMKH-DIf5X8oo.js → stateDiagram-D77RDMKH-Clq5bN0v.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-Bn1oW4gp.js +1 -0
- package/web/dist/assets/{swimlanes-42K2YHIH-Cx_Rc8M2.js → swimlanes-42K2YHIH-11MDGnYA.js} +1 -1
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-BW96Edgo.js +8 -0
- package/web/dist/assets/{timeline-definition-24CTP7MA-BRrDhWVz.js → timeline-definition-24CTP7MA-BeQfWP95.js} +1 -1
- package/web/dist/assets/{vennDiagram-4TSXK5OY-Ad6G0YwD.js → vennDiagram-4TSXK5OY-BMYkX0KL.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-VM6X3IG4-X6-ENpSi.js → wardleyDiagram-VM6X3IG4-BV3fTUkU.js} +1 -1
- package/web/dist/assets/{xychartDiagram-S5SC5T6Z-DhegNS6d.js → xychartDiagram-S5SC5T6Z-Ct7ezLnc.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/dist/assets/NodeWorkbench-C4U1PldE.js +0 -3
- package/web/dist/assets/channel-D8Hd7T4P.js +0 -1
- package/web/dist/assets/classDiagram-ZZMXUADV-B_btgYRB.js +0 -1
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-B_btgYRB.js +0 -1
- package/web/dist/assets/index-CI2KfVSr.css +0 -10
- package/web/dist/assets/index-DQcpggFU.js +0 -275
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DYwe_2mB.js +0 -1
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-52Y8tgRV.js +0 -8
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readFile, writeFile, stat, mkdir, rm, rename, access, lstat, readdir, cp, copyFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, extname, join, resolve, isAbsolute } from "node:path";
|
|
5
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { unified } from "unified";
|
|
7
|
+
import remarkParse from "remark-parse";
|
|
8
|
+
import lockfile from "proper-lockfile";
|
|
9
|
+
import { documentDataDir } from "./settings.js";
|
|
10
|
+
import { decodeText } from "../text-sniff.js";
|
|
11
|
+
import { CONVERSION_PROFILE, ensureRuntimeReady, runConversionWorker, runtimeFingerprint, waitDocumentQueue } from "./runtime.js";
|
|
12
|
+
export const DOCUMENT_EXTENSIONS = new Set([".pdf", ".md", ".markdown", ".txt", ".docx", ".xlsx", ".pptx"]);
|
|
13
|
+
const locks = new Map();
|
|
14
|
+
async function hashFile(path, signal) {
|
|
15
|
+
const hash = createHash("sha256"), stream = createReadStream(path, { signal });
|
|
16
|
+
for await (const chunk of stream)
|
|
17
|
+
hash.update(chunk);
|
|
18
|
+
return hash.digest("hex");
|
|
19
|
+
}
|
|
20
|
+
function digest(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
21
|
+
function markdownImageNodes(text) {
|
|
22
|
+
const tree = unified().use(remarkParse).parse(text);
|
|
23
|
+
const nodes = [], references = new Set();
|
|
24
|
+
const visit = (node) => { nodes.push(node); if (node.type === "imageReference" && node.identifier)
|
|
25
|
+
references.add(node.identifier); node.children?.forEach(visit); };
|
|
26
|
+
visit(tree);
|
|
27
|
+
return nodes.filter(node => node.type === "image" || (node.type === "definition" && node.identifier && references.has(node.identifier)));
|
|
28
|
+
}
|
|
29
|
+
function imageSource(url, source, assets) {
|
|
30
|
+
if (assets !== undefined) {
|
|
31
|
+
if (!Object.hasOwn(assets, url) || !assets[url])
|
|
32
|
+
throw new Error("Image is not present in the archived evidence snapshot");
|
|
33
|
+
return assets[url];
|
|
34
|
+
}
|
|
35
|
+
return resolve(dirname(source), decodeURIComponent(url.split("#")[0]));
|
|
36
|
+
}
|
|
37
|
+
async function markdownDependencyFingerprint(text, source, assets) {
|
|
38
|
+
const dependencies = [];
|
|
39
|
+
for (const node of markdownImageNodes(text)) {
|
|
40
|
+
const url = node.url ?? "";
|
|
41
|
+
if (!url || /^(?:[a-z]+:|#)/i.test(url) || isAbsolute(url))
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const path = imageSource(url, source, assets), info = await stat(path);
|
|
45
|
+
if (!info.isFile() || info.size > 20 * 1024 * 1024)
|
|
46
|
+
throw new Error("Unsupported asset");
|
|
47
|
+
dependencies.push(`${url}:${await hashFile(path)}`);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
dependencies.push(`${url}:missing`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return digest(JSON.stringify(dependencies));
|
|
54
|
+
}
|
|
55
|
+
async function preserveMarkdownImages(text, source, output, warnings, assets) {
|
|
56
|
+
const replacements = [];
|
|
57
|
+
for (const node of markdownImageNodes(text)) {
|
|
58
|
+
const url = node.url ?? "";
|
|
59
|
+
if (!url || /^(?:https?:|data:|#)/i.test(url))
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
if (isAbsolute(url) || /^[a-z]+:/i.test(url))
|
|
63
|
+
throw new Error("absolute image paths are not portable");
|
|
64
|
+
const local = imageSource(url, source, assets);
|
|
65
|
+
if (![".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".bmp", ".tiff"].includes(extname(local).toLowerCase()))
|
|
66
|
+
throw new Error("unsupported image format");
|
|
67
|
+
const info = await stat(local);
|
|
68
|
+
if (!info.isFile() || info.size > 20 * 1024 * 1024)
|
|
69
|
+
throw new Error("image is not a regular file below 20 MiB");
|
|
70
|
+
const target = `assets/${(await hashFile(local)).slice(0, 24)}${extname(local).toLowerCase()}`;
|
|
71
|
+
await copyFile(local, join(output, target));
|
|
72
|
+
const start = node.position?.start.offset, end = node.position?.end.offset;
|
|
73
|
+
if (start !== undefined && end !== undefined)
|
|
74
|
+
replacements.push({ start, end, text: node.type === "definition" ? `[${node.identifier}]: ${target}${node.title ? ` ${JSON.stringify(node.title)}` : ""}` : `![${(node.alt ?? "").replace(/]/g, "\\]")}](${target}${node.title ? ` ${JSON.stringify(node.title)}` : ""})` });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
warnings.push(`Markdown image was not copied: ${url}: ${error instanceof Error ? error.message : error}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const replacement of replacements.sort((a, b) => b.start - a.start))
|
|
81
|
+
text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end);
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
export function textBlocks(text) {
|
|
85
|
+
const lines = text.split("\n"), blocks = [];
|
|
86
|
+
let start = 0, heading, fence;
|
|
87
|
+
const emit = (end) => {
|
|
88
|
+
const value = lines.slice(start, end).join("\n");
|
|
89
|
+
if (value.trim())
|
|
90
|
+
blocks.push({ id: `block-${blocks.length + 1}`, text: value, locator: { lineStart: start + 1, lineEnd: end, ...(heading ? { heading } : {}) } });
|
|
91
|
+
start = end;
|
|
92
|
+
};
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const marker = lines[i].match(/^\s*(`{3,}|~{3,})/);
|
|
95
|
+
if (marker) {
|
|
96
|
+
if (!fence)
|
|
97
|
+
fence = marker[1];
|
|
98
|
+
else if (marker[1][0] === fence[0] && marker[1].length >= fence.length)
|
|
99
|
+
fence = undefined;
|
|
100
|
+
}
|
|
101
|
+
if (!fence && /^#{1,6}\s+/.test(lines[i])) {
|
|
102
|
+
emit(i);
|
|
103
|
+
heading = lines[i].replace(/^#{1,6}\s+/, "");
|
|
104
|
+
}
|
|
105
|
+
if (!fence && !lines[i].trim()) {
|
|
106
|
+
emit(i);
|
|
107
|
+
start = i + 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
emit(lines.length);
|
|
111
|
+
return blocks;
|
|
112
|
+
}
|
|
113
|
+
export async function convertDocument(options) {
|
|
114
|
+
const key = resolve(options.outputDir), prior = locks.get(key) ?? Promise.resolve();
|
|
115
|
+
let release;
|
|
116
|
+
const lock = new Promise(done => { release = done; });
|
|
117
|
+
const chain = prior.then(() => lock);
|
|
118
|
+
locks.set(key, chain);
|
|
119
|
+
try {
|
|
120
|
+
await waitDocumentQueue(prior, options.signal);
|
|
121
|
+
options.signal?.throwIfAborted();
|
|
122
|
+
return await convert({ ...options, outputDir: key });
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
release();
|
|
126
|
+
if (locks.get(key) === chain)
|
|
127
|
+
locks.delete(key);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function artifactFiles(root, prefix = "") {
|
|
131
|
+
const paths = [];
|
|
132
|
+
for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
|
|
133
|
+
const path = join(prefix, entry.name);
|
|
134
|
+
if (entry.isSymbolicLink())
|
|
135
|
+
throw new Error("Parser artifacts must not contain symlinks");
|
|
136
|
+
if (entry.isDirectory())
|
|
137
|
+
paths.push(...await artifactFiles(root, path));
|
|
138
|
+
else if (entry.isFile() && path !== "manifest.json")
|
|
139
|
+
paths.push(path);
|
|
140
|
+
}
|
|
141
|
+
return paths;
|
|
142
|
+
}
|
|
143
|
+
async function loadResult(root, manifest) {
|
|
144
|
+
const sourceMap = JSON.parse(await readFile(join(root, "source-map.json"), "utf8"));
|
|
145
|
+
return { markdownPath: join(root, "document.md"), structurePath: join(root, "structure.json"), sourceMapPath: join(root, "source-map.json"), assetsDir: join(root, "assets"), sourceHash: manifest.sourceHash, parserVersion: manifest.parserVersion, warnings: manifest.warnings, status: manifest.status, blocks: sourceMap.blocks };
|
|
146
|
+
}
|
|
147
|
+
async function existingManifest(root, signal) {
|
|
148
|
+
try {
|
|
149
|
+
await access(root);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code === "ENOENT")
|
|
153
|
+
return undefined;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if ((await lstat(root)).isSymbolicLink())
|
|
157
|
+
throw new Error("Output directory must not be a symlink");
|
|
158
|
+
if ((await readdir(root)).length === 0)
|
|
159
|
+
return undefined;
|
|
160
|
+
let manifest;
|
|
161
|
+
try {
|
|
162
|
+
manifest = JSON.parse(await readFile(join(root, "manifest.json"), "utf8"));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
throw new Error("Output directory contains existing files without a conversion manifest; choose another directory");
|
|
166
|
+
}
|
|
167
|
+
if (manifest.schemaVersion !== 1 || !manifest.files || typeof manifest.files !== "object")
|
|
168
|
+
throw new Error("Invalid existing conversion manifest");
|
|
169
|
+
const actual = await artifactFiles(root);
|
|
170
|
+
if (actual.length !== Object.keys(manifest.files).length)
|
|
171
|
+
throw new Error("Conversion output has added or removed files; choose another directory to preserve changes");
|
|
172
|
+
for (const path of actual) {
|
|
173
|
+
if (!manifest.files[path] || await hashFile(join(root, path), signal) !== manifest.files[path])
|
|
174
|
+
throw new Error(`Conversion output was edited: ${path}. Choose another directory to preserve changes.`);
|
|
175
|
+
}
|
|
176
|
+
for (const required of ["document.md", "structure.json", "source-map.json"])
|
|
177
|
+
if (!manifest.files[required])
|
|
178
|
+
throw new Error("Incomplete conversion manifest");
|
|
179
|
+
return manifest;
|
|
180
|
+
}
|
|
181
|
+
async function destinationLock(root, signal) {
|
|
182
|
+
for (;;) {
|
|
183
|
+
signal?.throwIfAborted();
|
|
184
|
+
try {
|
|
185
|
+
return await lockfile.lock(root, { realpath: false, lockfilePath: `${root}.conversion-lock`, stale: 120_000, retries: 0 });
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error.code !== "ELOCKED")
|
|
189
|
+
throw error;
|
|
190
|
+
await new Promise(done => setTimeout(done, 100));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function withArtifactMutationQueues(root, paths, operation) {
|
|
195
|
+
const ordered = [...new Set(paths)].sort();
|
|
196
|
+
const acquire = (index) => index === ordered.length ? operation() : withFileMutationQueue(join(root, ordered[index]), () => acquire(index + 1));
|
|
197
|
+
return acquire(0);
|
|
198
|
+
}
|
|
199
|
+
async function convert(options) {
|
|
200
|
+
const { outputDir, signal, onProgress } = options, inputPath = resolve(options.inputPath), suffix = extname(inputPath).toLowerCase();
|
|
201
|
+
if (!DOCUMENT_EXTENSIONS.has(suffix))
|
|
202
|
+
throw new Error(`Unsupported document format: ${suffix}`);
|
|
203
|
+
if (inputPath === outputDir || inputPath.startsWith(outputDir + "/") || inputPath.startsWith(outputDir + "\\"))
|
|
204
|
+
throw new Error("Output directory must not contain the source document");
|
|
205
|
+
const info = await stat(inputPath);
|
|
206
|
+
if (!info.isFile() || info.size > 100 * 1024 * 1024)
|
|
207
|
+
throw new Error("Document must be a file no larger than 100 MiB");
|
|
208
|
+
const sourceHash = await hashFile(inputPath, signal), plain = [".md", ".markdown", ".txt"].includes(suffix);
|
|
209
|
+
let parserVersion = "text-v1", fingerprint = "text-v1", markdownDependencies;
|
|
210
|
+
if (suffix === ".md" || suffix === ".markdown") {
|
|
211
|
+
if (info.size > 16 * 1024 * 1024)
|
|
212
|
+
throw new Error("Text documents are limited to 16 MiB");
|
|
213
|
+
markdownDependencies = await markdownDependencyFingerprint(decodeText(await readFile(inputPath)), inputPath, options.markdownAssets);
|
|
214
|
+
fingerprint += markdownDependencies;
|
|
215
|
+
}
|
|
216
|
+
if (!plain) {
|
|
217
|
+
const doctor = await ensureRuntimeReady({ signal, onProgress });
|
|
218
|
+
if (!doctor.ready)
|
|
219
|
+
throw new Error(`Local document runtime is not ready. Run /pdf-md setup. ${doctor.missing.join("; ")}`);
|
|
220
|
+
parserVersion = `docling-${doctor.parserVersion}`;
|
|
221
|
+
fingerprint = await runtimeFingerprint();
|
|
222
|
+
}
|
|
223
|
+
const cacheKey = digest(JSON.stringify({ sourceHash, suffix, profile: CONVERSION_PROFILE, fingerprint }));
|
|
224
|
+
await mkdir(dirname(outputDir), { recursive: true });
|
|
225
|
+
const unlock = await destinationLock(outputDir, signal);
|
|
226
|
+
try {
|
|
227
|
+
const existing = await existingManifest(outputDir, signal);
|
|
228
|
+
if (existing?.cacheKey === cacheKey && existing.status === "complete") {
|
|
229
|
+
const priorSource = JSON.parse(await readFile(join(outputDir, "source-map.json"), "utf8")).source;
|
|
230
|
+
if (priorSource === inputPath) {
|
|
231
|
+
onProgress?.("Using unchanged document conversion");
|
|
232
|
+
return await loadResult(outputDir, existing);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const cacheDir = join(documentDataDir(), "document-cache", cacheKey);
|
|
236
|
+
const staging = join(dirname(outputDir), `.${basename(outputDir)}-${randomUUID()}.partial`), backup = `${staging}.previous`;
|
|
237
|
+
await mkdir(join(staging, "assets"), { recursive: true });
|
|
238
|
+
let installed = false, backedUp = false;
|
|
239
|
+
try {
|
|
240
|
+
let warnings = [], status = "complete";
|
|
241
|
+
let reused;
|
|
242
|
+
try {
|
|
243
|
+
reused = await existingManifest(cacheDir, signal);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
signal?.throwIfAborted();
|
|
247
|
+
}
|
|
248
|
+
if (reused?.cacheKey === cacheKey && reused.status === "complete") {
|
|
249
|
+
await cp(cacheDir, staging, { recursive: true });
|
|
250
|
+
warnings = reused.warnings;
|
|
251
|
+
status = reused.status;
|
|
252
|
+
const sourceMap = JSON.parse(await readFile(join(staging, "source-map.json"), "utf8"));
|
|
253
|
+
sourceMap.source = inputPath;
|
|
254
|
+
await writeFile(join(staging, "source-map.json"), JSON.stringify(sourceMap, null, 2));
|
|
255
|
+
const structure = JSON.parse(await readFile(join(staging, "structure.json"), "utf8"));
|
|
256
|
+
if (structure.origin && typeof structure.origin === "object")
|
|
257
|
+
structure.origin.filename = basename(inputPath);
|
|
258
|
+
await writeFile(join(staging, "structure.json"), JSON.stringify(structure, null, 2));
|
|
259
|
+
onProgress?.("Reusing the local document content cache");
|
|
260
|
+
}
|
|
261
|
+
else if (plain) {
|
|
262
|
+
if (info.size > 16 * 1024 * 1024)
|
|
263
|
+
throw new Error("Text documents are limited to 16 MiB");
|
|
264
|
+
const data = await readFile(inputPath);
|
|
265
|
+
try {
|
|
266
|
+
new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
warnings.push("Source is not UTF-8; decoded using the legacy text encoding fallback. Verify non-ASCII text.");
|
|
270
|
+
}
|
|
271
|
+
const text = decodeText(data).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n"), blocks = textBlocks(text);
|
|
272
|
+
const markdown = suffix === ".txt" ? text : await preserveMarkdownImages(text, inputPath, staging, warnings, options.markdownAssets);
|
|
273
|
+
if (warnings.some(warning => warning.startsWith("Markdown image was not copied")))
|
|
274
|
+
status = "partial";
|
|
275
|
+
await writeFile(join(staging, "document.md"), markdown.endsWith("\n") ? markdown : markdown + "\n");
|
|
276
|
+
await writeFile(join(staging, "structure.json"), JSON.stringify({ schemaVersion: 1, kind: "text", blocks }, null, 2));
|
|
277
|
+
await writeFile(join(staging, "source-map.json"), JSON.stringify({ schemaVersion: 1, source: inputPath, sourceHash, blocks }, null, 2));
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
onProgress?.(`Converting ${basename(inputPath)} locally`);
|
|
281
|
+
const response = await runConversionWorker({ inputPath, outputDir: staging, sourceHash }, { signal, onProgress });
|
|
282
|
+
if (response.status !== "complete" && response.status !== "partial")
|
|
283
|
+
throw new Error("Invalid parser completion status");
|
|
284
|
+
status = response.status;
|
|
285
|
+
warnings = response.warnings ?? [];
|
|
286
|
+
}
|
|
287
|
+
if (await hashFile(inputPath, signal) !== sourceHash)
|
|
288
|
+
throw new Error("Source changed during conversion; retry with the current document");
|
|
289
|
+
if (markdownDependencies !== undefined && await markdownDependencyFingerprint(decodeText(await readFile(inputPath)), inputPath, options.markdownAssets) !== markdownDependencies)
|
|
290
|
+
throw new Error("Markdown images changed during conversion; retry with the current document");
|
|
291
|
+
for (const path of ["document.md", "structure.json", "source-map.json"])
|
|
292
|
+
await access(join(staging, path));
|
|
293
|
+
const files = {};
|
|
294
|
+
for (const path of await artifactFiles(staging))
|
|
295
|
+
files[path] = await hashFile(join(staging, path), signal);
|
|
296
|
+
const manifest = { schemaVersion: 1, cacheKey, sourceHash, parserVersion, status, warnings, files };
|
|
297
|
+
await writeFile(join(staging, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
298
|
+
signal?.throwIfAborted();
|
|
299
|
+
onProgress?.("Committing verified document artifacts");
|
|
300
|
+
await withArtifactMutationQueues(outputDir, ["manifest.json", ...Object.keys(existing?.files ?? {}), ...Object.keys(files)], async () => {
|
|
301
|
+
// Revalidate after the slow parser run, catching edits made during OCR.
|
|
302
|
+
await existingManifest(outputDir, signal);
|
|
303
|
+
try {
|
|
304
|
+
await access(outputDir);
|
|
305
|
+
await rename(outputDir, backup);
|
|
306
|
+
backedUp = true;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (error.code !== "ENOENT")
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
await rename(staging, outputDir);
|
|
313
|
+
installed = true;
|
|
314
|
+
});
|
|
315
|
+
if (status === "complete") {
|
|
316
|
+
await mkdir(dirname(cacheDir), { recursive: true });
|
|
317
|
+
const temporaryCache = `${cacheDir}.${randomUUID()}.partial`;
|
|
318
|
+
try {
|
|
319
|
+
await cp(outputDir, temporaryCache, { recursive: true });
|
|
320
|
+
await rename(temporaryCache, cacheDir);
|
|
321
|
+
}
|
|
322
|
+
catch { /* Cache races or cache I/O failures never invalidate the output. */ }
|
|
323
|
+
finally {
|
|
324
|
+
await rm(temporaryCache, { recursive: true, force: true });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return await loadResult(outputDir, manifest);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
if (!installed && backedUp)
|
|
331
|
+
await rename(backup, outputDir);
|
|
332
|
+
await rm(staging, { recursive: true, force: true });
|
|
333
|
+
if (installed)
|
|
334
|
+
await rm(backup, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await unlock();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
export function documentDataDir() {
|
|
6
|
+
return resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
7
|
+
}
|
|
8
|
+
function readSettings() {
|
|
9
|
+
try {
|
|
10
|
+
const value = JSON.parse(readFileSync(join(documentDataDir(), "document-extensions.json"), "utf8"));
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
12
|
+
throw new Error("Expected an object");
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === "ENOENT")
|
|
17
|
+
return {};
|
|
18
|
+
throw new Error(`Cannot read document-extensions.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function validate(value) {
|
|
22
|
+
const result = { pdfEnabled: true, okfEnabled: true };
|
|
23
|
+
for (const key of ["pdfEnabled", "okfEnabled"]) {
|
|
24
|
+
if (value[key] !== undefined) {
|
|
25
|
+
if (typeof value[key] !== "boolean")
|
|
26
|
+
throw new Error(`${key} must be a boolean`);
|
|
27
|
+
result[key] = value[key];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const key of ["pythonPath", "runtimePath"]) {
|
|
31
|
+
const path = value[key];
|
|
32
|
+
if (path === undefined || path === "")
|
|
33
|
+
continue;
|
|
34
|
+
if (typeof path !== "string" || !isAbsolute(path) || /[\0\r\n]/.test(path))
|
|
35
|
+
throw new Error(`${key} must be an absolute filesystem path`);
|
|
36
|
+
result[key] = resolve(path);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
/** Read on every invocation so already loaded sessions honor a global disable. */
|
|
41
|
+
export function getDocumentSettings() {
|
|
42
|
+
return validate(readSettings());
|
|
43
|
+
}
|
|
44
|
+
export function updateDocumentSettings(patch) {
|
|
45
|
+
const saved = readSettings();
|
|
46
|
+
const next = validate({ ...saved, ...patch });
|
|
47
|
+
const output = { ...saved, ...next };
|
|
48
|
+
for (const key of ["pythonPath", "runtimePath"])
|
|
49
|
+
if (!next[key])
|
|
50
|
+
delete output[key];
|
|
51
|
+
const folder = documentDataDir();
|
|
52
|
+
mkdirSync(folder, { recursive: true });
|
|
53
|
+
const target = join(folder, "document-extensions.json");
|
|
54
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(temporary, JSON.stringify(output, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
57
|
+
renameSync(temporary, target);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
rmSync(temporary, { force: true });
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DefaultPackageManager, SettingsManager, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { readJson, textField, resourceTypes, sourceOf, sourceInfo, packageDisabled, settingsVersion } from "./extensions-model.js";
|
|
6
|
+
import { extensionDisplay } from "./extension-display.js";
|
|
6
7
|
import { gitOutput, latestNpmVersion } from "./component-updates.js";
|
|
7
8
|
const blankResources = () => ({ extensions: [], skills: [], prompts: [], themes: [] });
|
|
8
9
|
export async function extensionWork(input, progress = () => { }) {
|
|
@@ -55,7 +56,7 @@ export async function extensionWork(input, progress = () => { }) {
|
|
|
55
56
|
for (const resource of resources.extensions) {
|
|
56
57
|
if (resource.metadata.origin !== "top-level" || resource.path.startsWith("builtin:") || resource.metadata.scope === "temporary")
|
|
57
58
|
continue;
|
|
58
|
-
result.push({ id: `file:${resource.metadata.scope}:${resource.path}`, source: resource.path,
|
|
59
|
+
result.push({ id: `file:${resource.metadata.scope}:${resource.path}`, source: resource.path, ...extensionDisplay(resource.path), scope: resource.metadata.scope, kind: "file", path: resource.path, enabled: resource.enabled, pinned: false, trusted: resource.metadata.scope === "user" || trusted, resources: blankResources() });
|
|
59
60
|
}
|
|
60
61
|
return { packages: result, version: settingsVersion(cwd, agentDir), trusted };
|
|
61
62
|
}
|
|
@@ -117,7 +117,7 @@ export function isRealPathInWorkspace(root, abs) {
|
|
|
117
117
|
* win32: stability and completeness first, preview second. ACL-protected
|
|
118
118
|
* system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
|
|
119
119
|
* throw EPERM/EACCES on open — that must not kill the panel, so it degrades
|
|
120
|
-
* to an empty listing
|
|
120
|
+
* to an empty listing with a node-local error. Directory symlinks/junctions are
|
|
121
121
|
* followed so mklink /D folders stay navigable; broken links still show as
|
|
122
122
|
* files instead of vanishing. The cap is 4x posix and truncation is reported
|
|
123
123
|
* via `truncated` instead of happening silently.
|
|
@@ -132,11 +132,11 @@ async function readDirForUI(abs, rel) {
|
|
|
132
132
|
dirents = await fs.readdir(abs, { withFileTypes: true });
|
|
133
133
|
}
|
|
134
134
|
catch (err) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
const code = err.code;
|
|
136
|
+
return { entries: [], truncated: false, error: {
|
|
137
|
+
code: code === "ENOENT" ? "missing" : code === "ENOTDIR" ? "not_directory" : code === "EACCES" || code === "EPERM" ? "denied" : "unavailable",
|
|
138
|
+
message: err.message,
|
|
139
|
+
} };
|
|
140
140
|
}
|
|
141
141
|
const out = [];
|
|
142
142
|
for (const d of dirents) {
|
|
@@ -225,15 +225,6 @@ export class FilesService {
|
|
|
225
225
|
// always use "/", but relative() returns "\\" on Windows.
|
|
226
226
|
const rel = rawRel.split(sep).join("/");
|
|
227
227
|
const { entries, truncated, error } = await readDirForUI(target, rel);
|
|
228
|
-
if (error) {
|
|
229
|
-
// Windows-only: unreadable system dirs degrade to an empty list
|
|
230
|
-
// with a warning instead of a hard error — the panel stays usable.
|
|
231
|
-
this.host.emit({
|
|
232
|
-
type: "notice",
|
|
233
|
-
level: "warning",
|
|
234
|
-
text: `目录不可读:${error}`,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
228
|
this.host.emit({
|
|
238
229
|
type: "files",
|
|
239
230
|
cwd,
|
|
@@ -245,6 +236,7 @@ export class FilesService {
|
|
|
245
236
|
: "",
|
|
246
237
|
entries,
|
|
247
238
|
truncated,
|
|
239
|
+
...(error ? { error } : {}),
|
|
248
240
|
});
|
|
249
241
|
// Watch the listed directory — deferred until AFTER the response is on
|
|
250
242
|
// the wire. On win32, fs.watch(root, {recursive:true}) registering over
|
|
@@ -253,7 +245,7 @@ export class FilesService {
|
|
|
253
245
|
// macOS's FSEvents); doing it inline before the emit made every
|
|
254
246
|
// project switch wait on that registration instead of just seeing the
|
|
255
247
|
// file list immediately and having the watcher arm a beat later.
|
|
256
|
-
if (cwd === this.host.getCwd())
|
|
248
|
+
if (!error && cwd === this.host.getCwd())
|
|
257
249
|
this.watchDir(target, rel);
|
|
258
250
|
}
|
|
259
251
|
/**
|
package/dist/server/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { homedir } from "node:os";
|
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import { randomUUID } from "node:crypto";
|
|
31
31
|
import express from "express";
|
|
32
|
+
import { shutdownDocumentRuntime } from "./document-conversion/runtime.js";
|
|
32
33
|
import compression from "compression";
|
|
33
34
|
import { WebSocket, WebSocketServer } from "ws";
|
|
34
35
|
import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -615,7 +616,7 @@ wss.on("connection", (ws) => {
|
|
|
615
616
|
pending.push(msg);
|
|
616
617
|
return;
|
|
617
618
|
}
|
|
618
|
-
if (cs.switchingWorkspace && msg.type !== "set_cwd" && msg.type !== "get_state") {
|
|
619
|
+
if (cs.switchingWorkspace && msg.type !== "set_cwd" && msg.type !== "get_state" && msg.type !== "node_request") {
|
|
619
620
|
if (msg.type === "prompt" && msg.requestId)
|
|
620
621
|
send({ type: "prompt_result", requestId: msg.requestId, ok: false });
|
|
621
622
|
if (msg.type === "read_file" || msg.type === "write_file")
|
|
@@ -628,6 +629,10 @@ wss.on("connection", (ws) => {
|
|
|
628
629
|
}
|
|
629
630
|
switch (msg.type) {
|
|
630
631
|
case "node_request":
|
|
632
|
+
if (service.quiesceInfo().quiesced && ["agent_start", "agent_prompt", "agent_new", "agent_model"].includes(msg.action)) {
|
|
633
|
+
send({ type: "node_event", event: "failure", requestId: msg.requestId, nodeId: msg.nodeId, conversationId: msg.conversationId, data: { action: msg.action, message: "服务暂停接收新操作。" } });
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
631
636
|
void nodeWorkbench.handle(clientId, msg);
|
|
632
637
|
break;
|
|
633
638
|
case "prompt":
|
|
@@ -754,7 +759,7 @@ wss.on("connection", (ws) => {
|
|
|
754
759
|
void cs.checkComponentUpdates(msg.requestId);
|
|
755
760
|
break;
|
|
756
761
|
case "update_component":
|
|
757
|
-
if (service.quiesceInfo().quiesced || service.activeConversations() || service.pendingMessages()) {
|
|
762
|
+
if (service.quiesceInfo().quiesced || service.activeConversations() || nodeWorkbench.activeAgents() || service.pendingMessages()) {
|
|
758
763
|
send({ type: "component_updates", requestId: msg.requestId, cwd: cs.cwd, phase: "error", items: [], error: "请等待所有任务结束后再更新扩展" });
|
|
759
764
|
}
|
|
760
765
|
else {
|
|
@@ -1009,7 +1014,7 @@ httpServer.listen(PORT, HOST, () => {
|
|
|
1009
1014
|
scheduleUploadCleanup();
|
|
1010
1015
|
// Local control socket (status / quiesce / unquiesce) — same data dir the
|
|
1011
1016
|
// CLI uses, so `pi-harness server status|quiesce|unquiesce` just works.
|
|
1012
|
-
const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT });
|
|
1017
|
+
const stopControl = startControlServer({ service, dataDir: DATA_DIR, port: PORT, additionalActiveConversations: () => nodeWorkbench.activeAgents() });
|
|
1013
1018
|
let shuttingDown = false;
|
|
1014
1019
|
async function shutdown() {
|
|
1015
1020
|
if (shuttingDown)
|
|
@@ -1021,6 +1026,7 @@ async function shutdown() {
|
|
|
1021
1026
|
nodeWorkbench.dispose();
|
|
1022
1027
|
pluginMgr.dispose();
|
|
1023
1028
|
await service.disposeAll();
|
|
1029
|
+
await shutdownDocumentRuntime();
|
|
1024
1030
|
wss.close();
|
|
1025
1031
|
httpServer.close();
|
|
1026
1032
|
process.exit(0);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createPlanExtension } from "./plan/extension.js";
|
|
2
|
+
import { createPdfMarkdownExtension } from "./document-conversion/extension.js";
|
|
3
|
+
import { createOkfExtension } from "./okf/extension.js";
|
|
2
4
|
import { SettingsManager, createCodemodeExtension, createMcpExtension, createToolSearchExtension } from "@earendil-works/pi-coding-agent";
|
|
3
5
|
const runOverrides = new WeakMap();
|
|
4
6
|
export function setConversationRunSettings(settings, values) {
|
|
@@ -21,6 +23,8 @@ export function conversationSettings(cwd, agentDir) {
|
|
|
21
23
|
export function nativeToolExtensions() {
|
|
22
24
|
return [
|
|
23
25
|
{ name: "pi-harness-plan", replaceable: true, factory: createPlanExtension() },
|
|
26
|
+
{ name: "pi-harness-pdf-markdown", replaceable: true, factory: createPdfMarkdownExtension() },
|
|
27
|
+
{ name: "pi-harness-okf", replaceable: true, factory: createOkfExtension() },
|
|
24
28
|
{ name: "codemode", builtin: true, replaceable: true, factory: createCodemodeExtension() },
|
|
25
29
|
{ name: "tool-search", builtin: true, replaceable: true, factory: createToolSearchExtension() },
|
|
26
30
|
{ name: "mcp", builtin: true, replaceable: true, factory: createMcpExtension() },
|