sessionmem 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) 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 +164 -0
  5. package/dist/adapters/factory.js +68 -9
  6. package/dist/adapters/generic.js +221 -15
  7. package/dist/adapters/global/antigravity.js +14 -7
  8. package/dist/adapters/global/claudeCode.js +46 -10
  9. package/dist/adapters/global/codex.js +73 -13
  10. package/dist/adapters/global/qcoder.js +18 -5
  11. package/dist/adapters/ide/cline.js +54 -9
  12. package/dist/adapters/ide/cursor.js +15 -13
  13. package/dist/adapters/ide/installer.js +201 -8
  14. package/dist/adapters/ide/windsurf.js +14 -13
  15. package/dist/adapters/tools/ping.js +4 -1
  16. package/dist/cli/commands/config.js +10 -1
  17. package/dist/cli/commands/import.js +6 -1
  18. package/dist/cli/commands/install.js +63 -5
  19. package/dist/cli/commands/ping.js +42 -8
  20. package/dist/cli/commands/reEmbed.js +48 -0
  21. package/dist/cli/commands/run.js +18 -2
  22. package/dist/cli/commands/savings.js +91 -0
  23. package/dist/cli/commands/sessionEnd.js +124 -0
  24. package/dist/cli/commands/sessionStart.js +52 -0
  25. package/dist/cli/commands/sync.js +39 -9
  26. package/dist/cli/commands/uninstall.js +37 -1
  27. package/dist/cli/context.js +14 -18
  28. package/dist/cli/index.js +30 -4
  29. package/dist/cli/output.js +11 -3
  30. package/dist/cli/projectId.js +69 -0
  31. package/dist/core/api/contracts.js +182 -45
  32. package/dist/core/api/errors.js +4 -7
  33. package/dist/core/api/memoryCoreService.js +409 -240
  34. package/dist/core/api/sessionLifecycleService.js +20 -2
  35. package/dist/core/config/policyConfig.js +53 -6
  36. package/dist/core/injection/formatStartupInjection.js +55 -10
  37. package/dist/core/injection/tokenBudget.js +8 -0
  38. package/dist/core/retrieve/importance.js +4 -3
  39. package/dist/core/retrieve/recencyBands.js +6 -10
  40. package/dist/core/retrieve/retrieveMemories.js +19 -4
  41. package/dist/core/retrieve/score.js +11 -1
  42. package/dist/core/schema/migrations/005_team_provenance.sql +14 -9
  43. package/dist/core/schema/migrations/006_access_pattern_boosting.sql +10 -0
  44. package/dist/core/schema/migrations/007_feedback_manual_delete.sql +23 -0
  45. package/dist/core/schema/migrations/008_fts5_search.sql +37 -0
  46. package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
  47. package/dist/core/schema/runMigrations.js +64 -2
  48. package/dist/core/storage/db.js +6 -0
  49. package/dist/core/storage/memoryFeedbackRepo.js +14 -4
  50. package/dist/core/storage/memoryRepo.js +292 -121
  51. package/dist/core/storage/memorySearchRepo.js +125 -13
  52. package/dist/core/storage/sessionEventsRepo.js +33 -10
  53. package/dist/core/storage/summarizationFailuresRepo.js +36 -26
  54. package/dist/core/storage/tokenSavingsRepo.js +20 -0
  55. package/dist/core/summarize/cloudSummarizer.js +34 -5
  56. package/dist/core/summarize/localSummarizer.js +1 -10
  57. package/dist/core/summarize/redaction.js +45 -8
  58. package/package.json +50 -48
