@pi-unipi/memory 2.14.1 → 2.15.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/index.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  import { registerMemoryTools, MEMORY_TOOLS, GLOBAL_SEARCH_ALIAS } from "./tools.js";
32
32
  import { registerMemoryCommands } from "./commands.js";
33
33
  import { isEmbeddingReady, hasModelChanged } from "./settings.js";
34
+ import { maybeAutoUpdateMempalace } from "./mempalace.js";
34
35
 
35
36
  /** Package version */
36
37
  const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
@@ -157,6 +158,22 @@ export default function (pi: ExtensionAPI) {
157
158
  projectStorage = null;
158
159
  }
159
160
 
161
+ // Keep the MemPalace backend current — TTL-gated (~daily) background
162
+ // check; upgrades via uv only when a newer release exists. Never blocks
163
+ // startup and never throws into the session path.
164
+ if (projectStorage?.isMempalace()) {
165
+ void maybeAutoUpdateMempalace()
166
+ .then((outcome) => {
167
+ if (outcome.updated) {
168
+ emitEvent(pi, UNIPI_EVENTS.UPDATE_APPLIED, {
169
+ previousVersion: outcome.currentVersion ?? "",
170
+ newVersion: outcome.latestVersion ?? "",
171
+ });
172
+ }
173
+ })
174
+ .catch(() => {});
175
+ }
176
+
160
177
 
161
178
  // Announce module
162
179
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
package/mempalace.ts CHANGED
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.14.1",
3
+ "version": "2.15.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.14.1",
43
- "@pi-unipi/info-screen": "2.14.1",
42
+ "@pi-unipi/core": "2.15.0",
43
+ "@pi-unipi/info-screen": "2.15.0",
44
44
  "js-yaml": "^4.1.0"
45
45
  },
46
46
  "peerDependencies": {
package/settings.ts CHANGED
@@ -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
  }