@ghostpaw/affinity 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.0";
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,25 @@ 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.content != null) base.content = decodeEventText(row.content);
771
+ if (row.channel != null) base.channel = row.channel;
772
+ if (row.external_source != null && row.external_id != null)
773
+ base.externalRef = { source: row.external_source, id: row.external_id };
774
+ if (row.parent_source != null && row.parent_ref_id != null)
775
+ base.parentRef = { source: row.parent_source, id: row.parent_ref_id };
776
+ if (row.thread_source != null && row.thread_id != null)
777
+ base.threadRef = { source: row.thread_source, id: row.thread_id };
778
+ if (row.parent_event_id != null) base.parentEventId = row.parent_event_id;
779
+ if (row.type === "promise" || row.type === "agreement") {
780
+ base.dueAt = row.due_at ?? null;
781
+ base.dueOn = row.due_on ?? (row.due_at == null ? null : new Date(row.due_at).toISOString().slice(0, 10));
782
+ }
586
783
  if (row.recurrence_kind !== null) {
587
784
  base.recurrenceKind = row.recurrence_kind;
588
785
  base.anchorMonth = row.anchor_month;
@@ -612,10 +809,14 @@ function mapUpcomingRowToUpcomingDateRecord(row) {
612
809
  targetRef,
613
810
  recurrenceKind,
614
811
  occursOn: row.occurs_on,
615
- summary: row.summary,
812
+ occursOnDate: new Date(row.occurs_on).toISOString().slice(0, 10),
813
+ summary: decodeEventText(row.summary),
616
814
  significance: row.significance
617
815
  };
618
816
  }
