@prmichaelsen/remember-mcp 3.16.2 → 3.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.17.1] - 2026-03-09
9
+
10
+ ### Fixed
11
+
12
+ - Move `server-factory.spec.ts` to `server-factory.e2e.ts` — test requires live Weaviate and was crashing CI workers
13
+ - Revert CI test command to `npm test` (OOM was a symptom of the Weaviate connection failure, not memory)
14
+
15
+ ## [3.17.0] - 2026-03-09
16
+
17
+ ### Added
18
+
19
+ - Webhook event bus integration — `SpaceService` now emits `memory.published_to_group`, `memory.published_to_space`, `comment.published_to_group`, and `comment.published_to_space` events via `BatchedWebhookService`
20
+ - `REMEMBER_WEBHOOK_URL` and `REMEMBER_WEBHOOK_SECRET` env vars configure outbound webhook delivery
21
+
22
+ ### Changed
23
+
24
+ - Bump `@prmichaelsen/remember-core` to 0.54.0 (fan-out webhooks, comment events, publish dedupe)
25
+
26
+ ### Fixed
27
+
28
+ - CI OOM crash in `server-factory.spec.ts` — add `maxWorkers: '50%'` to jest config
29
+
8
30
  ## [3.16.0] - 2026-03-08
9
31
 
10
32
  ### Added
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=server-factory.e2e.d.ts.map
@@ -1458,6 +1458,18 @@ function unsafeStringify(arr, offset = 0) {
1458
1458
  return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
1459
1459
  }
1460
1460
 
1461
+ // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/rng.js
1462
+ import { randomFillSync } from "crypto";
1463
+ var rnds8Pool = new Uint8Array(256);
1464
+ var poolPtr = rnds8Pool.length;
1465
+ function rng() {
1466
+ if (poolPtr > rnds8Pool.length - 16) {
1467
+ randomFillSync(rnds8Pool);
1468
+ poolPtr = 0;
1469
+ }
1470
+ return rnds8Pool.slice(poolPtr, poolPtr += 16);
1471
+ }
1472
+
1461
1473
  // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/v35.js
