@ghostpaw/affinity 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, mkdirSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { readFileSync, mkdirSync, existsSync } from 'node:fs';
4
3
  import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { isSea } from 'node:sea';
5
6
  import { Writable } from 'node:stream';
6
7
 
7
8
  var __defProp = Object.defineProperty;
@@ -23,7 +24,122 @@ var __export = (target, all) => {
23
24
  var affinityVersion;
24
25
  var init_version = __esm({
25
26
  "src/version.ts"() {
26
- affinityVersion = "0.4.0";
27
+ affinityVersion = "0.5.0";
28
+ }
29
+ });
30
+ function parseCliArgs(argv, spec) {
31
+ const values = {};
32
+ const positions = [];
33
+ const flags = spec.flags ?? [];
34
+ const convert = (raw, arg) => {
35
+ let value = raw;
36
+ if (raw.startsWith("@")) {
37
+ try {
38
+ value = readFileSync(raw.slice(1), "utf8");
39
+ } catch {
40
+ throw new CliUsageError(`Cannot read value file: ${raw.slice(1)}`);
41
+ }
42
+ }
43
+ let result = value;
44
+ if (arg.type === "number" || arg.type === "integer") {
45
+ result = Number(value);
46
+ if (!value.trim() || !Number.isFinite(result) || arg.type === "integer" && !Number.isInteger(result))
47
+ throw new CliUsageError(
48
+ `Expected ${arg.type} for ${arg.name}: ${value}`
49
+ );
50
+ } else if (arg.type === "boolean") {
51
+ if (!["true", "false"].includes(value))
52
+ throw new CliUsageError(`Expected boolean for ${arg.name}`);
53
+ result = value === "true";
54
+ } else if (arg.type === "json") {
55
+ try {
56
+ result = JSON.parse(value);
57
+ } catch {
58
+ throw new CliUsageError(`Invalid JSON for ${arg.name}`);
59
+ }
60
+ }
61
+ if (arg.enum && !arg.enum.includes(result))
62
+ throw new CliUsageError(
63
+ `${arg.name} must be one of: ${arg.enum.join(", ")}`
64
+ );
65
+ return result;
66
+ };
67
+ let ended = false;
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const token = argv[i];
70
+ if (!ended && token === "--") {
71
+ ended = true;
72
+ continue;
73
+ }
74
+ if (ended || token === "-" || !token.startsWith("-")) {
75
+ positions.push(token);
76
+ continue;
77
+ }
78
+ const [flag, inline] = token.split(/=(.*)/s);
79
+ const negative = flag?.startsWith("--no-");
80
+ const name = flag?.replace(/^--?/, "").replace(/^no-/, "");
81
+ const arg = flags.find(
82
+ (entry) => entry.name === name || keyOf(entry.name) === name || entry.short === name
83
+ );
84
+ if (!arg) {
85
+ const prefix = (name ?? "").slice(0, 3);
86
+ const suggestion = prefix ? flags.find((entry) => entry.name.startsWith(prefix)) : void 0;
87
+ throw new CliUsageError(
88
+ `Unknown flag: ${flag}${suggestion ? `; did you mean --${suggestion.name}?` : ""}`
89
+ );
90
+ }
91
+ if (negative && arg.type !== "boolean")
92
+ throw new CliUsageError(`--no-${arg.name} requires a boolean flag`);
93
+ let raw = inline;
94
+ if (arg.type === "boolean" && raw === void 0)
95
+ raw = negative ? "false" : "true";
96
+ if (raw === void 0) {
97
+ const next = argv[i + 1];
98
+ if (next === void 0 || /^--[a-z]/i.test(next))
99
+ throw new CliUsageError(`Missing value: --${arg.name}`);
100
+ raw = next;
101
+ i++;
102
+ }
103
+ const key = keyOf(arg.name);
104
+ if (arg.type === "array") {
105
+ const item = convert(raw, { ...arg, type: arg.itemType ?? "string" });
106
+ values[key] = [...values[key] ?? [], item];
107
+ } else values[key] = convert(raw, arg);
108
+ }
109
+ let index = 0;
110
+ for (const arg of spec.positionals ?? []) {
111
+ if (index >= positions.length) continue;
112
+ values[keyOf(arg.name)] = arg.variadic ? positions.slice(index).map(
113
+ (value) => convert(value, { ...arg, type: arg.itemType ?? arg.type })
114
+ ) : convert(positions[index], arg);
115
+ index = arg.variadic ? positions.length : index + 1;
116
+ }
117
+ if (index < positions.length)
118
+ throw new CliUsageError(`Unexpected positional: ${positions[index]}`);
119
+ for (const arg of [...flags, ...spec.positionals ?? []]) {
120
+ if (arg.required && values[keyOf(arg.name)] === void 0)
121
+ throw new CliUsageError(`Missing required argument: ${arg.name}`);
122
+ }
123
+ return values;
124
+ }
125
+ function renderCliHelp(spec) {
126
+ const describe = (arg) => `${arg.type}${arg.enum ? ` (${arg.enum.join("|")})` : ""}${arg.required ? " required" : ""} ${arg.description ?? ""}`;
127
+ return `Usage: ${spec.command} ${(spec.positionals ?? []).map((arg) => `<${arg.name}${arg.variadic ? "..." : ""}>`).join(" ")} [flags]
128
+ ${spec.description ?? ""}
129
+
130
+ Positionals:
131
+ ${(spec.positionals ?? []).map((arg) => ` ${arg.name} ${describe(arg)}`).join("\n")}
132
+ Flags:
133
+ ${(spec.flags ?? []).map((arg) => ` --${arg.name}${arg.short ? `, -${arg.short}` : ""} ${describe(arg)}`).join("\n")}
134
+ `;
135
+ }
136
+ var CliUsageError, keyOf;
137
+ var init_cli_args = __esm({
138
+ "src/surfaces/cli_args.ts"() {
139
+ CliUsageError = class extends Error {
140
+ exitCode = 2;
141
+ };
142
+ keyOf = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
27
143
  }
28
144
  });
29
145
 
@@ -41,6 +157,78 @@ var init_affinity_error = __esm({
41
157
  }
42
158
  });
43
159
 
160
+ // src/lib/errors/affinity_validation_error.ts
161
+ var AffinityValidationError;
162
+ var init_affinity_validation_error = __esm({
163
+ "src/lib/errors/affinity_validation_error.ts"() {
164
+ init_affinity_error();
165
+ AffinityValidationError = class extends AffinityError {
166
+ code = "VALIDATION";
167
+ };
168
+ }
169
+ });
170
+
171
+ // src/lib/read/assert_default_ordering.ts
172
+ function assertDefaultOrdering(_readName, options) {
173
+ if (options?.sort !== void 0 || options?.descending !== void 0) {
174
+ throw new AffinityValidationError(
175
+ "This review does not support custom sort ordering"
176
+ );
177
+ }
178
+ }
179
+ var init_assert_default_ordering = __esm({
180
+ "src/lib/read/assert_default_ordering.ts"() {
181
+ init_affinity_validation_error();
182
+ }
183
+ });
184
+
185
+ // src/lib/types/read_list_options.ts
186
+ var AFFINITY_DEFAULT_LIST_LIMIT, AFFINITY_MAX_LIST_LIMIT;
187
+ var init_read_list_options = __esm({
188
+ "src/lib/types/read_list_options.ts"() {
189
+ AFFINITY_DEFAULT_LIST_LIMIT = 50;
190
+ AFFINITY_MAX_LIST_LIMIT = 250;
191
+ }
192
+ });
193
+
194
+ // src/lib/types/resolve_affinity_list_limit.ts
195
+ function resolveAffinityListLimit(limit) {
196
+ if (limit === void 0) {
197
+ return AFFINITY_DEFAULT_LIST_LIMIT;
198
+ }
199
+ if (!Number.isInteger(limit) || limit < 1) {
200
+ throw new AffinityValidationError("limit must be a positive integer");
201
+ }
202
+ if (limit > AFFINITY_MAX_LIST_LIMIT) {
203
+ throw new AffinityValidationError(
204
+ `limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
205
+ );
206
+ }
207
+ return limit;
208
+ }
209
+ var init_resolve_affinity_list_limit = __esm({
210
+ "src/lib/types/resolve_affinity_list_limit.ts"() {
211
+ init_affinity_validation_error();
212
+ init_read_list_options();
213
+ }
214
+ });
215
+
216
+ // src/lib/read/resolve_list_limit_offset.ts
217
+ function resolveListLimitOffset(options) {
218
+ const limit = resolveAffinityListLimit(options?.limit);
219
+ const offset = options?.offset ?? 0;
220
+ if (!Number.isInteger(offset) || offset < 0) {
221
+ throw new AffinityValidationError("offset must be a non-negative integer");
222
+ }
223
+ return { limit, offset };
224
+ }
225
+ var init_resolve_list_limit_offset = __esm({
226
+ "src/lib/read/resolve_list_limit_offset.ts"() {
227
+ init_affinity_validation_error();
228
+ init_resolve_affinity_list_limit();
229
+ }
230
+ });
231
+
44
232
  // src/lib/errors/affinity_invariant_error.ts
45
233
  var AffinityInvariantError;
46
234
  var init_affinity_invariant_error = __esm({
@@ -52,6 +240,17 @@ var init_affinity_invariant_error = __esm({
52
240
  }
53
241
  });
54
242
 
243
+ // src/lib/errors/affinity_not_found_error.ts
244
+ var AffinityNotFoundError;
245
+ var init_affinity_not_found_error = __esm({
246
+ "src/lib/errors/affinity_not_found_error.ts"() {
247
+ init_affinity_error();
248
+ AffinityNotFoundError = class extends AffinityError {
249
+ code = "NOT_FOUND";
250
+ };
251
+ }
252
+ });
253
+
55
254
  // src/contacts/queries.ts
56
255
  function getContactRowById(db, id) {
57
256
  const row = db.prepare(
@@ -88,17 +287,6 @@ var init_queries = __esm({
88
287
  }
89
288
  });
90
289
 
91
- // src/lib/errors/affinity_not_found_error.ts
92
- var AffinityNotFoundError;
93
- var init_affinity_not_found_error = __esm({
94
- "src/lib/errors/affinity_not_found_error.ts"() {
95
- init_affinity_error();
96
- AffinityNotFoundError = class extends AffinityError {
97
- code = "NOT_FOUND";
98
- };
99
- }
100
- });
101
-
102
290
  // src/lib/errors/affinity_state_error.ts
103
291
  var AffinityStateError;
104
292
  var init_affinity_state_error = __esm({
@@ -110,17 +298,6 @@ var init_affinity_state_error = __esm({
110
298
  }
111
299
  });
112
300
 
113
- // src/lib/errors/affinity_validation_error.ts
114
- var AffinityValidationError;
115
- var init_affinity_validation_error = __esm({
116
- "src/lib/errors/affinity_validation_error.ts"() {
117
- init_affinity_error();
118
- AffinityValidationError = class extends AffinityError {
119
- code = "VALIDATION";
120
- };
121
- }
122
- });
123
-
124
301
  // src/events/validators.ts
125
302
  function assertOwnerParticipates(ownerId, participantIds) {
126
303
  if (!participantIds.includes(ownerId)) {
@@ -134,7 +311,10 @@ function assertParticipantContactsLive(db, contactIds) {
134
311
  throw new AffinityNotFoundError("contact not found");
135
312
  }
136
313
  if (row.lifecycle_state === "merged") {
137
- throw new AffinityStateError("merged contact is read-only");
314
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
315
+ throw new AffinityStateError(
316
+ `Contact #${id} was merged into #${row.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row.merged_into_contact_id}`
317
+ );
138
318
  }
139
319
  }
140
320
  }
@@ -345,29 +525,261 @@ var init_queries2 = __esm({
345
525
  }
346
526
  });
347
527
 
