@ghostpaw/affinity 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -6,13 +6,130 @@ var __export = (target, all) => {
6
6
  __defProp(target, name, { get: all[name], enumerable: true });
7
7
  };
8
8
 
9
+ // src/events/init_events_tables.ts
10
+ function initEventsTables(db) {
11
+ db.exec(`
12
+ CREATE TABLE IF NOT EXISTS events (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ type TEXT NOT NULL,
15
+ occurred_at INTEGER NOT NULL,
16
+ summary TEXT NOT NULL,
17
+ significance REAL NOT NULL DEFAULT 0,
18
+ moment_kind TEXT,
19
+ recurrence_kind TEXT,
20
+ anchor_month INTEGER,
21
+ anchor_day INTEGER,
22
+ anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
23
+ anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
24
+ created_at INTEGER NOT NULL,
25
+ updated_at INTEGER NOT NULL,
26
+ deleted_at INTEGER,
27
+ CHECK (length(trim(summary)) > 0),
28
+ CHECK (
29
+ type IN (
30
+ 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
31
+ 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
32
+ )
33
+ ),
34
+ CHECK (
35
+ (type = 'date_anchor' AND (
36
+ (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
37
+ OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
38
+ ))
39
+ OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
40
+ ),
41
+ CHECK (moment_kind IS NULL OR moment_kind IN (
42
+ 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
43
+ )),
44
+ CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
45
+ 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
46
+ )),
47
+ CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
48
+ CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
49
+ )
50
+ `);
51
+ db.exec(
52
+ "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
53
+ );
54
+ db.exec(
55
+ "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
56
+ );
57
+ const columns = db.prepare("PRAGMA table_info(events)").all();
58
+ if (!columns.some((c) => c.name === "declared_moment_kind"))
59
+ db.exec("ALTER TABLE events ADD COLUMN declared_moment_kind TEXT");
60
+ if (!columns.some((c) => c.name === "affects_bond"))
61
+ db.exec("ALTER TABLE events ADD COLUMN affects_bond INTEGER");
62
+ if (!columns.some((c) => c.name === "content"))
63
+ db.exec("ALTER TABLE events ADD COLUMN content TEXT");
64
+ if (!columns.some((c) => c.name === "external_source"))
65
+ db.exec("ALTER TABLE events ADD COLUMN external_source TEXT");
66
+ if (!columns.some((c) => c.name === "external_id"))
67
+ db.exec("ALTER TABLE events ADD COLUMN external_id TEXT");
68
+ if (!columns.some((c) => c.name === "channel"))
69
+ db.exec("ALTER TABLE events ADD COLUMN channel TEXT");
70
+ if (!columns.some((c) => c.name === "parent_source"))
71
+ db.exec("ALTER TABLE events ADD COLUMN parent_source TEXT");
72
+ if (!columns.some((c) => c.name === "parent_ref_id"))
73
+ db.exec("ALTER TABLE events ADD COLUMN parent_ref_id TEXT");
74
+ if (!columns.some((c) => c.name === "parent_event_id"))
75
+ db.exec("ALTER TABLE events ADD COLUMN parent_event_id INTEGER");
76
+ if (!columns.some((c) => c.name === "thread_source"))
77
+ db.exec("ALTER TABLE events ADD COLUMN thread_source TEXT");
78
+ if (!columns.some((c) => c.name === "thread_id"))
79
+ db.exec("ALTER TABLE events ADD COLUMN thread_id TEXT");
80
+ if (!columns.some((c) => c.name === "provenance_json"))
81
+ db.exec("ALTER TABLE events ADD COLUMN provenance_json TEXT");
82
+ db.exec(
83
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external_ref ON events(external_source, external_id)"
84
+ );
85
+ if (!columns.some((column) => column.name === "occurred_on"))
86
+ db.exec("ALTER TABLE events ADD COLUMN occurred_on TEXT");
87
+ }
88
+
89
+ // src/with_transaction.ts
90
+ var transactionDepth = /* @__PURE__ */ new WeakMap();
91
+ function withTransaction(db, fn) {
92
+ const depth = transactionDepth.get(db) ?? 0;
93
+ const savepoint = `affinity_${String(depth)}`;
94
+ if (depth === 0) {
95
+ db.exec("BEGIN IMMEDIATE");
96
+ } else {
97
+ db.exec(`SAVEPOINT ${savepoint}`);
98
+ }
99
+ transactionDepth.set(db, depth + 1);
100
+ try {
101
+ const result = fn();
102
+ if (depth === 0) {
103
+ db.exec("COMMIT");
104
+ } else {
105
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
106
+ }
107
+ transactionDepth.set(db, depth);
108
+ return result;
109
+ } catch (error) {
110
+ try {
111
+ if (depth === 0) {
112
+ db.exec("ROLLBACK");
113
+ } else {
114
+ db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
115
+ db.exec(`RELEASE SAVEPOINT ${savepoint}`);
116
+ }
117
+ } catch {
118
+ }
119
+ transactionDepth.set(db, depth);
120
+ throw error;
121
+ }
122
+ }
123
+
9
124
  // src/attributes/init_attributes_tables.ts
10
125
  function initAttributesTables(db) {
126
+ initEventsTables(db);
11
127
  db.exec(`
12
128
  CREATE TABLE IF NOT EXISTS attributes (
13
129
  id INTEGER PRIMARY KEY AUTOINCREMENT,
14
130
  contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
15
131
  link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
132
+ event_id INTEGER REFERENCES events (id) ON DELETE CASCADE,
16
133
  name TEXT NOT NULL,
17
134
  value TEXT,
18
135
  created_at INTEGER NOT NULL,
@@ -20,15 +137,35 @@ function initAttributesTables(db) {
20
137
  deleted_at INTEGER,
21
138
  CHECK (length(trim(name)) > 0),
22
139
  CHECK (
23
- (contact_id IS NOT NULL AND link_id IS NULL)
24
- OR
25
- (contact_id IS NULL AND link_id IS NOT NULL)
140
+ (contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1
26
141
  )
27
142
  )
28
143
  `);
144
+ const columns = db.prepare("PRAGMA table_info(attributes)").all();
145
+ if (!columns.some((c) => c.name === "event_id")) {
146
+ withTransaction(
147
+ db,
148
+ () => db.exec(`CREATE TABLE attributes_upgrade (
149
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
150
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
151
+ link_id INTEGER REFERENCES links(id) ON DELETE CASCADE,
152
+ event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
153
+ name TEXT NOT NULL CHECK(length(trim(name)) > 0), value TEXT,
154
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER,
155
+ CHECK ((contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1)
156
+ );
157
+ INSERT INTO attributes_upgrade (id,contact_id,link_id,name,value,created_at,updated_at,deleted_at)
158
+ SELECT id,contact_id,link_id,name,value,created_at,updated_at,deleted_at FROM attributes;
159
+ DROP TABLE attributes;
160
+ ALTER TABLE attributes_upgrade RENAME TO attributes;`)
161
+ );
162
+ }
29
163
  db.exec(
30
164
  "CREATE INDEX IF NOT EXISTS idx_attributes_contact ON attributes(contact_id) WHERE deleted_at IS NULL"
31
165
  );
166
+ db.exec(
167
+ "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"
168
+ );
32
169
  db.exec(
33
170
  "CREATE INDEX IF NOT EXISTS idx_attributes_link ON attributes(link_id) WHERE deleted_at IS NULL"
34
171
  );
@@ -141,56 +278,6 @@ function initEventParticipantsTables(db) {
141
278
  );
142
279
  }
143
280
 
144
- // src/events/init_events_tables.ts
145
- function initEventsTables(db) {
146
- db.exec(`
147
- CREATE TABLE IF NOT EXISTS events (
148
- id INTEGER PRIMARY KEY AUTOINCREMENT,
149
- type TEXT NOT NULL,
150
- occurred_at INTEGER NOT NULL,
151
- summary TEXT NOT NULL,
152
- significance REAL NOT NULL DEFAULT 0,
153
- moment_kind TEXT,
154
- recurrence_kind TEXT,
155
- anchor_month INTEGER,
156
- anchor_day INTEGER,
157
- anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
158
- anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
159
- created_at INTEGER NOT NULL,
160
- updated_at INTEGER NOT NULL,
161
- deleted_at INTEGER,
162
- CHECK (length(trim(summary)) > 0),
163
- CHECK (
164
- type IN (
165
- 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
166
- 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
167
- )
168
- ),
169
- CHECK (
170
- (type = 'date_anchor' AND (
171
- (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
172
- OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
173
- ))
174
- OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
175
- ),
176
- CHECK (moment_kind IS NULL OR moment_kind IN (
177
- 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
178
- )),
179
- CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
180
- 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
181
- )),
182
- CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
183
- CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
184
- )
185
- `);
186
- db.exec(
187
- "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
188
- );
189
- db.exec(
190
- "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
191
- );
192
- }
193
-
194
281
  // src/events/init_open_commitments_tables.ts
195
282
  function initOpenCommitmentsTables(db) {
196
283
  db.exec(`
@@ -210,6 +297,9 @@ function initOpenCommitmentsTables(db) {
210
297
  )
211
298
  )
212
299
  `);
300
+ const columns = db.prepare("PRAGMA table_info(open_commitments)").all();
301
+ if (!columns.some((column) => column.name === "due_on"))
302
+ db.exec("ALTER TABLE open_commitments ADD COLUMN due_on TEXT");
213
303
  }
214
304
 
215
305
  // src/identities/init_identities_tables.ts
@@ -274,6 +364,21 @@ function initLinkEventEffectsTables(db) {
274
364
  db.exec(
275
365
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
276
366
  );
367
+ for (const name of ["participant_json", "provenance_json"])
368
+ if (!columns.some((c) => c.name === name))
369
+ db.exec(`ALTER TABLE link_event_effects ADD COLUMN ${name} TEXT`);
370
+ db.exec(
371
+ "CREATE TABLE IF NOT EXISTS event_link_baselines (link_id INTEGER PRIMARY KEY REFERENCES links(id) ON DELETE CASCADE, state_json TEXT NOT NULL)"
372
+ );
373
+ db.exec(
374
+ "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)"
375
+ );
376
+ db.exec(`INSERT OR IGNORE INTO event_link_baselines(link_id,state_json)
377
+ 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)
378
+ FROM links l JOIN link_event_effects e ON e.link_id=l.id GROUP BY l.id`);
379
+ db.exec(
380
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id IN (SELECT id FROM events WHERE type IN ('promise', 'agreement'))"
381
+ );
277
382
  db.exec(
278
383
  "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"
279
384
  );
@@ -457,6 +562,7 @@ var AffinityError = class extends Error {
457
562
 
458
563
  // src/lib/errors/affinity_conflict_error.ts
459
564
  var AffinityConflictError = class extends AffinityError {
565
+ existingContactId;
460
566
  code = "CONFLICT";
461
567
  };
462
568
 
@@ -526,6 +632,9 @@ __export(read_exports, {
526
632
  getAffinityChart: () => getAffinityChart,
527
633
  getContactJournal: () => getContactJournal,
528
634
  getContactProfile: () => getContactProfile,
635
+ getEventByExternalRef: () => getEventByExternalRef,
636
+ getEventById: () => getEventById,
637
+ getEventThread: () => getEventThread,
529
638
  getLinkDetail: () => getLinkDetail,
530
639
  getLinkTimeline: () => getLinkTimeline,
531
640
  getLinksBetween: () => getLinksBetween,
@@ -541,7 +650,8 @@ __export(read_exports, {
541
650
  listProgressionReadiness: () => listProgressionReadiness,
542
651
  listRadar: () => listRadar,
543
652
  listUpcomingDates: () => listUpcomingDates,
544
- searchContacts: () => searchContacts
653
+ searchContacts: () => searchContacts,
654
+ searchEvents: () => searchEvents
545
655
  });
546
656
 
547
657
  // src/contacts/mappers.ts
@@ -729,6 +839,104 @@ function refreshContactRollup(db, contactId, now) {
729
839
  ).run(contactId, warnings.length, JSON.stringify(warnings), "{}", now);
730
840
  }
731
841
 
842
+ // src/identities/mappers.ts
843
+ function mapIdentityRowToIdentityRecord(row) {
844
+ return {
845
+ id: row.id,
846
+ contactId: row.contact_id,
847
+ type: row.type,
848
+ value: row.value,
849
+ label: row.label,
850
+ verified: row.verified === 1,
851
+ verifiedAt: row.verified_at
852
+ };
853
+ }
854
+
855
+ // src/identities/normalize.ts
856
+ var handleIdentityTypes = /* @__PURE__ */ new Set([
857
+ "handle",
858
+ "bluesky",
859
+ "x",
860
+ "twitter",
861
+ "mastodon",
862
+ "github",
863
+ "instagram",
864
+ "telegram",
865
+ "linkedin",
866
+ "facebook",
867
+ "youtube",
868
+ "tiktok",
869
+ "discord",
870
+ "slack"
871
+ ]);
872
+ function normalizeIdentityValue(type, value) {
873
+ const v = value.trim();
874
+ return handleIdentityTypes.has(type.toLowerCase()) ? v.replace(/^@/, "").toLowerCase() : v;
875
+ }
876
+ function normalizeIdentityKey(type, value) {
877
+ let t = type.trim().toLowerCase();
878
+ if (["did", "atproto_did", "custom:atproto-did"].includes(t)) t = "did";
879
+ const v = normalizeIdentityValue(t, value).toLowerCase();
880
+ if (t.length === 0 || v.length === 0) {
881
+ throw new AffinityValidationError("type and value must be non-empty");
882
+ }
883
+ return `${t}:${v}`;
884
+ }
885
+ function legacyIdentityKey(key) {
886
+ const colon = key.indexOf(":");
887
+ return handleIdentityTypes.has(key.slice(0, colon)) ? `${key.slice(0, colon + 1)}@${key.slice(colon + 1)}` : key;
888
+ }
889
+
890
+ // src/identities/queries.ts
891
+ function getIdentityRowById(db, id) {
892
+ const row = db.prepare(
893
+ `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
894
+ created_at, updated_at, removed_at
895
+ FROM identities
896
+ WHERE id = ? AND removed_at IS NULL`
897
+ ).get(id);
898
+ if (row === void 0) {
899
+ return null;
900
+ }
901
+ return row;
902
+ }
903
+
904
+ // src/identities/receipts.ts
905
+ function buildIdentityMutationReceipt(primary, partial) {
906
+ const normalized = normalizeMutationReceiptPartial(partial);
907
+ return {
908
+ primary,
909
+ created: normalized.created,
910
+ updated: normalized.updated,
911
+ archived: normalized.archived,
912
+ removed: normalized.removed,
913
+ affectedLinks: normalized.affectedLinks,
914
+ derivedEffects: normalized.derivedEffects
915
+ };
916
+ }
917
+
918
+ // src/identities/validators.ts
919
+ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
920
+ const row = excludeIdentityId === void 0 ? db.prepare(
921
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL"
922
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey) : db.prepare(
923
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL AND id != ?"
924
+ ).get(
925
+ normalizedKey,
926
+ legacyIdentityKey(normalizedKey),
927
+ normalizedKey,
928
+ excludeIdentityId
929
+ );
930
+ if (row !== void 0) {
931
+ const owner = db.prepare(
932
+ "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"
933
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey);
934
+ throw new AffinityConflictError(
935
+ `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
936
+ );
937
+ }
938
+ }
939
+
732
940
  // src/resolve_now.ts
733
941
  function resolveNow(now) {
734
942
  if (now !== void 0) {
@@ -742,39 +950,140 @@ function resolveNow(now) {
742
950
  return Date.now();
743
951
  }
744
952
 
745
- // src/with_transaction.ts
746
- var transactionDepth = /* @__PURE__ */ new WeakMap();
747
- function withTransaction(db, fn) {
748
- const depth = transactionDepth.get(db) ?? 0;
749
- const savepoint = `affinity_${String(depth)}`;
750
- if (depth === 0) {
751
- db.exec("BEGIN IMMEDIATE");
752
- } else {
753
- db.exec(`SAVEPOINT ${savepoint}`);
953
+ // src/identities/validate_identity_input.ts
954
+ var identityTypes = [
955
+ "did",
956
+ "atproto_did",
957
+ "email",
958
+ "phone",
959
+ "url",
960
+ "website",
961
+ "handle",
962
+ "x",
963
+ "twitter",
964
+ "linkedin",
965
+ "github",
966
+ "mastodon",
967
+ "bluesky",
968
+ "telegram",
969
+ "whatsapp",
970
+ "signal",
971
+ "matrix",
972
+ "discord",
973
+ "slack",
974
+ "instagram",
975
+ "facebook",
976
+ "youtube",
977
+ "tiktok",
978
+ "address",
979
+ "other",
980
+ "account_id",
981
+ "alias"
982
+ ];
983
+ function validateIdentityInput(type, rawValue) {
984
+ const kind = type.toLowerCase();
985
+ const value = normalizeIdentityValue(kind, rawValue);
986
+ if (["did", "atproto_did", "custom:atproto-did"].includes(kind) && !/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value))
987
+ throw new AffinityValidationError(
988
+ "did value must match did:<method>:<identifier> (for example did:plc:abc123)"
989
+ );
990
+ if (handleIdentityTypes.has(kind)) {
991
+ 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_.-]+$/;
992
+ if (!pattern.test(value))
993
+ throw new AffinityValidationError(
994
+ `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)"}`
995
+ );
754
996
  }
755
- transactionDepth.set(db, depth + 1);
756
- try {
757
- const result = fn();
758
- if (depth === 0) {
759
- db.exec("COMMIT");
760
- } else {
761
- db.exec(`RELEASE SAVEPOINT ${savepoint}`);
762
- }
763
- transactionDepth.set(db, depth);
764
- return result;
765
- } catch (error) {
997
+ if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
998
+ throw new AffinityValidationError(
999
+ `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
1000
+ );
1001
+ if (!value.trim())
1002
+ throw new AffinityValidationError("identity value must be non-empty");
1003
+ if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
1004
+ throw new AffinityValidationError(
1005
+ "email value must match local@domain.tld"
1006
+ );
1007
+ if (kind === "phone" && value.replace(/\D/g, "").length < 5)
1008
+ throw new AffinityValidationError(
1009
+ "phone value must contain at least 5 digits"
1010
+ );
1011
+ if (kind === "url" || kind === "website") {
766
1012
  try {
767
- if (depth === 0) {
768
- db.exec("ROLLBACK");
769
- } else {
770
- db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
771
- db.exec(`RELEASE SAVEPOINT ${savepoint}`);
772
- }
1013
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
1014
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
1015
+ throw new Error();
773
1016
  } catch {
1017
+ throw new AffinityValidationError(
1018
+ "url/website value must be an http/https URL or bare domain"
1019
+ );
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ // src/identities/add_identity.ts
1025
+ function addIdentity(db, contactId, input) {
1026
+ return withTransaction(db, () => {
1027
+ const contact = getContactRowById(db, contactId);
1028
+ if (!contact) {
1029
+ throw new AffinityNotFoundError("contact not found");
1030
+ }
1031
+ if (contact.lifecycle_state === "merged") {
1032
+ throw new AffinityStateError("merged contact is read-only");
1033
+ }
1034
+ const type = input.type.trim();
1035
+ const value = normalizeIdentityValue(type, input.value);
1036
+ if (type.length === 0 || value.length === 0) {
1037
+ throw new AffinityValidationError("type and value must be non-empty");
1038
+ }
1039
+ validateIdentityInput(type, value);
1040
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
1041
+ throw new AffinityValidationError("ifExists must be error or return");
1042
+ const nk = normalizeIdentityKey(type, value);
1043
+ if (input.ifExists === "return") {
1044
+ const existing = db.prepare(
1045
+ "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"
1046
+ ).get(contactId, nk, legacyIdentityKey(nk), nk);
1047
+ if (existing) {
1048
+ const row2 = getIdentityRowById(db, Number(existing.id));
1049
+ if (row2)
1050
+ return buildIdentityMutationReceipt(
1051
+ mapIdentityRowToIdentityRecord(row2),
1052
+ {}
1053
+ );
1054
+ }
1055
+ }
1056
+ assertNoIdentityCollision(db, nk);
1057
+ const now = resolveNow(input.now);
1058
+ const verified = input.verified ? 1 : 0;
1059
+ const verifiedAt = verified === 1 ? now : null;
1060
+ const result = db.prepare(
1061
+ `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
1062
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
1063
+ ).run(
1064
+ contactId,
1065
+ type,
1066
+ value,
1067
+ input.label ?? null,
1068
+ nk,
1069
+ verified,
1070
+ verifiedAt,
1071
+ now,
1072
+ now
1073
+ );
1074
+ const id = Number(result.lastInsertRowid);
1075
+ const row = getIdentityRowById(db, id);
1076
+ if (!row) {
1077
+ throw new AffinityInvariantError("inserted identity not found");
774
1078
  }
775
- transactionDepth.set(db, depth);
776
- throw error;
777
- }
1079
+ refreshContactRollup(db, contactId, now);
1080
+ db.prepare(
1081
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
1082
+ ).run(contactId, contactId);
1083
+ return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
1084
+ created: [{ kind: "identity", id }]
1085
+ });
1086
+ });
778
1087
  }
779
1088
 
780
1089
  // src/contacts/create_contact.ts
@@ -801,6 +1110,34 @@ function createContact(db, input) {
801
1110
  "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
802
1111
  );
803
1112
  }
