@ghostpaw/affinity 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -265,6 +265,12 @@ function initLinkEventEffectsTables(db) {
265
265
  )
266
266
  )
267
267
  `);
268
+ const columns = db.prepare("PRAGMA table_info(link_event_effects)").all();
269
+ if (!columns.some((column) => column.name === "counts_for_recency")) {
270
+ db.exec(
271
+ "ALTER TABLE link_event_effects ADD COLUMN counts_for_recency INTEGER NOT NULL DEFAULT 1"
272
+ );
273
+ }
268
274
  db.exec(
269
275
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
270
276
  );
@@ -515,6 +521,8 @@ function resolveAffinityListLimit(limit) {
515
521
  // src/read.ts
516
522
  var read_exports = {};
517
523
  __export(read_exports, {
524
+ exportAffinity: () => exportAffinity,
525
+ exportContact: () => exportContact,
518
526
  getAffinityChart: () => getAffinityChart,
519
527
  getContactJournal: () => getContactJournal,
520
528
  getContactProfile: () => getContactProfile,
@@ -822,6 +830,25 @@ function createContact(db, input) {
822
830
  });
823
831
  }
824
832
 
833
+ // src/lib/read/assert_default_ordering.ts
834
+ function assertDefaultOrdering(_readName, options) {
835
+ if (options?.sort !== void 0 || options?.descending !== void 0) {
836
+ throw new AffinityValidationError(
837
+ "This review does not support custom sort ordering"
838
+ );
839
+ }
840
+ }
841
+
842
+ // src/lib/read/resolve_list_limit_offset.ts
843
+ function resolveListLimitOffset(options) {
844
+ const limit = resolveAffinityListLimit(options?.limit);
845
+ const offset = options?.offset ?? 0;
846
+ if (!Number.isInteger(offset) || offset < 0) {
847
+ throw new AffinityValidationError("offset must be a non-negative integer");
848
+ }
849
+ return { limit, offset };
850
+ }
851
+
825
852
  // src/events/validators.ts
826
853
  var PARTICIPANT_ROLES = /* @__PURE__ */ new Set([
827
854
  "actor",
@@ -855,7 +882,10 @@ function assertParticipantContactsLive(db, contactIds) {
855
882
  throw new AffinityNotFoundError("contact not found");
856
883
  }
857
884
  if (row.lifecycle_state === "merged") {
858
- throw new AffinityStateError("merged contact is read-only");
885
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
886
+ throw new AffinityStateError(
887
+ `Contact #${id} was merged into #${row.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row.merged_into_contact_id}`
888
+ );
859
889
  }
860
890
  }
861
891
  }
@@ -1027,6 +1057,213 @@ function findDuplicateDateAnchor(db, params) {
1027
1057
  return null;
1028
1058
  }
1029
1059
 
1060
+ // src/events/loaders.ts
1061
+ function loadEventParticipantViews(db, eventId) {
1062
+ const rows = db.prepare(
1063
+ `SELECT contact_id, role, directionality
1064
+ FROM event_participants
1065
+ WHERE event_id = ?`
1066
+ ).all(eventId);
1067
+ return rows.map((row) => ({
1068
+ contactId: row.contact_id,
1069
+ role: row.role,
1070
+ ...row.directionality === null ? {} : { directionality: row.directionality }
1071
+ }));
1072
+ }
1073
+ function loadEventRecord(db, eventId) {
1074
+ const row = getEventRowById(db, eventId);
1075
+ if (!row) {
1076
+ throw new AffinityInvariantError("event not found");
1077
+ }
1078
+ const participants = loadEventParticipantViews(db, eventId);
1079
+ return mapEventRowToEventRecord(row, participants);
1080
+ }
1081
+ function batchLoadEventRecords(db, eventIds) {
1082
+ if (eventIds.length === 0) {
1083
+ return [];
1084
+ }
1085
+ const ph = eventIds.map(() => "?").join(", ");
1086
+ const eventRows = db.prepare(
1087
+ `SELECT id, type, occurred_at, summary, significance, moment_kind,
1088
+ recurrence_kind, anchor_month, anchor_day,
1089
+ anchor_contact_id, anchor_link_id,
1090
+ created_at, updated_at, deleted_at
1091
+ FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
1092
+ ).all(...eventIds);
1093
+ const partRows = db.prepare(
1094
+ `SELECT event_id, contact_id, role, directionality
1095
+ FROM event_participants WHERE event_id IN (${ph})`
1096
+ ).all(...eventIds);
1097
+ const partsByEvent = /* @__PURE__ */ new Map();
1098
+ for (const p of partRows) {
1099
+ let arr = partsByEvent.get(p.event_id);
1100
+ if (!arr) {
1101
+ arr = [];
1102
+ partsByEvent.set(p.event_id, arr);
1103
+ }
1104
+ arr.push({
1105
+ contactId: p.contact_id,
1106
+ role: p.role,
1107
+ ...p.directionality === null ? {} : { directionality: p.directionality }
1108
+ });
1109
+ }
1110
+ const rowById = new Map(eventRows.map((r) => [r.id, r]));
1111
+ const out = [];
1112
+ for (const id of eventIds) {
1113
+ const row = rowById.get(id);
1114
+ if (row) {
1115
+ out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
1116
+ }
1117
+ }
1118
+ return out;
1119
+ }
1120
+ function batchLoadEventParticipantViews(db, eventIds) {
1121
+ const result = /* @__PURE__ */ new Map();
1122
+ if (eventIds.length === 0) {
1123
+ return result;
1124
+ }
1125
+ const ph = eventIds.map(() => "?").join(", ");
1126
+ const rows = db.prepare(
1127
+ `SELECT event_id, contact_id, role, directionality
1128
+ FROM event_participants WHERE event_id IN (${ph})`
1129
+ ).all(...eventIds);
1130
+ for (const row of rows) {
1131
+ let arr = result.get(row.event_id);
1132
+ if (!arr) {
1133
+ arr = [];
1134
+ result.set(row.event_id, arr);
1135
+ }
1136
+ arr.push({
1137
+ contactId: row.contact_id,
1138
+ role: row.role,
1139
+ ...row.directionality === null ? {} : { directionality: row.directionality }
1140
+ });
1141
+ }
1142
+ return result;
1143
+ }
1144
+ function requireDateAnchorEvent(db, eventId) {
1145
+ const row = getEventRowById(db, eventId);
1146
+ if (!row) {
1147
+ throw new AffinityNotFoundError("event not found");
1148
+ }
1149
+ if (row.type !== "date_anchor") {
1150
+ throw new AffinityInvariantError("not a date anchor");
1151
+ }
1152
+ return row;
1153
+ }
1154
+
1155
+ // src/events/get_contact_journal.ts
1156
+ function getContactJournal(db, contactId, options) {
1157
+ assertDefaultOrdering("getContactJournal", options);
1158
+ const { limit, offset } = resolveListLimitOffset(options);
1159
+ const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
1160
+ const params = [contactId];
1161
+ if (options?.since !== void 0) {
1162
+ clauses.push("e.occurred_at >= ?");
1163
+ params.push(options.since);
1164
+ }
1165
+ if (options?.until !== void 0) {
1166
+ clauses.push("e.occurred_at <= ?");
1167
+ params.push(options.until);
1168
+ }
1169
+ if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
1170
+ const ph = options.eventTypes.map(() => "?").join(", ");
1171
+ clauses.push(`e.type IN (${ph})`);
1172
+ params.push(...options.eventTypes);
1173
+ }
1174
+ const where = clauses.join(" AND ");
1175
+ const rows = db.prepare(
1176
+ `SELECT e.id FROM events e
1177
+ INNER JOIN event_participants ep ON ep.event_id = e.id
1178
+ WHERE ${where}
1179
+ ORDER BY e.occurred_at DESC, e.id DESC
1180
+ LIMIT ? OFFSET ?`
1181
+ ).all(...params, limit, offset);
1182
+ return batchLoadEventRecords(
1183
+ db,
1184
+ rows.map((r) => r.id)
1185
+ );
1186
+ }
1187
+
1188
+ // src/events/list_open_commitments.ts
1189
+ var DAY_MS = 864e5;
1190
+ function mapResolution(resolvedAt, resolution) {
1191
+ if (resolvedAt === null) {
1192
+ return "open";
1193
+ }
1194
+ if (resolution === "cancelled") {
1195
+ return "cancelled";
1196
+ }
1197
+ return "resolved";
1198
+ }
1199
+ function listOpenCommitments(db, filters, options) {
1200
+ assertDefaultOrdering("listOpenCommitments", options);
1201
+ const now = resolveNow(options?.now);
1202
+ const { limit, offset } = resolveListLimitOffset(options);
1203
+ const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
1204
+ const params = [];
1205
+ if (options?.since !== void 0) {
1206
+ clauses.push("e.occurred_at >= ?");
1207
+ params.push(options.since);
1208
+ }
1209
+ if (options?.until !== void 0) {
1210
+ clauses.push("e.occurred_at <= ?");
1211
+ params.push(options.until);
1212
+ }
1213
+ if (filters?.commitmentType !== void 0) {
1214
+ if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
1215
+ throw new AffinityValidationError("invalid commitmentType filter");
1216
+ }
1217
+ clauses.push("oc.commitment_type = ?");
1218
+ params.push(filters.commitmentType);
1219
+ }
1220
+ if (filters?.contactId !== void 0) {
1221
+ clauses.push(
1222
+ "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
1223
+ );
1224
+ params.push(filters.contactId);
1225
+ }
1226
+ if (filters?.linkId !== void 0) {
1227
+ clauses.push(
1228
+ "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
1229
+ );
1230
+ params.push(filters.linkId);
1231
+ }
1232
+ if (filters?.horizonDays !== void 0) {
1233
+ if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
1234
+ throw new AffinityValidationError(
1235
+ "horizonDays must be a non-negative integer"
1236
+ );
1237
+ }
1238
+ const anchor = options?.since ?? now;
1239
+ clauses.push("oc.due_at IS NOT NULL");
1240
+ clauses.push("oc.due_at <= ?");
1241
+ params.push(anchor + filters.horizonDays * DAY_MS);
1242
+ }
1243
+ const where = clauses.join(" AND ");
1244
+ const rows = db.prepare(
1245
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
1246
+ e.type, e.summary
1247
+ FROM open_commitments oc
1248
+ INNER JOIN events e ON e.id = oc.event_id
1249
+ WHERE ${where}
1250
+ ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
1251
+ LIMIT ? OFFSET ?`
1252
+ ).all(...params, limit, offset);
1253
+ const eventIds = rows.map((r) => r.event_id);
1254
+ const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
1255
+ return rows.map((r) => ({
1256
+ eventId: r.event_id,
1257
+ type: r.type,
1258
+ summary: r.summary,
1259
+ participants: partsByEvent.get(r.event_id) ?? [],
1260
+ dueAt: r.due_at,
1261
+ dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
1262
+ overdue: r.due_at !== null && r.due_at < now,
1263
+ resolutionState: mapResolution(r.resolved_at, r.resolution)
1264
+ }));
1265
+ }
1266
+
1030
1267
  // src/dates/calendar.ts
1031
1268
  function utcStartOfDayMs(ms) {
1032
1269
  const date = new Date(ms);
@@ -1193,13 +1430,20 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
1193
1430
  const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
1194
1431
  const activeDates = loadActiveDatesForContact(db, contactId);
1195
1432
  const contactRollup = loadContactRollup(db, contactId);
1433
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
1196
1434
  return {
1435
+ ...winner ? { mergedInto: { id: winner.id, name: winner.name } } : {},
1197
1436
  contact: mapContactRowToContactCore(row),
1198
1437
  identities,
1199
1438
  attributes,
1200
1439
  topLinks,
1201
1440
  activeDates,
1202
- warnings: contactRollup?.warnings ?? [],
1441
+ warnings: [
1442
+ ...contactRollup?.warnings ?? [],
1443
+ ...winner ? [
1444
+ `Contact #${row.id} was merged into #${winner.id} ${winner.name}; use contactId ${winner.id}`
1445
+ ] : []
1446
+ ],
1203
1447
  rollups: contactRollup?.rollup ?? null
1204
1448
  };
1205
1449
  }
@@ -1207,7 +1451,27 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
1207
1451
  // src/contacts/get_contact_profile.ts
1208
1452
  function getContactProfile(db, contactId, options) {
1209
1453
  const t = options?.topLinksLimit ?? 8;
1210
- return loadContactProfileRecord(db, contactId, t);
1454
+ const profile = loadContactProfileRecord(db, contactId, t);
1455
+ if (!profile) return null;
1456
+ const ties = db.prepare(`SELECT l.id, l.kind, l.role, c.id AS contactId, c.name
1457
+ FROM links l JOIN contacts c ON c.id = CASE WHEN l.from_contact_id = ? THEN l.to_contact_id ELSE l.from_contact_id END
1458
+ WHERE l.is_structural = 1 AND l.removed_at IS NULL AND (l.from_contact_id = ? OR l.to_contact_id = ?)
1459
+ AND c.deleted_at IS NULL ORDER BY l.id`).all(contactId, contactId, contactId);
1460
+ return {
1461
+ ...profile,
1462
+ recentEvents: getContactJournal(db, contactId, {
1463
+ limit: options?.recentEventsLimit ?? 5
1464
+ }).map((event) => ({
1465
+ ...event,
1466
+ occurredAtIso: new Date(event.occurredAt).toISOString()
1467
+ })),
1468
+ openCommitments: listOpenCommitments(
1469
+ db,
1470
+ { contactId },
1471
+ options?.now === void 0 ? void 0 : { now: options.now }
1472
+ ),
1473
+ ties
1474
+ };
1211
1475
  }
1212
1476
 
1213
1477
  // src/contacts/get_owner_profile.ts
@@ -1220,23 +1484,21 @@ function getOwnerProfile(db, options) {
1220
1484
  return loadContactProfileRecord(db, ownerId, t);
1221
1485
  }
1222
1486
 