@@ -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";
9
- 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";
8
+ import { countAllMemoriesByProject, countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, deleteMemoryById, getMemoryOwnerProjectId, getMemoryRecordById, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertImportedMemory, upsertPulledMemory, 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";
11
+ import { countAllSessionEvents, insertSessionEvent } from "../storage/sessionEventsRepo.js";
12
+ import { batchStoreMemoryItemSchema, batchStoreMemoryRequestSchema, exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, LIST_MEMORIES_DEFAULT_LIMIT, 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";
@@ -33,6 +35,28 @@ function resolveServiceUsername(explicit) {
33
35
  return "";
34
36
  }
35
37
  }
38
+ // Maximum content length serialized into an MCP retrieve response. The full
39
+ // content remains in the DB; this only caps what is returned to the tool caller
40
+ // so a large result set cannot overflow the agent context (100 rows × 10k chars
41
+ // ≈ 1MB JSON).
42
+ const RETRIEVE_CONTENT_MAX_LENGTH = 2000;
43
+ /**
44
+ * Clamp an imported/pulled timestamp to server time. A record carrying a future
45
+ * createdAt/updatedAt would otherwise be immune to retention pruning (its age
46
+ * never crosses the cutoff), so any value past `serverNow` is pulled back to it.
47
+ */
48
+ function clampDateToNow(date) {
49
+ if (!date)
50
+ return null;
51
+ const epochMs = Date.parse(date);
52
+ if (isNaN(epochMs))
53
+ return null; // invalid date → discard
54
+ const nowMs = Date.now();
55
+ // Parse to epoch (handles timezone offsets correctly), clamp future dates to
56
+ // now, and normalize to a canonical UTC ISO string (no timezone offset) so
57
+ // lexicographic comparison in the retention prune stays consistent.
58
+ return new Date(Math.min(epochMs, nowMs)).toISOString();
59
+ }
36
60
  function toMemoryDto(record) {
37
61
  return {
38
62
  id: record.id,
@@ -40,18 +64,36 @@ function toMemoryDto(record) {
40
64
  sessionId: record.session_id,
41
65
  sourceAdapter: record.source_adapter,
42
66
  kind: record.kind,
43
- content: record.content,
44
- normalizedContent: record.normalized_content,
67
+ content: record.content.slice(0, RETRIEVE_CONTENT_MAX_LENGTH),
68
+ normalizedContent: record.normalized_content?.slice(0, RETRIEVE_CONTENT_MAX_LENGTH) ?? null,
45
69
  importance: record.importance,
46
- embedding: record.embedding,
70
+ embedding: null,
47
71
  embeddingDim: record.embedding_dim,
48
72
  embeddingVersion: record.embedding_version,
49
73
  author: record.author,
50
74
  originProjectId: record.origin_project_id,
75
+ accessCount: record.access_count,
76
+ lastAccessed: record.last_accessed,
77
+ effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
51
78
  createdAt: record.created_at,
52
79
  updatedAt: record.updated_at,
53
80
  };
54
81
  }
82
+ /**
83
+ * Export/sync DTO: preserves FULL content and normalized_content, unlike
84
+ * toMemoryDto which caps both at RETRIEVE_CONTENT_MAX_LENGTH (2000) to bound MCP
85
+ * tool responses against context overflow. Export and team-push must round-trip
86
+ * losslessly — importMemories/pullMemories re-embed from the exported `content`,
87
+ * so truncating here would permanently lose any memory body over 2000 chars
88
+ * (stored content can be up to MAX_CONTENT_LENGTH = 10000) on re-import.
89
+ */
90
+ function toExportMemoryDto(record) {
91
+ return {
92
+ ...toMemoryDto(record),
93
+ content: record.content,
94
+ normalizedContent: record.normalized_content,
95
+ };
96
+ }
55
97
  function toRetrievedMemoryDto(record) {
56
98
  return {
57
99
  id: record.id,
@@ -59,34 +101,25 @@ function toRetrievedMemoryDto(record) {
59
101
  sessionId: record.session_id,
60
102
  sourceAdapter: record.source_adapter,
61
103
  kind: record.kind,
62
- content: record.content,
63
- normalizedContent: record.normalized_content,
104
+ // Cap content for the MCP tool response to prevent context overflow; the
105
+ // full content stays in the DB and is reachable via getMemory.
106
+ content: record.content.slice(0, RETRIEVE_CONTENT_MAX_LENGTH),
107
+ normalizedContent: record.normalized_content?.slice(0, RETRIEVE_CONTENT_MAX_LENGTH) ?? null,
64
108
  importance: record.importance,
65
109
  embedding: null,
66
110
  embeddingDim: record.embedding_dim,
67
111
  embeddingVersion: record.embedding_version,
68
112
  author: record.author,
69
113
  originProjectId: record.origin_project_id,
114
+ accessCount: record.access_count,
115
+ lastAccessed: null,
116
+ effectiveImportance: computeEffectiveImportance(record.importance, record.access_count),
70
117
  createdAt: record.created_at,
71
118
  updatedAt: record.updated_at,
72
119
  semantic: record.semantic,
73
120
  score: record.score,
74
121
  };
75
122
  }
76
- function getMemoryById(db, projectId, memoryId) {
77
- 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
- `)
87
- .get(projectId, memoryId);
88
- return row;
89
- }
90
123
  function parseRequest(schema, request) {
91
124
  try {
92
125
  return schema.parse(request);
@@ -136,32 +169,56 @@ export function createMemoryCoreService(deps) {
136
169
  const methods = {
137
170
  async ingestSessionEvents(request) {
138
171
  const parsed = parseRequest(ingestSessionEventsRequestSchema, request);
139
- for (const event of parsed.events) {
140
- insertSessionEvent(db, {
141
- id: event.id,
142
- project_id: parsed.projectId,
143
- session_id: parsed.sessionId,
144
- event_index: event.eventIndex,
145
- event_type: event.eventType,
146
- payload_json: event.payloadJson,
147
- created_at: event.createdAt,
148
- });
149
- }
172
+ // Wrap the whole batch in a single transaction so a mid-loop failure rolls
173
+ // back every insert (no partial ingestion). Inserts use INSERT OR IGNORE
174
+ // on the (project_id, session_id, event_index) UNIQUE index, so the count
175
+ // reflects rows actually written and re-ingestion is a no-op.
176
+ // Redact each event's payload_json before persisting so secrets in tool
177
+ // inputs/outputs never reach storage — same write-path guarantee as
178
+ // storeMemory. Events carry no explicit redactionEnabled flag, so resolve
179
+ // it from the policy config.
180
+ const redactionEnabled = resolveRedactionEnabled(undefined);
181
+ const ingest = db.transaction(() => {
182
+ let written = 0;
183
+ for (const event of parsed.events) {
184
+ const redactedPayload = applyRedaction(event.payloadJson, {
185
+ redactionEnabled,
186
+ }).text;
187
+ written += insertSessionEvent(db, {
188
+ id: event.id,
189
+ project_id: parsed.projectId,
190
+ session_id: parsed.sessionId,
191
+ event_index: event.eventIndex,
192
+ event_type: event.eventType,
193
+ payload_json: redactedPayload,
194
+ created_at: event.createdAt,
195
+ });
196
+ }
197
+ return written;
198
+ });
199
+ const ingested = ingest();
150
200
  return {
151
201
  ok: true,
152
- ingested: parsed.events.length,
202
+ ingested,
153
203
  };
154
204
  },
155
205
  async summarizeSessionToMemory(request) {
156
206
  const parsed = parseRequest(summarizeSessionToMemoryRequestSchema, request);
157
- const embedding = deterministicEmbed(parsed.summary, dimension);
207
+ // Redact before embedding/persisting so secrets in the summary text never
208
+ // reach storage and the embedding is computed on the redacted text — same
209
+ // write-path guarantee as storeMemory. The request carries no explicit
210
+ // redactionEnabled flag, so resolve it from the policy config.
211
+ const redaction = applyRedaction(parsed.summary, {
212
+ redactionEnabled: resolveRedactionEnabled(undefined),
213
+ });
214
+ const embedding = deterministicEmbed(redaction.text, dimension);
158
215
  upsertSessionSummaryMemory(db, {
159
216
  id: parsed.memoryId,
160
217
  project_id: parsed.projectId,
161
218
  session_id: parsed.sessionId,
162
219
  source_adapter: parsed.sourceAdapter,
163
220
  kind: "summary",
164
- content: parsed.summary,
221
+ content: redaction.text,
165
222
  normalized_content: embedding.normalizedText,
166
223
  importance: parsed.importance,
167
224
  embedding: JSON.stringify(embedding.vector),
@@ -188,6 +245,14 @@ export function createMemoryCoreService(deps) {
188
245
  redactionEnabled: resolveRedactionEnabled(parsed.redactionEnabled),
189
246
  });
190
247
  const embedding = deterministicEmbed(redaction.text, dimension);
248
+ // Per-session write soft limit: warn the caller when the session has
249
+ // already accumulated SESSION_WRITE_SOFT_LIMIT memories so the agent
250
+ // gets feedback to stop storing excessively. The write still proceeds.
251
+ const warningCodes = [...redaction.warningCodes];
252
+ const sessionCount = countMemoriesBySession(db, parsed.sessionId, parsed.projectId);
253
+ if (sessionCount >= SESSION_WRITE_SOFT_LIMIT) {
254
+ warningCodes.push("session_write_limit_warning");
255
+ }
191
256
  insertMemory(db, {
192
257
  id: parsed.memoryId,
193
258
  project_id: parsed.projectId,
@@ -205,80 +270,93 @@ export function createMemoryCoreService(deps) {
205
270
  author: localAuthor,
206
271
  origin_project_id: null,
207
272
  });
208
- const inserted = getMemoryById(db, parsed.projectId, parsed.memoryId);
273
+ const inserted = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
209
274
  if (!inserted) {
210
275
  throw new DomainError("INTERNAL", "Memory insert did not persist");
211
276
  }
212
277
  return {
213
278
  ok: true,
214
- memory: toMemoryDto(inserted),
215
- warningCodes: redaction.warningCodes,
279
+ // Single-record write echo-back: return the FULL stored body (uncapped),
280
+ // mirroring getMemory's single-record read. A store response carries one
281
+ // row bounded by MAX_CONTENT_LENGTH (10000), so it cannot overflow the
282
+ // agent context the way a multi-row list can, and the caller may want to
283
+ // verify the actual persisted (post-redaction) content. Contrast with
284
+ // batchStoreMemory below, which keeps the cap because it returns many rows.
285
+ memory: toExportMemoryDto(inserted),
286
+ warningCodes,
216
287
  };
217
288
  },
218
289
  async retrieveMemories(request) {
219
290
  const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
220
- const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, 100) : parsed.limit;
291
+ const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, DEEP_MODE_RETRIEVAL_CAP) : parsed.limit;
221
292
  const ranked = retrieveMemories({
222
293
  db,
223
294
  projectId: parsed.projectId,
224
295
  queryText: parsed.query,
225
296
  limit,
226
297
  });
298
+ if (ranked.length > 0 && parsed.mode !== "on-demand") {
299
+ // Only boost access counts for startup injection (mode='auto'), not for
300
+ // explicit on-demand retrieval, so a mid-session lookup does not inflate
301
+ // recall-frequency ranking.
302
+ incrementAccessCounts(db, parsed.projectId, ranked.map((m) => m.id));
303
+ }
304
+ // Honor a user-configured injectionCap when present; otherwise
305
+ // formatStartupInjection falls back to its built-in default cap.
306
+ const injectionCap = readPolicyConfig(policyConfigPath).injectionCap;
227
307
  return {
228
308
  ok: true,
229
309
  memories: ranked.map(toRetrievedMemoryDto),
230
310
  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
311
  startupInjection: formatStartupInjection(ranked, {
235
312
  localUsername: localAuthor,
313
+ tokenCap: injectionCap,
236
314
  }),
237
315
  };
238
316
  },
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
317
  async listMemories(request) {
255
318
  const parsed = parseRequest(listMemoriesRequestSchema, request);
256
- const memories = listMemoriesByProject(db, parsed.projectId);
319
+ const all = listMemoriesByProject(db, parsed.projectId);
320
+ // Rows arrive ordered by updated_at DESC, so slicing keeps the most
321
+ // recently touched memories. `total` reports the full count; a shorter
322
+ // `memories` array signals the caller that the list was truncated.
323
+ const limit = parsed.limit ?? LIST_MEMORIES_DEFAULT_LIMIT;
324
+ const memories = all.slice(0, limit);
257
325
  return {
258
326
  ok: true,
259
327
  memories: memories.map(toMemoryDto),
260
- total: memories.length,
328
+ total: all.length,
261
329
  };
262
330
  },
263
331
  async getMemory(request) {
264
332
  const parsed = parseRequest(getMemoryRequestSchema, request);
265
- const memory = getMemoryById(db, parsed.projectId, parsed.memoryId);
333
+ const memory = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
266
334
  if (!memory) {
267
335
  throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
268
336
  }
269
337
  return {
270
338
  ok: true,
271
- memory: toMemoryDto(memory),
339
+ memory: toExportMemoryDto(memory),
272
340
  };
273
341
  },
274
342
  async forgetMemory(request) {
275
343
  const parsed = parseRequest(forgetMemoryRequestSchema, request);
276
- const result = db
277
- .prepare("DELETE FROM memories WHERE project_id = ? AND id = ?")
278
- .run(parsed.projectId, parsed.memoryId);
279
- if (result.changes === 0) {
344
+ // Capture the memory's importance before deletion so we can record
345
+ // it in the feedback table as an analytics signal.
346
+ const existing = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
347
+ const deleted = deleteMemoryById(db, parsed.projectId, parsed.memoryId);
348
+ if (deleted === 0) {
280
349
  throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
281
350
  }
351
+ // Record the explicit user deletion as feedback. The FK on
352
+ // memory_feedback no longer cascades (migration 007), so this row
353
+ // survives the memory deletion and serves as an analytics signal.
354
+ insertMemoryFeedbackEvent(db, {
355
+ memory_id: parsed.memoryId,
356
+ feedback_type: "manual_delete",
357
+ previous_importance: existing?.importance ?? 0,
358
+ new_importance: 0,
359
+ });
282
360
  return {
283
361
  ok: true,
284
362
  };
@@ -288,45 +366,16 @@ export function createMemoryCoreService(deps) {
288
366
  const memories = listMemoriesByProject(db, parsed.projectId);
289
367
  return {
290
368
  ok: true,
291
- memories: memories.map(toMemoryDto),
369
+ memories: memories.map(toExportMemoryDto),
292
370
  };
293
371
  },
294
372
  async importMemories(request) {
295
373
  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
322
- `);
323
- // `id` is a globally-unique PRIMARY KEY (not scoped by
324
- // project_id). The upsert above reassigns `project_id = excluded.project_id`
325
- // on conflict, which would let an imported record silently overwrite and
326
- // relocate another project's memory if its `id` happens to collide.
327
- // Look up existing ownership per id and skip (rather than upsert) any
328
- // record whose id already belongs to a *different* project.
329
- const ownerStmt = db.prepare("SELECT project_id FROM memories WHERE id = ?");
374
+ // The upsert (upsertImportedMemory) reassigns project_id on ON CONFLICT(id).
375
+ // Because `id` is a globally-unique PRIMARY KEY (not scoped by project_id),
376
+ // a colliding id owned by a *different* project would otherwise be silently
377
+ // overwritten and relocated. getMemoryOwnerProjectId resolves ownership per
378
+ // id so we skip those rather than upsert them.
330
379
  // Aggregate redaction warnings across all imported records. A
331
380
  // Set de-duplicates the redaction_partial_failure code so the envelope
332
381
  // stays compact regardless of how many records tripped the same rule.
@@ -334,52 +383,66 @@ export function createMemoryCoreService(deps) {
334
383
  const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
335
384
  let imported = 0;
336
385
  let skippedCrossProject = 0;
337
- for (const memory of parsed.memories) {
338
- const owner = ownerStmt.get(memory.id);
339
- if (owner && owner.project_id !== parsed.projectId) {
340
- // Another project already owns this id: skip rather than overwrite
341
- // and reassign ownership via ON CONFLICT(id).
342
- skippedCrossProject += 1;
343
- continue;
344
- }
345
- // Redact each record before embedding/upsert so secrets never persist
346
- // and the embedding reflects the redacted text.
347
- const redaction = applyRedaction(memory.content, {
348
- redactionEnabled: effectiveRedactionEnabled,
349
- });
350
- for (const code of redaction.warningCodes) {
351
- warningCodeSet.add(code);
386
+ let skippedExisting = 0;
387
+ // Wrap the whole batch in a single transaction so a mid-loop failure rolls
388
+ // back every upsert (no partial import).
389
+ const runImport = db.transaction(() => {
390
+ for (const memory of parsed.memories) {
391
+ const ownerProjectId = getMemoryOwnerProjectId(db, memory.id);
392
+ if (ownerProjectId !== undefined) {
393
+ if (ownerProjectId !== parsed.projectId) {
394
+ // Another project already owns this id: skip rather than overwrite
395
+ // and reassign ownership via ON CONFLICT(id).
396
+ skippedCrossProject += 1;
397
+ }
398
+ else {
399
+ // This project already owns this id: skip rather than overwrite the
400
+ // existing memory's content/timestamps. Only brand-new ids import.
401
+ skippedExisting += 1;
402
+ }
403
+ continue;
404
+ }
405
+ // Redact each record before embedding/upsert so secrets never persist
406
+ // and the embedding reflects the redacted text.
407
+ const redaction = applyRedaction(memory.content, {
408
+ redactionEnabled: effectiveRedactionEnabled,
409
+ });
410
+ for (const code of redaction.warningCodes) {
411
+ warningCodeSet.add(code);
412
+ }
413
+ const embedding = deterministicEmbed(redaction.text, dimension);
414
+ upsertImportedMemory(db, {
415
+ id: memory.id,
416
+ project_id: parsed.projectId,
417
+ session_id: memory.sessionId,
418
+ source_adapter: memory.sourceAdapter,
419
+ kind: memory.kind,
420
+ content: redaction.text,
421
+ normalized_content: embedding.normalizedText,
422
+ importance: memory.importance,
423
+ embedding: JSON.stringify(embedding.vector),
424
+ embedding_dim: embedding.dimension,
425
+ embedding_version: embedding.embeddingVersion,
426
+ // Plain import (not a team pull): preserve an incoming author when
427
+ // the export carried one, else stamp the local username so the row
428
+ // is never left with an empty author. origin_project_id is carried
429
+ // through when present, else null for locally-originating rows.
430
+ author: memory.author && memory.author.trim() !== ""
431
+ ? memory.author
432
+ : localAuthor,
433
+ origin_project_id: memory.originProjectId ?? null,
434
+ created_at: clampDateToNow(memory.createdAt) ?? undefined,
435
+ updated_at: clampDateToNow(memory.updatedAt) ?? undefined,
436
+ });
437
+ imported += 1;
352
438
  }
353
- const embedding = deterministicEmbed(redaction.text, dimension);
354
- stmt.run({
355
- id: memory.id,
356
- project_id: parsed.projectId,
357
- session_id: memory.sessionId,
358
- source_adapter: memory.sourceAdapter,
359
- kind: memory.kind,
360
- content: redaction.text,
361
- normalized_content: embedding.normalizedText,
362
- importance: memory.importance,
363
- embedding: JSON.stringify(embedding.vector),
364
- embedding_dim: embedding.dimension,
365
- embedding_version: embedding.embeddingVersion,
366
- // Plain import (not a team pull): preserve an incoming author when the
367
- // export carried one, else stamp the local username so the row is
368
- // never left with an empty author. origin_project_id is carried
369
- // through when present, else null for locally-originating rows.
370
- author: memory.author && memory.author.trim() !== ""
371
- ? memory.author
372
- : localAuthor,
373
- origin_project_id: memory.originProjectId ?? null,
374
- created_at: memory.createdAt,
375
- updated_at: memory.updatedAt,
376
- });
377
- imported += 1;
378
- }
439
+ });
440
+ runImport();
379
441
  return {
380
442
  ok: true,
381
443
  imported,
382
444
  skippedCrossProject,
445
+ skippedExisting,
383
446
  warningCodes: [...warningCodeSet],
384
447
  };
385
448
  },
@@ -388,102 +451,74 @@ export function createMemoryCoreService(deps) {
388
451
  // Structural twin of importMemories with three team-pull changes:
389
452
  // - importance uses MAX(local, incoming) so a teammate can never lower a
390
453
  // locally-boosted importance (last-write-wins on content but
391
- // importance-preserving).
454
+ // importance-preserving). upsertPulledMemory carries that merge.
392
455
  // - author/origin_project_id are stamped from the incoming record's
393
456
  // provenance so pulled rows carry the teammate's identity and
394
457
  // their source project_id.
395
458
  // - 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
425
- `);
426
- // Same cross-project ownership skip as importMemories. A colliding
427
- // id owned by a different project is skipped, never overwritten/relocated.
428
- const ownerStmt = db.prepare("SELECT project_id FROM memories WHERE id = ?");
429
459
  const warningCodeSet = new Set();
430
460
  const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
431
461
  let pulledNew = 0;
432
462
  let pulledUpdated = 0;
433
463
  let skippedCrossProject = 0;
434
- for (const memory of parsed.memories) {
435
- const owner = ownerStmt.get(memory.id);
436
- if (owner && owner.project_id !== parsed.projectId) {
437
- skippedCrossProject += 1;
438
- continue;
439
- }
440
- // An id already owned by THIS project is an update; otherwise a
441
- // brand-new insert. Snapshotting per-id via ownerStmt keeps the count
442
- // correct even when the same id appears across multiple teammate files.
443
- const isUpdate = owner !== undefined;
444
- // Re-run redaction on every pulled record regardless of the
445
- // teammate's redaction setting (4th write path), then re-embed the
446
- // redacted text so secrets never persist and the embedding matches.
447
- const redaction = applyRedaction(memory.content, {
448
- redactionEnabled: effectiveRedactionEnabled,
449
- });
450
- for (const code of redaction.warningCodes) {
451
- warningCodeSet.add(code);
452
- }
453
- const embedding = deterministicEmbed(redaction.text, dimension);
454
- stmt.run({
455
- id: memory.id,
456
- // LOCAL project_id so merged rows are retrievable in the pulling
457
- // user's project (Open Q4).
458
- project_id: parsed.projectId,
459
- session_id: memory.sessionId,
460
- source_adapter: memory.sourceAdapter,
461
- kind: memory.kind,
462
- content: redaction.text,
463
- normalized_content: embedding.normalizedText,
464
- importance: memory.importance,
465
- embedding: JSON.stringify(embedding.vector),
466
- embedding_dim: embedding.dimension,
467
- embedding_version: embedding.embeddingVersion,
468
- // Stamp the teammate's provenance. author falls back to the
469
- // local username only when the incoming record carries none.
470
- author: memory.author && memory.author.trim() !== ""
471
- ? memory.author
472
- : localAuthor,
473
- // origin_project_id records the record's source-machine project_id:
474
- // its explicit originProjectId if present, else the record's own
475
- // incoming projectId (Open Q4).
476
- origin_project_id: memory.originProjectId ?? memory.projectId,
477
- created_at: memory.createdAt,
478
- updated_at: memory.updatedAt,
479
- });
480
- if (isUpdate) {
481
- pulledUpdated += 1;
482
- }
483
- else {
484
- pulledNew += 1;
464
+ // Wrap the whole batch in a single transaction so a mid-loop failure rolls
465
+ // back every upsert (no partial pull).
466
+ const runPull = db.transaction(() => {
467
+ for (const memory of parsed.memories) {
468
+ const ownerProjectId = getMemoryOwnerProjectId(db, memory.id);
469
+ if (ownerProjectId !== undefined && ownerProjectId !== parsed.projectId) {
470
+ skippedCrossProject += 1;
471
+ continue;
472
+ }
473
+ // An id already owned by THIS project is an update; otherwise a
474
+ // brand-new insert. Snapshotting per-id keeps the count correct even
475
+ // when the same id appears across multiple teammate files.
476
+ const isUpdate = ownerProjectId !== undefined;
477
+ // Re-run redaction on every pulled record regardless of the
478
+ // teammate's redaction setting (4th write path), then re-embed the
479
+ // redacted text so secrets never persist and the embedding matches.
480
+ const redaction = applyRedaction(memory.content, {
481
+ redactionEnabled: effectiveRedactionEnabled,
482
+ });
483
+ for (const code of redaction.warningCodes) {
484
+ warningCodeSet.add(code);
485
+ }
486
+ const embedding = deterministicEmbed(redaction.text, dimension);
487
+ upsertPulledMemory(db, {
488
+ id: memory.id,
489
+ // LOCAL project_id so merged rows are retrievable in the pulling
490
+ // user's project (Open Q4).
491
+ project_id: parsed.projectId,
492
+ session_id: memory.sessionId,
493
+ source_adapter: memory.sourceAdapter,
494
+ kind: memory.kind,
495
+ content: redaction.text,
496
+ normalized_content: embedding.normalizedText,
497
+ importance: memory.importance,
498
+ embedding: JSON.stringify(embedding.vector),
499
+ embedding_dim: embedding.dimension,
500
+ embedding_version: embedding.embeddingVersion,
501
+ // Stamp the teammate's provenance. author falls back to the
502
+ // local username only when the incoming record carries none.
503
+ author: memory.author && memory.author.trim() !== ""
504
+ ? memory.author
505
+ : localAuthor,
506
+ // origin_project_id records the record's source-machine project_id:
507
+ // its explicit originProjectId if present, else the record's own
508
+ // incoming projectId (Open Q4).
509
+ origin_project_id: memory.originProjectId ?? memory.projectId,
510
+ created_at: clampDateToNow(memory.createdAt) ?? undefined,
511
+ updated_at: clampDateToNow(memory.updatedAt) ?? undefined,
512
+ });
513
+ if (isUpdate) {
514
+ pulledUpdated += 1;
515
+ }
516
+ else {
517
+ pulledNew += 1;
518
+ }
485
519
  }
486
- }
520
+ });
521
+ runPull();
487
522
  return {
488
523
  ok: true,
489
524
  pulledNew,
@@ -537,8 +572,12 @@ export function createMemoryCoreService(deps) {
537
572
  // the limit isn't split into an unpaired surrogate.
538
573
  previews.push(Array.from(redaction.text).slice(0, REDACT_PREVIEW_MAX_LENGTH).join(""));
539
574
  if (parsed.apply) {
540
- // Recompute the embedding-normalized text on the redacted content so
541
- // the stored normalized_content stays consistent with the scrub.
575
+ // Recompute the embedding on the redacted content so BOTH the stored
576
+ // normalized_content AND the embedding vector track the scrub. Without
577
+ // re-embedding, the vector would remain a hash of the pre-redaction
578
+ // (secret-bearing) text — inconsistent with normalized_content and
579
+ // still ranking against the un-redacted body in semantic retrieval,
580
+ // defeating the purpose of the scrub.
542
581
  const embedding = deterministicEmbed(redaction.text, dimension);
543
582
  // A single row that was deleted concurrently between the
544
583
  // initial listMemoriesByProject snapshot and this update would
@@ -548,7 +587,11 @@ export function createMemoryCoreService(deps) {
548
587
  // wrapped in a transaction). Catch per-row and report it as
549
588
  // skipped instead.
550
589
  try {
551
- updateMemoryContent(db, parsed.projectId, memory.id, redaction.text, embedding.normalizedText);
590
+ updateMemoryContent(db, parsed.projectId, memory.id, redaction.text, embedding.normalizedText, {
591
+ vector: embedding.vector,
592
+ dimension: embedding.dimension,
593
+ embeddingVersion: embedding.embeddingVersion,
594
+ });
552
595
  updated += 1;
553
596
  }
554
597
  catch {
@@ -565,18 +608,144 @@ export function createMemoryCoreService(deps) {
565
608
  previews,
566
609
  };
567
610
  },
611
+ async batchStoreMemory(request) {
612
+ const parsed = parseRequest(batchStoreMemoryRequestSchema, request);
613
+ const uniqueSessions = new Set(parsed.memories.map((m) => m.sessionId));
614
+ const sessionOverLimit = new Set();
615
+ for (const sid of uniqueSessions) {
616
+ const count = countMemoriesBySession(db, sid, parsed.projectId);
617
+ if (count >= SESSION_WRITE_SOFT_LIMIT) {
618
+ sessionOverLimit.add(sid);
619
+ }
620
+ }
621
+ const results = [];
622
+ let stored = 0;
623
+ let failed = 0;
624
+ // Validate each item individually before entering the transaction so
625
+ // validation errors are reported per-item without aborting the whole batch.
626
+ const validatedItems = [];
627
+ for (let i = 0; i < parsed.memories.length; i++) {
628
+ const raw = parsed.memories[i];
629
+ try {
630
+ // The array items were already parsed by batchStoreMemoryRequestSchema,
631
+ // but we re-validate with the item schema so per-item errors are
632
+ // captured individually (e.g. if a caller bypasses the outer schema).
633
+ const item = batchStoreMemoryItemSchema.parse(raw);
634
+ validatedItems.push({ index: i, item });
635
+ }
636
+ catch (err) {
637
+ results.push({
638
+ memoryId: raw.memoryId ?? `<index-${i}>`,
639
+ ok: false,
640
+ error: err instanceof ZodError
641
+ ? err.issues.map((issue) => issue.message).join("; ")
642
+ : String(err),
643
+ });
644
+ failed += 1;
645
+ }
646
+ }
647
+ // Wrap all valid inserts in a single SQLite transaction for atomicity
648
+ // and performance (better-sqlite3 transactions avoid per-statement
649
+ // fsync, making batch inserts significantly faster).
650
+ if (validatedItems.length > 0) {
651
+ const runTransaction = db.transaction(() => {
652
+ for (const { item } of validatedItems) {
653
+ // Each insert is guarded individually so a duplicate-id (or other
654
+ // constraint) collision fails only that item instead of aborting the
655
+ // whole batch. A SQLite constraint violation rolls back only the
656
+ // current statement, not the surrounding transaction, so the loop can
657
+ // continue and the transaction still commits the successful inserts.
658
+ try {
659
+ const redaction = applyRedaction(item.content, {
660
+ redactionEnabled: resolveRedactionEnabled(item.redactionEnabled),
661
+ });
662
+ const embedding = deterministicEmbed(redaction.text, dimension);
663
+ insertMemory(db, {
664
+ id: item.memoryId,
665
+ project_id: parsed.projectId,
666
+ session_id: item.sessionId,
667
+ source_adapter: item.sourceAdapter,
668
+ kind: item.kind,
669
+ content: redaction.text,
670
+ normalized_content: embedding.normalizedText,
671
+ importance: item.importance,
672
+ embedding: JSON.stringify(embedding.vector),
673
+ embedding_dim: embedding.dimension,
674
+ embedding_version: embedding.embeddingVersion,
675
+ author: localAuthor,
676
+ origin_project_id: null,
677
+ });
678
+ const inserted = getMemoryRecordById(db, parsed.projectId, item.memoryId);
679
+ if (!inserted) {
680
+ throw new DomainError("INTERNAL", `Memory insert did not persist: ${item.memoryId}`);
681
+ }
682
+ const itemWarningCodes = [...redaction.warningCodes];
683
+ if (sessionOverLimit.has(item.sessionId)) {
684
+ itemWarningCodes.push("session_write_limit_warning");
685
+ }
686
+ results.push({
687
+ memoryId: item.memoryId,
688
+ ok: true,
689
+ // Capped echo-back (unlike single-record storeMemory): a batch
690
+ // returns up to MAX_BATCH_SIZE rows, so returning full content per
691
+ // row could overflow the agent context (parallel to listMemories).
692
+ // The caller already holds each original body; full content stays
693
+ // in the DB and is reachable via getMemory.
694
+ memory: toMemoryDto(inserted),
695
+ warningCodes: itemWarningCodes,
696
+ });
697
+ stored += 1;
698
+ }
699
+ catch (err) {
700
+ const code = err.code ?? "";
701
+ const message = err instanceof Error ? err.message : String(err);
702
+ const isConstraint = code.startsWith("SQLITE_CONSTRAINT") ||
703
+ /constraint failed/i.test(message);
704
+ if (!isConstraint) {
705
+ // Unexpected (non-constraint) failure — abort the whole
706
+ // transaction so we don't silently commit a corrupt partial batch.
707
+ throw err;
708
+ }
709
+ results.push({
710
+ memoryId: item.memoryId,
711
+ ok: false,
712
+ error: "duplicate id",
713
+ });
714
+ failed += 1;
715
+ }
716
+ }
717
+ });
718
+ runTransaction();
719
+ }
720
+ // Sort results back into original input order: validated items were
721
+ // processed in order but failed items were pushed first. Re-sort by
722
+ // the memoryId to maintain a predictable output. Since memoryIds are
723
+ // unique, use the input array order as the canonical sort key.
724
+ const inputOrder = new Map(parsed.memories.map((m, i) => [m.memoryId, i]));
725
+ results.sort((a, b) => (inputOrder.get(a.memoryId) ?? 0) - (inputOrder.get(b.memoryId) ?? 0));
726
+ return {
727
+ ok: true,
728
+ results,
729
+ stored,
730
+ failed,
731
+ };
732
+ },
568
733
  async stats(request) {
569
734
  const parsed = parseRequest(statsRequestSchema, request);
570
- const memoryCount = db
571
- .prepare("SELECT COUNT(*) AS count FROM memories WHERE project_id = ?")
572
- .get(parsed.projectId);
573
- const sessionEventCount = db
574
- .prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?")
575
- .get(parsed.projectId);
735
+ const totalMemories = countAllMemoriesByProject(db, parsed.projectId);
736
+ const totalSessionEvents = countAllSessionEvents(db, parsed.projectId);
737
+ return {
738
+ ok: true,
739
+ totalMemories,
740
+ totalSessionEvents,
741
+ };
742
+ },
743
+ async resetAccessCounts(request) {
744
+ const parsed = parseRequest(resetAccessCountsRequestSchema, request);
745
+ const affected = resetAccessCounts(db, parsed.projectId);
576
746
  return {
577
747
  ok: true,
578
- totalMemories: memoryCount.count,
579
- totalSessionEvents: sessionEventCount.count,
748
+ affected,
580
749
  };
581
750
  },
582
751
  };