@pi-unipi/memory 2.2.1 → 2.3.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
@@ -25,7 +25,8 @@ import {
25
25
  MemoryStorage,
26
26
  getProjectName,
27
27
  searchAllProjects,
28
- listAllProjects,
28
+ listAllProjectsCachedAsync,
29
+ invalidateAllProjectsCache,
29
30
  } from "./storage.js";
30
31
  import { registerMemoryTools, MEMORY_TOOLS, GLOBAL_SEARCH_ALIAS } from "./tools.js";
31
32
  import { registerMemoryCommands } from "./commands.js";
@@ -37,6 +38,26 @@ const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
37
38
  /** Storage instance for current project */
38
39
  let projectStorage: MemoryStorage | null = null;
39
40
 
41
+ /**
42
+ * Whether orphaned-file sync still owes this session a run.
43
+ *
44
+ * The sync spawns a Python bridge (~0.5s). Running it in session_start delayed
45
+ * every startup for a job that only matters once memory is actually used, so it
46
+ * is deferred to the first storage access instead.
47
+ */
48
+ let orphanSyncPending = false;
49
+
50
+ /** Run the deferred orphaned-file sync exactly once per session. */
51
+ function ensureOrphanSync(storage: MemoryStorage): void {
52
+ if (!orphanSyncPending) return;
53
+ orphanSyncPending = false;
54
+ try {
55
+ storage.syncOrphanedFiles();
56
+ } catch {
57
+ // Sync failure must not break the tool call that triggered it.
58
+ }
59
+ }
60
+
40
61
  /**
41
62
  * Get storage for the current project.
42
63
  */
@@ -45,6 +66,8 @@ function getStorage(): MemoryStorage {
45
66
  // Fallback: create new instance (shouldn't happen after session_start)
46
67
  return new MemoryStorage("unknown");
47
68
  }
69
+ // Any real use of memory picks up markdown files added out of band.
70
+ ensureOrphanSync(projectStorage);
48
71
  return projectStorage;
49
72
  }
50
73
 
@@ -56,7 +79,12 @@ export default function (pi: ExtensionAPI) {
56
79
  // Register tools and commands
57
80
  registerMemoryTools(pi, getStorage, {
58
81
  onRecall: () => { recallDone = true; },
59
- onStore: () => { storeDone = true; },
82
+ onStore: () => {
83
+ storeDone = true;
84
+ // Fires on store and delete; drop the cached cross-project counts so
85
+ // the info overlay reflects the write.
86
+ invalidateAllProjectsCache();
87
+ },
60
88
  });
61
89
  registerMemoryCommands(pi, getStorage);
62
90
 
@@ -71,11 +99,11 @@ export default function (pi: ExtensionAPI) {
71
99
  projectStorage = new MemoryStorage(projectName);
72
100
  try {
73
101
  projectStorage.init();
74
-
75
- // Sync any orphaned markdown files into the database
76
- const synced = projectStorage.syncOrphanedFiles();
77
- // Removed console.warn orphaned file sync is informational only.
78
- // Visible via memory tool list or info-screen memory group.
102
+
103
+ // Orphaned markdown files are synced lazily on first storage access
104
+ // (see ensureOrphanSync) — the Python bridge spawn is too slow to run
105
+ // on the startup path, and nothing reads the result until memory is used.
106
+ orphanSyncPending = true;
79
107
  } catch (_err) {
80
108
  // Memory init failure — running without memory. Silent startup.
81
109
  projectStorage = null;
@@ -135,8 +163,10 @@ export default function (pi: ExtensionAPI) {
135
163
  let projectMemories: Array<{ id: string; title: string; type: string }> = [];
136
164
  let allMemories: Array<{ project: string; id: string; title: string; type: string }> = [];
137
165
  try {
138
- projectMemories = projectStorage.listAll();
139
- allMemories = listAllProjects();
166
+ // Async twins: the MemPalace backend spawns Python, which would
167
+ // otherwise block the UI while the overlay is open.
168
+ projectMemories = await projectStorage.listAllAsync();
169
+ allMemories = await listAllProjectsCachedAsync();
140
170
  } catch (_err) {
141
171
  // Info panel data unavailable — shows empty values.
142
172
  }
@@ -162,23 +192,31 @@ export default function (pi: ExtensionAPI) {
162
192
  });
163
193
  }
164
194
 
165
- // Show memory status in UI
195
+ // Show memory status in UI.
196
+ //
197
+ // Both counts come from the Python MemPalace bridge (~1.5s combined) and
198
+ // only produce a status-bar string, so they are resolved after startup and
199
+ // the status is filled in when they land. Blocking session_start on them
200
+ // delayed the whole extension chain.
166
201
  if (ctx.hasUI) {
167
- let projectCount = 0;
168
- let projectCountAll = 0;
169
- try {
170
- projectCount = projectStorage?.listAll()?.length ?? 0;
171
- projectCountAll = listAllProjects().length;
172
- } catch (_err) {
173
- // Count unavailable — status bar shows 0.
174
- }
175
202
  const mempalaceActive = projectStorage?.isMempalace() ?? false;
176
203
  const backendIcon = mempalaceActive ? "🧠" : (isEmbeddingReady() ? "⚡" : "📝");
177
204
  const warn = hasModelChanged() ? " ⚠" : "";
178
- ctx.ui.setStatus(
179
- "unipi-memory",
180
- `${backendIcon} mem ${projectCount}p/${projectCountAll}all${warn}`
181
- );
205
+ const setStatus = (counts: string) =>
206
+ ctx.ui.setStatus("unipi-memory", `${backendIcon} mem ${counts}${warn}`);
207
+
208
+ setStatus("…");
209
+ void (async () => {
210
+ let projectCount = 0;
211
+ let projectCountAll = 0;
212
+ try {
213
+ projectCount = (await projectStorage?.listAllAsync())?.length ?? 0;
214
+ projectCountAll = (await listAllProjectsCachedAsync()).length;
215
+ } catch (_err) {
216
+ // Count unavailable — status bar shows 0.
217
+ }
218
+ setStatus(`${projectCount}p/${projectCountAll}all`);
219
+ })();
182
220
  }
183
221
  });
