nlm-memory 0.5.17 → 0.5.19

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 (74) hide show
  1. package/README.md +22 -1
  2. package/dist/cli/nlm.js +161 -81
  3. package/dist/cli/nlm.js.map +1 -1
  4. package/dist/cli/supersede.js +5 -2
  5. package/dist/cli/supersede.js.map +1 -1
  6. package/dist/core/actions/actions-log.d.ts +9 -0
  7. package/dist/core/actions/actions-log.js +86 -0
  8. package/dist/core/actions/actions-log.js.map +1 -1
  9. package/dist/core/digest/compose.d.ts +0 -1
  10. package/dist/core/digest/compose.js +0 -4
  11. package/dist/core/digest/compose.js.map +1 -1
  12. package/dist/core/providers/provider-registry.d.ts +15 -0
  13. package/dist/core/providers/provider-registry.js +85 -0
  14. package/dist/core/providers/provider-registry.js.map +1 -1
  15. package/dist/core/recall/query-log.d.ts +1 -2
  16. package/dist/core/recall/query-log.js +1 -5
  17. package/dist/core/recall/query-log.js.map +1 -1
  18. package/dist/core/scheduler/scan-once.d.ts +4 -0
  19. package/dist/core/scheduler/scan-once.js +64 -0
  20. package/dist/core/scheduler/scan-once.js.map +1 -1
  21. package/dist/core/scheduler/scheduler.js +25 -9
  22. package/dist/core/scheduler/scheduler.js.map +1 -1
  23. package/dist/core/sources/source-registry.d.ts +16 -0
  24. package/dist/core/sources/source-registry.js +123 -0
  25. package/dist/core/sources/source-registry.js.map +1 -1
  26. package/dist/core/storage/pg-fact-store.d.ts +24 -0
  27. package/dist/core/storage/pg-fact-store.js +233 -0
  28. package/dist/core/storage/pg-fact-store.js.map +1 -0
  29. package/dist/core/storage/pg-session-store.d.ts +29 -0
  30. package/dist/core/storage/pg-session-store.js +315 -0
  31. package/dist/core/storage/pg-session-store.js.map +1 -0
  32. package/dist/core/storage/pg-storage.d.ts +36 -0
  33. package/dist/core/storage/pg-storage.js +87 -0
  34. package/dist/core/storage/pg-storage.js.map +1 -0
  35. package/dist/core/storage/pg-tx-context.d.ts +44 -0
  36. package/dist/core/storage/pg-tx-context.js +133 -0
  37. package/dist/core/storage/pg-tx-context.js.map +1 -0
  38. package/dist/core/storage/sqlite-fact-store.d.ts +13 -7
  39. package/dist/core/storage/sqlite-fact-store.js +38 -12
  40. package/dist/core/storage/sqlite-fact-store.js.map +1 -1
  41. package/dist/core/storage/sqlite-session-store.d.ts +6 -16
  42. package/dist/core/storage/sqlite-session-store.js +51 -42
  43. package/dist/core/storage/sqlite-session-store.js.map +1 -1
  44. package/dist/core/storage/sqlite-storage.d.ts +38 -0
  45. package/dist/core/storage/sqlite-storage.js +66 -0
  46. package/dist/core/storage/sqlite-storage.js.map +1 -0
  47. package/dist/hook/pi-extension.d.ts +43 -0
  48. package/dist/hook/pi-extension.js +50 -0
  49. package/dist/hook/pi-extension.js.map +1 -0
  50. package/dist/hook/prompt-recall-hook.js +1 -36
  51. package/dist/hook/prompt-recall-hook.js.map +1 -1
  52. package/dist/hook/recall-over-http.d.ts +10 -0
  53. package/dist/hook/recall-over-http.js +41 -0
  54. package/dist/hook/recall-over-http.js.map +1 -0
  55. package/dist/hook/stop-hook.d.ts +1 -3
  56. package/dist/hook/stop-hook.js +1 -3
  57. package/dist/hook/stop-hook.js.map +1 -1
  58. package/dist/http/app.d.ts +6 -5
  59. package/dist/http/app.js +15 -4
  60. package/dist/http/app.js.map +1 -1
  61. package/dist/install/pi.d.ts +42 -0
  62. package/dist/install/pi.js +84 -0
  63. package/dist/install/pi.js.map +1 -0
  64. package/dist/install/setup.js +21 -5
  65. package/dist/install/setup.js.map +1 -1
  66. package/dist/ports/fact-store.d.ts +26 -0
  67. package/dist/ports/storage.d.ts +45 -0
  68. package/dist/ports/storage.js +14 -0
  69. package/dist/ports/storage.js.map +1 -0
  70. package/package.json +5 -1
  71. package/plugin/scripts/prompt-recall-hook.mjs +31 -29
  72. package/plugin-pi/README.md +71 -0
  73. package/plugin-pi/package.json +12 -0
  74. package/plugin-pi/scripts/nlm-extension.mjs +280 -0
