@ghostpaw/affinity 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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.1";
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,28 +525,260 @@ 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)}`;
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)}`;
370
782
  if (depth === 0) {
371
783
  db.exec("BEGIN IMMEDIATE");
372
784
  } else {
@@ -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,7 +2460,10 @@ 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)] }
2463
+ {
2464
+ entities: [toContactEntityRef(detail2.contact)],
2465
+ warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
2466
+ }
1962
2467
  );
1963
2468
  }
1964
2469
  const link = resolveLinkLocator(db, input.link, "link");
@@ -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.",
@@ -2493,9 +3000,17 @@ var init_unset_attribute = __esm({
2493
3000
  });
2494
3001
 
2495
3002
  // src/tools/tool_mutation.ts
2496
- function mutationToolResult(action, receipt, entitySummary) {
3003
+ function mutationToolResult(action, receipt, entitySummary, fields) {
2497
3004
  const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
2498
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
+ ),
2499
3014
  action,
2500
3015
  primary: receipt.primary,
2501
3016
  created: receipt.created,
@@ -2589,7 +3104,23 @@ function resolveAttributeTarget(db, target) {
2589
3104
  }
2590
3105
  return { kind: "ok", target: { kind: "link", id: link.value.id } };
2591
3106
  }
2592
- 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
+ );
2593
3124
  return withToolHandling(() => {
2594
3125
  const resolved = resolveAttributeTarget(db, input.target);
2595
3126
  if (resolved.kind === "error") {
@@ -2627,6 +3158,8 @@ var init_manage_attribute_tool = __esm({
2627
3158
  init_tool_mutation();
2628
3159
  init_tool_names();
2629
3160
  init_tool_resolvers();
3161
+ init_tool_types();
3162
+ init_validate_tool_input();
2630
3163
  manageAttributeTool = defineAffinityTool({
2631
3164
  name: manageAttributeToolName,
2632
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.",
@@ -3259,7 +3792,7 @@ function clamp3(value, min, max) {
3259
3792
  }
3260
3793
  function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3261
3794
  const dayStart = utcStartOfDayMs(occurredAt);
3262
- const dayEnd = dayStart + DAY_MS2;
3795
+ const dayEnd = dayStart + DAY_MS3;
3263
3796
  const row = db.prepare(
3264
3797
  `SELECT COUNT(*) AS c
3265
3798
  FROM link_event_effects le
@@ -3272,7 +3805,7 @@ function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
3272
3805
  return Number(row.c ?? 0);
3273
3806
  }
3274
3807
  function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3275
- const weekStart = occurredAt - 7 * DAY_MS2;
3808
+ const weekStart = occurredAt - 7 * DAY_MS3;
3276
3809
  const row = db.prepare(
3277
3810
  `SELECT SUM(base_weight) AS total
3278
3811
  FROM link_event_effects
@@ -3284,7 +3817,7 @@ function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
3284
3817
  return Number(row.total ?? 0);
3285
3818
  }
3286
3819
  function countRepairContext(db, linkId, occurredAt) {
3287
- const thirtyDaysAgo = occurredAt - 30 * DAY_MS2;
3820
+ const thirtyDaysAgo = occurredAt - 30 * DAY_MS3;
3288
3821
  const hasPriorDamage = db.prepare(
3289
3822
  `SELECT 1 FROM link_event_effects
3290
3823
  WHERE link_id = ? AND trust_delta < 0
@@ -3359,8 +3892,8 @@ function computePreferenceMatch(db, linkId, contactId, eventType, provenance) {
3359
3892
  return preferenceMatch(liked, disliked);
3360
3893
  }
3361
3894
  function computeDateSalienceMultiplier(db, contactId, occurredAt, significance) {
3362
- const windowBefore = 7 * DAY_MS2;
3363
- const windowAfter = 2 * DAY_MS2;
3895
+ const windowBefore = 7 * DAY_MS3;
3896
+ const windowAfter = 2 * DAY_MS3;
3364
3897
  const row = db.prepare(
3365
3898
  `SELECT 1 FROM upcoming_occurrences uo
3366
3899
  JOIN events e ON e.id = uo.event_id
@@ -3527,9 +4060,9 @@ function applyEventEffectToLink(db, params) {
3527
4060
  ).get(params.linkId);
3528
4061
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
3529
4062
  1,
3530
- Math.round((params.occurredAt - latest.occurred_at) / DAY_MS2)
4063
+ Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
3531
4064
  );
3532
- const cadenceAfter = nextCadenceDays({
4065
+ const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
3533
4066
  kind: linkKind,
3534
4067
  currentCadenceDays: cadenceBefore,
3535
4068
  gapDays
@@ -3592,6 +4125,11 @@ function applyEventEffectToLink(db, params) {
3592
4125
  params.now,
3593
4126
  params.now
3594
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
+ }
3595
4133
  refreshLinkRollup(db, params.linkId, params.now);
3596
4134
  return {
3597
4135
  linkId: params.linkId,
@@ -3615,7 +4153,7 @@ function applyEventEffectToLink(db, params) {
3615
4153
  momentKind
3616
4154
  };
3617
4155
  }
3618
- var DAY_MS2;
4156
+ var DAY_MS3;
3619
4157
  var init_effects = __esm({
3620
4158
  "src/links/effects.ts"() {
3621
4159
  init_calendar();
@@ -3625,7 +4163,7 @@ var init_effects = __esm({
3625
4163
  init_queries3();
3626
4164
  init_rollups2();
3627
4165
  init_validators2();
3628
- DAY_MS2 = 864e5;
4166
+ DAY_MS3 = 864e5;
3629
4167
  }
3630
4168
  });
3631
4169
 
@@ -4006,12 +4544,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4006
4544
  if (row.type !== "promise" && row.type !== "agreement") {
4007
4545
  throw new AffinityInvariantError("not a commitment event");
4008
4546
  }
4009
- 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);
4010
4550
  if (!open) {
4011
4551
  throw new AffinityNotFoundError("open commitment not found");
4012
4552
  }
4013
4553
  if (open.resolved_at !== null) {
4014
- throw new AffinityStateError("commitment already resolved");
4554
+ throw new AffinityConflictError(
4555
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4556
+ );
4015
4557
  }
4016
4558
  const now = resolveNow(options?.now);
4017
4559
  const originalParticipants = loadEventParticipantViews(
@@ -4024,7 +4566,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4024
4566
  WHERE event_id = ? AND resolved_at IS NULL`
4025
4567
  ).run(resolution, now, now, commitmentEventId);
4026
4568
  if (Number(result.changes ?? 0) === 0) {
4027
- throw new AffinityStateError("commitment already resolved");
4569
+ throw new AffinityConflictError(
4570
+ `Commitment #${commitmentEventId} is already resolved (${open.resolution})`
4571
+ );
4028
4572
  }
4029
4573
  const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
4030
4574
  const created = [];
@@ -4094,9 +4638,9 @@ var init_resolve_commitment = __esm({
4094
4638
  init_queries2();
4095
4639
  init_receipts2();
4096
4640
  init_validators();
4641
+ init_affinity_conflict_error();
4097
4642
  init_affinity_invariant_error();
4098
4643
  init_affinity_not_found_error();
4099
- init_affinity_state_error();
4100
4644
  init_affinity_validation_error();
4101
4645
  init_resolve_now();
4102
4646
  init_with_transaction();
@@ -4129,9 +4673,25 @@ function ensureOwnerParticipates(db, participants) {
4129
4673
  function prepareParticipants(db, raw) {
4130
4674
  return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
4131
4675
  }
4132
- 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
+ );
4133
4686
  return withToolHandling(() => {
4134
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
+ );
4135
4695
  const patched = {
4136
4696
  ...input.input,
4137
4697
  participants: prepareParticipants(
@@ -4167,6 +4727,8 @@ var init_manage_commitment_tool = __esm({
4167
4727
  init_tool_mutation();
4168
4728
  init_tool_names();
4169
4729
  init_tool_resolvers();
4730
+ init_tool_types();
4731
+ init_validate_tool_input();
4170
4732
  manageCommitmentTool = defineAffinityTool({
4171
4733
  name: manageCommitmentToolName,
4172
4734
  description: "Record or resolve one commitment such as a promise or agreement. The owner contact is auto-injected into participants if missing.",
@@ -4208,7 +4770,7 @@ var init_manage_commitment_tool = __esm({
4208
4770
  role: stringSchema("Participant role."),
4209
4771
  directionality: stringSchema("Optional directionality.")
4210
4772
  },
4211
- ["contactId", "role"]
4773
+ ["role"]
4212
4774
  ),
4213
4775
  "Participant list. Must be non-empty and include the owner contact."
4214
4776
  ),
@@ -4257,6 +4819,163 @@ var init_manage_commitment_tool = __esm({
4257
4819
  }
4258
4820
  });
4259
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
+
4260
4979
  // src/contacts/receipts.ts
4261
4980
  function buildContactMutationReceipt(primary, partial) {
4262
4981
  const normalized = normalizeMutationReceiptPartial(partial);
@@ -4288,73 +5007,6 @@ var init_receipts4 = __esm({
4288
5007
  }
4289
5008
  });
4290
5009
 
4291
- // src/contacts/create_contact.ts
4292
- function createContact(db, input) {
4293
- return withTransaction(db, () => {
4294
- const name = input.name.trim();
4295
- if (name.length === 0) {
4296
- throw new AffinityValidationError("name must be non-empty");
4297
- }
4298
- if (!KINDS.has(input.kind)) {
4299
- throw new AffinityValidationError("invalid contact kind");
4300
- }
4301
- if (input.isOwner !== void 0) {
4302
- throw new AffinityValidationError(
4303
- "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
4304
- );
4305
- }
4306
- const now = resolveNow(input.now);
4307
- if (input.bootstrapOwner) {
4308
- const existing = db.prepare(
4309
- "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
4310
- ).get();
4311
- if (existing !== void 0) {
4312
- throw new AffinityConflictError(
4313
- `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
4314
- );
4315
- }
4316
- }
4317
- const isOwner = input.bootstrapOwner ? 1 : 0;
4318
- const result = db.prepare(
4319
- `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
4320
- VALUES (?, ?, 'active', ?, ?, ?)`
4321
- ).run(name, input.kind, isOwner, now, now);
4322
- const id = Number(result.lastInsertRowid);
4323
- const row = getContactRowById(db, id);
4324
- if (!row) {
4325
- throw new AffinityInvariantError("inserted contact row not found");
4326
- }
4327
- refreshContactRollup(db, id, now);
4328
- const primary = mapContactRowToContactListItem(row);
4329
- return buildContactMutationReceipt(primary, {
4330
- created: [{ kind: "contact", id }]
4331
- });
4332
- });
4333
- }
4334
- var KINDS;
4335
- var init_create_contact = __esm({
4336
- "src/contacts/create_contact.ts"() {
4337
- init_mappers4();
4338
- init_queries();
4339
- init_receipts4();
4340
- init_rollups();
4341
- init_affinity_conflict_error();
4342
- init_affinity_invariant_error();
4343
- init_affinity_validation_error();
4344
- init_resolve_now();
4345
- init_with_transaction();
4346
- KINDS = /* @__PURE__ */ new Set([
4347
- "human",
4348
- "group",
4349
- "company",
4350
- "team",
4351
- "pet",
4352
- "service",
4353
- "other"
4354
- ]);
4355
- }
4356
- });
4357
-
4358
5010
  // src/contacts/revise_contact.ts
4359
5011
  function reviseContact(db, contactId, patch) {
4360
5012
  return withTransaction(db, () => {
@@ -4410,7 +5062,9 @@ function assertValidLifecycleTransition(from, to) {
4410
5062
  throw new AffinityStateError("merged contact is read-only");
4411
5063
  }
4412
5064
  if (to === "merged") {
4413
- 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
+ );
4414
5068
  }
4415
5069
  if (from === to) {
4416
5070
  return;
@@ -4441,44 +5095,362 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
4441
5095
  if (!STATES2.has(lifecycleState)) {
4442
5096
  throw new AffinityValidationError("invalid lifecycle state");
4443
5097
  }
4444
- const row = getContactRowById(db, contactId);
5098
+ const row = getContactRowById(db, contactId);
5099
+ if (!row) {
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);
4445
5377
  if (!row) {
4446
- throw new AffinityNotFoundError("contact not found");
4447
- }
4448
- const from = row.lifecycle_state;
4449
- assertValidLifecycleTransition(from, lifecycleState);
4450
- const now = resolveNow(options?.now);
4451
- if (from === lifecycleState) {
4452
- return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
4453
- updated: []
4454
- });
5378
+ throw new AffinityInvariantError("inserted identity not found");
4455
5379
  }
4456
- db.prepare(
4457
- "UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
4458
- ).run(lifecycleState, now, contactId);
4459
5380
  refreshContactRollup(db, contactId, now);
4460
- const next = getContactRowById(db, contactId);
4461
- if (!next) {
4462
- throw new AffinityNotFoundError("contact not found");
4463
- }
4464
- return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
4465
- 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 }]
4466
5386
  });
4467
5387
  });
4468
5388
  }
4469
- var STATES2;
4470
- var init_set_contact_lifecycle = __esm({
4471
- "src/contacts/set_contact_lifecycle.ts"() {
4472
- init_mappers4();
5389
+ var init_add_identity = __esm({
5390
+ "src/identities/add_identity.ts"() {
4473
5391
  init_queries();
4474
- init_receipts4();
4475
5392
  init_rollups();
4476
- init_validators4();
5393
+ init_mappers2();
5394
+ init_normalize();
5395
+ init_queries5();
5396
+ init_receipts5();
5397
+ init_validators5();
5398
+ init_affinity_invariant_error();
4477
5399
  init_affinity_not_found_error();
5400
+ init_affinity_state_error();
4478
5401
  init_affinity_validation_error();
4479
5402
  init_resolve_now();
4480
5403
  init_with_transaction();
4481
- 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();
4482
5454
  }
4483
5455
  });
4484
5456
 
@@ -4500,24 +5472,76 @@ function contactLocatorSchema3(description) {
4500
5472
  description
4501
5473
  };
4502
5474
  }
4503
- 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
+ );
4504
5485
  return withToolHandling(() => {
4505
- if (input.action === "create") {
4506
- return mutationToolResult(
4507
- "create",
4508
- createContact(db, input.input),
4509
- "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)"
4510
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);
4511
5515
  }
4512
- const contact = resolveContactLocator(db, input.contact, "contact");
5516
+ const contact = resolveContactLocator(
5517
+ db,
5518
+ input.contact,
5519
+ "contact",
5520
+ input.action === "delete"
5521
+ );
4513
5522
  if (!contact.ok) {
4514
5523
  return contact.result;
4515
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
+ }
4516
5539
  if (input.action === "revise") {
4517
5540
  return mutationToolResult(
4518
5541
  "revise",
4519
5542
  reviseContact(db, contact.value.id, input.patch),
4520
- "contact"
5543
+ "contact",
5544
+ Object.keys(input.patch)
4521
5545
  );
4522
5546
  }
4523
5547
  return mutationToolResult(
@@ -4535,16 +5559,22 @@ function manageContactToolHandler(db, input) {
4535
5559
  var manageContactTool;
4536
5560
  var init_manage_contact_tool = __esm({
4537
5561
  "src/tools/manage_contact_tool.ts"() {
4538
- init_create_contact();
5562
+ init_delete_contact();
5563
+ init_export_affinity();
5564
+ init_export_contact();
4539
5565
  init_revise_contact();
4540
5566
  init_set_contact_lifecycle();
5567
+ init_create_contact_tool_result();
4541
5568
  init_tool_metadata();
4542
5569
  init_tool_mutation();
4543
5570
  init_tool_names();
4544
5571
  init_tool_resolvers();
5572
+ init_tool_types();
5573
+ init_tool_types();
5574
+ init_validate_tool_input();
4545
5575
  manageContactTool = defineAffinityTool({
4546
5576
  name: manageContactToolName,
4547
- 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.",
4548
5578
  whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
4549
5579
  whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
4550
5580
  sideEffects: "writes_state",
@@ -4565,10 +5595,25 @@ var init_manage_contact_tool = __esm({
4565
5595
  action: enumSchema("Operation to perform.", [
4566
5596
  "create",
4567
5597
  "revise",
4568
- "set_lifecycle"
5598
+ "set_lifecycle",
5599
+ "export",
5600
+ "delete"
4569
5601
  ]),
4570
5602
  input: objectSchema(
4571
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
+ ),
4572
5617
  name: stringSchema("Contact name."),
4573
5618
  kind: enumSchema("Contact kind.", [
4574
5619
  "human",
@@ -4587,6 +5632,7 @@ var init_manage_contact_tool = __esm({
4587
5632
  ["name", "kind"],
4588
5633
  "New contact payload. Required when action=create."
4589
5634
  ),
5635
+ confirm: booleanSchema("Required true for permanent deletion."),
4590
5636
  contact: contactLocatorSchema3(
4591
5637
  "Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
4592
5638
  ),
@@ -4856,9 +5902,32 @@ function resolveDateAnchorTarget(db, target) {
4856
5902
  }
4857
5903
  return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
4858
5904
  }
4859
- 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
+ );
4860
5922
  return withToolHandling(() => {
4861
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
+ );
4862
5931
  const resolved = resolveDateAnchorTarget(db, input.input.target);
4863
5932
  if (resolved.kind === "error") {
4864
5933
  return resolved.result;
@@ -4873,7 +5942,8 @@ function manageDateAnchorToolHandler(db, input) {
4873
5942
  return mutationToolResult(
4874
5943
  "revise",
4875
5944
  reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
4876
- "date anchor"
5945
+ "date anchor",
5946
+ Object.keys(input.patch)
4877
5947
  );
4878
5948
  }
4879
5949
  return mutationToolResult(
@@ -4893,6 +5963,8 @@ var init_manage_date_anchor_tool = __esm({
4893
5963
  init_tool_mutation();
4894
5964
  init_tool_names();
4895
5965
  init_tool_resolvers();
5966
+ init_tool_types();
5967
+ init_validate_tool_input();
4896
5968
  manageDateAnchorTool = defineAffinityTool({
4897
5969
  name: manageDateAnchorToolName,
4898
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.",
@@ -5006,7 +6078,7 @@ function buildDismissalMutationReceipt(primary, partial) {
5006
6078
  derivedEffects: normalized.derivedEffects
5007
6079
  };
5008
6080
  }
5009
- var init_receipts5 = __esm({
6081
+ var init_receipts6 = __esm({
5010
6082
  "src/merges/receipts.ts"() {
5011
6083
  init_normalize_mutation_receipt();
5012
6084
  }
@@ -5068,7 +6140,7 @@ var init_dismiss_duplicate = __esm({
5068
6140
  init_affinity_validation_error();
5069
6141
  init_resolve_now();
5070
6142
  init_with_transaction();
5071
- init_receipts5();
6143
+ init_receipts6();
5072
6144
  }
5073
6145
  });
5074
6146
 
@@ -5107,7 +6179,7 @@ var init_undismiss_duplicate = __esm({
5107
6179
  init_affinity_not_found_error();
5108
6180
  init_affinity_validation_error();
5109
6181
  init_with_transaction();
5110
- init_receipts5();
6182
+ init_receipts6();
5111
6183
  }
5112
6184
  });
5113
6185
 
@@ -5129,7 +6201,19 @@ function contactLocatorSchema5(description) {
5129
6201
  description
5130
6202
  };
5131
6203
  }
5132
- 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
+ );
5133
6217
  return withToolHandling(() => {
5134
6218
  const left = resolveContactLocator(db, input.left, "left");
5135
6219
  if (!left.ok) {
@@ -5139,6 +6223,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
5139
6223
  if (!right.ok) {
5140
6224
  return right.result;
5141
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;
5142
6231
  if (input.action === "dismiss") {
5143
6232
  const receipt2 = dismissDuplicate(
5144
6233
  db,
@@ -5148,13 +6237,35 @@ function manageDuplicateDismissalToolHandler(db, input) {
5148
6237
  );
5149
6238
  return toolSuccess(
5150
6239
  `Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
5151
- { 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
+ }
5152
6252
  );
5153
6253
  }
5154
6254
  const receipt = undismissDuplicate(db, left.value.id, right.value.id);
5155
6255
  return toolSuccess(
5156
6256
  `Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
5157
- { 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
+ }
5158
6269
  );
5159
6270
  }, "Duplicate dismissal failed.");
5160
6271
  }
@@ -5167,9 +6278,11 @@ var init_manage_duplicate_dismissal_tool = __esm({
5167
6278
  init_tool_names();
5168
6279
  init_tool_resolvers();
5169
6280
  init_tool_types();
6281
+ init_tool_types();
6282
+ init_validate_tool_input();
5170
6283
  manageDuplicateDismissalTool = defineAffinityTool({
5171
6284
  name: manageDuplicateDismissalToolName,
5172
- 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.",
5173
6286
  whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
5174
6287
  whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
5175
6288
  sideEffects: "writes_state",
@@ -5187,140 +6300,18 @@ var init_manage_duplicate_dismissal_tool = __esm({
5187
6300
  {
5188
6301
  action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
5189
6302
  left: contactLocatorSchema5(
5190
- "Left contact. Provide contactId or identity."
5191
- ),
5192
- right: contactLocatorSchema5(
5193
- "Right contact. Provide contactId or identity."
5194
- ),
5195
- reason: nullableStringSchema("Optional reason for the dismissal.")
5196
- },
5197
- ["action", "left", "right"],
5198
- "Dismiss or un-dismiss a duplicate candidate pair."
5199
- ),
5200
- handler: manageDuplicateDismissalToolHandler
5201
- });
5202
- }
5203
- });
5204
-
5205
- // src/identities/queries.ts
5206
- function getIdentityRowById(db, id) {
5207
- const row = db.prepare(
5208
- `SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
5209
- created_at, updated_at, removed_at
5210
- FROM identities
5211
- WHERE id = ? AND removed_at IS NULL`
5212
- ).get(id);
5213
- if (row === void 0) {
5214
- return null;
5215
- }
5216
- return row;
5217
- }
5218
- var init_queries5 = __esm({
5219
- "src/identities/queries.ts"() {
5220
- }
5221
- });
5222
-
5223
- // src/identities/receipts.ts
5224
- function buildIdentityMutationReceipt(primary, partial) {
5225
- const normalized = normalizeMutationReceiptPartial(partial);
5226
- return {
5227
- primary,
5228
- created: normalized.created,
5229
- updated: normalized.updated,
5230
- archived: normalized.archived,
5231
- removed: normalized.removed,
5232
- affectedLinks: normalized.affectedLinks,
5233
- derivedEffects: normalized.derivedEffects
5234
- };
5235
- }
5236
- var init_receipts6 = __esm({
5237
- "src/identities/receipts.ts"() {
5238
- init_normalize_mutation_receipt();
5239
- }
5240
- });
5241
-
5242
- // src/identities/validators.ts
5243
- function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
5244
- const row = excludeIdentityId === void 0 ? db.prepare(
5245
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
5246
- ).get(normalizedKey) : db.prepare(
5247
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
5248
- ).get(normalizedKey, excludeIdentityId);
5249
- if (row !== void 0) {
5250
- throw new AffinityConflictError(
5251
- "another contact already owns this normalized identity"
5252
- );
5253
- }
5254
- }
5255
- var init_validators5 = __esm({
5256
- "src/identities/validators.ts"() {
5257
- init_affinity_conflict_error();
5258
- }
5259
- });
5260
-
5261
- // src/identities/add_identity.ts
5262
- function addIdentity(db, contactId, input) {
5263
- return withTransaction(db, () => {
5264
- const contact = getContactRowById(db, contactId);
5265
- if (!contact) {
5266
- throw new AffinityNotFoundError("contact not found");
5267
- }
5268
- if (contact.lifecycle_state === "merged") {
5269
- throw new AffinityStateError("merged contact is read-only");
5270
- }
5271
- const type = input.type.trim();
5272
- const value = input.value.trim();
5273
- if (type.length === 0 || value.length === 0) {
5274
- throw new AffinityValidationError("type and value must be non-empty");
5275
- }
5276
- const nk = normalizeIdentityKey(type, value);
5277
- assertNoIdentityCollision(db, nk);
5278
- const now = resolveNow(input.now);
5279
- const verified = input.verified ? 1 : 0;
5280
- const verifiedAt = verified === 1 ? now : null;
5281
- const result = db.prepare(
5282
- `INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
5283
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
5284
- ).run(
5285
- contactId,
5286
- type,
5287
- value,
5288
- input.label ?? null,
5289
- nk,
5290
- verified,
5291
- verifiedAt,
5292
- now,
5293
- now
5294
- );
5295
- const id = Number(result.lastInsertRowid);
5296
- const row = getIdentityRowById(db, id);
5297
- if (!row) {
5298
- throw new AffinityInvariantError("inserted identity not found");
5299
- }
5300
- refreshContactRollup(db, contactId, now);
5301
- db.prepare(
5302
- "DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
5303
- ).run(contactId, contactId);
5304
- return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
5305
- created: [{ kind: "identity", id }]
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
5306
6314
  });
5307
- });
5308
- }
5309
- var init_add_identity = __esm({
5310
- "src/identities/add_identity.ts"() {
5311
- init_queries();
5312
- init_rollups();
5313
- init_mappers2();
5314
- init_normalize();
5315
- init_queries5();
5316
- init_receipts6();
5317
- init_validators5();
5318
- init_affinity_invariant_error();
5319
- init_affinity_not_found_error();
5320
- init_affinity_state_error();
5321
- init_affinity_validation_error();
5322
- init_resolve_now();
5323
- init_with_transaction();
5324
6315
  }
5325
6316
  });
5326
6317
 
@@ -5355,7 +6346,7 @@ var init_remove_identity = __esm({
5355
6346
  init_rollups();
5356
6347
  init_mappers2();
5357
6348
  init_queries5();
5358
- init_receipts6();
6349
+ init_receipts5();
5359
6350
  init_affinity_not_found_error();
5360
6351
  init_affinity_state_error();
5361
6352
  init_resolve_now();
@@ -5388,6 +6379,7 @@ function reviseIdentity(db, identityId, patch) {
5388
6379
  if (nextType.length === 0 || nextValue.length === 0) {
5389
6380
  throw new AffinityValidationError("type and value must be non-empty");
5390
6381
  }
6382
+ validateIdentityInput(nextType, nextValue);
5391
6383
  const nextLabel = patch.label !== void 0 ? patch.label : row.label;
5392
6384
  const nkNext = normalizeIdentityKey(nextType, nextValue);
5393
6385
  if (nkNext !== row.normalized_key) {
@@ -5413,13 +6405,14 @@ var init_revise_identity = __esm({
5413
6405
  init_mappers2();
5414
6406
  init_normalize();
5415
6407
  init_queries5();
5416
- init_receipts6();
6408
+ init_receipts5();
5417
6409
  init_validators5();
5418
6410
  init_affinity_not_found_error();
5419
6411
  init_affinity_state_error();
5420
6412
  init_affinity_validation_error();
5421
6413
  init_resolve_now();
5422
6414
  init_with_transaction();
6415
+ init_validate_identity_input();
5423
6416
  }
5424
6417
  });
5425
6418
 
@@ -5453,7 +6446,7 @@ var init_verify_identity = __esm({
5453
6446
  init_queries();
5454
6447
  init_mappers2();
5455
6448
  init_queries5();
5456
- init_receipts6();
6449
+ init_receipts5();
5457
6450
  init_affinity_not_found_error();
5458
6451
  init_affinity_state_error();
5459
6452
  init_resolve_now();
@@ -5469,7 +6462,9 @@ function contactLocatorSchema6(description) {
5469
6462
  contactId: integerSchema("Exact contact id."),
5470
6463
  identity: objectSchema(
5471
6464
  {
5472
- type: stringSchema("Identity type."),
6465
+ type: stringSchema(
6466
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6467
+ ),
5473
6468
  value: stringSchema("Identity value.")
5474
6469
  },
5475
6470
  ["type", "value"],
@@ -5479,7 +6474,23 @@ function contactLocatorSchema6(description) {
5479
6474
  description
5480
6475
  };
5481
6476
  }
5482
- 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
+ );
5483
6494
  return withToolHandling(() => {
5484
6495
  switch (input.action) {
5485
6496
  case "add": {
@@ -5497,7 +6508,8 @@ function manageIdentityToolHandler(db, input) {
5497
6508
  return mutationToolResult(
5498
6509
  "revise",
5499
6510
  reviseIdentity(db, input.identityId, input.patch),
5500
- "identity"
6511
+ "identity",
6512
+ Object.keys(input.patch)
5501
6513
  );
5502
6514
  case "verify":
5503
6515
  return mutationToolResult(
@@ -5520,11 +6532,14 @@ var init_manage_identity_tool = __esm({
5520
6532
  init_add_identity();
5521
6533
  init_remove_identity();
5522
6534
  init_revise_identity();
6535
+ init_validate_identity_input();
5523
6536
  init_verify_identity();
5524
6537
  init_tool_metadata();
5525
6538
  init_tool_mutation();
5526
6539
  init_tool_names();
5527
6540
  init_tool_resolvers();
6541
+ init_tool_types();
6542
+ init_validate_tool_input();
5528
6543
  manageIdentityTool = defineAffinityTool({
5529
6544
  name: manageIdentityToolName,
5530
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.",
@@ -5557,7 +6572,9 @@ var init_manage_identity_tool = __esm({
5557
6572
  ),
5558
6573
  input: objectSchema(
5559
6574
  {
5560
- type: stringSchema("Identity type."),
6575
+ type: stringSchema(
6576
+ `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
6577
+ ),
5561
6578
  value: stringSchema("Identity value."),
5562
6579
  label: stringSchema("Optional label."),
5563
6580
  verified: booleanSchema("Whether the identity starts verified."),
@@ -5571,7 +6588,9 @@ var init_manage_identity_tool = __esm({
5571
6588
  ),
5572
6589
  patch: objectSchema(
5573
6590
  {
5574
- 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
+ ),
5575
6594
  value: stringSchema("Optional identity value."),
5576
6595
  label: stringSchema("Optional label.")
5577
6596
  },
@@ -5842,7 +6861,19 @@ function linkLocatorSchema4(description) {
5842
6861
  description
5843
6862
  };
5844
6863
  }
5845
- 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
+ );
5846
6877
  return withToolHandling(() => {
5847
6878
  switch (input.action) {
5848
6879
  case "seed_social_link": {
@@ -5941,6 +6972,8 @@ var init_manage_relationship_tool = __esm({
5941
6972
  init_tool_mutation();
5942
6973
  init_tool_names();
5943
6974
  init_tool_resolvers();
6975
+ init_tool_types();
6976
+ init_validate_tool_input();
5944
6977
  manageRelationshipTool = defineAffinityTool({
5945
6978
  name: manageRelationshipToolName,
5946
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.",
@@ -6246,7 +7279,16 @@ function contactLocatorSchema8(description) {
6246
7279
  description
6247
7280
  };
6248
7281
  }
6249
- 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
+ );
6250
7292
  return withToolHandling(() => {
6251
7293
  const winner = resolveContactLocator(db, input.winner, "winner");
6252
7294
  if (!winner.ok) {
@@ -6276,6 +7318,8 @@ var init_merge_contacts_tool = __esm({
6276
7318
  init_tool_mutation();
6277
7319
  init_tool_names();
6278
7320
  init_tool_resolvers();
7321
+ init_tool_types();
7322
+ init_validate_tool_input();
6279
7323
  mergeContactsTool = defineAffinityTool({
6280
7324
  name: mergeContactsToolName,
6281
7325
  description: "Merge one loser contact into one winner contact using explicit contact locators.",
@@ -6535,7 +7579,16 @@ function prepareParticipants2(db, raw, injectOwner) {
6535
7579
  const normalized = raw.map(normalizeParticipant2);
6536
7580
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
6537
7581
  }
6538
- 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
+ );
6539
7592
  return withToolHandling(() => {
6540
7593
  const patched = {
6541
7594
  ...input.input,
@@ -6585,6 +7638,8 @@ var init_record_event_tool = __esm({
6585
7638
  init_tool_mutation();
6586
7639
  init_tool_names();
6587
7640
  init_tool_resolvers();
7641
+ init_tool_types();
7642
+ init_validate_tool_input();
6588
7643
  recordEventTool = defineAffinityTool({
6589
7644
  name: recordEventToolName,
6590
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.",
@@ -6645,7 +7700,7 @@ var init_record_event_tool = __esm({
6645
7700
  "observed"
6646
7701
  ])
6647
7702
  },
6648
- ["contactId", "role"]
7703
+ ["role"]
6649
7704
  ),
6650
7705
  "Participant list. Must be non-empty and include the owner contact."
6651
7706
  ),
@@ -6663,6 +7718,27 @@ var init_record_event_tool = __esm({
6663
7718
  }
6664
7719
  });
6665
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
+
6666
7742
  // src/contacts/list_contacts.ts
6667
7743
  function listContacts(db, filters, options) {
6668
7744
  assertDefaultOrdering("listContacts", options);
@@ -6686,6 +7762,9 @@ function listContacts(db, filters, options) {
6686
7762
  if (filters?.includeDormant === false || options?.includeDormant === false) {
6687
7763
  clauses.push("lifecycle_state != 'dormant'");
6688
7764
  }
7765
+ const attributeFilters = contactAttributeClauses(filters);
7766
+ clauses.push(...attributeFilters.clauses);
7767
+ params.push(...attributeFilters.params);
6689
7768
  const where = clauses.join(" AND ");
6690
7769
  const rows = db.prepare(
6691
7770
  `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
@@ -6714,6 +7793,7 @@ var init_list_contacts = __esm({
6714
7793
  "src/contacts/list_contacts.ts"() {
6715
7794
  init_assert_default_ordering();
6716
7795
  init_resolve_list_limit_offset();
7796
+ init_contact_attribute_clauses();
6717
7797
  init_mappers4();
6718
7798
  }
6719
7799
  });
@@ -6941,131 +8021,6 @@ var init_list_upcoming_dates = __esm({
6941
8021
  }
6942
8022
  });
6943
8023
 
6944
- // src/events/get_contact_journal.ts
6945
- function getContactJournal(db, contactId, options) {
6946
- assertDefaultOrdering("getContactJournal", options);
6947
- const { limit, offset } = resolveListLimitOffset(options);
6948
- const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
6949
- const params = [contactId];
6950
- if (options?.since !== void 0) {
6951
- clauses.push("e.occurred_at >= ?");
6952
- params.push(options.since);
6953
- }
6954
- if (options?.until !== void 0) {
6955
- clauses.push("e.occurred_at <= ?");
6956
- params.push(options.until);
6957
- }
6958
- if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
6959
- const ph = options.eventTypes.map(() => "?").join(", ");
6960
- clauses.push(`e.type IN (${ph})`);
6961
- params.push(...options.eventTypes);
6962
- }
6963
- const where = clauses.join(" AND ");
6964
- const rows = db.prepare(
6965
- `SELECT e.id FROM events e
6966
- INNER JOIN event_participants ep ON ep.event_id = e.id
6967
- WHERE ${where}
6968
- ORDER BY e.occurred_at DESC, e.id DESC
6969
- LIMIT ? OFFSET ?`
6970
- ).all(...params, limit, offset);
6971
- return batchLoadEventRecords(
6972
- db,
6973
- rows.map((r) => r.id)
6974
- );
6975
- }
6976
- var init_get_contact_journal = __esm({
6977
- "src/events/get_contact_journal.ts"() {
6978
- init_assert_default_ordering();
6979
- init_resolve_list_limit_offset();
6980
- init_loaders();
6981
- }
6982
- });
6983
-
6984
- // src/events/list_open_commitments.ts
6985
- function mapResolution(resolvedAt, resolution) {
6986
- if (resolvedAt === null) {
6987
- return "open";
6988
- }
6989
- if (resolution === "cancelled") {
6990
- return "cancelled";
6991
- }
6992
- return "resolved";
6993
- }
6994
- function listOpenCommitments(db, filters, options) {
6995
- assertDefaultOrdering("listOpenCommitments", options);
6996
- const { limit, offset } = resolveListLimitOffset(options);
6997
- const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
6998
- const params = [];
6999
- if (options?.since !== void 0) {
7000
- clauses.push("e.occurred_at >= ?");
7001
- params.push(options.since);
7002
- }
7003
- if (options?.until !== void 0) {
7004
- clauses.push("e.occurred_at <= ?");
7005
- params.push(options.until);
7006
- }
7007
- if (filters?.commitmentType !== void 0) {
7008
- if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
7009
- throw new AffinityValidationError("invalid commitmentType filter");
7010
- }
7011
- clauses.push("oc.commitment_type = ?");
7012
- params.push(filters.commitmentType);
7013
- }
7014
- if (filters?.contactId !== void 0) {
7015
- clauses.push(
7016
- "EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
7017
- );
7018
- params.push(filters.contactId);
7019
- }
7020
- if (filters?.linkId !== void 0) {
7021
- clauses.push(
7022
- "EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
7023
- );
7024
- params.push(filters.linkId);
7025
- }
7026
- if (filters?.horizonDays !== void 0) {
7027
- if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
7028
- throw new AffinityValidationError(
7029
- "horizonDays must be a non-negative integer"
7030
- );
7031
- }
7032
- const anchor = options?.since ?? Date.now();
7033
- clauses.push("oc.due_at IS NOT NULL");
7034
- clauses.push("oc.due_at <= ?");
7035
- params.push(anchor + filters.horizonDays * DAY_MS3);
7036
- }
7037
- const where = clauses.join(" AND ");
7038
- const rows = db.prepare(
7039
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
7040
- e.type, e.summary
7041
- FROM open_commitments oc
7042
- INNER JOIN events e ON e.id = oc.event_id
7043
- WHERE ${where}
7044
- ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
7045
- LIMIT ? OFFSET ?`
7046
- ).all(...params, limit, offset);
7047
- const eventIds = rows.map((r) => r.event_id);
7048
- const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
7049
- return rows.map((r) => ({
7050
- eventId: r.event_id,
7051
- type: r.type,
7052
- summary: r.summary,
7053
- participants: partsByEvent.get(r.event_id) ?? [],
7054
- dueAt: r.due_at,
7055
- resolutionState: mapResolution(r.resolved_at, r.resolution)
7056
- }));
7057
- }
7058
- var DAY_MS3;
7059
- var init_list_open_commitments = __esm({
7060
- "src/events/list_open_commitments.ts"() {
7061
- init_affinity_validation_error();
7062
- init_assert_default_ordering();
7063
- init_resolve_list_limit_offset();
7064
- init_loaders();
7065
- DAY_MS3 = 864e5;
7066
- }
7067
- });
7068
-
7069
8024
  // src/graph/get_affinity_chart.ts
7070
8025
  function getAffinityChart(db, options) {
7071
8026
  const contactClauses = [
@@ -7299,13 +8254,15 @@ function listRadar(db, filters, options) {
7299
8254
  if (ownerId === null) {
7300
8255
  return [];
7301
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})`;
7302
8259
  const { limit, offset } = resolveListLimitOffset(options);
7303
8260
  const clauses = [
7304
8261
  "l.removed_at IS NULL",
7305
8262
  "l.is_structural = 0",
7306
- "l.from_contact_id = ?"
8263
+ "(l.from_contact_id = ? OR l.to_contact_id = ?)"
7307
8264
  ];
7308
- const params = [ownerId];
8265
+ const params = [ownerId, ownerId];
7309
8266
  if (filters?.kind !== void 0) {
7310
8267
  clauses.push("l.kind = ?");
7311
8268
  params.push(filters.kind);
@@ -7327,6 +8284,7 @@ function listRadar(db, filters, options) {
7327
8284
  const rows = db.prepare(
7328
8285
  `SELECT
7329
8286
  l.id,
8287
+ ${overdue} AS overdue_commitment,
7330
8288
  l.from_contact_id,
7331
8289
  l.to_contact_id,
7332
8290
  l.trust,
@@ -7335,17 +8293,18 @@ function listRadar(db, filters, options) {
7335
8293
  lr.recency_score,
7336
8294
  lr.normalized_rank
7337
8295
  FROM links l
7338
- 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
7339
8297
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
7340
8298
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
7341
8299
  WHERE ${where}
7342
- ORDER BY COALESCE(lr.radar_score, 0) DESC
8300
+ ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
7343
8301
  LIMIT ? OFFSET ?`
7344
8302
  ).all(...params, limit, offset);
7345
8303
  return rows.map((row) => ({
7346
8304
  linkId: row.id,
7347
8305
  contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
7348
- 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,
7349
8308
  recencyScore: row.recency_score ?? 0,
7350
8309
  normalizedRank: row.normalized_rank ?? 0,
7351
8310
  trust: row.trust ?? 0,
@@ -7361,6 +8320,7 @@ var init_list_radar = __esm({
7361
8320
  init_queries();
7362
8321
  init_assert_default_ordering();
7363
8322
  init_resolve_list_limit_offset();
8323
+ init_resolve_now();
7364
8324
  }
7365
8325
  });
7366
8326
 
@@ -7414,6 +8374,60 @@ var init_list_dismissed_duplicates = __esm({
7414
8374
  }
7415
8375
  });
7416
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
+
7417
8431
  // src/tools/tool_summary.ts
7418
8432
  function summarizeCount(count, singular) {
7419
8433
  if (count === 0) {
@@ -7432,6 +8446,7 @@ var init_tool_summary = __esm({
7432
8446
  // src/tools/review_affinity_tool.ts
7433
8447
  function buildReadOptions(input) {
7434
8448
  return {
8449
+ ...input.now === void 0 ? {} : { now: input.now },
7435
8450
  ...input.limit === void 0 ? {} : { limit: input.limit },
7436
8451
  ...input.offset === void 0 ? {} : { offset: input.offset },
7437
8452
  ...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
@@ -7551,6 +8566,7 @@ function validateReviewInput(input) {
7551
8566
  }
7552
8567
  function duplicateCandidateItem(candidate) {
7553
8568
  return {
8569
+ ...candidate,
7554
8570
  kind: "contact",
7555
8571
  id: candidate.leftContactId,
7556
8572
  title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
@@ -7560,24 +8576,28 @@ function duplicateCandidateItem(candidate) {
7560
8576
  }
7561
8577
  function momentItem(moment) {
7562
8578
  return {
8579
+ ...moment,
8580
+ occurredAtIso: new Date(moment.occurredAt).toISOString(),
7563
8581
  kind: "event",
7564
8582
  id: moment.eventId,
7565
8583
  title: moment.summary,
7566
8584
  subtitle: moment.momentKind,
7567
- snippet: `link #${moment.linkId} at ${moment.occurredAt}`
8585
+ snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
7568
8586
  };
7569
8587
  }
7570
8588
  function commitmentItem(commitment) {
7571
8589
  return {
8590
+ ...commitment,
7572
8591
  kind: "event",
7573
8592
  id: commitment.eventId,
7574
8593
  title: commitment.summary,
7575
8594
  subtitle: commitment.type,
7576
- 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)}`
7577
8596
  };
7578
8597
  }
7579
8598
  function radarItem(radar) {
7580
8599
  return {
8600
+ ...radar,
7581
8601
  kind: "link",
7582
8602
  id: radar.linkId,
7583
8603
  title: `Link #${radar.linkId}`,
@@ -7587,29 +8607,34 @@ function radarItem(radar) {
7587
8607
  }
7588
8608
  function upcomingDateItem(date) {
7589
8609
  return {
8610
+ ...date,
8611
+ occursAt: date.occursOn,
8612
+ occursAtIso: new Date(date.occursOn).toISOString(),
7590
8613
  kind: date.targetRef.kind,
7591
8614
  id: date.targetRef.id,
7592
8615
  title: date.summary,
7593
8616
  subtitle: date.recurrenceKind,
7594
- snippet: `occurs ${date.occursOn}`
8617
+ snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
7595
8618
  };
7596
8619
  }
7597
8620
  function mergeHistoryItem(record) {
7598
8621
  return {
8622
+ ...record,
7599
8623
  kind: "contact",
7600
8624
  id: record.winnerContactId,
7601
8625
  title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
7602
- subtitle: `at ${record.mergedAt}`,
8626
+ subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
7603
8627
  snippet: record.reasonSummary ?? void 0
7604
8628
  };
7605
8629
  }
7606
8630
  function dismissedPairItem(record) {
7607
8631
  return {
8632
+ ...record,
7608
8633
  kind: "contact",
7609
8634
  id: record.leftContactId,
7610
8635
  title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
7611
8636
  subtitle: record.reason ?? "no reason given",
7612
- snippet: `dismissed at ${record.dismissedAt}`
8637
+ snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
7613
8638
  };
7614
8639
  }
7615
8640
  function compactFilters(input) {
@@ -7619,7 +8644,16 @@ function compactFilters(input) {
7619
8644
  )
7620
8645
  );
7621
8646
  }
7622
- 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
+ );
7623
8657
  return withToolHandling(() => {
7624
8658
  const invalid = validateReviewInput(input);
7625
8659
  if (invalid) {
@@ -7633,6 +8667,9 @@ function reviewAffinityToolHandler(db, input) {
7633
8667
  items = listContacts(
7634
8668
  db,
7635
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 },
7636
8673
  ...input.kind === void 0 ? {} : { kind: input.kind },
7637
8674
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
7638
8675
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -7663,7 +8700,7 @@ function reviewAffinityToolHandler(db, input) {
7663
8700
  "Provide `contact` when `view` is `events.contact_journal`."
7664
8701
  );
7665
8702
  }
7666
- const contact = resolveContactLocator(db, locator, "contact");
8703
+ const contact = resolveContactLocator(db, locator, "contact", true);
7667
8704
  if (!contact.ok) {
7668
8705
  return contact.result;
7669
8706
  }
@@ -7698,7 +8735,12 @@ function reviewAffinityToolHandler(db, input) {
7698
8735
  let contactId;
7699
8736
  let linkId;
7700
8737
  if (input.contact !== void 0) {
7701
- const contact = resolveContactLocator(db, input.contact, "contact");
8738
+ const contact = resolveContactLocator(
8739
+ db,
8740
+ input.contact,
8741
+ "contact",
8742
+ true
8743
+ );
7702
8744
  if (!contact.ok) {
7703
8745
  return contact.result;
7704
8746
  }
@@ -7727,7 +8769,12 @@ function reviewAffinityToolHandler(db, input) {
7727
8769
  let contactId;
7728
8770
  let linkId;
7729
8771
  if (input.contact !== void 0) {
7730
- const contact = resolveContactLocator(db, input.contact, "contact");
8772
+ const contact = resolveContactLocator(
8773
+ db,
8774
+ input.contact,
8775
+ "contact",
8776
+ true
8777
+ );
7731
8778
  if (!contact.ok) {
7732
8779
  return contact.result;
7733
8780
  }
@@ -7820,7 +8867,7 @@ function reviewAffinityToolHandler(db, input) {
7820
8867
  "Provide `contact` when `view` is `merges.history`."
7821
8868
  );
7822
8869
  }
7823
- const contact = resolveContactLocator(db, locator, "contact");
8870
+ const contact = resolveContactLocator(db, locator, "contact", true);
7824
8871
  if (!contact.ok) {
7825
8872
  return contact.result;
7826
8873
  }
@@ -7833,6 +8880,7 @@ function reviewAffinityToolHandler(db, input) {
7833
8880
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
7834
8881
  break;
7835
8882
  }
8883
+ items = items.map((item) => enrichReviewItem(db, item));
7836
8884
  return toolSuccess(
7837
8885
  summarizeCount(items.length, "item"),
7838
8886
  {
@@ -7871,12 +8919,15 @@ var init_review_affinity_tool = __esm({
7871
8919
  init_list_radar();
7872
8920
  init_get_merge_history();
7873
8921
  init_list_dismissed_duplicates();
8922
+ init_enrich_review_item();
7874
8923
  init_tool_metadata();
7875
8924
  init_tool_names();
7876
8925
  init_tool_ref();
7877
8926
  init_tool_resolvers();
7878
8927
  init_tool_summary();
7879
8928
  init_tool_types();
8929
+ init_tool_types();
8930
+ init_validate_tool_input();
7880
8931
  reviewAffinityTool = defineAffinityTool({
7881
8932
  name: reviewAffinityToolName,
7882
8933
  description: "Review one affinity surface such as contacts, journals, link queues, commitments, dates, graph state, or merge history.",
@@ -7888,12 +8939,16 @@ var init_review_affinity_tool = __esm({
7888
8939
  targetKinds: ["contact", "link", "event"],
7889
8940
  inputDescriptions: {
7890
8941
  view: "Which review surface to return.",
8942
+ now: "Optional current timestamp for overdue calculations.",
7891
8943
  contact: "Target contact locator for views that require one.",
7892
8944
  link: "Target link locator for views that require one.",
7893
8945
  contactIds: "Optional contact id subset for duplicate review or graph review.",
7894
8946
  kind: "Optional contact kind or link kind filter, depending on the view.",
7895
8947
  lifecycleState: "Optional contact lifecycle filter for the contact list view.",
7896
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.",
7897
8952
  includeOwner: "Whether owner contacts should be included in contact review.",
7898
8953
  includeDormant: "Whether dormant links or contacts should be included where supported.",
7899
8954
  includeArchived: "Whether archived links should be included where supported.",
@@ -7930,6 +8985,9 @@ var init_review_affinity_tool = __esm({
7930
8985
  "merges.history",
7931
8986
  "merges.dismissed"
7932
8987
  ]),
8988
+ now: integerSchema(
8989
+ "Optional current timestamp for overdue calculations."
8990
+ ),
7933
8991
  contact: contactLocatorSchema9("Optional target contact locator."),
7934
8992
  link: linkLocatorSchema5("Optional target link locator."),
7935
8993
  contactIds: arraySchema(
@@ -7952,6 +9010,17 @@ var init_review_affinity_tool = __esm({
7952
9010
  "broken",
7953
9011
  "archived"
7954
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
+ ),
7955
9024
  includeOwner: booleanSchema("Whether owner contacts are included."),
7956
9025
  includeDormant: booleanSchema("Whether dormant rows are included."),
7957
9026
  includeArchived: booleanSchema("Whether archived links are included."),
@@ -7998,99 +9067,110 @@ var init_review_affinity_tool = __esm({
7998
9067
  });
7999
9068
 
8000
9069
  // src/contacts/search_contacts.ts
8001
- function relevanceScore(name, queryLower, matchedName, matchedNormalizedIdentity) {
8002
- const nl = name.toLowerCase();
8003
- if (matchedNormalizedIdentity) return 1.1;
8004
- if (nl.startsWith(queryLower)) return 1;
8005
- if (matchedName) return 0.85;
8006
- return 0.7;
8007
- }
8008
9070
  function searchContacts(db, query, filters, options) {
8009
- const q = query.trim();
8010
- if (q.length === 0) {
8011
- return [];
8012
- }
9071
+ const q = foldSearchText(query.trim());
9072
+ if (!q) return [];
8013
9073
  assertDefaultOrdering("searchContacts", options);
8014
9074
  const { limit, offset } = resolveListLimitOffset(options);
8015
- const queryLower = q.toLowerCase();
8016
- const pattern = `%${q.replace(/%/g, "").replace(/_/g, "")}%`;
8017
- 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);
8018
9092
  const clauses = [
8019
- "c.deleted_at IS NULL",
8020
- "c.lifecycle_state != 'merged'"
9093
+ "deleted_at IS NULL",
9094
+ "lifecycle_state != 'merged'",
9095
+ ...attributeFilters.clauses
8021
9096
  ];
8022
- const params = [];
9097
+ const params = [...attributeFilters.params];
8023
9098
  if (filters?.kind !== void 0) {
8024
- clauses.push("c.kind = ?");
9099
+ clauses.push("kind = ?");
8025
9100
  params.push(filters.kind);
8026
9101
  }
8027
9102
  if (filters?.lifecycleState !== void 0) {
8028
- clauses.push("c.lifecycle_state = ?");
9103
+ clauses.push("lifecycle_state = ?");
8029
9104
  params.push(filters.lifecycleState);
8030
9105
  }
8031
- if (filters?.includeOwner === false) {
8032
- clauses.push("c.is_owner = 0");
8033
- }
8034
- if (filters?.includeDormant === false || options?.includeDormant === false) {
8035
- clauses.push("c.lifecycle_state != 'dormant'");
8036
- }
8037
- const where = clauses.join(" AND ");
8038
- const sql = `
8039
- SELECT c.id, c.name, c.kind, c.lifecycle_state, c.is_owner, c.merged_into_contact_id,
8040
- c.created_at, c.updated_at, c.deleted_at,
8041
- (
8042
- SELECT value
8043
- FROM identities primary_i
8044
- WHERE primary_i.contact_id = c.id AND primary_i.removed_at IS NULL
8045
- ORDER BY primary_i.id
8046
- LIMIT 1
8047
- ) AS primary_identity,
8048
- MAX(CASE WHEN i.normalized_key LIKE ? THEN 1 ELSE 0 END) AS matched_normalized_identity
8049
- FROM contacts c
8050
- LEFT JOIN identities i ON i.contact_id = c.id AND i.removed_at IS NULL
8051
- WHERE ${where}
8052
- AND (c.name LIKE ? OR i.value LIKE ? OR i.normalized_key LIKE ?)
8053
- GROUP BY c.id
8054
- `;
8055
- const rows = db.prepare(sql).all(
8056
- normalizedPattern,
8057
- ...params,
8058
- pattern,
8059
- pattern,
8060
- normalizedPattern
8061
- );
8062
- const scored = rows.map((r) => {
8063
- const item = mapContactRowToContactListItem(r);
8064
- const matchedName = r.name.toLowerCase().includes(queryLower);
8065
- const score = relevanceScore(
8066
- r.name,
8067
- queryLower,
8068
- matchedName,
8069
- Number(
8070
- r.matched_normalized_identity ?? 0
8071
- ) > 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:")
8072
9144
  );
8073
- return {
8074
- item: {
8075
- ...item,
8076
- primaryIdentity: r.primary_identity ?? null
8077
- },
8078
- score
8079
- };
8080
- });
8081
- scored.sort((a, b) => b.score - a.score);
8082
- 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);
8083
9152
  }
8084
9153
  var init_search_contacts = __esm({
8085
9154
  "src/contacts/search_contacts.ts"() {
8086
9155
  init_assert_default_ordering();
8087
9156
  init_resolve_list_limit_offset();
9157
+ init_contact_attribute_clauses();
9158
+ init_fold_search_text();
8088
9159
  init_mappers4();
8089
9160
  }
8090
9161
  });
8091
9162
 
8092
9163
  // src/tools/search_affinity_tool.ts
8093
- 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
+ );
8094
9174
  return withToolHandling(() => {
8095
9175
  const options = {
8096
9176
  ...input.limit === void 0 ? {} : { limit: input.limit },
@@ -8100,6 +9180,9 @@ function searchAffinityToolHandler(db, input) {
8100
9180
  db,
8101
9181
  input.query,
8102
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 },
8103
9186
  ...input.kind === void 0 ? {} : { kind: input.kind },
8104
9187
  ...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
8105
9188
  ...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
@@ -8131,9 +9214,11 @@ var init_search_affinity_tool = __esm({
8131
9214
  init_tool_resolvers();
8132
9215
  init_tool_summary();
8133
9216
  init_tool_types();
9217
+ init_tool_types();
9218
+ init_validate_tool_input();
8134
9219
  searchAffinityTool = defineAffinityTool({
8135
9220
  name: searchAffinityToolName,
8136
- 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.",
8137
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.",
8138
9223
  whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
8139
9224
  sideEffects: "none",
@@ -8144,6 +9229,9 @@ var init_search_affinity_tool = __esm({
8144
9229
  query: "Free-text or natural-key search text.",
8145
9230
  kind: "Optional contact kind filter.",
8146
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.",
8147
9235
  includeOwner: "Whether owner contacts are eligible for results.",
8148
9236
  includeDormant: "Whether dormant contacts should be eligible for results.",
8149
9237
  limit: "Optional result limit.",
@@ -8168,6 +9256,17 @@ var init_search_affinity_tool = __esm({
8168
9256
  "merged",
8169
9257
  "lost"
8170
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
+ ),
8171
9270
  includeOwner: booleanSchema("Whether owner contacts are included."),
8172
9271
  includeDormant: booleanSchema("Whether dormant contacts are included."),
8173
9272
  limit: integerSchema("Optional result limit."),
@@ -8214,11 +9313,112 @@ var init_tool_registry = __esm({
8214
9313
  }
8215
9314
  });
8216
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
+
8217
9413
  // src/surface/affinity_surface_tools.ts
8218
9414
  var affinitySurfaceTools, mapping, affinityCliSurfaceTools;
8219
9415
  var init_affinity_surface_tools = __esm({
8220
9416
  "src/surface/affinity_surface_tools.ts"() {
9417
+ init_cli_args();
8221
9418
  init_tool_registry();
9419
+ init_tool_types();
9420
+ init_plain_dates();
9421
+ init_timestamp_schema();
8222
9422
  affinitySurfaceTools = affinityTools.map((tool) => ({
8223
9423
  name: tool.name,
8224
9424
  description: tool.description,
@@ -8226,8 +9426,24 @@ var init_affinity_surface_tools = __esm({
8226
9426
  ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
8227
9427
  readOnly: tool.readOnly,
8228
9428
  sideEffects: tool.sideEffects,
8229
- inputSchema: { ...tool.inputSchema, type: "object" },
8230
- 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
+ }
8231
9447
  }));
8232
9448
  mapping = {
8233
9449
  search_affinity: { verb: "search", positionals: ["query"] },
@@ -8288,10 +9504,22 @@ var init_affinity_surface_tools = __esm({
8288
9504
  };
8289
9505
  affinityCliSurfaceTools = /* @__PURE__ */ affinitySurfaceTools.map((tool) => ({
8290
9506
  ...tool,
9507
+ inputSchema: {
9508
+ ...affinityTools.find((original) => original.name === tool.name)?.inputSchema,
9509
+ type: "object"
9510
+ },
8291
9511
  ...mapping[tool.name] ? { cli: mapping[tool.name] } : {}
8292
9512
  }));
8293
9513
  }
8294
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
+ });
8295
9523
 
8296
9524
  // src/attributes/init_attributes_tables.ts
8297
9525
  function initAttributesTables(db) {
@@ -8584,6 +9812,12 @@ function initLinkEventEffectsTables(db) {
8584
9812
  )
8585
9813
  )
8586
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
+ }
8587
9821
  db.exec(
8588
9822
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
8589
9823
  );
@@ -8821,19 +10055,6 @@ var init_open_affinity_db = __esm({
8821
10055
  }
8822
10056
  });
8823
10057
 
8824
- // src/surface/resolve_affinity_db_path.ts
8825
- var resolve_affinity_db_path_exports = {};
8826
- __export(resolve_affinity_db_path_exports, {
8827
- resolveAffinityDbPath: () => resolveAffinityDbPath
8828
- });
8829
- function resolveAffinityDbPath(options = {}) {
8830
- return options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || join(homedir(), ".ghostpaw", "affinity.db"));
8831
- }
8832
- var init_resolve_affinity_db_path = __esm({
8833
- "src/surface/resolve_affinity_db_path.ts"() {
8834
- }
8835
- });
8836
-
8837
10058
  // src/surfaces/surface_types.ts
8838
10059
  function isErrorResult(result) {
8839
10060
  return typeof result === "object" && result !== null && "ok" in result && result.ok === false;
@@ -9073,10 +10294,10 @@ async function serveMcpStdio(server, options = {}) {
9073
10294
  const response = await server.handleLine(line.replace(/\r$/, ""));
9074
10295
  if (response !== void 0)
9075
10296
  await new Promise(
9076
- (resolve, reject) => output.write(
10297
+ (resolve2, reject) => output.write(
9077
10298
  `${response}
9078
10299
  `,
9079
- (error) => error ? reject(error) : resolve()
10300
+ (error) => error ? reject(error) : resolve2()
9080
10301
  )
9081
10302
  );
9082
10303
  };
@@ -9166,11 +10387,165 @@ var init_serve_affinity_mcp = __esm({
9166
10387
  }
9167
10388
  });
9168
10389
 
9169
- // src/surfaces/skill_markdown.ts
9170
- var skill_markdown_exports = {};
9171
- __export(skill_markdown_exports, {
9172
- 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
+ }
9173
10546
  });
10547
+
10548
+ // src/surfaces/skill_markdown.ts
9174
10549
  function renderSkillMarkdown(spec, { referencesBase = "references" } = {}) {
9175
10550
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(spec.name) || spec.name.length > 64)
9176
10551
  throw new Error("Invalid skill name");
@@ -9229,6 +10604,25 @@ function renderSkillMarkdown(spec, { referencesBase = "references" } = {}) {
9229
10604
  lines.push(
9230
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", " ")} |`
9231
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
+ }
9232
10626
  lines.push("");
9233
10627
  }
9234
10628
  lines.push(
@@ -9243,13 +10637,38 @@ function renderSkillMarkdown(spec, { referencesBase = "references" } = {}) {
9243
10637
  ...spec.principles.map((principle) => `- ${principle}`),
9244
10638
  ""
9245
10639
  );
9246
- 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
+ ` : ""}`;
9247
10658
  }
9248
10659
  var init_skill_markdown = __esm({
9249
10660
  "src/surfaces/skill_markdown.ts"() {
9250
10661
  }
9251
10662
  });
9252
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
+
9253
10672
  // src/skills/skill_types.ts
9254
10673
  function defineAffinitySkill(skill) {
9255
10674
  return skill;
@@ -10000,22 +11419,76 @@ var init_soul = __esm({
10000
11419
  });
10001
11420
 
10002
11421
  // src/surface/affinity_skill_spec.ts
10003
- var affinity_skill_spec_exports = {};
10004
- __export(affinity_skill_spec_exports, {
10005
- affinitySkillSpec: () => affinitySkillSpec
10006
- });
10007
- var affinitySkillSpec;
11422
+ var affinityCliExamples, affinityCheatSheet, affinitySkillSpec;
10008
11423
  var init_affinity_skill_spec = __esm({
10009
11424
  "src/surface/affinity_skill_spec.ts"() {
10010
11425
  init_skill_registry();
10011
11426
  init_soul();
10012
11427
  init_affinity_mcp_instructions();
10013
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
+ `;
10014
11485
  affinitySkillSpec = {
11486
+ cheatSheet: affinityCheatSheet,
11487
+ examples: affinityCliExamples,
10015
11488
  name: "affinity",
10016
11489
  description: "Search and maintain local contacts, identities, relationships, events, commitments, and recurring dates. Use when recording relationship evidence or reviewing a social CRM.",
10017
11490
  license: "MIT",
10018
- 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.",
10019
11492
  bin: "ghostpaw-affinity",
10020
11493
  packageName: "@ghostpaw/affinity",
10021
11494
  server: "ghostpaw-affinity",
@@ -10027,22 +11500,98 @@ var init_affinity_skill_spec = __esm({
10027
11500
  }
10028
11501
  });
10029
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
+
10030
11537
  // src/surface/affinity_cli_options.ts
10031
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
10032
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();
10033
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,
10034
11581
  bin: "ghostpaw-affinity",
10035
11582
  version: affinityVersion,
10036
11583
  description: "Local relationship model and social CRM",
10037
11584
  tools: affinityCliSurfaceTools,
10038
11585
  openContext: async (options) => {
10039
11586
  const { openAffinityDb: openAffinityDb2 } = await Promise.resolve().then(() => (init_open_affinity_db(), open_affinity_db_exports));
10040
- const { resolveAffinityDbPath: resolveAffinityDbPath2 } = await Promise.resolve().then(() => (init_resolve_affinity_db_path(), resolve_affinity_db_path_exports));
10041
- return openAffinityDb2(
10042
- resolveAffinityDbPath2(
10043
- options.db === void 0 ? {} : { flag: options.db }
10044
- )
11587
+ const path = resolveAffinityDbPath(
11588
+ options.db === void 0 ? {} : { flag: options.db }
10045
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;
10046
11595
  },
10047
11596
  closeContext: (db) => db.close(),
10048
11597
  commands: {
@@ -10052,125 +11601,19 @@ var affinityCliOptions = {
10052
11601
  options.db === void 0 ? {} : { dbPath: options.db }
10053
11602
  );
10054
11603
  },
10055
- skill: async () => {
10056
- const { renderSkillMarkdown: renderSkillMarkdown2 } = await Promise.resolve().then(() => (init_skill_markdown(), skill_markdown_exports));
10057
- const { affinitySkillSpec: affinitySkillSpec2 } = await Promise.resolve().then(() => (init_affinity_skill_spec(), affinity_skill_spec_exports));
10058
- 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);
10059
11607
  }
10060
11608
  }
10061
11609
  };
10062
- var CliUsageError = class extends Error {
10063
- exitCode = 2;
10064
- };
10065
- var keyOf = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
10066
- function parseCliArgs(argv, spec) {
10067
- const values = {};
10068
- const positions = [];
10069
- const flags = spec.flags ?? [];
10070
- const convert = (raw, arg) => {
10071
- let value = raw;
10072
- if (raw.startsWith("@")) {
10073
- try {
10074
- value = readFileSync(raw.slice(1), "utf8");
10075
- } catch {
10076
- throw new CliUsageError(`Cannot read value file: ${raw.slice(1)}`);
10077
- }
10078
- }
10079
- let result = value;
10080
- if (arg.type === "number" || arg.type === "integer") {
10081
- result = Number(value);
10082
- if (!value.trim() || !Number.isFinite(result) || arg.type === "integer" && !Number.isInteger(result))
10083
- throw new CliUsageError(
10084
- `Expected ${arg.type} for ${arg.name}: ${value}`
10085
- );
10086
- } else if (arg.type === "boolean") {
10087
- if (!["true", "false"].includes(value))
10088
- throw new CliUsageError(`Expected boolean for ${arg.name}`);
10089
- result = value === "true";
10090
- } else if (arg.type === "json") {
10091
- try {
10092
- result = JSON.parse(value);
10093
- } catch {
10094
- throw new CliUsageError(`Invalid JSON for ${arg.name}`);
10095
- }
10096
- }
10097
- if (arg.enum && !arg.enum.includes(result))
10098
- throw new CliUsageError(
10099
- `${arg.name} must be one of: ${arg.enum.join(", ")}`
10100
- );
10101
- return result;
10102
- };
10103
- let ended = false;
10104
- for (let i = 0; i < argv.length; i++) {
10105
- const token = argv[i];
10106
- if (!ended && token === "--") {
10107
- ended = true;
10108
- continue;
10109
- }
10110
- if (ended || token === "-" || !token.startsWith("-")) {
10111
- positions.push(token);
10112
- continue;
10113
- }
10114
- const [flag, inline] = token.split(/=(.*)/s);
10115
- const negative = flag?.startsWith("--no-");
10116
- const name = flag?.replace(/^--?/, "").replace(/^no-/, "");
10117
- const arg = flags.find(
10118
- (entry) => entry.name === name || keyOf(entry.name) === name || entry.short === name
10119
- );
10120
- if (!arg) {
10121
- const prefix = (name ?? "").slice(0, 3);
10122
- const suggestion = prefix ? flags.find((entry) => entry.name.startsWith(prefix)) : void 0;
10123
- throw new CliUsageError(
10124
- `Unknown flag: ${flag}${suggestion ? `; did you mean --${suggestion.name}?` : ""}`
10125
- );
10126
- }
10127
- if (negative && arg.type !== "boolean")
10128
- throw new CliUsageError(`--no-${arg.name} requires a boolean flag`);
10129
- let raw = inline;
10130
- if (arg.type === "boolean" && raw === void 0)
10131
- raw = negative ? "false" : "true";
10132
- if (raw === void 0) {
10133
- const next = argv[i + 1];
10134
- if (next === void 0 || /^--[a-z]/i.test(next))
10135
- throw new CliUsageError(`Missing value: --${arg.name}`);
10136
- raw = next;
10137
- i++;
10138
- }
10139
- const key = keyOf(arg.name);
10140
- if (arg.type === "array") {
10141
- const item = convert(raw, { ...arg, type: arg.itemType ?? "string" });
10142
- values[key] = [...values[key] ?? [], item];
10143
- } else values[key] = convert(raw, arg);
10144
- }
10145
- let index = 0;
10146
- for (const arg of spec.positionals ?? []) {
10147
- if (index >= positions.length) continue;
10148
- values[keyOf(arg.name)] = arg.variadic ? positions.slice(index).map(
10149
- (value) => convert(value, { ...arg, type: arg.itemType ?? arg.type })
10150
- ) : convert(positions[index], arg);
10151
- index = arg.variadic ? positions.length : index + 1;
10152
- }
10153
- if (index < positions.length)
10154
- throw new CliUsageError(`Unexpected positional: ${positions[index]}`);
10155
- for (const arg of [...flags, ...spec.positionals ?? []]) {
10156
- if (arg.required && values[keyOf(arg.name)] === void 0)
10157
- throw new CliUsageError(`Missing required argument: ${arg.name}`);
10158
- }
10159
- return values;
10160
- }
10161
- function renderCliHelp(spec) {
10162
- const describe = (arg) => `${arg.type}${arg.enum ? ` (${arg.enum.join("|")})` : ""}${arg.required ? " required" : ""} ${arg.description ?? ""}`;
10163
- return `Usage: ${spec.command} ${(spec.positionals ?? []).map((arg) => `<${arg.name}${arg.variadic ? "..." : ""}>`).join(" ")} [flags]
10164
- ${spec.description ?? ""}
10165
11610
 
10166
- Positionals:
10167
- ${(spec.positionals ?? []).map((arg) => ` ${arg.name} ${describe(arg)}`).join("\n")}
10168
- Flags:
10169
- ${(spec.flags ?? []).map((arg) => ` --${arg.name}${arg.short ? `, -${arg.short}` : ""} ${describe(arg)}`).join("\n")}
10170
- `;
10171
- }
11611
+ // src/surfaces/cli.ts
11612
+ init_cli_args();
10172
11613
 
10173
11614
  // src/surfaces/cli_commands.ts
11615
+ init_cli_args();
11616
+ init_cli_output();
10174
11617
  init_validate_input();
10175
11618
  function cliCommands(tools) {
10176
11619
  return tools.flatMap((tool) => {
@@ -10242,14 +11685,16 @@ async function runCliCommand(argv, options) {
10242
11685
  else if (flag === "--compact") compact = true;
10243
11686
  else if (flag === "--help" || flag === "-h") help = true;
10244
11687
  else if (["--version", "-V", "-v"].includes(flag ?? "")) {
10245
- if (!quiet) stdout.write(`${options.version}
11688
+ if (!quiet)
11689
+ stdout.write(`${options.versionLine?.() ?? options.version}
10246
11690
  `);
10247
11691
  return 0;
10248
11692
  } else if (flag !== "--no-color") continue;
10249
11693
  args.splice(i--, 1);
10250
11694
  }
10251
11695
  if (args[0] === "version") {
10252
- if (!quiet) stdout.write(`${options.version}
11696
+ if (!quiet)
11697
+ stdout.write(`${options.versionLine?.() ?? options.version}
10253
11698
  `);
10254
11699
  return 0;
10255
11700
  }
@@ -10264,10 +11709,12 @@ async function runCliCommand(argv, options) {
10264
11709
  const specFor = (command) => {
10265
11710
  const argument = (name, schema) => ({
10266
11711
  name,
10267
- 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",
10268
11713
  description: schema.description,
10269
11714
  enum: schema.enum,
10270
- ...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
+ } : {}
10271
11718
  });
10272
11719
  const properties = command.tool.inputSchema.properties ?? {};
10273
11720
  return {
@@ -10298,7 +11745,7 @@ async function runCliCommand(argv, options) {
10298
11745
  throw new CliUsageError(`Unknown command: ${args.join(" ")}`);
10299
11746
  if (!quiet)
10300
11747
  stdout.write(
10301
- entry ? renderCliHelp(specFor(entry)) : `${options.bin} ${options.version}
11748
+ entry ? renderCliHelp(specFor(entry)) : `${options.versionLine?.() ?? `${options.bin} ${options.version}`}
10302
11749
  ${options.description}
10303
11750
 
10304
11751
  Commands:
@@ -10307,6 +11754,9 @@ ${matches.map((command) => ` ${command.path}`).join("\n")}
10307
11754
  Global flags: --db <path>, --json, --quiet, --compact, --help/-h, --version/-V/-v, --no-color
10308
11755
  `
10309
11756
  );
11757
+ if (options.describeDb && !quiet)
11758
+ stdout.write(`Database: ${options.describeDb(db)}
11759
+ `);
10310
11760
  return 0;
10311
11761
  }
10312
11762
  if (args[0] === "completions") {
@@ -10341,7 +11791,16 @@ _arguments '*:command:(${words.join(" ")})'
10341
11791
  throw new CliUsageError("Expected completion shell: bash, zsh or fish");
10342
11792
  return 0;
10343
11793
  }
10344
- 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
+ }
10345
11804
  const rest = args.slice(entry.path.split(" ").length);
10346
11805
  let base = {};
10347
11806
  const spec = specFor(entry);
@@ -10371,29 +11830,42 @@ _arguments '*:command:(${words.join(" ")})'
10371
11830
  if (!base || typeof base !== "object" || Array.isArray(base))
10372
11831
  throw new CliUsageError("Expected stdin JSON object");
10373
11832
  }
10374
- 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 = {
10375
11839
  ...base,
10376
11840
  ...parseCliArgs(rest, spec),
10377
11841
  ...entry.fixed
10378
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;
10379
11850
  const errors = validateInput(entry.tool.inputSchema, input);
10380
11851
  if (errors.length) throw new CliUsageError(errors.join("\n"));
10381
- 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
+ });
10382
11856
  try {
10383
11857
  const result = await entry.tool.handler(context, input);
10384
11858
  const record = result;
10385
- 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);
10386
11860
  if (code)
10387
11861
  stderr.write(
10388
- `${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"}
10389
11863
  `
10390
11864
  );
10391
11865
  if (!quiet)
10392
11866
  stdout.write(
10393
- json || !stdout.isTTY ? `${JSON.stringify(result, null, compact ? void 0 : 2)}
10394
- ` : `${record.summary ?? ""}
10395
- ${JSON.stringify(record.data ?? {}, null, 2)}
10396
- `
11867
+ json || !stdout.isTTY ? `${JSON.stringify(plainJson(result, compact, options.formatError), null, compact ? void 0 : 2)}
11868
+ ` : humanResult(result)
10397
11869
  );
10398
11870
  return code;
10399
11871
  } finally {
@@ -10401,22 +11873,24 @@ ${JSON.stringify(record.data ?? {}, null, 2)}
10401
11873
  }
10402
11874
  } catch (error) {
10403
11875
  return fail(
10404
- error instanceof Error ? error.message : String(error),
11876
+ `${error instanceof CliUsageError ? "" : "internal error: "}${error instanceof Error ? error.message : String(error)}`,
10405
11877
  error instanceof CliUsageError ? 2 : 70
10406
11878
  );
10407
11879
  }
10408
11880
  }
10409
11881
 
10410
11882
  // src/surfaces/cli.ts
10411
- init_surface_types();
11883
+ init_cli_output();
10412
11884
  init_validate_input();
10413
- async function runToolCli(argv, providedOptions) {
11885
+ async function runToolCliInternal(argv, providedOptions) {
10414
11886
  let options = providedOptions;
10415
11887
  const end = argv.indexOf("--");
10416
11888
  if (argv.slice(0, end < 0 ? argv.length : end).some((arg) => ["--version", "-V", "-v"].includes(arg))) {
10417
11889
  if (!argv.includes("--quiet"))
10418
- (options.stdout ?? process.stdout).write(`${options.version}
10419
- `);
11890
+ (options.stdout ?? process.stdout).write(
11891
+ `${options.versionLine?.() ?? options.version}
11892
+ `
11893
+ );
10420
11894
  return 0;
10421
11895
  }
10422
11896
  const modern = await runCliCommand(argv, options);
@@ -10468,7 +11942,7 @@ async function runToolCli(argv, providedOptions) {
10468
11942
  const normalize = (name2) => name2.replace(/[-_]/g, "").toLowerCase();
10469
11943
  if (["help", "--help", "-h"].includes(command)) {
10470
11944
  stdout.write(
10471
- `${options.bin} ${options.version}
11945
+ `${options.versionLine?.() ?? `${options.bin} ${options.version}`}
10472
11946
  ${options.description}
10473
11947
 
10474
11948
  Usage: ${options.bin} [--db path] <command>
@@ -10481,7 +11955,7 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
10481
11955
  return 0;
10482
11956
  }
10483
11957
  if (["--version", "-V", "-v", "version"].includes(command)) {
10484
- stdout.write(`${options.version}
11958
+ stdout.write(`${options.versionLine?.() ?? options.version}
10485
11959
  `);
10486
11960
  return 0;
10487
11961
  }
@@ -10501,7 +11975,14 @@ ${options.tools.map((tool2) => `${tool2.name} ${tool2.description}`).join("\n")
10501
11975
  }
10502
11976
  if (command === "skill") {
10503
11977
  if (!options.commands?.skill) return fail("skill command unavailable");
10504
- 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
+ }
10505
11986
  return 0;
10506
11987
  }
10507
11988
  if (command === "mcp") {
@@ -10545,7 +12026,7 @@ ${JSON.stringify(tool.inputSchema, null, 2)}
10545
12026
  if (args.length > 1) return fail("Expected a single JSON argument");
10546
12027
  const raw = args[0];
10547
12028
  input = JSON.parse(
10548
- 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 ?? "{}"
10549
12030
  );
10550
12031
  } else {
10551
12032
  const values = {};
@@ -10589,40 +12070,79 @@ ${JSON.stringify(tool.inputSchema, null, 2)}
10589
12070
  ...Array.isArray(prior) ? prior : [],
10590
12071
  coerce(raw, schema.items ?? {})
10591
12072
  ];
10592
- } else values[property] = coerce(raw, schema);
12073
+ } else
12074
+ values[property] = options.transformInput && /At$|^(asOf|now)$/.test(property) ? raw : coerce(raw, schema);
10593
12075
  }
10594
12076
  input = values;
10595
12077
  }
10596
12078
  } catch (cause) {
10597
12079
  return fail(
10598
12080
  `Invalid input: ${cause instanceof Error ? cause.message : String(cause)}`,
10599
- 1
12081
+ 2
10600
12082
  );
10601
12083
  }
12084
+ try {
12085
+ input = options.transformInput?.(input) ?? input;
12086
+ } catch (cause) {
12087
+ return fail(cause instanceof Error ? cause.message : String(cause), 2);
12088
+ }
10602
12089
  const errors = validateInput(tool.inputSchema, input);
10603
- if (errors.length) return fail(errors.join("\n"), 1);
12090
+ if (errors.length) return fail(errors.join("\n"), 2);
10604
12091
  let context;
10605
12092
  try {
10606
- context = await options.openContext(ctxOptions);
12093
+ context = await options.openContext(
12094
+ options.reportCreation ? { ...ctxOptions, stderr } : ctxOptions
12095
+ );
10607
12096
  } catch (cause) {
10608
- return fail(String(cause), 1);
12097
+ return fail(
12098
+ `internal error: ${cause instanceof Error ? cause.message : String(cause)}`,
12099
+ 70
12100
+ );
10609
12101
  }
10610
12102
  try {
10611
12103
  const result = await tool.handler(
10612
12104
  context,
10613
12105
  input
10614
12106
  );
10615
- stdout.write(`${JSON.stringify(result, null, compact ? void 0 : 2)}
10616
- `);
10617
- if (typeof result === "object" && result !== null && "outcome" in result && result.outcome === "needs_clarification")
10618
- return 3;
10619
- 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;
10620
12121
  } catch (cause) {
10621
- return fail(String(cause), 1);
12122
+ return fail(
12123
+ `internal error: ${cause instanceof Error ? cause.message : String(cause)}`,
12124
+ 70
12125
+ );
10622
12126
  } finally {
10623
12127
  await options.closeContext?.(context);
10624
12128
  }
10625
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
+ }
10626
12145
 
10627
12146
  // src/cli.ts
12147
+ markNpmEntry();
10628
12148
  process.exitCode = await runToolCli(process.argv.slice(2), affinityCliOptions);