@ghostpaw/affinity 0.4.0 → 0.5.0

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