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