sessionmem 1.0.5 → 1.0.6

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 (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +372 -365
  3. package/dist/adapters/capabilities/fallbackTools.js +33 -18
  4. package/dist/adapters/claudeMdInjector.js +120 -0
  5. package/dist/adapters/generic.js +83 -12
  6. package/dist/adapters/tools/ping.js +4 -1
  7. package/dist/cli/commands/install.js +18 -1
  8. package/dist/cli/commands/reEmbed.js +47 -0
  9. package/dist/cli/commands/run.js +28 -2
  10. package/dist/cli/commands/savings.js +75 -0
  11. package/dist/cli/commands/uninstall.js +10 -0
  12. package/dist/cli/index.js +14 -0
  13. package/dist/cli/output.js +11 -3
  14. package/dist/core/api/contracts.js +34 -10
  15. package/dist/core/api/memoryCoreService.js +188 -86
  16. package/dist/core/api/sessionLifecycleService.js +12 -2
  17. package/dist/core/config/policyConfig.js +20 -0
  18. package/dist/core/injection/formatStartupInjection.js +2 -1
  19. package/dist/core/injection/tokenBudget.js +8 -0
  20. package/dist/core/retrieve/importance.js +4 -3
  21. package/dist/core/retrieve/recencyBands.js +3 -10
  22. package/dist/core/retrieve/retrieveMemories.js +17 -4
  23. package/dist/core/retrieve/score.js +11 -1
  24. package/dist/core/schema/migrations/005_team_provenance.sql +9 -9
  25. package/dist/core/schema/migrations/006_access_pattern_boosting.sql +5 -0
  26. package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
  27. package/dist/core/schema/migrations/008_fts5_search.sql +33 -0
  28. package/dist/core/storage/db.js +6 -0
  29. package/dist/core/storage/memoryFeedbackRepo.js +14 -4
  30. package/dist/core/storage/memoryRepo.js +134 -120
  31. package/dist/core/storage/memorySearchRepo.js +87 -13
  32. package/dist/core/storage/sessionEventsRepo.js +19 -9
  33. package/dist/core/storage/summarizationFailuresRepo.js +36 -26
  34. package/dist/core/storage/tokenSavingsRepo.js +20 -0
  35. package/dist/core/summarize/cloudSummarizer.js +21 -5
  36. package/dist/core/summarize/localSummarizer.js +1 -10
  37. package/package.json +50 -48
@@ -1,21 +1,29 @@
1
1
  export function formatTable(rows) {
2
2
  const ID_WIDTH = 36;
3
- const IMP_WIDTH = 10;
3
+ const IMP_WIDTH = 14;
4
+ const ACC_WIDTH = 8;
4
5
  const DATE_WIDTH = 10;
5
- const PREVIEW_WIDTH = 60;
6
+ const PREVIEW_WIDTH = 50;
6
7
  const header = "ID".padEnd(ID_WIDTH) +
7
8
  " | " +
8
9
  "importance".padEnd(IMP_WIDTH) +
9
10
  " | " +
11
+ "accesses".padEnd(ACC_WIDTH) +
12
+ " | " +
10
13
  "date".padEnd(DATE_WIDTH) +
11
14
  " | " +
12
15
  "preview";
13
16
  const lines = rows.map((row) => {
14
17
  const preview = row.content.replace(/\s+/g, " ").slice(0, PREVIEW_WIDTH);
15
18
  const date = row.createdAt.slice(0, 10);
19
+ const imp = row.effectiveImportance !== row.importance
20
+ ? `${row.importance}(${row.effectiveImportance})`
21
+ : String(row.importance);
16
22
  return (row.id.padEnd(ID_WIDTH) +
17
23
  " | " +
18
- String(row.importance).padEnd(IMP_WIDTH) +
24
+ imp.padEnd(IMP_WIDTH) +
25
+ " | " +
26
+ String(row.accessCount).padEnd(ACC_WIDTH) +
19
27
  " | " +
20
28
  date.padEnd(DATE_WIDTH) +
21
29
  " | " +
@@ -11,6 +11,9 @@ export const memorySchema = z.object({
11
11
  embedding: z.string().nullable(),
12
12
  embeddingDim: z.number().int().nullable(),
13
13
  embeddingVersion: z.string().nullable(),
14
+ accessCount: z.number().int().nonnegative(),
15
+ lastAccessed: z.string().nullable(),
16
+ effectiveImportance: z.number().int().min(1).max(10),
14
17
  createdAt: z.string().min(1),
15
18
  updatedAt: z.string().min(1),
16
19
  });
@@ -78,12 +81,6 @@ export const retrieveMemoriesRequestSchema = z.object({
78
81
  mode: z.enum(["auto", "on-demand"]).default("auto"),
79
82
  depth: z.enum(["default", "deep"]).default("default"),
80
83
  });
81
- export const recordMemoryUsedRequestSchema = z.object({
82
- projectId: z.string().min(1),
83
- memoryId: z.string().min(1),
84
- feedbackType: z.enum(["auto_use", "manual"]).default("auto_use"),
85
- usedAt: z.string().min(1).optional(),
86
- });
87
84
  export const listMemoriesRequestSchema = z.object({
88
85
  projectId: z.string().min(1),
89
86
  });
@@ -164,6 +161,13 @@ export const redactExistingResponseSchema = z.object({
164
161
  skipped: z.number().int().nonnegative().default(0),
165
162
  previews: z.array(z.string()),
166
163
  });
164
+ export const resetAccessCountsRequestSchema = z.object({
165
+ projectId: z.string().min(1),
166
+ });
167
+ export const resetAccessCountsResponseSchema = z.object({
168
+ ok: z.literal(true),
169
+ affected: z.number().int().nonnegative(),
170
+ });
167
171
  export const operationResultSchema = z.object({
168
172
  ok: z.literal(true),
169
173
  });
@@ -255,9 +259,29 @@ export const pullMemoriesResponseSchema = z.object({
255
259
  skippedCrossProject: z.number().int().nonnegative().default(0),
256
260
  warningCodes: z.array(z.string()),
257
261
  });
258
- export const recordMemoryUsedResponseSchema = z.object({
259
- ok: z.literal(true),
262
+ export const batchStoreMemoryItemSchema = z.object({
263
+ memoryId: z.string().min(1),
264
+ sessionId: z.string().min(1),
265
+ sourceAdapter: z.string().min(1),
266
+ kind: z.string().min(1),
267
+ content: z.string().min(1),
268
+ importance: z.number().int().min(1).max(10),
269
+ redactionEnabled: z.boolean().optional(),
270
+ });
271
+ export const batchStoreMemoryRequestSchema = z.object({
272
+ projectId: z.string().min(1),
273
+ memories: z.array(batchStoreMemoryItemSchema).min(1),
274
+ });
275
+ export const batchStoreMemoryResultSchema = z.object({
260
276
  memoryId: z.string().min(1),
261
- previousImportance: z.number().int().min(1).max(10),
262
- newImportance: z.number().int().min(1).max(10),
277
+ ok: z.boolean(),
278
+ memory: memorySchema.optional(),
279
+ warningCodes: z.array(z.string()).optional(),
280
+ error: z.string().optional(),
281
+ });
282
+ export const batchStoreMemoryResponseSchema = z.object({
283
+ ok: z.literal(true),
284
+ results: z.array(batchStoreMemoryResultSchema),
285
+ stored: z.number().int().nonnegative(),
286
+ failed: z.number().int().nonnegative(),
263
287
  });
@@ -2,12 +2,14 @@ import { userInfo } from "node:os";
2
2
  import { ZodError } from "zod";
3
3
  import { deterministicEmbed } from "../embed/deterministicEmbed.js";
4
4
  import { retrieveMemories } from "../retrieve/retrieveMemories.js";
5
+ import { computeEffectiveImportance } from "../retrieve/score.js";
5
6
  import { formatStartupInjection } from "../injection/formatStartupInjection.js";
6
7
  import { applyRedaction } from "../summarize/redaction.js";
7
- import { countMemoriesOlderThan, deleteMemoriesOlderThan, insertMemory, listMemoriesByProject, recordUse, updateMemoryContent, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
8
- import { configFilePath, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
8
+ import { countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
9
+ import { SESSION_WRITE_SOFT_LIMIT, configFilePath, DEEP_MODE_RETRIEVAL_CAP, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
10
+ import { insertMemoryFeedbackEvent } from "../storage/memoryFeedbackRepo.js";
9
11
  import { insertSessionEvent } from "../storage/sessionEventsRepo.js";
10
- import { exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, listMemoriesRequestSchema, pruneMemoriesRequestSchema, recordMemoryUsedRequestSchema, redactExistingRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.js";
12
+ import { batchStoreMemoryItemSchema, batchStoreMemoryRequestSchema, exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, listMemoriesRequestSchema, pruneMemoriesRequestSchema, redactExistingRequestSchema, resetAccessCountsRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.js";
11
13
  import { DomainError, toErrorEnvelope } from "./errors.js";
12
14
  import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
13
15
  import { createSessionLifecycleService } from "./sessionLifecycleService.js";
@@ -48,6 +50,9 @@ function toMemoryDto(record) {
48
50
  embeddingVersion: record.embedding_version,
49
51
  author: record.author,
50
52
  originProjectId: record.origin_project_id,
53
+ accessCount: record.access_count,
54
+ lastAccessed: record.last_accessed,
55
+ effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
51
56
  createdAt: record.created_at,
52
57
  updatedAt: record.updated_at,
53
58
  };
@@ -67,6 +72,9 @@ function toRetrievedMemoryDto(record) {
67
72
  embeddingVersion: record.embedding_version,
68
73
  author: record.author,
69
74
  originProjectId: record.origin_project_id,
75
+ accessCount: record.access_count,
76
+ lastAccessed: null,
77
+ effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
70
78
  createdAt: record.created_at,
71
79
  updatedAt: record.updated_at,
72
80
  semantic: record.semantic,
@@ -75,14 +83,14 @@ function toRetrievedMemoryDto(record) {
75
83
  }
76
84
  function getMemoryById(db, projectId, memoryId) {
77
85
  const row = db
78
- .prepare(`
79
- SELECT
80
- id, project_id, session_id, source_adapter, kind, content, normalized_content,
81
- importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
82
- created_at, updated_at
83
- FROM memories
84
- WHERE project_id = ? AND id = ?
85
- LIMIT 1
86
+ .prepare(`
87
+ SELECT
88
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
89
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
90
+ access_count, last_accessed, created_at, updated_at
91
+ FROM memories
92
+ WHERE project_id = ? AND id = ?
93
+ LIMIT 1
86
94
  `)
87
95
  .get(projectId, memoryId);
88
96
  return row;
@@ -188,6 +196,14 @@ export function createMemoryCoreService(deps) {
188
196
  redactionEnabled: resolveRedactionEnabled(parsed.redactionEnabled),
189
197
  });
190
198
  const embedding = deterministicEmbed(redaction.text, dimension);
199
+ // Per-session write soft limit: warn the caller when the session has
200
+ // already accumulated SESSION_WRITE_SOFT_LIMIT memories so the agent
201
+ // gets feedback to stop storing excessively. The write still proceeds.
202
+ const warningCodes = [...redaction.warningCodes];
203
+ const sessionCount = countMemoriesBySession(db, parsed.sessionId);
204
+ if (sessionCount >= SESSION_WRITE_SOFT_LIMIT) {
205
+ warningCodes.push("session_write_limit_warning");
206
+ }
191
207
  insertMemory(db, {
192
208
  id: parsed.memoryId,
193
209
  project_id: parsed.projectId,
@@ -212,45 +228,30 @@ export function createMemoryCoreService(deps) {
212
228
  return {
213
229
  ok: true,
214
230
  memory: toMemoryDto(inserted),
215
- warningCodes: redaction.warningCodes,
231
+ warningCodes,
216
232
  };
217
233
  },
218
234
  async retrieveMemories(request) {
219
235
  const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
220
- const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, 100) : parsed.limit;
236
+ const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, DEEP_MODE_RETRIEVAL_CAP) : parsed.limit;
221
237
  const ranked = retrieveMemories({
222
238
  db,
223
239
  projectId: parsed.projectId,
224
240
  queryText: parsed.query,
225
241
  limit,
226
242
  });
243
+ if (ranked.length > 0) {
244
+ incrementAccessCounts(db, parsed.projectId, ranked.map((m) => m.id));
245
+ }
227
246
  return {
228
247
  ok: true,
229
248
  memories: ranked.map(toRetrievedMemoryDto),
230
249
  total: ranked.length,
231
- // Render the startup-injection block here so the `author:`
232
- // prefix annotation for teammate-authored memories reaches CLI/MCP
233
- // callers via the production retrieval path.
234
250
  startupInjection: formatStartupInjection(ranked, {
235
251
  localUsername: localAuthor,
236
252
  }),
237
253
  };
238
254
  },
239
- async recordMemoryUsed(request) {
240
- const parsed = parseRequest(recordMemoryUsedRequestSchema, request);
241
- const result = recordUse(db, {
242
- project_id: parsed.projectId,
243
- memory_id: parsed.memoryId,
244
- feedback_type: parsed.feedbackType,
245
- used_at: parsed.usedAt,
246
- });
247
- return {
248
- ok: true,
249
- memoryId: result.memory_id,
250
- previousImportance: result.previous_importance,
251
- newImportance: result.new_importance,
252
- };
253
- },
254
255
  async listMemories(request) {
255
256
  const parsed = parseRequest(listMemoriesRequestSchema, request);
256
257
  const memories = listMemoriesByProject(db, parsed.projectId);
@@ -273,12 +274,24 @@ export function createMemoryCoreService(deps) {
273
274
  },
274
275
  async forgetMemory(request) {
275
276
  const parsed = parseRequest(forgetMemoryRequestSchema, request);
277
+ // Capture the memory's importance before deletion so we can record
278
+ // it in the feedback table as an analytics signal.
279
+ const existing = getMemoryById(db, parsed.projectId, parsed.memoryId);
276
280
  const result = db
277
281
  .prepare("DELETE FROM memories WHERE project_id = ? AND id = ?")
278
282
  .run(parsed.projectId, parsed.memoryId);
279
283
  if (result.changes === 0) {
280
284
  throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
281
285
  }
286
+ // Record the explicit user deletion as feedback. The FK on
287
+ // memory_feedback no longer cascades (migration 006), so this row
288
+ // survives the memory deletion and serves as an analytics signal.
289
+ insertMemoryFeedbackEvent(db, {
290
+ memory_id: parsed.memoryId,
291
+ feedback_type: "manual_delete",
292
+ previous_importance: existing?.importance ?? 0,
293
+ new_importance: 0,
294
+ });
282
295
  return {
283
296
  ok: true,
284
297
  };
@@ -293,32 +306,32 @@ export function createMemoryCoreService(deps) {
293
306
  },
294
307
  async importMemories(request) {
295
308
  const parsed = parseRequest(importMemoriesRequestSchema, request);
296
- const stmt = db.prepare(`
297
- INSERT INTO memories (
298
- id, project_id, session_id, source_adapter, kind, content, normalized_content,
299
- importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
300
- created_at, updated_at
301
- ) VALUES (
302
- @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
303
- @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
304
- COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
305
- COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
306
- )
307
- ON CONFLICT(id) DO UPDATE SET
308
- project_id = excluded.project_id,
309
- session_id = excluded.session_id,
310
- source_adapter = excluded.source_adapter,
311
- kind = excluded.kind,
312
- content = excluded.content,
313
- normalized_content = excluded.normalized_content,
314
- importance = excluded.importance,
315
- embedding = excluded.embedding,
316
- embedding_dim = excluded.embedding_dim,
317
- embedding_version = excluded.embedding_version,
318
- author = excluded.author,
319
- origin_project_id = excluded.origin_project_id,
320
- created_at = excluded.created_at,
321
- updated_at = excluded.updated_at
309
+ const stmt = db.prepare(`
310
+ INSERT INTO memories (
311
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
312
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
313
+ created_at, updated_at
314
+ ) VALUES (
315
+ @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
316
+ @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
317
+ COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
318
+ COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
319
+ )
320
+ ON CONFLICT(id) DO UPDATE SET
321
+ project_id = excluded.project_id,
322
+ session_id = excluded.session_id,
323
+ source_adapter = excluded.source_adapter,
324
+ kind = excluded.kind,
325
+ content = excluded.content,
326
+ normalized_content = excluded.normalized_content,
327
+ importance = excluded.importance,
328
+ embedding = excluded.embedding,
329
+ embedding_dim = excluded.embedding_dim,
330
+ embedding_version = excluded.embedding_version,
331
+ author = excluded.author,
332
+ origin_project_id = excluded.origin_project_id,
333
+ created_at = excluded.created_at,
334
+ updated_at = excluded.updated_at
322
335
  `);
323
336
  // `id` is a globally-unique PRIMARY KEY (not scoped by
324
337
  // project_id). The upsert above reassigns `project_id = excluded.project_id`
@@ -393,35 +406,35 @@ export function createMemoryCoreService(deps) {
393
406
  // provenance so pulled rows carry the teammate's identity and
394
407
  // their source project_id.
395
408
  // - cross-project id collisions are skipped, exactly as import.
396
- const stmt = db.prepare(`
397
- INSERT INTO memories (
398
- id, project_id, session_id, source_adapter, kind, content, normalized_content,
399
- importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
400
- created_at, updated_at
401
- ) VALUES (
402
- @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
403
- @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
404
- COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
405
- COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
406
- )
407
- ON CONFLICT(id) DO UPDATE SET
408
- project_id = excluded.project_id,
409
- session_id = excluded.session_id,
410
- source_adapter = excluded.source_adapter,
411
- kind = excluded.kind,
412
- content = excluded.content,
413
- normalized_content = excluded.normalized_content,
414
- -- Importance-preserving merge. better-sqlite3@12 bundles a
415
- -- SQLite that accepts the two-arg scalar MAX() inside DO UPDATE; the
416
- -- pull-merge importance-preserve test verifies both directions.
417
- importance = MAX(memories.importance, excluded.importance),
418
- embedding = excluded.embedding,
419
- embedding_dim = excluded.embedding_dim,
420
- embedding_version = excluded.embedding_version,
421
- author = excluded.author,
422
- origin_project_id = excluded.origin_project_id,
423
- created_at = excluded.created_at,
424
- updated_at = excluded.updated_at
409
+ const stmt = db.prepare(`
410
+ INSERT INTO memories (
411
+ id, project_id, session_id, source_adapter, kind, content, normalized_content,
412
+ importance, embedding, embedding_dim, embedding_version, author, origin_project_id,
413
+ created_at, updated_at
414
+ ) VALUES (
415
+ @id, @project_id, @session_id, @source_adapter, @kind, @content, @normalized_content,
416
+ @importance, @embedding, @embedding_dim, @embedding_version, @author, @origin_project_id,
417
+ COALESCE(@created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
418
+ COALESCE(@updated_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
419
+ )
420
+ ON CONFLICT(id) DO UPDATE SET
421
+ project_id = excluded.project_id,
422
+ session_id = excluded.session_id,
423
+ source_adapter = excluded.source_adapter,
424
+ kind = excluded.kind,
425
+ content = excluded.content,
426
+ normalized_content = excluded.normalized_content,
427
+ -- Importance-preserving merge. better-sqlite3@12 bundles a
428
+ -- SQLite that accepts the two-arg scalar MAX() inside DO UPDATE; the
429
+ -- pull-merge importance-preserve test verifies both directions.
430
+ importance = MAX(memories.importance, excluded.importance),
431
+ embedding = excluded.embedding,
432
+ embedding_dim = excluded.embedding_dim,
433
+ embedding_version = excluded.embedding_version,
434
+ author = excluded.author,
435
+ origin_project_id = excluded.origin_project_id,
436
+ created_at = excluded.created_at,
437
+ updated_at = excluded.updated_at
425
438
  `);
426
439
  // Same cross-project ownership skip as importMemories. A colliding
427
440
  // id owned by a different project is skipped, never overwritten/relocated.
@@ -565,6 +578,87 @@ export function createMemoryCoreService(deps) {
565
578
  previews,
566
579
  };
567
580
  },
581
+ async batchStoreMemory(request) {
582
+ const parsed = parseRequest(batchStoreMemoryRequestSchema, request);
583
+ const results = [];
584
+ let stored = 0;
585
+ let failed = 0;
586
+ // Validate each item individually before entering the transaction so
587
+ // validation errors are reported per-item without aborting the whole batch.
588
+ const validatedItems = [];
589
+ for (let i = 0; i < parsed.memories.length; i++) {
590
+ const raw = parsed.memories[i];
591
+ try {
592
+ // The array items were already parsed by batchStoreMemoryRequestSchema,
593
+ // but we re-validate with the item schema so per-item errors are
594
+ // captured individually (e.g. if a caller bypasses the outer schema).
595
+ const item = batchStoreMemoryItemSchema.parse(raw);
596
+ validatedItems.push({ index: i, item });
597
+ }
598
+ catch (err) {
599
+ results.push({
600
+ memoryId: raw.memoryId ?? `<index-${i}>`,
601
+ ok: false,
602
+ error: err instanceof ZodError
603
+ ? err.issues.map((issue) => issue.message).join("; ")
604
+ : String(err),
605
+ });
606
+ failed += 1;
607
+ }
608
+ }
609
+ // Wrap all valid inserts in a single SQLite transaction for atomicity
610
+ // and performance (better-sqlite3 transactions avoid per-statement
611
+ // fsync, making batch inserts significantly faster).
612
+ if (validatedItems.length > 0) {
613
+ const runTransaction = db.transaction(() => {
614
+ for (const { item } of validatedItems) {
615
+ const redaction = applyRedaction(item.content, {
616
+ redactionEnabled: resolveRedactionEnabled(item.redactionEnabled),
617
+ });
618
+ const embedding = deterministicEmbed(redaction.text, dimension);
619
+ insertMemory(db, {
620
+ id: item.memoryId,
621
+ project_id: parsed.projectId,
622
+ session_id: item.sessionId,
623
+ source_adapter: item.sourceAdapter,
624
+ kind: item.kind,
625
+ content: redaction.text,
626
+ normalized_content: embedding.normalizedText,
627
+ importance: item.importance,
628
+ embedding: JSON.stringify(embedding.vector),
629
+ embedding_dim: embedding.dimension,
630
+ embedding_version: embedding.embeddingVersion,
631
+ author: localAuthor,
632
+ origin_project_id: null,
633
+ });
634
+ const inserted = getMemoryById(db, parsed.projectId, item.memoryId);
635
+ if (!inserted) {
636
+ throw new DomainError("INTERNAL", `Memory insert did not persist: ${item.memoryId}`);
637
+ }
638
+ results.push({
639
+ memoryId: item.memoryId,
640
+ ok: true,
641
+ memory: toMemoryDto(inserted),
642
+ warningCodes: redaction.warningCodes,
643
+ });
644
+ stored += 1;
645
+ }
646
+ });
647
+ runTransaction();
648
+ }
649
+ // Sort results back into original input order: validated items were
650
+ // processed in order but failed items were pushed first. Re-sort by
651
+ // the memoryId to maintain a predictable output. Since memoryIds are
652
+ // unique, use the input array order as the canonical sort key.
653
+ const inputOrder = new Map(parsed.memories.map((m, i) => [m.memoryId, i]));
654
+ results.sort((a, b) => (inputOrder.get(a.memoryId) ?? 0) - (inputOrder.get(b.memoryId) ?? 0));
655
+ return {
656
+ ok: true,
657
+ results,
658
+ stored,
659
+ failed,
660
+ };
661
+ },
568
662
  async stats(request) {
569
663
  const parsed = parseRequest(statsRequestSchema, request);
570
664
  const memoryCount = db
@@ -579,6 +673,14 @@ export function createMemoryCoreService(deps) {
579
673
  totalSessionEvents: sessionEventCount.count,
580
674
  };
581
675
  },
676
+ async resetAccessCounts(request) {
677
+ const parsed = parseRequest(resetAccessCountsRequestSchema, request);
678
+ const affected = resetAccessCounts(db, parsed.projectId);
679
+ return {
680
+ ok: true,
681
+ affected,
682
+ };
683
+ },
582
684
  };
583
685
  async function call(method, request) {
584
686
  try {
@@ -185,8 +185,18 @@ export function createSessionLifecycleService(deps) {
185
185
  memoryId,
186
186
  };
187
187
  }
188
- catch {
189
- // fall back to local summarizer
188
+ catch (cloudError) {
189
+ const failureRecordId = createFailureId();
190
+ insertSummarizationFailure(deps.db, {
191
+ id: failureRecordId,
192
+ project_id: request.projectId,
193
+ session_id: request.sessionId,
194
+ source_adapter: request.sourceAdapter,
195
+ reason: "cloud_failed",
196
+ attempt_count: CLOUD_RETRY_CONFIG.retries + 1,
197
+ last_error_json: toErrorJson(cloudError),
198
+ });
199
+ // fall through to local summarizer
190
200
  }
191
201
  try {
192
202
  const fallbackResult = await summarizeLocal(baseInput);
@@ -2,6 +2,19 @@ import { z } from "zod";
2
2
  import { homedir } from "os";
3
3
  import { join, dirname } from "path";
4
4
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
5
+ /** Lower bound of the 1-10 importance scale. */
6
+ export const MIN_IMPORTANCE = 1;
7
+ /** Upper bound of the 1-10 importance scale. */
8
+ export const MAX_IMPORTANCE = 10;
9
+ /** Importance threshold at or above which a warning is considered critical. */
10
+ export const CRITICAL_WARNING_IMPORTANCE_THRESHOLD = 9;
11
+ /** Maximum number of memories returned by a "deep" retrieval. */
12
+ export const DEEP_MODE_RETRIEVAL_CAP = 100;
13
+ /**
14
+ * Default model for cloud summarization via the Anthropic API.
15
+ * Consumed by {@link import("../summarize/cloudSummarizer.js").summarizeWithCloud}.
16
+ */
17
+ export const DEFAULT_SUMMARIZER_MODEL = "claude-sonnet-4-6";
5
18
  /**
6
19
  * Built-in policy defaults. Used whenever the config file is missing, malformed,
7
20
  * or fails validation, and as the lowest-precedence source in
@@ -116,6 +129,13 @@ export function resolvePolicySettings(input) {
116
129
  redactionEnabled: resolve("redactionEnabled"),
117
130
  };
118
131
  }
132
+ /**
133
+ * Per-session write soft limit. When a session has stored at least this many
134
+ * memories, subsequent storeMemory calls still succeed but the response
135
+ * includes a "session_write_limit_warning" warningCode, giving the agent
136
+ * feedback to stop storing excessive memories in a single session.
137
+ */
138
+ export const SESSION_WRITE_SOFT_LIMIT = 50;
119
139
  /**
120
140
  * Resolve the `team` config as a single object unit using precedence
121
141
  * override > config.json > default (RESEARCH Pitfall 5). Unlike
@@ -1,3 +1,4 @@
1
+ import { CRITICAL_WARNING_IMPORTANCE_THRESHOLD } from "../config/policyConfig.js";
1
2
  import { countTokens, trimLowestPriorityContent } from "./tokenBudget.js";
2
3
  const DEFAULT_TOKEN_CAP = 450;
3
4
  const HEADER = "Relevant prior context";
@@ -7,7 +8,7 @@ function kindRank(kind) {
7
8
  return KIND_RANK.get(kind) ?? KIND_ORDER.length;
8
9
  }
9
10
  function isCriticalWarning(memory) {
10
- return memory.kind === "warning" && memory.importance >= 9;
11
+ return memory.kind === "warning" && memory.importance >= CRITICAL_WARNING_IMPORTANCE_THRESHOLD;
11
12
  }
12
13
  function sortMemories(memories) {
13
14
  return [...memories].sort((left, right) => {
@@ -5,6 +5,14 @@ const DEFAULT_TRIM_RATIO = 0.75;
5
5
  export function countTokens(text) {
6
6
  return encoding.encode(text).length;
7
7
  }
8
+ export function capTokens(text, cap) {
9
+ const tokens = encoding.encode(text);
10
+ if (tokens.length <= cap) {
11
+ return text;
12
+ }
13
+ const trimmed = encoding.decode(tokens.slice(0, cap)).trimEnd();
14
+ return `${trimmed} ...`;
15
+ }
8
16
  export function trimLowestPriorityContent(included, options = {}) {
9
17
  const minContentTokens = options.minContentTokens ?? DEFAULT_MIN_CONTENT_TOKENS;
10
18
  const trimRatio = options.trimRatio ?? DEFAULT_TRIM_RATIO;
@@ -1,6 +1,7 @@
1
+ import { MIN_IMPORTANCE, MAX_IMPORTANCE } from "../config/policyConfig.js";
1
2
  export function normalizeImportance(score1to10) {
2
- if (!Number.isFinite(score1to10) || score1to10 < 1 || score1to10 > 10) {
3
- throw new Error("importance must be between 1 and 10");
3
+ if (!Number.isFinite(score1to10) || score1to10 < MIN_IMPORTANCE || score1to10 > MAX_IMPORTANCE) {
4
+ throw new Error(`importance must be between ${MIN_IMPORTANCE} and ${MAX_IMPORTANCE}`);
4
5
  }
5
- return (score1to10 - 1) / 9;
6
+ return (score1to10 - MIN_IMPORTANCE) / (MAX_IMPORTANCE - MIN_IMPORTANCE);
6
7
  }
@@ -5,14 +5,7 @@ function toDate(value) {
5
5
  export function getRecencyBandScore(updatedAt, now = new Date()) {
6
6
  const updatedDate = toDate(updatedAt);
7
7
  const ageDays = Math.max(0, (now.getTime() - updatedDate.getTime()) / DAY_IN_MS);
8
- if (ageDays <= 1) {
9
- return 1.0;
10
- }
11
- if (ageDays <= 7) {
12
- return 0.75;
13
- }
14
- if (ageDays <= 30) {
15
- return 0.5;
16
- }
17
- return 0.25;
8
+ const HALF_LIFE_DAYS = 14;
9
+ const lambda = Math.LN2 / HALF_LIFE_DAYS;
10
+ return Math.max(0.05, Math.exp(-lambda * ageDays));
18
11
  }
@@ -1,6 +1,7 @@
1
1
  import { deterministicEmbed } from "../embed/deterministicEmbed.js";
2
+ import { decayOldBoosts } from "./decay.js";
2
3
  import { scoreMemoryCandidate, } from "./score.js";
3
- import { searchMemoryCandidates, } from "../storage/memorySearchRepo.js";
4
+ import { searchMemoryCandidates, searchMemoryCandidatesFTS, } from "../storage/memorySearchRepo.js";
4
5
  const DEFAULT_EMBEDDING_DIMENSION = 32;
5
6
  function resolveEmbeddingDimension(candidates) {
6
7
  for (const candidate of candidates) {
@@ -40,16 +41,27 @@ export function retrieveMemories(input) {
40
41
  }
41
42
  const topK = input.topK ?? input.limit ?? 20;
42
43
  const now = input.now ?? new Date();
43
- const candidates = searchMemoryCandidates(input.db, input.projectId);
44
+ // Use FTS5 pre-filtering when a semantic query is present to limit
45
+ // cosine similarity computation to ~50 candidates instead of all.
46
+ const candidates = queryText
47
+ ? searchMemoryCandidatesFTS(input.db, input.projectId, queryText)
48
+ : searchMemoryCandidates(input.db, input.projectId);
49
+ const decayedCandidates = decayOldBoosts(candidates, now);
44
50
  const dimension = resolveEmbeddingDimension(candidates);
45
51
  const queryVector = deterministicEmbed(queryText, dimension).vector;
46
- const ranked = candidates
52
+ const ranked = decayedCandidates
47
53
  .map((candidate) => {
48
- const semantic = cosineSimilarity(queryVector, candidate.embedding);
54
+ // When embedding is null (version mismatch or missing), use a neutral
55
+ // score of 0.5 so the memory is neither penalized nor boosted.
56
+ const semantic = candidate.embedding === null
57
+ ? 0.5
58
+ : cosineSimilarity(queryVector, candidate.embedding);
49
59
  const score = scoreMemoryCandidate({
50
60
  semantic,
51
61
  updated_at: candidate.updated_at,
52
62
  importance: candidate.importance,
63
+ access_count: candidate.access_count,
64
+ decayedImportance: candidate.decayedImportance,
53
65
  }, now);
54
66
  return {
55
67
  id: candidate.id,
@@ -62,6 +74,7 @@ export function retrieveMemories(input) {
62
74
  importance: candidate.importance,
63
75
  author: candidate.author,
64
76
  origin_project_id: candidate.origin_project_id,
77
+ access_count: candidate.access_count,
65
78
  created_at: candidate.created_at,
66
79
  updated_at: candidate.updated_at,
67
80
  embedding_dim: candidate.embedding_dim,