@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/mcp.cjs
CHANGED
|
@@ -5,11 +5,16 @@ var node_path = require('node:path');
|
|
|
5
5
|
var node_os = require('node:os');
|
|
6
6
|
|
|
7
7
|
// src/version.ts
|
|
8
|
-
var affinityVersion = "0.
|
|
8
|
+
var affinityVersion = "0.5.0";
|
|
9
9
|
|
|
10
10
|
// src/surface/affinity_mcp_instructions.ts
|
|
11
11
|
var affinityMcpInstructions = "Affinity is a local relationship model and social CRM. Search before creating contacts. Inspect before revising or merging. Record direct evidence honestly, keep identities and structural ties distinct from social links, and ask for clarification when a target is ambiguous. Review commitments and recurring dates regularly. Derived scores guide attention and do not prove relationship quality.";
|
|
12
12
|
|
|
13
|
+
// src/surfaces/cli_args.ts
|
|
14
|
+
var CliUsageError = class extends Error {
|
|
15
|
+
exitCode = 2;
|
|
16
|
+
};
|
|
17
|
+
|
|
13
18
|
// src/lib/errors/affinity_error.ts
|
|
14
19
|
var AffinityError = class extends Error {
|
|
15
20
|
constructor(message, options) {
|
|
@@ -19,11 +24,60 @@ var AffinityError = class extends Error {
|
|
|
19
24
|
}
|
|
20
25
|
};
|
|
21
26
|
|
|
27
|
+
// src/lib/errors/affinity_validation_error.ts
|
|
28
|
+
var AffinityValidationError = class extends AffinityError {
|
|
29
|
+
code = "VALIDATION";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/lib/read/assert_default_ordering.ts
|
|
33
|
+
function assertDefaultOrdering(_readName, options) {
|
|
34
|
+
if (options?.sort !== void 0 || options?.descending !== void 0) {
|
|
35
|
+
throw new AffinityValidationError(
|
|
36
|
+
"This review does not support custom sort ordering"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/lib/types/read_list_options.ts
|
|
42
|
+
var AFFINITY_DEFAULT_LIST_LIMIT = 50;
|
|
43
|
+
var AFFINITY_MAX_LIST_LIMIT = 250;
|
|
44
|
+
|
|
45
|
+
// src/lib/types/resolve_affinity_list_limit.ts
|
|
46
|
+
function resolveAffinityListLimit(limit) {
|
|
47
|
+
if (limit === void 0) {
|
|
48
|
+
return AFFINITY_DEFAULT_LIST_LIMIT;
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
51
|
+
throw new AffinityValidationError("limit must be a positive integer");
|
|
52
|
+
}
|
|
53
|
+
if (limit > AFFINITY_MAX_LIST_LIMIT) {
|
|
54
|
+
throw new AffinityValidationError(
|
|
55
|
+
`limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return limit;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/lib/read/resolve_list_limit_offset.ts
|
|
62
|
+
function resolveListLimitOffset(options) {
|
|
63
|
+
const limit = resolveAffinityListLimit(options?.limit);
|
|
64
|
+
const offset = options?.offset ?? 0;
|
|
65
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
66
|
+
throw new AffinityValidationError("offset must be a non-negative integer");
|
|
67
|
+
}
|
|
68
|
+
return { limit, offset };
|
|
69
|
+
}
|
|
70
|
+
|
|
22
71
|
// src/lib/errors/affinity_invariant_error.ts
|
|
23
72
|
var AffinityInvariantError = class extends AffinityError {
|
|
24
73
|
code = "INVARIANT";
|
|
25
74
|
};
|
|
26
75
|
|
|
76
|
+
// src/lib/errors/affinity_not_found_error.ts
|
|
77
|
+
var AffinityNotFoundError = class extends AffinityError {
|
|
78
|
+
code = "NOT_FOUND";
|
|
79
|
+
};
|
|
80
|
+
|
|
27
81
|
// src/contacts/queries.ts
|
|
28
82
|
function getContactRowById(db, id) {
|
|
29
83
|
const row = db.prepare(
|
|
@@ -55,21 +109,11 @@ function requireOwnerContactId(db) {
|
|
|
55
109
|
return ownerId;
|
|
56
110
|
}
|
|
57
111
|
|
|
58
|
-
// src/lib/errors/affinity_not_found_error.ts
|
|
59
|
-
var AffinityNotFoundError = class extends AffinityError {
|
|
60
|
-
code = "NOT_FOUND";
|
|
61
|
-
};
|
|
62
|
-
|
|
63
112
|
// src/lib/errors/affinity_state_error.ts
|
|
64
113
|
var AffinityStateError = class extends AffinityError {
|
|
65
114
|
code = "STATE";
|
|
66
115
|
};
|
|
67
116
|
|
|
68
|
-
// src/lib/errors/affinity_validation_error.ts
|
|
69
|
-
var AffinityValidationError = class extends AffinityError {
|
|
70
|
-
code = "VALIDATION";
|
|
71
|
-
};
|
|
72
|
-
|
|
73
117
|
// src/events/validators.ts
|
|
74
118
|
var PARTICIPANT_ROLES = /* @__PURE__ */ new Set([
|
|
75
119
|
"actor",
|
|
@@ -103,7 +147,10 @@ function assertParticipantContactsLive(db, contactIds) {
|
|
|
103
147
|
throw new AffinityNotFoundError("contact not found");
|
|
104
148
|
}
|
|
105
149
|
if (row.lifecycle_state === "merged") {
|
|
106
|
-
|
|
150
|
+
const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
|
|
151
|
+
throw new AffinityStateError(
|
|
152
|
+
`Contact #${id} was merged into #${row.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row.merged_into_contact_id}`
|
|
153
|
+
);
|
|
107
154
|
}
|
|
108
155
|
}
|
|
109
156
|
}
|
|
@@ -275,6 +322,134 @@ function findDuplicateDateAnchor(db, params) {
|
|
|
275
322
|
return null;
|
|
276
323
|
}
|
|
277
324
|
|
|
325
|
+
// src/events/loaders.ts
|
|
326
|
+
function loadEventParticipantViews(db, eventId) {
|
|
327
|
+
const rows = db.prepare(
|
|
328
|
+
`SELECT contact_id, role, directionality
|
|
329
|
+
FROM event_participants
|
|
330
|
+
WHERE event_id = ?`
|
|
331
|
+
).all(eventId);
|
|
332
|
+
return rows.map((row) => ({
|
|
333
|
+
contactId: row.contact_id,
|
|
334
|
+
role: row.role,
|
|
335
|
+
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
function loadEventRecord(db, eventId) {
|
|
339
|
+
const row = getEventRowById(db, eventId);
|
|
340
|
+
if (!row) {
|
|
341
|
+
throw new AffinityInvariantError("event not found");
|
|
342
|
+
}
|
|
343
|
+
const participants = loadEventParticipantViews(db, eventId);
|
|
344
|
+
return mapEventRowToEventRecord(row, participants);
|
|
345
|
+
}
|
|
346
|
+
function batchLoadEventRecords(db, eventIds) {
|
|
347
|
+
if (eventIds.length === 0) {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
const ph = eventIds.map(() => "?").join(", ");
|
|
351
|
+
const eventRows = db.prepare(
|
|
352
|
+
`SELECT id, type, occurred_at, summary, significance, moment_kind,
|
|
353
|
+
recurrence_kind, anchor_month, anchor_day,
|
|
354
|
+
anchor_contact_id, anchor_link_id,
|
|
355
|
+
created_at, updated_at, deleted_at
|
|
356
|
+
FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
|
|
357
|
+
).all(...eventIds);
|
|
358
|
+
const partRows = db.prepare(
|
|
359
|
+
`SELECT event_id, contact_id, role, directionality
|
|
360
|
+
FROM event_participants WHERE event_id IN (${ph})`
|
|
361
|
+
).all(...eventIds);
|
|
362
|
+
const partsByEvent = /* @__PURE__ */ new Map();
|
|
363
|
+
for (const p of partRows) {
|
|
364
|
+
let arr = partsByEvent.get(p.event_id);
|
|
365
|
+
if (!arr) {
|
|
366
|
+
arr = [];
|
|
367
|
+
partsByEvent.set(p.event_id, arr);
|
|
368
|
+
}
|
|
369
|
+
arr.push({
|
|
370
|
+
contactId: p.contact_id,
|
|
371
|
+
role: p.role,
|
|
372
|
+
...p.directionality === null ? {} : { directionality: p.directionality }
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
const rowById = new Map(eventRows.map((r) => [r.id, r]));
|
|
376
|
+
const out = [];
|
|
377
|
+
for (const id of eventIds) {
|
|
378
|
+
const row = rowById.get(id);
|
|
379
|
+
if (row) {
|
|
380
|
+
out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
function batchLoadEventParticipantViews(db, eventIds) {
|
|
386
|
+
const result = /* @__PURE__ */ new Map();
|
|
387
|
+
if (eventIds.length === 0) {
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
const ph = eventIds.map(() => "?").join(", ");
|
|
391
|
+
const rows = db.prepare(
|
|
392
|
+
`SELECT event_id, contact_id, role, directionality
|
|
393
|
+
FROM event_participants WHERE event_id IN (${ph})`
|
|
394
|
+
).all(...eventIds);
|
|
395
|
+
for (const row of rows) {
|
|
396
|
+
let arr = result.get(row.event_id);
|
|
397
|
+
if (!arr) {
|
|
398
|
+
arr = [];
|
|
399
|
+
result.set(row.event_id, arr);
|
|
400
|
+
}
|
|
401
|
+
arr.push({
|
|
402
|
+
contactId: row.contact_id,
|
|
403
|
+
role: row.role,
|
|
404
|
+
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
function requireDateAnchorEvent(db, eventId) {
|
|
410
|
+
const row = getEventRowById(db, eventId);
|
|
411
|
+
if (!row) {
|
|
412
|
+
throw new AffinityNotFoundError("event not found");
|
|
413
|
+
}
|
|
414
|
+
if (row.type !== "date_anchor") {
|
|
415
|
+
throw new AffinityInvariantError("not a date anchor");
|
|
416
|
+
}
|
|
417
|
+
return row;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/events/get_contact_journal.ts
|
|
421
|
+
function getContactJournal(db, contactId, options) {
|
|
422
|
+
assertDefaultOrdering("getContactJournal", options);
|
|
423
|
+
const { limit, offset } = resolveListLimitOffset(options);
|
|
424
|
+
const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
|
|
425
|
+
const params = [contactId];
|
|
426
|
+
if (options?.since !== void 0) {
|
|
427
|
+
clauses.push("e.occurred_at >= ?");
|
|
428
|
+
params.push(options.since);
|
|
429
|
+
}
|
|
430
|
+
if (options?.until !== void 0) {
|
|
431
|
+
clauses.push("e.occurred_at <= ?");
|
|
432
|
+
params.push(options.until);
|
|
433
|
+
}
|
|
434
|
+
if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
|
|
435
|
+
const ph = options.eventTypes.map(() => "?").join(", ");
|
|
436
|
+
clauses.push(`e.type IN (${ph})`);
|
|
437
|
+
params.push(...options.eventTypes);
|
|
438
|
+
}
|
|
439
|
+
const where = clauses.join(" AND ");
|
|
440
|
+
const rows = db.prepare(
|
|
441
|
+
`SELECT e.id FROM events e
|
|
442
|
+
INNER JOIN event_participants ep ON ep.event_id = e.id
|
|
443
|
+
WHERE ${where}
|
|
444
|
+
ORDER BY e.occurred_at DESC, e.id DESC
|
|
445
|
+
LIMIT ? OFFSET ?`
|
|
446
|
+
).all(...params, limit, offset);
|
|
447
|
+
return batchLoadEventRecords(
|
|
448
|
+
db,
|
|
449
|
+
rows.map((r) => r.id)
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
278
453
|
// src/resolve_now.ts
|
|
279
454
|
function resolveNow(now) {
|
|
280
455
|
if (now !== void 0) {
|
|
@@ -288,6 +463,85 @@ function resolveNow(now) {
|
|
|
288
463
|
return Date.now();
|
|
289
464
|
}
|
|
290
465
|
|
|
466
|
+
// src/events/list_open_commitments.ts
|
|
467
|
+
var DAY_MS = 864e5;
|
|
468
|
+
function mapResolution(resolvedAt, resolution) {
|
|
469
|
+
if (resolvedAt === null) {
|
|
470
|
+
return "open";
|
|
471
|
+
}
|
|
472
|
+
if (resolution === "cancelled") {
|
|
473
|
+
return "cancelled";
|
|
474
|
+
}
|
|
475
|
+
return "resolved";
|
|
476
|
+
}
|
|
477
|
+
function listOpenCommitments(db, filters, options) {
|
|
478
|
+
assertDefaultOrdering("listOpenCommitments", options);
|
|
479
|
+
const now = resolveNow(options?.now);
|
|
480
|
+
const { limit, offset } = resolveListLimitOffset(options);
|
|
481
|
+
const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
|
|
482
|
+
const params = [];
|
|
483
|
+
if (options?.since !== void 0) {
|
|
484
|
+
clauses.push("e.occurred_at >= ?");
|
|
485
|
+
params.push(options.since);
|
|
486
|
+
}
|
|
487
|
+
if (options?.until !== void 0) {
|
|
488
|
+
clauses.push("e.occurred_at <= ?");
|
|
489
|
+
params.push(options.until);
|
|
490
|
+
}
|
|
491
|
+
if (filters?.commitmentType !== void 0) {
|
|
492
|
+
if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
|
|
493
|
+
throw new AffinityValidationError("invalid commitmentType filter");
|
|
494
|
+
}
|
|
495
|
+
clauses.push("oc.commitment_type = ?");
|
|
496
|
+
params.push(filters.commitmentType);
|
|
497
|
+
}
|
|
498
|
+
if (filters?.contactId !== void 0) {
|
|
499
|
+
clauses.push(
|
|
500
|
+
"EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
|
|
501
|
+
);
|
|
502
|
+
params.push(filters.contactId);
|
|
503
|
+
}
|
|
504
|
+
if (filters?.linkId !== void 0) {
|
|
505
|
+
clauses.push(
|
|
506
|
+
"EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
|
|
507
|
+
);
|
|
508
|
+
params.push(filters.linkId);
|
|
509
|
+
}
|
|
510
|
+
if (filters?.horizonDays !== void 0) {
|
|
511
|
+
if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
|
|
512
|
+
throw new AffinityValidationError(
|
|
513
|
+
"horizonDays must be a non-negative integer"
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const anchor = options?.since ?? now;
|
|
517
|
+
clauses.push("oc.due_at IS NOT NULL");
|
|
518
|
+
clauses.push("oc.due_at <= ?");
|
|
519
|
+
params.push(anchor + filters.horizonDays * DAY_MS);
|
|
520
|
+
}
|
|
521
|
+
const where = clauses.join(" AND ");
|
|
522
|
+
const rows = db.prepare(
|
|
523
|
+
`SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
|
|
524
|
+
e.type, e.summary
|
|
525
|
+
FROM open_commitments oc
|
|
526
|
+
INNER JOIN events e ON e.id = oc.event_id
|
|
527
|
+
WHERE ${where}
|
|
528
|
+
ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
|
|
529
|
+
LIMIT ? OFFSET ?`
|
|
530
|
+
).all(...params, limit, offset);
|
|
531
|
+
const eventIds = rows.map((r) => r.event_id);
|
|
532
|
+
const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
|
|
533
|
+
return rows.map((r) => ({
|
|
534
|
+
eventId: r.event_id,
|
|
535
|
+
type: r.type,
|
|
536
|
+
summary: r.summary,
|
|
537
|
+
participants: partsByEvent.get(r.event_id) ?? [],
|
|
538
|
+
dueAt: r.due_at,
|
|
539
|
+
dueAtIso: r.due_at === null ? null : new Date(r.due_at).toISOString(),
|
|
540
|
+
overdue: r.due_at !== null && r.due_at < now,
|
|
541
|
+
resolutionState: mapResolution(r.resolved_at, r.resolution)
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
|
|
291
545
|
// src/with_transaction.ts
|
|
292
546
|
var transactionDepth = /* @__PURE__ */ new WeakMap();
|
|
293
547
|
function withTransaction(db, fn) {
|
|
@@ -559,13 +813,20 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
|
|
|
559
813
|
const topLinks = linkRows.map((l) => mapLinkRowToLinkListItem(l));
|
|
560
814
|
const activeDates = loadActiveDatesForContact(db, contactId);
|
|
561
815
|
const contactRollup = loadContactRollup(db, contactId);
|
|
816
|
+
const winner = row.merged_into_contact_id === null ? null : getContactRowById(db, row.merged_into_contact_id);
|
|
562
817
|
return {
|
|
818
|
+
...winner ? { mergedInto: { id: winner.id, name: winner.name } } : {},
|
|
563
819
|
contact: mapContactRowToContactCore(row),
|
|
564
820
|
identities,
|
|
565
821
|
attributes,
|
|
566
822
|
topLinks,
|
|
567
823
|
activeDates,
|
|
568
|
-
warnings:
|
|
824
|
+
warnings: [
|
|
825
|
+
...contactRollup?.warnings ?? [],
|
|
826
|
+
...winner ? [
|
|
827
|
+
`Contact #${row.id} was merged into #${winner.id} ${winner.name}; use contactId ${winner.id}`
|
|
828
|
+
] : []
|
|
829
|
+
],
|
|
569
830
|
rollups: contactRollup?.rollup ?? null
|
|
570
831
|
};
|
|
571
832
|
}
|
|
@@ -573,7 +834,27 @@ function loadContactProfileRecord(db, contactId, topLinksLimit) {
|
|
|
573
834
|
// src/contacts/get_contact_profile.ts
|
|
574
835
|
function getContactProfile(db, contactId, options) {
|
|
575
836
|
const t = options?.topLinksLimit ?? 8;
|
|
576
|
-
|
|
837
|
+
const profile = loadContactProfileRecord(db, contactId, t);
|
|
838
|
+
if (!profile) return null;
|
|
839
|
+
const ties = db.prepare(`SELECT l.id, l.kind, l.role, c.id AS contactId, c.name
|
|
840
|
+
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
|
|
841
|
+
WHERE l.is_structural = 1 AND l.removed_at IS NULL AND (l.from_contact_id = ? OR l.to_contact_id = ?)
|
|
842
|
+
AND c.deleted_at IS NULL ORDER BY l.id`).all(contactId, contactId, contactId);
|
|
843
|
+
return {
|
|
844
|
+
...profile,
|
|
845
|
+
recentEvents: getContactJournal(db, contactId, {
|
|
846
|
+
limit: options?.recentEventsLimit ?? 5
|
|
847
|
+
}).map((event) => ({
|
|
848
|
+
...event,
|
|
849
|
+
occurredAtIso: new Date(event.occurredAt).toISOString()
|
|
850
|
+
})),
|
|
851
|
+
openCommitments: listOpenCommitments(
|
|
852
|
+
db,
|
|
853
|
+
{ contactId },
|
|
854
|
+
options?.now === void 0 ? void 0 : { now: options.now }
|
|
855
|
+
),
|
|
856
|
+
ties
|
|
857
|
+
};
|
|
577
858
|
}
|
|
578
859
|
|
|
579
860
|
// src/contacts/get_owner_profile.ts
|
|
@@ -586,45 +867,6 @@ function getOwnerProfile(db, options) {
|
|
|
586
867
|
return loadContactProfileRecord(db, ownerId, t);
|
|
587
868
|
}
|
|
588
869
|
|
|
589
|
-
// src/lib/read/assert_default_ordering.ts
|
|
590
|
-
function assertDefaultOrdering(readName, options) {
|
|
591
|
-
if (options?.sort !== void 0 || options?.descending !== void 0) {
|
|
592
|
-
throw new AffinityValidationError(
|
|
593
|
-
`${readName} does not support custom sort ordering`
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// src/lib/types/read_list_options.ts
|
|
599
|
-
var AFFINITY_DEFAULT_LIST_LIMIT = 50;
|
|
600
|
-
var AFFINITY_MAX_LIST_LIMIT = 250;
|
|
601
|
-
|
|
602
|
-
// src/lib/types/resolve_affinity_list_limit.ts
|
|
603
|
-
function resolveAffinityListLimit(limit) {
|
|
604
|
-
if (limit === void 0) {
|
|
605
|
-
return AFFINITY_DEFAULT_LIST_LIMIT;
|
|
606
|
-
}
|
|
607
|
-
if (!Number.isInteger(limit) || limit < 1) {
|
|
608
|
-
throw new AffinityValidationError("limit must be a positive integer");
|
|
609
|
-
}
|
|
610
|
-
if (limit > AFFINITY_MAX_LIST_LIMIT) {
|
|
611
|
-
throw new AffinityValidationError(
|
|
612
|
-
`limit must be <= ${String(AFFINITY_MAX_LIST_LIMIT)}`
|
|
613
|
-
);
|
|
614
|
-
}
|
|
615
|
-
return limit;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// src/lib/read/resolve_list_limit_offset.ts
|
|
619
|
-
function resolveListLimitOffset(options) {
|
|
620
|
-
const limit = resolveAffinityListLimit(options?.limit);
|
|
621
|
-
const offset = options?.offset ?? 0;
|
|
622
|
-
if (!Number.isInteger(offset) || offset < 0) {
|
|
623
|
-
throw new AffinityValidationError("offset must be a non-negative integer");
|
|
624
|
-
}
|
|
625
|
-
return { limit, offset };
|
|
626
|
-
}
|
|
627
|
-
|
|
628
870
|
// src/events/list_moments.ts
|
|
629
871
|
function listMoments(db, filters, options) {
|
|
630
872
|
assertDefaultOrdering("listMoments", options);
|
|
@@ -680,101 +922,6 @@ function listMoments(db, filters, options) {
|
|
|
680
922
|
}));
|
|
681
923
|
}
|
|
682
924
|
|
|
683
|
-
// src/events/loaders.ts
|
|
684
|
-
function loadEventParticipantViews(db, eventId) {
|
|
685
|
-
const rows = db.prepare(
|
|
686
|
-
`SELECT contact_id, role, directionality
|
|
687
|
-
FROM event_participants
|
|
688
|
-
WHERE event_id = ?`
|
|
689
|
-
).all(eventId);
|
|
690
|
-
return rows.map((row) => ({
|
|
691
|
-
contactId: row.contact_id,
|
|
692
|
-
role: row.role,
|
|
693
|
-
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
694
|
-
}));
|
|
695
|
-
}
|
|
696
|
-
function loadEventRecord(db, eventId) {
|
|
697
|
-
const row = getEventRowById(db, eventId);
|
|
698
|
-
if (!row) {
|
|
699
|
-
throw new AffinityInvariantError("event not found");
|
|
700
|
-
}
|
|
701
|
-
const participants = loadEventParticipantViews(db, eventId);
|
|
702
|
-
return mapEventRowToEventRecord(row, participants);
|
|
703
|
-
}
|
|
704
|
-
function batchLoadEventRecords(db, eventIds) {
|
|
705
|
-
if (eventIds.length === 0) {
|
|
706
|
-
return [];
|
|
707
|
-
}
|
|
708
|
-
const ph = eventIds.map(() => "?").join(", ");
|
|
709
|
-
const eventRows = db.prepare(
|
|
710
|
-
`SELECT id, type, occurred_at, summary, significance, moment_kind,
|
|
711
|
-
recurrence_kind, anchor_month, anchor_day,
|
|
712
|
-
anchor_contact_id, anchor_link_id,
|
|
713
|
-
created_at, updated_at, deleted_at
|
|
714
|
-
FROM events WHERE id IN (${ph}) AND deleted_at IS NULL`
|
|
715
|
-
).all(...eventIds);
|
|
716
|
-
const partRows = db.prepare(
|
|
717
|
-
`SELECT event_id, contact_id, role, directionality
|
|
718
|
-
FROM event_participants WHERE event_id IN (${ph})`
|
|
719
|
-
).all(...eventIds);
|
|
720
|
-
const partsByEvent = /* @__PURE__ */ new Map();
|
|
721
|
-
for (const p of partRows) {
|
|
722
|
-
let arr = partsByEvent.get(p.event_id);
|
|
723
|
-
if (!arr) {
|
|
724
|
-
arr = [];
|
|
725
|
-
partsByEvent.set(p.event_id, arr);
|
|
726
|
-
}
|
|
727
|
-
arr.push({
|
|
728
|
-
contactId: p.contact_id,
|
|
729
|
-
role: p.role,
|
|
730
|
-
...p.directionality === null ? {} : { directionality: p.directionality }
|
|
731
|
-
});
|
|
732
|
-
}
|
|
733
|
-
const rowById = new Map(eventRows.map((r) => [r.id, r]));
|
|
734
|
-
const out = [];
|
|
735
|
-
for (const id of eventIds) {
|
|
736
|
-
const row = rowById.get(id);
|
|
737
|
-
if (row) {
|
|
738
|
-
out.push(mapEventRowToEventRecord(row, partsByEvent.get(id) ?? []));
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
return out;
|
|
742
|
-
}
|
|
743
|
-
function batchLoadEventParticipantViews(db, eventIds) {
|
|
744
|
-
const result = /* @__PURE__ */ new Map();
|
|
745
|
-
if (eventIds.length === 0) {
|
|
746
|
-
return result;
|
|
747
|
-
}
|
|
748
|
-
const ph = eventIds.map(() => "?").join(", ");
|
|
749
|
-
const rows = db.prepare(
|
|
750
|
-
`SELECT event_id, contact_id, role, directionality
|
|
751
|
-
FROM event_participants WHERE event_id IN (${ph})`
|
|
752
|
-
).all(...eventIds);
|
|
753
|
-
for (const row of rows) {
|
|
754
|
-
let arr = result.get(row.event_id);
|
|
755
|
-
if (!arr) {
|
|
756
|
-
arr = [];
|
|
757
|
-
result.set(row.event_id, arr);
|
|
758
|
-
}
|
|
759
|
-
arr.push({
|
|
760
|
-
contactId: row.contact_id,
|
|
761
|
-
role: row.role,
|
|
762
|
-
...row.directionality === null ? {} : { directionality: row.directionality }
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
return result;
|
|
766
|
-
}
|
|
767
|
-
function requireDateAnchorEvent(db, eventId) {
|
|
768
|
-
const row = getEventRowById(db, eventId);
|
|
769
|
-
if (!row) {
|
|
770
|
-
throw new AffinityNotFoundError("event not found");
|
|
771
|
-
}
|
|
772
|
-
if (row.type !== "date_anchor") {
|
|
773
|
-
throw new AffinityInvariantError("not a date anchor");
|
|
774
|
-
}
|
|
775
|
-
return row;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
925
|
// src/links/queries.ts
|
|
779
926
|
function getLinkRowById(db, id) {
|
|
780
927
|
const row = db.prepare(
|
|
@@ -972,7 +1119,7 @@ function nextCadenceDays(params) {
|
|
|
972
1119
|
}
|
|
973
1120
|
|
|
974
1121
|
// src/links/rollups.ts
|
|
975
|
-
var
|
|
1122
|
+
var DAY_MS2 = 864e5;
|
|
976
1123
|
function parseJsonObject2(raw) {
|
|
977
1124
|
try {
|
|
978
1125
|
const parsed = JSON.parse(raw);
|
|
@@ -1030,7 +1177,7 @@ function recencyScore(occurredAt, cadenceDays, now) {
|
|
|
1030
1177
|
return 0;
|
|
1031
1178
|
}
|
|
1032
1179
|
const cadence = Math.max(cadenceDays ?? 0, 1);
|
|
1033
|
-
const daysSince = Math.max(0, (now - occurredAt) /
|
|
1180
|
+
const daysSince = Math.max(0, (now - occurredAt) / DAY_MS2);
|
|
1034
1181
|
return Math.max(0, 1 - daysSince / cadence);
|
|
1035
1182
|
}
|
|
1036
1183
|
function refreshLinkRollup(db, linkId, now) {
|
|
@@ -1041,15 +1188,15 @@ function refreshLinkRollup(db, linkId, now) {
|
|
|
1041
1188
|
const latest = db.prepare(
|
|
1042
1189
|
`SELECT event_id, occurred_at
|
|
1043
1190
|
FROM link_event_effects
|
|
1044
|
-
WHERE link_id = ?
|
|
1191
|
+
WHERE link_id = ? AND counts_for_recency = 1
|
|
1045
1192
|
ORDER BY occurred_at DESC, event_id DESC
|
|
1046
1193
|
LIMIT 1`
|
|
1047
1194
|
).get(linkId);
|
|
1048
|
-
const trailingWindowStart = now - 180 *
|
|
1195
|
+
const trailingWindowStart = now - 180 * DAY_MS2;
|
|
1049
1196
|
const counts = db.prepare(
|
|
1050
1197
|
`SELECT
|
|
1051
1198
|
COUNT(*) AS total_meaningful_events,
|
|
1052
|
-
SUM(CASE WHEN affinity_delta > 0 OR trust_delta > 0 THEN 1 ELSE 0 END) AS positive_meaningful_events
|
|
1199
|
+
SUM(CASE WHEN counts_for_recency = 1 AND (affinity_delta > 0 OR trust_delta > 0) THEN 1 ELSE 0 END) AS positive_meaningful_events
|
|
1053
1200
|
FROM link_event_effects
|
|
1054
1201
|
WHERE link_id = ? AND occurred_at >= ?`
|
|
1055
1202
|
).get(linkId, trailingWindowStart);
|
|
@@ -1057,7 +1204,7 @@ function refreshLinkRollup(db, linkId, now) {
|
|
|
1057
1204
|
`SELECT ep.directionality
|
|
1058
1205
|
FROM link_event_effects le
|
|
1059
1206
|
INNER JOIN event_participants ep ON ep.event_id = le.event_id
|
|
1060
|
-
WHERE le.link_id = ? AND ep.contact_id = ?
|
|
1207
|
+
WHERE le.link_id = ? AND le.counts_for_recency = 1 AND ep.contact_id = ?
|
|
1061
1208
|
AND ep.directionality IS NOT NULL
|
|
1062
1209
|
AND le.occurred_at >= ?`
|
|
1063
1210
|
).all(linkId, link.from_contact_id, trailingWindowStart);
|
|
@@ -1083,7 +1230,7 @@ function refreshLinkRollup(db, linkId, now) {
|
|
|
1083
1230
|
link.cadence_days,
|
|
1084
1231
|
now
|
|
1085
1232
|
);
|
|
1086
|
-
const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) /
|
|
1233
|
+
const daysSinceLastMeaningfulEvent = latest === void 0 ? 0 : Math.max(0, (now - latest.occurred_at) / DAY_MS2);
|
|
1087
1234
|
const driftPriorityValue = driftPriority({
|
|
1088
1235
|
daysSinceLastMeaningfulEvent,
|
|
1089
1236
|
cadenceDays: link.cadence_days ?? 1,
|
|
@@ -1210,8 +1357,55 @@ function getLinkDetail(db, linkId, options) {
|
|
|
1210
1357
|
};
|
|
1211
1358
|
}
|
|
1212
1359
|
|
|
1360
|
+
// src/tools/parse_time_input.ts
|
|
1361
|
+
var timeInputForms = "epoch milliseconds integer, ISO 8601 date-time with Z/offset, YYYY-MM-DD, today, tomorrow, yesterday, +3d or -2d";
|
|
1362
|
+
function parseTimeInput(value, field = "time", now = Date.now()) {
|
|
1363
|
+
const fail = () => {
|
|
1364
|
+
throw new AffinityValidationError(`${field}: expected ${timeInputForms}`);
|
|
1365
|
+
};
|
|
1366
|
+
if (typeof value === "number")
|
|
1367
|
+
return Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()) ? value : fail();
|
|
1368
|
+
if (typeof value !== "string") return fail();
|
|
1369
|
+
const text = value.trim();
|
|
1370
|
+
const relative = /^(?:today|tomorrow|yesterday|[+-]\d+d)$/.test(text);
|
|
1371
|
+
let day;
|
|
1372
|
+
if (relative) {
|
|
1373
|
+
const offset = text === "today" ? 0 : text === "tomorrow" ? 1 : text === "yesterday" ? -1 : Number(text.slice(0, -1));
|
|
1374
|
+
day = Math.floor(now / 864e5) * 864e5 + offset * 864e5;
|
|
1375
|
+
} else if (/^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
|
1376
|
+
day = Date.parse(`${text}T00:00:00.000Z`);
|
|
1377
|
+
if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== text)
|
|
1378
|
+
return fail();
|
|
1379
|
+
} else {
|
|
1380
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
1381
|
+
text
|
|
1382
|
+
))
|
|
1383
|
+
return fail();
|
|
1384
|
+
const calendar = Date.parse(`${text.slice(0, 10)}T00:00:00Z`);
|
|
1385
|
+
if (!Number.isFinite(calendar) || new Date(calendar).toISOString().slice(0, 10) !== text.slice(0, 10))
|
|
1386
|
+
return fail();
|
|
1387
|
+
const parsed = Date.parse(text);
|
|
1388
|
+
return Number.isFinite(parsed) ? parsed : fail();
|
|
1389
|
+
}
|
|
1390
|
+
const result = day + (field.split(".").at(-1) === "dueAt" ? 86399999 : 0);
|
|
1391
|
+
return Number.isSafeInteger(result) && Number.isFinite(new Date(result).getTime()) ? result : fail();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1213
1394
|
// src/tools/tool_metadata.ts
|
|
1214
1395
|
function defineAffinityTool(definition) {
|
|
1396
|
+
const visit = (schema) => {
|
|
1397
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
1398
|
+
if ((key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.type === "integer") {
|
|
1399
|
+
child.type = void 0;
|
|
1400
|
+
child.oneOf = [{ type: "integer" }, { type: "string" }];
|
|
1401
|
+
child.description = `${child.description ?? "Time."} Accepts ${timeInputForms}. UTC midnight for dates; dueAt uses UTC end of day.`;
|
|
1402
|
+
}
|
|
1403
|
+
visit(child);
|
|
1404
|
+
}
|
|
1405
|
+
if (schema.items) visit(schema.items);
|
|
1406
|
+
for (const child of schema.oneOf ?? schema.anyOf ?? []) visit(child);
|
|
1407
|
+
};
|
|
1408
|
+
visit(definition.inputSchema);
|
|
1215
1409
|
return definition;
|
|
1216
1410
|
}
|
|
1217
1411
|
function stringSchema(description, options = {}) {
|
|
@@ -1258,6 +1452,12 @@ function objectSchema(properties, required, description) {
|
|
|
1258
1452
|
function nullableStringSchema(description) {
|
|
1259
1453
|
return { anyOf: [{ type: "string" }, { type: "null" }], description };
|
|
1260
1454
|
}
|
|
1455
|
+
function oneOfSchema(schemas, description) {
|
|
1456
|
+
const schema = {
|
|
1457
|
+
oneOf: schemas
|
|
1458
|
+
};
|
|
1459
|
+
return schema;
|
|
1460
|
+
}
|
|
1261
1461
|
|
|
1262
1462
|
// src/tools/tool_names.ts
|
|
1263
1463
|
var searchAffinityToolName = "search_affinity";
|
|
@@ -1291,6 +1491,7 @@ function toContactEntityRef(value) {
|
|
|
1291
1491
|
function toContactListToolItem(value) {
|
|
1292
1492
|
return {
|
|
1293
1493
|
...toContactEntityRef(value),
|
|
1494
|
+
..."matchedOn" in value ? { matchedOn: value.matchedOn } : {},
|
|
1294
1495
|
subtitle: "kind" in value ? value.kind : void 0
|
|
1295
1496
|
};
|
|
1296
1497
|
}
|
|
@@ -1322,7 +1523,9 @@ function toEventEntityRef(value) {
|
|
|
1322
1523
|
}
|
|
1323
1524
|
function toEventListToolItem(value) {
|
|
1324
1525
|
return {
|
|
1526
|
+
...value,
|
|
1325
1527
|
...toEventEntityRef(value),
|
|
1528
|
+
occurredAtIso: new Date(value.occurredAt).toISOString(),
|
|
1326
1529
|
snippet: `${value.participants.length} participant(s)`
|
|
1327
1530
|
};
|
|
1328
1531
|
}
|
|
@@ -1370,7 +1573,7 @@ function withOptionalFields(result, options) {
|
|
|
1370
1573
|
if (options.warnings && options.warnings.length > 0) {
|
|
1371
1574
|
result.warnings = options.warnings;
|
|
1372
1575
|
}
|
|
1373
|
-
return result;
|
|
1576
|
+
return roundToolValues(result);
|
|
1374
1577
|
}
|
|
1375
1578
|
function toolWarning(code, message) {
|
|
1376
1579
|
return { code, message };
|
|
@@ -1434,9 +1637,31 @@ function toolFailure(kind, code, summary, message, options = {}) {
|
|
|
1434
1637
|
options
|
|
1435
1638
|
);
|
|
1436
1639
|
}
|
|
1640
|
+
function roundToolValues(value) {
|
|
1641
|
+
if (typeof value === "number")
|
|
1642
|
+
return Number.isInteger(value) ? value : Math.round(value * 1e3) / 1e3;
|
|
1643
|
+
if (Array.isArray(value)) return value.map(roundToolValues);
|
|
1644
|
+
if (value !== null && typeof value === "object")
|
|
1645
|
+
return Object.fromEntries(
|
|
1646
|
+
Object.entries(value).map(([key, child]) => [
|
|
1647
|
+
key,
|
|
1648
|
+
roundToolValues(child)
|
|
1649
|
+
])
|
|
1650
|
+
);
|
|
1651
|
+
return value;
|
|
1652
|
+
}
|
|
1437
1653
|
|
|
1438
1654
|
// src/tools/tool_resolvers.ts
|
|
1439
|
-
function resolveContactLocator(db, locator, field = "contact") {
|
|
1655
|
+
function resolveContactLocator(db, locator, field = "contact", allowMerged = false) {
|
|
1656
|
+
if (!locator || typeof locator !== "object")
|
|
1657
|
+
return {
|
|
1658
|
+
ok: false,
|
|
1659
|
+
result: toolNeedsClarification(
|
|
1660
|
+
"missing_required_choice",
|
|
1661
|
+
`Provide ${field} locator.`,
|
|
1662
|
+
[field]
|
|
1663
|
+
)
|
|
1664
|
+
};
|
|
1440
1665
|
if ("contactId" in locator) {
|
|
1441
1666
|
const row2 = getContactRowById(db, locator.contactId);
|
|
1442
1667
|
if (!row2) {
|
|
@@ -1450,8 +1675,25 @@ function resolveContactLocator(db, locator, field = "contact") {
|
|
|
1450
1675
|
)
|
|
1451
1676
|
};
|
|
1452
1677
|
}
|
|
1678
|
+
if (!allowMerged && row2.merged_into_contact_id !== null) {
|
|
1679
|
+
const winner = getContactRowById(db, row2.merged_into_contact_id);
|
|
1680
|
+
const message = `Contact #${row2.id} was merged into #${row2.merged_into_contact_id} ${winner?.name ?? ""}; use contactId ${row2.merged_into_contact_id}`;
|
|
1681
|
+
return {
|
|
1682
|
+
ok: false,
|
|
1683
|
+
result: toolFailure("domain", "invalid_state", message, message)
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1453
1686
|
return { ok: true, value: row2 };
|
|
1454
1687
|
}
|
|
1688
|
+
if (!("identity" in locator) || !locator.identity)
|
|
1689
|
+
return {
|
|
1690
|
+
ok: false,
|
|
1691
|
+
result: toolNeedsClarification(
|
|
1692
|
+
"missing_required_choice",
|
|
1693
|
+
`Provide ${field}.contactId or identity.`,
|
|
1694
|
+
[field]
|
|
1695
|
+
)
|
|
1696
|
+
};
|
|
1455
1697
|
const normalizedKey = normalizeIdentityKey(
|
|
1456
1698
|
locator.identity.type,
|
|
1457
1699
|
locator.identity.value
|
|
@@ -1509,6 +1751,15 @@ function resolveContactLocator(db, locator, field = "contact") {
|
|
|
1509
1751
|
return { ok: true, value: matchedContact };
|
|
1510
1752
|
}
|
|
1511
1753
|
function resolveLinkLocator(db, locator, field = "link") {
|
|
1754
|
+
if (!locator || typeof locator !== "object")
|
|
1755
|
+
return {
|
|
1756
|
+
ok: false,
|
|
1757
|
+
result: toolNeedsClarification(
|
|
1758
|
+
"missing_required_choice",
|
|
1759
|
+
`Provide ${field} locator.`,
|
|
1760
|
+
[field]
|
|
1761
|
+
)
|
|
1762
|
+
};
|
|
1512
1763
|
if ("linkId" in locator) {
|
|
1513
1764
|
const row = getLinkRowById(db, locator.linkId);
|
|
1514
1765
|
if (!row) {
|
|
@@ -1524,6 +1775,15 @@ function resolveLinkLocator(db, locator, field = "link") {
|
|
|
1524
1775
|
}
|
|
1525
1776
|
return { ok: true, value: row };
|
|
1526
1777
|
}
|
|
1778
|
+
if (!("endpoints" in locator) || !locator.endpoints)
|
|
1779
|
+
return {
|
|
1780
|
+
ok: false,
|
|
1781
|
+
result: toolNeedsClarification(
|
|
1782
|
+
"missing_required_choice",
|
|
1783
|
+
`Provide ${field}.linkId or endpoints.`,
|
|
1784
|
+
[field]
|
|
1785
|
+
)
|
|
1786
|
+
};
|
|
1527
1787
|
const { endpoints } = locator;
|
|
1528
1788
|
const clauses = [
|
|
1529
1789
|
"removed_at IS NULL",
|
|
@@ -1642,6 +1902,92 @@ function affinityErrorCodeToToolCode(error) {
|
|
|
1642
1902
|
}
|
|
1643
1903
|
}
|
|
1644
1904
|
|
|
1905
|
+
// src/tools/validate_tool_input.ts
|
|
1906
|
+
function validateToolInput(schema, value, path = "input", injectedNow = Date.now()) {
|
|
1907
|
+
if (schema.anyOf || schema.oneOf) {
|
|
1908
|
+
const choices = schema.anyOf ?? schema.oneOf ?? [];
|
|
1909
|
+
return choices.some(
|
|
1910
|
+
(choice) => validateToolInput(choice, value, path, injectedNow) === null
|
|
1911
|
+
) ? null : `${path}: invalid value`;
|
|
1912
|
+
}
|
|
1913
|
+
if (schema.type === "object") {
|
|
1914
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
1915
|
+
return `${path}: missing input or expected object`;
|
|
1916
|
+
const object = value;
|
|
1917
|
+
if (path.endsWith(".patch") && schema.additionalProperties === false) {
|
|
1918
|
+
for (const key of Object.keys(object)) {
|
|
1919
|
+
if (!Object.hasOwn(schema.properties ?? {}, key))
|
|
1920
|
+
return key === "kind" && "name" in (schema.properties ?? {}) ? "kind cannot be changed after creation" : `${path}.${key}: unknown patch key; allowed keys: ${Object.keys(schema.properties ?? {}).join(", ")}`;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
if (object.action === "revise" && schema.properties?.patch && object.patch === void 0)
|
|
1924
|
+
return `${path}.patch: missing required field`;
|
|
1925
|
+
if (object.patch !== void 0 && !schema.properties?.patch)
|
|
1926
|
+
return `${path}.patch: unknown patch key; allowed keys: none (use the action's named fields)`;
|
|
1927
|
+
const referenceNow = object.now === void 0 ? injectedNow : (() => {
|
|
1928
|
+
try {
|
|
1929
|
+
return parseTimeInput(
|
|
1930
|
+
object.now,
|
|
1931
|
+
`${path}.now`,
|
|
1932
|
+
injectedNow
|
|
1933
|
+
);
|
|
1934
|
+
} catch {
|
|
1935
|
+
return Date.now();
|
|
1936
|
+
}
|
|
1937
|
+
})();
|
|
1938
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
1939
|
+
if (object[key] !== void 0 && (key.endsWith("At") || ["now", "asOf", "since", "until"].includes(key)) && child.oneOf?.some((choice) => choice.type === "integer")) {
|
|
1940
|
+
try {
|
|
1941
|
+
object[key] = parseTimeInput(
|
|
1942
|
+
object[key],
|
|
1943
|
+
`${path}.${key}`,
|
|
1944
|
+
referenceNow
|
|
1945
|
+
);
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
return error.message;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
for (const key of schema.required ?? []) {
|
|
1952
|
+
if (object[key] === void 0)
|
|
1953
|
+
return `${path}.${key}: missing required field`;
|
|
1954
|
+
}
|
|
1955
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
1956
|
+
if (object[key] === void 0) continue;
|
|
1957
|
+
const error = validateToolInput(
|
|
1958
|
+
child,
|
|
1959
|
+
object[key],
|
|
1960
|
+
`${path}.${key}`,
|
|
1961
|
+
referenceNow
|
|
1962
|
+
);
|
|
1963
|
+
if (error) return error;
|
|
1964
|
+
}
|
|
1965
|
+
return null;
|
|
1966
|
+
}
|
|
1967
|
+
if (schema.type === "array") {
|
|
1968
|
+
if (!Array.isArray(value)) return `${path}: expected array`;
|
|
1969
|
+
for (const [index, item] of value.entries()) {
|
|
1970
|
+
const error = validateToolInput(
|
|
1971
|
+
schema.items ?? {},
|
|
1972
|
+
item,
|
|
1973
|
+
`${path}[${index}]`,
|
|
1974
|
+
injectedNow
|
|
1975
|
+
);
|
|
1976
|
+
if (error) return error;
|
|
1977
|
+
}
|
|
1978
|
+
return null;
|
|
1979
|
+
}
|
|
1980
|
+
if (schema.enum && !schema.enum.includes(value))
|
|
1981
|
+
return `${path}: expected ${schema.enum.join(", ")}`;
|
|
1982
|
+
if (schema.type === "null")
|
|
1983
|
+
return value === null ? null : `${path}: expected null`;
|
|
1984
|
+
if (schema.type === "integer")
|
|
1985
|
+
return Number.isInteger(value) ? null : `${path}: expected integer`;
|
|
1986
|
+
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";
|
|
1987
|
+
if (!valid) return `${path}: expected ${schema.type}`;
|
|
1988
|
+
return null;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1645
1991
|
// src/tools/inspect_affinity_item_tool.ts
|
|
1646
1992
|
function contactLocatorSchema(description) {
|
|
1647
1993
|
return {
|
|
@@ -1677,7 +2023,19 @@ function linkLocatorSchema(description) {
|
|
|
1677
2023
|
description
|
|
1678
2024
|
};
|
|
1679
2025
|
}
|
|
1680
|
-
function inspectAffinityItemToolHandler(db,
|
|
2026
|
+
function inspectAffinityItemToolHandler(db, rawInput) {
|
|
2027
|
+
const input = structuredClone(rawInput);
|
|
2028
|
+
const inputError = validateToolInput(
|
|
2029
|
+
inspectAffinityItemTool.inputSchema,
|
|
2030
|
+
input
|
|
2031
|
+
);
|
|
2032
|
+
if (inputError)
|
|
2033
|
+
return toolFailure(
|
|
2034
|
+
"protocol",
|
|
2035
|
+
"invalid_input",
|
|
2036
|
+
"Invalid input.",
|
|
2037
|
+
inputError
|
|
2038
|
+
);
|
|
1681
2039
|
return withToolHandling(() => {
|
|
1682
2040
|
if (input.kind === "owner_profile") {
|
|
1683
2041
|
const detail2 = getOwnerProfile(
|
|
@@ -1695,19 +2053,21 @@ function inspectAffinityItemToolHandler(db, input) {
|
|
|
1695
2053
|
return toolSuccess(
|
|
1696
2054
|
`Loaded owner profile for ${detail2.contact.name}.`,
|
|
1697
2055
|
{ kind: input.kind, detail: detail2 },
|
|
1698
|
-
{
|
|
2056
|
+
{
|
|
2057
|
+
entities: [toContactEntityRef(detail2.contact)],
|
|
2058
|
+
warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
|
|
2059
|
+
}
|
|
1699
2060
|
);
|
|
1700
2061
|
}
|
|
1701
2062
|
if (input.kind === "contact_profile") {
|
|
1702
|
-
const contact = resolveContactLocator(db, input.contact, "contact");
|
|
2063
|
+
const contact = resolveContactLocator(db, input.contact, "contact", true);
|
|
1703
2064
|
if (!contact.ok) {
|
|
1704
|
-
return contact.result;
|
|
1705
|
-
}
|
|
1706
|
-
const detail2 = getContactProfile(
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
);
|
|
2065
|
+
return contact.result;
|
|
2066
|
+
}
|
|
2067
|
+
const detail2 = getContactProfile(db, contact.value.id, {
|
|
2068
|
+
...input.topLinksLimit === void 0 ? {} : { topLinksLimit: input.topLinksLimit },
|
|
2069
|
+
...input.recentEventsLimit === void 0 ? {} : { recentEventsLimit: input.recentEventsLimit }
|
|
2070
|
+
});
|
|
1711
2071
|
if (!detail2) {
|
|
1712
2072
|
return toolFailure(
|
|
1713
2073
|
"domain",
|
|
@@ -1719,7 +2079,10 @@ function inspectAffinityItemToolHandler(db, input) {
|
|
|
1719
2079
|
return toolSuccess(
|
|
1720
2080
|
`Loaded contact profile for ${detail2.contact.name}.`,
|
|
1721
2081
|
{ kind: input.kind, detail: detail2 },
|
|
1722
|
-
{
|
|
2082
|
+
{
|
|
2083
|
+
entities: [toContactEntityRef(detail2.contact)],
|
|
2084
|
+
warnings: detail2.mergedInto ? [{ code: "partial_match", message: detail2.warnings.join("; ") }] : void 0
|
|
2085
|
+
}
|
|
1723
2086
|
);
|
|
1724
2087
|
}
|
|
1725
2088
|
const link = resolveLinkLocator(db, input.link, "link");
|
|
@@ -2160,21 +2523,18 @@ function unsetAttribute(db, target, name, removedAt) {
|
|
|
2160
2523
|
});
|
|
2161
2524
|
}
|
|
2162
2525
|
|
|
2163
|
-
// src/tools/tool_summary.ts
|
|
2164
|
-
function summarizeCount(count, singular) {
|
|
2165
|
-
if (count === 0) {
|
|
2166
|
-
return `No ${singular}s found.`;
|
|
2167
|
-
}
|
|
2168
|
-
if (count === 1) {
|
|
2169
|
-
return `Found 1 ${singular}.`;
|
|
2170
|
-
}
|
|
2171
|
-
return `Found ${count} ${singular}s.`;
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
2526
|
// src/tools/tool_mutation.ts
|
|
2175
|
-
function mutationToolResult(action, receipt, entitySummary) {
|
|
2527
|
+
function mutationToolResult(action, receipt, entitySummary, fields) {
|
|
2176
2528
|
const changedCount = receipt.created.length + receipt.updated.length + receipt.archived.length + receipt.removed.length;
|
|
2177
2529
|
const data = {
|
|
2530
|
+
changes: ["created", "updated", "archived", "removed"].flatMap(
|
|
2531
|
+
(verb) => receipt[verb].map((ref) => ({
|
|
2532
|
+
entity: ref.kind,
|
|
2533
|
+
id: ref.id,
|
|
2534
|
+
action: verb,
|
|
2535
|
+
...verb === "updated" && fields ? { fields } : {}
|
|
2536
|
+
}))
|
|
2537
|
+
),
|
|
2178
2538
|
action,
|
|
2179
2539
|
primary: receipt.primary,
|
|
2180
2540
|
created: receipt.created,
|
|
@@ -2184,7 +2544,7 @@ function mutationToolResult(action, receipt, entitySummary) {
|
|
|
2184
2544
|
affectedLinks: receipt.affectedLinks,
|
|
2185
2545
|
derivedEffects: receipt.derivedEffects
|
|
2186
2546
|
};
|
|
2187
|
-
const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` :
|
|
2547
|
+
const summary = changedCount === 0 ? `No ${entitySummary} changes were needed.` : describeChanges(receipt);
|
|
2188
2548
|
if (changedCount === 0) {
|
|
2189
2549
|
return toolNoOp(summary, data, {
|
|
2190
2550
|
entities: toReceiptEntityRefs(receipt)
|
|
@@ -2194,6 +2554,23 @@ function mutationToolResult(action, receipt, entitySummary) {
|
|
|
2194
2554
|
entities: toReceiptEntityRefs(receipt)
|
|
2195
2555
|
});
|
|
2196
2556
|
}
|
|
2557
|
+
var PLURALS = { identity: "identities" };
|
|
2558
|
+
function describeChanges(receipt) {
|
|
2559
|
+
const parts = [];
|
|
2560
|
+
for (const verb of ["created", "updated", "archived", "removed"]) {
|
|
2561
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2562
|
+
for (const ref of receipt[verb]) {
|
|
2563
|
+
counts.set(ref.kind, (counts.get(ref.kind) ?? 0) + 1);
|
|
2564
|
+
}
|
|
2565
|
+
if (counts.size === 0) continue;
|
|
2566
|
+
const items = [...counts].map(
|
|
2567
|
+
([kind, n]) => `${n} ${n === 1 ? kind : PLURALS[kind] ?? `${kind}s`}`
|
|
2568
|
+
);
|
|
2569
|
+
parts.push(`${verb} ${items.join(", ")}`);
|
|
2570
|
+
}
|
|
2571
|
+
const text = parts.join("; ");
|
|
2572
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
|
|
2573
|
+
}
|
|
2197
2574
|
|
|
2198
2575
|
// src/tools/manage_attribute_tool.ts
|
|
2199
2576
|
function contactLocatorSchema2(description) {
|
|
@@ -2244,7 +2621,23 @@ function resolveAttributeTarget(db, target) {
|
|
|
2244
2621
|
}
|
|
2245
2622
|
return { kind: "ok", target: { kind: "link", id: link.value.id } };
|
|
2246
2623
|
}
|
|
2247
|
-
function manageAttributeToolHandler(db,
|
|
2624
|
+
function manageAttributeToolHandler(db, rawInput) {
|
|
2625
|
+
const input = structuredClone(rawInput);
|
|
2626
|
+
const inputError = validateToolInput(manageAttributeTool.inputSchema, input);
|
|
2627
|
+
if (inputError)
|
|
2628
|
+
return toolFailure(
|
|
2629
|
+
"protocol",
|
|
2630
|
+
"invalid_input",
|
|
2631
|
+
"Invalid input.",
|
|
2632
|
+
inputError
|
|
2633
|
+
);
|
|
2634
|
+
if (!input.target)
|
|
2635
|
+
return toolFailure(
|
|
2636
|
+
"protocol",
|
|
2637
|
+
"invalid_input",
|
|
2638
|
+
"Missing target.",
|
|
2639
|
+
"target is required."
|
|
2640
|
+
);
|
|
2248
2641
|
return withToolHandling(() => {
|
|
2249
2642
|
const resolved = resolveAttributeTarget(db, input.target);
|
|
2250
2643
|
if (resolved.kind === "error") {
|
|
@@ -2870,13 +3263,13 @@ function repairBonus(consecutiveRepairEvents) {
|
|
|
2870
3263
|
}
|
|
2871
3264
|
|
|
2872
3265
|
// src/links/effects.ts
|
|
2873
|
-
var
|
|
3266
|
+
var DAY_MS3 = 864e5;
|
|
2874
3267
|
function clamp3(value, min, max) {
|
|
2875
3268
|
return Math.min(max, Math.max(min, value));
|
|
2876
3269
|
}
|
|
2877
3270
|
function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
|
|
2878
3271
|
const dayStart = utcStartOfDayMs(occurredAt);
|
|
2879
|
-
const dayEnd = dayStart +
|
|
3272
|
+
const dayEnd = dayStart + DAY_MS3;
|
|
2880
3273
|
const row = db.prepare(
|
|
2881
3274
|
`SELECT COUNT(*) AS c
|
|
2882
3275
|
FROM link_event_effects le
|
|
@@ -2889,7 +3282,7 @@ function countSameDaySimilarEvents(db, linkId, eventType, occurredAt) {
|
|
|
2889
3282
|
return Number(row.c ?? 0);
|
|
2890
3283
|
}
|
|
2891
3284
|
function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
|
|
2892
|
-
const weekStart = occurredAt - 7 *
|
|
3285
|
+
const weekStart = occurredAt - 7 * DAY_MS3;
|
|
2893
3286
|
const row = db.prepare(
|
|
2894
3287
|
`SELECT SUM(base_weight) AS total
|
|
2895
3288
|
FROM link_event_effects
|
|
@@ -2901,7 +3294,7 @@ function weeklyPositiveBaseWeight(db, linkId, occurredAt) {
|
|
|
2901
3294
|
return Number(row.total ?? 0);
|
|
2902
3295
|
}
|
|
2903
3296
|
function countRepairContext(db, linkId, occurredAt) {
|
|
2904
|
-
const thirtyDaysAgo = occurredAt - 30 *
|
|
3297
|
+
const thirtyDaysAgo = occurredAt - 30 * DAY_MS3;
|
|
2905
3298
|
const hasPriorDamage = db.prepare(
|
|
2906
3299
|
`SELECT 1 FROM link_event_effects
|
|
2907
3300
|
WHERE link_id = ? AND trust_delta < 0
|
|
@@ -2976,8 +3369,8 @@ function computePreferenceMatch(db, linkId, contactId, eventType, provenance) {
|
|
|
2976
3369
|
return preferenceMatch(liked, disliked);
|
|
2977
3370
|
}
|
|
2978
3371
|
function computeDateSalienceMultiplier(db, contactId, occurredAt, significance) {
|
|
2979
|
-
const windowBefore = 7 *
|
|
2980
|
-
const windowAfter = 2 *
|
|
3372
|
+
const windowBefore = 7 * DAY_MS3;
|
|
3373
|
+
const windowAfter = 2 * DAY_MS3;
|
|
2981
3374
|
const row = db.prepare(
|
|
2982
3375
|
`SELECT 1 FROM upcoming_occurrences uo
|
|
2983
3376
|
JOIN events e ON e.id = uo.event_id
|
|
@@ -3144,9 +3537,9 @@ function applyEventEffectToLink(db, params) {
|
|
|
3144
3537
|
).get(params.linkId);
|
|
3145
3538
|
const gapDays = latest === void 0 ? cadenceBefore : Math.max(
|
|
3146
3539
|
1,
|
|
3147
|
-
Math.round((params.occurredAt - latest.occurred_at) /
|
|
3540
|
+
Math.round((params.occurredAt - latest.occurred_at) / DAY_MS3)
|
|
3148
3541
|
);
|
|
3149
|
-
const cadenceAfter = nextCadenceDays({
|
|
3542
|
+
const cadenceAfter = commitmentResolution === "broken" || commitmentResolution === "cancelled" ? cadenceBefore : nextCadenceDays({
|
|
3150
3543
|
kind: linkKind,
|
|
3151
3544
|
currentCadenceDays: cadenceBefore,
|
|
3152
3545
|
gapDays
|
|
@@ -3209,6 +3602,11 @@ function applyEventEffectToLink(db, params) {
|
|
|
3209
3602
|
params.now,
|
|
3210
3603
|
params.now
|
|
3211
3604
|
);
|
|
3605
|
+
if (commitmentResolution === "broken" || commitmentResolution === "cancelled") {
|
|
3606
|
+
db.prepare(
|
|
3607
|
+
"UPDATE link_event_effects SET counts_for_recency = 0 WHERE event_id = ? AND link_id = ?"
|
|
3608
|
+
).run(params.eventId, params.linkId);
|
|
3609
|
+
}
|
|
3212
3610
|
refreshLinkRollup(db, params.linkId, params.now);
|
|
3213
3611
|
return {
|
|
3214
3612
|
linkId: params.linkId,
|
|
@@ -3558,12 +3956,16 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
|
|
|
3558
3956
|
if (row.type !== "promise" && row.type !== "agreement") {
|
|
3559
3957
|
throw new AffinityInvariantError("not a commitment event");
|
|
3560
3958
|
}
|
|
3561
|
-
const open = db.prepare(
|
|
3959
|
+
const open = db.prepare(
|
|
3960
|
+
"SELECT resolved_at, resolution FROM open_commitments WHERE event_id = ?"
|
|
3961
|
+
).get(commitmentEventId);
|
|
3562
3962
|
if (!open) {
|
|
3563
3963
|
throw new AffinityNotFoundError("open commitment not found");
|
|
3564
3964
|
}
|
|
3565
3965
|
if (open.resolved_at !== null) {
|
|
3566
|
-
throw new
|
|
3966
|
+
throw new AffinityConflictError(
|
|
3967
|
+
`Commitment #${commitmentEventId} is already resolved (${open.resolution})`
|
|
3968
|
+
);
|
|
3567
3969
|
}
|
|
3568
3970
|
const now = resolveNow(options?.now);
|
|
3569
3971
|
const originalParticipants = loadEventParticipantViews(
|
|
@@ -3576,7 +3978,9 @@ function resolveCommitment(db, commitmentEventId, resolution, options) {
|
|
|
3576
3978
|
WHERE event_id = ? AND resolved_at IS NULL`
|
|
3577
3979
|
).run(resolution, now, now, commitmentEventId);
|
|
3578
3980
|
if (Number(result.changes ?? 0) === 0) {
|
|
3579
|
-
throw new
|
|
3981
|
+
throw new AffinityConflictError(
|
|
3982
|
+
`Commitment #${commitmentEventId} is already resolved (${open.resolution})`
|
|
3983
|
+
);
|
|
3580
3984
|
}
|
|
3581
3985
|
const shouldCreateResolvingEvent = options?.resolvingEvent !== void 0 || resolution === "broken";
|
|
3582
3986
|
const created = [];
|
|
@@ -3664,9 +4068,25 @@ function ensureOwnerParticipates(db, participants) {
|
|
|
3664
4068
|
function prepareParticipants(db, raw) {
|
|
3665
4069
|
return ensureOwnerParticipates(db, raw.map(normalizeParticipant));
|
|
3666
4070
|
}
|
|
3667
|
-
function manageCommitmentToolHandler(db,
|
|
4071
|
+
function manageCommitmentToolHandler(db, rawInput) {
|
|
4072
|
+
const input = structuredClone(rawInput);
|
|
4073
|
+
const inputError = validateToolInput(manageCommitmentTool.inputSchema, input);
|
|
4074
|
+
if (inputError)
|
|
4075
|
+
return toolFailure(
|
|
4076
|
+
"protocol",
|
|
4077
|
+
"invalid_input",
|
|
4078
|
+
"Invalid input.",
|
|
4079
|
+
inputError
|
|
4080
|
+
);
|
|
3668
4081
|
return withToolHandling(() => {
|
|
3669
4082
|
if (input.action === "record") {
|
|
4083
|
+
if (!input.input)
|
|
4084
|
+
return toolFailure(
|
|
4085
|
+
"protocol",
|
|
4086
|
+
"invalid_input",
|
|
4087
|
+
"Missing input.",
|
|
4088
|
+
"input is required for record."
|
|
4089
|
+
);
|
|
3670
4090
|
const patched = {
|
|
3671
4091
|
...input.input,
|
|
3672
4092
|
participants: prepareParticipants(
|
|
@@ -3733,7 +4153,7 @@ var manageCommitmentTool = defineAffinityTool({
|
|
|
3733
4153
|
role: stringSchema("Participant role."),
|
|
3734
4154
|
directionality: stringSchema("Optional directionality.")
|
|
3735
4155
|
},
|
|
3736
|
-
["
|
|
4156
|
+
["role"]
|
|
3737
4157
|
),
|
|
3738
4158
|
"Participant list. Must be non-empty and include the owner contact."
|
|
3739
4159
|
),
|
|
@@ -3780,6 +4200,142 @@ var manageCommitmentTool = defineAffinityTool({
|
|
|
3780
4200
|
handler: manageCommitmentToolHandler
|
|
3781
4201
|
});
|
|
3782
4202
|
|
|
4203
|
+
// src/contacts/delete_contact.ts
|
|
4204
|
+
function deleteContact(db, locatorId, options) {
|
|
4205
|
+
return withTransaction(db, () => {
|
|
4206
|
+
const contact = db.prepare("SELECT is_owner FROM contacts WHERE id = ?").get(locatorId);
|
|
4207
|
+
if (!contact) throw new AffinityNotFoundError("contact not found");
|
|
4208
|
+
if (contact.is_owner)
|
|
4209
|
+
throw new AffinityStateError("The owner contact cannot be deleted");
|
|
4210
|
+
const tables = [
|
|
4211
|
+
"contacts",
|
|
4212
|
+
"identities",
|
|
4213
|
+
"attributes",
|
|
4214
|
+
"links",
|
|
4215
|
+
"events",
|
|
4216
|
+
"event_participants",
|
|
4217
|
+
"open_commitments",
|
|
4218
|
+
"contact_merges",
|
|
4219
|
+
"dismissed_duplicates",
|
|
4220
|
+
"link_event_effects",
|
|
4221
|
+
"upcoming_occurrences",
|
|
4222
|
+
"link_rollups",
|
|
4223
|
+
"contact_rollups"
|
|
4224
|
+
];
|
|
4225
|
+
const before = new Map(
|
|
4226
|
+
tables.map((table) => [
|
|
4227
|
+
table,
|
|
4228
|
+
db.prepare(`SELECT rowid AS row_id, * FROM ${table}`).all()
|
|
4229
|
+
])
|
|
4230
|
+
);
|
|
4231
|
+
db.prepare(
|
|
4232
|
+
"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))"
|
|
4233
|
+
).run(locatorId, locatorId);
|
|
4234
|
+
db.prepare(
|
|
4235
|
+
"DELETE FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ?"
|
|
4236
|
+
).run(locatorId, locatorId);
|
|
4237
|
+
db.prepare(
|
|
4238
|
+
"DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
|
|
4239
|
+
).run(locatorId, locatorId);
|
|
4240
|
+
db.prepare(
|
|
4241
|
+
"UPDATE contacts SET merged_into_contact_id = NULL WHERE merged_into_contact_id = ?"
|
|
4242
|
+
).run(locatorId);
|
|
4243
|
+
db.prepare("DELETE FROM contacts WHERE id = ?").run(locatorId);
|
|
4244
|
+
const now = Date.now();
|
|
4245
|
+
const affectedLinks = db.prepare(
|
|
4246
|
+
"SELECT id FROM links WHERE removed_at IS NULL AND is_structural = 0"
|
|
4247
|
+
).all().map((row) => row.id);
|
|
4248
|
+
refreshAllBridgeScores(db, now);
|
|
4249
|
+
for (const id of affectedLinks) refreshLinkRollup(db, id, now);
|
|
4250
|
+
for (const row of db.prepare("SELECT id FROM contacts").all())
|
|
4251
|
+
refreshContactRollup(db, Number(row.id), now);
|
|
4252
|
+
const removed = tables.map((table) => {
|
|
4253
|
+
const remaining = new Set(
|
|
4254
|
+
db.prepare(`SELECT rowid AS row_id FROM ${table}`).all().map((row) => row.row_id)
|
|
4255
|
+
);
|
|
4256
|
+
const ids = (before.get(table) ?? []).filter((row) => !remaining.has(row.row_id)).map((row) => Number(row.id ?? row.event_id ?? row.row_id));
|
|
4257
|
+
return { entity: table, count: ids.length, ids };
|
|
4258
|
+
}).filter((item) => item.count > 0);
|
|
4259
|
+
return { contactId: locatorId, removed, affectedLinks };
|
|
4260
|
+
});
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
// src/contacts/export_contact.ts
|
|
4264
|
+
function exportContact(db, id) {
|
|
4265
|
+
const query = (sql, ...params) => db.prepare(sql).all(...params).map((row) => {
|
|
4266
|
+
const result = {};
|
|
4267
|
+
for (const [key, value] of Object.entries(row)) {
|
|
4268
|
+
const name = key.replace(
|
|
4269
|
+
/_([a-z])/g,
|
|
4270
|
+
(_, letter) => letter.toUpperCase()
|
|
4271
|
+
);
|
|
4272
|
+
result[name] = value;
|
|
4273
|
+
if ((name.endsWith("At") || name === "dueAt") && typeof value === "number")
|
|
4274
|
+
result[`${name}Iso`] = new Date(value).toISOString();
|
|
4275
|
+
if (key.endsWith("_json") && typeof value === "string")
|
|
4276
|
+
result[name] = JSON.parse(value);
|
|
4277
|
+
}
|
|
4278
|
+
return result;
|
|
4279
|
+
});
|
|
4280
|
+
const contact = query("SELECT * FROM contacts WHERE id = ?", id)[0];
|
|
4281
|
+
if (!contact) throw new AffinityNotFoundError("contact not found");
|
|
4282
|
+
const events = query(
|
|
4283
|
+
"SELECT DISTINCT e.* FROM events e JOIN event_participants p ON p.event_id = e.id WHERE p.contact_id = ? ORDER BY e.id",
|
|
4284
|
+
id
|
|
4285
|
+
).map((event) => ({
|
|
4286
|
+
...event,
|
|
4287
|
+
participants: query(
|
|
4288
|
+
"SELECT p.*, c.name AS contact_name FROM event_participants p JOIN contacts c ON c.id = p.contact_id WHERE event_id = ? ORDER BY p.contact_id",
|
|
4289
|
+
event.id
|
|
4290
|
+
)
|
|
4291
|
+
}));
|
|
4292
|
+
return {
|
|
4293
|
+
contact,
|
|
4294
|
+
identities: query(
|
|
4295
|
+
"SELECT * FROM identities WHERE contact_id = ? ORDER BY id",
|
|
4296
|
+
id
|
|
4297
|
+
),
|
|
4298
|
+
attributes: query(
|
|
4299
|
+
"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",
|
|
4300
|
+
id,
|
|
4301
|
+
id,
|
|
4302
|
+
id
|
|
4303
|
+
),
|
|
4304
|
+
links: query(
|
|
4305
|
+
"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",
|
|
4306
|
+
id,
|
|
4307
|
+
id
|
|
4308
|
+
),
|
|
4309
|
+
events,
|
|
4310
|
+
commitments: query(
|
|
4311
|
+
"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",
|
|
4312
|
+
id
|
|
4313
|
+
),
|
|
4314
|
+
dates: query(
|
|
4315
|
+
"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",
|
|
4316
|
+
id,
|
|
4317
|
+
id,
|
|
4318
|
+
id
|
|
4319
|
+
),
|
|
4320
|
+
mergeHistory: query(
|
|
4321
|
+
"SELECT * FROM contact_merges WHERE winner_contact_id = ? OR loser_contact_id = ? ORDER BY id",
|
|
4322
|
+
id,
|
|
4323
|
+
id
|
|
4324
|
+
)
|
|
4325
|
+
};
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
// src/contacts/export_affinity.ts
|
|
4329
|
+
function exportAffinity(db) {
|
|
4330
|
+
const now = Date.now();
|
|
4331
|
+
return {
|
|
4332
|
+
version: 1,
|
|
4333
|
+
exportedAt: now,
|
|
4334
|
+
exportedAtIso: new Date(now).toISOString(),
|
|
4335
|
+
contacts: db.prepare("SELECT id FROM contacts ORDER BY id").all().map((row) => exportContact(db, row.id))
|
|
4336
|
+
};
|
|
4337
|
+
}
|
|
4338
|
+
|
|
3783
4339
|
// src/contacts/receipts.ts
|
|
3784
4340
|
function buildContactMutationReceipt(primary, partial) {
|
|
3785
4341
|
const normalized = normalizeMutationReceiptPartial(partial);
|
|
@@ -3806,52 +4362,6 @@ function buildMergeMutationReceipt(primary, partial) {
|
|
|
3806
4362
|
};
|
|
3807
4363
|
}
|
|
3808
4364
|
|
|
3809
|
-
// src/contacts/create_contact.ts
|
|
3810
|
-
var KINDS = /* @__PURE__ */ new Set([
|
|
3811
|
-
"human",
|
|
3812
|
-
"group",
|
|
3813
|
-
"company",
|
|
3814
|
-
"team",
|
|
3815
|
-
"pet",
|
|
3816
|
-
"service",
|
|
3817
|
-
"other"
|
|
3818
|
-
]);
|
|
3819
|
-
function createContact(db, input) {
|
|
3820
|
-
return withTransaction(db, () => {
|
|
3821
|
-
const name = input.name.trim();
|
|
3822
|
-
if (name.length === 0) {
|
|
3823
|
-
throw new AffinityValidationError("name must be non-empty");
|
|
3824
|
-
}
|
|
3825
|
-
if (!KINDS.has(input.kind)) {
|
|
3826
|
-
throw new AffinityValidationError("invalid contact kind");
|
|
3827
|
-
}
|
|
3828
|
-
const now = resolveNow(input.now);
|
|
3829
|
-
if (input.bootstrapOwner) {
|
|
3830
|
-
const existing = db.prepare(
|
|
3831
|
-
"SELECT id FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
|
|
3832
|
-
).get();
|
|
3833
|
-
if (existing !== void 0) {
|
|
3834
|
-
throw new AffinityConflictError("owner already exists");
|
|
3835
|
-
}
|
|
3836
|
-
}
|
|
3837
|
-
const isOwner = input.bootstrapOwner ? 1 : 0;
|
|
3838
|
-
const result = db.prepare(
|
|
3839
|
-
`INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
|
|
3840
|
-
VALUES (?, ?, 'active', ?, ?, ?)`
|
|
3841
|
-
).run(name, input.kind, isOwner, now, now);
|
|
3842
|
-
const id = Number(result.lastInsertRowid);
|
|
3843
|
-
const row = getContactRowById(db, id);
|
|
3844
|
-
if (!row) {
|
|
3845
|
-
throw new AffinityInvariantError("inserted contact row not found");
|
|
3846
|
-
}
|
|
3847
|
-
refreshContactRollup(db, id, now);
|
|
3848
|
-
const primary = mapContactRowToContactListItem(row);
|
|
3849
|
-
return buildContactMutationReceipt(primary, {
|
|
3850
|
-
created: [{ kind: "contact", id }]
|
|
3851
|
-
});
|
|
3852
|
-
});
|
|
3853
|
-
}
|
|
3854
|
-
|
|
3855
4365
|
// src/contacts/revise_contact.ts
|
|
3856
4366
|
function reviseContact(db, contactId, patch) {
|
|
3857
4367
|
return withTransaction(db, () => {
|
|
@@ -3894,7 +4404,9 @@ function assertValidLifecycleTransition(from, to) {
|
|
|
3894
4404
|
throw new AffinityStateError("merged contact is read-only");
|
|
3895
4405
|
}
|
|
3896
4406
|
if (to === "merged") {
|
|
3897
|
-
throw new AffinityInvariantError(
|
|
4407
|
+
throw new AffinityInvariantError(
|
|
4408
|
+
"use manage_contact merge (CLI: contact merge) to reach merged state"
|
|
4409
|
+
);
|
|
3898
4410
|
}
|
|
3899
4411
|
if (from === to) {
|
|
3900
4412
|
return;
|
|
@@ -3907,44 +4419,295 @@ function assertValidLifecycleTransition(from, to) {
|
|
|
3907
4419
|
};
|
|
3908
4420
|
if (!allowed[from].includes(to)) {
|
|
3909
4421
|
throw new AffinityValidationError(
|
|
3910
|
-
`cannot transition from ${from} to ${to}`
|
|
4422
|
+
`cannot transition from ${from} to ${to}`
|
|
4423
|
+
);
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
// src/contacts/set_contact_lifecycle.ts
|
|
4428
|
+
var STATES2 = /* @__PURE__ */ new Set(["active", "dormant", "merged", "lost"]);
|
|
4429
|
+
function setContactLifecycle(db, contactId, lifecycleState, options) {
|
|
4430
|
+
return withTransaction(db, () => {
|
|
4431
|
+
if (!STATES2.has(lifecycleState)) {
|
|
4432
|
+
throw new AffinityValidationError("invalid lifecycle state");
|
|
4433
|
+
}
|
|
4434
|
+
const row = getContactRowById(db, contactId);
|
|
4435
|
+
if (!row) {
|
|
4436
|
+
throw new AffinityNotFoundError("contact not found");
|
|
4437
|
+
}
|
|
4438
|
+
const from = row.lifecycle_state;
|
|
4439
|
+
assertValidLifecycleTransition(from, lifecycleState);
|
|
4440
|
+
const now = resolveNow(options?.now);
|
|
4441
|
+
if (from === lifecycleState) {
|
|
4442
|
+
return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
|
|
4443
|
+
updated: []
|
|
4444
|
+
});
|
|
4445
|
+
}
|
|
4446
|
+
db.prepare(
|
|
4447
|
+
"UPDATE contacts SET lifecycle_state = ?, updated_at = ? WHERE id = ?"
|
|
4448
|
+
).run(lifecycleState, now, contactId);
|
|
4449
|
+
refreshContactRollup(db, contactId, now);
|
|
4450
|
+
const next = getContactRowById(db, contactId);
|
|
4451
|
+
if (!next) {
|
|
4452
|
+
throw new AffinityNotFoundError("contact not found");
|
|
4453
|
+
}
|
|
4454
|
+
return buildContactMutationReceipt(mapContactRowToContactListItem(next), {
|
|
4455
|
+
updated: [{ kind: "contact", id: contactId }]
|
|
4456
|
+
});
|
|
4457
|
+
});
|
|
4458
|
+
}
|
|
4459
|
+
|
|
4460
|
+
// src/contacts/create_contact.ts
|
|
4461
|
+
var KINDS = /* @__PURE__ */ new Set([
|
|
4462
|
+
"human",
|
|
4463
|
+
"group",
|
|
4464
|
+
"company",
|
|
4465
|
+
"team",
|
|
4466
|
+
"pet",
|
|
4467
|
+
"service",
|
|
4468
|
+
"other"
|
|
4469
|
+
]);
|
|
4470
|
+
function createContact(db, input) {
|
|
4471
|
+
return withTransaction(db, () => {
|
|
4472
|
+
const name = input.name.trim();
|
|
4473
|
+
if (name.length === 0) {
|
|
4474
|
+
throw new AffinityValidationError("name must be non-empty");
|
|
4475
|
+
}
|
|
4476
|
+
if (!KINDS.has(input.kind)) {
|
|
4477
|
+
throw new AffinityValidationError("invalid contact kind");
|
|
4478
|
+
}
|
|
4479
|
+
if (input.isOwner !== void 0) {
|
|
4480
|
+
throw new AffinityValidationError(
|
|
4481
|
+
"isOwner is read-only; pass bootstrapOwner: true to create the owner contact"
|
|
4482
|
+
);
|
|
4483
|
+
}
|
|
4484
|
+
const now = resolveNow(input.now);
|
|
4485
|
+
if (input.bootstrapOwner) {
|
|
4486
|
+
const existing = db.prepare(
|
|
4487
|
+
"SELECT id, name FROM contacts WHERE is_owner = 1 AND deleted_at IS NULL"
|
|
4488
|
+
).get();
|
|
4489
|
+
if (existing !== void 0) {
|
|
4490
|
+
throw new AffinityConflictError(
|
|
4491
|
+
`owner already exists: contact ${existing.id} (${existing.name}); bootstrapOwner can only be used once`
|
|
4492
|
+
);
|
|
4493
|
+
}
|
|
4494
|
+
}
|
|
4495
|
+
const isOwner = input.bootstrapOwner ? 1 : 0;
|
|
4496
|
+
const result = db.prepare(
|
|
4497
|
+
`INSERT INTO contacts (name, kind, lifecycle_state, is_owner, created_at, updated_at)
|
|
4498
|
+
VALUES (?, ?, 'active', ?, ?, ?)`
|
|
4499
|
+
).run(name, input.kind, isOwner, now, now);
|
|
4500
|
+
const id = Number(result.lastInsertRowid);
|
|
4501
|
+
const row = getContactRowById(db, id);
|
|
4502
|
+
if (!row) {
|
|
4503
|
+
throw new AffinityInvariantError("inserted contact row not found");
|
|
4504
|
+
}
|
|
4505
|
+
refreshContactRollup(db, id, now);
|
|
4506
|
+
const primary = mapContactRowToContactListItem(row);
|
|
4507
|
+
return buildContactMutationReceipt(primary, {
|
|
4508
|
+
created: [{ kind: "contact", id }]
|
|
4509
|
+
});
|
|
4510
|
+
});
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
// src/contacts/fold_search_text.ts
|
|
4514
|
+
function foldSearchText(value) {
|
|
4515
|
+
let folded = value.toLowerCase();
|
|
4516
|
+
if (/[^\u0020-\u007e]/.test(folded))
|
|
4517
|
+
folded = folded.replace(/ß/g, "ss").normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "");
|
|
4518
|
+
return folded.replace(/ae/g, "a").replace(/oe/g, "o").replace(/ue/g, "u");
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
// src/identities/queries.ts
|
|
4522
|
+
function getIdentityRowById(db, id) {
|
|
4523
|
+
const row = db.prepare(
|
|
4524
|
+
`SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
|
|
4525
|
+
created_at, updated_at, removed_at
|
|
4526
|
+
FROM identities
|
|
4527
|
+
WHERE id = ? AND removed_at IS NULL`
|
|
4528
|
+
).get(id);
|
|
4529
|
+
if (row === void 0) {
|
|
4530
|
+
return null;
|
|
4531
|
+
}
|
|
4532
|
+
return row;
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
// src/identities/receipts.ts
|
|
4536
|
+
function buildIdentityMutationReceipt(primary, partial) {
|
|
4537
|
+
const normalized = normalizeMutationReceiptPartial(partial);
|
|
4538
|
+
return {
|
|
4539
|
+
primary,
|
|
4540
|
+
created: normalized.created,
|
|
4541
|
+
updated: normalized.updated,
|
|
4542
|
+
archived: normalized.archived,
|
|
4543
|
+
removed: normalized.removed,
|
|
4544
|
+
affectedLinks: normalized.affectedLinks,
|
|
4545
|
+
derivedEffects: normalized.derivedEffects
|
|
4546
|
+
};
|
|
4547
|
+
}
|
|
4548
|
+
|
|
4549
|
+
// src/identities/validators.ts
|
|
4550
|
+
function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
|
|
4551
|
+
const row = excludeIdentityId === void 0 ? db.prepare(
|
|
4552
|
+
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
|
|
4553
|
+
).get(normalizedKey) : db.prepare(
|
|
4554
|
+
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
|
|
4555
|
+
).get(normalizedKey, excludeIdentityId);
|
|
4556
|
+
if (row !== void 0) {
|
|
4557
|
+
const owner = db.prepare(
|
|
4558
|
+
"SELECT c.id, c.name FROM contacts c JOIN identities i ON i.contact_id = c.id WHERE i.normalized_key = ? AND i.removed_at IS NULL"
|
|
4559
|
+
).get(normalizedKey);
|
|
4560
|
+
throw new AffinityConflictError(
|
|
4561
|
+
`${normalizedKey.replace(":", " ")} already belongs to #${owner?.id} ${owner?.name}`
|
|
4562
|
+
);
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
|
|
4566
|
+
// src/identities/validate_identity_input.ts
|
|
4567
|
+
var identityTypes = [
|
|
4568
|
+
"email",
|
|
4569
|
+
"phone",
|
|
4570
|
+
"url",
|
|
4571
|
+
"website",
|
|
4572
|
+
"handle",
|
|
4573
|
+
"x",
|
|
4574
|
+
"twitter",
|
|
4575
|
+
"linkedin",
|
|
4576
|
+
"github",
|
|
4577
|
+
"mastodon",
|
|
4578
|
+
"bluesky",
|
|
4579
|
+
"telegram",
|
|
4580
|
+
"whatsapp",
|
|
4581
|
+
"signal",
|
|
4582
|
+
"matrix",
|
|
4583
|
+
"discord",
|
|
4584
|
+
"slack",
|
|
4585
|
+
"instagram",
|
|
4586
|
+
"facebook",
|
|
4587
|
+
"youtube",
|
|
4588
|
+
"tiktok",
|
|
4589
|
+
"address",
|
|
4590
|
+
"other",
|
|
4591
|
+
"account_id",
|
|
4592
|
+
"alias"
|
|
4593
|
+
];
|
|
4594
|
+
function validateIdentityInput(type, value) {
|
|
4595
|
+
const kind = type.toLowerCase();
|
|
4596
|
+
if (!identityTypes.includes(kind) && !/^custom:[a-z0-9_-]+$/.test(type))
|
|
4597
|
+
throw new AffinityValidationError(
|
|
4598
|
+
`identity type must be one of ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen)`
|
|
4599
|
+
);
|
|
4600
|
+
if (!value.trim())
|
|
4601
|
+
throw new AffinityValidationError("identity value must be non-empty");
|
|
4602
|
+
if (kind === "email" && !/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(value))
|
|
4603
|
+
throw new AffinityValidationError(
|
|
4604
|
+
"email value must match local@domain.tld"
|
|
4605
|
+
);
|
|
4606
|
+
if (kind === "phone" && value.replace(/\D/g, "").length < 5)
|
|
4607
|
+
throw new AffinityValidationError(
|
|
4608
|
+
"phone value must contain at least 5 digits"
|
|
3911
4609
|
);
|
|
4610
|
+
if (kind === "url" || kind === "website") {
|
|
4611
|
+
try {
|
|
4612
|
+
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
|
4613
|
+
if (!["http:", "https:"].includes(url.protocol) || !url.hostname || /\s/.test(value))
|
|
4614
|
+
throw new Error();
|
|
4615
|
+
} catch {
|
|
4616
|
+
throw new AffinityValidationError(
|
|
4617
|
+
"url/website value must be an http/https URL or bare domain"
|
|
4618
|
+
);
|
|
4619
|
+
}
|
|
3912
4620
|
}
|
|
3913
4621
|
}
|
|
3914
4622
|
|
|
3915
|
-
// src/
|
|
3916
|
-
|
|
3917
|
-
function setContactLifecycle(db, contactId, lifecycleState, options) {
|
|
4623
|
+
// src/identities/add_identity.ts
|
|
4624
|
+
function addIdentity(db, contactId, input) {
|
|
3918
4625
|
return withTransaction(db, () => {
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
}
|
|
3922
|
-
const row = getContactRowById(db, contactId);
|
|
3923
|
-
if (!row) {
|
|
4626
|
+
const contact = getContactRowById(db, contactId);
|
|
4627
|
+
if (!contact) {
|
|
3924
4628
|
throw new AffinityNotFoundError("contact not found");
|
|
3925
4629
|
}
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
const now = resolveNow(options?.now);
|
|
3929
|
-
if (from === lifecycleState) {
|
|
3930
|
-
return buildContactMutationReceipt(mapContactRowToContactListItem(row), {
|
|
3931
|
-
updated: []
|
|
3932
|
-
});
|
|
4630
|
+
if (contact.lifecycle_state === "merged") {
|
|
4631
|
+
throw new AffinityStateError("merged contact is read-only");
|
|
3933
4632
|
}
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
const next = getContactRowById(db, contactId);
|
|
3939
|
-
if (!next) {
|
|
3940
|
-
throw new AffinityNotFoundError("contact not found");
|
|
4633
|
+
const type = input.type.trim();
|
|
4634
|
+
const value = input.value.trim();
|
|
4635
|
+
if (type.length === 0 || value.length === 0) {
|
|
4636
|
+
throw new AffinityValidationError("type and value must be non-empty");
|
|
3941
4637
|
}
|
|
3942
|
-
|
|
3943
|
-
|
|
4638
|
+
validateIdentityInput(type, value);
|
|
4639
|
+
const nk = normalizeIdentityKey(type, value);
|
|
4640
|
+
assertNoIdentityCollision(db, nk);
|
|
4641
|
+
const now = resolveNow(input.now);
|
|
4642
|
+
const verified = input.verified ? 1 : 0;
|
|
4643
|
+
const verifiedAt = verified === 1 ? now : null;
|
|
4644
|
+
const result = db.prepare(
|
|
4645
|
+
`INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
|
|
4646
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
4647
|
+
).run(
|
|
4648
|
+
contactId,
|
|
4649
|
+
type,
|
|
4650
|
+
value,
|
|
4651
|
+
input.label ?? null,
|
|
4652
|
+
nk,
|
|
4653
|
+
verified,
|
|
4654
|
+
verifiedAt,
|
|
4655
|
+
now,
|
|
4656
|
+
now
|
|
4657
|
+
);
|
|
4658
|
+
const id = Number(result.lastInsertRowid);
|
|
4659
|
+
const row = getIdentityRowById(db, id);
|
|
4660
|
+
if (!row) {
|
|
4661
|
+
throw new AffinityInvariantError("inserted identity not found");
|
|
4662
|
+
}
|
|
4663
|
+
refreshContactRollup(db, contactId, now);
|
|
4664
|
+
db.prepare(
|
|
4665
|
+
"DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
|
|
4666
|
+
).run(contactId, contactId);
|
|
4667
|
+
return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
|
|
4668
|
+
created: [{ kind: "identity", id }]
|
|
3944
4669
|
});
|
|
3945
4670
|
});
|
|
3946
4671
|
}
|
|
3947
4672
|
|
|
4673
|
+
// src/tools/create_contact_tool_result.ts
|
|
4674
|
+
function createContactToolResult(db, input) {
|
|
4675
|
+
return withTransaction(db, () => {
|
|
4676
|
+
const receipt = createContact(db, input);
|
|
4677
|
+
const identityOwners = /* @__PURE__ */ new Set();
|
|
4678
|
+
for (const identity of input.identities ?? []) {
|
|
4679
|
+
validateIdentityInput(identity.type.trim(), identity.value.trim());
|
|
4680
|
+
const existing = db.prepare(
|
|
4681
|
+
"SELECT contact_id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
|
|
4682
|
+
).get(normalizeIdentityKey(identity.type, identity.value));
|
|
4683
|
+
if (existing) {
|
|
4684
|
+
if (Number(existing.contact_id) !== receipt.primary.id)
|
|
4685
|
+
identityOwners.add(Number(existing.contact_id));
|
|
4686
|
+
continue;
|
|
4687
|
+
}
|
|
4688
|
+
const added = addIdentity(db, receipt.primary.id, identity);
|
|
4689
|
+
receipt.created.push(...added.created);
|
|
4690
|
+
}
|
|
4691
|
+
const result = mutationToolResult("create", receipt, "contact");
|
|
4692
|
+
const fold = (name) => foldSearchText(name).trim().replace(/\s+/g, " ");
|
|
4693
|
+
const warnings = db.prepare(
|
|
4694
|
+
"SELECT id, name FROM contacts WHERE id != ? AND deleted_at IS NULL AND lifecycle_state IN ('active', 'dormant')"
|
|
4695
|
+
).all(receipt.primary.id).flatMap((row) => {
|
|
4696
|
+
const score = identityOwners.has(row.id) ? 1 : fold(row.name) === fold(input.name) ? 0.92 : void 0;
|
|
4697
|
+
return score === void 0 ? [] : [
|
|
4698
|
+
{
|
|
4699
|
+
code: "possible_duplicate",
|
|
4700
|
+
message: `Possible duplicate of #${row.id} ${row.name} (score ${score})`,
|
|
4701
|
+
contactId: row.id,
|
|
4702
|
+
score
|
|
4703
|
+
}
|
|
4704
|
+
];
|
|
4705
|
+
});
|
|
4706
|
+
if (warnings.length) result.warnings = warnings;
|
|
4707
|
+
return result;
|
|
4708
|
+
});
|
|
4709
|
+
}
|
|
4710
|
+
|
|
3948
4711
|
// src/tools/manage_contact_tool.ts
|
|
3949
4712
|
function contactLocatorSchema3(description) {
|
|
3950
4713
|
return {
|
|
@@ -3963,24 +4726,76 @@ function contactLocatorSchema3(description) {
|
|
|
3963
4726
|
description
|
|
3964
4727
|
};
|
|
3965
4728
|
}
|
|
3966
|
-
function manageContactToolHandler(db,
|
|
4729
|
+
function manageContactToolHandler(db, rawInput) {
|
|
4730
|
+
const input = structuredClone(rawInput);
|
|
4731
|
+
const inputError = validateToolInput(manageContactTool.inputSchema, input);
|
|
4732
|
+
if (inputError)
|
|
4733
|
+
return toolFailure(
|
|
4734
|
+
"protocol",
|
|
4735
|
+
"invalid_input",
|
|
4736
|
+
"Invalid input.",
|
|
4737
|
+
inputError
|
|
4738
|
+
);
|
|
3967
4739
|
return withToolHandling(() => {
|
|
3968
|
-
if (input.action === "
|
|
3969
|
-
|
|
3970
|
-
"
|
|
3971
|
-
|
|
3972
|
-
|
|
4740
|
+
if (input.action === "export") {
|
|
4741
|
+
if (!input.contact)
|
|
4742
|
+
return toolSuccess("Exported Affinity.", {
|
|
4743
|
+
action: "export",
|
|
4744
|
+
export: exportAffinity(db)
|
|
4745
|
+
});
|
|
4746
|
+
const target = resolveContactLocator(db, input.contact, "contact", true);
|
|
4747
|
+
if (!target.ok) return target.result;
|
|
4748
|
+
return toolSuccess("Exported contact.", {
|
|
4749
|
+
action: "export",
|
|
4750
|
+
export: exportContact(db, target.value.id)
|
|
4751
|
+
});
|
|
4752
|
+
}
|
|
4753
|
+
if (input.action === "delete" && input.confirm !== true)
|
|
4754
|
+
return toolFailure(
|
|
4755
|
+
"protocol",
|
|
4756
|
+
"invalid_input",
|
|
4757
|
+
"Delete requires confirmation.",
|
|
4758
|
+
"delete is permanent; pass confirm: true (CLI: --confirm)"
|
|
3973
4759
|
);
|
|
4760
|
+
if (input.action === "create") {
|
|
4761
|
+
if (!input.input)
|
|
4762
|
+
return toolFailure(
|
|
4763
|
+
"protocol",
|
|
4764
|
+
"invalid_input",
|
|
4765
|
+
"Missing input.",
|
|
4766
|
+
"input is required for create."
|
|
4767
|
+
);
|
|
4768
|
+
return createContactToolResult(db, input.input);
|
|
3974
4769
|
}
|
|
3975
|
-
const contact = resolveContactLocator(
|
|
4770
|
+
const contact = resolveContactLocator(
|
|
4771
|
+
db,
|
|
4772
|
+
input.contact,
|
|
4773
|
+
"contact",
|
|
4774
|
+
input.action === "delete"
|
|
4775
|
+
);
|
|
3976
4776
|
if (!contact.ok) {
|
|
3977
4777
|
return contact.result;
|
|
3978
4778
|
}
|
|
4779
|
+
if (input.action === "delete") {
|
|
4780
|
+
const deletion = deleteContact(db, contact.value.id, { confirm: true });
|
|
4781
|
+
return toolSuccess("Permanently deleted contact.", {
|
|
4782
|
+
action: "delete",
|
|
4783
|
+
deletion,
|
|
4784
|
+
changes: deletion.removed.flatMap(
|
|
4785
|
+
(item) => item.ids.map((id) => ({
|
|
4786
|
+
entity: item.entity,
|
|
4787
|
+
id,
|
|
4788
|
+
action: "removed"
|
|
4789
|
+
}))
|
|
4790
|
+
)
|
|
4791
|
+
});
|
|
4792
|
+
}
|
|
3979
4793
|
if (input.action === "revise") {
|
|
3980
4794
|
return mutationToolResult(
|
|
3981
4795
|
"revise",
|
|
3982
4796
|
reviseContact(db, contact.value.id, input.patch),
|
|
3983
|
-
"contact"
|
|
4797
|
+
"contact",
|
|
4798
|
+
Object.keys(input.patch)
|
|
3984
4799
|
);
|
|
3985
4800
|
}
|
|
3986
4801
|
return mutationToolResult(
|
|
@@ -3997,7 +4812,7 @@ function manageContactToolHandler(db, input) {
|
|
|
3997
4812
|
}
|
|
3998
4813
|
var manageContactTool = defineAffinityTool({
|
|
3999
4814
|
name: manageContactToolName,
|
|
4000
|
-
description: "Create a contact
|
|
4815
|
+
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.",
|
|
4001
4816
|
whenToUse: "Use this for contact creation and lifecycle-safe contact maintenance.",
|
|
4002
4817
|
whenNotToUse: "Do not use this for identities, links, attributes, or merges.",
|
|
4003
4818
|
sideEffects: "writes_state",
|
|
@@ -4018,10 +4833,25 @@ var manageContactTool = defineAffinityTool({
|
|
|
4018
4833
|
action: enumSchema("Operation to perform.", [
|
|
4019
4834
|
"create",
|
|
4020
4835
|
"revise",
|
|
4021
|
-
"set_lifecycle"
|
|
4836
|
+
"set_lifecycle",
|
|
4837
|
+
"export",
|
|
4838
|
+
"delete"
|
|
4022
4839
|
]),
|
|
4023
4840
|
input: objectSchema(
|
|
4024
4841
|
{
|
|
4842
|
+
identities: arraySchema(
|
|
4843
|
+
objectSchema(
|
|
4844
|
+
{
|
|
4845
|
+
type: stringSchema("Identity type."),
|
|
4846
|
+
value: stringSchema("Identity value."),
|
|
4847
|
+
label: stringSchema("Optional label."),
|
|
4848
|
+
verified: booleanSchema("Verified identity."),
|
|
4849
|
+
now: integerSchema("Optional timestamp.")
|
|
4850
|
+
},
|
|
4851
|
+
["type", "value"]
|
|
4852
|
+
),
|
|
4853
|
+
"Optional identities to attach. Identities already owned by another contact are left with that owner and reported as possible duplicates."
|
|
4854
|
+
),
|
|
4025
4855
|
name: stringSchema("Contact name."),
|
|
4026
4856
|
kind: enumSchema("Contact kind.", [
|
|
4027
4857
|
"human",
|
|
@@ -4040,6 +4870,7 @@ var manageContactTool = defineAffinityTool({
|
|
|
4040
4870
|
["name", "kind"],
|
|
4041
4871
|
"New contact payload. Required when action=create."
|
|
4042
4872
|
),
|
|
4873
|
+
confirm: booleanSchema("Required true for permanent deletion."),
|
|
4043
4874
|
contact: contactLocatorSchema3(
|
|
4044
4875
|
"Target contact. Required when action=revise or set_lifecycle. Provide contactId or identity."
|
|
4045
4876
|
),
|
|
@@ -4268,9 +5099,32 @@ function resolveDateAnchorTarget(db, target) {
|
|
|
4268
5099
|
}
|
|
4269
5100
|
return { kind: "ok", target: { kind: "link", linkId: link.value.id } };
|
|
4270
5101
|
}
|
|
4271
|
-
function manageDateAnchorToolHandler(db,
|
|
5102
|
+
function manageDateAnchorToolHandler(db, rawInput) {
|
|
5103
|
+
const input = structuredClone(rawInput);
|
|
5104
|
+
const inputError = validateToolInput(manageDateAnchorTool.inputSchema, input);
|
|
5105
|
+
if (inputError)
|
|
5106
|
+
return toolFailure(
|
|
5107
|
+
"protocol",
|
|
5108
|
+
"invalid_input",
|
|
5109
|
+
"Invalid input.",
|
|
5110
|
+
inputError
|
|
5111
|
+
);
|
|
5112
|
+
if (input.action !== "add" && input.anchorEventId === void 0)
|
|
5113
|
+
return toolFailure(
|
|
5114
|
+
"protocol",
|
|
5115
|
+
"invalid_input",
|
|
5116
|
+
"Missing anchorEventId.",
|
|
5117
|
+
"anchorEventId is required."
|
|
5118
|
+
);
|
|
4272
5119
|
return withToolHandling(() => {
|
|
4273
5120
|
if (input.action === "add") {
|
|
5121
|
+
if (!input.input)
|
|
5122
|
+
return toolFailure(
|
|
5123
|
+
"protocol",
|
|
5124
|
+
"invalid_input",
|
|
5125
|
+
"Missing input.",
|
|
5126
|
+
"input is required for add."
|
|
5127
|
+
);
|
|
4274
5128
|
const resolved = resolveDateAnchorTarget(db, input.input.target);
|
|
4275
5129
|
if (resolved.kind === "error") {
|
|
4276
5130
|
return resolved.result;
|
|
@@ -4285,7 +5139,8 @@ function manageDateAnchorToolHandler(db, input) {
|
|
|
4285
5139
|
return mutationToolResult(
|
|
4286
5140
|
"revise",
|
|
4287
5141
|
reviseDateAnchor(db, input.anchorEventId, input.patch, input.options),
|
|
4288
|
-
"date anchor"
|
|
5142
|
+
"date anchor",
|
|
5143
|
+
Object.keys(input.patch)
|
|
4289
5144
|
);
|
|
4290
5145
|
}
|
|
4291
5146
|
return mutationToolResult(
|
|
@@ -4504,7 +5359,19 @@ function contactLocatorSchema5(description) {
|
|
|
4504
5359
|
description
|
|
4505
5360
|
};
|
|
4506
5361
|
}
|
|
4507
|
-
function manageDuplicateDismissalToolHandler(db,
|
|
5362
|
+
function manageDuplicateDismissalToolHandler(db, rawInput) {
|
|
5363
|
+
const input = structuredClone(rawInput);
|
|
5364
|
+
const inputError = validateToolInput(
|
|
5365
|
+
manageDuplicateDismissalTool.inputSchema,
|
|
5366
|
+
input
|
|
5367
|
+
);
|
|
5368
|
+
if (inputError)
|
|
5369
|
+
return toolFailure(
|
|
5370
|
+
"protocol",
|
|
5371
|
+
"invalid_input",
|
|
5372
|
+
"Invalid input.",
|
|
5373
|
+
inputError
|
|
5374
|
+
);
|
|
4508
5375
|
return withToolHandling(() => {
|
|
4509
5376
|
const left = resolveContactLocator(db, input.left, "left");
|
|
4510
5377
|
if (!left.ok) {
|
|
@@ -4514,6 +5381,11 @@ function manageDuplicateDismissalToolHandler(db, input) {
|
|
|
4514
5381
|
if (!right.ok) {
|
|
4515
5382
|
return right.result;
|
|
4516
5383
|
}
|
|
5384
|
+
const leftContactId = Math.min(left.value.id, right.value.id);
|
|
5385
|
+
const rightContactId = Math.max(left.value.id, right.value.id);
|
|
5386
|
+
const existed = db.prepare(
|
|
5387
|
+
"SELECT 1 FROM dismissed_duplicates WHERE left_id = ? AND right_id = ?"
|
|
5388
|
+
).get(leftContactId, rightContactId) !== void 0;
|
|
4517
5389
|
if (input.action === "dismiss") {
|
|
4518
5390
|
const receipt2 = dismissDuplicate(
|
|
4519
5391
|
db,
|
|
@@ -4523,139 +5395,70 @@ function manageDuplicateDismissalToolHandler(db, input) {
|
|
|
4523
5395
|
);
|
|
4524
5396
|
return toolSuccess(
|
|
4525
5397
|
`Dismissed pair #${receipt2.primary.leftContactId} / #${receipt2.primary.rightContactId}.`,
|
|
4526
|
-
{
|
|
5398
|
+
{
|
|
5399
|
+
...receipt2,
|
|
5400
|
+
action: "dismiss",
|
|
5401
|
+
changes: [
|
|
5402
|
+
{
|
|
5403
|
+
entity: "duplicate_dismissal",
|
|
5404
|
+
leftContactId,
|
|
5405
|
+
rightContactId,
|
|
5406
|
+
action: existed ? "updated" : "created"
|
|
5407
|
+
}
|
|
5408
|
+
]
|
|
5409
|
+
}
|
|
4527
5410
|
);
|
|
4528
5411
|
}
|
|
4529
5412
|
const receipt = undismissDuplicate(db, left.value.id, right.value.id);
|
|
4530
5413
|
return toolSuccess(
|
|
4531
5414
|
`Un-dismissed pair #${receipt.primary.leftContactId} / #${receipt.primary.rightContactId}.`,
|
|
4532
|
-
{
|
|
5415
|
+
{
|
|
5416
|
+
...receipt,
|
|
5417
|
+
action: "undismiss",
|
|
5418
|
+
changes: existed ? [
|
|
5419
|
+
{
|
|
5420
|
+
entity: "duplicate_dismissal",
|
|
5421
|
+
leftContactId,
|
|
5422
|
+
rightContactId,
|
|
5423
|
+
action: "removed"
|
|
5424
|
+
}
|
|
5425
|
+
] : []
|
|
5426
|
+
}
|
|
4533
5427
|
);
|
|
4534
5428
|
}, "Duplicate dismissal failed.");
|
|
4535
5429
|
}
|
|
4536
5430
|
var manageDuplicateDismissalTool = defineAffinityTool({
|
|
4537
5431
|
name: manageDuplicateDismissalToolName,
|
|
4538
|
-
description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in
|
|
5432
|
+
description: "Dismiss or un-dismiss a duplicate candidate pair so it does not resurface in review_affinity contacts.duplicates.",
|
|
4539
5433
|
whenToUse: "Use dismiss after confirming two contacts are different people (not duplicates). Use undismiss to re-surface a pair for re-evaluation.",
|
|
4540
5434
|
whenNotToUse: "Do not use this to merge contacts \u2014 use merge_contacts for confirmed duplicates.",
|
|
4541
5435
|
sideEffects: "writes_state",
|
|
4542
5436
|
readOnly: false,
|
|
4543
|
-
supportsClarification: true,
|
|
4544
|
-
targetKinds: ["contact"],
|
|
4545
|
-
inputDescriptions: {
|
|
4546
|
-
action: "dismiss to suppress the pair; undismiss to re-surface it.",
|
|
4547
|
-
left: "One contact of the pair.",
|
|
4548
|
-
right: "The other contact of the pair.",
|
|
4549
|
-
reason: "Optional reason for the dismissal (audit trail)."
|
|
4550
|
-
},
|
|
4551
|
-
outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
|
|
4552
|
-
inputSchema: objectSchema(
|
|
4553
|
-
{
|
|
4554
|
-
action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
|
|
4555
|
-
left: contactLocatorSchema5(
|
|
4556
|
-
"Left contact. Provide contactId or identity."
|
|
4557
|
-
),
|
|
4558
|
-
right: contactLocatorSchema5(
|
|
4559
|
-
"Right contact. Provide contactId or identity."
|
|
4560
|
-
),
|
|
4561
|
-
reason: nullableStringSchema("Optional reason for the dismissal.")
|
|
4562
|
-
},
|
|
4563
|
-
["action", "left", "right"],
|
|
4564
|
-
"Dismiss or un-dismiss a duplicate candidate pair."
|
|
4565
|
-
),
|
|
4566
|
-
handler: manageDuplicateDismissalToolHandler
|
|
4567
|
-
});
|
|
4568
|
-
|
|
4569
|
-
// src/identities/queries.ts
|
|
4570
|
-
function getIdentityRowById(db, id) {
|
|
4571
|
-
const row = db.prepare(
|
|
4572
|
-
`SELECT id, contact_id, type, value, label, normalized_key, verified, verified_at,
|
|
4573
|
-
created_at, updated_at, removed_at
|
|
4574
|
-
FROM identities
|
|
4575
|
-
WHERE id = ? AND removed_at IS NULL`
|
|
4576
|
-
).get(id);
|
|
4577
|
-
if (row === void 0) {
|
|
4578
|
-
return null;
|
|
4579
|
-
}
|
|
4580
|
-
return row;
|
|
4581
|
-
}
|
|
4582
|
-
|
|
4583
|
-
// src/identities/receipts.ts
|
|
4584
|
-
function buildIdentityMutationReceipt(primary, partial) {
|
|
4585
|
-
const normalized = normalizeMutationReceiptPartial(partial);
|
|
4586
|
-
return {
|
|
4587
|
-
primary,
|
|
4588
|
-
created: normalized.created,
|
|
4589
|
-
updated: normalized.updated,
|
|
4590
|
-
archived: normalized.archived,
|
|
4591
|
-
removed: normalized.removed,
|
|
4592
|
-
affectedLinks: normalized.affectedLinks,
|
|
4593
|
-
derivedEffects: normalized.derivedEffects
|
|
4594
|
-
};
|
|
4595
|
-
}
|
|
4596
|
-
|
|
4597
|
-
// src/identities/validators.ts
|
|
4598
|
-
function assertNoIdentityCollision(db, normalizedKey, excludeIdentityId) {
|
|
4599
|
-
const row = excludeIdentityId === void 0 ? db.prepare(
|
|
4600
|
-
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL"
|
|
4601
|
-
).get(normalizedKey) : db.prepare(
|
|
4602
|
-
"SELECT id FROM identities WHERE normalized_key = ? AND removed_at IS NULL AND id != ?"
|
|
4603
|
-
).get(normalizedKey, excludeIdentityId);
|
|
4604
|
-
if (row !== void 0) {
|
|
4605
|
-
throw new AffinityConflictError(
|
|
4606
|
-
"another contact already owns this normalized identity"
|
|
4607
|
-
);
|
|
4608
|
-
}
|
|
4609
|
-
}
|
|
4610
|
-
|
|
4611
|
-
// src/identities/add_identity.ts
|
|
4612
|
-
function addIdentity(db, contactId, input) {
|
|
4613
|
-
return withTransaction(db, () => {
|
|
4614
|
-
const contact = getContactRowById(db, contactId);
|
|
4615
|
-
if (!contact) {
|
|
4616
|
-
throw new AffinityNotFoundError("contact not found");
|
|
4617
|
-
}
|
|
4618
|
-
if (contact.lifecycle_state === "merged") {
|
|
4619
|
-
throw new AffinityStateError("merged contact is read-only");
|
|
4620
|
-
}
|
|
4621
|
-
const type = input.type.trim();
|
|
4622
|
-
const value = input.value.trim();
|
|
4623
|
-
if (type.length === 0 || value.length === 0) {
|
|
4624
|
-
throw new AffinityValidationError("type and value must be non-empty");
|
|
4625
|
-
}
|
|
4626
|
-
const nk = normalizeIdentityKey(type, value);
|
|
4627
|
-
assertNoIdentityCollision(db, nk);
|
|
4628
|
-
const now = resolveNow(input.now);
|
|
4629
|
-
const verified = input.verified ? 1 : 0;
|
|
4630
|
-
const verifiedAt = verified === 1 ? now : null;
|
|
4631
|
-
const result = db.prepare(
|
|
4632
|
-
`INSERT INTO identities (contact_id, type, value, label, normalized_key, verified, verified_at, created_at, updated_at)
|
|
4633
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
4634
|
-
).run(
|
|
4635
|
-
contactId,
|
|
4636
|
-
type,
|
|
4637
|
-
value,
|
|
4638
|
-
input.label ?? null,
|
|
4639
|
-
nk,
|
|
4640
|
-
verified,
|
|
4641
|
-
verifiedAt,
|
|
4642
|
-
now,
|
|
4643
|
-
now
|
|
4644
|
-
);
|
|
4645
|
-
const id = Number(result.lastInsertRowid);
|
|
4646
|
-
const row = getIdentityRowById(db, id);
|
|
4647
|
-
if (!row) {
|
|
4648
|
-
throw new AffinityInvariantError("inserted identity not found");
|
|
4649
|
-
}
|
|
4650
|
-
refreshContactRollup(db, contactId, now);
|
|
4651
|
-
db.prepare(
|
|
4652
|
-
"DELETE FROM dismissed_duplicates WHERE left_id = ? OR right_id = ?"
|
|
4653
|
-
).run(contactId, contactId);
|
|
4654
|
-
return buildIdentityMutationReceipt(mapIdentityRowToIdentityRecord(row), {
|
|
4655
|
-
created: [{ kind: "identity", id }]
|
|
4656
|
-
});
|
|
4657
|
-
});
|
|
4658
|
-
}
|
|
5437
|
+
supportsClarification: true,
|
|
5438
|
+
targetKinds: ["contact"],
|
|
5439
|
+
inputDescriptions: {
|
|
5440
|
+
action: "dismiss to suppress the pair; undismiss to re-surface it.",
|
|
5441
|
+
left: "One contact of the pair.",
|
|
5442
|
+
right: "The other contact of the pair.",
|
|
5443
|
+
reason: "Optional reason for the dismissal (audit trail)."
|
|
5444
|
+
},
|
|
5445
|
+
outputDescription: "Returns the dismissal receipt with the canonical pair identifiers.",
|
|
5446
|
+
inputSchema: objectSchema(
|
|
5447
|
+
{
|
|
5448
|
+
action: enumSchema("Action to perform.", ["dismiss", "undismiss"]),
|
|
5449
|
+
left: contactLocatorSchema5(
|
|
5450
|
+
"Left contact. Provide contactId or identity."
|
|
5451
|
+
),
|
|
5452
|
+
right: contactLocatorSchema5(
|
|
5453
|
+
"Right contact. Provide contactId or identity."
|
|
5454
|
+
),
|
|
5455
|
+
reason: nullableStringSchema("Optional reason for the dismissal.")
|
|
5456
|
+
},
|
|
5457
|
+
["action", "left", "right"],
|
|
5458
|
+
"Dismiss or un-dismiss a duplicate candidate pair."
|
|
5459
|
+
),
|
|
5460
|
+
handler: manageDuplicateDismissalToolHandler
|
|
5461
|
+
});
|
|
4659
5462
|
|
|
4660
5463
|
// src/identities/remove_identity.ts
|
|
4661
5464
|
function removeIdentity(db, identityId, removedAt) {
|
|
@@ -4708,6 +5511,7 @@ function reviseIdentity(db, identityId, patch) {
|
|
|
4708
5511
|
if (nextType.length === 0 || nextValue.length === 0) {
|
|
4709
5512
|
throw new AffinityValidationError("type and value must be non-empty");
|
|
4710
5513
|
}
|
|
5514
|
+
validateIdentityInput(nextType, nextValue);
|
|
4711
5515
|
const nextLabel = patch.label !== void 0 ? patch.label : row.label;
|
|
4712
5516
|
const nkNext = normalizeIdentityKey(nextType, nextValue);
|
|
4713
5517
|
if (nkNext !== row.normalized_key) {
|
|
@@ -4762,7 +5566,9 @@ function contactLocatorSchema6(description) {
|
|
|
4762
5566
|
contactId: integerSchema("Exact contact id."),
|
|
4763
5567
|
identity: objectSchema(
|
|
4764
5568
|
{
|
|
4765
|
-
type: stringSchema(
|
|
5569
|
+
type: stringSchema(
|
|
5570
|
+
`Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
|
|
5571
|
+
),
|
|
4766
5572
|
value: stringSchema("Identity value.")
|
|
4767
5573
|
},
|
|
4768
5574
|
["type", "value"],
|
|
@@ -4772,7 +5578,23 @@ function contactLocatorSchema6(description) {
|
|
|
4772
5578
|
description
|
|
4773
5579
|
};
|
|
4774
5580
|
}
|
|
4775
|
-
function manageIdentityToolHandler(db,
|
|
5581
|
+
function manageIdentityToolHandler(db, rawInput) {
|
|
5582
|
+
const input = structuredClone(rawInput);
|
|
5583
|
+
const inputError = validateToolInput(manageIdentityTool.inputSchema, input);
|
|
5584
|
+
if (inputError)
|
|
5585
|
+
return toolFailure(
|
|
5586
|
+
"protocol",
|
|
5587
|
+
"invalid_input",
|
|
5588
|
+
"Invalid input.",
|
|
5589
|
+
inputError
|
|
5590
|
+
);
|
|
5591
|
+
if (input.action !== "add" && input.identityId === void 0)
|
|
5592
|
+
return toolFailure(
|
|
5593
|
+
"protocol",
|
|
5594
|
+
"invalid_input",
|
|
5595
|
+
"Missing identityId.",
|
|
5596
|
+
"identityId is required."
|
|
5597
|
+
);
|
|
4776
5598
|
return withToolHandling(() => {
|
|
4777
5599
|
switch (input.action) {
|
|
4778
5600
|
case "add": {
|
|
@@ -4790,7 +5612,8 @@ function manageIdentityToolHandler(db, input) {
|
|
|
4790
5612
|
return mutationToolResult(
|
|
4791
5613
|
"revise",
|
|
4792
5614
|
reviseIdentity(db, input.identityId, input.patch),
|
|
4793
|
-
"identity"
|
|
5615
|
+
"identity",
|
|
5616
|
+
Object.keys(input.patch)
|
|
4794
5617
|
);
|
|
4795
5618
|
case "verify":
|
|
4796
5619
|
return mutationToolResult(
|
|
@@ -4839,7 +5662,9 @@ var manageIdentityTool = defineAffinityTool({
|
|
|
4839
5662
|
),
|
|
4840
5663
|
input: objectSchema(
|
|
4841
5664
|
{
|
|
4842
|
-
type: stringSchema(
|
|
5665
|
+
type: stringSchema(
|
|
5666
|
+
`Identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
|
|
5667
|
+
),
|
|
4843
5668
|
value: stringSchema("Identity value."),
|
|
4844
5669
|
label: stringSchema("Optional label."),
|
|
4845
5670
|
verified: booleanSchema("Whether the identity starts verified."),
|
|
@@ -4853,7 +5678,9 @@ var manageIdentityTool = defineAffinityTool({
|
|
|
4853
5678
|
),
|
|
4854
5679
|
patch: objectSchema(
|
|
4855
5680
|
{
|
|
4856
|
-
type: stringSchema(
|
|
5681
|
+
type: stringSchema(
|
|
5682
|
+
`Optional identity type: ${identityTypes.join(", ")}, or custom:<name> (lowercase a-z, 0-9, underscore or hyphen).`
|
|
5683
|
+
),
|
|
4857
5684
|
value: stringSchema("Optional identity value."),
|
|
4858
5685
|
label: stringSchema("Optional label.")
|
|
4859
5686
|
},
|
|
@@ -5072,7 +5899,19 @@ function linkLocatorSchema4(description) {
|
|
|
5072
5899
|
description
|
|
5073
5900
|
};
|
|
5074
5901
|
}
|
|
5075
|
-
function manageRelationshipToolHandler(db,
|
|
5902
|
+
function manageRelationshipToolHandler(db, rawInput) {
|
|
5903
|
+
const input = structuredClone(rawInput);
|
|
5904
|
+
const inputError = validateToolInput(
|
|
5905
|
+
manageRelationshipTool.inputSchema,
|
|
5906
|
+
input
|
|
5907
|
+
);
|
|
5908
|
+
if (inputError)
|
|
5909
|
+
return toolFailure(
|
|
5910
|
+
"protocol",
|
|
5911
|
+
"invalid_input",
|
|
5912
|
+
"Invalid input.",
|
|
5913
|
+
inputError
|
|
5914
|
+
);
|
|
5076
5915
|
return withToolHandling(() => {
|
|
5077
5916
|
switch (input.action) {
|
|
5078
5917
|
case "seed_social_link": {
|
|
@@ -5442,7 +6281,16 @@ function contactLocatorSchema8(description) {
|
|
|
5442
6281
|
description
|
|
5443
6282
|
};
|
|
5444
6283
|
}
|
|
5445
|
-
function mergeContactsToolHandler(db,
|
|
6284
|
+
function mergeContactsToolHandler(db, rawInput) {
|
|
6285
|
+
const input = structuredClone(rawInput);
|
|
6286
|
+
const inputError = validateToolInput(mergeContactsTool.inputSchema, input);
|
|
6287
|
+
if (inputError)
|
|
6288
|
+
return toolFailure(
|
|
6289
|
+
"protocol",
|
|
6290
|
+
"invalid_input",
|
|
6291
|
+
"Invalid input.",
|
|
6292
|
+
inputError
|
|
6293
|
+
);
|
|
5446
6294
|
return withToolHandling(() => {
|
|
5447
6295
|
const winner = resolveContactLocator(db, input.winner, "winner");
|
|
5448
6296
|
if (!winner.ok) {
|
|
@@ -5672,7 +6520,16 @@ function prepareParticipants2(db, raw, injectOwner) {
|
|
|
5672
6520
|
const normalized = raw.map(normalizeParticipant2);
|
|
5673
6521
|
return injectOwner ? ensureOwnerParticipates2(db, normalized) : normalized;
|
|
5674
6522
|
}
|
|
5675
|
-
function recordEventToolHandler(db,
|
|
6523
|
+
function recordEventToolHandler(db, rawInput) {
|
|
6524
|
+
const input = structuredClone(rawInput);
|
|
6525
|
+
const inputError = validateToolInput(recordEventTool.inputSchema, input);
|
|
6526
|
+
if (inputError)
|
|
6527
|
+
return toolFailure(
|
|
6528
|
+
"protocol",
|
|
6529
|
+
"invalid_input",
|
|
6530
|
+
"Invalid input.",
|
|
6531
|
+
inputError
|
|
6532
|
+
);
|
|
5676
6533
|
return withToolHandling(() => {
|
|
5677
6534
|
const patched = {
|
|
5678
6535
|
...input.input,
|
|
@@ -5770,7 +6627,7 @@ var recordEventTool = defineAffinityTool({
|
|
|
5770
6627
|
"observed"
|
|
5771
6628
|
])
|
|
5772
6629
|
},
|
|
5773
|
-
["
|
|
6630
|
+
["role"]
|
|
5774
6631
|
),
|
|
5775
6632
|
"Participant list. Must be non-empty and include the owner contact."
|
|
5776
6633
|
),
|
|
@@ -5786,6 +6643,23 @@ var recordEventTool = defineAffinityTool({
|
|
|
5786
6643
|
handler: recordEventToolHandler
|
|
5787
6644
|
});
|
|
5788
6645
|
|
|
6646
|
+
// src/contacts/contact_attribute_clauses.ts
|
|
6647
|
+
function contactAttributeClauses(filters) {
|
|
6648
|
+
const clauses = [];
|
|
6649
|
+
const params = [];
|
|
6650
|
+
const attributes = filters?.attribute === void 0 ? [] : [filters.attribute].flat();
|
|
6651
|
+
const tags = filters?.tag === void 0 ? [] : [filters.tag].flat();
|
|
6652
|
+
for (const filter of [...attributes, ...tags.map((tag) => `tag:${tag}`)]) {
|
|
6653
|
+
const separator = filter.indexOf("=");
|
|
6654
|
+
const name = separator < 0 ? filter : filter.slice(0, separator);
|
|
6655
|
+
clauses.push(`EXISTS (SELECT 1 FROM attributes a WHERE a.contact_id = contacts.id
|
|
6656
|
+
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(?)"})`);
|
|
6657
|
+
params.push(name);
|
|
6658
|
+
if (separator >= 0) params.push(filter.slice(separator + 1));
|
|
6659
|
+
}
|
|
6660
|
+
return { clauses, params };
|
|
6661
|
+
}
|
|
6662
|
+
|
|
5789
6663
|
// src/contacts/list_contacts.ts
|
|
5790
6664
|
function listContacts(db, filters, options) {
|
|
5791
6665
|
assertDefaultOrdering("listContacts", options);
|
|
@@ -5809,6 +6683,9 @@ function listContacts(db, filters, options) {
|
|
|
5809
6683
|
if (filters?.includeDormant === false || options?.includeDormant === false) {
|
|
5810
6684
|
clauses.push("lifecycle_state != 'dormant'");
|
|
5811
6685
|
}
|
|
6686
|
+
const attributeFilters = contactAttributeClauses(filters);
|
|
6687
|
+
clauses.push(...attributeFilters.clauses);
|
|
6688
|
+
params.push(...attributeFilters.params);
|
|
5812
6689
|
const where = clauses.join(" AND ");
|
|
5813
6690
|
const rows = db.prepare(
|
|
5814
6691
|
`SELECT id, name, kind, lifecycle_state, is_owner, merged_into_contact_id,
|
|
@@ -6040,115 +6917,6 @@ function listUpcomingDates(db, filters, options) {
|
|
|
6040
6917
|
return rows.map((r) => mapUpcomingRowToUpcomingDateRecord(r));
|
|
6041
6918
|
}
|
|
6042
6919
|
|
|
6043
|
-
// src/events/get_contact_journal.ts
|
|
6044
|
-
function getContactJournal(db, contactId, options) {
|
|
6045
|
-
assertDefaultOrdering("getContactJournal", options);
|
|
6046
|
-
const { limit, offset } = resolveListLimitOffset(options);
|
|
6047
|
-
const clauses = ["e.deleted_at IS NULL", "ep.contact_id = ?"];
|
|
6048
|
-
const params = [contactId];
|
|
6049
|
-
if (options?.since !== void 0) {
|
|
6050
|
-
clauses.push("e.occurred_at >= ?");
|
|
6051
|
-
params.push(options.since);
|
|
6052
|
-
}
|
|
6053
|
-
if (options?.until !== void 0) {
|
|
6054
|
-
clauses.push("e.occurred_at <= ?");
|
|
6055
|
-
params.push(options.until);
|
|
6056
|
-
}
|
|
6057
|
-
if (options?.eventTypes !== void 0 && options.eventTypes.length > 0) {
|
|
6058
|
-
const ph = options.eventTypes.map(() => "?").join(", ");
|
|
6059
|
-
clauses.push(`e.type IN (${ph})`);
|
|
6060
|
-
params.push(...options.eventTypes);
|
|
6061
|
-
}
|
|
6062
|
-
const where = clauses.join(" AND ");
|
|
6063
|
-
const rows = db.prepare(
|
|
6064
|
-
`SELECT e.id FROM events e
|
|
6065
|
-
INNER JOIN event_participants ep ON ep.event_id = e.id
|
|
6066
|
-
WHERE ${where}
|
|
6067
|
-
ORDER BY e.occurred_at DESC, e.id DESC
|
|
6068
|
-
LIMIT ? OFFSET ?`
|
|
6069
|
-
).all(...params, limit, offset);
|
|
6070
|
-
return batchLoadEventRecords(
|
|
6071
|
-
db,
|
|
6072
|
-
rows.map((r) => r.id)
|
|
6073
|
-
);
|
|
6074
|
-
}
|
|
6075
|
-
|
|
6076
|
-
// src/events/list_open_commitments.ts
|
|
6077
|
-
var DAY_MS3 = 864e5;
|
|
6078
|
-
function mapResolution(resolvedAt, resolution) {
|
|
6079
|
-
if (resolvedAt === null) {
|
|
6080
|
-
return "open";
|
|
6081
|
-
}
|
|
6082
|
-
if (resolution === "cancelled") {
|
|
6083
|
-
return "cancelled";
|
|
6084
|
-
}
|
|
6085
|
-
return "resolved";
|
|
6086
|
-
}
|
|
6087
|
-
function listOpenCommitments(db, filters, options) {
|
|
6088
|
-
assertDefaultOrdering("listOpenCommitments", options);
|
|
6089
|
-
const { limit, offset } = resolveListLimitOffset(options);
|
|
6090
|
-
const clauses = ["e.deleted_at IS NULL", "oc.resolved_at IS NULL"];
|
|
6091
|
-
const params = [];
|
|
6092
|
-
if (options?.since !== void 0) {
|
|
6093
|
-
clauses.push("e.occurred_at >= ?");
|
|
6094
|
-
params.push(options.since);
|
|
6095
|
-
}
|
|
6096
|
-
if (options?.until !== void 0) {
|
|
6097
|
-
clauses.push("e.occurred_at <= ?");
|
|
6098
|
-
params.push(options.until);
|
|
6099
|
-
}
|
|
6100
|
-
if (filters?.commitmentType !== void 0) {
|
|
6101
|
-
if (filters.commitmentType !== "promise" && filters.commitmentType !== "agreement") {
|
|
6102
|
-
throw new AffinityValidationError("invalid commitmentType filter");
|
|
6103
|
-
}
|
|
6104
|
-
clauses.push("oc.commitment_type = ?");
|
|
6105
|
-
params.push(filters.commitmentType);
|
|
6106
|
-
}
|
|
6107
|
-
if (filters?.contactId !== void 0) {
|
|
6108
|
-
clauses.push(
|
|
6109
|
-
"EXISTS (SELECT 1 FROM event_participants ep WHERE ep.event_id = oc.event_id AND ep.contact_id = ?)"
|
|
6110
|
-
);
|
|
6111
|
-
params.push(filters.contactId);
|
|
6112
|
-
}
|
|
6113
|
-
if (filters?.linkId !== void 0) {
|
|
6114
|
-
clauses.push(
|
|
6115
|
-
"EXISTS (SELECT 1 FROM link_event_effects le WHERE le.event_id = oc.event_id AND le.link_id = ?)"
|
|
6116
|
-
);
|
|
6117
|
-
params.push(filters.linkId);
|
|
6118
|
-
}
|
|
6119
|
-
if (filters?.horizonDays !== void 0) {
|
|
6120
|
-
if (!Number.isInteger(filters.horizonDays) || filters.horizonDays < 0) {
|
|
6121
|
-
throw new AffinityValidationError(
|
|
6122
|
-
"horizonDays must be a non-negative integer"
|
|
6123
|
-
);
|
|
6124
|
-
}
|
|
6125
|
-
const anchor = options?.since ?? Date.now();
|
|
6126
|
-
clauses.push("oc.due_at IS NOT NULL");
|
|
6127
|
-
clauses.push("oc.due_at <= ?");
|
|
6128
|
-
params.push(anchor + filters.horizonDays * DAY_MS3);
|
|
6129
|
-
}
|
|
6130
|
-
const where = clauses.join(" AND ");
|
|
6131
|
-
const rows = db.prepare(
|
|
6132
|
-
`SELECT oc.event_id, oc.commitment_type, oc.due_at, oc.resolved_at, oc.resolution,
|
|
6133
|
-
e.type, e.summary
|
|
6134
|
-
FROM open_commitments oc
|
|
6135
|
-
INNER JOIN events e ON e.id = oc.event_id
|
|
6136
|
-
WHERE ${where}
|
|
6137
|
-
ORDER BY oc.due_at IS NULL, oc.due_at ASC, oc.created_at ASC
|
|
6138
|
-
LIMIT ? OFFSET ?`
|
|
6139
|
-
).all(...params, limit, offset);
|
|
6140
|
-
const eventIds = rows.map((r) => r.event_id);
|
|
6141
|
-
const partsByEvent = batchLoadEventParticipantViews(db, eventIds);
|
|
6142
|
-
return rows.map((r) => ({
|
|
6143
|
-
eventId: r.event_id,
|
|
6144
|
-
type: r.type,
|
|
6145
|
-
summary: r.summary,
|
|
6146
|
-
participants: partsByEvent.get(r.event_id) ?? [],
|
|
6147
|
-
dueAt: r.due_at,
|
|
6148
|
-
resolutionState: mapResolution(r.resolved_at, r.resolution)
|
|
6149
|
-
}));
|
|
6150
|
-
}
|
|
6151
|
-
|
|
6152
6920
|
// src/graph/get_affinity_chart.ts
|
|
6153
6921
|
function getAffinityChart(db, options) {
|
|
6154
6922
|
const contactClauses = [
|
|
@@ -6355,13 +7123,15 @@ function listRadar(db, filters, options) {
|
|
|
6355
7123
|
if (ownerId === null) {
|
|
6356
7124
|
return [];
|
|
6357
7125
|
}
|
|
7126
|
+
const now = resolveNow(options?.now);
|
|
7127
|
+
const overdue = `EXISTS (SELECT 1 FROM open_commitments oc JOIN events e ON e.id = oc.event_id JOIN event_participants ep ON ep.event_id = e.id WHERE ep.contact_id = CASE WHEN l.from_contact_id = ${ownerId} THEN l.to_contact_id ELSE l.from_contact_id END AND e.deleted_at IS NULL AND oc.resolved_at IS NULL AND oc.due_at < ${now})`;
|
|
6358
7128
|
const { limit, offset } = resolveListLimitOffset(options);
|
|
6359
7129
|
const clauses = [
|
|
6360
7130
|
"l.removed_at IS NULL",
|
|
6361
7131
|
"l.is_structural = 0",
|
|
6362
|
-
"l.from_contact_id = ?"
|
|
7132
|
+
"(l.from_contact_id = ? OR l.to_contact_id = ?)"
|
|
6363
7133
|
];
|
|
6364
|
-
const params = [ownerId];
|
|
7134
|
+
const params = [ownerId, ownerId];
|
|
6365
7135
|
if (filters?.kind !== void 0) {
|
|
6366
7136
|
clauses.push("l.kind = ?");
|
|
6367
7137
|
params.push(filters.kind);
|
|
@@ -6383,6 +7153,7 @@ function listRadar(db, filters, options) {
|
|
|
6383
7153
|
const rows = db.prepare(
|
|
6384
7154
|
`SELECT
|
|
6385
7155
|
l.id,
|
|
7156
|
+
${overdue} AS overdue_commitment,
|
|
6386
7157
|
l.from_contact_id,
|
|
6387
7158
|
l.to_contact_id,
|
|
6388
7159
|
l.trust,
|
|
@@ -6391,17 +7162,18 @@ function listRadar(db, filters, options) {
|
|
|
6391
7162
|
lr.recency_score,
|
|
6392
7163
|
lr.normalized_rank
|
|
6393
7164
|
FROM links l
|
|
6394
|
-
INNER JOIN contacts c_to ON c_to.id = l.to_contact_id
|
|
7165
|
+
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
|
|
6395
7166
|
AND c_to.deleted_at IS NULL AND c_to.lifecycle_state != 'merged'
|
|
6396
7167
|
LEFT JOIN link_rollups lr ON lr.link_id = l.id
|
|
6397
7168
|
WHERE ${where}
|
|
6398
|
-
ORDER BY COALESCE(lr.radar_score, 0) DESC
|
|
7169
|
+
ORDER BY ${overdue} DESC, COALESCE(lr.radar_score, 0) DESC
|
|
6399
7170
|
LIMIT ? OFFSET ?`
|
|
6400
7171
|
).all(...params, limit, offset);
|
|
6401
7172
|
return rows.map((row) => ({
|
|
6402
7173
|
linkId: row.id,
|
|
6403
7174
|
contactId: row.from_contact_id === ownerId ? row.to_contact_id : row.from_contact_id,
|
|
6404
|
-
|
|
7175
|
+
reasons: row.overdue_commitment ? ["overdue_commitment"] : [],
|
|
7176
|
+
driftPriority: row.overdue_commitment ? Math.max(1, row.drift_priority ?? 0) : row.drift_priority ?? 0,
|
|
6405
7177
|
recencyScore: row.recency_score ?? 0,
|
|
6406
7178
|
normalizedRank: row.normalized_rank ?? 0,
|
|
6407
7179
|
trust: row.trust ?? 0,
|
|
@@ -6451,9 +7223,71 @@ function listDismissedDuplicates(db, options) {
|
|
|
6451
7223
|
}));
|
|
6452
7224
|
}
|
|
6453
7225
|
|
|
7226
|
+
// src/tools/enrich_review_item.ts
|
|
7227
|
+
function enrichReviewItem(db, item) {
|
|
7228
|
+
const name = (id) => {
|
|
7229
|
+
if (typeof id !== "number") return "";
|
|
7230
|
+
return db.prepare("SELECT name FROM contacts WHERE id = ?").get(id)?.name ?? `Contact #${id}`;
|
|
7231
|
+
};
|
|
7232
|
+
const result = { ...item };
|
|
7233
|
+
if (item.kind === "link") {
|
|
7234
|
+
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,
|
|
7235
|
+
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);
|
|
7236
|
+
if (link) {
|
|
7237
|
+
result.title = link.from_owner ? String(link.to_name) : link.to_owner ? String(link.from_name) : `${link.from_name} \xB7 ${link.to_name}`;
|
|
7238
|
+
result.subtitle = [link.kind, link.role].filter(Boolean).join(" \xB7 ");
|
|
7239
|
+
for (const key of ["affinity", "trust", "rank"])
|
|
7240
|
+
if (typeof link[key] === "number") result[key] = link[key];
|
|
7241
|
+
result.recencyScore ??= link.recency_score;
|
|
7242
|
+
result.driftPriority ??= link.drift_priority;
|
|
7243
|
+
result.readinessScore = link.readiness_score;
|
|
7244
|
+
result.normalizedRank ??= link.normalized_rank;
|
|
7245
|
+
result.reciprocityScore = link.reciprocity_score;
|
|
7246
|
+
result.radarScore = link.radar_score;
|
|
7247
|
+
}
|
|
7248
|
+
}
|
|
7249
|
+
if (item.kind === "event" && typeof item.linkId === "number" && !Array.isArray(item.participants)) {
|
|
7250
|
+
const link = db.prepare("SELECT from_contact_id, to_contact_id FROM links WHERE id = ?").get(item.linkId);
|
|
7251
|
+
if (link)
|
|
7252
|
+
result.subtitle = `${item.subtitle} \xB7 ${name(link.from_contact_id)} \xB7 ${name(link.to_contact_id)}`;
|
|
7253
|
+
}
|
|
7254
|
+
if (Array.isArray(item.participants)) {
|
|
7255
|
+
result.counterpartyNames = item.participants.map(
|
|
7256
|
+
(p) => name(p.contactId)
|
|
7257
|
+
);
|
|
7258
|
+
result.subtitle = [
|
|
7259
|
+
item.subtitle,
|
|
7260
|
+
result.counterpartyNames.join(", ")
|
|
7261
|
+
].filter(Boolean).join(" \xB7 ");
|
|
7262
|
+
}
|
|
7263
|
+
if (typeof item.leftContactId === "number")
|
|
7264
|
+
result.title = `${name(item.leftContactId)} \xB7 ${name(item.rightContactId)}`;
|
|
7265
|
+
if (typeof item.winnerContactId === "number")
|
|
7266
|
+
result.title = `Merged ${name(item.loserContactId)} into ${name(item.winnerContactId)}`;
|
|
7267
|
+
if (item.occursAt !== void 0 && item.kind === "contact")
|
|
7268
|
+
result.title = `${name(item.id)} \xB7 ${item.title}`;
|
|
7269
|
+
for (const [key, value] of Object.entries(result)) {
|
|
7270
|
+
if (typeof value === "number" && /score|priority|affinity|trust|rank/i.test(key))
|
|
7271
|
+
result[key] = Math.round(value * 1e3) / 1e3;
|
|
7272
|
+
}
|
|
7273
|
+
return result;
|
|
7274
|
+
}
|
|
7275
|
+
|
|
7276
|
+
// src/tools/tool_summary.ts
|
|
7277
|
+
function summarizeCount(count, singular) {
|
|
7278
|
+
if (count === 0) {
|
|
7279
|
+
return `No ${singular}s found.`;
|
|
7280
|
+
}
|
|
7281
|
+
if (count === 1) {
|
|
7282
|
+
return `Found 1 ${singular}.`;
|
|
7283
|
+
}
|
|
7284
|
+
return `Found ${count} ${singular}s.`;
|
|
7285
|
+
}
|
|
7286
|
+
|
|
6454
7287
|
// src/tools/review_affinity_tool.ts
|
|
6455
7288
|
function buildReadOptions(input) {
|
|
6456
7289
|
return {
|
|
7290
|
+
...input.now === void 0 ? {} : { now: input.now },
|
|
6457
7291
|
...input.limit === void 0 ? {} : { limit: input.limit },
|
|
6458
7292
|
...input.offset === void 0 ? {} : { offset: input.offset },
|
|
6459
7293
|
...input.includeArchived === void 0 ? {} : { includeArchived: input.includeArchived },
|
|
@@ -6573,6 +7407,7 @@ function validateReviewInput(input) {
|
|
|
6573
7407
|
}
|
|
6574
7408
|
function duplicateCandidateItem(candidate) {
|
|
6575
7409
|
return {
|
|
7410
|
+
...candidate,
|
|
6576
7411
|
kind: "contact",
|
|
6577
7412
|
id: candidate.leftContactId,
|
|
6578
7413
|
title: `Possible duplicate: #${candidate.leftContactId} and #${candidate.rightContactId}`,
|
|
@@ -6582,24 +7417,28 @@ function duplicateCandidateItem(candidate) {
|
|
|
6582
7417
|
}
|
|
6583
7418
|
function momentItem(moment) {
|
|
6584
7419
|
return {
|
|
7420
|
+
...moment,
|
|
7421
|
+
occurredAtIso: new Date(moment.occurredAt).toISOString(),
|
|
6585
7422
|
kind: "event",
|
|
6586
7423
|
id: moment.eventId,
|
|
6587
7424
|
title: moment.summary,
|
|
6588
7425
|
subtitle: moment.momentKind,
|
|
6589
|
-
snippet:
|
|
7426
|
+
snippet: new Date(moment.occurredAt).toISOString().slice(0, 10)
|
|
6590
7427
|
};
|
|
6591
7428
|
}
|
|
6592
7429
|
function commitmentItem(commitment) {
|
|
6593
7430
|
return {
|
|
7431
|
+
...commitment,
|
|
6594
7432
|
kind: "event",
|
|
6595
7433
|
id: commitment.eventId,
|
|
6596
7434
|
title: commitment.summary,
|
|
6597
7435
|
subtitle: commitment.type,
|
|
6598
|
-
snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt
|
|
7436
|
+
snippet: `state ${commitment.resolutionState}; participants ${commitment.participants.length}; due ${commitment.dueAt == null ? "none" : new Date(commitment.dueAt).toISOString().slice(0, 10)}`
|
|
6599
7437
|
};
|
|
6600
7438
|
}
|
|
6601
7439
|
function radarItem(radar) {
|
|
6602
7440
|
return {
|
|
7441
|
+
...radar,
|
|
6603
7442
|
kind: "link",
|
|
6604
7443
|
id: radar.linkId,
|
|
6605
7444
|
title: `Link #${radar.linkId}`,
|
|
@@ -6609,29 +7448,34 @@ function radarItem(radar) {
|
|
|
6609
7448
|
}
|
|
6610
7449
|
function upcomingDateItem(date) {
|
|
6611
7450
|
return {
|
|
7451
|
+
...date,
|
|
7452
|
+
occursAt: date.occursOn,
|
|
7453
|
+
occursAtIso: new Date(date.occursOn).toISOString(),
|
|
6612
7454
|
kind: date.targetRef.kind,
|
|
6613
7455
|
id: date.targetRef.id,
|
|
6614
7456
|
title: date.summary,
|
|
6615
7457
|
subtitle: date.recurrenceKind,
|
|
6616
|
-
snippet: `occurs ${date.occursOn}`
|
|
7458
|
+
snippet: `occurs ${new Date(date.occursOn).toISOString().slice(0, 10)}`
|
|
6617
7459
|
};
|
|
6618
7460
|
}
|
|
6619
7461
|
function mergeHistoryItem(record) {
|
|
6620
7462
|
return {
|
|
7463
|
+
...record,
|
|
6621
7464
|
kind: "contact",
|
|
6622
7465
|
id: record.winnerContactId,
|
|
6623
7466
|
title: `Merged #${record.loserContactId} into #${record.winnerContactId}`,
|
|
6624
|
-
subtitle:
|
|
7467
|
+
subtitle: new Date(record.mergedAt).toISOString().slice(0, 10),
|
|
6625
7468
|
snippet: record.reasonSummary ?? void 0
|
|
6626
7469
|
};
|
|
6627
7470
|
}
|
|
6628
7471
|
function dismissedPairItem(record) {
|
|
6629
7472
|
return {
|
|
7473
|
+
...record,
|
|
6630
7474
|
kind: "contact",
|
|
6631
7475
|
id: record.leftContactId,
|
|
6632
7476
|
title: `Dismissed pair: #${record.leftContactId} and #${record.rightContactId}`,
|
|
6633
7477
|
subtitle: record.reason ?? "no reason given",
|
|
6634
|
-
snippet: `dismissed at ${record.dismissedAt}`
|
|
7478
|
+
snippet: `dismissed at ${new Date(record.dismissedAt).toISOString().slice(0, 10)}`
|
|
6635
7479
|
};
|
|
6636
7480
|
}
|
|
6637
7481
|
function compactFilters(input) {
|
|
@@ -6641,7 +7485,16 @@ function compactFilters(input) {
|
|
|
6641
7485
|
)
|
|
6642
7486
|
);
|
|
6643
7487
|
}
|
|
6644
|
-
function reviewAffinityToolHandler(db,
|
|
7488
|
+
function reviewAffinityToolHandler(db, rawInput) {
|
|
7489
|
+
const input = structuredClone(rawInput);
|
|
7490
|
+
const inputError = validateToolInput(reviewAffinityTool.inputSchema, input);
|
|
7491
|
+
if (inputError)
|
|
7492
|
+
return toolFailure(
|
|
7493
|
+
"protocol",
|
|
7494
|
+
"invalid_input",
|
|
7495
|
+
"Invalid input.",
|
|
7496
|
+
inputError
|
|
7497
|
+
);
|
|
6645
7498
|
return withToolHandling(() => {
|
|
6646
7499
|
const invalid = validateReviewInput(input);
|
|
6647
7500
|
if (invalid) {
|
|
@@ -6655,6 +7508,9 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
6655
7508
|
items = listContacts(
|
|
6656
7509
|
db,
|
|
6657
7510
|
{
|
|
7511
|
+
...input.attribute === void 0 ? {} : { attribute: input.attribute },
|
|
7512
|
+
...input.tag === void 0 ? {} : { tag: input.tag },
|
|
7513
|
+
...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
|
|
6658
7514
|
...input.kind === void 0 ? {} : { kind: input.kind },
|
|
6659
7515
|
...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
|
|
6660
7516
|
...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
|
|
@@ -6685,7 +7541,7 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
6685
7541
|
"Provide `contact` when `view` is `events.contact_journal`."
|
|
6686
7542
|
);
|
|
6687
7543
|
}
|
|
6688
|
-
const contact = resolveContactLocator(db, locator, "contact");
|
|
7544
|
+
const contact = resolveContactLocator(db, locator, "contact", true);
|
|
6689
7545
|
if (!contact.ok) {
|
|
6690
7546
|
return contact.result;
|
|
6691
7547
|
}
|
|
@@ -6720,7 +7576,12 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
6720
7576
|
let contactId;
|
|
6721
7577
|
let linkId;
|
|
6722
7578
|
if (input.contact !== void 0) {
|
|
6723
|
-
const contact = resolveContactLocator(
|
|
7579
|
+
const contact = resolveContactLocator(
|
|
7580
|
+
db,
|
|
7581
|
+
input.contact,
|
|
7582
|
+
"contact",
|
|
7583
|
+
true
|
|
7584
|
+
);
|
|
6724
7585
|
if (!contact.ok) {
|
|
6725
7586
|
return contact.result;
|
|
6726
7587
|
}
|
|
@@ -6749,7 +7610,12 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
6749
7610
|
let contactId;
|
|
6750
7611
|
let linkId;
|
|
6751
7612
|
if (input.contact !== void 0) {
|
|
6752
|
-
const contact = resolveContactLocator(
|
|
7613
|
+
const contact = resolveContactLocator(
|
|
7614
|
+
db,
|
|
7615
|
+
input.contact,
|
|
7616
|
+
"contact",
|
|
7617
|
+
true
|
|
7618
|
+
);
|
|
6753
7619
|
if (!contact.ok) {
|
|
6754
7620
|
return contact.result;
|
|
6755
7621
|
}
|
|
@@ -6842,7 +7708,7 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
6842
7708
|
"Provide `contact` when `view` is `merges.history`."
|
|
6843
7709
|
);
|
|
6844
7710
|
}
|
|
6845
|
-
const contact = resolveContactLocator(db, locator, "contact");
|
|
7711
|
+
const contact = resolveContactLocator(db, locator, "contact", true);
|
|
6846
7712
|
if (!contact.ok) {
|
|
6847
7713
|
return contact.result;
|
|
6848
7714
|
}
|
|
@@ -6855,6 +7721,7 @@ function reviewAffinityToolHandler(db, input) {
|
|
|
6855
7721
|
items = listDismissedDuplicates(db, options).map(dismissedPairItem);
|
|
6856
7722
|
break;
|
|
6857
7723
|
}
|
|
7724
|
+
items = items.map((item) => enrichReviewItem(db, item));
|
|
6858
7725
|
return toolSuccess(
|
|
6859
7726
|
summarizeCount(items.length, "item"),
|
|
6860
7727
|
{
|
|
@@ -6887,12 +7754,16 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
6887
7754
|
targetKinds: ["contact", "link", "event"],
|
|
6888
7755
|
inputDescriptions: {
|
|
6889
7756
|
view: "Which review surface to return.",
|
|
7757
|
+
now: "Optional current timestamp for overdue calculations.",
|
|
6890
7758
|
contact: "Target contact locator for views that require one.",
|
|
6891
7759
|
link: "Target link locator for views that require one.",
|
|
6892
7760
|
contactIds: "Optional contact id subset for duplicate review or graph review.",
|
|
6893
7761
|
kind: "Optional contact kind or link kind filter, depending on the view.",
|
|
6894
7762
|
lifecycleState: "Optional contact lifecycle filter for the contact list view.",
|
|
6895
7763
|
linkState: "Optional link state filter for link review views.",
|
|
7764
|
+
attribute: "Attribute name or name=value; arrays require all filters.",
|
|
7765
|
+
tag: "Tag name; arrays require all tags.",
|
|
7766
|
+
includeLost: "Whether lost contacts are included.",
|
|
6896
7767
|
includeOwner: "Whether owner contacts should be included in contact review.",
|
|
6897
7768
|
includeDormant: "Whether dormant links or contacts should be included where supported.",
|
|
6898
7769
|
includeArchived: "Whether archived links should be included where supported.",
|
|
@@ -6929,6 +7800,9 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
6929
7800
|
"merges.history",
|
|
6930
7801
|
"merges.dismissed"
|
|
6931
7802
|
]),
|
|
7803
|
+
now: integerSchema(
|
|
7804
|
+
"Optional current timestamp for overdue calculations."
|
|
7805
|
+
),
|
|
6932
7806
|
contact: contactLocatorSchema9("Optional target contact locator."),
|
|
6933
7807
|
link: linkLocatorSchema5("Optional target link locator."),
|
|
6934
7808
|
contactIds: arraySchema(
|
|
@@ -6951,6 +7825,17 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
6951
7825
|
"broken",
|
|
6952
7826
|
"archived"
|
|
6953
7827
|
]),
|
|
7828
|
+
attribute: oneOfSchema([
|
|
7829
|
+
stringSchema("Attribute filter."),
|
|
7830
|
+
arraySchema(stringSchema("Attribute filter."), "AND filters.")
|
|
7831
|
+
]),
|
|
7832
|
+
tag: oneOfSchema([
|
|
7833
|
+
stringSchema("Tag filter."),
|
|
7834
|
+
arraySchema(stringSchema("Tag filter."), "AND tags.")
|
|
7835
|
+
]),
|
|
7836
|
+
includeLost: booleanSchema(
|
|
7837
|
+
"Include lost contacts (search excludes them by default)."
|
|
7838
|
+
),
|
|
6954
7839
|
includeOwner: booleanSchema("Whether owner contacts are included."),
|
|
6955
7840
|
includeDormant: booleanSchema("Whether dormant rows are included."),
|
|
6956
7841
|
includeArchived: booleanSchema("Whether archived links are included."),
|
|
@@ -6995,92 +7880,101 @@ var reviewAffinityTool = defineAffinityTool({
|
|
|
6995
7880
|
});
|
|
6996
7881
|
|
|
6997
7882
|
// src/contacts/search_contacts.ts
|
|
6998
|
-
function relevanceScore(name, queryLower, matchedName, matchedNormalizedIdentity) {
|
|
6999
|
-
const nl = name.toLowerCase();
|
|
7000
|
-
if (matchedNormalizedIdentity) return 1.1;
|
|
7001
|
-
if (nl.startsWith(queryLower)) return 1;
|
|
7002
|
-
if (matchedName) return 0.85;
|
|
7003
|
-
return 0.7;
|
|
7004
|
-
}
|
|
7005
7883
|
function searchContacts(db, query, filters, options) {
|
|
7006
|
-
const q = query.trim();
|
|
7007
|
-
if (q
|
|
7008
|
-
return [];
|
|
7009
|
-
}
|
|
7884
|
+
const q = foldSearchText(query.trim());
|
|
7885
|
+
if (!q) return [];
|
|
7010
7886
|
assertDefaultOrdering("searchContacts", options);
|
|
7011
7887
|
const { limit, offset } = resolveListLimitOffset(options);
|
|
7012
|
-
const
|
|
7013
|
-
const
|
|
7014
|
-
|
|
7888
|
+
const fields = /* @__PURE__ */ new Map();
|
|
7889
|
+
const rows = db.prepare(`
|
|
7890
|
+
SELECT contact_id, value || ' ' || normalized_key AS text, 'identity:' || type AS source FROM identities WHERE removed_at IS NULL
|
|
7891
|
+
UNION ALL SELECT contact_id, name || ' ' || COALESCE(value, ''), 'attribute:' || name FROM attributes WHERE deleted_at IS NULL AND contact_id IS NOT NULL
|
|
7892
|
+
UNION ALL SELECT from_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
|
|
7893
|
+
UNION ALL SELECT to_contact_id, COALESCE(role, '') || ' ' || COALESCE(bond, ''), 'link_role' FROM links WHERE removed_at IS NULL
|
|
7894
|
+
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
|
|
7895
|
+
`).all();
|
|
7896
|
+
for (const row of rows) {
|
|
7897
|
+
const values = fields.get(row.contact_id) ?? [];
|
|
7898
|
+
values.push({ text: foldSearchText(row.text), source: row.source });
|
|
7899
|
+
fields.set(row.contact_id, values);
|
|
7900
|
+
}
|
|
7901
|
+
const tokens = q.split(/\s+/);
|
|
7902
|
+
const phone = /^[+\d()\-/.\s]+$/.test(q) && q.replace(/\D/g, "").length >= 3;
|
|
7903
|
+
const digits = q.replace(/\D/g, "").replace(/^00/, "");
|
|
7904
|
+
const attributeFilters = contactAttributeClauses(filters);
|
|
7015
7905
|
const clauses = [
|
|
7016
|
-
"
|
|
7017
|
-
"
|
|
7906
|
+
"deleted_at IS NULL",
|
|
7907
|
+
"lifecycle_state != 'merged'",
|
|
7908
|
+
...attributeFilters.clauses
|
|
7018
7909
|
];
|
|
7019
|
-
const params = [];
|
|
7910
|
+
const params = [...attributeFilters.params];
|
|
7020
7911
|
if (filters?.kind !== void 0) {
|
|
7021
|
-
clauses.push("
|
|
7912
|
+
clauses.push("kind = ?");
|
|
7022
7913
|
params.push(filters.kind);
|
|
7023
7914
|
}
|
|
7024
7915
|
if (filters?.lifecycleState !== void 0) {
|
|
7025
|
-
clauses.push("
|
|
7916
|
+
clauses.push("lifecycle_state = ?");
|
|
7026
7917
|
params.push(filters.lifecycleState);
|
|
7027
7918
|
}
|
|
7028
|
-
if (filters?.includeOwner === false)
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
const
|
|
7036
|
-
|
|
7037
|
-
|
|
7038
|
-
|
|
7039
|
-
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
Number(
|
|
7067
|
-
r.matched_normalized_identity ?? 0
|
|
7068
|
-
) > 0
|
|
7919
|
+
if (filters?.includeOwner === false) clauses.push("is_owner = 0");
|
|
7920
|
+
const contacts = db.prepare(
|
|
7921
|
+
`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 ")}`
|
|
7922
|
+
).all(...params).map((row) => ({
|
|
7923
|
+
...mapContactRowToContactListItem(row),
|
|
7924
|
+
primaryIdentity: row.primary_identity
|
|
7925
|
+
}));
|
|
7926
|
+
const scored = [];
|
|
7927
|
+
for (const contact of contacts) {
|
|
7928
|
+
if (contact.lifecycleState === "dormant" && !(filters?.includeDormant ?? options?.includeDormant))
|
|
7929
|
+
continue;
|
|
7930
|
+
if (contact.lifecycleState === "lost" && !filters?.includeLost) continue;
|
|
7931
|
+
const name = foldSearchText(contact.name);
|
|
7932
|
+
const values = [
|
|
7933
|
+
{ text: name, source: "name" },
|
|
7934
|
+
...(fields.get(contact.id) ?? []).filter(
|
|
7935
|
+
(field) => !contact.isOwner || field.source !== "event" && field.source !== "link_role"
|
|
7936
|
+
)
|
|
7937
|
+
];
|
|
7938
|
+
const phoneFields = phone ? values.filter(
|
|
7939
|
+
(field) => field.source === "identity:phone" && field.text.split(" ").join("").replace(/[^\d]/g, "").replace(/^00/, "").includes(digits)
|
|
7940
|
+
) : [];
|
|
7941
|
+
if (!phoneFields.length && !tokens.every(
|
|
7942
|
+
(token) => values.some((field) => field.text.includes(token))
|
|
7943
|
+
))
|
|
7944
|
+
continue;
|
|
7945
|
+
const matchedOn = [
|
|
7946
|
+
...new Set(
|
|
7947
|
+
[
|
|
7948
|
+
...values.filter(
|
|
7949
|
+
(field) => tokens.some((token) => field.text.includes(token))
|
|
7950
|
+
),
|
|
7951
|
+
...phoneFields
|
|
7952
|
+
].map((field) => field.source)
|
|
7953
|
+
)
|
|
7954
|
+
];
|
|
7955
|
+
const identities = values.filter(
|
|
7956
|
+
(field) => field.source.startsWith("identity:")
|
|
7069
7957
|
);
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
});
|
|
7078
|
-
scored.sort((a, b) => b.score - a.score);
|
|
7079
|
-
return scored.slice(offset, offset + limit).map((s) => s.item);
|
|
7958
|
+
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(
|
|
7959
|
+
(field) => tokens.every((token) => field.text.includes(token))
|
|
7960
|
+
) ? 2 : 1;
|
|
7961
|
+
scored.push({ item: { ...contact, matchedOn }, score });
|
|
7962
|
+
}
|
|
7963
|
+
scored.sort((a, b) => b.score - a.score || a.item.id - b.item.id);
|
|
7964
|
+
return scored.slice(offset, offset + limit).map(({ item }) => item);
|
|
7080
7965
|
}
|
|
7081
7966
|
|
|
7082
7967
|
// src/tools/search_affinity_tool.ts
|
|
7083
|
-
function searchAffinityToolHandler(db,
|
|
7968
|
+
function searchAffinityToolHandler(db, rawInput) {
|
|
7969
|
+
const input = structuredClone(rawInput);
|
|
7970
|
+
const inputError = validateToolInput(searchAffinityTool.inputSchema, input);
|
|
7971
|
+
if (inputError)
|
|
7972
|
+
return toolFailure(
|
|
7973
|
+
"protocol",
|
|
7974
|
+
"invalid_input",
|
|
7975
|
+
"Invalid input.",
|
|
7976
|
+
inputError
|
|
7977
|
+
);
|
|
7084
7978
|
return withToolHandling(() => {
|
|
7085
7979
|
const options = {
|
|
7086
7980
|
...input.limit === void 0 ? {} : { limit: input.limit },
|
|
@@ -7090,6 +7984,9 @@ function searchAffinityToolHandler(db, input) {
|
|
|
7090
7984
|
db,
|
|
7091
7985
|
input.query,
|
|
7092
7986
|
{
|
|
7987
|
+
...input.attribute === void 0 ? {} : { attribute: input.attribute },
|
|
7988
|
+
...input.tag === void 0 ? {} : { tag: input.tag },
|
|
7989
|
+
...input.includeLost === void 0 ? {} : { includeLost: input.includeLost },
|
|
7093
7990
|
...input.kind === void 0 ? {} : { kind: input.kind },
|
|
7094
7991
|
...input.lifecycleState === void 0 ? {} : { lifecycleState: input.lifecycleState },
|
|
7095
7992
|
...input.includeOwner === void 0 ? {} : { includeOwner: input.includeOwner },
|
|
@@ -7113,7 +8010,7 @@ function searchAffinityToolHandler(db, input) {
|
|
|
7113
8010
|
}
|
|
7114
8011
|
var searchAffinityTool = defineAffinityTool({
|
|
7115
8012
|
name: searchAffinityToolName,
|
|
7116
|
-
description: "Search contacts by name
|
|
8013
|
+
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.",
|
|
7117
8014
|
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.",
|
|
7118
8015
|
whenNotToUse: "Do not use this when you already know the exact contact you want to inspect in depth.",
|
|
7119
8016
|
sideEffects: "none",
|
|
@@ -7124,6 +8021,9 @@ var searchAffinityTool = defineAffinityTool({
|
|
|
7124
8021
|
query: "Free-text or natural-key search text.",
|
|
7125
8022
|
kind: "Optional contact kind filter.",
|
|
7126
8023
|
lifecycleState: "Optional lifecycle state filter.",
|
|
8024
|
+
attribute: "Attribute name or name=value; arrays require all filters.",
|
|
8025
|
+
tag: "Tag name; arrays require all tags.",
|
|
8026
|
+
includeLost: "Whether lost contacts are included in search.",
|
|
7127
8027
|
includeOwner: "Whether owner contacts are eligible for results.",
|
|
7128
8028
|
includeDormant: "Whether dormant contacts should be eligible for results.",
|
|
7129
8029
|
limit: "Optional result limit.",
|
|
@@ -7148,6 +8048,17 @@ var searchAffinityTool = defineAffinityTool({
|
|
|
7148
8048
|
"merged",
|
|
7149
8049
|
"lost"
|
|
7150
8050
|
]),
|
|
8051
|
+
attribute: oneOfSchema([
|
|
8052
|
+
stringSchema("Attribute filter."),
|
|
8053
|
+
arraySchema(stringSchema("Attribute filter."), "AND filters.")
|
|
8054
|
+
]),
|
|
8055
|
+
tag: oneOfSchema([
|
|
8056
|
+
stringSchema("Tag filter."),
|
|
8057
|
+
arraySchema(stringSchema("Tag filter."), "AND tags.")
|
|
8058
|
+
]),
|
|
8059
|
+
includeLost: booleanSchema(
|
|
8060
|
+
"Include lost contacts (search excludes them by default)."
|
|
8061
|
+
),
|
|
7151
8062
|
includeOwner: booleanSchema("Whether owner contacts are included."),
|
|
7152
8063
|
includeDormant: booleanSchema("Whether dormant contacts are included."),
|
|
7153
8064
|
limit: integerSchema("Optional result limit."),
|
|
@@ -7175,6 +8086,94 @@ var affinityTools = [
|
|
|
7175
8086
|
manageAttributeTool
|
|
7176
8087
|
];
|
|
7177
8088
|
|
|
8089
|
+
// src/surface/plain_dates.ts
|
|
8090
|
+
function parsePlainDue(raw, now = Date.now(), endOfDay = true) {
|
|
8091
|
+
const invalid = () => new CliUsageError(
|
|
8092
|
+
`Invalid timestamp "${raw}". Use YYYY-MM-DD, DD.MM.YYYY, today, tomorrow, yesterday, +3d, -2d or an ISO date-time.`
|
|
8093
|
+
);
|
|
8094
|
+
if (/^-?\d+$/.test(raw)) {
|
|
8095
|
+
const value = Number(raw);
|
|
8096
|
+
if (Number.isSafeInteger(value) && Number.isFinite(new Date(value).getTime()))
|
|
8097
|
+
return value;
|
|
8098
|
+
throw invalid();
|
|
8099
|
+
}
|
|
8100
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
|
8101
|
+
const european = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
|
|
8102
|
+
let date;
|
|
8103
|
+
if (iso || european) {
|
|
8104
|
+
const year = Number(iso?.[1] ?? european?.[3]);
|
|
8105
|
+
const month = Number(iso?.[2] ?? european?.[2]);
|
|
8106
|
+
const day = Number(iso?.[3] ?? european?.[1]);
|
|
8107
|
+
date = /* @__PURE__ */ new Date(0);
|
|
8108
|
+
date.setFullYear(year, month - 1, day);
|
|
8109
|
+
date.setHours(
|
|
8110
|
+
endOfDay ? 23 : 0,
|
|
8111
|
+
endOfDay ? 59 : 0,
|
|
8112
|
+
endOfDay ? 59 : 0,
|
|
8113
|
+
endOfDay ? 999 : 0
|
|
8114
|
+
);
|
|
8115
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day)
|
|
8116
|
+
throw invalid();
|
|
8117
|
+
} else if (raw === "today" || raw === "tomorrow" || raw === "yesterday" || /^[+-]\d+d$/.test(raw)) {
|
|
8118
|
+
date = new Date(now);
|
|
8119
|
+
date.setDate(
|
|
8120
|
+
date.getDate() + (raw === "today" ? 0 : raw === "tomorrow" ? 1 : raw === "yesterday" ? -1 : Number(raw.slice(0, -1)))
|
|
8121
|
+
);
|
|
8122
|
+
date.setHours(
|
|
8123
|
+
endOfDay ? 23 : 0,
|
|
8124
|
+
endOfDay ? 59 : 0,
|
|
8125
|
+
endOfDay ? 59 : 0,
|
|
8126
|
+
endOfDay ? 999 : 0
|
|
8127
|
+
);
|
|
8128
|
+
} else {
|
|
8129
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
8130
|
+
raw
|
|
8131
|
+
))
|
|
8132
|
+
throw invalid();
|
|
8133
|
+
parsePlainDue(raw.slice(0, 10), now);
|
|
8134
|
+
date = new Date(raw);
|
|
8135
|
+
}
|
|
8136
|
+
if (!Number.isFinite(date.getTime())) throw invalid();
|
|
8137
|
+
return date.getTime();
|
|
8138
|
+
}
|
|
8139
|
+
function parsePlainStart(raw, now = Date.now()) {
|
|
8140
|
+
return parsePlainDue(raw, now, false);
|
|
8141
|
+
}
|
|
8142
|
+
function normalizeTimeInputs(value, now = Date.now()) {
|
|
8143
|
+
if (Array.isArray(value))
|
|
8144
|
+
return value.map((item) => normalizeTimeInputs(item, now));
|
|
8145
|
+
if (!value || typeof value !== "object") return value;
|
|
8146
|
+
return Object.fromEntries(
|
|
8147
|
+
Object.entries(value).map(([key, item]) => [
|
|
8148
|
+
key,
|
|
8149
|
+
typeof item === "string" && (key.endsWith("At") || ["asOf", "now"].includes(key)) ? key === "dueAt" ? parsePlainDue(item, now) : parsePlainStart(item, now) : normalizeTimeInputs(item, now)
|
|
8150
|
+
])
|
|
8151
|
+
);
|
|
8152
|
+
}
|
|
8153
|
+
|
|
8154
|
+
// src/surface/timestamp_schema.ts
|
|
8155
|
+
function acceptsString(schema) {
|
|
8156
|
+
return schema.type === "string" || Array.isArray(schema.type) && schema.type.includes("string") || (schema.oneOf?.some(acceptsString) ?? false);
|
|
8157
|
+
}
|
|
8158
|
+
function timestampSchema(schema) {
|
|
8159
|
+
return {
|
|
8160
|
+
...schema,
|
|
8161
|
+
...schema.properties ? {
|
|
8162
|
+
properties: Object.fromEntries(
|
|
8163
|
+
Object.entries(schema.properties).map(([key, value]) => [
|
|
8164
|
+
key,
|
|
8165
|
+
key.endsWith("At") || ["asOf", "now"].includes(key) ? acceptsString(value) ? value : {
|
|
8166
|
+
description: value.description,
|
|
8167
|
+
oneOf: [value, { type: "string" }]
|
|
8168
|
+
} : timestampSchema(value)
|
|
8169
|
+
])
|
|
8170
|
+
)
|
|
8171
|
+
} : {},
|
|
8172
|
+
...schema.items ? { items: timestampSchema(schema.items) } : {},
|
|
8173
|
+
...schema.oneOf ? { oneOf: schema.oneOf.map(timestampSchema) } : {}
|
|
8174
|
+
};
|
|
8175
|
+
}
|
|
8176
|
+
|
|
7178
8177
|
// src/surface/affinity_surface_tools.ts
|
|
7179
8178
|
var affinitySurfaceTools = affinityTools.map((tool) => ({
|
|
7180
8179
|
name: tool.name,
|
|
@@ -7183,8 +8182,24 @@ var affinitySurfaceTools = affinityTools.map((tool) => ({
|
|
|
7183
8182
|
...tool.whenNotToUse === void 0 ? {} : { whenNotToUse: tool.whenNotToUse },
|
|
7184
8183
|
readOnly: tool.readOnly,
|
|
7185
8184
|
sideEffects: tool.sideEffects,
|
|
7186
|
-
inputSchema: {
|
|
7187
|
-
|
|
8185
|
+
inputSchema: {
|
|
8186
|
+
...timestampSchema({ ...tool.inputSchema, type: "object" }),
|
|
8187
|
+
type: "object"
|
|
8188
|
+
},
|
|
8189
|
+
handler: (db, input) => {
|
|
8190
|
+
try {
|
|
8191
|
+
return tool.handler(db, normalizeTimeInputs(input));
|
|
8192
|
+
} catch (error) {
|
|
8193
|
+
if (error instanceof CliUsageError)
|
|
8194
|
+
return toolFailure(
|
|
8195
|
+
"protocol",
|
|
8196
|
+
"invalid_input",
|
|
8197
|
+
"Invalid input.",
|
|
8198
|
+
error.message
|
|
8199
|
+
);
|
|
8200
|
+
throw error;
|
|
8201
|
+
}
|
|
8202
|
+
}
|
|
7188
8203
|
}));
|
|
7189
8204
|
|
|
7190
8205
|
// src/surface/affinity_mcp_options.ts
|
|
@@ -7466,6 +8481,12 @@ function initLinkEventEffectsTables(db) {
|
|
|
7466
8481
|
)
|
|
7467
8482
|
)
|
|
7468
8483
|
`);
|
|
8484
|
+
const columns = db.prepare("PRAGMA table_info(link_event_effects)").all();
|
|
8485
|
+
if (!columns.some((column) => column.name === "counts_for_recency")) {
|
|
8486
|
+
db.exec(
|
|
8487
|
+
"ALTER TABLE link_event_effects ADD COLUMN counts_for_recency INTEGER NOT NULL DEFAULT 1"
|
|
8488
|
+
);
|
|
8489
|
+
}
|
|
7469
8490
|
db.exec(
|
|
7470
8491
|
"CREATE INDEX IF NOT EXISTS idx_link_event_effects_link_time ON link_event_effects(link_id, occurred_at DESC, event_id DESC)"
|
|
7471
8492
|
);
|
|
@@ -7657,7 +8678,8 @@ async function openAffinityDb(path) {
|
|
|
7657
8678
|
}
|
|
7658
8679
|
}
|
|
7659
8680
|
function resolveAffinityDbPath(options = {}) {
|
|
7660
|
-
|
|
8681
|
+
const path = options.flag ?? ((options.env ?? process.env).GHOSTPAW_AFFINITY_DB || node_path.join(node_os.homedir(), ".ghostpaw", "affinity.db"));
|
|
8682
|
+
return path === ":memory:" ? path : node_path.resolve(path);
|
|
7661
8683
|
}
|
|
7662
8684
|
|
|
7663
8685
|
// src/surfaces/surface_types.ts
|
|
@@ -7884,10 +8906,10 @@ async function serveMcpStdio(server, options = {}) {
|
|
|
7884
8906
|
const response = await server.handleLine(line.replace(/\r$/, ""));
|
|
7885
8907
|
if (response !== void 0)
|
|
7886
8908
|
await new Promise(
|
|
7887
|
-
(
|
|
8909
|
+
(resolve2, reject) => output.write(
|
|
7888
8910
|
`${response}
|
|
7889
8911
|
`,
|
|
7890
|
-
(error) => error ? reject(error) :
|
|
8912
|
+
(error) => error ? reject(error) : resolve2()
|
|
7891
8913
|
)
|
|
7892
8914
|
);
|
|
7893
8915
|
};
|