@ghostpaw/affinity 0.5.0 → 0.6.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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync } from 'node:fs';
2
+ import { mkdirSync, existsSync, closeSync, openSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
 
@@ -29,6 +29,13 @@ var init_surface_types = __esm({
29
29
 
30
30
  // src/surfaces/validate_input.ts
31
31
  function validateInput(schema, input, path = "$") {
32
+ if (path.endsWith(".occurredAt") && input == null)
33
+ return ["occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"];
34
+ const branches = schema.oneOf ?? schema.anyOf;
35
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && input !== null && typeof input !== "string")
36
+ return [
37
+ `attribute values are strings or null; pass "${String(input)}" to store it as text`
38
+ ];
32
39
  const errors = [];
33
40
  const matchesType = (type) => {
34
41
  switch (type) {
@@ -59,6 +66,10 @@ function validateInput(schema, input, path = "$") {
59
66
  (branch) => validateInput(branch, input, path).length === 0
60
67
  ))
61
68
  errors.push(`${path}: must match at least one schema branch`);
69
+ if (schema.anyOf && !schema.anyOf.some(
70
+ (branch) => validateInput(branch, input, path).length === 0
71
+ ))
72
+ errors.push(`${path}: must match at least one schema branch`);
62
73
  if (typeof input === "object" && input !== null && !Array.isArray(input)) {
63
74
  const record = input;
64
75
  for (const name of schema.required ?? [])
@@ -285,7 +296,7 @@ var init_mcp_stdio = __esm({
285
296
  var affinityVersion;
286
297
  var init_version = __esm({
287
298
  "src/version.ts"() {
288
- affinityVersion = "0.5.0";
299
+ affinityVersion = "0.6.1";
289
300
  }
290
301
  });
291
302
 
@@ -415,6 +426,174 @@ var init_affinity_not_found_error = __esm({
415
426
  }
416
427
  });
417
428
 
429
+ // src/lib/normalize_mutation_receipt.ts
430
+ function uniqueEntityRefs(refs) {
431
+ const out = [];
432
+ const seen = /* @__PURE__ */ new Set();
433
+ for (const ref of refs ?? []) {
434
+ const key = `${ref.kind}:${ref.id}`;
435
+ if (seen.has(key)) {
436
+ continue;
437
+ }
438
+ seen.add(key);
439
+ out.push(ref);
440
+ }
441
+ return out;
442
+ }
443
+ function uniqueDerivedEffects(effects) {
444
+ const out = [];
445
+ const seen = /* @__PURE__ */ new Set();
446
+ for (const effect of effects ?? []) {
447
+ const key = `${effect.eventId}:${effect.linkId}`;
448
+ if (seen.has(key)) {
449
+ continue;
450
+ }
451
+ seen.add(key);
452
+ out.push(effect);
453
+ }
454
+ return out;
455
+ }
456
+ function uniqueAffectedLinks(affectedLinks, derivedEffects) {
457
+ const out = [];
458
+ const seen = /* @__PURE__ */ new Set();
459
+ for (const linkId of affectedLinks ?? []) {
460
+ if (seen.has(linkId)) {
461
+ continue;
462
+ }
463
+ seen.add(linkId);
464
+ out.push(linkId);
465
+ }
466
+ for (const effect of derivedEffects) {
467
+ if (seen.has(effect.linkId)) {
468
+ continue;
469
+ }
470
+ seen.add(effect.linkId);
471
+ out.push(effect.linkId);
472
+ }
473
+ return out;
474
+ }
475
+ function normalizeMutationReceiptPartial(partial) {
476
+ const derivedEffects = uniqueDerivedEffects(partial?.derivedEffects);
477
+ return {
478
+ created: uniqueEntityRefs(partial?.created),
479
+ updated: uniqueEntityRefs(partial?.updated),
480
+ archived: uniqueEntityRefs(partial?.archived),
481
+ removed: uniqueEntityRefs(partial?.removed),
482
+ affectedLinks: uniqueAffectedLinks(partial?.affectedLinks, derivedEffects),
483
+ derivedEffects
484
+ };
485
+ }
486
+ var init_normalize_mutation_receipt = __esm({
487
+ "src/lib/normalize_mutation_receipt.ts"() {
488
+ }
489
+ });
490
+
491
+ // src/events/receipts.ts
492
+ function buildEventMutationReceipt(primary, partial) {
493
+ const normalized = normalizeMutationReceiptPartial(partial);
494
+ return {
495
+ primary,
496
+ ...partial?.warnings ? { warnings: partial.warnings } : {},
497
+ created: normalized.created,
498
+ updated: normalized.updated,
499
+ archived: normalized.archived,
500
+ removed: normalized.removed,
501
+ affectedLinks: normalized.affectedLinks,
502
+ derivedEffects: normalized.derivedEffects
503
+ };
504
+ }
505
+ var init_receipts = __esm({
506
+ "src/events/receipts.ts"() {
507
+ init_normalize_mutation_receipt();
508
+ }
509
+ });
510
+
511
+ // src/events/event_metadata.ts
512
+ function normalizeExternalRef(ref) {
513
+ if (!ref || typeof ref.source !== "string" || typeof ref.id !== "string" || !ref.source.trim() || !ref.id.trim())
514
+ throw new AffinityValidationError(
515
+ "external reference source and id must be non-empty strings"
516
+ );
517
+ return { source: ref.source.trim().toLowerCase(), id: ref.id.trim() };
518
+ }
519
+ function deriveEventSummary(content) {
520
+ const line = content.split(/\r?\n/).find((line2) => line2.trim())?.replace(/\s+/g, " ").trim() ?? "(no text)";
521
+ return line.length > 140 ? `${line.slice(0, 139)}\u2026` : line;
522
+ }
523
+ function validateEventMetadata(input) {
524
+ if (input.affectsBond !== void 0 && typeof input.affectsBond !== "boolean")
525
+ throw new AffinityValidationError("affectsBond must be boolean");
526
+ if (input.content !== void 0 && (typeof input.content !== "string" || Buffer.byteLength(input.content, "utf8") > 1048576))
527
+ throw new AffinityValidationError(
528
+ "content must be a string of at most 1 MB (1048576 UTF-8 bytes)"
529
+ );
530
+ for (const key of ["externalRef", "parentRef", "threadRef"])
531
+ if (input[key] !== void 0) input[key] = normalizeExternalRef(input[key]);
532
+ if (input.channel !== void 0) {
533
+ if (typeof input.channel !== "string" || !input.channel.trim() || input.channel.trim().length > 40)
534
+ throw new AffinityValidationError(
535
+ "channel must be non-empty and at most 40 characters"
536
+ );
537
+ input.channel = input.channel.trim().toLowerCase();
538
+ }
539
+ if (input.parentEventId !== void 0 && (!Number.isSafeInteger(input.parentEventId) || input.parentEventId <= 0))
540
+ throw new AffinityValidationError(
541
+ "parentEventId must be a positive integer"
542
+ );
543
+ const moments = [
544
+ "breakthrough",
545
+ "rupture",
546
+ "reconciliation",
547
+ "milestone",
548
+ "turning_point"
549
+ ];
550
+ if (input.momentKind !== void 0 && !moments.includes(input.momentKind))
551
+ throw new AffinityValidationError(
552
+ `momentKind must be one of: ${moments.join(", ")}`
553
+ );
554
+ }
555
+ function getEventById(db, eventId) {
556
+ return db.prepare("SELECT 1 FROM events WHERE id=? AND deleted_at IS NULL").get(eventId) ? loadEventRecord(db, eventId) : null;
557
+ }
558
+ function getEventByExternalRef(db, source, id) {
559
+ const ref = normalizeExternalRef({ source, id });
560
+ const row = db.prepare(
561
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
562
+ ).get(ref.source, ref.id);
563
+ return row ? loadEventRecord(db, row.id) : null;
564
+ }
565
+ function existingEventReceipt(db, ref) {
566
+ const event = ref && getEventByExternalRef(db, ref.source, ref.id);
567
+ return event ? { ...buildEventMutationReceipt(event, {}), outcome: "no_op" } : null;
568
+ }
569
+ function enrichEventMetadata(db, record) {
570
+ const bond = db.prepare("SELECT affects_bond FROM events WHERE id=?").get(record.id);
571
+ record.affectsBond = bond?.affects_bond == null ? !(record.type === "observation" && record.significance <= 2) : Boolean(bond.affects_bond);
572
+ if (record.parentEventId === void 0 && record.parentRef) {
573
+ const row = db.prepare(
574
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
575
+ ).get(record.parentRef.source, record.parentRef.id);
576
+ if (row) record.parentEventId = row.id;
577
+ }
578
+ const attrs = db.prepare(
579
+ "SELECT id, name, value FROM attributes WHERE event_id=? AND deleted_at IS NULL ORDER BY id"
580
+ ).all(record.id);
581
+ if (attrs.length)
582
+ record.attributes = attrs.map((a) => ({
583
+ ...a,
584
+ contactId: null,
585
+ linkId: null,
586
+ eventId: record.id
587
+ }));
588
+ }
589
+ var init_event_metadata = __esm({
590
+ "src/events/event_metadata.ts"() {
591
+ init_affinity_validation_error();
592
+ init_loaders();
593
+ init_receipts();
594
+ }
595
+ });
596
+
418
597
  // src/contacts/queries.ts
419
598
  function getContactRowById(db, id) {
420
599
  const row = db.prepare(
@@ -484,8 +663,11 @@ function assertParticipantContactsLive(db, contactIds) {
484
663
  }
485
664
  function validateSocialEventInput(input) {
486
665
  assertFiniteTimestamp(input.occurredAt, "occurredAt");
487
- const summary = input.summary.trim();
488
- if (summary.length === 0) {
666
+ validateEventMetadata(input);
667
+ if (input.summary !== void 0 && typeof input.summary !== "string")
668
+ throw new AffinityValidationError("summary must be a string");
669
+ const summary = input.summary?.trim();
670
+ if (summary === "" || summary === void 0 && input.content === void 0) {
489
671
  throw new AffinityValidationError("summary must be non-empty");
490
672
  }
491
673
  const sig = input.significance;
@@ -548,6 +730,7 @@ var init_validators = __esm({
548
730
  init_affinity_not_found_error();
549
731
  init_affinity_state_error();
550
732
  init_affinity_validation_error();
733
+ init_event_metadata();
551
734
  PARTICIPANT_ROLES = /* @__PURE__ */ new Set([
552
735
  "actor",
553
736
  "recipient",
@@ -578,11 +761,27 @@ function mapEventRowToEventRecord(row, participants) {
578
761
  id: row.id,
579
762
  type: row.type,
580
763
  occurredAt: row.occurred_at,
581
- summary: row.summary,
764
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
765
+ summary: decodeEventText(row.summary),
582
766
  significance: row.significance,
583
767
  momentKind: row.moment_kind,
584
768
  participants
585
769
  };
770
+ if (row.provenance_json != null)
771
+ base.provenance = JSON.parse(row.provenance_json);
772
+ if (row.content != null) base.content = decodeEventText(row.content);
773
+ if (row.channel != null) base.channel = row.channel;
774
+ if (row.external_source != null && row.external_id != null)
775
+ base.externalRef = { source: row.external_source, id: row.external_id };
776
+ if (row.parent_source != null && row.parent_ref_id != null)
777
+ base.parentRef = { source: row.parent_source, id: row.parent_ref_id };
778
+ if (row.thread_source != null && row.thread_id != null)
779
+ base.threadRef = { source: row.thread_source, id: row.thread_id };
780
+ if (row.parent_event_id != null) base.parentEventId = row.parent_event_id;
781
+ if (row.type === "promise" || row.type === "agreement") {
782
+ base.dueAt = row.due_at ?? null;
783
+ base.dueOn = row.due_on ?? (row.due_at == null ? null : new Date(row.due_at).toISOString().slice(0, 10));
784
+ }
586
785
  if (row.recurrence_kind !== null) {
587
786
  base.recurrenceKind = row.recurrence_kind;
588
787
  base.anchorMonth = row.anchor_month;
@@ -612,10 +811,14 @@ function mapUpcomingRowToUpcomingDateRecord(row) {
612
811
  targetRef,
613
812
  recurrenceKind,
614
813
  occursOn: row.occurs_on,
615
- summary: row.summary,
814
+ occursOnDate: new Date(row.occurs_on).toISOString().slice(0, 10),
815
+ summary: decodeEventText(row.summary),
616
816
  significance: row.significance
617
817
  };
618
818
  }
819
+ function decodeEventText(value) {
820
+ return typeof value === "string" ? value : Buffer.from(value).toString("utf8");
821
+ }
619
822
  var init_mappers = __esm({
620
823
  "src/events/mappers.ts"() {
621
824
  init_affinity_invariant_error();
@@ -626,17 +829,17 @@ var init_mappers = __esm({
626
829
  // src/events/queries.ts
627
830
  function getEventRowById(db, id) {
628
831
  const row = db.prepare(
629
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
630
- recurrence_kind, anchor_month, anchor_day,
631
- anchor_contact_id, anchor_link_id,
632
- created_at, updated_at, deleted_at
832
+ `SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary
633
833
  FROM events
634
834
  WHERE id = ? AND deleted_at IS NULL`
635
835
  ).get(id);
636
836
  if (row === void 0) {
637
837
  return null;
638
838
  }
639
- return row;
839
+ const event = row;
840
+ event.summary = decodeEventText(event.summary);
841
+ if (event.content != null) event.content = decodeEventText(event.content);
842
+ return event;
640
843
  }
641
844
  function findDuplicateDateAnchor(db, params) {
642
845
  const exclude = params.excludeEventId;
@@ -686,6 +889,7 @@ function findDuplicateDateAnchor(db, params) {
686
889
  }
687
890
  var init_queries2 = __esm({
688
891
  "src/events/queries.ts"() {
892
+ init_mappers();
689
893
  }
690
894
  });
691
895
 
@@ -708,19 +912,27 @@ function loadEventRecord(db, eventId) {
708
912
  throw new AffinityInvariantError("event not found");
709
913
  }
710
914
  const participants = loadEventParticipantViews(db, eventId);
711
- return mapEventRowToEventRecord(row, participants);
915
+ const record = mapEventRowToEventRecord(row, participants);
916
+ const due = db.prepare("SELECT due_at, due_on FROM open_commitments WHERE event_id = ?").get(eventId);
917
+ if (due) {
918
+ record.dueAt = due.due_at;
919
+ record.dueOn = due.due_on ?? (due.due_at === null ? null : new Date(due.due_at).toISOString().slice(0, 10));
920
+ }
921
+ enrichEventMetadata(db, record);
922
+ return record;
712
923
  }
713
- function batchLoadEventRecords(db, eventIds) {
924
+ function batchLoadEventRecords(db, eventIds, includeContent = false) {
714
925
  if (eventIds.length === 0) {
715
926
  return [];
716
927
  }
717
928
  const ph = eventIds.map(() => "?").join(", ");
718
929
  const eventRows = db.prepare(
719
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
930
+ `SELECT e.id, e.type, e.occurred_at, e.occurred_on, oc.due_at, oc.due_on, significance, moment_kind,
931
+ e.external_source,e.external_id,e.channel,e.parent_source,e.parent_ref_id,e.parent_event_id,e.thread_source,e.thread_id,
720
932
  recurrence_kind, anchor_month, anchor_day,
721
933
  anchor_contact_id, anchor_link_id,
722
- created_at, updated_at, deleted_at
723
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
934
+ e.created_at, e.updated_at, e.deleted_at, ${includeContent ? "CAST(e.content AS BLOB)" : "NULL"} AS content, CAST(e.summary AS BLOB) AS summary
935
+ FROM events e LEFT JOIN open_commitments oc ON oc.event_id = e.id WHERE e.id IN (${ph}) AND e.deleted_at IS NULL`
724
936
  ).all(...eventIds);
725
937
  const partRows = db.prepare(
726
938
  `SELECT event_id, contact_id, role, directionality
@@ -744,7 +956,9 @@ function batchLoadEventRecords(db, eventIds) {
744
956
  for (const id of eventIds) {
745
957
  const row = rowById.get(id);
746
958
  if (row) {
747
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
959
+ const record = mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []);
960
+ enrichEventMetadata(db, record);
961
+ out.push(record);
748
962
  }
749
963
  }
750
964
  return out;
@@ -787,6 +1001,7 @@ var init_loaders = __esm({
787
1001
  "src/events/loaders.ts"() {
788
1002
  init_affinity_invariant_error();
789
1003
  init_affinity_not_found_error();
1004
+ init_event_metadata();
790
1005
  init_mappers();
791
1006
  init_queries2();
792
1007
  }
@@ -811,6 +1026,10 @@ function getContactJournal(db, contactId, options) {
811
1026
  clauses.push(`e.type IN (${ph})`);
812
1027
  params.push(...options.eventTypes);
813
1028
  }
1029
+ if (options?.channel !== void 0) {
1030
+ clauses.push("e.channel = ?");
1031
+ params.push(options.channel.trim().toLowerCase());
1032
+ }
814
1033
  const where = clauses.join(" AND ");
815
1034
  const rows = db.prepare(
816
1035
  `SELECT e.id FROM events e
@@ -821,7 +1040,8 @@ function getContactJournal(db, contactId, options) {
821
1040
  ).all(...params, limit, offset);
822
1041
  return batchLoadEventRecords(
823
1042
  db,
824
- rows.map((r) => r.id)
1043
+ rows.map((r) => r.id),
1044
+ options?.includeContent ?? false
825
1045
  );
826
1046
  }
827
1047
  var init_get_contact_journal = __esm({
@@ -832,6 +1052,73 @@ var init_get_contact_journal = __esm({
832
1052
  }
833
1053
  });
834
1054
 
1055
+ // src/lib/parse_calendar_time.ts
1056
+ function isCommitmentOverdue(dueAt, dueOn, now) {
1057
+ if (dueAt === null) return false;
1058
+ if (dueOn && dueAt === Date.parse(`${dueOn}T23:59:59.999Z`)) {
1059
+ return localCalendarDate(now) > dueOn;
1060
+ }
1061
+ return dueAt < now;
1062
+ }
1063
+ function localCalendarDate(now) {
1064
+ const d = new Date(now);
1065
+ return `${String(d.getFullYear()).padStart(4, "0")}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1066
+ }
1067
+ function parseCalendarTime(value, field = "time", now = Date.now()) {
1068
+ if (value == null && field.endsWith("occurredAt"))
1069
+ throw new AffinityValidationError(
1070
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
1071
+ );
1072
+ const fail = () => {
1073
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1074
+ };
1075
+ if (typeof value === "number" || typeof value === "string" && /^-?\d+$/.test(value.trim())) {
1076
+ const timestamp2 = Number(value);
1077
+ if (!Number.isSafeInteger(timestamp2) || !Number.isFinite(new Date(timestamp2).getTime()))
1078
+ return fail();
1079
+ return { timestamp: timestamp2, day: new Date(timestamp2).toISOString().slice(0, 10) };
1080
+ }
1081
+ if (typeof value !== "string") return fail();
1082
+ let text = value.trim();
1083
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text)) {
1084
+ text = text.replace(" ", "T");
1085
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(text)) text += "Z";
1086
+ }
1087
+ text = text.replace(
1088
+ /\.(\d{4,})(?=Z|[+-]\d{2}:\d{2}$)/,
1089
+ (_, digits) => `.${digits.slice(0, 3)}`
1090
+ );
1091
+ if (/^(today|yesterday|tomorrow|[+-]\d+d)$/.test(text)) {
1092
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1093
+ const date = /* @__PURE__ */ new Date(`${localCalendarDate(now)}T00:00:00Z`);
1094
+ date.setUTCDate(date.getUTCDate() + offset);
1095
+ if (!Number.isFinite(date.getTime())) return fail();
1096
+ text = date.toISOString().slice(0, 10);
1097
+ }
1098
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
1099
+ if (european) text = `${european[3]}-${european[2]}-${european[1]}`;
1100
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(text);
1101
+ if (!dateOnly && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1102
+ text
1103
+ ))
1104
+ return fail();
1105
+ const day = text.slice(0, 10);
1106
+ const midnight = Date.parse(`${day}T00:00:00Z`);
1107
+ if (!Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== day)
1108
+ return fail();
1109
+ const timestamp = dateOnly ? midnight + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0) : Date.parse(text);
1110
+ if (!Number.isFinite(timestamp)) return fail();
1111
+ return { timestamp, day };
1112
+ }
1113
+ var timeInputForms, OVERDUE_SQL;
1114
+ var init_parse_calendar_time = __esm({
1115
+ "src/lib/parse_calendar_time.ts"() {
1116
+ init_affinity_validation_error();
1117
+ timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d or -2d";
1118
+ OVERDUE_SQL = "(CASE WHEN oc.due_on IS NOT NULL AND oc.due_at = CAST(strftime('%s', oc.due_on) AS INTEGER) * 1000 + 86399999 THEN oc.due_on < ? ELSE oc.due_at < ? END)";
1119
+ }
1120
+ });
1121
+
835
1122
  // src/resolve_now.ts
836
1123
  function resolveNow(now) {
837
1124
  if (now !== void 0) {
@@ -904,9 +1191,13 @@ function listOpenCommitments(db, filters, options) {
904
1191
  clauses.push("oc.due_at <= ?");
905
1192
  params.push(anchor + filters.horizonDays * DAY_MS);
906
1193
  }
1194
+ if (options?.channel !== void 0) {
1195
+ clauses.push("e.channel=?");
1196
+ params.push(options.channel.trim().toLowerCase());
1197
+ }
907
1198
  const where = clauses.join(" AND ");
908
1199
  const rows = db.prepare(
909
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
1200
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.due_on, oc.resolved_at, oc.resolution,
910
1201
  e.type, e.summary
911
1202
  FROM open_commitments oc
912
1203
  INNER JOIN events e ON e.id = oc.event_id
@@ -923,7 +1214,8 @@ function listOpenCommitments(db, filters, options) {
923
1214
  participants: partsByEvent.get(r.event_id) ?? [],
924
1215
  dueAt: r.due_at,
925
1216
  dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
926
- overdue: r.due_at !== null && r.due_at < now,
1217
+ dueOn: r.due_on ?? (r.due_at === null ? null : new Date(r.due_at).toISOString().slice(0, 10)),
1218
+ overdue: isCommitmentOverdue(r.due_at, r.due_on, now),
927
1219
  resolutionState: mapResolution(r.resolved_at, r.resolution)
928
1220
  }));
929
1221
  }
@@ -931,6 +1223,7 @@ var DAY_MS;
931
1223
  var init_list_open_commitments = __esm({
932
1224
  "src/events/list_open_commitments.ts"() {
933
1225
  init_affinity_validation_error();
1226
+ init_parse_calendar_time();
934
1227
  init_assert_default_ordering();
935
1228
  init_resolve_list_limit_offset();
936
1229
  init_resolve_now();
@@ -1008,8 +1301,10 @@ function utcMidnight(year, month1Based, day) {
1008
1301
  return Date.UTC(year, month1Based - 1, day, 0, 0, 0, 0);
1009
1302
  }
1010
1303
  function computeNextAnchorOccursOn(params) {
1011
- const refStart = utcStartOfDayMs(params.referenceMs);
1012
- const refYear = new Date(params.referenceMs).getUTCFullYear();
1304
+ const refStart = Date.parse(
1305
+ `${localCalendarDate(params.referenceMs)}T00:00:00Z`
1306
+ );
1307
+ const refYear = new Date(refStart).getUTCFullYear();
1013
1308
  for (let year2 = refYear; year2 <= refYear + 2; year2 += 1) {
1014
1309
  const day2 = effectiveAnchorDay(year2, params.anchorMonth, params.anchorDay);
1015
1310
  const candidate = utcMidnight(year2, params.anchorMonth, day2);
@@ -1023,6 +1318,7 @@ function computeNextAnchorOccursOn(params) {
1023
1318
  }
1024
1319
  var init_calendar = __esm({
1025
1320
  "src/dates/calendar.ts"() {
1321
+ init_parse_calendar_time();
1026
1322
  }
1027
1323
  });
1028
1324
 
@@ -1240,7 +1536,13 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
1240
1536
  ORDER BY COALESCE(lr.normalized_rank, 0) DESC, l.trust DESC
1241
1537
  LIMIT ?`
1242
1538
  ).all(contactId, contactId, topLinksLimit);
1243
- const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
1539
+ const topLinks = linkRows.map((l) => ({
1540
+ ...mapLinkRowToLinkListItem(l),
1541
+ counterpartyName: getContactRowById(
1542
+ db,
1543
+ l.from_contact_id === contactId ? l.to_contact_id : l.from_contact_id
1544
+ )?.name ?? ""
1545
+ }));
1244
1546
  const activeDates = loadActiveDatesForContact(db, contactId);
1245
1547
  const contactRollup = loadContactRollup(db, contactId);
1246
1548
  const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
@@ -1283,7 +1585,20 @@ function getContactProfile(db, contactId, options) {
1283
1585
  return {
1284
1586
  ...profile,
1285
1587
  recentEvents: getContactJournal(db, contactId, {
1286
- limit: options?.recentEventsLimit ?? 5
1588
+ limit: options?.recentEventsLimit ?? 5,
1589
+ eventTypes: [
1590
+ "conversation",
1591
+ "activity",
1592
+ "gift",
1593
+ "support",
1594
+ "milestone",
1595
+ "observation",
1596
+ "conflict",
1597
+ "correction",
1598
+ "transaction",
1599
+ "promise",
1600
+ "agreement"
1601
+ ]
1287
1602
  }).map((event) => ({
1288
1603
  ...event,
1289
1604
  occurredAtIso: new Date(event.occurredAt).toISOString()
@@ -1351,6 +1666,10 @@ function listMoments(db, filters, options) {
1351
1666
  clauses.push("le.link_id = ?");
1352
1667
  params.push(filters.linkId);
1353
1668
  }
1669
+ if (options?.channel !== void 0) {
1670
+ clauses.push("e.channel=?");
1671
+ params.push(options.channel.trim().toLowerCase());
1672
+ }
1354
1673
  const where = clauses.join(" AND ");
1355
1674
  return db.prepare(
1356
1675
  `SELECT
@@ -1358,6 +1677,7 @@ function listMoments(db, filters, options) {
1358
1677
  le.link_id,
1359
1678
  le.moment_kind,
1360
1679
  le.occurred_at,
1680
+ e.occurred_on,
1361
1681
  e.summary,
1362
1682
  le.impact_score
1363
1683
  FROM link_event_effects le
@@ -1370,6 +1690,7 @@ function listMoments(db, filters, options) {
1370
1690
  linkId: row.link_id,
1371
1691
  momentKind: row.moment_kind,
1372
1692
  occurredAt: row.occurred_at,
1693
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
1373
1694
  summary: row.summary,
1374
1695
  impactScore: row.impact_score
1375
1696
  }));
@@ -1469,6 +1790,10 @@ function getLinkTimeline(db, linkId, options) {
1469
1790
  clauses.push(`e.type IN (${ph})`);
1470
1791
  params.push(...options.eventTypes);
1471
1792
  }
1793
+ if (options?.channel !== void 0) {
1794
+ clauses.push("e.channel = ?");
1795
+ params.push(options.channel.trim().toLowerCase());
1796
+ }
1472
1797
  const where = clauses.join(" AND ");
1473
1798
  const rows = db.prepare(
1474
1799
  `SELECT e.id
@@ -1480,7 +1805,8 @@ function getLinkTimeline(db, linkId, options) {
1480
1805
  ).all(...params, limit, offset);
1481
1806
  return batchLoadEventRecords(
1482
1807
  db,
1483
- rows.map((r) => r.id)
1808
+ rows.map((r) => r.id),
1809
+ options?.includeContent ?? false
1484
1810
  );
1485
1811
  }
1486
1812
  var init_get_link_timeline = __esm({
@@ -1664,26 +1990,34 @@ function refreshLinkRollup(db, linkId, now) {
1664
1990
  const latest = db.prepare(
1665
1991
  `SELECT event_id, occurred_at
1666
1992
  FROM link_event_effects
1667
- WHERE link_id = ? AND counts_for_recency = 1
1993
+ WHERE link_id = ? AND counts_for_recency = 1 AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))
1668
1994
  ORDER BY occurred_at DESC, event_id DESC
1669
1995
  LIMIT 1`
1670
- ).get(linkId);
1996
+ ).get(linkId, now, localCalendarDate(now));
1671
1997
  const trailingWindowStart = now - 180 * DAY_MS2;
1672
1998
  const counts = db.prepare(
1673
1999
  `SELECT
1674
2000
  COUNT(*) AS total_meaningful_events,
1675
2001
  SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
1676
2002
  FROM link_event_effects
1677
- WHERE link_id = ? AND occurred_at >= ?`
1678
- ).get(linkId, trailingWindowStart);
2003
+ WHERE link_id = ? AND occurred_at >= ? AND counts_for_recency = 1
2004
+ AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
2005
+ ).get(linkId, trailingWindowStart, now, localCalendarDate(now));
1679
2006
  const directionalityRows = db.prepare(
1680
2007
  `SELECT ep.directionality
1681
2008
  FROM link_event_effects le
1682
2009
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
1683
2010
  WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
1684
2011
  AND ep.directionality IS NOT NULL
1685
- AND le.occurred_at >= ?`
1686
- ).all(linkId, link.from_contact_id, trailingWindowStart);
2012
+ AND le.occurred_at >= ?
2013
+ AND le.event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
2014
+ ).all(
2015
+ linkId,
2016
+ link.from_contact_id,
2017
+ trailingWindowStart,
2018
+ now,
2019
+ localCalendarDate(now)
2020
+ );
1687
2021
  let outboundCount = 0;
1688
2022
  let inboundCount = 0;
1689
2023
  for (const row of directionalityRows) {
@@ -1793,6 +2127,7 @@ var DAY_MS2;
1793
2127
  var init_rollups2 = __esm({
1794
2128
  "src/links/rollups.ts"() {
1795
2129
  init_rollups();
2130
+ init_parse_calendar_time();
1796
2131
  init_mechanics();
1797
2132
  init_queries3();
1798
2133
  DAY_MS2 = 864e5;
@@ -1855,41 +2190,12 @@ var init_get_link_detail = __esm({
1855
2190
 
1856
2191
  // src/tools/parse_time_input.ts
1857
2192
  function parseTimeInput(value, field = "time", now = Date.now()) {
1858
- const fail = () => {
1859
- throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1860
- };
1861
- if (typeof value === "number")
1862
- return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
1863
- if (typeof value !== "string") return fail();
1864
- const text = value.trim();
1865
- const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
1866
- let day;
1867
- if (relative) {
1868
- const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1869
- day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
1870
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
1871
- day = Date.parse(`${text}T00:00:00.000Z`);
1872
- if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
1873
- return fail();
1874
- } else {
1875
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1876
- text
1877
- ))
1878
- return fail();
1879
- const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
1880
- if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
1881
- return fail();
1882
- const parsed = Date.parse(text);
1883
- return Number.isFinite(parsed) ? parsed : fail();
1884
- }
1885
- const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
1886
- return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
2193
+ return parseCalendarTime(value, field, now).timestamp;
1887
2194
  }
1888
- var timeInputForms;
1889
2195
  var init_parse_time_input = __esm({
1890
2196
  "src/tools/parse_time_input.ts"() {
1891
- init_affinity_validation_error();
1892
- timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
2197
+ init_parse_calendar_time();
2198
+ init_parse_calendar_time();
1893
2199
  }
1894
2200
  });
1895
2201
 
@@ -1966,8 +2272,47 @@ var init_tool_metadata = __esm({
1966
2272
  }
1967
2273
  });
1968
2274
 
2275
+ // src/tools/event_metadata_schema.ts
2276
+ var externalRefSchema, eventMetadataSchema;
2277
+ var init_event_metadata_schema = __esm({
2278
+ "src/tools/event_metadata_schema.ts"() {
2279
+ init_tool_metadata();
2280
+ externalRefSchema = objectSchema(
2281
+ {
2282
+ source: stringSchema("External source; trimmed and lowercased."),
2283
+ id: stringSchema("Non-empty external id.")
2284
+ },
2285
+ ["source", "id"]
2286
+ );
2287
+ eventMetadataSchema = {
2288
+ affectsBond: booleanSchema(
2289
+ "Whether evidence affects the relationship; defaults false for like/follow/repost labels and observations with significance <=2; explicit true opts in."
2290
+ ),
2291
+ content: stringSchema(
2292
+ "Verbatim text, including empty text; at most 1 MB UTF-8. Summary may be omitted when content is supplied."
2293
+ ),
2294
+ externalRef: externalRefSchema,
2295
+ channel: stringSchema(
2296
+ "Free-text channel, trimmed and lowercased, at most 40 characters."
2297
+ ),
2298
+ parentRef: externalRefSchema,
2299
+ parentEventId: integerSchema("Parent event id."),
2300
+ threadRef: externalRefSchema,
2301
+ momentKind: enumSchema("Optional moment kind.", [
2302
+ "breakthrough",
2303
+ "rupture",
2304
+ "reconciliation",
2305
+ "milestone",
2306
+ "turning_point"
2307
+ ]),
2308
+ occurredOn: stringSchema("Calendar day."),
2309
+ provenance: {}
2310
+ };
2311
+ }
2312
+ });
2313
+
1969
2314
  // src/tools/tool_names.ts
1970
- var searchAffinityToolName, reviewAffinityToolName, inspectAffinityItemToolName, manageContactToolName, mergeContactsToolName, manageDuplicateDismissalToolName, manageIdentityToolName, manageRelationshipToolName, recordEventToolName, manageCommitmentToolName, manageDateAnchorToolName, manageAttributeToolName;
2315
+ var searchAffinityToolName, reviewAffinityToolName, inspectAffinityItemToolName, manageContactToolName, mergeContactsToolName, manageDuplicateDismissalToolName, manageIdentityToolName, manageRelationshipToolName, recordEventToolName, manageCommitmentToolName, manageDateAnchorToolName, manageAttributeToolName, manageEventToolName;
1971
2316
  var init_tool_names = __esm({
1972
2317
  "src/tools/tool_names.ts"() {
1973
2318
  searchAffinityToolName = "search_affinity";
@@ -1982,6 +2327,7 @@ var init_tool_names = __esm({
1982
2327
  manageCommitmentToolName = "manage_commitment";
1983
2328
  manageDateAnchorToolName = "manage_date_anchor";
1984
2329
  manageAttributeToolName = "manage_attribute";
2330
+ manageEventToolName = "manage_event";
1985
2331
  }
1986
2332
  });
1987
2333
 
@@ -2028,7 +2374,7 @@ function toEventEntityRef(value) {
2028
2374
  return {
2029
2375
  kind: "event",
2030
2376
  id: value.id,
2031
- title: value.summary,
2377
+ title: deriveEventSummary(value.summary),
2032
2378
  subtitle: value.type,
2033
2379
  state: value.type
2034
2380
  };
@@ -2063,21 +2409,48 @@ function toReceiptEntityRefs(receipt) {
2063
2409
  }
2064
2410
  var init_tool_ref = __esm({
2065
2411
  "src/tools/tool_ref.ts"() {
2412
+ init_event_metadata();
2066
2413
  }
2067
2414
  });
2068
2415
 
2069
2416
  // src/identities/normalize.ts
2417
+ function normalizeIdentityValue(type, value) {
2418
+ const v = value.trim();
2419
+ return handleIdentityTypes.has(type.toLowerCase()) ? v.replace(/^@/, "").toLowerCase() : v;
2420
+ }
2070
2421
  function normalizeIdentityKey(type, value) {
2071
- const t = type.trim().toLowerCase();
2072
- const v = value.trim().toLowerCase();
2422
+ let t = type.trim().toLowerCase();
2423
+ if (["did", "atproto_did", "custom:atproto-did"].includes(t)) t = "did";
2424
+ const v = normalizeIdentityValue(t, value).toLowerCase();
2073
2425
  if (t.length === 0 || v.length === 0) {
2074
2426
  throw new AffinityValidationError("type and value must be non-empty");
2075
2427
  }
2076
2428
  return `${t}:${v}`;
2077
2429
  }
2430
+ function legacyIdentityKey(key) {
2431
+ const colon = key.indexOf(":");
2432
+ return handleIdentityTypes.has(key.slice(0, colon)) ? `${key.slice(0, colon + 1)}@${key.slice(colon + 1)}` : key;
2433
+ }
2434
+ var handleIdentityTypes;
2078
2435
  var init_normalize = __esm({
2079
2436
  "src/identities/normalize.ts"() {
2080
2437
  init_affinity_validation_error();
2438
+ handleIdentityTypes = /* @__PURE__ */ new Set([
2439
+ "handle",
2440
+ "bluesky",
2441
+ "x",
2442
+ "twitter",
2443
+ "mastodon",
2444
+ "github",
2445
+ "instagram",
2446
+ "telegram",
2447
+ "linkedin",
2448
+ "facebook",
2449
+ "youtube",
2450
+ "tiktok",
2451
+ "discord",
2452
+ "slack"
2453
+ ]);
2081
2454
  }
2082
2455
  });
2083
2456
 
@@ -2210,7 +2583,7 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
2210
2583
  const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
2211
2584
  return {
2212
2585
  ok: false,
2213
- result: toolFailure("domain", "invalid_state", message, message)
2586
+ result: toolFailure("domain", "conflict", message, message)
2214
2587
  };
2215
2588
  }
2216
2589
  return { ok: true, value: row2 };
@@ -2235,9 +2608,13 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
2235
2608
  INNER JOIN contacts c ON c.id = i.contact_id
2236
2609
  WHERE i.removed_at IS NULL
2237
2610
  AND c.deleted_at IS NULL
2238
- AND i.normalized_key = ?
2611
+ AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))
2239
2612
  LIMIT 2`
2240
- ).all(normalizedKey);
2613
+ ).all(
2614
+ normalizedKey,
2615
+ legacyIdentityKey(normalizedKey),
2616
+ normalizedKey
2617
+ );
2241
2618
  if (row.length === 0) {
2242
2619
  return {
2243
2620
  ok: false,
@@ -2388,13 +2765,21 @@ function resolveLinkLocator(db, locator, field = "link") {
2388
2765
  return { ok: true, value: matchedLink };
2389
2766
  }
2390
2767
  function mapErrorToToolFailure(error, summary = "The tool failed.") {
2768
+ if (error && typeof error === "object" && "ok" in error && error.ok === false && "outcome" in error && error.outcome === "error")
2769
+ return error;
2391
2770
  if (isAffinityError(error)) {
2392
- return toolFailure(
2771
+ const result = toolFailure(
2393
2772
  "domain",
2394
2773
  affinityErrorCodeToToolCode(error),
2395
2774
  summary,
2396
2775
  error.message
2397
2776
  );
2777
+ const id = error.existingContactId;
2778
+ if (id !== void 0) {
2779
+ result.data = { existingContactId: id };
2780
+ result.error.details = { existingContactId: id };
2781
+ }
2782
+ return result;
2398
2783
  }
2399
2784
  if (error instanceof Error) {
2400
2785
  return toolFailure("system", "system_error", summary, error.message);
@@ -2443,27 +2828,32 @@ var init_tool_resolvers = __esm({
2443
2828
  });
2444
2829
 
2445
2830
  // src/tools/validate_tool_input.ts
2446
- function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
2831
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now(), strict = false) {
2832
+ if (path.endsWith(".occurredAt") && value == null)
2833
+ return "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)";
2834
+ const branches = schema.oneOf ?? schema.anyOf;
2835
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && value !== null && typeof value !== "string")
2836
+ return `attribute values are strings or null; pass "${String(value)}" to store it as text`;
2447
2837
  if (schema.anyOf || schema.oneOf) {
2448
2838
  const choices = schema.anyOf ?? schema.oneOf ?? [];
2449
2839
  return choices.some(
2450
- (choice) => validateToolInput(choice, value, path, injectedNow) === null
2840
+ (choice) => validateToolInput(choice, value, path, injectedNow, strict) === null
2451
2841
  ) ? null : `${path}: invalid value`;
2452
2842
  }
2453
2843
  if (schema.type === "object") {
2454
2844
  if (value === null || typeof value !== "object" || Array.isArray(value))
2455
2845
  return `${path}: missing input or expected object`;
2456
2846
  const object = value;
2457
- if (path.endsWith(".patch") && schema.additionalProperties === false) {
2847
+ if ((strict || path.endsWith(".patch")) && schema.additionalProperties === false) {
2458
2848
  for (const key of Object.keys(object)) {
2459
2849
  if (!Object.hasOwn(schema.properties ?? {}, key))
2460
- return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown patch key; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
2850
+ return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown ${path.endsWith(".patch") ? "patch key" : "key"}; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
2461
2851
  }
2462
2852
  }
2463
2853
  if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
2464
2854
  return `${path}.patch: missing required field`;
2465
2855
  if (object.patch !== void 0 && !schema.properties?.patch)
2466
- return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
2856
+ return `${path}.patch: unknown key; allowed keys: none (use the action's named fields)`;
2467
2857
  const referenceNow = object.now === void 0 ? injectedNow : (() => {
2468
2858
  try {
2469
2859
  return parseTimeInput(
@@ -2478,11 +2868,21 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2478
2868
  for (const [key, child] of Object.entries(schema.properties ?? {})) {
2479
2869
  if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
2480
2870
  try {
2481
- object[key] = parseTimeInput(
2871
+ const parsed = parseCalendarTime(
2482
2872
  object[key],
2483
2873
  `${path}.${key}`,
2484
2874
  referenceNow
2485
2875
  );
2876
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key) && typeof object[key] === "string" && /\.\d{4,}/.test(object[key])) {
2877
+ const provenance = object.provenance;
2878
+ object.provenance = {
2879
+ ...provenance && typeof provenance === "object" ? provenance : {},
2880
+ [`raw${key[0]?.toUpperCase()}${key.slice(1)}`]: object[key]
2881
+ };
2882
+ }
2883
+ object[key] = parsed.timestamp;
2884
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key))
2885
+ object[key.replace(/At$/, "On")] ??= parsed.day;
2486
2886
  } catch (error) {
2487
2887
  return error.message;
2488
2888
  }
@@ -2497,8 +2897,9 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2497
2897
  const error = validateToolInput(
2498
2898
  child,
2499
2899
  object[key],
2500
- `${path}.${key}`,
2501
- referenceNow
2900
+ key === "input" && path === "input" ? path : `${path}.${key}`,
2901
+ referenceNow,
2902
+ strict
2502
2903
  );
2503
2904
  if (error) return error;
2504
2905
  }
@@ -2511,7 +2912,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2511
2912
  schema.items ?? {},
2512
2913
  item,
2513
2914
  `${path}[${index}]`,
2514
- injectedNow
2915
+ injectedNow,
2916
+ strict
2515
2917
  );
2516
2918
  if (error) return error;
2517
2919
  }
@@ -2529,6 +2931,7 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2529
2931
  }
2530
2932
  var init_validate_tool_input = __esm({
2531
2933
  "src/tools/validate_tool_input.ts"() {
2934
+ init_parse_calendar_time();
2532
2935
  init_parse_time_input();
2533
2936
  }
2534
2937
  });
@@ -2582,6 +2985,26 @@ function inspectAffinityItemToolHandler(db, rawInput) {
2582
2985
  inputError
2583
2986
  );
2584
2987
  return withToolHandling(() => {
2988
+ if (input.kind === "event") {
2989
+ if (input.eventId === void 0 === (input.ref === void 0))
2990
+ return toolFailure(
2991
+ "protocol",
2992
+ "invalid_input",
2993
+ "Invalid event target.",
2994
+ "Provide exactly one of eventId or ref."
2995
+ );
2996
+ const detail2 = input.ref ? getEventByExternalRef(db, input.ref.source, input.ref.id) : getEventById(db, input.eventId);
2997
+ return detail2 ? toolSuccess(
2998
+ `Loaded event #${detail2.id}.`,
2999
+ { kind: "event", detail: detail2 },
3000
+ { entities: [{ kind: "event", id: detail2.id }] }
3001
+ ) : toolFailure(
3002
+ "domain",
3003
+ "not_found",
3004
+ "Event not found.",
3005
+ "No event matches the target."
3006
+ );
3007
+ }
2585
3008
  if (input.kind === "owner_profile") {
2586
3009
  const detail2 = getOwnerProfile(
2587
3010
  db,
@@ -2664,7 +3087,9 @@ var init_inspect_affinity_item_tool = __esm({
2664
3087
  "src/tools/inspect_affinity_item_tool.ts"() {
2665
3088
  init_get_contact_profile();
2666
3089
  init_get_owner_profile();
3090
+ init_event_metadata();
2667
3091
  init_get_link_detail();
3092
+ init_event_metadata_schema();
2668
3093
  init_tool_metadata();
2669
3094
  init_tool_names();
2670
3095
  init_tool_ref();
@@ -2692,10 +3117,13 @@ var init_inspect_affinity_item_tool = __esm({
2692
3117
  inputSchema: objectSchema(
2693
3118
  {
2694
3119
  kind: enumSchema("What to inspect.", [
3120
+ "event",
2695
3121
  "owner_profile",
2696
3122
  "contact_profile",
2697
3123
  "link"
2698
3124
  ]),
3125
+ eventId: integerSchema("Exact event id."),
3126
+ ref: externalRefSchema,
2699
3127
  contact: contactLocatorSchema(
2700
3128
  "Target contact. Required when kind=contact_profile. Provide contactId or identity."
2701
3129
  ),
@@ -2716,6 +3144,7 @@ var init_inspect_affinity_item_tool = __esm({
2716
3144
  // src/attributes/helpers.ts
2717
3145
  function clearedAttributePrimary(target) {
2718
3146
  return {
3147
+ ...target.kind === "event" ? { eventId: target.eventId } : {},
2719
3148
  id: 0,
2720
3149
  contactId: target.kind === "contact" ? target.id : null,
2721
3150
  linkId: target.kind === "link" ? target.id : null,
@@ -2731,6 +3160,7 @@ var init_helpers = __esm({
2731
3160
  // src/attributes/mappers.ts
2732
3161
  function mapAttributeRowToAttributeRecord(row) {
2733
3162
  return {
3163
+ ...row.event_id == null ? {} : { eventId: row.event_id },
2734
3164
  id: row.id,
2735
3165
  contactId: row.contact_id,
2736
3166
  linkId: row.link_id,
@@ -2744,131 +3174,46 @@ var init_mappers5 = __esm({
2744
3174
  });
2745
3175
 
2746
3176
  // src/attributes/queries.ts
3177
+ function attributeTargetSql(target) {
3178
+ return {
3179
+ column: target.kind === "event" ? "event_id" : target.kind === "contact" ? "contact_id" : "link_id",
3180
+ id: target.kind === "event" ? target.eventId : target.id
3181
+ };
3182
+ }
2747
3183
  function getAttributeRowById(db, id) {
2748
- const row = db.prepare(
2749
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2750
- FROM attributes WHERE id = ?`
2751
- ).get(id);
2752
- if (row === void 0) {
2753
- return null;
2754
- }
2755
- return row;
3184
+ return db.prepare("SELECT * FROM attributes WHERE id=?").get(id) ?? null;
2756
3185
  }
2757
3186
  function getLiveAttributeRow(db, target, name) {
2758
- if (target.kind === "contact") {
2759
- const row2 = db.prepare(
2760
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2761
- FROM attributes
2762
- WHERE contact_id = ? AND name = ? AND deleted_at IS NULL`
2763
- ).get(target.id, name);
2764
- if (row2 === void 0) {
2765
- return null;
2766
- }
2767
- return row2;
2768
- }
2769
- const row = db.prepare(
2770
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2771
- FROM attributes
2772
- WHERE link_id = ? AND name = ? AND deleted_at IS NULL`
2773
- ).get(target.id, name);
2774
- if (row === void 0) {
2775
- return null;
2776
- }
2777
- return row;
3187
+ const t = attributeTargetSql(target);
3188
+ return db.prepare(
3189
+ `SELECT * FROM attributes WHERE ${t.column}=? AND name=? AND deleted_at IS NULL`
3190
+ ).get(t.id, name) ?? null;
2778
3191
  }
2779
3192
  function listLiveAttributeIdsForTarget(db, target) {
2780
- if (target.kind === "contact") {
2781
- const rows2 = db.prepare(
2782
- "SELECT id FROM attributes WHERE contact_id = ? AND deleted_at IS NULL ORDER BY id"
2783
- ).all(target.id);
2784
- return rows2.map((row) => row.id);
2785
- }
2786
- const rows = db.prepare(
2787
- "SELECT id FROM attributes WHERE link_id = ? AND deleted_at IS NULL ORDER BY id"
2788
- ).all(target.id);
2789
- return rows.map((row) => row.id);
3193
+ const t = attributeTargetSql(target);
3194
+ return db.prepare(
3195
+ `SELECT id FROM attributes WHERE ${t.column}=? AND deleted_at IS NULL ORDER BY id`
3196
+ ).all(t.id).map((r) => r.id);
2790
3197
  }
2791
3198
  var init_queries4 = __esm({
2792
3199
  "src/attributes/queries.ts"() {
2793
3200
  }
2794
3201
  });
2795
3202
 
2796
- // src/lib/normalize_mutation_receipt.ts
2797
- function uniqueEntityRefs(refs) {
2798
- const out = [];
2799
- const seen = /* @__PURE__ */ new Set();
2800
- for (const ref of refs ?? []) {
2801
- const key = `${ref.kind}:${ref.id}`;
2802
- if (seen.has(key)) {
2803
- continue;
2804
- }
2805
- seen.add(key);
2806
- out.push(ref);
2807
- }
2808
- return out;
3203
+ // src/attributes/receipts.ts
3204
+ function buildAttributeMutationReceipt(primary, partial) {
3205
+ const normalized = normalizeMutationReceiptPartial(partial);
3206
+ return {
3207
+ primary,
3208
+ created: normalized.created,
3209
+ updated: normalized.updated,
3210
+ archived: normalized.archived,
3211
+ removed: normalized.removed,
3212
+ affectedLinks: normalized.affectedLinks,
3213
+ derivedEffects: normalized.derivedEffects
3214
+ };
2809
3215
  }
2810
- function uniqueDerivedEffects(effects) {
2811
- const out = [];
2812
- const seen = /* @__PURE__ */ new Set();
2813
- for (const effect of effects ?? []) {
2814
- const key = `${effect.eventId}:${effect.linkId}`;
2815
- if (seen.has(key)) {
2816
- continue;
2817
- }
2818
- seen.add(key);
2819
- out.push(effect);
2820
- }
2821
- return out;
2822
- }
2823
- function uniqueAffectedLinks(affectedLinks, derivedEffects) {
2824
- const out = [];
2825
- const seen = /* @__PURE__ */ new Set();
2826
- for (const linkId of affectedLinks ?? []) {
2827
- if (seen.has(linkId)) {
2828
- continue;
2829
- }
2830
- seen.add(linkId);
2831
- out.push(linkId);
2832
- }
2833
- for (const effect of derivedEffects) {
2834
- if (seen.has(effect.linkId)) {
2835
- continue;
2836
- }
2837
- seen.add(effect.linkId);
2838
- out.push(effect.linkId);
2839
- }
2840
- return out;
2841
- }
2842
- function normalizeMutationReceiptPartial(partial) {
2843
- const derivedEffects = uniqueDerivedEffects(partial?.derivedEffects);
2844
- return {
2845
- created: uniqueEntityRefs(partial?.created),
2846
- updated: uniqueEntityRefs(partial?.updated),
2847
- archived: uniqueEntityRefs(partial?.archived),
2848
- removed: uniqueEntityRefs(partial?.removed),
2849
- affectedLinks: uniqueAffectedLinks(partial?.affectedLinks, derivedEffects),
2850
- derivedEffects
2851
- };
2852
- }
2853
- var init_normalize_mutation_receipt = __esm({
2854
- "src/lib/normalize_mutation_receipt.ts"() {
2855
- }
2856
- });
2857
-
2858
- // src/attributes/receipts.ts
2859
- function buildAttributeMutationReceipt(primary, partial) {
2860
- const normalized = normalizeMutationReceiptPartial(partial);
2861
- return {
2862
- primary,
2863
- created: normalized.created,
2864
- updated: normalized.updated,
2865
- archived: normalized.archived,
2866
- removed: normalized.removed,
2867
- affectedLinks: normalized.affectedLinks,
2868
- derivedEffects: normalized.derivedEffects
2869
- };
2870
- }
2871
- var init_receipts = __esm({
3216
+ var init_receipts2 = __esm({
2872
3217
  "src/attributes/receipts.ts"() {
2873
3218
  init_normalize_mutation_receipt();
2874
3219
  }
@@ -2931,11 +3276,14 @@ function normalizeAttributeValue(value) {
2931
3276
  if (value === null) {
2932
3277
  return null;
2933
3278
  }
3279
+ if (typeof value !== "string")
3280
+ throw new AffinityValidationError("attribute values are strings or null");
2934
3281
  const trimmed = value.trim();
2935
3282
  return trimmed.length === 0 ? null : trimmed;
2936
3283
  }
2937
3284
  var init_normalize2 = __esm({
2938
3285
  "src/attributes/normalize.ts"() {
3286
+ init_affinity_validation_error();
2939
3287
  }
2940
3288
  });
2941
3289
 
@@ -2963,6 +3311,11 @@ function validateAttributeEntries(entries) {
2963
3311
  return out;
2964
3312
  }
2965
3313
  function assertAttributeTargetWritable(db, target) {
3314
+ if (target.kind === "event") {
3315
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(target.eventId))
3316
+ throw new AffinityNotFoundError("event not found");
3317
+ return;
3318
+ }
2966
3319
  if (target.kind === "contact") {
2967
3320
  const contact = getContactRowById(db, target.id);
2968
3321
  if (!contact) {
@@ -3009,26 +3362,14 @@ function replaceAttributes(db, target, entries) {
3009
3362
  }
3010
3363
  const created = [];
3011
3364
  let lastId = 0;
3012
- if (target.kind === "contact") {
3013
- const insert = db.prepare(
3014
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
3015
- VALUES (?, NULL, ?, ?, ?, ?)`
3016
- );
3017
- for (const e of normalized) {
3018
- const r = insert.run(target.id, e.name, e.value, now, now);
3019
- lastId = Number(r.lastInsertRowid);
3020
- created.push({ kind: "attribute", id: lastId });
3021
- }
3022
- } else {
3023
- const insert = db.prepare(
3024
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
3025
- VALUES (NULL, ?, ?, ?, ?, ?)`
3026
- );
3027
- for (const e of normalized) {
3028
- const r = insert.run(target.id, e.name, e.value, now, now);
3029
- lastId = Number(r.lastInsertRowid);
3030
- created.push({ kind: "attribute", id: lastId });
3031
- }
3365
+ const t = attributeTargetSql(target);
3366
+ const insert = db.prepare(
3367
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
3368
+ );
3369
+ for (const e of normalized) {
3370
+ const r = insert.run(t.id, e.name, e.value, now, now);
3371
+ lastId = Number(r.lastInsertRowid);
3372
+ created.push({ kind: "attribute", id: lastId });
3032
3373
  }
3033
3374
  if (normalized.length === 0) {
3034
3375
  return buildAttributeMutationReceipt(clearedAttributePrimary(target), {
@@ -3054,11 +3395,12 @@ var init_replace_attributes = __esm({
3054
3395
  init_helpers();
3055
3396
  init_mappers5();
3056
3397
  init_queries4();
3057
- init_receipts();
3398
+ init_receipts2();
3058
3399
  init_validators3();
3059
3400
  init_affinity_invariant_error();
3060
3401
  init_resolve_now();
3061
3402
  init_with_transaction();
3403
+ init_queries4();
3062
3404
  }
3063
3405
  });
3064
3406
 
@@ -3070,6 +3412,11 @@ function setAttribute(db, target, name, value) {
3070
3412
  const stored = normalizeAttributeValue(value);
3071
3413
  const now = resolveNow();
3072
3414
  const existing = getLiveAttributeRow(db, target, key);
3415
+ if (existing?.value === stored)
3416
+ return buildAttributeMutationReceipt(
3417
+ mapAttributeRowToAttributeRecord(existing),
3418
+ {}
3419
+ );
3073
3420
  if (existing) {
3074
3421
  db.prepare(
3075
3422
  "UPDATE attributes SET value = ?, updated_at = ? WHERE id = ?"
@@ -3085,27 +3432,10 @@ function setAttribute(db, target, name, value) {
3085
3432
  }
3086
3433
  );
3087
3434
  }
3088
- if (target.kind === "contact") {
3089
- const r2 = db.prepare(
3090
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
3091
- VALUES (?, NULL, ?, ?, ?, ?)`
3092
- ).run(target.id, key, stored, now, now);
3093
- const id2 = Number(r2.lastInsertRowid);
3094
- const row2 = getAttributeRowById(db, id2);
3095
- if (!row2) {
3096
- throw new AffinityInvariantError("attribute row missing after insert");
3097
- }
3098
- return buildAttributeMutationReceipt(
3099
- mapAttributeRowToAttributeRecord(row2),
3100
- {
3101
- created: [{ kind: "attribute", id: id2 }]
3102
- }
3103
- );
3104
- }
3435
+ const t = attributeTargetSql(target);
3105
3436
  const r = db.prepare(
3106
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
3107
- VALUES (NULL, ?, ?, ?, ?, ?)`
3108
- ).run(target.id, key, stored, now, now);
3437
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
3438
+ ).run(t.id, key, stored, now, now);
3109
3439
  const id = Number(r.lastInsertRowid);
3110
3440
  const row = getAttributeRowById(db, id);
3111
3441
  if (!row) {
@@ -3124,11 +3454,12 @@ var init_set_attribute = __esm({
3124
3454
  init_mappers5();
3125
3455
  init_normalize2();
3126
3456
  init_queries4();
3127
- init_receipts();
3457
+ init_receipts2();
3128
3458
  init_validators3();
3129
3459
  init_affinity_invariant_error();
3130
3460
  init_resolve_now();
3131
3461
  init_with_transaction();
3462
+ init_queries4();
3132
3463
  }
3133
3464
  });
3134
3465
 
@@ -3155,7 +3486,7 @@ var init_unset_attribute = __esm({
3155
3486
  "src/attributes/unset_attribute.ts"() {
3156
3487
  init_mappers5();
3157
3488
  init_queries4();
3158
- init_receipts();
3489
+ init_receipts2();
3159
3490
  init_validators3();
3160
3491
  init_affinity_not_found_error();
3161
3492
  init_resolve_now();
@@ -3184,14 +3515,15 @@ function mutationToolResult(action, receipt, entitySummary, fields) {
3184
3515
  affectedLinks: receipt.affectedLinks,
3185
3516
  derivedEffects: receipt.derivedEffects
3186
3517
  };
3187
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
3518
+ const summary = changedCount === 0 ? receipt.primary && typeof receipt.primary === "object" && "externalRef" in receipt.primary && "id" in receipt.primary ? `Event already recorded (#${receipt.primary.id}, ${receipt.primary.externalRef.source} ${receipt.primary.externalRef.id}).` : `No ${entitySummary} changes were needed.` : describeChanges(receipt);
3188
3519
  if (changedCount === 0) {
3189
3520
  return toolNoOp(summary, data, {
3190
3521
  entities: toReceiptEntityRefs(receipt)
3191
3522
  });
3192
3523
  }
3193
3524
  return toolSuccess(summary, data, {
3194
- entities: toReceiptEntityRefs(receipt)
3525
+ entities: toReceiptEntityRefs(receipt),
3526
+ warnings: receipt.warnings
3195
3527
  });
3196
3528
  }
3197
3529
  function describeChanges(receipt) {
@@ -3255,6 +3587,30 @@ function linkLocatorSchema2(description) {
3255
3587
  };
3256
3588
  }
3257
3589
  function resolveAttributeTarget(db, target) {
3590
+ if (target.kind === "event") {
3591
+ if (target.eventId === void 0 === (target.ref === void 0))
3592
+ return {
3593
+ kind: "error",
3594
+ result: toolFailure(
3595
+ "protocol",
3596
+ "invalid_input",
3597
+ "Invalid event target.",
3598
+ "Provide exactly one of target.eventId or target.ref."
3599
+ )
3600
+ };
3601
+ const eventId = target.eventId ?? (target.ref && getEventByExternalRef(db, target.ref.source, target.ref.id)?.id);
3602
+ if (eventId === void 0)
3603
+ return {
3604
+ kind: "error",
3605
+ result: toolFailure(
3606
+ "domain",
3607
+ "not_found",
3608
+ "Event not found.",
3609
+ "No event matches target.ref."
3610
+ )
3611
+ };
3612
+ return { kind: "ok", target: { kind: "event", eventId } };
3613
+ }
3258
3614
  if (target.kind === "contact") {
3259
3615
  const contact = resolveContactLocator(db, target.contact, "target.contact");
3260
3616
  if (!contact.ok) {
@@ -3318,6 +3674,8 @@ var init_manage_attribute_tool = __esm({
3318
3674
  init_replace_attributes();
3319
3675
  init_set_attribute();
3320
3676
  init_unset_attribute();
3677
+ init_event_metadata();
3678
+ init_event_metadata_schema();
3321
3679
  init_tool_metadata();
3322
3680
  init_tool_mutation();
3323
3681
  init_tool_names();
@@ -3326,7 +3684,7 @@ var init_manage_attribute_tool = __esm({
3326
3684
  init_validate_tool_input();
3327
3685
  manageAttributeTool = defineAffinityTool({
3328
3686
  name: manageAttributeToolName,
3329
- description: "Set, unset, or replace custom metadata attributes on contacts and links. For emails, phones, handles, and URLs use manage_identity instead \u2014 those are routing keys, not attributes.",
3687
+ description: "Set, unset, or replace custom metadata attributes on contacts, links and events. For emails, phones, handles, and URLs use manage_identity instead \u2014 those are routing keys, not attributes.",
3330
3688
  whenToUse: "Use this for metadata such as tags, notes, or small labeled facts attached to a contact or link.",
3331
3689
  whenNotToUse: "Do not use this for core contact fields, identities, or direct relationship mechanics.",
3332
3690
  sideEffects: "writes_state",
@@ -3347,7 +3705,9 @@ var init_manage_attribute_tool = __esm({
3347
3705
  action: enumSchema("Operation to perform.", ["set", "unset", "replace"]),
3348
3706
  target: objectSchema(
3349
3707
  {
3350
- kind: enumSchema("Target kind.", ["contact", "link"]),
3708
+ kind: enumSchema("Target kind.", ["contact", "link", "event"]),
3709
+ eventId: integerSchema("Target event id; provide eventId or ref."),
3710
+ ref: externalRefSchema,
3351
3711
  contact: contactLocatorSchema2(
3352
3712
  "Contact locator. Required when kind=contact. Provide contactId or identity."
3353
3713
  ),
@@ -3377,7 +3737,7 @@ var init_manage_attribute_tool = __esm({
3377
3737
  removedAt: integerSchema("Removal timestamp. Optional for action=unset.")
3378
3738
  },
3379
3739
  ["action"],
3380
- "Set, unset, or replace attributes on contacts and links."
3740
+ "Set, unset, or replace attributes on contacts, links and events."
3381
3741
  ),
3382
3742
  handler: manageAttributeToolHandler
3383
3743
  });
@@ -3387,7 +3747,7 @@ var init_manage_attribute_tool = __esm({
3387
3747
  // src/events/persistence.ts
3388
3748
  function insertJournalEvent(db, params) {
3389
3749
  const now = resolveNow(params.now);
3390
- const summary = params.summary.trim();
3750
+ const summary = params.summary?.trim() ?? deriveEventSummary(params.content ?? "");
3391
3751
  const result = db.prepare(
3392
3752
  `INSERT INTO events (
3393
3753
  type, occurred_at, summary, significance, moment_kind,
@@ -3404,6 +3764,35 @@ function insertJournalEvent(db, params) {
3404
3764
  now
3405
3765
  );
3406
3766
  const eventId = Number(result.lastInsertRowid);
3767
+ db.prepare("UPDATE events SET affects_bond=? WHERE id=?").run(
3768
+ params.affectsBond === void 0 ? params.type === "observation" && params.significance <= 2 || ["activity", "milestone", "observation"].includes(params.type) && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
3769
+ summary
3770
+ ) && !/^follow[ -]up\b/i.test(summary) ? 0 : 1 : Number(params.affectsBond),
3771
+ eventId
3772
+ );
3773
+ db.prepare("UPDATE events SET declared_moment_kind=? WHERE id=?").run(
3774
+ params.momentKind,
3775
+ eventId
3776
+ );
3777
+ db.prepare(
3778
+ "UPDATE events SET content=?, external_source=?, external_id=?, channel=?, parent_source=?, parent_ref_id=?, parent_event_id=?, thread_source=?, thread_id=?, provenance_json=? WHERE id=?"
3779
+ ).run(
3780
+ params.content ?? null,
3781
+ params.externalRef?.source ?? null,
3782
+ params.externalRef?.id ?? null,
3783
+ params.channel ?? null,
3784
+ params.parentRef?.source ?? null,
3785
+ params.parentRef?.id ?? null,
3786
+ params.parentEventId ?? null,
3787
+ params.threadRef?.source ?? null,
3788
+ params.threadRef?.id ?? null,
3789
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
3790
+ eventId
3791
+ );
3792
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3793
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3794
+ eventId
3795
+ );
3407
3796
  const insertParticipant = db.prepare(
3408
3797
  `INSERT INTO event_participants (
3409
3798
  event_id, contact_id, role, directionality
@@ -3446,6 +3835,10 @@ function insertDateAnchorEvent(db, params) {
3446
3835
  now
3447
3836
  );
3448
3837
  const eventId = Number(result.lastInsertRowid);
3838
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3839
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3840
+ eventId
3841
+ );
3449
3842
  const insertParticipant = db.prepare(
3450
3843
  `INSERT INTO event_participants (
3451
3844
  event_id, contact_id, role, directionality
@@ -3469,29 +3862,11 @@ var init_persistence = __esm({
3469
3862
  "src/events/persistence.ts"() {
3470
3863
  init_affinity_invariant_error();
3471
3864
  init_resolve_now();
3865
+ init_event_metadata();
3472
3866
  init_queries2();
3473
3867
  }
3474
3868
  });
3475
3869
 
3476
- // src/events/receipts.ts
3477
- function buildEventMutationReceipt(primary, partial) {
3478
- const normalized = normalizeMutationReceiptPartial(partial);
3479
- return {
3480
- primary,
3481
- created: normalized.created,
3482
- updated: normalized.updated,
3483
- archived: normalized.archived,
3484
- removed: normalized.removed,
3485
- affectedLinks: normalized.affectedLinks,
3486
- derivedEffects: normalized.derivedEffects
3487
- };
3488
- }
3489
- var init_receipts2 = __esm({
3490
- "src/events/receipts.ts"() {
3491
- init_normalize_mutation_receipt();
3492
- }
3493
- });
3494
-
3495
3870
  // src/graph/betweenness.ts
3496
3871
  function computeNodeBetweenness(adjacency) {
3497
3872
  const nodes = [...adjacency.keys()];
@@ -4104,6 +4479,8 @@ function deriveStateAfter(params) {
4104
4479
  return params.stateBefore;
4105
4480
  }
4106
4481
  function deriveMomentKind(params) {
4482
+ if (params.eventType === "observation" || params.eventType === "milestone" && params.significance < 7)
4483
+ return null;
4107
4484
  if (params.rankAfter > params.rankBefore) {
4108
4485
  return "breakthrough";
4109
4486
  }
@@ -4133,6 +4510,18 @@ function applyEventEffectToLink(db, params) {
4133
4510
  if (!link || link.is_structural !== 0) {
4134
4511
  throw new AffinityStateError("relational link not found");
4135
4512
  }
4513
+ db.prepare(
4514
+ "INSERT OR IGNORE INTO event_link_baselines(link_id,state_json) VALUES (?,?)"
4515
+ ).run(
4516
+ params.linkId,
4517
+ JSON.stringify({
4518
+ rank: link.rank ?? 0,
4519
+ affinity: link.affinity ?? 0,
4520
+ trust: link.trust ?? 0,
4521
+ state: link.state ?? "active",
4522
+ cadence_days: link.cadence_days
4523
+ })
4524
+ );
4136
4525
  const stateBefore = link.state ?? "active";
4137
4526
  const linkKind = link.kind;
4138
4527
  const rankBefore = link.rank ?? 0;
@@ -4177,7 +4566,7 @@ function applyEventEffectToLink(db, params) {
4177
4566
  );
4178
4567
  const weightedBase = base * massPenalty(
4179
4568
  weeklyPositiveBaseWeight(db, params.linkId, params.occurredAt)
4180
- ) * salienceMultiplier;
4569
+ ) * salienceMultiplier * (params.participant.directionality === "owner_initiated" ? 0.05 : 1);
4181
4570
  const affinityGain = weightedBase * Math.max(eventValence, 0) * (0.55 + 0.45 * eventIntimacyDepth) * (0.7 + 0.3 * participantReciprocity) * (1 / (1 + 0.22 * rankBefore));
4182
4571
  const affinityLoss = weightedBase * Math.max(-eventValence, 0) * 0.35;
4183
4572
  const affinityMass = clamp3(
@@ -4218,15 +4607,16 @@ function applyEventEffectToLink(db, params) {
4218
4607
  const latest = db.prepare(
4219
4608
  `SELECT occurred_at
4220
4609
  FROM link_event_effects
4221
- WHERE link_id = ?
4610
+ WHERE link_id = ? AND counts_for_recency = 1 AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))
4222
4611
  ORDER BY occurred_at DESC, event_id DESC
4223
4612
  LIMIT 1`
4224
- ).get(params.linkId);
4613
+ ).get(params.linkId, params.now, localCalendarDate(params.now));
4225
4614
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
4226
4615
  1,
4227
4616
  Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
4228
4617
  );
4229
- const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
4618
+ const eventDay = db.prepare("SELECT occurred_on FROM events WHERE id = ?").get(params.eventId)?.occurred_on;
4619
+ const cadenceAfter = commitmentResolution != null || params.eventType === "promise" || params.eventType === "agreement" || params.occurredAt > params.now && (eventDay == null || eventDay > localCalendarDate(params.now)) ? cadenceBefore : nextCadenceDays({
4230
4620
  kind: linkKind,
4231
4621
  currentCadenceDays: cadenceBefore,
4232
4622
  gapDays
@@ -4246,7 +4636,11 @@ function applyEventEffectToLink(db, params) {
4246
4636
  }
4247
4637
  const affinityDelta = affinityAfter - affinityBefore;
4248
4638
  const trustDelta = trustAfter - trustBefore;
4249
- const momentKind = deriveMomentKind({
4639
+ const eventLabel = db.prepare("SELECT summary FROM events WHERE id=?").get(params.eventId)?.summary;
4640
+ const followLike = params.eventType === "milestone" && typeof eventLabel === "string" && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
4641
+ eventLabel
4642
+ );
4643
+ const momentKind = followLike ? null : deriveMomentKind({
4250
4644
  rankBefore,
4251
4645
  rankAfter,
4252
4646
  stateBefore,
@@ -4289,7 +4683,15 @@ function applyEventEffectToLink(db, params) {
4289
4683
  params.now,
4290
4684
  params.now
4291
4685
  );
4292
- if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
4686
+ db.prepare(
4687
+ "UPDATE link_event_effects SET participant_json=?,provenance_json=? WHERE event_id=? AND link_id=?"
4688
+ ).run(
4689
+ JSON.stringify(params.participant),
4690
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
4691
+ params.eventId,
4692
+ params.linkId
4693
+ );
4694
+ if (commitmentResolution !== void 0 && commitmentResolution !== null || params.eventType === "promise" || params.eventType === "agreement" || params.participant.directionality === "owner_initiated") {
4293
4695
  db.prepare(
4294
4696
  "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
4295
4697
  ).run(params.eventId, params.linkId);
@@ -4323,6 +4725,7 @@ var init_effects = __esm({
4323
4725
  init_calendar();
4324
4726
  init_mechanics2();
4325
4727
  init_affinity_state_error();
4728
+ init_parse_calendar_time();
4326
4729
  init_mechanics();
4327
4730
  init_queries3();
4328
4731
  init_rollups2();
@@ -4337,6 +4740,7 @@ var init_affinity_conflict_error = __esm({
4337
4740
  "src/lib/errors/affinity_conflict_error.ts"() {
4338
4741
  init_affinity_error();
4339
4742
  AffinityConflictError = class extends AffinityError {
4743
+ existingContactId;
4340
4744
  code = "CONFLICT";
4341
4745
  };
4342
4746
  }
@@ -4403,6 +4807,8 @@ var init_receipts3 = __esm({
4403
4807
  // src/links/seed_social_link.ts
4404
4808
  function seedSocialLink(db, input) {
4405
4809
  return withTransaction(db, () => {
4810
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
4811
+ throw new AffinityValidationError("ifExists must be error or return");
4406
4812
  if (!isRelationalLinkKind(input.kind)) {
4407
4813
  throw new AffinityValidationError("invalid relational link kind");
4408
4814
  }
@@ -4410,11 +4816,14 @@ function seedSocialLink(db, input) {
4410
4816
  throw new AffinityValidationError("from and to contacts must differ");
4411
4817
  }
4412
4818
  assertContactEndpointsNotMerged(db, input.fromContactId, input.toContactId);
4413
- if (findLiveRelationalLinkAnyDirection(
4819
+ const existing = findLiveRelationalLinkAnyDirection(
4414
4820
  db,
4415
4821
  input.fromContactId,
4416
4822
  input.toContactId
4417
- )) {
4823
+ );
4824
+ if (existing) {
4825
+ if (input.ifExists === "return")
4826
+ return buildLinkMutationReceipt(mapLinkRowToLinkListItem(existing), {});
4418
4827
  throw new AffinityConflictError(
4419
4828
  "relational link already exists between these contacts"
4420
4829
  );
@@ -4534,15 +4943,36 @@ function ensureRelationalLink(db, params) {
4534
4943
  };
4535
4944
  }
4536
4945
  function applyJournalEventMechanics(db, params) {
4946
+ const bond = db.prepare("SELECT affects_bond, channel FROM events WHERE id=?").get(params.eventId);
4947
+ const ambient = params.provenance && typeof params.provenance === "object" && (params.provenance.ambient === true || params.provenance.tags?.includes("ambient"));
4948
+ if (bond?.affects_bond === 0 || bond?.affects_bond == null && params.eventType === "observation" && params.significance <= 2 || ambient)
4949
+ return { created: [], affectedLinks: [], derivedEffects: [] };
4537
4950
  const ownerId = requireOwnerContactId(db);
4538
- const participantIds = params.participants.map(
4951
+ const owner = params.participants.find((p) => p.contactId === ownerId);
4952
+ const socialChannel = [
4953
+ "bluesky",
4954
+ "x",
4955
+ "twitter",
4956
+ "mastodon",
4957
+ "github",
4958
+ "instagram"
4959
+ ].includes(String(bond?.channel));
4960
+ const direction = owner?.directionality ?? (socialChannel ? owner?.role === "actor" ? "owner_initiated" : owner?.role === "recipient" ? "other_initiated" : void 0 : void 0);
4961
+ const participants = params.participants.map(
4962
+ (p) => direction && p.directionality === void 0 ? { ...p, directionality: direction } : p
4963
+ );
4964
+ if (direction)
4965
+ db.prepare(
4966
+ "UPDATE event_participants SET directionality=? WHERE event_id=? AND directionality IS NULL"
4967
+ ).run(direction, params.eventId);
4968
+ const participantIds = participants.map(
4539
4969
  (participant) => participant.contactId
4540
4970
  );
4541
4971
  const hasOwner = participantIds.includes(ownerId);
4542
4972
  const created = [];
4543
4973
  const effectInputs = [];
4544
4974
  if (hasOwner) {
4545
- for (const participant of params.participants) {
4975
+ for (const participant of participants) {
4546
4976
  if (participant.contactId === ownerId) {
4547
4977
  continue;
4548
4978
  }
@@ -4550,13 +4980,14 @@ function applyJournalEventMechanics(db, params) {
4550
4980
  fromContactId: ownerId,
4551
4981
  toContactId: participant.contactId,
4552
4982
  kind: inferAutoLinkKind(db, ownerId, participant.contactId),
4983
+ ...(participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality) === "owner_initiated" ? { affinity: 0.15 } : {},
4553
4984
  now: params.now
4554
4985
  });
4555
4986
  created.push(...ensured.created);
4556
4987
  effectInputs.push({ linkId: ensured.linkId, participant });
4557
4988
  }
4558
- } else if (params.eventType === "observation" && params.participants.length === 2) {
4559
- const [first, second] = params.participants;
4989
+ } else if (params.eventType === "observation" && participants.length === 2) {
4990
+ const [first, second] = participants;
4560
4991
  if (first !== void 0 && second !== void 0) {
4561
4992
  const a = Math.min(first.contactId, second.contactId);
4562
4993
  const b = Math.max(first.contactId, second.contactId);
@@ -4591,7 +5022,10 @@ function applyJournalEventMechanics(db, params) {
4591
5022
  eventType: params.eventType,
4592
5023
  occurredAt: params.occurredAt,
4593
5024
  significance: params.significance,
4594
- participant: input.participant,
5025
+ participant: {
5026
+ ...input.participant,
5027
+ directionality: input.participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality
5028
+ },
4595
5029
  provenance: params.provenance,
4596
5030
  now: params.now
4597
5031
  })
@@ -4624,6 +5058,8 @@ var init_apply_journal_event_mechanics = __esm({
4624
5058
  function recordCommitment(db, input) {
4625
5059
  return withTransaction(db, () => {
4626
5060
  validateSocialEventInput(input);
5061
+ const existing = existingEventReceipt(db, input.externalRef);
5062
+ if (existing) return existing;
4627
5063
  if (input.commitmentType !== "promise" && input.commitmentType !== "agreement") {
4628
5064
  throw new AffinityValidationError(
4629
5065
  "commitmentType must be promise or agreement"
@@ -4638,11 +5074,12 @@ function recordCommitment(db, input) {
4638
5074
  assertOwnerParticipates(ownerId, ids);
4639
5075
  const now = resolveNow(input.now);
4640
5076
  const eventId = insertJournalEvent(db, {
5077
+ ...input,
4641
5078
  type: input.commitmentType,
4642
5079
  occurredAt: input.occurredAt,
4643
- summary: input.summary,
5080
+ occurredOn: input.occurredOn,
4644
5081
  significance: input.significance,
4645
- momentKind: null,
5082
+ momentKind: input.momentKind ?? null,
4646
5083
  participants: input.participants,
4647
5084
  now
4648
5085
  });
@@ -4651,15 +5088,25 @@ function recordCommitment(db, input) {
4651
5088
  event_id, commitment_type, due_at, created_at, updated_at
4652
5089
  ) VALUES (?, ?, ?, ?, ?)`
4653
5090
  ).run(eventId, input.commitmentType, input.dueAt ?? null, now, now);
5091
+ db.prepare("UPDATE open_commitments SET due_on = ? WHERE event_id = ?").run(
5092
+ input.dueAt == null ? null : input.dueOn ?? new Date(input.dueAt).toISOString().slice(0, 10),
5093
+ eventId
5094
+ );
4654
5095
  const mechanics = applyJournalEventMechanics(db, {
4655
5096
  eventId,
4656
5097
  eventType: input.commitmentType,
4657
5098
  occurredAt: input.occurredAt,
5099
+ occurredOn: input.occurredOn,
4658
5100
  significance: input.significance,
4659
5101
  participants: input.participants,
4660
5102
  provenance: input.provenance,
4661
5103
  now
4662
5104
  });
5105
+ if (input.momentKind !== void 0)
5106
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5107
+ input.momentKind,
5108
+ eventId
5109
+ );
4663
5110
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
4664
5111
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4665
5112
  affectedLinks: mechanics.affectedLinks,
@@ -4672,12 +5119,13 @@ var init_record_commitment = __esm({
4672
5119
  init_queries();
4673
5120
  init_loaders();
4674
5121
  init_persistence();
4675
- init_receipts2();
5122
+ init_receipts();
4676
5123
  init_validators();
4677
5124
  init_affinity_validation_error();
4678
5125
  init_resolve_now();
4679
5126
  init_with_transaction();
4680
5127
  init_apply_journal_event_mechanics();
5128
+ init_event_metadata();
4681
5129
  }
4682
5130
  });
4683
5131
 
@@ -4746,6 +5194,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4746
5194
  const occurredAt = options?.resolvingEvent?.occurredAt ?? now;
4747
5195
  validateSocialEventInput({
4748
5196
  occurredAt,
5197
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4749
5198
  summary,
4750
5199
  significance,
4751
5200
  participants,
@@ -4762,6 +5211,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4762
5211
  const resolvingEventId = insertJournalEvent(db, {
4763
5212
  type: eventType,
4764
5213
  occurredAt,
5214
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4765
5215
  summary,
4766
5216
  significance,
4767
5217
  momentKind: null,
@@ -4800,7 +5250,7 @@ var init_resolve_commitment = __esm({
4800
5250
  init_loaders();
4801
5251
  init_persistence();
4802
5252
  init_queries2();
4803
- init_receipts2();
5253
+ init_receipts();
4804
5254
  init_validators();
4805
5255
  init_affinity_conflict_error();
4806
5256
  init_affinity_invariant_error();
@@ -4839,7 +5289,13 @@ function prepareParticipants(db, raw) {
4839
5289
  }
4840
5290
  function manageCommitmentToolHandler(db, rawInput) {
4841
5291
  const input = structuredClone(rawInput);
4842
- const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
5292
+ const inputError = validateToolInput(
5293
+ manageCommitmentTool.inputSchema,
5294
+ input,
5295
+ "input",
5296
+ Date.now(),
5297
+ true
5298
+ );
4843
5299
  if (inputError)
4844
5300
  return toolFailure(
4845
5301
  "protocol",
@@ -4869,6 +5325,21 @@ function manageCommitmentToolHandler(db, rawInput) {
4869
5325
  "commitment"
4870
5326
  );
4871
5327
  }
5328
+ if (!Number.isInteger(input.commitmentEventId))
5329
+ return toolFailure(
5330
+ "protocol",
5331
+ "invalid_input",
5332
+ "Missing commitment.",
5333
+ "commitmentEventId is required for resolve."
5334
+ );
5335
+ const event = db.prepare("SELECT type FROM events WHERE id = ? AND deleted_at IS NULL").get(input.commitmentEventId);
5336
+ if (event && !["promise", "agreement"].includes(event.type))
5337
+ return toolFailure(
5338
+ "domain",
5339
+ "invalid_input",
5340
+ "Invalid commitment.",
5341
+ "not a commitment event"
5342
+ );
4872
5343
  return mutationToolResult(
4873
5344
  "resolve",
4874
5345
  resolveCommitment(
@@ -4887,6 +5358,7 @@ var init_manage_commitment_tool = __esm({
4887
5358
  init_queries();
4888
5359
  init_record_commitment();
4889
5360
  init_resolve_commitment();
5361
+ init_event_metadata_schema();
4890
5362
  init_tool_metadata();
4891
5363
  init_tool_mutation();
4892
5364
  init_tool_names();
@@ -4919,15 +5391,21 @@ var init_manage_commitment_tool = __esm({
4919
5391
  "promise",
4920
5392
  "agreement"
4921
5393
  ]),
5394
+ ...eventMetadataSchema,
4922
5395
  occurredAt: integerSchema(
4923
5396
  "When the commitment was made, Unix timestamp in milliseconds."
4924
5397
  ),
4925
5398
  summary: stringSchema("Commitment summary. Must be non-empty."),
4926
5399
  significance: integerSchema("Commitment significance from 1 to 10."),
4927
5400
  dueAt: integerSchema("Optional due timestamp."),
5401
+ dueOn: stringSchema("Due calendar day."),
4928
5402
  participants: arraySchema(
4929
5403
  objectSchema(
4930
5404
  {
5405
+ contact: objectSchema(
5406
+ { contactId: integerSchema("Contact id.") },
5407
+ ["contactId"]
5408
+ ),
4931
5409
  contactId: integerSchema(
4932
5410
  "Participant contact id. The owner contact MUST be included."
4933
5411
  ),
@@ -4940,13 +5418,7 @@ var init_manage_commitment_tool = __esm({
4940
5418
  ),
4941
5419
  now: integerSchema("Optional timestamp override.")
4942
5420
  },
4943
- [
4944
- "commitmentType",
4945
- "occurredAt",
4946
- "summary",
4947
- "significance",
4948
- "participants"
4949
- ],
5421
+ ["commitmentType", "occurredAt", "significance", "participants"],
4950
5422
  "Commitment payload. Required when action=record."
4951
5423
  ),
4952
5424
  commitmentEventId: integerSchema(
@@ -4961,6 +5433,19 @@ var init_manage_commitment_tool = __esm({
4961
5433
  now: integerSchema("Optional timestamp."),
4962
5434
  resolvingEvent: objectSchema(
4963
5435
  {
5436
+ occurredOn: stringSchema("Calendar day."),
5437
+ provenance: {},
5438
+ participants: arraySchema(
5439
+ objectSchema(
5440
+ {
5441
+ contactId: integerSchema("Contact id."),
5442
+ role: stringSchema("Role."),
5443
+ directionality: stringSchema("Directionality.")
5444
+ },
5445
+ ["contactId", "role"]
5446
+ ),
5447
+ "Resolving participants."
5448
+ ),
4964
5449
  type: stringSchema("Optional resolving event type."),
4965
5450
  occurredAt: integerSchema("Optional resolving event timestamp."),
4966
5451
  summary: stringSchema("Optional resolving event summary."),
@@ -5054,30 +5539,50 @@ var init_delete_contact = __esm({
5054
5539
  }
5055
5540
  });
5056
5541
 
5057
- // src/contacts/export_contact.ts
5058
- function exportContact(db, id) {
5059
- const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
5542
+ // src/contacts/export_rows.ts
5543
+ function exportRows(db, sql, ...params) {
5544
+ return db.prepare(sql).all(...params).map((row) => {
5060
5545
  const result = {};
5061
5546
  for (const [key, value] of Object.entries(row)) {
5062
5547
  const name = key.replace(
5063
5548
  /_([a-z])/g,
5064
5549
  (_, letter) => letter.toUpperCase()
5065
5550
  );
5066
- result[name] = value;
5067
- if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
5551
+ result[name] = [
5552
+ "affects_bond",
5553
+ "is_owner",
5554
+ "is_structural",
5555
+ "verified",
5556
+ "counts_for_recency",
5557
+ "manual"
5558
+ ].includes(key) ? Boolean(value) : value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : value;
5559
+ if (name.endsWith("At") && typeof value === "number")
5068
5560
  result[`${name}Iso`] = new Date(value).toISOString();
5069
5561
  if (key.endsWith("_json") && typeof value === "string")
5070
5562
  result[name] = JSON.parse(value);
5071
5563
  }
5072
5564
  return result;
5073
5565
  });
5566
+ }
5567
+ var init_export_rows = __esm({
5568
+ "src/contacts/export_rows.ts"() {
5569
+ }
5570
+ });
5571
+
5572
+ // src/contacts/export_contact.ts
5573
+ function exportContact(db, id) {
5574
+ const query = (sql, ...params) => exportRows(db, sql, ...params);
5074
5575
  const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
5075
5576
  if (!contact) throw new AffinityNotFoundError("contact not found");
5076
5577
  const events = query(
5077
- "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
5578
+ "SELECT DISTINCT e.*, COALESCE(e.parent_event_id, (SELECT parent.id FROM events parent WHERE parent.external_source=e.parent_source AND parent.external_id=e.parent_ref_id AND parent.deleted_at IS NULL)) AS parent_event_id, CAST(e.content AS BLOB) AS content, CAST(e.summary AS BLOB) AS summary FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? AND e.type != 'date_anchor' ORDER BY e.id",
5078
5579
  id
5079
5580
  ).map((event) => ({
5080
5581
  ...event,
5582
+ attributes: query(
5583
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5584
+ event.id
5585
+ ),
5081
5586
  participants: query(
5082
5587
  "SELECT p.*, c.name AS contact_name FROM event_participants p JOIN contacts c ON c.id = p.contact_id WHERE event_id = ? ORDER BY p.contact_id",
5083
5588
  event.id
@@ -5121,22 +5626,46 @@ function exportContact(db, id) {
5121
5626
  var init_export_contact = __esm({
5122
5627
  "src/contacts/export_contact.ts"() {
5123
5628
  init_affinity_not_found_error();
5629
+ init_export_rows();
5124
5630
  }
5125
5631
  });
5126
5632
 
5127
5633
  // src/contacts/export_affinity.ts
5128
5634
  function exportAffinity(db) {
5129
5635
  const now = Date.now();
5636
+ const records = db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id));
5637
+ const events = exportRows(
5638
+ db,
5639
+ "SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary FROM events ORDER BY id"
5640
+ ).map((event) => ({
5641
+ ...event,
5642
+ attributes: exportRows(
5643
+ db,
5644
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5645
+ event.id
5646
+ ),
5647
+ participants: exportRows(
5648
+ db,
5649
+ "SELECT p.contact_id, p.role, c.name AS contact_name FROM event_participants p JOIN contacts c ON c.id = p.contact_id WHERE p.event_id = ? ORDER BY p.contact_id",
5650
+ event.id
5651
+ )
5652
+ }));
5130
5653
  return {
5131
5654
  version: 1,
5132
5655
  exportedAt: now,
5133
5656
  exportedAtIso: new Date(now).toISOString(),
5134
- contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
5657
+ events,
5658
+ contacts: records.map(({ events: events2, dates, ...record }) => ({
5659
+ ...record,
5660
+ eventIds: events2.map((event) => event.id),
5661
+ dateIds: dates.map((date) => date.id)
5662
+ }))
5135
5663
  };
5136
5664
  }
5137
5665
  var init_export_affinity = __esm({
5138
5666
  "src/contacts/export_affinity.ts"() {
5139
5667
  init_export_contact();
5668
+ init_export_rows();
5140
5669
  }
5141
5670
  });
5142
5671
 
@@ -5227,7 +5756,7 @@ function assertValidLifecycleTransition(from, to) {
5227
5756
  }
5228
5757
  if (to === "merged") {
5229
5758
  throw new AffinityInvariantError(
5230
- "use manage_contact merge (CLI: contact merge) to reach merged state"
5759
+ "use merge_contacts (CLI: contact merge) to reach merged state"
5231
5760
  );
5232
5761
  }
5233
5762
  if (from === to) {
@@ -5300,97 +5829,18 @@ var init_set_contact_lifecycle = __esm({
5300
5829
  }
5301
5830
  });
5302
5831
 
5303
- // src/contacts/create_contact.ts
5304
- function createContact(db, input) {
5305
- return withTransaction(db, () => {
5306
- const name = input.name.trim();
5307
- if (name.length === 0) {
5308
- throw new AffinityValidationError("name must be non-empty");
5309
- }
5310
- if (!KINDS.has(input.kind)) {
5311
- throw new AffinityValidationError("invalid contact kind");
5312
- }
5313
- if (input.isOwner !== void 0) {
5314
- throw new AffinityValidationError(
5315
- "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
5316
- );
5317
- }
5318
- const now = resolveNow(input.now);
5319
- if (input.bootstrapOwner) {
5320
- const existing = db.prepare(
5321
- "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
5322
- ).get();
5323
- if (existing !== void 0) {
5324
- throw new AffinityConflictError(
5325
- `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
5326
- );
5327
- }
5328
- }
5329
- const isOwner = input.bootstrapOwner ? 1 : 0;
5330
- const result = db.prepare(
5331
- `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
5332
- VALUES (?, ?, 'active', ?, ?, ?)`
5333
- ).run(name, input.kind, isOwner, now, now);
5334
- const id = Number(result.lastInsertRowid);
5335
- const row = getContactRowById(db, id);
5336
- if (!row) {
5337
- throw new AffinityInvariantError("inserted contact row not found");
5338
- }
5339
- refreshContactRollup(db, id, now);
5340
- const primary = mapContactRowToContactListItem(row);
5341
- return buildContactMutationReceipt(primary, {
5342
- created: [{ kind: "contact", id }]
5343
- });
5344
- });
5345
- }
5346
- var KINDS;
5347
- var init_create_contact = __esm({
5348
- "src/contacts/create_contact.ts"() {
5349
- init_mappers4();
5350
- init_queries();
5351
- init_receipts4();
5352
- init_rollups();
5353
- init_affinity_conflict_error();
5354
- init_affinity_invariant_error();
5355
- init_affinity_validation_error();
5356
- init_resolve_now();
5357
- init_with_transaction();
5358
- KINDS = /* @__PURE__ */ new Set([
5359
- "human",
5360
- "group",
5361
- "company",
5362
- "team",
5363
- "pet",
5364
- "service",
5365
- "other"
5366
- ]);
5367
- }
5368
- });
5369
-
5370
- // src/contacts/fold_search_text.ts
5371
- function foldSearchText(value) {
5372
- let folded = value.toLowerCase();
5373
- if (/[^\u0020-\u007e]/.test(folded))
5374
- folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
5375
- return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
5376
- }
5377
- var init_fold_search_text = __esm({
5378
- "src/contacts/fold_search_text.ts"() {
5379
- }
5380
- });
5381
-
5382
- // src/identities/queries.ts
5383
- function getIdentityRowById(db, id) {
5384
- const row = db.prepare(
5385
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5386
- created_at, updated_at, removed_at
5387
- FROM identities
5388
- WHERE id = ? AND removed_at IS NULL`
5389
- ).get(id);
5390
- if (row === void 0) {
5391
- return null;
5392
- }
5393
- return row;
5832
+ // src/identities/queries.ts
5833
+ function getIdentityRowById(db, id) {
5834
+ const row = db.prepare(
5835
+ `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5836
+ created_at, updated_at, removed_at
5837
+ FROM identities
5838
+ WHERE id = ? AND removed_at IS NULL`
5839
+ ).get(id);
5840
+ if (row === void 0) {
5841
+ return null;
5842
+ }
5843
+ return row;
5394
5844
  }
5395
5845
  var init_queries5 = __esm({
5396
5846
  "src/identities/queries.ts"() {
@@ -5419,14 +5869,19 @@ var init_receipts5 = __esm({
5419
5869
  // src/identities/validators.ts
5420
5870
  function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5421
5871
  const row = excludeIdentityId === void 0 ? db.prepare(
5422
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5423
- ).get(normalizedKey) : db.prepare(
5424
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
5425
- ).get(normalizedKey, excludeIdentityId);
5872
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL"
5873
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey) : db.prepare(
5874
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL AND id != ?"
5875
+ ).get(
5876
+ normalizedKey,
5877
+ legacyIdentityKey(normalizedKey),
5878
+ normalizedKey,
5879
+ excludeIdentityId
5880
+ );
5426
5881
  if (row !== void 0) {
5427
5882
  const owner = db.prepare(
5428
- "SELECT c.id, c.name FROM contacts c JOIN identities i ON i.contact_id = c.id WHERE i.normalized_key = ? AND i.removed_at IS NULL"
5429
- ).get(normalizedKey);
5883
+ "SELECT c.id, c.name FROM contacts c JOIN identities i ON i.contact_id = c.id WHERE (i.normalized_key IN (?, ?) OR (i.type='custom:atproto-did' AND ? = 'did:' || lower(i.value))) AND i.removed_at IS NULL"
5884
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey);
5430
5885
  throw new AffinityConflictError(
5431
5886
  `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
5432
5887
  );
@@ -5435,12 +5890,25 @@ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5435
5890
  var init_validators5 = __esm({
5436
5891
  "src/identities/validators.ts"() {
5437
5892
  init_affinity_conflict_error();
5893
+ init_normalize();
5438
5894
  }
5439
5895
  });
5440
5896
 
5441
5897
  // src/identities/validate_identity_input.ts
5442
- function validateIdentityInput(type, value) {
5898
+ function validateIdentityInput(type, rawValue) {
5443
5899
  const kind = type.toLowerCase();
5900
+ const value = normalizeIdentityValue(kind, rawValue);
5901
+ if (["did", "atproto_did", "custom:atproto-did"].includes(kind) && !/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value))
5902
+ throw new AffinityValidationError(
5903
+ "did value must match did:<method>:<identifier> (for example did:plc:abc123)"
5904
+ );
5905
+ if (handleIdentityTypes.has(kind)) {
5906
+ const pattern = kind === "bluesky" ? /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/ : ["x", "twitter"].includes(kind) ? /^[a-z0-9_]{1,15}$/ : kind === "mastodon" ? /^[a-z0-9_.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/ : /^[a-z0-9_.-]+$/;
5907
+ if (!pattern.test(value))
5908
+ throw new AffinityValidationError(
5909
+ `invalid ${kind === "handle" ? "" : `${kind} `}handle; use a handle without spaces or a URI${kind === "mastodon" ? ": user@host.tld" : kind === "bluesky" ? ": name.bsky.social" : " (leading @ is optional)"}`
5910
+ );
5911
+ }
5444
5912
  if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
5445
5913
  throw new AffinityValidationError(
5446
5914
  `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
@@ -5471,7 +5939,10 @@ var identityTypes;
5471
5939
  var init_validate_identity_input = __esm({
5472
5940
  "src/identities/validate_identity_input.ts"() {
5473
5941
  init_affinity_validation_error();
5942
+ init_normalize();
5474
5943
  identityTypes = [
5944
+ "did",
5945
+ "atproto_did",
5475
5946
  "email",
5476
5947
  "phone",
5477
5948
  "url",
@@ -5512,12 +5983,27 @@ function addIdentity(db, contactId, input) {
5512
5983
  throw new AffinityStateError("merged contact is read-only");
5513
5984
  }
5514
5985
  const type = input.type.trim();
5515
- const value = input.value.trim();
5986
+ const value = normalizeIdentityValue(type, input.value);
5516
5987
  if (type.length === 0 || value.length === 0) {
5517
5988
  throw new AffinityValidationError("type and value must be non-empty");
5518
5989
  }
5519
5990
  validateIdentityInput(type, value);
5991
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
5992
+ throw new AffinityValidationError("ifExists must be error or return");
5520
5993
  const nk = normalizeIdentityKey(type, value);
5994
+ if (input.ifExists === "return") {
5995
+ const existing = db.prepare(
5996
+ "SELECT id FROM identities WHERE contact_id=? AND (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL"
5997
+ ).get(contactId, nk, legacyIdentityKey(nk), nk);
5998
+ if (existing) {
5999
+ const row2 = getIdentityRowById(db, Number(existing.id));
6000
+ if (row2)
6001
+ return buildIdentityMutationReceipt(
6002
+ mapIdentityRowToIdentityRecord(row2),
6003
+ {}
6004
+ );
6005
+ }
6006
+ }
5521
6007
  assertNoIdentityCollision(db, nk);
5522
6008
  const now = resolveNow(input.now);
5523
6009
  const verified = input.verified ? 1 : 0;
@@ -5569,31 +6055,266 @@ var init_add_identity = __esm({
5569
6055
  }
5570
6056
  });
5571
6057
 
5572
- // src/tools/create_contact_tool_result.ts
5573
- function createContactToolResult(db, input) {
6058
+ // src/contacts/create_contact.ts
6059
+ function createContact(db, input) {
5574
6060
  return withTransaction(db, () => {
5575
- const receipt = createContact(db, input);
5576
- const identityOwners = /* @__PURE__ */ new Set();
5577
- for (const identity of input.identities ?? []) {
6061
+ const name = input.name.trim();
6062
+ if (name.length === 0) {
6063
+ throw new AffinityValidationError("name must be non-empty");
6064
+ }
6065
+ if (!KINDS.has(input.kind)) {
6066
+ throw new AffinityValidationError("invalid contact kind");
6067
+ }
6068
+ if (input.isOwner !== void 0) {
6069
+ throw new AffinityValidationError(
6070
+ "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
6071
+ );
6072
+ }
6073
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
6074
+ throw new AffinityValidationError("ifExists must be error or return");
6075
+ const matches = (input.identities ?? []).flatMap((identity) => {
5578
6076
  validateIdentityInput(identity.type.trim(), identity.value.trim());
6077
+ const key = normalizeIdentityKey(identity.type, identity.value);
6078
+ const row2 = db.prepare(
6079
+ `SELECT c.id, c.name FROM identities i JOIN contacts c ON c.id=i.contact_id WHERE i.removed_at IS NULL AND c.deleted_at IS NULL AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))`
6080
+ ).get(key, legacyIdentityKey(key), key);
6081
+ return row2 ? [{ ...row2, identity }] : [];
6082
+ });
6083
+ const match = matches[0];
6084
+ if (match) {
6085
+ if (input.ifExists === "return") {
6086
+ const existing = getContactRowById(db, match.id);
6087
+ if (!existing)
6088
+ throw new AffinityInvariantError("identity owner not found");
6089
+ const primary2 = mapContactRowToContactListItem(existing);
6090
+ return Object.assign(buildContactMutationReceipt(primary2, {}), {
6091
+ outcome: "no_op",
6092
+ matchedIdentities: matches.map((m) => m.identity)
6093
+ });
6094
+ }
6095
+ const error = new AffinityConflictError(
6096
+ `identity ${normalizeIdentityKey(match.identity.type, match.identity.value).replace(":", " ")} already belongs to #${match.id} ${match.name}`
6097
+ );
6098
+ error.existingContactId = match.id;
6099
+ throw error;
6100
+ }
6101
+ const now = resolveNow(input.now);
6102
+ if (input.bootstrapOwner) {
5579
6103
  const existing = db.prepare(
5580
- "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5581
- ).get(normalizeIdentityKey(identity.type, identity.value));
5582
- if (existing) {
5583
- if (Number(existing.contact_id) !== receipt.primary.id)
5584
- identityOwners.add(Number(existing.contact_id));
6104
+ "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
6105
+ ).get();
6106
+ if (existing !== void 0) {
6107
+ throw new AffinityConflictError(
6108
+ `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
6109
+ );
6110
+ }
6111
+ }
6112
+ const isOwner = input.bootstrapOwner ? 1 : 0;
6113
+ const result = db.prepare(
6114
+ `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
6115
+ VALUES (?, ?, 'active', ?, ?, ?)`
6116
+ ).run(name, input.kind, isOwner, now, now);
6117
+ const id = Number(result.lastInsertRowid);
6118
+ const row = getContactRowById(db, id);
6119
+ if (!row) {
6120
+ throw new AffinityInvariantError("inserted contact row not found");
6121
+ }
6122
+ refreshContactRollup(db, id, now);
6123
+ const primary = mapContactRowToContactListItem(row);
6124
+ const receipt = buildContactMutationReceipt(primary, {
6125
+ created: [{ kind: "contact", id }]
6126
+ });
6127
+ for (const identity of input.identities ?? [])
6128
+ receipt.created.push(...addIdentity(db, id, identity).created);
6129
+ return receipt;
6130
+ });
6131
+ }
6132
+ var KINDS;
6133
+ var init_create_contact = __esm({
6134
+ "src/contacts/create_contact.ts"() {
6135
+ init_mappers4();
6136
+ init_queries();
6137
+ init_receipts4();
6138
+ init_rollups();
6139
+ init_add_identity();
6140
+ init_normalize();
6141
+ init_validate_identity_input();
6142
+ init_affinity_conflict_error();
6143
+ init_affinity_invariant_error();
6144
+ init_affinity_validation_error();
6145
+ init_resolve_now();
6146
+ init_with_transaction();
6147
+ KINDS = /* @__PURE__ */ new Set([
6148
+ "human",
6149
+ "group",
6150
+ "company",
6151
+ "team",
6152
+ "pet",
6153
+ "service",
6154
+ "other"
6155
+ ]);
6156
+ }
6157
+ });
6158
+
6159
+ // src/contacts/fold_search_text.ts
6160
+ function foldSearchText(value) {
6161
+ let folded = value.toLowerCase();
6162
+ if (/[^\u0020-\u007e]/.test(folded))
6163
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
6164
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
6165
+ }
6166
+ var init_fold_search_text = __esm({
6167
+ "src/contacts/fold_search_text.ts"() {
6168
+ }
6169
+ });
6170
+
6171
+ // src/contacts/list_duplicate_candidates.ts
6172
+ function pairKey(leftContactId, rightContactId) {
6173
+ const left = Math.min(leftContactId, rightContactId);
6174
+ const right = Math.max(leftContactId, rightContactId);
6175
+ return `${left}:${right}`;
6176
+ }
6177
+ function normalizeName(name) {
6178
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
6179
+ }
6180
+ function fuzzyNameScore(leftInput, rightInput) {
6181
+ const left = normalizeName(leftInput);
6182
+ const right = normalizeName(rightInput);
6183
+ if (left === right) {
6184
+ return 0.92;
6185
+ }
6186
+ if (left.length >= 3 && right.length >= 3) {
6187
+ if (left.includes(right) || right.includes(left)) {
6188
+ return 0.72;
6189
+ }
6190
+ const leftTokens = new Set(left.split(" "));
6191
+ const rightTokens = new Set(right.split(" "));
6192
+ const overlap = [...leftTokens].filter(
6193
+ (token) => rightTokens.has(token)
6194
+ ).length;
6195
+ if (overlap > 0) {
6196
+ return overlap / Math.max(leftTokens.size, rightTokens.size) * 0.7;
6197
+ }
6198
+ }
6199
+ return 0;
6200
+ }
6201
+ function listDuplicateCandidates(db, filters, options) {
6202
+ assertDefaultOrdering("listDuplicateCandidates", options);
6203
+ const { limit, offset } = resolveListLimitOffset(options);
6204
+ const subset = filters?.contactIds;
6205
+ const contacts = db.prepare(
6206
+ `SELECT id, name
6207
+ FROM contacts
6208
+ WHERE deleted_at IS NULL
6209
+ AND lifecycle_state != 'merged'`
6210
+ ).all();
6211
+ const allowedIds = subset === void 0 ? null : new Set(subset.map((contactId) => Number(contactId)));
6212
+ const pairs = /* @__PURE__ */ new Map();
6213
+ const exactMatches = db.prepare(
6214
+ `SELECT
6215
+ i1.contact_id AS left_contact_id,
6216
+ i2.contact_id AS right_contact_id,
6217
+ i1.normalized_key
6218
+ FROM identities i1
6219
+ INNER JOIN identities i2
6220
+ ON i1.normalized_key = i2.normalized_key
6221
+ AND i1.contact_id < i2.contact_id
6222
+ INNER JOIN contacts c1 ON c1.id = i1.contact_id
6223
+ INNER JOIN contacts c2 ON c2.id = i2.contact_id
6224
+ WHERE i1.removed_at IS NULL
6225
+ AND i2.removed_at IS NULL
6226
+ AND c1.deleted_at IS NULL
6227
+ AND c2.deleted_at IS NULL
6228
+ AND c1.lifecycle_state != 'merged'
6229
+ AND c2.lifecycle_state != 'merged'`
6230
+ ).all();
6231
+ for (const match of exactMatches) {
6232
+ if (allowedIds !== null && (!allowedIds.has(match.left_contact_id) || !allowedIds.has(match.right_contact_id))) {
6233
+ continue;
6234
+ }
6235
+ pairs.set(pairKey(match.left_contact_id, match.right_contact_id), {
6236
+ leftContactId: match.left_contact_id,
6237
+ rightContactId: match.right_contact_id,
6238
+ matchReason: `exact identity ${match.normalized_key}`,
6239
+ matchScore: 1
6240
+ });
6241
+ }
6242
+ if (filters?.exactOnly !== true) {
6243
+ for (let index = 0; index < contacts.length; index += 1) {
6244
+ const left = contacts[index];
6245
+ if (left === void 0 || allowedIds !== null && !allowedIds.has(left.id)) {
5585
6246
  continue;
5586
6247
  }
5587
- const added = addIdentity(db, receipt.primary.id, identity);
5588
- receipt.created.push(...added.created);
6248
+ const leftName = normalizeName(left.name);
6249
+ for (let offsetIndex = index + 1; offsetIndex < contacts.length; offsetIndex += 1) {
6250
+ const right = contacts[offsetIndex];
6251
+ if (right === void 0 || allowedIds !== null && !allowedIds.has(right.id)) {
6252
+ continue;
6253
+ }
6254
+ const score = fuzzyNameScore(leftName, normalizeName(right.name));
6255
+ if (score <= 0) {
6256
+ continue;
6257
+ }
6258
+ const key = pairKey(left.id, right.id);
6259
+ if (!pairs.has(key)) {
6260
+ pairs.set(key, {
6261
+ leftContactId: left.id,
6262
+ rightContactId: right.id,
6263
+ matchReason: "name similarity",
6264
+ matchScore: score
6265
+ });
6266
+ }
6267
+ }
6268
+ }
6269
+ }
6270
+ const dismissedRows = db.prepare("SELECT left_id, right_id FROM dismissed_duplicates").all();
6271
+ const dismissedSet = new Set(
6272
+ dismissedRows.map((r) => pairKey(r.left_id, r.right_id))
6273
+ );
6274
+ const includeDismissed = filters?.includeDismissed === true;
6275
+ for (const candidate of pairs.values()) {
6276
+ if (dismissedSet.has(
6277
+ pairKey(candidate.leftContactId, candidate.rightContactId)
6278
+ )) {
6279
+ candidate.dismissed = true;
5589
6280
  }
6281
+ }
6282
+ const minScore = filters?.minScore ?? 0;
6283
+ return [...pairs.values()].filter((candidate) => {
6284
+ if (candidate.matchScore < minScore) return false;
6285
+ if (candidate.dismissed === true && !includeDismissed) return false;
6286
+ return true;
6287
+ }).sort((left, right) => {
6288
+ const leftExact = left.matchScore === 1 ? 1 : 0;
6289
+ const rightExact = right.matchScore === 1 ? 1 : 0;
6290
+ return rightExact - leftExact || right.matchScore - left.matchScore;
6291
+ }).slice(offset, offset + limit);
6292
+ }
6293
+ var init_list_duplicate_candidates = __esm({
6294
+ "src/contacts/list_duplicate_candidates.ts"() {
6295
+ init_assert_default_ordering();
6296
+ init_resolve_list_limit_offset();
6297
+ }
6298
+ });
6299
+
6300
+ // src/tools/create_contact_tool_result.ts
6301
+ function createContactToolResult(db, input) {
6302
+ return withTransaction(db, () => {
6303
+ const receipt = createContact(db, input);
5590
6304
  const result = mutationToolResult("create", receipt, "contact");
6305
+ if (result.ok && receipt.outcome === "no_op") {
6306
+ result.outcome = "no_op";
6307
+ Object.assign(result.data, {
6308
+ matchedIdentities: receipt.matchedIdentities
6309
+ });
6310
+ return result;
6311
+ }
5591
6312
  const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
5592
6313
  const warnings = db.prepare(
5593
6314
  "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
5594
6315
  ).all(receipt.primary.id).flatMap((row) => {
5595
- const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
5596
- return score === void 0 ? [] : [
6316
+ const score = fold(row.name) === fold(input.name) ? 0.92 : fuzzyNameScore(row.name, input.name);
6317
+ return score <= 0 ? [] : [
5597
6318
  {
5598
6319
  code: "possible_duplicate",
5599
6320
  message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
@@ -5610,9 +6331,7 @@ var init_create_contact_tool_result = __esm({
5610
6331
  "src/tools/create_contact_tool_result.ts"() {
5611
6332
  init_create_contact();
5612
6333
  init_fold_search_text();
5613
- init_add_identity();
5614
- init_normalize();
5615
- init_validate_identity_input();
6334
+ init_list_duplicate_candidates();
5616
6335
  init_with_transaction();
5617
6336
  init_tool_mutation();
5618
6337
  }
@@ -5687,6 +6406,13 @@ function manageContactToolHandler(db, rawInput) {
5687
6406
  return contact.result;
5688
6407
  }
5689
6408
  if (input.action === "delete") {
6409
+ if (contact.value.is_owner)
6410
+ return toolFailure(
6411
+ "domain",
6412
+ "conflict",
6413
+ "Cannot delete owner.",
6414
+ "The owner contact cannot be deleted"
6415
+ );
5690
6416
  const deletion = deleteContact(db, contact.value.id, { confirm: true });
5691
6417
  return toolSuccess("Permanently deleted contact.", {
5692
6418
  action: "delete",
@@ -5776,7 +6502,11 @@ var init_manage_contact_tool = __esm({
5776
6502
  },
5777
6503
  ["type", "value"]
5778
6504
  ),
5779
- "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
6505
+ "Optional identities to attach. An identity owned by another live contact causes a conflict, or returns that contact when ifExists=return."
6506
+ ),
6507
+ ifExists: enumSchema(
6508
+ "Identity collision policy; return attaches nothing.",
6509
+ ["error", "return"]
5780
6510
  ),
5781
6511
  name: stringSchema("Contact name."),
5782
6512
  kind: enumSchema("Contact kind.", [
@@ -5900,7 +6630,7 @@ var init_add_date_anchor = __esm({
5900
6630
  init_loaders();
5901
6631
  init_persistence();
5902
6632
  init_queries2();
5903
- init_receipts2();
6633
+ init_receipts();
5904
6634
  init_validators();
5905
6635
  init_affinity_conflict_error();
5906
6636
  init_affinity_not_found_error();
@@ -5932,7 +6662,7 @@ var init_remove_date_anchor = __esm({
5932
6662
  "src/dates/remove_date_anchor.ts"() {
5933
6663
  init_loaders();
5934
6664
  init_mappers();
5935
- init_receipts2();
6665
+ init_receipts();
5936
6666
  init_resolve_now();
5937
6667
  init_with_transaction();
5938
6668
  init_upcoming_occurrences();
@@ -6001,7 +6731,7 @@ var init_revise_date_anchor = __esm({
6001
6731
  "src/dates/revise_date_anchor.ts"() {
6002
6732
  init_loaders();
6003
6733
  init_queries2();
6004
- init_receipts2();
6734
+ init_receipts();
6005
6735
  init_validators();
6006
6736
  init_affinity_conflict_error();
6007
6737
  init_affinity_validation_error();
@@ -6414,67 +7144,362 @@ function manageDuplicateDismissalToolHandler(db, rawInput) {
6414
7144
  ]
6415
7145
  }
6416
7146
  );
6417
- }
6418
- const receipt = undismissDuplicate(db, left.value.id, right.value.id);
6419
- return toolSuccess(
6420
- `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
6421
- {
6422
- ...receipt,
6423
- action: "undismiss",
6424
- changes: existed ? [
6425
- {
6426
- entity: "duplicate_dismissal",
6427
- leftContactId,
6428
- rightContactId,
6429
- action: "removed"
6430
- }
6431
- ] : []
6432
- }
7147
+ }
7148
+ const receipt = undismissDuplicate(db, left.value.id, right.value.id);
7149
+ return toolSuccess(
7150
+ `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
7151
+ {
7152
+ ...receipt,
7153
+ action: "undismiss",
7154
+ changes: existed ? [
7155
+ {
7156
+ entity: "duplicate_dismissal",
7157
+ leftContactId,
7158
+ rightContactId,
7159
+ action: "removed"
7160
+ }
7161
+ ] : []
7162
+ }
7163
+ );
7164
+ }, "Duplicate dismissal failed.");
7165
+ }
7166
+ var manageDuplicateDismissalTool;
7167
+ var init_manage_duplicate_dismissal_tool = __esm({
7168
+ "src/tools/manage_duplicate_dismissal_tool.ts"() {
7169
+ init_dismiss_duplicate();
7170
+ init_undismiss_duplicate();
7171
+ init_tool_metadata();
7172
+ init_tool_names();
7173
+ init_tool_resolvers();
7174
+ init_tool_types();
7175
+ init_tool_types();
7176
+ init_validate_tool_input();
7177
+ manageDuplicateDismissalTool = defineAffinityTool({
7178
+ name: manageDuplicateDismissalToolName,
7179
+ description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
7180
+ whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
7181
+ whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
7182
+ sideEffects: "writes_state",
7183
+ readOnly: false,
7184
+ supportsClarification: true,
7185
+ targetKinds: ["contact"],
7186
+ inputDescriptions: {
7187
+ action: "dismiss to suppress the pair; undismiss to re-surface it.",
7188
+ left: "One contact of the pair.",
7189
+ right: "The other contact of the pair.",
7190
+ reason: "Optional reason for the dismissal (audit trail)."
7191
+ },
7192
+ outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
7193
+ inputSchema: objectSchema(
7194
+ {
7195
+ action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
7196
+ left: contactLocatorSchema5(
7197
+ "Left contact. Provide contactId or identity."
7198
+ ),
7199
+ right: contactLocatorSchema5(
7200
+ "Right contact. Provide contactId or identity."
7201
+ ),
7202
+ reason: nullableStringSchema("Optional reason for the dismissal.")
7203
+ },
7204
+ ["action", "left", "right"],
7205
+ "Dismiss or un-dismiss a duplicate candidate pair."
7206
+ ),
7207
+ handler: manageDuplicateDismissalToolHandler
7208
+ });
7209
+ }
7210
+ });
7211
+
7212
+ // src/events/event_replay.ts
7213
+ function replayEventLinks(db, linkIds, now) {
7214
+ const eventIds = /* @__PURE__ */ new Set();
7215
+ for (const linkId of linkIds) {
7216
+ const saved = db.prepare("SELECT state_json FROM event_link_baselines WHERE link_id=?").get(linkId);
7217
+ if (!saved) continue;
7218
+ const baseline = JSON.parse(saved.state_json);
7219
+ const effects = db.prepare(
7220
+ "SELECT event_id, participant_json, provenance_json, created_at FROM link_event_effects WHERE link_id=? ORDER BY event_id"
7221
+ ).all(linkId);
7222
+ const overrides = db.prepare(
7223
+ "SELECT after_event_id,state FROM event_link_state_changes WHERE link_id=? ORDER BY id"
7224
+ ).all(linkId);
7225
+ let overrideIndex = 0;
7226
+ const applyOverrides = (beforeId) => {
7227
+ while (overrideIndex < overrides.length && (overrides[overrideIndex]?.after_event_id ?? Number.POSITIVE_INFINITY) < beforeId) {
7228
+ const override = overrides[overrideIndex];
7229
+ if (override)
7230
+ db.prepare("UPDATE links SET state=? WHERE id=?").run(
7231
+ override.state,
7232
+ linkId
7233
+ );
7234
+ overrideIndex++;
7235
+ }
7236
+ };
7237
+ db.prepare("DELETE FROM link_event_effects WHERE link_id=?").run(linkId);
7238
+ db.prepare(
7239
+ "UPDATE links SET rank=?,affinity=?,trust=?,state=?,cadence_days=?,updated_at=? WHERE id=?"
7240
+ ).run(
7241
+ baseline.rank,
7242
+ baseline.affinity,
7243
+ baseline.trust,
7244
+ baseline.state,
7245
+ baseline.cadence_days,
7246
+ now,
7247
+ linkId
7248
+ );
7249
+ for (const effect of effects) {
7250
+ eventIds.add(effect.event_id);
7251
+ applyOverrides(effect.event_id);
7252
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(effect.event_id))
7253
+ continue;
7254
+ const event = loadEventRecord(db, effect.event_id);
7255
+ const link = db.prepare("SELECT from_contact_id,to_contact_id FROM links WHERE id=?").get(linkId);
7256
+ const participant = effect.participant_json ? JSON.parse(effect.participant_json) : event.participants.find((p) => p.contactId === link.to_contact_id) ?? event.participants[0];
7257
+ const provenance = db.prepare("SELECT provenance_json FROM events WHERE id=?").get(event.id);
7258
+ applyEventEffectToLink(db, {
7259
+ linkId,
7260
+ eventId: event.id,
7261
+ eventType: event.type,
7262
+ occurredAt: event.occurredAt,
7263
+ significance: event.significance,
7264
+ participant,
7265
+ provenance: provenance.provenance_json ? JSON.parse(provenance.provenance_json) : effect.provenance_json ? JSON.parse(effect.provenance_json) : void 0,
7266
+ now: effect.created_at
7267
+ });
7268
+ }
7269
+ applyOverrides(Number.POSITIVE_INFINITY);
7270
+ refreshLinkRollup(db, linkId, now);
7271
+ }
7272
+ for (const eventId of eventIds)
7273
+ db.prepare(
7274
+ "UPDATE events SET moment_kind=COALESCE(declared_moment_kind,(SELECT moment_kind FROM link_event_effects WHERE event_id=events.id AND moment_kind IS NOT NULL ORDER BY link_id LIMIT 1)) WHERE id=?"
7275
+ ).run(eventId);
7276
+ refreshAllBridgeScores(db, now);
7277
+ }
7278
+ var init_event_replay = __esm({
7279
+ "src/events/event_replay.ts"() {
7280
+ init_bridge_scores();
7281
+ init_effects();
7282
+ init_rollups2();
7283
+ init_loaders();
7284
+ }
7285
+ });
7286
+
7287
+ // src/events/manage_event.ts
7288
+ function requireEventTarget(db, target) {
7289
+ const selector = typeof target === "number" ? { eventId: target } : target;
7290
+ if (!selector || selector.eventId === void 0 === (selector.ref === void 0))
7291
+ throw new AffinityValidationError("Provide exactly one of eventId or ref");
7292
+ const event = selector.ref ? getEventByExternalRef(db, selector.ref.source, selector.ref.id) : getEventById(db, selector.eventId);
7293
+ if (!event) throw new AffinityNotFoundError("event not found");
7294
+ return event;
7295
+ }
7296
+ function affected(db, id) {
7297
+ return db.prepare("SELECT link_id FROM link_event_effects WHERE event_id=?").all(id).map((r) => r.link_id);
7298
+ }
7299
+ function reviseEvent(db, target, patch) {
7300
+ return withTransaction(db, () => {
7301
+ const allowed = [
7302
+ "summary",
7303
+ "content",
7304
+ "occurredAt",
7305
+ "significance",
7306
+ "channel",
7307
+ "type",
7308
+ "externalRef"
7309
+ ];
7310
+ for (const key of Object.keys(patch))
7311
+ if (!allowed.includes(key))
7312
+ throw new AffinityValidationError(
7313
+ `unknown patch key ${key}; allowed keys: ${allowed.join(", ")}`
7314
+ );
7315
+ const event = requireEventTarget(db, target);
7316
+ const next = { ...event, ...patch };
7317
+ const { momentKind: _momentKind, ...metadata } = next;
7318
+ validateEventMetadata(metadata);
7319
+ Object.assign(next, metadata);
7320
+ if (patch.summary !== void 0 && (typeof patch.summary !== "string" || !patch.summary.trim()))
7321
+ throw new AffinityValidationError("summary must be non-empty");
7322
+ if (patch.occurredAt !== void 0)
7323
+ assertFiniteTimestamp(patch.occurredAt, "occurredAt");
7324
+ if (!Number.isInteger(next.significance) || next.significance < 1 || next.significance > 10)
7325
+ throw new AffinityValidationError(
7326
+ "significance must be an integer from 1 to 10"
7327
+ );
7328
+ const interactions = [
7329
+ "conversation",
7330
+ "activity",
7331
+ "gift",
7332
+ "support",
7333
+ "conflict",
7334
+ "correction"
7335
+ ];
7336
+ if (next.type !== event.type && !(interactions.includes(event.type) && interactions.includes(next.type)) && !(["promise", "agreement"].includes(event.type) && ["promise", "agreement"].includes(next.type)))
7337
+ throw new AffinityValidationError(
7338
+ "type must stay within the same event kind"
7339
+ );
7340
+ if (patch.externalRef && event.externalRef)
7341
+ throw new AffinityConflictError(
7342
+ "externalRef can only be set when absent"
7343
+ );
7344
+ if (patch.externalRef && next.externalRef && getEventByExternalRef(db, next.externalRef.source, next.externalRef.id))
7345
+ throw new AffinityConflictError(
7346
+ "externalRef already belongs to an event"
7347
+ );
7348
+ const now = Date.now();
7349
+ const links = affected(db, event.id);
7350
+ db.prepare(
7351
+ "UPDATE events SET summary=?,content=?,occurred_at=?,occurred_on=?,significance=?,channel=?,type=?,external_source=?,external_id=?,updated_at=? WHERE id=?"
7352
+ ).run(
7353
+ next.summary.trim(),
7354
+ next.content ?? null,
7355
+ next.occurredAt,
7356
+ patch.occurredAt === void 0 ? event.occurredOn ?? null : new Date(next.occurredAt).toISOString().slice(0, 10),
7357
+ next.significance,
7358
+ next.channel ?? null,
7359
+ next.type,
7360
+ next.externalRef?.source ?? null,
7361
+ next.externalRef?.id ?? null,
7362
+ now,
7363
+ event.id
6433
7364
  );
6434
- }, "Duplicate dismissal failed.");
7365
+ if (patch.type && ["promise", "agreement"].includes(patch.type))
7366
+ db.prepare(
7367
+ "UPDATE open_commitments SET commitment_type=? WHERE event_id=?"
7368
+ ).run(patch.type, event.id);
7369
+ if (patch.significance !== void 0 || patch.type !== void 0 || patch.occurredAt !== void 0)
7370
+ replayEventLinks(db, links, now);
7371
+ return buildEventMutationReceipt(requireEventTarget(db, event.id), {
7372
+ updated: [{ kind: "event", id: event.id }],
7373
+ affectedLinks: links
7374
+ });
7375
+ });
6435
7376
  }
6436
- var manageDuplicateDismissalTool;
6437
- var init_manage_duplicate_dismissal_tool = __esm({
6438
- "src/tools/manage_duplicate_dismissal_tool.ts"() {
6439
- init_dismiss_duplicate();
6440
- init_undismiss_duplicate();
7377
+ function deleteEvent(db, target, options) {
7378
+ return withTransaction(db, () => {
7379
+ if (options?.confirm !== true)
7380
+ throw new AffinityValidationError("deleteEvent requires confirm: true");
7381
+ const event = requireEventTarget(db, target);
7382
+ const links = affected(db, event.id);
7383
+ for (const table of [
7384
+ "event_participants",
7385
+ "attributes",
7386
+ "open_commitments",
7387
+ "upcoming_occurrences",
7388
+ "link_event_effects"
7389
+ ])
7390
+ db.prepare(`DELETE FROM ${table} WHERE event_id=?`).run(event.id);
7391
+ db.prepare(
7392
+ "UPDATE events SET parent_event_id=NULL WHERE parent_event_id=?"
7393
+ ).run(event.id);
7394
+ db.prepare("DELETE FROM events WHERE id=?").run(event.id);
7395
+ replayEventLinks(db, links, Date.now());
7396
+ return buildEventMutationReceipt(event, {
7397
+ removed: [{ kind: "event", id: event.id }],
7398
+ affectedLinks: links
7399
+ });
7400
+ });
7401
+ }
7402
+ var init_manage_event = __esm({
7403
+ "src/events/manage_event.ts"() {
7404
+ init_affinity_conflict_error();
7405
+ init_affinity_not_found_error();
7406
+ init_affinity_validation_error();
7407
+ init_with_transaction();
7408
+ init_event_metadata();
7409
+ init_event_replay();
7410
+ init_receipts();
7411
+ init_validators();
7412
+ }
7413
+ });
7414
+
7415
+ // src/tools/manage_event_tool.ts
7416
+ function manageEventToolHandler(db, raw) {
7417
+ const input = structuredClone(raw);
7418
+ const error = validateToolInput(
7419
+ manageEventTool.inputSchema,
7420
+ input,
7421
+ "input",
7422
+ Date.now(),
7423
+ true
7424
+ );
7425
+ if (error)
7426
+ return toolFailure("protocol", "invalid_input", "Invalid input.", error);
7427
+ return withToolHandling(() => {
7428
+ const target = {
7429
+ ...input.eventId === void 0 ? {} : { eventId: input.eventId },
7430
+ ...input.ref === void 0 ? {} : { ref: input.ref }
7431
+ };
7432
+ return mutationToolResult(
7433
+ input.action,
7434
+ input.action === "revise" ? reviseEvent(db, target, input.patch ?? {}) : deleteEvent(db, target, { confirm: input.confirm === true }),
7435
+ "event"
7436
+ );
7437
+ }, "Event management failed.");
7438
+ }
7439
+ var manageEventTool;
7440
+ var init_manage_event_tool = __esm({
7441
+ "src/tools/manage_event_tool.ts"() {
7442
+ init_manage_event();
7443
+ init_event_metadata_schema();
6441
7444
  init_tool_metadata();
7445
+ init_tool_mutation();
6442
7446
  init_tool_names();
6443
7447
  init_tool_resolvers();
6444
7448
  init_tool_types();
6445
- init_tool_types();
6446
7449
  init_validate_tool_input();
6447
- manageDuplicateDismissalTool = defineAffinityTool({
6448
- name: manageDuplicateDismissalToolName,
6449
- description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
6450
- whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
6451
- whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
7450
+ manageEventTool = defineAffinityTool({
7451
+ name: manageEventToolName,
7452
+ description: "Revise or delete one event by eventId or external reference. Delete requires confirm=true and recomputes affected relationships.",
7453
+ whenToUse: "Correct imported event text or metadata, or remove an event.",
7454
+ whenNotToUse: "Use record_event to capture new evidence.",
6452
7455
  sideEffects: "writes_state",
6453
7456
  readOnly: false,
6454
- supportsClarification: true,
6455
- targetKinds: ["contact"],
7457
+ supportsClarification: false,
7458
+ targetKinds: ["event"],
6456
7459
  inputDescriptions: {
6457
- action: "dismiss to suppress the pair; undismiss to re-surface it.",
6458
- left: "One contact of the pair.",
6459
- right: "The other contact of the pair.",
6460
- reason: "Optional reason for the dismissal (audit trail)."
7460
+ action: "revise or delete",
7461
+ eventId: "Exact event id",
7462
+ ref: "External event reference",
7463
+ patch: "Fields to revise",
7464
+ confirm: "Required true for deletion"
6461
7465
  },
6462
- outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
7466
+ outputDescription: "Event mutation receipt and affected links.",
6463
7467
  inputSchema: objectSchema(
6464
7468
  {
6465
- action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
6466
- left: contactLocatorSchema5(
6467
- "Left contact. Provide contactId or identity."
6468
- ),
6469
- right: contactLocatorSchema5(
6470
- "Right contact. Provide contactId or identity."
6471
- ),
6472
- reason: nullableStringSchema("Optional reason for the dismissal.")
7469
+ action: enumSchema("Action.", ["revise", "delete"]),
7470
+ eventId: integerSchema("Event id; provide eventId or ref."),
7471
+ ref: externalRefSchema,
7472
+ confirm: booleanSchema("Must be true to delete."),
7473
+ patch: objectSchema(
7474
+ {
7475
+ summary: stringSchema("Short non-empty label."),
7476
+ content: stringSchema("Verbatim content, at most 1 MB UTF-8."),
7477
+ occurredAt: integerSchema("Event timestamp."),
7478
+ significance: integerSchema("Significance 1..10."),
7479
+ channel: stringSchema("Channel, at most 40 characters."),
7480
+ type: enumSchema("Type within the same event kind.", [
7481
+ "conversation",
7482
+ "activity",
7483
+ "gift",
7484
+ "support",
7485
+ "conflict",
7486
+ "correction",
7487
+ "observation",
7488
+ "milestone",
7489
+ "transaction",
7490
+ "promise",
7491
+ "agreement",
7492
+ "date_anchor"
7493
+ ]),
7494
+ externalRef: externalRefSchema
7495
+ },
7496
+ [],
7497
+ "Fields to revise; externalRef may only be added when absent."
7498
+ )
6473
7499
  },
6474
- ["action", "left", "right"],
6475
- "Dismiss or un-dismiss a duplicate candidate pair."
7500
+ ["action"]
6476
7501
  ),
6477
- handler: manageDuplicateDismissalToolHandler
7502
+ handler: manageEventToolHandler
6478
7503
  });
6479
7504
  }
6480
7505
  });
@@ -6539,7 +7564,10 @@ function reviseIdentity(db, identityId, patch) {
6539
7564
  });
6540
7565
  }
6541
7566
  const nextType = patch.type !== void 0 ? patch.type.trim() : row.type;
6542
- const nextValue = patch.value !== void 0 ? patch.value.trim() : row.value;
7567
+ const nextValue = normalizeIdentityValue(
7568
+ nextType,
7569
+ patch.value ?? row.value
7570
+ );
6543
7571
  if (nextType.length === 0 || nextValue.length === 0) {
6544
7572
  throw new AffinityValidationError("type and value must be non-empty");
6545
7573
  }
@@ -6626,6 +7654,10 @@ function contactLocatorSchema6(description) {
6626
7654
  contactId: integerSchema("Exact contact id."),
6627
7655
  identity: objectSchema(
6628
7656
  {
7657
+ ifExists: enumSchema(
7658
+ "Return an existing identity on this contact without changes.",
7659
+ ["error", "return"]
7660
+ ),
6629
7661
  type: stringSchema(
6630
7662
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6631
7663
  ),
@@ -6736,6 +7768,10 @@ var init_manage_identity_tool = __esm({
6736
7768
  ),
6737
7769
  input: objectSchema(
6738
7770
  {
7771
+ ifExists: enumSchema(
7772
+ "Return an existing identity on this contact without changes.",
7773
+ ["error", "return"]
7774
+ ),
6739
7775
  type: stringSchema(
6740
7776
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6741
7777
  ),
@@ -6799,6 +7835,9 @@ function overrideLinkState(db, linkId, state, options) {
6799
7835
  now,
6800
7836
  linkId
6801
7837
  );
7838
+ db.prepare(
7839
+ "INSERT INTO event_link_state_changes(link_id,after_event_id,state) SELECT ?, COALESCE(MAX(event_id),0), ? FROM link_event_effects WHERE link_id=?"
7840
+ ).run(linkId, state, linkId);
6802
7841
  refreshLinkRollup(db, linkId, now);
6803
7842
  if (state === "archived" || row.state === "archived") {
6804
7843
  refreshAllBridgeScores(db, now);
@@ -7523,6 +8562,8 @@ var init_merge_contacts_tool = __esm({
7523
8562
  function recordInteraction(db, input) {
7524
8563
  return withTransaction(db, () => {
7525
8564
  validateSocialEventInput(input);
8565
+ const existing = existingEventReceipt(db, input.externalRef);
8566
+ if (existing) return existing;
7526
8567
  if (!ALLOWED.has(input.type)) {
7527
8568
  throw new AffinityValidationError("invalid interaction event type");
7528
8569
  }
@@ -7532,11 +8573,12 @@ function recordInteraction(db, input) {
7532
8573
  assertOwnerParticipates(ownerId, ids);
7533
8574
  const now = resolveNow(input.now);
7534
8575
  const eventId = insertJournalEvent(db, {
8576
+ ...input,
7535
8577
  type: input.type,
7536
8578
  occurredAt: input.occurredAt,
7537
- summary: input.summary,
8579
+ occurredOn: input.occurredOn,
7538
8580
  significance: input.significance,
7539
- momentKind: null,
8581
+ momentKind: input.momentKind ?? null,
7540
8582
  participants: input.participants,
7541
8583
  now
7542
8584
  });
@@ -7544,12 +8586,24 @@ function recordInteraction(db, input) {
7544
8586
  eventId,
7545
8587
  eventType: input.type,
7546
8588
  occurredAt: input.occurredAt,
8589
+ occurredOn: input.occurredOn,
7547
8590
  significance: input.significance,
7548
8591
  participants: input.participants,
7549
8592
  provenance: input.provenance,
7550
8593
  now
7551
8594
  });
8595
+ if (input.momentKind !== void 0)
8596
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8597
+ input.momentKind,
8598
+ eventId
8599
+ );
7552
8600
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8601
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8602
+ {
8603
+ code: "future_event",
8604
+ message: "occurredAt is in the future; it will not count as contact until then"
8605
+ }
8606
+ ] : void 0,
7553
8607
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7554
8608
  affectedLinks: mechanics.affectedLinks,
7555
8609
  derivedEffects: mechanics.derivedEffects
@@ -7562,12 +8616,14 @@ var init_record_interaction = __esm({
7562
8616
  init_queries();
7563
8617
  init_loaders();
7564
8618
  init_persistence();
7565
- init_receipts2();
8619
+ init_receipts();
7566
8620
  init_validators();
7567
8621
  init_affinity_validation_error();
8622
+ init_parse_calendar_time();
7568
8623
  init_resolve_now();
7569
8624
  init_with_transaction();
7570
8625
  init_apply_journal_event_mechanics();
8626
+ init_event_metadata();
7571
8627
  ALLOWED = /* @__PURE__ */ new Set([
7572
8628
  "conversation",
7573
8629
  "activity",
@@ -7583,17 +8639,20 @@ var init_record_interaction = __esm({
7583
8639
  function recordMilestone(db, input) {
7584
8640
  return withTransaction(db, () => {
7585
8641
  validateSocialEventInput(input);
8642
+ const existing = existingEventReceipt(db, input.externalRef);
8643
+ if (existing) return existing;
7586
8644
  const ownerId = requireOwnerContactId(db);
7587
8645
  const ids = input.participants.map((p) => p.contactId);
7588
8646
  assertParticipantContactsLive(db, ids);
7589
8647
  assertOwnerParticipates(ownerId, ids);
7590
8648
  const now = resolveNow(input.now);
7591
8649
  const eventId = insertJournalEvent(db, {
8650
+ ...input,
7592
8651
  type: "milestone",
7593
8652
  occurredAt: input.occurredAt,
7594
- summary: input.summary,
8653
+ occurredOn: input.occurredOn,
7595
8654
  significance: input.significance,
7596
- momentKind: null,
8655
+ momentKind: input.momentKind ?? null,
7597
8656
  participants: input.participants,
7598
8657
  now
7599
8658
  });
@@ -7601,12 +8660,24 @@ function recordMilestone(db, input) {
7601
8660
  eventId,
7602
8661
  eventType: "milestone",
7603
8662
  occurredAt: input.occurredAt,
8663
+ occurredOn: input.occurredOn,
7604
8664
  significance: input.significance,
7605
8665
  participants: input.participants,
7606
8666
  provenance: input.provenance,
7607
8667
  now
7608
8668
  });
8669
+ if (input.momentKind !== void 0)
8670
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8671
+ input.momentKind,
8672
+ eventId
8673
+ );
7609
8674
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8675
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8676
+ {
8677
+ code: "future_event",
8678
+ message: "occurredAt is in the future; it will not count as contact until then"
8679
+ }
8680
+ ] : void 0,
7610
8681
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7611
8682
  affectedLinks: mechanics.affectedLinks,
7612
8683
  derivedEffects: mechanics.derivedEffects
@@ -7618,11 +8689,13 @@ var init_record_milestone = __esm({
7618
8689
  init_queries();
7619
8690
  init_loaders();
7620
8691
  init_persistence();
7621
- init_receipts2();
8692
+ init_receipts();
7622
8693
  init_validators();
8694
+ init_parse_calendar_time();
7623
8695
  init_resolve_now();
7624
8696
  init_with_transaction();
7625
8697
  init_apply_journal_event_mechanics();
8698
+ init_event_metadata();
7626
8699
  }
7627
8700
  });
7628
8701
 
@@ -7630,15 +8703,18 @@ var init_record_milestone = __esm({
7630
8703
  function recordObservation(db, input) {
7631
8704
  return withTransaction(db, () => {
7632
8705
  validateSocialEventInput(input);
8706
+ const existing = existingEventReceipt(db, input.externalRef);
8707
+ if (existing) return existing;
7633
8708
  const ids = input.participants.map((p) => p.contactId);
7634
8709
  assertParticipantContactsLive(db, ids);
7635
8710
  const now = resolveNow(input.now);
7636
8711
  const eventId = insertJournalEvent(db, {
8712
+ ...input,
7637
8713
  type: "observation",
7638
8714
  occurredAt: input.occurredAt,
7639
- summary: input.summary,
8715
+ occurredOn: input.occurredOn,
7640
8716
  significance: input.significance,
7641
- momentKind: null,
8717
+ momentKind: input.momentKind ?? null,
7642
8718
  participants: input.participants,
7643
8719
  now
7644
8720
  });
@@ -7646,12 +8722,24 @@ function recordObservation(db, input) {
7646
8722
  eventId,
7647
8723
  eventType: "observation",
7648
8724
  occurredAt: input.occurredAt,
8725
+ occurredOn: input.occurredOn,
7649
8726
  significance: input.significance,
7650
8727
  participants: input.participants,
7651
8728
  provenance: input.provenance,
7652
8729
  now
7653
8730
  });
8731
+ if (input.momentKind !== void 0)
8732
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8733
+ input.momentKind,
8734
+ eventId
8735
+ );
7654
8736
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8737
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8738
+ {
8739
+ code: "future_event",
8740
+ message: "occurredAt is in the future; it will not count as contact until then"
8741
+ }
8742
+ ] : void 0,
7655
8743
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7656
8744
  affectedLinks: mechanics.affectedLinks,
7657
8745
  derivedEffects: mechanics.derivedEffects
@@ -7662,11 +8750,13 @@ var init_record_observation = __esm({
7662
8750
  "src/events/record_observation.ts"() {
7663
8751
  init_loaders();
7664
8752
  init_persistence();
7665
- init_receipts2();
8753
+ init_receipts();
7666
8754
  init_validators();
8755
+ init_parse_calendar_time();
7667
8756
  init_resolve_now();
7668
8757
  init_with_transaction();
7669
8758
  init_apply_journal_event_mechanics();
8759
+ init_event_metadata();
7670
8760
  }
7671
8761
  });
7672
8762
 
@@ -7674,17 +8764,20 @@ var init_record_observation = __esm({
7674
8764
  function recordTransaction(db, input) {
7675
8765
  return withTransaction(db, () => {
7676
8766
  validateSocialEventInput(input);
8767
+ const existing = existingEventReceipt(db, input.externalRef);
8768
+ if (existing) return existing;
7677
8769
  const ownerId = requireOwnerContactId(db);
7678
8770
  const ids = input.participants.map((p) => p.contactId);
7679
8771
  assertParticipantContactsLive(db, ids);
7680
8772
  assertOwnerParticipates(ownerId, ids);
7681
8773
  const now = resolveNow(input.now);
7682
8774
  const eventId = insertJournalEvent(db, {
8775
+ ...input,
7683
8776
  type: "transaction",
7684
8777
  occurredAt: input.occurredAt,
7685
- summary: input.summary,
8778
+ occurredOn: input.occurredOn,
7686
8779
  significance: input.significance,
7687
- momentKind: null,
8780
+ momentKind: input.momentKind ?? null,
7688
8781
  participants: input.participants,
7689
8782
  now
7690
8783
  });
@@ -7692,12 +8785,24 @@ function recordTransaction(db, input) {
7692
8785
  eventId,
7693
8786
  eventType: "transaction",
7694
8787
  occurredAt: input.occurredAt,
8788
+ occurredOn: input.occurredOn,
7695
8789
  significance: input.significance,
7696
8790
  participants: input.participants,
7697
8791
  provenance: input.provenance,
7698
8792
  now
7699
8793
  });
8794
+ if (input.momentKind !== void 0)
8795
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8796
+ input.momentKind,
8797
+ eventId
8798
+ );
7700
8799
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8800
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8801
+ {
8802
+ code: "future_event",
8803
+ message: "occurredAt is in the future; it will not count as contact until then"
8804
+ }
8805
+ ] : void 0,
7701
8806
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7702
8807
  affectedLinks: mechanics.affectedLinks,
7703
8808
  derivedEffects: mechanics.derivedEffects
@@ -7709,20 +8814,24 @@ var init_record_transaction = __esm({
7709
8814
  init_queries();
7710
8815
  init_loaders();
7711
8816
  init_persistence();
7712
- init_receipts2();
8817
+ init_receipts();
7713
8818
  init_validators();
8819
+ init_parse_calendar_time();
7714
8820
  init_resolve_now();
7715
8821
  init_with_transaction();
7716
8822
  init_apply_journal_event_mechanics();
8823
+ init_event_metadata();
7717
8824
  }
7718
8825
  });
7719
8826
 
7720
8827
  // src/tools/record_event_tool.ts
7721
- function normalizeParticipant2(p) {
8828
+ function normalizeParticipant2(db, p) {
7722
8829
  let contactId = p.contactId;
7723
8830
  if (contactId === void 0 && p.contact != null) {
7724
8831
  const loc = p.contact;
7725
- contactId = loc.contactId;
8832
+ const resolved = resolveContactLocator(db, loc);
8833
+ if (!resolved.ok) throw resolved.result;
8834
+ contactId = resolved.value.id;
7726
8835
  }
7727
8836
  const result = {
7728
8837
  contactId,
@@ -7740,12 +8849,18 @@ function ensureOwnerParticipates2(db, participants) {
7740
8849
  return [{ contactId: ownerId, role: "actor" }, ...participants];
7741
8850
  }
7742
8851
  function prepareParticipants2(db, raw, injectOwner) {
7743
- const normalized = raw.map(normalizeParticipant2);
8852
+ const normalized = raw.map((p) => normalizeParticipant2(db, p));
7744
8853
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
7745
8854
  }
7746
8855
  function recordEventToolHandler(db, rawInput) {
7747
8856
  const input = structuredClone(rawInput);
7748
- const inputError = validateToolInput(recordEventTool.inputSchema, input);
8857
+ const inputError = validateToolInput(
8858
+ recordEventTool.inputSchema,
8859
+ input,
8860
+ "input",
8861
+ Date.now(),
8862
+ true
8863
+ );
7749
8864
  if (inputError)
7750
8865
  return toolFailure(
7751
8866
  "protocol",
@@ -7798,6 +8913,7 @@ var init_record_event_tool = __esm({
7798
8913
  init_record_milestone();
7799
8914
  init_record_observation();
7800
8915
  init_record_transaction();
8916
+ init_event_metadata_schema();
7801
8917
  init_tool_metadata();
7802
8918
  init_tool_mutation();
7803
8919
  init_tool_names();
@@ -7839,6 +8955,7 @@ var init_record_event_tool = __esm({
7839
8955
  "correction"
7840
8956
  ]
7841
8957
  ),
8958
+ ...eventMetadataSchema,
7842
8959
  occurredAt: integerSchema(
7843
8960
  "When the event occurred, Unix timestamp in milliseconds."
7844
8961
  ),
@@ -7847,6 +8964,19 @@ var init_record_event_tool = __esm({
7847
8964
  participants: arraySchema(
7848
8965
  objectSchema(
7849
8966
  {
8967
+ contact: objectSchema(
8968
+ {
8969
+ contactId: integerSchema("Contact id."),
8970
+ identity: objectSchema(
8971
+ {
8972
+ type: stringSchema("Identity type."),
8973
+ value: stringSchema("Identity value.")
8974
+ },
8975
+ ["type", "value"]
8976
+ )
8977
+ },
8978
+ []
8979
+ ),
7850
8980
  contactId: integerSchema(
7851
8981
  "Participant contact id. The owner contact MUST be included."
7852
8982
  ),
@@ -7870,7 +9000,7 @@ var init_record_event_tool = __esm({
7870
9000
  ),
7871
9001
  now: integerSchema("Optional timestamp override.")
7872
9002
  },
7873
- ["occurredAt", "summary", "significance", "participants"],
9003
+ ["occurredAt", "significance", "participants"],
7874
9004
  "Event payload. Always required. Interaction events also require `type`."
7875
9005
  )
7876
9006
  },
@@ -7888,7 +9018,10 @@ function contactAttributeClauses(filters) {
7888
9018
  const params = [];
7889
9019
  const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
7890
9020
  const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
7891
- for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
9021
+ for (const filter of [
9022
+ ...attributes,
9023
+ ...tags.map((tag) => `tag:${tag.replace(/^tag:/i, "")}`)
9024
+ ]) {
7892
9025
  const separator = filter.indexOf("=");
7893
9026
  const name = separator < 0 ? filter : filter.slice(0, separator);
7894
9027
  clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
@@ -7949,143 +9082,16 @@ function listContacts(db, filters, options) {
7949
9082
  const item = mapContactRowToContactListItem(r);
7950
9083
  return {
7951
9084
  ...item,
7952
- primaryIdentity: r.primary_identity ?? null
7953
- };
7954
- });
7955
- }
7956
- var init_list_contacts = __esm({
7957
- "src/contacts/list_contacts.ts"() {
7958
- init_assert_default_ordering();
7959
- init_resolve_list_limit_offset();
7960
- init_contact_attribute_clauses();
7961
- init_mappers4();
7962
- }
7963
- });
7964
-
7965
- // src/contacts/list_duplicate_candidates.ts
7966
- function pairKey(leftContactId, rightContactId) {
7967
- const left = Math.min(leftContactId, rightContactId);
7968
- const right = Math.max(leftContactId, rightContactId);
7969
- return `${left}:${right}`;
7970
- }
7971
- function normalizeName(name) {
7972
- return name.trim().toLowerCase().replace(/\s+/g, " ");
7973
- }
7974
- function fuzzyNameScore(left, right) {
7975
- if (left === right) {
7976
- return 0.92;
7977
- }
7978
- if (left.length >= 3 && right.length >= 3) {
7979
- if (left.includes(right) || right.includes(left)) {
7980
- return 0.72;
7981
- }
7982
- const leftTokens = new Set(left.split(" "));
7983
- const rightTokens = new Set(right.split(" "));
7984
- const overlap = [...leftTokens].filter(
7985
- (token) => rightTokens.has(token)
7986
- ).length;
7987
- if (overlap > 0) {
7988
- return overlap / Math.max(leftTokens.size, rightTokens.size) * 0.7;
7989
- }
7990
- }
7991
- return 0;
7992
- }
7993
- function listDuplicateCandidates(db, filters, options) {
7994
- assertDefaultOrdering("listDuplicateCandidates", options);
7995
- const { limit, offset } = resolveListLimitOffset(options);
7996
- const subset = filters?.contactIds;
7997
- const contacts = db.prepare(
7998
- `SELECT id, name
7999
- FROM contacts
8000
- WHERE deleted_at IS NULL
8001
- AND lifecycle_state != 'merged'`
8002
- ).all();
8003
- const allowedIds = subset === void 0 ? null : new Set(subset.map((contactId) => Number(contactId)));
8004
- const pairs = /* @__PURE__ */ new Map();
8005
- const exactMatches = db.prepare(
8006
- `SELECT
8007
- i1.contact_id AS left_contact_id,
8008
- i2.contact_id AS right_contact_id,
8009
- i1.normalized_key
8010
- FROM identities i1
8011
- INNER JOIN identities i2
8012
- ON i1.normalized_key = i2.normalized_key
8013
- AND i1.contact_id < i2.contact_id
8014
- INNER JOIN contacts c1 ON c1.id = i1.contact_id
8015
- INNER JOIN contacts c2 ON c2.id = i2.contact_id
8016
- WHERE i1.removed_at IS NULL
8017
- AND i2.removed_at IS NULL
8018
- AND c1.deleted_at IS NULL
8019
- AND c2.deleted_at IS NULL
8020
- AND c1.lifecycle_state != 'merged'
8021
- AND c2.lifecycle_state != 'merged'`
8022
- ).all();
8023
- for (const match of exactMatches) {
8024
- if (allowedIds !== null && (!allowedIds.has(match.left_contact_id) || !allowedIds.has(match.right_contact_id))) {
8025
- continue;
8026
- }
8027
- pairs.set(pairKey(match.left_contact_id, match.right_contact_id), {
8028
- leftContactId: match.left_contact_id,
8029
- rightContactId: match.right_contact_id,
8030
- matchReason: `exact identity ${match.normalized_key}`,
8031
- matchScore: 1
8032
- });
8033
- }
8034
- if (filters?.exactOnly !== true) {
8035
- for (let index = 0; index < contacts.length; index += 1) {
8036
- const left = contacts[index];
8037
- if (left === void 0 || allowedIds !== null && !allowedIds.has(left.id)) {
8038
- continue;
8039
- }
8040
- const leftName = normalizeName(left.name);
8041
- for (let offsetIndex = index + 1; offsetIndex < contacts.length; offsetIndex += 1) {
8042
- const right = contacts[offsetIndex];
8043
- if (right === void 0 || allowedIds !== null && !allowedIds.has(right.id)) {
8044
- continue;
8045
- }
8046
- const score = fuzzyNameScore(leftName, normalizeName(right.name));
8047
- if (score <= 0) {
8048
- continue;
8049
- }
8050
- const key = pairKey(left.id, right.id);
8051
- if (!pairs.has(key)) {
8052
- pairs.set(key, {
8053
- leftContactId: left.id,
8054
- rightContactId: right.id,
8055
- matchReason: "name similarity",
8056
- matchScore: score
8057
- });
8058
- }
8059
- }
8060
- }
8061
- }
8062
- const dismissedRows = db.prepare("SELECT left_id, right_id FROM dismissed_duplicates").all();
8063
- const dismissedSet = new Set(
8064
- dismissedRows.map((r) => pairKey(r.left_id, r.right_id))
8065
- );
8066
- const includeDismissed = filters?.includeDismissed === true;
8067
- for (const candidate of pairs.values()) {
8068
- if (dismissedSet.has(
8069
- pairKey(candidate.leftContactId, candidate.rightContactId)
8070
- )) {
8071
- candidate.dismissed = true;
8072
- }
8073
- }
8074
- const minScore = filters?.minScore ?? 0;
8075
- return [...pairs.values()].filter((candidate) => {
8076
- if (candidate.matchScore < minScore) return false;
8077
- if (candidate.dismissed === true && !includeDismissed) return false;
8078
- return true;
8079
- }).sort((left, right) => {
8080
- const leftExact = left.matchScore === 1 ? 1 : 0;
8081
- const rightExact = right.matchScore === 1 ? 1 : 0;
8082
- return rightExact - leftExact || right.matchScore - left.matchScore;
8083
- }).slice(offset, offset + limit);
9085
+ primaryIdentity: r.primary_identity ?? null
9086
+ };
9087
+ });
8084
9088
  }
8085
- var init_list_duplicate_candidates = __esm({
8086
- "src/contacts/list_duplicate_candidates.ts"() {
9089
+ var init_list_contacts = __esm({
9090
+ "src/contacts/list_contacts.ts"() {
8087
9091
  init_assert_default_ordering();
8088
9092
  init_resolve_list_limit_offset();
9093
+ init_contact_attribute_clauses();
9094
+ init_mappers4();
8089
9095
  }
8090
9096
  });
8091
9097
 
@@ -8113,12 +9119,12 @@ function listUpcomingDates(db, filters, options) {
8113
9119
  }
8114
9120
  const clauses = ["e.deleted_at IS NULL", "e.type = 'date_anchor'"];
8115
9121
  const params = [];
8116
- const since = options?.since ?? utcStartOfDayMs(Date.now());
9122
+ const since = options?.since ?? Date.parse(`${localCalendarDate(options?.now ?? Date.now())}T00:00:00Z`);
8117
9123
  clauses.push("uo.occurs_on >= ?");
8118
9124
  params.push(since);
8119
9125
  let maxOccurs = options?.until;
8120
9126
  if (filters?.horizonDays !== void 0) {
8121
- const anchor = options?.since ?? Date.now();
9127
+ const anchor = since;
8122
9128
  const horizonEnd = anchor + filters.horizonDays * 864e5;
8123
9129
  maxOccurs = maxOccurs === void 0 ? horizonEnd : Math.min(maxOccurs, horizonEnd);
8124
9130
  }
@@ -8170,9 +9176,9 @@ var init_list_upcoming_dates = __esm({
8170
9176
  init_mappers();
8171
9177
  init_validators();
8172
9178
  init_affinity_validation_error();
9179
+ init_parse_calendar_time();
8173
9180
  init_assert_default_ordering();
8174
9181
  init_resolve_list_limit_offset();
8175
- init_calendar();
8176
9182
  CONTACT_KINDS = /* @__PURE__ */ new Set([
8177
9183
  "human",
8178
9184
  "group",
@@ -8185,6 +9191,79 @@ var init_list_upcoming_dates = __esm({
8185
9191
  }
8186
9192
  });
8187
9193
 
9194
+ // src/events/event_discovery.ts
9195
+ function searchEvents(db, query, options) {
9196
+ const { limit, offset } = resolveListLimitOffset(options);
9197
+ const tokens = foldSearchText(query).split(/\s+/).filter(Boolean);
9198
+ if (!tokens.length) return [];
9199
+ const rows = db.prepare(
9200
+ "SELECT id,type,occurred_at,occurred_on,CAST(summary AS BLOB) AS summary,CAST(content AS BLOB) AS content,external_source,external_id,channel FROM events WHERE deleted_at IS NULL ORDER BY occurred_at DESC,id DESC"
9201
+ ).all();
9202
+ return rows.flatMap((row) => {
9203
+ const fields = {
9204
+ summary: decodeEventText(row.summary),
9205
+ content: row.content === null ? null : decodeEventText(row.content),
9206
+ source: row.external_source,
9207
+ id: row.external_id,
9208
+ channel: row.channel
9209
+ };
9210
+ const folded = Object.entries(fields).map(
9211
+ ([key, value]) => [key, foldSearchText(value ?? "")]
9212
+ );
9213
+ if (!tokens.every((t) => folded.some(([, v]) => v.includes(t))))
9214
+ return [];
9215
+ return [
9216
+ {
9217
+ kind: "event",
9218
+ id: row.id,
9219
+ type: row.type,
9220
+ title: deriveEventSummary(decodeEventText(row.summary)),
9221
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
9222
+ matchedOn: folded.filter(([, v]) => tokens.some((t) => v.includes(t))).map(([k]) => k),
9223
+ ...row.external_source && row.external_id ? {
9224
+ externalRef: {
9225
+ source: row.external_source,
9226
+ id: row.external_id
9227
+ }
9228
+ } : {}
9229
+ }
9230
+ ];
9231
+ }).slice(offset, offset + limit);
9232
+ }
9233
+ function getEventThread(db, source, id, options) {
9234
+ const { limit, offset } = resolveListLimitOffset(options);
9235
+ const root = getEventByExternalRef(db, source, id);
9236
+ if (!root) return [];
9237
+ const rows = db.prepare(`WITH RECURSIVE thread(id) AS (
9238
+ SELECT ? UNION SELECT e.id FROM events e JOIN thread t JOIN events p ON p.id=t.id
9239
+ WHERE e.deleted_at IS NULL AND (e.parent_event_id=p.id OR (e.parent_source=p.external_source AND e.parent_ref_id=p.external_id) OR (e.thread_source=? AND e.thread_id=?))
9240
+ ) SELECT e.id FROM events e JOIN thread t ON e.id=t.id WHERE e.deleted_at IS NULL AND (? IS NULL OR e.channel=?) AND (? IS NULL OR e.type IN (SELECT value FROM json_each(?))) ORDER BY e.occurred_at,e.id LIMIT ? OFFSET ?`).all(
9241
+ root.id,
9242
+ root.externalRef?.source ?? null,
9243
+ root.externalRef?.id ?? null,
9244
+ options?.channel ?? null,
9245
+ options?.channel?.trim().toLowerCase() ?? null,
9246
+ options?.eventTypes === void 0 ? null : JSON.stringify(options.eventTypes),
9247
+ options?.eventTypes === void 0 ? null : JSON.stringify(options.eventTypes),
9248
+ limit,
9249
+ offset
9250
+ );
9251
+ return batchLoadEventRecords(
9252
+ db,
9253
+ rows.map((r) => r.id),
9254
+ options?.includeContent ?? false
9255
+ );
9256
+ }
9257
+ var init_event_discovery = __esm({
9258
+ "src/events/event_discovery.ts"() {
9259
+ init_fold_search_text();
9260
+ init_resolve_list_limit_offset();
9261
+ init_event_metadata();
9262
+ init_loaders();
9263
+ init_mappers();
9264
+ }
9265
+ });
9266
+
8188
9267
  // src/graph/get_affinity_chart.ts
8189
9268
  function getAffinityChart(db, options) {
8190
9269
  const contactClauses = [
@@ -8397,21 +9476,6 @@ var init_list_progression_readiness = __esm({
8397
9476
  });
8398
9477
 
8399
9478
  // src/links/list_radar.ts
8400
- function deriveRadarReason(drift, recency, normalizedRank3) {
8401
- if (drift > 0.5) {
8402
- return "Significantly overdue for contact";
8403
- }
8404
- if (drift > 0.2) {
8405
- return "Drifting \u2014 approaching overdue";
8406
- }
8407
- if (recency < 0.3 && normalizedRank3 > 0.3) {
8408
- return "Important link losing recency";
8409
- }
8410
- if (normalizedRank3 > 0.5 && recency < 0.5) {
8411
- return "High-rank link needs attention";
8412
- }
8413
- return "Routine check-in recommended";
8414
- }
8415
9479
  function listRadar(db, filters, options) {
8416
9480
  assertDefaultOrdering("listRadar", options);
8417
9481
  const ownerId = findOwnerContactId(db);
@@ -8419,7 +9483,10 @@ function listRadar(db, filters, options) {
8419
9483
  return [];
8420
9484
  }
8421
9485
  const now = resolveNow(options?.now);
8422
- const overdue = `EXISTS (SELECT 1 FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND oc.due_at < ${now})`;
9486
+ const today = localCalendarDate(now);
9487
+ const lastContact = `(SELECT MAX(le.occurred_at) FROM link_event_effects le JOIN events ev ON ev.id = le.event_id WHERE le.link_id = l.id AND le.counts_for_recency = 1 AND ev.deleted_at IS NULL AND (le.occurred_at <= ${now} OR ev.occurred_on <= '${today}'))`;
9488
+ const drift = `(${lastContact} IS NOT NULL AND (${now} - ${lastContact}) > 1.5 * COALESCE(l.cadence_days, 1) * 86400000)`;
9489
+ const overdue = `EXISTS (SELECT 1 FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND (CASE WHEN oc.due_on IS NOT NULL AND oc.due_at = CAST(strftime('%s', oc.due_on) AS INTEGER) * 1000 + 86399999 THEN oc.due_on < '${today}' ELSE oc.due_at < ${now} END))`;
8423
9490
  const { limit, offset } = resolveListLimitOffset(options);
8424
9491
  const clauses = [
8425
9492
  "l.removed_at IS NULL",
@@ -8444,10 +9511,23 @@ function listRadar(db, filters, options) {
8444
9511
  if (options?.includeObserved === false) {
8445
9512
  clauses.push("l.kind != 'observed'");
8446
9513
  }
9514
+ if (options?.includeDormant !== true)
9515
+ clauses.push("c_to.lifecycle_state != 'dormant'");
9516
+ if (filters?.includeLost !== true)
9517
+ clauses.push("c_to.lifecycle_state != 'lost'");
9518
+ const other = `CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END`;
9519
+ clauses.push(`(${lastContact} IS NOT NULL OR ${overdue} OR NOT EXISTS (
9520
+ SELECT 1 FROM event_participants ep JOIN events ev ON ev.id=ep.event_id
9521
+ WHERE ep.contact_id=${other} AND ev.deleted_at IS NULL
9522
+ AND (ev.affects_bond=0 OR (ev.type='observation' AND ev.significance<=2) OR json_extract(ev.provenance_json, '$.ambient')=1 OR ep.directionality='owner_initiated' OR EXISTS (SELECT 1 FROM event_participants op WHERE op.event_id=ev.id AND op.contact_id=${ownerId} AND op.directionality='owner_initiated'))
9523
+ ))`);
8447
9524
  const where = clauses.join(" AND ");
8448
9525
  const rows = db.prepare(
8449
9526
  `SELECT
8450
9527
  l.id,
9528
+ l.cadence_days,
9529
+ ${lastContact} AS last_contact,
9530
+ ${drift} AS drift,
8451
9531
  ${overdue} AS overdue_commitment,
8452
9532
  l.from_contact_id,
8453
9533
  l.to_contact_id,
@@ -8461,30 +9541,53 @@ function listRadar(db, filters, options) {
8461
9541
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
8462
9542
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
8463
9543
  WHERE ${where}
8464
- ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
9544
+ ORDER BY (${overdue} OR ${drift}) DESC, ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
8465
9545
  LIMIT ? OFFSET ?`
8466
9546
  ).all(...params, limit, offset);
8467
- return rows.map((row) => ({
8468
- linkId: row.id,
8469
- contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
8470
- reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
8471
- driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
8472
- recencyScore: row.recency_score ?? 0,
8473
- normalizedRank: row.normalized_rank ?? 0,
8474
- trust: row.trust ?? 0,
8475
- recommendedReason: deriveRadarReason(
8476
- row.drift_priority ?? 0,
8477
- row.recency_score ?? 0,
8478
- row.normalized_rank ?? 0
8479
- )
8480
- }));
9547
+ return rows.map((row) => {
9548
+ const contactId = row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id;
9549
+ const promise = row.overdue_commitment ? db.prepare(
9550
+ `SELECT e.summary, oc.due_at, oc.due_on FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = ? AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND ${OVERDUE_SQL} ORDER BY oc.due_at LIMIT 1`
9551
+ ).get(contactId, today, now) : void 0;
9552
+ const reasons = [
9553
+ ...row.overdue_commitment ? ["overdue_commitment"] : [],
9554
+ ...row.drift ? ["drift"] : []
9555
+ ];
9556
+ const snippets = [
9557
+ ...promise ? [
9558
+ `Overdue promise: ${promise.summary} (due ${promise.due_on ?? new Date(promise.due_at).toISOString().slice(0, 10)})`
9559
+ ] : [],
9560
+ ...row.drift ? [
9561
+ `No contact for ${Math.floor((now - (row.last_contact ?? now)) / 864e5)} days (cadence ${row.cadence_days ?? 1} days)`
9562
+ ] : []
9563
+ ];
9564
+ const elapsed = row.last_contact === null ? null : Math.max(0, (now - row.last_contact) / 864e5);
9565
+ const currentDrift = elapsed === null ? 0 : driftPriority({
9566
+ daysSinceLastMeaningfulEvent: elapsed,
9567
+ cadenceDays: row.cadence_days ?? 1,
9568
+ trust: row.trust ?? 0,
9569
+ rank: row.rank ?? 0
9570
+ });
9571
+ return {
9572
+ linkId: row.id,
9573
+ contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
9574
+ reasons,
9575
+ driftPriority: row.overdue_commitment ? Math.max(1, currentDrift) : currentDrift,
9576
+ recencyScore: elapsed === null ? 0 : Math.max(0, 1 - elapsed / Math.max(row.cadence_days ?? 1, 1)),
9577
+ normalizedRank: row.normalized_rank ?? 0,
9578
+ trust: row.trust ?? 0,
9579
+ recommendedReason: snippets.join("; ") || "Routine check-in recommended"
9580
+ };
9581
+ });
8481
9582
  }
8482
9583
  var init_list_radar = __esm({
8483
9584
  "src/links/list_radar.ts"() {
8484
9585
  init_queries();
9586
+ init_parse_calendar_time();
8485
9587
  init_assert_default_ordering();
8486
9588
  init_resolve_list_limit_offset();
8487
9589
  init_resolve_now();
9590
+ init_mechanics();
8488
9591
  }
8489
9592
  });
8490
9593
 
@@ -8567,13 +9670,40 @@ function enrichReviewItem(db, item) {
8567
9670
  result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
8568
9671
  }
8569
9672
  if (Array.isArray(item.participants)) {
8570
- result.counterpartyNames = item.participants.map(
8571
- (p) => name(p.contactId)
9673
+ result.participants = item.participants.map(
9674
+ (p) => ({
9675
+ id: p.contactId,
9676
+ name: name(p.contactId),
9677
+ role: p.role
9678
+ })
8572
9679
  );
8573
- result.subtitle = [
8574
- item.subtitle,
8575
- result.counterpartyNames.join(", ")
8576
- ].filter(Boolean).join(" \xB7 ");
9680
+ for (const key of ["title", "subtitle", "snippet", "state", "eventId"])
9681
+ result[key] = void 0;
9682
+ }
9683
+ if (result.momentKind === null) result.momentKind = void 0;
9684
+ if (result.eventId === result.id) result.eventId = void 0;
9685
+ if (result.linkId === result.id) result.linkId = void 0;
9686
+ if (result.title === result.summary) result.title = void 0;
9687
+ if (item.kind === "link" && item.occursAt !== void 0) {
9688
+ for (const key of [
9689
+ "affinity",
9690
+ "trust",
9691
+ "rank",
9692
+ "recencyScore",
9693
+ "driftPriority",
9694
+ "readinessScore",
9695
+ "normalizedRank",
9696
+ "reciprocityScore",
9697
+ "radarScore",
9698
+ "subtitle",
9699
+ "snippet",
9700
+ "targetRef",
9701
+ "occursOnDate"
9702
+ ])
9703
+ result[key] = void 0;
9704
+ } else if (item.kind === "link") {
9705
+ result.recommendedReason = void 0;
9706
+ result.snippet = void 0;
8577
9707
  }
8578
9708
  if (typeof item.leftContactId === "number")
8579
9709
  result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
@@ -8610,6 +9740,8 @@ var init_tool_summary = __esm({
8610
9740
  // src/tools/review_affinity_tool.ts
8611
9741
  function buildReadOptions(input) {
8612
9742
  return {
9743
+ ...input.includeContent === void 0 ? {} : { includeContent: input.includeContent },
9744
+ ...input.channel === void 0 ? {} : { channel: input.channel },
8613
9745
  ...input.now === void 0 ? {} : { now: input.now },
8614
9746
  ...input.limit === void 0 ? {} : { limit: input.limit },
8615
9747
  ...input.offset === void 0 ? {} : { offset: input.offset },
@@ -8746,7 +9878,7 @@ function momentItem(moment) {
8746
9878
  id: moment.eventId,
8747
9879
  title: moment.summary,
8748
9880
  subtitle: moment.momentKind,
8749
- snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
9881
+ snippet: moment.occurredOn ?? new Date(moment.occurredAt).toISOString().slice(0, 10)
8750
9882
  };
8751
9883
  }
8752
9884
  function commitmentItem(commitment) {
@@ -8756,7 +9888,7 @@ function commitmentItem(commitment) {
8756
9888
  id: commitment.eventId,
8757
9889
  title: commitment.summary,
8758
9890
  subtitle: commitment.type,
8759
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
9891
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : commitment.dueOn ?? new Date(commitment.dueAt).toISOString().slice(0, 10)}`
8760
9892
  };
8761
9893
  }
8762
9894
  function radarItem(radar) {
@@ -8772,6 +9904,7 @@ function radarItem(radar) {
8772
9904
  function upcomingDateItem(date) {
8773
9905
  return {
8774
9906
  ...date,
9907
+ occursOn: new Date(date.occursOn).toISOString().slice(0, 10),
8775
9908
  occursAt: date.occursOn,
8776
9909
  occursAtIso: new Date(date.occursOn).toISOString(),
8777
9910
  kind: date.targetRef.kind,
@@ -8809,6 +9942,9 @@ function compactFilters(input) {
8809
9942
  );
8810
9943
  }
8811
9944
  function reviewAffinityToolHandler(db, rawInput) {
9945
+ return reviewPage(db, rawInput, false);
9946
+ }
9947
+ function reviewPage(db, rawInput, pageOnly) {
8812
9948
  const input = structuredClone(rawInput);
8813
9949
  const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
8814
9950
  if (inputError)
@@ -8823,6 +9959,41 @@ function reviewAffinityToolHandler(db, rawInput) {
8823
9959
  if (invalid) {
8824
9960
  return invalid;
8825
9961
  }
9962
+ if (!pageOnly && input.view !== "graph.chart") {
9963
+ const limit = input.limit ?? 50;
9964
+ const offset = input.offset ?? 0;
9965
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1e4 || !Number.isInteger(offset) || offset < 0)
9966
+ return toolFailure(
9967
+ "protocol",
9968
+ "invalid_input",
9969
+ "Invalid pagination.",
9970
+ "limit must be 1..10000; use offset to page through larger results; offset must be non-negative"
9971
+ );
9972
+ const all = [];
9973
+ let page;
9974
+ do {
9975
+ page = reviewPage(
9976
+ db,
9977
+ { ...input, limit: 250, offset: all.length },
9978
+ true
9979
+ );
9980
+ if (!page.ok) return page;
9981
+ all.push(...page.data.items);
9982
+ } while (page.data.items.length === 250);
9983
+ const items2 = all.slice(offset, offset + limit);
9984
+ return toolSuccess(
9985
+ summarizeCount(items2.length, "item"),
9986
+ {
9987
+ view: input.view,
9988
+ items: items2,
9989
+ count: items2.length,
9990
+ total: all.length,
9991
+ hasMore: offset + items2.length < all.length,
9992
+ appliedFilters: compactFilters(input)
9993
+ },
9994
+ { entities: items2.map(({ content: _content, ...item }) => item) }
9995
+ );
9996
+ }
8826
9997
  const options = buildReadOptions(input);
8827
9998
  let items = [];
8828
9999
  let chart;
@@ -8848,12 +10019,26 @@ function reviewAffinityToolHandler(db, rawInput) {
8848
10019
  {
8849
10020
  ...input.contactIds === void 0 ? {} : { contactIds: input.contactIds },
8850
10021
  ...input.exactOnly === void 0 ? {} : { exactOnly: input.exactOnly },
8851
- ...input.minScore === void 0 ? {} : { minScore: input.minScore },
10022
+ ...input.minScore === void 0 ? { minScore: 0.5 } : { minScore: input.minScore },
8852
10023
  ...input.includeDismissed === void 0 ? {} : { includeDismissed: input.includeDismissed }
8853
10024
  },
8854
10025
  options
8855
10026
  ).map(duplicateCandidateItem);
8856
10027
  break;
10028
+ case "events.thread": {
10029
+ if (!input.ref)
10030
+ return toolFailure(
10031
+ "protocol",
10032
+ "invalid_input",
10033
+ "Missing thread reference.",
10034
+ "ref is required for events.thread."
10035
+ );
10036
+ items = getEventThread(db, input.ref.source, input.ref.id, {
10037
+ ...options,
10038
+ ...input.eventTypes === void 0 ? {} : { eventTypes: input.eventTypes }
10039
+ }).map(toEventListToolItem);
10040
+ break;
10041
+ }
8857
10042
  case "events.contact_journal": {
8858
10043
  const locator = input.contact;
8859
10044
  if (locator === void 0) {
@@ -8995,6 +10180,7 @@ function reviewAffinityToolHandler(db, rawInput) {
8995
10180
  items = listRadar(
8996
10181
  db,
8997
10182
  {
10183
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
8998
10184
  ...input.kind === void 0 ? {} : { kind: input.kind },
8999
10185
  ...input.linkState === void 0 ? {} : { state: input.linkState }
9000
10186
  },
@@ -9044,6 +10230,14 @@ function reviewAffinityToolHandler(db, rawInput) {
9044
10230
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
9045
10231
  break;
9046
10232
  }
10233
+ if (input.includeContent && ["events.moments", "commitments.open"].includes(input.view))
10234
+ items = items.map((item) => {
10235
+ const content = getEventById(
10236
+ db,
10237
+ Number(item.eventId ?? item.id)
10238
+ )?.content;
10239
+ return content === void 0 ? item : { ...item, content };
10240
+ });
9047
10241
  items = items.map((item) => enrichReviewItem(db, item));
9048
10242
  return toolSuccess(
9049
10243
  summarizeCount(items.length, "item"),
@@ -9055,7 +10249,7 @@ function reviewAffinityToolHandler(db, rawInput) {
9055
10249
  ...chart === void 0 ? {} : { chart }
9056
10250
  },
9057
10251
  {
9058
- entities: items,
10252
+ entities: items.map(({ content: _content, ...item }) => item),
9059
10253
  warnings: items.length === 0 ? [
9060
10254
  toolWarning(
9061
10255
  "empty_result",
@@ -9072,6 +10266,8 @@ var init_review_affinity_tool = __esm({
9072
10266
  init_list_contacts();
9073
10267
  init_list_duplicate_candidates();
9074
10268
  init_list_upcoming_dates();
10269
+ init_event_discovery();
10270
+ init_event_metadata();
9075
10271
  init_get_contact_journal();
9076
10272
  init_list_moments();
9077
10273
  init_list_open_commitments();
@@ -9084,6 +10280,7 @@ var init_review_affinity_tool = __esm({
9084
10280
  init_get_merge_history();
9085
10281
  init_list_dismissed_duplicates();
9086
10282
  init_enrich_review_item();
10283
+ init_event_metadata_schema();
9087
10284
  init_tool_metadata();
9088
10285
  init_tool_names();
9089
10286
  init_tool_ref();
@@ -9127,15 +10324,21 @@ var init_review_affinity_tool = __esm({
9127
10324
  includeDismissed: "Whether dismissed pairs should be included in duplicate review (marked with dismissed flag).",
9128
10325
  since: "Optional lower timestamp bound for time-based views.",
9129
10326
  until: "Optional upper timestamp bound for time-based views.",
9130
- limit: "Optional result limit.",
10327
+ limit: "Optional result limit, 1..10000 (default 50); use offset for paging.",
9131
10328
  offset: "Optional result offset."
9132
10329
  },
9133
10330
  outputDescription: "Returns compact review items, a count, the applied filters, and the chart payload for graph review.",
9134
10331
  inputSchema: objectSchema(
9135
10332
  {
10333
+ ref: externalRefSchema,
10334
+ includeContent: booleanSchema(
10335
+ "Include verbatim content in event rows; defaults to false."
10336
+ ),
10337
+ channel: stringSchema("Filter events by channel."),
9136
10338
  view: enumSchema("Which review surface to return.", [
9137
10339
  "contacts.list",
9138
10340
  "contacts.duplicates",
10341
+ "events.thread",
9139
10342
  "events.contact_journal",
9140
10343
  "events.link_timeline",
9141
10344
  "events.moments",
@@ -9190,7 +10393,20 @@ var init_review_affinity_tool = __esm({
9190
10393
  includeArchived: booleanSchema("Whether archived links are included."),
9191
10394
  includeObserved: booleanSchema("Whether observed links are included."),
9192
10395
  eventTypes: arraySchema(
9193
- stringSchema("Event type."),
10396
+ enumSchema("Event type.", [
10397
+ "conversation",
10398
+ "activity",
10399
+ "gift",
10400
+ "support",
10401
+ "milestone",
10402
+ "observation",
10403
+ "conflict",
10404
+ "correction",
10405
+ "transaction",
10406
+ "promise",
10407
+ "agreement",
10408
+ "date_anchor"
10409
+ ]),
9194
10410
  "Optional event-type filter."
9195
10411
  ),
9196
10412
  momentKind: enumSchema("Optional moment kind filter.", [
@@ -9213,13 +10429,17 @@ var init_review_affinity_tool = __esm({
9213
10429
  ]),
9214
10430
  horizonDays: integerSchema("Optional horizon-days filter."),
9215
10431
  exactOnly: booleanSchema("Whether duplicate review is exact-only."),
9216
- minScore: numberSchema("Optional duplicate minimum score."),
10432
+ minScore: numberSchema(
10433
+ "Optional duplicate minimum score (default 0.5; use 0 for all candidates)."
10434
+ ),
9217
10435
  includeDismissed: booleanSchema(
9218
10436
  "Whether dismissed pairs are included in duplicate review (marked with dismissed flag)."
9219
10437
  ),
9220
10438
  since: integerSchema("Optional lower timestamp bound."),
9221
10439
  until: integerSchema("Optional upper timestamp bound."),
9222
- limit: integerSchema("Optional result limit."),
10440
+ limit: integerSchema(
10441
+ "Optional result limit, 1..10000 (default 50); use offset for paging."
10442
+ ),
9223
10443
  offset: integerSchema("Optional result offset.")
9224
10444
  },
9225
10445
  ["view"],
@@ -9232,7 +10452,7 @@ var init_review_affinity_tool = __esm({
9232
10452
 
9233
10453
  // src/contacts/search_contacts.ts
9234
10454
  function searchContacts(db, query, filters, options) {
9235
- const q = foldSearchText(query.trim());
10455
+ const q = foldSearchText(query.trim().replace(/^@/, ""));
9236
10456
  if (!q) return [];
9237
10457
  assertDefaultOrdering("searchContacts", options);
9238
10458
  const { limit, offset } = resolveListLimitOffset(options);
@@ -9252,6 +10472,7 @@ function searchContacts(db, query, filters, options) {
9252
10472
  const tokens = q.split(/\s+/);
9253
10473
  const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
9254
10474
  const digits = q.replace(/\D/g, "").replace(/^00/, "");
10475
+ const suffix = digits.replace(/^0/, "");
9255
10476
  const attributeFilters = contactAttributeClauses(filters);
9256
10477
  const clauses = [
9257
10478
  "deleted_at IS NULL",
@@ -9287,7 +10508,7 @@ function searchContacts(db, query, filters, options) {
9287
10508
  )
9288
10509
  ];
9289
10510
  const phoneFields = phone ? values.filter(
9290
- (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
10511
+ (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits) || field.source === "identity:phone" && suffix.length >= 6 && field.text.replace(/\D/g, "").endsWith(suffix)
9291
10512
  ) : [];
9292
10513
  if (!phoneFields.length && !tokens.every(
9293
10514
  (token) => values.some((field) => field.text.includes(token))
@@ -9326,6 +10547,9 @@ var init_search_contacts = __esm({
9326
10547
 
9327
10548
  // src/tools/search_affinity_tool.ts
9328
10549
  function searchAffinityToolHandler(db, rawInput) {
10550
+ return searchPage(db, rawInput, false);
10551
+ }
10552
+ function searchPage(db, rawInput, pageOnly) {
9329
10553
  const input = structuredClone(rawInput);
9330
10554
  const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
9331
10555
  if (inputError)
@@ -9336,11 +10560,53 @@ function searchAffinityToolHandler(db, rawInput) {
9336
10560
  inputError
9337
10561
  );
9338
10562
  return withToolHandling(() => {
10563
+ if (!pageOnly) {
10564
+ const limit = input.limit ?? 50;
10565
+ const offset = input.offset ?? 0;
10566
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
10567
+ return toolFailure(
10568
+ "protocol",
10569
+ "invalid_input",
10570
+ "Invalid pagination.",
10571
+ "limit must be 1..250; offset must be non-negative"
10572
+ );
10573
+ const all = [];
10574
+ const scopes = input.scope === "all" ? ["contacts", "events"] : [input.scope ?? "contacts"];
10575
+ for (const scope of scopes) {
10576
+ let page;
10577
+ let nextOffset = 0;
10578
+ do {
10579
+ page = searchPage(
10580
+ db,
10581
+ { ...input, scope, limit: 250, offset: nextOffset },
10582
+ true
10583
+ );
10584
+ if (!page.ok) return page;
10585
+ all.push(...page.data.items);
10586
+ nextOffset += page.data.count;
10587
+ } while (page.data.count === 250);
10588
+ }
10589
+ const items2 = all.slice(offset, offset + limit);
10590
+ return toolSuccess(
10591
+ summarizeCount(
10592
+ items2.length,
10593
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10594
+ ),
10595
+ {
10596
+ query: input.query,
10597
+ items: items2,
10598
+ count: items2.length,
10599
+ total: all.length,
10600
+ hasMore: offset + items2.length < all.length
10601
+ },
10602
+ { entities: items2 }
10603
+ );
10604
+ }
9339
10605
  const options = {
9340
10606
  ...input.limit === void 0 ? {} : { limit: input.limit },
9341
10607
  ...input.offset === void 0 ? {} : { offset: input.offset }
9342
10608
  };
9343
- const rows = searchContacts(
10609
+ const rows = input.scope === "events" ? [] : searchContacts(
9344
10610
  db,
9345
10611
  input.query,
9346
10612
  {
@@ -9354,9 +10620,15 @@ function searchAffinityToolHandler(db, rawInput) {
9354
10620
  },
9355
10621
  options
9356
10622
  );
9357
- const items = rows.map(toContactListToolItem);
10623
+ const items = [
10624
+ ...rows.map(toContactListToolItem),
10625
+ ...input.scope === "events" || input.scope === "all" ? searchEvents(db, input.query, options) : []
10626
+ ];
9358
10627
  return toolSuccess(
9359
- summarizeCount(items.length, "contact"),
10628
+ summarizeCount(
10629
+ items.length,
10630
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10631
+ ),
9360
10632
  {
9361
10633
  query: input.query,
9362
10634
  items,
@@ -9372,6 +10644,7 @@ var searchAffinityTool;
9372
10644
  var init_search_affinity_tool = __esm({
9373
10645
  "src/tools/search_affinity_tool.ts"() {
9374
10646
  init_search_contacts();
10647
+ init_event_discovery();
9375
10648
  init_tool_metadata();
9376
10649
  init_tool_names();
9377
10650
  init_tool_ref();
@@ -9404,6 +10677,11 @@ var init_search_affinity_tool = __esm({
9404
10677
  outputDescription: "Returns compact contact hits, the original query, and the hit count.",
9405
10678
  inputSchema: objectSchema(
9406
10679
  {
10680
+ scope: enumSchema("Search scope; defaults to contacts.", [
10681
+ "contacts",
10682
+ "events",
10683
+ "all"
10684
+ ]),
9407
10685
  query: stringSchema("Free-text or natural-key query."),
9408
10686
  kind: enumSchema("Optional contact kind filter.", [
9409
10687
  "human",
@@ -9454,6 +10732,7 @@ var init_tool_registry = __esm({
9454
10732
  init_manage_contact_tool();
9455
10733
  init_manage_date_anchor_tool();
9456
10734
  init_manage_duplicate_dismissal_tool();
10735
+ init_manage_event_tool();
9457
10736
  init_manage_identity_tool();
9458
10737
  init_manage_relationship_tool();
9459
10738
  init_merge_contacts_tool();
@@ -9472,77 +10751,50 @@ var init_tool_registry = __esm({
9472
10751
  recordEventTool,
9473
10752
  manageCommitmentTool,
9474
10753
  manageDateAnchorTool,
9475
- manageAttributeTool
10754
+ manageAttributeTool,
10755
+ manageEventTool
9476
10756
  ];
9477
10757
  }
9478
10758
  });
9479
10759
 
9480
10760
  // src/surface/plain_dates.ts
9481
- function parsePlainDue(raw, now = Date.now(), endOfDay = true) {
9482
- const invalid = () => new CliUsageError(
9483
- `Invalid timestamp "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d, -2d or an ISO date-time.`
9484
- );
9485
- if (/^-?\d+$/.test(raw)) {
9486
- const value = Number(raw);
9487
- if (Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()))
9488
- return value;
9489
- throw invalid();
9490
- }
9491
- const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
9492
- const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
9493
- let date;
9494
- if (iso || european) {
9495
- const year = Number(iso?.[1] ?? european?.[3]);
9496
- const month = Number(iso?.[2] ?? european?.[2]);
9497
- const day = Number(iso?.[3] ?? european?.[1]);
9498
- date = /* @__PURE__ */ new Date(0);
9499
- date.setFullYear(year, month - 1, day);
9500
- date.setHours(
9501
- endOfDay ? 23 : 0,
9502
- endOfDay ? 59 : 0,
9503
- endOfDay ? 59 : 0,
9504
- endOfDay ? 999 : 0
9505
- );
9506
- if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
9507
- throw invalid();
9508
- } else if (raw === "today" || raw === "tomorrow" || raw === "yesterday" || /^[+-]\d+d$/.test(raw)) {
9509
- date = new Date(now);
9510
- date.setDate(
9511
- date.getDate() + (raw === "today" ? 0 : raw === "tomorrow" ? 1 : raw === "yesterday" ? -1 : Number(raw.slice(0, -1)))
9512
- );
9513
- date.setHours(
9514
- endOfDay ? 23 : 0,
9515
- endOfDay ? 59 : 0,
9516
- endOfDay ? 59 : 0,
9517
- endOfDay ? 999 : 0
9518
- );
9519
- } else {
9520
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
9521
- raw
9522
- ))
9523
- throw invalid();
9524
- parsePlainDue(raw.slice(0, 10), now);
9525
- date = new Date(raw);
9526
- }
9527
- if (!Number.isFinite(date.getTime())) throw invalid();
9528
- return date.getTime();
9529
- }
9530
- function parsePlainStart(raw, now = Date.now()) {
9531
- return parsePlainDue(raw, now, false);
9532
- }
9533
- function normalizeTimeInputs(value, now = Date.now()) {
10761
+ function normalizeTimeInputs(value, now = Date.now(), inPatch = false) {
9534
10762
  if (Array.isArray(value))
9535
10763
  return value.map((item) => normalizeTimeInputs(item, now));
9536
10764
  if (!value || typeof value !== "object") return value;
9537
- return Object.fromEntries(
9538
- Object.entries(value).map(([key, item]) => [
9539
- key,
9540
- typeof item === "string" && (key.endsWith("At") || ["asOf", "now"].includes(key)) ? key === "dueAt" ? parsePlainDue(item, now) : parsePlainStart(item, now) : normalizeTimeInputs(item, now)
9541
- ])
9542
- );
10765
+ const result = {};
10766
+ for (const [key, item] of Object.entries(value)) {
10767
+ if (key === "occurredAt" && item === null)
10768
+ throw new CliUsageError(
10769
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
10770
+ );
10771
+ if (typeof item === "string" && (key.endsWith("At") || ["asOf", "now", "since", "until"].includes(key))) {
10772
+ let parsed;
10773
+ try {
10774
+ parsed = parseCalendarTime(item, key, now);
10775
+ } catch (error) {
10776
+ throw new CliUsageError(
10777
+ `Invalid timestamp "${item}". ${error.message}`
10778
+ );
10779
+ }
10780
+ if (!inPatch && ["occurredAt", "dueAt"].includes(key) && typeof item === "string" && /\.\d{4,}/.test(item)) {
10781
+ const provenance = result.provenance ?? value.provenance;
10782
+ result.provenance = {
10783
+ ...provenance && typeof provenance === "object" ? provenance : {},
10784
+ [`raw${key[0]?.toUpperCase()}${key.slice(1)}`]: item
10785
+ };
10786
+ }
10787
+ result[key] = parsed.timestamp;
10788
+ if (!inPatch && ["occurredAt", "dueAt"].includes(key))
10789
+ result[key.replace(/At$/, "On")] = parsed.day;
10790
+ } else if (key !== "provenance" || result.provenance === void 0)
10791
+ result[key] = key === "provenance" ? item : normalizeTimeInputs(item, now, key === "patch");
10792
+ }
10793
+ return result;
9543
10794
  }
9544
10795
  var init_plain_dates = __esm({
9545
10796
  "src/surface/plain_dates.ts"() {
10797
+ init_parse_calendar_time();
9546
10798
  init_cli_args();
9547
10799
  }
9548
10800
  });
@@ -9575,6 +10827,35 @@ var init_timestamp_schema = __esm({
9575
10827
  });
9576
10828
 
9577
10829
  // src/surface/affinity_surface_tools.ts
10830
+ function surfaceProperties(tool) {
10831
+ return {
10832
+ ...tool.inputSchema.properties,
10833
+ ...tool.name === "review_affinity" ? {
10834
+ includeContent: {
10835
+ type: "boolean",
10836
+ description: "Deprecated alias for --with-content; includes verbatim event content."
10837
+ }
10838
+ } : {},
10839
+ ...["inspect_affinity_item", "review_affinity", "manage_event"].includes(
10840
+ tool.name
10841
+ ) ? {
10842
+ refSource: {
10843
+ type: "string",
10844
+ description: "External reference source."
10845
+ },
10846
+ refId: {
10847
+ type: "string",
10848
+ description: "External reference id."
10849
+ }
10850
+ } : {},
10851
+ ...tool.name === "review_affinity" ? {
10852
+ withContent: {
10853
+ type: "boolean",
10854
+ description: "Include verbatim event content."
10855
+ }
10856
+ } : {}
10857
+ };
10858
+ }
9578
10859
  var affinitySurfaceTools;
9579
10860
  var init_affinity_surface_tools = __esm({
9580
10861
  "src/surface/affinity_surface_tools.ts"() {
@@ -9586,17 +10867,31 @@ var init_affinity_surface_tools = __esm({
9586
10867
  affinitySurfaceTools = affinityTools.map((tool) => ({
9587
10868
  name: tool.name,
9588
10869
  description: tool.description,
9589
- whenToUse: tool.whenToUse,
9590
- ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
9591
10870
  readOnly: tool.readOnly,
9592
10871
  sideEffects: tool.sideEffects,
9593
10872
  inputSchema: {
9594
- ...timestampSchema({ ...tool.inputSchema, type: "object" }),
10873
+ ...timestampSchema({
10874
+ ...tool.inputSchema,
10875
+ properties: surfaceProperties(tool),
10876
+ type: "object"
10877
+ }),
9595
10878
  type: "object"
9596
10879
  },
9597
10880
  handler: (db, input) => {
9598
10881
  try {
9599
- return tool.handler(db, normalizeTimeInputs(input));
10882
+ const { withContent, refSource, refId, ...normalized } = input;
10883
+ if (withContent !== void 0) normalized.includeContent = withContent;
10884
+ if (refSource !== void 0 || refId !== void 0) {
10885
+ if (normalized.ref !== void 0)
10886
+ return toolFailure(
10887
+ "protocol",
10888
+ "invalid_input",
10889
+ "Invalid target.",
10890
+ "Provide ref or ref-source/ref-id, not both."
10891
+ );
10892
+ normalized.ref = { source: refSource, id: refId };
10893
+ }
10894
+ return tool.handler(db, normalizeTimeInputs(normalized));
9600
10895
  } catch (error) {
9601
10896
  if (error instanceof CliUsageError)
9602
10897
  return toolFailure(
@@ -9623,7 +10918,7 @@ function affinityMcpOptions(context) {
9623
10918
  context,
9624
10919
  annotations: (tool) => ({
9625
10920
  readOnlyHint: tool.readOnly ?? false,
9626
- destructiveHint: tool.name === "merge_contacts" || /remove|unset|undismiss/.test(
10921
+ destructiveHint: tool.name === "merge_contacts" || tool.name === "manage_event" || /remove|unset|undismiss/.test(
9627
10922
  String(tool.inputSchema.properties?.action?.enum ?? [])
9628
10923
  ),
9629
10924
  idempotentHint: tool.readOnly ?? false,
@@ -9639,13 +10934,99 @@ var init_affinity_mcp_options = __esm({
9639
10934
  }
9640
10935
  });
9641
10936
 
10937
+ // src/events/init_events_tables.ts
10938
+ function initEventsTables(db) {
10939
+ db.exec(`
10940
+ CREATE TABLE IF NOT EXISTS events (
10941
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10942
+ type TEXT NOT NULL,
10943
+ occurred_at INTEGER NOT NULL,
10944
+ summary TEXT NOT NULL,
10945
+ significance REAL NOT NULL DEFAULT 0,
10946
+ moment_kind TEXT,
10947
+ recurrence_kind TEXT,
10948
+ anchor_month INTEGER,
10949
+ anchor_day INTEGER,
10950
+ anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
10951
+ anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
10952
+ created_at INTEGER NOT NULL,
10953
+ updated_at INTEGER NOT NULL,
10954
+ deleted_at INTEGER,
10955
+ CHECK (length(trim(summary)) > 0),
10956
+ CHECK (
10957
+ type IN (
10958
+ 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
10959
+ 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
10960
+ )
10961
+ ),
10962
+ CHECK (
10963
+ (type = 'date_anchor' AND (
10964
+ (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
10965
+ OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
10966
+ ))
10967
+ OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
10968
+ ),
10969
+ CHECK (moment_kind IS NULL OR moment_kind IN (
10970
+ 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
10971
+ )),
10972
+ CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
10973
+ 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
10974
+ )),
10975
+ CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
10976
+ CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
10977
+ )
10978
+ `);
10979
+ db.exec(
10980
+ "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
10981
+ );
10982
+ db.exec(
10983
+ "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
10984
+ );
10985
+ const columns = db.prepare("PRAGMA table_info(events)").all();
10986
+ if (!columns.some((c) => c.name === "declared_moment_kind"))
10987
+ db.exec("ALTER TABLE events ADD COLUMN declared_moment_kind TEXT");
10988
+ if (!columns.some((c) => c.name === "affects_bond"))
10989
+ db.exec("ALTER TABLE events ADD COLUMN affects_bond INTEGER");
10990
+ if (!columns.some((c) => c.name === "content"))
10991
+ db.exec("ALTER TABLE events ADD COLUMN content TEXT");
10992
+ if (!columns.some((c) => c.name === "external_source"))
10993
+ db.exec("ALTER TABLE events ADD COLUMN external_source TEXT");
10994
+ if (!columns.some((c) => c.name === "external_id"))
10995
+ db.exec("ALTER TABLE events ADD COLUMN external_id TEXT");
10996
+ if (!columns.some((c) => c.name === "channel"))
10997
+ db.exec("ALTER TABLE events ADD COLUMN channel TEXT");
10998
+ if (!columns.some((c) => c.name === "parent_source"))
10999
+ db.exec("ALTER TABLE events ADD COLUMN parent_source TEXT");
11000
+ if (!columns.some((c) => c.name === "parent_ref_id"))
11001
+ db.exec("ALTER TABLE events ADD COLUMN parent_ref_id TEXT");
11002
+ if (!columns.some((c) => c.name === "parent_event_id"))
11003
+ db.exec("ALTER TABLE events ADD COLUMN parent_event_id INTEGER");
11004
+ if (!columns.some((c) => c.name === "thread_source"))
11005
+ db.exec("ALTER TABLE events ADD COLUMN thread_source TEXT");
11006
+ if (!columns.some((c) => c.name === "thread_id"))
11007
+ db.exec("ALTER TABLE events ADD COLUMN thread_id TEXT");
11008
+ if (!columns.some((c) => c.name === "provenance_json"))
11009
+ db.exec("ALTER TABLE events ADD COLUMN provenance_json TEXT");
11010
+ db.exec(
11011
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external_ref ON events(external_source, external_id)"
11012
+ );
11013
+ if (!columns.some((column) => column.name === "occurred_on"))
11014
+ db.exec("ALTER TABLE events ADD COLUMN occurred_on TEXT");
11015
+ }
11016
+ var init_init_events_tables = __esm({
11017
+ "src/events/init_events_tables.ts"() {
11018
+ }
11019
+ });
11020
+
9642
11021
  // src/attributes/init_attributes_tables.ts
9643
11022
  function initAttributesTables(db) {
11023
+ initEventsTables(db);
9644
11024
  db.exec(`
9645
11025
  CREATE TABLE IF NOT EXISTS attributes (
9646
11026
  id INTEGER PRIMARY KEY AUTOINCREMENT,
9647
11027
  contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
9648
11028
  link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
11029
+ event_id INTEGER REFERENCES events (id) ON DELETE CASCADE,
9649
11030
  name TEXT NOT NULL,
9650
11031
  value TEXT,
9651
11032
  created_at INTEGER NOT NULL,
@@ -9653,15 +11034,35 @@ function initAttributesTables(db) {
9653
11034
  deleted_at INTEGER,
9654
11035
  CHECK (length(trim(name)) > 0),
9655
11036
  CHECK (
9656
- (contact_id IS NOT NULL AND link_id IS NULL)
9657
- OR
9658
- (contact_id IS NULL AND link_id IS NOT NULL)
11037
+ (contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1
9659
11038
  )
9660
11039
  )
9661
11040
  `);
11041
+ const columns = db.prepare("PRAGMA table_info(attributes)").all();
11042
+ if (!columns.some((c) => c.name === "event_id")) {
11043
+ withTransaction(
11044
+ db,
11045
+ () => db.exec(`CREATE TABLE attributes_upgrade (
11046
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11047
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
11048
+ link_id INTEGER REFERENCES links(id) ON DELETE CASCADE,
11049
+ event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
11050
+ name TEXT NOT NULL CHECK(length(trim(name)) > 0), value TEXT,
11051
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER,
11052
+ CHECK ((contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1)
11053
+ );
11054
+ INSERT INTO attributes_upgrade (id,contact_id,link_id,name,value,created_at,updated_at,deleted_at)
11055
+ SELECT id,contact_id,link_id,name,value,created_at,updated_at,deleted_at FROM attributes;
11056
+ DROP TABLE attributes;
11057
+ ALTER TABLE attributes_upgrade RENAME TO attributes;`)
11058
+ );
11059
+ }
9662
11060
  db.exec(
9663
11061
  "CREATE INDEX IF NOT EXISTS idx_attributes_contact ON attributes(contact_id) WHERE deleted_at IS NULL"
9664
11062
  );
11063
+ db.exec(
11064
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_attributes_event_name_live ON attributes(event_id,name) WHERE deleted_at IS NULL AND event_id IS NOT NULL"
11065
+ );
9665
11066
  db.exec(
9666
11067
  "CREATE INDEX IF NOT EXISTS idx_attributes_link ON attributes(link_id) WHERE deleted_at IS NULL"
9667
11068
  );
@@ -9678,6 +11079,8 @@ function initAttributesTables(db) {
9678
11079
  }
9679
11080
  var init_init_attributes_tables = __esm({
9680
11081
  "src/attributes/init_attributes_tables.ts"() {
11082
+ init_init_events_tables();
11083
+ init_with_transaction();
9681
11084
  }
9682
11085
  });
9683
11086
 
@@ -9794,60 +11197,6 @@ var init_init_event_participants_tables = __esm({
9794
11197
  }
9795
11198
  });
9796
11199
 
9797
- // src/events/init_events_tables.ts
9798
- function initEventsTables(db) {
9799
- db.exec(`
9800
- CREATE TABLE IF NOT EXISTS events (
9801
- id INTEGER PRIMARY KEY AUTOINCREMENT,
9802
- type TEXT NOT NULL,
9803
- occurred_at INTEGER NOT NULL,
9804
- summary TEXT NOT NULL,
9805
- significance REAL NOT NULL DEFAULT 0,
9806
- moment_kind TEXT,
9807
- recurrence_kind TEXT,
9808
- anchor_month INTEGER,
9809
- anchor_day INTEGER,
9810
- anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
9811
- anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
9812
- created_at INTEGER NOT NULL,
9813
- updated_at INTEGER NOT NULL,
9814
- deleted_at INTEGER,
9815
- CHECK (length(trim(summary)) > 0),
9816
- CHECK (
9817
- type IN (
9818
- 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
9819
- 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
9820
- )
9821
- ),
9822
- CHECK (
9823
- (type = 'date_anchor' AND (
9824
- (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
9825
- OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
9826
- ))
9827
- OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
9828
- ),
9829
- CHECK (moment_kind IS NULL OR moment_kind IN (
9830
- 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
9831
- )),
9832
- CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
9833
- 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
9834
- )),
9835
- CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
9836
- CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
9837
- )
9838
- `);
9839
- db.exec(
9840
- "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
9841
- );
9842
- db.exec(
9843
- "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
9844
- );
9845
- }
9846
- var init_init_events_tables = __esm({
9847
- "src/events/init_events_tables.ts"() {
9848
- }
9849
- });
9850
-
9851
11200
  // src/events/init_open_commitments_tables.ts
9852
11201
  function initOpenCommitmentsTables(db) {
9853
11202
  db.exec(`
@@ -9867,6 +11216,9 @@ function initOpenCommitmentsTables(db) {
9867
11216
  )
9868
11217
  )
9869
11218
  `);
11219
+ const columns = db.prepare("PRAGMA table_info(open_commitments)").all();
11220
+ if (!columns.some((column) => column.name === "due_on"))
11221
+ db.exec("ALTER TABLE open_commitments ADD COLUMN due_on TEXT");
9870
11222
  }
9871
11223
  var init_init_open_commitments_tables = __esm({
9872
11224
  "src/events/init_open_commitments_tables.ts"() {
@@ -9939,6 +11291,21 @@ function initLinkEventEffectsTables(db) {
9939
11291
  db.exec(
9940
11292
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
9941
11293
  );
11294
+ for (const name of ["participant_json", "provenance_json"])
11295
+ if (!columns.some((c) => c.name === name))
11296
+ db.exec(`ALTER TABLE link_event_effects ADD COLUMN ${name} TEXT`);
11297
+ db.exec(
11298
+ "CREATE TABLE IF NOT EXISTS event_link_baselines (link_id INTEGER PRIMARY KEY REFERENCES links(id) ON DELETE CASCADE, state_json TEXT NOT NULL)"
11299
+ );
11300
+ db.exec(
11301
+ "CREATE TABLE IF NOT EXISTS event_link_state_changes (id INTEGER PRIMARY KEY AUTOINCREMENT, link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE, after_event_id INTEGER NOT NULL, state TEXT NOT NULL)"
11302
+ );
11303
+ db.exec(`INSERT OR IGNORE INTO event_link_baselines(link_id,state_json)
11304
+ SELECT l.id,json_object('rank',l.rank-COALESCE(SUM(e.rank_delta),0),'affinity',l.affinity-COALESCE(SUM(e.affinity_delta),0),'trust',l.trust-COALESCE(SUM(e.trust_delta),0),'state','active','cadence_days',NULL)
11305
+ FROM links l JOIN link_event_effects e ON e.link_id=l.id GROUP BY l.id`);
11306
+ db.exec(
11307
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id IN (SELECT id FROM events WHERE type IN ('promise', 'agreement'))"
11308
+ );
9942
11309
  db.exec(
9943
11310
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_moment ON link_event_effects(moment_kind, occurred_at DESC, impact_score DESC) WHERE moment_kind IS NOT NULL"
9944
11311
  );
@@ -10149,12 +11516,15 @@ async function openAffinityDb(path) {
10149
11516
  } finally {
10150
11517
  process.emitWarning = original;
10151
11518
  }
10152
- if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
11519
+ if (path !== ":memory:" && !path.startsWith("file:")) {
11520
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
11521
+ if (!existsSync(path)) closeSync(openSync(path, "wx", 384));
11522
+ }
10153
11523
  const db = new sqlite.DatabaseSync(path);
10154
11524
  try {
10155
- db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
11525
+ db.exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;");
10156
11526
  const context = db;
10157
- initAffinityTables(context);
11527
+ withTransaction(context, () => initAffinityTables(context));
10158
11528
  return context;
10159
11529
  } catch (error) {
10160
11530
  db.close();
@@ -10164,6 +11534,7 @@ async function openAffinityDb(path) {
10164
11534
  var init_open_affinity_db = __esm({
10165
11535
  "src/surface/open_affinity_db.ts"() {
10166
11536
  init_init_affinity_tables();
11537
+ init_with_transaction();
10167
11538
  }
10168
11539
  });
10169
11540
  function resolveAffinityDbPath(options = {}) {