@hasna/mementos 0.14.68 → 0.14.70

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 (57) hide show
  1. package/README.md +155 -73
  2. package/dist/cli/__fixtures__/clean-fallback-stub-server.d.ts +2 -0
  3. package/dist/cli/__fixtures__/clean-fallback-stub-server.d.ts.map +1 -0
  4. package/dist/cli/commands/io-clean.d.ts.map +1 -1
  5. package/dist/cli/commands/memory-cmd-crud.d.ts.map +1 -1
  6. package/dist/cli/commands/storage.d.ts.map +1 -1
  7. package/dist/cli/commands/system-mcp.d.ts.map +1 -1
  8. package/dist/cli/commands/system-profile.d.ts.map +1 -1
  9. package/dist/cli/global-options.d.ts +27 -0
  10. package/dist/cli/global-options.d.ts.map +1 -0
  11. package/dist/cli/index.js +699 -301
  12. package/dist/cli/register-all.d.ts +17 -0
  13. package/dist/cli/register-all.d.ts.map +1 -0
  14. package/dist/cli/startup-side-effects.d.ts +9 -0
  15. package/dist/cli/startup-side-effects.d.ts.map +1 -0
  16. package/dist/db/__fixtures__/fail-closed-stub-server.d.ts +2 -0
  17. package/dist/db/__fixtures__/fail-closed-stub-server.d.ts.map +1 -0
  18. package/dist/db/agents.d.ts.map +1 -1
  19. package/dist/db/api-mode.d.ts +70 -2
  20. package/dist/db/api-mode.d.ts.map +1 -1
  21. package/dist/db/database.d.ts +16 -0
  22. package/dist/db/database.d.ts.map +1 -1
  23. package/dist/db/locks.d.ts.map +1 -1
  24. package/dist/db/memories.d.ts +24 -6
  25. package/dist/db/memories.d.ts.map +1 -1
  26. package/dist/db/migrations.d.ts +1 -0
  27. package/dist/db/migrations.d.ts.map +1 -1
  28. package/dist/db/pg-migrations.d.ts.map +1 -1
  29. package/dist/db/session-jobs.d.ts.map +1 -1
  30. package/dist/db/store-backend.d.ts +33 -0
  31. package/dist/db/store-backend.d.ts.map +1 -0
  32. package/dist/index.js +196 -62
  33. package/dist/lib/auto-memory-queue.d.ts +2 -0
  34. package/dist/lib/auto-memory-queue.d.ts.map +1 -1
  35. package/dist/lib/auto-memory.d.ts +1 -0
  36. package/dist/lib/auto-memory.d.ts.map +1 -1
  37. package/dist/lib/built-in-hooks.d.ts.map +1 -1
  38. package/dist/lib/enum-validation.d.ts +20 -0
  39. package/dist/lib/enum-validation.d.ts.map +1 -0
  40. package/dist/lib/gdpr.d.ts.map +1 -1
  41. package/dist/mcp/index.d.ts.map +1 -1
  42. package/dist/mcp/index.js +301 -78
  43. package/dist/mcp/tools/system-tools-memory-admin.d.ts.map +1 -1
  44. package/dist/server/helpers.d.ts +11 -0
  45. package/dist/server/helpers.d.ts.map +1 -1
  46. package/dist/server/index.d.ts.map +1 -1
  47. package/dist/server/index.js +304 -72
  48. package/dist/storage.d.ts +11 -2
  49. package/dist/storage.d.ts.map +1 -1
  50. package/dist/storage.js +42 -11
  51. package/dist/test-support/preload-local-store.d.ts +2 -0
  52. package/dist/test-support/preload-local-store.d.ts.map +1 -0
  53. package/dist/test-support/store-isolation.d.ts +83 -0
  54. package/dist/test-support/store-isolation.d.ts.map +1 -0
  55. package/dist/types/index.d.ts +8 -4
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/package.json +1 -1
package/dist/mcp/index.js CHANGED
@@ -66,8 +66,19 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
66
66
  var __require = import.meta.require;
67
67
 
68
68
  // src/types/index.ts
