pi-midcompact 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -40
- package/README.zh-CN.md +227 -0
- package/figures/review-tui.png +0 -0
- package/figures/review-webui.png +0 -0
- package/package.json +2 -1
- package/skills/midcompact/SKILL.md +1 -1
- package/src/index.ts +56 -28
- package/src/review-ui.ts +190 -147
- package/src/review-webui.html +846 -0
- package/src/review-webui.ts +212 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import type { Atom, DraftPlan, DraftTelemetry } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const HTML_TEMPLATE = readFileSync(new URL("review-webui.html", import.meta.url), "utf8");
|
|
11
|
+
|
|
12
|
+
export interface ReviewState {
|
|
13
|
+
atoms: Array<{
|
|
14
|
+
ref: string;
|
|
15
|
+
index: number;
|
|
16
|
+
kind: string;
|
|
17
|
+
preview: string;
|
|
18
|
+
approxTokens: number;
|
|
19
|
+
compressible: boolean;
|
|
20
|
+
protocolClosed: boolean;
|
|
21
|
+
toolNames: string[];
|
|
22
|
+
roles: string[];
|
|
23
|
+
compressedBlockId?: string;
|
|
24
|
+
owningRangeId?: string;
|
|
25
|
+
isRangeStart?: boolean;
|
|
26
|
+
isRangeEnd?: boolean;
|
|
27
|
+
}>;
|
|
28
|
+
draft: {
|
|
29
|
+
revision: number;
|
|
30
|
+
ranges: Array<{
|
|
31
|
+
id: string;
|
|
32
|
+
startRef: string;
|
|
33
|
+
endRef: string;
|
|
34
|
+
topic?: string;
|
|
35
|
+
summary: string;
|
|
36
|
+
originalApproxTokens: number;
|
|
37
|
+
compressedApproxTokens: number;
|
|
38
|
+
atomCount: number;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
telemetry: DraftTelemetry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReviewWebUiCallbacks {
|
|
45
|
+
editSummary(draftId: string, summary: string): void;
|
|
46
|
+
editTopic(draftId: string, topic: string): void;
|
|
47
|
+
remove(draftId: string): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
function owningRange(atomIndex: number, ranges: DraftPlan["ranges"]) {
|
|
53
|
+
return ranges.find((r) => atomIndex >= r.startIndex && atomIndex <= r.endIndex);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function serializeReviewState(atoms: Atom[], draft: DraftPlan, telemetry: DraftTelemetry): ReviewState {
|
|
57
|
+
return {
|
|
58
|
+
atoms: atoms.map((atom) => {
|
|
59
|
+
const owner = owningRange(atom.index, draft.ranges);
|
|
60
|
+
return {
|
|
61
|
+
ref: atom.ref,
|
|
62
|
+
index: atom.index,
|
|
63
|
+
kind: atom.kind,
|
|
64
|
+
preview: atom.preview,
|
|
65
|
+
approxTokens: atom.approxTokens,
|
|
66
|
+
compressible: atom.compressible,
|
|
67
|
+
protocolClosed: atom.protocolClosed,
|
|
68
|
+
toolNames: atom.toolNames,
|
|
69
|
+
roles: atom.roles,
|
|
70
|
+
compressedBlockId: atom.compressedBlockId,
|
|
71
|
+
owningRangeId: owner?.id,
|
|
72
|
+
isRangeStart: owner?.startIndex === atom.index,
|
|
73
|
+
isRangeEnd: owner?.endIndex === atom.index,
|
|
74
|
+
};
|
|
75
|
+
}),
|
|
76
|
+
draft: {
|
|
77
|
+
revision: draft.revision,
|
|
78
|
+
ranges: draft.ranges.map((range) => ({
|
|
79
|
+
id: range.id,
|
|
80
|
+
startRef: range.startRef,
|
|
81
|
+
endRef: range.endRef,
|
|
82
|
+
topic: range.topic,
|
|
83
|
+
summary: range.summary,
|
|
84
|
+
originalApproxTokens: range.originalApproxTokens,
|
|
85
|
+
compressedApproxTokens: range.compressedApproxTokens,
|
|
86
|
+
atomCount: range.endIndex - range.startIndex + 1,
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
telemetry,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start a local HTTP server hosting the midcompact review page. Resolves when
|
|
95
|
+
* the user closes the review (POST /api/close) or the server errors out.
|
|
96
|
+
*
|
|
97
|
+
* Edits are applied via callbacks that the caller wires to the same draft
|
|
98
|
+
* mutation + append-entry path used by the TUI, so both surfaces stay consistent.
|
|
99
|
+
*/
|
|
100
|
+
export async function showReviewWebUi(
|
|
101
|
+
ctx: ExtensionCommandContext,
|
|
102
|
+
atoms: Atom[],
|
|
103
|
+
getLatest: () => { draft: DraftPlan; telemetry: DraftTelemetry },
|
|
104
|
+
callbacks: ReviewWebUiCallbacks,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
return new Promise<void>((resolve, reject) => {
|
|
107
|
+
const server = http.createServer((req, res) => {
|
|
108
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
109
|
+
const path = url.pathname;
|
|
110
|
+
|
|
111
|
+
const sendJson = (code: number, body: unknown) => {
|
|
112
|
+
const payload = JSON.stringify(body);
|
|
113
|
+
res.writeHead(code, {
|
|
114
|
+
"content-type": "application/json; charset=utf-8",
|
|
115
|
+
"content-length": Buffer.byteLength(payload),
|
|
116
|
+
});
|
|
117
|
+
res.end(payload);
|
|
118
|
+
};
|
|
119
|
+
const sendHtml = (html: string) => {
|
|
120
|
+
res.writeHead(200, {
|
|
121
|
+
"content-type": "text/html; charset=utf-8",
|
|
122
|
+
"content-length": Buffer.byteLength(html),
|
|
123
|
+
});
|
|
124
|
+
res.end(html);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const readBody = async (): Promise<string> => {
|
|
128
|
+
const chunks: Buffer[] = [];
|
|
129
|
+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
130
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
if (req.method === "GET" && path === "/") {
|
|
135
|
+
const { draft, telemetry } = getLatest();
|
|
136
|
+
const stateJson = JSON.stringify(serializeReviewState(atoms, draft, telemetry)).replace(/</g, "\\u003c");
|
|
137
|
+
// NOTE: function replacer — a string replacement would interpret `$&` / `$'` in the payload.
|
|
138
|
+
sendHtml(HTML_TEMPLATE.replace("<!--MIDCOMPACT_STATE-->", () => stateJson));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (req.method === "GET" && path === "/api/state") {
|
|
142
|
+
const { draft, telemetry } = getLatest();
|
|
143
|
+
sendJson(200, serializeReviewState(atoms, draft, telemetry));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (req.method === "POST" && path === "/api/close") {
|
|
147
|
+
sendJson(200, { ok: true });
|
|
148
|
+
server.close();
|
|
149
|
+
resolve();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const editMatch = path.match(/^\/api\/range\/([^/]+)\/(edit-summary|edit-topic|remove)$/);
|
|
153
|
+
if (req.method === "POST" && editMatch) {
|
|
154
|
+
const [, draftId, op] = editMatch;
|
|
155
|
+
const current = getLatest().draft;
|
|
156
|
+
const range = current.ranges.find((r) => r.id === draftId);
|
|
157
|
+
if (!range) {
|
|
158
|
+
sendJson(404, { error: `Unknown range ${draftId}` });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (op === "remove") {
|
|
162
|
+
callbacks.remove(draftId);
|
|
163
|
+
const { draft, telemetry } = getLatest();
|
|
164
|
+
sendJson(200, serializeReviewState(atoms, draft, telemetry));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
readBody().then((raw) => {
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(raw) as { value?: string };
|
|
170
|
+
const value = typeof parsed.value === "string" ? parsed.value : "";
|
|
171
|
+
if (op === "edit-summary") {
|
|
172
|
+
if (!value.trim()) {
|
|
173
|
+
sendJson(400, { error: "summary must not be empty" });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
callbacks.editSummary(draftId, value.trim());
|
|
177
|
+
} else {
|
|
178
|
+
callbacks.editTopic(draftId, value.trim());
|
|
179
|
+
}
|
|
180
|
+
const { draft, telemetry } = getLatest();
|
|
181
|
+
sendJson(200, serializeReviewState(atoms, draft, telemetry));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
sendJson(400, { error: err instanceof Error ? err.message : String(err) });
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
sendJson(404, { error: "not found" });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
sendJson(500, { error: err instanceof Error ? err.message : String(err) });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
server.on("error", reject);
|
|
195
|
+
// Bind loopback only; random port assigned by the OS.
|
|
196
|
+
server.listen(0, "127.0.0.1", () => {
|
|
197
|
+
const addr = server.address();
|
|
198
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
199
|
+
const url = `http://127.0.0.1:${port}`;
|
|
200
|
+
const token = randomBytes(3).toString("hex");
|
|
201
|
+
ctx.ui.notify(`Midcompact review-webui ready: ${url} (token ${token})`, "info");
|
|
202
|
+
tryOpenBrowser(url);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function tryOpenBrowser(url: string): void {
|
|
208
|
+
const cmd = process.platform === "win32" ? `start "" "${url}"`
|
|
209
|
+
: process.platform === "darwin" ? `open "${url}"`
|
|
210
|
+
: `xdg-open "${url}"`;
|
|
211
|
+
exec(cmd, () => { /* best-effort; ignore failures */ });
|
|
212
|
+
}
|