1113
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
1114
+ throw new AffinityValidationError("ifExists must be error or return");
1115
+ const matches = (input.identities ?? []).flatMap((identity) => {
1116
+ validateIdentityInput(identity.type.trim(), identity.value.trim());
1117
+ const key = normalizeIdentityKey(identity.type, identity.value);
1118
+ const row2 = db.prepare(
1119
+ `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)))`
1120
+ ).get(key, legacyIdentityKey(key), key);
1121
+ return row2 ? [{ ...row2, identity }] : [];
1122
+ });
1123
+ const match = matches[0];
1124
+ if (match) {
1125
+ if (input.ifExists === "return") {
1126
+ const existing = getContactRowById(db, match.id);
1127
+ if (!existing)
1128
+ throw new AffinityInvariantError("identity owner not found");
1129
+ const primary2 = mapContactRowToContactListItem(existing);
1130
+ return Object.assign(buildContactMutationReceipt(primary2, {}), {
1131
+ outcome: "no_op",
1132
+ matchedIdentities: matches.map((m) => m.identity)
1133
+ });
1134
+ }
1135
+ const error = new AffinityConflictError(
1136
+ `identity ${normalizeIdentityKey(match.identity.type, match.identity.value).replace(":", " ")} already belongs to #${match.id} ${match.name}`
1137
+ );
1138
+ error.existingContactId = match.id;
1139
+ throw error;
1140
+ }
804
1141
  const now = resolveNow(input.now);
