@ghostpaw/affinity 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.cjs CHANGED
@@ -5,7 +5,7 @@ var node_path = require('node:path');
5
5
  var node_os = require('node:os');
6
6
 
7
7
  // src/version.ts
8
- var affinityVersion = "0.5.0";
8
+ var affinityVersion = "0.6.1";
9
9
 
10
10
  // src/surface/affinity_mcp_instructions.ts
11
11
  var affinityMcpInstructions = "Affinity is a local relationship model and social CRM. Search before creating contacts. Inspect before revising or merging. Record direct evidence honestly, keep identities and structural ties distinct from social links, and ask for clarification when a target is ambiguous. Review commitments and recurring dates regularly. Derived scores guide attention and do not prove relationship quality.";
@@ -78,6 +78,158 @@ var AffinityNotFoundError = class extends AffinityError {
78
78
  code = "NOT_FOUND";
79
79
  };
80
80
 
81
+ // src/lib/normalize_mutation_receipt.ts
82
+ function uniqueEntityRefs(refs) {
83
+ const out = [];
84
+ const seen = /* @__PURE__ */ new Set();
85
+ for (const ref of refs ?? []) {
86
+ const key = `${ref.kind}:${ref.id}`;
87
+ if (seen.has(key)) {
88
+ continue;
89
+ }
90
+ seen.add(key);
91
+ out.push(ref);
92
+ }
93
+ return out;
94
+ }
95
+ function uniqueDerivedEffects(effects) {
96
+ const out = [];
97
+ const seen = /* @__PURE__ */ new Set();
98
+ for (const effect of effects ?? []) {
99
+ const key = `${effect.eventId}:${effect.linkId}`;
100
+ if (seen.has(key)) {
101
+ continue;
102
+ }
103
+ seen.add(key);
104
+ out.push(effect);
105
+ }
106
+ return out;
107
+ }
108
+ function uniqueAffectedLinks(affectedLinks, derivedEffects) {
109
+ const out = [];
110
+ const seen = /* @__PURE__ */ new Set();
111
+ for (const linkId of affectedLinks ?? []) {
112
+ if (seen.has(linkId)) {
113
+ continue;
114
+ }
115
+ seen.add(linkId);
116
+ out.push(linkId);
117
+ }
118
+ for (const effect of derivedEffects) {
119
+ if (seen.has(effect.linkId)) {
120
+ continue;
121
+ }
122
+ seen.add(effect.linkId);
123
+ out.push(effect.linkId);
124
+ }
125
+ return out;
126
+ }
127
+ function normalizeMutationReceiptPartial(partial) {
128
+ const derivedEffects = uniqueDerivedEffects(partial?.derivedEffects);
129
+ return {
130
+ created: uniqueEntityRefs(partial?.created),
131
+ updated: uniqueEntityRefs(partial?.updated),
132
+ archived: uniqueEntityRefs(partial?.archived),
133
+ removed: uniqueEntityRefs(partial?.removed),
134
+ affectedLinks: uniqueAffectedLinks(partial?.affectedLinks, derivedEffects),
135
+ derivedEffects
136
+ };
137
+ }
138
+
139
+ // src/events/receipts.ts
140
+ function buildEventMutationReceipt(primary, partial) {
141
+ const normalized = normalizeMutationReceiptPartial(partial);
142
+ return {
143
+ primary,
144
+ ...partial?.warnings ? { warnings: partial.warnings } : {},
145
+ created: normalized.created,
146
+ updated: normalized.updated,
147
+ archived: normalized.archived,
148
+ removed: normalized.removed,
149
+ affectedLinks: normalized.affectedLinks,
150
+ derivedEffects: normalized.derivedEffects
151
+ };
152
+ }
153
+
154
+ // src/events/event_metadata.ts
155
+ function normalizeExternalRef(ref) {
156
+ if (!ref || typeof ref.source !== "string" || typeof ref.id !== "string" || !ref.source.trim() || !ref.id.trim())
157
+ throw new AffinityValidationError(
158
+ "external reference source and id must be non-empty strings"
159
+ );
160
+ return { source: ref.source.trim().toLowerCase(), id: ref.id.trim() };
161
+ }
162
+ function deriveEventSummary(content) {
163
+ const line = content.split(/\r?\n/).find((line2) => line2.trim())?.replace(/\s+/g, " ").trim() ?? "(no text)";
164
+ return line.length > 140 ? `${line.slice(0, 139)}\u2026` : line;
165
+ }
166
+ function validateEventMetadata(input) {
167
+ if (input.affectsBond !== void 0 && typeof input.affectsBond !== "boolean")
168
+ throw new AffinityValidationError("affectsBond must be boolean");
169
+ if (input.content !== void 0 && (typeof input.content !== "string" || Buffer.byteLength(input.content, "utf8") > 1048576))
170
+ throw new AffinityValidationError(
171
+ "content must be a string of at most 1 MB (1048576 UTF-8 bytes)"
172
+ );
173
+ for (const key of ["externalRef", "parentRef", "threadRef"])
174
+ if (input[key] !== void 0) input[key] = normalizeExternalRef(input[key]);
175
+ if (input.channel !== void 0) {
176
+ if (typeof input.channel !== "string" || !input.channel.trim() || input.channel.trim().length > 40)
177
+ throw new AffinityValidationError(
178
+ "channel must be non-empty and at most 40 characters"
179
+ );
180
+ input.channel = input.channel.trim().toLowerCase();
181
+ }
182
+ if (input.parentEventId !== void 0 && (!Number.isSafeInteger(input.parentEventId) || input.parentEventId <= 0))
183
+ throw new AffinityValidationError(
184
+ "parentEventId must be a positive integer"
185
+ );
186
+ const moments = [
187
+ "breakthrough",
188
+ "rupture",
189
+ "reconciliation",
190
+ "milestone",
191
+ "turning_point"
192
+ ];
193
+ if (input.momentKind !== void 0 && !moments.includes(input.momentKind))
194
+ throw new AffinityValidationError(
195
+ `momentKind must be one of: ${moments.join(", ")}`
196
+ );
197
+ }
198
+ function getEventById(db, eventId) {
199
+ return db.prepare("SELECT 1 FROM events WHERE id=? AND deleted_at IS NULL").get(eventId) ? loadEventRecord(db, eventId) : null;
200
+ }
201
+ function getEventByExternalRef(db, source, id) {
202
+ const ref = normalizeExternalRef({ source, id });
203
+ const row = db.prepare(
204
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
205
+ ).get(ref.source, ref.id);
206
+ return row ? loadEventRecord(db, row.id) : null;
207
+ }
208
+ function existingEventReceipt(db, ref) {
209
+ const event = ref && getEventByExternalRef(db, ref.source, ref.id);
210
+ return event ? { ...buildEventMutationReceipt(event, {}), outcome: "no_op" } : null;
211
+ }
212
+ function enrichEventMetadata(db, record) {
213
+ const bond = db.prepare("SELECT affects_bond FROM events WHERE id=?").get(record.id);
214
+ record.affectsBond = bond?.affects_bond == null ? !(record.type === "observation" && record.significance <= 2) : Boolean(bond.affects_bond);
215
+ if (record.parentEventId === void 0 && record.parentRef) {
216
+ const row = db.prepare(
217
+ "SELECT id FROM events WHERE external_source=? AND external_id=? AND deleted_at IS NULL"
218
+ ).get(record.parentRef.source, record.parentRef.id);
219
+ if (row) record.parentEventId = row.id;
220
+ }
221
+ const attrs = db.prepare(
222
+ "SELECT id, name, value FROM attributes WHERE event_id=? AND deleted_at IS NULL ORDER BY id"
223
+ ).all(record.id);
224
+ if (attrs.length)
225
+ record.attributes = attrs.map((a) => ({
226
+ ...a,
227
+ contactId: null,
228
+ linkId: null,
229
+ eventId: record.id
230
+ }));
231
+ }
232
+
81
233
  // src/contacts/queries.ts
82
234
  function getContactRowById(db, id) {
83
235
  const row = db.prepare(
@@ -156,8 +308,11 @@ function assertParticipantContactsLive(db, contactIds) {
156
308
  }
157
309
  function validateSocialEventInput(input) {
158
310
  assertFiniteTimestamp(input.occurredAt, "occurredAt");
159
- const summary = input.summary.trim();
160
- if (summary.length === 0) {
311
+ validateEventMetadata(input);
312
+ if (input.summary !== void 0 && typeof input.summary !== "string")
313
+ throw new AffinityValidationError("summary must be a string");
314
+ const summary = input.summary?.trim();
315
+ if (summary === "" || summary === void 0 && input.content === void 0) {
161
316
  throw new AffinityValidationError("summary must be non-empty");
162
317
  }
163
318
  const sig = input.significance;
@@ -221,11 +376,27 @@ function mapEventRowToEventRecord(row, participants) {
221
376
  id: row.id,
222
377
  type: row.type,
223
378
  occurredAt: row.occurred_at,
224
- summary: row.summary,
379
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
380
+ summary: decodeEventText(row.summary),
225
381
  significance: row.significance,
226
382
  momentKind: row.moment_kind,
227
383
  participants
228
384
  };
385
+ if (row.provenance_json != null)
386
+ base.provenance = JSON.parse(row.provenance_json);
387
+ if (row.content != null) base.content = decodeEventText(row.content);
388
+ if (row.channel != null) base.channel = row.channel;
389
+ if (row.external_source != null && row.external_id != null)
390
+ base.externalRef = { source: row.external_source, id: row.external_id };
391
+ if (row.parent_source != null && row.parent_ref_id != null)
392
+ base.parentRef = { source: row.parent_source, id: row.parent_ref_id };
393
+ if (row.thread_source != null && row.thread_id != null)
394
+ base.threadRef = { source: row.thread_source, id: row.thread_id };
395
+ if (row.parent_event_id != null) base.parentEventId = row.parent_event_id;
396
+ if (row.type === "promise" || row.type === "agreement") {
397
+ base.dueAt = row.due_at ?? null;
398
+ base.dueOn = row.due_on ?? (row.due_at == null ? null : new Date(row.due_at).toISOString().slice(0, 10));
399
+ }
229
400
  if (row.recurrence_kind !== null) {
230
401
  base.recurrenceKind = row.recurrence_kind;
231
402
  base.anchorMonth = row.anchor_month;
@@ -255,25 +426,29 @@ function mapUpcomingRowToUpcomingDateRecord(row) {
255
426
  targetRef,
256
427
  recurrenceKind,
257
428
  occursOn: row.occurs_on,
258
- summary: row.summary,
429
+ occursOnDate: new Date(row.occurs_on).toISOString().slice(0, 10),
430
+ summary: decodeEventText(row.summary),
259
431
  significance: row.significance
260
432
  };
261
433
  }
434
+ function decodeEventText(value) {
435
+ return typeof value === "string" ? value : Buffer.from(value).toString("utf8");
436
+ }
262
437
 
263
438
  // src/events/queries.ts
264
439
  function getEventRowById(db, id) {
265
440
  const row = db.prepare(
266
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
267
- recurrence_kind, anchor_month, anchor_day,
268
- anchor_contact_id, anchor_link_id,
269
- created_at, updated_at, deleted_at
441
+ `SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary
270
442
  FROM events
271
443
  WHERE id = ? AND deleted_at IS NULL`
272
444
  ).get(id);
273
445
  if (row === void 0) {
274
446
  return null;
275
447
  }
276
- return row;
448
+ const event = row;
449
+ event.summary = decodeEventText(event.summary);
450
+ if (event.content != null) event.content = decodeEventText(event.content);
451
+ return event;
277
452
  }
278
453
  function findDuplicateDateAnchor(db, params) {
279
454
  const exclude = params.excludeEventId;
@@ -341,19 +516,27 @@ function loadEventRecord(db, eventId) {
341
516
  throw new AffinityInvariantError("event not found");
342
517
  }
343
518
  const participants = loadEventParticipantViews(db, eventId);
344
- return mapEventRowToEventRecord(row, participants);
519
+ const record = mapEventRowToEventRecord(row, participants);
520
+ const due = db.prepare("SELECT due_at, due_on FROM open_commitments WHERE event_id = ?").get(eventId);
521
+ if (due) {
522
+ record.dueAt = due.due_at;
523
+ record.dueOn = due.due_on ?? (due.due_at === null ? null : new Date(due.due_at).toISOString().slice(0, 10));
524
+ }
525
+ enrichEventMetadata(db, record);
526
+ return record;
345
527
  }
346
- function batchLoadEventRecords(db, eventIds) {
528
+ function batchLoadEventRecords(db, eventIds, includeContent = false) {
347
529
  if (eventIds.length === 0) {
348
530
  return [];
349
531
  }
350
532
  const ph = eventIds.map(() => "?").join(", ");
351
533
  const eventRows = db.prepare(
352
- `SELECT id, type, occurred_at, summary, significance, moment_kind,
534
+ `SELECT e.id, e.type, e.occurred_at, e.occurred_on, oc.due_at, oc.due_on, significance, moment_kind,
535
+ e.external_source,e.external_id,e.channel,e.parent_source,e.parent_ref_id,e.parent_event_id,e.thread_source,e.thread_id,
353
536
  recurrence_kind, anchor_month, anchor_day,
354
537
  anchor_contact_id, anchor_link_id,
355
- created_at, updated_at, deleted_at
356
- FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
538
+ 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
539
+ FROM events e LEFT JOIN open_commitments oc ON oc.event_id = e.id WHERE e.id IN (${ph}) AND e.deleted_at IS NULL`
357
540
  ).all(...eventIds);
358
541
  const partRows = db.prepare(
359
542
  `SELECT event_id, contact_id, role, directionality
@@ -377,7 +560,9 @@ function batchLoadEventRecords(db, eventIds) {
377
560
  for (const id of eventIds) {
378
561
  const row = rowById.get(id);
379
562
  if (row) {
380
- out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
563
+ const record = mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []);
564
+ enrichEventMetadata(db, record);
565
+ out.push(record);
381
566
  }
382
567
  }
383
568
  return out;
@@ -436,6 +621,10 @@ function getContactJournal(db, contactId, options) {
436
621
  clauses.push(`e.type IN (${ph})`);
437
622
  params.push(...options.eventTypes);
438
623
  }
624
+ if (options?.channel !== void 0) {
625
+ clauses.push("e.channel = ?");
626
+ params.push(options.channel.trim().toLowerCase());
627
+ }
439
628
  const where = clauses.join(" AND ");
440
629
  const rows = db.prepare(
441
630
  `SELECT e.id FROM events e
@@ -446,8 +635,70 @@ function getContactJournal(db, contactId, options) {
446
635
  ).all(...params, limit, offset);
447
636
  return batchLoadEventRecords(
448
637
  db,
449
- rows.map((r) => r.id)
638
+ rows.map((r) => r.id),
639
+ options?.includeContent ?? false
640
+ );
641
+ }
642
+
643
+ // src/lib/parse_calendar_time.ts
644
+ var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d or -2d";
645
+ function isCommitmentOverdue(dueAt, dueOn, now) {
646
+ if (dueAt === null) return false;
647
+ if (dueOn && dueAt === Date.parse(`${dueOn}T23:59:59.999Z`)) {
648
+ return localCalendarDate(now) > dueOn;
649
+ }
650
+ return dueAt < now;
651
+ }
652
+ 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)";
653
+ function localCalendarDate(now) {
654
+ const d = new Date(now);
655
+ return `${String(d.getFullYear()).padStart(4, "0")}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
656
+ }
657
+ function parseCalendarTime(value, field = "time", now = Date.now()) {
658
+ if (value == null && field.endsWith("occurredAt"))
659
+ throw new AffinityValidationError(
660
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
661
+ );
662
+ const fail = () => {
663
+ throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
664
+ };
665
+ if (typeof value === "number" || typeof value === "string" && /^-?\d+$/.test(value.trim())) {
666
+ const timestamp2 = Number(value);
667
+ if (!Number.isSafeInteger(timestamp2) || !Number.isFinite(new Date(timestamp2).getTime()))
668
+ return fail();
669
+ return { timestamp: timestamp2, day: new Date(timestamp2).toISOString().slice(0, 10) };
670
+ }
671
+ if (typeof value !== "string") return fail();
672
+ let text = value.trim();
673
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text)) {
674
+ text = text.replace(" ", "T");
675
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(text)) text += "Z";
676
+ }
677
+ text = text.replace(
678
+ /\.(\d{4,})(?=Z|[+-]\d{2}:\d{2}$)/,
679
+ (_, digits) => `.${digits.slice(0, 3)}`
450
680
  );
681
+ if (/^(today|yesterday|tomorrow|[+-]\d+d)$/.test(text)) {
682
+ const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
683
+ const date = /* @__PURE__ */ new Date(`${localCalendarDate(now)}T00:00:00Z`);
684
+ date.setUTCDate(date.getUTCDate() + offset);
685
+ if (!Number.isFinite(date.getTime())) return fail();
686
+ text = date.toISOString().slice(0, 10);
687
+ }
688
+ const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text);
689
+ if (european) text = `${european[3]}-${european[2]}-${european[1]}`;
690
+ const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(text);
691
+ if (!dateOnly && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
692
+ text
693
+ ))
694
+ return fail();
695
+ const day = text.slice(0, 10);
696
+ const midnight = Date.parse(`${day}T00:00:00Z`);
697
+ if (!Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== day)
698
+ return fail();
699
+ const timestamp = dateOnly ? midnight + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0) : Date.parse(text);
700
+ if (!Number.isFinite(timestamp)) return fail();
701
+ return { timestamp, day };
451
702
  }
452
703
 
453
704
  // src/resolve_now.ts
@@ -518,9 +769,13 @@ function listOpenCommitments(db, filters, options) {
518
769
  clauses.push("oc.due_at <= ?");
519
770
  params.push(anchor + filters.horizonDays * DAY_MS);
520
771
  }
772
+ if (options?.channel !== void 0) {
773
+ clauses.push("e.channel=?");
774
+ params.push(options.channel.trim().toLowerCase());
775
+ }
521
776
  const where = clauses.join(" AND ");
522
777
  const rows = db.prepare(
523
- `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
778
+ `SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.due_on, oc.resolved_at, oc.resolution,
524
779
  e.type, e.summary
525
780
  FROM open_commitments oc
526
781
  INNER JOIN events e ON e.id = oc.event_id
@@ -537,7 +792,8 @@ function listOpenCommitments(db, filters, options) {
537
792
  participants: partsByEvent.get(r.event_id) ?? [],
538
793
  dueAt: r.due_at,
539
794
  dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
540
- overdue: r.due_at !== null && r.due_at < now,
795
+ dueOn: r.due_on ?? (r.due_at === null ? null : new Date(r.due_at).toISOString().slice(0, 10)),
796
+ overdue: isCommitmentOverdue(r.due_at, r.due_on, now),
541
797
  resolutionState: mapResolution(r.resolved_at, r.resolution)
542
798
  }));
543
799
  }
@@ -606,8 +862,10 @@ function utcMidnight(year, month1Based, day) {
606
862
  return Date.UTC(year, month1Based - 1, day, 0, 0, 0, 0);
607
863
  }
608
864
  function computeNextAnchorOccursOn(params) {
609
- const refStart = utcStartOfDayMs(params.referenceMs);
610
- const refYear = new Date(params.referenceMs).getUTCFullYear();
865
+ const refStart = Date.parse(
866
+ `${localCalendarDate(params.referenceMs)}T00:00:00Z`
867
+ );
868
+ const refYear = new Date(refStart).getUTCFullYear();
611
869
  for (let year2 = refYear; year2 <= refYear + 2; year2 += 1) {
612
870
  const day2 = effectiveAnchorDay(year2, params.anchorMonth, params.anchorDay);
613
871
  const candidate = utcMidnight(year2, params.anchorMonth, day2);
@@ -810,7 +1068,13 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
810
1068
  ORDER BY COALESCE(lr.normalized_rank, 0) DESC, l.trust DESC
811
1069
  LIMIT ?`
812
1070
  ).all(contactId, contactId, topLinksLimit);
813
- const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
1071
+ const topLinks = linkRows.map((l) => ({
1072
+ ...mapLinkRowToLinkListItem(l),
1073
+ counterpartyName: getContactRowById(
1074
+ db,
1075
+ l.from_contact_id === contactId ? l.to_contact_id : l.from_contact_id
1076
+ )?.name ?? ""
1077
+ }));
814
1078
  const activeDates = loadActiveDatesForContact(db, contactId);
815
1079
  const contactRollup = loadContactRollup(db, contactId);
816
1080
  const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
@@ -843,7 +1107,20 @@ function getContactProfile(db, contactId, options) {
843
1107
  return {
844
1108
  ...profile,
845
1109
  recentEvents: getContactJournal(db, contactId, {
846
- limit: options?.recentEventsLimit ?? 5
1110
+ limit: options?.recentEventsLimit ?? 5,
1111
+ eventTypes: [
1112
+ "conversation",
1113
+ "activity",
1114
+ "gift",
1115
+ "support",
1116
+ "milestone",
1117
+ "observation",
1118
+ "conflict",
1119
+ "correction",
1120
+ "transaction",
1121
+ "promise",
1122
+ "agreement"
1123
+ ]
847
1124
  }).map((event) => ({
848
1125
  ...event,
849
1126
  occurredAtIso: new Date(event.occurredAt).toISOString()
@@ -898,6 +1175,10 @@ function listMoments(db, filters, options) {
898
1175
  clauses.push("le.link_id = ?");
899
1176
  params.push(filters.linkId);
900
1177
  }
1178
+ if (options?.channel !== void 0) {
1179
+ clauses.push("e.channel=?");
1180
+ params.push(options.channel.trim().toLowerCase());
1181
+ }
901
1182
  const where = clauses.join(" AND ");
902
1183
  return db.prepare(
903
1184
  `SELECT
@@ -905,6 +1186,7 @@ function listMoments(db, filters, options) {
905
1186
  le.link_id,
906
1187
  le.moment_kind,
907
1188
  le.occurred_at,
1189
+ e.occurred_on,
908
1190
  e.summary,
909
1191
  le.impact_score
910
1192
  FROM link_event_effects le
@@ -917,6 +1199,7 @@ function listMoments(db, filters, options) {
917
1199
  linkId: row.link_id,
918
1200
  momentKind: row.moment_kind,
919
1201
  occurredAt: row.occurred_at,
1202
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
920
1203
  summary: row.summary,
921
1204
  impactScore: row.impact_score
922
1205
  }));
@@ -1004,6 +1287,10 @@ function getLinkTimeline(db, linkId, options) {
1004
1287
  clauses.push(`e.type IN (${ph})`);
1005
1288
  params.push(...options.eventTypes);
1006
1289
  }
1290
+ if (options?.channel !== void 0) {
1291
+ clauses.push("e.channel = ?");
1292
+ params.push(options.channel.trim().toLowerCase());
1293
+ }
1007
1294
  const where = clauses.join(" AND ");
1008
1295
  const rows = db.prepare(
1009
1296
  `SELECT e.id
@@ -1015,7 +1302,8 @@ function getLinkTimeline(db, linkId, options) {
1015
1302
  ).all(...params, limit, offset);
1016
1303
  return batchLoadEventRecords(
1017
1304
  db,
1018
- rows.map((r) => r.id)
1305
+ rows.map((r) => r.id),
1306
+ options?.includeContent ?? false
1019
1307
  );
1020
1308
  }
1021
1309
 
@@ -1188,26 +1476,34 @@ function refreshLinkRollup(db, linkId, now) {
1188
1476
  const latest = db.prepare(
1189
1477
  `SELECT event_id, occurred_at
1190
1478
  FROM link_event_effects
1191
- WHERE link_id = ? AND counts_for_recency = 1
1479
+ WHERE link_id = ? AND counts_for_recency = 1 AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))
1192
1480
  ORDER BY occurred_at DESC, event_id DESC
