sessionmem 1.0.6 → 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 (45) hide show
  1. package/dist/adapters/capabilities/fallbackTools.js +2 -2
  2. package/dist/adapters/claudeMdInjector.js +49 -5
  3. package/dist/adapters/factory.js +68 -9
  4. package/dist/adapters/generic.js +147 -12
  5. package/dist/adapters/global/antigravity.js +14 -7
  6. package/dist/adapters/global/claudeCode.js +46 -10
  7. package/dist/adapters/global/codex.js +73 -13
  8. package/dist/adapters/global/qcoder.js +18 -5
  9. package/dist/adapters/ide/cline.js +54 -9
  10. package/dist/adapters/ide/cursor.js +15 -13
  11. package/dist/adapters/ide/installer.js +201 -8
  12. package/dist/adapters/ide/windsurf.js +14 -13
  13. package/dist/cli/commands/config.js +10 -1
  14. package/dist/cli/commands/import.js +6 -1
  15. package/dist/cli/commands/install.js +57 -16
  16. package/dist/cli/commands/ping.js +42 -8
  17. package/dist/cli/commands/reEmbed.js +4 -3
  18. package/dist/cli/commands/run.js +7 -17
  19. package/dist/cli/commands/savings.js +33 -17
  20. package/dist/cli/commands/sessionEnd.js +124 -0
  21. package/dist/cli/commands/sessionStart.js +52 -0
  22. package/dist/cli/commands/sync.js +39 -9
  23. package/dist/cli/commands/uninstall.js +35 -9
  24. package/dist/cli/context.js +14 -18
  25. package/dist/cli/index.js +16 -4
  26. package/dist/cli/projectId.js +69 -0
  27. package/dist/core/api/contracts.js +155 -42
  28. package/dist/core/api/errors.js +4 -7
  29. package/dist/core/api/memoryCoreService.js +319 -252
  30. package/dist/core/api/sessionLifecycleService.js +8 -0
  31. package/dist/core/config/policyConfig.js +33 -6
  32. package/dist/core/injection/formatStartupInjection.js +53 -9
  33. package/dist/core/retrieve/recencyBands.js +4 -1
  34. package/dist/core/retrieve/retrieveMemories.js +10 -8
  35. package/dist/core/schema/migrations/005_team_provenance.sql +5 -0
  36. package/dist/core/schema/migrations/006_access_pattern_boosting.sql +5 -0
  37. package/dist/core/schema/migrations/008_fts5_search.sql +6 -2
  38. package/dist/core/schema/migrations/009_session_events_unique.sql +24 -0
  39. package/dist/core/schema/runMigrations.js +64 -2
  40. package/dist/core/storage/memoryRepo.js +164 -7
  41. package/dist/core/storage/memorySearchRepo.js +45 -7
  42. package/dist/core/storage/sessionEventsRepo.js +15 -2
  43. package/dist/core/summarize/cloudSummarizer.js +15 -2
  44. package/dist/core/summarize/redaction.js +45 -8
  45. package/package.json +2 -2
@@ -5,11 +5,11 @@ import { retrieveMemories } from "../retrieve/retrieveMemories.js";
5
5
  import { computeEffectiveImportance } from "../retrieve/score.js";
6
6
  import { formatStartupInjection } from "../injection/formatStartupInjection.js";
7
7
  import { applyRedaction } from "../summarize/redaction.js";
