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