@ghostpaw/affinity 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.cjs CHANGED
@@ -5,7 +5,7 @@ var node_path = require('node:path');
5
5
  var node_os = require('node:os');
6
6
 
7
7
  // src/version.ts
8
- var affinityVersion = "0.5.0";
8
+ var affinityVersion = "0.6.0";
9
9
 
10
10
  // src/surface/affinity_mcp_instructions.ts
11
11
  var affinityMcpInstructions = "Affinity is a local relationship model and social CRM. Search before creating contacts. Inspect before revising or merging. Record direct evidence honestly, keep identities and structural ties distinct from social links, and ask for clarification when a target is ambiguous. Review commitments and recurring dates regularly. Derived scores guide attention and do not prove relationship quality.";
@@ -78,6 +78,158 @@ var AffinityNotFoundError = class extends AffinityError {
78
78
  code = "NOT_FOUND";
79
79
  };
80
80
 
81
+ // src/lib/normalize_mutation_receipt.ts
82
+ function uniqueEntityRefs(refs) {
83
+ const out = [];
84
+ const seen = /* @__PURE__ */ new Set();
85
+ for (const ref of refs ?? []) {
86
+ const key = `${ref.kind}:${ref.id}`;
87
+ if (seen.has(key)) {
88
+ continue;
89
+ }
90
+ seen.add(key);
91
+ out.push(ref);
92
+ }
93
+ return out;
94
+ }
95
+ function uniqueDerivedEffects(effects) {
96
+ const out = [];
97
+ const seen = /* @__PURE__ */ new Set();
98
+ for (const effect of effects ?? []) {
99
+ const key = `${effect.eventId}:${effect.linkId}`;
100
+ if (seen.has(key)) {
101
+ continue;
102
+ }
103
+ seen.add(key);
104
+ out.push(effect);
105
+ }
106
+ return out;
107
+ }
108
+ function uniqueAffectedLinks(affectedLinks, derivedEffects) {
109
+ const out = [];
110
+ const seen = /* @__PURE__ */ new Set();
111
+ for (const linkId of affectedLinks ?? []) {
112
+ if (seen.has(linkId)) {
113
+ continue;
114
+ }
115
+ seen.add(linkId);
116
+ out.push(linkId);
117
+ }
118
+ for (const effect of derivedEffects) {
119
+ if (seen.has(effect.linkId)) {
120
+ continue;
121
+ }
122
+ seen.add(effect.linkId);
123
+ out.push(effect.linkId);
124
+ }
125
+ return out;
126
+ }
127
+ function normalizeMutationReceiptPartial(partial) {
128
+ const derivedEffects = uniqueDerivedEffects(partial?.derivedEffects);
129
+ return {
130
+ created: uniqueEntityRefs(partial?.created),
131
+ updated: uniqueEntityRefs(partial?.updated),
132
+ archived: uniqueEntityRefs(partial?.archived),
133
+ removed: uniqueEntityRefs(partial?.removed),
134
+ affectedLinks: uniqueAffectedLinks(partial?.affectedLinks, derivedEffects),
135
+ derivedEffects
136
+ };
137
+ }
138
+
139
+ // src/events/receipts.ts
140
+ function buildEventMutationReceipt(primary, partial) {
141
+ const normalized = normalizeMutationReceiptPartial(partial);
142
+ return {
143
+ primary,
144
+ ...partial?.warnings ? { warnings: partial.warnings } : {},
145
+ created: normalized.created,
146
+ updated: normalized.updated,
147
+ archived: normalized.archived,
148
+ removed: normalized.removed,
149
+ affectedLinks: normalized.affectedLinks,
150
+ derivedEffects: normalized.derivedEffects
151
+ };
152
+ }
153
+
154
+ // src/events/event_metadata.ts
155
+ function normalizeExternalRef(ref) {
156
+ if (!ref || typeof ref.source !== "string" || typeof ref.id !== "string" || !ref.source.trim() || !ref.id.trim())
157
+ throw new AffinityValidationError(
158
+ "external reference source and id must be non-empty strings"
159
+ );
160
+ return { source: ref.source.trim().toLowerCase(), id: ref.id.trim() };
161
+ }
162
+ function deriveEventSummary(content) {
163
+ const line = content.split(/\r?\n/).find((line2) => line2.trim())?.replace(/\s+/g, " ").trim() ?? "(no text)";
164
+ return line.length > 140 ? `${line.slice(0, 139)}\u2026` : line;
165
+ }
166
+ function validateEventMetadata(input) {
167
+ if (input.affectsBond !== void 0 && typeof input.affectsBond !== "boolean")
168
+ throw new AffinityValidationError("affectsBond must be boolean");
169
+ if (input.content !== void 0 && (typeof input.content !== "string" || Buffer.byteLength(input.content, "utf8") > 1048576))
170
+ throw new AffinityValidationError(
171
+ "content must be a string of at most 1 MB (1048576 UTF-8 bytes)"
172
+ );
173
+ for (const key of ["externalRef", "parentRef", "threadRef"])
174
+ if (input[key] !== void 0) input[key] = normalizeExternalRef(input[key]);
175
+ if (input.channel !== void 0) {
176
+ if (typeof input.channel !== "string" || !input.channel.trim() || input.channel.trim().length > 40)
177
+ throw new AffinityValidationError(
178
+ "channel must be non-empty and at most 40 characters"
179
+ );
180
+ input.channel = input.channel.trim().toLowerCase();
181
+ }
182
+ if (input.parentEventId !== void 0 && (!Number.isSafeInteger(input.parentEventId) || input.parentEventId <= 0))
183
+ throw new AffinityValidationError(
184
+ "parentEventId must be a positive integer"
185
+ );
186
+ const moments = [
187
+ "breakthrough",
188
+ "rupture",
189
+ "reconciliation",
190
+ "milestone",
191
+ "turning_point"
192
+ ];
193
+ if (input.momentKind !== void 0 && !moments.includes(input.momentKind))
194
+ throw new AffinityValidationError(
195
+ `momentKind must be one of: ${moments.join(", ")}`
196
+ );
197
+ }
198
+ function getEventById(db, eventId) {
199
+ return db.prepare("SELECT 1 FROM events WHERE id=? AND deleted_at IS NULL").get(eventId) ? loadEventRecord(db, eventId) : null;
200
+ }
201
+ function getEventByExternalRef(db, source, id) {
202
+ const ref = normalizeExternalRef({ source, id });
203
+ const row = db.prepare(
204
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
205
+ ).get(ref.source, ref.id);
206
+ return row ? loadEventRecord(db, row.id) : null;
207
+ }
208
+ function existingEventReceipt(db, ref) {
209
+ const event = ref && getEventByExternalRef(db, ref.source, ref.id);
210
+ return event ? { ...buildEventMutationReceipt(event, {}), outcome: "no_op" } : null;
211
+ }
212
+ function enrichEventMetadata(db, record) {
213
+ const bond = db.prepare("SELECT affects_bond FROM events WHERE id=?").get(record.id);
214
+ record.affectsBond = bond?.affects_bond == null ? !(record.type === "observation" && record.significance <= 2) : Boolean(bond.affects_bond);
215
+ if (record.parentEventId === void 0 && record.parentRef) {
216
+ const row = db.prepare(
217
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
218
+ ).get(record.parentRef.source, record.parentRef.id);
219
+ if (row) record.parentEventId = row.id;
220
+ }
221
+ const attrs = db.prepare(
222
+ "SELECT id, name, value FROM attributes WHERE event_id=? AND deleted_at IS NULL ORDER BY id"
223
+ ).all(record.id);
224
+ if (attrs.length)
225
+ record.attributes = attrs.map((a) => ({
226
+ ...a,
227
+ contactId: null,
228
+ linkId: null,
229
+ eventId: record.id
230
+ }));
231
+ }
232
+
81
233
  // src/contacts/queries.ts