8
- import { countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
8
+ import { countAllMemoriesByProject, countMemoriesBySession, countMemoriesOlderThan, deleteMemoriesOlderThan, deleteMemoryById, getMemoryOwnerProjectId, getMemoryRecordById, incrementAccessCounts, insertMemory, listMemoriesByProject, resetAccessCounts, updateMemoryContent, upsertImportedMemory, upsertPulledMemory, upsertSessionSummaryMemory, } from "../storage/memoryRepo.js";
9
9
  import { SESSION_WRITE_SOFT_LIMIT, configFilePath, DEEP_MODE_RETRIEVAL_CAP, readPolicyConfig, resolvePolicySettings, } from "../config/policyConfig.js";
10
10
  import { insertMemoryFeedbackEvent } from "../storage/memoryFeedbackRepo.js";
11
- import { insertSessionEvent } from "../storage/sessionEventsRepo.js";
12
- import { batchStoreMemoryItemSchema, batchStoreMemoryRequestSchema, exportMemoriesRequestSchema, forgetMemoryRequestSchema, getMemoryRequestSchema, handleSessionEndRequestSchema, importMemoriesRequestSchema, pullMemoriesRequestSchema, ingestSessionEventsRequestSchema, listMemoriesRequestSchema, pruneMemoriesRequestSchema, redactExistingRequestSchema, resetAccessCountsRequestSchema, retrieveMemoriesRequestSchema, statsRequestSchema, storeMemoryRequestSchema, summarizeSessionToMemoryRequestSchema, } from "./contracts.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";
13
13
  import { DomainError, toErrorEnvelope } from "./errors.js";
14
14
  import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
15
15
  import { createSessionLifecycleService } from "./sessionLifecycleService.js";
@@ -35,6 +35,28 @@ function resolveServiceUsername(explicit) {
35
35
  return "";
36
36
  }
37
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
+ }
38
60
  function toMemoryDto(record) {
39
61
  return {
40
62
  id: record.id,
@@ -42,10 +64,10 @@ function toMemoryDto(record) {
42
64
  sessionId: record.session_id,
43
65
  sourceAdapter: record.source_adapter,
44
66
  kind: record.kind,
45
- content: record.content,
46
- 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,
47
69
  importance: record.importance,
48
- embedding: record.embedding,
70
+ embedding: null,
49
71
  embeddingDim: record.embedding_dim,
50
72
  embeddingVersion: record.embedding_version,
51
73
  author: record.author,
@@ -57,6 +79,21 @@ function toMemoryDto(record) {
57
79
  updatedAt: record.updated_at,
58
80
  };
59
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
+ }
60
97
  function toRetrievedMemoryDto(record) {
61
98
  return {
62
99
  id: record.id,
@@ -64,8 +101,10 @@ function toRetrievedMemoryDto(record) {
64
101
  sessionId: record.session_id,
65
102
  sourceAdapter: record.source_adapter,
66
103
  kind: record.kind,
67
- content: record.content,
68
- 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,
69
108
  importance: record.importance,
70
109
  embedding: null,
71
110
  embeddingDim: record.embedding_dim,
@@ -81,20 +120,6 @@ function toRetrievedMemoryDto(record) {
81
120
  score: record.score,
82
121
  };
83
122
  }
84
- function getMemoryById(db, projectId, memoryId) {
85
- const row = db
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
94
- `)
95
- .get(projectId, memoryId);
96
- return row;
97
- }
98
123
  function parseRequest(schema, request) {
99
124
  try {
100
125
  return schema.parse(request);
@@ -144,32 +169,56 @@ export function createMemoryCoreService(deps) {
144
169
  const methods = {
145
170
  async ingestSessionEvents(request) {
146
171
  const parsed = parseRequest(ingestSessionEventsRequestSchema, request);
147
- for (const event of parsed.events) {
148
- insertSessionEvent(db, {
149
- id: event.id,
150
- project_id: parsed.projectId,
151
- session_id: parsed.sessionId,
152
- event_index: event.eventIndex,
153
- event_type: event.eventType,
154
- payload_json: event.payloadJson,
155
- created_at: event.createdAt,
156
- });
157
- }
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();
158
200
  return {
159
201
  ok: true,
160
- ingested: parsed.events.length,
202
+ ingested,
161
203
  };
162
204
  },
163
205
  async summarizeSessionToMemory(request) {
164
206
  const parsed = parseRequest(summarizeSessionToMemoryRequestSchema, request);
165
- 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);
166
215
  upsertSessionSummaryMemory(db, {
167
216
  id: parsed.memoryId,
168
217
  project_id: parsed.projectId,
169
218
  session_id: parsed.sessionId,
170
219
  source_adapter: parsed.sourceAdapter,
171
220
  kind: "summary",
172
- content: parsed.summary,
221
+ content: redaction.text,
173
222
  normalized_content: embedding.normalizedText,
174
223
  importance: parsed.importance,
175
224
  embedding: JSON.stringify(embedding.vector),
@@ -200,7 +249,7 @@ export function createMemoryCoreService(deps) {
200
249
  // already accumulated SESSION_WRITE_SOFT_LIMIT memories so the agent
201
250
  // gets feedback to stop storing excessively. The write still proceeds.
202
251
  const warningCodes = [...redaction.warningCodes];
203
- const sessionCount = countMemoriesBySession(db, parsed.sessionId);
252
+ const sessionCount = countMemoriesBySession(db, parsed.sessionId, parsed.projectId);
204
253
  if (sessionCount >= SESSION_WRITE_SOFT_LIMIT) {
205
254
  warningCodes.push("session_write_limit_warning");
206
255
  }
@@ -221,13 +270,19 @@ export function createMemoryCoreService(deps) {
221
270
  author: localAuthor,
222
271
  origin_project_id: null,
223
272
  });
224
- const inserted = getMemoryById(db, parsed.projectId, parsed.memoryId);
273
+ const inserted = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
225
274
  if (!inserted) {
226
275
  throw new DomainError("INTERNAL", "Memory insert did not persist");
227
276
  }
228
277
  return {
229
278
  ok: true,
230
- memory: toMemoryDto(inserted),
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),
231
286
  warningCodes,
232
287
  };
233
288
  },
@@ -240,51 +295,61 @@ export function createMemoryCoreService(deps) {
240
295
  queryText: parsed.query,
241
296
  limit,
242
297
  });
243
- if (ranked.length > 0) {
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.
244
302
  incrementAccessCounts(db, parsed.projectId, ranked.map((m) => m.id));
245
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;
246
307
  return {
247
308
  ok: true,
248
309
  memories: ranked.map(toRetrievedMemoryDto),
249
310
  total: ranked.length,
250
311
  startupInjection: formatStartupInjection(ranked, {
251
312
  localUsername: localAuthor,
313
+ tokenCap: injectionCap,
252
314
  }),
253
315
  };
254
316
  },
255
317
  async listMemories(request) {
256
318
  const parsed = parseRequest(listMemoriesRequestSchema, request);
257
- 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);
258
325
  return {
259
326
  ok: true,
260
327
  memories: memories.map(toMemoryDto),
261
- total: memories.length,
328
+ total: all.length,
262
329
  };
263
330
  },
264
331
  async getMemory(request) {
265
332
  const parsed = parseRequest(getMemoryRequestSchema, request);
266
- const memory = getMemoryById(db, parsed.projectId, parsed.memoryId);
333
+ const memory = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
267
334
  if (!memory) {
268
335
  throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
269
336
  }
270
337
  return {
271
338
  ok: true,
272
- memory: toMemoryDto(memory),
339
+ memory: toExportMemoryDto(memory),
273
340
  };
274
341
  },
275
342
  async forgetMemory(request) {
276
343
  const parsed = parseRequest(forgetMemoryRequestSchema, request);
277
344
  // Capture the memory's importance before deletion so we can record
278
345
  // it in the feedback table as an analytics signal.
279
- const existing = getMemoryById(db, parsed.projectId, parsed.memoryId);
280
- const result = db
281
- .prepare("DELETE FROM memories WHERE project_id = ? AND id = ?")
282
- .run(parsed.projectId, parsed.memoryId);
283
- if (result.changes === 0) {
346
+ const existing = getMemoryRecordById(db, parsed.projectId, parsed.memoryId);
347
+ const deleted = deleteMemoryById(db, parsed.projectId, parsed.memoryId);
348
+ if (deleted === 0) {
284
349
  throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
285
350
  }
286
351
  // Record the explicit user deletion as feedback. The FK on
287
- // memory_feedback no longer cascades (migration 006), so this row
352
+ // memory_feedback no longer cascades (migration 007), so this row
288
353
  // survives the memory deletion and serves as an analytics signal.
289
354
  insertMemoryFeedbackEvent(db, {
290
355
  memory_id: parsed.memoryId,
@@ -301,45 +366,16 @@ export function createMemoryCoreService(deps) {
301
366
  const memories = listMemoriesByProject(db, parsed.projectId);
302
367
  return {
303
368
  ok: true,
304
- memories: memories.map(toMemoryDto),
369
+ memories: memories.map(toExportMemoryDto),
305
370
  };
306
371
  },
307
372
  async importMemories(request) {
308
373
  const parsed = parseRequest(importMemoriesRequestSchema, request);
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
335
- `);
336
- // `id` is a globally-unique PRIMARY KEY (not scoped by
337
- // project_id). The upsert above reassigns `project_id = excluded.project_id`
338
- // on conflict, which would let an imported record silently overwrite and
339
- // relocate another project's memory if its `id` happens to collide.
340
- // Look up existing ownership per id and skip (rather than upsert) any
341
- // record whose id already belongs to a *different* project.
342
- 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.
343
379
  // Aggregate redaction warnings across all imported records. A
344
380
  // Set de-duplicates the redaction_partial_failure code so the envelope
345
381
  // stays compact regardless of how many records tripped the same rule.
@@ -347,52 +383,66 @@ export function createMemoryCoreService(deps) {
347
383
  const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
348
384
  let imported = 0;
349
385
  let skippedCrossProject = 0;
350
- for (const memory of parsed.memories) {
351
- const owner = ownerStmt.get(memory.id);
352
- if (owner && owner.project_id !== parsed.projectId) {
353
- // Another project already owns this id: skip rather than overwrite
354
- // and reassign ownership via ON CONFLICT(id).
355
- skippedCrossProject += 1;
356
- continue;
357
- }
358
- // Redact each record before embedding/upsert so secrets never persist
359
- // and the embedding reflects the redacted text.
360
- const redaction = applyRedaction(memory.content, {
361
- redactionEnabled: effectiveRedactionEnabled,
362
- });
363
- for (const code of redaction.warningCodes) {
364
- 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;
365
438
  }
366
- const embedding = deterministicEmbed(redaction.text, dimension);
367
- stmt.run({
368
- id: memory.id,
369
- project_id: parsed.projectId,
370
- session_id: memory.sessionId,
371
- source_adapter: memory.sourceAdapter,
372
- kind: memory.kind,
373
- content: redaction.text,
374
- normalized_content: embedding.normalizedText,
375
- importance: memory.importance,
376
- embedding: JSON.stringify(embedding.vector),
377
- embedding_dim: embedding.dimension,
378
- embedding_version: embedding.embeddingVersion,
379
- // Plain import (not a team pull): preserve an incoming author when the
380
- // export carried one, else stamp the local username so the row is
381
- // never left with an empty author. origin_project_id is carried
382
- // through when present, else null for locally-originating rows.
383
- author: memory.author && memory.author.trim() !== ""
384
- ? memory.author
385
- : localAuthor,
386
- origin_project_id: memory.originProjectId ?? null,
387
- created_at: memory.createdAt,
388
- updated_at: memory.updatedAt,
389
- });
390
- imported += 1;
391
- }
439
+ });
440
+ runImport();
392
441
  return {
393
442
  ok: true,
394
443
  imported,
395
444
  skippedCrossProject,
445
+ skippedExisting,
396
446
  warningCodes: [...warningCodeSet],
397
447
  };
398
448
  },
@@ -401,102 +451,74 @@ export function createMemoryCoreService(deps) {
401
451
  // Structural twin of importMemories with three team-pull changes:
402
452
  // - importance uses MAX(local, incoming) so a teammate can never lower a
403
453
  // locally-boosted importance (last-write-wins on content but
404
- // importance-preserving).
454
+ // importance-preserving). upsertPulledMemory carries that merge.
405
455
  // - author/origin_project_id are stamped from the incoming record's
406
456
  // provenance so pulled rows carry the teammate's identity and
407
457
  // their source project_id.
408
458
  // - cross-project id collisions are skipped, exactly as import.
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
438
- `);
439
- // Same cross-project ownership skip as importMemories. A colliding
440
- // id owned by a different project is skipped, never overwritten/relocated.
441
- const ownerStmt = db.prepare("SELECT project_id FROM memories WHERE id = ?");
442
459
  const warningCodeSet = new Set();
443
460
  const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
444
461
  let pulledNew = 0;
445
462
  let pulledUpdated = 0;
446
463
  let skippedCrossProject = 0;
447
- for (const memory of parsed.memories) {
448
- const owner = ownerStmt.get(memory.id);
449
- if (owner && owner.project_id !== parsed.projectId) {
450
- skippedCrossProject += 1;
451
- continue;
452
- }
453
- // An id already owned by THIS project is an update; otherwise a
454
- // brand-new insert. Snapshotting per-id via ownerStmt keeps the count
455
- // correct even when the same id appears across multiple teammate files.
456
- const isUpdate = owner !== undefined;
457
- // Re-run redaction on every pulled record regardless of the
458
- // teammate's redaction setting (4th write path), then re-embed the
459
- // redacted text so secrets never persist and the embedding matches.
460
- const redaction = applyRedaction(memory.content, {
461
- redactionEnabled: effectiveRedactionEnabled,
462
- });
463
- for (const code of redaction.warningCodes) {
464
- warningCodeSet.add(code);
465
- }
466
- const embedding = deterministicEmbed(redaction.text, dimension);
467
- stmt.run({
468
- id: memory.id,
469
- // LOCAL project_id so merged rows are retrievable in the pulling
470
- // user's project (Open Q4).
471
- project_id: parsed.projectId,
472
- session_id: memory.sessionId,
473
- source_adapter: memory.sourceAdapter,
474
- kind: memory.kind,
475
- content: redaction.text,
476
- normalized_content: embedding.normalizedText,
477
- importance: memory.importance,
478
- embedding: JSON.stringify(embedding.vector),
479
- embedding_dim: embedding.dimension,
480
- embedding_version: embedding.embeddingVersion,
481
- // Stamp the teammate's provenance. author falls back to the
482
- // local username only when the incoming record carries none.
483
- author: memory.author && memory.author.trim() !== ""
484
- ? memory.author
485
- : localAuthor,
486
- // origin_project_id records the record's source-machine project_id:
487
- // its explicit originProjectId if present, else the record's own
488
- // incoming projectId (Open Q4).
489
- origin_project_id: memory.originProjectId ?? memory.projectId,
490
- created_at: memory.createdAt,
491
- updated_at: memory.updatedAt,
492
- });
493
- if (isUpdate) {
494
- pulledUpdated += 1;
495
- }
496
- else {
497
- 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
+ }
498
519
  }
499
- }
520
+ });
521
+ runPull();
500
522
  return {
501
523
  ok: true,
502
524
  pulledNew,
@@ -550,8 +572,12 @@ export function createMemoryCoreService(deps) {
550
572
  // the limit isn't split into an unpaired surrogate.
551
573
  previews.push(Array.from(redaction.text).slice(0, REDACT_PREVIEW_MAX_LENGTH).join(""));
552
574
  if (parsed.apply) {
553
- // Recompute the embedding-normalized text on the redacted content so
554
- // 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.
555
581
  const embedding = deterministicEmbed(redaction.text, dimension);
556
582
  // A single row that was deleted concurrently between the
557
583
  // initial listMemoriesByProject snapshot and this update would
@@ -561,7 +587,11 @@ export function createMemoryCoreService(deps) {
561
587
  // wrapped in a transaction). Catch per-row and report it as
562
588
  // skipped instead.
563
589
  try {
564
- 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
+ });
565
595
  updated += 1;
566
596
  }
567
597
  catch {
@@ -580,6 +610,14 @@ export function createMemoryCoreService(deps) {
580
610
  },
581
611
  async batchStoreMemory(request) {
582
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
+ }
583
621
  const results = [];
584
622
  let stored = 0;
585
623
  let failed = 0;
@@ -612,36 +650,69 @@ export function createMemoryCoreService(deps) {
612
650
  if (validatedItems.length > 0) {
613
651
  const runTransaction = db.transaction(() => {
614
652
  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}`);
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;
637
715
  }
638
- results.push({
639
- memoryId: item.memoryId,
640
- ok: true,
641
- memory: toMemoryDto(inserted),
642
- warningCodes: redaction.warningCodes,
643
- });
644
- stored += 1;
645
716
  }
646
717
  });
647
718
  runTransaction();
@@ -661,16 +732,12 @@ export function createMemoryCoreService(deps) {
661
732
  },
662
733
  async stats(request) {
663
734
  const parsed = parseRequest(statsRequestSchema, request);
664
- const memoryCount = db
665
- .prepare("SELECT COUNT(*) AS count FROM memories WHERE project_id = ?")
666
- .get(parsed.projectId);
667
- const sessionEventCount = db
668
- .prepare("SELECT COUNT(*) AS count FROM session_events WHERE project_id = ?")
669
- .get(parsed.projectId);
735
+ const totalMemories = countAllMemoriesByProject(db, parsed.projectId);
736
+ const totalSessionEvents = countAllSessionEvents(db, parsed.projectId);
670
737
  return {
671
738
  ok: true,
672
- totalMemories: memoryCount.count,
673
- totalSessionEvents: sessionEventCount.count,
739
+ totalMemories,
740
+ totalSessionEvents,
674
741
  };
675
742
  },
676
743
  async resetAccessCounts(request) {