@ghostpaw/affinity 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1";
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.",
@@ -2763,9 +3164,17 @@ var init_unset_attribute = __esm({
2763
3164
  });
2764
3165
 
2765
3166
  // src/tools/tool_mutation.ts
2766
- function mutationToolResult(action, receipt, entitySummary) {
3167
+ function mutationToolResult(action, receipt, entitySummary, fields) {
2767
3168
  const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
2768
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
+ ),
2769
3178
  action,
2770
3179
  primary: receipt.primary,
2771
3180
  created: receipt.created,
@@ -2859,7 +3268,23 @@ function resolveAttributeTarget(db, target) {
2859
3268
  }
2860
3269
  return { kind: "ok", target: { kind: "link", id: link.value.id } };
2861
3270
  }
2862
- 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
+ );
2863
3288
  return withToolHandling(() => {
2864
3289
  const resolved = resolveAttributeTarget(db, input.target);
2865
3290
  if (resolved.kind === "error") {
@@ -2897,6 +3322,8 @@ var init_manage_attribute_tool = __esm({
2897
3322
  init_tool_mutation();
2898
3323
  init_tool_names();
2899
3324
  init_tool_resolvers();
3325
+ init_tool_types();
3326
+ init_validate_tool_input();
2900
3327
  manageAttributeTool = defineAffinityTool({
2901
3328
  name: manageAttributeToolName,
2902
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.",
@@ -3529,7 +3956,7 @@ function clamp3(value, min, max) {
3529
3956
  }
3530
3957
  function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3531
3958
  const dayStart = utcStartOfDayMs(occurredAt);
3532
- const dayEnd = dayStart + DAY_MS2;
3959
+ const dayEnd = dayStart + DAY_MS3;
3533
3960
  const row = db.prepare(
3534
3961
  `SELECT COUNT(*) AS c
3535
3962
  FROM link_event_effects le
@@ -3542,7 +3969,7 @@ function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3542
3969
  return Number(row.c ?? 0);
3543
3970
  }
3544
3971
  function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3545
- const weekStart = occurredAt - 7 * DAY_MS2;
3972
+ const weekStart = occurredAt - 7 * DAY_MS3;
3546
3973
  const row = db.prepare(
3547
3974
  `SELECT SUM(base_weight) AS total
3548
3975
  FROM link_event_effects
@@ -3554,7 +3981,7 @@ function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3554
3981
  return Number(row.total ?? 0);
3555
3982
  }
3556
3983
  function countRepairContext(db, linkId, occurredAt) {
3557
- const thirtyDaysAgo = occurredAt - 30 * DAY_MS2;
3984
+ const thirtyDaysAgo = occurredAt - 30 * DAY_MS3;
3558
3985
  const hasPriorDamage = db.prepare(
3559
3986
  `SELECT 1 FROM link_event_effects
3560
3987
  WHERE link_id = ? AND trust_delta < 0
@@ -3629,8 +4056,8 @@ function computePreferenceMatch(db, linkId, contactId, eventType, provenance) {
3629
4056
  return preferenceMatch(liked, disliked);
3630
4057
  }
3631
4058
  function computeDateSalienceMultiplier(db, contactId, occurredAt, significance) {
3632
- const windowBefore = 7 * DAY_MS2;
3633
- const windowAfter = 2 * DAY_MS2;
4059
+ const windowBefore = 7 * DAY_MS3;
4060
+ const windowAfter = 2 * DAY_MS3;
3634
4061
  const row = db.prepare(
3635
4062
  `SELECT 1 FROM upcoming_occurrences uo
3636
4063
  JOIN events e ON e.id = uo.event_id
@@ -3797,9 +4224,9 @@ function applyEventEffectToLink(db, params) {
3797
4224
  ).get(params.linkId);
3798
4225
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
3799
4226
  1,
3800
- Math.round((params.occurredAt - latest.occurred_at) / DAY_MS2)
4227
+ Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
3801
4228
  );
3802
- const cadenceAfter = nextCadenceDays({
4229
+ const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
3803
4230
  kind: linkKind,
3804
4231
  currentCadenceDays: cadenceBefore,
3805
4232
  gapDays
@@ -3862,6 +4289,11 @@ function applyEventEffectToLink(db, params) {
3862
4289
  params.now,
3863
4290
  params.now
3864
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
+ }
3865
4297
  refreshLinkRollup(db, params.linkId, params.now);
3866
4298
  return {
3867
4299
  linkId: params.linkId,
@@ -3885,7 +4317,7 @@ function applyEventEffectToLink(db, params) {
3885
4317
  momentKind
3886
4318
  };
3887
4319
  }
3888
- var DAY_MS2;
4320
+ var DAY_MS3;
3889
4321
  var init_effects = __esm({
3890
4322
  "src/links/effects.ts"() {
3891
4323
  init_calendar();
@@ -3895,7 +4327,7 @@ var init_effects = __esm({
3895
4327
  init_queries3();
3896
4328
  init_rollups2();
3897
4329
  init_validators2();
3898
- DAY_MS2 = 864e5;
4330
+ DAY_MS3 = 864e5;
3899
4331
  }
3900
4332
  });
3901
4333
 
@@ -4276,12 +4708,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4276
4708
  if (row.type !== "promise" && row.type !== "agreement") {
4277
4709
  throw new AffinityInvariantError("not a commitment event");
4278
4710
  }
4279
- 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);
4280
4714
  if (!open) {
4281
4715
  throw new AffinityNotFoundError("open commitment not found");
4282
4716
  }
4283
4717
  if (open.resolved_at !== null) {
4284
- throw new AffinityStateError("commitment already resolved");
4718
+ throw new AffinityConflictError(
4719
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4720
+ );
4285
4721
  }
4286
4722
  const now = resolveNow(options?.now);
4287
4723
  const originalParticipants = loadEventParticipantViews(
@@ -4294,7 +4730,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4294
4730
  WHERE event_id = ? AND resolved_at IS NULL`
4295
4731
  ).run(resolution, now, now, commitmentEventId);
4296
4732
  if (Number(result.changes ?? 0) === 0) {
4297
- throw new AffinityStateError("commitment already resolved");
4733
+ throw new AffinityConflictError(
4734
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4735
+ );
4298
4736
  }
4299
4737
  const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
4300
4738
  const created = [];
@@ -4364,9 +4802,9 @@ var init_resolve_commitment = __esm({
4364
4802
  init_queries2();
4365
4803
  init_receipts2();
4366
4804
  init_validators();
4805
+ init_affinity_conflict_error();
4367
4806
  init_affinity_invariant_error();
4368
4807
  init_affinity_not_found_error();
4369
- init_affinity_state_error();
4370
4808
  init_affinity_validation_error();
4371
4809
  init_resolve_now();
4372
4810
  init_with_transaction();
@@ -4399,9 +4837,25 @@ function ensureOwnerParticipates(db, participants) {
4399
4837
  function prepareParticipants(db, raw) {
4400
4838
  return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
4401
4839
  }
4402
- 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
+ );
4403
4850
  return withToolHandling(() => {
4404
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
+ );
4405
4859
  const patched = {
4406
4860
  ...input.input,
4407
4861
  participants: prepareParticipants(
@@ -4437,6 +4891,8 @@ var init_manage_commitment_tool = __esm({
4437
4891
  init_tool_mutation();
4438
4892
  init_tool_names();
4439
4893
  init_tool_resolvers();
4894
+ init_tool_types();
4895
+ init_validate_tool_input();
4440
4896
  manageCommitmentTool = defineAffinityTool({
4441
4897
  name: manageCommitmentToolName,
4442
4898
  description: "Record or resolve one commitment such as a promise or agreement. The owner contact is auto-injected into participants if missing.",
@@ -4478,7 +4934,7 @@ var init_manage_commitment_tool = __esm({
4478
4934
  role: stringSchema("Participant role."),
4479
4935
  directionality: stringSchema("Optional directionality.")
4480
4936
  },
4481
- ["contactId", "role"]
4937
+ ["role"]
4482
4938
  ),
4483
4939
  "Participant list. Must be non-empty and include the owner contact."
4484
4940
  ),
@@ -4527,6 +4983,163 @@ var init_manage_commitment_tool = __esm({
4527
4983
  }
4528
4984
  });
4529
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
+
4530
5143
  // src/contacts/receipts.ts
4531
5144
  function buildContactMutationReceipt(primary, partial) {
4532
5145
  const normalized = normalizeMutationReceiptPartial(partial);
@@ -4558,73 +5171,6 @@ var init_receipts4 = __esm({
4558
5171
  }
4559
5172
  });
4560
5173
 
4561
- // src/contacts/create_contact.ts
4562
- function createContact(db, input) {
4563
- return withTransaction(db, () => {
4564
- const name = input.name.trim();
4565
- if (name.length === 0) {
4566
- throw new AffinityValidationError("name must be non-empty");
4567
- }
4568
- if (!KINDS.has(input.kind)) {
4569
- throw new AffinityValidationError("invalid contact kind");
4570
- }
4571
- if (input.isOwner !== void 0) {
4572
- throw new AffinityValidationError(
4573
- "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
4574
- );
4575
- }
4576
- const now = resolveNow(input.now);
4577
- if (input.bootstrapOwner) {
4578
- const existing = db.prepare(
4579
- "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
4580
- ).get();
4581
- if (existing !== void 0) {
4582
- throw new AffinityConflictError(
4583
- `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
4584
- );
4585
- }
4586
- }
4587
- const isOwner = input.bootstrapOwner ? 1 : 0;
4588
- const result = db.prepare(
4589
- `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
4590
- VALUES (?, ?, 'active', ?, ?, ?)`
4591
- ).run(name, input.kind, isOwner, now, now);
4592
- const id = Number(result.lastInsertRowid);
4593
- const row = getContactRowById(db, id);
4594
- if (!row) {
4595
- throw new AffinityInvariantError("inserted contact row not found");
4596
- }
4597
- refreshContactRollup(db, id, now);
4598
- const primary = mapContactRowToContactListItem(row);
4599
- return buildContactMutationReceipt(primary, {
4600
- created: [{ kind: "contact", id }]
4601
- });
4602
- });
4603
- }
4604
- var KINDS;
4605
- var init_create_contact = __esm({
4606
- "src/contacts/create_contact.ts"() {
4607
- init_mappers4();
4608
- init_queries();
4609
- init_receipts4();
4610
- init_rollups();
4611
- init_affinity_conflict_error();
4612
- init_affinity_invariant_error();
4613
- init_affinity_validation_error();
4614
- init_resolve_now();
4615
- init_with_transaction();
4616
- KINDS = /* @__PURE__ */ new Set([
4617
- "human",
4618
- "group",
4619
- "company",
4620
- "team",
4621
- "pet",
4622
- "service",
4623
- "other"
4624
- ]);
4625
- }
4626
- });
4627
-
4628
5174
  // src/contacts/revise_contact.ts
4629
5175
  function reviseContact(db, contactId, patch) {
4630
5176
  return withTransaction(db, () => {
@@ -4680,7 +5226,9 @@ function assertValidLifecycleTransition(from, to) {
4680
5226
  throw new AffinityStateError("merged contact is read-only");
4681
5227
  }
4682
5228
  if (to === "merged") {
4683
- 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
+ );
4684
5232
  }
4685
5233
  if (from === to) {
4686
5234
  return;
@@ -4713,42 +5261,360 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
4713
5261
  }
4714
5262
  const row = getContactRowById(db, contactId);
4715
5263
  if (!row) {
4716
- throw new AffinityNotFoundError("contact not found");
4717
- }
4718
- const from = row.lifecycle_state;
4719
- assertValidLifecycleTransition(from, lifecycleState);
4720
- const now = resolveNow(options?.now);
4721
- if (from === lifecycleState) {
4722
- return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
4723
- updated: []
4724
- });
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");
4725
5543
  }
4726
- db.prepare(
4727
- "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
4728
- ).run(lifecycleState, now, contactId);
4729
5544
  refreshContactRollup(db, contactId, now);
4730
- const next = getContactRowById(db, contactId);
4731
- if (!next) {
4732
- throw new AffinityNotFoundError("contact not found");
4733
- }
4734
- return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
4735
- 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 }]
4736
5550
  });
4737
5551
  });
