@pi-unipi/unipi 2.14.2 → 2.16.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/CHANGELOG.md +18 -0
- package/package.json +23 -23
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/background-tasks/package.json +2 -2
- package/packages/btw/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/core/package.json +1 -1
- package/packages/footer/README.md +3 -5
- package/packages/footer/package.json +3 -3
- package/packages/footer/src/glance-editor.ts +35 -66
- package/packages/footer/src/rendering/icons.ts +5 -0
- package/packages/footer/src/rendering/lolcat.ts +84 -0
- package/packages/footer/src/tui/settings-tui.ts +1 -1
- package/packages/image/package.json +2 -2
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/memory/index.ts +17 -0
- package/packages/memory/mempalace.ts +185 -0
- package/packages/memory/package.json +3 -3
- package/packages/memory/settings.ts +3 -0
- package/packages/memory/tui/settings-tui.ts +14 -0
- package/packages/milestone/package.json +2 -2
- package/packages/notify/README.md +16 -0
- package/packages/notify/activity.ts +100 -0
- package/packages/notify/events.ts +9 -1
- package/packages/notify/index.ts +20 -0
- package/packages/notify/package.json +2 -2
- package/packages/notify/settings.ts +21 -7
- package/packages/notify/skills/configure-notify/SKILL.md +23 -0
- package/packages/notify/tui/settings-overlay.ts +141 -19
- package/packages/notify/types.ts +12 -0
- package/packages/ralph/README.md +2 -0
- package/packages/ralph/SKILL.md +2 -0
- package/packages/ralph/completions.ts +170 -0
- package/packages/ralph/index.ts +3 -0
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +2 -2
- package/packages/subagents/src/custom-agents.ts +22 -2
- package/packages/trajectory/package.json +1 -1
- package/packages/unipi/bundled.js +3472 -9650
- package/packages/updater/package.json +2 -2
- package/packages/utility/package.json +2 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
|
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
|
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import * as os from "node:os";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { loadEmbeddingConfig } from "./settings.js";
|
|
21
22
|
|
|
22
23
|
/** Default MemPalace palace path. */
|
|
23
24
|
export const DEFAULT_PALACE = path.join(os.homedir(), ".mempalace", "palace");
|
|
@@ -458,3 +459,187 @@ export function runBridgeAsync<T = unknown>(
|
|
|
458
459
|
export function ping(install: MempalaceInstall, palace: string): boolean {
|
|
459
460
|
return runBridge<string>(install, palace, "ping") === "pong";
|
|
460
461
|
}
|
|
462
|
+
|
|
463
|
+
// ── Auto-update (TTL-gated PyPI check + `uv tool upgrade`) ────────────────
|
|
464
|
+
|
|
465
|
+
/** Update check state file — records the last check so we hit PyPI ~daily. */
|
|
466
|
+
const UPDATE_FLAG = path.join(os.homedir(), ".unipi", "memory", ".mempalace-update");
|
|
467
|
+
export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
468
|
+
const PYPI_URL = "https://pypi.org/pypi/mempalace/json";
|
|
469
|
+
|
|
470
|
+
export interface MempalaceUpdateState {
|
|
471
|
+
checkedAt: number;
|
|
472
|
+
latestVersion: string;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export interface MempalaceUpdateOutcome {
|
|
476
|
+
checked: boolean;
|
|
477
|
+
updated: boolean;
|
|
478
|
+
currentVersion?: string;
|
|
479
|
+
latestVersion?: string;
|
|
480
|
+
reason?: "disabled" | "not-installed" | "recent" | "lookup-failed" | "up-to-date" | "uv-missing" | "upgrade-failed";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Read the cached update-check state (null when missing/corrupt). */
|
|
484
|
+
export function readUpdateState(flagPath = UPDATE_FLAG): MempalaceUpdateState | null {
|
|
485
|
+
try {
|
|
486
|
+
const parsed = JSON.parse(fs.readFileSync(flagPath, "utf-8")) as MempalaceUpdateState;
|
|
487
|
+
if (typeof parsed?.checkedAt !== "number" || typeof parsed?.latestVersion !== "string") return null;
|
|
488
|
+
return parsed;
|
|
489
|
+
} catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Persist the update-check state atomically. */
|
|
495
|
+
export function writeUpdateState(state: MempalaceUpdateState, flagPath = UPDATE_FLAG): void {
|
|
496
|
+
try {
|
|
497
|
+
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
|
498
|
+
const temp = `${flagPath}.${process.pid}.tmp`;
|
|
499
|
+
fs.writeFileSync(temp, JSON.stringify(state, null, 2), "utf-8");
|
|
500
|
+
fs.renameSync(temp, flagPath);
|
|
501
|
+
} catch { /* ignore */ }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Is a PyPI lookup due? (no state yet, or the TTL has elapsed) */
|
|
505
|
+
export function isUpdateCheckDue(
|
|
506
|
+
flagPath = UPDATE_FLAG,
|
|
507
|
+
now = Date.now(),
|
|
508
|
+
ttlMs = UPDATE_CHECK_TTL_MS,
|
|
509
|
+
): boolean {
|
|
510
|
+
const state = readUpdateState(flagPath);
|
|
511
|
+
if (!state) return true;
|
|
512
|
+
return now - state.checkedAt >= ttlMs;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Numeric dotted-version compare: >0 if a is newer, <0 if older, 0 if equal. */
|
|
516
|
+
export function compareVersions(a: string, b: string): number {
|
|
517
|
+
const pa = String(a ?? "").trim().split(".");
|
|
518
|
+
const pb = String(b ?? "").trim().split(".");
|
|
519
|
+
const len = Math.max(pa.length, pb.length);
|
|
520
|
+
for (let i = 0; i < len; i++) {
|
|
521
|
+
const na = Number.parseInt(pa[i] ?? "0", 10) || 0;
|
|
522
|
+
const nb = Number.parseInt(pb[i] ?? "0") || 0;
|
|
523
|
+
if (na !== nb) return na - nb;
|
|
524
|
+
}
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Latest MemPalace version on PyPI, or null on any failure. */
|
|
529
|
+
export async function fetchLatestMempalaceVersion(
|
|
530
|
+
fetchImpl: typeof fetch = fetch,
|
|
531
|
+
): Promise<string | null> {
|
|
532
|
+
try {
|
|
533
|
+
const res = await fetchImpl(PYPI_URL, { signal: AbortSignal.timeout(8_000) });
|
|
534
|
+
if (!res.ok) return null;
|
|
535
|
+
const body = (await res.json()) as { info?: { version?: string } };
|
|
536
|
+
return typeof body?.info?.version === "string" ? body.info.version : null;
|
|
537
|
+
} catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Is the opt-in MemPalace daemon currently running? */
|
|
543
|
+
function daemonRunning(): boolean {
|
|
544
|
+
try {
|
|
545
|
+
const res = spawnSync("mempalace", ["daemon", "status"], { encoding: "utf-8", timeout: 10_000 });
|
|
546
|
+
return /is running/i.test(res.stdout || "");
|
|
547
|
+
} catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Fire-and-forget process run; resolves null on spawn failure or non-zero exit. */
|
|
553
|
+
function runProcess(bin: string, args: string[], timeoutMs: number): Promise<boolean> {
|
|
554
|
+
return new Promise((resolve) => {
|
|
555
|
+
let child;
|
|
556
|
+
try {
|
|
557
|
+
child = spawn(bin, args, { stdio: ["ignore", "ignore", "ignore"] });
|
|
558
|
+
} catch {
|
|
559
|
+
resolve(false);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
let settled = false;
|
|
563
|
+
const finish = (ok: boolean) => {
|
|
564
|
+
if (settled) return;
|
|
565
|
+
settled = true;
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
resolve(ok);
|
|
568
|
+
};
|
|
569
|
+
const timer = setTimeout(() => {
|
|
570
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
571
|
+
finish(false);
|
|
572
|
+
}, timeoutMs);
|
|
573
|
+
timer.unref?.();
|
|
574
|
+
child.on("error", () => finish(false));
|
|
575
|
+
child.on("close", (code) => finish(code === 0));
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Upgrade MemPalace via `uv tool install --upgrade`. The daemon (if running)
|
|
581
|
+
* is stopped first and restarted after, so the long-lived process picks up
|
|
582
|
+
* the new venv instead of straddling versions.
|
|
583
|
+
*/
|
|
584
|
+
async function upgradeMempalace(): Promise<boolean> {
|
|
585
|
+
if (!which("uv")) return false;
|
|
586
|
+
const wasRunning = daemonRunning();
|
|
587
|
+
if (wasRunning) await runProcess("mempalace", ["daemon", "stop"], 30_000);
|
|
588
|
+
const upgraded = await runProcess("uv", ["tool", "upgrade", "mempalace"], 300_000);
|
|
589
|
+
if (wasRunning) await runProcess("mempalace", ["daemon", "start"], 30_000);
|
|
590
|
+
return upgraded;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export interface MempalaceUpdateOptions {
|
|
594
|
+
/** Skip the TTL gate and force a PyPI lookup. */
|
|
595
|
+
force?: boolean;
|
|
596
|
+
/** Injectable fetch for tests. */
|
|
597
|
+
fetchImpl?: typeof fetch;
|
|
598
|
+
/** Override "now" for TTL math (tests). */
|
|
599
|
+
now?: number;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Keep the user's MemPalace install current.
|
|
604
|
+
*
|
|
605
|
+
* TTL-gated (~daily) PyPI lookup; when a newer release exists and the install
|
|
606
|
+
* came from uv, runs `uv tool upgrade mempalace` in the background. Never
|
|
607
|
+
* throws — callers fire-and-forget this from session_start.
|
|
608
|
+
*/
|
|
609
|
+
export async function maybeAutoUpdateMempalace(
|
|
610
|
+
options: MempalaceUpdateOptions = {},
|
|
611
|
+
): Promise<MempalaceUpdateOutcome> {
|
|
612
|
+
const now = options.now ?? Date.now();
|
|
613
|
+
if (loadEmbeddingConfig().mempalaceAutoUpdate === false) {
|
|
614
|
+
return { checked: false, updated: false, reason: "disabled" };
|
|
615
|
+
}
|
|
616
|
+
const install = readCachedInstall();
|
|
617
|
+
if (!install) return { checked: false, updated: false, reason: "not-installed" };
|
|
618
|
+
|
|
619
|
+
if (!options.force && !isUpdateCheckDue(UPDATE_FLAG, now, UPDATE_CHECK_TTL_MS)) {
|
|
620
|
+
return { checked: false, updated: false, reason: "recent" };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const latest = await fetchLatestMempalaceVersion(options.fetchImpl).catch(() => null);
|
|
624
|
+
const previous = readUpdateState()?.latestVersion ?? "";
|
|
625
|
+
writeUpdateState({ checkedAt: now, latestVersion: latest ?? previous });
|
|
626
|
+
if (!latest) {
|
|
627
|
+
return { checked: true, updated: false, currentVersion: install.version, reason: "lookup-failed" };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const current = detectVersion(install.python);
|
|
631
|
+
if (compareVersions(latest, current) <= 0) {
|
|
632
|
+
return { checked: true, updated: false, currentVersion: current, latestVersion: latest, reason: "up-to-date" };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const upgraded = await upgradeMempalace();
|
|
636
|
+
if (!upgraded) {
|
|
637
|
+
return { checked: true, updated: false, currentVersion: current, latestVersion: latest, reason: "upgrade-failed" };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Refresh the cached install record so the new version is used next bridge call.
|
|
641
|
+
const python = findVenvPython();
|
|
642
|
+
if (python) writeCachedInstall({ python, version: detectVersion(python) });
|
|
643
|
+
invalidatePingVerified();
|
|
644
|
+
return { checked: true, updated: true, currentVersion: current, latestVersion: latest };
|
|
645
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"README.md"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@pi-unipi/core": "2.
|
|
43
|
-
"@pi-unipi/info-screen": "2.
|
|
42
|
+
"@pi-unipi/core": "2.16.0",
|
|
43
|
+
"@pi-unipi/info-screen": "2.16.0",
|
|
44
44
|
"js-yaml": "^4.1.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -26,6 +26,8 @@ export interface EmbeddingConfig {
|
|
|
26
26
|
lastModel?: string;
|
|
27
27
|
/** Whether to show migration warning on startup */
|
|
28
28
|
suppressMigrationWarning?: boolean;
|
|
29
|
+
/** Keep the MemPalace backend current via a daily PyPI check + uv upgrade */
|
|
30
|
+
mempalaceAutoUpdate?: boolean;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** Default configuration */
|
|
@@ -34,6 +36,7 @@ const DEFAULT_CONFIG: EmbeddingConfig = {
|
|
|
34
36
|
model: "openai/text-embedding-3-small",
|
|
35
37
|
dimensions: 384,
|
|
36
38
|
suppressMigrationWarning: false,
|
|
39
|
+
mempalaceAutoUpdate: true,
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
/** Known embedding models on OpenRouter */
|
|
@@ -83,6 +83,13 @@ export async function showMemorySettings(ctx: ExtensionCommandContext): Promise<
|
|
|
83
83
|
description: "Embedding dimensions (lower = faster, less storage)",
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
// MemPalace auto-update
|
|
87
|
+
options.push({
|
|
88
|
+
label: `⬆️ MemPalace Auto-Update: ${config.mempalaceAutoUpdate === false ? "Off" : "On"}`,
|
|
89
|
+
value: "__toggle_autoupdate__",
|
|
90
|
+
description: "Daily PyPI check; upgrades the backend via uv when a newer MemPalace ships",
|
|
91
|
+
});
|
|
92
|
+
|
|
86
93
|
// Re-embed
|
|
87
94
|
if (ready && hasModelChanged()) {
|
|
88
95
|
options.push({
|
|
@@ -145,6 +152,13 @@ export async function showMemorySettings(ctx: ExtensionCommandContext): Promise<
|
|
|
145
152
|
saveEmbeddingConfig(cfg);
|
|
146
153
|
ui.notify("Migration warning suppressed.", "info");
|
|
147
154
|
break;
|
|
155
|
+
case "__toggle_autoupdate__": {
|
|
156
|
+
const autoCfg = loadEmbeddingConfig();
|
|
157
|
+
autoCfg.mempalaceAutoUpdate = autoCfg.mempalaceAutoUpdate === false;
|
|
158
|
+
saveEmbeddingConfig(autoCfg);
|
|
159
|
+
ui.notify(`MemPalace auto-update ${autoCfg.mempalaceAutoUpdate ? "enabled" : "disabled"}.`, "info");
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
148
162
|
}
|
|
149
163
|
}
|
|
150
164
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/milestone",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "2.
|
|
35
|
+
"@pi-unipi/core": "2.16.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -61,6 +61,22 @@ Desktop notifications via [node-notifier](https://github.com/mikaelbr/node-notif
|
|
|
61
61
|
|
|
62
62
|
Zero configuration — works out of the box. Set `native.suppressWhenFocused` to `true` to skip native notifications when the active/focused window is already Pi.
|
|
63
63
|
|
|
64
|
+
### Silence after input
|
|
65
|
+
|
|
66
|
+
After a terminal keypress, listed platforms stay quiet for `windowMs`. Default: **off**, native only, 10s. Edit in `/unipi:notify-settings` → Platforms (Quiet after activity + channel chips), or in `~/.unipi/config/notify/config.json`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"silenceAfterInput": {
|
|
71
|
+
"enabled": true,
|
|
72
|
+
"windowMs": 10000,
|
|
73
|
+
"platforms": ["native"]
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Add `gotify`, `telegram`, or `ntfy` to `platforms` to silence those channels too. Empty `platforms` silences all enabled platforms (same as `events.*.platforms`).
|
|
79
|
+
|
|
64
80
|
### Gotify
|
|
65
81
|
|
|
66
82
|
Self-hosted push notification server:
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Recent terminal-input activity
|
|
3
|
+
*
|
|
4
|
+
* Tracks the last interactive keypress so dispatch can silence selected
|
|
5
|
+
* platforms while the user is at the keyboard.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
NotifyConfig,
|
|
10
|
+
NotifyPlatform,
|
|
11
|
+
SilenceAfterInputConfig,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
/** Known platform names — unknown strings are dropped when merging config. */
|
|
15
|
+
const VALID_PLATFORMS: ReadonlySet<NotifyPlatform> = new Set([
|
|
16
|
+
"native",
|
|
17
|
+
"gotify",
|
|
18
|
+
"telegram",
|
|
19
|
+
"ntfy",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
let lastInputAt = 0;
|
|
23
|
+
|
|
24
|
+
/** Record a terminal keypress. `at` is injectable for tests. */
|
|
25
|
+
export function noteInput(at: number = Date.now()): void {
|
|
26
|
+
lastInputAt = at;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Clear activity (session start/shutdown). */
|
|
30
|
+
export function resetInputActivity(): void {
|
|
31
|
+
lastInputAt = 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Split already-enabled platforms into "send" vs "silenced by recent input".
|
|
36
|
+
* Does not look at platform `enabled` flags — caller filters those first.
|
|
37
|
+
* Empty `platforms` (while enabled) silences all incoming channels, matching
|
|
38
|
+
* `events.*.platforms: []` → all enabled.
|
|
39
|
+
*/
|
|
40
|
+
export function filterPlatformsAfterInput(
|
|
41
|
+
platforms: NotifyPlatform[],
|
|
42
|
+
config: Pick<NotifyConfig, "silenceAfterInput">,
|
|
43
|
+
now: number = Date.now(),
|
|
44
|
+
): { send: NotifyPlatform[]; silenced: NotifyPlatform[] } {
|
|
45
|
+
const cfg = config.silenceAfterInput;
|
|
46
|
+
if (!shouldSilence(cfg, now)) {
|
|
47
|
+
return { send: platforms.slice(), silenced: [] };
|
|
48
|
+
}
|
|
49
|
+
if (cfg.platforms.length === 0) {
|
|
50
|
+
return { send: [], silenced: platforms.slice() };
|
|
51
|
+
}
|
|
52
|
+
const silent = new Set(cfg.platforms);
|
|
53
|
+
const send: NotifyPlatform[] = [];
|
|
54
|
+
const silenced: NotifyPlatform[] = [];
|
|
55
|
+
for (const platform of platforms) {
|
|
56
|
+
if (silent.has(platform)) silenced.push(platform);
|
|
57
|
+
else send.push(platform);
|
|
58
|
+
}
|
|
59
|
+
return { send, silenced };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Normalize a partial config blob against defaults. */
|
|
63
|
+
export function mergeSilenceAfterInput(
|
|
64
|
+
loaded: Partial<SilenceAfterInputConfig> | undefined,
|
|
65
|
+
defaults: SilenceAfterInputConfig,
|
|
66
|
+
): SilenceAfterInputConfig {
|
|
67
|
+
if (!loaded) {
|
|
68
|
+
return {
|
|
69
|
+
enabled: defaults.enabled,
|
|
70
|
+
windowMs: defaults.windowMs,
|
|
71
|
+
platforms: defaults.platforms.slice(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const platforms = Array.isArray(loaded.platforms)
|
|
75
|
+
? loaded.platforms.filter((p): p is NotifyPlatform =>
|
|
76
|
+
VALID_PLATFORMS.has(p as NotifyPlatform),
|
|
77
|
+
)
|
|
78
|
+
: defaults.platforms.slice();
|
|
79
|
+
const windowMs =
|
|
80
|
+
typeof loaded.windowMs === "number" &&
|
|
81
|
+
Number.isFinite(loaded.windowMs) &&
|
|
82
|
+
loaded.windowMs >= 0
|
|
83
|
+
? loaded.windowMs
|
|
84
|
+
: defaults.windowMs;
|
|
85
|
+
return {
|
|
86
|
+
enabled: loaded.enabled ?? defaults.enabled,
|
|
87
|
+
windowMs,
|
|
88
|
+
platforms,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function shouldSilence(
|
|
93
|
+
cfg: SilenceAfterInputConfig | undefined,
|
|
94
|
+
now: number,
|
|
95
|
+
): cfg is SilenceAfterInputConfig {
|
|
96
|
+
if (!cfg?.enabled) return false;
|
|
97
|
+
if (lastInputAt <= 0) return false;
|
|
98
|
+
if (now - lastInputAt >= cfg.windowMs) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
@@ -16,6 +16,7 @@ import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
|
16
16
|
import { buildAskUserPromptMessage } from "./ask-user-prompt-message.js";
|
|
17
17
|
import { buildPermissionPromptMessage } from "./permission-prompt-message.js";
|
|
18
18
|
import { summarizeLastMessage } from "./summarize.js";
|
|
19
|
+
import { filterPlatformsAfterInput } from "./activity.js";
|
|
19
20
|
|
|
20
21
|
// Event emitted by @juicesharp/rpiv-ask-user-question before showing its UI.
|
|
21
22
|
// Keep this as a local string until that package publishes an importable
|
|
@@ -182,8 +183,11 @@ export async function dispatchNotification(
|
|
|
182
183
|
return false;
|
|
183
184
|
});
|
|
184
185
|
|
|
186
|
+
const { send: platformsToSend, silenced: inputSilenced } =
|
|
187
|
+
filterPlatformsAfterInput(enabledPlatforms, config);
|
|
188
|
+
|
|
185
189
|
const results = await Promise.all(
|
|
186
|
-
|
|
190
|
+
platformsToSend.map(async (platform) => {
|
|
187
191
|
try {
|
|
188
192
|
const effectivePriority = await sendToPlatform(platform, title, message, config, cwd, priority);
|
|
189
193
|
return { platform, success: true, ...(effectivePriority === undefined ? {} : { priority: effectivePriority }) };
|
|
@@ -202,6 +206,10 @@ export async function dispatchNotification(
|
|
|
202
206
|
})
|
|
203
207
|
);
|
|
204
208
|
|
|
209
|
+
for (const platform of inputSilenced) {
|
|
210
|
+
results.push({ platform, success: true, suppressed: true });
|
|
211
|
+
}
|
|
212
|
+
|
|
205
213
|
const unsuppressed = results.filter((r) => !r.suppressed);
|
|
206
214
|
const allSuccess = results.length > 0 && unsuppressed.every((r) => r.success);
|
|
207
215
|
const suppressedPlatforms = results
|
package/packages/notify/index.ts
CHANGED
|
@@ -24,10 +24,14 @@ import {
|
|
|
24
24
|
setSessionContext,
|
|
25
25
|
clearSessionContext,
|
|
26
26
|
} from "./events.js";
|
|
27
|
+
import { noteInput, resetInputActivity } from "./activity.js";
|
|
27
28
|
|
|
28
29
|
/** Package version */
|
|
29
30
|
const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
|
|
30
31
|
|
|
32
|
+
/** Unsubscribe for the interactive-mode keypress listener. */
|
|
33
|
+
let unsubTerminalInput: (() => void) | undefined;
|
|
34
|
+
|
|
31
35
|
export default function (pi: ExtensionAPI) {
|
|
32
36
|
|
|
33
37
|
// Register tools and commands
|
|
@@ -37,6 +41,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
37
41
|
// Session lifecycle — register events and announce module
|
|
38
42
|
pi.on("session_start", async (_event, ctx) => {
|
|
39
43
|
setSessionContext(ctx);
|
|
44
|
+
resetInputActivity();
|
|
45
|
+
unsubTerminalInput?.();
|
|
46
|
+
unsubTerminalInput = undefined;
|
|
47
|
+
const onTerminalInput = ctx.ui?.onTerminalInput;
|
|
48
|
+
if (typeof onTerminalInput === "function") {
|
|
49
|
+
try {
|
|
50
|
+
unsubTerminalInput = onTerminalInput(() => {
|
|
51
|
+
noteInput();
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
unsubTerminalInput = undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
40
57
|
const cwd = process.cwd();
|
|
41
58
|
const config = loadConfig();
|
|
42
59
|
registerEventListeners(pi, config, cwd);
|
|
@@ -51,6 +68,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
68
|
|
|
52
69
|
// Cleanup on session shutdown
|
|
53
70
|
pi.on("session_shutdown", async () => {
|
|
71
|
+
unsubTerminalInput?.();
|
|
72
|
+
unsubTerminalInput = undefined;
|
|
73
|
+
resetInputActivity();
|
|
54
74
|
clearSessionContext();
|
|
55
75
|
unregisterEventListeners();
|
|
56
76
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/notify",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@pi-unipi/core": "2.
|
|
37
|
+
"@pi-unipi/core": "2.16.0",
|
|
38
38
|
"node-notifier": "^10.0.1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
@@ -8,6 +8,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
|
8
8
|
import { dirname, join } from "path";
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { NOTIFY_DIRS } from "@pi-unipi/core";
|
|
11
|
+
import { mergeSilenceAfterInput } from "./activity.js";
|
|
11
12
|
import type { NotifyConfig } from "./types.js";
|
|
12
13
|
|
|
13
14
|
/** Resolve config path (expands ~ to homedir) */
|
|
@@ -45,6 +46,11 @@ export const DEFAULT_CONFIG: NotifyConfig = {
|
|
|
45
46
|
enabled: false,
|
|
46
47
|
model: "openrouter/openai/gpt-oss-20b",
|
|
47
48
|
},
|
|
49
|
+
silenceAfterInput: {
|
|
50
|
+
enabled: false,
|
|
51
|
+
windowMs: 10000,
|
|
52
|
+
platforms: ["native"],
|
|
53
|
+
},
|
|
48
54
|
};
|
|
49
55
|
|
|
50
56
|
/** Load config from disk, returning defaults if missing or invalid */
|
|
@@ -60,7 +66,10 @@ export function loadConfig(): NotifyConfig {
|
|
|
60
66
|
} catch (_err) {
|
|
61
67
|
// Config load failure — using defaults silently.
|
|
62
68
|
}
|
|
63
|
-
|
|
69
|
+
// Deep copy: callers (e.g. the settings overlay) mutate the returned config.
|
|
70
|
+
// A shallow copy would share nested objects with DEFAULT_CONFIG and leak
|
|
71
|
+
// mutations into later loadConfig() calls (even after Esc/cancel).
|
|
72
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** Save config to disk, creating directory if needed */
|
|
@@ -110,12 +119,17 @@ export function validateConfig(config: NotifyConfig): string[] {
|
|
|
110
119
|
|
|
111
120
|
/** Merge loaded config with defaults to ensure all fields exist */
|
|
112
121
|
function mergeWithDefaults(loaded: Partial<NotifyConfig>): NotifyConfig {
|
|
122
|
+
const base = structuredClone(DEFAULT_CONFIG);
|
|
113
123
|
return {
|
|
114
|
-
defaultPlatforms: loaded.defaultPlatforms ??
|
|
115
|
-
events: { ...
|
|
116
|
-
native: { ...
|
|
117
|
-
gotify: { ...
|
|
118
|
-
telegram: { ...
|
|
119
|
-
recap: { ...
|
|
124
|
+
defaultPlatforms: loaded.defaultPlatforms ?? base.defaultPlatforms,
|
|
125
|
+
events: { ...base.events, ...loaded.events },
|
|
126
|
+
native: { ...base.native, ...loaded.native },
|
|
127
|
+
gotify: { ...base.gotify, ...loaded.gotify },
|
|
128
|
+
telegram: { ...base.telegram, ...loaded.telegram },
|
|
129
|
+
recap: { ...base.recap, ...loaded.recap },
|
|
130
|
+
silenceAfterInput: mergeSilenceAfterInput(
|
|
131
|
+
loaded.silenceAfterInput,
|
|
132
|
+
base.silenceAfterInput,
|
|
133
|
+
),
|
|
120
134
|
};
|
|
121
135
|
}
|
|
@@ -62,6 +62,11 @@ Help users configure the `@pi-unipi/notify` notification system.
|
|
|
62
62
|
"token": null,
|
|
63
63
|
"priority": 3
|
|
64
64
|
},
|
|
65
|
+
"silenceAfterInput": {
|
|
66
|
+
"enabled": false,
|
|
67
|
+
"windowMs": 10000,
|
|
68
|
+
"platforms": ["native"]
|
|
69
|
+
},
|
|
65
70
|
"NOTE": "ntfy section is legacy — migrated to ntfy.json on first run"
|
|
66
71
|
}
|
|
67
72
|
```
|
|
@@ -72,6 +77,24 @@ Help users configure the `@pi-unipi/notify` notification system.
|
|
|
72
77
|
|
|
73
78
|
Desktop notifications via node-notifier. Works out of the box on Windows, macOS, Linux.
|
|
74
79
|
|
|
80
|
+
### Silence after input
|
|
81
|
+
|
|
82
|
+
Quiet listed platforms for `windowMs` after any terminal keypress. **Default: off.** Same `config.json` as other notify settings.
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
"silenceAfterInput": {
|
|
86
|
+
"enabled": true,
|
|
87
|
+
"windowMs": 10000,
|
|
88
|
+
"platforms": ["native"]
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `enabled` — master switch
|
|
93
|
+
- `windowMs` — quiet window in milliseconds (default: 10000)
|
|
94
|
+
- `platforms` — channels to silence (`native`, `gotify`, `telegram`, `ntfy`). Empty list silences all enabled platforms (same as `events.*.platforms`).
|
|
95
|
+
|
|
96
|
+
TUI: `/unipi:notify-settings` → Platforms → Quiet after activity (Space), ←→ then Space for channels, +/− for the window (1s steps).
|
|
97
|
+
|
|
75
98
|
### Gotify (default: disabled)
|
|
76
99
|
|
|
77
100
|
Self-hosted push notification server. Requires:
|