82
234
  function getContactRowById(db, id) {
83
235
  const row = db.prepare(
@@ -156,8 +308,11 @@ function assertParticipantContactsLive(db, contactIds) {
156
308
  }
157
309
  function validateSocialEventInput(input) {
158
310
  assertFiniteTimestamp(input.occurredAt, "occurredAt");
159
- const summary = input.summary.trim();
160
- if (summary.length === 0) {
311
+ validateEventMetadata(input);
312
+ if (input.summary !== void 0 && typeof input.summary !== "string")
313
+ throw new AffinityValidationError("summary must be a string");
314
+ const summary = input.summary?.trim();
315
+ if (summary === "" || summary === void 0 && input.content === void 0) {
161
316
  throw new AffinityValidationError("summary must be non-empty");
162
317
  }
163
318
  const sig = input.significance;
@@ -221,11 +376,25 @@ function mapEventRowToEventRecord(row, participants) {
221
376
  id: row.id,
222
377
  type: row.type,
223
378
  occurredAt: row.occurred_at,
224
- summary: row.summary,
379
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
380
+ summary: decodeEventText(row.summary),
225
381
  significance: row.significance,
226
382
  momentKind: row.moment_kind,
227
383
  participants
228
384
  };
385
+ if (row.content != null) base.content = decodeEventText(row.content);
386
+ if (row.channel != null) base.channel = row.channel;
387
+ if (row.external_source != null && row.external_id != null)
388
+ base.externalRef = { source: row.external_source, id: row.external_id };
389
+ if (row.parent_source != null && row.parent_ref_id != null)
390
+ base.parentRef = { source: row.parent_source, id: row.parent_ref_id };
391
+ if (row.thread_source != null && row.thread_id != null)
392
+ base.threadRef = { source: row.thread_source, id: row.thread_id };
393
+ if (row.parent_event_id != null) base.parentEventId = row.parent_event_id;
394
+ if (row.type === "promise" || row.type === "agreement") {
395
+ base.dueAt = row.due_at ?? null;
396
+ base.dueOn = row.due_on ?? (row.due_at == null ? null : new Date(row.due_at).toISOString().slice(0, 10));
397
+ }
229
398
  if (row.recurrence_kind !== null) {
230
399
  base.recurrenceKind = row.recurrence_kind;
231
400
  base.anchorMonth = row.anchor_month;
@@ -255,25 +424,29 @@ function mapUpcomingRowToUpcomingDateRecord(row) {
255
424
  targetRef,
256
425
  recurrenceKind,
257
426
  occursOn: row.occurs_on,
258
- summary: row.summary,
427
+ occursOnDate: new Date(row.occurs_on).toISOString().slice(0, 10),
428
+ summary: decodeEventText(row.summary),
259
429
  significance: row.significance
260
430
  };
261
431
  }
432
+ function decodeEventText(value) {
433
+ return typeof value === "string" ? value : Buffer.from(value).toString("utf8");
434
+ }
262
435
 
263
436
  // src/events/queries.ts
264
437
  function getEventRowById(db, id) {
265
438
  const row = db.prepare(
266
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
267
- recurrence_kind, anchor_month, anchor_day,
268
- anchor_contact_id, anchor_link_id,
269
- created_at, updated_at, deleted_at
439
+ `SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary
270
440
  FROM events
271
441
  WHERE id = ? AND deleted_at IS NULL`
272
442
  ).get(id);
273
443
  if (row === void 0) {
274
444
  return null;
275
445
  }
276
- return row;
446
+ const event = row;
447
+ event.summary = decodeEventText(event.summary);
448
+ if (event.content != null) event.content = decodeEventText(event.content);
449
+ return event;
277
450
  }
278
451
  function findDuplicateDateAnchor(db, params) {
279
452
  const exclude = params.excludeEventId;
@@ -341,19 +514,27 @@ function loadEventRecord(db, eventId) {
341
514
  throw new AffinityInvariantError("event not found");
342
515
  }
343
516
  const participants = loadEventParticipantViews(db, eventId);
344
- return mapEventRowToEventRecord(row, participants);
517
+ const record = mapEventRowToEventRecord(row, participants);
518
+ const due = db.prepare("SELECT due_at, due_on FROM open_commitments WHERE event_id = ?").get(eventId);
519
+ if (due) {
520
+ record.dueAt = due.due_at;
521
+ record.dueOn = due.due_on ?? (due.due_at === null ? null : new Date(due.due_at).toISOString().slice(0, 10));
522
+ }
523
+ enrichEventMetadata(db, record);
524
+ return record;
345
525
  }
346
- function batchLoadEventRecords(db, eventIds) {
526
+ function batchLoadEventRecords(db, eventIds, includeContent = false) {
347
527
  if (eventIds.length === 0) {
348
528
  return [];
349
529
  }
350
530
  const ph = eventIds.map(() => "?").join(", ");
351
531
  const eventRows = db.prepare(
352
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
532
+ `SELECT e.id, e.type, e.occurred_at, e.occurred_on, oc.due_at, oc.due_on, significance, moment_kind,
533
+ e.external_source,e.external_id,e.channel,e.parent_source,e.parent_ref_id,e.parent_event_id,e.thread_source,e.thread_id,
353
534
  recurrence_kind, anchor_month, anchor_day,
354
535
  anchor_contact_id, anchor_link_id,
355
- created_at, updated_at, deleted_at
356
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
536
+ e.created_at, e.updated_at, e.deleted_at, ${includeContent ? "CAST(e.content AS BLOB)" : "NULL"} AS content, CAST(e.summary AS BLOB) AS summary
537
+ FROM events e LEFT JOIN open_commitments oc ON oc.event_id = e.id WHERE e.id IN (${ph}) AND e.deleted_at IS NULL`
357
538
  ).all(...eventIds);
358
539
  const partRows = db.prepare(
359
540
  `SELECT event_id, contact_id, role, directionality
@@ -377,7 +558,9 @@ function batchLoadEventRecords(db, eventIds) {
377
558
  for (const id of eventIds) {
378
559
  const row = rowById.get(id);
379
560
  if (row) {
380
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
561
+ const record = mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []);
562
+ enrichEventMetadata(db, record);
563
+ out.push(record);
381
564
  }
382
565
  }
383
566
  return out;
@@ -436,6 +619,10 @@ function getContactJournal(db, contactId, options) {
436
619
  clauses.push(`e.type IN (${ph})`);
437
620
  params.push(...options.eventTypes);
438
621
  }
622
+ if (options?.channel !== void 0) {
623
+ clauses.push("e.channel = ?");
624
+ params.push(options.channel.trim().toLowerCase());
625
+ }
439
626
  const where = clauses.join(" AND ");
440
627
  const rows = db.prepare(
441
628
  `SELECT e.id FROM events e
@@ -446,8 +633,75 @@ function getContactJournal(db, contactId, options) {
446
633
  ).all(...params, limit, offset);
447
634
  return batchLoadEventRecords(
448
635
  db,
449
- rows.map((r) => r.id)
636
+ rows.map((r) => r.id),
637
+ options?.includeContent ?? false
638
+ );
639
+ }
640
+
641
+ // src/lib/parse_calendar_time.ts
642
+ var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d or -2d";
643
+ function isCommitmentOverdue(dueAt, dueOn, now) {
644
+ if (dueAt === null) return false;
645
+ if (dueOn && dueAt === Date.parse(`${dueOn}T23:59:59.999Z`)) {
646
+ return localCalendarDate(now) > dueOn;
647
+ }
648
+ return dueAt < now;
649
+ }
650
+ var OVERDUE_SQL = "(CASE WHEN oc.due_on IS NOT NULL AND oc.due_at = CAST(strftime('%s', oc.due_on) AS INTEGER) * 1000 + 86399999 THEN oc.due_on < ? ELSE oc.due_at < ? END)";
651
+ function localCalendarDate(now) {
652
+ const d = new Date(now);
653
+ return `${String(d.getFullYear()).padStart(4, "0")}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
654
+ }
655
+ function parseCalendarTime(value, field = "time", now = Date.now()) {
656
+ if (value == null && field.endsWith("occurredAt"))
657
+ throw new AffinityValidationError(
658
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
659
+ );
660
+ const fail = () => {
661
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
662
+ };
663
+ if (typeof value === "number" || typeof value === "string" && /^-?\d+$/.test(value.trim())) {
664
+ const timestamp2 = Number(value);
665
+ if (!Number.isSafeInteger(timestamp2) || !Number.isFinite(new Date(timestamp2).getTime()))
666
+ return fail();
667
+ return { timestamp: timestamp2, day: new Date(timestamp2).toISOString().slice(0, 10) };
668
+ }
669
+ if (typeof value !== "string") return fail();
670
+ let text = value.trim();
671
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text)) {
672
+ text = text.replace(" ", "T");
673
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(text)) text += "Z";
674
+ }
675
+ let rounding = 0;
676
+ text = text.replace(
677
+ /\.(\d{4,})(?=Z|[+-]\d{2}:\d{2}$)/,
678
+ (_, digits) => {
679
+ const ms = Math.round(Number(`0.${digits}`) * 1e3);
680
+ rounding = ms === 1e3 ? 1 : 0;
681
+ return `.${String(ms === 1e3 ? 999 : ms).padStart(3, "0")}`;
682
+ }
450
683
  );
684
+ if (/^(today|yesterday|tomorrow|[+-]\d+d)$/.test(text)) {
685
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
686
+ const date = /* @__PURE__ */ new Date(`${localCalendarDate(now)}T00:00:00Z`);
687
+ date.setUTCDate(date.getUTCDate() + offset);
688
+ if (!Number.isFinite(date.getTime())) return fail();
689
+ text = date.toISOString().slice(0, 10);
690
+ }
691
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
692
+ if (european) text = `${european[3]}-${european[2]}-${european[1]}`;
693
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(text);
694
+ if (!dateOnly && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
695
+ text
696
+ ))
697
+ return fail();
698
+ const day = text.slice(0, 10);
699
+ const midnight = Date.parse(`${day}T00:00:00Z`);
700
+ if (!Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== day)
701
+ return fail();
702
+ const timestamp = dateOnly ? midnight + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0) : Date.parse(text) + rounding;
703
+ if (!Number.isFinite(timestamp)) return fail();
704
+ return { timestamp, day };
451
705
  }
452
706
 
453
707
  // src/resolve_now.ts
@@ -518,9 +772,13 @@ function listOpenCommitments(db, filters, options) {
518
772
  clauses.push("oc.due_at <= ?");
519
773
  params.push(anchor + filters.horizonDays * DAY_MS);
520
774
  }
775
+ if (options?.channel !== void 0) {
776
+ clauses.push("e.channel=?");
777
+ params.push(options.channel.trim().toLowerCase());
778
+ }
521
779
  const where = clauses.join(" AND ");
522
780
  const rows = db.prepare(
523
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
781
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.due_on, oc.resolved_at, oc.resolution,
524
782
  e.type, e.summary
525
783
  FROM open_commitments oc
526
784
  INNER JOIN events e ON e.id = oc.event_id
@@ -537,7 +795,8 @@ function listOpenCommitments(db, filters, options) {
537
795
  participants: partsByEvent.get(r.event_id) ?? [],
538
796
  dueAt: r.due_at,
539
797
  dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
540
- overdue: r.due_at !== null && r.due_at < now,
798
+ dueOn: r.due_on ?? (r.due_at === null ? null : new Date(r.due_at).toISOString().slice(0, 10)),
799
+ overdue: isCommitmentOverdue(r.due_at, r.due_on, now),
541
800
  resolutionState: mapResolution(r.resolved_at, r.resolution)
542
801
  }));
543
802
  }
@@ -606,8 +865,10 @@ function utcMidnight(year, month1Based, day) {
606
865
  return Date.UTC(year, month1Based - 1, day, 0, 0, 0, 0);
607
866
  }
608
867
  function computeNextAnchorOccursOn(params) {
609
- const refStart = utcStartOfDayMs(params.referenceMs);
610
- const refYear = new Date(params.referenceMs).getUTCFullYear();
868
+ const refStart = Date.parse(
869
+ `${localCalendarDate(params.referenceMs)}T00:00:00Z`
870
+ );
871
+ const refYear = new Date(refStart).getUTCFullYear();
611
872
  for (let year2 = refYear; year2 <= refYear + 2; year2 += 1) {
612
873
  const day2 = effectiveAnchorDay(year2, params.anchorMonth, params.anchorDay);
613
874
  const candidate = utcMidnight(year2, params.anchorMonth, day2);
@@ -810,7 +1071,13 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
810
1071
  ORDER BY COALESCE(lr.normalized_rank, 0) DESC, l.trust DESC
811
1072
  LIMIT ?`
812
1073
  ).all(contactId, contactId, topLinksLimit);
813
- const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
1074
+ const topLinks = linkRows.map((l) => ({
1075
+ ...mapLinkRowToLinkListItem(l),
1076
+ counterpartyName: getContactRowById(
1077
+ db,
1078
+ l.from_contact_id === contactId ? l.to_contact_id : l.from_contact_id
1079
+ )?.name ?? ""
1080
+ }));
814
1081
  const activeDates = loadActiveDatesForContact(db, contactId);
815
1082
  const contactRollup = loadContactRollup(db, contactId);
816
1083
  const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
@@ -843,7 +1110,20 @@ function getContactProfile(db, contactId, options) {
843
1110
  return {
844
1111
  ...profile,
845
1112
  recentEvents: getContactJournal(db, contactId, {
846
- limit: options?.recentEventsLimit ?? 5
1113
+ limit: options?.recentEventsLimit ?? 5,
1114
+ eventTypes: [
1115
+ "conversation",
1116
+ "activity",
1117
+ "gift",
1118
+ "support",
1119
+ "milestone",
1120
+ "observation",
1121
+ "conflict",
1122
+ "correction",
1123
+ "transaction",
1124
+ "promise",
1125
+ "agreement"
1126
+ ]
847
1127
  }).map((event) => ({
848
1128
  ...event,
849
1129
  occurredAtIso: new Date(event.occurredAt).toISOString()
@@ -898,6 +1178,10 @@ function listMoments(db, filters, options) {
898
1178
  clauses.push("le.link_id = ?");
899
1179
  params.push(filters.linkId);
900
1180
  }
1181
+ if (options?.channel !== void 0) {
1182
+ clauses.push("e.channel=?");
1183
+ params.push(options.channel.trim().toLowerCase());
1184
+ }
901
1185
  const where = clauses.join(" AND ");
902
1186
  return db.prepare(
903
1187
  `SELECT
@@ -905,6 +1189,7 @@ function listMoments(db, filters, options) {
905
1189
  le.link_id,
906
1190
  le.moment_kind,
907
1191
  le.occurred_at,
1192
+ e.occurred_on,
908
1193
  e.summary,
909
1194
  le.impact_score
910
1195
  FROM link_event_effects le
@@ -917,6 +1202,7 @@ function listMoments(db, filters, options) {
917
1202
  linkId: row.link_id,
918
1203
  momentKind: row.moment_kind,
919
1204
  occurredAt: row.occurred_at,
1205
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
920
1206
  summary: row.summary,
921
1207
  impactScore: row.impact_score
922
1208
  }));
@@ -1004,6 +1290,10 @@ function getLinkTimeline(db, linkId, options) {
1004
1290
  clauses.push(`e.type IN (${ph})`);
1005
1291
  params.push(...options.eventTypes);
1006
1292
  }
1293
+ if (options?.channel !== void 0) {
1294
+ clauses.push("e.channel = ?");
1295
+ params.push(options.channel.trim().toLowerCase());
1296
+ }
1007
1297
  const where = clauses.join(" AND ");
1008
1298
  const rows = db.prepare(
1009
1299
  `SELECT e.id
@@ -1015,7 +1305,8 @@ function getLinkTimeline(db, linkId, options) {
1015
1305
  ).all(...params, limit, offset);
1016
1306
  return batchLoadEventRecords(
1017
1307
  db,
1018
- rows.map((r) => r.id)
1308
+ rows.map((r) => r.id),
1309
+ options?.includeContent ?? false
1019
1310
  );
1020
1311
  }
1021
1312
 
@@ -1188,26 +1479,34 @@ function refreshLinkRollup(db, linkId, now) {
1188
1479
  const latest = db.prepare(
1189
1480
  `SELECT event_id, occurred_at
1190
1481
  FROM link_event_effects
1191
- WHERE link_id = ? AND counts_for_recency = 1
1482
+ WHERE link_id = ? AND counts_for_recency = 1 AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))
1192
1483
  ORDER BY occurred_at DESC, event_id DESC
1193
1484
  LIMIT 1`
1194
- ).get(linkId);
1485
+ ).get(linkId, now, localCalendarDate(now));
1195
1486
  const trailingWindowStart = now - 180 * DAY_MS2;
1196
1487
  const counts = db.prepare(
1197
1488
  `SELECT
1198
1489
  COUNT(*) AS total_meaningful_events,
1199
1490
  SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
1200
1491
  FROM link_event_effects
1201
- WHERE link_id = ? AND occurred_at >= ?`
1202
- ).get(linkId, trailingWindowStart);
1492
+ WHERE link_id = ? AND occurred_at >= ? AND counts_for_recency = 1
1493
+ AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
1494
+ ).get(linkId, trailingWindowStart, now, localCalendarDate(now));
1203
1495
  const directionalityRows = db.prepare(
1204
1496
  `SELECT ep.directionality
1205
1497
  FROM link_event_effects le
1206
1498
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
1207
1499
  WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
1208
1500
  AND ep.directionality IS NOT NULL
1209
- AND le.occurred_at >= ?`
1210
- ).all(linkId, link.from_contact_id, trailingWindowStart);
1501
+ AND le.occurred_at >= ?
1502
+ AND le.event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
1503
+ ).all(
1504
+ linkId,
1505
+ link.from_contact_id,
1506
+ trailingWindowStart,
1507
+ now,
1508
+ localCalendarDate(now)
1509
+ );
1211
1510
  let outboundCount = 0;
1212
1511
  let inboundCount = 0;
1213
1512
  for (const row of directionalityRows) {
@@ -1358,37 +1657,8 @@ function getLinkDetail(db, linkId, options) {
1358
1657
  }
1359
1658
 
1360
1659
  // src/tools/parse_time_input.ts
1361
- var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
1362
1660
  function parseTimeInput(value, field = "time", now = Date.now()) {
1363
- const fail = () => {
1364
- throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1365
- };
1366
- if (typeof value === "number")
1367
- return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
1368
- if (typeof value !== "string") return fail();
1369
- const text = value.trim();
1370
- const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
1371
- let day;
1372
- if (relative) {
1373
- const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1374
- day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
1375
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
1376
- day = Date.parse(`${text}T00:00:00.000Z`);
1377
- if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
1378
- return fail();
1379
- } else {
1380
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1381
- text
1382
- ))
1383
- return fail();
1384
- const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
1385
- if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
1386
- return fail();
1387
- const parsed = Date.parse(text);
1388
- return Number.isFinite(parsed) ? parsed : fail();
1389
- }
1390
- const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
1391
- return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
1661
+ return parseCalendarTime(value, field, now).timestamp;
1392
1662
  }
1393
1663
 
1394
1664
  // src/tools/tool_metadata.ts
@@ -1459,6 +1729,39 @@ function oneOfSchema(schemas, description) {
1459
1729
  return schema;
1460
1730
  }
1461
1731
 
1732
+ // src/tools/event_metadata_schema.ts
1733
+ var externalRefSchema = objectSchema(
1734
+ {
1735
+ source: stringSchema("External source; trimmed and lowercased."),
1736
+ id: stringSchema("Non-empty external id.")
1737
+ },
1738
+ ["source", "id"]
1739
+ );
1740
+ var eventMetadataSchema = {
1741
+ affectsBond: booleanSchema(
1742
+ "Whether evidence affects the relationship; defaults false for observations with significance <=2."
1743
+ ),
1744
+ content: stringSchema(
1745
+ "Verbatim text, including empty text; at most 1 MB UTF-8. Summary may be omitted when content is supplied."
1746
+ ),
1747
+ externalRef: externalRefSchema,
1748
+ channel: stringSchema(
1749
+ "Free-text channel, trimmed and lowercased, at most 40 characters."
1750
+ ),
1751
+ parentRef: externalRefSchema,
1752
+ parentEventId: integerSchema("Parent event id."),
1753
+ threadRef: externalRefSchema,
1754
+ momentKind: enumSchema("Optional moment kind.", [
1755
+ "breakthrough",
1756
+ "rupture",
1757
+ "reconciliation",
1758
+ "milestone",
1759
+ "turning_point"
1760
+ ]),
1761
+ occurredOn: stringSchema("Calendar day."),
1762
+ provenance: {}
1763
+ };
1764
+
1462
1765
  // src/tools/tool_names.ts
1463
1766
  var searchAffinityToolName = "search_affinity";
1464
1767
  var reviewAffinityToolName = "review_affinity";
@@ -1472,6 +1775,7 @@ var recordEventToolName = "record_event";
1472
1775
  var manageCommitmentToolName = "manage_commitment";
1473
1776
  var manageDateAnchorToolName = "manage_date_anchor";
1474
1777
  var manageAttributeToolName = "manage_attribute";
1778
+ var manageEventToolName = "manage_event";
1475
1779
 
1476
1780
  // src/tools/tool_ref.ts
1477
1781
  function contactState(value) {
@@ -1516,7 +1820,7 @@ function toEventEntityRef(value) {
1516
1820
  return {
1517
1821
  kind: "event",
1518
1822
  id: value.id,
1519
- title: value.summary,
1823
+ title: deriveEventSummary(value.summary),
1520
1824
  subtitle: value.type,
1521
1825
  state: value.type
1522
1826
  };
@@ -1551,14 +1855,39 @@ function toReceiptEntityRefs(receipt) {
1551
1855
  }
1552
1856
 
1553
1857
  // src/identities/normalize.ts
1858
+ var handleIdentityTypes = /* @__PURE__ */ new Set([
1859
+ "handle",
1860
+ "bluesky",
1861
+ "x",
1862
+ "twitter",
1863
+ "mastodon",
1864
+ "github",
1865
+ "instagram",
1866
+ "telegram",
1867
+ "linkedin",
1868
+ "facebook",
1869
+ "youtube",
1870
+ "tiktok",
1871
+ "discord",
1872
+ "slack"
1873
+ ]);
1874
+ function normalizeIdentityValue(type, value) {
1875
+ const v = value.trim();
1876
+ return handleIdentityTypes.has(type.toLowerCase()) ? v.replace(/^@/, "").toLowerCase() : v;
1877
+ }
1554
1878
  function normalizeIdentityKey(type, value) {
1555
- const t = type.trim().toLowerCase();
1556
- const v = value.trim().toLowerCase();
1879
+ let t = type.trim().toLowerCase();
1880
+ if (["did", "atproto_did", "custom:atproto-did"].includes(t)) t = "did";
1881
+ const v = normalizeIdentityValue(t, value).toLowerCase();
1557
1882
  if (t.length === 0 || v.length === 0) {
1558
1883
  throw new AffinityValidationError("type and value must be non-empty");
1559
1884
  }
1560
1885
  return `${t}:${v}`;
1561
1886
  }
1887
+ function legacyIdentityKey(key) {
1888
+ const colon = key.indexOf(":");
1889
+ return handleIdentityTypes.has(key.slice(0, colon)) ? `${key.slice(0, colon + 1)}@${key.slice(colon + 1)}` : key;
1890
+ }
1562
1891
 
1563
1892
  // src/lib/errors/is_affinity_error.ts
1564
1893
  function isAffinityError(value) {
@@ -1680,7 +2009,7 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
1680
2009
  const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
1681
2010
  return {
1682
2011
  ok: false,
1683
- result: toolFailure("domain", "invalid_state", message, message)
2012
+ result: toolFailure("domain", "conflict", message, message)
1684
2013
  };
1685
2014
  }
1686
2015
  return { ok: true, value: row2 };
@@ -1705,9 +2034,13 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
1705
2034
  INNER JOIN contacts c ON c.id = i.contact_id
1706
2035
  WHERE i.removed_at IS NULL
1707
2036
  AND c.deleted_at IS NULL
1708
- AND i.normalized_key = ?
2037
+ AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))
1709
2038
  LIMIT 2`
1710
- ).all(normalizedKey);
2039
+ ).all(
2040
+ normalizedKey,
2041
+ legacyIdentityKey(normalizedKey),
2042
+ normalizedKey
2043
+ );
1711
2044
  if (row.length === 0) {
1712
2045
  return {
1713
2046
  ok: false,
@@ -1858,13 +2191,21 @@ function resolveLinkLocator(db, locator, field = "link") {
1858
2191
  return { ok: true, value: matchedLink };
1859
2192
  }
1860
2193
  function mapErrorToToolFailure(error, summary = "The tool failed.") {
2194
+ if (error && typeof error === "object" && "ok" in error && error.ok === false && "outcome" in error && error.outcome === "error")
2195
+ return error;
1861
2196
  if (isAffinityError(error)) {
1862
- return toolFailure(
2197
+ const result = toolFailure(
1863
2198
  "domain",
1864
2199
  affinityErrorCodeToToolCode(error),
1865
2200
  summary,
1866
2201
  error.message
1867
2202
  );
2203
+ const id = error.existingContactId;
2204
+ if (id !== void 0) {
2205
+ result.data = { existingContactId: id };
2206
+ result.error.details = { existingContactId: id };
2207
+ }
2208
+ return result;
1868
2209
  }
1869
2210
  if (error instanceof Error) {
1870
2211
  return toolFailure("system", "system_error", summary, error.message);
@@ -1903,27 +2244,32 @@ function affinityErrorCodeToToolCode(error) {
1903
2244
  }
1904
2245
 
1905
2246
  // src/tools/validate_tool_input.ts
1906
- function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
2247
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now(), strict = false) {
2248
+ if (path.endsWith(".occurredAt") && value == null)
2249
+ return "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)";
2250
+ const branches = schema.oneOf ?? schema.anyOf;
2251
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && value !== null && typeof value !== "string")
2252
+ return `attribute values are strings or null; pass "${String(value)}" to store it as text`;
1907
2253
  if (schema.anyOf || schema.oneOf) {
1908
2254
  const choices = schema.anyOf ?? schema.oneOf ?? [];
1909
2255
  return choices.some(
1910
- (choice) => validateToolInput(choice, value, path, injectedNow) === null
2256
+ (choice) => validateToolInput(choice, value, path, injectedNow, strict) === null
1911
2257
  ) ? null : `${path}: invalid value`;
1912
2258
  }
1913
2259
  if (schema.type === "object") {
1914
2260
  if (value === null || typeof value !== "object" || Array.isArray(value))
1915
2261
  return `${path}: missing input or expected object`;
1916
2262
  const object = value;
1917
- if (path.endsWith(".patch") && schema.additionalProperties === false) {
2263
+ if ((strict || path.endsWith(".patch")) && schema.additionalProperties === false) {
1918
2264
  for (const key of Object.keys(object)) {
1919
2265
  if (!Object.hasOwn(schema.properties ?? {}, key))
1920
- return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown patch key; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
2266
+ return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown ${path.endsWith(".patch") ? "patch key" : "key"}; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
1921
2267
  }
1922
2268
  }
1923
2269
  if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
1924
2270
  return `${path}.patch: missing required field`;
1925
2271
  if (object.patch !== void 0 && !schema.properties?.patch)
1926
- return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
2272
+ return `${path}.patch: unknown key; allowed keys: none (use the action's named fields)`;
1927
2273
  const referenceNow = object.now === void 0 ? injectedNow : (() => {
1928
2274
  try {
1929
2275
  return parseTimeInput(
@@ -1938,11 +2284,14 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
1938
2284
  for (const [key, child] of Object.entries(schema.properties ?? {})) {
1939
2285
  if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
1940
2286
  try {
1941
- object[key] = parseTimeInput(
2287
+ const parsed = parseCalendarTime(
1942
2288
  object[key],
1943
2289
  `${path}.${key}`,
1944
2290
  referenceNow
1945
2291
  );
2292
+ object[key] = parsed.timestamp;
2293
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key))
2294
+ object[key.replace(/At$/, "On")] ??= parsed.day;
1946
2295
  } catch (error) {
1947
2296
  return error.message;
1948
2297
  }
@@ -1958,7 +2307,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
1958
2307
  child,
1959
2308
  object[key],
1960
2309
  `${path}.${key}`,
1961
- referenceNow
2310
+ referenceNow,
2311
+ strict
1962
2312
  );
1963
2313
  if (error) return error;
1964
2314
  }
@@ -1971,7 +2321,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
1971
2321
  schema.items ?? {},
1972
2322
  item,
1973
2323
  `${path}[${index}]`,
1974
- injectedNow
2324
+ injectedNow,
2325
+ strict
1975
2326
  );
1976
2327
  if (error) return error;
1977
2328
  }
@@ -2037,6 +2388,26 @@ function inspectAffinityItemToolHandler(db, rawInput) {
2037
2388
  inputError
2038
2389
  );
2039
2390
  return withToolHandling(() => {
2391
+ if (input.kind === "event") {
2392
+ if (input.eventId === void 0 === (input.ref === void 0))
2393
+ return toolFailure(
2394
+ "protocol",
2395
+ "invalid_input",
2396
+ "Invalid event target.",
2397
+ "Provide exactly one of eventId or ref."
2398
+ );
2399
+ const detail2 = input.ref ? getEventByExternalRef(db, input.ref.source, input.ref.id) : getEventById(db, input.eventId);
2400
+ return detail2 ? toolSuccess(
2401
+ `Loaded event #${detail2.id}.`,
2402
+ { kind: "event", detail: detail2 },
2403
+ { entities: [{ kind: "event", id: detail2.id }] }
2404
+ ) : toolFailure(
2405
+ "domain",
2406
+ "not_found",
2407
+ "Event not found.",
2408
+ "No event matches the target."
2409
+ );
2410
+ }
2040
2411
  if (input.kind === "owner_profile") {
2041
2412
  const detail2 = getOwnerProfile(
2042
2413
  db,
@@ -2134,10 +2505,13 @@ var inspectAffinityItemTool = defineAffinityTool({
2134
2505
  inputSchema: objectSchema(
2135
2506
  {
2136
2507
  kind: enumSchema("What to inspect.", [
2508
+ "event",
2137
2509
  "owner_profile",
2138
2510
  "contact_profile",
2139
2511
  "link"
2140
2512
  ]),
2513
+ eventId: integerSchema("Exact event id."),
2514
+ ref: externalRefSchema,
2141
2515
  contact: contactLocatorSchema(
2142
2516
  "Target contact. Required when kind=contact_profile. Provide contactId or identity."
2143
2517
  ),
@@ -2156,6 +2530,7 @@ var inspectAffinityItemTool = defineAffinityTool({
2156
2530
  // src/attributes/helpers.ts
2157
2531
  function clearedAttributePrimary(target) {
2158
2532
  return {
2533
+ ...target.kind === "event" ? { eventId: target.eventId } : {},
2159
2534
  id: 0,
2160
2535
  contactId: target.kind === "contact" ? target.id : null,
2161
2536
  linkId: target.kind === "link" ? target.id : null,
@@ -2167,6 +2542,7 @@ function clearedAttributePrimary(target) {
2167
2542
  // src/attributes/mappers.ts
2168
2543
  function mapAttributeRowToAttributeRecord(row) {
2169
2544
  return {
2545
+ ...row.event_id == null ? {} : { eventId: row.event_id },
2170
2546
  id: row.id,
2171
2547
  contactId: row.contact_id,
2172
2548
  linkId: row.link_id,
@@ -2176,136 +2552,55 @@ function mapAttributeRowToAttributeRecord(row) {
2176
2552
  }
2177
2553
 
2178
2554
  // src/attributes/queries.ts
2555
+ function attributeTargetSql(target) {
2556
+ return {
2557
+ column: target.kind === "event" ? "event_id" : target.kind === "contact" ? "contact_id" : "link_id",
2558
+ id: target.kind === "event" ? target.eventId : target.id
2559
+ };
2560
+ }
2179
2561
  function getAttributeRowById(db, id) {
2180
- const row = db.prepare(
2181
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2182
- FROM attributes WHERE id = ?`
2183
- ).get(id);
2184
- if (row === void 0) {
2185
- return null;
2186
- }
2187
- return row;
2562
+ return db.prepare("SELECT * FROM attributes WHERE id=?").get(id) ?? null;
2188
2563
  }
2189
2564
  function getLiveAttributeRow(db, target, name) {
2190
- if (target.kind === "contact") {
2191
- const row2 = db.prepare(
2192
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2193
- FROM attributes
2194
- WHERE contact_id = ? AND name = ? AND deleted_at IS NULL`
2195
- ).get(target.id, name);
2196
- if (row2 === void 0) {
2197
- return null;
2198
- }
2199
- return row2;
2200
- }
2201
- const row = db.prepare(
2202
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2203
- FROM attributes
2204
- WHERE link_id = ? AND name = ? AND deleted_at IS NULL`
2205
- ).get(target.id, name);
2206
- if (row === void 0) {
2207
- return null;
2208
- }
2209
- return row;
2565
+ const t = attributeTargetSql(target);
2566
+ return db.prepare(
2567
+ `SELECT * FROM attributes WHERE ${t.column}=? AND name=? AND deleted_at IS NULL`
2568
+ ).get(t.id, name) ?? null;
2210
2569
  }
2211
2570
  function listLiveAttributeIdsForTarget(db, target) {
2212
- if (target.kind === "contact") {
2213
- const rows2 = db.prepare(
2214
- "SELECT id FROM attributes WHERE contact_id = ? AND deleted_at IS NULL ORDER BY id"
2215
- ).all(target.id);
2216
- return rows2.map((row) => row.id);
2217
- }
2218
- const rows = db.prepare(
2219
- "SELECT id FROM attributes WHERE link_id = ? AND deleted_at IS NULL ORDER BY id"
2220
- ).all(target.id);
2221
- return rows.map((row) => row.id);
2571
+ const t = attributeTargetSql(target);
2572
+ return db.prepare(
2573
+ `SELECT id FROM attributes WHERE ${t.column}=? AND deleted_at IS NULL ORDER BY id`
2574
+ ).all(t.id).map((r) => r.id);
2222
2575
  }
2223
2576
 
2224
- // src/lib/normalize_mutation_receipt.ts
2225
- function uniqueEntityRefs(refs) {
2226
- const out = [];
2227
- const seen = /* @__PURE__ */ new Set();
2228
- for (const ref of refs ?? []) {
2229
- const key = `${ref.kind}:${ref.id}`;
2230
- if (seen.has(key)) {
2231
- continue;
2232
- }
2233
- seen.add(key);
2234
- out.push(ref);
2235
- }
2236
- return out;
2577
+ // src/attributes/receipts.ts
2578
+ function buildAttributeMutationReceipt(primary, partial) {
2579
+ const normalized = normalizeMutationReceiptPartial(partial);
2580
+ return {
2581
+ primary,
2582
+ created: normalized.created,
2583
+ updated: normalized.updated,
2584
+ archived: normalized.archived,
2585
+ removed: normalized.removed,
2586
+ affectedLinks: normalized.affectedLinks,
2587
+ derivedEffects: normalized.derivedEffects
2588
+ };
2237
2589
  }
2238
- function uniqueDerivedEffects(effects) {
2239
- const out = [];
2240
- const seen = /* @__PURE__ */ new Set();
2241
- for (const effect of effects ?? []) {
2242
- const key = `${effect.eventId}:${effect.linkId}`;
2243
- if (seen.has(key)) {
2244
- continue;
2245
- }
2246
- seen.add(key);
2247
- out.push(effect);
2248
- }
2249
- return out;
2250
- }
2251
- function uniqueAffectedLinks(affectedLinks, derivedEffects) {
2252
- const out = [];
2253
- const seen = /* @__PURE__ */ new Set();
2254
- for (const linkId of affectedLinks ?? []) {
2255
- if (seen.has(linkId)) {
2256
- continue;
2257
- }
2258
- seen.add(linkId);
2259
- out.push(linkId);
2260
- }
2261
- for (const effect of derivedEffects) {
2262
- if (seen.has(effect.linkId)) {
2263
- continue;
2264
- }
2265
- seen.add(effect.linkId);
2266
- out.push(effect.linkId);
2267
- }
2268
- return out;
2269
- }
2270
- function normalizeMutationReceiptPartial(partial) {
2271
- const derivedEffects = uniqueDerivedEffects(partial?.derivedEffects);
2272
- return {
2273
- created: uniqueEntityRefs(partial?.created),
2274
- updated: uniqueEntityRefs(partial?.updated),
2275
- archived: uniqueEntityRefs(partial?.archived),
2276
- removed: uniqueEntityRefs(partial?.removed),
2277
- affectedLinks: uniqueAffectedLinks(partial?.affectedLinks, derivedEffects),
2278
- derivedEffects
2279
- };
2280
- }
2281
-
2282
- // src/attributes/receipts.ts
2283
- function buildAttributeMutationReceipt(primary, partial) {
2284
- const normalized = normalizeMutationReceiptPartial(partial);
2285
- return {
2286
- primary,
2287
- created: normalized.created,
2288
- updated: normalized.updated,
2289
- archived: normalized.archived,
2290
- removed: normalized.removed,
2291
- affectedLinks: normalized.affectedLinks,
2292
- derivedEffects: normalized.derivedEffects
2293
- };
2294
- }
2295
-
2296
- // src/links/validators.ts
2297
- var STATES = /* @__PURE__ */ new Set([
2298
- "active",
2299
- "dormant",
2300
- "strained",
2301
- "broken",
2302
- "archived"
2303
- ]);
2304
- function assertContactEndpointsNotMerged(db, fromContactId, toContactId) {
2305
- const from = getContactRowById(db, fromContactId);
2306
- const to = getContactRowById(db, toContactId);
2307
- if (!from || !to) {
2308
- throw new AffinityNotFoundError("contact not found");
2590
+
2591
+ // src/links/validators.ts
2592
+ var STATES = /* @__PURE__ */ new Set([
2593
+ "active",
2594
+ "dormant",
2595
+ "strained",
2596
+ "broken",
2597
+ "archived"
2598
+ ]);
2599
+ function assertContactEndpointsNotMerged(db, fromContactId, toContactId) {
2600
+ const from = getContactRowById(db, fromContactId);
2601
+ const to = getContactRowById(db, toContactId);
2602
+ if (!from || !to) {
2603
+ throw new AffinityNotFoundError("contact not found");
2309
2604
  }
2310
2605
  if (from.lifecycle_state === "merged" || to.lifecycle_state === "merged") {
2311
2606
  throw new AffinityStateError("merged contact is read-only");
@@ -2341,6 +2636,8 @@ function normalizeAttributeValue(value) {
2341
2636
  if (value === null) {
2342
2637
  return null;
2343
2638
  }
2639
+ if (typeof value !== "string")
2640
+ throw new AffinityValidationError("attribute values are strings or null");
2344
2641
  const trimmed = value.trim();
2345
2642
  return trimmed.length === 0 ? null : trimmed;
2346
2643
  }
@@ -2369,6 +2666,11 @@ function validateAttributeEntries(entries) {
2369
2666
  return out;
2370
2667
  }
2371
2668
  function assertAttributeTargetWritable(db, target) {
2669
+ if (target.kind === "event") {
2670
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(target.eventId))
2671
+ throw new AffinityNotFoundError("event not found");
2672
+ return;
2673
+ }
2372
2674
  if (target.kind === "contact") {
2373
2675
  const contact = getContactRowById(db, target.id);
2374
2676
  if (!contact) {
@@ -2404,26 +2706,14 @@ function replaceAttributes(db, target, entries) {
2404
2706
  }
2405
2707
  const created = [];
2406
2708
  let lastId = 0;
2407
- if (target.kind === "contact") {
2408
- const insert = db.prepare(
2409
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2410
- VALUES (?, NULL, ?, ?, ?, ?)`
2411
- );
2412
- for (const e of normalized) {
2413
- const r = insert.run(target.id, e.name, e.value, now, now);
2414
- lastId = Number(r.lastInsertRowid);
2415
- created.push({ kind: "attribute", id: lastId });
2416
- }
2417
- } else {
2418
- const insert = db.prepare(
2419
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2420
- VALUES (NULL, ?, ?, ?, ?, ?)`
2421
- );
2422
- for (const e of normalized) {
2423
- const r = insert.run(target.id, e.name, e.value, now, now);
2424
- lastId = Number(r.lastInsertRowid);
2425
- created.push({ kind: "attribute", id: lastId });
2426
- }
2709
+ const t = attributeTargetSql(target);
2710
+ const insert = db.prepare(
2711
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
2712
+ );
2713
+ for (const e of normalized) {
2714
+ const r = insert.run(t.id, e.name, e.value, now, now);
2715
+ lastId = Number(r.lastInsertRowid);
2716
+ created.push({ kind: "attribute", id: lastId });
2427
2717
  }
2428
2718
  if (normalized.length === 0) {
2429
2719
  return buildAttributeMutationReceipt(clearedAttributePrimary(target), {
@@ -2468,27 +2758,10 @@ function setAttribute(db, target, name, value) {
2468
2758
  }
2469
2759
  );
2470
2760
  }
2471
- if (target.kind === "contact") {
2472
- const r2 = db.prepare(
2473
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2474
- VALUES (?, NULL, ?, ?, ?, ?)`
2475
- ).run(target.id, key, stored, now, now);
2476
- const id2 = Number(r2.lastInsertRowid);
2477
- const row2 = getAttributeRowById(db, id2);
2478
- if (!row2) {
2479
- throw new AffinityInvariantError("attribute row missing after insert");
2480
- }
2481
- return buildAttributeMutationReceipt(
2482
- mapAttributeRowToAttributeRecord(row2),
2483
- {
2484
- created: [{ kind: "attribute", id: id2 }]
2485
- }
2486
- );
2487
- }
2761
+ const t = attributeTargetSql(target);
2488
2762
  const r = db.prepare(
2489
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2490
- VALUES (NULL, ?, ?, ?, ?, ?)`
2491
- ).run(target.id, key, stored, now, now);
2763
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
2764
+ ).run(t.id, key, stored, now, now);
2492
2765
  const id = Number(r.lastInsertRowid);
2493
2766
  const row = getAttributeRowById(db, id);
2494
2767
  if (!row) {
@@ -2544,14 +2817,15 @@ function mutationToolResult(action, receipt, entitySummary, fields) {
2544
2817
  affectedLinks: receipt.affectedLinks,
2545
2818
  derivedEffects: receipt.derivedEffects
2546
2819
  };
2547
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
2820
+ const summary = changedCount === 0 ? receipt.primary && typeof receipt.primary === "object" && "externalRef" in receipt.primary && "id" in receipt.primary ? `Event already recorded (#${receipt.primary.id}, ${receipt.primary.externalRef.source} ${receipt.primary.externalRef.id}).` : `No ${entitySummary} changes were needed.` : describeChanges(receipt);
2548
2821
  if (changedCount === 0) {
2549
2822
  return toolNoOp(summary, data, {
2550
2823
  entities: toReceiptEntityRefs(receipt)
2551
2824
  });
2552
2825
  }
2553
2826
  return toolSuccess(summary, data, {
2554
- entities: toReceiptEntityRefs(receipt)
2827
+ entities: toReceiptEntityRefs(receipt),
2828
+ warnings: receipt.warnings
2555
2829
  });
2556
2830
  }
2557
2831
  var PLURALS = { identity: "identities" };
@@ -2608,6 +2882,7 @@ function linkLocatorSchema2(description) {
2608
2882
  };
2609
2883
  }
2610
2884
  function resolveAttributeTarget(db, target) {
2885
+ if (target.kind === "event") return { kind: "ok", target };
2611
2886
  if (target.kind === "contact") {
2612
2887
  const contact = resolveContactLocator(db, target.contact, "target.contact");
2613
2888
  if (!contact.ok) {
@@ -2667,7 +2942,7 @@ function manageAttributeToolHandler(db, rawInput) {
2667
2942
  }
2668
2943
  var manageAttributeTool = defineAffinityTool({
2669
2944
  name: manageAttributeToolName,
2670
- 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.",
2945
+ description: "Set, unset, or replace custom metadata attributes on contacts, links and events. For emails, phones, handles, and URLs use manage_identity instead \u2014 those are routing keys, not attributes.",
2671
2946
  whenToUse: "Use this for metadata such as tags, notes, or small labeled facts attached to a contact or link.",
2672
2947
  whenNotToUse: "Do not use this for core contact fields, identities, or direct relationship mechanics.",
2673
2948
  sideEffects: "writes_state",
@@ -2688,7 +2963,8 @@ var manageAttributeTool = defineAffinityTool({
2688
2963
  action: enumSchema("Operation to perform.", ["set", "unset", "replace"]),
2689
2964
  target: objectSchema(
2690
2965
  {
2691
- kind: enumSchema("Target kind.", ["contact", "link"]),
2966
+ kind: enumSchema("Target kind.", ["contact", "link", "event"]),
2967
+ eventId: integerSchema("Target event id."),
2692
2968
  contact: contactLocatorSchema2(
2693
2969
  "Contact locator. Required when kind=contact. Provide contactId or identity."
2694
2970
  ),
@@ -2718,7 +2994,7 @@ var manageAttributeTool = defineAffinityTool({
2718
2994
  removedAt: integerSchema("Removal timestamp. Optional for action=unset.")
2719
2995
  },
2720
2996
  ["action"],
2721
- "Set, unset, or replace attributes on contacts and links."
2997
+ "Set, unset, or replace attributes on contacts, links and events."
2722
2998
  ),
2723
2999
  handler: manageAttributeToolHandler
2724
3000
  });
@@ -2726,7 +3002,7 @@ var manageAttributeTool = defineAffinityTool({
2726
3002
  // src/events/persistence.ts
2727
3003
  function insertJournalEvent(db, params) {
2728
3004
  const now = resolveNow(params.now);
2729
- const summary = params.summary.trim();
3005
+ const summary = params.summary?.trim() ?? deriveEventSummary(params.content ?? "");
2730
3006
  const result = db.prepare(
2731
3007
  `INSERT INTO events (
2732
3008
  type, occurred_at, summary, significance, moment_kind,
@@ -2743,6 +3019,33 @@ function insertJournalEvent(db, params) {
2743
3019
  now
2744
3020
  );
2745
3021
  const eventId = Number(result.lastInsertRowid);
3022
+ db.prepare("UPDATE events SET affects_bond=? WHERE id=?").run(
3023
+ params.affectsBond === void 0 ? params.type === "observation" && params.significance <= 2 ? 0 : 1 : Number(params.affectsBond),
3024
+ eventId
3025
+ );
3026
+ db.prepare("UPDATE events SET declared_moment_kind=? WHERE id=?").run(
3027
+ params.momentKind,
3028
+ eventId
3029
+ );
3030
+ db.prepare(
3031
+ "UPDATE events SET content=?, external_source=?, external_id=?, channel=?, parent_source=?, parent_ref_id=?, parent_event_id=?, thread_source=?, thread_id=?, provenance_json=? WHERE id=?"
3032
+ ).run(
3033
+ params.content ?? null,
3034
+ params.externalRef?.source ?? null,
3035
+ params.externalRef?.id ?? null,
3036
+ params.channel ?? null,
3037
+ params.parentRef?.source ?? null,
3038
+ params.parentRef?.id ?? null,
3039
+ params.parentEventId ?? null,
3040
+ params.threadRef?.source ?? null,
3041
+ params.threadRef?.id ?? null,
3042
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
3043
+ eventId
3044
+ );
3045
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3046
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3047
+ eventId
3048
+ );
2746
3049
  const insertParticipant = db.prepare(
2747
3050
  `INSERT INTO event_participants (
2748
3051
  event_id, contact_id, role, directionality
@@ -2785,6 +3088,10 @@ function insertDateAnchorEvent(db, params) {
2785
3088
  now
2786
3089
  );
2787
3090
  const eventId = Number(result.lastInsertRowid);
3091
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3092
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3093
+ eventId
3094
+ );
2788
3095
  const insertParticipant = db.prepare(
2789
3096
  `INSERT INTO event_participants (
2790
3097
  event_id, contact_id, role, directionality
@@ -2805,20 +3112,6 @@ function insertDateAnchorEvent(db, params) {
2805
3112
  return eventId;
2806
3113
  }
2807
3114
 
2808
- // src/events/receipts.ts
2809
- function buildEventMutationReceipt(primary, partial) {
2810
- const normalized = normalizeMutationReceiptPartial(partial);
2811
- return {
2812
- primary,
2813
- created: normalized.created,
2814
- updated: normalized.updated,
2815
- archived: normalized.archived,
2816
- removed: normalized.removed,
2817
- affectedLinks: normalized.affectedLinks,
2818
- derivedEffects: normalized.derivedEffects
2819
- };
2820
- }
2821
-
2822
3115
  // src/graph/betweenness.ts
2823
3116
  function computeNodeBetweenness(adjacency) {
2824
3117
  const nodes = [...adjacency.keys()];
@@ -3417,6 +3710,8 @@ function deriveStateAfter(params) {
3417
3710
  return params.stateBefore;
3418
3711
  }
3419
3712
  function deriveMomentKind(params) {
3713
+ if (params.eventType === "observation" || params.eventType === "milestone" && params.significance < 7)
3714
+ return null;
3420
3715
  if (params.rankAfter > params.rankBefore) {
3421
3716
  return "breakthrough";
3422
3717
  }
@@ -3446,6 +3741,18 @@ function applyEventEffectToLink(db, params) {
3446
3741
  if (!link || link.is_structural !== 0) {
3447
3742
  throw new AffinityStateError("relational link not found");
3448
3743
  }
3744
+ db.prepare(
3745
+ "INSERT OR IGNORE INTO event_link_baselines(link_id,state_json) VALUES (?,?)"
3746
+ ).run(
3747
+ params.linkId,
3748
+ JSON.stringify({
3749
+ rank: link.rank ?? 0,
3750
+ affinity: link.affinity ?? 0,
3751
+ trust: link.trust ?? 0,
3752
+ state: link.state ?? "active",
3753
+ cadence_days: link.cadence_days
3754
+ })
3755
+ );
3449
3756
  const stateBefore = link.state ?? "active";
3450
3757
  const linkKind = link.kind;
3451
3758
  const rankBefore = link.rank ?? 0;
@@ -3490,7 +3797,7 @@ function applyEventEffectToLink(db, params) {
3490
3797
  );
3491
3798
  const weightedBase = base * massPenalty(
3492
3799
  weeklyPositiveBaseWeight(db, params.linkId, params.occurredAt)
3493
- ) * salienceMultiplier;
3800
+ ) * salienceMultiplier * (params.participant.directionality === "owner_initiated" ? 0.05 : 1);
3494
3801
  const affinityGain = weightedBase * Math.max(eventValence, 0) * (0.55 + 0.45 * eventIntimacyDepth) * (0.7 + 0.3 * participantReciprocity) * (1 / (1 + 0.22 * rankBefore));
3495
3802
  const affinityLoss = weightedBase * Math.max(-eventValence, 0) * 0.35;
3496
3803
  const affinityMass = clamp3(
@@ -3531,15 +3838,16 @@ function applyEventEffectToLink(db, params) {
3531
3838
  const latest = db.prepare(
3532
3839
  `SELECT occurred_at
3533
3840
  FROM link_event_effects
3534
- WHERE link_id = ?
3841
+ WHERE link_id = ? AND counts_for_recency = 1 AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))
3535
3842
  ORDER BY occurred_at DESC, event_id DESC
3536
3843
  LIMIT 1`
3537
- ).get(params.linkId);
3844
+ ).get(params.linkId, params.now, localCalendarDate(params.now));
3538
3845
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
3539
3846
  1,
3540
3847
  Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
3541
3848
  );
3542
- const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
3849
+ const eventDay = db.prepare("SELECT occurred_on FROM events WHERE id = ?").get(params.eventId)?.occurred_on;
3850
+ const cadenceAfter = commitmentResolution != null || params.eventType === "promise" || params.eventType === "agreement" || params.occurredAt > params.now && (eventDay == null || eventDay > localCalendarDate(params.now)) ? cadenceBefore : nextCadenceDays({
3543
3851
  kind: linkKind,
3544
3852
  currentCadenceDays: cadenceBefore,
3545
3853
  gapDays
@@ -3559,7 +3867,11 @@ function applyEventEffectToLink(db, params) {
3559
3867
  }
3560
3868
  const affinityDelta = affinityAfter - affinityBefore;
3561
3869
  const trustDelta = trustAfter - trustBefore;
3562
- const momentKind = deriveMomentKind({
3870
+ const eventLabel = db.prepare("SELECT summary FROM events WHERE id=?").get(params.eventId)?.summary;
3871
+ const followLike = params.eventType === "milestone" && typeof eventLabel === "string" && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
3872
+ eventLabel
3873
+ );
3874
+ const momentKind = followLike ? null : deriveMomentKind({
3563
3875
  rankBefore,
3564
3876
  rankAfter,
3565
3877
  stateBefore,
@@ -3602,7 +3914,15 @@ function applyEventEffectToLink(db, params) {
3602
3914
  params.now,
3603
3915
  params.now
3604
3916
  );
3605
- if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
3917
+ db.prepare(
3918
+ "UPDATE link_event_effects SET participant_json=?,provenance_json=? WHERE event_id=? AND link_id=?"
3919
+ ).run(
3920
+ JSON.stringify(params.participant),
3921
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
3922
+ params.eventId,
3923
+ params.linkId
3924
+ );
3925
+ if (commitmentResolution !== void 0 && commitmentResolution !== null || params.eventType === "promise" || params.eventType === "agreement" || params.participant.directionality === "owner_initiated") {
3606
3926
  db.prepare(
3607
3927
  "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
3608
3928
  ).run(params.eventId, params.linkId);
@@ -3633,6 +3953,7 @@ function applyEventEffectToLink(db, params) {
3633
3953
 
3634
3954
  // src/lib/errors/affinity_conflict_error.ts
3635
3955
  var AffinityConflictError = class extends AffinityError {
3956
+ existingContactId;
3636
3957
  code = "CONFLICT";
3637
3958
  };
3638
3959
 
@@ -3687,6 +4008,8 @@ function buildLinkMutationReceipt(primary, partial) {
3687
4008
  // src/links/seed_social_link.ts
3688
4009
  function seedSocialLink(db, input) {
3689
4010
  return withTransaction(db, () => {
4011
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
4012
+ throw new AffinityValidationError("ifExists must be error or return");
3690
4013
  if (!isRelationalLinkKind(input.kind)) {
3691
4014
  throw new AffinityValidationError("invalid relational link kind");
3692
4015
  }
@@ -3694,11 +4017,14 @@ function seedSocialLink(db, input) {
3694
4017
  throw new AffinityValidationError("from and to contacts must differ");
3695
4018
  }
3696
4019
  assertContactEndpointsNotMerged(db, input.fromContactId, input.toContactId);
3697
- if (findLiveRelationalLinkAnyDirection(
4020
+ const existing = findLiveRelationalLinkAnyDirection(
3698
4021
  db,
3699
4022
  input.fromContactId,
3700
4023
  input.toContactId
3701
- )) {
4024
+ );
4025
+ if (existing) {
4026
+ if (input.ifExists === "return")
4027
+ return buildLinkMutationReceipt(mapLinkRowToLinkListItem(existing), {});
3702
4028
  throw new AffinityConflictError(
3703
4029
  "relational link already exists between these contacts"
3704
4030
  );
@@ -3804,15 +4130,36 @@ function ensureRelationalLink(db, params) {
3804
4130
  };
3805
4131
  }
3806
4132
  function applyJournalEventMechanics(db, params) {
4133
+ const bond = db.prepare("SELECT affects_bond, channel FROM events WHERE id=?").get(params.eventId);
4134
+ const ambient = params.provenance && typeof params.provenance === "object" && (params.provenance.ambient === true || params.provenance.tags?.includes("ambient"));
4135
+ if (bond?.affects_bond === 0 || bond?.affects_bond == null && params.eventType === "observation" && params.significance <= 2 || ambient)
4136
+ return { created: [], affectedLinks: [], derivedEffects: [] };
3807
4137
  const ownerId = requireOwnerContactId(db);
3808
- const participantIds = params.participants.map(
4138
+ const owner = params.participants.find((p) => p.contactId === ownerId);
4139
+ const socialChannel = [
4140
+ "bluesky",
4141
+ "x",
4142
+ "twitter",
4143
+ "mastodon",
4144
+ "github",
4145
+ "instagram"
4146
+ ].includes(String(bond?.channel));
4147
+ const direction = owner?.directionality ?? (socialChannel ? owner?.role === "actor" ? "owner_initiated" : owner?.role === "recipient" ? "other_initiated" : void 0 : void 0);
4148
+ const participants = params.participants.map(
4149
+ (p) => direction && p.directionality === void 0 ? { ...p, directionality: direction } : p
4150
+ );
4151
+ if (direction)
4152
+ db.prepare(
4153
+ "UPDATE event_participants SET directionality=? WHERE event_id=? AND directionality IS NULL"
4154
+ ).run(direction, params.eventId);
4155
+ const participantIds = participants.map(
3809
4156
  (participant) => participant.contactId
3810
4157
  );
3811
4158
  const hasOwner = participantIds.includes(ownerId);
3812
4159
  const created = [];
3813
4160
  const effectInputs = [];
3814
4161
  if (hasOwner) {
3815
- for (const participant of params.participants) {
4162
+ for (const participant of participants) {
3816
4163
  if (participant.contactId === ownerId) {
3817
4164
  continue;
3818
4165
  }
@@ -3820,13 +4167,14 @@ function applyJournalEventMechanics(db, params) {
3820
4167
  fromContactId: ownerId,
3821
4168
  toContactId: participant.contactId,
3822
4169
  kind: inferAutoLinkKind(db, ownerId, participant.contactId),
4170
+ ...(participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality) === "owner_initiated" ? { affinity: 0.15 } : {},
3823
4171
  now: params.now
3824
4172
  });
3825
4173
  created.push(...ensured.created);
3826
4174
  effectInputs.push({ linkId: ensured.linkId, participant });
3827
4175
  }
3828
- } else if (params.eventType === "observation" && params.participants.length === 2) {
3829
- const [first, second] = params.participants;
4176
+ } else if (params.eventType === "observation" && participants.length === 2) {
4177
+ const [first, second] = participants;
3830
4178
  if (first !== void 0 && second !== void 0) {
3831
4179
  const a = Math.min(first.contactId, second.contactId);
3832
4180
  const b = Math.max(first.contactId, second.contactId);
@@ -3861,7 +4209,10 @@ function applyJournalEventMechanics(db, params) {
3861
4209
  eventType: params.eventType,
3862
4210
  occurredAt: params.occurredAt,
3863
4211
  significance: params.significance,
3864
- participant: input.participant,
4212
+ participant: {
4213
+ ...input.participant,
4214
+ directionality: input.participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality
4215
+ },
3865
4216
  provenance: params.provenance,
3866
4217
  now: params.now
3867
4218
  })
@@ -3885,6 +4236,8 @@ function applyJournalEventMechanics(db, params) {
3885
4236
  function recordCommitment(db, input) {
3886
4237
  return withTransaction(db, () => {
3887
4238
  validateSocialEventInput(input);
4239
+ const existing = existingEventReceipt(db, input.externalRef);
4240
+ if (existing) return existing;
3888
4241
  if (input.commitmentType !== "promise" && input.commitmentType !== "agreement") {
3889
4242
  throw new AffinityValidationError(
3890
4243
  "commitmentType must be promise or agreement"
@@ -3899,11 +4252,12 @@ function recordCommitment(db, input) {
3899
4252
  assertOwnerParticipates(ownerId, ids);
3900
4253
  const now = resolveNow(input.now);
3901
4254
  const eventId = insertJournalEvent(db, {
4255
+ ...input,
3902
4256
  type: input.commitmentType,
3903
4257
  occurredAt: input.occurredAt,
3904
- summary: input.summary,
4258
+ occurredOn: input.occurredOn,
3905
4259
  significance: input.significance,
3906
- momentKind: null,
4260
+ momentKind: input.momentKind ?? null,
3907
4261
  participants: input.participants,
3908
4262
  now
3909
4263
  });
@@ -3912,15 +4266,25 @@ function recordCommitment(db, input) {
3912
4266
  event_id, commitment_type, due_at, created_at, updated_at
3913
4267
  ) VALUES (?, ?, ?, ?, ?)`
3914
4268
  ).run(eventId, input.commitmentType, input.dueAt ?? null, now, now);
4269
+ db.prepare("UPDATE open_commitments SET due_on = ? WHERE event_id = ?").run(
4270
+ input.dueAt == null ? null : input.dueOn ?? new Date(input.dueAt).toISOString().slice(0, 10),
4271
+ eventId
4272
+ );
3915
4273
  const mechanics = applyJournalEventMechanics(db, {
3916
4274
  eventId,
3917
4275
  eventType: input.commitmentType,
3918
4276
  occurredAt: input.occurredAt,
4277
+ occurredOn: input.occurredOn,
3919
4278
  significance: input.significance,
3920
4279
  participants: input.participants,
3921
4280
  provenance: input.provenance,
3922
4281
  now
3923
4282
  });
4283
+ if (input.momentKind !== void 0)
4284
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
4285
+ input.momentKind,
4286
+ eventId
4287
+ );
3924
4288
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
3925
4289
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
3926
4290
  affectedLinks: mechanics.affectedLinks,
@@ -3994,6 +4358,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
3994
4358
  const occurredAt = options?.resolvingEvent?.occurredAt ?? now;
3995
4359
  validateSocialEventInput({
3996
4360
  occurredAt,
4361
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
3997
4362
  summary,
3998
4363
  significance,
3999
4364
  participants,
@@ -4010,6 +4375,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4010
4375
  const resolvingEventId = insertJournalEvent(db, {
4011
4376
  type: eventType,
4012
4377
  occurredAt,
4378
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4013
4379
  summary,
4014
4380
  significance,
4015
4381
  momentKind: null,
@@ -4070,7 +4436,13 @@ function prepareParticipants(db, raw) {
4070
4436
  }
4071
4437
  function manageCommitmentToolHandler(db, rawInput) {
4072
4438
  const input = structuredClone(rawInput);
4073
- const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
4439
+ const inputError = validateToolInput(
4440
+ manageCommitmentTool.inputSchema,
4441
+ input,
4442
+ "input",
4443
+ Date.now(),
4444
+ true
4445
+ );
4074
4446
  if (inputError)
4075
4447
  return toolFailure(
4076
4448
  "protocol",
@@ -4100,6 +4472,21 @@ function manageCommitmentToolHandler(db, rawInput) {
4100
4472
  "commitment"
4101
4473
  );
4102
4474
  }
4475
+ if (!Number.isInteger(input.commitmentEventId))
4476
+ return toolFailure(
4477
+ "protocol",
4478
+ "invalid_input",
4479
+ "Missing commitment.",
4480
+ "commitmentEventId is required for resolve."
4481
+ );
4482
+ const event = db.prepare("SELECT type FROM events WHERE id = ? AND deleted_at IS NULL").get(input.commitmentEventId);
4483
+ if (event && !["promise", "agreement"].includes(event.type))
4484
+ return toolFailure(
4485
+ "domain",
4486
+ "invalid_input",
4487
+ "Invalid commitment.",
4488
+ "not a commitment event"
4489
+ );
4103
4490
  return mutationToolResult(
4104
4491
  "resolve",
4105
4492
  resolveCommitment(
@@ -4138,15 +4525,21 @@ var manageCommitmentTool = defineAffinityTool({
4138
4525
  "promise",
4139
4526
  "agreement"
4140
4527
  ]),
4528
+ ...eventMetadataSchema,
4141
4529
  occurredAt: integerSchema(
4142
4530
  "When the commitment was made, Unix timestamp in milliseconds."
4143
4531
  ),
4144
4532
  summary: stringSchema("Commitment summary. Must be non-empty."),
4145
4533
  significance: integerSchema("Commitment significance from 1 to 10."),
4146
4534
  dueAt: integerSchema("Optional due timestamp."),
4535
+ dueOn: stringSchema("Due calendar day."),
4147
4536
  participants: arraySchema(
4148
4537
  objectSchema(
4149
4538
  {
4539
+ contact: objectSchema(
4540
+ { contactId: integerSchema("Contact id.") },
4541
+ ["contactId"]
4542
+ ),
4150
4543
  contactId: integerSchema(
4151
4544
  "Participant contact id. The owner contact MUST be included."
4152
4545
  ),
@@ -4159,13 +4552,7 @@ var manageCommitmentTool = defineAffinityTool({
4159
4552
  ),
4160
4553
  now: integerSchema("Optional timestamp override.")
4161
4554
  },
4162
- [
4163
- "commitmentType",
4164
- "occurredAt",
4165
- "summary",
4166
- "significance",
4167
- "participants"
4168
- ],
4555
+ ["commitmentType", "occurredAt", "significance", "participants"],
4169
4556
  "Commitment payload. Required when action=record."
4170
4557
  ),
4171
4558
  commitmentEventId: integerSchema(
@@ -4180,6 +4567,19 @@ var manageCommitmentTool = defineAffinityTool({
4180
4567
  now: integerSchema("Optional timestamp."),
4181
4568
  resolvingEvent: objectSchema(
4182
4569
  {
4570
+ occurredOn: stringSchema("Calendar day."),
4571
+ provenance: {},
4572
+ participants: arraySchema(
4573
+ objectSchema(
4574
+ {
4575
+ contactId: integerSchema("Contact id."),
4576
+ role: stringSchema("Role."),
4577
+ directionality: stringSchema("Directionality.")
4578
+ },
4579
+ ["contactId", "role"]
4580
+ ),
4581
+ "Resolving participants."
4582
+ ),
4183
4583
  type: stringSchema("Optional resolving event type."),
4184
4584
  occurredAt: integerSchema("Optional resolving event timestamp."),
4185
4585
  summary: stringSchema("Optional resolving event summary."),
@@ -4260,30 +4660,46 @@ function deleteContact(db, locatorId, options) {
4260
4660
  });
4261
4661
  }
4262
4662
 
4263
- // src/contacts/export_contact.ts
4264
- function exportContact(db, id) {
4265
- const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
4663
+ // src/contacts/export_rows.ts
4664
+ function exportRows(db, sql, ...params) {
4665
+ return db.prepare(sql).all(...params).map((row) => {
4266
4666
  const result = {};
4267
4667
  for (const [key, value] of Object.entries(row)) {
4268
4668
  const name = key.replace(
4269
4669
  /_([a-z])/g,
4270
4670
  (_, letter) => letter.toUpperCase()
4271
4671
  );
4272
- result[name] = value;
4273
- if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
4672
+ result[name] = [
4673
+ "affects_bond",
4674
+ "is_owner",
4675
+ "is_structural",
4676
+ "verified",
4677
+ "counts_for_recency",
4678
+ "manual"
4679
+ ].includes(key) ? Boolean(value) : value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : value;
4680
+ if (name.endsWith("At") && typeof value === "number")
4274
4681
  result[`${name}Iso`] = new Date(value).toISOString();
4275
4682
  if (key.endsWith("_json") && typeof value === "string")
4276
4683
  result[name] = JSON.parse(value);
4277
4684
  }
4278
4685
  return result;
4279
4686
  });
4687
+ }
4688
+
4689
+ // src/contacts/export_contact.ts
4690
+ function exportContact(db, id) {
4691
+ const query = (sql, ...params) => exportRows(db, sql, ...params);
4280
4692
  const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
4281
4693
  if (!contact) throw new AffinityNotFoundError("contact not found");
4282
4694
  const events = query(
4283
- "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
4695
+ "SELECT DISTINCT e.*, CAST(e.content AS BLOB) AS content, CAST(e.summary AS BLOB) AS summary FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? AND e.type != 'date_anchor' ORDER BY e.id",
4284
4696
  id
4285
4697
  ).map((event) => ({
4286
4698
  ...event,
4699
+ attributes: query(
4700
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
4701
+ event.id
4702
+ ),
4287
4703
  participants: query(
4288
4704
  "SELECT p.*, c.name AS contact_name FROM event_participants p JOIN contacts c ON c.id = p.contact_id WHERE event_id = ? ORDER BY p.contact_id",
4289
4705
  event.id
@@ -4328,11 +4744,33 @@ function exportContact(db, id) {
4328
4744
  // src/contacts/export_affinity.ts
4329
4745
  function exportAffinity(db) {
4330
4746
  const now = Date.now();
4747
+ const records = db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id));
4748
+ const events = exportRows(
4749
+ db,
4750
+ "SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary FROM events ORDER BY id"
4751
+ ).map((event) => ({
4752
+ ...event,
4753
+ attributes: exportRows(
4754
+ db,
4755
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
4756
+ event.id
4757
+ ),
4758
+ participants: exportRows(
4759
+ db,
4760
+ "SELECT p.contact_id, p.role, c.name AS contact_name FROM event_participants p JOIN contacts c ON c.id = p.contact_id WHERE p.event_id = ? ORDER BY p.contact_id",
4761
+ event.id
4762
+ )
4763
+ }));
4331
4764
  return {
4332
4765
  version: 1,
4333
4766
  exportedAt: now,
4334
4767
  exportedAtIso: new Date(now).toISOString(),
4335
- contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
4768
+ events,
4769
+ contacts: records.map(({ events: events2, dates, ...record }) => ({
4770
+ ...record,
4771
+ eventIds: events2.map((event) => event.id),
4772
+ dateIds: dates.map((date) => date.id)
4773
+ }))
4336
4774
  };
4337
4775
  }
4338
4776
 
@@ -4405,7 +4843,7 @@ function assertValidLifecycleTransition(from, to) {
4405
4843
  }
4406
4844
  if (to === "merged") {
4407
4845
  throw new AffinityInvariantError(
4408
- "use manage_contact merge (CLI: contact merge) to reach merged state"
4846
+ "use merge_contacts (CLI: contact merge) to reach merged state"
4409
4847
  );
4410
4848
  }
4411
4849
  if (from === to) {
@@ -4457,67 +4895,6 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
4457
4895
  });
4458
4896
  }
4459
4897
 
4460
- // src/contacts/create_contact.ts
4461
- var KINDS = /* @__PURE__ */ new Set([
4462
- "human",
4463
- "group",
4464
- "company",
4465
- "team",
4466
- "pet",
4467
- "service",
4468
- "other"
4469
- ]);
4470
- function createContact(db, input) {
4471
- return withTransaction(db, () => {
4472
- const name = input.name.trim();
4473
- if (name.length === 0) {
4474
- throw new AffinityValidationError("name must be non-empty");
4475
- }
4476
- if (!KINDS.has(input.kind)) {
4477
- throw new AffinityValidationError("invalid contact kind");
4478
- }
4479
- if (input.isOwner !== void 0) {
4480
- throw new AffinityValidationError(
4481
- "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
4482
- );
4483
- }
4484
- const now = resolveNow(input.now);
4485
- if (input.bootstrapOwner) {
4486
- const existing = db.prepare(
4487
- "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
4488
- ).get();
4489
- if (existing !== void 0) {
4490
- throw new AffinityConflictError(
4491
- `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
4492
- );
4493
- }
4494
- }
4495
- const isOwner = input.bootstrapOwner ? 1 : 0;
4496
- const result = db.prepare(
4497
- `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
4498
- VALUES (?, ?, 'active', ?, ?, ?)`
4499
- ).run(name, input.kind, isOwner, now, now);
4500
- const id = Number(result.lastInsertRowid);
4501
- const row = getContactRowById(db, id);
4502
- if (!row) {
4503
- throw new AffinityInvariantError("inserted contact row not found");
4504
- }
4505
- refreshContactRollup(db, id, now);
4506
- const primary = mapContactRowToContactListItem(row);
4507
- return buildContactMutationReceipt(primary, {
4508
- created: [{ kind: "contact", id }]
4509
- });
4510
- });
4511
- }
4512
-
4513
- // src/contacts/fold_search_text.ts
4514
- function foldSearchText(value) {
4515
- let folded = value.toLowerCase();
4516
- if (/[^\u0020-\u007e]/.test(folded))
4517
- folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
4518
- return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
4519
- }
4520
-
4521
4898
  // src/identities/queries.ts
4522
4899
  function getIdentityRowById(db, id) {
4523
4900
  const row = db.prepare(
@@ -4549,14 +4926,19 @@ function buildIdentityMutationReceipt(primary, partial) {
4549
4926
  // src/identities/validators.ts
4550
4927
  function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
4551
4928
  const row = excludeIdentityId === void 0 ? db.prepare(
4552
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
4553
- ).get(normalizedKey) : db.prepare(
4554
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
4555
- ).get(normalizedKey, excludeIdentityId);
4929
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL"
4930
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey) : db.prepare(
4931
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL AND id != ?"
4932
+ ).get(
4933
+ normalizedKey,
4934
+ legacyIdentityKey(normalizedKey),
4935
+ normalizedKey,
4936
+ excludeIdentityId
4937
+ );
4556
4938
  if (row !== void 0) {
4557
4939
  const owner = db.prepare(
4558
- "SELECT c.id, c.name FROM contacts c JOIN identities i ON i.contact_id = c.id WHERE i.normalized_key = ? AND i.removed_at IS NULL"
4559
- ).get(normalizedKey);
4940
+ "SELECT c.id, c.name FROM contacts c JOIN identities i ON i.contact_id = c.id WHERE (i.normalized_key IN (?, ?) OR (i.type='custom:atproto-did' AND ? = 'did:' || lower(i.value))) AND i.removed_at IS NULL"
4941
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey);
4560
4942
  throw new AffinityConflictError(
4561
4943
  `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
4562
4944
  );
@@ -4565,6 +4947,8 @@ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
4565
4947
 
4566
4948
  // src/identities/validate_identity_input.ts
4567
4949
  var identityTypes = [
4950
+ "did",
4951
+ "atproto_did",
4568
4952
  "email",
4569
4953
  "phone",
4570
4954
  "url",
@@ -4591,8 +4975,20 @@ var identityTypes = [
4591
4975
  "account_id",
4592
4976
  "alias"
4593
4977
  ];
4594
- function validateIdentityInput(type, value) {
4978
+ function validateIdentityInput(type, rawValue) {
4595
4979
  const kind = type.toLowerCase();
4980
+ const value = normalizeIdentityValue(kind, rawValue);
4981
+ if (["did", "atproto_did", "custom:atproto-did"].includes(kind) && !/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value))
4982
+ throw new AffinityValidationError(
4983
+ "did value must match did:<method>:<identifier> (for example did:plc:abc123)"
4984
+ );
4985
+ if (handleIdentityTypes.has(kind)) {
4986
+ const pattern = kind === "bluesky" ? /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/ : ["x", "twitter"].includes(kind) ? /^[a-z0-9_]{1,15}$/ : kind === "mastodon" ? /^[a-z0-9_.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/ : /^[a-z0-9_.-]+$/;
4987
+ if (!pattern.test(value))
4988
+ throw new AffinityValidationError(
4989
+ `invalid ${kind === "handle" ? "" : `${kind} `}handle; use a handle without spaces or a URI${kind === "mastodon" ? ": user@host.tld" : kind === "bluesky" ? ": name.bsky.social" : " (leading @ is optional)"}`
4990
+ );
4991
+ }
4596
4992
  if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
4597
4993
  throw new AffinityValidationError(
4598
4994
  `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
@@ -4631,12 +5027,27 @@ function addIdentity(db, contactId, input) {
4631
5027
  throw new AffinityStateError("merged contact is read-only");
4632
5028
  }
4633
5029
  const type = input.type.trim();
4634
- const value = input.value.trim();
5030
+ const value = normalizeIdentityValue(type, input.value);
4635
5031
  if (type.length === 0 || value.length === 0) {
4636
5032
  throw new AffinityValidationError("type and value must be non-empty");
4637
5033
  }
4638
5034
  validateIdentityInput(type, value);
5035
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
5036
+ throw new AffinityValidationError("ifExists must be error or return");
4639
5037
  const nk = normalizeIdentityKey(type, value);
5038
+ if (input.ifExists === "return") {
5039
+ const existing = db.prepare(
5040
+ "SELECT id FROM identities WHERE contact_id=? AND (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL"
5041
+ ).get(contactId, nk, legacyIdentityKey(nk), nk);
5042
+ if (existing) {
5043
+ const row2 = getIdentityRowById(db, Number(existing.id));
5044
+ if (row2)
5045
+ return buildIdentityMutationReceipt(
5046
+ mapIdentityRowToIdentityRecord(row2),
5047
+ {}
5048
+ );
5049
+ }
5050
+ }
4640
5051
  assertNoIdentityCollision(db, nk);
4641
5052
  const now = resolveNow(input.now);
4642
5053
  const verified = input.verified ? 1 : 0;
@@ -4670,31 +5081,239 @@ function addIdentity(db, contactId, input) {
4670
5081
  });
4671
5082
  }
4672
5083
 
4673
- // src/tools/create_contact_tool_result.ts
4674
- function createContactToolResult(db, input) {
5084
+ // src/contacts/create_contact.ts
5085
+ var KINDS = /* @__PURE__ */ new Set([
5086
+ "human",
5087
+ "group",
5088
+ "company",
5089
+ "team",
5090
+ "pet",
5091
+ "service",
5092
+ "other"
5093
+ ]);
5094
+ function createContact(db, input) {
4675
5095
  return withTransaction(db, () => {
4676
- const receipt = createContact(db, input);
4677
- const identityOwners = /* @__PURE__ */ new Set();
4678
- for (const identity of input.identities ?? []) {
4679
- validateIdentityInput(identity.type.trim(), identity.value.trim());
4680
- const existing = db.prepare(
4681
- "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
4682
- ).get(normalizeIdentityKey(identity.type, identity.value));
4683
- if (existing) {
4684
- if (Number(existing.contact_id) !== receipt.primary.id)
4685
- identityOwners.add(Number(existing.contact_id));
4686
- continue;
4687
- }
4688
- const added = addIdentity(db, receipt.primary.id, identity);
4689
- receipt.created.push(...added.created);
4690
- }
4691
- const result = mutationToolResult("create", receipt, "contact");
5096
+ const name = input.name.trim();
5097
+ if (name.length === 0) {
5098
+ throw new AffinityValidationError("name must be non-empty");
5099
+ }
5100
+ if (!KINDS.has(input.kind)) {
5101
+ throw new AffinityValidationError("invalid contact kind");
5102
+ }
5103
+ if (input.isOwner !== void 0) {
5104
+ throw new AffinityValidationError(
5105
+ "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
5106
+ );
5107
+ }
5108
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
5109
+ throw new AffinityValidationError("ifExists must be error or return");
5110
+ const matches = (input.identities ?? []).flatMap((identity) => {
5111
+ validateIdentityInput(identity.type.trim(), identity.value.trim());
5112
+ const key = normalizeIdentityKey(identity.type, identity.value);
5113
+ const row2 = db.prepare(
5114
+ `SELECT c.id, c.name FROM identities i JOIN contacts c ON c.id=i.contact_id WHERE i.removed_at IS NULL AND c.deleted_at IS NULL AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))`
5115
+ ).get(key, legacyIdentityKey(key), key);
5116
+ return row2 ? [{ ...row2, identity }] : [];
5117
+ });
5118
+ const match = matches[0];
5119
+ if (match) {
5120
+ if (input.ifExists === "return") {
5121
+ const existing = getContactRowById(db, match.id);
5122
+ if (!existing)
5123
+ throw new AffinityInvariantError("identity owner not found");
5124
+ const primary2 = mapContactRowToContactListItem(existing);
5125
+ return Object.assign(buildContactMutationReceipt(primary2, {}), {
5126
+ outcome: "no_op",
5127
+ matchedIdentities: matches.map((m) => m.identity)
5128
+ });
5129
+ }
5130
+ const error = new AffinityConflictError(
5131
+ `identity ${normalizeIdentityKey(match.identity.type, match.identity.value).replace(":", " ")} already belongs to #${match.id} ${match.name}`
5132
+ );
5133
+ error.existingContactId = match.id;
5134
+ throw error;
5135
+ }
5136
+ const now = resolveNow(input.now);
5137
+ if (input.bootstrapOwner) {
5138
+ const existing = db.prepare(
5139
+ "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
5140
+ ).get();
5141
+ if (existing !== void 0) {
5142
+ throw new AffinityConflictError(
5143
+ `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
5144
+ );
5145
+ }
5146
+ }
5147
+ const isOwner = input.bootstrapOwner ? 1 : 0;
5148
+ const result = db.prepare(
5149
+ `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
5150
+ VALUES (?, ?, 'active', ?, ?, ?)`
5151
+ ).run(name, input.kind, isOwner, now, now);
5152
+ const id = Number(result.lastInsertRowid);
5153
+ const row = getContactRowById(db, id);
5154
+ if (!row) {
5155
+ throw new AffinityInvariantError("inserted contact row not found");
5156
+ }
5157
+ refreshContactRollup(db, id, now);
5158
+ const primary = mapContactRowToContactListItem(row);
5159
+ const receipt = buildContactMutationReceipt(primary, {
5160
+ created: [{ kind: "contact", id }]
5161
+ });
5162
+ for (const identity of input.identities ?? [])
5163
+ receipt.created.push(...addIdentity(db, id, identity).created);
5164
+ return receipt;
5165
+ });
5166
+ }
5167
+
5168
+ // src/contacts/fold_search_text.ts
5169
+ function foldSearchText(value) {
5170
+ let folded = value.toLowerCase();
5171
+ if (/[^\u0020-\u007e]/.test(folded))
5172
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
5173
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
5174
+ }
5175
+
5176
+ // src/contacts/list_duplicate_candidates.ts
5177
+ function pairKey(leftContactId, rightContactId) {
5178
+ const left = Math.min(leftContactId, rightContactId);
5179
+ const right = Math.max(leftContactId, rightContactId);
5180
+ return `${left}:${right}`;
5181
+ }
5182
+ function normalizeName(name) {
5183
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
5184
+ }
5185
+ function fuzzyNameScore(leftInput, rightInput) {
5186
+ const left = normalizeName(leftInput);
5187
+ const right = normalizeName(rightInput);
5188
+ if (left === right) {
5189
+ return 0.92;
5190
+ }
5191
+ if (left.length >= 3 && right.length >= 3) {
5192
+ if (left.includes(right) || right.includes(left)) {
5193
+ return 0.72;
5194
+ }
5195
+ const leftTokens = new Set(left.split(" "));
5196
+ const rightTokens = new Set(right.split(" "));
5197
+ const overlap = [...leftTokens].filter(
5198
+ (token) => rightTokens.has(token)
5199
+ ).length;
5200
+ if (overlap > 0) {
5201
+ return overlap / Math.max(leftTokens.size, rightTokens.size) * 0.7;
5202
+ }
5203
+ }
5204
+ return 0;
5205
+ }
5206
+ function listDuplicateCandidates(db, filters, options) {
5207
+ assertDefaultOrdering("listDuplicateCandidates", options);
5208
+ const { limit, offset } = resolveListLimitOffset(options);
5209
+ const subset = filters?.contactIds;
5210
+ const contacts = db.prepare(
5211
+ `SELECT id, name
5212
+ FROM contacts
5213
+ WHERE deleted_at IS NULL
5214
+ AND lifecycle_state != 'merged'`
5215
+ ).all();
5216
+ const allowedIds = subset === void 0 ? null : new Set(subset.map((contactId) => Number(contactId)));
5217
+ const pairs = /* @__PURE__ */ new Map();
5218
+ const exactMatches = db.prepare(
5219
+ `SELECT
5220
+ i1.contact_id AS left_contact_id,
5221
+ i2.contact_id AS right_contact_id,
5222
+ i1.normalized_key
5223
+ FROM identities i1
5224
+ INNER JOIN identities i2
5225
+ ON i1.normalized_key = i2.normalized_key
5226
+ AND i1.contact_id < i2.contact_id
5227
+ INNER JOIN contacts c1 ON c1.id = i1.contact_id
5228
+ INNER JOIN contacts c2 ON c2.id = i2.contact_id
5229
+ WHERE i1.removed_at IS NULL
5230
+ AND i2.removed_at IS NULL
5231
+ AND c1.deleted_at IS NULL
5232
+ AND c2.deleted_at IS NULL
5233
+ AND c1.lifecycle_state != 'merged'
5234
+ AND c2.lifecycle_state != 'merged'`
5235
+ ).all();
5236
+ for (const match of exactMatches) {
5237
+ if (allowedIds !== null && (!allowedIds.has(match.left_contact_id) || !allowedIds.has(match.right_contact_id))) {
5238
+ continue;
5239
+ }
5240
+ pairs.set(pairKey(match.left_contact_id, match.right_contact_id), {
5241
+ leftContactId: match.left_contact_id,
5242
+ rightContactId: match.right_contact_id,
5243
+ matchReason: `exact identity ${match.normalized_key}`,
5244
+ matchScore: 1
5245
+ });
5246
+ }
5247
+ if (filters?.exactOnly !== true) {
5248
+ for (let index = 0; index < contacts.length; index += 1) {
5249
+ const left = contacts[index];
5250
+ if (left === void 0 || allowedIds !== null && !allowedIds.has(left.id)) {
5251
+ continue;
5252
+ }
5253
+ const leftName = normalizeName(left.name);
5254
+ for (let offsetIndex = index + 1; offsetIndex < contacts.length; offsetIndex += 1) {
5255
+ const right = contacts[offsetIndex];
5256
+ if (right === void 0 || allowedIds !== null && !allowedIds.has(right.id)) {
5257
+ continue;
5258
+ }
5259
+ const score = fuzzyNameScore(leftName, normalizeName(right.name));
5260
+ if (score <= 0) {
5261
+ continue;
5262
+ }
5263
+ const key = pairKey(left.id, right.id);
5264
+ if (!pairs.has(key)) {
5265
+ pairs.set(key, {
5266
+ leftContactId: left.id,
5267
+ rightContactId: right.id,
5268
+ matchReason: "name similarity",
5269
+ matchScore: score
5270
+ });
5271
+ }
5272
+ }
5273
+ }
5274
+ }
5275
+ const dismissedRows = db.prepare("SELECT left_id, right_id FROM dismissed_duplicates").all();
5276
+ const dismissedSet = new Set(
5277
+ dismissedRows.map((r) => pairKey(r.left_id, r.right_id))
5278
+ );
5279
+ const includeDismissed = filters?.includeDismissed === true;
5280
+ for (const candidate of pairs.values()) {
5281
+ if (dismissedSet.has(
5282
+ pairKey(candidate.leftContactId, candidate.rightContactId)
5283
+ )) {
5284
+ candidate.dismissed = true;
5285
+ }
5286
+ }
5287
+ const minScore = filters?.minScore ?? 0;
5288
+ return [...pairs.values()].filter((candidate) => {
5289
+ if (candidate.matchScore < minScore) return false;
5290
+ if (candidate.dismissed === true && !includeDismissed) return false;
5291
+ return true;
5292
+ }).sort((left, right) => {
5293
+ const leftExact = left.matchScore === 1 ? 1 : 0;
5294
+ const rightExact = right.matchScore === 1 ? 1 : 0;
5295
+ return rightExact - leftExact || right.matchScore - left.matchScore;
5296
+ }).slice(offset, offset + limit);
5297
+ }
5298
+
5299
+ // src/tools/create_contact_tool_result.ts
5300
+ function createContactToolResult(db, input) {
5301
+ return withTransaction(db, () => {
5302
+ const receipt = createContact(db, input);
5303
+ const result = mutationToolResult("create", receipt, "contact");
5304
+ if (result.ok && receipt.outcome === "no_op") {
5305
+ result.outcome = "no_op";
5306
+ Object.assign(result.data, {
5307
+ matchedIdentities: receipt.matchedIdentities
5308
+ });
5309
+ return result;
5310
+ }
4692
5311
  const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
4693
5312
  const warnings = db.prepare(
4694
5313
  "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
4695
5314
  ).all(receipt.primary.id).flatMap((row) => {
4696
- const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
4697
- return score === void 0 ? [] : [
5315
+ const score = fold(row.name) === fold(input.name) ? 0.92 : fuzzyNameScore(row.name, input.name);
5316
+ return score <= 0 ? [] : [
4698
5317
  {
4699
5318
  code: "possible_duplicate",
4700
5319
  message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
@@ -4777,6 +5396,13 @@ function manageContactToolHandler(db, rawInput) {
4777
5396
  return contact.result;
4778
5397
  }
4779
5398
  if (input.action === "delete") {
5399
+ if (contact.value.is_owner)
5400
+ return toolFailure(
5401
+ "domain",
5402
+ "conflict",
5403
+ "Cannot delete owner.",
5404
+ "The owner contact cannot be deleted"
5405
+ );
4780
5406
  const deletion = deleteContact(db, contact.value.id, { confirm: true });
4781
5407
  return toolSuccess("Permanently deleted contact.", {
4782
5408
  action: "delete",
@@ -4850,7 +5476,11 @@ var manageContactTool = defineAffinityTool({
4850
5476
  },
4851
5477
  ["type", "value"]
4852
5478
  ),
4853
- "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
5479
+ "Optional identities to attach. An identity owned by another live contact causes a conflict, or returns that contact when ifExists=return."
5480
+ ),
5481
+ ifExists: enumSchema(
5482
+ "Identity collision policy; return attaches nothing.",
5483
+ ["error", "return"]
4854
5484
  ),
4855
5485
  name: stringSchema("Contact name."),
4856
5486
  kind: enumSchema("Contact kind.", [
@@ -5460,6 +6090,268 @@ var manageDuplicateDismissalTool = defineAffinityTool({
5460
6090
  handler: manageDuplicateDismissalToolHandler
5461
6091
  });
5462
6092
 
6093
+ // src/events/event_replay.ts
6094
+ function replayEventLinks(db, linkIds, now) {
6095
+ const eventIds = /* @__PURE__ */ new Set();
6096
+ for (const linkId of linkIds) {
6097
+ const saved = db.prepare("SELECT state_json FROM event_link_baselines WHERE link_id=?").get(linkId);
6098
+ if (!saved) continue;
6099
+ const baseline = JSON.parse(saved.state_json);
6100
+ const effects = db.prepare(
6101
+ "SELECT event_id, participant_json, provenance_json, created_at FROM link_event_effects WHERE link_id=? ORDER BY event_id"
6102
+ ).all(linkId);
6103
+ const overrides = db.prepare(
6104
+ "SELECT after_event_id,state FROM event_link_state_changes WHERE link_id=? ORDER BY id"
6105
+ ).all(linkId);
6106
+ let overrideIndex = 0;
6107
+ const applyOverrides = (beforeId) => {
6108
+ while (overrideIndex < overrides.length && (overrides[overrideIndex]?.after_event_id ?? Number.POSITIVE_INFINITY) < beforeId) {
6109
+ const override = overrides[overrideIndex];
6110
+ if (override)
6111
+ db.prepare("UPDATE links SET state=? WHERE id=?").run(
6112
+ override.state,
6113
+ linkId
6114
+ );
6115
+ overrideIndex++;
6116
+ }
6117
+ };
6118
+ db.prepare("DELETE FROM link_event_effects WHERE link_id=?").run(linkId);
6119
+ db.prepare(
6120
+ "UPDATE links SET rank=?,affinity=?,trust=?,state=?,cadence_days=?,updated_at=? WHERE id=?"
6121
+ ).run(
6122
+ baseline.rank,
6123
+ baseline.affinity,
6124
+ baseline.trust,
6125
+ baseline.state,
6126
+ baseline.cadence_days,
6127
+ now,
6128
+ linkId
6129
+ );
6130
+ for (const effect of effects) {
6131
+ eventIds.add(effect.event_id);
6132
+ applyOverrides(effect.event_id);
6133
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(effect.event_id))
6134
+ continue;
6135
+ const event = loadEventRecord(db, effect.event_id);
6136
+ const link = db.prepare("SELECT from_contact_id,to_contact_id FROM links WHERE id=?").get(linkId);
6137
+ const participant = effect.participant_json ? JSON.parse(effect.participant_json) : event.participants.find((p) => p.contactId === link.to_contact_id) ?? event.participants[0];
6138
+ const provenance = db.prepare("SELECT provenance_json FROM events WHERE id=?").get(event.id);
6139
+ applyEventEffectToLink(db, {
6140
+ linkId,
6141
+ eventId: event.id,
6142
+ eventType: event.type,
6143
+ occurredAt: event.occurredAt,
6144
+ significance: event.significance,
6145
+ participant,
6146
+ provenance: provenance.provenance_json ? JSON.parse(provenance.provenance_json) : effect.provenance_json ? JSON.parse(effect.provenance_json) : void 0,
6147
+ now: effect.created_at
6148
+ });
6149
+ }
6150
+ applyOverrides(Number.POSITIVE_INFINITY);
6151
+ refreshLinkRollup(db, linkId, now);
6152
+ }
6153
+ for (const eventId of eventIds)
6154
+ db.prepare(
6155
+ "UPDATE events SET moment_kind=COALESCE(declared_moment_kind,(SELECT moment_kind FROM link_event_effects WHERE event_id=events.id AND moment_kind IS NOT NULL ORDER BY link_id LIMIT 1)) WHERE id=?"
6156
+ ).run(eventId);
6157
+ refreshAllBridgeScores(db, now);
6158
+ }
6159
+
6160
+ // src/events/manage_event.ts
6161
+ function requireEventTarget(db, target) {
6162
+ const selector = typeof target === "number" ? { eventId: target } : target;
6163
+ if (!selector || selector.eventId === void 0 === (selector.ref === void 0))
6164
+ throw new AffinityValidationError("Provide exactly one of eventId or ref");
6165
+ const event = selector.ref ? getEventByExternalRef(db, selector.ref.source, selector.ref.id) : getEventById(db, selector.eventId);
6166
+ if (!event) throw new AffinityNotFoundError("event not found");
6167
+ return event;
6168
+ }
6169
+ function affected(db, id) {
6170
+ return db.prepare("SELECT link_id FROM link_event_effects WHERE event_id=?").all(id).map((r) => r.link_id);
6171
+ }
6172
+ function reviseEvent(db, target, patch) {
6173
+ return withTransaction(db, () => {
6174
+ const allowed = [
6175
+ "summary",
6176
+ "content",
6177
+ "occurredAt",
6178
+ "significance",
6179
+ "channel",
6180
+ "type",
6181
+ "externalRef"
6182
+ ];
6183
+ for (const key of Object.keys(patch))
6184
+ if (!allowed.includes(key))
6185
+ throw new AffinityValidationError(
6186
+ `unknown patch key ${key}; allowed keys: ${allowed.join(", ")}`
6187
+ );
6188
+ const event = requireEventTarget(db, target);
6189
+ const next = { ...event, ...patch };
6190
+ const { momentKind: _momentKind, ...metadata } = next;
6191
+ validateEventMetadata(metadata);
6192
+ Object.assign(next, metadata);
6193
+ if (patch.summary !== void 0 && (typeof patch.summary !== "string" || !patch.summary.trim()))
6194
+ throw new AffinityValidationError("summary must be non-empty");
6195
+ if (patch.occurredAt !== void 0)
6196
+ assertFiniteTimestamp(patch.occurredAt, "occurredAt");
6197
+ if (!Number.isInteger(next.significance) || next.significance < 1 || next.significance > 10)
6198
+ throw new AffinityValidationError(
6199
+ "significance must be an integer from 1 to 10"
6200
+ );
6201
+ const interactions = [
6202
+ "conversation",
6203
+ "activity",
6204
+ "gift",
6205
+ "support",
6206
+ "conflict",
6207
+ "correction"
6208
+ ];
6209
+ if (next.type !== event.type && !(interactions.includes(event.type) && interactions.includes(next.type)) && !(["promise", "agreement"].includes(event.type) && ["promise", "agreement"].includes(next.type)))
6210
+ throw new AffinityValidationError(
6211
+ "type must stay within the same event kind"
6212
+ );
6213
+ if (patch.externalRef && event.externalRef)
6214
+ throw new AffinityConflictError(
6215
+ "externalRef can only be set when absent"
6216
+ );
6217
+ if (patch.externalRef && next.externalRef && getEventByExternalRef(db, next.externalRef.source, next.externalRef.id))
6218
+ throw new AffinityConflictError(
6219
+ "externalRef already belongs to an event"
6220
+ );
6221
+ const now = Date.now();
6222
+ const links = affected(db, event.id);
6223
+ db.prepare(
6224
+ "UPDATE events SET summary=?,content=?,occurred_at=?,occurred_on=?,significance=?,channel=?,type=?,external_source=?,external_id=?,updated_at=? WHERE id=?"
6225
+ ).run(
6226
+ next.summary.trim(),
6227
+ next.content ?? null,
6228
+ next.occurredAt,
6229
+ patch.occurredAt === void 0 ? event.occurredOn ?? null : new Date(next.occurredAt).toISOString().slice(0, 10),
6230
+ next.significance,
6231
+ next.channel ?? null,
6232
+ next.type,
6233
+ next.externalRef?.source ?? null,
6234
+ next.externalRef?.id ?? null,
6235
+ now,
6236
+ event.id
6237
+ );
6238
+ if (patch.type && ["promise", "agreement"].includes(patch.type))
6239
+ db.prepare(
6240
+ "UPDATE open_commitments SET commitment_type=? WHERE event_id=?"
6241
+ ).run(patch.type, event.id);
6242
+ if (patch.significance !== void 0 || patch.type !== void 0 || patch.occurredAt !== void 0)
6243
+ replayEventLinks(db, links, now);
6244
+ return buildEventMutationReceipt(requireEventTarget(db, event.id), {
6245
+ updated: [{ kind: "event", id: event.id }],
6246
+ affectedLinks: links
6247
+ });
6248
+ });
6249
+ }
6250
+ function deleteEvent(db, target, options) {
6251
+ return withTransaction(db, () => {
6252
+ if (options?.confirm !== true)
6253
+ throw new AffinityValidationError("deleteEvent requires confirm: true");
6254
+ const event = requireEventTarget(db, target);
6255
+ const links = affected(db, event.id);
6256
+ for (const table of [
6257
+ "event_participants",
6258
+ "attributes",
6259
+ "open_commitments",
6260
+ "upcoming_occurrences",
6261
+ "link_event_effects"
6262
+ ])
6263
+ db.prepare(`DELETE FROM ${table} WHERE event_id=?`).run(event.id);
6264
+ db.prepare(
6265
+ "UPDATE events SET parent_event_id=NULL WHERE parent_event_id=?"
6266
+ ).run(event.id);
6267
+ db.prepare("DELETE FROM events WHERE id=?").run(event.id);
6268
+ replayEventLinks(db, links, Date.now());
6269
+ return buildEventMutationReceipt(event, {
6270
+ removed: [{ kind: "event", id: event.id }],
6271
+ affectedLinks: links
6272
+ });
6273
+ });
6274
+ }
6275
+
6276
+ // src/tools/manage_event_tool.ts
6277
+ function manageEventToolHandler(db, raw) {
6278
+ const input = structuredClone(raw);
6279
+ const error = validateToolInput(
6280
+ manageEventTool.inputSchema,
6281
+ input,
6282
+ "input",
6283
+ Date.now(),
6284
+ true
6285
+ );
6286
+ if (error)
6287
+ return toolFailure("protocol", "invalid_input", "Invalid input.", error);
6288
+ return withToolHandling(() => {
6289
+ const target = {
6290
+ ...input.eventId === void 0 ? {} : { eventId: input.eventId },
6291
+ ...input.ref === void 0 ? {} : { ref: input.ref }
6292
+ };
6293
+ return mutationToolResult(
6294
+ input.action,
6295
+ input.action === "revise" ? reviseEvent(db, target, input.patch ?? {}) : deleteEvent(db, target, { confirm: input.confirm === true }),
6296
+ "event"
6297
+ );
6298
+ }, "Event management failed.");
6299
+ }
6300
+ var manageEventTool = defineAffinityTool({
6301
+ name: manageEventToolName,
6302
+ description: "Revise or delete one event by eventId or external reference. Delete requires confirm=true and recomputes affected relationships.",
6303
+ whenToUse: "Correct imported event text or metadata, or remove an event.",
6304
+ whenNotToUse: "Use record_event to capture new evidence.",
6305
+ sideEffects: "writes_state",
6306
+ readOnly: false,
6307
+ supportsClarification: false,
6308
+ targetKinds: ["event"],
6309
+ inputDescriptions: {
6310
+ action: "revise or delete",
6311
+ eventId: "Exact event id",
6312
+ ref: "External event reference",
6313
+ patch: "Fields to revise",
6314
+ confirm: "Required true for deletion"
6315
+ },
6316
+ outputDescription: "Event mutation receipt and affected links.",
6317
+ inputSchema: objectSchema(
6318
+ {
6319
+ action: enumSchema("Action.", ["revise", "delete"]),
6320
+ eventId: integerSchema("Event id; provide eventId or ref."),
6321
+ ref: externalRefSchema,
6322
+ confirm: booleanSchema("Must be true to delete."),
6323
+ patch: objectSchema(
6324
+ {
6325
+ summary: stringSchema("Short non-empty label."),
6326
+ content: stringSchema("Verbatim content, at most 1 MB UTF-8."),
6327
+ occurredAt: integerSchema("Event timestamp."),
6328
+ significance: integerSchema("Significance 1..10."),
6329
+ channel: stringSchema("Channel, at most 40 characters."),
6330
+ type: enumSchema("Type within the same event kind.", [
6331
+ "conversation",
6332
+ "activity",
6333
+ "gift",
6334
+ "support",
6335
+ "conflict",
6336
+ "correction",
6337
+ "observation",
6338
+ "milestone",
6339
+ "transaction",
6340
+ "promise",
6341
+ "agreement",
6342
+ "date_anchor"
6343
+ ]),
6344
+ externalRef: externalRefSchema
6345
+ },
6346
+ [],
6347
+ "Fields to revise; externalRef may only be added when absent."
6348
+ )
6349
+ },
6350
+ ["action"]
6351
+ ),
6352
+ handler: manageEventToolHandler
6353
+ });
6354
+
5463
6355
  // src/identities/remove_identity.ts
5464
6356
  function removeIdentity(db, identityId, removedAt) {
5465
6357
  return withTransaction(db, () => {
@@ -5507,7 +6399,10 @@ function reviseIdentity(db, identityId, patch) {
5507
6399
  });
5508
6400
  }
5509
6401
  const nextType = patch.type !== void 0 ? patch.type.trim() : row.type;
5510
- const nextValue = patch.value !== void 0 ? patch.value.trim() : row.value;
6402
+ const nextValue = normalizeIdentityValue(
6403
+ nextType,
6404
+ patch.value ?? row.value
6405
+ );
5511
6406
  if (nextType.length === 0 || nextValue.length === 0) {
5512
6407
  throw new AffinityValidationError("type and value must be non-empty");
5513
6408
  }
@@ -5566,6 +6461,10 @@ function contactLocatorSchema6(description) {
5566
6461
  contactId: integerSchema("Exact contact id."),
5567
6462
  identity: objectSchema(
5568
6463
  {
6464
+ ifExists: enumSchema(
6465
+ "Return an existing identity on this contact without changes.",
6466
+ ["error", "return"]
6467
+ ),
5569
6468
  type: stringSchema(
5570
6469
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
5571
6470
  ),
@@ -5662,6 +6561,10 @@ var manageIdentityTool = defineAffinityTool({
5662
6561
  ),
5663
6562
  input: objectSchema(
5664
6563
  {
6564
+ ifExists: enumSchema(
6565
+ "Return an existing identity on this contact without changes.",
6566
+ ["error", "return"]
6567
+ ),
5665
6568
  type: stringSchema(
5666
6569
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
5667
6570
  ),
@@ -5723,6 +6626,9 @@ function overrideLinkState(db, linkId, state, options) {
5723
6626
  now,
5724
6627
  linkId
5725
6628
  );
6629
+ db.prepare(
6630
+ "INSERT INTO event_link_state_changes(link_id,after_event_id,state) SELECT ?, COALESCE(MAX(event_id),0), ? FROM link_event_effects WHERE link_id=?"
6631
+ ).run(linkId, state, linkId);
5726
6632
  refreshLinkRollup(db, linkId, now);
5727
6633
  if (state === "archived" || row.state === "archived") {
5728
6634
  refreshAllBridgeScores(db, now);
@@ -6357,6 +7263,8 @@ var ALLOWED = /* @__PURE__ */ new Set([
6357
7263
  function recordInteraction(db, input) {
6358
7264
  return withTransaction(db, () => {
6359
7265
  validateSocialEventInput(input);
7266
+ const existing = existingEventReceipt(db, input.externalRef);
7267
+ if (existing) return existing;
6360
7268
  if (!ALLOWED.has(input.type)) {
6361
7269
  throw new AffinityValidationError("invalid interaction event type");
6362
7270
  }
@@ -6366,11 +7274,12 @@ function recordInteraction(db, input) {
6366
7274
  assertOwnerParticipates(ownerId, ids);
6367
7275
  const now = resolveNow(input.now);
6368
7276
  const eventId = insertJournalEvent(db, {
7277
+ ...input,
6369
7278
  type: input.type,
6370
7279
  occurredAt: input.occurredAt,
6371
- summary: input.summary,
7280
+ occurredOn: input.occurredOn,
6372
7281
  significance: input.significance,
6373
- momentKind: null,
7282
+ momentKind: input.momentKind ?? null,
6374
7283
  participants: input.participants,
6375
7284
  now
6376
7285
  });
@@ -6378,12 +7287,24 @@ function recordInteraction(db, input) {
6378
7287
  eventId,
6379
7288
  eventType: input.type,
6380
7289
  occurredAt: input.occurredAt,
7290
+ occurredOn: input.occurredOn,
6381
7291
  significance: input.significance,
6382
7292
  participants: input.participants,
6383
7293
  provenance: input.provenance,
6384
7294
  now
6385
7295
  });
7296
+ if (input.momentKind !== void 0)
7297
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7298
+ input.momentKind,
7299
+ eventId
7300
+ );
6386
7301
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7302
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7303
+ {
7304
+ code: "future_event",
7305
+ message: "occurredAt is in the future; it will not count as contact until then"
7306
+ }
7307
+ ] : void 0,
6387
7308
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6388
7309
  affectedLinks: mechanics.affectedLinks,
6389
7310
  derivedEffects: mechanics.derivedEffects
@@ -6395,17 +7316,20 @@ function recordInteraction(db, input) {
6395
7316
  function recordMilestone(db, input) {
6396
7317
  return withTransaction(db, () => {
6397
7318
  validateSocialEventInput(input);
7319
+ const existing = existingEventReceipt(db, input.externalRef);
7320
+ if (existing) return existing;
6398
7321
  const ownerId = requireOwnerContactId(db);
6399
7322
  const ids = input.participants.map((p) => p.contactId);
6400
7323
  assertParticipantContactsLive(db, ids);
6401
7324
  assertOwnerParticipates(ownerId, ids);
6402
7325
  const now = resolveNow(input.now);
6403
7326
  const eventId = insertJournalEvent(db, {
7327
+ ...input,
6404
7328
  type: "milestone",
6405
7329
  occurredAt: input.occurredAt,
6406
- summary: input.summary,
7330
+ occurredOn: input.occurredOn,
6407
7331
  significance: input.significance,
6408
- momentKind: null,
7332
+ momentKind: input.momentKind ?? null,
6409
7333
  participants: input.participants,
6410
7334
  now
6411
7335
  });
@@ -6413,12 +7337,24 @@ function recordMilestone(db, input) {
6413
7337
  eventId,
6414
7338
  eventType: "milestone",
6415
7339
  occurredAt: input.occurredAt,
7340
+ occurredOn: input.occurredOn,
6416
7341
  significance: input.significance,
6417
7342
  participants: input.participants,
6418
7343
  provenance: input.provenance,
6419
7344
  now
6420
7345
  });
7346
+ if (input.momentKind !== void 0)
7347
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7348
+ input.momentKind,
7349
+ eventId
7350
+ );
6421
7351
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7352
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7353
+ {
7354
+ code: "future_event",
7355
+ message: "occurredAt is in the future; it will not count as contact until then"
7356
+ }
7357
+ ] : void 0,
6422
7358
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6423
7359
  affectedLinks: mechanics.affectedLinks,
6424
7360
  derivedEffects: mechanics.derivedEffects
@@ -6430,15 +7366,18 @@ function recordMilestone(db, input) {
6430
7366
  function recordObservation(db, input) {
6431
7367
  return withTransaction(db, () => {
6432
7368
  validateSocialEventInput(input);
7369
+ const existing = existingEventReceipt(db, input.externalRef);
7370
+ if (existing) return existing;
6433
7371
  const ids = input.participants.map((p) => p.contactId);
6434
7372
  assertParticipantContactsLive(db, ids);
6435
7373
  const now = resolveNow(input.now);
6436
7374
  const eventId = insertJournalEvent(db, {
7375
+ ...input,
6437
7376
  type: "observation",
6438
7377
  occurredAt: input.occurredAt,
6439
- summary: input.summary,
7378
+ occurredOn: input.occurredOn,
6440
7379
  significance: input.significance,
6441
- momentKind: null,
7380
+ momentKind: input.momentKind ?? null,
6442
7381
  participants: input.participants,
6443
7382
  now
6444
7383
  });
@@ -6446,12 +7385,24 @@ function recordObservation(db, input) {
6446
7385
  eventId,
6447
7386
  eventType: "observation",
6448
7387
  occurredAt: input.occurredAt,
7388
+ occurredOn: input.occurredOn,
6449
7389
  significance: input.significance,
6450
7390
  participants: input.participants,
6451
7391
  provenance: input.provenance,
6452
7392
  now
6453
7393
  });
7394
+ if (input.momentKind !== void 0)
7395
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7396
+ input.momentKind,
7397
+ eventId
7398
+ );
6454
7399
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7400
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7401
+ {
7402
+ code: "future_event",
7403
+ message: "occurredAt is in the future; it will not count as contact until then"
7404
+ }
7405
+ ] : void 0,
6455
7406
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6456
7407
  affectedLinks: mechanics.affectedLinks,
6457
7408
  derivedEffects: mechanics.derivedEffects
@@ -6463,17 +7414,20 @@ function recordObservation(db, input) {
6463
7414
  function recordTransaction(db, input) {
6464
7415
  return withTransaction(db, () => {
6465
7416
  validateSocialEventInput(input);
7417
+ const existing = existingEventReceipt(db, input.externalRef);
7418
+ if (existing) return existing;
6466
7419
  const ownerId = requireOwnerContactId(db);
6467
7420
  const ids = input.participants.map((p) => p.contactId);
6468
7421
  assertParticipantContactsLive(db, ids);
6469
7422
  assertOwnerParticipates(ownerId, ids);
6470
7423
  const now = resolveNow(input.now);
6471
7424
  const eventId = insertJournalEvent(db, {
7425
+ ...input,
6472
7426
  type: "transaction",
6473
7427
  occurredAt: input.occurredAt,
6474
- summary: input.summary,
7428
+ occurredOn: input.occurredOn,
6475
7429
  significance: input.significance,
6476
- momentKind: null,
7430
+ momentKind: input.momentKind ?? null,
6477
7431
  participants: input.participants,
6478
7432
  now
6479
7433
  });
@@ -6481,12 +7435,24 @@ function recordTransaction(db, input) {
6481
7435
  eventId,
6482
7436
  eventType: "transaction",
6483
7437
  occurredAt: input.occurredAt,
7438
+ occurredOn: input.occurredOn,
6484
7439
  significance: input.significance,
6485
7440
  participants: input.participants,
6486
7441
  provenance: input.provenance,
6487
7442
  now
6488
7443
  });
7444
+ if (input.momentKind !== void 0)
7445
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7446
+ input.momentKind,
7447
+ eventId
7448
+ );
6489
7449
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7450
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7451
+ {
7452
+ code: "future_event",
7453
+ message: "occurredAt is in the future; it will not count as contact until then"
7454
+ }
7455
+ ] : void 0,
6490
7456
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6491
7457
  affectedLinks: mechanics.affectedLinks,
6492
7458
  derivedEffects: mechanics.derivedEffects
@@ -6495,11 +7461,13 @@ function recordTransaction(db, input) {
6495
7461
  }
6496
7462
 
6497
7463
  // src/tools/record_event_tool.ts
6498
- function normalizeParticipant2(p) {
7464
+ function normalizeParticipant2(db, p) {
6499
7465
  let contactId = p.contactId;
6500
7466
  if (contactId === void 0 && p.contact != null) {
6501
7467
  const loc = p.contact;
6502
- contactId = loc.contactId;
7468
+ const resolved = resolveContactLocator(db, loc);
7469
+ if (!resolved.ok) throw resolved.result;
7470
+ contactId = resolved.value.id;
6503
7471
  }
6504
7472
  const result = {
6505
7473
  contactId,
@@ -6517,12 +7485,18 @@ function ensureOwnerParticipates2(db, participants) {
6517
7485
  return [{ contactId: ownerId, role: "actor" }, ...participants];
6518
7486
  }
6519
7487
  function prepareParticipants2(db, raw, injectOwner) {
6520
- const normalized = raw.map(normalizeParticipant2);
7488
+ const normalized = raw.map((p) => normalizeParticipant2(db, p));
6521
7489
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
6522
7490
  }
6523
7491
  function recordEventToolHandler(db, rawInput) {
6524
7492
  const input = structuredClone(rawInput);
6525
- const inputError = validateToolInput(recordEventTool.inputSchema, input);
7493
+ const inputError = validateToolInput(
7494
+ recordEventTool.inputSchema,
7495
+ input,
7496
+ "input",
7497
+ Date.now(),
7498
+ true
7499
+ );
6526
7500
  if (inputError)
6527
7501
  return toolFailure(
6528
7502
  "protocol",
@@ -6602,6 +7576,7 @@ var recordEventTool = defineAffinityTool({
6602
7576
  "correction"
6603
7577
  ]
6604
7578
  ),
7579
+ ...eventMetadataSchema,
6605
7580
  occurredAt: integerSchema(
6606
7581
  "When the event occurred, Unix timestamp in milliseconds."
6607
7582
  ),
@@ -6610,6 +7585,19 @@ var recordEventTool = defineAffinityTool({
6610
7585
  participants: arraySchema(
6611
7586
  objectSchema(
6612
7587
  {
7588
+ contact: objectSchema(
7589
+ {
7590
+ contactId: integerSchema("Contact id."),
7591
+ identity: objectSchema(
7592
+ {
7593
+ type: stringSchema("Identity type."),
7594
+ value: stringSchema("Identity value.")
7595
+ },
7596
+ ["type", "value"]
7597
+ )
7598
+ },
7599
+ []
7600
+ ),
6613
7601
  contactId: integerSchema(
6614
7602
  "Participant contact id. The owner contact MUST be included."
6615
7603
  ),
@@ -6633,7 +7621,7 @@ var recordEventTool = defineAffinityTool({
6633
7621
  ),
6634
7622
  now: integerSchema("Optional timestamp override.")
6635
7623
  },
6636
- ["occurredAt", "summary", "significance", "participants"],
7624
+ ["occurredAt", "significance", "participants"],
6637
7625
  "Event payload. Always required. Interaction events also require `type`."
6638
7626
  )
6639
7627
  },
@@ -6649,7 +7637,10 @@ function contactAttributeClauses(filters) {
6649
7637
  const params = [];
6650
7638
  const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
6651
7639
  const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
6652
- for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
7640
+ for (const filter of [
7641
+ ...attributes,
7642
+ ...tags.map((tag) => `tag:${tag.replace(/^tag:/i, "")}`)
7643
+ ]) {
6653
7644
  const separator = filter.indexOf("=");
6654
7645
  const name = separator < 0 ? filter : filter.slice(0, separator);
6655
7646
  clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
@@ -6683,153 +7674,32 @@ function listContacts(db, filters, options) {
6683
7674
  if (filters?.includeDormant === false || options?.includeDormant === false) {
6684
7675
  clauses.push("lifecycle_state != 'dormant'");
6685
7676
  }
6686
- const attributeFilters = contactAttributeClauses(filters);
6687
- clauses.push(...attributeFilters.clauses);
6688
- params.push(...attributeFilters.params);
6689
- const where = clauses.join(" AND ");
6690
- const rows = db.prepare(
6691
- `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
6692
- created_at, updated_at, deleted_at,
6693
- (
6694
- SELECT value
6695
- FROM identities i
6696
- WHERE i.contact_id = contacts.id AND i.removed_at IS NULL
6697
- ORDER BY i.id
6698
- LIMIT 1
6699
- ) AS primary_identity
6700
- FROM contacts
6701
- WHERE ${where}
6702
- ORDER BY name ASC
6703
- LIMIT ? OFFSET ?`
6704
- ).all(...params, limit, offset);
6705
- return rows.map((r) => {
6706
- const item = mapContactRowToContactListItem(r);
6707
- return {
6708
- ...item,
6709
- primaryIdentity: r.primary_identity ?? null
6710
- };
6711
- });
6712
- }
6713
-
6714
- // src/contacts/list_duplicate_candidates.ts
6715
- function pairKey(leftContactId, rightContactId) {
6716
- const left = Math.min(leftContactId, rightContactId);
6717
- const right = Math.max(leftContactId, rightContactId);
6718
- return `${left}:${right}`;
6719
- }
6720
- function normalizeName(name) {
6721
- return name.trim().toLowerCase().replace(/\s+/g, " ");
6722
- }
6723
- function fuzzyNameScore(left, right) {
6724
- if (left === right) {
6725
- return 0.92;
6726
- }
6727
- if (left.length >= 3 && right.length >= 3) {
6728
- if (left.includes(right) || right.includes(left)) {
6729
- return 0.72;
6730
- }
6731
- const leftTokens = new Set(left.split(" "));
6732
- const rightTokens = new Set(right.split(" "));
6733
- const overlap = [...leftTokens].filter(
6734
- (token) => rightTokens.has(token)
6735
- ).length;
6736
- if (overlap > 0) {
6737
- return overlap / Math.max(leftTokens.size, rightTokens.size) * 0.7;
6738
- }
6739
- }
6740
- return 0;
6741
- }
6742
- function listDuplicateCandidates(db, filters, options) {
6743
- assertDefaultOrdering("listDuplicateCandidates", options);
6744
- const { limit, offset } = resolveListLimitOffset(options);
6745
- const subset = filters?.contactIds;
6746
- const contacts = db.prepare(
6747
- `SELECT id, name
6748
- FROM contacts
6749
- WHERE deleted_at IS NULL
6750
- AND lifecycle_state != 'merged'`
6751
- ).all();
6752
- const allowedIds = subset === void 0 ? null : new Set(subset.map((contactId) => Number(contactId)));
6753
- const pairs = /* @__PURE__ */ new Map();
6754
- const exactMatches = db.prepare(
6755
- `SELECT
6756
- i1.contact_id AS left_contact_id,
6757
- i2.contact_id AS right_contact_id,
6758
- i1.normalized_key
6759
- FROM identities i1
6760
- INNER JOIN identities i2
6761
- ON i1.normalized_key = i2.normalized_key
6762
- AND i1.contact_id < i2.contact_id
6763
- INNER JOIN contacts c1 ON c1.id = i1.contact_id
6764
- INNER JOIN contacts c2 ON c2.id = i2.contact_id
6765
- WHERE i1.removed_at IS NULL
6766
- AND i2.removed_at IS NULL
6767
- AND c1.deleted_at IS NULL
6768
- AND c2.deleted_at IS NULL
6769
- AND c1.lifecycle_state != 'merged'
6770
- AND c2.lifecycle_state != 'merged'`
6771
- ).all();
6772
- for (const match of exactMatches) {
6773
- if (allowedIds !== null && (!allowedIds.has(match.left_contact_id) || !allowedIds.has(match.right_contact_id))) {
6774
- continue;
6775
- }
6776
- pairs.set(pairKey(match.left_contact_id, match.right_contact_id), {
6777
- leftContactId: match.left_contact_id,
6778
- rightContactId: match.right_contact_id,
6779
- matchReason: `exact identity ${match.normalized_key}`,
6780
- matchScore: 1
6781
- });
6782
- }
6783
- if (filters?.exactOnly !== true) {
6784
- for (let index = 0; index < contacts.length; index += 1) {
6785
- const left = contacts[index];
6786
- if (left === void 0 || allowedIds !== null && !allowedIds.has(left.id)) {
6787
- continue;
6788
- }
6789
- const leftName = normalizeName(left.name);
6790
- for (let offsetIndex = index + 1; offsetIndex < contacts.length; offsetIndex += 1) {
6791
- const right = contacts[offsetIndex];
6792
- if (right === void 0 || allowedIds !== null && !allowedIds.has(right.id)) {
6793
- continue;
6794
- }
6795
- const score = fuzzyNameScore(leftName, normalizeName(right.name));
6796
- if (score <= 0) {
6797
- continue;
6798
- }
6799
- const key = pairKey(left.id, right.id);
6800
- if (!pairs.has(key)) {
6801
- pairs.set(key, {
6802
- leftContactId: left.id,
6803
- rightContactId: right.id,
6804
- matchReason: "name similarity",
6805
- matchScore: score
6806
- });
6807
- }
6808
- }
6809
- }
6810
- }
6811
- const dismissedRows = db.prepare("SELECT left_id, right_id FROM dismissed_duplicates").all();
6812
- const dismissedSet = new Set(
6813
- dismissedRows.map((r) => pairKey(r.left_id, r.right_id))
6814
- );
6815
- const includeDismissed = filters?.includeDismissed === true;
6816
- for (const candidate of pairs.values()) {
6817
- if (dismissedSet.has(
6818
- pairKey(candidate.leftContactId, candidate.rightContactId)
6819
- )) {
6820
- candidate.dismissed = true;
6821
- }
6822
- }
6823
- const minScore = filters?.minScore ?? 0;
6824
- return [...pairs.values()].filter((candidate) => {
6825
- if (candidate.matchScore < minScore) return false;
6826
- if (candidate.dismissed === true && !includeDismissed) return false;
6827
- return true;
6828
- }).sort((left, right) => {
6829
- const leftExact = left.matchScore === 1 ? 1 : 0;
6830
- const rightExact = right.matchScore === 1 ? 1 : 0;
6831
- return rightExact - leftExact || right.matchScore - left.matchScore;
6832
- }).slice(offset, offset + limit);
7677
+ const attributeFilters = contactAttributeClauses(filters);
7678
+ clauses.push(...attributeFilters.clauses);
7679
+ params.push(...attributeFilters.params);
7680
+ const where = clauses.join(" AND ");
7681
+ const rows = db.prepare(
7682
+ `SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
7683
+ created_at, updated_at, deleted_at,
7684
+ (
7685
+ SELECT value
7686
+ FROM identities i
7687
+ WHERE i.contact_id = contacts.id AND i.removed_at IS NULL
7688
+ ORDER BY i.id
7689
+ LIMIT 1
7690
+ ) AS primary_identity
7691
+ FROM contacts
7692
+ WHERE ${where}
7693
+ ORDER BY name ASC
7694
+ LIMIT ? OFFSET ?`
7695
+ ).all(...params, limit, offset);
7696
+ return rows.map((r) => {
7697
+ const item = mapContactRowToContactListItem(r);
7698
+ return {
7699
+ ...item,
7700
+ primaryIdentity: r.primary_identity ?? null
7701
+ };
7702
+ });
6833
7703
  }
6834
7704
 
6835
7705
  // src/dates/list_upcoming_dates.ts
@@ -6865,12 +7735,12 @@ function listUpcomingDates(db, filters, options) {
6865
7735
  }
6866
7736
  const clauses = ["e.deleted_at IS NULL", "e.type = 'date_anchor'"];
6867
7737
  const params = [];
6868
- const since = options?.since ?? utcStartOfDayMs(Date.now());
7738
+ const since = options?.since ?? Date.parse(`${localCalendarDate(options?.now ?? Date.now())}T00:00:00Z`);
6869
7739
  clauses.push("uo.occurs_on >= ?");
6870
7740
  params.push(since);
6871
7741
  let maxOccurs = options?.until;
6872
7742
  if (filters?.horizonDays !== void 0) {
6873
- const anchor = options?.since ?? Date.now();
7743
+ const anchor = since;
6874
7744
  const horizonEnd = anchor + filters.horizonDays * 864e5;
6875
7745
  maxOccurs = maxOccurs === void 0 ? horizonEnd : Math.min(maxOccurs, horizonEnd);
6876
7746
  }
@@ -6917,6 +7787,68 @@ function listUpcomingDates(db, filters, options) {
6917
7787
  return rows.map((r) => mapUpcomingRowToUpcomingDateRecord(r));
6918
7788
  }
6919
7789
 
7790
+ // src/events/event_discovery.ts
7791
+ function searchEvents(db, query, options) {
7792
+ const { limit, offset } = resolveListLimitOffset(options);
7793
+ const tokens = foldSearchText(query).split(/\s+/).filter(Boolean);
7794
+ if (!tokens.length) return [];
7795
+ const rows = db.prepare(
7796
+ "SELECT id,type,occurred_at,occurred_on,CAST(summary AS BLOB) AS summary,CAST(content AS BLOB) AS content,external_source,external_id,channel FROM events WHERE deleted_at IS NULL ORDER BY occurred_at DESC,id DESC"
7797
+ ).all();
7798
+ return rows.flatMap((row) => {
7799
+ const fields = {
7800
+ summary: decodeEventText(row.summary),
7801
+ content: row.content === null ? null : decodeEventText(row.content),
7802
+ source: row.external_source,
7803
+ id: row.external_id,
7804
+ channel: row.channel
7805
+ };
7806
+ const folded = Object.entries(fields).map(
7807
+ ([key, value]) => [key, foldSearchText(value ?? "")]
7808
+ );
7809
+ if (!tokens.every((t) => folded.some(([, v]) => v.includes(t))))
7810
+ return [];
7811
+ return [
7812
+ {
7813
+ kind: "event",
7814
+ id: row.id,
7815
+ type: row.type,
7816
+ title: deriveEventSummary(decodeEventText(row.summary)),
7817
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
7818
+ matchedOn: folded.filter(([, v]) => tokens.some((t) => v.includes(t))).map(([k]) => k),
7819
+ ...row.external_source && row.external_id ? {
7820
+ externalRef: {
7821
+ source: row.external_source,
7822
+ id: row.external_id
7823
+ }
7824
+ } : {}
7825
+ }
7826
+ ];
7827
+ }).slice(offset, offset + limit);
7828
+ }
7829
+ function getEventThread(db, source, id, options) {
7830
+ const { limit, offset } = resolveListLimitOffset(options);
7831
+ const root = getEventByExternalRef(db, source, id);
7832
+ if (!root) return [];
7833
+ const rows = db.prepare(`WITH RECURSIVE thread(id) AS (
7834
+ SELECT ? UNION SELECT e.id FROM events e JOIN thread t JOIN events p ON p.id=t.id
7835
+ WHERE e.deleted_at IS NULL AND (e.parent_event_id=p.id OR (e.parent_source=p.external_source AND e.parent_ref_id=p.external_id) OR (e.thread_source=? AND e.thread_id=?))
7836
+ ) SELECT e.id FROM events e JOIN thread t ON e.id=t.id WHERE e.deleted_at IS NULL AND (? IS NULL OR e.channel=?) ORDER BY e.occurred_at,e.id LIMIT ? OFFSET ?`).all(
7837
+ root.id,
7838
+ root.externalRef?.source ?? null,
7839
+ root.externalRef?.id ?? null,
7840
+ options?.channel ?? null,
7841
+ options?.channel?.trim().toLowerCase() ?? null,
7842
+ limit,
7843
+ offset
7844
+ );
7845
+ return batchLoadEventRecords(
7846
+ db,
7847
+ rows.map((r) => r.id),
7848
+ options?.includeContent ?? false
7849
+ );
7850
+ }
7851
+
6920
7852
  // src/graph/get_affinity_chart.ts
6921
7853
  function getAffinityChart(db, options) {
6922
7854
  const contactClauses = [
@@ -7102,21 +8034,6 @@ function listProgressionReadiness(db, filters, options) {
7102
8034
  }
7103
8035
 
7104
8036
  // src/links/list_radar.ts
7105
- function deriveRadarReason(drift, recency, normalizedRank3) {
7106
- if (drift > 0.5) {
7107
- return "Significantly overdue for contact";
7108
- }
7109
- if (drift > 0.2) {
7110
- return "Drifting \u2014 approaching overdue";
7111
- }
7112
- if (recency < 0.3 && normalizedRank3 > 0.3) {
7113
- return "Important link losing recency";
7114
- }
7115
- if (normalizedRank3 > 0.5 && recency < 0.5) {
7116
- return "High-rank link needs attention";
7117
- }
7118
- return "Routine check-in recommended";
7119
- }
7120
8037
  function listRadar(db, filters, options) {
7121
8038
  assertDefaultOrdering("listRadar", options);
7122
8039
  const ownerId = findOwnerContactId(db);
@@ -7124,7 +8041,10 @@ function listRadar(db, filters, options) {
7124
8041
  return [];
7125
8042
  }
7126
8043
  const now = resolveNow(options?.now);
7127
- const overdue = `EXISTS (SELECT 1 FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND oc.due_at < ${now})`;
8044
+ const today = localCalendarDate(now);
8045
+ const lastContact = `(SELECT MAX(le.occurred_at) FROM link_event_effects le JOIN events ev ON ev.id = le.event_id WHERE le.link_id = l.id AND le.counts_for_recency = 1 AND ev.deleted_at IS NULL AND (le.occurred_at <= ${now} OR ev.occurred_on <= '${today}'))`;
8046
+ const drift = `(${lastContact} IS NOT NULL AND (${now} - ${lastContact}) > 1.5 * COALESCE(l.cadence_days, 1) * 86400000)`;
8047
+ 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 (CASE WHEN oc.due_on IS NOT NULL AND oc.due_at = CAST(strftime('%s', oc.due_on) AS INTEGER) * 1000 + 86399999 THEN oc.due_on < '${today}' ELSE oc.due_at < ${now} END))`;
7128
8048
  const { limit, offset } = resolveListLimitOffset(options);
7129
8049
  const clauses = [
7130
8050
  "l.removed_at IS NULL",
@@ -7149,10 +8069,23 @@ function listRadar(db, filters, options) {
7149
8069
  if (options?.includeObserved === false) {
7150
8070
  clauses.push("l.kind != 'observed'");
7151
8071
  }
8072
+ if (options?.includeDormant !== true)
8073
+ clauses.push("c_to.lifecycle_state != 'dormant'");
8074
+ if (filters?.includeLost !== true)
8075
+ clauses.push("c_to.lifecycle_state != 'lost'");
8076
+ const other = `CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END`;
8077
+ clauses.push(`(${lastContact} IS NOT NULL OR ${overdue} OR NOT EXISTS (
8078
+ SELECT 1 FROM event_participants ep JOIN events ev ON ev.id=ep.event_id
8079
+ WHERE ep.contact_id=${other} AND ev.deleted_at IS NULL
8080
+ AND (ev.affects_bond=0 OR (ev.type='observation' AND ev.significance<=2) OR json_extract(ev.provenance_json, '$.ambient')=1 OR ep.directionality='owner_initiated' OR EXISTS (SELECT 1 FROM event_participants op WHERE op.event_id=ev.id AND op.contact_id=${ownerId} AND op.directionality='owner_initiated'))
8081
+ ))`);
7152
8082
  const where = clauses.join(" AND ");
7153
8083
  const rows = db.prepare(
7154
8084
  `SELECT
7155
8085
  l.id,
8086
+ l.cadence_days,
8087
+ ${lastContact} AS last_contact,
8088
+ ${drift} AS drift,
7156
8089
  ${overdue} AS overdue_commitment,
7157
8090
  l.from_contact_id,
7158
8091
  l.to_contact_id,
@@ -7166,23 +8099,44 @@ function listRadar(db, filters, options) {
7166
8099
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
7167
8100
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
7168
8101
  WHERE ${where}
7169
- ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
8102
+ ORDER BY (${overdue} OR ${drift}) DESC, ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
7170
8103
  LIMIT ? OFFSET ?`
7171
8104
  ).all(...params, limit, offset);
7172
- return rows.map((row) => ({
7173
- linkId: row.id,
7174
- contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
7175
- reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
7176
- driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
7177
- recencyScore: row.recency_score ?? 0,
7178
- normalizedRank: row.normalized_rank ?? 0,
7179
- trust: row.trust ?? 0,
7180
- recommendedReason: deriveRadarReason(
7181
- row.drift_priority ?? 0,
7182
- row.recency_score ?? 0,
7183
- row.normalized_rank ?? 0
7184
- )
7185
- }));
8105
+ return rows.map((row) => {
8106
+ const contactId = row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id;
8107
+ const promise = row.overdue_commitment ? db.prepare(
8108
+ `SELECT e.summary, oc.due_at, oc.due_on 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 = ? AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND ${OVERDUE_SQL} ORDER BY oc.due_at LIMIT 1`
8109
+ ).get(contactId, today, now) : void 0;
8110
+ const reasons = [
8111
+ ...row.overdue_commitment ? ["overdue_commitment"] : [],
8112
+ ...row.drift ? ["drift"] : []
8113
+ ];
8114
+ const snippets = [
8115
+ ...promise ? [
8116
+ `Overdue promise: ${promise.summary} (due ${promise.due_on ?? new Date(promise.due_at).toISOString().slice(0, 10)})`
8117
+ ] : [],
8118
+ ...row.drift ? [
8119
+ `No contact for ${Math.floor((now - (row.last_contact ?? now)) / 864e5)} days (cadence ${row.cadence_days ?? 1} days)`
8120
+ ] : []
8121
+ ];
8122
+ const elapsed = row.last_contact === null ? null : Math.max(0, (now - row.last_contact) / 864e5);
8123
+ const currentDrift = elapsed === null ? 0 : driftPriority({
8124
+ daysSinceLastMeaningfulEvent: elapsed,
8125
+ cadenceDays: row.cadence_days ?? 1,
8126
+ trust: row.trust ?? 0,
8127
+ rank: row.rank ?? 0
8128
+ });
8129
+ return {
8130
+ linkId: row.id,
8131
+ contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
8132
+ reasons,
8133
+ driftPriority: row.overdue_commitment ? Math.max(1, currentDrift) : currentDrift,
8134
+ recencyScore: elapsed === null ? 0 : Math.max(0, 1 - elapsed / Math.max(row.cadence_days ?? 1, 1)),
8135
+ normalizedRank: row.normalized_rank ?? 0,
8136
+ trust: row.trust ?? 0,
8137
+ recommendedReason: snippets.join("; ") || "Routine check-in recommended"
8138
+ };
8139
+ });
7186
8140
  }
7187
8141
 
7188
8142
  // src/merges/get_merge_history.ts
@@ -7252,13 +8206,40 @@ function enrichReviewItem(db, item) {
7252
8206
  result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
7253
8207
  }
7254
8208
  if (Array.isArray(item.participants)) {
7255
- result.counterpartyNames = item.participants.map(
7256
- (p) => name(p.contactId)
8209
+ result.participants = item.participants.map(
8210
+ (p) => ({
8211
+ id: p.contactId,
8212
+ name: name(p.contactId),
8213
+ role: p.role
8214
+ })
7257
8215
  );
7258
- result.subtitle = [
7259
- item.subtitle,
7260
- result.counterpartyNames.join(", ")
7261
- ].filter(Boolean).join(" \xB7 ");
8216
+ for (const key of ["title", "subtitle", "snippet", "state", "eventId"])
8217
+ result[key] = void 0;
8218
+ }
8219
+ if (result.momentKind === null) result.momentKind = void 0;
8220
+ if (result.eventId === result.id) result.eventId = void 0;
8221
+ if (result.linkId === result.id) result.linkId = void 0;
8222
+ if (result.title === result.summary) result.title = void 0;
8223
+ if (item.kind === "link" && item.occursAt !== void 0) {
8224
+ for (const key of [
8225
+ "affinity",
8226
+ "trust",
8227
+ "rank",
8228
+ "recencyScore",
8229
+ "driftPriority",
8230
+ "readinessScore",
8231
+ "normalizedRank",
8232
+ "reciprocityScore",
8233
+ "radarScore",
8234
+ "subtitle",
8235
+ "snippet",
8236
+ "targetRef",
8237
+ "occursOnDate"
8238
+ ])
8239
+ result[key] = void 0;
8240
+ } else if (item.kind === "link") {
8241
+ result.recommendedReason = void 0;
8242
+ result.snippet = void 0;
7262
8243
  }
7263
8244
  if (typeof item.leftContactId === "number")
7264
8245
  result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
@@ -7287,6 +8268,8 @@ function summarizeCount(count, singular) {
7287
8268
  // src/tools/review_affinity_tool.ts
7288
8269
  function buildReadOptions(input) {
7289
8270
  return {
8271
+ ...input.includeContent === void 0 ? {} : { includeContent: input.includeContent },
8272
+ ...input.channel === void 0 ? {} : { channel: input.channel },
7290
8273
  ...input.now === void 0 ? {} : { now: input.now },
7291
8274
  ...input.limit === void 0 ? {} : { limit: input.limit },
7292
8275
  ...input.offset === void 0 ? {} : { offset: input.offset },
@@ -7423,7 +8406,7 @@ function momentItem(moment) {
7423
8406
  id: moment.eventId,
7424
8407
  title: moment.summary,
7425
8408
  subtitle: moment.momentKind,
7426
- snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
8409
+ snippet: moment.occurredOn ?? new Date(moment.occurredAt).toISOString().slice(0, 10)
7427
8410
  };
7428
8411
  }
7429
8412
  function commitmentItem(commitment) {
@@ -7433,7 +8416,7 @@ function commitmentItem(commitment) {
7433
8416
  id: commitment.eventId,
7434
8417
  title: commitment.summary,
7435
8418
  subtitle: commitment.type,
7436
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
8419
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : commitment.dueOn ?? new Date(commitment.dueAt).toISOString().slice(0, 10)}`
7437
8420
  };
7438
8421
  }
7439
8422
  function radarItem(radar) {
@@ -7449,6 +8432,7 @@ function radarItem(radar) {
7449
8432
  function upcomingDateItem(date) {
7450
8433
  return {
7451
8434
  ...date,
8435
+ occursOn: new Date(date.occursOn).toISOString().slice(0, 10),
7452
8436
  occursAt: date.occursOn,
7453
8437
  occursAtIso: new Date(date.occursOn).toISOString(),
7454
8438
  kind: date.targetRef.kind,
@@ -7486,6 +8470,9 @@ function compactFilters(input) {
7486
8470
  );
7487
8471
  }
7488
8472
  function reviewAffinityToolHandler(db, rawInput) {
8473
+ return reviewPage(db, rawInput, false);
8474
+ }
8475
+ function reviewPage(db, rawInput, pageOnly) {
7489
8476
  const input = structuredClone(rawInput);
7490
8477
  const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
7491
8478
  if (inputError)
@@ -7500,6 +8487,41 @@ function reviewAffinityToolHandler(db, rawInput) {
7500
8487
  if (invalid) {
7501
8488
  return invalid;
7502
8489
  }
8490
+ if (!pageOnly && input.view !== "graph.chart") {
8491
+ const limit = input.limit ?? 50;
8492
+ const offset = input.offset ?? 0;
8493
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
8494
+ return toolFailure(
8495
+ "protocol",
8496
+ "invalid_input",
8497
+ "Invalid pagination.",
8498
+ "limit must be 1..250; offset must be non-negative"
8499
+ );
8500
+ const all = [];
8501
+ let page;
8502
+ do {
8503
+ page = reviewPage(
8504
+ db,
8505
+ { ...input, limit: 250, offset: all.length },
8506
+ true
8507
+ );
8508
+ if (!page.ok) return page;
8509
+ all.push(...page.data.items);
8510
+ } while (page.data.items.length === 250);
8511
+ const items2 = all.slice(offset, offset + limit);
8512
+ return toolSuccess(
8513
+ summarizeCount(items2.length, "item"),
8514
+ {
8515
+ view: input.view,
8516
+ items: items2,
8517
+ count: items2.length,
8518
+ total: all.length,
8519
+ hasMore: offset + items2.length < all.length,
8520
+ appliedFilters: compactFilters(input)
8521
+ },
8522
+ { entities: items2.map(({ content: _content, ...item }) => item) }
8523
+ );
8524
+ }
7503
8525
  const options = buildReadOptions(input);
7504
8526
  let items = [];
7505
8527
  let chart;
@@ -7531,6 +8553,19 @@ function reviewAffinityToolHandler(db, rawInput) {
7531
8553
  options
7532
8554
  ).map(duplicateCandidateItem);
7533
8555
  break;
8556
+ case "events.thread": {
8557
+ if (!input.ref)
8558
+ return toolFailure(
8559
+ "protocol",
8560
+ "invalid_input",
8561
+ "Missing thread reference.",
8562
+ "ref is required for events.thread."
8563
+ );
8564
+ items = getEventThread(db, input.ref.source, input.ref.id, options).map(
8565
+ toEventListToolItem
8566
+ );
8567
+ break;
8568
+ }
7534
8569
  case "events.contact_journal": {
7535
8570
  const locator = input.contact;
7536
8571
  if (locator === void 0) {
@@ -7672,6 +8707,7 @@ function reviewAffinityToolHandler(db, rawInput) {
7672
8707
  items = listRadar(
7673
8708
  db,
7674
8709
  {
8710
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
7675
8711
  ...input.kind === void 0 ? {} : { kind: input.kind },
7676
8712
  ...input.linkState === void 0 ? {} : { state: input.linkState }
7677
8713
  },
@@ -7721,6 +8757,14 @@ function reviewAffinityToolHandler(db, rawInput) {
7721
8757
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
7722
8758
  break;
7723
8759
  }
8760
+ if (input.includeContent && ["events.moments", "commitments.open"].includes(input.view))
8761
+ items = items.map((item) => {
8762
+ const content = getEventById(
8763
+ db,
8764
+ Number(item.eventId ?? item.id)
8765
+ )?.content;
8766
+ return content === void 0 ? item : { ...item, content };
8767
+ });
7724
8768
  items = items.map((item) => enrichReviewItem(db, item));
7725
8769
  return toolSuccess(
7726
8770
  summarizeCount(items.length, "item"),
@@ -7732,7 +8776,7 @@ function reviewAffinityToolHandler(db, rawInput) {
7732
8776
  ...chart === void 0 ? {} : { chart }
7733
8777
  },
7734
8778
  {
7735
- entities: items,
8779
+ entities: items.map(({ content: _content, ...item }) => item),
7736
8780
  warnings: items.length === 0 ? [
7737
8781
  toolWarning(
7738
8782
  "empty_result",
@@ -7784,9 +8828,15 @@ var reviewAffinityTool = defineAffinityTool({
7784
8828
  outputDescription: "Returns compact review items, a count, the applied filters, and the chart payload for graph review.",
7785
8829
  inputSchema: objectSchema(
7786
8830
  {
8831
+ ref: externalRefSchema,
8832
+ includeContent: booleanSchema(
8833
+ "Include verbatim content in event rows; defaults to false."
8834
+ ),
8835
+ channel: stringSchema("Filter events by channel."),
7787
8836
  view: enumSchema("Which review surface to return.", [
7788
8837
  "contacts.list",
7789
8838
  "contacts.duplicates",
8839
+ "events.thread",
7790
8840
  "events.contact_journal",
7791
8841
  "events.link_timeline",
7792
8842
  "events.moments",
@@ -7841,7 +8891,20 @@ var reviewAffinityTool = defineAffinityTool({
7841
8891
  includeArchived: booleanSchema("Whether archived links are included."),
7842
8892
  includeObserved: booleanSchema("Whether observed links are included."),
7843
8893
  eventTypes: arraySchema(
7844
- stringSchema("Event type."),
8894
+ enumSchema("Event type.", [
8895
+ "conversation",
8896
+ "activity",
8897
+ "gift",
8898
+ "support",
8899
+ "milestone",
8900
+ "observation",
8901
+ "conflict",
8902
+ "correction",
8903
+ "transaction",
8904
+ "promise",
8905
+ "agreement",
8906
+ "date_anchor"
8907
+ ]),
7845
8908
  "Optional event-type filter."
7846
8909
  ),
7847
8910
  momentKind: enumSchema("Optional moment kind filter.", [
@@ -7881,7 +8944,7 @@ var reviewAffinityTool = defineAffinityTool({
7881
8944
 
7882
8945
  // src/contacts/search_contacts.ts
7883
8946
  function searchContacts(db, query, filters, options) {
7884
- const q = foldSearchText(query.trim());
8947
+ const q = foldSearchText(query.trim().replace(/^@/, ""));
7885
8948
  if (!q) return [];
7886
8949
  assertDefaultOrdering("searchContacts", options);
7887
8950
  const { limit, offset } = resolveListLimitOffset(options);
@@ -7901,6 +8964,7 @@ function searchContacts(db, query, filters, options) {
7901
8964
  const tokens = q.split(/\s+/);
7902
8965
  const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
7903
8966
  const digits = q.replace(/\D/g, "").replace(/^00/, "");
8967
+ const suffix = digits.replace(/^0/, "");
7904
8968
  const attributeFilters = contactAttributeClauses(filters);
7905
8969
  const clauses = [
7906
8970
  "deleted_at IS NULL",
@@ -7936,7 +9000,7 @@ function searchContacts(db, query, filters, options) {
7936
9000
  )
7937
9001
  ];
7938
9002
  const phoneFields = phone ? values.filter(
7939
- (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
9003
+ (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits) || field.source === "identity:phone" && suffix.length >= 6 && field.text.replace(/\D/g, "").endsWith(suffix)
7940
9004
  ) : [];
7941
9005
  if (!phoneFields.length && !tokens.every(
7942
9006
  (token) => values.some((field) => field.text.includes(token))
@@ -7966,6 +9030,9 @@ function searchContacts(db, query, filters, options) {
7966
9030
 
7967
9031
  // src/tools/search_affinity_tool.ts
7968
9032
  function searchAffinityToolHandler(db, rawInput) {
9033
+ return searchPage(db, rawInput, false);
9034
+ }
9035
+ function searchPage(db, rawInput, pageOnly) {
7969
9036
  const input = structuredClone(rawInput);
7970
9037
  const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
7971
9038
  if (inputError)
@@ -7976,11 +9043,53 @@ function searchAffinityToolHandler(db, rawInput) {
7976
9043
  inputError
7977
9044
  );
7978
9045
  return withToolHandling(() => {
9046
+ if (!pageOnly) {
9047
+ const limit = input.limit ?? 50;
9048
+ const offset = input.offset ?? 0;
9049
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
9050
+ return toolFailure(
9051
+ "protocol",
9052
+ "invalid_input",
9053
+ "Invalid pagination.",
9054
+ "limit must be 1..250; offset must be non-negative"
9055
+ );
9056
+ const all = [];
9057
+ const scopes = input.scope === "all" ? ["contacts", "events"] : [input.scope ?? "contacts"];
9058
+ for (const scope of scopes) {
9059
+ let page;
9060
+ let nextOffset = 0;
9061
+ do {
9062
+ page = searchPage(
9063
+ db,
9064
+ { ...input, scope, limit: 250, offset: nextOffset },
9065
+ true
9066
+ );
9067
+ if (!page.ok) return page;
9068
+ all.push(...page.data.items);
9069
+ nextOffset += page.data.count;
9070
+ } while (page.data.count === 250);
9071
+ }
9072
+ const items2 = all.slice(offset, offset + limit);
9073
+ return toolSuccess(
9074
+ summarizeCount(
9075
+ items2.length,
9076
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
9077
+ ),
9078
+ {
9079
+ query: input.query,
9080
+ items: items2,
9081
+ count: items2.length,
9082
+ total: all.length,
9083
+ hasMore: offset + items2.length < all.length
9084
+ },
9085
+ { entities: items2 }
9086
+ );
9087
+ }
7979
9088
  const options = {
7980
9089
  ...input.limit === void 0 ? {} : { limit: input.limit },
7981
9090
  ...input.offset === void 0 ? {} : { offset: input.offset }
7982
9091
  };
7983
- const rows = searchContacts(
9092
+ const rows = input.scope === "events" ? [] : searchContacts(
7984
9093
  db,
7985
9094
  input.query,
7986
9095
  {
@@ -7994,9 +9103,15 @@ function searchAffinityToolHandler(db, rawInput) {
7994
9103
  },
7995
9104
  options
7996
9105
  );
7997
- const items = rows.map(toContactListToolItem);
9106
+ const items = [
9107
+ ...rows.map(toContactListToolItem),
9108
+ ...input.scope === "events" || input.scope === "all" ? searchEvents(db, input.query, options) : []
9109
+ ];
7998
9110
  return toolSuccess(
7999
- summarizeCount(items.length, "contact"),
9111
+ summarizeCount(
9112
+ items.length,
9113
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
9114
+ ),
8000
9115
  {
8001
9116
  query: input.query,
8002
9117
  items,
@@ -8032,6 +9147,11 @@ var searchAffinityTool = defineAffinityTool({
8032
9147
  outputDescription: "Returns compact contact hits, the original query, and the hit count.",
8033
9148
  inputSchema: objectSchema(
8034
9149
  {
9150
+ scope: enumSchema("Search scope; defaults to contacts.", [
9151
+ "contacts",
9152
+ "events",
9153
+ "all"
9154
+ ]),
8035
9155
  query: stringSchema("Free-text or natural-key query."),
8036
9156
  kind: enumSchema("Optional contact kind filter.", [
8037
9157
  "human",
@@ -8083,72 +9203,36 @@ var affinityTools = [
8083
9203
  recordEventTool,
8084
9204
  manageCommitmentTool,
8085
9205
  manageDateAnchorTool,
8086
- manageAttributeTool
9206
+ manageAttributeTool,
9207
+ manageEventTool
8087
9208
  ];
8088
9209
 
8089
9210
  // src/surface/plain_dates.ts
8090
- function parsePlainDue(raw, now = Date.now(), endOfDay = true) {
8091
- const invalid = () => new CliUsageError(
8092
- `Invalid timestamp "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d, -2d or an ISO date-time.`
8093
- );
8094
- if (/^-?\d+$/.test(raw)) {
8095
- const value = Number(raw);
8096
- if (Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()))
8097
- return value;
8098
- throw invalid();
8099
- }
8100
- const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
8101
- const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
8102
- let date;
8103
- if (iso || european) {
8104
- const year = Number(iso?.[1] ?? european?.[3]);
8105
- const month = Number(iso?.[2] ?? european?.[2]);
8106
- const day = Number(iso?.[3] ?? european?.[1]);
8107
- date = /* @__PURE__ */ new Date(0);
8108
- date.setFullYear(year, month - 1, day);
8109
- date.setHours(
8110
- endOfDay ? 23 : 0,
8111
- endOfDay ? 59 : 0,
8112
- endOfDay ? 59 : 0,
8113
- endOfDay ? 999 : 0
8114
- );
8115
- if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
8116
- throw invalid();
8117
- } else if (raw === "today" || raw === "tomorrow" || raw === "yesterday" || /^[+-]\d+d$/.test(raw)) {
8118
- date = new Date(now);
8119
- date.setDate(
8120
- date.getDate() + (raw === "today" ? 0 : raw === "tomorrow" ? 1 : raw === "yesterday" ? -1 : Number(raw.slice(0, -1)))
8121
- );
8122
- date.setHours(
8123
- endOfDay ? 23 : 0,
8124
- endOfDay ? 59 : 0,
8125
- endOfDay ? 59 : 0,
8126
- endOfDay ? 999 : 0
8127
- );
8128
- } else {
8129
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
8130
- raw
8131
- ))
8132
- throw invalid();
8133
- parsePlainDue(raw.slice(0, 10), now);
8134
- date = new Date(raw);
8135
- }
8136
- if (!Number.isFinite(date.getTime())) throw invalid();
8137
- return date.getTime();
8138
- }
8139
- function parsePlainStart(raw, now = Date.now()) {
8140
- return parsePlainDue(raw, now, false);
8141
- }
8142
- function normalizeTimeInputs(value, now = Date.now()) {
9211
+ function normalizeTimeInputs(value, now = Date.now(), inPatch = false) {
8143
9212
  if (Array.isArray(value))
8144
9213
  return value.map((item) => normalizeTimeInputs(item, now));
8145
9214
  if (!value || typeof value !== "object") return value;
8146
- return Object.fromEntries(
8147
- Object.entries(value).map(([key, item]) => [
8148
- key,
8149
- typeof item === "string" && (key.endsWith("At") || ["asOf", "now"].includes(key)) ? key === "dueAt" ? parsePlainDue(item, now) : parsePlainStart(item, now) : normalizeTimeInputs(item, now)
8150
- ])
8151
- );
9215
+ const result = {};
9216
+ for (const [key, item] of Object.entries(value)) {
9217
+ if (key === "occurredAt" && item === null)
9218
+ throw new CliUsageError(
9219
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
9220
+ );
9221
+ if (typeof item === "string" && (key.endsWith("At") || ["asOf", "now", "since", "until"].includes(key))) {
9222
+ let parsed;
9223
+ try {
9224
+ parsed = parseCalendarTime(item, key, now);
9225
+ } catch (error) {
9226
+ throw new CliUsageError(
9227
+ `Invalid timestamp "${item}". ${error.message}`
9228
+ );
9229
+ }
9230
+ result[key] = parsed.timestamp;
9231
+ if (!inPatch && ["occurredAt", "dueAt"].includes(key))
9232
+ result[key.replace(/At$/, "On")] = parsed.day;
9233
+ } else result[key] = normalizeTimeInputs(item, now, key === "patch");
9234
+ }
9235
+ return result;
8152
9236
  }
8153
9237
 
8154
9238
  // src/surface/timestamp_schema.ts
@@ -8175,20 +9259,57 @@ function timestampSchema(schema) {
8175
9259
  }
8176
9260
 
8177
9261
  // src/surface/affinity_surface_tools.ts
9262
+ function surfaceProperties(tool) {
9263
+ return {
9264
+ ...tool.inputSchema.properties,
9265
+ ...["inspect_affinity_item", "review_affinity", "manage_event"].includes(
9266
+ tool.name
9267
+ ) ? {
9268
+ refSource: {
9269
+ type: "string",
9270
+ description: "External reference source."
9271
+ },
9272
+ refId: {
9273
+ type: "string",
9274
+ description: "External reference id."
9275
+ }
9276
+ } : {},
9277
+ ...tool.name === "review_affinity" ? {
9278
+ withContent: {
9279
+ type: "boolean",
9280
+ description: "Include verbatim event content."
9281
+ }
9282
+ } : {}
9283
+ };
9284
+ }
8178
9285
  var affinitySurfaceTools = affinityTools.map((tool) => ({
8179
9286
  name: tool.name,
8180
9287
  description: tool.description,
8181
- whenToUse: tool.whenToUse,
8182
- ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
8183
9288
  readOnly: tool.readOnly,
8184
9289
  sideEffects: tool.sideEffects,
8185
9290
  inputSchema: {
8186
- ...timestampSchema({ ...tool.inputSchema, type: "object" }),
9291
+ ...timestampSchema({
9292
+ ...tool.inputSchema,
9293
+ properties: surfaceProperties(tool),
9294
+ type: "object"
9295
+ }),
8187
9296
  type: "object"
8188
9297
  },
8189
9298
  handler: (db, input) => {
8190
9299
  try {
8191
- return tool.handler(db, normalizeTimeInputs(input));
9300
+ const { withContent, refSource, refId, ...normalized } = input;
9301
+ if (withContent !== void 0) normalized.includeContent = withContent;
9302
+ if (refSource !== void 0 || refId !== void 0) {
9303
+ if (normalized.ref !== void 0)
9304
+ return toolFailure(
9305
+ "protocol",
9306
+ "invalid_input",
9307
+ "Invalid target.",
9308
+ "Provide ref or ref-source/ref-id, not both."
9309
+ );
9310
+ normalized.ref = { source: refSource, id: refId };
9311
+ }
9312
+ return tool.handler(db, normalizeTimeInputs(normalized));
8192
9313
  } catch (error) {
8193
9314
  if (error instanceof CliUsageError)
8194
9315
  return toolFailure(
@@ -8213,7 +9334,7 @@ function affinityMcpOptions(context) {
8213
9334
  context,
8214
9335
  annotations: (tool) => ({
8215
9336
  readOnlyHint: tool.readOnly ?? false,
8216
- destructiveHint: tool.name === "merge_contacts" || /remove|unset|undismiss/.test(
9337
+ destructiveHint: tool.name === "merge_contacts" || tool.name === "manage_event" || /remove|unset|undismiss/.test(
8217
9338
  String(tool.inputSchema.properties?.action?.enum ?? [])
8218
9339
  ),
8219
9340
  idempotentHint: tool.readOnly ?? false,
@@ -8222,13 +9343,95 @@ function affinityMcpOptions(context) {
8222
9343
  };
8223
9344
  }
8224
9345
 
9346
+ // src/events/init_events_tables.ts
9347
+ function initEventsTables(db) {
9348
+ db.exec(`
9349
+ CREATE TABLE IF NOT EXISTS events (
9350
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9351
+ type TEXT NOT NULL,
9352
+ occurred_at INTEGER NOT NULL,
9353
+ summary TEXT NOT NULL,
9354
+ significance REAL NOT NULL DEFAULT 0,
9355
+ moment_kind TEXT,
9356
+ recurrence_kind TEXT,
9357
+ anchor_month INTEGER,
9358
+ anchor_day INTEGER,
9359
+ anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
9360
+ anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
9361
+ created_at INTEGER NOT NULL,
9362
+ updated_at INTEGER NOT NULL,
9363
+ deleted_at INTEGER,
9364
+ CHECK (length(trim(summary)) > 0),
9365
+ CHECK (
9366
+ type IN (
9367
+ 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
9368
+ 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
9369
+ )
9370
+ ),
9371
+ CHECK (
9372
+ (type = 'date_anchor' AND (
9373
+ (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
9374
+ OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
9375
+ ))
9376
+ OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
9377
+ ),
9378
+ CHECK (moment_kind IS NULL OR moment_kind IN (
9379
+ 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
9380
+ )),
9381
+ CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
9382
+ 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
9383
+ )),
9384
+ CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
9385
+ CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
9386
+ )
9387
+ `);
9388
+ db.exec(
9389
+ "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
9390
+ );
9391
+ db.exec(
9392
+ "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
9393
+ );
9394
+ const columns = db.prepare("PRAGMA table_info(events)").all();
9395
+ if (!columns.some((c) => c.name === "declared_moment_kind"))
9396
+ db.exec("ALTER TABLE events ADD COLUMN declared_moment_kind TEXT");
9397
+ if (!columns.some((c) => c.name === "affects_bond"))
9398
+ db.exec("ALTER TABLE events ADD COLUMN affects_bond INTEGER");
9399
+ if (!columns.some((c) => c.name === "content"))
9400
+ db.exec("ALTER TABLE events ADD COLUMN content TEXT");
9401
+ if (!columns.some((c) => c.name === "external_source"))
9402
+ db.exec("ALTER TABLE events ADD COLUMN external_source TEXT");
9403
+ if (!columns.some((c) => c.name === "external_id"))
9404
+ db.exec("ALTER TABLE events ADD COLUMN external_id TEXT");
9405
+ if (!columns.some((c) => c.name === "channel"))
9406
+ db.exec("ALTER TABLE events ADD COLUMN channel TEXT");
9407
+ if (!columns.some((c) => c.name === "parent_source"))
9408
+ db.exec("ALTER TABLE events ADD COLUMN parent_source TEXT");
9409
+ if (!columns.some((c) => c.name === "parent_ref_id"))
9410
+ db.exec("ALTER TABLE events ADD COLUMN parent_ref_id TEXT");
9411
+ if (!columns.some((c) => c.name === "parent_event_id"))
9412
+ db.exec("ALTER TABLE events ADD COLUMN parent_event_id INTEGER");
9413
+ if (!columns.some((c) => c.name === "thread_source"))
9414
+ db.exec("ALTER TABLE events ADD COLUMN thread_source TEXT");
9415
+ if (!columns.some((c) => c.name === "thread_id"))
9416
+ db.exec("ALTER TABLE events ADD COLUMN thread_id TEXT");
9417
+ if (!columns.some((c) => c.name === "provenance_json"))
9418
+ db.exec("ALTER TABLE events ADD COLUMN provenance_json TEXT");
9419
+ db.exec(
9420
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external_ref ON events(external_source, external_id)"
9421
+ );
9422
+ if (!columns.some((column) => column.name === "occurred_on"))
9423
+ db.exec("ALTER TABLE events ADD COLUMN occurred_on TEXT");
9424
+ }
9425
+
8225
9426
  // src/attributes/init_attributes_tables.ts
8226
9427
  function initAttributesTables(db) {
9428
+ initEventsTables(db);
8227
9429
  db.exec(`
8228
9430
  CREATE TABLE IF NOT EXISTS attributes (
8229
9431
  id INTEGER PRIMARY KEY AUTOINCREMENT,
8230
9432
  contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
8231
9433
  link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
9434
+ event_id INTEGER REFERENCES events (id) ON DELETE CASCADE,
8232
9435
  name TEXT NOT NULL,
8233
9436
  value TEXT,
8234
9437
  created_at INTEGER NOT NULL,
@@ -8236,15 +9439,35 @@ function initAttributesTables(db) {
8236
9439
  deleted_at INTEGER,
8237
9440
  CHECK (length(trim(name)) > 0),
8238
9441
  CHECK (
8239
- (contact_id IS NOT NULL AND link_id IS NULL)
8240
- OR
8241
- (contact_id IS NULL AND link_id IS NOT NULL)
9442
+ (contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1
8242
9443
  )
8243
9444
  )
8244
9445
  `);
9446
+ const columns = db.prepare("PRAGMA table_info(attributes)").all();
9447
+ if (!columns.some((c) => c.name === "event_id")) {
9448
+ withTransaction(
9449
+ db,
9450
+ () => db.exec(`CREATE TABLE attributes_upgrade (
9451
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9452
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
9453
+ link_id INTEGER REFERENCES links(id) ON DELETE CASCADE,
9454
+ event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
9455
+ name TEXT NOT NULL CHECK(length(trim(name)) > 0), value TEXT,
9456
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER,
9457
+ CHECK ((contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1)
9458
+ );
9459
+ INSERT INTO attributes_upgrade (id,contact_id,link_id,name,value,created_at,updated_at,deleted_at)
9460
+ SELECT id,contact_id,link_id,name,value,created_at,updated_at,deleted_at FROM attributes;
9461
+ DROP TABLE attributes;
9462
+ ALTER TABLE attributes_upgrade RENAME TO attributes;`)
9463
+ );
9464
+ }
8245
9465
  db.exec(
8246
9466
  "CREATE INDEX IF NOT EXISTS idx_attributes_contact ON attributes(contact_id) WHERE deleted_at IS NULL"
8247
9467
  );
9468
+ db.exec(
9469
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_attributes_event_name_live ON attributes(event_id,name) WHERE deleted_at IS NULL AND event_id IS NOT NULL"
9470
+ );
8248
9471
  db.exec(
8249
9472
  "CREATE INDEX IF NOT EXISTS idx_attributes_link ON attributes(link_id) WHERE deleted_at IS NULL"
8250
9473
  );
@@ -8357,56 +9580,6 @@ function initEventParticipantsTables(db) {
8357
9580
  );
8358
9581
  }
8359
9582
 
8360
- // src/events/init_events_tables.ts
8361
- function initEventsTables(db) {
8362
- db.exec(`
8363
- CREATE TABLE IF NOT EXISTS events (
8364
- id INTEGER PRIMARY KEY AUTOINCREMENT,
8365
- type TEXT NOT NULL,
8366
- occurred_at INTEGER NOT NULL,
8367
- summary TEXT NOT NULL,
8368
- significance REAL NOT NULL DEFAULT 0,
8369
- moment_kind TEXT,
8370
- recurrence_kind TEXT,
8371
- anchor_month INTEGER,
8372
- anchor_day INTEGER,
8373
- anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
8374
- anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
8375
- created_at INTEGER NOT NULL,
8376
- updated_at INTEGER NOT NULL,
8377
- deleted_at INTEGER,
8378
- CHECK (length(trim(summary)) > 0),
8379
- CHECK (
8380
- type IN (
8381
- 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
8382
- 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
8383
- )
8384
- ),
8385
- CHECK (
8386
- (type = 'date_anchor' AND (
8387
- (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
8388
- OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
8389
- ))
8390
- OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
8391
- ),
8392
- CHECK (moment_kind IS NULL OR moment_kind IN (
8393
- 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
8394
- )),
8395
- CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
8396
- 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
8397
- )),
8398
- CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
8399
- CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
8400
- )
8401
- `);
8402
- db.exec(
8403
- "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
8404
- );
8405
- db.exec(
8406
- "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
8407
- );
8408
- }
8409
-
8410
9583
  // src/events/init_open_commitments_tables.ts
8411
9584
  function initOpenCommitmentsTables(db) {
8412
9585
  db.exec(`
@@ -8426,6 +9599,9 @@ function initOpenCommitmentsTables(db) {
8426
9599
  )
8427
9600
  )
8428
9601
  `);
9602
+ const columns = db.prepare("PRAGMA table_info(open_commitments)").all();
9603
+ if (!columns.some((column) => column.name === "due_on"))
9604
+ db.exec("ALTER TABLE open_commitments ADD COLUMN due_on TEXT");
8429
9605
  }
8430
9606
 
8431
9607
  // src/identities/init_identities_tables.ts
@@ -8490,6 +9666,21 @@ function initLinkEventEffectsTables(db) {
8490
9666
  db.exec(
8491
9667
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
8492
9668
  );
9669
+ for (const name of ["participant_json", "provenance_json"])
9670
+ if (!columns.some((c) => c.name === name))
9671
+ db.exec(`ALTER TABLE link_event_effects ADD COLUMN ${name} TEXT`);
9672
+ db.exec(
9673
+ "CREATE TABLE IF NOT EXISTS event_link_baselines (link_id INTEGER PRIMARY KEY REFERENCES links(id) ON DELETE CASCADE, state_json TEXT NOT NULL)"
9674
+ );
9675
+ db.exec(
9676
+ "CREATE TABLE IF NOT EXISTS event_link_state_changes (id INTEGER PRIMARY KEY AUTOINCREMENT, link_id INTEGER NOT NULL REFERENCES links(id) ON DELETE CASCADE, after_event_id INTEGER NOT NULL, state TEXT NOT NULL)"
9677
+ );
9678
+ db.exec(`INSERT OR IGNORE INTO event_link_baselines(link_id,state_json)
9679
+ SELECT l.id,json_object('rank',l.rank-COALESCE(SUM(e.rank_delta),0),'affinity',l.affinity-COALESCE(SUM(e.affinity_delta),0),'trust',l.trust-COALESCE(SUM(e.trust_delta),0),'state','active','cadence_days',NULL)
9680
+ FROM links l JOIN link_event_effects e ON e.link_id=l.id GROUP BY l.id`);
9681
+ db.exec(
9682
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id IN (SELECT id FROM events WHERE type IN ('promise', 'agreement'))"
9683
+ );
8493
9684
  db.exec(
8494
9685
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_moment ON link_event_effects(moment_kind, occurred_at DESC, impact_score DESC) WHERE moment_kind IS NOT NULL"
8495
9686
  );
@@ -8665,7 +9856,10 @@ async function openAffinityDb(path) {
8665
9856
  } finally {
8666
9857
  process.emitWarning = original;
8667
9858
  }
8668
- if (path !== ":memory:") node_fs.mkdirSync(node_path.dirname(path), { recursive: true });
9859
+ if (path !== ":memory:" && !path.startsWith("file:")) {
9860
+ node_fs.mkdirSync(node_path.dirname(path), { recursive: true, mode: 448 });
9861
+ if (!node_fs.existsSync(path)) node_fs.closeSync(node_fs.openSync(path, "wx", 384));
9862
+ }
8669
9863
  const db = new sqlite.DatabaseSync(path);
8670
9864
  try {
8671
9865
  db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
@@ -8689,6 +9883,13 @@ function isErrorResult(result) {
8689
9883
 
8690
9884
  // src/surfaces/validate_input.ts
8691
9885
  function validateInput(schema, input, path = "$") {
9886
+ if (path.endsWith(".occurredAt") && input == null)
9887
+ return ["occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"];
9888
+ const branches = schema.oneOf ?? schema.anyOf;
9889
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && input !== null && typeof input !== "string")
9890
+ return [
9891
+ `attribute values are strings or null; pass "${String(input)}" to store it as text`
9892
+ ];
8692
9893
  const errors = [];
8693
9894
  const matchesType = (type) => {
8694
9895
  switch (type) {
@@ -8719,6 +9920,10 @@ function validateInput(schema, input, path = "$") {
8719
9920
  (branch) => validateInput(branch, input, path).length === 0
8720
9921
  ))
8721
9922
  errors.push(`${path}: must match at least one schema branch`);
9923
+ if (schema.anyOf && !schema.anyOf.some(
9924
+ (branch) => validateInput(branch, input, path).length === 0
9925
+ ))
9926
+ errors.push(`${path}: must match at least one schema branch`);
8722
9927
  if (typeof input === "object" && input !== null && !Array.isArray(input)) {
8723
9928
  const record = input;
8724
9929
  for (const name of schema.required ?? [])