4738
5552
  }
4739
- var STATES2;
4740
- var init_set_contact_lifecycle = __esm({
4741
- "src/contacts/set_contact_lifecycle.ts"() {
4742
- init_mappers4();
5553
+ var init_add_identity = __esm({
5554
+ "src/identities/add_identity.ts"() {
4743
5555
  init_queries();
4744
- init_receipts4();
4745
5556
  init_rollups();
4746
- init_validators4();
5557
+ init_mappers2();
5558
+ init_normalize();
5559
+ init_queries5();
5560
+ init_receipts5();
5561
+ init_validators5();
5562
+ init_affinity_invariant_error();
4747
5563
  init_affinity_not_found_error();
5564
+ init_affinity_state_error();
4748
5565
  init_affinity_validation_error();
4749
5566
  init_resolve_now();
4750
5567
  init_with_transaction();
4751
- 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();
4752
5618
  }
4753
5619
  });
4754
5620
 
@@ -4770,24 +5636,76 @@ function contactLocatorSchema3(description) {
4770
5636
  description
4771
5637
  };
4772
5638
  }
4773
- 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
+ );
4774
5649
  return withToolHandling(() => {
4775
- if (input.action === "create") {
4776
- return mutationToolResult(
4777
- "create",
4778
- createContact(db, input.input),
4779
- "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)"
4780
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);
4781
5679
  }
4782
- const contact = resolveContactLocator(db, input.contact, "contact");
5680
+ const contact = resolveContactLocator(
5681
+ db,
5682
+ input.contact,
5683
+ "contact",
5684
+ input.action === "delete"
5685
+ );
4783
5686
  if (!contact.ok) {
4784
5687
  return contact.result;
4785
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
+ }
4786
5703
  if (input.action === "revise") {
4787
5704
  return mutationToolResult(
4788
5705
  "revise",
4789
5706
  reviseContact(db, contact.value.id, input.patch),
4790
- "contact"
5707
+ "contact",
5708
+ Object.keys(input.patch)
4791
5709
  );
4792
5710
  }
4793
5711
  return mutationToolResult(
@@ -4805,16 +5723,22 @@ function manageContactToolHandler(db, input) {
4805
5723
  var manageContactTool;
4806
5724
  var init_manage_contact_tool = __esm({
4807
5725
  "src/tools/manage_contact_tool.ts"() {
4808
- init_create_contact();
5726
+ init_delete_contact();
5727
+ init_export_affinity();
5728
+ init_export_contact();
4809
5729
  init_revise_contact();
4810
5730
  init_set_contact_lifecycle();
5731
+ init_create_contact_tool_result();
4811
5732
  init_tool_metadata();
4812
5733
  init_tool_mutation();
4813
5734
  init_tool_names();
4814
5735
  init_tool_resolvers();
5736
+ init_tool_types();
5737
+ init_tool_types();
5738
+ init_validate_tool_input();
4815
5739
  manageContactTool = defineAffinityTool({
4816
5740
  name: manageContactToolName,
4817
- 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.",
4818
5742
  whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
4819
5743
  whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
4820
5744
  sideEffects: "writes_state",
@@ -4835,10 +5759,25 @@ var init_manage_contact_tool = __esm({
4835
5759
  action: enumSchema("Operation to perform.", [
4836
5760
  "create",
4837
5761
  "revise",
4838
- "set_lifecycle"
5762
+ "set_lifecycle",
5763
+ "export",
5764
+ "delete"
4839
5765
  ]),
4840
5766
  input: objectSchema(
4841
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
+ ),
4842
5781
  name: stringSchema("Contact name."),
4843
5782
  kind: enumSchema("Contact kind.", [
4844
5783
  "human",
@@ -4857,6 +5796,7 @@ var init_manage_contact_tool = __esm({
4857
5796
  ["name", "kind"],
4858
5797
  "New contact payload. Required when action=create."
4859
5798
  ),
5799
+ confirm: booleanSchema("Required true for permanent deletion."),
4860
5800
  contact: contactLocatorSchema3(
4861
5801
  "Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
4862
5802
  ),
@@ -5126,9 +6066,32 @@ function resolveDateAnchorTarget(db, target) {
5126
6066
  }
5127
6067
  return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
5128
6068
  }
5129
- 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
+ );
5130
6086
  return withToolHandling(() => {
5131
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
+ );
5132
6095
  const resolved = resolveDateAnchorTarget(db, input.input.target);
5133
6096
  if (resolved.kind === "error") {
5134
6097
  return resolved.result;
@@ -5143,7 +6106,8 @@ function manageDateAnchorToolHandler(db, input) {
5143
6106
  return mutationToolResult(
5144
6107
  "revise",
5145
6108
  reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
5146
- "date anchor"
6109
+ "date anchor",
6110
+ Object.keys(input.patch)
5147
6111
  );
5148
6112
  }
5149
6113
  return mutationToolResult(
@@ -5163,6 +6127,8 @@ var init_manage_date_anchor_tool = __esm({
5163
6127
  init_tool_mutation();
5164
6128
  init_tool_names();
5165
6129
  init_tool_resolvers();
6130
+ init_tool_types();
6131
+ init_validate_tool_input();
5166
6132
  manageDateAnchorTool = defineAffinityTool({
5167
6133
  name: manageDateAnchorToolName,
5168
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.",
@@ -5276,7 +6242,7 @@ function buildDismissalMutationReceipt(primary, partial) {
5276
6242
  derivedEffects: normalized.derivedEffects
5277
6243
  };
5278
6244
  }
5279
- var init_receipts5 = __esm({
6245
+ var init_receipts6 = __esm({
5280
6246
  "src/merges/receipts.ts"() {
5281
6247
  init_normalize_mutation_receipt();
5282
6248
  }
@@ -5338,7 +6304,7 @@ var init_dismiss_duplicate = __esm({
5338
6304
  init_affinity_validation_error();
5339
6305
  init_resolve_now();
5340
6306
  init_with_transaction();
5341
- init_receipts5();
6307
+ init_receipts6();
5342
6308
  }
5343
6309
  });
5344
6310
 
@@ -5377,7 +6343,7 @@ var init_undismiss_duplicate = __esm({
5377
6343
  init_affinity_not_found_error();
5378
6344
  init_affinity_validation_error();
5379
6345
  init_with_transaction();
5380
- init_receipts5();
6346
+ init_receipts6();
5381
6347
  }
5382
6348
  });
5383
6349
 
@@ -5399,7 +6365,19 @@ function contactLocatorSchema5(description) {
5399
6365
  description
5400
6366
  };
5401
6367
  }
5402
- 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
+ );
5403
6381
  return withToolHandling(() => {
5404
6382
  const left = resolveContactLocator(db, input.left, "left");
5405
6383
  if (!left.ok) {
@@ -5409,6 +6387,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
5409
6387
  if (!right.ok) {
5410
6388
  return right.result;
5411
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;
5412
6395
  if (input.action === "dismiss") {
5413
6396
  const receipt2 = dismissDuplicate(
5414
6397
  db,
@@ -5418,13 +6401,35 @@ function manageDuplicateDismissalToolHandler(db, input) {
5418
6401
  );
5419
6402
  return toolSuccess(
5420
6403
  `Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
5421
- { 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
+ }
5422
6416
  );
5423
6417
  }
5424
6418
  const receipt = undismissDuplicate(db, left.value.id, right.value.id);
5425
6419
  return toolSuccess(
5426
6420
  `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
5427
- { 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
+ }
5428
6433
  );
5429
6434
  }, "Duplicate dismissal failed.");
5430
6435
  }
@@ -5437,9 +6442,11 @@ var init_manage_duplicate_dismissal_tool = __esm({
5437
6442
  init_tool_names();
5438
6443
  init_tool_resolvers();
5439
6444
  init_tool_types();
6445
+ init_tool_types();
6446
+ init_validate_tool_input();
5440
6447
  manageDuplicateDismissalTool = defineAffinityTool({
5441
6448
  name: manageDuplicateDismissalToolName,
5442
- 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.",
5443
6450
  whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
5444
6451
  whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
5445
6452
  sideEffects: "writes_state",
@@ -5472,128 +6479,6 @@ var init_manage_duplicate_dismissal_tool = __esm({
5472
6479
  }
5473
6480
  });
5474
6481
 
5475
- // src/identities/queries.ts
5476
- function getIdentityRowById(db, id) {
5477
- const row = db.prepare(
5478
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5479
- created_at, updated_at, removed_at
5480
- FROM identities
5481
- WHERE id = ? AND removed_at IS NULL`
5482
- ).get(id);
5483
- if (row === void 0) {
5484
- return null;
5485
- }
5486
- return row;
5487
- }
5488
- var init_queries5 = __esm({
5489
- "src/identities/queries.ts"() {
5490
- }
5491
- });
5492
-
5493
- // src/identities/receipts.ts
5494
- function buildIdentityMutationReceipt(primary, partial) {
5495
- const normalized = normalizeMutationReceiptPartial(partial);
5496
- return {
5497
- primary,
5498
- created: normalized.created,
5499
- updated: normalized.updated,
5500
- archived: normalized.archived,
5501
- removed: normalized.removed,
5502
- affectedLinks: normalized.affectedLinks,
5503
- derivedEffects: normalized.derivedEffects
5504
- };
5505
- }
5506
- var init_receipts6 = __esm({
5507
- "src/identities/receipts.ts"() {
5508
- init_normalize_mutation_receipt();
5509
- }
5510
- });
5511
-
5512
- // src/identities/validators.ts
5513
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5514
- const row = excludeIdentityId === void 0 ? db.prepare(
5515
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5516
- ).get(normalizedKey) : db.prepare(
5517
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
5518
- ).get(normalizedKey, excludeIdentityId);
5519
- if (row !== void 0) {
5520
- throw new AffinityConflictError(
5521
- "another contact already owns this normalized identity"
5522
- );
5523
- }
5524
- }
5525
- var init_validators5 = __esm({
5526
- "src/identities/validators.ts"() {
5527
- init_affinity_conflict_error();
5528
- }
5529
- });
5530
-
5531
- // src/identities/add_identity.ts
5532
- function addIdentity(db, contactId, input) {
5533
- return withTransaction(db, () => {
5534
- const contact = getContactRowById(db, contactId);
5535
- if (!contact) {
5536
- throw new AffinityNotFoundError("contact not found");
5537
- }
5538
- if (contact.lifecycle_state === "merged") {
5539
- throw new AffinityStateError("merged contact is read-only");
5540
- }
5541
- const type = input.type.trim();
5542
- const value = input.value.trim();
5543
- if (type.length === 0 || value.length === 0) {
5544
- throw new AffinityValidationError("type and value must be non-empty");
5545
- }
5546
- const nk = normalizeIdentityKey(type, value);
5547
- assertNoIdentityCollision(db, nk);
5548
- const now = resolveNow(input.now);
5549
- const verified = input.verified ? 1 : 0;
5550
- const verifiedAt = verified === 1 ? now : null;
5551
- const result = db.prepare(
5552
- `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
5553
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
5554
- ).run(
5555
- contactId,
5556
- type,
5557
- value,
5558
- input.label ?? null,
5559
- nk,
5560
- verified,
5561
- verifiedAt,
5562
- now,
5563
- now
5564
- );
5565
- const id = Number(result.lastInsertRowid);
5566
- const row = getIdentityRowById(db, id);
5567
- if (!row) {
5568
- throw new AffinityInvariantError("inserted identity not found");
5569
- }
5570
- refreshContactRollup(db, contactId, now);
5571
- db.prepare(
5572
- "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
5573
- ).run(contactId, contactId);
5574
- return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
5575
- created: [{ kind: "identity", id }]
5576
- });
5577
- });
5578
- }
5579
- var init_add_identity = __esm({
5580
- "src/identities/add_identity.ts"() {
5581
- init_queries();
5582
- init_rollups();
5583
- init_mappers2();
5584
- init_normalize();
5585
- init_queries5();
5586
- init_receipts6();
5587
- init_validators5();
5588
- init_affinity_invariant_error();
5589
- init_affinity_not_found_error();
5590
- init_affinity_state_error();
5591
- init_affinity_validation_error();
5592
- init_resolve_now();
5593
- init_with_transaction();
5594
- }
5595
- });
5596
-
5597
6482
  // src/identities/remove_identity.ts
5598
6483
  function removeIdentity(db, identityId, removedAt) {
5599
6484
  return withTransaction(db, () => {
@@ -5625,7 +6510,7 @@ var init_remove_identity = __esm({
5625
6510
  init_rollups();
5626
6511
  init_mappers2();
5627
6512
  init_queries5();
5628
- init_receipts6();
6513
+ init_receipts5();
5629
6514
  init_affinity_not_found_error();
5630
6515
  init_affinity_state_error();
5631
6516
  init_resolve_now();
@@ -5658,6 +6543,7 @@ function reviseIdentity(db, identityId, patch) {
5658
6543
  if (nextType.length === 0 || nextValue.length === 0) {
5659
6544
  throw new AffinityValidationError("type and value must be non-empty");
5660
6545
  }
6546
+ validateIdentityInput(nextType, nextValue);
5661
6547
  const nextLabel = patch.label !== void 0 ? patch.label : row.label;
5662
6548
  const nkNext = normalizeIdentityKey(nextType, nextValue);
5663
6549
  if (nkNext !== row.normalized_key) {
@@ -5683,13 +6569,14 @@ var init_revise_identity = __esm({
5683
6569
  init_mappers2();
5684
6570
  init_normalize();
5685
6571
  init_queries5();
5686
- init_receipts6();
6572
+ init_receipts5();
5687
6573
  init_validators5();
5688
6574
  init_affinity_not_found_error();
5689
6575
  init_affinity_state_error();
5690
6576
  init_affinity_validation_error();
5691
6577
  init_resolve_now();
5692
6578
  init_with_transaction();
6579
+ init_validate_identity_input();
5693
6580
  }
5694
6581
  });
5695
6582
 
@@ -5723,7 +6610,7 @@ var init_verify_identity = __esm({
5723
6610
  init_queries();
5724
6611
  init_mappers2();
5725
6612
  init_queries5();
5726
- init_receipts6();
6613
+ init_receipts5();
5727
6614
  init_affinity_not_found_error();
5728
6615
  init_affinity_state_error();
5729
6616
  init_resolve_now();
@@ -5739,7 +6626,9 @@ function contactLocatorSchema6(description) {
5739
6626
  contactId: integerSchema("Exact contact id."),
5740
6627
  identity: objectSchema(
5741
6628
  {
5742
- type: stringSchema("Identity type."),
6629
+ type: stringSchema(
6630
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6631
+ ),
5743
6632
  value: stringSchema("Identity value.")
5744
6633
  },
5745
6634
  ["type", "value"],
@@ -5749,7 +6638,23 @@ function contactLocatorSchema6(description) {
5749
6638
  description
5750
6639
  };
5751
6640
  }
5752
- 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
+ );
5753
6658
  return withToolHandling(() => {
5754
6659
  switch (input.action) {
5755
6660
  case "add": {
@@ -5767,7 +6672,8 @@ function manageIdentityToolHandler(db, input) {
5767
6672
  return mutationToolResult(
5768
6673
  "revise",
5769
6674
  reviseIdentity(db, input.identityId, input.patch),
5770
- "identity"
6675
+ "identity",
6676
+ Object.keys(input.patch)
5771
6677
  );
5772
6678
  case "verify":
5773
6679
  return mutationToolResult(
@@ -5790,11 +6696,14 @@ var init_manage_identity_tool = __esm({
5790
6696
  init_add_identity();
5791
6697
  init_remove_identity();
5792
6698
  init_revise_identity();
6699
+ init_validate_identity_input();
5793
6700
  init_verify_identity();
5794
6701
  init_tool_metadata();
5795
6702
  init_tool_mutation();
5796
6703
  init_tool_names();
5797
6704
  init_tool_resolvers();
6705
+ init_tool_types();
6706
+ init_validate_tool_input();
5798
6707
  manageIdentityTool = defineAffinityTool({
5799
6708
  name: manageIdentityToolName,
5800
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.",
@@ -5827,7 +6736,9 @@ var init_manage_identity_tool = __esm({
5827
6736
  ),
5828
6737
  input: objectSchema(
5829
6738
  {
5830
- type: stringSchema("Identity type."),
6739
+ type: stringSchema(
6740
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6741
+ ),
5831
6742
  value: stringSchema("Identity value."),
5832
6743
  label: stringSchema("Optional label."),
5833
6744
  verified: booleanSchema("Whether the identity starts verified."),
@@ -5841,7 +6752,9 @@ var init_manage_identity_tool = __esm({
5841
6752
  ),
5842
6753
  patch: objectSchema(
5843
6754
  {
5844
- 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
+ ),
5845
6758
  value: stringSchema("Optional identity value."),
5846
6759
  label: stringSchema("Optional label.")
5847
6760
  },
@@ -6112,7 +7025,19 @@ function linkLocatorSchema4(description) {
6112
7025
  description
6113
7026
  };
6114
7027
  }
6115
- 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
+ );
6116
7041
  return withToolHandling(() => {
6117
7042
  switch (input.action) {
6118
7043
  case "seed_social_link": {
@@ -6211,6 +7136,8 @@ var init_manage_relationship_tool = __esm({
6211
7136
  init_tool_mutation();
6212
7137
  init_tool_names();
6213
7138
  init_tool_resolvers();
7139
+ init_tool_types();
7140
+ init_validate_tool_input();
6214
7141
  manageRelationshipTool = defineAffinityTool({
6215
7142
  name: manageRelationshipToolName,
6216
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.",
@@ -6516,7 +7443,16 @@ function contactLocatorSchema8(description) {
6516
7443
  description
6517
7444
  };
6518
7445
  }
6519
- 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
+ );
6520
7456
  return withToolHandling(() => {
6521
7457
  const winner = resolveContactLocator(db, input.winner, "winner");
6522
7458
  if (!winner.ok) {
@@ -6546,6 +7482,8 @@ var init_merge_contacts_tool = __esm({
6546
7482
  init_tool_mutation();
6547
7483
  init_tool_names();
6548
7484
  init_tool_resolvers();
7485
+ init_tool_types();
7486
+ init_validate_tool_input();
6549
7487
  mergeContactsTool = defineAffinityTool({
6550
7488
  name: mergeContactsToolName,
6551
7489
  description: "Merge one loser contact into one winner contact using explicit contact locators.",
@@ -6805,7 +7743,16 @@ function prepareParticipants2(db, raw, injectOwner) {
6805
7743
  const normalized = raw.map(normalizeParticipant2);
6806
7744
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
6807
7745
  }
6808
- 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
+ );
6809
7756
  return withToolHandling(() => {
6810
7757
  const patched = {
6811
7758
  ...input.input,
@@ -6855,6 +7802,8 @@ var init_record_event_tool = __esm({
6855
7802
  init_tool_mutation();
6856
7803
  init_tool_names();
6857
7804
  init_tool_resolvers();
7805
+ init_tool_types();
7806
+ init_validate_tool_input();
6858
7807
  recordEventTool = defineAffinityTool({
6859
7808
  name: recordEventToolName,
6860
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.",
@@ -6915,7 +7864,7 @@ var init_record_event_tool = __esm({
6915
7864
  "observed"
6916
7865
  ])
6917
7866
  },
6918
- ["contactId", "role"]
7867
+ ["role"]
6919
7868
  ),
6920
7869
  "Participant list. Must be non-empty and include the owner contact."
6921
7870
  ),
@@ -6933,6 +7882,27 @@ var init_record_event_tool = __esm({
6933
7882
  }
6934
7883
  });
6935
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
+
6936
7906
  // src/contacts/list_contacts.ts
6937
7907
  function listContacts(db, filters, options) {
6938
7908
  assertDefaultOrdering("listContacts", options);
@@ -6956,6 +7926,9 @@ function listContacts(db, filters, options) {
6956
7926
  if (filters?.includeDormant === false || options?.includeDormant === false) {
6957
7927
  clauses.push("lifecycle_state != 'dormant'");
6958
7928
  }
7929
+ const attributeFilters = contactAttributeClauses(filters);
7930
+ clauses.push(...attributeFilters.clauses);
7931
+ params.push(...attributeFilters.params);
6959
7932
  const where = clauses.join(" AND ");
6960
7933
  const rows = db.prepare(
6961
7934
  `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
@@ -6984,6 +7957,7 @@ var init_list_contacts = __esm({
6984
7957
  "src/contacts/list_contacts.ts"() {
6985
7958
  init_assert_default_ordering();
6986
7959
  init_resolve_list_limit_offset();
7960
+ init_contact_attribute_clauses();
6987
7961
  init_mappers4();
6988
7962
  }
6989
7963
  });
@@ -7211,131 +8185,6 @@ var init_list_upcoming_dates = __esm({
7211
8185
  }
7212
8186
  });
7213
8187
 
7214
- // src/events/get_contact_journal.ts
7215
- function getContactJournal(db, contactId, options) {
7216
- assertDefaultOrdering("getContactJournal", options);
7217
- const { limit, offset } = resolveListLimitOffset(options);
7218
- const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
7219
- const params = [contactId];
7220
- if (options?.since !== void 0) {
7221
- clauses.push("e.occurred_at >= ?");
7222
- params.push(options.since);
7223
- }
7224
- if (options?.until !== void 0) {
7225
- clauses.push("e.occurred_at <= ?");
7226
- params.push(options.until);
7227
- }
7228
- if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
7229
- const ph = options.eventTypes.map(() => "?").join(", ");
7230
- clauses.push(`e.type IN (${ph})`);
7231
- params.push(...options.eventTypes);
7232
- }
7233
- const where = clauses.join(" AND ");
7234
- const rows = db.prepare(
7235
- `SELECT e.id FROM events e
7236
- INNER JOIN event_participants ep ON ep.event_id = e.id
7237
- WHERE ${where}
7238
- ORDER BY e.occurred_at DESC, e.id DESC
7239
- LIMIT ? OFFSET ?`
7240
- ).all(...params, limit, offset);
7241
- return batchLoadEventRecords(
7242
- db,
7243
- rows.map((r) => r.id)
7244
- );
7245
- }
7246
- var init_get_contact_journal = __esm({
7247
- "src/events/get_contact_journal.ts"() {
7248
- init_assert_default_ordering();
7249
- init_resolve_list_limit_offset();
7250
- init_loaders();
7251
- }
7252
- });
7253
-
7254
- // src/events/list_open_commitments.ts
7255
- function mapResolution(resolvedAt, resolution) {
7256
- if (resolvedAt === null) {
7257
- return "open";
7258
- }
7259
- if (resolution === "cancelled") {
7260
- return "cancelled";
7261
- }
7262
- return "resolved";
7263
- }
7264
- function listOpenCommitments(db, filters, options) {
7265
- assertDefaultOrdering("listOpenCommitments", options);
7266
- const { limit, offset } = resolveListLimitOffset(options);
7267
- const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
7268
- const params = [];
7269
- if (options?.since !== void 0) {
7270
- clauses.push("e.occurred_at >= ?");
7271
- params.push(options.since);
7272
- }
7273
- if (options?.until !== void 0) {
7274
- clauses.push("e.occurred_at <= ?");
7275
- params.push(options.until);
7276
- }
7277
- if (filters?.commitmentType !== void 0) {
7278
- if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
7279
- throw new AffinityValidationError("invalid commitmentType filter");
7280
- }
7281
- clauses.push("oc.commitment_type = ?");
7282
- params.push(filters.commitmentType);
7283
- }
7284
- if (filters?.contactId !== void 0) {
7285
- clauses.push(
7286
- "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
7287
- );
7288
- params.push(filters.contactId);
7289
- }
7290
- if (filters?.linkId !== void 0) {
7291
- clauses.push(
7292
- "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
7293
- );
7294
- params.push(filters.linkId);
7295
- }
7296
- if (filters?.horizonDays !== void 0) {
7297
- if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
7298
- throw new AffinityValidationError(
7299
- "horizonDays must be a non-negative integer"
7300
- );
7301
- }
7302
- const anchor = options?.since ?? Date.now();
7303
- clauses.push("oc.due_at IS NOT NULL");
7304
- clauses.push("oc.due_at <= ?");
7305
- params.push(anchor + filters.horizonDays * DAY_MS3);
7306
- }
7307
- const where = clauses.join(" AND ");
7308
- const rows = db.prepare(
7309
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
7310
- e.type, e.summary
7311
- FROM open_commitments oc
7312
- INNER JOIN events e ON e.id = oc.event_id
7313
- WHERE ${where}
7314
- ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
7315
- LIMIT ? OFFSET ?`
7316
- ).all(...params, limit, offset);
7317
- const eventIds = rows.map((r) => r.event_id);
7318
- const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
7319
- return rows.map((r) => ({
7320
- eventId: r.event_id,
7321
- type: r.type,
7322
- summary: r.summary,
7323
- participants: partsByEvent.get(r.event_id) ?? [],
7324
- dueAt: r.due_at,
7325
- resolutionState: mapResolution(r.resolved_at, r.resolution)
7326
- }));
7327
- }
7328
- var DAY_MS3;
7329
- var init_list_open_commitments = __esm({
7330
- "src/events/list_open_commitments.ts"() {
7331
- init_affinity_validation_error();
7332
- init_assert_default_ordering();
7333
- init_resolve_list_limit_offset();
7334
- init_loaders();
7335
- DAY_MS3 = 864e5;
7336
- }
7337
- });
7338
-
7339
8188
  // src/graph/get_affinity_chart.ts
7340
8189
  function getAffinityChart(db, options) {
7341
8190
  const contactClauses = [
@@ -7569,13 +8418,15 @@ function listRadar(db, filters, options) {
7569
8418
  if (ownerId === null) {
7570
8419
  return [];
7571
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})`;
7572
8423
  const { limit, offset } = resolveListLimitOffset(options);
7573
8424
  const clauses = [
7574
8425
  "l.removed_at IS NULL",
7575
8426
  "l.is_structural = 0",
7576
- "l.from_contact_id = ?"
8427
+ "(l.from_contact_id = ? OR l.to_contact_id = ?)"
7577
8428
  ];
7578
- const params = [ownerId];
8429
+ const params = [ownerId, ownerId];
7579
8430
  if (filters?.kind !== void 0) {
7580
8431
  clauses.push("l.kind = ?");
7581
8432
  params.push(filters.kind);
@@ -7597,6 +8448,7 @@ function listRadar(db, filters, options) {
7597
8448
  const rows = db.prepare(
7598
8449
  `SELECT
7599
8450
  l.id,
8451
+ ${overdue} AS overdue_commitment,
7600
8452
  l.from_contact_id,
7601
8453
  l.to_contact_id,
7602
8454
  l.trust,
@@ -7605,17 +8457,18 @@ function listRadar(db, filters, options) {
7605
8457
  lr.recency_score,
7606
8458
  lr.normalized_rank
7607
8459
  FROM links l
7608
- 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
7609
8461
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
7610
8462
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
7611
8463
  WHERE ${where}
7612
- ORDER BY COALESCE(lr.radar_score, 0) DESC
8464
+ ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
7613
8465
  LIMIT ? OFFSET ?`
7614
8466
  ).all(...params, limit, offset);
7615
8467
  return rows.map((row) => ({
7616
8468
  linkId: row.id,
7617
8469
  contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
7618
- 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,
7619
8472
  recencyScore: row.recency_score ?? 0,
7620
8473
  normalizedRank: row.normalized_rank ?? 0,
7621
8474
  trust: row.trust ?? 0,
@@ -7631,6 +8484,7 @@ var init_list_radar = __esm({
7631
8484
  init_queries();
7632
8485
  init_assert_default_ordering();
7633
8486
  init_resolve_list_limit_offset();
8487
+ init_resolve_now();
7634
8488
  }
7635
8489
  });
7636
8490
 
@@ -7684,6 +8538,60 @@ var init_list_dismissed_duplicates = __esm({
7684
8538
  }
7685
8539
  });
7686
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
+
7687
8595
  // src/tools/tool_summary.ts
7688
8596
  function summarizeCount(count, singular) {
7689
8597
  if (count === 0) {
@@ -7702,6 +8610,7 @@ var init_tool_summary = __esm({
7702
8610
  // src/tools/review_affinity_tool.ts
7703
8611
  function buildReadOptions(input) {
7704
8612
  return {
8613
+ ...input.now === void 0 ? {} : { now: input.now },
7705
8614
  ...input.limit === void 0 ? {} : { limit: input.limit },
7706
8615
  ...input.offset === void 0 ? {} : { offset: input.offset },
7707
8616
  ...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
@@ -7821,6 +8730,7 @@ function validateReviewInput(input) {
7821
8730
  }
7822
8731
  function duplicateCandidateItem(candidate) {
7823
8732
  return {
8733
+ ...candidate,
7824
8734
  kind: "contact",
7825
8735
  id: candidate.leftContactId,
7826
8736
  title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
@@ -7830,24 +8740,28 @@ function duplicateCandidateItem(candidate) {
7830
8740
  }
7831
8741
  function momentItem(moment) {
7832
8742
  return {
8743
+ ...moment,
8744
+ occurredAtIso: new Date(moment.occurredAt).toISOString(),
7833
8745
  kind: "event",
7834
8746
  id: moment.eventId,
7835
8747
  title: moment.summary,
7836
8748
  subtitle: moment.momentKind,
7837
- snippet: `link #${moment.linkId} at ${moment.occurredAt}`
8749
+ snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
7838
8750
  };
7839
8751
  }
7840
8752
  function commitmentItem(commitment) {
7841
8753
  return {
8754
+ ...commitment,
7842
8755
  kind: "event",
7843
8756
  id: commitment.eventId,
7844
8757
  title: commitment.summary,
7845
8758
  subtitle: commitment.type,
7846
- 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)}`
7847
8760
  };
7848
8761
  }
7849
8762
  function radarItem(radar) {
7850
8763
  return {
8764
+ ...radar,
7851
8765
  kind: "link",
7852
8766
  id: radar.linkId,
7853
8767
  title: `Link #${radar.linkId}`,
@@ -7857,29 +8771,34 @@ function radarItem(radar) {
7857
8771
  }
7858
8772
  function upcomingDateItem(date) {
7859
8773
  return {
8774
+ ...date,
8775
+ occursAt: date.occursOn,
8776
+ occursAtIso: new Date(date.occursOn).toISOString(),
7860
8777
  kind: date.targetRef.kind,
7861
8778
  id: date.targetRef.id,
7862
8779
  title: date.summary,
7863
8780
  subtitle: date.recurrenceKind,
7864
- snippet: `occurs ${date.occursOn}`
8781
+ snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
7865
8782
  };
7866
8783
  }
7867
8784
  function mergeHistoryItem(record) {
7868
8785
  return {
8786
+ ...record,
7869
8787
  kind: "contact",
7870
8788
  id: record.winnerContactId,
7871
8789
  title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
7872
- subtitle: `at ${record.mergedAt}`,
8790
+ subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
7873
8791
  snippet: record.reasonSummary ?? void 0
7874
8792
  };
7875
8793
  }
7876
8794
  function dismissedPairItem(record) {
7877
8795
  return {
8796
+ ...record,
7878
8797
  kind: "contact",
7879
8798
  id: record.leftContactId,
7880
8799
  title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
7881
8800
  subtitle: record.reason ?? "no reason given",
7882
- snippet: `dismissed at ${record.dismissedAt}`
8801
+ snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
7883
8802
  };
7884
8803
  }
7885
8804
  function compactFilters(input) {
@@ -7889,7 +8808,16 @@ function compactFilters(input) {
7889
8808
  )
7890
8809
  );
7891
8810
  }
7892
- 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
+ );
7893
8821
  return withToolHandling(() => {
7894
8822
  const invalid = validateReviewInput(input);
7895
8823
  if (invalid) {
@@ -7903,6 +8831,9 @@ function reviewAffinityToolHandler(db, input) {
7903
8831
  items = listContacts(
7904
8832
  db,
7905
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 },
7906
8837
  ...input.kind === void 0 ? {} : { kind: input.kind },
7907
8838
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
7908
8839
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -7933,7 +8864,7 @@ function reviewAffinityToolHandler(db, input) {
7933
8864
  "Provide `contact` when `view` is `events.contact_journal`."
7934
8865
  );
7935
8866
  }
7936
- const contact = resolveContactLocator(db, locator, "contact");
8867
+ const contact = resolveContactLocator(db, locator, "contact", true);
7937
8868
  if (!contact.ok) {
7938
8869
  return contact.result;
7939
8870
  }
@@ -7968,7 +8899,12 @@ function reviewAffinityToolHandler(db, input) {
7968
8899
  let contactId;
7969
8900
  let linkId;
7970
8901
  if (input.contact !== void 0) {
7971
- const contact = resolveContactLocator(db, input.contact, "contact");
8902
+ const contact = resolveContactLocator(
8903
+ db,
8904
+ input.contact,
8905
+ "contact",
8906
+ true
8907
+ );
7972
8908
  if (!contact.ok) {
7973
8909
  return contact.result;
7974
8910
  }
@@ -7997,7 +8933,12 @@ function reviewAffinityToolHandler(db, input) {
7997
8933
  let contactId;
7998
8934
  let linkId;
7999
8935
  if (input.contact !== void 0) {
8000
- const contact = resolveContactLocator(db, input.contact, "contact");
8936
+ const contact = resolveContactLocator(
8937
+ db,
8938
+ input.contact,
8939
+ "contact",
8940
+ true
8941
+ );
8001
8942
  if (!contact.ok) {
8002
8943
  return contact.result;
8003
8944
  }
@@ -8090,7 +9031,7 @@ function reviewAffinityToolHandler(db, input) {
8090
9031
  "Provide `contact` when `view` is `merges.history`."
8091
9032
  );
8092
9033
  }
8093
- const contact = resolveContactLocator(db, locator, "contact");
9034
+ const contact = resolveContactLocator(db, locator, "contact", true);
8094
9035
  if (!contact.ok) {
8095
9036
  return contact.result;
8096
9037
  }
@@ -8103,6 +9044,7 @@ function reviewAffinityToolHandler(db, input) {
8103
9044
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
8104
9045
  break;
8105
9046
  }
9047
+ items = items.map((item) => enrichReviewItem(db, item));
8106
9048
  return toolSuccess(
8107
9049
  summarizeCount(items.length, "item"),
8108
9050
  {
@@ -8141,12 +9083,15 @@ var init_review_affinity_tool = __esm({
8141
9083
  init_list_radar();
8142
9084
  init_get_merge_history();
8143
9085
  init_list_dismissed_duplicates();
9086
+ init_enrich_review_item();
8144
9087
  init_tool_metadata();
8145
9088
  init_tool_names();
8146
9089
  init_tool_ref();
8147
9090
  init_tool_resolvers();
8148
9091
  init_tool_summary();
8149
9092
  init_tool_types();
9093
+ init_tool_types();
9094
+ init_validate_tool_input();
8150
9095
  reviewAffinityTool = defineAffinityTool({
8151
9096
  name: reviewAffinityToolName,
8152
9097
  description: "Review one affinity surface such as contacts, journals, link queues, commitments, dates, graph state, or merge history.",
@@ -8158,12 +9103,16 @@ var init_review_affinity_tool = __esm({
8158
9103
  targetKinds: ["contact", "link", "event"],
8159
9104
  inputDescriptions: {
8160
9105
  view: "Which review surface to return.",
9106
+ now: "Optional current timestamp for overdue calculations.",
8161
9107
  contact: "Target contact locator for views that require one.",
8162
9108
  link: "Target link locator for views that require one.",
8163
9109
  contactIds: "Optional contact id subset for duplicate review or graph review.",
8164
9110
  kind: "Optional contact kind or link kind filter, depending on the view.",
8165
9111
  lifecycleState: "Optional contact lifecycle filter for the contact list view.",
8166
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.",
8167
9116
  includeOwner: "Whether owner contacts should be included in contact review.",
8168
9117
  includeDormant: "Whether dormant links or contacts should be included where supported.",
8169
9118
  includeArchived: "Whether archived links should be included where supported.",
@@ -8200,6 +9149,9 @@ var init_review_affinity_tool = __esm({
8200
9149
  "merges.history",
8201
9150
  "merges.dismissed"
8202
9151
  ]),
9152
+ now: integerSchema(
9153
+ "Optional current timestamp for overdue calculations."
9154
+ ),
8203
9155
  contact: contactLocatorSchema9("Optional target contact locator."),
8204
9156
  link: linkLocatorSchema5("Optional target link locator."),
8205
9157
  contactIds: arraySchema(
@@ -8222,6 +9174,17 @@ var init_review_affinity_tool = __esm({
8222
9174
  "broken",
8223
9175
  "archived"
8224
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
+ ),
8225
9188
  includeOwner: booleanSchema("Whether owner contacts are included."),
8226
9189
  includeDormant: booleanSchema("Whether dormant rows are included."),
8227
9190
  includeArchived: booleanSchema("Whether archived links are included."),
@@ -8268,99 +9231,110 @@ var init_review_affinity_tool = __esm({
8268
9231
  });
8269
9232
 
8270
9233
  // src/contacts/search_contacts.ts
8271
- function relevanceScore(name, queryLower, matchedName, matchedNormalizedIdentity) {
8272
- const nl = name.toLowerCase();
8273
- if (matchedNormalizedIdentity) return 1.1;
8274
- if (nl.startsWith(queryLower)) return 1;
8275
- if (matchedName) return 0.85;
8276
- return 0.7;
8277
- }
8278
9234
  function searchContacts(db, query, filters, options) {
8279
- const q = query.trim();
8280
- if (q.length === 0) {
8281
- return [];
8282
- }
9235
+ const q = foldSearchText(query.trim());
9236
+ if (!q) return [];
8283
9237
  assertDefaultOrdering("searchContacts", options);
8284
9238
  const { limit, offset } = resolveListLimitOffset(options);
8285
- const queryLower = q.toLowerCase();
8286
- const pattern = `%${q.replace(/%/g, "").replace(/_/g, "")}%`;
8287
- 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);
8288
9256
  const clauses = [
8289
- "c.deleted_at IS NULL",
8290
- "c.lifecycle_state != 'merged'"
9257
+ "deleted_at IS NULL",
9258
+ "lifecycle_state != 'merged'",
9259
+ ...attributeFilters.clauses
8291
9260
  ];
8292
- const params = [];
9261
+ const params = [...attributeFilters.params];
8293
9262
  if (filters?.kind !== void 0) {
8294
- clauses.push("c.kind = ?");
9263
+ clauses.push("kind = ?");
8295
9264
  params.push(filters.kind);
8296
9265
  }
8297
9266
  if (filters?.lifecycleState !== void 0) {
8298
- clauses.push("c.lifecycle_state = ?");
9267
+ clauses.push("lifecycle_state = ?");
8299
9268
  params.push(filters.lifecycleState);
8300
9269
  }
8301
- if (filters?.includeOwner === false) {
8302
- clauses.push("c.is_owner = 0");
8303
- }
8304
- if (filters?.includeDormant === false || options?.includeDormant === false) {
8305
- clauses.push("c.lifecycle_state != 'dormant'");
8306
- }
8307
- const where = clauses.join(" AND ");
8308
- const sql = `
8309
- SELECT c.id, c.name, c.kind, c.lifecycle_state, c.is_owner, c.merged_into_contact_id,
8310
- c.created_at, c.updated_at, c.deleted_at,
8311
- (
8312
- SELECT value
8313
- FROM identities primary_i
8314
- WHERE primary_i.contact_id = c.id AND primary_i.removed_at IS NULL
8315
- ORDER BY primary_i.id
8316
- LIMIT 1
8317
- ) AS primary_identity,
8318
- MAX(CASE WHEN i.normalized_key LIKE ? THEN 1 ELSE 0 END) AS matched_normalized_identity
8319
- FROM contacts c
8320
- LEFT JOIN identities i ON i.contact_id = c.id AND i.removed_at IS NULL
8321
- WHERE ${where}
8322
- AND (c.name LIKE ? OR i.value LIKE ? OR i.normalized_key LIKE ?)
8323
- GROUP BY c.id
8324
- `;
8325
- const rows = db.prepare(sql).all(
8326
- normalizedPattern,
8327
- ...params,
8328
- pattern,
8329
- pattern,
8330
- normalizedPattern
8331
- );
8332
- const scored = rows.map((r) => {
8333
- const item = mapContactRowToContactListItem(r);
8334
- const matchedName = r.name.toLowerCase().includes(queryLower);
8335
- const score = relevanceScore(
8336
- r.name,
8337
- queryLower,
8338
- matchedName,
8339
- Number(
8340
- r.matched_normalized_identity ?? 0
8341
- ) > 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:")
8342
9308
  );
8343
- return {
8344
- item: {
8345
- ...item,
8346
- primaryIdentity: r.primary_identity ?? null
8347
- },
8348
- score
8349
- };
8350
- });
8351
- scored.sort((a, b) => b.score - a.score);
8352
- 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);
8353
9316
  }
8354
9317
  var init_search_contacts = __esm({
8355
9318
  "src/contacts/search_contacts.ts"() {
8356
9319
  init_assert_default_ordering();
8357
9320
  init_resolve_list_limit_offset();
9321
+ init_contact_attribute_clauses();
9322
+ init_fold_search_text();
8358
9323
  init_mappers4();
8359
9324
  }
8360
9325
  });
8361
9326
 
8362
9327
  // src/tools/search_affinity_tool.ts
8363
- 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
+ );
8364
9338
  return withToolHandling(() => {
8365
9339
  const options = {
8366
9340
  ...input.limit === void 0 ? {} : { limit: input.limit },
@@ -8370,6 +9344,9 @@ function searchAffinityToolHandler(db, input) {
8370
9344
  db,
8371
9345
  input.query,
8372
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 },
8373
9350
  ...input.kind === void 0 ? {} : { kind: input.kind },
8374
9351
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
8375
9352
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -8401,9 +9378,11 @@ var init_search_affinity_tool = __esm({
8401
9378
  init_tool_resolvers();
8402
9379
  init_tool_summary();
8403
9380
  init_tool_types();
9381
+ init_tool_types();
9382
+ init_validate_tool_input();
8404
9383
  searchAffinityTool = defineAffinityTool({
8405
9384
  name: searchAffinityToolName,
8406
- 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.",
8407
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.",
8408
9387
  whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
8409
9388
  sideEffects: "none",
@@ -8414,6 +9393,9 @@ var init_search_affinity_tool = __esm({
8414
9393
  query: "Free-text or natural-key search text.",
8415
9394
  kind: "Optional contact kind filter.",
8416
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.",
8417
9399
  includeOwner: "Whether owner contacts are eligible for results.",
8418
9400
  includeDormant: "Whether dormant contacts should be eligible for results.",
8419
9401
  limit: "Optional result limit.",
@@ -8438,6 +9420,17 @@ var init_search_affinity_tool = __esm({
8438
9420
  "merged",
8439
9421
  "lost"
8440
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
+ ),
8441
9434
  includeOwner: booleanSchema("Whether owner contacts are included."),
8442
9435
  includeDormant: booleanSchema("Whether dormant contacts are included."),
8443
9436
  limit: integerSchema("Optional result limit."),
@@ -8484,11 +9477,112 @@ var init_tool_registry = __esm({
8484
9477
  }
8485
9478
  });
8486
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
+
8487
9577
  // src/surface/affinity_surface_tools.ts
8488
9578
  var affinitySurfaceTools;
8489
9579
  var init_affinity_surface_tools = __esm({
8490
9580
  "src/surface/affinity_surface_tools.ts"() {
9581
+ init_cli_args();
8491
9582
  init_tool_registry();
9583
+ init_tool_types();
9584
+ init_plain_dates();
9585
+ init_timestamp_schema();
8492
9586
  affinitySurfaceTools = affinityTools.map((tool) => ({
8493
9587
  name: tool.name,
8494
9588
  description: tool.description,
@@ -8496,8 +9590,24 @@ var init_affinity_surface_tools = __esm({
8496
9590
  ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
8497
9591
  readOnly: tool.readOnly,
8498
9592
  sideEffects: tool.sideEffects,
8499
- inputSchema: { ...tool.inputSchema, type: "object" },
8500
- 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
+ }
8501
9611
  }));
8502
9612
  }
8503
9613
  });
@@ -8820,6 +9930,12 @@ function initLinkEventEffectsTables(db) {
8820
9930
  )
8821
9931
  )
8822
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
+ }
8823
9939
  db.exec(
8824
9940
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
8825
9941
  );
@@ -9051,7 +10167,8 @@ var init_open_affinity_db = __esm({
9051
10167
  }
9052
10168
  });
9053
10169
  function resolveAffinityDbPath(options = {}) {
9054
- 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);
9055
10172
  }
9056
10173
  var init_resolve_affinity_db_path = __esm({
9057
10174
  "src/surface/resolve_affinity_db_path.ts"() {