@nuzo/memory-core 0.1.0 → 0.1.1

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/dist/policy.d.ts CHANGED
@@ -1,12 +1,33 @@
1
1
  import type { PolicyEngine, SecretScanner } from "./ports.js";
2
+ import { type MemoryScope } from "./types.js";
2
3
  import type { ListMemoriesInput, MemoryRecord, RecallMemoriesInput, RememberMemoryInput, UpdateMemoryInput } from "./types.js";
3
4
  export declare const memoryScopePattern: RegExp;
4
5
  export declare const memoryTagPattern: RegExp;
6
+ export declare const memoryLimits: {
7
+ readonly actorLength: 256;
8
+ readonly contentLength: 8000;
9
+ readonly dateLength: 64;
10
+ readonly identifierLength: 256;
11
+ readonly importItems: 1000;
12
+ readonly queryLength: 2000;
13
+ readonly reasonLength: 1000;
14
+ readonly scopeLength: 256;
15
+ readonly sourceLength: 256;
16
+ readonly tags: 32;
17
+ };
18
+ export interface DefaultPolicyEngineOptions {
19
+ allowedScopes?: readonly MemoryScope[];
20
+ }
5
21
  export declare class DefaultPolicyEngine implements PolicyEngine {
6
22
  private readonly secretScanner;
7
- constructor(secretScanner: SecretScanner);
23
+ private readonly allowedScopes;
24
+ constructor(secretScanner: SecretScanner, options?: DefaultPolicyEngineOptions);
8
25
  assertCanRemember(input: RememberMemoryInput): Promise<void>;
9
26
  assertCanUpdate(input: UpdateMemoryInput, current: MemoryRecord): Promise<void>;
27
+ assertCanForget(_input: {
28
+ id: string;
29
+ }, current: MemoryRecord): Promise<void>;
10
30
  assertCanRecall(input: RecallMemoriesInput): Promise<void>;
11
31
  assertCanList(input: ListMemoriesInput): Promise<void>;
32
+ private assertScopeAllowed;
12
33
  }
package/dist/policy.js CHANGED
@@ -1,21 +1,41 @@
1
- import { invariant } from "./errors.js";
1
+ import { invariant, NuzoMemoryError } from "./errors.js";
2
2
  import { memoryKinds } from "./types.js";
3
3
  export const memoryScopePattern = /^(user|project|agent|team):[A-Za-z0-9._~:/-]+$/;
4
4
  export const memoryTagPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/;