805
1142
  if (input.bootstrapOwner) {
806
1143
  const existing = db.prepare(
@@ -824,9 +1161,12 @@ function createContact(db, input) {
824
1161
  }
825
1162
  refreshContactRollup(db, id, now);
826
1163
  const primary = mapContactRowToContactListItem(row);
827
- return buildContactMutationReceipt(primary, {
1164
+ const receipt = buildContactMutationReceipt(primary, {
828
1165
  created: [{ kind: "contact", id }]
829
1166
  });
1167
+ for (const identity of input.identities ?? [])
1168
+ receipt.created.push(...addIdentity(db, id, identity).created);
1169
+ return receipt;
830
1170
  });
831
1171
  }
832
1172
 
@@ -849,6 +1189,100 @@ function resolveListLimitOffset(options) {
849
1189
  return { limit, offset };
850
1190
  }
851
1191
 
1192
+ // src/events/receipts.ts
1193
+ function buildEventMutationReceipt(primary, partial) {
1194
+ const normalized = normalizeMutationReceiptPartial(partial);
1195
+ return {
1196
+ primary,
1197
+ ...partial?.warnings ? { warnings: partial.warnings } : {},
1198
+ created: normalized.created,
1199
+ updated: normalized.updated,
1200
+ archived: normalized.archived,
1201
+ removed: normalized.removed,
1202
+ affectedLinks: normalized.affectedLinks,
1203
+ derivedEffects: normalized.derivedEffects
1204
+ };
1205
+ }
1206
+
1207
+ // src/events/event_metadata.ts
1208
+ function normalizeExternalRef(ref) {
1209
+ if (!ref || typeof ref.source !== "string" || typeof ref.id !== "string" || !ref.source.trim() || !ref.id.trim())
1210
+ throw new AffinityValidationError(
1211
+ "external reference source and id must be non-empty strings"
1212
+ );
1213
+ return { source: ref.source.trim().toLowerCase(), id: ref.id.trim() };
1214
+ }
1215
+ function deriveEventSummary(content) {
1216
+ const line = content.split(/\r?\n/).find((line2) => line2.trim())?.replace(/\s+/g, " ").trim() ?? "(no text)";
1217
+ return line.length > 140 ? `${line.slice(0, 139)}\u2026` : line;
1218
+ }
1219
+ function validateEventMetadata(input) {
1220
+ if (input.affectsBond !== void 0 && typeof input.affectsBond !== "boolean")
1221
+ throw new AffinityValidationError("affectsBond must be boolean");
1222
+ if (input.content !== void 0 && (typeof input.content !== "string" || Buffer.byteLength(input.content, "utf8") > 1048576))
1223
+ throw new AffinityValidationError(
1224
+ "content must be a string of at most 1 MB (1048576 UTF-8 bytes)"
1225
+ );
1226
+ for (const key of ["externalRef", "parentRef", "threadRef"])
1227
+ if (input[key] !== void 0) input[key] = normalizeExternalRef(input[key]);
1228
+ if (input.channel !== void 0) {
1229
+ if (typeof input.channel !== "string" || !input.channel.trim() || input.channel.trim().length > 40)
1230
+ throw new AffinityValidationError(
1231
+ "channel must be non-empty and at most 40 characters"
1232
+ );
1233
+ input.channel = input.channel.trim().toLowerCase();
1234
+ }
1235
+ if (input.parentEventId !== void 0 && (!Number.isSafeInteger(input.parentEventId) || input.parentEventId <= 0))
1236
+ throw new AffinityValidationError(
1237
+ "parentEventId must be a positive integer"
1238
+ );
1239
+ const moments = [
1240
+ "breakthrough",
1241
+ "rupture",
1242
+ "reconciliation",
1243
+ "milestone",
1244
+ "turning_point"
1245
+ ];
1246
+ if (input.momentKind !== void 0 && !moments.includes(input.momentKind))
1247
+ throw new AffinityValidationError(
1248
+ `momentKind must be one of: ${moments.join(", ")}`
1249
+ );
1250
+ }
1251
+ function getEventById(db, eventId) {
1252
+ return db.prepare("SELECT 1 FROM events WHERE id=? AND deleted_at IS NULL").get(eventId) ? loadEventRecord(db, eventId) : null;
1253
+ }
1254
+ function getEventByExternalRef(db, source, id) {
1255
+ const ref = normalizeExternalRef({ source, id });
1256
+ const row = db.prepare(
1257
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
1258
+ ).get(ref.source, ref.id);
1259
+ return row ? loadEventRecord(db, row.id) : null;
1260
+ }
1261
+ function existingEventReceipt(db, ref) {
1262
+ const event = ref && getEventByExternalRef(db, ref.source, ref.id);
1263
+ return event ? { ...buildEventMutationReceipt(event, {}), outcome: "no_op" } : null;
1264
+ }
1265
+ function enrichEventMetadata(db, record) {
1266
+ const bond = db.prepare("SELECT affects_bond FROM events WHERE id=?").get(record.id);
1267
+ record.affectsBond = bond?.affects_bond == null ? !(record.type === "observation" && record.significance <= 2) : Boolean(bond.affects_bond);
1268
+ if (record.parentEventId === void 0 && record.parentRef) {
1269
+ const row = db.prepare(
1270
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
1271
+ ).get(record.parentRef.source, record.parentRef.id);
1272
+ if (row) record.parentEventId = row.id;
1273
+ }
1274
+ const attrs = db.prepare(
1275
+ "SELECT id, name, value FROM attributes WHERE event_id=? AND deleted_at IS NULL ORDER BY id"
1276
+ ).all(record.id);
1277
+ if (attrs.length)
1278
+ record.attributes = attrs.map((a) => ({
1279
+ ...a,
1280
+ contactId: null,
1281
+ linkId: null,
1282
+ eventId: record.id
1283
+ }));
1284
+ }
1285
+
852
1286
  // src/events/validators.ts
853
1287
  var PARTICIPANT_ROLES = /* @__PURE__ */ new Set([
854
1288
  "actor",
@@ -891,8 +1325,11 @@ function assertParticipantContactsLive(db, contactIds) {
891
1325
  }
892
1326
  function validateSocialEventInput(input) {
893
1327
  assertFiniteTimestamp(input.occurredAt, "occurredAt");
894
- const summary = input.summary.trim();
895
- if (summary.length === 0) {
1328
+ validateEventMetadata(input);
1329
+ if (input.summary !== void 0 && typeof input.summary !== "string")
1330
+ throw new AffinityValidationError("summary must be a string");
1331
+ const summary = input.summary?.trim();
1332
+ if (summary === "" || summary === void 0 && input.content === void 0) {
896
1333
  throw new AffinityValidationError("summary must be non-empty");
897
1334
  }
898
1335
  const sig = input.significance;
@@ -956,11 +1393,27 @@ function mapEventRowToEventRecord(row, participants) {
956
1393
  id: row.id,
957
1394
  type: row.type,
958
1395
  occurredAt: row.occurred_at,
959
- summary: row.summary,
1396
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
1397
+ summary: decodeEventText(row.summary),
960
1398
  significance: row.significance,
961
1399
  momentKind: row.moment_kind,
962
1400
  participants
963
1401
  };
1402
+ if (row.provenance_json != null)
1403
+ base.provenance = JSON.parse(row.provenance_json);
1404
+ if (row.content != null) base.content = decodeEventText(row.content);
1405
+ if (row.channel != null) base.channel = row.channel;
1406
+ if (row.external_source != null && row.external_id != null)
1407
+ base.externalRef = { source: row.external_source, id: row.external_id };
1408
+ if (row.parent_source != null && row.parent_ref_id != null)
1409
+ base.parentRef = { source: row.parent_source, id: row.parent_ref_id };
1410
+ if (row.thread_source != null && row.thread_id != null)
1411
+ base.threadRef = { source: row.thread_source, id: row.thread_id };
1412
+ if (row.parent_event_id != null) base.parentEventId = row.parent_event_id;
1413
+ if (row.type === "promise" || row.type === "agreement") {
1414
+ base.dueAt = row.due_at ?? null;
1415
+ base.dueOn = row.due_on ?? (row.due_at == null ? null : new Date(row.due_at).toISOString().slice(0, 10));
1416
+ }
964
1417
  if (row.recurrence_kind !== null) {
965
1418
  base.recurrenceKind = row.recurrence_kind;
966
1419
  base.anchorMonth = row.anchor_month;
@@ -990,25 +1443,29 @@ function mapUpcomingRowToUpcomingDateRecord(row) {
990
1443
  targetRef,
991
1444
  recurrenceKind,
992
1445
  occursOn: row.occurs_on,
993
- summary: row.summary,
1446
+ occursOnDate: new Date(row.occurs_on).toISOString().slice(0, 10),
1447
+ summary: decodeEventText(row.summary),
994
1448
  significance: row.significance
995
1449
  };
996
1450
  }
1451
+ function decodeEventText(value) {
1452
+ return typeof value === "string" ? value : Buffer.from(value).toString("utf8");
1453
+ }
997
1454
 
998
1455
  // src/events/queries.ts
999
1456
  function getEventRowById(db, id) {
1000
1457
  const row = db.prepare(
1001
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
1002
- recurrence_kind, anchor_month, anchor_day,
1003
- anchor_contact_id, anchor_link_id,
1004
- created_at, updated_at, deleted_at
1458
+ `SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary
1005
1459
  FROM events
1006
1460
  WHERE id = ? AND deleted_at IS NULL`
1007
1461
  ).get(id);
1008
1462
  if (row === void 0) {
1009
1463
  return null;
1010
1464
  }
1011
- return row;
1465
+ const event = row;
1466
+ event.summary = decodeEventText(event.summary);
1467
+ if (event.content != null) event.content = decodeEventText(event.content);
1468
+ return event;
1012
1469
  }
1013
1470
  function findDuplicateDateAnchor(db, params) {
1014
1471
  const exclude = params.excludeEventId;
@@ -1076,19 +1533,27 @@ function loadEventRecord(db, eventId) {
1076
1533
  throw new AffinityInvariantError("event not found");
1077
1534
  }
1078
1535
  const participants = loadEventParticipantViews(db, eventId);
1079
- return mapEventRowToEventRecord(row, participants);
1536
+ const record = mapEventRowToEventRecord(row, participants);
1537
+ const due = db.prepare("SELECT due_at, due_on FROM open_commitments WHERE event_id = ?").get(eventId);
1538
+ if (due) {
1539
+ record.dueAt = due.due_at;
1540
+ record.dueOn = due.due_on ?? (due.due_at === null ? null : new Date(due.due_at).toISOString().slice(0, 10));
1541
+ }
1542
+ enrichEventMetadata(db, record);
1543
+ return record;
1080
1544
  }
1081
- function batchLoadEventRecords(db, eventIds) {
1545
+ function batchLoadEventRecords(db, eventIds, includeContent = false) {
1082
1546
  if (eventIds.length === 0) {
1083
1547
  return [];
1084
1548
  }
1085
1549
  const ph = eventIds.map(() => "?").join(", ");
1086
1550
  const eventRows = db.prepare(
1087
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
1551
+ `SELECT e.id, e.type, e.occurred_at, e.occurred_on, oc.due_at, oc.due_on, significance, moment_kind,
1552
+ 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,
1088
1553
  recurrence_kind, anchor_month, anchor_day,
1089
1554
  anchor_contact_id, anchor_link_id,
1090
- created_at, updated_at, deleted_at
1091
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
1555
+ 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
1556
+ 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`
1092
1557
  ).all(...eventIds);
1093
1558
  const partRows = db.prepare(
1094
1559
  `SELECT event_id, contact_id, role, directionality
@@ -1112,7 +1577,9 @@ function batchLoadEventRecords(db, eventIds) {
1112
1577
  for (const id of eventIds) {
1113
1578
  const row = rowById.get(id);
1114
1579
  if (row) {
1115
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
1580
+ const record = mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []);
1581
+ enrichEventMetadata(db, record);
1582
+ out.push(record);
1116
1583
  }
1117
1584
  }
1118
1585
  return out;
@@ -1171,6 +1638,10 @@ function getContactJournal(db, contactId, options) {
1171
1638
  clauses.push(`e.type IN (${ph})`);
1172
1639
  params.push(...options.eventTypes);
1173
1640
  }
1641
+ if (options?.channel !== void 0) {
1642
+ clauses.push("e.channel = ?");
1643
+ params.push(options.channel.trim().toLowerCase());
1644
+ }
1174
1645
  const where = clauses.join(" AND ");
1175
1646
  const rows = db.prepare(
1176
1647
  `SELECT e.id FROM events e
@@ -1181,8 +1652,70 @@ function getContactJournal(db, contactId, options) {
1181
1652
  ).all(...params, limit, offset);
1182
1653
  return batchLoadEventRecords(
1183
1654
  db,
1184
- rows.map((r) => r.id)
1655
+ rows.map((r) => r.id),
1656
+ options?.includeContent ?? false
1657
+ );
1658
+ }
1659
+
1660
+ // src/lib/parse_calendar_time.ts
1661
+ var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d or -2d";
1662
+ function isCommitmentOverdue(dueAt, dueOn, now) {
1663
+ if (dueAt === null) return false;
1664
+ if (dueOn && dueAt === Date.parse(`${dueOn}T23:59:59.999Z`)) {
1665
+ return localCalendarDate(now) > dueOn;
1666
+ }
1667
+ return dueAt < now;
1668
+ }
1669
+ var 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)";
1670
+ function localCalendarDate(now) {
1671
+ const d = new Date(now);
1672
+ return `${String(d.getFullYear()).padStart(4, "0")}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1673
+ }
1674
+ function parseCalendarTime(value, field = "time", now = Date.now()) {
1675
+ if (value == null && field.endsWith("occurredAt"))
1676
+ throw new AffinityValidationError(
1677
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
1678
+ );
1679
+ const fail = () => {
1680
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1681
+ };
1682
+ if (typeof value === "number" || typeof value === "string" && /^-?\d+$/.test(value.trim())) {
1683
+ const timestamp2 = Number(value);
1684
+ if (!Number.isSafeInteger(timestamp2) || !Number.isFinite(new Date(timestamp2).getTime()))
1685
+ return fail();
1686
+ return { timestamp: timestamp2, day: new Date(timestamp2).toISOString().slice(0, 10) };
1687
+ }
1688
+ if (typeof value !== "string") return fail();
1689
+ let text = value.trim();
1690
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text)) {
1691
+ text = text.replace(" ", "T");
1692
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(text)) text += "Z";
1693
+ }
1694
+ text = text.replace(
1695
+ /\.(\d{4,})(?=Z|[+-]\d{2}:\d{2}$)/,
1696
+ (_, digits) => `.${digits.slice(0, 3)}`
1185
1697
  );
1698
+ if (/^(today|yesterday|tomorrow|[+-]\d+d)$/.test(text)) {
1699
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1700
+ const date = /* @__PURE__ */ new Date(`${localCalendarDate(now)}T00:00:00Z`);
1701
+ date.setUTCDate(date.getUTCDate() + offset);
1702
+ if (!Number.isFinite(date.getTime())) return fail();
1703
+ text = date.toISOString().slice(0, 10);
1704
+ }
1705
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
1706
+ if (european) text = `${european[3]}-${european[2]}-${european[1]}`;
1707
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(text);
1708
+ if (!dateOnly && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1709
+ text
1710
+ ))
1711
+ return fail();
1712
+ const day = text.slice(0, 10);
1713
+ const midnight = Date.parse(`${day}T00:00:00Z`);
1714
+ if (!Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== day)
1715
+ return fail();
1716
+ const timestamp = dateOnly ? midnight + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0) : Date.parse(text);
1717
+ if (!Number.isFinite(timestamp)) return fail();
1718
+ return { timestamp, day };
1186
1719
  }
1187
1720
 
1188
1721
  // src/events/list_open_commitments.ts
@@ -1240,9 +1773,13 @@ function listOpenCommitments(db, filters, options) {
1240
1773
  clauses.push("oc.due_at <= ?");
1241
1774
  params.push(anchor + filters.horizonDays * DAY_MS);
1242
1775
  }
1776
+ if (options?.channel !== void 0) {
1777
+ clauses.push("e.channel=?");
1778
+ params.push(options.channel.trim().toLowerCase());
1779
+ }
1243
1780
  const where = clauses.join(" AND ");
1244
1781
  const rows = db.prepare(
1245
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
1782
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.due_on, oc.resolved_at, oc.resolution,
1246
1783
  e.type, e.summary
1247
1784
  FROM open_commitments oc
1248
1785
  INNER JOIN events e ON e.id = oc.event_id
@@ -1259,7 +1796,8 @@ function listOpenCommitments(db, filters, options) {
1259
1796
  participants: partsByEvent.get(r.event_id) ?? [],
1260
1797
  dueAt: r.due_at,
1261
1798
  dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
1262
- overdue: r.due_at !== null && r.due_at < now,
1799
+ dueOn: r.due_on ?? (r.due_at === null ? null : new Date(r.due_at).toISOString().slice(0, 10)),
1800
+ overdue: isCommitmentOverdue(r.due_at, r.due_on, now),
1263
1801
  resolutionState: mapResolution(r.resolved_at, r.resolution)
1264
1802
  }));
1265
1803
  }
@@ -1293,8 +1831,10 @@ function utcMidnight(year, month1Based, day) {
1293
1831
  return Date.UTC(year, month1Based - 1, day, 0, 0, 0, 0);
1294
1832
  }
1295
1833
  function computeNextAnchorOccursOn(params) {
1296
- const refStart = utcStartOfDayMs(params.referenceMs);
1297
- const refYear = new Date(params.referenceMs).getUTCFullYear();
1834
+ const refStart = Date.parse(
1835
+ `${localCalendarDate(params.referenceMs)}T00:00:00Z`
1836
+ );
1837
+ const refYear = new Date(refStart).getUTCFullYear();
1298
1838
  for (let year2 = refYear; year2 <= refYear + 2; year2 += 1) {
1299
1839
  const day2 = effectiveAnchorDay(year2, params.anchorMonth, params.anchorDay);
1300
1840
  const candidate = utcMidnight(year2, params.anchorMonth, day2);
@@ -1354,19 +1894,6 @@ function loadActiveDatesForContact(db, contactId) {
1354
1894
  return rows.map((row) => mapUpcomingRowToUpcomingDateRecord(row));
1355
1895
  }
1356
1896
 
1357
- // src/identities/mappers.ts
1358
- function mapIdentityRowToIdentityRecord(row) {
1359
- return {
1360
- id: row.id,
1361
- contactId: row.contact_id,
1362
- type: row.type,
1363
- value: row.value,
1364
- label: row.label,
1365
- verified: row.verified === 1,
1366
- verifiedAt: row.verified_at
1367
- };
1368
- }
1369
-
1370
1897
  // src/links/mappers.ts
1371
1898
  function mapLinkRowToLinkListItem(row) {
1372
1899
  const kind = row.kind;
@@ -1427,7 +1954,13 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
1427
1954
  ORDER BY COALESCE(lr.normalized_rank, 0) DESC, l.trust DESC
1428
1955
  LIMIT ?`
1429
1956
  ).all(contactId, contactId, topLinksLimit);
1430
- const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
1957
+ const topLinks = linkRows.map((l) => ({
1958
+ ...mapLinkRowToLinkListItem(l),
1959
+ counterpartyName: getContactRowById(
1960
+ db,
1961
+ l.from_contact_id === contactId ? l.to_contact_id : l.from_contact_id
1962
+ )?.name ?? ""
1963
+ }));
1431
1964
  const activeDates = loadActiveDatesForContact(db, contactId);
1432
1965
  const contactRollup = loadContactRollup(db, contactId);
1433
1966
  const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
@@ -1460,7 +1993,20 @@ function getContactProfile(db, contactId, options) {
1460
1993
  return {
1461
1994
  ...profile,
1462
1995
  recentEvents: getContactJournal(db, contactId, {
1463
- limit: options?.recentEventsLimit ?? 5
1996
+ limit: options?.recentEventsLimit ?? 5,
1997
+ eventTypes: [
1998
+ "conversation",
1999
+ "activity",
2000
+ "gift",
2001
+ "support",
2002
+ "milestone",
2003
+ "observation",
2004
+ "conflict",
2005
+ "correction",
2006
+ "transaction",
2007
+ "promise",
2008
+ "agreement"
2009
+ ]
1464
2010
  }).map((event) => ({
1465
2011
  ...event,
1466
2012
  occurredAtIso: new Date(event.occurredAt).toISOString()
@@ -1490,7 +2036,10 @@ function contactAttributeClauses(filters) {
1490
2036
  const params = [];
1491
2037
  const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
1492
2038
  const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
1493
- for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
2039
+ for (const filter of [
2040
+ ...attributes,
2041
+ ...tags.map((tag) => `tag:${tag.replace(/^tag:/i, "")}`)
2042
+ ]) {
1494
2043
  const separator = filter.indexOf("=");
1495
2044
  const name = separator < 0 ? filter : filter.slice(0, separator);
1496
2045
  clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
@@ -1561,7 +2110,9 @@ function pairKey(leftContactId, rightContactId) {
1561
2110
  function normalizeName(name) {
1562
2111
  return name.trim().toLowerCase().replace(/\s+/g, " ");
1563
2112
  }
1564
- function fuzzyNameScore(left, right) {
2113
+ function fuzzyNameScore(leftInput, rightInput) {
2114
+ const left = normalizeName(leftInput);
2115
+ const right = normalizeName(rightInput);
1565
2116
  if (left === right) {
1566
2117
  return 0.92;
1567
2118
  }
@@ -1719,7 +2270,7 @@ function foldSearchText(value) {
1719
2270
 
1720
2271
  // src/contacts/search_contacts.ts
1721
2272
  function searchContacts(db, query, filters, options) {
1722
- const q = foldSearchText(query.trim());
2273
+ const q = foldSearchText(query.trim().replace(/^@/, ""));
1723
2274
  if (!q) return [];
1724
2275
  assertDefaultOrdering("searchContacts", options);
1725
2276
  const { limit, offset } = resolveListLimitOffset(options);
@@ -1739,6 +2290,7 @@ function searchContacts(db, query, filters, options) {
1739
2290
  const tokens = q.split(/\s+/);
1740
2291
  const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
1741
2292
  const digits = q.replace(/\D/g, "").replace(/^00/, "");
2293
+ const suffix = digits.replace(/^0/, "");
1742
2294
  const attributeFilters = contactAttributeClauses(filters);
1743
2295
  const clauses = [
1744
2296
  "deleted_at IS NULL",
@@ -1774,7 +2326,7 @@ function searchContacts(db, query, filters, options) {
1774
2326
  )
1775
2327
  ];
1776
2328
  const phoneFields = phone ? values.filter(
1777
- (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
2329
+ (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)
1778
2330
  ) : [];
1779
2331
  if (!phoneFields.length && !tokens.every(
1780
2332
  (token) => values.some((field) => field.text.includes(token))
@@ -1809,7 +2361,7 @@ function assertValidLifecycleTransition(from, to) {
1809
2361
  }
1810
2362
  if (to === "merged") {
1811
2363
  throw new AffinityInvariantError(
1812
- "use manage_contact merge (CLI: contact merge) to reach merged state"
2364
+ "use merge_contacts (CLI: contact merge) to reach merged state"
1813
2365
  );
1814
2366
  }
1815
2367
  if (from === to) {
@@ -1864,7 +2416,7 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
1864
2416
  // src/events/persistence.ts
1865
2417
  function insertJournalEvent(db, params) {
1866
2418
  const now = resolveNow(params.now);
1867
- const summary = params.summary.trim();
2419
+ const summary = params.summary?.trim() ?? deriveEventSummary(params.content ?? "");
1868
2420
  const result = db.prepare(
1869
2421
  `INSERT INTO events (
1870
2422
  type, occurred_at, summary, significance, moment_kind,
@@ -1881,6 +2433,35 @@ function insertJournalEvent(db, params) {
1881
2433
  now
1882
2434
  );
1883
2435
  const eventId = Number(result.lastInsertRowid);
2436
+ db.prepare("UPDATE events SET affects_bond=? WHERE id=?").run(
2437
+ params.affectsBond === void 0 ? params.type === "observation" && params.significance <= 2 || ["activity", "milestone", "observation"].includes(params.type) && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
2438
+ summary
2439
+ ) && !/^follow[ -]up\b/i.test(summary) ? 0 : 1 : Number(params.affectsBond),
2440
+ eventId
2441
+ );
2442
+ db.prepare("UPDATE events SET declared_moment_kind=? WHERE id=?").run(
2443
+ params.momentKind,
2444
+ eventId
2445
+ );
2446
+ db.prepare(
2447
+ "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=?"
2448
+ ).run(
2449
+ params.content ?? null,
2450
+ params.externalRef?.source ?? null,
2451
+ params.externalRef?.id ?? null,
2452
+ params.channel ?? null,
2453
+ params.parentRef?.source ?? null,
2454
+ params.parentRef?.id ?? null,
2455
+ params.parentEventId ?? null,
2456
+ params.threadRef?.source ?? null,
2457
+ params.threadRef?.id ?? null,
2458
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
2459
+ eventId
2460
+ );
2461
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
2462
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
2463
+ eventId
2464
+ );
1884
2465
  const insertParticipant = db.prepare(
1885
2466
  `INSERT INTO event_participants (
1886
2467
  event_id, contact_id, role, directionality
@@ -1923,6 +2504,10 @@ function insertDateAnchorEvent(db, params) {
1923
2504
  now
1924
2505
  );
1925
2506
  const eventId = Number(result.lastInsertRowid);
2507
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
2508
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
2509
+ eventId
2510
+ );
1926
2511
  const insertParticipant = db.prepare(
1927
2512
  `INSERT INTO event_participants (
1928
2513
  event_id, contact_id, role, directionality
@@ -1943,20 +2528,6 @@ function insertDateAnchorEvent(db, params) {
1943
2528
  return eventId;
1944
2529
  }
1945
2530
 
1946
- // src/events/receipts.ts
1947
- function buildEventMutationReceipt(primary, partial) {
1948
- const normalized = normalizeMutationReceiptPartial(partial);
1949
- return {
1950
- primary,
1951
- created: normalized.created,
1952
- updated: normalized.updated,
1953
- archived: normalized.archived,
1954
- removed: normalized.removed,
1955
- affectedLinks: normalized.affectedLinks,
1956
- derivedEffects: normalized.derivedEffects
1957
- };
1958
- }
1959
-
1960
2531
  // src/links/queries.ts
1961
2532
  function getLinkRowById(db, id) {
1962
2533
  const row = db.prepare(
@@ -2118,12 +2689,12 @@ function listUpcomingDates(db, filters, options) {
2118
2689
  }
2119
2690
  const clauses = ["e.deleted_at IS NULL", "e.type = 'date_anchor'"];
2120
2691
  const params = [];
2121
- const since = options?.since ?? utcStartOfDayMs(Date.now());
2692
+ const since = options?.since ?? Date.parse(`${localCalendarDate(options?.now ?? Date.now())}T00:00:00Z`);
2122
2693
  clauses.push("uo.occurs_on >= ?");
2123
2694
  params.push(since);
2124
2695
  let maxOccurs = options?.until;
2125
2696
  if (filters?.horizonDays !== void 0) {
2126
- const anchor = options?.since ?? Date.now();
2697
+ const anchor = since;
2127
2698
  const horizonEnd = anchor + filters.horizonDays * 864e5;
2128
2699
  maxOccurs = maxOccurs === void 0 ? horizonEnd : Math.min(maxOccurs, horizonEnd);
2129
2700
  }
@@ -2661,6 +3232,10 @@ function listMoments(db, filters, options) {
2661
3232
  clauses.push("le.link_id = ?");
2662
3233
  params.push(filters.linkId);
2663
3234
  }
3235
+ if (options?.channel !== void 0) {
3236
+ clauses.push("e.channel=?");
3237
+ params.push(options.channel.trim().toLowerCase());
3238
+ }
2664
3239
  const where = clauses.join(" AND ");
2665
3240
  return db.prepare(
2666
3241
  `SELECT
@@ -2668,6 +3243,7 @@ function listMoments(db, filters, options) {
2668
3243
  le.link_id,
2669
3244
  le.moment_kind,
2670
3245
  le.occurred_at,
3246
+ e.occurred_on,
2671
3247
  e.summary,
2672
3248
  le.impact_score
2673
3249
  FROM link_event_effects le
@@ -2680,6 +3256,7 @@ function listMoments(db, filters, options) {
2680
3256
  linkId: row.link_id,
2681
3257
  momentKind: row.moment_kind,
2682
3258
  occurredAt: row.occurred_at,
3259
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
2683
3260
  summary: row.summary,
2684
3261
  impactScore: row.impact_score
2685
3262
  }));
@@ -2708,6 +3285,10 @@ function getLinkTimeline(db, linkId, options) {
2708
3285
  clauses.push(`e.type IN (${ph})`);
2709
3286
  params.push(...options.eventTypes);
2710
3287
  }
3288
+ if (options?.channel !== void 0) {
3289
+ clauses.push("e.channel = ?");
3290
+ params.push(options.channel.trim().toLowerCase());
3291
+ }
2711
3292
  const where = clauses.join(" AND ");
2712
3293
  const rows = db.prepare(
2713
3294
  `SELECT e.id
@@ -2719,7 +3300,8 @@ function getLinkTimeline(db, linkId, options) {
2719
3300
  ).all(...params, limit, offset);
2720
3301
  return batchLoadEventRecords(
2721
3302
  db,
2722
- rows.map((r) => r.id)
3303
+ rows.map((r) => r.id),
3304
+ options?.includeContent ?? false
2723
3305
  );
2724
3306
  }
2725
3307
 
@@ -2793,26 +3375,34 @@ function refreshLinkRollup(db, linkId, now) {
2793
3375
  const latest = db.prepare(
2794
3376
  `SELECT event_id, occurred_at
2795
3377
  FROM link_event_effects
2796
- WHERE link_id = ? AND counts_for_recency = 1
3378
+ 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 <= ?))
2797
3379
  ORDER BY occurred_at DESC, event_id DESC
2798
3380
  LIMIT 1`
2799
- ).get(linkId);
3381
+ ).get(linkId, now, localCalendarDate(now));
2800
3382
  const trailingWindowStart = now - 180 * DAY_MS2;
2801
3383
  const counts = db.prepare(
2802
3384
  `SELECT
2803
3385
  COUNT(*) AS total_meaningful_events,
2804
3386
  SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
2805
3387
  FROM link_event_effects
2806
- WHERE link_id = ? AND occurred_at >= ?`
2807
- ).get(linkId, trailingWindowStart);
3388
+ WHERE link_id = ? AND occurred_at >= ? AND counts_for_recency = 1
3389
+ AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
3390
+ ).get(linkId, trailingWindowStart, now, localCalendarDate(now));
2808
3391
  const directionalityRows = db.prepare(
2809
3392
  `SELECT ep.directionality
2810
3393
  FROM link_event_effects le
2811
3394
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
2812
3395
  WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
2813
3396
  AND ep.directionality IS NOT NULL
2814
- AND le.occurred_at >= ?`
2815
- ).all(linkId, link.from_contact_id, trailingWindowStart);
3397
+ AND le.occurred_at >= ?
3398
+ AND le.event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
3399
+ ).all(
3400
+ linkId,
3401
+ link.from_contact_id,
3402
+ trailingWindowStart,
3403
+ now,
3404
+ localCalendarDate(now)
3405
+ );
2816
3406
  let outboundCount = 0;
2817
3407
  let inboundCount = 0;
2818
3408
  for (const row of directionalityRows) {
@@ -3131,21 +3721,6 @@ function listProgressionReadiness(db, filters, options) {
3131
3721
  }
3132
3722
 
3133
3723
  // src/links/list_radar.ts
3134
- function deriveRadarReason(drift, recency, normalizedRank3) {
3135
- if (drift > 0.5) {
3136
- return "Significantly overdue for contact";
3137
- }
3138
- if (drift > 0.2) {
3139
- return "Drifting \u2014 approaching overdue";
3140
- }
3141
- if (recency < 0.3 && normalizedRank3 > 0.3) {
3142
- return "Important link losing recency";
3143
- }
3144
- if (normalizedRank3 > 0.5 && recency < 0.5) {
3145
- return "High-rank link needs attention";
3146
- }
3147
- return "Routine check-in recommended";
3148
- }
3149
3724
  function listRadar(db, filters, options) {
3150
3725
  assertDefaultOrdering("listRadar", options);
3151
3726
  const ownerId = findOwnerContactId(db);
@@ -3153,7 +3728,10 @@ function listRadar(db, filters, options) {
3153
3728
  return [];
3154
3729
  }
3155
3730
  const now = resolveNow(options?.now);
3156
- 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})`;
3731
+ const today = localCalendarDate(now);
3732
+ 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}'))`;
3733
+ const drift = `(${lastContact} IS NOT NULL AND (${now} - ${lastContact}) > 1.5 * COALESCE(l.cadence_days, 1) * 86400000)`;
3734
+ 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))`;
3157
3735
  const { limit, offset } = resolveListLimitOffset(options);
3158
3736
  const clauses = [
3159
3737
  "l.removed_at IS NULL",
@@ -3178,10 +3756,23 @@ function listRadar(db, filters, options) {
3178
3756
  if (options?.includeObserved === false) {
3179
3757
  clauses.push("l.kind != 'observed'");
3180
3758
  }
3759
+ if (options?.includeDormant !== true)
3760
+ clauses.push("c_to.lifecycle_state != 'dormant'");
3761
+ if (filters?.includeLost !== true)
3762
+ clauses.push("c_to.lifecycle_state != 'lost'");
3763
+ const other = `CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END`;
3764
+ clauses.push(`(${lastContact} IS NOT NULL OR ${overdue} OR NOT EXISTS (
3765
+ SELECT 1 FROM event_participants ep JOIN events ev ON ev.id=ep.event_id
3766
+ WHERE ep.contact_id=${other} AND ev.deleted_at IS NULL
3767
+ 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'))
3768
+ ))`);
3181
3769
  const where = clauses.join(" AND ");
3182
3770
  const rows = db.prepare(
3183
3771
  `SELECT
3184
3772
  l.id,
3773
+ l.cadence_days,
3774
+ ${lastContact} AS last_contact,
3775
+ ${drift} AS drift,
3185
3776
  ${overdue} AS overdue_commitment,
3186
3777
  l.from_contact_id,
3187
3778
  l.to_contact_id,
@@ -3195,23 +3786,44 @@ function listRadar(db, filters, options) {
3195
3786
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
3196
3787
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
3197
3788
  WHERE ${where}
3198
- ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
3789
+ ORDER BY (${overdue} OR ${drift}) DESC, ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
3199
3790
  LIMIT ? OFFSET ?`
3200
3791
  ).all(...params, limit, offset);
3201
- return rows.map((row) => ({
3202
- linkId: row.id,
3203
- contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
3204
- reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
3205
- driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
3206
- recencyScore: row.recency_score ?? 0,
3207
- normalizedRank: row.normalized_rank ?? 0,
3208
- trust: row.trust ?? 0,
3209
- recommendedReason: deriveRadarReason(
3210
- row.drift_priority ?? 0,
3211
- row.recency_score ?? 0,
3212
- row.normalized_rank ?? 0
3213
- )
3214
- }));
3792
+ return rows.map((row) => {
3793
+ const contactId = row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id;
3794
+ const promise = row.overdue_commitment ? db.prepare(
3795
+ `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`
3796
+ ).get(contactId, today, now) : void 0;
3797
+ const reasons = [
3798
+ ...row.overdue_commitment ? ["overdue_commitment"] : [],
3799
+ ...row.drift ? ["drift"] : []
3800
+ ];
3801
+ const snippets = [
3802
+ ...promise ? [
3803
+ `Overdue promise: ${promise.summary} (due ${promise.due_on ?? new Date(promise.due_at).toISOString().slice(0, 10)})`
3804
+ ] : [],
3805
+ ...row.drift ? [
3806
+ `No contact for ${Math.floor((now - (row.last_contact ?? now)) / 864e5)} days (cadence ${row.cadence_days ?? 1} days)`
3807
+ ] : []
3808
+ ];
3809
+ const elapsed = row.last_contact === null ? null : Math.max(0, (now - row.last_contact) / 864e5);
3810
+ const currentDrift = elapsed === null ? 0 : driftPriority({
3811
+ daysSinceLastMeaningfulEvent: elapsed,
3812
+ cadenceDays: row.cadence_days ?? 1,
3813
+ trust: row.trust ?? 0,
3814
+ rank: row.rank ?? 0
3815
+ });
3816
+ return {
3817
+ linkId: row.id,
3818
+ contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
3819
+ reasons,
3820
+ driftPriority: row.overdue_commitment ? Math.max(1, currentDrift) : currentDrift,
3821
+ recencyScore: elapsed === null ? 0 : Math.max(0, 1 - elapsed / Math.max(row.cadence_days ?? 1, 1)),
3822
+ normalizedRank: row.normalized_rank ?? 0,
3823
+ trust: row.trust ?? 0,
3824
+ recommendedReason: snippets.join("; ") || "Routine check-in recommended"
3825
+ };
3826
+ });
3215
3827
  }
3216
3828
 
3217
3829
  // src/links/receipts.ts
@@ -3294,6 +3906,9 @@ function overrideLinkState(db, linkId, state, options) {
3294
3906
  now,
3295
3907
  linkId
3296
3908
  );
3909
+ db.prepare(
3910
+ "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=?"
3911
+ ).run(linkId, state, linkId);
3297
3912
  refreshLinkRollup(db, linkId, now);
3298
3913
  if (state === "archived" || row.state === "archived") {
3299
3914
  refreshAllBridgeScores(db, now);
@@ -3368,6 +3983,8 @@ function reviseBond(db, linkId, bond, options) {
3368
3983
  // src/links/seed_social_link.ts
3369
3984
  function seedSocialLink(db, input) {
3370
3985
  return withTransaction(db, () => {
3986
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
3987
+ throw new AffinityValidationError("ifExists must be error or return");
3371
3988
  if (!isRelationalLinkKind(input.kind)) {
3372
3989
  throw new AffinityValidationError("invalid relational link kind");
3373
3990
  }
@@ -3375,11 +3992,14 @@ function seedSocialLink(db, input) {
3375
3992
  throw new AffinityValidationError("from and to contacts must differ");
3376
3993
  }
3377
3994
  assertContactEndpointsNotMerged(db, input.fromContactId, input.toContactId);
3378
- if (findLiveRelationalLinkAnyDirection(
3995
+ const existing = findLiveRelationalLinkAnyDirection(
3379
3996
  db,
3380
3997
  input.fromContactId,
3381
3998
  input.toContactId
3382
- )) {
3999
+ );
4000
+ if (existing) {
4001
+ if (input.ifExists === "return")
4002
+ return buildLinkMutationReceipt(mapLinkRowToLinkListItem(existing), {});
3383
4003
  throw new AffinityConflictError(
3384
4004
  "relational link already exists between these contacts"
3385
4005
  );
@@ -4198,6 +4818,8 @@ function deriveStateAfter(params) {
4198
4818
  return params.stateBefore;
4199
4819
  }
4200
4820
  function deriveMomentKind(params) {
4821
+ if (params.eventType === "observation" || params.eventType === "milestone" && params.significance < 7)
4822
+ return null;
4201
4823
  if (params.rankAfter > params.rankBefore) {
4202
4824
  return "breakthrough";
4203
4825
  }
@@ -4227,6 +4849,18 @@ function applyEventEffectToLink(db, params) {
4227
4849
  if (!link || link.is_structural !== 0) {
4228
4850
  throw new AffinityStateError("relational link not found");
4229
4851
  }
4852
+ db.prepare(
4853
+ "INSERT OR IGNORE INTO event_link_baselines(link_id,state_json) VALUES (?,?)"
4854
+ ).run(
4855
+ params.linkId,
4856
+ JSON.stringify({
4857
+ rank: link.rank ?? 0,
4858
+ affinity: link.affinity ?? 0,
4859
+ trust: link.trust ?? 0,
4860
+ state: link.state ?? "active",
4861
+ cadence_days: link.cadence_days
4862
+ })
4863
+ );
4230
4864
  const stateBefore = link.state ?? "active";
4231
4865
  const linkKind = link.kind;
4232
4866
  const rankBefore = link.rank ?? 0;
@@ -4271,7 +4905,7 @@ function applyEventEffectToLink(db, params) {
4271
4905
  );
4272
4906
  const weightedBase = base * massPenalty(
4273
4907
  weeklyPositiveBaseWeight(db, params.linkId, params.occurredAt)
4274
- ) * salienceMultiplier;
4908
+ ) * salienceMultiplier * (params.participant.directionality === "owner_initiated" ? 0.05 : 1);
4275
4909
  const affinityGain = weightedBase * Math.max(eventValence, 0) * (0.55 + 0.45 * eventIntimacyDepth) * (0.7 + 0.3 * participantReciprocity) * (1 / (1 + 0.22 * rankBefore));
4276
4910
  const affinityLoss = weightedBase * Math.max(-eventValence, 0) * 0.35;
4277
4911
  const affinityMass = clamp3(
@@ -4312,15 +4946,16 @@ function applyEventEffectToLink(db, params) {
4312
4946
  const latest = db.prepare(
4313
4947
  `SELECT occurred_at
4314
4948
  FROM link_event_effects
4315
- WHERE link_id = ?
4949
+ 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 <= ?))
4316
4950
  ORDER BY occurred_at DESC, event_id DESC
4317
4951
  LIMIT 1`
4318
- ).get(params.linkId);
4952
+ ).get(params.linkId, params.now, localCalendarDate(params.now));
4319
4953
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
4320
4954
  1,
4321
4955
  Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
4322
4956
  );
4323
- const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
4957
+ const eventDay = db.prepare("SELECT occurred_on FROM events WHERE id = ?").get(params.eventId)?.occurred_on;
4958
+ const cadenceAfter = commitmentResolution != null || params.eventType === "promise" || params.eventType === "agreement" || params.occurredAt > params.now && (eventDay == null || eventDay > localCalendarDate(params.now)) ? cadenceBefore : nextCadenceDays({
4324
4959
  kind: linkKind,
4325
4960
  currentCadenceDays: cadenceBefore,
4326
4961
  gapDays
@@ -4340,7 +4975,11 @@ function applyEventEffectToLink(db, params) {
4340
4975
  }
4341
4976
  const affinityDelta = affinityAfter - affinityBefore;
4342
4977
  const trustDelta = trustAfter - trustBefore;
4343
- const momentKind = deriveMomentKind({
4978
+ const eventLabel = db.prepare("SELECT summary FROM events WHERE id=?").get(params.eventId)?.summary;
4979
+ const followLike = params.eventType === "milestone" && typeof eventLabel === "string" && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
4980
+ eventLabel
4981
+ );
4982
+ const momentKind = followLike ? null : deriveMomentKind({
4344
4983
  rankBefore,
4345
4984
  rankAfter,
4346
4985
  stateBefore,
@@ -4383,7 +5022,15 @@ function applyEventEffectToLink(db, params) {
4383
5022
  params.now,
4384
5023
  params.now
4385
5024
  );
4386
- if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
5025
+ db.prepare(
5026
+ "UPDATE link_event_effects SET participant_json=?,provenance_json=? WHERE event_id=? AND link_id=?"
5027
+ ).run(
5028
+ JSON.stringify(params.participant),
5029
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
5030
+ params.eventId,
5031
+ params.linkId
5032
+ );
5033
+ if (commitmentResolution !== void 0 && commitmentResolution !== null || params.eventType === "promise" || params.eventType === "agreement" || params.participant.directionality === "owner_initiated") {
4387
5034
  db.prepare(
4388
5035
  "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
4389
5036
  ).run(params.eventId, params.linkId);
@@ -4466,15 +5113,36 @@ function ensureRelationalLink(db, params) {
4466
5113
  };
4467
5114
  }
4468
5115
  function applyJournalEventMechanics(db, params) {
5116
+ const bond = db.prepare("SELECT affects_bond, channel FROM events WHERE id=?").get(params.eventId);
5117
+ const ambient = params.provenance && typeof params.provenance === "object" && (params.provenance.ambient === true || params.provenance.tags?.includes("ambient"));
5118
+ if (bond?.affects_bond === 0 || bond?.affects_bond == null && params.eventType === "observation" && params.significance <= 2 || ambient)
5119
+ return { created: [], affectedLinks: [], derivedEffects: [] };
4469
5120
  const ownerId = requireOwnerContactId(db);
4470
- const participantIds = params.participants.map(
5121
+ const owner = params.participants.find((p) => p.contactId === ownerId);
5122
+ const socialChannel = [
5123
+ "bluesky",
5124
+ "x",
5125
+ "twitter",
5126
+ "mastodon",
5127
+ "github",
5128
+ "instagram"
5129
+ ].includes(String(bond?.channel));
5130
+ const direction = owner?.directionality ?? (socialChannel ? owner?.role === "actor" ? "owner_initiated" : owner?.role === "recipient" ? "other_initiated" : void 0 : void 0);
5131
+ const participants = params.participants.map(
5132
+ (p) => direction && p.directionality === void 0 ? { ...p, directionality: direction } : p
5133
+ );
5134
+ if (direction)
5135
+ db.prepare(
5136
+ "UPDATE event_participants SET directionality=? WHERE event_id=? AND directionality IS NULL"
5137
+ ).run(direction, params.eventId);
5138
+ const participantIds = participants.map(
4471
5139
  (participant) => participant.contactId
4472
5140
  );
4473
5141
  const hasOwner = participantIds.includes(ownerId);
4474
5142
  const created = [];
4475
5143
  const effectInputs = [];
4476
5144
  if (hasOwner) {
4477
- for (const participant of params.participants) {
5145
+ for (const participant of participants) {
4478
5146
  if (participant.contactId === ownerId) {
4479
5147
  continue;
4480
5148
  }
@@ -4482,13 +5150,14 @@ function applyJournalEventMechanics(db, params) {
4482
5150
  fromContactId: ownerId,
4483
5151
  toContactId: participant.contactId,
4484
5152
  kind: inferAutoLinkKind(db, ownerId, participant.contactId),
5153
+ ...(participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality) === "owner_initiated" ? { affinity: 0.15 } : {},
4485
5154
  now: params.now
4486
5155
  });
4487
5156
  created.push(...ensured.created);
4488
5157
  effectInputs.push({ linkId: ensured.linkId, participant });
4489
5158
  }
4490
- } else if (params.eventType === "observation" && params.participants.length === 2) {
4491
- const [first, second] = params.participants;
5159
+ } else if (params.eventType === "observation" && participants.length === 2) {
5160
+ const [first, second] = participants;
4492
5161
  if (first !== void 0 && second !== void 0) {
4493
5162
  const a = Math.min(first.contactId, second.contactId);
4494
5163
  const b = Math.max(first.contactId, second.contactId);
@@ -4523,7 +5192,10 @@ function applyJournalEventMechanics(db, params) {
4523
5192
  eventType: params.eventType,
4524
5193
  occurredAt: params.occurredAt,
4525
5194
  significance: params.significance,
4526
- participant: input.participant,
5195
+ participant: {
5196
+ ...input.participant,
5197
+ directionality: input.participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality
5198
+ },
4527
5199
  provenance: params.provenance,
4528
5200
  now: params.now
4529
5201
  })
@@ -4547,6 +5219,8 @@ function applyJournalEventMechanics(db, params) {
4547
5219
  function recordCommitment(db, input) {
4548
5220
  return withTransaction(db, () => {
4549
5221
  validateSocialEventInput(input);
5222
+ const existing = existingEventReceipt(db, input.externalRef);
5223
+ if (existing) return existing;
4550
5224
  if (input.commitmentType !== "promise" && input.commitmentType !== "agreement") {
4551
5225
  throw new AffinityValidationError(
4552
5226
  "commitmentType must be promise or agreement"
@@ -4561,11 +5235,12 @@ function recordCommitment(db, input) {
4561
5235
  assertOwnerParticipates(ownerId, ids);
4562
5236
  const now = resolveNow(input.now);
4563
5237
  const eventId = insertJournalEvent(db, {
5238
+ ...input,
4564
5239
  type: input.commitmentType,
4565
5240
  occurredAt: input.occurredAt,
4566
- summary: input.summary,
5241
+ occurredOn: input.occurredOn,
4567
5242
  significance: input.significance,
4568
- momentKind: null,
5243
+ momentKind: input.momentKind ?? null,
4569
5244
  participants: input.participants,
4570
5245
  now
4571
5246
  });
@@ -4574,15 +5249,25 @@ function recordCommitment(db, input) {
4574
5249
  event_id, commitment_type, due_at, created_at, updated_at
4575
5250
  ) VALUES (?, ?, ?, ?, ?)`
4576
5251
  ).run(eventId, input.commitmentType, input.dueAt ?? null, now, now);
5252
+ db.prepare("UPDATE open_commitments SET due_on = ? WHERE event_id = ?").run(
5253
+ input.dueAt == null ? null : input.dueOn ?? new Date(input.dueAt).toISOString().slice(0, 10),
5254
+ eventId
5255
+ );
4577
5256
  const mechanics = applyJournalEventMechanics(db, {
4578
5257
  eventId,
4579
5258
  eventType: input.commitmentType,
4580
5259
  occurredAt: input.occurredAt,
5260
+ occurredOn: input.occurredOn,
4581
5261
  significance: input.significance,
4582
5262
  participants: input.participants,
4583
5263
  provenance: input.provenance,
4584
5264
  now
4585
5265
  });
5266
+ if (input.momentKind !== void 0)
5267
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5268
+ input.momentKind,
5269
+ eventId
5270
+ );
4586
5271
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
4587
5272
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4588
5273
  affectedLinks: mechanics.affectedLinks,
@@ -4603,6 +5288,8 @@ var ALLOWED = /* @__PURE__ */ new Set([
4603
5288
  function recordInteraction(db, input) {
4604
5289
  return withTransaction(db, () => {
4605
5290
  validateSocialEventInput(input);
5291
+ const existing = existingEventReceipt(db, input.externalRef);
5292
+ if (existing) return existing;
4606
5293
  if (!ALLOWED.has(input.type)) {
4607
5294
  throw new AffinityValidationError("invalid interaction event type");
4608
5295
  }
@@ -4612,11 +5299,12 @@ function recordInteraction(db, input) {
4612
5299
  assertOwnerParticipates(ownerId, ids);
4613
5300
  const now = resolveNow(input.now);
4614
5301
  const eventId = insertJournalEvent(db, {
5302
+ ...input,
4615
5303
  type: input.type,
4616
5304
  occurredAt: input.occurredAt,
4617
- summary: input.summary,
5305
+ occurredOn: input.occurredOn,
4618
5306
  significance: input.significance,
4619
- momentKind: null,
5307
+ momentKind: input.momentKind ?? null,
4620
5308
  participants: input.participants,
4621
5309
  now
4622
5310
  });
@@ -4624,12 +5312,24 @@ function recordInteraction(db, input) {
4624
5312
  eventId,
4625
5313
  eventType: input.type,
4626
5314
  occurredAt: input.occurredAt,
5315
+ occurredOn: input.occurredOn,
4627
5316
  significance: input.significance,
4628
5317
  participants: input.participants,
4629
5318
  provenance: input.provenance,
4630
5319
  now
4631
5320
  });
5321
+ if (input.momentKind !== void 0)
5322
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5323
+ input.momentKind,
5324
+ eventId
5325
+ );
4632
5326
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5327
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5328
+ {
5329
+ code: "future_event",
5330
+ message: "occurredAt is in the future; it will not count as contact until then"
5331
+ }
5332
+ ] : void 0,
4633
5333
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4634
5334
  affectedLinks: mechanics.affectedLinks,
4635
5335
  derivedEffects: mechanics.derivedEffects
@@ -4641,17 +5341,20 @@ function recordInteraction(db, input) {
4641
5341
  function recordMilestone(db, input) {
4642
5342
  return withTransaction(db, () => {
4643
5343
  validateSocialEventInput(input);
5344
+ const existing = existingEventReceipt(db, input.externalRef);
5345
+ if (existing) return existing;
4644
5346
  const ownerId = requireOwnerContactId(db);
4645
5347
  const ids = input.participants.map((p) => p.contactId);
4646
5348
  assertParticipantContactsLive(db, ids);
4647
5349
  assertOwnerParticipates(ownerId, ids);
4648
5350
  const now = resolveNow(input.now);
4649
5351
  const eventId = insertJournalEvent(db, {
5352
+ ...input,
4650
5353
  type: "milestone",
4651
5354
  occurredAt: input.occurredAt,
4652
- summary: input.summary,
5355
+ occurredOn: input.occurredOn,
4653
5356
  significance: input.significance,
4654
- momentKind: null,
5357
+ momentKind: input.momentKind ?? null,
4655
5358
  participants: input.participants,
4656
5359
  now
4657
5360
  });
@@ -4659,12 +5362,24 @@ function recordMilestone(db, input) {
4659
5362
  eventId,
4660
5363
  eventType: "milestone",
4661
5364
  occurredAt: input.occurredAt,
5365
+ occurredOn: input.occurredOn,
4662
5366
  significance: input.significance,
4663
5367
  participants: input.participants,
4664
5368
  provenance: input.provenance,
4665
5369
  now
4666
5370
  });
5371
+ if (input.momentKind !== void 0)
5372
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5373
+ input.momentKind,
5374
+ eventId
5375
+ );
4667
5376
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5377
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5378
+ {
5379
+ code: "future_event",
5380
+ message: "occurredAt is in the future; it will not count as contact until then"
5381
+ }
5382
+ ] : void 0,
4668
5383
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4669
5384
  affectedLinks: mechanics.affectedLinks,
4670
5385
  derivedEffects: mechanics.derivedEffects
@@ -4676,15 +5391,18 @@ function recordMilestone(db, input) {
4676
5391
  function recordObservation(db, input) {
4677
5392
  return withTransaction(db, () => {
4678
5393
  validateSocialEventInput(input);
5394
+ const existing = existingEventReceipt(db, input.externalRef);
5395
+ if (existing) return existing;
4679
5396
  const ids = input.participants.map((p) => p.contactId);
4680
5397
  assertParticipantContactsLive(db, ids);
4681
5398
  const now = resolveNow(input.now);
4682
5399
  const eventId = insertJournalEvent(db, {
5400
+ ...input,
4683
5401
  type: "observation",
4684
5402
  occurredAt: input.occurredAt,
4685
- summary: input.summary,
5403
+ occurredOn: input.occurredOn,
4686
5404
  significance: input.significance,
4687
- momentKind: null,
5405
+ momentKind: input.momentKind ?? null,
4688
5406
  participants: input.participants,
4689
5407
  now
4690
5408
  });
@@ -4692,12 +5410,24 @@ function recordObservation(db, input) {
4692
5410
  eventId,
4693
5411
  eventType: "observation",
4694
5412
  occurredAt: input.occurredAt,
5413
+ occurredOn: input.occurredOn,
4695
5414
  significance: input.significance,
4696
5415
  participants: input.participants,
4697
5416
  provenance: input.provenance,
4698
5417
  now
4699
5418
  });
5419
+ if (input.momentKind !== void 0)
5420
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5421
+ input.momentKind,
5422
+ eventId
5423
+ );
4700
5424
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5425
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5426
+ {
5427
+ code: "future_event",
5428
+ message: "occurredAt is in the future; it will not count as contact until then"
5429
+ }
5430
+ ] : void 0,
4701
5431
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4702
5432
  affectedLinks: mechanics.affectedLinks,
4703
5433
  derivedEffects: mechanics.derivedEffects
@@ -4709,17 +5439,20 @@ function recordObservation(db, input) {
4709
5439
  function recordTransaction(db, input) {
4710
5440
  return withTransaction(db, () => {
4711
5441
  validateSocialEventInput(input);
5442
+ const existing = existingEventReceipt(db, input.externalRef);
5443
+ if (existing) return existing;
4712
5444
  const ownerId = requireOwnerContactId(db);
4713
5445
  const ids = input.participants.map((p) => p.contactId);
4714
5446
  assertParticipantContactsLive(db, ids);
4715
5447
  assertOwnerParticipates(ownerId, ids);
4716
5448
  const now = resolveNow(input.now);
4717
5449
  const eventId = insertJournalEvent(db, {
5450
+ ...input,
4718
5451
  type: "transaction",
4719
5452
  occurredAt: input.occurredAt,
4720
- summary: input.summary,
5453
+ occurredOn: input.occurredOn,
4721
5454
  significance: input.significance,
4722
- momentKind: null,
5455
+ momentKind: input.momentKind ?? null,
4723
5456
  participants: input.participants,
4724
5457
  now
4725
5458
  });
@@ -4727,12 +5460,24 @@ function recordTransaction(db, input) {
4727
5460
  eventId,
4728
5461
  eventType: "transaction",
4729
5462
  occurredAt: input.occurredAt,
5463
+ occurredOn: input.occurredOn,
4730
5464
  significance: input.significance,
4731
5465
  participants: input.participants,
4732
5466
  provenance: input.provenance,
4733
5467
  now
4734
5468
  });
5469
+ if (input.momentKind !== void 0)
5470
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5471
+ input.momentKind,
5472
+ eventId
5473
+ );
4735
5474
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5475
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5476
+ {
5477
+ code: "future_event",
5478
+ message: "occurredAt is in the future; it will not count as contact until then"
5479
+ }
5480
+ ] : void 0,
4736
5481
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4737
5482
  affectedLinks: mechanics.affectedLinks,
4738
5483
  derivedEffects: mechanics.derivedEffects
@@ -4805,6 +5550,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4805
5550
  const occurredAt = options?.resolvingEvent?.occurredAt ?? now;
4806
5551
  validateSocialEventInput({
4807
5552
  occurredAt,
5553
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4808
5554
  summary,
4809
5555
  significance,
4810
5556
  participants,
@@ -4821,6 +5567,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4821
5567
  const resolvingEventId = insertJournalEvent(db, {
4822
5568
  type: eventType,
4823
5569
  occurredAt,
5570
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4824
5571
  summary,
4825
5572
  significance,
4826
5573
  momentKind: null,
@@ -4854,30 +5601,46 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4854
5601
  });
4855
5602
  }
4856
5603
 
4857
- // src/contacts/export_contact.ts
4858
- function exportContact(db, id) {
4859
- const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
5604
+ // src/contacts/export_rows.ts
5605
+ function exportRows(db, sql, ...params) {
5606
+ return db.prepare(sql).all(...params).map((row) => {
4860
5607
  const result = {};
4861
5608
  for (const [key, value] of Object.entries(row)) {
4862
5609
  const name = key.replace(
4863
5610
  /_([a-z])/g,
4864
5611
  (_, letter) => letter.toUpperCase()
4865
5612
  );
4866
- result[name] = value;
4867
- if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
5613
+ result[name] = [
5614
+ "affects_bond",
5615
+ "is_owner",
5616
+ "is_structural",
5617
+ "verified",
5618
+ "counts_for_recency",
5619
+ "manual"
5620
+ ].includes(key) ? Boolean(value) : value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : value;
5621
+ if (name.endsWith("At") && typeof value === "number")
4868
5622
  result[`${name}Iso`] = new Date(value).toISOString();
4869
5623
  if (key.endsWith("_json") && typeof value === "string")
4870
5624
  result[name] = JSON.parse(value);
4871
5625
  }
4872
5626
  return result;
4873
5627
  });
5628
+ }
5629
+
5630
+ // src/contacts/export_contact.ts
5631
+ function exportContact(db, id) {
5632
+ const query = (sql, ...params) => exportRows(db, sql, ...params);
4874
5633
  const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
4875
5634
  if (!contact) throw new AffinityNotFoundError("contact not found");
4876
5635
  const events = query(
4877
- "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
5636
+ "SELECT DISTINCT e.*, COALESCE(e.parent_event_id, (SELECT parent.id FROM events parent WHERE parent.external_source=e.parent_source AND parent.external_id=e.parent_ref_id AND parent.deleted_at IS NULL)) AS parent_event_id, CAST(e.content AS BLOB) AS content, CAST(e.summary AS BLOB) AS summary FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? AND e.type != 'date_anchor' ORDER BY e.id",
4878
5637
  id
4879
5638
  ).map((event) => ({
4880
5639
  ...event,
5640
+ attributes: query(
5641
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5642
+ event.id
5643
+ ),
4881
5644
  participants: query(
4882
5645
  "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",
4883
5646
  event.id
@@ -4922,14 +5685,100 @@ function exportContact(db, id) {
4922
5685
  // src/contacts/export_affinity.ts
4923
5686
  function exportAffinity(db) {
4924
5687
  const now = Date.now();
5688
+ const records = db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id));
5689
+ const events = exportRows(
5690
+ db,
5691
+ "SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary FROM events ORDER BY id"
5692
+ ).map((event) => ({
5693
+ ...event,
5694
+ attributes: exportRows(
5695
+ db,
5696
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5697
+ event.id
5698
+ ),
5699
+ participants: exportRows(
5700
+ db,
5701
+ "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",
5702
+ event.id
5703
+ )
5704
+ }));
4925
5705
  return {
4926
5706
  version: 1,
4927
5707
  exportedAt: now,
4928
5708
  exportedAtIso: new Date(now).toISOString(),
4929
- contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
5709
+ events,
5710
+ contacts: records.map(({ events: events2, dates, ...record }) => ({
5711
+ ...record,
5712
+ eventIds: events2.map((event) => event.id),
5713
+ dateIds: dates.map((date) => date.id)
5714
+ }))
4930
5715
  };
4931
5716
  }
4932
5717
 
5718
+ // src/events/event_discovery.ts
5719
+ function searchEvents(db, query, options) {
5720
+ const { limit, offset } = resolveListLimitOffset(options);
5721
+ const tokens = foldSearchText(query).split(/\s+/).filter(Boolean);
5722
+ if (!tokens.length) return [];
5723
+ const rows = db.prepare(
5724
+ "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"
5725
+ ).all();
5726
+ return rows.flatMap((row) => {
5727
+ const fields = {
5728
+ summary: decodeEventText(row.summary),
5729
+ content: row.content === null ? null : decodeEventText(row.content),
5730
+ source: row.external_source,
5731
+ id: row.external_id,
5732
+ channel: row.channel
5733
+ };
5734
+ const folded = Object.entries(fields).map(
5735
+ ([key, value]) => [key, foldSearchText(value ?? "")]
5736
+ );
5737
+ if (!tokens.every((t) => folded.some(([, v]) => v.includes(t))))
5738
+ return [];
5739
+ return [
5740
+ {
5741
+ kind: "event",
5742
+ id: row.id,
5743
+ type: row.type,
5744
+ title: deriveEventSummary(decodeEventText(row.summary)),
5745
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
5746
+ matchedOn: folded.filter(([, v]) => tokens.some((t) => v.includes(t))).map(([k]) => k),
5747
+ ...row.external_source && row.external_id ? {
5748
+ externalRef: {
5749
+ source: row.external_source,
5750
+ id: row.external_id
5751
+ }
5752
+ } : {}
5753
+ }
5754
+ ];
5755
+ }).slice(offset, offset + limit);
5756
+ }
5757
+ function getEventThread(db, source, id, options) {
5758
+ const { limit, offset } = resolveListLimitOffset(options);
5759
+ const root = getEventByExternalRef(db, source, id);
5760
+ if (!root) return [];
5761
+ const rows = db.prepare(`WITH RECURSIVE thread(id) AS (
5762
+ SELECT ? UNION SELECT e.id FROM events e JOIN thread t JOIN events p ON p.id=t.id
5763
+ 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=?))
5764
+ ) SELECT e.id FROM events e JOIN thread t ON e.id=t.id WHERE e.deleted_at IS NULL AND (? IS NULL OR e.channel=?) AND (? IS NULL OR e.type IN (SELECT value FROM json_each(?))) ORDER BY e.occurred_at,e.id LIMIT ? OFFSET ?`).all(
5765
+ root.id,
5766
+ root.externalRef?.source ?? null,
5767
+ root.externalRef?.id ?? null,
5768
+ options?.channel ?? null,
5769
+ options?.channel?.trim().toLowerCase() ?? null,
5770
+ options?.eventTypes === void 0 ? null : JSON.stringify(options.eventTypes),
5771
+ options?.eventTypes === void 0 ? null : JSON.stringify(options.eventTypes),
5772
+ limit,
5773
+ offset
5774
+ );
5775
+ return batchLoadEventRecords(
5776
+ db,
5777
+ rows.map((r) => r.id),
5778
+ options?.includeContent ?? false
5779
+ );
5780
+ }
5781
+
4933
5782
  // src/soul.ts
4934
5783
  var soul_exports = {};
4935
5784
  __export(soul_exports, {
@@ -5136,6 +5985,15 @@ var identifyAndLocateContactsSkill = defineAffinitySkill({
5136
5985
  description: "Find the right contact reliably, prefer stable natural keys when possible, and avoid creating duplicates through weak lookup discipline.",
5137
5986
  content: `# Identify And Locate Contacts
5138
5987
 
5988
+ Handle identities normalize leading @ and case. Bluesky uses a domain handle,
5989
+ X/Twitter uses 1\u201315 letters/digits/underscores, Mastodon uses user@host.tld.
5990
+ Native did (alias atproto_did) validates did:<method>:<identifier>; legacy
5991
+ custom:atproto-did locators find the same identity. Keep old handles as identities.
5992
+ An owned identity makes contact creation fail atomically with conflict and
5993
+ existingContactId. input.ifExists="return" returns the matching contact and
5994
+ matchedIdentities without attaching any free identities. CLI: --if-exists return.
5995
+ Name-only possible duplicates still warn. Search @handle is always literal.
5996
+
5139
5997
  Primary tools:
5140
5998
  - \`search_affinity\`
5141
5999
  - \`inspect_affinity_item\`
@@ -5193,6 +6051,31 @@ var importHistoryWithoutFakingEvidenceSkill = defineAffinitySkill({
5193
6051
  description: "Import pre-existing relationship state honestly, using seeded links for known history instead of inventing fake journal evidence.",
5194
6052
  content: `# Import History Without Faking Evidence
5195
6053
 
6054
+ For social imports, use native did identities (atproto_did is an alias; legacy
6055
+ custom:atproto-did locators remain equivalent). Create with input.ifExists="return"
6056
+ for safe retries: this attaches nothing to an existing contact. For handle changes,
6057
+ call manage_identity add separately with input.ifExists="return"; an identity already
6058
+ on that contact is a no-op, while another owner still conflicts. Seed links with
6059
+ input.ifExists="return" to reuse them without changing their state.
6060
+ Use ghostpaw-affinity batch [--chunk 500] with JSONL {tool,args}, or
6061
+ {op:"event",kind:"interaction",input:{...}}. Per-line savepoints isolate failures;
6062
+ --stop-on-error stops early. Exit 2 identifies malformed JSON; other failures exit 1.
6063
+ Use externalRef {source:"bluesky",id:recordUri}, verbatim content and channel.
6064
+ Participant contact locators may use identities, including did, instead of ids.
6065
+ Read JSON flags from stdin with --input - or from @file for large payloads (E2BIG).
6066
+ SQLite date-times without offsets mean UTC; microseconds truncate to milliseconds; raw timestamps remain in provenance.
6067
+ Missing inbound-follow timestamps should use a known first_seen or be counted as skipped.
6068
+ The synthetic scripts/examples/import_social_jsonl.mjs and import_dry_run.mjs
6069
+ show the mapping and verify an idempotent retry and byte-identical text.
6070
+
6071
+ For archived messages with known timestamps, record actual evidence with externalRef:
6072
+ {source, id} deduplicates retries across event families and commitments. Keep original
6073
+ message text in content, use channel and parentRef/threadRef for context, and keep
6074
+ summary short or let it derive from content. Content is limited to 1 MB UTF-8;
6075
+ keep larger raw archives in the source system. Inspect events by external reference,
6076
+ search scope "events", and use manage_event revise/delete to correct imports.
6077
+ See the tools guide for field details. Existing 0.5.x databases upgrade in place.
6078
+
5196
6079
  Primary tools:
5197
6080
  - \`manage_contact\`
5198
6081
  - \`manage_identity\`
@@ -5249,6 +6132,14 @@ var managePromisesAndAgreementsSkill = defineAffinitySkill({
5249
6132
  description: "Track obligations explicitly from creation to resolution so open commitments remain actionable and trust consequences stay honest.",
5250
6133
  content: `# Manage Promises And Agreements
5251
6134
 
6135
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6136
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d).
6137
+ Date-only values keep their day with UTC boundaries; keywords use the process
6138
+ local date. Read occurredOn/dueOn and review occursOn for calendar dates;
6139
+ *Iso fields stay UTC. Future events warn and count as contact only when their
6140
+ time and day arrive. Commitments and resolutions never reset recency/cadence.
6141
+ Radar explains overdue promises and drift (gap > 1.5 times cadence).
6142
+
5252
6143
  Primary tools:
5253
6144
  - \`manage_commitment\`
5254
6145
  - \`review_affinity\`
@@ -5304,6 +6195,14 @@ var manageRecurringDatesAndRemindersSkill = defineAffinitySkill({
5304
6195
  description: "Use date anchors for recurring salience so important birthdays, anniversaries, renewals, and memorials feed review surfaces cleanly.",
5305
6196
  content: `# Manage Recurring Dates And Reminders
5306
6197
 
6198
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6199
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d).
6200
+ Date-only values keep their day with UTC boundaries; keywords use the process
6201
+ local date. Read occurredOn/dueOn and review occursOn for calendar dates;
6202
+ *Iso fields stay UTC. Future events warn and count as contact only when their
6203
+ time and day arrive. Commitments and resolutions never reset recency/cadence.
6204
+ Radar explains overdue promises and drift (gap > 1.5 times cadence).
6205
+
5307
6206
  Primary tools:
5308
6207
  - \`manage_date_anchor\`
5309
6208
  - \`review_affinity\`
@@ -5415,6 +6314,24 @@ var recordDirectEvidenceWellSkill = defineAffinitySkill({
5415
6314
  description: "Record direct owner-facing social evidence in a way that produces truthful mechanics, good moments, and useful future review surfaces.",
5416
6315
  content: `# Record Direct Evidence Well
5417
6316
 
6317
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6318
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d). Date-only inputs
6319
+ preserve the calendar day at UTC midnight (UTC end of day for dueAt).
6320
+ Keywords use the process local date. Read occurredOn/dueOn and review occursOn
6321
+ for calendar dates; *Iso fields remain UTC. Future events carry future_event
6322
+ warnings and do not count for recency, cadence or drift until their time and day
6323
+ arrive. Commitments and resolutions never reset contact recency or cadence.
6324
+
6325
+ Imported text and event maintenance:
6326
+ - Use externalRef: {source, id} for idempotent intake; retries return no_op and the existing id.
6327
+ - Store verbatim text in content (up to 1,048,576 UTF-8 bytes, empty allowed). Summary is optional when content exists; its derived label is at most 140 characters.
6328
+ - Use channel for the medium, parentRef or parentEventId for replies, and threadRef for the root. Event attributes use target {kind: "event", eventId} or {kind: "event", ref: {source, id}}. Unchanged values return no_op.
6329
+ - Inspect kind "event" by eventId or ref. Search scope "events" or "all" discovers event ids; default scope remains contacts.
6330
+ - Review journals/timelines with includeContent: true only when needed; CLI uses --with-content. Filter with channel. Review events.thread by ref for chronological context.
6331
+ - Use manage_event revise with patch: summary, content, occurredAt, significance, channel, type within the same kind, or externalRef only when absent. Unknown keys fail validation.
6332
+ - Use manage_event delete with confirm: true to remove incorrect evidence and recompute affected relationship scores.
6333
+ - Send large payloads through whole-payload stdin or @file rather than argv. For example, affinity event record < payload.json or affinity call record_event - < payload.json.
6334
+
5418
6335
  Primary tools:
5419
6336
  - \`record_event\`
5420
6337
  - \`inspect_affinity_item\`
@@ -5473,6 +6390,17 @@ var recordObservationsAndReferralsSkill = defineAffinitySkill({
5473
6390
  description: "Capture third-party and referral truth without overclaiming intimacy, directness, or trust that the operator did not actually earn.",
5474
6391
  content: `# Record Observations And Referrals
5475
6392
 
6393
+ Likes, reposts and follows are ambient observations, not milestones. Record them
6394
+ with affectsBond:false. Like/follow/repost labels default to false across families;
6395
+ explicit true opts in. Observations with significance <=2 also default to false.
6396
+ They stay in journals but do not change affinity or meaningful-contact recency.
6397
+ provenance.ambient:true also suppresses effects. Use explicit owner and other
6398
+ participants, actor/recipient roles, and the correct directionality.
6399
+ Unanswered owner_initiated evidence contributes much less and does not reset
6400
+ radar recency. A relationship with only outbound activity gets no routine check-in
6401
+ or drift prompt. other_initiated evidence counts fully. Observations and follow-like
6402
+ milestones never derive breakthroughs; low-significance milestones derive no moment.
6403
+
5476
6404
  Primary tools:
5477
6405
  - \`record_event\`
5478
6406
  - \`review_affinity\`
@@ -5582,6 +6510,19 @@ var reviewRadarProgressionAndGraphSkill = defineAffinitySkill({
5582
6510
  description: "Use the review surfaces to prioritize maintenance, understand progression readiness, and read the shape of the relationship graph without guessing from raw rows.",
5583
6511
  content: `# Review Radar, Progression, And Graph
5584
6512
 
6513
+ List reviews accept limit (default 50, maximum 250) and offset. Results include
6514
+ total and hasMore; continue paging while hasMore is true. Event type array flags
6515
+ accept JSON arrays, comma lists, or repeated --event-types flags; invalid types
6516
+ report allowed values with exit 2. Use skill --short for a compact CLI reference.
6517
+
6518
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6519
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d).
6520
+ Date-only values keep their day with UTC boundaries; keywords use the process
6521
+ local date. Read occurredOn/dueOn and review occursOn for calendar dates;
6522
+ *Iso fields stay UTC. Future events warn and count as contact only when their
6523
+ time and day arrive. Commitments and resolutions never reset recency/cadence.
6524
+ Radar explains overdue promises and drift (gap > 1.5 times cadence).
6525
+
5585
6526
  Primary tools:
5586
6527
  - \`review_affinity\`
5587
6528
  - \`inspect_affinity_item\`
@@ -5682,6 +6623,9 @@ __export(tools_exports, {
5682
6623
  manageDuplicateDismissalTool: () => manageDuplicateDismissalTool,
5683
6624
  manageDuplicateDismissalToolHandler: () => manageDuplicateDismissalToolHandler,
5684
6625
  manageDuplicateDismissalToolName: () => manageDuplicateDismissalToolName,
6626
+ manageEventTool: () => manageEventTool,
6627
+ manageEventToolHandler: () => manageEventToolHandler,
6628
+ manageEventToolName: () => manageEventToolName,
5685
6629
  manageIdentityTool: () => manageIdentityTool,
5686
6630
  manageIdentityToolHandler: () => manageIdentityToolHandler,
5687
6631
  manageIdentityToolName: () => manageIdentityToolName,
@@ -5732,37 +6676,8 @@ __export(tools_exports, {
5732
6676
  });
5733
6677
 
5734
6678
  // src/tools/parse_time_input.ts
5735
- var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
5736
6679
  function parseTimeInput(value, field = "time", now = Date.now()) {
5737
- const fail = () => {
5738
- throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
5739
- };
5740
- if (typeof value === "number")
5741
- return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
5742
- if (typeof value !== "string") return fail();
5743
- const text = value.trim();
5744
- const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
5745
- let day;
5746
- if (relative) {
5747
- const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
5748
- day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
5749
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
5750
- day = Date.parse(`${text}T00:00:00.000Z`);
5751
- if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
5752
- return fail();
5753
- } else {
5754
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
5755
- text
5756
- ))
5757
- return fail();
5758
- const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
5759
- if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
5760
- return fail();
5761
- const parsed = Date.parse(text);
5762
- return Number.isFinite(parsed) ? parsed : fail();
5763
- }
5764
- const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
5765
- return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
6680
+ return parseCalendarTime(value, field, now).timestamp;
5766
6681
  }
5767
6682
 
5768
6683
  // src/tools/tool_metadata.ts
@@ -5846,6 +6761,39 @@ function oneOfSchema(schemas, description) {
5846
6761
  return schema;
5847
6762
  }
5848
6763
 
6764
+ // src/tools/event_metadata_schema.ts
6765
+ var externalRefSchema = objectSchema(
6766
+ {
6767
+ source: stringSchema("External source; trimmed and lowercased."),
6768
+ id: stringSchema("Non-empty external id.")
6769
+ },
6770
+ ["source", "id"]
6771
+ );
6772
+ var eventMetadataSchema = {
6773
+ affectsBond: booleanSchema(
6774
+ "Whether evidence affects the relationship; defaults false for like/follow/repost labels and observations with significance <=2; explicit true opts in."
6775
+ ),
6776
+ content: stringSchema(
6777
+ "Verbatim text, including empty text; at most 1 MB UTF-8. Summary may be omitted when content is supplied."
6778
+ ),
6779
+ externalRef: externalRefSchema,
6780
+ channel: stringSchema(
6781
+ "Free-text channel, trimmed and lowercased, at most 40 characters."
6782
+ ),
6783
+ parentRef: externalRefSchema,
6784
+ parentEventId: integerSchema("Parent event id."),
6785
+ threadRef: externalRefSchema,
6786
+ momentKind: enumSchema("Optional moment kind.", [
6787
+ "breakthrough",
6788
+ "rupture",
6789
+ "reconciliation",
6790
+ "milestone",
6791
+ "turning_point"
6792
+ ]),
6793
+ occurredOn: stringSchema("Calendar day."),
6794
+ provenance: {}
6795
+ };
6796
+
5849
6797
  // src/tools/tool_names.ts
5850
6798
  var searchAffinityToolName = "search_affinity";
5851
6799
  var reviewAffinityToolName = "review_affinity";
@@ -5859,6 +6807,7 @@ var recordEventToolName = "record_event";
5859
6807
  var manageCommitmentToolName = "manage_commitment";
5860
6808
  var manageDateAnchorToolName = "manage_date_anchor";
5861
6809
  var manageAttributeToolName = "manage_attribute";
6810
+ var manageEventToolName = "manage_event";
5862
6811
 
5863
6812
  // src/tools/tool_ref.ts
5864
6813
  function contactState(value) {
@@ -5918,7 +6867,7 @@ function toEventEntityRef(value) {
5918
6867
  return {
5919
6868
  kind: "event",
5920
6869
  id: value.id,
5921
- title: value.summary,
6870
+ title: deriveEventSummary(value.summary),
5922
6871
  subtitle: value.type,
5923
6872
  state: value.type
5924
6873
  };
@@ -5966,16 +6915,6 @@ function toReceiptEntityRefs(receipt) {
5966
6915
  });
5967
6916
  }
5968
6917
 
5969
- // src/identities/normalize.ts
5970
- function normalizeIdentityKey(type, value) {
5971
- const t = type.trim().toLowerCase();
5972
- const v = value.trim().toLowerCase();
5973
- if (t.length === 0 || v.length === 0) {
5974
- throw new AffinityValidationError("type and value must be non-empty");
5975
- }
5976
- return `${t}:${v}`;
5977
- }
5978
-
5979
6918
  // src/tools/tool_types.ts
5980
6919
  function withOptionalFields(result, options) {
5981
6920
  if (options.next && options.next.length > 0) {
@@ -6091,7 +7030,7 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
6091
7030
  const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
6092
7031
  return {
6093
7032
  ok: false,
6094
- result: toolFailure("domain", "invalid_state", message, message)
7033
+ result: toolFailure("domain", "conflict", message, message)
6095
7034
  };
6096
7035
  }
6097
7036
  return { ok: true, value: row2 };
@@ -6116,9 +7055,13 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
6116
7055
  INNER JOIN contacts c ON c.id = i.contact_id
6117
7056
  WHERE i.removed_at IS NULL
6118
7057
  AND c.deleted_at IS NULL
6119
- AND i.normalized_key = ?
7058
+ AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))
6120
7059
  LIMIT 2`
6121
- ).all(normalizedKey);
7060
+ ).all(
7061
+ normalizedKey,
7062
+ legacyIdentityKey(normalizedKey),
7063
+ normalizedKey
7064
+ );
6122
7065
  if (row.length === 0) {
6123
7066
  return {
6124
7067
  ok: false,
@@ -6269,13 +7212,21 @@ function resolveLinkLocator(db, locator, field = "link") {
6269
7212
  return { ok: true, value: matchedLink };
6270
7213
  }
6271
7214
  function mapErrorToToolFailure(error, summary = "The tool failed.") {
7215
+ if (error && typeof error === "object" && "ok" in error && error.ok === false && "outcome" in error && error.outcome === "error")
7216
+ return error;
6272
7217
  if (isAffinityError(error)) {
6273
- return toolFailure(
7218
+ const result = toolFailure(
6274
7219
  "domain",
6275
7220
  affinityErrorCodeToToolCode(error),
6276
7221
  summary,
6277
7222
  error.message
6278
7223
  );
7224
+ const id = error.existingContactId;
7225
+ if (id !== void 0) {
7226
+ result.data = { existingContactId: id };
7227
+ result.error.details = { existingContactId: id };
7228
+ }
7229
+ return result;
6279
7230
  }
6280
7231
  if (error instanceof Error) {
6281
7232
  return toolFailure("system", "system_error", summary, error.message);
@@ -6314,27 +7265,32 @@ function affinityErrorCodeToToolCode(error) {
6314
7265
  }
6315
7266
 
6316
7267
  // src/tools/validate_tool_input.ts
6317
- function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
7268
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now(), strict = false) {
7269
+ if (path.endsWith(".occurredAt") && value == null)
7270
+ return "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)";
7271
+ const branches = schema.oneOf ?? schema.anyOf;
7272
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && value !== null && typeof value !== "string")
7273
+ return `attribute values are strings or null; pass "${String(value)}" to store it as text`;
6318
7274
  if (schema.anyOf || schema.oneOf) {
6319
7275
  const choices = schema.anyOf ?? schema.oneOf ?? [];
6320
7276
  return choices.some(
6321
- (choice) => validateToolInput(choice, value, path, injectedNow) === null
7277
+ (choice) => validateToolInput(choice, value, path, injectedNow, strict) === null
6322
7278
  ) ? null : `${path}: invalid value`;
6323
7279
  }
6324
7280
  if (schema.type === "object") {
6325
7281
  if (value === null || typeof value !== "object" || Array.isArray(value))
6326
7282
  return `${path}: missing input or expected object`;
6327
7283
  const object = value;
6328
- if (path.endsWith(".patch") && schema.additionalProperties === false) {
7284
+ if ((strict || path.endsWith(".patch")) && schema.additionalProperties === false) {
6329
7285
  for (const key of Object.keys(object)) {
6330
7286
  if (!Object.hasOwn(schema.properties ?? {}, key))
6331
- 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(", ")}`;
7287
+ 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(", ")}`;
6332
7288
  }
6333
7289
  }
6334
7290
  if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
6335
7291
  return `${path}.patch: missing required field`;
6336
7292
  if (object.patch !== void 0 && !schema.properties?.patch)
6337
- return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
7293
+ return `${path}.patch: unknown key; allowed keys: none (use the action's named fields)`;
6338
7294
  const referenceNow = object.now === void 0 ? injectedNow : (() => {
6339
7295
  try {
6340
7296
  return parseTimeInput(
@@ -6349,11 +7305,21 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
6349
7305
  for (const [key, child] of Object.entries(schema.properties ?? {})) {
6350
7306
  if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
6351
7307
  try {
6352
- object[key] = parseTimeInput(
7308
+ const parsed = parseCalendarTime(
6353
7309
  object[key],
6354
7310
  `${path}.${key}`,
6355
7311
  referenceNow
6356
7312
  );
7313
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key) && typeof object[key] === "string" && /\.\d{4,}/.test(object[key])) {
7314
+ const provenance = object.provenance;
7315
+ object.provenance = {
7316
+ ...provenance && typeof provenance === "object" ? provenance : {},
7317
+ [`raw${key[0]?.toUpperCase()}${key.slice(1)}`]: object[key]
7318
+ };
7319
+ }
7320
+ object[key] = parsed.timestamp;
7321
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key))
7322
+ object[key.replace(/At$/, "On")] ??= parsed.day;
6357
7323
  } catch (error) {
6358
7324
  return error.message;
6359
7325
  }
@@ -6368,8 +7334,9 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
6368
7334
  const error = validateToolInput(
6369
7335
  child,
6370
7336
  object[key],
6371
- `${path}.${key}`,
6372
- referenceNow
7337
+ key === "input" && path === "input" ? path : `${path}.${key}`,
7338
+ referenceNow,
7339
+ strict
6373
7340
  );
6374
7341
  if (error) return error;
6375
7342
  }
@@ -6382,7 +7349,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
6382
7349
  schema.items ?? {},
6383
7350
  item,
6384
7351
  `${path}[${index}]`,
6385
- injectedNow
7352
+ injectedNow,
7353
+ strict
6386
7354
  );
6387
7355
  if (error) return error;
6388
7356
  }
@@ -6448,6 +7416,26 @@ function inspectAffinityItemToolHandler(db, rawInput) {
6448
7416
  inputError
6449
7417
  );
6450
7418
  return withToolHandling(() => {
7419
+ if (input.kind === "event") {
7420
+ if (input.eventId === void 0 === (input.ref === void 0))
7421
+ return toolFailure(
7422
+ "protocol",
7423
+ "invalid_input",
7424
+ "Invalid event target.",
7425
+ "Provide exactly one of eventId or ref."
7426
+ );
7427
+ const detail2 = input.ref ? getEventByExternalRef(db, input.ref.source, input.ref.id) : getEventById(db, input.eventId);
7428
+ return detail2 ? toolSuccess(
7429
+ `Loaded event #${detail2.id}.`,
7430
+ { kind: "event", detail: detail2 },
7431
+ { entities: [{ kind: "event", id: detail2.id }] }
7432
+ ) : toolFailure(
7433
+ "domain",
7434
+ "not_found",
7435
+ "Event not found.",
7436
+ "No event matches the target."
7437
+ );
7438
+ }
6451
7439
  if (input.kind === "owner_profile") {
6452
7440
  const detail2 = getOwnerProfile(
6453
7441
  db,
@@ -6545,10 +7533,13 @@ var inspectAffinityItemTool = defineAffinityTool({
6545
7533
  inputSchema: objectSchema(
6546
7534
  {
6547
7535
  kind: enumSchema("What to inspect.", [
7536
+ "event",
6548
7537
  "owner_profile",
6549
7538
  "contact_profile",
6550
7539
  "link"
6551
7540
  ]),
7541
+ eventId: integerSchema("Exact event id."),
7542
+ ref: externalRefSchema,
6552
7543
  contact: contactLocatorSchema(
6553
7544
  "Target contact. Required when kind=contact_profile. Provide contactId or identity."
6554
7545
  ),
@@ -6567,6 +7558,7 @@ var inspectAffinityItemTool = defineAffinityTool({
6567
7558
  // src/attributes/helpers.ts
6568
7559
  function clearedAttributePrimary(target) {
6569
7560
  return {
7561
+ ...target.kind === "event" ? { eventId: target.eventId } : {},
6570
7562
  id: 0,
6571
7563
  contactId: target.kind === "contact" ? target.id : null,
6572
7564
  linkId: target.kind === "link" ? target.id : null,
@@ -6578,6 +7570,7 @@ function clearedAttributePrimary(target) {
6578
7570
  // src/attributes/mappers.ts
6579
7571
  function mapAttributeRowToAttributeRecord(row) {
6580
7572
  return {
7573
+ ...row.event_id == null ? {} : { eventId: row.event_id },
6581
7574
  id: row.id,
6582
7575
  contactId: row.contact_id,
6583
7576
  linkId: row.link_id,
@@ -6587,49 +7580,26 @@ function mapAttributeRowToAttributeRecord(row) {
6587
7580
  }
6588
7581
 
6589
7582
  // src/attributes/queries.ts
7583
+ function attributeTargetSql(target) {
7584
+ return {
7585
+ column: target.kind === "event" ? "event_id" : target.kind === "contact" ? "contact_id" : "link_id",
7586
+ id: target.kind === "event" ? target.eventId : target.id
7587
+ };
7588
+ }
6590
7589
  function getAttributeRowById(db, id) {
6591
- const row = db.prepare(
6592
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
6593
- FROM attributes WHERE id = ?`
6594
- ).get(id);
6595
- if (row === void 0) {
6596
- return null;
6597
- }
6598
- return row;
7590
+ return db.prepare("SELECT * FROM attributes WHERE id=?").get(id) ?? null;
6599
7591
  }
6600
7592
  function getLiveAttributeRow(db, target, name) {
6601
- if (target.kind === "contact") {
6602
- const row2 = db.prepare(
6603
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
6604
- FROM attributes
6605
- WHERE contact_id = ? AND name = ? AND deleted_at IS NULL`
6606
- ).get(target.id, name);
6607
- if (row2 === void 0) {
6608
- return null;
6609
- }
6610
- return row2;
6611
- }
6612
- const row = db.prepare(
6613
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
6614
- FROM attributes
6615
- WHERE link_id = ? AND name = ? AND deleted_at IS NULL`
6616
- ).get(target.id, name);
6617
- if (row === void 0) {
6618
- return null;
6619
- }
6620
- return row;
7593
+ const t = attributeTargetSql(target);
7594
+ return db.prepare(
7595
+ `SELECT * FROM attributes WHERE ${t.column}=? AND name=? AND deleted_at IS NULL`
7596
+ ).get(t.id, name) ?? null;
6621
7597
  }
6622
7598
  function listLiveAttributeIdsForTarget(db, target) {
6623
- if (target.kind === "contact") {
6624
- const rows2 = db.prepare(
6625
- "SELECT id FROM attributes WHERE contact_id = ? AND deleted_at IS NULL ORDER BY id"
6626
- ).all(target.id);
6627
- return rows2.map((row) => row.id);
6628
- }
6629
- const rows = db.prepare(
6630
- "SELECT id FROM attributes WHERE link_id = ? AND deleted_at IS NULL ORDER BY id"
6631
- ).all(target.id);
6632
- return rows.map((row) => row.id);
7599
+ const t = attributeTargetSql(target);
7600
+ return db.prepare(
7601
+ `SELECT id FROM attributes WHERE ${t.column}=? AND deleted_at IS NULL ORDER BY id`
7602
+ ).all(t.id).map((r) => r.id);
6633
7603
  }
6634
7604
 
6635
7605
  // src/attributes/receipts.ts
@@ -6651,6 +7621,8 @@ function normalizeAttributeValue(value) {
6651
7621
  if (value === null) {
6652
7622
  return null;
6653
7623
  }
7624
+ if (typeof value !== "string")
7625
+ throw new AffinityValidationError("attribute values are strings or null");
6654
7626
  const trimmed = value.trim();
6655
7627
  return trimmed.length === 0 ? null : trimmed;
6656
7628
  }
@@ -6679,6 +7651,11 @@ function validateAttributeEntries(entries) {
6679
7651
  return out;
6680
7652
  }
6681
7653
  function assertAttributeTargetWritable(db, target) {
7654
+ if (target.kind === "event") {
7655
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(target.eventId))
7656
+ throw new AffinityNotFoundError("event not found");
7657
+ return;
7658
+ }
6682
7659
  if (target.kind === "contact") {
6683
7660
  const contact = getContactRowById(db, target.id);
6684
7661
  if (!contact) {
@@ -6714,26 +7691,14 @@ function replaceAttributes(db, target, entries) {
6714
7691
  }
6715
7692
  const created = [];
6716
7693
  let lastId = 0;
6717
- if (target.kind === "contact") {
6718
- const insert = db.prepare(
6719
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
6720
- VALUES (?, NULL, ?, ?, ?, ?)`
6721
- );
6722
- for (const e of normalized) {
6723
- const r = insert.run(target.id, e.name, e.value, now, now);
6724
- lastId = Number(r.lastInsertRowid);
6725
- created.push({ kind: "attribute", id: lastId });
6726
- }
6727
- } else {
6728
- const insert = db.prepare(
6729
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
6730
- VALUES (NULL, ?, ?, ?, ?, ?)`
6731
- );
6732
- for (const e of normalized) {
6733
- const r = insert.run(target.id, e.name, e.value, now, now);
6734
- lastId = Number(r.lastInsertRowid);
6735
- created.push({ kind: "attribute", id: lastId });
6736
- }
7694
+ const t = attributeTargetSql(target);
7695
+ const insert = db.prepare(
7696
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
7697
+ );
7698
+ for (const e of normalized) {
7699
+ const r = insert.run(t.id, e.name, e.value, now, now);
7700
+ lastId = Number(r.lastInsertRowid);
7701
+ created.push({ kind: "attribute", id: lastId });
6737
7702
  }
6738
7703
  if (normalized.length === 0) {
6739
7704
  return buildAttributeMutationReceipt(clearedAttributePrimary(target), {
@@ -6763,6 +7728,11 @@ function setAttribute(db, target, name, value) {
6763
7728
  const stored = normalizeAttributeValue(value);
6764
7729
  const now = resolveNow();
6765
7730
  const existing = getLiveAttributeRow(db, target, key);
7731
+ if (existing?.value === stored)
7732
+ return buildAttributeMutationReceipt(
7733
+ mapAttributeRowToAttributeRecord(existing),
7734
+ {}
7735
+ );
6766
7736
  if (existing) {
6767
7737
  db.prepare(
6768
7738
  "UPDATE attributes SET value = ?, updated_at = ? WHERE id = ?"
@@ -6778,27 +7748,10 @@ function setAttribute(db, target, name, value) {
6778
7748
  }
6779
7749
  );
6780
7750
  }
6781
- if (target.kind === "contact") {
6782
- const r2 = db.prepare(
6783
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
6784
- VALUES (?, NULL, ?, ?, ?, ?)`
6785
- ).run(target.id, key, stored, now, now);
6786
- const id2 = Number(r2.lastInsertRowid);
6787
- const row2 = getAttributeRowById(db, id2);
6788
- if (!row2) {
6789
- throw new AffinityInvariantError("attribute row missing after insert");
6790
- }
6791
- return buildAttributeMutationReceipt(
6792
- mapAttributeRowToAttributeRecord(row2),
6793
- {
6794
- created: [{ kind: "attribute", id: id2 }]
6795
- }
6796
- );
6797
- }
7751
+ const t = attributeTargetSql(target);
6798
7752
  const r = db.prepare(
6799
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
6800
- VALUES (NULL, ?, ?, ?, ?, ?)`
6801
- ).run(target.id, key, stored, now, now);
7753
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
7754
+ ).run(t.id, key, stored, now, now);
6802
7755
  const id = Number(r.lastInsertRowid);
6803
7756
  const row = getAttributeRowById(db, id);
6804
7757
  if (!row) {
@@ -6854,14 +7807,15 @@ function mutationToolResult(action, receipt, entitySummary, fields) {
6854
7807
  affectedLinks: receipt.affectedLinks,
6855
7808
  derivedEffects: receipt.derivedEffects
6856
7809
  };
6857
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
7810
+ 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);
6858
7811
  if (changedCount === 0) {
6859
7812
  return toolNoOp(summary, data, {
6860
7813
  entities: toReceiptEntityRefs(receipt)
6861
7814
  });
6862
7815
  }
6863
7816
  return toolSuccess(summary, data, {
6864
- entities: toReceiptEntityRefs(receipt)
7817
+ entities: toReceiptEntityRefs(receipt),
7818
+ warnings: receipt.warnings
6865
7819
  });
6866
7820
  }
6867
7821
  var PLURALS = { identity: "identities" };
@@ -6918,6 +7872,30 @@ function linkLocatorSchema2(description) {
6918
7872
  };
6919
7873
  }
6920
7874
  function resolveAttributeTarget(db, target) {
7875
+ if (target.kind === "event") {
7876
+ if (target.eventId === void 0 === (target.ref === void 0))
7877
+ return {
7878
+ kind: "error",
7879
+ result: toolFailure(
7880
+ "protocol",
7881
+ "invalid_input",
7882
+ "Invalid event target.",
7883
+ "Provide exactly one of target.eventId or target.ref."
7884
+ )
7885
+ };
7886
+ const eventId = target.eventId ?? (target.ref && getEventByExternalRef(db, target.ref.source, target.ref.id)?.id);
7887
+ if (eventId === void 0)
7888
+ return {
7889
+ kind: "error",
7890
+ result: toolFailure(
7891
+ "domain",
7892
+ "not_found",
7893
+ "Event not found.",
7894
+ "No event matches target.ref."
7895
+ )
7896
+ };
7897
+ return { kind: "ok", target: { kind: "event", eventId } };
7898
+ }
6921
7899
  if (target.kind === "contact") {
6922
7900
  const contact = resolveContactLocator(db, target.contact, "target.contact");
6923
7901
  if (!contact.ok) {
@@ -6977,7 +7955,7 @@ function manageAttributeToolHandler(db, rawInput) {
6977
7955
  }
6978
7956
  var manageAttributeTool = defineAffinityTool({
6979
7957
  name: manageAttributeToolName,
6980
- 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.",
7958
+ 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.",
6981
7959
  whenToUse: "Use this for metadata such as tags, notes, or small labeled facts attached to a contact or link.",
6982
7960
  whenNotToUse: "Do not use this for core contact fields, identities, or direct relationship mechanics.",
6983
7961
  sideEffects: "writes_state",
@@ -6998,7 +7976,9 @@ var manageAttributeTool = defineAffinityTool({
6998
7976
  action: enumSchema("Operation to perform.", ["set", "unset", "replace"]),
6999
7977
  target: objectSchema(
7000
7978
  {
7001
- kind: enumSchema("Target kind.", ["contact", "link"]),
7979
+ kind: enumSchema("Target kind.", ["contact", "link", "event"]),
7980
+ eventId: integerSchema("Target event id; provide eventId or ref."),
7981
+ ref: externalRefSchema,
7002
7982
  contact: contactLocatorSchema2(
7003
7983
  "Contact locator. Required when kind=contact. Provide contactId or identity."
7004
7984
  ),
@@ -7028,7 +8008,7 @@ var manageAttributeTool = defineAffinityTool({
7028
8008
  removedAt: integerSchema("Removal timestamp. Optional for action=unset.")
7029
8009
  },
7030
8010
  ["action"],
7031
- "Set, unset, or replace attributes on contacts and links."
8011
+ "Set, unset, or replace attributes on contacts, links and events."
7032
8012
  ),
7033
8013
  handler: manageAttributeToolHandler
7034
8014
  });
@@ -7060,7 +8040,13 @@ function prepareParticipants(db, raw) {
7060
8040
  }
7061
8041
  function manageCommitmentToolHandler(db, rawInput) {
7062
8042
  const input = structuredClone(rawInput);
7063
- const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
8043
+ const inputError = validateToolInput(
8044
+ manageCommitmentTool.inputSchema,
8045
+ input,
8046
+ "input",
8047
+ Date.now(),
8048
+ true
8049
+ );
7064
8050
  if (inputError)
7065
8051
  return toolFailure(
7066
8052
  "protocol",
@@ -7090,6 +8076,21 @@ function manageCommitmentToolHandler(db, rawInput) {
7090
8076
  "commitment"
7091
8077
  );
7092
8078
  }
8079
+ if (!Number.isInteger(input.commitmentEventId))
8080
+ return toolFailure(
8081
+ "protocol",
8082
+ "invalid_input",
8083
+ "Missing commitment.",
8084
+ "commitmentEventId is required for resolve."
8085
+ );
8086
+ const event = db.prepare("SELECT type FROM events WHERE id = ? AND deleted_at IS NULL").get(input.commitmentEventId);
8087
+ if (event && !["promise", "agreement"].includes(event.type))
8088
+ return toolFailure(
8089
+ "domain",
8090
+ "invalid_input",
8091
+ "Invalid commitment.",
8092
+ "not a commitment event"
8093
+ );
7093
8094
  return mutationToolResult(
7094
8095
  "resolve",
7095
8096
  resolveCommitment(
@@ -7128,15 +8129,21 @@ var manageCommitmentTool = defineAffinityTool({
7128
8129
  "promise",
7129
8130
  "agreement"
7130
8131
  ]),
8132
+ ...eventMetadataSchema,
7131
8133
  occurredAt: integerSchema(
7132
8134
  "When the commitment was made, Unix timestamp in milliseconds."
7133
8135
  ),
7134
8136
  summary: stringSchema("Commitment summary. Must be non-empty."),
7135
8137
  significance: integerSchema("Commitment significance from 1 to 10."),
7136
8138
  dueAt: integerSchema("Optional due timestamp."),
8139
+ dueOn: stringSchema("Due calendar day."),
7137
8140
  participants: arraySchema(
7138
8141
  objectSchema(
7139
8142
  {
8143
+ contact: objectSchema(
8144
+ { contactId: integerSchema("Contact id.") },
8145
+ ["contactId"]
8146
+ ),
7140
8147
  contactId: integerSchema(
7141
8148
  "Participant contact id. The owner contact MUST be included."
7142
8149
  ),
@@ -7149,13 +8156,7 @@ var manageCommitmentTool = defineAffinityTool({
7149
8156
  ),
7150
8157
  now: integerSchema("Optional timestamp override.")
7151
8158
  },
7152
- [
7153
- "commitmentType",
7154
- "occurredAt",
7155
- "summary",
7156
- "significance",
7157
- "participants"
7158
- ],
8159
+ ["commitmentType", "occurredAt", "significance", "participants"],
7159
8160
  "Commitment payload. Required when action=record."
7160
8161
  ),
7161
8162
  commitmentEventId: integerSchema(
@@ -7170,6 +8171,19 @@ var manageCommitmentTool = defineAffinityTool({
7170
8171
  now: integerSchema("Optional timestamp."),
7171
8172
  resolvingEvent: objectSchema(
7172
8173
  {
8174
+ occurredOn: stringSchema("Calendar day."),
8175
+ provenance: {},
8176
+ participants: arraySchema(
8177
+ objectSchema(
8178
+ {
8179
+ contactId: integerSchema("Contact id."),
8180
+ role: stringSchema("Role."),
8181
+ directionality: stringSchema("Directionality.")
8182
+ },
8183
+ ["contactId", "role"]
8184
+ ),
8185
+ "Resolving participants."
8186
+ ),
7173
8187
  type: stringSchema("Optional resolving event type."),
7174
8188
  occurredAt: integerSchema("Optional resolving event timestamp."),
7175
8189
  summary: stringSchema("Optional resolving event summary."),
@@ -7188,221 +8202,69 @@ var manageCommitmentTool = defineAffinityTool({
7188
8202
  "Record or resolve one commitment."
7189
8203
  ),
7190
8204
  handler: manageCommitmentToolHandler
7191
- });
7192
-
7193
- // src/contacts/delete_contact.ts
7194
- function deleteContact(db, locatorId, options) {
7195
- if (options?.confirm !== true)
7196
- throw new AffinityValidationError(
7197
- "delete is permanent; pass confirm: true"
7198
- );
7199
- return withTransaction(db, () => {
7200
- const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
7201
- if (!contact) throw new AffinityNotFoundError("contact not found");
7202
- if (contact.is_owner)
7203
- throw new AffinityStateError("The owner contact cannot be deleted");
7204
- const tables = [
7205
- "contacts",
7206
- "identities",
7207
- "attributes",
7208
- "links",
7209
- "events",
7210
- "event_participants",
7211
- "open_commitments",
7212
- "contact_merges",
7213
- "dismissed_duplicates",
7214
- "link_event_effects",
7215
- "upcoming_occurrences",
7216
- "link_rollups",
7217
- "contact_rollups"
7218
- ];
7219
- const before = new Map(
7220
- tables.map((table) => [
7221
- table,
7222
- db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
7223
- ])
7224
- );
7225
- db.prepare(
7226
- "DELETE FROM events WHERE id IN (SELECT p.event_id FROM event_participants p WHERE p.contact_id = ? AND NOT EXISTS (SELECT 1 FROM event_participants q JOIN contacts c ON c.id = q.contact_id WHERE q.event_id = p.event_id AND q.contact_id != ? AND c.is_owner = 0))"
7227
- ).run(locatorId, locatorId);
7228
- db.prepare(
7229
- "DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
7230
- ).run(locatorId, locatorId);
7231
- db.prepare(
7232
- "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
7233
- ).run(locatorId, locatorId);
7234
- db.prepare(
7235
- "UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
7236
- ).run(locatorId);
7237
- db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
7238
- const now = Date.now();
7239
- const affectedLinks = db.prepare(
7240
- "SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
7241
- ).all().map((row) => row.id);
7242
- refreshAllBridgeScores(db, now);
7243
- for (const id of affectedLinks) refreshLinkRollup(db, id, now);
7244
- for (const row of db.prepare("SELECT id FROM contacts").all())
7245
- refreshContactRollup(db, Number(row.id), now);
7246
- const removed = tables.map((table) => {
7247
- const remaining = new Set(
7248
- db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
7249
- );
7250
- const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
7251
- return { entity: table, count: ids.length, ids };
7252
- }).filter((item) => item.count > 0);
7253
- return { contactId: locatorId, removed, affectedLinks };
7254
- });
7255
- }
7256
-
7257
- // src/identities/queries.ts
7258
- function getIdentityRowById(db, id) {
7259
- const row = db.prepare(
7260
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
7261
- created_at, updated_at, removed_at
7262
- FROM identities
7263
- WHERE id = ? AND removed_at IS NULL`
7264
- ).get(id);
7265
- if (row === void 0) {
7266
- return null;
7267
- }
7268
- return row;
7269
- }
7270
-
7271
- // src/identities/receipts.ts
7272
- function buildIdentityMutationReceipt(primary, partial) {
7273
- const normalized = normalizeMutationReceiptPartial(partial);
7274
- return {
7275
- primary,
7276
- created: normalized.created,
7277
- updated: normalized.updated,
7278
- archived: normalized.archived,
7279
- removed: normalized.removed,
7280
- affectedLinks: normalized.affectedLinks,
7281
- derivedEffects: normalized.derivedEffects
7282
- };
7283
- }
7284
-
7285
- // src/identities/validators.ts
7286
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
7287
- const row = excludeIdentityId === void 0 ? db.prepare(
7288
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
7289
- ).get(normalizedKey) : db.prepare(
7290
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
7291
- ).get(normalizedKey, excludeIdentityId);
7292
- if (row !== void 0) {
7293
- const owner = db.prepare(
7294
- "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"
7295
- ).get(normalizedKey);
7296
- throw new AffinityConflictError(
7297
- `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
7298
- );
7299
- }
7300
- }
7301
-
7302
- // src/identities/validate_identity_input.ts
7303
- var identityTypes = [
7304
- "email",
7305
- "phone",
7306
- "url",
7307
- "website",
7308
- "handle",
7309
- "x",
7310
- "twitter",
7311
- "linkedin",
7312
- "github",
7313
- "mastodon",
7314
- "bluesky",
7315
- "telegram",
7316
- "whatsapp",
7317
- "signal",
7318
- "matrix",
7319
- "discord",
7320
- "slack",
7321
- "instagram",
7322
- "facebook",
7323
- "youtube",
7324
- "tiktok",
7325
- "address",
7326
- "other",
7327
- "account_id",
7328
- "alias"
7329
- ];
7330
- function validateIdentityInput(type, value) {
7331
- const kind = type.toLowerCase();
7332
- if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
7333
- throw new AffinityValidationError(
7334
- `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
7335
- );
7336
- if (!value.trim())
7337
- throw new AffinityValidationError("identity value must be non-empty");
7338
- if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
7339
- throw new AffinityValidationError(
7340
- "email value must match local@domain.tld"
7341
- );
7342
- if (kind === "phone" && value.replace(/\D/g, "").length < 5)
7343
- throw new AffinityValidationError(
7344
- "phone value must contain at least 5 digits"
7345
- );
7346
- if (kind === "url" || kind === "website") {
7347
- try {
7348
- const url = new URL(value.includes("://") ? value : `https://${value}`);
7349
- if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
7350
- throw new Error();
7351
- } catch {
7352
- throw new AffinityValidationError(
7353
- "url/website value must be an http/https URL or bare domain"
7354
- );
7355
- }
7356
- }
7357
- }
8205
+ });
7358
8206
 
7359
- // src/identities/add_identity.ts
7360
- function addIdentity(db, contactId, input) {
8207
+ // src/contacts/delete_contact.ts
8208
+ function deleteContact(db, locatorId, options) {
8209
+ if (options?.confirm !== true)
8210
+ throw new AffinityValidationError(
8211
+ "delete is permanent; pass confirm: true"
8212
+ );
7361
8213
  return withTransaction(db, () => {
7362
- const contact = getContactRowById(db, contactId);
7363
- if (!contact) {
7364
- throw new AffinityNotFoundError("contact not found");
7365
- }
7366
- if (contact.lifecycle_state === "merged") {
7367
- throw new AffinityStateError("merged contact is read-only");
7368
- }
7369
- const type = input.type.trim();
7370
- const value = input.value.trim();
7371
- if (type.length === 0 || value.length === 0) {
7372
- throw new AffinityValidationError("type and value must be non-empty");
7373
- }
7374
- validateIdentityInput(type, value);
7375
- const nk = normalizeIdentityKey(type, value);
7376
- assertNoIdentityCollision(db, nk);
7377
- const now = resolveNow(input.now);
7378
- const verified = input.verified ? 1 : 0;
7379
- const verifiedAt = verified === 1 ? now : null;
7380
- const result = db.prepare(
7381
- `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
7382
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7383
- ).run(
7384
- contactId,
7385
- type,
7386
- value,
7387
- input.label ?? null,
7388
- nk,
7389
- verified,
7390
- verifiedAt,
7391
- now,
7392
- now
8214
+ const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
8215
+ if (!contact) throw new AffinityNotFoundError("contact not found");
8216
+ if (contact.is_owner)
8217
+ throw new AffinityStateError("The owner contact cannot be deleted");
8218
+ const tables = [
8219
+ "contacts",
8220
+ "identities",
8221
+ "attributes",
8222
+ "links",
8223
+ "events",
8224
+ "event_participants",
8225
+ "open_commitments",
8226
+ "contact_merges",
8227
+ "dismissed_duplicates",
8228
+ "link_event_effects",
8229
+ "upcoming_occurrences",
8230
+ "link_rollups",
8231
+ "contact_rollups"
8232
+ ];
8233
+ const before = new Map(
8234
+ tables.map((table) => [
8235
+ table,
8236
+ db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
8237
+ ])
7393
8238
  );
7394
- const id = Number(result.lastInsertRowid);
7395
- const row = getIdentityRowById(db, id);
7396
- if (!row) {
7397
- throw new AffinityInvariantError("inserted identity not found");
7398
- }
7399
- refreshContactRollup(db, contactId, now);
8239
+ db.prepare(
8240
+ "DELETE FROM events WHERE id IN (SELECT p.event_id FROM event_participants p WHERE p.contact_id = ? AND NOT EXISTS (SELECT 1 FROM event_participants q JOIN contacts c ON c.id = q.contact_id WHERE q.event_id = p.event_id AND q.contact_id != ? AND c.is_owner = 0))"
8241
+ ).run(locatorId, locatorId);
8242
+ db.prepare(
8243
+ "DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
8244
+ ).run(locatorId, locatorId);
7400
8245
  db.prepare(
7401
8246
  "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
7402
- ).run(contactId, contactId);
7403
- return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
7404
- created: [{ kind: "identity", id }]
7405
- });
8247
+ ).run(locatorId, locatorId);
8248
+ db.prepare(
8249
+ "UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
8250
+ ).run(locatorId);
8251
+ db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
8252
+ const now = Date.now();
8253
+ const affectedLinks = db.prepare(
8254
+ "SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
8255
+ ).all().map((row) => row.id);
8256
+ refreshAllBridgeScores(db, now);
8257
+ for (const id of affectedLinks) refreshLinkRollup(db, id, now);
8258
+ for (const row of db.prepare("SELECT id FROM contacts").all())
8259
+ refreshContactRollup(db, Number(row.id), now);
8260
+ const removed = tables.map((table) => {
8261
+ const remaining = new Set(
8262
+ db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
8263
+ );
8264
+ const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
8265
+ return { entity: table, count: ids.length, ids };
8266
+ }).filter((item) => item.count > 0);
8267
+ return { contactId: locatorId, removed, affectedLinks };
7406
8268
  });
7407
8269
  }
7408
8270
 
@@ -7410,27 +8272,20 @@ function addIdentity(db, contactId, input) {
7410
8272
  function createContactToolResult(db, input) {
7411
8273
  return withTransaction(db, () => {
7412
8274
  const receipt = createContact(db, input);
7413
- const identityOwners = /* @__PURE__ */ new Set();
7414
- for (const identity of input.identities ?? []) {
7415
- validateIdentityInput(identity.type.trim(), identity.value.trim());
7416
- const existing = db.prepare(
7417
- "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
7418
- ).get(normalizeIdentityKey(identity.type, identity.value));
7419
- if (existing) {
7420
- if (Number(existing.contact_id) !== receipt.primary.id)
7421
- identityOwners.add(Number(existing.contact_id));
7422
- continue;
7423
- }
7424
- const added = addIdentity(db, receipt.primary.id, identity);
7425
- receipt.created.push(...added.created);
7426
- }
7427
8275
  const result = mutationToolResult("create", receipt, "contact");
8276
+ if (result.ok && receipt.outcome === "no_op") {
8277
+ result.outcome = "no_op";
8278
+ Object.assign(result.data, {
8279
+ matchedIdentities: receipt.matchedIdentities
8280
+ });
8281
+ return result;
8282
+ }
7428
8283
  const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
7429
8284
  const warnings = db.prepare(
7430
8285
  "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
7431
8286
  ).all(receipt.primary.id).flatMap((row) => {
7432
- const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
7433
- return score === void 0 ? [] : [
8287
+ const score = fold(row.name) === fold(input.name) ? 0.92 : fuzzyNameScore(row.name, input.name);
8288
+ return score <= 0 ? [] : [
7434
8289
  {
7435
8290
  code: "possible_duplicate",
7436
8291
  message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
@@ -7513,6 +8368,13 @@ function manageContactToolHandler(db, rawInput) {
7513
8368
  return contact.result;
7514
8369
  }
7515
8370
  if (input.action === "delete") {
8371
+ if (contact.value.is_owner)
8372
+ return toolFailure(
8373
+ "domain",
8374
+ "conflict",
8375
+ "Cannot delete owner.",
8376
+ "The owner contact cannot be deleted"
8377
+ );
7516
8378
  const deletion = deleteContact(db, contact.value.id, { confirm: true });
7517
8379
  return toolSuccess("Permanently deleted contact.", {
7518
8380
  action: "delete",
@@ -7586,7 +8448,11 @@ var manageContactTool = defineAffinityTool({
7586
8448
  },
7587
8449
  ["type", "value"]
7588
8450
  ),
7589
- "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
8451
+ "Optional identities to attach. An identity owned by another live contact causes a conflict, or returns that contact when ifExists=return."
8452
+ ),
8453
+ ifExists: enumSchema(
8454
+ "Identity collision policy; return attaches nothing.",
8455
+ ["error", "return"]
7590
8456
  ),
7591
8457
  name: stringSchema("Contact name."),
7592
8458
  kind: enumSchema("Contact kind.", [
@@ -8005,7 +8871,10 @@ function reviseIdentity(db, identityId, patch) {
8005
8871
  });
8006
8872
  }
8007
8873
  const nextType = patch.type !== void 0 ? patch.type.trim() : row.type;
8008
- const nextValue = patch.value !== void 0 ? patch.value.trim() : row.value;
8874
+ const nextValue = normalizeIdentityValue(
8875
+ nextType,
8876
+ patch.value ?? row.value
8877
+ );
8009
8878
  if (nextType.length === 0 || nextValue.length === 0) {
8010
8879
  throw new AffinityValidationError("type and value must be non-empty");
8011
8880
  }
@@ -8064,6 +8933,10 @@ function contactLocatorSchema6(description) {
8064
8933
  contactId: integerSchema("Exact contact id."),
8065
8934
  identity: objectSchema(
8066
8935
  {
8936
+ ifExists: enumSchema(
8937
+ "Return an existing identity on this contact without changes.",
8938
+ ["error", "return"]
8939
+ ),
8067
8940
  type: stringSchema(
8068
8941
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
8069
8942
  ),
@@ -8160,6 +9033,10 @@ var manageIdentityTool = defineAffinityTool({
8160
9033
  ),
8161
9034
  input: objectSchema(
8162
9035
  {
9036
+ ifExists: enumSchema(
9037
+ "Return an existing identity on this contact without changes.",
9038
+ ["error", "return"]
9039
+ ),
8163
9040
  type: stringSchema(
8164
9041
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
8165
9042
  ),
@@ -8494,11 +9371,13 @@ var mergeContactsTool = defineAffinityTool({
8494
9371
  });
8495
9372
 
8496
9373
  // src/tools/record_event_tool.ts
8497
- function normalizeParticipant2(p) {
9374
+ function normalizeParticipant2(db, p) {
8498
9375
  let contactId = p.contactId;
8499
9376
  if (contactId === void 0 && p.contact != null) {
8500
9377
  const loc = p.contact;
8501
- contactId = loc.contactId;
9378
+ const resolved = resolveContactLocator(db, loc);
9379
+ if (!resolved.ok) throw resolved.result;
9380
+ contactId = resolved.value.id;
8502
9381
  }
8503
9382
  const result = {
8504
9383
  contactId,
@@ -8516,12 +9395,18 @@ function ensureOwnerParticipates2(db, participants) {
8516
9395
  return [{ contactId: ownerId, role: "actor" }, ...participants];
8517
9396
  }
8518
9397
  function prepareParticipants2(db, raw, injectOwner) {
8519
- const normalized = raw.map(normalizeParticipant2);
9398
+ const normalized = raw.map((p) => normalizeParticipant2(db, p));
8520
9399
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
8521
9400
  }
8522
9401
  function recordEventToolHandler(db, rawInput) {
8523
9402
  const input = structuredClone(rawInput);
8524
- const inputError = validateToolInput(recordEventTool.inputSchema, input);
9403
+ const inputError = validateToolInput(
9404
+ recordEventTool.inputSchema,
9405
+ input,
9406
+ "input",
9407
+ Date.now(),
9408
+ true
9409
+ );
8525
9410
  if (inputError)
8526
9411
  return toolFailure(
8527
9412
  "protocol",
@@ -8601,6 +9486,7 @@ var recordEventTool = defineAffinityTool({
8601
9486
  "correction"
8602
9487
  ]
8603
9488
  ),
9489
+ ...eventMetadataSchema,
8604
9490
  occurredAt: integerSchema(
8605
9491
  "When the event occurred, Unix timestamp in milliseconds."
8606
9492
  ),
@@ -8609,6 +9495,19 @@ var recordEventTool = defineAffinityTool({
8609
9495
  participants: arraySchema(
8610
9496
  objectSchema(
8611
9497
  {
9498
+ contact: objectSchema(
9499
+ {
9500
+ contactId: integerSchema("Contact id."),
9501
+ identity: objectSchema(
9502
+ {
9503
+ type: stringSchema("Identity type."),
9504
+ value: stringSchema("Identity value.")
9505
+ },
9506
+ ["type", "value"]
9507
+ )
9508
+ },
9509
+ []
9510
+ ),
8612
9511
  contactId: integerSchema(
8613
9512
  "Participant contact id. The owner contact MUST be included."
8614
9513
  ),
@@ -8632,7 +9531,7 @@ var recordEventTool = defineAffinityTool({
8632
9531
  ),
8633
9532
  now: integerSchema("Optional timestamp override.")
8634
9533
  },
8635
- ["occurredAt", "summary", "significance", "participants"],
9534
+ ["occurredAt", "significance", "participants"],
8636
9535
  "Event payload. Always required. Interaction events also require `type`."
8637
9536
  )
8638
9537
  },
@@ -8671,13 +9570,40 @@ function enrichReviewItem(db, item) {
8671
9570
  result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
8672
9571
  }
8673
9572
  if (Array.isArray(item.participants)) {
8674
- result.counterpartyNames = item.participants.map(
8675
- (p) => name(p.contactId)
9573
+ result.participants = item.participants.map(
9574
+ (p) => ({
9575
+ id: p.contactId,
9576
+ name: name(p.contactId),
9577
+ role: p.role
9578
+ })
8676
9579
  );
8677
- result.subtitle = [
8678
- item.subtitle,
8679
- result.counterpartyNames.join(", ")
8680
- ].filter(Boolean).join(" \xB7 ");
9580
+ for (const key of ["title", "subtitle", "snippet", "state", "eventId"])
9581
+ result[key] = void 0;
9582
+ }
9583
+ if (result.momentKind === null) result.momentKind = void 0;
9584
+ if (result.eventId === result.id) result.eventId = void 0;
9585
+ if (result.linkId === result.id) result.linkId = void 0;
9586
+ if (result.title === result.summary) result.title = void 0;
9587
+ if (item.kind === "link" && item.occursAt !== void 0) {
9588
+ for (const key of [
9589
+ "affinity",
9590
+ "trust",
9591
+ "rank",
9592
+ "recencyScore",
9593
+ "driftPriority",
9594
+ "readinessScore",
9595
+ "normalizedRank",
9596
+ "reciprocityScore",
9597
+ "radarScore",
9598
+ "subtitle",
9599
+ "snippet",
9600
+ "targetRef",
9601
+ "occursOnDate"
9602
+ ])
9603
+ result[key] = void 0;
9604
+ } else if (item.kind === "link") {
9605
+ result.recommendedReason = void 0;
9606
+ result.snippet = void 0;
8681
9607
  }
8682
9608
  if (typeof item.leftContactId === "number")
8683
9609
  result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
@@ -8706,6 +9632,8 @@ function summarizeCount(count, singular) {
8706
9632
  // src/tools/review_affinity_tool.ts
8707
9633
  function buildReadOptions(input) {
8708
9634
  return {
9635
+ ...input.includeContent === void 0 ? {} : { includeContent: input.includeContent },
9636
+ ...input.channel === void 0 ? {} : { channel: input.channel },
8709
9637
  ...input.now === void 0 ? {} : { now: input.now },
8710
9638
  ...input.limit === void 0 ? {} : { limit: input.limit },
8711
9639
  ...input.offset === void 0 ? {} : { offset: input.offset },
@@ -8842,7 +9770,7 @@ function momentItem(moment) {
8842
9770
  id: moment.eventId,
8843
9771
  title: moment.summary,
8844
9772
  subtitle: moment.momentKind,
8845
- snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
9773
+ snippet: moment.occurredOn ?? new Date(moment.occurredAt).toISOString().slice(0, 10)
8846
9774
  };
8847
9775
  }
8848
9776
  function commitmentItem(commitment) {
@@ -8852,7 +9780,7 @@ function commitmentItem(commitment) {
8852
9780
  id: commitment.eventId,
8853
9781
  title: commitment.summary,
8854
9782
  subtitle: commitment.type,
8855
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
9783
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : commitment.dueOn ?? new Date(commitment.dueAt).toISOString().slice(0, 10)}`
8856
9784
  };
8857
9785
  }
8858
9786
  function radarItem(radar) {
@@ -8868,6 +9796,7 @@ function radarItem(radar) {
8868
9796
  function upcomingDateItem(date) {
8869
9797
  return {
8870
9798
  ...date,
9799
+ occursOn: new Date(date.occursOn).toISOString().slice(0, 10),
8871
9800
  occursAt: date.occursOn,
8872
9801
  occursAtIso: new Date(date.occursOn).toISOString(),
8873
9802
  kind: date.targetRef.kind,
@@ -8905,6 +9834,9 @@ function compactFilters(input) {
8905
9834
  );
8906
9835
  }
8907
9836
  function reviewAffinityToolHandler(db, rawInput) {
9837
+ return reviewPage(db, rawInput, false);
9838
+ }
9839
+ function reviewPage(db, rawInput, pageOnly) {
8908
9840
  const input = structuredClone(rawInput);
8909
9841
  const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
8910
9842
  if (inputError)
@@ -8919,6 +9851,41 @@ function reviewAffinityToolHandler(db, rawInput) {
8919
9851
  if (invalid) {
8920
9852
  return invalid;
8921
9853
  }
9854
+ if (!pageOnly && input.view !== "graph.chart") {
9855
+ const limit = input.limit ?? 50;
9856
+ const offset = input.offset ?? 0;
9857
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1e4 || !Number.isInteger(offset) || offset < 0)
9858
+ return toolFailure(
9859
+ "protocol",
9860
+ "invalid_input",
9861
+ "Invalid pagination.",
9862
+ "limit must be 1..10000; use offset to page through larger results; offset must be non-negative"
9863
+ );
9864
+ const all = [];
9865
+ let page;
9866
+ do {
9867
+ page = reviewPage(
9868
+ db,
9869
+ { ...input, limit: 250, offset: all.length },
9870
+ true
9871
+ );
9872
+ if (!page.ok) return page;
9873
+ all.push(...page.data.items);
9874
+ } while (page.data.items.length === 250);
9875
+ const items2 = all.slice(offset, offset + limit);
9876
+ return toolSuccess(
9877
+ summarizeCount(items2.length, "item"),
9878
+ {
9879
+ view: input.view,
9880
+ items: items2,
9881
+ count: items2.length,
9882
+ total: all.length,
9883
+ hasMore: offset + items2.length < all.length,
9884
+ appliedFilters: compactFilters(input)
9885
+ },
9886
+ { entities: items2.map(({ content: _content, ...item }) => item) }
9887
+ );
9888
+ }
8922
9889
  const options = buildReadOptions(input);
8923
9890
  let items = [];
8924
9891
  let chart;
@@ -8944,12 +9911,26 @@ function reviewAffinityToolHandler(db, rawInput) {
8944
9911
  {
8945
9912
  ...input.contactIds === void 0 ? {} : { contactIds: input.contactIds },
8946
9913
  ...input.exactOnly === void 0 ? {} : { exactOnly: input.exactOnly },
8947
- ...input.minScore === void 0 ? {} : { minScore: input.minScore },
9914
+ ...input.minScore === void 0 ? { minScore: 0.5 } : { minScore: input.minScore },
8948
9915
  ...input.includeDismissed === void 0 ? {} : { includeDismissed: input.includeDismissed }
8949
9916
  },
8950
9917
  options
8951
9918
  ).map(duplicateCandidateItem);
8952
9919
  break;
9920
+ case "events.thread": {
9921
+ if (!input.ref)
9922
+ return toolFailure(
9923
+ "protocol",
9924
+ "invalid_input",
9925
+ "Missing thread reference.",
9926
+ "ref is required for events.thread."
9927
+ );
9928
+ items = getEventThread(db, input.ref.source, input.ref.id, {
9929
+ ...options,
9930
+ ...input.eventTypes === void 0 ? {} : { eventTypes: input.eventTypes }
9931
+ }).map(toEventListToolItem);
9932
+ break;
9933
+ }
8953
9934
  case "events.contact_journal": {
8954
9935
  const locator = input.contact;
8955
9936
  if (locator === void 0) {
@@ -9091,6 +10072,7 @@ function reviewAffinityToolHandler(db, rawInput) {
9091
10072
  items = listRadar(
9092
10073
  db,
9093
10074
  {
10075
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
9094
10076
  ...input.kind === void 0 ? {} : { kind: input.kind },
9095
10077
  ...input.linkState === void 0 ? {} : { state: input.linkState }
9096
10078
  },
@@ -9140,6 +10122,14 @@ function reviewAffinityToolHandler(db, rawInput) {
9140
10122
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
9141
10123
  break;
9142
10124
  }
10125
+ if (input.includeContent && ["events.moments", "commitments.open"].includes(input.view))
10126
+ items = items.map((item) => {
10127
+ const content = getEventById(
10128
+ db,
10129
+ Number(item.eventId ?? item.id)
10130
+ )?.content;
10131
+ return content === void 0 ? item : { ...item, content };
10132
+ });
9143
10133
  items = items.map((item) => enrichReviewItem(db, item));
9144
10134
  return toolSuccess(
9145
10135
  summarizeCount(items.length, "item"),
@@ -9151,7 +10141,7 @@ function reviewAffinityToolHandler(db, rawInput) {
9151
10141
  ...chart === void 0 ? {} : { chart }
9152
10142
  },
9153
10143
  {
9154
- entities: items,
10144
+ entities: items.map(({ content: _content, ...item }) => item),
9155
10145
  warnings: items.length === 0 ? [
9156
10146
  toolWarning(
9157
10147
  "empty_result",
@@ -9197,15 +10187,21 @@ var reviewAffinityTool = defineAffinityTool({
9197
10187
  includeDismissed: "Whether dismissed pairs should be included in duplicate review (marked with dismissed flag).",
9198
10188
  since: "Optional lower timestamp bound for time-based views.",
9199
10189
  until: "Optional upper timestamp bound for time-based views.",
9200
- limit: "Optional result limit.",
10190
+ limit: "Optional result limit, 1..10000 (default 50); use offset for paging.",
9201
10191
  offset: "Optional result offset."
9202
10192
  },
9203
10193
  outputDescription: "Returns compact review items, a count, the applied filters, and the chart payload for graph review.",
9204
10194
  inputSchema: objectSchema(
9205
10195
  {
10196
+ ref: externalRefSchema,
10197
+ includeContent: booleanSchema(
10198
+ "Include verbatim content in event rows; defaults to false."
10199
+ ),
10200
+ channel: stringSchema("Filter events by channel."),
9206
10201
  view: enumSchema("Which review surface to return.", [
9207
10202
  "contacts.list",
9208
10203
  "contacts.duplicates",
10204
+ "events.thread",
9209
10205
  "events.contact_journal",
9210
10206
  "events.link_timeline",
9211
10207
  "events.moments",
@@ -9260,7 +10256,20 @@ var reviewAffinityTool = defineAffinityTool({
9260
10256
  includeArchived: booleanSchema("Whether archived links are included."),
9261
10257
  includeObserved: booleanSchema("Whether observed links are included."),
9262
10258
  eventTypes: arraySchema(
9263
- stringSchema("Event type."),
10259
+ enumSchema("Event type.", [
10260
+ "conversation",
10261
+ "activity",
10262
+ "gift",
10263
+ "support",
10264
+ "milestone",
10265
+ "observation",
10266
+ "conflict",
10267
+ "correction",
10268
+ "transaction",
10269
+ "promise",
10270
+ "agreement",
10271
+ "date_anchor"
10272
+ ]),
9264
10273
  "Optional event-type filter."
9265
10274
  ),
9266
10275
  momentKind: enumSchema("Optional moment kind filter.", [
@@ -9283,13 +10292,17 @@ var reviewAffinityTool = defineAffinityTool({
9283
10292
  ]),
9284
10293
  horizonDays: integerSchema("Optional horizon-days filter."),
9285
10294
  exactOnly: booleanSchema("Whether duplicate review is exact-only."),
9286
- minScore: numberSchema("Optional duplicate minimum score."),
10295
+ minScore: numberSchema(
10296
+ "Optional duplicate minimum score (default 0.5; use 0 for all candidates)."
10297
+ ),
9287
10298
  includeDismissed: booleanSchema(
9288
10299
  "Whether dismissed pairs are included in duplicate review (marked with dismissed flag)."
9289
10300
  ),
9290
10301
  since: integerSchema("Optional lower timestamp bound."),
9291
10302
  until: integerSchema("Optional upper timestamp bound."),
9292
- limit: integerSchema("Optional result limit."),
10303
+ limit: integerSchema(
10304
+ "Optional result limit, 1..10000 (default 50); use offset for paging."
10305
+ ),
9293
10306
  offset: integerSchema("Optional result offset.")
9294
10307
  },
9295
10308
  ["view"],
@@ -9300,6 +10313,9 @@ var reviewAffinityTool = defineAffinityTool({
9300
10313
 
9301
10314
  // src/tools/search_affinity_tool.ts
9302
10315
  function searchAffinityToolHandler(db, rawInput) {
10316
+ return searchPage(db, rawInput, false);
10317
+ }
10318
+ function searchPage(db, rawInput, pageOnly) {
9303
10319
  const input = structuredClone(rawInput);
9304
10320
  const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
9305
10321
  if (inputError)
@@ -9310,11 +10326,53 @@ function searchAffinityToolHandler(db, rawInput) {
9310
10326
  inputError
9311
10327
  );
9312
10328
  return withToolHandling(() => {
10329
+ if (!pageOnly) {
10330
+ const limit = input.limit ?? 50;
10331
+ const offset = input.offset ?? 0;
10332
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
10333
+ return toolFailure(
10334
+ "protocol",
10335
+ "invalid_input",
10336
+ "Invalid pagination.",
10337
+ "limit must be 1..250; offset must be non-negative"
10338
+ );
10339
+ const all = [];
10340
+ const scopes = input.scope === "all" ? ["contacts", "events"] : [input.scope ?? "contacts"];
10341
+ for (const scope of scopes) {
10342
+ let page;
10343
+ let nextOffset = 0;
10344
+ do {
10345
+ page = searchPage(
10346
+ db,
10347
+ { ...input, scope, limit: 250, offset: nextOffset },
10348
+ true
10349
+ );
10350
+ if (!page.ok) return page;
10351
+ all.push(...page.data.items);
10352
+ nextOffset += page.data.count;
10353
+ } while (page.data.count === 250);
10354
+ }
10355
+ const items2 = all.slice(offset, offset + limit);
10356
+ return toolSuccess(
10357
+ summarizeCount(
10358
+ items2.length,
10359
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10360
+ ),
10361
+ {
10362
+ query: input.query,
10363
+ items: items2,
10364
+ count: items2.length,
10365
+ total: all.length,
10366
+ hasMore: offset + items2.length < all.length
10367
+ },
10368
+ { entities: items2 }
10369
+ );
10370
+ }
9313
10371
  const options = {
9314
10372
  ...input.limit === void 0 ? {} : { limit: input.limit },
9315
10373
  ...input.offset === void 0 ? {} : { offset: input.offset }
9316
10374
  };
9317
- const rows = searchContacts(
10375
+ const rows = input.scope === "events" ? [] : searchContacts(
9318
10376
  db,
9319
10377
  input.query,
9320
10378
  {
@@ -9328,9 +10386,15 @@ function searchAffinityToolHandler(db, rawInput) {
9328
10386
  },
9329
10387
  options
9330
10388
  );
9331
- const items = rows.map(toContactListToolItem);
10389
+ const items = [
10390
+ ...rows.map(toContactListToolItem),
10391
+ ...input.scope === "events" || input.scope === "all" ? searchEvents(db, input.query, options) : []
10392
+ ];
9332
10393
  return toolSuccess(
9333
- summarizeCount(items.length, "contact"),
10394
+ summarizeCount(
10395
+ items.length,
10396
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10397
+ ),
9334
10398
  {
9335
10399
  query: input.query,
9336
10400
  items,
@@ -9366,6 +10430,11 @@ var searchAffinityTool = defineAffinityTool({
9366
10430
  outputDescription: "Returns compact contact hits, the original query, and the hit count.",
9367
10431
  inputSchema: objectSchema(
9368
10432
  {
10433
+ scope: enumSchema("Search scope; defaults to contacts.", [
10434
+ "contacts",
10435
+ "events",
10436
+ "all"
10437
+ ]),
9369
10438
  query: stringSchema("Free-text or natural-key query."),
9370
10439
  kind: enumSchema("Optional contact kind filter.", [
9371
10440
  "human",
@@ -9404,6 +10473,268 @@ var searchAffinityTool = defineAffinityTool({
9404
10473
  handler: searchAffinityToolHandler
9405
10474
  });
9406
10475
 
10476
+ // src/events/event_replay.ts
10477
+ function replayEventLinks(db, linkIds, now) {
10478
+ const eventIds = /* @__PURE__ */ new Set();
10479
+ for (const linkId of linkIds) {
10480
+ const saved = db.prepare("SELECT state_json FROM event_link_baselines WHERE link_id=?").get(linkId);
10481
+ if (!saved) continue;
10482
+ const baseline = JSON.parse(saved.state_json);
10483
+ const effects = db.prepare(
10484
+ "SELECT event_id, participant_json, provenance_json, created_at FROM link_event_effects WHERE link_id=? ORDER BY event_id"
10485
+ ).all(linkId);
10486
+ const overrides = db.prepare(
10487
+ "SELECT after_event_id,state FROM event_link_state_changes WHERE link_id=? ORDER BY id"
10488
+ ).all(linkId);
10489
+ let overrideIndex = 0;
10490
+ const applyOverrides = (beforeId) => {
10491
+ while (overrideIndex < overrides.length && (overrides[overrideIndex]?.after_event_id ?? Number.POSITIVE_INFINITY) < beforeId) {
10492
+ const override = overrides[overrideIndex];
10493
+ if (override)
10494
+ db.prepare("UPDATE links SET state=? WHERE id=?").run(
10495
+ override.state,
10496
+ linkId
10497
+ );
10498
+ overrideIndex++;
10499
+ }
10500
+ };
10501
+ db.prepare("DELETE FROM link_event_effects WHERE link_id=?").run(linkId);
10502
+ db.prepare(
10503
+ "UPDATE links SET rank=?,affinity=?,trust=?,state=?,cadence_days=?,updated_at=? WHERE id=?"
10504
+ ).run(
10505
+ baseline.rank,
10506
+ baseline.affinity,
10507
+ baseline.trust,
10508
+ baseline.state,
10509
+ baseline.cadence_days,
10510
+ now,
10511
+ linkId
10512
+ );
10513
+ for (const effect of effects) {
10514
+ eventIds.add(effect.event_id);
10515
+ applyOverrides(effect.event_id);
10516
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(effect.event_id))
10517
+ continue;
10518
+ const event = loadEventRecord(db, effect.event_id);
10519
+ const link = db.prepare("SELECT from_contact_id,to_contact_id FROM links WHERE id=?").get(linkId);
10520
+ const participant = effect.participant_json ? JSON.parse(effect.participant_json) : event.participants.find((p) => p.contactId === link.to_contact_id) ?? event.participants[0];
10521
+ const provenance = db.prepare("SELECT provenance_json FROM events WHERE id=?").get(event.id);
10522
+ applyEventEffectToLink(db, {
10523
+ linkId,
10524
+ eventId: event.id,
10525
+ eventType: event.type,
10526
+ occurredAt: event.occurredAt,
10527
+ significance: event.significance,
10528
+ participant,
10529
+ provenance: provenance.provenance_json ? JSON.parse(provenance.provenance_json) : effect.provenance_json ? JSON.parse(effect.provenance_json) : void 0,
10530
+ now: effect.created_at
10531
+ });
10532
+ }
10533
+ applyOverrides(Number.POSITIVE_INFINITY);
10534
+ refreshLinkRollup(db, linkId, now);
10535
+ }
10536
+ for (const eventId of eventIds)
10537
+ db.prepare(
10538
+ "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=?"
10539
+ ).run(eventId);
10540
+ refreshAllBridgeScores(db, now);
10541
+ }
10542
+
10543
+ // src/events/manage_event.ts
10544
+ function requireEventTarget(db, target) {
10545
+ const selector = typeof target === "number" ? { eventId: target } : target;
10546
+ if (!selector || selector.eventId === void 0 === (selector.ref === void 0))
10547
+ throw new AffinityValidationError("Provide exactly one of eventId or ref");
10548
+ const event = selector.ref ? getEventByExternalRef(db, selector.ref.source, selector.ref.id) : getEventById(db, selector.eventId);
10549
+ if (!event) throw new AffinityNotFoundError("event not found");
10550
+ return event;
10551
+ }
10552
+ function affected(db, id) {
10553
+ return db.prepare("SELECT link_id FROM link_event_effects WHERE event_id=?").all(id).map((r) => r.link_id);
10554
+ }
10555
+ function reviseEvent(db, target, patch) {
10556
+ return withTransaction(db, () => {
10557
+ const allowed = [
10558
+ "summary",
10559
+ "content",
10560
+ "occurredAt",
10561
+ "significance",
10562
+ "channel",
10563
+ "type",
10564
+ "externalRef"
10565
+ ];
10566
+ for (const key of Object.keys(patch))
10567
+ if (!allowed.includes(key))
10568
+ throw new AffinityValidationError(
10569
+ `unknown patch key ${key}; allowed keys: ${allowed.join(", ")}`
10570
+ );
10571
+ const event = requireEventTarget(db, target);
10572
+ const next = { ...event, ...patch };
10573
+ const { momentKind: _momentKind, ...metadata } = next;
10574
+ validateEventMetadata(metadata);
10575
+ Object.assign(next, metadata);
10576
+ if (patch.summary !== void 0 && (typeof patch.summary !== "string" || !patch.summary.trim()))
10577
+ throw new AffinityValidationError("summary must be non-empty");
10578
+ if (patch.occurredAt !== void 0)
10579
+ assertFiniteTimestamp(patch.occurredAt, "occurredAt");
10580
+ if (!Number.isInteger(next.significance) || next.significance < 1 || next.significance > 10)
10581
+ throw new AffinityValidationError(
10582
+ "significance must be an integer from 1 to 10"
10583
+ );
10584
+ const interactions = [
10585
+ "conversation",
10586
+ "activity",
10587
+ "gift",
10588
+ "support",
10589
+ "conflict",
10590
+ "correction"
10591
+ ];
10592
+ if (next.type !== event.type && !(interactions.includes(event.type) && interactions.includes(next.type)) && !(["promise", "agreement"].includes(event.type) && ["promise", "agreement"].includes(next.type)))
10593
+ throw new AffinityValidationError(
10594
+ "type must stay within the same event kind"
10595
+ );
10596
+ if (patch.externalRef && event.externalRef)
10597
+ throw new AffinityConflictError(
10598
+ "externalRef can only be set when absent"
10599
+ );
10600
+ if (patch.externalRef && next.externalRef && getEventByExternalRef(db, next.externalRef.source, next.externalRef.id))
10601
+ throw new AffinityConflictError(
10602
+ "externalRef already belongs to an event"
10603
+ );
10604
+ const now = Date.now();
10605
+ const links = affected(db, event.id);
10606
+ db.prepare(
10607
+ "UPDATE events SET summary=?,content=?,occurred_at=?,occurred_on=?,significance=?,channel=?,type=?,external_source=?,external_id=?,updated_at=? WHERE id=?"
10608
+ ).run(
10609
+ next.summary.trim(),
10610
+ next.content ?? null,
10611
+ next.occurredAt,
10612
+ patch.occurredAt === void 0 ? event.occurredOn ?? null : new Date(next.occurredAt).toISOString().slice(0, 10),
10613
+ next.significance,
10614
+ next.channel ?? null,
10615
+ next.type,
10616
+ next.externalRef?.source ?? null,
10617
+ next.externalRef?.id ?? null,
10618
+ now,
10619
+ event.id
10620
+ );
10621
+ if (patch.type && ["promise", "agreement"].includes(patch.type))
10622
+ db.prepare(
10623
+ "UPDATE open_commitments SET commitment_type=? WHERE event_id=?"
10624
+ ).run(patch.type, event.id);
10625
+ if (patch.significance !== void 0 || patch.type !== void 0 || patch.occurredAt !== void 0)
10626
+ replayEventLinks(db, links, now);
10627
+ return buildEventMutationReceipt(requireEventTarget(db, event.id), {
10628
+ updated: [{ kind: "event", id: event.id }],
10629
+ affectedLinks: links
10630
+ });
10631
+ });
10632
+ }
10633
+ function deleteEvent(db, target, options) {
10634
+ return withTransaction(db, () => {
10635
+ if (options?.confirm !== true)
10636
+ throw new AffinityValidationError("deleteEvent requires confirm: true");
10637
+ const event = requireEventTarget(db, target);
10638
+ const links = affected(db, event.id);
10639
+ for (const table of [
10640
+ "event_participants",
10641
+ "attributes",
10642
+ "open_commitments",
10643
+ "upcoming_occurrences",
10644
+ "link_event_effects"
10645
+ ])
10646
+ db.prepare(`DELETE FROM ${table} WHERE event_id=?`).run(event.id);
10647
+ db.prepare(
10648
+ "UPDATE events SET parent_event_id=NULL WHERE parent_event_id=?"
10649
+ ).run(event.id);
10650
+ db.prepare("DELETE FROM events WHERE id=?").run(event.id);
10651
+ replayEventLinks(db, links, Date.now());
10652
+ return buildEventMutationReceipt(event, {
10653
+ removed: [{ kind: "event", id: event.id }],
10654
+ affectedLinks: links
10655
+ });
10656
+ });
10657
+ }
10658
+
10659
+ // src/tools/manage_event_tool.ts
10660
+ function manageEventToolHandler(db, raw) {
10661
+ const input = structuredClone(raw);
10662
+ const error = validateToolInput(
10663
+ manageEventTool.inputSchema,
10664
+ input,
10665
+ "input",
10666
+ Date.now(),
10667
+ true
10668
+ );
10669
+ if (error)
10670
+ return toolFailure("protocol", "invalid_input", "Invalid input.", error);
10671
+ return withToolHandling(() => {
10672
+ const target = {
10673
+ ...input.eventId === void 0 ? {} : { eventId: input.eventId },
10674
+ ...input.ref === void 0 ? {} : { ref: input.ref }
10675
+ };
10676
+ return mutationToolResult(
10677
+ input.action,
10678
+ input.action === "revise" ? reviseEvent(db, target, input.patch ?? {}) : deleteEvent(db, target, { confirm: input.confirm === true }),
10679
+ "event"
10680
+ );
10681
+ }, "Event management failed.");
10682
+ }
10683
+ var manageEventTool = defineAffinityTool({
10684
+ name: manageEventToolName,
10685
+ description: "Revise or delete one event by eventId or external reference. Delete requires confirm=true and recomputes affected relationships.",
10686
+ whenToUse: "Correct imported event text or metadata, or remove an event.",
10687
+ whenNotToUse: "Use record_event to capture new evidence.",
10688
+ sideEffects: "writes_state",
10689
+ readOnly: false,
10690
+ supportsClarification: false,
10691
+ targetKinds: ["event"],
10692
+ inputDescriptions: {
10693
+ action: "revise or delete",
10694
+ eventId: "Exact event id",
10695
+ ref: "External event reference",
10696
+ patch: "Fields to revise",
10697
+ confirm: "Required true for deletion"
10698
+ },
10699
+ outputDescription: "Event mutation receipt and affected links.",
10700
+ inputSchema: objectSchema(
10701
+ {
10702
+ action: enumSchema("Action.", ["revise", "delete"]),
10703
+ eventId: integerSchema("Event id; provide eventId or ref."),
10704
+ ref: externalRefSchema,
10705
+ confirm: booleanSchema("Must be true to delete."),
10706
+ patch: objectSchema(
10707
+ {
10708
+ summary: stringSchema("Short non-empty label."),
10709
+ content: stringSchema("Verbatim content, at most 1 MB UTF-8."),
10710
+ occurredAt: integerSchema("Event timestamp."),
10711
+ significance: integerSchema("Significance 1..10."),
10712
+ channel: stringSchema("Channel, at most 40 characters."),
10713
+ type: enumSchema("Type within the same event kind.", [
10714
+ "conversation",
10715
+ "activity",
10716
+ "gift",
10717
+ "support",
10718
+ "conflict",
10719
+ "correction",
10720
+ "observation",
10721
+ "milestone",
10722
+ "transaction",
10723
+ "promise",
10724
+ "agreement",
10725
+ "date_anchor"
10726
+ ]),
10727
+ externalRef: externalRefSchema
10728
+ },
10729
+ [],
10730
+ "Fields to revise; externalRef may only be added when absent."
10731
+ )
10732
+ },
10733
+ ["action"]
10734
+ ),
10735
+ handler: manageEventToolHandler
10736
+ });
10737
+
9407
10738
  // src/tools/tool_registry.ts
9408
10739
  var affinityTools = [
9409
10740
  searchAffinityTool,
@@ -9417,7 +10748,8 @@ var affinityTools = [
9417
10748
  recordEventTool,
9418
10749
  manageCommitmentTool,
9419
10750
  manageDateAnchorTool,
9420
- manageAttributeTool
10751
+ manageAttributeTool,
10752
+ manageEventTool
9421
10753
  ];
9422
10754
  function listAffinityToolDefinitions() {
9423
10755
  return [...affinityTools];
@@ -9428,6 +10760,24 @@ function getAffinityToolByName(name) {
9428
10760
 
9429
10761
  // src/tools/tool_mapping.ts
9430
10762
  var affinityToolMappings = [
10763
+ {
10764
+ source: "getEventById",
10765
+ tool: inspectAffinityItemToolName,
10766
+ action: "event"
10767
+ },
10768
+ {
10769
+ source: "getEventByExternalRef",
10770
+ tool: inspectAffinityItemToolName,
10771
+ action: "event"
10772
+ },
10773
+ {
10774
+ source: "getEventThread",
10775
+ tool: reviewAffinityToolName,
10776
+ view: "events.thread"
10777
+ },
10778
+ { source: "searchEvents", tool: searchAffinityToolName },
10779
+ { source: "reviseEvent", tool: "manage_event", action: "revise" },
10780
+ { source: "deleteEvent", tool: "manage_event", action: "delete" },
9431
10781
  { source: "exportContact", tool: manageContactToolName, action: "export" },
9432
10782
  { source: "exportAffinity", tool: manageContactToolName, action: "export" },
9433
10783
  { source: "deleteContact", tool: manageContactToolName, action: "delete" },
@@ -9618,6 +10968,7 @@ __export(write_exports, {
9618
10968
  addIdentity: () => addIdentity,
9619
10969
  createContact: () => createContact,
9620
10970
  deleteContact: () => deleteContact,
10971
+ deleteEvent: () => deleteEvent,
9621
10972
  dismissDuplicate: () => dismissDuplicate,
9622
10973
  mergeContacts: () => mergeContacts,
9623
10974
  overrideLinkState: () => overrideLinkState,
@@ -9634,6 +10985,7 @@ __export(write_exports, {
9634
10985
  reviseBond: () => reviseBond,
9635
10986
  reviseContact: () => reviseContact,
9636
10987
  reviseDateAnchor: () => reviseDateAnchor,
10988
+ reviseEvent: () => reviseEvent,
9637
10989
  reviseIdentity: () => reviseIdentity,
9638
10990
  seedSocialLink: () => seedSocialLink,
9639
10991
  setAttribute: () => setAttribute,