@ghostpaw/affinity 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.cjs CHANGED
@@ -5,11 +5,16 @@ var node_path = require('node:path');
5
5
  var node_os = require('node:os');
6
6
 
7
7
  // src/version.ts
8
- var affinityVersion = "0.4.1";
8
+ var affinityVersion = "0.5.0";
9
9
 
10
10
  // src/surface/affinity_mcp_instructions.ts
11
11
  var affinityMcpInstructions = "Affinity is a local relationship model and social CRM. Search before creating contacts. Inspect before revising or merging. Record direct evidence honestly, keep identities and structural ties distinct from social links, and ask for clarification when a target is ambiguous. Review commitments and recurring dates regularly. Derived scores guide attention and do not prove relationship quality.";
12
12
 
13
+ // src/surfaces/cli_args.ts
14
+ var CliUsageError = class extends Error {
15
+ exitCode = 2;
16
+ };
17
+
13
18
  // src/lib/errors/affinity_error.ts
14
19
  var AffinityError = class extends Error {
15
20
  constructor(message, options) {
@@ -19,11 +24,60 @@ var AffinityError = class extends Error {
19
24
  }
20
25
  };
21
26
 
27
+ // src/lib/errors/affinity_validation_error.ts
28
+ var AffinityValidationError = class extends AffinityError {
29
+ code = "VALIDATION";
30
+ };
31
+
32
+ // src/lib/read/assert_default_ordering.ts
33
+ function assertDefaultOrdering(_readName, options) {
34
+ if (options?.sort !== void 0 || options?.descending !== void 0) {
35
+ throw new AffinityValidationError(
36
+ "This review does not support custom sort ordering"
37
+ );
38
+ }
39
+ }
40
+
41
+ // src/lib/types/read_list_options.ts
42
+ var AFFINITY_DEFAULT_LIST_LIMIT = 50;
43
+ var AFFINITY_MAX_LIST_LIMIT = 250;
44
+
45
+ // src/lib/types/resolve_affinity_list_limit.ts
46
+ function resolveAffinityListLimit(limit) {
47
+ if (limit === void 0) {
48
+ return AFFINITY_DEFAULT_LIST_LIMIT;
49
+ }
50
+ if (!Number.isInteger(limit) || limit < 1) {
51
+ throw new AffinityValidationError("limit must be a positive integer");
52
+ }
53
+ if (limit > AFFINITY_MAX_LIST_LIMIT) {
54
+ throw new AffinityValidationError(
55
+ `limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
56
+ );
57
+ }
58
+ return limit;
59
+ }
60
+
61
+ // src/lib/read/resolve_list_limit_offset.ts
62
+ function resolveListLimitOffset(options) {
63
+ const limit = resolveAffinityListLimit(options?.limit);
64
+ const offset = options?.offset ?? 0;
65
+ if (!Number.isInteger(offset) || offset < 0) {
66
+ throw new AffinityValidationError("offset must be a non-negative integer");
67
+ }
68
+ return { limit, offset };
69
+ }
70
+
22
71
  // src/lib/errors/affinity_invariant_error.ts
23
72
  var AffinityInvariantError = class extends AffinityError {
24
73
  code = "INVARIANT";
25
74
  };
26
75
 
76
+ // src/lib/errors/affinity_not_found_error.ts
77
+ var AffinityNotFoundError = class extends AffinityError {
78
+ code = "NOT_FOUND";
79
+ };
80
+
27
81
  // src/contacts/queries.ts
28
82
  function getContactRowById(db, id) {
29
83
  const row = db.prepare(
@@ -55,21 +109,11 @@ function requireOwnerContactId(db) {
55
109
  return ownerId;
56
110
  }
57
111
 
58
- // src/lib/errors/affinity_not_found_error.ts
59
- var AffinityNotFoundError = class extends AffinityError {
60
- code = "NOT_FOUND";
61
- };
62
-
63
112
  // src/lib/errors/affinity_state_error.ts
64
113
  var AffinityStateError = class extends AffinityError {
65
114
  code = "STATE";
66
115
  };
67
116
 
68
- // src/lib/errors/affinity_validation_error.ts
69
- var AffinityValidationError = class extends AffinityError {
70
- code = "VALIDATION";
71
- };
72
-
73
117
  // src/events/validators.ts
74
118
  var PARTICIPANT_ROLES = /* @__PURE__ */ new Set([
75
119
  "actor",
@@ -103,7 +147,10 @@ function assertParticipantContactsLive(db, contactIds) {
103
147
  throw new AffinityNotFoundError("contact not found");
104
148
  }
105
149
  if (row.lifecycle_state === "merged") {
106
- throw new AffinityStateError("merged contact is read-only");
150
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
151
+ throw new AffinityStateError(
152
+ `Contact #${id} was merged into #${row.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row.merged_into_contact_id}`
153
+ );
107
154
  }
108
155
  }
109
156
  }
@@ -275,6 +322,134 @@ function findDuplicateDateAnchor(db, params) {
275
322
  return null;
276
323
  }
277
324
 
325
+ // src/events/loaders.ts
326
+ function loadEventParticipantViews(db, eventId) {
327
+ const rows = db.prepare(
328
+ `SELECT contact_id, role, directionality
329
+ FROM event_participants
330
+ WHERE event_id = ?`
331
+ ).all(eventId);
332
+ return rows.map((row) => ({
333
+ contactId: row.contact_id,
334
+ role: row.role,
335
+ ...row.directionality === null ? {} : { directionality: row.directionality }
336
+ }));
337
+ }
338
+ function loadEventRecord(db, eventId) {
339
+ const row = getEventRowById(db, eventId);
340
+ if (!row) {
341
+ throw new AffinityInvariantError("event not found");
342
+ }
343
+ const participants = loadEventParticipantViews(db, eventId);
344
+ return mapEventRowToEventRecord(row, participants);
345
+ }
346
+ function batchLoadEventRecords(db, eventIds) {
347
+ if (eventIds.length === 0) {
348
+ return [];
349
+ }
350
+ const ph = eventIds.map(() => "?").join(", ");
351
+ const eventRows = db.prepare(
352
+ `SELECT id, type, occurred_at, summary, significance, moment_kind,
353
+ recurrence_kind, anchor_month, anchor_day,
354
+ anchor_contact_id, anchor_link_id,
355
+ created_at, updated_at, deleted_at
356
+ FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
357
+ ).all(...eventIds);
358
+ const partRows = db.prepare(
359
+ `SELECT event_id, contact_id, role, directionality
360
+ FROM event_participants WHERE event_id IN (${ph})`
361
+ ).all(...eventIds);
362
+ const partsByEvent = /* @__PURE__ */ new Map();
363
+ for (const p of partRows) {
364
+ let arr = partsByEvent.get(p.event_id);
365
+ if (!arr) {
366
+ arr = [];
367
+ partsByEvent.set(p.event_id, arr);
368
+ }
369
+ arr.push({
370
+ contactId: p.contact_id,
371
+ role: p.role,
372
+ ...p.directionality === null ? {} : { directionality: p.directionality }
373
+ });
374
+ }
375
+ const rowById = new Map(eventRows.map((r) => [r.id, r]));
376
+ const out = [];
377
+ for (const id of eventIds) {
378
+ const row = rowById.get(id);
379
+ if (row) {
380
+ out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
381
+ }
382
+ }
383
+ return out;
384
+ }
385
+ function batchLoadEventParticipantViews(db, eventIds) {
386
+ const result = /* @__PURE__ */ new Map();
387
+ if (eventIds.length === 0) {
388
+ return result;
389
+ }
390
+ const ph = eventIds.map(() => "?").join(", ");
391
+ const rows = db.prepare(
392
+ `SELECT event_id, contact_id, role, directionality
393
+ FROM event_participants WHERE event_id IN (${ph})`
394
+ ).all(...eventIds);
395
+ for (const row of rows) {
396
+ let arr = result.get(row.event_id);
397
+ if (!arr) {
398
+ arr = [];
399
+ result.set(row.event_id, arr);
400
+ }
401
+ arr.push({
402
+ contactId: row.contact_id,
403
+ role: row.role,
404
+ ...row.directionality === null ? {} : { directionality: row.directionality }
405
+ });
406
+ }
407
+ return result;
408
+ }
409
+ function requireDateAnchorEvent(db, eventId) {
410
+ const row = getEventRowById(db, eventId);
411
+ if (!row) {
412
+ throw new AffinityNotFoundError("event not found");
413
+ }
414
+ if (row.type !== "date_anchor") {
415
+ throw new AffinityInvariantError("not a date anchor");
416
+ }
417
+ return row;
418
+ }
419
+
420
+ // src/events/get_contact_journal.ts
421
+ function getContactJournal(db, contactId, options) {
422
+ assertDefaultOrdering("getContactJournal", options);
423
+ const { limit, offset } = resolveListLimitOffset(options);
424
+ const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
425
+ const params = [contactId];
426
+ if (options?.since !== void 0) {
427
+ clauses.push("e.occurred_at >= ?");
428
+ params.push(options.since);
429
+ }
430
+ if (options?.until !== void 0) {
431
+ clauses.push("e.occurred_at <= ?");
432
+ params.push(options.until);
433
+ }
434
+ if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
435
+ const ph = options.eventTypes.map(() => "?").join(", ");
436
+ clauses.push(`e.type IN (${ph})`);
437
+ params.push(...options.eventTypes);
438
+ }
439
+ const where = clauses.join(" AND ");
440
+ const rows = db.prepare(
441
+ `SELECT e.id FROM events e
442
+ INNER JOIN event_participants ep ON ep.event_id = e.id
443
+ WHERE ${where}
444
+ ORDER BY e.occurred_at DESC, e.id DESC
445
+ LIMIT ? OFFSET ?`
446
+ ).all(...params, limit, offset);
447
+ return batchLoadEventRecords(
448
+ db,
449
+ rows.map((r) => r.id)
450
+ );
451
+ }
452
+
278
453
  // src/resolve_now.ts
279
454
  function resolveNow(now) {
280
455
  if (now !== void 0) {
@@ -288,6 +463,85 @@ function resolveNow(now) {
288
463
  return Date.now();
289
464
  }
290
465
 
466
+ // src/events/list_open_commitments.ts
467
+ var DAY_MS = 864e5;
468
+ function mapResolution(resolvedAt, resolution) {
469
+ if (resolvedAt === null) {
470
+ return "open";
471
+ }
472
+ if (resolution === "cancelled") {
473
+ return "cancelled";
474
+ }
475
+ return "resolved";
476
+ }
477
+ function listOpenCommitments(db, filters, options) {
478
+ assertDefaultOrdering("listOpenCommitments", options);
479
+ const now = resolveNow(options?.now);
480
+ const { limit, offset } = resolveListLimitOffset(options);
481
+ const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
482
+ const params = [];
483
+ if (options?.since !== void 0) {
484
+ clauses.push("e.occurred_at >= ?");
485
+ params.push(options.since);
486
+ }
487
+ if (options?.until !== void 0) {
488
+ clauses.push("e.occurred_at <= ?");
489
+ params.push(options.until);
490
+ }
491
+ if (filters?.commitmentType !== void 0) {
492
+ if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
493
+ throw new AffinityValidationError("invalid commitmentType filter");
494
+ }
495
+ clauses.push("oc.commitment_type = ?");
496
+ params.push(filters.commitmentType);
497
+ }
498
+ if (filters?.contactId !== void 0) {
499
+ clauses.push(
500
+ "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
501
+ );
502
+ params.push(filters.contactId);
503
+ }
504
+ if (filters?.linkId !== void 0) {
505
+ clauses.push(
506
+ "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
507
+ );
508
+ params.push(filters.linkId);
509
+ }
510
+ if (filters?.horizonDays !== void 0) {
511
+ if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
512
+ throw new AffinityValidationError(
513
+ "horizonDays must be a non-negative integer"
514
+ );
515
+ }
516
+ const anchor = options?.since ?? now;
517
+ clauses.push("oc.due_at IS NOT NULL");
518
+ clauses.push("oc.due_at <= ?");
519
+ params.push(anchor + filters.horizonDays * DAY_MS);
520
+ }
521
+ const where = clauses.join(" AND ");
522
+ const rows = db.prepare(
523
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
524
+ e.type, e.summary
525
+ FROM open_commitments oc
526
+ INNER JOIN events e ON e.id = oc.event_id
527
+ WHERE ${where}
528
+ ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
529
+ LIMIT ? OFFSET ?`
530
+ ).all(...params, limit, offset);
531
+ const eventIds = rows.map((r) => r.event_id);
532
+ const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
533
+ return rows.map((r) => ({
534
+ eventId: r.event_id,
535
+ type: r.type,
536
+ summary: r.summary,
537
+ participants: partsByEvent.get(r.event_id) ?? [],
538
+ dueAt: r.due_at,
539
+ dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
540
+ overdue: r.due_at !== null && r.due_at < now,
541
+ resolutionState: mapResolution(r.resolved_at, r.resolution)
542
+ }));
543
+ }
544
+
291
545
  // src/with_transaction.ts
292
546
  var transactionDepth = /* @__PURE__ */ new WeakMap();
293
547
  function withTransaction(db, fn) {
@@ -559,13 +813,20 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
559
813
  const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
560
814
  const activeDates = loadActiveDatesForContact(db, contactId);
561
815
  const contactRollup = loadContactRollup(db, contactId);
816
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
562
817
  return {
818
+ ...winner ? { mergedInto: { id: winner.id, name: winner.name } } : {},
563
819
  contact: mapContactRowToContactCore(row),
564
820
  identities,
565
821
  attributes,
566
822
  topLinks,
567
823
  activeDates,
568
- warnings: contactRollup?.warnings ?? [],
824
+ warnings: [
825
+ ...contactRollup?.warnings ?? [],
826
+ ...winner ? [
827
+ `Contact #${row.id} was merged into #${winner.id} ${winner.name}; use contactId ${winner.id}`
828
+ ] : []
829
+ ],
569
830
  rollups: contactRollup?.rollup ?? null
570
831
  };
571
832
  }
@@ -573,7 +834,27 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
573
834
  // src/contacts/get_contact_profile.ts
574
835
  function getContactProfile(db, contactId, options) {
575
836
  const t = options?.topLinksLimit ?? 8;
576
- return loadContactProfileRecord(db, contactId, t);
837
+ const profile = loadContactProfileRecord(db, contactId, t);
838
+ if (!profile) return null;
839
+ const ties = db.prepare(`SELECT l.id, l.kind, l.role, c.id AS contactId, c.name
840
+ 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
841
+ WHERE l.is_structural = 1 AND l.removed_at IS NULL AND (l.from_contact_id = ? OR l.to_contact_id = ?)
842
+ AND c.deleted_at IS NULL ORDER BY l.id`).all(contactId, contactId, contactId);
843
+ return {
844
+ ...profile,
845
+ recentEvents: getContactJournal(db, contactId, {
846
+ limit: options?.recentEventsLimit ?? 5
847
+ }).map((event) => ({
848
+ ...event,
849
+ occurredAtIso: new Date(event.occurredAt).toISOString()
850
+ })),
851
+ openCommitments: listOpenCommitments(
852
+ db,
853
+ { contactId },
854
+ options?.now === void 0 ? void 0 : { now: options.now }
855
+ ),
856
+ ties
857
+ };
577
858
  }
578
859
 
579
860
  // src/contacts/get_owner_profile.ts