5
+ export const memoryLimits = {
6
+ actorLength: 256,
7
+ contentLength: 8000,
8
+ dateLength: 64,
9
+ identifierLength: 256,
10
+ importItems: 1000,
11
+ queryLength: 2000,
12
+ reasonLength: 1000,
13
+ scopeLength: 256,
14
+ sourceLength: 256,
15
+ tags: 32,
16
+ };
5
17
  export class DefaultPolicyEngine {
6
18
  secretScanner;
7
- constructor(secretScanner) {
19
+ allowedScopes;
20
+ constructor(secretScanner, options = {}) {
8
21
  this.secretScanner = secretScanner;
22
+ this.allowedScopes = options.allowedScopes === undefined
23
+ ? null
24
+ : new Set(options.allowedScopes);
9
25
  }
10
26
  async assertCanRemember(input) {
11
27
  invariant(input.content.trim().length > 0, "MEMORY_CONTENT_EMPTY", "Memory content cannot be empty.");
12
- invariant(input.content.length <= 8000, "MEMORY_CONTENT_TOO_LONG", "Memory content is too long.", { maxLength: 8000 });
28
+ invariant(input.content.length <= memoryLimits.contentLength, "MEMORY_CONTENT_TOO_LONG", "Memory content is too long.", { maxLength: memoryLimits.contentLength });
13
29
  invariant(memoryKinds.includes(input.kind), "MEMORY_KIND_UNSUPPORTED", "Memory kind is not supported.", { kind: input.kind });
14
30
  assertScope(input.scope);
31
+ this.assertScopeAllowed(input.scope);
15
32
  invariant(input.source.trim().length > 0, "MEMORY_SOURCE_EMPTY", "Memory source cannot be empty.");
33
+ invariant(input.source.length <= memoryLimits.sourceLength, "MEMORY_SOURCE_TOO_LONG", "Memory source is too long.", { maxLength: memoryLimits.sourceLength });
16
34
  const confidence = input.confidence ?? 1;
17
35
  invariant(confidence >= 0 && confidence <= 1, "MEMORY_CONFIDENCE_INVALID", "Memory confidence must be between 0 and 1.", { confidence });
18
- for (const tag of input.tags ?? []) {
36
+ const tags = input.tags ?? [];
37
+ invariant(tags.length <= memoryLimits.tags, "MEMORY_TAG_LIMIT_EXCEEDED", "Memory has too many tags.", { maxTags: memoryLimits.tags });
38
+ for (const tag of tags) {
19
39
  assertTag(tag);
20
40
  }
21
41
  const secretScan = await this.secretScanner.scan(input.content);
@@ -24,6 +44,7 @@ export class DefaultPolicyEngine {
24
44
  });
25
45
  }
26
46
  async assertCanUpdate(input, current) {
47
+ this.assertScopeAllowed(current.scope);
27
48
  await this.assertCanRemember({
28
49
  content: input.content ?? current.content,
29
50
  kind: input.kind ?? current.kind,
@@ -34,9 +55,17 @@ export class DefaultPolicyEngine {
34
55
  });
35
56
  invariant(input.actor.trim().length > 0, "MEMORY_ACTOR_EMPTY", "Memory actor cannot be empty.");
36
57
  }
58
+ async assertCanForget(_input, current) {
59
+ this.assertScopeAllowed(current.scope);
60
+ }
37
61
  async assertCanRecall(input) {
38
62
  invariant(input.query.trim().length > 0, "RECALL_QUERY_EMPTY", "Recall query cannot be empty.");
63
+ invariant(input.query.length <= memoryLimits.queryLength, "RECALL_QUERY_TOO_LONG", "Recall query is too long.", { maxLength: memoryLimits.queryLength });
39
64
  assertScope(input.scope);
65
+ this.assertScopeAllowed(input.scope);
66
+ if (input.includeGlobal === true) {
67
+ this.assertScopeAllowed("user:default");
68
+ }
40
69
  const limit = input.limit ?? 8;
41
70
  invariant(limit > 0 && limit <= 50, "RECALL_LIMIT_INVALID", "Recall limit must be 1-50.", {
42
71
  limit,
@@ -45,16 +74,30 @@ export class DefaultPolicyEngine {
45
74
  async assertCanList(input) {
46
75
  if (input.scope !== undefined) {
47
76
  assertScope(input.scope);
77
+ this.assertScopeAllowed(input.scope);
48
78
  }
49
- for (const tag of input.tags ?? []) {
79
+ else if (this.allowedScopes !== null) {
80
+ throw new NuzoMemoryError("MEMORY_SCOPE_REQUIRED", "A scope is required for this restricted memory session.");
81
+ }
82
+ const tags = input.tags ?? [];
83
+ invariant(tags.length <= memoryLimits.tags, "MEMORY_TAG_LIMIT_EXCEEDED", "Memory filter has too many tags.", { maxTags: memoryLimits.tags });
84
+ for (const tag of tags) {
50
85
  assertTag(tag);
51
86
  }
52
87
  }
88
+ assertScopeAllowed(scope) {
89
+ if (this.allowedScopes === null) {
90
+ return;
91
+ }
92
+ if (!this.allowedScopes.has(scope)) {
93
+ throw new NuzoMemoryError("MEMORY_SCOPE_FORBIDDEN", "Memory scope is not authorized.", {
94
+ scope,
95
+ });
96
+ }
97
+ }
53
98
  }
54
99
  function assertScope(scope) {
55
- invariant(memoryScopePattern.test(scope), "MEMORY_SCOPE_INVALID", "Memory scope is invalid.", {
56
- scope,
57
- });
100
+ invariant(scope.length <= memoryLimits.scopeLength && memoryScopePattern.test(scope), "MEMORY_SCOPE_INVALID", "Memory scope is invalid.", { scope, maxLength: memoryLimits.scopeLength });
58
101
  }
59
102
  function assertTag(tag) {
60
103
  invariant(memoryTagPattern.test(tag), "MEMORY_TAG_INVALID", "Memory tag is invalid.", {
package/dist/ports.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { ListMemoriesInput, MemoryEvent, MemoryRecord, RecallMemoriesInput, RecallMemoryResult, RememberMemoryInput, UpdateMemoryInput } from "./types.js";
2
2
  export interface MemoryStore {
3
3
  create(memory: MemoryRecord): Promise<void>;
4
- update(memory: MemoryRecord): Promise<void>;
4
+ update(memory: MemoryRecord, expectedRevision?: number): Promise<boolean>;
5
5
  findById(id: string): Promise<MemoryRecord | null>;
6
6
  list(filter: ListMemoriesInput): Promise<MemoryRecord[]>;
7
- archive(id: string, archivedAt: Date): Promise<void>;
8
- delete(id: string): Promise<void>;
7
+ archive(id: string, archivedAt: Date, expectedRevision?: number): Promise<boolean>;
8
+ delete(id: string, expectedRevision?: number): Promise<boolean>;
9
9
  }
10
10
  export interface SearchIndex {
11
11
  index(memory: MemoryRecord): Promise<void>;
@@ -40,6 +40,9 @@ export interface SecretFinding {
40
40
  export interface PolicyEngine {
41
41
  assertCanRemember(input: RememberMemoryInput): Promise<void>;
42
42
  assertCanUpdate(input: UpdateMemoryInput, current: MemoryRecord): Promise<void>;
43
+ assertCanForget(input: {
44
+ id: string;
45
+ }, current: MemoryRecord): Promise<void>;
43
46
  assertCanRecall(input: RecallMemoriesInput): Promise<void>;
44
47
  assertCanList(input: ListMemoriesInput): Promise<void>;
45
48
  }
package/dist/secrets.js CHANGED
@@ -9,6 +9,11 @@ const patterns = [
9
9
  regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/,
10
10
  message: "GitHub tokens should not be stored as memory.",
11
11
  },
12
+ {
13
+ kind: "npm_token",
14
+ regex: /\bnpm_[A-Za-z0-9]{20,}\b/,
15
+ message: "npm access tokens should not be stored as memory.",
16
+ },
12
17
  {
13
18
  kind: "provider_api_key",
14
19
  regex: /\b(?:sk-(?:proj-|ant-[A-Za-z0-9-]+-)?[A-Za-z0-9_-]{20,}|sk_live_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{30,})\b/,
package/dist/service.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AuditLog, Clock, IdGenerator, MemoryStore, PolicyEngine, SearchIndex, TransactionManager } from "./ports.js";
2
- import type { ExportMemoriesInput, ForgetMemoryInput, ForgetMemoriesInput, ForgetMemoriesResult, ImportMemoriesInput, ImportMemoriesResult, ListMemoriesInput, MemoryExportDocument, MemoryEvent, MemoryRecord, RecallMemoriesInput, RecallMemoryResult, RememberMemoryInput, UpdateMemoryInput } from "./types.js";
2
+ import type { CaptureSuggestionResult, ExportMemoriesInput, ForgetMemoryInput, ForgetMemoriesInput, ForgetMemoriesResult, ImportMemoriesInput, ImportMemoriesResult, ListMemoriesInput, MemoryExportDocument, MemoryEvent, MemoryRecord, RecallMemoriesInput, RecallMemoryResult, RememberMemoryInput, SuggestCaptureInput, UpdateMemoryInput } from "./types.js";
3
3
  export interface MemoryServiceDependencies {
4
4
  store: MemoryStore;
5
5
  searchIndex: SearchIndex;
@@ -10,6 +10,7 @@ export interface MemoryServiceDependencies {
10
10
  transactions?: TransactionManager;
11
11
  }
12
12
  export interface MemoryService {
13
+ suggestCapture(input: SuggestCaptureInput): Promise<CaptureSuggestionResult>;
13
14
  remember(input: RememberMemoryInput): Promise<MemoryRecord>;
14
15
  recall(input: RecallMemoriesInput): Promise<RecallMemoryResult[]>;
15
16
  list(input?: ListMemoriesInput): Promise<MemoryRecord[]>;
package/dist/service.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { NuzoMemoryError } from "./errors.js";
2
+ import { memoryLimits } from "./policy.js";
2
3
  import { memoryKinds } from "./types.js";
3
4
  export function createMemoryService(dependencies) {
4
5
  const { auditLog, clock, ids, policy, searchIndex, store, transactions } = dependencies;
@@ -6,11 +7,15 @@ export function createMemoryService(dependencies) {
6
7
  ? (operation) => transactions.run(operation)
7
8
  : (operation) => operation();
8
9
  async function forgetMemory(input) {
10
+ assertMemoryId(input.id);
9
11
  assertActor(input.actor);
12
+ assertReason(input.reason);
10
13
  const memory = await store.findById(input.id);
11
14
  if (!memory) {
12
15
  throw new NuzoMemoryError("MEMORY_NOT_FOUND", "Memory was not found.", { id: input.id });
13
16
  }
17
+ assertExpectedRevision(input.expectedRevision, memory);
18
+ await policy.assertCanForget(input, memory);
14
19
  const mode = input.mode ?? "archive";
15
20
  const now = clock.now();
16
21
  if (mode === "delete") {
@@ -18,7 +23,8 @@ export function createMemoryService(dependencies) {
18
23
  throw new NuzoMemoryError("MEMORY_DELETE_CONFIRMATION_REQUIRED", "Hard delete requires explicit confirmation.", { id: input.id });
19
24
  }
20
25
  await runTransaction(async () => {
21
- await store.delete(input.id);
26
+ const deleted = await store.delete(input.id, memory.revision);
27
+ assertRevisionCommitted(deleted, input.id, memory.revision);
22
28
  await searchIndex.remove(input.id);
23
29
  await auditLog.append({
24
30
  id: ids.eventId(),
@@ -32,7 +38,8 @@ export function createMemoryService(dependencies) {
32
38
  return;
33
39
  }
34
40
  await runTransaction(async () => {
35
- await store.archive(input.id, now);
41
+ const archived = await store.archive(input.id, now, memory.revision);
42
+ assertRevisionCommitted(archived, input.id, memory.revision);
36
43
  await searchIndex.remove(input.id);
37
44
  await auditLog.append({
38
45
  id: ids.eventId(),
@@ -45,11 +52,36 @@ export function createMemoryService(dependencies) {
45
52
  });
46
53
  }
47
54
  return {
55
+ async suggestCapture(input) {
56
+ assertCaptureReason(input.reason);
57
+ await policy.assertCanRemember(input);
58
+ const draft = {
59
+ content: input.content.trim(),
60
+ kind: input.kind,
61
+ scope: input.scope,
62
+ tags: [...new Set(input.tags ?? [])],
63
+ source: input.source,
64
+ confidence: input.confidence ?? 1,
65
+ reason: input.reason.trim(),
66
+ };
67
+ const duplicateKey = toCaptureDuplicateKey(draft.content);
68
+ const memories = await store.list({ scope: draft.scope });
69
+ const duplicate = memories.find((memory) => (memory.archivedAt === null &&
70
+ toCaptureDuplicateKey(memory.content) === duplicateKey)) ?? null;
71
+ return {
72
+ status: duplicate ? "duplicate" : "ready",
73
+ memoryWrites: false,
74
+ requiresConfirmation: true,
75
+ draft,
76
+ duplicate,
77
+ };
78
+ },
48
79
  async remember(input) {
49
80
  await policy.assertCanRemember(input);
50
81
  const now = clock.now();
51
82
  const memory = {
52
83
  id: ids.memoryId(),
84
+ revision: 1,
53
85
  scope: input.scope,
54
86
  kind: input.kind,
55
87
  content: input.content.trim(),
@@ -81,19 +113,26 @@ export function createMemoryService(dependencies) {
81
113
  ...input,
82
114
  limit: input.limit ?? 8,
83
115
  });
84
- if (input.recordUsage === false) {
116
+ if (input.recordUsage !== true) {
85
117
  return results;
86
118
  }
87
119
  const now = clock.now();
88
120
  await runTransaction(async () => {
89
121
  for (const result of results) {
90
- await store.update({
91
- ...result.memory,
122
+ const current = await store.findById(result.memory.id);
123
+ if (!current || current.archivedAt !== null) {
124
+ continue;
125
+ }
126
+ const updated = {
127
+ ...current,
128
+ revision: current.revision + 1,
92
129
  lastUsedAt: now,
93
- });
130
+ };
131
+ const committed = await store.update(updated, current.revision);
132
+ assertRevisionCommitted(committed, current.id, current.revision);
94
133
  await auditLog.append({
95
134
  id: ids.eventId(),
96
- memoryId: result.memory.id,
135
+ memoryId: current.id,
97
136
  eventType: "memory.recalled",
98
137
  actor: "core",
99
138
  payload: { query: input.query, score: result.score },
@@ -108,10 +147,12 @@ export function createMemoryService(dependencies) {
108
147
  return store.list(input);
109
148
  },
110
149
  async update(input) {
150
+ assertMemoryId(input.id);
111
151
  const current = await store.findById(input.id);
112
152
  if (!current) {
113
153
  throw new NuzoMemoryError("MEMORY_NOT_FOUND", "Memory was not found.", { id: input.id });
114
154
  }
155
+ assertExpectedRevision(input.expectedRevision, current);
115
156
  const hasChanges = input.content !== undefined ||
116
157
  input.kind !== undefined ||
117
158
  input.scope !== undefined ||
@@ -125,6 +166,7 @@ export function createMemoryService(dependencies) {
125
166
  await policy.assertCanUpdate(input, current);
126
167
  const updated = {
127
168
  ...current,
169
+ revision: current.revision + 1,
128
170
  content: input.content?.trim() ?? current.content,
129
171
  kind: input.kind ?? current.kind,
130
172
  scope: input.scope ?? current.scope,
@@ -133,7 +175,8 @@ export function createMemoryService(dependencies) {
133
175
  updatedAt: clock.now(),
134
176
  };
135
177
  await runTransaction(async () => {
136
- await store.update(updated);
178
+ const committed = await store.update(updated, current.revision);
179
+ assertRevisionCommitted(committed, input.id, current.revision);
137
180
  await searchIndex.index(updated);
138
181
  await auditLog.append({
139
182
  id: ids.eventId(),
@@ -155,9 +198,7 @@ export function createMemoryService(dependencies) {
155
198
  return updated;
156
199
  },
157
200
  async history(memoryId) {
158
- if (memoryId.trim().length === 0) {
159
- throw new NuzoMemoryError("MEMORY_ID_EMPTY", "Memory ID cannot be empty.");
160
- }
201
+ assertMemoryId(memoryId);
161
202
  return auditLog.list(memoryId);
162
203
  },
163
204
  async exportMemories(input) {
@@ -188,9 +229,6 @@ export function createMemoryService(dependencies) {
188
229
  async importMemories(input) {
189
230
  assertActor(input.actor);
190
231
  assertExportDocument(input.document);
191
- const planned = [];
192
- const duplicateKeysByScope = new Map();
193
- let skipped = 0;
194
232
  for (const item of input.document.memories) {
195
233
  const scope = input.scope ?? item.scope;
196
234
  await policy.assertCanRemember({
@@ -201,37 +239,53 @@ export function createMemoryService(dependencies) {
201
239
  source: item.source,
202
240
  confidence: item.confidence,
203
241
  });
204
- const tags = [...new Set(item.tags)];
205
- let duplicateKeys = duplicateKeysByScope.get(scope);
206
- if (!duplicateKeys) {
207
- const existing = await store.list({ scope, includeArchived: true });
208
- duplicateKeys = new Set(existing.map(toImportDuplicateKey));
209
- duplicateKeysByScope.set(scope, duplicateKeys);
210
- }
211
- const duplicateKey = toImportDuplicateKey({
212
- scope,
213
- kind: item.kind,
214
- content: item.content,
215
- tags,
216
- });
217
- if (duplicateKeys.has(duplicateKey)) {
218
- skipped += 1;
219
- continue;
220
- }
221
- duplicateKeys.add(duplicateKey);
222
- planned.push({ item, scope, tags });
223
242
  }
243
+ const planImport = async () => {
244
+ const planned = [];
245
+ const duplicateKeysByScope = new Map();
246
+ let skipped = 0;
247
+ for (const item of input.document.memories) {
248
+ const scope = input.scope ?? item.scope;
249
+ const tags = [...new Set(item.tags)];
250
+ let duplicateKeys = duplicateKeysByScope.get(scope);
251
+ if (!duplicateKeys) {
252
+ const existing = await store.list({ scope, includeArchived: true });
253
+ duplicateKeys = new Set(existing.map(toImportDuplicateKey));
254
+ duplicateKeysByScope.set(scope, duplicateKeys);
255
+ }
256
+ const duplicateKey = toImportDuplicateKey({
257
+ scope,
258
+ kind: item.kind,
259
+ content: item.content,
260
+ tags,
261
+ });
262
+ if (duplicateKeys.has(duplicateKey)) {
263
+ skipped += 1;
264
+ continue;
265
+ }
266
+ duplicateKeys.add(duplicateKey);
267
+ planned.push({ item, scope, tags });
268
+ }
269
+ return { planned, skipped };
270
+ };
224
271
  if (input.dryRun === true) {
272
+ const { planned, skipped } = await planImport();
225
273
  return {
226
274
  imported: planned.length,
227
275
  skipped,
228
276
  dryRun: true,
229
277
  };
230
278
  }
279
+ let imported = 0;
280
+ let skipped = 0;
231
281
  await runTransaction(async () => {
232
- for (const { item, scope, tags } of planned) {
282
+ const plan = await planImport();
283
+ imported = plan.planned.length;
284
+ skipped = plan.skipped;
285
+ for (const { item, scope, tags } of plan.planned) {
233
286
  const memory = {
234
287
  id: ids.memoryId(),
288
+ revision: 1,
235
289
  scope,
236
290
  kind: item.kind,
237
291
  content: item.content.trim(),
@@ -260,7 +314,7 @@ export function createMemoryService(dependencies) {
260
314
  }
261
315
  });
262
316
  return {
263
- imported: planned.length,
317
+ imported,
264
318
  skipped,
265
319
  dryRun: false,
266
320
  };
@@ -277,6 +331,7 @@ export function createMemoryService(dependencies) {
277
331
  throw new NuzoMemoryError("MEMORY_BULK_SELECTOR_CONFLICT", "Bulk forget all cannot be combined with scope or tags.");
278
332
  }
279
333
  assertActor(input.actor);
334
+ assertReason(input.reason);
280
335
  await policy.assertCanList({
281
336
  ...(input.scope === undefined ? {} : { scope: input.scope }),
282
337
  ...(input.tags === undefined ? {} : { tags: input.tags }),
@@ -299,6 +354,7 @@ export function createMemoryService(dependencies) {
299
354
  for (const memory of memories) {
300
355
  const forgetInput = {
301
356
  id: memory.id,
357
+ expectedRevision: memory.revision,
302
358
  mode,
303
359
  actor: input.actor,
304
360
  };
@@ -321,10 +377,63 @@ export function createMemoryService(dependencies) {
321
377
  },
322
378
  };
323
379
  }
380
+ function assertExpectedRevision(expectedRevision, memory) {
381
+ if (expectedRevision === undefined) {
382
+ return;
383
+ }
384
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
385
+ throw new NuzoMemoryError("MEMORY_REVISION_INVALID", "Memory revision is invalid.", {
386
+ expectedRevision,
387
+ });
388
+ }
389
+ if (memory.revision !== expectedRevision) {
390
+ throw new NuzoMemoryError("MEMORY_REVISION_CONFLICT", "Memory changed before this operation could commit.", {
391
+ id: memory.id,
392
+ expectedRevision,
393
+ currentRevision: memory.revision,
394
+ });
395
+ }
396
+ }
397
+ function assertRevisionCommitted(committed, id, expectedRevision) {
398
+ if (!committed) {
399
+ throw new NuzoMemoryError("MEMORY_REVISION_CONFLICT", "Memory changed before this operation could commit.", {
400
+ id,
401
+ expectedRevision,
402
+ });
403
+ }
404
+ }
324
405
  function assertActor(actor) {
325
406
  if (actor.trim().length === 0) {
326
407
  throw new NuzoMemoryError("MEMORY_ACTOR_EMPTY", "Memory actor cannot be empty.");
327
408
  }
409
+ if (actor.length > memoryLimits.actorLength) {
410
+ throw new NuzoMemoryError("MEMORY_ACTOR_INVALID", "Memory actor is too long.", {
411
+ maxLength: memoryLimits.actorLength,
412
+ });
413
+ }
414
+ }
415
+ function assertMemoryId(memoryId) {
416
+ if (memoryId.trim().length === 0) {
417
+ throw new NuzoMemoryError("MEMORY_ID_EMPTY", "Memory ID cannot be empty.");
418
+ }
419
+ if (memoryId.length > memoryLimits.identifierLength) {
420
+ throw new NuzoMemoryError("MEMORY_ID_INVALID", "Memory ID is too long.", {
421
+ maxLength: memoryLimits.identifierLength,
422
+ });
423
+ }
424
+ }
425
+ function assertReason(reason) {
426
+ if (reason !== undefined && reason.length > memoryLimits.reasonLength) {
427
+ throw new NuzoMemoryError("MEMORY_REASON_TOO_LONG", "Memory reason is too long.", {
428
+ maxLength: memoryLimits.reasonLength,
429
+ });
430
+ }
431
+ }
432
+ function assertCaptureReason(reason) {
433
+ if (reason.trim().length === 0) {
434
+ throw new NuzoMemoryError("MEMORY_REASON_EMPTY", "Memory reason cannot be empty.");
435
+ }
436
+ assertReason(reason);
328
437
  }
329
438
  function toExportItem(memory) {
330
439
  return {
@@ -355,6 +464,9 @@ function assertExportDocument(document) {
355
464
  if (!Array.isArray(value.memories)) {
356
465
  throw new NuzoMemoryError("MEMORY_EXPORT_INVALID", "Memory export document is invalid.");
357
466
  }
467
+ if (value.memories.length > memoryLimits.importItems) {
468
+ throw new NuzoMemoryError("MEMORY_IMPORT_LIMIT_EXCEEDED", "Memory import contains too many items.", { maxItems: memoryLimits.importItems });
469
+ }
358
470
  value.memories.forEach(assertExportItem);
359
471
  }
360
472
  function assertExportItem(item, index) {
@@ -430,6 +542,12 @@ function throwInvalidExportItem(index, reason, details = {}) {
430
542
  });
431
543
  }
432
544
  function parseExportDate(value, field) {
545
+ if (value.length > memoryLimits.dateLength) {
546
+ throw new NuzoMemoryError("MEMORY_EXPORT_INVALID", "Memory export contains an invalid date.", {
547
+ field,
548
+ maxLength: memoryLimits.dateLength,
549
+ });
550
+ }
433
551
  const date = new Date(value);
434
552
  if (Number.isNaN(date.getTime())) {
435
553
  throw new NuzoMemoryError("MEMORY_EXPORT_INVALID", "Memory export contains an invalid date.", {
@@ -450,6 +568,9 @@ function toImportDuplicateKey(memory) {
450
568
  function normalizeContent(content) {
451
569
  return content.trim().replace(/\s+/g, " ");
452
570
  }
571
+ function toCaptureDuplicateKey(content) {
572
+ return normalizeContent(content).toLowerCase();
573
+ }
453
574
  function normalizeTags(tags) {
454
575
  return [...new Set(tags)].sort();
455
576
  }
@@ -12,10 +12,10 @@ export declare class SQLiteMemoryDatabase implements MemoryStore, SearchIndex, A
12
12
  getSchemaVersion(): number;
13
13
  run<T>(operation: () => Promise<T>): Promise<T>;
14
14
  create(memory: MemoryRecord): Promise<void>;
15
- update(memory: MemoryRecord): Promise<void>;
15
+ update(memory: MemoryRecord, expectedRevision?: number): Promise<boolean>;
16
16
  findById(id: string): Promise<MemoryRecord | null>;
17
- archive(id: string, archivedAt: Date): Promise<void>;
18
- delete(id: string): Promise<void>;
17
+ archive(id: string, archivedAt: Date, expectedRevision?: number): Promise<boolean>;
18
+ delete(id: string, expectedRevision?: number): Promise<boolean>;
19
19
  index(memory: MemoryRecord): Promise<void>;
20
20
  remove(memoryId: string): Promise<void>;
21
21
  search(input: RecallMemoriesInput): Promise<RecallMemoryResult[]>;
@@ -1,21 +1,26 @@
1
1
  import Database from "better-sqlite3";
2
+ import { chmodSync, closeSync, existsSync, openSync } from "node:fs";
2
3
  import { NuzoMemoryError } from "../errors.js";
3
4
  import { migrate } from "./schema.js";
4
5
  export class SQLiteMemoryDatabase {
5
6
  database;
6
7
  transactionQueue = Promise.resolve();
7
8
  constructor(options) {
9
+ createPrivateDatabaseFile(options.path);
8
10
  this.database = new Database(options.path);
9
11
  try {
10
12
  migrate(this.database);
13
+ protectDatabaseFiles(options.path);
11
14
  }
12
15
  catch (error) {
13
16
  this.database.close();
17
+ protectDatabaseFiles(options.path);
14
18
  throw error;
15
19
  }
16
20
  }
17
21
  close() {
18
22
  this.database.close();
23
+ protectDatabaseFiles(this.database.name);
19
24
  }
20
25
  getSchemaVersion() {
21
26
  return this.database.pragma("user_version", { simple: true });
@@ -31,6 +36,7 @@ export class SQLiteMemoryDatabase {
31
36
  try {
32
37
  this.database.exec("BEGIN IMMEDIATE");
33
38
  started = true;
39
+ protectDatabaseFiles(this.database.name);
34
40
  const result = await operation();
35
41
  this.database.exec("COMMIT");
36
42
  return result;
@@ -42,6 +48,7 @@ export class SQLiteMemoryDatabase {
42
48
  throw error;
43
49
  }
44
50
  finally {
51
+ protectDatabaseFiles(this.database.name);
45
52
  release();
46
53
  }
47
54
  }
@@ -59,11 +66,13 @@ export class SQLiteMemoryDatabase {
59
66
  `)
60
67
  .run(toMemoryRow(memory));
61
68
  }
62
- async update(memory) {
63
- this.database
69
+ async update(memory, expectedRevision) {
70
+ const where = expectedRevision === undefined ? "id = @id" : "id = @id AND revision = @expected_revision";
71
+ const result = this.database
64
72
  .prepare(`
65
73
  UPDATE memories
66
- SET scope = @scope,
74
+ SET revision = @revision,
75
+ scope = @scope,
67
76
  kind = @kind,
68
77
  content = @content,
69
78
  tags = @tags,
@@ -73,21 +82,37 @@ export class SQLiteMemoryDatabase {
73
82
  updated_at = @updated_at,
74
83
  last_used_at = @last_used_at,
75
84
  archived_at = @archived_at
76
- WHERE id = @id
85
+ WHERE ${where}
77
86
  `)
78
- .run(toMemoryRow(memory));
87
+ .run({
88
+ ...toMemoryRow(memory),
89
+ expected_revision: expectedRevision,
90
+ });
91
+ return result.changes === 1;
79
92
  }
80
93
  async findById(id) {
81
94
  const row = this.database.prepare("SELECT * FROM memories WHERE id = ?").get(id);
82
95
  return row ? fromMemoryRow(row) : null;
83
96
  }
84
- async archive(id, archivedAt) {
85
- this.database
86
- .prepare("UPDATE memories SET archived_at = @archived_at, updated_at = @archived_at WHERE id = @id")
87
- .run({ id, archived_at: archivedAt.toISOString() });
97
+ async archive(id, archivedAt, expectedRevision) {
98
+ const where = expectedRevision === undefined ? "id = @id" : "id = @id AND revision = @expected_revision";
99
+ const result = this.database
100
+ .prepare(`
101
+ UPDATE memories
102
+ SET revision = revision + 1,
103
+ archived_at = @archived_at,
104
+ updated_at = @archived_at
105
+ WHERE ${where}
106
+ `)
107
+ .run({ id, archived_at: archivedAt.toISOString(), expected_revision: expectedRevision });
108
+ return result.changes === 1;
88
109
  }
89
- async delete(id) {
90
- this.database.prepare("DELETE FROM memories WHERE id = ?").run(id);
110
+ async delete(id, expectedRevision) {
111
+ const where = expectedRevision === undefined ? "id = ?" : "id = ? AND revision = ?";
112
+ const result = expectedRevision === undefined
113
+ ? this.database.prepare(`DELETE FROM memories WHERE ${where}`).run(id)
114
+ : this.database.prepare(`DELETE FROM memories WHERE ${where}`).run(id, expectedRevision);
115
+ return result.changes === 1;
91
116
  }
92
117
  async index(memory) {
93
118
  this.database.prepare("DELETE FROM memories_fts WHERE id = ?").run(memory.id);
@@ -170,6 +195,7 @@ export class SQLiteMemoryDatabase {
170
195
  function toMemoryRow(memory) {
171
196
  return {
172
197
  id: memory.id,
198
+ revision: memory.revision,
173
199
  scope: memory.scope,
174
200
  kind: memory.kind,
175
201
  content: memory.content,
@@ -185,6 +211,7 @@ function toMemoryRow(memory) {
185
211
  function fromMemoryRow(row) {
186
212
  return {
187
213
  id: row.id,
214
+ revision: row.revision,
188
215
  scope: row.scope,
189
216
  kind: row.kind,
190
217
  content: row.content,
@@ -234,8 +261,23 @@ function parsePayload(value) {
234
261
  function toFtsQuery(query) {
235
262
  return query
236
263
  .trim()
237
- .split(/\W+/)
264
+ .split(/[^\p{L}\p{N}_]+/u)
238
265
  .filter(Boolean)
239
266
  .map((term) => `"${term.replaceAll('"', '""')}"`)
240
267
  .join(" OR ");
241
268
  }
269
+ function protectDatabaseFiles(path) {
270
+ for (const candidate of [path, `${path}-wal`, `${path}-shm`]) {
271
+ if (existsSync(candidate)) {
272
+ chmodSync(candidate, 0o600);
273
+ }
274
+ }
275
+ }
276
+ function createPrivateDatabaseFile(path) {
277
+ if (path === ":memory:") {
278
+ return;
279
+ }
280
+ const descriptor = openSync(path, "a", 0o600);
281
+ closeSync(descriptor);
282
+ chmodSync(path, 0o600);
283
+ }
@@ -1,3 +1,3 @@
1
1
  import type Database from "better-sqlite3";
2
- export declare const schemaVersion = 1;
2
+ export declare const schemaVersion = 2;
3
3
  export declare function migrate(database: Database.Database): void;
@@ -1,7 +1,8 @@
1
1
  import { NuzoMemoryError } from "../errors.js";
2
- export const schemaVersion = 1;
2
+ export const schemaVersion = 2;
3
3
  export function migrate(database) {
4
4
  database.pragma("journal_mode = WAL");
5
+ database.pragma("busy_timeout = 5000");
5
6
  database.pragma("foreign_keys = ON");
6
7
  const currentVersion = database.pragma("user_version", { simple: true });
7
8
  if (currentVersion > schemaVersion) {
@@ -12,6 +13,10 @@ export function migrate(database) {
12
13
  }
13
14
  if (currentVersion < 1) {
14
15
  migrateToV1(database);
16
+ database.pragma("user_version = 1");
17
+ }
18
+ if (currentVersion < 2) {
19
+ migrateToV2(database);
15
20
  database.pragma(`user_version = ${schemaVersion}`);
16
21
  }
17
22
  }
@@ -19,6 +24,7 @@ function migrateToV1(database) {
19
24
  database.exec(`
20
25
  CREATE TABLE IF NOT EXISTS memories (
21
26
  id TEXT PRIMARY KEY,
27
+ revision INTEGER NOT NULL DEFAULT 1,
22
28
  scope TEXT NOT NULL,
23
29
  kind TEXT NOT NULL,
24
30
  content TEXT NOT NULL,
@@ -52,3 +58,9 @@ function migrateToV1(database) {
52
58
  CREATE INDEX IF NOT EXISTS idx_memory_events_memory_id ON memory_events(memory_id);
53
59
  `);
54
60
  }
61
+ function migrateToV2(database) {
62
+ const columns = database.pragma("table_info(memories)");
63
+ if (!columns.some((column) => column.name === "revision")) {
64
+ database.exec("ALTER TABLE memories ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;");
65
+ }
66
+ }
package/dist/types.d.ts CHANGED
@@ -3,6 +3,7 @@ export declare const memoryKinds: readonly ["preference", "project_decision", "f
3
3
  export type MemoryScope = `user:${string}` | `project:${string}` | `agent:${string}` | `team:${string}`;
4
4
  export interface MemoryRecord {
5
5
  id: string;
6
+ revision: number;
6
7
  scope: MemoryScope;
7
8
  kind: MemoryKind;
8
9
  content: string;
@@ -30,6 +31,25 @@ export interface RememberMemoryInput {
30
31
  source: string;
31
32
  confidence?: number;
32
33
  }
34
+ export interface SuggestCaptureInput extends RememberMemoryInput {
35
+ reason: string;
36
+ }
37
+ export interface CaptureSuggestionDraft {
38
+ content: string;
39
+ kind: MemoryKind;
40
+ scope: MemoryScope;
41
+ tags: string[];
42
+ source: string;
43
+ confidence: number;
44
+ reason: string;
45
+ }
46
+ export interface CaptureSuggestionResult {
47
+ status: "ready" | "duplicate";
48
+ memoryWrites: false;
49
+ requiresConfirmation: true;
50
+ draft: CaptureSuggestionDraft;
51
+ duplicate: MemoryRecord | null;
52
+ }
33
53
  export interface RecallMemoriesInput {
34
54
  query: string;
35
55
  scope: MemoryScope;
@@ -44,6 +64,7 @@ export interface ListMemoriesInput {
44
64
  }
45
65
  export interface ForgetMemoryInput {
46
66
  id: string;
67
+ expectedRevision?: number;
47
68
  mode?: "archive" | "delete";
48
69
  confirm?: boolean;
49
70
  actor: string;
@@ -68,6 +89,7 @@ export interface ForgetMemoriesResult {
68
89
  }
69
90
  export interface UpdateMemoryInput {
70
91
  id: string;
92
+ expectedRevision?: number;
71
93
  content?: string;
72
94
  kind?: MemoryKind;
73
95
  scope?: MemoryScope;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuzo/memory-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Core memory lifecycle, ports, and domain contracts for Nuzo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -28,7 +28,7 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "dependencies": {
31
- "better-sqlite3": "^12.10.0"
31
+ "better-sqlite3": "^12.11.1"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"