pi-midcompact 0.5.3 → 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.
@@ -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 ReviewWebUiRuntimeOptions {
61
- /** Test seam; production opens the system browser. */
62
- openBrowser?: (url: string) => void;
63
- /** Release the UI when no page establishes its liveness stream. */
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
- id: range.id,
130
- startRef: range.startRef,
131
- endRef: range.endRef,
132
- topic: range.topic,
133
- summary: range.summary,
134
- originalContentChars: range.originalContentChars,
135
- originalImageCount: range.originalImageCount,
136
- originalImagePayloadBytes: range.originalImagePayloadBytes,
137
- replacementContentChars: range.replacementContentChars,
138
- atomCount: range.endIndex - range.startIndex + 1,
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
- /** Serve the shared Review/Selection workbench over loopback HTTP. */
146
- export async function showReviewWebUi(
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
- runtime: ReviewWebUiRuntimeOptions = {},
153
- ): Promise<void> {
154
- return new Promise<void>((resolve, reject) => {
155
- const connectTimeoutMs = runtime.livenessConnectTimeoutMs ?? 30_000;
156
- const pingIntervalMs = runtime.livenessPingIntervalMs ?? 10_000;
157
- let settled = false;
158
- let closing = false;
159
- let livenessResponse: http.ServerResponse | undefined;
160
- let connectTimer: NodeJS.Timeout | undefined;
161
- let pingTimer: NodeJS.Timeout | undefined;
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
- const clearTimers = () => {
164
- if (connectTimer) clearTimeout(connectTimer);
165
- if (pingTimer) clearInterval(pingTimer);
166
- connectTimer = undefined;
167
- pingTimer = undefined;
168
- };
169
- const finish = () => {
170
- if (settled) return;
171
- settled = true;
172
- clearTimers();
173
- resolve();
174
- };
175
- const fail = (error: Error) => {
176
- if (settled) return;
177
- settled = true;
178
- clearTimers();
179
- reject(error);
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
- sendHtml(HTML_TEMPLATE.replace("<!--MIDCOMPACT_STATE-->", () => stateJson));
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", fail);
300
- server.listen(0, "127.0.0.1", () => {
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
- const token = randomBytes(3).toString("hex");
305
- ctx.ui.notify(`Midcompact ${view} webui ready: ${url} (token ${token})`, "info");
306
- connectTimer = setTimeout(closeServer, connectTimeoutMs);
307
- (runtime.openBrowser ?? tryOpenBrowser)(url);
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}"`