@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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdirSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
 
6
6
  var __defProp = Object.defineProperty;
@@ -257,10 +257,10 @@ async function serveMcpStdio(server, options = {}) {
257
257
  const response = await server.handleLine(line.replace(/\r$/, ""));
258
258
  if (response !== void 0)
259
259
  await new Promise(
260
- (resolve, reject) => output.write(
260
+ (resolve2, reject) => output.write(
261
261
  `${response}
262
262
  `,
263
- (error) => error ? reject(error) : resolve()
263
+ (error) => error ? reject(error) : resolve2()
264
264
  )
265
265
  );
266
266
  };
@@ -285,7 +285,7 @@ var init_mcp_stdio = __esm({
285
285
  var affinityVersion;
286
286
  var init_version = __esm({
287
287
  "src/version.ts"() {
288
- affinityVersion = "0.4.0";
288
+ affinityVersion = "0.5.0";
289
289
  }
290
290
  });
291
291
 
@@ -297,6 +297,16 @@ var init_affinity_mcp_instructions = __esm({
297
297
  }
298
298
  });
299
299
 
300
+ // src/surfaces/cli_args.ts
301
+ var CliUsageError;
302
+ var init_cli_args = __esm({
303
+ "src/surfaces/cli_args.ts"() {
304
+ CliUsageError = class extends Error {
305
+ exitCode = 2;
306
+ };
307
+ }
308
+ });
309
+
300
310
  // src/lib/errors/affinity_error.ts
301
311
  var AffinityError;
302
312
  var init_affinity_error = __esm({
@@ -311,6 +321,78 @@ var init_affinity_error = __esm({
311
321
  }
312
322
  });
313
323
 
324
+ // src/lib/errors/affinity_validation_error.ts
325
+ var AffinityValidationError;
326
+ var init_affinity_validation_error = __esm({
327
+ "src/lib/errors/affinity_validation_error.ts"() {
328
+ init_affinity_error();
329
+ AffinityValidationError = class extends AffinityError {
330
+ code = "VALIDATION";
331
+ };
332
+ }
333
+ });
334
+
335
+ // src/lib/read/assert_default_ordering.ts
336
+ function assertDefaultOrdering(_readName, options) {
337
+ if (options?.sort !== void 0 || options?.descending !== void 0) {
338
+ throw new AffinityValidationError(
339
+ "This review does not support custom sort ordering"
340
+ );
341
+ }
342
+ }
343
+ var init_assert_default_ordering = __esm({
344
+ "src/lib/read/assert_default_ordering.ts"() {
345
+ init_affinity_validation_error();
346
+ }
347
+ });
348
+
349
+ // src/lib/types/read_list_options.ts
350
+ var AFFINITY_DEFAULT_LIST_LIMIT, AFFINITY_MAX_LIST_LIMIT;
351
+ var init_read_list_options = __esm({
352
+ "src/lib/types/read_list_options.ts"() {
353
+ AFFINITY_DEFAULT_LIST_LIMIT = 50;
354
+ AFFINITY_MAX_LIST_LIMIT = 250;
355
+ }
356
+ });
357
+
358
+ // src/lib/types/resolve_affinity_list_limit.ts
359
+ function resolveAffinityListLimit(limit) {
360
+ if (limit === void 0) {
361
+ return AFFINITY_DEFAULT_LIST_LIMIT;
362
+ }
363
+ if (!Number.isInteger(limit) || limit < 1) {
364
+ throw new AffinityValidationError("limit must be a positive integer");
365
+ }
366
+ if (limit > AFFINITY_MAX_LIST_LIMIT) {
367
+ throw new AffinityValidationError(
368
+ `limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
369
+ );
370
+ }
371
+ return limit;
372
+ }
373
+ var init_resolve_affinity_list_limit = __esm({
374
+ "src/lib/types/resolve_affinity_list_limit.ts"() {
375
+ init_affinity_validation_error();
376
+ init_read_list_options();
377
+ }
378
+ });
379
+
380
+ // src/lib/read/resolve_list_limit_offset.ts
381
+ function resolveListLimitOffset(options) {
382
+ const limit = resolveAffinityListLimit(options?.limit);
383
+ const offset = options?.offset ?? 0;
384
+ if (!Number.isInteger(offset) || offset < 0) {
385
+ throw new AffinityValidationError("offset must be a non-negative integer");
386
+ }
387
+ return { limit, offset };
388
+ }
389
+ var init_resolve_list_limit_offset = __esm({
390
+ "src/lib/read/resolve_list_limit_offset.ts"() {
391
+ init_affinity_validation_error();
392
+ init_resolve_affinity_list_limit();
393
+ }
394
+ });
395
+
314
396
  // src/lib/errors/affinity_invariant_error.ts
315
397
  var AffinityInvariantError;
316
398
  var init_affinity_invariant_error = __esm({
@@ -322,6 +404,17 @@ var init_affinity_invariant_error = __esm({
322
404
  }
323
405
  });
324
406
 
407
+ // src/lib/errors/affinity_not_found_error.ts
408
+ var AffinityNotFoundError;
409
+ var init_affinity_not_found_error = __esm({
410
+ "src/lib/errors/affinity_not_found_error.ts"() {
411
+ init_affinity_error();
412
+ AffinityNotFoundError = class extends AffinityError {
413
+ code = "NOT_FOUND";
414
+ };
415
+ }
416
+ });
417
+
325
418
  // src/contacts/queries.ts
326
419
  function getContactRowById(db, id) {
327
420
  const row = db.prepare(
@@ -358,17 +451,6 @@ var init_queries = __esm({
358
451
  }
359
452
  });
360
453
 
361
- // src/lib/errors/affinity_not_found_error.ts
362
- var AffinityNotFoundError;
363
- var init_affinity_not_found_error = __esm({
364
- "src/lib/errors/affinity_not_found_error.ts"() {
365
- init_affinity_error();
366
- AffinityNotFoundError = class extends AffinityError {
367
- code = "NOT_FOUND";
368
- };
369
- }
370
- });
371
-
372
454
  // src/lib/errors/affinity_state_error.ts
373
455
  var AffinityStateError;
374
456
  var init_affinity_state_error = __esm({
@@ -380,17 +462,6 @@ var init_affinity_state_error = __esm({
380
462
  }
381
463
  });
382
464
 
383
- // src/lib/errors/affinity_validation_error.ts
384
- var AffinityValidationError;
385
- var init_affinity_validation_error = __esm({
386
- "src/lib/errors/affinity_validation_error.ts"() {
387
- init_affinity_error();
388
- AffinityValidationError = class extends AffinityError {
389
- code = "VALIDATION";
390
- };
391
- }
392
- });
393
-
394
465
  // src/events/validators.ts
395
466
  function assertOwnerParticipates(ownerId, participantIds) {
396
467
  if (!participantIds.includes(ownerId)) {
@@ -404,7 +475,10 @@ function assertParticipantContactsLive(db, contactIds) {
404
475
  throw new AffinityNotFoundError("contact not found");
405
476
  }
406
477
  if (row.lifecycle_state === "merged") {
407
- throw new AffinityStateError("merged contact is read-only");
478
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
479
+ throw new AffinityStateError(
480
+ `Contact #${id} was merged into #${row.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row.merged_into_contact_id}`
481
+ );
408
482
  }
409
483
  }
410
484
  }
@@ -615,6 +689,149 @@ var init_queries2 = __esm({
615
689
  }
616
690
  });
617
691
 
692
+ // src/events/loaders.ts
693
+ function loadEventParticipantViews(db, eventId) {
694
+ const rows = db.prepare(
695
+ `SELECT contact_id, role, directionality
696
+ FROM event_participants
697
+ WHERE event_id = ?`
698
+ ).all(eventId);
699
+ return rows.map((row) => ({
700
+ contactId: row.contact_id,
701
+ role: row.role,
702
+ ...row.directionality === null ? {} : { directionality: row.directionality }
703
+ }));
704
+ }
705
+ function loadEventRecord(db, eventId) {
706
+ const row = getEventRowById(db, eventId);
707
+ if (!row) {
708
+ throw new AffinityInvariantError("event not found");
709
+ }
710
+ const participants = loadEventParticipantViews(db, eventId);
711
+ return mapEventRowToEventRecord(row, participants);
712
+ }
713
+ function batchLoadEventRecords(db, eventIds) {
714
+ if (eventIds.length === 0) {
715
+ return [];
716
+ }
717
+ const ph = eventIds.map(() => "?").join(", ");
718
+ const eventRows = db.prepare(
719
+ `SELECT id, type, occurred_at, summary, significance, moment_kind,
720
+ recurrence_kind, anchor_month, anchor_day,
721
+ anchor_contact_id, anchor_link_id,
722
+ created_at, updated_at, deleted_at
723
+ FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
724
+ ).all(...eventIds);
725
+ const partRows = db.prepare(
726
+ `SELECT event_id, contact_id, role, directionality
727
+ FROM event_participants WHERE event_id IN (${ph})`
728
+ ).all(...eventIds);
729
+ const partsByEvent = /* @__PURE__ */ new Map();
730
+ for (const p of partRows) {
731
+ let arr = partsByEvent.get(p.event_id);
732
+ if (!arr) {
733
+ arr = [];
734
+ partsByEvent.set(p.event_id, arr);
735
+ }
736
+ arr.push({
737
+ contactId: p.contact_id,
738
+ role: p.role,
739
+ ...p.directionality === null ? {} : { directionality: p.directionality }
740
+ });
741
+ }
742
+ const rowById = new Map(eventRows.map((r) => [r.id, r]));
743
+ const out = [];
744
+ for (const id of eventIds) {
745
+ const row = rowById.get(id);
746
+ if (row) {
747
+ out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
748
+ }
749
+ }
750
+ return out;
751
+ }
752
+ function batchLoadEventParticipantViews(db, eventIds) {
753
+ const result = /* @__PURE__ */ new Map();
754
+ if (eventIds.length === 0) {
755
+ return result;
756
+ }
757
+ const ph = eventIds.map(() => "?").join(", ");
758
+ const rows = db.prepare(
759
+ `SELECT event_id, contact_id, role, directionality
760
+ FROM event_participants WHERE event_id IN (${ph})`
761
+ ).all(...eventIds);
762
+ for (const row of rows) {
763
+ let arr = result.get(row.event_id);
764
+ if (!arr) {
765
+ arr = [];
766
+ result.set(row.event_id, arr);
767
+ }
768
+ arr.push({
769
+ contactId: row.contact_id,
770
+ role: row.role,
771
+ ...row.directionality === null ? {} : { directionality: row.directionality }
772
+ });
773
+ }
774
+ return result;
775
+ }
776
+ function requireDateAnchorEvent(db, eventId) {
777
+ const row = getEventRowById(db, eventId);
778
+ if (!row) {
779
+ throw new AffinityNotFoundError("event not found");
780
+ }
781
+ if (row.type !== "date_anchor") {
782
+ throw new AffinityInvariantError("not a date anchor");
783
+ }
784
+ return row;
785
+ }
786
+ var init_loaders = __esm({
787
+ "src/events/loaders.ts"() {
788
+ init_affinity_invariant_error();
789
+ init_affinity_not_found_error();
790
+ init_mappers();
791
+ init_queries2();
792
+ }
793
+ });
794
+
795
+ // src/events/get_contact_journal.ts
796
+ function getContactJournal(db, contactId, options) {
797
+ assertDefaultOrdering("getContactJournal", options);
798
+ const { limit, offset } = resolveListLimitOffset(options);
799
+ const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
800
+ const params = [contactId];
801
+ if (options?.since !== void 0) {
802
+ clauses.push("e.occurred_at >= ?");
803
+ params.push(options.since);
804
+ }
805
+ if (options?.until !== void 0) {
806
+ clauses.push("e.occurred_at <= ?");
807
+ params.push(options.until);
808
+ }
809
+ if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
810
+ const ph = options.eventTypes.map(() => "?").join(", ");
811
+ clauses.push(`e.type IN (${ph})`);
812
+ params.push(...options.eventTypes);
813
+ }
814
+ const where = clauses.join(" AND ");
815
+ const rows = db.prepare(
816
+ `SELECT e.id FROM events e
817
+ INNER JOIN event_participants ep ON ep.event_id = e.id
818
+ WHERE ${where}
819
+ ORDER BY e.occurred_at DESC, e.id DESC
820
+ LIMIT ? OFFSET ?`
821
+ ).all(...params, limit, offset);
822
+ return batchLoadEventRecords(
823
+ db,
824
+ rows.map((r) => r.id)
825
+ );
826
+ }
827
+ var init_get_contact_journal = __esm({
828
+ "src/events/get_contact_journal.ts"() {
829
+ init_assert_default_ordering();
830
+ init_resolve_list_limit_offset();
831
+ init_loaders();
832
+ }
833
+ });
834
+
618
835
  // src/resolve_now.ts
619
836
  function resolveNow(now) {
620
837
  if (now !== void 0) {
@@ -633,9 +850,98 @@ var init_resolve_now = __esm({
633
850
  }
634
851
  });
635
852
 
636
- // src/with_transaction.ts
637
- function withTransaction(db, fn) {
638
- const depth = transactionDepth.get(db) ?? 0;
853
+ // src/events/list_open_commitments.ts
854
+ function mapResolution(resolvedAt, resolution) {
855
+ if (resolvedAt === null) {
856
+ return "open";
857
+ }
858
+ if (resolution === "cancelled") {
859
+ return "cancelled";
860
+ }
861
+ return "resolved";
862
+ }
863
+ function listOpenCommitments(db, filters, options) {
864
+ assertDefaultOrdering("listOpenCommitments", options);
865
+ const now = resolveNow(options?.now);
866
+ const { limit, offset } = resolveListLimitOffset(options);
867
+ const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
868
+ const params = [];
869
+ if (options?.since !== void 0) {
870
+ clauses.push("e.occurred_at >= ?");
871
+ params.push(options.since);
872
+ }
873
+ if (options?.until !== void 0) {
874
+ clauses.push("e.occurred_at <= ?");
875
+ params.push(options.until);
876
+ }
877
+ if (filters?.commitmentType !== void 0) {
878
+ if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
879
+ throw new AffinityValidationError("invalid commitmentType filter");
880
+ }
881
+ clauses.push("oc.commitment_type = ?");
882
+ params.push(filters.commitmentType);
883
+ }
884
+ if (filters?.contactId !== void 0) {
885
+ clauses.push(
886
+ "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
887
+ );
888
+ params.push(filters.contactId);
889
+ }
890
+ if (filters?.linkId !== void 0) {
891
+ clauses.push(
892
+ "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
893
+ );
894
+ params.push(filters.linkId);
895
+ }
896
+ if (filters?.horizonDays !== void 0) {
897
+ if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
898
+ throw new AffinityValidationError(
899
+ "horizonDays must be a non-negative integer"
900
+ );
901
+ }
902
+ const anchor = options?.since ?? now;
903
+ clauses.push("oc.due_at IS NOT NULL");
904
+ clauses.push("oc.due_at <= ?");
905
+ params.push(anchor + filters.horizonDays * DAY_MS);
906
+ }
907
+ const where = clauses.join(" AND ");
908
+ const rows = db.prepare(
909
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
910
+ e.type, e.summary
911
+ FROM open_commitments oc
912
+ INNER JOIN events e ON e.id = oc.event_id
913
+ WHERE ${where}
914
+ ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
915
+ LIMIT ? OFFSET ?`
916
+ ).all(...params, limit, offset);
917
+ const eventIds = rows.map((r) => r.event_id);
918
+ const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
919
+ return rows.map((r) => ({
920
+ eventId: r.event_id,
921
+ type: r.type,
922
+ summary: r.summary,
923
+ participants: partsByEvent.get(r.event_id) ?? [],
924
+ dueAt: r.due_at,
925
+ dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
926
+ overdue: r.due_at !== null && r.due_at < now,
927
+ resolutionState: mapResolution(r.resolved_at, r.resolution)
928
+ }));
929
+ }
930
+ var DAY_MS;
931
+ var init_list_open_commitments = __esm({
932
+ "src/events/list_open_commitments.ts"() {
933
+ init_affinity_validation_error();
934
+ init_assert_default_ordering();
935
+ init_resolve_list_limit_offset();
936
+ init_resolve_now();
937
+ init_loaders();
938
+ DAY_MS = 864e5;
939
+ }
940
+ });
941
+
942
+ // src/with_transaction.ts
943
+ function withTransaction(db, fn) {
944
+ const depth = transactionDepth.get(db) ?? 0;
639
945
  const savepoint = `affinity_${String(depth)}`;
640
946
  if (depth === 0) {
641
947
  db.exec("BEGIN IMMEDIATE");
@@ -937,13 +1243,20 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
937
1243
  const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
938
1244
  const activeDates = loadActiveDatesForContact(db, contactId);
939
1245
  const contactRollup = loadContactRollup(db, contactId);
1246
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
940
1247
  return {
1248
+ ...winner ? { mergedInto: { id: winner.id, name: winner.name } } : {},
941
1249
  contact: mapContactRowToContactCore(row),
942
1250
  identities,
943
1251
  attributes,
944
1252
  topLinks,
945
1253
  activeDates,
946
- warnings: contactRollup?.warnings ?? [],
1254
+ warnings: [
1255
+ ...contactRollup?.warnings ?? [],
1256
+ ...winner ? [
1257
+ `Contact #${row.id} was merged into #${winner.id} ${winner.name}; use contactId ${winner.id}`
1258
+ ] : []
1259
+ ],
947
1260
  rollups: contactRollup?.rollup ?? null
948
1261
  };
949
1262
  }
@@ -961,10 +1274,32 @@ var init_load_contact_profile_record = __esm({
961
1274
  // src/contacts/get_contact_profile.ts
962
1275
  function getContactProfile(db, contactId, options) {
963
1276
  const t = options?.topLinksLimit ?? 8;
964
- return loadContactProfileRecord(db, contactId, t);
1277
+ const profile = loadContactProfileRecord(db, contactId, t);
1278
+ if (!profile) return null;
1279
+ const ties = db.prepare(`SELECT l.id, l.kind, l.role, c.id AS contactId, c.name
1280
+ 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
1281
+ WHERE l.is_structural = 1 AND l.removed_at IS NULL AND (l.from_contact_id = ? OR l.to_contact_id = ?)
1282
+ AND c.deleted_at IS NULL ORDER BY l.id`).all(contactId, contactId, contactId);
1283
+ return {
1284
+ ...profile,
1285
+ recentEvents: getContactJournal(db, contactId, {
1286
+ limit: options?.recentEventsLimit ?? 5
1287
+ }).map((event) => ({
1288
+ ...event,
1289
+ occurredAtIso: new Date(event.occurredAt).toISOString()
1290
+ })),
1291
+ openCommitments: listOpenCommitments(
1292
+ db,
1293
+ { contactId },
1294
+ options?.now === void 0 ? void 0 : { now: options.now }
1295
+ ),
1296
+ ties
1297
+ };
965
1298
  }
966
1299
  var init_get_contact_profile = __esm({
967
1300
  "src/contacts/get_contact_profile.ts"() {
1301
+ init_get_contact_journal();
1302
+ init_list_open_commitments();
968
1303
  init_load_contact_profile_record();
969
1304
  }
970
1305
  });
@@ -985,67 +1320,6 @@ var init_get_owner_profile = __esm({
985
1320
  }
986
1321
  });
987
1322
 
988
- // src/lib/read/assert_default_ordering.ts
989
- function assertDefaultOrdering(readName, options) {
990
- if (options?.sort !== void 0 || options?.descending !== void 0) {
991
- throw new AffinityValidationError(
992
- `${readName} does not support custom sort ordering`
993
- );
994
- }
995
- }
996
- var init_assert_default_ordering = __esm({
997
- "src/lib/read/assert_default_ordering.ts"() {
998
- init_affinity_validation_error();
999
- }
1000
- });
1001
-
1002
- // src/lib/types/read_list_options.ts
1003
- var AFFINITY_DEFAULT_LIST_LIMIT, AFFINITY_MAX_LIST_LIMIT;
1004
- var init_read_list_options = __esm({
1005
- "src/lib/types/read_list_options.ts"() {
1006
- AFFINITY_DEFAULT_LIST_LIMIT = 50;
1007
- AFFINITY_MAX_LIST_LIMIT = 250;
1008
- }
1009
- });
1010
-
1011
- // src/lib/types/resolve_affinity_list_limit.ts
1012
- function resolveAffinityListLimit(limit) {
1013
- if (limit === void 0) {
1014
- return AFFINITY_DEFAULT_LIST_LIMIT;
1015
- }
1016
- if (!Number.isInteger(limit) || limit < 1) {
1017
- throw new AffinityValidationError("limit must be a positive integer");
1018
- }
1019
- if (limit > AFFINITY_MAX_LIST_LIMIT) {
1020
- throw new AffinityValidationError(
1021
- `limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
1022
- );
1023
- }
1024
- return limit;
1025
- }
1026
- var init_resolve_affinity_list_limit = __esm({
1027
- "src/lib/types/resolve_affinity_list_limit.ts"() {
1028
- init_affinity_validation_error();
1029
- init_read_list_options();
1030
- }
1031
- });
1032
-
1033
- // src/lib/read/resolve_list_limit_offset.ts
1034
- function resolveListLimitOffset(options) {
1035
- const limit = resolveAffinityListLimit(options?.limit);
1036
- const offset = options?.offset ?? 0;
1037
- if (!Number.isInteger(offset) || offset < 0) {
1038
- throw new AffinityValidationError("offset must be a non-negative integer");
1039
- }
1040
- return { limit, offset };
1041
- }
1042
- var init_resolve_list_limit_offset = __esm({
1043
- "src/lib/read/resolve_list_limit_offset.ts"() {
1044
- init_affinity_validation_error();
1045
- init_resolve_affinity_list_limit();
1046
- }
1047
- });
1048
-
1049
1323
  // src/events/list_moments.ts
1050
1324
  function listMoments(db, filters, options) {
1051
1325
  assertDefaultOrdering("listMoments", options);
@@ -1107,109 +1381,6 @@ var init_list_moments = __esm({
1107
1381
  }
1108
1382
  });
1109
1383
 
1110
- // src/events/loaders.ts
1111
- function loadEventParticipantViews(db, eventId) {
1112
- const rows = db.prepare(
1113
- `SELECT contact_id, role, directionality
1114
- FROM event_participants
1115
- WHERE event_id = ?`
1116
- ).all(eventId);
1117
- return rows.map((row) => ({
1118
- contactId: row.contact_id,
1119
- role: row.role,
1120
- ...row.directionality === null ? {} : { directionality: row.directionality }
1121
- }));
1122
- }
1123
- function loadEventRecord(db, eventId) {
1124
- const row = getEventRowById(db, eventId);
1125
- if (!row) {
1126
- throw new AffinityInvariantError("event not found");
1127
- }
1128
- const participants = loadEventParticipantViews(db, eventId);
1129
- return mapEventRowToEventRecord(row, participants);
1130
- }
1131
- function batchLoadEventRecords(db, eventIds) {
1132
- if (eventIds.length === 0) {
1133
- return [];
1134
- }
1135
- const ph = eventIds.map(() => "?").join(", ");
1136
- const eventRows = db.prepare(
1137
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
1138
- recurrence_kind, anchor_month, anchor_day,
1139
- anchor_contact_id, anchor_link_id,
1140
- created_at, updated_at, deleted_at
1141
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
1142
- ).all(...eventIds);
1143
- const partRows = db.prepare(
1144
- `SELECT event_id, contact_id, role, directionality
1145
- FROM event_participants WHERE event_id IN (${ph})`
1146
- ).all(...eventIds);
1147
- const partsByEvent = /* @__PURE__ */ new Map();
1148
- for (const p of partRows) {
1149
- let arr = partsByEvent.get(p.event_id);
1150
- if (!arr) {
1151
- arr = [];
1152
- partsByEvent.set(p.event_id, arr);
1153
- }
1154
- arr.push({
1155
- contactId: p.contact_id,
1156
- role: p.role,
1157
- ...p.directionality === null ? {} : { directionality: p.directionality }
1158
- });
1159
- }
1160
- const rowById = new Map(eventRows.map((r) => [r.id, r]));
1161
- const out = [];
1162
- for (const id of eventIds) {
1163
- const row = rowById.get(id);
1164
- if (row) {
1165
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
1166
- }
1167
- }
1168
- return out;
1169
- }
1170
- function batchLoadEventParticipantViews(db, eventIds) {
1171
- const result = /* @__PURE__ */ new Map();
1172
- if (eventIds.length === 0) {
1173
- return result;
1174
- }
1175
- const ph = eventIds.map(() => "?").join(", ");
1176
- const rows = db.prepare(
1177
- `SELECT event_id, contact_id, role, directionality
1178
- FROM event_participants WHERE event_id IN (${ph})`
1179
- ).all(...eventIds);
1180
- for (const row of rows) {
1181
- let arr = result.get(row.event_id);
1182
- if (!arr) {
1183
- arr = [];
1184
- result.set(row.event_id, arr);
1185
- }
1186
- arr.push({
1187
- contactId: row.contact_id,
1188
- role: row.role,
1189
- ...row.directionality === null ? {} : { directionality: row.directionality }
1190
- });
1191
- }
1192
- return result;
1193
- }
1194
- function requireDateAnchorEvent(db, eventId) {
1195
- const row = getEventRowById(db, eventId);
1196
- if (!row) {
1197
- throw new AffinityNotFoundError("event not found");
1198
- }
1199
- if (row.type !== "date_anchor") {
1200
- throw new AffinityInvariantError("not a date anchor");
1201
- }
1202
- return row;
1203
- }
1204
- var init_loaders = __esm({
1205
- "src/events/loaders.ts"() {
1206
- init_affinity_invariant_error();
1207
- init_affinity_not_found_error();
1208
- init_mappers();
1209
- init_queries2();
1210
- }
1211
- });
1212
-
1213
1384
  // src/links/queries.ts
1214
1385
  function getLinkRowById(db, id) {
1215
1386
  const row = db.prepare(
@@ -1482,7 +1653,7 @@ function recencyScore(occurredAt, cadenceDays, now) {
1482
1653
  return 0;
1483
1654
  }
1484
1655
  const cadence = Math.max(cadenceDays ?? 0, 1);
1485
- const daysSince = Math.max(0, (now - occurredAt) / DAY_MS);
1656
+ const daysSince = Math.max(0, (now - occurredAt) / DAY_MS2);
1486
1657
  return Math.max(0, 1 - daysSince / cadence);
1487
1658
  }
1488
1659
  function refreshLinkRollup(db, linkId, now) {
@@ -1493,15 +1664,15 @@ function refreshLinkRollup(db, linkId, now) {
1493
1664
  const latest = db.prepare(
1494
1665
  `SELECT event_id, occurred_at
1495
1666
  FROM link_event_effects
1496
- WHERE link_id = ?
1667
+ WHERE link_id = ? AND counts_for_recency = 1
1497
1668
  ORDER BY occurred_at DESC, event_id DESC
1498
1669
  LIMIT 1`
1499
1670
  ).get(linkId);
1500
- const trailingWindowStart = now - 180 * DAY_MS;
1671
+ const trailingWindowStart = now - 180 * DAY_MS2;
1501
1672
  const counts = db.prepare(
1502
1673
  `SELECT
1503
1674
  COUNT(*) AS total_meaningful_events,
1504
- SUM(CASE WHEN affinity_delta > 0 OR trust_delta > 0 THEN 1 ELSE 0 END) AS positive_meaningful_events
1675
+ SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
1505
1676
  FROM link_event_effects
1506
1677
  WHERE link_id = ? AND occurred_at >= ?`
1507
1678
  ).get(linkId, trailingWindowStart);
@@ -1509,7 +1680,7 @@ function refreshLinkRollup(db, linkId, now) {
1509
1680
  `SELECT ep.directionality
1510
1681
  FROM link_event_effects le
1511
1682
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
1512
- WHERE le.link_id = ? AND ep.contact_id = ?
1683
+ WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
1513
1684
  AND ep.directionality IS NOT NULL
1514
1685
  AND le.occurred_at >= ?`
1515
1686
  ).all(linkId, link.from_contact_id, trailingWindowStart);
@@ -1535,7 +1706,7 @@ function refreshLinkRollup(db, linkId, now) {
1535
1706
  link.cadence_days,
1536
1707
  now
1537
1708
  );
1538
- const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS);
1709
+ const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS2);
1539
1710
  const driftPriorityValue = driftPriority({
1540
1711
  daysSinceLastMeaningfulEvent,
1541
1712
  cadenceDays: link.cadence_days ?? 1,
@@ -1618,13 +1789,13 @@ function refreshLinkRollup(db, linkId, now) {
1618
1789
  refreshContactRollup(db, link.from_contact_id, now);
1619
1790
  refreshContactRollup(db, link.to_contact_id, now);
1620
1791
  }
1621
- var DAY_MS;
1792
+ var DAY_MS2;
1622
1793
  var init_rollups2 = __esm({
1623
1794
  "src/links/rollups.ts"() {
1624
1795
  init_rollups();
1625
1796
  init_mechanics();
1626
1797
  init_queries3();
1627
- DAY_MS = 864e5;
1798
+ DAY_MS2 = 864e5;
1628
1799
  }
1629
1800
  });
1630
1801
 
@@ -1682,8 +1853,61 @@ var init_get_link_detail = __esm({
1682
1853
  }
1683
1854
  });
1684
1855
 
1856
+ // src/tools/parse_time_input.ts
1857
+ function parseTimeInput(value, field = "time", now = Date.now()) {
1858
+ const fail = () => {
1859
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1860
+ };
1861
+ if (typeof value === "number")
1862
+ return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
1863
+ if (typeof value !== "string") return fail();
1864
+ const text = value.trim();
1865
+ const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
1866
+ let day;
1867
+ if (relative) {
1868
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1869
+ day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
1870
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
1871
+ day = Date.parse(`${text}T00:00:00.000Z`);
1872
+ if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
1873
+ return fail();
1874
+ } else {
1875
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1876
+ text
1877
+ ))
1878
+ return fail();
1879
+ const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
1880
+ if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
1881
+ return fail();
1882
+ const parsed = Date.parse(text);
1883
+ return Number.isFinite(parsed) ? parsed : fail();
1884
+ }
1885
+ const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
1886
+ return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
1887
+ }
1888
+ var timeInputForms;
1889
+ var init_parse_time_input = __esm({
1890
+ "src/tools/parse_time_input.ts"() {
1891
+ init_affinity_validation_error();
1892
+ timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
1893
+ }
1894
+ });
1895
+
1685
1896
  // src/tools/tool_metadata.ts
1686
1897
  function defineAffinityTool(definition) {
1898
+ const visit = (schema) => {
1899
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
1900
+ if ((key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.type === "integer") {
1901
+ child.type = void 0;
1902
+ child.oneOf = [{ type: "integer" }, { type: "string" }];
1903
+ child.description = `${child.description ?? "Time."} Accepts ${timeInputForms}. UTC midnight for dates; dueAt uses UTC end of day.`;
1904
+ }
1905
+ visit(child);
1906
+ }
1907
+ if (schema.items) visit(schema.items);
1908
+ for (const child of schema.oneOf ?? schema.anyOf ?? []) visit(child);
1909
+ };
1910
+ visit(definition.inputSchema);
1687
1911
  return definition;
1688
1912
  }
1689
1913
  function stringSchema(description, options = {}) {
@@ -1730,8 +1954,15 @@ function objectSchema(properties, required, description) {
1730
1954
  function nullableStringSchema(description) {
1731
1955
  return { anyOf: [{ type: "string" }, { type: "null" }], description };
1732
1956
  }
1957
+ function oneOfSchema(schemas, description) {
1958
+ const schema = {
1959
+ oneOf: schemas
1960
+ };
1961
+ return schema;
1962
+ }
1733
1963
  var init_tool_metadata = __esm({
1734
1964
  "src/tools/tool_metadata.ts"() {
1965
+ init_parse_time_input();
1735
1966
  }
1736
1967
  });
1737
1968
 
@@ -1772,6 +2003,7 @@ function toContactEntityRef(value) {
1772
2003
  function toContactListToolItem(value) {
1773
2004
  return {
1774
2005
  ...toContactEntityRef(value),
2006
+ ..."matchedOn" in value ? { matchedOn: value.matchedOn } : {},
1775
2007
  subtitle: "kind" in value ? value.kind : void 0
1776
2008
  };
1777
2009
  }
@@ -1803,7 +2035,9 @@ function toEventEntityRef(value) {
1803
2035
  }
1804
2036
  function toEventListToolItem(value) {
1805
2037
  return {
2038
+ ...value,
1806
2039
  ...toEventEntityRef(value),
2040
+ occurredAtIso: new Date(value.occurredAt).toISOString(),
1807
2041
  snippet: `${value.participants.length} participant(s)`
1808
2042
  };
1809
2043
  }
@@ -1865,7 +2099,7 @@ function withOptionalFields(result, options) {
1865
2099
  if (options.warnings && options.warnings.length > 0) {
1866
2100
  result.warnings = options.warnings;
1867
2101
  }
1868
- return result;
2102
+ return roundToolValues(result);
1869
2103
  }
1870
2104
  function toolWarning(code, message) {
1871
2105
  return { code, message };
@@ -1929,13 +2163,35 @@ function toolFailure(kind, code, summary, message, options = {}) {
1929
2163
  options
1930
2164
  );
1931
2165
  }
2166
+ function roundToolValues(value) {
2167
+ if (typeof value === "number")
2168
+ return Number.isInteger(value) ? value : Math.round(value * 1e3) / 1e3;
2169
+ if (Array.isArray(value)) return value.map(roundToolValues);
2170
+ if (value !== null && typeof value === "object")
2171
+ return Object.fromEntries(
2172
+ Object.entries(value).map(([key, child]) => [
2173
+ key,
2174
+ roundToolValues(child)
2175
+ ])
2176
+ );
2177
+ return value;
2178
+ }
1932
2179
  var init_tool_types = __esm({
1933
2180
  "src/tools/tool_types.ts"() {
1934
2181
  }
1935
2182
  });
1936
2183
 
1937
2184
  // src/tools/tool_resolvers.ts
1938
- function resolveContactLocator(db, locator, field = "contact") {
2185
+ function resolveContactLocator(db, locator, field = "contact", allowMerged = false) {
2186
+ if (!locator || typeof locator !== "object")
2187
+ return {
2188
+ ok: false,
2189
+ result: toolNeedsClarification(
2190
+ "missing_required_choice",
2191
+ `Provide ${field} locator.`,
2192
+ [field]
2193
+ )
2194
+ };
1939
2195
  if ("contactId" in locator) {
1940
2196
  const row2 = getContactRowById(db, locator.contactId);
1941
2197
  if (!row2) {
@@ -1949,8 +2205,25 @@ function resolveContactLocator(db, locator, field = "contact") {
1949
2205
  )
1950
2206
  };
1951
2207
  }
2208
+ if (!allowMerged && row2.merged_into_contact_id !== null) {
2209
+ const winner = getContactRowById(db, row2.merged_into_contact_id);
2210
+ const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
2211
+ return {
2212
+ ok: false,
2213
+ result: toolFailure("domain", "invalid_state", message, message)
2214
+ };
2215
+ }
1952
2216
  return { ok: true, value: row2 };
1953
2217
  }
2218
+ if (!("identity" in locator) || !locator.identity)
2219
+ return {
2220
+ ok: false,
2221
+ result: toolNeedsClarification(
2222
+ "missing_required_choice",
2223
+ `Provide ${field}.contactId or identity.`,
2224
+ [field]
2225
+ )
2226
+ };
1954
2227
  const normalizedKey = normalizeIdentityKey(
1955
2228
  locator.identity.type,
1956
2229
  locator.identity.value
@@ -2008,6 +2281,15 @@ function resolveContactLocator(db, locator, field = "contact") {
2008
2281
  return { ok: true, value: matchedContact };
2009
2282
  }
2010
2283
  function resolveLinkLocator(db, locator, field = "link") {
2284
+ if (!locator || typeof locator !== "object")
2285
+ return {
2286
+ ok: false,
2287
+ result: toolNeedsClarification(
2288
+ "missing_required_choice",
2289
+ `Provide ${field} locator.`,
2290
+ [field]
2291
+ )
2292
+ };
2011
2293
  if ("linkId" in locator) {
2012
2294
  const row = getLinkRowById(db, locator.linkId);
2013
2295
  if (!row) {
@@ -2023,6 +2305,15 @@ function resolveLinkLocator(db, locator, field = "link") {
2023
2305
  }
2024
2306
  return { ok: true, value: row };
2025
2307
  }
2308
+ if (!("endpoints" in locator) || !locator.endpoints)
2309
+ return {
2310
+ ok: false,
2311
+ result: toolNeedsClarification(
2312
+ "missing_required_choice",
2313
+ `Provide ${field}.linkId or endpoints.`,
2314
+ [field]
2315
+ )
2316
+ };
2026
2317
  const { endpoints } = locator;
2027
2318
  const clauses = [
2028
2319
  "removed_at IS NULL",
@@ -2151,6 +2442,97 @@ var init_tool_resolvers = __esm({
2151
2442
  }
2152
2443
  });
2153
2444
 
2445
+ // src/tools/validate_tool_input.ts
2446
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
2447
+ if (schema.anyOf || schema.oneOf) {
2448
+ const choices = schema.anyOf ?? schema.oneOf ?? [];
2449
+ return choices.some(
2450
+ (choice) => validateToolInput(choice, value, path, injectedNow) === null
2451
+ ) ? null : `${path}: invalid value`;
2452
+ }
2453
+ if (schema.type === "object") {
2454
+ if (value === null || typeof value !== "object" || Array.isArray(value))
2455
+ return `${path}: missing input or expected object`;
2456
+ const object = value;
2457
+ if (path.endsWith(".patch") && schema.additionalProperties === false) {
2458
+ for (const key of Object.keys(object)) {
2459
+ if (!Object.hasOwn(schema.properties ?? {}, key))
2460
+ 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(", ")}`;
2461
+ }
2462
+ }
2463
+ if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
2464
+ return `${path}.patch: missing required field`;
2465
+ if (object.patch !== void 0 && !schema.properties?.patch)
2466
+ return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
2467
+ const referenceNow = object.now === void 0 ? injectedNow : (() => {
2468
+ try {
2469
+ return parseTimeInput(
2470
+ object.now,
2471
+ `${path}.now`,
2472
+ injectedNow
2473
+ );
2474
+ } catch {
2475
+ return Date.now();
2476
+ }
2477
+ })();
2478
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
2479
+ if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
2480
+ try {
2481
+ object[key] = parseTimeInput(
2482
+ object[key],
2483
+ `${path}.${key}`,
2484
+ referenceNow
2485
+ );
2486
+ } catch (error) {
2487
+ return error.message;
2488
+ }
2489
+ }
2490
+ }
2491
+ for (const key of schema.required ?? []) {
2492
+ if (object[key] === void 0)
2493
+ return `${path}.${key}: missing required field`;
2494
+ }
2495
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
2496
+ if (object[key] === void 0) continue;
2497
+ const error = validateToolInput(
2498
+ child,
2499
+ object[key],
2500
+ `${path}.${key}`,
2501
+ referenceNow
2502
+ );
2503
+ if (error) return error;
2504
+ }
2505
+ return null;
2506
+ }
2507
+ if (schema.type === "array") {
2508
+ if (!Array.isArray(value)) return `${path}: expected array`;
2509
+ for (const [index, item] of value.entries()) {
2510
+ const error = validateToolInput(
2511
+ schema.items ?? {},
2512
+ item,
2513
+ `${path}[${index}]`,
2514
+ injectedNow
2515
+ );
2516
+ if (error) return error;
2517
+ }
2518
+ return null;
2519
+ }
2520
+ if (schema.enum && !schema.enum.includes(value))
2521
+ return `${path}: expected ${schema.enum.join(", ")}`;
2522
+ if (schema.type === "null")
2523
+ return value === null ? null : `${path}: expected null`;
2524
+ if (schema.type === "integer")
2525
+ return Number.isInteger(value) ? null : `${path}: expected integer`;
2526
+ 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";
2527
+ if (!valid) return `${path}: expected ${schema.type}`;
2528
+ return null;
2529
+ }
2530
+ var init_validate_tool_input = __esm({
2531
+ "src/tools/validate_tool_input.ts"() {
2532
+ init_parse_time_input();
2533
+ }
2534
+ });
2535
+
2154
2536
  // src/tools/inspect_affinity_item_tool.ts
