@ghostpaw/affinity 0.5.0 → 0.6.0

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