@chalky/chalky 0.0.0-stage → 0.1.3
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 +46 -2
- package/app/SYSTEM.md +30 -0
- package/app/extensions/appdata/README.md +10 -0
- package/app/extensions/appdata/guide.md +4 -0
- package/app/extensions/appdata/index.ts +47 -0
- package/app/extensions/doodle/README.md +15 -0
- package/app/extensions/doodle/browser.js +388 -0
- package/app/extensions/doodle/guide.md +6 -0
- package/app/extensions/doodle/index.ts +54 -0
- package/app/extensions/files/browser.js +96 -0
- package/app/extensions/files/guide.md +7 -0
- package/app/extensions/files/index.ts +73 -0
- package/app/extensions/frames/README.md +37 -0
- package/app/extensions/frames/browser.js +163 -0
- package/app/extensions/frames/guide.md +7 -0
- package/app/extensions/frames/index.ts +76 -0
- package/app/extensions/mcp/browser.js +64 -0
- package/app/extensions/mcp/guide.md +5 -0
- package/app/extensions/mcp/index.ts +64 -0
- package/app/extensions/model/index.ts +20 -0
- package/app/extensions/page/index.ts +96 -0
- package/app/extensions/page/page.html +615 -0
- package/app/extensions/point/README.md +22 -0
- package/app/extensions/point/browser.js +245 -0
- package/app/extensions/point/guide.md +6 -0
- package/app/extensions/point/index.ts +97 -0
- package/app/extensions/prompt/index.ts +19 -0
- package/app/extensions/reload/index.ts +21 -0
- package/app/extensions/screen/browser.js +76 -0
- package/app/extensions/screen/index.ts +80 -0
- package/app/extensions/steps/index.ts +17 -0
- package/app/extensions/swarm/README.md +47 -0
- package/app/extensions/swarm/guide.md +11 -0
- package/app/extensions/swarm/index.ts +304 -0
- package/app/extensions/ui/README.md +69 -0
- package/app/extensions/ui/agents.js +45 -0
- package/app/extensions/ui/browser.js +363 -0
- package/app/extensions/ui/components/App.js +28 -0
- package/app/extensions/ui/components/Button.js +28 -0
- package/app/extensions/ui/components/Card.js +23 -0
- package/app/extensions/ui/components/Column.js +17 -0
- package/app/extensions/ui/components/Markdown.js +37 -0
- package/app/extensions/ui/components/Row.js +17 -0
- package/app/extensions/ui/components/Table.js +31 -0
- package/app/extensions/ui/components/Text.js +19 -0
- package/app/extensions/ui/components/TextField.js +30 -0
- package/app/extensions/ui/guide.md +7 -0
- package/app/extensions/ui/index.ts +271 -0
- package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
- package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
- package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
- package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
- package/app/extensions/ui/voice.js +114 -0
- package/app/extensions/ui_history/browser.js +97 -0
- package/app/extensions/ui_history/index.ts +109 -0
- package/app/extensions/versions/browser.js +58 -0
- package/app/extensions/versions/index.ts +168 -0
- package/app/extensions/viewer/README.md +12 -0
- package/app/extensions/viewer/browser.js +322 -0
- package/app/extensions/viewer/guide.md +5 -0
- package/app/extensions/viewer/index.ts +209 -0
- package/app/mcp.json +3 -0
- package/app/skills/plugin-map/SKILL.md +49 -0
- package/app/skills/write-skill/SKILL.md +57 -0
- package/app/ui/apps/.keep +0 -0
- package/bin/chalky.mjs +11 -0
- package/node_modules/xlsx/CHANGELOG.md +361 -0
- package/node_modules/xlsx/LICENSE +201 -0
- package/node_modules/xlsx/README.md +41 -0
- package/node_modules/xlsx/bin/xlsx.njs +310 -0
- package/node_modules/xlsx/bower.json +22 -0
- package/node_modules/xlsx/dist/LICENSE +201 -0
- package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
- package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
- package/node_modules/xlsx/dist/cpexcel.js +1506 -0
- package/node_modules/xlsx/dist/shim.min.js +2 -0
- package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
- package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
- package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
- package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
- package/node_modules/xlsx/dist/zahl.d.ts +4 -0
- package/node_modules/xlsx/package.json +183 -0
- package/node_modules/xlsx/types/index.d.ts +1065 -0
- package/node_modules/xlsx/types/tsconfig.json +15 -0
- package/node_modules/xlsx/xlsx.js +28103 -0
- package/node_modules/xlsx/xlsx.mjs +28228 -0
- package/node_modules/xlsx/xlsxworker.js +14 -0
- package/package.json +42 -4
- package/server.ts +275 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// Browser half of the viewer plugin: a preview window per file, opened by the files plugin's "file-open" DOM
|
|
2
|
+
// event {file: {name, path, size}} (a click on a row of the file panel, or files dropped on the page).
|
|
3
|
+
// A window starts attached to its row: left of the panel, at the row's height, a notch pointing at it, the row
|
|
4
|
+
// lit. Only one window is attached at a time (opening another file replaces it); dragging its label (shown
|
|
5
|
+
// on hover, like a surface's: name, ✕) or resizing it from the corner detaches it, and then it stays where
|
|
6
|
+
// it was put. ✕ closes it. Windows live only in this page (not saved, not seen by the agent).
|
|
7
|
+
// Pointing at a window with the pen: point.js asks its partAt(x, y), which answers which file and where:
|
|
8
|
+
// {viewing, sheet, cell} | {viewing, slide} | {viewing, paragraph} | {viewing, line} | {viewing}.
|
|
9
|
+
// Drawing around something: partIn(points) answers what is inside the drawn shape (a thing whose middle is
|
|
10
|
+
// inside it; if none, the thing under the drawing's middle), with its content, at most MAX_PICK characters:
|
|
11
|
+
// sheets {viewing, sheet, range: "B2:D5", values} doc {viewing, paragraphs: [{paragraph, text}]}
|
|
12
|
+
// slides {viewing, lines: [{slide, line, text}]} or {viewing, slides: [{slide, title, lines}]}
|
|
13
|
+
// text {viewing, lines: "12-18", text} image {viewing, region: {x, y, width, height}, size}
|
|
14
|
+
// pdf {viewing, image: <png path>}: the browser's pdf viewer can't be read, so the page captures its own
|
|
15
|
+
// tab (the browser asks once per page load to share it; the stream stays open until the page closes
|
|
16
|
+
// or the user stops it), crops the frame to the drawing and saves it (POST /viewer/shot)
|
|
17
|
+
// The content comes from GET /viewer/view?path= (parsed in the sandbox, see viewer.py); images and pdfs
|
|
18
|
+
// from /viewer/raw (the browser draws them; pdf in its own viewer).
|
|
19
|
+
const STYLE = `
|
|
20
|
+
.viewers { position: fixed; inset: 0; z-index: 15; pointer-events: none; } /* every window, under the palette and the input */
|
|
21
|
+
.viewer { position: fixed; pointer-events: auto; display: flex; flex-direction: column; width: 560px; height: min(70vh, 640px);
|
|
22
|
+
min-width: 240px; min-height: 160px; box-sizing: border-box; border-radius: var(--radius, 14px); font-size: 13px;
|
|
23
|
+
background: var(--glass-strong, #fff); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
24
|
+
border: 1px solid var(--edge, #eee); box-shadow: var(--shadow, 0 8px 30px rgba(0,0,0,.1)); color: var(--text, #1d1d1f); --grid: rgba(128, 128, 128, .3); }
|
|
25
|
+
.viewer > .viewer-label { position: absolute; bottom: 100%; left: 0; right: 0; display: flex; gap: 8px; align-items: center;
|
|
26
|
+
padding: 0 2px 4px; font-size: 12px; color: var(--muted, #888); cursor: grab; opacity: 0; transition: opacity .15s;
|
|
27
|
+
white-space: nowrap; overflow: hidden; }
|
|
28
|
+
.viewer:hover > .viewer-label { opacity: 1; }
|
|
29
|
+
.viewer > .viewer-label span { overflow: hidden; text-overflow: ellipsis; }
|
|
30
|
+
.viewer > .viewer-label button { color: inherit; font: inherit; background: none; border: 0; cursor: pointer; padding: 0 2px; }
|
|
31
|
+
.viewer[data-attached]::after { content: ""; position: absolute; right: -7px; top: var(--notch, 20px); width: 12px; height: 12px;
|
|
32
|
+
background: inherit; border: 1px solid var(--edge, #eee); border-left: 0; border-bottom: 0; transform: rotate(45deg); }
|
|
33
|
+
.viewer > .viewer-body { flex: 1; min-height: 0; overflow: auto; border-radius: inherit; }
|
|
34
|
+
.viewer > .viewer-resize { position: absolute; right: 0; bottom: 0; width: 12px; height: 12px; cursor: nwse-resize; }
|
|
35
|
+
.viewer .v-pad { padding: 16px 20px; }
|
|
36
|
+
.viewer .v-muted { color: var(--muted, #888); }
|
|
37
|
+
.viewer img { display: block; max-width: 100%; max-height: 100%; margin: auto; object-fit: contain; }
|
|
38
|
+
.viewer .v-fill { width: 100%; height: 100%; border: 0; display: block; }
|
|
39
|
+
.viewer pre { margin: 0; padding: 14px 18px; white-space: pre-wrap; word-break: break-word; font: 12px/1.5 ui-monospace, monospace; }
|
|
40
|
+
.viewer .v-doc { padding: 18px 26px; line-height: 1.55; font-size: 14px; }
|
|
41
|
+
.viewer .v-doc p, .viewer .v-doc li { margin: 0 0 8px; }
|
|
42
|
+
.viewer .v-doc table, .viewer .v-grid { border-collapse: collapse; }
|
|
43
|
+
.viewer .v-doc table { margin: 4px 0 12px; }
|
|
44
|
+
.viewer .v-doc td { border: 1px solid var(--grid); padding: 4px 8px; vertical-align: top; }
|
|
45
|
+
.viewer .v-sheets { display: flex; flex-direction: column; height: 100%; }
|
|
46
|
+
.viewer .v-sheet { flex: 1; min-height: 0; overflow: auto; }
|
|
47
|
+
.viewer .v-grid td, .viewer .v-grid th { border: 1px solid var(--grid); padding: 3px 8px; white-space: nowrap;
|
|
48
|
+
max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
|
49
|
+
.viewer .v-grid th { position: sticky; top: 0; background: var(--hover, #f2f2f2); font-weight: 500; color: var(--muted, #888); }
|
|
50
|
+
.viewer .v-grid th:first-child, .viewer .v-grid td:first-child { position: sticky; left: 0; background: var(--hover, #f2f2f2);
|
|
51
|
+
color: var(--muted, #888); text-align: right; }
|
|
52
|
+
.viewer .v-grid th:first-child { z-index: 1; }
|
|
53
|
+
.viewer .v-tabs { display: flex; gap: 2px; overflow-x: auto; padding: 0 8px; border-top: 1px solid var(--grid); }
|
|
54
|
+
.viewer .v-tabs button { font: inherit; font-size: 12px; color: var(--muted, #888); background: none; border: 0; cursor: pointer;
|
|
55
|
+
padding: 6px 12px; border-bottom: 2px solid transparent; white-space: nowrap; }
|
|
56
|
+
.viewer .v-tabs button.on { color: var(--text, #1d1d1f); border-bottom-color: var(--accent, #0071e3); font-weight: 500; }
|
|
57
|
+
.viewer .v-slides { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
|
|
58
|
+
.viewer .v-slide { aspect-ratio: 16 / 9; box-sizing: border-box; padding: 18px 24px; border-radius: 10px; overflow: hidden;
|
|
59
|
+
background: #fff; color: #1d1d1f; box-shadow: 0 1px 6px rgba(0,0,0,.12); position: relative; }
|
|
60
|
+
.viewer .v-slide h2 { margin: 0 0 10px; font-size: 18px; }
|
|
61
|
+
.viewer .v-slide ul { margin: 0; padding-left: 18px; }
|
|
62
|
+
.viewer .v-slide .v-n { position: absolute; right: 12px; bottom: 8px; font-size: 11px; color: #999; }
|
|
63
|
+
.viewer .v-notes { margin-top: -8px; font-size: 12px; }`;
|
|
64
|
+
|
|
65
|
+
function el(tag, attrs = {}, ...children) { // textContent, never innerHTML, for anything from a file
|
|
66
|
+
const e = document.createElement(tag);
|
|
67
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
68
|
+
if (k === "text") e.textContent = v;
|
|
69
|
+
else if (k.startsWith("on")) e[k] = v;
|
|
70
|
+
else e.setAttribute(k, v);
|
|
71
|
+
}
|
|
72
|
+
e.append(...children);
|
|
73
|
+
return e;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MAX_PICK = 4000; // characters of picked content sent with a note
|
|
77
|
+
const column = (i) => (i >= 26 ? column(Math.floor(i / 26) - 1) : "") + String.fromCharCode(65 + (i % 26)); // 0 -> A, 26 -> AA
|
|
78
|
+
|
|
79
|
+
export default async function (page) {
|
|
80
|
+
document.head.append(el("style", { text: STYLE }));
|
|
81
|
+
const windows = {}; // path -> {box, file, attached}
|
|
82
|
+
let top = 0; // z-index inside the layer: the last one touched is in front
|
|
83
|
+
const layer = el("div", { class: "viewers" });
|
|
84
|
+
document.body.append(layer);
|
|
85
|
+
|
|
86
|
+
const rowOf = (path) => document.querySelector(`#side .files-row[data-path="${CSS.escape(path)}"]`);
|
|
87
|
+
|
|
88
|
+
function place(w) { // attached: left of the panel, at the row's height (the folder button if the panel is closed)
|
|
89
|
+
if (!w.attached) return;
|
|
90
|
+
const row = rowOf(w.file.path);
|
|
91
|
+
const visible = row && row.offsetParent;
|
|
92
|
+
const anchor = visible ? row : document.querySelector("#side .files-toggle");
|
|
93
|
+
if (!anchor) return;
|
|
94
|
+
const a = anchor.getBoundingClientRect(), panel = (visible ? row.parentElement : anchor).getBoundingClientRect();
|
|
95
|
+
const h = w.box.offsetHeight, y = Math.max(72, Math.min(a.top - 14, innerHeight - h - 16)); // 72: below the palette
|
|
96
|
+
Object.assign(w.box.style, { right: `${innerWidth - panel.left + 14}px`, left: "", top: `${y}px` });
|
|
97
|
+
w.box.style.setProperty("--notch", `${Math.max(10, Math.min(a.top + a.height / 2 - y - 6, h - 22))}px`);
|
|
98
|
+
for (const r of document.querySelectorAll("#side .files-row.viewing")) r.classList.remove("viewing");
|
|
99
|
+
if (visible) row.classList.add("viewing");
|
|
100
|
+
}
|
|
101
|
+
const placeAll = () => Object.values(windows).forEach(place);
|
|
102
|
+
addEventListener("resize", placeAll);
|
|
103
|
+
document.addEventListener("scroll", placeAll, true); // the panel's list scrolls
|
|
104
|
+
const side = document.getElementById("side");
|
|
105
|
+
if (side) new MutationObserver(() => { // the list redrawn or the panel opened or closed
|
|
106
|
+
for (const w of Object.values(windows)) if (!rowOf(w.file.path) && w.attached && side.querySelector(".files-row")) close(w); // file gone
|
|
107
|
+
placeAll();
|
|
108
|
+
}).observe(side, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-open", "hidden"] });
|
|
109
|
+
|
|
110
|
+
function detach(w) {
|
|
111
|
+
if (!w.attached) return;
|
|
112
|
+
const r = w.box.getBoundingClientRect();
|
|
113
|
+
Object.assign(w.box.style, { left: `${r.left}px`, top: `${r.top}px`, right: "" });
|
|
114
|
+
w.attached = false;
|
|
115
|
+
w.box.removeAttribute("data-attached");
|
|
116
|
+
rowOf(w.file.path)?.classList.remove("viewing");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function close(w) {
|
|
120
|
+
w.box.remove();
|
|
121
|
+
delete windows[w.file.path];
|
|
122
|
+
if (w.attached) rowOf(w.file.path)?.classList.remove("viewing");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function dragging(w, e, move) { // pointer capture, so moves over iframes (a pdf) still arrive
|
|
126
|
+
if (e.target.closest("button")) return;
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
detach(w);
|
|
129
|
+
const handle = e.currentTarget, r = w.box.getBoundingClientRect(), x0 = e.clientX, y0 = e.clientY;
|
|
130
|
+
handle.setPointerCapture(e.pointerId);
|
|
131
|
+
handle.onpointermove = (ev) => move(r, ev.clientX - x0, ev.clientY - y0);
|
|
132
|
+
handle.onpointerup = () => { handle.onpointermove = handle.onpointerup = null; };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function open(file) {
|
|
136
|
+
const old = windows[file.path];
|
|
137
|
+
if (old) { old.box.style.zIndex = ++top; return; }
|
|
138
|
+
for (const w of Object.values(windows)) if (w.attached) close(w); // one attached window at a time
|
|
139
|
+
const body = el("div", { class: "viewer-body" }, el("div", { class: "v-pad v-muted", text: "Opening…" }));
|
|
140
|
+
const box = el("div", { class: "viewer", "data-attached": "" });
|
|
141
|
+
const w = { box, file, attached: true };
|
|
142
|
+
const label = el("div", { class: "viewer-label", title: "Drag to move" },
|
|
143
|
+
el("span", { text: file.name }), el("button", { text: "✕", title: "Close", onclick: () => close(w) }));
|
|
144
|
+
const corner = el("div", { class: "viewer-resize", title: "Drag to resize" });
|
|
145
|
+
box.append(label, body, corner);
|
|
146
|
+
box.onpointerdown = () => { box.style.zIndex = ++top; };
|
|
147
|
+
label.onpointerdown = (e) => dragging(w, e, (r, dx, dy) => Object.assign(box.style, {
|
|
148
|
+
left: `${Math.max(0, Math.min(r.left + dx, innerWidth - 60))}px`, top: `${Math.max(24, Math.min(r.top + dy, innerHeight - 40))}px` }));
|
|
149
|
+
corner.onpointerdown = (e) => dragging(w, e, (r, dx, dy) => Object.assign(box.style, {
|
|
150
|
+
width: `${Math.max(240, r.width + dx)}px`, height: `${Math.max(160, r.height + dy)}px` }));
|
|
151
|
+
box.partAt = (x, y) => { // for point.js: which file, and where in it
|
|
152
|
+
const r = box.getBoundingClientRect();
|
|
153
|
+
const hit = document.elementsFromPoint(r.left + x, r.top + y).find((e) => box.contains(e));
|
|
154
|
+
const spot = hit?.closest("[data-part]");
|
|
155
|
+
return { viewing: file.path, ...(spot ? JSON.parse(spot.dataset.part) : {}) };
|
|
156
|
+
};
|
|
157
|
+
box.style.zIndex = ++top;
|
|
158
|
+
windows[file.path] = w;
|
|
159
|
+
layer.append(box);
|
|
160
|
+
place(w);
|
|
161
|
+
box.partIn = (points) => pick(w, points);
|
|
162
|
+
fill(w, body, file);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function fill(w, body, file) {
|
|
166
|
+
const q = `?path=${encodeURIComponent(file.path)}`;
|
|
167
|
+
let p;
|
|
168
|
+
try { p = await (await fetch(`/viewer/view${q}`)).json(); } catch (e) { p = { kind: "none", reason: `Could not load: ${e}` }; }
|
|
169
|
+
w.p = p;
|
|
170
|
+
const part = (o) => JSON.stringify(o);
|
|
171
|
+
let content;
|
|
172
|
+
if (p.kind === "image") content = el("div", { class: "v-fill", style: "display:grid;place-items:center;padding:10px;box-sizing:border-box" },
|
|
173
|
+
el("img", { src: `/viewer/raw${q}`, alt: file.name }));
|
|
174
|
+
else if (p.kind === "pdf") content = el("iframe", { class: "v-fill", src: `/viewer/raw${q}`, title: file.name });
|
|
175
|
+
else if (p.kind === "text") content = el("pre", {}, ...p.text.split("\n").map((t, i) => el("span", { "data-part": part({ line: i + 1 }), text: t + "\n" })),
|
|
176
|
+
...(p.cut ? [el("span", { class: "v-muted", text: "… (cut: the file is longer)" })] : []));
|
|
177
|
+
else if (p.kind === "doc") content = el("div", { class: "v-doc" }, ...docBlocks(p.blocks, part));
|
|
178
|
+
else if (p.kind === "slides") content = el("div", { class: "v-slides" }, ...p.slides.flatMap((s, i) => [
|
|
179
|
+
el("div", { class: "v-slide", "data-part": part({ slide: i + 1 }) },
|
|
180
|
+
el("h2", { text: s.title, "data-part": part({ slide: i + 1, line: 0 }) }),
|
|
181
|
+
el("ul", {}, ...s.lines.map((t, j) => el("li", { "data-part": part({ slide: i + 1, line: j + 1 }), text: t }))),
|
|
182
|
+
el("span", { class: "v-n", text: `${i + 1} / ${p.slides.length}` })),
|
|
183
|
+
...(s.notes ? [el("div", { class: "v-notes v-muted", text: `Notes: ${s.notes}` })] : [])]));
|
|
184
|
+
else if (p.kind === "sheets") content = sheets(w, p.sheets, part);
|
|
185
|
+
else content = el("div", { class: "v-pad v-muted", text: p.reason || p.error || "No preview." });
|
|
186
|
+
body.replaceChildren(content);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function docBlocks(blocks, part) { // headings, paragraphs, runs of list items as one list, tables
|
|
190
|
+
const out = [];
|
|
191
|
+
let list = null;
|
|
192
|
+
blocks.forEach((b, i) => {
|
|
193
|
+
const at = { "data-part": part({ paragraph: i + 1 }) };
|
|
194
|
+
if (b.item) { if (!list) out.push(list = el("ul")); list.append(el("li", { ...at, text: b.text })); return; }
|
|
195
|
+
list = null;
|
|
196
|
+
if (b.table) out.push(el("table", at, ...b.table.map((r) => el("tr", {}, ...r.map((c) => el("td", { text: c }))))));
|
|
197
|
+
else out.push(el(b.heading ? `h${Math.min(b.heading + 1, 6)}` : "p", { ...at, text: b.text }));
|
|
198
|
+
});
|
|
199
|
+
return out.length ? out : [el("p", { class: "v-muted", text: "(empty document)" })];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sheets(w, list, part) { // one grid at a time, its sheets as tabs along the bottom (like Excel)
|
|
203
|
+
const view = el("div", { class: "v-sheet" });
|
|
204
|
+
const tabs = el("div", { class: "v-tabs" });
|
|
205
|
+
const show = (k) => {
|
|
206
|
+
w.sheet = k;
|
|
207
|
+
const s = list[k];
|
|
208
|
+
const width = Math.max(0, ...s.rows.map((r) => r.length));
|
|
209
|
+
const head = el("tr", {}, el("th"), ...Array.from({ length: width }, (_, c) => el("th", { text: column(c) })));
|
|
210
|
+
const rows = s.rows.map((r, i) => el("tr", {}, el("td", { text: String(i + 1) }),
|
|
211
|
+
...Array.from({ length: width }, (_, c) => el("td", { "data-part": part({ sheet: s.name, cell: `${column(c)}${i + 1}` }), text: r[c] ?? "", title: r[c] ?? "" }))));
|
|
212
|
+
view.replaceChildren(rows.length ? el("table", { class: "v-grid" }, head, ...rows) : el("div", { class: "v-pad v-muted", text: "(empty sheet)" }),
|
|
213
|
+
...(s.cut ? [el("div", { class: "v-pad v-muted", text: "Only the first rows and columns are shown." })] : []));
|
|
214
|
+
[...tabs.children].forEach((t, j) => t.classList.toggle("on", j === k));
|
|
215
|
+
};
|
|
216
|
+
list.forEach((s, k) => tabs.append(el("button", { text: s.name, onclick: () => show(k) })));
|
|
217
|
+
show(0);
|
|
218
|
+
return el("div", { class: "v-sheets" }, view, tabs);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Drawn around (partIn): what is inside the drawing. inShape: is (x, y) inside the polygon (ray casting).
|
|
222
|
+
const inShape = (pts, x, y) => {
|
|
223
|
+
let c = false;
|
|
224
|
+
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
225
|
+
const [xi, yi] = pts[i], [xj, yj] = pts[j];
|
|
226
|
+
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c;
|
|
227
|
+
}
|
|
228
|
+
return c;
|
|
229
|
+
};
|
|
230
|
+
const bounds = (pts) => {
|
|
231
|
+
const xs = pts.map((p) => p[0]), ys = pts.map((p) => p[1]);
|
|
232
|
+
return { left: Math.min(...xs), top: Math.min(...ys), right: Math.max(...xs), bottom: Math.max(...ys) };
|
|
233
|
+
};
|
|
234
|
+
const fit = (list, size) => { // as many items as fit in MAX_PICK characters
|
|
235
|
+
const out = [];
|
|
236
|
+
let n = 0;
|
|
237
|
+
for (const x of list) { n += size(x); if (n > MAX_PICK && out.length) break; out.push(x); }
|
|
238
|
+
return { list: out, cut: out.length < list.length };
|
|
239
|
+
};
|
|
240
|
+
const cellAt = (ref) => { const [, c, r] = ref.match(/^([A-Z]+)(\d+)$/); return [[...c].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1, +r - 1]; };
|
|
241
|
+
|
|
242
|
+
async function pick(w, points) {
|
|
243
|
+
const base = { viewing: w.file.path }, kind = w.p?.kind;
|
|
244
|
+
if (kind === "pdf") return { ...base, ...(await capture(w, points)) };
|
|
245
|
+
const b = bounds(points);
|
|
246
|
+
if (kind === "image") {
|
|
247
|
+
const img = w.box.querySelector("img"), r = img.getBoundingClientRect(), k = img.naturalWidth / r.width;
|
|
248
|
+
const x0 = Math.max(b.left, r.left), y0 = Math.max(b.top, r.top), x1 = Math.min(b.right, r.right), y1 = Math.min(b.bottom, r.bottom);
|
|
249
|
+
if (x1 <= x0 || y1 <= y0) return base;
|
|
250
|
+
return { ...base, region: { x: Math.round((x0 - r.left) * k), y: Math.round((y0 - r.top) * k),
|
|
251
|
+
width: Math.round((x1 - x0) * k), height: Math.round((y1 - y0) * k) }, size: [img.naturalWidth, img.naturalHeight] };
|
|
252
|
+
}
|
|
253
|
+
const view = w.box.querySelector(".viewer-body").getBoundingClientRect();
|
|
254
|
+
let picked = [...w.box.querySelectorAll("[data-part]")].filter((e) => { // its middle inside the drawing (and in view)
|
|
255
|
+
const r = e.getBoundingClientRect(), x = r.x + r.width / 2, y = r.y + r.height / 2;
|
|
256
|
+
return r.width && x >= view.left && x <= view.right && y >= view.top && y <= view.bottom && inShape(points, x, y);
|
|
257
|
+
});
|
|
258
|
+
if (!picked.length) { // a drawing inside one big thing (a long paragraph): that thing
|
|
259
|
+
const mid = document.elementsFromPoint((b.left + b.right) / 2, (b.top + b.bottom) / 2).find((e) => w.box.contains(e))?.closest("[data-part]");
|
|
260
|
+
picked = mid ? [mid] : [];
|
|
261
|
+
}
|
|
262
|
+
const parts = picked.map((e) => JSON.parse(e.dataset.part));
|
|
263
|
+
if (!parts.length) return base;
|
|
264
|
+
if (kind === "sheets") {
|
|
265
|
+
const s = w.p.sheets[w.sheet ?? 0], at = parts.map((q) => cellAt(q.cell));
|
|
266
|
+
const c0 = Math.min(...at.map((a) => a[0])), c1 = Math.max(...at.map((a) => a[0]));
|
|
267
|
+
const r0 = Math.min(...at.map((a) => a[1])), r1 = Math.max(...at.map((a) => a[1]));
|
|
268
|
+
const rows = fit(s.rows.slice(r0, r1 + 1).map((r) => r.slice(c0, c1 + 1)), (r) => r.join(",").length + 1);
|
|
269
|
+
return { ...base, sheet: s.name, range: `${column(c0)}${r0 + 1}:${column(c1)}${r1 + 1}`, values: rows.list, ...(rows.cut && { cut: true }) };
|
|
270
|
+
}
|
|
271
|
+
if (kind === "doc") {
|
|
272
|
+
const text = (bl) => bl.table ? bl.table.map((r) => r.join(" | ")).join("\n") : bl.text;
|
|
273
|
+
const ps = fit(parts.map((q) => ({ paragraph: q.paragraph, text: text(w.p.blocks[q.paragraph - 1]) })), (x) => x.text.length);
|
|
274
|
+
return { ...base, paragraphs: ps.list, ...(ps.cut && { cut: true }) };
|
|
275
|
+
}
|
|
276
|
+
if (kind === "slides") {
|
|
277
|
+
const slide = (n) => w.p.slides[n - 1];
|
|
278
|
+
const lines = parts.filter((q) => q.line !== undefined);
|
|
279
|
+
if (lines.length) {
|
|
280
|
+
const ls = fit(lines.map((q) => ({ slide: q.slide, line: q.line, text: q.line ? slide(q.slide).lines[q.line - 1] : slide(q.slide).title })), (x) => x.text.length);
|
|
281
|
+
return { ...base, lines: ls.list, ...(ls.cut && { cut: true }) };
|
|
282
|
+
}
|
|
283
|
+
const ss = fit(parts.map((q) => ({ slide: q.slide, title: slide(q.slide).title, lines: slide(q.slide).lines })), (x) => JSON.stringify(x).length);
|
|
284
|
+
return { ...base, slides: ss.list, ...(ss.cut && { cut: true }) };
|
|
285
|
+
}
|
|
286
|
+
if (kind === "text") {
|
|
287
|
+
const n = parts.map((q) => q.line), lo = Math.min(...n), hi = Math.max(...n);
|
|
288
|
+
const text = w.p.text.split("\n").slice(lo - 1, hi).join("\n");
|
|
289
|
+
return { ...base, lines: lo === hi ? `${lo}` : `${lo}-${hi}`, text: text.length > MAX_PICK ? text.slice(0, MAX_PICK) + " …(cut)" : text };
|
|
290
|
+
}
|
|
291
|
+
return base;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let stream = null, video = null; // this tab, shared once (pdf pictures)
|
|
295
|
+
async function capture(w, points) {
|
|
296
|
+
try {
|
|
297
|
+
if (!stream) {
|
|
298
|
+
stream = await navigator.mediaDevices.getDisplayMedia({ video: true, preferCurrentTab: true, selfBrowserSurface: "include" });
|
|
299
|
+
stream.getVideoTracks()[0].onended = () => { stream = null; video?.remove(); }; // the user stopped sharing
|
|
300
|
+
video = el("video", { muted: "", playsinline: "", style: "position:fixed;width:1px;height:1px;opacity:0;pointer-events:none" });
|
|
301
|
+
video.srcObject = stream;
|
|
302
|
+
document.body.append(video);
|
|
303
|
+
await video.play();
|
|
304
|
+
}
|
|
305
|
+
await new Promise((ok) => setTimeout(ok, 250)); // a fresh frame, with the drawing on it
|
|
306
|
+
const f = w.box.querySelector("iframe").getBoundingClientRect(), b = bounds(points), pad = 8;
|
|
307
|
+
const x0 = Math.max(f.left, b.left - pad), y0 = Math.max(f.top, b.top - pad);
|
|
308
|
+
const x1 = Math.min(f.right, b.right + pad), y1 = Math.min(f.bottom, b.bottom + pad);
|
|
309
|
+
if (x1 <= x0 || y1 <= y0) return {};
|
|
310
|
+
const kx = video.videoWidth / innerWidth, ky = video.videoHeight / innerHeight; // frame px per window px
|
|
311
|
+
const c = el("canvas", { width: Math.round((x1 - x0) * kx), height: Math.round((y1 - y0) * ky) });
|
|
312
|
+
c.getContext("2d").drawImage(video, x0 * kx, y0 * ky, (x1 - x0) * kx, (y1 - y0) * ky, 0, 0, c.width, c.height);
|
|
313
|
+
const png = await new Promise((ok) => c.toBlob(ok, "image/png"));
|
|
314
|
+
const out = await (await fetch("/viewer/shot", { method: "POST", body: png })).json();
|
|
315
|
+
return out.path ? { image: out.path } : { image: null, why: out.error };
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return { image: null, why: `no picture of the area (${e.name || e}): the tab was not shared` };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
document.addEventListener("file-open", (e) => open(e.detail.file));
|
|
322
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Previews of the user's files
|
|
2
|
+
The user can open a file from their panel in a preview and point at a spot in it: the note's part has
|
|
3
|
+
"viewing": <the file>. Such a note is about that file and that spot, not about the page.
|
|
4
|
+
read_document(path) gives a document's text (docx, pptx, xlsx, pdf, ...). Before you handle a note on a
|
|
5
|
+
preview, read README.md.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Viewer: previews of the user's files (the ones in the files plugin's folder), in a window on the page (port of the
|
|
2
|
+
// Python viewer plugin; the browser half, browser.js, is its viewer.js). browser.js opens a window when files' browser.js fires
|
|
3
|
+
// "file-open"; the window lives only in that page (a note pointed at it says which file and which spot).
|
|
4
|
+
// Parsing happens here, one library per format (plain previews, no LibreOffice):
|
|
5
|
+
// images, pdf shown by the browser itself, from /viewer/raw
|
|
6
|
+
// csv, tsv, xlsx, xlsm, xls a table per sheet, shown as tabs like Excel (SheetJS)
|
|
7
|
+
// docx headings, paragraphs, list items and tables, in order (mammoth's document tree)
|
|
8
|
+
// pptx the title and text of each slide, tables as lines (node-pptx-parser's XML trees);
|
|
9
|
+
// no speaker notes yet (the library doesn't read the notes files)
|
|
10
|
+
// doc (old Word) plain text (word-extractor)
|
|
11
|
+
// anything else that is text text
|
|
12
|
+
// GET /viewer/view?path= {"kind": "image"|"pdf"|"text"|"sheets"|"doc"|"slides"|"none", ...} (see preview())
|
|
13
|
+
// GET /viewer/raw?path= the file's bytes, images and pdf only
|
|
14
|
+
// POST /viewer/shot a png of an area the user drew around on a pdf; saved in SHOTS, returns {"path"}
|
|
15
|
+
// Tool read_document(path): the same content as text, for the agent (pdf text with unpdf). The Python tool was
|
|
16
|
+
// read_file; renamed so it isn't mistaken for pi's read (text and pictures, which the guide now names for pictures).
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import * as XLSX from "xlsx";
|
|
20
|
+
import mammoth from "mammoth";
|
|
21
|
+
import PptxParser from "node-pptx-parser";
|
|
22
|
+
import WordExtractor from "word-extractor";
|
|
23
|
+
import { extractText, getDocumentProxy } from "unpdf";
|
|
24
|
+
import { Type } from "typebox";
|
|
25
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
|
|
27
|
+
const host = (globalThis as any).chalky;
|
|
28
|
+
const DIR = path.join(host.work, "drops"); // the files plugin's folder
|
|
29
|
+
const IMAGES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
30
|
+
".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml", ".avif": "image/avif", ".ico": "image/x-icon" };
|
|
31
|
+
const MAX_ROWS = 1000, MAX_COLS = 60; // per sheet shown
|
|
32
|
+
const MAX_TEXT = 300_000; // characters shown
|
|
33
|
+
const MAX_TOOL_TEXT = 40_000; // characters read_document returns
|
|
34
|
+
const SHOTS = path.join(host.state, "pointed"); // pictures of drawn-around pdf areas (outside the user's folder)
|
|
35
|
+
const MAX_SHOT = 10 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
function inside(p: string | null) { // the real path, if it is a file in DIR; else null (no reading elsewhere)
|
|
38
|
+
try {
|
|
39
|
+
const real = fs.realpathSync(p ?? "");
|
|
40
|
+
return real.startsWith(fs.realpathSync(DIR) + path.sep) && fs.statSync(real).isFile() ? real : null;
|
|
41
|
+
} catch { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sheet(name: string, ws: XLSX.WorkSheet) { // {name, rows, cut}: from A1, at most MAX_ROWS x MAX_COLS
|
|
45
|
+
if (!ws["!ref"]) return { name, rows: [], cut: false };
|
|
46
|
+
const r = XLSX.utils.decode_range(ws["!ref"]);
|
|
47
|
+
const cut = r.e.r >= MAX_ROWS || r.e.c >= MAX_COLS;
|
|
48
|
+
r.s = { r: 0, c: 0 }; // browser.js numbers rows and columns from the first one sent
|
|
49
|
+
r.e = { r: Math.min(r.e.r, MAX_ROWS - 1), c: Math.min(r.e.c, MAX_COLS - 1) };
|
|
50
|
+
// raw: false = the cell's text as Excel shows it (number formats, dates); csv cells are already that text
|
|
51
|
+
const rows = XLSX.utils.sheet_to_json<string[]>(ws, { header: 1, range: r, defval: "", raw: false, blankrows: true })
|
|
52
|
+
.map((row) => row.map(String));
|
|
53
|
+
const width = Math.max(0, ...rows.map((row) => row.findLastIndex((v) => v !== "") + 1)); // trailing empty columns dropped
|
|
54
|
+
while (rows.length && rows.at(-1)!.every((v) => v === "")) rows.pop();
|
|
55
|
+
return { name, rows: rows.map((row) => row.slice(0, width)), cut };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// docx: mammoth reads the file into its own tree (paragraphs with their style name and list numbering, tables of rows
|
|
59
|
+
// of cells) and hands it to transformDocument before making HTML; we keep the tree and ignore the HTML.
|
|
60
|
+
const textOf = (e: any): string => e.type === "text" ? e.value : e.type === "tab" ? "\t" : e.type === "break" ? "\n"
|
|
61
|
+
: e.type === "paragraph" ? (e.children ?? []).map(textOf).join("")
|
|
62
|
+
: e.type === "tableCell" ? e.children.filter((c: any) => c.type === "paragraph").map(textOf).join("\n")
|
|
63
|
+
: (e.children ?? []).map(textOf).join("");
|
|
64
|
+
|
|
65
|
+
async function docx(p: string) {
|
|
66
|
+
let tree: any;
|
|
67
|
+
await mammoth.convertToHtml({ path: p }, { transformDocument: (d: any) => (tree = d) });
|
|
68
|
+
const blocks = [];
|
|
69
|
+
for (const b of tree.children) {
|
|
70
|
+
if (b.type === "table") blocks.push({ table: b.children.map((row: any) => row.children.flatMap((c: any) => Array(c.colSpan).fill(textOf(c)))) });
|
|
71
|
+
else if (b.type === "paragraph" && textOf(b).trim()) {
|
|
72
|
+
// Word stores built-in style names in lower case ("heading 1"); python-docx showed them as "Heading 1"
|
|
73
|
+
const style = (b.styleName ?? "").toLowerCase(), n = style.match(/^heading (\d)$/)?.[1];
|
|
74
|
+
blocks.push({ text: textOf(b), heading: style === "title" ? 1 : n ? +n : 0, item: style.startsWith("list") || !!b.numbering });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { kind: "doc", blocks };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// pptx: node-pptx-parser gives each slide's XML as a tree (xml2js: every child is an array, attributes in "$").
|
|
81
|
+
// The slides come in the order of the links file, so they are put in the deck's own order (presentation.xml).
|
|
82
|
+
// A title is the shape that is a title placeholder; tables are lines "a | b | c"; groups are looked into.
|
|
83
|
+
const paraText = (pa: any) => [...(pa["a:r"] ?? []), ...(pa["a:fld"] ?? [])].map((r) => {
|
|
84
|
+
const t = r["a:t"]?.[0];
|
|
85
|
+
return typeof t === "string" ? t : t?._ ?? "";
|
|
86
|
+
}).join("");
|
|
87
|
+
const bodyLines = (body: any) => (body?.["a:p"] ?? []).map(paraText).filter((t: string) => t.trim());
|
|
88
|
+
|
|
89
|
+
function shapes(tree: any, out: { title: string, lines: string[] }) {
|
|
90
|
+
for (const sp of tree["p:sp"] ?? []) {
|
|
91
|
+
const ph = sp["p:nvSpPr"]?.[0]?.["p:nvPr"]?.[0]?.["p:ph"]?.[0]?.$?.type;
|
|
92
|
+
const lines = bodyLines(sp["p:txBody"]?.[0]);
|
|
93
|
+
if ((ph === "title" || ph === "ctrTitle") && !out.title) out.title = lines.join(" ");
|
|
94
|
+
else out.lines.push(...lines);
|
|
95
|
+
}
|
|
96
|
+
for (const f of tree["p:graphicFrame"] ?? [])
|
|
97
|
+
for (const tbl of f["a:graphic"]?.[0]?.["a:graphicData"]?.[0]?.["a:tbl"] ?? [])
|
|
98
|
+
for (const tr of tbl["a:tr"] ?? []) out.lines.push((tr["a:tc"] ?? []).map((tc: any) => bodyLines(tc["a:txBody"]?.[0]).join("\n")).join(" | "));
|
|
99
|
+
for (const g of tree["p:grpSp"] ?? []) shapes(g, out);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function pptx(p: string) {
|
|
103
|
+
const { presentation, slides } = await new PptxParser(p).parse();
|
|
104
|
+
const order = (presentation.parsed["p:presentation"]["p:sldIdLst"]?.[0]["p:sldId"] ?? []).map((s: any) => s.$["r:id"]);
|
|
105
|
+
slides.sort((a: any, b: any) => order.indexOf(a.id) - order.indexOf(b.id));
|
|
106
|
+
return { kind: "slides", slides: slides.map((s: any) => {
|
|
107
|
+
const out = { title: "", lines: [] as string[] };
|
|
108
|
+
shapes(s.parsed["p:sld"]["p:cSld"][0]["p:spTree"][0], out);
|
|
109
|
+
return { ...out, notes: "" };
|
|
110
|
+
}) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function preview(p: string) {
|
|
114
|
+
const ext = path.extname(p).toLowerCase();
|
|
115
|
+
if (ext === ".docx") return docx(p);
|
|
116
|
+
if (ext === ".pptx") return pptx(p);
|
|
117
|
+
if (ext === ".doc") return { kind: "text", text: (await new WordExtractor().extract(p)).getBody().slice(0, MAX_TEXT) };
|
|
118
|
+
if (ext in IMAGES) return { kind: "image" };
|
|
119
|
+
if (ext === ".pdf") return { kind: "pdf" };
|
|
120
|
+
if (ext === ".csv" || ext === ".tsv") { // as text, cells left as written (raw: no guessing numbers or dates)
|
|
121
|
+
const wb = XLSX.read(fs.readFileSync(p, "utf8").replace(/^/, ""), { type: "string", raw: true, FS: ext === ".tsv" ? "\t" : "," });
|
|
122
|
+
return { kind: "sheets", sheets: [sheet(path.basename(p), wb.Sheets[wb.SheetNames[0]])] };
|
|
123
|
+
}
|
|
124
|
+
if (ext === ".xlsx" || ext === ".xlsm" || ext === ".xls") {
|
|
125
|
+
const wb = XLSX.read(fs.readFileSync(p), { sheetRows: MAX_ROWS + 1 }); // only read what can be shown (+1 to know it was cut)
|
|
126
|
+
return { kind: "sheets", sheets: wb.SheetNames.map((n) => sheet(n, wb.Sheets[n])) };
|
|
127
|
+
}
|
|
128
|
+
const fd = fs.openSync(p, "r"), head = Buffer.alloc(MAX_TEXT); // anything else: text if it looks like text
|
|
129
|
+
const n = fs.readSync(fd, head, 0, MAX_TEXT, 0);
|
|
130
|
+
fs.closeSync(fd);
|
|
131
|
+
if (head.subarray(0, Math.min(n, 8192)).includes(0)) return { kind: "none", reason: `No preview for ${ext || "this kind of"} files.` };
|
|
132
|
+
return { kind: "text", text: head.subarray(0, n).toString("utf8"), cut: fs.statSync(p).size > MAX_TEXT };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function asText(p: string): Promise<string> { // what read_document returns: the preview, written out as text
|
|
136
|
+
const v: any = await preview(p);
|
|
137
|
+
if (v.kind === "image") return `${p} is an image: look at it with read.`;
|
|
138
|
+
if (v.kind === "pdf") {
|
|
139
|
+
const { totalPages, text } = await extractText(await getDocumentProxy(new Uint8Array(fs.readFileSync(p))));
|
|
140
|
+
return text.map((t, i) => `--- page ${i + 1} of ${totalPages} ---\n${t}`).join("\n\n");
|
|
141
|
+
}
|
|
142
|
+
if (v.kind === "sheets") return v.sheets.map((s: any) =>
|
|
143
|
+
`--- sheet ${s.name} (${s.rows.length} rows${s.cut ? ", cut" : ""}) ---\n${XLSX.utils.sheet_to_csv(XLSX.utils.aoa_to_sheet(s.rows))}\n`).join("\n");
|
|
144
|
+
if (v.kind === "doc") return v.blocks.map((b: any) => b.table ? "[table]\n" + b.table.map((r: string[]) => r.join(" | ")).join("\n")
|
|
145
|
+
: (b.heading ? "#".repeat(b.heading) + " " : b.item ? "- " : "") + b.text).join("\n");
|
|
146
|
+
if (v.kind === "slides") return v.slides.map((s: any, i: number) => `--- slide ${i + 1}: ${s.title} ---\n` + s.lines.join("\n")
|
|
147
|
+
+ (s.notes ? `\n[notes] ${s.notes}` : "")).join("\n\n");
|
|
148
|
+
return v.text ?? v.reason ?? "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const json = (res: any, v: unknown, status = 200) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(v));
|
|
152
|
+
|
|
153
|
+
export default function (pi: ExtensionAPI) {
|
|
154
|
+
|
|
155
|
+
host.routes.set("/viewer/view", async (req: any, res: any) => {
|
|
156
|
+
const p = inside(new URL(req.url, "http://x").searchParams.get("path"));
|
|
157
|
+
if (!p) return json(res, { error: "no such file in " + DIR }, 404);
|
|
158
|
+
try { json(res, await preview(p)); }
|
|
159
|
+
catch (e: any) { json(res, { kind: "none", reason: `Could not read this file: ${e.name}: ${e.message}` }); } // a broken or unusual file
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
host.routes.set("/viewer/raw", (req: any, res: any) => {
|
|
163
|
+
const p = inside(new URL(req.url, "http://x").searchParams.get("path"));
|
|
164
|
+
const ext = path.extname(p ?? "").toLowerCase();
|
|
165
|
+
if (!p || !(ext in IMAGES || ext === ".pdf")) return res.writeHead(404).end();
|
|
166
|
+
const headers: Record<string, string> = { "content-type": IMAGES[ext] ?? "application/pdf" };
|
|
167
|
+
if (ext === ".svg") headers["content-security-policy"] = "sandbox"; // an SVG's scripts never run, even opened on its own
|
|
168
|
+
res.writeHead(200, headers);
|
|
169
|
+
fs.createReadStream(p).pipe(res);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
host.routes.set("/viewer/shot", (req: any, res: any) => {
|
|
173
|
+
if (req.method !== "POST") return res.writeHead(405).end();
|
|
174
|
+
const chunks: Buffer[] = [];
|
|
175
|
+
let size = 0;
|
|
176
|
+
req.on("data", (c: Buffer) => { size += c.length; if (size <= MAX_SHOT) chunks.push(c); });
|
|
177
|
+
req.on("end", () => {
|
|
178
|
+
const data = Buffer.concat(chunks);
|
|
179
|
+
if (size > MAX_SHOT || !data.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])))
|
|
180
|
+
return json(res, { error: "not a png, or over 10 MB" }, 400);
|
|
181
|
+
fs.mkdirSync(SHOTS, { recursive: true });
|
|
182
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
183
|
+
const p = path.join(SHOTS, `${stamp}-${process.hrtime.bigint() % 1000000n}.png`);
|
|
184
|
+
fs.writeFileSync(p, data);
|
|
185
|
+
json(res, { path: p });
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
pi.registerTool({
|
|
191
|
+
name: "read_document", label: "Read document",
|
|
192
|
+
description: "Read a document as text: docx, pptx, xlsx, xls, csv, pdf, doc, or any text file "
|
|
193
|
+
+ "(sheets come back as CSV, slides and pages numbered).",
|
|
194
|
+
parameters: Type.Object({ path: Type.String() }),
|
|
195
|
+
async execute(_id, { path: p }) {
|
|
196
|
+
let text: string;
|
|
197
|
+
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) text = `error: no file ${p}`;
|
|
198
|
+
else {
|
|
199
|
+
try { text = await asText(p); } catch (e: any) { text = `error: could not read ${p}: ${e.name}: ${e.message}`; }
|
|
200
|
+
if (text.length > MAX_TOOL_TEXT) text = text.slice(0, MAX_TOOL_TEXT) + `\n[... cut: ${text.length} characters in all; read parts of it with bash]`;
|
|
201
|
+
}
|
|
202
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
|
|
207
|
+
.replace("README.md", path.join(import.meta.dirname, "README.md"));
|
|
208
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.viewer = guide; });
|
|
209
|
+
}
|
package/app/mcp.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plugin-map
|
|
3
|
+
description: Find your way around your own code (the pi extensions in your code folder, the page among them) without reading whole files. Use before changing yourself, or before planning work for others that will.
|
|
4
|
+
---
|
|
5
|
+
# Getting a map of your own code
|
|
6
|
+
|
|
7
|
+
Your code folder is the one your prompt names (it holds `extensions/`). Every feature is one extension, and every
|
|
8
|
+
extension is one folder, `extensions/<name>/`, holding all of that feature:
|
|
9
|
+
|
|
10
|
+
| File | What it is |
|
|
11
|
+
|---------------|--------------------------------------------------------------------|
|
|
12
|
+
| `index.ts` | the extension itself (pi loads it); starts with a header comment |
|
|
13
|
+
| `guide.md` | its short prompt section (the part you are reading in your own prompt) |
|
|
14
|
+
| `README.md` | the whole of what the guide says, read when the guide says |
|
|
15
|
+
| `browser.js` | its part of the page (the page extension loads every one it finds) |
|
|
16
|
+
| `skills/` | skills only this feature needs (its index.ts gives them to pi) |
|
|
17
|
+
|
|
18
|
+
An extension can have other files too (`ui/` has its components, `page/` has `page.html`). The headers are the
|
|
19
|
+
documentation, kept with the code, so read them before the code itself.
|
|
20
|
+
|
|
21
|
+
## 1. The map: one line per extension
|
|
22
|
+
From your code folder:
|
|
23
|
+
```
|
|
24
|
+
for d in extensions/*/; do printf '%-24s %s\n' "$d" "$(grep -m1 '^//' "$d/index.ts" | sed 's|^// \?||' | cut -c1-140)"; done
|
|
25
|
+
```
|
|
26
|
+
Also there:
|
|
27
|
+
- `extensions/page/`: the page itself (`page.html`), its list of browser parts, and what serves them.
|
|
28
|
+
- `extensions/ui/components/*.js`: UI components, each starting with a comment line on what it is, then its
|
|
29
|
+
`about` block (props, levers, example; `head -20 <file>`).
|
|
30
|
+
- `skills/<name>/SKILL.md`: skills about you as a whole (this one); a feature's own skills are in its folder.
|
|
31
|
+
- `ui/`: what you built, not code: `surfaces.json` (the surfaces' design) and `apps/<name>/`.
|
|
32
|
+
- The host, `server.ts` one folder up: sessions, the web server, `globalThis.chalky`. Read its header; don't edit it.
|
|
33
|
+
|
|
34
|
+
## 2. The headers of the extensions involved
|
|
35
|
+
```
|
|
36
|
+
awk '/^import /{exit} 1' extensions/swarm/index.ts # everything above the imports
|
|
37
|
+
```
|
|
38
|
+
Read only the headers of the extensions your work touches. All of them at once is over 13,000 characters.
|
|
39
|
+
|
|
40
|
+
## 3. Then only the code you will change
|
|
41
|
+
Use `grep -n` to find the function, then `sed -n 'START,ENDp'` for that part. A whole file can be long
|
|
42
|
+
(swarm's index.ts is over 16,000 characters). If you are planning work for other agents, the headers are usually
|
|
43
|
+
enough: let each agent read the code it owns.
|
|
44
|
+
|
|
45
|
+
## Keep the headers true
|
|
46
|
+
When you change what an extension provides (a tool, a route, a page message, an event, a host service), update
|
|
47
|
+
its header in the same change. Other agents, and you after a reload, rely on it. A new extension is a new folder
|
|
48
|
+
with an `index.ts` whose header starts with a one-line summary: that line is its entry in the map. After editing,
|
|
49
|
+
call the reload tool.
|