1193
1481
  LIMIT 1`
1194
- ).get(linkId);
1482
+ ).get(linkId, now, localCalendarDate(now));
1195
1483
  const trailingWindowStart = now - 180 * DAY_MS2;
1196
1484
  const counts = db.prepare(
1197
1485
  `SELECT
1198
1486
  COUNT(*) AS total_meaningful_events,
1199
1487
  SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
1200
1488
  FROM link_event_effects
1201
- WHERE link_id = ? AND occurred_at >= ?`
1202
- ).get(linkId, trailingWindowStart);
1489
+ WHERE link_id = ? AND occurred_at >= ? AND counts_for_recency = 1
1490
+ AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
1491
+ ).get(linkId, trailingWindowStart, now, localCalendarDate(now));
1203
1492
  const directionalityRows = db.prepare(
1204
1493
  `SELECT ep.directionality
1205
1494
  FROM link_event_effects le
1206
1495
  INNER JOIN event_participants ep ON ep.event_id = le.event_id
1207
1496
  WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
1208
1497
  AND ep.directionality IS NOT NULL
1209
- AND le.occurred_at >= ?`
1210
- ).all(linkId, link.from_contact_id, trailingWindowStart);
1498
+ AND le.occurred_at >= ?
1499
+ AND le.event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))`
1500
+ ).all(
1501
+ linkId,
1502
+ link.from_contact_id,
1503
+ trailingWindowStart,
1504
+ now,
1505
+ localCalendarDate(now)
1506
+ );
1211
1507
  let outboundCount = 0;
1212
1508
  let inboundCount = 0;
1213
1509
  for (const row of directionalityRows) {
@@ -1358,37 +1654,8 @@ function getLinkDetail(db, linkId, options) {
1358
1654
  }
1359
1655
 
1360
1656
  // src/tools/parse_time_input.ts
1361
- var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
1362
1657
  function parseTimeInput(value, field = "time", now = Date.now()) {
1363
- const fail = () => {
1364
- throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
1365
- };
1366
- if (typeof value === "number")
1367
- return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
1368
- if (typeof value !== "string") return fail();
1369
- const text = value.trim();
1370
- const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
1371
- let day;
1372
- if (relative) {
1373
- const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
1374
- day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
1375
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
1376
- day = Date.parse(`${text}T00:00:00.000Z`);
1377
- if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
1378
- return fail();
1379
- } else {
1380
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
1381
- text
1382
- ))
1383
- return fail();
1384
- const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
1385
- if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
1386
- return fail();
1387
- const parsed = Date.parse(text);
1388
- return Number.isFinite(parsed) ? parsed : fail();
1389
- }
1390
- const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
1391
- return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
1658
+ return parseCalendarTime(value, field, now).timestamp;
1392
1659
  }
1393
1660
 
1394
1661
  // src/tools/tool_metadata.ts
@@ -1459,6 +1726,39 @@ function oneOfSchema(schemas, description) {
1459
1726
  return schema;
1460
1727
  }
1461
1728
 
1729
+ // src/tools/event_metadata_schema.ts
1730
+ var externalRefSchema = objectSchema(
1731
+ {
1732
+ source: stringSchema("External source; trimmed and lowercased."),
1733
+ id: stringSchema("Non-empty external id.")
1734
+ },
1735
+ ["source", "id"]
1736
+ );
1737
+ var eventMetadataSchema = {
1738
+ affectsBond: booleanSchema(
1739
+ "Whether evidence affects the relationship; defaults false for like/follow/repost labels and observations with significance <=2; explicit true opts in."
1740
+ ),
1741
+ content: stringSchema(
1742
+ "Verbatim text, including empty text; at most 1 MB UTF-8. Summary may be omitted when content is supplied."
1743
+ ),
1744
+ externalRef: externalRefSchema,
1745
+ channel: stringSchema(
1746
+ "Free-text channel, trimmed and lowercased, at most 40 characters."
1747
+ ),
1748
+ parentRef: externalRefSchema,
1749
+ parentEventId: integerSchema("Parent event id."),
1750
+ threadRef: externalRefSchema,
1751
+ momentKind: enumSchema("Optional moment kind.", [
1752
+ "breakthrough",
1753
+ "rupture",
1754
+ "reconciliation",
1755
+ "milestone",
1756
+ "turning_point"
1757
+ ]),
1758
+ occurredOn: stringSchema("Calendar day."),
1759
+ provenance: {}
1760
+ };
1761
+
1462
1762
  // src/tools/tool_names.ts
1463
1763
  var searchAffinityToolName = "search_affinity";
1464
1764
  var reviewAffinityToolName = "review_affinity";
@@ -1472,6 +1772,7 @@ var recordEventToolName = "record_event";
1472
1772
  var manageCommitmentToolName = "manage_commitment";
1473
1773
  var manageDateAnchorToolName = "manage_date_anchor";
1474
1774
  var manageAttributeToolName = "manage_attribute";
1775
+ var manageEventToolName = "manage_event";
1475
1776
 
1476
1777
  // src/tools/tool_ref.ts