817
+ function decodeEventText(value) {
818
+ return typeof value === "string" ? value : Buffer.from(value).toString("utf8");
819
+ }
619
820
  var init_mappers = __esm({
620
821
  "src/events/mappers.ts"() {
621
822
  init_affinity_invariant_error();
@@ -626,17 +827,17 @@ var init_mappers = __esm({
626
827
  // src/events/queries.ts
627
828
  function getEventRowById(db, id) {
628
829
  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
830
+ `SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary
633
831
  FROM events
634
832
  WHERE id = ? AND deleted_at IS NULL`
635
833
  ).get(id);
636
834
  if (row === void 0) {
637
835
  return null;
638
836
  }
639
- return row;
837
+ const event = row;
838
+ event.summary = decodeEventText(event.summary);
839
+ if (event.content != null) event.content = decodeEventText(event.content);
840
+ return event;
640
841
  }
641
842
  function findDuplicateDateAnchor(db, params) {
642
843
  const exclude = params.excludeEventId;
@@ -686,6 +887,7 @@ function findDuplicateDateAnchor(db, params) {
686
887
  }
687
888
  var init_queries2 = __esm({
688
889
  "src/events/queries.ts"() {
890
+ init_mappers();
689
891
  }
690
892
  });
691
893
 
@@ -708,19 +910,27 @@ function loadEventRecord(db, eventId) {
708
910
  throw new AffinityInvariantError("event not found");
709
911
  }
710
912
  const participants = loadEventParticipantViews(db, eventId);
711
- return mapEventRowToEventRecord(row, participants);
913
+ const record = mapEventRowToEventRecord(row, participants);
914
+ const due = db.prepare("SELECT due_at, due_on FROM open_commitments WHERE event_id = ?").get(eventId);
915
+ if (due) {
916
+ record.dueAt = due.due_at;
917
+ record.dueOn = due.due_on ?? (due.due_at === null ? null : new Date(due.due_at).toISOString().slice(0, 10));
918
+ }
919
+ enrichEventMetadata(db, record);
920
+ return record;
712
921
  }
713
- function batchLoadEventRecords(db, eventIds) {
922
+ function batchLoadEventRecords(db, eventIds, includeContent = false) {
714
923
  if (eventIds.length === 0) {
715
924
  return [];
716
925
  }
717
926
  const ph = eventIds.map(() => "?").join(", ");
718
927
  const eventRows = db.prepare(
719
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
928
+ `SELECT e.id, e.type, e.occurred_at, e.occurred_on, oc.due_at, oc.due_on, significance, moment_kind,
929
+ 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
930
  recurrence_kind, anchor_month, anchor_day,
721
931
  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`
932
+ 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
933
+ 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
934
  ).all(...eventIds);
725
935
  const partRows = db.prepare(
726
936
  `SELECT event_id, contact_id, role, directionality
@@ -744,7 +954,9 @@ function batchLoadEventRecords(db, eventIds) {
744
954
  for (const id of eventIds) {
745
955
  const row = rowById.get(id);
746
956
  if (row) {
747
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
957
+ const record = mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []);
958
+ enrichEventMetadata(db, record);
959
+ out.push(record);
748
960
  }
749
961
  }
750
962
  return out;
@@ -787,6 +999,7 @@ var init_loaders = __esm({
787
999
  "src/events/loaders.ts"() {
788
1000
  init_affinity_invariant_error();
789
1001
  init_affinity_not_found_error();
1002
+ init_event_metadata();
790
1003
  init_mappers();
791
1004
  init_queries2();
792
1005
  }
@@ -811,6 +1024,10 @@ function getContactJournal(db, contactId, options) {
811
1024
  clauses.push(`e.type IN (${ph})`);
812
1025
  params.push(...options.eventTypes);
813
1026
  }
1027
+ if (options?.channel !== void 0) {
1028
+ clauses.push("e.channel = ?");
1029
+ params.push(options.channel.trim().toLowerCase());
1030
+ }
814
1031
  const where = clauses.join(" AND ");
815
1032
  const rows = db.prepare(
816
1033
  `SELECT e.id FROM events e
@@ -821,7 +1038,8 @@ function getContactJournal(db, contactId, options) {
821
1038
  ).all(...params, limit, offset);
822
1039
  return batchLoadEventRecords(
823
1040
  db,
824
- rows.map((r) => r.id)
1041
+ rows.map((r) => r.id),
1042
+ options?.includeContent ?? false
825
1043
  );
826
1044
  }
827
1045
  var init_get_contact_journal = __esm({
@@ -832,6 +1050,78 @@ var init_get_contact_journal = __esm({
832
1050
  }
833
1051
  });
834
1052
 
1053
+ // src/lib/parse_calendar_time.ts
1054
+ function isCommitmentOverdue(dueAt, dueOn, now) {
1055
+ if (dueAt === null) return false;
1056
+ if (dueOn && dueAt === Date.parse(`${dueOn}T23:59:59.999Z`)) {
1057
+ return localCalendarDate(now) > dueOn;
1058
+ }
1059
+ return dueAt < now;
1060
+ }
1061
+ function localCalendarDate(now) {
1062
+ const d = new Date(now);
1063
+ return `${String(d.getFullYear()).padStart(4, "0")}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1064
+ }
1065
+ function parseCalendarTime(value, field = "time", now = Date.now()) {
1066
+ if (value == null && field.endsWith("occurredAt"))
1067
+ throw new AffinityValidationError(
1068
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
1069
+ );
1070
+ const fail = () => {
1071
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1072
+ };
1073
+ if (typeof value === "number" || typeof value === "string" && /^-?\d+$/.test(value.trim())) {
1074
+ const timestamp2 = Number(value);
1075
+ if (!Number.isSafeInteger(timestamp2) || !Number.isFinite(new Date(timestamp2).getTime()))
1076
+ return fail();
1077
+ return { timestamp: timestamp2, day: new Date(timestamp2).toISOString().slice(0, 10) };
1078
+ }
1079
+ if (typeof value !== "string") return fail();
1080
+ let text = value.trim();
1081
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text)) {
1082
+ text = text.replace(" ", "T");
1083
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(text)) text += "Z";
1084
+ }
1085
+ let rounding = 0;
1086
+ text = text.replace(
1087
+ /\.(\d{4,})(?=Z|[+-]\d{2}:\d{2}$)/,
1088
+ (_, digits) => {
1089
+ const ms = Math.round(Number(`0.${digits}`) * 1e3);
1090
+ rounding = ms === 1e3 ? 1 : 0;
1091
+ return `.${String(ms === 1e3 ? 999 : ms).padStart(3, "0")}`;
1092
+ }
1093
+ );
1094
+ if (/^(today|yesterday|tomorrow|[+-]\d+d)$/.test(text)) {
1095
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1096
+ const date = /* @__PURE__ */ new Date(`${localCalendarDate(now)}T00:00:00Z`);
1097
+ date.setUTCDate(date.getUTCDate() + offset);
1098
+ if (!Number.isFinite(date.getTime())) return fail();
1099
+ text = date.toISOString().slice(0, 10);
1100
+ }
1101
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
1102
+ if (european) text = `${european[3]}-${european[2]}-${european[1]}`;
1103
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(text);
1104
+ if (!dateOnly && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1105
+ text
1106
+ ))
1107
+ return fail();
1108
+ const day = text.slice(0, 10);
1109
+ const midnight = Date.parse(`${day}T00:00:00Z`);
1110
+ if (!Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== day)
1111
+ return fail();
1112
+ const timestamp = dateOnly ? midnight + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0) : Date.parse(text) + rounding;
1113
+ if (!Number.isFinite(timestamp)) return fail();
1114
+ return { timestamp, day };
1115
+ }
1116
+ var timeInputForms, OVERDUE_SQL;
1117
+ var init_parse_calendar_time = __esm({
1118
+ "src/lib/parse_calendar_time.ts"() {
1119
+ init_affinity_validation_error();
1120
+ timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d or -2d";
1121
+ 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)";
1122
+ }
1123
+ });
1124
+
835
1125
  // src/resolve_now.ts
836
1126
  function resolveNow(now) {
837
1127
  if (now !== void 0) {
@@ -904,9 +1194,13 @@ function listOpenCommitments(db, filters, options) {
904
1194
  clauses.push("oc.due_at <= ?");
905
1195
  params.push(anchor + filters.horizonDays * DAY_MS);
906
1196
  }
1197
+ if (options?.channel !== void 0) {
1198
+ clauses.push("e.channel=?");
1199
+ params.push(options.channel.trim().toLowerCase());
1200
+ }
907
1201
  const where = clauses.join(" AND ");
908
1202
  const rows = db.prepare(
909
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
1203
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.due_on, oc.resolved_at, oc.resolution,
910
1204
  e.type, e.summary
911
1205
  FROM open_commitments oc
912
1206
  INNER JOIN events e ON e.id = oc.event_id
@@ -923,7 +1217,8 @@ function listOpenCommitments(db, filters, options) {
923
1217
  participants: partsByEvent.get(r.event_id) ?? [],
924
1218
  dueAt: r.due_at,
925
1219
  dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
926
- overdue: r.due_at !== null && r.due_at < now,
1220
+ dueOn: r.due_on ?? (r.due_at === null ? null : new Date(r.due_at).toISOString().slice(0, 10)),
1221
+ overdue: isCommitmentOverdue(r.due_at, r.due_on, now),
927
1222
  resolutionState: mapResolution(r.resolved_at, r.resolution)
928
1223
  }));
929
1224
  }
@@ -931,6 +1226,7 @@ var DAY_MS;
931
1226
  var init_list_open_commitments = __esm({
932
1227
  "src/events/list_open_commitments.ts"() {
933
1228
  init_affinity_validation_error();
1229
+ init_parse_calendar_time();
934
1230
  init_assert_default_ordering();
935
1231
  init_resolve_list_limit_offset();
936
1232
  init_resolve_now();
@@ -1008,8 +1304,10 @@ function utcMidnight(year, month1Based, day) {
1008
1304
  return Date.UTC(year, month1Based - 1, day, 0, 0, 0, 0);
1009
1305
  }
1010
1306
  function computeNextAnchorOccursOn(params) {
1011
- const refStart = utcStartOfDayMs(params.referenceMs);
1012
- const refYear = new Date(params.referenceMs).getUTCFullYear();
1307
+ const refStart = Date.parse(
1308
+ `${localCalendarDate(params.referenceMs)}T00:00:00Z`
1309
+ );
1310
+ const refYear = new Date(refStart).getUTCFullYear();
1013
1311
  for (let year2 = refYear; year2 <= refYear + 2; year2 += 1) {
1014
1312
  const day2 = effectiveAnchorDay(year2, params.anchorMonth, params.anchorDay);
1015
1313
  const candidate = utcMidnight(year2, params.anchorMonth, day2);
@@ -1023,6 +1321,7 @@ function computeNextAnchorOccursOn(params) {
1023
1321
  }
1024
1322
  var init_calendar = __esm({
1025
1323
  "src/dates/calendar.ts"() {
1324
+ init_parse_calendar_time();
1026
1325
  }
1027
1326
  });
1028
1327
 
@@ -1240,7 +1539,13 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
1240
1539
  ORDER BY COALESCE(lr.normalized_rank, 0) DESC, l.trust DESC
1241
1540
  LIMIT ?`
1242
1541
  ).all(contactId, contactId, topLinksLimit);
1243
- const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
1542
+ const topLinks = linkRows.map((l) => ({
1543
+ ...mapLinkRowToLinkListItem(l),
1544
+ counterpartyName: getContactRowById(
1545
+ db,
1546
+ l.from_contact_id === contactId ? l.to_contact_id : l.from_contact_id
1547
+ )?.name ?? ""
1548
+ }));
1244
1549
  const activeDates = loadActiveDatesForContact(db, contactId);
1245
1550
  const contactRollup = loadContactRollup(db, contactId);
1246
1551
  const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
@@ -1283,7 +1588,20 @@ function getContactProfile(db, contactId, options) {
1283
1588
  return {
1284
1589
  ...profile,
1285
1590
  recentEvents: getContactJournal(db, contactId, {
1286
- limit: options?.recentEventsLimit ?? 5
1591
+ limit: options?.recentEventsLimit ?? 5,
1592
+ eventTypes: [
1593
+ "conversation",
1594
+ "activity",
1595
+ "gift",
1596
+ "support",
1597
+ "milestone",
1598
+ "observation",
1599
+ "conflict",
1600
+ "correction",
1601
+ "transaction",
1602
+ "promise",
1603
+ "agreement"
1604
+ ]
1287
1605
  }).map((event) => ({
1288
1606
  ...event,
1289
1607
  occurredAtIso: new Date(event.occurredAt).toISOString()
@@ -1351,6 +1669,10 @@ function listMoments(db, filters, options) {
1351
1669
  clauses.push("le.link_id = ?");
1352
1670
  params.push(filters.linkId);
1353
1671
  }
1672
+ if (options?.channel !== void 0) {
1673
+ clauses.push("e.channel=?");
1674
+ params.push(options.channel.trim().toLowerCase());
1675
+ }
1354
1676
  const where = clauses.join(" AND ");
1355
1677
  return db.prepare(
1356
1678
  `SELECT
@@ -1358,6 +1680,7 @@ function listMoments(db, filters, options) {
1358
1680
  le.link_id,
1359
1681
  le.moment_kind,
1360
1682
  le.occurred_at,
1683
+ e.occurred_on,
1361
1684
  e.summary,
1362
1685
  le.impact_score
1363
1686
  FROM link_event_effects le
@@ -1370,6 +1693,7 @@ function listMoments(db, filters, options) {
1370
1693
  linkId: row.link_id,
1371
1694
  momentKind: row.moment_kind,
1372
1695
  occurredAt: row.occurred_at,
1696
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
1373
1697
  summary: row.summary,
1374
1698
  impactScore: row.impact_score
1375
1699
  }));
@@ -1469,6 +1793,10 @@ function getLinkTimeline(db, linkId, options) {
1469
1793
  clauses.push(`e.type IN (${ph})`);
1470
1794
  params.push(...options.eventTypes);
1471
1795
  }
1796
+ if (options?.channel !== void 0) {
1797
+ clauses.push("e.channel = ?");
1798
+ params.push(options.channel.trim().toLowerCase());
1799
+ }
1472
1800
  const where = clauses.join(" AND ");
1473
1801
  const rows = db.prepare(
1474
1802
  `SELECT e.id
@@ -1480,7 +1808,8 @@ function getLinkTimeline(db, linkId, options) {
1480
1808
  ).all(...params, limit, offset);
1481
1809
  return batchLoadEventRecords(
1482
1810
  db,
1483
- rows.map((r) => r.id)
1811
+ rows.map((r) => r.id),
1812
+ options?.includeContent ?? false
1484
1813
  );
1485
1814
  }
1486
1815
  var init_get_link_timeline = __esm({
@@ -1664,26 +1993,34 @@ function refreshLinkRollup(db, linkId, now) {
1664
1993
  const latest = db.prepare(
1665
1994
  `SELECT event_id, occurred_at
1666
1995
  FROM link_event_effects
1667
- WHERE link_id = ? AND counts_for_recency = 1
1996
+ 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
1997
  ORDER BY occurred_at DESC, event_id DESC
1669
1998
  LIMIT 1`
1670
- ).get(linkId);
1999
+ ).get(linkId, now, localCalendarDate(now));
1671
2000
  const trailingWindowStart = now - 180 * DAY_MS2;
1672
2001
  const counts = db.prepare(
1673
2002
  `SELECT
1674
2003
  COUNT(*) AS total_meaningful_events,
1675
2004
  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
2005
  FROM link_event_effects
1677
- WHERE link_id = ? AND occurred_at >= ?`
1678
- ).get(linkId, trailingWindowStart);
2006
+ WHERE link_id = ? AND occurred_at >= ? AND counts_for_recency = 1
2007
+ AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
2008
+ ).get(linkId, trailingWindowStart, now, localCalendarDate(now));
1679
2009
  const directionalityRows = db.prepare(
1680
2010
  `SELECT ep.directionality
1681
2011
  FROM link_event_effects le
1682
2012
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
1683
2013
  WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
1684
2014
  AND ep.directionality IS NOT NULL
1685
- AND le.occurred_at >= ?`
1686
- ).all(linkId, link.from_contact_id, trailingWindowStart);
2015
+ AND le.occurred_at >= ?
2016
+ AND le.event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
2017
+ ).all(
2018
+ linkId,
2019
+ link.from_contact_id,
2020
+ trailingWindowStart,
2021
+ now,
2022
+ localCalendarDate(now)
2023
+ );
1687
2024
  let outboundCount = 0;
1688
2025
  let inboundCount = 0;
1689
2026
  for (const row of directionalityRows) {
@@ -1793,6 +2130,7 @@ var DAY_MS2;
1793
2130
  var init_rollups2 = __esm({
1794
2131
  "src/links/rollups.ts"() {
1795
2132
  init_rollups();
2133
+ init_parse_calendar_time();
1796
2134
  init_mechanics();
1797
2135
  init_queries3();
1798
2136
  DAY_MS2 = 864e5;
@@ -1855,41 +2193,12 @@ var init_get_link_detail = __esm({
1855
2193
 
1856
2194
  // src/tools/parse_time_input.ts
1857
2195
  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();
2196
+ return parseCalendarTime(value, field, now).timestamp;
1887
2197
  }
1888
- var timeInputForms;
1889
2198
  var init_parse_time_input = __esm({
1890
2199
  "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";
2200
+ init_parse_calendar_time();
2201
+ init_parse_calendar_time();
1893
2202
  }
1894
2203
  });
1895
2204
 
@@ -1966,8 +2275,47 @@ var init_tool_metadata = __esm({
1966
2275
  }
1967
2276
  });
1968
2277
 
2278
+ // src/tools/event_metadata_schema.ts
2279
+ var externalRefSchema, eventMetadataSchema;
2280
+ var init_event_metadata_schema = __esm({
2281
+ "src/tools/event_metadata_schema.ts"() {
2282
+ init_tool_metadata();
2283
+ externalRefSchema = objectSchema(
2284
+ {
2285
+ source: stringSchema("External source; trimmed and lowercased."),
2286
+ id: stringSchema("Non-empty external id.")
2287
+ },
2288
+ ["source", "id"]
2289
+ );
2290
+ eventMetadataSchema = {
2291
+ affectsBond: booleanSchema(
2292
+ "Whether evidence affects the relationship; defaults false for observations with significance <=2."
2293
+ ),
2294
+ content: stringSchema(
2295
+ "Verbatim text, including empty text; at most 1 MB UTF-8. Summary may be omitted when content is supplied."
2296
+ ),
2297
+ externalRef: externalRefSchema,
2298
+ channel: stringSchema(
2299
+ "Free-text channel, trimmed and lowercased, at most 40 characters."
2300
+ ),
2301
+ parentRef: externalRefSchema,
2302
+ parentEventId: integerSchema("Parent event id."),
2303
+ threadRef: externalRefSchema,
2304
+ momentKind: enumSchema("Optional moment kind.", [
2305
+ "breakthrough",
2306
+ "rupture",
2307
+ "reconciliation",
2308
+ "milestone",
2309
+ "turning_point"
2310
+ ]),
2311
+ occurredOn: stringSchema("Calendar day."),
2312
+ provenance: {}
2313
+ };
2314
+ }
2315
+ });
2316
+
1969
2317
  // src/tools/tool_names.ts
1970
- var searchAffinityToolName, reviewAffinityToolName, inspectAffinityItemToolName, manageContactToolName, mergeContactsToolName, manageDuplicateDismissalToolName, manageIdentityToolName, manageRelationshipToolName, recordEventToolName, manageCommitmentToolName, manageDateAnchorToolName, manageAttributeToolName;
2318
+ var searchAffinityToolName, reviewAffinityToolName, inspectAffinityItemToolName, manageContactToolName, mergeContactsToolName, manageDuplicateDismissalToolName, manageIdentityToolName, manageRelationshipToolName, recordEventToolName, manageCommitmentToolName, manageDateAnchorToolName, manageAttributeToolName, manageEventToolName;
1971
2319
  var init_tool_names = __esm({
1972
2320
  "src/tools/tool_names.ts"() {
1973
2321
  searchAffinityToolName = "search_affinity";
@@ -1982,6 +2330,7 @@ var init_tool_names = __esm({
1982
2330
  manageCommitmentToolName = "manage_commitment";
1983
2331
  manageDateAnchorToolName = "manage_date_anchor";
1984
2332
  manageAttributeToolName = "manage_attribute";
2333
+ manageEventToolName = "manage_event";
1985
2334
  }
1986
2335
  });
1987
2336
 
@@ -2028,7 +2377,7 @@ function toEventEntityRef(value) {
2028
2377
  return {
2029
2378
  kind: "event",
2030
2379
  id: value.id,
2031
- title: value.summary,
2380
+ title: deriveEventSummary(value.summary),
2032
2381
  subtitle: value.type,
2033
2382
  state: value.type
2034
2383
  };
@@ -2063,21 +2412,48 @@ function toReceiptEntityRefs(receipt) {
2063
2412
  }
2064
2413
  var init_tool_ref = __esm({
2065
2414
  "src/tools/tool_ref.ts"() {
2415
+ init_event_metadata();
2066
2416
  }
2067
2417
  });
2068
2418
 
2069
2419
  // src/identities/normalize.ts
2420
+ function normalizeIdentityValue(type, value) {
2421
+ const v = value.trim();
2422
+ return handleIdentityTypes.has(type.toLowerCase()) ? v.replace(/^@/, "").toLowerCase() : v;
2423
+ }
2070
2424
  function normalizeIdentityKey(type, value) {
2071
- const t = type.trim().toLowerCase();
2072
- const v = value.trim().toLowerCase();
2425
+ let t = type.trim().toLowerCase();
2426
+ if (["did", "atproto_did", "custom:atproto-did"].includes(t)) t = "did";
2427
+ const v = normalizeIdentityValue(t, value).toLowerCase();
2073
2428
  if (t.length === 0 || v.length === 0) {
2074
2429
  throw new AffinityValidationError("type and value must be non-empty");
2075
2430
  }
2076
2431
  return `${t}:${v}`;
2077
2432
  }
2433
+ function legacyIdentityKey(key) {
2434
+ const colon = key.indexOf(":");
2435
+ return handleIdentityTypes.has(key.slice(0, colon)) ? `${key.slice(0, colon + 1)}@${key.slice(colon + 1)}` : key;
2436
+ }
2437
+ var handleIdentityTypes;
2078
2438
  var init_normalize = __esm({
2079
2439
  "src/identities/normalize.ts"() {
2080
2440
  init_affinity_validation_error();
2441
+ handleIdentityTypes = /* @__PURE__ */ new Set([
2442
+ "handle",
2443
+ "bluesky",
2444
+ "x",
2445
+ "twitter",
2446
+ "mastodon",
2447
+ "github",
2448
+ "instagram",
2449
+ "telegram",
2450
+ "linkedin",
2451
+ "facebook",
2452
+ "youtube",
2453
+ "tiktok",
2454
+ "discord",
2455
+ "slack"
2456
+ ]);
2081
2457
  }
2082
2458
  });
2083
2459
 
@@ -2210,7 +2586,7 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
2210
2586
  const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
2211
2587
  return {
2212
2588
  ok: false,
2213
- result: toolFailure("domain", "invalid_state", message, message)
2589
+ result: toolFailure("domain", "conflict", message, message)
2214
2590
  };
2215
2591
  }
2216
2592
  return { ok: true, value: row2 };
@@ -2235,9 +2611,13 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
2235
2611
  INNER JOIN contacts c ON c.id = i.contact_id
2236
2612
  WHERE i.removed_at IS NULL
2237
2613
  AND c.deleted_at IS NULL
2238
- AND i.normalized_key = ?
2614
+ AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))
2239
2615
  LIMIT 2`
2240
- ).all(normalizedKey);
2616
+ ).all(
2617
+ normalizedKey,
2618
+ legacyIdentityKey(normalizedKey),
2619
+ normalizedKey
2620
+ );
2241
2621
  if (row.length === 0) {
2242
2622
  return {
2243
2623
  ok: false,
@@ -2388,13 +2768,21 @@ function resolveLinkLocator(db, locator, field = "link") {
2388
2768
  return { ok: true, value: matchedLink };
2389
2769
  }
2390
2770
  function mapErrorToToolFailure(error, summary = "The tool failed.") {
2771
+ if (error && typeof error === "object" && "ok" in error && error.ok === false && "outcome" in error && error.outcome === "error")
2772
+ return error;
2391
2773
  if (isAffinityError(error)) {
2392
- return toolFailure(
2774
+ const result = toolFailure(
2393
2775
  "domain",
2394
2776
  affinityErrorCodeToToolCode(error),
2395
2777
  summary,
2396
2778
  error.message
2397
2779
  );
2780
+ const id = error.existingContactId;
2781
+ if (id !== void 0) {
2782
+ result.data = { existingContactId: id };
2783
+ result.error.details = { existingContactId: id };
2784
+ }
2785
+ return result;
2398
2786
  }
2399
2787
  if (error instanceof Error) {
2400
2788
  return toolFailure("system", "system_error", summary, error.message);
@@ -2443,27 +2831,32 @@ var init_tool_resolvers = __esm({
2443
2831
  });
2444
2832
 
2445
2833
  // src/tools/validate_tool_input.ts
2446
- function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
2834
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now(), strict = false) {
2835
+ if (path.endsWith(".occurredAt") && value == null)
2836
+ return "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)";
2837
+ const branches = schema.oneOf ?? schema.anyOf;
2838
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && value !== null && typeof value !== "string")
2839
+ return `attribute values are strings or null; pass "${String(value)}" to store it as text`;
2447
2840
  if (schema.anyOf || schema.oneOf) {
2448
2841
  const choices = schema.anyOf ?? schema.oneOf ?? [];
2449
2842
  return choices.some(
2450
- (choice) => validateToolInput(choice, value, path, injectedNow) === null
2843
+ (choice) => validateToolInput(choice, value, path, injectedNow, strict) === null
2451
2844
  ) ? null : `${path}: invalid value`;
2452
2845
  }
2453
2846
  if (schema.type === "object") {
2454
2847
  if (value === null || typeof value !== "object" || Array.isArray(value))
2455
2848
  return `${path}: missing input or expected object`;
2456
2849
  const object = value;
2457
- if (path.endsWith(".patch") && schema.additionalProperties === false) {
2850
+ if ((strict || path.endsWith(".patch")) && schema.additionalProperties === false) {
2458
2851
  for (const key of Object.keys(object)) {
2459
2852
  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(", ")}`;
2853
+ 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
2854
  }
2462
2855
  }
2463
2856
  if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
2464
2857
  return `${path}.patch: missing required field`;
2465
2858
  if (object.patch !== void 0 && !schema.properties?.patch)
2466
- return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
2859
+ return `${path}.patch: unknown key; allowed keys: none (use the action's named fields)`;
2467
2860
  const referenceNow = object.now === void 0 ? injectedNow : (() => {
2468
2861
  try {
2469
2862
  return parseTimeInput(
@@ -2478,11 +2871,14 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2478
2871
  for (const [key, child] of Object.entries(schema.properties ?? {})) {
2479
2872
  if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
2480
2873
  try {
2481
- object[key] = parseTimeInput(
2874
+ const parsed = parseCalendarTime(
2482
2875
  object[key],
2483
2876
  `${path}.${key}`,
2484
2877
  referenceNow
2485
2878
  );
2879
+ object[key] = parsed.timestamp;
2880
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key))
2881
+ object[key.replace(/At$/, "On")] ??= parsed.day;
2486
2882
  } catch (error) {
2487
2883
  return error.message;
2488
2884
  }
@@ -2498,7 +2894,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2498
2894
  child,
2499
2895
  object[key],
2500
2896
  `${path}.${key}`,
2501
- referenceNow
2897
+ referenceNow,
2898
+ strict
2502
2899
  );
2503
2900
  if (error) return error;
2504
2901
  }
@@ -2511,7 +2908,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2511
2908
  schema.items ?? {},
2512
2909
  item,
2513
2910
  `${path}[${index}]`,
2514
- injectedNow
2911
+ injectedNow,
2912
+ strict
2515
2913
  );
2516
2914
  if (error) return error;
2517
2915
  }
@@ -2529,6 +2927,7 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
2529
2927
  }
2530
2928
  var init_validate_tool_input = __esm({
2531
2929
  "src/tools/validate_tool_input.ts"() {
2930
+ init_parse_calendar_time();
2532
2931
  init_parse_time_input();
2533
2932
  }
2534
2933
  });
@@ -2582,6 +2981,26 @@ function inspectAffinityItemToolHandler(db, rawInput) {
2582
2981
  inputError
2583
2982
  );
2584
2983
  return withToolHandling(() => {
2984
+ if (input.kind === "event") {
2985
+ if (input.eventId === void 0 === (input.ref === void 0))
2986
+ return toolFailure(
2987
+ "protocol",
2988
+ "invalid_input",
2989
+ "Invalid event target.",
2990
+ "Provide exactly one of eventId or ref."
2991
+ );
2992
+ const detail2 = input.ref ? getEventByExternalRef(db, input.ref.source, input.ref.id) : getEventById(db, input.eventId);
2993
+ return detail2 ? toolSuccess(
2994
+ `Loaded event #${detail2.id}.`,
2995
+ { kind: "event", detail: detail2 },
2996
+ { entities: [{ kind: "event", id: detail2.id }] }
2997
+ ) : toolFailure(
2998
+ "domain",
2999
+ "not_found",
3000
+ "Event not found.",
3001
+ "No event matches the target."
3002
+ );
3003
+ }
2585
3004
  if (input.kind === "owner_profile") {
2586
3005
  const detail2 = getOwnerProfile(
2587
3006
  db,
@@ -2664,7 +3083,9 @@ var init_inspect_affinity_item_tool = __esm({
2664
3083
  "src/tools/inspect_affinity_item_tool.ts"() {
2665
3084
  init_get_contact_profile();
2666
3085
  init_get_owner_profile();
3086
+ init_event_metadata();
2667
3087
  init_get_link_detail();
3088
+ init_event_metadata_schema();
2668
3089
  init_tool_metadata();
2669
3090
  init_tool_names();
2670
3091
  init_tool_ref();
@@ -2692,10 +3113,13 @@ var init_inspect_affinity_item_tool = __esm({
2692
3113
  inputSchema: objectSchema(
2693
3114
  {
2694
3115
  kind: enumSchema("What to inspect.", [
3116
+ "event",
2695
3117
  "owner_profile",
2696
3118
  "contact_profile",
2697
3119
  "link"
2698
3120
  ]),
3121
+ eventId: integerSchema("Exact event id."),
3122
+ ref: externalRefSchema,
2699
3123
  contact: contactLocatorSchema(
2700
3124
  "Target contact. Required when kind=contact_profile. Provide contactId or identity."
2701
3125
  ),
@@ -2716,6 +3140,7 @@ var init_inspect_affinity_item_tool = __esm({
2716
3140
  // src/attributes/helpers.ts
2717
3141
  function clearedAttributePrimary(target) {
2718
3142
  return {
3143
+ ...target.kind === "event" ? { eventId: target.eventId } : {},
2719
3144
  id: 0,
2720
3145
  contactId: target.kind === "contact" ? target.id : null,
2721
3146
  linkId: target.kind === "link" ? target.id : null,
@@ -2731,6 +3156,7 @@ var init_helpers = __esm({
2731
3156
  // src/attributes/mappers.ts
2732
3157
  function mapAttributeRowToAttributeRecord(row) {
2733
3158
  return {
3159
+ ...row.event_id == null ? {} : { eventId: row.event_id },
2734
3160
  id: row.id,
2735
3161
  contactId: row.contact_id,
2736
3162
  linkId: row.link_id,
@@ -2744,131 +3170,46 @@ var init_mappers5 = __esm({
2744
3170
  });
2745
3171
 
2746
3172
  // src/attributes/queries.ts
3173
+ function attributeTargetSql(target) {
3174
+ return {
3175
+ column: target.kind === "event" ? "event_id" : target.kind === "contact" ? "contact_id" : "link_id",
3176
+ id: target.kind === "event" ? target.eventId : target.id
3177
+ };
3178
+ }
2747
3179
  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;
3180
+ return db.prepare("SELECT * FROM attributes WHERE id=?").get(id) ?? null;
2756
3181
  }
2757
3182
  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;
3183
+ const t = attributeTargetSql(target);
3184
+ return db.prepare(
3185
+ `SELECT * FROM attributes WHERE ${t.column}=? AND name=? AND deleted_at IS NULL`
3186
+ ).get(t.id, name) ?? null;
2778
3187
  }
2779
3188
  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);
3189
+ const t = attributeTargetSql(target);
3190
+ return db.prepare(
3191
+ `SELECT id FROM attributes WHERE ${t.column}=? AND deleted_at IS NULL ORDER BY id`
3192
+ ).all(t.id).map((r) => r.id);
2790
3193
  }
2791
3194
  var init_queries4 = __esm({
2792
3195
  "src/attributes/queries.ts"() {
2793
3196
  }
2794
3197
  });
2795
3198
 
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;
3199
+ // src/attributes/receipts.ts
3200
+ function buildAttributeMutationReceipt(primary, partial) {
3201
+ const normalized = normalizeMutationReceiptPartial(partial);
3202
+ return {
3203
+ primary,
3204
+ created: normalized.created,
3205
+ updated: normalized.updated,
3206
+ archived: normalized.archived,
3207
+ removed: normalized.removed,
3208
+ affectedLinks: normalized.affectedLinks,
3209
+ derivedEffects: normalized.derivedEffects
3210
+ };
2809
3211
  }
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({
3212
+ var init_receipts2 = __esm({
2872
3213
  "src/attributes/receipts.ts"() {
2873
3214
  init_normalize_mutation_receipt();
2874
3215
  }
@@ -2931,11 +3272,14 @@ function normalizeAttributeValue(value) {
2931
3272
  if (value === null) {
2932
3273
  return null;
2933
3274
  }
3275
+ if (typeof value !== "string")
3276
+ throw new AffinityValidationError("attribute values are strings or null");
2934
3277
  const trimmed = value.trim();
2935
3278
  return trimmed.length === 0 ? null : trimmed;
2936
3279
  }
2937
3280
  var init_normalize2 = __esm({
2938
3281
  "src/attributes/normalize.ts"() {
3282
+ init_affinity_validation_error();
2939
3283
  }
2940
3284
  });
2941
3285
 
@@ -2963,6 +3307,11 @@ function validateAttributeEntries(entries) {
2963
3307
  return out;
2964
3308
  }
2965
3309
  function assertAttributeTargetWritable(db, target) {
3310
+ if (target.kind === "event") {
3311
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(target.eventId))
3312
+ throw new AffinityNotFoundError("event not found");
3313
+ return;
3314
+ }
2966
3315
  if (target.kind === "contact") {
2967
3316
  const contact = getContactRowById(db, target.id);
2968
3317
  if (!contact) {
@@ -3009,26 +3358,14 @@ function replaceAttributes(db, target, entries) {
3009
3358
  }
3010
3359
  const created = [];
3011
3360
  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
- }
3361
+ const t = attributeTargetSql(target);
3362
+ const insert = db.prepare(
3363
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
3364
+ );
3365
+ for (const e of normalized) {
3366
+ const r = insert.run(t.id, e.name, e.value, now, now);
3367
+ lastId = Number(r.lastInsertRowid);
3368
+ created.push({ kind: "attribute", id: lastId });
3032
3369
  }
3033
3370
  if (normalized.length === 0) {
3034
3371
  return buildAttributeMutationReceipt(clearedAttributePrimary(target), {
@@ -3054,11 +3391,12 @@ var init_replace_attributes = __esm({
3054
3391
  init_helpers();
3055
3392
  init_mappers5();
3056
3393
  init_queries4();
3057
- init_receipts();
3394
+ init_receipts2();
3058
3395
  init_validators3();
3059
3396
  init_affinity_invariant_error();
3060
3397
  init_resolve_now();
3061
3398
  init_with_transaction();
3399
+ init_queries4();
3062
3400
  }
3063
3401
  });
3064
3402
 
@@ -3085,27 +3423,10 @@ function setAttribute(db, target, name, value) {
3085
3423
  }
3086
3424
  );
3087
3425
  }
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
- }
3426
+ const t = attributeTargetSql(target);
3105
3427
  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);
3428
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
3429
+ ).run(t.id, key, stored, now, now);
3109
3430
  const id = Number(r.lastInsertRowid);
3110
3431
  const row = getAttributeRowById(db, id);
3111
3432
  if (!row) {
@@ -3124,11 +3445,12 @@ var init_set_attribute = __esm({
3124
3445
  init_mappers5();
3125
3446
  init_normalize2();
3126
3447
  init_queries4();
3127
- init_receipts();
3448
+ init_receipts2();
3128
3449
  init_validators3();
3129
3450
  init_affinity_invariant_error();
3130
3451
  init_resolve_now();
3131
3452
  init_with_transaction();
3453
+ init_queries4();
3132
3454
  }
3133
3455
  });
3134
3456
 
@@ -3155,7 +3477,7 @@ var init_unset_attribute = __esm({
3155
3477
  "src/attributes/unset_attribute.ts"() {
3156
3478
  init_mappers5();
3157
3479
  init_queries4();
3158
- init_receipts();
3480
+ init_receipts2();
3159
3481
  init_validators3();
3160
3482
  init_affinity_not_found_error();
3161
3483
  init_resolve_now();
@@ -3184,14 +3506,15 @@ function mutationToolResult(action, receipt, entitySummary, fields) {
3184
3506
  affectedLinks: receipt.affectedLinks,
3185
3507
  derivedEffects: receipt.derivedEffects
3186
3508
  };
3187
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
3509
+ 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
3510
  if (changedCount === 0) {
3189
3511
  return toolNoOp(summary, data, {
3190
3512
  entities: toReceiptEntityRefs(receipt)
3191
3513
  });
3192
3514
  }
3193
3515
  return toolSuccess(summary, data, {
3194
- entities: toReceiptEntityRefs(receipt)
3516
+ entities: toReceiptEntityRefs(receipt),
3517
+ warnings: receipt.warnings
3195
3518
  });
3196
3519
  }
3197
3520
  function describeChanges(receipt) {
@@ -3255,6 +3578,7 @@ function linkLocatorSchema2(description) {
3255
3578
  };
3256
3579
  }
3257
3580
  function resolveAttributeTarget(db, target) {
3581
+ if (target.kind === "event") return { kind: "ok", target };
3258
3582
  if (target.kind === "contact") {
3259
3583
  const contact = resolveContactLocator(db, target.contact, "target.contact");
3260
3584
  if (!contact.ok) {
@@ -3326,7 +3650,7 @@ var init_manage_attribute_tool = __esm({
3326
3650
  init_validate_tool_input();
3327
3651
  manageAttributeTool = defineAffinityTool({
3328
3652
  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.",
3653
+ 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
3654
  whenToUse: "Use this for metadata such as tags, notes, or small labeled facts attached to a contact or link.",
3331
3655
  whenNotToUse: "Do not use this for core contact fields, identities, or direct relationship mechanics.",
3332
3656
  sideEffects: "writes_state",
@@ -3347,7 +3671,8 @@ var init_manage_attribute_tool = __esm({
3347
3671
  action: enumSchema("Operation to perform.", ["set", "unset", "replace"]),
3348
3672
  target: objectSchema(
3349
3673
  {
3350
- kind: enumSchema("Target kind.", ["contact", "link"]),
3674
+ kind: enumSchema("Target kind.", ["contact", "link", "event"]),
3675
+ eventId: integerSchema("Target event id."),
3351
3676
  contact: contactLocatorSchema2(
3352
3677
  "Contact locator. Required when kind=contact. Provide contactId or identity."
3353
3678
  ),
@@ -3377,7 +3702,7 @@ var init_manage_attribute_tool = __esm({
3377
3702
  removedAt: integerSchema("Removal timestamp. Optional for action=unset.")
3378
3703
  },
3379
3704
  ["action"],
3380
- "Set, unset, or replace attributes on contacts and links."
3705
+ "Set, unset, or replace attributes on contacts, links and events."
3381
3706
  ),
3382
3707
  handler: manageAttributeToolHandler
3383
3708
  });
@@ -3387,7 +3712,7 @@ var init_manage_attribute_tool = __esm({
3387
3712
  // src/events/persistence.ts
3388
3713
  function insertJournalEvent(db, params) {
3389
3714
  const now = resolveNow(params.now);
3390
- const summary = params.summary.trim();
3715
+ const summary = params.summary?.trim() ?? deriveEventSummary(params.content ?? "");
3391
3716
  const result = db.prepare(
3392
3717
  `INSERT INTO events (
3393
3718
  type, occurred_at, summary, significance, moment_kind,
@@ -3404,6 +3729,33 @@ function insertJournalEvent(db, params) {
3404
3729
  now
3405
3730
  );
3406
3731
  const eventId = Number(result.lastInsertRowid);
3732
+ db.prepare("UPDATE events SET affects_bond=? WHERE id=?").run(
3733
+ params.affectsBond === void 0 ? params.type === "observation" && params.significance <= 2 ? 0 : 1 : Number(params.affectsBond),
3734
+ eventId
3735
+ );
3736
+ db.prepare("UPDATE events SET declared_moment_kind=? WHERE id=?").run(
3737
+ params.momentKind,
3738
+ eventId
3739
+ );
3740
+ db.prepare(
3741
+ "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=?"
3742
+ ).run(
3743
+ params.content ?? null,
3744
+ params.externalRef?.source ?? null,
3745
+ params.externalRef?.id ?? null,
3746
+ params.channel ?? null,
3747
+ params.parentRef?.source ?? null,
3748
+ params.parentRef?.id ?? null,
3749
+ params.parentEventId ?? null,
3750
+ params.threadRef?.source ?? null,
3751
+ params.threadRef?.id ?? null,
3752
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
3753
+ eventId
3754
+ );
3755
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3756
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3757
+ eventId
3758
+ );
3407
3759
  const insertParticipant = db.prepare(
3408
3760
  `INSERT INTO event_participants (
3409
3761
  event_id, contact_id, role, directionality
@@ -3446,6 +3798,10 @@ function insertDateAnchorEvent(db, params) {
3446
3798
  now
3447
3799
  );
3448
3800
  const eventId = Number(result.lastInsertRowid);
3801
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3802
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3803
+ eventId
3804
+ );
3449
3805
  const insertParticipant = db.prepare(
3450
3806
  `INSERT INTO event_participants (
3451
3807
  event_id, contact_id, role, directionality
@@ -3469,29 +3825,11 @@ var init_persistence = __esm({
3469
3825
  "src/events/persistence.ts"() {
3470
3826
  init_affinity_invariant_error();
3471
3827
  init_resolve_now();
3828
+ init_event_metadata();
3472
3829
  init_queries2();
3473
3830
  }
3474
3831
  });
3475
3832
 
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
3833
  // src/graph/betweenness.ts
3496
3834
  function computeNodeBetweenness(adjacency) {
3497
3835
  const nodes = [...adjacency.keys()];
@@ -4104,6 +4442,8 @@ function deriveStateAfter(params) {
4104
4442
  return params.stateBefore;
4105
4443
  }
4106
4444
  function deriveMomentKind(params) {
4445
+ if (params.eventType === "observation" || params.eventType === "milestone" && params.significance < 7)
4446
+ return null;
4107
4447
  if (params.rankAfter > params.rankBefore) {
4108
4448
  return "breakthrough";
4109
4449
  }
@@ -4133,6 +4473,18 @@ function applyEventEffectToLink(db, params) {
4133
4473
  if (!link || link.is_structural !== 0) {
4134
4474
  throw new AffinityStateError("relational link not found");
4135
4475
  }
4476
+ db.prepare(
4477
+ "INSERT OR IGNORE INTO event_link_baselines(link_id,state_json) VALUES (?,?)"
4478
+ ).run(
4479
+ params.linkId,
4480
+ JSON.stringify({
4481
+ rank: link.rank ?? 0,
4482
+ affinity: link.affinity ?? 0,
4483
+ trust: link.trust ?? 0,
4484
+ state: link.state ?? "active",
4485
+ cadence_days: link.cadence_days
4486
+ })
4487
+ );
4136
4488
  const stateBefore = link.state ?? "active";
4137
4489
  const linkKind = link.kind;
4138
4490
  const rankBefore = link.rank ?? 0;
@@ -4177,7 +4529,7 @@ function applyEventEffectToLink(db, params) {
4177
4529
  );
4178
4530
  const weightedBase = base * massPenalty(
4179
4531
  weeklyPositiveBaseWeight(db, params.linkId, params.occurredAt)
4180
- ) * salienceMultiplier;
4532
+ ) * salienceMultiplier * (params.participant.directionality === "owner_initiated" ? 0.05 : 1);
4181
4533
  const affinityGain = weightedBase * Math.max(eventValence, 0) * (0.55 + 0.45 * eventIntimacyDepth) * (0.7 + 0.3 * participantReciprocity) * (1 / (1 + 0.22 * rankBefore));
4182
4534
  const affinityLoss = weightedBase * Math.max(-eventValence, 0) * 0.35;
4183
4535
  const affinityMass = clamp3(
@@ -4218,15 +4570,16 @@ function applyEventEffectToLink(db, params) {
4218
4570
  const latest = db.prepare(
4219
4571
  `SELECT occurred_at
4220
4572
  FROM link_event_effects
4221
- WHERE link_id = ?
4573
+ 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
4574
  ORDER BY occurred_at DESC, event_id DESC
4223
4575
  LIMIT 1`
4224
- ).get(params.linkId);
4576
+ ).get(params.linkId, params.now, localCalendarDate(params.now));
4225
4577
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
4226
4578
  1,
4227
4579
  Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
4228
4580
  );
4229
- const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
4581
+ const eventDay = db.prepare("SELECT occurred_on FROM events WHERE id = ?").get(params.eventId)?.occurred_on;
4582
+ const cadenceAfter = commitmentResolution != null || params.eventType === "promise" || params.eventType === "agreement" || params.occurredAt > params.now && (eventDay == null || eventDay > localCalendarDate(params.now)) ? cadenceBefore : nextCadenceDays({
4230
4583
  kind: linkKind,
4231
4584
  currentCadenceDays: cadenceBefore,
4232
4585
  gapDays
@@ -4246,7 +4599,11 @@ function applyEventEffectToLink(db, params) {
4246
4599
  }
4247
4600
  const affinityDelta = affinityAfter - affinityBefore;
4248
4601
  const trustDelta = trustAfter - trustBefore;
4249
- const momentKind = deriveMomentKind({
4602
+ const eventLabel = db.prepare("SELECT summary FROM events WHERE id=?").get(params.eventId)?.summary;
4603
+ const followLike = params.eventType === "milestone" && typeof eventLabel === "string" && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
4604
+ eventLabel
4605
+ );
4606
+ const momentKind = followLike ? null : deriveMomentKind({
4250
4607
  rankBefore,
4251
4608
  rankAfter,
4252
4609
  stateBefore,
@@ -4289,7 +4646,15 @@ function applyEventEffectToLink(db, params) {
4289
4646
  params.now,
4290
4647
  params.now
4291
4648
  );
4292
- if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
4649
+ db.prepare(
4650
+ "UPDATE link_event_effects SET participant_json=?,provenance_json=? WHERE event_id=? AND link_id=?"
4651
+ ).run(
4652
+ JSON.stringify(params.participant),
4653
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
4654
+ params.eventId,
4655
+ params.linkId
4656
+ );
4657
+ if (commitmentResolution !== void 0 && commitmentResolution !== null || params.eventType === "promise" || params.eventType === "agreement" || params.participant.directionality === "owner_initiated") {
4293
4658
  db.prepare(
4294
4659
  "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
4295
4660
  ).run(params.eventId, params.linkId);
@@ -4323,6 +4688,7 @@ var init_effects = __esm({
4323
4688
  init_calendar();
4324
4689
  init_mechanics2();
4325
4690
  init_affinity_state_error();
4691
+ init_parse_calendar_time();
4326
4692
  init_mechanics();
4327
4693
  init_queries3();
4328
4694
  init_rollups2();
@@ -4337,6 +4703,7 @@ var init_affinity_conflict_error = __esm({
4337
4703
  "src/lib/errors/affinity_conflict_error.ts"() {
4338
4704
  init_affinity_error();
4339
4705
  AffinityConflictError = class extends AffinityError {
4706
+ existingContactId;
4340
4707
  code = "CONFLICT";
4341
4708
  };
4342
4709
  }
@@ -4403,6 +4770,8 @@ var init_receipts3 = __esm({
4403
4770
  // src/links/seed_social_link.ts
4404
4771
  function seedSocialLink(db, input) {
4405
4772
  return withTransaction(db, () => {
4773
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
4774
+ throw new AffinityValidationError("ifExists must be error or return");
4406
4775
  if (!isRelationalLinkKind(input.kind)) {
4407
4776
  throw new AffinityValidationError("invalid relational link kind");
4408
4777
  }
@@ -4410,11 +4779,14 @@ function seedSocialLink(db, input) {
4410
4779
  throw new AffinityValidationError("from and to contacts must differ");
4411
4780
  }
4412
4781
  assertContactEndpointsNotMerged(db, input.fromContactId, input.toContactId);
4413
- if (findLiveRelationalLinkAnyDirection(
4782
+ const existing = findLiveRelationalLinkAnyDirection(
4414
4783
  db,
4415
4784
  input.fromContactId,
4416
4785
  input.toContactId
4417
- )) {
4786
+ );
4787
+ if (existing) {
4788
+ if (input.ifExists === "return")
4789
+ return buildLinkMutationReceipt(mapLinkRowToLinkListItem(existing), {});
4418
4790
  throw new AffinityConflictError(
4419
4791
  "relational link already exists between these contacts"
4420
4792
  );
@@ -4534,15 +4906,36 @@ function ensureRelationalLink(db, params) {
4534
4906
  };
4535
4907
  }
4536
4908
  function applyJournalEventMechanics(db, params) {
4909
+ const bond = db.prepare("SELECT affects_bond, channel FROM events WHERE id=?").get(params.eventId);
4910
+ const ambient = params.provenance && typeof params.provenance === "object" && (params.provenance.ambient === true || params.provenance.tags?.includes("ambient"));
4911
+ if (bond?.affects_bond === 0 || bond?.affects_bond == null && params.eventType === "observation" && params.significance <= 2 || ambient)
4912
+ return { created: [], affectedLinks: [], derivedEffects: [] };
4537
4913
  const ownerId = requireOwnerContactId(db);
4538
- const participantIds = params.participants.map(
4914
+ const owner = params.participants.find((p) => p.contactId === ownerId);
4915
+ const socialChannel = [
4916
+ "bluesky",
4917
+ "x",
4918
+ "twitter",
4919
+ "mastodon",
4920
+ "github",
4921
+ "instagram"
4922
+ ].includes(String(bond?.channel));
4923
+ const direction = owner?.directionality ?? (socialChannel ? owner?.role === "actor" ? "owner_initiated" : owner?.role === "recipient" ? "other_initiated" : void 0 : void 0);
4924
+ const participants = params.participants.map(
4925
+ (p) => direction && p.directionality === void 0 ? { ...p, directionality: direction } : p
4926
+ );
4927
+ if (direction)
4928
+ db.prepare(
4929
+ "UPDATE event_participants SET directionality=? WHERE event_id=? AND directionality IS NULL"
4930
+ ).run(direction, params.eventId);
4931
+ const participantIds = participants.map(
4539
4932
  (participant) => participant.contactId
4540
4933
  );
4541
4934
  const hasOwner = participantIds.includes(ownerId);
4542
4935
  const created = [];
4543
4936
  const effectInputs = [];
4544
4937
  if (hasOwner) {
4545
- for (const participant of params.participants) {
4938
+ for (const participant of participants) {
4546
4939
  if (participant.contactId === ownerId) {
4547
4940
  continue;
4548
4941
  }
@@ -4550,13 +4943,14 @@ function applyJournalEventMechanics(db, params) {
4550
4943
  fromContactId: ownerId,
4551
4944
  toContactId: participant.contactId,
4552
4945
  kind: inferAutoLinkKind(db, ownerId, participant.contactId),
4946
+ ...(participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality) === "owner_initiated" ? { affinity: 0.15 } : {},
4553
4947
  now: params.now
4554
4948
  });
4555
4949
  created.push(...ensured.created);
4556
4950
  effectInputs.push({ linkId: ensured.linkId, participant });
4557
4951
  }
4558
- } else if (params.eventType === "observation" && params.participants.length === 2) {
4559
- const [first, second] = params.participants;
4952
+ } else if (params.eventType === "observation" && participants.length === 2) {
4953
+ const [first, second] = participants;
4560
4954
  if (first !== void 0 && second !== void 0) {
4561
4955
  const a = Math.min(first.contactId, second.contactId);
4562
4956
  const b = Math.max(first.contactId, second.contactId);
@@ -4591,7 +4985,10 @@ function applyJournalEventMechanics(db, params) {
4591
4985
  eventType: params.eventType,
4592
4986
  occurredAt: params.occurredAt,
4593
4987
  significance: params.significance,
4594
- participant: input.participant,
4988
+ participant: {
4989
+ ...input.participant,
4990
+ directionality: input.participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality
4991
+ },
4595
4992
  provenance: params.provenance,
4596
4993
  now: params.now
4597
4994
  })
@@ -4624,6 +5021,8 @@ var init_apply_journal_event_mechanics = __esm({
4624
5021
  function recordCommitment(db, input) {
4625
5022
  return withTransaction(db, () => {
4626
5023
  validateSocialEventInput(input);
5024
+ const existing = existingEventReceipt(db, input.externalRef);
5025
+ if (existing) return existing;
4627
5026
  if (input.commitmentType !== "promise" && input.commitmentType !== "agreement") {
4628
5027
  throw new AffinityValidationError(
4629
5028
  "commitmentType must be promise or agreement"
@@ -4638,11 +5037,12 @@ function recordCommitment(db, input) {
4638
5037
  assertOwnerParticipates(ownerId, ids);
4639
5038
  const now = resolveNow(input.now);
4640
5039
  const eventId = insertJournalEvent(db, {
5040
+ ...input,
4641
5041
  type: input.commitmentType,
4642
5042
  occurredAt: input.occurredAt,
4643
- summary: input.summary,
5043
+ occurredOn: input.occurredOn,
4644
5044
  significance: input.significance,
4645
- momentKind: null,
5045
+ momentKind: input.momentKind ?? null,
4646
5046
  participants: input.participants,
4647
5047
  now
4648
5048
  });
@@ -4651,15 +5051,25 @@ function recordCommitment(db, input) {
4651
5051
  event_id, commitment_type, due_at, created_at, updated_at
4652
5052
  ) VALUES (?, ?, ?, ?, ?)`
4653
5053
  ).run(eventId, input.commitmentType, input.dueAt ?? null, now, now);
5054
+ db.prepare("UPDATE open_commitments SET due_on = ? WHERE event_id = ?").run(
5055
+ input.dueAt == null ? null : input.dueOn ?? new Date(input.dueAt).toISOString().slice(0, 10),
5056
+ eventId
5057
+ );
4654
5058
  const mechanics = applyJournalEventMechanics(db, {
4655
5059
  eventId,
4656
5060
  eventType: input.commitmentType,
4657
5061
  occurredAt: input.occurredAt,
5062
+ occurredOn: input.occurredOn,
4658
5063
  significance: input.significance,
4659
5064
  participants: input.participants,
4660
5065
  provenance: input.provenance,
4661
5066
  now
4662
5067
  });
5068
+ if (input.momentKind !== void 0)
5069
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5070
+ input.momentKind,
5071
+ eventId
5072
+ );
4663
5073
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
4664
5074
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4665
5075
  affectedLinks: mechanics.affectedLinks,
@@ -4672,12 +5082,13 @@ var init_record_commitment = __esm({
4672
5082
  init_queries();
4673
5083
  init_loaders();
4674
5084
  init_persistence();
4675
- init_receipts2();
5085
+ init_receipts();
4676
5086
  init_validators();
4677
5087
  init_affinity_validation_error();
4678
5088
  init_resolve_now();
4679
5089
  init_with_transaction();
4680
5090
  init_apply_journal_event_mechanics();
5091
+ init_event_metadata();
4681
5092
  }
4682
5093
  });
4683
5094
 
@@ -4746,6 +5157,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4746
5157
  const occurredAt = options?.resolvingEvent?.occurredAt ?? now;
4747
5158
  validateSocialEventInput({
4748
5159
  occurredAt,
5160
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4749
5161
  summary,
4750
5162
  significance,
4751
5163
  participants,
@@ -4762,6 +5174,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4762
5174
  const resolvingEventId = insertJournalEvent(db, {
4763
5175
  type: eventType,
4764
5176
  occurredAt,
5177
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4765
5178
  summary,
4766
5179
  significance,
4767
5180
  momentKind: null,
@@ -4800,7 +5213,7 @@ var init_resolve_commitment = __esm({
4800
5213
  init_loaders();
4801
5214
  init_persistence();
4802
5215
  init_queries2();
4803
- init_receipts2();
5216
+ init_receipts();
4804
5217
  init_validators();
4805
5218
  init_affinity_conflict_error();
4806
5219
  init_affinity_invariant_error();
@@ -4839,7 +5252,13 @@ function prepareParticipants(db, raw) {
4839
5252
  }
4840
5253
  function manageCommitmentToolHandler(db, rawInput) {
4841
5254
  const input = structuredClone(rawInput);
4842
- const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
5255
+ const inputError = validateToolInput(
5256
+ manageCommitmentTool.inputSchema,
5257
+ input,
5258
+ "input",
5259
+ Date.now(),
5260
+ true
5261
+ );
4843
5262
  if (inputError)
4844
5263
  return toolFailure(
4845
5264
  "protocol",
@@ -4869,6 +5288,21 @@ function manageCommitmentToolHandler(db, rawInput) {
4869
5288
  "commitment"
4870
5289
  );
4871
5290
  }
5291
+ if (!Number.isInteger(input.commitmentEventId))
5292
+ return toolFailure(
5293
+ "protocol",
5294
+ "invalid_input",
5295
+ "Missing commitment.",
5296
+ "commitmentEventId is required for resolve."
5297
+ );
5298
+ const event = db.prepare("SELECT type FROM events WHERE id = ? AND deleted_at IS NULL").get(input.commitmentEventId);
5299
+ if (event && !["promise", "agreement"].includes(event.type))
5300
+ return toolFailure(
5301
+ "domain",
5302
+ "invalid_input",
5303
+ "Invalid commitment.",
5304
+ "not a commitment event"
5305
+ );
4872
5306
  return mutationToolResult(
4873
5307
  "resolve",
4874
5308
  resolveCommitment(
@@ -4887,6 +5321,7 @@ var init_manage_commitment_tool = __esm({
4887
5321
  init_queries();
4888
5322
  init_record_commitment();
4889
5323
  init_resolve_commitment();
5324
+ init_event_metadata_schema();
4890
5325
  init_tool_metadata();
4891
5326
  init_tool_mutation();
4892
5327
  init_tool_names();
@@ -4919,15 +5354,21 @@ var init_manage_commitment_tool = __esm({
4919
5354
  "promise",
4920
5355
  "agreement"
4921
5356
  ]),
5357
+ ...eventMetadataSchema,
4922
5358
  occurredAt: integerSchema(
4923
5359
  "When the commitment was made, Unix timestamp in milliseconds."
4924
5360
  ),
4925
5361
  summary: stringSchema("Commitment summary. Must be non-empty."),
4926
5362
  significance: integerSchema("Commitment significance from 1 to 10."),
4927
5363
  dueAt: integerSchema("Optional due timestamp."),
5364
+ dueOn: stringSchema("Due calendar day."),
4928
5365
  participants: arraySchema(
4929
5366
  objectSchema(
4930
5367
  {
5368
+ contact: objectSchema(
5369
+ { contactId: integerSchema("Contact id.") },
5370
+ ["contactId"]
5371
+ ),
4931
5372
  contactId: integerSchema(
4932
5373
  "Participant contact id. The owner contact MUST be included."
4933
5374
  ),
@@ -4940,13 +5381,7 @@ var init_manage_commitment_tool = __esm({
4940
5381
  ),
4941
5382
  now: integerSchema("Optional timestamp override.")
4942
5383
  },
4943
- [
4944
- "commitmentType",
4945
- "occurredAt",
4946
- "summary",
4947
- "significance",
4948
- "participants"
4949
- ],
5384
+ ["commitmentType", "occurredAt", "significance", "participants"],
4950
5385
  "Commitment payload. Required when action=record."
4951
5386
  ),
4952
5387
  commitmentEventId: integerSchema(
@@ -4961,6 +5396,19 @@ var init_manage_commitment_tool = __esm({
4961
5396
  now: integerSchema("Optional timestamp."),
4962
5397
  resolvingEvent: objectSchema(
4963
5398
  {
5399
+ occurredOn: stringSchema("Calendar day."),
5400
+ provenance: {},
5401
+ participants: arraySchema(
5402
+ objectSchema(
5403
+ {
5404
+ contactId: integerSchema("Contact id."),
5405
+ role: stringSchema("Role."),
5406
+ directionality: stringSchema("Directionality.")
5407
+ },
5408
+ ["contactId", "role"]
5409
+ ),
5410
+ "Resolving participants."
5411
+ ),
4964
5412
  type: stringSchema("Optional resolving event type."),
4965
5413
  occurredAt: integerSchema("Optional resolving event timestamp."),
4966
5414
  summary: stringSchema("Optional resolving event summary."),
@@ -5054,30 +5502,50 @@ var init_delete_contact = __esm({
5054
5502
  }
5055
5503
  });
5056
5504
 
5057
- // src/contacts/export_contact.ts
5058
- function exportContact(db, id) {
5059
- const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
5505
+ // src/contacts/export_rows.ts
5506
+ function exportRows(db, sql, ...params) {
5507
+ return db.prepare(sql).all(...params).map((row) => {
5060
5508
  const result = {};
5061
5509
  for (const [key, value] of Object.entries(row)) {
5062
5510
  const name = key.replace(
5063
5511
  /_([a-z])/g,
5064
5512
  (_, letter) => letter.toUpperCase()
5065
5513
  );
5066
- result[name] = value;
5067
- if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
5514
+ result[name] = [
5515
+ "affects_bond",
5516
+ "is_owner",
5517
+ "is_structural",
5518
+ "verified",
5519
+ "counts_for_recency",
5520
+ "manual"
5521
+ ].includes(key) ? Boolean(value) : value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : value;
5522
+ if (name.endsWith("At") && typeof value === "number")
5068
5523
  result[`${name}Iso`] = new Date(value).toISOString();
5069
5524
  if (key.endsWith("_json") && typeof value === "string")
5070
5525
  result[name] = JSON.parse(value);
5071
5526
  }
5072
5527
  return result;
5073
5528
  });
5529
+ }
5530
+ var init_export_rows = __esm({
5531
+ "src/contacts/export_rows.ts"() {
5532
+ }
5533
+ });
5534
+
5535
+ // src/contacts/export_contact.ts
5536
+ function exportContact(db, id) {
5537
+ const query = (sql, ...params) => exportRows(db, sql, ...params);
5074
5538
  const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
5075
5539
  if (!contact) throw new AffinityNotFoundError("contact not found");
5076
5540
  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",
5541
+ "SELECT DISTINCT e.*, 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
5542
  id
5079
5543
  ).map((event) => ({
5080
5544
  ...event,
5545
+ attributes: query(
5546
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5547
+ event.id
5548
+ ),
5081
5549
  participants: query(
5082
5550
  "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
5551
  event.id
@@ -5121,22 +5589,46 @@ function exportContact(db, id) {
5121
5589
  var init_export_contact = __esm({
5122
5590
  "src/contacts/export_contact.ts"() {
5123
5591
  init_affinity_not_found_error();
5592
+ init_export_rows();
5124
5593
  }
5125
5594
  });
5126
5595
 
5127
5596
  // src/contacts/export_affinity.ts
5128
5597
  function exportAffinity(db) {
5129
5598
  const now = Date.now();
5599
+ const records = db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id));
5600
+ const events = exportRows(
5601
+ db,
5602
+ "SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary FROM events ORDER BY id"
5603
+ ).map((event) => ({
5604
+ ...event,
5605
+ attributes: exportRows(
5606
+ db,
5607
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5608
+ event.id
5609
+ ),
5610
+ participants: exportRows(
5611
+ db,
5612
+ "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",
5613
+ event.id
5614
+ )
5615
+ }));
5130
5616
  return {
5131
5617
  version: 1,
5132
5618
  exportedAt: now,
5133
5619
  exportedAtIso: new Date(now).toISOString(),
5134
- contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
5620
+ events,
5621
+ contacts: records.map(({ events: events2, dates, ...record }) => ({
5622
+ ...record,
5623
+ eventIds: events2.map((event) => event.id),
5624
+ dateIds: dates.map((date) => date.id)
5625
+ }))
5135
5626
  };
5136
5627
  }
5137
5628
  var init_export_affinity = __esm({
5138
5629
  "src/contacts/export_affinity.ts"() {
5139
5630
  init_export_contact();
5631
+ init_export_rows();
5140
5632
  }
5141
5633
  });
5142
5634
 
@@ -5227,7 +5719,7 @@ function assertValidLifecycleTransition(from, to) {
5227
5719
  }
5228
5720
  if (to === "merged") {
5229
5721
  throw new AffinityInvariantError(
5230
- "use manage_contact merge (CLI: contact merge) to reach merged state"
5722
+ "use merge_contacts (CLI: contact merge) to reach merged state"
5231
5723
  );
5232
5724
  }
5233
5725
  if (from === to) {
@@ -5300,147 +5792,86 @@ var init_set_contact_lifecycle = __esm({
5300
5792
  }
5301
5793
  });
5302
5794
 
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) {
5795
+ // src/identities/queries.ts
5796
+ function getIdentityRowById(db, id) {
5797
+ const row = db.prepare(
5798
+ `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5799
+ created_at, updated_at, removed_at
5800
+ FROM identities
5801
+ WHERE id = ? AND removed_at IS NULL`
5802
+ ).get(id);
5803
+ if (row === void 0) {
5804
+ return null;
5805
+ }
5806
+ return row;
5807
+ }
5808
+ var init_queries5 = __esm({
5809
+ "src/identities/queries.ts"() {
5810
+ }
5811
+ });
5812
+
5813
+ // src/identities/receipts.ts
5814
+ function buildIdentityMutationReceipt(primary, partial) {
5815
+ const normalized = normalizeMutationReceiptPartial(partial);
5816
+ return {
5817
+ primary,
5818
+ created: normalized.created,
5819
+ updated: normalized.updated,
5820
+ archived: normalized.archived,
5821
+ removed: normalized.removed,
5822
+ affectedLinks: normalized.affectedLinks,
5823
+ derivedEffects: normalized.derivedEffects
5824
+ };
5825
+ }
5826
+ var init_receipts5 = __esm({
5827
+ "src/identities/receipts.ts"() {
5828
+ init_normalize_mutation_receipt();
5829
+ }
5830
+ });
5831
+
5832
+ // src/identities/validators.ts
5833
+ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5834
+ const row = excludeIdentityId === void 0 ? db.prepare(
5835
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL"
5836
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey) : db.prepare(
5837
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL AND id != ?"
5838
+ ).get(
5839
+ normalizedKey,
5840
+ legacyIdentityKey(normalizedKey),
5841
+ normalizedKey,
5842
+ excludeIdentityId
5843
+ );
5844
+ if (row !== void 0) {
5845
+ const owner = db.prepare(
5846
+ "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"
5847
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey);
5848
+ throw new AffinityConflictError(
5849
+ `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
5850
+ );
5851
+ }
5852
+ }
5853
+ var init_validators5 = __esm({
5854
+ "src/identities/validators.ts"() {
5855
+ init_affinity_conflict_error();
5856
+ init_normalize();
5857
+ }
5858
+ });
5859
+
5860
+ // src/identities/validate_identity_input.ts
5861
+ function validateIdentityInput(type, rawValue) {
5862
+ const kind = type.toLowerCase();
5863
+ const value = normalizeIdentityValue(kind, rawValue);
5864
+ if (["did", "atproto_did", "custom:atproto-did"].includes(kind) && !/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value))
5865
+ throw new AffinityValidationError(
5866
+ "did value must match did:<method>:<identifier> (for example did:plc:abc123)"
5867
+ );
5868
+ if (handleIdentityTypes.has(kind)) {
5869
+ 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_.-]+$/;
5870
+ if (!pattern.test(value))
5314
5871
  throw new AffinityValidationError(
5315
- "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
5872
+ `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)"}`
5316
5873
  );
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;
5394
- }
5395
- var init_queries5 = __esm({
5396
- "src/identities/queries.ts"() {
5397
- }
5398
- });
5399
-
5400
- // src/identities/receipts.ts
5401
- function buildIdentityMutationReceipt(primary, partial) {
5402
- const normalized = normalizeMutationReceiptPartial(partial);
5403
- return {
5404
- primary,
5405
- created: normalized.created,
5406
- updated: normalized.updated,
5407
- archived: normalized.archived,
5408
- removed: normalized.removed,
5409
- affectedLinks: normalized.affectedLinks,
5410
- derivedEffects: normalized.derivedEffects
5411
- };
5412
- }
5413
- var init_receipts5 = __esm({
5414
- "src/identities/receipts.ts"() {
5415
- init_normalize_mutation_receipt();
5416
- }
5417
- });
5418
-
5419
- // src/identities/validators.ts
5420
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5421
- 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);
5426
- if (row !== void 0) {
5427
- 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);
5430
- throw new AffinityConflictError(
5431
- `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
5432
- );
5433
5874
  }
5434
- }
5435
- var init_validators5 = __esm({
5436
- "src/identities/validators.ts"() {
5437
- init_affinity_conflict_error();
5438
- }
5439
- });
5440
-
5441
- // src/identities/validate_identity_input.ts
5442
- function validateIdentityInput(type, value) {
5443
- const kind = type.toLowerCase();
5444
5875
  if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
5445
5876
  throw new AffinityValidationError(
5446
5877
  `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
@@ -5471,7 +5902,10 @@ var identityTypes;
5471
5902
  var init_validate_identity_input = __esm({
5472
5903
  "src/identities/validate_identity_input.ts"() {
5473
5904
  init_affinity_validation_error();
5905
+ init_normalize();
5474
5906
  identityTypes = [
5907
+ "did",
5908
+ "atproto_did",
5475
5909
  "email",
5476
5910
  "phone",
5477
5911
  "url",
@@ -5512,12 +5946,27 @@ function addIdentity(db, contactId, input) {
5512
5946
  throw new AffinityStateError("merged contact is read-only");
5513
5947
  }
5514
5948
  const type = input.type.trim();
5515
- const value = input.value.trim();
5949
+ const value = normalizeIdentityValue(type, input.value);
5516
5950
  if (type.length === 0 || value.length === 0) {
5517
5951
  throw new AffinityValidationError("type and value must be non-empty");
5518
5952
  }
5519
5953
  validateIdentityInput(type, value);
5954
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
5955
+ throw new AffinityValidationError("ifExists must be error or return");
5520
5956
  const nk = normalizeIdentityKey(type, value);
5957
+ if (input.ifExists === "return") {
5958
+ const existing = db.prepare(
5959
+ "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"
5960
+ ).get(contactId, nk, legacyIdentityKey(nk), nk);
5961
+ if (existing) {
5962
+ const row2 = getIdentityRowById(db, Number(existing.id));
5963
+ if (row2)
5964
+ return buildIdentityMutationReceipt(
5965
+ mapIdentityRowToIdentityRecord(row2),
5966
+ {}
5967
+ );
5968
+ }
5969
+ }
5521
5970
  assertNoIdentityCollision(db, nk);
5522
5971
  const now = resolveNow(input.now);
5523
5972
  const verified = input.verified ? 1 : 0;
@@ -5569,31 +6018,266 @@ var init_add_identity = __esm({
5569
6018
  }
5570
6019
  });
5571
6020
 
5572
- // src/tools/create_contact_tool_result.ts
5573
- function createContactToolResult(db, input) {
6021
+ // src/contacts/create_contact.ts
6022
+ function createContact(db, input) {
5574
6023
  return withTransaction(db, () => {
5575
- const receipt = createContact(db, input);
5576
- const identityOwners = /* @__PURE__ */ new Set();
5577
- for (const identity of input.identities ?? []) {
6024
+ const name = input.name.trim();
6025
+ if (name.length === 0) {
6026
+ throw new AffinityValidationError("name must be non-empty");
6027
+ }
6028
+ if (!KINDS.has(input.kind)) {
6029
+ throw new AffinityValidationError("invalid contact kind");
6030
+ }
6031
+ if (input.isOwner !== void 0) {
6032
+ throw new AffinityValidationError(
6033
+ "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
6034
+ );
6035
+ }
6036
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
6037
+ throw new AffinityValidationError("ifExists must be error or return");
6038
+ const matches = (input.identities ?? []).flatMap((identity) => {
5578
6039
  validateIdentityInput(identity.type.trim(), identity.value.trim());
6040
+ const key = normalizeIdentityKey(identity.type, identity.value);
6041
+ const row2 = db.prepare(
6042
+ `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)))`
6043
+ ).get(key, legacyIdentityKey(key), key);
6044
+ return row2 ? [{ ...row2, identity }] : [];
6045
+ });
6046
+ const match = matches[0];
6047
+ if (match) {
6048
+ if (input.ifExists === "return") {
6049
+ const existing = getContactRowById(db, match.id);
6050
+ if (!existing)
6051
+ throw new AffinityInvariantError("identity owner not found");
6052
+ const primary2 = mapContactRowToContactListItem(existing);
6053
+ return Object.assign(buildContactMutationReceipt(primary2, {}), {
6054
+ outcome: "no_op",
6055
+ matchedIdentities: matches.map((m) => m.identity)
6056
+ });
6057
+ }
6058
+ const error = new AffinityConflictError(
6059
+ `identity ${normalizeIdentityKey(match.identity.type, match.identity.value).replace(":", " ")} already belongs to #${match.id} ${match.name}`
6060
+ );
6061
+ error.existingContactId = match.id;
6062
+ throw error;
6063
+ }
6064
+ const now = resolveNow(input.now);
6065
+ if (input.bootstrapOwner) {
5579
6066
  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));
6067
+ "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
6068
+ ).get();
6069
+ if (existing !== void 0) {
6070
+ throw new AffinityConflictError(
6071
+ `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
6072
+ );
6073
+ }
6074
+ }
6075
+ const isOwner = input.bootstrapOwner ? 1 : 0;
6076
+ const result = db.prepare(
6077
+ `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
6078
+ VALUES (?, ?, 'active', ?, ?, ?)`
6079
+ ).run(name, input.kind, isOwner, now, now);
6080
+ const id = Number(result.lastInsertRowid);
6081
+ const row = getContactRowById(db, id);
6082
+ if (!row) {
6083
+ throw new AffinityInvariantError("inserted contact row not found");
6084
+ }
6085
+ refreshContactRollup(db, id, now);
6086
+ const primary = mapContactRowToContactListItem(row);
6087
+ const receipt = buildContactMutationReceipt(primary, {
6088
+ created: [{ kind: "contact", id }]
6089
+ });
6090
+ for (const identity of input.identities ?? [])
6091
+ receipt.created.push(...addIdentity(db, id, identity).created);
6092
+ return receipt;
6093
+ });
6094
+ }
6095
+ var KINDS;
6096
+ var init_create_contact = __esm({
6097
+ "src/contacts/create_contact.ts"() {
6098
+ init_mappers4();
6099
+ init_queries();
6100
+ init_receipts4();
6101
+ init_rollups();
6102
+ init_add_identity();
6103
+ init_normalize();
6104
+ init_validate_identity_input();
6105
+ init_affinity_conflict_error();
6106
+ init_affinity_invariant_error();
6107
+ init_affinity_validation_error();
6108
+ init_resolve_now();
6109
+ init_with_transaction();
6110
+ KINDS = /* @__PURE__ */ new Set([
6111
+ "human",
6112
+ "group",
6113
+ "company",
6114
+ "team",
6115
+ "pet",
6116
+ "service",
6117
+ "other"
6118
+ ]);
6119
+ }
6120
+ });
6121
+
6122
+ // src/contacts/fold_search_text.ts
6123
+ function foldSearchText(value) {
6124
+ let folded = value.toLowerCase();
6125
+ if (/[^\u0020-\u007e]/.test(folded))
6126
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
6127
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
6128
+ }
6129
+ var init_fold_search_text = __esm({
6130
+ "src/contacts/fold_search_text.ts"() {
6131
+ }
6132
+ });
6133
+
6134
+ // src/contacts/list_duplicate_candidates.ts
6135
+ function pairKey(leftContactId, rightContactId) {
6136
+ const left = Math.min(leftContactId, rightContactId);
6137
+ const right = Math.max(leftContactId, rightContactId);
6138
+ return `${left}:${right}`;
6139
+ }
6140
+ function normalizeName(name) {
6141
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
6142
+ }
6143
+ function fuzzyNameScore(leftInput, rightInput) {
6144
+ const left = normalizeName(leftInput);
6145
+ const right = normalizeName(rightInput);
6146
+ if (left === right) {
6147
+ return 0.92;
6148
+ }
6149
+ if (left.length >= 3 && right.length >= 3) {
6150
+ if (left.includes(right) || right.includes(left)) {
6151
+ return 0.72;
6152
+ }
6153
+ const leftTokens = new Set(left.split(" "));
6154
+ const rightTokens = new Set(right.split(" "));
6155
+ const overlap = [...leftTokens].filter(
6156
+ (token) => rightTokens.has(token)
6157
+ ).length;
6158
+ if (overlap > 0) {
6159
+ return overlap / Math.max(leftTokens.size, rightTokens.size) * 0.7;
6160
+ }
6161
+ }
6162
+ return 0;
6163
+ }
6164
+ function listDuplicateCandidates(db, filters, options) {
6165
+ assertDefaultOrdering("listDuplicateCandidates", options);
6166
+ const { limit, offset } = resolveListLimitOffset(options);
6167
+ const subset = filters?.contactIds;
6168
+ const contacts = db.prepare(
6169
+ `SELECT id, name
6170
+ FROM contacts
6171
+ WHERE deleted_at IS NULL
6172
+ AND lifecycle_state != 'merged'`
6173
+ ).all();
6174
+ const allowedIds = subset === void 0 ? null : new Set(subset.map((contactId) => Number(contactId)));
6175
+ const pairs = /* @__PURE__ */ new Map();
6176
+ const exactMatches = db.prepare(
6177
+ `SELECT
6178
+ i1.contact_id AS left_contact_id,
6179
+ i2.contact_id AS right_contact_id,
6180
+ i1.normalized_key
6181
+ FROM identities i1
6182
+ INNER JOIN identities i2
6183
+ ON i1.normalized_key = i2.normalized_key
6184
+ AND i1.contact_id < i2.contact_id
6185
+ INNER JOIN contacts c1 ON c1.id = i1.contact_id
6186
+ INNER JOIN contacts c2 ON c2.id = i2.contact_id
6187
+ WHERE i1.removed_at IS NULL
6188
+ AND i2.removed_at IS NULL
6189
+ AND c1.deleted_at IS NULL
6190
+ AND c2.deleted_at IS NULL
6191
+ AND c1.lifecycle_state != 'merged'
6192
+ AND c2.lifecycle_state != 'merged'`
6193
+ ).all();
6194
+ for (const match of exactMatches) {
6195
+ if (allowedIds !== null && (!allowedIds.has(match.left_contact_id) || !allowedIds.has(match.right_contact_id))) {
6196
+ continue;
6197
+ }
6198
+ pairs.set(pairKey(match.left_contact_id, match.right_contact_id), {
6199
+ leftContactId: match.left_contact_id,
6200
+ rightContactId: match.right_contact_id,
6201
+ matchReason: `exact identity ${match.normalized_key}`,
6202
+ matchScore: 1
6203
+ });
6204
+ }
6205
+ if (filters?.exactOnly !== true) {
6206
+ for (let index = 0; index < contacts.length; index += 1) {
6207
+ const left = contacts[index];
6208
+ if (left === void 0 || allowedIds !== null && !allowedIds.has(left.id)) {
5585
6209
  continue;
5586
6210
  }
5587
- const added = addIdentity(db, receipt.primary.id, identity);
5588
- receipt.created.push(...added.created);
6211
+ const leftName = normalizeName(left.name);
6212
+ for (let offsetIndex = index + 1; offsetIndex < contacts.length; offsetIndex += 1) {
6213
+ const right = contacts[offsetIndex];
6214
+ if (right === void 0 || allowedIds !== null && !allowedIds.has(right.id)) {
6215
+ continue;
6216
+ }
6217
+ const score = fuzzyNameScore(leftName, normalizeName(right.name));
6218
+ if (score <= 0) {
6219
+ continue;
6220
+ }
6221
+ const key = pairKey(left.id, right.id);
6222
+ if (!pairs.has(key)) {
6223
+ pairs.set(key, {
6224
+ leftContactId: left.id,
6225
+ rightContactId: right.id,
6226
+ matchReason: "name similarity",
6227
+ matchScore: score
6228
+ });
6229
+ }
6230
+ }
5589
6231
  }
6232
+ }
6233
+ const dismissedRows = db.prepare("SELECT left_id, right_id FROM dismissed_duplicates").all();
6234
+ const dismissedSet = new Set(
6235
+ dismissedRows.map((r) => pairKey(r.left_id, r.right_id))
6236
+ );
6237
+ const includeDismissed = filters?.includeDismissed === true;
6238
+ for (const candidate of pairs.values()) {
6239
+ if (dismissedSet.has(
6240
+ pairKey(candidate.leftContactId, candidate.rightContactId)
6241
+ )) {
6242
+ candidate.dismissed = true;
6243
+ }
6244
+ }
6245
+ const minScore = filters?.minScore ?? 0;
6246
+ return [...pairs.values()].filter((candidate) => {
6247
+ if (candidate.matchScore < minScore) return false;
6248
+ if (candidate.dismissed === true && !includeDismissed) return false;
6249
+ return true;
6250
+ }).sort((left, right) => {
6251
+ const leftExact = left.matchScore === 1 ? 1 : 0;
6252
+ const rightExact = right.matchScore === 1 ? 1 : 0;
6253
+ return rightExact - leftExact || right.matchScore - left.matchScore;
6254
+ }).slice(offset, offset + limit);
6255
+ }
6256
+ var init_list_duplicate_candidates = __esm({
6257
+ "src/contacts/list_duplicate_candidates.ts"() {
6258
+ init_assert_default_ordering();
6259
+ init_resolve_list_limit_offset();
6260
+ }
6261
+ });
6262
+
6263
+ // src/tools/create_contact_tool_result.ts
6264
+ function createContactToolResult(db, input) {
6265
+ return withTransaction(db, () => {
6266
+ const receipt = createContact(db, input);
5590
6267
  const result = mutationToolResult("create", receipt, "contact");
6268
+ if (result.ok && receipt.outcome === "no_op") {
6269
+ result.outcome = "no_op";
6270
+ Object.assign(result.data, {
6271
+ matchedIdentities: receipt.matchedIdentities
6272
+ });
6273
+ return result;
6274
+ }
5591
6275
  const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
5592
6276
  const warnings = db.prepare(
5593
6277
  "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
5594
6278
  ).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 ? [] : [
6279
+ const score = fold(row.name) === fold(input.name) ? 0.92 : fuzzyNameScore(row.name, input.name);
6280
+ return score <= 0 ? [] : [
5597
6281
  {
5598
6282
  code: "possible_duplicate",
5599
6283
  message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
@@ -5610,9 +6294,7 @@ var init_create_contact_tool_result = __esm({
5610
6294
  "src/tools/create_contact_tool_result.ts"() {
5611
6295
  init_create_contact();
5612
6296
  init_fold_search_text();
5613
- init_add_identity();
5614
- init_normalize();
5615
- init_validate_identity_input();
6297
+ init_list_duplicate_candidates();
5616
6298
  init_with_transaction();
5617
6299
  init_tool_mutation();
5618
6300
  }
@@ -5687,6 +6369,13 @@ function manageContactToolHandler(db, rawInput) {
5687
6369
  return contact.result;
5688
6370
  }
5689
6371
  if (input.action === "delete") {
6372
+ if (contact.value.is_owner)
6373
+ return toolFailure(
6374
+ "domain",
6375
+ "conflict",
6376
+ "Cannot delete owner.",
6377
+ "The owner contact cannot be deleted"
6378
+ );
5690
6379
  const deletion = deleteContact(db, contact.value.id, { confirm: true });
5691
6380
  return toolSuccess("Permanently deleted contact.", {
5692
6381
  action: "delete",
@@ -5776,7 +6465,11 @@ var init_manage_contact_tool = __esm({
5776
6465
  },
5777
6466
  ["type", "value"]
5778
6467
  ),
5779
- "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
6468
+ "Optional identities to attach. An identity owned by another live contact causes a conflict, or returns that contact when ifExists=return."
6469
+ ),
6470
+ ifExists: enumSchema(
6471
+ "Identity collision policy; return attaches nothing.",
6472
+ ["error", "return"]
5780
6473
  ),
5781
6474
  name: stringSchema("Contact name."),
5782
6475
  kind: enumSchema("Contact kind.", [
@@ -5900,7 +6593,7 @@ var init_add_date_anchor = __esm({
5900
6593
  init_loaders();
5901
6594
  init_persistence();
5902
6595
  init_queries2();
5903
- init_receipts2();
6596
+ init_receipts();
5904
6597
  init_validators();
5905
6598
  init_affinity_conflict_error();
5906
6599
  init_affinity_not_found_error();
@@ -5932,7 +6625,7 @@ var init_remove_date_anchor = __esm({
5932
6625
  "src/dates/remove_date_anchor.ts"() {
5933
6626
  init_loaders();
5934
6627
  init_mappers();
5935
- init_receipts2();
6628
+ init_receipts();
5936
6629
  init_resolve_now();
5937
6630
  init_with_transaction();
5938
6631
  init_upcoming_occurrences();
@@ -6001,7 +6694,7 @@ var init_revise_date_anchor = __esm({
6001
6694
  "src/dates/revise_date_anchor.ts"() {
6002
6695
  init_loaders();
6003
6696
  init_queries2();
6004
- init_receipts2();
6697
+ init_receipts();
6005
6698
  init_validators();
6006
6699
  init_affinity_conflict_error();
6007
6700
  init_affinity_validation_error();
@@ -6431,50 +7124,345 @@ function manageDuplicateDismissalToolHandler(db, rawInput) {
6431
7124
  ] : []
6432
7125
  }
6433
7126
  );
6434
- }, "Duplicate dismissal failed.");
7127
+ }, "Duplicate dismissal failed.");
7128
+ }
7129
+ var manageDuplicateDismissalTool;
7130
+ var init_manage_duplicate_dismissal_tool = __esm({
7131
+ "src/tools/manage_duplicate_dismissal_tool.ts"() {
7132
+ init_dismiss_duplicate();
7133
+ init_undismiss_duplicate();
7134
+ init_tool_metadata();
7135
+ init_tool_names();
7136
+ init_tool_resolvers();
7137
+ init_tool_types();
7138
+ init_tool_types();
7139
+ init_validate_tool_input();
7140
+ manageDuplicateDismissalTool = defineAffinityTool({
7141
+ name: manageDuplicateDismissalToolName,
7142
+ description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
7143
+ whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
7144
+ whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
7145
+ sideEffects: "writes_state",
7146
+ readOnly: false,
7147
+ supportsClarification: true,
7148
+ targetKinds: ["contact"],
7149
+ inputDescriptions: {
7150
+ action: "dismiss to suppress the pair; undismiss to re-surface it.",
7151
+ left: "One contact of the pair.",
7152
+ right: "The other contact of the pair.",
7153
+ reason: "Optional reason for the dismissal (audit trail)."
7154
+ },
7155
+ outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
7156
+ inputSchema: objectSchema(
7157
+ {
7158
+ action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
7159
+ left: contactLocatorSchema5(
7160
+ "Left contact. Provide contactId or identity."
7161
+ ),
7162
+ right: contactLocatorSchema5(
7163
+ "Right contact. Provide contactId or identity."
7164
+ ),
7165
+ reason: nullableStringSchema("Optional reason for the dismissal.")
7166
+ },
7167
+ ["action", "left", "right"],
7168
+ "Dismiss or un-dismiss a duplicate candidate pair."
7169
+ ),
7170
+ handler: manageDuplicateDismissalToolHandler
7171
+ });
7172
+ }
7173
+ });
7174
+
7175
+ // src/events/event_replay.ts
7176
+ function replayEventLinks(db, linkIds, now) {
7177
+ const eventIds = /* @__PURE__ */ new Set();
7178
+ for (const linkId of linkIds) {
7179
+ const saved = db.prepare("SELECT state_json FROM event_link_baselines WHERE link_id=?").get(linkId);
7180
+ if (!saved) continue;
7181
+ const baseline = JSON.parse(saved.state_json);
7182
+ const effects = db.prepare(
7183
+ "SELECT event_id, participant_json, provenance_json, created_at FROM link_event_effects WHERE link_id=? ORDER BY event_id"
7184
+ ).all(linkId);
7185
+ const overrides = db.prepare(
7186
+ "SELECT after_event_id,state FROM event_link_state_changes WHERE link_id=? ORDER BY id"
7187
+ ).all(linkId);
7188
+ let overrideIndex = 0;
7189
+ const applyOverrides = (beforeId) => {
7190
+ while (overrideIndex < overrides.length && (overrides[overrideIndex]?.after_event_id ?? Number.POSITIVE_INFINITY) < beforeId) {
7191
+ const override = overrides[overrideIndex];
7192
+ if (override)
7193
+ db.prepare("UPDATE links SET state=? WHERE id=?").run(
7194
+ override.state,
7195
+ linkId
7196
+ );
7197
+ overrideIndex++;
7198
+ }
7199
+ };
7200
+ db.prepare("DELETE FROM link_event_effects WHERE link_id=?").run(linkId);
7201
+ db.prepare(
7202
+ "UPDATE links SET rank=?,affinity=?,trust=?,state=?,cadence_days=?,updated_at=? WHERE id=?"
7203
+ ).run(
7204
+ baseline.rank,
7205
+ baseline.affinity,
7206
+ baseline.trust,
7207
+ baseline.state,
7208
+ baseline.cadence_days,
7209
+ now,
7210
+ linkId
7211
+ );
7212
+ for (const effect of effects) {
7213
+ eventIds.add(effect.event_id);
7214
+ applyOverrides(effect.event_id);
7215
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(effect.event_id))
7216
+ continue;
7217
+ const event = loadEventRecord(db, effect.event_id);
7218
+ const link = db.prepare("SELECT from_contact_id,to_contact_id FROM links WHERE id=?").get(linkId);
7219
+ const participant = effect.participant_json ? JSON.parse(effect.participant_json) : event.participants.find((p) => p.contactId === link.to_contact_id) ?? event.participants[0];
7220
+ const provenance = db.prepare("SELECT provenance_json FROM events WHERE id=?").get(event.id);
7221
+ applyEventEffectToLink(db, {
7222
+ linkId,
7223
+ eventId: event.id,
7224
+ eventType: event.type,
7225
+ occurredAt: event.occurredAt,
7226
+ significance: event.significance,
7227
+ participant,
7228
+ provenance: provenance.provenance_json ? JSON.parse(provenance.provenance_json) : effect.provenance_json ? JSON.parse(effect.provenance_json) : void 0,
7229
+ now: effect.created_at
7230
+ });
7231
+ }
7232
+ applyOverrides(Number.POSITIVE_INFINITY);
7233
+ refreshLinkRollup(db, linkId, now);
7234
+ }
7235
+ for (const eventId of eventIds)
7236
+ db.prepare(
7237
+ "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=?"
7238
+ ).run(eventId);
7239
+ refreshAllBridgeScores(db, now);
7240
+ }
7241
+ var init_event_replay = __esm({
7242
+ "src/events/event_replay.ts"() {
7243
+ init_bridge_scores();
7244
+ init_effects();
7245
+ init_rollups2();
7246
+ init_loaders();
7247
+ }
7248
+ });
7249
+
7250
+ // src/events/manage_event.ts
7251
+ function requireEventTarget(db, target) {
7252
+ const selector = typeof target === "number" ? { eventId: target } : target;
7253
+ if (!selector || selector.eventId === void 0 === (selector.ref === void 0))
7254
+ throw new AffinityValidationError("Provide exactly one of eventId or ref");
7255
+ const event = selector.ref ? getEventByExternalRef(db, selector.ref.source, selector.ref.id) : getEventById(db, selector.eventId);
7256
+ if (!event) throw new AffinityNotFoundError("event not found");
7257
+ return event;
7258
+ }
7259
+ function affected(db, id) {
7260
+ return db.prepare("SELECT link_id FROM link_event_effects WHERE event_id=?").all(id).map((r) => r.link_id);
7261
+ }
7262
+ function reviseEvent(db, target, patch) {
7263
+ return withTransaction(db, () => {
7264
+ const allowed = [
7265
+ "summary",
7266
+ "content",
7267
+ "occurredAt",
7268
+ "significance",
7269
+ "channel",
7270
+ "type",
7271
+ "externalRef"
7272
+ ];
7273
+ for (const key of Object.keys(patch))
7274
+ if (!allowed.includes(key))
7275
+ throw new AffinityValidationError(
7276
+ `unknown patch key ${key}; allowed keys: ${allowed.join(", ")}`
7277
+ );
7278
+ const event = requireEventTarget(db, target);
7279
+ const next = { ...event, ...patch };
7280
+ const { momentKind: _momentKind, ...metadata } = next;
7281
+ validateEventMetadata(metadata);
7282
+ Object.assign(next, metadata);
7283
+ if (patch.summary !== void 0 && (typeof patch.summary !== "string" || !patch.summary.trim()))
7284
+ throw new AffinityValidationError("summary must be non-empty");
7285
+ if (patch.occurredAt !== void 0)
7286
+ assertFiniteTimestamp(patch.occurredAt, "occurredAt");
7287
+ if (!Number.isInteger(next.significance) || next.significance < 1 || next.significance > 10)
7288
+ throw new AffinityValidationError(
7289
+ "significance must be an integer from 1 to 10"
7290
+ );
7291
+ const interactions = [
7292
+ "conversation",
7293
+ "activity",
7294
+ "gift",
7295
+ "support",
7296
+ "conflict",
7297
+ "correction"
7298
+ ];
7299
+ if (next.type !== event.type && !(interactions.includes(event.type) && interactions.includes(next.type)) && !(["promise", "agreement"].includes(event.type) && ["promise", "agreement"].includes(next.type)))
7300
+ throw new AffinityValidationError(
7301
+ "type must stay within the same event kind"
7302
+ );
7303
+ if (patch.externalRef && event.externalRef)
7304
+ throw new AffinityConflictError(
7305
+ "externalRef can only be set when absent"
7306
+ );
7307
+ if (patch.externalRef && next.externalRef && getEventByExternalRef(db, next.externalRef.source, next.externalRef.id))
7308
+ throw new AffinityConflictError(
7309
+ "externalRef already belongs to an event"
7310
+ );
7311
+ const now = Date.now();
7312
+ const links = affected(db, event.id);
7313
+ db.prepare(
7314
+ "UPDATE events SET summary=?,content=?,occurred_at=?,occurred_on=?,significance=?,channel=?,type=?,external_source=?,external_id=?,updated_at=? WHERE id=?"
7315
+ ).run(
7316
+ next.summary.trim(),
7317
+ next.content ?? null,
7318
+ next.occurredAt,
7319
+ patch.occurredAt === void 0 ? event.occurredOn ?? null : new Date(next.occurredAt).toISOString().slice(0, 10),
7320
+ next.significance,
7321
+ next.channel ?? null,
7322
+ next.type,
7323
+ next.externalRef?.source ?? null,
7324
+ next.externalRef?.id ?? null,
7325
+ now,
7326
+ event.id
7327
+ );
7328
+ if (patch.type && ["promise", "agreement"].includes(patch.type))
7329
+ db.prepare(
7330
+ "UPDATE open_commitments SET commitment_type=? WHERE event_id=?"
7331
+ ).run(patch.type, event.id);
7332
+ if (patch.significance !== void 0 || patch.type !== void 0 || patch.occurredAt !== void 0)
7333
+ replayEventLinks(db, links, now);
7334
+ return buildEventMutationReceipt(requireEventTarget(db, event.id), {
7335
+ updated: [{ kind: "event", id: event.id }],
7336
+ affectedLinks: links
7337
+ });
7338
+ });
6435
7339
  }
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();
7340
+ function deleteEvent(db, target, options) {
7341
+ return withTransaction(db, () => {
7342
+ if (options?.confirm !== true)
7343
+ throw new AffinityValidationError("deleteEvent requires confirm: true");
7344
+ const event = requireEventTarget(db, target);
7345
+ const links = affected(db, event.id);
7346
+ for (const table of [
7347
+ "event_participants",
7348
+ "attributes",
7349
+ "open_commitments",
7350
+ "upcoming_occurrences",
7351
+ "link_event_effects"
7352
+ ])
7353
+ db.prepare(`DELETE FROM ${table} WHERE event_id=?`).run(event.id);
7354
+ db.prepare(
7355
+ "UPDATE events SET parent_event_id=NULL WHERE parent_event_id=?"
7356
+ ).run(event.id);
7357
+ db.prepare("DELETE FROM events WHERE id=?").run(event.id);
7358
+ replayEventLinks(db, links, Date.now());
7359
+ return buildEventMutationReceipt(event, {
7360
+ removed: [{ kind: "event", id: event.id }],
7361
+ affectedLinks: links
7362
+ });
7363
+ });
7364
+ }
7365
+ var init_manage_event = __esm({
7366
+ "src/events/manage_event.ts"() {
7367
+ init_affinity_conflict_error();
7368
+ init_affinity_not_found_error();
7369
+ init_affinity_validation_error();
7370
+ init_with_transaction();
7371
+ init_event_metadata();
7372
+ init_event_replay();
7373
+ init_receipts();
7374
+ init_validators();
7375
+ }
7376
+ });
7377
+
7378
+ // src/tools/manage_event_tool.ts
7379
+ function manageEventToolHandler(db, raw) {
7380
+ const input = structuredClone(raw);
7381
+ const error = validateToolInput(
7382
+ manageEventTool.inputSchema,
7383
+ input,
7384
+ "input",
7385
+ Date.now(),
7386
+ true
7387
+ );
7388
+ if (error)
7389
+ return toolFailure("protocol", "invalid_input", "Invalid input.", error);
7390
+ return withToolHandling(() => {
7391
+ const target = {
7392
+ ...input.eventId === void 0 ? {} : { eventId: input.eventId },
7393
+ ...input.ref === void 0 ? {} : { ref: input.ref }
7394
+ };
7395
+ return mutationToolResult(
7396
+ input.action,
7397
+ input.action === "revise" ? reviseEvent(db, target, input.patch ?? {}) : deleteEvent(db, target, { confirm: input.confirm === true }),
7398
+ "event"
7399
+ );
7400
+ }, "Event management failed.");
7401
+ }
7402
+ var manageEventTool;
7403
+ var init_manage_event_tool = __esm({
7404
+ "src/tools/manage_event_tool.ts"() {
7405
+ init_manage_event();
7406
+ init_event_metadata_schema();
6441
7407
  init_tool_metadata();
7408
+ init_tool_mutation();
6442
7409
  init_tool_names();
6443
7410
  init_tool_resolvers();
6444
7411
  init_tool_types();
6445
- init_tool_types();
6446
7412
  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.",
7413
+ manageEventTool = defineAffinityTool({
7414
+ name: manageEventToolName,
7415
+ description: "Revise or delete one event by eventId or external reference. Delete requires confirm=true and recomputes affected relationships.",
7416
+ whenToUse: "Correct imported event text or metadata, or remove an event.",
7417
+ whenNotToUse: "Use record_event to capture new evidence.",
6452
7418
  sideEffects: "writes_state",
6453
7419
  readOnly: false,
6454
- supportsClarification: true,
6455
- targetKinds: ["contact"],
7420
+ supportsClarification: false,
7421
+ targetKinds: ["event"],
6456
7422
  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)."
7423
+ action: "revise or delete",
7424
+ eventId: "Exact event id",
7425
+ ref: "External event reference",
7426
+ patch: "Fields to revise",
7427
+ confirm: "Required true for deletion"
6461
7428
  },
6462
- outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
7429
+ outputDescription: "Event mutation receipt and affected links.",
6463
7430
  inputSchema: objectSchema(
6464
7431
  {
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.")
7432
+ action: enumSchema("Action.", ["revise", "delete"]),
7433
+ eventId: integerSchema("Event id; provide eventId or ref."),
7434
+ ref: externalRefSchema,
7435
+ confirm: booleanSchema("Must be true to delete."),
7436
+ patch: objectSchema(
7437
+ {
7438
+ summary: stringSchema("Short non-empty label."),
7439
+ content: stringSchema("Verbatim content, at most 1 MB UTF-8."),
7440
+ occurredAt: integerSchema("Event timestamp."),
7441
+ significance: integerSchema("Significance 1..10."),
7442
+ channel: stringSchema("Channel, at most 40 characters."),
7443
+ type: enumSchema("Type within the same event kind.", [
7444
+ "conversation",
7445
+ "activity",
7446
+ "gift",
7447
+ "support",
7448
+ "conflict",
7449
+ "correction",
7450
+ "observation",
7451
+ "milestone",
7452
+ "transaction",
7453
+ "promise",
7454
+ "agreement",
7455
+ "date_anchor"
7456
+ ]),
7457
+ externalRef: externalRefSchema
7458
+ },
7459
+ [],
7460
+ "Fields to revise; externalRef may only be added when absent."
7461
+ )
6473
7462
  },
6474
- ["action", "left", "right"],
6475
- "Dismiss or un-dismiss a duplicate candidate pair."
7463
+ ["action"]
6476
7464
  ),
6477
- handler: manageDuplicateDismissalToolHandler
7465
+ handler: manageEventToolHandler
6478
7466
  });
6479
7467
  }
6480
7468
  });
@@ -6539,7 +7527,10 @@ function reviseIdentity(db, identityId, patch) {
6539
7527
  });
6540
7528
  }
6541
7529
  const nextType = patch.type !== void 0 ? patch.type.trim() : row.type;
6542
- const nextValue = patch.value !== void 0 ? patch.value.trim() : row.value;
7530
+ const nextValue = normalizeIdentityValue(
7531
+ nextType,
7532
+ patch.value ?? row.value
7533
+ );
6543
7534
  if (nextType.length === 0 || nextValue.length === 0) {
6544
7535
  throw new AffinityValidationError("type and value must be non-empty");
6545
7536
  }
@@ -6626,6 +7617,10 @@ function contactLocatorSchema6(description) {
6626
7617
  contactId: integerSchema("Exact contact id."),
6627
7618
  identity: objectSchema(
6628
7619
  {
7620
+ ifExists: enumSchema(
7621
+ "Return an existing identity on this contact without changes.",
7622
+ ["error", "return"]
7623
+ ),
6629
7624
  type: stringSchema(
6630
7625
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6631
7626
  ),
@@ -6736,6 +7731,10 @@ var init_manage_identity_tool = __esm({
6736
7731
  ),
6737
7732
  input: objectSchema(
6738
7733
  {
7734
+ ifExists: enumSchema(
7735
+ "Return an existing identity on this contact without changes.",
7736
+ ["error", "return"]
7737
+ ),
6739
7738
  type: stringSchema(
6740
7739
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6741
7740
  ),
@@ -6799,6 +7798,9 @@ function overrideLinkState(db, linkId, state, options) {
6799
7798
  now,
6800
7799
  linkId
6801
7800
  );
7801
+ db.prepare(
7802
+ "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=?"
7803
+ ).run(linkId, state, linkId);
6802
7804
  refreshLinkRollup(db, linkId, now);
6803
7805
  if (state === "archived" || row.state === "archived") {
6804
7806
  refreshAllBridgeScores(db, now);
@@ -7523,6 +8525,8 @@ var init_merge_contacts_tool = __esm({
7523
8525
  function recordInteraction(db, input) {
7524
8526
  return withTransaction(db, () => {
7525
8527
  validateSocialEventInput(input);
8528
+ const existing = existingEventReceipt(db, input.externalRef);
8529
+ if (existing) return existing;
7526
8530
  if (!ALLOWED.has(input.type)) {
7527
8531
  throw new AffinityValidationError("invalid interaction event type");
7528
8532
  }
@@ -7532,11 +8536,12 @@ function recordInteraction(db, input) {
7532
8536
  assertOwnerParticipates(ownerId, ids);
7533
8537
  const now = resolveNow(input.now);
7534
8538
  const eventId = insertJournalEvent(db, {
8539
+ ...input,
7535
8540
  type: input.type,
7536
8541
  occurredAt: input.occurredAt,
7537
- summary: input.summary,
8542
+ occurredOn: input.occurredOn,
7538
8543
  significance: input.significance,
7539
- momentKind: null,
8544
+ momentKind: input.momentKind ?? null,
7540
8545
  participants: input.participants,
7541
8546
  now
7542
8547
  });
@@ -7544,12 +8549,24 @@ function recordInteraction(db, input) {
7544
8549
  eventId,
7545
8550
  eventType: input.type,
7546
8551
  occurredAt: input.occurredAt,
8552
+ occurredOn: input.occurredOn,
7547
8553
  significance: input.significance,
7548
8554
  participants: input.participants,
7549
8555
  provenance: input.provenance,
7550
8556
  now
7551
8557
  });
8558
+ if (input.momentKind !== void 0)
8559
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8560
+ input.momentKind,
8561
+ eventId
8562
+ );
7552
8563
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8564
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8565
+ {
8566
+ code: "future_event",
8567
+ message: "occurredAt is in the future; it will not count as contact until then"
8568
+ }
8569
+ ] : void 0,
7553
8570
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7554
8571
  affectedLinks: mechanics.affectedLinks,
7555
8572
  derivedEffects: mechanics.derivedEffects
@@ -7562,12 +8579,14 @@ var init_record_interaction = __esm({
7562
8579
  init_queries();
7563
8580
  init_loaders();
7564
8581
  init_persistence();
7565
- init_receipts2();
8582
+ init_receipts();
7566
8583
  init_validators();
7567
8584
  init_affinity_validation_error();
8585
+ init_parse_calendar_time();
7568
8586
  init_resolve_now();
7569
8587
  init_with_transaction();
7570
8588
  init_apply_journal_event_mechanics();
8589
+ init_event_metadata();
7571
8590
  ALLOWED = /* @__PURE__ */ new Set([
7572
8591
  "conversation",
7573
8592
  "activity",
@@ -7583,17 +8602,20 @@ var init_record_interaction = __esm({
7583
8602
  function recordMilestone(db, input) {
7584
8603
  return withTransaction(db, () => {
7585
8604
  validateSocialEventInput(input);
8605
+ const existing = existingEventReceipt(db, input.externalRef);
8606
+ if (existing) return existing;
7586
8607
  const ownerId = requireOwnerContactId(db);
7587
8608
  const ids = input.participants.map((p) => p.contactId);
7588
8609
  assertParticipantContactsLive(db, ids);
7589
8610
  assertOwnerParticipates(ownerId, ids);
7590
8611
  const now = resolveNow(input.now);
7591
8612
  const eventId = insertJournalEvent(db, {
8613
+ ...input,
7592
8614
  type: "milestone",
7593
8615
  occurredAt: input.occurredAt,
7594
- summary: input.summary,
8616
+ occurredOn: input.occurredOn,
7595
8617
  significance: input.significance,
7596
- momentKind: null,
8618
+ momentKind: input.momentKind ?? null,
7597
8619
  participants: input.participants,
7598
8620
  now
7599
8621
  });
@@ -7601,12 +8623,24 @@ function recordMilestone(db, input) {
7601
8623
  eventId,
7602
8624
  eventType: "milestone",
7603
8625
  occurredAt: input.occurredAt,
8626
+ occurredOn: input.occurredOn,
7604
8627
  significance: input.significance,
7605
8628
  participants: input.participants,
7606
8629
  provenance: input.provenance,
7607
8630
  now
7608
8631
  });
8632
+ if (input.momentKind !== void 0)
8633
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8634
+ input.momentKind,
8635
+ eventId
8636
+ );
7609
8637
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8638
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8639
+ {
8640
+ code: "future_event",
8641
+ message: "occurredAt is in the future; it will not count as contact until then"
8642
+ }
8643
+ ] : void 0,
7610
8644
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7611
8645
  affectedLinks: mechanics.affectedLinks,
7612
8646
  derivedEffects: mechanics.derivedEffects
@@ -7618,11 +8652,13 @@ var init_record_milestone = __esm({
7618
8652
  init_queries();
7619
8653
  init_loaders();
7620
8654
  init_persistence();
7621
- init_receipts2();
8655
+ init_receipts();
7622
8656
  init_validators();
8657
+ init_parse_calendar_time();
7623
8658
  init_resolve_now();
7624
8659
  init_with_transaction();
7625
8660
  init_apply_journal_event_mechanics();
8661
+ init_event_metadata();
7626
8662
  }
7627
8663
  });
7628
8664
 
@@ -7630,15 +8666,18 @@ var init_record_milestone = __esm({
7630
8666
  function recordObservation(db, input) {
7631
8667
  return withTransaction(db, () => {
7632
8668
  validateSocialEventInput(input);
8669
+ const existing = existingEventReceipt(db, input.externalRef);
8670
+ if (existing) return existing;
7633
8671
  const ids = input.participants.map((p) => p.contactId);
7634
8672
  assertParticipantContactsLive(db, ids);
7635
8673
  const now = resolveNow(input.now);
7636
8674
  const eventId = insertJournalEvent(db, {
8675
+ ...input,
7637
8676
  type: "observation",
7638
8677
  occurredAt: input.occurredAt,
7639
- summary: input.summary,
8678
+ occurredOn: input.occurredOn,
7640
8679
  significance: input.significance,
7641
- momentKind: null,
8680
+ momentKind: input.momentKind ?? null,
7642
8681
  participants: input.participants,
7643
8682
  now
7644
8683
  });
@@ -7646,12 +8685,24 @@ function recordObservation(db, input) {
7646
8685
  eventId,
7647
8686
  eventType: "observation",
7648
8687
  occurredAt: input.occurredAt,
8688
+ occurredOn: input.occurredOn,
7649
8689
  significance: input.significance,
7650
8690
  participants: input.participants,
7651
8691
  provenance: input.provenance,
7652
8692
  now
7653
8693
  });
8694
+ if (input.momentKind !== void 0)
8695
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8696
+ input.momentKind,
8697
+ eventId
8698
+ );
7654
8699
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8700
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8701
+ {
8702
+ code: "future_event",
8703
+ message: "occurredAt is in the future; it will not count as contact until then"
8704
+ }
8705
+ ] : void 0,
7655
8706
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7656
8707
  affectedLinks: mechanics.affectedLinks,
7657
8708
  derivedEffects: mechanics.derivedEffects
@@ -7662,11 +8713,13 @@ var init_record_observation = __esm({
7662
8713
  "src/events/record_observation.ts"() {
7663
8714
  init_loaders();
7664
8715
  init_persistence();
7665
- init_receipts2();
8716
+ init_receipts();
7666
8717
  init_validators();
8718
+ init_parse_calendar_time();
7667
8719
  init_resolve_now();
7668
8720
  init_with_transaction();
7669
8721
  init_apply_journal_event_mechanics();
8722
+ init_event_metadata();
7670
8723
  }
7671
8724
  });
7672
8725
 
@@ -7674,17 +8727,20 @@ var init_record_observation = __esm({
7674
8727
  function recordTransaction(db, input) {
7675
8728
  return withTransaction(db, () => {
7676
8729
  validateSocialEventInput(input);
8730
+ const existing = existingEventReceipt(db, input.externalRef);
8731
+ if (existing) return existing;
7677
8732
  const ownerId = requireOwnerContactId(db);
7678
8733
  const ids = input.participants.map((p) => p.contactId);
7679
8734
  assertParticipantContactsLive(db, ids);
7680
8735
  assertOwnerParticipates(ownerId, ids);
7681
8736
  const now = resolveNow(input.now);
7682
8737
  const eventId = insertJournalEvent(db, {
8738
+ ...input,
7683
8739
  type: "transaction",
7684
8740
  occurredAt: input.occurredAt,
7685
- summary: input.summary,
8741
+ occurredOn: input.occurredOn,
7686
8742
  significance: input.significance,
7687
- momentKind: null,
8743
+ momentKind: input.momentKind ?? null,
7688
8744
  participants: input.participants,
7689
8745
  now
7690
8746
  });
@@ -7692,12 +8748,24 @@ function recordTransaction(db, input) {
7692
8748
  eventId,
7693
8749
  eventType: "transaction",
7694
8750
  occurredAt: input.occurredAt,
8751
+ occurredOn: input.occurredOn,
7695
8752
  significance: input.significance,
7696
8753
  participants: input.participants,
7697
8754
  provenance: input.provenance,
7698
8755
  now
7699
8756
  });
8757
+ if (input.momentKind !== void 0)
8758
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
8759
+ input.momentKind,
8760
+ eventId
8761
+ );
7700
8762
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
8763
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
8764
+ {
8765
+ code: "future_event",
8766
+ message: "occurredAt is in the future; it will not count as contact until then"
8767
+ }
8768
+ ] : void 0,
7701
8769
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
7702
8770
  affectedLinks: mechanics.affectedLinks,
7703
8771
  derivedEffects: mechanics.derivedEffects
@@ -7709,20 +8777,24 @@ var init_record_transaction = __esm({
7709
8777
  init_queries();
7710
8778
  init_loaders();
7711
8779
  init_persistence();
7712
- init_receipts2();
8780
+ init_receipts();
7713
8781
  init_validators();
8782
+ init_parse_calendar_time();
7714
8783
  init_resolve_now();
7715
8784
  init_with_transaction();
7716
8785
  init_apply_journal_event_mechanics();
8786
+ init_event_metadata();
7717
8787
  }
7718
8788
  });
7719
8789
 
7720
8790
  // src/tools/record_event_tool.ts
7721
- function normalizeParticipant2(p) {
8791
+ function normalizeParticipant2(db, p) {
7722
8792
  let contactId = p.contactId;
7723
8793
  if (contactId === void 0 && p.contact != null) {
7724
8794
  const loc = p.contact;
7725
- contactId = loc.contactId;
8795
+ const resolved = resolveContactLocator(db, loc);
8796
+ if (!resolved.ok) throw resolved.result;
8797
+ contactId = resolved.value.id;
7726
8798
  }
7727
8799
  const result = {
7728
8800
  contactId,
@@ -7740,12 +8812,18 @@ function ensureOwnerParticipates2(db, participants) {
7740
8812
  return [{ contactId: ownerId, role: "actor" }, ...participants];
7741
8813
  }
7742
8814
  function prepareParticipants2(db, raw, injectOwner) {
7743
- const normalized = raw.map(normalizeParticipant2);
8815
+ const normalized = raw.map((p) => normalizeParticipant2(db, p));
7744
8816
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
7745
8817
  }
7746
8818
  function recordEventToolHandler(db, rawInput) {
7747
8819
  const input = structuredClone(rawInput);
7748
- const inputError = validateToolInput(recordEventTool.inputSchema, input);
8820
+ const inputError = validateToolInput(
8821
+ recordEventTool.inputSchema,
8822
+ input,
8823
+ "input",
8824
+ Date.now(),
8825
+ true
8826
+ );
7749
8827
  if (inputError)
7750
8828
  return toolFailure(
7751
8829
  "protocol",
@@ -7798,6 +8876,7 @@ var init_record_event_tool = __esm({
7798
8876
  init_record_milestone();
7799
8877
  init_record_observation();
7800
8878
  init_record_transaction();
8879
+ init_event_metadata_schema();
7801
8880
  init_tool_metadata();
7802
8881
  init_tool_mutation();
7803
8882
  init_tool_names();
@@ -7839,6 +8918,7 @@ var init_record_event_tool = __esm({
7839
8918
  "correction"
7840
8919
  ]
7841
8920
  ),
8921
+ ...eventMetadataSchema,
7842
8922
  occurredAt: integerSchema(
7843
8923
  "When the event occurred, Unix timestamp in milliseconds."
7844
8924
  ),
@@ -7847,6 +8927,19 @@ var init_record_event_tool = __esm({
7847
8927
  participants: arraySchema(
7848
8928
  objectSchema(
7849
8929
  {
8930
+ contact: objectSchema(
8931
+ {
8932
+ contactId: integerSchema("Contact id."),
8933
+ identity: objectSchema(
8934
+ {
8935
+ type: stringSchema("Identity type."),
8936
+ value: stringSchema("Identity value.")
8937
+ },
8938
+ ["type", "value"]
8939
+ )
8940
+ },
8941
+ []
8942
+ ),
7850
8943
  contactId: integerSchema(
7851
8944
  "Participant contact id. The owner contact MUST be included."
7852
8945
  ),
@@ -7870,7 +8963,7 @@ var init_record_event_tool = __esm({
7870
8963
  ),
7871
8964
  now: integerSchema("Optional timestamp override.")
7872
8965
  },
7873
- ["occurredAt", "summary", "significance", "participants"],
8966
+ ["occurredAt", "significance", "participants"],
7874
8967
  "Event payload. Always required. Interaction events also require `type`."
7875
8968
  )
7876
8969
  },
@@ -7888,7 +8981,10 @@ function contactAttributeClauses(filters) {
7888
8981
  const params = [];
7889
8982
  const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
7890
8983
  const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
7891
- for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
8984
+ for (const filter of [
8985
+ ...attributes,
8986
+ ...tags.map((tag) => `tag:${tag.replace(/^tag:/i, "")}`)
8987
+ ]) {
7892
8988
  const separator = filter.indexOf("=");
7893
8989
  const name = separator < 0 ? filter : filter.slice(0, separator);
7894
8990
  clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
@@ -7938,154 +9034,27 @@ function listContacts(db, filters, options) {
7938
9034
  FROM identities i
7939
9035
  WHERE i.contact_id = contacts.id AND i.removed_at IS NULL
7940
9036
  ORDER BY i.id
7941
- LIMIT 1
7942
- ) AS primary_identity
7943
- FROM contacts
7944
- WHERE ${where}
7945
- ORDER BY name ASC
7946
- LIMIT ? OFFSET ?`
7947
- ).all(...params, limit, offset);
7948
- return rows.map((r) => {
7949
- const item = mapContactRowToContactListItem(r);
7950
- return {
7951
- ...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);
9037
+ LIMIT 1
9038
+ ) AS primary_identity
9039
+ FROM contacts
9040
+ WHERE ${where}
9041
+ ORDER BY name ASC
9042
+ LIMIT ? OFFSET ?`
9043
+ ).all(...params, limit, offset);
9044
+ return rows.map((r) => {
9045
+ const item = mapContactRowToContactListItem(r);
9046
+ return {
9047
+ ...item,
9048
+ primaryIdentity: r.primary_identity ?? null
9049
+ };
9050
+ });
8084
9051
  }
8085
- var init_list_duplicate_candidates = __esm({
8086
- "src/contacts/list_duplicate_candidates.ts"() {
9052
+ var init_list_contacts = __esm({
9053
+ "src/contacts/list_contacts.ts"() {
8087
9054
  init_assert_default_ordering();
8088
9055
  init_resolve_list_limit_offset();
9056
+ init_contact_attribute_clauses();
9057
+ init_mappers4();
8089
9058
  }
8090
9059
  });
8091
9060
 
@@ -8113,12 +9082,12 @@ function listUpcomingDates(db, filters, options) {
8113
9082
  }
8114
9083
  const clauses = ["e.deleted_at IS NULL", "e.type = 'date_anchor'"];
8115
9084
  const params = [];
8116
- const since = options?.since ?? utcStartOfDayMs(Date.now());
9085
+ const since = options?.since ?? Date.parse(`${localCalendarDate(options?.now ?? Date.now())}T00:00:00Z`);
8117
9086
  clauses.push("uo.occurs_on >= ?");
8118
9087
  params.push(since);
8119
9088
  let maxOccurs = options?.until;
8120
9089
  if (filters?.horizonDays !== void 0) {
8121
- const anchor = options?.since ?? Date.now();
9090
+ const anchor = since;
8122
9091
  const horizonEnd = anchor + filters.horizonDays * 864e5;
8123
9092
  maxOccurs = maxOccurs === void 0 ? horizonEnd : Math.min(maxOccurs, horizonEnd);
8124
9093
  }
@@ -8170,9 +9139,9 @@ var init_list_upcoming_dates = __esm({
8170
9139
  init_mappers();
8171
9140
  init_validators();
8172
9141
  init_affinity_validation_error();
9142
+ init_parse_calendar_time();
8173
9143
  init_assert_default_ordering();
8174
9144
  init_resolve_list_limit_offset();
8175
- init_calendar();
8176
9145
  CONTACT_KINDS = /* @__PURE__ */ new Set([
8177
9146
  "human",
8178
9147
  "group",
@@ -8185,6 +9154,77 @@ var init_list_upcoming_dates = __esm({
8185
9154
  }
8186
9155
  });
8187
9156
 
9157
+ // src/events/event_discovery.ts
9158
+ function searchEvents(db, query, options) {
9159
+ const { limit, offset } = resolveListLimitOffset(options);
9160
+ const tokens = foldSearchText(query).split(/\s+/).filter(Boolean);
9161
+ if (!tokens.length) return [];
9162
+ const rows = db.prepare(
9163
+ "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"
9164
+ ).all();
9165
+ return rows.flatMap((row) => {
9166
+ const fields = {
9167
+ summary: decodeEventText(row.summary),
9168
+ content: row.content === null ? null : decodeEventText(row.content),
9169
+ source: row.external_source,
9170
+ id: row.external_id,
9171
+ channel: row.channel
9172
+ };
9173
+ const folded = Object.entries(fields).map(
9174
+ ([key, value]) => [key, foldSearchText(value ?? "")]
9175
+ );
9176
+ if (!tokens.every((t) => folded.some(([, v]) => v.includes(t))))
9177
+ return [];
9178
+ return [
9179
+ {
9180
+ kind: "event",
9181
+ id: row.id,
9182
+ type: row.type,
9183
+ title: deriveEventSummary(decodeEventText(row.summary)),
9184
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
9185
+ matchedOn: folded.filter(([, v]) => tokens.some((t) => v.includes(t))).map(([k]) => k),
9186
+ ...row.external_source && row.external_id ? {
9187
+ externalRef: {
9188
+ source: row.external_source,
9189
+ id: row.external_id
9190
+ }
9191
+ } : {}
9192
+ }
9193
+ ];
9194
+ }).slice(offset, offset + limit);
9195
+ }
9196
+ function getEventThread(db, source, id, options) {
9197
+ const { limit, offset } = resolveListLimitOffset(options);
9198
+ const root = getEventByExternalRef(db, source, id);
9199
+ if (!root) return [];
9200
+ const rows = db.prepare(`WITH RECURSIVE thread(id) AS (
9201
+ SELECT ? UNION SELECT e.id FROM events e JOIN thread t JOIN events p ON p.id=t.id
9202
+ 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=?))
9203
+ ) 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=?) ORDER BY e.occurred_at,e.id LIMIT ? OFFSET ?`).all(
9204
+ root.id,
9205
+ root.externalRef?.source ?? null,
9206
+ root.externalRef?.id ?? null,
9207
+ options?.channel ?? null,
9208
+ options?.channel?.trim().toLowerCase() ?? null,
9209
+ limit,
9210
+ offset
9211
+ );
9212
+ return batchLoadEventRecords(
9213
+ db,
9214
+ rows.map((r) => r.id),
9215
+ options?.includeContent ?? false
9216
+ );
9217
+ }
9218
+ var init_event_discovery = __esm({
9219
+ "src/events/event_discovery.ts"() {
9220
+ init_fold_search_text();
9221
+ init_resolve_list_limit_offset();
9222
+ init_event_metadata();
9223
+ init_loaders();
9224
+ init_mappers();
9225
+ }
9226
+ });
9227
+
8188
9228
  // src/graph/get_affinity_chart.ts
8189
9229
  function getAffinityChart(db, options) {
8190
9230
  const contactClauses = [
@@ -8397,21 +9437,6 @@ var init_list_progression_readiness = __esm({
8397
9437
  });
8398
9438
 
8399
9439
  // 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
9440
  function listRadar(db, filters, options) {
8416
9441
  assertDefaultOrdering("listRadar", options);
8417
9442
  const ownerId = findOwnerContactId(db);
@@ -8419,7 +9444,10 @@ function listRadar(db, filters, options) {
8419
9444
  return [];
8420
9445
  }
8421
9446
  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})`;
9447
+ const today = localCalendarDate(now);
9448
+ 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}'))`;
9449
+ const drift = `(${lastContact} IS NOT NULL AND (${now} - ${lastContact}) > 1.5 * COALESCE(l.cadence_days, 1) * 86400000)`;
9450
+ 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
9451
  const { limit, offset } = resolveListLimitOffset(options);
8424
9452
  const clauses = [
8425
9453
  "l.removed_at IS NULL",
@@ -8444,10 +9472,23 @@ function listRadar(db, filters, options) {
8444
9472
  if (options?.includeObserved === false) {
8445
9473
  clauses.push("l.kind != 'observed'");
8446
9474
  }
9475
+ if (options?.includeDormant !== true)
9476
+ clauses.push("c_to.lifecycle_state != 'dormant'");
9477
+ if (filters?.includeLost !== true)
9478
+ clauses.push("c_to.lifecycle_state != 'lost'");
9479
+ const other = `CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END`;
9480
+ clauses.push(`(${lastContact} IS NOT NULL OR ${overdue} OR NOT EXISTS (
9481
+ SELECT 1 FROM event_participants ep JOIN events ev ON ev.id=ep.event_id
9482
+ WHERE ep.contact_id=${other} AND ev.deleted_at IS NULL
9483
+ 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'))
9484
+ ))`);
8447
9485
  const where = clauses.join(" AND ");
8448
9486
  const rows = db.prepare(
8449
9487
  `SELECT
8450
9488
  l.id,
9489
+ l.cadence_days,
9490
+ ${lastContact} AS last_contact,
9491
+ ${drift} AS drift,
8451
9492
  ${overdue} AS overdue_commitment,
8452
9493
  l.from_contact_id,
8453
9494
  l.to_contact_id,
@@ -8461,30 +9502,53 @@ function listRadar(db, filters, options) {
8461
9502
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
8462
9503
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
8463
9504
  WHERE ${where}
8464
- ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
9505
+ ORDER BY (${overdue} OR ${drift}) DESC, ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
8465
9506
  LIMIT ? OFFSET ?`
8466
9507
  ).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
- }));
9508
+ return rows.map((row) => {
9509
+ const contactId = row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id;
9510
+ const promise = row.overdue_commitment ? db.prepare(
9511
+ `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`
9512
+ ).get(contactId, today, now) : void 0;
9513
+ const reasons = [
9514
+ ...row.overdue_commitment ? ["overdue_commitment"] : [],
9515
+ ...row.drift ? ["drift"] : []
9516
+ ];
9517
+ const snippets = [
9518
+ ...promise ? [
9519
+ `Overdue promise: ${promise.summary} (due ${promise.due_on ?? new Date(promise.due_at).toISOString().slice(0, 10)})`
9520
+ ] : [],
9521
+ ...row.drift ? [
9522
+ `No contact for ${Math.floor((now - (row.last_contact ?? now)) / 864e5)} days (cadence ${row.cadence_days ?? 1} days)`
9523
+ ] : []
9524
+ ];
9525
+ const elapsed = row.last_contact === null ? null : Math.max(0, (now - row.last_contact) / 864e5);
9526
+ const currentDrift = elapsed === null ? 0 : driftPriority({
9527
+ daysSinceLastMeaningfulEvent: elapsed,
9528
+ cadenceDays: row.cadence_days ?? 1,
9529
+ trust: row.trust ?? 0,
9530
+ rank: row.rank ?? 0
9531
+ });
9532
+ return {
9533
+ linkId: row.id,
9534
+ contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
9535
+ reasons,
9536
+ driftPriority: row.overdue_commitment ? Math.max(1, currentDrift) : currentDrift,
9537
+ recencyScore: elapsed === null ? 0 : Math.max(0, 1 - elapsed / Math.max(row.cadence_days ?? 1, 1)),
9538
+ normalizedRank: row.normalized_rank ?? 0,
9539
+ trust: row.trust ?? 0,
9540
+ recommendedReason: snippets.join("; ") || "Routine check-in recommended"
9541
+ };
9542
+ });
8481
9543
  }
8482
9544
  var init_list_radar = __esm({
8483
9545
  "src/links/list_radar.ts"() {
8484
9546
  init_queries();
9547
+ init_parse_calendar_time();
8485
9548
  init_assert_default_ordering();
8486
9549
  init_resolve_list_limit_offset();
8487
9550
  init_resolve_now();
9551
+ init_mechanics();
8488
9552
  }
8489
9553
  });
8490
9554
 
@@ -8567,13 +9631,40 @@ function enrichReviewItem(db, item) {
8567
9631
  result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
8568
9632
  }
8569
9633
  if (Array.isArray(item.participants)) {
8570
- result.counterpartyNames = item.participants.map(
8571
- (p) => name(p.contactId)
9634
+ result.participants = item.participants.map(
9635
+ (p) => ({
9636
+ id: p.contactId,
9637
+ name: name(p.contactId),
9638
+ role: p.role
9639
+ })
8572
9640
  );
8573
- result.subtitle = [
8574
- item.subtitle,
8575
- result.counterpartyNames.join(", ")
8576
- ].filter(Boolean).join(" \xB7 ");
9641
+ for (const key of ["title", "subtitle", "snippet", "state", "eventId"])
9642
+ result[key] = void 0;
9643
+ }
9644
+ if (result.momentKind === null) result.momentKind = void 0;
9645
+ if (result.eventId === result.id) result.eventId = void 0;
9646
+ if (result.linkId === result.id) result.linkId = void 0;
9647
+ if (result.title === result.summary) result.title = void 0;
9648
+ if (item.kind === "link" && item.occursAt !== void 0) {
9649
+ for (const key of [
9650
+ "affinity",
9651
+ "trust",
9652
+ "rank",
9653
+ "recencyScore",
9654
+ "driftPriority",
9655
+ "readinessScore",
9656
+ "normalizedRank",
9657
+ "reciprocityScore",
9658
+ "radarScore",
9659
+ "subtitle",
9660
+ "snippet",
9661
+ "targetRef",
9662
+ "occursOnDate"
9663
+ ])
9664
+ result[key] = void 0;
9665
+ } else if (item.kind === "link") {
9666
+ result.recommendedReason = void 0;
9667
+ result.snippet = void 0;
8577
9668
  }
8578
9669
  if (typeof item.leftContactId === "number")
8579
9670
  result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
@@ -8610,6 +9701,8 @@ var init_tool_summary = __esm({
8610
9701
  // src/tools/review_affinity_tool.ts
8611
9702
  function buildReadOptions(input) {
8612
9703
  return {
9704
+ ...input.includeContent === void 0 ? {} : { includeContent: input.includeContent },
9705
+ ...input.channel === void 0 ? {} : { channel: input.channel },
8613
9706
  ...input.now === void 0 ? {} : { now: input.now },
8614
9707
  ...input.limit === void 0 ? {} : { limit: input.limit },
8615
9708
  ...input.offset === void 0 ? {} : { offset: input.offset },
@@ -8746,7 +9839,7 @@ function momentItem(moment) {
8746
9839
  id: moment.eventId,
8747
9840
  title: moment.summary,
8748
9841
  subtitle: moment.momentKind,
8749
- snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
9842
+ snippet: moment.occurredOn ?? new Date(moment.occurredAt).toISOString().slice(0, 10)
8750
9843
  };
8751
9844
  }
8752
9845
  function commitmentItem(commitment) {
@@ -8756,7 +9849,7 @@ function commitmentItem(commitment) {
8756
9849
  id: commitment.eventId,
8757
9850
  title: commitment.summary,
8758
9851
  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)}`
9852
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : commitment.dueOn ?? new Date(commitment.dueAt).toISOString().slice(0, 10)}`
8760
9853
  };
8761
9854
  }
8762
9855
  function radarItem(radar) {
@@ -8772,6 +9865,7 @@ function radarItem(radar) {
8772
9865
  function upcomingDateItem(date) {
8773
9866
  return {
8774
9867
  ...date,
9868
+ occursOn: new Date(date.occursOn).toISOString().slice(0, 10),
8775
9869
  occursAt: date.occursOn,
8776
9870
  occursAtIso: new Date(date.occursOn).toISOString(),
8777
9871
  kind: date.targetRef.kind,
@@ -8809,6 +9903,9 @@ function compactFilters(input) {
8809
9903
  );
8810
9904
  }
8811
9905
  function reviewAffinityToolHandler(db, rawInput) {
9906
+ return reviewPage(db, rawInput, false);
9907
+ }
9908
+ function reviewPage(db, rawInput, pageOnly) {
8812
9909
  const input = structuredClone(rawInput);
8813
9910
  const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
8814
9911
  if (inputError)
@@ -8823,6 +9920,41 @@ function reviewAffinityToolHandler(db, rawInput) {
8823
9920
  if (invalid) {
8824
9921
  return invalid;
8825
9922
  }
9923
+ if (!pageOnly && input.view !== "graph.chart") {
9924
+ const limit = input.limit ?? 50;
9925
+ const offset = input.offset ?? 0;
9926
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
9927
+ return toolFailure(
9928
+ "protocol",
9929
+ "invalid_input",
9930
+ "Invalid pagination.",
9931
+ "limit must be 1..250; offset must be non-negative"
9932
+ );
9933
+ const all = [];
9934
+ let page;
9935
+ do {
9936
+ page = reviewPage(
9937
+ db,
9938
+ { ...input, limit: 250, offset: all.length },
9939
+ true
9940
+ );
9941
+ if (!page.ok) return page;
9942
+ all.push(...page.data.items);
9943
+ } while (page.data.items.length === 250);
9944
+ const items2 = all.slice(offset, offset + limit);
9945
+ return toolSuccess(
9946
+ summarizeCount(items2.length, "item"),
9947
+ {
9948
+ view: input.view,
9949
+ items: items2,
9950
+ count: items2.length,
9951
+ total: all.length,
9952
+ hasMore: offset + items2.length < all.length,
9953
+ appliedFilters: compactFilters(input)
9954
+ },
9955
+ { entities: items2.map(({ content: _content, ...item }) => item) }
9956
+ );
9957
+ }
8826
9958
  const options = buildReadOptions(input);
8827
9959
  let items = [];
8828
9960
  let chart;
@@ -8854,6 +9986,19 @@ function reviewAffinityToolHandler(db, rawInput) {
8854
9986
  options
8855
9987
  ).map(duplicateCandidateItem);
8856
9988
  break;
9989
+ case "events.thread": {
9990
+ if (!input.ref)
9991
+ return toolFailure(
9992
+ "protocol",
9993
+ "invalid_input",
9994
+ "Missing thread reference.",
9995
+ "ref is required for events.thread."
9996
+ );
9997
+ items = getEventThread(db, input.ref.source, input.ref.id, options).map(
9998
+ toEventListToolItem
9999
+ );
10000
+ break;
10001
+ }
8857
10002
  case "events.contact_journal": {
8858
10003
  const locator = input.contact;
8859
10004
  if (locator === void 0) {
@@ -8995,6 +10140,7 @@ function reviewAffinityToolHandler(db, rawInput) {
8995
10140
  items = listRadar(
8996
10141
  db,
8997
10142
  {
10143
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
8998
10144
  ...input.kind === void 0 ? {} : { kind: input.kind },
8999
10145
  ...input.linkState === void 0 ? {} : { state: input.linkState }
9000
10146
  },
@@ -9044,6 +10190,14 @@ function reviewAffinityToolHandler(db, rawInput) {
9044
10190
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
9045
10191
  break;
9046
10192
  }
10193
+ if (input.includeContent && ["events.moments", "commitments.open"].includes(input.view))
10194
+ items = items.map((item) => {
10195
+ const content = getEventById(
10196
+ db,
10197
+ Number(item.eventId ?? item.id)
10198
+ )?.content;
10199
+ return content === void 0 ? item : { ...item, content };
10200
+ });
9047
10201
  items = items.map((item) => enrichReviewItem(db, item));
9048
10202
  return toolSuccess(
9049
10203
  summarizeCount(items.length, "item"),
@@ -9055,7 +10209,7 @@ function reviewAffinityToolHandler(db, rawInput) {
9055
10209
  ...chart === void 0 ? {} : { chart }
9056
10210
  },
9057
10211
  {
9058
- entities: items,
10212
+ entities: items.map(({ content: _content, ...item }) => item),
9059
10213
  warnings: items.length === 0 ? [
9060
10214
  toolWarning(
9061
10215
  "empty_result",
@@ -9072,6 +10226,8 @@ var init_review_affinity_tool = __esm({
9072
10226
  init_list_contacts();
9073
10227
  init_list_duplicate_candidates();
9074
10228
  init_list_upcoming_dates();
10229
+ init_event_discovery();
10230
+ init_event_metadata();
9075
10231
  init_get_contact_journal();
9076
10232
  init_list_moments();
9077
10233
  init_list_open_commitments();
@@ -9084,6 +10240,7 @@ var init_review_affinity_tool = __esm({
9084
10240
  init_get_merge_history();
9085
10241
  init_list_dismissed_duplicates();
9086
10242
  init_enrich_review_item();
10243
+ init_event_metadata_schema();
9087
10244
  init_tool_metadata();
9088
10245
  init_tool_names();
9089
10246
  init_tool_ref();
@@ -9133,9 +10290,15 @@ var init_review_affinity_tool = __esm({
9133
10290
  outputDescription: "Returns compact review items, a count, the applied filters, and the chart payload for graph review.",
9134
10291
  inputSchema: objectSchema(
9135
10292
  {
10293
+ ref: externalRefSchema,
10294
+ includeContent: booleanSchema(
10295
+ "Include verbatim content in event rows; defaults to false."
10296
+ ),
10297
+ channel: stringSchema("Filter events by channel."),
9136
10298
  view: enumSchema("Which review surface to return.", [
9137
10299
  "contacts.list",
9138
10300
  "contacts.duplicates",
10301
+ "events.thread",
9139
10302
  "events.contact_journal",
9140
10303
  "events.link_timeline",
9141
10304
  "events.moments",
@@ -9190,7 +10353,20 @@ var init_review_affinity_tool = __esm({
9190
10353
  includeArchived: booleanSchema("Whether archived links are included."),
9191
10354
  includeObserved: booleanSchema("Whether observed links are included."),
9192
10355
  eventTypes: arraySchema(
9193
- stringSchema("Event type."),
10356
+ enumSchema("Event type.", [
10357
+ "conversation",
10358
+ "activity",
10359
+ "gift",
10360
+ "support",
10361
+ "milestone",
10362
+ "observation",
10363
+ "conflict",
10364
+ "correction",
10365
+ "transaction",
10366
+ "promise",
10367
+ "agreement",
10368
+ "date_anchor"
10369
+ ]),
9194
10370
  "Optional event-type filter."
9195
10371
  ),
9196
10372
  momentKind: enumSchema("Optional moment kind filter.", [
@@ -9232,7 +10408,7 @@ var init_review_affinity_tool = __esm({
9232
10408
 
9233
10409
  // src/contacts/search_contacts.ts
9234
10410
  function searchContacts(db, query, filters, options) {
9235
- const q = foldSearchText(query.trim());
10411
+ const q = foldSearchText(query.trim().replace(/^@/, ""));
9236
10412
  if (!q) return [];
9237
10413
  assertDefaultOrdering("searchContacts", options);
9238
10414
  const { limit, offset } = resolveListLimitOffset(options);
@@ -9252,6 +10428,7 @@ function searchContacts(db, query, filters, options) {
9252
10428
  const tokens = q.split(/\s+/);
9253
10429
  const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
9254
10430
  const digits = q.replace(/\D/g, "").replace(/^00/, "");
10431
+ const suffix = digits.replace(/^0/, "");
9255
10432
  const attributeFilters = contactAttributeClauses(filters);
9256
10433
  const clauses = [
9257
10434
  "deleted_at IS NULL",
@@ -9287,7 +10464,7 @@ function searchContacts(db, query, filters, options) {
9287
10464
  )
9288
10465
  ];
9289
10466
  const phoneFields = phone ? values.filter(
9290
- (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
10467
+ (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
10468
  ) : [];
9292
10469
  if (!phoneFields.length && !tokens.every(
9293
10470
  (token) => values.some((field) => field.text.includes(token))
@@ -9326,6 +10503,9 @@ var init_search_contacts = __esm({
9326
10503
 
9327
10504
  // src/tools/search_affinity_tool.ts
9328
10505
  function searchAffinityToolHandler(db, rawInput) {
10506
+ return searchPage(db, rawInput, false);
10507
+ }
10508
+ function searchPage(db, rawInput, pageOnly) {
9329
10509
  const input = structuredClone(rawInput);
9330
10510
  const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
9331
10511
  if (inputError)
@@ -9336,11 +10516,53 @@ function searchAffinityToolHandler(db, rawInput) {
9336
10516
  inputError
9337
10517
  );
9338
10518
  return withToolHandling(() => {
10519
+ if (!pageOnly) {
10520
+ const limit = input.limit ?? 50;
10521
+ const offset = input.offset ?? 0;
10522
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
10523
+ return toolFailure(
10524
+ "protocol",
10525
+ "invalid_input",
10526
+ "Invalid pagination.",
10527
+ "limit must be 1..250; offset must be non-negative"
10528
+ );
10529
+ const all = [];
10530
+ const scopes = input.scope === "all" ? ["contacts", "events"] : [input.scope ?? "contacts"];
10531
+ for (const scope of scopes) {
10532
+ let page;
10533
+ let nextOffset = 0;
10534
+ do {
10535
+ page = searchPage(
10536
+ db,
10537
+ { ...input, scope, limit: 250, offset: nextOffset },
10538
+ true
10539
+ );
10540
+ if (!page.ok) return page;
10541
+ all.push(...page.data.items);
10542
+ nextOffset += page.data.count;
10543
+ } while (page.data.count === 250);
10544
+ }
10545
+ const items2 = all.slice(offset, offset + limit);
10546
+ return toolSuccess(
10547
+ summarizeCount(
10548
+ items2.length,
10549
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10550
+ ),
10551
+ {
10552
+ query: input.query,
10553
+ items: items2,
10554
+ count: items2.length,
10555
+ total: all.length,
10556
+ hasMore: offset + items2.length < all.length
10557
+ },
10558
+ { entities: items2 }
10559
+ );
10560
+ }
9339
10561
  const options = {
9340
10562
  ...input.limit === void 0 ? {} : { limit: input.limit },
9341
10563
  ...input.offset === void 0 ? {} : { offset: input.offset }
9342
10564
  };
9343
- const rows = searchContacts(
10565
+ const rows = input.scope === "events" ? [] : searchContacts(
9344
10566
  db,
9345
10567
  input.query,
9346
10568
  {
@@ -9354,9 +10576,15 @@ function searchAffinityToolHandler(db, rawInput) {
9354
10576
  },
9355
10577
  options
9356
10578
  );
9357
- const items = rows.map(toContactListToolItem);
10579
+ const items = [
10580
+ ...rows.map(toContactListToolItem),
10581
+ ...input.scope === "events" || input.scope === "all" ? searchEvents(db, input.query, options) : []
10582
+ ];
9358
10583
  return toolSuccess(
9359
- summarizeCount(items.length, "contact"),
10584
+ summarizeCount(
10585
+ items.length,
10586
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10587
+ ),
9360
10588
  {
9361
10589
  query: input.query,
9362
10590
  items,
@@ -9372,6 +10600,7 @@ var searchAffinityTool;
9372
10600
  var init_search_affinity_tool = __esm({
9373
10601
  "src/tools/search_affinity_tool.ts"() {
9374
10602
  init_search_contacts();
10603
+ init_event_discovery();
9375
10604
  init_tool_metadata();
9376
10605
  init_tool_names();
9377
10606
  init_tool_ref();
@@ -9404,6 +10633,11 @@ var init_search_affinity_tool = __esm({
9404
10633
  outputDescription: "Returns compact contact hits, the original query, and the hit count.",
9405
10634
  inputSchema: objectSchema(
9406
10635
  {
10636
+ scope: enumSchema("Search scope; defaults to contacts.", [
10637
+ "contacts",
10638
+ "events",
10639
+ "all"
10640
+ ]),
9407
10641
  query: stringSchema("Free-text or natural-key query."),
9408
10642
  kind: enumSchema("Optional contact kind filter.", [
9409
10643
  "human",
@@ -9454,6 +10688,7 @@ var init_tool_registry = __esm({
9454
10688
  init_manage_contact_tool();
9455
10689
  init_manage_date_anchor_tool();
9456
10690
  init_manage_duplicate_dismissal_tool();
10691
+ init_manage_event_tool();
9457
10692
  init_manage_identity_tool();
9458
10693
  init_manage_relationship_tool();
9459
10694
  init_merge_contacts_tool();
@@ -9472,77 +10707,42 @@ var init_tool_registry = __esm({
9472
10707
  recordEventTool,
9473
10708
  manageCommitmentTool,
9474
10709
  manageDateAnchorTool,
9475
- manageAttributeTool
10710
+ manageAttributeTool,
10711
+ manageEventTool
9476
10712
  ];
9477
10713
  }
9478
10714
  });
9479
10715
 
9480
10716
  // 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()) {
10717
+ function normalizeTimeInputs(value, now = Date.now(), inPatch = false) {
9534
10718
  if (Array.isArray(value))
9535
10719
  return value.map((item) => normalizeTimeInputs(item, now));
9536
10720
  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
- );
10721
+ const result = {};
10722
+ for (const [key, item] of Object.entries(value)) {
10723
+ if (key === "occurredAt" && item === null)
10724
+ throw new CliUsageError(
10725
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
10726
+ );
10727
+ if (typeof item === "string" && (key.endsWith("At") || ["asOf", "now", "since", "until"].includes(key))) {
10728
+ let parsed;
10729
+ try {
10730
+ parsed = parseCalendarTime(item, key, now);
10731
+ } catch (error) {
10732
+ throw new CliUsageError(
10733
+ `Invalid timestamp "${item}". ${error.message}`
10734
+ );
10735
+ }
10736
+ result[key] = parsed.timestamp;
10737
+ if (!inPatch && ["occurredAt", "dueAt"].includes(key))
10738
+ result[key.replace(/At$/, "On")] = parsed.day;
10739
+ } else result[key] = normalizeTimeInputs(item, now, key === "patch");
10740
+ }
10741
+ return result;
9543
10742
  }
9544
10743
  var init_plain_dates = __esm({
9545
10744
  "src/surface/plain_dates.ts"() {
10745
+ init_parse_calendar_time();
9546
10746
  init_cli_args();
9547
10747
  }
9548
10748
  });
@@ -9575,6 +10775,29 @@ var init_timestamp_schema = __esm({
9575
10775
  });
9576
10776
 
9577
10777
  // src/surface/affinity_surface_tools.ts
10778
+ function surfaceProperties(tool) {
10779
+ return {
10780
+ ...tool.inputSchema.properties,
10781
+ ...["inspect_affinity_item", "review_affinity", "manage_event"].includes(
10782
+ tool.name
10783
+ ) ? {
10784
+ refSource: {
10785
+ type: "string",
10786
+ description: "External reference source."
10787
+ },
10788
+ refId: {
10789
+ type: "string",
10790
+ description: "External reference id."
10791
+ }
10792
+ } : {},
10793
+ ...tool.name === "review_affinity" ? {
10794
+ withContent: {
10795
+ type: "boolean",
10796
+ description: "Include verbatim event content."
10797
+ }
10798
+ } : {}
10799
+ };
10800
+ }
9578
10801
  var affinitySurfaceTools;
9579
10802
  var init_affinity_surface_tools = __esm({
9580
10803
  "src/surface/affinity_surface_tools.ts"() {
@@ -9586,17 +10809,31 @@ var init_affinity_surface_tools = __esm({
9586
10809
  affinitySurfaceTools = affinityTools.map((tool) => ({
9587
10810
  name: tool.name,
9588
10811
  description: tool.description,
9589
- whenToUse: tool.whenToUse,
9590
- ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
9591
10812
  readOnly: tool.readOnly,
9592
10813
  sideEffects: tool.sideEffects,
9593
10814
  inputSchema: {
9594
- ...timestampSchema({ ...tool.inputSchema, type: "object" }),
10815
+ ...timestampSchema({
10816
+ ...tool.inputSchema,
10817
+ properties: surfaceProperties(tool),
10818
+ type: "object"
10819
+ }),
9595
10820
  type: "object"
9596
10821
  },
9597
10822
  handler: (db, input) => {
9598
10823
  try {
9599
- return tool.handler(db, normalizeTimeInputs(input));
10824
+ const { withContent, refSource, refId, ...normalized } = input;
10825
+ if (withContent !== void 0) normalized.includeContent = withContent;
10826
+ if (refSource !== void 0 || refId !== void 0) {
10827
+ if (normalized.ref !== void 0)
10828
+ return toolFailure(
10829
+ "protocol",
10830
+ "invalid_input",
10831
+ "Invalid target.",
10832
+ "Provide ref or ref-source/ref-id, not both."
10833
+ );
10834
+ normalized.ref = { source: refSource, id: refId };
10835
+ }
10836
+ return tool.handler(db, normalizeTimeInputs(normalized));
9600
10837
  } catch (error) {
9601
10838
  if (error instanceof CliUsageError)
9602
10839
  return toolFailure(
@@ -9623,7 +10860,7 @@ function affinityMcpOptions(context) {
9623
10860
  context,
9624
10861
  annotations: (tool) => ({
9625
10862
  readOnlyHint: tool.readOnly ?? false,
9626
- destructiveHint: tool.name === "merge_contacts" || /remove|unset|undismiss/.test(
10863
+ destructiveHint: tool.name === "merge_contacts" || tool.name === "manage_event" || /remove|unset|undismiss/.test(
9627
10864
  String(tool.inputSchema.properties?.action?.enum ?? [])
9628
10865
  ),
9629
10866
  idempotentHint: tool.readOnly ?? false,
@@ -9639,13 +10876,99 @@ var init_affinity_mcp_options = __esm({
9639
10876
  }
9640
10877
  });
9641
10878
 
10879
+ // src/events/init_events_tables.ts
10880
+ function initEventsTables(db) {
10881
+ db.exec(`
10882
+ CREATE TABLE IF NOT EXISTS events (
10883
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10884
+ type TEXT NOT NULL,
10885
+ occurred_at INTEGER NOT NULL,
10886
+ summary TEXT NOT NULL,
10887
+ significance REAL NOT NULL DEFAULT 0,
10888
+ moment_kind TEXT,
10889
+ recurrence_kind TEXT,
10890
+ anchor_month INTEGER,
10891
+ anchor_day INTEGER,
10892
+ anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
10893
+ anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
10894
+ created_at INTEGER NOT NULL,
10895
+ updated_at INTEGER NOT NULL,
10896
+ deleted_at INTEGER,
10897
+ CHECK (length(trim(summary)) > 0),
10898
+ CHECK (
10899
+ type IN (
10900
+ 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
10901
+ 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
10902
+ )
10903
+ ),
10904
+ CHECK (
10905
+ (type = 'date_anchor' AND (
10906
+ (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
10907
+ OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
10908
+ ))
10909
+ OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
10910
+ ),
10911
+ CHECK (moment_kind IS NULL OR moment_kind IN (
10912
+ 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
10913
+ )),
10914
+ CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
10915
+ 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
10916
+ )),
10917
+ CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
10918
+ CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
10919
+ )
10920
+ `);
10921
+ db.exec(
10922
+ "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
10923
+ );
10924
+ db.exec(
10925
+ "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
10926
+ );
10927
+ const columns = db.prepare("PRAGMA table_info(events)").all();
10928
+ if (!columns.some((c) => c.name === "declared_moment_kind"))
10929
+ db.exec("ALTER TABLE events ADD COLUMN declared_moment_kind TEXT");
10930
+ if (!columns.some((c) => c.name === "affects_bond"))
10931
+ db.exec("ALTER TABLE events ADD COLUMN affects_bond INTEGER");
10932
+ if (!columns.some((c) => c.name === "content"))
10933
+ db.exec("ALTER TABLE events ADD COLUMN content TEXT");
10934
+ if (!columns.some((c) => c.name === "external_source"))
10935
+ db.exec("ALTER TABLE events ADD COLUMN external_source TEXT");
10936
+ if (!columns.some((c) => c.name === "external_id"))
10937
+ db.exec("ALTER TABLE events ADD COLUMN external_id TEXT");
10938
+ if (!columns.some((c) => c.name === "channel"))
10939
+ db.exec("ALTER TABLE events ADD COLUMN channel TEXT");
10940
+ if (!columns.some((c) => c.name === "parent_source"))
10941
+ db.exec("ALTER TABLE events ADD COLUMN parent_source TEXT");
10942
+ if (!columns.some((c) => c.name === "parent_ref_id"))
10943
+ db.exec("ALTER TABLE events ADD COLUMN parent_ref_id TEXT");
10944
+ if (!columns.some((c) => c.name === "parent_event_id"))
10945
+ db.exec("ALTER TABLE events ADD COLUMN parent_event_id INTEGER");
10946
+ if (!columns.some((c) => c.name === "thread_source"))
10947
+ db.exec("ALTER TABLE events ADD COLUMN thread_source TEXT");
10948
+ if (!columns.some((c) => c.name === "thread_id"))
10949
+ db.exec("ALTER TABLE events ADD COLUMN thread_id TEXT");
10950
+ if (!columns.some((c) => c.name === "provenance_json"))
10951
+ db.exec("ALTER TABLE events ADD COLUMN provenance_json TEXT");
10952
+ db.exec(
10953
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external_ref ON events(external_source, external_id)"
10954
+ );
10955
+ if (!columns.some((column) => column.name === "occurred_on"))
10956
+ db.exec("ALTER TABLE events ADD COLUMN occurred_on TEXT");
10957
+ }
10958
+ var init_init_events_tables = __esm({
10959
+ "src/events/init_events_tables.ts"() {
10960
+ }
10961
+ });
10962
+
9642
10963
  // src/attributes/init_attributes_tables.ts
9643
10964
  function initAttributesTables(db) {
10965
+ initEventsTables(db);
9644
10966
  db.exec(`
9645
10967
  CREATE TABLE IF NOT EXISTS attributes (
9646
10968
  id INTEGER PRIMARY KEY AUTOINCREMENT,
9647
10969
  contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
9648
10970
  link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
10971
+ event_id INTEGER REFERENCES events (id) ON DELETE CASCADE,
9649
10972
  name TEXT NOT NULL,
9650
10973
  value TEXT,
9651
10974
  created_at INTEGER NOT NULL,
@@ -9653,15 +10976,35 @@ function initAttributesTables(db) {
9653
10976
  deleted_at INTEGER,
9654
10977
  CHECK (length(trim(name)) > 0),
9655
10978
  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)
10979
+ (contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1
9659
10980
  )
9660
10981
  )
9661
10982
  `);
10983
+ const columns = db.prepare("PRAGMA table_info(attributes)").all();
10984
+ if (!columns.some((c) => c.name === "event_id")) {
10985
+ withTransaction(
10986
+ db,
10987
+ () => db.exec(`CREATE TABLE attributes_upgrade (
10988
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10989
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
10990
+ link_id INTEGER REFERENCES links(id) ON DELETE CASCADE,
10991
+ event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
10992
+ name TEXT NOT NULL CHECK(length(trim(name)) > 0), value TEXT,
10993
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER,
10994
+ CHECK ((contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1)
10995
+ );
10996
+ INSERT INTO attributes_upgrade (id,contact_id,link_id,name,value,created_at,updated_at,deleted_at)
10997
+ SELECT id,contact_id,link_id,name,value,created_at,updated_at,deleted_at FROM attributes;
10998
+ DROP TABLE attributes;
10999
+ ALTER TABLE attributes_upgrade RENAME TO attributes;`)
11000
+ );
11001
+ }
9662
11002
  db.exec(
9663
11003
  "CREATE INDEX IF NOT EXISTS idx_attributes_contact ON attributes(contact_id) WHERE deleted_at IS NULL"
9664
11004
  );
11005
+ db.exec(
11006
+ "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"
11007
+ );
9665
11008
  db.exec(
9666
11009
  "CREATE INDEX IF NOT EXISTS idx_attributes_link ON attributes(link_id) WHERE deleted_at IS NULL"
9667
11010
  );
@@ -9678,6 +11021,8 @@ function initAttributesTables(db) {
9678
11021
  }
9679
11022
  var init_init_attributes_tables = __esm({
9680
11023
  "src/attributes/init_attributes_tables.ts"() {
11024
+ init_init_events_tables();
11025
+ init_with_transaction();
9681
11026
  }
9682
11027
  });
9683
11028
 
@@ -9794,60 +11139,6 @@ var init_init_event_participants_tables = __esm({
9794
11139
  }
9795
11140
  });
9796
11141
 
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
11142
  // src/events/init_open_commitments_tables.ts
9852
11143
  function initOpenCommitmentsTables(db) {
9853
11144
  db.exec(`
@@ -9867,6 +11158,9 @@ function initOpenCommitmentsTables(db) {
9867
11158
  )
9868
11159
  )
9869
11160
  `);
11161
+ const columns = db.prepare("PRAGMA table_info(open_commitments)").all();
11162
+ if (!columns.some((column) => column.name === "due_on"))
11163
+ db.exec("ALTER TABLE open_commitments ADD COLUMN due_on TEXT");
9870
11164
  }
9871
11165
  var init_init_open_commitments_tables = __esm({
9872
11166
  "src/events/init_open_commitments_tables.ts"() {
@@ -9939,6 +11233,21 @@ function initLinkEventEffectsTables(db) {
9939
11233
  db.exec(
9940
11234
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
9941
11235
  );
11236
+ for (const name of ["participant_json", "provenance_json"])
11237
+ if (!columns.some((c) => c.name === name))
11238
+ db.exec(`ALTER TABLE link_event_effects ADD COLUMN ${name} TEXT`);
11239
+ db.exec(
11240
+ "CREATE TABLE IF NOT EXISTS event_link_baselines (link_id INTEGER PRIMARY KEY REFERENCES links(id) ON DELETE CASCADE, state_json TEXT NOT NULL)"
11241
+ );
11242
+ db.exec(
11243
+ "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)"
11244
+ );
11245
+ db.exec(`INSERT OR IGNORE INTO event_link_baselines(link_id,state_json)
11246
+ 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)
11247
+ FROM links l JOIN link_event_effects e ON e.link_id=l.id GROUP BY l.id`);
11248
+ db.exec(
11249
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id IN (SELECT id FROM events WHERE type IN ('promise', 'agreement'))"
11250
+ );
9942
11251
  db.exec(
9943
11252
  "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
11253
  );
@@ -10149,7 +11458,10 @@ async function openAffinityDb(path) {
10149
11458
  } finally {
10150
11459
  process.emitWarning = original;
10151
11460
  }
10152
- if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
11461
+ if (path !== ":memory:" && !path.startsWith("file:")) {
11462
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
11463
+ if (!existsSync(path)) closeSync(openSync(path, "wx", 384));
11464
+ }
10153
11465
  const db = new sqlite.DatabaseSync(path);
10154
11466
  try {
10155
11467
  db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");