184
222
 
package/mempalace.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * hard-fail because the backend is missing.
12
12
  */
13
13
 
14
- import { spawnSync } from "node:child_process";
14
+ import { spawnSync, spawn } from "node:child_process";
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import * as os from "node:os";
@@ -254,6 +254,71 @@ export function runBridge<T = unknown>(
254
254
  }
255
255
  }
256
256
 
257
+ /**
258
+ * Async variant of runBridge that does not block the event loop.
259
+ *
260
+ * spawnSync freezes the process for the whole Python round-trip (~0.5-1.1s).
261
+ * Use this from any path that runs while the UI is live — startup status,
262
+ * background refreshes — so keystrokes stay responsive.
263
+ */
264
+ export function runBridgeAsync<T = unknown>(
265
+ install: MempalaceInstall,
266
+ palace: string,
267
+ cmd: string,
268
+ args: Record<string, unknown> = {},
269
+ ): Promise<T | null> {
270
+ return new Promise((resolve) => {
271
+ let argsJson: string;
272
+ try {
273
+ argsJson = JSON.stringify(args);
274
+ } catch {
275
+ resolve(null);
276
+ return;
277
+ }
278
+
279
+ let child;
280
+ try {
281
+ child = spawn(install.python, [BRIDGE_PATH, palace, cmd, argsJson], {
282
+ stdio: ["ignore", "pipe", "ignore"],
283
+ });
284
+ } catch {
285
+ resolve(null);
286
+ return;
287
+ }
288
+
289
+ let out = "";
290
+ let settled = false;
291
+ const finish = (value: T | null) => {
292
+ if (settled) return;
293
+ settled = true;
294
+ clearTimeout(timer);
295
+ resolve(value);
296
+ };
297
+
298
+ const timer = setTimeout(() => {
299
+ try { child.kill(); } catch { /* already gone */ }
300
+ finish(null);
301
+ }, 60_000);
302
+ // Do not hold the process open purely for a background bridge call.
303
+ timer.unref?.();
304
+
305
+ child.stdout?.setEncoding("utf-8");
306
+ child.stdout?.on("data", (chunk) => { out += chunk; });
307
+ child.on("error", () => finish(null));
308
+ child.on("close", (code) => {
309
+ if (code !== 0) return finish(null);
310
+ const trimmed = out.trim();
311
+ if (!trimmed) return finish(null);
312
+ try {
313
+ const parsed = JSON.parse(trimmed) as BridgeResponse<T>;
314
+ finish(parsed.ok ? ((parsed.result ?? null) as T | null) : null);
315
+ } catch {
316
+ finish(null);
317
+ }
318
+ });
319
+ });
320
+ }
321
+
257
322
  /** Ping the bridge — returns true if the backend is alive. */
