@ghostpaw/affinity 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,25 @@ 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.content != null) base.content = decodeEventText(row.content);
1403
+ if (row.channel != null) base.channel = row.channel;
1404
+ if (row.external_source != null && row.external_id != null)
1405
+ base.externalRef = { source: row.external_source, id: row.external_id };
1406
+ if (row.parent_source != null && row.parent_ref_id != null)
1407
+ base.parentRef = { source: row.parent_source, id: row.parent_ref_id };
1408
+ if (row.thread_source != null && row.thread_id != null)
1409
+ base.threadRef = { source: row.thread_source, id: row.thread_id };
1410
+ if (row.parent_event_id != null) base.parentEventId = row.parent_event_id;
1411
+ if (row.type === "promise" || row.type === "agreement") {
1412
+ base.dueAt = row.due_at ?? null;
1413
+ base.dueOn = row.due_on ?? (row.due_at == null ? null : new Date(row.due_at).toISOString().slice(0, 10));
1414
+ }
964
1415
  if (row.recurrence_kind !== null) {
965
1416
  base.recurrenceKind = row.recurrence_kind;
966
1417
  base.anchorMonth = row.anchor_month;
@@ -990,25 +1441,29 @@ function mapUpcomingRowToUpcomingDateRecord(row) {
990
1441
  targetRef,
991
1442
  recurrenceKind,
992
1443
  occursOn: row.occurs_on,
993
- summary: row.summary,
1444
+ occursOnDate: new Date(row.occurs_on).toISOString().slice(0, 10),
1445
+ summary: decodeEventText(row.summary),
994
1446
  significance: row.significance
995
1447
  };
996
1448
  }
1449
+ function decodeEventText(value) {
1450
+ return typeof value === "string" ? value : Buffer.from(value).toString("utf8");
1451
+ }
997
1452
 
998
1453
  // src/events/queries.ts
999
1454
  function getEventRowById(db, id) {
1000
1455
  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
1456
+ `SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary
1005
1457
  FROM events
1006
1458
  WHERE id = ? AND deleted_at IS NULL`
1007
1459
  ).get(id);
1008
1460
  if (row === void 0) {
1009
1461
  return null;
1010
1462
  }
1011
- return row;
1463
+ const event = row;
1464
+ event.summary = decodeEventText(event.summary);
1465
+ if (event.content != null) event.content = decodeEventText(event.content);
1466
+ return event;
1012
1467
  }
1013
1468
  function findDuplicateDateAnchor(db, params) {
1014
1469
  const exclude = params.excludeEventId;
@@ -1076,19 +1531,27 @@ function loadEventRecord(db, eventId) {
1076
1531
  throw new AffinityInvariantError("event not found");
1077
1532
  }
1078
1533
  const participants = loadEventParticipantViews(db, eventId);
1079
- return mapEventRowToEventRecord(row, participants);
1534
+ const record = mapEventRowToEventRecord(row, participants);
1535
+ const due = db.prepare("SELECT due_at, due_on FROM open_commitments WHERE event_id = ?").get(eventId);
1536
+ if (due) {
1537
+ record.dueAt = due.due_at;
1538
+ record.dueOn = due.due_on ?? (due.due_at === null ? null : new Date(due.due_at).toISOString().slice(0, 10));
1539
+ }
1540
+ enrichEventMetadata(db, record);
1541
+ return record;
1080
1542
  }
