@ghostpaw/affinity 0.5.0 → 0.6.1

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