1462
1474
  function stringToBytes(str) {
1463
1475
  str = unescape(encodeURIComponent(str));
@@ -1494,6 +1506,36 @@ function v35(version, hash, value, namespace, buf, offset) {
1494
1506
  return unsafeStringify(bytes);
1495
1507
  }
1496
1508
 
1509
+ // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/native.js
1510
+ import { randomUUID } from "crypto";
1511
+ var native_default = { randomUUID };
1512
+
1513
+ // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/v4.js
1514
+ function v4(options, buf, offset) {
1515
+ if (native_default.randomUUID && !buf && !options) {
1516
+ return native_default.randomUUID();
1517
+ }
1518
+ options = options || {};
1519
+ const rnds = options.random ?? options.rng?.() ?? rng();
1520
+ if (rnds.length < 16) {
1521
+ throw new Error("Random bytes length must be >= 16");
1522
+ }
1523
+ rnds[6] = rnds[6] & 15 | 64;
1524
+ rnds[8] = rnds[8] & 63 | 128;
1525
+ if (buf) {
1526
+ offset = offset || 0;
1527
+ if (offset < 0 || offset + 16 > buf.length) {
1528
+ throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
1529
+ }
1530
+ for (let i = 0; i < 16; ++i) {
1531
+ buf[offset + i] = rnds[i];
1532
+ }
1533
+ return buf;
1534
+ }
1535
+ return unsafeStringify(rnds);
1536
+ }
1537
+ var v4_default = v4;
1538
+
1497
1539
  // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/sha1.js
1498
1540
  import { createHash } from "crypto";
1499
1541
  function sha1(bytes) {
@@ -1712,6 +1754,9 @@ function buildDocTypeFilters(collection, docType, filters) {
1712
1754
  if (filters?.tags && filters.tags.length > 0) {
1713
1755
  filterList.push(collection.filter.byProperty("tags").containsAny(filters.tags));
1714
1756
  }
1757
+ if (filters?.is_user_organized !== void 0) {
1758
+ filterList.push(collection.filter.byProperty("is_user_organized").equal(filters.is_user_organized));
1759
+ }
1715
1760
  if (filters?.memory_ids && filters.memory_ids.length > 0) {
1716
1761
  filterList.push(collection.filter.byId().containsAny(filters.memory_ids));
1717
1762
  }
@@ -1871,6 +1916,7 @@ var COMMON_MEMORY_PROPERTIES = [
1871
1916
  { name: "relationships", dataType: configure.dataType.TEXT_ARRAY },
1872
1917
  { name: "memory_ids", dataType: configure.dataType.TEXT_ARRAY },
1873
1918
  { name: "relationship_count", dataType: configure.dataType.INT },
1919
+ { name: "member_count", dataType: configure.dataType.INT },
1874
1920
  // Rating aggregates (denormalized from Firestore individual ratings)
1875
1921
  { name: "rating_sum", dataType: configure.dataType.INT },
1876
1922
  { name: "rating_count", dataType: configure.dataType.INT },
@@ -1932,6 +1978,8 @@ var COMMON_MEMORY_PROPERTIES = [
1932
1978
  // REM metadata
1933
1979
  { name: "rem_touched_at", dataType: configure.dataType.TEXT },
1934
1980
  { name: "rem_visits", dataType: configure.dataType.INT },
1981
+ // User organization (M64)
1982
+ { name: "is_user_organized", dataType: configure.dataType.BOOLEAN },
1935
1983
  // Curation scoring (M36)
1936
1984
  { name: "curated_score", dataType: configure.dataType.NUMBER },
1937
1985
  { name: "editorial_score", dataType: configure.dataType.NUMBER },
@@ -2292,7 +2340,7 @@ var PreferencesDatabaseService = class {
2292
2340
  };
2293
2341
 
2294
2342
  // node_modules/@prmichaelsen/remember-core/dist/services/confirmation-token.service.js
2295
- var randomUUID = () => globalThis.crypto.randomUUID();
2343
+ var randomUUID2 = () => globalThis.crypto.randomUUID();
2296
2344
  var ConfirmationTokenService = class {
2297
2345
  EXPIRY_MINUTES = 5;
2298
2346
  logger;
@@ -2304,7 +2352,7 @@ var ConfirmationTokenService = class {
2304
2352
  */
2305
2353
  async createRequest(userId, action, payload, targetCollection) {
2306
2354
  try {
2307
- const token = randomUUID();
2355
+ const token = randomUUID2();
2308
2356
  const now = /* @__PURE__ */ new Date();
2309
2357
  const expiresAt = new Date(now.getTime() + this.EXPIRY_MINUTES * 60 * 1e3);
2310
2358
  const request = {
@@ -2835,6 +2883,7 @@ var MemoryService = class {
2835
2883
  thread_root_id: input.thread_root_id ?? null,
2836
2884
  moderation_flags: input.moderation_flags ?? [],
2837
2885
  follow_up_at: input.follow_up_at || null,
2886
+ is_user_organized: input.is_user_organized ?? false,
2838
2887
  space_ids: [],
2839
2888
  group_ids: []
2840
2889
  };
@@ -3613,6 +3662,10 @@ var MemoryService = class {
3613
3662
  updates.moderation_flags = input.moderation_flags;
3614
3663
  updatedFields.push("moderation_flags");
3615
3664
  }
3665
+ if (input.is_user_organized !== void 0) {
3666
+ updates.is_user_organized = input.is_user_organized;
3667
+ updatedFields.push("is_user_organized");
3668
+ }
3616
3669
  if (updatedFields.length === 0)
3617
3670
  throw new Error("No fields provided for update");
3618
3671
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -3745,6 +3798,7 @@ var RelationshipService = class {
3745
3798
  "confidence",
3746
3799
  "source",
3747
3800
  "tags",
3801
+ "member_count",
3748
3802
  "created_at",
3749
3803
  "updated_at",
3750
3804
  "version"
@@ -3791,6 +3845,7 @@ var RelationshipService = class {
3791
3845
  strength: input.strength ?? 0.5,
3792
3846
  confidence: input.confidence ?? 0.8,
3793
3847
  source: input.source ?? "user",
3848
+ member_count: input.memory_ids.length,
3794
3849
  created_at: now,
3795
3850
  updated_at: now,
3796
3851
  version: 1,
@@ -3894,6 +3949,9 @@ var RelationshipService = class {
3894
3949
  const opts = { alpha: 1, limit: limit + offset };
3895
3950
  if (combinedFilters)
3896
3951
  opts.filters = combinedFilters;
3952
+ if (input.sort_by) {
3953
+ opts.sort = this.collection.sort.byProperty(input.sort_by, input.sort_direction === "asc");
3954
+ }
3897
3955
  const results = await this.collection.query.hybrid(input.query, opts);
3898
3956
  const paginated = results.objects.slice(offset, offset + limit);
3899
3957
  const relationships = paginated.map((obj) => ({
@@ -3930,6 +3988,7 @@ var RelationshipService = class {
3930
3988
  "confidence",
3931
3989
  "source",
3932
3990
  "tags",
3991
+ "member_count",
3933
3992
  "created_at",
3934
3993
  "updated_at",
3935
3994
  "version"
@@ -4023,6 +4082,7 @@ var SpaceService = class {
4023
4082
  moderationClient;
4024
4083
  memoryIndex;
4025
4084
  recommendationService;
4085
+ eventBus;
4026
4086
  constructor(weaviateClient, userCollection, userId, confirmationTokenService, logger2, memoryIndexService2, options) {
4027
4087
  this.weaviateClient = weaviateClient;
4028
4088
  this.userCollection = userCollection;
@@ -4032,6 +4092,7 @@ var SpaceService = class {
4032
4092
  this.memoryIndexService = memoryIndexService2;
4033
4093
  this.moderationClient = options?.moderationClient;
4034
4094
  this.recommendationService = options?.recommendationService;
4095
+ this.eventBus = options?.eventBus;
4035
4096
  this.memoryIndex = memoryIndexService2;
4036
4097
  }
4037
4098
  // ── Content moderation helper ────────────────────────────────────────
@@ -4046,6 +4107,46 @@ var SpaceService = class {
4046
4107
  });
4047
4108
  }
4048
4109
  }
4110
+ // ── Resolve composite UUID to original memory ──────────────────────
4111
+ /**
4112
+ * Looks up a memory in the user's collection. If not found, checks whether
4113
+ * the ID is a composite UUID from a published copy and resolves to the
4114
+ * original memory via composite_id or original_memory_id.
4115
+ */
4116
+ async resolveToOriginalMemory(memoryId) {
4117
+ let memory = await fetchMemoryWithAllProperties(this.userCollection, memoryId);
4118
+ if (memory) {
4119
+ if (memory.properties.user_id !== this.userId)
4120
+ throw new ForbiddenError("Permission denied: not memory owner");
4121
+ return { resolvedId: memoryId, memory };
4122
+ }
4123
+ const collectionName = await this.memoryIndex.lookup(memoryId);
4124
+ if (collectionName && collectionName !== this.userCollection.name) {
4125
+ const publishedCollection = this.weaviateClient.collections.get(collectionName);
4126
+ const published = await fetchMemoryWithAllProperties(publishedCollection, memoryId);
4127
+ if (published) {
4128
+ let originalId;
4129
+ if (published.properties.original_memory_id) {
4130
+ originalId = published.properties.original_memory_id;
4131
+ } else if (published.properties.composite_id) {
4132
+ try {
4133
+ const parsed = parseCompositeId(published.properties.composite_id);
4134
+ originalId = parsed.memoryId;
4135
+ } catch {
4136
+ }
4137
+ }
4138
+ if (originalId) {
4139
+ memory = await fetchMemoryWithAllProperties(this.userCollection, originalId);
4140
+ if (memory) {
4141
+ if (memory.properties.user_id !== this.userId)
4142
+ throw new ForbiddenError("Permission denied: not memory owner");
4143
+ return { resolvedId: originalId, memory };
4144
+ }
4145
+ }
4146
+ }
4147
+ }
4148
+ throw new NotFoundError("Memory", memoryId);
4149
+ }
4049
4150
  // ── Publish (phase 1: generate confirmation token) ──────────────────
4050
4151
  async publish(input) {
4051
4152
  const spaces = input.spaces || [];
@@ -4065,11 +4166,7 @@ var SpaceService = class {
4065
4166
  throw new ValidationError("Group IDs cannot be empty or contain dots");
4066
4167
  }
4067
4168
  }
4068
- const memory = await fetchMemoryWithAllProperties(this.userCollection, input.memory_id);
4069
- if (!memory)
4070
- throw new NotFoundError("Memory", input.memory_id);
4071
- if (memory.properties.user_id !== this.userId)
4072
- throw new ForbiddenError("Permission denied: not memory owner");
4169
+ const { resolvedId: resolvedMemoryId, memory } = await this.resolveToOriginalMemory(input.memory_id);
4073
4170
  if (memory.properties.doc_type !== "memory")
4074
4171
  throw new ValidationError("Only memories can be published");
4075
4172
  const memoryContentType = memory.properties.content_type;
@@ -4081,14 +4178,14 @@ var SpaceService = class {
4081
4178
  }
4082
4179
  await this.checkModeration(memory.properties.content);
4083
4180
  const { token } = await this.confirmationTokenService.createRequest(this.userId, "publish_memory", {
4084
- memory_id: input.memory_id,
4181
+ memory_id: resolvedMemoryId,
4085
4182
  spaces,
4086
4183
  groups,
4087
4184
  additional_tags: input.additional_tags || []
4088
4185
  });
4089
4186
  this.logger.info("Publish confirmation created", {
4090
4187
  userId: this.userId,
4091
- memoryId: input.memory_id,
4188
+ memoryId: resolvedMemoryId,
4092
4189
  spaces,
4093
4190
  groups
4094
4191
  });
@@ -4107,11 +4204,7 @@ var SpaceService = class {
4107
4204
  throw new ValidationError(`Group IDs cannot contain dots: ${invalidGroups.join(", ")}`);
4108
4205
  }
4109
4206
  }
4110
- const memory = await fetchMemoryWithAllProperties(this.userCollection, input.memory_id);
4111
- if (!memory)
4112
- throw new NotFoundError("Memory", input.memory_id);
4113
- if (memory.properties.user_id !== this.userId)
4114
- throw new ForbiddenError("Permission denied: not memory owner");
4207
+ const { resolvedId: resolvedMemoryId, memory } = await this.resolveToOriginalMemory(input.memory_id);
4115
4208
  const currentSpaceIds = Array.isArray(memory.properties.space_ids) ? memory.properties.space_ids : [];
4116
4209
  const currentGroupIds = Array.isArray(memory.properties.group_ids) ? memory.properties.group_ids : [];
4117
4210
  const notPublishedSpaces = spaces.filter((s) => !currentSpaceIds.includes(s));
@@ -4120,7 +4213,7 @@ var SpaceService = class {
4120
4213
  throw new ValidationError(`Memory is not published to some destinations. Not in spaces: [${notPublishedSpaces.join(", ")}], Not in groups: [${notPublishedGroups.join(", ")}]`);
4121
4214
  }
4122
4215
  const { token } = await this.confirmationTokenService.createRequest(this.userId, "retract_memory", {
4123
- memory_id: input.memory_id,
4216
+ memory_id: resolvedMemoryId,
4124
4217
  spaces,
4125
4218
  groups,
4126
4219
  current_space_ids: currentSpaceIds,
@@ -4128,7 +4221,7 @@ var SpaceService = class {
4128
4221
  });
4129
4222
  this.logger.info("Retract confirmation created", {
4130
4223
  userId: this.userId,
4131
- memoryId: input.memory_id,
4224
+ memoryId: resolvedMemoryId,
4132
4225
  spaces,
4133
4226
  groups
4134
4227
  });
@@ -4382,6 +4475,22 @@ var SpaceService = class {
4382
4475
  total: memories.length
4383
4476
  };
4384
4477
  }
4478
+ // ── Private: Dedupe Check ──────────────────────────────────────────
4479
+ /**
4480
+ * Check that the given original_memory_id is not already published to the
4481
+ * target collection by a different user. If the same user re-publishes
4482
+ * (same weaviateId) this is fine — the caller handles update vs insert.
4483
+ */
4484
+ async checkOriginalMemoryNotPublished(collection, originalMemoryId, expectedWeaviateId) {
4485
+ const filter = collection.filter.byProperty("original_memory_id").equal(originalMemoryId);
4486
+ const result = await collection.query.fetchObjects({ filters: filter, limit: 1 });
4487
+ if (result.objects.length > 0) {
4488
+ const existing = result.objects[0];
4489
+ if (existing.uuid === expectedWeaviateId)
4490
+ return;
4491
+ throw new ValidationError(`This memory is already published by another user`);
4492
+ }
4493
+ }
4385
4494
  // ── Private: Execute Publish ────────────────────────────────────────
4386
4495
  async executePublish(request) {
4387
4496
  const spaces = request.payload.spaces || [];
@@ -4406,6 +4515,7 @@ var SpaceService = class {
4406
4515
  if (spaces.length > 0) {
4407
4516
  try {
4408
4517
  const publicCollection = await ensurePublicCollection(this.weaviateClient);
4518
+ await this.checkOriginalMemoryNotPublished(publicCollection, request.payload.memory_id, weaviateId);
4409
4519
  let existingSpaceMemory = null;
4410
4520
  try {
4411
4521
  existingSpaceMemory = await fetchMemoryWithAllProperties(publicCollection, weaviateId);
@@ -4455,6 +4565,7 @@ var SpaceService = class {
4455
4565
  try {
4456
4566
  await ensureGroupCollection(this.weaviateClient, groupId);
4457
4567
  const groupCollection = this.weaviateClient.collections.get(groupCollectionName);
4568
+ await this.checkOriginalMemoryNotPublished(groupCollection, request.payload.memory_id, weaviateId);
4458
4569
  let existingGroupMemory = null;
4459
4570
  try {
4460
4571
  existingGroupMemory = await fetchMemoryWithAllProperties(groupCollection, weaviateId);
@@ -4513,6 +4624,35 @@ var SpaceService = class {
4513
4624
  published: successfulPublications,
4514
4625
  failed: failedPublications
4515
4626
  });
4627
+ if (this.eventBus) {
4628
+ const title = String(originalMemory.properties.title ?? "");
4629
+ const actor = { type: "user", id: this.userId };
4630
+ const isComment = originalMemory.properties.content_type === "comment";
4631
+ if (isComment) {
4632
+ const parentId = String(originalMemory.properties.parent_id ?? "");
4633
+ const threadRootId = String(originalMemory.properties.thread_root_id ?? parentId);
4634
+ const contentPreview = String(originalMemory.properties.content ?? "").slice(0, 200);
4635
+ if (successfulPublications.some((p) => p.startsWith("spaces:"))) {
4636
+ for (const spaceId of spaces) {
4637
+ this.eventBus.emit({ type: "comment.published_to_space", memory_id: request.payload.memory_id, parent_id: parentId, thread_root_id: threadRootId, content_preview: contentPreview, space_id: spaceId, owner_id: this.userId }, actor);
4638
+ }
4639
+ }
4640
+ const publishedGroups = groups.filter((g) => successfulPublications.some((p) => p === `group: ${g}`));
4641
+ for (const groupId of publishedGroups) {
4642
+ this.eventBus.emit({ type: "comment.published_to_group", memory_id: request.payload.memory_id, parent_id: parentId, thread_root_id: threadRootId, content_preview: contentPreview, group_id: groupId, owner_id: this.userId }, actor);
4643
+ }
4644
+ } else {
4645
+ if (successfulPublications.some((p) => p.startsWith("spaces:"))) {
4646
+ for (const spaceId of spaces) {
4647
+ this.eventBus.emit({ type: "memory.published_to_space", memory_id: request.payload.memory_id, title, space_id: spaceId, owner_id: this.userId }, actor);
4648
+ }
4649
+ }
4650
+ const publishedGroups = groups.filter((g) => successfulPublications.some((p) => p === `group: ${g}`));
4651
+ for (const groupId of publishedGroups) {
4652
+ this.eventBus.emit({ type: "memory.published_to_group", memory_id: request.payload.memory_id, title, group_id: groupId, owner_id: this.userId }, actor);
4653
+ }
4654
+ }
4655
+ }
4516
4656
  return {
4517
4657
  action: "publish_memory",
4518
4658
  success: true,
@@ -4594,6 +4734,21 @@ var SpaceService = class {
4594
4734
  retracted: successfulRetractions,
4595
4735
  failed: failedRetractions
4596
4736
  });
4737
+ if (this.eventBus && successfulRetractions.length > 0) {
4738
+ const targets = [];
4739
+ if (successfulRetractions.some((r) => r.startsWith("spaces:"))) {
4740
+ for (const spaceId of spaces) {
4741
+ targets.push({ kind: "space", id: spaceId });
4742
+ }
4743
+ }
4744
+ const retractedGroups = groups.filter((g) => successfulRetractions.some((r) => r === `group: ${g}`));
4745
+ for (const groupId of retractedGroups) {
4746
+ targets.push({ kind: "group", id: groupId });
4747
+ }
4748
+ if (targets.length > 0) {
4749
+ this.eventBus.emit({ type: "memory.retracted", memory_id: request.payload.memory_id, owner_id: this.userId, targets }, { type: "user", id: this.userId });
4750
+ }
4751
+ }
4597
4752
  return {
4598
4753
  action: "retract_memory",
4599
4754
  success: true,
@@ -5523,6 +5678,132 @@ var INITIAL_PERCEPTION = {
5523
5678
  last_updated: (/* @__PURE__ */ new Date()).toISOString()
5524
5679
  };
5525
5680
 
5681
+ // node_modules/@prmichaelsen/remember-core/dist/webhooks/signing.js
5682
+ import { createHmac } from "node:crypto";
5683
+ function signWebhookPayload(webhookId, timestamp, body, secret) {
5684
+ const content = `${webhookId}.${timestamp}.${body}`;
5685
+ const hmac = createHmac("sha256", secret).update(content).digest("base64");
5686
+ return `v1,${hmac}`;
5687
+ }
5688
+
5689
+ // node_modules/@prmichaelsen/remember-core/dist/webhooks/batched-webhook.service.js
5690
+ var DEFAULT_MAX_BATCH_SIZE = 20;
5691
+ var DEFAULT_FLUSH_INTERVAL_MS = 1e3;
5692
+ var DEFAULT_TIMEOUT_MS = 5e3;
5693
+ var SOURCE = "remember-core";
5694
+ var API_VERSION = "1";
5695
+ var BatchedWebhookService = class {
5696
+ logger;
5697
+ resolveEndpoint;
5698
+ maxBatchSize;
5699
+ flushIntervalMs;
5700
+ timeoutMs;
5701
+ onError;
5702
+ buffers = /* @__PURE__ */ new Map();
5703
+ constructor(logger2, config2) {
5704
+ this.logger = logger2;
5705
+ this.resolveEndpoint = config2.resolveEndpoint;
5706
+ this.maxBatchSize = config2.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
5707
+ this.flushIntervalMs = config2.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
5708
+ this.timeoutMs = config2.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5709
+ this.onError = config2.onError;
5710
+ }
5711
+ emit(event, actor) {
5712
+ const ownerId = event.owner_id;
5713
+ const endpoints = this.resolveEndpoint(ownerId);
5714
+ if (endpoints.length === 0) {
5715
+ this.logger.debug?.("[BatchedWebhookService] no endpoints for owner, dropping event", {
5716
+ owner_id: ownerId,
5717
+ type: event.type
5718
+ });
5719
+ return;
5720
+ }
5721
+ const envelope = this.buildEnvelope(event, actor);
5722
+ for (const endpoint of endpoints) {
5723
+ const url = endpoint.url;
5724
+ let buffer = this.buffers.get(url);
5725
+ if (!buffer) {
5726
+ buffer = { endpoint, envelopes: [], timer: null };
5727
+ this.buffers.set(url, buffer);
5728
+ }
5729
+ buffer.envelopes.push(envelope);
5730
+ if (buffer.envelopes.length >= this.maxBatchSize) {
5731
+ this.flush(url);
5732
+ } else if (!buffer.timer) {
5733
+ buffer.timer = setTimeout(() => this.flush(url), this.flushIntervalMs);
5734
+ }
5735
+ }
5736
+ }
5737
+ flush(url) {
5738
+ const buffer = this.buffers.get(url);
5739
+ if (!buffer || buffer.envelopes.length === 0)
5740
+ return;
5741
+ const envelopes = buffer.envelopes;
5742
+ const endpoint = buffer.endpoint;
5743
+ if (buffer.timer) {
5744
+ clearTimeout(buffer.timer);
5745
+ }
5746
+ buffer.envelopes = [];
5747
+ buffer.timer = null;
5748
+ this.sendBatch(url, endpoint, envelopes).catch((err2) => {
5749
+ this.logger.error?.("[BatchedWebhookService] batch delivery failed", {
5750
+ error: err2,
5751
+ url,
5752
+ count: envelopes.length
5753
+ });
5754
+ this.onError?.(err2, envelopes);
5755
+ });
5756
+ }
5757
+ dispose() {
5758
+ for (const url of this.buffers.keys()) {
5759
+ this.flush(url);
5760
+ }
5761
+ }
5762
+ async sendBatch(url, endpoint, envelopes) {
5763
+ const batchId = v4_default();
5764
+ const batchTimestamp = Math.floor(Date.now() / 1e3);
5765
+ const body = JSON.stringify(envelopes);
5766
+ const signature = signWebhookPayload(batchId, batchTimestamp, body, endpoint.signingSecret);
5767
+ const controller = new AbortController();
5768
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
5769
+ try {
5770
+ const response = await fetch(url, {
5771
+ method: "POST",
5772
+ headers: {
5773
+ "Content-Type": "application/json",
5774
+ "webhook-id": batchId,
5775
+ "webhook-timestamp": String(batchTimestamp),
5776
+ "webhook-signature": signature,
5777
+ "x-webhook-batch": "true"
5778
+ },
5779
+ body,
5780
+ signal: controller.signal
5781
+ });
5782
+ if (!response.ok) {
5783
+ throw new Error(`Webhook batch delivery failed: HTTP ${response.status}`);
5784
+ }
5785
+ } finally {
5786
+ clearTimeout(timeout);
5787
+ }
5788
+ }
5789
+ buildEnvelope(event, actor) {
5790
+ return {
5791
+ id: v4_default(),
5792
+ timestamp: Math.floor(Date.now() / 1e3),
5793
+ source: SOURCE,
5794
+ api_version: API_VERSION,
5795
+ type: event.type,
5796
+ actor,
5797
+ data: event
5798
+ };
5799
+ }
5800
+ };
5801
+
5802
+ // node_modules/@prmichaelsen/remember-core/dist/webhooks/create.js
5803
+ function createBatchedWebhookService(logger2, config2) {
5804
+ return new BatchedWebhookService(logger2, config2);
5805
+ }
5806
+
5526
5807
  // src/weaviate/schema.ts
5527
5808
  init_logger();
5528
5809
 
@@ -5641,6 +5922,15 @@ var tokenService = new ConfirmationTokenService(coreLogger);
5641
5922
  var preferencesService = new PreferencesDatabaseService(coreLogger);
5642
5923
  var moderationClient = process.env.ANTHROPIC_API_KEY ? createModerationClient({ apiKey: process.env.ANTHROPIC_API_KEY }) : void 0;
5643
5924
  var memoryIndexService = new MemoryIndexService(coreLogger);
5925
+ var eventBus = (() => {
5926
+ const url = process.env.REMEMBER_WEBHOOK_URL;
5927
+ const secret = process.env.REMEMBER_WEBHOOK_SECRET;
5928
+ if (!url || !secret)
5929
+ return void 0;
5930
+ return createBatchedWebhookService(coreLogger, {
5931
+ resolveEndpoint: () => [{ url, signingSecret: secret }]
5932
+ });
5933
+ })();
5644
5934
  var coreServicesCache = /* @__PURE__ */ new Map();
5645
5935
  function createCoreServices(userId) {
5646
5936
  const cached = coreServicesCache.get(userId);
@@ -5654,7 +5944,7 @@ function createCoreServices(userId) {
5654
5944
  weaviateClient
5655
5945
  }),
5656
5946
  relationship: new RelationshipService(collection, userId, coreLogger),
5657
- space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger, memoryIndexService, { moderationClient }),
5947
+ space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger, memoryIndexService, { moderationClient, eventBus }),
5658
5948
  preferences: preferencesService,
5659
5949
  token: tokenService
5660
5950
  };
package/dist/server.js CHANGED
@@ -1462,6 +1462,18 @@ function unsafeStringify(arr, offset = 0) {
1462
1462
  return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
1463
1463
  }
1464
1464
 
1465
+ // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/rng.js
1466
+ import { randomFillSync } from "crypto";
1467
+ var rnds8Pool = new Uint8Array(256);
1468
+ var poolPtr = rnds8Pool.length;
1469
+ function rng() {
1470
+ if (poolPtr > rnds8Pool.length - 16) {
1471
+ randomFillSync(rnds8Pool);
1472
+ poolPtr = 0;
1473
+ }
1474
+ return rnds8Pool.slice(poolPtr, poolPtr += 16);
1475
+ }
1476
+
1465
1477
  // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/v35.js
1466
1478
  function stringToBytes(str) {
1467
1479
  str = unescape(encodeURIComponent(str));
@@ -1498,6 +1510,36 @@ function v35(version, hash, value, namespace, buf, offset) {
1498
1510
  return unsafeStringify(bytes);
1499
1511
  }
1500
1512
 
1513
+ // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/native.js
1514
+ import { randomUUID } from "crypto";
1515
+ var native_default = { randomUUID };
1516
+
1517
+ // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/v4.js
1518
+ function v4(options, buf, offset) {
1519
+ if (native_default.randomUUID && !buf && !options) {
1520
+ return native_default.randomUUID();
1521
+ }
1522
+ options = options || {};
1523
+ const rnds = options.random ?? options.rng?.() ?? rng();
1524
+ if (rnds.length < 16) {
1525
+ throw new Error("Random bytes length must be >= 16");
1526
+ }
1527
+ rnds[6] = rnds[6] & 15 | 64;
1528
+ rnds[8] = rnds[8] & 63 | 128;
1529
+ if (buf) {
1530
+ offset = offset || 0;
1531
+ if (offset < 0 || offset + 16 > buf.length) {
1532
+ throw new RangeError(`UUID byte range ${offset}:${offset + 15} is out of buffer bounds`);
1533
+ }
1534
+ for (let i = 0; i < 16; ++i) {
1535
+ buf[offset + i] = rnds[i];
1536
+ }
1537
+ return buf;
1538
+ }
1539
+ return unsafeStringify(rnds);
1540
+ }
1541
+ var v4_default = v4;
1542
+
1501
1543
  // node_modules/@prmichaelsen/remember-core/node_modules/uuid/dist/esm/sha1.js
1502
1544
  import { createHash } from "crypto";
1503
1545
  function sha1(bytes) {
@@ -1716,6 +1758,9 @@ function buildDocTypeFilters(collection, docType, filters) {
1716
1758
  if (filters?.tags && filters.tags.length > 0) {
1717
1759
  filterList.push(collection.filter.byProperty("tags").containsAny(filters.tags));
1718
1760
  }
1761
+ if (filters?.is_user_organized !== void 0) {
1762
+ filterList.push(collection.filter.byProperty("is_user_organized").equal(filters.is_user_organized));
1763
+ }
1719
1764
  if (filters?.memory_ids && filters.memory_ids.length > 0) {
1720
1765
  filterList.push(collection.filter.byId().containsAny(filters.memory_ids));
1721
1766
  }
@@ -1875,6 +1920,7 @@ var COMMON_MEMORY_PROPERTIES = [
1875
1920
  { name: "relationships", dataType: configure.dataType.TEXT_ARRAY },
1876
1921
  { name: "memory_ids", dataType: configure.dataType.TEXT_ARRAY },
1877
1922
  { name: "relationship_count", dataType: configure.dataType.INT },
1923
+ { name: "member_count", dataType: configure.dataType.INT },
1878
1924
  // Rating aggregates (denormalized from Firestore individual ratings)
1879
1925
  { name: "rating_sum", dataType: configure.dataType.INT },
1880
1926
  { name: "rating_count", dataType: configure.dataType.INT },
@@ -1936,6 +1982,8 @@ var COMMON_MEMORY_PROPERTIES = [
1936
1982
  // REM metadata
1937
1983
  { name: "rem_touched_at", dataType: configure.dataType.TEXT },
1938
1984
  { name: "rem_visits", dataType: configure.dataType.INT },
1985
+ // User organization (M64)
1986
+ { name: "is_user_organized", dataType: configure.dataType.BOOLEAN },
1939
1987
  // Curation scoring (M36)
1940
1988
  { name: "curated_score", dataType: configure.dataType.NUMBER },
1941
1989
  { name: "editorial_score", dataType: configure.dataType.NUMBER },
@@ -2296,7 +2344,7 @@ var PreferencesDatabaseService = class {
2296
2344
  };
2297
2345
 
2298
2346
  // node_modules/@prmichaelsen/remember-core/dist/services/confirmation-token.service.js
2299
- var randomUUID = () => globalThis.crypto.randomUUID();
2347
+ var randomUUID2 = () => globalThis.crypto.randomUUID();
2300
2348
  var ConfirmationTokenService = class {
2301
2349
  EXPIRY_MINUTES = 5;
2302
2350
  logger;
@@ -2308,7 +2356,7 @@ var ConfirmationTokenService = class {
2308
2356
  */
2309
2357
  async createRequest(userId, action, payload, targetCollection) {
2310
2358
  try {
2311
- const token = randomUUID();
2359
+ const token = randomUUID2();
2312
2360
  const now = /* @__PURE__ */ new Date();
2313
2361
  const expiresAt = new Date(now.getTime() + this.EXPIRY_MINUTES * 60 * 1e3);
2314
2362
  const request = {
@@ -2839,6 +2887,7 @@ var MemoryService = class {
2839
2887
  thread_root_id: input.thread_root_id ?? null,
2840
2888
  moderation_flags: input.moderation_flags ?? [],
2841
2889
  follow_up_at: input.follow_up_at || null,
2890
+ is_user_organized: input.is_user_organized ?? false,
2842
2891
  space_ids: [],
2843
2892
  group_ids: []
2844
2893
  };
@@ -3617,6 +3666,10 @@ var MemoryService = class {
3617
3666
  updates.moderation_flags = input.moderation_flags;
3618
3667
  updatedFields.push("moderation_flags");
3619
3668
  }
3669
+ if (input.is_user_organized !== void 0) {
3670
+ updates.is_user_organized = input.is_user_organized;
3671
+ updatedFields.push("is_user_organized");
3672
+ }
3620
3673
  if (updatedFields.length === 0)
3621
3674
  throw new Error("No fields provided for update");
3622
3675
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -3749,6 +3802,7 @@ var RelationshipService = class {
3749
3802
  "confidence",
3750
3803
  "source",
3751
3804
  "tags",
3805
+ "member_count",
3752
3806
  "created_at",
3753
3807
  "updated_at",
3754
3808
  "version"
@@ -3795,6 +3849,7 @@ var RelationshipService = class {
3795
3849
  strength: input.strength ?? 0.5,
3796
3850
  confidence: input.confidence ?? 0.8,
3797
3851
  source: input.source ?? "user",
3852
+ member_count: input.memory_ids.length,
3798
3853
  created_at: now,
3799
3854
  updated_at: now,
3800
3855
  version: 1,
@@ -3898,6 +3953,9 @@ var RelationshipService = class {
3898
3953
  const opts = { alpha: 1, limit: limit + offset };
3899
3954
  if (combinedFilters)
3900
3955
  opts.filters = combinedFilters;
3956
+ if (input.sort_by) {
3957
+ opts.sort = this.collection.sort.byProperty(input.sort_by, input.sort_direction === "asc");
3958
+ }
3901
3959
  const results = await this.collection.query.hybrid(input.query, opts);
3902
3960
  const paginated = results.objects.slice(offset, offset + limit);
3903
3961
  const relationships = paginated.map((obj) => ({
@@ -3934,6 +3992,7 @@ var RelationshipService = class {
3934
3992
  "confidence",
3935
3993
  "source",
3936
3994
  "tags",
3995
+ "member_count",
3937
3996
  "created_at",
3938
3997
  "updated_at",
3939
3998
  "version"
@@ -4027,6 +4086,7 @@ var SpaceService = class {
4027
4086
  moderationClient;
4028
4087
  memoryIndex;
4029
4088
  recommendationService;
4089
+ eventBus;
4030
4090
  constructor(weaviateClient, userCollection, userId, confirmationTokenService, logger2, memoryIndexService2, options) {
4031
4091
  this.weaviateClient = weaviateClient;
4032
4092
  this.userCollection = userCollection;
@@ -4036,6 +4096,7 @@ var SpaceService = class {
4036
4096
  this.memoryIndexService = memoryIndexService2;
4037
4097
  this.moderationClient = options?.moderationClient;
4038
4098
  this.recommendationService = options?.recommendationService;
4099
+ this.eventBus = options?.eventBus;
4039
4100
  this.memoryIndex = memoryIndexService2;
4040
4101
  }
4041
4102
  // ── Content moderation helper ────────────────────────────────────────
@@ -4050,6 +4111,46 @@ var SpaceService = class {
4050
4111
  });
4051
4112
  }
4052
4113
  }
4114
+ // ── Resolve composite UUID to original memory ──────────────────────
4115
+ /**
4116
+ * Looks up a memory in the user's collection. If not found, checks whether
4117
+ * the ID is a composite UUID from a published copy and resolves to the
4118
+ * original memory via composite_id or original_memory_id.
4119
+ */
4120
+ async resolveToOriginalMemory(memoryId) {
4121
+ let memory = await fetchMemoryWithAllProperties(this.userCollection, memoryId);
4122
+ if (memory) {
4123
+ if (memory.properties.user_id !== this.userId)
4124
+ throw new ForbiddenError("Permission denied: not memory owner");
4125
+ return { resolvedId: memoryId, memory };
4126
+ }
4127
+ const collectionName = await this.memoryIndex.lookup(memoryId);
4128
+ if (collectionName && collectionName !== this.userCollection.name) {
4129
+ const publishedCollection = this.weaviateClient.collections.get(collectionName);
4130
+ const published = await fetchMemoryWithAllProperties(publishedCollection, memoryId);
4131
+ if (published) {
4132
+ let originalId;
4133
+ if (published.properties.original_memory_id) {
4134
+ originalId = published.properties.original_memory_id;
4135
+ } else if (published.properties.composite_id) {
4136
+ try {
4137
+ const parsed = parseCompositeId(published.properties.composite_id);
4138
+ originalId = parsed.memoryId;
4139
+ } catch {
4140
+ }
4141
+ }
4142
+ if (originalId) {
4143
+ memory = await fetchMemoryWithAllProperties(this.userCollection, originalId);
4144
+ if (memory) {
4145
+ if (memory.properties.user_id !== this.userId)
4146
+ throw new ForbiddenError("Permission denied: not memory owner");
4147
+ return { resolvedId: originalId, memory };
4148
+ }
4149
+ }
4150
+ }
4151
+ }
4152
+ throw new NotFoundError("Memory", memoryId);
4153
+ }
4053
4154
  // ── Publish (phase 1: generate confirmation token) ──────────────────
4054
4155
  async publish(input) {
4055
4156
  const spaces = input.spaces || [];
@@ -4069,11 +4170,7 @@ var SpaceService = class {
4069
4170
  throw new ValidationError("Group IDs cannot be empty or contain dots");
4070
4171
  }
4071
4172
  }
4072
- const memory = await fetchMemoryWithAllProperties(this.userCollection, input.memory_id);
4073
- if (!memory)
4074
- throw new NotFoundError("Memory", input.memory_id);
4075
- if (memory.properties.user_id !== this.userId)
4076
- throw new ForbiddenError("Permission denied: not memory owner");
4173
+ const { resolvedId: resolvedMemoryId, memory } = await this.resolveToOriginalMemory(input.memory_id);
4077
4174
  if (memory.properties.doc_type !== "memory")
4078
4175
  throw new ValidationError("Only memories can be published");
4079
4176
  const memoryContentType = memory.properties.content_type;
@@ -4085,14 +4182,14 @@ var SpaceService = class {
4085
4182
  }
4086
4183
  await this.checkModeration(memory.properties.content);
4087
4184
  const { token } = await this.confirmationTokenService.createRequest(this.userId, "publish_memory", {
4088
- memory_id: input.memory_id,
4185
+ memory_id: resolvedMemoryId,
4089
4186
  spaces,
4090
4187
  groups,
4091
4188
  additional_tags: input.additional_tags || []
4092
4189
  });
4093
4190
  this.logger.info("Publish confirmation created", {
4094
4191
  userId: this.userId,
4095
- memoryId: input.memory_id,
4192
+ memoryId: resolvedMemoryId,
4096
4193
  spaces,
4097
4194
  groups
4098
4195
  });
@@ -4111,11 +4208,7 @@ var SpaceService = class {
4111
4208
  throw new ValidationError(`Group IDs cannot contain dots: ${invalidGroups.join(", ")}`);
4112
4209
  }
4113
4210
  }
4114
- const memory = await fetchMemoryWithAllProperties(this.userCollection, input.memory_id);
4115
- if (!memory)
4116
- throw new NotFoundError("Memory", input.memory_id);
4117
- if (memory.properties.user_id !== this.userId)
4118
- throw new ForbiddenError("Permission denied: not memory owner");
4211
+ const { resolvedId: resolvedMemoryId, memory } = await this.resolveToOriginalMemory(input.memory_id);
4119
4212
  const currentSpaceIds = Array.isArray(memory.properties.space_ids) ? memory.properties.space_ids : [];
4120
4213
  const currentGroupIds = Array.isArray(memory.properties.group_ids) ? memory.properties.group_ids : [];
4121
4214
  const notPublishedSpaces = spaces.filter((s) => !currentSpaceIds.includes(s));
@@ -4124,7 +4217,7 @@ var SpaceService = class {
4124
4217
  throw new ValidationError(`Memory is not published to some destinations. Not in spaces: [${notPublishedSpaces.join(", ")}], Not in groups: [${notPublishedGroups.join(", ")}]`);
4125
4218
  }
4126
4219
  const { token } = await this.confirmationTokenService.createRequest(this.userId, "retract_memory", {
4127
- memory_id: input.memory_id,
4220
+ memory_id: resolvedMemoryId,
4128
4221
  spaces,
4129
4222
  groups,
4130
4223
  current_space_ids: currentSpaceIds,
@@ -4132,7 +4225,7 @@ var SpaceService = class {
4132
4225
  });
4133
4226
  this.logger.info("Retract confirmation created", {
4134
4227
  userId: this.userId,
4135
- memoryId: input.memory_id,
4228
+ memoryId: resolvedMemoryId,
4136
4229
  spaces,
4137
4230
  groups
4138
4231
  });
@@ -4386,6 +4479,22 @@ var SpaceService = class {
4386
4479
  total: memories.length
4387
4480
  };
4388
4481
  }
4482
+ // ── Private: Dedupe Check ──────────────────────────────────────────
4483
+ /**
4484
+ * Check that the given original_memory_id is not already published to the
4485
+ * target collection by a different user. If the same user re-publishes
4486
+ * (same weaviateId) this is fine — the caller handles update vs insert.
4487
+ */
4488
+ async checkOriginalMemoryNotPublished(collection, originalMemoryId, expectedWeaviateId) {
4489
+ const filter = collection.filter.byProperty("original_memory_id").equal(originalMemoryId);
4490
+ const result = await collection.query.fetchObjects({ filters: filter, limit: 1 });
4491
+ if (result.objects.length > 0) {
4492
+ const existing = result.objects[0];
4493
+ if (existing.uuid === expectedWeaviateId)
4494
+ return;
4495
+ throw new ValidationError(`This memory is already published by another user`);
4496
+ }
4497
+ }
4389
4498
  // ── Private: Execute Publish ────────────────────────────────────────
4390
4499
  async executePublish(request) {
4391
4500
  const spaces = request.payload.spaces || [];
@@ -4410,6 +4519,7 @@ var SpaceService = class {
4410
4519
  if (spaces.length > 0) {
4411
4520
  try {
4412
4521
  const publicCollection = await ensurePublicCollection(this.weaviateClient);
4522
+ await this.checkOriginalMemoryNotPublished(publicCollection, request.payload.memory_id, weaviateId);
4413
4523
  let existingSpaceMemory = null;
4414
4524
  try {
4415
4525
  existingSpaceMemory = await fetchMemoryWithAllProperties(publicCollection, weaviateId);
@@ -4459,6 +4569,7 @@ var SpaceService = class {
4459
4569
  try {
4460
4570
  await ensureGroupCollection(this.weaviateClient, groupId);
4461
4571
  const groupCollection = this.weaviateClient.collections.get(groupCollectionName);
4572
+ await this.checkOriginalMemoryNotPublished(groupCollection, request.payload.memory_id, weaviateId);
4462
4573
  let existingGroupMemory = null;
4463
4574
  try {
4464
4575
  existingGroupMemory = await fetchMemoryWithAllProperties(groupCollection, weaviateId);
@@ -4517,6 +4628,35 @@ var SpaceService = class {
4517
4628
  published: successfulPublications,
4518
4629
  failed: failedPublications
4519
4630
  });
4631
+ if (this.eventBus) {
4632
+ const title = String(originalMemory.properties.title ?? "");
4633
+ const actor = { type: "user", id: this.userId };
4634
+ const isComment = originalMemory.properties.content_type === "comment";
4635
+ if (isComment) {
4636
+ const parentId = String(originalMemory.properties.parent_id ?? "");
4637
+ const threadRootId = String(originalMemory.properties.thread_root_id ?? parentId);
4638
+ const contentPreview = String(originalMemory.properties.content ?? "").slice(0, 200);
4639
+ if (successfulPublications.some((p) => p.startsWith("spaces:"))) {
4640
+ for (const spaceId of spaces) {
4641
+ this.eventBus.emit({ type: "comment.published_to_space", memory_id: request.payload.memory_id, parent_id: parentId, thread_root_id: threadRootId, content_preview: contentPreview, space_id: spaceId, owner_id: this.userId }, actor);
4642
+ }
4643
+ }
4644
+ const publishedGroups = groups.filter((g) => successfulPublications.some((p) => p === `group: ${g}`));
4645
+ for (const groupId of publishedGroups) {
4646
+ this.eventBus.emit({ type: "comment.published_to_group", memory_id: request.payload.memory_id, parent_id: parentId, thread_root_id: threadRootId, content_preview: contentPreview, group_id: groupId, owner_id: this.userId }, actor);
4647
+ }
4648
+ } else {
4649
+ if (successfulPublications.some((p) => p.startsWith("spaces:"))) {
4650
+ for (const spaceId of spaces) {
4651
+ this.eventBus.emit({ type: "memory.published_to_space", memory_id: request.payload.memory_id, title, space_id: spaceId, owner_id: this.userId }, actor);
4652
+ }
4653
+ }
4654
+ const publishedGroups = groups.filter((g) => successfulPublications.some((p) => p === `group: ${g}`));
4655
+ for (const groupId of publishedGroups) {
4656
+ this.eventBus.emit({ type: "memory.published_to_group", memory_id: request.payload.memory_id, title, group_id: groupId, owner_id: this.userId }, actor);
4657
+ }
4658
+ }
4659
+ }
4520
4660
  return {
4521
4661
  action: "publish_memory",
4522
4662
  success: true,
@@ -4598,6 +4738,21 @@ var SpaceService = class {
4598
4738
  retracted: successfulRetractions,
4599
4739
  failed: failedRetractions
4600
4740
  });
4741
+ if (this.eventBus && successfulRetractions.length > 0) {
4742
+ const targets = [];
4743
+ if (successfulRetractions.some((r) => r.startsWith("spaces:"))) {
4744
+ for (const spaceId of spaces) {
4745
+ targets.push({ kind: "space", id: spaceId });
4746
+ }
4747
+ }
4748
+ const retractedGroups = groups.filter((g) => successfulRetractions.some((r) => r === `group: ${g}`));
4749
+ for (const groupId of retractedGroups) {
4750
+ targets.push({ kind: "group", id: groupId });
4751
+ }
4752
+ if (targets.length > 0) {
4753
+ this.eventBus.emit({ type: "memory.retracted", memory_id: request.payload.memory_id, owner_id: this.userId, targets }, { type: "user", id: this.userId });
4754
+ }
4755
+ }
4601
4756
  return {
4602
4757
  action: "retract_memory",
4603
4758
  success: true,
@@ -5527,6 +5682,132 @@ var INITIAL_PERCEPTION = {
5527
5682
  last_updated: (/* @__PURE__ */ new Date()).toISOString()
5528
5683
  };
5529
5684
 
5685
+ // node_modules/@prmichaelsen/remember-core/dist/webhooks/signing.js
5686
+ import { createHmac } from "node:crypto";
5687
+ function signWebhookPayload(webhookId, timestamp, body, secret) {
5688
+ const content = `${webhookId}.${timestamp}.${body}`;
5689
+ const hmac = createHmac("sha256", secret).update(content).digest("base64");
5690
+ return `v1,${hmac}`;
5691
+ }
5692
+
5693
+ // node_modules/@prmichaelsen/remember-core/dist/webhooks/batched-webhook.service.js
5694
+ var DEFAULT_MAX_BATCH_SIZE = 20;
5695
+ var DEFAULT_FLUSH_INTERVAL_MS = 1e3;
5696
+ var DEFAULT_TIMEOUT_MS = 5e3;
5697
+ var SOURCE = "remember-core";
5698
+ var API_VERSION = "1";
5699
+ var BatchedWebhookService = class {
5700
+ logger;
5701
+ resolveEndpoint;
5702
+ maxBatchSize;
5703
+ flushIntervalMs;
5704
+ timeoutMs;
5705
+ onError;
5706
+ buffers = /* @__PURE__ */ new Map();
5707
+ constructor(logger2, config3) {
5708
+ this.logger = logger2;
5709
+ this.resolveEndpoint = config3.resolveEndpoint;
5710
+ this.maxBatchSize = config3.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
5711
+ this.flushIntervalMs = config3.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
5712
+ this.timeoutMs = config3.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5713
+ this.onError = config3.onError;
5714
+ }
5715
+ emit(event, actor) {
5716
+ const ownerId = event.owner_id;
5717
+ const endpoints = this.resolveEndpoint(ownerId);
5718
+ if (endpoints.length === 0) {
5719
+ this.logger.debug?.("[BatchedWebhookService] no endpoints for owner, dropping event", {
5720
+ owner_id: ownerId,
5721
+ type: event.type
5722
+ });
5723
+ return;
5724
+ }
5725
+ const envelope = this.buildEnvelope(event, actor);
5726
+ for (const endpoint of endpoints) {
5727
+ const url = endpoint.url;
5728
+ let buffer = this.buffers.get(url);
5729
+ if (!buffer) {
5730
+ buffer = { endpoint, envelopes: [], timer: null };
5731
+ this.buffers.set(url, buffer);
5732
+ }
5733
+ buffer.envelopes.push(envelope);
5734
+ if (buffer.envelopes.length >= this.maxBatchSize) {
5735
+ this.flush(url);
5736
+ } else if (!buffer.timer) {
5737
+ buffer.timer = setTimeout(() => this.flush(url), this.flushIntervalMs);
5738
+ }
5739
+ }
5740
+ }
5741
+ flush(url) {
5742
+ const buffer = this.buffers.get(url);
5743
+ if (!buffer || buffer.envelopes.length === 0)
5744
+ return;
5745
+ const envelopes = buffer.envelopes;
5746
+ const endpoint = buffer.endpoint;
5747
+ if (buffer.timer) {
5748
+ clearTimeout(buffer.timer);
5749
+ }
5750
+ buffer.envelopes = [];
5751
+ buffer.timer = null;
5752
+ this.sendBatch(url, endpoint, envelopes).catch((err2) => {
5753
+ this.logger.error?.("[BatchedWebhookService] batch delivery failed", {
5754
+ error: err2,
5755
+ url,
5756
+ count: envelopes.length
5757
+ });
5758
+ this.onError?.(err2, envelopes);
5759
+ });
5760
+ }
5761
+ dispose() {
5762
+ for (const url of this.buffers.keys()) {
5763
+ this.flush(url);
5764
+ }
5765
+ }
5766
+ async sendBatch(url, endpoint, envelopes) {
5767
+ const batchId = v4_default();
5768
+ const batchTimestamp = Math.floor(Date.now() / 1e3);
5769
+ const body = JSON.stringify(envelopes);
5770
+ const signature = signWebhookPayload(batchId, batchTimestamp, body, endpoint.signingSecret);
5771
+ const controller = new AbortController();
5772
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
5773
+ try {
5774
+ const response = await fetch(url, {
5775
+ method: "POST",
5776
+ headers: {
5777
+ "Content-Type": "application/json",
5778
+ "webhook-id": batchId,
5779
+ "webhook-timestamp": String(batchTimestamp),
5780
+ "webhook-signature": signature,
5781
+ "x-webhook-batch": "true"
5782
+ },
5783
+ body,
5784
+ signal: controller.signal
5785
+ });
5786
+ if (!response.ok) {
5787
+ throw new Error(`Webhook batch delivery failed: HTTP ${response.status}`);
5788
+ }
5789
+ } finally {
5790
+ clearTimeout(timeout);
5791
+ }
5792
+ }
5793
+ buildEnvelope(event, actor) {
5794
+ return {
5795
+ id: v4_default(),
5796
+ timestamp: Math.floor(Date.now() / 1e3),
5797
+ source: SOURCE,
5798
+ api_version: API_VERSION,
5799
+ type: event.type,
5800
+ actor,
5801
+ data: event
5802
+ };
5803
+ }
5804
+ };
5805
+
5806
+ // node_modules/@prmichaelsen/remember-core/dist/webhooks/create.js
5807
+ function createBatchedWebhookService(logger2, config3) {
5808
+ return new BatchedWebhookService(logger2, config3);
5809
+ }
5810
+
5530
5811
  // src/weaviate/schema.ts
5531
5812
  init_logger();
5532
5813
 
@@ -5645,6 +5926,15 @@ var tokenService = new ConfirmationTokenService(coreLogger);
5645
5926
  var preferencesService = new PreferencesDatabaseService(coreLogger);
5646
5927
  var moderationClient = process.env.ANTHROPIC_API_KEY ? createModerationClient({ apiKey: process.env.ANTHROPIC_API_KEY }) : void 0;
5647
5928
  var memoryIndexService = new MemoryIndexService(coreLogger);
5929
+ var eventBus = (() => {
5930
+ const url = process.env.REMEMBER_WEBHOOK_URL;
5931
+ const secret = process.env.REMEMBER_WEBHOOK_SECRET;
5932
+ if (!url || !secret)
5933
+ return void 0;
5934
+ return createBatchedWebhookService(coreLogger, {
5935
+ resolveEndpoint: () => [{ url, signingSecret: secret }]
5936
+ });
5937
+ })();
5648
5938
  var coreServicesCache = /* @__PURE__ */ new Map();
5649
5939
  function createCoreServices(userId) {
5650
5940
  const cached = coreServicesCache.get(userId);
@@ -5658,7 +5948,7 @@ function createCoreServices(userId) {
5658
5948
  weaviateClient
5659
5949
  }),
5660
5950
  relationship: new RelationshipService(collection, userId, coreLogger),
5661
- space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger, memoryIndexService, { moderationClient }),
5951
+ space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger, memoryIndexService, { moderationClient, eventBus }),
5662
5952
  preferences: preferencesService,
5663
5953
  token: tokenService
5664
5954
  };
package/jest.config.js CHANGED
@@ -2,6 +2,7 @@ export default {
2
2
  preset: 'ts-jest/presets/default-esm',
3
3
  testEnvironment: 'node',
4
4
  extensionsToTreatAsEsm: ['.ts'],
5
+ maxWorkers: '50%',
5
6
  roots: ['<rootDir>/src'],
6
7
  testMatch: ['**/*.spec.ts'],
7
8
  moduleFileExtensions: ['ts', 'js'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/remember-mcp",
3
- "version": "3.16.2",
3
+ "version": "3.17.1",
4
4
  "description": "Multi-tenant memory system MCP server with vector search and relationships",
5
5
  "main": "dist/server.js",
6
6
  "type": "module",
@@ -51,7 +51,7 @@
51
51
  "@google-cloud/vision": "^5.3.4",
52
52
  "@modelcontextprotocol/sdk": "^1.0.4",
53
53
  "@prmichaelsen/firebase-admin-sdk-v8": "^2.2.0",
54
- "@prmichaelsen/remember-core": "^0.49.16",
54
+ "@prmichaelsen/remember-core": "^0.54.0",
55
55
  "dotenv": "^16.4.5",
56
56
  "uuid": "^13.0.0",
57
57
  "weaviate-client": "^3.2.0"
@@ -14,8 +14,9 @@ import {
14
14
  ConfirmationTokenService,
15
15
  createLogger,
16
16
  createModerationClient,
17
+ createBatchedWebhookService,
17
18
  } from '@prmichaelsen/remember-core';
18
- import type { Logger, ModerationClient } from '@prmichaelsen/remember-core';
19
+ import type { Logger, ModerationClient, EventBus } from '@prmichaelsen/remember-core';
19
20
  import { getWeaviateClient } from './weaviate/client.js';
20
21
  import { getMemoryCollection } from './weaviate/schema.js';
21
22
 
@@ -36,6 +37,16 @@ const moderationClient: ModerationClient | undefined = process.env.ANTHROPIC_API
36
37
  : undefined;
37
38
  const memoryIndexService = new MemoryIndexService(coreLogger);
38
39
 
40
+ // Webhook event bus — fans out events to all configured endpoints
41
+ const eventBus: EventBus | undefined = (() => {
42
+ const url = process.env.REMEMBER_WEBHOOK_URL;
43
+ const secret = process.env.REMEMBER_WEBHOOK_SECRET;
44
+ if (!url || !secret) return undefined;
45
+ return createBatchedWebhookService(coreLogger, {
46
+ resolveEndpoint: () => [{ url, signingSecret: secret }],
47
+ });
48
+ })();
49
+
39
50
  /** Cached CoreServices per userId — avoids re-instantiation on every tool call */
40
51
  const coreServicesCache = new Map<string, CoreServices>();
41
52
 
@@ -56,7 +67,7 @@ export function createCoreServices(userId: string): CoreServices {
56
67
  weaviateClient,
57
68
  }),
58
69
  relationship: new RelationshipService(collection, userId, coreLogger),
59
- space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger, memoryIndexService, { moderationClient }),
70
+ space: new SpaceService(weaviateClient, collection, userId, tokenService, coreLogger, memoryIndexService, { moderationClient, eventBus }),
60
71
  preferences: preferencesService,
61
72
  token: tokenService,
62
73
  };
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=server-factory.spec.d.ts.map