1081
- function batchLoadEventRecords(db, eventIds) {
1543
+ function batchLoadEventRecords(db, eventIds, includeContent = false) {
1082
1544
  if (eventIds.length === 0) {
1083
1545
  return [];
1084
1546
  }
1085
1547
  const ph = eventIds.map(() => "?").join(", ");
1086
1548
  const eventRows = db.prepare(
1087
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
1549
+ `SELECT e.id, e.type, e.occurred_at, e.occurred_on, oc.due_at, oc.due_on, significance, moment_kind,
1550
+ 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
1551
  recurrence_kind, anchor_month, anchor_day,
1089
1552
  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`
1553
+ 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
1554
+ 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
1555
  ).all(...eventIds);
1093
1556
  const partRows = db.prepare(
1094
1557
  `SELECT event_id, contact_id, role, directionality
@@ -1112,7 +1575,9 @@ function batchLoadEventRecords(db, eventIds) {
1112
1575
  for (const id of eventIds) {
1113
1576
  const row = rowById.get(id);
1114
1577
  if (row) {
1115
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
1578
+ const record = mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []);
1579
+ enrichEventMetadata(db, record);
1580
+ out.push(record);
1116
1581
  }
1117
1582
  }
1118
1583
  return out;
@@ -1171,6 +1636,10 @@ function getContactJournal(db, contactId, options) {
1171
1636
  clauses.push(`e.type IN (${ph})`);
1172
1637
  params.push(...options.eventTypes);
1173
1638
  }
1639
+ if (options?.channel !== void 0) {
1640
+ clauses.push("e.channel = ?");
1641
+ params.push(options.channel.trim().toLowerCase());
1642
+ }
1174
1643
  const where = clauses.join(" AND ");
1175
1644
  const rows = db.prepare(
1176
1645
  `SELECT e.id FROM events e
@@ -1181,8 +1650,75 @@ function getContactJournal(db, contactId, options) {
1181
1650
  ).all(...params, limit, offset);
1182
1651
  return batchLoadEventRecords(
1183
1652
  db,
1184
- rows.map((r) => r.id)
1653
+ rows.map((r) => r.id),
1654
+ options?.includeContent ?? false
1655
+ );
1656
+ }
1657
+
1658
+ // src/lib/parse_calendar_time.ts
1659
+ var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d or -2d";
1660
+ function isCommitmentOverdue(dueAt, dueOn, now) {
1661
+ if (dueAt === null) return false;
1662
+ if (dueOn && dueAt === Date.parse(`${dueOn}T23:59:59.999Z`)) {
1663
+ return localCalendarDate(now) > dueOn;
1664
+ }
1665
+ return dueAt < now;
1666
+ }
1667
+ 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)";
1668
+ function localCalendarDate(now) {
1669
+ const d = new Date(now);
1670
+ return `${String(d.getFullYear()).padStart(4, "0")}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1671
+ }
1672
+ function parseCalendarTime(value, field = "time", now = Date.now()) {
1673
+ if (value == null && field.endsWith("occurredAt"))
1674
+ throw new AffinityValidationError(
1675
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
1676
+ );
1677
+ const fail = () => {
1678
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1679
+ };
1680
+ if (typeof value === "number" || typeof value === "string" && /^-?\d+$/.test(value.trim())) {
1681
+ const timestamp2 = Number(value);
1682
+ if (!Number.isSafeInteger(timestamp2) || !Number.isFinite(new Date(timestamp2).getTime()))
1683
+ return fail();
1684
+ return { timestamp: timestamp2, day: new Date(timestamp2).toISOString().slice(0, 10) };
1685
+ }
1686
+ if (typeof value !== "string") return fail();
1687
+ let text = value.trim();
1688
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text)) {
1689
+ text = text.replace(" ", "T");
1690
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(text)) text += "Z";
1691
+ }
1692
+ let rounding = 0;
1693
+ text = text.replace(
1694
+ /\.(\d{4,})(?=Z|[+-]\d{2}:\d{2}$)/,
1695
+ (_, digits) => {
1696
+ const ms = Math.round(Number(`0.${digits}`) * 1e3);
1697
+ rounding = ms === 1e3 ? 1 : 0;
1698
+ return `.${String(ms === 1e3 ? 999 : ms).padStart(3, "0")}`;
1699
+ }
1185
1700
  );
1701
+ if (/^(today|yesterday|tomorrow|[+-]\d+d)$/.test(text)) {
1702
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1703
+ const date = /* @__PURE__ */ new Date(`${localCalendarDate(now)}T00:00:00Z`);
1704
+ date.setUTCDate(date.getUTCDate() + offset);
1705
+ if (!Number.isFinite(date.getTime())) return fail();
1706
+ text = date.toISOString().slice(0, 10);
1707
+ }
1708
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
1709
+ if (european) text = `${european[3]}-${european[2]}-${european[1]}`;
1710
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(text);
1711
+ if (!dateOnly && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1712
+ text
1713
+ ))
1714
+ return fail();
1715
+ const day = text.slice(0, 10);
1716
+ const midnight = Date.parse(`${day}T00:00:00Z`);
1717
+ if (!Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== day)
1718
+ return fail();
1719
+ const timestamp = dateOnly ? midnight + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0) : Date.parse(text) + rounding;
1720
+ if (!Number.isFinite(timestamp)) return fail();
1721
+ return { timestamp, day };
1186
1722
  }
1187
1723
 
1188
1724
  // src/events/list_open_commitments.ts
@@ -1240,9 +1776,13 @@ function listOpenCommitments(db, filters, options) {
1240
1776
  clauses.push("oc.due_at <= ?");
1241
1777
  params.push(anchor + filters.horizonDays * DAY_MS);
1242
1778
  }
1779
+ if (options?.channel !== void 0) {
1780
+ clauses.push("e.channel=?");
1781
+ params.push(options.channel.trim().toLowerCase());
1782
+ }
1243
1783
  const where = clauses.join(" AND ");
1244
1784
  const rows = db.prepare(
1245
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
1785
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.due_on, oc.resolved_at, oc.resolution,
1246
1786
  e.type, e.summary
1247
1787
  FROM open_commitments oc
1248
1788
  INNER JOIN events e ON e.id = oc.event_id
@@ -1259,7 +1799,8 @@ function listOpenCommitments(db, filters, options) {
1259
1799
  participants: partsByEvent.get(r.event_id) ?? [],
1260
1800
  dueAt: r.due_at,
1261
1801
  dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
1262
- overdue: r.due_at !== null && r.due_at < now,
1802
+ dueOn: r.due_on ?? (r.due_at === null ? null : new Date(r.due_at).toISOString().slice(0, 10)),
1803
+ overdue: isCommitmentOverdue(r.due_at, r.due_on, now),
1263
1804
  resolutionState: mapResolution(r.resolved_at, r.resolution)
1264
1805
  }));
1265
1806
  }
@@ -1293,8 +1834,10 @@ function utcMidnight(year, month1Based, day) {
1293
1834
  return Date.UTC(year, month1Based - 1, day, 0, 0, 0, 0);
1294
1835
  }
1295
1836
  function computeNextAnchorOccursOn(params) {
1296
- const refStart = utcStartOfDayMs(params.referenceMs);
1297
- const refYear = new Date(params.referenceMs).getUTCFullYear();
1837
+ const refStart = Date.parse(
1838
+ `${localCalendarDate(params.referenceMs)}T00:00:00Z`
1839
+ );
1840
+ const refYear = new Date(refStart).getUTCFullYear();
1298
1841
  for (let year2 = refYear; year2 <= refYear + 2; year2 += 1) {
1299
1842
  const day2 = effectiveAnchorDay(year2, params.anchorMonth, params.anchorDay);
1300
1843
  const candidate = utcMidnight(year2, params.anchorMonth, day2);
@@ -1354,19 +1897,6 @@ function loadActiveDatesForContact(db, contactId) {
1354
1897
  return rows.map((row) => mapUpcomingRowToUpcomingDateRecord(row));
1355
1898
  }
1356
1899
 
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
1900
  // src/links/mappers.ts
1371
1901
  function mapLinkRowToLinkListItem(row) {
1372
1902
  const kind = row.kind;
@@ -1427,7 +1957,13 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
1427
1957
  ORDER BY COALESCE(lr.normalized_rank, 0) DESC, l.trust DESC
1428
1958
  LIMIT ?`
1429
1959
  ).all(contactId, contactId, topLinksLimit);
1430
- const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
1960
+ const topLinks = linkRows.map((l) => ({
1961
+ ...mapLinkRowToLinkListItem(l),
1962
+ counterpartyName: getContactRowById(
1963
+ db,
1964
+ l.from_contact_id === contactId ? l.to_contact_id : l.from_contact_id
1965
+ )?.name ?? ""
1966
+ }));
1431
1967
  const activeDates = loadActiveDatesForContact(db, contactId);
1432
1968
  const contactRollup = loadContactRollup(db, contactId);
1433
1969
  const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
@@ -1460,7 +1996,20 @@ function getContactProfile(db, contactId, options) {
1460
1996
  return {
1461
1997
  ...profile,
1462
1998
  recentEvents: getContactJournal(db, contactId, {
1463
- limit: options?.recentEventsLimit ?? 5
1999
+ limit: options?.recentEventsLimit ?? 5,
2000
+ eventTypes: [
2001
+ "conversation",
2002
+ "activity",
2003
+ "gift",
2004
+ "support",
2005
+ "milestone",
2006
+ "observation",
2007
+ "conflict",
2008
+ "correction",
2009
+ "transaction",
2010
+ "promise",
2011
+ "agreement"
2012
+ ]
1464
2013
  }).map((event) => ({
1465
2014
  ...event,
1466
2015
  occurredAtIso: new Date(event.occurredAt).toISOString()
@@ -1490,7 +2039,10 @@ function contactAttributeClauses(filters) {
1490
2039
  const params = [];
1491
2040
  const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
1492
2041
  const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
1493
- for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
2042
+ for (const filter of [
2043
+ ...attributes,
2044
+ ...tags.map((tag) => `tag:${tag.replace(/^tag:/i, "")}`)
2045
+ ]) {
1494
2046
  const separator = filter.indexOf("=");
1495
2047
  const name = separator < 0 ? filter : filter.slice(0, separator);
1496
2048
  clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
@@ -1561,7 +2113,9 @@ function pairKey(leftContactId, rightContactId) {
1561
2113
  function normalizeName(name) {
1562
2114
  return name.trim().toLowerCase().replace(/\s+/g, " ");
1563
2115
  }
1564
- function fuzzyNameScore(left, right) {
2116
+ function fuzzyNameScore(leftInput, rightInput) {
2117
+ const left = normalizeName(leftInput);
2118
+ const right = normalizeName(rightInput);
1565
2119
  if (left === right) {
1566
2120
  return 0.92;
1567
2121
  }
@@ -1719,7 +2273,7 @@ function foldSearchText(value) {
1719
2273
 
1720
2274
  // src/contacts/search_contacts.ts
1721
2275
  function searchContacts(db, query, filters, options) {
1722
- const q = foldSearchText(query.trim());
2276
+ const q = foldSearchText(query.trim().replace(/^@/, ""));
1723
2277
  if (!q) return [];
1724
2278
  assertDefaultOrdering("searchContacts", options);
1725
2279
  const { limit, offset } = resolveListLimitOffset(options);
@@ -1739,6 +2293,7 @@ function searchContacts(db, query, filters, options) {
1739
2293
  const tokens = q.split(/\s+/);
1740
2294
  const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
1741
2295
  const digits = q.replace(/\D/g, "").replace(/^00/, "");
2296
+ const suffix = digits.replace(/^0/, "");
1742
2297
  const attributeFilters = contactAttributeClauses(filters);
1743
2298
  const clauses = [
1744
2299
  "deleted_at IS NULL",
@@ -1774,7 +2329,7 @@ function searchContacts(db, query, filters, options) {
1774
2329
  )
1775
2330
  ];
1776
2331
  const phoneFields = phone ? values.filter(
1777
- (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
2332
+ (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
2333
  ) : [];
1779
2334
  if (!phoneFields.length && !tokens.every(
1780
2335
  (token) => values.some((field) => field.text.includes(token))
@@ -1809,7 +2364,7 @@ function assertValidLifecycleTransition(from, to) {
1809
2364
  }
1810
2365
  if (to === "merged") {
1811
2366
  throw new AffinityInvariantError(
1812
- "use manage_contact merge (CLI: contact merge) to reach merged state"
2367
+ "use merge_contacts (CLI: contact merge) to reach merged state"
1813
2368
  );
1814
2369
  }
1815
2370
  if (from === to) {
@@ -1864,7 +2419,7 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
1864
2419
  // src/events/persistence.ts
1865
2420
  function insertJournalEvent(db, params) {
1866
2421
  const now = resolveNow(params.now);
1867
- const summary = params.summary.trim();
2422
+ const summary = params.summary?.trim() ?? deriveEventSummary(params.content ?? "");
1868
2423
  const result = db.prepare(
1869
2424
  `INSERT INTO events (
1870
2425
  type, occurred_at, summary, significance, moment_kind,
@@ -1881,6 +2436,33 @@ function insertJournalEvent(db, params) {
1881
2436
  now
1882
2437
  );
1883
2438
  const eventId = Number(result.lastInsertRowid);
2439
+ db.prepare("UPDATE events SET affects_bond=? WHERE id=?").run(
2440
+ params.affectsBond === void 0 ? params.type === "observation" && params.significance <= 2 ? 0 : 1 : Number(params.affectsBond),
2441
+ eventId
2442
+ );
2443
+ db.prepare("UPDATE events SET declared_moment_kind=? WHERE id=?").run(
2444
+ params.momentKind,
2445
+ eventId
2446
+ );
2447
+ db.prepare(
2448
+ "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=?"
2449
+ ).run(
2450
+ params.content ?? null,
2451
+ params.externalRef?.source ?? null,
2452
+ params.externalRef?.id ?? null,
2453
+ params.channel ?? null,
2454
+ params.parentRef?.source ?? null,
2455
+ params.parentRef?.id ?? null,
2456
+ params.parentEventId ?? null,
2457
+ params.threadRef?.source ?? null,
2458
+ params.threadRef?.id ?? null,
2459
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
2460
+ eventId
2461
+ );
2462
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
2463
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
2464
+ eventId
2465
+ );
1884
2466
  const insertParticipant = db.prepare(
1885
2467
  `INSERT INTO event_participants (
1886
2468
  event_id, contact_id, role, directionality
@@ -1923,6 +2505,10 @@ function insertDateAnchorEvent(db, params) {
1923
2505
  now
1924
2506
  );
1925
2507
  const eventId = Number(result.lastInsertRowid);
2508
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
2509
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
2510
+ eventId
2511
+ );
1926
2512
  const insertParticipant = db.prepare(
1927
2513
  `INSERT INTO event_participants (
1928
2514
  event_id, contact_id, role, directionality
@@ -1943,20 +2529,6 @@ function insertDateAnchorEvent(db, params) {
1943
2529
  return eventId;
1944
2530
  }
1945
2531
 
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
2532
  // src/links/queries.ts
1961
2533
  function getLinkRowById(db, id) {
1962
2534
  const row = db.prepare(
@@ -2118,12 +2690,12 @@ function listUpcomingDates(db, filters, options) {
2118
2690
  }
2119
2691
  const clauses = ["e.deleted_at IS NULL", "e.type = 'date_anchor'"];
2120
2692
  const params = [];
2121
- const since = options?.since ?? utcStartOfDayMs(Date.now());
2693
+ const since = options?.since ?? Date.parse(`${localCalendarDate(options?.now ?? Date.now())}T00:00:00Z`);
2122
2694
  clauses.push("uo.occurs_on >= ?");
2123
2695
  params.push(since);
2124
2696
  let maxOccurs = options?.until;
2125
2697
  if (filters?.horizonDays !== void 0) {
2126
- const anchor = options?.since ?? Date.now();
2698
+ const anchor = since;
2127
2699
  const horizonEnd = anchor + filters.horizonDays * 864e5;
2128
2700
  maxOccurs = maxOccurs === void 0 ? horizonEnd : Math.min(maxOccurs, horizonEnd);
2129
2701
  }
@@ -2661,6 +3233,10 @@ function listMoments(db, filters, options) {
2661
3233
  clauses.push("le.link_id = ?");
2662
3234
  params.push(filters.linkId);
2663
3235
  }
3236
+ if (options?.channel !== void 0) {
3237
+ clauses.push("e.channel=?");
3238
+ params.push(options.channel.trim().toLowerCase());
3239
+ }
2664
3240
  const where = clauses.join(" AND ");
2665
3241
  return db.prepare(
2666
3242
  `SELECT
@@ -2668,6 +3244,7 @@ function listMoments(db, filters, options) {
2668
3244
  le.link_id,
2669
3245
  le.moment_kind,
2670
3246
  le.occurred_at,
3247
+ e.occurred_on,
2671
3248
  e.summary,
2672
3249
  le.impact_score
2673
3250
  FROM link_event_effects le
@@ -2680,6 +3257,7 @@ function listMoments(db, filters, options) {
2680
3257
  linkId: row.link_id,
2681
3258
  momentKind: row.moment_kind,
2682
3259
  occurredAt: row.occurred_at,
3260
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
2683
3261
  summary: row.summary,
2684
3262
  impactScore: row.impact_score
2685
3263
  }));
@@ -2708,6 +3286,10 @@ function getLinkTimeline(db, linkId, options) {
2708
3286
  clauses.push(`e.type IN (${ph})`);
2709
3287
  params.push(...options.eventTypes);
2710
3288
  }
3289
+ if (options?.channel !== void 0) {
3290
+ clauses.push("e.channel = ?");
3291
+ params.push(options.channel.trim().toLowerCase());
3292
+ }
2711
3293
  const where = clauses.join(" AND ");
2712
3294
  const rows = db.prepare(
2713
3295
  `SELECT e.id
@@ -2719,7 +3301,8 @@ function getLinkTimeline(db, linkId, options) {
2719
3301
  ).all(...params, limit, offset);
2720
3302
  return batchLoadEventRecords(
2721
3303
  db,
2722
- rows.map((r) => r.id)
3304
+ rows.map((r) => r.id),
3305
+ options?.includeContent ?? false
2723
3306
  );
2724
3307
  }
2725
3308
 
@@ -2793,26 +3376,34 @@ function refreshLinkRollup(db, linkId, now) {
2793
3376
  const latest = db.prepare(
2794
3377
  `SELECT event_id, occurred_at
2795
3378
  FROM link_event_effects
2796
- WHERE link_id = ? AND counts_for_recency = 1
3379
+ 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
3380
  ORDER BY occurred_at DESC, event_id DESC
2798
3381
  LIMIT 1`
2799
- ).get(linkId);
3382
+ ).get(linkId, now, localCalendarDate(now));
2800
3383
  const trailingWindowStart = now - 180 * DAY_MS2;
2801
3384
  const counts = db.prepare(
2802
3385
  `SELECT
2803
3386
  COUNT(*) AS total_meaningful_events,
2804
3387
  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
3388
  FROM link_event_effects
2806
- WHERE link_id = ? AND occurred_at >= ?`
2807
- ).get(linkId, trailingWindowStart);
3389
+ WHERE link_id = ? AND occurred_at >= ? AND counts_for_recency = 1
3390
+ AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
3391
+ ).get(linkId, trailingWindowStart, now, localCalendarDate(now));
2808
3392
  const directionalityRows = db.prepare(
2809
3393
  `SELECT ep.directionality
2810
3394
  FROM link_event_effects le
2811
3395
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
2812
3396
  WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
2813
3397
  AND ep.directionality IS NOT NULL
2814
- AND le.occurred_at >= ?`
2815
- ).all(linkId, link.from_contact_id, trailingWindowStart);
3398
+ AND le.occurred_at >= ?
3399
+ AND le.event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
3400
+ ).all(
3401
+ linkId,
3402
+ link.from_contact_id,
3403
+ trailingWindowStart,
3404
+ now,
3405
+ localCalendarDate(now)
3406
+ );
2816
3407
  let outboundCount = 0;
2817
3408
  let inboundCount = 0;
2818
3409
  for (const row of directionalityRows) {
@@ -3131,21 +3722,6 @@ function listProgressionReadiness(db, filters, options) {
3131
3722
  }
3132
3723
 
3133
3724
  // 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
3725
  function listRadar(db, filters, options) {
3150
3726
  assertDefaultOrdering("listRadar", options);
3151
3727
  const ownerId = findOwnerContactId(db);
@@ -3153,7 +3729,10 @@ function listRadar(db, filters, options) {
3153
3729
  return [];
3154
3730
  }
3155
3731
  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})`;
3732
+ const today = localCalendarDate(now);
3733
+ 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}'))`;
3734
+ const drift = `(${lastContact} IS NOT NULL AND (${now} - ${lastContact}) > 1.5 * COALESCE(l.cadence_days, 1) * 86400000)`;
3735
+ 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
3736
  const { limit, offset } = resolveListLimitOffset(options);
3158
3737
  const clauses = [
3159
3738
  "l.removed_at IS NULL",
@@ -3178,10 +3757,23 @@ function listRadar(db, filters, options) {
3178
3757
  if (options?.includeObserved === false) {
3179
3758
  clauses.push("l.kind != 'observed'");
3180
3759
  }
3760
+ if (options?.includeDormant !== true)
3761
+ clauses.push("c_to.lifecycle_state != 'dormant'");
3762
+ if (filters?.includeLost !== true)
3763
+ clauses.push("c_to.lifecycle_state != 'lost'");
3764
+ const other = `CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END`;
3765
+ clauses.push(`(${lastContact} IS NOT NULL OR ${overdue} OR NOT EXISTS (
3766
+ SELECT 1 FROM event_participants ep JOIN events ev ON ev.id=ep.event_id
3767
+ WHERE ep.contact_id=${other} AND ev.deleted_at IS NULL
3768
+ 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'))
3769
+ ))`);
3181
3770
  const where = clauses.join(" AND ");
3182
3771
  const rows = db.prepare(
3183
3772
  `SELECT
3184
3773
  l.id,
3774
+ l.cadence_days,
3775
+ ${lastContact} AS last_contact,
3776
+ ${drift} AS drift,
3185
3777
  ${overdue} AS overdue_commitment,
3186
3778
  l.from_contact_id,
3187
3779
  l.to_contact_id,
@@ -3195,23 +3787,44 @@ function listRadar(db, filters, options) {
3195
3787
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
3196
3788
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
3197
3789
  WHERE ${where}
3198
- ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
3790
+ ORDER BY (${overdue} OR ${drift}) DESC, ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
3199
3791
  LIMIT ? OFFSET ?`
3200
3792
  ).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
- }));
3793
+ return rows.map((row) => {
3794
+ const contactId = row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id;
3795
+ const promise = row.overdue_commitment ? db.prepare(
3796
+ `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`
3797
+ ).get(contactId, today, now) : void 0;
3798
+ const reasons = [
3799
+ ...row.overdue_commitment ? ["overdue_commitment"] : [],
3800
+ ...row.drift ? ["drift"] : []
3801
+ ];
3802
+ const snippets = [
3803
+ ...promise ? [
3804
+ `Overdue promise: ${promise.summary} (due ${promise.due_on ?? new Date(promise.due_at).toISOString().slice(0, 10)})`
3805
+ ] : [],
3806
+ ...row.drift ? [
3807
+ `No contact for ${Math.floor((now - (row.last_contact ?? now)) / 864e5)} days (cadence ${row.cadence_days ?? 1} days)`
3808
+ ] : []
3809
+ ];
3810
+ const elapsed = row.last_contact === null ? null : Math.max(0, (now - row.last_contact) / 864e5);
3811
+ const currentDrift = elapsed === null ? 0 : driftPriority({
3812
+ daysSinceLastMeaningfulEvent: elapsed,
3813
+ cadenceDays: row.cadence_days ?? 1,
3814
+ trust: row.trust ?? 0,
3815
+ rank: row.rank ?? 0
3816
+ });
3817
+ return {
3818
+ linkId: row.id,
3819
+ contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
3820
+ reasons,
3821
+ driftPriority: row.overdue_commitment ? Math.max(1, currentDrift) : currentDrift,
3822
+ recencyScore: elapsed === null ? 0 : Math.max(0, 1 - elapsed / Math.max(row.cadence_days ?? 1, 1)),
3823
+ normalizedRank: row.normalized_rank ?? 0,
3824
+ trust: row.trust ?? 0,
3825
+ recommendedReason: snippets.join("; ") || "Routine check-in recommended"
3826
+ };
3827
+ });
3215
3828
  }
3216
3829
 
3217
3830
  // src/links/receipts.ts
@@ -3294,6 +3907,9 @@ function overrideLinkState(db, linkId, state, options) {
3294
3907
  now,
3295
3908
  linkId
3296
3909
  );
3910
+ db.prepare(
3911
+ "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=?"
3912
+ ).run(linkId, state, linkId);
3297
3913
  refreshLinkRollup(db, linkId, now);
3298
3914
  if (state === "archived" || row.state === "archived") {
3299
3915
  refreshAllBridgeScores(db, now);
@@ -3368,6 +3984,8 @@ function reviseBond(db, linkId, bond, options) {
3368
3984
  // src/links/seed_social_link.ts
3369
3985
  function seedSocialLink(db, input) {
3370
3986
  return withTransaction(db, () => {
3987
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
3988
+ throw new AffinityValidationError("ifExists must be error or return");
3371
3989
  if (!isRelationalLinkKind(input.kind)) {
3372
3990
  throw new AffinityValidationError("invalid relational link kind");
3373
3991
  }
@@ -3375,11 +3993,14 @@ function seedSocialLink(db, input) {
3375
3993
  throw new AffinityValidationError("from and to contacts must differ");
3376
3994
  }
3377
3995
  assertContactEndpointsNotMerged(db, input.fromContactId, input.toContactId);
3378
- if (findLiveRelationalLinkAnyDirection(
3996
+ const existing = findLiveRelationalLinkAnyDirection(
3379
3997
  db,
3380
3998
  input.fromContactId,
3381
3999
  input.toContactId
3382
- )) {
4000
+ );
4001
+ if (existing) {
4002
+ if (input.ifExists === "return")
4003
+ return buildLinkMutationReceipt(mapLinkRowToLinkListItem(existing), {});
3383
4004
  throw new AffinityConflictError(
3384
4005
  "relational link already exists between these contacts"
3385
4006
  );
@@ -4198,6 +4819,8 @@ function deriveStateAfter(params) {
4198
4819
  return params.stateBefore;
4199
4820
  }
4200
4821
  function deriveMomentKind(params) {
4822
+ if (params.eventType === "observation" || params.eventType === "milestone" && params.significance < 7)
4823
+ return null;
4201
4824
  if (params.rankAfter > params.rankBefore) {
4202
4825
  return "breakthrough";
4203
4826
  }
@@ -4227,6 +4850,18 @@ function applyEventEffectToLink(db, params) {
4227
4850
  if (!link || link.is_structural !== 0) {
4228
4851
  throw new AffinityStateError("relational link not found");
4229
4852
  }
4853
+ db.prepare(
4854
+ "INSERT OR IGNORE INTO event_link_baselines(link_id,state_json) VALUES (?,?)"
4855
+ ).run(
4856
+ params.linkId,
4857
+ JSON.stringify({
4858
+ rank: link.rank ?? 0,
4859
+ affinity: link.affinity ?? 0,
4860
+ trust: link.trust ?? 0,
4861
+ state: link.state ?? "active",
4862
+ cadence_days: link.cadence_days
4863
+ })
4864
+ );
4230
4865
  const stateBefore = link.state ?? "active";
4231
4866
  const linkKind = link.kind;
4232
4867
  const rankBefore = link.rank ?? 0;
@@ -4271,7 +4906,7 @@ function applyEventEffectToLink(db, params) {
4271
4906
  );
4272
4907
  const weightedBase = base * massPenalty(
4273
4908
  weeklyPositiveBaseWeight(db, params.linkId, params.occurredAt)
4274
- ) * salienceMultiplier;
4909
+ ) * salienceMultiplier * (params.participant.directionality === "owner_initiated" ? 0.05 : 1);
4275
4910
  const affinityGain = weightedBase * Math.max(eventValence, 0) * (0.55 + 0.45 * eventIntimacyDepth) * (0.7 + 0.3 * participantReciprocity) * (1 / (1 + 0.22 * rankBefore));
4276
4911
  const affinityLoss = weightedBase * Math.max(-eventValence, 0) * 0.35;
4277
4912
  const affinityMass = clamp3(
@@ -4312,15 +4947,16 @@ function applyEventEffectToLink(db, params) {
4312
4947
  const latest = db.prepare(
4313
4948
  `SELECT occurred_at
4314
4949
  FROM link_event_effects
4315
- WHERE link_id = ?
4950
+ 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
4951
  ORDER BY occurred_at DESC, event_id DESC
4317
4952
  LIMIT 1`
4318
- ).get(params.linkId);
4953
+ ).get(params.linkId, params.now, localCalendarDate(params.now));
4319
4954
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
4320
4955
  1,
4321
4956
  Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
4322
4957
  );
4323
- const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
4958
+ const eventDay = db.prepare("SELECT occurred_on FROM events WHERE id = ?").get(params.eventId)?.occurred_on;
4959
+ const cadenceAfter = commitmentResolution != null || params.eventType === "promise" || params.eventType === "agreement" || params.occurredAt > params.now && (eventDay == null || eventDay > localCalendarDate(params.now)) ? cadenceBefore : nextCadenceDays({
4324
4960
  kind: linkKind,
4325
4961
  currentCadenceDays: cadenceBefore,
4326
4962
  gapDays
@@ -4340,7 +4976,11 @@ function applyEventEffectToLink(db, params) {
4340
4976
  }
4341
4977
  const affinityDelta = affinityAfter - affinityBefore;
4342
4978
  const trustDelta = trustAfter - trustBefore;
4343
- const momentKind = deriveMomentKind({
4979
+ const eventLabel = db.prepare("SELECT summary FROM events WHERE id=?").get(params.eventId)?.summary;
4980
+ const followLike = params.eventType === "milestone" && typeof eventLabel === "string" && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
4981
+ eventLabel
4982
+ );
4983
+ const momentKind = followLike ? null : deriveMomentKind({
4344
4984
  rankBefore,
4345
4985
  rankAfter,
4346
4986
  stateBefore,
@@ -4383,7 +5023,15 @@ function applyEventEffectToLink(db, params) {
4383
5023
  params.now,
4384
5024
  params.now
4385
5025
  );
4386
- if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
5026
+ db.prepare(
5027
+ "UPDATE link_event_effects SET participant_json=?,provenance_json=? WHERE event_id=? AND link_id=?"
5028
+ ).run(
5029
+ JSON.stringify(params.participant),
5030
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
5031
+ params.eventId,
5032
+ params.linkId
5033
+ );
5034
+ if (commitmentResolution !== void 0 && commitmentResolution !== null || params.eventType === "promise" || params.eventType === "agreement" || params.participant.directionality === "owner_initiated") {
4387
5035
  db.prepare(
4388
5036
  "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
4389
5037
  ).run(params.eventId, params.linkId);
@@ -4466,15 +5114,36 @@ function ensureRelationalLink(db, params) {
4466
5114
  };
4467
5115
  }
4468
5116
  function applyJournalEventMechanics(db, params) {
5117
+ const bond = db.prepare("SELECT affects_bond, channel FROM events WHERE id=?").get(params.eventId);
5118
+ const ambient = params.provenance && typeof params.provenance === "object" && (params.provenance.ambient === true || params.provenance.tags?.includes("ambient"));
5119
+ if (bond?.affects_bond === 0 || bond?.affects_bond == null && params.eventType === "observation" && params.significance <= 2 || ambient)
5120
+ return { created: [], affectedLinks: [], derivedEffects: [] };
4469
5121
  const ownerId = requireOwnerContactId(db);
4470
- const participantIds = params.participants.map(
5122
+ const owner = params.participants.find((p) => p.contactId === ownerId);
5123
+ const socialChannel = [
5124
+ "bluesky",
5125
+ "x",
5126
+ "twitter",
5127
+ "mastodon",
5128
+ "github",
5129
+ "instagram"
5130
+ ].includes(String(bond?.channel));
5131
+ const direction = owner?.directionality ?? (socialChannel ? owner?.role === "actor" ? "owner_initiated" : owner?.role === "recipient" ? "other_initiated" : void 0 : void 0);
5132
+ const participants = params.participants.map(
5133
+ (p) => direction && p.directionality === void 0 ? { ...p, directionality: direction } : p
5134
+ );
5135
+ if (direction)
5136
+ db.prepare(
5137
+ "UPDATE event_participants SET directionality=? WHERE event_id=? AND directionality IS NULL"
5138
+ ).run(direction, params.eventId);
5139
+ const participantIds = participants.map(
4471
5140
  (participant) => participant.contactId
4472
5141
  );
4473
5142
  const hasOwner = participantIds.includes(ownerId);
4474
5143
  const created = [];
4475
5144
  const effectInputs = [];
4476
5145
  if (hasOwner) {
4477
- for (const participant of params.participants) {
5146
+ for (const participant of participants) {
4478
5147
  if (participant.contactId === ownerId) {
4479
5148
  continue;
4480
5149
  }
@@ -4482,13 +5151,14 @@ function applyJournalEventMechanics(db, params) {
4482
5151
  fromContactId: ownerId,
4483
5152
  toContactId: participant.contactId,
4484
5153
  kind: inferAutoLinkKind(db, ownerId, participant.contactId),
5154
+ ...(participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality) === "owner_initiated" ? { affinity: 0.15 } : {},
4485
5155
  now: params.now
4486
5156
  });
4487
5157
  created.push(...ensured.created);
4488
5158
  effectInputs.push({ linkId: ensured.linkId, participant });
4489
5159
  }
4490
- } else if (params.eventType === "observation" && params.participants.length === 2) {
4491
- const [first, second] = params.participants;
5160
+ } else if (params.eventType === "observation" && participants.length === 2) {
5161
+ const [first, second] = participants;
4492
5162
  if (first !== void 0 && second !== void 0) {
4493
5163
  const a = Math.min(first.contactId, second.contactId);
4494
5164
  const b = Math.max(first.contactId, second.contactId);
@@ -4523,7 +5193,10 @@ function applyJournalEventMechanics(db, params) {
4523
5193
  eventType: params.eventType,
4524
5194
  occurredAt: params.occurredAt,
4525
5195
  significance: params.significance,
4526
- participant: input.participant,
5196
+ participant: {
5197
+ ...input.participant,
5198
+ directionality: input.participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality
5199
+ },
4527
5200
  provenance: params.provenance,
4528
5201
  now: params.now
4529
5202
  })
@@ -4547,6 +5220,8 @@ function applyJournalEventMechanics(db, params) {
4547
5220
  function recordCommitment(db, input) {
4548
5221
  return withTransaction(db, () => {
4549
5222
  validateSocialEventInput(input);
5223
+ const existing = existingEventReceipt(db, input.externalRef);
5224
+ if (existing) return existing;
4550
5225
  if (input.commitmentType !== "promise" && input.commitmentType !== "agreement") {
4551
5226
  throw new AffinityValidationError(
4552
5227
  "commitmentType must be promise or agreement"
@@ -4561,11 +5236,12 @@ function recordCommitment(db, input) {
4561
5236
  assertOwnerParticipates(ownerId, ids);
4562
5237
  const now = resolveNow(input.now);
4563
5238
  const eventId = insertJournalEvent(db, {
5239
+ ...input,
4564
5240
  type: input.commitmentType,
4565
5241
  occurredAt: input.occurredAt,
4566
- summary: input.summary,
5242
+ occurredOn: input.occurredOn,
4567
5243
  significance: input.significance,
4568
- momentKind: null,
5244
+ momentKind: input.momentKind ?? null,
4569
5245
  participants: input.participants,
4570
5246
  now
4571
5247
  });
@@ -4574,15 +5250,25 @@ function recordCommitment(db, input) {
4574
5250
  event_id, commitment_type, due_at, created_at, updated_at
4575
5251
  ) VALUES (?, ?, ?, ?, ?)`
4576
5252
  ).run(eventId, input.commitmentType, input.dueAt ?? null, now, now);
5253
+ db.prepare("UPDATE open_commitments SET due_on = ? WHERE event_id = ?").run(
5254
+ input.dueAt == null ? null : input.dueOn ?? new Date(input.dueAt).toISOString().slice(0, 10),
5255
+ eventId
5256
+ );
4577
5257
  const mechanics = applyJournalEventMechanics(db, {
4578
5258
  eventId,
4579
5259
  eventType: input.commitmentType,
4580
5260
  occurredAt: input.occurredAt,
5261
+ occurredOn: input.occurredOn,
4581
5262
  significance: input.significance,
4582
5263
  participants: input.participants,
4583
5264
  provenance: input.provenance,
4584
5265
  now
4585
5266
  });
5267
+ if (input.momentKind !== void 0)
5268
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5269
+ input.momentKind,
5270
+ eventId
5271
+ );
4586
5272
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
4587
5273
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4588
5274
  affectedLinks: mechanics.affectedLinks,
@@ -4603,6 +5289,8 @@ var ALLOWED = /* @__PURE__ */ new Set([
4603
5289
  function recordInteraction(db, input) {
4604
5290
  return withTransaction(db, () => {
4605
5291
  validateSocialEventInput(input);
5292
+ const existing = existingEventReceipt(db, input.externalRef);
5293
+ if (existing) return existing;
4606
5294
  if (!ALLOWED.has(input.type)) {
4607
5295
  throw new AffinityValidationError("invalid interaction event type");
4608
5296
  }
@@ -4612,11 +5300,12 @@ function recordInteraction(db, input) {
4612
5300
  assertOwnerParticipates(ownerId, ids);
4613
5301
  const now = resolveNow(input.now);
4614
5302
  const eventId = insertJournalEvent(db, {
5303
+ ...input,
4615
5304
  type: input.type,
4616
5305
  occurredAt: input.occurredAt,
4617
- summary: input.summary,
5306
+ occurredOn: input.occurredOn,
4618
5307
  significance: input.significance,
4619
- momentKind: null,
5308
+ momentKind: input.momentKind ?? null,
4620
5309
  participants: input.participants,
4621
5310
  now
4622
5311
  });
@@ -4624,12 +5313,24 @@ function recordInteraction(db, input) {
4624
5313
  eventId,
4625
5314
  eventType: input.type,
4626
5315
  occurredAt: input.occurredAt,
5316
+ occurredOn: input.occurredOn,
4627
5317
  significance: input.significance,
4628
5318
  participants: input.participants,
4629
5319
  provenance: input.provenance,
4630
5320
  now
4631
5321
  });
5322
+ if (input.momentKind !== void 0)
5323
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5324
+ input.momentKind,
5325
+ eventId
5326
+ );
4632
5327
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5328
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5329
+ {
5330
+ code: "future_event",
5331
+ message: "occurredAt is in the future; it will not count as contact until then"
5332
+ }
5333
+ ] : void 0,
4633
5334
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4634
5335
  affectedLinks: mechanics.affectedLinks,
4635
5336
  derivedEffects: mechanics.derivedEffects
@@ -4641,17 +5342,20 @@ function recordInteraction(db, input) {
4641
5342
  function recordMilestone(db, input) {
4642
5343
  return withTransaction(db, () => {
4643
5344
  validateSocialEventInput(input);
5345
+ const existing = existingEventReceipt(db, input.externalRef);
5346
+ if (existing) return existing;
4644
5347
  const ownerId = requireOwnerContactId(db);
4645
5348
  const ids = input.participants.map((p) => p.contactId);
4646
5349
  assertParticipantContactsLive(db, ids);
4647
5350
  assertOwnerParticipates(ownerId, ids);
4648
5351
  const now = resolveNow(input.now);
4649
5352
  const eventId = insertJournalEvent(db, {
5353
+ ...input,
4650
5354
  type: "milestone",
4651
5355
  occurredAt: input.occurredAt,
4652
- summary: input.summary,
5356
+ occurredOn: input.occurredOn,
4653
5357
  significance: input.significance,
4654
- momentKind: null,
5358
+ momentKind: input.momentKind ?? null,
4655
5359
  participants: input.participants,
4656
5360
  now
4657
5361
  });
@@ -4659,12 +5363,24 @@ function recordMilestone(db, input) {
4659
5363
  eventId,
4660
5364
  eventType: "milestone",
4661
5365
  occurredAt: input.occurredAt,
5366
+ occurredOn: input.occurredOn,
4662
5367
  significance: input.significance,
4663
5368
  participants: input.participants,
4664
5369
  provenance: input.provenance,
4665
5370
  now
4666
5371
  });
5372
+ if (input.momentKind !== void 0)
5373
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5374
+ input.momentKind,
5375
+ eventId
5376
+ );
4667
5377
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5378
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5379
+ {
5380
+ code: "future_event",
5381
+ message: "occurredAt is in the future; it will not count as contact until then"
5382
+ }
5383
+ ] : void 0,
4668
5384
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4669
5385
  affectedLinks: mechanics.affectedLinks,
4670
5386
  derivedEffects: mechanics.derivedEffects
@@ -4676,15 +5392,18 @@ function recordMilestone(db, input) {
4676
5392
  function recordObservation(db, input) {
4677
5393
  return withTransaction(db, () => {
4678
5394
  validateSocialEventInput(input);
5395
+ const existing = existingEventReceipt(db, input.externalRef);
5396
+ if (existing) return existing;
4679
5397
  const ids = input.participants.map((p) => p.contactId);
4680
5398
  assertParticipantContactsLive(db, ids);
4681
5399
  const now = resolveNow(input.now);
4682
5400
  const eventId = insertJournalEvent(db, {
5401
+ ...input,
4683
5402
  type: "observation",
4684
5403
  occurredAt: input.occurredAt,
4685
- summary: input.summary,
5404
+ occurredOn: input.occurredOn,
4686
5405
  significance: input.significance,
4687
- momentKind: null,
5406
+ momentKind: input.momentKind ?? null,
4688
5407
  participants: input.participants,
4689
5408
  now
4690
5409
  });
@@ -4692,12 +5411,24 @@ function recordObservation(db, input) {
4692
5411
  eventId,
4693
5412
  eventType: "observation",
4694
5413
  occurredAt: input.occurredAt,
5414
+ occurredOn: input.occurredOn,
4695
5415
  significance: input.significance,
4696
5416
  participants: input.participants,
4697
5417
  provenance: input.provenance,
4698
5418
  now
4699
5419
  });
5420
+ if (input.momentKind !== void 0)
5421
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5422
+ input.momentKind,
5423
+ eventId
5424
+ );
4700
5425
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5426
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5427
+ {
5428
+ code: "future_event",
5429
+ message: "occurredAt is in the future; it will not count as contact until then"
5430
+ }
5431
+ ] : void 0,
4701
5432
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4702
5433
  affectedLinks: mechanics.affectedLinks,
4703
5434
  derivedEffects: mechanics.derivedEffects
@@ -4709,17 +5440,20 @@ function recordObservation(db, input) {
4709
5440
  function recordTransaction(db, input) {
4710
5441
  return withTransaction(db, () => {
4711
5442
  validateSocialEventInput(input);
5443
+ const existing = existingEventReceipt(db, input.externalRef);
5444
+ if (existing) return existing;
4712
5445
  const ownerId = requireOwnerContactId(db);
4713
5446
  const ids = input.participants.map((p) => p.contactId);
4714
5447
  assertParticipantContactsLive(db, ids);
4715
5448
  assertOwnerParticipates(ownerId, ids);
4716
5449
  const now = resolveNow(input.now);
4717
5450
  const eventId = insertJournalEvent(db, {
5451
+ ...input,
4718
5452
  type: "transaction",
4719
5453
  occurredAt: input.occurredAt,
4720
- summary: input.summary,
5454
+ occurredOn: input.occurredOn,
4721
5455
  significance: input.significance,
4722
- momentKind: null,
5456
+ momentKind: input.momentKind ?? null,
4723
5457
  participants: input.participants,
4724
5458
  now
4725
5459
  });
@@ -4727,12 +5461,24 @@ function recordTransaction(db, input) {
4727
5461
  eventId,
4728
5462
  eventType: "transaction",
4729
5463
  occurredAt: input.occurredAt,
5464
+ occurredOn: input.occurredOn,
4730
5465
  significance: input.significance,
4731
5466
  participants: input.participants,
4732
5467
  provenance: input.provenance,
4733
5468
  now
4734
5469
  });
5470
+ if (input.momentKind !== void 0)
5471
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
5472
+ input.momentKind,
5473
+ eventId
5474
+ );
4735
5475
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
5476
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
5477
+ {
5478
+ code: "future_event",
5479
+ message: "occurredAt is in the future; it will not count as contact until then"
5480
+ }
5481
+ ] : void 0,
4736
5482
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
4737
5483
  affectedLinks: mechanics.affectedLinks,
4738
5484
  derivedEffects: mechanics.derivedEffects
@@ -4805,6 +5551,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4805
5551
  const occurredAt = options?.resolvingEvent?.occurredAt ?? now;
4806
5552
  validateSocialEventInput({
4807
5553
  occurredAt,
5554
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4808
5555
  summary,
4809
5556
  significance,
4810
5557
  participants,
@@ -4821,6 +5568,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4821
5568
  const resolvingEventId = insertJournalEvent(db, {
4822
5569
  type: eventType,
4823
5570
  occurredAt,
5571
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4824
5572
  summary,
4825
5573
  significance,
4826
5574
  momentKind: null,
@@ -4854,30 +5602,46 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4854
5602
  });
4855
5603
  }
4856
5604
 
4857
- // src/contacts/export_contact.ts
4858
- function exportContact(db, id) {
4859
- const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
5605
+ // src/contacts/export_rows.ts
5606
+ function exportRows(db, sql, ...params) {
5607
+ return db.prepare(sql).all(...params).map((row) => {
4860
5608
  const result = {};
4861
5609
  for (const [key, value] of Object.entries(row)) {
4862
5610
  const name = key.replace(
4863
5611
  /_([a-z])/g,
4864
5612
  (_, letter) => letter.toUpperCase()
4865
5613
  );
4866
- result[name] = value;
4867
- if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
5614
+ result[name] = [
5615
+ "affects_bond",
5616
+ "is_owner",
5617
+ "is_structural",
5618
+ "verified",
5619
+ "counts_for_recency",
5620
+ "manual"
5621
+ ].includes(key) ? Boolean(value) : value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : value;
5622
+ if (name.endsWith("At") && typeof value === "number")
4868
5623
  result[`${name}Iso`] = new Date(value).toISOString();
4869
5624
  if (key.endsWith("_json") && typeof value === "string")
4870
5625
  result[name] = JSON.parse(value);
4871
5626
  }
4872
5627
  return result;
4873
5628
  });
5629
+ }
5630
+
5631
+ // src/contacts/export_contact.ts
5632
+ function exportContact(db, id) {
5633
+ const query = (sql, ...params) => exportRows(db, sql, ...params);
4874
5634
  const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
4875
5635
  if (!contact) throw new AffinityNotFoundError("contact not found");
4876
5636
  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",
5637
+ "SELECT DISTINCT e.*, CAST(e.content AS BLOB) AS content, CAST(e.summary AS BLOB) AS summary FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? AND e.type != 'date_anchor' ORDER BY e.id",
4878
5638
  id
4879
5639
  ).map((event) => ({
4880
5640
  ...event,
5641
+ attributes: query(
5642
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5643
+ event.id
5644
+ ),
4881
5645
  participants: query(
4882
5646
  "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
5647
  event.id
@@ -4922,14 +5686,98 @@ function exportContact(db, id) {
4922
5686
  // src/contacts/export_affinity.ts
4923
5687
  function exportAffinity(db) {
4924
5688
  const now = Date.now();
5689
+ const records = db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id));
5690
+ const events = exportRows(
5691
+ db,
5692
+ "SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary FROM events ORDER BY id"
5693
+ ).map((event) => ({
5694
+ ...event,
5695
+ attributes: exportRows(
5696
+ db,
5697
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
5698
+ event.id
5699
+ ),
5700
+ participants: exportRows(
5701
+ db,
5702
+ "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",
5703
+ event.id
5704
+ )
5705
+ }));
4925
5706
  return {
4926
5707
  version: 1,
4927
5708
  exportedAt: now,
4928
5709
  exportedAtIso: new Date(now).toISOString(),
4929
- contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
5710
+ events,
5711
+ contacts: records.map(({ events: events2, dates, ...record }) => ({
5712
+ ...record,
5713
+ eventIds: events2.map((event) => event.id),
5714
+ dateIds: dates.map((date) => date.id)
5715
+ }))
4930
5716
  };
4931
5717
  }
4932
5718
 
5719
+ // src/events/event_discovery.ts
5720
+ function searchEvents(db, query, options) {
5721
+ const { limit, offset } = resolveListLimitOffset(options);
5722
+ const tokens = foldSearchText(query).split(/\s+/).filter(Boolean);
5723
+ if (!tokens.length) return [];
5724
+ const rows = db.prepare(
5725
+ "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"
5726
+ ).all();
5727
+ return rows.flatMap((row) => {
5728
+ const fields = {
5729
+ summary: decodeEventText(row.summary),
5730
+ content: row.content === null ? null : decodeEventText(row.content),
5731
+ source: row.external_source,
5732
+ id: row.external_id,
5733
+ channel: row.channel
5734
+ };
5735
+ const folded = Object.entries(fields).map(
5736
+ ([key, value]) => [key, foldSearchText(value ?? "")]
5737
+ );
5738
+ if (!tokens.every((t) => folded.some(([, v]) => v.includes(t))))
5739
+ return [];
5740
+ return [
5741
+ {
5742
+ kind: "event",
5743
+ id: row.id,
5744
+ type: row.type,
5745
+ title: deriveEventSummary(decodeEventText(row.summary)),
5746
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
5747
+ matchedOn: folded.filter(([, v]) => tokens.some((t) => v.includes(t))).map(([k]) => k),
5748
+ ...row.external_source && row.external_id ? {
5749
+ externalRef: {
5750
+ source: row.external_source,
5751
+ id: row.external_id
5752
+ }
5753
+ } : {}
5754
+ }
5755
+ ];
5756
+ }).slice(offset, offset + limit);
5757
+ }
5758
+ function getEventThread(db, source, id, options) {
5759
+ const { limit, offset } = resolveListLimitOffset(options);
5760
+ const root = getEventByExternalRef(db, source, id);
5761
+ if (!root) return [];
5762
+ const rows = db.prepare(`WITH RECURSIVE thread(id) AS (
5763
+ SELECT ? UNION SELECT e.id FROM events e JOIN thread t JOIN events p ON p.id=t.id
5764
+ 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=?))
5765
+ ) SELECT e.id FROM events e JOIN thread t ON e.id=t.id WHERE e.deleted_at IS NULL AND (? IS NULL OR e.channel=?) ORDER BY e.occurred_at,e.id LIMIT ? OFFSET ?`).all(
5766
+ root.id,
5767
+ root.externalRef?.source ?? null,
5768
+ root.externalRef?.id ?? null,
5769
+ options?.channel ?? null,
5770
+ options?.channel?.trim().toLowerCase() ?? null,
5771
+ limit,
5772
+ offset
5773
+ );
5774
+ return batchLoadEventRecords(
5775
+ db,
5776
+ rows.map((r) => r.id),
5777
+ options?.includeContent ?? false
5778
+ );
5779
+ }
5780
+
4933
5781
  // src/soul.ts
4934
5782
  var soul_exports = {};
4935
5783
  __export(soul_exports, {
@@ -5136,6 +5984,15 @@ var identifyAndLocateContactsSkill = defineAffinitySkill({
5136
5984
  description: "Find the right contact reliably, prefer stable natural keys when possible, and avoid creating duplicates through weak lookup discipline.",
5137
5985
  content: `# Identify And Locate Contacts
5138
5986
 
5987
+ Handle identities normalize leading @ and case. Bluesky uses a domain handle,
5988
+ X/Twitter uses 1\u201315 letters/digits/underscores, Mastodon uses user@host.tld.
5989
+ Native did (alias atproto_did) validates did:<method>:<identifier>; legacy
5990
+ custom:atproto-did locators find the same identity. Keep old handles as identities.
5991
+ An owned identity makes contact creation fail atomically with conflict and
5992
+ existingContactId. input.ifExists="return" returns the matching contact and
5993
+ matchedIdentities without attaching any free identities. CLI: --if-exists return.
5994
+ Name-only possible duplicates still warn. Search @handle is always literal.
5995
+
5139
5996
  Primary tools:
5140
5997
  - \`search_affinity\`
5141
5998
  - \`inspect_affinity_item\`
@@ -5193,6 +6050,31 @@ var importHistoryWithoutFakingEvidenceSkill = defineAffinitySkill({
5193
6050
  description: "Import pre-existing relationship state honestly, using seeded links for known history instead of inventing fake journal evidence.",
5194
6051
  content: `# Import History Without Faking Evidence
5195
6052
 
6053
+ For social imports, use native did identities (atproto_did is an alias; legacy
6054
+ custom:atproto-did locators remain equivalent). Create with input.ifExists="return"
6055
+ for safe retries: this attaches nothing to an existing contact. For handle changes,
6056
+ call manage_identity add separately with input.ifExists="return"; an identity already
6057
+ on that contact is a no-op, while another owner still conflicts. Seed links with
6058
+ input.ifExists="return" to reuse them without changing their state.
6059
+ Use ghostpaw-affinity batch [--chunk 500] with JSONL {tool,args}, or
6060
+ {op:"event",kind:"interaction",input:{...}}. Per-line savepoints isolate failures;
6061
+ --stop-on-error stops early. Exit 2 identifies malformed JSON; other failures exit 1.
6062
+ Use externalRef {source:"bluesky",id:recordUri}, verbatim content and channel.
6063
+ Participant contact locators may use identities, including did, instead of ids.
6064
+ Read JSON flags from stdin with --input - or from @file for large payloads (E2BIG).
6065
+ SQLite date-times without offsets mean UTC; microseconds round to milliseconds.
6066
+ Missing inbound-follow timestamps should use a known first_seen or be counted as skipped.
6067
+ The synthetic scripts/examples/import_social_jsonl.mjs and import_dry_run.mjs
6068
+ show the mapping and verify an idempotent retry and byte-identical text.
6069
+
6070
+ For archived messages with known timestamps, record actual evidence with externalRef:
6071
+ {source, id} deduplicates retries across event families and commitments. Keep original
6072
+ message text in content, use channel and parentRef/threadRef for context, and keep
6073
+ summary short or let it derive from content. Content is limited to 1 MB UTF-8;
6074
+ keep larger raw archives in the source system. Inspect events by external reference,
6075
+ search scope "events", and use manage_event revise/delete to correct imports.
6076
+ See the tools guide for field details. Existing 0.5.x databases upgrade in place.
6077
+
5196
6078
  Primary tools:
5197
6079
  - \`manage_contact\`
5198
6080
  - \`manage_identity\`
@@ -5249,6 +6131,14 @@ var managePromisesAndAgreementsSkill = defineAffinitySkill({
5249
6131
  description: "Track obligations explicitly from creation to resolution so open commitments remain actionable and trust consequences stay honest.",
5250
6132
  content: `# Manage Promises And Agreements
5251
6133
 
6134
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6135
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d).
6136
+ Date-only values keep their day with UTC boundaries; keywords use the process
6137
+ local date. Read occurredOn/dueOn and review occursOn for calendar dates;
6138
+ *Iso fields stay UTC. Future events warn and count as contact only when their
6139
+ time and day arrive. Commitments and resolutions never reset recency/cadence.
6140
+ Radar explains overdue promises and drift (gap > 1.5 times cadence).
6141
+
5252
6142
  Primary tools:
5253
6143
  - \`manage_commitment\`
5254
6144
  - \`review_affinity\`
@@ -5304,6 +6194,14 @@ var manageRecurringDatesAndRemindersSkill = defineAffinitySkill({
5304
6194
  description: "Use date anchors for recurring salience so important birthdays, anniversaries, renewals, and memorials feed review surfaces cleanly.",
5305
6195
  content: `# Manage Recurring Dates And Reminders
5306
6196
 
6197
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6198
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d).
6199
+ Date-only values keep their day with UTC boundaries; keywords use the process
6200
+ local date. Read occurredOn/dueOn and review occursOn for calendar dates;
6201
+ *Iso fields stay UTC. Future events warn and count as contact only when their
6202
+ time and day arrive. Commitments and resolutions never reset recency/cadence.
6203
+ Radar explains overdue promises and drift (gap > 1.5 times cadence).
6204
+
5307
6205
  Primary tools:
5308
6206
  - \`manage_date_anchor\`
5309
6207
  - \`review_affinity\`
@@ -5415,6 +6313,24 @@ var recordDirectEvidenceWellSkill = defineAffinitySkill({
5415
6313
  description: "Record direct owner-facing social evidence in a way that produces truthful mechanics, good moments, and useful future review surfaces.",
5416
6314
  content: `# Record Direct Evidence Well
5417
6315
 
6316
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6317
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d). Date-only inputs
6318
+ preserve the calendar day at UTC midnight (UTC end of day for dueAt).
6319
+ Keywords use the process local date. Read occurredOn/dueOn and review occursOn
6320
+ for calendar dates; *Iso fields remain UTC. Future events carry future_event
6321
+ warnings and do not count for recency, cadence or drift until their time and day
6322
+ arrive. Commitments and resolutions never reset contact recency or cadence.
6323
+
6324
+ Imported text and event maintenance:
6325
+ - Use externalRef: {source, id} for idempotent intake; retries return no_op and the existing id.
6326
+ - 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.
6327
+ - Use channel for the medium, parentRef or parentEventId for replies, and threadRef for the root. Event attributes use target {kind: "event", eventId}.
6328
+ - Inspect kind "event" by eventId or ref. Search scope "events" or "all" discovers event ids; default scope remains contacts.
6329
+ - Review journals/timelines with includeContent: true only when needed; CLI uses --with-content. Filter with channel. Review events.thread by ref for chronological context.
6330
+ - 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.
6331
+ - Use manage_event delete with confirm: true to remove incorrect evidence and recompute affected relationship scores.
6332
+ - 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.
6333
+
5418
6334
  Primary tools:
5419
6335
  - \`record_event\`
5420
6336
  - \`inspect_affinity_item\`
@@ -5473,6 +6389,16 @@ var recordObservationsAndReferralsSkill = defineAffinitySkill({
5473
6389
  description: "Capture third-party and referral truth without overclaiming intimacy, directness, or trust that the operator did not actually earn.",
5474
6390
  content: `# Record Observations And Referrals
5475
6391
 
6392
+ Likes, reposts and follows are ambient observations, not milestones. Record them
6393
+ with affectsBond:false; observations with significance <=2 default to false.
6394
+ They stay in journals but do not change affinity or meaningful-contact recency.
6395
+ provenance.ambient:true also suppresses effects. Use explicit owner and other
6396
+ participants, actor/recipient roles, and the correct directionality.
6397
+ Unanswered owner_initiated evidence contributes much less and does not reset
6398
+ radar recency. A relationship with only outbound activity gets no routine check-in
6399
+ or drift prompt. other_initiated evidence counts fully. Observations and follow-like
6400
+ milestones never derive breakthroughs; low-significance milestones derive no moment.
6401
+
5476
6402
  Primary tools:
5477
6403
  - \`record_event\`
5478
6404
  - \`review_affinity\`
@@ -5582,6 +6508,19 @@ var reviewRadarProgressionAndGraphSkill = defineAffinitySkill({
5582
6508
  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
6509
  content: `# Review Radar, Progression, And Graph
5584
6510
 
6511
+ List reviews accept limit (default 50, maximum 250) and offset. Results include
6512
+ total and hasMore; continue paging while hasMore is true. Event type array flags
6513
+ accept JSON arrays, comma lists, or repeated --event-types flags; invalid types
6514
+ report allowed values with exit 2. Use skill --short for a compact CLI reference.
6515
+
6516
+ CLI and MCP share epoch ms, ISO with Z/offset, YYYY-MM-DD, DD.MM.YYYY,
6517
+ today/yesterday/tomorrow and signed day offsets (+3d/-2d).
6518
+ Date-only values keep their day with UTC boundaries; keywords use the process
6519
+ local date. Read occurredOn/dueOn and review occursOn for calendar dates;
6520
+ *Iso fields stay UTC. Future events warn and count as contact only when their
6521
+ time and day arrive. Commitments and resolutions never reset recency/cadence.
6522
+ Radar explains overdue promises and drift (gap > 1.5 times cadence).
6523
+
5585
6524
  Primary tools:
5586
6525
  - \`review_affinity\`
5587
6526
  - \`inspect_affinity_item\`
@@ -5682,6 +6621,9 @@ __export(tools_exports, {
5682
6621
  manageDuplicateDismissalTool: () => manageDuplicateDismissalTool,
5683
6622
  manageDuplicateDismissalToolHandler: () => manageDuplicateDismissalToolHandler,
5684
6623
  manageDuplicateDismissalToolName: () => manageDuplicateDismissalToolName,
6624
+ manageEventTool: () => manageEventTool,
6625
+ manageEventToolHandler: () => manageEventToolHandler,
6626
+ manageEventToolName: () => manageEventToolName,
5685
6627
  manageIdentityTool: () => manageIdentityTool,
5686
6628
  manageIdentityToolHandler: () => manageIdentityToolHandler,
5687
6629
  manageIdentityToolName: () => manageIdentityToolName,
@@ -5726,43 +6668,14 @@ __export(tools_exports, {
5726
6668
  toolFailure: () => toolFailure,
5727
6669
  toolNeedsClarification: () => toolNeedsClarification,
5728
6670
  toolNoOp: () => toolNoOp,
5729
- toolSuccess: () => toolSuccess,
5730
- toolWarning: () => toolWarning,
5731
- withToolHandling: () => withToolHandling
5732
- });
5733
-
5734
- // 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
- 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();
6671
+ toolSuccess: () => toolSuccess,
6672
+ toolWarning: () => toolWarning,
6673
+ withToolHandling: () => withToolHandling
6674
+ });
6675
+
6676
+ // src/tools/parse_time_input.ts
6677
+ function parseTimeInput(value, field = "time", now = Date.now()) {
6678
+ return parseCalendarTime(value, field, now).timestamp;
5766
6679
  }
5767
6680
 
5768
6681
  // src/tools/tool_metadata.ts
@@ -5846,6 +6759,39 @@ function oneOfSchema(schemas, description) {
5846
6759
  return schema;
5847
6760
  }
5848
6761
 
6762
+ // src/tools/event_metadata_schema.ts
6763
+ var externalRefSchema = objectSchema(
6764
+ {
6765
+ source: stringSchema("External source; trimmed and lowercased."),
6766
+ id: stringSchema("Non-empty external id.")
6767
+ },
6768
+ ["source", "id"]
6769
+ );
6770
+ var eventMetadataSchema = {
6771
+ affectsBond: booleanSchema(
6772
+ "Whether evidence affects the relationship; defaults false for observations with significance <=2."
6773
+ ),
6774
+ content: stringSchema(
6775
+ "Verbatim text, including empty text; at most 1 MB UTF-8. Summary may be omitted when content is supplied."
6776
+ ),
6777
+ externalRef: externalRefSchema,
6778
+ channel: stringSchema(
6779
+ "Free-text channel, trimmed and lowercased, at most 40 characters."
6780
+ ),
6781
+ parentRef: externalRefSchema,
6782
+ parentEventId: integerSchema("Parent event id."),
6783
+ threadRef: externalRefSchema,
6784
+ momentKind: enumSchema("Optional moment kind.", [
6785
+ "breakthrough",
6786
+ "rupture",
6787
+ "reconciliation",
6788
+ "milestone",
6789
+ "turning_point"
6790
+ ]),
6791
+ occurredOn: stringSchema("Calendar day."),
6792
+ provenance: {}
6793
+ };
6794
+
5849
6795
  // src/tools/tool_names.ts
5850
6796
  var searchAffinityToolName = "search_affinity";
5851
6797
  var reviewAffinityToolName = "review_affinity";
@@ -5859,6 +6805,7 @@ var recordEventToolName = "record_event";
5859
6805
  var manageCommitmentToolName = "manage_commitment";
5860
6806
  var manageDateAnchorToolName = "manage_date_anchor";
5861
6807
  var manageAttributeToolName = "manage_attribute";
6808
+ var manageEventToolName = "manage_event";
5862
6809
 
5863
6810
  // src/tools/tool_ref.ts
5864
6811
  function contactState(value) {
@@ -5918,7 +6865,7 @@ function toEventEntityRef(value) {
5918
6865
  return {
5919
6866
  kind: "event",
5920
6867
  id: value.id,
5921
- title: value.summary,
6868
+ title: deriveEventSummary(value.summary),
5922
6869
  subtitle: value.type,
5923
6870
  state: value.type
5924
6871
  };
@@ -5966,16 +6913,6 @@ function toReceiptEntityRefs(receipt) {
5966
6913
  });
5967
6914
  }
5968
6915
 
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
6916
  // src/tools/tool_types.ts
5980
6917
  function withOptionalFields(result, options) {
5981
6918
  if (options.next && options.next.length > 0) {
@@ -6091,7 +7028,7 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
6091
7028
  const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
6092
7029
  return {
6093
7030
  ok: false,
6094
- result: toolFailure("domain", "invalid_state", message, message)
7031
+ result: toolFailure("domain", "conflict", message, message)
6095
7032
  };
6096
7033
  }
6097
7034
  return { ok: true, value: row2 };
@@ -6116,9 +7053,13 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
6116
7053
  INNER JOIN contacts c ON c.id = i.contact_id
6117
7054
  WHERE i.removed_at IS NULL
6118
7055
  AND c.deleted_at IS NULL
6119
- AND i.normalized_key = ?
7056
+ AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))
6120
7057
  LIMIT 2`
6121
- ).all(normalizedKey);
7058
+ ).all(
7059
+ normalizedKey,
7060
+ legacyIdentityKey(normalizedKey),
7061
+ normalizedKey
7062
+ );
6122
7063
  if (row.length === 0) {
6123
7064
  return {
6124
7065
  ok: false,
@@ -6269,13 +7210,21 @@ function resolveLinkLocator(db, locator, field = "link") {
6269
7210
  return { ok: true, value: matchedLink };
6270
7211
  }
6271
7212
  function mapErrorToToolFailure(error, summary = "The tool failed.") {
7213
+ if (error && typeof error === "object" && "ok" in error && error.ok === false && "outcome" in error && error.outcome === "error")
7214
+ return error;
6272
7215
  if (isAffinityError(error)) {
6273
- return toolFailure(
7216
+ const result = toolFailure(
6274
7217
  "domain",
6275
7218
  affinityErrorCodeToToolCode(error),
6276
7219
  summary,
6277
7220
  error.message
6278
7221
  );
7222
+ const id = error.existingContactId;
7223
+ if (id !== void 0) {
7224
+ result.data = { existingContactId: id };
7225
+ result.error.details = { existingContactId: id };
7226
+ }
7227
+ return result;
6279
7228
  }
6280
7229
  if (error instanceof Error) {
6281
7230
  return toolFailure("system", "system_error", summary, error.message);
@@ -6314,27 +7263,32 @@ function affinityErrorCodeToToolCode(error) {
6314
7263
  }
6315
7264
 
6316
7265
  // src/tools/validate_tool_input.ts
6317
- function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
7266
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now(), strict = false) {
7267
+ if (path.endsWith(".occurredAt") && value == null)
7268
+ return "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)";
7269
+ const branches = schema.oneOf ?? schema.anyOf;
7270
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && value !== null && typeof value !== "string")
7271
+ return `attribute values are strings or null; pass "${String(value)}" to store it as text`;
6318
7272
  if (schema.anyOf || schema.oneOf) {
6319
7273
  const choices = schema.anyOf ?? schema.oneOf ?? [];
6320
7274
  return choices.some(
6321
- (choice) => validateToolInput(choice, value, path, injectedNow) === null
7275
+ (choice) => validateToolInput(choice, value, path, injectedNow, strict) === null
6322
7276
  ) ? null : `${path}: invalid value`;
6323
7277
  }
6324
7278
  if (schema.type === "object") {
6325
7279
  if (value === null || typeof value !== "object" || Array.isArray(value))
6326
7280
  return `${path}: missing input or expected object`;
6327
7281
  const object = value;
6328
- if (path.endsWith(".patch") && schema.additionalProperties === false) {
7282
+ if ((strict || path.endsWith(".patch")) && schema.additionalProperties === false) {
6329
7283
  for (const key of Object.keys(object)) {
6330
7284
  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(", ")}`;
7285
+ 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
7286
  }
6333
7287
  }
6334
7288
  if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
6335
7289
  return `${path}.patch: missing required field`;
6336
7290
  if (object.patch !== void 0 && !schema.properties?.patch)
6337
- return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
7291
+ return `${path}.patch: unknown key; allowed keys: none (use the action's named fields)`;
6338
7292
  const referenceNow = object.now === void 0 ? injectedNow : (() => {
6339
7293
  try {
6340
7294
  return parseTimeInput(
@@ -6349,11 +7303,14 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
6349
7303
  for (const [key, child] of Object.entries(schema.properties ?? {})) {
6350
7304
  if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
6351
7305
  try {
6352
- object[key] = parseTimeInput(
7306
+ const parsed = parseCalendarTime(
6353
7307
  object[key],
6354
7308
  `${path}.${key}`,
6355
7309
  referenceNow
6356
7310
  );
7311
+ object[key] = parsed.timestamp;
7312
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key))
7313
+ object[key.replace(/At$/, "On")] ??= parsed.day;
6357
7314
  } catch (error) {
6358
7315
  return error.message;
6359
7316
  }
@@ -6369,7 +7326,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
6369
7326
  child,
6370
7327
  object[key],
6371
7328
  `${path}.${key}`,
6372
- referenceNow
7329
+ referenceNow,
7330
+ strict
6373
7331
  );
6374
7332
  if (error) return error;
6375
7333
  }
@@ -6382,7 +7340,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
6382
7340
  schema.items ?? {},
6383
7341
  item,
6384
7342
  `${path}[${index}]`,
6385
- injectedNow
7343
+ injectedNow,
7344
+ strict
6386
7345
  );
6387
7346
  if (error) return error;
6388
7347
  }
@@ -6448,6 +7407,26 @@ function inspectAffinityItemToolHandler(db, rawInput) {
6448
7407
  inputError
6449
7408
  );
6450
7409
  return withToolHandling(() => {
7410
+ if (input.kind === "event") {
7411
+ if (input.eventId === void 0 === (input.ref === void 0))
7412
+ return toolFailure(
7413
+ "protocol",
7414
+ "invalid_input",
7415
+ "Invalid event target.",
7416
+ "Provide exactly one of eventId or ref."
7417
+ );
7418
+ const detail2 = input.ref ? getEventByExternalRef(db, input.ref.source, input.ref.id) : getEventById(db, input.eventId);
7419
+ return detail2 ? toolSuccess(
7420
+ `Loaded event #${detail2.id}.`,
7421
+ { kind: "event", detail: detail2 },
7422
+ { entities: [{ kind: "event", id: detail2.id }] }
7423
+ ) : toolFailure(
7424
+ "domain",
7425
+ "not_found",
7426
+ "Event not found.",
7427
+ "No event matches the target."
7428
+ );
7429
+ }
6451
7430
  if (input.kind === "owner_profile") {
6452
7431
  const detail2 = getOwnerProfile(
6453
7432
  db,
@@ -6545,10 +7524,13 @@ var inspectAffinityItemTool = defineAffinityTool({
6545
7524
  inputSchema: objectSchema(
6546
7525
  {
6547
7526
  kind: enumSchema("What to inspect.", [
7527
+ "event",
6548
7528
  "owner_profile",
6549
7529
  "contact_profile",
6550
7530
  "link"
6551
7531
  ]),
7532
+ eventId: integerSchema("Exact event id."),
7533
+ ref: externalRefSchema,
6552
7534
  contact: contactLocatorSchema(
6553
7535
  "Target contact. Required when kind=contact_profile. Provide contactId or identity."
6554
7536
  ),
@@ -6567,6 +7549,7 @@ var inspectAffinityItemTool = defineAffinityTool({
6567
7549
  // src/attributes/helpers.ts
6568
7550
  function clearedAttributePrimary(target) {
6569
7551
  return {
7552
+ ...target.kind === "event" ? { eventId: target.eventId } : {},
6570
7553
  id: 0,
6571
7554
  contactId: target.kind === "contact" ? target.id : null,
6572
7555
  linkId: target.kind === "link" ? target.id : null,
@@ -6578,6 +7561,7 @@ function clearedAttributePrimary(target) {
6578
7561
  // src/attributes/mappers.ts
6579
7562
  function mapAttributeRowToAttributeRecord(row) {
6580
7563
  return {
7564
+ ...row.event_id == null ? {} : { eventId: row.event_id },
6581
7565
  id: row.id,
6582
7566
  contactId: row.contact_id,
6583
7567
  linkId: row.link_id,
@@ -6587,49 +7571,26 @@ function mapAttributeRowToAttributeRecord(row) {
6587
7571
  }
6588
7572
 
6589
7573
  // src/attributes/queries.ts
7574
+ function attributeTargetSql(target) {
7575
+ return {
7576
+ column: target.kind === "event" ? "event_id" : target.kind === "contact" ? "contact_id" : "link_id",
7577
+ id: target.kind === "event" ? target.eventId : target.id
7578
+ };
7579
+ }
6590
7580
  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;
7581
+ return db.prepare("SELECT * FROM attributes WHERE id=?").get(id) ?? null;
6599
7582
  }
6600
7583
  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;
7584
+ const t = attributeTargetSql(target);
7585
+ return db.prepare(
7586
+ `SELECT * FROM attributes WHERE ${t.column}=? AND name=? AND deleted_at IS NULL`
7587
+ ).get(t.id, name) ?? null;
6621
7588
  }
6622
7589
  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);
7590
+ const t = attributeTargetSql(target);
7591
+ return db.prepare(
7592
+ `SELECT id FROM attributes WHERE ${t.column}=? AND deleted_at IS NULL ORDER BY id`
7593
+ ).all(t.id).map((r) => r.id);
6633
7594
  }
6634
7595
 
6635
7596
  // src/attributes/receipts.ts
@@ -6651,6 +7612,8 @@ function normalizeAttributeValue(value) {
6651
7612
  if (value === null) {
6652
7613
  return null;
6653
7614
  }
7615
+ if (typeof value !== "string")
7616
+ throw new AffinityValidationError("attribute values are strings or null");
6654
7617
  const trimmed = value.trim();
6655
7618
  return trimmed.length === 0 ? null : trimmed;
6656
7619
  }
@@ -6679,6 +7642,11 @@ function validateAttributeEntries(entries) {
6679
7642
  return out;
6680
7643
  }
6681
7644
  function assertAttributeTargetWritable(db, target) {
7645
+ if (target.kind === "event") {
7646
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(target.eventId))
7647
+ throw new AffinityNotFoundError("event not found");
7648
+ return;
7649
+ }
6682
7650
  if (target.kind === "contact") {
6683
7651
  const contact = getContactRowById(db, target.id);
6684
7652
  if (!contact) {
@@ -6714,26 +7682,14 @@ function replaceAttributes(db, target, entries) {
6714
7682
  }
6715
7683
  const created = [];
6716
7684
  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
- }
7685
+ const t = attributeTargetSql(target);
7686
+ const insert = db.prepare(
7687
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
7688
+ );
7689
+ for (const e of normalized) {
7690
+ const r = insert.run(t.id, e.name, e.value, now, now);
7691
+ lastId = Number(r.lastInsertRowid);
7692
+ created.push({ kind: "attribute", id: lastId });
6737
7693
  }
6738
7694
  if (normalized.length === 0) {
6739
7695
  return buildAttributeMutationReceipt(clearedAttributePrimary(target), {
@@ -6778,27 +7734,10 @@ function setAttribute(db, target, name, value) {
6778
7734
  }
6779
7735
  );
6780
7736
  }
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
- }
7737
+ const t = attributeTargetSql(target);
6798
7738
  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);
7739
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
7740
+ ).run(t.id, key, stored, now, now);
6802
7741
  const id = Number(r.lastInsertRowid);
6803
7742
  const row = getAttributeRowById(db, id);
6804
7743
  if (!row) {
@@ -6854,14 +7793,15 @@ function mutationToolResult(action, receipt, entitySummary, fields) {
6854
7793
  affectedLinks: receipt.affectedLinks,
6855
7794
  derivedEffects: receipt.derivedEffects
6856
7795
  };
6857
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
7796
+ 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
7797
  if (changedCount === 0) {
6859
7798
  return toolNoOp(summary, data, {
6860
7799
  entities: toReceiptEntityRefs(receipt)
6861
7800
  });
6862
7801
  }
6863
7802
  return toolSuccess(summary, data, {
6864
- entities: toReceiptEntityRefs(receipt)
7803
+ entities: toReceiptEntityRefs(receipt),
7804
+ warnings: receipt.warnings
6865
7805
  });
6866
7806
  }
6867
7807
  var PLURALS = { identity: "identities" };
@@ -6918,6 +7858,7 @@ function linkLocatorSchema2(description) {
6918
7858
  };
6919
7859
  }
6920
7860
  function resolveAttributeTarget(db, target) {
7861
+ if (target.kind === "event") return { kind: "ok", target };
6921
7862
  if (target.kind === "contact") {
6922
7863
  const contact = resolveContactLocator(db, target.contact, "target.contact");
6923
7864
  if (!contact.ok) {
@@ -6977,7 +7918,7 @@ function manageAttributeToolHandler(db, rawInput) {
6977
7918
  }
6978
7919
  var manageAttributeTool = defineAffinityTool({
6979
7920
  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.",
7921
+ 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
7922
  whenToUse: "Use this for metadata such as tags, notes, or small labeled facts attached to a contact or link.",
6982
7923
  whenNotToUse: "Do not use this for core contact fields, identities, or direct relationship mechanics.",
6983
7924
  sideEffects: "writes_state",
@@ -6998,7 +7939,8 @@ var manageAttributeTool = defineAffinityTool({
6998
7939
  action: enumSchema("Operation to perform.", ["set", "unset", "replace"]),
6999
7940
  target: objectSchema(
7000
7941
  {
7001
- kind: enumSchema("Target kind.", ["contact", "link"]),
7942
+ kind: enumSchema("Target kind.", ["contact", "link", "event"]),
7943
+ eventId: integerSchema("Target event id."),
7002
7944
  contact: contactLocatorSchema2(
7003
7945
  "Contact locator. Required when kind=contact. Provide contactId or identity."
7004
7946
  ),
@@ -7028,7 +7970,7 @@ var manageAttributeTool = defineAffinityTool({
7028
7970
  removedAt: integerSchema("Removal timestamp. Optional for action=unset.")
7029
7971
  },
7030
7972
  ["action"],
7031
- "Set, unset, or replace attributes on contacts and links."
7973
+ "Set, unset, or replace attributes on contacts, links and events."
7032
7974
  ),
7033
7975
  handler: manageAttributeToolHandler
7034
7976
  });
@@ -7060,7 +8002,13 @@ function prepareParticipants(db, raw) {
7060
8002
  }
7061
8003
  function manageCommitmentToolHandler(db, rawInput) {
7062
8004
  const input = structuredClone(rawInput);
7063
- const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
8005
+ const inputError = validateToolInput(
8006
+ manageCommitmentTool.inputSchema,
8007
+ input,
8008
+ "input",
8009
+ Date.now(),
8010
+ true
8011
+ );
7064
8012
  if (inputError)
7065
8013
  return toolFailure(
7066
8014
  "protocol",
@@ -7090,6 +8038,21 @@ function manageCommitmentToolHandler(db, rawInput) {
7090
8038
  "commitment"
7091
8039
  );
7092
8040
  }
8041
+ if (!Number.isInteger(input.commitmentEventId))
8042
+ return toolFailure(
8043
+ "protocol",
8044
+ "invalid_input",
8045
+ "Missing commitment.",
8046
+ "commitmentEventId is required for resolve."
8047
+ );
8048
+ const event = db.prepare("SELECT type FROM events WHERE id = ? AND deleted_at IS NULL").get(input.commitmentEventId);
8049
+ if (event && !["promise", "agreement"].includes(event.type))
8050
+ return toolFailure(
8051
+ "domain",
8052
+ "invalid_input",
8053
+ "Invalid commitment.",
8054
+ "not a commitment event"
8055
+ );
7093
8056
  return mutationToolResult(
7094
8057
  "resolve",
7095
8058
  resolveCommitment(
@@ -7128,15 +8091,21 @@ var manageCommitmentTool = defineAffinityTool({
7128
8091
  "promise",
7129
8092
  "agreement"
7130
8093
  ]),
8094
+ ...eventMetadataSchema,
7131
8095
  occurredAt: integerSchema(
7132
8096
  "When the commitment was made, Unix timestamp in milliseconds."
7133
8097
  ),
7134
8098
  summary: stringSchema("Commitment summary. Must be non-empty."),
7135
8099
  significance: integerSchema("Commitment significance from 1 to 10."),
7136
8100
  dueAt: integerSchema("Optional due timestamp."),
8101
+ dueOn: stringSchema("Due calendar day."),
7137
8102
  participants: arraySchema(
7138
8103
  objectSchema(
7139
8104
  {
8105
+ contact: objectSchema(
8106
+ { contactId: integerSchema("Contact id.") },
8107
+ ["contactId"]
8108
+ ),
7140
8109
  contactId: integerSchema(
7141
8110
  "Participant contact id. The owner contact MUST be included."
7142
8111
  ),
@@ -7149,13 +8118,7 @@ var manageCommitmentTool = defineAffinityTool({
7149
8118
  ),
7150
8119
  now: integerSchema("Optional timestamp override.")
7151
8120
  },
7152
- [
7153
- "commitmentType",
7154
- "occurredAt",
7155
- "summary",
7156
- "significance",
7157
- "participants"
7158
- ],
8121
+ ["commitmentType", "occurredAt", "significance", "participants"],
7159
8122
  "Commitment payload. Required when action=record."
7160
8123
  ),
7161
8124
  commitmentEventId: integerSchema(
@@ -7170,6 +8133,19 @@ var manageCommitmentTool = defineAffinityTool({
7170
8133
  now: integerSchema("Optional timestamp."),
7171
8134
  resolvingEvent: objectSchema(
7172
8135
  {
8136
+ occurredOn: stringSchema("Calendar day."),
8137
+ provenance: {},
8138
+ participants: arraySchema(
8139
+ objectSchema(
8140
+ {
8141
+ contactId: integerSchema("Contact id."),
8142
+ role: stringSchema("Role."),
8143
+ directionality: stringSchema("Directionality.")
8144
+ },
8145
+ ["contactId", "role"]
8146
+ ),
8147
+ "Resolving participants."
8148
+ ),
7173
8149
  type: stringSchema("Optional resolving event type."),
7174
8150
  occurredAt: integerSchema("Optional resolving event timestamp."),
7175
8151
  summary: stringSchema("Optional resolving event summary."),
@@ -7182,227 +8158,75 @@ var manageCommitmentTool = defineAffinityTool({
7182
8158
  },
7183
8159
  [],
7184
8160
  "Optional metadata."
7185
- )
7186
- },
7187
- ["action"],
7188
- "Record or resolve one commitment."
7189
- ),
7190
- 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
- }
8161
+ )
8162
+ },
8163
+ ["action"],
8164
+ "Record or resolve one commitment."
8165
+ ),
8166
+ handler: manageCommitmentToolHandler
8167
+ });
7358
8168
 
7359
- // src/identities/add_identity.ts
7360
- function addIdentity(db, contactId, input) {
8169
+ // src/contacts/delete_contact.ts
8170
+ function deleteContact(db, locatorId, options) {
8171
+ if (options?.confirm !== true)
8172
+ throw new AffinityValidationError(
8173
+ "delete is permanent; pass confirm: true"
8174
+ );
7361
8175
  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
8176
+ const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
8177
+ if (!contact) throw new AffinityNotFoundError("contact not found");
8178
+ if (contact.is_owner)
8179
+ throw new AffinityStateError("The owner contact cannot be deleted");
8180
+ const tables = [
8181
+ "contacts",
8182
+ "identities",
8183
+ "attributes",
8184
+ "links",
8185
+ "events",
8186
+ "event_participants",
8187
+ "open_commitments",
8188
+ "contact_merges",
8189
+ "dismissed_duplicates",
8190
+ "link_event_effects",
8191
+ "upcoming_occurrences",
8192
+ "link_rollups",
8193
+ "contact_rollups"
8194
+ ];
8195
+ const before = new Map(
8196
+ tables.map((table) => [
8197
+ table,
8198
+ db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
8199
+ ])
7393
8200
  );
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);
8201
+ db.prepare(
8202
+ "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))"
8203
+ ).run(locatorId, locatorId);
8204
+ db.prepare(
8205
+ "DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
8206
+ ).run(locatorId, locatorId);
7400
8207
  db.prepare(
7401
8208
  "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
- });
8209
+ ).run(locatorId, locatorId);
8210
+ db.prepare(
8211
+ "UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
8212
+ ).run(locatorId);
8213
+ db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
8214
+ const now = Date.now();
8215
+ const affectedLinks = db.prepare(
8216
+ "SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
8217
+ ).all().map((row) => row.id);
8218
+ refreshAllBridgeScores(db, now);
8219
+ for (const id of affectedLinks) refreshLinkRollup(db, id, now);
8220
+ for (const row of db.prepare("SELECT id FROM contacts").all())
8221
+ refreshContactRollup(db, Number(row.id), now);
8222
+ const removed = tables.map((table) => {
8223
+ const remaining = new Set(
8224
+ db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
8225
+ );
8226
+ const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
8227
+ return { entity: table, count: ids.length, ids };
8228
+ }).filter((item) => item.count > 0);
8229
+ return { contactId: locatorId, removed, affectedLinks };
7406
8230
  });
7407
8231
  }
7408
8232
 
@@ -7410,27 +8234,20 @@ function addIdentity(db, contactId, input) {
7410
8234
  function createContactToolResult(db, input) {
7411
8235
  return withTransaction(db, () => {
7412
8236
  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
8237
  const result = mutationToolResult("create", receipt, "contact");
8238
+ if (result.ok && receipt.outcome === "no_op") {
8239
+ result.outcome = "no_op";
8240
+ Object.assign(result.data, {
8241
+ matchedIdentities: receipt.matchedIdentities
8242
+ });
8243
+ return result;
8244
+ }
7428
8245
  const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
7429
8246
  const warnings = db.prepare(
7430
8247
  "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
7431
8248
  ).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 ? [] : [
8249
+ const score = fold(row.name) === fold(input.name) ? 0.92 : fuzzyNameScore(row.name, input.name);
8250
+ return score <= 0 ? [] : [
7434
8251
  {
7435
8252
  code: "possible_duplicate",
7436
8253
  message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
@@ -7513,6 +8330,13 @@ function manageContactToolHandler(db, rawInput) {
7513
8330
  return contact.result;
7514
8331
  }
7515
8332
  if (input.action === "delete") {
8333
+ if (contact.value.is_owner)
8334
+ return toolFailure(
8335
+ "domain",
8336
+ "conflict",
8337
+ "Cannot delete owner.",
8338
+ "The owner contact cannot be deleted"
8339
+ );
7516
8340
  const deletion = deleteContact(db, contact.value.id, { confirm: true });
7517
8341
  return toolSuccess("Permanently deleted contact.", {
7518
8342
  action: "delete",
@@ -7586,7 +8410,11 @@ var manageContactTool = defineAffinityTool({
7586
8410
  },
7587
8411
  ["type", "value"]
7588
8412
  ),
7589
- "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
8413
+ "Optional identities to attach. An identity owned by another live contact causes a conflict, or returns that contact when ifExists=return."
8414
+ ),
8415
+ ifExists: enumSchema(
8416
+ "Identity collision policy; return attaches nothing.",
8417
+ ["error", "return"]
7590
8418
  ),
7591
8419
  name: stringSchema("Contact name."),
7592
8420
  kind: enumSchema("Contact kind.", [
@@ -8005,7 +8833,10 @@ function reviseIdentity(db, identityId, patch) {
8005
8833
  });
8006
8834
  }
8007
8835
  const nextType = patch.type !== void 0 ? patch.type.trim() : row.type;
8008
- const nextValue = patch.value !== void 0 ? patch.value.trim() : row.value;
8836
+ const nextValue = normalizeIdentityValue(
8837
+ nextType,
8838
+ patch.value ?? row.value
8839
+ );
8009
8840
  if (nextType.length === 0 || nextValue.length === 0) {
8010
8841
  throw new AffinityValidationError("type and value must be non-empty");
8011
8842
  }
@@ -8064,6 +8895,10 @@ function contactLocatorSchema6(description) {
8064
8895
  contactId: integerSchema("Exact contact id."),
8065
8896
  identity: objectSchema(
8066
8897
  {
8898
+ ifExists: enumSchema(
8899
+ "Return an existing identity on this contact without changes.",
8900
+ ["error", "return"]
8901
+ ),
8067
8902
  type: stringSchema(
8068
8903
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
8069
8904
  ),
@@ -8160,6 +8995,10 @@ var manageIdentityTool = defineAffinityTool({
8160
8995
  ),
8161
8996
  input: objectSchema(
8162
8997
  {
8998
+ ifExists: enumSchema(
8999
+ "Return an existing identity on this contact without changes.",
9000
+ ["error", "return"]
9001
+ ),
8163
9002
  type: stringSchema(
8164
9003
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
8165
9004
  ),
@@ -8494,11 +9333,13 @@ var mergeContactsTool = defineAffinityTool({
8494
9333
  });
8495
9334
 
8496
9335
  // src/tools/record_event_tool.ts
8497
- function normalizeParticipant2(p) {
9336
+ function normalizeParticipant2(db, p) {
8498
9337
  let contactId = p.contactId;
8499
9338
  if (contactId === void 0 && p.contact != null) {
8500
9339
  const loc = p.contact;
8501
- contactId = loc.contactId;
9340
+ const resolved = resolveContactLocator(db, loc);
9341
+ if (!resolved.ok) throw resolved.result;
9342
+ contactId = resolved.value.id;
8502
9343
  }
8503
9344
  const result = {
8504
9345
  contactId,
@@ -8516,12 +9357,18 @@ function ensureOwnerParticipates2(db, participants) {
8516
9357
  return [{ contactId: ownerId, role: "actor" }, ...participants];
8517
9358
  }
8518
9359
  function prepareParticipants2(db, raw, injectOwner) {
8519
- const normalized = raw.map(normalizeParticipant2);
9360
+ const normalized = raw.map((p) => normalizeParticipant2(db, p));
8520
9361
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
8521
9362
  }
8522
9363
  function recordEventToolHandler(db, rawInput) {
8523
9364
  const input = structuredClone(rawInput);
8524
- const inputError = validateToolInput(recordEventTool.inputSchema, input);
9365
+ const inputError = validateToolInput(
9366
+ recordEventTool.inputSchema,
9367
+ input,
9368
+ "input",
9369
+ Date.now(),
9370
+ true
9371
+ );
8525
9372
  if (inputError)
8526
9373
  return toolFailure(
8527
9374
  "protocol",
@@ -8601,6 +9448,7 @@ var recordEventTool = defineAffinityTool({
8601
9448
  "correction"
8602
9449
  ]
8603
9450
  ),
9451
+ ...eventMetadataSchema,
8604
9452
  occurredAt: integerSchema(
8605
9453
  "When the event occurred, Unix timestamp in milliseconds."
8606
9454
  ),
@@ -8609,6 +9457,19 @@ var recordEventTool = defineAffinityTool({
8609
9457
  participants: arraySchema(
8610
9458
  objectSchema(
8611
9459
  {
9460
+ contact: objectSchema(
9461
+ {
9462
+ contactId: integerSchema("Contact id."),
9463
+ identity: objectSchema(
9464
+ {
9465
+ type: stringSchema("Identity type."),
9466
+ value: stringSchema("Identity value.")
9467
+ },
9468
+ ["type", "value"]
9469
+ )
9470
+ },
9471
+ []
9472
+ ),
8612
9473
  contactId: integerSchema(
8613
9474
  "Participant contact id. The owner contact MUST be included."
8614
9475
  ),
@@ -8632,7 +9493,7 @@ var recordEventTool = defineAffinityTool({
8632
9493
  ),
8633
9494
  now: integerSchema("Optional timestamp override.")
8634
9495
  },
8635
- ["occurredAt", "summary", "significance", "participants"],
9496
+ ["occurredAt", "significance", "participants"],
8636
9497
  "Event payload. Always required. Interaction events also require `type`."
8637
9498
  )
8638
9499
  },
@@ -8671,13 +9532,40 @@ function enrichReviewItem(db, item) {
8671
9532
  result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
8672
9533
  }
8673
9534
  if (Array.isArray(item.participants)) {
8674
- result.counterpartyNames = item.participants.map(
8675
- (p) => name(p.contactId)
9535
+ result.participants = item.participants.map(
9536
+ (p) => ({
9537
+ id: p.contactId,
9538
+ name: name(p.contactId),
9539
+ role: p.role
9540
+ })
8676
9541
  );
8677
- result.subtitle = [
8678
- item.subtitle,
8679
- result.counterpartyNames.join(", ")
8680
- ].filter(Boolean).join(" \xB7 ");
9542
+ for (const key of ["title", "subtitle", "snippet", "state", "eventId"])
9543
+ result[key] = void 0;
9544
+ }
9545
+ if (result.momentKind === null) result.momentKind = void 0;
9546
+ if (result.eventId === result.id) result.eventId = void 0;
9547
+ if (result.linkId === result.id) result.linkId = void 0;
9548
+ if (result.title === result.summary) result.title = void 0;
9549
+ if (item.kind === "link" && item.occursAt !== void 0) {
9550
+ for (const key of [
9551
+ "affinity",
9552
+ "trust",
9553
+ "rank",
9554
+ "recencyScore",
9555
+ "driftPriority",
9556
+ "readinessScore",
9557
+ "normalizedRank",
9558
+ "reciprocityScore",
9559
+ "radarScore",
9560
+ "subtitle",
9561
+ "snippet",
9562
+ "targetRef",
9563
+ "occursOnDate"
9564
+ ])
9565
+ result[key] = void 0;
9566
+ } else if (item.kind === "link") {
9567
+ result.recommendedReason = void 0;
9568
+ result.snippet = void 0;
8681
9569
  }
8682
9570
  if (typeof item.leftContactId === "number")
8683
9571
  result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
@@ -8706,6 +9594,8 @@ function summarizeCount(count, singular) {
8706
9594
  // src/tools/review_affinity_tool.ts
8707
9595
  function buildReadOptions(input) {
8708
9596
  return {
9597
+ ...input.includeContent === void 0 ? {} : { includeContent: input.includeContent },
9598
+ ...input.channel === void 0 ? {} : { channel: input.channel },
8709
9599
  ...input.now === void 0 ? {} : { now: input.now },
8710
9600
  ...input.limit === void 0 ? {} : { limit: input.limit },
8711
9601
  ...input.offset === void 0 ? {} : { offset: input.offset },
@@ -8842,7 +9732,7 @@ function momentItem(moment) {
8842
9732
  id: moment.eventId,
8843
9733
  title: moment.summary,
8844
9734
  subtitle: moment.momentKind,
8845
- snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
9735
+ snippet: moment.occurredOn ?? new Date(moment.occurredAt).toISOString().slice(0, 10)
8846
9736
  };
8847
9737
  }
8848
9738
  function commitmentItem(commitment) {
@@ -8852,7 +9742,7 @@ function commitmentItem(commitment) {
8852
9742
  id: commitment.eventId,
8853
9743
  title: commitment.summary,
8854
9744
  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)}`
9745
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : commitment.dueOn ?? new Date(commitment.dueAt).toISOString().slice(0, 10)}`
8856
9746
  };
8857
9747
  }
8858
9748
  function radarItem(radar) {
@@ -8868,6 +9758,7 @@ function radarItem(radar) {
8868
9758
  function upcomingDateItem(date) {
8869
9759
  return {
8870
9760
  ...date,
9761
+ occursOn: new Date(date.occursOn).toISOString().slice(0, 10),
8871
9762
  occursAt: date.occursOn,
8872
9763
  occursAtIso: new Date(date.occursOn).toISOString(),
8873
9764
  kind: date.targetRef.kind,
@@ -8905,6 +9796,9 @@ function compactFilters(input) {
8905
9796
  );
8906
9797
  }
8907
9798
  function reviewAffinityToolHandler(db, rawInput) {
9799
+ return reviewPage(db, rawInput, false);
9800
+ }
9801
+ function reviewPage(db, rawInput, pageOnly) {
8908
9802
  const input = structuredClone(rawInput);
8909
9803
  const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
8910
9804
  if (inputError)
@@ -8919,6 +9813,41 @@ function reviewAffinityToolHandler(db, rawInput) {
8919
9813
  if (invalid) {
8920
9814
  return invalid;
8921
9815
  }
9816
+ if (!pageOnly && input.view !== "graph.chart") {
9817
+ const limit = input.limit ?? 50;
9818
+ const offset = input.offset ?? 0;
9819
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
9820
+ return toolFailure(
9821
+ "protocol",
9822
+ "invalid_input",
9823
+ "Invalid pagination.",
9824
+ "limit must be 1..250; offset must be non-negative"
9825
+ );
9826
+ const all = [];
9827
+ let page;
9828
+ do {
9829
+ page = reviewPage(
9830
+ db,
9831
+ { ...input, limit: 250, offset: all.length },
9832
+ true
9833
+ );
9834
+ if (!page.ok) return page;
9835
+ all.push(...page.data.items);
9836
+ } while (page.data.items.length === 250);
9837
+ const items2 = all.slice(offset, offset + limit);
9838
+ return toolSuccess(
9839
+ summarizeCount(items2.length, "item"),
9840
+ {
9841
+ view: input.view,
9842
+ items: items2,
9843
+ count: items2.length,
9844
+ total: all.length,
9845
+ hasMore: offset + items2.length < all.length,
9846
+ appliedFilters: compactFilters(input)
9847
+ },
9848
+ { entities: items2.map(({ content: _content, ...item }) => item) }
9849
+ );
9850
+ }
8922
9851
  const options = buildReadOptions(input);
8923
9852
  let items = [];
8924
9853
  let chart;
@@ -8950,6 +9879,19 @@ function reviewAffinityToolHandler(db, rawInput) {
8950
9879
  options
8951
9880
  ).map(duplicateCandidateItem);
8952
9881
  break;
9882
+ case "events.thread": {
9883
+ if (!input.ref)
9884
+ return toolFailure(
9885
+ "protocol",
9886
+ "invalid_input",
9887
+ "Missing thread reference.",
9888
+ "ref is required for events.thread."
9889
+ );
9890
+ items = getEventThread(db, input.ref.source, input.ref.id, options).map(
9891
+ toEventListToolItem
9892
+ );
9893
+ break;
9894
+ }
8953
9895
  case "events.contact_journal": {
8954
9896
  const locator = input.contact;
8955
9897
  if (locator === void 0) {
@@ -9091,6 +10033,7 @@ function reviewAffinityToolHandler(db, rawInput) {
9091
10033
  items = listRadar(
9092
10034
  db,
9093
10035
  {
10036
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
9094
10037
  ...input.kind === void 0 ? {} : { kind: input.kind },
9095
10038
  ...input.linkState === void 0 ? {} : { state: input.linkState }
9096
10039
  },
@@ -9140,6 +10083,14 @@ function reviewAffinityToolHandler(db, rawInput) {
9140
10083
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
9141
10084
  break;
9142
10085
  }
10086
+ if (input.includeContent && ["events.moments", "commitments.open"].includes(input.view))
10087
+ items = items.map((item) => {
10088
+ const content = getEventById(
10089
+ db,
10090
+ Number(item.eventId ?? item.id)
10091
+ )?.content;
10092
+ return content === void 0 ? item : { ...item, content };
10093
+ });
9143
10094
  items = items.map((item) => enrichReviewItem(db, item));
9144
10095
  return toolSuccess(
9145
10096
  summarizeCount(items.length, "item"),
@@ -9151,7 +10102,7 @@ function reviewAffinityToolHandler(db, rawInput) {
9151
10102
  ...chart === void 0 ? {} : { chart }
9152
10103
  },
9153
10104
  {
9154
- entities: items,
10105
+ entities: items.map(({ content: _content, ...item }) => item),
9155
10106
  warnings: items.length === 0 ? [
9156
10107
  toolWarning(
9157
10108
  "empty_result",
@@ -9203,9 +10154,15 @@ var reviewAffinityTool = defineAffinityTool({
9203
10154
  outputDescription: "Returns compact review items, a count, the applied filters, and the chart payload for graph review.",
9204
10155
  inputSchema: objectSchema(
9205
10156
  {
10157
+ ref: externalRefSchema,
10158
+ includeContent: booleanSchema(
10159
+ "Include verbatim content in event rows; defaults to false."
10160
+ ),
10161
+ channel: stringSchema("Filter events by channel."),
9206
10162
  view: enumSchema("Which review surface to return.", [
9207
10163
  "contacts.list",
9208
10164
  "contacts.duplicates",
10165
+ "events.thread",
9209
10166
  "events.contact_journal",
9210
10167
  "events.link_timeline",
9211
10168
  "events.moments",
@@ -9260,7 +10217,20 @@ var reviewAffinityTool = defineAffinityTool({
9260
10217
  includeArchived: booleanSchema("Whether archived links are included."),
9261
10218
  includeObserved: booleanSchema("Whether observed links are included."),
9262
10219
  eventTypes: arraySchema(
9263
- stringSchema("Event type."),
10220
+ enumSchema("Event type.", [
10221
+ "conversation",
10222
+ "activity",
10223
+ "gift",
10224
+ "support",
10225
+ "milestone",
10226
+ "observation",
10227
+ "conflict",
10228
+ "correction",
10229
+ "transaction",
10230
+ "promise",
10231
+ "agreement",
10232
+ "date_anchor"
10233
+ ]),
9264
10234
  "Optional event-type filter."
9265
10235
  ),
9266
10236
  momentKind: enumSchema("Optional moment kind filter.", [
@@ -9300,6 +10270,9 @@ var reviewAffinityTool = defineAffinityTool({
9300
10270
 
9301
10271
  // src/tools/search_affinity_tool.ts
9302
10272
  function searchAffinityToolHandler(db, rawInput) {
10273
+ return searchPage(db, rawInput, false);
10274
+ }
10275
+ function searchPage(db, rawInput, pageOnly) {
9303
10276
  const input = structuredClone(rawInput);
9304
10277
  const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
9305
10278
  if (inputError)
@@ -9310,11 +10283,53 @@ function searchAffinityToolHandler(db, rawInput) {
9310
10283
  inputError
9311
10284
  );
9312
10285
  return withToolHandling(() => {
10286
+ if (!pageOnly) {
10287
+ const limit = input.limit ?? 50;
10288
+ const offset = input.offset ?? 0;
10289
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
10290
+ return toolFailure(
10291
+ "protocol",
10292
+ "invalid_input",
10293
+ "Invalid pagination.",
10294
+ "limit must be 1..250; offset must be non-negative"
10295
+ );
10296
+ const all = [];
10297
+ const scopes = input.scope === "all" ? ["contacts", "events"] : [input.scope ?? "contacts"];
10298
+ for (const scope of scopes) {
10299
+ let page;
10300
+ let nextOffset = 0;
10301
+ do {
10302
+ page = searchPage(
10303
+ db,
10304
+ { ...input, scope, limit: 250, offset: nextOffset },
10305
+ true
10306
+ );
10307
+ if (!page.ok) return page;
10308
+ all.push(...page.data.items);
10309
+ nextOffset += page.data.count;
10310
+ } while (page.data.count === 250);
10311
+ }
10312
+ const items2 = all.slice(offset, offset + limit);
10313
+ return toolSuccess(
10314
+ summarizeCount(
10315
+ items2.length,
10316
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10317
+ ),
10318
+ {
10319
+ query: input.query,
10320
+ items: items2,
10321
+ count: items2.length,
10322
+ total: all.length,
10323
+ hasMore: offset + items2.length < all.length
10324
+ },
10325
+ { entities: items2 }
10326
+ );
10327
+ }
9313
10328
  const options = {
9314
10329
  ...input.limit === void 0 ? {} : { limit: input.limit },
9315
10330
  ...input.offset === void 0 ? {} : { offset: input.offset }
9316
10331
  };
9317
- const rows = searchContacts(
10332
+ const rows = input.scope === "events" ? [] : searchContacts(
9318
10333
  db,
9319
10334
  input.query,
9320
10335
  {
@@ -9328,9 +10343,15 @@ function searchAffinityToolHandler(db, rawInput) {
9328
10343
  },
9329
10344
  options
9330
10345
  );
9331
- const items = rows.map(toContactListToolItem);
10346
+ const items = [
10347
+ ...rows.map(toContactListToolItem),
10348
+ ...input.scope === "events" || input.scope === "all" ? searchEvents(db, input.query, options) : []
10349
+ ];
9332
10350
  return toolSuccess(
9333
- summarizeCount(items.length, "contact"),
10351
+ summarizeCount(
10352
+ items.length,
10353
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
10354
+ ),
9334
10355
  {
9335
10356
  query: input.query,
9336
10357
  items,
@@ -9366,6 +10387,11 @@ var searchAffinityTool = defineAffinityTool({
9366
10387
  outputDescription: "Returns compact contact hits, the original query, and the hit count.",
9367
10388
  inputSchema: objectSchema(
9368
10389
  {
10390
+ scope: enumSchema("Search scope; defaults to contacts.", [
10391
+ "contacts",
10392
+ "events",
10393
+ "all"
10394
+ ]),
9369
10395
  query: stringSchema("Free-text or natural-key query."),
9370
10396
  kind: enumSchema("Optional contact kind filter.", [
9371
10397
  "human",
@@ -9404,6 +10430,268 @@ var searchAffinityTool = defineAffinityTool({
9404
10430
  handler: searchAffinityToolHandler
9405
10431
  });
9406
10432
 
10433
+ // src/events/event_replay.ts
10434
+ function replayEventLinks(db, linkIds, now) {
10435
+ const eventIds = /* @__PURE__ */ new Set();
10436
+ for (const linkId of linkIds) {
10437
+ const saved = db.prepare("SELECT state_json FROM event_link_baselines WHERE link_id=?").get(linkId);
10438
+ if (!saved) continue;
10439
+ const baseline = JSON.parse(saved.state_json);
10440
+ const effects = db.prepare(
10441
+ "SELECT event_id, participant_json, provenance_json, created_at FROM link_event_effects WHERE link_id=? ORDER BY event_id"
10442
+ ).all(linkId);
10443
+ const overrides = db.prepare(
10444
+ "SELECT after_event_id,state FROM event_link_state_changes WHERE link_id=? ORDER BY id"
10445
+ ).all(linkId);
10446
+ let overrideIndex = 0;
10447
+ const applyOverrides = (beforeId) => {
10448
+ while (overrideIndex < overrides.length && (overrides[overrideIndex]?.after_event_id ?? Number.POSITIVE_INFINITY) < beforeId) {
10449
+ const override = overrides[overrideIndex];
10450
+ if (override)
10451
+ db.prepare("UPDATE links SET state=? WHERE id=?").run(
10452
+ override.state,
10453
+ linkId
10454
+ );
10455
+ overrideIndex++;
10456
+ }
10457
+ };
10458
+ db.prepare("DELETE FROM link_event_effects WHERE link_id=?").run(linkId);
10459
+ db.prepare(
10460
+ "UPDATE links SET rank=?,affinity=?,trust=?,state=?,cadence_days=?,updated_at=? WHERE id=?"
10461
+ ).run(
10462
+ baseline.rank,
10463
+ baseline.affinity,
10464
+ baseline.trust,
10465
+ baseline.state,
10466
+ baseline.cadence_days,
10467
+ now,
10468
+ linkId
10469
+ );
10470
+ for (const effect of effects) {
10471
+ eventIds.add(effect.event_id);
10472
+ applyOverrides(effect.event_id);
10473
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(effect.event_id))
10474
+ continue;
10475
+ const event = loadEventRecord(db, effect.event_id);
10476
+ const link = db.prepare("SELECT from_contact_id,to_contact_id FROM links WHERE id=?").get(linkId);
10477
+ const participant = effect.participant_json ? JSON.parse(effect.participant_json) : event.participants.find((p) => p.contactId === link.to_contact_id) ?? event.participants[0];
10478
+ const provenance = db.prepare("SELECT provenance_json FROM events WHERE id=?").get(event.id);
10479
+ applyEventEffectToLink(db, {
10480
+ linkId,
10481
+ eventId: event.id,
10482
+ eventType: event.type,
10483
+ occurredAt: event.occurredAt,
10484
+ significance: event.significance,
10485
+ participant,
10486
+ provenance: provenance.provenance_json ? JSON.parse(provenance.provenance_json) : effect.provenance_json ? JSON.parse(effect.provenance_json) : void 0,
10487
+ now: effect.created_at
10488
+ });
10489
+ }
10490
+ applyOverrides(Number.POSITIVE_INFINITY);
10491
+ refreshLinkRollup(db, linkId, now);
10492
+ }
10493
+ for (const eventId of eventIds)
10494
+ db.prepare(
10495
+ "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=?"
10496
+ ).run(eventId);
10497
+ refreshAllBridgeScores(db, now);
10498
+ }
10499
+
10500
+ // src/events/manage_event.ts
10501
+ function requireEventTarget(db, target) {
10502
+ const selector = typeof target === "number" ? { eventId: target } : target;
10503
+ if (!selector || selector.eventId === void 0 === (selector.ref === void 0))
10504
+ throw new AffinityValidationError("Provide exactly one of eventId or ref");
10505
+ const event = selector.ref ? getEventByExternalRef(db, selector.ref.source, selector.ref.id) : getEventById(db, selector.eventId);
10506
+ if (!event) throw new AffinityNotFoundError("event not found");
10507
+ return event;
10508
+ }
10509
+ function affected(db, id) {
10510
+ return db.prepare("SELECT link_id FROM link_event_effects WHERE event_id=?").all(id).map((r) => r.link_id);
10511
+ }
10512
+ function reviseEvent(db, target, patch) {
10513
+ return withTransaction(db, () => {
10514
+ const allowed = [
10515
+ "summary",
10516
+ "content",
10517
+ "occurredAt",
10518
+ "significance",
10519
+ "channel",
10520
+ "type",
10521
+ "externalRef"
10522
+ ];
10523
+ for (const key of Object.keys(patch))
10524
+ if (!allowed.includes(key))
10525
+ throw new AffinityValidationError(
10526
+ `unknown patch key ${key}; allowed keys: ${allowed.join(", ")}`
10527
+ );
10528
+ const event = requireEventTarget(db, target);
10529
+ const next = { ...event, ...patch };
10530
+ const { momentKind: _momentKind, ...metadata } = next;
10531
+ validateEventMetadata(metadata);
10532
+ Object.assign(next, metadata);
10533
+ if (patch.summary !== void 0 && (typeof patch.summary !== "string" || !patch.summary.trim()))
10534
+ throw new AffinityValidationError("summary must be non-empty");
10535
+ if (patch.occurredAt !== void 0)
10536
+ assertFiniteTimestamp(patch.occurredAt, "occurredAt");
10537
+ if (!Number.isInteger(next.significance) || next.significance < 1 || next.significance > 10)
10538
+ throw new AffinityValidationError(
10539
+ "significance must be an integer from 1 to 10"
10540
+ );
10541
+ const interactions = [
10542
+ "conversation",
10543
+ "activity",
10544
+ "gift",
10545
+ "support",
10546
+ "conflict",
10547
+ "correction"
10548
+ ];
10549
+ if (next.type !== event.type && !(interactions.includes(event.type) && interactions.includes(next.type)) && !(["promise", "agreement"].includes(event.type) && ["promise", "agreement"].includes(next.type)))
10550
+ throw new AffinityValidationError(
10551
+ "type must stay within the same event kind"
10552
+ );
10553
+ if (patch.externalRef && event.externalRef)
10554
+ throw new AffinityConflictError(
10555
+ "externalRef can only be set when absent"
10556
+ );
10557
+ if (patch.externalRef && next.externalRef && getEventByExternalRef(db, next.externalRef.source, next.externalRef.id))
10558
+ throw new AffinityConflictError(
10559
+ "externalRef already belongs to an event"
10560
+ );
10561
+ const now = Date.now();
10562
+ const links = affected(db, event.id);
10563
+ db.prepare(
10564
+ "UPDATE events SET summary=?,content=?,occurred_at=?,occurred_on=?,significance=?,channel=?,type=?,external_source=?,external_id=?,updated_at=? WHERE id=?"
10565
+ ).run(
10566
+ next.summary.trim(),
10567
+ next.content ?? null,
10568
+ next.occurredAt,
10569
+ patch.occurredAt === void 0 ? event.occurredOn ?? null : new Date(next.occurredAt).toISOString().slice(0, 10),
10570
+ next.significance,
10571
+ next.channel ?? null,
10572
+ next.type,
10573
+ next.externalRef?.source ?? null,
10574
+ next.externalRef?.id ?? null,
10575
+ now,
10576
+ event.id
10577
+ );
10578
+ if (patch.type && ["promise", "agreement"].includes(patch.type))
10579
+ db.prepare(
10580
+ "UPDATE open_commitments SET commitment_type=? WHERE event_id=?"
10581
+ ).run(patch.type, event.id);
10582
+ if (patch.significance !== void 0 || patch.type !== void 0 || patch.occurredAt !== void 0)
10583
+ replayEventLinks(db, links, now);
10584
+ return buildEventMutationReceipt(requireEventTarget(db, event.id), {
10585
+ updated: [{ kind: "event", id: event.id }],
10586
+ affectedLinks: links
10587
+ });
10588
+ });
10589
+ }
10590
+ function deleteEvent(db, target, options) {
10591
+ return withTransaction(db, () => {
10592
+ if (options?.confirm !== true)
10593
+ throw new AffinityValidationError("deleteEvent requires confirm: true");
10594
+ const event = requireEventTarget(db, target);
10595
+ const links = affected(db, event.id);
10596
+ for (const table of [
10597
+ "event_participants",
10598
+ "attributes",
10599
+ "open_commitments",
10600
+ "upcoming_occurrences",
10601
+ "link_event_effects"
10602
+ ])
10603
+ db.prepare(`DELETE FROM ${table} WHERE event_id=?`).run(event.id);
10604
+ db.prepare(
10605
+ "UPDATE events SET parent_event_id=NULL WHERE parent_event_id=?"
10606
+ ).run(event.id);
10607
+ db.prepare("DELETE FROM events WHERE id=?").run(event.id);
10608
+ replayEventLinks(db, links, Date.now());
10609
+ return buildEventMutationReceipt(event, {
10610
+ removed: [{ kind: "event", id: event.id }],
10611
+ affectedLinks: links
10612
+ });
10613
+ });
10614
+ }
10615
+
10616
+ // src/tools/manage_event_tool.ts
10617
+ function manageEventToolHandler(db, raw) {
10618
+ const input = structuredClone(raw);
10619
+ const error = validateToolInput(
10620
+ manageEventTool.inputSchema,
10621
+ input,
10622
+ "input",
10623
+ Date.now(),
10624
+ true
10625
+ );
10626
+ if (error)
10627
+ return toolFailure("protocol", "invalid_input", "Invalid input.", error);
10628
+ return withToolHandling(() => {
10629
+ const target = {
10630
+ ...input.eventId === void 0 ? {} : { eventId: input.eventId },
10631
+ ...input.ref === void 0 ? {} : { ref: input.ref }
10632
+ };
10633
+ return mutationToolResult(
10634
+ input.action,
10635
+ input.action === "revise" ? reviseEvent(db, target, input.patch ?? {}) : deleteEvent(db, target, { confirm: input.confirm === true }),
10636
+ "event"
10637
+ );
10638
+ }, "Event management failed.");
10639
+ }
10640
+ var manageEventTool = defineAffinityTool({
10641
+ name: manageEventToolName,
10642
+ description: "Revise or delete one event by eventId or external reference. Delete requires confirm=true and recomputes affected relationships.",
10643
+ whenToUse: "Correct imported event text or metadata, or remove an event.",
10644
+ whenNotToUse: "Use record_event to capture new evidence.",
10645
+ sideEffects: "writes_state",
10646
+ readOnly: false,
10647
+ supportsClarification: false,
10648
+ targetKinds: ["event"],
10649
+ inputDescriptions: {
10650
+ action: "revise or delete",
10651
+ eventId: "Exact event id",
10652
+ ref: "External event reference",
10653
+ patch: "Fields to revise",
10654
+ confirm: "Required true for deletion"
10655
+ },
10656
+ outputDescription: "Event mutation receipt and affected links.",
10657
+ inputSchema: objectSchema(
10658
+ {
10659
+ action: enumSchema("Action.", ["revise", "delete"]),
10660
+ eventId: integerSchema("Event id; provide eventId or ref."),
10661
+ ref: externalRefSchema,
10662
+ confirm: booleanSchema("Must be true to delete."),
10663
+ patch: objectSchema(
10664
+ {
10665
+ summary: stringSchema("Short non-empty label."),
10666
+ content: stringSchema("Verbatim content, at most 1 MB UTF-8."),
10667
+ occurredAt: integerSchema("Event timestamp."),
10668
+ significance: integerSchema("Significance 1..10."),
10669
+ channel: stringSchema("Channel, at most 40 characters."),
10670
+ type: enumSchema("Type within the same event kind.", [
10671
+ "conversation",
10672
+ "activity",
10673
+ "gift",
10674
+ "support",
10675
+ "conflict",
10676
+ "correction",
10677
+ "observation",
10678
+ "milestone",
10679
+ "transaction",
10680
+ "promise",
10681
+ "agreement",
10682
+ "date_anchor"
10683
+ ]),
10684
+ externalRef: externalRefSchema
10685
+ },
10686
+ [],
10687
+ "Fields to revise; externalRef may only be added when absent."
10688
+ )
10689
+ },
10690
+ ["action"]
10691
+ ),
10692
+ handler: manageEventToolHandler
10693
+ });
10694
+
9407
10695
  // src/tools/tool_registry.ts
9408
10696
  var affinityTools = [
9409
10697
  searchAffinityTool,
@@ -9417,7 +10705,8 @@ var affinityTools = [
9417
10705
  recordEventTool,
9418
10706
  manageCommitmentTool,
9419
10707
  manageDateAnchorTool,
9420
- manageAttributeTool
10708
+ manageAttributeTool,
10709
+ manageEventTool
9421
10710
  ];
9422
10711
  function listAffinityToolDefinitions() {
9423
10712
  return [...affinityTools];
@@ -9428,6 +10717,24 @@ function getAffinityToolByName(name) {
9428
10717
 
9429
10718
  // src/tools/tool_mapping.ts
9430
10719
  var affinityToolMappings = [
10720
+ {
10721
+ source: "getEventById",
10722
+ tool: inspectAffinityItemToolName,
10723
+ action: "event"
10724
+ },
10725
+ {
10726
+ source: "getEventByExternalRef",
10727
+ tool: inspectAffinityItemToolName,
10728
+ action: "event"
10729
+ },
10730
+ {
10731
+ source: "getEventThread",
10732
+ tool: reviewAffinityToolName,
10733
+ view: "events.thread"
10734
+ },
10735
+ { source: "searchEvents", tool: searchAffinityToolName },
10736
+ { source: "reviseEvent", tool: "manage_event", action: "revise" },
10737
+ { source: "deleteEvent", tool: "manage_event", action: "delete" },
9431
10738
  { source: "exportContact", tool: manageContactToolName, action: "export" },
9432
10739
  { source: "exportAffinity", tool: manageContactToolName, action: "export" },
9433
10740
  { source: "deleteContact", tool: manageContactToolName, action: "delete" },
@@ -9618,6 +10925,7 @@ __export(write_exports, {
9618
10925
  addIdentity: () => addIdentity,
9619
10926
  createContact: () => createContact,
9620
10927
  deleteContact: () => deleteContact,
10928
+ deleteEvent: () => deleteEvent,
9621
10929
  dismissDuplicate: () => dismissDuplicate,
9622
10930
  mergeContacts: () => mergeContacts,
9623
10931
  overrideLinkState: () => overrideLinkState,
@@ -9634,6 +10942,7 @@ __export(write_exports, {
9634
10942
  reviseBond: () => reviseBond,
9635
10943
  reviseContact: () => reviseContact,
9636
10944
  reviseDateAnchor: () => reviseDateAnchor,
10945
+ reviseEvent: () => reviseEvent,
9637
10946
  reviseIdentity: () => reviseIdentity,
9638
10947
  seedSocialLink: () => seedSocialLink,
9639
10948
  setAttribute: () => setAttribute,