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