@chalky/chalky 0.1.3 → 0.1.4
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/README.md +0 -1
- package/app/SYSTEM.md +3 -2
- package/app/extensions/boards/browser.js +357 -0
- package/app/extensions/boards/guide.md +4 -0
- package/app/extensions/boards/index.ts +98 -0
- package/app/extensions/commands/browser.js +13 -0
- package/app/extensions/commands/guide.md +3 -0
- package/app/extensions/commands/index.ts +96 -0
- package/app/extensions/doodle/browser.js +11 -1
- package/app/extensions/doodle/index.ts +1 -1
- package/app/extensions/frames/browser.js +7 -4
- package/app/extensions/frames/index.ts +16 -7
- package/app/extensions/login/browser.js +363 -0
- package/app/extensions/login/index.ts +214 -0
- package/app/extensions/point/browser.js +4 -2
- package/app/extensions/point/index.ts +18 -3
- package/app/extensions/ui/browser.js +1 -0
- package/app/extensions/ui/index.ts +23 -3
- package/app/extensions/versions/index.ts +2 -2
- package/app/extensions/voice/browser.js +71 -0
- package/app/extensions/voice/index.ts +37 -0
- package/app/settings.json +3 -0
- package/bin/chalky.mjs +1 -1
- package/package.json +1 -1
- package/server.ts +14 -8
package/README.md
CHANGED
|
@@ -26,7 +26,6 @@ Then open http://127.0.0.1:3000.
|
|
|
26
26
|
| `MODEL_BASE_URL` | the endpoint, up to `/v1` |
|
|
27
27
|
| `MODEL_API_KEY` | its key |
|
|
28
28
|
| `MODEL_NAME` | the model (default: `default`) |
|
|
29
|
-
| `MODEL_THINKING` | optional thinking level: `minimal`, `low`, `medium`, `high` |
|
|
30
29
|
| `CHALKY_HOME` | where Chalky keeps its things (default: `~/.chalky`) |
|
|
31
30
|
| `UI_HOST` | the address to serve on (default: `127.0.0.1`, this machine only) |
|
|
32
31
|
| `PORT` | the port to serve on (default: `3000`) |
|
package/app/SYSTEM.md
CHANGED
|
@@ -12,7 +12,7 @@ and {data}/... are paths in those folders. A section that names a README.md is t
|
|
|
12
12
|
extension: read that file when the section says, once (it stays in your conversation).
|
|
13
13
|
|
|
14
14
|
Your tools:
|
|
15
|
-
- read to look at files and pictures (not cat or sed); bash for ls,
|
|
15
|
+
- read to look at files and pictures (not cat or sed); bash for ls, grep, find and running things.
|
|
16
16
|
- edit for precise changes: each edits[].oldText must match the file exactly and only once; keep it as small as
|
|
17
17
|
that allows. Several changes in one file: one edit call with several entries, each matched against the original
|
|
18
18
|
file, none overlapping. An edit whose newText equals its oldText changes nothing and is refused.
|
|
@@ -23,7 +23,8 @@ Changing yourself: when the work needs it, you can change any part of you, the p
|
|
|
23
23
|
extensions, one folder each: {code}/extensions/<name>/. In it, index.ts is `export default function (pi:
|
|
24
24
|
ExtensionAPI) { ... }`, which registers tools (pi.registerTool), prompt sections and event handlers (pi.on); and as
|
|
25
25
|
the extension needs them, guide.md (its short prompt section), README.md (the whole of it), browser.js (its part
|
|
26
|
-
of the page) and skills/. The page itself is the page extension; this text is {code}/SYSTEM.md.
|
|
26
|
+
of the page) and skills/. The page itself is the page extension; this text is {code}/SYSTEM.md; pi's settings (e.g. compaction) are
|
|
27
|
+
{code}/settings.json (pi's docs/settings.md). Extensions reach
|
|
27
28
|
shared state and the web server through globalThis.chalky: the host, the fixed core under them (don't edit it).
|
|
28
29
|
Read pi's extension docs (<paths>) before writing or changing an extension. After editing, call the reload tool:
|
|
29
30
|
your extensions load again when your turn ends, and your conversation is kept. A changed page or browser.js shows
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Browser half of boards: the main space as numbered boards, and the shelf to move between them (above the input).
|
|
2
|
+
// Each board is a container in #top. The one you're on is display: contents (its things sit in the main space as
|
|
3
|
+
// before); the others are laid out but hidden, so apps in them keep running and the shelf can draw them.
|
|
4
|
+
// Any element with data-board="n" that lands in #top (a surface, a frame) is moved into board n's container, and
|
|
5
|
+
// moved again when its data-board changes (a MutationObserver): no extension needs this one. One on the body (a
|
|
6
|
+
// note's pin, fixed to the window) shows only while its board is shown.
|
|
7
|
+
// Other scripts read the board shown from body[data-board] (new things go there), and get a DOM event "board"
|
|
8
|
+
// {from, to} just before it changes (doodle: the drawing so far becomes a step; frames: count this board's).
|
|
9
|
+
// The shelf: a dot per board (yours is bigger). Hover it and the dots open into thumbnails, with + for a new board;
|
|
10
|
+
// click one to go there. It stays clear of the chat panel: more thumbnails than fit scroll between ‹ and › (grey at
|
|
11
|
+
// the ends; a new board scrolls to the end). Drag a surface by its label onto a thumbnail to move it to that board, where it was ({type: "ui_board"}).
|
|
12
|
+
// page messages board_view {board}: the board this page shows; board_add
|
|
13
|
+
// GET /boards {boards, current} event "boards" {boards, added?, by?}
|
|
14
|
+
const CSS = `
|
|
15
|
+
#top > .board:not(.on) { position: absolute; top: 0; left: 0; right: 0; height: 0; overflow: hidden; padding: inherit;
|
|
16
|
+
box-sizing: border-box; visibility: hidden; pointer-events: none; }
|
|
17
|
+
#top > .board.on { display: contents; }
|
|
18
|
+
#top > .board.on:empty::before { content: "What should we build?"; position: absolute; inset: 0; display: grid; place-items: center;
|
|
19
|
+
font-size: 30px; font-weight: 300; letter-spacing: -0.02em; color: rgba(0, 0, 0, 0.2); pointer-events: none; }
|
|
20
|
+
.boards-shelf { position: fixed; left: 50%; bottom: 72px; transform: translateX(-50%); z-index: 25; display: flex; align-items: flex-end;
|
|
21
|
+
gap: 8px; padding: 5px 8px; border-radius: 14px; border: 1px solid transparent; transition: background 0.15s; }
|
|
22
|
+
.boards-shelf.open { background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
23
|
+
border-color: var(--edge); box-shadow: var(--shadow); }
|
|
24
|
+
.boards-shelf .slot { position: relative; display: grid; justify-items: center; gap: 4px; padding: 0; }
|
|
25
|
+
.boards-shelf .slot .x { display: none; position: absolute; top: 3px; right: 3px; width: 16px; height: 16px; border-radius: 50%;
|
|
26
|
+
font-size: 9px; line-height: 16px; text-align: center; color: var(--danger); background: var(--glass-strong); box-shadow: var(--shadow); }
|
|
27
|
+
.boards-shelf.open .slot:hover .x { display: block; }
|
|
28
|
+
.boards-toast { position: fixed; left: 50%; top: 76px; transform: translateX(-50%); /* under the palette: clear of the shelf */ z-index: 27; display: flex; gap: 12px;
|
|
29
|
+
align-items: center; padding: 7px 14px; border-radius: 10px; background: rgba(29, 29, 31, 0.88); color: #fff; font-size: 13px; }
|
|
30
|
+
.boards-toast button { color: #9cc3f5; font-weight: 600; }
|
|
31
|
+
.boards-shelf .dot { width: 7px; height: 7px; border-radius: 50%; background: rgba(0, 0, 0, 0.22); transition: all 0.15s; }
|
|
32
|
+
.boards-shelf .slot.on .dot { width: 11px; height: 11px; background: var(--text); }
|
|
33
|
+
/* more boards than fit (fit(): clear of the chat panel): the thumbnails scroll inside .view, between ‹ and › */
|
|
34
|
+
.boards-shelf .view { overflow: hidden; scroll-behavior: smooth; padding: 3px; }
|
|
35
|
+
.boards-shelf .track { position: relative; display: flex; align-items: flex-end; gap: 8px; width: max-content; }
|
|
36
|
+
.boards-shelf .arrow { display: none; width: 24px; height: 70px; margin-top: 3px; align-self: flex-start; border-radius: 6px;
|
|
37
|
+
font-size: 20px; color: var(--text); }
|
|
38
|
+
.boards-shelf.open.more .arrow { display: block; }
|
|
39
|
+
.boards-shelf .arrow:disabled { opacity: 0.25; cursor: default; }
|
|
40
|
+
.boards-shelf .arrow:not(:disabled):hover { background: var(--hover); }
|
|
41
|
+
.boards-shelf .mini { display: none; position: relative; width: 120px; overflow: hidden; border-radius: 6px;
|
|
42
|
+
background: rgba(255, 255, 255, 0.6); border: 1px solid var(--line); }
|
|
43
|
+
.boards-shelf .mini i, .boards-peek .mini i { position: absolute; overflow: hidden; border-radius: 3px; background: rgba(0, 0, 0, 0.08); }
|
|
44
|
+
/* a surface: a card in its own main colour (light grey when it has none) */
|
|
45
|
+
.boards-shelf .mini i.s, .boards-peek .mini i.s { --accent-of: rgb(200, 203, 210);
|
|
46
|
+
background: color-mix(in srgb, var(--accent-of) 30%, #fff);
|
|
47
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-of) 70%, transparent), 0 1px 3px rgba(0, 0, 0, 0.08); }
|
|
48
|
+
.boards-shelf .mini i.s::before, .boards-peek .mini i.s::before { content: ""; position: absolute; left: 0; right: 0; top: 0;
|
|
49
|
+
height: max(2px, 12%); background: var(--accent-of); }
|
|
50
|
+
.boards-peek .mini i.s.detail { background: #fff; } /* the sketch of its insides shows its colours */
|
|
51
|
+
.boards-peek .mini i.s.detail::before { display: none; }
|
|
52
|
+
.boards-peek .mini i u, .boards-peek .mini i img { display: block; }
|
|
53
|
+
.boards-peek { position: fixed; z-index: 26; transform: translateX(-50%); padding: 6px; border-radius: 12px; pointer-events: none;
|
|
54
|
+
background: var(--glass-strong); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
55
|
+
border: 1px solid var(--edge); box-shadow: var(--shadow); }
|
|
56
|
+
.boards-peek .n { font-size: 11px; color: var(--muted); margin: 0 2px 4px; }
|
|
57
|
+
.boards-peek .mini { position: relative; width: 320px; overflow: hidden; border-radius: 8px; background: rgba(255, 255, 255, 0.6);
|
|
58
|
+
border: 1px solid var(--line); }
|
|
59
|
+
.boards-peek .empty { position: absolute; inset: 0; display: grid; place-items: center; font-size: 12px; color: var(--muted); }
|
|
60
|
+
.boards-shelf .n { display: none; font-size: 10.5px; color: var(--muted); }
|
|
61
|
+
.boards-shelf.open .mini, .boards-shelf.open .n { display: block; }
|
|
62
|
+
.boards-shelf.open .dot { display: none; }
|
|
63
|
+
.boards-shelf .slot.on .mini { outline: 2px solid var(--text); outline-offset: 1px; }
|
|
64
|
+
.boards-shelf .slot.drop .mini { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
65
|
+
.boards-shelf .add { display: none; width: 122px; height: 70px; border-radius: 6px; /* a thumbnail's size, border included */ border: 1px dashed rgba(0, 0, 0, 0.25);
|
|
66
|
+
color: var(--muted); font-size: 13px; align-self: flex-start; margin-top: 3px; }
|
|
67
|
+
.boards-shelf.open .add { display: block; } /* only in the open shelf: at rest it's just the dots */`;
|
|
68
|
+
|
|
69
|
+
export default async function (page) {
|
|
70
|
+
document.head.insertAdjacentHTML("beforeend", `<style>${CSS}</style>`);
|
|
71
|
+
const top = page.slot("top");
|
|
72
|
+
const containers = {};
|
|
73
|
+
let boards = [1], current = 1, drag = null, over = null, adding = false; // adding: this page clicked +
|
|
74
|
+
|
|
75
|
+
const container = (n) => {
|
|
76
|
+
if (!containers[n]) {
|
|
77
|
+
containers[n] = Object.assign(document.createElement("div"), { className: "board" });
|
|
78
|
+
containers[n].dataset.n = n;
|
|
79
|
+
top.append(containers[n]);
|
|
80
|
+
}
|
|
81
|
+
return containers[n];
|
|
82
|
+
};
|
|
83
|
+
const home = (el) => { // an element with data-board: into its board's container
|
|
84
|
+
const c = container(Number(el.dataset.board) || 1);
|
|
85
|
+
if (el.parentElement !== c) c.append(el);
|
|
86
|
+
};
|
|
87
|
+
new MutationObserver((records) => {
|
|
88
|
+
for (const r of records) {
|
|
89
|
+
if (r.type === "attributes") home(r.target);
|
|
90
|
+
else for (const el of r.addedNodes) if (el.parentElement === top && el.dataset?.board) home(el);
|
|
91
|
+
}
|
|
92
|
+
}).observe(top, { childList: true, subtree: true, attributeFilter: ["data-board"] });
|
|
93
|
+
|
|
94
|
+
// the shelf: ‹ [the thumbnails, scrolling] › + (+ outside the scrolling part, so it is always there)
|
|
95
|
+
const shelf = Object.assign(document.createElement("div"), { className: "boards-shelf" });
|
|
96
|
+
const button = (className, textContent, title, onclick) => Object.assign(document.createElement("button"), { className, textContent, title, onclick });
|
|
97
|
+
const view = Object.assign(document.createElement("div"), { className: "view" });
|
|
98
|
+
const track = Object.assign(document.createElement("div"), { className: "track" });
|
|
99
|
+
const step = (d) => view.scrollBy({ left: d * Math.max(130, view.clientWidth - 130) }); // about a view's width
|
|
100
|
+
const prev = button("arrow", "‹", "Earlier boards", () => step(-1));
|
|
101
|
+
const next = button("arrow", "›", "Later boards", () => step(1));
|
|
102
|
+
const add = button("add", "+", "New board", () => { adding = true; page.send({ type: "board_add" }); });
|
|
103
|
+
view.append(track);
|
|
104
|
+
shelf.append(prev, view, next, add);
|
|
105
|
+
document.body.append(shelf);
|
|
106
|
+
shelf.onmouseenter = () => open(true);
|
|
107
|
+
shelf.onmouseleave = () => { if (!drag) open(false); };
|
|
108
|
+
view.onscroll = arrows;
|
|
109
|
+
// the wheel's left/right (or up/down): each click moves the strip as an arrow does; the page doesn't scroll. At
|
|
110
|
+
// most one step per 250 ms, as a trackpad swipe sends many small wheel events
|
|
111
|
+
let lastWheel = 0;
|
|
112
|
+
shelf.addEventListener("wheel", (e) => {
|
|
113
|
+
const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
|
114
|
+
if (!shelf.classList.contains("more") || !d) return;
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
if (Date.now() - lastWheel < 250) return;
|
|
117
|
+
lastWheel = Date.now();
|
|
118
|
+
step(Math.sign(d));
|
|
119
|
+
}, { passive: false });
|
|
120
|
+
addEventListener("resize", fit);
|
|
121
|
+
new MutationObserver(fit).observe(document.body, { attributeFilter: ["data-panel"] }); // the chat panel opened or closed
|
|
122
|
+
|
|
123
|
+
function fit() { // as wide as fits between the chat panel (or the rail) and the same gap on the right, centred
|
|
124
|
+
const side = ["messages", "versions"].includes(document.body.dataset.panel) ? "panel" : "rail";
|
|
125
|
+
const left = document.getElementById(side)?.getBoundingClientRect().right ?? 0;
|
|
126
|
+
view.style.maxWidth = `${Math.max(130, innerWidth - 2 * (left + 16) - 2 * 32 - 140)}px`; // the arrows and + beside it
|
|
127
|
+
shelf.classList.toggle("more", track.scrollWidth > view.clientWidth + 1);
|
|
128
|
+
arrows();
|
|
129
|
+
}
|
|
130
|
+
function arrows() { // grey at the ends
|
|
131
|
+
prev.disabled = view.scrollLeft <= 0;
|
|
132
|
+
next.disabled = view.scrollLeft + view.clientWidth >= view.scrollWidth - 1;
|
|
133
|
+
}
|
|
134
|
+
function reveal() { // the board shown, in view (a new one: the strip goes to the end)
|
|
135
|
+
const s = track.querySelector(".slot.on");
|
|
136
|
+
if (s && (s.offsetLeft < view.scrollLeft || s.offsetLeft + s.offsetWidth > view.scrollLeft + view.clientWidth))
|
|
137
|
+
view.scrollLeft = s.offsetLeft - (view.clientWidth - s.offsetWidth) / 2;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const others = document.createElement("style"); // hides what is on the body for other boards (a note's pin)
|
|
141
|
+
document.head.append(others);
|
|
142
|
+
function show(n, tell = true) { // go to board n (tell: the server, which the first show got it from)
|
|
143
|
+
// before the switch, so a doodle in progress becomes a step with a picture of this board
|
|
144
|
+
if (n !== current) document.dispatchEvent(new CustomEvent("board", { detail: { from: current, to: n } }));
|
|
145
|
+
current = n;
|
|
146
|
+
document.body.dataset.board = n;
|
|
147
|
+
others.textContent = `body > [data-board]:not([data-board="${n}"]) { display: none !important; }`;
|
|
148
|
+
for (const b of boards) container(b).classList.toggle("on", b === n);
|
|
149
|
+
if (tell) page.send({ type: "board_view", board: n });
|
|
150
|
+
render();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function render() {
|
|
154
|
+
const shown = boards.filter((n) => n !== pending?.n);
|
|
155
|
+
track.replaceChildren(...shown.map((n) => {
|
|
156
|
+
const slot = document.createElement("button");
|
|
157
|
+
slot.className = "slot" + (n === current ? " on" : "");
|
|
158
|
+
slot.dataset.n = n;
|
|
159
|
+
slot.title = `Board ${n}`;
|
|
160
|
+
slot.innerHTML = `<span class="mini"></span><span class="dot"></span><span class="n">${n}</span>` +
|
|
161
|
+
(shown.length > 1 ? `<span class="x" role="button" title="Remove board ${n} and everything on it">✕</span>` : ""); // the last board stays
|
|
162
|
+
slot.onclick = (e) => (e.target.closest(".x") ? removeBoard(n) : show(n));
|
|
163
|
+
slot.onmouseenter = () => showPeek(slot);
|
|
164
|
+
slot.onmouseleave = () => { peek.hidden = true; }; // off it (onto +, a gap): no preview
|
|
165
|
+
return slot;
|
|
166
|
+
}));
|
|
167
|
+
if (shelf.classList.contains("open")) { thumbnails(); fit(); reveal(); }
|
|
168
|
+
else fit();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ✕ on a thumbnail: the board leaves the shelf with a few seconds of Undo, and only then goes from the server
|
|
172
|
+
// (board_remove: its things go with it). Leaving the page in those seconds removes it at once.
|
|
173
|
+
let pending = null; // {n, was: the board shown when ✕ was clicked, timer}
|
|
174
|
+
const toast = Object.assign(document.createElement("div"), { className: "boards-toast", hidden: true });
|
|
175
|
+
document.body.append(toast);
|
|
176
|
+
function removeBoard(n) {
|
|
177
|
+
if (pending) commit();
|
|
178
|
+
const i = boards.indexOf(n);
|
|
179
|
+
pending = { n, was: current, timer: setTimeout(commit, 5000) };
|
|
180
|
+
peek.hidden = true;
|
|
181
|
+
if (current === n) show(boards.filter((b) => b !== n)[Math.max(0, i - 1)]); // the one before it (else after)
|
|
182
|
+
else render();
|
|
183
|
+
toast.replaceChildren(`Board ${n} removed`, button("", "Undo", "Bring it back", undo));
|
|
184
|
+
toast.hidden = false;
|
|
185
|
+
}
|
|
186
|
+
function commit() {
|
|
187
|
+
clearTimeout(pending.timer);
|
|
188
|
+
page.send({ type: "board_remove", board: pending.n });
|
|
189
|
+
pending = null;
|
|
190
|
+
toast.hidden = true;
|
|
191
|
+
}
|
|
192
|
+
function undo() {
|
|
193
|
+
clearTimeout(pending.timer);
|
|
194
|
+
const { n, was } = pending;
|
|
195
|
+
pending = null;
|
|
196
|
+
toast.hidden = true;
|
|
197
|
+
if (was === n) show(n);
|
|
198
|
+
else render();
|
|
199
|
+
}
|
|
200
|
+
addEventListener("pagehide", () => { if (pending) commit(); });
|
|
201
|
+
|
|
202
|
+
function thumbnails() {
|
|
203
|
+
for (const slot of shelf.querySelectorAll(".slot")) drawBoard(slot.querySelector(".mini"), Number(slot.dataset.n), 120, 68);
|
|
204
|
+
}
|
|
205
|
+
// board n's things where they are in the main space, scaled down into mini (w x h): a surface is a small card in
|
|
206
|
+
// its main colour (accentOf), no words. Zoomed to fit what's on the board (not the whole window), so the cards fill it.
|
|
207
|
+
function drawBoard(mini, n, W, H) {
|
|
208
|
+
const PAD = 12, els = [...container(n).children];
|
|
209
|
+
const rects = els.map((el) => el.getBoundingClientRect());
|
|
210
|
+
const x0 = Math.min(...rects.map((r) => r.left)) - PAD, y0 = Math.min(...rects.map((r) => r.top)) - PAD;
|
|
211
|
+
const k = Math.min(W / (Math.max(...rects.map((r) => r.right)) + PAD - x0), H / (Math.max(...rects.map((r) => r.bottom)) + PAD - y0), 0.5);
|
|
212
|
+
mini.style.height = `${H}px`;
|
|
213
|
+
mini.replaceChildren(...els.map((el, n) => {
|
|
214
|
+
const r = rects[n], i = document.createElement("i");
|
|
215
|
+
if (el.dataset.surface) {
|
|
216
|
+
const accent = accentOf(el.querySelector(".surface-body") ?? el);
|
|
217
|
+
i.className = "s";
|
|
218
|
+
i.title = el.dataset.surface;
|
|
219
|
+
if (accent) i.style.setProperty("--accent-of", accent);
|
|
220
|
+
if (W > 200) { i.classList.add("detail"); sketch(el, i, k); } // the big preview: what's inside it
|
|
221
|
+
}
|
|
222
|
+
Object.assign(i.style, { left: `${(r.left - x0) * k}px`, top: `${(r.top - y0) * k}px`,
|
|
223
|
+
width: `${Math.max(2, r.width * k)}px`, height: `${Math.max(2, r.height * k)}px` });
|
|
224
|
+
return i;
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
// hover one thumbnail: a bigger picture of that board above the shelf, to read it
|
|
228
|
+
const peek = Object.assign(document.createElement("div"), { className: "boards-peek", hidden: true });
|
|
229
|
+
document.body.append(peek);
|
|
230
|
+
function showPeek(slot) {
|
|
231
|
+
if (!slot || drag) return (peek.hidden = true);
|
|
232
|
+
const n = Number(slot.dataset.n), mini = Object.assign(document.createElement("div"), { className: "mini" });
|
|
233
|
+
peek.replaceChildren(Object.assign(document.createElement("div"), { className: "n", textContent: `Board ${n}` }), mini);
|
|
234
|
+
peek.hidden = false;
|
|
235
|
+
drawBoard(mini, n, 320, 180);
|
|
236
|
+
if (!mini.children.length) mini.append(Object.assign(document.createElement("span"), { className: "empty", textContent: "Empty" }));
|
|
237
|
+
const s = slot.getBoundingClientRect(), half = peek.offsetWidth / 2;
|
|
238
|
+
peek.style.left = `${Math.min(innerWidth - half - 8, Math.max(half + 8, s.left + s.width / 2))}px`;
|
|
239
|
+
peek.style.bottom = `${innerHeight - shelf.getBoundingClientRect().top + 8}px`;
|
|
240
|
+
}
|
|
241
|
+
// a surface's insides, scaled by k into its card (the big preview): each line of text a bar in its colour, coloured
|
|
242
|
+
// areas (buttons, filled boxes) in theirs, pictures and canvases (a game) as small images. Apps' pages too (same
|
|
243
|
+
// server). No words. At most 400 pieces a surface.
|
|
244
|
+
function sketch(surface, card, k) {
|
|
245
|
+
const base = surface.getBoundingClientRect();
|
|
246
|
+
const put = (tag, r, dx, dy, style) => {
|
|
247
|
+
if (r.width * k < 0.5 || r.height * k < 0.5) return null;
|
|
248
|
+
const e = card.appendChild(document.createElement(tag));
|
|
249
|
+
Object.assign(e.style, { position: "absolute", left: `${(r.left + dx - base.left) * k}px`, top: `${(r.top + dy - base.top) * k}px`,
|
|
250
|
+
width: `${r.width * k}px`, height: `${r.height * k}px` }, style);
|
|
251
|
+
return e;
|
|
252
|
+
};
|
|
253
|
+
const roots = [[surface.querySelector(".surface-body") ?? surface, 0, 0]];
|
|
254
|
+
for (const f of surface.querySelectorAll("iframe")) {
|
|
255
|
+
try { const r = f.getBoundingClientRect(); if (f.contentDocument?.body) roots.push([f.contentDocument.body, r.left + f.clientLeft, r.top + f.clientTop]); } catch {}
|
|
256
|
+
}
|
|
257
|
+
let n = 0;
|
|
258
|
+
for (const [root, dx, dy] of roots) {
|
|
259
|
+
const win = root.ownerDocument.defaultView, walk = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
|
260
|
+
for (let node = walk.currentNode; node && n < 400; node = walk.nextNode()) {
|
|
261
|
+
if (node.nodeType === 3) { // text: a bar per line, in its colour
|
|
262
|
+
if (!node.data.trim() || node.parentElement.closest("style, script")) continue;
|
|
263
|
+
const range = root.ownerDocument.createRange();
|
|
264
|
+
range.selectNodeContents(node);
|
|
265
|
+
const color = win.getComputedStyle(node.parentElement).color;
|
|
266
|
+
for (const r of range.getClientRects())
|
|
267
|
+
if (put("u", { left: r.left, top: r.top + r.height * 0.3, width: r.width, height: r.height * 0.4 }, dx, dy,
|
|
268
|
+
{ background: color, opacity: "0.45", borderRadius: "1px" })) n++;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const cs = win.getComputedStyle(node), r = node.getBoundingClientRect();
|
|
272
|
+
if (node.localName === "img" && node.currentSrc) { const e = put("img", r, dx, dy, { objectFit: cs.objectFit }); if (e) { e.src = node.currentSrc; n++; } }
|
|
273
|
+
else if (node.localName === "canvas") {
|
|
274
|
+
let src = null;
|
|
275
|
+
try { src = node.toDataURL("image/png"); } catch {} // a canvas painted from another site can't be read
|
|
276
|
+
const e = put(src ? "img" : "u", r, dx, dy, src ? {} : { background: "rgba(0, 0, 0, 0.12)" });
|
|
277
|
+
if (e && src) e.src = src;
|
|
278
|
+
if (e) n++;
|
|
279
|
+
} else if ((cs.backgroundColor.match(/[\d.]+/g)?.[3] ?? 1) > 0.05 && !/^rgba?\(255, 255, 255/.test(cs.backgroundColor) && cs.backgroundColor !== "transparent") {
|
|
280
|
+
if (put("u", r, dx, dy, { background: cs.backgroundColor, borderRadius: `${parseFloat(cs.borderTopLeftRadius) * k}px` })) n++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// the colour a thing shows most: each coloured background counts by its area, a border or text a little; whites,
|
|
286
|
+
// greys and blacks don't count. Apps are pages from this server, so their frame's insides count too. None: null
|
|
287
|
+
function accentOf(node) {
|
|
288
|
+
const roots = [node, ...[...node.querySelectorAll("iframe")].map((f) => { try { return f.contentDocument?.body; } catch { return null; } })];
|
|
289
|
+
const score = {};
|
|
290
|
+
const add = (color, weight) => {
|
|
291
|
+
const m = color.match(/[\d.]+/g)?.map(Number);
|
|
292
|
+
if (!m || (m[3] ?? 1) < 0.3) return;
|
|
293
|
+
const [r, g, b] = m, max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 510;
|
|
294
|
+
const sat = max === min ? 0 : (max - min) / 255 / (1 - Math.abs(2 * l - 1));
|
|
295
|
+
if (sat < 0.25 || l < 0.12 || l > 0.92) return; // white, grey, black
|
|
296
|
+
const key = `rgb(${r & 0xf0},${g & 0xf0},${b & 0xf0})`; // near colours count together
|
|
297
|
+
score[key] = { color: `rgb(${r},${g},${b})`, n: (score[key]?.n ?? 0) + weight };
|
|
298
|
+
};
|
|
299
|
+
for (const root of roots.filter(Boolean)) {
|
|
300
|
+
for (const e of [root, ...root.querySelectorAll("*")].slice(0, 400)) {
|
|
301
|
+
const cs = e.ownerDocument.defaultView.getComputedStyle(e);
|
|
302
|
+
add(cs.backgroundColor, Math.min(e.offsetWidth * e.offsetHeight, 60000));
|
|
303
|
+
if (parseFloat(cs.borderTopWidth) > 0) add(cs.borderTopColor, 400);
|
|
304
|
+
if ([...e.childNodes].some((c) => c.nodeType === 3 && c.data.trim())) add(cs.color, 300);
|
|
305
|
+
if (e.localName === "svg" || e.closest("svg")) add(cs.fill, 600);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return Object.values(score).sort((a, b) => b.n - a.n)[0]?.color ?? null;
|
|
309
|
+
}
|
|
310
|
+
function open(yes) {
|
|
311
|
+
shelf.classList.toggle("open", yes);
|
|
312
|
+
if (yes) { thumbnails(); fit(); reveal(); }
|
|
313
|
+
else { peek.hidden = true; fit(); }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// drag a surface (by its label: ui/browser.js moves it) onto a thumbnail: it goes to that board, at the place it had
|
|
317
|
+
document.addEventListener("pointerdown", (e) => {
|
|
318
|
+
const box = e.target.closest?.("[data-surface] > .surface-label")?.parentElement;
|
|
319
|
+
if (!box || e.target.closest("button")) return;
|
|
320
|
+
const px = (v) => (v ? parseFloat(v) : undefined);
|
|
321
|
+
drag = { id: box.dataset.surface, board: Number(box.dataset.board) || 1,
|
|
322
|
+
place: box.hasAttribute("data-placed") ? { x: box.offsetLeft, y: box.offsetTop, width: px(box.style.width), height: px(box.style.height) } : null };
|
|
323
|
+
});
|
|
324
|
+
document.addEventListener("pointermove", (e) => {
|
|
325
|
+
if (!drag) return;
|
|
326
|
+
const r = shelf.getBoundingClientRect();
|
|
327
|
+
if (!shelf.classList.contains("open") && e.clientY > r.top - 80 && e.clientX > r.left - 80 && e.clientX < r.right + 80) open(true);
|
|
328
|
+
const under = document.elementsFromPoint(e.clientX, e.clientY);
|
|
329
|
+
const now = under.find((el) => el.matches(".boards-shelf .slot")) ?? null;
|
|
330
|
+
if (now !== over) { over?.classList.remove("drop"); over = now; over?.classList.add("drop"); }
|
|
331
|
+
const arrow = under.find((el) => el.matches(".boards-shelf .arrow:not(:disabled)")); // held over ‹ or ›: the strip moves on
|
|
332
|
+
if (arrow && Date.now() - lastStep > 600) { lastStep = Date.now(); step(arrow === prev ? -1 : 1); }
|
|
333
|
+
});
|
|
334
|
+
let lastStep = 0;
|
|
335
|
+
document.addEventListener("pointerup", () => {
|
|
336
|
+
if (!drag) return;
|
|
337
|
+
const to = over && Number(over.dataset.n);
|
|
338
|
+
if (to && to !== drag.board) page.send({ type: "ui_board", surface: drag.id, board: to, place: drag.place });
|
|
339
|
+
over?.classList.remove("drop");
|
|
340
|
+
drag = over = null;
|
|
341
|
+
if (!shelf.matches(":hover")) open(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
page.on("boards", (ev) => {
|
|
345
|
+
boards = ev.boards;
|
|
346
|
+
if (ev.removed) { // its things went with it (their own events); its container goes now
|
|
347
|
+
containers[ev.removed]?.remove();
|
|
348
|
+
delete containers[ev.removed];
|
|
349
|
+
if (!boards.includes(current)) return show(boards[0]); // another page removed the board this one shows
|
|
350
|
+
}
|
|
351
|
+
if (ev.added && ev.by === "user" && adding) { adding = false; return show(ev.added); } // the + clicked here: go there
|
|
352
|
+
render();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
try { ({ boards, current } = await (await fetch("/boards")).json()); } catch {}
|
|
356
|
+
show(current, false); // nothing to tell: the server said it (and the page's WebSocket may not be open yet)
|
|
357
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Boards
|
|
2
|
+
The user's work is on numbered boards; the page shows one at a time. A plain message doesn't say which board the
|
|
3
|
+
user means: when it names one, use that; when it is about something already built, use that thing's board; else
|
|
4
|
+
call boards() (it says which board the user is on) and, if it is still unclear, ask the user.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Boards: the main space is several numbered boards, and everything on it (surfaces, frames, notes; a doodle's
|
|
2
|
+
// steps when sent) has a board number. Numbers are never reused: remove board 2 and the rest stay 1, 3, 4, so "board 3" keeps
|
|
3
|
+
// meaning the same board to the user and to every agent. A thing with no number is on board 1.
|
|
4
|
+
// app/ui/boards.json {last: <last number>, boards: [numbers]}: next to surfaces.json, so a rollback of the code
|
|
5
|
+
// brings back both together (read each time: never stale after one)
|
|
6
|
+
// The board the user is looking at is the page's to say (in memory; board 1 until it does). browser.js: the shelf.
|
|
7
|
+
// Tool boards(board?): the boards and what is on each, and the board the user is on. Others use host.api.boards:
|
|
8
|
+
// list() -> [numbers] has(n) add(by?) -> the new number current() -> the user's board
|
|
9
|
+
// onRemove(name, fn) remove(n): see below
|
|
10
|
+
// GET /boards {boards, current} page messages board_view {board}, board_add, board_remove {board}
|
|
11
|
+
// event "boards" {boards, added, by}: a board was added (by "user": that page goes there); {boards, removed}
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
|
|
17
|
+
const host = (globalThis as any).chalky;
|
|
18
|
+
const FILE = path.join(host.app, "ui/boards.json");
|
|
19
|
+
const read = () => (fs.existsSync(FILE) ? JSON.parse(fs.readFileSync(FILE, "utf8")) : { last: 1, boards: [1] });
|
|
20
|
+
if (!host.shared.has("board-view")) host.shared.set("board-view", { current: 1 });
|
|
21
|
+
const view = host.shared.get("board-view");
|
|
22
|
+
|
|
23
|
+
host.api.boards = {
|
|
24
|
+
list: () => read().boards as number[],
|
|
25
|
+
has: (n: number) => read().boards.includes(n),
|
|
26
|
+
add(by = "agent") { // by "user": the page that asked goes there
|
|
27
|
+
const saved = read();
|
|
28
|
+
saved.boards.push(++saved.last);
|
|
29
|
+
fs.writeFileSync(FILE, JSON.stringify(saved));
|
|
30
|
+
host.broadcast("boards", { boards: saved.boards, added: saved.last, by });
|
|
31
|
+
return saved.last;
|
|
32
|
+
},
|
|
33
|
+
current: () => (read().boards.includes(view.current) ? view.current : read().boards[0]),
|
|
34
|
+
// Removing a board (the user's ✕, after its few seconds of Undo on the page): each extension takes its own things
|
|
35
|
+
// off it (onRemove(name, fn): fn(n) deletes them and returns [{owner, what}], e.g. {owner: "agent2", what:
|
|
36
|
+
// "surface prices"}; name: the extension's, so its reload replaces its own). Each agent that owned something gets
|
|
37
|
+
// one fyi (no turn of its own: it reads it on its next one). The last board stays.
|
|
38
|
+
onRemove: (name: string, fn: (n: number) => { owner?: string; what: string }[]) => removers.set(name, fn),
|
|
39
|
+
remove(n: number) {
|
|
40
|
+
const saved = read();
|
|
41
|
+
if (!saved.boards.includes(n) || saved.boards.length === 1) return;
|
|
42
|
+
const gone: { owner?: string; what: string }[] = [];
|
|
43
|
+
for (const [name, fn] of removers) {
|
|
44
|
+
try { gone.push(...fn(n)); }
|
|
45
|
+
catch (e) { host.broadcast("error", { agent: "server", text: `board ${n} removal, ${name}: ${e}` }); }
|
|
46
|
+
}
|
|
47
|
+
saved.boards = saved.boards.filter((b: number) => b !== n);
|
|
48
|
+
fs.writeFileSync(FILE, JSON.stringify(saved));
|
|
49
|
+
host.broadcast("boards", { boards: saved.boards, removed: n });
|
|
50
|
+
const byOwner = new Map<string, string[]>();
|
|
51
|
+
for (const g of gone) if (g.owner && host.agents.has(g.owner)) byOwner.set(g.owner, [...(byOwner.get(g.owner) ?? []), g.what]);
|
|
52
|
+
for (const [owner, things] of byOwner)
|
|
53
|
+
host.api.swarm?.fyi("user", owner, `[board removed] The user removed board ${n}; your ${things.join(", ")} went with it.`);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
// on the host, so they outlive this file's reloads (as ui's watchers)
|
|
57
|
+
const removers: Map<string, (n: number) => { owner?: string; what: string }[]> = host.shared.get("board-removers") ?? new Map();
|
|
58
|
+
host.shared.set("board-removers", removers);
|
|
59
|
+
|
|
60
|
+
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: undefined });
|
|
61
|
+
|
|
62
|
+
export default function (pi: ExtensionAPI) {
|
|
63
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8");
|
|
64
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.boards = guide; });
|
|
65
|
+
|
|
66
|
+
pi.registerTool({
|
|
67
|
+
name: "boards", label: "Boards",
|
|
68
|
+
description: "List the boards, what is on each, and the board the user is on. With a board: that one in detail.",
|
|
69
|
+
parameters: Type.Object({ board: Type.Optional(Type.Number()) }),
|
|
70
|
+
async execute(_id, a) {
|
|
71
|
+
// what has a board: surfaces (ui/), frames not filled yet (frames/), open notes (point/); doodles aren't kept
|
|
72
|
+
const of = (m: any) => Object.entries(m ?? {}) as [string, any][];
|
|
73
|
+
const kinds = (n: number) => [of(host.shared.get("ui")?.surfaces), of(host.shared.get("frames")?.frames), of(host.shared.get("point")?.open)]
|
|
74
|
+
.map((list) => list.filter(([, r]) => (r.board ?? 1) === n));
|
|
75
|
+
const head = `the user is on board ${host.api.boards.current()}`;
|
|
76
|
+
if (a.board === undefined)
|
|
77
|
+
return result([head, ...host.api.boards.list().map((n: number) => {
|
|
78
|
+
const [s, f, notes] = kinds(n);
|
|
79
|
+
return `${n} ${[s.map(([id]) => id).join(", "), f.map(([id]) => id).join(", "), notes.map(([id]) => `note ${id}`).join(", ")]
|
|
80
|
+
.filter(Boolean).join(" · ") || "(empty)"}`;
|
|
81
|
+
})].join("\n"));
|
|
82
|
+
if (!host.api.boards.has(a.board)) return result(`error: no board ${a.board}. Boards: ${host.api.boards.list().join(", ")}`);
|
|
83
|
+
const [s, f, notes] = kinds(a.board);
|
|
84
|
+
return result([head, `board ${a.board}:`,
|
|
85
|
+
...s.map(([id, r]) => ` surface ${id} · by ${r.owner} · place ${JSON.stringify(r.place)}${r.pinned ? " · pinned" : ""}`),
|
|
86
|
+
...f.map(([id, r]) => ` frame ${id} "${r.text}" · ${r.sent ? `sent to ${r.to}` : "not sent"} · place ${JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height })}`),
|
|
87
|
+
...notes.map(([id, r]) => ` note ${id} "${r.text}" · to ${r.to}`)].join("\n") + (s.length + f.length + notes.length ? "" : "\n (empty)"));
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
host.routes.set("/boards", (_req: any, res: any) => res.writeHead(200, { "content-type": "application/json" })
|
|
92
|
+
.end(JSON.stringify({ boards: host.api.boards.list(), current: host.api.boards.current() })));
|
|
93
|
+
host.pageHandlers.set("boards", (msg: any) => {
|
|
94
|
+
if (msg.type === "board_view" && host.api.boards.has(Number(msg.board))) view.current = Number(msg.board);
|
|
95
|
+
else if (msg.type === "board_add") host.api.boards.add("user");
|
|
96
|
+
else if (msg.type === "board_remove") host.api.boards.remove(Number(msg.board));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Browser half of the commands extension: the agents' pi commands (GET /commands) join the "/" list in the chat
|
|
2
|
+
// input. One runs on the agent picked in "to" (agent1 if none), with the words after it.
|
|
3
|
+
export default function (page) {
|
|
4
|
+
const target = () => document.getElementById("to")?.value || "main";
|
|
5
|
+
page.on("commands", (ev) => page.note(`${ev.agent}: ${ev.text}`));
|
|
6
|
+
fetch("/commands").then((r) => r.ok ? r.json() : []).then((list) => {
|
|
7
|
+
for (const c of list) page.command(c.name, (rest) => {
|
|
8
|
+
const agent = target() === "main" ? "agent1" : target();
|
|
9
|
+
page.note(`/${c.name} → ${agent}`);
|
|
10
|
+
page.send({ type: "command", agent, text: `/${c.name} ${rest}`.trim() });
|
|
11
|
+
}, c.description);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Agent commands: pi's built-in "/" commands live in its terminal UI only, so an SDK session doesn't have them. Each
|
|
2
|
+
// one here is a pi command (pi.registerCommand) that calls what the built-in calls (pi's docs/extensions.md).
|
|
3
|
+
// The page lists every pi command an agent has and sends one to the agent picked in "to" (pi runs it at once, even
|
|
4
|
+
// mid-run: session.prompt checks for commands first).
|
|
5
|
+
// GET /commands -> [{name, description}] (agent1's, from app/extensions)
|
|
6
|
+
// page message command {agent, text: "/name args"}
|
|
7
|
+
// page event commands {agent, text} -> a plain note (e.g. /compact with nothing new)
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
const host = (globalThis as any).chalky;
|
|
13
|
+
|
|
14
|
+
export default function (pi: ExtensionAPI) {
|
|
15
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8");
|
|
16
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.commands = guide; });
|
|
17
|
+
|
|
18
|
+
// pi's /compact: summarize the conversation now (the page shows "history compacted" when it's done). Whatever its
|
|
19
|
+
// size: keepRecentTokens is 0 for this one (in memory, not saved), so all but the newest message is summarized;
|
|
20
|
+
// the automatic compaction keeps app/settings.json's.
|
|
21
|
+
pi.registerCommand("compact", {
|
|
22
|
+
description: "Summarize the agent's conversation now (words after it: what the summary should focus on)",
|
|
23
|
+
handler: async (args, ctx) => {
|
|
24
|
+
const agent = pi.getSessionName()!;
|
|
25
|
+
// nothing said since the last compaction (its note is the newest entry): skip, it would only re-summarize the summary
|
|
26
|
+
const last = ctx.sessionManager.getLeafEntry();
|
|
27
|
+
if (last?.type === "compaction" || (last?.type === "custom_message" && last.customType === "compacted"))
|
|
28
|
+
return host.broadcast("commands", { agent, text: "nothing new since the last compaction" });
|
|
29
|
+
const settings = host.agents.get(agent).session.settingsManager;
|
|
30
|
+
const keep = settings.getSettings().compaction?.keepRecentTokens;
|
|
31
|
+
settings.applyOverrides({ compaction: { keepRecentTokens: 0 } });
|
|
32
|
+
const restore = () => settings.applyOverrides({ compaction: { keepRecentTokens: keep } });
|
|
33
|
+
ctx.compact({ customInstructions: args.trim() || undefined, onComplete: restore,
|
|
34
|
+
onError: (e) => { restore(); host.broadcast("error", { agent, text: `compact: ${e.message}` }); } });
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// pi's /thinking: how hard this agent thinks (sent as reasoning_effort; "off": nothing is sent, the model's own
|
|
39
|
+
// default). pi saves it in the agent's session file, so it stays after a restart.
|
|
40
|
+
pi.registerCommand("thinking", {
|
|
41
|
+
description: "Set how hard the agent thinks: off, minimal, low, medium, high (none: show it)",
|
|
42
|
+
handler: async (args, ctx) => {
|
|
43
|
+
const agent = pi.getSessionName()!;
|
|
44
|
+
const session = host.agents.get(agent).session;
|
|
45
|
+
const levels = session.getAvailableThinkingLevels().join(", ");
|
|
46
|
+
const level = args.trim();
|
|
47
|
+
if (level && !session.getAvailableThinkingLevels().includes(level))
|
|
48
|
+
return host.broadcast("error", { agent, text: `thinking: "${level}" isn't one of ${levels}` });
|
|
49
|
+
if (level) pi.setThinkingLevel(level);
|
|
50
|
+
host.broadcast("commands", { agent, text: `thinking ${pi.getThinkingLevel()}${level ? "" : ` (${levels})`}` });
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// pi's /new: an empty conversation for this agent (the swarm's reset: a new session file, role and thinking kept;
|
|
55
|
+
// the old file stays). After this command returns: the reset replaces the session that is running it.
|
|
56
|
+
pi.registerCommand("new", {
|
|
57
|
+
description: "Start a new, empty conversation for the agent (its role stays; the old one is kept on disk)",
|
|
58
|
+
handler: async () => {
|
|
59
|
+
const agent = pi.getSessionName()!;
|
|
60
|
+
const thinking = pi.getThinkingLevel();
|
|
61
|
+
setTimeout(async () => {
|
|
62
|
+
const error = await host.api.swarm.reset([agent]);
|
|
63
|
+
if (error) return host.broadcast("error", { agent, text: `new: ${error}` });
|
|
64
|
+
host.agents.get(agent).session.setThinkingLevel(thinking);
|
|
65
|
+
host.broadcast("commands", { agent, text: "new conversation" });
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// after any compaction (/compact or pi's automatic one): a note right after the summary, so the agent knows it just
|
|
71
|
+
// happened and where the full conversation still is (pi only appends to the session file; nothing is deleted)
|
|
72
|
+
pi.on("session_compact", (event, ctx) => {
|
|
73
|
+
const time = new Date().toTimeString().slice(0, 8);
|
|
74
|
+
pi.sendMessage({ customType: "compacted", display: false, content: `[note from system · ${time}]\nYour ` +
|
|
75
|
+
`conversation was just compacted (${event.reason}): what came before is the summary above. The full ` +
|
|
76
|
+
`conversation is still in ${ctx.sessionManager.getSessionFile()} when the summary isn't enough. Its lines are ` +
|
|
77
|
+
`long JSON entries, so print only around a match: grep -oE '.{0,300}words.{0,300}' <file>` });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
host.routes.set("/commands", (_req: any, res: any) => {
|
|
81
|
+
// only ours (app/extensions): pi's built-in extensions' commands are terminal screens (e.g. its MCP's /mcp)
|
|
82
|
+
const runner = host.agents.get("agent1")?.session.extensionRunner;
|
|
83
|
+
const list = (runner?.getRegisteredCommands() ?? []).filter((c: any) => c.sourceInfo?.path?.startsWith(host.app))
|
|
84
|
+
.map((c: any) => ({ name: c.invocationName, description: c.description ?? "" }));
|
|
85
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(list));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
host.pageHandlers.set("commands", async (msg: any) => {
|
|
89
|
+
if (msg.type !== "command") return;
|
|
90
|
+
const name = host.agents.has(msg.agent) ? msg.agent : "agent1";
|
|
91
|
+
// a running turn is stopped first, on purpose (compact would abort it anyway; this way it isn't shown as an error)
|
|
92
|
+
if (host.agents.get(name).session.isStreaming) await host.abort(name);
|
|
93
|
+
try { await host.agents.get(name).session.prompt(msg.text); }
|
|
94
|
+
catch (e: any) { host.broadcast("error", { agent: name, text: `${msg.text}: ${e.message}` }); }
|
|
95
|
+
});
|
|
96
|
+
}
|