@ghostpaw/affinity 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -99
- package/SKILL.md +217 -33
- package/dist/cli.js +2471 -927
- package/dist/index.cjs +1410 -493
- package/dist/index.d.cts +556 -55
- package/dist/index.d.ts +556 -55
- package/dist/index.js +1410 -493
- package/dist/mcp-server.js +1845 -704
- package/dist/mcp.cjs +1631 -609
- package/dist/mcp.js +1632 -610
- package/package.json +1 -1
- package/skills/affinity/SKILL.md +217 -33
package/dist/index.js
CHANGED
|
@@ -263,6 +263,12 @@ function initLinkEventEffectsTables(db) {
|
|
|
263
263
|
)
|
|
264
264
|
)
|
|
265
265
|
`);
|
|
266
|
+
const columns = db.prepare("PRAGMA table_info(link_event_effects)").all();
|
|
267
|
+
if (!columns.some((column) => column.name === "counts_for_recency")) {
|
|
268
|
+
db.exec(
|
|
269
|
+
"ALTER TABLE link_event_effects ADD COLUMN counts_for_recency INTEGER NOT NULL DEFAULT 1"
|
|
270
|
+
);
|
|
271
|
+
}
|
|
266
272
|
db.exec(
|
|
267
273
|
"CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
|
|
268
274
|
);
|
|
@@ -513,6 +519,8 @@ function resolveAffinityListLimit(limit) {
|
|
|
513
519
|
// src/read.ts
|
|
514
520
|
var read_exports = {};
|
|
515
521
|
__export(read_exports, {
|
|
522
|
+
exportAffinity: () => exportAffinity,
|
|
523
|
+
exportContact: () => exportContact,
|
|
516
524
|
getAffinityChart: () => getAffinityChart,
|
|
517
525
|
getContactJournal: () => getContactJournal,
|
|
518
526
|
getContactProfile: () => getContactProfile,
|
|
@@ -786,13 +794,20 @@ function createContact(db, input) {
|
|
|
786
794
|
if (!KINDS.has(input.kind)) {
|
|
787
795
|
throw new AffinityValidationError("invalid contact kind");
|
|
788
796
|
}
|
|
797
|
+
if (input.isOwner !== void 0) {
|
|
798
|
+
throw new AffinityValidationError(
|
|
799
|
+
"isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
|
|
800
|
+
);
|
|
801
|
+
}
|
|
789
802
|
const now = resolveNow(input.now);
|
|
790
803
|
if (input.bootstrapOwner) {
|
|
791
804
|
const existing = db.prepare(
|
|
792
|
-
"SELECT id FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
|
|
805
|
+
"SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
|
|
793
806
|
).get();
|
|
794
807
|
if (existing !== void 0) {
|
|
795
|
-
throw new AffinityConflictError(
|
|
808
|
+
throw new AffinityConflictError(
|
|
809
|
+
`owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
|
|
810
|
+
);
|
|
796
811
|
}
|
|
797
812
|
}
|
|
798
813
|
const isOwner = input.bootstrapOwner ? 1 : 0;
|
|
@@ -813,6 +828,25 @@ function createContact(db, input) {
|
|
|
813
828
|
});
|
|
814
829
|
}
|
|
815
830
|
|
|
831
|
+
// src/lib/read/assert_default_ordering.ts
|
|
832
|
+
function assertDefaultOrdering(_readName, options) {
|
|
833
|
+
if (options?.sort !== void 0 || options?.descending !== void 0) {
|
|
834
|
+
throw new AffinityValidationError(
|
|
835
|
+
"This review does not support custom sort ordering"
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/lib/read/resolve_list_limit_offset.ts
|
|
841
|
+
function resolveListLimitOffset(options) {
|
|
842
|
+
const limit = resolveAffinityListLimit(options?.limit);
|
|
843
|
+
const offset = options?.offset ?? 0;
|
|
844
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
845
|
+
throw new AffinityValidationError("offset must be a non-negative integer");
|
|
846
|
+
}
|
|
847
|
+
return { limit, offset };
|
|
848
|
+
}
|
|
849
|
+
|
|
816
850
|
// src/events/validators.ts
|
|
817
851
|
var PARTICIPANT_ROLES = /* @__PURE__ */ new Set([
|
|
818
852
|
"actor",
|
|
@@ -846,7 +880,10 @@ function assertParticipantContactsLive(db, contactIds) {
|
|
|
846
880
|
throw new AffinityNotFoundError("contact not found");
|
|
847
881
|
}
|
|
848
882
|
if (row.lifecycle_state === "merged") {
|
|
849
|
-
|
|
883
|
+
const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
|
|
884
|
+
throw new AffinityStateError(
|
|
885
|
+
`Contact #${id} was merged into #${row.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row.merged_into_contact_id}`
|
|
886
|
+
);
|
|
850
887
|
}
|
|
851
888
|
}
|
|
852
889
|
}
|
|
@@ -1018,6 +1055,213 @@ function findDuplicateDateAnchor(db, params) {
|
|
|
1018
1055
|
return null;
|
|
1019
1056
|
}
|
|
1020
1057
|
|
|
1058
|
+
// src/events/loaders.ts
|
|
1059
|
+
function loadEventParticipantViews(db, eventId) {
|
|
1060
|
+
const rows = db.prepare(
|
|
1061
|
+
`SELECT contact_id, role, directionality
|
|
1062
|
+
FROM event_participants
|
|
1063
|
+
WHERE event_id = ?`
|
|
1064
|
+
).all(eventId);
|
|
1065
|
+
return rows.map((row) => ({
|
|
1066
|
+
contactId: row.contact_id,
|
|
1067
|
+
role: row.role,
|
|
1068
|
+
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
1069
|
+
}));
|
|
1070
|
+
}
|
|
1071
|
+
function loadEventRecord(db, eventId) {
|
|
1072
|
+
const row = getEventRowById(db, eventId);
|
|
1073
|
+
if (!row) {
|
|
1074
|
+
throw new AffinityInvariantError("event not found");
|
|
1075
|
+
}
|
|
1076
|
+
const participants = loadEventParticipantViews(db, eventId);
|
|
1077
|
+
return mapEventRowToEventRecord(row, participants);
|
|
1078
|
+
}
|
|
1079
|
+
function batchLoadEventRecords(db, eventIds) {
|
|
1080
|
+
if (eventIds.length === 0) {
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
const ph = eventIds.map(() => "?").join(", ");
|
|
1084
|
+
const eventRows = db.prepare(
|
|
1085
|
+
`SELECT id, type, occurred_at, summary, significance, moment_kind,
|
|
1086
|
+
recurrence_kind, anchor_month, anchor_day,
|
|
1087
|
+
anchor_contact_id, anchor_link_id,
|
|
1088
|
+
created_at, updated_at, deleted_at
|
|
1089
|
+
FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
|
|
1090
|
+
).all(...eventIds);
|
|
1091
|
+
const partRows = db.prepare(
|
|
1092
|
+
`SELECT event_id, contact_id, role, directionality
|
|
1093
|
+
FROM event_participants WHERE event_id IN (${ph})`
|
|
1094
|
+
).all(...eventIds);
|
|
1095
|
+
const partsByEvent = /* @__PURE__ */ new Map();
|
|
1096
|
+
for (const p of partRows) {
|
|
1097
|
+
let arr = partsByEvent.get(p.event_id);
|
|
1098
|
+
if (!arr) {
|
|
1099
|
+
arr = [];
|
|
1100
|
+
partsByEvent.set(p.event_id, arr);
|
|
1101
|
+
}
|
|
1102
|
+
arr.push({
|
|
1103
|
+
contactId: p.contact_id,
|
|
1104
|
+
role: p.role,
|
|
1105
|
+
...p.directionality === null ? {} : { directionality: p.directionality }
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
const rowById = new Map(eventRows.map((r) => [r.id, r]));
|
|
1109
|
+
const out = [];
|
|
1110
|
+
for (const id of eventIds) {
|
|
1111
|
+
const row = rowById.get(id);
|
|
1112
|
+
if (row) {
|
|
1113
|
+
out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return out;
|
|
1117
|
+
}
|
|
1118
|
+
function batchLoadEventParticipantViews(db, eventIds) {
|
|
1119
|
+
const result = /* @__PURE__ */ new Map();
|
|
1120
|
+
if (eventIds.length === 0) {
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
const ph = eventIds.map(() => "?").join(", ");
|
|
1124
|
+
const rows = db.prepare(
|
|
1125
|
+
`SELECT event_id, contact_id, role, directionality
|
|
1126
|
+
FROM event_participants WHERE event_id IN (${ph})`
|
|
1127
|
+
).all(...eventIds);
|
|
1128
|
+
for (const row of rows) {
|
|
1129
|
+
let arr = result.get(row.event_id);
|
|
1130
|
+
if (!arr) {
|
|
1131
|
+
arr = [];
|
|
1132
|
+
result.set(row.event_id, arr);
|
|
1133
|
+
}
|
|
1134
|
+
arr.push({
|
|
1135
|
+
contactId: row.contact_id,
|
|
1136
|
+
role: row.role,
|
|
1137
|
+
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return result;
|
|
1141
|
+
}
|
|
1142
|
+
function requireDateAnchorEvent(db, eventId) {
|
|
1143
|
+
const row = getEventRowById(db, eventId);
|
|
1144
|
+
if (!row) {
|
|
1145
|
+
throw new AffinityNotFoundError("event not found");
|
|
1146
|
+
}
|
|
1147
|
+
if (row.type !== "date_anchor") {
|
|
1148
|
+
throw new AffinityInvariantError("not a date anchor");
|
|
1149
|
+
}
|
|
1150
|
+
return row;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/events/get_contact_journal.ts
|
|
1154
|
+
function getContactJournal(db, contactId, options) {
|
|
1155
|
+
assertDefaultOrdering("getContactJournal", options);
|
|
1156
|
+
const { limit, offset } = resolveListLimitOffset(options);
|
|
1157
|
+
const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
|
|
1158
|
+
const params = [contactId];
|
|
1159
|
+
if (options?.since !== void 0) {
|
|
1160
|
+
clauses.push("e.occurred_at >= ?");
|
|
1161
|
+
params.push(options.since);
|
|
1162
|
+
}
|
|
1163
|
+
if (options?.until !== void 0) {
|
|
1164
|
+
clauses.push("e.occurred_at <= ?");
|
|
1165
|
+
params.push(options.until);
|
|
1166
|
+
}
|
|
1167
|
+
if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
|
|
1168
|
+
const ph = options.eventTypes.map(() => "?").join(", ");
|
|
1169
|
+
clauses.push(`e.type IN (${ph})`);
|
|
1170
|
+
params.push(...options.eventTypes);
|
|
1171
|
+
}
|
|
1172
|
+
const where = clauses.join(" AND ");
|
|
1173
|
+
const rows = db.prepare(
|
|
1174
|
+
`SELECT e.id FROM events e
|
|
1175
|
+
INNER JOIN event_participants ep ON ep.event_id = e.id
|
|
1176
|
+
WHERE ${where}
|
|
1177
|
+
ORDER BY e.occurred_at DESC, e.id DESC
|
|
1178
|
+
LIMIT ? OFFSET ?`
|
|
1179
|
+
).all(...params, limit, offset);
|
|
1180
|
+
return batchLoadEventRecords(
|
|
1181
|
+
db,
|
|
1182
|
+
rows.map((r) => r.id)
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/events/list_open_commitments.ts
|
|
1187
|
+
var DAY_MS = 864e5;
|
|
1188
|
+
function mapResolution(resolvedAt, resolution) {
|
|
1189
|
+
if (resolvedAt === null) {
|
|
1190
|
+
return "open";
|
|
1191
|
+
}
|
|
1192
|
+
if (resolution === "cancelled") {
|
|
1193
|
+
return "cancelled";
|
|
1194
|
+
}
|
|
1195
|
+
return "resolved";
|
|
1196
|
+
}
|
|
1197
|
+
function listOpenCommitments(db, filters, options) {
|
|
1198
|
+
assertDefaultOrdering("listOpenCommitments", options);
|
|
1199
|
+
const now = resolveNow(options?.now);
|
|
1200
|
+
const { limit, offset } = resolveListLimitOffset(options);
|
|
1201
|
+
const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
|
|
1202
|
+
const params = [];
|
|
1203
|
+
if (options?.since !== void 0) {
|
|
1204
|
+
clauses.push("e.occurred_at >= ?");
|
|
1205
|
+
params.push(options.since);
|
|
1206
|
+
}
|
|
1207
|
+
if (options?.until !== void 0) {
|
|
1208
|
+
clauses.push("e.occurred_at <= ?");
|
|
1209
|
+
params.push(options.until);
|
|
1210
|
+
}
|
|
1211
|
+
if (filters?.commitmentType !== void 0) {
|
|
1212
|
+
if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
|
|
1213
|
+
throw new AffinityValidationError("invalid commitmentType filter");
|
|
1214
|
+
}
|
|
1215
|
+
clauses.push("oc.commitment_type = ?");
|
|
1216
|
+
params.push(filters.commitmentType);
|
|
1217
|
+
}
|
|
1218
|
+
if (filters?.contactId !== void 0) {
|
|
1219
|
+
clauses.push(
|
|
1220
|
+
"EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
|
|
1221
|
+
);
|
|
1222
|
+
params.push(filters.contactId);
|
|
1223
|
+
}
|
|
1224
|
+
if (filters?.linkId !== void 0) {
|
|
1225
|
+
clauses.push(
|
|
1226
|
+
"EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
|
|
1227
|
+
);
|
|
1228
|
+
params.push(filters.linkId);
|
|
1229
|
+
}
|
|
1230
|
+
if (filters?.horizonDays !== void 0) {
|
|
1231
|
+
if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
|
|
1232
|
+
throw new AffinityValidationError(
|
|
1233
|
+
"horizonDays must be a non-negative integer"
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
const anchor = options?.since ?? now;
|
|
1237
|
+
clauses.push("oc.due_at IS NOT NULL");
|
|
1238
|
+
clauses.push("oc.due_at <= ?");
|
|
1239
|
+
params.push(anchor + filters.horizonDays * DAY_MS);
|
|
1240
|
+
}
|
|
1241
|
+
const where = clauses.join(" AND ");
|
|
1242
|
+
const rows = db.prepare(
|
|
1243
|
+
`SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
|
|
1244
|
+
e.type, e.summary
|
|
1245
|
+
FROM open_commitments oc
|
|
1246
|
+
INNER JOIN events e ON e.id = oc.event_id
|
|
1247
|
+
WHERE ${where}
|
|
1248
|
+
ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
|
|
1249
|
+
LIMIT ? OFFSET ?`
|
|
1250
|
+
).all(...params, limit, offset);
|
|
1251
|
+
const eventIds = rows.map((r) => r.event_id);
|
|
1252
|
+
const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
|
|
1253
|
+
return rows.map((r) => ({
|
|
1254
|
+
eventId: r.event_id,
|
|
1255
|
+
type: r.type,
|
|
1256
|
+
summary: r.summary,
|
|
1257
|
+
participants: partsByEvent.get(r.event_id) ?? [],
|
|
1258
|
+
dueAt: r.due_at,
|
|
1259
|
+
dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
|
|
1260
|
+
overdue: r.due_at !== null && r.due_at < now,
|
|
1261
|
+
resolutionState: mapResolution(r.resolved_at, r.resolution)
|
|
1262
|
+
}));
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1021
1265
|
// src/dates/calendar.ts
|
|
1022
1266
|
function utcStartOfDayMs(ms) {
|
|
1023
1267
|
const date = new Date(ms);
|
|
@@ -1184,13 +1428,20 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
|
|
|
1184
1428
|
const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
|
|
1185
1429
|
const activeDates = loadActiveDatesForContact(db, contactId);
|
|
1186
1430
|
const contactRollup = loadContactRollup(db, contactId);
|
|
1431
|
+
const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
|
|
1187
1432
|
return {
|
|
1433
|
+
...winner ? { mergedInto: { id: winner.id, name: winner.name } } : {},
|
|
1188
1434
|
contact: mapContactRowToContactCore(row),
|
|
1189
1435
|
identities,
|
|
1190
1436
|
attributes,
|
|
1191
1437
|
topLinks,
|
|
1192
1438
|
activeDates,
|
|
1193
|
-
warnings:
|
|
1439
|
+
warnings: [
|
|
1440
|
+
...contactRollup?.warnings ?? [],
|
|
1441
|
+
...winner ? [
|
|
1442
|
+
`Contact #${row.id} was merged into #${winner.id} ${winner.name}; use contactId ${winner.id}`
|
|
1443
|
+
] : []
|
|
1444
|
+
],
|
|
1194
1445
|
rollups: contactRollup?.rollup ?? null
|
|
1195
1446
|
};
|
|
1196
1447
|
}
|
|
@@ -1198,7 +1449,27 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
|
|
|
1198
1449
|
// src/contacts/get_contact_profile.ts
|
|
1199
1450
|
function getContactProfile(db, contactId, options) {
|
|
1200
1451
|
const t = options?.topLinksLimit ?? 8;
|
|
1201
|
-
|
|
1452
|
+
const profile = loadContactProfileRecord(db, contactId, t);
|
|
1453
|
+
if (!profile) return null;
|
|
1454
|
+
const ties = db.prepare(`SELECT l.id, l.kind, l.role, c.id AS contactId, c.name
|
|
1455
|
+
FROM links l JOIN contacts c ON c.id = CASE WHEN l.from_contact_id = ? THEN l.to_contact_id ELSE l.from_contact_id END
|
|
1456
|
+
WHERE l.is_structural = 1 AND l.removed_at IS NULL AND (l.from_contact_id = ? OR l.to_contact_id = ?)
|
|
1457
|
+
AND c.deleted_at IS NULL ORDER BY l.id`).all(contactId, contactId, contactId);
|
|
1458
|
+
return {
|
|
1459
|
+
...profile,
|
|
1460
|
+
recentEvents: getContactJournal(db, contactId, {
|
|
1461
|
+
limit: options?.recentEventsLimit ?? 5
|
|
1462
|
+
}).map((event) => ({
|
|
1463
|
+
...event,
|
|
1464
|
+
occurredAtIso: new Date(event.occurredAt).toISOString()
|
|
1465
|
+
})),
|
|
1466
|
+
openCommitments: listOpenCommitments(
|
|
1467
|
+
db,
|
|
1468
|
+
{ contactId },
|
|
1469
|
+
options?.now === void 0 ? void 0 : { now: options.now }
|
|
1470
|
+
),
|
|
1471
|
+
ties
|
|
1472
|
+
};
|
|
1202
1473
|
}
|
|
1203
1474
|
|
|
1204
1475
|
// src/contacts/get_owner_profile.ts
|
|
@@ -1211,23 +1482,21 @@ function getOwnerProfile(db, options) {
|
|
|
1211
1482
|
return loadContactProfileRecord(db, ownerId, t);
|
|
1212
1483
|
}
|
|
1213
1484
|
|
|
1214
|
-
// src/
|
|
1215
|
-
function
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1485
|
+
// src/contacts/contact_attribute_clauses.ts
|
|
1486
|
+
function contactAttributeClauses(filters) {
|
|
1487
|
+
const clauses = [];
|
|
1488
|
+
const params = [];
|
|
1489
|
+
const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
|
|
1490
|
+
const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
|
|
1491
|
+
for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
|
|
1492
|
+
const separator = filter.indexOf("=");
|
|
1493
|
+
const name = separator < 0 ? filter : filter.slice(0, separator);
|
|
1494
|
+
clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
|
|
1495
|
+
AND a.deleted_at IS NULL AND lower(a.name) = lower(?)${separator < 0 ? "" : " AND lower(CAST(CASE WHEN json_valid(a.value) THEN json_extract(a.value, '$') ELSE a.value END AS TEXT)) = lower(?)"})`);
|
|
1496
|
+
params.push(name);
|
|
1497
|
+
if (separator >= 0) params.push(filter.slice(separator + 1));
|
|
1220
1498
|
}
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
// src/lib/read/resolve_list_limit_offset.ts
|
|
1224
|
-
function resolveListLimitOffset(options) {
|
|
1225
|
-
const limit = resolveAffinityListLimit(options?.limit);
|
|
1226
|
-
const offset = options?.offset ?? 0;
|
|
1227
|
-
if (!Number.isInteger(offset) || offset < 0) {
|
|
1228
|
-
throw new AffinityValidationError("offset must be a non-negative integer");
|
|
1229
|
-
}
|
|
1230
|
-
return { limit, offset };
|
|
1499
|
+
return { clauses, params };
|
|
1231
1500
|
}
|
|
1232
1501
|
|
|
1233
1502
|
// src/contacts/list_contacts.ts
|
|
@@ -1253,6 +1522,9 @@ function listContacts(db, filters, options) {
|
|
|
1253
1522
|
if (filters?.includeDormant === false || options?.includeDormant === false) {
|
|
1254
1523
|
clauses.push("lifecycle_state != 'dormant'");
|
|
1255
1524
|
}
|
|
1525
|
+
const attributeFilters = contactAttributeClauses(filters);
|
|
1526
|
+
clauses.push(...attributeFilters.clauses);
|
|
1527
|
+
params.push(...attributeFilters.params);
|
|
1256
1528
|
const where = clauses.join(" AND ");
|
|
1257
1529
|
const rows = db.prepare(
|
|
1258
1530
|
`SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
|
|
@@ -1435,89 +1707,97 @@ function reviseContact(db, contactId, patch) {
|
|
|
1435
1707
|
});
|
|
1436
1708
|
}
|
|
1437
1709
|
|
|
1438
|
-
// src/contacts/
|
|
1439
|
-
function
|
|
1440
|
-
|
|
1441
|
-
if (
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
return 0.7;
|
|
1710
|
+
// src/contacts/fold_search_text.ts
|
|
1711
|
+
function foldSearchText(value) {
|
|
1712
|
+
let folded = value.toLowerCase();
|
|
1713
|
+
if (/[^\u0020-\u007e]/.test(folded))
|
|
1714
|
+
folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
|
|
1715
|
+
return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
|
|
1445
1716
|
}
|
|
1717
|
+
|
|
1718
|
+
// src/contacts/search_contacts.ts
|
|
1446
1719
|
function searchContacts(db, query, filters, options) {
|
|
1447
|
-
const q = query.trim();
|
|
1448
|
-
if (q
|
|
1449
|
-
return [];
|
|
1450
|
-
}
|
|
1720
|
+
const q = foldSearchText(query.trim());
|
|
1721
|
+
if (!q) return [];
|
|
1451
1722
|
assertDefaultOrdering("searchContacts", options);
|
|
1452
1723
|
const { limit, offset } = resolveListLimitOffset(options);
|
|
1453
|
-
const
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1724
|
+
const fields = /* @__PURE__ */ new Map();
|
|
1725
|
+
const rows = db.prepare(`
|
|
1726
|
+
SELECT contact_id, value || ' ' || normalized_key AS text, 'identity:' || type AS source FROM identities WHERE removed_at IS NULL
|
|
1727
|
+
UNION ALL SELECT contact_id, name || ' ' || COALESCE(value, ''), 'attribute:' || name FROM attributes WHERE deleted_at IS NULL AND contact_id IS NOT NULL
|
|
1728
|
+
UNION ALL SELECT from_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
|
|
1729
|
+
UNION ALL SELECT to_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
|
|
1730
|
+
UNION ALL SELECT ep.contact_id, e.summary, 'event' FROM event_participants ep JOIN events e ON e.id = ep.event_id WHERE e.deleted_at IS NULL
|
|
1731
|
+
`).all();
|
|
1732
|
+
for (const row of rows) {
|
|
1733
|
+
const values = fields.get(row.contact_id) ?? [];
|
|
1734
|
+
values.push({ text: foldSearchText(row.text), source: row.source });
|
|
1735
|
+
fields.set(row.contact_id, values);
|
|
1736
|
+
}
|
|
1737
|
+
const tokens = q.split(/\s+/);
|
|
1738
|
+
const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
|
|
1739
|
+
const digits = q.replace(/\D/g, "").replace(/^00/, "");
|
|
1740
|
+
const attributeFilters = contactAttributeClauses(filters);
|
|
1456
1741
|
const clauses = [
|
|
1457
|
-
"
|
|
1458
|
-
"
|
|
1742
|
+
"deleted_at IS NULL",
|
|
1743
|
+
"lifecycle_state != 'merged'",
|
|
1744
|
+
...attributeFilters.clauses
|
|
1459
1745
|
];
|
|
1460
|
-
const params = [];
|
|
1746
|
+
const params = [...attributeFilters.params];
|
|
1461
1747
|
if (filters?.kind !== void 0) {
|
|
1462
|
-
clauses.push("
|
|
1748
|
+
clauses.push("kind = ?");
|
|
1463
1749
|
params.push(filters.kind);
|
|
1464
1750
|
}
|
|
1465
1751
|
if (filters?.lifecycleState !== void 0) {
|
|
1466
|
-
clauses.push("
|
|
1752
|
+
clauses.push("lifecycle_state = ?");
|
|
1467
1753
|
params.push(filters.lifecycleState);
|
|
1468
1754
|
}
|
|
1469
|
-
if (filters?.includeOwner === false)
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
Number(
|
|
1508
|
-
r.matched_normalized_identity ?? 0
|
|
1509
|
-
) > 0
|
|
1755
|
+
if (filters?.includeOwner === false) clauses.push("is_owner = 0");
|
|
1756
|
+
const contacts = db.prepare(
|
|
1757
|
+
`SELECT id, name, kind, lifecycle_state, is_owner, (SELECT value FROM identities i WHERE i.contact_id = contacts.id AND i.removed_at IS NULL ORDER BY i.id LIMIT 1) AS primary_identity FROM contacts WHERE ${clauses.join(" AND ")}`
|
|
1758
|
+
).all(...params).map((row) => ({
|
|
1759
|
+
...mapContactRowToContactListItem(row),
|
|
1760
|
+
primaryIdentity: row.primary_identity
|
|
1761
|
+
}));
|
|
1762
|
+
const scored = [];
|
|
1763
|
+
for (const contact of contacts) {
|
|
1764
|
+
if (contact.lifecycleState === "dormant" && !(filters?.includeDormant ?? options?.includeDormant))
|
|
1765
|
+
continue;
|
|
1766
|
+
if (contact.lifecycleState === "lost" && !filters?.includeLost) continue;
|
|
1767
|
+
const name = foldSearchText(contact.name);
|
|
1768
|
+
const values = [
|
|
1769
|
+
{ text: name, source: "name" },
|
|
1770
|
+
...(fields.get(contact.id) ?? []).filter(
|
|
1771
|
+
(field) => !contact.isOwner || field.source !== "event" && field.source !== "link_role"
|
|
1772
|
+
)
|
|
1773
|
+
];
|
|
1774
|
+
const phoneFields = phone ? values.filter(
|
|
1775
|
+
(field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
|
|
1776
|
+
) : [];
|
|
1777
|
+
if (!phoneFields.length && !tokens.every(
|
|
1778
|
+
(token) => values.some((field) => field.text.includes(token))
|
|
1779
|
+
))
|
|
1780
|
+
continue;
|
|
1781
|
+
const matchedOn = [
|
|
1782
|
+
...new Set(
|
|
1783
|
+
[
|
|
1784
|
+
...values.filter(
|
|
1785
|
+
(field) => tokens.some((token) => field.text.includes(token))
|
|
1786
|
+
),
|
|
1787
|
+
...phoneFields
|
|
1788
|
+
].map((field) => field.source)
|
|
1789
|
+
)
|
|
1790
|
+
];
|
|
1791
|
+
const identities = values.filter(
|
|
1792
|
+
(field) => field.source.startsWith("identity:")
|
|
1510
1793
|
);
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
});
|
|
1519
|
-
scored.sort((a, b) => b.score - a.score);
|
|
1520
|
-
return scored.slice(offset, offset + limit).map((s) => s.item);
|
|
1794
|
+
const score = name === q ? 5 : name.startsWith(q) || tokens.every((token) => name.includes(token)) ? 4 : phoneFields.length || identities.some((field) => field.text.split(" ").includes(q)) ? 3 : identities.some(
|
|
1795
|
+
(field) => tokens.every((token) => field.text.includes(token))
|
|
1796
|
+
) ? 2 : 1;
|
|
1797
|
+
scored.push({ item: { ...contact, matchedOn }, score });
|
|
1798
|
+
}
|
|
1799
|
+
scored.sort((a, b) => b.score - a.score || a.item.id - b.item.id);
|
|
1800
|
+
return scored.slice(offset, offset + limit).map(({ item }) => item);
|
|
1521
1801
|
}
|
|
1522
1802
|
|
|
1523
1803
|
// src/contacts/validators.ts
|
|
@@ -1526,7 +1806,9 @@ function assertValidLifecycleTransition(from, to) {
|
|
|
1526
1806
|
throw new AffinityStateError("merged contact is read-only");
|
|
1527
1807
|
}
|
|
1528
1808
|
if (to === "merged") {
|
|
1529
|
-
throw new AffinityInvariantError(
|
|
1809
|
+
throw new AffinityInvariantError(
|
|
1810
|
+
"use manage_contact merge (CLI: contact merge) to reach merged state"
|
|
1811
|
+
);
|
|
1530
1812
|
}
|
|
1531
1813
|
if (from === to) {
|
|
1532
1814
|
return;
|
|
@@ -1577,101 +1859,6 @@ function setContactLifecycle(db, contactId, lifecycleState, options) {
|
|
|
1577
1859
|
});
|
|
1578
1860
|
}
|
|
1579
1861
|
|
|
1580
|
-
// src/events/loaders.ts
|
|
1581
|
-
function loadEventParticipantViews(db, eventId) {
|
|
1582
|
-
const rows = db.prepare(
|
|
1583
|
-
`SELECT contact_id, role, directionality
|
|
1584
|
-
FROM event_participants
|
|
1585
|
-
WHERE event_id = ?`
|
|
1586
|
-
).all(eventId);
|
|
1587
|
-
return rows.map((row) => ({
|
|
1588
|
-
contactId: row.contact_id,
|
|
1589
|
-
role: row.role,
|
|
1590
|
-
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
1591
|
-
}));
|
|
1592
|
-
}
|
|
1593
|
-
function loadEventRecord(db, eventId) {
|
|
1594
|
-
const row = getEventRowById(db, eventId);
|
|
1595
|
-
if (!row) {
|
|
1596
|
-
throw new AffinityInvariantError("event not found");
|
|
1597
|
-
}
|
|
1598
|
-
const participants = loadEventParticipantViews(db, eventId);
|
|
1599
|
-
return mapEventRowToEventRecord(row, participants);
|
|
1600
|
-
}
|
|
1601
|
-
function batchLoadEventRecords(db, eventIds) {
|
|
1602
|
-
if (eventIds.length === 0) {
|
|
1603
|
-
return [];
|
|
1604
|
-
}
|
|
1605
|
-
const ph = eventIds.map(() => "?").join(", ");
|
|
1606
|
-
const eventRows = db.prepare(
|
|
1607
|
-
`SELECT id, type, occurred_at, summary, significance, moment_kind,
|
|
1608
|
-
recurrence_kind, anchor_month, anchor_day,
|
|
1609
|
-
anchor_contact_id, anchor_link_id,
|
|
1610
|
-
created_at, updated_at, deleted_at
|
|
1611
|
-
FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
|
|
1612
|
-
).all(...eventIds);
|
|
1613
|
-
const partRows = db.prepare(
|
|
1614
|
-
`SELECT event_id, contact_id, role, directionality
|
|
1615
|
-
FROM event_participants WHERE event_id IN (${ph})`
|
|
1616
|
-
).all(...eventIds);
|
|
1617
|
-
const partsByEvent = /* @__PURE__ */ new Map();
|
|
1618
|
-
for (const p of partRows) {
|
|
1619
|
-
let arr = partsByEvent.get(p.event_id);
|
|
1620
|
-
if (!arr) {
|
|
1621
|
-
arr = [];
|
|
1622
|
-
partsByEvent.set(p.event_id, arr);
|
|
1623
|
-
}
|
|
1624
|
-
arr.push({
|
|
1625
|
-
contactId: p.contact_id,
|
|
1626
|
-
role: p.role,
|
|
1627
|
-
...p.directionality === null ? {} : { directionality: p.directionality }
|
|
1628
|
-
});
|
|
1629
|
-
}
|
|
1630
|
-
const rowById = new Map(eventRows.map((r) => [r.id, r]));
|
|
1631
|
-
const out = [];
|
|
1632
|
-
for (const id of eventIds) {
|
|
1633
|
-
const row = rowById.get(id);
|
|
1634
|
-
if (row) {
|
|
1635
|
-
out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
return out;
|
|
1639
|
-
}
|
|
1640
|
-
function batchLoadEventParticipantViews(db, eventIds) {
|
|
1641
|
-
const result = /* @__PURE__ */ new Map();
|
|
1642
|
-
if (eventIds.length === 0) {
|
|
1643
|
-
return result;
|
|
1644
|
-
}
|
|
1645
|
-
const ph = eventIds.map(() => "?").join(", ");
|
|
1646
|
-
const rows = db.prepare(
|
|
1647
|
-
`SELECT event_id, contact_id, role, directionality
|
|
1648
|
-
FROM event_participants WHERE event_id IN (${ph})`
|
|
1649
|
-
).all(...eventIds);
|
|
1650
|
-
for (const row of rows) {
|
|
1651
|
-
let arr = result.get(row.event_id);
|
|
1652
|
-
if (!arr) {
|
|
1653
|
-
arr = [];
|
|
1654
|
-
result.set(row.event_id, arr);
|
|
1655
|
-
}
|
|
1656
|
-
arr.push({
|
|
1657
|
-
contactId: row.contact_id,
|
|
1658
|
-
role: row.role,
|
|
1659
|
-
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
1660
|
-
});
|
|
1661
|
-
}
|
|
1662
|
-
return result;
|
|
1663
|
-
}
|
|
1664
|
-
function requireDateAnchorEvent(db, eventId) {
|
|
1665
|
-
const row = getEventRowById(db, eventId);
|
|
1666
|
-
if (!row) {
|
|
1667
|
-
throw new AffinityNotFoundError("event not found");
|
|
1668
|
-
}
|
|
1669
|
-
if (row.type !== "date_anchor") {
|
|
1670
|
-
throw new AffinityInvariantError("not a date anchor");
|
|
1671
|
-
}
|
|
1672
|
-
return row;
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
1862
|
// src/events/persistence.ts
|
|
1676
1863
|
function insertJournalEvent(db, params) {
|
|
1677
1864
|
const now = resolveNow(params.now);
|
|
@@ -2535,7 +2722,7 @@ function getLinkTimeline(db, linkId, options) {
|
|
|
2535
2722
|
}
|
|
2536
2723
|
|
|
2537
2724
|
// src/links/rollups.ts
|
|
2538
|
-
var
|
|
2725
|
+
var DAY_MS2 = 864e5;
|
|
2539
2726
|
function parseJsonObject2(raw) {
|
|
2540
2727
|
try {
|
|
2541
2728
|
const parsed = JSON.parse(raw);
|
|
@@ -2593,7 +2780,7 @@ function recencyScore(occurredAt, cadenceDays, now) {
|
|
|
2593
2780
|
return 0;
|
|
2594
2781
|
}
|
|
2595
2782
|
const cadence = Math.max(cadenceDays ?? 0, 1);
|
|
2596
|
-
const daysSince = Math.max(0, (now - occurredAt) /
|
|
2783
|
+
const daysSince = Math.max(0, (now - occurredAt) / DAY_MS2);
|
|
2597
2784
|
return Math.max(0, 1 - daysSince / cadence);
|
|
2598
2785
|
}
|
|
2599
2786
|
function refreshLinkRollup(db, linkId, now) {
|
|
@@ -2604,15 +2791,15 @@ function refreshLinkRollup(db, linkId, now) {
|
|
|
2604
2791
|
const latest = db.prepare(
|
|
2605
2792
|
`SELECT event_id, occurred_at
|
|
2606
2793
|
FROM link_event_effects
|
|
2607
|
-
WHERE link_id = ?
|
|
2794
|
+
WHERE link_id = ? AND counts_for_recency = 1
|
|
2608
2795
|
ORDER BY occurred_at DESC, event_id DESC
|
|
2609
2796
|
LIMIT 1`
|
|
2610
2797
|
).get(linkId);
|
|
2611
|
-
const trailingWindowStart = now - 180 *
|
|
2798
|
+
const trailingWindowStart = now - 180 * DAY_MS2;
|
|
2612
2799
|
const counts = db.prepare(
|
|
2613
2800
|
`SELECT
|
|
2614
2801
|
COUNT(*) AS total_meaningful_events,
|
|
2615
|
-
SUM(CASE WHEN affinity_delta > 0 OR trust_delta > 0 THEN 1 ELSE 0 END) AS positive_meaningful_events
|
|
2802
|
+
SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
|
|
2616
2803
|
FROM link_event_effects
|
|
2617
2804
|
WHERE link_id = ? AND occurred_at >= ?`
|
|
2618
2805
|
).get(linkId, trailingWindowStart);
|
|
@@ -2620,7 +2807,7 @@ function refreshLinkRollup(db, linkId, now) {
|
|
|
2620
2807
|
`SELECT ep.directionality
|
|
2621
2808
|
FROM link_event_effects le
|
|
2622
2809
|
INNER JOIN event_participants ep ON ep.event_id = le.event_id
|
|
2623
|
-
WHERE le.link_id = ? AND ep.contact_id = ?
|
|
2810
|
+
WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
|
|
2624
2811
|
AND ep.directionality IS NOT NULL
|
|
2625
2812
|
AND le.occurred_at >= ?`
|
|
2626
2813
|
).all(linkId, link.from_contact_id, trailingWindowStart);
|
|
@@ -2646,7 +2833,7 @@ function refreshLinkRollup(db, linkId, now) {
|
|
|
2646
2833
|
link.cadence_days,
|
|
2647
2834
|
now
|
|
2648
2835
|
);
|
|
2649
|
-
const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) /
|
|
2836
|
+
const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS2);
|
|
2650
2837
|
const driftPriorityValue = driftPriority({
|
|
2651
2838
|
daysSinceLastMeaningfulEvent,
|
|
2652
2839
|
cadenceDays: link.cadence_days ?? 1,
|
|
@@ -2963,13 +3150,15 @@ function listRadar(db, filters, options) {
|
|
|
2963
3150
|
if (ownerId === null) {
|
|
2964
3151
|
return [];
|
|
2965
3152
|
}
|
|
3153
|
+
const now = resolveNow(options?.now);
|
|
3154
|
+
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})`;
|
|
2966
3155
|
const { limit, offset } = resolveListLimitOffset(options);
|
|
2967
3156
|
const clauses = [
|
|
2968
3157
|
"l.removed_at IS NULL",
|
|
2969
3158
|
"l.is_structural = 0",
|
|
2970
|
-
"l.from_contact_id = ?"
|
|
3159
|
+
"(l.from_contact_id = ? OR l.to_contact_id = ?)"
|
|
2971
3160
|
];
|
|
2972
|
-
const params = [ownerId];
|
|
3161
|
+
const params = [ownerId, ownerId];
|
|
2973
3162
|
if (filters?.kind !== void 0) {
|
|
2974
3163
|
clauses.push("l.kind = ?");
|
|
2975
3164
|
params.push(filters.kind);
|
|
@@ -2991,6 +3180,7 @@ function listRadar(db, filters, options) {
|
|
|
2991
3180
|
const rows = db.prepare(
|
|
2992
3181
|
`SELECT
|
|
2993
3182
|
l.id,
|
|
3183
|
+
${overdue} AS overdue_commitment,
|
|
2994
3184
|
l.from_contact_id,
|
|
2995
3185
|
l.to_contact_id,
|
|
2996
3186
|
l.trust,
|
|
@@ -2999,17 +3189,18 @@ function listRadar(db, filters, options) {
|
|
|
2999
3189
|
lr.recency_score,
|
|
3000
3190
|
lr.normalized_rank
|
|
3001
3191
|
FROM links l
|
|
3002
|
-
INNER JOIN contacts c_to ON c_to.id = l.to_contact_id
|
|
3192
|
+
INNER JOIN contacts c_to ON c_to.id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END
|
|
3003
3193
|
AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
|
|
3004
3194
|
LEFT JOIN link_rollups lr ON lr.link_id = l.id
|
|
3005
3195
|
WHERE ${where}
|
|
3006
|
-
ORDER BY COALESCE(lr.radar_score, 0) DESC
|
|
3196
|
+
ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
|
|
3007
3197
|
LIMIT ? OFFSET ?`
|
|
3008
3198
|
).all(...params, limit, offset);
|
|
3009
3199
|
return rows.map((row) => ({
|
|
3010
3200
|
linkId: row.id,
|
|
3011
3201
|
contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
|
|
3012
|
-
|
|
3202
|
+
reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
|
|
3203
|
+
driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
|
|
3013
3204
|
recencyScore: row.recency_score ?? 0,
|
|
3014
3205
|
normalizedRank: row.normalized_rank ?? 0,
|
|
3015
3206
|
trust: row.trust ?? 0,
|
|
@@ -3620,39 +3811,6 @@ function listDismissedDuplicates(db, options) {
|
|
|
3620
3811
|
}));
|
|
3621
3812
|
}
|
|
3622
3813
|
|
|
3623
|
-
// src/events/get_contact_journal.ts
|
|
3624
|
-
function getContactJournal(db, contactId, options) {
|
|
3625
|
-
assertDefaultOrdering("getContactJournal", options);
|
|
3626
|
-
const { limit, offset } = resolveListLimitOffset(options);
|
|
3627
|
-
const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
|
|
3628
|
-
const params = [contactId];
|
|
3629
|
-
if (options?.since !== void 0) {
|
|
3630
|
-
clauses.push("e.occurred_at >= ?");
|
|
3631
|
-
params.push(options.since);
|
|
3632
|
-
}
|
|
3633
|
-
if (options?.until !== void 0) {
|
|
3634
|
-
clauses.push("e.occurred_at <= ?");
|
|
3635
|
-
params.push(options.until);
|
|
3636
|
-
}
|
|
3637
|
-
if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
|
|
3638
|
-
const ph = options.eventTypes.map(() => "?").join(", ");
|
|
3639
|
-
clauses.push(`e.type IN (${ph})`);
|
|
3640
|
-
params.push(...options.eventTypes);
|
|
3641
|
-
}
|
|
3642
|
-
const where = clauses.join(" AND ");
|
|
3643
|
-
const rows = db.prepare(
|
|
3644
|
-
`SELECT e.id FROM events e
|
|
3645
|
-
INNER JOIN event_participants ep ON ep.event_id = e.id
|
|
3646
|
-
WHERE ${where}
|
|
3647
|
-
ORDER BY e.occurred_at DESC, e.id DESC
|
|
3648
|
-
LIMIT ? OFFSET ?`
|
|
3649
|
-
).all(...params, limit, offset);
|
|
3650
|
-
return batchLoadEventRecords(
|
|
3651
|
-
db,
|
|
3652
|
-
rows.map((r) => r.id)
|
|
3653
|
-
);
|
|
3654
|
-
}
|
|
3655
|
-
|
|
3656
3814
|
// src/events/mechanics.ts
|
|
3657
3815
|
function clamp2(value, min, max) {
|
|
3658
3816
|
return Math.min(max, Math.max(min, value));
|
|
@@ -3877,86 +4035,10 @@ function massPenalty(weeklyPositiveBaseWeight2) {
|
|
|
3877
4035
|
return 1 / (1 + excessWeeklyMass / 8);
|
|
3878
4036
|
}
|
|
3879
4037
|
function dateSalienceBonus(significance) {
|
|
3880
|
-
return 1 + Math.min(0.25, significance / 40);
|
|
3881
|
-
}
|
|
3882
|
-
function repairBonus(consecutiveRepairEvents) {
|
|
3883
|
-
return Math.min(0.12, consecutiveRepairEvents * 0.02);
|
|
3884
|
-
}
|
|
3885
|
-
|
|
3886
|
-
// src/events/list_open_commitments.ts
|
|
3887
|
-
var DAY_MS2 = 864e5;
|
|
3888
|
-
function mapResolution(resolvedAt, resolution) {
|
|
3889
|
-
if (resolvedAt === null) {
|
|
3890
|
-
return "open";
|
|
3891
|
-
}
|
|
3892
|
-
if (resolution === "cancelled") {
|
|
3893
|
-
return "cancelled";
|
|
3894
|
-
}
|
|
3895
|
-
return "resolved";
|
|
3896
|
-
}
|
|
3897
|
-
function listOpenCommitments(db, filters, options) {
|
|
3898
|
-
assertDefaultOrdering("listOpenCommitments", options);
|
|
3899
|
-
const { limit, offset } = resolveListLimitOffset(options);
|
|
3900
|
-
const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
|
|
3901
|
-
const params = [];
|
|
3902
|
-
if (options?.since !== void 0) {
|
|
3903
|
-
clauses.push("e.occurred_at >= ?");
|
|
3904
|
-
params.push(options.since);
|
|
3905
|
-
}
|
|
3906
|
-
if (options?.until !== void 0) {
|
|
3907
|
-
clauses.push("e.occurred_at <= ?");
|
|
3908
|
-
params.push(options.until);
|
|
3909
|
-
}
|
|
3910
|
-
if (filters?.commitmentType !== void 0) {
|
|
3911
|
-
if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
|
|
3912
|
-
throw new AffinityValidationError("invalid commitmentType filter");
|
|
3913
|
-
}
|
|
3914
|
-
clauses.push("oc.commitment_type = ?");
|
|
3915
|
-
params.push(filters.commitmentType);
|
|
3916
|
-
}
|
|
3917
|
-
if (filters?.contactId !== void 0) {
|
|
3918
|
-
clauses.push(
|
|
3919
|
-
"EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
|
|
3920
|
-
);
|
|
3921
|
-
params.push(filters.contactId);
|
|
3922
|
-
}
|
|
3923
|
-
if (filters?.linkId !== void 0) {
|
|
3924
|
-
clauses.push(
|
|
3925
|
-
"EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
|
|
3926
|
-
);
|
|
3927
|
-
params.push(filters.linkId);
|
|
3928
|
-
}
|
|
3929
|
-
if (filters?.horizonDays !== void 0) {
|
|
3930
|
-
if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
|
|
3931
|
-
throw new AffinityValidationError(
|
|
3932
|
-
"horizonDays must be a non-negative integer"
|
|
3933
|
-
);
|
|
3934
|
-
}
|
|
3935
|
-
const anchor = options?.since ?? Date.now();
|
|
3936
|
-
clauses.push("oc.due_at IS NOT NULL");
|
|
3937
|
-
clauses.push("oc.due_at <= ?");
|
|
3938
|
-
params.push(anchor + filters.horizonDays * DAY_MS2);
|
|
3939
|
-
}
|
|
3940
|
-
const where = clauses.join(" AND ");
|
|
3941
|
-
const rows = db.prepare(
|
|
3942
|
-
`SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
|
|
3943
|
-
e.type, e.summary
|
|
3944
|
-
FROM open_commitments oc
|
|
3945
|
-
INNER JOIN events e ON e.id = oc.event_id
|
|
3946
|
-
WHERE ${where}
|
|
3947
|
-
ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
|
|
3948
|
-
LIMIT ? OFFSET ?`
|
|
3949
|
-
).all(...params, limit, offset);
|
|
3950
|
-
const eventIds = rows.map((r) => r.event_id);
|
|
3951
|
-
const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
|
|
3952
|
-
return rows.map((r) => ({
|
|
3953
|
-
eventId: r.event_id,
|
|
3954
|
-
type: r.type,
|
|
3955
|
-
summary: r.summary,
|
|
3956
|
-
participants: partsByEvent.get(r.event_id) ?? [],
|
|
3957
|
-
dueAt: r.due_at,
|
|
3958
|
-
resolutionState: mapResolution(r.resolved_at, r.resolution)
|
|
3959
|
-
}));
|
|
4038
|
+
return 1 + Math.min(0.25, significance / 40);
|
|
4039
|
+
}
|
|
4040
|
+
function repairBonus(consecutiveRepairEvents) {
|
|
4041
|
+
return Math.min(0.12, consecutiveRepairEvents * 0.02);
|
|
3960
4042
|
}
|
|
3961
4043
|
|
|
3962
4044
|
// src/links/effects.ts
|
|
@@ -4236,7 +4318,7 @@ function applyEventEffectToLink(db, params) {
|
|
|
4236
4318
|
1,
|
|
4237
4319
|
Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
|
|
4238
4320
|
);
|
|
4239
|
-
const cadenceAfter = nextCadenceDays({
|
|
4321
|
+
const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
|
|
4240
4322
|
kind: linkKind,
|
|
4241
4323
|
currentCadenceDays: cadenceBefore,
|
|
4242
4324
|
gapDays
|
|
@@ -4299,6 +4381,11 @@ function applyEventEffectToLink(db, params) {
|
|
|
4299
4381
|
params.now,
|
|
4300
4382
|
params.now
|
|
4301
4383
|
);
|
|
4384
|
+
if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
|
|
4385
|
+
db.prepare(
|
|
4386
|
+
"UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
|
|
4387
|
+
).run(params.eventId, params.linkId);
|
|
4388
|
+
}
|
|
4302
4389
|
refreshLinkRollup(db, params.linkId, params.now);
|
|
4303
4390
|
return {
|
|
4304
4391
|
linkId: params.linkId,
|
|
@@ -4678,12 +4765,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
|
|
|
4678
4765
|
if (row.type !== "promise" && row.type !== "agreement") {
|
|
4679
4766
|
throw new AffinityInvariantError("not a commitment event");
|
|
4680
4767
|
}
|
|
4681
|
-
const open = db.prepare(
|
|
4768
|
+
const open = db.prepare(
|
|
4769
|
+
"SELECT resolved_at, resolution FROM open_commitments WHERE event_id = ?"
|
|
4770
|
+
).get(commitmentEventId);
|
|
4682
4771
|
if (!open) {
|
|
4683
4772
|
throw new AffinityNotFoundError("open commitment not found");
|
|
4684
4773
|
}
|
|
4685
4774
|
if (open.resolved_at !== null) {
|
|
4686
|
-
throw new
|
|
4775
|
+
throw new AffinityConflictError(
|
|
4776
|
+
`Commitment #${commitmentEventId} is already resolved (${open.resolution})`
|
|
4777
|
+
);
|
|
4687
4778
|
}
|
|
4688
4779
|
const now = resolveNow(options?.now);
|
|
4689
4780
|
const originalParticipants = loadEventParticipantViews(
|
|
@@ -4696,7 +4787,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
|
|
|
4696
4787
|
WHERE event_id = ? AND resolved_at IS NULL`
|
|
4697
4788
|
).run(resolution, now, now, commitmentEventId);
|
|
4698
4789
|
if (Number(result.changes ?? 0) === 0) {
|
|
4699
|
-
throw new
|
|
4790
|
+
throw new AffinityConflictError(
|
|
4791
|
+
`Commitment #${commitmentEventId} is already resolved (${open.resolution})`
|
|
4792
|
+
);
|
|
4700
4793
|
}
|
|
4701
4794
|
const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
|
|
4702
4795
|
const created = [];
|
|
@@ -4759,6 +4852,82 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
|
|
|
4759
4852
|
});
|
|
4760
4853
|
}
|
|
4761
4854
|
|
|
4855
|
+
// src/contacts/export_contact.ts
|
|
4856
|
+
function exportContact(db, id) {
|
|
4857
|
+
const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
|
|
4858
|
+
const result = {};
|
|
4859
|
+
for (const [key, value] of Object.entries(row)) {
|
|
4860
|
+
const name = key.replace(
|
|
4861
|
+
/_([a-z])/g,
|
|
4862
|
+
(_, letter) => letter.toUpperCase()
|
|
4863
|
+
);
|
|
4864
|
+
result[name] = value;
|
|
4865
|
+
if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
|
|
4866
|
+
result[`${name}Iso`] = new Date(value).toISOString();
|
|
4867
|
+
if (key.endsWith("_json") && typeof value === "string")
|
|
4868
|
+
result[name] = JSON.parse(value);
|
|
4869
|
+
}
|
|
4870
|
+
return result;
|
|
4871
|
+
});
|
|
4872
|
+
const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
|
|
4873
|
+
if (!contact) throw new AffinityNotFoundError("contact not found");
|
|
4874
|
+
const events = query(
|
|
4875
|
+
"SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
|
|
4876
|
+
id
|
|
4877
|
+
).map((event) => ({
|
|
4878
|
+
...event,
|
|
4879
|
+
participants: query(
|
|
4880
|
+
"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",
|
|
4881
|
+
event.id
|
|
4882
|
+
)
|
|
4883
|
+
}));
|
|
4884
|
+
return {
|
|
4885
|
+
contact,
|
|
4886
|
+
identities: query(
|
|
4887
|
+
"SELECT * FROM identities WHERE contact_id = ? ORDER BY id",
|
|
4888
|
+
id
|
|
4889
|
+
),
|
|
4890
|
+
attributes: query(
|
|
4891
|
+
"SELECT * FROM attributes WHERE contact_id = ? OR link_id IN (SELECT id FROM links WHERE from_contact_id = ? OR to_contact_id = ?) ORDER BY id",
|
|
4892
|
+
id,
|
|
4893
|
+
id,
|
|
4894
|
+
id
|
|
4895
|
+
),
|
|
4896
|
+
links: query(
|
|
4897
|
+
"SELECT l.*, f.name AS from_contact_name, t.name AS to_contact_name FROM links l JOIN contacts f ON f.id = l.from_contact_id JOIN contacts t ON t.id = l.to_contact_id WHERE from_contact_id = ? OR to_contact_id = ? ORDER BY l.id",
|
|
4898
|
+
id,
|
|
4899
|
+
id
|
|
4900
|
+
),
|
|
4901
|
+
events,
|
|
4902
|
+
commitments: query(
|
|
4903
|
+
"SELECT o.* FROM open_commitments o JOIN event_participants p ON p.event_id = o.event_id WHERE p.contact_id = ? ORDER BY o.event_id",
|
|
4904
|
+
id
|
|
4905
|
+
),
|
|
4906
|
+
dates: query(
|
|
4907
|
+
"SELECT * FROM events WHERE anchor_contact_id = ? OR anchor_link_id IN (SELECT id FROM links WHERE from_contact_id = ? OR to_contact_id = ?) ORDER BY id",
|
|
4908
|
+
id,
|
|
4909
|
+
id,
|
|
4910
|
+
id
|
|
4911
|
+
),
|
|
4912
|
+
mergeHistory: query(
|
|
4913
|
+
"SELECT * FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ? ORDER BY id",
|
|
4914
|
+
id,
|
|
4915
|
+
id
|
|
4916
|
+
)
|
|
4917
|
+
};
|
|
4918
|
+
}
|
|
4919
|
+
|
|
4920
|
+
// src/contacts/export_affinity.ts
|
|
4921
|
+
function exportAffinity(db) {
|
|
4922
|
+
const now = Date.now();
|
|
4923
|
+
return {
|
|
4924
|
+
version: 1,
|
|
4925
|
+
exportedAt: now,
|
|
4926
|
+
exportedAtIso: new Date(now).toISOString(),
|
|
4927
|
+
contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
|
|
4928
|
+
};
|
|
4929
|
+
}
|
|
4930
|
+
|
|
4762
4931
|
// src/soul.ts
|
|
4763
4932
|
var soul_exports = {};
|
|
4764
4933
|
__export(soul_exports, {
|
|
@@ -5526,6 +5695,7 @@ __export(tools_exports, {
|
|
|
5526
5695
|
numberSchema: () => numberSchema,
|
|
5527
5696
|
objectSchema: () => objectSchema,
|
|
5528
5697
|
oneOfSchema: () => oneOfSchema,
|
|
5698
|
+
parseTimeInput: () => parseTimeInput,
|
|
5529
5699
|
recordEventTool: () => recordEventTool,
|
|
5530
5700
|
recordEventToolHandler: () => recordEventToolHandler,
|
|
5531
5701
|
recordEventToolName: () => recordEventToolName,
|
|
@@ -5559,8 +5729,55 @@ __export(tools_exports, {
|
|
|
5559
5729
|
withToolHandling: () => withToolHandling
|
|
5560
5730
|
});
|
|
5561
5731
|
|
|
5732
|
+
// src/tools/parse_time_input.ts
|
|
5733
|
+
var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
|
|
5734
|
+
function parseTimeInput(value, field = "time", now = Date.now()) {
|
|
5735
|
+
const fail = () => {
|
|
5736
|
+
throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
|
|
5737
|
+
};
|
|
5738
|
+
if (typeof value === "number")
|
|
5739
|
+
return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
|
|
5740
|
+
if (typeof value !== "string") return fail();
|
|
5741
|
+
const text = value.trim();
|
|
5742
|
+
const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
|
|
5743
|
+
let day;
|
|
5744
|
+
if (relative) {
|
|
5745
|
+
const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
|
|
5746
|
+
day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
|
|
5747
|
+
} else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
|
5748
|
+
day = Date.parse(`${text}T00:00:00.000Z`);
|
|
5749
|
+
if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
|
|
5750
|
+
return fail();
|
|
5751
|
+
} else {
|
|
5752
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
5753
|
+
text
|
|
5754
|
+
))
|
|
5755
|
+
return fail();
|
|
5756
|
+
const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
|
|
5757
|
+
if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
|
|
5758
|
+
return fail();
|
|
5759
|
+
const parsed = Date.parse(text);
|
|
5760
|
+
return Number.isFinite(parsed) ? parsed : fail();
|
|
5761
|
+
}
|
|
5762
|
+
const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
|
|
5763
|
+
return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
|
|
5764
|
+
}
|
|
5765
|
+
|
|
5562
5766
|
// src/tools/tool_metadata.ts
|
|
5563
5767
|
function defineAffinityTool(definition) {
|
|
5768
|
+
const visit = (schema) => {
|
|
5769
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
5770
|
+
if ((key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.type === "integer") {
|
|
5771
|
+
child.type = void 0;
|
|
5772
|
+
child.oneOf = [{ type: "integer" }, { type: "string" }];
|
|
5773
|
+
child.description = `${child.description ?? "Time."} Accepts ${timeInputForms}. UTC midnight for dates; dueAt uses UTC end of day.`;
|
|
5774
|
+
}
|
|
5775
|
+
visit(child);
|
|
5776
|
+
}
|
|
5777
|
+
if (schema.items) visit(schema.items);
|
|
5778
|
+
for (const child of schema.oneOf ?? schema.anyOf ?? []) visit(child);
|
|
5779
|
+
};
|
|
5780
|
+
visit(definition.inputSchema);
|
|
5564
5781
|
return definition;
|
|
5565
5782
|
}
|
|
5566
5783
|
function stringSchema(description, options = {}) {
|
|
@@ -5659,6 +5876,7 @@ function toContactEntityRef(value) {
|
|
|
5659
5876
|
function toContactListToolItem(value) {
|
|
5660
5877
|
return {
|
|
5661
5878
|
...toContactEntityRef(value),
|
|
5879
|
+
..."matchedOn" in value ? { matchedOn: value.matchedOn } : {},
|
|
5662
5880
|
subtitle: "kind" in value ? value.kind : void 0
|
|
5663
5881
|
};
|
|
5664
5882
|
}
|
|
@@ -5705,7 +5923,9 @@ function toEventEntityRef(value) {
|
|
|
5705
5923
|
}
|
|
5706
5924
|
function toEventListToolItem(value) {
|
|
5707
5925
|
return {
|
|
5926
|
+
...value,
|
|
5708
5927
|
...toEventEntityRef(value),
|
|
5928
|
+
occurredAtIso: new Date(value.occurredAt).toISOString(),
|
|
5709
5929
|
snippet: `${value.participants.length} participant(s)`
|
|
5710
5930
|
};
|
|
5711
5931
|
}
|
|
@@ -5762,7 +5982,7 @@ function withOptionalFields(result, options) {
|
|
|
5762
5982
|
if (options.warnings && options.warnings.length > 0) {
|
|
5763
5983
|
result.warnings = options.warnings;
|
|
5764
5984
|
}
|
|
5765
|
-
return result;
|
|
5985
|
+
return roundToolValues(result);
|
|
5766
5986
|
}
|
|
5767
5987
|
function toolWarning(code, message) {
|
|
5768
5988
|
return { code, message };
|
|
@@ -5826,9 +6046,31 @@ function toolFailure(kind, code, summary, message, options = {}) {
|
|
|
5826
6046
|
options
|
|
5827
6047
|
);
|
|
5828
6048
|
}
|
|
6049
|
+
function roundToolValues(value) {
|
|
6050
|
+
if (typeof value === "number")
|
|
6051
|
+
return Number.isInteger(value) ? value : Math.round(value * 1e3) / 1e3;
|
|
6052
|
+
if (Array.isArray(value)) return value.map(roundToolValues);
|
|
6053
|
+
if (value !== null && typeof value === "object")
|
|
6054
|
+
return Object.fromEntries(
|
|
6055
|
+
Object.entries(value).map(([key, child]) => [
|
|
6056
|
+
key,
|
|
6057
|
+
roundToolValues(child)
|
|
6058
|
+
])
|
|
6059
|
+
);
|
|
6060
|
+
return value;
|
|
6061
|
+
}
|
|
5829
6062
|
|
|
5830
6063
|
// src/tools/tool_resolvers.ts
|
|
5831
|
-
function resolveContactLocator(db, locator, field = "contact") {
|
|
6064
|
+
function resolveContactLocator(db, locator, field = "contact", allowMerged = false) {
|
|
6065
|
+
if (!locator || typeof locator !== "object")
|
|
6066
|
+
return {
|
|
6067
|
+
ok: false,
|
|
6068
|
+
result: toolNeedsClarification(
|
|
6069
|
+
"missing_required_choice",
|
|
6070
|
+
`Provide ${field} locator.`,
|
|
6071
|
+
[field]
|
|
6072
|
+
)
|
|
6073
|
+
};
|
|
5832
6074
|
if ("contactId" in locator) {
|
|
5833
6075
|
const row2 = getContactRowById(db, locator.contactId);
|
|
5834
6076
|
if (!row2) {
|
|
@@ -5842,8 +6084,25 @@ function resolveContactLocator(db, locator, field = "contact") {
|
|
|
5842
6084
|
)
|
|
5843
6085
|
};
|
|
5844
6086
|
}
|
|
6087
|
+
if (!allowMerged && row2.merged_into_contact_id !== null) {
|
|
6088
|
+
const winner = getContactRowById(db, row2.merged_into_contact_id);
|
|
6089
|
+
const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
|
|
6090
|
+
return {
|
|
6091
|
+
ok: false,
|
|
6092
|
+
result: toolFailure("domain", "invalid_state", message, message)
|
|
6093
|
+
};
|
|
6094
|
+
}
|
|
5845
6095
|
return { ok: true, value: row2 };
|
|
5846
6096
|
}
|
|
6097
|
+
if (!("identity" in locator) || !locator.identity)
|
|
6098
|
+
return {
|
|
6099
|
+
ok: false,
|
|
6100
|
+
result: toolNeedsClarification(
|
|
6101
|
+
"missing_required_choice",
|
|
6102
|
+
`Provide ${field}.contactId or identity.`,
|
|
6103
|
+
[field]
|
|
6104
|
+
)
|
|
6105
|
+
};
|
|
5847
6106
|
const normalizedKey = normalizeIdentityKey(
|
|
5848
6107
|
locator.identity.type,
|
|
5849
6108
|
locator.identity.value
|
|
@@ -5901,6 +6160,15 @@ function resolveContactLocator(db, locator, field = "contact") {
|
|
|
5901
6160
|
return { ok: true, value: matchedContact };
|
|
5902
6161
|
}
|
|
5903
6162
|
function resolveLinkLocator(db, locator, field = "link") {
|
|
6163
|
+
if (!locator || typeof locator !== "object")
|
|
6164
|
+
return {
|
|
6165
|
+
ok: false,
|
|
6166
|
+
result: toolNeedsClarification(
|
|
6167
|
+
"missing_required_choice",
|
|
6168
|
+
`Provide ${field} locator.`,
|
|
6169
|
+
[field]
|
|
6170
|
+
)
|
|
6171
|
+
};
|
|
5904
6172
|
if ("linkId" in locator) {
|
|
5905
6173
|
const row = getLinkRowById(db, locator.linkId);
|
|
5906
6174
|
if (!row) {
|
|
@@ -5916,6 +6184,15 @@ function resolveLinkLocator(db, locator, field = "link") {
|
|
|
5916
6184
|
}
|
|
5917
6185
|
return { ok: true, value: row };
|
|
5918
6186
|
}
|
|
6187
|
+
if (!("endpoints" in locator) || !locator.endpoints)
|
|
6188
|
+
return {
|
|
6189
|
+
ok: false,
|
|
6190
|
+
result: toolNeedsClarification(
|
|
6191
|
+
"missing_required_choice",
|
|
6192
|
+
`Provide ${field}.linkId or endpoints.`,
|
|
6193
|
+
[field]
|
|
6194
|
+
)
|
|
6195
|
+
};
|
|
5919
6196
|
const { endpoints } = locator;
|
|
5920
6197
|
const clauses = [
|
|
5921
6198
|
"removed_at IS NULL",
|
|
@@ -6034,6 +6311,92 @@ function affinityErrorCodeToToolCode(error) {
|
|
|
6034
6311
|
}
|
|
6035
6312
|
}
|
|
6036
6313
|
|
|
6314
|
+
// src/tools/validate_tool_input.ts
|
|
6315
|
+
function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
|
|
6316
|
+
if (schema.anyOf || schema.oneOf) {
|
|
6317
|
+
const choices = schema.anyOf ?? schema.oneOf ?? [];
|
|
6318
|
+
return choices.some(
|
|
6319
|
+
(choice) => validateToolInput(choice, value, path, injectedNow) === null
|
|
6320
|
+
) ? null : `${path}: invalid value`;
|
|
6321
|
+
}
|
|
6322
|
+
if (schema.type === "object") {
|
|
6323
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
6324
|
+
return `${path}: missing input or expected object`;
|
|
6325
|
+
const object = value;
|
|
6326
|
+
if (path.endsWith(".patch") && schema.additionalProperties === false) {
|
|
6327
|
+
for (const key of Object.keys(object)) {
|
|
6328
|
+
if (!Object.hasOwn(schema.properties ?? {}, key))
|
|
6329
|
+
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(", ")}`;
|
|
6330
|
+
}
|
|
6331
|
+
}
|
|
6332
|
+
if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
|
|
6333
|
+
return `${path}.patch: missing required field`;
|
|
6334
|
+
if (object.patch !== void 0 && !schema.properties?.patch)
|
|
6335
|
+
return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
|
|
6336
|
+
const referenceNow = object.now === void 0 ? injectedNow : (() => {
|
|
6337
|
+
try {
|
|
6338
|
+
return parseTimeInput(
|
|
6339
|
+
object.now,
|
|
6340
|
+
`${path}.now`,
|
|
6341
|
+
injectedNow
|
|
6342
|
+
);
|
|
6343
|
+
} catch {
|
|
6344
|
+
return Date.now();
|
|
6345
|
+
}
|
|
6346
|
+
})();
|
|
6347
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
6348
|
+
if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
|
|
6349
|
+
try {
|
|
6350
|
+
object[key] = parseTimeInput(
|
|
6351
|
+
object[key],
|
|
6352
|
+
`${path}.${key}`,
|
|
6353
|
+
referenceNow
|
|
6354
|
+
);
|
|
6355
|
+
} catch (error) {
|
|
6356
|
+
return error.message;
|
|
6357
|
+
}
|
|
6358
|
+
}
|
|
6359
|
+
}
|
|
6360
|
+
for (const key of schema.required ?? []) {
|
|
6361
|
+
if (object[key] === void 0)
|
|
6362
|
+
return `${path}.${key}: missing required field`;
|
|
6363
|
+
}
|
|
6364
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
6365
|
+
if (object[key] === void 0) continue;
|
|
6366
|
+
const error = validateToolInput(
|
|
6367
|
+
child,
|
|
6368
|
+
object[key],
|
|
6369
|
+
`${path}.${key}`,
|
|
6370
|
+
referenceNow
|
|
6371
|
+
);
|
|
6372
|
+
if (error) return error;
|
|
6373
|
+
}
|
|
6374
|
+
return null;
|
|
6375
|
+
}
|
|
6376
|
+
if (schema.type === "array") {
|
|
6377
|
+
if (!Array.isArray(value)) return `${path}: expected array`;
|
|
6378
|
+
for (const [index, item] of value.entries()) {
|
|
6379
|
+
const error = validateToolInput(
|
|
6380
|
+
schema.items ?? {},
|
|
6381
|
+
item,
|
|
6382
|
+
`${path}[${index}]`,
|
|
6383
|
+
injectedNow
|
|
6384
|
+
);
|
|
6385
|
+
if (error) return error;
|
|
6386
|
+
}
|
|
6387
|
+
return null;
|
|
6388
|
+
}
|
|
6389
|
+
if (schema.enum && !schema.enum.includes(value))
|
|
6390
|
+
return `${path}: expected ${schema.enum.join(", ")}`;
|
|
6391
|
+
if (schema.type === "null")
|
|
6392
|
+
return value === null ? null : `${path}: expected null`;
|
|
6393
|
+
if (schema.type === "integer")
|
|
6394
|
+
return Number.isInteger(value) ? null : `${path}: expected integer`;
|
|
6395
|
+
const valid = schema.type === void 0 || schema.type === "string" && typeof value === "string" || schema.type === "number" && typeof value === "number" && Number.isFinite(value) || schema.type === "boolean" && typeof value === "boolean";
|
|
6396
|
+
if (!valid) return `${path}: expected ${schema.type}`;
|
|
6397
|
+
return null;
|
|
6398
|
+
}
|
|
6399
|
+
|
|
6037
6400
|
// src/tools/inspect_affinity_item_tool.ts
|
|
6038
6401
|
function contactLocatorSchema(description) {
|
|
6039
6402
|
return {
|
|
@@ -6069,7 +6432,19 @@ function linkLocatorSchema(description) {
|
|
|
6069
6432
|
description
|
|
6070
6433
|
};
|
|
6071
6434
|
}
|
|
6072
|
-
function inspectAffinityItemToolHandler(db,
|
|
6435
|
+
function inspectAffinityItemToolHandler(db, rawInput) {
|
|
6436
|
+
const input = structuredClone(rawInput);
|
|
6437
|
+
const inputError = validateToolInput(
|
|
6438
|
+
inspectAffinityItemTool.inputSchema,
|
|
6439
|
+
input
|
|
6440
|
+
);
|
|
6441
|
+
if (inputError)
|
|
6442
|
+
return toolFailure(
|
|
6443
|
+
"protocol",
|
|
6444
|
+
"invalid_input",
|
|
6445
|
+
"Invalid input.",
|
|
6446
|
+
inputError
|
|
6447
|
+
);
|
|
6073
6448
|
return withToolHandling(() => {
|
|
6074
6449
|
if (input.kind === "owner_profile") {
|
|
6075
6450
|
const detail2 = getOwnerProfile(
|
|
@@ -6087,19 +6462,21 @@ function inspectAffinityItemToolHandler(db, input) {
|
|
|
6087
6462
|
return toolSuccess(
|
|
6088
6463
|
`Loaded owner profile for ${detail2.contact.name}.`,
|
|
6089
6464
|
{ kind: input.kind, detail: detail2 },
|
|
6090
|
-
{
|
|
6465
|
+
{
|
|
6466
|
+
entities: [toContactEntityRef(detail2.contact)],
|
|
6467
|
+
warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
|
|
6468
|
+
}
|
|
6091
6469
|
);
|
|
6092
6470
|
}
|
|
6093
6471
|
if (input.kind === "contact_profile") {
|
|
6094
|
-
const contact = resolveContactLocator(db, input.contact, "contact");
|
|
6472
|
+
const contact = resolveContactLocator(db, input.contact, "contact", true);
|
|
6095
6473
|
if (!contact.ok) {
|
|
6096
6474
|
return contact.result;
|
|
6097
6475
|
}
|
|
6098
|
-
const detail2 = getContactProfile(
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
);
|
|
6476
|
+
const detail2 = getContactProfile(db, contact.value.id, {
|
|
6477
|
+
...input.topLinksLimit === void 0 ? {} : { topLinksLimit: input.topLinksLimit },
|
|
6478
|
+
...input.recentEventsLimit === void 0 ? {} : { recentEventsLimit: input.recentEventsLimit }
|
|
6479
|
+
});
|
|
6103
6480
|
if (!detail2) {
|
|
6104
6481
|
return toolFailure(
|
|
6105
6482
|
"domain",
|
|
@@ -6111,7 +6488,10 @@ function inspectAffinityItemToolHandler(db, input) {
|
|
|
6111
6488
|
return toolSuccess(
|
|
6112
6489
|
`Loaded contact profile for ${detail2.contact.name}.`,
|
|
6113
6490
|
{ kind: input.kind, detail: detail2 },
|
|
6114
|
-
{
|
|
6491
|
+
{
|
|
6492
|
+
entities: [toContactEntityRef(detail2.contact)],
|
|
6493
|
+
warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
|
|
6494
|
+
}
|
|
6115
6495
|
);
|
|
6116
6496
|
}
|
|
6117
6497
|
const link = resolveLinkLocator(db, input.link, "link");
|
|
@@ -6451,21 +6831,18 @@ function unsetAttribute(db, target, name, removedAt) {
|
|
|
6451
6831
|
});
|
|
6452
6832
|
}
|
|
6453
6833
|
|
|
6454
|
-
// src/tools/tool_summary.ts
|
|
6455
|
-
function summarizeCount(count, singular) {
|
|
6456
|
-
if (count === 0) {
|
|
6457
|
-
return `No ${singular}s found.`;
|
|
6458
|
-
}
|
|
6459
|
-
if (count === 1) {
|
|
6460
|
-
return `Found 1 ${singular}.`;
|
|
6461
|
-
}
|
|
6462
|
-
return `Found ${count} ${singular}s.`;
|
|
6463
|
-
}
|
|
6464
|
-
|
|
6465
6834
|
// src/tools/tool_mutation.ts
|
|
6466
|
-
function mutationToolResult(action, receipt, entitySummary) {
|
|
6835
|
+
function mutationToolResult(action, receipt, entitySummary, fields) {
|
|
6467
6836
|
const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
|
|
6468
6837
|
const data = {
|
|
6838
|
+
changes: ["created", "updated", "archived", "removed"].flatMap(
|
|
6839
|
+
(verb) => receipt[verb].map((ref) => ({
|
|
6840
|
+
entity: ref.kind,
|
|
6841
|
+
id: ref.id,
|
|
6842
|
+
action: verb,
|
|
6843
|
+
...verb === "updated" && fields ? { fields } : {}
|
|
6844
|
+
}))
|
|
6845
|
+
),
|
|
6469
6846
|
action,
|
|
6470
6847
|
primary: receipt.primary,
|
|
6471
6848
|
created: receipt.created,
|
|
@@ -6475,7 +6852,7 @@ function mutationToolResult(action, receipt, entitySummary) {
|
|
|
6475
6852
|
affectedLinks: receipt.affectedLinks,
|
|
6476
6853
|
derivedEffects: receipt.derivedEffects
|
|
6477
6854
|
};
|
|
6478
|
-
const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` :
|
|
6855
|
+
const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
|
|
6479
6856
|
if (changedCount === 0) {
|
|
6480
6857
|
return toolNoOp(summary, data, {
|
|
6481
6858
|
entities: toReceiptEntityRefs(receipt)
|
|
@@ -6485,6 +6862,23 @@ function mutationToolResult(action, receipt, entitySummary) {
|
|
|
6485
6862
|
entities: toReceiptEntityRefs(receipt)
|
|
6486
6863
|
});
|
|
6487
6864
|
}
|
|
6865
|
+
var PLURALS = { identity: "identities" };
|
|
6866
|
+
function describeChanges(receipt) {
|
|
6867
|
+
const parts = [];
|
|
6868
|
+
for (const verb of ["created", "updated", "archived", "removed"]) {
|
|
6869
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6870
|
+
for (const ref of receipt[verb]) {
|
|
6871
|
+
counts.set(ref.kind, (counts.get(ref.kind) ?? 0) + 1);
|
|
6872
|
+
}
|
|
6873
|
+
if (counts.size === 0) continue;
|
|
6874
|
+
const items = [...counts].map(
|
|
6875
|
+
([kind, n]) => `${n} ${n === 1 ? kind : PLURALS[kind] ?? `${kind}s`}`
|
|
6876
|
+
);
|
|
6877
|
+
parts.push(`${verb} ${items.join(", ")}`);
|
|
6878
|
+
}
|
|
6879
|
+
const text = parts.join("; ");
|
|
6880
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
|
|
6881
|
+
}
|
|
6488
6882
|
|
|
6489
6883
|
// src/tools/manage_attribute_tool.ts
|
|
6490
6884
|
function contactLocatorSchema2(description) {
|
|
@@ -6535,7 +6929,23 @@ function resolveAttributeTarget(db, target) {
|
|
|
6535
6929
|
}
|
|
6536
6930
|
return { kind: "ok", target: { kind: "link", id: link.value.id } };
|
|
6537
6931
|
}
|
|
6538
|
-
function manageAttributeToolHandler(db,
|
|
6932
|
+
function manageAttributeToolHandler(db, rawInput) {
|
|
6933
|
+
const input = structuredClone(rawInput);
|
|
6934
|
+
const inputError = validateToolInput(manageAttributeTool.inputSchema, input);
|
|
6935
|
+
if (inputError)
|
|
6936
|
+
return toolFailure(
|
|
6937
|
+
"protocol",
|
|
6938
|
+
"invalid_input",
|
|
6939
|
+
"Invalid input.",
|
|
6940
|
+
inputError
|
|
6941
|
+
);
|
|
6942
|
+
if (!input.target)
|
|
6943
|
+
return toolFailure(
|
|
6944
|
+
"protocol",
|
|
6945
|
+
"invalid_input",
|
|
6946
|
+
"Missing target.",
|
|
6947
|
+
"target is required."
|
|
6948
|
+
);
|
|
6539
6949
|
return withToolHandling(() => {
|
|
6540
6950
|
const resolved = resolveAttributeTarget(db, input.target);
|
|
6541
6951
|
if (resolved.kind === "error") {
|
|
@@ -6646,9 +7056,25 @@ function ensureOwnerParticipates(db, participants) {
|
|
|
6646
7056
|
function prepareParticipants(db, raw) {
|
|
6647
7057
|
return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
|
|
6648
7058
|
}
|
|
6649
|
-
function manageCommitmentToolHandler(db,
|
|
7059
|
+
function manageCommitmentToolHandler(db, rawInput) {
|
|
7060
|
+
const input = structuredClone(rawInput);
|
|
7061
|
+
const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
|
|
7062
|
+
if (inputError)
|
|
7063
|
+
return toolFailure(
|
|
7064
|
+
"protocol",
|
|
7065
|
+
"invalid_input",
|
|
7066
|
+
"Invalid input.",
|
|
7067
|
+
inputError
|
|
7068
|
+
);
|
|
6650
7069
|
return withToolHandling(() => {
|
|
6651
7070
|
if (input.action === "record") {
|
|
7071
|
+
if (!input.input)
|
|
7072
|
+
return toolFailure(
|
|
7073
|
+
"protocol",
|
|
7074
|
+
"invalid_input",
|
|
7075
|
+
"Missing input.",
|
|
7076
|
+
"input is required for record."
|
|
7077
|
+
);
|
|
6652
7078
|
const patched = {
|
|
6653
7079
|
...input.input,
|
|
6654
7080
|
participants: prepareParticipants(
|
|
@@ -6715,7 +7141,7 @@ var manageCommitmentTool = defineAffinityTool({
|
|
|
6715
7141
|
role: stringSchema("Participant role."),
|
|
6716
7142
|
directionality: stringSchema("Optional directionality.")
|
|
6717
7143
|
},
|
|
6718
|
-
["
|
|
7144
|
+
["role"]
|
|
6719
7145
|
),
|
|
6720
7146
|
"Participant list. Must be non-empty and include the owner contact."
|
|
6721
7147
|
),
|
|
@@ -6739,28 +7165,282 @@ var manageCommitmentTool = defineAffinityTool({
|
|
|
6739
7165
|
),
|
|
6740
7166
|
options: objectSchema(
|
|
6741
7167
|
{
|
|
6742
|
-
now: integerSchema("Optional timestamp."),
|
|
6743
|
-
resolvingEvent: objectSchema(
|
|
6744
|
-
{
|
|
6745
|
-
type: stringSchema("Optional resolving event type."),
|
|
6746
|
-
occurredAt: integerSchema("Optional resolving event timestamp."),
|
|
6747
|
-
summary: stringSchema("Optional resolving event summary."),
|
|
6748
|
-
significance: integerSchema(
|
|
6749
|
-
"Optional resolving event significance."
|
|
6750
|
-
)
|
|
6751
|
-
},
|
|
6752
|
-
[]
|
|
6753
|
-
)
|
|
6754
|
-
},
|
|
6755
|
-
[],
|
|
6756
|
-
"Optional metadata."
|
|
6757
|
-
)
|
|
6758
|
-
},
|
|
6759
|
-
["action"],
|
|
6760
|
-
"Record or resolve one commitment."
|
|
6761
|
-
),
|
|
6762
|
-
handler: manageCommitmentToolHandler
|
|
6763
|
-
});
|
|
7168
|
+
now: integerSchema("Optional timestamp."),
|
|
7169
|
+
resolvingEvent: objectSchema(
|
|
7170
|
+
{
|
|
7171
|
+
type: stringSchema("Optional resolving event type."),
|
|
7172
|
+
occurredAt: integerSchema("Optional resolving event timestamp."),
|
|
7173
|
+
summary: stringSchema("Optional resolving event summary."),
|
|
7174
|
+
significance: integerSchema(
|
|
7175
|
+
"Optional resolving event significance."
|
|
7176
|
+
)
|
|
7177
|
+
},
|
|
7178
|
+
[]
|
|
7179
|
+
)
|
|
7180
|
+
},
|
|
7181
|
+
[],
|
|
7182
|
+
"Optional metadata."
|
|
7183
|
+
)
|
|
7184
|
+
},
|
|
7185
|
+
["action"],
|
|
7186
|
+
"Record or resolve one commitment."
|
|
7187
|
+
),
|
|
7188
|
+
handler: manageCommitmentToolHandler
|
|
7189
|
+
});
|
|
7190
|
+
|
|
7191
|
+
// src/contacts/delete_contact.ts
|
|
7192
|
+
function deleteContact(db, locatorId, options) {
|
|
7193
|
+
if (options?.confirm !== true)
|
|
7194
|
+
throw new AffinityValidationError(
|
|
7195
|
+
"delete is permanent; pass confirm: true"
|
|
7196
|
+
);
|
|
7197
|
+
return withTransaction(db, () => {
|
|
7198
|
+
const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
|
|
7199
|
+
if (!contact) throw new AffinityNotFoundError("contact not found");
|
|
7200
|
+
if (contact.is_owner)
|
|
7201
|
+
throw new AffinityStateError("The owner contact cannot be deleted");
|
|
7202
|
+
const tables = [
|
|
7203
|
+
"contacts",
|
|
7204
|
+
"identities",
|
|
7205
|
+
"attributes",
|
|
7206
|
+
"links",
|
|
7207
|
+
"events",
|
|
7208
|
+
"event_participants",
|
|
7209
|
+
"open_commitments",
|
|
7210
|
+
"contact_merges",
|
|
7211
|
+
"dismissed_duplicates",
|
|
7212
|
+
"link_event_effects",
|
|
7213
|
+
"upcoming_occurrences",
|
|
7214
|
+
"link_rollups",
|
|
7215
|
+
"contact_rollups"
|
|
7216
|
+
];
|
|
7217
|
+
const before = new Map(
|
|
7218
|
+
tables.map((table) => [
|
|
7219
|
+
table,
|
|
7220
|
+
db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
|
|
7221
|
+
])
|
|
7222
|
+
);
|
|
7223
|
+
db.prepare(
|
|
7224
|
+
"DELETE FROM events WHERE id IN (SELECT p.event_id FROM event_participants p WHERE p.contact_id = ? AND NOT EXISTS (SELECT 1 FROM event_participants q JOIN contacts c ON c.id = q.contact_id WHERE q.event_id = p.event_id AND q.contact_id != ? AND c.is_owner = 0))"
|
|
7225
|
+
).run(locatorId, locatorId);
|
|
7226
|
+
db.prepare(
|
|
7227
|
+
"DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
|
|
7228
|
+
).run(locatorId, locatorId);
|
|
7229
|
+
db.prepare(
|
|
7230
|
+
"DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
|
|
7231
|
+
).run(locatorId, locatorId);
|
|
7232
|
+
db.prepare(
|
|
7233
|
+
"UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
|
|
7234
|
+
).run(locatorId);
|
|
7235
|
+
db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
|
|
7236
|
+
const now = Date.now();
|
|
7237
|
+
const affectedLinks = db.prepare(
|
|
7238
|
+
"SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
|
|
7239
|
+
).all().map((row) => row.id);
|
|
7240
|
+
refreshAllBridgeScores(db, now);
|
|
7241
|
+
for (const id of affectedLinks) refreshLinkRollup(db, id, now);
|
|
7242
|
+
for (const row of db.prepare("SELECT id FROM contacts").all())
|
|
7243
|
+
refreshContactRollup(db, Number(row.id), now);
|
|
7244
|
+
const removed = tables.map((table) => {
|
|
7245
|
+
const remaining = new Set(
|
|
7246
|
+
db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
|
|
7247
|
+
);
|
|
7248
|
+
const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
|
|
7249
|
+
return { entity: table, count: ids.length, ids };
|
|
7250
|
+
}).filter((item) => item.count > 0);
|
|
7251
|
+
return { contactId: locatorId, removed, affectedLinks };
|
|
7252
|
+
});
|
|
7253
|
+
}
|
|
7254
|
+
|
|
7255
|
+
// src/identities/queries.ts
|
|
7256
|
+
function getIdentityRowById(db, id) {
|
|
7257
|
+
const row = db.prepare(
|
|
7258
|
+
`SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
|
|
7259
|
+
created_at, updated_at, removed_at
|
|
7260
|
+
FROM identities
|
|
7261
|
+
WHERE id = ? AND removed_at IS NULL`
|
|
7262
|
+
).get(id);
|
|
7263
|
+
if (row === void 0) {
|
|
7264
|
+
return null;
|
|
7265
|
+
}
|
|
7266
|
+
return row;
|
|
7267
|
+
}
|
|
7268
|
+
|
|
7269
|
+
// src/identities/receipts.ts
|
|
7270
|
+
function buildIdentityMutationReceipt(primary, partial) {
|
|
7271
|
+
const normalized = normalizeMutationReceiptPartial(partial);
|
|
7272
|
+
return {
|
|
7273
|
+
primary,
|
|
7274
|
+
created: normalized.created,
|
|
7275
|
+
updated: normalized.updated,
|
|
7276
|
+
archived: normalized.archived,
|
|
7277
|
+
removed: normalized.removed,
|
|
7278
|
+
affectedLinks: normalized.affectedLinks,
|
|
7279
|
+
derivedEffects: normalized.derivedEffects
|
|
7280
|
+
};
|
|
7281
|
+
}
|
|
7282
|
+
|
|
7283
|
+
// src/identities/validators.ts
|
|
7284
|
+
function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
|
|
7285
|
+
const row = excludeIdentityId === void 0 ? db.prepare(
|
|
7286
|
+
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
|
|
7287
|
+
).get(normalizedKey) : db.prepare(
|
|
7288
|
+
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
|
|
7289
|
+
).get(normalizedKey, excludeIdentityId);
|
|
7290
|
+
if (row !== void 0) {
|
|
7291
|
+
const owner = db.prepare(
|
|
7292
|
+
"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"
|
|
7293
|
+
).get(normalizedKey);
|
|
7294
|
+
throw new AffinityConflictError(
|
|
7295
|
+
`${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
|
|
7296
|
+
);
|
|
7297
|
+
}
|
|
7298
|
+
}
|
|
7299
|
+
|
|
7300
|
+
// src/identities/validate_identity_input.ts
|
|
7301
|
+
var identityTypes = [
|
|
7302
|
+
"email",
|
|
7303
|
+
"phone",
|
|
7304
|
+
"url",
|
|
7305
|
+
"website",
|
|
7306
|
+
"handle",
|
|
7307
|
+
"x",
|
|
7308
|
+
"twitter",
|
|
7309
|
+
"linkedin",
|
|
7310
|
+
"github",
|
|
7311
|
+
"mastodon",
|
|
7312
|
+
"bluesky",
|
|
7313
|
+
"telegram",
|
|
7314
|
+
"whatsapp",
|
|
7315
|
+
"signal",
|
|
7316
|
+
"matrix",
|
|
7317
|
+
"discord",
|
|
7318
|
+
"slack",
|
|
7319
|
+
"instagram",
|
|
7320
|
+
"facebook",
|
|
7321
|
+
"youtube",
|
|
7322
|
+
"tiktok",
|
|
7323
|
+
"address",
|
|
7324
|
+
"other",
|
|
7325
|
+
"account_id",
|
|
7326
|
+
"alias"
|
|
7327
|
+
];
|
|
7328
|
+
function validateIdentityInput(type, value) {
|
|
7329
|
+
const kind = type.toLowerCase();
|
|
7330
|
+
if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
|
|
7331
|
+
throw new AffinityValidationError(
|
|
7332
|
+
`identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
|
|
7333
|
+
);
|
|
7334
|
+
if (!value.trim())
|
|
7335
|
+
throw new AffinityValidationError("identity value must be non-empty");
|
|
7336
|
+
if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
|
|
7337
|
+
throw new AffinityValidationError(
|
|
7338
|
+
"email value must match local@domain.tld"
|
|
7339
|
+
);
|
|
7340
|
+
if (kind === "phone" && value.replace(/\D/g, "").length < 5)
|
|
7341
|
+
throw new AffinityValidationError(
|
|
7342
|
+
"phone value must contain at least 5 digits"
|
|
7343
|
+
);
|
|
7344
|
+
if (kind === "url" || kind === "website") {
|
|
7345
|
+
try {
|
|
7346
|
+
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
|
7347
|
+
if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
|
|
7348
|
+
throw new Error();
|
|
7349
|
+
} catch {
|
|
7350
|
+
throw new AffinityValidationError(
|
|
7351
|
+
"url/website value must be an http/https URL or bare domain"
|
|
7352
|
+
);
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
}
|
|
7356
|
+
|
|
7357
|
+
// src/identities/add_identity.ts
|
|
7358
|
+
function addIdentity(db, contactId, input) {
|
|
7359
|
+
return withTransaction(db, () => {
|
|
7360
|
+
const contact = getContactRowById(db, contactId);
|
|
7361
|
+
if (!contact) {
|
|
7362
|
+
throw new AffinityNotFoundError("contact not found");
|
|
7363
|
+
}
|
|
7364
|
+
if (contact.lifecycle_state === "merged") {
|
|
7365
|
+
throw new AffinityStateError("merged contact is read-only");
|
|
7366
|
+
}
|
|
7367
|
+
const type = input.type.trim();
|
|
7368
|
+
const value = input.value.trim();
|
|
7369
|
+
if (type.length === 0 || value.length === 0) {
|
|
7370
|
+
throw new AffinityValidationError("type and value must be non-empty");
|
|
7371
|
+
}
|
|
7372
|
+
validateIdentityInput(type, value);
|
|
7373
|
+
const nk = normalizeIdentityKey(type, value);
|
|
7374
|
+
assertNoIdentityCollision(db, nk);
|
|
7375
|
+
const now = resolveNow(input.now);
|
|
7376
|
+
const verified = input.verified ? 1 : 0;
|
|
7377
|
+
const verifiedAt = verified === 1 ? now : null;
|
|
7378
|
+
const result = db.prepare(
|
|
7379
|
+
`INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
|
|
7380
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
7381
|
+
).run(
|
|
7382
|
+
contactId,
|
|
7383
|
+
type,
|
|
7384
|
+
value,
|
|
7385
|
+
input.label ?? null,
|
|
7386
|
+
nk,
|
|
7387
|
+
verified,
|
|
7388
|
+
verifiedAt,
|
|
7389
|
+
now,
|
|
7390
|
+
now
|
|
7391
|
+
);
|
|
7392
|
+
const id = Number(result.lastInsertRowid);
|
|
7393
|
+
const row = getIdentityRowById(db, id);
|
|
7394
|
+
if (!row) {
|
|
7395
|
+
throw new AffinityInvariantError("inserted identity not found");
|
|
7396
|
+
}
|
|
7397
|
+
refreshContactRollup(db, contactId, now);
|
|
7398
|
+
db.prepare(
|
|
7399
|
+
"DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
|
|
7400
|
+
).run(contactId, contactId);
|
|
7401
|
+
return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
|
|
7402
|
+
created: [{ kind: "identity", id }]
|
|
7403
|
+
});
|
|
7404
|
+
});
|
|
7405
|
+
}
|
|
7406
|
+
|
|
7407
|
+
// src/tools/create_contact_tool_result.ts
|
|
7408
|
+
function createContactToolResult(db, input) {
|
|
7409
|
+
return withTransaction(db, () => {
|
|
7410
|
+
const receipt = createContact(db, input);
|
|
7411
|
+
const identityOwners = /* @__PURE__ */ new Set();
|
|
7412
|
+
for (const identity of input.identities ?? []) {
|
|
7413
|
+
validateIdentityInput(identity.type.trim(), identity.value.trim());
|
|
7414
|
+
const existing = db.prepare(
|
|
7415
|
+
"SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
|
|
7416
|
+
).get(normalizeIdentityKey(identity.type, identity.value));
|
|
7417
|
+
if (existing) {
|
|
7418
|
+
if (Number(existing.contact_id) !== receipt.primary.id)
|
|
7419
|
+
identityOwners.add(Number(existing.contact_id));
|
|
7420
|
+
continue;
|
|
7421
|
+
}
|
|
7422
|
+
const added = addIdentity(db, receipt.primary.id, identity);
|
|
7423
|
+
receipt.created.push(...added.created);
|
|
7424
|
+
}
|
|
7425
|
+
const result = mutationToolResult("create", receipt, "contact");
|
|
7426
|
+
const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
|
|
7427
|
+
const warnings = db.prepare(
|
|
7428
|
+
"SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
|
|
7429
|
+
).all(receipt.primary.id).flatMap((row) => {
|
|
7430
|
+
const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
|
|
7431
|
+
return score === void 0 ? [] : [
|
|
7432
|
+
{
|
|
7433
|
+
code: "possible_duplicate",
|
|
7434
|
+
message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
|
|
7435
|
+
contactId: row.id,
|
|
7436
|
+
score
|
|
7437
|
+
}
|
|
7438
|
+
];
|
|
7439
|
+
});
|
|
7440
|
+
if (warnings.length) result.warnings = warnings;
|
|
7441
|
+
return result;
|
|
7442
|
+
});
|
|
7443
|
+
}
|
|
6764
7444
|
|
|
6765
7445
|
// src/tools/manage_contact_tool.ts
|
|
6766
7446
|
function contactLocatorSchema3(description) {
|
|
@@ -6780,24 +7460,76 @@ function contactLocatorSchema3(description) {
|
|
|
6780
7460
|
description
|
|
6781
7461
|
};
|
|
6782
7462
|
}
|
|
6783
|
-
function manageContactToolHandler(db,
|
|
7463
|
+
function manageContactToolHandler(db, rawInput) {
|
|
7464
|
+
const input = structuredClone(rawInput);
|
|
7465
|
+
const inputError = validateToolInput(manageContactTool.inputSchema, input);
|
|
7466
|
+
if (inputError)
|
|
7467
|
+
return toolFailure(
|
|
7468
|
+
"protocol",
|
|
7469
|
+
"invalid_input",
|
|
7470
|
+
"Invalid input.",
|
|
7471
|
+
inputError
|
|
7472
|
+
);
|
|
6784
7473
|
return withToolHandling(() => {
|
|
6785
|
-
if (input.action === "
|
|
6786
|
-
|
|
6787
|
-
"
|
|
6788
|
-
|
|
6789
|
-
|
|
7474
|
+
if (input.action === "export") {
|
|
7475
|
+
if (!input.contact)
|
|
7476
|
+
return toolSuccess("Exported Affinity.", {
|
|
7477
|
+
action: "export",
|
|
7478
|
+
export: exportAffinity(db)
|
|
7479
|
+
});
|
|
7480
|
+
const target = resolveContactLocator(db, input.contact, "contact", true);
|
|
7481
|
+
if (!target.ok) return target.result;
|
|
7482
|
+
return toolSuccess("Exported contact.", {
|
|
7483
|
+
action: "export",
|
|
7484
|
+
export: exportContact(db, target.value.id)
|
|
7485
|
+
});
|
|
7486
|
+
}
|
|
7487
|
+
if (input.action === "delete" && input.confirm !== true)
|
|
7488
|
+
return toolFailure(
|
|
7489
|
+
"protocol",
|
|
7490
|
+
"invalid_input",
|
|
7491
|
+
"Delete requires confirmation.",
|
|
7492
|
+
"delete is permanent; pass confirm: true (CLI: --confirm)"
|
|
6790
7493
|
);
|
|
7494
|
+
if (input.action === "create") {
|
|
7495
|
+
if (!input.input)
|
|
7496
|
+
return toolFailure(
|
|
7497
|
+
"protocol",
|
|
7498
|
+
"invalid_input",
|
|
7499
|
+
"Missing input.",
|
|
7500
|
+
"input is required for create."
|
|
7501
|
+
);
|
|
7502
|
+
return createContactToolResult(db, input.input);
|
|
6791
7503
|
}
|
|
6792
|
-
const contact = resolveContactLocator(
|
|
7504
|
+
const contact = resolveContactLocator(
|
|
7505
|
+
db,
|
|
7506
|
+
input.contact,
|
|
7507
|
+
"contact",
|
|
7508
|
+
input.action === "delete"
|
|
7509
|
+
);
|
|
6793
7510
|
if (!contact.ok) {
|
|
6794
7511
|
return contact.result;
|
|
6795
7512
|
}
|
|
7513
|
+
if (input.action === "delete") {
|
|
7514
|
+
const deletion = deleteContact(db, contact.value.id, { confirm: true });
|
|
7515
|
+
return toolSuccess("Permanently deleted contact.", {
|
|
7516
|
+
action: "delete",
|
|
7517
|
+
deletion,
|
|
7518
|
+
changes: deletion.removed.flatMap(
|
|
7519
|
+
(item) => item.ids.map((id) => ({
|
|
7520
|
+
entity: item.entity,
|
|
7521
|
+
id,
|
|
7522
|
+
action: "removed"
|
|
7523
|
+
}))
|
|
7524
|
+
)
|
|
7525
|
+
});
|
|
7526
|
+
}
|
|
6796
7527
|
if (input.action === "revise") {
|
|
6797
7528
|
return mutationToolResult(
|
|
6798
7529
|
"revise",
|
|
6799
7530
|
reviseContact(db, contact.value.id, input.patch),
|
|
6800
|
-
"contact"
|
|
7531
|
+
"contact",
|
|
7532
|
+
Object.keys(input.patch)
|
|
6801
7533
|
);
|
|
6802
7534
|
}
|
|
6803
7535
|
return mutationToolResult(
|
|
@@ -6814,7 +7546,7 @@ function manageContactToolHandler(db, input) {
|
|
|
6814
7546
|
}
|
|
6815
7547
|
var manageContactTool = defineAffinityTool({
|
|
6816
7548
|
name: manageContactToolName,
|
|
6817
|
-
description: "Create a contact
|
|
7549
|
+
description: "Create, revise, export or permanently delete a contact (confirm: true required; owner cannot be deleted). Export without contact exports the whole database. Lifecycle lost is soft delete. ALWAYS search_affinity first before creating \u2014 duplicates corrupt the graph.",
|
|
6818
7550
|
whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
|
|
6819
7551
|
whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
|
|
6820
7552
|
sideEffects: "writes_state",
|
|
@@ -6835,10 +7567,25 @@ var manageContactTool = defineAffinityTool({
|
|
|
6835
7567
|
action: enumSchema("Operation to perform.", [
|
|
6836
7568
|
"create",
|
|
6837
7569
|
"revise",
|
|
6838
|
-
"set_lifecycle"
|
|
7570
|
+
"set_lifecycle",
|
|
7571
|
+
"export",
|
|
7572
|
+
"delete"
|
|
6839
7573
|
]),
|
|
6840
7574
|
input: objectSchema(
|
|
6841
7575
|
{
|
|
7576
|
+
identities: arraySchema(
|
|
7577
|
+
objectSchema(
|
|
7578
|
+
{
|
|
7579
|
+
type: stringSchema("Identity type."),
|
|
7580
|
+
value: stringSchema("Identity value."),
|
|
7581
|
+
label: stringSchema("Optional label."),
|
|
7582
|
+
verified: booleanSchema("Verified identity."),
|
|
7583
|
+
now: integerSchema("Optional timestamp.")
|
|
7584
|
+
},
|
|
7585
|
+
["type", "value"]
|
|
7586
|
+
),
|
|
7587
|
+
"Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
|
|
7588
|
+
),
|
|
6842
7589
|
name: stringSchema("Contact name."),
|
|
6843
7590
|
kind: enumSchema("Contact kind.", [
|
|
6844
7591
|
"human",
|
|
@@ -6857,6 +7604,7 @@ var manageContactTool = defineAffinityTool({
|
|
|
6857
7604
|
["name", "kind"],
|
|
6858
7605
|
"New contact payload. Required when action=create."
|
|
6859
7606
|
),
|
|
7607
|
+
confirm: booleanSchema("Required true for permanent deletion."),
|
|
6860
7608
|
contact: contactLocatorSchema3(
|
|
6861
7609
|
"Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
|
|
6862
7610
|
),
|
|
@@ -6903,7 +7651,19 @@ function contactLocatorSchema4(description) {
|
|
|
6903
7651
|
description
|
|
6904
7652
|
};
|
|
6905
7653
|
}
|
|
6906
|
-
function manageDuplicateDismissalToolHandler(db,
|
|
7654
|
+
function manageDuplicateDismissalToolHandler(db, rawInput) {
|
|
7655
|
+
const input = structuredClone(rawInput);
|
|
7656
|
+
const inputError = validateToolInput(
|
|
7657
|
+
manageDuplicateDismissalTool.inputSchema,
|
|
7658
|
+
input
|
|
7659
|
+
);
|
|
7660
|
+
if (inputError)
|
|
7661
|
+
return toolFailure(
|
|
7662
|
+
"protocol",
|
|
7663
|
+
"invalid_input",
|
|
7664
|
+
"Invalid input.",
|
|
7665
|
+
inputError
|
|
7666
|
+
);
|
|
6907
7667
|
return withToolHandling(() => {
|
|
6908
7668
|
const left = resolveContactLocator(db, input.left, "left");
|
|
6909
7669
|
if (!left.ok) {
|
|
@@ -6913,6 +7673,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
|
|
|
6913
7673
|
if (!right.ok) {
|
|
6914
7674
|
return right.result;
|
|
6915
7675
|
}
|
|
7676
|
+
const leftContactId = Math.min(left.value.id, right.value.id);
|
|
7677
|
+
const rightContactId = Math.max(left.value.id, right.value.id);
|
|
7678
|
+
const existed = db.prepare(
|
|
7679
|
+
"SELECT 1 FROM dismissed_duplicates WHERE left_id = ? AND right_id = ?"
|
|
7680
|
+
).get(leftContactId, rightContactId) !== void 0;
|
|
6916
7681
|
if (input.action === "dismiss") {
|
|
6917
7682
|
const receipt2 = dismissDuplicate(
|
|
6918
7683
|
db,
|
|
@@ -6922,19 +7687,41 @@ function manageDuplicateDismissalToolHandler(db, input) {
|
|
|
6922
7687
|
);
|
|
6923
7688
|
return toolSuccess(
|
|
6924
7689
|
`Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
|
|
6925
|
-
{
|
|
7690
|
+
{
|
|
7691
|
+
...receipt2,
|
|
7692
|
+
action: "dismiss",
|
|
7693
|
+
changes: [
|
|
7694
|
+
{
|
|
7695
|
+
entity: "duplicate_dismissal",
|
|
7696
|
+
leftContactId,
|
|
7697
|
+
rightContactId,
|
|
7698
|
+
action: existed ? "updated" : "created"
|
|
7699
|
+
}
|
|
7700
|
+
]
|
|
7701
|
+
}
|
|
6926
7702
|
);
|
|
6927
7703
|
}
|
|
6928
7704
|
const receipt = undismissDuplicate(db, left.value.id, right.value.id);
|
|
6929
7705
|
return toolSuccess(
|
|
6930
7706
|
`Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
|
|
6931
|
-
{
|
|
7707
|
+
{
|
|
7708
|
+
...receipt,
|
|
7709
|
+
action: "undismiss",
|
|
7710
|
+
changes: existed ? [
|
|
7711
|
+
{
|
|
7712
|
+
entity: "duplicate_dismissal",
|
|
7713
|
+
leftContactId,
|
|
7714
|
+
rightContactId,
|
|
7715
|
+
action: "removed"
|
|
7716
|
+
}
|
|
7717
|
+
] : []
|
|
7718
|
+
}
|
|
6932
7719
|
);
|
|
6933
7720
|
}, "Duplicate dismissal failed.");
|
|
6934
7721
|
}
|
|
6935
7722
|
var manageDuplicateDismissalTool = defineAffinityTool({
|
|
6936
7723
|
name: manageDuplicateDismissalToolName,
|
|
6937
|
-
description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in
|
|
7724
|
+
description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
|
|
6938
7725
|
whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
|
|
6939
7726
|
whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
|
|
6940
7727
|
sideEffects: "writes_state",
|
|
@@ -7020,9 +7807,32 @@ function resolveDateAnchorTarget(db, target) {
|
|
|
7020
7807
|
}
|
|
7021
7808
|
return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
|
|
7022
7809
|
}
|
|
7023
|
-
function manageDateAnchorToolHandler(db,
|
|
7810
|
+
function manageDateAnchorToolHandler(db, rawInput) {
|
|
7811
|
+
const input = structuredClone(rawInput);
|
|
7812
|
+
const inputError = validateToolInput(manageDateAnchorTool.inputSchema, input);
|
|
7813
|
+
if (inputError)
|
|
7814
|
+
return toolFailure(
|
|
7815
|
+
"protocol",
|
|
7816
|
+
"invalid_input",
|
|
7817
|
+
"Invalid input.",
|
|
7818
|
+
inputError
|
|
7819
|
+
);
|
|
7820
|
+
if (input.action !== "add" && input.anchorEventId === void 0)
|
|
7821
|
+
return toolFailure(
|
|
7822
|
+
"protocol",
|
|
7823
|
+
"invalid_input",
|
|
7824
|
+
"Missing anchorEventId.",
|
|
7825
|
+
"anchorEventId is required."
|
|
7826
|
+
);
|
|
7024
7827
|
return withToolHandling(() => {
|
|
7025
7828
|
if (input.action === "add") {
|
|
7829
|
+
if (!input.input)
|
|
7830
|
+
return toolFailure(
|
|
7831
|
+
"protocol",
|
|
7832
|
+
"invalid_input",
|
|
7833
|
+
"Missing input.",
|
|
7834
|
+
"input is required for add."
|
|
7835
|
+
);
|
|
7026
7836
|
const resolved = resolveDateAnchorTarget(db, input.input.target);
|
|
7027
7837
|
if (resolved.kind === "error") {
|
|
7028
7838
|
return resolved.result;
|
|
@@ -7037,7 +7847,8 @@ function manageDateAnchorToolHandler(db, input) {
|
|
|
7037
7847
|
return mutationToolResult(
|
|
7038
7848
|
"revise",
|
|
7039
7849
|
reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
|
|
7040
|
-
"date anchor"
|
|
7850
|
+
"date anchor",
|
|
7851
|
+
Object.keys(input.patch)
|
|
7041
7852
|
);
|
|
7042
7853
|
}
|
|
7043
7854
|
return mutationToolResult(
|
|
@@ -7145,97 +7956,6 @@ var manageDateAnchorTool = defineAffinityTool({
|
|
|
7145
7956
|
handler: manageDateAnchorToolHandler
|
|
7146
7957
|
});
|
|
7147
7958
|
|
|
7148
|
-
// src/identities/queries.ts
|
|
7149
|
-
function getIdentityRowById(db, id) {
|
|
7150
|
-
const row = db.prepare(
|
|
7151
|
-
`SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
|
|
7152
|
-
created_at, updated_at, removed_at
|
|
7153
|
-
FROM identities
|
|
7154
|
-
WHERE id = ? AND removed_at IS NULL`
|
|
7155
|
-
).get(id);
|
|
7156
|
-
if (row === void 0) {
|
|
7157
|
-
return null;
|
|
7158
|
-
}
|
|
7159
|
-
return row;
|
|
7160
|
-
}
|
|
7161
|
-
|
|
7162
|
-
// src/identities/receipts.ts
|
|
7163
|
-
function buildIdentityMutationReceipt(primary, partial) {
|
|
7164
|
-
const normalized = normalizeMutationReceiptPartial(partial);
|
|
7165
|
-
return {
|
|
7166
|
-
primary,
|
|
7167
|
-
created: normalized.created,
|
|
7168
|
-
updated: normalized.updated,
|
|
7169
|
-
archived: normalized.archived,
|
|
7170
|
-
removed: normalized.removed,
|
|
7171
|
-
affectedLinks: normalized.affectedLinks,
|
|
7172
|
-
derivedEffects: normalized.derivedEffects
|
|
7173
|
-
};
|
|
7174
|
-
}
|
|
7175
|
-
|
|
7176
|
-
// src/identities/validators.ts
|
|
7177
|
-
function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
|
|
7178
|
-
const row = excludeIdentityId === void 0 ? db.prepare(
|
|
7179
|
-
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
|
|
7180
|
-
).get(normalizedKey) : db.prepare(
|
|
7181
|
-
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
|
|
7182
|
-
).get(normalizedKey, excludeIdentityId);
|
|
7183
|
-
if (row !== void 0) {
|
|
7184
|
-
throw new AffinityConflictError(
|
|
7185
|
-
"another contact already owns this normalized identity"
|
|
7186
|
-
);
|
|
7187
|
-
}
|
|
7188
|
-
}
|
|
7189
|
-
|
|
7190
|
-
// src/identities/add_identity.ts
|
|
7191
|
-
function addIdentity(db, contactId, input) {
|
|
7192
|
-
return withTransaction(db, () => {
|
|
7193
|
-
const contact = getContactRowById(db, contactId);
|
|
7194
|
-
if (!contact) {
|
|
7195
|
-
throw new AffinityNotFoundError("contact not found");
|
|
7196
|
-
}
|
|
7197
|
-
if (contact.lifecycle_state === "merged") {
|
|
7198
|
-
throw new AffinityStateError("merged contact is read-only");
|
|
7199
|
-
}
|
|
7200
|
-
const type = input.type.trim();
|
|
7201
|
-
const value = input.value.trim();
|
|
7202
|
-
if (type.length === 0 || value.length === 0) {
|
|
7203
|
-
throw new AffinityValidationError("type and value must be non-empty");
|
|
7204
|
-
}
|
|
7205
|
-
const nk = normalizeIdentityKey(type, value);
|
|
7206
|
-
assertNoIdentityCollision(db, nk);
|
|
7207
|
-
const now = resolveNow(input.now);
|
|
7208
|
-
const verified = input.verified ? 1 : 0;
|
|
7209
|
-
const verifiedAt = verified === 1 ? now : null;
|
|
7210
|
-
const result = db.prepare(
|
|
7211
|
-
`INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
|
|
7212
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
7213
|
-
).run(
|
|
7214
|
-
contactId,
|
|
7215
|
-
type,
|
|
7216
|
-
value,
|
|
7217
|
-
input.label ?? null,
|
|
7218
|
-
nk,
|
|
7219
|
-
verified,
|
|
7220
|
-
verifiedAt,
|
|
7221
|
-
now,
|
|
7222
|
-
now
|
|
7223
|
-
);
|
|
7224
|
-
const id = Number(result.lastInsertRowid);
|
|
7225
|
-
const row = getIdentityRowById(db, id);
|
|
7226
|
-
if (!row) {
|
|
7227
|
-
throw new AffinityInvariantError("inserted identity not found");
|
|
7228
|
-
}
|
|
7229
|
-
refreshContactRollup(db, contactId, now);
|
|
7230
|
-
db.prepare(
|
|
7231
|
-
"DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
|
|
7232
|
-
).run(contactId, contactId);
|
|
7233
|
-
return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
|
|
7234
|
-
created: [{ kind: "identity", id }]
|
|
7235
|
-
});
|
|
7236
|
-
});
|
|
7237
|
-
}
|
|
7238
|
-
|
|
7239
7959
|
// src/identities/remove_identity.ts
|
|
7240
7960
|
function removeIdentity(db, identityId, removedAt) {
|
|
7241
7961
|
return withTransaction(db, () => {
|
|
@@ -7287,6 +8007,7 @@ function reviseIdentity(db, identityId, patch) {
|
|
|
7287
8007
|
if (nextType.length === 0 || nextValue.length === 0) {
|
|
7288
8008
|
throw new AffinityValidationError("type and value must be non-empty");
|
|
7289
8009
|
}
|
|
8010
|
+
validateIdentityInput(nextType, nextValue);
|
|
7290
8011
|
const nextLabel = patch.label !== void 0 ? patch.label : row.label;
|
|
7291
8012
|
const nkNext = normalizeIdentityKey(nextType, nextValue);
|
|
7292
8013
|
if (nkNext !== row.normalized_key) {
|
|
@@ -7341,7 +8062,9 @@ function contactLocatorSchema6(description) {
|
|
|
7341
8062
|
contactId: integerSchema("Exact contact id."),
|
|
7342
8063
|
identity: objectSchema(
|
|
7343
8064
|
{
|
|
7344
|
-
type: stringSchema(
|
|
8065
|
+
type: stringSchema(
|
|
8066
|
+
`Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
|
|
8067
|
+
),
|
|
7345
8068
|
value: stringSchema("Identity value.")
|
|
7346
8069
|
},
|
|
7347
8070
|
["type", "value"],
|
|
@@ -7351,7 +8074,23 @@ function contactLocatorSchema6(description) {
|
|
|
7351
8074
|
description
|
|
7352
8075
|
};
|
|
7353
8076
|
}
|
|
7354
|
-
function manageIdentityToolHandler(db,
|
|
8077
|
+
function manageIdentityToolHandler(db, rawInput) {
|
|
8078
|
+
const input = structuredClone(rawInput);
|
|
8079
|
+
const inputError = validateToolInput(manageIdentityTool.inputSchema, input);
|
|
8080
|
+
if (inputError)
|
|
8081
|
+
return toolFailure(
|
|
8082
|
+
"protocol",
|
|
8083
|
+
"invalid_input",
|
|
8084
|
+
"Invalid input.",
|
|
8085
|
+
inputError
|
|
8086
|
+
);
|
|
8087
|
+
if (input.action !== "add" && input.identityId === void 0)
|
|
8088
|
+
return toolFailure(
|
|
8089
|
+
"protocol",
|
|
8090
|
+
"invalid_input",
|
|
8091
|
+
"Missing identityId.",
|
|
8092
|
+
"identityId is required."
|
|
8093
|
+
);
|
|
7355
8094
|
return withToolHandling(() => {
|
|
7356
8095
|
switch (input.action) {
|
|
7357
8096
|
case "add": {
|
|
@@ -7369,7 +8108,8 @@ function manageIdentityToolHandler(db, input) {
|
|
|
7369
8108
|
return mutationToolResult(
|
|
7370
8109
|
"revise",
|
|
7371
8110
|
reviseIdentity(db, input.identityId, input.patch),
|
|
7372
|
-
"identity"
|
|
8111
|
+
"identity",
|
|
8112
|
+
Object.keys(input.patch)
|
|
7373
8113
|
);
|
|
7374
8114
|
case "verify":
|
|
7375
8115
|
return mutationToolResult(
|
|
@@ -7418,7 +8158,9 @@ var manageIdentityTool = defineAffinityTool({
|
|
|
7418
8158
|
),
|
|
7419
8159
|
input: objectSchema(
|
|
7420
8160
|
{
|
|
7421
|
-
type: stringSchema(
|
|
8161
|
+
type: stringSchema(
|
|
8162
|
+
`Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
|
|
8163
|
+
),
|
|
7422
8164
|
value: stringSchema("Identity value."),
|
|
7423
8165
|
label: stringSchema("Optional label."),
|
|
7424
8166
|
verified: booleanSchema("Whether the identity starts verified."),
|
|
@@ -7432,7 +8174,9 @@ var manageIdentityTool = defineAffinityTool({
|
|
|
7432
8174
|
),
|
|
7433
8175
|
patch: objectSchema(
|
|
7434
8176
|
{
|
|
7435
|
-
type: stringSchema(
|
|
8177
|
+
type: stringSchema(
|
|
8178
|
+
`Optional identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
|
|
8179
|
+
),
|
|
7436
8180
|
value: stringSchema("Optional identity value."),
|
|
7437
8181
|
label: stringSchema("Optional label.")
|
|
7438
8182
|
},
|
|
@@ -7490,7 +8234,19 @@ function linkLocatorSchema4(description) {
|
|
|
7490
8234
|
description
|
|
7491
8235
|
};
|
|
7492
8236
|
}
|
|
7493
|
-
function manageRelationshipToolHandler(db,
|
|
8237
|
+
function manageRelationshipToolHandler(db, rawInput) {
|
|
8238
|
+
const input = structuredClone(rawInput);
|
|
8239
|
+
const inputError = validateToolInput(
|
|
8240
|
+
manageRelationshipTool.inputSchema,
|
|
8241
|
+
input
|
|
8242
|
+
);
|
|
8243
|
+
if (inputError)
|
|
8244
|
+
return toolFailure(
|
|
8245
|
+
"protocol",
|
|
8246
|
+
"invalid_input",
|
|
8247
|
+
"Invalid input.",
|
|
8248
|
+
inputError
|
|
8249
|
+
);
|
|
7494
8250
|
return withToolHandling(() => {
|
|
7495
8251
|
switch (input.action) {
|
|
7496
8252
|
case "seed_social_link": {
|
|
@@ -7671,7 +8427,16 @@ function contactLocatorSchema8(description) {
|
|
|
7671
8427
|
description
|
|
7672
8428
|
};
|
|
7673
8429
|
}
|
|
7674
|
-
function mergeContactsToolHandler(db,
|
|
8430
|
+
function mergeContactsToolHandler(db, rawInput) {
|
|
8431
|
+
const input = structuredClone(rawInput);
|
|
8432
|
+
const inputError = validateToolInput(mergeContactsTool.inputSchema, input);
|
|
8433
|
+
if (inputError)
|
|
8434
|
+
return toolFailure(
|
|
8435
|
+
"protocol",
|
|
8436
|
+
"invalid_input",
|
|
8437
|
+
"Invalid input.",
|
|
8438
|
+
inputError
|
|
8439
|
+
);
|
|
7675
8440
|
return withToolHandling(() => {
|
|
7676
8441
|
const winner = resolveContactLocator(db, input.winner, "winner");
|
|
7677
8442
|
if (!winner.ok) {
|
|
@@ -7752,7 +8517,16 @@ function prepareParticipants2(db, raw, injectOwner) {
|
|
|
7752
8517
|
const normalized = raw.map(normalizeParticipant2);
|
|
7753
8518
|
return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
|
|
7754
8519
|
}
|
|
7755
|
-
function recordEventToolHandler(db,
|
|
8520
|
+
function recordEventToolHandler(db, rawInput) {
|
|
8521
|
+
const input = structuredClone(rawInput);
|
|
8522
|
+
const inputError = validateToolInput(recordEventTool.inputSchema, input);
|
|
8523
|
+
if (inputError)
|
|
8524
|
+
return toolFailure(
|
|
8525
|
+
"protocol",
|
|
8526
|
+
"invalid_input",
|
|
8527
|
+
"Invalid input.",
|
|
8528
|
+
inputError
|
|
8529
|
+
);
|
|
7756
8530
|
return withToolHandling(() => {
|
|
7757
8531
|
const patched = {
|
|
7758
8532
|
...input.input,
|
|
@@ -7850,7 +8624,7 @@ var recordEventTool = defineAffinityTool({
|
|
|
7850
8624
|
"observed"
|
|
7851
8625
|
])
|
|
7852
8626
|
},
|
|
7853
|
-
["
|
|
8627
|
+
["role"]
|
|
7854
8628
|
),
|
|
7855
8629
|
"Participant list. Must be non-empty and include the owner contact."
|
|
7856
8630
|
),
|
|
@@ -7866,9 +8640,71 @@ var recordEventTool = defineAffinityTool({
|
|
|
7866
8640
|
handler: recordEventToolHandler
|
|
7867
8641
|
});
|
|
7868
8642
|
|
|
8643
|
+
// src/tools/enrich_review_item.ts
|
|
8644
|
+
function enrichReviewItem(db, item) {
|
|
8645
|
+
const name = (id) => {
|
|
8646
|
+
if (typeof id !== "number") return "";
|
|
8647
|
+
return db.prepare("SELECT name FROM contacts WHERE id = ?").get(id)?.name ?? `Contact #${id}`;
|
|
8648
|
+
};
|
|
8649
|
+
const result = { ...item };
|
|
8650
|
+
if (item.kind === "link") {
|
|
8651
|
+
const link = db.prepare(`SELECT l.*, a.name AS from_name, b.name AS to_name, a.is_owner AS from_owner, b.is_owner AS to_owner,
|
|
8652
|
+
lr.recency_score, lr.drift_priority, lr.readiness_score, lr.normalized_rank, lr.reciprocity_score, lr.radar_score FROM links l JOIN contacts a ON a.id = l.from_contact_id JOIN contacts b ON b.id = l.to_contact_id LEFT JOIN link_rollups lr ON lr.link_id = l.id WHERE l.id = ?`).get(item.id);
|
|
8653
|
+
if (link) {
|
|
8654
|
+
result.title = link.from_owner ? String(link.to_name) : link.to_owner ? String(link.from_name) : `${link.from_name} \xB7 ${link.to_name}`;
|
|
8655
|
+
result.subtitle = [link.kind, link.role].filter(Boolean).join(" \xB7 ");
|
|
8656
|
+
for (const key of ["affinity", "trust", "rank"])
|
|
8657
|
+
if (typeof link[key] === "number") result[key] = link[key];
|
|
8658
|
+
result.recencyScore ??= link.recency_score;
|
|
8659
|
+
result.driftPriority ??= link.drift_priority;
|
|
8660
|
+
result.readinessScore = link.readiness_score;
|
|
8661
|
+
result.normalizedRank ??= link.normalized_rank;
|
|
8662
|
+
result.reciprocityScore = link.reciprocity_score;
|
|
8663
|
+
result.radarScore = link.radar_score;
|
|
8664
|
+
}
|
|
8665
|
+
}
|
|
8666
|
+
if (item.kind === "event" && typeof item.linkId === "number" && !Array.isArray(item.participants)) {
|
|
8667
|
+
const link = db.prepare("SELECT from_contact_id, to_contact_id FROM links WHERE id = ?").get(item.linkId);
|
|
8668
|
+
if (link)
|
|
8669
|
+
result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
|
|
8670
|
+
}
|
|
8671
|
+
if (Array.isArray(item.participants)) {
|
|
8672
|
+
result.counterpartyNames = item.participants.map(
|
|
8673
|
+
(p) => name(p.contactId)
|
|
8674
|
+
);
|
|
8675
|
+
result.subtitle = [
|
|
8676
|
+
item.subtitle,
|
|
8677
|
+
result.counterpartyNames.join(", ")
|
|
8678
|
+
].filter(Boolean).join(" \xB7 ");
|
|
8679
|
+
}
|
|
8680
|
+
if (typeof item.leftContactId === "number")
|
|
8681
|
+
result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
|
|
8682
|
+
if (typeof item.winnerContactId === "number")
|
|
8683
|
+
result.title = `Merged ${name(item.loserContactId)} into ${name(item.winnerContactId)}`;
|
|
8684
|
+
if (item.occursAt !== void 0 && item.kind === "contact")
|
|
8685
|
+
result.title = `${name(item.id)} \xB7 ${item.title}`;
|
|
8686
|
+
for (const [key, value] of Object.entries(result)) {
|
|
8687
|
+
if (typeof value === "number" && /score|priority|affinity|trust|rank/i.test(key))
|
|
8688
|
+
result[key] = Math.round(value * 1e3) / 1e3;
|
|
8689
|
+
}
|
|
8690
|
+
return result;
|
|
8691
|
+
}
|
|
8692
|
+
|
|
8693
|
+
// src/tools/tool_summary.ts
|
|
8694
|
+
function summarizeCount(count, singular) {
|
|
8695
|
+
if (count === 0) {
|
|
8696
|
+
return `No ${singular}s found.`;
|
|
8697
|
+
}
|
|
8698
|
+
if (count === 1) {
|
|
8699
|
+
return `Found 1 ${singular}.`;
|
|
8700
|
+
}
|
|
8701
|
+
return `Found ${count} ${singular}s.`;
|
|
8702
|
+
}
|
|
8703
|
+
|
|
7869
8704
|
// src/tools/review_affinity_tool.ts
|
|
7870
8705
|
function buildReadOptions(input) {
|
|
7871
8706
|
return {
|
|
8707
|
+
...input.now === void 0 ? {} : { now: input.now },
|
|
7872
8708
|
...input.limit === void 0 ? {} : { limit: input.limit },
|
|
7873
8709
|
...input.offset === void 0 ? {} : { offset: input.offset },
|
|
7874
8710
|
...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
|
|
@@ -7988,6 +8824,7 @@ function validateReviewInput(input) {
|
|
|
7988
8824
|
}
|
|
7989
8825
|
function duplicateCandidateItem(candidate) {
|
|
7990
8826
|
return {
|
|
8827
|
+
...candidate,
|
|
7991
8828
|
kind: "contact",
|
|
7992
8829
|
id: candidate.leftContactId,
|
|
7993
8830
|
title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
|
|
@@ -7997,24 +8834,28 @@ function duplicateCandidateItem(candidate) {
|
|
|
7997
8834
|
}
|
|
7998
8835
|
function momentItem(moment) {
|
|
7999
8836
|
return {
|
|
8837
|
+
...moment,
|
|
8838
|
+
occurredAtIso: new Date(moment.occurredAt).toISOString(),
|
|
8000
8839
|
kind: "event",
|
|
8001
8840
|
id: moment.eventId,
|
|
8002
8841
|
title: moment.summary,
|
|
8003
8842
|
subtitle: moment.momentKind,
|
|
8004
|
-
snippet:
|
|
8843
|
+
snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
|
|
8005
8844
|
};
|
|
8006
8845
|
}
|
|
8007
8846
|
function commitmentItem(commitment) {
|
|
8008
8847
|
return {
|
|
8848
|
+
...commitment,
|
|
8009
8849
|
kind: "event",
|
|
8010
8850
|
id: commitment.eventId,
|
|
8011
8851
|
title: commitment.summary,
|
|
8012
8852
|
subtitle: commitment.type,
|
|
8013
|
-
snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt
|
|
8853
|
+
snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
|
|
8014
8854
|
};
|
|
8015
8855
|
}
|
|
8016
8856
|
function radarItem(radar) {
|
|
8017
8857
|
return {
|
|
8858
|
+
...radar,
|
|
8018
8859
|
kind: "link",
|
|
8019
8860
|
id: radar.linkId,
|
|
8020
8861
|
title: `Link #${radar.linkId}`,
|
|
@@ -8024,29 +8865,34 @@ function radarItem(radar) {
|
|
|
8024
8865
|
}
|
|
8025
8866
|
function upcomingDateItem(date) {
|
|
8026
8867
|
return {
|
|
8868
|
+
...date,
|
|
8869
|
+
occursAt: date.occursOn,
|
|
8870
|
+
occursAtIso: new Date(date.occursOn).toISOString(),
|
|
8027
8871
|
kind: date.targetRef.kind,
|
|
8028
8872
|
id: date.targetRef.id,
|
|
8029
8873
|
title: date.summary,
|
|
8030
8874
|
subtitle: date.recurrenceKind,
|
|
8031
|
-
snippet: `occurs ${date.occursOn}`
|
|
8875
|
+
snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
|
|
8032
8876
|
};
|
|
8033
8877
|
}
|
|
8034
8878
|
function mergeHistoryItem(record) {
|
|
8035
8879
|
return {
|
|
8880
|
+
...record,
|
|
8036
8881
|
kind: "contact",
|
|
8037
8882
|
id: record.winnerContactId,
|
|
8038
8883
|
title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
|
|
8039
|
-
subtitle:
|
|
8884
|
+
subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
|
|
8040
8885
|
snippet: record.reasonSummary ?? void 0
|
|
8041
8886
|
};
|
|
8042
8887
|
}
|
|
8043
8888
|
function dismissedPairItem(record) {
|
|
8044
8889
|
return {
|
|
8890
|
+
...record,
|
|
8045
8891
|
kind: "contact",
|
|
8046
8892
|
id: record.leftContactId,
|
|
8047
8893
|
title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
|
|
8048
8894
|
subtitle: record.reason ?? "no reason given",
|
|
8049
|
-
snippet: `dismissed at ${record.dismissedAt}`
|
|
8895
|
+
snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
|
|
8050
8896
|
};
|
|
8051
8897
|
}
|
|
8052
8898
|
function compactFilters(input) {
|
|
@@ -8056,7 +8902,16 @@ function compactFilters(input) {
|
|
|
8056
8902
|
)
|
|
8057
8903
|
);
|
|
8058
8904
|
}
|
|
8059
|
-
function reviewAffinityToolHandler(db,
|
|
8905
|
+
function reviewAffinityToolHandler(db, rawInput) {
|
|
8906
|
+
const input = structuredClone(rawInput);
|
|
8907
|
+
const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
|
|
8908
|
+
if (inputError)
|
|
8909
|
+
return toolFailure(
|
|
8910
|
+
"protocol",
|
|
8911
|
+
"invalid_input",
|
|
8912
|
+
"Invalid input.",
|
|
8913
|
+
inputError
|
|
8914
|
+
);
|
|
8060
8915
|
return withToolHandling(() => {
|
|
8061
8916
|
const invalid = validateReviewInput(input);
|
|
8062
8917
|
if (invalid) {
|
|
@@ -8070,6 +8925,9 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
8070
8925
|
items = listContacts(
|
|
8071
8926
|
db,
|
|
8072
8927
|
{
|
|
8928
|
+
...input.attribute === void 0 ? {} : { attribute: input.attribute },
|
|
8929
|
+
...input.tag === void 0 ? {} : { tag: input.tag },
|
|
8930
|
+
...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
|
|
8073
8931
|
...input.kind === void 0 ? {} : { kind: input.kind },
|
|
8074
8932
|
...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
|
|
8075
8933
|
...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
|
|
@@ -8100,7 +8958,7 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
8100
8958
|
"Provide `contact` when `view` is `events.contact_journal`."
|
|
8101
8959
|
);
|
|
8102
8960
|
}
|
|
8103
|
-
const contact = resolveContactLocator(db, locator, "contact");
|
|
8961
|
+
const contact = resolveContactLocator(db, locator, "contact", true);
|
|
8104
8962
|
if (!contact.ok) {
|
|
8105
8963
|
return contact.result;
|
|
8106
8964
|
}
|
|
@@ -8135,7 +8993,12 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
8135
8993
|
let contactId;
|
|
8136
8994
|
let linkId;
|
|
8137
8995
|
if (input.contact !== void 0) {
|
|
8138
|
-
const contact = resolveContactLocator(
|
|
8996
|
+
const contact = resolveContactLocator(
|
|
8997
|
+
db,
|
|
8998
|
+
input.contact,
|
|
8999
|
+
"contact",
|
|
9000
|
+
true
|
|
9001
|
+
);
|
|
8139
9002
|
if (!contact.ok) {
|
|
8140
9003
|
return contact.result;
|
|
8141
9004
|
}
|
|
@@ -8164,7 +9027,12 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
8164
9027
|
let contactId;
|
|
8165
9028
|
let linkId;
|
|
8166
9029
|
if (input.contact !== void 0) {
|
|
8167
|
-
const contact = resolveContactLocator(
|
|
9030
|
+
const contact = resolveContactLocator(
|
|
9031
|
+
db,
|
|
9032
|
+
input.contact,
|
|
9033
|
+
"contact",
|
|
9034
|
+
true
|
|
9035
|
+
);
|
|
8168
9036
|
if (!contact.ok) {
|
|
8169
9037
|
return contact.result;
|
|
8170
9038
|
}
|
|
@@ -8257,7 +9125,7 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
8257
9125
|
"Provide `contact` when `view` is `merges.history`."
|
|
8258
9126
|
);
|
|
8259
9127
|
}
|
|
8260
|
-
const contact = resolveContactLocator(db, locator, "contact");
|
|
9128
|
+
const contact = resolveContactLocator(db, locator, "contact", true);
|
|
8261
9129
|
if (!contact.ok) {
|
|
8262
9130
|
return contact.result;
|
|
8263
9131
|
}
|
|
@@ -8270,6 +9138,7 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
8270
9138
|
items = listDismissedDuplicates(db, options).map(dismissedPairItem);
|
|
8271
9139
|
break;
|
|
8272
9140
|
}
|
|
9141
|
+
items = items.map((item) => enrichReviewItem(db, item));
|
|
8273
9142
|
return toolSuccess(
|
|
8274
9143
|
summarizeCount(items.length, "item"),
|
|
8275
9144
|
{
|
|
@@ -8302,12 +9171,16 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
8302
9171
|
targetKinds: ["contact", "link", "event"],
|
|
8303
9172
|
inputDescriptions: {
|
|
8304
9173
|
view: "Which review surface to return.",
|
|
9174
|
+
now: "Optional current timestamp for overdue calculations.",
|
|
8305
9175
|
contact: "Target contact locator for views that require one.",
|
|
8306
9176
|
link: "Target link locator for views that require one.",
|
|
8307
9177
|
contactIds: "Optional contact id subset for duplicate review or graph review.",
|
|
8308
9178
|
kind: "Optional contact kind or link kind filter, depending on the view.",
|
|
8309
9179
|
lifecycleState: "Optional contact lifecycle filter for the contact list view.",
|
|
8310
9180
|
linkState: "Optional link state filter for link review views.",
|
|
9181
|
+
attribute: "Attribute name or name=value; arrays require all filters.",
|
|
9182
|
+
tag: "Tag name; arrays require all tags.",
|
|
9183
|
+
includeLost: "Whether lost contacts are included.",
|
|
8311
9184
|
includeOwner: "Whether owner contacts should be included in contact review.",
|
|
8312
9185
|
includeDormant: "Whether dormant links or contacts should be included where supported.",
|
|
8313
9186
|
includeArchived: "Whether archived links should be included where supported.",
|
|
@@ -8344,6 +9217,9 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
8344
9217
|
"merges.history",
|
|
8345
9218
|
"merges.dismissed"
|
|
8346
9219
|
]),
|
|
9220
|
+
now: integerSchema(
|
|
9221
|
+
"Optional current timestamp for overdue calculations."
|
|
9222
|
+
),
|
|
8347
9223
|
contact: contactLocatorSchema9("Optional target contact locator."),
|
|
8348
9224
|
link: linkLocatorSchema5("Optional target link locator."),
|
|
8349
9225
|
contactIds: arraySchema(
|
|
@@ -8366,6 +9242,17 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
8366
9242
|
"broken",
|
|
8367
9243
|
"archived"
|
|
8368
9244
|
]),
|
|
9245
|
+
attribute: oneOfSchema([
|
|
9246
|
+
stringSchema("Attribute filter."),
|
|
9247
|
+
arraySchema(stringSchema("Attribute filter."), "AND filters.")
|
|
9248
|
+
]),
|
|
9249
|
+
tag: oneOfSchema([
|
|
9250
|
+
stringSchema("Tag filter."),
|
|
9251
|
+
arraySchema(stringSchema("Tag filter."), "AND tags.")
|
|
9252
|
+
]),
|
|
9253
|
+
includeLost: booleanSchema(
|
|
9254
|
+
"Include lost contacts (search excludes them by default)."
|
|
9255
|
+
),
|
|
8369
9256
|
includeOwner: booleanSchema("Whether owner contacts are included."),
|
|
8370
9257
|
includeDormant: booleanSchema("Whether dormant rows are included."),
|
|
8371
9258
|
includeArchived: booleanSchema("Whether archived links are included."),
|
|
@@ -8410,7 +9297,16 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
8410
9297
|
});
|
|
8411
9298
|
|
|
8412
9299
|
// src/tools/search_affinity_tool.ts
|
|
8413
|
-
function searchAffinityToolHandler(db,
|
|
9300
|
+
function searchAffinityToolHandler(db, rawInput) {
|
|
9301
|
+
const input = structuredClone(rawInput);
|
|
9302
|
+
const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
|
|
9303
|
+
if (inputError)
|
|
9304
|
+
return toolFailure(
|
|
9305
|
+
"protocol",
|
|
9306
|
+
"invalid_input",
|
|
9307
|
+
"Invalid input.",
|
|
9308
|
+
inputError
|
|
9309
|
+
);
|
|
8414
9310
|
return withToolHandling(() => {
|
|
8415
9311
|
const options = {
|
|
8416
9312
|
...input.limit === void 0 ? {} : { limit: input.limit },
|
|
@@ -8420,6 +9316,9 @@ function searchAffinityToolHandler(db, input) {
|
|
|
8420
9316
|
db,
|
|
8421
9317
|
input.query,
|
|
8422
9318
|
{
|
|
9319
|
+
...input.attribute === void 0 ? {} : { attribute: input.attribute },
|
|
9320
|
+
...input.tag === void 0 ? {} : { tag: input.tag },
|
|
9321
|
+
...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
|
|
8423
9322
|
...input.kind === void 0 ? {} : { kind: input.kind },
|
|
8424
9323
|
...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
|
|
8425
9324
|
...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
|
|
@@ -8443,7 +9342,7 @@ function searchAffinityToolHandler(db, input) {
|
|
|
8443
9342
|
}
|
|
8444
9343
|
var searchAffinityTool = defineAffinityTool({
|
|
8445
9344
|
name: searchAffinityToolName,
|
|
8446
|
-
description: "Search contacts by name
|
|
9345
|
+
description: "Search contacts by name, identity, attributes, link roles, bonds and event text. All whitespace tokens must match. Dormant and lost contacts are excluded by default; use includeDormant and includeLost to include them.",
|
|
8447
9346
|
whenToUse: "Use this when you want to find contacts from free text or a known routing identity such as an email or URL-like handle.",
|
|
8448
9347
|
whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
|
|
8449
9348
|
sideEffects: "none",
|
|
@@ -8454,6 +9353,9 @@ var searchAffinityTool = defineAffinityTool({
|
|
|
8454
9353
|
query: "Free-text or natural-key search text.",
|
|
8455
9354
|
kind: "Optional contact kind filter.",
|
|
8456
9355
|
lifecycleState: "Optional lifecycle state filter.",
|
|
9356
|
+
attribute: "Attribute name or name=value; arrays require all filters.",
|
|
9357
|
+
tag: "Tag name; arrays require all tags.",
|
|
9358
|
+
includeLost: "Whether lost contacts are included in search.",
|
|
8457
9359
|
includeOwner: "Whether owner contacts are eligible for results.",
|
|
8458
9360
|
includeDormant: "Whether dormant contacts should be eligible for results.",
|
|
8459
9361
|
limit: "Optional result limit.",
|
|
@@ -8478,6 +9380,17 @@ var searchAffinityTool = defineAffinityTool({
|
|
|
8478
9380
|
"merged",
|
|
8479
9381
|
"lost"
|
|
8480
9382
|
]),
|
|
9383
|
+
attribute: oneOfSchema([
|
|
9384
|
+
stringSchema("Attribute filter."),
|
|
9385
|
+
arraySchema(stringSchema("Attribute filter."), "AND filters.")
|
|
9386
|
+
]),
|
|
9387
|
+
tag: oneOfSchema([
|
|
9388
|
+
stringSchema("Tag filter."),
|
|
9389
|
+
arraySchema(stringSchema("Tag filter."), "AND tags.")
|
|
9390
|
+
]),
|
|
9391
|
+
includeLost: booleanSchema(
|
|
9392
|
+
"Include lost contacts (search excludes them by default)."
|
|
9393
|
+
),
|
|
8481
9394
|
includeOwner: booleanSchema("Whether owner contacts are included."),
|
|
8482
9395
|
includeDormant: booleanSchema("Whether dormant contacts are included."),
|
|
8483
9396
|
limit: integerSchema("Optional result limit."),
|
|
@@ -8513,6 +9426,9 @@ function getAffinityToolByName(name) {
|
|
|
8513
9426
|
|
|
8514
9427
|
// src/tools/tool_mapping.ts
|
|
8515
9428
|
var affinityToolMappings = [
|
|
9429
|
+
{ source: "exportContact", tool: manageContactToolName, action: "export" },
|
|
9430
|
+
{ source: "exportAffinity", tool: manageContactToolName, action: "export" },
|
|
9431
|
+
{ source: "deleteContact", tool: manageContactToolName, action: "delete" },
|
|
8516
9432
|
{ source: "searchContacts", tool: searchAffinityToolName },
|
|
8517
9433
|
{
|
|
8518
9434
|
source: "getOwnerProfile",
|
|
@@ -8699,6 +9615,7 @@ __export(write_exports, {
|
|
|
8699
9615
|
addDateAnchor: () => addDateAnchor,
|
|
8700
9616
|
addIdentity: () => addIdentity,
|
|
8701
9617
|
createContact: () => createContact,
|
|
9618
|
+
deleteContact: () => deleteContact,
|
|
8702
9619
|
dismissDuplicate: () => dismissDuplicate,
|
|
8703
9620
|
mergeContacts: () => mergeContacts,
|
|
8704
9621
|
overrideLinkState: () => overrideLinkState,
|