@pi-unipi/unipi 2.0.2 → 2.0.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 +10 -3
- package/package.json +2 -2
- package/packages/ask-user/README.md +4 -0
- package/packages/ask-user/ask-ui.ts +97 -90
- package/packages/ask-user/tools.ts +12 -10
- package/packages/autocomplete/README.md +36 -0
- package/packages/compactor/README.md +290 -73
- package/packages/compactor/skills/compactor/SKILL.md +2 -3
- package/packages/compactor/skills/compactor-detail/SKILL.md +49 -64
- package/packages/compactor/skills/compactor-doctor/SKILL.md +28 -31
- package/packages/compactor/skills/compactor-stats/SKILL.md +22 -20
- package/packages/compactor/src/commands/index.ts +4 -1
- package/packages/compactor/src/compaction/auto-trigger.ts +306 -0
- package/packages/compactor/src/config/manager.ts +1 -0
- package/packages/compactor/src/config/presets.ts +26 -0
- package/packages/compactor/src/config/schema.ts +7 -0
- package/packages/compactor/src/index.ts +74 -1
- package/packages/compactor/src/tools/context-budget.ts +18 -2
- package/packages/compactor/src/tools/register.ts +19 -11
- package/packages/compactor/src/tui/settings-overlay.ts +142 -3
- package/packages/compactor/src/types.ts +17 -0
- package/packages/core/events.ts +2 -0
- package/packages/notify/README.md +2 -2
- package/packages/notify/commands.ts +9 -4
- package/packages/notify/events.ts +12 -2
- package/packages/notify/platforms/focus-win.ts +123 -0
- package/packages/notify/platforms/focus.ts +33 -0
- package/packages/notify/platforms/native.ts +33 -1
- package/packages/notify/settings.ts +1 -0
- package/packages/notify/tui/settings-overlay.ts +33 -7
- package/packages/notify/types.ts +8 -0
- package/packages/workflow/README.md +2 -0
- package/packages/workflow/commands.ts +28 -11
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
-
import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, emitEvent } from "@pi-unipi/core";
|
|
6
|
+
import { MODULES, UNIPI_EVENTS, COMPACTOR_COMMANDS, COMPACTOR_TOOLS, COMPACTOR_INSTRUCTION, emitEvent } from "@pi-unipi/core";
|
|
7
7
|
import { scaffoldConfig, loadConfig } from "./config/manager.js";
|
|
8
8
|
import { registerCompactionHooks } from "./compaction/hooks.js";
|
|
9
|
+
import {
|
|
10
|
+
createAutoCompactionState,
|
|
11
|
+
decideAutoCompaction,
|
|
12
|
+
markAutoCompactionComplete,
|
|
13
|
+
markAutoCompactionError,
|
|
14
|
+
type AutoCompactionState,
|
|
15
|
+
} from "./compaction/auto-trigger.js";
|
|
9
16
|
import { SessionDB, getWorktreeSuffix } from "./session/db.js";
|
|
10
17
|
import { extractEventsFromToolResult } from "./session/extract.js";
|
|
11
18
|
import { injectResumeSnapshot } from "./session/resume-inject.js";
|
|
@@ -26,6 +33,12 @@ function createDebugLogger(getConfig: () => { debug: boolean }) {
|
|
|
26
33
|
};
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
const formatTokenCount = (n: number): string => {
|
|
37
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
38
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
39
|
+
return String(n);
|
|
40
|
+
};
|
|
41
|
+
|
|
29
42
|
/** Measure byte size of a tool_result event's response content. */
|
|
30
43
|
function measureResponseBytes(event: any): number {
|
|
31
44
|
try {
|
|
@@ -59,6 +72,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
59
72
|
let sessionDB: SessionDB | null = null;
|
|
60
73
|
let executor: PolyglotExecutor | null = null;
|
|
61
74
|
let config = loadConfig();
|
|
75
|
+
let autoCompactionState: AutoCompactionState = createAutoCompactionState();
|
|
62
76
|
let cachedBlocks: NormalizedBlock[] = [];
|
|
63
77
|
let currentSessionId = "default";
|
|
64
78
|
const counters: RuntimeCounters = {
|
|
@@ -148,6 +162,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
148
162
|
runtimeStats.sessionStart = Date.now();
|
|
149
163
|
runtimeStats.cacheHits = 0;
|
|
150
164
|
runtimeStats.cacheBytesSaved = 0;
|
|
165
|
+
autoCompactionState = createAutoCompactionState();
|
|
151
166
|
|
|
152
167
|
sessionDB?.ensureSession(fullSessionId, projectDir);
|
|
153
168
|
|
|
@@ -284,6 +299,64 @@ export default function compactorExtension(pi: ExtensionAPI): void {
|
|
|
284
299
|
}
|
|
285
300
|
});
|
|
286
301
|
|
|
302
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
303
|
+
const cwd = (ctx as any).cwd ?? process.cwd();
|
|
304
|
+
config = loadConfig(cwd);
|
|
305
|
+
|
|
306
|
+
const decision = decideAutoCompaction({
|
|
307
|
+
config: config.autoCompaction,
|
|
308
|
+
usage: ctx.getContextUsage?.(),
|
|
309
|
+
state: autoCompactionState,
|
|
310
|
+
nowMs: Date.now(),
|
|
311
|
+
});
|
|
312
|
+
autoCompactionState = decision.state;
|
|
313
|
+
|
|
314
|
+
debug("auto_compaction_decision", {
|
|
315
|
+
enabled: config.autoCompaction.enabled,
|
|
316
|
+
reason: decision.reason,
|
|
317
|
+
shouldTrigger: decision.shouldTrigger,
|
|
318
|
+
percent: decision.usage?.percent,
|
|
319
|
+
tokens: decision.usage?.tokens,
|
|
320
|
+
thresholdPercent: decision.thresholdPercent,
|
|
321
|
+
cooldownRemainingMs: decision.cooldownRemainingMs,
|
|
322
|
+
tokenGrowth: decision.tokenGrowth,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (!decision.shouldTrigger) return;
|
|
326
|
+
|
|
327
|
+
const notify = config.autoCompaction.notify;
|
|
328
|
+
if (notify && decision.usage) {
|
|
329
|
+
ctx.ui.notify(
|
|
330
|
+
`Auto-compacting at ${decision.usage.percent.toFixed(1)}% context (~${formatTokenCount(decision.usage.tokens)} tokens; threshold ${decision.thresholdPercent}%).`,
|
|
331
|
+
"info",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
ctx.compact({
|
|
337
|
+
customInstructions: COMPACTOR_INSTRUCTION,
|
|
338
|
+
onComplete: () => {
|
|
339
|
+
autoCompactionState = markAutoCompactionComplete(autoCompactionState);
|
|
340
|
+
if (notify) {
|
|
341
|
+
ctx.ui.notify("Auto-compaction completed.", "info");
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
onError: (err: Error) => {
|
|
345
|
+
autoCompactionState = markAutoCompactionError(autoCompactionState, Date.now());
|
|
346
|
+
const benign = err.message === "Compaction cancelled" || err.message === "Already compacted";
|
|
347
|
+
if (notify && !benign) {
|
|
348
|
+
ctx.ui.notify(`Auto-compaction failed: ${err.message}`, "warning");
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
} catch (err) {
|
|
353
|
+
autoCompactionState = markAutoCompactionError(autoCompactionState, Date.now());
|
|
354
|
+
if (notify) {
|
|
355
|
+
ctx.ui.notify(`Auto-compaction failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
287
360
|
pi.on("session_before_compact", async (event, _ctx) => {
|
|
288
361
|
if (sessionDB) {
|
|
289
362
|
// Use closure currentSessionId — Pi's session_before_compact event
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* context_budget tool — estimate remaining context window
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { AutoCompactionConfig } from "../types.js";
|
|
6
|
+
|
|
5
7
|
export interface ContextBudgetResult {
|
|
6
8
|
percentFull: number;
|
|
7
9
|
remainingTokens: number;
|
|
@@ -13,6 +15,7 @@ export interface ContextBudgetResult {
|
|
|
13
15
|
export function estimateContextBudget(
|
|
14
16
|
tokensBefore?: number,
|
|
15
17
|
contextWindowSize?: number,
|
|
18
|
+
autoCompaction?: Pick<AutoCompactionConfig, "enabled" | "thresholdPercent">,
|
|
16
19
|
): ContextBudgetResult | null {
|
|
17
20
|
const windowSize = contextWindowSize ?? 200000; // Default 200K context
|
|
18
21
|
const used = tokensBefore ?? 0;
|
|
@@ -33,6 +36,15 @@ export function estimateContextBudget(
|
|
|
33
36
|
advice = "Context has plenty of room. No compaction needed yet.";
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
if (autoCompaction?.enabled) {
|
|
40
|
+
const threshold = autoCompaction.thresholdPercent;
|
|
41
|
+
if (percentFull >= threshold) {
|
|
42
|
+
advice += ` UniPi percentage auto-compaction is enabled at ${threshold}% and usage is above that threshold, subject to cooldown/repeat safeguards.`;
|
|
43
|
+
} else {
|
|
44
|
+
advice += ` UniPi percentage auto-compaction is enabled at ${threshold}%.`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
const message = `Context: ~${percentFull}% full (estimated ${remaining.toLocaleString()} tokens remaining)`;
|
|
37
49
|
|
|
38
50
|
return { percentFull, remainingTokens: remaining, totalTokens: windowSize, message, advice };
|
|
@@ -42,8 +54,12 @@ export function estimateContextBudget(
|
|
|
42
54
|
* The context_budget tool handler.
|
|
43
55
|
* Called from the tool registration — receives tokensBefore from Pi context.
|
|
44
56
|
*/
|
|
45
|
-
export function contextBudgetTool(
|
|
46
|
-
|
|
57
|
+
export function contextBudgetTool(
|
|
58
|
+
tokensBefore?: number,
|
|
59
|
+
contextWindowSize?: number,
|
|
60
|
+
autoCompaction?: Pick<AutoCompactionConfig, "enabled" | "thresholdPercent">,
|
|
61
|
+
): string {
|
|
62
|
+
const budget = estimateContextBudget(tokensBefore, contextWindowSize, autoCompaction);
|
|
47
63
|
if (!budget) return "Context budget: Unknown (no token data available from session).";
|
|
48
64
|
|
|
49
65
|
return `${budget.message}\nAdvice: ${budget.advice}`;
|
|
@@ -291,17 +291,25 @@ export function registerCompactorTools(pi: ExtensionAPI, deps: CompactorToolDeps
|
|
|
291
291
|
label: "Context Budget",
|
|
292
292
|
description: "Estimate remaining context window (% full, tokens left) and get advice on whether to compact.",
|
|
293
293
|
parameters: Type.Object({}),
|
|
294
|
-
async execute(): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> {
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
294
|
+
async execute(_toolCallId: string, _params: any, _signal?: AbortSignal, _onUpdate?: unknown, ctx?: ExtensionContext): Promise<import("@mariozechner/pi-coding-agent").AgentToolResult<unknown>> {
|
|
295
|
+
const config = loadConfig(ctx?.cwd ?? process.cwd());
|
|
296
|
+
const liveUsage = ctx?.getContextUsage?.();
|
|
297
|
+
let estimatedTokens: number | undefined = liveUsage?.tokens ?? undefined;
|
|
298
|
+
let contextWindow = liveUsage?.contextWindow;
|
|
299
|
+
|
|
300
|
+
if (estimatedTokens === undefined) {
|
|
301
|
+
const blocks = deps.getBlocks();
|
|
302
|
+
estimatedTokens = blocks.reduce((sum, b) => {
|
|
303
|
+
const text = b.kind === "tool_call"
|
|
304
|
+
? `${b.name} ${JSON.stringify((b as any).args ?? {})}`
|
|
305
|
+
: b.kind === "tool_result"
|
|
306
|
+
? `${b.name} ${(b as any).text ?? ""}`
|
|
307
|
+
: (b as any).text ?? "";
|
|
308
|
+
return sum + Math.ceil(text.length / 4);
|
|
309
|
+
}, 0);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const message = contextBudgetTool(estimatedTokens, contextWindow, config.autoCompaction);
|
|
305
313
|
return textResult(message);
|
|
306
314
|
},
|
|
307
315
|
} as any));
|
|
@@ -18,8 +18,8 @@ import { existsSync, unlinkSync } from "node:fs";
|
|
|
18
18
|
|
|
19
19
|
// ─── Section types ─────────────────────────────────────────────────────
|
|
20
20
|
|
|
21
|
-
type Section = "presets" | "strategies" | "pipeline";
|
|
22
|
-
const SECTIONS: Section[] = ["presets", "strategies", "pipeline"];
|
|
21
|
+
type Section = "presets" | "strategies" | "auto" | "pipeline";
|
|
22
|
+
const SECTIONS: Section[] = ["presets", "strategies", "auto", "pipeline"];
|
|
23
23
|
|
|
24
24
|
// ─── Strategy item definition ──────────────────────────────────────────
|
|
25
25
|
|
|
@@ -160,6 +160,10 @@ const PIPELINE_ITEMS: PipelineDef[] = [
|
|
|
160
160
|
|
|
161
161
|
const PRESETS: CompactorPreset[] = ["precise", "balanced", "thorough", "lean"];
|
|
162
162
|
|
|
163
|
+
const THRESHOLD_VALUES = ["60%", "70%", "75%", "80%", "85%", "90%", "95%"];
|
|
164
|
+
const COOLDOWN_VALUES = ["0s", "30s", "60s", "5m", "10m"];
|
|
165
|
+
const REPEAT_GROWTH_VALUES = ["0", "1k", "4k", "8k", "16k", "32k"];
|
|
166
|
+
|
|
163
167
|
const PRESET_DESCRIPTIONS: Record<string, { summary: string; detail: string }> = {
|
|
164
168
|
precise: {
|
|
165
169
|
summary: "Code-heavy, minimal waste — compaction: full, sandbox: safe-only",
|
|
@@ -207,6 +211,40 @@ function borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
|
207
211
|
return `\x1b[90m${left}${"─".repeat(innerWidth)}${right}\x1b[0m`;
|
|
208
212
|
}
|
|
209
213
|
|
|
214
|
+
function uniqueValues(values: string[]): string[] {
|
|
215
|
+
return [...new Set(values)];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function formatPercent(value: number): string {
|
|
219
|
+
return `${Math.round(value)}%`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parsePercent(value: string): number {
|
|
223
|
+
return Number(value.replace("%", ""));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatCooldown(ms: number): string {
|
|
227
|
+
if (ms === 0) return "0s";
|
|
228
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
|
229
|
+
return `${Math.round(ms / 1000)}s`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function parseCooldown(value: string): number {
|
|
233
|
+
if (value.endsWith("m")) return Number(value.slice(0, -1)) * 60_000;
|
|
234
|
+
if (value.endsWith("s")) return Number(value.slice(0, -1)) * 1000;
|
|
235
|
+
return Number(value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatGrowthTokens(tokens: number): string {
|
|
239
|
+
if (tokens >= 1000 && tokens % 1000 === 0) return `${tokens / 1000}k`;
|
|
240
|
+
return String(tokens);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseGrowthTokens(value: string): number {
|
|
244
|
+
if (value.endsWith("k")) return Number(value.slice(0, -1)) * 1000;
|
|
245
|
+
return Number(value);
|
|
246
|
+
}
|
|
247
|
+
|
|
210
248
|
// ─── Main component ────────────────────────────────────────────────────
|
|
211
249
|
|
|
212
250
|
/**
|
|
@@ -224,6 +262,7 @@ export class CompactorSettingsOverlay implements Component {
|
|
|
224
262
|
// Per-section SettingsList instances
|
|
225
263
|
private presetList!: SettingsList;
|
|
226
264
|
private strategyList!: SettingsList;
|
|
265
|
+
private autoList!: SettingsList;
|
|
227
266
|
private pipelineList!: SettingsList;
|
|
228
267
|
|
|
229
268
|
constructor(opts?: { cwd?: string }) {
|
|
@@ -290,6 +329,53 @@ export class CompactorSettingsOverlay implements Component {
|
|
|
290
329
|
{ enableSearch: true },
|
|
291
330
|
);
|
|
292
331
|
|
|
332
|
+
// ── Auto-compaction trigger list ──────────────────────────────────
|
|
333
|
+
const autoItems: SettingItem[] = [
|
|
334
|
+
{
|
|
335
|
+
id: "auto:enabled",
|
|
336
|
+
label: "Percentage Trigger",
|
|
337
|
+
description: "UniPi-managed auto-compaction based on context percentage",
|
|
338
|
+
currentValue: this.config.autoCompaction.enabled ? "on" : "off",
|
|
339
|
+
values: ["on", "off"],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: "auto:thresholdPercent",
|
|
343
|
+
label: "Threshold",
|
|
344
|
+
description: "Trigger when Pi reports context usage at or above this percent",
|
|
345
|
+
currentValue: formatPercent(this.config.autoCompaction.thresholdPercent),
|
|
346
|
+
values: uniqueValues([...THRESHOLD_VALUES, formatPercent(this.config.autoCompaction.thresholdPercent)]),
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: "auto:cooldownMs",
|
|
350
|
+
label: "Cooldown",
|
|
351
|
+
description: "Minimum delay between UniPi-triggered compaction attempts",
|
|
352
|
+
currentValue: formatCooldown(this.config.autoCompaction.cooldownMs),
|
|
353
|
+
values: uniqueValues([...COOLDOWN_VALUES, formatCooldown(this.config.autoCompaction.cooldownMs)]),
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
id: "auto:repeatMinGrowthTokens",
|
|
357
|
+
label: "Repeat Growth",
|
|
358
|
+
description: "If still above threshold after compaction, require this many new tokens",
|
|
359
|
+
currentValue: formatGrowthTokens(this.config.autoCompaction.repeatMinGrowthTokens),
|
|
360
|
+
values: uniqueValues([...REPEAT_GROWTH_VALUES, formatGrowthTokens(this.config.autoCompaction.repeatMinGrowthTokens)]),
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "auto:notify",
|
|
364
|
+
label: "Notifications",
|
|
365
|
+
description: "Notify when UniPi auto-compaction triggers or fails",
|
|
366
|
+
currentValue: this.config.autoCompaction.notify ? "on" : "off",
|
|
367
|
+
values: ["on", "off"],
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
this.autoList = new SettingsList(
|
|
372
|
+
autoItems,
|
|
373
|
+
8,
|
|
374
|
+
THEME,
|
|
375
|
+
(id, newValue) => this.onAutoChange(id, newValue),
|
|
376
|
+
() => this.onCancel(),
|
|
377
|
+
);
|
|
378
|
+
|
|
293
379
|
// ── Pipeline list ─────────────────────────────────────────────────
|
|
294
380
|
const pipelineItems: SettingItem[] = PIPELINE_ITEMS.map((p) => ({
|
|
295
381
|
id: `pipeline:${p.key}`,
|
|
@@ -312,6 +398,7 @@ export class CompactorSettingsOverlay implements Component {
|
|
|
312
398
|
|
|
313
399
|
private get currentList(): SettingsList {
|
|
314
400
|
if (this.section === "strategies") return this.strategyList;
|
|
401
|
+
if (this.section === "auto") return this.autoList;
|
|
315
402
|
if (this.section === "pipeline") return this.pipelineList;
|
|
316
403
|
return this.presetList;
|
|
317
404
|
}
|
|
@@ -332,8 +419,9 @@ export class CompactorSettingsOverlay implements Component {
|
|
|
332
419
|
const presetName = id.replace("preset:", "") as CompactorPreset;
|
|
333
420
|
if (PRESETS.includes(presetName)) {
|
|
334
421
|
this.config = applyPreset(presetName);
|
|
335
|
-
// Update all strategy/pipeline items to reflect new config
|
|
422
|
+
// Update all strategy/auto/pipeline items to reflect new config
|
|
336
423
|
this.refreshStrategyValues();
|
|
424
|
+
this.refreshAutoValues();
|
|
337
425
|
this.refreshPipelineValues();
|
|
338
426
|
// Update preset indicators
|
|
339
427
|
for (const name of PRESETS) {
|
|
@@ -366,6 +454,39 @@ export class CompactorSettingsOverlay implements Component {
|
|
|
366
454
|
}
|
|
367
455
|
}
|
|
368
456
|
|
|
457
|
+
private onAutoChange(id: string, newValue: string): void {
|
|
458
|
+
const key = id.replace("auto:", "");
|
|
459
|
+
switch (key) {
|
|
460
|
+
case "enabled":
|
|
461
|
+
this.config.autoCompaction.enabled = newValue === "on";
|
|
462
|
+
break;
|
|
463
|
+
case "thresholdPercent":
|
|
464
|
+
this.config.autoCompaction.thresholdPercent = parsePercent(newValue);
|
|
465
|
+
break;
|
|
466
|
+
case "cooldownMs":
|
|
467
|
+
this.config.autoCompaction.cooldownMs = parseCooldown(newValue);
|
|
468
|
+
break;
|
|
469
|
+
case "repeatMinGrowthTokens":
|
|
470
|
+
this.config.autoCompaction.repeatMinGrowthTokens = parseGrowthTokens(newValue);
|
|
471
|
+
break;
|
|
472
|
+
case "notify":
|
|
473
|
+
this.config.autoCompaction.notify = newValue === "on";
|
|
474
|
+
break;
|
|
475
|
+
default:
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
this.autoList.updateValue(id, this.formatAutoValue(key));
|
|
480
|
+
|
|
481
|
+
// Update preset indicators
|
|
482
|
+
for (const name of PRESETS) {
|
|
483
|
+
this.presetList.updateValue(
|
|
484
|
+
`preset:${name}`,
|
|
485
|
+
detectPreset(this.config) === name ? "✓ active" : "",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
369
490
|
private onPipelineChange(id: string, newValue: string): void {
|
|
370
491
|
const key = id.replace("pipeline:", "");
|
|
371
492
|
const item = PIPELINE_ITEMS.find((p) => p.key === key);
|
|
@@ -395,12 +516,30 @@ export class CompactorSettingsOverlay implements Component {
|
|
|
395
516
|
return mode;
|
|
396
517
|
}
|
|
397
518
|
|
|
519
|
+
private formatAutoValue(key: string): string {
|
|
520
|
+
const auto = this.config.autoCompaction;
|
|
521
|
+
switch (key) {
|
|
522
|
+
case "enabled": return auto.enabled ? "on" : "off";
|
|
523
|
+
case "thresholdPercent": return formatPercent(auto.thresholdPercent);
|
|
524
|
+
case "cooldownMs": return formatCooldown(auto.cooldownMs);
|
|
525
|
+
case "repeatMinGrowthTokens": return formatGrowthTokens(auto.repeatMinGrowthTokens);
|
|
526
|
+
case "notify": return auto.notify ? "on" : "off";
|
|
527
|
+
default: return "";
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
398
531
|
private refreshStrategyValues(): void {
|
|
399
532
|
for (const s of STRATEGIES) {
|
|
400
533
|
this.strategyList.updateValue(`strategy:${s.key}`, this.formatStrategyValue(s));
|
|
401
534
|
}
|
|
402
535
|
}
|
|
403
536
|
|
|
537
|
+
private refreshAutoValues(): void {
|
|
538
|
+
for (const key of ["enabled", "thresholdPercent", "cooldownMs", "repeatMinGrowthTokens", "notify"]) {
|
|
539
|
+
this.autoList.updateValue(`auto:${key}`, this.formatAutoValue(key));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
404
543
|
private refreshPipelineValues(): void {
|
|
405
544
|
for (const p of PIPELINE_ITEMS) {
|
|
406
545
|
this.pipelineList.updateValue(`pipeline:${p.key}`, p.getValue(this.config) ? "on" : "off");
|
|
@@ -88,6 +88,20 @@ export interface CompactorStrategyConfig {
|
|
|
88
88
|
autoDetect?: "git" | null;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** UniPi-managed percentage auto-compaction trigger settings. */
|
|
92
|
+
export interface AutoCompactionConfig {
|
|
93
|
+
/** Enable the extension-managed percentage trigger. Disabled by default for backward compatibility. */
|
|
94
|
+
enabled: boolean;
|
|
95
|
+
/** Trigger when Pi reports context usage at or above this percent (0-100 scale). */
|
|
96
|
+
thresholdPercent: number;
|
|
97
|
+
/** Minimum delay between UniPi-triggered compaction attempts. */
|
|
98
|
+
cooldownMs: number;
|
|
99
|
+
/** When usage stays above threshold after compaction, require this many new tokens before repeating. */
|
|
100
|
+
repeatMinGrowthTokens: number;
|
|
101
|
+
/** Show user notifications for UniPi-triggered compaction attempts/results. */
|
|
102
|
+
notify: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
export interface CompactorConfig {
|
|
92
106
|
// Compaction strategies
|
|
93
107
|
sessionGoals: CompactorStrategyConfig & { mode: "full" | "brief" | "off" };
|
|
@@ -112,6 +126,9 @@ export interface CompactorConfig {
|
|
|
112
126
|
customNoisePatterns: string[];
|
|
113
127
|
};
|
|
114
128
|
|
|
129
|
+
// Auto compaction trigger
|
|
130
|
+
autoCompaction: AutoCompactionConfig;
|
|
131
|
+
|
|
115
132
|
// Global settings
|
|
116
133
|
overrideDefaultCompaction: boolean;
|
|
117
134
|
debug: boolean;
|
package/packages/core/events.ts
CHANGED
|
@@ -409,6 +409,8 @@ export interface UnipiNotificationSentEvent {
|
|
|
409
409
|
platforms: string[];
|
|
410
410
|
/** Whether all platforms succeeded */
|
|
411
411
|
success: boolean;
|
|
412
|
+
/** Platforms where notification was suppressed (e.g. window focused) */
|
|
413
|
+
suppressedPlatforms?: string[];
|
|
412
414
|
/** ISO timestamp */
|
|
413
415
|
timestamp: string;
|
|
414
416
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Push notifications when things happen. Workflow finishes, Ralph loop completes, MCP server errors — notify sends alerts to native OS, Gotify, Telegram, or ntfy.
|
|
4
4
|
|
|
5
|
-
Configure once, get alerts everywhere. Per-event platform routing lets you send critical errors to Telegram and routine completions to Gotify.
|
|
5
|
+
Configure once, get alerts everywhere. Per-event platform routing lets you send critical errors to Telegram and routine completions to Gotify. Native desktop notifications can also be suppressed while the Pi window is focused.
|
|
6
6
|
|
|
7
7
|
## Commands
|
|
8
8
|
|
|
@@ -53,7 +53,7 @@ Desktop notifications via [node-notifier](https://github.com/mikaelbr/node-notif
|
|
|
53
53
|
- **macOS:** terminal-notifier
|
|
54
54
|
- **Linux:** notify-send / libnotify
|
|
55
55
|
|
|
56
|
-
Zero configuration — works out of the box.
|
|
56
|
+
Zero configuration — works out of the box. Set `native.suppressWhenFocused` to `true` to skip native notifications when the active/focused window is already Pi.
|
|
57
57
|
|
|
58
58
|
### Gotify
|
|
59
59
|
|
|
@@ -14,7 +14,7 @@ import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
|
|
|
14
14
|
import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
|
|
15
15
|
import { loadConfig } from "./settings.js";
|
|
16
16
|
import { loadNtfyConfig } from "./ntfy-config.js";
|
|
17
|
-
import { sendNativeNotification } from "./platforms/native.js";
|
|
17
|
+
import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
|
|
18
18
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
19
19
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
20
20
|
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
@@ -267,12 +267,17 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
267
267
|
try {
|
|
268
268
|
await sendNativeNotification(title, message, {
|
|
269
269
|
windowsAppId: config.native.windowsAppId,
|
|
270
|
+
suppressWhenFocused: config.native.suppressWhenFocused,
|
|
270
271
|
});
|
|
271
272
|
results.push("✓ Native: sent");
|
|
272
273
|
} catch (err) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
if (err instanceof SuppressedError) {
|
|
275
|
+
results.push("— Native: suppressed (window focused)");
|
|
276
|
+
} else {
|
|
277
|
+
results.push(
|
|
278
|
+
`✗ Native: ${err instanceof Error ? err.message : "failed"}`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
276
281
|
}
|
|
277
282
|
}
|
|
278
283
|
|
|
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
|
|
|
9
9
|
import { UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
|
|
10
10
|
import type { NotifyConfig, NotifyPlatform, NotifyDispatchResult } from "./types.js";
|
|
11
11
|
import { loadNtfyConfig } from "./ntfy-config.js";
|
|
12
|
-
import { sendNativeNotification } from "./platforms/native.js";
|
|
12
|
+
import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
|
|
13
13
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
14
14
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
15
15
|
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
@@ -184,6 +184,10 @@ export async function dispatchNotification(
|
|
|
184
184
|
await sendToPlatform(platform, title, message, config, cwd);
|
|
185
185
|
return { platform, success: true };
|
|
186
186
|
} catch (err) {
|
|
187
|
+
// SuppressedError is intentional, not a failure
|
|
188
|
+
if (err instanceof SuppressedError) {
|
|
189
|
+
return { platform, success: true, suppressed: true };
|
|
190
|
+
}
|
|
187
191
|
// Silently ignore — platform send failure is tracked in results.
|
|
188
192
|
return {
|
|
189
193
|
platform,
|
|
@@ -194,13 +198,18 @@ export async function dispatchNotification(
|
|
|
194
198
|
})
|
|
195
199
|
);
|
|
196
200
|
|
|
197
|
-
const
|
|
201
|
+
const unsuppressed = results.filter((r) => !r.suppressed);
|
|
202
|
+
const allSuccess = results.length > 0 && unsuppressed.every((r) => r.success);
|
|
203
|
+
const suppressedPlatforms = results
|
|
204
|
+
.filter((r) => r.suppressed)
|
|
205
|
+
.map((r) => r.platform);
|
|
198
206
|
|
|
199
207
|
// Emit notification sent event
|
|
200
208
|
emitEvent(pi, UNIPI_EVENTS.NOTIFICATION_SENT, {
|
|
201
209
|
eventType,
|
|
202
210
|
platforms: enabledPlatforms,
|
|
203
211
|
success: allSuccess,
|
|
212
|
+
...(suppressedPlatforms.length > 0 && { suppressedPlatforms }),
|
|
204
213
|
timestamp: new Date().toISOString(),
|
|
205
214
|
});
|
|
206
215
|
|
|
@@ -219,6 +228,7 @@ async function sendToPlatform(
|
|
|
219
228
|
case "native":
|
|
220
229
|
await sendNativeNotification(title, message, {
|
|
221
230
|
windowsAppId: config.native.windowsAppId,
|
|
231
|
+
suppressWhenFocused: config.native.suppressWhenFocused,
|
|
222
232
|
});
|
|
223
233
|
break;
|
|
224
234
|
case "gotify":
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Windows focus detection
|
|
3
|
+
*
|
|
4
|
+
* Checks whether the terminal window is the foreground (active) window
|
|
5
|
+
* by walking the WMI process tree upward from the current process PID
|
|
6
|
+
* and comparing each ancestor against the foreground window's owner PID.
|
|
7
|
+
*
|
|
8
|
+
* This approach works reliably across cmd, PowerShell, and Windows
|
|
9
|
+
* Terminal, unlike GetConsoleWindow which returns NULL in spawned
|
|
10
|
+
* child processes.
|
|
11
|
+
*
|
|
12
|
+
* Requires PowerShell (built-in on Windows 7+).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from "child_process";
|
|
16
|
+
import { writeFileSync, rmSync, mkdtempSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// PowerShell script (embedded)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const POWERCHECK_SCRIPT = `
|
|
25
|
+
param($targetPid)
|
|
26
|
+
Add-Type @'
|
|
27
|
+
using System;
|
|
28
|
+
using System.Runtime.InteropServices;
|
|
29
|
+
public class WinAPI {
|
|
30
|
+
[DllImport("user32.dll", SetLastError = false)]
|
|
31
|
+
public static extern IntPtr GetForegroundWindow();
|
|
32
|
+
[DllImport("user32.dll", SetLastError = false)]
|
|
33
|
+
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
|
34
|
+
}
|
|
35
|
+
'@ | Out-Null
|
|
36
|
+
$fgHwnd = [WinAPI]::GetForegroundWindow()
|
|
37
|
+
[uint32]$fgPid = 0
|
|
38
|
+
[void][WinAPI]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
|
|
39
|
+
$curPid = $targetPid
|
|
40
|
+
$maxDepth = 20
|
|
41
|
+
while ($curPid -gt 0 -and $maxDepth-- -gt 0) {
|
|
42
|
+
if ($curPid -eq $fgPid) { Write-Host -NoNewline 'True'; exit }
|
|
43
|
+
$proc = Get-CimInstance -Class Win32_Process -Filter "ProcessId = $curPid" -ErrorAction SilentlyContinue | Select-Object -Property ParentProcessId
|
|
44
|
+
if (-not $proc) { break }
|
|
45
|
+
$curPid = $proc.ParentProcessId
|
|
46
|
+
}
|
|
47
|
+
Write-Host -NoNewline 'False'
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Cache — avoid spawning PowerShell on every check
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
let cached: { result: boolean; time: number } | null = null;
|
|
55
|
+
const CACHE_TTL_MS = 500;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Public API
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns true when the terminal window that owns the current process is
|
|
63
|
+
* the foreground (active) window on Windows.
|
|
64
|
+
*
|
|
65
|
+
* Works by:
|
|
66
|
+
* 1. Calling Win32 GetForegroundWindow + GetWindowThreadProcessId to
|
|
67
|
+
* obtain the foreground window's owning PID.
|
|
68
|
+
* 2. Walking the WMI Win32_Process parent chain upward from
|
|
69
|
+
* process.pid.
|
|
70
|
+
* 3. If any ancestor PID matches the foreground PID the terminal is
|
|
71
|
+
* considered focused.
|
|
72
|
+
*
|
|
73
|
+
* The result is cached for 500 ms to avoid spawning PowerShell on rapid
|
|
74
|
+
* consecutive checks (e.g. batch notifications).
|
|
75
|
+
*/
|
|
76
|
+
export async function isWindowFocusedOnWindows(): Promise<boolean> {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
if (cached && now - cached.time < CACHE_TTL_MS) {
|
|
79
|
+
return cached.result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let tmpDir: string | null = null;
|
|
83
|
+
try {
|
|
84
|
+
tmpDir = mkdtempSync(join(tmpdir(), "pi-focus-"));
|
|
85
|
+
const scriptPath = join(tmpDir, "check.ps1");
|
|
86
|
+
writeFileSync(scriptPath, POWERCHECK_SCRIPT, "utf-8");
|
|
87
|
+
|
|
88
|
+
const stdout = await new Promise<string>((resolve, reject) => {
|
|
89
|
+
execFile(
|
|
90
|
+
"powershell.exe",
|
|
91
|
+
[
|
|
92
|
+
"-NoProfile",
|
|
93
|
+
"-NonInteractive",
|
|
94
|
+
"-ExecutionPolicy",
|
|
95
|
+
"Bypass",
|
|
96
|
+
"-File",
|
|
97
|
+
scriptPath,
|
|
98
|
+
String(process.pid),
|
|
99
|
+
],
|
|
100
|
+
{ timeout: 5000, encoding: "utf-8" },
|
|
101
|
+
(err, out) => {
|
|
102
|
+
if (err) reject(err);
|
|
103
|
+
else resolve(out);
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
cached = { result: stdout.trim() === "True", time: Date.now() };
|
|
109
|
+
return cached.result;
|
|
110
|
+
} catch {
|
|
111
|
+
// Detection failure → safe default: assume NOT focused (don't suppress)
|
|
112
|
+
cached = { result: false, time: Date.now() };
|
|
113
|
+
return false;
|
|
114
|
+
} finally {
|
|
115
|
+
if (tmpDir) {
|
|
116
|
+
try {
|
|
117
|
+
rmSync(tmpDir, { recursive: true });
|
|
118
|
+
} catch {
|
|
119
|
+
// Temp file cleanup is non-critical
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|