sessionmem 1.0.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +344 -0
  3. package/dist/adapters/capabilities/fallbackTools.js +36 -0
  4. package/dist/adapters/contract/hostAdapterContract.js +1 -0
  5. package/dist/adapters/factory.js +40 -0
  6. package/dist/adapters/generic.js +128 -0
  7. package/dist/adapters/global/antigravity.js +22 -0
  8. package/dist/adapters/global/claudeCode.js +22 -0
  9. package/dist/adapters/global/codex.js +22 -0
  10. package/dist/adapters/global/qcoder.js +22 -0
  11. package/dist/adapters/ide/cline.js +20 -0
  12. package/dist/adapters/ide/cursor.js +28 -0
  13. package/dist/adapters/ide/installer.js +57 -0
  14. package/dist/adapters/ide/windsurf.js +28 -0
  15. package/dist/adapters/tools/ping.js +15 -0
  16. package/dist/cli/commands/config.js +79 -0
  17. package/dist/cli/commands/export.js +28 -0
  18. package/dist/cli/commands/forget.js +28 -0
  19. package/dist/cli/commands/import.js +112 -0
  20. package/dist/cli/commands/install.js +57 -0
  21. package/dist/cli/commands/list.js +13 -0
  22. package/dist/cli/commands/ping.js +12 -0
  23. package/dist/cli/commands/redactScan.js +40 -0
  24. package/dist/cli/commands/retention.js +54 -0
  25. package/dist/cli/commands/run.js +26 -0
  26. package/dist/cli/commands/search.js +29 -0
  27. package/dist/cli/commands/show.js +15 -0
  28. package/dist/cli/commands/stats.js +46 -0
  29. package/dist/cli/commands/sync.js +118 -0
  30. package/dist/cli/commands/team.js +96 -0
  31. package/dist/cli/commands/uninstall.js +30 -0
  32. package/dist/cli/context.js +69 -0
  33. package/dist/cli/index.js +147 -0
  34. package/dist/cli/output.js +37 -0
  35. package/dist/core/api/contracts.js +263 -0
  36. package/dist/core/api/errors.js +29 -0
  37. package/dist/core/api/localOnlyPolicy.js +29 -0
  38. package/dist/core/api/memoryCoreService.js +595 -0
  39. package/dist/core/api/sessionLifecycleService.js +289 -0
  40. package/dist/core/config/policyConfig.js +131 -0
  41. package/dist/core/embed/deterministicEmbed.js +31 -0
  42. package/dist/core/embed/embeddingVersion.js +1 -0
  43. package/dist/core/embed/reembedPolicy.js +9 -0
  44. package/dist/core/embed/textNormalize.js +12 -0
  45. package/dist/core/injection/formatStartupInjection.js +97 -0
  46. package/dist/core/injection/tokenBudget.js +38 -0
  47. package/dist/core/retrieve/decay.js +15 -0
  48. package/dist/core/retrieve/importance.js +6 -0
  49. package/dist/core/retrieve/recencyBands.js +18 -0
  50. package/dist/core/retrieve/retrieveMemories.js +83 -0
  51. package/dist/core/retrieve/score.js +25 -0
  52. package/dist/core/schema/migrations/001_initial.sql +25 -0
  53. package/dist/core/schema/migrations/002_indexes.sql +18 -0
  54. package/dist/core/schema/migrations/003_summarization_failures.sql +14 -0
  55. package/dist/core/schema/migrations/004_memory_feedback.sql +12 -0
  56. package/dist/core/schema/migrations/005_team_provenance.sql +9 -0
  57. package/dist/core/schema/runMigrations.js +38 -0
  58. package/dist/core/session.js +4 -0
  59. package/dist/core/storage/db.js +8 -0
  60. package/dist/core/storage/memoryFeedbackRepo.js +16 -0
  61. package/dist/core/storage/memoryRepo.js +179 -0
  62. package/dist/core/storage/memorySearchRepo.js +30 -0
  63. package/dist/core/storage/sessionEventsRepo.js +20 -0
  64. package/dist/core/storage/summarizationFailuresRepo.js +39 -0
  65. package/dist/core/storage/types.js +1 -0
  66. package/dist/core/summarize/cloudSummarizer.js +19 -0
  67. package/dist/core/summarize/localSummarizer.js +31 -0
  68. package/dist/core/summarize/redaction.js +48 -0
  69. package/dist/core/summarize/strategySelector.js +7 -0
  70. package/dist/core/summarize/summaryShape.js +49 -0
  71. package/package.json +48 -0