258
323
  export function ping(install: MempalaceInstall, palace: string): boolean {
259
324
  return runBridge<string>(install, palace, "ping") === "pong";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.2.1",
3
+ "version": "2.3.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",
@@ -43,8 +43,8 @@
43
43
  "better-sqlite3": "^12.9.0",
44
44
  "sqlite-vec": "^0.1.9",
45
45
  "js-yaml": "^4.1.0",
46
- "@pi-unipi/core": "2.2.0",
47
- "@pi-unipi/info-screen": "2.2.1"
46
+ "@pi-unipi/core": "2.3.0",
47
+ "@pi-unipi/info-screen": "2.3.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "^0.80.0",
package/storage.ts CHANGED
@@ -17,6 +17,7 @@ import { randomUUID } from "node:crypto";
17
17
  import {
18
18
  ensureMempalace,
19
19
  runBridge,
20
+ runBridgeAsync,
20
21
  isMigrated,
21
22
  markMigrated,
22
23
  isPingVerified,
@@ -281,6 +282,32 @@ export class MemoryStorage {
281
282
  return result;
282
283
  }
283
284
 
285
+ /** Async twin of memPalaceCall, for paths that must not block the UI. */
286
+ private async memPalaceCallAsync<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T | null> {
287
+ const install = this.mempalaceInstall;
288
+ if (!install) return null;
289
+ const result = await runBridgeAsync<T>(install, this.palacePath, cmd, args);
290
+ if (result === null) {
291
+ invalidatePingVerified();
292
+ }
293
+ return result;
294
+ }
295
+
296
+ /**
297
+ * Async twin of listAll().
298
+ *
299
+ * The SQLite path is already fast and stays synchronous; only the MemPalace
300
+ * path (a Python spawn) actually needs to yield.
301
+ */
302
+ async listAllAsync(): Promise<Array<{ id: string; title: string; type: string }>> {
303
+ if (this.isMempalace()) {
304
+ return (await this.memPalaceCallAsync<MempalaceListItem[]>("list", {
305
+ wing: this.projectName,
306
+ })) ?? [];
307
+ }
308
+ return this.listAll();
309
+ }
310
+
284
311
  /**
285
312
  * Initialize storage. Tries MemPalace first (auto-install + one-way
286
313
  * auto-migration of legacy memories); falls back to SQLite if MemPalace
@@ -1043,16 +1070,57 @@ export function searchAllProjects(
1043
1070
  .slice(0, limit);
1044
1071
  }
1045
1072
 
1073
+ /** Result shape shared by listAllProjects and its cached wrapper. */
1074
+ type AllProjectsEntry = { project: string; id: string; title: string; type: string };
1075
+
1076
+ /**
1077
+ * Cached view of listAllProjects().
1078
+ *
1079
+ * The uncached call spawns a Python MemPalace bridge (~1.1s). It backs two
1080
+ * display-only counters in the info overlay, so a slightly stale number is
1081
+ * strictly better than a 1.1s stall on every startup.
1082
+ */
1083
+ let allProjectsCache: { at: number; value: AllProjectsEntry[] } | null = null;
1084
+
1085
+ /** How long a cached cross-project listing stays valid. */
1086
+ const ALL_PROJECTS_TTL_MS = 60_000;
1087
+
1088
+ /** Drop the cached cross-project listing (call after storing/deleting). */
1089
+ export function invalidateAllProjectsCache(): void {
1090
+ allProjectsCache = null;
1091
+ }
1092
+
1093
+ /**
1094
+ * Async twin of listAllProjectsCached(), for UI paths.
1095
+ *
1096
+ * On a cache miss the MemPalace path spawns Python; doing that synchronously
1097
+ * froze the UI for ~1.1s. Only the bridge call is async — the SQLite fallback
1098
+ * is fast enough to run inline.
1099
+ */
1100
+ export async function listAllProjectsCachedAsync(): Promise<AllProjectsEntry[]> {
1101
+ const now = Date.now();
1102
+ if (allProjectsCache && now - allProjectsCache.at < ALL_PROJECTS_TTL_MS) {
1103
+ return allProjectsCache.value;
1104
+ }
1105
+
1106
+ const install = ensureMempalace();
1107
+ let value: AllProjectsEntry[];
1108
+ if (install) {
1109
+ const items = (await runBridgeAsync<MempalaceListItemAll[]>(install, DEFAULT_PALACE, "list_all", {})) ?? [];
1110
+ value = items.map((m) => ({ project: m.project, id: m.id, title: m.title, type: m.type }));
1111
+ } else {
1112
+ value = listAllProjects();
1113
+ }
1114
+
1115
+ allProjectsCache = { at: now, value };
1116
+ return value;
1117
+ }
1118
+
1046
1119
  /**
1047
1120
  * List memories from ALL projects.
1048
1121
  * Returns memories with project name prefix.
1049
1122
  */
1050
- export function listAllProjects(): Array<{
1051
- project: string;
1052
- id: string;
1053
- title: string;
1054
- type: string;
1055
- }> {
1123
+ export function listAllProjects(): AllProjectsEntry[] {
1056
1124
  // MemPalace global path: list all drawers across wings.
1057
1125
  const install = ensureMempalace();
1058
1126
  if (install) {
@@ -1067,12 +1135,7 @@ export function listAllProjects(): Array<{
1067
1135
 
1068
1136
  // SQLite fallback: iterate project directories.
1069
1137
  const projectDirs = getAllProjectDirs();
1070
- const allMemories: Array<{
1071
- project: string;
1072
- id: string;
1073
- title: string;
1074
- type: string;
1075
- }> = [];
1138
+ const allMemories: AllProjectsEntry[] = [];
1076
1139
 
1077
1140
  for (const { name: projectName, dir } of projectDirs) {
1078
1141
  const dbPath = path.join(dir, MEMORY_DB_NAME);