1223
- // src/lib/read/assert_default_ordering.ts
1224
- function assertDefaultOrdering(readName, options) {
1225
- if (options?.sort !== void 0 || options?.descending !== void 0) {
1226
- throw new AffinityValidationError(
1227
- `${readName} does not support custom sort ordering`
1228
- );
1229
- }
1230
- }
1231
-
1232
- // src/lib/read/resolve_list_limit_offset.ts
1233
- function resolveListLimitOffset(options) {
1234
- const limit = resolveAffinityListLimit(options?.limit);
1235
- const offset = options?.offset ?? 0;
1236
- if (!Number.isInteger(offset) || offset < 0) {
1237
- throw new AffinityValidationError("offset must be a non-negative integer");
1487
+ // src/contacts/contact_attribute_clauses.ts
1488
+ function contactAttributeClauses(filters) {
1489
+ const clauses = [];
1490
+ const params = [];
1491
+ const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
1492
+ const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
1493
+ for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
1494
+ const separator = filter.indexOf("=");
1495
+ const name = separator < 0 ? filter : filter.slice(0, separator);
1496
+ clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
1497
+ AND a.deleted_at IS NULL AND lower(a.name) = lower(?)${separator < 0 ? "" : " AND lower(CAST(CASE WHEN json_valid(a.value) THEN json_extract(a.value, '$') ELSE a.value END AS TEXT)) = lower(?)"})`);
1498
+ params.push(name);
1499
+ if (separator >= 0) params.push(filter.slice(separator + 1));
1238
1500
  }
1239
- return { limit, offset };
1501
+ return { clauses, params };
1240
1502
  }
1241
1503
 
1242
1504
  // src/contacts/list_contacts.ts
@@ -1262,6 +1524,9 @@ function listContacts(db, filters, options) {
1262
1524
  if (filters?.includeDormant === false || options?.includeDormant === false) {
1263
1525
  clauses.push("lifecycle_state != 'dormant'");
1264
1526
  }
1527
+ const attributeFilters = contactAttributeClauses(filters);
1528
+ clauses.push(...attributeFilters.clauses);
1529
+ params.push(...attributeFilters.params);
1265
1530
  const where = clauses.join(" AND ");
1266
1531
  const rows = db.prepare(
1267
1532
  `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
@@ -1444,89 +1709,97 @@ function reviseContact(db, contactId, patch) {
1444
1709
  });
1445
1710
  }
1446
1711
 