@@ -0,0 +1,595 @@
1
+ import { userInfo } from "node:os";
2
+ import { ZodError } from "zod";
3
+ import { deterministicEmbed } from "../embed/deterministicEmbed.js";
4
+ import { retrieveMemories } from "../retrieve/retrieveMemories.js";
5
+ import { formatStartupInjection } from "../injection/formatStartupInjection.js";
6
+ 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";
11
+ import { DomainError, toErrorEnvelope } from "./errors.js";
12
+ import { assertLocalOnlyPolicy, } from "./localOnlyPolicy.js";
13
+ import { createSessionLifecycleService } from "./sessionLifecycleService.js";
14
+ const DEFAULT_EMBEDDING_DIMENSION = 32;
15
+ // Maximum length of a redactExisting preview entry. Previews are built from the
16
+ // REDACTED text (never the raw secret) and truncated so a long
17
+ // memory body cannot leak surrounding context in bulk.
18
+ const REDACT_PREVIEW_MAX_LENGTH = 120;
19
+ /**
20
+ * Resolve the local OS username for author stamping, sanitized to a
21
+ * filename-safe token and falling back to "" when unavailable. Mirrors
22
+ * cli/context.localUsername but without a "user" fallback so the service can be
23
+ * driven with an explicit username dep in tests.
24
+ */
25
+ function resolveServiceUsername(explicit) {
26
+ if (explicit !== undefined) {
27
+ return explicit;
28
+ }
29
+ try {
30
+ return (userInfo().username ?? "").replace(/[^A-Za-z0-9._-]/g, "_");
31
+ }
32
+ catch {
33
+ return "";
34
+ }
35
+ }
36
+ function toMemoryDto(record) {
37
+ return {
38
+ id: record.id,
39
+ projectId: record.project_id,
40
+ sessionId: record.session_id,
41
+ sourceAdapter: record.source_adapter,
42
+ kind: record.kind,
43
+ content: record.content,
44
+ normalizedContent: record.normalized_content,
45
+ importance: record.importance,
46
+ embedding: record.embedding,
47
+ embeddingDim: record.embedding_dim,
48
+ embeddingVersion: record.embedding_version,
49
+ author: record.author,
50
+ originProjectId: record.origin_project_id,
51
+ createdAt: record.created_at,
52
+ updatedAt: record.updated_at,
53
+ };
54
+ }
55
+ function toRetrievedMemoryDto(record) {
56
+ return {
57
+ id: record.id,
58
+ projectId: record.project_id,
59
+ sessionId: record.session_id,
60
+ sourceAdapter: record.source_adapter,
61
+ kind: record.kind,
62
+ content: record.content,
63
+ normalizedContent: record.normalized_content,
64
+ importance: record.importance,
65
+ embedding: null,
66
+ embeddingDim: record.embedding_dim,
67
+ embeddingVersion: record.embedding_version,
68
+ author: record.author,
69
+ originProjectId: record.origin_project_id,
70
+ createdAt: record.created_at,
71
+ updatedAt: record.updated_at,
72
+ semantic: record.semantic,
73
+ score: record.score,
74
+ };
75
+ }
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
+ function parseRequest(schema, request) {
91
+ try {
92
+ return schema.parse(request);
93
+ }
94
+ catch (error) {
95
+ if (error instanceof ZodError) {
96
+ throw new DomainError("VALIDATION", "Invalid request payload", error.issues);
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+ function toErrorResponse(error) {
102
+ return {
103
+ ok: false,
104
+ error: toErrorEnvelope(error),
105
+ };
106
+ }
107
+ export function createMemoryCoreService(deps) {
108
+ assertLocalOnlyPolicy(deps.policyConfig ?? { localOnly: true });
109
+ const dimension = deps.embeddingDimension ?? DEFAULT_EMBEDDING_DIMENSION;
110
+ const { db } = deps;
111
+ // Resolve the local author identity once per service instance.
112
+ const localAuthor = resolveServiceUsername(deps.username);
113
+ const policyConfigPath = deps.policyConfigPath ?? configFilePath();
114
+ /**
115
+ * Resolve the effective redactionEnabled flag using precedence
116
+ * override (explicit per-request value) > config.json > default,
117
+ * mirroring resolveRetentionDays in sessionLifecycleService. An explicit
118
+ * `false` or `true` on the request always wins; omission falls back to
119
+ * `~/.sessionmem/config.json`'s redactionEnabled.
120
+ */
121
+ function resolveRedactionEnabled(explicit) {
122
+ return resolvePolicySettings({
123
+ override: explicit !== undefined ? { redactionEnabled: explicit } : undefined,
124
+ config: readPolicyConfig(policyConfigPath),
125
+ }).redactionEnabled;
126
+ }
127
+ const lifecycleService = createSessionLifecycleService({
128
+ db,
129
+ embeddingDimension: dimension,
130
+ username: localAuthor,
131
+ policyConfigPath: deps.policyConfigPath,
132
+ retentionDaysOverride: deps.retentionDaysOverride,
133
+ now: deps.now,
134
+ deleteOldMemories: deps.deleteOldMemories,
135
+ });
136
+ const methods = {
137
+ async ingestSessionEvents(request) {
138
+ 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
+ }
150
+ return {
151
+ ok: true,
152
+ ingested: parsed.events.length,
153
+ };
154
+ },
155
+ async summarizeSessionToMemory(request) {
156
+ const parsed = parseRequest(summarizeSessionToMemoryRequestSchema, request);
157
+ const embedding = deterministicEmbed(parsed.summary, dimension);
158
+ upsertSessionSummaryMemory(db, {
159
+ id: parsed.memoryId,
160
+ project_id: parsed.projectId,
161
+ session_id: parsed.sessionId,
162
+ source_adapter: parsed.sourceAdapter,
163
+ kind: "summary",
164
+ content: parsed.summary,
165
+ normalized_content: embedding.normalizedText,
166
+ importance: parsed.importance,
167
+ embedding: JSON.stringify(embedding.vector),
168
+ embedding_dim: embedding.dimension,
169
+ embedding_version: embedding.embeddingVersion,
170
+ author: localAuthor,
171
+ origin_project_id: null,
172
+ });
173
+ return {
174
+ ok: true,
175
+ memoryId: parsed.memoryId,
176
+ };
177
+ },
178
+ async handleSessionEnd(request) {
179
+ const parsed = parseRequest(handleSessionEndRequestSchema, request);
180
+ return lifecycleService.handleSessionEnd(parsed);
181
+ },
182
+ async storeMemory(request) {
183
+ const parsed = parseRequest(storeMemoryRequestSchema, request);
184
+ // Redact before embedding/persisting so secrets never reach storage and
185
+ // the embedding is computed on the redacted text. warningCodes
186
+ // reuse the existing redaction_partial_failure mechanism.
187
+ const redaction = applyRedaction(parsed.content, {
188
+ redactionEnabled: resolveRedactionEnabled(parsed.redactionEnabled),
189
+ });
190
+ const embedding = deterministicEmbed(redaction.text, dimension);
191
+ insertMemory(db, {
192
+ id: parsed.memoryId,
193
+ project_id: parsed.projectId,
194
+ session_id: parsed.sessionId,
195
+ source_adapter: parsed.sourceAdapter,
196
+ kind: parsed.kind,
197
+ content: redaction.text,
198
+ normalized_content: embedding.normalizedText,
199
+ importance: parsed.importance,
200
+ embedding: JSON.stringify(embedding.vector),
201
+ embedding_dim: embedding.dimension,
202
+ embedding_version: embedding.embeddingVersion,
203
+ // Locally-authored row: stamp the local username; origin is null
204
+ // because this row did not come from another project's store.
205
+ author: localAuthor,
206
+ origin_project_id: null,
207
+ });
208
+ const inserted = getMemoryById(db, parsed.projectId, parsed.memoryId);
209
+ if (!inserted) {
210
+ throw new DomainError("INTERNAL", "Memory insert did not persist");
211
+ }
212
+ return {
213
+ ok: true,
214
+ memory: toMemoryDto(inserted),
215
+ warningCodes: redaction.warningCodes,
216
+ };
217
+ },
218
+ async retrieveMemories(request) {
219
+ const parsed = parseRequest(retrieveMemoriesRequestSchema, request);
220
+ const limit = parsed.depth === "deep" ? Math.min(parsed.limit * 2, 100) : parsed.limit;
221
+ const ranked = retrieveMemories({
222
+ db,
223
+ projectId: parsed.projectId,
224
+ queryText: parsed.query,
225
+ limit,
226
+ });
227
+ return {
228
+ ok: true,
229
+ memories: ranked.map(toRetrievedMemoryDto),
230
+ 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
+ startupInjection: formatStartupInjection(ranked, {
235
+ localUsername: localAuthor,
236
+ }),
237
+ };
238
+ },
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
+ async listMemories(request) {
255
+ const parsed = parseRequest(listMemoriesRequestSchema, request);
256
+ const memories = listMemoriesByProject(db, parsed.projectId);
257
+ return {
258
+ ok: true,
259
+ memories: memories.map(toMemoryDto),
260
+ total: memories.length,
261
+ };
262
+ },
263
+ async getMemory(request) {
264
+ const parsed = parseRequest(getMemoryRequestSchema, request);
265
+ const memory = getMemoryById(db, parsed.projectId, parsed.memoryId);
266
+ if (!memory) {
267
+ throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
268
+ }
269
+ return {
270
+ ok: true,
271
+ memory: toMemoryDto(memory),
272
+ };
273
+ },
274
+ async forgetMemory(request) {
275
+ 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) {
280
+ throw new DomainError("NOT_FOUND", `Memory not found: ${parsed.memoryId}`);
281
+ }
282
+ return {
283
+ ok: true,
284
+ };
285
+ },
286
+ async exportMemories(request) {
287
+ const parsed = parseRequest(exportMemoriesRequestSchema, request);
288
+ const memories = listMemoriesByProject(db, parsed.projectId);
289
+ return {
290
+ ok: true,
291
+ memories: memories.map(toMemoryDto),
292
+ };
293
+ },
294
+ async importMemories(request) {
295
+ 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 = ?");
330
+ // Aggregate redaction warnings across all imported records. A
331
+ // Set de-duplicates the redaction_partial_failure code so the envelope
332
+ // stays compact regardless of how many records tripped the same rule.
333
+ const warningCodeSet = new Set();
334
+ const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
335
+ let imported = 0;
336
+ 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);
352
+ }
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
+ }
379
+ return {
380
+ ok: true,
381
+ imported,
382
+ skippedCrossProject,
383
+ warningCodes: [...warningCodeSet],
384
+ };
385
+ },
386
+ async pullMemories(request) {
387
+ const parsed = parseRequest(pullMemoriesRequestSchema, request);
388
+ // Structural twin of importMemories with three team-pull changes:
389
+ // - importance uses MAX(local, incoming) so a teammate can never lower a
390
+ // locally-boosted importance (last-write-wins on content but
391
+ // importance-preserving).
392
+ // - author/origin_project_id are stamped from the incoming record's
393
+ // provenance so pulled rows carry the teammate's identity and
394
+ // their source project_id.
395
+ // - 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
+ const warningCodeSet = new Set();
430
+ const effectiveRedactionEnabled = resolveRedactionEnabled(parsed.redactionEnabled);
431
+ let pulledNew = 0;
432
+ let pulledUpdated = 0;
433
+ 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;
485
+ }
486
+ }
487
+ return {
488
+ ok: true,
489
+ pulledNew,
490
+ pulledUpdated,
491
+ skippedCrossProject,
492
+ warningCodes: [...warningCodeSet],
493
+ };
494
+ },
495
+ async pruneMemories(request) {
496
+ const parsed = parseRequest(pruneMemoriesRequestSchema, request);
497
+ // retentionDays <= 0 disables pruning entirely. A non-positive
498
+ // window must never translate into a future cutoff that could delete
499
+ // everything.
500
+ if (parsed.retentionDays <= 0) {
501
+ return { ok: true, deleted: 0, eligible: 0 };
502
+ }
503
+ const cutoffMs = Date.now() - parsed.retentionDays * 24 * 60 * 60 * 1000;
504
+ // ISO-8601 UTC with millisecond precision matches the stored created_at
505
+ // format (strftime('%Y-%m-%dT%H:%M:%fZ')), enabling lexicographic compare.
506
+ const cutoffIso = new Date(cutoffMs).toISOString();
507
+ const eligible = countMemoriesOlderThan(db, parsed.projectId, cutoffIso);
508
+ if (parsed.dryRun) {
509
+ return { ok: true, deleted: 0, eligible };
510
+ }
511
+ const deleted = deleteMemoriesOlderThan(db, parsed.projectId, cutoffIso);
512
+ return { ok: true, deleted, eligible };
513
+ },
514
+ async redactExisting(request) {
515
+ const parsed = parseRequest(redactExistingRequestSchema, request);
516
+ // One-time scrub of pre-existing rows. Dry-run by default:
517
+ // apply=false reports matches and previews but writes nothing.
518
+ const memories = listMemoriesByProject(db, parsed.projectId);
519
+ let matched = 0;
520
+ let updated = 0;
521
+ let skipped = 0;
522
+ const previews = [];
523
+ for (const memory of memories) {
524
+ const redaction = applyRedaction(memory.content, {
525
+ redactionEnabled: true,
526
+ });
527
+ // A "match" is a row whose content changes under the rule set.
528
+ if (redaction.text === memory.content) {
529
+ continue;
530
+ }
531
+ matched += 1;
532
+ // Preview is built from the REDACTED text and length-bounded so no raw
533
+ // secret is echoed and no large body leaks in bulk.
534
+ // Truncate on Unicode code-point boundaries (Array.from
535
+ // iterates by code point) rather than String.slice's UTF-16 code-unit
536
+ // boundaries, so a multi-byte character (emoji, non-BMP) straddling
537
+ // the limit isn't split into an unpaired surrogate.
538
+ previews.push(Array.from(redaction.text).slice(0, REDACT_PREVIEW_MAX_LENGTH).join(""));
539
+ if (parsed.apply) {
540
+ // Recompute the embedding-normalized text on the redacted content so
541
+ // the stored normalized_content stays consistent with the scrub.
542
+ const embedding = deterministicEmbed(redaction.text, dimension);
543
+ // A single row that was deleted concurrently between the
544
+ // initial listMemoriesByProject snapshot and this update would
545
+ // otherwise throw and abort the whole scrub, discarding the
546
+ // scanned/matched/updated counts and previews accumulated so far
547
+ // (and any prior updates already committed, since the loop is not
548
+ // wrapped in a transaction). Catch per-row and report it as
549
+ // skipped instead.
550
+ try {
551
+ updateMemoryContent(db, parsed.projectId, memory.id, redaction.text, embedding.normalizedText);
552
+ updated += 1;
553
+ }
554
+ catch {
555
+ skipped += 1;
556
+ }
557
+ }
558
+ }
559
+ return {
560
+ ok: true,
561
+ scanned: memories.length,
562
+ matched,
563
+ updated,
564
+ skipped,
565
+ previews,
566
+ };
567
+ },
568
+ async stats(request) {
569
+ 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);
576
+ return {
577
+ ok: true,
578
+ totalMemories: memoryCount.count,
579
+ totalSessionEvents: sessionEventCount.count,
580
+ };
581
+ },
582
+ };
583
+ async function call(method, request) {
584
+ try {
585
+ return await methods[method](request);
586
+ }
587
+ catch (error) {
588
+ return toErrorResponse(error);
589
+ }
590
+ }
591
+ return {
592
+ ...methods,
593
+ call,
594
+ };
595
+ }