69
- var AgentConflictError, EntityNotFoundError, MemoryNotFoundError, DuplicateMemoryError, InvalidScopeError, VersionConflictError, MemoryConflictError;
69
+ var MEMORY_SCOPES, MEMORY_CATEGORIES, MEMORY_SOURCES, MEMORY_STATUSES, AgentConflictError, EntityNotFoundError, MemoryNotFoundError, DuplicateMemoryError, InvalidScopeError, VersionConflictError, MemoryConflictError;
70
70
  var init_types = __esm(() => {
71
+ MEMORY_SCOPES = ["global", "shared", "private", "working"];
72
+ MEMORY_CATEGORIES = [
73
+ "preference",
74
+ "fact",
75
+ "knowledge",
76
+ "history",
77
+ "procedural",
78
+ "resource"
79
+ ];
80
+ MEMORY_SOURCES = ["user", "agent", "system", "auto", "imported"];
81
+ MEMORY_STATUSES = ["active", "archived", "expired"];
71
82
  AgentConflictError = class AgentConflictError extends Error {
72
83
  conflict = true;
73
84
  existing_id;
@@ -134,6 +145,27 @@ var init_types = __esm(() => {
134
145
  };
135
146
  });
136
147
 
148
+ // src/generated/storage-kit/mode.ts
149
+ function normalizeStorageMode(value) {
150
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
151
+ if (normalized === "local")
152
+ return { mode: "local", deprecatedAlias: null };
153
+ if (normalized === "cloud")
154
+ return { mode: "cloud", deprecatedAlias: null };
155
+ if (DEPRECATED_STORAGE_MODE_ALIASES.includes(normalized)) {
156
+ return { mode: "cloud", deprecatedAlias: normalized };
157
+ }
158
+ throw new Error(`Unknown storage mode: ${value}. Use local or cloud.`);
159
+ }
160
+ var DEPRECATED_STORAGE_MODE_ALIASES;
161
+ var init_mode = __esm(() => {
162
+ DEPRECATED_STORAGE_MODE_ALIASES = [
163
+ "remote",
164
+ "hybrid",
165
+ "self_hosted"
166
+ ];
167
+ });
168
+
137
169
  // src/storage.ts
138
170
  import { Database } from "bun:sqlite";
139
171
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -392,18 +424,20 @@ function warnDeprecatedStorageMode(alias) {
392
424
  warnedDeprecatedModes.add(alias);
393
425
  process.emitWarning(`${MEMENTOS_STORAGE_ENV.mode}="${alias}" is deprecated; use "cloud". ` + `"${alias}" now maps to pure-remote cloud storage. The local<->remote ` + `sync path is deprecated and is not the fleet cutover path.`, { type: "DeprecationWarning", code: "MEMENTOS_STORAGE_MODE_ALIAS" });
394
426
  }
395
- function normalizeStorageMode(value) {
396
- if (!value)
427
+ function normalizeStorageMode2(value, source) {
428
+ if (!value || !value.trim())
397
429
  return null;
398
- const normalized = value.trim().toLowerCase();
399
- if (normalized === "local" || normalized === "cloud") {
400
- return normalized;
430
+ let normalized;
431
+ try {
432
+ normalized = normalizeStorageMode(value);
433
+ } catch (error) {
434
+ const detail = error instanceof Error ? error.message : String(error);
435
+ throw new Error(`mementos: ${source}=${value} is not a valid mode. ${detail}`);
401
436
  }
402
- if (normalized === "remote" || normalized === "hybrid") {
403
- warnDeprecatedStorageMode(normalized);
404
- return "cloud";
437
+ if (normalized.deprecatedAlias) {
438
+ warnDeprecatedStorageMode(normalized.deprecatedAlias);
405
439
  }
406
- return null;
440
+ return normalized.mode;
407
441
  }
408
442
  function readConfigFile() {
409
443
  if (!existsSync(STORAGE_CONFIG_PATH)) {
@@ -433,7 +467,7 @@ function getStorageDatabaseUrl() {
433
467
  }
434
468
  function getStorageModeOverride() {
435
469
  for (const env of MODE_ENV_NAMES) {
436
- const value = normalizeStorageMode(readEnv(env.name) ?? undefined);
470
+ const value = normalizeStorageMode2(readEnv(env.name) ?? undefined, env.name);
437
471
  if (value)
438
472
  return value;
439
473
  }
@@ -443,7 +477,7 @@ function getStorageConfig() {
443
477
  const fileConfig = readConfigFile();
444
478
  const modeOverride = getStorageModeOverride();
445
479
  const envConnectionString = getConfiguredConnectionString();
446
- const fileMode = normalizeStorageMode(fileConfig.mode);
480
+ const fileMode = normalizeStorageMode2(fileConfig.mode, `${STORAGE_CONFIG_PATH} "mode"`);
447
481
  const merged = {
448
482
  ...DEFAULT_STORAGE_CONFIG,
449
483
  ...fileConfig,
@@ -854,6 +888,7 @@ CREATE TABLE IF NOT EXISTS _sync_meta (
854
888
  direction TEXT DEFAULT 'push'
855
889
  )`;
856
890
  var init_storage = __esm(() => {
891
+ init_mode();
857
892
  PgSyncPool = class PgSyncPool {
858
893
  worker;
859
894
  status;
@@ -996,7 +1031,7 @@ import { tmpdir } from "os";
996
1031
  import { join as join2 } from "path";
997
1032
  import { writeFileSync as writeFileSync2, unlinkSync } from "fs";
998
1033
  import { randomUUID } from "crypto";
999
- function firstEnv(...keys) {
1034
+ function firstEnv(keys) {
1000
1035
  for (const k of keys) {
1001
1036
  const v = process.env[k]?.trim();
1002
1037
  if (v)
@@ -1004,8 +1039,38 @@ function firstEnv(...keys) {
1004
1039
  }
1005
1040
  return;
1006
1041
  }
1042
+ function firstEnvKey(keys) {
1043
+ for (const k of keys) {
1044
+ if (process.env[k]?.trim())
1045
+ return k;
1046
+ }
1047
+ return null;
1048
+ }
1007
1049
  function hasDatabaseUrl() {
1008
- return Boolean(firstEnv("HASNA_MEMENTOS_DATABASE_URL", "MEMENTOS_DATABASE_URL"));
1050
+ return Boolean(firstEnv(DATABASE_URL_ENV_KEYS));
1051
+ }
1052
+ function isLoopbackHost(rawHost) {
1053
+ const host = rawHost.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
1054
+ return host === "localhost" || host === "::1" || /^127\./.test(host);
1055
+ }
1056
+ function assertRequestAllowedUnderTest(baseUrl) {
1057
+ if (process.env["NODE_ENV"] !== "test")
1058
+ return;
1059
+ if (process.env[ALLOW_REMOTE_API_IN_TESTS_ENV]?.trim())
1060
+ return;
1061
+ let host;
1062
+ try {
1063
+ host = new URL(baseUrl).hostname;
1064
+ } catch {
1065
+ host = "";
1066
+ }
1067
+ if (host && isLoopbackHost(host))
1068
+ return;
1069
+ throw new Error("api-mode: REFUSING to make a cloud request from a test process \u2014 this would write to or read " + "from the SHARED PRODUCTION memory store, where test fixtures are indistinguishable from real " + `memories.
1070
+ ` + ` host : ${host || "(unparseable base URL)"}
1071
+ ` + ` how this happens: a selector set at module scope (after the bun test preload ran), or \`bun test\` ` + `invoked from a directory with no bunfig.toml so the preload never loaded.
1072
+ ` + " fix : build the child/process env via src/test-support/store-isolation.ts, or point the " + `suite at a loopback stub.
1073
+ ` + ` override : set ${ALLOW_REMOTE_API_IN_TESTS_ENV}=1 only for a test that must reach a remote endpoint.`);
1009
1074
  }
1010
1075
  function normalizeBase(raw) {
1011
1076
  let base = raw.trim().replace(/\/+$/, "");
@@ -1013,9 +1078,24 @@ function normalizeBase(raw) {
1013
1078
  return base;
1014
1079
  return `${base}/v1`;
1015
1080
  }
1081
+ function assertUnambiguousStoreEnv() {
1082
+ if (firstEnvKey(DB_PATH_ENV_KEYS))
1083
+ return;
1084
+ if (hasDatabaseUrl())
1085
+ return;
1086
+ const urlKey = firstEnvKey(API_URL_ENV_KEYS);
1087
+ const keyKey = firstEnvKey(API_KEY_ENV_KEYS);
1088
+ if (urlKey && !keyKey) {
1089
+ throw new MementosStoreConfigError(`${urlKey} points at the cloud memory store but ${API_KEY_ENV_KEYS[0]} is not set. ` + `Refusing to serve the on-box SQLite store in its place, because it holds a different ` + `dataset. Set ${API_KEY_ENV_KEYS[0]} to reach the cloud store. If you meant to use the ` + `on-box SQLite store, unset ${urlKey} or set ${DB_PATH_ENV_KEYS[0]} explicitly.`);
1090
+ }
1091
+ if (keyKey && !urlKey) {
1092
+ throw new MementosStoreConfigError(`${keyKey} is set but ${API_URL_ENV_KEYS[0]} is not, so the cloud memory store cannot be ` + `reached. Refusing to serve the on-box SQLite store in its place, because it holds a ` + `different dataset. Set ${API_URL_ENV_KEYS[0]} to reach the cloud store. If you meant to ` + `use the on-box SQLite store, unset ${keyKey} or set ${DB_PATH_ENV_KEYS[0]} explicitly.`);
1093
+ }
1094
+ }
1016
1095
  function getApiConfig() {
1017
- const rawBase = firstEnv("HASNA_MEMENTOS_API_URL", "MEMENTOS_API_URL");
1018
- const apiKey = firstEnv("HASNA_MEMENTOS_API_KEY", "MEMENTOS_API_KEY");
1096
+ assertUnambiguousStoreEnv();
1097
+ const rawBase = firstEnv(API_URL_ENV_KEYS);
1098
+ const apiKey = firstEnv(API_KEY_ENV_KEYS);
1019
1099
  if (!rawBase || !apiKey)
1020
1100
  return null;
1021
1101
  return { baseUrl: normalizeBase(rawBase), apiKey };
@@ -1029,6 +1109,7 @@ function apiRequestRaw(method, path, body) {
1029
1109
  const cfg = getApiConfig();
1030
1110
  if (!cfg)
1031
1111
  throw new Error("api-mode: not configured (HASNA_MEMENTOS_API_URL / HASNA_MEMENTOS_API_KEY)");
1112
+ assertRequestAllowedUnderTest(cfg.baseUrl);
1032
1113
  const url = `${cfg.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
1033
1114
  const hasBody = body !== undefined && body !== null;
1034
1115
  const timeout = process.env["HASNA_MEMENTOS_API_TIMEOUT"] || DEFAULT_TIMEOUT_S;
@@ -1095,13 +1176,13 @@ x-api-key: ${cfg.apiKey}
1095
1176
  }
1096
1177
  return { status, body: respBody };
1097
1178
  }
1098
- function apiJson(method, path, body) {
1179
+ function apiJson(method, path, body, options) {
1099
1180
  const raw = apiRequestRaw(method, path, body);
1100
1181
  if (raw.status >= 200 && raw.status < 300) {
1101
1182
  const data = raw.body.trim() ? JSON.parse(raw.body) : undefined;
1102
1183
  return { status: raw.status, data };
1103
1184
  }
1104
- if (raw.status === 404) {
1185
+ if (raw.status === 404 && options?.allow404) {
1105
1186
  return { status: 404, data: undefined };
1106
1187
  }
1107
1188
  let msg = `mementos cloud ${method} ${path} \u2192 ${raw.status}`;
@@ -1133,8 +1214,19 @@ function toQuery(params) {
1133
1214
  const s = sp.toString();
1134
1215
  return s ? `?${s}` : "";
1135
1216
  }
1136
- var ApiRequestError, DEFAULT_TIMEOUT_S = "45";
1217
+ var API_URL_ENV_KEYS, API_KEY_ENV_KEYS, DATABASE_URL_ENV_KEYS, ALLOW_REMOTE_API_IN_TESTS_ENV = "MEMENTOS_ALLOW_REMOTE_API_IN_TESTS", DB_PATH_ENV_KEYS, MementosStoreConfigError, ApiRequestError, DEFAULT_TIMEOUT_S = "45";
1137
1218
  var init_api_mode = __esm(() => {
1219
+ API_URL_ENV_KEYS = ["HASNA_MEMENTOS_API_URL", "MEMENTOS_API_URL"];
1220
+ API_KEY_ENV_KEYS = ["HASNA_MEMENTOS_API_KEY", "MEMENTOS_API_KEY"];
1221
+ DATABASE_URL_ENV_KEYS = ["HASNA_MEMENTOS_DATABASE_URL", "MEMENTOS_DATABASE_URL"];
1222
+ DB_PATH_ENV_KEYS = ["HASNA_MEMENTOS_DB_PATH", "MEMENTOS_DB_PATH"];
1223
+ MementosStoreConfigError = class MementosStoreConfigError extends Error {
1224
+ code = "MEMENTOS_STORE_CONFIG";
1225
+ constructor(message) {
1226
+ super(message);
1227
+ this.name = "MementosStoreConfigError";
1228
+ }
1229
+ };
1138
1230
  ApiRequestError = class ApiRequestError extends Error {
1139
1231
  status;
1140
1232
  body;
@@ -1148,7 +1240,24 @@ var init_api_mode = __esm(() => {
1148
1240
  });
1149
1241
 
1150
1242
  // src/db/migrations.ts
1151
- var MIGRATIONS;
1243
+ var MEMORY_VERSION_SNAPSHOT_TRIGGER = `
1244
+ CREATE TRIGGER IF NOT EXISTS memories_version_snapshot
1245
+ BEFORE UPDATE ON memories
1246
+ WHEN NEW.version > OLD.version
1247
+ BEGIN
1248
+ INSERT OR IGNORE INTO memory_versions (
1249
+ id, memory_id, version, value, importance, scope, category, tags,
1250
+ summary, pinned, status, when_to_use, created_at
1251
+ ) VALUES (
1252
+ lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' ||
1253
+ lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' ||
1254
+ lower(hex(randomblob(6))),
1255
+ OLD.id, OLD.version, OLD.value, OLD.importance, OLD.scope, OLD.category,
1256
+ OLD.tags, OLD.summary, OLD.pinned, OLD.status, OLD.when_to_use,
1257
+ OLD.updated_at
1258
+ );
1259
+ END;
1260
+ `, MIGRATIONS;
1152
1261
  var init_migrations = __esm(() => {
1153
1262
  MIGRATIONS = [
1154
1263
  `
@@ -2046,6 +2155,10 @@ CREATE INDEX IF NOT EXISTS idx_memory_reflection_lessons_memory ON memory_reflec
2046
2155
  CREATE INDEX IF NOT EXISTS idx_memory_reflection_lessons_kind ON memory_reflection_lessons(kind);
2047
2156
 
2048
2157
  INSERT OR IGNORE INTO _migrations (id) VALUES (35);
2158
+ `,
2159
+ `
2160
+ ${MEMORY_VERSION_SNAPSHOT_TRIGGER}
2161
+ INSERT OR IGNORE INTO _migrations (id) VALUES (36);
2049
2162
  `
2050
2163
  ];
2051
2164
  });
@@ -2060,6 +2173,7 @@ __export(exports_database, {
2060
2173
  now: () => now,
2061
2174
  getDbPath: () => getDbPath,
2062
2175
  getDatabase: () => getDatabase,
2176
+ escapeLikePrefix: () => escapeLikePrefix,
2063
2177
  closeDatabase: () => closeDatabase
2064
2178
  });
2065
2179
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, cpSync } from "fs";
@@ -2234,15 +2348,20 @@ function uuid() {
2234
2348
  function shortUuid() {
2235
2349
  return crypto.randomUUID().slice(0, 8);
2236
2350
  }
2351
+ function escapeLikePrefix(s) {
2352
+ return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
2353
+ }
2237
2354
  function resolvePartialId(db, table, partialId) {
2238
2355
  if (!ALLOWED_TABLES.has(table)) {
2239
2356
  throw new Error(`Invalid table name: ${table}`);
2240
2357
  }
2358
+ if (partialId === "")
2359
+ return null;
2241
2360
  if (partialId.length >= 36) {
2242
2361
  const row = db.query(`SELECT id FROM ${table} WHERE id = ?`).get(partialId);
2243
2362
  return row?.id ?? null;
2244
2363
  }
2245
- const rows = db.query(`SELECT id FROM ${table} WHERE id LIKE ?`).all(`${partialId}%`);
2364
+ const rows = db.query(`SELECT id FROM ${table} WHERE id LIKE ? ESCAPE '\\'`).all(`${escapeLikePrefix(partialId)}%`);
2246
2365
  if (rows.length === 1) {
2247
2366
  return rows[0].id;
2248
2367
  }
@@ -2379,6 +2498,41 @@ var init_redact = __esm(() => {
2379
2498
  ];
2380
2499
  });
2381
2500
 
2501
+ // src/lib/enum-validation.ts
2502
+ function formatEnumViolation(v) {
2503
+ return `Invalid ${v.field}: "${v.value}". Allowed values: ${v.allowed.join(", ")}.`;
2504
+ }
2505
+ function validateEnumField(field, value) {
2506
+ const allowed = ENUM_FIELDS[field];
2507
+ if (!allowed)
2508
+ return null;
2509
+ if (value === undefined || value === null || value === "")
2510
+ return null;
2511
+ if (typeof value === "string" && allowed.includes(value))
2512
+ return null;
2513
+ return { field, value: String(value), allowed };
2514
+ }
2515
+ function validateMemoryEnums(input) {
2516
+ for (const field of Object.keys(ENUM_FIELDS)) {
2517
+ if (!(field in input))
2518
+ continue;
2519
+ const violation = validateEnumField(field, input[field]);
2520
+ if (violation)
2521
+ return violation;
2522
+ }
2523
+ return null;
2524
+ }
2525
+ var ENUM_FIELDS;
2526
+ var init_enum_validation = __esm(() => {
2527
+ init_types();
2528
+ ENUM_FIELDS = {
2529
+ category: MEMORY_CATEGORIES,
2530
+ scope: MEMORY_SCOPES,
2531
+ source: MEMORY_SOURCES,
2532
+ status: MEMORY_STATUSES
2533
+ };
2534
+ });
2535
+
2382
2536
  // src/lib/hooks.ts
2383
2537
  function generateHookId() {
2384
2538
  return `hook_${++_idCounter}_${Date.now().toString(36)}`;
@@ -2692,7 +2846,10 @@ function parseMemoryRow(row) {
2692
2846
  }
2693
2847
  function createMemory(input, dedupeMode = "merge", db) {
2694
2848
  if (!db && isApiMode()) {
2695
- const { data } = apiJson("POST", "/memories", { ...input, dedupe: dedupeMode });
2849
+ const { status, data } = apiJson("POST", "/memories", { ...input, dedupe: dedupeMode });
2850
+ if (!data || !data.id) {
2851
+ throw new ApiRequestError(`mementos cloud POST /memories \u2192 ${status} but no memory was returned; the write did not persist (key: ${input.key})`, status, "");
2852
+ }
2696
2853
  return data;
2697
2854
  }
2698
2855
  const d = db || getDatabase();
@@ -2831,16 +2988,25 @@ function bulkUpsertMemories(memories, db) {
2831
2988
  const d = db || getDatabase();
2832
2989
  let inserted = 0;
2833
2990
  let skipped = 0;
2991
+ let rejected = 0;
2834
2992
  const errors = [];
2835
- const insert = d.prepare(`INSERT OR IGNORE INTO memories (id, key, value, category, scope, summary, tags, importance, source, status, pinned, agent_id, project_id, session_id, machine_id, namespace, created_by_agent, when_to_use, sequence_group, sequence_order, metadata, access_count, version, expires_at, valid_from, valid_until, ingested_at, created_at, updated_at)
2836
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2993
+ const insert = d.prepare(`INSERT INTO memories (id, key, value, category, scope, summary, tags, importance, source, status, pinned, agent_id, project_id, session_id, machine_id, namespace, created_by_agent, when_to_use, sequence_group, sequence_order, metadata, access_count, version, expires_at, valid_from, valid_until, ingested_at, created_at, updated_at)
2994
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2995
+ ON CONFLICT DO NOTHING`);
2837
2996
  const insertTag = d.prepare("INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)");
2838
2997
  for (const mem of memories) {
2839
2998
  const key = mem["key"];
2840
2999
  const id = mem["id"] || uuid();
2841
3000
  try {
2842
3001
  if (!key) {
2843
- errors.push(`skipped row without key (id=${id})`);
3002
+ rejected++;
3003
+ errors.push(`rejected row without key (id=${id})`);
3004
+ continue;
3005
+ }
3006
+ const violation = validateMemoryEnums(mem);
3007
+ if (violation) {
3008
+ rejected++;
3009
+ errors.push(`Rejected "${key}": ${formatEnumViolation(violation)}`);
2844
3010
  continue;
2845
3011
  }
2846
3012
  const timestamp = now();
@@ -2889,10 +3055,11 @@ function bulkUpsertMemories(memories, db) {
2889
3055
  skipped++;
2890
3056
  }
2891
3057
  } catch (e) {
3058
+ rejected++;
2892
3059
  errors.push(`Failed "${String(key)}": ${e instanceof Error ? e.message : String(e)}`);
2893
3060
  }
2894
3061
  }
2895
- return { inserted, skipped, errors, total: memories.length };
3062
+ return { inserted, skipped, rejected, errors, total: memories.length };
2896
3063
  }
2897
3064
  function ensureMemoryReferences(d, input) {
2898
3065
  const t = now();
@@ -2920,7 +3087,7 @@ function listMemoriesByKey(key, db) {
2920
3087
  }
2921
3088
  function getMemory(id, db) {
2922
3089
  if (!db && isApiMode()) {
2923
- const { status, data } = apiJson("GET", `/memories/${encodeURIComponent(id)}`);
3090
+ const { status, data } = apiJson("GET", `/memories/${encodeURIComponent(id)}`, undefined, { allow404: true });
2924
3091
  return status === 404 ? null : data ?? null;
2925
3092
  }
2926
3093
  const d = db || getDatabase();
@@ -3253,36 +3420,22 @@ function getMemoryEmbeddings(ids, db) {
3253
3420
  }
3254
3421
  function updateMemory(id, input, db) {
3255
3422
  if (!db && isApiMode()) {
3256
- const { status, data } = apiJson("PATCH", `/memories/${encodeURIComponent(id)}`, input);
3423
+ const { status, data } = apiJson("PATCH", `/memories/${encodeURIComponent(id)}`, input, { allow404: true });
3257
3424
  if (status === 404)
3258
3425
  throw new MemoryNotFoundError(id);
3426
+ if (data && typeof input.version === "number" && typeof data.version === "number" && data.version <= input.version) {
3427
+ throw new Error(`Update did not persist for memory ${id}: the server returned success but the record is unchanged ` + `(version still ${data.version}). Your data was NOT written. ` + `The server is likely running a build predating the partial-id fix \u2014 pass the full 36-character id as a workaround.`);
3428
+ }
3259
3429
  return data;
3260
3430
  }
3261
3431
  const d = db || getDatabase();
3262
3432
  const existing = getMemory(id, d);
3263
3433
  if (!existing)
3264
3434
  throw new MemoryNotFoundError(id);
3435
+ const memoryId = existing.id;
3265
3436
  if (existing.version !== input.version) {
3266
3437
  throw new VersionConflictError(id, input.version, existing.version);
3267
3438
  }
3268
- try {
3269
- d.run(`INSERT OR IGNORE INTO memory_versions (id, memory_id, version, value, importance, scope, category, tags, summary, pinned, status, when_to_use, created_at)
3270
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
3271
- uuid(),
3272
- existing.id,
3273
- existing.version,
3274
- existing.value,
3275
- existing.importance,
3276
- existing.scope,
3277
- existing.category,
3278
- JSON.stringify(existing.tags),
3279
- existing.summary,
3280
- existing.pinned ? 1 : 0,
3281
- existing.status,
3282
- existing.when_to_use || null,
3283
- existing.updated_at
3284
- ]);
3285
- } catch {}
3286
3439
  const sets = ["version = version + 1", "updated_at = ?"];
3287
3440
  const params = [now()];
3288
3441
  if (input.value !== undefined) {
@@ -3332,15 +3485,18 @@ function updateMemory(id, input, db) {
3332
3485
  if (input.tags !== undefined) {
3333
3486
  sets.push("tags = ?");
3334
3487
  params.push(JSON.stringify(input.tags));
3335
- d.run("DELETE FROM memory_tags WHERE memory_id = ?", [id]);
3488
+ d.run("DELETE FROM memory_tags WHERE memory_id = ?", [memoryId]);
3336
3489
  const insertTag = d.prepare("INSERT OR IGNORE INTO memory_tags (memory_id, tag) VALUES (?, ?)");
3337
3490
  for (const tag of input.tags) {
3338
- insertTag.run(id, tag);
3491
+ insertTag.run(memoryId, tag);
3339
3492
  }
3340
3493
  }
3341
- params.push(id);
3342
- d.run(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`, params);
3343
- const updated = getMemory(id, d);
3494
+ params.push(memoryId);
3495
+ const result = d.run(`UPDATE memories SET ${sets.join(", ")} WHERE id = ?`, params);
3496
+ if (result.changes === 0) {
3497
+ throw new Error(`Update affected no rows for memory ${memoryId}: the record was read but not written. ` + `This is a bug in @hasna/mementos, not a bad argument \u2014 please report it.`);
3498
+ }
3499
+ const updated = getMemory(memoryId, d);
3344
3500
  if (input.value !== undefined) {
3345
3501
  try {
3346
3502
  const oldLinks = getEntityMemoryLinks(undefined, updated.id, d);
@@ -3361,14 +3517,15 @@ function updateMemory(id, input, db) {
3361
3517
  }
3362
3518
  function deleteMemory(id, db) {
3363
3519
  if (!db && isApiMode()) {
3364
- const { status } = apiJson("DELETE", `/memories/${encodeURIComponent(id)}`);
3520
+ const { status } = apiJson("DELETE", `/memories/${encodeURIComponent(id)}`, undefined, { allow404: true });
3365
3521
  return status !== 404;
3366
3522
  }
3367
3523
  const d = db || getDatabase();
3368
- const result = d.run("DELETE FROM memories WHERE id = ?", [id]);
3524
+ const memoryId = resolvePartialId(d, "memories", id) ?? id;
3525
+ const result = d.run("DELETE FROM memories WHERE id = ?", [memoryId]);
3369
3526
  if (result.changes > 0) {
3370
3527
  hookRegistry.runHooks("PostMemoryDelete", {
3371
- memoryId: id,
3528
+ memoryId,
3372
3529
  timestamp: Date.now()
3373
3530
  });
3374
3531
  }
@@ -3382,11 +3539,12 @@ function bulkDeleteMemories(ids, db) {
3382
3539
  return data?.deleted ?? 0;
3383
3540
  }
3384
3541
  const d = db || getDatabase();
3385
- const placeholders = ids.map(() => "?").join(",");
3386
- const countRow = d.query(`SELECT COUNT(*) as c FROM memories WHERE id IN (${placeholders})`).get(...ids);
3542
+ const resolvedIds = ids.map((id) => resolvePartialId(d, "memories", id) ?? id);
3543
+ const placeholders = resolvedIds.map(() => "?").join(",");
3544
+ const countRow = d.query(`SELECT COUNT(*) as c FROM memories WHERE id IN (${placeholders})`).get(...resolvedIds);
3387
3545
  const count = countRow.c;
3388
3546
  if (count > 0) {
3389
- d.run(`DELETE FROM memories WHERE id IN (${placeholders})`, ids);
3547
+ d.run(`DELETE FROM memories WHERE id IN (${placeholders})`, resolvedIds);
3390
3548
  }
3391
3549
  return count;
3392
3550
  }
@@ -3513,6 +3671,7 @@ var init_memories = __esm(() => {
3513
3671
  init_types();
3514
3672
  init_database();
3515
3673
  init_redact();
3674
+ init_enum_validation();
3516
3675
  init_hooks();
3517
3676
  init_poisoning();
3518
3677
  init_entity_memories();
@@ -3660,7 +3819,7 @@ function createWebhookHook(input, db) {
3660
3819
  }
3661
3820
  function getWebhookHook(id, db) {
3662
3821
  if (!db && isApiMode()) {
3663
- const { status, data } = apiJson("GET", `/webhooks/${encodeURIComponent(id)}`);
3822
+ const { status, data } = apiJson("GET", `/webhooks/${encodeURIComponent(id)}`, undefined, { allow404: true });
3664
3823
  if (status === 404 || !data)
3665
3824
  return null;
3666
3825
  return data;
@@ -3696,7 +3855,7 @@ function updateWebhookHook(id, updates, db) {
3696
3855
  enabled: updates.enabled,
3697
3856
  priority: updates.priority,
3698
3857
  description: updates.description
3699
- });
3858
+ }, { allow404: true });
3700
3859
  if (status === 404 || !data)
3701
3860
  return null;
3702
3861
  return data;
@@ -3727,7 +3886,7 @@ function updateWebhookHook(id, updates, db) {
3727
3886
  }
3728
3887
  function deleteWebhookHook(id, db) {
3729
3888
  if (!db && isApiMode()) {
3730
- const { status } = apiJson("DELETE", `/webhooks/${encodeURIComponent(id)}`);
3889
+ const { status } = apiJson("DELETE", `/webhooks/${encodeURIComponent(id)}`, undefined, { allow404: true });
3731
3890
  return status === 204 || status === 200;
3732
3891
  }
3733
3892
  const d = db || getDatabase();
@@ -3825,7 +3984,7 @@ function createEntity(input, db) {
3825
3984
  }
3826
3985
  function getEntity(id, db) {
3827
3986
  if (!db && isApiMode()) {
3828
- const { status, data } = apiJson("GET", `/entities/${encodeURIComponent(id)}`);
3987
+ const { status, data } = apiJson("GET", `/entities/${encodeURIComponent(id)}`, undefined, { allow404: true });
3829
3988
  if (status === 404 || !data)
3830
3989
  throw new EntityNotFoundError(id);
3831
3990
  return data;
@@ -3908,7 +4067,7 @@ function listEntities(filter = {}, db) {
3908
4067
  }
3909
4068
  function updateEntity(id, input, db) {
3910
4069
  if (!db && isApiMode()) {
3911
- const { status, data } = apiJson("PATCH", `/entities/${encodeURIComponent(id)}`, input);
4070
+ const { status, data } = apiJson("PATCH", `/entities/${encodeURIComponent(id)}`, input, { allow404: true });
3912
4071
  if (status === 404 || !data)
3913
4072
  throw new EntityNotFoundError(id);
3914
4073
  return data;
@@ -3941,7 +4100,7 @@ function updateEntity(id, input, db) {
3941
4100
  }
3942
4101
  function deleteEntity(id, db) {
3943
4102
  if (!db && isApiMode()) {
3944
- const { status } = apiJson("DELETE", `/entities/${encodeURIComponent(id)}`);
4103
+ const { status } = apiJson("DELETE", `/entities/${encodeURIComponent(id)}`, undefined, { allow404: true });
3945
4104
  if (status === 404)
3946
4105
  throw new EntityNotFoundError(id);
3947
4106
  return;
@@ -4920,7 +5079,7 @@ function createRelation(input, db) {
4920
5079
  }
4921
5080
  function getRelation(id, db) {
4922
5081
  if (!db && isApiMode()) {
4923
- const { status, data } = apiJson("GET", `/relations/${encodeURIComponent(id)}`);
5082
+ const { status, data } = apiJson("GET", `/relations/${encodeURIComponent(id)}`, undefined, { allow404: true });
4924
5083
  if (status === 404 || !data)
4925
5084
  throw new Error(`Relation not found: ${id}`);
4926
5085
  return data;
@@ -4965,7 +5124,7 @@ function listRelations(filter, db) {
4965
5124
  }
4966
5125
  function deleteRelation(id, db) {
4967
5126
  if (!db && isApiMode()) {
4968
- const { status } = apiJson("DELETE", `/relations/${encodeURIComponent(id)}`);
5127
+ const { status } = apiJson("DELETE", `/relations/${encodeURIComponent(id)}`, undefined, { allow404: true });
4969
5128
  if (status === 404)
4970
5129
  throw new Error(`Relation not found: ${id}`);
4971
5130
  return;
@@ -5587,6 +5746,32 @@ class AutoMemoryQueue {
5587
5746
  getStats() {
5588
5747
  return { ...this.stats, pending: this.queue.length };
5589
5748
  }
5749
+ async waitForIdleForTests(timeoutMs = 3000) {
5750
+ const start = Date.now();
5751
+ while (Date.now() - start < timeoutMs) {
5752
+ if (this.queue.length === 0 && this.activeCount === 0)
5753
+ return;
5754
+ await new Promise((r) => setTimeout(r, 20));
5755
+ }
5756
+ throw new Error("autoMemoryQueue did not become idle before test reset");
5757
+ }
5758
+ resetForTests(handler) {
5759
+ if (this.activeCount !== 0) {
5760
+ throw new Error("Cannot reset autoMemoryQueue while jobs are processing");
5761
+ }
5762
+ this.queue = [];
5763
+ this.running = false;
5764
+ this.stats = {
5765
+ pending: 0,
5766
+ processing: 0,
5767
+ processed: 0,
5768
+ failed: 0,
5769
+ dropped: 0
5770
+ };
5771
+ if (handler !== undefined) {
5772
+ this.handler = handler;
5773
+ }
5774
+ }
5590
5775
  startLoop() {
5591
5776
  this.running = true;
5592
5777
  this.loop();
@@ -5632,6 +5817,7 @@ var init_auto_memory_queue = __esm(() => {
5632
5817
  // src/lib/auto-memory.ts
5633
5818
  var exports_auto_memory = {};
5634
5819
  __export(exports_auto_memory, {
5820
+ resetAutoMemoryForTests: () => resetAutoMemoryForTests,
5635
5821
  processConversationTurn: () => processConversationTurn,
5636
5822
  getAutoMemoryStats: () => getAutoMemoryStats,
5637
5823
  configureAutoMemory: () => configureAutoMemory
@@ -5792,6 +5978,12 @@ function getAutoMemoryStats() {
5792
5978
  function configureAutoMemory(config) {
5793
5979
  providerRegistry.configure(config);
5794
5980
  }
5981
+ async function resetAutoMemoryForTests() {
5982
+ if (autoMemoryQueue.getStats().processing > 0) {
5983
+ await autoMemoryQueue.waitForIdleForTests();
5984
+ }
5985
+ autoMemoryQueue.resetForTests(processJob);
5986
+ }
5795
5987
  var DEDUP_SIMILARITY_THRESHOLD = 0.85;
5796
5988
  var init_auto_memory = __esm(() => {
5797
5989
  init_memories();
@@ -6480,6 +6672,8 @@ var init_built_in_hooks = __esm(() => {
6480
6672
  priority: 100,
6481
6673
  description: "Trigger async LLM entity extraction when a memory is saved",
6482
6674
  handler: async (ctx) => {
6675
+ if (process.env["NODE_ENV"] === "test")
6676
+ return;
6483
6677
  if (ctx.wasUpdated)
6484
6678
  return;
6485
6679
  const processConversationTurn2 = await getAutoMemory();
@@ -11854,11 +12048,14 @@ __export(exports_gdpr, {
11854
12048
  });
11855
12049
  function gdprErase(identifier, options = {}, db) {
11856
12050
  const d = db || getDatabase();
12051
+ if (identifier.trim() === "") {
12052
+ throw new Error("GDPR erase requires a non-empty identifier: an empty or whitespace-only " + "identifier matches every memory and would redact the entire store");
12053
+ }
11857
12054
  const timestamp = now();
11858
12055
  const conditions = [
11859
- "(key LIKE ? OR value LIKE ? OR summary LIKE ? OR tags LIKE ? OR metadata LIKE ?)"
12056
+ "(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR metadata LIKE ? ESCAPE '\\')"
11860
12057
  ];
11861
- const searchParam = `%${identifier}%`;
12058
+ const searchParam = `%${escapeLikePrefix(identifier)}%`;
11862
12059
  const params = [searchParam, searchParam, searchParam, searchParam, searchParam];
11863
12060
  if (options.project_id) {
11864
12061
  conditions.push("project_id = ?");
@@ -12780,6 +12977,31 @@ var init_pg_migrations = __esm(() => {
12780
12977
  );
12781
12978
  CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
12782
12979
  CREATE INDEX IF NOT EXISTS idx_task_comments_agent ON task_comments(agent_id);
12980
+ `,
12981
+ `
12982
+ CREATE OR REPLACE FUNCTION snapshot_memory_version() RETURNS trigger AS $$
12983
+ BEGIN
12984
+ INSERT INTO memory_versions (
12985
+ id, memory_id, version, value, importance, scope, category, tags,
12986
+ summary, pinned, status, when_to_use, created_at
12987
+ ) VALUES (
12988
+ gen_random_uuid()::text,
12989
+ OLD.id, OLD.version, OLD.value, OLD.importance, OLD.scope, OLD.category,
12990
+ OLD.tags, OLD.summary, OLD.pinned, OLD.status, OLD.when_to_use,
12991
+ OLD.updated_at
12992
+ ) ON CONFLICT DO NOTHING;
12993
+ RETURN NEW;
12994
+ END;
12995
+ $$ LANGUAGE plpgsql;
12996
+
12997
+ DROP TRIGGER IF EXISTS memories_version_snapshot ON memories;
12998
+ CREATE TRIGGER memories_version_snapshot
12999
+ BEFORE UPDATE ON memories
13000
+ FOR EACH ROW
13001
+ WHEN (NEW.version > OLD.version)
13002
+ EXECUTE FUNCTION snapshot_memory_version();
13003
+
13004
+ INSERT INTO _migrations (id) VALUES (36) ON CONFLICT DO NOTHING;
12783
13005
  `
12784
13006
  ];
12785
13007
  });
@@ -55581,7 +55803,7 @@ function registerAgent(name, sessionId, description, role, projectId, db) {
55581
55803
  }
55582
55804
  function getAgent(idOrName, db) {
55583
55805
  if (!db && isApiMode()) {
55584
- const { status, data } = apiJson("GET", `/agents/${encodeURIComponent(idOrName)}`);
55806
+ const { status, data } = apiJson("GET", `/agents/${encodeURIComponent(idOrName)}`, undefined, { allow404: true });
55585
55807
  if (status === 404 || !data)
55586
55808
  return null;
55587
55809
  return data;
@@ -55593,7 +55815,7 @@ function getAgent(idOrName, db) {
55593
55815
  row = d.query("SELECT * FROM agents WHERE LOWER(name) = ?").get(idOrName.trim().toLowerCase());
55594
55816
  if (row)
55595
55817
  return parseAgentRow(row);
55596
- const rows = d.query("SELECT * FROM agents WHERE id LIKE ?").all(`${idOrName}%`);
55818
+ const rows = d.query("SELECT * FROM agents WHERE id LIKE ? ESCAPE '\\'").all(`${escapeLikePrefix(idOrName)}%`);
55597
55819
  if (rows.length === 1)
55598
55820
  return parseAgentRow(rows[0]);
55599
55821
  return null;
@@ -55634,7 +55856,7 @@ function listAgentsByProject(projectId, db) {
55634
55856
  }
55635
55857
  function updateAgent(id, updates, db) {
55636
55858
  if (!db && isApiMode()) {
55637
- const { status, data } = apiJson("PATCH", `/agents/${encodeURIComponent(id)}`, updates);
55859
+ const { status, data } = apiJson("PATCH", `/agents/${encodeURIComponent(id)}`, updates, { allow404: true });
55638
55860
  if (status === 404 || !data)
55639
55861
  return null;
55640
55862
  return data;
@@ -55719,7 +55941,7 @@ function registerProject(name, path, description, memoryPrefix, db) {
55719
55941
  }
55720
55942
  function getProject(idOrPath, db) {
55721
55943
  if (!db && isApiMode()) {
55722
- const { status, data } = apiJson("GET", `/projects/${encodeURIComponent(idOrPath)}`);
55944
+ const { status, data } = apiJson("GET", `/projects/${encodeURIComponent(idOrPath)}`, undefined, { allow404: true });
55723
55945
  if (status === 404 || !data)
55724
55946
  return null;
55725
55947
  return data;
@@ -58856,7 +59078,7 @@ function createSubscription(input, db) {
58856
59078
  }
58857
59079
  function deleteSubscription(id, db) {
58858
59080
  if (!db && isApiMode()) {
58859
- const { status } = apiJson("DELETE", `/subscriptions/${encodeURIComponent(id)}`);
59081
+ const { status } = apiJson("DELETE", `/subscriptions/${encodeURIComponent(id)}`, undefined, { allow404: true });
58860
59082
  return status !== 404;
58861
59083
  }
58862
59084
  const d = db || getDatabase();
@@ -60772,7 +60994,7 @@ function acquireLock(agentId, resourceType, resourceId, lockType = "exclusive",
60772
60994
  }
60773
60995
  function releaseLock(lockId, agentId, db) {
60774
60996
  if (!db && isApiMode()) {
60775
- const { status } = apiJson("DELETE", `/locks/${encodeURIComponent(lockId)}`, { agent_id: agentId });
60997
+ const { status } = apiJson("DELETE", `/locks/${encodeURIComponent(lockId)}`, { agent_id: agentId }, { allow404: true });
60776
60998
  return status !== 404;
60777
60999
  }
60778
61000
  const d = db || getDatabase();
@@ -62517,7 +62739,7 @@ function createSessionJob(input, db) {
62517
62739
  }
62518
62740
  function getSessionJob(id, db) {
62519
62741
  if (!db && isApiMode()) {
62520
- const { status, data } = apiJson("GET", `/sessions/jobs/${encodeURIComponent(id)}`);
62742
+ const { status, data } = apiJson("GET", `/sessions/jobs/${encodeURIComponent(id)}`, undefined, { allow404: true });
62521
62743
  if (status === 404 || !data)
62522
62744
  return null;
62523
62745
  return data;
@@ -63645,7 +63867,7 @@ ${lines.join(`
63645
63867
  }
63646
63868
  });
63647
63869
  server.tool("memory_gdpr_erase", "GDPR right to be forgotten: erase all memories containing a PII identifier. Replaces content with [REDACTED], preserves anonymized audit trail. IRREVERSIBLE.", {
63648
- identifier: z.string().describe("PII to search for and erase (name, email, etc.)"),
63870
+ identifier: z.string().min(1).describe("PII to search for and erase (name, email, etc.)"),
63649
63871
  project_id: z.string().optional(),
63650
63872
  dry_run: z.boolean().optional().describe("Preview what would be erased without actually erasing (default: false)")
63651
63873
  }, async (args) => {
@@ -65310,11 +65532,12 @@ function hasFlag(...flags) {
65310
65532
  function printHelp() {
65311
65533
  process.stdout.write(`Usage: mementos-mcp [options]
65312
65534
 
65313
- Mementos MCP server (stdio transport by default)
65535
+ Mementos MCP server (Streamable HTTP transport by default)
65314
65536
 
65315
65537
  Options:
65316
- --http Serve MCP over Streamable HTTP (127.0.0.1)
65317
- --port <number> HTTP port (default: 8824, env: MCP_HTTP_PORT)
65538
+ --http Serve MCP over Streamable HTTP (default, 127.0.0.1)
65539
+ --stdio Serve MCP over stdio (env: MCP_STDIO=1)
65540
+ --port <number> HTTP port (default: 8867, env: MCP_HTTP_PORT)
65318
65541
  -h, --help Show help
65319
65542
  -V, --version Show version
65320
65543
  `);