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