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