package/README.md CHANGED
@@ -85,7 +85,7 @@ One corpus across every adapter. `nlm connect` wires hooks + MCP for each runtim
85
85
  | **Windsurf** | `nlm connect windsurf` | Windsurf user dir | MCP only |
86
86
  | **OpenCode** | adapter active | `~/.local/share/opencode/` | MCP only |
87
87
  | **Aider** | adapter active | `AIDER_CHAT_HISTORY_FILE` | MCP only |
88
- | **pi.dev** | adapter active | `~/.pi/sessions/` | MCP only |
88
+ | **pi.dev** | `nlm setup` (auto) or `nlm connect pi` | `~/.pi/agent/sessions/**/*.jsonl` | input (prompt-recall) |
89
89
 
90
90
  `nlm disconnect <runtime>` reverses any of the above.
91
91
 
@@ -265,6 +265,27 @@ No telemetry. No vendor pings. No account.
265
265
 
266
266
  Report vulnerabilities via [SECURITY.md](SECURITY.md).
267
267
 
268
+ ### Remote access
269
+
270
+ The daemon binds to `127.0.0.1`. If you want to reach the UI from another device — phone, second laptop — don't change the bind. Put a tunnel in front instead.
271
+
272
+ **Tailscale (recommended for personal use).** Run once on the daemon host:
273
+
274
+ ```sh
275
+ tailscale serve --bg http://localhost:3940
276
+ ```
277
+
278
+ Then visit `https://<machine>.<tailnet>.ts.net/ui/` from any tailnet device. Tailscale Serve rewrites the upstream `Host` header to `localhost:3940`, so the loopback check passes without any nlm-memory config. WireGuard + your tailnet ACLs are the auth layer — for a single-user tailnet this is strictly stronger than `NLM_UI_AUTH=cookie`, so leave that off.
279
+
280
+ **If you do enable `NLM_UI_AUTH=cookie`** (defense in depth, or you've added untrusted devices to your tailnet), bootstrapping a cookie from a remote device needs one extra step. `nlm ui` only opens a browser on the daemon host; for the remote browser:
281
+
282
+ ```sh
283
+ ssh <daemon-host> 'nlm ui --print' # mints a nonce, prints the URL
284
+ # Paste the URL into the remote browser within ~60s (nonce TTL)
285
+ ```
286
+
287
+ **Do not expose the daemon directly to the public internet.** The cookie is a shared-HMAC speed bump, not real public-internet auth. If you absolutely must, put it behind something with real authentication (Cloudflare Access, Tailscale Funnel with auth in front, etc.).
288
+
268
289
  ---
269
290
 
270
291
  ## Upgrading from v0.4.x
package/dist/cli/nlm.js CHANGED
@@ -34,10 +34,12 @@ import { serve } from "@hono/node-server";
34
34
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
35
35
  import { FactRecallService } from "../core/recall-facts/fact-recall-service.js";
36
36
  import { RecallService } from "../core/recall/recall-service.js";
37
- import { SqliteFactStore } from "../core/storage/sqlite-fact-store.js";
38
37
  import { ProviderRegistry } from "../core/providers/provider-registry.js";
39
38
  import { SourceRegistry } from "../core/sources/source-registry.js";
40
- import { SqliteSessionStore } from "../core/storage/sqlite-session-store.js";
39
+ import { SqliteStorage } from "../core/storage/sqlite-storage.js";
40
+ import { PgStorage } from "../core/storage/pg-storage.js";
41
+ import { PgSourceRegistry } from "../core/sources/source-registry.js";
42
+ import { PgProviderRegistry } from "../core/providers/provider-registry.js";
41
43
  import { applyPendingRestore } from "../core/storage/db-restore.js";
42
44
  import { createApp } from "../http/app.js";
43
45
  import { createMcpServer } from "../mcp/server.js";
@@ -56,6 +58,7 @@ import { getUpdateStatus } from "../core/update-check/check.js";
56
58
  import { connectHermes, disconnectHermes, hermesConfigPath } from "../install/hermes.js";
57
59
  import { connectHermesAgent, disconnectHermesAgent, hermesAgentPluginDir } from "../install/hermes-agent.js";
58
60
  import { connectWindsurf, disconnectWindsurf } from "../install/windsurf.js";
61
+ import { connectPi, disconnectPi, piSettingsPath } from "../install/pi.js";
59
62
  import { runSetup } from "../install/setup.js";
60
63
  import { runParity } from "./classify-parity.js";
61
64
  import { reembedCorpus } from "../core/embedding/embed-backfill.js";
@@ -67,11 +70,11 @@ import { isAgentLoaded, isBenignBootoutError } from "./launchctl-helpers.js";
67
70
  import { DAEMON_PKILL_PATTERN, planRestart } from "./restart-helpers.js";
68
71
  import { applyEnvAssignment } from "./config-env.js";
69
72
  import { adapterFromSource } from "../core/adapters/from-source.js";
70
- import { scanUsefulHits } from "../core/recall/useful-scan.js";
71
73
  import { runDigest } from "./digest.js";
72
74
  const __filename = fileURLToPath(import.meta.url);
73
75
  const __dirname = dirname(__filename);
74
76
  const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
77
+ const PG_MIGRATIONS_DIR = join(fileURLToPath(new URL(".", import.meta.url)), "../../migrations/pg");
75
78
  const UI_DIST = resolve(__dirname, "../../dist/ui");
76
79
  const DEFAULT_DB_PATH = resolve(homedir(), ".nlm/canonical.sqlite");
77
80
  const DEFAULT_PORT = 3940;
@@ -102,15 +105,16 @@ function buildClassifier() {
102
105
  ?? (provider === "ollama" ? "phi4-mini:latest" : "deepseek-v4-flash");
103
106
  return new ClassifierBox({ provider, model, ollamaUrl: ollamaUrl() });
104
107
  }
105
- function buildAdapters(sources) {
108
+ async function buildAdapters(sources) {
106
109
  // Sources table is the source of truth. Each enabled row maps to one
107
110
  // adapter via adapterFromSource(). Detection still gates registration —
108
111
  // a row pointing at a missing dir won't poll. NLM_ADAPTERS keeps working
109
112
  // as a name-based filter for forcing a subset during dev.
110
113
  const explicit = process.env["NLM_ADAPTERS"];
111
114
  const allowed = explicit ? new Set(explicit.split(",").map((s) => s.trim())) : null;
115
+ const rows = await sources.list();
112
116
  const out = [];
113
- for (const row of sources.list()) {
117
+ for (const row of rows) {
114
118
  if (!row.enabled)
115
119
  continue;
116
120
  const adapter = adapterFromSource(row);
@@ -124,7 +128,16 @@ function buildAdapters(sources) {
124
128
  }
125
129
  return out;
126
130
  }
127
- function buildStack() {
131
+ async function buildStorage(path) {
132
+ const pgUrl = process.env["NLM_PG_URL"];
133
+ if (pgUrl) {
134
+ const storage = PgStorage.create({ connectionString: pgUrl, migrationsDir: PG_MIGRATIONS_DIR });
135
+ await storage.init();
136
+ return storage;
137
+ }
138
+ return SqliteStorage.create({ dbPath: path, migrationsDir: MIGRATIONS_DIR });
139
+ }
140
+ async function buildStack() {
128
141
  // Load .env before any registry seeds so secrets carried in env vars
129
142
  // (DEEPSEEK_API_KEY today; OPENAI_API_KEY etc. tomorrow) bridge into
130
143
  // the providers table on first boot under launchd.
@@ -137,24 +150,29 @@ function buildStack() {
137
150
  if (restored.archivedTo)
138
151
  console.error(` previous db archived at ${restored.archivedTo}`);
139
152
  }
140
- const store = new SqliteSessionStore({
141
- dbPath: dbPath(),
142
- migrationsDir: MIGRATIONS_DIR,
143
- });
153
+ const storage = await buildStorage(dbPath());
154
+ const store = storage.sessions;
144
155
  // FactStore shares the SessionStore's connection so session+facts ingest
145
156
  // can commit in one transaction. Phase B.1 wires it in; no callers yet.
146
- const facts = new SqliteFactStore(store.rawDb());
147
- const sources = new SourceRegistry(store.rawDb());
148
- sources.seedDefaults();
149
- const providers = new ProviderRegistry(store.rawDb());
150
- providers.seedDefaults();
157
+ const facts = storage.facts;
158
+ // TODO(#215a): replace storage.rawDb() with port methods
159
+ const sources = storage instanceof PgStorage
160
+ ? new PgSourceRegistry(storage.pgPool())
161
+ : new SourceRegistry(storage.rawDb());
162
+ await sources.seedDefaults();
163
+ // TODO(#215a): replace storage.rawDb() with port methods
164
+ const providers = storage instanceof PgStorage
165
+ ? new PgProviderRegistry(storage.pgPool())
166
+ : new ProviderRegistry(storage.rawDb());
167
+ if (providers instanceof ProviderRegistry)
168
+ providers.seedDefaults();
151
169
  // Recall only uses embed(). Embeddings live on Ollama; DeepSeek doesn't
152
170
  // expose them. Classifier is wired separately for Phase D ingest.
153
171
  const embedder = new OllamaClient({ baseUrl: ollamaUrl() });
154
172
  const classifier = buildClassifier();
155
173
  const recall = new RecallService({ store, llm: embedder });
156
174
  const factRecall = new FactRecallService({ factStore: facts, llm: embedder });
157
- return { store, facts, sources, providers, recall, factRecall, embedder, classifier };
175
+ return { storage, store, facts, sources, providers, recall, factRecall, embedder, classifier };
158
176
  }
159
177
  const program = new Command();
160
178
  program
@@ -174,7 +192,7 @@ program
174
192
  // non-browser callers. Idempotent: re-reads persisted token first.
175
193
  autoloadEnv();
176
194
  ensureMcpToken();
177
- const { store, facts, sources, providers, recall, factRecall, embedder, classifier } = buildStack();
195
+ const { storage, store, facts, sources, providers, recall, factRecall, embedder, classifier } = await buildStack();
178
196
  const { existsSync } = await import("node:fs");
179
197
  const hasMcpToken = Boolean(process.env["NLM_MCP_TOKEN"]);
180
198
  const app = createApp({
@@ -187,7 +205,15 @@ program
187
205
  classifier,
188
206
  sources,
189
207
  providers,
190
- ingest: { classifier, embedder, store, factStore: facts },
208
+ // TODO(#215a): PgStorage ingest port; cast until then
209
+ ...(!(storage instanceof PgStorage) ? {
210
+ ingest: {
211
+ classifier,
212
+ embedder,
213
+ store: store,
214
+ ...(facts ? { factStore: facts } : {}),
215
+ },
216
+ } : {}),
191
217
  embedderInfo: { provider: "ollama", model: "nomic-embed-text", dims: 768 },
192
218
  ...(existsSync(UI_DIST) ? { uiDist: UI_DIST } : {}),
193
219
  // Wire POST /mcp only when NLM_MCP_TOKEN is present. Absent = route never
@@ -218,23 +244,29 @@ program
218
244
  // Keep the SQLite WAL bounded. WAL mode is on but nothing else
219
245
  // checkpoints it; under continuous readers it grows without limit
220
246
  // (it had reached 38 MB), which slows every read. Drain once at boot,
221
- // then every 5 minutes.
222
- const WAL_CHECKPOINT_INTERVAL_MS = 5 * 60_000;
223
- try {
224
- store.checkpoint();
225
- }
226
- catch {
227
- // Boot checkpoint can lose a race with readers — the interval retries.
228
- }
229
- const checkpointTimer = setInterval(() => {
230
- try {
231
- store.checkpoint();
232
- }
233
- catch {
234
- // Checkpoint contention — the next tick retries.
235
- }
236
- }, WAL_CHECKPOINT_INTERVAL_MS);
237
- checkpointTimer.unref();
247
+ // then every 5 minutes. Skip entirely when using PgStorage (no WAL).
248
+ const checkpointTimer = !(storage instanceof PgStorage)
249
+ ? (() => {
250
+ const WAL_CHECKPOINT_INTERVAL_MS = 5 * 60_000;
251
+ const sqliteStore = store;
252
+ try {
253
+ sqliteStore.checkpoint();
254
+ }
255
+ catch {
256
+ // Boot checkpoint can lose a race with readers — the interval retries.
257
+ }
258
+ const t = setInterval(() => {
259
+ try {
260
+ sqliteStore.checkpoint();
261
+ }
262
+ catch {
263
+ // Checkpoint contention — the next tick retries.
264
+ }
265
+ }, WAL_CHECKPOINT_INTERVAL_MS);
266
+ t.unref();
267
+ return t;
268
+ })()
269
+ : null;
238
270
  // Memo sweep runs independently of the transcript scheduler — it's the
239
271
  // backstop for SessionEnd hook unreliability (crashes, kill -9, IDE
240
272
  // force-close don't fire SessionEnd, so memo files would otherwise
@@ -242,27 +274,29 @@ program
242
274
  const memoSweep = new MemoSweepScheduler();
243
275
  memoSweep.start();
244
276
  console.error(" memo sweep: dormant cleanup every 5m (threshold 24h)");
245
- if (opts.scheduler !== false) {
246
- const adapters = buildAdapters(sources);
277
+ if (opts.scheduler !== false && !(storage instanceof PgStorage)) {
278
+ const adapters = await buildAdapters(sources);
247
279
  if (adapters.length === 0) {
248
280
  console.error(" scheduler: no adapters detected (set NLM_ADAPTERS to force-enable)");
249
281
  }
250
282
  else {
251
283
  const scheduler = new ScanScheduler({
252
- store,
284
+ // TODO(#215a): PgStorage scheduler port; SQLite-only until then
285
+ store: store,
253
286
  adapters,
254
287
  classifier,
255
288
  embedder,
256
- factStore: facts,
289
+ factStore: facts ?? null,
257
290
  intervalMs: opts.intervalMin * 60_000,
258
291
  });
259
292
  scheduler.start();
260
293
  console.error(` scheduler: ${adapters.map((a) => a.name).join(", ")} every ${opts.intervalMin}m`);
261
- const shutdown = () => {
262
- clearInterval(checkpointTimer);
294
+ const shutdown = async () => {
295
+ if (checkpointTimer)
296
+ clearInterval(checkpointTimer);
263
297
  scheduler.stop();
264
298
  memoSweep.stop();
265
- store.close();
299
+ await storage.close();
266
300
  process.exit(0);
267
301
  };
268
302
  process.on("SIGINT", shutdown);
@@ -273,14 +307,15 @@ program
273
307
  program
274
308
  .command("migrate")
275
309
  .description("Run pending migrations against the canonical SQLite")
276
- .action(() => {
310
+ .action(async () => {
277
311
  // SqliteSessionStore's constructor loads sqlite-vec and runs migrations.
278
312
  // Opening + closing is the whole operation.
279
- const store = new SqliteSessionStore({
313
+ const storage = SqliteStorage.create({
280
314
  dbPath: dbPath(),
281
315
  migrationsDir: MIGRATIONS_DIR,
282
316
  });
283
- store.close();
317
+ await storage.init();
318
+ await storage.close();
284
319
  console.error(`nlm-memory: migrations applied at ${dbPath()}`);
285
320
  });
286
321
  program
@@ -292,7 +327,7 @@ program
292
327
  .option("-m, --mode <mode>", "keyword|semantic|hybrid", "keyword")
293
328
  .option("-l, --limit <n>", "max results", (v) => Number.parseInt(v, 10), 10)
294
329
  .action(async (query, opts) => {
295
- const { store, recall } = buildStack();
330
+ const { storage, recall } = await buildStack();
296
331
  try {
297
332
  const result = await recall.search({
298
333
  query,
@@ -304,7 +339,7 @@ program
304
339
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
305
340
  }
306
341
  finally {
307
- store.close();
342
+ await storage.close();
308
343
  }
309
344
  });
310
345
  program
@@ -376,10 +411,11 @@ program
376
411
  .option("--no-embed", "skip per-fact embedding (faster but disables semantic recall)")
377
412
  .option("-v, --verbose", "per-session progress on stderr")
378
413
  .action(async (opts) => {
379
- const { store, facts, embedder, classifier } = buildStack();
414
+ const { storage, store, facts, embedder, classifier } = await buildStack();
380
415
  try {
381
416
  const report = await backfillFacts({
382
- store,
417
+ // TODO(#215a): PgStorage backfill port; SQLite-only until then
418
+ store: store,
383
419
  factStore: facts,
384
420
  classifier,
385
421
  embedder: opts.embed === false ? null : embedder,
@@ -400,7 +436,7 @@ program
400
436
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
401
437
  }
402
438
  finally {
403
- store.close();
439
+ await storage.close();
404
440
  }
405
441
  });
406
442
  program
@@ -422,7 +458,7 @@ program
422
458
  .command("mcp")
423
459
  .description("Run as an MCP stdio server (for ~/.mcp.json)")
424
460
  .action(async () => {
425
- const { recall, store, facts, factRecall } = buildStack();
461
+ const { recall, store, facts, factRecall } = await buildStack();
426
462
  const server = createMcpServer({ recall, store, factStore: facts, factRecall });
427
463
  const transport = new StdioServerTransport();
428
464
  await server.connect(transport);
@@ -719,7 +755,8 @@ config
719
755
  program
720
756
  .command("ui")
721
757
  .description("Open the WebUI, bootstrapping a session cookie via single-use nonce")
722
- .action(async () => {
758
+ .option("--print", "Print the bootstrap URL to stdout instead of opening a browser (use over SSH when the daemon host is headless or you're accessing via Tailscale)")
759
+ .action(async (opts) => {
723
760
  // The daemon autoloads .env at startup, but a fresh shell invoking
724
761
  // `nlm ui` won't have NLM_MCP_TOKEN exported unless the user sourced
725
762
  // it manually. Mirror the daemon's lookup so this command works from
@@ -751,6 +788,18 @@ program
751
788
  process.exit(1);
752
789
  }
753
790
  }
791
+ if (opts.print) {
792
+ // stdout (not stderr) so the URL can be piped or captured cleanly.
793
+ // The accompanying status line goes to stderr.
794
+ if (token) {
795
+ console.error(`nlm ui: paste this URL into your browser within ~60s (nonce expires):`);
796
+ }
797
+ else {
798
+ console.error("nlm ui: visit this URL in your browser:");
799
+ }
800
+ process.stdout.write(`${target}\n`);
801
+ return;
802
+ }
754
803
  const opener = process.platform === "darwin"
755
804
  ? "open"
756
805
  : process.platform === "linux"
@@ -955,10 +1004,12 @@ connect
955
1004
  .description("Register Cursor as an nlm source (reads state.vscdb directly — no files installed)")
956
1005
  .option("--db-path <path>", "override path to globalStorage/state.vscdb")
957
1006
  .option("--dry-run", "print what would happen without changing files")
958
- .action((opts) => {
959
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1007
+ .action(async (opts) => {
1008
+ const storage = SqliteStorage.create({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1009
+ await storage.init();
960
1010
  try {
961
- const registry = new SourceRegistry(store.rawDb());
1011
+ // TODO(#215a): replace storage.rawDb() with port methods
1012
+ const registry = new SourceRegistry(storage.rawDb());
962
1013
  const report = connectCursor(registry, {
963
1014
  ...(opts.dbPath ? { dbPath: opts.dbPath } : {}),
964
1015
  dryRun: Boolean(opts.dryRun),
@@ -971,7 +1022,7 @@ connect
971
1022
  console.error(`nlm: Cursor source ${report.action} → ${report.adapterDbPath}${suffix}`);
972
1023
  }
973
1024
  finally {
974
- store.close();
1025
+ await storage.close();
975
1026
  }
976
1027
  });
977
1028
  connect
@@ -979,10 +1030,12 @@ connect
979
1030
  .description("Register Windsurf as an nlm source (reads state.vscdb files directly — no files installed)")
980
1031
  .option("--user-dir <path>", "override path to Windsurf User directory")
981
1032
  .option("--dry-run", "print what would happen without changing files")
982
- .action((opts) => {
983
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1033
+ .action(async (opts) => {
1034
+ const storage = SqliteStorage.create({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1035
+ await storage.init();
984
1036
  try {
985
- const registry = new SourceRegistry(store.rawDb());
1037
+ // TODO(#215a): replace storage.rawDb() with port methods
1038
+ const registry = new SourceRegistry(storage.rawDb());
986
1039
  const report = connectWindsurf(registry, {
987
1040
  ...(opts.userDir ? { userDir: opts.userDir } : {}),
988
1041
  dryRun: Boolean(opts.dryRun),
@@ -995,9 +1048,31 @@ connect
995
1048
  console.error(`nlm: Windsurf source ${report.action} → ${report.userDir}${suffix}`);
996
1049
  }
997
1050
  finally {
998
- store.close();
1051
+ await storage.close();
999
1052
  }
1000
1053
  });
1054
+ connect
1055
+ .command("pi")
1056
+ .description("Register the nlm-memory prompt-recall extension in ~/.pi/agent/settings.json")
1057
+ .option("--dry-run", "print what would happen without changing files")
1058
+ .action((opts) => {
1059
+ const pluginDir = join(REPO_ROOT, "plugin-pi");
1060
+ const report = connectPi({ pluginDir, dryRun: Boolean(opts.dryRun) });
1061
+ if (opts.dryRun) {
1062
+ const verb = report.alreadyPresent ? "already present in" : "append to";
1063
+ console.error(`nlm connect pi (dry run): ${verb} packages[] in ${report.settingsPath} → ${pluginDir}`);
1064
+ return;
1065
+ }
1066
+ if (report.alreadyPresent) {
1067
+ console.error(`nlm: pi extension already registered → ${report.pluginDir}`);
1068
+ }
1069
+ else {
1070
+ console.error(`nlm: pi extension registered → ${report.settingsPath}`);
1071
+ console.error(` Packages entry: ${report.pluginDir}`);
1072
+ }
1073
+ console.error(" Restart pi to activate the prompt-recall hook.");
1074
+ console.error(" Set NLM_HOOK_MODE=live in ~/.nlm/.env to flip from shadow → live.");
1075
+ });
1001
1076
  const disconnect = program
1002
1077
  .command("disconnect")
1003
1078
  .description("Disconnect nlm-memory from an AI coding runtime");
@@ -1091,10 +1166,12 @@ disconnect
1091
1166
  .command("cursor")
1092
1167
  .description("Disable the Cursor source in the nlm registry (leaves Cursor untouched)")
1093
1168
  .option("--dry-run", "print what would happen without changing files")
1094
- .action((opts) => {
1095
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1169
+ .action(async (opts) => {
1170
+ const storage = SqliteStorage.create({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1171
+ await storage.init();
1096
1172
  try {
1097
- const registry = new SourceRegistry(store.rawDb());
1173
+ // TODO(#215a): replace storage.rawDb() with port methods
1174
+ const registry = new SourceRegistry(storage.rawDb());
1098
1175
  const report = disconnectCursor(registry, { dryRun: Boolean(opts.dryRun) });
1099
1176
  if (opts.dryRun) {
1100
1177
  console.error("nlm disconnect cursor (dry run): disable Cursor source in registry");
@@ -1105,17 +1182,19 @@ disconnect
1105
1182
  : "nlm: no Cursor source found in registry");
1106
1183
  }
1107
1184
  finally {
1108
- store.close();
1185
+ await storage.close();
1109
1186
  }
1110
1187
  });
1111
1188
  disconnect
1112
1189
  .command("windsurf")
1113
1190
  .description("Disable the Windsurf source in the nlm registry (leaves Windsurf untouched)")
1114
1191
  .option("--dry-run", "print what would happen without changing files")
1115
- .action((opts) => {
1116
- const store = new SqliteSessionStore({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1192
+ .action(async (opts) => {
1193
+ const storage = SqliteStorage.create({ dbPath: dbPath(), migrationsDir: MIGRATIONS_DIR });
1194
+ await storage.init();
1117
1195
  try {
1118
- const registry = new SourceRegistry(store.rawDb());
1196
+ // TODO(#215a): replace storage.rawDb() with port methods
1197
+ const registry = new SourceRegistry(storage.rawDb());
1119
1198
  const report = disconnectWindsurf(registry, { dryRun: Boolean(opts.dryRun) });
1120
1199
  if (opts.dryRun) {
1121
1200
  console.error("nlm disconnect windsurf (dry run): disable Windsurf source in registry");
@@ -1126,8 +1205,22 @@ disconnect
1126
1205
  : "nlm: no Windsurf source found in registry");
1127
1206
  }
1128
1207
  finally {
1129
- store.close();
1208
+ await storage.close();
1209
+ }
1210
+ });
1211
+ disconnect
1212
+ .command("pi")
1213
+ .description("Remove the nlm-memory pi extension from ~/.pi/agent/settings.json")
1214
+ .option("--dry-run", "print what would happen without changing files")
1215
+ .action((opts) => {
1216
+ const report = disconnectPi({ dryRun: Boolean(opts.dryRun) });
1217
+ if (opts.dryRun) {
1218
+ console.error(`nlm disconnect pi (dry run): strip plugin-pi from packages[] in ${piSettingsPath()}`);
1219
+ return;
1130
1220
  }
1221
+ console.error(report.removed
1222
+ ? `nlm: pi extension removed → ${report.settingsPath}`
1223
+ : `nlm: no nlm pi extension found in ${report.settingsPath}`);
1131
1224
  });
1132
1225
  program
1133
1226
  .command("setup")
@@ -1153,19 +1246,6 @@ program
1153
1246
  buildHookCommand,
1154
1247
  });
1155
1248
  });
1156
- program
1157
- .command("useful-scan")
1158
- .description("Scan hook log for useful recall hits; writes to ~/.nlm/useful-hit-log.jsonl")
1159
- .option("-d, --days <n>", "rolling window in days", (v) => Number.parseInt(v, 10), 1)
1160
- .option("--dry-run", "compute without writing to disk")
1161
- .action(async (opts) => {
1162
- const result = await scanUsefulHits({ days: opts.days, ...(opts.dryRun ? { dryRun: true } : {}) });
1163
- const rate = result.measurable === 0
1164
- ? "no measurable entries"
1165
- : `${result.useful}/${result.measurable} useful (${Math.round((result.useful / result.measurable) * 100)}%)`;
1166
- console.error(`nlm useful-scan: scanned ${result.total} recalls in the last ${opts.days}d — ${rate}` +
1167
- (opts.dryRun ? " (dry-run)" : `, ${result.appended} appended`));
1168
- });
1169
1249
  program
1170
1250
  .command("digest")
1171
1251
  .description("Compose a daily-activity digest from the running daemon (optionally post to Telegram)")