1447
- // src/contacts/search_contacts.ts
1448
- function relevanceScore(name, queryLower, matchedName, matchedNormalizedIdentity) {
1449
- const nl = name.toLowerCase();
1450
- if (matchedNormalizedIdentity) return 1.1;
1451
- if (nl.startsWith(queryLower)) return 1;
1452
- if (matchedName) return 0.85;
1453
- return 0.7;
1712
+ // src/contacts/fold_search_text.ts
1713
+ function foldSearchText(value) {
1714
+ let folded = value.toLowerCase();
1715
+ if (/[^\u0020-\u007e]/.test(folded))
1716
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
1717
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
1454
1718
  }
1719
+
1720
+ // src/contacts/search_contacts.ts
1455
1721
  function searchContacts(db, query, filters, options) {
1456
- const q = query.trim();
1457
- if (q.length === 0) {
1458
- return [];
1459
- }
1722
+ const q = foldSearchText(query.trim());
1723
+ if (!q) return [];
1460
1724
  assertDefaultOrdering("searchContacts", options);
1461
1725
  const { limit, offset } = resolveListLimitOffset(options);
1462
- const queryLower = q.toLowerCase();
1463
- const pattern = `%${q.replace(/%/g, "").replace(/_/g, "")}%`;
1464
- const normalizedPattern = `%${queryLower}%`;
1726
+ const fields = /* @__PURE__ */ new Map();
1727
+ const rows = db.prepare(`
1728
+ SELECT contact_id, value || ' ' || normalized_key AS text, 'identity:' || type AS source FROM identities WHERE removed_at IS NULL
1729
+ UNION ALL SELECT contact_id, name || ' ' || COALESCE(value, ''), 'attribute:' || name FROM attributes WHERE deleted_at IS NULL AND contact_id IS NOT NULL
1730
+ UNION ALL SELECT from_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
1731
+ UNION ALL SELECT to_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
1732
+ UNION ALL SELECT ep.contact_id, e.summary, 'event' FROM event_participants ep JOIN events e ON e.id = ep.event_id WHERE e.deleted_at IS NULL
1733
+ `).all();
1734
+ for (const row of rows) {
1735
+ const values = fields.get(row.contact_id) ?? [];
1736
+ values.push({ text: foldSearchText(row.text), source: row.source });
1737
+ fields.set(row.contact_id, values);
1738
+ }
1739
+ const tokens = q.split(/\s+/);
1740
+ const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
1741
+ const digits = q.replace(/\D/g, "").replace(/^00/, "");
1742
+ const attributeFilters = contactAttributeClauses(filters);
1465
1743
  const clauses = [
1466
- "c.deleted_at IS NULL",
1467
- "c.lifecycle_state != 'merged'"
1744
+ "deleted_at IS NULL",
1745
+ "lifecycle_state != 'merged'",
1746
+ ...attributeFilters.clauses
1468
1747
  ];
1469
- const params = [];
1748
+ const params = [...attributeFilters.params];
1470
1749
  if (filters?.kind !== void 0) {
1471
- clauses.push("c.kind = ?");
1750
+ clauses.push("kind = ?");
1472
1751
  params.push(filters.kind);
1473
1752
  }
1474
1753
  if (filters?.lifecycleState !== void 0) {
1475
- clauses.push("c.lifecycle_state = ?");
1754
+ clauses.push("lifecycle_state = ?");
1476
1755
  params.push(filters.lifecycleState);
1477
1756
  }
1478
- if (filters?.includeOwner === false) {
1479
- clauses.push("c.is_owner = 0");
1480
- }
1481
- if (filters?.includeDormant === false || options?.includeDormant === false) {
1482
- clauses.push("c.lifecycle_state != 'dormant'");
1483
- }
1484
- const where = clauses.join(" AND ");
1485
- const sql = `
1486
- SELECT c.id, c.name, c.kind, c.lifecycle_state, c.is_owner, c.merged_into_contact_id,
1487
- c.created_at, c.updated_at, c.deleted_at,
1488
- (
1489
- SELECT value
1490
- FROM identities primary_i
1491
- WHERE primary_i.contact_id = c.id AND primary_i.removed_at IS NULL
1492
- ORDER BY primary_i.id
1493
- LIMIT 1
1494
- ) AS primary_identity,
1495
- MAX(CASE WHEN i.normalized_key LIKE ? THEN 1 ELSE 0 END) AS matched_normalized_identity
1496
- FROM contacts c
1497
- LEFT JOIN identities i ON i.contact_id = c.id AND i.removed_at IS NULL
1498
- WHERE ${where}
1499
- AND (c.name LIKE ? OR i.value LIKE ? OR i.normalized_key LIKE ?)
1500
- GROUP BY c.id
1501
- `;
1502
- const rows = db.prepare(sql).all(
1503
- normalizedPattern,
1504
- ...params,
1505
- pattern,
1506
- pattern,
1507
- normalizedPattern
1508
- );
1509
- const scored = rows.map((r) => {
1510
- const item = mapContactRowToContactListItem(r);
1511
- const matchedName = r.name.toLowerCase().includes(queryLower);
1512
- const score = relevanceScore(
1513
- r.name,
1514
- queryLower,
1515
- matchedName,
1516
- Number(
1517
- r.matched_normalized_identity ?? 0
1518
- ) > 0
1757
+ if (filters?.includeOwner === false) clauses.push("is_owner = 0");
1758
+ const contacts = db.prepare(
1759
+ `SELECT id, name, kind, lifecycle_state, is_owner, (SELECT value FROM identities i WHERE i.contact_id = contacts.id AND i.removed_at IS NULL ORDER BY i.id LIMIT 1) AS primary_identity FROM contacts WHERE ${clauses.join(" AND ")}`
1760
+ ).all(...params).map((row) => ({
1761
+ ...mapContactRowToContactListItem(row),
1762
+ primaryIdentity: row.primary_identity
1763
+ }));
1764
+ const scored = [];
1765
+ for (const contact of contacts) {
1766
+ if (contact.lifecycleState === "dormant" && !(filters?.includeDormant ?? options?.includeDormant))
1767
+ continue;
1768
+ if (contact.lifecycleState === "lost" && !filters?.includeLost) continue;
1769
+ const name = foldSearchText(contact.name);
1770
+ const values = [
1771
+ { text: name, source: "name" },
1772
+ ...(fields.get(contact.id) ?? []).filter(
1773
+ (field) => !contact.isOwner || field.source !== "event" && field.source !== "link_role"
1774
+ )
1775
+ ];
1776
+ const phoneFields = phone ? values.filter(
1777
+ (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
1778
+ ) : [];
1779
+ if (!phoneFields.length && !tokens.every(
1780
+ (token) => values.some((field) => field.text.includes(token))
1781
+ ))
1782
+ continue;
1783
+ const matchedOn = [
1784
+ ...new Set(
1785
+ [
1786
+ ...values.filter(
1787
+ (field) => tokens.some((token) => field.text.includes(token))
1788
+ ),
1789
+ ...phoneFields
1790
+ ].map((field) => field.source)
1791
+ )
1792
+ ];
1793
+ const identities = values.filter(
1794
+ (field) => field.source.startsWith("identity:")
1519
1795
  );
1520
- return {
1521
- item: {
1522
- ...item,
1523
- primaryIdentity: r.primary_identity ?? null
1524
- },
1525
- score
1526
- };
1527
- });
1528
- scored.sort((a, b) => b.score - a.score);
1529
- return scored.slice(offset, offset + limit).map((s) => s.item);
1796
+ const score = name === q ? 5 : name.startsWith(q) || tokens.every((token) => name.includes(token)) ? 4 : phoneFields.length || identities.some((field) => field.text.split(" ").includes(q)) ? 3 : identities.some(
1797
+ (field) => tokens.every((token) => field.text.includes(token))
1798
+ ) ? 2 : 1;
1799
+ scored.push({ item: { ...contact, matchedOn }, score });
1800
+ }
1801
+ scored.sort((a, b) => b.score - a.score || a.item.id - b.item.id);
1802
+ return scored.slice(offset, offset + limit).map(({ item }) => item);
1530
1803
  }
1531
1804
 
1532
1805
  // src/contacts/validators.ts
@@ -1535,7 +1808,9 @@ function assertValidLifecycleTransition(from, to) {
1535
1808
  throw new AffinityStateError("merged contact is read-only");
1536
1809
  }
1537
1810
  if (to === "merged") {
1538
- throw new AffinityInvariantError("use mergeContacts to reach merged state");
1811
+ throw new AffinityInvariantError(
1812
+ "use manage_contact merge (CLI: contact merge) to reach merged state"
1813
+ );
1539
1814
  }
1540
1815
  if (from === to) {
1541
1816
  return;
@@ -1586,101 +1861,6 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
1586
1861
  });
1587
1862
  }
1588
1863
 
1589
- // src/events/loaders.ts
1590
- function loadEventParticipantViews(db, eventId) {
1591
- const rows = db.prepare(
1592
- `SELECT contact_id, role, directionality
1593
- FROM event_participants
1594
- WHERE event_id = ?`
1595
- ).all(eventId);
1596
- return rows.map((row) => ({
1597
- contactId: row.contact_id,
1598
- role: row.role,
1599
- ...row.directionality === null ? {} : { directionality: row.directionality }
1600
- }));
1601
- }
1602
- function loadEventRecord(db, eventId) {
1603
- const row = getEventRowById(db, eventId);
1604
- if (!row) {
1605
- throw new AffinityInvariantError("event not found");
1606
- }
1607
- const participants = loadEventParticipantViews(db, eventId);
1608
- return mapEventRowToEventRecord(row, participants);
1609
- }
1610
- function batchLoadEventRecords(db, eventIds) {
1611
- if (eventIds.length === 0) {
1612
- return [];
1613
- }
1614
- const ph = eventIds.map(() => "?").join(", ");
1615
- const eventRows = db.prepare(
1616
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
1617
- recurrence_kind, anchor_month, anchor_day,
1618
- anchor_contact_id, anchor_link_id,
1619
- created_at, updated_at, deleted_at
1620
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
1621
- ).all(...eventIds);
1622
- const partRows = db.prepare(
1623
- `SELECT event_id, contact_id, role, directionality
1624
- FROM event_participants WHERE event_id IN (${ph})`
1625
- ).all(...eventIds);
1626
- const partsByEvent = /* @__PURE__ */ new Map();
1627
- for (const p of partRows) {
1628
- let arr = partsByEvent.get(p.event_id);
1629
- if (!arr) {
1630
- arr = [];
1631
- partsByEvent.set(p.event_id, arr);
1632
- }
1633
- arr.push({
1634
- contactId: p.contact_id,
1635
- role: p.role,
1636
- ...p.directionality === null ? {} : { directionality: p.directionality }
1637
- });
1638
- }
1639
- const rowById = new Map(eventRows.map((r) => [r.id, r]));
1640
- const out = [];
1641
- for (const id of eventIds) {
1642
- const row = rowById.get(id);
1643
- if (row) {
1644
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
1645
- }
1646
- }
1647
- return out;
1648
- }
1649
- function batchLoadEventParticipantViews(db, eventIds) {
1650
- const result = /* @__PURE__ */ new Map();
1651
- if (eventIds.length === 0) {
1652
- return result;
1653
- }
1654
- const ph = eventIds.map(() => "?").join(", ");
1655
- const rows = db.prepare(
1656
- `SELECT event_id, contact_id, role, directionality
1657
- FROM event_participants WHERE event_id IN (${ph})`
1658
- ).all(...eventIds);
1659
- for (const row of rows) {
1660
- let arr = result.get(row.event_id);
1661
- if (!arr) {
1662
- arr = [];
1663
- result.set(row.event_id, arr);
1664
- }
1665
- arr.push({
1666
- contactId: row.contact_id,
1667
- role: row.role,
1668
- ...row.directionality === null ? {} : { directionality: row.directionality }
1669
- });
1670
- }
1671
- return result;
1672
- }
1673
- function requireDateAnchorEvent(db, eventId) {
1674
- const row = getEventRowById(db, eventId);
1675
- if (!row) {
1676
- throw new AffinityNotFoundError("event not found");
1677
- }
1678
- if (row.type !== "date_anchor") {
1679
- throw new AffinityInvariantError("not a date anchor");
1680
- }
1681
- return row;
1682
- }
1683
-
1684
1864
  // src/events/persistence.ts
1685
1865
  function insertJournalEvent(db, params) {
1686
1866
  const now = resolveNow(params.now);
@@ -2544,7 +2724,7 @@ function getLinkTimeline(db, linkId, options) {
2544
2724
  }
2545
2725
 
2546
2726
  // src/links/rollups.ts
2547
- var DAY_MS = 864e5;
2727
+ var DAY_MS2 = 864e5;
2548
2728
  function parseJsonObject2(raw) {
2549
2729
  try {
2550
2730
  const parsed = JSON.parse(raw);
@@ -2602,7 +2782,7 @@ function recencyScore(occurredAt, cadenceDays, now) {
2602
2782
  return 0;
2603
2783
  }
2604
2784
  const cadence = Math.max(cadenceDays ?? 0, 1);
2605
- const daysSince = Math.max(0, (now - occurredAt) / DAY_MS);
2785
+ const daysSince = Math.max(0, (now - occurredAt) / DAY_MS2);
2606
2786
  return Math.max(0, 1 - daysSince / cadence);
2607
2787
  }
2608
2788
  function refreshLinkRollup(db, linkId, now) {
@@ -2613,15 +2793,15 @@ function refreshLinkRollup(db, linkId, now) {
2613
2793
  const latest = db.prepare(
2614
2794
  `SELECT event_id, occurred_at
2615
2795
  FROM link_event_effects
2616
- WHERE link_id = ?
2796
+ WHERE link_id = ? AND counts_for_recency = 1
2617
2797
  ORDER BY occurred_at DESC, event_id DESC
2618
2798
  LIMIT 1`
2619
2799
  ).get(linkId);
2620
- const trailingWindowStart = now - 180 * DAY_MS;
2800
+ const trailingWindowStart = now - 180 * DAY_MS2;
2621
2801
  const counts = db.prepare(
2622
2802
  `SELECT
2623
2803
  COUNT(*) AS total_meaningful_events,
2624
- SUM(CASE WHEN affinity_delta > 0 OR trust_delta > 0 THEN 1 ELSE 0 END) AS positive_meaningful_events
2804
+ SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
2625
2805
  FROM link_event_effects
2626
2806
  WHERE link_id = ? AND occurred_at >= ?`
2627
2807
  ).get(linkId, trailingWindowStart);
@@ -2629,7 +2809,7 @@ function refreshLinkRollup(db, linkId, now) {
2629
2809
  `SELECT ep.directionality
2630
2810
  FROM link_event_effects le
2631
2811
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
2632
- WHERE le.link_id = ? AND ep.contact_id = ?
2812
+ WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
2633
2813
  AND ep.directionality IS NOT NULL
2634
2814
  AND le.occurred_at >= ?`
2635
2815
  ).all(linkId, link.from_contact_id, trailingWindowStart);
@@ -2655,7 +2835,7 @@ function refreshLinkRollup(db, linkId, now) {
2655
2835
  link.cadence_days,
2656
2836
  now
2657
2837
  );
2658
- const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS);
2838
+ const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS2);
2659
2839
  const driftPriorityValue = driftPriority({
2660
2840
  daysSinceLastMeaningfulEvent,
2661
2841
  cadenceDays: link.cadence_days ?? 1,
@@ -2972,13 +3152,15 @@ function listRadar(db, filters, options) {
2972
3152
  if (ownerId === null) {
2973
3153
  return [];
2974
3154
  }
3155
+ const now = resolveNow(options?.now);
3156
+ const overdue = `EXISTS (SELECT 1 FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND oc.due_at < ${now})`;
2975
3157
  const { limit, offset } = resolveListLimitOffset(options);
2976
3158
  const clauses = [
2977
3159
  "l.removed_at IS NULL",
2978
3160
  "l.is_structural = 0",
2979
- "l.from_contact_id = ?"
3161
+ "(l.from_contact_id = ? OR l.to_contact_id = ?)"
2980
3162
  ];
2981
- const params = [ownerId];
3163
+ const params = [ownerId, ownerId];
2982
3164
  if (filters?.kind !== void 0) {
2983
3165
  clauses.push("l.kind = ?");
2984
3166
  params.push(filters.kind);
@@ -3000,6 +3182,7 @@ function listRadar(db, filters, options) {
3000
3182
  const rows = db.prepare(
3001
3183
  `SELECT
3002
3184
  l.id,
3185
+ ${overdue} AS overdue_commitment,
3003
3186
  l.from_contact_id,
3004
3187
  l.to_contact_id,
3005
3188
  l.trust,
@@ -3008,17 +3191,18 @@ function listRadar(db, filters, options) {
3008
3191
  lr.recency_score,
3009
3192
  lr.normalized_rank
3010
3193
  FROM links l
3011
- INNER JOIN contacts c_to ON c_to.id = l.to_contact_id
3194
+ INNER JOIN contacts c_to ON c_to.id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END
3012
3195
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
3013
3196
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
3014
3197
  WHERE ${where}
3015
- ORDER BY COALESCE(lr.radar_score, 0) DESC
3198
+ ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
3016
3199
  LIMIT ? OFFSET ?`
3017
3200
  ).all(...params, limit, offset);
3018
3201
  return rows.map((row) => ({
3019
3202
  linkId: row.id,
3020
3203
  contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
3021
- driftPriority: row.drift_priority ?? 0,
3204
+ reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
3205
+ driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
3022
3206
  recencyScore: row.recency_score ?? 0,
3023
3207
  normalizedRank: row.normalized_rank ?? 0,
3024
3208
  trust: row.trust ?? 0,
@@ -3629,39 +3813,6 @@ function listDismissedDuplicates(db, options) {
3629
3813
  }));
3630
3814
  }
3631
3815
 
3632
- // src/events/get_contact_journal.ts
3633
- function getContactJournal(db, contactId, options) {
3634
- assertDefaultOrdering("getContactJournal", options);
3635
- const { limit, offset } = resolveListLimitOffset(options);
3636
- const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
3637
- const params = [contactId];
3638
- if (options?.since !== void 0) {
3639
- clauses.push("e.occurred_at >= ?");
3640
- params.push(options.since);
3641
- }
3642
- if (options?.until !== void 0) {
3643
- clauses.push("e.occurred_at <= ?");
3644
- params.push(options.until);
3645
- }
3646
- if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
3647
- const ph = options.eventTypes.map(() => "?").join(", ");
3648
- clauses.push(`e.type IN (${ph})`);
3649
- params.push(...options.eventTypes);
3650
- }
3651
- const where = clauses.join(" AND ");
3652
- const rows = db.prepare(
3653
- `SELECT e.id FROM events e
3654
- INNER JOIN event_participants ep ON ep.event_id = e.id
3655
- WHERE ${where}
3656
- ORDER BY e.occurred_at DESC, e.id DESC
3657
- LIMIT ? OFFSET ?`
3658
- ).all(...params, limit, offset);
3659
- return batchLoadEventRecords(
3660
- db,
3661
- rows.map((r) => r.id)
3662
- );
3663
- }
3664
-
3665
3816
  // src/events/mechanics.ts
3666
3817
  function clamp2(value, min, max) {
3667
3818
  return Math.min(max, Math.max(min, value));
@@ -3861,111 +4012,35 @@ function warmthMatch(eventType, eventValence) {
3861
4012
  if (eventType === "transaction") {
3862
4013
  return 0.5;
3863
4014
  }
3864
- if (eventType === "conflict") {
3865
- return 0;
3866
- }
3867
- return 0.35;
3868
- }
3869
- function reliabilityMatch(params) {
3870
- if (params?.supportDelivered === true) {
3871
- return 1;
3872
- }
3873
- if (params?.resolution === "kept") {
3874
- return 1;
3875
- }
3876
- if (params?.resolution === "broken") {
3877
- return 0;
3878
- }
3879
- return 0.45;
3880
- }
3881
- function baseWeight(params) {
3882
- return typeWeight(params.eventType) * (0.35 + 0.65 * intensity(params.significance)) * params.directness * params.preferenceMatch * params.novelty;
3883
- }
3884
- function massPenalty(weeklyPositiveBaseWeight2) {
3885
- const excessWeeklyMass = Math.max(0, weeklyPositiveBaseWeight2 - 8);
3886
- return 1 / (1 + excessWeeklyMass / 8);
3887
- }
3888
- function dateSalienceBonus(significance) {
3889
- return 1 + Math.min(0.25, significance / 40);
3890
- }
3891
- function repairBonus(consecutiveRepairEvents) {
3892
- return Math.min(0.12, consecutiveRepairEvents * 0.02);
3893
- }
3894
-
3895
- // src/events/list_open_commitments.ts
3896
- var DAY_MS2 = 864e5;
3897
- function mapResolution(resolvedAt, resolution) {
3898
- if (resolvedAt === null) {
3899
- return "open";
3900
- }
3901
- if (resolution === "cancelled") {
3902
- return "cancelled";
3903
- }
3904
- return "resolved";
3905
- }
3906
- function listOpenCommitments(db, filters, options) {
3907
- assertDefaultOrdering("listOpenCommitments", options);
3908
- const { limit, offset } = resolveListLimitOffset(options);
3909
- const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
3910
- const params = [];
3911
- if (options?.since !== void 0) {
3912
- clauses.push("e.occurred_at >= ?");
3913
- params.push(options.since);
3914
- }
3915
- if (options?.until !== void 0) {
3916
- clauses.push("e.occurred_at <= ?");
3917
- params.push(options.until);
3918
- }
3919
- if (filters?.commitmentType !== void 0) {
3920
- if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
3921
- throw new AffinityValidationError("invalid commitmentType filter");
3922
- }
3923
- clauses.push("oc.commitment_type = ?");
3924
- params.push(filters.commitmentType);
3925
- }
3926
- if (filters?.contactId !== void 0) {
3927
- clauses.push(
3928
- "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
3929
- );
3930
- params.push(filters.contactId);
3931
- }
3932
- if (filters?.linkId !== void 0) {
3933
- clauses.push(
3934
- "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
3935
- );
3936
- params.push(filters.linkId);
3937
- }
3938
- if (filters?.horizonDays !== void 0) {
3939
- if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
3940
- throw new AffinityValidationError(
3941
- "horizonDays must be a non-negative integer"
3942
- );
3943
- }
3944
- const anchor = options?.since ?? Date.now();
3945
- clauses.push("oc.due_at IS NOT NULL");
3946
- clauses.push("oc.due_at <= ?");
3947
- params.push(anchor + filters.horizonDays * DAY_MS2);
3948
- }
3949
- const where = clauses.join(" AND ");
3950
- const rows = db.prepare(
3951
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
3952
- e.type, e.summary
3953
- FROM open_commitments oc
3954
- INNER JOIN events e ON e.id = oc.event_id
3955
- WHERE ${where}
3956
- ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
3957
- LIMIT ? OFFSET ?`
3958
- ).all(...params, limit, offset);
3959
- const eventIds = rows.map((r) => r.event_id);
3960
- const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
3961
- return rows.map((r) => ({
3962
- eventId: r.event_id,
3963
- type: r.type,
3964
- summary: r.summary,
3965
- participants: partsByEvent.get(r.event_id) ?? [],
3966
- dueAt: r.due_at,
3967
- resolutionState: mapResolution(r.resolved_at, r.resolution)
3968
- }));
4015
+ if (eventType === "conflict") {
4016
+ return 0;
4017
+ }
4018
+ return 0.35;
4019
+ }
4020
+ function reliabilityMatch(params) {
4021
+ if (params?.supportDelivered === true) {
4022
+ return 1;
4023
+ }
4024
+ if (params?.resolution === "kept") {
4025
+ return 1;
4026
+ }
4027
+ if (params?.resolution === "broken") {
4028
+ return 0;
4029
+ }
4030
+ return 0.45;
4031
+ }
4032
+ function baseWeight(params) {
4033
+ return typeWeight(params.eventType) * (0.35 + 0.65 * intensity(params.significance)) * params.directness * params.preferenceMatch * params.novelty;
4034
+ }
4035
+ function massPenalty(weeklyPositiveBaseWeight2) {
4036
+ const excessWeeklyMass = Math.max(0, weeklyPositiveBaseWeight2 - 8);
4037
+ return 1 / (1 + excessWeeklyMass / 8);
4038
+ }
4039
+ function dateSalienceBonus(significance) {
4040
+ return 1 + Math.min(0.25, significance / 40);
4041
+ }
4042
+ function repairBonus(consecutiveRepairEvents) {
4043
+ return Math.min(0.12, consecutiveRepairEvents * 0.02);
3969
4044
  }
3970
4045
 
3971
4046
  // src/links/effects.ts
@@ -4245,7 +4320,7 @@ function applyEventEffectToLink(db, params) {
4245
4320
  1,
4246
4321
  Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
4247
4322
  );
4248
- const cadenceAfter = nextCadenceDays({
4323
+ const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
4249
4324
  kind: linkKind,
4250
4325
  currentCadenceDays: cadenceBefore,
4251
4326
  gapDays
@@ -4308,6 +4383,11 @@ function applyEventEffectToLink(db, params) {
4308
4383
  params.now,
4309
4384
  params.now
4310
4385
  );
4386
+ if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
4387
+ db.prepare(
4388
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
4389
+ ).run(params.eventId, params.linkId);
4390
+ }
4311
4391
  refreshLinkRollup(db, params.linkId, params.now);
4312
4392
  return {
4313
4393
  linkId: params.linkId,
@@ -4687,12 +4767,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4687
4767
  if (row.type !== "promise" && row.type !== "agreement") {
4688
4768
  throw new AffinityInvariantError("not a commitment event");
4689
4769
  }
4690
- const open = db.prepare("SELECT resolved_at FROM open_commitments WHERE event_id = ?").get(commitmentEventId);
4770
+ const open = db.prepare(
4771
+ "SELECT resolved_at, resolution FROM open_commitments WHERE event_id = ?"
4772
+ ).get(commitmentEventId);
4691
4773
  if (!open) {
4692
4774
  throw new AffinityNotFoundError("open commitment not found");
4693
4775
  }
4694
4776
  if (open.resolved_at !== null) {
4695
- throw new AffinityStateError("commitment already resolved");
4777
+ throw new AffinityConflictError(
4778
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4779
+ );
4696
4780
  }
4697
4781
  const now = resolveNow(options?.now);
4698
4782
  const originalParticipants = loadEventParticipantViews(
@@ -4705,7 +4789,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4705
4789
  WHERE event_id = ? AND resolved_at IS NULL`
4706
4790
  ).run(resolution, now, now, commitmentEventId);
4707
4791
  if (Number(result.changes ?? 0) === 0) {
4708
- throw new AffinityStateError("commitment already resolved");
4792
+ throw new AffinityConflictError(
4793
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4794
+ );
4709
4795
  }
4710
4796
  const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
4711
4797
  const created = [];
@@ -4768,6 +4854,82 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4768
4854
  });
4769
4855
  }
4770
4856
 
4857
+ // src/contacts/export_contact.ts
4858
+ function exportContact(db, id) {
4859
+ const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
4860
+ const result = {};
4861
+ for (const [key, value] of Object.entries(row)) {
4862
+ const name = key.replace(
4863
+ /_([a-z])/g,
4864
+ (_, letter) => letter.toUpperCase()
4865
+ );
4866
+ result[name] = value;
4867
+ if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
4868
+ result[`${name}Iso`] = new Date(value).toISOString();
4869
+ if (key.endsWith("_json") && typeof value === "string")
4870
+ result[name] = JSON.parse(value);
4871
+ }
4872
+ return result;
4873
+ });
4874
+ const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
4875
+ if (!contact) throw new AffinityNotFoundError("contact not found");
4876
+ const events = query(
4877
+ "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
4878
+ id
4879
+ ).map((event) => ({
4880
+ ...event,
4881
+ participants: query(
4882
+ "SELECT p.*, c.name AS contact_name FROM event_participants p JOIN contacts c ON c.id = p.contact_id WHERE event_id = ? ORDER BY p.contact_id",
4883
+ event.id
4884
+ )
4885
+ }));
4886
+ return {
4887
+ contact,
4888
+ identities: query(
4889
+ "SELECT * FROM identities WHERE contact_id = ? ORDER BY id",
4890
+ id
4891
+ ),
4892
+ attributes: query(
4893
+ "SELECT * FROM attributes WHERE contact_id = ? OR link_id IN (SELECT id FROM links WHERE from_contact_id = ? OR to_contact_id = ?) ORDER BY id",
4894
+ id,
4895
+ id,
4896
+ id
4897
+ ),
4898
+ links: query(
4899
+ "SELECT l.*, f.name AS from_contact_name, t.name AS to_contact_name FROM links l JOIN contacts f ON f.id = l.from_contact_id JOIN contacts t ON t.id = l.to_contact_id WHERE from_contact_id = ? OR to_contact_id = ? ORDER BY l.id",
4900
+ id,
4901
+ id
4902
+ ),
4903
+ events,
4904
+ commitments: query(
4905
+ "SELECT o.* FROM open_commitments o JOIN event_participants p ON p.event_id = o.event_id WHERE p.contact_id = ? ORDER BY o.event_id",
4906
+ id
4907
+ ),
4908
+ dates: query(
4909
+ "SELECT * FROM events WHERE anchor_contact_id = ? OR anchor_link_id IN (SELECT id FROM links WHERE from_contact_id = ? OR to_contact_id = ?) ORDER BY id",
4910
+ id,
4911
+ id,
4912
+ id
4913
+ ),
4914
+ mergeHistory: query(
4915
+ "SELECT * FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ? ORDER BY id",
4916
+ id,
4917
+ id
4918
+ )
4919
+ };
4920
+ }
4921
+
4922
+ // src/contacts/export_affinity.ts
4923
+ function exportAffinity(db) {
4924
+ const now = Date.now();
4925
+ return {
4926
+ version: 1,
4927
+ exportedAt: now,
4928
+ exportedAtIso: new Date(now).toISOString(),
4929
+ contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
4930
+ };
4931
+ }
4932
+
4771
4933
  // src/soul.ts
4772
4934
  var soul_exports = {};
4773
4935
  __export(soul_exports, {
@@ -5535,6 +5697,7 @@ __export(tools_exports, {
5535
5697
  numberSchema: () => numberSchema,
5536
5698
  objectSchema: () => objectSchema,
5537
5699
  oneOfSchema: () => oneOfSchema,
5700
+ parseTimeInput: () => parseTimeInput,
5538
5701
  recordEventTool: () => recordEventTool,
5539
5702
  recordEventToolHandler: () => recordEventToolHandler,
5540
5703
  recordEventToolName: () => recordEventToolName,
@@ -5568,8 +5731,55 @@ __export(tools_exports, {
5568
5731
  withToolHandling: () => withToolHandling
5569
5732
  });
5570
5733
 
5734
+ // src/tools/parse_time_input.ts
5735
+ var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
5736
+ function parseTimeInput(value, field = "time", now = Date.now()) {
5737
+ const fail = () => {
5738
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
5739
+ };
5740
+ if (typeof value === "number")
5741
+ return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
5742
+ if (typeof value !== "string") return fail();
5743
+ const text = value.trim();
5744
+ const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
5745
+ let day;
5746
+ if (relative) {
5747
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
5748
+ day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
5749
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
5750
+ day = Date.parse(`${text}T00:00:00.000Z`);
5751
+ if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
5752
+ return fail();
5753
+ } else {
5754
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
5755
+ text
5756
+ ))
5757
+ return fail();
5758
+ const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
5759
+ if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
5760
+ return fail();
5761
+ const parsed = Date.parse(text);
5762
+ return Number.isFinite(parsed) ? parsed : fail();
5763
+ }
5764
+ const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
5765
+ return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
5766
+ }
5767
+
5571
5768
  // src/tools/tool_metadata.ts
5572
5769
  function defineAffinityTool(definition) {
5770
+ const visit = (schema) => {
5771
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
5772
+ if ((key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.type === "integer") {
5773
+ child.type = void 0;
5774
+ child.oneOf = [{ type: "integer" }, { type: "string" }];
5775
+ child.description = `${child.description ?? "Time."} Accepts ${timeInputForms}. UTC midnight for dates; dueAt uses UTC end of day.`;
5776
+ }
5777
+ visit(child);
5778
+ }
5779
+ if (schema.items) visit(schema.items);
5780
+ for (const child of schema.oneOf ?? schema.anyOf ?? []) visit(child);
5781
+ };
5782
+ visit(definition.inputSchema);
5573
5783
  return definition;
5574
5784
  }
5575
5785
  function stringSchema(description, options = {}) {
@@ -5668,6 +5878,7 @@ function toContactEntityRef(value) {
5668
5878
  function toContactListToolItem(value) {
5669
5879
  return {
5670
5880
  ...toContactEntityRef(value),
5881
+ ..."matchedOn" in value ? { matchedOn: value.matchedOn } : {},
5671
5882
  subtitle: "kind" in value ? value.kind : void 0
5672
5883
  };
5673
5884
  }
@@ -5714,7 +5925,9 @@ function toEventEntityRef(value) {
5714
5925
  }
5715
5926
  function toEventListToolItem(value) {
5716
5927
  return {
5928
+ ...value,
5717
5929
  ...toEventEntityRef(value),
5930
+ occurredAtIso: new Date(value.occurredAt).toISOString(),
5718
5931
  snippet: `${value.participants.length} participant(s)`
5719
5932
  };
5720
5933
  }
@@ -5771,7 +5984,7 @@ function withOptionalFields(result, options) {
5771
5984
  if (options.warnings && options.warnings.length > 0) {
5772
5985
  result.warnings = options.warnings;
5773
5986
  }
5774
- return result;
5987
+ return roundToolValues(result);
5775
5988
  }
5776
5989
  function toolWarning(code, message) {
5777
5990
  return { code, message };
@@ -5835,9 +6048,31 @@ function toolFailure(kind, code, summary, message, options = {}) {
5835
6048
  options
5836
6049
  );
5837
6050
  }
6051
+ function roundToolValues(value) {
6052
+ if (typeof value === "number")
6053
+ return Number.isInteger(value) ? value : Math.round(value * 1e3) / 1e3;
6054
+ if (Array.isArray(value)) return value.map(roundToolValues);
6055
+ if (value !== null && typeof value === "object")
6056
+ return Object.fromEntries(
6057
+ Object.entries(value).map(([key, child]) => [
6058
+ key,
6059
+ roundToolValues(child)
6060
+ ])
6061
+ );
6062
+ return value;
6063
+ }
5838
6064
 
5839
6065
  // src/tools/tool_resolvers.ts
5840
- function resolveContactLocator(db, locator, field = "contact") {
6066
+ function resolveContactLocator(db, locator, field = "contact", allowMerged = false) {
6067
+ if (!locator || typeof locator !== "object")
6068
+ return {
6069
+ ok: false,
6070
+ result: toolNeedsClarification(
6071
+ "missing_required_choice",
6072
+ `Provide ${field} locator.`,
6073
+ [field]
6074
+ )
6075
+ };
5841
6076
  if ("contactId" in locator) {
5842
6077
  const row2 = getContactRowById(db, locator.contactId);
5843
6078
  if (!row2) {
@@ -5851,8 +6086,25 @@ function resolveContactLocator(db, locator, field = "contact") {
5851
6086
  )
5852
6087
  };
5853
6088
  }
6089
+ if (!allowMerged && row2.merged_into_contact_id !== null) {
6090
+ const winner = getContactRowById(db, row2.merged_into_contact_id);
6091
+ const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
6092
+ return {
6093
+ ok: false,
6094
+ result: toolFailure("domain", "invalid_state", message, message)
6095
+ };
6096
+ }
5854
6097
  return { ok: true, value: row2 };
5855
6098
  }
6099
+ if (!("identity" in locator) || !locator.identity)
6100
+ return {
6101
+ ok: false,
6102
+ result: toolNeedsClarification(
6103
+ "missing_required_choice",
6104
+ `Provide ${field}.contactId or identity.`,
6105
+ [field]
6106
+ )
6107
+ };
5856
6108
  const normalizedKey = normalizeIdentityKey(
5857
6109
  locator.identity.type,
5858
6110
  locator.identity.value
@@ -5910,6 +6162,15 @@ function resolveContactLocator(db, locator, field = "contact") {
5910
6162
  return { ok: true, value: matchedContact };
5911
6163
  }
5912
6164
  function resolveLinkLocator(db, locator, field = "link") {
6165
+ if (!locator || typeof locator !== "object")
6166
+ return {
6167
+ ok: false,
6168
+ result: toolNeedsClarification(
6169
+ "missing_required_choice",
6170
+ `Provide ${field} locator.`,
6171
+ [field]
6172
+ )
6173
+ };
5913
6174
  if ("linkId" in locator) {
5914
6175
  const row = getLinkRowById(db, locator.linkId);
5915
6176
  if (!row) {
@@ -5925,6 +6186,15 @@ function resolveLinkLocator(db, locator, field = "link") {
5925
6186
  }
5926
6187
  return { ok: true, value: row };
5927
6188
  }
6189
+ if (!("endpoints" in locator) || !locator.endpoints)
6190
+ return {
6191
+ ok: false,
6192
+ result: toolNeedsClarification(
6193
+ "missing_required_choice",
6194
+ `Provide ${field}.linkId or endpoints.`,
6195
+ [field]
6196
+ )
6197
+ };
5928
6198
  const { endpoints } = locator;
5929
6199
  const clauses = [
5930
6200
  "removed_at IS NULL",
@@ -6043,6 +6313,92 @@ function affinityErrorCodeToToolCode(error) {
6043
6313
  }
6044
6314
  }
6045
6315
 
6316
+ // src/tools/validate_tool_input.ts
6317
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
6318
+ if (schema.anyOf || schema.oneOf) {
6319
+ const choices = schema.anyOf ?? schema.oneOf ?? [];
6320
+ return choices.some(
6321
+ (choice) => validateToolInput(choice, value, path, injectedNow) === null
6322
+ ) ? null : `${path}: invalid value`;
6323
+ }
6324
+ if (schema.type === "object") {
6325
+ if (value === null || typeof value !== "object" || Array.isArray(value))
6326
+ return `${path}: missing input or expected object`;
6327
+ const object = value;
6328
+ if (path.endsWith(".patch") && schema.additionalProperties === false) {
6329
+ for (const key of Object.keys(object)) {
6330
+ if (!Object.hasOwn(schema.properties ?? {}, key))
6331
+ return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown patch key; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
6332
+ }
6333
+ }
6334
+ if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
6335
+ return `${path}.patch: missing required field`;
6336
+ if (object.patch !== void 0 && !schema.properties?.patch)
6337
+ return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
6338
+ const referenceNow = object.now === void 0 ? injectedNow : (() => {
6339
+ try {
6340
+ return parseTimeInput(
6341
+ object.now,
6342
+ `${path}.now`,
6343
+ injectedNow
6344
+ );
6345
+ } catch {
6346
+ return Date.now();
6347
+ }
6348
+ })();
6349
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
6350
+ if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
6351
+ try {
6352
+ object[key] = parseTimeInput(
6353
+ object[key],
6354
+ `${path}.${key}`,
6355
+ referenceNow
6356
+ );
6357
+ } catch (error) {
6358
+ return error.message;
6359
+ }
6360
+ }
6361
+ }
6362
+ for (const key of schema.required ?? []) {
6363
+ if (object[key] === void 0)
6364
+ return `${path}.${key}: missing required field`;
6365
+ }
6366
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
6367
+ if (object[key] === void 0) continue;
6368
+ const error = validateToolInput(
6369
+ child,
6370
+ object[key],
6371
+ `${path}.${key}`,
6372
+ referenceNow
6373
+ );
6374
+ if (error) return error;
6375
+ }
6376
+ return null;
6377
+ }
6378
+ if (schema.type === "array") {
6379
+ if (!Array.isArray(value)) return `${path}: expected array`;
6380
+ for (const [index, item] of value.entries()) {
6381
+ const error = validateToolInput(
6382
+ schema.items ?? {},
6383
+ item,
6384
+ `${path}[${index}]`,
6385
+ injectedNow
6386
+ );
6387
+ if (error) return error;
6388
+ }
6389
+ return null;
6390
+ }
6391
+ if (schema.enum && !schema.enum.includes(value))
6392
+ return `${path}: expected ${schema.enum.join(", ")}`;
6393
+ if (schema.type === "null")
6394
+ return value === null ? null : `${path}: expected null`;
6395
+ if (schema.type === "integer")
6396
+ return Number.isInteger(value) ? null : `${path}: expected integer`;
6397
+ const valid = schema.type === void 0 || schema.type === "string" && typeof value === "string" || schema.type === "number" && typeof value === "number" && Number.isFinite(value) || schema.type === "boolean" && typeof value === "boolean";
6398
+ if (!valid) return `${path}: expected ${schema.type}`;
6399
+ return null;
6400
+ }
6401
+
6046
6402
  // src/tools/inspect_affinity_item_tool.ts
6047
6403
  function contactLocatorSchema(description) {
6048
6404
  return {
@@ -6078,7 +6434,19 @@ function linkLocatorSchema(description) {
6078
6434
  description
6079
6435
  };
6080
6436
  }
6081
- function inspectAffinityItemToolHandler(db, input) {
6437
+ function inspectAffinityItemToolHandler(db, rawInput) {
6438
+ const input = structuredClone(rawInput);
6439
+ const inputError = validateToolInput(
6440
+ inspectAffinityItemTool.inputSchema,
6441
+ input
6442
+ );
6443
+ if (inputError)
6444
+ return toolFailure(
6445
+ "protocol",
6446
+ "invalid_input",
6447
+ "Invalid input.",
6448
+ inputError
6449
+ );
6082
6450
  return withToolHandling(() => {
6083
6451
  if (input.kind === "owner_profile") {
6084
6452
  const detail2 = getOwnerProfile(
@@ -6096,19 +6464,21 @@ function inspectAffinityItemToolHandler(db, input) {
6096
6464
  return toolSuccess(
6097
6465
  `Loaded owner profile for ${detail2.contact.name}.`,
6098
6466
  { kind: input.kind, detail: detail2 },
6099
- { entities: [toContactEntityRef(detail2.contact)] }
6467
+ {
6468
+ entities: [toContactEntityRef(detail2.contact)],
6469
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
6470
+ }
6100
6471
  );
6101
6472
  }
6102
6473
  if (input.kind === "contact_profile") {
6103
- const contact = resolveContactLocator(db, input.contact, "contact");
6474
+ const contact = resolveContactLocator(db, input.contact, "contact", true);
6104
6475
  if (!contact.ok) {
6105
6476
  return contact.result;
6106
6477
  }
6107
- const detail2 = getContactProfile(
6108
- db,
6109
- contact.value.id,
6110
- input.topLinksLimit === void 0 ? void 0 : { topLinksLimit: input.topLinksLimit }
6111
- );
6478
+ const detail2 = getContactProfile(db, contact.value.id, {
6479
+ ...input.topLinksLimit === void 0 ? {} : { topLinksLimit: input.topLinksLimit },
6480
+ ...input.recentEventsLimit === void 0 ? {} : { recentEventsLimit: input.recentEventsLimit }
6481
+ });
6112
6482
  if (!detail2) {
6113
6483
  return toolFailure(
6114
6484
  "domain",
@@ -6120,7 +6490,10 @@ function inspectAffinityItemToolHandler(db, input) {
6120
6490
  return toolSuccess(
6121
6491
  `Loaded contact profile for ${detail2.contact.name}.`,
6122
6492
  { kind: input.kind, detail: detail2 },
6123
- { entities: [toContactEntityRef(detail2.contact)] }
6493
+ {
6494
+ entities: [toContactEntityRef(detail2.contact)],
6495
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
6496
+ }
6124
6497
  );
6125
6498
  }
6126
6499
  const link = resolveLinkLocator(db, input.link, "link");
@@ -6461,9 +6834,17 @@ function unsetAttribute(db, target, name, removedAt) {
6461
6834
  }
6462
6835
 
6463
6836
  // src/tools/tool_mutation.ts
6464
- function mutationToolResult(action, receipt, entitySummary) {
6837
+ function mutationToolResult(action, receipt, entitySummary, fields) {
6465
6838
  const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
6466
6839
  const data = {
6840
+ changes: ["created", "updated", "archived", "removed"].flatMap(
6841
+ (verb) => receipt[verb].map((ref) => ({
6842
+ entity: ref.kind,
6843
+ id: ref.id,
6844
+ action: verb,
6845
+ ...verb === "updated" && fields ? { fields } : {}
6846
+ }))
6847
+ ),
6467
6848
  action,
6468
6849
  primary: receipt.primary,
6469
6850
  created: receipt.created,
@@ -6550,7 +6931,23 @@ function resolveAttributeTarget(db, target) {
6550
6931
  }
6551
6932
  return { kind: "ok", target: { kind: "link", id: link.value.id } };
6552
6933
  }
6553
- function manageAttributeToolHandler(db, input) {
6934
+ function manageAttributeToolHandler(db, rawInput) {
6935
+ const input = structuredClone(rawInput);
6936
+ const inputError = validateToolInput(manageAttributeTool.inputSchema, input);
6937
+ if (inputError)
6938
+ return toolFailure(
6939
+ "protocol",
6940
+ "invalid_input",
6941
+ "Invalid input.",
6942
+ inputError
6943
+ );
6944
+ if (!input.target)
6945
+ return toolFailure(
6946
+ "protocol",
6947
+ "invalid_input",
6948
+ "Missing target.",
6949
+ "target is required."
6950
+ );
6554
6951
  return withToolHandling(() => {
6555
6952
  const resolved = resolveAttributeTarget(db, input.target);
6556
6953
  if (resolved.kind === "error") {
@@ -6661,9 +7058,25 @@ function ensureOwnerParticipates(db, participants) {
6661
7058
  function prepareParticipants(db, raw) {
6662
7059
  return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
6663
7060
  }
6664
- function manageCommitmentToolHandler(db, input) {
7061
+ function manageCommitmentToolHandler(db, rawInput) {
7062
+ const input = structuredClone(rawInput);
7063
+ const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
7064
+ if (inputError)
7065
+ return toolFailure(
7066
+ "protocol",
7067
+ "invalid_input",
7068
+ "Invalid input.",
7069
+ inputError
7070
+ );
6665
7071
  return withToolHandling(() => {
6666
7072
  if (input.action === "record") {
7073
+ if (!input.input)
7074
+ return toolFailure(
7075
+ "protocol",
7076
+ "invalid_input",
7077
+ "Missing input.",
7078
+ "input is required for record."
7079
+ );
6667
7080
  const patched = {
6668
7081
  ...input.input,
6669
7082
  participants: prepareParticipants(
@@ -6730,7 +7143,7 @@ var manageCommitmentTool = defineAffinityTool({
6730
7143
  role: stringSchema("Participant role."),
6731
7144
  directionality: stringSchema("Optional directionality.")
6732
7145
  },
6733
- ["contactId", "role"]
7146
+ ["role"]
6734
7147
  ),
6735
7148
  "Participant list. Must be non-empty and include the owner contact."
6736
7149
  ),
@@ -6754,28 +7167,282 @@ var manageCommitmentTool = defineAffinityTool({
6754
7167
  ),
6755
7168
  options: objectSchema(
6756
7169
  {
6757
- now: integerSchema("Optional timestamp."),
6758
- resolvingEvent: objectSchema(
6759
- {
6760
- type: stringSchema("Optional resolving event type."),
6761
- occurredAt: integerSchema("Optional resolving event timestamp."),
6762
- summary: stringSchema("Optional resolving event summary."),
6763
- significance: integerSchema(
6764
- "Optional resolving event significance."
6765
- )
6766
- },
6767
- []
6768
- )
6769
- },
6770
- [],
6771
- "Optional metadata."
6772
- )
6773
- },
6774
- ["action"],
6775
- "Record or resolve one commitment."
6776
- ),
6777
- handler: manageCommitmentToolHandler
6778
- });
7170
+ now: integerSchema("Optional timestamp."),
7171
+ resolvingEvent: objectSchema(
7172
+ {
7173
+ type: stringSchema("Optional resolving event type."),
7174
+ occurredAt: integerSchema("Optional resolving event timestamp."),
7175
+ summary: stringSchema("Optional resolving event summary."),
7176
+ significance: integerSchema(
7177
+ "Optional resolving event significance."
7178
+ )
7179
+ },
7180
+ []
7181
+ )
7182
+ },
7183
+ [],
7184
+ "Optional metadata."
7185
+ )
7186
+ },
7187
+ ["action"],
7188
+ "Record or resolve one commitment."
7189
+ ),
7190
+ handler: manageCommitmentToolHandler
7191
+ });
7192
+
7193
+ // src/contacts/delete_contact.ts
7194
+ function deleteContact(db, locatorId, options) {
7195
+ if (options?.confirm !== true)
7196
+ throw new AffinityValidationError(
7197
+ "delete is permanent; pass confirm: true"
7198
+ );
7199
+ return withTransaction(db, () => {
7200
+ const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
7201
+ if (!contact) throw new AffinityNotFoundError("contact not found");
7202
+ if (contact.is_owner)
7203
+ throw new AffinityStateError("The owner contact cannot be deleted");
7204
+ const tables = [
7205
+ "contacts",
7206
+ "identities",
7207
+ "attributes",
7208
+ "links",
7209
+ "events",
7210
+ "event_participants",
7211
+ "open_commitments",
7212
+ "contact_merges",
7213
+ "dismissed_duplicates",
7214
+ "link_event_effects",
7215
+ "upcoming_occurrences",
7216
+ "link_rollups",
7217
+ "contact_rollups"
7218
+ ];
7219
+ const before = new Map(
7220
+ tables.map((table) => [
7221
+ table,
7222
+ db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
7223
+ ])
7224
+ );
7225
+ db.prepare(
7226
+ "DELETE FROM events WHERE id IN (SELECT p.event_id FROM event_participants p WHERE p.contact_id = ? AND NOT EXISTS (SELECT 1 FROM event_participants q JOIN contacts c ON c.id = q.contact_id WHERE q.event_id = p.event_id AND q.contact_id != ? AND c.is_owner = 0))"
7227
+ ).run(locatorId, locatorId);
7228
+ db.prepare(
7229
+ "DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
7230
+ ).run(locatorId, locatorId);
7231
+ db.prepare(
7232
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
7233
+ ).run(locatorId, locatorId);
7234
+ db.prepare(
7235
+ "UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
7236
+ ).run(locatorId);
7237
+ db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
7238
+ const now = Date.now();
7239
+ const affectedLinks = db.prepare(
7240
+ "SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
7241
+ ).all().map((row) => row.id);
7242
+ refreshAllBridgeScores(db, now);
7243
+ for (const id of affectedLinks) refreshLinkRollup(db, id, now);
7244
+ for (const row of db.prepare("SELECT id FROM contacts").all())
7245
+ refreshContactRollup(db, Number(row.id), now);
7246
+ const removed = tables.map((table) => {
7247
+ const remaining = new Set(
7248
+ db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
7249
+ );
7250
+ const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
7251
+ return { entity: table, count: ids.length, ids };
7252
+ }).filter((item) => item.count > 0);
7253
+ return { contactId: locatorId, removed, affectedLinks };
7254
+ });
7255
+ }
7256
+
7257
+ // src/identities/queries.ts
7258
+ function getIdentityRowById(db, id) {
7259
+ const row = db.prepare(
7260
+ `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
7261
+ created_at, updated_at, removed_at
7262
+ FROM identities
7263
+ WHERE id = ? AND removed_at IS NULL`
7264
+ ).get(id);
7265
+ if (row === void 0) {
7266
+ return null;
7267
+ }
7268
+ return row;
7269
+ }
7270
+
7271
+ // src/identities/receipts.ts
7272
+ function buildIdentityMutationReceipt(primary, partial) {
7273
+ const normalized = normalizeMutationReceiptPartial(partial);
7274
+ return {
7275
+ primary,
7276
+ created: normalized.created,
7277
+ updated: normalized.updated,
7278
+ archived: normalized.archived,
7279
+ removed: normalized.removed,
7280
+ affectedLinks: normalized.affectedLinks,
7281
+ derivedEffects: normalized.derivedEffects
7282
+ };
7283
+ }
7284
+
7285
+ // src/identities/validators.ts
7286
+ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
7287
+ const row = excludeIdentityId === void 0 ? db.prepare(
7288
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
7289
+ ).get(normalizedKey) : db.prepare(
7290
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
7291
+ ).get(normalizedKey, excludeIdentityId);
7292
+ if (row !== void 0) {
7293
+ const owner = db.prepare(
7294
+ "SELECT c.id, c.name FROM contacts c JOIN identities i ON i.contact_id = c.id WHERE i.normalized_key = ? AND i.removed_at IS NULL"
7295
+ ).get(normalizedKey);
7296
+ throw new AffinityConflictError(
7297
+ `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
7298
+ );
7299
+ }
7300
+ }
7301
+
7302
+ // src/identities/validate_identity_input.ts
7303
+ var identityTypes = [
7304
+ "email",
7305
+ "phone",
7306
+ "url",
7307
+ "website",
7308
+ "handle",
7309
+ "x",
7310
+ "twitter",
7311
+ "linkedin",
7312
+ "github",
7313
+ "mastodon",
7314
+ "bluesky",
7315
+ "telegram",
7316
+ "whatsapp",
7317
+ "signal",
7318
+ "matrix",
7319
+ "discord",
7320
+ "slack",
7321
+ "instagram",
7322
+ "facebook",
7323
+ "youtube",
7324
+ "tiktok",
7325
+ "address",
7326
+ "other",
7327
+ "account_id",
7328
+ "alias"
7329
+ ];
7330
+ function validateIdentityInput(type, value) {
7331
+ const kind = type.toLowerCase();
7332
+ if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
7333
+ throw new AffinityValidationError(
7334
+ `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
7335
+ );
7336
+ if (!value.trim())
7337
+ throw new AffinityValidationError("identity value must be non-empty");
7338
+ if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
7339
+ throw new AffinityValidationError(
7340
+ "email value must match local@domain.tld"
7341
+ );
7342
+ if (kind === "phone" && value.replace(/\D/g, "").length < 5)
7343
+ throw new AffinityValidationError(
7344
+ "phone value must contain at least 5 digits"
7345
+ );
7346
+ if (kind === "url" || kind === "website") {
7347
+ try {
7348
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
7349
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
7350
+ throw new Error();
7351
+ } catch {
7352
+ throw new AffinityValidationError(
7353
+ "url/website value must be an http/https URL or bare domain"
7354
+ );
7355
+ }
7356
+ }
7357
+ }
7358
+
7359
+ // src/identities/add_identity.ts
7360
+ function addIdentity(db, contactId, input) {
7361
+ return withTransaction(db, () => {
7362
+ const contact = getContactRowById(db, contactId);
7363
+ if (!contact) {
7364
+ throw new AffinityNotFoundError("contact not found");
7365
+ }
7366
+ if (contact.lifecycle_state === "merged") {
7367
+ throw new AffinityStateError("merged contact is read-only");
7368
+ }
7369
+ const type = input.type.trim();
7370
+ const value = input.value.trim();
7371
+ if (type.length === 0 || value.length === 0) {
7372
+ throw new AffinityValidationError("type and value must be non-empty");
7373
+ }
7374
+ validateIdentityInput(type, value);
7375
+ const nk = normalizeIdentityKey(type, value);
7376
+ assertNoIdentityCollision(db, nk);
7377
+ const now = resolveNow(input.now);
7378
+ const verified = input.verified ? 1 : 0;
7379
+ const verifiedAt = verified === 1 ? now : null;
7380
+ const result = db.prepare(
7381
+ `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
7382
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7383
+ ).run(
7384
+ contactId,
7385
+ type,
7386
+ value,
7387
+ input.label ?? null,
7388
+ nk,
7389
+ verified,
7390
+ verifiedAt,
7391
+ now,
7392
+ now
7393
+ );
7394
+ const id = Number(result.lastInsertRowid);
7395
+ const row = getIdentityRowById(db, id);
7396
+ if (!row) {
7397
+ throw new AffinityInvariantError("inserted identity not found");
7398
+ }
7399
+ refreshContactRollup(db, contactId, now);
7400
+ db.prepare(
7401
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
7402
+ ).run(contactId, contactId);
7403
+ return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
7404
+ created: [{ kind: "identity", id }]
7405
+ });
7406
+ });
7407
+ }
7408
+
7409
+ // src/tools/create_contact_tool_result.ts
7410
+ function createContactToolResult(db, input) {
7411
+ return withTransaction(db, () => {
7412
+ const receipt = createContact(db, input);
7413
+ const identityOwners = /* @__PURE__ */ new Set();
7414
+ for (const identity of input.identities ?? []) {
7415
+ validateIdentityInput(identity.type.trim(), identity.value.trim());
7416
+ const existing = db.prepare(
7417
+ "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
7418
+ ).get(normalizeIdentityKey(identity.type, identity.value));
7419
+ if (existing) {
7420
+ if (Number(existing.contact_id) !== receipt.primary.id)
7421
+ identityOwners.add(Number(existing.contact_id));
7422
+ continue;
7423
+ }
7424
+ const added = addIdentity(db, receipt.primary.id, identity);
7425
+ receipt.created.push(...added.created);
7426
+ }
7427
+ const result = mutationToolResult("create", receipt, "contact");
7428
+ const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
7429
+ const warnings = db.prepare(
7430
+ "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
7431
+ ).all(receipt.primary.id).flatMap((row) => {
7432
+ const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
7433
+ return score === void 0 ? [] : [
7434
+ {
7435
+ code: "possible_duplicate",
7436
+ message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
7437
+ contactId: row.id,
7438
+ score
7439
+ }
7440
+ ];
7441
+ });
7442
+ if (warnings.length) result.warnings = warnings;
7443
+ return result;
7444
+ });
7445
+ }
6779
7446
 
6780
7447
  // src/tools/manage_contact_tool.ts
6781
7448
  function contactLocatorSchema3(description) {
@@ -6795,24 +7462,76 @@ function contactLocatorSchema3(description) {
6795
7462
  description
6796
7463
  };
6797
7464
  }
6798
- function manageContactToolHandler(db, input) {
7465
+ function manageContactToolHandler(db, rawInput) {
7466
+ const input = structuredClone(rawInput);
7467
+ const inputError = validateToolInput(manageContactTool.inputSchema, input);
7468
+ if (inputError)
7469
+ return toolFailure(
7470
+ "protocol",
7471
+ "invalid_input",
7472
+ "Invalid input.",
7473
+ inputError
7474
+ );
6799
7475
  return withToolHandling(() => {
6800
- if (input.action === "create") {
6801
- return mutationToolResult(
6802
- "create",
6803
- createContact(db, input.input),
6804
- "contact"
7476
+ if (input.action === "export") {
7477
+ if (!input.contact)
7478
+ return toolSuccess("Exported Affinity.", {
7479
+ action: "export",
7480
+ export: exportAffinity(db)
7481
+ });
7482
+ const target = resolveContactLocator(db, input.contact, "contact", true);
7483
+ if (!target.ok) return target.result;
7484
+ return toolSuccess("Exported contact.", {
7485
+ action: "export",
7486
+ export: exportContact(db, target.value.id)
7487
+ });
7488
+ }
7489
+ if (input.action === "delete" && input.confirm !== true)
7490
+ return toolFailure(
7491
+ "protocol",
7492
+ "invalid_input",
7493
+ "Delete requires confirmation.",
7494
+ "delete is permanent; pass confirm: true (CLI: --confirm)"
6805
7495
  );
7496
+ if (input.action === "create") {
7497
+ if (!input.input)
7498
+ return toolFailure(
7499
+ "protocol",
7500
+ "invalid_input",
7501
+ "Missing input.",
7502
+ "input is required for create."
7503
+ );
7504
+ return createContactToolResult(db, input.input);
6806
7505
  }
6807
- const contact = resolveContactLocator(db, input.contact, "contact");
7506
+ const contact = resolveContactLocator(
7507
+ db,
7508
+ input.contact,
7509
+ "contact",
7510
+ input.action === "delete"
7511
+ );
6808
7512
  if (!contact.ok) {
6809
7513
  return contact.result;
6810
7514
  }
7515
+ if (input.action === "delete") {
7516
+ const deletion = deleteContact(db, contact.value.id, { confirm: true });
7517
+ return toolSuccess("Permanently deleted contact.", {
7518
+ action: "delete",
7519
+ deletion,
7520
+ changes: deletion.removed.flatMap(
7521
+ (item) => item.ids.map((id) => ({
7522
+ entity: item.entity,
7523
+ id,
7524
+ action: "removed"
7525
+ }))
7526
+ )
7527
+ });
7528
+ }
6811
7529
  if (input.action === "revise") {
6812
7530
  return mutationToolResult(
6813
7531
  "revise",
6814
7532
  reviseContact(db, contact.value.id, input.patch),
6815
- "contact"
7533
+ "contact",
7534
+ Object.keys(input.patch)
6816
7535
  );
6817
7536
  }
6818
7537
  return mutationToolResult(
@@ -6829,7 +7548,7 @@ function manageContactToolHandler(db, input) {
6829
7548
  }
6830
7549
  var manageContactTool = defineAffinityTool({
6831
7550
  name: manageContactToolName,
6832
- description: "Create a contact, revise one contact's core fields, or change lifecycle state. ALWAYS search_affinity first before creating \u2014 duplicates corrupt the graph.",
7551
+ description: "Create, revise, export or permanently delete a contact (confirm: true required; owner cannot be deleted). Export without contact exports the whole database. Lifecycle lost is soft delete. ALWAYS search_affinity first before creating \u2014 duplicates corrupt the graph.",
6833
7552
  whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
6834
7553
  whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
6835
7554
  sideEffects: "writes_state",
@@ -6850,10 +7569,25 @@ var manageContactTool = defineAffinityTool({
6850
7569
  action: enumSchema("Operation to perform.", [
6851
7570
  "create",
6852
7571
  "revise",
6853
- "set_lifecycle"
7572
+ "set_lifecycle",
7573
+ "export",
7574
+ "delete"
6854
7575
  ]),
6855
7576
  input: objectSchema(
6856
7577
  {
7578
+ identities: arraySchema(
7579
+ objectSchema(
7580
+ {
7581
+ type: stringSchema("Identity type."),
7582
+ value: stringSchema("Identity value."),
7583
+ label: stringSchema("Optional label."),
7584
+ verified: booleanSchema("Verified identity."),
7585
+ now: integerSchema("Optional timestamp.")
7586
+ },
7587
+ ["type", "value"]
7588
+ ),
7589
+ "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
7590
+ ),
6857
7591
  name: stringSchema("Contact name."),
6858
7592
  kind: enumSchema("Contact kind.", [
6859
7593
  "human",
@@ -6872,6 +7606,7 @@ var manageContactTool = defineAffinityTool({
6872
7606
  ["name", "kind"],
6873
7607
  "New contact payload. Required when action=create."
6874
7608
  ),
7609
+ confirm: booleanSchema("Required true for permanent deletion."),
6875
7610
  contact: contactLocatorSchema3(
6876
7611
  "Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
6877
7612
  ),
@@ -6918,7 +7653,19 @@ function contactLocatorSchema4(description) {
6918
7653
  description
6919
7654
  };
6920
7655
  }
6921
- function manageDuplicateDismissalToolHandler(db, input) {
7656
+ function manageDuplicateDismissalToolHandler(db, rawInput) {
7657
+ const input = structuredClone(rawInput);
7658
+ const inputError = validateToolInput(
7659
+ manageDuplicateDismissalTool.inputSchema,
7660
+ input
7661
+ );
7662
+ if (inputError)
7663
+ return toolFailure(
7664
+ "protocol",
7665
+ "invalid_input",
7666
+ "Invalid input.",
7667
+ inputError
7668
+ );
6922
7669
  return withToolHandling(() => {
6923
7670
  const left = resolveContactLocator(db, input.left, "left");
6924
7671
  if (!left.ok) {
@@ -6928,6 +7675,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
6928
7675
  if (!right.ok) {
6929
7676
  return right.result;
6930
7677
  }
7678
+ const leftContactId = Math.min(left.value.id, right.value.id);
7679
+ const rightContactId = Math.max(left.value.id, right.value.id);
7680
+ const existed = db.prepare(
7681
+ "SELECT 1 FROM dismissed_duplicates WHERE left_id = ? AND right_id = ?"
7682
+ ).get(leftContactId, rightContactId) !== void 0;
6931
7683
  if (input.action === "dismiss") {
6932
7684
  const receipt2 = dismissDuplicate(
6933
7685
  db,
@@ -6937,19 +7689,41 @@ function manageDuplicateDismissalToolHandler(db, input) {
6937
7689
  );
6938
7690
  return toolSuccess(
6939
7691
  `Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
6940
- { action: "dismiss", primary: receipt2.primary }
7692
+ {
7693
+ ...receipt2,
7694
+ action: "dismiss",
7695
+ changes: [
7696
+ {
7697
+ entity: "duplicate_dismissal",
7698
+ leftContactId,
7699
+ rightContactId,
7700
+ action: existed ? "updated" : "created"
7701
+ }
7702
+ ]
7703
+ }
6941
7704
  );
6942
7705
  }
6943
7706
  const receipt = undismissDuplicate(db, left.value.id, right.value.id);
6944
7707
  return toolSuccess(
6945
7708
  `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
6946
- { action: "undismiss", primary: receipt.primary }
7709
+ {
7710
+ ...receipt,
7711
+ action: "undismiss",
7712
+ changes: existed ? [
7713
+ {
7714
+ entity: "duplicate_dismissal",
7715
+ leftContactId,
7716
+ rightContactId,
7717
+ action: "removed"
7718
+ }
7719
+ ] : []
7720
+ }
6947
7721
  );
6948
7722
  }, "Duplicate dismissal failed.");
6949
7723
  }
6950
7724
  var manageDuplicateDismissalTool = defineAffinityTool({
6951
7725
  name: manageDuplicateDismissalToolName,
6952
- description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in listDuplicateCandidates.",
7726
+ description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
6953
7727
  whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
6954
7728
  whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
6955
7729
  sideEffects: "writes_state",
@@ -7035,9 +7809,32 @@ function resolveDateAnchorTarget(db, target) {
7035
7809
  }
7036
7810
  return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
7037
7811
  }
7038
- function manageDateAnchorToolHandler(db, input) {
7812
+ function manageDateAnchorToolHandler(db, rawInput) {
7813
+ const input = structuredClone(rawInput);
7814
+ const inputError = validateToolInput(manageDateAnchorTool.inputSchema, input);
7815
+ if (inputError)
7816
+ return toolFailure(
7817
+ "protocol",
7818
+ "invalid_input",
7819
+ "Invalid input.",
7820
+ inputError
7821
+ );
7822
+ if (input.action !== "add" && input.anchorEventId === void 0)
7823
+ return toolFailure(
7824
+ "protocol",
7825
+ "invalid_input",
7826
+ "Missing anchorEventId.",
7827
+ "anchorEventId is required."
7828
+ );
7039
7829
  return withToolHandling(() => {
7040
7830
  if (input.action === "add") {
7831
+ if (!input.input)
7832
+ return toolFailure(
7833
+ "protocol",
7834
+ "invalid_input",
7835
+ "Missing input.",
7836
+ "input is required for add."
7837
+ );
7041
7838
  const resolved = resolveDateAnchorTarget(db, input.input.target);
7042
7839
  if (resolved.kind === "error") {
7043
7840
  return resolved.result;
@@ -7052,7 +7849,8 @@ function manageDateAnchorToolHandler(db, input) {
7052
7849
  return mutationToolResult(
7053
7850
  "revise",
7054
7851
  reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
7055
- "date anchor"
7852
+ "date anchor",
7853
+ Object.keys(input.patch)
7056
7854
  );
7057
7855
  }
7058
7856
  return mutationToolResult(
@@ -7160,97 +7958,6 @@ var manageDateAnchorTool = defineAffinityTool({
7160
7958
  handler: manageDateAnchorToolHandler
7161
7959
  });
7162
7960
 
7163
- // src/identities/queries.ts
7164
- function getIdentityRowById(db, id) {
7165
- const row = db.prepare(
7166
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
7167
- created_at, updated_at, removed_at
7168
- FROM identities
7169
- WHERE id = ? AND removed_at IS NULL`
7170
- ).get(id);
7171
- if (row === void 0) {
7172
- return null;
7173
- }
7174
- return row;
7175
- }
7176
-
7177
- // src/identities/receipts.ts
7178
- function buildIdentityMutationReceipt(primary, partial) {
7179
- const normalized = normalizeMutationReceiptPartial(partial);
7180
- return {
7181
- primary,
7182
- created: normalized.created,
7183
- updated: normalized.updated,
7184
- archived: normalized.archived,
7185
- removed: normalized.removed,
7186
- affectedLinks: normalized.affectedLinks,
7187
- derivedEffects: normalized.derivedEffects
7188
- };
7189
- }
7190
-
7191
- // src/identities/validators.ts
7192
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
7193
- const row = excludeIdentityId === void 0 ? db.prepare(
7194
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
7195
- ).get(normalizedKey) : db.prepare(
7196
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
7197
- ).get(normalizedKey, excludeIdentityId);
7198
- if (row !== void 0) {
7199
- throw new AffinityConflictError(
7200
- "another contact already owns this normalized identity"
7201
- );
7202
- }
7203
- }
7204
-
7205
- // src/identities/add_identity.ts
7206
- function addIdentity(db, contactId, input) {
7207
- return withTransaction(db, () => {
7208
- const contact = getContactRowById(db, contactId);
7209
- if (!contact) {
7210
- throw new AffinityNotFoundError("contact not found");
7211
- }
7212
- if (contact.lifecycle_state === "merged") {
7213
- throw new AffinityStateError("merged contact is read-only");
7214
- }
7215
- const type = input.type.trim();
7216
- const value = input.value.trim();
7217
- if (type.length === 0 || value.length === 0) {
7218
- throw new AffinityValidationError("type and value must be non-empty");
7219
- }
7220
- const nk = normalizeIdentityKey(type, value);
7221
- assertNoIdentityCollision(db, nk);
7222
- const now = resolveNow(input.now);
7223
- const verified = input.verified ? 1 : 0;
7224
- const verifiedAt = verified === 1 ? now : null;
7225
- const result = db.prepare(
7226
- `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
7227
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7228
- ).run(
7229
- contactId,
7230
- type,
7231
- value,
7232
- input.label ?? null,
7233
- nk,
7234
- verified,
7235
- verifiedAt,
7236
- now,
7237
- now
7238
- );
7239
- const id = Number(result.lastInsertRowid);
7240
- const row = getIdentityRowById(db, id);
7241
- if (!row) {
7242
- throw new AffinityInvariantError("inserted identity not found");
7243
- }
7244
- refreshContactRollup(db, contactId, now);
7245
- db.prepare(
7246
- "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
7247
- ).run(contactId, contactId);
7248
- return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
7249
- created: [{ kind: "identity", id }]
7250
- });
7251
- });
7252
- }
7253
-
7254
7961
  // src/identities/remove_identity.ts
7255
7962
  function removeIdentity(db, identityId, removedAt) {
7256
7963
  return withTransaction(db, () => {
@@ -7302,6 +8009,7 @@ function reviseIdentity(db, identityId, patch) {
7302
8009
  if (nextType.length === 0 || nextValue.length === 0) {
7303
8010
  throw new AffinityValidationError("type and value must be non-empty");
7304
8011
  }
8012
+ validateIdentityInput(nextType, nextValue);
7305
8013
  const nextLabel = patch.label !== void 0 ? patch.label : row.label;
7306
8014
  const nkNext = normalizeIdentityKey(nextType, nextValue);
7307
8015
  if (nkNext !== row.normalized_key) {
@@ -7356,7 +8064,9 @@ function contactLocatorSchema6(description) {
7356
8064
  contactId: integerSchema("Exact contact id."),
7357
8065
  identity: objectSchema(
7358
8066
  {
7359
- type: stringSchema("Identity type."),
8067
+ type: stringSchema(
8068
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
8069
+ ),
7360
8070
  value: stringSchema("Identity value.")
7361
8071
  },
7362
8072
  ["type", "value"],
@@ -7366,7 +8076,23 @@ function contactLocatorSchema6(description) {
7366
8076
  description
7367
8077
  };
7368
8078
  }
7369
- function manageIdentityToolHandler(db, input) {
8079
+ function manageIdentityToolHandler(db, rawInput) {
8080
+ const input = structuredClone(rawInput);
8081
+ const inputError = validateToolInput(manageIdentityTool.inputSchema, input);
8082
+ if (inputError)
8083
+ return toolFailure(
8084
+ "protocol",
8085
+ "invalid_input",
8086
+ "Invalid input.",
8087
+ inputError
8088
+ );
8089
+ if (input.action !== "add" && input.identityId === void 0)
8090
+ return toolFailure(
8091
+ "protocol",
8092
+ "invalid_input",
8093
+ "Missing identityId.",
8094
+ "identityId is required."
8095
+ );
7370
8096
  return withToolHandling(() => {
7371
8097
  switch (input.action) {
7372
8098
  case "add": {
@@ -7384,7 +8110,8 @@ function manageIdentityToolHandler(db, input) {
7384
8110
  return mutationToolResult(
7385
8111
  "revise",
7386
8112
  reviseIdentity(db, input.identityId, input.patch),
7387
- "identity"
8113
+ "identity",
8114
+ Object.keys(input.patch)
7388
8115
  );
7389
8116
  case "verify":
7390
8117
  return mutationToolResult(
@@ -7433,7 +8160,9 @@ var manageIdentityTool = defineAffinityTool({
7433
8160
  ),
7434
8161
  input: objectSchema(
7435
8162
  {
7436
- type: stringSchema("Identity type."),
8163
+ type: stringSchema(
8164
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
8165
+ ),
7437
8166
  value: stringSchema("Identity value."),
7438
8167
  label: stringSchema("Optional label."),
7439
8168
  verified: booleanSchema("Whether the identity starts verified."),
@@ -7447,7 +8176,9 @@ var manageIdentityTool = defineAffinityTool({
7447
8176
  ),
7448
8177
  patch: objectSchema(
7449
8178
  {
7450
- type: stringSchema("Optional identity type."),
8179
+ type: stringSchema(
8180
+ `Optional identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
8181
+ ),
7451
8182
  value: stringSchema("Optional identity value."),
7452
8183
  label: stringSchema("Optional label.")
7453
8184
  },
@@ -7505,7 +8236,19 @@ function linkLocatorSchema4(description) {
7505
8236
  description
7506
8237
  };
7507
8238
  }
7508
- function manageRelationshipToolHandler(db, input) {
8239
+ function manageRelationshipToolHandler(db, rawInput) {
8240
+ const input = structuredClone(rawInput);
8241
+ const inputError = validateToolInput(
8242
+ manageRelationshipTool.inputSchema,
8243
+ input
8244
+ );
8245
+ if (inputError)
8246
+ return toolFailure(
8247
+ "protocol",
8248
+ "invalid_input",
8249
+ "Invalid input.",
8250
+ inputError
8251
+ );
7509
8252
  return withToolHandling(() => {
7510
8253
  switch (input.action) {
7511
8254
  case "seed_social_link": {
@@ -7686,7 +8429,16 @@ function contactLocatorSchema8(description) {
7686
8429
  description
7687
8430
  };
7688
8431
  }
7689
- function mergeContactsToolHandler(db, input) {
8432
+ function mergeContactsToolHandler(db, rawInput) {
8433
+ const input = structuredClone(rawInput);
8434
+ const inputError = validateToolInput(mergeContactsTool.inputSchema, input);
8435
+ if (inputError)
8436
+ return toolFailure(
8437
+ "protocol",
8438
+ "invalid_input",
8439
+ "Invalid input.",
8440
+ inputError
8441
+ );
7690
8442
  return withToolHandling(() => {
7691
8443
  const winner = resolveContactLocator(db, input.winner, "winner");
7692
8444
  if (!winner.ok) {
@@ -7767,7 +8519,16 @@ function prepareParticipants2(db, raw, injectOwner) {
7767
8519
  const normalized = raw.map(normalizeParticipant2);
7768
8520
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
7769
8521
  }
7770
- function recordEventToolHandler(db, input) {
8522
+ function recordEventToolHandler(db, rawInput) {
8523
+ const input = structuredClone(rawInput);
8524
+ const inputError = validateToolInput(recordEventTool.inputSchema, input);
8525
+ if (inputError)
8526
+ return toolFailure(
8527
+ "protocol",
8528
+ "invalid_input",
8529
+ "Invalid input.",
8530
+ inputError
8531
+ );
7771
8532
  return withToolHandling(() => {
7772
8533
  const patched = {
7773
8534
  ...input.input,
@@ -7865,7 +8626,7 @@ var recordEventTool = defineAffinityTool({
7865
8626
  "observed"
7866
8627
  ])
7867
8628
  },
7868
- ["contactId", "role"]
8629
+ ["role"]
7869
8630
  ),
7870
8631
  "Participant list. Must be non-empty and include the owner contact."
7871
8632
  ),
@@ -7881,6 +8642,56 @@ var recordEventTool = defineAffinityTool({
7881
8642
  handler: recordEventToolHandler
7882
8643
  });
7883
8644
 
8645
+ // src/tools/enrich_review_item.ts
8646
+ function enrichReviewItem(db, item) {
8647
+ const name = (id) => {
8648
+ if (typeof id !== "number") return "";
8649
+ return db.prepare("SELECT name FROM contacts WHERE id = ?").get(id)?.name ?? `Contact #${id}`;
8650
+ };
8651
+ const result = { ...item };
8652
+ if (item.kind === "link") {
8653
+ const link = db.prepare(`SELECT l.*, a.name AS from_name, b.name AS to_name, a.is_owner AS from_owner, b.is_owner AS to_owner,
8654
+ lr.recency_score, lr.drift_priority, lr.readiness_score, lr.normalized_rank, lr.reciprocity_score, lr.radar_score FROM links l JOIN contacts a ON a.id = l.from_contact_id JOIN contacts b ON b.id = l.to_contact_id LEFT JOIN link_rollups lr ON lr.link_id = l.id WHERE l.id = ?`).get(item.id);
8655
+ if (link) {
8656
+ result.title = link.from_owner ? String(link.to_name) : link.to_owner ? String(link.from_name) : `${link.from_name} \xB7 ${link.to_name}`;
8657
+ result.subtitle = [link.kind, link.role].filter(Boolean).join(" \xB7 ");
8658
+ for (const key of ["affinity", "trust", "rank"])
8659
+ if (typeof link[key] === "number") result[key] = link[key];
8660
+ result.recencyScore ??= link.recency_score;
8661
+ result.driftPriority ??= link.drift_priority;
8662
+ result.readinessScore = link.readiness_score;
8663
+ result.normalizedRank ??= link.normalized_rank;
8664
+ result.reciprocityScore = link.reciprocity_score;
8665
+ result.radarScore = link.radar_score;
8666
+ }
8667
+ }
8668
+ if (item.kind === "event" && typeof item.linkId === "number" && !Array.isArray(item.participants)) {
8669
+ const link = db.prepare("SELECT from_contact_id, to_contact_id FROM links WHERE id = ?").get(item.linkId);
8670
+ if (link)
8671
+ result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
8672
+ }
8673
+ if (Array.isArray(item.participants)) {
8674
+ result.counterpartyNames = item.participants.map(
8675
+ (p) => name(p.contactId)
8676
+ );
8677
+ result.subtitle = [
8678
+ item.subtitle,
8679
+ result.counterpartyNames.join(", ")
8680
+ ].filter(Boolean).join(" \xB7 ");
8681
+ }
8682
+ if (typeof item.leftContactId === "number")
8683
+ result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
8684
+ if (typeof item.winnerContactId === "number")
8685
+ result.title = `Merged ${name(item.loserContactId)} into ${name(item.winnerContactId)}`;
8686
+ if (item.occursAt !== void 0 && item.kind === "contact")
8687
+ result.title = `${name(item.id)} \xB7 ${item.title}`;
8688
+ for (const [key, value] of Object.entries(result)) {
8689
+ if (typeof value === "number" && /score|priority|affinity|trust|rank/i.test(key))
8690
+ result[key] = Math.round(value * 1e3) / 1e3;
8691
+ }
8692
+ return result;
8693
+ }
8694
+
7884
8695
  // src/tools/tool_summary.ts
7885
8696
  function summarizeCount(count, singular) {
7886
8697
  if (count === 0) {
@@ -7895,6 +8706,7 @@ function summarizeCount(count, singular) {
7895
8706
  // src/tools/review_affinity_tool.ts
7896
8707
  function buildReadOptions(input) {
7897
8708
  return {
8709
+ ...input.now === void 0 ? {} : { now: input.now },
7898
8710
  ...input.limit === void 0 ? {} : { limit: input.limit },
7899
8711
  ...input.offset === void 0 ? {} : { offset: input.offset },
7900
8712
  ...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
@@ -8014,6 +8826,7 @@ function validateReviewInput(input) {
8014
8826
  }
8015
8827
  function duplicateCandidateItem(candidate) {
8016
8828
  return {
8829
+ ...candidate,
8017
8830
  kind: "contact",
8018
8831
  id: candidate.leftContactId,
8019
8832
  title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
@@ -8023,24 +8836,28 @@ function duplicateCandidateItem(candidate) {
8023
8836
  }
8024
8837
  function momentItem(moment) {
8025
8838
  return {
8839
+ ...moment,
8840
+ occurredAtIso: new Date(moment.occurredAt).toISOString(),
8026
8841
  kind: "event",
8027
8842
  id: moment.eventId,
8028
8843
  title: moment.summary,
8029
8844
  subtitle: moment.momentKind,
8030
- snippet: `link #${moment.linkId} at ${moment.occurredAt}`
8845
+ snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
8031
8846
  };
8032
8847
  }
8033
8848
  function commitmentItem(commitment) {
8034
8849
  return {
8850
+ ...commitment,
8035
8851
  kind: "event",
8036
8852
  id: commitment.eventId,
8037
8853
  title: commitment.summary,
8038
8854
  subtitle: commitment.type,
8039
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt ?? "none"}`
8855
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
8040
8856
  };
8041
8857
  }
8042
8858
  function radarItem(radar) {
8043
8859
  return {
8860
+ ...radar,
8044
8861
  kind: "link",
8045
8862
  id: radar.linkId,
8046
8863
  title: `Link #${radar.linkId}`,
@@ -8050,29 +8867,34 @@ function radarItem(radar) {
8050
8867
  }
8051
8868
  function upcomingDateItem(date) {
8052
8869
  return {
8870
+ ...date,
8871
+ occursAt: date.occursOn,
8872
+ occursAtIso: new Date(date.occursOn).toISOString(),
8053
8873
  kind: date.targetRef.kind,
8054
8874
  id: date.targetRef.id,
8055
8875
  title: date.summary,
8056
8876
  subtitle: date.recurrenceKind,
8057
- snippet: `occurs ${date.occursOn}`
8877
+ snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
8058
8878
  };
8059
8879
  }
8060
8880
  function mergeHistoryItem(record) {
8061
8881
  return {
8882
+ ...record,
8062
8883
  kind: "contact",
8063
8884
  id: record.winnerContactId,
8064
8885
  title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
8065
- subtitle: `at ${record.mergedAt}`,
8886
+ subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
8066
8887
  snippet: record.reasonSummary ?? void 0
8067
8888
  };
8068
8889
  }
8069
8890
  function dismissedPairItem(record) {
8070
8891
  return {
8892
+ ...record,
8071
8893
  kind: "contact",
8072
8894
  id: record.leftContactId,
8073
8895
  title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
8074
8896
  subtitle: record.reason ?? "no reason given",
8075
- snippet: `dismissed at ${record.dismissedAt}`
8897
+ snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
8076
8898
  };
8077
8899
  }
8078
8900
  function compactFilters(input) {
@@ -8082,7 +8904,16 @@ function compactFilters(input) {
8082
8904
  )
8083
8905
  );
8084
8906
  }
8085
- function reviewAffinityToolHandler(db, input) {
8907
+ function reviewAffinityToolHandler(db, rawInput) {
8908
+ const input = structuredClone(rawInput);
8909
+ const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
8910
+ if (inputError)
8911
+ return toolFailure(
8912
+ "protocol",
8913
+ "invalid_input",
8914
+ "Invalid input.",
8915
+ inputError
8916
+ );
8086
8917
  return withToolHandling(() => {
8087
8918
  const invalid = validateReviewInput(input);
8088
8919
  if (invalid) {
@@ -8096,6 +8927,9 @@ function reviewAffinityToolHandler(db, input) {
8096
8927
  items = listContacts(
8097
8928
  db,
8098
8929
  {
8930
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
8931
+ ...input.tag === void 0 ? {} : { tag: input.tag },
8932
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
8099
8933
  ...input.kind === void 0 ? {} : { kind: input.kind },
8100
8934
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
8101
8935
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -8126,7 +8960,7 @@ function reviewAffinityToolHandler(db, input) {
8126
8960
  "Provide `contact` when `view` is `events.contact_journal`."
8127
8961
  );
8128
8962
  }
8129
- const contact = resolveContactLocator(db, locator, "contact");
8963
+ const contact = resolveContactLocator(db, locator, "contact", true);
8130
8964
  if (!contact.ok) {
8131
8965
  return contact.result;
8132
8966
  }
@@ -8161,7 +8995,12 @@ function reviewAffinityToolHandler(db, input) {
8161
8995
  let contactId;
8162
8996
  let linkId;
8163
8997
  if (input.contact !== void 0) {
8164
- const contact = resolveContactLocator(db, input.contact, "contact");
8998
+ const contact = resolveContactLocator(
8999
+ db,
9000
+ input.contact,
9001
+ "contact",
9002
+ true
9003
+ );
8165
9004
  if (!contact.ok) {
8166
9005
  return contact.result;
8167
9006
  }
@@ -8190,7 +9029,12 @@ function reviewAffinityToolHandler(db, input) {
8190
9029
  let contactId;
8191
9030
  let linkId;
8192
9031
  if (input.contact !== void 0) {
8193
- const contact = resolveContactLocator(db, input.contact, "contact");
9032
+ const contact = resolveContactLocator(
9033
+ db,
9034
+ input.contact,
9035
+ "contact",
9036
+ true
9037
+ );
8194
9038
  if (!contact.ok) {
8195
9039
  return contact.result;
8196
9040
  }
@@ -8283,7 +9127,7 @@ function reviewAffinityToolHandler(db, input) {
8283
9127
  "Provide `contact` when `view` is `merges.history`."
8284
9128
  );
8285
9129
  }
8286
- const contact = resolveContactLocator(db, locator, "contact");
9130
+ const contact = resolveContactLocator(db, locator, "contact", true);
8287
9131
  if (!contact.ok) {
8288
9132
  return contact.result;
8289
9133
  }
@@ -8296,6 +9140,7 @@ function reviewAffinityToolHandler(db, input) {
8296
9140
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
8297
9141
  break;
8298
9142
  }
9143
+ items = items.map((item) => enrichReviewItem(db, item));
8299
9144
  return toolSuccess(
8300
9145
  summarizeCount(items.length, "item"),
8301
9146
  {
@@ -8328,12 +9173,16 @@ var reviewAffinityTool = defineAffinityTool({
8328
9173
  targetKinds: ["contact", "link", "event"],
8329
9174
  inputDescriptions: {
8330
9175
  view: "Which review surface to return.",
9176
+ now: "Optional current timestamp for overdue calculations.",
8331
9177
  contact: "Target contact locator for views that require one.",
8332
9178
  link: "Target link locator for views that require one.",
8333
9179
  contactIds: "Optional contact id subset for duplicate review or graph review.",
8334
9180
  kind: "Optional contact kind or link kind filter, depending on the view.",
8335
9181
  lifecycleState: "Optional contact lifecycle filter for the contact list view.",
8336
9182
  linkState: "Optional link state filter for link review views.",
9183
+ attribute: "Attribute name or name=value; arrays require all filters.",
9184
+ tag: "Tag name; arrays require all tags.",
9185
+ includeLost: "Whether lost contacts are included.",
8337
9186
  includeOwner: "Whether owner contacts should be included in contact review.",
8338
9187
  includeDormant: "Whether dormant links or contacts should be included where supported.",
8339
9188
  includeArchived: "Whether archived links should be included where supported.",
@@ -8370,6 +9219,9 @@ var reviewAffinityTool = defineAffinityTool({
8370
9219
  "merges.history",
8371
9220
  "merges.dismissed"
8372
9221
  ]),
9222
+ now: integerSchema(
9223
+ "Optional current timestamp for overdue calculations."
9224
+ ),
8373
9225
  contact: contactLocatorSchema9("Optional target contact locator."),
8374
9226
  link: linkLocatorSchema5("Optional target link locator."),
8375
9227
  contactIds: arraySchema(
@@ -8392,6 +9244,17 @@ var reviewAffinityTool = defineAffinityTool({
8392
9244
  "broken",
8393
9245
  "archived"
8394
9246
  ]),
9247
+ attribute: oneOfSchema([
9248
+ stringSchema("Attribute filter."),
9249
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
9250
+ ]),
9251
+ tag: oneOfSchema([
9252
+ stringSchema("Tag filter."),
9253
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
9254
+ ]),
9255
+ includeLost: booleanSchema(
9256
+ "Include lost contacts (search excludes them by default)."
9257
+ ),
8395
9258
  includeOwner: booleanSchema("Whether owner contacts are included."),
8396
9259
  includeDormant: booleanSchema("Whether dormant rows are included."),
8397
9260
  includeArchived: booleanSchema("Whether archived links are included."),
@@ -8436,7 +9299,16 @@ var reviewAffinityTool = defineAffinityTool({
8436
9299
  });
8437
9300
 
8438
9301
  // src/tools/search_affinity_tool.ts
8439
- function searchAffinityToolHandler(db, input) {
9302
+ function searchAffinityToolHandler(db, rawInput) {
9303
+ const input = structuredClone(rawInput);
9304
+ const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
9305
+ if (inputError)
9306
+ return toolFailure(
9307
+ "protocol",
9308
+ "invalid_input",
9309
+ "Invalid input.",
9310
+ inputError
9311
+ );
8440
9312
  return withToolHandling(() => {
8441
9313
  const options = {
8442
9314
  ...input.limit === void 0 ? {} : { limit: input.limit },
@@ -8446,6 +9318,9 @@ function searchAffinityToolHandler(db, input) {
8446
9318
  db,
8447
9319
  input.query,
8448
9320
  {
9321
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
9322
+ ...input.tag === void 0 ? {} : { tag: input.tag },
9323
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
8449
9324
  ...input.kind === void 0 ? {} : { kind: input.kind },
8450
9325
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
8451
9326
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -8469,7 +9344,7 @@ function searchAffinityToolHandler(db, input) {
8469
9344
  }
8470
9345
  var searchAffinityTool = defineAffinityTool({
8471
9346
  name: searchAffinityToolName,
8472
- description: "Search contacts by name or identity, including exact natural-key lookups like email-style identifiers.",
9347
+ description: "Search contacts by name, identity, attributes, link roles, bonds and event text. All whitespace tokens must match. Dormant and lost contacts are excluded by default; use includeDormant and includeLost to include them.",
8473
9348
  whenToUse: "Use this when you want to find contacts from free text or a known routing identity such as an email or URL-like handle.",
8474
9349
  whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
8475
9350
  sideEffects: "none",
@@ -8480,6 +9355,9 @@ var searchAffinityTool = defineAffinityTool({
8480
9355
  query: "Free-text or natural-key search text.",
8481
9356
  kind: "Optional contact kind filter.",
8482
9357
  lifecycleState: "Optional lifecycle state filter.",
9358
+ attribute: "Attribute name or name=value; arrays require all filters.",
9359
+ tag: "Tag name; arrays require all tags.",
9360
+ includeLost: "Whether lost contacts are included in search.",
8483
9361
  includeOwner: "Whether owner contacts are eligible for results.",
8484
9362
  includeDormant: "Whether dormant contacts should be eligible for results.",
8485
9363
  limit: "Optional result limit.",
@@ -8504,6 +9382,17 @@ var searchAffinityTool = defineAffinityTool({
8504
9382
  "merged",
8505
9383
  "lost"
8506
9384
  ]),
9385
+ attribute: oneOfSchema([
9386
+ stringSchema("Attribute filter."),
9387
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
9388
+ ]),
9389
+ tag: oneOfSchema([
9390
+ stringSchema("Tag filter."),
9391
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
9392
+ ]),
9393
+ includeLost: booleanSchema(
9394
+ "Include lost contacts (search excludes them by default)."
9395
+ ),
8507
9396
  includeOwner: booleanSchema("Whether owner contacts are included."),
8508
9397
  includeDormant: booleanSchema("Whether dormant contacts are included."),
8509
9398
  limit: integerSchema("Optional result limit."),
@@ -8539,6 +9428,9 @@ function getAffinityToolByName(name) {
8539
9428
 
8540
9429
  // src/tools/tool_mapping.ts
8541
9430
  var affinityToolMappings = [
9431
+ { source: "exportContact", tool: manageContactToolName, action: "export" },
9432
+ { source: "exportAffinity", tool: manageContactToolName, action: "export" },
9433
+ { source: "deleteContact", tool: manageContactToolName, action: "delete" },
8542
9434
  { source: "searchContacts", tool: searchAffinityToolName },
8543
9435
  {
8544
9436
  source: "getOwnerProfile",
@@ -8725,6 +9617,7 @@ __export(write_exports, {
8725
9617
  addDateAnchor: () => addDateAnchor,
8726
9618
  addIdentity: () => addIdentity,
8727
9619
  createContact: () => createContact,
9620
+ deleteContact: () => deleteContact,
8728
9621
  dismissDuplicate: () => dismissDuplicate,
8729
9622
  mergeContacts: () => mergeContacts,
8730
9623
  overrideLinkState: () => overrideLinkState,