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