2155
2537
  function contactLocatorSchema(description) {
2156
2538
  return {
@@ -2186,7 +2568,19 @@ function linkLocatorSchema(description) {
2186
2568
  description
2187
2569
  };
2188
2570
  }
2189
- function inspectAffinityItemToolHandler(db, input) {
2571
+ function inspectAffinityItemToolHandler(db, rawInput) {
2572
+ const input = structuredClone(rawInput);
2573
+ const inputError = validateToolInput(
2574
+ inspectAffinityItemTool.inputSchema,
2575
+ input
2576
+ );
2577
+ if (inputError)
2578
+ return toolFailure(
2579
+ "protocol",
2580
+ "invalid_input",
2581
+ "Invalid input.",
2582
+ inputError
2583
+ );
2190
2584
  return withToolHandling(() => {
2191
2585
  if (input.kind === "owner_profile") {
2192
2586
  const detail2 = getOwnerProfile(
@@ -2204,19 +2598,21 @@ function inspectAffinityItemToolHandler(db, input) {
2204
2598
  return toolSuccess(
2205
2599
  `Loaded owner profile for ${detail2.contact.name}.`,
2206
2600
  { kind: input.kind, detail: detail2 },
2207
- { entities: [toContactEntityRef(detail2.contact)] }
2601
+ {
2602
+ entities: [toContactEntityRef(detail2.contact)],
2603
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
2604
+ }
2208
2605
  );
2209
2606
  }
2210
2607
  if (input.kind === "contact_profile") {
2211
- const contact = resolveContactLocator(db, input.contact, "contact");
2608
+ const contact = resolveContactLocator(db, input.contact, "contact", true);
2212
2609
  if (!contact.ok) {
2213
2610
  return contact.result;
2214
2611
  }
2215
- const detail2 = getContactProfile(
2216
- db,
2217
- contact.value.id,
2218
- input.topLinksLimit === void 0 ? void 0 : { topLinksLimit: input.topLinksLimit }
2219
- );
2612
+ const detail2 = getContactProfile(db, contact.value.id, {
2613
+ ...input.topLinksLimit === void 0 ? {} : { topLinksLimit: input.topLinksLimit },
2614
+ ...input.recentEventsLimit === void 0 ? {} : { recentEventsLimit: input.recentEventsLimit }
2615
+ });
2220
2616
  if (!detail2) {
2221
2617
  return toolFailure(
2222
2618
  "domain",
@@ -2228,7 +2624,10 @@ function inspectAffinityItemToolHandler(db, input) {
2228
2624
  return toolSuccess(
2229
2625
  `Loaded contact profile for ${detail2.contact.name}.`,
2230
2626
  { kind: input.kind, detail: detail2 },
2231
- { entities: [toContactEntityRef(detail2.contact)] }
2627
+ {
2628
+ entities: [toContactEntityRef(detail2.contact)],
2629
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
2630
+ }
2232
2631
  );
2233
2632
  }
2234
2633
  const link = resolveLinkLocator(db, input.link, "link");
@@ -2271,6 +2670,8 @@ var init_inspect_affinity_item_tool = __esm({
2271
2670
  init_tool_ref();
2272
2671
  init_tool_resolvers();
2273
2672
  init_tool_types();
2673
+ init_tool_types();
2674
+ init_validate_tool_input();
2274
2675
  inspectAffinityItemTool = defineAffinityTool({
2275
2676
  name: inspectAffinityItemToolName,
2276
2677
  description: "Inspect one exact affinity item such as the owner profile, a contact profile, or one link detail.",
@@ -2762,25 +3163,18 @@ var init_unset_attribute = __esm({
2762
3163
  }
2763
3164
  });
2764
3165
 
2765
- // src/tools/tool_summary.ts
2766
- function summarizeCount(count, singular) {
2767
- if (count === 0) {
2768
- return `No ${singular}s found.`;
2769
- }
2770
- if (count === 1) {
2771
- return `Found 1 ${singular}.`;
2772
- }
2773
- return `Found ${count} ${singular}s.`;
2774
- }
2775
- var init_tool_summary = __esm({
2776
- "src/tools/tool_summary.ts"() {
2777
- }
2778
- });
2779
-
2780
3166
  // src/tools/tool_mutation.ts
2781
- function mutationToolResult(action, receipt, entitySummary) {
3167
+ function mutationToolResult(action, receipt, entitySummary, fields) {
2782
3168
  const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
2783
3169
  const data = {
3170
+ changes: ["created", "updated", "archived", "removed"].flatMap(
3171
+ (verb) => receipt[verb].map((ref) => ({
3172
+ entity: ref.kind,
3173
+ id: ref.id,
3174
+ action: verb,
3175
+ ...verb === "updated" && fields ? { fields } : {}
3176
+ }))
3177
+ ),
2784
3178
  action,
2785
3179
  primary: receipt.primary,
2786
3180
  created: receipt.created,
@@ -2790,7 +3184,7 @@ function mutationToolResult(action, receipt, entitySummary) {
2790
3184
  affectedLinks: receipt.affectedLinks,
2791
3185
  derivedEffects: receipt.derivedEffects
2792
3186
  };
2793
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : summarizeCount(changedCount, `${entitySummary} change`);
3187
+ const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
2794
3188
  if (changedCount === 0) {
2795
3189
  return toolNoOp(summary, data, {
2796
3190
  entities: toReceiptEntityRefs(receipt)
@@ -2800,11 +3194,28 @@ function mutationToolResult(action, receipt, entitySummary) {
2800
3194
  entities: toReceiptEntityRefs(receipt)
2801
3195
  });
2802
3196
  }
3197
+ function describeChanges(receipt) {
3198
+ const parts = [];
3199
+ for (const verb of ["created", "updated", "archived", "removed"]) {
3200
+ const counts = /* @__PURE__ */ new Map();
3201
+ for (const ref of receipt[verb]) {
3202
+ counts.set(ref.kind, (counts.get(ref.kind) ?? 0) + 1);
3203
+ }
3204
+ if (counts.size === 0) continue;
3205
+ const items = [...counts].map(
3206
+ ([kind, n]) => `${n} ${n === 1 ? kind : PLURALS[kind] ?? `${kind}s`}`
3207
+ );
3208
+ parts.push(`${verb} ${items.join(", ")}`);
3209
+ }
3210
+ const text = parts.join("; ");
3211
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
3212
+ }
3213
+ var PLURALS;
2803
3214
  var init_tool_mutation = __esm({
2804
3215
  "src/tools/tool_mutation.ts"() {
2805
3216
  init_tool_ref();
2806
- init_tool_summary();
2807
3217
  init_tool_types();
3218
+ PLURALS = { identity: "identities" };
2808
3219
  }
2809
3220
  });
2810
3221
 
@@ -2857,7 +3268,23 @@ function resolveAttributeTarget(db, target) {
2857
3268
  }
2858
3269
  return { kind: "ok", target: { kind: "link", id: link.value.id } };
2859
3270
  }
2860
- function manageAttributeToolHandler(db, input) {
3271
+ function manageAttributeToolHandler(db, rawInput) {
3272
+ const input = structuredClone(rawInput);
3273
+ const inputError = validateToolInput(manageAttributeTool.inputSchema, input);
3274
+ if (inputError)
3275
+ return toolFailure(
3276
+ "protocol",
3277
+ "invalid_input",
3278
+ "Invalid input.",
3279
+ inputError
3280
+ );
3281
+ if (!input.target)
3282
+ return toolFailure(
3283
+ "protocol",
3284
+ "invalid_input",
3285
+ "Missing target.",
3286
+ "target is required."
3287
+ );
2861
3288
  return withToolHandling(() => {
2862
3289
  const resolved = resolveAttributeTarget(db, input.target);
2863
3290
  if (resolved.kind === "error") {
@@ -2895,6 +3322,8 @@ var init_manage_attribute_tool = __esm({
2895
3322
  init_tool_mutation();
2896
3323
  init_tool_names();
2897
3324
  init_tool_resolvers();
3325
+ init_tool_types();
3326
+ init_validate_tool_input();
2898
3327
  manageAttributeTool = defineAffinityTool({
2899
3328
  name: manageAttributeToolName,
2900
3329
  description: "Set, unset, or replace custom metadata attributes on contacts and links. For emails, phones, handles, and URLs use manage_identity instead \u2014 those are routing keys, not attributes.",
@@ -3527,7 +3956,7 @@ function clamp3(value, min, max) {
3527
3956
  }
3528
3957
  function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3529
3958
  const dayStart = utcStartOfDayMs(occurredAt);
3530
- const dayEnd = dayStart + DAY_MS2;
3959
+ const dayEnd = dayStart + DAY_MS3;
3531
3960
  const row = db.prepare(
3532
3961
  `SELECT COUNT(*) AS c
3533
3962
  FROM link_event_effects le
@@ -3540,7 +3969,7 @@ function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3540
3969
  return Number(row.c ?? 0);
3541
3970
  }
3542
3971
  function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3543
- const weekStart = occurredAt - 7 * DAY_MS2;
3972
+ const weekStart = occurredAt - 7 * DAY_MS3;
3544
3973
  const row = db.prepare(
3545
3974
  `SELECT SUM(base_weight) AS total
3546
3975
  FROM link_event_effects
@@ -3552,7 +3981,7 @@ function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3552
3981
  return Number(row.total ?? 0);
3553
3982
  }
3554
3983
  function countRepairContext(db, linkId, occurredAt) {
3555
- const thirtyDaysAgo = occurredAt - 30 * DAY_MS2;
3984
+ const thirtyDaysAgo = occurredAt - 30 * DAY_MS3;
3556
3985
  const hasPriorDamage = db.prepare(
3557
3986
  `SELECT 1 FROM link_event_effects
3558
3987
  WHERE link_id = ? AND trust_delta < 0
@@ -3627,8 +4056,8 @@ function computePreferenceMatch(db, linkId, contactId, eventType, provenance) {
3627
4056
  return preferenceMatch(liked, disliked);
3628
4057
  }
3629
4058
  function computeDateSalienceMultiplier(db, contactId, occurredAt, significance) {
3630
- const windowBefore = 7 * DAY_MS2;
3631
- const windowAfter = 2 * DAY_MS2;
4059
+ const windowBefore = 7 * DAY_MS3;
4060
+ const windowAfter = 2 * DAY_MS3;
3632
4061
  const row = db.prepare(
3633
4062
  `SELECT 1 FROM upcoming_occurrences uo
3634
4063
  JOIN events e ON e.id = uo.event_id
@@ -3795,9 +4224,9 @@ function applyEventEffectToLink(db, params) {
3795
4224
  ).get(params.linkId);
3796
4225
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
3797
4226
  1,
3798
- Math.round((params.occurredAt - latest.occurred_at) / DAY_MS2)
4227
+ Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
3799
4228
  );
3800
- const cadenceAfter = nextCadenceDays({
4229
+ const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
3801
4230
  kind: linkKind,
3802
4231
  currentCadenceDays: cadenceBefore,
3803
4232
  gapDays
@@ -3860,6 +4289,11 @@ function applyEventEffectToLink(db, params) {
3860
4289
  params.now,
3861
4290
  params.now
3862
4291
  );
4292
+ if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
4293
+ db.prepare(
4294
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
4295
+ ).run(params.eventId, params.linkId);
4296
+ }
3863
4297
  refreshLinkRollup(db, params.linkId, params.now);
3864
4298
  return {
3865
4299
  linkId: params.linkId,
@@ -3883,7 +4317,7 @@ function applyEventEffectToLink(db, params) {
3883
4317
  momentKind
3884
4318
  };
3885
4319
  }
3886
- var DAY_MS2;
4320
+ var DAY_MS3;
3887
4321
  var init_effects = __esm({
3888
4322
  "src/links/effects.ts"() {
3889
4323
  init_calendar();
@@ -3893,7 +4327,7 @@ var init_effects = __esm({
3893
4327
  init_queries3();
3894
4328
  init_rollups2();
3895
4329
  init_validators2();
3896
- DAY_MS2 = 864e5;
4330
+ DAY_MS3 = 864e5;
3897
4331
  }
3898
4332
  });
3899
4333
 
@@ -4274,12 +4708,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4274
4708
  if (row.type !== "promise" && row.type !== "agreement") {
4275
4709
  throw new AffinityInvariantError("not a commitment event");
4276
4710
  }
4277
- const open = db.prepare("SELECT resolved_at FROM open_commitments WHERE event_id = ?").get(commitmentEventId);
4711
+ const open = db.prepare(
4712
+ "SELECT resolved_at, resolution FROM open_commitments WHERE event_id = ?"
4713
+ ).get(commitmentEventId);
4278
4714
  if (!open) {
4279
4715
  throw new AffinityNotFoundError("open commitment not found");
4280
4716
  }
4281
4717
  if (open.resolved_at !== null) {
4282
- throw new AffinityStateError("commitment already resolved");
4718
+ throw new AffinityConflictError(
4719
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4720
+ );
4283
4721
  }
4284
4722
  const now = resolveNow(options?.now);
4285
4723
  const originalParticipants = loadEventParticipantViews(
@@ -4292,7 +4730,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4292
4730
  WHERE event_id = ? AND resolved_at IS NULL`
4293
4731
  ).run(resolution, now, now, commitmentEventId);
4294
4732
  if (Number(result.changes ?? 0) === 0) {
4295
- throw new AffinityStateError("commitment already resolved");
4733
+ throw new AffinityConflictError(
4734
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4735
+ );
4296
4736
  }
4297
4737
  const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
4298
4738
  const created = [];
@@ -4362,9 +4802,9 @@ var init_resolve_commitment = __esm({
4362
4802
  init_queries2();
4363
4803
  init_receipts2();
4364
4804
  init_validators();
4805
+ init_affinity_conflict_error();
4365
4806
  init_affinity_invariant_error();
4366
4807
  init_affinity_not_found_error();
4367
- init_affinity_state_error();
4368
4808
  init_affinity_validation_error();
4369
4809
  init_resolve_now();
4370
4810
  init_with_transaction();
@@ -4397,9 +4837,25 @@ function ensureOwnerParticipates(db, participants) {
4397
4837
  function prepareParticipants(db, raw) {
4398
4838
  return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
4399
4839
  }
4400
- function manageCommitmentToolHandler(db, input) {
4840
+ function manageCommitmentToolHandler(db, rawInput) {
4841
+ const input = structuredClone(rawInput);
4842
+ const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
4843
+ if (inputError)
4844
+ return toolFailure(
4845
+ "protocol",
4846
+ "invalid_input",
4847
+ "Invalid input.",
4848
+ inputError
4849
+ );
4401
4850
  return withToolHandling(() => {
4402
4851
  if (input.action === "record") {
4852
+ if (!input.input)
4853
+ return toolFailure(
4854
+ "protocol",
4855
+ "invalid_input",
4856
+ "Missing input.",
4857
+ "input is required for record."
4858
+ );
4403
4859
  const patched = {
4404
4860
  ...input.input,
4405
4861
  participants: prepareParticipants(
@@ -4435,6 +4891,8 @@ var init_manage_commitment_tool = __esm({
4435
4891
  init_tool_mutation();
4436
4892
  init_tool_names();
4437
4893
  init_tool_resolvers();
4894
+ init_tool_types();
4895
+ init_validate_tool_input();
4438
4896
  manageCommitmentTool = defineAffinityTool({
4439
4897
  name: manageCommitmentToolName,
4440
4898
  description: "Record or resolve one commitment such as a promise or agreement. The owner contact is auto-injected into participants if missing.",
@@ -4476,7 +4934,7 @@ var init_manage_commitment_tool = __esm({
4476
4934
  role: stringSchema("Participant role."),
4477
4935
  directionality: stringSchema("Optional directionality.")
4478
4936
  },
4479
- ["contactId", "role"]
4937
+ ["role"]
4480
4938
  ),
4481
4939
  "Participant list. Must be non-empty and include the owner contact."
4482
4940
  ),
@@ -4525,6 +4983,163 @@ var init_manage_commitment_tool = __esm({
4525
4983
  }
4526
4984
  });
4527
4985
 
4986
+ // src/contacts/delete_contact.ts
4987
+ function deleteContact(db, locatorId, options) {
4988
+ return withTransaction(db, () => {
4989
+ const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
4990
+ if (!contact) throw new AffinityNotFoundError("contact not found");
4991
+ if (contact.is_owner)
4992
+ throw new AffinityStateError("The owner contact cannot be deleted");
4993
+ const tables = [
4994
+ "contacts",
4995
+ "identities",
4996
+ "attributes",
4997
+ "links",
4998
+ "events",
4999
+ "event_participants",
5000
+ "open_commitments",
5001
+ "contact_merges",
5002
+ "dismissed_duplicates",
5003
+ "link_event_effects",
5004
+ "upcoming_occurrences",
5005
+ "link_rollups",
5006
+ "contact_rollups"
5007
+ ];
5008
+ const before = new Map(
5009
+ tables.map((table) => [
5010
+ table,
5011
+ db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
5012
+ ])
5013
+ );
5014
+ db.prepare(
5015
+ "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))"
5016
+ ).run(locatorId, locatorId);
5017
+ db.prepare(
5018
+ "DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
5019
+ ).run(locatorId, locatorId);
5020
+ db.prepare(
5021
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
5022
+ ).run(locatorId, locatorId);
5023
+ db.prepare(
5024
+ "UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
5025
+ ).run(locatorId);
5026
+ db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
5027
+ const now = Date.now();
5028
+ const affectedLinks = db.prepare(
5029
+ "SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
5030
+ ).all().map((row) => row.id);
5031
+ refreshAllBridgeScores(db, now);
5032
+ for (const id of affectedLinks) refreshLinkRollup(db, id, now);
5033
+ for (const row of db.prepare("SELECT id FROM contacts").all())
5034
+ refreshContactRollup(db, Number(row.id), now);
5035
+ const removed = tables.map((table) => {
5036
+ const remaining = new Set(
5037
+ db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
5038
+ );
5039
+ const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
5040
+ return { entity: table, count: ids.length, ids };
5041
+ }).filter((item) => item.count > 0);
5042
+ return { contactId: locatorId, removed, affectedLinks };
5043
+ });
5044
+ }
5045
+ var init_delete_contact = __esm({
5046
+ "src/contacts/delete_contact.ts"() {
5047
+ init_bridge_scores();
5048
+ init_affinity_not_found_error();
5049
+ init_affinity_state_error();
5050
+ init_affinity_validation_error();
5051
+ init_rollups2();
5052
+ init_with_transaction();
5053
+ init_rollups();
5054
+ }
5055
+ });
5056
+
5057
+ // src/contacts/export_contact.ts
5058
+ function exportContact(db, id) {
5059
+ const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
5060
+ const result = {};
5061
+ for (const [key, value] of Object.entries(row)) {
5062
+ const name = key.replace(
5063
+ /_([a-z])/g,
5064
+ (_, letter) => letter.toUpperCase()
5065
+ );
5066
+ result[name] = value;
5067
+ if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
5068
+ result[`${name}Iso`] = new Date(value).toISOString();
5069
+ if (key.endsWith("_json") && typeof value === "string")
5070
+ result[name] = JSON.parse(value);
5071
+ }
5072
+ return result;
5073
+ });
5074
+ const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
5075
+ if (!contact) throw new AffinityNotFoundError("contact not found");
5076
+ const events = query(
5077
+ "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
5078
+ id
5079
+ ).map((event) => ({
5080
+ ...event,
5081
+ participants: query(
5082
+ "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",
5083
+ event.id
5084
+ )
5085
+ }));
5086
+ return {
5087
+ contact,
5088
+ identities: query(
5089
+ "SELECT * FROM identities WHERE contact_id = ? ORDER BY id",
5090
+ id
5091
+ ),
5092
+ attributes: query(
5093
+ "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",
5094
+ id,
5095
+ id,
5096
+ id
5097
+ ),
5098
+ links: query(
5099
+ "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",
5100
+ id,
5101
+ id
5102
+ ),
5103
+ events,
5104
+ commitments: query(
5105
+ "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",
5106
+ id
5107
+ ),
5108
+ dates: query(
5109
+ "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",
5110
+ id,
5111
+ id,
5112
+ id
5113
+ ),
5114
+ mergeHistory: query(
5115
+ "SELECT * FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ? ORDER BY id",
5116
+ id,
5117
+ id
5118
+ )
5119
+ };
5120
+ }
5121
+ var init_export_contact = __esm({
5122
+ "src/contacts/export_contact.ts"() {
5123
+ init_affinity_not_found_error();
5124
+ }
5125
+ });
5126
+
5127
+ // src/contacts/export_affinity.ts
5128
+ function exportAffinity(db) {
5129
+ const now = Date.now();
5130
+ return {
5131
+ version: 1,
5132
+ exportedAt: now,
5133
+ exportedAtIso: new Date(now).toISOString(),
5134
+ contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
5135
+ };
5136
+ }
5137
+ var init_export_affinity = __esm({
5138
+ "src/contacts/export_affinity.ts"() {
5139
+ init_export_contact();
5140
+ }
5141
+ });
5142
+
4528
5143
  // src/contacts/receipts.ts
4529
5144
  function buildContactMutationReceipt(primary, partial) {
4530
5145
  const normalized = normalizeMutationReceiptPartial(partial);
@@ -4556,66 +5171,6 @@ var init_receipts4 = __esm({
4556
5171
  }
4557
5172
  });
4558
5173
 
4559
- // src/contacts/create_contact.ts
4560
- function createContact(db, input) {
4561
- return withTransaction(db, () => {
4562
- const name = input.name.trim();
4563
- if (name.length === 0) {
4564
- throw new AffinityValidationError("name must be non-empty");
4565
- }
4566
- if (!KINDS.has(input.kind)) {
4567
- throw new AffinityValidationError("invalid contact kind");
4568
- }
4569
- const now = resolveNow(input.now);
4570
- if (input.bootstrapOwner) {
4571
- const existing = db.prepare(
4572
- "SELECT id FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
4573
- ).get();
4574
- if (existing !== void 0) {
4575
- throw new AffinityConflictError("owner already exists");
4576
- }
4577
- }
4578
- const isOwner = input.bootstrapOwner ? 1 : 0;
4579
- const result = db.prepare(
4580
- `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
4581
- VALUES (?, ?, 'active', ?, ?, ?)`
4582
- ).run(name, input.kind, isOwner, now, now);
4583
- const id = Number(result.lastInsertRowid);
4584
- const row = getContactRowById(db, id);
4585
- if (!row) {
4586
- throw new AffinityInvariantError("inserted contact row not found");
4587
- }
4588
- refreshContactRollup(db, id, now);
4589
- const primary = mapContactRowToContactListItem(row);
4590
- return buildContactMutationReceipt(primary, {
4591
- created: [{ kind: "contact", id }]
4592
- });
4593
- });
4594
- }
4595
- var KINDS;
4596
- var init_create_contact = __esm({
4597
- "src/contacts/create_contact.ts"() {
4598
- init_mappers4();
4599
- init_queries();
4600
- init_receipts4();
4601
- init_rollups();
4602
- init_affinity_conflict_error();
4603
- init_affinity_invariant_error();
4604
- init_affinity_validation_error();
4605
- init_resolve_now();
4606
- init_with_transaction();
4607
- KINDS = /* @__PURE__ */ new Set([
4608
- "human",
4609
- "group",
4610
- "company",
4611
- "team",
4612
- "pet",
4613
- "service",
4614
- "other"
4615
- ]);
4616
- }
4617
- });
4618
-
4619
5174
  // src/contacts/revise_contact.ts
4620
5175
  function reviseContact(db, contactId, patch) {
4621
5176
  return withTransaction(db, () => {
@@ -4671,7 +5226,9 @@ function assertValidLifecycleTransition(from, to) {
4671
5226
  throw new AffinityStateError("merged contact is read-only");
4672
5227
  }
4673
5228
  if (to === "merged") {
4674
- throw new AffinityInvariantError("use mergeContacts to reach merged state");
5229
+ throw new AffinityInvariantError(
5230
+ "use manage_contact merge (CLI: contact merge) to reach merged state"
5231
+ );
4675
5232
  }
4676
5233
  if (from === to) {
4677
5234
  return;
@@ -4704,42 +5261,360 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
4704
5261
  }
4705
5262
  const row = getContactRowById(db, contactId);
4706
5263
  if (!row) {
4707
- throw new AffinityNotFoundError("contact not found");
4708
- }
4709
- const from = row.lifecycle_state;
4710
- assertValidLifecycleTransition(from, lifecycleState);
4711
- const now = resolveNow(options?.now);
4712
- if (from === lifecycleState) {
4713
- return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
4714
- updated: []
4715
- });
5264
+ throw new AffinityNotFoundError("contact not found");
5265
+ }
5266
+ const from = row.lifecycle_state;
5267
+ assertValidLifecycleTransition(from, lifecycleState);
5268
+ const now = resolveNow(options?.now);
5269
+ if (from === lifecycleState) {
5270
+ return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
5271
+ updated: []
5272
+ });
5273
+ }
5274
+ db.prepare(
5275
+ "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
5276
+ ).run(lifecycleState, now, contactId);
5277
+ refreshContactRollup(db, contactId, now);
5278
+ const next = getContactRowById(db, contactId);
5279
+ if (!next) {
5280
+ throw new AffinityNotFoundError("contact not found");
5281
+ }
5282
+ return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
5283
+ updated: [{ kind: "contact", id: contactId }]
5284
+ });
5285
+ });
5286
+ }
5287
+ var STATES2;
5288
+ var init_set_contact_lifecycle = __esm({
5289
+ "src/contacts/set_contact_lifecycle.ts"() {
5290
+ init_mappers4();
5291
+ init_queries();
5292
+ init_receipts4();
5293
+ init_rollups();
5294
+ init_validators4();
5295
+ init_affinity_not_found_error();
5296
+ init_affinity_validation_error();
5297
+ init_resolve_now();
5298
+ init_with_transaction();
5299
+ STATES2 = /* @__PURE__ */ new Set(["active", "dormant", "merged", "lost"]);
5300
+ }
5301
+ });
5302
+
5303
+ // src/contacts/create_contact.ts
5304
+ function createContact(db, input) {
5305
+ return withTransaction(db, () => {
5306
+ const name = input.name.trim();
5307
+ if (name.length === 0) {
5308
+ throw new AffinityValidationError("name must be non-empty");
5309
+ }
5310
+ if (!KINDS.has(input.kind)) {
5311
+ throw new AffinityValidationError("invalid contact kind");
5312
+ }
5313
+ if (input.isOwner !== void 0) {
5314
+ throw new AffinityValidationError(
5315
+ "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
5316
+ );
5317
+ }
5318
+ const now = resolveNow(input.now);
5319
+ if (input.bootstrapOwner) {
5320
+ const existing = db.prepare(
5321
+ "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
5322
+ ).get();
5323
+ if (existing !== void 0) {
5324
+ throw new AffinityConflictError(
5325
+ `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
5326
+ );
5327
+ }
5328
+ }
5329
+ const isOwner = input.bootstrapOwner ? 1 : 0;
5330
+ const result = db.prepare(
5331
+ `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
5332
+ VALUES (?, ?, 'active', ?, ?, ?)`
5333
+ ).run(name, input.kind, isOwner, now, now);
5334
+ const id = Number(result.lastInsertRowid);
5335
+ const row = getContactRowById(db, id);
5336
+ if (!row) {
5337
+ throw new AffinityInvariantError("inserted contact row not found");
5338
+ }
5339
+ refreshContactRollup(db, id, now);
5340
+ const primary = mapContactRowToContactListItem(row);
5341
+ return buildContactMutationReceipt(primary, {
5342
+ created: [{ kind: "contact", id }]
5343
+ });
5344
+ });
5345
+ }
5346
+ var KINDS;
5347
+ var init_create_contact = __esm({
5348
+ "src/contacts/create_contact.ts"() {
5349
+ init_mappers4();
5350
+ init_queries();
5351
+ init_receipts4();
5352
+ init_rollups();
5353
+ init_affinity_conflict_error();
5354
+ init_affinity_invariant_error();
5355
+ init_affinity_validation_error();
5356
+ init_resolve_now();
5357
+ init_with_transaction();
5358
+ KINDS = /* @__PURE__ */ new Set([
5359
+ "human",
5360
+ "group",
5361
+ "company",
5362
+ "team",
5363
+ "pet",
5364
+ "service",
5365
+ "other"
5366
+ ]);
5367
+ }
5368
+ });
5369
+
5370
+ // src/contacts/fold_search_text.ts
5371
+ function foldSearchText(value) {
5372
+ let folded = value.toLowerCase();
5373
+ if (/[^\u0020-\u007e]/.test(folded))
5374
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
5375
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
5376
+ }
5377
+ var init_fold_search_text = __esm({
5378
+ "src/contacts/fold_search_text.ts"() {
5379
+ }
5380
+ });
5381
+
5382
+ // src/identities/queries.ts
5383
+ function getIdentityRowById(db, id) {
5384
+ const row = db.prepare(
5385
+ `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5386
+ created_at, updated_at, removed_at
5387
+ FROM identities
5388
+ WHERE id = ? AND removed_at IS NULL`
5389
+ ).get(id);
5390
+ if (row === void 0) {
5391
+ return null;
5392
+ }
5393
+ return row;
5394
+ }
5395
+ var init_queries5 = __esm({
5396
+ "src/identities/queries.ts"() {
5397
+ }
5398
+ });
5399
+
5400
+ // src/identities/receipts.ts
5401
+ function buildIdentityMutationReceipt(primary, partial) {
5402
+ const normalized = normalizeMutationReceiptPartial(partial);
5403
+ return {
5404
+ primary,
5405
+ created: normalized.created,
5406
+ updated: normalized.updated,
5407
+ archived: normalized.archived,
5408
+ removed: normalized.removed,
5409
+ affectedLinks: normalized.affectedLinks,
5410
+ derivedEffects: normalized.derivedEffects
5411
+ };
5412
+ }
5413
+ var init_receipts5 = __esm({
5414
+ "src/identities/receipts.ts"() {
5415
+ init_normalize_mutation_receipt();
5416
+ }
5417
+ });
5418
+
5419
+ // src/identities/validators.ts
5420
+ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5421
+ const row = excludeIdentityId === void 0 ? db.prepare(
5422
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5423
+ ).get(normalizedKey) : db.prepare(
5424
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
5425
+ ).get(normalizedKey, excludeIdentityId);
5426
+ if (row !== void 0) {
5427
+ const owner = db.prepare(
5428
+ "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"
5429
+ ).get(normalizedKey);
5430
+ throw new AffinityConflictError(
5431
+ `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
5432
+ );
5433
+ }
5434
+ }
5435
+ var init_validators5 = __esm({
5436
+ "src/identities/validators.ts"() {
5437
+ init_affinity_conflict_error();
5438
+ }
5439
+ });
5440
+
5441
+ // src/identities/validate_identity_input.ts
5442
+ function validateIdentityInput(type, value) {
5443
+ const kind = type.toLowerCase();
5444
+ if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
5445
+ throw new AffinityValidationError(
5446
+ `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
5447
+ );
5448
+ if (!value.trim())
5449
+ throw new AffinityValidationError("identity value must be non-empty");
5450
+ if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
5451
+ throw new AffinityValidationError(
5452
+ "email value must match local@domain.tld"
5453
+ );
5454
+ if (kind === "phone" && value.replace(/\D/g, "").length < 5)
5455
+ throw new AffinityValidationError(
5456
+ "phone value must contain at least 5 digits"
5457
+ );
5458
+ if (kind === "url" || kind === "website") {
5459
+ try {
5460
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
5461
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
5462
+ throw new Error();
5463
+ } catch {
5464
+ throw new AffinityValidationError(
5465
+ "url/website value must be an http/https URL or bare domain"
5466
+ );
5467
+ }
5468
+ }
5469
+ }
5470
+ var identityTypes;
5471
+ var init_validate_identity_input = __esm({
5472
+ "src/identities/validate_identity_input.ts"() {
5473
+ init_affinity_validation_error();
5474
+ identityTypes = [
5475
+ "email",
5476
+ "phone",
5477
+ "url",
5478
+ "website",
5479
+ "handle",
5480
+ "x",
5481
+ "twitter",
5482
+ "linkedin",
5483
+ "github",
5484
+ "mastodon",
5485
+ "bluesky",
5486
+ "telegram",
5487
+ "whatsapp",
5488
+ "signal",
5489
+ "matrix",
5490
+ "discord",
5491
+ "slack",
5492
+ "instagram",
5493
+ "facebook",
5494
+ "youtube",
5495
+ "tiktok",
5496
+ "address",
5497
+ "other",
5498
+ "account_id",
5499
+ "alias"
5500
+ ];
5501
+ }
5502
+ });
5503
+
5504
+ // src/identities/add_identity.ts
5505
+ function addIdentity(db, contactId, input) {
5506
+ return withTransaction(db, () => {
5507
+ const contact = getContactRowById(db, contactId);
5508
+ if (!contact) {
5509
+ throw new AffinityNotFoundError("contact not found");
5510
+ }
5511
+ if (contact.lifecycle_state === "merged") {
5512
+ throw new AffinityStateError("merged contact is read-only");
5513
+ }
5514
+ const type = input.type.trim();
5515
+ const value = input.value.trim();
5516
+ if (type.length === 0 || value.length === 0) {
5517
+ throw new AffinityValidationError("type and value must be non-empty");
5518
+ }
5519
+ validateIdentityInput(type, value);
5520
+ const nk = normalizeIdentityKey(type, value);
5521
+ assertNoIdentityCollision(db, nk);
5522
+ const now = resolveNow(input.now);
5523
+ const verified = input.verified ? 1 : 0;
5524
+ const verifiedAt = verified === 1 ? now : null;
5525
+ const result = db.prepare(
5526
+ `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
5527
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
5528
+ ).run(
5529
+ contactId,
5530
+ type,
5531
+ value,
5532
+ input.label ?? null,
5533
+ nk,
5534
+ verified,
5535
+ verifiedAt,
5536
+ now,
5537
+ now
5538
+ );
5539
+ const id = Number(result.lastInsertRowid);
5540
+ const row = getIdentityRowById(db, id);
5541
+ if (!row) {
5542
+ throw new AffinityInvariantError("inserted identity not found");
4716
5543
  }
4717
- db.prepare(
4718
- "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
4719
- ).run(lifecycleState, now, contactId);
4720
5544
  refreshContactRollup(db, contactId, now);
4721
- const next = getContactRowById(db, contactId);
4722
- if (!next) {
4723
- throw new AffinityNotFoundError("contact not found");
4724
- }
4725
- return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
4726
- updated: [{ kind: "contact", id: contactId }]
5545
+ db.prepare(
5546
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
5547
+ ).run(contactId, contactId);
5548
+ return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
5549
+ created: [{ kind: "identity", id }]
4727
5550
  });
4728
5551
  });
4729
5552
  }
4730
- var STATES2;
4731
- var init_set_contact_lifecycle = __esm({
4732
- "src/contacts/set_contact_lifecycle.ts"() {
4733
- init_mappers4();
5553
+ var init_add_identity = __esm({
5554
+ "src/identities/add_identity.ts"() {
4734
5555
  init_queries();
4735
- init_receipts4();
4736
5556
  init_rollups();
4737
- init_validators4();
5557
+ init_mappers2();
5558
+ init_normalize();
5559
+ init_queries5();
5560
+ init_receipts5();
5561
+ init_validators5();
5562
+ init_affinity_invariant_error();
4738
5563
  init_affinity_not_found_error();
5564
+ init_affinity_state_error();
4739
5565
  init_affinity_validation_error();
4740
5566
  init_resolve_now();
4741
5567
  init_with_transaction();
4742
- STATES2 = /* @__PURE__ */ new Set(["active", "dormant", "merged", "lost"]);
5568
+ init_validate_identity_input();
5569
+ }
5570
+ });
5571
+
5572
+ // src/tools/create_contact_tool_result.ts
5573
+ function createContactToolResult(db, input) {
5574
+ return withTransaction(db, () => {
5575
+ const receipt = createContact(db, input);
5576
+ const identityOwners = /* @__PURE__ */ new Set();
5577
+ for (const identity of input.identities ?? []) {
5578
+ validateIdentityInput(identity.type.trim(), identity.value.trim());
5579
+ const existing = db.prepare(
5580
+ "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5581
+ ).get(normalizeIdentityKey(identity.type, identity.value));
5582
+ if (existing) {
5583
+ if (Number(existing.contact_id) !== receipt.primary.id)
5584
+ identityOwners.add(Number(existing.contact_id));
5585
+ continue;
5586
+ }
5587
+ const added = addIdentity(db, receipt.primary.id, identity);
5588
+ receipt.created.push(...added.created);
5589
+ }
5590
+ const result = mutationToolResult("create", receipt, "contact");
5591
+ const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
5592
+ const warnings = db.prepare(
5593
+ "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
5594
+ ).all(receipt.primary.id).flatMap((row) => {
5595
+ const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
5596
+ return score === void 0 ? [] : [
5597
+ {
5598
+ code: "possible_duplicate",
5599
+ message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
5600
+ contactId: row.id,
5601
+ score
5602
+ }
5603
+ ];
5604
+ });
5605
+ if (warnings.length) result.warnings = warnings;
5606
+ return result;
5607
+ });
5608
+ }
5609
+ var init_create_contact_tool_result = __esm({
5610
+ "src/tools/create_contact_tool_result.ts"() {
5611
+ init_create_contact();
5612
+ init_fold_search_text();
5613
+ init_add_identity();
5614
+ init_normalize();
5615
+ init_validate_identity_input();
5616
+ init_with_transaction();
5617
+ init_tool_mutation();
4743
5618
  }
4744
5619
  });
4745
5620
 
@@ -4761,24 +5636,76 @@ function contactLocatorSchema3(description) {
4761
5636
  description
4762
5637
  };
4763
5638
  }
4764
- function manageContactToolHandler(db, input) {
5639
+ function manageContactToolHandler(db, rawInput) {
5640
+ const input = structuredClone(rawInput);
5641
+ const inputError = validateToolInput(manageContactTool.inputSchema, input);
5642
+ if (inputError)
5643
+ return toolFailure(
5644
+ "protocol",
5645
+ "invalid_input",
5646
+ "Invalid input.",
5647
+ inputError
5648
+ );
4765
5649
  return withToolHandling(() => {
4766
- if (input.action === "create") {
4767
- return mutationToolResult(
4768
- "create",
4769
- createContact(db, input.input),
4770
- "contact"
5650
+ if (input.action === "export") {
5651
+ if (!input.contact)
5652
+ return toolSuccess("Exported Affinity.", {
5653
+ action: "export",
5654
+ export: exportAffinity(db)
5655
+ });
5656
+ const target = resolveContactLocator(db, input.contact, "contact", true);
5657
+ if (!target.ok) return target.result;
5658
+ return toolSuccess("Exported contact.", {
5659
+ action: "export",
5660
+ export: exportContact(db, target.value.id)
5661
+ });
5662
+ }
5663
+ if (input.action === "delete" && input.confirm !== true)
5664
+ return toolFailure(
5665
+ "protocol",
5666
+ "invalid_input",
5667
+ "Delete requires confirmation.",
5668
+ "delete is permanent; pass confirm: true (CLI: --confirm)"
4771
5669
  );
5670
+ if (input.action === "create") {
5671
+ if (!input.input)
5672
+ return toolFailure(
5673
+ "protocol",
5674
+ "invalid_input",
5675
+ "Missing input.",
5676
+ "input is required for create."
5677
+ );
5678
+ return createContactToolResult(db, input.input);
4772
5679
  }
4773
- const contact = resolveContactLocator(db, input.contact, "contact");
5680
+ const contact = resolveContactLocator(
5681
+ db,
5682
+ input.contact,
5683
+ "contact",
5684
+ input.action === "delete"
5685
+ );
4774
5686
  if (!contact.ok) {
4775
5687
  return contact.result;
4776
5688
  }
5689
+ if (input.action === "delete") {
5690
+ const deletion = deleteContact(db, contact.value.id, { confirm: true });
5691
+ return toolSuccess("Permanently deleted contact.", {
5692
+ action: "delete",
5693
+ deletion,
5694
+ changes: deletion.removed.flatMap(
5695
+ (item) => item.ids.map((id) => ({
5696
+ entity: item.entity,
5697
+ id,
5698
+ action: "removed"
5699
+ }))
5700
+ )
5701
+ });
5702
+ }
4777
5703
  if (input.action === "revise") {
4778
5704
  return mutationToolResult(
4779
5705
  "revise",
4780
5706
  reviseContact(db, contact.value.id, input.patch),
4781
- "contact"
5707
+ "contact",
5708
+ Object.keys(input.patch)
4782
5709
  );
4783
5710
  }
4784
5711
  return mutationToolResult(
@@ -4796,16 +5723,22 @@ function manageContactToolHandler(db, input) {
4796
5723
  var manageContactTool;
4797
5724
  var init_manage_contact_tool = __esm({
4798
5725
  "src/tools/manage_contact_tool.ts"() {
4799
- init_create_contact();
5726
+ init_delete_contact();
5727
+ init_export_affinity();
5728
+ init_export_contact();
4800
5729
  init_revise_contact();
4801
5730
  init_set_contact_lifecycle();
5731
+ init_create_contact_tool_result();
4802
5732
  init_tool_metadata();
4803
5733
  init_tool_mutation();
4804
5734
  init_tool_names();
4805
5735
  init_tool_resolvers();
5736
+ init_tool_types();
5737
+ init_tool_types();
5738
+ init_validate_tool_input();
4806
5739
  manageContactTool = defineAffinityTool({
4807
5740
  name: manageContactToolName,
4808
- 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.",
5741
+ 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.",
4809
5742
  whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
4810
5743
  whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
4811
5744
  sideEffects: "writes_state",
@@ -4826,10 +5759,25 @@ var init_manage_contact_tool = __esm({
4826
5759
  action: enumSchema("Operation to perform.", [
4827
5760
  "create",
4828
5761
  "revise",
4829
- "set_lifecycle"
5762
+ "set_lifecycle",
5763
+ "export",
5764
+ "delete"
4830
5765
  ]),
4831
5766
  input: objectSchema(
4832
5767
  {
5768
+ identities: arraySchema(
5769
+ objectSchema(
5770
+ {
5771
+ type: stringSchema("Identity type."),
5772
+ value: stringSchema("Identity value."),
5773
+ label: stringSchema("Optional label."),
5774
+ verified: booleanSchema("Verified identity."),
5775
+ now: integerSchema("Optional timestamp.")
5776
+ },
5777
+ ["type", "value"]
5778
+ ),
5779
+ "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
5780
+ ),
4833
5781
  name: stringSchema("Contact name."),
4834
5782
  kind: enumSchema("Contact kind.", [
4835
5783
  "human",
@@ -4848,6 +5796,7 @@ var init_manage_contact_tool = __esm({
4848
5796
  ["name", "kind"],
4849
5797
  "New contact payload. Required when action=create."
4850
5798
  ),
5799
+ confirm: booleanSchema("Required true for permanent deletion."),
4851
5800
  contact: contactLocatorSchema3(
4852
5801
  "Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
4853
5802
  ),
@@ -5117,9 +6066,32 @@ function resolveDateAnchorTarget(db, target) {
5117
6066
  }
5118
6067
  return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
5119
6068
  }
5120
- function manageDateAnchorToolHandler(db, input) {
6069
+ function manageDateAnchorToolHandler(db, rawInput) {
6070
+ const input = structuredClone(rawInput);
6071
+ const inputError = validateToolInput(manageDateAnchorTool.inputSchema, input);
6072
+ if (inputError)
6073
+ return toolFailure(
6074
+ "protocol",
6075
+ "invalid_input",
6076
+ "Invalid input.",
6077
+ inputError
6078
+ );
6079
+ if (input.action !== "add" && input.anchorEventId === void 0)
6080
+ return toolFailure(
6081
+ "protocol",
6082
+ "invalid_input",
6083
+ "Missing anchorEventId.",
6084
+ "anchorEventId is required."
6085
+ );
5121
6086
  return withToolHandling(() => {
5122
6087
  if (input.action === "add") {
6088
+ if (!input.input)
6089
+ return toolFailure(
6090
+ "protocol",
6091
+ "invalid_input",
6092
+ "Missing input.",
6093
+ "input is required for add."
6094
+ );
5123
6095
  const resolved = resolveDateAnchorTarget(db, input.input.target);
5124
6096
  if (resolved.kind === "error") {
5125
6097
  return resolved.result;
@@ -5134,7 +6106,8 @@ function manageDateAnchorToolHandler(db, input) {
5134
6106
  return mutationToolResult(
5135
6107
  "revise",
5136
6108
  reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
5137
- "date anchor"
6109
+ "date anchor",
6110
+ Object.keys(input.patch)
5138
6111
  );
5139
6112
  }
5140
6113
  return mutationToolResult(
@@ -5154,6 +6127,8 @@ var init_manage_date_anchor_tool = __esm({
5154
6127
  init_tool_mutation();
5155
6128
  init_tool_names();
5156
6129
  init_tool_resolvers();
6130
+ init_tool_types();
6131
+ init_validate_tool_input();
5157
6132
  manageDateAnchorTool = defineAffinityTool({
5158
6133
  name: manageDateAnchorToolName,
5159
6134
  description: "Add, revise, or remove recurring date anchors on contacts or links. Use this for birthdays, anniversaries, and yearly events \u2014 not manage_attribute.",
@@ -5267,7 +6242,7 @@ function buildDismissalMutationReceipt(primary, partial) {
5267
6242
  derivedEffects: normalized.derivedEffects
5268
6243
  };
5269
6244
  }
5270
- var init_receipts5 = __esm({
6245
+ var init_receipts6 = __esm({
5271
6246
  "src/merges/receipts.ts"() {
5272
6247
  init_normalize_mutation_receipt();
5273
6248
  }
@@ -5329,7 +6304,7 @@ var init_dismiss_duplicate = __esm({
5329
6304
  init_affinity_validation_error();
5330
6305
  init_resolve_now();
5331
6306
  init_with_transaction();
5332
- init_receipts5();
6307
+ init_receipts6();
5333
6308
  }
5334
6309
  });
5335
6310
 
@@ -5368,7 +6343,7 @@ var init_undismiss_duplicate = __esm({
5368
6343
  init_affinity_not_found_error();
5369
6344
  init_affinity_validation_error();
5370
6345
  init_with_transaction();
5371
- init_receipts5();
6346
+ init_receipts6();
5372
6347
  }
5373
6348
  });
5374
6349
 
@@ -5390,7 +6365,19 @@ function contactLocatorSchema5(description) {
5390
6365
  description
5391
6366
  };
5392
6367
  }
5393
- function manageDuplicateDismissalToolHandler(db, input) {
6368
+ function manageDuplicateDismissalToolHandler(db, rawInput) {
6369
+ const input = structuredClone(rawInput);
6370
+ const inputError = validateToolInput(
6371
+ manageDuplicateDismissalTool.inputSchema,
6372
+ input
6373
+ );
6374
+ if (inputError)
6375
+ return toolFailure(
6376
+ "protocol",
6377
+ "invalid_input",
6378
+ "Invalid input.",
6379
+ inputError
6380
+ );
5394
6381
  return withToolHandling(() => {
5395
6382
  const left = resolveContactLocator(db, input.left, "left");
5396
6383
  if (!left.ok) {
@@ -5400,6 +6387,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
5400
6387
  if (!right.ok) {
5401
6388
  return right.result;
5402
6389
  }
6390
+ const leftContactId = Math.min(left.value.id, right.value.id);
6391
+ const rightContactId = Math.max(left.value.id, right.value.id);
6392
+ const existed = db.prepare(
6393
+ "SELECT 1 FROM dismissed_duplicates WHERE left_id = ? AND right_id = ?"
6394
+ ).get(leftContactId, rightContactId) !== void 0;
5403
6395
  if (input.action === "dismiss") {
5404
6396
  const receipt2 = dismissDuplicate(
5405
6397
  db,
@@ -5409,13 +6401,35 @@ function manageDuplicateDismissalToolHandler(db, input) {
5409
6401
  );
5410
6402
  return toolSuccess(
5411
6403
  `Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
5412
- { action: "dismiss", primary: receipt2.primary }
6404
+ {
6405
+ ...receipt2,
6406
+ action: "dismiss",
6407
+ changes: [
6408
+ {
6409
+ entity: "duplicate_dismissal",
6410
+ leftContactId,
6411
+ rightContactId,
6412
+ action: existed ? "updated" : "created"
6413
+ }
6414
+ ]
6415
+ }
5413
6416
  );
5414
6417
  }
5415
6418
  const receipt = undismissDuplicate(db, left.value.id, right.value.id);
5416
6419
  return toolSuccess(
5417
6420
  `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
5418
- { action: "undismiss", primary: receipt.primary }
6421
+ {
6422
+ ...receipt,
6423
+ action: "undismiss",
6424
+ changes: existed ? [
6425
+ {
6426
+ entity: "duplicate_dismissal",
6427
+ leftContactId,
6428
+ rightContactId,
6429
+ action: "removed"
6430
+ }
6431
+ ] : []
6432
+ }
5419
6433
  );
5420
6434
  }, "Duplicate dismissal failed.");
5421
6435
  }
@@ -5428,9 +6442,11 @@ var init_manage_duplicate_dismissal_tool = __esm({
5428
6442
  init_tool_names();
5429
6443
  init_tool_resolvers();
5430
6444
  init_tool_types();
6445
+ init_tool_types();
6446
+ init_validate_tool_input();
5431
6447
  manageDuplicateDismissalTool = defineAffinityTool({
5432
6448
  name: manageDuplicateDismissalToolName,
5433
- description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in listDuplicateCandidates.",
6449
+ description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
5434
6450
  whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
5435
6451
  whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
5436
6452
  sideEffects: "writes_state",
@@ -5463,128 +6479,6 @@ var init_manage_duplicate_dismissal_tool = __esm({
5463
6479
  }
5464
6480
  });
5465
6481
 
5466
- // src/identities/queries.ts
5467
- function getIdentityRowById(db, id) {
5468
- const row = db.prepare(
5469
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5470
- created_at, updated_at, removed_at
5471
- FROM identities
5472
- WHERE id = ? AND removed_at IS NULL`
5473
- ).get(id);
5474
- if (row === void 0) {
5475
- return null;
5476
- }
5477
- return row;
5478
- }
5479
- var init_queries5 = __esm({
5480
- "src/identities/queries.ts"() {
5481
- }
5482
- });
5483
-
5484
- // src/identities/receipts.ts
5485
- function buildIdentityMutationReceipt(primary, partial) {
5486
- const normalized = normalizeMutationReceiptPartial(partial);
5487
- return {
5488
- primary,
5489
- created: normalized.created,
5490
- updated: normalized.updated,
5491
- archived: normalized.archived,
5492
- removed: normalized.removed,
5493
- affectedLinks: normalized.affectedLinks,
5494
- derivedEffects: normalized.derivedEffects
5495
- };
5496
- }
5497
- var init_receipts6 = __esm({
5498
- "src/identities/receipts.ts"() {
5499
- init_normalize_mutation_receipt();
5500
- }
5501
- });
5502
-
5503
- // src/identities/validators.ts
5504
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5505
- const row = excludeIdentityId === void 0 ? db.prepare(
5506
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5507
- ).get(normalizedKey) : db.prepare(
5508
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
5509
- ).get(normalizedKey, excludeIdentityId);
5510
- if (row !== void 0) {
5511
- throw new AffinityConflictError(
5512
- "another contact already owns this normalized identity"
5513
- );
5514
- }
5515
- }
5516
- var init_validators5 = __esm({
5517
- "src/identities/validators.ts"() {
5518
- init_affinity_conflict_error();
5519
- }
5520
- });
5521
-
5522
- // src/identities/add_identity.ts
5523
- function addIdentity(db, contactId, input) {
5524
- return withTransaction(db, () => {
5525
- const contact = getContactRowById(db, contactId);
5526
- if (!contact) {
5527
- throw new AffinityNotFoundError("contact not found");
5528
- }
5529
- if (contact.lifecycle_state === "merged") {
5530
- throw new AffinityStateError("merged contact is read-only");
5531
- }
5532
- const type = input.type.trim();
5533
- const value = input.value.trim();
5534
- if (type.length === 0 || value.length === 0) {
5535
- throw new AffinityValidationError("type and value must be non-empty");
5536
- }
5537
- const nk = normalizeIdentityKey(type, value);
5538
- assertNoIdentityCollision(db, nk);
5539
- const now = resolveNow(input.now);
5540
- const verified = input.verified ? 1 : 0;
5541
- const verifiedAt = verified === 1 ? now : null;
5542
- const result = db.prepare(
5543
- `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
5544
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
5545
- ).run(
5546
- contactId,
5547
- type,
5548
- value,
5549
- input.label ?? null,
5550
- nk,
5551
- verified,
5552
- verifiedAt,
5553
- now,
5554
- now
5555
- );
5556
- const id = Number(result.lastInsertRowid);
5557
- const row = getIdentityRowById(db, id);
5558
- if (!row) {
5559
- throw new AffinityInvariantError("inserted identity not found");
5560
- }
5561
- refreshContactRollup(db, contactId, now);
5562
- db.prepare(
5563
- "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
5564
- ).run(contactId, contactId);
5565
- return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
5566
- created: [{ kind: "identity", id }]
5567
- });
5568
- });
5569
- }
5570
- var init_add_identity = __esm({
5571
- "src/identities/add_identity.ts"() {
5572
- init_queries();
5573
- init_rollups();
5574
- init_mappers2();
5575
- init_normalize();
5576
- init_queries5();
5577
- init_receipts6();
5578
- init_validators5();
5579
- init_affinity_invariant_error();
5580
- init_affinity_not_found_error();
5581
- init_affinity_state_error();
5582
- init_affinity_validation_error();
5583
- init_resolve_now();
5584
- init_with_transaction();
5585
- }
5586
- });
5587
-
5588
6482
  // src/identities/remove_identity.ts
5589
6483
  function removeIdentity(db, identityId, removedAt) {
5590
6484
  return withTransaction(db, () => {
@@ -5616,7 +6510,7 @@ var init_remove_identity = __esm({
5616
6510
  init_rollups();
5617
6511
  init_mappers2();
5618
6512
  init_queries5();
5619
- init_receipts6();
6513
+ init_receipts5();
5620
6514
  init_affinity_not_found_error();
5621
6515
  init_affinity_state_error();
5622
6516
  init_resolve_now();
@@ -5649,6 +6543,7 @@ function reviseIdentity(db, identityId, patch) {
5649
6543
  if (nextType.length === 0 || nextValue.length === 0) {
5650
6544
  throw new AffinityValidationError("type and value must be non-empty");
5651
6545
  }
6546
+ validateIdentityInput(nextType, nextValue);
5652
6547
  const nextLabel = patch.label !== void 0 ? patch.label : row.label;
5653
6548
  const nkNext = normalizeIdentityKey(nextType, nextValue);
5654
6549
  if (nkNext !== row.normalized_key) {
@@ -5674,13 +6569,14 @@ var init_revise_identity = __esm({
5674
6569
  init_mappers2();
5675
6570
  init_normalize();
5676
6571
  init_queries5();
5677
- init_receipts6();
6572
+ init_receipts5();
5678
6573
  init_validators5();
5679
6574
  init_affinity_not_found_error();
5680
6575
  init_affinity_state_error();
5681
6576
  init_affinity_validation_error();
5682
6577
  init_resolve_now();
5683
6578
  init_with_transaction();
6579
+ init_validate_identity_input();
5684
6580
  }
5685
6581
  });
5686
6582
 
@@ -5714,7 +6610,7 @@ var init_verify_identity = __esm({
5714
6610
  init_queries();
5715
6611
  init_mappers2();
5716
6612
  init_queries5();
5717
- init_receipts6();
6613
+ init_receipts5();
5718
6614
  init_affinity_not_found_error();
5719
6615
  init_affinity_state_error();
5720
6616
  init_resolve_now();
@@ -5730,7 +6626,9 @@ function contactLocatorSchema6(description) {
5730
6626
  contactId: integerSchema("Exact contact id."),
5731
6627
  identity: objectSchema(
5732
6628
  {
5733
- type: stringSchema("Identity type."),
6629
+ type: stringSchema(
6630
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6631
+ ),
5734
6632
  value: stringSchema("Identity value.")
5735
6633
  },
5736
6634
  ["type", "value"],
@@ -5740,7 +6638,23 @@ function contactLocatorSchema6(description) {
5740
6638
  description
5741
6639
  };
5742
6640
  }
5743
- function manageIdentityToolHandler(db, input) {
6641
+ function manageIdentityToolHandler(db, rawInput) {
6642
+ const input = structuredClone(rawInput);
6643
+ const inputError = validateToolInput(manageIdentityTool.inputSchema, input);
6644
+ if (inputError)
6645
+ return toolFailure(
6646
+ "protocol",
6647
+ "invalid_input",
6648
+ "Invalid input.",
6649
+ inputError
6650
+ );
6651
+ if (input.action !== "add" && input.identityId === void 0)
6652
+ return toolFailure(
6653
+ "protocol",
6654
+ "invalid_input",
6655
+ "Missing identityId.",
6656
+ "identityId is required."
6657
+ );
5744
6658
  return withToolHandling(() => {
5745
6659
  switch (input.action) {
5746
6660
  case "add": {
@@ -5758,7 +6672,8 @@ function manageIdentityToolHandler(db, input) {
5758
6672
  return mutationToolResult(
5759
6673
  "revise",
5760
6674
  reviseIdentity(db, input.identityId, input.patch),
5761
- "identity"
6675
+ "identity",
6676
+ Object.keys(input.patch)
5762
6677
  );
5763
6678
  case "verify":
5764
6679
  return mutationToolResult(
@@ -5781,11 +6696,14 @@ var init_manage_identity_tool = __esm({
5781
6696
  init_add_identity();
5782
6697
  init_remove_identity();
5783
6698
  init_revise_identity();
6699
+ init_validate_identity_input();
5784
6700
  init_verify_identity();
5785
6701
  init_tool_metadata();
5786
6702
  init_tool_mutation();
5787
6703
  init_tool_names();
5788
6704
  init_tool_resolvers();
6705
+ init_tool_types();
6706
+ init_validate_tool_input();
5789
6707
  manageIdentityTool = defineAffinityTool({
5790
6708
  name: manageIdentityToolName,
5791
6709
  description: "Add, revise, verify, or remove one routing identity on a contact. Use this for emails, phone numbers, social handles, and URLs \u2014 anything that could locate a person. Do not use manage_attribute for these.",
@@ -5818,7 +6736,9 @@ var init_manage_identity_tool = __esm({
5818
6736
  ),
5819
6737
  input: objectSchema(
5820
6738
  {
5821
- type: stringSchema("Identity type."),
6739
+ type: stringSchema(
6740
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6741
+ ),
5822
6742
  value: stringSchema("Identity value."),
5823
6743
  label: stringSchema("Optional label."),
5824
6744
  verified: booleanSchema("Whether the identity starts verified."),
@@ -5832,7 +6752,9 @@ var init_manage_identity_tool = __esm({
5832
6752
  ),
5833
6753
  patch: objectSchema(
5834
6754
  {
5835
- type: stringSchema("Optional identity type."),
6755
+ type: stringSchema(
6756
+ `Optional identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6757
+ ),
5836
6758
  value: stringSchema("Optional identity value."),
5837
6759
  label: stringSchema("Optional label.")
5838
6760
  },
@@ -6103,7 +7025,19 @@ function linkLocatorSchema4(description) {
6103
7025
  description
6104
7026
  };
6105
7027
  }
6106
- function manageRelationshipToolHandler(db, input) {
7028
+ function manageRelationshipToolHandler(db, rawInput) {
7029
+ const input = structuredClone(rawInput);
7030
+ const inputError = validateToolInput(
7031
+ manageRelationshipTool.inputSchema,
7032
+ input
7033
+ );
7034
+ if (inputError)
7035
+ return toolFailure(
7036
+ "protocol",
7037
+ "invalid_input",
7038
+ "Invalid input.",
7039
+ inputError
7040
+ );
6107
7041
  return withToolHandling(() => {
6108
7042
  switch (input.action) {
6109
7043
  case "seed_social_link": {
@@ -6202,6 +7136,8 @@ var init_manage_relationship_tool = __esm({
6202
7136
  init_tool_mutation();
6203
7137
  init_tool_names();
6204
7138
  init_tool_resolvers();
7139
+ init_tool_types();
7140
+ init_validate_tool_input();
6205
7141
  manageRelationshipTool = defineAffinityTool({
6206
7142
  name: manageRelationshipToolName,
6207
7143
  description: "Seed social links, revise bond and state, and manage structural ties. Seed a link as soon as a relationship is mentioned \u2014 do not wait for repeated evidence.",
@@ -6507,7 +7443,16 @@ function contactLocatorSchema8(description) {
6507
7443
  description
6508
7444
  };
6509
7445
  }
6510
- function mergeContactsToolHandler(db, input) {
7446
+ function mergeContactsToolHandler(db, rawInput) {
7447
+ const input = structuredClone(rawInput);
7448
+ const inputError = validateToolInput(mergeContactsTool.inputSchema, input);
7449
+ if (inputError)
7450
+ return toolFailure(
7451
+ "protocol",
7452
+ "invalid_input",
7453
+ "Invalid input.",
7454
+ inputError
7455
+ );
6511
7456
  return withToolHandling(() => {
6512
7457
  const winner = resolveContactLocator(db, input.winner, "winner");
6513
7458
  if (!winner.ok) {
@@ -6537,6 +7482,8 @@ var init_merge_contacts_tool = __esm({
6537
7482
  init_tool_mutation();
6538
7483
  init_tool_names();
6539
7484
  init_tool_resolvers();
7485
+ init_tool_types();
7486
+ init_validate_tool_input();
6540
7487
  mergeContactsTool = defineAffinityTool({
6541
7488
  name: mergeContactsToolName,
6542
7489
  description: "Merge one loser contact into one winner contact using explicit contact locators.",
@@ -6796,7 +7743,16 @@ function prepareParticipants2(db, raw, injectOwner) {
6796
7743
  const normalized = raw.map(normalizeParticipant2);
6797
7744
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
6798
7745
  }
6799
- function recordEventToolHandler(db, input) {
7746
+ function recordEventToolHandler(db, rawInput) {
7747
+ const input = structuredClone(rawInput);
7748
+ const inputError = validateToolInput(recordEventTool.inputSchema, input);
7749
+ if (inputError)
7750
+ return toolFailure(
7751
+ "protocol",
7752
+ "invalid_input",
7753
+ "Invalid input.",
7754
+ inputError
7755
+ );
6800
7756
  return withToolHandling(() => {
6801
7757
  const patched = {
6802
7758
  ...input.input,
@@ -6846,6 +7802,8 @@ var init_record_event_tool = __esm({
6846
7802
  init_tool_mutation();
6847
7803
  init_tool_names();
6848
7804
  init_tool_resolvers();
7805
+ init_tool_types();
7806
+ init_validate_tool_input();
6849
7807
  recordEventTool = defineAffinityTool({
6850
7808
  name: recordEventToolName,
6851
7809
  description: "Record one social event such as an interaction, observation, milestone, or transaction. For interactions, milestones, and transactions the owner contact is auto-injected into participants if missing. Observations may omit the owner when recording third-party evidence.",
@@ -6906,7 +7864,7 @@ var init_record_event_tool = __esm({
6906
7864
  "observed"
6907
7865
  ])
6908
7866
  },
6909
- ["contactId", "role"]
7867
+ ["role"]
6910
7868
  ),
6911
7869
  "Participant list. Must be non-empty and include the owner contact."
6912
7870
  ),
@@ -6924,6 +7882,27 @@ var init_record_event_tool = __esm({
6924
7882
  }
6925
7883
  });
6926
7884
 
7885
+ // src/contacts/contact_attribute_clauses.ts
7886
+ function contactAttributeClauses(filters) {
7887
+ const clauses = [];
7888
+ const params = [];
7889
+ const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
7890
+ const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
7891
+ for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
7892
+ const separator = filter.indexOf("=");
7893
+ const name = separator < 0 ? filter : filter.slice(0, separator);
7894
+ clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
7895
+ 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(?)"})`);
7896
+ params.push(name);
7897
+ if (separator >= 0) params.push(filter.slice(separator + 1));
7898
+ }
7899
+ return { clauses, params };
7900
+ }
7901
+ var init_contact_attribute_clauses = __esm({
7902
+ "src/contacts/contact_attribute_clauses.ts"() {
7903
+ }
7904
+ });
7905
+
6927
7906
  // src/contacts/list_contacts.ts
6928
7907
  function listContacts(db, filters, options) {
6929
7908
  assertDefaultOrdering("listContacts", options);
@@ -6947,6 +7926,9 @@ function listContacts(db, filters, options) {
6947
7926
  if (filters?.includeDormant === false || options?.includeDormant === false) {
6948
7927
  clauses.push("lifecycle_state != 'dormant'");
6949
7928
  }
7929
+ const attributeFilters = contactAttributeClauses(filters);
7930
+ clauses.push(...attributeFilters.clauses);
7931
+ params.push(...attributeFilters.params);
6950
7932
  const where = clauses.join(" AND ");
6951
7933
  const rows = db.prepare(
6952
7934
  `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
@@ -6975,6 +7957,7 @@ var init_list_contacts = __esm({
6975
7957
  "src/contacts/list_contacts.ts"() {
6976
7958
  init_assert_default_ordering();
6977
7959
  init_resolve_list_limit_offset();
7960
+ init_contact_attribute_clauses();
6978
7961
  init_mappers4();
6979
7962
  }
6980
7963
  });
@@ -7202,131 +8185,6 @@ var init_list_upcoming_dates = __esm({
7202
8185
  }
7203
8186
  });
7204
8187
 
7205
- // src/events/get_contact_journal.ts
7206
- function getContactJournal(db, contactId, options) {
7207
- assertDefaultOrdering("getContactJournal", options);
7208
- const { limit, offset } = resolveListLimitOffset(options);
7209
- const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
7210
- const params = [contactId];
7211
- if (options?.since !== void 0) {
7212
- clauses.push("e.occurred_at >= ?");
7213
- params.push(options.since);
7214
- }
7215
- if (options?.until !== void 0) {
7216
- clauses.push("e.occurred_at <= ?");
7217
- params.push(options.until);
7218
- }
7219
- if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
7220
- const ph = options.eventTypes.map(() => "?").join(", ");
7221
- clauses.push(`e.type IN (${ph})`);
7222
- params.push(...options.eventTypes);
7223
- }
7224
- const where = clauses.join(" AND ");
7225
- const rows = db.prepare(
7226
- `SELECT e.id FROM events e
7227
- INNER JOIN event_participants ep ON ep.event_id = e.id
7228
- WHERE ${where}
7229
- ORDER BY e.occurred_at DESC, e.id DESC
7230
- LIMIT ? OFFSET ?`
7231
- ).all(...params, limit, offset);
7232
- return batchLoadEventRecords(
7233
- db,
7234
- rows.map((r) => r.id)
7235
- );
7236
- }
7237
- var init_get_contact_journal = __esm({
7238
- "src/events/get_contact_journal.ts"() {
7239
- init_assert_default_ordering();
7240
- init_resolve_list_limit_offset();
7241
- init_loaders();
7242
- }
7243
- });
7244
-
7245
- // src/events/list_open_commitments.ts
7246
- function mapResolution(resolvedAt, resolution) {
7247
- if (resolvedAt === null) {
7248
- return "open";
7249
- }
7250
- if (resolution === "cancelled") {
7251
- return "cancelled";
7252
- }
7253
- return "resolved";
7254
- }
7255
- function listOpenCommitments(db, filters, options) {
7256
- assertDefaultOrdering("listOpenCommitments", options);
7257
- const { limit, offset } = resolveListLimitOffset(options);
7258
- const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
7259
- const params = [];
7260
- if (options?.since !== void 0) {
7261
- clauses.push("e.occurred_at >= ?");
7262
- params.push(options.since);
7263
- }
7264
- if (options?.until !== void 0) {
7265
- clauses.push("e.occurred_at <= ?");
7266
- params.push(options.until);
7267
- }
7268
- if (filters?.commitmentType !== void 0) {
7269
- if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
7270
- throw new AffinityValidationError("invalid commitmentType filter");
7271
- }
7272
- clauses.push("oc.commitment_type = ?");
7273
- params.push(filters.commitmentType);
7274
- }
7275
- if (filters?.contactId !== void 0) {
7276
- clauses.push(
7277
- "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
7278
- );
7279
- params.push(filters.contactId);
7280
- }
7281
- if (filters?.linkId !== void 0) {
7282
- clauses.push(
7283
- "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
7284
- );
7285
- params.push(filters.linkId);
7286
- }
7287
- if (filters?.horizonDays !== void 0) {
7288
- if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
7289
- throw new AffinityValidationError(
7290
- "horizonDays must be a non-negative integer"
7291
- );
7292
- }
7293
- const anchor = options?.since ?? Date.now();
7294
- clauses.push("oc.due_at IS NOT NULL");
7295
- clauses.push("oc.due_at <= ?");
7296
- params.push(anchor + filters.horizonDays * DAY_MS3);
7297
- }
7298
- const where = clauses.join(" AND ");
7299
- const rows = db.prepare(
7300
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
7301
- e.type, e.summary
7302
- FROM open_commitments oc
7303
- INNER JOIN events e ON e.id = oc.event_id
7304
- WHERE ${where}
7305
- ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
7306
- LIMIT ? OFFSET ?`
7307
- ).all(...params, limit, offset);
7308
- const eventIds = rows.map((r) => r.event_id);
7309
- const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
7310
- return rows.map((r) => ({
7311
- eventId: r.event_id,
7312
- type: r.type,
7313
- summary: r.summary,
7314
- participants: partsByEvent.get(r.event_id) ?? [],
7315
- dueAt: r.due_at,
7316
- resolutionState: mapResolution(r.resolved_at, r.resolution)
7317
- }));
7318
- }
7319
- var DAY_MS3;
7320
- var init_list_open_commitments = __esm({
7321
- "src/events/list_open_commitments.ts"() {
7322
- init_affinity_validation_error();
7323
- init_assert_default_ordering();
7324
- init_resolve_list_limit_offset();
7325
- init_loaders();
7326
- DAY_MS3 = 864e5;
7327
- }
7328
- });
7329
-
7330
8188
  // src/graph/get_affinity_chart.ts
7331
8189
  function getAffinityChart(db, options) {
7332
8190
  const contactClauses = [
@@ -7560,13 +8418,15 @@ function listRadar(db, filters, options) {
7560
8418
  if (ownerId === null) {
7561
8419
  return [];
7562
8420
  }
8421
+ const now = resolveNow(options?.now);
8422
+ 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})`;
7563
8423
  const { limit, offset } = resolveListLimitOffset(options);
7564
8424
  const clauses = [
7565
8425
  "l.removed_at IS NULL",
7566
8426
  "l.is_structural = 0",
7567
- "l.from_contact_id = ?"
8427
+ "(l.from_contact_id = ? OR l.to_contact_id = ?)"
7568
8428
  ];
7569
- const params = [ownerId];
8429
+ const params = [ownerId, ownerId];
7570
8430
  if (filters?.kind !== void 0) {
7571
8431
  clauses.push("l.kind = ?");
7572
8432
  params.push(filters.kind);
@@ -7588,6 +8448,7 @@ function listRadar(db, filters, options) {
7588
8448
  const rows = db.prepare(
7589
8449
  `SELECT
7590
8450
  l.id,
8451
+ ${overdue} AS overdue_commitment,
7591
8452
  l.from_contact_id,
7592
8453
  l.to_contact_id,
7593
8454
  l.trust,
@@ -7596,17 +8457,18 @@ function listRadar(db, filters, options) {
7596
8457
  lr.recency_score,
7597
8458
  lr.normalized_rank
7598
8459
  FROM links l
7599
- INNER JOIN contacts c_to ON c_to.id = l.to_contact_id
8460
+ 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
7600
8461
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
7601
8462
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
7602
8463
  WHERE ${where}
7603
- ORDER BY COALESCE(lr.radar_score, 0) DESC
8464
+ ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
7604
8465
  LIMIT ? OFFSET ?`
7605
8466
  ).all(...params, limit, offset);
7606
8467
  return rows.map((row) => ({
7607
8468
  linkId: row.id,
7608
8469
  contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
7609
- driftPriority: row.drift_priority ?? 0,
8470
+ reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
8471
+ driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
7610
8472
  recencyScore: row.recency_score ?? 0,
7611
8473
  normalizedRank: row.normalized_rank ?? 0,
7612
8474
  trust: row.trust ?? 0,
@@ -7622,6 +8484,7 @@ var init_list_radar = __esm({
7622
8484
  init_queries();
7623
8485
  init_assert_default_ordering();
7624
8486
  init_resolve_list_limit_offset();
8487
+ init_resolve_now();
7625
8488
  }
7626
8489
  });
7627
8490
 
@@ -7675,9 +8538,79 @@ var init_list_dismissed_duplicates = __esm({
7675
8538
  }
7676
8539
  });
7677
8540
 
8541
+ // src/tools/enrich_review_item.ts
8542
+ function enrichReviewItem(db, item) {
8543
+ const name = (id) => {
8544
+ if (typeof id !== "number") return "";
8545
+ return db.prepare("SELECT name FROM contacts WHERE id = ?").get(id)?.name ?? `Contact #${id}`;
8546
+ };
8547
+ const result = { ...item };
8548
+ if (item.kind === "link") {
8549
+ 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,
8550
+ 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);
8551
+ if (link) {
8552
+ result.title = link.from_owner ? String(link.to_name) : link.to_owner ? String(link.from_name) : `${link.from_name} \xB7 ${link.to_name}`;
8553
+ result.subtitle = [link.kind, link.role].filter(Boolean).join(" \xB7 ");
8554
+ for (const key of ["affinity", "trust", "rank"])
8555
+ if (typeof link[key] === "number") result[key] = link[key];
8556
+ result.recencyScore ??= link.recency_score;
8557
+ result.driftPriority ??= link.drift_priority;
8558
+ result.readinessScore = link.readiness_score;
8559
+ result.normalizedRank ??= link.normalized_rank;
8560
+ result.reciprocityScore = link.reciprocity_score;
8561
+ result.radarScore = link.radar_score;
8562
+ }
8563
+ }
8564
+ if (item.kind === "event" && typeof item.linkId === "number" && !Array.isArray(item.participants)) {
8565
+ const link = db.prepare("SELECT from_contact_id, to_contact_id FROM links WHERE id = ?").get(item.linkId);
8566
+ if (link)
8567
+ result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
8568
+ }
8569
+ if (Array.isArray(item.participants)) {
8570
+ result.counterpartyNames = item.participants.map(
8571
+ (p) => name(p.contactId)
8572
+ );
8573
+ result.subtitle = [
8574
+ item.subtitle,
8575
+ result.counterpartyNames.join(", ")
8576
+ ].filter(Boolean).join(" \xB7 ");
8577
+ }
8578
+ if (typeof item.leftContactId === "number")
8579
+ result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
8580
+ if (typeof item.winnerContactId === "number")
8581
+ result.title = `Merged ${name(item.loserContactId)} into ${name(item.winnerContactId)}`;
8582
+ if (item.occursAt !== void 0 && item.kind === "contact")
8583
+ result.title = `${name(item.id)} \xB7 ${item.title}`;
8584
+ for (const [key, value] of Object.entries(result)) {
8585
+ if (typeof value === "number" && /score|priority|affinity|trust|rank/i.test(key))
8586
+ result[key] = Math.round(value * 1e3) / 1e3;
8587
+ }
8588
+ return result;
8589
+ }
8590
+ var init_enrich_review_item = __esm({
8591
+ "src/tools/enrich_review_item.ts"() {
8592
+ }
8593
+ });
8594
+
8595
+ // src/tools/tool_summary.ts
8596
+ function summarizeCount(count, singular) {
8597
+ if (count === 0) {
8598
+ return `No ${singular}s found.`;
8599
+ }
8600
+ if (count === 1) {
8601
+ return `Found 1 ${singular}.`;
8602
+ }
8603
+ return `Found ${count} ${singular}s.`;
8604
+ }
8605
+ var init_tool_summary = __esm({
8606
+ "src/tools/tool_summary.ts"() {
8607
+ }
8608
+ });
8609
+
7678
8610
  // src/tools/review_affinity_tool.ts
7679
8611
  function buildReadOptions(input) {
7680
8612
  return {
8613
+ ...input.now === void 0 ? {} : { now: input.now },
7681
8614
  ...input.limit === void 0 ? {} : { limit: input.limit },
7682
8615
  ...input.offset === void 0 ? {} : { offset: input.offset },
7683
8616
  ...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
@@ -7797,6 +8730,7 @@ function validateReviewInput(input) {
7797
8730
  }
7798
8731
  function duplicateCandidateItem(candidate) {
7799
8732
  return {
8733
+ ...candidate,
7800
8734
  kind: "contact",
7801
8735
  id: candidate.leftContactId,
7802
8736
  title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
@@ -7806,24 +8740,28 @@ function duplicateCandidateItem(candidate) {
7806
8740
  }
7807
8741
  function momentItem(moment) {
7808
8742
  return {
8743
+ ...moment,
8744
+ occurredAtIso: new Date(moment.occurredAt).toISOString(),
7809
8745
  kind: "event",
7810
8746
  id: moment.eventId,
7811
8747
  title: moment.summary,
7812
8748
  subtitle: moment.momentKind,
7813
- snippet: `link #${moment.linkId} at ${moment.occurredAt}`
8749
+ snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
7814
8750
  };
7815
8751
  }
7816
8752
  function commitmentItem(commitment) {
7817
8753
  return {
8754
+ ...commitment,
7818
8755
  kind: "event",
7819
8756
  id: commitment.eventId,
7820
8757
  title: commitment.summary,
7821
8758
  subtitle: commitment.type,
7822
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt ?? "none"}`
8759
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
7823
8760
  };
7824
8761
  }
7825
8762
  function radarItem(radar) {
7826
8763
  return {
8764
+ ...radar,
7827
8765
  kind: "link",
7828
8766
  id: radar.linkId,
7829
8767
  title: `Link #${radar.linkId}`,
@@ -7833,29 +8771,34 @@ function radarItem(radar) {
7833
8771
  }
7834
8772
  function upcomingDateItem(date) {
7835
8773
  return {
8774
+ ...date,
8775
+ occursAt: date.occursOn,
8776
+ occursAtIso: new Date(date.occursOn).toISOString(),
7836
8777
  kind: date.targetRef.kind,
7837
8778
  id: date.targetRef.id,
7838
8779
  title: date.summary,
7839
8780
  subtitle: date.recurrenceKind,
7840
- snippet: `occurs ${date.occursOn}`
8781
+ snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
7841
8782
  };
7842
8783
  }
7843
8784
  function mergeHistoryItem(record) {
7844
8785
  return {
8786
+ ...record,
7845
8787
  kind: "contact",
7846
8788
  id: record.winnerContactId,
7847
8789
  title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
7848
- subtitle: `at ${record.mergedAt}`,
8790
+ subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
7849
8791
  snippet: record.reasonSummary ?? void 0
7850
8792
  };
7851
8793
  }
7852
8794
  function dismissedPairItem(record) {
7853
8795
  return {
8796
+ ...record,
7854
8797
  kind: "contact",
7855
8798
  id: record.leftContactId,
7856
8799
  title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
7857
8800
  subtitle: record.reason ?? "no reason given",
7858
- snippet: `dismissed at ${record.dismissedAt}`
8801
+ snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
7859
8802
  };
7860
8803
  }
7861
8804
  function compactFilters(input) {
@@ -7865,7 +8808,16 @@ function compactFilters(input) {
7865
8808
  )
7866
8809
  );
7867
8810
  }
7868
- function reviewAffinityToolHandler(db, input) {
8811
+ function reviewAffinityToolHandler(db, rawInput) {
8812
+ const input = structuredClone(rawInput);
8813
+ const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
8814
+ if (inputError)
8815
+ return toolFailure(
8816
+ "protocol",
8817
+ "invalid_input",
8818
+ "Invalid input.",
8819
+ inputError
8820
+ );
7869
8821
  return withToolHandling(() => {
7870
8822
  const invalid = validateReviewInput(input);
7871
8823
  if (invalid) {
@@ -7879,6 +8831,9 @@ function reviewAffinityToolHandler(db, input) {
7879
8831
  items = listContacts(
7880
8832
  db,
7881
8833
  {
8834
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
8835
+ ...input.tag === void 0 ? {} : { tag: input.tag },
8836
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
7882
8837
  ...input.kind === void 0 ? {} : { kind: input.kind },
7883
8838
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
7884
8839
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -7909,7 +8864,7 @@ function reviewAffinityToolHandler(db, input) {
7909
8864
  "Provide `contact` when `view` is `events.contact_journal`."
7910
8865
  );
7911
8866
  }
7912
- const contact = resolveContactLocator(db, locator, "contact");
8867
+ const contact = resolveContactLocator(db, locator, "contact", true);
7913
8868
  if (!contact.ok) {
7914
8869
  return contact.result;
7915
8870
  }
@@ -7944,7 +8899,12 @@ function reviewAffinityToolHandler(db, input) {
7944
8899
  let contactId;
7945
8900
  let linkId;
7946
8901
  if (input.contact !== void 0) {
7947
- const contact = resolveContactLocator(db, input.contact, "contact");
8902
+ const contact = resolveContactLocator(
8903
+ db,
8904
+ input.contact,
8905
+ "contact",
8906
+ true
8907
+ );
7948
8908
  if (!contact.ok) {
7949
8909
  return contact.result;
7950
8910
  }
@@ -7973,7 +8933,12 @@ function reviewAffinityToolHandler(db, input) {
7973
8933
  let contactId;
7974
8934
  let linkId;
7975
8935
  if (input.contact !== void 0) {
7976
- const contact = resolveContactLocator(db, input.contact, "contact");
8936
+ const contact = resolveContactLocator(
8937
+ db,
8938
+ input.contact,
8939
+ "contact",
8940
+ true
8941
+ );
7977
8942
  if (!contact.ok) {
7978
8943
  return contact.result;
7979
8944
  }
@@ -8066,7 +9031,7 @@ function reviewAffinityToolHandler(db, input) {
8066
9031
  "Provide `contact` when `view` is `merges.history`."
8067
9032
  );
8068
9033
  }
8069
- const contact = resolveContactLocator(db, locator, "contact");
9034
+ const contact = resolveContactLocator(db, locator, "contact", true);
8070
9035
  if (!contact.ok) {
8071
9036
  return contact.result;
8072
9037
  }
@@ -8079,6 +9044,7 @@ function reviewAffinityToolHandler(db, input) {
8079
9044
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
8080
9045
  break;
8081
9046
  }
9047
+ items = items.map((item) => enrichReviewItem(db, item));
8082
9048
  return toolSuccess(
8083
9049
  summarizeCount(items.length, "item"),
8084
9050
  {
@@ -8117,12 +9083,15 @@ var init_review_affinity_tool = __esm({
8117
9083
  init_list_radar();
8118
9084
  init_get_merge_history();
8119
9085
  init_list_dismissed_duplicates();
9086
+ init_enrich_review_item();
8120
9087
  init_tool_metadata();
8121
9088
  init_tool_names();
8122
9089
  init_tool_ref();
8123
9090
  init_tool_resolvers();
8124
9091
  init_tool_summary();
8125
9092
  init_tool_types();
9093
+ init_tool_types();
9094
+ init_validate_tool_input();
8126
9095
  reviewAffinityTool = defineAffinityTool({
8127
9096
  name: reviewAffinityToolName,
8128
9097
  description: "Review one affinity surface such as contacts, journals, link queues, commitments, dates, graph state, or merge history.",
@@ -8134,12 +9103,16 @@ var init_review_affinity_tool = __esm({
8134
9103
  targetKinds: ["contact", "link", "event"],
8135
9104
  inputDescriptions: {
8136
9105
  view: "Which review surface to return.",
9106
+ now: "Optional current timestamp for overdue calculations.",
8137
9107
  contact: "Target contact locator for views that require one.",
8138
9108
  link: "Target link locator for views that require one.",
8139
9109
  contactIds: "Optional contact id subset for duplicate review or graph review.",
8140
9110
  kind: "Optional contact kind or link kind filter, depending on the view.",
8141
9111
  lifecycleState: "Optional contact lifecycle filter for the contact list view.",
8142
9112
  linkState: "Optional link state filter for link review views.",
9113
+ attribute: "Attribute name or name=value; arrays require all filters.",
9114
+ tag: "Tag name; arrays require all tags.",
9115
+ includeLost: "Whether lost contacts are included.",
8143
9116
  includeOwner: "Whether owner contacts should be included in contact review.",
8144
9117
  includeDormant: "Whether dormant links or contacts should be included where supported.",
8145
9118
  includeArchived: "Whether archived links should be included where supported.",
@@ -8176,6 +9149,9 @@ var init_review_affinity_tool = __esm({
8176
9149
  "merges.history",
8177
9150
  "merges.dismissed"
8178
9151
  ]),
9152
+ now: integerSchema(
9153
+ "Optional current timestamp for overdue calculations."
9154
+ ),
8179
9155
  contact: contactLocatorSchema9("Optional target contact locator."),
8180
9156
  link: linkLocatorSchema5("Optional target link locator."),
8181
9157
  contactIds: arraySchema(
@@ -8198,6 +9174,17 @@ var init_review_affinity_tool = __esm({
8198
9174
  "broken",
8199
9175
  "archived"
8200
9176
  ]),
9177
+ attribute: oneOfSchema([
9178
+ stringSchema("Attribute filter."),
9179
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
9180
+ ]),
9181
+ tag: oneOfSchema([
9182
+ stringSchema("Tag filter."),
9183
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
9184
+ ]),
9185
+ includeLost: booleanSchema(
9186
+ "Include lost contacts (search excludes them by default)."
9187
+ ),
8201
9188
  includeOwner: booleanSchema("Whether owner contacts are included."),
8202
9189
  includeDormant: booleanSchema("Whether dormant rows are included."),
8203
9190
  includeArchived: booleanSchema("Whether archived links are included."),
@@ -8244,99 +9231,110 @@ var init_review_affinity_tool = __esm({
8244
9231
  });
8245
9232
 
8246
9233
  // src/contacts/search_contacts.ts
8247
- function relevanceScore(name, queryLower, matchedName, matchedNormalizedIdentity) {
8248
- const nl = name.toLowerCase();
8249
- if (matchedNormalizedIdentity) return 1.1;
8250
- if (nl.startsWith(queryLower)) return 1;
8251
- if (matchedName) return 0.85;
8252
- return 0.7;
8253
- }
8254
9234
  function searchContacts(db, query, filters, options) {
8255
- const q = query.trim();
8256
- if (q.length === 0) {
8257
- return [];
8258
- }
9235
+ const q = foldSearchText(query.trim());
9236
+ if (!q) return [];
8259
9237
  assertDefaultOrdering("searchContacts", options);
8260
9238
  const { limit, offset } = resolveListLimitOffset(options);
8261
- const queryLower = q.toLowerCase();
8262
- const pattern = `%${q.replace(/%/g, "").replace(/_/g, "")}%`;
8263
- const normalizedPattern = `%${queryLower}%`;
9239
+ const fields = /* @__PURE__ */ new Map();
9240
+ const rows = db.prepare(`
9241
+ SELECT contact_id, value || ' ' || normalized_key AS text, 'identity:' || type AS source FROM identities WHERE removed_at IS NULL
9242
+ UNION ALL SELECT contact_id, name || ' ' || COALESCE(value, ''), 'attribute:' || name FROM attributes WHERE deleted_at IS NULL AND contact_id IS NOT NULL
9243
+ UNION ALL SELECT from_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
9244
+ UNION ALL SELECT to_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
9245
+ 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
9246
+ `).all();
9247
+ for (const row of rows) {
9248
+ const values = fields.get(row.contact_id) ?? [];
9249
+ values.push({ text: foldSearchText(row.text), source: row.source });
9250
+ fields.set(row.contact_id, values);
9251
+ }
9252
+ const tokens = q.split(/\s+/);
9253
+ const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
9254
+ const digits = q.replace(/\D/g, "").replace(/^00/, "");
9255
+ const attributeFilters = contactAttributeClauses(filters);
8264
9256
  const clauses = [
8265
- "c.deleted_at IS NULL",
8266
- "c.lifecycle_state != 'merged'"
9257
+ "deleted_at IS NULL",
9258
+ "lifecycle_state != 'merged'",
9259
+ ...attributeFilters.clauses
8267
9260
  ];
8268
- const params = [];
9261
+ const params = [...attributeFilters.params];
8269
9262
  if (filters?.kind !== void 0) {
8270
- clauses.push("c.kind = ?");
9263
+ clauses.push("kind = ?");
8271
9264
  params.push(filters.kind);
8272
9265
  }
8273
9266
  if (filters?.lifecycleState !== void 0) {
8274
- clauses.push("c.lifecycle_state = ?");
9267
+ clauses.push("lifecycle_state = ?");
8275
9268
  params.push(filters.lifecycleState);
8276
9269
  }
8277
- if (filters?.includeOwner === false) {
8278
- clauses.push("c.is_owner = 0");
8279
- }
8280
- if (filters?.includeDormant === false || options?.includeDormant === false) {
8281
- clauses.push("c.lifecycle_state != 'dormant'");
8282
- }
8283
- const where = clauses.join(" AND ");
8284
- const sql = `
8285
- SELECT c.id, c.name, c.kind, c.lifecycle_state, c.is_owner, c.merged_into_contact_id,
8286
- c.created_at, c.updated_at, c.deleted_at,
8287
- (
8288
- SELECT value
8289
- FROM identities primary_i
8290
- WHERE primary_i.contact_id = c.id AND primary_i.removed_at IS NULL
8291
- ORDER BY primary_i.id
8292
- LIMIT 1
8293
- ) AS primary_identity,
8294
- MAX(CASE WHEN i.normalized_key LIKE ? THEN 1 ELSE 0 END) AS matched_normalized_identity
8295
- FROM contacts c
8296
- LEFT JOIN identities i ON i.contact_id = c.id AND i.removed_at IS NULL
8297
- WHERE ${where}
8298
- AND (c.name LIKE ? OR i.value LIKE ? OR i.normalized_key LIKE ?)
8299
- GROUP BY c.id
8300
- `;
8301
- const rows = db.prepare(sql).all(
8302
- normalizedPattern,
8303
- ...params,
8304
- pattern,
8305
- pattern,
8306
- normalizedPattern
8307
- );
8308
- const scored = rows.map((r) => {
8309
- const item = mapContactRowToContactListItem(r);
8310
- const matchedName = r.name.toLowerCase().includes(queryLower);
8311
- const score = relevanceScore(
8312
- r.name,
8313
- queryLower,
8314
- matchedName,
8315
- Number(
8316
- r.matched_normalized_identity ?? 0
8317
- ) > 0
9270
+ if (filters?.includeOwner === false) clauses.push("is_owner = 0");
9271
+ const contacts = db.prepare(
9272
+ `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 ")}`
9273
+ ).all(...params).map((row) => ({
9274
+ ...mapContactRowToContactListItem(row),
9275
+ primaryIdentity: row.primary_identity
9276
+ }));
9277
+ const scored = [];
9278
+ for (const contact of contacts) {
9279
+ if (contact.lifecycleState === "dormant" && !(filters?.includeDormant ?? options?.includeDormant))
9280
+ continue;
9281
+ if (contact.lifecycleState === "lost" && !filters?.includeLost) continue;
9282
+ const name = foldSearchText(contact.name);
9283
+ const values = [
9284
+ { text: name, source: "name" },
9285
+ ...(fields.get(contact.id) ?? []).filter(
9286
+ (field) => !contact.isOwner || field.source !== "event" && field.source !== "link_role"
9287
+ )
9288
+ ];
9289
+ const phoneFields = phone ? values.filter(
9290
+ (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
9291
+ ) : [];
9292
+ if (!phoneFields.length && !tokens.every(
9293
+ (token) => values.some((field) => field.text.includes(token))
9294
+ ))
9295
+ continue;
9296
+ const matchedOn = [
9297
+ ...new Set(
9298
+ [
9299
+ ...values.filter(
9300
+ (field) => tokens.some((token) => field.text.includes(token))
9301
+ ),
9302
+ ...phoneFields
9303
+ ].map((field) => field.source)
9304
+ )
9305
+ ];
9306
+ const identities = values.filter(
9307
+ (field) => field.source.startsWith("identity:")
8318
9308
  );
8319
- return {
8320
- item: {
8321
- ...item,
8322
- primaryIdentity: r.primary_identity ?? null
8323
- },
8324
- score
8325
- };
8326
- });
8327
- scored.sort((a, b) => b.score - a.score);
8328
- return scored.slice(offset, offset + limit).map((s) => s.item);
9309
+ 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(
9310
+ (field) => tokens.every((token) => field.text.includes(token))
9311
+ ) ? 2 : 1;
9312
+ scored.push({ item: { ...contact, matchedOn }, score });
9313
+ }
9314
+ scored.sort((a, b) => b.score - a.score || a.item.id - b.item.id);
9315
+ return scored.slice(offset, offset + limit).map(({ item }) => item);
8329
9316
  }
8330
9317
  var init_search_contacts = __esm({
8331
9318
  "src/contacts/search_contacts.ts"() {
8332
9319
  init_assert_default_ordering();
8333
9320
  init_resolve_list_limit_offset();
9321
+ init_contact_attribute_clauses();
9322
+ init_fold_search_text();
8334
9323
  init_mappers4();
8335
9324
  }
8336
9325
  });
8337
9326
 
8338
9327
  // src/tools/search_affinity_tool.ts
8339
- function searchAffinityToolHandler(db, input) {
9328
+ function searchAffinityToolHandler(db, rawInput) {
9329
+ const input = structuredClone(rawInput);
9330
+ const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
9331
+ if (inputError)
9332
+ return toolFailure(
9333
+ "protocol",
9334
+ "invalid_input",
9335
+ "Invalid input.",
9336
+ inputError
9337
+ );
8340
9338
  return withToolHandling(() => {
8341
9339
  const options = {
8342
9340
  ...input.limit === void 0 ? {} : { limit: input.limit },
@@ -8346,6 +9344,9 @@ function searchAffinityToolHandler(db, input) {
8346
9344
  db,
8347
9345
  input.query,
8348
9346
  {
9347
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
9348
+ ...input.tag === void 0 ? {} : { tag: input.tag },
9349
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
8349
9350
  ...input.kind === void 0 ? {} : { kind: input.kind },
8350
9351
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
8351
9352
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -8377,9 +9378,11 @@ var init_search_affinity_tool = __esm({
8377
9378
  init_tool_resolvers();
8378
9379
  init_tool_summary();
8379
9380
  init_tool_types();
9381
+ init_tool_types();
9382
+ init_validate_tool_input();
8380
9383
  searchAffinityTool = defineAffinityTool({
8381
9384
  name: searchAffinityToolName,
8382
- description: "Search contacts by name or identity, including exact natural-key lookups like email-style identifiers.",
9385
+ 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.",
8383
9386
  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.",
8384
9387
  whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
8385
9388
  sideEffects: "none",
@@ -8390,6 +9393,9 @@ var init_search_affinity_tool = __esm({
8390
9393
  query: "Free-text or natural-key search text.",
8391
9394
  kind: "Optional contact kind filter.",
8392
9395
  lifecycleState: "Optional lifecycle state filter.",
9396
+ attribute: "Attribute name or name=value; arrays require all filters.",
9397
+ tag: "Tag name; arrays require all tags.",
9398
+ includeLost: "Whether lost contacts are included in search.",
8393
9399
  includeOwner: "Whether owner contacts are eligible for results.",
8394
9400
  includeDormant: "Whether dormant contacts should be eligible for results.",
8395
9401
  limit: "Optional result limit.",
@@ -8414,6 +9420,17 @@ var init_search_affinity_tool = __esm({
8414
9420
  "merged",
8415
9421
  "lost"
8416
9422
  ]),
9423
+ attribute: oneOfSchema([
9424
+ stringSchema("Attribute filter."),
9425
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
9426
+ ]),
9427
+ tag: oneOfSchema([
9428
+ stringSchema("Tag filter."),
9429
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
9430
+ ]),
9431
+ includeLost: booleanSchema(
9432
+ "Include lost contacts (search excludes them by default)."
9433
+ ),
8417
9434
  includeOwner: booleanSchema("Whether owner contacts are included."),
8418
9435
  includeDormant: booleanSchema("Whether dormant contacts are included."),
8419
9436
  limit: integerSchema("Optional result limit."),
@@ -8460,11 +9477,112 @@ var init_tool_registry = __esm({
8460
9477
  }
8461
9478
  });
8462
9479
 
9480
+ // src/surface/plain_dates.ts
9481
+ function parsePlainDue(raw, now = Date.now(), endOfDay = true) {
9482
+ const invalid = () => new CliUsageError(
9483
+ `Invalid timestamp "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d, -2d or an ISO date-time.`
9484
+ );
9485
+ if (/^-?\d+$/.test(raw)) {
9486
+ const value = Number(raw);
9487
+ if (Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()))
9488
+ return value;
9489
+ throw invalid();
9490
+ }
9491
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
9492
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
9493
+ let date;
9494
+ if (iso || european) {
9495
+ const year = Number(iso?.[1] ?? european?.[3]);
9496
+ const month = Number(iso?.[2] ?? european?.[2]);
9497
+ const day = Number(iso?.[3] ?? european?.[1]);
9498
+ date = /* @__PURE__ */ new Date(0);
9499
+ date.setFullYear(year, month - 1, day);
9500
+ date.setHours(
9501
+ endOfDay ? 23 : 0,
9502
+ endOfDay ? 59 : 0,
9503
+ endOfDay ? 59 : 0,
9504
+ endOfDay ? 999 : 0
9505
+ );
9506
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
9507
+ throw invalid();
9508
+ } else if (raw === "today" || raw === "tomorrow" || raw === "yesterday" || /^[+-]\d+d$/.test(raw)) {
9509
+ date = new Date(now);
9510
+ date.setDate(
9511
+ date.getDate() + (raw === "today" ? 0 : raw === "tomorrow" ? 1 : raw === "yesterday" ? -1 : Number(raw.slice(0, -1)))
9512
+ );
9513
+ date.setHours(
9514
+ endOfDay ? 23 : 0,
9515
+ endOfDay ? 59 : 0,
9516
+ endOfDay ? 59 : 0,
9517
+ endOfDay ? 999 : 0
9518
+ );
9519
+ } else {
9520
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
9521
+ raw
9522
+ ))
9523
+ throw invalid();
9524
+ parsePlainDue(raw.slice(0, 10), now);
9525
+ date = new Date(raw);
9526
+ }
9527
+ if (!Number.isFinite(date.getTime())) throw invalid();
9528
+ return date.getTime();
9529
+ }
9530
+ function parsePlainStart(raw, now = Date.now()) {
9531
+ return parsePlainDue(raw, now, false);
9532
+ }
9533
+ function normalizeTimeInputs(value, now = Date.now()) {
9534
+ if (Array.isArray(value))
9535
+ return value.map((item) => normalizeTimeInputs(item, now));
9536
+ if (!value || typeof value !== "object") return value;
9537
+ return Object.fromEntries(
9538
+ Object.entries(value).map(([key, item]) => [
9539
+ key,
9540
+ typeof item === "string" && (key.endsWith("At") || ["asOf", "now"].includes(key)) ? key === "dueAt" ? parsePlainDue(item, now) : parsePlainStart(item, now) : normalizeTimeInputs(item, now)
9541
+ ])
9542
+ );
9543
+ }
9544
+ var init_plain_dates = __esm({
9545
+ "src/surface/plain_dates.ts"() {
9546
+ init_cli_args();
9547
+ }
9548
+ });
9549
+
9550
+ // src/surface/timestamp_schema.ts
9551
+ function acceptsString(schema) {
9552
+ return schema.type === "string" || Array.isArray(schema.type) && schema.type.includes("string") || (schema.oneOf?.some(acceptsString) ?? false);
9553
+ }
9554
+ function timestampSchema(schema) {
9555
+ return {
9556
+ ...schema,
9557
+ ...schema.properties ? {
9558
+ properties: Object.fromEntries(
9559
+ Object.entries(schema.properties).map(([key, value]) => [
9560
+ key,
9561
+ key.endsWith("At") || ["asOf", "now"].includes(key) ? acceptsString(value) ? value : {
9562
+ description: value.description,
9563
+ oneOf: [value, { type: "string" }]
9564
+ } : timestampSchema(value)
9565
+ ])
9566
+ )
9567
+ } : {},
9568
+ ...schema.items ? { items: timestampSchema(schema.items) } : {},
9569
+ ...schema.oneOf ? { oneOf: schema.oneOf.map(timestampSchema) } : {}
9570
+ };
9571
+ }
9572
+ var init_timestamp_schema = __esm({
9573
+ "src/surface/timestamp_schema.ts"() {
9574
+ }
9575
+ });
9576
+
8463
9577
  // src/surface/affinity_surface_tools.ts
8464
9578
  var affinitySurfaceTools;
8465
9579
  var init_affinity_surface_tools = __esm({
8466
9580
  "src/surface/affinity_surface_tools.ts"() {
9581
+ init_cli_args();
8467
9582
  init_tool_registry();
9583
+ init_tool_types();
9584
+ init_plain_dates();
9585
+ init_timestamp_schema();
8468
9586
  affinitySurfaceTools = affinityTools.map((tool) => ({
8469
9587
  name: tool.name,
8470
9588
  description: tool.description,
@@ -8472,8 +9590,24 @@ var init_affinity_surface_tools = __esm({
8472
9590
  ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
8473
9591
  readOnly: tool.readOnly,
8474
9592
  sideEffects: tool.sideEffects,
8475
- inputSchema: { ...tool.inputSchema, type: "object" },
8476
- handler: tool.handler
9593
+ inputSchema: {
9594
+ ...timestampSchema({ ...tool.inputSchema, type: "object" }),
9595
+ type: "object"
9596
+ },
9597
+ handler: (db, input) => {
9598
+ try {
9599
+ return tool.handler(db, normalizeTimeInputs(input));
9600
+ } catch (error) {
9601
+ if (error instanceof CliUsageError)
9602
+ return toolFailure(
9603
+ "protocol",
9604
+ "invalid_input",
9605
+ "Invalid input.",
9606
+ error.message
9607
+ );
9608
+ throw error;
9609
+ }
9610
+ }
8477
9611
  }));
8478
9612
  }
8479
9613
  });
@@ -8796,6 +9930,12 @@ function initLinkEventEffectsTables(db) {
8796
9930
  )
8797
9931
  )
8798
9932
  `);
9933
+ const columns = db.prepare("PRAGMA table_info(link_event_effects)").all();
9934
+ if (!columns.some((column) => column.name === "counts_for_recency")) {
9935
+ db.exec(
9936
+ "ALTER TABLE link_event_effects ADD COLUMN counts_for_recency INTEGER NOT NULL DEFAULT 1"
9937
+ );
9938
+ }
8799
9939
  db.exec(
8800
9940
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
8801
9941
  );
@@ -9027,7 +10167,8 @@ var init_open_affinity_db = __esm({
9027
10167
  }
9028
10168
  });
9029
10169
  function resolveAffinityDbPath(options = {}) {
9030
- return options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || join(homedir(), ".ghostpaw", "affinity.db"));
10170
+ const path = options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || join(homedir(), ".ghostpaw", "affinity.db"));
10171
+ return path === ":memory:" ? path : resolve(path);
9031
10172
  }
9032
10173
  var init_resolve_affinity_db_path = __esm({
9033
10174
  "src/surface/resolve_affinity_db_path.ts"() {