pi-midcompact 0.5.2 → 0.6.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 +18 -1
- package/README.zh-CN.md +16 -1
- package/package.json +4 -2
- package/skills/midcompact/SKILL.md +7 -7
- package/skills/midcompact/references/tool-interface.md +7 -7
- package/src/SPEC.md +116 -0
- package/src/content-metrics.ts +75 -3
- package/src/index.ts +87 -62
- package/src/projection.ts +13 -4
- package/src/review-webui.html +837 -481
- package/src/review-webui.ts +150 -57
package/src/review-webui.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
|
|
6
6
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
|
|
8
|
+
import { TOKEN_ESTIMATE, charClassCounts } from "./content-metrics.js";
|
|
9
|
+
import { replacementContentText } from "./projection.js";
|
|
8
10
|
import type { Atom, DraftPlan, DraftTelemetry, SelectionSpan } from "./types.js";
|
|
9
11
|
|
|
10
12
|
const HTML_TEMPLATE = readFileSync(new URL("review-webui.html", import.meta.url), "utf8");
|
|
@@ -13,6 +15,12 @@ export type ReviewWebUiView = "review" | "selection";
|
|
|
13
15
|
|
|
14
16
|
export interface ReviewState {
|
|
15
17
|
view: ReviewWebUiView;
|
|
18
|
+
/** Char-class token-cost assumption table shared with the page (see content-metrics). */
|
|
19
|
+
est: {
|
|
20
|
+
readonly narrowTokPerChar: readonly [number, number];
|
|
21
|
+
readonly wideTokPerChar: readonly [number, number];
|
|
22
|
+
readonly imageTok: readonly [number, number];
|
|
23
|
+
};
|
|
16
24
|
atoms: Array<{
|
|
17
25
|
ref: string;
|
|
18
26
|
index: number;
|
|
@@ -31,6 +39,9 @@ export interface ReviewState {
|
|
|
31
39
|
owningRangeId?: string;
|
|
32
40
|
isRangeStart?: boolean;
|
|
33
41
|
isRangeEnd?: boolean;
|
|
42
|
+
/** Char-class counts over the atom's full text; display-level estimation input. */
|
|
43
|
+
narrowChars: number;
|
|
44
|
+
wideChars: number;
|
|
34
45
|
}>;
|
|
35
46
|
draft: {
|
|
36
47
|
revision: number;
|
|
@@ -44,6 +55,9 @@ export interface ReviewState {
|
|
|
44
55
|
originalImageCount: number;
|
|
45
56
|
originalImagePayloadBytes: number;
|
|
46
57
|
replacementContentChars: number;
|
|
58
|
+
/** Char-class counts over the exact saved replacement wrapper and summary. */
|
|
59
|
+
replacementNarrowChars: number;
|
|
60
|
+
replacementWideChars: number;
|
|
47
61
|
atomCount: number;
|
|
48
62
|
}>;
|
|
49
63
|
};
|
|
@@ -57,13 +71,28 @@ export interface ReviewWebUiCallbacks {
|
|
|
57
71
|
remove(draftId: string): void;
|
|
58
72
|
}
|
|
59
73
|
|
|
60
|
-
export interface
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
/** Release the
|
|
74
|
+
export interface ReviewWebUiServerOptions {
|
|
75
|
+
/** Fixed port for a long-running development host; production uses an ephemeral port. */
|
|
76
|
+
port?: number;
|
|
77
|
+
/** Release the server when no page establishes its liveness stream. Zero disables the timeout. */
|
|
64
78
|
livenessConnectTimeoutMs?: number;
|
|
65
79
|
/** Server-originated keepalive interval for detecting a disappeared page. */
|
|
66
80
|
livenessPingIntervalMs?: number;
|
|
81
|
+
/** Page-bound production lifetime by default; persistent hosts survive refresh and Close. */
|
|
82
|
+
lifecycle?: "page" | "persistent";
|
|
83
|
+
/** Supply a fresh page template per request, primarily for development reloads. */
|
|
84
|
+
htmlTemplate?: () => string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ReviewWebUiServerHandle {
|
|
88
|
+
url: string;
|
|
89
|
+
closed: Promise<void>;
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ReviewWebUiRuntimeOptions extends ReviewWebUiServerOptions {
|
|
94
|
+
/** Test seam; production opens the system browser. */
|
|
95
|
+
openBrowser?: (url: string) => void;
|
|
67
96
|
}
|
|
68
97
|
|
|
69
98
|
function owningRange(atomIndex: number, ranges: DraftPlan["ranges"]) {
|
|
@@ -100,9 +129,11 @@ export function serializeReviewState(
|
|
|
100
129
|
const groups = groupMeta(atoms);
|
|
101
130
|
return {
|
|
102
131
|
view,
|
|
132
|
+
est: TOKEN_ESTIMATE,
|
|
103
133
|
atoms: atoms.map((atom) => {
|
|
104
134
|
const owner = owningRange(atom.index, draft.ranges);
|
|
105
135
|
const group = groups.get(atom.index)!;
|
|
136
|
+
const mix = charClassCounts(atom.fullText);
|
|
106
137
|
return {
|
|
107
138
|
ref: atom.ref,
|
|
108
139
|
index: atom.index,
|
|
@@ -121,63 +152,78 @@ export function serializeReviewState(
|
|
|
121
152
|
owningRangeId: owner?.id,
|
|
122
153
|
isRangeStart: owner?.startIndex === atom.index,
|
|
123
154
|
isRangeEnd: owner?.endIndex === atom.index,
|
|
155
|
+
narrowChars: mix.narrowChars,
|
|
156
|
+
wideChars: mix.wideChars,
|
|
124
157
|
};
|
|
125
158
|
}),
|
|
126
159
|
draft: {
|
|
127
160
|
revision: draft.revision,
|
|
128
|
-
ranges: draft.ranges.map((range) =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
161
|
+
ranges: draft.ranges.map((range) => {
|
|
162
|
+
const replacementMix = charClassCounts(replacementContentText(range.summary, range.topic));
|
|
163
|
+
return {
|
|
164
|
+
id: range.id,
|
|
165
|
+
startRef: range.startRef,
|
|
166
|
+
endRef: range.endRef,
|
|
167
|
+
topic: range.topic,
|
|
168
|
+
summary: range.summary,
|
|
169
|
+
originalContentChars: range.originalContentChars,
|
|
170
|
+
originalImageCount: range.originalImageCount,
|
|
171
|
+
originalImagePayloadBytes: range.originalImagePayloadBytes,
|
|
172
|
+
replacementContentChars: range.replacementContentChars,
|
|
173
|
+
replacementNarrowChars: replacementMix.narrowChars,
|
|
174
|
+
replacementWideChars: replacementMix.wideChars,
|
|
175
|
+
atomCount: range.endIndex - range.startIndex + 1,
|
|
176
|
+
};
|
|
177
|
+
}),
|
|
140
178
|
},
|
|
141
179
|
telemetry,
|
|
142
180
|
};
|
|
143
181
|
}
|
|
144
182
|
|
|
145
|
-
/**
|
|
146
|
-
export async function
|
|
147
|
-
ctx: ExtensionCommandContext,
|
|
183
|
+
/** Start the Pi-independent Review/Selection HTTP surface on loopback. */
|
|
184
|
+
export async function startReviewWebUiServer(
|
|
148
185
|
atoms: Atom[],
|
|
149
186
|
getLatest: () => { draft: DraftPlan; telemetry: DraftTelemetry },
|
|
150
187
|
callbacks: ReviewWebUiCallbacks,
|
|
151
188
|
view: ReviewWebUiView = "review",
|
|
152
|
-
|
|
153
|
-
): Promise<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
options: ReviewWebUiServerOptions = {},
|
|
190
|
+
): Promise<ReviewWebUiServerHandle> {
|
|
191
|
+
const pageBound = (options.lifecycle ?? "page") === "page";
|
|
192
|
+
const connectTimeoutMs = options.livenessConnectTimeoutMs ?? (pageBound ? 30_000 : 0);
|
|
193
|
+
const pingIntervalMs = options.livenessPingIntervalMs ?? 10_000;
|
|
194
|
+
let settled = false;
|
|
195
|
+
let closing = false;
|
|
196
|
+
let started = false;
|
|
197
|
+
let livenessResponse: http.ServerResponse | undefined;
|
|
198
|
+
let connectTimer: NodeJS.Timeout | undefined;
|
|
199
|
+
let pingTimer: NodeJS.Timeout | undefined;
|
|
200
|
+
let resolveClosed!: () => void;
|
|
201
|
+
let rejectClosed!: (error: Error) => void;
|
|
202
|
+
const closed = new Promise<void>((resolve, reject) => {
|
|
203
|
+
resolveClosed = resolve;
|
|
204
|
+
rejectClosed = reject;
|
|
205
|
+
});
|
|
162
206
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
207
|
+
const clearTimers = () => {
|
|
208
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
209
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
210
|
+
connectTimer = undefined;
|
|
211
|
+
pingTimer = undefined;
|
|
212
|
+
};
|
|
213
|
+
const finish = () => {
|
|
214
|
+
if (settled) return;
|
|
215
|
+
settled = true;
|
|
216
|
+
clearTimers();
|
|
217
|
+
resolveClosed();
|
|
218
|
+
};
|
|
219
|
+
const fail = (error: Error) => {
|
|
220
|
+
if (settled) return;
|
|
221
|
+
settled = true;
|
|
222
|
+
clearTimers();
|
|
223
|
+
rejectClosed(error);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return new Promise<ReviewWebUiServerHandle>((resolveStart, rejectStart) => {
|
|
181
227
|
const server = http.createServer(async (req, res) => {
|
|
182
228
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
183
229
|
const path = url.pathname;
|
|
@@ -219,23 +265,40 @@ export async function showReviewWebUi(
|
|
|
219
265
|
req.on("close", () => {
|
|
220
266
|
if (livenessResponse === res) {
|
|
221
267
|
livenessResponse = undefined;
|
|
222
|
-
closeServer();
|
|
268
|
+
if (pageBound) closeServer();
|
|
223
269
|
}
|
|
224
270
|
});
|
|
225
271
|
return;
|
|
226
272
|
}
|
|
227
273
|
if (req.method === "GET" && path === "/") {
|
|
228
274
|
const stateJson = JSON.stringify(currentState()).replace(/</g, "\\u003c");
|
|
229
|
-
|
|
275
|
+
const template = options.htmlTemplate?.() ?? HTML_TEMPLATE;
|
|
276
|
+
sendHtml(template.replace("<!--MIDCOMPACT_STATE-->", () => stateJson));
|
|
230
277
|
return;
|
|
231
278
|
}
|
|
232
279
|
if (req.method === "GET" && path === "/api/state") {
|
|
233
280
|
sendJson(200, currentState());
|
|
234
281
|
return;
|
|
235
282
|
}
|
|
283
|
+
const atomMatch = path.match(/^\/api\/atom\/([^/]+)$/);
|
|
284
|
+
if (req.method === "GET" && atomMatch) {
|
|
285
|
+
const atom = atoms.find((a) => a.ref === decodeURIComponent(atomMatch[1]!));
|
|
286
|
+
if (!atom) {
|
|
287
|
+
sendJson(404, { error: `Unknown atom ${atomMatch[1]}` });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
sendJson(200, {
|
|
291
|
+
ref: atom.ref,
|
|
292
|
+
kind: atom.kind,
|
|
293
|
+
contentChars: atom.metrics.contentChars,
|
|
294
|
+
imageCount: atom.metrics.imageCount,
|
|
295
|
+
fullText: atom.fullText,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
236
299
|
if (req.method === "POST" && path === "/api/close") {
|
|
237
300
|
sendJson(200, { ok: true });
|
|
238
|
-
closeServer();
|
|
301
|
+
if (pageBound) closeServer();
|
|
239
302
|
return;
|
|
240
303
|
}
|
|
241
304
|
if (req.method === "POST" && path === "/api/selection") {
|
|
@@ -293,22 +356,52 @@ export async function showReviewWebUi(
|
|
|
293
356
|
clearTimers();
|
|
294
357
|
if (livenessResponse && !livenessResponse.writableEnded) livenessResponse.end();
|
|
295
358
|
livenessResponse = undefined;
|
|
296
|
-
server.close(finish);
|
|
359
|
+
server.close((error) => error ? fail(error) : finish());
|
|
297
360
|
};
|
|
298
361
|
|
|
299
|
-
server.on("error",
|
|
300
|
-
|
|
362
|
+
server.on("error", (error) => {
|
|
363
|
+
if (!started) {
|
|
364
|
+
started = true;
|
|
365
|
+
clearTimers();
|
|
366
|
+
rejectStart(error);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
fail(error);
|
|
370
|
+
});
|
|
371
|
+
server.listen(options.port ?? 0, "127.0.0.1", () => {
|
|
372
|
+
started = true;
|
|
301
373
|
const address = server.address();
|
|
302
374
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
303
375
|
const url = `http://127.0.0.1:${port}/?view=${view}`;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
376
|
+
if (connectTimeoutMs > 0) connectTimer = setTimeout(closeServer, connectTimeoutMs);
|
|
377
|
+
resolveStart({
|
|
378
|
+
url,
|
|
379
|
+
closed,
|
|
380
|
+
close: () => {
|
|
381
|
+
closeServer();
|
|
382
|
+
return closed;
|
|
383
|
+
},
|
|
384
|
+
});
|
|
308
385
|
});
|
|
309
386
|
});
|
|
310
387
|
}
|
|
311
388
|
|
|
389
|
+
/** Serve the shared workbench with production Pi notification and page lifetime. */
|
|
390
|
+
export async function showReviewWebUi(
|
|
391
|
+
ctx: ExtensionCommandContext,
|
|
392
|
+
atoms: Atom[],
|
|
393
|
+
getLatest: () => { draft: DraftPlan; telemetry: DraftTelemetry },
|
|
394
|
+
callbacks: ReviewWebUiCallbacks,
|
|
395
|
+
view: ReviewWebUiView = "review",
|
|
396
|
+
runtime: ReviewWebUiRuntimeOptions = {},
|
|
397
|
+
): Promise<void> {
|
|
398
|
+
const server = await startReviewWebUiServer(atoms, getLatest, callbacks, view, runtime);
|
|
399
|
+
const token = randomBytes(3).toString("hex");
|
|
400
|
+
ctx.ui.notify(`Midcompact ${view} webui ready: ${server.url} (token ${token})`, "info");
|
|
401
|
+
(runtime.openBrowser ?? tryOpenBrowser)(server.url);
|
|
402
|
+
await server.closed;
|
|
403
|
+
}
|
|
404
|
+
|
|
312
405
|
function tryOpenBrowser(url: string): void {
|
|
313
406
|
const command = process.platform === "win32" ? `start "" "${url}"`
|
|
314
407
|
: process.platform === "darwin" ? `open "${url}"`
|