@docyrus/docyrus 0.0.21 → 0.0.23

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.
Files changed (39) hide show
  1. package/README.md +12 -0
  2. package/main.js +237 -11
  3. package/main.js.map +4 -4
  4. package/package.json +9 -4
  5. package/resources/pi-agent/assets/docyrus-logo.svg +16 -0
  6. package/resources/pi-agent/extensions/architect.ts +771 -0
  7. package/resources/pi-agent/extensions/notify.ts +57 -55
  8. package/resources/pi-agent/extensions/pi-bash-live-view/LICENSE +21 -0
  9. package/resources/pi-agent/extensions/pi-bash-live-view/README.md +19 -0
  10. package/resources/pi-agent/extensions/pi-bash-live-view/index.ts +52 -0
  11. package/resources/pi-agent/extensions/pi-bash-live-view/package.json +61 -0
  12. package/resources/pi-agent/extensions/pi-bash-live-view/pty-execute.ts +97 -0
  13. package/resources/pi-agent/extensions/pi-bash-live-view/pty-kill.ts +25 -0
  14. package/resources/pi-agent/extensions/pi-bash-live-view/pty-session.ts +143 -0
  15. package/resources/pi-agent/extensions/pi-bash-live-view/spawn-helper.ts +31 -0
  16. package/resources/pi-agent/extensions/pi-bash-live-view/terminal-emulator.ts +439 -0
  17. package/resources/pi-agent/extensions/pi-bash-live-view/truncate.ts +68 -0
  18. package/resources/pi-agent/extensions/pi-bash-live-view/widget.ts +114 -0
  19. package/resources/pi-agent/extensions/pi-custom-compaction/CHANGELOG.md +27 -0
  20. package/resources/pi-agent/extensions/pi-custom-compaction/LICENSE +21 -0
  21. package/resources/pi-agent/extensions/pi-custom-compaction/README.md +244 -0
  22. package/resources/pi-agent/extensions/pi-custom-compaction/VENDORED_FROM.md +6 -0
  23. package/resources/pi-agent/extensions/pi-custom-compaction/banner.png +0 -0
  24. package/resources/pi-agent/extensions/pi-custom-compaction/commands/register-commands.ts +63 -0
  25. package/resources/pi-agent/extensions/pi-custom-compaction/events/register-events.ts +229 -0
  26. package/resources/pi-agent/extensions/pi-custom-compaction/index.ts +10 -0
  27. package/resources/pi-agent/extensions/pi-custom-compaction/package.json +57 -0
  28. package/resources/pi-agent/extensions/pi-custom-compaction/paths.ts +13 -0
  29. package/resources/pi-agent/extensions/pi-custom-compaction/policy/config.ts +32 -0
  30. package/resources/pi-agent/extensions/pi-custom-compaction/policy/merge.ts +67 -0
  31. package/resources/pi-agent/extensions/pi-custom-compaction/policy/parse.ts +354 -0
  32. package/resources/pi-agent/extensions/pi-custom-compaction/policy/types.ts +131 -0
  33. package/resources/pi-agent/extensions/pi-custom-compaction/runtime/model-resolution.ts +77 -0
  34. package/resources/pi-agent/extensions/pi-custom-compaction/runtime/pure.ts +56 -0
  35. package/resources/pi-agent/extensions/pi-custom-compaction/runtime/session-state.ts +244 -0
  36. package/resources/pi-agent/extensions/pi-custom-compaction/summary/generate.ts +184 -0
  37. package/resources/pi-agent/extensions/pi-custom-compaction/summary/template.ts +124 -0
  38. package/server-loader.js +3841 -0
  39. package/server-loader.js.map +7 -0