1477
1778
  function contactState(value) {
@@ -1516,7 +1817,7 @@ function toEventEntityRef(value) {
1516
1817
  return {
1517
1818
  kind: "event",
1518
1819
  id: value.id,
1519
- title: value.summary,
1820
+ title: deriveEventSummary(value.summary),
1520
1821
  subtitle: value.type,
1521
1822
  state: value.type
1522
1823
  };
@@ -1551,14 +1852,39 @@ function toReceiptEntityRefs(receipt) {
1551
1852
  }
1552
1853
 
1553
1854
  // src/identities/normalize.ts
1855
+ var handleIdentityTypes = /* @__PURE__ */ new Set([
1856
+ "handle",
1857
+ "bluesky",
1858
+ "x",
1859
+ "twitter",
1860
+ "mastodon",
1861
+ "github",
1862
+ "instagram",
1863
+ "telegram",
1864
+ "linkedin",
1865
+ "facebook",
1866
+ "youtube",
1867
+ "tiktok",
1868
+ "discord",
1869
+ "slack"
1870
+ ]);
1871
+ function normalizeIdentityValue(type, value) {
1872
+ const v = value.trim();
1873
+ return handleIdentityTypes.has(type.toLowerCase()) ? v.replace(/^@/, "").toLowerCase() : v;
1874
+ }
1554
1875
  function normalizeIdentityKey(type, value) {
1555
- const t = type.trim().toLowerCase();
1556
- const v = value.trim().toLowerCase();
1876
+ let t = type.trim().toLowerCase();
1877
+ if (["did", "atproto_did", "custom:atproto-did"].includes(t)) t = "did";
1878
+ const v = normalizeIdentityValue(t, value).toLowerCase();
1557
1879
  if (t.length === 0 || v.length === 0) {
1558
1880
  throw new AffinityValidationError("type and value must be non-empty");
1559
1881
  }
1560
1882
  return `${t}:${v}`;
1561
1883
  }
1884
+ function legacyIdentityKey(key) {
1885
+ const colon = key.indexOf(":");
1886
+ return handleIdentityTypes.has(key.slice(0, colon)) ? `${key.slice(0, colon + 1)}@${key.slice(colon + 1)}` : key;
1887
+ }
1562
1888
 
1563
1889
  // src/lib/errors/is_affinity_error.ts
1564
1890
  function isAffinityError(value) {
@@ -1680,7 +2006,7 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
1680
2006
  const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
1681
2007
  return {
1682
2008
  ok: false,
1683
- result: toolFailure("domain", "invalid_state", message, message)
2009
+ result: toolFailure("domain", "conflict", message, message)
1684
2010
  };
1685
2011
  }
1686
2012
  return { ok: true, value: row2 };
@@ -1705,9 +2031,13 @@ function resolveContactLocator(db, locator, field = "contact", allowMerged = fal
1705
2031
  INNER JOIN contacts c ON c.id = i.contact_id
1706
2032
  WHERE i.removed_at IS NULL
1707
2033
  AND c.deleted_at IS NULL
1708
- AND i.normalized_key = ?
2034
+ AND (i.normalized_key IN (?, ?) OR (i.type = 'custom:atproto-did' AND ? = 'did:' || lower(i.value)))
1709
2035
  LIMIT 2`
1710
- ).all(normalizedKey);
2036
+ ).all(
2037
+ normalizedKey,
2038
+ legacyIdentityKey(normalizedKey),
2039
+ normalizedKey
2040
+ );
1711
2041
  if (row.length === 0) {
1712
2042
  return {
1713
2043
  ok: false,
@@ -1858,13 +2188,21 @@ function resolveLinkLocator(db, locator, field = "link") {
1858
2188
  return { ok: true, value: matchedLink };
1859
2189
  }
1860
2190
  function mapErrorToToolFailure(error, summary = "The tool failed.") {
2191
+ if (error && typeof error === "object" && "ok" in error && error.ok === false && "outcome" in error && error.outcome === "error")
2192
+ return error;
1861
2193
  if (isAffinityError(error)) {
1862
- return toolFailure(
2194
+ const result = toolFailure(
1863
2195
  "domain",
1864
2196
  affinityErrorCodeToToolCode(error),
1865
2197
  summary,
1866
2198
  error.message
1867
2199
  );
2200
+ const id = error.existingContactId;
2201
+ if (id !== void 0) {
2202
+ result.data = { existingContactId: id };
2203
+ result.error.details = { existingContactId: id };
2204
+ }
2205
+ return result;
1868
2206
  }
1869
2207
  if (error instanceof Error) {
1870
2208
  return toolFailure("system", "system_error", summary, error.message);
@@ -1903,27 +2241,32 @@ function affinityErrorCodeToToolCode(error) {
1903
2241
  }
1904
2242
 
1905
2243
  // src/tools/validate_tool_input.ts
1906
- function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
2244
+ function validateToolInput(schema, value, path = "input", injectedNow = Date.now(), strict = false) {
2245
+ if (path.endsWith(".occurredAt") && value == null)
2246
+ return "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)";
2247
+ const branches = schema.oneOf ?? schema.anyOf;
2248
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && value !== null && typeof value !== "string")
2249
+ return `attribute values are strings or null; pass "${String(value)}" to store it as text`;
1907
2250
  if (schema.anyOf || schema.oneOf) {
1908
2251
  const choices = schema.anyOf ?? schema.oneOf ?? [];
1909
2252
  return choices.some(
1910
- (choice) => validateToolInput(choice, value, path, injectedNow) === null
2253
+ (choice) => validateToolInput(choice, value, path, injectedNow, strict) === null
1911
2254
  ) ? null : `${path}: invalid value`;
1912
2255
  }
1913
2256
  if (schema.type === "object") {
1914
2257
  if (value === null || typeof value !== "object" || Array.isArray(value))
1915
2258
  return `${path}: missing input or expected object`;
1916
2259
  const object = value;
1917
- if (path.endsWith(".patch") && schema.additionalProperties === false) {
2260
+ if ((strict || path.endsWith(".patch")) && schema.additionalProperties === false) {
1918
2261
  for (const key of Object.keys(object)) {
1919
2262
  if (!Object.hasOwn(schema.properties ?? {}, key))
1920
- return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown patch key; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
2263
+ return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown ${path.endsWith(".patch") ? "patch key" : "key"}; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
1921
2264
  }
1922
2265
  }
1923
2266
  if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
1924
2267
  return `${path}.patch: missing required field`;
1925
2268
  if (object.patch !== void 0 && !schema.properties?.patch)
1926
- return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
2269
+ return `${path}.patch: unknown key; allowed keys: none (use the action's named fields)`;
1927
2270
  const referenceNow = object.now === void 0 ? injectedNow : (() => {
1928
2271
  try {
1929
2272
  return parseTimeInput(
@@ -1938,11 +2281,21 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
1938
2281
  for (const [key, child] of Object.entries(schema.properties ?? {})) {
1939
2282
  if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
1940
2283
  try {
1941
- object[key] = parseTimeInput(
2284
+ const parsed = parseCalendarTime(
1942
2285
  object[key],
1943
2286
  `${path}.${key}`,
1944
2287
  referenceNow
1945
2288
  );
2289
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key) && typeof object[key] === "string" && /\.\d{4,}/.test(object[key])) {
2290
+ const provenance = object.provenance;
2291
+ object.provenance = {
2292
+ ...provenance && typeof provenance === "object" ? provenance : {},
2293
+ [`raw${key[0]?.toUpperCase()}${key.slice(1)}`]: object[key]
2294
+ };
2295
+ }
2296
+ object[key] = parsed.timestamp;
2297
+ if (!path.endsWith(".patch") && ["occurredAt", "dueAt"].includes(key))
2298
+ object[key.replace(/At$/, "On")] ??= parsed.day;
1946
2299
  } catch (error) {
1947
2300
  return error.message;
1948
2301
  }
@@ -1957,8 +2310,9 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
1957
2310
  const error = validateToolInput(
1958
2311
  child,
1959
2312
  object[key],
1960
- `${path}.${key}`,
1961
- referenceNow
2313
+ key === "input" && path === "input" ? path : `${path}.${key}`,
2314
+ referenceNow,
2315
+ strict
1962
2316
  );
1963
2317
  if (error) return error;
1964
2318
  }
@@ -1971,7 +2325,8 @@ function validateToolInput(schema, value, path = "input", injectedNow = Date.now
1971
2325
  schema.items ?? {},
1972
2326
  item,
1973
2327
  `${path}[${index}]`,
1974
- injectedNow
2328
+ injectedNow,
2329
+ strict
1975
2330
  );
1976
2331
  if (error) return error;
1977
2332
  }
@@ -2037,6 +2392,26 @@ function inspectAffinityItemToolHandler(db, rawInput) {
2037
2392
  inputError
2038
2393
  );
2039
2394
  return withToolHandling(() => {
2395
+ if (input.kind === "event") {
2396
+ if (input.eventId === void 0 === (input.ref === void 0))
2397
+ return toolFailure(
2398
+ "protocol",
2399
+ "invalid_input",
2400
+ "Invalid event target.",
2401
+ "Provide exactly one of eventId or ref."
2402
+ );
2403
+ const detail2 = input.ref ? getEventByExternalRef(db, input.ref.source, input.ref.id) : getEventById(db, input.eventId);
2404
+ return detail2 ? toolSuccess(
2405
+ `Loaded event #${detail2.id}.`,
2406
+ { kind: "event", detail: detail2 },
2407
+ { entities: [{ kind: "event", id: detail2.id }] }
2408
+ ) : toolFailure(
2409
+ "domain",
2410
+ "not_found",
2411
+ "Event not found.",
2412
+ "No event matches the target."
2413
+ );
2414
+ }
2040
2415
  if (input.kind === "owner_profile") {
2041
2416
  const detail2 = getOwnerProfile(
2042
2417
  db,
@@ -2134,10 +2509,13 @@ var inspectAffinityItemTool = defineAffinityTool({
2134
2509
  inputSchema: objectSchema(
2135
2510
  {
2136
2511
  kind: enumSchema("What to inspect.", [
2512
+ "event",
2137
2513
  "owner_profile",
2138
2514
  "contact_profile",
2139
2515
  "link"
2140
2516
  ]),
2517
+ eventId: integerSchema("Exact event id."),
2518
+ ref: externalRefSchema,
2141
2519
  contact: contactLocatorSchema(
2142
2520
  "Target contact. Required when kind=contact_profile. Provide contactId or identity."
2143
2521
  ),
@@ -2156,6 +2534,7 @@ var inspectAffinityItemTool = defineAffinityTool({
2156
2534
  // src/attributes/helpers.ts
2157
2535
  function clearedAttributePrimary(target) {
2158
2536
  return {
2537
+ ...target.kind === "event" ? { eventId: target.eventId } : {},
2159
2538
  id: 0,
2160
2539
  contactId: target.kind === "contact" ? target.id : null,
2161
2540
  linkId: target.kind === "link" ? target.id : null,
@@ -2167,6 +2546,7 @@ function clearedAttributePrimary(target) {
2167
2546
  // src/attributes/mappers.ts
2168
2547
  function mapAttributeRowToAttributeRecord(row) {
2169
2548
  return {
2549
+ ...row.event_id == null ? {} : { eventId: row.event_id },
2170
2550
  id: row.id,
2171
2551
  contactId: row.contact_id,
2172
2552
  linkId: row.link_id,
@@ -2176,136 +2556,55 @@ function mapAttributeRowToAttributeRecord(row) {
2176
2556
  }
2177
2557
 
2178
2558
  // src/attributes/queries.ts
2559
+ function attributeTargetSql(target) {
2560
+ return {
2561
+ column: target.kind === "event" ? "event_id" : target.kind === "contact" ? "contact_id" : "link_id",
2562
+ id: target.kind === "event" ? target.eventId : target.id
2563
+ };
2564
+ }
2179
2565
  function getAttributeRowById(db, id) {
2180
- const row = db.prepare(
2181
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2182
- FROM attributes WHERE id = ?`
2183
- ).get(id);
2184
- if (row === void 0) {
2185
- return null;
2186
- }
2187
- return row;
2566
+ return db.prepare("SELECT * FROM attributes WHERE id=?").get(id) ?? null;
2188
2567
  }
2189
2568
  function getLiveAttributeRow(db, target, name) {
2190
- if (target.kind === "contact") {
2191
- const row2 = db.prepare(
2192
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2193
- FROM attributes
2194
- WHERE contact_id = ? AND name = ? AND deleted_at IS NULL`
2195
- ).get(target.id, name);
2196
- if (row2 === void 0) {
2197
- return null;
2198
- }
2199
- return row2;
2200
- }
2201
- const row = db.prepare(
2202
- `SELECT id, contact_id, link_id, name, value, created_at, updated_at, deleted_at
2203
- FROM attributes
2204
- WHERE link_id = ? AND name = ? AND deleted_at IS NULL`
2205
- ).get(target.id, name);
2206
- if (row === void 0) {
2207
- return null;
2208
- }
2209
- return row;
2569
+ const t = attributeTargetSql(target);
2570
+ return db.prepare(
2571
+ `SELECT * FROM attributes WHERE ${t.column}=? AND name=? AND deleted_at IS NULL`
2572
+ ).get(t.id, name) ?? null;
2210
2573
  }
2211
2574
  function listLiveAttributeIdsForTarget(db, target) {
2212
- if (target.kind === "contact") {
2213
- const rows2 = db.prepare(
2214
- "SELECT id FROM attributes WHERE contact_id = ? AND deleted_at IS NULL ORDER BY id"
2215
- ).all(target.id);
2216
- return rows2.map((row) => row.id);
2217
- }
2218
- const rows = db.prepare(
2219
- "SELECT id FROM attributes WHERE link_id = ? AND deleted_at IS NULL ORDER BY id"
2220
- ).all(target.id);
2221
- return rows.map((row) => row.id);
2575
+ const t = attributeTargetSql(target);
2576
+ return db.prepare(
2577
+ `SELECT id FROM attributes WHERE ${t.column}=? AND deleted_at IS NULL ORDER BY id`
2578
+ ).all(t.id).map((r) => r.id);
2222
2579
  }
2223
2580
 
2224
- // src/lib/normalize_mutation_receipt.ts
2225
- function uniqueEntityRefs(refs) {
2226
- const out = [];
2227
- const seen = /* @__PURE__ */ new Set();
2228
- for (const ref of refs ?? []) {
2229
- const key = `${ref.kind}:${ref.id}`;
2230
- if (seen.has(key)) {
2231
- continue;
2232
- }
2233
- seen.add(key);
2234
- out.push(ref);
2235
- }
2236
- return out;
2581
+ // src/attributes/receipts.ts
2582
+ function buildAttributeMutationReceipt(primary, partial) {
2583
+ const normalized = normalizeMutationReceiptPartial(partial);
2584
+ return {
2585
+ primary,
2586
+ created: normalized.created,
2587
+ updated: normalized.updated,
2588
+ archived: normalized.archived,
2589
+ removed: normalized.removed,
2590
+ affectedLinks: normalized.affectedLinks,
2591
+ derivedEffects: normalized.derivedEffects
2592
+ };
2237
2593
  }
2238
- function uniqueDerivedEffects(effects) {
2239
- const out = [];
2240
- const seen = /* @__PURE__ */ new Set();
2241
- for (const effect of effects ?? []) {
2242
- const key = `${effect.eventId}:${effect.linkId}`;
2243
- if (seen.has(key)) {
2244
- continue;
2245
- }
2246
- seen.add(key);
2247
- out.push(effect);
2248
- }
2249
- return out;
2250
- }
2251
- function uniqueAffectedLinks(affectedLinks, derivedEffects) {
2252
- const out = [];
2253
- const seen = /* @__PURE__ */ new Set();
2254
- for (const linkId of affectedLinks ?? []) {
2255
- if (seen.has(linkId)) {
2256
- continue;
2257
- }
2258
- seen.add(linkId);
2259
- out.push(linkId);
2260
- }
2261
- for (const effect of derivedEffects) {
2262
- if (seen.has(effect.linkId)) {
2263
- continue;
2264
- }
2265
- seen.add(effect.linkId);
2266
- out.push(effect.linkId);
2267
- }
2268
- return out;
2269
- }
2270
- function normalizeMutationReceiptPartial(partial) {
2271
- const derivedEffects = uniqueDerivedEffects(partial?.derivedEffects);
2272
- return {
2273
- created: uniqueEntityRefs(partial?.created),
2274
- updated: uniqueEntityRefs(partial?.updated),
2275
- archived: uniqueEntityRefs(partial?.archived),
2276
- removed: uniqueEntityRefs(partial?.removed),
2277
- affectedLinks: uniqueAffectedLinks(partial?.affectedLinks, derivedEffects),
2278
- derivedEffects
2279
- };
2280
- }
2281
-
2282
- // src/attributes/receipts.ts
2283
- function buildAttributeMutationReceipt(primary, partial) {
2284
- const normalized = normalizeMutationReceiptPartial(partial);
2285
- return {
2286
- primary,
2287
- created: normalized.created,
2288
- updated: normalized.updated,
2289
- archived: normalized.archived,
2290
- removed: normalized.removed,
2291
- affectedLinks: normalized.affectedLinks,
2292
- derivedEffects: normalized.derivedEffects
2293
- };
2294
- }
2295
-
2296
- // src/links/validators.ts
2297
- var STATES = /* @__PURE__ */ new Set([
2298
- "active",
2299
- "dormant",
2300
- "strained",
2301
- "broken",
2302
- "archived"
2303
- ]);
2304
- function assertContactEndpointsNotMerged(db, fromContactId, toContactId) {
2305
- const from = getContactRowById(db, fromContactId);
2306
- const to = getContactRowById(db, toContactId);
2307
- if (!from || !to) {
2308
- throw new AffinityNotFoundError("contact not found");
2594
+
2595
+ // src/links/validators.ts
2596
+ var STATES = /* @__PURE__ */ new Set([
2597
+ "active",
2598
+ "dormant",
2599
+ "strained",
2600
+ "broken",
2601
+ "archived"
2602
+ ]);
2603
+ function assertContactEndpointsNotMerged(db, fromContactId, toContactId) {
2604
+ const from = getContactRowById(db, fromContactId);
2605
+ const to = getContactRowById(db, toContactId);
2606
+ if (!from || !to) {
2607
+ throw new AffinityNotFoundError("contact not found");
2309
2608
  }
2310
2609
  if (from.lifecycle_state === "merged" || to.lifecycle_state === "merged") {
2311
2610
  throw new AffinityStateError("merged contact is read-only");
@@ -2341,6 +2640,8 @@ function normalizeAttributeValue(value) {
2341
2640
  if (value === null) {
2342
2641
  return null;
2343
2642
  }
2643
+ if (typeof value !== "string")
2644
+ throw new AffinityValidationError("attribute values are strings or null");
2344
2645
  const trimmed = value.trim();
2345
2646
  return trimmed.length === 0 ? null : trimmed;
2346
2647
  }
@@ -2369,6 +2670,11 @@ function validateAttributeEntries(entries) {
2369
2670
  return out;
2370
2671
  }
2371
2672
  function assertAttributeTargetWritable(db, target) {
2673
+ if (target.kind === "event") {
2674
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(target.eventId))
2675
+ throw new AffinityNotFoundError("event not found");
2676
+ return;
2677
+ }
2372
2678
  if (target.kind === "contact") {
2373
2679
  const contact = getContactRowById(db, target.id);
2374
2680
  if (!contact) {
@@ -2404,26 +2710,14 @@ function replaceAttributes(db, target, entries) {
2404
2710
  }
2405
2711
  const created = [];
2406
2712
  let lastId = 0;
2407
- if (target.kind === "contact") {
2408
- const insert = db.prepare(
2409
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2410
- VALUES (?, NULL, ?, ?, ?, ?)`
2411
- );
2412
- for (const e of normalized) {
2413
- const r = insert.run(target.id, e.name, e.value, now, now);
2414
- lastId = Number(r.lastInsertRowid);
2415
- created.push({ kind: "attribute", id: lastId });
2416
- }
2417
- } else {
2418
- const insert = db.prepare(
2419
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2420
- VALUES (NULL, ?, ?, ?, ?, ?)`
2421
- );
2422
- for (const e of normalized) {
2423
- const r = insert.run(target.id, e.name, e.value, now, now);
2424
- lastId = Number(r.lastInsertRowid);
2425
- created.push({ kind: "attribute", id: lastId });
2426
- }
2713
+ const t = attributeTargetSql(target);
2714
+ const insert = db.prepare(
2715
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
2716
+ );
2717
+ for (const e of normalized) {
2718
+ const r = insert.run(t.id, e.name, e.value, now, now);
2719
+ lastId = Number(r.lastInsertRowid);
2720
+ created.push({ kind: "attribute", id: lastId });
2427
2721
  }
2428
2722
  if (normalized.length === 0) {
2429
2723
  return buildAttributeMutationReceipt(clearedAttributePrimary(target), {
@@ -2453,6 +2747,11 @@ function setAttribute(db, target, name, value) {
2453
2747
  const stored = normalizeAttributeValue(value);
2454
2748
  const now = resolveNow();
2455
2749
  const existing = getLiveAttributeRow(db, target, key);
2750
+ if (existing?.value === stored)
2751
+ return buildAttributeMutationReceipt(
2752
+ mapAttributeRowToAttributeRecord(existing),
2753
+ {}
2754
+ );
2456
2755
  if (existing) {
2457
2756
  db.prepare(
2458
2757
  "UPDATE attributes SET value = ?, updated_at = ? WHERE id = ?"
@@ -2468,27 +2767,10 @@ function setAttribute(db, target, name, value) {
2468
2767
  }
2469
2768
  );
2470
2769
  }
2471
- if (target.kind === "contact") {
2472
- const r2 = db.prepare(
2473
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2474
- VALUES (?, NULL, ?, ?, ?, ?)`
2475
- ).run(target.id, key, stored, now, now);
2476
- const id2 = Number(r2.lastInsertRowid);
2477
- const row2 = getAttributeRowById(db, id2);
2478
- if (!row2) {
2479
- throw new AffinityInvariantError("attribute row missing after insert");
2480
- }
2481
- return buildAttributeMutationReceipt(
2482
- mapAttributeRowToAttributeRecord(row2),
2483
- {
2484
- created: [{ kind: "attribute", id: id2 }]
2485
- }
2486
- );
2487
- }
2770
+ const t = attributeTargetSql(target);
2488
2771
  const r = db.prepare(
2489
- `INSERT INTO attributes (contact_id, link_id, name, value, created_at, updated_at)
2490
- VALUES (NULL, ?, ?, ?, ?, ?)`
2491
- ).run(target.id, key, stored, now, now);
2772
+ `INSERT INTO attributes (${t.column},name,value,created_at,updated_at) VALUES (?,?,?,?,?)`
2773
+ ).run(t.id, key, stored, now, now);
2492
2774
  const id = Number(r.lastInsertRowid);
2493
2775
  const row = getAttributeRowById(db, id);
2494
2776
  if (!row) {
@@ -2544,14 +2826,15 @@ function mutationToolResult(action, receipt, entitySummary, fields) {
2544
2826
  affectedLinks: receipt.affectedLinks,
2545
2827
  derivedEffects: receipt.derivedEffects
2546
2828
  };
2547
- const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
2829
+ const summary = changedCount === 0 ? receipt.primary && typeof receipt.primary === "object" && "externalRef" in receipt.primary && "id" in receipt.primary ? `Event already recorded (#${receipt.primary.id}, ${receipt.primary.externalRef.source} ${receipt.primary.externalRef.id}).` : `No ${entitySummary} changes were needed.` : describeChanges(receipt);
2548
2830
  if (changedCount === 0) {
2549
2831
  return toolNoOp(summary, data, {
2550
2832
  entities: toReceiptEntityRefs(receipt)
2551
2833
  });
2552
2834
  }
2553
2835
  return toolSuccess(summary, data, {
2554
- entities: toReceiptEntityRefs(receipt)
2836
+ entities: toReceiptEntityRefs(receipt),
2837
+ warnings: receipt.warnings
2555
2838
  });
2556
2839
  }
2557
2840
  var PLURALS = { identity: "identities" };
@@ -2608,6 +2891,30 @@ function linkLocatorSchema2(description) {
2608
2891
  };
2609
2892
  }
2610
2893
  function resolveAttributeTarget(db, target) {
2894
+ if (target.kind === "event") {
2895
+ if (target.eventId === void 0 === (target.ref === void 0))
2896
+ return {
2897
+ kind: "error",
2898
+ result: toolFailure(
2899
+ "protocol",
2900
+ "invalid_input",
2901
+ "Invalid event target.",
2902
+ "Provide exactly one of target.eventId or target.ref."
2903
+ )
2904
+ };
2905
+ const eventId = target.eventId ?? (target.ref && getEventByExternalRef(db, target.ref.source, target.ref.id)?.id);
2906
+ if (eventId === void 0)
2907
+ return {
2908
+ kind: "error",
2909
+ result: toolFailure(
2910
+ "domain",
2911
+ "not_found",
2912
+ "Event not found.",
2913
+ "No event matches target.ref."
2914
+ )
2915
+ };
2916
+ return { kind: "ok", target: { kind: "event", eventId } };
2917
+ }
2611
2918
  if (target.kind === "contact") {
2612
2919
  const contact = resolveContactLocator(db, target.contact, "target.contact");
2613
2920
  if (!contact.ok) {
@@ -2667,7 +2974,7 @@ function manageAttributeToolHandler(db, rawInput) {
2667
2974
  }
2668
2975
  var manageAttributeTool = defineAffinityTool({
2669
2976
  name: manageAttributeToolName,
2670
- description: "Set, unset, or replace custom metadata attributes on contacts and links. For emails, phones, handles, and URLs use manage_identity instead \u2014 those are routing keys, not attributes.",
2977
+ description: "Set, unset, or replace custom metadata attributes on contacts, links and events. For emails, phones, handles, and URLs use manage_identity instead \u2014 those are routing keys, not attributes.",
2671
2978
  whenToUse: "Use this for metadata such as tags, notes, or small labeled facts attached to a contact or link.",
2672
2979
  whenNotToUse: "Do not use this for core contact fields, identities, or direct relationship mechanics.",
2673
2980
  sideEffects: "writes_state",
@@ -2688,7 +2995,9 @@ var manageAttributeTool = defineAffinityTool({
2688
2995
  action: enumSchema("Operation to perform.", ["set", "unset", "replace"]),
2689
2996
  target: objectSchema(
2690
2997
  {
2691
- kind: enumSchema("Target kind.", ["contact", "link"]),
2998
+ kind: enumSchema("Target kind.", ["contact", "link", "event"]),
2999
+ eventId: integerSchema("Target event id; provide eventId or ref."),
3000
+ ref: externalRefSchema,
2692
3001
  contact: contactLocatorSchema2(
2693
3002
  "Contact locator. Required when kind=contact. Provide contactId or identity."
2694
3003
  ),
@@ -2718,7 +3027,7 @@ var manageAttributeTool = defineAffinityTool({
2718
3027
  removedAt: integerSchema("Removal timestamp. Optional for action=unset.")
2719
3028
  },
2720
3029
  ["action"],
2721
- "Set, unset, or replace attributes on contacts and links."
3030
+ "Set, unset, or replace attributes on contacts, links and events."
2722
3031
  ),
2723
3032
  handler: manageAttributeToolHandler
2724
3033
  });
@@ -2726,7 +3035,7 @@ var manageAttributeTool = defineAffinityTool({
2726
3035
  // src/events/persistence.ts
2727
3036
  function insertJournalEvent(db, params) {
2728
3037
  const now = resolveNow(params.now);
2729
- const summary = params.summary.trim();
3038
+ const summary = params.summary?.trim() ?? deriveEventSummary(params.content ?? "");
2730
3039
  const result = db.prepare(
2731
3040
  `INSERT INTO events (
2732
3041
  type, occurred_at, summary, significance, moment_kind,
@@ -2743,6 +3052,35 @@ function insertJournalEvent(db, params) {
2743
3052
  now
2744
3053
  );
2745
3054
  const eventId = Number(result.lastInsertRowid);
3055
+ db.prepare("UPDATE events SET affects_bond=? WHERE id=?").run(
3056
+ params.affectsBond === void 0 ? params.type === "observation" && params.significance <= 2 || ["activity", "milestone", "observation"].includes(params.type) && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
3057
+ summary
3058
+ ) && !/^follow[ -]up\b/i.test(summary) ? 0 : 1 : Number(params.affectsBond),
3059
+ eventId
3060
+ );
3061
+ db.prepare("UPDATE events SET declared_moment_kind=? WHERE id=?").run(
3062
+ params.momentKind,
3063
+ eventId
3064
+ );
3065
+ db.prepare(
3066
+ "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=?"
3067
+ ).run(
3068
+ params.content ?? null,
3069
+ params.externalRef?.source ?? null,
3070
+ params.externalRef?.id ?? null,
3071
+ params.channel ?? null,
3072
+ params.parentRef?.source ?? null,
3073
+ params.parentRef?.id ?? null,
3074
+ params.parentEventId ?? null,
3075
+ params.threadRef?.source ?? null,
3076
+ params.threadRef?.id ?? null,
3077
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
3078
+ eventId
3079
+ );
3080
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3081
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3082
+ eventId
3083
+ );
2746
3084
  const insertParticipant = db.prepare(
2747
3085
  `INSERT INTO event_participants (
2748
3086
  event_id, contact_id, role, directionality
@@ -2785,6 +3123,10 @@ function insertDateAnchorEvent(db, params) {
2785
3123
  now
2786
3124
  );
2787
3125
  const eventId = Number(result.lastInsertRowid);
3126
+ db.prepare("UPDATE events SET occurred_on = ? WHERE id = ?").run(
3127
+ params.occurredOn ?? new Date(params.occurredAt).toISOString().slice(0, 10),
3128
+ eventId
3129
+ );
2788
3130
  const insertParticipant = db.prepare(
2789
3131
  `INSERT INTO event_participants (
2790
3132
  event_id, contact_id, role, directionality
@@ -2805,20 +3147,6 @@ function insertDateAnchorEvent(db, params) {
2805
3147
  return eventId;
2806
3148
  }
2807
3149
 
2808
- // src/events/receipts.ts
2809
- function buildEventMutationReceipt(primary, partial) {
2810
- const normalized = normalizeMutationReceiptPartial(partial);
2811
- return {
2812
- primary,
2813
- created: normalized.created,
2814
- updated: normalized.updated,
2815
- archived: normalized.archived,
2816
- removed: normalized.removed,
2817
- affectedLinks: normalized.affectedLinks,
2818
- derivedEffects: normalized.derivedEffects
2819
- };
2820
- }
2821
-
2822
3150
  // src/graph/betweenness.ts
2823
3151
  function computeNodeBetweenness(adjacency) {
2824
3152
  const nodes = [...adjacency.keys()];
@@ -3417,6 +3745,8 @@ function deriveStateAfter(params) {
3417
3745
  return params.stateBefore;
3418
3746
  }
3419
3747
  function deriveMomentKind(params) {
3748
+ if (params.eventType === "observation" || params.eventType === "milestone" && params.significance < 7)
3749
+ return null;
3420
3750
  if (params.rankAfter > params.rankBefore) {
3421
3751
  return "breakthrough";
3422
3752
  }
@@ -3446,6 +3776,18 @@ function applyEventEffectToLink(db, params) {
3446
3776
  if (!link || link.is_structural !== 0) {
3447
3777
  throw new AffinityStateError("relational link not found");
3448
3778
  }
3779
+ db.prepare(
3780
+ "INSERT OR IGNORE INTO event_link_baselines(link_id,state_json) VALUES (?,?)"
3781
+ ).run(
3782
+ params.linkId,
3783
+ JSON.stringify({
3784
+ rank: link.rank ?? 0,
3785
+ affinity: link.affinity ?? 0,
3786
+ trust: link.trust ?? 0,
3787
+ state: link.state ?? "active",
3788
+ cadence_days: link.cadence_days
3789
+ })
3790
+ );
3449
3791
  const stateBefore = link.state ?? "active";
3450
3792
  const linkKind = link.kind;
3451
3793
  const rankBefore = link.rank ?? 0;
@@ -3490,7 +3832,7 @@ function applyEventEffectToLink(db, params) {
3490
3832
  );
3491
3833
  const weightedBase = base * massPenalty(
3492
3834
  weeklyPositiveBaseWeight(db, params.linkId, params.occurredAt)
3493
- ) * salienceMultiplier;
3835
+ ) * salienceMultiplier * (params.participant.directionality === "owner_initiated" ? 0.05 : 1);
3494
3836
  const affinityGain = weightedBase * Math.max(eventValence, 0) * (0.55 + 0.45 * eventIntimacyDepth) * (0.7 + 0.3 * participantReciprocity) * (1 / (1 + 0.22 * rankBefore));
3495
3837
  const affinityLoss = weightedBase * Math.max(-eventValence, 0) * 0.35;
3496
3838
  const affinityMass = clamp3(
@@ -3531,15 +3873,16 @@ function applyEventEffectToLink(db, params) {
3531
3873
  const latest = db.prepare(
3532
3874
  `SELECT occurred_at
3533
3875
  FROM link_event_effects
3534
- WHERE link_id = ?
3876
+ WHERE link_id = ? AND counts_for_recency = 1 AND event_id IN (SELECT id FROM events WHERE deleted_at IS NULL AND (occurred_at <= ? OR occurred_on <= ?))
3535
3877
  ORDER BY occurred_at DESC, event_id DESC
3536
3878
  LIMIT 1`
3537
- ).get(params.linkId);
3879
+ ).get(params.linkId, params.now, localCalendarDate(params.now));
3538
3880
  const gapDays = latest === void 0 ? cadenceBefore : Math.max(
3539
3881
  1,
3540
3882
  Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
3541
3883
  );
3542
- const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
3884
+ const eventDay = db.prepare("SELECT occurred_on FROM events WHERE id = ?").get(params.eventId)?.occurred_on;
3885
+ const cadenceAfter = commitmentResolution != null || params.eventType === "promise" || params.eventType === "agreement" || params.occurredAt > params.now && (eventDay == null || eventDay > localCalendarDate(params.now)) ? cadenceBefore : nextCadenceDays({
3543
3886
  kind: linkKind,
3544
3887
  currentCadenceDays: cadenceBefore,
3545
3888
  gapDays
@@ -3559,7 +3902,11 @@ function applyEventEffectToLink(db, params) {
3559
3902
  }
3560
3903
  const affinityDelta = affinityAfter - affinityBefore;
3561
3904
  const trustDelta = trustAfter - trustBefore;
3562
- const momentKind = deriveMomentKind({
3905
+ const eventLabel = db.prepare("SELECT summary FROM events WHERE id=?").get(params.eventId)?.summary;
3906
+ const followLike = params.eventType === "milestone" && typeof eventLabel === "string" && /^(?:follow(?:ed|ing)?|like(?:d)?|repost(?:ed)?)(?:\b|[-_])/i.test(
3907
+ eventLabel
3908
+ );
3909
+ const momentKind = followLike ? null : deriveMomentKind({
3563
3910
  rankBefore,
3564
3911
  rankAfter,
3565
3912
  stateBefore,
@@ -3602,7 +3949,15 @@ function applyEventEffectToLink(db, params) {
3602
3949
  params.now,
3603
3950
  params.now
3604
3951
  );
3605
- if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
3952
+ db.prepare(
3953
+ "UPDATE link_event_effects SET participant_json=?,provenance_json=? WHERE event_id=? AND link_id=?"
3954
+ ).run(
3955
+ JSON.stringify(params.participant),
3956
+ params.provenance === void 0 ? null : JSON.stringify(params.provenance),
3957
+ params.eventId,
3958
+ params.linkId
3959
+ );
3960
+ if (commitmentResolution !== void 0 && commitmentResolution !== null || params.eventType === "promise" || params.eventType === "agreement" || params.participant.directionality === "owner_initiated") {
3606
3961
  db.prepare(
3607
3962
  "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
3608
3963
  ).run(params.eventId, params.linkId);
@@ -3633,6 +3988,7 @@ function applyEventEffectToLink(db, params) {
3633
3988
 
3634
3989
  // src/lib/errors/affinity_conflict_error.ts
3635
3990
  var AffinityConflictError = class extends AffinityError {
3991
+ existingContactId;
3636
3992
  code = "CONFLICT";
3637
3993
  };
3638
3994
 
@@ -3687,6 +4043,8 @@ function buildLinkMutationReceipt(primary, partial) {
3687
4043
  // src/links/seed_social_link.ts
3688
4044
  function seedSocialLink(db, input) {
3689
4045
  return withTransaction(db, () => {
4046
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
4047
+ throw new AffinityValidationError("ifExists must be error or return");
3690
4048
  if (!isRelationalLinkKind(input.kind)) {
3691
4049
  throw new AffinityValidationError("invalid relational link kind");
3692
4050
  }
@@ -3694,11 +4052,14 @@ function seedSocialLink(db, input) {
3694
4052
  throw new AffinityValidationError("from and to contacts must differ");
3695
4053
  }
3696
4054
  assertContactEndpointsNotMerged(db, input.fromContactId, input.toContactId);
3697
- if (findLiveRelationalLinkAnyDirection(
4055
+ const existing = findLiveRelationalLinkAnyDirection(
3698
4056
  db,
3699
4057
  input.fromContactId,
3700
4058
  input.toContactId
3701
- )) {
4059
+ );
4060
+ if (existing) {
4061
+ if (input.ifExists === "return")
4062
+ return buildLinkMutationReceipt(mapLinkRowToLinkListItem(existing), {});
3702
4063
  throw new AffinityConflictError(
3703
4064
  "relational link already exists between these contacts"
3704
4065
  );
@@ -3804,15 +4165,36 @@ function ensureRelationalLink(db, params) {
3804
4165
  };
3805
4166
  }
3806
4167
  function applyJournalEventMechanics(db, params) {
4168
+ const bond = db.prepare("SELECT affects_bond, channel FROM events WHERE id=?").get(params.eventId);
4169
+ const ambient = params.provenance && typeof params.provenance === "object" && (params.provenance.ambient === true || params.provenance.tags?.includes("ambient"));
4170
+ if (bond?.affects_bond === 0 || bond?.affects_bond == null && params.eventType === "observation" && params.significance <= 2 || ambient)
4171
+ return { created: [], affectedLinks: [], derivedEffects: [] };
3807
4172
  const ownerId = requireOwnerContactId(db);
3808
- const participantIds = params.participants.map(
4173
+ const owner = params.participants.find((p) => p.contactId === ownerId);
4174
+ const socialChannel = [
4175
+ "bluesky",
4176
+ "x",
4177
+ "twitter",
4178
+ "mastodon",
4179
+ "github",
4180
+ "instagram"
4181
+ ].includes(String(bond?.channel));
4182
+ const direction = owner?.directionality ?? (socialChannel ? owner?.role === "actor" ? "owner_initiated" : owner?.role === "recipient" ? "other_initiated" : void 0 : void 0);
4183
+ const participants = params.participants.map(
4184
+ (p) => direction && p.directionality === void 0 ? { ...p, directionality: direction } : p
4185
+ );
4186
+ if (direction)
4187
+ db.prepare(
4188
+ "UPDATE event_participants SET directionality=? WHERE event_id=? AND directionality IS NULL"
4189
+ ).run(direction, params.eventId);
4190
+ const participantIds = participants.map(
3809
4191
  (participant) => participant.contactId
3810
4192
  );
3811
4193
  const hasOwner = participantIds.includes(ownerId);
3812
4194
  const created = [];
3813
4195
  const effectInputs = [];
3814
4196
  if (hasOwner) {
3815
- for (const participant of params.participants) {
4197
+ for (const participant of participants) {
3816
4198
  if (participant.contactId === ownerId) {
3817
4199
  continue;
3818
4200
  }
@@ -3820,13 +4202,14 @@ function applyJournalEventMechanics(db, params) {
3820
4202
  fromContactId: ownerId,
3821
4203
  toContactId: participant.contactId,
3822
4204
  kind: inferAutoLinkKind(db, ownerId, participant.contactId),
4205
+ ...(participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality) === "owner_initiated" ? { affinity: 0.15 } : {},
3823
4206
  now: params.now
3824
4207
  });
3825
4208
  created.push(...ensured.created);
3826
4209
  effectInputs.push({ linkId: ensured.linkId, participant });
3827
4210
  }
3828
- } else if (params.eventType === "observation" && params.participants.length === 2) {
3829
- const [first, second] = params.participants;
4211
+ } else if (params.eventType === "observation" && participants.length === 2) {
4212
+ const [first, second] = participants;
3830
4213
  if (first !== void 0 && second !== void 0) {
3831
4214
  const a = Math.min(first.contactId, second.contactId);
3832
4215
  const b = Math.max(first.contactId, second.contactId);
@@ -3861,7 +4244,10 @@ function applyJournalEventMechanics(db, params) {
3861
4244
  eventType: params.eventType,
3862
4245
  occurredAt: params.occurredAt,
3863
4246
  significance: params.significance,
3864
- participant: input.participant,
4247
+ participant: {
4248
+ ...input.participant,
4249
+ directionality: input.participant.directionality ?? participants.find((p) => p.contactId === ownerId)?.directionality
4250
+ },
3865
4251
  provenance: params.provenance,
3866
4252
  now: params.now
3867
4253
  })
@@ -3885,6 +4271,8 @@ function applyJournalEventMechanics(db, params) {
3885
4271
  function recordCommitment(db, input) {
3886
4272
  return withTransaction(db, () => {
3887
4273
  validateSocialEventInput(input);
4274
+ const existing = existingEventReceipt(db, input.externalRef);
4275
+ if (existing) return existing;
3888
4276
  if (input.commitmentType !== "promise" && input.commitmentType !== "agreement") {
3889
4277
  throw new AffinityValidationError(
3890
4278
  "commitmentType must be promise or agreement"
@@ -3899,11 +4287,12 @@ function recordCommitment(db, input) {
3899
4287
  assertOwnerParticipates(ownerId, ids);
3900
4288
  const now = resolveNow(input.now);
3901
4289
  const eventId = insertJournalEvent(db, {
4290
+ ...input,
3902
4291
  type: input.commitmentType,
3903
4292
  occurredAt: input.occurredAt,
3904
- summary: input.summary,
4293
+ occurredOn: input.occurredOn,
3905
4294
  significance: input.significance,
3906
- momentKind: null,
4295
+ momentKind: input.momentKind ?? null,
3907
4296
  participants: input.participants,
3908
4297
  now
3909
4298
  });
@@ -3912,15 +4301,25 @@ function recordCommitment(db, input) {
3912
4301
  event_id, commitment_type, due_at, created_at, updated_at
3913
4302
  ) VALUES (?, ?, ?, ?, ?)`
3914
4303
  ).run(eventId, input.commitmentType, input.dueAt ?? null, now, now);
4304
+ db.prepare("UPDATE open_commitments SET due_on = ? WHERE event_id = ?").run(
4305
+ input.dueAt == null ? null : input.dueOn ?? new Date(input.dueAt).toISOString().slice(0, 10),
4306
+ eventId
4307
+ );
3915
4308
  const mechanics = applyJournalEventMechanics(db, {
3916
4309
  eventId,
3917
4310
  eventType: input.commitmentType,
3918
4311
  occurredAt: input.occurredAt,
4312
+ occurredOn: input.occurredOn,
3919
4313
  significance: input.significance,
3920
4314
  participants: input.participants,
3921
4315
  provenance: input.provenance,
3922
4316
  now
3923
4317
  });
4318
+ if (input.momentKind !== void 0)
4319
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
4320
+ input.momentKind,
4321
+ eventId
4322
+ );
3924
4323
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
3925
4324
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
3926
4325
  affectedLinks: mechanics.affectedLinks,
@@ -3994,6 +4393,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
3994
4393
  const occurredAt = options?.resolvingEvent?.occurredAt ?? now;
3995
4394
  validateSocialEventInput({
3996
4395
  occurredAt,
4396
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
3997
4397
  summary,
3998
4398
  significance,
3999
4399
  participants,
@@ -4010,6 +4410,7 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
4010
4410
  const resolvingEventId = insertJournalEvent(db, {
4011
4411
  type: eventType,
4012
4412
  occurredAt,
4413
+ ...options?.resolvingEvent?.occurredOn ? { occurredOn: options.resolvingEvent.occurredOn } : {},
4013
4414
  summary,
4014
4415
  significance,
4015
4416
  momentKind: null,
@@ -4070,7 +4471,13 @@ function prepareParticipants(db, raw) {
4070
4471
  }
4071
4472
  function manageCommitmentToolHandler(db, rawInput) {
4072
4473
  const input = structuredClone(rawInput);
4073
- const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
4474
+ const inputError = validateToolInput(
4475
+ manageCommitmentTool.inputSchema,
4476
+ input,
4477
+ "input",
4478
+ Date.now(),
4479
+ true
4480
+ );
4074
4481
  if (inputError)
4075
4482
  return toolFailure(
4076
4483
  "protocol",
@@ -4100,6 +4507,21 @@ function manageCommitmentToolHandler(db, rawInput) {
4100
4507
  "commitment"
4101
4508
  );
4102
4509
  }
4510
+ if (!Number.isInteger(input.commitmentEventId))
4511
+ return toolFailure(
4512
+ "protocol",
4513
+ "invalid_input",
4514
+ "Missing commitment.",
4515
+ "commitmentEventId is required for resolve."
4516
+ );
4517
+ const event = db.prepare("SELECT type FROM events WHERE id = ? AND deleted_at IS NULL").get(input.commitmentEventId);
4518
+ if (event && !["promise", "agreement"].includes(event.type))
4519
+ return toolFailure(
4520
+ "domain",
4521
+ "invalid_input",
4522
+ "Invalid commitment.",
4523
+ "not a commitment event"
4524
+ );
4103
4525
  return mutationToolResult(
4104
4526
  "resolve",
4105
4527
  resolveCommitment(
@@ -4138,15 +4560,21 @@ var manageCommitmentTool = defineAffinityTool({
4138
4560
  "promise",
4139
4561
  "agreement"
4140
4562
  ]),
4563
+ ...eventMetadataSchema,
4141
4564
  occurredAt: integerSchema(
4142
4565
  "When the commitment was made, Unix timestamp in milliseconds."
4143
4566
  ),
4144
4567
  summary: stringSchema("Commitment summary. Must be non-empty."),
4145
4568
  significance: integerSchema("Commitment significance from 1 to 10."),
4146
4569
  dueAt: integerSchema("Optional due timestamp."),
4570
+ dueOn: stringSchema("Due calendar day."),
4147
4571
  participants: arraySchema(
4148
4572
  objectSchema(
4149
4573
  {
4574
+ contact: objectSchema(
4575
+ { contactId: integerSchema("Contact id.") },
4576
+ ["contactId"]
4577
+ ),
4150
4578
  contactId: integerSchema(
4151
4579
  "Participant contact id. The owner contact MUST be included."
4152
4580
  ),
@@ -4159,13 +4587,7 @@ var manageCommitmentTool = defineAffinityTool({
4159
4587
  ),
4160
4588
  now: integerSchema("Optional timestamp override.")
4161
4589
  },
4162
- [
4163
- "commitmentType",
4164
- "occurredAt",
4165
- "summary",
4166
- "significance",
4167
- "participants"
4168
- ],
4590
+ ["commitmentType", "occurredAt", "significance", "participants"],
4169
4591
  "Commitment payload. Required when action=record."
4170
4592
  ),
4171
4593
  commitmentEventId: integerSchema(
@@ -4180,6 +4602,19 @@ var manageCommitmentTool = defineAffinityTool({
4180
4602
  now: integerSchema("Optional timestamp."),
4181
4603
  resolvingEvent: objectSchema(
4182
4604
  {
4605
+ occurredOn: stringSchema("Calendar day."),
4606
+ provenance: {},
4607
+ participants: arraySchema(
4608
+ objectSchema(
4609
+ {
4610
+ contactId: integerSchema("Contact id."),
4611
+ role: stringSchema("Role."),
4612
+ directionality: stringSchema("Directionality.")
4613
+ },
4614
+ ["contactId", "role"]
4615
+ ),
4616
+ "Resolving participants."
4617
+ ),
4183
4618
  type: stringSchema("Optional resolving event type."),
4184
4619
  occurredAt: integerSchema("Optional resolving event timestamp."),
4185
4620
  summary: stringSchema("Optional resolving event summary."),
@@ -4260,30 +4695,46 @@ function deleteContact(db, locatorId, options) {
4260
4695
  });
4261
4696
  }
4262
4697
 
4263
- // src/contacts/export_contact.ts
4264
- function exportContact(db, id) {
4265
- const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
4698
+ // src/contacts/export_rows.ts
4699
+ function exportRows(db, sql, ...params) {
4700
+ return db.prepare(sql).all(...params).map((row) => {
4266
4701
  const result = {};
4267
4702
  for (const [key, value] of Object.entries(row)) {
4268
4703
  const name = key.replace(
4269
4704
  /_([a-z])/g,
4270
4705
  (_, letter) => letter.toUpperCase()
4271
4706
  );
4272
- result[name] = value;
4273
- if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
4707
+ result[name] = [
4708
+ "affects_bond",
4709
+ "is_owner",
4710
+ "is_structural",
4711
+ "verified",
4712
+ "counts_for_recency",
4713
+ "manual"
4714
+ ].includes(key) ? Boolean(value) : value instanceof Uint8Array ? Buffer.from(value).toString("utf8") : value;
4715
+ if (name.endsWith("At") && typeof value === "number")
4274
4716
  result[`${name}Iso`] = new Date(value).toISOString();
4275
4717
  if (key.endsWith("_json") && typeof value === "string")
4276
4718
  result[name] = JSON.parse(value);
4277
4719
  }
4278
4720
  return result;
4279
4721
  });
4722
+ }
4723
+
4724
+ // src/contacts/export_contact.ts
4725
+ function exportContact(db, id) {
4726
+ const query = (sql, ...params) => exportRows(db, sql, ...params);
4280
4727
  const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
4281
4728
  if (!contact) throw new AffinityNotFoundError("contact not found");
4282
4729
  const events = query(
4283
- "SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
4730
+ "SELECT DISTINCT e.*, COALESCE(e.parent_event_id, (SELECT parent.id FROM events parent WHERE parent.external_source=e.parent_source AND parent.external_id=e.parent_ref_id AND parent.deleted_at IS NULL)) AS parent_event_id, CAST(e.content AS BLOB) AS content, CAST(e.summary AS BLOB) AS summary FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? AND e.type != 'date_anchor' ORDER BY e.id",
4284
4731
  id
4285
4732
  ).map((event) => ({
4286
4733
  ...event,
4734
+ attributes: query(
4735
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
4736
+ event.id
4737
+ ),
4287
4738
  participants: query(
4288
4739
  "SELECT p.*, c.name AS contact_name FROM event_participants p JOIN contacts c ON c.id = p.contact_id WHERE event_id = ? ORDER BY p.contact_id",
4289
4740
  event.id
@@ -4328,11 +4779,33 @@ function exportContact(db, id) {
4328
4779
  // src/contacts/export_affinity.ts
4329
4780
  function exportAffinity(db) {
4330
4781
  const now = Date.now();
4782
+ const records = db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id));
4783
+ const events = exportRows(
4784
+ db,
4785
+ "SELECT *, CAST(content AS BLOB) AS content, CAST(summary AS BLOB) AS summary FROM events ORDER BY id"
4786
+ ).map((event) => ({
4787
+ ...event,
4788
+ attributes: exportRows(
4789
+ db,
4790
+ "SELECT * FROM attributes WHERE event_id=? ORDER BY id",
4791
+ event.id
4792
+ ),
4793
+ participants: exportRows(
4794
+ db,
4795
+ "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",
4796
+ event.id
4797
+ )
4798
+ }));
4331
4799
  return {
4332
4800
  version: 1,
4333
4801
  exportedAt: now,
4334
4802
  exportedAtIso: new Date(now).toISOString(),
4335
- contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
4803
+ events,
4804
+ contacts: records.map(({ events: events2, dates, ...record }) => ({
4805
+ ...record,
4806
+ eventIds: events2.map((event) => event.id),
4807
+ dateIds: dates.map((date) => date.id)
4808
+ }))
4336
4809
  };
4337
4810
  }
4338
4811
 
@@ -4405,7 +4878,7 @@ function assertValidLifecycleTransition(from, to) {
4405
4878
  }
4406
4879
  if (to === "merged") {
4407
4880
  throw new AffinityInvariantError(
4408
- "use manage_contact merge (CLI: contact merge) to reach merged state"
4881
+ "use merge_contacts (CLI: contact merge) to reach merged state"
4409
4882
  );
4410
4883
  }
4411
4884
  if (from === to) {
@@ -4457,67 +4930,6 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
4457
4930
  });
4458
4931
  }
4459
4932
 
4460
- // src/contacts/create_contact.ts
4461
- var KINDS = /* @__PURE__ */ new Set([
4462
- "human",
4463
- "group",
4464
- "company",
4465
- "team",
4466
- "pet",
4467
- "service",
4468
- "other"
4469
- ]);
4470
- function createContact(db, input) {
4471
- return withTransaction(db, () => {
4472
- const name = input.name.trim();
4473
- if (name.length === 0) {
4474
- throw new AffinityValidationError("name must be non-empty");
4475
- }
4476
- if (!KINDS.has(input.kind)) {
4477
- throw new AffinityValidationError("invalid contact kind");
4478
- }
4479
- if (input.isOwner !== void 0) {
4480
- throw new AffinityValidationError(
4481
- "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
4482
- );
4483
- }
4484
- const now = resolveNow(input.now);
4485
- if (input.bootstrapOwner) {
4486
- const existing = db.prepare(
4487
- "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
4488
- ).get();
4489
- if (existing !== void 0) {
4490
- throw new AffinityConflictError(
4491
- `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
4492
- );
4493
- }
4494
- }
4495
- const isOwner = input.bootstrapOwner ? 1 : 0;
4496
- const result = db.prepare(
4497
- `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
4498
- VALUES (?, ?, 'active', ?, ?, ?)`
4499
- ).run(name, input.kind, isOwner, now, now);
4500
- const id = Number(result.lastInsertRowid);
4501
- const row = getContactRowById(db, id);
4502
- if (!row) {
4503
- throw new AffinityInvariantError("inserted contact row not found");
4504
- }
4505
- refreshContactRollup(db, id, now);
4506
- const primary = mapContactRowToContactListItem(row);
4507
- return buildContactMutationReceipt(primary, {
4508
- created: [{ kind: "contact", id }]
4509
- });
4510
- });
4511
- }
4512
-
4513
- // src/contacts/fold_search_text.ts
4514
- function foldSearchText(value) {
4515
- let folded = value.toLowerCase();
4516
- if (/[^\u0020-\u007e]/.test(folded))
4517
- folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
4518
- return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
4519
- }
4520
-
4521
4933
  // src/identities/queries.ts
4522
4934
  function getIdentityRowById(db, id) {
4523
4935
  const row = db.prepare(
@@ -4549,14 +4961,19 @@ function buildIdentityMutationReceipt(primary, partial) {
4549
4961
  // src/identities/validators.ts
4550
4962
  function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
4551
4963
  const row = excludeIdentityId === void 0 ? db.prepare(
4552
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
4553
- ).get(normalizedKey) : db.prepare(
4554
- "SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
4555
- ).get(normalizedKey, excludeIdentityId);
4964
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL"
4965
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey) : db.prepare(
4966
+ "SELECT id FROM identities WHERE (normalized_key IN (?, ?) OR (type='custom:atproto-did' AND ? = 'did:' || lower(value))) AND removed_at IS NULL AND id != ?"
4967
+ ).get(
4968
+ normalizedKey,
4969
+ legacyIdentityKey(normalizedKey),
4970
+ normalizedKey,
4971
+ excludeIdentityId
4972
+ );
4556
4973
  if (row !== void 0) {
4557
4974
  const owner = db.prepare(
4558
- "SELECT c.id, c.name FROM contacts c JOIN identities i ON i.contact_id = c.id WHERE i.normalized_key = ? AND i.removed_at IS NULL"
4559
- ).get(normalizedKey);
4975
+ "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"
4976
+ ).get(normalizedKey, legacyIdentityKey(normalizedKey), normalizedKey);
4560
4977
  throw new AffinityConflictError(
4561
4978
  `${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
4562
4979
  );
@@ -4565,6 +4982,8 @@ function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
4565
4982
 
4566
4983
  // src/identities/validate_identity_input.ts
4567
4984
  var identityTypes = [
4985
+ "did",
4986
+ "atproto_did",
4568
4987
  "email",
4569
4988
  "phone",
4570
4989
  "url",
@@ -4591,8 +5010,20 @@ var identityTypes = [
4591
5010
  "account_id",
4592
5011
  "alias"
4593
5012
  ];
4594
- function validateIdentityInput(type, value) {
5013
+ function validateIdentityInput(type, rawValue) {
4595
5014
  const kind = type.toLowerCase();
5015
+ const value = normalizeIdentityValue(kind, rawValue);
5016
+ if (["did", "atproto_did", "custom:atproto-did"].includes(kind) && !/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value))
5017
+ throw new AffinityValidationError(
5018
+ "did value must match did:<method>:<identifier> (for example did:plc:abc123)"
5019
+ );
5020
+ if (handleIdentityTypes.has(kind)) {
5021
+ 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_.-]+$/;
5022
+ if (!pattern.test(value))
5023
+ throw new AffinityValidationError(
5024
+ `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)"}`
5025
+ );
5026
+ }
4596
5027
  if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
4597
5028
  throw new AffinityValidationError(
4598
5029
  `identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
@@ -4631,12 +5062,27 @@ function addIdentity(db, contactId, input) {
4631
5062
  throw new AffinityStateError("merged contact is read-only");
4632
5063
  }
4633
5064
  const type = input.type.trim();
4634
- const value = input.value.trim();
5065
+ const value = normalizeIdentityValue(type, input.value);
4635
5066
  if (type.length === 0 || value.length === 0) {
4636
5067
  throw new AffinityValidationError("type and value must be non-empty");
4637
5068
  }
4638
5069
  validateIdentityInput(type, value);
5070
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
5071
+ throw new AffinityValidationError("ifExists must be error or return");
4639
5072
  const nk = normalizeIdentityKey(type, value);
5073
+ if (input.ifExists === "return") {
5074
+ const existing = db.prepare(
5075
+ "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"
5076
+ ).get(contactId, nk, legacyIdentityKey(nk), nk);
5077
+ if (existing) {
5078
+ const row2 = getIdentityRowById(db, Number(existing.id));
5079
+ if (row2)
5080
+ return buildIdentityMutationReceipt(
5081
+ mapIdentityRowToIdentityRecord(row2),
5082
+ {}
5083
+ );
5084
+ }
5085
+ }
4640
5086
  assertNoIdentityCollision(db, nk);
4641
5087
  const now = resolveNow(input.now);
4642
5088
  const verified = input.verified ? 1 : 0;
@@ -4670,31 +5116,239 @@ function addIdentity(db, contactId, input) {
4670
5116
  });
4671
5117
  }
4672
5118
 
4673
- // src/tools/create_contact_tool_result.ts
4674
- function createContactToolResult(db, input) {
4675
- return withTransaction(db, () => {
4676
- const receipt = createContact(db, input);
4677
- const identityOwners = /* @__PURE__ */ new Set();
4678
- for (const identity of input.identities ?? []) {
4679
- validateIdentityInput(identity.type.trim(), identity.value.trim());
4680
- const existing = db.prepare(
4681
- "SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
4682
- ).get(normalizeIdentityKey(identity.type, identity.value));
4683
- if (existing) {
4684
- if (Number(existing.contact_id) !== receipt.primary.id)
4685
- identityOwners.add(Number(existing.contact_id));
5119
+ // src/contacts/create_contact.ts
5120
+ var KINDS = /* @__PURE__ */ new Set([
5121
+ "human",
5122
+ "group",
5123
+ "company",
5124
+ "team",
5125
+ "pet",
5126
+ "service",
5127
+ "other"
5128
+ ]);
5129
+ function createContact(db, input) {
5130
+ return withTransaction(db, () => {
5131
+ const name = input.name.trim();
5132
+ if (name.length === 0) {
5133
+ throw new AffinityValidationError("name must be non-empty");
5134
+ }
5135
+ if (!KINDS.has(input.kind)) {
5136
+ throw new AffinityValidationError("invalid contact kind");
5137
+ }
5138
+ if (input.isOwner !== void 0) {
5139
+ throw new AffinityValidationError(
5140
+ "isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
5141
+ );
5142
+ }
5143
+ if (input.ifExists !== void 0 && !["error", "return"].includes(input.ifExists))
5144
+ throw new AffinityValidationError("ifExists must be error or return");
5145
+ const matches = (input.identities ?? []).flatMap((identity) => {
5146
+ validateIdentityInput(identity.type.trim(), identity.value.trim());
5147
+ const key = normalizeIdentityKey(identity.type, identity.value);
5148
+ const row2 = db.prepare(
5149
+ `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)))`
5150
+ ).get(key, legacyIdentityKey(key), key);
5151
+ return row2 ? [{ ...row2, identity }] : [];
5152
+ });
5153
+ const match = matches[0];
5154
+ if (match) {
5155
+ if (input.ifExists === "return") {
5156
+ const existing = getContactRowById(db, match.id);
5157
+ if (!existing)
5158
+ throw new AffinityInvariantError("identity owner not found");
5159
+ const primary2 = mapContactRowToContactListItem(existing);
5160
+ return Object.assign(buildContactMutationReceipt(primary2, {}), {
5161
+ outcome: "no_op",
5162
+ matchedIdentities: matches.map((m) => m.identity)
5163
+ });
5164
+ }
5165
+ const error = new AffinityConflictError(
5166
+ `identity ${normalizeIdentityKey(match.identity.type, match.identity.value).replace(":", " ")} already belongs to #${match.id} ${match.name}`
5167
+ );
5168
+ error.existingContactId = match.id;
5169
+ throw error;
5170
+ }
5171
+ const now = resolveNow(input.now);
5172
+ if (input.bootstrapOwner) {
5173
+ const existing = db.prepare(
5174
+ "SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
5175
+ ).get();
5176
+ if (existing !== void 0) {
5177
+ throw new AffinityConflictError(
5178
+ `owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
5179
+ );
5180
+ }
5181
+ }
5182
+ const isOwner = input.bootstrapOwner ? 1 : 0;
5183
+ const result = db.prepare(
5184
+ `INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
5185
+ VALUES (?, ?, 'active', ?, ?, ?)`
5186
+ ).run(name, input.kind, isOwner, now, now);
5187
+ const id = Number(result.lastInsertRowid);
5188
+ const row = getContactRowById(db, id);
5189
+ if (!row) {
5190
+ throw new AffinityInvariantError("inserted contact row not found");
5191
+ }
5192
+ refreshContactRollup(db, id, now);
5193
+ const primary = mapContactRowToContactListItem(row);
5194
+ const receipt = buildContactMutationReceipt(primary, {
5195
+ created: [{ kind: "contact", id }]
5196
+ });
5197
+ for (const identity of input.identities ?? [])
5198
+ receipt.created.push(...addIdentity(db, id, identity).created);
5199
+ return receipt;
5200
+ });
5201
+ }
5202
+
5203
+ // src/contacts/fold_search_text.ts
5204
+ function foldSearchText(value) {
5205
+ let folded = value.toLowerCase();
5206
+ if (/[^\u0020-\u007e]/.test(folded))
5207
+ folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
5208
+ return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
5209
+ }
5210
+
5211
+ // src/contacts/list_duplicate_candidates.ts
5212
+ function pairKey(leftContactId, rightContactId) {
5213
+ const left = Math.min(leftContactId, rightContactId);
5214
+ const right = Math.max(leftContactId, rightContactId);
5215
+ return `${left}:${right}`;
5216
+ }
5217
+ function normalizeName(name) {
5218
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
5219
+ }
5220
+ function fuzzyNameScore(leftInput, rightInput) {
5221
+ const left = normalizeName(leftInput);
5222
+ const right = normalizeName(rightInput);
5223
+ if (left === right) {
5224
+ return 0.92;
5225
+ }
5226
+ if (left.length >= 3 && right.length >= 3) {
5227
+ if (left.includes(right) || right.includes(left)) {
5228
+ return 0.72;
5229
+ }
5230
+ const leftTokens = new Set(left.split(" "));
5231
+ const rightTokens = new Set(right.split(" "));
5232
+ const overlap = [...leftTokens].filter(
5233
+ (token) => rightTokens.has(token)
5234
+ ).length;
5235
+ if (overlap > 0) {
5236
+ return overlap / Math.max(leftTokens.size, rightTokens.size) * 0.7;
5237
+ }
5238
+ }
5239
+ return 0;
5240
+ }
5241
+ function listDuplicateCandidates(db, filters, options) {
5242
+ assertDefaultOrdering("listDuplicateCandidates", options);
5243
+ const { limit, offset } = resolveListLimitOffset(options);
5244
+ const subset = filters?.contactIds;
5245
+ const contacts = db.prepare(
5246
+ `SELECT id, name
5247
+ FROM contacts
5248
+ WHERE deleted_at IS NULL
5249
+ AND lifecycle_state != 'merged'`
5250
+ ).all();
5251
+ const allowedIds = subset === void 0 ? null : new Set(subset.map((contactId) => Number(contactId)));
5252
+ const pairs = /* @__PURE__ */ new Map();
5253
+ const exactMatches = db.prepare(
5254
+ `SELECT
5255
+ i1.contact_id AS left_contact_id,
5256
+ i2.contact_id AS right_contact_id,
5257
+ i1.normalized_key
5258
+ FROM identities i1
5259
+ INNER JOIN identities i2
5260
+ ON i1.normalized_key = i2.normalized_key
5261
+ AND i1.contact_id < i2.contact_id
5262
+ INNER JOIN contacts c1 ON c1.id = i1.contact_id
5263
+ INNER JOIN contacts c2 ON c2.id = i2.contact_id
5264
+ WHERE i1.removed_at IS NULL
5265
+ AND i2.removed_at IS NULL
5266
+ AND c1.deleted_at IS NULL
5267
+ AND c2.deleted_at IS NULL
5268
+ AND c1.lifecycle_state != 'merged'
5269
+ AND c2.lifecycle_state != 'merged'`
5270
+ ).all();
5271
+ for (const match of exactMatches) {
5272
+ if (allowedIds !== null && (!allowedIds.has(match.left_contact_id) || !allowedIds.has(match.right_contact_id))) {
5273
+ continue;
5274
+ }
5275
+ pairs.set(pairKey(match.left_contact_id, match.right_contact_id), {
5276
+ leftContactId: match.left_contact_id,
5277
+ rightContactId: match.right_contact_id,
5278
+ matchReason: `exact identity ${match.normalized_key}`,
5279
+ matchScore: 1
5280
+ });
5281
+ }
5282
+ if (filters?.exactOnly !== true) {
5283
+ for (let index = 0; index < contacts.length; index += 1) {
5284
+ const left = contacts[index];
5285
+ if (left === void 0 || allowedIds !== null && !allowedIds.has(left.id)) {
4686
5286
  continue;
4687
5287
  }
4688
- const added = addIdentity(db, receipt.primary.id, identity);
4689
- receipt.created.push(...added.created);
5288
+ const leftName = normalizeName(left.name);
5289
+ for (let offsetIndex = index + 1; offsetIndex < contacts.length; offsetIndex += 1) {
5290
+ const right = contacts[offsetIndex];
5291
+ if (right === void 0 || allowedIds !== null && !allowedIds.has(right.id)) {
5292
+ continue;
5293
+ }
5294
+ const score = fuzzyNameScore(leftName, normalizeName(right.name));
5295
+ if (score <= 0) {
5296
+ continue;
5297
+ }
5298
+ const key = pairKey(left.id, right.id);
5299
+ if (!pairs.has(key)) {
5300
+ pairs.set(key, {
5301
+ leftContactId: left.id,
5302
+ rightContactId: right.id,
5303
+ matchReason: "name similarity",
5304
+ matchScore: score
5305
+ });
5306
+ }
5307
+ }
4690
5308
  }
5309
+ }
5310
+ const dismissedRows = db.prepare("SELECT left_id, right_id FROM dismissed_duplicates").all();
5311
+ const dismissedSet = new Set(
5312
+ dismissedRows.map((r) => pairKey(r.left_id, r.right_id))
5313
+ );
5314
+ const includeDismissed = filters?.includeDismissed === true;
5315
+ for (const candidate of pairs.values()) {
5316
+ if (dismissedSet.has(
5317
+ pairKey(candidate.leftContactId, candidate.rightContactId)
5318
+ )) {
5319
+ candidate.dismissed = true;
5320
+ }
5321
+ }
5322
+ const minScore = filters?.minScore ?? 0;
5323
+ return [...pairs.values()].filter((candidate) => {
5324
+ if (candidate.matchScore < minScore) return false;
5325
+ if (candidate.dismissed === true && !includeDismissed) return false;
5326
+ return true;
5327
+ }).sort((left, right) => {
5328
+ const leftExact = left.matchScore === 1 ? 1 : 0;
5329
+ const rightExact = right.matchScore === 1 ? 1 : 0;
5330
+ return rightExact - leftExact || right.matchScore - left.matchScore;
5331
+ }).slice(offset, offset + limit);
5332
+ }
5333
+
5334
+ // src/tools/create_contact_tool_result.ts
5335
+ function createContactToolResult(db, input) {
5336
+ return withTransaction(db, () => {
5337
+ const receipt = createContact(db, input);
4691
5338
  const result = mutationToolResult("create", receipt, "contact");
5339
+ if (result.ok && receipt.outcome === "no_op") {
5340
+ result.outcome = "no_op";
5341
+ Object.assign(result.data, {
5342
+ matchedIdentities: receipt.matchedIdentities
5343
+ });
5344
+ return result;
5345
+ }
4692
5346
  const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
4693
5347
  const warnings = db.prepare(
4694
5348
  "SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
4695
5349
  ).all(receipt.primary.id).flatMap((row) => {
4696
- const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
4697
- return score === void 0 ? [] : [
5350
+ const score = fold(row.name) === fold(input.name) ? 0.92 : fuzzyNameScore(row.name, input.name);
5351
+ return score <= 0 ? [] : [
4698
5352
  {
4699
5353
  code: "possible_duplicate",
4700
5354
  message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
@@ -4777,6 +5431,13 @@ function manageContactToolHandler(db, rawInput) {
4777
5431
  return contact.result;
4778
5432
  }
4779
5433
  if (input.action === "delete") {
5434
+ if (contact.value.is_owner)
5435
+ return toolFailure(
5436
+ "domain",
5437
+ "conflict",
5438
+ "Cannot delete owner.",
5439
+ "The owner contact cannot be deleted"
5440
+ );
4780
5441
  const deletion = deleteContact(db, contact.value.id, { confirm: true });
4781
5442
  return toolSuccess("Permanently deleted contact.", {
4782
5443
  action: "delete",
@@ -4850,7 +5511,11 @@ var manageContactTool = defineAffinityTool({
4850
5511
  },
4851
5512
  ["type", "value"]
4852
5513
  ),
4853
- "Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
5514
+ "Optional identities to attach. An identity owned by another live contact causes a conflict, or returns that contact when ifExists=return."
5515
+ ),
5516
+ ifExists: enumSchema(
5517
+ "Identity collision policy; return attaches nothing.",
5518
+ ["error", "return"]
4854
5519
  ),
4855
5520
  name: stringSchema("Contact name."),
4856
5521
  kind: enumSchema("Contact kind.", [
@@ -5460,6 +6125,268 @@ var manageDuplicateDismissalTool = defineAffinityTool({
5460
6125
  handler: manageDuplicateDismissalToolHandler
5461
6126
  });
5462
6127
 
6128
+ // src/events/event_replay.ts
6129
+ function replayEventLinks(db, linkIds, now) {
6130
+ const eventIds = /* @__PURE__ */ new Set();
6131
+ for (const linkId of linkIds) {
6132
+ const saved = db.prepare("SELECT state_json FROM event_link_baselines WHERE link_id=?").get(linkId);
6133
+ if (!saved) continue;
6134
+ const baseline = JSON.parse(saved.state_json);
6135
+ const effects = db.prepare(
6136
+ "SELECT event_id, participant_json, provenance_json, created_at FROM link_event_effects WHERE link_id=? ORDER BY event_id"
6137
+ ).all(linkId);
6138
+ const overrides = db.prepare(
6139
+ "SELECT after_event_id,state FROM event_link_state_changes WHERE link_id=? ORDER BY id"
6140
+ ).all(linkId);
6141
+ let overrideIndex = 0;
6142
+ const applyOverrides = (beforeId) => {
6143
+ while (overrideIndex < overrides.length && (overrides[overrideIndex]?.after_event_id ?? Number.POSITIVE_INFINITY) < beforeId) {
6144
+ const override = overrides[overrideIndex];
6145
+ if (override)
6146
+ db.prepare("UPDATE links SET state=? WHERE id=?").run(
6147
+ override.state,
6148
+ linkId
6149
+ );
6150
+ overrideIndex++;
6151
+ }
6152
+ };
6153
+ db.prepare("DELETE FROM link_event_effects WHERE link_id=?").run(linkId);
6154
+ db.prepare(
6155
+ "UPDATE links SET rank=?,affinity=?,trust=?,state=?,cadence_days=?,updated_at=? WHERE id=?"
6156
+ ).run(
6157
+ baseline.rank,
6158
+ baseline.affinity,
6159
+ baseline.trust,
6160
+ baseline.state,
6161
+ baseline.cadence_days,
6162
+ now,
6163
+ linkId
6164
+ );
6165
+ for (const effect of effects) {
6166
+ eventIds.add(effect.event_id);
6167
+ applyOverrides(effect.event_id);
6168
+ if (!db.prepare("SELECT id FROM events WHERE id=? AND deleted_at IS NULL").get(effect.event_id))
6169
+ continue;
6170
+ const event = loadEventRecord(db, effect.event_id);
6171
+ const link = db.prepare("SELECT from_contact_id,to_contact_id FROM links WHERE id=?").get(linkId);
6172
+ const participant = effect.participant_json ? JSON.parse(effect.participant_json) : event.participants.find((p) => p.contactId === link.to_contact_id) ?? event.participants[0];
6173
+ const provenance = db.prepare("SELECT provenance_json FROM events WHERE id=?").get(event.id);
6174
+ applyEventEffectToLink(db, {
6175
+ linkId,
6176
+ eventId: event.id,
6177
+ eventType: event.type,
6178
+ occurredAt: event.occurredAt,
6179
+ significance: event.significance,
6180
+ participant,
6181
+ provenance: provenance.provenance_json ? JSON.parse(provenance.provenance_json) : effect.provenance_json ? JSON.parse(effect.provenance_json) : void 0,
6182
+ now: effect.created_at
6183
+ });
6184
+ }
6185
+ applyOverrides(Number.POSITIVE_INFINITY);
6186
+ refreshLinkRollup(db, linkId, now);
6187
+ }
6188
+ for (const eventId of eventIds)
6189
+ db.prepare(
6190
+ "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=?"
6191
+ ).run(eventId);
6192
+ refreshAllBridgeScores(db, now);
6193
+ }
6194
+
6195
+ // src/events/manage_event.ts
6196
+ function requireEventTarget(db, target) {
6197
+ const selector = typeof target === "number" ? { eventId: target } : target;
6198
+ if (!selector || selector.eventId === void 0 === (selector.ref === void 0))
6199
+ throw new AffinityValidationError("Provide exactly one of eventId or ref");
6200
+ const event = selector.ref ? getEventByExternalRef(db, selector.ref.source, selector.ref.id) : getEventById(db, selector.eventId);
6201
+ if (!event) throw new AffinityNotFoundError("event not found");
6202
+ return event;
6203
+ }
6204
+ function affected(db, id) {
6205
+ return db.prepare("SELECT link_id FROM link_event_effects WHERE event_id=?").all(id).map((r) => r.link_id);
6206
+ }
6207
+ function reviseEvent(db, target, patch) {
6208
+ return withTransaction(db, () => {
6209
+ const allowed = [
6210
+ "summary",
6211
+ "content",
6212
+ "occurredAt",
6213
+ "significance",
6214
+ "channel",
6215
+ "type",
6216
+ "externalRef"
6217
+ ];
6218
+ for (const key of Object.keys(patch))
6219
+ if (!allowed.includes(key))
6220
+ throw new AffinityValidationError(
6221
+ `unknown patch key ${key}; allowed keys: ${allowed.join(", ")}`
6222
+ );
6223
+ const event = requireEventTarget(db, target);
6224
+ const next = { ...event, ...patch };
6225
+ const { momentKind: _momentKind, ...metadata } = next;
6226
+ validateEventMetadata(metadata);
6227
+ Object.assign(next, metadata);
6228
+ if (patch.summary !== void 0 && (typeof patch.summary !== "string" || !patch.summary.trim()))
6229
+ throw new AffinityValidationError("summary must be non-empty");
6230
+ if (patch.occurredAt !== void 0)
6231
+ assertFiniteTimestamp(patch.occurredAt, "occurredAt");
6232
+ if (!Number.isInteger(next.significance) || next.significance < 1 || next.significance > 10)
6233
+ throw new AffinityValidationError(
6234
+ "significance must be an integer from 1 to 10"
6235
+ );
6236
+ const interactions = [
6237
+ "conversation",
6238
+ "activity",
6239
+ "gift",
6240
+ "support",
6241
+ "conflict",
6242
+ "correction"
6243
+ ];
6244
+ if (next.type !== event.type && !(interactions.includes(event.type) && interactions.includes(next.type)) && !(["promise", "agreement"].includes(event.type) && ["promise", "agreement"].includes(next.type)))
6245
+ throw new AffinityValidationError(
6246
+ "type must stay within the same event kind"
6247
+ );
6248
+ if (patch.externalRef && event.externalRef)
6249
+ throw new AffinityConflictError(
6250
+ "externalRef can only be set when absent"
6251
+ );
6252
+ if (patch.externalRef && next.externalRef && getEventByExternalRef(db, next.externalRef.source, next.externalRef.id))
6253
+ throw new AffinityConflictError(
6254
+ "externalRef already belongs to an event"
6255
+ );
6256
+ const now = Date.now();
6257
+ const links = affected(db, event.id);
6258
+ db.prepare(
6259
+ "UPDATE events SET summary=?,content=?,occurred_at=?,occurred_on=?,significance=?,channel=?,type=?,external_source=?,external_id=?,updated_at=? WHERE id=?"
6260
+ ).run(
6261
+ next.summary.trim(),
6262
+ next.content ?? null,
6263
+ next.occurredAt,
6264
+ patch.occurredAt === void 0 ? event.occurredOn ?? null : new Date(next.occurredAt).toISOString().slice(0, 10),
6265
+ next.significance,
6266
+ next.channel ?? null,
6267
+ next.type,
6268
+ next.externalRef?.source ?? null,
6269
+ next.externalRef?.id ?? null,
6270
+ now,
6271
+ event.id
6272
+ );
6273
+ if (patch.type && ["promise", "agreement"].includes(patch.type))
6274
+ db.prepare(
6275
+ "UPDATE open_commitments SET commitment_type=? WHERE event_id=?"
6276
+ ).run(patch.type, event.id);
6277
+ if (patch.significance !== void 0 || patch.type !== void 0 || patch.occurredAt !== void 0)
6278
+ replayEventLinks(db, links, now);
6279
+ return buildEventMutationReceipt(requireEventTarget(db, event.id), {
6280
+ updated: [{ kind: "event", id: event.id }],
6281
+ affectedLinks: links
6282
+ });
6283
+ });
6284
+ }
6285
+ function deleteEvent(db, target, options) {
6286
+ return withTransaction(db, () => {
6287
+ if (options?.confirm !== true)
6288
+ throw new AffinityValidationError("deleteEvent requires confirm: true");
6289
+ const event = requireEventTarget(db, target);
6290
+ const links = affected(db, event.id);
6291
+ for (const table of [
6292
+ "event_participants",
6293
+ "attributes",
6294
+ "open_commitments",
6295
+ "upcoming_occurrences",
6296
+ "link_event_effects"
6297
+ ])
6298
+ db.prepare(`DELETE FROM ${table} WHERE event_id=?`).run(event.id);
6299
+ db.prepare(
6300
+ "UPDATE events SET parent_event_id=NULL WHERE parent_event_id=?"
6301
+ ).run(event.id);
6302
+ db.prepare("DELETE FROM events WHERE id=?").run(event.id);
6303
+ replayEventLinks(db, links, Date.now());
6304
+ return buildEventMutationReceipt(event, {
6305
+ removed: [{ kind: "event", id: event.id }],
6306
+ affectedLinks: links
6307
+ });
6308
+ });
6309
+ }
6310
+
6311
+ // src/tools/manage_event_tool.ts
6312
+ function manageEventToolHandler(db, raw) {
6313
+ const input = structuredClone(raw);
6314
+ const error = validateToolInput(
6315
+ manageEventTool.inputSchema,
6316
+ input,
6317
+ "input",
6318
+ Date.now(),
6319
+ true
6320
+ );
6321
+ if (error)
6322
+ return toolFailure("protocol", "invalid_input", "Invalid input.", error);
6323
+ return withToolHandling(() => {
6324
+ const target = {
6325
+ ...input.eventId === void 0 ? {} : { eventId: input.eventId },
6326
+ ...input.ref === void 0 ? {} : { ref: input.ref }
6327
+ };
6328
+ return mutationToolResult(
6329
+ input.action,
6330
+ input.action === "revise" ? reviseEvent(db, target, input.patch ?? {}) : deleteEvent(db, target, { confirm: input.confirm === true }),
6331
+ "event"
6332
+ );
6333
+ }, "Event management failed.");
6334
+ }
6335
+ var manageEventTool = defineAffinityTool({
6336
+ name: manageEventToolName,
6337
+ description: "Revise or delete one event by eventId or external reference. Delete requires confirm=true and recomputes affected relationships.",
6338
+ whenToUse: "Correct imported event text or metadata, or remove an event.",
6339
+ whenNotToUse: "Use record_event to capture new evidence.",
6340
+ sideEffects: "writes_state",
6341
+ readOnly: false,
6342
+ supportsClarification: false,
6343
+ targetKinds: ["event"],
6344
+ inputDescriptions: {
6345
+ action: "revise or delete",
6346
+ eventId: "Exact event id",
6347
+ ref: "External event reference",
6348
+ patch: "Fields to revise",
6349
+ confirm: "Required true for deletion"
6350
+ },
6351
+ outputDescription: "Event mutation receipt and affected links.",
6352
+ inputSchema: objectSchema(
6353
+ {
6354
+ action: enumSchema("Action.", ["revise", "delete"]),
6355
+ eventId: integerSchema("Event id; provide eventId or ref."),
6356
+ ref: externalRefSchema,
6357
+ confirm: booleanSchema("Must be true to delete."),
6358
+ patch: objectSchema(
6359
+ {
6360
+ summary: stringSchema("Short non-empty label."),
6361
+ content: stringSchema("Verbatim content, at most 1 MB UTF-8."),
6362
+ occurredAt: integerSchema("Event timestamp."),
6363
+ significance: integerSchema("Significance 1..10."),
6364
+ channel: stringSchema("Channel, at most 40 characters."),
6365
+ type: enumSchema("Type within the same event kind.", [
6366
+ "conversation",
6367
+ "activity",
6368
+ "gift",
6369
+ "support",
6370
+ "conflict",
6371
+ "correction",
6372
+ "observation",
6373
+ "milestone",
6374
+ "transaction",
6375
+ "promise",
6376
+ "agreement",
6377
+ "date_anchor"
6378
+ ]),
6379
+ externalRef: externalRefSchema
6380
+ },
6381
+ [],
6382
+ "Fields to revise; externalRef may only be added when absent."
6383
+ )
6384
+ },
6385
+ ["action"]
6386
+ ),
6387
+ handler: manageEventToolHandler
6388
+ });
6389
+
5463
6390
  // src/identities/remove_identity.ts
5464
6391
  function removeIdentity(db, identityId, removedAt) {
5465
6392
  return withTransaction(db, () => {
@@ -5507,7 +6434,10 @@ function reviseIdentity(db, identityId, patch) {
5507
6434
  });
5508
6435
  }
5509
6436
  const nextType = patch.type !== void 0 ? patch.type.trim() : row.type;
5510
- const nextValue = patch.value !== void 0 ? patch.value.trim() : row.value;
6437
+ const nextValue = normalizeIdentityValue(
6438
+ nextType,
6439
+ patch.value ?? row.value
6440
+ );
5511
6441
  if (nextType.length === 0 || nextValue.length === 0) {
5512
6442
  throw new AffinityValidationError("type and value must be non-empty");
5513
6443
  }
@@ -5566,6 +6496,10 @@ function contactLocatorSchema6(description) {
5566
6496
  contactId: integerSchema("Exact contact id."),
5567
6497
  identity: objectSchema(
5568
6498
  {
6499
+ ifExists: enumSchema(
6500
+ "Return an existing identity on this contact without changes.",
6501
+ ["error", "return"]
6502
+ ),
5569
6503
  type: stringSchema(
5570
6504
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
5571
6505
  ),
@@ -5662,6 +6596,10 @@ var manageIdentityTool = defineAffinityTool({
5662
6596
  ),
5663
6597
  input: objectSchema(
5664
6598
  {
6599
+ ifExists: enumSchema(
6600
+ "Return an existing identity on this contact without changes.",
6601
+ ["error", "return"]
6602
+ ),
5665
6603
  type: stringSchema(
5666
6604
  `Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
5667
6605
  ),
@@ -5723,6 +6661,9 @@ function overrideLinkState(db, linkId, state, options) {
5723
6661
  now,
5724
6662
  linkId
5725
6663
  );
6664
+ db.prepare(
6665
+ "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=?"
6666
+ ).run(linkId, state, linkId);
5726
6667
  refreshLinkRollup(db, linkId, now);
5727
6668
  if (state === "archived" || row.state === "archived") {
5728
6669
  refreshAllBridgeScores(db, now);
@@ -6357,6 +7298,8 @@ var ALLOWED = /* @__PURE__ */ new Set([
6357
7298
  function recordInteraction(db, input) {
6358
7299
  return withTransaction(db, () => {
6359
7300
  validateSocialEventInput(input);
7301
+ const existing = existingEventReceipt(db, input.externalRef);
7302
+ if (existing) return existing;
6360
7303
  if (!ALLOWED.has(input.type)) {
6361
7304
  throw new AffinityValidationError("invalid interaction event type");
6362
7305
  }
@@ -6366,11 +7309,12 @@ function recordInteraction(db, input) {
6366
7309
  assertOwnerParticipates(ownerId, ids);
6367
7310
  const now = resolveNow(input.now);
6368
7311
  const eventId = insertJournalEvent(db, {
7312
+ ...input,
6369
7313
  type: input.type,
6370
7314
  occurredAt: input.occurredAt,
6371
- summary: input.summary,
7315
+ occurredOn: input.occurredOn,
6372
7316
  significance: input.significance,
6373
- momentKind: null,
7317
+ momentKind: input.momentKind ?? null,
6374
7318
  participants: input.participants,
6375
7319
  now
6376
7320
  });
@@ -6378,12 +7322,24 @@ function recordInteraction(db, input) {
6378
7322
  eventId,
6379
7323
  eventType: input.type,
6380
7324
  occurredAt: input.occurredAt,
7325
+ occurredOn: input.occurredOn,
6381
7326
  significance: input.significance,
6382
7327
  participants: input.participants,
6383
7328
  provenance: input.provenance,
6384
7329
  now
6385
7330
  });
7331
+ if (input.momentKind !== void 0)
7332
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7333
+ input.momentKind,
7334
+ eventId
7335
+ );
6386
7336
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7337
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7338
+ {
7339
+ code: "future_event",
7340
+ message: "occurredAt is in the future; it will not count as contact until then"
7341
+ }
7342
+ ] : void 0,
6387
7343
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6388
7344
  affectedLinks: mechanics.affectedLinks,
6389
7345
  derivedEffects: mechanics.derivedEffects
@@ -6395,17 +7351,20 @@ function recordInteraction(db, input) {
6395
7351
  function recordMilestone(db, input) {
6396
7352
  return withTransaction(db, () => {
6397
7353
  validateSocialEventInput(input);
7354
+ const existing = existingEventReceipt(db, input.externalRef);
7355
+ if (existing) return existing;
6398
7356
  const ownerId = requireOwnerContactId(db);
6399
7357
  const ids = input.participants.map((p) => p.contactId);
6400
7358
  assertParticipantContactsLive(db, ids);
6401
7359
  assertOwnerParticipates(ownerId, ids);
6402
7360
  const now = resolveNow(input.now);
6403
7361
  const eventId = insertJournalEvent(db, {
7362
+ ...input,
6404
7363
  type: "milestone",
6405
7364
  occurredAt: input.occurredAt,
6406
- summary: input.summary,
7365
+ occurredOn: input.occurredOn,
6407
7366
  significance: input.significance,
6408
- momentKind: null,
7367
+ momentKind: input.momentKind ?? null,
6409
7368
  participants: input.participants,
6410
7369
  now
6411
7370
  });
@@ -6413,12 +7372,24 @@ function recordMilestone(db, input) {
6413
7372
  eventId,
6414
7373
  eventType: "milestone",
6415
7374
  occurredAt: input.occurredAt,
7375
+ occurredOn: input.occurredOn,
6416
7376
  significance: input.significance,
6417
7377
  participants: input.participants,
6418
7378
  provenance: input.provenance,
6419
7379
  now
6420
7380
  });
7381
+ if (input.momentKind !== void 0)
7382
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7383
+ input.momentKind,
7384
+ eventId
7385
+ );
6421
7386
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7387
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7388
+ {
7389
+ code: "future_event",
7390
+ message: "occurredAt is in the future; it will not count as contact until then"
7391
+ }
7392
+ ] : void 0,
6422
7393
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6423
7394
  affectedLinks: mechanics.affectedLinks,
6424
7395
  derivedEffects: mechanics.derivedEffects
@@ -6430,15 +7401,18 @@ function recordMilestone(db, input) {
6430
7401
  function recordObservation(db, input) {
6431
7402
  return withTransaction(db, () => {
6432
7403
  validateSocialEventInput(input);
7404
+ const existing = existingEventReceipt(db, input.externalRef);
7405
+ if (existing) return existing;
6433
7406
  const ids = input.participants.map((p) => p.contactId);
6434
7407
  assertParticipantContactsLive(db, ids);
6435
7408
  const now = resolveNow(input.now);
6436
7409
  const eventId = insertJournalEvent(db, {
7410
+ ...input,
6437
7411
  type: "observation",
6438
7412
  occurredAt: input.occurredAt,
6439
- summary: input.summary,
7413
+ occurredOn: input.occurredOn,
6440
7414
  significance: input.significance,
6441
- momentKind: null,
7415
+ momentKind: input.momentKind ?? null,
6442
7416
  participants: input.participants,
6443
7417
  now
6444
7418
  });
@@ -6446,12 +7420,24 @@ function recordObservation(db, input) {
6446
7420
  eventId,
6447
7421
  eventType: "observation",
6448
7422
  occurredAt: input.occurredAt,
7423
+ occurredOn: input.occurredOn,
6449
7424
  significance: input.significance,
6450
7425
  participants: input.participants,
6451
7426
  provenance: input.provenance,
6452
7427
  now
6453
7428
  });
7429
+ if (input.momentKind !== void 0)
7430
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7431
+ input.momentKind,
7432
+ eventId
7433
+ );
6454
7434
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7435
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7436
+ {
7437
+ code: "future_event",
7438
+ message: "occurredAt is in the future; it will not count as contact until then"
7439
+ }
7440
+ ] : void 0,
6455
7441
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6456
7442
  affectedLinks: mechanics.affectedLinks,
6457
7443
  derivedEffects: mechanics.derivedEffects
@@ -6463,17 +7449,20 @@ function recordObservation(db, input) {
6463
7449
  function recordTransaction(db, input) {
6464
7450
  return withTransaction(db, () => {
6465
7451
  validateSocialEventInput(input);
7452
+ const existing = existingEventReceipt(db, input.externalRef);
7453
+ if (existing) return existing;
6466
7454
  const ownerId = requireOwnerContactId(db);
6467
7455
  const ids = input.participants.map((p) => p.contactId);
6468
7456
  assertParticipantContactsLive(db, ids);
6469
7457
  assertOwnerParticipates(ownerId, ids);
6470
7458
  const now = resolveNow(input.now);
6471
7459
  const eventId = insertJournalEvent(db, {
7460
+ ...input,
6472
7461
  type: "transaction",
6473
7462
  occurredAt: input.occurredAt,
6474
- summary: input.summary,
7463
+ occurredOn: input.occurredOn,
6475
7464
  significance: input.significance,
6476
- momentKind: null,
7465
+ momentKind: input.momentKind ?? null,
6477
7466
  participants: input.participants,
6478
7467
  now
6479
7468
  });
@@ -6481,12 +7470,24 @@ function recordTransaction(db, input) {
6481
7470
  eventId,
6482
7471
  eventType: "transaction",
6483
7472
  occurredAt: input.occurredAt,
7473
+ occurredOn: input.occurredOn,
6484
7474
  significance: input.significance,
6485
7475
  participants: input.participants,
6486
7476
  provenance: input.provenance,
6487
7477
  now
6488
7478
  });
7479
+ if (input.momentKind !== void 0)
7480
+ db.prepare("UPDATE events SET moment_kind=? WHERE id=?").run(
7481
+ input.momentKind,
7482
+ eventId
7483
+ );
6489
7484
  return buildEventMutationReceipt(loadEventRecord(db, eventId), {
7485
+ warnings: input.occurredAt > now && (input.occurredOn ?? new Date(input.occurredAt).toISOString().slice(0, 10)) > localCalendarDate(now) ? [
7486
+ {
7487
+ code: "future_event",
7488
+ message: "occurredAt is in the future; it will not count as contact until then"
7489
+ }
7490
+ ] : void 0,
6490
7491
  created: [{ kind: "event", id: eventId }, ...mechanics.created],
6491
7492
  affectedLinks: mechanics.affectedLinks,
6492
7493
  derivedEffects: mechanics.derivedEffects
@@ -6495,11 +7496,13 @@ function recordTransaction(db, input) {
6495
7496
  }
6496
7497
 
6497
7498
  // src/tools/record_event_tool.ts
6498
- function normalizeParticipant2(p) {
7499
+ function normalizeParticipant2(db, p) {
6499
7500
  let contactId = p.contactId;
6500
7501
  if (contactId === void 0 && p.contact != null) {
6501
7502
  const loc = p.contact;
6502
- contactId = loc.contactId;
7503
+ const resolved = resolveContactLocator(db, loc);
7504
+ if (!resolved.ok) throw resolved.result;
7505
+ contactId = resolved.value.id;
6503
7506
  }
6504
7507
  const result = {
6505
7508
  contactId,
@@ -6517,12 +7520,18 @@ function ensureOwnerParticipates2(db, participants) {
6517
7520
  return [{ contactId: ownerId, role: "actor" }, ...participants];
6518
7521
  }
6519
7522
  function prepareParticipants2(db, raw, injectOwner) {
6520
- const normalized = raw.map(normalizeParticipant2);
7523
+ const normalized = raw.map((p) => normalizeParticipant2(db, p));
6521
7524
  return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
6522
7525
  }
6523
7526
  function recordEventToolHandler(db, rawInput) {
6524
7527
  const input = structuredClone(rawInput);
6525
- const inputError = validateToolInput(recordEventTool.inputSchema, input);
7528
+ const inputError = validateToolInput(
7529
+ recordEventTool.inputSchema,
7530
+ input,
7531
+ "input",
7532
+ Date.now(),
7533
+ true
7534
+ );
6526
7535
  if (inputError)
6527
7536
  return toolFailure(
6528
7537
  "protocol",
@@ -6602,6 +7611,7 @@ var recordEventTool = defineAffinityTool({
6602
7611
  "correction"
6603
7612
  ]
6604
7613
  ),
7614
+ ...eventMetadataSchema,
6605
7615
  occurredAt: integerSchema(
6606
7616
  "When the event occurred, Unix timestamp in milliseconds."
6607
7617
  ),
@@ -6610,6 +7620,19 @@ var recordEventTool = defineAffinityTool({
6610
7620
  participants: arraySchema(
6611
7621
  objectSchema(
6612
7622
  {
7623
+ contact: objectSchema(
7624
+ {
7625
+ contactId: integerSchema("Contact id."),
7626
+ identity: objectSchema(
7627
+ {
7628
+ type: stringSchema("Identity type."),
7629
+ value: stringSchema("Identity value.")
7630
+ },
7631
+ ["type", "value"]
7632
+ )
7633
+ },
7634
+ []
7635
+ ),
6613
7636
  contactId: integerSchema(
6614
7637
  "Participant contact id. The owner contact MUST be included."
6615
7638
  ),
@@ -6633,7 +7656,7 @@ var recordEventTool = defineAffinityTool({
6633
7656
  ),
6634
7657
  now: integerSchema("Optional timestamp override.")
6635
7658
  },
6636
- ["occurredAt", "summary", "significance", "participants"],
7659
+ ["occurredAt", "significance", "participants"],
6637
7660
  "Event payload. Always required. Interaction events also require `type`."
6638
7661
  )
6639
7662
  },
@@ -6649,7 +7672,10 @@ function contactAttributeClauses(filters) {
6649
7672
  const params = [];
6650
7673
  const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
6651
7674
  const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
6652
- for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
7675
+ for (const filter of [
7676
+ ...attributes,
7677
+ ...tags.map((tag) => `tag:${tag.replace(/^tag:/i, "")}`)
7678
+ ]) {
6653
7679
  const separator = filter.indexOf("=");
6654
7680
  const name = separator < 0 ? filter : filter.slice(0, separator);
6655
7681
  clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
@@ -6693,143 +7719,22 @@ function listContacts(db, filters, options) {
6693
7719
  (
6694
7720
  SELECT value
6695
7721
  FROM identities i
6696
- WHERE i.contact_id = contacts.id AND i.removed_at IS NULL
6697
- ORDER BY i.id
6698
- LIMIT 1
6699
- ) AS primary_identity
6700
- FROM contacts
6701
- WHERE ${where}
6702
- ORDER BY name ASC
6703
- LIMIT ? OFFSET ?`
6704
- ).all(...params, limit, offset);
6705
- return rows.map((r) => {
6706
- const item = mapContactRowToContactListItem(r);
6707
- return {
6708
- ...item,
6709
- primaryIdentity: r.primary_identity ?? null
6710
- };
6711
- });
6712
- }
6713
-
6714
- // src/contacts/list_duplicate_candidates.ts
6715
- function pairKey(leftContactId, rightContactId) {
6716
- const left = Math.min(leftContactId, rightContactId);
6717
- const right = Math.max(leftContactId, rightContactId);
6718
- return `${left}:${right}`;
6719
- }
6720
- function normalizeName(name) {
6721
- return name.trim().toLowerCase().replace(/\s+/g, " ");
6722
- }
6723
- function fuzzyNameScore(left, right) {
6724
- if (left === right) {
6725
- return 0.92;
6726
- }
6727
- if (left.length >= 3 && right.length >= 3) {
6728
- if (left.includes(right) || right.includes(left)) {
6729
- return 0.72;
6730
- }
6731
- const leftTokens = new Set(left.split(" "));
6732
- const rightTokens = new Set(right.split(" "));
6733
- const overlap = [...leftTokens].filter(
6734
- (token) => rightTokens.has(token)
6735
- ).length;
6736
- if (overlap > 0) {
6737
- return overlap / Math.max(leftTokens.size, rightTokens.size) * 0.7;
6738
- }
6739
- }
6740
- return 0;
6741
- }
6742
- function listDuplicateCandidates(db, filters, options) {
6743
- assertDefaultOrdering("listDuplicateCandidates", options);
6744
- const { limit, offset } = resolveListLimitOffset(options);
6745
- const subset = filters?.contactIds;
6746
- const contacts = db.prepare(
6747
- `SELECT id, name
6748
- FROM contacts
6749
- WHERE deleted_at IS NULL
6750
- AND lifecycle_state != 'merged'`
6751
- ).all();
6752
- const allowedIds = subset === void 0 ? null : new Set(subset.map((contactId) => Number(contactId)));
6753
- const pairs = /* @__PURE__ */ new Map();
6754
- const exactMatches = db.prepare(
6755
- `SELECT
6756
- i1.contact_id AS left_contact_id,
6757
- i2.contact_id AS right_contact_id,
6758
- i1.normalized_key
6759
- FROM identities i1
6760
- INNER JOIN identities i2
6761
- ON i1.normalized_key = i2.normalized_key
6762
- AND i1.contact_id < i2.contact_id
6763
- INNER JOIN contacts c1 ON c1.id = i1.contact_id
6764
- INNER JOIN contacts c2 ON c2.id = i2.contact_id
6765
- WHERE i1.removed_at IS NULL
6766
- AND i2.removed_at IS NULL
6767
- AND c1.deleted_at IS NULL
6768
- AND c2.deleted_at IS NULL
6769
- AND c1.lifecycle_state != 'merged'
6770
- AND c2.lifecycle_state != 'merged'`
6771
- ).all();
6772
- for (const match of exactMatches) {
6773
- if (allowedIds !== null && (!allowedIds.has(match.left_contact_id) || !allowedIds.has(match.right_contact_id))) {
6774
- continue;
6775
- }
6776
- pairs.set(pairKey(match.left_contact_id, match.right_contact_id), {
6777
- leftContactId: match.left_contact_id,
6778
- rightContactId: match.right_contact_id,
6779
- matchReason: `exact identity ${match.normalized_key}`,
6780
- matchScore: 1
6781
- });
6782
- }
6783
- if (filters?.exactOnly !== true) {
6784
- for (let index = 0; index < contacts.length; index += 1) {
6785
- const left = contacts[index];
6786
- if (left === void 0 || allowedIds !== null && !allowedIds.has(left.id)) {
6787
- continue;
6788
- }
6789
- const leftName = normalizeName(left.name);
6790
- for (let offsetIndex = index + 1; offsetIndex < contacts.length; offsetIndex += 1) {
6791
- const right = contacts[offsetIndex];
6792
- if (right === void 0 || allowedIds !== null && !allowedIds.has(right.id)) {
6793
- continue;
6794
- }
6795
- const score = fuzzyNameScore(leftName, normalizeName(right.name));
6796
- if (score <= 0) {
6797
- continue;
6798
- }
6799
- const key = pairKey(left.id, right.id);
6800
- if (!pairs.has(key)) {
6801
- pairs.set(key, {
6802
- leftContactId: left.id,
6803
- rightContactId: right.id,
6804
- matchReason: "name similarity",
6805
- matchScore: score
6806
- });
6807
- }
6808
- }
6809
- }
6810
- }
6811
- const dismissedRows = db.prepare("SELECT left_id, right_id FROM dismissed_duplicates").all();
6812
- const dismissedSet = new Set(
6813
- dismissedRows.map((r) => pairKey(r.left_id, r.right_id))
6814
- );
6815
- const includeDismissed = filters?.includeDismissed === true;
6816
- for (const candidate of pairs.values()) {
6817
- if (dismissedSet.has(
6818
- pairKey(candidate.leftContactId, candidate.rightContactId)
6819
- )) {
6820
- candidate.dismissed = true;
6821
- }
6822
- }
6823
- const minScore = filters?.minScore ?? 0;
6824
- return [...pairs.values()].filter((candidate) => {
6825
- if (candidate.matchScore < minScore) return false;
6826
- if (candidate.dismissed === true && !includeDismissed) return false;
6827
- return true;
6828
- }).sort((left, right) => {
6829
- const leftExact = left.matchScore === 1 ? 1 : 0;
6830
- const rightExact = right.matchScore === 1 ? 1 : 0;
6831
- return rightExact - leftExact || right.matchScore - left.matchScore;
6832
- }).slice(offset, offset + limit);
7722
+ WHERE i.contact_id = contacts.id AND i.removed_at IS NULL
7723
+ ORDER BY i.id
7724
+ LIMIT 1
7725
+ ) AS primary_identity
7726
+ FROM contacts
7727
+ WHERE ${where}
7728
+ ORDER BY name ASC
7729
+ LIMIT ? OFFSET ?`
7730
+ ).all(...params, limit, offset);
7731
+ return rows.map((r) => {
7732
+ const item = mapContactRowToContactListItem(r);
7733
+ return {
7734
+ ...item,
7735
+ primaryIdentity: r.primary_identity ?? null
7736
+ };
7737
+ });
6833
7738
  }
6834
7739
 
6835
7740
  // src/dates/list_upcoming_dates.ts
@@ -6865,12 +7770,12 @@ function listUpcomingDates(db, filters, options) {
6865
7770
  }
6866
7771
  const clauses = ["e.deleted_at IS NULL", "e.type = 'date_anchor'"];
6867
7772
  const params = [];
6868
- const since = options?.since ?? utcStartOfDayMs(Date.now());
7773
+ const since = options?.since ?? Date.parse(`${localCalendarDate(options?.now ?? Date.now())}T00:00:00Z`);
6869
7774
  clauses.push("uo.occurs_on >= ?");
6870
7775
  params.push(since);
6871
7776
  let maxOccurs = options?.until;
6872
7777
  if (filters?.horizonDays !== void 0) {
6873
- const anchor = options?.since ?? Date.now();
7778
+ const anchor = since;
6874
7779
  const horizonEnd = anchor + filters.horizonDays * 864e5;
6875
7780
  maxOccurs = maxOccurs === void 0 ? horizonEnd : Math.min(maxOccurs, horizonEnd);
6876
7781
  }
@@ -6917,6 +7822,70 @@ function listUpcomingDates(db, filters, options) {
6917
7822
  return rows.map((r) => mapUpcomingRowToUpcomingDateRecord(r));
6918
7823
  }
6919
7824
 
7825
+ // src/events/event_discovery.ts
7826
+ function searchEvents(db, query, options) {
7827
+ const { limit, offset } = resolveListLimitOffset(options);
7828
+ const tokens = foldSearchText(query).split(/\s+/).filter(Boolean);
7829
+ if (!tokens.length) return [];
7830
+ const rows = db.prepare(
7831
+ "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"
7832
+ ).all();
7833
+ return rows.flatMap((row) => {
7834
+ const fields = {
7835
+ summary: decodeEventText(row.summary),
7836
+ content: row.content === null ? null : decodeEventText(row.content),
7837
+ source: row.external_source,
7838
+ id: row.external_id,
7839
+ channel: row.channel
7840
+ };
7841
+ const folded = Object.entries(fields).map(
7842
+ ([key, value]) => [key, foldSearchText(value ?? "")]
7843
+ );
7844
+ if (!tokens.every((t) => folded.some(([, v]) => v.includes(t))))
7845
+ return [];
7846
+ return [
7847
+ {
7848
+ kind: "event",
7849
+ id: row.id,
7850
+ type: row.type,
7851
+ title: deriveEventSummary(decodeEventText(row.summary)),
7852
+ occurredOn: row.occurred_on ?? new Date(row.occurred_at).toISOString().slice(0, 10),
7853
+ matchedOn: folded.filter(([, v]) => tokens.some((t) => v.includes(t))).map(([k]) => k),
7854
+ ...row.external_source && row.external_id ? {
7855
+ externalRef: {
7856
+ source: row.external_source,
7857
+ id: row.external_id
7858
+ }
7859
+ } : {}
7860
+ }
7861
+ ];
7862
+ }).slice(offset, offset + limit);
7863
+ }
7864
+ function getEventThread(db, source, id, options) {
7865
+ const { limit, offset } = resolveListLimitOffset(options);
7866
+ const root = getEventByExternalRef(db, source, id);
7867
+ if (!root) return [];
7868
+ const rows = db.prepare(`WITH RECURSIVE thread(id) AS (
7869
+ SELECT ? UNION SELECT e.id FROM events e JOIN thread t JOIN events p ON p.id=t.id
7870
+ 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=?))
7871
+ ) 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=?) AND (? IS NULL OR e.type IN (SELECT value FROM json_each(?))) ORDER BY e.occurred_at,e.id LIMIT ? OFFSET ?`).all(
7872
+ root.id,
7873
+ root.externalRef?.source ?? null,
7874
+ root.externalRef?.id ?? null,
7875
+ options?.channel ?? null,
7876
+ options?.channel?.trim().toLowerCase() ?? null,
7877
+ options?.eventTypes === void 0 ? null : JSON.stringify(options.eventTypes),
7878
+ options?.eventTypes === void 0 ? null : JSON.stringify(options.eventTypes),
7879
+ limit,
7880
+ offset
7881
+ );
7882
+ return batchLoadEventRecords(
7883
+ db,
7884
+ rows.map((r) => r.id),
7885
+ options?.includeContent ?? false
7886
+ );
7887
+ }
7888
+
6920
7889
  // src/graph/get_affinity_chart.ts
6921
7890
  function getAffinityChart(db, options) {
6922
7891
  const contactClauses = [
@@ -7102,21 +8071,6 @@ function listProgressionReadiness(db, filters, options) {
7102
8071
  }
7103
8072
 
7104
8073
  // src/links/list_radar.ts
7105
- function deriveRadarReason(drift, recency, normalizedRank3) {
7106
- if (drift > 0.5) {
7107
- return "Significantly overdue for contact";
7108
- }
7109
- if (drift > 0.2) {
7110
- return "Drifting \u2014 approaching overdue";
7111
- }
7112
- if (recency < 0.3 && normalizedRank3 > 0.3) {
7113
- return "Important link losing recency";
7114
- }
7115
- if (normalizedRank3 > 0.5 && recency < 0.5) {
7116
- return "High-rank link needs attention";
7117
- }
7118
- return "Routine check-in recommended";
7119
- }
7120
8074
  function listRadar(db, filters, options) {
7121
8075
  assertDefaultOrdering("listRadar", options);
7122
8076
  const ownerId = findOwnerContactId(db);
@@ -7124,7 +8078,10 @@ function listRadar(db, filters, options) {
7124
8078
  return [];
7125
8079
  }
7126
8080
  const now = resolveNow(options?.now);
7127
- const overdue = `EXISTS (SELECT 1 FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND oc.due_at < ${now})`;
8081
+ const today = localCalendarDate(now);
8082
+ 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}'))`;
8083
+ const drift = `(${lastContact} IS NOT NULL AND (${now} - ${lastContact}) > 1.5 * COALESCE(l.cadence_days, 1) * 86400000)`;
8084
+ const overdue = `EXISTS (SELECT 1 FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND (CASE WHEN oc.due_on IS NOT NULL AND oc.due_at = CAST(strftime('%s', oc.due_on) AS INTEGER) * 1000 + 86399999 THEN oc.due_on < '${today}' ELSE oc.due_at < ${now} END))`;
7128
8085
  const { limit, offset } = resolveListLimitOffset(options);
7129
8086
  const clauses = [
7130
8087
  "l.removed_at IS NULL",
@@ -7149,10 +8106,23 @@ function listRadar(db, filters, options) {
7149
8106
  if (options?.includeObserved === false) {
7150
8107
  clauses.push("l.kind != 'observed'");
7151
8108
  }
8109
+ if (options?.includeDormant !== true)
8110
+ clauses.push("c_to.lifecycle_state != 'dormant'");
8111
+ if (filters?.includeLost !== true)
8112
+ clauses.push("c_to.lifecycle_state != 'lost'");
8113
+ const other = `CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END`;
8114
+ clauses.push(`(${lastContact} IS NOT NULL OR ${overdue} OR NOT EXISTS (
8115
+ SELECT 1 FROM event_participants ep JOIN events ev ON ev.id=ep.event_id
8116
+ WHERE ep.contact_id=${other} AND ev.deleted_at IS NULL
8117
+ 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'))
8118
+ ))`);
7152
8119
  const where = clauses.join(" AND ");
7153
8120
  const rows = db.prepare(
7154
8121
  `SELECT
7155
8122
  l.id,
8123
+ l.cadence_days,
8124
+ ${lastContact} AS last_contact,
8125
+ ${drift} AS drift,
7156
8126
  ${overdue} AS overdue_commitment,
7157
8127
  l.from_contact_id,
7158
8128
  l.to_contact_id,
@@ -7166,23 +8136,44 @@ function listRadar(db, filters, options) {
7166
8136
  AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
7167
8137
  LEFT JOIN link_rollups lr ON lr.link_id = l.id
7168
8138
  WHERE ${where}
7169
- ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
8139
+ ORDER BY (${overdue} OR ${drift}) DESC, ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
7170
8140
  LIMIT ? OFFSET ?`
7171
8141
  ).all(...params, limit, offset);
7172
- return rows.map((row) => ({
7173
- linkId: row.id,
7174
- contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
7175
- reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
7176
- driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
7177
- recencyScore: row.recency_score ?? 0,
7178
- normalizedRank: row.normalized_rank ?? 0,
7179
- trust: row.trust ?? 0,
7180
- recommendedReason: deriveRadarReason(
7181
- row.drift_priority ?? 0,
7182
- row.recency_score ?? 0,
7183
- row.normalized_rank ?? 0
7184
- )
7185
- }));
8142
+ return rows.map((row) => {
8143
+ const contactId = row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id;
8144
+ const promise = row.overdue_commitment ? db.prepare(
8145
+ `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`
8146
+ ).get(contactId, today, now) : void 0;
8147
+ const reasons = [
8148
+ ...row.overdue_commitment ? ["overdue_commitment"] : [],
8149
+ ...row.drift ? ["drift"] : []
8150
+ ];
8151
+ const snippets = [
8152
+ ...promise ? [
8153
+ `Overdue promise: ${promise.summary} (due ${promise.due_on ?? new Date(promise.due_at).toISOString().slice(0, 10)})`
8154
+ ] : [],
8155
+ ...row.drift ? [
8156
+ `No contact for ${Math.floor((now - (row.last_contact ?? now)) / 864e5)} days (cadence ${row.cadence_days ?? 1} days)`
8157
+ ] : []
8158
+ ];
8159
+ const elapsed = row.last_contact === null ? null : Math.max(0, (now - row.last_contact) / 864e5);
8160
+ const currentDrift = elapsed === null ? 0 : driftPriority({
8161
+ daysSinceLastMeaningfulEvent: elapsed,
8162
+ cadenceDays: row.cadence_days ?? 1,
8163
+ trust: row.trust ?? 0,
8164
+ rank: row.rank ?? 0
8165
+ });
8166
+ return {
8167
+ linkId: row.id,
8168
+ contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
8169
+ reasons,
8170
+ driftPriority: row.overdue_commitment ? Math.max(1, currentDrift) : currentDrift,
8171
+ recencyScore: elapsed === null ? 0 : Math.max(0, 1 - elapsed / Math.max(row.cadence_days ?? 1, 1)),
8172
+ normalizedRank: row.normalized_rank ?? 0,
8173
+ trust: row.trust ?? 0,
8174
+ recommendedReason: snippets.join("; ") || "Routine check-in recommended"
8175
+ };
8176
+ });
7186
8177
  }
7187
8178
 
7188
8179
  // src/merges/get_merge_history.ts
@@ -7252,13 +8243,40 @@ function enrichReviewItem(db, item) {
7252
8243
  result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
7253
8244
  }
7254
8245
  if (Array.isArray(item.participants)) {
7255
- result.counterpartyNames = item.participants.map(
7256
- (p) => name(p.contactId)
8246
+ result.participants = item.participants.map(
8247
+ (p) => ({
8248
+ id: p.contactId,
8249
+ name: name(p.contactId),
8250
+ role: p.role
8251
+ })
7257
8252
  );
7258
- result.subtitle = [
7259
- item.subtitle,
7260
- result.counterpartyNames.join(", ")
7261
- ].filter(Boolean).join(" \xB7 ");
8253
+ for (const key of ["title", "subtitle", "snippet", "state", "eventId"])
8254
+ result[key] = void 0;
8255
+ }
8256
+ if (result.momentKind === null) result.momentKind = void 0;
8257
+ if (result.eventId === result.id) result.eventId = void 0;
8258
+ if (result.linkId === result.id) result.linkId = void 0;
8259
+ if (result.title === result.summary) result.title = void 0;
8260
+ if (item.kind === "link" && item.occursAt !== void 0) {
8261
+ for (const key of [
8262
+ "affinity",
8263
+ "trust",
8264
+ "rank",
8265
+ "recencyScore",
8266
+ "driftPriority",
8267
+ "readinessScore",
8268
+ "normalizedRank",
8269
+ "reciprocityScore",
8270
+ "radarScore",
8271
+ "subtitle",
8272
+ "snippet",
8273
+ "targetRef",
8274
+ "occursOnDate"
8275
+ ])
8276
+ result[key] = void 0;
8277
+ } else if (item.kind === "link") {
8278
+ result.recommendedReason = void 0;
8279
+ result.snippet = void 0;
7262
8280
  }
7263
8281
  if (typeof item.leftContactId === "number")
7264
8282
  result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
@@ -7287,6 +8305,8 @@ function summarizeCount(count, singular) {
7287
8305
  // src/tools/review_affinity_tool.ts
7288
8306
  function buildReadOptions(input) {
7289
8307
  return {
8308
+ ...input.includeContent === void 0 ? {} : { includeContent: input.includeContent },
8309
+ ...input.channel === void 0 ? {} : { channel: input.channel },
7290
8310
  ...input.now === void 0 ? {} : { now: input.now },
7291
8311
  ...input.limit === void 0 ? {} : { limit: input.limit },
7292
8312
  ...input.offset === void 0 ? {} : { offset: input.offset },
@@ -7423,7 +8443,7 @@ function momentItem(moment) {
7423
8443
  id: moment.eventId,
7424
8444
  title: moment.summary,
7425
8445
  subtitle: moment.momentKind,
7426
- snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
8446
+ snippet: moment.occurredOn ?? new Date(moment.occurredAt).toISOString().slice(0, 10)
7427
8447
  };
7428
8448
  }
7429
8449
  function commitmentItem(commitment) {
@@ -7433,7 +8453,7 @@ function commitmentItem(commitment) {
7433
8453
  id: commitment.eventId,
7434
8454
  title: commitment.summary,
7435
8455
  subtitle: commitment.type,
7436
- snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
8456
+ snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : commitment.dueOn ?? new Date(commitment.dueAt).toISOString().slice(0, 10)}`
7437
8457
  };
7438
8458
  }
7439
8459
  function radarItem(radar) {
@@ -7449,6 +8469,7 @@ function radarItem(radar) {
7449
8469
  function upcomingDateItem(date) {
7450
8470
  return {
7451
8471
  ...date,
8472
+ occursOn: new Date(date.occursOn).toISOString().slice(0, 10),
7452
8473
  occursAt: date.occursOn,
7453
8474
  occursAtIso: new Date(date.occursOn).toISOString(),
7454
8475
  kind: date.targetRef.kind,
@@ -7486,6 +8507,9 @@ function compactFilters(input) {
7486
8507
  );
7487
8508
  }
7488
8509
  function reviewAffinityToolHandler(db, rawInput) {
8510
+ return reviewPage(db, rawInput, false);
8511
+ }
8512
+ function reviewPage(db, rawInput, pageOnly) {
7489
8513
  const input = structuredClone(rawInput);
7490
8514
  const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
7491
8515
  if (inputError)
@@ -7500,6 +8524,41 @@ function reviewAffinityToolHandler(db, rawInput) {
7500
8524
  if (invalid) {
7501
8525
  return invalid;
7502
8526
  }
8527
+ if (!pageOnly && input.view !== "graph.chart") {
8528
+ const limit = input.limit ?? 50;
8529
+ const offset = input.offset ?? 0;
8530
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1e4 || !Number.isInteger(offset) || offset < 0)
8531
+ return toolFailure(
8532
+ "protocol",
8533
+ "invalid_input",
8534
+ "Invalid pagination.",
8535
+ "limit must be 1..10000; use offset to page through larger results; offset must be non-negative"
8536
+ );
8537
+ const all = [];
8538
+ let page;
8539
+ do {
8540
+ page = reviewPage(
8541
+ db,
8542
+ { ...input, limit: 250, offset: all.length },
8543
+ true
8544
+ );
8545
+ if (!page.ok) return page;
8546
+ all.push(...page.data.items);
8547
+ } while (page.data.items.length === 250);
8548
+ const items2 = all.slice(offset, offset + limit);
8549
+ return toolSuccess(
8550
+ summarizeCount(items2.length, "item"),
8551
+ {
8552
+ view: input.view,
8553
+ items: items2,
8554
+ count: items2.length,
8555
+ total: all.length,
8556
+ hasMore: offset + items2.length < all.length,
8557
+ appliedFilters: compactFilters(input)
8558
+ },
8559
+ { entities: items2.map(({ content: _content, ...item }) => item) }
8560
+ );
8561
+ }
7503
8562
  const options = buildReadOptions(input);
7504
8563
  let items = [];
7505
8564
  let chart;
@@ -7525,12 +8584,26 @@ function reviewAffinityToolHandler(db, rawInput) {
7525
8584
  {
7526
8585
  ...input.contactIds === void 0 ? {} : { contactIds: input.contactIds },
7527
8586
  ...input.exactOnly === void 0 ? {} : { exactOnly: input.exactOnly },
7528
- ...input.minScore === void 0 ? {} : { minScore: input.minScore },
8587
+ ...input.minScore === void 0 ? { minScore: 0.5 } : { minScore: input.minScore },
7529
8588
  ...input.includeDismissed === void 0 ? {} : { includeDismissed: input.includeDismissed }
7530
8589
  },
7531
8590
  options
7532
8591
  ).map(duplicateCandidateItem);
7533
8592
  break;
8593
+ case "events.thread": {
8594
+ if (!input.ref)
8595
+ return toolFailure(
8596
+ "protocol",
8597
+ "invalid_input",
8598
+ "Missing thread reference.",
8599
+ "ref is required for events.thread."
8600
+ );
8601
+ items = getEventThread(db, input.ref.source, input.ref.id, {
8602
+ ...options,
8603
+ ...input.eventTypes === void 0 ? {} : { eventTypes: input.eventTypes }
8604
+ }).map(toEventListToolItem);
8605
+ break;
8606
+ }
7534
8607
  case "events.contact_journal": {
7535
8608
  const locator = input.contact;
7536
8609
  if (locator === void 0) {
@@ -7672,6 +8745,7 @@ function reviewAffinityToolHandler(db, rawInput) {
7672
8745
  items = listRadar(
7673
8746
  db,
7674
8747
  {
8748
+ ...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
7675
8749
  ...input.kind === void 0 ? {} : { kind: input.kind },
7676
8750
  ...input.linkState === void 0 ? {} : { state: input.linkState }
7677
8751
  },
@@ -7721,6 +8795,14 @@ function reviewAffinityToolHandler(db, rawInput) {
7721
8795
  items = listDismissedDuplicates(db, options).map(dismissedPairItem);
7722
8796
  break;
7723
8797
  }
8798
+ if (input.includeContent && ["events.moments", "commitments.open"].includes(input.view))
8799
+ items = items.map((item) => {
8800
+ const content = getEventById(
8801
+ db,
8802
+ Number(item.eventId ?? item.id)
8803
+ )?.content;
8804
+ return content === void 0 ? item : { ...item, content };
8805
+ });
7724
8806
  items = items.map((item) => enrichReviewItem(db, item));
7725
8807
  return toolSuccess(
7726
8808
  summarizeCount(items.length, "item"),
@@ -7732,7 +8814,7 @@ function reviewAffinityToolHandler(db, rawInput) {
7732
8814
  ...chart === void 0 ? {} : { chart }
7733
8815
  },
7734
8816
  {
7735
- entities: items,
8817
+ entities: items.map(({ content: _content, ...item }) => item),
7736
8818
  warnings: items.length === 0 ? [
7737
8819
  toolWarning(
7738
8820
  "empty_result",
@@ -7778,15 +8860,21 @@ var reviewAffinityTool = defineAffinityTool({
7778
8860
  includeDismissed: "Whether dismissed pairs should be included in duplicate review (marked with dismissed flag).",
7779
8861
  since: "Optional lower timestamp bound for time-based views.",
7780
8862
  until: "Optional upper timestamp bound for time-based views.",
7781
- limit: "Optional result limit.",
8863
+ limit: "Optional result limit, 1..10000 (default 50); use offset for paging.",
7782
8864
  offset: "Optional result offset."
7783
8865
  },
7784
8866
  outputDescription: "Returns compact review items, a count, the applied filters, and the chart payload for graph review.",
7785
8867
  inputSchema: objectSchema(
7786
8868
  {
8869
+ ref: externalRefSchema,
8870
+ includeContent: booleanSchema(
8871
+ "Include verbatim content in event rows; defaults to false."
8872
+ ),
8873
+ channel: stringSchema("Filter events by channel."),
7787
8874
  view: enumSchema("Which review surface to return.", [
7788
8875
  "contacts.list",
7789
8876
  "contacts.duplicates",
8877
+ "events.thread",
7790
8878
  "events.contact_journal",
7791
8879
  "events.link_timeline",
7792
8880
  "events.moments",
@@ -7841,7 +8929,20 @@ var reviewAffinityTool = defineAffinityTool({
7841
8929
  includeArchived: booleanSchema("Whether archived links are included."),
7842
8930
  includeObserved: booleanSchema("Whether observed links are included."),
7843
8931
  eventTypes: arraySchema(
7844
- stringSchema("Event type."),
8932
+ enumSchema("Event type.", [
8933
+ "conversation",
8934
+ "activity",
8935
+ "gift",
8936
+ "support",
8937
+ "milestone",
8938
+ "observation",
8939
+ "conflict",
8940
+ "correction",
8941
+ "transaction",
8942
+ "promise",
8943
+ "agreement",
8944
+ "date_anchor"
8945
+ ]),
7845
8946
  "Optional event-type filter."
7846
8947
  ),
7847
8948
  momentKind: enumSchema("Optional moment kind filter.", [
@@ -7864,13 +8965,17 @@ var reviewAffinityTool = defineAffinityTool({
7864
8965
  ]),
7865
8966
  horizonDays: integerSchema("Optional horizon-days filter."),
7866
8967
  exactOnly: booleanSchema("Whether duplicate review is exact-only."),
7867
- minScore: numberSchema("Optional duplicate minimum score."),
8968
+ minScore: numberSchema(
8969
+ "Optional duplicate minimum score (default 0.5; use 0 for all candidates)."
8970
+ ),
7868
8971
  includeDismissed: booleanSchema(
7869
8972
  "Whether dismissed pairs are included in duplicate review (marked with dismissed flag)."
7870
8973
  ),
7871
8974
  since: integerSchema("Optional lower timestamp bound."),
7872
8975
  until: integerSchema("Optional upper timestamp bound."),
7873
- limit: integerSchema("Optional result limit."),
8976
+ limit: integerSchema(
8977
+ "Optional result limit, 1..10000 (default 50); use offset for paging."
8978
+ ),
7874
8979
  offset: integerSchema("Optional result offset.")
7875
8980
  },
7876
8981
  ["view"],
@@ -7881,7 +8986,7 @@ var reviewAffinityTool = defineAffinityTool({
7881
8986
 
7882
8987
  // src/contacts/search_contacts.ts
7883
8988
  function searchContacts(db, query, filters, options) {
7884
- const q = foldSearchText(query.trim());
8989
+ const q = foldSearchText(query.trim().replace(/^@/, ""));
7885
8990
  if (!q) return [];
7886
8991
  assertDefaultOrdering("searchContacts", options);
7887
8992
  const { limit, offset } = resolveListLimitOffset(options);
@@ -7901,6 +9006,7 @@ function searchContacts(db, query, filters, options) {
7901
9006
  const tokens = q.split(/\s+/);
7902
9007
  const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
7903
9008
  const digits = q.replace(/\D/g, "").replace(/^00/, "");
9009
+ const suffix = digits.replace(/^0/, "");
7904
9010
  const attributeFilters = contactAttributeClauses(filters);
7905
9011
  const clauses = [
7906
9012
  "deleted_at IS NULL",
@@ -7936,7 +9042,7 @@ function searchContacts(db, query, filters, options) {
7936
9042
  )
7937
9043
  ];
7938
9044
  const phoneFields = phone ? values.filter(
7939
- (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
9045
+ (field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits) || field.source === "identity:phone" && suffix.length >= 6 && field.text.replace(/\D/g, "").endsWith(suffix)
7940
9046
  ) : [];
7941
9047
  if (!phoneFields.length && !tokens.every(
7942
9048
  (token) => values.some((field) => field.text.includes(token))
@@ -7966,6 +9072,9 @@ function searchContacts(db, query, filters, options) {
7966
9072
 
7967
9073
  // src/tools/search_affinity_tool.ts
7968
9074
  function searchAffinityToolHandler(db, rawInput) {
9075
+ return searchPage(db, rawInput, false);
9076
+ }
9077
+ function searchPage(db, rawInput, pageOnly) {
7969
9078
  const input = structuredClone(rawInput);
7970
9079
  const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
7971
9080
  if (inputError)
@@ -7976,11 +9085,53 @@ function searchAffinityToolHandler(db, rawInput) {
7976
9085
  inputError
7977
9086
  );
7978
9087
  return withToolHandling(() => {
9088
+ if (!pageOnly) {
9089
+ const limit = input.limit ?? 50;
9090
+ const offset = input.offset ?? 0;
9091
+ if (!Number.isInteger(limit) || limit < 1 || limit > 250 || !Number.isInteger(offset) || offset < 0)
9092
+ return toolFailure(
9093
+ "protocol",
9094
+ "invalid_input",
9095
+ "Invalid pagination.",
9096
+ "limit must be 1..250; offset must be non-negative"
9097
+ );
9098
+ const all = [];
9099
+ const scopes = input.scope === "all" ? ["contacts", "events"] : [input.scope ?? "contacts"];
9100
+ for (const scope of scopes) {
9101
+ let page;
9102
+ let nextOffset = 0;
9103
+ do {
9104
+ page = searchPage(
9105
+ db,
9106
+ { ...input, scope, limit: 250, offset: nextOffset },
9107
+ true
9108
+ );
9109
+ if (!page.ok) return page;
9110
+ all.push(...page.data.items);
9111
+ nextOffset += page.data.count;
9112
+ } while (page.data.count === 250);
9113
+ }
9114
+ const items2 = all.slice(offset, offset + limit);
9115
+ return toolSuccess(
9116
+ summarizeCount(
9117
+ items2.length,
9118
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
9119
+ ),
9120
+ {
9121
+ query: input.query,
9122
+ items: items2,
9123
+ count: items2.length,
9124
+ total: all.length,
9125
+ hasMore: offset + items2.length < all.length
9126
+ },
9127
+ { entities: items2 }
9128
+ );
9129
+ }
7979
9130
  const options = {
7980
9131
  ...input.limit === void 0 ? {} : { limit: input.limit },
7981
9132
  ...input.offset === void 0 ? {} : { offset: input.offset }
7982
9133
  };
7983
- const rows = searchContacts(
9134
+ const rows = input.scope === "events" ? [] : searchContacts(
7984
9135
  db,
7985
9136
  input.query,
7986
9137
  {
@@ -7994,9 +9145,15 @@ function searchAffinityToolHandler(db, rawInput) {
7994
9145
  },
7995
9146
  options
7996
9147
  );
7997
- const items = rows.map(toContactListToolItem);
9148
+ const items = [
9149
+ ...rows.map(toContactListToolItem),
9150
+ ...input.scope === "events" || input.scope === "all" ? searchEvents(db, input.query, options) : []
9151
+ ];
7998
9152
  return toolSuccess(
7999
- summarizeCount(items.length, "contact"),
9153
+ summarizeCount(
9154
+ items.length,
9155
+ input.scope === "events" ? "event" : input.scope === "all" ? "item" : "contact"
9156
+ ),
8000
9157
  {
8001
9158
  query: input.query,
8002
9159
  items,
@@ -8032,6 +9189,11 @@ var searchAffinityTool = defineAffinityTool({
8032
9189
  outputDescription: "Returns compact contact hits, the original query, and the hit count.",
8033
9190
  inputSchema: objectSchema(
8034
9191
  {
9192
+ scope: enumSchema("Search scope; defaults to contacts.", [
9193
+ "contacts",
9194
+ "events",
9195
+ "all"
9196
+ ]),
8035
9197
  query: stringSchema("Free-text or natural-key query."),
8036
9198
  kind: enumSchema("Optional contact kind filter.", [
8037
9199
  "human",
@@ -8083,72 +9245,44 @@ var affinityTools = [
8083
9245
  recordEventTool,
8084
9246
  manageCommitmentTool,
8085
9247
  manageDateAnchorTool,
8086
- manageAttributeTool
9248
+ manageAttributeTool,
9249
+ manageEventTool
8087
9250
  ];
8088
9251
 
8089
9252
  // src/surface/plain_dates.ts
8090
- function parsePlainDue(raw, now = Date.now(), endOfDay = true) {
8091
- const invalid = () => new CliUsageError(
8092
- `Invalid timestamp "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d, -2d or an ISO date-time.`
8093
- );
8094
- if (/^-?\d+$/.test(raw)) {
8095
- const value = Number(raw);
8096
- if (Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()))
8097
- return value;
8098
- throw invalid();
8099
- }
8100
- const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
8101
- const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
8102
- let date;
8103
- if (iso || european) {
8104
- const year = Number(iso?.[1] ?? european?.[3]);
8105
- const month = Number(iso?.[2] ?? european?.[2]);
8106
- const day = Number(iso?.[3] ?? european?.[1]);
8107
- date = /* @__PURE__ */ new Date(0);
8108
- date.setFullYear(year, month - 1, day);
8109
- date.setHours(
8110
- endOfDay ? 23 : 0,
8111
- endOfDay ? 59 : 0,
8112
- endOfDay ? 59 : 0,
8113
- endOfDay ? 999 : 0
8114
- );
8115
- if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
8116
- throw invalid();
8117
- } else if (raw === "today" || raw === "tomorrow" || raw === "yesterday" || /^[+-]\d+d$/.test(raw)) {
8118
- date = new Date(now);
8119
- date.setDate(
8120
- date.getDate() + (raw === "today" ? 0 : raw === "tomorrow" ? 1 : raw === "yesterday" ? -1 : Number(raw.slice(0, -1)))
8121
- );
8122
- date.setHours(
8123
- endOfDay ? 23 : 0,
8124
- endOfDay ? 59 : 0,
8125
- endOfDay ? 59 : 0,
8126
- endOfDay ? 999 : 0
8127
- );
8128
- } else {
8129
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
8130
- raw
8131
- ))
8132
- throw invalid();
8133
- parsePlainDue(raw.slice(0, 10), now);
8134
- date = new Date(raw);
8135
- }
8136
- if (!Number.isFinite(date.getTime())) throw invalid();
8137
- return date.getTime();
8138
- }
8139
- function parsePlainStart(raw, now = Date.now()) {
8140
- return parsePlainDue(raw, now, false);
8141
- }
8142
- function normalizeTimeInputs(value, now = Date.now()) {
9253
+ function normalizeTimeInputs(value, now = Date.now(), inPatch = false) {
8143
9254
  if (Array.isArray(value))
8144
9255
  return value.map((item) => normalizeTimeInputs(item, now));
8145
9256
  if (!value || typeof value !== "object") return value;
8146
- return Object.fromEntries(
8147
- Object.entries(value).map(([key, item]) => [
8148
- key,
8149
- typeof item === "string" && (key.endsWith("At") || ["asOf", "now"].includes(key)) ? key === "dueAt" ? parsePlainDue(item, now) : parsePlainStart(item, now) : normalizeTimeInputs(item, now)
8150
- ])
8151
- );
9257
+ const result = {};
9258
+ for (const [key, item] of Object.entries(value)) {
9259
+ if (key === "occurredAt" && item === null)
9260
+ throw new CliUsageError(
9261
+ "occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"
9262
+ );
9263
+ if (typeof item === "string" && (key.endsWith("At") || ["asOf", "now", "since", "until"].includes(key))) {
9264
+ let parsed;
9265
+ try {
9266
+ parsed = parseCalendarTime(item, key, now);
9267
+ } catch (error) {
9268
+ throw new CliUsageError(
9269
+ `Invalid timestamp "${item}". ${error.message}`
9270
+ );
9271
+ }
9272
+ if (!inPatch && ["occurredAt", "dueAt"].includes(key) && typeof item === "string" && /\.\d{4,}/.test(item)) {
9273
+ const provenance = result.provenance ?? value.provenance;
9274
+ result.provenance = {
9275
+ ...provenance && typeof provenance === "object" ? provenance : {},
9276
+ [`raw${key[0]?.toUpperCase()}${key.slice(1)}`]: item
9277
+ };
9278
+ }
9279
+ result[key] = parsed.timestamp;
9280
+ if (!inPatch && ["occurredAt", "dueAt"].includes(key))
9281
+ result[key.replace(/At$/, "On")] = parsed.day;
9282
+ } else if (key !== "provenance" || result.provenance === void 0)
9283
+ result[key] = key === "provenance" ? item : normalizeTimeInputs(item, now, key === "patch");
9284
+ }
9285
+ return result;
8152
9286
  }
8153
9287
 
8154
9288
  // src/surface/timestamp_schema.ts
@@ -8175,20 +9309,63 @@ function timestampSchema(schema) {
8175
9309
  }
8176
9310
 
8177
9311
  // src/surface/affinity_surface_tools.ts
9312
+ function surfaceProperties(tool) {
9313
+ return {
9314
+ ...tool.inputSchema.properties,
9315
+ ...tool.name === "review_affinity" ? {
9316
+ includeContent: {
9317
+ type: "boolean",
9318
+ description: "Deprecated alias for --with-content; includes verbatim event content."
9319
+ }
9320
+ } : {},
9321
+ ...["inspect_affinity_item", "review_affinity", "manage_event"].includes(
9322
+ tool.name
9323
+ ) ? {
9324
+ refSource: {
9325
+ type: "string",
9326
+ description: "External reference source."
9327
+ },
9328
+ refId: {
9329
+ type: "string",
9330
+ description: "External reference id."
9331
+ }
9332
+ } : {},
9333
+ ...tool.name === "review_affinity" ? {
9334
+ withContent: {
9335
+ type: "boolean",
9336
+ description: "Include verbatim event content."
9337
+ }
9338
+ } : {}
9339
+ };
9340
+ }
8178
9341
  var affinitySurfaceTools = affinityTools.map((tool) => ({
8179
9342
  name: tool.name,
8180
9343
  description: tool.description,
8181
- whenToUse: tool.whenToUse,
8182
- ...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
8183
9344
  readOnly: tool.readOnly,
8184
9345
  sideEffects: tool.sideEffects,
8185
9346
  inputSchema: {
8186
- ...timestampSchema({ ...tool.inputSchema, type: "object" }),
9347
+ ...timestampSchema({
9348
+ ...tool.inputSchema,
9349
+ properties: surfaceProperties(tool),
9350
+ type: "object"
9351
+ }),
8187
9352
  type: "object"
8188
9353
  },
8189
9354
  handler: (db, input) => {
8190
9355
  try {
8191
- return tool.handler(db, normalizeTimeInputs(input));
9356
+ const { withContent, refSource, refId, ...normalized } = input;
9357
+ if (withContent !== void 0) normalized.includeContent = withContent;
9358
+ if (refSource !== void 0 || refId !== void 0) {
9359
+ if (normalized.ref !== void 0)
9360
+ return toolFailure(
9361
+ "protocol",
9362
+ "invalid_input",
9363
+ "Invalid target.",
9364
+ "Provide ref or ref-source/ref-id, not both."
9365
+ );
9366
+ normalized.ref = { source: refSource, id: refId };
9367
+ }
9368
+ return tool.handler(db, normalizeTimeInputs(normalized));
8192
9369
  } catch (error) {
8193
9370
  if (error instanceof CliUsageError)
8194
9371
  return toolFailure(
@@ -8213,7 +9390,7 @@ function affinityMcpOptions(context) {
8213
9390
  context,
8214
9391
  annotations: (tool) => ({
8215
9392
  readOnlyHint: tool.readOnly ?? false,
8216
- destructiveHint: tool.name === "merge_contacts" || /remove|unset|undismiss/.test(
9393
+ destructiveHint: tool.name === "merge_contacts" || tool.name === "manage_event" || /remove|unset|undismiss/.test(
8217
9394
  String(tool.inputSchema.properties?.action?.enum ?? [])
8218
9395
  ),
8219
9396
  idempotentHint: tool.readOnly ?? false,
@@ -8222,13 +9399,95 @@ function affinityMcpOptions(context) {
8222
9399
  };
8223
9400
  }
8224
9401
 
9402
+ // src/events/init_events_tables.ts
9403
+ function initEventsTables(db) {
9404
+ db.exec(`
9405
+ CREATE TABLE IF NOT EXISTS events (
9406
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9407
+ type TEXT NOT NULL,
9408
+ occurred_at INTEGER NOT NULL,
9409
+ summary TEXT NOT NULL,
9410
+ significance REAL NOT NULL DEFAULT 0,
9411
+ moment_kind TEXT,
9412
+ recurrence_kind TEXT,
9413
+ anchor_month INTEGER,
9414
+ anchor_day INTEGER,
9415
+ anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
9416
+ anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
9417
+ created_at INTEGER NOT NULL,
9418
+ updated_at INTEGER NOT NULL,
9419
+ deleted_at INTEGER,
9420
+ CHECK (length(trim(summary)) > 0),
9421
+ CHECK (
9422
+ type IN (
9423
+ 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
9424
+ 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
9425
+ )
9426
+ ),
9427
+ CHECK (
9428
+ (type = 'date_anchor' AND (
9429
+ (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
9430
+ OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
9431
+ ))
9432
+ OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
9433
+ ),
9434
+ CHECK (moment_kind IS NULL OR moment_kind IN (
9435
+ 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
9436
+ )),
9437
+ CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
9438
+ 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
9439
+ )),
9440
+ CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
9441
+ CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
9442
+ )
9443
+ `);
9444
+ db.exec(
9445
+ "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
9446
+ );
9447
+ db.exec(
9448
+ "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
9449
+ );
9450
+ const columns = db.prepare("PRAGMA table_info(events)").all();
9451
+ if (!columns.some((c) => c.name === "declared_moment_kind"))
9452
+ db.exec("ALTER TABLE events ADD COLUMN declared_moment_kind TEXT");
9453
+ if (!columns.some((c) => c.name === "affects_bond"))
9454
+ db.exec("ALTER TABLE events ADD COLUMN affects_bond INTEGER");
9455
+ if (!columns.some((c) => c.name === "content"))
9456
+ db.exec("ALTER TABLE events ADD COLUMN content TEXT");
9457
+ if (!columns.some((c) => c.name === "external_source"))
9458
+ db.exec("ALTER TABLE events ADD COLUMN external_source TEXT");
9459
+ if (!columns.some((c) => c.name === "external_id"))
9460
+ db.exec("ALTER TABLE events ADD COLUMN external_id TEXT");
9461
+ if (!columns.some((c) => c.name === "channel"))
9462
+ db.exec("ALTER TABLE events ADD COLUMN channel TEXT");
9463
+ if (!columns.some((c) => c.name === "parent_source"))
9464
+ db.exec("ALTER TABLE events ADD COLUMN parent_source TEXT");
9465
+ if (!columns.some((c) => c.name === "parent_ref_id"))
9466
+ db.exec("ALTER TABLE events ADD COLUMN parent_ref_id TEXT");
9467
+ if (!columns.some((c) => c.name === "parent_event_id"))
9468
+ db.exec("ALTER TABLE events ADD COLUMN parent_event_id INTEGER");
9469
+ if (!columns.some((c) => c.name === "thread_source"))
9470
+ db.exec("ALTER TABLE events ADD COLUMN thread_source TEXT");
9471
+ if (!columns.some((c) => c.name === "thread_id"))
9472
+ db.exec("ALTER TABLE events ADD COLUMN thread_id TEXT");
9473
+ if (!columns.some((c) => c.name === "provenance_json"))
9474
+ db.exec("ALTER TABLE events ADD COLUMN provenance_json TEXT");
9475
+ db.exec(
9476
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external_ref ON events(external_source, external_id)"
9477
+ );
9478
+ if (!columns.some((column) => column.name === "occurred_on"))
9479
+ db.exec("ALTER TABLE events ADD COLUMN occurred_on TEXT");
9480
+ }
9481
+
8225
9482
  // src/attributes/init_attributes_tables.ts
8226
9483
  function initAttributesTables(db) {
9484
+ initEventsTables(db);
8227
9485
  db.exec(`
8228
9486
  CREATE TABLE IF NOT EXISTS attributes (
8229
9487
  id INTEGER PRIMARY KEY AUTOINCREMENT,
8230
9488
  contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
8231
9489
  link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
9490
+ event_id INTEGER REFERENCES events (id) ON DELETE CASCADE,
8232
9491
  name TEXT NOT NULL,
8233
9492
  value TEXT,
8234
9493
  created_at INTEGER NOT NULL,
@@ -8236,15 +9495,35 @@ function initAttributesTables(db) {
8236
9495
  deleted_at INTEGER,
8237
9496
  CHECK (length(trim(name)) > 0),
8238
9497
  CHECK (
8239
- (contact_id IS NOT NULL AND link_id IS NULL)
8240
- OR
8241
- (contact_id IS NULL AND link_id IS NOT NULL)
9498
+ (contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1
8242
9499
  )
8243
9500
  )
8244
9501
  `);
9502
+ const columns = db.prepare("PRAGMA table_info(attributes)").all();
9503
+ if (!columns.some((c) => c.name === "event_id")) {
9504
+ withTransaction(
9505
+ db,
9506
+ () => db.exec(`CREATE TABLE attributes_upgrade (
9507
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9508
+ contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE,
9509
+ link_id INTEGER REFERENCES links(id) ON DELETE CASCADE,
9510
+ event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
9511
+ name TEXT NOT NULL CHECK(length(trim(name)) > 0), value TEXT,
9512
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER,
9513
+ CHECK ((contact_id IS NOT NULL) + (link_id IS NOT NULL) + (event_id IS NOT NULL) = 1)
9514
+ );
9515
+ INSERT INTO attributes_upgrade (id,contact_id,link_id,name,value,created_at,updated_at,deleted_at)
9516
+ SELECT id,contact_id,link_id,name,value,created_at,updated_at,deleted_at FROM attributes;
9517
+ DROP TABLE attributes;
9518
+ ALTER TABLE attributes_upgrade RENAME TO attributes;`)
9519
+ );
9520
+ }
8245
9521
  db.exec(
8246
9522
  "CREATE INDEX IF NOT EXISTS idx_attributes_contact ON attributes(contact_id) WHERE deleted_at IS NULL"
8247
9523
  );
9524
+ db.exec(
9525
+ "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"
9526
+ );
8248
9527
  db.exec(
8249
9528
  "CREATE INDEX IF NOT EXISTS idx_attributes_link ON attributes(link_id) WHERE deleted_at IS NULL"
8250
9529
  );
@@ -8357,56 +9636,6 @@ function initEventParticipantsTables(db) {
8357
9636
  );
8358
9637
  }
8359
9638
 
8360
- // src/events/init_events_tables.ts
8361
- function initEventsTables(db) {
8362
- db.exec(`
8363
- CREATE TABLE IF NOT EXISTS events (
8364
- id INTEGER PRIMARY KEY AUTOINCREMENT,
8365
- type TEXT NOT NULL,
8366
- occurred_at INTEGER NOT NULL,
8367
- summary TEXT NOT NULL,
8368
- significance REAL NOT NULL DEFAULT 0,
8369
- moment_kind TEXT,
8370
- recurrence_kind TEXT,
8371
- anchor_month INTEGER,
8372
- anchor_day INTEGER,
8373
- anchor_contact_id INTEGER REFERENCES contacts (id) ON DELETE CASCADE,
8374
- anchor_link_id INTEGER REFERENCES links (id) ON DELETE CASCADE,
8375
- created_at INTEGER NOT NULL,
8376
- updated_at INTEGER NOT NULL,
8377
- deleted_at INTEGER,
8378
- CHECK (length(trim(summary)) > 0),
8379
- CHECK (
8380
- type IN (
8381
- 'conversation', 'activity', 'gift', 'support', 'milestone', 'observation',
8382
- 'conflict', 'correction', 'transaction', 'promise', 'agreement', 'date_anchor'
8383
- )
8384
- ),
8385
- CHECK (
8386
- (type = 'date_anchor' AND (
8387
- (anchor_contact_id IS NOT NULL AND anchor_link_id IS NULL)
8388
- OR (anchor_contact_id IS NULL AND anchor_link_id IS NOT NULL)
8389
- ))
8390
- OR (type != 'date_anchor' AND anchor_contact_id IS NULL AND anchor_link_id IS NULL)
8391
- ),
8392
- CHECK (moment_kind IS NULL OR moment_kind IN (
8393
- 'breakthrough', 'rupture', 'reconciliation', 'milestone', 'turning_point'
8394
- )),
8395
- CHECK (recurrence_kind IS NULL OR recurrence_kind IN (
8396
- 'birthday', 'anniversary', 'renewal', 'memorial', 'custom_yearly'
8397
- )),
8398
- CHECK (anchor_month IS NULL OR (anchor_month >= 1 AND anchor_month <= 12)),
8399
- CHECK (anchor_day IS NULL OR (anchor_day >= 1 AND anchor_day <= 31))
8400
- )
8401
- `);
8402
- db.exec(
8403
- "CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at DESC) WHERE deleted_at IS NULL"
8404
- );
8405
- db.exec(
8406
- "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type) WHERE deleted_at IS NULL"
8407
- );
8408
- }
8409
-
8410
9639
  // src/events/init_open_commitments_tables.ts
8411
9640
  function initOpenCommitmentsTables(db) {
8412
9641
  db.exec(`
@@ -8426,6 +9655,9 @@ function initOpenCommitmentsTables(db) {
8426
9655
  )
8427
9656
  )
8428
9657
  `);
9658
+ const columns = db.prepare("PRAGMA table_info(open_commitments)").all();
9659
+ if (!columns.some((column) => column.name === "due_on"))
9660
+ db.exec("ALTER TABLE open_commitments ADD COLUMN due_on TEXT");
8429
9661
  }
8430
9662
 
8431
9663
  // src/identities/init_identities_tables.ts
@@ -8490,6 +9722,21 @@ function initLinkEventEffectsTables(db) {
8490
9722
  db.exec(
8491
9723
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
8492
9724
  );
9725
+ for (const name of ["participant_json", "provenance_json"])
9726
+ if (!columns.some((c) => c.name === name))
9727
+ db.exec(`ALTER TABLE link_event_effects ADD COLUMN ${name} TEXT`);
9728
+ db.exec(
9729
+ "CREATE TABLE IF NOT EXISTS event_link_baselines (link_id INTEGER PRIMARY KEY REFERENCES links(id) ON DELETE CASCADE, state_json TEXT NOT NULL)"
9730
+ );
9731
+ db.exec(
9732
+ "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)"
9733
+ );
9734
+ db.exec(`INSERT OR IGNORE INTO event_link_baselines(link_id,state_json)
9735
+ 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)
9736
+ FROM links l JOIN link_event_effects e ON e.link_id=l.id GROUP BY l.id`);
9737
+ db.exec(
9738
+ "UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id IN (SELECT id FROM events WHERE type IN ('promise', 'agreement'))"
9739
+ );
8493
9740
  db.exec(
8494
9741
  "CREATE INDEX IF NOT EXISTS idx_link_event_effects_moment ON link_event_effects(moment_kind, occurred_at DESC, impact_score DESC) WHERE moment_kind IS NOT NULL"
8495
9742
  );
@@ -8665,12 +9912,15 @@ async function openAffinityDb(path) {
8665
9912
  } finally {
8666
9913
  process.emitWarning = original;
8667
9914
  }
8668
- if (path !== ":memory:") node_fs.mkdirSync(node_path.dirname(path), { recursive: true });
9915
+ if (path !== ":memory:" && !path.startsWith("file:")) {
9916
+ node_fs.mkdirSync(node_path.dirname(path), { recursive: true, mode: 448 });
9917
+ if (!node_fs.existsSync(path)) node_fs.closeSync(node_fs.openSync(path, "wx", 384));
9918
+ }
8669
9919
  const db = new sqlite.DatabaseSync(path);
8670
9920
  try {
8671
- db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
9921
+ db.exec("PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL;");
8672
9922
  const context = db;
8673
- initAffinityTables(context);
9923
+ withTransaction(context, () => initAffinityTables(context));
8674
9924
  return context;
8675
9925
  } catch (error) {
8676
9926
  db.close();
@@ -8689,6 +9939,13 @@ function isErrorResult(result) {
8689
9939
 
8690
9940
  // src/surfaces/validate_input.ts
8691
9941
  function validateInput(schema, input, path = "$") {
9942
+ if (path.endsWith(".occurredAt") && input == null)
9943
+ return ["occurredAt is required (ISO, YYYY-MM-DD, epoch ms, today, +3d \u2026)"];
9944
+ const branches = schema.oneOf ?? schema.anyOf;
9945
+ if (path.endsWith(".value") && branches?.some((b) => b.type === "string") && branches.some((b) => b.type === "null") && input !== null && typeof input !== "string")
9946
+ return [
9947
+ `attribute values are strings or null; pass "${String(input)}" to store it as text`
9948
+ ];
8692
9949
  const errors = [];
8693
9950
  const matchesType = (type) => {
8694
9951
  switch (type) {
@@ -8719,6 +9976,10 @@ function validateInput(schema, input, path = "$") {
8719
9976
  (branch) => validateInput(branch, input, path).length === 0
8720
9977
  ))
8721
9978
  errors.push(`${path}: must match at least one schema branch`);
9979
+ if (schema.anyOf && !schema.anyOf.some(
9980
+ (branch) => validateInput(branch, input, path).length === 0
9981
+ ))
9982
+ errors.push(`${path}: must match at least one schema branch`);
8722
9983
  if (typeof input === "object" && input !== null && !Array.isArray(input)) {
8723
9984
  const record = input;
8724
9985
  for (const name of schema.required ?? [])