@@ -586,45 +867,6 @@ function getOwnerProfile(db, options) {
586
867
  return loadContactProfileRecord(db, ownerId, t);
587
868
  }
588
869
 
589
- // src/lib/read/assert_default_ordering.ts
590
- function assertDefaultOrdering(readName, options) {
591
- if (options?.sort !== void 0 || options?.descending !== void 0) {
592
- throw new AffinityValidationError(
593
- `${readName} does not support custom sort ordering`
594
- );
595
- }
596
- }
597
-
598
- // src/lib/types/read_list_options.ts
599
- var AFFINITY_DEFAULT_LIST_LIMIT = 50;
600
- var AFFINITY_MAX_LIST_LIMIT = 250;
601
-
602
- // src/lib/types/resolve_affinity_list_limit.ts
603
- function resolveAffinityListLimit(limit) {
604
- if (limit === void 0) {
605
- return AFFINITY_DEFAULT_LIST_LIMIT;
606
- }
607
- if (!Number.isInteger(limit) || limit < 1) {
608
- throw new AffinityValidationError("limit must be a positive integer");
609
- }
610
- if (limit > AFFINITY_MAX_LIST_LIMIT) {
611
- throw new AffinityValidationError(
612
- `limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
613
- );
614
- }
615
- return limit;
616
- }
617
-
618
- // src/lib/read/resolve_list_limit_offset.ts
619
- function resolveListLimitOffset(options) {
620
- const limit = resolveAffinityListLimit(options?.limit);
621
- const offset = options?.offset ?? 0;
622
- if (!Number.isInteger(offset) || offset < 0) {
623
- throw new AffinityValidationError("offset must be a non-negative integer");
624
- }
625
- return { limit, offset };
626
- }
627
-
628
870
  // src/events/list_moments.ts
629
871
  function listMoments(db, filters, options) {
630
872
  assertDefaultOrdering("listMoments", options);
@@ -680,101 +922,6 @@ function listMoments(db, filters, options) {
680
922
  }));
681
923
  }
682
924
 
683
- // src/events/loaders.ts
684
- function loadEventParticipantViews(db, eventId) {
685
- const rows = db.prepare(
686
- `SELECT contact_id, role, directionality
687
- FROM event_participants
688
- WHERE event_id = ?`
689
- ).all(eventId);
690
- return rows.map((row) => ({
691
- contactId: row.contact_id,
692
- role: row.role,
693
- ...row.directionality === null ? {} : { directionality: row.directionality }
694
- }));
695
- }
696
- function loadEventRecord(db, eventId) {
697
- const row = getEventRowById(db, eventId);
698
- if (!row) {
699
- throw new AffinityInvariantError("event not found");
700
- }
701
- const participants = loadEventParticipantViews(db, eventId);
702
- return mapEventRowToEventRecord(row, participants);
703
- }
704
- function batchLoadEventRecords(db, eventIds) {
705
- if (eventIds.length === 0) {
706
- return [];
707
- }
708
- const ph = eventIds.map(() => "?").join(", ");
709
- const eventRows = db.prepare(
710
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
711
- recurrence_kind, anchor_month, anchor_day,
712
- anchor_contact_id, anchor_link_id,
713
- created_at, updated_at, deleted_at
714
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
715
- ).all(...eventIds);
716
- const partRows = db.prepare(
717
- `SELECT event_id, contact_id, role, directionality
718
- FROM event_participants WHERE event_id IN (${ph})`
719
- ).all(...eventIds);
720
- const partsByEvent = /* @__PURE__ */ new Map();
721
- for (const p of partRows) {
722
- let arr = partsByEvent.get(p.event_id);
723
- if (!arr) {
724
- arr = [];
725
- partsByEvent.set(p.event_id, arr);
726
- }
727
- arr.push({
728
- contactId: p.contact_id,
729
- role: p.role,
730
- ...p.directionality === null ? {} : { directionality: p.directionality }
731
- });
732
- }
733
- const rowById = new Map(eventRows.map((r) => [r.id, r]));
734
- const out = [];
735
- for (const id of eventIds) {
736
- const row = rowById.get(id);
737
- if (row) {
738
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
739
- }
740
- }
741
- return out;
742
- }
743
- function batchLoadEventParticipantViews(db, eventIds) {
744
- const result = /* @__PURE__ */ new Map();
745
- if (eventIds.length === 0) {
746
- return result;
747
- }
748
- const ph = eventIds.map(() => "?").join(", ");
749
- const rows = db.prepare(
750
- `SELECT event_id, contact_id, role, directionality
751
- FROM event_participants WHERE event_id IN (${ph})`
752
- ).all(...eventIds);
753
- for (const row of rows) {
754
- let arr = result.get(row.event_id);
755
- if (!arr) {
756
- arr = [];
757
- result.set(row.event_id, arr);
758
- }
759
- arr.push({
760
- contactId: row.contact_id,
761
- role: row.role,
762
- ...row.directionality === null ? {} : { directionality: row.directionality }
763
- });
764
- }
765
- return result;
766
- }
767
- function requireDateAnchorEvent(db, eventId) {
768
- const row = getEventRowById(db, eventId);
769
- if (!row) {
770
- throw new AffinityNotFoundError("event not found");
771
- }
772
- if (row.type !== "date_anchor") {
773
- throw new AffinityInvariantError("not a date anchor");
774
- }
775
- return row;
776
- }
777
-
778
925
  // src/links/queries.ts
779
926
  function getLinkRowById(db, id) {
780
927
  const row = db.prepare(
@@ -972,7 +1119,7 @@ function nextCadenceDays(params) {
972
1119
  }
973
1120
 
974
1121
  // src/links/rollups.ts
975
- var DAY_MS = 864e5;
1122
+ var DAY_MS2 = 864e5;
976
1123
  function parseJsonObject2(raw) {
977
1124
  try {
978
1125
  const parsed = JSON.parse(raw);
@@ -1030,7 +1177,7 @@ function recencyScore(occurredAt, cadenceDays, now) {
1030
1177
  return 0;
1031
1178
  }
1032
1179
  const cadence = Math.max(cadenceDays ?? 0, 1);
1033
- const daysSince = Math.max(0, (now - occurredAt) / DAY_MS);
1180
+ const daysSince = Math.max(0, (now - occurredAt) / DAY_MS2);
1034
1181
  return Math.max(0, 1 - daysSince / cadence);
1035
1182
  }
1036
1183
  function refreshLinkRollup(db, linkId, now) {
@@ -1041,15 +1188,15 @@ function refreshLinkRollup(db, linkId, now) {
1041
1188
  const latest = db.prepare(
1042
1189
  `SELECT event_id, occurred_at
1043
1190
  FROM link_event_effects
1044
- WHERE link_id = ?
1191
+ WHERE link_id = ? AND counts_for_recency = 1
1045
1192
  ORDER BY occurred_at DESC, event_id DESC
1046
1193
  LIMIT 1`
1047
1194
  ).get(linkId);
1048
- const trailingWindowStart = now - 180 * DAY_MS;
1195
+ const trailingWindowStart = now - 180 * DAY_MS2;
1049
1196
  const counts = db.prepare(
1050
1197
  `SELECT
1051
1198
  COUNT(*) AS total_meaningful_events,
1052
- SUM(CASE WHEN affinity_delta > 0 OR trust_delta > 0 THEN 1 ELSE 0 END) AS positive_meaningful_events
1199
+ SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
1053
1200
  FROM link_event_effects
1054
1201
  WHERE link_id = ? AND occurred_at >= ?`
1055
1202
  ).get(linkId, trailingWindowStart);
@@ -1057,7 +1204,7 @@ function refreshLinkRollup(db, linkId, now) {
1057
1204
  `SELECT ep.directionality
1058
1205
  FROM link_event_effects le
1059
1206
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
1060
- WHERE le.link_id = ? AND ep.contact_id = ?
1207
+ WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
1061
1208
  AND ep.directionality IS NOT NULL
1062
1209
  AND le.occurred_at >= ?`
1063
1210
  ).all(linkId, link.from_contact_id, trailingWindowStart);
@@ -1083,7 +1230,7 @@ function refreshLinkRollup(db, linkId, now) {
1083
1230
  link.cadence_days,
1084
1231
  now
1085
1232
  );
1086
- const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS);
1233
+ const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS2);
1087
1234
  const driftPriorityValue = driftPriority({
1088
1235
  daysSinceLastMeaningfulEvent,
1089
1236
  cadenceDays: link.cadence_days ?? 1,
@@ -1210,8 +1357,55 @@ function getLinkDetail(db, linkId, options) {
1210
1357
  };
1211
1358
  }
1212
1359
 
1360
+ // src/tools/parse_time_input.ts
1361
+ var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
1362
+ function parseTimeInput(value, field = "time", now = Date.now()) {
1363
+ const fail = () => {
1364
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1365
+ };
1366
+ if (typeof value === "number")
1367
+ return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
1368
+ if (typeof value !== "string") return fail();
1369
+ const text = value.trim();
1370
+ const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
1371
+ let day;
1372
+ if (relative) {
1373
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1374
+ day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
1375
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
1376
+ day = Date.parse(`${text}T00:00:00.000Z`);
1377
+ if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
1378
+ return fail();
1379
+ } else {
1380
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1381
+ text
1382
+ ))
1383
+ return fail();
1384
+ const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
1385
+ if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
1386
+ return fail();
1387
+ const parsed = Date.parse(text);
1388
+ return Number.isFinite(parsed) ? parsed : fail();
1389
+ }
1390
+ const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
1391
+ return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
1392
+ }
1393
+
1213
1394
  // src/tools/tool_metadata.ts
1214
1395
  function defineAffinityTool(definition) {
1396
+ const visit = (schema) => {
1397
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
1398
+ if ((key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.type === "integer") {
1399
+ child.type = void 0;
1400
+ child.oneOf = [{ type: "integer" }, { type: "string" }];
1401
+ child.description = `${child.description ?? "Time."} Accepts ${timeInputForms}. UTC midnight for dates; dueAt uses UTC end of day.`;
1402
+ }
1403
+ visit(child);
1404
+ }
1405
+ if (schema.items) visit(schema.items);
1406
+ for (const child of schema.oneOf ?? schema.anyOf ?? []) visit(child);
1407
+ };
1408
+ visit(definition.inputSchema);
1215
1409
  return definition;
1216
1410
  }
1217
1411
  function stringSchema(description, options = {}) {
@@ -1258,6 +1452,12 @@ function objectSchema(properties, required, description) {
1258
1452
  function nullableStringSchema(description) {
1259
1453
  return { anyOf: [{ type: "string" }, { type: "null" }], description };
1260
1454
  }
1455
+ function oneOfSchema(schemas, description) {
1456
+ const schema = {
1457
+ oneOf: schemas
1458
+ };
1459
+ return schema;
1460
+ }
1261
1461
 
1262
1462
  // src/tools/tool_names.ts
1263
1463
  var searchAffinityToolName = "search_affinity";
@@ -1291,6 +1491,7 @@ function toContactEntityRef(value) {
1291
1491
  function toContactListToolItem(value) {
1292
1492
  return {
1293
1493
  ...toContactEntityRef(value),
1494
+ ..."matchedOn" in value ? { matchedOn: value.matchedOn } : {},
1294
1495
  subtitle: "kind" in value ? value.kind : void 0
1295
1496
  };
1296
1497
  }
@@ -1322,7 +1523,9 @@ function toEventEntityRef(value) {
1322
1523
  }
1323
1524
  function toEventListToolItem(value) {
1324
1525
  return {
1526
+ ...value,
1325
1527
  ...toEventEntityRef(value),
1528
+ occurredAtIso: new Date(value.occurredAt).toISOString(),
1326
1529
  snippet: `${value.participants.length} participant(s)`
1327
1530
  };
1328
1531
  }
@@ -1370,7 +1573,7 @@ function withOptionalFields(result, options) {
1370
1573
  if (options.warnings && options.warnings.length > 0) {
1371
1574
  result.warnings = options.warnings;
1372
1575
  }
1373
- return result;
1576
+ return roundToolValues(result);
1374
1577
  }
1375
1578
  function toolWarning(code, message) {
1376
1579
  return { code, message };
@@ -1434,9 +1637,31 @@ function toolFailure(kind, code, summary, message, options = {}) {
1434
1637
  options
1435
1638
  );
1436
1639
  }
1640
+ function roundToolValues(value) {
1641
+ if (typeof value === "number")
1642
+ return Number.isInteger(value) ? value : Math.round(value * 1e3) / 1e3;
1643
+ if (Array.isArray(value)) return value.map(roundToolValues);
1644
+ if (value !== null && typeof value === "object")
1645
+ return Object.fromEntries(
1646
+ Object.entries(value).map(([key, child]) => [
1647
+ key,
1648
+ roundToolValues(child)
1649
+ ])
1650
+ );
1651
+ return value;
1652
+ }
1437
1653
 
1438
1654
  // src/tools/tool_resolvers.ts
1439
- function resolveContactLocator(db, locator, field = "contact") {
1655
+ function resolveContactLocator(db, locator, field = "contact", allowMerged = false) {
1656
+ if (!locator || typeof locator !== "object")
1657
+ return {
1658
+ ok: false,
1659
+ result: toolNeedsClarification(
1660
+ "missing_required_choice",
1661
+ `Provide ${field} locator.`,
1662
+ [field]
1663
+ )
1664
+ };
1440
1665
  if ("contactId" in locator) {
1441
1666
  const row2 = getContactRowById(db, locator.contactId);
1442
1667
  if (!row2) {
@@ -1450,8 +1675,25 @@ function resolveContactLocator(db, locator, field = "contact") {
1450
1675
  )
1451
1676
  };
1452
1677
  }
1678
+ if (!allowMerged && row2.merged_into_contact_id !== null) {
1679
+ const winner = getContactRowById(db, row2.merged_into_contact_id);
1680
+ const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
1681
+ return {
1682
+ ok: false,
1683
+ result: toolFailure("domain", "invalid_state", message, message)
1684
+ };
1685
+ }
1453
1686
  return { ok: true, value: row2 };
1454
1687
  }
1688
+ if (!("identity" in locator) || !locator.identity)
1689
+ return {
1690
+ ok: false,
1691
+ result: toolNeedsClarification(
1692
+ "missing_required_choice",
1693
+ `Provide ${field}.contactId or identity.`,
1694
+ [field]
1695
+ )
1696
+ };
1455
1697
  const normalizedKey = normalizeIdentityKey(
1456
1698
  locator.identity.type,
1457
1699
  locator.identity.value
@@ -1509,6 +1751,15 @@ function resolveContactLocator(db, locator, field = "contact") {
1509
1751
  return { ok: true, value: matchedContact };
1510
1752
  }
1511
1753
  function resolveLinkLocator(db, locator, field = "link") {
1754
+ if (!locator || typeof locator !== "object")
1755
+ return {
1756
+ ok: false,
1757
+ result: toolNeedsClarification(
1758
+ "missing_required_choice",
1759
+ `Provide ${field} locator.`,
1760
+ [field]
1761
+ )
1762
+ };
1512
1763
  if ("linkId" in locator) {
1513
1764
  const row = getLinkRowById(db, locator.linkId);
1514
1765
  if (!row) {
@@ -1524,6 +1775,15 @@ function resolveLinkLocator(db, locator, field = "link") {
1524
1775
  }
1525
1776
  return { ok: true, value: row };
1526
1777
  }
1778
+ if (!("endpoints" in locator) || !locator.endpoints)
1779
+ return {
1780
+ ok: false,
1781
+ result: toolNeedsClarification(
1782
+ "missing_required_choice",
1783
+ `Provide ${field}.linkId or endpoints.`,
1784
+ [field]
1785
+ )
1786
+ };
1527
1787
  const { endpoints } = locator;
1528
1788
  const clauses = [
1529
1789
  "removed_at IS NULL",
@@ -1642,6 +1902,92 @@ function affinityErrorCodeToToolCode(error) {
1642
1902
  }
1643
1903
  }
1644
1904
 
1905
+ // src/tools/validate_tool_input.ts
1906
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
1907
+ if (schema.anyOf || schema.oneOf) {
1908
+ const choices = schema.anyOf ?? schema.oneOf ?? [];
1909
+ return choices.some(
1910
+ (choice) => validateToolInput(choice, value, path, injectedNow) === null
1911
+ ) ? null : `${path}: invalid value`;
1912
+ }
1913
+ if (schema.type === "object") {
1914
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1915
+ return `${path}: missing input or expected object`;
1916
+ const object = value;
1917
+ if (path.endsWith(".patch") && schema.additionalProperties === false) {
1918
+ for (const key of Object.keys(object)) {
1919
+ if (!Object.hasOwn(schema.properties ?? {}, key))
1920
+ 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(", ")}`;
1921
+ }
1922
+ }
1923
+ if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
1924
+ return `${path}.patch: missing required field`;
1925
+ if (object.patch !== void 0 && !schema.properties?.patch)
1926
+ return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
1927
+ const referenceNow = object.now === void 0 ? injectedNow : (() => {
1928
+ try {
1929
+ return parseTimeInput(
1930
+ object.now,
1931
+ `${path}.now`,
1932
+ injectedNow
1933
+ );
1934
+ } catch {
1935
+ return Date.now();
1936
+ }
1937
+ })();
1938
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
1939
+ if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
1940
+ try {
1941
+ object[key] = parseTimeInput(
1942
+ object[key],
1943
+ `${path}.${key}`,
1944
+ referenceNow
1945
+ );
1946
+ } catch (error) {
1947
+ return error.message;
1948
+ }
1949
+ }
1950
+ }
1951
+ for (const key of schema.required ?? []) {
1952
+ if (object[key] === void 0)
1953
+ return `${path}.${key}: missing required field`;
1954
+ }
1955
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
1956
+ if (object[key] === void 0) continue;
1957
+ const error = validateToolInput(
1958
+ child,
1959
+ object[key],
1960
+ `${path}.${key}`,
1961
+ referenceNow
1962
+ );
1963
+ if (error) return error;
1964
+ }
1965
+ return null;
1966
+ }
1967
+ if (schema.type === "array") {
1968
+ if (!Array.isArray(value)) return `${path}: expected array`;
1969
+ for (const [index, item] of value.entries()) {
1970
+ const error = validateToolInput(
1971
+ schema.items ?? {},
1972
+ item,
1973
+ `${path}[${index}]`,
1974
+ injectedNow
1975
+ );
1976
+ if (error) return error;
1977
+ }
1978
+ return null;
1979
+ }
1980
+ if (schema.enum && !schema.enum.includes(value))
1981
+ return `${path}: expected ${schema.enum.join(", ")}`;
1982
+ if (schema.type === "null")
1983
+ return value === null ? null : `${path}: expected null`;
1984
+ if (schema.type === "integer")
1985
+ return Number.isInteger(value) ? null : `${path}: expected integer`;
1986
+ 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";
1987
+ if (!valid) return `${path}: expected ${schema.type}`;
1988
+ return null;
1989
+ }
1990
+
1645
1991
  // src/tools/inspect_affinity_item_tool.ts
