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