348
- // src/resolve_now.ts
349
- function resolveNow(now) {
350
- if (now !== void 0) {
351
- if (!Number.isFinite(now) || now < 0) {
352
- throw new AffinityValidationError(
353
- "now must be a finite non-negative number"
354
- );
355
- }
356
- return now;
528
+ // src/events/loaders.ts
529
+ function loadEventParticipantViews(db, eventId) {
530
+ const rows = db.prepare(
531
+ `SELECT contact_id, role, directionality
532
+ FROM event_participants
533
+ WHERE event_id = ?`
534
+ ).all(eventId);
535
+ return rows.map((row) => ({
536
+ contactId: row.contact_id,
537
+ role: row.role,
538
+ ...row.directionality === null ? {} : { directionality: row.directionality }
539
+ }));
540
+ }
541
+ function loadEventRecord(db, eventId) {
542
+ const row = getEventRowById(db, eventId);
543
+ if (!row) {
544
+ throw new AffinityInvariantError("event not found");
357
545
  }
358
- return Date.now();
546
+ const participants = loadEventParticipantViews(db, eventId);
547
+ return mapEventRowToEventRecord(row, participants);
359
548
  }
360
- var init_resolve_now = __esm({
361
- "src/resolve_now.ts"() {
362
- init_affinity_validation_error();
549
+ function batchLoadEventRecords(db, eventIds) {
550
+ if (eventIds.length === 0) {
551
+ return [];
363
552
  }
364
- });
365
-
366
- // src/with_transaction.ts
367
- function withTransaction(db, fn) {
368
- const depth = transactionDepth.get(db) ?? 0;
369
- const savepoint = `affinity_${String(depth)}`;
370
- if (depth === 0) {
553
+ const ph = eventIds.map(() => "?").join(", ");
554
+ const eventRows = db.prepare(
555
+ `SELECT id, type, occurred_at, summary, significance, moment_kind,
556
+ recurrence_kind, anchor_month, anchor_day,
557
+ anchor_contact_id, anchor_link_id,
558
+ created_at, updated_at, deleted_at
559
+ FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
560
+ ).all(...eventIds);
561
+ const partRows = db.prepare(
562
+ `SELECT event_id, contact_id, role, directionality
563
+ FROM event_participants WHERE event_id IN (${ph})`
564
+ ).all(...eventIds);
565
+ const partsByEvent = /* @__PURE__ */ new Map();
566
+ for (const p of partRows) {
567
+ let arr = partsByEvent.get(p.event_id);
568
+ if (!arr) {
569
+ arr = [];
570
+ partsByEvent.set(p.event_id, arr);
571
+ }
572
+ arr.push({
573
+ contactId: p.contact_id,
574
+ role: p.role,
575
+ ...p.directionality === null ? {} : { directionality: p.directionality }
576
+ });
577
+ }
578
+ const rowById = new Map(eventRows.map((r) => [r.id, r]));
579
+ const out = [];
580
+ for (const id of eventIds) {
581
+ const row = rowById.get(id);
582
+ if (row) {
583
+ out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
584
+ }
585
+ }
586
+ return out;
587
+ }
588
+ function batchLoadEventParticipantViews(db, eventIds) {
589
+ const result = /* @__PURE__ */ new Map();
590
+ if (eventIds.length === 0) {
591
+ return result;
592
+ }
593
+ const ph = eventIds.map(() => "?").join(", ");
594
+ const rows = db.prepare(
595
+ `SELECT event_id, contact_id, role, directionality
596
+ FROM event_participants WHERE event_id IN (${ph})`
597
+ ).all(...eventIds);
598
+ for (const row of rows) {
599
+ let arr = result.get(row.event_id);
600
+ if (!arr) {
601
+ arr = [];
602
+ result.set(row.event_id, arr);
603
+ }
604
+ arr.push({
605
+ contactId: row.contact_id,
606
+ role: row.role,
607
+ ...row.directionality === null ? {} : { directionality: row.directionality }
608
+ });
609
+ }
610
+ return result;
611
+ }
612
+ function requireDateAnchorEvent(db, eventId) {
613
+ const row = getEventRowById(db, eventId);
614
+ if (!row) {
615
+ throw new AffinityNotFoundError("event not found");
616
+ }
617
+ if (row.type !== "date_anchor") {
618
+ throw new AffinityInvariantError("not a date anchor");
619
+ }
620
+ return row;
621
+ }
622
+ var init_loaders = __esm({
623
+ "src/events/loaders.ts"() {
624
+ init_affinity_invariant_error();
625
+ init_affinity_not_found_error();
626
+ init_mappers();
627
+ init_queries2();
628
+ }
629
+ });
630
+
631
+ // src/events/get_contact_journal.ts
632
+ function getContactJournal(db, contactId, options) {
633
+ assertDefaultOrdering("getContactJournal", options);
634
+ const { limit, offset } = resolveListLimitOffset(options);
635
+ const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
636
+ const params = [contactId];
637
+ if (options?.since !== void 0) {
638
+ clauses.push("e.occurred_at >= ?");
639
+ params.push(options.since);
640
+ }
641
+ if (options?.until !== void 0) {
642
+ clauses.push("e.occurred_at <= ?");
643
+ params.push(options.until);
644
+ }
645
+ if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
646
+ const ph = options.eventTypes.map(() => "?").join(", ");
647
+ clauses.push(`e.type IN (${ph})`);
648
+ params.push(...options.eventTypes);
649
+ }
650
+ const where = clauses.join(" AND ");
651
+ const rows = db.prepare(
652
+ `SELECT e.id FROM events e
653
+ INNER JOIN event_participants ep ON ep.event_id = e.id
654
+ WHERE ${where}
655
+ ORDER BY e.occurred_at DESC, e.id DESC
656
+ LIMIT ? OFFSET ?`
657
+ ).all(...params, limit, offset);
658
+ return batchLoadEventRecords(
659
+ db,
660
+ rows.map((r) => r.id)
661
+ );
662
+ }
663
+ var init_get_contact_journal = __esm({
664
+ "src/events/get_contact_journal.ts"() {
665
+ init_assert_default_ordering();
666
+ init_resolve_list_limit_offset();
667
+ init_loaders();
668
+ }
669
+ });
670
+
671
+ // src/resolve_now.ts
672
+ function resolveNow(now) {
673
+ if (now !== void 0) {
674
+ if (!Number.isFinite(now) || now < 0) {
675
+ throw new AffinityValidationError(
676
+ "now must be a finite non-negative number"
677
+ );
678
+ }
679
+ return now;
680
+ }
681
+ return Date.now();
682
+ }
683
+ var init_resolve_now = __esm({
684
+ "src/resolve_now.ts"() {
685
+ init_affinity_validation_error();
686
+ }
687
+ });
688
+
689
+ // src/events/list_open_commitments.ts
690
+ function mapResolution(resolvedAt, resolution) {
691
+ if (resolvedAt === null) {
692
+ return "open";
693
+ }
694
+ if (resolution === "cancelled") {
695
+ return "cancelled";
696
+ }
697
+ return "resolved";
698
+ }
699
+ function listOpenCommitments(db, filters, options) {
700
+ assertDefaultOrdering("listOpenCommitments", options);
701
+ const now = resolveNow(options?.now);
702
+ const { limit, offset } = resolveListLimitOffset(options);
703
+ const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
704
+ const params = [];
705
+ if (options?.since !== void 0) {
706
+ clauses.push("e.occurred_at >= ?");
707
+ params.push(options.since);
708
+ }
709
+ if (options?.until !== void 0) {
710
+ clauses.push("e.occurred_at <= ?");
711
+ params.push(options.until);
712
+ }
713
+ if (filters?.commitmentType !== void 0) {
714
+ if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
715
+ throw new AffinityValidationError("invalid commitmentType filter");
716
+ }
717
+ clauses.push("oc.commitment_type = ?");
718
+ params.push(filters.commitmentType);
719
+ }
720
+ if (filters?.contactId !== void 0) {
721
+ clauses.push(
722
+ "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
723
+ );
724
+ params.push(filters.contactId);
725
+ }
726
+ if (filters?.linkId !== void 0) {
727
+ clauses.push(
728
+ "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
729
+ );
730
+ params.push(filters.linkId);
731
+ }
732
+ if (filters?.horizonDays !== void 0) {
733
+ if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
734
+ throw new AffinityValidationError(
735
+ "horizonDays must be a non-negative integer"
736
+ );
737
+ }
738
+ const anchor = options?.since ?? now;
739
+ clauses.push("oc.due_at IS NOT NULL");
740
+ clauses.push("oc.due_at <= ?");
741
+ params.push(anchor + filters.horizonDays * DAY_MS);
742
+ }
743
+ const where = clauses.join(" AND ");
744
+ const rows = db.prepare(
745
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
746
+ e.type, e.summary
747
+ FROM open_commitments oc
748
+ INNER JOIN events e ON e.id = oc.event_id
749
+ WHERE ${where}
750
+ ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
751
+ LIMIT ? OFFSET ?`
752
+ ).all(...params, limit, offset);
753
+ const eventIds = rows.map((r) => r.event_id);
754
+ const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
755
+ return rows.map((r) => ({
756
+ eventId: r.event_id,
757
+ type: r.type,
758
+ summary: r.summary,
759
+ participants: partsByEvent.get(r.event_id) ?? [],
760
+ dueAt: r.due_at,
761
+ dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
762
+ overdue: r.due_at !== null && r.due_at < now,
763
+ resolutionState: mapResolution(r.resolved_at, r.resolution)
764
+ }));
765
+ }
766
+ var DAY_MS;
767
+ var init_list_open_commitments = __esm({
768
+ "src/events/list_open_commitments.ts"() {
769
+ init_affinity_validation_error();
770
+ init_assert_default_ordering();
771
+ init_resolve_list_limit_offset();
772
+ init_resolve_now();
773
+ init_loaders();
774
+ DAY_MS = 864e5;
775
+ }
776
+ });
777
+
778
+ // src/with_transaction.ts
779
+ function withTransaction(db, fn) {
780
+ const depth = transactionDepth.get(db) ?? 0;
781
+ const savepoint = `affinity_${String(depth)}`;
782
+ if (depth === 0) {
371
783
  db.exec("BEGIN IMMEDIATE");
372
784
  } else {
373
785
  db.exec(`SAVEPOINT ${savepoint}`);
@@ -667,13 +1079,20 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
667
1079
  const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
668
1080
  const activeDates = loadActiveDatesForContact(db, contactId);
669
1081
  const contactRollup = loadContactRollup(db, contactId);
1082
+ const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
670
1083
  return {
1084
+ ...winner ? { mergedInto: { id: winner.id, name: winner.name } } : {},
671
1085
  contact: mapContactRowToContactCore(row),
672
1086
  identities,
673
1087
  attributes,
674
1088
  topLinks,
675
1089
  activeDates,
676
- warnings: contactRollup?.warnings ?? [],
1090
+ warnings: [
1091
+ ...contactRollup?.warnings ?? [],
1092
+ ...winner ? [
1093
+ `Contact #${row.id} was merged into #${winner.id} ${winner.name}; use contactId ${winner.id}`
1094
+ ] : []
1095
+ ],
677
1096
  rollups: contactRollup?.rollup ?? null
678
1097
  };
679
1098
  }
@@ -691,10 +1110,32 @@ var init_load_contact_profile_record = __esm({
691
1110
  // src/contacts/get_contact_profile.ts
692
1111
  function getContactProfile(db, contactId, options) {
693
1112
  const t = options?.topLinksLimit ?? 8;
694
- return loadContactProfileRecord(db, contactId, t);
1113
+ const profile = loadContactProfileRecord(db, contactId, t);
1114
+ if (!profile) return null;
1115
+ const ties = db.prepare(`SELECT l.id, l.kind, l.role, c.id AS contactId, c.name
1116
+ 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
1117
+ WHERE l.is_structural = 1 AND l.removed_at IS NULL AND (l.from_contact_id = ? OR l.to_contact_id = ?)
1118
+ AND c.deleted_at IS NULL ORDER BY l.id`).all(contactId, contactId, contactId);
1119
+ return {
1120
+ ...profile,
1121
+ recentEvents: getContactJournal(db, contactId, {
1122
+ limit: options?.recentEventsLimit ?? 5
1123
+ }).map((event) => ({
1124
+ ...event,
1125
+ occurredAtIso: new Date(event.occurredAt).toISOString()
1126
+ })),
1127
+ openCommitments: listOpenCommitments(
1128
+ db,
1129
+ { contactId },
1130
+ options?.now === void 0 ? void 0 : { now: options.now }
1131
+ ),
1132
+ ties
1133
+ };
695
1134
  }
696
1135
  var init_get_contact_profile = __esm({
697
1136
  "src/contacts/get_contact_profile.ts"() {
1137
+ init_get_contact_journal();
1138
+ init_list_open_commitments();
698
1139
  init_load_contact_profile_record();
699
1140
  }
700
1141
  });
@@ -715,83 +1156,22 @@ var init_get_owner_profile = __esm({
715
1156
  }
716
1157
  });
717
1158
 
718
- // src/lib/read/assert_default_ordering.ts
719
- function assertDefaultOrdering(readName, options) {
720
- if (options?.sort !== void 0 || options?.descending !== void 0) {
721
- throw new AffinityValidationError(
722
- `${readName} does not support custom sort ordering`
723
- );
1159
+ // src/events/list_moments.ts
1160
+ function listMoments(db, filters, options) {
1161
+ assertDefaultOrdering("listMoments", options);
1162
+ const { limit, offset } = resolveListLimitOffset(options);
1163
+ const clauses = [
1164
+ "e.deleted_at IS NULL",
1165
+ "le.moment_kind IS NOT NULL"
1166
+ ];
1167
+ const params = [];
1168
+ if (options?.since !== void 0) {
1169
+ clauses.push("le.occurred_at >= ?");
1170
+ params.push(options.since);
724
1171
  }
725
- }
726
- var init_assert_default_ordering = __esm({
727
- "src/lib/read/assert_default_ordering.ts"() {
728
- init_affinity_validation_error();
729
- }
730
- });
731
-
732
- // src/lib/types/read_list_options.ts
733
- var AFFINITY_DEFAULT_LIST_LIMIT, AFFINITY_MAX_LIST_LIMIT;
734
- var init_read_list_options = __esm({
735
- "src/lib/types/read_list_options.ts"() {
736
- AFFINITY_DEFAULT_LIST_LIMIT = 50;
737
- AFFINITY_MAX_LIST_LIMIT = 250;
738
- }
739
- });
740
-
741
- // src/lib/types/resolve_affinity_list_limit.ts
742
- function resolveAffinityListLimit(limit) {
743
- if (limit === void 0) {
744
- return AFFINITY_DEFAULT_LIST_LIMIT;
745
- }
746
- if (!Number.isInteger(limit) || limit < 1) {
747
- throw new AffinityValidationError("limit must be a positive integer");
748
- }
749
- if (limit > AFFINITY_MAX_LIST_LIMIT) {
750
- throw new AffinityValidationError(
751
- `limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
752
- );
753
- }
754
- return limit;
755
- }
756
- var init_resolve_affinity_list_limit = __esm({
757
- "src/lib/types/resolve_affinity_list_limit.ts"() {
758
- init_affinity_validation_error();
759
- init_read_list_options();
760
- }
761
- });
762
-
763
- // src/lib/read/resolve_list_limit_offset.ts
764
- function resolveListLimitOffset(options) {
765
- const limit = resolveAffinityListLimit(options?.limit);
766
- const offset = options?.offset ?? 0;
767
- if (!Number.isInteger(offset) || offset < 0) {
768
- throw new AffinityValidationError("offset must be a non-negative integer");
769
- }
770
- return { limit, offset };
771
- }
772
- var init_resolve_list_limit_offset = __esm({
773
- "src/lib/read/resolve_list_limit_offset.ts"() {
774
- init_affinity_validation_error();
775
- init_resolve_affinity_list_limit();
776
- }
777
- });
778
-
779
- // src/events/list_moments.ts
780
- function listMoments(db, filters, options) {
781
- assertDefaultOrdering("listMoments", options);
782
- const { limit, offset } = resolveListLimitOffset(options);
783
- const clauses = [
784
- "e.deleted_at IS NULL",
785
- "le.moment_kind IS NOT NULL"
786
- ];
787
- const params = [];
788
- if (options?.since !== void 0) {
789
- clauses.push("le.occurred_at >= ?");
790
- params.push(options.since);
791
- }
792
- if (options?.until !== void 0) {
793
- clauses.push("le.occurred_at <= ?");
794
- params.push(options.until);
1172
+ if (options?.until !== void 0) {
1173
+ clauses.push("le.occurred_at <= ?");
1174
+ params.push(options.until);
795
1175
  }
796
1176
  if (filters?.contactId !== void 0) {
797
1177
  clauses.push(
@@ -837,109 +1217,6 @@ var init_list_moments = __esm({
837
1217
  }
838
1218
  });
839
1219
 
840
- // src/events/loaders.ts
841
- function loadEventParticipantViews(db, eventId) {
842
- const rows = db.prepare(
843
- `SELECT contact_id, role, directionality
844
- FROM event_participants
845
- WHERE event_id = ?`
846
- ).all(eventId);
847
- return rows.map((row) => ({
848
- contactId: row.contact_id,
849
- role: row.role,
850
- ...row.directionality === null ? {} : { directionality: row.directionality }
851
- }));
852
- }
853
- function loadEventRecord(db, eventId) {
854
- const row = getEventRowById(db, eventId);
855
- if (!row) {
856
- throw new AffinityInvariantError("event not found");
857
- }
858
- const participants = loadEventParticipantViews(db, eventId);
859
- return mapEventRowToEventRecord(row, participants);
860
- }
861
- function batchLoadEventRecords(db, eventIds) {
862
- if (eventIds.length === 0) {
863
- return [];
864
- }
865
- const ph = eventIds.map(() => "?").join(", ");
866
- const eventRows = db.prepare(
867
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
868
- recurrence_kind, anchor_month, anchor_day,
869
- anchor_contact_id, anchor_link_id,
870
- created_at, updated_at, deleted_at
871
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
872
- ).all(...eventIds);
873
- const partRows = db.prepare(
874
- `SELECT event_id, contact_id, role, directionality
875
- FROM event_participants WHERE event_id IN (${ph})`
876
- ).all(...eventIds);
877
- const partsByEvent = /* @__PURE__ */ new Map();
878
- for (const p of partRows) {
879
- let arr = partsByEvent.get(p.event_id);
880
- if (!arr) {
881
- arr = [];
882
- partsByEvent.set(p.event_id, arr);
883
- }
884
- arr.push({
885
- contactId: p.contact_id,
886
- role: p.role,
887
- ...p.directionality === null ? {} : { directionality: p.directionality }
888
- });
889
- }
890
- const rowById = new Map(eventRows.map((r) => [r.id, r]));
891
- const out = [];
892
- for (const id of eventIds) {
893
- const row = rowById.get(id);
894
- if (row) {
895
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
896
- }
897
- }
898
- return out;
899
- }
900
- function batchLoadEventParticipantViews(db, eventIds) {
901
- const result = /* @__PURE__ */ new Map();
902
- if (eventIds.length === 0) {
903
- return result;
904
- }
905
- const ph = eventIds.map(() => "?").join(", ");
906
- const rows = db.prepare(
907
- `SELECT event_id, contact_id, role, directionality
908
- FROM event_participants WHERE event_id IN (${ph})`
909
- ).all(...eventIds);
910
- for (const row of rows) {
911
- let arr = result.get(row.event_id);
912
- if (!arr) {
913
- arr = [];
914
- result.set(row.event_id, arr);
915
- }
916
- arr.push({
917
- contactId: row.contact_id,
918
- role: row.role,
919
- ...row.directionality === null ? {} : { directionality: row.directionality }
920
- });
921
- }
922
- return result;
923
- }
924
- function requireDateAnchorEvent(db, eventId) {
925
- const row = getEventRowById(db, eventId);
926
- if (!row) {
927
- throw new AffinityNotFoundError("event not found");
928
- }
929
- if (row.type !== "date_anchor") {
930
- throw new AffinityInvariantError("not a date anchor");
931
- }
932
- return row;
933
- }
934
- var init_loaders = __esm({
935
- "src/events/loaders.ts"() {
936
- init_affinity_invariant_error();
937
- init_affinity_not_found_error();
938
- init_mappers();
939
- init_queries2();
940
- }
941
- });
942
-
943
1220
  // src/links/queries.ts
944
1221
  function getLinkRowById(db, id) {
945
1222
  const row = db.prepare(
@@ -1212,7 +1489,7 @@ function recencyScore(occurredAt, cadenceDays, now) {
1212
1489
  return 0;
1213
1490
  }
1214
1491
  const cadence = Math.max(cadenceDays ?? 0, 1);
1215
- const daysSince = Math.max(0, (now - occurredAt) / DAY_MS);
1492
+ const daysSince = Math.max(0, (now - occurredAt) / DAY_MS2);
1216
1493
  return Math.max(0, 1 - daysSince / cadence);
1217
1494
  }
1218
1495
  function refreshLinkRollup(db, linkId, now) {
@@ -1223,15 +1500,15 @@ function refreshLinkRollup(db, linkId, now) {
1223
1500
  const latest = db.prepare(
1224
1501
  `SELECT event_id, occurred_at
1225
1502
  FROM link_event_effects
1226
- WHERE link_id = ?
1503
+ WHERE link_id = ? AND counts_for_recency = 1
1227
1504
  ORDER BY occurred_at DESC, event_id DESC
1228
1505
  LIMIT 1`
1229
1506
  ).get(linkId);
1230
- const trailingWindowStart = now - 180 * DAY_MS;
1507
+ const trailingWindowStart = now - 180 * DAY_MS2;
1231
1508
  const counts = db.prepare(
1232
1509
  `SELECT
1233
1510
  COUNT(*) AS total_meaningful_events,
1234
- SUM(CASE WHEN affinity_delta > 0 OR trust_delta > 0 THEN 1 ELSE 0 END) AS positive_meaningful_events
1511
+ SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
1235
1512
  FROM link_event_effects
1236
1513
  WHERE link_id = ? AND occurred_at >= ?`
1237
1514
  ).get(linkId, trailingWindowStart);
@@ -1239,7 +1516,7 @@ function refreshLinkRollup(db, linkId, now) {
1239
1516
  `SELECT ep.directionality
1240
1517
  FROM link_event_effects le
1241
1518
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
1242
- WHERE le.link_id = ? AND ep.contact_id = ?
1519
+ WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
1243
1520
  AND ep.directionality IS NOT NULL
1244
1521
  AND le.occurred_at >= ?`
1245
1522
  ).all(linkId, link.from_contact_id, trailingWindowStart);
@@ -1265,7 +1542,7 @@ function refreshLinkRollup(db, linkId, now) {
1265
1542
  link.cadence_days,
1266
1543
  now
1267
1544
  );
1268
- const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS);
1545
+ const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS2);
1269
1546
  const driftPriorityValue = driftPriority({
1270
1547
  daysSinceLastMeaningfulEvent,
1271
1548
  cadenceDays: link.cadence_days ?? 1,
@@ -1348,13 +1625,13 @@ function refreshLinkRollup(db, linkId, now) {
1348
1625
  refreshContactRollup(db, link.from_contact_id, now);
1349
1626
  refreshContactRollup(db, link.to_contact_id, now);
1350
1627
  }
1351
- var DAY_MS;
1628
+ var DAY_MS2;
1352
1629
  var init_rollups2 = __esm({
1353
1630
  "src/links/rollups.ts"() {
1354
1631
  init_rollups();
1355
1632
  init_mechanics();
1356
1633
  init_queries3();
1357
- DAY_MS = 864e5;
1634
+ DAY_MS2 = 864e5;
1358
1635
  }
1359
1636
  });
1360
1637
 
@@ -1412,8 +1689,61 @@ var init_get_link_detail = __esm({
1412
1689
  }
1413
1690
  });
1414
1691
 
1692
+ // src/tools/parse_time_input.ts
1693
+ function parseTimeInput(value, field = "time", now = Date.now()) {
1694
+ const fail = () => {
1695
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1696
+ };
1697
+ if (typeof value === "number")
1698
+ return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
1699
+ if (typeof value !== "string") return fail();
1700
+ const text = value.trim();
1701
+ const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
1702
+ let day;
1703
+ if (relative) {
1704
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1705
+ day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
1706
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
1707
+ day = Date.parse(`${text}T00:00:00.000Z`);
1708
+ if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
1709
+ return fail();
1710
+ } else {
1711
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1712
+ text
1713
+ ))
1714
+ return fail();
1715
+ const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
1716
+ if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
1717
+ return fail();
1718
+ const parsed = Date.parse(text);
1719
+ return Number.isFinite(parsed) ? parsed : fail();
1720
+ }
1721
+ const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
1722
+ return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
1723
+ }
1724
+ var timeInputForms;
1725
+ var init_parse_time_input = __esm({
1726
+ "src/tools/parse_time_input.ts"() {
1727
+ init_affinity_validation_error();
1728
+ timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
1729
+ }
1730
+ });
1731
+
1415
1732
  // src/tools/tool_metadata.ts
1416
1733
  function defineAffinityTool(definition) {
1734
+ const visit = (schema) => {
1735
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
1736
+ if ((key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.type === "integer") {
1737
+ child.type = void 0;
1738
+ child.oneOf = [{ type: "integer" }, { type: "string" }];
1739
+ child.description = `${child.description ?? "Time."} Accepts ${timeInputForms}. UTC midnight for dates; dueAt uses UTC end of day.`;
1740
+ }
1741
+ visit(child);
1742
+ }
1743
+ if (schema.items) visit(schema.items);
1744
+ for (const child of schema.oneOf ?? schema.anyOf ?? []) visit(child);
1745
+ };
1746
+ visit(definition.inputSchema);
1417
1747
  return definition;
1418
1748
  }
1419
1749
  function stringSchema(description, options = {}) {
@@ -1460,8 +1790,15 @@ function objectSchema(properties, required, description) {
1460
1790
  function nullableStringSchema(description) {
1461
1791
  return { anyOf: [{ type: "string" }, { type: "null" }], description };
1462
1792
  }
1793
+ function oneOfSchema(schemas, description) {
1794
+ const schema = {
1795
+ oneOf: schemas
1796
+ };
1797
+ return schema;
1798
+ }
1463
1799
  var init_tool_metadata = __esm({
1464
1800
  "src/tools/tool_metadata.ts"() {
1801
+ init_parse_time_input();
1465
1802
  }
1466
1803
  });
1467
1804
 
@@ -1502,6 +1839,7 @@ function toContactEntityRef(value) {
1502
1839
  function toContactListToolItem(value) {
1503
1840
  return {
1504
1841
  ...toContactEntityRef(value),
1842
+ ..."matchedOn" in value ? { matchedOn: value.matchedOn } : {},
1505
1843
  subtitle: "kind" in value ? value.kind : void 0
1506
1844
  };
1507
1845
  }
@@ -1533,7 +1871,9 @@ function toEventEntityRef(value) {
1533
1871
  }
1534
1872
  function toEventListToolItem(value) {
1535
1873
  return {
1874
+ ...value,
1536
1875
  ...toEventEntityRef(value),
1876
+ occurredAtIso: new Date(value.occurredAt).toISOString(),
1537
1877
  snippet: `${value.participants.length} participant(s)`
1538
1878
  };
1539
1879
  }
@@ -1595,7 +1935,7 @@ function withOptionalFields(result, options) {
1595
1935
  if (options.warnings && options.warnings.length > 0) {
1596
1936
  result.warnings = options.warnings;
1597
1937
  }
1598
- return result;
1938
+ return roundToolValues(result);
1599
1939
  }
1600
1940
  function toolWarning(code, message) {
1601
1941
  return { code, message };
@@ -1659,13 +1999,35 @@ function toolFailure(kind, code, summary, message, options = {}) {
1659
1999
  options
1660
2000
  );
1661
2001
  }
2002
+ function roundToolValues(value) {
2003
+ if (typeof value === "number")
2004
+ return Number.isInteger(value) ? value : Math.round(value * 1e3) / 1e3;
2005
+ if (Array.isArray(value)) return value.map(roundToolValues);
2006
+ if (value !== null && typeof value === "object")
2007
+ return Object.fromEntries(
2008
+ Object.entries(value).map(([key, child]) => [
2009
+ key,
2010
+ roundToolValues(child)
2011
+ ])
2012
+ );
2013
+ return value;
2014
+ }
1662
2015
  var init_tool_types = __esm({
1663
2016
  "src/tools/tool_types.ts"() {
1664
2017
  }
1665
2018
  });
1666
2019
 
1667
2020
  // src/tools/tool_resolvers.ts
1668
- function resolveContactLocator(db, locator, field = "contact") {
2021
+ function resolveContactLocator(db, locator, field = "contact", allowMerged = false) {
2022
+ if (!locator || typeof locator !== "object")
2023
+ return {
2024
+ ok: false,
2025
+ result: toolNeedsClarification(
2026
+ "missing_required_choice",
2027
+ `Provide ${field} locator.`,
2028
+ [field]
2029
+ )
2030
+ };
1669
2031
  if ("contactId" in locator) {
1670
2032
  const row2 = getContactRowById(db, locator.contactId);
1671
2033
  if (!row2) {
@@ -1679,8 +2041,25 @@ function resolveContactLocator(db, locator, field = "contact") {
1679
2041
  )
1680
2042
  };
1681
2043
  }
2044
+ if (!allowMerged && row2.merged_into_contact_id !== null) {
2045
+ const winner = getContactRowById(db, row2.merged_into_contact_id);
2046
+ const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
2047
+ return {
2048
+ ok: false,
2049
+ result: toolFailure("domain", "invalid_state", message, message)
2050
+ };
2051
+ }
1682
2052
  return { ok: true, value: row2 };
1683
2053
  }
2054
+ if (!("identity" in locator) || !locator.identity)
2055
+ return {
2056
+ ok: false,
2057
+ result: toolNeedsClarification(
2058
+ "missing_required_choice",
2059
+ `Provide ${field}.contactId or identity.`,
2060
+ [field]
2061
+ )
2062
+ };
1684
2063
  const normalizedKey = normalizeIdentityKey(
1685
2064
  locator.identity.type,
1686
2065
  locator.identity.value
@@ -1738,6 +2117,15 @@ function resolveContactLocator(db, locator, field = "contact") {
1738
2117
  return { ok: true, value: matchedContact };
1739
2118
  }
1740
2119
  function resolveLinkLocator(db, locator, field = "link") {
2120
+ if (!locator || typeof locator !== "object")
2121
+ return {
2122
+ ok: false,
2123
+ result: toolNeedsClarification(
2124
+ "missing_required_choice",
2125
+ `Provide ${field} locator.`,
2126
+ [field]
2127
+ )
2128
+ };
1741
2129
  if ("linkId" in locator) {
1742
2130
  const row = getLinkRowById(db, locator.linkId);
1743
2131
  if (!row) {
@@ -1753,6 +2141,15 @@ function resolveLinkLocator(db, locator, field = "link") {
1753
2141
  }
1754
2142
  return { ok: true, value: row };
1755
2143
  }
2144
+ if (!("endpoints" in locator) || !locator.endpoints)
2145
+ return {
2146
+ ok: false,
2147
+ result: toolNeedsClarification(
2148
+ "missing_required_choice",
2149
+ `Provide ${field}.linkId or endpoints.`,
2150
+ [field]
2151
+ )
2152
+ };
1756
2153
  const { endpoints } = locator;
1757
2154
  const clauses = [
1758
2155
  "removed_at IS NULL",
@@ -1881,6 +2278,97 @@ var init_tool_resolvers = __esm({
1881
2278
  }
1882
2279
  });
1883
2280
 
2281
+ // src/tools/validate_tool_input.ts
2282
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
2283
+ if (schema.anyOf || schema.oneOf) {
2284
+ const choices = schema.anyOf ?? schema.oneOf ?? [];
2285
+ return choices.some(
2286
+ (choice) => validateToolInput(choice, value, path, injectedNow) === null
2287
+ ) ? null : `${path}: invalid value`;
2288
+ }
2289
+ if (schema.type === "object") {
2290
+ if (value === null || typeof value !== "object" || Array.isArray(value))
2291
+ return `${path}: missing input or expected object`;
2292
+ const object = value;
2293
+ if (path.endsWith(".patch") && schema.additionalProperties === false) {
2294
+ for (const key of Object.keys(object)) {
2295
+ if (!Object.hasOwn(schema.properties ?? {}, key))
2296
+ 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(", ")}`;
2297
+ }
2298
+ }
2299
+ if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
2300
+ return `${path}.patch: missing required field`;
2301
+ if (object.patch !== void 0 && !schema.properties?.patch)
2302
+ return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
2303
+ const referenceNow = object.now === void 0 ? injectedNow : (() => {
2304
+ try {
2305
+ return parseTimeInput(
2306
+ object.now,
2307
+ `${path}.now`,
2308
+ injectedNow
2309
+ );
2310
+ } catch {
2311
+ return Date.now();
2312
+ }
2313
+ })();
2314
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
2315
+ if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
2316
+ try {
2317
+ object[key] = parseTimeInput(
2318
+ object[key],
2319
+ `${path}.${key}`,
2320
+ referenceNow
2321
+ );
2322
+ } catch (error) {
2323
+ return error.message;
2324
+ }
2325
+ }
2326
+ }
2327
+ for (const key of schema.required ?? []) {
2328
+ if (object[key] === void 0)
2329
+ return `${path}.${key}: missing required field`;
2330
+ }
2331
+ for (const [key, child] of Object.entries(schema.properties ?? {})) {
2332
+ if (object[key] === void 0) continue;
2333
+ const error = validateToolInput(
2334
+ child,
2335
+ object[key],
2336
+ `${path}.${key}`,
2337
+ referenceNow
2338
+ );
2339
+ if (error) return error;
2340
+ }
2341
+ return null;
2342
+ }
2343
+ if (schema.type === "array") {
2344
+ if (!Array.isArray(value)) return `${path}: expected array`;
2345
+ for (const [index, item] of value.entries()) {
2346
+ const error = validateToolInput(
2347
+ schema.items ?? {},
2348
+ item,
2349
+ `${path}[${index}]`,
2350
+ injectedNow
2351
+ );
2352
+ if (error) return error;
2353
+ }
2354
+ return null;
2355
+ }
2356
+ if (schema.enum && !schema.enum.includes(value))
2357
+ return `${path}: expected ${schema.enum.join(", ")}`;
2358
+ if (schema.type === "null")
2359
+ return value === null ? null : `${path}: expected null`;
2360
+ if (schema.type === "integer")
2361
+ return Number.isInteger(value) ? null : `${path}: expected integer`;
2362
+ 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";
2363
+ if (!valid) return `${path}: expected ${schema.type}`;
2364
+ return null;
2365
+ }
2366
+ var init_validate_tool_input = __esm({
2367
+ "src/tools/validate_tool_input.ts"() {
2368
+ init_parse_time_input();
2369
+ }
2370
+ });
2371
+
1884
2372
  // src/tools/inspect_affinity_item_tool.ts
1885
2373
  function contactLocatorSchema(description) {
1886
2374
  return {
@@ -1916,7 +2404,19 @@ function linkLocatorSchema(description) {
1916
2404
  description
1917
2405
  };
1918
2406
  }
1919
- function inspectAffinityItemToolHandler(db, input) {
2407
+ function inspectAffinityItemToolHandler(db, rawInput) {
2408
+ const input = structuredClone(rawInput);
2409
+ const inputError = validateToolInput(
2410
+ inspectAffinityItemTool.inputSchema,
2411
+ input
2412
+ );
2413
+ if (inputError)
2414
+ return toolFailure(
2415
+ "protocol",
2416
+ "invalid_input",
2417
+ "Invalid input.",
2418
+ inputError
2419
+ );
1920
2420
  return withToolHandling(() => {
1921
2421
  if (input.kind === "owner_profile") {
1922
2422
  const detail2 = getOwnerProfile(
@@ -1934,19 +2434,21 @@ function inspectAffinityItemToolHandler(db, input) {
1934
2434
  return toolSuccess(
1935
2435
  `Loaded owner profile for ${detail2.contact.name}.`,
1936
2436
  { kind: input.kind, detail: detail2 },
1937
- { entities: [toContactEntityRef(detail2.contact)] }
2437
+ {
2438
+ entities: [toContactEntityRef(detail2.contact)],
2439
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
2440
+ }
1938
2441
  );
1939
2442
  }
1940
2443
  if (input.kind === "contact_profile") {
1941
- const contact = resolveContactLocator(db, input.contact, "contact");
2444
+ const contact = resolveContactLocator(db, input.contact, "contact", true);
1942
2445
  if (!contact.ok) {
1943
2446
  return contact.result;
1944
2447
  }
1945
- const detail2 = getContactProfile(
1946
- db,
1947
- contact.value.id,
1948
- input.topLinksLimit === void 0 ? void 0 : { topLinksLimit: input.topLinksLimit }
1949
- );
2448
+ const detail2 = getContactProfile(db, contact.value.id, {
2449
+ ...input.topLinksLimit === void 0 ? {} : { topLinksLimit: input.topLinksLimit },
2450
+ ...input.recentEventsLimit === void 0 ? {} : { recentEventsLimit: input.recentEventsLimit }
2451
+ });
1950
2452
  if (!detail2) {
1951
2453
  return toolFailure(
1952
2454
  "domain",
@@ -1958,8 +2460,11 @@ function inspectAffinityItemToolHandler(db, input) {
1958
2460
  return toolSuccess(
1959
2461
  `Loaded contact profile for ${detail2.contact.name}.`,
1960
2462
  { kind: input.kind, detail: detail2 },
1961
- { entities: [toContactEntityRef(detail2.contact)] }
1962
- );
2463
+ {
2464
+ entities: [toContactEntityRef(detail2.contact)],
2465
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
2466
+ }
2467
+ );
1963
2468
  }
1964
2469
  const link = resolveLinkLocator(db, input.link, "link");
1965
2470
  if (!link.ok) {
@@ -2001,6 +2506,8 @@ var init_inspect_affinity_item_tool = __esm({
2001
2506
  init_tool_ref();
2002
2507
  init_tool_resolvers();
2003
2508
  init_tool_types();
2509
+ init_tool_types();
2510
+ init_validate_tool_input();
2004
2511
  inspectAffinityItemTool = defineAffinityTool({
2005
2512
  name: inspectAffinityItemToolName,
2006
2513
  description: "Inspect one exact affinity item such as the owner profile, a contact profile, or one link detail.",
@@ -2492,25 +2999,18 @@ var init_unset_attribute = __esm({
2492
2999
  }
2493
3000
  });
2494
3001
 
2495
- // src/tools/tool_summary.ts
2496
- function summarizeCount(count, singular) {
2497
- if (count === 0) {
2498
- return `No ${singular}s found.`;
2499
- }
2500
- if (count === 1) {
2501
- return `Found 1 ${singular}.`;
2502
- }
2503
- return `Found ${count} ${singular}s.`;
2504
- }
2505
- var init_tool_summary = __esm({
2506
- "src/tools/tool_summary.ts"() {
2507
- }
2508
- });
2509
-
2510
3002
  // src/tools/tool_mutation.ts
2511
- function mutationToolResult(action, receipt, entitySummary) {
3003
+ function mutationToolResult(action, receipt, entitySummary, fields) {
2512
3004
  const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
2513
3005
  const data = {
3006
+ changes: ["created", "updated", "archived", "removed"].flatMap(
3007
+ (verb) => receipt[verb].map((ref) => ({
3008
+ entity: ref.kind,
3009
+ id: ref.id,
3010
+ action: verb,
3011
+ ...verb === "updated" && fields ? { fields } : {}
3012
+ }))
3013
+ ),
2514
3014
  action,
2515
3015
  primary: receipt.primary,
2516
3016
  created: receipt.created,
@@ -2520,7 +3020,7 @@ function mutationToolResult(action, receipt, entitySummary) {
2520
3020
  affectedLinks: receipt.affectedLinks,
2521
3021
  derivedEffects: receipt.derivedEffects
2522
3022
  };
2523
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : summarizeCount(changedCount, `${entitySummary} change`);
3023
+ const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
2524
3024
  if (changedCount === 0) {
2525
3025
  return toolNoOp(summary, data, {
2526
3026
  entities: toReceiptEntityRefs(receipt)
@@ -2530,11 +3030,28 @@ function mutationToolResult(action, receipt, entitySummary) {
2530
3030
  entities: toReceiptEntityRefs(receipt)
2531
3031
  });
2532
3032
  }
3033
+ function describeChanges(receipt) {
3034
+ const parts = [];
3035
+ for (const verb of ["created", "updated", "archived", "removed"]) {
3036
+ const counts = /* @__PURE__ */ new Map();
3037
+ for (const ref of receipt[verb]) {
3038
+ counts.set(ref.kind, (counts.get(ref.kind) ?? 0) + 1);
3039
+ }
3040
+ if (counts.size === 0) continue;
3041
+ const items = [...counts].map(
3042
+ ([kind, n]) => `${n} ${n === 1 ? kind : PLURALS[kind] ?? `${kind}s`}`
3043
+ );
3044
+ parts.push(`${verb} ${items.join(", ")}`);
3045
+ }
3046
+ const text = parts.join("; ");
3047
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
3048
+ }
3049
+ var PLURALS;
2533
3050
  var init_tool_mutation = __esm({
2534
3051
  "src/tools/tool_mutation.ts"() {
2535
3052
  init_tool_ref();
2536
- init_tool_summary();
2537
3053
  init_tool_types();
3054
+ PLURALS = { identity: "identities" };
2538
3055
  }
2539
3056
  });
2540
3057
 
@@ -2587,7 +3104,23 @@ function resolveAttributeTarget(db, target) {
2587
3104
  }
2588
3105
  return { kind: "ok", target: { kind: "link", id: link.value.id } };
2589
3106
  }
2590
- function manageAttributeToolHandler(db, input) {
3107
+ function manageAttributeToolHandler(db, rawInput) {
3108
+ const input = structuredClone(rawInput);
3109
+ const inputError = validateToolInput(manageAttributeTool.inputSchema, input);
3110
+ if (inputError)
3111
+ return toolFailure(
3112
+ "protocol",
3113
+ "invalid_input",
3114
+ "Invalid input.",
3115
+ inputError
3116
+ );
3117
+ if (!input.target)
3118
+ return toolFailure(
3119
+ "protocol",
3120
+ "invalid_input",
3121
+ "Missing target.",
3122
+ "target is required."
3123
+ );
2591
3124
  return withToolHandling(() => {
2592
3125
  const resolved = resolveAttributeTarget(db, input.target);
2593
3126
  if (resolved.kind === "error") {
@@ -2625,6 +3158,8 @@ var init_manage_attribute_tool = __esm({
2625
3158
  init_tool_mutation();
2626
3159
  init_tool_names();
2627
3160
  init_tool_resolvers();
3161
+ init_tool_types();
3162
+ init_validate_tool_input();
2628
3163
  manageAttributeTool = defineAffinityTool({
2629
3164
  name: manageAttributeToolName,
2630
3165
  description: "Set, unset, or replace custom metadata attributes on contacts and links. For emails, phones, handles, and URLs use manage_identity instead \u2014 those are routing keys, not attributes.",
@@ -3257,7 +3792,7 @@ function clamp3(value, min, max) {
3257
3792
  }
3258
3793
  function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3259
3794
  const dayStart = utcStartOfDayMs(occurredAt);
3260
- const dayEnd = dayStart + DAY_MS2;
3795
+ const dayEnd = dayStart + DAY_MS3;
3261
3796
  const row = db.prepare(
3262
3797
  `SELECT COUNT(*) AS c
3263
3798
  FROM link_event_effects le
@@ -3270,7 +3805,7 @@ function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3270
3805
  return Number(row.c ?? 0);
3271
3806
  }
3272
3807
  function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3273
- const weekStart = occurredAt - 7 * DAY_MS2;
3808
+ const weekStart = occurredAt - 7 * DAY_MS3;
3274
3809
  const row = db.prepare(
3275
3810
  `SELECT SUM(base_weight) AS total
3276
3811
  FROM link_event_effects
@@ -3282,7 +3817,7 @@ function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3282
3817
  return Number(row.total ?? 0);
3283
3818
  }
3284
3819
  function countRepairContext(db, linkId, occurredAt) {
3285
- const thirtyDaysAgo = occurredAt - 30 * DAY_MS2;
3820
+ const thirtyDaysAgo = occurredAt - 30 * DAY_MS3;
3286
3821
  const hasPriorDamage = db.prepare(
3287
3822
  `SELECT 1 FROM link_event_effects
3288
3823
  WHERE link_id = ? AND trust_delta < 0
@@ -3357,8 +3892,8 @@ function computePreferenceMatch(db, linkId, contactId, eventType, provenance) {
3357
3892
  return preferenceMatch(liked, disliked);
3358
3893
  }
3359
3894
  function computeDateSalienceMultiplier(db, contactId, occurredAt, significance) {
3360
- const windowBefore = 7 * DAY_MS2;
3361
- const windowAfter = 2 * DAY_MS2;
3895
+ const windowBefore = 7 * DAY_MS3;
3896
+ const windowAfter = 2 * DAY_MS3;
3362
3897
  const row = db.prepare(
3363
3898
  `SELECT 1 FROM upcoming_occurrences uo
3364
3899
  JOIN events e ON e.id = uo.event_id
@@ -3525,9 +4060,9 @@ function applyEventEffectToLink(db, params) {
3525
4060
  ).get(params.linkId);
3526
4061
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
3527
4062
  1,
3528
- Math.round((params.occurredAt - latest.occurred_at) / DAY_MS2)
4063
+ Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
3529
4064
  );
3530
- const cadenceAfter = nextCadenceDays({
4065
+ const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
3531
4066
  kind: linkKind,
3532
4067
  currentCadenceDays: cadenceBefore,
3533
4068
  gapDays
@@ -3590,6 +4125,11 @@ function applyEventEffectToLink(db, params) {
3590
4125
  params.now,
3591
4126
  params.now
3592
4127
  );
4128
+ if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
4129
+ db.prepare(
4130
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
4131
+ ).run(params.eventId, params.linkId);
4132
+ }
3593
4133
  refreshLinkRollup(db, params.linkId, params.now);
3594
4134
  return {
3595
4135
  linkId: params.linkId,
@@ -3613,7 +4153,7 @@ function applyEventEffectToLink(db, params) {
3613
4153
  momentKind
3614
4154
  };
3615
4155
  }
3616
- var DAY_MS2;
4156
+ var DAY_MS3;
3617
4157
  var init_effects = __esm({
3618
4158
  "src/links/effects.ts"() {
3619
4159
  init_calendar();
@@ -3623,7 +4163,7 @@ var init_effects = __esm({
3623
4163
  init_queries3();
3624
4164
  init_rollups2();
3625
4165
  init_validators2();
3626
- DAY_MS2 = 864e5;
4166
+ DAY_MS3 = 864e5;
3627
4167
  }
3628
4168
  });
3629
4169
 
@@ -4004,12 +4544,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4004
4544
  if (row.type !== "promise" && row.type !== "agreement") {
4005
4545
  throw new AffinityInvariantError("not a commitment event");
4006
4546
  }
4007
- const open = db.prepare("SELECT resolved_at FROM open_commitments WHERE event_id = ?").get(commitmentEventId);
4547
+ const open = db.prepare(
4548
+ "SELECT resolved_at, resolution FROM open_commitments WHERE event_id = ?"
4549
+ ).get(commitmentEventId);
4008
4550
  if (!open) {
4009
4551
  throw new AffinityNotFoundError("open commitment not found");
4010
4552
  }
4011
4553
  if (open.resolved_at !== null) {
4012
- throw new AffinityStateError("commitment already resolved");
4554
+ throw new AffinityConflictError(
4555
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4556
+ );
4013
4557
  }
4014
4558
  const now = resolveNow(options?.now);
4015
4559
  const originalParticipants = loadEventParticipantViews(
@@ -4022,7 +4566,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4022
4566
  WHERE event_id = ? AND resolved_at IS NULL`
4023
4567
  ).run(resolution, now, now, commitmentEventId);
4024
4568
  if (Number(result.changes ?? 0) === 0) {
4025
- throw new AffinityStateError("commitment already resolved");
4569
+ throw new AffinityConflictError(
4570
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4571
+ );
4026
4572
  }
4027
4573
  const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
4028
4574
  const created = [];
@@ -4092,9 +4638,9 @@ var init_resolve_commitment = __esm({
4092
4638
  init_queries2();
4093
4639
  init_receipts2();
4094
4640
  init_validators();
4641
+ init_affinity_conflict_error();
4095
4642
  init_affinity_invariant_error();
4096
4643
  init_affinity_not_found_error();
4097
- init_affinity_state_error();
4098
4644
  init_affinity_validation_error();
4099
4645
  init_resolve_now();
4100
4646
  init_with_transaction();
@@ -4127,9 +4673,25 @@ function ensureOwnerParticipates(db, participants) {
4127
4673
  function prepareParticipants(db, raw) {
4128
4674
  return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
4129
4675
  }
4130
- function manageCommitmentToolHandler(db, input) {
4676
+ function manageCommitmentToolHandler(db, rawInput) {
4677
+ const input = structuredClone(rawInput);
4678
+ const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
4679
+ if (inputError)
4680
+ return toolFailure(
4681
+ "protocol",
4682
+ "invalid_input",
4683
+ "Invalid input.",
4684
+ inputError
4685
+ );
4131
4686
  return withToolHandling(() => {
4132
4687
  if (input.action === "record") {
4688
+ if (!input.input)
4689
+ return toolFailure(
4690
+ "protocol",
4691
+ "invalid_input",
4692
+ "Missing input.",
4693
+ "input is required for record."
4694
+ );
4133
4695
  const patched = {
4134
4696
  ...input.input,
4135
4697
  participants: prepareParticipants(
@@ -4165,6 +4727,8 @@ var init_manage_commitment_tool = __esm({
4165
4727
  init_tool_mutation();
4166
4728
  init_tool_names();
4167
4729
  init_tool_resolvers();
4730
+ init_tool_types();
4731
+ init_validate_tool_input();
4168
4732
  manageCommitmentTool = defineAffinityTool({
4169
4733
  name: manageCommitmentToolName,
4170
4734
  description: "Record or resolve one commitment such as a promise or agreement. The owner contact is auto-injected into participants if missing.",
@@ -4206,7 +4770,7 @@ var init_manage_commitment_tool = __esm({
4206
4770
  role: stringSchema("Participant role."),
4207
4771
  directionality: stringSchema("Optional directionality.")
4208
4772
  },
4209
- ["contactId", "role"]
4773
+ ["role"]
4210
4774
  ),
4211
4775
  "Participant list. Must be non-empty and include the owner contact."
4212
4776
  ),
@@ -4255,6 +4819,163 @@ var init_manage_commitment_tool = __esm({
4255
4819
  }
4256
4820
  });
4257
4821
 
4822
+ // src/contacts/delete_contact.ts
4823
+ function deleteContact(db, locatorId, options) {
4824
+ return withTransaction(db, () => {
4825
+ const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
4826
+ if (!contact) throw new AffinityNotFoundError("contact not found");
4827
+ if (contact.is_owner)
4828
+ throw new AffinityStateError("The owner contact cannot be deleted");
4829
+ const tables = [
4830
+ "contacts",
4831
+ "identities",
4832
+ "attributes",
4833
+ "links",
4834
+ "events",
4835
+ "event_participants",
4836
+ "open_commitments",
4837
+ "contact_merges",
4838
+ "dismissed_duplicates",
4839
+ "link_event_effects",
4840
+ "upcoming_occurrences",
4841
+ "link_rollups",
4842
+ "contact_rollups"
4843
+ ];
4844
+ const before = new Map(
4845
+ tables.map((table) => [
4846
+ table,
4847
+ db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
4848
+ ])
4849
+ );
4850
+ db.prepare(
4851
+ "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))"
4852
+ ).run(locatorId, locatorId);
4853
+ db.prepare(
4854
+ "DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
4855
+ ).run(locatorId, locatorId);
4856
+ db.prepare(
4857
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
4858
+ ).run(locatorId, locatorId);
4859
+ db.prepare(
4860
+ "UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
4861
+ ).run(locatorId);
4862
+ db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
4863
+ const now = Date.now();
4864
+ const affectedLinks = db.prepare(
4865
+ "SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
4866
+ ).all().map((row) => row.id);
4867
+ refreshAllBridgeScores(db, now);
4868
+ for (const id of affectedLinks) refreshLinkRollup(db, id, now);
4869
+ for (const row of db.prepare("SELECT id FROM contacts").all())
4870
+ refreshContactRollup(db, Number(row.id), now);
4871
+ const removed = tables.map((table) => {
4872
+ const remaining = new Set(
4873
+ db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
4874
+ );
4875
+ const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
4876
+ return { entity: table, count: ids.length, ids };
4877
+ }).filter((item) => item.count > 0);
4878
+ return { contactId: locatorId, removed, affectedLinks };
4879
+ });
4880
+ }
4881
+ var init_delete_contact = __esm({
4882
+ "src/contacts/delete_contact.ts"() {
4883
+ init_bridge_scores();
4884
+ init_affinity_not_found_error();
4885
+ init_affinity_state_error();
4886
+ init_affinity_validation_error();
4887
+ init_rollups2();
4888
+ init_with_transaction();
4889
+ init_rollups();
4890
+ }
4891
+ });
4892
+
4893
+ // src/contacts/export_contact.ts
4894
+ function exportContact(db, id) {
4895
+ const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
4896
+ const result = {};
4897
+ for (const [key, value] of Object.entries(row)) {
4898
+ const name = key.replace(
4899
+ /_([a-z])/g,
4900
+ (_, letter) => letter.toUpperCase()
4901
+ );
4902
+ result[name] = value;
4903
+ if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
4904
+ result[`${name}Iso`] = new Date(value).toISOString();
4905
+ if (key.endsWith("_json") && typeof value === "string")
4906
+ result[name] = JSON.parse(value);
4907
+ }
4908
+ return result;
4909
+ });
4910
+ const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
4911
+ if (!contact) throw new AffinityNotFoundError("contact not found");
4912
+ const events = query(
4913
+ "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
4914
+ id
4915
+ ).map((event) => ({
4916
+ ...event,
4917
+ participants: query(
4918
+ "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",
4919
+ event.id
4920
+ )
4921
+ }));
4922
+ return {
4923
+ contact,
4924
+ identities: query(
4925
+ "SELECT * FROM identities WHERE contact_id = ? ORDER BY id",
4926
+ id
4927
+ ),
4928
+ attributes: query(
4929
+ "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",
4930
+ id,
4931
+ id,
4932
+ id
4933
+ ),
4934
+ links: query(
4935
+ "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",
4936
+ id,
4937
+ id
4938
+ ),
4939
+ events,
4940
+ commitments: query(
4941
+ "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",
4942
+ id
4943
+ ),
4944
+ dates: query(
4945
+ "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",
4946
+ id,
4947
+ id,
4948
+ id
4949
+ ),
4950
+ mergeHistory: query(
4951
+ "SELECT * FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ? ORDER BY id",
4952
+ id,
4953
+ id
4954
+ )
4955
+ };
4956
+ }
4957
+ var init_export_contact = __esm({
4958
+ "src/contacts/export_contact.ts"() {
4959
+ init_affinity_not_found_error();
4960
+ }
4961
+ });
4962
+
4963
+ // src/contacts/export_affinity.ts
4964
+ function exportAffinity(db) {
4965
+ const now = Date.now();
4966
+ return {
4967
+ version: 1,
4968
+ exportedAt: now,
4969
+ exportedAtIso: new Date(now).toISOString(),
4970
+ contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
4971
+ };
4972
+ }
4973
+ var init_export_affinity = __esm({
4974
+ "src/contacts/export_affinity.ts"() {
4975
+ init_export_contact();
4976
+ }
4977
+ });
4978
+
4258
4979
  // src/contacts/receipts.ts
4259
4980
  function buildContactMutationReceipt(primary, partial) {
4260
4981
  const normalized = normalizeMutationReceiptPartial(partial);
@@ -4286,66 +5007,6 @@ var init_receipts4 = __esm({
4286
5007
  }
4287
5008
  });
4288
5009
 
4289
- // src/contacts/create_contact.ts
4290
- function createContact(db, input) {
4291
- return withTransaction(db, () => {
4292
- const name = input.name.trim();
4293
- if (name.length === 0) {
4294
- throw new AffinityValidationError("name must be non-empty");
4295
- }
4296
- if (!KINDS.has(input.kind)) {
4297
- throw new AffinityValidationError("invalid contact kind");
4298
- }
4299
- const now = resolveNow(input.now);
4300
- if (input.bootstrapOwner) {
4301
- const existing = db.prepare(
4302
- "SELECT id FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
4303
- ).get();
4304
- if (existing !== void 0) {
4305
- throw new AffinityConflictError("owner already exists");
4306
- }
4307
- }
4308
- const isOwner = input.bootstrapOwner ? 1 : 0;
4309
- const result = db.prepare(
4310
- `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
4311
- VALUES (?, ?, 'active', ?, ?, ?)`
4312
- ).run(name, input.kind, isOwner, now, now);
4313
- const id = Number(result.lastInsertRowid);
4314
- const row = getContactRowById(db, id);
4315
- if (!row) {
4316
- throw new AffinityInvariantError("inserted contact row not found");
4317
- }
4318
- refreshContactRollup(db, id, now);
4319
- const primary = mapContactRowToContactListItem(row);
4320
- return buildContactMutationReceipt(primary, {
4321
- created: [{ kind: "contact", id }]
4322
- });
4323
- });
4324
- }
4325
- var KINDS;
4326
- var init_create_contact = __esm({
4327
- "src/contacts/create_contact.ts"() {
4328
- init_mappers4();
4329
- init_queries();
4330
- init_receipts4();
4331
- init_rollups();
4332
- init_affinity_conflict_error();
4333
- init_affinity_invariant_error();
4334
- init_affinity_validation_error();
4335
- init_resolve_now();
4336
- init_with_transaction();
4337
- KINDS = /* @__PURE__ */ new Set([
4338
- "human",
4339
- "group",
4340
- "company",
4341
- "team",
4342
- "pet",
4343
- "service",
4344
- "other"
4345
- ]);
4346
- }
4347
- });
4348
-
4349
5010
  // src/contacts/revise_contact.ts
4350
5011
  function reviseContact(db, contactId, patch) {
4351
5012
  return withTransaction(db, () => {
@@ -4401,7 +5062,9 @@ function assertValidLifecycleTransition(from, to) {
4401
5062
  throw new AffinityStateError("merged contact is read-only");
4402
5063
  }
4403
5064
  if (to === "merged") {
4404
- throw new AffinityInvariantError("use mergeContacts to reach merged state");
5065
+ throw new AffinityInvariantError(
5066
+ "use manage_contact merge (CLI: contact merge) to reach merged state"
5067
+ );
4405
5068
  }
4406
5069
  if (from === to) {
4407
5070
  return;
@@ -4434,42 +5097,360 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
4434
5097
  }
4435
5098
  const row = getContactRowById(db, contactId);
4436
5099
  if (!row) {
4437
- throw new AffinityNotFoundError("contact not found");
4438
- }
4439
- const from = row.lifecycle_state;
4440
- assertValidLifecycleTransition(from, lifecycleState);
4441
- const now = resolveNow(options?.now);
4442
- if (from === lifecycleState) {
4443
- return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
4444
- updated: []
4445
- });
5100
+ throw new AffinityNotFoundError("contact not found");
5101
+ }
5102
+ const from = row.lifecycle_state;
5103
+ assertValidLifecycleTransition(from, lifecycleState);
5104
+ const now = resolveNow(options?.now);
5105
+ if (from === lifecycleState) {
5106
+ return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
5107
+ updated: []
5108
+ });
5109
+ }
5110
+ db.prepare(
5111
+ "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
5112
+ ).run(lifecycleState, now, contactId);
5113
+ refreshContactRollup(db, contactId, now);
5114
+ const next = getContactRowById(db, contactId);
5115
+ if (!next) {
5116
+ throw new AffinityNotFoundError("contact not found");
5117
+ }
5118
+ return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
5119
+ updated: [{ kind: "contact", id: contactId }]
5120
+ });
5121
+ });
5122
+ }
5123
+ var STATES2;
5124
+ var init_set_contact_lifecycle = __esm({
5125
+ "src/contacts/set_contact_lifecycle.ts"() {
5126
+ init_mappers4();
5127
+ init_queries();
5128
+ init_receipts4();
5129
+ init_rollups();
5130
+ init_validators4();
5131
+ init_affinity_not_found_error();
5132
+ init_affinity_validation_error();
5133
+ init_resolve_now();
5134
+ init_with_transaction();
5135
+ STATES2 = /* @__PURE__ */ new Set(["active", "dormant", "merged", "lost"]);
5136
+ }
5137
+ });
5138
+
5139
+ // src/contacts/create_contact.ts
5140
+ function createContact(db, input) {
5141
+ return withTransaction(db, () => {
5142
+ const name = input.name.trim();
5143
+ if (name.length === 0) {
5144
+ throw new AffinityValidationError("name must be non-empty");
5145
+ }
5146
+ if (!KINDS.has(input.kind)) {
5147
+ throw new AffinityValidationError("invalid contact kind");
5148
+ }
5149
+ if (input.isOwner !== void 0) {
5150
+ throw new AffinityValidationError(
5151
+ "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
5152
+ );
5153
+ }
5154
+ const now = resolveNow(input.now);
5155
+ if (input.bootstrapOwner) {
5156
+ const existing = db.prepare(
5157
+ "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
5158
+ ).get();
5159
+ if (existing !== void 0) {
5160
+ throw new AffinityConflictError(
5161
+ `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
5162
+ );
5163
+ }
5164
+ }
5165
+ const isOwner = input.bootstrapOwner ? 1 : 0;
5166
+ const result = db.prepare(
5167
+ `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
5168
+ VALUES (?, ?, 'active', ?, ?, ?)`
5169
+ ).run(name, input.kind, isOwner, now, now);
5170
+ const id = Number(result.lastInsertRowid);
5171
+ const row = getContactRowById(db, id);
5172
+ if (!row) {
5173
+ throw new AffinityInvariantError("inserted contact row not found");
5174
+ }
5175
+ refreshContactRollup(db, id, now);
5176
+ const primary = mapContactRowToContactListItem(row);
5177
+ return buildContactMutationReceipt(primary, {
5178
+ created: [{ kind: "contact", id }]
5179
+ });
5180
+ });
5181
+ }
5182
+ var KINDS;
5183
+ var init_create_contact = __esm({
5184
+ "src/contacts/create_contact.ts"() {
5185
+ init_mappers4();
5186
+ init_queries();
5187
+ init_receipts4();
5188
+ init_rollups();
5189
+ init_affinity_conflict_error();
5190
+ init_affinity_invariant_error();
5191
+ init_affinity_validation_error();
5192
+ init_resolve_now();
5193
+ init_with_transaction();
5194
+ KINDS = /* @__PURE__ */ new Set([
5195
+ "human",
5196
+ "group",
5197
+ "company",
5198
+ "team",
5199
+ "pet",
5200
+ "service",
5201
+ "other"
5202
+ ]);
5203
+ }
5204
+ });
5205
+
5206
+ // src/contacts/fold_search_text.ts
5207
+ function foldSearchText(value) {
5208
+ let folded = value.toLowerCase();
5209
+ if (/[^\u0020-\u007e]/.test(folded))
5210
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
5211
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
5212
+ }
5213
+ var init_fold_search_text = __esm({
5214
+ "src/contacts/fold_search_text.ts"() {
5215
+ }
5216
+ });
5217
+
5218
+ // src/identities/queries.ts
5219
+ function getIdentityRowById(db, id) {
5220
+ const row = db.prepare(
5221
+ `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5222
+ created_at, updated_at, removed_at
5223
+ FROM identities
5224
+ WHERE id = ? AND removed_at IS NULL`
5225
+ ).get(id);
5226
+ if (row === void 0) {
5227
+ return null;
5228
+ }
5229
+ return row;
5230
+ }
5231
+ var init_queries5 = __esm({
5232
+ "src/identities/queries.ts"() {
5233
+ }
5234
+ });
5235
+
5236
+ // src/identities/receipts.ts
5237
+ function buildIdentityMutationReceipt(primary, partial) {
5238
+ const normalized = normalizeMutationReceiptPartial(partial);
5239
+ return {
5240
+ primary,
5241
+ created: normalized.created,
5242
+ updated: normalized.updated,
5243
+ archived: normalized.archived,
5244
+ removed: normalized.removed,
5245
+ affectedLinks: normalized.affectedLinks,
5246
+ derivedEffects: normalized.derivedEffects
5247
+ };
5248
+ }
5249
+ var init_receipts5 = __esm({
5250
+ "src/identities/receipts.ts"() {
5251
+ init_normalize_mutation_receipt();
5252
+ }
5253
+ });
5254
+
5255
+ // src/identities/validators.ts
5256
+ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5257
+ const row = excludeIdentityId === void 0 ? db.prepare(
5258
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5259
+ ).get(normalizedKey) : db.prepare(
5260
+ "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
5261
+ ).get(normalizedKey, excludeIdentityId);
5262
+ if (row !== void 0) {
5263
+ const owner = db.prepare(
5264
+ "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"
5265
+ ).get(normalizedKey);
5266
+ throw new AffinityConflictError(
5267
+ `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
5268
+ );
5269
+ }
5270
+ }
5271
+ var init_validators5 = __esm({
5272
+ "src/identities/validators.ts"() {
5273
+ init_affinity_conflict_error();
5274
+ }
5275
+ });
5276
+
5277
+ // src/identities/validate_identity_input.ts
5278
+ function validateIdentityInput(type, value) {
5279
+ const kind = type.toLowerCase();
5280
+ if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
5281
+ throw new AffinityValidationError(
5282
+ `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
5283
+ );
5284
+ if (!value.trim())
5285
+ throw new AffinityValidationError("identity value must be non-empty");
5286
+ if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
5287
+ throw new AffinityValidationError(
5288
+ "email value must match local@domain.tld"
5289
+ );
5290
+ if (kind === "phone" && value.replace(/\D/g, "").length < 5)
5291
+ throw new AffinityValidationError(
5292
+ "phone value must contain at least 5 digits"
5293
+ );
5294
+ if (kind === "url" || kind === "website") {
5295
+ try {
5296
+ const url = new URL(value.includes("://") ? value : `https://${value}`);
5297
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
5298
+ throw new Error();
5299
+ } catch {
5300
+ throw new AffinityValidationError(
5301
+ "url/website value must be an http/https URL or bare domain"
5302
+ );
5303
+ }
5304
+ }
5305
+ }
5306
+ var identityTypes;
5307
+ var init_validate_identity_input = __esm({
5308
+ "src/identities/validate_identity_input.ts"() {
5309
+ init_affinity_validation_error();
5310
+ identityTypes = [
5311
+ "email",
5312
+ "phone",
5313
+ "url",
5314
+ "website",
5315
+ "handle",
5316
+ "x",
5317
+ "twitter",
5318
+ "linkedin",
5319
+ "github",
5320
+ "mastodon",
5321
+ "bluesky",
5322
+ "telegram",
5323
+ "whatsapp",
5324
+ "signal",
5325
+ "matrix",
5326
+ "discord",
5327
+ "slack",
5328
+ "instagram",
5329
+ "facebook",
5330
+ "youtube",
5331
+ "tiktok",
5332
+ "address",
5333
+ "other",
5334
+ "account_id",
5335
+ "alias"
5336
+ ];
5337
+ }
5338
+ });
5339
+
5340
+ // src/identities/add_identity.ts
5341
+ function addIdentity(db, contactId, input) {
5342
+ return withTransaction(db, () => {
5343
+ const contact = getContactRowById(db, contactId);
5344
+ if (!contact) {
5345
+ throw new AffinityNotFoundError("contact not found");
5346
+ }
5347
+ if (contact.lifecycle_state === "merged") {
5348
+ throw new AffinityStateError("merged contact is read-only");
5349
+ }
5350
+ const type = input.type.trim();
5351
+ const value = input.value.trim();
5352
+ if (type.length === 0 || value.length === 0) {
5353
+ throw new AffinityValidationError("type and value must be non-empty");
5354
+ }
5355
+ validateIdentityInput(type, value);
5356
+ const nk = normalizeIdentityKey(type, value);
5357
+ assertNoIdentityCollision(db, nk);
5358
+ const now = resolveNow(input.now);
5359
+ const verified = input.verified ? 1 : 0;
5360
+ const verifiedAt = verified === 1 ? now : null;
5361
+ const result = db.prepare(
5362
+ `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
5363
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
5364
+ ).run(
5365
+ contactId,
5366
+ type,
5367
+ value,
5368
+ input.label ?? null,
5369
+ nk,
5370
+ verified,
5371
+ verifiedAt,
5372
+ now,
5373
+ now
5374
+ );
5375
+ const id = Number(result.lastInsertRowid);
5376
+ const row = getIdentityRowById(db, id);
5377
+ if (!row) {
5378
+ throw new AffinityInvariantError("inserted identity not found");
4446
5379
  }
4447
- db.prepare(
4448
- "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
4449
- ).run(lifecycleState, now, contactId);
4450
5380
  refreshContactRollup(db, contactId, now);
4451
- const next = getContactRowById(db, contactId);
4452
- if (!next) {
4453
- throw new AffinityNotFoundError("contact not found");
4454
- }
4455
- return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
4456
- updated: [{ kind: "contact", id: contactId }]
5381
+ db.prepare(
5382
+ "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
5383
+ ).run(contactId, contactId);
5384
+ return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
5385
+ created: [{ kind: "identity", id }]
4457
5386
  });
4458
5387
  });
4459
5388
  }
4460
- var STATES2;
4461
- var init_set_contact_lifecycle = __esm({
4462
- "src/contacts/set_contact_lifecycle.ts"() {
4463
- init_mappers4();
5389
+ var init_add_identity = __esm({
5390
+ "src/identities/add_identity.ts"() {
4464
5391
  init_queries();
4465
- init_receipts4();
4466
5392
  init_rollups();
4467
- init_validators4();
5393
+ init_mappers2();
5394
+ init_normalize();
5395
+ init_queries5();
5396
+ init_receipts5();
5397
+ init_validators5();
5398
+ init_affinity_invariant_error();
4468
5399
  init_affinity_not_found_error();
5400
+ init_affinity_state_error();
4469
5401
  init_affinity_validation_error();
4470
5402
  init_resolve_now();
4471
5403
  init_with_transaction();
4472
- STATES2 = /* @__PURE__ */ new Set(["active", "dormant", "merged", "lost"]);
5404
+ init_validate_identity_input();
5405
+ }
5406
+ });
5407
+
5408
+ // src/tools/create_contact_tool_result.ts
5409
+ function createContactToolResult(db, input) {
5410
+ return withTransaction(db, () => {
5411
+ const receipt = createContact(db, input);
5412
+ const identityOwners = /* @__PURE__ */ new Set();
5413
+ for (const identity of input.identities ?? []) {
5414
+ validateIdentityInput(identity.type.trim(), identity.value.trim());
5415
+ const existing = db.prepare(
5416
+ "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5417
+ ).get(normalizeIdentityKey(identity.type, identity.value));
5418
+ if (existing) {
5419
+ if (Number(existing.contact_id) !== receipt.primary.id)
5420
+ identityOwners.add(Number(existing.contact_id));
5421
+ continue;
5422
+ }
5423
+ const added = addIdentity(db, receipt.primary.id, identity);
5424
+ receipt.created.push(...added.created);
5425
+ }
5426
+ const result = mutationToolResult("create", receipt, "contact");
5427
+ const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
5428
+ const warnings = db.prepare(
5429
+ "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
5430
+ ).all(receipt.primary.id).flatMap((row) => {
5431
+ const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
5432
+ return score === void 0 ? [] : [
5433
+ {
5434
+ code: "possible_duplicate",
5435
+ message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
5436
+ contactId: row.id,
5437
+ score
5438
+ }
5439
+ ];
5440
+ });
5441
+ if (warnings.length) result.warnings = warnings;
5442
+ return result;
5443
+ });
5444
+ }
5445
+ var init_create_contact_tool_result = __esm({
5446
+ "src/tools/create_contact_tool_result.ts"() {
5447
+ init_create_contact();
5448
+ init_fold_search_text();
5449
+ init_add_identity();
5450
+ init_normalize();
5451
+ init_validate_identity_input();
5452
+ init_with_transaction();
5453
+ init_tool_mutation();
4473
5454
  }
4474
5455
  });
4475
5456
 
@@ -4491,24 +5472,76 @@ function contactLocatorSchema3(description) {
4491
5472
  description
4492
5473
  };
4493
5474
  }
4494
- function manageContactToolHandler(db, input) {
5475
+ function manageContactToolHandler(db, rawInput) {
5476
+ const input = structuredClone(rawInput);
5477
+ const inputError = validateToolInput(manageContactTool.inputSchema, input);
5478
+ if (inputError)
5479
+ return toolFailure(
5480
+ "protocol",
5481
+ "invalid_input",
5482
+ "Invalid input.",
5483
+ inputError
5484
+ );
4495
5485
  return withToolHandling(() => {
4496
- if (input.action === "create") {
4497
- return mutationToolResult(
4498
- "create",
4499
- createContact(db, input.input),
4500
- "contact"
5486
+ if (input.action === "export") {
5487
+ if (!input.contact)
5488
+ return toolSuccess("Exported Affinity.", {
5489
+ action: "export",
5490
+ export: exportAffinity(db)
5491
+ });
5492
+ const target = resolveContactLocator(db, input.contact, "contact", true);
5493
+ if (!target.ok) return target.result;
5494
+ return toolSuccess("Exported contact.", {
5495
+ action: "export",
5496
+ export: exportContact(db, target.value.id)
5497
+ });
5498
+ }
5499
+ if (input.action === "delete" && input.confirm !== true)
5500
+ return toolFailure(
5501
+ "protocol",
5502
+ "invalid_input",
5503
+ "Delete requires confirmation.",
5504
+ "delete is permanent; pass confirm: true (CLI: --confirm)"
4501
5505
  );
5506
+ if (input.action === "create") {
5507
+ if (!input.input)
5508
+ return toolFailure(
5509
+ "protocol",
5510
+ "invalid_input",
5511
+ "Missing input.",
5512
+ "input is required for create."
5513
+ );
5514
+ return createContactToolResult(db, input.input);
4502
5515
  }
4503
- const contact = resolveContactLocator(db, input.contact, "contact");
5516
+ const contact = resolveContactLocator(
5517
+ db,
5518
+ input.contact,
5519
+ "contact",
5520
+ input.action === "delete"
5521
+ );
4504
5522
  if (!contact.ok) {
4505
5523
  return contact.result;
4506
5524
  }
5525
+ if (input.action === "delete") {
5526
+ const deletion = deleteContact(db, contact.value.id, { confirm: true });
5527
+ return toolSuccess("Permanently deleted contact.", {
5528
+ action: "delete",
5529
+ deletion,
5530
+ changes: deletion.removed.flatMap(
5531
+ (item) => item.ids.map((id) => ({
5532
+ entity: item.entity,
5533
+ id,
5534
+ action: "removed"
5535
+ }))
5536
+ )
5537
+ });
5538
+ }
4507
5539
  if (input.action === "revise") {
4508
5540
  return mutationToolResult(
4509
5541
  "revise",
4510
5542
  reviseContact(db, contact.value.id, input.patch),
4511
- "contact"
5543
+ "contact",
5544
+ Object.keys(input.patch)
4512
5545
  );
4513
5546
  }
4514
5547
  return mutationToolResult(
@@ -4526,16 +5559,22 @@ function manageContactToolHandler(db, input) {
4526
5559
  var manageContactTool;
4527
5560
  var init_manage_contact_tool = __esm({
4528
5561
  "src/tools/manage_contact_tool.ts"() {
4529
- init_create_contact();
5562
+ init_delete_contact();
5563
+ init_export_affinity();
5564
+ init_export_contact();
4530
5565
  init_revise_contact();
4531
5566
  init_set_contact_lifecycle();
5567
+ init_create_contact_tool_result();
4532
5568
  init_tool_metadata();
4533
5569
  init_tool_mutation();
4534
5570
  init_tool_names();
4535
5571
  init_tool_resolvers();
5572
+ init_tool_types();
5573
+ init_tool_types();
5574
+ init_validate_tool_input();
4536
5575
  manageContactTool = defineAffinityTool({
4537
5576
  name: manageContactToolName,
4538
- 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.",
5577
+ 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.",
4539
5578
  whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
4540
5579
  whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
4541
5580
  sideEffects: "writes_state",
@@ -4556,10 +5595,25 @@ var init_manage_contact_tool = __esm({
4556
5595
  action: enumSchema("Operation to perform.", [
4557
5596
  "create",
4558
5597
  "revise",
4559
- "set_lifecycle"
5598
+ "set_lifecycle",
5599
+ "export",
5600
+ "delete"
4560
5601
  ]),
4561
5602
  input: objectSchema(
4562
5603
  {
5604
+ identities: arraySchema(
5605
+ objectSchema(
5606
+ {
5607
+ type: stringSchema("Identity type."),
5608
+ value: stringSchema("Identity value."),
5609
+ label: stringSchema("Optional label."),
5610
+ verified: booleanSchema("Verified identity."),
5611
+ now: integerSchema("Optional timestamp.")
5612
+ },
5613
+ ["type", "value"]
5614
+ ),
5615
+ "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
5616
+ ),
4563
5617
  name: stringSchema("Contact name."),
4564
5618
  kind: enumSchema("Contact kind.", [
4565
5619
  "human",
@@ -4578,6 +5632,7 @@ var init_manage_contact_tool = __esm({
4578
5632
  ["name", "kind"],
4579
5633
  "New contact payload. Required when action=create."
4580
5634
  ),
5635
+ confirm: booleanSchema("Required true for permanent deletion."),
4581
5636
  contact: contactLocatorSchema3(
4582
5637
  "Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
4583
5638
  ),
@@ -4847,9 +5902,32 @@ function resolveDateAnchorTarget(db, target) {
4847
5902
  }
4848
5903
  return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
4849
5904
  }
4850
- function manageDateAnchorToolHandler(db, input) {
5905
+ function manageDateAnchorToolHandler(db, rawInput) {
5906
+ const input = structuredClone(rawInput);
5907
+ const inputError = validateToolInput(manageDateAnchorTool.inputSchema, input);
5908
+ if (inputError)
5909
+ return toolFailure(
5910
+ "protocol",
5911
+ "invalid_input",
5912
+ "Invalid input.",
5913
+ inputError
5914
+ );
5915
+ if (input.action !== "add" && input.anchorEventId === void 0)
5916
+ return toolFailure(
5917
+ "protocol",
5918
+ "invalid_input",
5919
+ "Missing anchorEventId.",
5920
+ "anchorEventId is required."
5921
+ );
4851
5922
  return withToolHandling(() => {
4852
5923
  if (input.action === "add") {
5924
+ if (!input.input)
5925
+ return toolFailure(
5926
+ "protocol",
5927
+ "invalid_input",
5928
+ "Missing input.",
5929
+ "input is required for add."
5930
+ );
4853
5931
  const resolved = resolveDateAnchorTarget(db, input.input.target);
4854
5932
  if (resolved.kind === "error") {
4855
5933
  return resolved.result;
@@ -4864,7 +5942,8 @@ function manageDateAnchorToolHandler(db, input) {
4864
5942
  return mutationToolResult(
4865
5943
  "revise",
4866
5944
  reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
4867
- "date anchor"
5945
+ "date anchor",
5946
+ Object.keys(input.patch)
4868
5947
  );
4869
5948
  }
4870
5949
  return mutationToolResult(
@@ -4884,6 +5963,8 @@ var init_manage_date_anchor_tool = __esm({
4884
5963
  init_tool_mutation();
4885
5964
  init_tool_names();
4886
5965
  init_tool_resolvers();
5966
+ init_tool_types();
5967
+ init_validate_tool_input();
4887
5968
  manageDateAnchorTool = defineAffinityTool({
4888
5969
  name: manageDateAnchorToolName,
4889
5970
  description: "Add, revise, or remove recurring date anchors on contacts or links. Use this for birthdays, anniversaries, and yearly events \u2014 not manage_attribute.",
@@ -4997,7 +6078,7 @@ function buildDismissalMutationReceipt(primary, partial) {
4997
6078
  derivedEffects: normalized.derivedEffects
4998
6079
  };
4999
6080
  }
5000
- var init_receipts5 = __esm({
6081
+ var init_receipts6 = __esm({
5001
6082
  "src/merges/receipts.ts"() {
5002
6083
  init_normalize_mutation_receipt();
5003
6084
  }
@@ -5059,7 +6140,7 @@ var init_dismiss_duplicate = __esm({
5059
6140
  init_affinity_validation_error();
5060
6141
  init_resolve_now();
5061
6142
  init_with_transaction();
5062
- init_receipts5();
6143
+ init_receipts6();
5063
6144
  }
5064
6145
  });
5065
6146
 
@@ -5098,7 +6179,7 @@ var init_undismiss_duplicate = __esm({
5098
6179
  init_affinity_not_found_error();
5099
6180
  init_affinity_validation_error();
5100
6181
  init_with_transaction();
5101
- init_receipts5();
6182
+ init_receipts6();
5102
6183
  }
5103
6184
  });
5104
6185
 
@@ -5120,7 +6201,19 @@ function contactLocatorSchema5(description) {
5120
6201
  description
5121
6202
  };
5122
6203
  }
5123
- function manageDuplicateDismissalToolHandler(db, input) {
6204
+ function manageDuplicateDismissalToolHandler(db, rawInput) {
6205
+ const input = structuredClone(rawInput);
6206
+ const inputError = validateToolInput(
6207
+ manageDuplicateDismissalTool.inputSchema,
6208
+ input
6209
+ );
6210
+ if (inputError)
6211
+ return toolFailure(
6212
+ "protocol",
6213
+ "invalid_input",
6214
+ "Invalid input.",
6215
+ inputError
6216
+ );
5124
6217
  return withToolHandling(() => {
5125
6218
  const left = resolveContactLocator(db, input.left, "left");
5126
6219
  if (!left.ok) {
@@ -5130,6 +6223,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
5130
6223
  if (!right.ok) {
5131
6224
  return right.result;
5132
6225
  }
6226
+ const leftContactId = Math.min(left.value.id, right.value.id);
6227
+ const rightContactId = Math.max(left.value.id, right.value.id);
6228
+ const existed = db.prepare(
6229
+ "SELECT 1 FROM dismissed_duplicates WHERE left_id = ? AND right_id = ?"
6230
+ ).get(leftContactId, rightContactId) !== void 0;
5133
6231
  if (input.action === "dismiss") {
5134
6232
  const receipt2 = dismissDuplicate(
5135
6233
  db,
@@ -5139,13 +6237,35 @@ function manageDuplicateDismissalToolHandler(db, input) {
5139
6237
  );
5140
6238
  return toolSuccess(
5141
6239
  `Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
5142
- { action: "dismiss", primary: receipt2.primary }
6240
+ {
6241
+ ...receipt2,
6242
+ action: "dismiss",
6243
+ changes: [
6244
+ {
6245
+ entity: "duplicate_dismissal",
6246
+ leftContactId,
6247
+ rightContactId,
6248
+ action: existed ? "updated" : "created"
6249
+ }
6250
+ ]
6251
+ }
5143
6252
  );
5144
6253
  }
5145
6254
  const receipt = undismissDuplicate(db, left.value.id, right.value.id);
5146
6255
  return toolSuccess(
5147
6256
  `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
5148
- { action: "undismiss", primary: receipt.primary }
6257
+ {
6258
+ ...receipt,
6259
+ action: "undismiss",
6260
+ changes: existed ? [
6261
+ {
6262
+ entity: "duplicate_dismissal",
6263
+ leftContactId,
6264
+ rightContactId,
6265
+ action: "removed"
6266
+ }
6267
+ ] : []
6268
+ }
5149
6269
  );
5150
6270
  }, "Duplicate dismissal failed.");
5151
6271
  }
@@ -5158,9 +6278,11 @@ var init_manage_duplicate_dismissal_tool = __esm({
5158
6278
  init_tool_names();
5159
6279
  init_tool_resolvers();
5160
6280
  init_tool_types();
6281
+ init_tool_types();
6282
+ init_validate_tool_input();
5161
6283
  manageDuplicateDismissalTool = defineAffinityTool({
5162
6284
  name: manageDuplicateDismissalToolName,
5163
- description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in listDuplicateCandidates.",
6285
+ description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
5164
6286
  whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
5165
6287
  whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
5166
6288
  sideEffects: "writes_state",
@@ -5176,142 +6298,20 @@ var init_manage_duplicate_dismissal_tool = __esm({
5176
6298
  outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
5177
6299
  inputSchema: objectSchema(
5178
6300
  {
5179
- action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
5180
- left: contactLocatorSchema5(
5181
- "Left contact. Provide contactId or identity."
5182
- ),
5183
- right: contactLocatorSchema5(
5184
- "Right contact. Provide contactId or identity."
5185
- ),
5186
- reason: nullableStringSchema("Optional reason for the dismissal.")
5187
- },
5188
- ["action", "left", "right"],
5189
- "Dismiss or un-dismiss a duplicate candidate pair."
5190
- ),
5191
- handler: manageDuplicateDismissalToolHandler
5192
- });
5193
- }
5194
- });
5195
-
5196
- // src/identities/queries.ts
5197
- function getIdentityRowById(db, id) {
5198
- const row = db.prepare(
5199
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5200
- created_at, updated_at, removed_at
5201
- FROM identities
5202
- WHERE id = ? AND removed_at IS NULL`
5203
- ).get(id);
5204
- if (row === void 0) {
5205
- return null;
5206
- }
5207
- return row;
5208
- }
5209
- var init_queries5 = __esm({
5210
- "src/identities/queries.ts"() {
5211
- }
5212
- });
5213
-
5214
- // src/identities/receipts.ts
5215
- function buildIdentityMutationReceipt(primary, partial) {
5216
- const normalized = normalizeMutationReceiptPartial(partial);
5217
- return {
5218
- primary,
5219
- created: normalized.created,
5220
- updated: normalized.updated,
5221
- archived: normalized.archived,
5222
- removed: normalized.removed,
5223
- affectedLinks: normalized.affectedLinks,
5224
- derivedEffects: normalized.derivedEffects
5225
- };
5226
- }
5227
- var init_receipts6 = __esm({
5228
- "src/identities/receipts.ts"() {
5229
- init_normalize_mutation_receipt();
5230
- }
5231
- });
5232
-
5233
- // src/identities/validators.ts
5234
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5235
- const row = excludeIdentityId === void 0 ? db.prepare(
5236
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5237
- ).get(normalizedKey) : db.prepare(
5238
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
5239
- ).get(normalizedKey, excludeIdentityId);
5240
- if (row !== void 0) {
5241
- throw new AffinityConflictError(
5242
- "another contact already owns this normalized identity"
5243
- );
5244
- }
5245
- }
5246
- var init_validators5 = __esm({
5247
- "src/identities/validators.ts"() {
5248
- init_affinity_conflict_error();
5249
- }
5250
- });
5251
-
5252
- // src/identities/add_identity.ts
5253
- function addIdentity(db, contactId, input) {
5254
- return withTransaction(db, () => {
5255
- const contact = getContactRowById(db, contactId);
5256
- if (!contact) {
5257
- throw new AffinityNotFoundError("contact not found");
5258
- }
5259
- if (contact.lifecycle_state === "merged") {
5260
- throw new AffinityStateError("merged contact is read-only");
5261
- }
5262
- const type = input.type.trim();
5263
- const value = input.value.trim();
5264
- if (type.length === 0 || value.length === 0) {
5265
- throw new AffinityValidationError("type and value must be non-empty");
5266
- }
5267
- const nk = normalizeIdentityKey(type, value);
5268
- assertNoIdentityCollision(db, nk);
5269
- const now = resolveNow(input.now);
5270
- const verified = input.verified ? 1 : 0;
5271
- const verifiedAt = verified === 1 ? now : null;
5272
- const result = db.prepare(
5273
- `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
5274
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
5275
- ).run(
5276
- contactId,
5277
- type,
5278
- value,
5279
- input.label ?? null,
5280
- nk,
5281
- verified,
5282
- verifiedAt,
5283
- now,
5284
- now
5285
- );
5286
- const id = Number(result.lastInsertRowid);
5287
- const row = getIdentityRowById(db, id);
5288
- if (!row) {
5289
- throw new AffinityInvariantError("inserted identity not found");
5290
- }
5291
- refreshContactRollup(db, contactId, now);
5292
- db.prepare(
5293
- "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
5294
- ).run(contactId, contactId);
5295
- return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
5296
- created: [{ kind: "identity", id }]
5297
- });
5298
- });
5299
- }
5300
- var init_add_identity = __esm({
5301
- "src/identities/add_identity.ts"() {
5302
- init_queries();
5303
- init_rollups();
5304
- init_mappers2();
5305
- init_normalize();
5306
- init_queries5();
5307
- init_receipts6();
5308
- init_validators5();
5309
- init_affinity_invariant_error();
5310
- init_affinity_not_found_error();
5311
- init_affinity_state_error();
5312
- init_affinity_validation_error();
5313
- init_resolve_now();
5314
- init_with_transaction();
6301
+ action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
6302
+ left: contactLocatorSchema5(
6303
+ "Left contact. Provide contactId or identity."
6304
+ ),
6305
+ right: contactLocatorSchema5(
6306
+ "Right contact. Provide contactId or identity."
6307
+ ),
6308
+ reason: nullableStringSchema("Optional reason for the dismissal.")
6309
+ },
6310
+ ["action", "left", "right"],
6311
+ "Dismiss or un-dismiss a duplicate candidate pair."
6312
+ ),
6313
+ handler: manageDuplicateDismissalToolHandler
6314
+ });
5315
6315
  }
5316
6316
  });
5317
6317
 
@@ -5346,7 +6346,7 @@ var init_remove_identity = __esm({
5346
6346
  init_rollups();
5347
6347
  init_mappers2();
5348
6348
  init_queries5();
5349
- init_receipts6();
6349
+ init_receipts5();
5350
6350
  init_affinity_not_found_error();
5351
6351
  init_affinity_state_error();
5352
6352
  init_resolve_now();
@@ -5379,6 +6379,7 @@ function reviseIdentity(db, identityId, patch) {
5379
6379
  if (nextType.length === 0 || nextValue.length === 0) {
5380
6380
  throw new AffinityValidationError("type and value must be non-empty");
5381
6381
  }
6382
+ validateIdentityInput(nextType, nextValue);
5382
6383
  const nextLabel = patch.label !== void 0 ? patch.label : row.label;
5383
6384
  const nkNext = normalizeIdentityKey(nextType, nextValue);
5384
6385
  if (nkNext !== row.normalized_key) {
@@ -5404,13 +6405,14 @@ var init_revise_identity = __esm({
5404
6405
  init_mappers2();
5405
6406
  init_normalize();
5406
6407
  init_queries5();
5407
- init_receipts6();
6408
+ init_receipts5();
5408
6409
  init_validators5();
5409
6410
  init_affinity_not_found_error();
5410
6411
  init_affinity_state_error();
5411
6412
  init_affinity_validation_error();
5412
6413
  init_resolve_now();
5413
6414
  init_with_transaction();
6415
+ init_validate_identity_input();
5414
6416
  }
5415
6417
  });
5416
6418
 
@@ -5444,7 +6446,7 @@ var init_verify_identity = __esm({
5444
6446
  init_queries();
5445
6447
  init_mappers2();
5446
6448
  init_queries5();
5447
- init_receipts6();
6449
+ init_receipts5();
5448
6450
  init_affinity_not_found_error();
5449
6451
  init_affinity_state_error();
5450
6452
  init_resolve_now();
@@ -5460,7 +6462,9 @@ function contactLocatorSchema6(description) {
5460
6462
  contactId: integerSchema("Exact contact id."),
5461
6463
  identity: objectSchema(
5462
6464
  {
5463
- type: stringSchema("Identity type."),
6465
+ type: stringSchema(
6466
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6467
+ ),
5464
6468
  value: stringSchema("Identity value.")
5465
6469
  },
5466
6470
  ["type", "value"],
@@ -5470,7 +6474,23 @@ function contactLocatorSchema6(description) {
5470
6474
  description
5471
6475
  };
5472
6476
  }
5473
- function manageIdentityToolHandler(db, input) {
6477
+ function manageIdentityToolHandler(db, rawInput) {
6478
+ const input = structuredClone(rawInput);
6479
+ const inputError = validateToolInput(manageIdentityTool.inputSchema, input);
6480
+ if (inputError)
6481
+ return toolFailure(
6482
+ "protocol",
6483
+ "invalid_input",
6484
+ "Invalid input.",
6485
+ inputError
6486
+ );
6487
+ if (input.action !== "add" && input.identityId === void 0)
6488
+ return toolFailure(
6489
+ "protocol",
6490
+ "invalid_input",
6491
+ "Missing identityId.",
6492
+ "identityId is required."
6493
+ );
5474
6494
  return withToolHandling(() => {
5475
6495
  switch (input.action) {
5476
6496
  case "add": {
@@ -5488,7 +6508,8 @@ function manageIdentityToolHandler(db, input) {
5488
6508
  return mutationToolResult(
5489
6509
  "revise",
5490
6510
  reviseIdentity(db, input.identityId, input.patch),
5491
- "identity"
6511
+ "identity",
6512
+ Object.keys(input.patch)
5492
6513
  );
5493
6514
  case "verify":
5494
6515
  return mutationToolResult(
@@ -5511,11 +6532,14 @@ var init_manage_identity_tool = __esm({
5511
6532
  init_add_identity();
5512
6533
  init_remove_identity();
5513
6534
  init_revise_identity();
6535
+ init_validate_identity_input();
5514
6536
  init_verify_identity();
5515
6537
  init_tool_metadata();
5516
6538
  init_tool_mutation();
5517
6539
  init_tool_names();
5518
6540
  init_tool_resolvers();
6541
+ init_tool_types();
6542
+ init_validate_tool_input();
5519
6543
  manageIdentityTool = defineAffinityTool({
5520
6544
  name: manageIdentityToolName,
5521
6545
  description: "Add, revise, verify, or remove one routing identity on a contact. Use this for emails, phone numbers, social handles, and URLs \u2014 anything that could locate a person. Do not use manage_attribute for these.",
@@ -5548,7 +6572,9 @@ var init_manage_identity_tool = __esm({
5548
6572
  ),
5549
6573
  input: objectSchema(
5550
6574
  {
5551
- type: stringSchema("Identity type."),
6575
+ type: stringSchema(
6576
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6577
+ ),
5552
6578
  value: stringSchema("Identity value."),
5553
6579
  label: stringSchema("Optional label."),
5554
6580
  verified: booleanSchema("Whether the identity starts verified."),
@@ -5562,7 +6588,9 @@ var init_manage_identity_tool = __esm({
5562
6588
  ),
5563
6589
  patch: objectSchema(
5564
6590
  {
5565
- type: stringSchema("Optional identity type."),
6591
+ type: stringSchema(
6592
+ `Optional identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6593
+ ),
5566
6594
  value: stringSchema("Optional identity value."),
5567
6595
  label: stringSchema("Optional label.")
5568
6596
  },
@@ -5833,7 +6861,19 @@ function linkLocatorSchema4(description) {
5833
6861
  description
5834
6862
  };
5835
6863
  }
5836
- function manageRelationshipToolHandler(db, input) {
6864
+ function manageRelationshipToolHandler(db, rawInput) {
6865
+ const input = structuredClone(rawInput);
6866
+ const inputError = validateToolInput(
6867
+ manageRelationshipTool.inputSchema,
6868
+ input
6869
+ );
6870
+ if (inputError)
6871
+ return toolFailure(
6872
+ "protocol",
6873
+ "invalid_input",
6874
+ "Invalid input.",
6875
+ inputError
6876
+ );
5837
6877
  return withToolHandling(() => {
5838
6878
  switch (input.action) {
5839
6879
  case "seed_social_link": {
@@ -5932,6 +6972,8 @@ var init_manage_relationship_tool = __esm({
5932
6972
  init_tool_mutation();
5933
6973
  init_tool_names();
5934
6974
  init_tool_resolvers();
6975
+ init_tool_types();
6976
+ init_validate_tool_input();
5935
6977
  manageRelationshipTool = defineAffinityTool({
5936
6978
  name: manageRelationshipToolName,
5937
6979
  description: "Seed social links, revise bond and state, and manage structural ties. Seed a link as soon as a relationship is mentioned \u2014 do not wait for repeated evidence.",
@@ -6237,7 +7279,16 @@ function contactLocatorSchema8(description) {
6237
7279
  description
6238
7280
  };
6239
7281
  }
6240
- function mergeContactsToolHandler(db, input) {
7282
+ function mergeContactsToolHandler(db, rawInput) {
7283
+ const input = structuredClone(rawInput);
7284
+ const inputError = validateToolInput(mergeContactsTool.inputSchema, input);
7285
+ if (inputError)
7286
+ return toolFailure(
7287
+ "protocol",
7288
+ "invalid_input",
7289
+ "Invalid input.",
7290
+ inputError
7291
+ );
6241
7292
  return withToolHandling(() => {
6242
7293
  const winner = resolveContactLocator(db, input.winner, "winner");
6243
7294
  if (!winner.ok) {
@@ -6267,6 +7318,8 @@ var init_merge_contacts_tool = __esm({
6267
7318
  init_tool_mutation();
6268
7319
  init_tool_names();
6269
7320
  init_tool_resolvers();
7321
+ init_tool_types();
7322
+ init_validate_tool_input();
6270
7323
  mergeContactsTool = defineAffinityTool({
6271
7324
  name: mergeContactsToolName,
6272
7325
  description: "Merge one loser contact into one winner contact using explicit contact locators.",
@@ -6526,7 +7579,16 @@ function prepareParticipants2(db, raw, injectOwner) {
6526
7579
  const normalized = raw.map(normalizeParticipant2);
6527
7580
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
6528
7581
  }
6529
- function recordEventToolHandler(db, input) {
7582
+ function recordEventToolHandler(db, rawInput) {
7583
+ const input = structuredClone(rawInput);
7584
+ const inputError = validateToolInput(recordEventTool.inputSchema, input);
7585
+ if (inputError)
7586
+ return toolFailure(
7587
+ "protocol",
7588
+ "invalid_input",
7589
+ "Invalid input.",
7590
+ inputError
7591
+ );
6530
7592
  return withToolHandling(() => {
6531
7593
  const patched = {
6532
7594
  ...input.input,
@@ -6576,6 +7638,8 @@ var init_record_event_tool = __esm({
6576
7638
  init_tool_mutation();
6577
7639
  init_tool_names();
6578
7640
  init_tool_resolvers();
7641
+ init_tool_types();
7642
+ init_validate_tool_input();
6579
7643
  recordEventTool = defineAffinityTool({
6580
7644
  name: recordEventToolName,
6581
7645
  description: "Record one social event such as an interaction, observation, milestone, or transaction. For interactions, milestones, and transactions the owner contact is auto-injected into participants if missing. Observations may omit the owner when recording third-party evidence.",
@@ -6636,7 +7700,7 @@ var init_record_event_tool = __esm({
6636
7700
  "observed"
6637
7701
  ])
6638
7702
  },
6639
- ["contactId", "role"]
7703
+ ["role"]
6640
7704
  ),
6641
7705
  "Participant list. Must be non-empty and include the owner contact."
6642
7706
  ),
@@ -6654,6 +7718,27 @@ var init_record_event_tool = __esm({
6654
7718
  }
6655
7719
  });
6656
7720
 
7721
+ // src/contacts/contact_attribute_clauses.ts
7722
+ function contactAttributeClauses(filters) {
7723
+ const clauses = [];
7724
+ const params = [];
7725
+ const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
7726
+ const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
7727
+ for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
7728
+ const separator = filter.indexOf("=");
7729
+ const name = separator < 0 ? filter : filter.slice(0, separator);
7730
+ clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
7731
+ 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(?)"})`);
7732
+ params.push(name);
7733
+ if (separator >= 0) params.push(filter.slice(separator + 1));
7734
+ }
7735
+ return { clauses, params };
7736
+ }
7737
+ var init_contact_attribute_clauses = __esm({
7738
+ "src/contacts/contact_attribute_clauses.ts"() {
7739
+ }
7740
+ });
7741
+
6657
7742
  // src/contacts/list_contacts.ts
6658
7743
  function listContacts(db, filters, options) {
6659
7744
  assertDefaultOrdering("listContacts", options);
@@ -6677,6 +7762,9 @@ function listContacts(db, filters, options) {
6677
7762
  if (filters?.includeDormant === false || options?.includeDormant === false) {
6678
7763
  clauses.push("lifecycle_state != 'dormant'");
6679
7764
  }
7765
+ const attributeFilters = contactAttributeClauses(filters);
7766
+ clauses.push(...attributeFilters.clauses);
7767
+ params.push(...attributeFilters.params);
6680
7768
  const where = clauses.join(" AND ");
6681
7769
  const rows = db.prepare(
6682
7770
  `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
@@ -6705,6 +7793,7 @@ var init_list_contacts = __esm({
6705
7793
  "src/contacts/list_contacts.ts"() {
6706
7794
  init_assert_default_ordering();
6707
7795
  init_resolve_list_limit_offset();
7796
+ init_contact_attribute_clauses();
6708
7797
  init_mappers4();
6709
7798
  }
6710
7799
  });
@@ -6932,131 +8021,6 @@ var init_list_upcoming_dates = __esm({
6932
8021
  }
6933
8022
  });
6934
8023
 
6935
- // src/events/get_contact_journal.ts
6936
- function getContactJournal(db, contactId, options) {
6937
- assertDefaultOrdering("getContactJournal", options);
6938
- const { limit, offset } = resolveListLimitOffset(options);
6939
- const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
6940
- const params = [contactId];
6941
- if (options?.since !== void 0) {
6942
- clauses.push("e.occurred_at >= ?");
6943
- params.push(options.since);
6944
- }
6945
- if (options?.until !== void 0) {
6946
- clauses.push("e.occurred_at <= ?");
6947
- params.push(options.until);
6948
- }
6949
- if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
6950
- const ph = options.eventTypes.map(() => "?").join(", ");
6951
- clauses.push(`e.type IN (${ph})`);
6952
- params.push(...options.eventTypes);
6953
- }
6954
- const where = clauses.join(" AND ");
6955
- const rows = db.prepare(
6956
- `SELECT e.id FROM events e
6957
- INNER JOIN event_participants ep ON ep.event_id = e.id
6958
- WHERE ${where}
6959
- ORDER BY e.occurred_at DESC, e.id DESC
6960
- LIMIT ? OFFSET ?`
6961
- ).all(...params, limit, offset);
6962
- return batchLoadEventRecords(
6963
- db,
6964
- rows.map((r) => r.id)
6965
- );
6966
- }
6967
- var init_get_contact_journal = __esm({
6968
- "src/events/get_contact_journal.ts"() {
6969
- init_assert_default_ordering();
6970
- init_resolve_list_limit_offset();
6971
- init_loaders();
6972
- }
6973
- });
6974
-
6975
- // src/events/list_open_commitments.ts
6976
- function mapResolution(resolvedAt, resolution) {
6977
- if (resolvedAt === null) {
6978
- return "open";
6979
- }
6980
- if (resolution === "cancelled") {
6981
- return "cancelled";
6982
- }
6983
- return "resolved";
6984
- }
6985
- function listOpenCommitments(db, filters, options) {
6986
- assertDefaultOrdering("listOpenCommitments", options);
6987
- const { limit, offset } = resolveListLimitOffset(options);
6988
- const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
6989
- const params = [];
6990
- if (options?.since !== void 0) {
6991
- clauses.push("e.occurred_at >= ?");
6992
- params.push(options.since);
6993
- }
6994
- if (options?.until !== void 0) {
6995
- clauses.push("e.occurred_at <= ?");
6996
- params.push(options.until);
6997
- }
6998
- if (filters?.commitmentType !== void 0) {
6999
- if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
7000
- throw new AffinityValidationError("invalid commitmentType filter");
7001
- }
7002
- clauses.push("oc.commitment_type = ?");
7003
- params.push(filters.commitmentType);
7004
- }
7005
- if (filters?.contactId !== void 0) {
7006
- clauses.push(
7007
- "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
7008
- );
7009
- params.push(filters.contactId);
7010
- }
7011
- if (filters?.linkId !== void 0) {
7012
- clauses.push(
7013
- "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
7014
- );
7015
- params.push(filters.linkId);
7016
- }
7017
- if (filters?.horizonDays !== void 0) {
7018
- if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
7019
- throw new AffinityValidationError(
7020
- "horizonDays must be a non-negative integer"
7021
- );
7022
- }
7023
- const anchor = options?.since ?? Date.now();
7024
- clauses.push("oc.due_at IS NOT NULL");
7025
- clauses.push("oc.due_at <= ?");
7026
- params.push(anchor + filters.horizonDays * DAY_MS3);
7027
- }
7028
- const where = clauses.join(" AND ");
7029
- const rows = db.prepare(
7030
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
7031
- e.type, e.summary
7032
- FROM open_commitments oc
7033
- INNER JOIN events e ON e.id = oc.event_id
7034
- WHERE ${where}
7035
- ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
7036
- LIMIT ? OFFSET ?`
7037
- ).all(...params, limit, offset);
7038
- const eventIds = rows.map((r) => r.event_id);
7039
- const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
7040
- return rows.map((r) => ({
7041
- eventId: r.event_id,
7042
- type: r.type,
7043
- summary: r.summary,
7044
- participants: partsByEvent.get(r.event_id) ?? [],
7045
- dueAt: r.due_at,
7046
- resolutionState: mapResolution(r.resolved_at, r.resolution)
7047
- }));
7048
- }
7049
- var DAY_MS3;
7050
- var init_list_open_commitments = __esm({
7051
- "src/events/list_open_commitments.ts"() {
7052
- init_affinity_validation_error();
7053
- init_assert_default_ordering();
7054
- init_resolve_list_limit_offset();
7055
- init_loaders();
7056
- DAY_MS3 = 864e5;
7057
- }
7058
- });
7059
-
7060
8024
  // src/graph/get_affinity_chart.ts
7061
8025
  function getAffinityChart(db, options) {
7062
8026
  const contactClauses = [
@@ -7290,13 +8254,15 @@ function listRadar(db, filters, options) {
7290
8254
  if (ownerId === null) {
7291
8255
  return [];
7292
8256
  }
8257
+ const now = resolveNow(options?.now);
8258
+ 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})`;
7293
8259
  const { limit, offset } = resolveListLimitOffset(options);
7294
8260
  const clauses = [
7295
8261
  "l.removed_at IS NULL",
7296
8262
  "l.is_structural = 0",
7297
- "l.from_contact_id = ?"
8263
+ "(l.from_contact_id = ? OR l.to_contact_id = ?)"
7298
8264
  ];
7299
- const params = [ownerId];
8265
+ const params = [ownerId, ownerId];
7300
8266
  if (filters?.kind !== void 0) {
7301
8267
  clauses.push("l.kind = ?");
7302
8268
  params.push(filters.kind);
@@ -7318,6 +8284,7 @@ function listRadar(db, filters, options) {
7318
8284
  const rows = db.prepare(
7319
8285
  `SELECT
7320
8286
  l.id,
8287
+ ${overdue} AS overdue_commitment,
7321
8288
  l.from_contact_id,
7322
8289
  l.to_contact_id,
7323
8290
  l.trust,
@@ -7326,17 +8293,18 @@ function listRadar(db, filters, options) {
7326
8293
  lr.recency_score,
7327
8294
  lr.normalized_rank
7328
8295
  FROM links l
7329
- INNER JOIN contacts c_to ON c_to.id = l.to_contact_id
8296
+ 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
7330
8297
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
7331
8298
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
7332
8299
  WHERE ${where}
7333
- ORDER BY COALESCE(lr.radar_score, 0) DESC
8300
+ ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
7334
8301
  LIMIT ? OFFSET ?`
7335
8302
  ).all(...params, limit, offset);
7336
8303
  return rows.map((row) => ({
7337
8304
  linkId: row.id,
7338
8305
  contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
7339
- driftPriority: row.drift_priority ?? 0,
8306
+ reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
8307
+ driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
7340
8308
  recencyScore: row.recency_score ?? 0,
7341
8309
  normalizedRank: row.normalized_rank ?? 0,
7342
8310
  trust: row.trust ?? 0,
@@ -7352,6 +8320,7 @@ var init_list_radar = __esm({
7352
8320
  init_queries();
7353
8321
  init_assert_default_ordering();
7354
8322
  init_resolve_list_limit_offset();
8323
+ init_resolve_now();
7355
8324
  }
7356
8325
  });
7357
8326
 
@@ -7405,9 +8374,79 @@ var init_list_dismissed_duplicates = __esm({
7405
8374
  }
7406
8375
  });
7407
8376
 
8377
+ // src/tools/enrich_review_item.ts
8378
+ function enrichReviewItem(db, item) {
8379
+ const name = (id) => {
8380
+ if (typeof id !== "number") return "";
8381
+ return db.prepare("SELECT name FROM contacts WHERE id = ?").get(id)?.name ?? `Contact #${id}`;
8382
+ };
8383
+ const result = { ...item };
8384
+ if (item.kind === "link") {
8385
+ 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,
8386
+ 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);
8387
+ if (link) {
8388
+ result.title = link.from_owner ? String(link.to_name) : link.to_owner ? String(link.from_name) : `${link.from_name} \xB7 ${link.to_name}`;
8389
+ result.subtitle = [link.kind, link.role].filter(Boolean).join(" \xB7 ");
8390
+ for (const key of ["affinity", "trust", "rank"])
8391
+ if (typeof link[key] === "number") result[key] = link[key];
8392
+ result.recencyScore ??= link.recency_score;
8393
+ result.driftPriority ??= link.drift_priority;
8394
+ result.readinessScore = link.readiness_score;
8395
+ result.normalizedRank ??= link.normalized_rank;
8396
+ result.reciprocityScore = link.reciprocity_score;
8397
+ result.radarScore = link.radar_score;
8398
+ }
8399
+ }
8400
+ if (item.kind === "event" && typeof item.linkId === "number" && !Array.isArray(item.participants)) {
8401
+ const link = db.prepare("SELECT from_contact_id, to_contact_id FROM links WHERE id = ?").get(item.linkId);
8402
+ if (link)
8403
+ result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
8404
+ }
8405
+ if (Array.isArray(item.participants)) {
8406
+ result.counterpartyNames = item.participants.map(
8407
+ (p) => name(p.contactId)
8408
+ );
8409
+ result.subtitle = [
8410
+ item.subtitle,
8411
+ result.counterpartyNames.join(", ")
8412
+ ].filter(Boolean).join(" \xB7 ");
8413
+ }
8414
+ if (typeof item.leftContactId === "number")
8415
+ result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
8416
+ if (typeof item.winnerContactId === "number")
8417
+ result.title = `Merged ${name(item.loserContactId)} into ${name(item.winnerContactId)}`;
8418
+ if (item.occursAt !== void 0 && item.kind === "contact")
8419
+ result.title = `${name(item.id)} \xB7 ${item.title}`;
8420
+ for (const [key, value] of Object.entries(result)) {
8421
+ if (typeof value === "number" && /score|priority|affinity|trust|rank/i.test(key))
8422
+ result[key] = Math.round(value * 1e3) / 1e3;
8423
+ }
8424
+ return result;
8425
+ }
8426
+ var init_enrich_review_item = __esm({
8427
+ "src/tools/enrich_review_item.ts"() {
8428
+ }
8429
+ });
8430
+
8431
+ // src/tools/tool_summary.ts
8432
+ function summarizeCount(count, singular) {
8433
+ if (count === 0) {
8434
+ return `No ${singular}s found.`;
8435
+ }
8436
+ if (count === 1) {
8437
+ return `Found 1 ${singular}.`;
8438
+ }
8439
+ return `Found ${count} ${singular}s.`;
8440
+ }
8441
+ var init_tool_summary = __esm({
8442
+ "src/tools/tool_summary.ts"() {
8443
+ }
8444
+ });
8445
+
7408
8446
  // src/tools/review_affinity_tool.ts
7409
8447
  function buildReadOptions(input) {
7410
8448
  return {
8449
+ ...input.now === void 0 ? {} : { now: input.now },
7411
8450
  ...input.limit === void 0 ? {} : { limit: input.limit },
7412
8451
  ...input.offset === void 0 ? {} : { offset: input.offset },
7413
8452
  ...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
@@ -7527,6 +8566,7 @@ function validateReviewInput(input) {
7527
8566
  }
7528
8567
  function duplicateCandidateItem(candidate) {
7529
8568
  return {
8569
+ ...candidate,
7530
8570
  kind: "contact",
7531
8571
  id: candidate.leftContactId,
7532
8572
  title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
@@ -7536,24 +8576,28 @@ function duplicateCandidateItem(candidate) {
7536
8576
  }
7537
8577
  function momentItem(moment) {
7538
8578
  return {
8579
+ ...moment,
8580
+ occurredAtIso: new Date(moment.occurredAt).toISOString(),
7539
8581
  kind: "event",
7540
8582
  id: moment.eventId,
7541
8583
  title: moment.summary,
7542
8584
  subtitle: moment.momentKind,
7543
- snippet: `link #${moment.linkId} at ${moment.occurredAt}`
8585
+ snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
7544
8586
  };
7545
8587
  }
7546
8588
  function commitmentItem(commitment) {
7547
8589
  return {
8590
+ ...commitment,
7548
8591
  kind: "event",
7549
8592
  id: commitment.eventId,
7550
8593
  title: commitment.summary,
7551
8594
  subtitle: commitment.type,
7552
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt ?? "none"}`
8595
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
7553
8596
  };
7554
8597
  }
7555
8598
  function radarItem(radar) {
7556
8599
  return {
8600
+ ...radar,
7557
8601
  kind: "link",
7558
8602
  id: radar.linkId,
7559
8603
  title: `Link #${radar.linkId}`,
@@ -7563,29 +8607,34 @@ function radarItem(radar) {
7563
8607
  }
7564
8608
  function upcomingDateItem(date) {
7565
8609
  return {
8610
+ ...date,
8611
+ occursAt: date.occursOn,
8612
+ occursAtIso: new Date(date.occursOn).toISOString(),
7566
8613
  kind: date.targetRef.kind,
7567
8614
  id: date.targetRef.id,
7568
8615
  title: date.summary,
7569
8616
  subtitle: date.recurrenceKind,
7570
- snippet: `occurs ${date.occursOn}`
8617
+ snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
7571
8618
  };
7572
8619
  }
7573
8620
  function mergeHistoryItem(record) {
7574
8621
  return {
8622
+ ...record,
7575
8623
  kind: "contact",
7576
8624
  id: record.winnerContactId,
7577
8625
  title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
7578
- subtitle: `at ${record.mergedAt}`,
8626
+ subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
7579
8627
  snippet: record.reasonSummary ?? void 0
7580
8628
  };
7581
8629
  }
7582
8630
  function dismissedPairItem(record) {
7583
8631
  return {
8632
+ ...record,
7584
8633
  kind: "contact",
7585
8634
  id: record.leftContactId,
7586
8635
  title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
7587
8636
  subtitle: record.reason ?? "no reason given",
7588
- snippet: `dismissed at ${record.dismissedAt}`
8637
+ snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
7589
8638
  };
7590
8639
  }
7591
8640
  function compactFilters(input) {
@@ -7595,7 +8644,16 @@ function compactFilters(input) {
7595
8644
  )
7596
8645
  );
7597
8646
  }
7598
- function reviewAffinityToolHandler(db, input) {
8647
+ function reviewAffinityToolHandler(db, rawInput) {
8648
+ const input = structuredClone(rawInput);
8649
+ const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
8650
+ if (inputError)
8651
+ return toolFailure(
8652
+ "protocol",
8653
+ "invalid_input",
8654
+ "Invalid input.",
8655
+ inputError
8656
+ );
7599
8657
  return withToolHandling(() => {
7600
8658
  const invalid = validateReviewInput(input);
7601
8659
  if (invalid) {
@@ -7609,6 +8667,9 @@ function reviewAffinityToolHandler(db, input) {
7609
8667
  items = listContacts(
7610
8668
  db,
7611
8669
  {
8670
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
8671
+ ...input.tag === void 0 ? {} : { tag: input.tag },
8672
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
7612
8673
  ...input.kind === void 0 ? {} : { kind: input.kind },
7613
8674
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
7614
8675
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -7639,7 +8700,7 @@ function reviewAffinityToolHandler(db, input) {
7639
8700
  "Provide `contact` when `view` is `events.contact_journal`."
7640
8701
  );
7641
8702
  }
7642
- const contact = resolveContactLocator(db, locator, "contact");
8703
+ const contact = resolveContactLocator(db, locator, "contact", true);
7643
8704
  if (!contact.ok) {
7644
8705
  return contact.result;
7645
8706
  }
@@ -7674,7 +8735,12 @@ function reviewAffinityToolHandler(db, input) {
7674
8735
  let contactId;
7675
8736
  let linkId;
7676
8737
  if (input.contact !== void 0) {
7677
- const contact = resolveContactLocator(db, input.contact, "contact");
8738
+ const contact = resolveContactLocator(
8739
+ db,
8740
+ input.contact,
8741
+ "contact",
8742
+ true
8743
+ );
7678
8744
  if (!contact.ok) {
7679
8745
  return contact.result;
7680
8746
  }
@@ -7703,7 +8769,12 @@ function reviewAffinityToolHandler(db, input) {
7703
8769
  let contactId;
7704
8770
  let linkId;
7705
8771
  if (input.contact !== void 0) {
7706
- const contact = resolveContactLocator(db, input.contact, "contact");
8772
+ const contact = resolveContactLocator(
8773
+ db,
8774
+ input.contact,
8775
+ "contact",
8776
+ true
8777
+ );
7707
8778
  if (!contact.ok) {
7708
8779
  return contact.result;
7709
8780
  }
@@ -7796,7 +8867,7 @@ function reviewAffinityToolHandler(db, input) {
7796
8867
  "Provide `contact` when `view` is `merges.history`."
7797
8868
  );
7798
8869
  }
7799
- const contact = resolveContactLocator(db, locator, "contact");
8870
+ const contact = resolveContactLocator(db, locator, "contact", true);
7800
8871
  if (!contact.ok) {
7801
8872
  return contact.result;
7802
8873
  }
@@ -7809,6 +8880,7 @@ function reviewAffinityToolHandler(db, input) {
7809
8880
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
7810
8881
  break;
7811
8882
  }
8883
+ items = items.map((item) => enrichReviewItem(db, item));
7812
8884
  return toolSuccess(
7813
8885
  summarizeCount(items.length, "item"),
7814
8886
  {
@@ -7847,12 +8919,15 @@ var init_review_affinity_tool = __esm({
7847
8919
  init_list_radar();
7848
8920
  init_get_merge_history();
7849
8921
  init_list_dismissed_duplicates();
8922
+ init_enrich_review_item();
7850
8923
  init_tool_metadata();
7851
8924
  init_tool_names();
7852
8925
  init_tool_ref();
7853
8926
  init_tool_resolvers();
7854
8927
  init_tool_summary();
7855
8928
  init_tool_types();
8929
+ init_tool_types();
8930
+ init_validate_tool_input();
7856
8931
  reviewAffinityTool = defineAffinityTool({
7857
8932
  name: reviewAffinityToolName,
7858
8933
  description: "Review one affinity surface such as contacts, journals, link queues, commitments, dates, graph state, or merge history.",
@@ -7864,12 +8939,16 @@ var init_review_affinity_tool = __esm({
7864
8939
  targetKinds: ["contact", "link", "event"],
7865
8940
  inputDescriptions: {
7866
8941
  view: "Which review surface to return.",
8942
+ now: "Optional current timestamp for overdue calculations.",
7867
8943
  contact: "Target contact locator for views that require one.",
7868
8944
  link: "Target link locator for views that require one.",
7869
8945
  contactIds: "Optional contact id subset for duplicate review or graph review.",
7870
8946
  kind: "Optional contact kind or link kind filter, depending on the view.",
7871
8947
  lifecycleState: "Optional contact lifecycle filter for the contact list view.",
7872
8948
  linkState: "Optional link state filter for link review views.",
8949
+ attribute: "Attribute name or name=value; arrays require all filters.",
8950
+ tag: "Tag name; arrays require all tags.",
8951
+ includeLost: "Whether lost contacts are included.",
7873
8952
  includeOwner: "Whether owner contacts should be included in contact review.",
7874
8953
  includeDormant: "Whether dormant links or contacts should be included where supported.",
7875
8954
  includeArchived: "Whether archived links should be included where supported.",
@@ -7906,6 +8985,9 @@ var init_review_affinity_tool = __esm({
7906
8985
  "merges.history",
7907
8986
  "merges.dismissed"
7908
8987
  ]),
8988
+ now: integerSchema(
8989
+ "Optional current timestamp for overdue calculations."
8990
+ ),
7909
8991
  contact: contactLocatorSchema9("Optional target contact locator."),
7910
8992
  link: linkLocatorSchema5("Optional target link locator."),
7911
8993
  contactIds: arraySchema(
@@ -7928,6 +9010,17 @@ var init_review_affinity_tool = __esm({
7928
9010
  "broken",
7929
9011
  "archived"
7930
9012
  ]),
9013
+ attribute: oneOfSchema([
9014
+ stringSchema("Attribute filter."),
9015
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
9016
+ ]),
9017
+ tag: oneOfSchema([
9018
+ stringSchema("Tag filter."),
9019
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
9020
+ ]),
9021
+ includeLost: booleanSchema(
9022
+ "Include lost contacts (search excludes them by default)."
9023
+ ),
7931
9024
  includeOwner: booleanSchema("Whether owner contacts are included."),
7932
9025
  includeDormant: booleanSchema("Whether dormant rows are included."),
7933
9026
  includeArchived: booleanSchema("Whether archived links are included."),
@@ -7974,99 +9067,110 @@ var init_review_affinity_tool = __esm({
7974
9067
  });
7975
9068
 
7976
9069
  // src/contacts/search_contacts.ts
7977
- function relevanceScore(name, queryLower, matchedName, matchedNormalizedIdentity) {
7978
- const nl = name.toLowerCase();
7979
- if (matchedNormalizedIdentity) return 1.1;
7980
- if (nl.startsWith(queryLower)) return 1;
7981
- if (matchedName) return 0.85;
7982
- return 0.7;
7983
- }
7984
9070
  function searchContacts(db, query, filters, options) {
7985
- const q = query.trim();
7986
- if (q.length === 0) {
7987
- return [];
7988
- }
9071
+ const q = foldSearchText(query.trim());
9072
+ if (!q) return [];
7989
9073
  assertDefaultOrdering("searchContacts", options);
7990
9074
  const { limit, offset } = resolveListLimitOffset(options);
7991
- const queryLower = q.toLowerCase();
7992
- const pattern = `%${q.replace(/%/g, "").replace(/_/g, "")}%`;
7993
- const normalizedPattern = `%${queryLower}%`;
9075
+ const fields = /* @__PURE__ */ new Map();
9076
+ const rows = db.prepare(`
9077
+ SELECT contact_id, value || ' ' || normalized_key AS text, 'identity:' || type AS source FROM identities WHERE removed_at IS NULL
9078
+ UNION ALL SELECT contact_id, name || ' ' || COALESCE(value, ''), 'attribute:' || name FROM attributes WHERE deleted_at IS NULL AND contact_id IS NOT NULL
9079
+ UNION ALL SELECT from_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
9080
+ UNION ALL SELECT to_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
9081
+ 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
9082
+ `).all();
9083
+ for (const row of rows) {
9084
+ const values = fields.get(row.contact_id) ?? [];
9085
+ values.push({ text: foldSearchText(row.text), source: row.source });
9086
+ fields.set(row.contact_id, values);
9087
+ }
9088
+ const tokens = q.split(/\s+/);
9089
+ const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
9090
+ const digits = q.replace(/\D/g, "").replace(/^00/, "");
9091
+ const attributeFilters = contactAttributeClauses(filters);
7994
9092
  const clauses = [
7995
- "c.deleted_at IS NULL",
7996
- "c.lifecycle_state != 'merged'"
9093
+ "deleted_at IS NULL",
9094
+ "lifecycle_state != 'merged'",
9095
+ ...attributeFilters.clauses
7997
9096
  ];
7998
- const params = [];
9097
+ const params = [...attributeFilters.params];
7999
9098
  if (filters?.kind !== void 0) {
8000
- clauses.push("c.kind = ?");
9099
+ clauses.push("kind = ?");
8001
9100
  params.push(filters.kind);
8002
9101
  }
8003
9102
  if (filters?.lifecycleState !== void 0) {
8004
- clauses.push("c.lifecycle_state = ?");
9103
+ clauses.push("lifecycle_state = ?");
8005
9104
  params.push(filters.lifecycleState);
8006
9105
  }
8007
- if (filters?.includeOwner === false) {
8008
- clauses.push("c.is_owner = 0");
8009
- }
8010
- if (filters?.includeDormant === false || options?.includeDormant === false) {
8011
- clauses.push("c.lifecycle_state != 'dormant'");
8012
- }
8013
- const where = clauses.join(" AND ");
8014
- const sql = `
8015
- SELECT c.id, c.name, c.kind, c.lifecycle_state, c.is_owner, c.merged_into_contact_id,
8016
- c.created_at, c.updated_at, c.deleted_at,
8017
- (
8018
- SELECT value
8019
- FROM identities primary_i
8020
- WHERE primary_i.contact_id = c.id AND primary_i.removed_at IS NULL
8021
- ORDER BY primary_i.id
8022
- LIMIT 1
8023
- ) AS primary_identity,
8024
- MAX(CASE WHEN i.normalized_key LIKE ? THEN 1 ELSE 0 END) AS matched_normalized_identity
8025
- FROM contacts c
8026
- LEFT JOIN identities i ON i.contact_id = c.id AND i.removed_at IS NULL
8027
- WHERE ${where}
8028
- AND (c.name LIKE ? OR i.value LIKE ? OR i.normalized_key LIKE ?)
8029
- GROUP BY c.id
8030
- `;
8031
- const rows = db.prepare(sql).all(
8032
- normalizedPattern,
8033
- ...params,
8034
- pattern,
8035
- pattern,
8036
- normalizedPattern
8037
- );
8038
- const scored = rows.map((r) => {
8039
- const item = mapContactRowToContactListItem(r);
8040
- const matchedName = r.name.toLowerCase().includes(queryLower);
8041
- const score = relevanceScore(
8042
- r.name,
8043
- queryLower,
8044
- matchedName,
8045
- Number(
8046
- r.matched_normalized_identity ?? 0
8047
- ) > 0
9106
+ if (filters?.includeOwner === false) clauses.push("is_owner = 0");
9107
+ const contacts = db.prepare(
9108
+ `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 ")}`
9109
+ ).all(...params).map((row) => ({
9110
+ ...mapContactRowToContactListItem(row),
9111
+ primaryIdentity: row.primary_identity
9112
+ }));
9113
+ const scored = [];
9114
+ for (const contact of contacts) {
9115
+ if (contact.lifecycleState === "dormant" && !(filters?.includeDormant ?? options?.includeDormant))
9116
+ continue;
9117
+ if (contact.lifecycleState === "lost" && !filters?.includeLost) continue;
9118
+ const name = foldSearchText(contact.name);
9119
+ const values = [
9120
+ { text: name, source: "name" },
9121
+ ...(fields.get(contact.id) ?? []).filter(
9122
+ (field) => !contact.isOwner || field.source !== "event" && field.source !== "link_role"
9123
+ )
9124
+ ];
9125
+ const phoneFields = phone ? values.filter(
9126
+ (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
9127
+ ) : [];
9128
+ if (!phoneFields.length && !tokens.every(
9129
+ (token) => values.some((field) => field.text.includes(token))
9130
+ ))
9131
+ continue;
9132
+ const matchedOn = [
9133
+ ...new Set(
9134
+ [
9135
+ ...values.filter(
9136
+ (field) => tokens.some((token) => field.text.includes(token))
9137
+ ),
9138
+ ...phoneFields
9139
+ ].map((field) => field.source)
9140
+ )
9141
+ ];
9142
+ const identities = values.filter(
9143
+ (field) => field.source.startsWith("identity:")
8048
9144
  );
8049
- return {
8050
- item: {
8051
- ...item,
8052
- primaryIdentity: r.primary_identity ?? null
8053
- },
8054
- score
8055
- };
8056
- });
8057
- scored.sort((a, b) => b.score - a.score);
8058
- return scored.slice(offset, offset + limit).map((s) => s.item);
9145
+ 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(
9146
+ (field) => tokens.every((token) => field.text.includes(token))
9147
+ ) ? 2 : 1;
9148
+ scored.push({ item: { ...contact, matchedOn }, score });
9149
+ }
9150
+ scored.sort((a, b) => b.score - a.score || a.item.id - b.item.id);
9151
+ return scored.slice(offset, offset + limit).map(({ item }) => item);
8059
9152
  }
8060
9153
  var init_search_contacts = __esm({
8061
9154
  "src/contacts/search_contacts.ts"() {
8062
9155
  init_assert_default_ordering();
8063
9156
  init_resolve_list_limit_offset();
9157
+ init_contact_attribute_clauses();
9158
+ init_fold_search_text();
8064
9159
  init_mappers4();
8065
9160
  }
8066
9161
  });
8067
9162
 
8068
9163
  // src/tools/search_affinity_tool.ts
8069
- function searchAffinityToolHandler(db, input) {
9164
+ function searchAffinityToolHandler(db, rawInput) {
9165
+ const input = structuredClone(rawInput);
9166
+ const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
9167
+ if (inputError)
9168
+ return toolFailure(
9169
+ "protocol",
9170
+ "invalid_input",
9171
+ "Invalid input.",
9172
+ inputError
9173
+ );
8070
9174
  return withToolHandling(() => {
8071
9175
  const options = {
8072
9176
  ...input.limit === void 0 ? {} : { limit: input.limit },
@@ -8076,6 +9180,9 @@ function searchAffinityToolHandler(db, input) {
8076
9180
  db,
8077
9181
  input.query,
8078
9182
  {
9183
+ ...input.attribute === void 0 ? {} : { attribute: input.attribute },
9184
+ ...input.tag === void 0 ? {} : { tag: input.tag },
9185
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
8079
9186
  ...input.kind === void 0 ? {} : { kind: input.kind },
8080
9187
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
8081
9188
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -8107,9 +9214,11 @@ var init_search_affinity_tool = __esm({
8107
9214
  init_tool_resolvers();
8108
9215
  init_tool_summary();
8109
9216
  init_tool_types();
9217
+ init_tool_types();
9218
+ init_validate_tool_input();
8110
9219
  searchAffinityTool = defineAffinityTool({
8111
9220
  name: searchAffinityToolName,
8112
- description: "Search contacts by name or identity, including exact natural-key lookups like email-style identifiers.",
9221
+ 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.",
8113
9222
  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.",
8114
9223
  whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
8115
9224
  sideEffects: "none",
@@ -8120,6 +9229,9 @@ var init_search_affinity_tool = __esm({
8120
9229
  query: "Free-text or natural-key search text.",
8121
9230
  kind: "Optional contact kind filter.",
8122
9231
  lifecycleState: "Optional lifecycle state filter.",
9232
+ attribute: "Attribute name or name=value; arrays require all filters.",
9233
+ tag: "Tag name; arrays require all tags.",
9234
+ includeLost: "Whether lost contacts are included in search.",
8123
9235
  includeOwner: "Whether owner contacts are eligible for results.",
8124
9236
  includeDormant: "Whether dormant contacts should be eligible for results.",
8125
9237
  limit: "Optional result limit.",
@@ -8144,6 +9256,17 @@ var init_search_affinity_tool = __esm({
8144
9256
  "merged",
8145
9257
  "lost"
8146
9258
  ]),
9259
+ attribute: oneOfSchema([
9260
+ stringSchema("Attribute filter."),
9261
+ arraySchema(stringSchema("Attribute filter."), "AND filters.")
9262
+ ]),
9263
+ tag: oneOfSchema([
9264
+ stringSchema("Tag filter."),
9265
+ arraySchema(stringSchema("Tag filter."), "AND tags.")
9266
+ ]),
9267
+ includeLost: booleanSchema(
9268
+ "Include lost contacts (search excludes them by default)."
9269
+ ),
8147
9270
  includeOwner: booleanSchema("Whether owner contacts are included."),
8148
9271
  includeDormant: booleanSchema("Whether dormant contacts are included."),
8149
9272
  limit: integerSchema("Optional result limit."),
@@ -8190,11 +9313,112 @@ var init_tool_registry = __esm({
8190
9313
  }
8191
9314
  });
8192
9315
 
9316
+ // src/surface/plain_dates.ts
9317
+ function parsePlainDue(raw, now = Date.now(), endOfDay = true) {
9318
+ const invalid = () => new CliUsageError(
9319
+ `Invalid timestamp "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d, -2d or an ISO date-time.`
9320
+ );
9321
+ if (/^-?\d+$/.test(raw)) {
9322
+ const value = Number(raw);
9323
+ if (Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()))
9324
+ return value;
9325
+ throw invalid();
9326
+ }
9327
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
9328
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
9329
+ let date;
9330
+ if (iso || european) {
9331
+ const year = Number(iso?.[1] ?? european?.[3]);
9332
+ const month = Number(iso?.[2] ?? european?.[2]);
9333
+ const day = Number(iso?.[3] ?? european?.[1]);
9334
+ date = /* @__PURE__ */ new Date(0);
9335
+ date.setFullYear(year, month - 1, day);
9336
+ date.setHours(
9337
+ endOfDay ? 23 : 0,
9338
+ endOfDay ? 59 : 0,
9339
+ endOfDay ? 59 : 0,
9340
+ endOfDay ? 999 : 0
9341
+ );
9342
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
9343
+ throw invalid();
9344
+ } else if (raw === "today" || raw === "tomorrow" || raw === "yesterday" || /^[+-]\d+d$/.test(raw)) {
9345
+ date = new Date(now);
9346
+ date.setDate(
9347
+ date.getDate() + (raw === "today" ? 0 : raw === "tomorrow" ? 1 : raw === "yesterday" ? -1 : Number(raw.slice(0, -1)))
9348
+ );
9349
+ date.setHours(
9350
+ endOfDay ? 23 : 0,
9351
+ endOfDay ? 59 : 0,
9352
+ endOfDay ? 59 : 0,
9353
+ endOfDay ? 999 : 0
9354
+ );
9355
+ } else {
9356
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
9357
+ raw
9358
+ ))
9359
+ throw invalid();
9360
+ parsePlainDue(raw.slice(0, 10), now);
9361
+ date = new Date(raw);
9362
+ }
9363
+ if (!Number.isFinite(date.getTime())) throw invalid();
9364
+ return date.getTime();
9365
+ }
9366
+ function parsePlainStart(raw, now = Date.now()) {
9367
+ return parsePlainDue(raw, now, false);
9368
+ }
9369
+ function normalizeTimeInputs(value, now = Date.now()) {
9370
+ if (Array.isArray(value))
9371
+ return value.map((item) => normalizeTimeInputs(item, now));
9372
+ if (!value || typeof value !== "object") return value;
9373
+ return Object.fromEntries(
9374
+ Object.entries(value).map(([key, item]) => [
9375
+ key,
9376
+ typeof item === "string" && (key.endsWith("At") || ["asOf", "now"].includes(key)) ? key === "dueAt" ? parsePlainDue(item, now) : parsePlainStart(item, now) : normalizeTimeInputs(item, now)
9377
+ ])
9378
+ );
9379
+ }
9380
+ var init_plain_dates = __esm({
9381
+ "src/surface/plain_dates.ts"() {
9382
+ init_cli_args();
9383
+ }
9384
+ });
9385
+
9386
+ // src/surface/timestamp_schema.ts
9387
+ function acceptsString(schema) {
9388
+ return schema.type === "string" || Array.isArray(schema.type) && schema.type.includes("string") || (schema.oneOf?.some(acceptsString) ?? false);
9389
+ }
9390
+ function timestampSchema(schema) {
9391
+ return {
9392
+ ...schema,
9393
+ ...schema.properties ? {
9394
+ properties: Object.fromEntries(
9395
+ Object.entries(schema.properties).map(([key, value]) => [
9396
+ key,
9397
+ key.endsWith("At") || ["asOf", "now"].includes(key) ? acceptsString(value) ? value : {
9398
+ description: value.description,
9399
+ oneOf: [value, { type: "string" }]
9400
+ } : timestampSchema(value)
9401
+ ])
9402
+ )
9403
+ } : {},
9404
+ ...schema.items ? { items: timestampSchema(schema.items) } : {},
9405
+ ...schema.oneOf ? { oneOf: schema.oneOf.map(timestampSchema) } : {}
9406
+ };
9407
+ }
9408
+ var init_timestamp_schema = __esm({
9409
+ "src/surface/timestamp_schema.ts"() {
9410
+ }
9411
+ });
9412
+
8193
9413
  // src/surface/affinity_surface_tools.ts
8194
9414
  var affinitySurfaceTools, mapping, affinityCliSurfaceTools;
8195
9415
  var init_affinity_surface_tools = __esm({
8196
9416
  "src/surface/affinity_surface_tools.ts"() {
9417
+ init_cli_args();
8197
9418
  init_tool_registry();
9419
+ init_tool_types();
9420
+ init_plain_dates();
9421
+ init_timestamp_schema();
8198
9422
  affinitySurfaceTools = affinityTools.map((tool) => ({
8199
9423
  name: tool.name,
8200
9424
  description: tool.description,
@@ -8202,8 +9426,24 @@ var init_affinity_surface_tools = __esm({
8202
9426
  ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
8203
9427
  readOnly: tool.readOnly,
8204
9428
  sideEffects: tool.sideEffects,
8205
- inputSchema: { ...tool.inputSchema, type: "object" },
8206
- handler: tool.handler
9429
+ inputSchema: {
9430
+ ...timestampSchema({ ...tool.inputSchema, type: "object" }),
9431
+ type: "object"
9432
+ },
9433
+ handler: (db, input) => {
9434
+ try {
9435
+ return tool.handler(db, normalizeTimeInputs(input));
9436
+ } catch (error) {
9437
+ if (error instanceof CliUsageError)
9438
+ return toolFailure(
9439
+ "protocol",
9440
+ "invalid_input",
9441
+ "Invalid input.",
9442
+ error.message
9443
+ );
9444
+ throw error;
9445
+ }
9446
+ }
8207
9447
  }));
8208
9448
  mapping = {
8209
9449
  search_affinity: { verb: "search", positionals: ["query"] },
@@ -8264,10 +9504,22 @@ var init_affinity_surface_tools = __esm({
8264
9504
  };
8265
9505
  affinityCliSurfaceTools = /* @__PURE__ */ affinitySurfaceTools.map((tool) => ({
8266
9506
  ...tool,
9507
+ inputSchema: {
9508
+ ...affinityTools.find((original) => original.name === tool.name)?.inputSchema,
9509
+ type: "object"
9510
+ },
8267
9511
  ...mapping[tool.name] ? { cli: mapping[tool.name] } : {}
8268
9512
  }));
8269
9513
  }
8270
9514
  });
9515
+ function resolveAffinityDbPath(options = {}) {
9516
+ const path = options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || join(homedir(), ".ghostpaw", "affinity.db"));
9517
+ return path === ":memory:" ? path : resolve(path);
9518
+ }
9519
+ var init_resolve_affinity_db_path = __esm({
9520
+ "src/surface/resolve_affinity_db_path.ts"() {
9521
+ }
9522
+ });
8271
9523
 
8272
9524
  // src/attributes/init_attributes_tables.ts
8273
9525
  function initAttributesTables(db) {
@@ -8560,6 +9812,12 @@ function initLinkEventEffectsTables(db) {
8560
9812
  )
8561
9813
  )
8562
9814
  `);
9815
+ const columns = db.prepare("PRAGMA table_info(link_event_effects)").all();
9816
+ if (!columns.some((column) => column.name === "counts_for_recency")) {
9817
+ db.exec(
9818
+ "ALTER TABLE link_event_effects ADD COLUMN counts_for_recency INTEGER NOT NULL DEFAULT 1"
9819
+ );
9820
+ }
8563
9821
  db.exec(
8564
9822
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
8565
9823
  );
@@ -8797,19 +10055,6 @@ var init_open_affinity_db = __esm({
8797
10055
  }
8798
10056
  });
8799
10057
 
8800
- // src/surface/resolve_affinity_db_path.ts
8801
- var resolve_affinity_db_path_exports = {};
8802
- __export(resolve_affinity_db_path_exports, {
8803
- resolveAffinityDbPath: () => resolveAffinityDbPath
8804
- });
8805
- function resolveAffinityDbPath(options = {}) {
8806
- return options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || join(homedir(), ".ghostpaw", "affinity.db"));
8807
- }
8808
- var init_resolve_affinity_db_path = __esm({
8809
- "src/surface/resolve_affinity_db_path.ts"() {
8810
- }
8811
- });
8812
-
8813
10058
  // src/surfaces/surface_types.ts
8814
10059
  function isErrorResult(result) {
8815
10060
  return typeof result === "object" && result !== null && "ok" in result && result.ok === false;
@@ -9049,10 +10294,10 @@ async function serveMcpStdio(server, options = {}) {
9049
10294
  const response = await server.handleLine(line.replace(/\r$/, ""));
9050
10295
  if (response !== void 0)
9051
10296
  await new Promise(
9052
- (resolve, reject) => output.write(
10297
+ (resolve2, reject) => output.write(
9053
10298
  `${response}
9054
10299
  `,
9055
- (error) => error ? reject(error) : resolve()
10300
+ (error) => error ? reject(error) : resolve2()
9056
10301
  )
9057
10302
  );
9058
10303
  };
@@ -9142,11 +10387,165 @@ var init_serve_affinity_mcp = __esm({
9142
10387
  }
9143
10388
  });
9144
10389
 
9145
- // src/surfaces/skill_markdown.ts
9146
- var skill_markdown_exports = {};
9147
- __export(skill_markdown_exports, {
9148
- renderSkillMarkdown: () => renderSkillMarkdown
10390
+ // src/surfaces/cli_output.ts
10391
+ function enrichPlainJson(value) {
10392
+ if (Array.isArray(value)) return value.map(enrichPlainJson);
10393
+ if (value === null || typeof value !== "object")
10394
+ return typeof value === "number" && !Number.isInteger(value) ? Math.round(value * 1e3) / 1e3 : value;
10395
+ const result = {};
10396
+ for (const [key, entry] of Object.entries(value)) {
10397
+ result[key] = enrichPlainJson(entry);
10398
+ if (key.endsWith("At") && typeof entry === "number" && Number.isFinite(new Date(entry).getTime()))
10399
+ result[`${key}Iso`] = new Date(entry).toISOString();
10400
+ }
10401
+ return result;
10402
+ }
10403
+ function suggestCommand(input, names) {
10404
+ let best;
10405
+ let distance = 3;
10406
+ for (const name of names) {
10407
+ let row = Array.from({ length: name.length + 1 }, (_, i) => i);
10408
+ for (let i = 0; i < input.length; i++) {
10409
+ const next = [i + 1];
10410
+ for (let j = 0; j < name.length; j++)
10411
+ next.push(
10412
+ Math.min(
10413
+ (next[j] ?? 0) + 1,
10414
+ (row[j + 1] ?? 0) + 1,
10415
+ (row[j] ?? 0) + (input[i] === name[j] ? 0 : 1)
10416
+ )
10417
+ );
10418
+ row = next;
10419
+ }
10420
+ const current = row[name.length] ?? 3;
10421
+ if (current < distance) {
10422
+ best = name;
10423
+ distance = current;
10424
+ }
10425
+ }
10426
+ return best;
10427
+ }
10428
+ function cliExitCode(value) {
10429
+ const r = value;
10430
+ const code = r.error?.code ?? "";
10431
+ if (r.outcome === "needs_clarification" || code === "not_found") return 3;
10432
+ if ([
10433
+ "conflict",
10434
+ "version_mismatch",
10435
+ "already_resolved",
10436
+ "already_in_state"
10437
+ ].includes(code) || /already resolved/i.test(r.error?.message ?? ""))
10438
+ return 4;
10439
+ if (["invalid_input", "validation_error"].includes(code)) return 2;
10440
+ if (["system_error", "internal_error"].includes(code)) return 70;
10441
+ return r.ok === false ? 1 : 0;
10442
+ }
10443
+ function plainJson(value, compact = false, formatError) {
10444
+ const result = enrichPlainJson(value);
10445
+ if (!result || typeof result !== "object") return result;
10446
+ if (formatError && result.error && typeof result.error === "object") {
10447
+ result.error = Object.fromEntries(
10448
+ Object.entries(result.error).map(([key, value2]) => [
10449
+ key,
10450
+ typeof value2 === "string" && ["message", "recovery"].includes(key) ? formatError(value2) : value2
10451
+ ])
10452
+ );
10453
+ }
10454
+ const data = result.data;
10455
+ if (data?.items && JSON.stringify(result.entities) === JSON.stringify(data.items))
10456
+ result.entities = void 0;
10457
+ const trim = (v) => {
10458
+ if (Array.isArray(v)) return v.map(trim);
10459
+ if (!v || typeof v !== "object") return v;
10460
+ return Object.fromEntries(
10461
+ Object.entries(v).filter(
10462
+ ([k, x]) => !([
10463
+ "created",
10464
+ "updated",
10465
+ "archived",
10466
+ "removed",
10467
+ "affectedLinks",
10468
+ "derivedEffects"
10469
+ ].includes(k) && Array.isArray(x) && !x.length)
10470
+ ).map(([k, x]) => [k, trim(x)])
10471
+ );
10472
+ };
10473
+ return compact ? trim(result) : result;
10474
+ }
10475
+ function formatLocalTime(ms) {
10476
+ const d = new Date(ms);
10477
+ const pad = (n) => String(n).padStart(2, "0");
10478
+ const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
10479
+ const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
10480
+ return hm === "00:00" || hm === "23:59" ? day : `${day} ${hm}`;
10481
+ }
10482
+ function humanResult(value) {
10483
+ const r = value;
10484
+ const items = r.data?.items;
10485
+ if (Array.isArray(items)) {
10486
+ if (!items.length) return `${r.summary ?? "No results."}
10487
+ `;
10488
+ return `${items.map((item) => {
10489
+ const dates = Object.entries(item).filter(([k, v]) => k in DATE_LABELS && typeof v === "number").map(
10490
+ ([k, v]) => `${DATE_LABELS[k]} ${// Recurring dates are stored as UTC calendar days.
10491
+ k === "occursAt" ? new Date(v).toISOString().slice(0, 10) : formatLocalTime(v)}`
10492
+ );
10493
+ if (Array.isArray(item.reasons) && item.reasons.length)
10494
+ dates.push(item.reasons.join(", ").replaceAll("_", " "));
10495
+ const subtitle = item.subtitle ? ` \u2014 ${item.subtitle}` : "";
10496
+ const tail = dates.length ? ` \xB7 ${dates.join(" \xB7 ")}` : "";
10497
+ return `#${item.id ?? item.entityId ?? "?"} ${item.title ?? item.name ?? ""}${subtitle}${tail}${item.overdue ? " \xB7 OVERDUE" : ""}`.trim();
10498
+ }).join("\n")}
10499
+ `;
10500
+ }
10501
+ const primary = r.data?.primary;
10502
+ if (primary)
10503
+ return `${r.summary ?? "Done"}${primary.id === void 0 ? "" : ` (id ${primary.id})`}
10504
+ `;
10505
+ if (r.data) {
10506
+ const fields = [];
10507
+ const display = (entry) => {
10508
+ if (entry === null) return "\u2014";
10509
+ if (Array.isArray(entry)) return entry.map(display).join(", ") || "\u2014";
10510
+ if (typeof entry === "object") {
10511
+ const item = entry;
10512
+ return String(
10513
+ item.title ?? item.name ?? item.summary ?? item.value ?? (item.id === void 0 ? JSON.stringify(item) : `#${item.id}`)
10514
+ );
10515
+ }
10516
+ return String(entry);
10517
+ };
10518
+ const flatten = (value2, prefix = "") => {
10519
+ if (!value2 || typeof value2 !== "object") return;
10520
+ for (const [key, entry] of Object.entries(value2)) {
10521
+ const label = `${prefix}${key}`;
10522
+ if (entry && typeof entry === "object" && !Array.isArray(entry))
10523
+ flatten(entry, `${label}.`);
10524
+ else fields.push([label, display(entry)]);
10525
+ }
10526
+ };
10527
+ flatten(enrichPlainJson(r.data.detail ?? r.data));
10528
+ const width = Math.max(0, ...fields.map(([label]) => label.length));
10529
+ return `${fields.map(([label, value2]) => `${label.padEnd(width)}: ${value2}`).join("\n")}
10530
+ `;
10531
+ }
10532
+ return `${r.summary ?? ""}
10533
+ `;
10534
+ }
10535
+ var DATE_LABELS;
10536
+ var init_cli_output = __esm({
10537
+ "src/surfaces/cli_output.ts"() {
10538
+ DATE_LABELS = {
10539
+ occurredAt: "on",
10540
+ dueAt: "due",
10541
+ occursAt: "next",
10542
+ resolvedAt: "resolved",
10543
+ lastMeaningfulInteractionAt: "last"
10544
+ };
10545
+ }
9149
10546
  });
10547
+
10548
+ // src/surfaces/skill_markdown.ts
9150
10549
  function renderSkillMarkdown(spec, { referencesBase = "references" } = {}) {
9151
10550
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(spec.name) || spec.name.length > 64)
9152
10551
  throw new Error("Invalid skill name");
@@ -9205,6 +10604,25 @@ function renderSkillMarkdown(spec, { referencesBase = "references" } = {}) {
9205
10604
  lines.push(
9206
10605
  `| ${name} | ${property.type ?? property.oneOf?.map((branch) => branch.type).join(" / ") ?? "any"} | ${tool.inputSchema.required?.includes(name) ? "yes" : "no"} | ${(property.enum ? `${property.enum.join(", ")}. ` : "") + (property.description ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")} |`
9207
10606
  );
10607
+ if (spec.cheatSheet) {
10608
+ for (const [parent, property] of Object.entries(
10609
+ tool.inputSchema.properties ?? {}
10610
+ )) {
10611
+ const nested = property.items ?? property;
10612
+ const branches = [nested, ...nested.oneOf ?? []];
10613
+ const seen = /* @__PURE__ */ new Set();
10614
+ for (const branch of branches)
10615
+ for (const [key, child] of Object.entries(branch.properties ?? {})) {
10616
+ if (seen.has(key)) continue;
10617
+ seen.add(key);
10618
+ lines.push(
10619
+ `| ${parent}.${key} | ${child.type ?? "oneOf"} | ${branch.required?.includes(key) ? "yes" : "no"} | ${(child.description ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")} |`
10620
+ );
10621
+ }
10622
+ }
10623
+ if (spec.examples?.[tool.name])
10624
+ lines.push("", "```sh", spec.examples[tool.name] ?? "", "```");
10625
+ }
9208
10626
  lines.push("");
9209
10627
  }
9210
10628
  lines.push(
@@ -9219,13 +10637,38 @@ function renderSkillMarkdown(spec, { referencesBase = "references" } = {}) {
9219
10637
  ...spec.principles.map((principle) => `- ${principle}`),
9220
10638
  ""
9221
10639
  );
9222
- return lines.join("\n");
10640
+ const output = lines.join("\n");
10641
+ if (!spec.cheatSheet) return output;
10642
+ const title = `# ${spec.name}
10643
+
10644
+ `;
10645
+ const start = output.indexOf(title) + title.length;
10646
+ const appendix = output.slice(start).replace(/ \(without a global install:.*?\)/g, "").replace(/^## /gm, "### ");
10647
+ return `${output.slice(0, start)}${spec.cheatSheet}
10648
+
10649
+ ## Appendix: MCP and tool calls
10650
+
10651
+ ${appendix}
10652
+ From the binary: \`${spec.bin} skill <guide>\`.
10653
+ ${spec.packageName ? `
10654
+ ## Without the binary
10655
+
10656
+ Use \`npx -y ${spec.packageName} ...\` (Node.js 24 or newer), or import \`${spec.packageName}\` as a library.
10657
+ ` : ""}`;
9223
10658
  }
9224
10659
  var init_skill_markdown = __esm({
9225
10660
  "src/surfaces/skill_markdown.ts"() {
9226
10661
  }
9227
10662
  });
9228
10663
 
10664
+ // src/surface/affinity_skill_references.ts
10665
+ var affinitySkillReferences;
10666
+ var init_affinity_skill_references = __esm({
10667
+ "src/surface/affinity_skill_references.ts"() {
10668
+ affinitySkillReferences = { "bootstrap-and-cold-start": { "description": "Establish a clean Affinity baseline with one owner, one verified identity, and the minimum structure needed for later evidence and review work.", "content": '# Bootstrap And Cold Start\n\nPrimary tools:\n- `manage_contact`\n- `manage_identity`\n- `inspect_affinity_item`\n- `review_affinity`\n\nGoal:\n- Establish one clean owner record, make it reachable, and verify the system is ready for later relationship work.\n\nWhen to use:\n- The database is new or logically empty.\n- A harness needs to initialize its own operator profile before doing anything else.\n\nWhen not to use:\n- The owner already exists and you only need to update profile fields.\n- You are importing historical relationships; use the import-focused skill after bootstrap.\n\nStep-by-step sequence:\n1. Assume the schema is already initialized and the database is live before using any tool.\n2. Use `manage_contact` with `action: "create"` and `bootstrapOwner: true` exactly once for the operator.\n3. Immediately attach at least one routing identity with `manage_identity` so future lookups can use a stable natural key.\n4. Use `inspect_affinity_item` with `kind: "owner_profile"` to confirm that the owner profile exists and reads back cleanly.\n5. Use `review_affinity` with `view: "contacts.list"` to confirm that the portfolio is still minimal and no duplicate owner-like records were created during setup.\n\nValidation checks:\n- The owner profile loads successfully.\n- The owner has at least one live identity.\n- The contact list shows exactly the records you expect at this early stage.\n\nPitfalls:\n- Do not create a non-owner placeholder and plan to convert it later if the harness can bootstrap the real owner immediately.\n- Do not skip identity attachment, or later natural-key lookup will be weaker and duplicate-prone.\n- Do not seed relationships before the owner exists if later evidence is meant to be owner-facing.\n\nTips and tricks:\n- Prefer an identity that is stable across sessions, such as a primary email or canonical profile URL.\n- Keep the first dataset tiny. Bootstrap is for a trustworthy base, not a full import.\n- If setup feels ambiguous, inspect first and add less rather than more.\n\nTool calls to prefer:\n- `manage_contact` for owner creation\n- `manage_identity` for first routing key\n- `inspect_affinity_item` for confirmation\n- `review_affinity` for early portfolio sanity checks\n\nRelated skills:\n- `identify-and-locate-contacts`\n- `import-history-without-faking-evidence`' }, "identify-and-locate-contacts": { "description": "Find the right contact reliably, prefer stable natural keys when possible, and avoid creating duplicates through weak lookup discipline.", "content": "# Identify And Locate Contacts\n\nPrimary tools:\n- `search_affinity`\n- `inspect_affinity_item`\n- `manage_identity`\n- `manage_contact`\n\nGoal:\n- Resolve the right contact before mutating anything, using the strongest available locator.\n\nWhen to use:\n- A harness receives a name, email, phone number, URL, or ambiguous reference to a person or organization.\n- You need to confirm whether a contact already exists before creating a new one.\n\nWhen not to use:\n- You already have one exact internal id from a prior successful tool result.\n- You are reviewing a list surface rather than identifying one exact target.\n\nStep-by-step sequence:\n1. Start with `search_affinity` whenever the target might already exist.\n2. If you have a strong natural key such as email, phone, or URL-like handle, use that query first rather than a vague display name.\n3. If search returns one strong candidate, use `inspect_affinity_item` to confirm the profile before mutating.\n4. If the target does not exist, create it with `manage_contact`.\n5. Immediately add the best routing key with `manage_identity` so future lookup becomes exact rather than fuzzy.\n6. If the system returns ambiguity, stop and clarify instead of guessing.\n\nValidation checks:\n- The inspected profile matches the intended person or organization.\n- The best known routing identity is attached and, when appropriate, verified.\n- Later searches by that identity return the same contact cleanly.\n\nPitfalls:\n- Do not trust names alone when a natural key is available.\n- Do not create a second contact just because search returned multiple plausible matches.\n- Do not remove or revise identities casually; they are your best future locator surface.\n\nTips and tricks:\n- Prefer exact identities over integer ids when the harness needs to survive across sessions or partial memory loss.\n- After creating a contact from a vague mention, upgrade it with a real identity as soon as possible.\n- If multiple weak matches appear, inspect before mutating.\n\nTool calls to prefer:\n- `search_affinity` for candidate discovery\n- `inspect_affinity_item` for exact confirmation\n- `manage_identity` for routing-key upgrades\n- `manage_contact` only after lookup fails honestly\n\nRelated skills:\n- `bootstrap-and-cold-start`\n- `reconcile-duplicates-and-merge-safely`" }, "import-history-without-faking-evidence": { "description": "Import pre-existing relationship state honestly, using seeded links for known history instead of inventing fake journal evidence.", "content": '# Import History Without Faking Evidence\n\nPrimary tools:\n- `manage_contact`\n- `manage_identity`\n- `manage_relationship`\n- `inspect_affinity_item`\n\nGoal:\n- Represent real pre-system relationship state without fabricating a timeline of events that never existed inside Affinity.\n\nWhen to use:\n- You are migrating from another CRM, notes system, or memory set.\n- You already know a relationship exists and roughly how strong or active it is, but you do not want to reconstruct every past interaction.\n\nWhen not to use:\n- A new event just happened and should affect mechanics now.\n- You are recording a specific conversation, milestone, transaction, or observation.\n\nStep-by-step sequence:\n1. Create or locate the relevant contacts first.\n2. Add the strongest identities you know so the imported graph stays addressable later.\n3. Use `manage_relationship` with `action: "seed_social_link"` to establish a relational link that already existed before the system.\n4. Set only the fields you can justify: kind, rough rank, trust, cadence, state, and optional bond.\n5. Inspect the result with `inspect_affinity_item` if you need to verify the exact post-import state.\n6. From that point forward, switch to evidence tools for all new real-world activity.\n\nValidation checks:\n- The imported contact pair now has the expected relational link.\n- The seeded state looks plausible when inspected.\n- Later direct events continue progression from the seeded baseline rather than from zero.\n\nPitfalls:\n- Do not backfill dozens of fake conversations just to reach a desired rank.\n- Do not use import seeding for ordinary day-to-day relationship maintenance.\n- Do not overstate confidence on rank or trust when the historical truth is actually vague.\n\nTips and tricks:\n- Seed conservatively if you are unsure. It is safer to underclaim and let real future evidence move the link.\n- Use bond text sparingly as a narrative reminder of why the imported state is what it is.\n- Prefer one honest seed over many synthetic event reconstructions.\n\nTool calls to prefer:\n- `manage_relationship` with `seed_social_link`\n- `manage_contact` and `manage_identity` before seeding\n- `inspect_affinity_item` for post-import verification\n\nRelated skills:\n- `bootstrap-and-cold-start`\n- `record-direct-evidence-well`' }, "model-structure-orgs-and-households": { "description": "Model factual structure such as employment, membership, hierarchy, and household ties without confusing it with live relational mechanics.", "content": '# Model Structure, Orgs, And Households\n\nPrimary tools:\n- `manage_contact`\n- `manage_relationship`\n- `inspect_affinity_item`\n- `review_affinity`\n\nGoal:\n- Capture the stable factual skeleton of the world: who belongs where, who reports to whom, and what entities are structurally connected.\n\nWhen to use:\n- You need to represent employment, family structure, team membership, households, or service affiliation.\n- The fact should exist even if no emotional or operational relationship score is implied.\n\nWhen not to use:\n- You want to express how strong, strained, warm, or active a relationship feels.\n- You are recording something that happened in time.\n\nStep-by-step sequence:\n1. Create or locate the entities involved.\n2. Decide whether the fact is structural rather than relational. If the answer survives even with zero interaction history, it is usually structural.\n3. Use `manage_relationship` with `action: "set_structural_tie"` to record the factual tie.\n4. Include a role when the role clarifies the structure, such as job title or household role.\n5. Inspect relevant profiles or review lists after large structural batches to confirm the world model is clean.\n6. Remove or replace outdated ties when the world changes, such as a job move or team change.\n\nValidation checks:\n- The structural tie is visible and stable.\n- No fake progression fields were introduced just to represent a factual tie.\n- The chart and review surfaces now reflect the world structure you expect.\n\nPitfalls:\n- Do not use structural ties to stand in for closeness, trust, or cadence.\n- Do not seed social links just because a structural tie exists; only do that if a live relational track also matters.\n- Do not leave outdated structural ties hanging after the factual world changes.\n\nTips and tricks:\n- Structural and relational links can coexist. Use both when both truths matter.\n- Groups, households, companies, teams, and services often deserve their own contacts.\n- If you are unsure, ask whether the statement is about belonging or about lived relationship quality.\n\nTool calls to prefer:\n- `manage_relationship` with `set_structural_tie`\n- `inspect_affinity_item` to verify exact targets\n- `review_affinity` for broader structural review\n\nRelated skills:\n- `identify-and-locate-contacts`\n- `record-direct-evidence-well`' }, "record-direct-evidence-well": { "description": "Record direct owner-facing social evidence in a way that produces truthful mechanics, good moments, and useful future review surfaces.", "content": "# Record Direct Evidence Well\n\nPrimary tools:\n- `record_event`\n- `inspect_affinity_item`\n- `review_affinity`\n- `manage_relationship`\n\nGoal:\n- Capture what really happened between the owner and other contacts so Affinity can derive progression, trust, cadence, and moments cleanly.\n\nWhen to use:\n- A conversation, activity, support exchange, conflict, correction, milestone, or direct operational interaction just happened.\n- You want future radar, progression, journal, and chart views to reflect the event truthfully.\n\nWhen not to use:\n- You are importing pre-system history.\n- You only observed third parties and were not directly part of the relationship evidence.\n\nStep-by-step sequence:\n1. Confirm the participants first. Use exact contacts whenever possible.\n2. Use `record_event` with the smallest honest event family: `interaction` for ordinary direct evidence, `milestone` when the event is truly milestone-shaped, or another event family only when it better matches reality.\n3. Set significance conservatively. Use high significance only when the event genuinely changed the relationship.\n4. Let the system auto-create owner-facing links when appropriate instead of manually seeding a link just because a direct event happened.\n5. Review the receipt or inspect the resulting profile when you need to understand the effect.\n6. Use `review_affinity` on journal, timeline, moments, or readiness views when you need context after a sequence of events.\n\nValidation checks:\n- The right contacts were involved.\n- The event family matches what really happened.\n- The resulting mechanics feel plausible when reviewed.\n\nPitfalls:\n- Do not inflate significance to force a rank-up.\n- Do not turn ordinary direct evidence into a milestone just to get a dramatic moment.\n- Do not use bond text as evidence. If the bond changed but no event happened, revise the bond separately.\n- Do not guess through ambiguity; clarify the target first.\n\nTips and tricks:\n- Conflict and correction are both first-class direct evidence. Use them honestly rather than hiding them inside generic summaries.\n- Multi-participant owner events can refresh multiple links at once. That is a feature, not a bug.\n- If you need to label the current narrative reading without changing mechanics, use `manage_relationship` with `revise_bond` after the evidence is recorded.\n\nTool calls to prefer:\n- `record_event` for the evidence itself\n- `inspect_affinity_item` for exact read-back\n- `review_affinity` for journals, timelines, moments, and readiness\n- `manage_relationship` only for bond interpretation after the fact\n\nRelated skills:\n- `record-observations-and-referrals`\n- `review-radar-progression-and-graph`" }, "record-observations-and-referrals": { "description": "Capture third-party and referral truth without overclaiming intimacy, directness, or trust that the operator did not actually earn.", "content": '# Record Observations And Referrals\n\nPrimary tools:\n- `record_event`\n- `review_affinity`\n- `inspect_affinity_item`\n\nGoal:\n- Add truthful third-party graph information while keeping observational evidence weaker than direct relationship evidence.\n\nWhen to use:\n- You learned that two people know each other, one person referred another, or you observed a third-party relationship pattern.\n- You want the graph to reflect world truth without pretending you had the direct interaction yourself.\n\nWhen not to use:\n- The owner directly participated in the event and should record direct evidence instead.\n- You merely suspect a relationship with no strong enough fact to justify recording it.\n\nStep-by-step sequence:\n1. Confirm the third-party contacts you are referring to.\n2. Use `record_event` with `kind: "observation"` when the truth is observational rather than direct.\n3. Keep significance modest unless the observed fact is genuinely consequential.\n4. After recording, use `review_affinity` to inspect observed-link or graph views and verify the resulting topology.\n5. If later direct evidence exists, record that separately rather than expecting the observation to do all the work.\n\nValidation checks:\n- The graph now reflects the third-party fact you meant to capture.\n- Observed links stay appropriately weaker than direct links.\n- No accidental owner-facing intimacy was invented.\n\nPitfalls:\n- Do not use observations to fake deep trust or rank.\n- Do not create a clique from vague group knowledge when the system only has justified pairwise facts.\n- Do not promote a rumor to structure or direct evidence without better confirmation.\n\nTips and tricks:\n- Referral chains are excellent observational facts because they help later review and graph interpretation.\n- Use chart and observed-link review views together after batches of observation work.\n- Observations are often better than silence, but they should remain humble.\n\nTool calls to prefer:\n- `record_event` with `observation`\n- `review_affinity` with observed-link and graph views\n- `inspect_affinity_item` if you need exact follow-up on one target\n\nRelated skills:\n- `record-direct-evidence-well`\n- `review-radar-progression-and-graph`' }, "capture-transactions-and-commercial-events": { "description": "Capture commercially meaningful interactions as transactions so operational and service relationships accumulate the right evidence shape.", "content": '# Capture Transactions And Commercial Events\n\nPrimary tools:\n- `record_event`\n- `inspect_affinity_item`\n- `review_affinity`\n\nGoal:\n- Represent deals, placements, closings, sponsorships, and operationally meaningful exchanges as transactions instead of flattening them into generic social notes.\n\nWhen to use:\n- Money, deliverables, placements, closings, or other commercially meaningful exchanges just occurred.\n- The relationship is service-oriented, professional, or business-critical.\n\nWhen not to use:\n- The main truth is a personal conversation or ordinary activity.\n- The important action is really a promise or agreement that still needs later resolution.\n\nStep-by-step sequence:\n1. Confirm the right counterparties first.\n2. Use `record_event` with `kind: "transaction"` for the commercial event itself.\n3. Keep the summary concrete enough that future review explains what was exchanged and why it mattered.\n4. Use significance according to the real business weight, not just enthusiasm.\n5. Review the affected relationship if the commercial event should meaningfully change trust, cadence, or graph visibility.\n\nValidation checks:\n- The transaction now appears in the relevant timeline or journal views.\n- The resulting relationship effect matches the commercial importance of the event.\n- Later maintenance views now have the right recency signal.\n\nPitfalls:\n- Do not bury closings, placements, or sponsorship deliveries inside generic conversations.\n- Do not record a transaction when the truth is still only a pending agreement.\n- Do not expect transactions to create the same kind of affinity movement as rich direct social evidence.\n\nTips and tricks:\n- A relationship can be commercially strong and emotionally shallow. That is normal.\n- Use transactions to preserve crisp operational history for later portfolio review.\n- Follow major transactions with review views to confirm the graph and maintenance surfaces updated as expected.\n\nTool calls to prefer:\n- `record_event` with `transaction`\n- `inspect_affinity_item` for exact relationship read-back\n- `review_affinity` for owner-link portfolio review\n\nRelated skills:\n- `manage-promises-and-agreements`\n- `review-radar-progression-and-graph`' }, "manage-promises-and-agreements": { "description": "Track obligations explicitly from creation to resolution so open commitments remain actionable and trust consequences stay honest.", "content": '# Manage Promises And Agreements\n\nPrimary tools:\n- `manage_commitment`\n- `review_affinity`\n- `inspect_affinity_item`\n\nGoal:\n- Keep promises and agreements visible, resolvable, and mechanically meaningful instead of letting them disappear into narrative history.\n\nWhen to use:\n- Someone committed to doing something later.\n- A promise, agreement, or operational obligation needs follow-through.\n\nWhen not to use:\n- The event is complete already and no unresolved obligation exists.\n- A date is recurring and belongs in the date-anchor surface instead.\n\nStep-by-step sequence:\n1. When the commitment is made, use `manage_commitment` with `action: "record"`.\n2. Choose `promise` vs `agreement` honestly rather than treating them as stylistic synonyms.\n3. Include a due time when the obligation has a meaningful time boundary.\n4. Use `review_affinity` with the open-commitment view during regular review cycles.\n5. When the outcome is known, resolve it with `manage_commitment` as `kept`, `cancelled`, or `broken`.\n6. Inspect the relevant profile or review the relevant queue again if you need to confirm closure.\n\nValidation checks:\n- Open commitments appear when they should and disappear when resolved.\n- Resolution state matches real-world truth.\n- Trust effects feel consistent with whether the promise was kept or broken.\n\nPitfalls:\n- Do not hide real obligations inside ordinary event summaries.\n- Do not resolve a commitment optimistically before the outcome is actually known.\n- Do not misuse `cancelled` when the truth is actually `broken`.\n\nTips and tricks:\n- If an obligation matters enough to disappoint someone when missed, record it.\n- Review open commitments regularly; the value of the system drops fast if this queue goes stale.\n- When a commitment resolution also deserves narrative context, pair the resolution with an appropriate event in the surrounding workflow.\n\nTool calls to prefer:\n- `manage_commitment` for record and resolve\n- `review_affinity` for the open-commitment queue\n- `inspect_affinity_item` when you need exact context on the affected relationship\n\nRelated skills:\n- `capture-transactions-and-commercial-events`\n- `manage-recurring-dates-and-reminders`' }, "manage-recurring-dates-and-reminders": { "description": "Use date anchors for recurring salience so important birthdays, anniversaries, renewals, and memorials feed review surfaces cleanly.", "content": '# Manage Recurring Dates And Reminders\n\nPrimary tools:\n- `manage_date_anchor`\n- `review_affinity`\n- `inspect_affinity_item`\n\nGoal:\n- Materialize recurring important dates into a durable reminder surface that later review and relationship maintenance can trust.\n\nWhen to use:\n- A date recurs yearly and matters to a contact or link.\n- You want reminders for birthdays, anniversaries, renewals, memorials, or other yearly anchors.\n\nWhen not to use:\n- The date is a one-off event.\n- The fact is a preference or metadata note rather than a recurring anchor.\n\nStep-by-step sequence:\n1. Confirm the target contact or link first.\n2. Use `manage_date_anchor` with `action: "add"` and the right recurrence kind.\n3. Keep the summary operator-friendly so future reminders are immediately understandable.\n4. Use `review_affinity` with the upcoming-dates view and an explicit horizon during regular review work.\n5. Revise anchors when recurrence details change.\n6. Remove anchors when the reminder surface should no longer treat the date as active.\n\nValidation checks:\n- The anchor appears in upcoming-date review when it falls within the chosen horizon.\n- Duplicate anchors are not silently piling up.\n- The summary and significance are still meaningful for later action.\n\nPitfalls:\n- Do not store important recurring dates only as loose attributes.\n- Do not create duplicate yearly anchors for the same target and meaning.\n- Do not use recurring anchors for commitments; those belong in the commitment flow.\n\nTips and tricks:\n- Tune horizons by workflow: short for weekly review, longer for quarterly planning.\n- Use significance to help order upcoming-date review meaningfully.\n- Link-level anchors are valuable when the date belongs to a relationship rather than one person.\n\nTool calls to prefer:\n- `manage_date_anchor` for add, revise, and remove\n- `review_affinity` with the upcoming-dates view\n- `inspect_affinity_item` when you need exact context on the target\n\nRelated skills:\n- `manage-promises-and-agreements`\n- `review-radar-progression-and-graph`' }, "review-radar-progression-and-graph": { "description": "Use the review surfaces to prioritize maintenance, understand progression readiness, and read the shape of the relationship graph without guessing from raw rows.", "content": "# Review Radar, Progression, And Graph\n\nPrimary tools:\n- `review_affinity`\n- `inspect_affinity_item`\n\nGoal:\n- Turn review surfaces into a disciplined operating loop for outreach, prioritization, and graph understanding.\n\nWhen to use:\n- You need a daily or weekly maintenance pass.\n- You want to know who is drifting, who is ready for deeper investment, or how the network is structured.\n\nWhen not to use:\n- You already know the one exact item you need to inspect in depth.\n- You are mutating state and only need confirmation of a single write result.\n\nStep-by-step sequence:\n1. Start with `review_affinity` on the highest-level view that matches the question: radar, owner links, progression readiness, graph, moments, or a timeline view.\n2. Use radar when the question is who needs attention now.\n3. Use progression readiness when the question is where additional positive investment is most likely to deepen a relationship.\n4. Use graph review when the question is network shape, bridges, clusters, or hidden centrality.\n5. Use journal or timeline views when a score needs narrative context before action.\n6. Escalate to `inspect_affinity_item` only after the review surface identifies the exact target worth opening deeply.\n\nValidation checks:\n- The chosen review surface actually matches the decision you are trying to make.\n- You inspected context before acting on a surprising score.\n- Follow-up work targets the right contact or link rather than the loudest metric.\n\nPitfalls:\n- Do not treat radar as a command to act blindly without context.\n- Do not confuse progression readiness with relationship importance; they answer different questions.\n- Do not rely on one score when a timeline or graph view would explain the situation better.\n\nTips and tricks:\n- Review flows are strongest when used rhythmically rather than sporadically.\n- Use graph review to understand bridges and clusters, then use timeline or profile inspection for the exact story behind them.\n- Empty results can still be informative: they may confirm that maintenance debt is currently low.\n\nTool calls to prefer:\n- `review_affinity` for all dashboard-style surfaces\n- `inspect_affinity_item` for exact deep follow-up\n\nRelated skills:\n- `record-direct-evidence-well`\n- `manage-recurring-dates-and-reminders`\n- `reconcile-duplicates-and-merge-safely`" }, "reconcile-duplicates-and-merge-safely": { "description": "Review duplicate candidates carefully and merge only after the identity, history, and structural consequences are understood.", "content": "# Reconcile Duplicates And Merge Safely\n\nPrimary tools:\n- `review_affinity`\n- `inspect_affinity_item`\n- `merge_contacts`\n\nGoal:\n- Collapse true duplicates without losing lineage, identities, participants, or relationship truth.\n\nWhen to use:\n- Duplicate-candidate review surfaces indicate a likely duplicate.\n- A harness or operator has strong evidence that two contacts are the same real entity.\n\nWhen not to use:\n- The match is still speculative and unresolved.\n- The real need is just better identities or labels rather than an actual merge.\n\nStep-by-step sequence:\n1. Start with `review_affinity` on the duplicate-candidate view.\n2. Inspect both candidate contacts before merging.\n3. Confirm the winner explicitly rather than letting recency or convenience decide by accident.\n4. Use `merge_contacts` only when the identity and history picture is clear.\n5. Inspect the surviving contact after the merge to confirm the merged state looks right.\n6. Review merge history when you need lineage confirmation later.\n\nValidation checks:\n- The surviving contact now contains the expected identities and visible history.\n- The loser is no longer treated as a live normal contact.\n- The merge reason is still understandable when reviewed later.\n\nPitfalls:\n- Do not merge on a weak fuzzy-name match alone.\n- Do not skip inspection of both sides before merging.\n- Do not assume merges are easy to conceptually undo just because the system preserves lineage.\n\nTips and tricks:\n- Prefer a winner that already carries the stronger routing identity and richer history unless another reason dominates.\n- If the system is uncertain, clarify first instead of forcing a merge.\n- Review merge history after sensitive merges to confirm the lineage chain is clean.\n\nTool calls to prefer:\n- `review_affinity` for duplicate-candidate discovery and merge-history review\n- `inspect_affinity_item` for both candidate contacts\n- `merge_contacts` only after confirmation\n\nRelated skills:\n- `identify-and-locate-contacts`\n- `import-history-without-faking-evidence`" } };
10669
+ }
10670
+ });
10671
+
9229
10672
  // src/skills/skill_types.ts
9230
10673
  function defineAffinitySkill(skill) {
9231
10674
  return skill;
@@ -9976,22 +11419,76 @@ var init_soul = __esm({
9976
11419
  });
9977
11420
 
9978
11421
  // src/surface/affinity_skill_spec.ts
9979
- var affinity_skill_spec_exports = {};
9980
- __export(affinity_skill_spec_exports, {
9981
- affinitySkillSpec: () => affinitySkillSpec
9982
- });
9983
- var affinitySkillSpec;
11422
+ var affinityCliExamples, affinityCheatSheet, affinitySkillSpec;
9984
11423
  var init_affinity_skill_spec = __esm({
9985
11424
  "src/surface/affinity_skill_spec.ts"() {
9986
11425
  init_skill_registry();
9987
11426
  init_soul();
9988
11427
  init_affinity_mcp_instructions();
9989
11428
  init_affinity_surface_tools();
11429
+ affinityCliExamples = {
11430
+ search_affinity: 'ghostpaw-affinity search "Jana"',
11431
+ review_affinity: `ghostpaw-affinity review events.contact_journal --contact '{"contactId":2}'`,
11432
+ inspect_affinity_item: `ghostpaw-affinity show contact_profile --contact '{"contactId":2}'`,
11433
+ manage_contact: `ghostpaw-affinity contact create '{"name":"Fox","kind":"human","bootstrapOwner":true}'`,
11434
+ merge_contacts: `ghostpaw-affinity contact merge --winner '{"contactId":2}' --loser '{"contactId":8}' --reason-summary "same person"`,
11435
+ manage_duplicate_dismissal: `ghostpaw-affinity duplicate dismiss --left '{"contactId":5}' --right '{"contactId":10}' --reason '"different people"'`,
11436
+ manage_identity: `ghostpaw-affinity identity add --contact '{"contactId":2}' --input '{"type":"email","value":"jana@x.de"}'`,
11437
+ manage_relationship: `ghostpaw-affinity relationship tie --from '{"contactId":2}' --to '{"contactId":3}' --kind works_at --role Founder`,
11438
+ record_event: `ghostpaw-affinity event record --kind interaction --input '{"type":"conversation","occurredAt":"today","summary":"Call re pilot","significance":6,"participants":[{"contactId":2,"role":"recipient","directionality":"mutual"}]}'`,
11439
+ manage_commitment: `ghostpaw-affinity commitment record --input '{"commitmentType":"promise","occurredAt":"today","dueAt":"+3d","summary":"Send proposal","significance":6,"participants":[{"contactId":2,"role":"recipient"}]}'`,
11440
+ manage_date_anchor: `ghostpaw-affinity date add --input '{"target":{"kind":"contact","contact":{"contactId":2}},"recurrenceKind":"birthday","anchorMonth":11,"anchorDay":3,"summary":"Jana bday","significance":4}'`,
11441
+ manage_attribute: `ghostpaw-affinity attribute set --target '{"kind":"contact","contact":{"contactId":2}}' --name pipeline_stage --value '"proposal"'`
11442
+ };
11443
+ affinityCheatSheet = `## CLI cheat sheet
11444
+
11445
+ Install: \`curl -fsSL https://raw.githubusercontent.com/GhostPawJS/affinity/main/install.sh | sh\`
11446
+ Windows: \`irm https://raw.githubusercontent.com/GhostPawJS/affinity/main/install.ps1 | iex\`
11447
+
11448
+ \`\`\`sh
11449
+ export GHOSTPAW_AFFINITY_DB=/abs/path/affinity.db
11450
+ # Or --db; default ~/.ghostpaw/affinity.db. Piped output is JSON; --compact omits empty receipt arrays.
11451
+ # Exit: 0 ok/no_op, 1 tool error, 2 usage/invalid input, 3 not found/clarification, 4 conflict, 70 internal.
11452
+ # Dates: epoch ms, ISO with offset/Z, YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, +3d, -2d.
11453
+ # Date-only occurredAt uses local midnight; dueAt uses local end of day.
11454
+ ghostpaw-affinity contact create '{"name":"Fox","kind":"human","bootstrapOwner":true}'
11455
+ ghostpaw-affinity search "Jana" # Search names, identities, tags, attributes, roles and events before creating.
11456
+ ghostpaw-affinity contact create '{"name":"Jana Petersen","kind":"human"}'
11457
+ ghostpaw-affinity identity add --contact '{"contactId":2}' --input '{"type":"email","value":"jana@x.de"}'
11458
+ ghostpaw-affinity relationship tie --from '{"contactId":2}' --to '{"contactId":3}' --kind works_at --role Founder
11459
+ ghostpaw-affinity relationship add --from '{"contactId":1}' --to '{"contactId":2}' --kind professional --role lead
11460
+ ghostpaw-affinity event record --kind interaction --input '{"type":"conversation","occurredAt":"today","summary":"Call re pilot","significance":6,"participants":[{"contactId":2,"role":"recipient","directionality":"mutual"}]}'
11461
+ ghostpaw-affinity commitment record --input '{"commitmentType":"promise","occurredAt":"today","dueAt":"+3d","summary":"Send proposal","significance":6,"participants":[{"contactId":2,"role":"recipient"}]}'
11462
+ ghostpaw-affinity commitment resolve --commitment-event-id 6 --resolution kept
11463
+ ghostpaw-affinity date add --input '{"target":{"kind":"contact","contact":{"contactId":2}},"recurrenceKind":"birthday","anchorMonth":11,"anchorDay":3,"summary":"Jana bday","significance":4}'
11464
+ ghostpaw-affinity attribute set --target '{"kind":"contact","contact":{"contactId":2}}' --name pipeline_stage --value '"proposal"'
11465
+ ghostpaw-affinity attribute set --target '{"kind":"contact","contact":{"contactId":2}}' --name tag:gastro-radar --value null
11466
+ ghostpaw-affinity show contact_profile --contact '{"contactId":2}'
11467
+ ghostpaw-affinity review events.contact_journal --contact '{"contactId":2}'
11468
+ ghostpaw-affinity review links.radar
11469
+ ghostpaw-affinity review commitments.open
11470
+ ghostpaw-affinity review dates.upcoming --horizon-days 14
11471
+ ghostpaw-affinity review contacts.list --tag gastro-radar
11472
+ ghostpaw-affinity review contacts.list --attribute pipeline_stage=proposal
11473
+ ghostpaw-affinity review contacts.duplicates
11474
+ ghostpaw-affinity contact merge --winner '{"contactId":2}' --loser '{"contactId":8}' --reason-summary "same person"
11475
+ ghostpaw-affinity duplicate dismiss --left '{"contactId":5}' --right '{"contactId":10}' --reason '"different people"'
11476
+ ghostpaw-affinity contact revise '{"contactId":3}' --patch '{"name":"New Name"}'
11477
+ ghostpaw-affinity contact lifecycle '{"contactId":9}' dormant
11478
+ ghostpaw-affinity relationship state --link '{"linkId":7}' --state dormant
11479
+ ghostpaw-affinity contact export --contact '{"contactId":2}'
11480
+ ghostpaw-affinity contact delete --contact '{"contactId":2}' --confirm
11481
+ \`\`\`
11482
+
11483
+ For bots and agents: run \`ghostpaw-affinity mcp\` for MCP stdio, or use this CLI with \`--json\`. Read a guide with \`ghostpaw-affinity skill <guide>\`.
11484
+ `;
9990
11485
  affinitySkillSpec = {
11486
+ cheatSheet: affinityCheatSheet,
11487
+ examples: affinityCliExamples,
9991
11488
  name: "affinity",
9992
11489
  description: "Search and maintain local contacts, identities, relationships, events, commitments, and recurring dates. Use when recording relationship evidence or reviewing a social CRM.",
9993
11490
  license: "MIT",
9994
- compatibility: "Node.js 24 or newer. MCP stdio or ghostpaw-affinity CLI.",
11491
+ compatibility: "The release binary needs no runtime. MCP stdio or ghostpaw-affinity CLI.",
9995
11492
  bin: "ghostpaw-affinity",
9996
11493
  packageName: "@ghostpaw/affinity",
9997
11494
  server: "ghostpaw-affinity",
@@ -10003,22 +11500,98 @@ var init_affinity_skill_spec = __esm({
10003
11500
  }
10004
11501
  });
10005
11502
 
11503
+ // src/surface/affinity_skill_command.ts
11504
+ var affinity_skill_command_exports = {};
11505
+ __export(affinity_skill_command_exports, {
11506
+ affinitySkillCommand: () => affinitySkillCommand
11507
+ });
11508
+ function affinitySkillCommand(args) {
11509
+ if (args.includes("--help") || args.includes("-h"))
11510
+ return "Usage: ghostpaw-affinity skill [<guide>|--list|--help]\n";
11511
+ if (args.length > 1) throw new CliUsageError("Expected one skill guide");
11512
+ const guide = args[0];
11513
+ if (guide === "--list")
11514
+ return `${Object.entries(affinitySkillReferences).map(([name, entry]) => `${name} ${entry.description}`).join("\n")}
11515
+ `;
11516
+ if (!guide) return renderSkillMarkdown(affinitySkillSpec);
11517
+ const found = affinitySkillReferences[guide];
11518
+ if (found) return found.content;
11519
+ const suggestion = suggestCommand(
11520
+ guide,
11521
+ Object.keys(affinitySkillReferences)
11522
+ );
11523
+ throw new CliUsageError(
11524
+ `Unknown guide: ${guide}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`
11525
+ );
11526
+ }
11527
+ var init_affinity_skill_command = __esm({
11528
+ "src/surface/affinity_skill_command.ts"() {
11529
+ init_cli_args();
11530
+ init_cli_output();
11531
+ init_skill_markdown();
11532
+ init_affinity_skill_references();
11533
+ init_affinity_skill_spec();
11534
+ }
11535
+ });
11536
+
10006
11537
  // src/surface/affinity_cli_options.ts
10007
11538
  init_version();
11539
+
11540
+ // src/surface/affinity_cli_errors.ts
11541
+ var commands = {
11542
+ mergeContacts: "ghostpaw-affinity contact merge"
11543
+ };
11544
+ function affinityCliError(message) {
11545
+ let result = message;
11546
+ for (const [source, command] of Object.entries(commands))
11547
+ result = result.replaceAll(`use ${source}`, `use: ${command}`);
11548
+ return result;
11549
+ }
11550
+
11551
+ // src/surface/affinity_cli_options.ts
10008
11552
  init_affinity_surface_tools();
11553
+ var npmEntry = false;
11554
+ function markNpmEntry() {
11555
+ npmEntry = true;
11556
+ }
11557
+ function cliVersion(version, {
11558
+ sea = isSea,
11559
+ npm = npmEntry,
11560
+ platform = process.platform,
11561
+ arch = process.arch
11562
+ } = {}) {
11563
+ let binary = false;
11564
+ try {
11565
+ binary = sea();
11566
+ } catch {
11567
+ }
11568
+ const target = `${platform === "win32" ? "windows" : platform}-${arch}`;
11569
+ return `affinity ${version} (${binary ? `binary, ${target}` : npm ? "npm" : "library"})`;
11570
+ }
11571
+
11572
+ // src/surface/affinity_cli_options.ts
11573
+ init_plain_dates();
11574
+ init_resolve_affinity_db_path();
10009
11575
  var affinityCliOptions = {
11576
+ reportCreation: true,
11577
+ transformInput: normalizeTimeInputs,
11578
+ versionLine: () => cliVersion(affinityVersion),
11579
+ describeDb: (db) => resolveAffinityDbPath(db === void 0 ? {} : { flag: db }),
11580
+ formatError: affinityCliError,
10010
11581
  bin: "ghostpaw-affinity",
10011
11582
  version: affinityVersion,
10012
11583
  description: "Local relationship model and social CRM",
10013
11584
  tools: affinityCliSurfaceTools,
10014
11585
  openContext: async (options) => {
10015
11586
  const { openAffinityDb: openAffinityDb2 } = await Promise.resolve().then(() => (init_open_affinity_db(), open_affinity_db_exports));
10016
- const { resolveAffinityDbPath: resolveAffinityDbPath2 } = await Promise.resolve().then(() => (init_resolve_affinity_db_path(), resolve_affinity_db_path_exports));
10017
- return openAffinityDb2(
10018
- resolveAffinityDbPath2(
10019
- options.db === void 0 ? {} : { flag: options.db }
10020
- )
11587
+ const path = resolveAffinityDbPath(
11588
+ options.db === void 0 ? {} : { flag: options.db }
10021
11589
  );
11590
+ const created = path !== ":memory:" && !existsSync(path);
11591
+ const db = await openAffinityDb2(path);
11592
+ if (created) (options.stderr ?? process.stderr).write(`created ${path}
11593
+ `);
11594
+ return db;
10022
11595
  },
10023
11596
  closeContext: (db) => db.close(),
10024
11597
  commands: {
@@ -10028,125 +11601,19 @@ var affinityCliOptions = {
10028
11601
  options.db === void 0 ? {} : { dbPath: options.db }
10029
11602
  );
10030
11603
  },
10031
- skill: async () => {
10032
- const { renderSkillMarkdown: renderSkillMarkdown2 } = await Promise.resolve().then(() => (init_skill_markdown(), skill_markdown_exports));
10033
- const { affinitySkillSpec: affinitySkillSpec2 } = await Promise.resolve().then(() => (init_affinity_skill_spec(), affinity_skill_spec_exports));
10034
- return renderSkillMarkdown2(affinitySkillSpec2);
11604
+ skill: async (args = []) => {
11605
+ const { affinitySkillCommand: affinitySkillCommand2 } = await Promise.resolve().then(() => (init_affinity_skill_command(), affinity_skill_command_exports));
11606
+ return affinitySkillCommand2(args);
10035
11607
  }
10036
11608
  }
10037
11609
  };
10038
- var CliUsageError = class extends Error {
10039
- exitCode = 2;
10040
- };
10041
- var keyOf = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
10042
- function parseCliArgs(argv, spec) {
10043
- const values = {};
10044
- const positions = [];
10045
- const flags = spec.flags ?? [];
10046
- const convert = (raw, arg) => {
10047
- let value = raw;
10048
- if (raw.startsWith("@")) {
10049
- try {
10050
- value = readFileSync(raw.slice(1), "utf8");
10051
- } catch {
10052
- throw new CliUsageError(`Cannot read value file: ${raw.slice(1)}`);
10053
- }
10054
- }
10055
- let result = value;
10056
- if (arg.type === "number" || arg.type === "integer") {
10057
- result = Number(value);
10058
- if (!value.trim() || !Number.isFinite(result) || arg.type === "integer" && !Number.isInteger(result))
10059
- throw new CliUsageError(
10060
- `Expected ${arg.type} for ${arg.name}: ${value}`
10061
- );
10062
- } else if (arg.type === "boolean") {
10063
- if (!["true", "false"].includes(value))
10064
- throw new CliUsageError(`Expected boolean for ${arg.name}`);
10065
- result = value === "true";
10066
- } else if (arg.type === "json") {
10067
- try {
10068
- result = JSON.parse(value);
10069
- } catch {
10070
- throw new CliUsageError(`Invalid JSON for ${arg.name}`);
10071
- }
10072
- }
10073
- if (arg.enum && !arg.enum.includes(result))
10074
- throw new CliUsageError(
10075
- `${arg.name} must be one of: ${arg.enum.join(", ")}`
10076
- );
10077
- return result;
10078
- };
10079
- let ended = false;
10080
- for (let i = 0; i < argv.length; i++) {
10081
- const token = argv[i];
10082
- if (!ended && token === "--") {
10083
- ended = true;
10084
- continue;
10085
- }
10086
- if (ended || token === "-" || !token.startsWith("-")) {
10087
- positions.push(token);
10088
- continue;
10089
- }
10090
- const [flag, inline] = token.split(/=(.*)/s);
10091
- const negative = flag?.startsWith("--no-");
10092
- const name = flag?.replace(/^--?/, "").replace(/^no-/, "");
10093
- const arg = flags.find(
10094
- (entry) => entry.name === name || keyOf(entry.name) === name || entry.short === name
10095
- );
10096
- if (!arg) {
10097
- const prefix = (name ?? "").slice(0, 3);
10098
- const suggestion = prefix ? flags.find((entry) => entry.name.startsWith(prefix)) : void 0;
10099
- throw new CliUsageError(
10100
- `Unknown flag: ${flag}${suggestion ? `; did you mean --${suggestion.name}?` : ""}`
10101
- );
10102
- }
10103
- if (negative && arg.type !== "boolean")
10104
- throw new CliUsageError(`--no-${arg.name} requires a boolean flag`);
10105
- let raw = inline;
10106
- if (arg.type === "boolean" && raw === void 0)
10107
- raw = negative ? "false" : "true";
10108
- if (raw === void 0) {
10109
- const next = argv[i + 1];
10110
- if (next === void 0 || next.startsWith("--"))
10111
- throw new CliUsageError(`Missing value: --${arg.name}`);
10112
- raw = next;
10113
- i++;
10114
- }
10115
- const key = keyOf(arg.name);
10116
- if (arg.type === "array") {
10117
- const item = convert(raw, { ...arg, type: arg.itemType ?? "string" });
10118
- values[key] = [...values[key] ?? [], item];
10119
- } else values[key] = convert(raw, arg);
10120
- }
10121
- let index = 0;
10122
- for (const arg of spec.positionals ?? []) {
10123
- if (index >= positions.length) continue;
10124
- values[keyOf(arg.name)] = arg.variadic ? positions.slice(index).map(
10125
- (value) => convert(value, { ...arg, type: arg.itemType ?? arg.type })
10126
- ) : convert(positions[index], arg);
10127
- index = arg.variadic ? positions.length : index + 1;
10128
- }
10129
- if (index < positions.length)
10130
- throw new CliUsageError(`Unexpected positional: ${positions[index]}`);
10131
- for (const arg of [...flags, ...spec.positionals ?? []]) {
10132
- if (arg.required && values[keyOf(arg.name)] === void 0)
10133
- throw new CliUsageError(`Missing required argument: ${arg.name}`);
10134
- }
10135
- return values;
10136
- }
10137
- function renderCliHelp(spec) {
10138
- const describe = (arg) => `${arg.type}${arg.enum ? ` (${arg.enum.join("|")})` : ""}${arg.required ? " required" : ""} ${arg.description ?? ""}`;
10139
- return `Usage: ${spec.command} ${(spec.positionals ?? []).map((arg) => `<${arg.name}${arg.variadic ? "..." : ""}>`).join(" ")} [flags]
10140
- ${spec.description ?? ""}
10141
11610
 
10142
- Positionals:
10143
- ${(spec.positionals ?? []).map((arg) => ` ${arg.name} ${describe(arg)}`).join("\n")}
10144
- Flags:
10145
- ${(spec.flags ?? []).map((arg) => ` --${arg.name}${arg.short ? `, -${arg.short}` : ""} ${describe(arg)}`).join("\n")}
10146
- `;
10147
- }
11611
+ // src/surfaces/cli.ts
11612
+ init_cli_args();
10148
11613
 
10149
11614
  // src/surfaces/cli_commands.ts
11615
+ init_cli_args();
11616
+ init_cli_output();
10150
11617
  init_validate_input();
10151
11618
  function cliCommands(tools) {
10152
11619
  return tools.flatMap((tool) => {
@@ -10218,14 +11685,16 @@ async function runCliCommand(argv, options) {
10218
11685
  else if (flag === "--compact") compact = true;
10219
11686
  else if (flag === "--help" || flag === "-h") help = true;
10220
11687
  else if (["--version", "-V", "-v"].includes(flag ?? "")) {
10221
- if (!quiet) stdout.write(`${options.version}
11688
+ if (!quiet)
11689
+ stdout.write(`${options.versionLine?.() ?? options.version}
10222
11690
  `);
10223
11691
  return 0;
10224
11692
  } else if (flag !== "--no-color") continue;
10225
11693
  args.splice(i--, 1);
10226
11694
  }
10227
11695
  if (args[0] === "version") {
10228
- if (!quiet) stdout.write(`${options.version}
11696
+ if (!quiet)
11697
+ stdout.write(`${options.versionLine?.() ?? options.version}
10229
11698
  `);
10230
11699
  return 0;
10231
11700
  }
@@ -10240,10 +11709,12 @@ async function runCliCommand(argv, options) {
10240
11709
  const specFor = (command) => {
10241
11710
  const argument = (name, schema) => ({
10242
11711
  name,
10243
- type: schema.type === "object" || !schema.type ? "json" : schema.type,
11712
+ type: options.transformInput && /(?:At|-at)$|^(asOf|as-of|now)$/.test(name) ? "string" : schema.type === "object" || !schema.type && !schema.oneOf?.some((branch) => branch.type === "string") ? "json" : schema.type ?? "string",
10244
11713
  description: schema.description,
10245
11714
  enum: schema.enum,
10246
- ...schema.items?.type ? { itemType: schema.items.type } : {}
11715
+ ...schema.items?.type ? {
11716
+ itemType: schema.items.type === "object" || schema.items.type === "array" ? "json" : schema.items.type
11717
+ } : {}
10247
11718
  });
10248
11719
  const properties = command.tool.inputSchema.properties ?? {};
10249
11720
  return {
@@ -10274,7 +11745,7 @@ async function runCliCommand(argv, options) {
10274
11745
  throw new CliUsageError(`Unknown command: ${args.join(" ")}`);
10275
11746
  if (!quiet)
10276
11747
  stdout.write(
10277
- entry ? renderCliHelp(specFor(entry)) : `${options.bin} ${options.version}
11748
+ entry ? renderCliHelp(specFor(entry)) : `${options.versionLine?.() ?? `${options.bin} ${options.version}`}
10278
11749
  ${options.description}
10279
11750
 
10280
11751
  Commands:
@@ -10283,6 +11754,9 @@ ${matches.map((command) => ` ${command.path}`).join("\n")}
10283
11754
  Global flags: --db <path>, --json, --quiet, --compact, --help/-h, --version/-V/-v, --no-color
10284
11755
  `
10285
11756
  );
11757
+ if (options.describeDb && !quiet)
11758
+ stdout.write(`Database: ${options.describeDb(db)}
11759
+ `);
10286
11760
  return 0;
10287
11761
  }
10288
11762
  if (args[0] === "completions") {
@@ -10317,7 +11791,16 @@ _arguments '*:command:(${words.join(" ")})'
10317
11791
  throw new CliUsageError("Expected completion shell: bash, zsh or fish");
10318
11792
  return 0;
10319
11793
  }
10320
- if (!entry) throw new CliUsageError(`Unknown command: ${args.join(" ")}`);
11794
+ if (!entry) {
11795
+ const path = args.filter((x) => !x.startsWith("--")).slice(0, 2).join(" ");
11796
+ const suggestion = suggestCommand(
11797
+ path,
11798
+ tree.map((c) => c.path)
11799
+ );
11800
+ throw new CliUsageError(
11801
+ `Unknown command: ${path}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`
11802
+ );
11803
+ }
10321
11804
  const rest = args.slice(entry.path.split(" ").length);
10322
11805
  let base = {};
10323
11806
  const spec = specFor(entry);
@@ -10347,29 +11830,42 @@ _arguments '*:command:(${words.join(" ")})'
10347
11830
  if (!base || typeof base !== "object" || Array.isArray(base))
10348
11831
  throw new CliUsageError("Expected stdin JSON object");
10349
11832
  }
10350
- const input = {
11833
+ const objectPositions = entry.positionals.filter(
11834
+ (name) => entry.tool.inputSchema.properties?.[name]?.type === "object"
11835
+ );
11836
+ if (objectPositions.length === 1 && base && typeof base === "object" && Object.keys(base).length && !(objectPositions[0] in base))
11837
+ base = { [objectPositions[0]]: base };
11838
+ let input = {
10351
11839
  ...base,
10352
11840
  ...parseCliArgs(rest, spec),
10353
11841
  ...entry.fixed
10354
11842
  };
11843
+ for (const [key, schema] of Object.entries(
11844
+ entry.tool.inputSchema.properties ?? {}
11845
+ )) {
11846
+ if (schema.type === "array" && schema.items?.type === "object" && Array.isArray(input[key]))
11847
+ input[key] = input[key].flat();
11848
+ }
11849
+ input = options.transformInput?.(input) ?? input;
10355
11850
  const errors = validateInput(entry.tool.inputSchema, input);
10356
11851
  if (errors.length) throw new CliUsageError(errors.join("\n"));
10357
- const context = await options.openContext(db === void 0 ? {} : { db });
11852
+ const context = await options.openContext({
11853
+ ...db === void 0 ? {} : { db },
11854
+ ...options.reportCreation ? { stderr } : {}
11855
+ });
10358
11856
  try {
10359
11857
  const result = await entry.tool.handler(context, input);
10360
11858
  const record = result;
10361
- const code = record.outcome === "needs_clarification" || record.error?.code === "not_found" ? 3 : ["conflict", "version_mismatch"].includes(record.error?.code ?? "") ? 4 : record.ok === false ? 1 : 0;
11859
+ const code = cliExitCode(result);
10362
11860
  if (code)
10363
11861
  stderr.write(
10364
- `${record.error?.message ?? record.summary ?? "Tool failed"}
11862
+ `${code === 70 ? "internal error: " : ""}${options.formatError?.(record.error?.message ?? record.summary ?? "Tool failed") ?? record.error?.message ?? record.summary ?? "Tool failed"}
10365
11863
  `
10366
11864
  );
10367
11865
  if (!quiet)
10368
11866
  stdout.write(
10369
- json || !stdout.isTTY ? `${JSON.stringify(result, null, compact ? void 0 : 2)}
10370
- ` : `${record.summary ?? ""}
10371
- ${JSON.stringify(record.data ?? {}, null, 2)}
10372
- `
11867
+ json || !stdout.isTTY ? `${JSON.stringify(plainJson(result, compact, options.formatError), null, compact ? void 0 : 2)}
11868
+ ` : humanResult(result)
10373
11869
  );
10374
11870
  return code;
10375
11871
  } finally {
@@ -10377,22 +11873,24 @@ ${JSON.stringify(record.data ?? {}, null, 2)}
10377
11873
  }
10378
11874
  } catch (error) {
10379
11875
  return fail(
10380
- error instanceof Error ? error.message : String(error),
11876
+ `${error instanceof CliUsageError ? "" : "internal error: "}${error instanceof Error ? error.message : String(error)}`,
10381
11877
  error instanceof CliUsageError ? 2 : 70
10382
11878
  );
10383
11879
  }
10384
11880
  }
10385
11881
 
10386
11882
  // src/surfaces/cli.ts
10387
- init_surface_types();
11883
+ init_cli_output();
10388
11884
  init_validate_input();
10389
- async function runToolCli(argv, providedOptions) {
11885
+ async function runToolCliInternal(argv, providedOptions) {
10390
11886
  let options = providedOptions;
10391
11887
  const end = argv.indexOf("--");
10392
11888
  if (argv.slice(0, end < 0 ? argv.length : end).some((arg) => ["--version", "-V", "-v"].includes(arg))) {
10393
11889
  if (!argv.includes("--quiet"))
10394
- (options.stdout ?? process.stdout).write(`${options.version}
10395
- `);
11890
+ (options.stdout ?? process.stdout).write(
11891
+ `${options.versionLine?.() ?? options.version}
11892
+ `
11893
+ );
10396
11894
  return 0;
10397
11895
  }
10398
11896
  const modern = await runCliCommand(argv, options);
@@ -10444,7 +11942,7 @@ async function runToolCli(argv, providedOptions) {
10444
11942
  const normalize = (name2) => name2.replace(/[-_]/g, "").toLowerCase();
10445
11943
  if (["help", "--help", "-h"].includes(command)) {
10446
11944
  stdout.write(
10447
- `${options.bin} ${options.version}
11945
+ `${options.versionLine?.() ?? `${options.bin} ${options.version}`}
10448
11946
  ${options.description}
10449
11947
 
10450
11948
  Usage: ${options.bin} [--db path] <command>
@@ -10457,7 +11955,7 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
10457
11955
  return 0;
10458
11956
  }
10459
11957
  if (["--version", "-V", "-v", "version"].includes(command)) {
10460
- stdout.write(`${options.version}
11958
+ stdout.write(`${options.versionLine?.() ?? options.version}
10461
11959
  `);
10462
11960
  return 0;
10463
11961
  }
@@ -10477,7 +11975,14 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
10477
11975
  }
10478
11976
  if (command === "skill") {
10479
11977
  if (!options.commands?.skill) return fail("skill command unavailable");
10480
- stdout.write(await options.commands.skill());
11978
+ try {
11979
+ stdout.write(await options.commands.skill(args));
11980
+ } catch (error) {
11981
+ return fail(
11982
+ `${error instanceof CliUsageError ? "" : "internal error: "}${error instanceof Error ? error.message : String(error)}`,
11983
+ error instanceof CliUsageError ? 2 : 70
11984
+ );
11985
+ }
10481
11986
  return 0;
10482
11987
  }
10483
11988
  if (command === "mcp") {
@@ -10521,7 +12026,7 @@ ${JSON.stringify(tool.inputSchema, null, 2)}
10521
12026
  if (args.length > 1) return fail("Expected a single JSON argument");
10522
12027
  const raw = args[0];
10523
12028
  input = JSON.parse(
10524
- raw === "-" || raw === void 0 && !stdin.isTTY ? await readStdin() : raw ?? "{}"
12029
+ raw === "-" || raw === void 0 && !stdin.isTTY ? await readStdin() : raw?.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw ?? "{}"
10525
12030
  );
10526
12031
  } else {
10527
12032
  const values = {};
@@ -10565,40 +12070,79 @@ ${JSON.stringify(tool.inputSchema, null, 2)}
10565
12070
  ...Array.isArray(prior) ? prior : [],
10566
12071
  coerce(raw, schema.items ?? {})
10567
12072
  ];
10568
- } else values[property] = coerce(raw, schema);
12073
+ } else
12074
+ values[property] = options.transformInput && /At$|^(asOf|now)$/.test(property) ? raw : coerce(raw, schema);
10569
12075
  }
10570
12076
  input = values;
10571
12077
  }
10572
12078
  } catch (cause) {
10573
12079
  return fail(
10574
12080
  `Invalid input: ${cause instanceof Error ? cause.message : String(cause)}`,
10575
- 1
12081
+ 2
10576
12082
  );
10577
12083
  }
12084
+ try {
12085
+ input = options.transformInput?.(input) ?? input;
12086
+ } catch (cause) {
12087
+ return fail(cause instanceof Error ? cause.message : String(cause), 2);
12088
+ }
10578
12089
  const errors = validateInput(tool.inputSchema, input);
10579
- if (errors.length) return fail(errors.join("\n"), 1);
12090
+ if (errors.length) return fail(errors.join("\n"), 2);
10580
12091
  let context;
10581
12092
  try {
10582
- context = await options.openContext(ctxOptions);
12093
+ context = await options.openContext(
12094
+ options.reportCreation ? { ...ctxOptions, stderr } : ctxOptions
12095
+ );
10583
12096
  } catch (cause) {
10584
- return fail(String(cause), 1);
12097
+ return fail(
12098
+ `internal error: ${cause instanceof Error ? cause.message : String(cause)}`,
12099
+ 70
12100
+ );
10585
12101
  }
10586
12102
  try {
10587
12103
  const result = await tool.handler(
10588
12104
  context,
10589
12105
  input
10590
12106
  );
10591
- stdout.write(`${JSON.stringify(result, null, compact ? void 0 : 2)}
10592
- `);
10593
- if (typeof result === "object" && result !== null && "outcome" in result && result.outcome === "needs_clarification")
10594
- return 3;
10595
- return isErrorResult(result) ? 1 : 0;
12107
+ stdout.write(
12108
+ explicitJson || !stdout.isTTY ? `${JSON.stringify(plainJson(result, compact, options.formatError), null, compact ? void 0 : 2)}
12109
+ ` : humanResult(result)
12110
+ );
12111
+ const code = cliExitCode(result);
12112
+ if (code) {
12113
+ const r = result;
12114
+ const message = r.error?.message ?? r.summary ?? "Tool failed";
12115
+ stderr.write(
12116
+ `${code === 70 ? "internal error: " : ""}${options.formatError?.(message) ?? message}
12117
+ `
12118
+ );
12119
+ }
12120
+ return code;
10596
12121
  } catch (cause) {
10597
- return fail(String(cause), 1);
12122
+ return fail(
12123
+ `internal error: ${cause instanceof Error ? cause.message : String(cause)}`,
12124
+ 70
12125
+ );
10598
12126
  } finally {
10599
12127
  await options.closeContext?.(context);
10600
12128
  }
10601
12129
  }
12130
+ async function runToolCli(argv, options) {
12131
+ try {
12132
+ return await runToolCliInternal(argv, options);
12133
+ } catch (error) {
12134
+ const message = `internal error: ${error instanceof Error ? error.message : String(error)}`;
12135
+ (options.stderr ?? process.stderr).write(`${message}
12136
+ `);
12137
+ if (argv.includes("--json") && !argv.includes("--quiet"))
12138
+ (options.stdout ?? process.stdout).write(
12139
+ `${JSON.stringify({ ok: false, error: { code: "internal_error", message } })}
12140
+ `
12141
+ );
12142
+ return 70;
12143
+ }
12144
+ }
10602
12145
 
10603
12146
  // src/cli.ts
12147
+ markNpmEntry();
10604
12148
  process.exitCode = await runToolCli(process.argv.slice(2), affinityCliOptions);