1646
1992
  function contactLocatorSchema(description) {
1647
1993
  return {
@@ -1677,7 +2023,19 @@ function linkLocatorSchema(description) {
1677
2023
  description
1678
2024
  };
1679
2025
  }
1680
- function inspectAffinityItemToolHandler(db, input) {
2026
+ function inspectAffinityItemToolHandler(db, rawInput) {
2027
+ const input = structuredClone(rawInput);
2028
+ const inputError = validateToolInput(
2029
+ inspectAffinityItemTool.inputSchema,
2030
+ input
2031
+ );
2032
+ if (inputError)
2033
+ return toolFailure(
2034
+ "protocol",
2035
+ "invalid_input",
2036
+ "Invalid input.",
2037
+ inputError
2038
+ );
1681
2039
  return withToolHandling(() => {
1682
2040
  if (input.kind === "owner_profile") {
1683
2041
  const detail2 = getOwnerProfile(
@@ -1695,19 +2053,21 @@ function inspectAffinityItemToolHandler(db, input) {
1695
2053
  return toolSuccess(
1696
2054
  `Loaded owner profile for ${detail2.contact.name}.`,
1697
2055
  { kind: input.kind, detail: detail2 },
1698
- { entities: [toContactEntityRef(detail2.contact)] }
2056
+ {
2057
+ entities: [toContactEntityRef(detail2.contact)],
2058
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
2059
+ }
1699
2060
  );
1700
2061
  }
1701
2062
  if (input.kind === "contact_profile") {
1702
- const contact = resolveContactLocator(db, input.contact, "contact");
2063
+ const contact = resolveContactLocator(db, input.contact, "contact", true);
1703
2064
  if (!contact.ok) {
1704
2065
  return contact.result;
1705
2066
  }
1706
- const detail2 = getContactProfile(
1707
- db,
1708
- contact.value.id,
1709
- input.topLinksLimit === void 0 ? void 0 : { topLinksLimit: input.topLinksLimit }
1710
- );
2067
+ const detail2 = getContactProfile(db, contact.value.id, {
2068
+ ...input.topLinksLimit === void 0 ? {} : { topLinksLimit: input.topLinksLimit },
2069
+ ...input.recentEventsLimit === void 0 ? {} : { recentEventsLimit: input.recentEventsLimit }
2070
+ });
1711
2071
  if (!detail2) {
1712
2072
  return toolFailure(
1713
2073
  "domain",
@@ -1719,7 +2079,10 @@ function inspectAffinityItemToolHandler(db, input) {
1719
2079
  return toolSuccess(
1720
2080
  `Loaded contact profile for ${detail2.contact.name}.`,
1721
2081
  { kind: input.kind, detail: detail2 },
1722
- { entities: [toContactEntityRef(detail2.contact)] }
2082
+ {
2083
+ entities: [toContactEntityRef(detail2.contact)],
2084
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
2085
+ }
1723
2086
  );
1724
2087
  }
1725
2088
  const link = resolveLinkLocator(db, input.link, "link");
@@ -2161,9 +2524,17 @@ function unsetAttribute(db, target, name, removedAt) {
2161
2524
  }
2162
2525
 
2163
2526
  // src/tools/tool_mutation.ts
2164
- function mutationToolResult(action, receipt, entitySummary) {
2527
+ function mutationToolResult(action, receipt, entitySummary, fields) {
2165
2528
  const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
2166
2529
  const data = {
2530
+ changes: ["created", "updated", "archived", "removed"].flatMap(
2531
+ (verb) => receipt[verb].map((ref) => ({
2532
+ entity: ref.kind,
2533
+ id: ref.id,
2534
+ action: verb,
2535
+ ...verb === "updated" && fields ? { fields } : {}
2536
+ }))
2537
+ ),
2167
2538
  action,
2168
2539
  primary: receipt.primary,
2169
2540
  created: receipt.created,
@@ -2250,7 +2621,23 @@ function resolveAttributeTarget(db, target) {
2250
2621
  }
2251
2622
  return { kind: "ok", target: { kind: "link", id: link.value.id } };
2252
2623
  }
2253
- function manageAttributeToolHandler(db, input) {
2624
+ function manageAttributeToolHandler(db, rawInput) {
2625
+ const input = structuredClone(rawInput);
2626
+ const inputError = validateToolInput(manageAttributeTool.inputSchema, input);
2627
+ if (inputError)
2628
+ return toolFailure(
2629
+ "protocol",
2630
+ "invalid_input",
2631
+ "Invalid input.",
2632
+ inputError
2633
+ );
2634
+ if (!input.target)
2635
+ return toolFailure(
2636
+ "protocol",
2637
+ "invalid_input",
2638
+ "Missing target.",
2639
+ "target is required."
2640
+ );
2254
2641
  return withToolHandling(() => {
2255
2642
  const resolved = resolveAttributeTarget(db, input.target);
2256
2643
  if (resolved.kind === "error") {
@@ -2876,13 +3263,13 @@ function repairBonus(consecutiveRepairEvents) {
2876
3263
  }
2877
3264
 
2878
3265
  // src/links/effects.ts
2879
- var DAY_MS2 = 864e5;
3266
+ var DAY_MS3 = 864e5;
2880
3267
  function clamp3(value, min, max) {
2881
3268
  return Math.min(max, Math.max(min, value));
2882
3269
  }
2883
3270
  function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
2884
3271
  const dayStart = utcStartOfDayMs(occurredAt);
2885
- const dayEnd = dayStart + DAY_MS2;
3272
+ const dayEnd = dayStart + DAY_MS3;
2886
3273
  const row = db.prepare(
2887
3274
  `SELECT COUNT(*) AS c
2888
3275
  FROM link_event_effects le
@@ -2895,7 +3282,7 @@ function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
2895
3282
  return Number(row.c ?? 0);
2896
3283
  }
2897
3284
  function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
2898
- const weekStart = occurredAt - 7 * DAY_MS2;
3285
+ const weekStart = occurredAt - 7 * DAY_MS3;
2899
3286
  const row = db.prepare(
2900
3287
  `SELECT SUM(base_weight) AS total
2901
3288
  FROM link_event_effects
@@ -2907,7 +3294,7 @@ function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
2907
3294
  return Number(row.total ?? 0);
2908
3295
  }
2909
3296
  function countRepairContext(db, linkId, occurredAt) {
2910
- const thirtyDaysAgo = occurredAt - 30 * DAY_MS2;
3297
+ const thirtyDaysAgo = occurredAt - 30 * DAY_MS3;
2911
3298
  const hasPriorDamage = db.prepare(
2912
3299
  `SELECT 1 FROM link_event_effects
2913
3300
  WHERE link_id = ? AND trust_delta < 0
@@ -2982,8 +3369,8 @@ function computePreferenceMatch(db, linkId, contactId, eventType, provenance) {
2982
3369
  return preferenceMatch(liked, disliked);
2983
3370
  }
2984
3371
  function computeDateSalienceMultiplier(db, contactId, occurredAt, significance) {
2985
- const windowBefore = 7 * DAY_MS2;
2986
- const windowAfter = 2 * DAY_MS2;
3372
+ const windowBefore = 7 * DAY_MS3;
3373
+ const windowAfter = 2 * DAY_MS3;
2987
3374
  const row = db.prepare(
2988
3375
  `SELECT 1 FROM upcoming_occurrences uo
2989
3376
  JOIN events e ON e.id = uo.event_id
@@ -3150,9 +3537,9 @@ function applyEventEffectToLink(db, params) {
3150
3537
  ).get(params.linkId);
3151
3538
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
3152
3539
  1,
3153
- Math.round((params.occurredAt - latest.occurred_at) / DAY_MS2)
3540
+ Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
3154
3541
  );
3155
- const cadenceAfter = nextCadenceDays({
3542
+ const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
3156
3543
  kind: linkKind,
3157
3544
  currentCadenceDays: cadenceBefore,
3158
3545
  gapDays
@@ -3215,6 +3602,11 @@ function applyEventEffectToLink(db, params) {
3215
3602
  params.now,
3216
3603
  params.now
3217
3604
  );
3605
+ if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
3606
+ db.prepare(
3607
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
3608
+ ).run(params.eventId, params.linkId);
3609
+ }
3218
3610
  refreshLinkRollup(db, params.linkId, params.now);
3219
3611
  return {
3220
3612
  linkId: params.linkId,
@@ -3564,12 +3956,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
3564
3956
  if (row.type !== "promise" && row.type !== "agreement") {
3565
3957
  throw new AffinityInvariantError("not a commitment event");
3566
3958
  }
3567
- const open = db.prepare("SELECT resolved_at FROM open_commitments WHERE event_id = ?").get(commitmentEventId);
3959
+ const open = db.prepare(
3960
+ "SELECT resolved_at, resolution FROM open_commitments WHERE event_id = ?"
3961
+ ).get(commitmentEventId);
3568
3962
  if (!open) {
3569
3963
  throw new AffinityNotFoundError("open commitment not found");
3570
3964
  }
3571
3965
  if (open.resolved_at !== null) {
3572
- throw new AffinityStateError("commitment already resolved");
3966
+ throw new AffinityConflictError(
3967
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
3968
+ );
3573
3969
  }
3574
3970
  const now = resolveNow(options?.now);
3575
3971
  const originalParticipants = loadEventParticipantViews(
@@ -3582,7 +3978,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
3582
3978
  WHERE event_id = ? AND resolved_at IS NULL`
3583
3979
  ).run(resolution, now, now, commitmentEventId);
3584
3980
  if (Number(result.changes ?? 0) === 0) {
3585
- throw new AffinityStateError("commitment already resolved");
3981
+ throw new AffinityConflictError(
3982
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
3983
+ );
3586
3984
  }
3587
3985
  const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
3588
3986
  const created = [];
@@ -3670,9 +4068,25 @@ function ensureOwnerParticipates(db, participants) {
3670
4068
  function prepareParticipants(db, raw) {
3671
4069
  return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
3672
4070
  }
3673
- function manageCommitmentToolHandler(db, input) {
4071
+ function manageCommitmentToolHandler(db, rawInput) {
4072
+ const input = structuredClone(rawInput);
4073
+ const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
4074
+ if (inputError)
4075
+ return toolFailure(
4076
+ "protocol",
4077
+ "invalid_input",
4078
+ "Invalid input.",
4079
+ inputError
4080
+ );
3674
4081
  return withToolHandling(() => {
3675
4082
  if (input.action === "record") {
4083
+ if (!input.input)
4084
+ return toolFailure(
4085
+ "protocol",
4086
+ "invalid_input",
4087
+ "Missing input.",
4088
+ "input is required for record."
4089
+ );
3676
4090
  const patched = {
3677
4091
  ...input.input,
3678
4092
  participants: prepareParticipants(
@@ -3739,7 +4153,7 @@ var manageCommitmentTool = defineAffinityTool({
3739
4153
  role: stringSchema("Participant role."),
3740
4154
  directionality: stringSchema("Optional directionality.")
3741
4155
  },
3742
- ["contactId", "role"]
4156
+ ["role"]
3743
4157
  ),
3744
4158
  "Participant list. Must be non-empty and include the owner contact."
3745
4159
  ),
@@ -3786,6 +4200,142 @@ var manageCommitmentTool = defineAffinityTool({
3786
4200
  handler: manageCommitmentToolHandler
3787
4201
  });
3788
4202
 
4203
+ // src/contacts/delete_contact.ts
4204
+ function deleteContact(db, locatorId, options) {
4205
+ return withTransaction(db, () => {
4206
+ const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
4207
+ if (!contact) throw new AffinityNotFoundError("contact not found");
4208
+ if (contact.is_owner)
4209
+ throw new AffinityStateError("The owner contact cannot be deleted");
4210
+ const tables = [
4211
+ "contacts",
4212
+ "identities",
4213
+ "attributes",
4214
+ "links",
4215
+ "events",
4216
+ "event_participants",
4217
+ "open_commitments",
4218
+ "contact_merges",
4219
+ "dismissed_duplicates",
4220
+ "link_event_effects",
4221
+ "upcoming_occurrences",
4222
+ "link_rollups",
4223
+ "contact_rollups"
4224
+ ];
4225
+ const before = new Map(
4226
+ tables.map((table) => [
4227
+ table,
4228
+ db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
4229
+ ])
4230
+ );
4231
+ db.prepare(
4232
+ "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))"
4233
+ ).run(locatorId, locatorId);
4234
+ db.prepare(
4235
+ "DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
4236
+ ).run(locatorId, locatorId);
4237
+ db.prepare(
4238
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
4239
+ ).run(locatorId, locatorId);
4240
+ db.prepare(
4241
+ "UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
4242
+ ).run(locatorId);
4243
+ db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
4244
+ const now = Date.now();
4245
+ const affectedLinks = db.prepare(
4246
+ "SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
4247
+ ).all().map((row) => row.id);
4248
+ refreshAllBridgeScores(db, now);
4249
+ for (const id of affectedLinks) refreshLinkRollup(db, id, now);
4250
+ for (const row of db.prepare("SELECT id FROM contacts").all())
4251
+ refreshContactRollup(db, Number(row.id), now);
4252
+ const removed = tables.map((table) => {
4253
+ const remaining = new Set(
4254
+ db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
4255
+ );
4256
+ const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
4257
+ return { entity: table, count: ids.length, ids };
4258
+ }).filter((item) => item.count > 0);
4259
+ return { contactId: locatorId, removed, affectedLinks };
4260
+ });
4261
+ }
4262
+
4263
+ // src/contacts/export_contact.ts
4264
+ function exportContact(db, id) {
4265
+ const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
4266
+ const result = {};
4267
+ for (const [key, value] of Object.entries(row)) {
4268
+ const name = key.replace(
4269
+ /_([a-z])/g,
4270
+ (_, letter) => letter.toUpperCase()
4271
+ );
4272
+ result[name] = value;
4273
+ if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
4274
+ result[`${name}Iso`] = new Date(value).toISOString();
4275
+ if (key.endsWith("_json") && typeof value === "string")
4276
+ result[name] = JSON.parse(value);
4277
+ }
4278
+ return result;
4279
+ });
4280
+ const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
4281
+ if (!contact) throw new AffinityNotFoundError("contact not found");
4282
+ const events = query(
4283
+ "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
4284
+ id
4285
+ ).map((event) => ({
4286
+ ...event,
4287
+ participants: query(
4288
+ "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",
4289
+ event.id
4290
+ )
4291
+ }));
4292
+ return {
4293
+ contact,
4294
+ identities: query(
4295
+ "SELECT * FROM identities WHERE contact_id = ? ORDER BY id",
4296
+ id
4297
+ ),
4298
+ attributes: query(
4299
+ "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",
4300
+ id,
4301
+ id,
4302
+ id
4303
+ ),
4304
+ links: query(
4305
+ "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",
4306
+ id,
4307
+ id
4308
+ ),
4309
+ events,
4310
+ commitments: query(
4311
+ "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",
4312
+ id
4313
+ ),
4314
+ dates: query(
4315
+ "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",
4316
+ id,
4317
+ id,
4318
+ id
4319
+ ),
4320
+ mergeHistory: query(
4321
+ "SELECT * FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ? ORDER BY id",
4322
+ id,
4323
+ id
4324
+ )
4325
+ };
4326
+ }
4327
+
4328
+ // src/contacts/export_affinity.ts
4329
+ function exportAffinity(db) {
4330
+ const now = Date.now();
4331
+ return {
4332
+ version: 1,
4333
+ exportedAt: now,
4334
+ exportedAtIso: new Date(now).toISOString(),
4335
+ contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
4336
+ };
4337
+ }
4338
+
3789
4339
  // src/contacts/receipts.ts
3790
4340
  function buildContactMutationReceipt(primary, partial) {
3791
4341
  const normalized = normalizeMutationReceiptPartial(partial);
@@ -3812,6 +4362,101 @@ function buildMergeMutationReceipt(primary, partial) {
3812
4362
  };
3813
4363
  }
3814
4364
 
4365
+ // src/contacts/revise_contact.ts
4366
+ function reviseContact(db, contactId, patch) {
4367
+ return withTransaction(db, () => {
4368
+ const row = getContactRowById(db, contactId);
4369
+ if (!row) {
4370
+ throw new AffinityNotFoundError("contact not found");
4371
+ }
4372
+ if (row.lifecycle_state === "merged") {
4373
+ throw new AffinityStateError("merged contact is read-only");
4374
+ }
4375
+ if (patch.name === void 0) {
4376
+ return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
4377
+ updated: []
4378
+ });
4379
+ }
4380
+ const name = patch.name.trim();
4381
+ if (name.length === 0) {
4382
+ throw new AffinityValidationError("name must be non-empty");
4383
+ }
4384
+ const now = resolveNow();
4385
+ db.prepare("UPDATE contacts SET name = ?, updated_at = ? WHERE id = ?").run(
4386
+ name,
4387
+ now,
4388
+ contactId
4389
+ );
4390
+ refreshContactRollup(db, contactId, now);
4391
+ const next = getContactRowById(db, contactId);
4392
+ if (!next) {
4393
+ throw new AffinityNotFoundError("contact not found");
4394
+ }
4395
+ return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
4396
+ updated: [{ kind: "contact", id: contactId }]
4397
+ });
4398
+ });
4399
+ }
4400
+
4401
+ // src/contacts/validators.ts
4402
+ function assertValidLifecycleTransition(from, to) {
4403
+ if (from === "merged") {
4404
+ throw new AffinityStateError("merged contact is read-only");
4405
+ }
4406
+ if (to === "merged") {
4407
+ throw new AffinityInvariantError(
4408
+ "use manage_contact merge (CLI: contact merge) to reach merged state"
4409
+ );
4410
+ }
4411
+ if (from === to) {
4412
+ return;
4413
+ }
4414
+ const allowed = {
4415
+ active: ["dormant", "lost"],
4416
+ dormant: ["active", "lost"],
4417
+ lost: ["active"],
4418
+ merged: []
4419
+ };
4420
+ if (!allowed[from].includes(to)) {
4421
+ throw new AffinityValidationError(
4422
+ `cannot transition from ${from} to ${to}`
4423
+ );
4424
+ }
4425
+ }
4426
+
4427
+ // src/contacts/set_contact_lifecycle.ts
4428
+ var STATES2 = /* @__PURE__ */ new Set(["active", "dormant", "merged", "lost"]);
4429
+ function setContactLifecycle(db, contactId, lifecycleState, options) {
4430
+ return withTransaction(db, () => {
4431
+ if (!STATES2.has(lifecycleState)) {
4432
+ throw new AffinityValidationError("invalid lifecycle state");
4433
+ }
4434
+ const row = getContactRowById(db, contactId);
4435
+ if (!row) {
4436
+ throw new AffinityNotFoundError("contact not found");
4437
+ }
4438
+ const from = row.lifecycle_state;
4439
+ assertValidLifecycleTransition(from, lifecycleState);
4440
+ const now = resolveNow(options?.now);
4441
+ if (from === lifecycleState) {
4442
+ return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
4443
+ updated: []
4444
+ });
4445
+ }
4446
+ db.prepare(
4447
+ "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
4448
+ ).run(lifecycleState, now, contactId);
4449
+ refreshContactRollup(db, contactId, now);
4450
+ const next = getContactRowById(db, contactId);
4451
+ if (!next) {
4452
+ throw new AffinityNotFoundError("contact not found");
4453
+ }
4454
+ return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
4455
+ updated: [{ kind: "contact", id: contactId }]
4456
+ });
4457
+ });
4458
+ }
4459
+
3815
4460
  // src/contacts/create_contact.ts
3816
4461
  var KINDS = /* @__PURE__ */ new Set([
3817
4462
  "human",
@@ -3865,96 +4510,201 @@ function createContact(db, input) {
3865
4510
  });
3866
4511
  }
3867
4512
 
3868
- // src/contacts/revise_contact.ts
3869
- function reviseContact(db, contactId, patch) {
3870
- return withTransaction(db, () => {
3871
- const row = getContactRowById(db, contactId);
3872
- if (!row) {
3873
- throw new AffinityNotFoundError("contact not found");
3874
- }
3875
- if (row.lifecycle_state === "merged") {
3876
- throw new AffinityStateError("merged contact is read-only");
3877
- }
3878
- if (patch.name === void 0) {
3879
- return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
3880
- updated: []
3881
- });
3882
- }
3883
- const name = patch.name.trim();
3884
- if (name.length === 0) {
3885
- throw new AffinityValidationError("name must be non-empty");
3886
- }
3887
- const now = resolveNow();
3888
- db.prepare("UPDATE contacts SET name = ?, updated_at = ? WHERE id = ?").run(
3889
- name,
3890
- now,
3891
- contactId
3892
- );
3893
- refreshContactRollup(db, contactId, now);
3894
- const next = getContactRowById(db, contactId);
3895
- if (!next) {
3896
- throw new AffinityNotFoundError("contact not found");
3897
- }
3898
- return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
3899
- updated: [{ kind: "contact", id: contactId }]
3900
- });
3901
- });
4513
+ // src/contacts/fold_search_text.ts
4514
+ function foldSearchText(value) {
4515
+ let folded = value.toLowerCase();
4516
+ if (/[^\u0020-\u007e]/.test(folded))
4517
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
4518
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
3902
4519
  }
3903
4520
 
3904
- // src/contacts/validators.ts
3905
- function assertValidLifecycleTransition(from, to) {
3906
- if (from === "merged") {
3907
- throw new AffinityStateError("merged contact is read-only");
3908
- }
3909
- if (to === "merged") {
3910
- throw new AffinityInvariantError("use mergeContacts to reach merged state");
3911
- }
3912
- if (from === to) {
3913
- return;
4521
+ // src/identities/queries.ts
4522
+ function getIdentityRowById(db, id) {
4523
+ const row = db.prepare(
4524
+ `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
4525
+ created_at, updated_at, removed_at
4526
+ FROM identities
4527
+ WHERE id = ? AND removed_at IS NULL`
4528
+ ).get(id);
4529
+ if (row === void 0) {
4530
+ return null;
3914
4531
  }
3915
- const allowed = {
3916
- active: ["dormant", "lost"],
3917
- dormant: ["active", "lost"],
3918
- lost: ["active"],
3919
- merged: []
4532
+ return row;
4533
+ }
4534
+
4535
+ // src/identities/receipts.ts
4536
+ function buildIdentityMutationReceipt(primary, partial) {
4537
+ const normalized = normalizeMutationReceiptPartial(partial);
4538
+ return {
4539
+ primary,
4540
+ created: normalized.created,
4541
+ updated: normalized.updated,
4542
+ archived: normalized.archived,
4543
+ removed: normalized.removed,
4544
+ affectedLinks: normalized.affectedLinks,
4545
+ derivedEffects: normalized.derivedEffects
3920
4546
  };
3921
- if (!allowed[from].includes(to)) {
4547
+ }
4548
+
4549
+ // src/identities/validators.ts
4550
+ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
4551
+ const row = excludeIdentityId === void 0 ? db.prepare(
4552
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
4553
+ ).get(normalizedKey) : db.prepare(
4554
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
4555
+ ).get(normalizedKey, excludeIdentityId);
4556
+ if (row !== void 0) {
4557
+ const owner = db.prepare(
4558
+ "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"
4559
+ ).get(normalizedKey);
4560
+ throw new AffinityConflictError(
4561
+ `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
4562
+ );
4563
+ }
4564
+ }
4565
+
4566
+ // src/identities/validate_identity_input.ts
4567
+ var identityTypes = [
4568
+ "email",
4569
+ "phone",
4570
+ "url",
4571
+ "website",
4572
+ "handle",
4573
+ "x",
4574
+ "twitter",
4575
+ "linkedin",
4576
+ "github",
4577
+ "mastodon",
4578
+ "bluesky",
4579
+ "telegram",
4580
+ "whatsapp",
4581
+ "signal",
4582
+ "matrix",
4583
+ "discord",
4584
+ "slack",
4585
+ "instagram",
4586
+ "facebook",
4587
+ "youtube",
4588
+ "tiktok",
4589
+ "address",
4590
+ "other",
4591
+ "account_id",
4592
+ "alias"
4593
+ ];
4594
+ function validateIdentityInput(type, value) {
4595
+ const kind = type.toLowerCase();
4596
+ if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
3922
4597
  throw new AffinityValidationError(
3923
- `cannot transition from ${from} to ${to}`
4598
+ `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
4599
+ );
4600
+ if (!value.trim())
4601
+ throw new AffinityValidationError("identity value must be non-empty");
4602
+ if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
4603
+ throw new AffinityValidationError(
4604
+ "email value must match local@domain.tld"
3924
4605
  );
4606
+ if (kind === "phone" && value.replace(/\D/g, "").length < 5)
4607
+ throw new AffinityValidationError(
4608
+ "phone value must contain at least 5 digits"
4609
+ );
4610
+ if (kind === "url" || kind === "website") {
4611
+ try {
4612
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
4613
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
4614
+ throw new Error();
4615
+ } catch {
4616
+ throw new AffinityValidationError(
4617
+ "url/website value must be an http/https URL or bare domain"
4618
+ );
4619
+ }
3925
4620
  }
3926
4621
  }
3927
4622
 
3928
- // src/contacts/set_contact_lifecycle.ts
3929
- var STATES2 = /* @__PURE__ */ new Set(["active", "dormant", "merged", "lost"]);
3930
- function setContactLifecycle(db, contactId, lifecycleState, options) {
4623
+ // src/identities/add_identity.ts
4624
+ function addIdentity(db, contactId, input) {
3931
4625
  return withTransaction(db, () => {
3932
- if (!STATES2.has(lifecycleState)) {
3933
- throw new AffinityValidationError("invalid lifecycle state");
3934
- }
3935
- const row = getContactRowById(db, contactId);
3936
- if (!row) {
4626
+ const contact = getContactRowById(db, contactId);
4627
+ if (!contact) {
3937
4628
  throw new AffinityNotFoundError("contact not found");
3938
4629
  }
3939
- const from = row.lifecycle_state;
3940
- assertValidLifecycleTransition(from, lifecycleState);
3941
- const now = resolveNow(options?.now);
3942
- if (from === lifecycleState) {
3943
- return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
3944
- updated: []
3945
- });
4630
+ if (contact.lifecycle_state === "merged") {
4631
+ throw new AffinityStateError("merged contact is read-only");
3946
4632
  }
3947
- db.prepare(
3948
- "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
3949
- ).run(lifecycleState, now, contactId);
3950
- refreshContactRollup(db, contactId, now);
3951
- const next = getContactRowById(db, contactId);
3952
- if (!next) {
3953
- throw new AffinityNotFoundError("contact not found");
4633
+ const type = input.type.trim();
4634
+ const value = input.value.trim();
4635
+ if (type.length === 0 || value.length === 0) {
4636
+ throw new AffinityValidationError("type and value must be non-empty");
3954
4637
  }
3955
- return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
3956
- updated: [{ kind: "contact", id: contactId }]
4638
+ validateIdentityInput(type, value);
4639
+ const nk = normalizeIdentityKey(type, value);
4640
+ assertNoIdentityCollision(db, nk);
4641
+ const now = resolveNow(input.now);
4642
+ const verified = input.verified ? 1 : 0;
4643
+ const verifiedAt = verified === 1 ? now : null;
4644
+ const result = db.prepare(
4645
+ `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
4646
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
4647
+ ).run(
4648
+ contactId,
4649
+ type,
4650
+ value,
4651
+ input.label ?? null,
4652
+ nk,
4653
+ verified,
4654
+ verifiedAt,
4655
+ now,
4656
+ now
4657
+ );
4658
+ const id = Number(result.lastInsertRowid);
4659
+ const row = getIdentityRowById(db, id);
4660
+ if (!row) {
4661
+ throw new AffinityInvariantError("inserted identity not found");
4662
+ }
4663
+ refreshContactRollup(db, contactId, now);
4664
+ db.prepare(
4665
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
4666
+ ).run(contactId, contactId);
4667
+ return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
4668
+ created: [{ kind: "identity", id }]
4669
+ });
4670
+ });
4671
+ }
4672
+
4673
+ // src/tools/create_contact_tool_result.ts
4674
+ function createContactToolResult(db, input) {
4675
+ return withTransaction(db, () => {
4676
+ const receipt = createContact(db, input);
4677
+ const identityOwners = /* @__PURE__ */ new Set();
4678
+ for (const identity of input.identities ?? []) {
4679
+ validateIdentityInput(identity.type.trim(), identity.value.trim());
4680
+ const existing = db.prepare(
4681
+ "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
4682
+ ).get(normalizeIdentityKey(identity.type, identity.value));
4683
+ if (existing) {
4684
+ if (Number(existing.contact_id) !== receipt.primary.id)
4685
+ identityOwners.add(Number(existing.contact_id));
4686
+ continue;
4687
+ }
4688
+ const added = addIdentity(db, receipt.primary.id, identity);
4689
+ receipt.created.push(...added.created);
4690
+ }
4691
+ const result = mutationToolResult("create", receipt, "contact");
4692
+ const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
4693
+ const warnings = db.prepare(
4694
+ "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
4695
+ ).all(receipt.primary.id).flatMap((row) => {
4696
+ const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
4697
+ return score === void 0 ? [] : [
4698
+ {
4699
+ code: "possible_duplicate",
4700
+ message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
4701
+ contactId: row.id,
4702
+ score
4703
+ }
4704
+ ];
3957
4705
  });
4706
+ if (warnings.length) result.warnings = warnings;
4707
+ return result;
3958
4708
  });
3959
4709
  }
3960
4710
 
@@ -3976,24 +4726,76 @@ function contactLocatorSchema3(description) {
3976
4726
  description
3977
4727
  };
3978
4728
  }
3979
- function manageContactToolHandler(db, input) {
4729
+ function manageContactToolHandler(db, rawInput) {
4730
+ const input = structuredClone(rawInput);
4731
+ const inputError = validateToolInput(manageContactTool.inputSchema, input);
4732
+ if (inputError)
4733
+ return toolFailure(
4734
+ "protocol",
4735
+ "invalid_input",
4736
+ "Invalid input.",
4737
+ inputError
4738
+ );
3980
4739
  return withToolHandling(() => {
3981
- if (input.action === "create") {
3982
- return mutationToolResult(
3983
- "create",
3984
- createContact(db, input.input),
3985
- "contact"
4740
+ if (input.action === "export") {
4741
+ if (!input.contact)
4742
+ return toolSuccess("Exported Affinity.", {
4743
+ action: "export",
4744
+ export: exportAffinity(db)
4745
+ });
4746
+ const target = resolveContactLocator(db, input.contact, "contact", true);
4747
+ if (!target.ok) return target.result;
4748
+ return toolSuccess("Exported contact.", {
4749
+ action: "export",
4750
+ export: exportContact(db, target.value.id)
4751
+ });
4752
+ }
4753
+ if (input.action === "delete" && input.confirm !== true)
4754
+ return toolFailure(
4755
+ "protocol",
4756
+ "invalid_input",
4757
+ "Delete requires confirmation.",
4758
+ "delete is permanent; pass confirm: true (CLI: --confirm)"
3986
4759
  );
4760
+ if (input.action === "create") {
4761
+ if (!input.input)
4762
+ return toolFailure(
4763
+ "protocol",
4764
+ "invalid_input",
4765
+ "Missing input.",
4766
+ "input is required for create."
4767
+ );
4768
+ return createContactToolResult(db, input.input);
3987
4769
  }
3988
- const contact = resolveContactLocator(db, input.contact, "contact");
4770
+ const contact = resolveContactLocator(
4771
+ db,
4772
+ input.contact,
4773
+ "contact",
4774
+ input.action === "delete"
4775
+ );
3989
4776
  if (!contact.ok) {
3990
4777
  return contact.result;
3991
4778
  }
4779
+ if (input.action === "delete") {
4780
+ const deletion = deleteContact(db, contact.value.id, { confirm: true });
4781
+ return toolSuccess("Permanently deleted contact.", {
4782
+ action: "delete",
4783
+ deletion,
4784
+ changes: deletion.removed.flatMap(
4785
+ (item) => item.ids.map((id) => ({
4786
+ entity: item.entity,
4787
+ id,
4788
+ action: "removed"
4789
+ }))
4790
+ )
4791
+ });
4792
+ }
3992
4793
  if (input.action === "revise") {
3993
4794
  return mutationToolResult(
3994
4795
  "revise",
3995
4796
  reviseContact(db, contact.value.id, input.patch),
3996
- "contact"
4797
+ "contact",
4798
+ Object.keys(input.patch)
3997
4799
  );
3998
4800
  }
3999
4801
  return mutationToolResult(
@@ -4010,7 +4812,7 @@ function manageContactToolHandler(db, input) {
4010
4812
  }
4011
4813
  var manageContactTool = defineAffinityTool({
4012
4814
  name: manageContactToolName,
4013
- 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.",
4815
+ 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.",
4014
4816
  whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
4015
4817
  whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
4016
4818
  sideEffects: "writes_state",
@@ -4031,10 +4833,25 @@ var manageContactTool = defineAffinityTool({
4031
4833
  action: enumSchema("Operation to perform.", [
4032
4834
  "create",
4033
4835
  "revise",
4034
- "set_lifecycle"
4836
+ "set_lifecycle",
4837
+ "export",
4838
+ "delete"
4035
4839
  ]),
4036
4840
  input: objectSchema(
4037
4841
  {
4842
+ identities: arraySchema(
4843
+ objectSchema(
4844
+ {
4845
+ type: stringSchema("Identity type."),
4846
+ value: stringSchema("Identity value."),
4847
+ label: stringSchema("Optional label."),
4848
+ verified: booleanSchema("Verified identity."),
4849
+ now: integerSchema("Optional timestamp.")
4850
+ },
4851
+ ["type", "value"]
4852
+ ),
4853
+ "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
4854
+ ),
4038
4855
  name: stringSchema("Contact name."),
4039
4856
  kind: enumSchema("Contact kind.", [
4040
4857
  "human",
@@ -4053,6 +4870,7 @@ var manageContactTool = defineAffinityTool({
4053
4870
  ["name", "kind"],
4054
4871
  "New contact payload. Required when action=create."
4055
4872
  ),
4873
+ confirm: booleanSchema("Required true for permanent deletion."),
4056
4874
  contact: contactLocatorSchema3(
4057
4875
  "Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
4058
4876
  ),
@@ -4281,9 +5099,32 @@ function resolveDateAnchorTarget(db, target) {
4281
5099
  }
4282
5100
  return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
4283
5101
  }
4284
- function manageDateAnchorToolHandler(db, input) {
5102
+ function manageDateAnchorToolHandler(db, rawInput) {
5103
+ const input = structuredClone(rawInput);
5104
+ const inputError = validateToolInput(manageDateAnchorTool.inputSchema, input);
5105
+ if (inputError)
5106
+ return toolFailure(
5107
+ "protocol",
5108
+ "invalid_input",
5109
+ "Invalid input.",
5110
+ inputError
5111
+ );
5112
+ if (input.action !== "add" && input.anchorEventId === void 0)
5113
+ return toolFailure(
5114
+ "protocol",
5115
+ "invalid_input",
5116
+ "Missing anchorEventId.",
5117
+ "anchorEventId is required."
5118
+ );
4285
5119
  return withToolHandling(() => {
4286
5120
  if (input.action === "add") {
5121
+ if (!input.input)
5122
+ return toolFailure(
5123
+ "protocol",
5124
+ "invalid_input",
5125
+ "Missing input.",
5126
+ "input is required for add."
5127
+ );
4287
5128
  const resolved = resolveDateAnchorTarget(db, input.input.target);
4288
5129
  if (resolved.kind === "error") {
4289
5130
  return resolved.result;
@@ -4298,7 +5139,8 @@ function manageDateAnchorToolHandler(db, input) {
4298
5139
  return mutationToolResult(
4299
5140
  "revise",
4300
5141
  reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
4301
- "date anchor"
5142
+ "date anchor",
5143
+ Object.keys(input.patch)
4302
5144
  );
4303
5145
  }
4304
5146
  return mutationToolResult(
@@ -4517,7 +5359,19 @@ function contactLocatorSchema5(description) {
4517
5359
  description
4518
5360
  };
4519
5361
  }
4520
- function manageDuplicateDismissalToolHandler(db, input) {
5362
+ function manageDuplicateDismissalToolHandler(db, rawInput) {
5363
+ const input = structuredClone(rawInput);
5364
+ const inputError = validateToolInput(
5365
+ manageDuplicateDismissalTool.inputSchema,
5366
+ input
5367
+ );
5368
+ if (inputError)
5369
+ return toolFailure(
5370
+ "protocol",
5371
+ "invalid_input",
5372
+ "Invalid input.",
5373
+ inputError
5374
+ );
4521
5375
  return withToolHandling(() => {
4522
5376
  const left = resolveContactLocator(db, input.left, "left");
4523
5377
  if (!left.ok) {
@@ -4527,6 +5381,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
4527
5381
  if (!right.ok) {
4528
5382
  return right.result;
4529
5383
  }
5384
+ const leftContactId = Math.min(left.value.id, right.value.id);
5385
+ const rightContactId = Math.max(left.value.id, right.value.id);
5386
+ const existed = db.prepare(
5387
+ "SELECT 1 FROM dismissed_duplicates WHERE left_id = ? AND right_id = ?"
5388
+ ).get(leftContactId, rightContactId) !== void 0;
4530
5389
  if (input.action === "dismiss") {
4531
5390
  const receipt2 = dismissDuplicate(
4532
5391
  db,
@@ -4536,139 +5395,70 @@ function manageDuplicateDismissalToolHandler(db, input) {
4536
5395
  );
4537
5396
  return toolSuccess(
4538
5397
  `Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
4539
- { action: "dismiss", primary: receipt2.primary }
5398
+ {
5399
+ ...receipt2,
5400
+ action: "dismiss",
5401
+ changes: [
5402
+ {
5403
+ entity: "duplicate_dismissal",
5404
+ leftContactId,
5405
+ rightContactId,
5406
+ action: existed ? "updated" : "created"
5407
+ }
5408
+ ]
5409
+ }
4540
5410
  );
4541
5411
  }
4542
5412
  const receipt = undismissDuplicate(db, left.value.id, right.value.id);
4543
5413
  return toolSuccess(
4544
5414
  `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
4545
- { action: "undismiss", primary: receipt.primary }
4546
- );
4547
- }, "Duplicate dismissal failed.");
4548
- }
4549
- var manageDuplicateDismissalTool = defineAffinityTool({
4550
- name: manageDuplicateDismissalToolName,
4551
- description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in listDuplicateCandidates.",
4552
- whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
4553
- whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
4554
- sideEffects: "writes_state",
4555
- readOnly: false,
4556
- supportsClarification: true,
4557
- targetKinds: ["contact"],
4558
- inputDescriptions: {
4559
- action: "dismiss to suppress the pair; undismiss to re-surface it.",
4560
- left: "One contact of the pair.",
4561
- right: "The other contact of the pair.",
4562
- reason: "Optional reason for the dismissal (audit trail)."
4563
- },
4564
- outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
4565
- inputSchema: objectSchema(
4566
- {
4567
- action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
4568
- left: contactLocatorSchema5(
4569
- "Left contact. Provide contactId or identity."
4570
- ),
4571
- right: contactLocatorSchema5(
4572
- "Right contact. Provide contactId or identity."
4573
- ),
4574
- reason: nullableStringSchema("Optional reason for the dismissal.")
4575
- },
4576
- ["action", "left", "right"],
4577
- "Dismiss or un-dismiss a duplicate candidate pair."
4578
- ),
4579
- handler: manageDuplicateDismissalToolHandler
4580
- });
4581
-
4582
- // src/identities/queries.ts
4583
- function getIdentityRowById(db, id) {
4584
- const row = db.prepare(
4585
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
4586
- created_at, updated_at, removed_at
4587
- FROM identities
4588
- WHERE id = ? AND removed_at IS NULL`
4589
- ).get(id);
4590
- if (row === void 0) {
4591
- return null;
4592
- }
4593
- return row;
4594
- }
4595
-
4596
- // src/identities/receipts.ts
4597
- function buildIdentityMutationReceipt(primary, partial) {
4598
- const normalized = normalizeMutationReceiptPartial(partial);
4599
- return {
4600
- primary,
4601
- created: normalized.created,
4602
- updated: normalized.updated,
4603
- archived: normalized.archived,
4604
- removed: normalized.removed,
4605
- affectedLinks: normalized.affectedLinks,
4606
- derivedEffects: normalized.derivedEffects
4607
- };
4608
- }
4609
-
4610
- // src/identities/validators.ts
4611
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
4612
- const row = excludeIdentityId === void 0 ? db.prepare(
4613
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
4614
- ).get(normalizedKey) : db.prepare(
4615
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
4616
- ).get(normalizedKey, excludeIdentityId);
4617
- if (row !== void 0) {
4618
- throw new AffinityConflictError(
4619
- "another contact already owns this normalized identity"
4620
- );
4621
- }
4622
- }
4623
-
4624
- // src/identities/add_identity.ts
4625
- function addIdentity(db, contactId, input) {
4626
- return withTransaction(db, () => {
4627
- const contact = getContactRowById(db, contactId);
4628
- if (!contact) {
4629
- throw new AffinityNotFoundError("contact not found");
4630
- }
4631
- if (contact.lifecycle_state === "merged") {
4632
- throw new AffinityStateError("merged contact is read-only");
4633
- }
4634
- const type = input.type.trim();
4635
- const value = input.value.trim();
4636
- if (type.length === 0 || value.length === 0) {
4637
- throw new AffinityValidationError("type and value must be non-empty");
4638
- }
4639
- const nk = normalizeIdentityKey(type, value);
4640
- assertNoIdentityCollision(db, nk);
4641
- const now = resolveNow(input.now);
4642
- const verified = input.verified ? 1 : 0;
4643
- const verifiedAt = verified === 1 ? now : null;
4644
- const result = db.prepare(
4645
- `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
4646
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
4647
- ).run(
4648
- contactId,
4649
- type,
4650
- value,
4651
- input.label ?? null,
4652
- nk,
4653
- verified,
4654
- verifiedAt,
4655
- now,
4656
- now
4657
- );
4658
- const id = Number(result.lastInsertRowid);
4659
- const row = getIdentityRowById(db, id);
4660
- if (!row) {
4661
- throw new AffinityInvariantError("inserted identity not found");
4662
- }
4663
- refreshContactRollup(db, contactId, now);
4664
- db.prepare(
4665
- "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
4666
- ).run(contactId, contactId);
4667
- return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
4668
- created: [{ kind: "identity", id }]
4669
- });
4670
- });
5415
+ {
5416
+ ...receipt,
5417
+ action: "undismiss",
5418
+ changes: existed ? [
5419
+ {
5420
+ entity: "duplicate_dismissal",
5421
+ leftContactId,
5422
+ rightContactId,
5423
+ action: "removed"
5424
+ }
5425
+ ] : []
5426
+ }
5427
+ );
5428
+ }, "Duplicate dismissal failed.");
4671
5429
  }
5430
+ var manageDuplicateDismissalTool = defineAffinityTool({
5431
+ name: manageDuplicateDismissalToolName,
5432
+ description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
5433
+ whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
5434
+ whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
5435
+ sideEffects: "writes_state",
5436
+ readOnly: false,
5437
+ supportsClarification: true,
5438
+ targetKinds: ["contact"],
5439
+ inputDescriptions: {
5440
+ action: "dismiss to suppress the pair; undismiss to re-surface it.",
5441
+ left: "One contact of the pair.",
5442
+ right: "The other contact of the pair.",
5443
+ reason: "Optional reason for the dismissal (audit trail)."
5444
+ },
5445
+ outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
5446
+ inputSchema: objectSchema(
5447
+ {
5448
+ action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
5449
+ left: contactLocatorSchema5(
5450
+ "Left contact. Provide contactId or identity."
5451
+ ),
5452
+ right: contactLocatorSchema5(
5453
+ "Right contact. Provide contactId or identity."
5454
+ ),
5455
+ reason: nullableStringSchema("Optional reason for the dismissal.")
5456
+ },
5457
+ ["action", "left", "right"],
5458
+ "Dismiss or un-dismiss a duplicate candidate pair."
5459
+ ),
5460
+ handler: manageDuplicateDismissalToolHandler
5461
+ });
4672
5462
 
4673
5463
  // src/identities/remove_identity.ts
4674
5464
  function removeIdentity(db, identityId, removedAt) {
@@ -4721,6 +5511,7 @@ function reviseIdentity(db, identityId, patch) {
4721
5511
  if (nextType.length === 0 || nextValue.length === 0) {
4722
5512
  throw new AffinityValidationError("type and value must be non-empty");
4723
5513
  }
5514
+ validateIdentityInput(nextType, nextValue);
4724
5515
  const nextLabel = patch.label !== void 0 ? patch.label : row.label;
4725
5516
  const nkNext = normalizeIdentityKey(nextType, nextValue);
4726
5517
  if (nkNext !== row.normalized_key) {
@@ -4775,7 +5566,9 @@ function contactLocatorSchema6(description) {
4775
5566
  contactId: integerSchema("Exact contact id."),
4776
5567
  identity: objectSchema(
4777
5568
  {
4778
- type: stringSchema("Identity type."),
5569
+ type: stringSchema(
5570
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
5571
+ ),
4779
5572
  value: stringSchema("Identity value.")
4780
5573
  },
4781
5574
  ["type", "value"],
@@ -4785,7 +5578,23 @@ function contactLocatorSchema6(description) {
4785
5578
  description
4786
5579
  };
4787
5580
  }
4788
- function manageIdentityToolHandler(db, input) {
5581
+ function manageIdentityToolHandler(db, rawInput) {
5582
+ const input = structuredClone(rawInput);
5583
+ const inputError = validateToolInput(manageIdentityTool.inputSchema, input);
5584
+ if (inputError)
5585
+ return toolFailure(
5586
+ "protocol",
5587
+ "invalid_input",
5588
+ "Invalid input.",
5589
+ inputError
5590
+ );
5591
+ if (input.action !== "add" && input.identityId === void 0)
5592
+ return toolFailure(
5593
+ "protocol",
5594
+ "invalid_input",
5595
+ "Missing identityId.",
5596
+ "identityId is required."
5597
+ );
4789
5598
  return withToolHandling(() => {
4790
5599
  switch (input.action) {
4791
5600
  case "add": {
@@ -4803,7 +5612,8 @@ function manageIdentityToolHandler(db, input) {
4803
5612
  return mutationToolResult(
4804
5613
  "revise",
4805
5614
  reviseIdentity(db, input.identityId, input.patch),
4806
- "identity"
5615
+ "identity",
5616
+ Object.keys(input.patch)
4807
5617
  );
4808
5618
  case "verify":
4809
5619
  return mutationToolResult(
@@ -4852,7 +5662,9 @@ var manageIdentityTool = defineAffinityTool({
4852
5662
  ),
4853
5663
  input: objectSchema(
4854
5664
  {
4855
- type: stringSchema("Identity type."),
5665
+ type: stringSchema(
5666
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
5667
+ ),
4856
5668
  value: stringSchema("Identity value."),
4857
5669
  label: stringSchema("Optional label."),
4858
5670
  verified: booleanSchema("Whether the identity starts verified."),
@@ -4866,7 +5678,9 @@ var manageIdentityTool = defineAffinityTool({
4866
5678
  ),
4867
5679
  patch: objectSchema(
4868
5680
  {
4869
- type: stringSchema("Optional identity type."),
5681
+ type: stringSchema(
5682
+ `Optional identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
5683
+ ),
4870
5684
  value: stringSchema("Optional identity value."),
4871
5685
  label: stringSchema("Optional label.")
4872
5686
  },
@@ -5085,7 +5899,19 @@ function linkLocatorSchema4(description) {
5085
5899
  description
5086
5900
  };
5087
5901
  }
5088
- function manageRelationshipToolHandler(db, input) {
5902
+ function manageRelationshipToolHandler(db, rawInput) {
5903
+ const input = structuredClone(rawInput);
5904
+ const inputError = validateToolInput(
5905
+ manageRelationshipTool.inputSchema,
5906
+ input
5907
+ );
5908
+ if (inputError)
5909
+ return toolFailure(
5910
+ "protocol",
5911
+ "invalid_input",
5912
+ "Invalid input.",
5913
+ inputError
5914
+ );
5089
5915
  return withToolHandling(() => {
5090
5916
  switch (input.action) {
5091
5917
  case "seed_social_link": {
@@ -5455,7 +6281,16 @@ function contactLocatorSchema8(description) {
5455
6281
  description
5456
6282
  };
5457
6283
  }
5458
- function mergeContactsToolHandler(db, input) {
6284
+ function mergeContactsToolHandler(db, rawInput) {
6285
+ const input = structuredClone(rawInput);
6286
+ const inputError = validateToolInput(mergeContactsTool.inputSchema, input);
6287
+ if (inputError)
6288
+ return toolFailure(
6289
+ "protocol",
6290
+ "invalid_input",
6291
+ "Invalid input.",
6292
+ inputError
6293
+ );
5459
6294
  return withToolHandling(() => {
5460
6295
  const winner = resolveContactLocator(db, input.winner, "winner");
5461
6296
  if (!winner.ok) {
@@ -5685,7 +6520,16 @@ function prepareParticipants2(db, raw, injectOwner) {
5685
6520
  const normalized = raw.map(normalizeParticipant2);
5686
6521
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
5687
6522
  }
5688
- function recordEventToolHandler(db, input) {
6523
+ function recordEventToolHandler(db, rawInput) {
6524
+ const input = structuredClone(rawInput);
6525
+ const inputError = validateToolInput(recordEventTool.inputSchema, input);
6526
+ if (inputError)
6527
+ return toolFailure(
6528
+ "protocol",
6529
+ "invalid_input",
6530
+ "Invalid input.",
6531
+ inputError
6532
+ );
5689
6533
  return withToolHandling(() => {
5690
6534
  const patched = {
5691
6535
  ...input.input,
@@ -5783,7 +6627,7 @@ var recordEventTool = defineAffinityTool({
5783
6627
  "observed"
5784
6628
  ])
5785
6629
  },
5786
- ["contactId", "role"]
6630
+ ["role"]
5787
6631
  ),
5788
6632
  "Participant list. Must be non-empty and include the owner contact."
5789
6633
  ),
@@ -5799,6 +6643,23 @@ var recordEventTool = defineAffinityTool({
5799
6643
  handler: recordEventToolHandler
5800
6644
  });
5801
6645
 
6646
+ // src/contacts/contact_attribute_clauses.ts
6647
+ function contactAttributeClauses(filters) {
6648
+ const clauses = [];
6649
+ const params = [];
6650
+ const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
6651
+ const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
6652
+ for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
6653
+ const separator = filter.indexOf("=");
6654
+ const name = separator < 0 ? filter : filter.slice(0, separator);
6655
+ clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
6656
+ 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(?)"})`);
6657
+ params.push(name);
6658
+ if (separator >= 0) params.push(filter.slice(separator + 1));
6659
+ }
6660
+ return { clauses, params };
6661
+ }
6662
+
5802
6663
  // src/contacts/list_contacts.ts
5803
6664
  function listContacts(db, filters, options) {
5804
6665
  assertDefaultOrdering("listContacts", options);
@@ -5822,6 +6683,9 @@ function listContacts(db, filters, options) {
5822
6683
  if (filters?.includeDormant === false || options?.includeDormant === false) {
5823
6684
  clauses.push("lifecycle_state != 'dormant'");
5824
6685
  }
6686
+ const attributeFilters = contactAttributeClauses(filters);
6687
+ clauses.push(...attributeFilters.clauses);
6688
+ params.push(...attributeFilters.params);
5825
6689
  const where = clauses.join(" AND ");
5826
6690
  const rows = db.prepare(
5827
6691
  `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
@@ -6053,115 +6917,6 @@ function listUpcomingDates(db, filters, options) {
6053
6917
  return rows.map((r) => mapUpcomingRowToUpcomingDateRecord(r));
6054
6918
  }
6055
6919
 
6056
- // src/events/get_contact_journal.ts
6057
- function getContactJournal(db, contactId, options) {
6058
- assertDefaultOrdering("getContactJournal", options);
6059
- const { limit, offset } = resolveListLimitOffset(options);
6060
- const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
6061
- const params = [contactId];
6062
- if (options?.since !== void 0) {
6063
- clauses.push("e.occurred_at >= ?");
6064
- params.push(options.since);
6065
- }
6066
- if (options?.until !== void 0) {
6067
- clauses.push("e.occurred_at <= ?");
6068
- params.push(options.until);
6069
- }
6070
- if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
6071
- const ph = options.eventTypes.map(() => "?").join(", ");
6072
- clauses.push(`e.type IN (${ph})`);
6073
- params.push(...options.eventTypes);
6074
- }
6075
- const where = clauses.join(" AND ");
6076
- const rows = db.prepare(
6077
- `SELECT e.id FROM events e
6078
- INNER JOIN event_participants ep ON ep.event_id = e.id
6079
- WHERE ${where}
6080
- ORDER BY e.occurred_at DESC, e.id DESC
6081
- LIMIT ? OFFSET ?`
6082
- ).all(...params, limit, offset);
6083
- return batchLoadEventRecords(
6084
- db,
6085
- rows.map((r) => r.id)
6086
- );
6087
- }
6088
-
6089
- // src/events/list_open_commitments.ts
6090
- var DAY_MS3 = 864e5;
6091
- function mapResolution(resolvedAt, resolution) {
6092
- if (resolvedAt === null) {
6093
- return "open";
6094
- }
6095
- if (resolution === "cancelled") {
6096
- return "cancelled";
6097
- }
6098
- return "resolved";
6099
- }
6100
- function listOpenCommitments(db, filters, options) {
6101
- assertDefaultOrdering("listOpenCommitments", options);
6102
- const { limit, offset } = resolveListLimitOffset(options);
6103
- const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
6104
- const params = [];
6105
- if (options?.since !== void 0) {
6106
- clauses.push("e.occurred_at >= ?");
6107
- params.push(options.since);
6108
- }
6109
- if (options?.until !== void 0) {
6110
- clauses.push("e.occurred_at <= ?");
6111
- params.push(options.until);
6112
- }
6113
- if (filters?.commitmentType !== void 0) {
6114
- if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
6115
- throw new AffinityValidationError("invalid commitmentType filter");
6116
- }
6117
- clauses.push("oc.commitment_type = ?");
6118
- params.push(filters.commitmentType);
6119
- }
6120
- if (filters?.contactId !== void 0) {
6121
- clauses.push(
6122
- "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
6123
- );
6124
- params.push(filters.contactId);
6125
- }
6126
- if (filters?.linkId !== void 0) {
6127
- clauses.push(
6128
- "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
6129
- );
6130
- params.push(filters.linkId);
6131
- }
6132
- if (filters?.horizonDays !== void 0) {
6133
- if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
6134
- throw new AffinityValidationError(
6135
- "horizonDays must be a non-negative integer"
6136
- );
6137
- }
6138
- const anchor = options?.since ?? Date.now();
6139
- clauses.push("oc.due_at IS NOT NULL");
6140
- clauses.push("oc.due_at <= ?");
6141
- params.push(anchor + filters.horizonDays * DAY_MS3);
6142
- }
6143
- const where = clauses.join(" AND ");
6144
- const rows = db.prepare(
6145
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
6146
- e.type, e.summary
6147
- FROM open_commitments oc
6148
- INNER JOIN events e ON e.id = oc.event_id
6149
- WHERE ${where}
6150
- ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
6151
- LIMIT ? OFFSET ?`
6152
- ).all(...params, limit, offset);
6153
- const eventIds = rows.map((r) => r.event_id);
6154
- const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
6155
- return rows.map((r) => ({
6156
- eventId: r.event_id,
6157
- type: r.type,
6158
- summary: r.summary,
6159
- participants: partsByEvent.get(r.event_id) ?? [],
6160
- dueAt: r.due_at,
6161
- resolutionState: mapResolution(r.resolved_at, r.resolution)
6162
- }));
6163
- }
6164
-
6165
6920
  // src/graph/get_affinity_chart.ts
6166
6921
  function getAffinityChart(db, options) {
6167
6922
  const contactClauses = [
@@ -6368,13 +7123,15 @@ function listRadar(db, filters, options) {
6368
7123
  if (ownerId === null) {
6369
7124
  return [];
6370
7125
  }
7126
+ const now = resolveNow(options?.now);
7127
+ 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})`;
6371
7128
  const { limit, offset } = resolveListLimitOffset(options);
6372
7129
  const clauses = [
6373
7130
  "l.removed_at IS NULL",
6374
7131
  "l.is_structural = 0",
6375
- "l.from_contact_id = ?"
7132
+ "(l.from_contact_id = ? OR l.to_contact_id = ?)"
6376
7133
  ];
6377
- const params = [ownerId];
7134
+ const params = [ownerId, ownerId];
6378
7135
  if (filters?.kind !== void 0) {
6379
7136
  clauses.push("l.kind = ?");
6380
7137
  params.push(filters.kind);
@@ -6396,6 +7153,7 @@ function listRadar(db, filters, options) {
6396
7153
  const rows = db.prepare(
6397
7154
  `SELECT
6398
7155
  l.id,
7156
+ ${overdue} AS overdue_commitment,
6399
7157
  l.from_contact_id,
6400
7158
  l.to_contact_id,
6401
7159
  l.trust,
@@ -6404,17 +7162,18 @@ function listRadar(db, filters, options) {
6404
7162
  lr.recency_score,
6405
7163
  lr.normalized_rank
6406
7164
  FROM links l
6407
- INNER JOIN contacts c_to ON c_to.id = l.to_contact_id
7165
+ 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
6408
7166
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
6409
7167
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
6410
7168
  WHERE ${where}
6411
- ORDER BY COALESCE(lr.radar_score, 0) DESC
7169
+ ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
6412
7170
  LIMIT ? OFFSET ?`
6413
7171
  ).all(...params, limit, offset);
6414
7172
  return rows.map((row) => ({
6415
7173
  linkId: row.id,
6416
7174
  contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
6417
- driftPriority: row.drift_priority ?? 0,
7175
+ reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
7176
+ driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
6418
7177
  recencyScore: row.recency_score ?? 0,
6419
7178
  normalizedRank: row.normalized_rank ?? 0,
6420
7179
  trust: row.trust ?? 0,
@@ -6464,6 +7223,56 @@ function listDismissedDuplicates(db, options) {
6464
7223
  }));
6465
7224
  }
6466
7225
 
7226
+ // src/tools/enrich_review_item.ts
7227
+ function enrichReviewItem(db, item) {
7228
+ const name = (id) => {
7229
+ if (typeof id !== "number") return "";
7230
+ return db.prepare("SELECT name FROM contacts WHERE id = ?").get(id)?.name ?? `Contact #${id}`;
7231
+ };
7232
+ const result = { ...item };
7233
+ if (item.kind === "link") {
7234
+ 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,
7235
+ 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);
7236
+ if (link) {
7237
+ result.title = link.from_owner ? String(link.to_name) : link.to_owner ? String(link.from_name) : `${link.from_name} \xB7 ${link.to_name}`;
7238
+ result.subtitle = [link.kind, link.role].filter(Boolean).join(" \xB7 ");
7239
+ for (const key of ["affinity", "trust", "rank"])
7240
+ if (typeof link[key] === "number") result[key] = link[key];
7241
+ result.recencyScore ??= link.recency_score;
7242
+ result.driftPriority ??= link.drift_priority;
7243
+ result.readinessScore = link.readiness_score;
7244
+ result.normalizedRank ??= link.normalized_rank;
7245
+ result.reciprocityScore = link.reciprocity_score;
7246
+ result.radarScore = link.radar_score;
7247
+ }
7248
+ }
7249
+ if (item.kind === "event" && typeof item.linkId === "number" && !Array.isArray(item.participants)) {
7250
+ const link = db.prepare("SELECT from_contact_id, to_contact_id FROM links WHERE id = ?").get(item.linkId);
7251
+ if (link)
7252
+ result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
7253
+ }
7254
+ if (Array.isArray(item.participants)) {
7255
+ result.counterpartyNames = item.participants.map(
7256
+ (p) => name(p.contactId)
7257
+ );
7258
+ result.subtitle = [
7259
+ item.subtitle,
7260
+ result.counterpartyNames.join(", ")
7261
+ ].filter(Boolean).join(" \xB7 ");
7262
+ }
7263
+ if (typeof item.leftContactId === "number")
7264
+ result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
7265
+ if (typeof item.winnerContactId === "number")
7266
+ result.title = `Merged ${name(item.loserContactId)} into ${name(item.winnerContactId)}`;
7267
+ if (item.occursAt !== void 0 && item.kind === "contact")
7268
+ result.title = `${name(item.id)} \xB7 ${item.title}`;
7269
+ for (const [key, value] of Object.entries(result)) {
7270
+ if (typeof value === "number" && /score|priority|affinity|trust|rank/i.test(key))
7271
+ result[key] = Math.round(value * 1e3) / 1e3;
7272
+ }
7273
+ return result;
7274
+ }
7275
+
6467
7276
  // src/tools/tool_summary.ts
6468
7277
  function summarizeCount(count, singular) {
6469
7278
  if (count === 0) {
@@ -6478,6 +7287,7 @@ function summarizeCount(count, singular) {
6478
7287
  // src/tools/review_affinity_tool.ts
6479
7288
  function buildReadOptions(input) {
6480
7289
  return {
7290
+ ...input.now === void 0 ? {} : { now: input.now },
6481
7291
  ...input.limit === void 0 ? {} : { limit: input.limit },
6482
7292
  ...input.offset === void 0 ? {} : { offset: input.offset },
6483
7293
  ...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
@@ -6597,6 +7407,7 @@ function validateReviewInput(input) {
6597
7407
  }
6598
7408
  function duplicateCandidateItem(candidate) {
6599
7409
  return {
7410
+ ...candidate,
6600
7411
  kind: "contact",
6601
7412
  id: candidate.leftContactId,
6602
7413
  title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
@@ -6606,24 +7417,28 @@ function duplicateCandidateItem(candidate) {
6606
7417
  }
6607
7418
  function momentItem(moment) {
6608
7419
  return {
7420
+ ...moment,
7421
+ occurredAtIso: new Date(moment.occurredAt).toISOString(),
6609
7422
  kind: "event",
6610
7423
  id: moment.eventId,
6611
7424
  title: moment.summary,
6612
7425
  subtitle: moment.momentKind,
6613
- snippet: `link #${moment.linkId} at ${moment.occurredAt}`
7426
+ snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
6614
7427
  };
6615
7428
  }
6616
7429
  function commitmentItem(commitment) {
6617
7430
  return {
7431
+ ...commitment,
6618
7432
  kind: "event",
6619
7433
  id: commitment.eventId,
6620
7434
  title: commitment.summary,
6621
7435
  subtitle: commitment.type,
6622
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt ?? "none"}`
7436
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
6623
7437
  };
6624
7438
  }
6625
7439
  function radarItem(radar) {
6626
7440
  return {
7441
+ ...radar,
6627
7442
  kind: "link",
6628
7443
  id: radar.linkId,
6629
7444
  title: `Link #${radar.linkId}`,
@@ -6633,29 +7448,34 @@ function radarItem(radar) {
6633
7448
  }
6634
7449
  function upcomingDateItem(date) {
6635
7450
  return {
7451
+ ...date,
7452
+ occursAt: date.occursOn,
7453
+ occursAtIso: new Date(date.occursOn).toISOString(),
6636
7454
  kind: date.targetRef.kind,
6637
7455
  id: date.targetRef.id,
6638
7456
  title: date.summary,
6639
7457
  subtitle: date.recurrenceKind,
6640
- snippet: `occurs ${date.occursOn}`
7458
+ snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
6641
7459
  };
6642
7460
  }
6643
7461
  function mergeHistoryItem(record) {
6644
7462
  return {
7463
+ ...record,
6645
7464
  kind: "contact",
6646
7465
  id: record.winnerContactId,
6647
7466
  title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
6648
- subtitle: `at ${record.mergedAt}`,
7467
+ subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
6649
7468
  snippet: record.reasonSummary ?? void 0
6650
7469
  };
6651
7470
  }
6652
7471
  function dismissedPairItem(record) {
6653
7472
  return {
7473
+ ...record,
6654
7474
  kind: "contact",
6655
7475
  id: record.leftContactId,
6656
7476
  title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
6657
7477
  subtitle: record.reason ?? "no reason given",
6658
- snippet: `dismissed at ${record.dismissedAt}`
7478
+ snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
6659
7479
  };
6660
7480
  }
6661
7481
  function compactFilters(input) {
@@ -6665,7 +7485,16 @@ function compactFilters(input) {
6665
7485
  )
6666
7486
  );
6667
7487
  }
6668
- function reviewAffinityToolHandler(db, input) {
7488
+ function reviewAffinityToolHandler(db, rawInput) {
7489
+ const input = structuredClone(rawInput);
7490
+ const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
7491
+ if (inputError)
7492
+ return toolFailure(
7493
+ "protocol",
7494
+ "invalid_input",
7495
+ "Invalid input.",
7496
+ inputError
7497
+ );
6669
7498
  return withToolHandling(() => {
6670
7499
  const invalid = validateReviewInput(input);
6671
7500
  if (invalid) {
@@ -6679,6 +7508,9 @@ function reviewAffinityToolHandler(db, input) {
6679
7508
  items = listContacts(
6680
7509
  db,
6681
7510
  {
7511
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
7512
+ ...input.tag === void 0 ? {} : { tag: input.tag },
7513
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
6682
7514
  ...input.kind === void 0 ? {} : { kind: input.kind },
6683
7515
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
6684
7516
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -6709,7 +7541,7 @@ function reviewAffinityToolHandler(db, input) {
6709
7541
  "Provide `contact` when `view` is `events.contact_journal`."
6710
7542
  );
6711
7543
  }
6712
- const contact = resolveContactLocator(db, locator, "contact");
7544
+ const contact = resolveContactLocator(db, locator, "contact", true);
6713
7545
  if (!contact.ok) {
6714
7546
  return contact.result;
6715
7547
  }
@@ -6744,7 +7576,12 @@ function reviewAffinityToolHandler(db, input) {
6744
7576
  let contactId;
6745
7577
  let linkId;
6746
7578
  if (input.contact !== void 0) {
6747
- const contact = resolveContactLocator(db, input.contact, "contact");
7579
+ const contact = resolveContactLocator(
7580
+ db,
7581
+ input.contact,
7582
+ "contact",
7583
+ true
7584
+ );
6748
7585
  if (!contact.ok) {
6749
7586
  return contact.result;
6750
7587
  }
@@ -6773,7 +7610,12 @@ function reviewAffinityToolHandler(db, input) {
6773
7610
  let contactId;
6774
7611
  let linkId;
6775
7612
  if (input.contact !== void 0) {
6776
- const contact = resolveContactLocator(db, input.contact, "contact");
7613
+ const contact = resolveContactLocator(
7614
+ db,
7615
+ input.contact,
7616
+ "contact",
7617
+ true
7618
+ );
6777
7619
  if (!contact.ok) {
6778
7620
  return contact.result;
6779
7621
  }
@@ -6866,7 +7708,7 @@ function reviewAffinityToolHandler(db, input) {
6866
7708
  "Provide `contact` when `view` is `merges.history`."
6867
7709
  );
6868
7710
  }
6869
- const contact = resolveContactLocator(db, locator, "contact");
7711
+ const contact = resolveContactLocator(db, locator, "contact", true);
6870
7712
  if (!contact.ok) {
6871
7713
  return contact.result;
6872
7714
  }
@@ -6879,6 +7721,7 @@ function reviewAffinityToolHandler(db, input) {
6879
7721
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
6880
7722
  break;
6881
7723
  }
7724
+ items = items.map((item) => enrichReviewItem(db, item));
6882
7725
  return toolSuccess(
6883
7726
  summarizeCount(items.length, "item"),
6884
7727
  {
@@ -6911,12 +7754,16 @@ var reviewAffinityTool = defineAffinityTool({
6911
7754
  targetKinds: ["contact", "link", "event"],
6912
7755
  inputDescriptions: {
6913
7756
  view: "Which review surface to return.",
7757
+ now: "Optional current timestamp for overdue calculations.",
6914
7758
  contact: "Target contact locator for views that require one.",
6915
7759
  link: "Target link locator for views that require one.",
6916
7760
  contactIds: "Optional contact id subset for duplicate review or graph review.",
6917
7761
  kind: "Optional contact kind or link kind filter, depending on the view.",
6918
7762
  lifecycleState: "Optional contact lifecycle filter for the contact list view.",
6919
7763
  linkState: "Optional link state filter for link review views.",
7764
+ attribute: "Attribute name or name=value; arrays require all filters.",
7765
+ tag: "Tag name; arrays require all tags.",
7766
+ includeLost: "Whether lost contacts are included.",
6920
7767
  includeOwner: "Whether owner contacts should be included in contact review.",
6921
7768
  includeDormant: "Whether dormant links or contacts should be included where supported.",
6922
7769
  includeArchived: "Whether archived links should be included where supported.",
@@ -6953,6 +7800,9 @@ var reviewAffinityTool = defineAffinityTool({
6953
7800
  "merges.history",
6954
7801
  "merges.dismissed"
6955
7802
  ]),
7803
+ now: integerSchema(
7804
+ "Optional current timestamp for overdue calculations."
7805
+ ),
6956
7806
  contact: contactLocatorSchema9("Optional target contact locator."),
6957
7807
  link: linkLocatorSchema5("Optional target link locator."),
6958
7808
  contactIds: arraySchema(
@@ -6975,6 +7825,17 @@ var reviewAffinityTool = defineAffinityTool({
6975
7825
  "broken",
6976
7826
  "archived"
6977
7827
  ]),
7828
+ attribute: oneOfSchema([
7829
+ stringSchema("Attribute filter."),
7830
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
7831
+ ]),
7832
+ tag: oneOfSchema([
7833
+ stringSchema("Tag filter."),
7834
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
7835
+ ]),
7836
+ includeLost: booleanSchema(
7837
+ "Include lost contacts (search excludes them by default)."
7838
+ ),
6978
7839
  includeOwner: booleanSchema("Whether owner contacts are included."),
6979
7840
  includeDormant: booleanSchema("Whether dormant rows are included."),
6980
7841
  includeArchived: booleanSchema("Whether archived links are included."),
@@ -7019,92 +7880,101 @@ var reviewAffinityTool = defineAffinityTool({
7019
7880
  });
7020
7881
 
7021
7882
  // src/contacts/search_contacts.ts
7022
- function relevanceScore(name, queryLower, matchedName, matchedNormalizedIdentity) {
7023
- const nl = name.toLowerCase();
7024
- if (matchedNormalizedIdentity) return 1.1;
7025
- if (nl.startsWith(queryLower)) return 1;
7026
- if (matchedName) return 0.85;
7027
- return 0.7;
7028
- }
7029
7883
  function searchContacts(db, query, filters, options) {
7030
- const q = query.trim();
7031
- if (q.length === 0) {
7032
- return [];
7033
- }
7884
+ const q = foldSearchText(query.trim());
7885
+ if (!q) return [];
7034
7886
  assertDefaultOrdering("searchContacts", options);
7035
7887
  const { limit, offset } = resolveListLimitOffset(options);
7036
- const queryLower = q.toLowerCase();
7037
- const pattern = `%${q.replace(/%/g, "").replace(/_/g, "")}%`;
7038
- const normalizedPattern = `%${queryLower}%`;
7888
+ const fields = /* @__PURE__ */ new Map();
7889
+ const rows = db.prepare(`
7890
+ SELECT contact_id, value || ' ' || normalized_key AS text, 'identity:' || type AS source FROM identities WHERE removed_at IS NULL
7891
+ UNION ALL SELECT contact_id, name || ' ' || COALESCE(value, ''), 'attribute:' || name FROM attributes WHERE deleted_at IS NULL AND contact_id IS NOT NULL
7892
+ UNION ALL SELECT from_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
7893
+ UNION ALL SELECT to_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
7894
+ 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
7895
+ `).all();
7896
+ for (const row of rows) {
7897
+ const values = fields.get(row.contact_id) ?? [];
7898
+ values.push({ text: foldSearchText(row.text), source: row.source });
7899
+ fields.set(row.contact_id, values);
7900
+ }
7901
+ const tokens = q.split(/\s+/);
7902
+ const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
7903
+ const digits = q.replace(/\D/g, "").replace(/^00/, "");
7904
+ const attributeFilters = contactAttributeClauses(filters);
7039
7905
  const clauses = [
7040
- "c.deleted_at IS NULL",
7041
- "c.lifecycle_state != 'merged'"
7906
+ "deleted_at IS NULL",
7907
+ "lifecycle_state != 'merged'",
7908
+ ...attributeFilters.clauses
7042
7909
  ];
7043
- const params = [];
7910
+ const params = [...attributeFilters.params];
7044
7911
  if (filters?.kind !== void 0) {
7045
- clauses.push("c.kind = ?");
7912
+ clauses.push("kind = ?");
7046
7913
  params.push(filters.kind);
7047
7914
  }
7048
7915
  if (filters?.lifecycleState !== void 0) {
7049
- clauses.push("c.lifecycle_state = ?");
7916
+ clauses.push("lifecycle_state = ?");
7050
7917
  params.push(filters.lifecycleState);
7051
7918
  }
7052
- if (filters?.includeOwner === false) {
7053
- clauses.push("c.is_owner = 0");
7054
- }
7055
- if (filters?.includeDormant === false || options?.includeDormant === false) {
7056
- clauses.push("c.lifecycle_state != 'dormant'");
7057
- }
7058
- const where = clauses.join(" AND ");
7059
- const sql = `
7060
- SELECT c.id, c.name, c.kind, c.lifecycle_state, c.is_owner, c.merged_into_contact_id,
7061
- c.created_at, c.updated_at, c.deleted_at,
7062
- (
7063
- SELECT value
7064
- FROM identities primary_i
7065
- WHERE primary_i.contact_id = c.id AND primary_i.removed_at IS NULL
7066
- ORDER BY primary_i.id
7067
- LIMIT 1
7068
- ) AS primary_identity,
7069
- MAX(CASE WHEN i.normalized_key LIKE ? THEN 1 ELSE 0 END) AS matched_normalized_identity
7070
- FROM contacts c
7071
- LEFT JOIN identities i ON i.contact_id = c.id AND i.removed_at IS NULL
7072
- WHERE ${where}
7073
- AND (c.name LIKE ? OR i.value LIKE ? OR i.normalized_key LIKE ?)
7074
- GROUP BY c.id
7075
- `;
7076
- const rows = db.prepare(sql).all(
7077
- normalizedPattern,
7078
- ...params,
7079
- pattern,
7080
- pattern,
7081
- normalizedPattern
7082
- );
7083
- const scored = rows.map((r) => {
7084
- const item = mapContactRowToContactListItem(r);
7085
- const matchedName = r.name.toLowerCase().includes(queryLower);
7086
- const score = relevanceScore(
7087
- r.name,
7088
- queryLower,
7089
- matchedName,
7090
- Number(
7091
- r.matched_normalized_identity ?? 0
7092
- ) > 0
7919
+ if (filters?.includeOwner === false) clauses.push("is_owner = 0");
7920
+ const contacts = db.prepare(
7921
+ `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 ")}`
7922
+ ).all(...params).map((row) => ({
7923
+ ...mapContactRowToContactListItem(row),
7924
+ primaryIdentity: row.primary_identity
7925
+ }));
7926
+ const scored = [];
7927
+ for (const contact of contacts) {
7928
+ if (contact.lifecycleState === "dormant" && !(filters?.includeDormant ?? options?.includeDormant))
7929
+ continue;
7930
+ if (contact.lifecycleState === "lost" && !filters?.includeLost) continue;
7931
+ const name = foldSearchText(contact.name);
7932
+ const values = [
7933
+ { text: name, source: "name" },
7934
+ ...(fields.get(contact.id) ?? []).filter(
7935
+ (field) => !contact.isOwner || field.source !== "event" && field.source !== "link_role"
7936
+ )
7937
+ ];
7938
+ const phoneFields = phone ? values.filter(
7939
+ (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
7940
+ ) : [];
7941
+ if (!phoneFields.length && !tokens.every(
7942
+ (token) => values.some((field) => field.text.includes(token))
7943
+ ))
7944
+ continue;
7945
+ const matchedOn = [
7946
+ ...new Set(
7947
+ [
7948
+ ...values.filter(
7949
+ (field) => tokens.some((token) => field.text.includes(token))
7950
+ ),
7951
+ ...phoneFields
7952
+ ].map((field) => field.source)
7953
+ )
7954
+ ];
7955
+ const identities = values.filter(
7956
+ (field) => field.source.startsWith("identity:")
7093
7957
  );
7094
- return {
7095
- item: {
7096
- ...item,
7097
- primaryIdentity: r.primary_identity ?? null
7098
- },
7099
- score
7100
- };
7101
- });
7102
- scored.sort((a, b) => b.score - a.score);
7103
- return scored.slice(offset, offset + limit).map((s) => s.item);
7958
+ 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(
7959
+ (field) => tokens.every((token) => field.text.includes(token))
7960
+ ) ? 2 : 1;
7961
+ scored.push({ item: { ...contact, matchedOn }, score });
7962
+ }
7963
+ scored.sort((a, b) => b.score - a.score || a.item.id - b.item.id);
7964
+ return scored.slice(offset, offset + limit).map(({ item }) => item);
7104
7965
  }
7105
7966
 
7106
7967
  // src/tools/search_affinity_tool.ts
7107
- function searchAffinityToolHandler(db, input) {
7968
+ function searchAffinityToolHandler(db, rawInput) {
7969
+ const input = structuredClone(rawInput);
7970
+ const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
7971
+ if (inputError)
7972
+ return toolFailure(
7973
+ "protocol",
7974
+ "invalid_input",
7975
+ "Invalid input.",
7976
+ inputError
7977
+ );
7108
7978
  return withToolHandling(() => {
7109
7979
  const options = {
7110
7980
  ...input.limit === void 0 ? {} : { limit: input.limit },
@@ -7114,6 +7984,9 @@ function searchAffinityToolHandler(db, input) {
7114
7984
  db,
7115
7985
  input.query,
7116
7986
  {
7987
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
7988
+ ...input.tag === void 0 ? {} : { tag: input.tag },
7989
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
7117
7990
  ...input.kind === void 0 ? {} : { kind: input.kind },
7118
7991
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
7119
7992
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -7137,7 +8010,7 @@ function searchAffinityToolHandler(db, input) {
7137
8010
  }
7138
8011
  var searchAffinityTool = defineAffinityTool({
7139
8012
  name: searchAffinityToolName,
7140
- description: "Search contacts by name or identity, including exact natural-key lookups like email-style identifiers.",
8013
+ 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.",
7141
8014
  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.",
7142
8015
  whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
7143
8016
  sideEffects: "none",
@@ -7148,6 +8021,9 @@ var searchAffinityTool = defineAffinityTool({
7148
8021
  query: "Free-text or natural-key search text.",
7149
8022
  kind: "Optional contact kind filter.",
7150
8023
  lifecycleState: "Optional lifecycle state filter.",
8024
+ attribute: "Attribute name or name=value; arrays require all filters.",
8025
+ tag: "Tag name; arrays require all tags.",
8026
+ includeLost: "Whether lost contacts are included in search.",
7151
8027
  includeOwner: "Whether owner contacts are eligible for results.",
7152
8028
  includeDormant: "Whether dormant contacts should be eligible for results.",
7153
8029
  limit: "Optional result limit.",
@@ -7172,6 +8048,17 @@ var searchAffinityTool = defineAffinityTool({
7172
8048
  "merged",
7173
8049
  "lost"
7174
8050
  ]),
8051
+ attribute: oneOfSchema([
8052
+ stringSchema("Attribute filter."),
8053
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
8054
+ ]),
8055
+ tag: oneOfSchema([
8056
+ stringSchema("Tag filter."),
8057
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
8058
+ ]),
8059
+ includeLost: booleanSchema(
8060
+ "Include lost contacts (search excludes them by default)."
8061
+ ),
7175
8062
  includeOwner: booleanSchema("Whether owner contacts are included."),
7176
8063
  includeDormant: booleanSchema("Whether dormant contacts are included."),
7177
8064
  limit: integerSchema("Optional result limit."),
@@ -7199,6 +8086,94 @@ var affinityTools = [
7199
8086
  manageAttributeTool
7200
8087
  ];
7201
8088
 
8089
+ // src/surface/plain_dates.ts
8090
+ function parsePlainDue(raw, now = Date.now(), endOfDay = true) {
8091
+ const invalid = () => new CliUsageError(
8092
+ `Invalid timestamp "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d, -2d or an ISO date-time.`
8093
+ );
8094
+ if (/^-?\d+$/.test(raw)) {
8095
+ const value = Number(raw);
8096
+ if (Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()))
8097
+ return value;
8098
+ throw invalid();
8099
+ }
8100
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
8101
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
8102
+ let date;
8103
+ if (iso || european) {
8104
+ const year = Number(iso?.[1] ?? european?.[3]);
8105
+ const month = Number(iso?.[2] ?? european?.[2]);
8106
+ const day = Number(iso?.[3] ?? european?.[1]);
8107
+ date = /* @__PURE__ */ new Date(0);
8108
+ date.setFullYear(year, month - 1, day);
8109
+ date.setHours(
8110
+ endOfDay ? 23 : 0,
8111
+ endOfDay ? 59 : 0,
8112
+ endOfDay ? 59 : 0,
8113
+ endOfDay ? 999 : 0
8114
+ );
8115
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
8116
+ throw invalid();
8117
+ } else if (raw === "today" || raw === "tomorrow" || raw === "yesterday" || /^[+-]\d+d$/.test(raw)) {
8118
+ date = new Date(now);
8119
+ date.setDate(
8120
+ date.getDate() + (raw === "today" ? 0 : raw === "tomorrow" ? 1 : raw === "yesterday" ? -1 : Number(raw.slice(0, -1)))
8121
+ );
8122
+ date.setHours(
8123
+ endOfDay ? 23 : 0,
8124
+ endOfDay ? 59 : 0,
8125
+ endOfDay ? 59 : 0,
8126
+ endOfDay ? 999 : 0
8127
+ );
8128
+ } else {
8129
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
8130
+ raw
8131
+ ))
8132
+ throw invalid();
8133
+ parsePlainDue(raw.slice(0, 10), now);
8134
+ date = new Date(raw);
8135
+ }
8136
+ if (!Number.isFinite(date.getTime())) throw invalid();
8137
+ return date.getTime();
8138
+ }
8139
+ function parsePlainStart(raw, now = Date.now()) {
8140
+ return parsePlainDue(raw, now, false);
8141
+ }
8142
+ function normalizeTimeInputs(value, now = Date.now()) {
8143
+ if (Array.isArray(value))
8144
+ return value.map((item) => normalizeTimeInputs(item, now));
8145
+ if (!value || typeof value !== "object") return value;
8146
+ return Object.fromEntries(
8147
+ Object.entries(value).map(([key, item]) => [
8148
+ key,
8149
+ typeof item === "string" && (key.endsWith("At") || ["asOf", "now"].includes(key)) ? key === "dueAt" ? parsePlainDue(item, now) : parsePlainStart(item, now) : normalizeTimeInputs(item, now)
8150
+ ])
8151
+ );
8152
+ }
8153
+
8154
+ // src/surface/timestamp_schema.ts
8155
+ function acceptsString(schema) {
8156
+ return schema.type === "string" || Array.isArray(schema.type) && schema.type.includes("string") || (schema.oneOf?.some(acceptsString) ?? false);
8157
+ }
8158
+ function timestampSchema(schema) {
8159
+ return {
8160
+ ...schema,
8161
+ ...schema.properties ? {
8162
+ properties: Object.fromEntries(
8163
+ Object.entries(schema.properties).map(([key, value]) => [
8164
+ key,
8165
+ key.endsWith("At") || ["asOf", "now"].includes(key) ? acceptsString(value) ? value : {
8166
+ description: value.description,
8167
+ oneOf: [value, { type: "string" }]
8168
+ } : timestampSchema(value)
8169
+ ])
8170
+ )
8171
+ } : {},
8172
+ ...schema.items ? { items: timestampSchema(schema.items) } : {},
8173
+ ...schema.oneOf ? { oneOf: schema.oneOf.map(timestampSchema) } : {}
8174
+ };
8175
+ }
8176
+
7202
8177
  // src/surface/affinity_surface_tools.ts
7203
8178
  var affinitySurfaceTools = affinityTools.map((tool) => ({
7204
8179
  name: tool.name,
@@ -7207,8 +8182,24 @@ var affinitySurfaceTools = affinityTools.map((tool) => ({
7207
8182
  ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
7208
8183
  readOnly: tool.readOnly,
7209
8184
  sideEffects: tool.sideEffects,
7210
- inputSchema: { ...tool.inputSchema, type: "object" },
7211
- handler: tool.handler
8185
+ inputSchema: {
8186
+ ...timestampSchema({ ...tool.inputSchema, type: "object" }),
8187
+ type: "object"
8188
+ },
8189
+ handler: (db, input) => {
8190
+ try {
8191
+ return tool.handler(db, normalizeTimeInputs(input));
8192
+ } catch (error) {
8193
+ if (error instanceof CliUsageError)
8194
+ return toolFailure(
8195
+ "protocol",
8196
+ "invalid_input",
8197
+ "Invalid input.",
8198
+ error.message
8199
+ );
8200
+ throw error;
8201
+ }
8202
+ }
7212
8203
  }));
7213
8204
 
7214
8205
  // src/surface/affinity_mcp_options.ts
@@ -7490,6 +8481,12 @@ function initLinkEventEffectsTables(db) {
7490
8481
  )
7491
8482
  )
7492
8483
  `);
8484
+ const columns = db.prepare("PRAGMA table_info(link_event_effects)").all();
8485
+ if (!columns.some((column) => column.name === "counts_for_recency")) {
8486
+ db.exec(
8487
+ "ALTER TABLE link_event_effects ADD COLUMN counts_for_recency INTEGER NOT NULL DEFAULT 1"
8488
+ );
8489
+ }
7493
8490
  db.exec(
7494
8491
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
7495
8492
  );
@@ -7681,7 +8678,8 @@ async function openAffinityDb(path) {
7681
8678
  }
7682
8679
  }
7683
8680
  function resolveAffinityDbPath(options = {}) {
7684
- return options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || node_path.join(node_os.homedir(), ".ghostpaw", "affinity.db"));
8681
+ const path = options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || node_path.join(node_os.homedir(), ".ghostpaw", "affinity.db"));
8682
+ return path === ":memory:" ? path : node_path.resolve(path);
7685
8683
  }
7686
8684
 
7687
8685
  // src/surfaces/surface_types.ts
@@ -7908,10 +8906,10 @@ async function serveMcpStdio(server, options = {}) {
7908
8906
  const response = await server.handleLine(line.replace(/\r$/, ""));
7909
8907
  if (response !== void 0)
7910
8908
  await new Promise(
7911
- (resolve, reject) => output.write(
8909
+ (resolve2, reject) => output.write(
7912
8910
  `${response}
7913
8911
  `,
7914
- (error) => error ? reject(error) : resolve()
8912
+ (error) => error ? reject(error) : resolve2()
7915
8913
  )
7916
8914
  );
7917
8915
  };