@@ -0,0 +1,439 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+
5
+ type StyleMode = 'default' | 'rgb' | 'palette';
6
+
7
+ type CellStyle = {
8
+ bold: boolean;
9
+ dim: boolean;
10
+ italic: boolean;
11
+ underline: boolean;
12
+ inverse: boolean;
13
+ invisible: boolean;
14
+ strikethrough: boolean;
15
+ fgMode: StyleMode;
16
+ fg: number;
17
+ bgMode: StyleMode;
18
+ bg: number;
19
+ };
20
+
21
+ type SnapshotCell = {
22
+ ch: string;
23
+ style: CellStyle;
24
+ };
25
+
26
+ type SnapshotLine = SnapshotCell[];
27
+ export type TerminalSnapshot = SnapshotLine[];
28
+
29
+ type TerminalUpdatePayload = {
30
+ elapsedMs: number;
31
+ snapshot: TerminalSnapshot;
32
+ inAltScreen: boolean;
33
+ inSyncRender: boolean;
34
+ };
35
+
36
+ type XtermCellLike = {
37
+ isBold?: () => boolean;
38
+ isDim?: () => boolean;
39
+ isItalic?: () => boolean;
40
+ isUnderline?: () => boolean;
41
+ isInverse?: () => boolean;
42
+ isInvisible?: () => boolean;
43
+ isStrikethrough?: () => boolean;
44
+ isFgDefault?: () => boolean;
45
+ isFgRGB?: () => boolean;
46
+ isFgPalette?: () => boolean;
47
+ isBgDefault?: () => boolean;
48
+ isBgRGB?: () => boolean;
49
+ isBgPalette?: () => boolean;
50
+ getFgColor?: () => number;
51
+ getBgColor?: () => number;
52
+ getWidth: () => number;
53
+ getChars: () => string;
54
+ };
55
+
56
+ type XtermBufferLineLike = {
57
+ isWrapped?: boolean;
58
+ translateToString: (trimRight?: boolean) => string;
59
+ getCell: (x: number, cell?: XtermCellLike) => XtermCellLike | undefined;
60
+ };
61
+
62
+ type XtermBufferLike = {
63
+ baseY: number;
64
+ length: number;
65
+ getLine: (y: number) => XtermBufferLineLike | undefined;
66
+ getNullCell: () => XtermCellLike;
67
+ };
68
+
69
+ type XtermTerminalLike = {
70
+ rows: number;
71
+ cols: number;
72
+ buffer: {
73
+ active: XtermBufferLike;
74
+ normal: XtermBufferLike;
75
+ };
76
+ write: (data: string, callback: () => void) => void;
77
+ dispose?: () => void;
78
+ };
79
+
80
+ type XtermTerminalCtor = new (options: {
81
+ cols: number;
82
+ rows: number;
83
+ scrollback: number;
84
+ allowProposedApi: boolean;
85
+ convertEol: boolean;
86
+ }) => XtermTerminalLike;
87
+
88
+ let TerminalCtor: XtermTerminalCtor | null = null;
89
+
90
+ function ensureXtermHeadlessLoaded(): XtermTerminalCtor {
91
+ if (!globalThis.window) {
92
+ (globalThis as typeof globalThis & { window: object }).window = {};
93
+ }
94
+ if (!TerminalCtor) {
95
+ ({ Terminal: TerminalCtor } = require('@xterm/headless') as { Terminal: XtermTerminalCtor });
96
+ }
97
+ return TerminalCtor;
98
+ }
99
+
100
+ function defaultStyle(): CellStyle {
101
+ return {
102
+ bold: false,
103
+ dim: false,
104
+ italic: false,
105
+ underline: false,
106
+ inverse: false,
107
+ invisible: false,
108
+ strikethrough: false,
109
+ fgMode: 'default',
110
+ fg: 0,
111
+ bgMode: 'default',
112
+ bg: 0,
113
+ };
114
+ }
115
+
116
+ function cloneStyle(style: CellStyle): CellStyle {
117
+ return {
118
+ bold: style.bold,
119
+ dim: style.dim,
120
+ italic: style.italic,
121
+ underline: style.underline,
122
+ inverse: style.inverse,
123
+ invisible: style.invisible,
124
+ strikethrough: style.strikethrough,
125
+ fgMode: style.fgMode,
126
+ fg: style.fg,
127
+ bgMode: style.bgMode,
128
+ bg: style.bg,
129
+ };
130
+ }
131
+
132
+ function cloneSnapshot(snapshot: TerminalSnapshot): TerminalSnapshot {
133
+ return snapshot.map((line) => line.map((cell) => ({ ...cell, style: cloneStyle(cell.style ?? defaultStyle()) })));
134
+ }
135
+
136
+ function styleKey(style: CellStyle): string {
137
+ return [
138
+ style.bold ? 'b' : '-',
139
+ style.dim ? 'd' : '-',
140
+ style.italic ? 'i' : '-',
141
+ style.underline ? 'u' : '-',
142
+ style.inverse ? 'v' : '-',
143
+ style.invisible ? 'x' : '-',
144
+ style.strikethrough ? 's' : '-',
145
+ `fg:${style.fgMode}:${style.fg}`,
146
+ `bg:${style.bgMode}:${style.bg}`,
147
+ ].join('');
148
+ }
149
+
150
+ function rgbToSgr(isForeground: boolean, value: number): string {
151
+ const r = (value >> 16) & 255;
152
+ const g = (value >> 8) & 255;
153
+ const b = value & 255;
154
+ return isForeground ? `38;2;${r};${g};${b}` : `48;2;${r};${g};${b}`;
155
+ }
156
+
157
+ function paletteToSgr(isForeground: boolean, index: number): string {
158
+ return isForeground ? `38;5;${index}` : `48;5;${index}`;
159
+ }
160
+
161
+ function styleToAnsi(style: CellStyle): string {
162
+ const codes = ['0'];
163
+ if (style.bold) codes.push('1');
164
+ if (style.dim) codes.push('2');
165
+ if (style.italic) codes.push('3');
166
+ if (style.underline) codes.push('4');
167
+ if (style.inverse) codes.push('7');
168
+ if (style.invisible) codes.push('8');
169
+ if (style.strikethrough) codes.push('9');
170
+ if (style.fgMode === 'rgb') codes.push(rgbToSgr(true, style.fg));
171
+ else if (style.fgMode === 'palette') codes.push(paletteToSgr(true, style.fg));
172
+ if (style.bgMode === 'rgb') codes.push(rgbToSgr(false, style.bg));
173
+ else if (style.bgMode === 'palette') codes.push(paletteToSgr(false, style.bg));
174
+ return `\x1b[${codes.join(';')}m`;
175
+ }
176
+
177
+ export function snapshotToAnsiContentLines(snapshot: TerminalSnapshot): string[] {
178
+ return snapshot.map((line) => {
179
+ let out = '';
180
+ let current = defaultStyle();
181
+ let currentKey = styleKey(current);
182
+ for (const cell of line) {
183
+ const style = cell.style ?? defaultStyle();
184
+ const nextKey = styleKey(style);
185
+ if (nextKey !== currentKey) {
186
+ out += styleToAnsi(style);
187
+ current = cloneStyle(style);
188
+ currentKey = nextKey;
189
+ }
190
+ out += cell.ch;
191
+ }
192
+ return `${out}\x1b[0m`.replace(/\s+\x1b\[0m$/, '\x1b[0m');
193
+ });
194
+ }
195
+
196
+ function sanitizeOutput(text: string): string {
197
+ return text.replace(/\r/g, '').replace(/\u0000/g, '').replace(/\p{Cf}/gu, '');
198
+ }
199
+
200
+ function extractTextFromBuffer(buffer: XtermBufferLike): string {
201
+ const logicalLines: string[] = [];
202
+
203
+ for (let i = 0; i < buffer.length; i += 1) {
204
+ const line = buffer.getLine(i);
205
+ const text = sanitizeOutput(line?.translateToString(true) ?? '');
206
+ if (line?.isWrapped && logicalLines.length > 0) {
207
+ logicalLines[logicalLines.length - 1] += text;
208
+ } else {
209
+ logicalLines.push(text);
210
+ }
211
+ }
212
+
213
+ while (logicalLines.length > 0 && logicalLines[logicalLines.length - 1] === '') {
214
+ logicalLines.pop();
215
+ }
216
+
217
+ const text = logicalLines.join('\n').trimEnd();
218
+ return text.length === 0 ? '(no output)' : `${text}\n`;
219
+ }
220
+
221
+ function normalizePaletteColor(mode: StyleMode, value: number): { mode: StyleMode; value: number } {
222
+ if (mode !== 'palette') return { mode, value };
223
+ if (value < 0 || value > 255) {
224
+ return { mode: 'default', value: 0 };
225
+ }
226
+ return { mode: 'palette', value };
227
+ }
228
+
229
+ function styleFromCell(cell: XtermCellLike | undefined): CellStyle {
230
+ const rawFgMode: StyleMode = cell?.isFgDefault?.()
231
+ ? 'default'
232
+ : cell?.isFgRGB?.()
233
+ ? 'rgb'
234
+ : cell?.isFgPalette?.()
235
+ ? 'palette'
236
+ : 'default';
237
+ const rawBgMode: StyleMode = cell?.isBgDefault?.()
238
+ ? 'default'
239
+ : cell?.isBgRGB?.()
240
+ ? 'rgb'
241
+ : cell?.isBgPalette?.()
242
+ ? 'palette'
243
+ : 'default';
244
+
245
+ const fg = normalizePaletteColor(rawFgMode, cell?.getFgColor?.() ?? 0);
246
+ const bg = normalizePaletteColor(rawBgMode, cell?.getBgColor?.() ?? 0);
247
+
248
+ return {
249
+ bold: Boolean(cell?.isBold?.()),
250
+ dim: Boolean(cell?.isDim?.()),
251
+ italic: Boolean(cell?.isItalic?.()),
252
+ underline: Boolean(cell?.isUnderline?.()),
253
+ inverse: Boolean(cell?.isInverse?.()),
254
+ invisible: Boolean(cell?.isInvisible?.()),
255
+ strikethrough: Boolean(cell?.isStrikethrough?.()),
256
+ fgMode: fg.mode,
257
+ fg: fg.value,
258
+ bgMode: bg.mode,
259
+ bg: bg.value,
260
+ };
261
+ }
262
+
263
+ function createXterm(cols: number, rows: number, scrollback: number): XtermTerminalLike {
264
+ const Terminal = ensureXtermHeadlessLoaded();
265
+ return new Terminal({
266
+ cols,
267
+ rows,
268
+ scrollback,
269
+ allowProposedApi: true,
270
+ convertEol: true,
271
+ });
272
+ }
273
+
274
+ function snapshotTerminal(term: XtermTerminalLike): TerminalSnapshot {
275
+ const buffer = term.buffer.active;
276
+ const start = buffer.baseY;
277
+ const lines: TerminalSnapshot = [];
278
+ const scratchCell = buffer.getNullCell();
279
+ for (let y = 0; y < term.rows; y++) {
280
+ const row: SnapshotLine = [];
281
+ const line = buffer.getLine(start + y);
282
+ for (let x = 0; x < term.cols; x++) {
283
+ const cell = line?.getCell(x, scratchCell);
284
+ if (!cell) {
285
+ row.push({ ch: ' ', style: defaultStyle() });
286
+ continue;
287
+ }
288
+ if (cell.getWidth() === 0) continue;
289
+ row.push({
290
+ ch: cell.getChars() || ' ',
291
+ style: styleFromCell(cell),
292
+ });
293
+ }
294
+ lines.push(row);
295
+ }
296
+ return lines;
297
+ }
298
+
299
+ function createDecPrivateModeTracker(onModeChange: (mode: number, enabled: boolean) => void) {
300
+ let state: 'ground' | 'escape' | 'csi' = 'ground';
301
+ let privateMarker = false;
302
+ let params = '';
303
+
304
+ function reset() {
305
+ state = 'ground';
306
+ privateMarker = false;
307
+ params = '';
308
+ }
309
+
310
+ function finalize(finalByte: string) {
311
+ if (!privateMarker || (finalByte !== 'h' && finalByte !== 'l')) {
312
+ reset();
313
+ return;
314
+ }
315
+ const enabled = finalByte === 'h';
316
+ for (const part of params.split(';')) {
317
+ if (!part) continue;
318
+ const mode = Number(part);
319
+ if (!Number.isInteger(mode)) continue;
320
+ onModeChange(mode, enabled);
321
+ }
322
+ reset();
323
+ }
324
+
325
+ return {
326
+ push(text: string) {
327
+ for (const ch of text) {
328
+ if (state === 'ground') {
329
+ if (ch === '\x1b') state = 'escape';
330
+ continue;
331
+ }
332
+ if (state === 'escape') {
333
+ if (ch === '[') {
334
+ state = 'csi';
335
+ privateMarker = false;
336
+ params = '';
337
+ continue;
338
+ }
339
+ state = ch === '\x1b' ? 'escape' : 'ground';
340
+ continue;
341
+ }
342
+ if (state === 'csi') {
343
+ if (ch === '?') {
344
+ if (params.length === 0 && !privateMarker) {
345
+ privateMarker = true;
346
+ continue;
347
+ }
348
+ reset();
349
+ continue;
350
+ }
351
+ if ((ch >= '0' && ch <= '9') || ch === ';') {
352
+ params += ch;
353
+ continue;
354
+ }
355
+ if (ch >= '@' && ch <= '~') {
356
+ finalize(ch);
357
+ continue;
358
+ }
359
+ reset();
360
+ }
361
+ }
362
+ },
363
+ };
364
+ }
365
+
366
+ export function createTerminalEmulator({ cols, rows, scrollback = 10_000 }: { cols: number; rows: number; scrollback?: number }) {
367
+ const term = createXterm(cols, rows, scrollback);
368
+ const listeners = new Set<(payload: TerminalUpdatePayload) => void>();
369
+ let writeChain: Promise<void | TerminalUpdatePayload> = Promise.resolve();
370
+ let inAltScreen = false;
371
+ let inSyncRender = false;
372
+ let lastCompletedSnapshot = snapshotTerminal(term);
373
+ let latestSnapshot = cloneSnapshot(lastCompletedSnapshot);
374
+ let lastElapsedMs = 0;
375
+ const modeTracker = createDecPrivateModeTracker((mode, enabled) => {
376
+ if (mode === 2026) {
377
+ inSyncRender = enabled;
378
+ return;
379
+ }
380
+ if (mode === 1049 || mode === 1047 || mode === 47) {
381
+ inAltScreen = enabled;
382
+ }
383
+ });
384
+
385
+ function emitUpdate(payload: TerminalUpdatePayload) {
386
+ for (const listener of listeners) listener(payload);
387
+ }
388
+
389
+ async function consumeProcessStdout(chunk: string, { elapsedMs = lastElapsedMs }: { elapsedMs?: number } = {}) {
390
+ lastElapsedMs = elapsedMs;
391
+ modeTracker.push(chunk);
392
+ writeChain = writeChain.then(() => new Promise<TerminalUpdatePayload>((resolve) => {
393
+ term.write(chunk, () => {
394
+ latestSnapshot = snapshotTerminal(term);
395
+ const renderableSnapshot = inSyncRender ? lastCompletedSnapshot : latestSnapshot;
396
+ if (!inSyncRender) lastCompletedSnapshot = cloneSnapshot(latestSnapshot);
397
+ const payload: TerminalUpdatePayload = {
398
+ elapsedMs: lastElapsedMs,
399
+ snapshot: cloneSnapshot(renderableSnapshot),
400
+ inAltScreen,
401
+ inSyncRender,
402
+ };
403
+ emitUpdate(payload);
404
+ resolve(payload);
405
+ });
406
+ }));
407
+ return writeChain;
408
+ }
409
+
410
+ function getViewportSnapshot(): TerminalSnapshot {
411
+ return cloneSnapshot(inSyncRender ? lastCompletedSnapshot : latestSnapshot);
412
+ }
413
+
414
+ function getStrippedTextIncludingEntireScrollback(): string {
415
+ return extractTextFromBuffer(term.buffer.normal);
416
+ }
417
+
418
+ return {
419
+ cols,
420
+ rows,
421
+ consumeProcessStdout,
422
+ whenIdle() {
423
+ return writeChain.then(() => undefined);
424
+ },
425
+ subscribe(listener: (payload: TerminalUpdatePayload) => void) {
426
+ listeners.add(listener);
427
+ return () => listeners.delete(listener);
428
+ },
429
+ getState() {
430
+ return { inAltScreen, inSyncRender, elapsedMs: lastElapsedMs };
431
+ },
432
+ getViewportSnapshot,
433
+ getStrippedTextIncludingEntireScrollback,
434
+ dispose() {
435
+ term.dispose?.();
436
+ listeners.clear();
437
+ },
438
+ };
439
+ }
@@ -0,0 +1,68 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { DEFAULT_MAX_BYTES, formatSize, truncateTail } from '@mariozechner/pi-coding-agent';
6
+
7
+ function getTempFilePath() {
8
+ const id = randomBytes(8).toString('hex');
9
+ return join(tmpdir(), `pi-bash-${id}.log`);
10
+ }
11
+
12
+ function maybePersistFullOutput(fullOutput: string) {
13
+ if (Buffer.byteLength(fullOutput, 'utf-8') <= DEFAULT_MAX_BYTES) return undefined;
14
+ const tempFilePath = getTempFilePath();
15
+ writeFileSync(tempFilePath, fullOutput, 'utf-8');
16
+ return tempFilePath;
17
+ }
18
+
19
+ function buildTruncationNotice(truncation: ReturnType<typeof truncateTail>, fullOutput: string, tempFilePath?: string) {
20
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
21
+ const endLine = truncation.totalLines;
22
+
23
+ if (truncation.lastLinePartial) {
24
+ const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split('\n').pop() || '', 'utf-8'));
25
+ return `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
26
+ }
27
+ if (truncation.truncatedBy === 'lines') {
28
+ return `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
29
+ }
30
+ return `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
31
+ }
32
+
33
+ export function buildSuccessfulBashResult(fullOutput: string) {
34
+ const truncation = truncateTail(fullOutput);
35
+ let outputText = truncation.content || '(no output)';
36
+ const tempFilePath = truncation.truncated ? maybePersistFullOutput(fullOutput) : undefined;
37
+ let details;
38
+
39
+ if (truncation.truncated) {
40
+ details = {
41
+ truncation,
42
+ fullOutputPath: tempFilePath,
43
+ };
44
+ outputText += buildTruncationNotice(truncation, fullOutput, tempFilePath);
45
+ }
46
+
47
+ return {
48
+ content: [{ type: 'text' as const, text: outputText }],
49
+ details,
50
+ };
51
+ }
52
+
53
+ export function buildExitCodeError(fullOutput: string, exitCode: number) {
54
+ const result = buildSuccessfulBashResult(fullOutput);
55
+ const outputText = result.content[0]?.text || '(no output)';
56
+ return new Error(`${outputText}\n\nCommand exited with code ${exitCode}`);
57
+ }
58
+
59
+ export function buildAbortError(fullOutput: string) {
60
+ const output = fullOutput === '(no output)' ? '' : fullOutput;
61
+ return new Error(output ? `${output}\n\nCommand aborted` : 'Command aborted');
62
+ }
63
+
64
+ export function buildTimeoutError(fullOutput: string, timeout: number) {
65
+ const output = fullOutput === '(no output)' ? '' : fullOutput;
66
+ return new Error(output ? `${output}\n\nCommand timed out after ${timeout} seconds` : `Command timed out after ${timeout} seconds`);
67
+ }
68
+
@@ -0,0 +1,114 @@
1
+ import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
2
+ import { snapshotToAnsiContentLines } from './terminal-emulator.ts';
3
+ import type { PtyTerminalSession } from './pty-session.ts';
4
+
5
+ export const WIDGET_PREFIX = 'pi-bash-live-view/live/';
6
+ const DEFAULT_TITLE = 'Live terminal';
7
+ const DEFAULT_ACCENT_COLOR = '77;163;255';
8
+
9
+ export type LiveSession = {
10
+ id: string;
11
+ startedAt: number;
12
+ rows: number;
13
+ visible: boolean;
14
+ disposed: boolean;
15
+ timer?: NodeJS.Timeout;
16
+ session: PtyTerminalSession;
17
+ requestRender?: () => void;
18
+ };
19
+
20
+ export function formatElapsed(ms: number): string {
21
+ const totalSeconds = Math.max(0, ms / 1000);
22
+ if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
23
+ const wholeSeconds = Math.floor(totalSeconds);
24
+ const hours = Math.floor(wholeSeconds / 3600);
25
+ const minutes = Math.floor((wholeSeconds % 3600) / 60);
26
+ const seconds = wholeSeconds % 60;
27
+ if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
28
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
29
+ }
30
+
31
+ export function buildTopBorder(title: string, innerWidth: number, elapsedMs: number): string {
32
+ const timer = ` ${formatElapsed(elapsedMs)} `;
33
+ const rawTitle = title ? ` ${title} ` : '';
34
+ const titleText = rawTitle.slice(0, Math.max(0, innerWidth - timer.length));
35
+ const fill = '─'.repeat(Math.max(0, innerWidth - titleText.length - timer.length));
36
+ return `${titleText}${fill}${timer}`.padEnd(innerWidth, '─').slice(0, innerWidth);
37
+ }
38
+
39
+ function fitAnsiLine(line: string, width: number): string {
40
+ let out = '';
41
+ let visible = 0;
42
+ let i = 0;
43
+ while (i < line.length && visible < width) {
44
+ if (line[i] === '\x1b' && line[i + 1] === '[') {
45
+ const match = line.slice(i).match(/^\x1b\[[0-9;]*m/);
46
+ if (match) {
47
+ out += match[0];
48
+ i += match[0].length;
49
+ continue;
50
+ }
51
+ }
52
+ out += line[i];
53
+ visible += 1;
54
+ i += 1;
55
+ }
56
+ return `${out}\x1b[0m${' '.repeat(Math.max(0, width - visible))}`;
57
+ }
58
+
59
+ export function buildWidgetAnsiLines({
60
+ title = DEFAULT_TITLE,
61
+ snapshot,
62
+ width,
63
+ rows,
64
+ elapsedMs = 0,
65
+ accentColor = DEFAULT_ACCENT_COLOR,
66
+ }: {
67
+ title?: string;
68
+ snapshot: ReturnType<PtyTerminalSession['getViewportSnapshot']>;
69
+ width: number;
70
+ rows: number;
71
+ elapsedMs?: number;
72
+ accentColor?: string;
73
+ }): string[] {
74
+ const accent = `\x1b[38;2;${accentColor}m`;
75
+ const reset = '\x1b[0m';
76
+ const innerWidth = Math.max(10, width - 2);
77
+ const top = `${accent}╭${buildTopBorder(title, innerWidth, elapsedMs)}╮${reset}`;
78
+ const bottom = `${accent}╰${'─'.repeat(innerWidth)}╯${reset}`;
79
+ const bodySource = snapshotToAnsiContentLines(snapshot).slice(-rows);
80
+ const body = [];
81
+ for (let i = 0; i < rows; i += 1) {
82
+ const line = fitAnsiLine(bodySource[i] ?? '', innerWidth);
83
+ body.push(`${accent}│${reset}${line}${accent}│${reset}`);
84
+ }
85
+ return [top, ...body, bottom];
86
+ }
87
+
88
+ function makeWidgetFactory(session: LiveSession) {
89
+ return (tui: any) => {
90
+ session.requestRender = () => tui.requestRender();
91
+ return {
92
+ invalidate() {},
93
+ render(width: number) {
94
+ return buildWidgetAnsiLines({
95
+ snapshot: session.session.getViewportSnapshot(),
96
+ width,
97
+ rows: session.rows,
98
+ elapsedMs: Date.now() - session.startedAt,
99
+ });
100
+ },
101
+ };
102
+ };
103
+ }
104
+
105
+ export function showWidget(ctx: ExtensionContext, session: LiveSession) {
106
+ if (!ctx.hasUI || session.visible || session.disposed) return;
107
+ session.visible = true;
108
+ ctx.ui.setWidget(`${WIDGET_PREFIX}${session.id}`, makeWidgetFactory(session));
109
+ }
110
+
111
+ export function hideWidget(ctx: ExtensionContext | null, session: LiveSession) {
112
+ if (!ctx || !ctx.hasUI) return;
113
+ ctx.ui.setWidget(`${WIDGET_PREFIX}${session.id}`, undefined);
114
+ }
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-03-22
4
+
5
+ - **Profile model overrides** — profiles can now override the `models` list per session model. Use a different summarization model when chatting with Opus vs Codex.
6
+ - **Profile template paths** — profiles can specify explicit `template` and `updateTemplate` paths, overriding convention-based discovery. Tilde-expanded.
7
+ - **Compaction spinner widget** — animated `Loader` spinner displays during extension-initiated compaction, matching pi's built-in appearance.
8
+ - **Post-compaction status fix** — status bar no longer shows `?` for 1-2 messages after compaction. Shows just the label until token counts are available again.
9
+ - **Watchdog widget cleanup** — if compaction hangs and the 2-minute watchdog fires, the spinner widget is now properly removed.
10
+ - **Test suite** — 62 tests across 6 files covering parse, merge, config, pure logic, model resolution, and template discovery. Run with `tsx --test test/*.test.ts`.
11
+
12
+ ## 0.1.0 — 2026-03-21
13
+
14
+ Initial release.
15
+
16
+ Custom compaction for Pi — swap the model used for compaction summaries, define your own summary template structure, and optionally trigger compaction at a specific token count. Everything is driven by a JSON config file with no UI overlay.
17
+
18
+ Core features:
19
+
20
+ - **Custom compaction model** with ordered fallback chain (`models`). If one provider's credits run out, the next model takes over. Per-model `thinkingLevel` and `preservationInstruction` overrides.
21
+ - **Token-based trigger** (`trigger.maxTokens`). Omit to let Pi's built-in decide when to compact. Set a value to proactively compact at that token count.
22
+ - **Custom summary templates** via convention-based markdown files. Separate initial and update templates so the model knows what to extract vs how to merge. Profile-specific templates supported. Entirely optional — Pi's built-in format works without one.
23
+ - **Profiles** — named overrides for trigger and summary settings that activate based on the session model. Match on exact `provider/modelId`.
24
+ - **Global and project config** — `~/.pi/agent/compaction-policy.json` (global) and `<project>/.pi/compaction-policy.json` (project, takes priority).
25
+ - **Status bar** with configurable label (`ui.name`), showing token usage relative to the effective trigger point.
26
+ - **Commands**: `/compact-policy` (show effective config) and `/compact-now [focus]` (trigger immediately).
27
+ - **Builtin skip margin** prevents double-triggering when the extension's trigger is close to Pi's own compaction threshold.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nico Bailon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.