@hraness/peopleblade 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -4
- package/dist/cloud-sync-client.js +13 -4
- package/dist/migrations/017_person_note_content_revisions.sql +151 -0
- package/dist/peopleblade.js +2478 -528
- package/package.json +1 -1
package/dist/peopleblade.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
var __require = import.meta.require;
|
|
4
4
|
|
|
5
5
|
// src/cli/main.ts
|
|
6
|
-
import { existsSync as existsSync5 } from "fs";
|
|
6
|
+
import { closeSync as closeSync9, constants as constants9, existsSync as existsSync5, fstatSync as fstatSync8, openSync as openSync9, readSync as readSync3 } from "fs";
|
|
7
7
|
import { resolve as resolve6 } from "path";
|
|
8
|
+
import { ZodError } from "zod";
|
|
8
9
|
|
|
9
10
|
// src/local/contacts.ts
|
|
10
11
|
import { closeSync, constants, fstatSync, openSync, readFileSync } from "fs";
|
|
@@ -363,6 +364,7 @@ var serverCliEnrichmentStartSchema = z.object({
|
|
|
363
364
|
var cliEnrichmentStatusSchema = z.object({
|
|
364
365
|
jobId: z.uuid()
|
|
365
366
|
}).strict();
|
|
367
|
+
var cliEnrichmentPublicEmailsRequestSchema = z.object({}).strict();
|
|
366
368
|
var cliEnrichmentRevalidationSchema = z.object({
|
|
367
369
|
limit: z.number().int().min(1).max(100)
|
|
368
370
|
}).strict();
|
|
@@ -2857,8 +2859,8 @@ function exactProfileSupersessionConflictsWithClaim(subject, output, clause, fie
|
|
|
2857
2859
|
if (clause.tokens.some((token) => LOCATION_SUPERSESSION_MARKERS.has(token))) {
|
|
2858
2860
|
return true;
|
|
2859
2861
|
}
|
|
2860
|
-
const
|
|
2861
|
-
return
|
|
2862
|
+
const location2 = normalizedEvidenceText(output.location).split(" ").filter(Boolean);
|
|
2863
|
+
return location2.length === 0 || normalizedPhraseStarts(clause.tokens, location2).length > 0;
|
|
2862
2864
|
}
|
|
2863
2865
|
if (!exactProfileClauseSupersedesEmployment(subject, clause))
|
|
2864
2866
|
return false;
|
|
@@ -3836,6 +3838,26 @@ var researchFileSchema = z3.object({
|
|
|
3836
3838
|
researcher: z3.enum(["agent-public-web", "human-public-web"]),
|
|
3837
3839
|
result: manualResearchSchema
|
|
3838
3840
|
}).strict();
|
|
3841
|
+
var researchInstructions = "Search only public pages. A same-name page is never enough: one identity-evidence row must use a trusted profile URL from identityAnchors, or contain the exact full name plus a stored organization or title or a trusted email or phone from identityAnchors. providerHandles are display/search hints only. Do not guess emails. Cite every non-null field with evidence indexes.";
|
|
3842
|
+
var researchSubjectSchema = z3.object({
|
|
3843
|
+
name: z3.string(),
|
|
3844
|
+
organization: z3.string().nullable(),
|
|
3845
|
+
title: z3.string().nullable(),
|
|
3846
|
+
emails: z3.array(z3.string()).max(100),
|
|
3847
|
+
phones: z3.array(z3.string()).max(100),
|
|
3848
|
+
identityAnchors: z3.object({
|
|
3849
|
+
emails: z3.array(z3.string()).max(100),
|
|
3850
|
+
phones: z3.array(z3.string()).max(100),
|
|
3851
|
+
profileUrls: z3.array(z3.string()).max(100)
|
|
3852
|
+
}).strict(),
|
|
3853
|
+
providerHandles: z3.record(z3.string(), z3.string())
|
|
3854
|
+
}).strict();
|
|
3855
|
+
var preparedResearchSchema = researchFileSchema.extend({
|
|
3856
|
+
schemaVersion: z3.literal("peopleblade.research.v1"),
|
|
3857
|
+
inputSha256: z3.string().regex(/^[a-f0-9]{64}$/u),
|
|
3858
|
+
subject: researchSubjectSchema,
|
|
3859
|
+
instructions: z3.literal(researchInstructions)
|
|
3860
|
+
}).strict();
|
|
3839
3861
|
var MAX_RESEARCH_FILE_BYTES = 2 * 1024 * 1024;
|
|
3840
3862
|
var MAX_RESEARCH_EMAILS = 100;
|
|
3841
3863
|
var MAX_RESEARCH_EMAIL_CANDIDATES = 1e4;
|
|
@@ -3980,53 +4002,582 @@ function readResearchFile(path) {
|
|
|
3980
4002
|
}
|
|
3981
4003
|
}
|
|
3982
4004
|
function applyManualResearch(database, path) {
|
|
3983
|
-
const parsed = researchFileSchema.parse(readResearchFile(path));
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
4005
|
+
const parsed = z3.union([preparedResearchSchema, researchFileSchema]).parse(readResearchFile(path));
|
|
4006
|
+
return database.transaction(() => {
|
|
4007
|
+
const { evidence, ...output } = parsed.result;
|
|
4008
|
+
const context = currentResearchContext(database, parsed.personId);
|
|
4009
|
+
if ("schemaVersion" in parsed) {
|
|
4010
|
+
const current = preparedResearchContext(database, context);
|
|
4011
|
+
if (parsed.personId !== context.personId || parsed.inputSha256 !== current.inputSha256 || canonicalJson(parsed.subject) !== canonicalJson(current.subject)) {
|
|
4012
|
+
throw new Error("Research preparation is stale or belongs to a different database. Prepare this contact again.");
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
validateEnrichmentIdentityBinding({
|
|
4016
|
+
displayName: context.displayName,
|
|
4017
|
+
emails: context.emails,
|
|
4018
|
+
phones: context.phones,
|
|
4019
|
+
identityAnchors: {
|
|
4020
|
+
state: "available",
|
|
4021
|
+
emails: [...context.emails],
|
|
4022
|
+
phones: [...context.phones],
|
|
4023
|
+
profileUrls: [...context.profileUrls]
|
|
4024
|
+
},
|
|
4025
|
+
organization: context.organization,
|
|
4026
|
+
title: context.title,
|
|
4027
|
+
providerHandles: context.providerHandles
|
|
4028
|
+
}, output, evidence);
|
|
4029
|
+
const evidenceSha = sha256(canonicalJson(parsed.result.evidence));
|
|
4030
|
+
const existing = database.query("SELECT id,output_json FROM public_enrichments WHERE person_id=? AND provider=? AND input_sha256=? AND evidence_sha256=?").get(context.personId, parsed.researcher, context.inputSha256, evidenceSha);
|
|
4031
|
+
if (existing !== null) {
|
|
4032
|
+
if (canonicalJson(JSON.parse(existing.output_json)) !== canonicalJson(parsed.result)) {
|
|
4033
|
+
throw new Error("Research evidence already has a different reviewed result. Existing evidence is immutable; do not treat this as a successful replay.");
|
|
4034
|
+
}
|
|
4035
|
+
return { enrichmentId: existing.id, cached: true };
|
|
4036
|
+
}
|
|
4037
|
+
const result = database.query(`INSERT INTO public_enrichments(person_id,provider,input_sha256,evidence_sha256,evidence_json,output_json)
|
|
4038
|
+
VALUES (?,?,?,?,?,?)`).run(context.personId, parsed.researcher, context.inputSha256, evidenceSha, canonicalJson(parsed.result.evidence), canonicalJson(parsed.result));
|
|
4039
|
+
return { enrichmentId: Number(result.lastInsertRowid), cached: false };
|
|
4040
|
+
}).immediate();
|
|
4041
|
+
}
|
|
4042
|
+
function preparedResearchContext(database, context) {
|
|
4043
|
+
const subject = {
|
|
4044
|
+
name: context.displayName,
|
|
4045
|
+
organization: context.organization,
|
|
4046
|
+
title: context.title,
|
|
3988
4047
|
emails: context.emails,
|
|
3989
4048
|
phones: context.phones,
|
|
3990
4049
|
identityAnchors: {
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
profileUrls: [...context.profileUrls]
|
|
4050
|
+
emails: context.emails,
|
|
4051
|
+
phones: context.phones,
|
|
4052
|
+
profileUrls: context.profileUrls
|
|
3995
4053
|
},
|
|
3996
|
-
organization: context.organization,
|
|
3997
|
-
title: context.title,
|
|
3998
4054
|
providerHandles: context.providerHandles
|
|
3999
|
-
}
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4055
|
+
};
|
|
4056
|
+
return {
|
|
4057
|
+
subject,
|
|
4058
|
+
inputSha256: sha256(canonicalJson({
|
|
4059
|
+
schemaVersion: "peopleblade.research.v1",
|
|
4060
|
+
databaseInstanceId: localDatabaseInstanceId(database),
|
|
4061
|
+
personId: context.personId,
|
|
4062
|
+
contextSha256: context.inputSha256,
|
|
4063
|
+
subject,
|
|
4064
|
+
instructions: researchInstructions
|
|
4065
|
+
}))
|
|
4066
|
+
};
|
|
4007
4067
|
}
|
|
4008
4068
|
function researchTemplate(database, personId) {
|
|
4009
|
-
|
|
4069
|
+
return database.transaction(() => {
|
|
4070
|
+
const context = currentResearchContext(database, personId);
|
|
4071
|
+
return {
|
|
4072
|
+
schemaVersion: "peopleblade.research.v1",
|
|
4073
|
+
personId: context.personId,
|
|
4074
|
+
researcher: "agent-public-web",
|
|
4075
|
+
...preparedResearchContext(database, context),
|
|
4076
|
+
instructions: researchInstructions,
|
|
4077
|
+
result: { identityMatch: "insufficient", identityEvidenceIndexes: [], confidence: 0, headline: null, organization: null, role: null, location: null, website: null, publicEmail: null, publicEmailEvidenceIndex: null, notes: "", claims: [], evidence: [] }
|
|
4078
|
+
};
|
|
4079
|
+
}).deferred();
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
// src/local/contact-query.ts
|
|
4083
|
+
import { z as z4 } from "zod";
|
|
4084
|
+
var contactQueryInputSchema = z4.object({
|
|
4085
|
+
search: z4.string().max(200).default(""),
|
|
4086
|
+
source: z4.string().regex(/^[a-z][a-z0-9-]{0,63}$/u).optional(),
|
|
4087
|
+
doNotContact: z4.enum(["all", "exclude", "only"]).default("all"),
|
|
4088
|
+
hasEmail: z4.boolean().optional(),
|
|
4089
|
+
hasPhone: z4.boolean().optional(),
|
|
4090
|
+
sort: z4.enum(["name", "organization", "interactions", "last-contact"]).default("interactions"),
|
|
4091
|
+
direction: z4.enum(["asc", "desc"]).default("desc"),
|
|
4092
|
+
limit: z4.number().int().min(1).max(100).default(50),
|
|
4093
|
+
offset: z4.number().int().min(0).max(1e6).default(0)
|
|
4094
|
+
}).strict();
|
|
4095
|
+
var contactCtes = `
|
|
4096
|
+
-- Resolve the existing canonical view once, including reviewed representatives.
|
|
4097
|
+
-- The schema qualifier avoids a recursive reference to this local CTE.
|
|
4098
|
+
person_identity_components AS MATERIALIZED (
|
|
4099
|
+
SELECT * FROM main.person_identity_components
|
|
4100
|
+
),
|
|
4101
|
+
${canonicalInteractionRollupCtesSql},
|
|
4102
|
+
active_resources AS MATERIALIZED (
|
|
4103
|
+
SELECT resource.*, member.canonical_person_id, realm.service
|
|
4104
|
+
FROM provider_resources resource
|
|
4105
|
+
JOIN person_identity_components member ON member.person_id=resource.person_id
|
|
4106
|
+
LEFT JOIN source_realms realm ON realm.id=resource.source_realm_id
|
|
4107
|
+
WHERE resource.active=1 AND (realm.id IS NULL OR realm.active=1)
|
|
4108
|
+
),
|
|
4109
|
+
active_methods AS MATERIALIZED (
|
|
4110
|
+
SELECT method.*,member.canonical_person_id
|
|
4111
|
+
FROM contact_methods method
|
|
4112
|
+
JOIN person_identity_components member ON member.person_id=method.person_id
|
|
4113
|
+
LEFT JOIN provider_resources resource ON resource.id=method.provider_resource_id
|
|
4114
|
+
LEFT JOIN source_realms realm ON realm.id=resource.source_realm_id
|
|
4115
|
+
WHERE method.active=1 AND (method.provider_resource_id IS NULL OR (
|
|
4116
|
+
resource.active=1 AND (realm.id IS NULL OR realm.active=1)
|
|
4117
|
+
))
|
|
4118
|
+
),
|
|
4119
|
+
component_rollup AS (
|
|
4120
|
+
SELECT member.canonical_person_id AS person_id,max(person.do_not_contact) AS do_not_contact,
|
|
4121
|
+
CASE WHEN count(DISTINCT person.birthday)=1 THEN min(person.birthday) ELSE NULL END AS birthday
|
|
4122
|
+
FROM person_identity_components member JOIN people person ON person.id=member.person_id
|
|
4123
|
+
GROUP BY member.canonical_person_id
|
|
4124
|
+
),
|
|
4125
|
+
source_values AS (
|
|
4126
|
+
SELECT canonical_person_id AS person_id,provider FROM active_resources
|
|
4127
|
+
UNION SELECT person_id,provider FROM canonical_interaction_inputs
|
|
4128
|
+
),
|
|
4129
|
+
ranked_methods AS (
|
|
4130
|
+
SELECT canonical_person_id,kind,value,
|
|
4131
|
+
row_number() OVER (
|
|
4132
|
+
PARTITION BY canonical_person_id,kind ORDER BY is_primary DESC,id
|
|
4133
|
+
) AS preference
|
|
4134
|
+
FROM active_methods WHERE kind IN ('email','phone')
|
|
4135
|
+
),
|
|
4136
|
+
primary_methods AS (
|
|
4137
|
+
SELECT canonical_person_id,
|
|
4138
|
+
max(CASE WHEN kind='email' THEN value END) AS primary_email,
|
|
4139
|
+
max(CASE WHEN kind='phone' THEN value END) AS primary_phone
|
|
4140
|
+
FROM ranked_methods WHERE preference=1 GROUP BY canonical_person_id
|
|
4141
|
+
),
|
|
4142
|
+
source_rollup AS (
|
|
4143
|
+
SELECT person_id,json_group_array(provider) AS sources_json
|
|
4144
|
+
FROM source_values GROUP BY person_id
|
|
4145
|
+
),
|
|
4146
|
+
-- Recursive-view cardinality estimates can make SQLite repeatedly scan each
|
|
4147
|
+
-- aggregated LEFT JOIN for every contact. Union the independent fact lanes
|
|
4148
|
+
-- and group once instead; each lane contributes at most one row per person.
|
|
4149
|
+
summary_facts(person_id,birthday,do_not_contact,interaction_count,reciprocal,
|
|
4150
|
+
last_interaction_at,primary_email,primary_phone,sources_json) AS (
|
|
4151
|
+
SELECT person_id,birthday,do_not_contact,NULL,NULL,NULL,NULL,NULL,NULL FROM component_rollup
|
|
4152
|
+
UNION ALL
|
|
4153
|
+
SELECT person_id,NULL,NULL,interaction_count,reciprocal,last_interaction_at,NULL,NULL,NULL
|
|
4154
|
+
FROM canonical_interaction_rollup
|
|
4155
|
+
UNION ALL
|
|
4156
|
+
SELECT canonical_person_id,NULL,NULL,NULL,NULL,NULL,primary_email,primary_phone,NULL
|
|
4157
|
+
FROM primary_methods
|
|
4158
|
+
UNION ALL
|
|
4159
|
+
SELECT person_id,NULL,NULL,NULL,NULL,NULL,NULL,NULL,sources_json FROM source_rollup
|
|
4160
|
+
),
|
|
4161
|
+
summary_rollup AS (
|
|
4162
|
+
SELECT person_id,max(birthday) AS birthday,max(do_not_contact) AS do_not_contact,
|
|
4163
|
+
coalesce(max(interaction_count),0) AS interaction_count,
|
|
4164
|
+
coalesce(max(reciprocal),0) AS reciprocal,max(last_interaction_at) AS last_interaction_at,
|
|
4165
|
+
max(primary_email) AS primary_email,max(primary_phone) AS primary_phone,
|
|
4166
|
+
coalesce(max(sources_json),'[]') AS sources_json
|
|
4167
|
+
FROM summary_facts GROUP BY person_id
|
|
4168
|
+
),
|
|
4169
|
+
summaries AS (
|
|
4170
|
+
SELECT person.id,coalesce(person.display_name,'Unnamed contact') AS display_name,
|
|
4171
|
+
person.organization,person.title,contact.birthday,contact.do_not_contact,
|
|
4172
|
+
contact.interaction_count,contact.reciprocal,contact.last_interaction_at,
|
|
4173
|
+
contact.primary_email,contact.primary_phone,contact.sources_json
|
|
4174
|
+
FROM people person
|
|
4175
|
+
JOIN summary_rollup contact ON contact.person_id=person.id
|
|
4176
|
+
)
|
|
4177
|
+
`;
|
|
4178
|
+
function summary(row) {
|
|
4179
|
+
const sources = JSON.parse(row.sources_json);
|
|
4010
4180
|
return {
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4181
|
+
id: row.id,
|
|
4182
|
+
displayName: row.display_name,
|
|
4183
|
+
organization: row.organization,
|
|
4184
|
+
title: row.title,
|
|
4185
|
+
birthday: row.birthday,
|
|
4186
|
+
primaryEmail: row.primary_email,
|
|
4187
|
+
primaryPhone: row.primary_phone,
|
|
4188
|
+
interactionCount: row.interaction_count,
|
|
4189
|
+
reciprocal: row.reciprocal === 1,
|
|
4190
|
+
lastInteractionAt: row.last_interaction_at,
|
|
4191
|
+
doNotContact: row.do_not_contact === 1,
|
|
4192
|
+
sources: z4.array(z4.string().max(64)).parse(sources).sort((left, right) => Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")))
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4195
|
+
function filter(input) {
|
|
4196
|
+
const clauses = [];
|
|
4197
|
+
const bindings = [];
|
|
4198
|
+
const search = input.search.trim();
|
|
4199
|
+
if (search.length > 0) {
|
|
4200
|
+
const pattern = `%${search.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
4201
|
+
clauses.push(`contact.id IN (
|
|
4202
|
+
SELECT member.canonical_person_id FROM people alias
|
|
4203
|
+
JOIN person_identity_components member ON member.person_id=alias.id
|
|
4204
|
+
WHERE (
|
|
4205
|
+
coalesce(alias.display_name,'') LIKE ? ESCAPE '\\' COLLATE NOCASE OR
|
|
4206
|
+
coalesce(alias.organization,'') LIKE ? ESCAPE '\\' COLLATE NOCASE OR
|
|
4207
|
+
coalesce(alias.title,'') LIKE ? ESCAPE '\\' COLLATE NOCASE)
|
|
4208
|
+
UNION SELECT method.canonical_person_id FROM active_methods method
|
|
4209
|
+
WHERE method.value LIKE ? ESCAPE '\\' COLLATE NOCASE
|
|
4210
|
+
UNION SELECT resource.canonical_person_id FROM active_resources resource
|
|
4211
|
+
WHERE (coalesce(resource.username,'') LIKE ? ESCAPE '\\' COLLATE NOCASE OR
|
|
4212
|
+
coalesce(resource.profile_url,'') LIKE ? ESCAPE '\\' COLLATE NOCASE))`);
|
|
4213
|
+
bindings.push(pattern, pattern, pattern, pattern, pattern, pattern);
|
|
4214
|
+
}
|
|
4215
|
+
if (input.source !== undefined) {
|
|
4216
|
+
clauses.push("contact.id IN (SELECT person_id FROM source_values WHERE provider=?)");
|
|
4217
|
+
bindings.push(input.source);
|
|
4218
|
+
}
|
|
4219
|
+
if (input.doNotContact !== "all") {
|
|
4220
|
+
clauses.push("contact.do_not_contact=?");
|
|
4221
|
+
bindings.push(input.doNotContact === "only" ? 1 : 0);
|
|
4222
|
+
}
|
|
4223
|
+
if (input.hasEmail !== undefined)
|
|
4224
|
+
clauses.push(`contact.primary_email IS ${input.hasEmail ? "NOT " : ""}NULL`);
|
|
4225
|
+
if (input.hasPhone !== undefined)
|
|
4226
|
+
clauses.push(`contact.primary_phone IS ${input.hasPhone ? "NOT " : ""}NULL`);
|
|
4227
|
+
return { sql: clauses.length === 0 ? "" : `WHERE ${clauses.join(" AND ")}`, bindings };
|
|
4228
|
+
}
|
|
4229
|
+
function queryLocalContacts(database, value = {}) {
|
|
4230
|
+
const input = contactQueryInputSchema.parse(value);
|
|
4231
|
+
const where = filter(input);
|
|
4232
|
+
const sortColumns = {
|
|
4233
|
+
name: "display_name COLLATE NOCASE",
|
|
4234
|
+
organization: "organization COLLATE NOCASE",
|
|
4235
|
+
interactions: "interaction_count",
|
|
4236
|
+
"last-contact": "last_interaction_at"
|
|
4237
|
+
};
|
|
4238
|
+
const column = sortColumns[input.sort];
|
|
4239
|
+
const direction = input.direction === "asc" ? "ASC" : "DESC";
|
|
4240
|
+
return database.transaction(() => {
|
|
4241
|
+
const total = database.query(`WITH ${contactCtes}
|
|
4242
|
+
SELECT count(*) AS count FROM summaries contact ${where.sql}`).get(...where.bindings)?.count ?? 0;
|
|
4243
|
+
const rows = database.query(`WITH ${contactCtes}
|
|
4244
|
+
SELECT contact.* FROM summaries contact ${where.sql}
|
|
4245
|
+
ORDER BY ${column} ${direction},id ASC LIMIT ? OFFSET ?
|
|
4246
|
+
`).all(...where.bindings, input.limit, input.offset);
|
|
4247
|
+
const sources = database.query(`WITH ${contactCtes}
|
|
4248
|
+
SELECT provider AS source,count(*) AS count FROM source_values GROUP BY provider ORDER BY provider`).all();
|
|
4249
|
+
return {
|
|
4250
|
+
schemaVersion: "peopleblade.contact-query.v1",
|
|
4251
|
+
consistency: "live",
|
|
4252
|
+
items: rows.map(summary),
|
|
4253
|
+
total,
|
|
4254
|
+
limit: input.limit,
|
|
4255
|
+
offset: input.offset,
|
|
4256
|
+
nextOffset: input.offset + rows.length < total ? input.offset + rows.length : null,
|
|
4257
|
+
facets: { sources }
|
|
4258
|
+
};
|
|
4259
|
+
}).deferred();
|
|
4260
|
+
}
|
|
4261
|
+
function getLocalContactDetail(database, requestedPersonId) {
|
|
4262
|
+
const id = z4.number().int().positive().max(Number.MAX_SAFE_INTEGER).parse(requestedPersonId);
|
|
4263
|
+
return database.transaction(() => {
|
|
4264
|
+
const canonical = database.query("SELECT canonical_person_id FROM person_identity_components WHERE person_id=?").get(id)?.canonical_person_id;
|
|
4265
|
+
if (canonical === undefined)
|
|
4266
|
+
throw new Error("Contact not found.");
|
|
4267
|
+
const row = database.query(`WITH ${contactCtes}
|
|
4268
|
+
SELECT * FROM summaries WHERE id=?`).get(canonical);
|
|
4269
|
+
if (row === null)
|
|
4270
|
+
throw new Error("Contact not found.");
|
|
4271
|
+
const totals = database.query(`WITH ${contactCtes} SELECT
|
|
4272
|
+
(SELECT count(*) FROM person_identity_components WHERE canonical_person_id=?) AS members,
|
|
4273
|
+
(SELECT count(*) FROM active_methods WHERE canonical_person_id=?) AS methods,
|
|
4274
|
+
(SELECT count(*) FROM active_resources WHERE canonical_person_id=?) AS sources`).get(canonical, canonical, canonical);
|
|
4275
|
+
const methods = database.query(`WITH ${contactCtes} SELECT id,kind,value,label,is_primary,identity_eligible,confidence
|
|
4276
|
+
FROM active_methods WHERE canonical_person_id=? ORDER BY is_primary DESC,kind,id LIMIT 100`).all(canonical);
|
|
4277
|
+
const sources = database.query(`WITH ${contactCtes} SELECT provider,service,resource_type,profile_url,profile_url_identity_eligible
|
|
4278
|
+
FROM active_resources WHERE canonical_person_id=? ORDER BY provider,id LIMIT 100`).all(canonical);
|
|
4279
|
+
return {
|
|
4280
|
+
schemaVersion: "peopleblade.contact-detail.v1",
|
|
4281
|
+
consistency: "live",
|
|
4282
|
+
requestedPersonId: id,
|
|
4283
|
+
canonicalPersonId: canonical,
|
|
4284
|
+
contact: summary(row),
|
|
4285
|
+
memberCount: totals.members,
|
|
4286
|
+
methods: { total: totals.methods, truncated: totals.methods > methods.length, items: methods.map((method) => ({
|
|
4287
|
+
id: method.id,
|
|
4288
|
+
kind: method.kind,
|
|
4289
|
+
value: method.value,
|
|
4290
|
+
label: method.label,
|
|
4291
|
+
primary: method.is_primary === 1,
|
|
4292
|
+
identityEligible: method.identity_eligible === 1 && method.confidence === "exact",
|
|
4293
|
+
confidence: method.confidence
|
|
4294
|
+
})) },
|
|
4295
|
+
sources: { total: totals.sources, truncated: totals.sources > sources.length, items: sources.map((source) => ({
|
|
4296
|
+
provider: source.provider,
|
|
4297
|
+
service: source.service,
|
|
4298
|
+
resourceType: source.resource_type,
|
|
4299
|
+
profileUrl: source.profile_url,
|
|
4300
|
+
identityEligible: source.profile_url_identity_eligible === 1
|
|
4301
|
+
})) }
|
|
4302
|
+
};
|
|
4303
|
+
}).deferred();
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
// src/local/capabilities.ts
|
|
4307
|
+
var peoplebladeCapabilities = Object.freeze({
|
|
4308
|
+
schemaVersion: "peopleblade.capabilities.v1",
|
|
4309
|
+
availability: "declarative-not-configured",
|
|
4310
|
+
localAuthority: true,
|
|
4311
|
+
discoveryPerformsNetworkRequests: false,
|
|
4312
|
+
sources: [
|
|
4313
|
+
{ id: "apple-contacts", command: "contacts sync", status: "supported", transport: "local-database", requirement: "macOS Contacts access" },
|
|
4314
|
+
{ id: "imessage", command: "imessage sync", status: "supported", transport: "local-metadata", requirement: "macOS Messages access; no message bodies" },
|
|
4315
|
+
{ id: "google-contacts", command: "google sync", status: "supported", transport: "wrench", requirement: "Reviewed Gmail/People contacts.list and contact scopes" },
|
|
4316
|
+
{ id: "beeper", command: "beeper sync", status: "supported", transport: "wrench", requirement: "Wrench 0.16.8, Beeper adapter 2.4.0, CLI 0.6.2; body-free relationships require macOS arm64" },
|
|
4317
|
+
{ id: "whatsapp", command: "whatsapp sync", status: "supported", transport: "wrench", requirement: "Reviewed linked-device contacts.list v2" },
|
|
4318
|
+
{ id: "linkedin", command: "linkedin import", status: "supported", transport: "official-archive", requirement: "Owner-selected official ZIP; no authenticated browser collection" },
|
|
4319
|
+
{ id: "instagram", command: "instagram import", status: "supported", transport: "official-archive", requirement: "Owner-selected JSON ZIP and exact owner username" },
|
|
4320
|
+
{ id: "x-archive", command: "x import", status: "bounded", transport: "official-archive", requirement: "Owner-selected ZIP; unresolved IDs remain inert" },
|
|
4321
|
+
{ id: "x-live", command: "x mutuals sync", status: "blocked", transport: "unavailable", requirement: "A reviewed bounded Wrench mutuals capability is not yet available" },
|
|
4322
|
+
{ id: "telegram", command: "telegram status", status: "planned", transport: "unavailable", requirement: "First real export and importer validation remain pending" }
|
|
4323
|
+
],
|
|
4324
|
+
enrichment: [
|
|
4325
|
+
{ id: "local-anchors", priority: 1, status: "supported", commands: ["identity audit", "identity suggest", "identity decide"], paid: false, boundary: "Exact source evidence first; joins require reversible evidence-bound decisions" },
|
|
4326
|
+
{ id: "manual-public-research", priority: 2, status: "supported", commands: ["research prepare", "research apply"], paid: false, boundary: "Agent or human supplies cited public evidence; PeopleBlade performs no provider calls" },
|
|
4327
|
+
{ id: "cloud-professional-research", priority: 3, status: "optional", commands: ["cloud enrich --person-id ID", "cloud enrich --confirm TOKEN"], paid: true, boundary: "Exa exact profiles or bounded search, then cited model extraction; device-bound preview approval required" },
|
|
4328
|
+
{ id: "cloud-email-fallback", priority: 4, status: "optional", commands: ["cloud enrich --person-id ID"], paid: true, boundary: "Hunter is a separately configured narrow fallback, not a substitute for identity evidence" }
|
|
4329
|
+
],
|
|
4330
|
+
interfaces: {
|
|
4331
|
+
workspace: { command: "ui [--port N]", availability: "included-in-this-build", binding: "127.0.0.1", defaultPort: "random", authentication: "per-process capability file", remoteAssets: false, noteUpload: false },
|
|
4332
|
+
query: { command: "query", schemaVersion: "peopleblade.contact-query.v1", consistency: "live", defaultLimit: 50, maximumLimit: 100, pagination: "offset", sorts: ["name", "organization", "interactions", "last-contact"] },
|
|
4333
|
+
detail: { command: "people show ID", schemaVersion: "peopleblade.contact-detail.v1", maximumMethods: 100, maximumSources: 100 },
|
|
4334
|
+
notes: { commands: ["notes add", "notes show ID", "notes update ID", "notes history ID", "notes list", "notes search"], cloudSync: false, edits: "append-only-compare-and-swap" },
|
|
4335
|
+
research: { schemaVersion: "peopleblade.research.v1", commands: ["research prepare ID", "research apply FILE"], legacyInputAccepted: true },
|
|
4336
|
+
errors: { schemaVersion: "peopleblade.error.v1", stream: "stderr", enabledBy: "--json" }
|
|
4337
|
+
},
|
|
4338
|
+
boundaries: [
|
|
4339
|
+
"Supported does not mean configured or authorized; discovery does not inspect private configuration.",
|
|
4340
|
+
"Notes, archives, credentials, provider payloads, and message bodies are not cloud-sync inputs.",
|
|
4341
|
+
"Provider availability never authorizes contact, outreach, account automation, or a paid request.",
|
|
4342
|
+
"Query pagination is a live view; concurrent imports and identity decisions can move rows between pages."
|
|
4343
|
+
]
|
|
4344
|
+
});
|
|
4345
|
+
|
|
4346
|
+
// src/local/workspace.ts
|
|
4347
|
+
import { randomBytes } from "crypto";
|
|
4348
|
+
import { chmodSync, lstatSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "fs";
|
|
4349
|
+
import { join } from "path";
|
|
4350
|
+
import { tmpdir } from "os";
|
|
4351
|
+
import { z as z5 } from "zod";
|
|
4352
|
+
|
|
4353
|
+
// src/local/markdown.ts
|
|
4354
|
+
function previewNoteMarkdown(source) {
|
|
4355
|
+
if (Buffer.byteLength(source, "utf8") > 65536)
|
|
4356
|
+
throw new Error("Markdown exceeds 64 KiB.");
|
|
4357
|
+
return Bun.markdown.render(source, {
|
|
4358
|
+
text: (text) => Bun.escapeHTML(text),
|
|
4359
|
+
html: (text) => Bun.escapeHTML(text),
|
|
4360
|
+
paragraph: (children) => `<p>${children}</p>`,
|
|
4361
|
+
heading: (children, { level }) => `<h${level}>${children}</h${level}>`,
|
|
4362
|
+
strong: (children) => `<strong>${children}</strong>`,
|
|
4363
|
+
emphasis: (children) => `<em>${children}</em>`,
|
|
4364
|
+
strikethrough: (children) => `<del>${children}</del>`,
|
|
4365
|
+
codespan: (children) => `<code>${children}</code>`,
|
|
4366
|
+
code: (children) => `<pre><code>${children}</code></pre>`,
|
|
4367
|
+
blockquote: (children) => `<blockquote>${children}</blockquote>`,
|
|
4368
|
+
list: (children, meta) => meta.ordered ? `<ol>${children}</ol>` : `<ul>${children}</ul>`,
|
|
4369
|
+
listItem: (children, meta) => `<li>${meta.checked === undefined ? "" : meta.checked ? "\u2611 " : "\u2610 "}${children}</li>`,
|
|
4370
|
+
hr: () => "<hr>",
|
|
4371
|
+
table: (children) => `<table>${children}</table>`,
|
|
4372
|
+
thead: (children) => `<thead>${children}</thead>`,
|
|
4373
|
+
tbody: (children) => `<tbody>${children}</tbody>`,
|
|
4374
|
+
tr: (children) => `<tr>${children}</tr>`,
|
|
4375
|
+
th: (children) => `<th>${children}</th>`,
|
|
4376
|
+
td: (children) => `<td>${children}</td>`,
|
|
4377
|
+
image: (children) => `<span>[Image omitted${children ? `: ${children}` : ""}]</span>`,
|
|
4378
|
+
link: (children, { href }) => {
|
|
4379
|
+
try {
|
|
4380
|
+
const url = new URL(href);
|
|
4381
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
4382
|
+
return children;
|
|
4383
|
+
return `<a href="${Bun.escapeHTML(url.href)}" target="_blank" rel="noopener noreferrer">${children}</a>`;
|
|
4384
|
+
} catch {
|
|
4385
|
+
return children;
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
}, { tables: true, strikethrough: true, tasklists: true, noHtmlBlocks: true, noHtmlSpans: true, autolinks: false });
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
// src/local/note-revisions.ts
|
|
4392
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
4393
|
+
var SHA256 = /^[0-9a-f]{64}$/u;
|
|
4394
|
+
var UNSUPPORTED_CONTROLS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u;
|
|
4395
|
+
|
|
4396
|
+
class NoteRevisionError extends Error {
|
|
4397
|
+
code;
|
|
4398
|
+
constructor(code, message) {
|
|
4399
|
+
super(message);
|
|
4400
|
+
this.name = "NoteRevisionError";
|
|
4401
|
+
this.code = code;
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
function invalid(message) {
|
|
4405
|
+
throw new NoteRevisionError("invalid_input", message);
|
|
4406
|
+
}
|
|
4407
|
+
function positiveInteger(value, label) {
|
|
4408
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
4409
|
+
invalid(`${label} must be a positive safe integer.`);
|
|
4410
|
+
}
|
|
4411
|
+
function validatePersonNoteMutationRequest(requestId, expectedContextSha256) {
|
|
4412
|
+
if (typeof requestId !== "string" || !UUID.test(requestId))
|
|
4413
|
+
invalid("Request ID must be a canonical lowercase UUID.");
|
|
4414
|
+
if (typeof expectedContextSha256 !== "string" || !SHA256.test(expectedContextSha256))
|
|
4415
|
+
invalid("Expected context must be a SHA-256 digest.");
|
|
4416
|
+
}
|
|
4417
|
+
function personNoteCreationContext(database, personId) {
|
|
4418
|
+
positiveInteger(personId, "Person ID");
|
|
4419
|
+
const row = database.query(`
|
|
4420
|
+
WITH components AS MATERIALIZED (SELECT * FROM person_identity_components)
|
|
4421
|
+
SELECT target.canonical_person_id,
|
|
4422
|
+
(SELECT instance_id FROM local_state WHERE singleton=1) AS database_instance_id,
|
|
4423
|
+
(SELECT identity_revision FROM local_revisions WHERE singleton=1) AS identity_revision,
|
|
4424
|
+
(SELECT json_group_array(person_id) FROM (
|
|
4425
|
+
SELECT person_id FROM components WHERE canonical_person_id=target.canonical_person_id ORDER BY person_id
|
|
4426
|
+
)) AS component_person_ids
|
|
4427
|
+
FROM components target WHERE target.person_id=?
|
|
4428
|
+
`).get(personId);
|
|
4429
|
+
if (row === null)
|
|
4430
|
+
throw new NoteRevisionError("not_found", "Person not found.");
|
|
4431
|
+
const members = JSON.parse(row.component_person_ids);
|
|
4432
|
+
if (!Array.isArray(members) || !members.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0)) {
|
|
4433
|
+
throw new Error("Stored note person context is malformed.");
|
|
4434
|
+
}
|
|
4435
|
+
const canonicalPersonId = Number(row.canonical_person_id);
|
|
4436
|
+
const contextJson = canonicalJson({
|
|
4437
|
+
schemaVersion: 1,
|
|
4438
|
+
databaseInstanceId: row.database_instance_id,
|
|
4439
|
+
personId,
|
|
4440
|
+
canonicalPersonId,
|
|
4441
|
+
componentPersonIds: members,
|
|
4442
|
+
identityRevision: Number(row.identity_revision)
|
|
4443
|
+
});
|
|
4444
|
+
return { personId, canonicalPersonId, contextJson, contextSha256: sha256(contextJson) };
|
|
4445
|
+
}
|
|
4446
|
+
function validatePersonNoteContent(title, body) {
|
|
4447
|
+
if (typeof body !== "string" || body.trim().length === 0)
|
|
4448
|
+
invalid("Note body must not be empty.");
|
|
4449
|
+
if (Buffer.byteLength(body, "utf8") > 65536)
|
|
4450
|
+
invalid("Note body exceeds 64 KiB.");
|
|
4451
|
+
if (UNSUPPORTED_CONTROLS.test(body) || Buffer.from(body, "utf8").toString("utf8") !== body) {
|
|
4452
|
+
invalid("Note body contains unsupported control characters or malformed Unicode.");
|
|
4453
|
+
}
|
|
4454
|
+
if (title !== null && (typeof title !== "string" || title.trim().length === 0 || [...title].length > 1024 || UNSUPPORTED_CONTROLS.test(title) || /[\r\n]/u.test(title) || Buffer.from(title, "utf8").toString("utf8") !== title)) {
|
|
4455
|
+
invalid("Note title must be null or nonblank text of at most 1024 characters.");
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4458
|
+
function personNoteContexts(database, noteIds) {
|
|
4459
|
+
if (noteIds.length > 1001)
|
|
4460
|
+
invalid("Note context request exceeds its bounded page limit.");
|
|
4461
|
+
for (const noteId of noteIds)
|
|
4462
|
+
positiveInteger(noteId, "Note ID");
|
|
4463
|
+
if (noteIds.length === 0)
|
|
4464
|
+
return new Map;
|
|
4465
|
+
const rows = database.query(`
|
|
4466
|
+
WITH components AS MATERIALIZED (SELECT * FROM person_identity_components)
|
|
4467
|
+
SELECT note.id,note.revision,original.person_id AS original_person_id,
|
|
4468
|
+
(SELECT instance_id FROM local_state WHERE singleton=1) AS database_instance_id,
|
|
4469
|
+
note.person_id AS attributed_person_id,
|
|
4470
|
+
coalesce((SELECT max(id) FROM person_note_attribution_revisions WHERE note_id=note.id),0) AS attribution_revision_id,
|
|
4471
|
+
(SELECT identity_revision FROM local_revisions WHERE singleton=1) AS identity_revision,
|
|
4472
|
+
target.canonical_person_id,
|
|
4473
|
+
(SELECT json_group_array(person_id) FROM (
|
|
4474
|
+
SELECT member.person_id FROM components member
|
|
4475
|
+
WHERE member.canonical_person_id=target.canonical_person_id ORDER BY member.person_id
|
|
4476
|
+
)) AS component_person_ids
|
|
4477
|
+
FROM person_notes_effective note JOIN person_notes original ON original.id=note.id
|
|
4478
|
+
JOIN components target ON target.person_id=note.person_id
|
|
4479
|
+
WHERE note.id IN (${noteIds.map(() => "?").join(",")})
|
|
4480
|
+
`).all(...noteIds);
|
|
4481
|
+
return new Map(rows.map((row) => {
|
|
4482
|
+
const members = JSON.parse(row.component_person_ids);
|
|
4483
|
+
if (!Array.isArray(members) || !members.every((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0)) {
|
|
4484
|
+
throw new Error("Stored note component context is malformed.");
|
|
4485
|
+
}
|
|
4486
|
+
const contextJson = canonicalJson({
|
|
4487
|
+
schemaVersion: 1,
|
|
4488
|
+
databaseInstanceId: row.database_instance_id,
|
|
4489
|
+
noteId: Number(row.id),
|
|
4490
|
+
revision: Number(row.revision),
|
|
4491
|
+
originalPersonId: Number(row.original_person_id),
|
|
4492
|
+
attributedPersonId: Number(row.attributed_person_id),
|
|
4493
|
+
attributionRevisionId: Number(row.attribution_revision_id),
|
|
4494
|
+
identityRevision: Number(row.identity_revision),
|
|
4495
|
+
canonicalPersonId: Number(row.canonical_person_id),
|
|
4496
|
+
componentPersonIds: members
|
|
4497
|
+
});
|
|
4498
|
+
return [Number(row.id), { revision: Number(row.revision), contextJson, contextSha256: sha256(contextJson) }];
|
|
4499
|
+
}));
|
|
4500
|
+
}
|
|
4501
|
+
function mapRevision(row) {
|
|
4502
|
+
return {
|
|
4503
|
+
noteId: Number(row.note_id),
|
|
4504
|
+
revision: Number(row.revision),
|
|
4505
|
+
expectedRevision: row.expected_revision === null ? null : Number(row.expected_revision),
|
|
4506
|
+
expectedContextSha256: row.expected_context_sha256,
|
|
4507
|
+
requestId: row.request_id,
|
|
4508
|
+
provenance: Number(row.revision) === 0 ? "original" : "user-edit",
|
|
4509
|
+
title: row.title,
|
|
4510
|
+
body: row.body,
|
|
4511
|
+
createdAt: row.created_at
|
|
4028
4512
|
};
|
|
4029
4513
|
}
|
|
4514
|
+
function revisePersonNote(database, input) {
|
|
4515
|
+
positiveInteger(input.noteId, "Note ID");
|
|
4516
|
+
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0 || input.expectedRevision >= Number.MAX_SAFE_INTEGER)
|
|
4517
|
+
invalid("Expected revision must be a nonnegative safe integer below the revision ceiling.");
|
|
4518
|
+
validatePersonNoteMutationRequest(input.requestId, input.expectedContextSha256);
|
|
4519
|
+
validatePersonNoteContent(input.title, input.body);
|
|
4520
|
+
database.exec("BEGIN IMMEDIATE");
|
|
4521
|
+
try {
|
|
4522
|
+
const prior = database.query("SELECT * FROM person_note_content_revisions WHERE request_id=?").get(input.requestId);
|
|
4523
|
+
if (prior !== null) {
|
|
4524
|
+
if (Number(prior.note_id) !== input.noteId || Number(prior.expected_revision) !== input.expectedRevision || prior.expected_context_sha256 !== input.expectedContextSha256 || prior.title !== input.title || prior.body !== input.body) {
|
|
4525
|
+
throw new NoteRevisionError("request_conflict", "Request ID already belongs to a different note edit. Use its original payload or a new request ID.");
|
|
4526
|
+
}
|
|
4527
|
+
database.exec("COMMIT");
|
|
4528
|
+
return { kind: "replayed", revision: mapRevision(prior) };
|
|
4529
|
+
}
|
|
4530
|
+
const context = personNoteContexts(database, [input.noteId]).get(input.noteId);
|
|
4531
|
+
if (context === undefined)
|
|
4532
|
+
throw new NoteRevisionError("not_found", "Note not found.");
|
|
4533
|
+
if (context.revision !== input.expectedRevision) {
|
|
4534
|
+
throw new NoteRevisionError("revision_conflict", "Note content changed. Reload the note and review your draft before saving again.");
|
|
4535
|
+
}
|
|
4536
|
+
if (context.contextSha256 !== input.expectedContextSha256) {
|
|
4537
|
+
throw new NoteRevisionError("context_conflict", "Note attribution or identity context changed. Reload the note and review its owner before saving again.");
|
|
4538
|
+
}
|
|
4539
|
+
const revision = input.expectedRevision + 1;
|
|
4540
|
+
database.query(`INSERT INTO person_note_content_revisions(
|
|
4541
|
+
note_id,revision,expected_revision,request_id,expected_context_sha256,context_json,title,body
|
|
4542
|
+
) VALUES (?,?,?,?,?,?,?,?)`).run(input.noteId, revision, input.expectedRevision, input.requestId, input.expectedContextSha256, context.contextJson, input.title, input.body);
|
|
4543
|
+
const inserted = database.query("SELECT * FROM person_note_content_revisions WHERE note_id=? AND revision=?").get(input.noteId, revision);
|
|
4544
|
+
if (inserted === null)
|
|
4545
|
+
throw new Error("Note revision was not recorded.");
|
|
4546
|
+
database.exec("COMMIT");
|
|
4547
|
+
return { kind: "created", revision: mapRevision(inserted) };
|
|
4548
|
+
} catch (error) {
|
|
4549
|
+
database.exec("ROLLBACK");
|
|
4550
|
+
throw error;
|
|
4551
|
+
}
|
|
4552
|
+
}
|
|
4553
|
+
function listPersonNoteRevisions(database, input) {
|
|
4554
|
+
positiveInteger(input.noteId, "Note ID");
|
|
4555
|
+
const limit = input.limit ?? 50;
|
|
4556
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
4557
|
+
invalid("Note history limit must be between 1 and 1000.");
|
|
4558
|
+
if (input.beforeRevision !== undefined && (!Number.isSafeInteger(input.beforeRevision) || input.beforeRevision < 1)) {
|
|
4559
|
+
invalid("Before revision must be a positive safe integer.");
|
|
4560
|
+
}
|
|
4561
|
+
return database.transaction(() => {
|
|
4562
|
+
const original = database.query("SELECT id FROM person_notes WHERE id=?").get(input.noteId);
|
|
4563
|
+
if (original === null)
|
|
4564
|
+
throw new NoteRevisionError("not_found", "Note not found.");
|
|
4565
|
+
const current = database.query("SELECT coalesce(max(revision),0) AS revision FROM person_note_content_revisions WHERE note_id=?").get(input.noteId);
|
|
4566
|
+
const before = input.beforeRevision ?? null;
|
|
4567
|
+
const rows = database.query(`
|
|
4568
|
+
SELECT note_id,revision,expected_revision,expected_context_sha256,request_id,title,body,created_at
|
|
4569
|
+
FROM person_note_content_revisions WHERE note_id=? AND (? IS NULL OR revision<?)
|
|
4570
|
+
UNION ALL SELECT id,0,NULL,NULL,NULL,title,body,created_at FROM person_notes WHERE id=?
|
|
4571
|
+
ORDER BY revision DESC LIMIT ?
|
|
4572
|
+
`).all(input.noteId, before, before, input.noteId, limit + 1);
|
|
4573
|
+
const items = rows.slice(0, limit).map(mapRevision);
|
|
4574
|
+
return {
|
|
4575
|
+
items,
|
|
4576
|
+
currentRevision: Number(current.revision),
|
|
4577
|
+
nextBeforeRevision: rows.length > limit ? items.at(-1).revision : null
|
|
4578
|
+
};
|
|
4579
|
+
}).deferred();
|
|
4580
|
+
}
|
|
4030
4581
|
|
|
4031
4582
|
// src/local/notes.ts
|
|
4032
4583
|
import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
|
|
@@ -4896,24 +5447,6 @@ function parseLimit(value) {
|
|
|
4896
5447
|
}
|
|
4897
5448
|
return limit;
|
|
4898
5449
|
}
|
|
4899
|
-
function boundedBody(value, label) {
|
|
4900
|
-
const body = value.normalize("NFKC").trim();
|
|
4901
|
-
if (body.length === 0)
|
|
4902
|
-
fail(`${label} must not be empty.`);
|
|
4903
|
-
if (/[\u0000\r]/u.test(body))
|
|
4904
|
-
fail(`${label} contains an unsupported control character.`);
|
|
4905
|
-
if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES)
|
|
4906
|
-
fail(`${label} exceeds 64 KiB.`);
|
|
4907
|
-
if (body.length > MAX_BODY_BYTES)
|
|
4908
|
-
fail(`${label} exceeds the stored character limit.`);
|
|
4909
|
-
return body;
|
|
4910
|
-
}
|
|
4911
|
-
function boundedTitle(value) {
|
|
4912
|
-
if (value === undefined)
|
|
4913
|
-
return null;
|
|
4914
|
-
const title = cleanText(value, MAX_TITLE_CHARS);
|
|
4915
|
-
return title;
|
|
4916
|
-
}
|
|
4917
5450
|
function metadataObject(value) {
|
|
4918
5451
|
if (value === undefined)
|
|
4919
5452
|
return {};
|
|
@@ -4931,7 +5464,7 @@ function canonicalPersonId2(database, personId) {
|
|
|
4931
5464
|
fail("Person not found.");
|
|
4932
5465
|
return Number(row.canonical_person_id);
|
|
4933
5466
|
}
|
|
4934
|
-
function mapNote(row) {
|
|
5467
|
+
function mapNote(row, context) {
|
|
4935
5468
|
let metadata = {};
|
|
4936
5469
|
try {
|
|
4937
5470
|
const parsed = JSON.parse(row.metadata_json);
|
|
@@ -4950,9 +5483,90 @@ function mapNote(row) {
|
|
|
4950
5483
|
title: row.title === null ? null : String(row.title),
|
|
4951
5484
|
body: String(row.body),
|
|
4952
5485
|
metadata,
|
|
4953
|
-
createdAt: String(row.created_at)
|
|
5486
|
+
createdAt: String(row.created_at),
|
|
5487
|
+
revision: context.revision,
|
|
5488
|
+
revisedAt: row.revised_at ?? null,
|
|
5489
|
+
contextSha256: context.contextSha256
|
|
4954
5490
|
};
|
|
4955
5491
|
}
|
|
5492
|
+
function mapEffectiveNotes(database, rows) {
|
|
5493
|
+
const contexts = personNoteContexts(database, rows.map((row) => Number(row.id)));
|
|
5494
|
+
return rows.map((row) => {
|
|
5495
|
+
const context = contexts.get(Number(row.id));
|
|
5496
|
+
if (context === undefined || context.revision !== Number(row.revision ?? 0)) {
|
|
5497
|
+
throw new Error("Stored note content and identity context disagree.");
|
|
5498
|
+
}
|
|
5499
|
+
return mapNote(row, context);
|
|
5500
|
+
});
|
|
5501
|
+
}
|
|
5502
|
+
function getPersonNote(database, noteId) {
|
|
5503
|
+
parsePersonId(noteId, "Note ID");
|
|
5504
|
+
return database.transaction(() => {
|
|
5505
|
+
const row = database.query("SELECT * FROM person_notes_effective WHERE id=?").get(noteId);
|
|
5506
|
+
if (row === null)
|
|
5507
|
+
throw new NoteRevisionError("not_found", "Note not found.");
|
|
5508
|
+
return mapEffectiveNotes(database, [row])[0];
|
|
5509
|
+
}).deferred();
|
|
5510
|
+
}
|
|
5511
|
+
function personNoteContext(database, personId) {
|
|
5512
|
+
const context = personNoteCreationContext(database, personId);
|
|
5513
|
+
return { personId: context.personId, canonicalPersonId: context.canonicalPersonId, contextSha256: context.contextSha256 };
|
|
5514
|
+
}
|
|
5515
|
+
function workspaceOccurredAt(value) {
|
|
5516
|
+
if (typeof value !== "string" || value.length > 80) {
|
|
5517
|
+
throw new NoteRevisionError("invalid_input", "Occurred-at must be an ISO date or canonical UTC datetime.");
|
|
5518
|
+
}
|
|
5519
|
+
const date = new Date(DATE_ONLY.test(value) ? `${value}T00:00:00.000Z` : value);
|
|
5520
|
+
if (!Number.isFinite(date.valueOf()) || (DATE_ONLY.test(value) ? date.toISOString().slice(0, 10) !== value : date.toISOString() !== value)) {
|
|
5521
|
+
throw new NoteRevisionError("invalid_input", "Occurred-at must be an ISO date or canonical UTC datetime.");
|
|
5522
|
+
}
|
|
5523
|
+
return date.toISOString();
|
|
5524
|
+
}
|
|
5525
|
+
function createWorkspaceNote(database, input) {
|
|
5526
|
+
if (!Number.isSafeInteger(input.personId) || input.personId < 1) {
|
|
5527
|
+
throw new NoteRevisionError("invalid_input", "Person ID must be a positive safe integer.");
|
|
5528
|
+
}
|
|
5529
|
+
validatePersonNoteMutationRequest(input.requestId, input.expectedPersonContextSha256);
|
|
5530
|
+
validatePersonNoteContent(input.title, input.body);
|
|
5531
|
+
const occurredAt = workspaceOccurredAt(input.occurredAt);
|
|
5532
|
+
database.exec("BEGIN IMMEDIATE");
|
|
5533
|
+
try {
|
|
5534
|
+
const prior = database.query(`SELECT receipt.note_id,receipt.requested_person_id,
|
|
5535
|
+
receipt.expected_context_sha256,receipt.occurred_at_input,note.title,note.body
|
|
5536
|
+
FROM person_note_creation_requests receipt JOIN person_notes note ON note.id=receipt.note_id
|
|
5537
|
+
WHERE receipt.request_id=?`).get(input.requestId);
|
|
5538
|
+
if (prior !== null) {
|
|
5539
|
+
if (Number(prior.requested_person_id) !== input.personId || prior.expected_context_sha256 !== input.expectedPersonContextSha256 || prior.occurred_at_input !== input.occurredAt || prior.title !== input.title || prior.body !== input.body) {
|
|
5540
|
+
throw new NoteRevisionError("request_conflict", "Request ID already belongs to a different note creation.");
|
|
5541
|
+
}
|
|
5542
|
+
database.exec("COMMIT");
|
|
5543
|
+
return { kind: "replayed", noteId: Number(prior.note_id) };
|
|
5544
|
+
}
|
|
5545
|
+
const context = personNoteCreationContext(database, input.personId);
|
|
5546
|
+
if (context.contextSha256 !== input.expectedPersonContextSha256) {
|
|
5547
|
+
throw new NoteRevisionError("context_conflict", "Person identity context changed. Reload and review the person before creating a note.");
|
|
5548
|
+
}
|
|
5549
|
+
const inserted = insertNote(database, {
|
|
5550
|
+
personId: context.canonicalPersonId,
|
|
5551
|
+
occurredAt,
|
|
5552
|
+
source: "manual",
|
|
5553
|
+
sourceId: input.requestId,
|
|
5554
|
+
title: input.title,
|
|
5555
|
+
body: input.body,
|
|
5556
|
+
metadata: {}
|
|
5557
|
+
});
|
|
5558
|
+
if (!inserted.inserted)
|
|
5559
|
+
throw new NoteRevisionError("request_conflict", "A source note already uses this request ID without a workspace creation receipt.");
|
|
5560
|
+
database.query(`INSERT INTO person_note_creation_requests(
|
|
5561
|
+
request_id,note_id,requested_person_id,expected_context_sha256,context_json,occurred_at_input
|
|
5562
|
+
) VALUES (?,?,?,?,?,?)`).run(input.requestId, inserted.id, input.personId, input.expectedPersonContextSha256, context.contextJson, input.occurredAt);
|
|
5563
|
+
database.exec("COMMIT");
|
|
5564
|
+
return { kind: "created", noteId: inserted.id };
|
|
5565
|
+
} catch (error) {
|
|
5566
|
+
database.exec("ROLLBACK");
|
|
5567
|
+
throw error;
|
|
5568
|
+
}
|
|
5569
|
+
}
|
|
4956
5570
|
function storedGranolaAttendeeIdentity(metadataJson) {
|
|
4957
5571
|
let metadata;
|
|
4958
5572
|
try {
|
|
@@ -5179,8 +5793,9 @@ function addPersonNote(database, args) {
|
|
|
5179
5793
|
const occurredAt = parseOccurredAt(args.occurredAt, "occurred-at");
|
|
5180
5794
|
const source = parseSource(args.source);
|
|
5181
5795
|
const sourceId = args.sourceId === undefined ? null : cleanText(args.sourceId, 256);
|
|
5182
|
-
const title =
|
|
5183
|
-
const body =
|
|
5796
|
+
const title = args.title ?? null;
|
|
5797
|
+
const body = args.body;
|
|
5798
|
+
validatePersonNoteContent(title, body);
|
|
5184
5799
|
const metadata = metadataObject(args.metadata);
|
|
5185
5800
|
database.exec("BEGIN IMMEDIATE");
|
|
5186
5801
|
try {
|
|
@@ -5196,9 +5811,10 @@ function addPersonNote(database, args) {
|
|
|
5196
5811
|
});
|
|
5197
5812
|
if (!inserted.inserted)
|
|
5198
5813
|
fail("A note for this source identity already exists.");
|
|
5199
|
-
const row = database.query("SELECT * FROM
|
|
5814
|
+
const row = database.query("SELECT * FROM person_notes_effective WHERE id=?").get(inserted.id);
|
|
5815
|
+
const note = mapEffectiveNotes(database, [row])[0];
|
|
5200
5816
|
database.exec("COMMIT");
|
|
5201
|
-
return
|
|
5817
|
+
return note;
|
|
5202
5818
|
} catch (error) {
|
|
5203
5819
|
database.exec("ROLLBACK");
|
|
5204
5820
|
throw error;
|
|
@@ -5235,16 +5851,18 @@ function noteFiltersSql(args) {
|
|
|
5235
5851
|
};
|
|
5236
5852
|
}
|
|
5237
5853
|
function listPersonNotes(database, args) {
|
|
5238
|
-
canonicalPersonId2(database, parsePersonId(args.personId));
|
|
5239
5854
|
const filters = noteFiltersSql(args);
|
|
5240
5855
|
const limit = parseLimit(args.limit);
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
|
|
5247
|
-
|
|
5856
|
+
return database.transaction(() => {
|
|
5857
|
+
canonicalPersonId2(database, parsePersonId(args.personId));
|
|
5858
|
+
const rows = database.query(`
|
|
5859
|
+
SELECT note.* FROM person_notes_effective note
|
|
5860
|
+
${filters.sql}
|
|
5861
|
+
ORDER BY note.occurred_at DESC, note.id DESC
|
|
5862
|
+
LIMIT ?
|
|
5863
|
+
`).all(...filters.bindings, limit);
|
|
5864
|
+
return mapEffectiveNotes(database, rows);
|
|
5865
|
+
}).deferred();
|
|
5248
5866
|
}
|
|
5249
5867
|
function ftsMatch(query) {
|
|
5250
5868
|
const tokens = query.normalize("NFKC").trim().slice(0, MAX_QUERY_CHARS).split(/[^\p{L}\p{N}]+/u).map((token) => token.toLocaleLowerCase("en-US")).filter((token) => token.length > 0);
|
|
@@ -5256,8 +5874,6 @@ function searchPersonNotes(database, args) {
|
|
|
5256
5874
|
if (typeof args.query !== "string" || args.query.trim().length === 0) {
|
|
5257
5875
|
fail("Search query is required.");
|
|
5258
5876
|
}
|
|
5259
|
-
if (args.personId !== undefined)
|
|
5260
|
-
canonicalPersonId2(database, parsePersonId(args.personId));
|
|
5261
5877
|
const match = ftsMatch(args.query);
|
|
5262
5878
|
const filters = noteFiltersSql({
|
|
5263
5879
|
...args.personId === undefined ? {} : { personId: args.personId },
|
|
@@ -5266,15 +5882,19 @@ function searchPersonNotes(database, args) {
|
|
|
5266
5882
|
...args.source === undefined ? {} : { source: args.source }
|
|
5267
5883
|
});
|
|
5268
5884
|
const limit = parseLimit(args.limit);
|
|
5269
|
-
const where = filters.sql.length === 0 ? "WHERE
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5885
|
+
const where = filters.sql.length === 0 ? "WHERE person_notes_effective_fts MATCH ?" : `${filters.sql} AND person_notes_effective_fts MATCH ?`;
|
|
5886
|
+
return database.transaction(() => {
|
|
5887
|
+
if (args.personId !== undefined)
|
|
5888
|
+
canonicalPersonId2(database, parsePersonId(args.personId));
|
|
5889
|
+
const rows = database.query(`
|
|
5890
|
+
SELECT note.* FROM person_notes_effective_fts
|
|
5891
|
+
JOIN person_notes_effective note ON note.id=person_notes_effective_fts.rowid
|
|
5892
|
+
${where}
|
|
5893
|
+
ORDER BY note.occurred_at DESC, note.id DESC
|
|
5894
|
+
LIMIT ?
|
|
5895
|
+
`).all(...filters.bindings, match, limit);
|
|
5896
|
+
return mapEffectiveNotes(database, rows);
|
|
5897
|
+
}).deferred();
|
|
5278
5898
|
}
|
|
5279
5899
|
function recordValue(record, ...keys) {
|
|
5280
5900
|
for (const key of keys) {
|
|
@@ -5360,8 +5980,8 @@ function firstCleanText(record, keys, maximum) {
|
|
|
5360
5980
|
}
|
|
5361
5981
|
function granolaBody(record) {
|
|
5362
5982
|
const privateNotes = firstCleanText(record, ["privateNotes", "private_notes", "notes"], MAX_BODY_BYTES);
|
|
5363
|
-
const
|
|
5364
|
-
const parts = [privateNotes,
|
|
5983
|
+
const summary2 = firstCleanText(record, ["summary_markdown", "summary", "summary_text", "aiSummary", "ai_summary"], MAX_BODY_BYTES);
|
|
5984
|
+
const parts = [privateNotes, summary2].filter((part) => part !== null);
|
|
5365
5985
|
if (parts.length === 0)
|
|
5366
5986
|
return null;
|
|
5367
5987
|
const joined = parts.join(`
|
|
@@ -5371,8 +5991,8 @@ function granolaBody(record) {
|
|
|
5371
5991
|
return joined;
|
|
5372
5992
|
if (privateNotes !== null && Buffer.byteLength(privateNotes, "utf8") <= MAX_BODY_BYTES)
|
|
5373
5993
|
return privateNotes;
|
|
5374
|
-
if (
|
|
5375
|
-
return
|
|
5994
|
+
if (summary2 !== null && Buffer.byteLength(summary2, "utf8") <= MAX_BODY_BYTES)
|
|
5995
|
+
return summary2;
|
|
5376
5996
|
return joined.slice(0, MAX_BODY_BYTES);
|
|
5377
5997
|
}
|
|
5378
5998
|
function inviteeEmailByName(record) {
|
|
@@ -5788,55 +6408,774 @@ function readGranolaImportFile(path) {
|
|
|
5788
6408
|
closeSync2(descriptor);
|
|
5789
6409
|
}
|
|
5790
6410
|
}
|
|
5791
|
-
function parseGranolaImportJson(raw) {
|
|
5792
|
-
if (Buffer.byteLength(raw, "utf8") > MAX_IMPORT_BYTES) {
|
|
5793
|
-
fail("Granola import exceeds the bounded size limit.");
|
|
6411
|
+
function parseGranolaImportJson(raw) {
|
|
6412
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_IMPORT_BYTES) {
|
|
6413
|
+
fail("Granola import exceeds the bounded size limit.");
|
|
6414
|
+
}
|
|
6415
|
+
try {
|
|
6416
|
+
return JSON.parse(raw);
|
|
6417
|
+
} catch {
|
|
6418
|
+
fail("Granola import is not valid JSON.");
|
|
6419
|
+
}
|
|
6420
|
+
}
|
|
6421
|
+
|
|
6422
|
+
// src/local/workspace-assets.ts
|
|
6423
|
+
var workspaceHtml = `<!doctype html>
|
|
6424
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6425
|
+
<title>PeopleBlade \xB7 Local workspace</title><link rel="stylesheet" href="/workspace.css"><script src="/workspace.js" defer></script></head>
|
|
6426
|
+
<body><a class="skip" href="#workspace">Skip to contacts</a><header><a class="brand" href="/">PeopleBlade<span>LOCAL WORKSPACE</span></a><p>Your people. Your machine.</p><span class="privacy">No cloud sync \xB7 no enrichment calls</span></header>
|
|
6427
|
+
<main id="workspace"><section id="unlock" class="unlock"><span class="eyebrow">PRIVATE BY DEFAULT</span><h1>Open your local contact book.</h1><p>Select the access file printed by <code>peopleblade ui</code>. It connects this tab to that running process only. The file is not uploaded to a remote service.</p><label class="file-label">Workspace access file<input id="access-file" type="file" accept="application/json,.json"></label><p class="muted">Keep the terminal running. Closing it locks the workspace. Notes never leave this machine.</p></section>
|
|
6428
|
+
<section id="book" hidden><div class="page-heading"><div><span class="eyebrow">YOUR RELATIONSHIPS</span><h1>Contact book</h1></div><button id="lock" type="button">Lock workspace</button></div>
|
|
6429
|
+
<form id="filters" class="filters"><label class="search">Search contacts<input id="search" name="search" type="search" placeholder="Name, company, title, email, phone\u2026" maxlength="200"></label><label>Source<select id="source"><option value="">All sources</option><option>apple</option><option>beeper</option><option>google</option><option>imessage</option><option>linkedin</option><option>instagram</option><option>whatsapp</option><option>x</option></select></label><label>Email<select id="email"><option value="">Any email</option><option value="yes">Has email</option><option value="no">Needs email</option></select></label><label>Contact preference<select id="dnc"><option value="all">All contacts</option><option value="exclude">May contact</option><option value="only">Do not contact</option></select></label><button type="submit">Search</button><button id="reset" type="button">Reset</button></form>
|
|
6430
|
+
<div class="workspace-grid"><section class="records" aria-label="Contacts"><div class="table-toolbar"><span id="range" aria-live="polite"></span><div><label>Sort <select id="sort"><option value="interactions">Interactions</option><option value="name">Name</option><option value="organization">Company</option><option value="last-contact">Last interaction</option></select></label><button id="direction" type="button" aria-label="Toggle sort direction">Descending \u2193</button></div></div><div class="table-scroll" tabindex="0" aria-label="Scrollable contacts"><table><caption class="sr-only">Local contacts; open a person to inspect details and notes.</caption><thead><tr><th scope="col">Person</th><th scope="col">Company</th><th scope="col">Interactions</th></tr></thead><tbody id="rows"></tbody></table></div><div id="empty" class="empty" hidden>No matches. Try another search or import a source with the CLI.</div><nav class="pagination" aria-label="Contact pages"><button id="previous" type="button">\u2190 Previous</button><span id="page"></span><button id="next" type="button">Next \u2192</button></nav><p class="live-note">Live view: imports and identity reviews can move rows between pages. Refresh after changes.</p></section>
|
|
6431
|
+
<aside id="detail" class="detail" aria-label="Contact details"><div class="empty"><h2>A little context goes a long way.</h2><p>Open a person to see their contact methods, sources and private notes.</p></div></aside></div></section>
|
|
6432
|
+
<p id="status" role="status" aria-live="polite"></p></main><footer>PeopleBlade \xB7 Local authority, reviewable identity, private notes.</footer></body></html>`;
|
|
6433
|
+
var workspaceCss = `
|
|
6434
|
+
:root{color-scheme:light dark;--bg:#f6f5f1;--panel:#fffefa;--text:#232721;--muted:#647064;--line:#dedfd6;--accent:#375a42;--soft:#edf1e8;--danger:#9c342d}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,select,textarea{font:inherit;color:inherit}button,input,select,textarea{border:1px solid var(--line);border-radius:7px;background:var(--panel);padding:9px 12px}button{cursor:pointer;font-weight:550}button:hover{border-color:var(--accent);background:var(--soft)}button:disabled{opacity:.45;cursor:default}a{color:var(--accent)}:focus-visible{outline:3px solid var(--accent);outline-offset:3px}header{display:flex;align-items:center;gap:30px;padding:22px 32px;border-bottom:1px solid var(--line);background:var(--panel)}header p{color:var(--muted);margin:0}.brand{color:var(--text);font-size:22px;line-height:1.1;text-decoration:none;font-weight:650;letter-spacing:-.7px}.brand span{display:block;font-size:9px;letter-spacing:1.8px;color:var(--muted);margin-top:8px}.privacy{margin-left:auto;border:1px solid var(--line);border-radius:99px;padding:6px 12px;font-size:11px;color:var(--muted)}main{max-width:1600px;margin:auto;padding:36px 32px}h1{font-size:34px;line-height:1.2;letter-spacing:-1px;font-weight:550;margin:8px 0 24px}h2{font-size:21px;line-height:1.3;letter-spacing:-.5px;margin:0 0 12px}.eyebrow{font-size:10px;letter-spacing:2px;color:var(--muted);font-weight:650}.unlock{max-width:600px;margin:7vh auto;padding:36px;background:var(--panel);border:1px solid var(--line);border-radius:12px}.unlock p{color:var(--muted)}.file-label{display:grid;gap:8px;margin:25px 0}.page-heading{display:flex;align-items:center;justify-content:space-between}.page-heading button{margin-bottom:20px}.filters{display:flex;gap:10px;align-items:end;margin-bottom:20px;flex-wrap:wrap}.filters label{display:grid;gap:5px;font-size:11px;color:var(--muted)}.filters input,.filters select{font-size:13px;color:var(--text);min-height:42px}.filters .search{flex:1;min-width:220px}.workspace-grid{display:grid;grid-template-columns:minmax(420px,1.4fr) minmax(320px,1fr);gap:20px;align-items:start}.records,.detail{border:1px solid var(--line);background:var(--panel);border-radius:10px;overflow:hidden}.table-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:13px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-size:12px;flex-wrap:wrap}.table-toolbar>div{display:flex;align-items:center;gap:6px}.table-toolbar select,.table-toolbar button{padding:5px 8px;font-size:12px}.table-scroll{overflow:auto;max-height:65vh}table{width:100%;border-collapse:collapse;text-align:left}th{position:sticky;top:0;background:var(--soft);font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);z-index:1}th,td{padding:11px 16px;border-bottom:1px solid var(--line);vertical-align:top}td{font-size:12px}td small{display:block;color:var(--muted);margin-top:2px}td button{padding:0;border:0;background:none;text-align:left;font-size:13px;color:var(--text)}tr.selected{background:var(--soft)}tr:hover{background:var(--soft)}.pagination{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;font-size:12px}.pagination button{font-size:12px;padding:6px 10px}.live-note{font-size:10px;color:var(--muted);padding:0 16px 12px;margin:0}.detail{padding:24px;position:sticky;top:20px;max-height:83vh;overflow:auto}.detail h2{font-size:25px}.detail dl{display:grid;grid-template-columns:90px 1fr;gap:8px;margin:22px 0}.detail dt{font-size:11px;color:var(--muted)}.detail dd{margin:0;font-size:12px;overflow-wrap:anywhere}.detail dd span{display:block}.pill{display:inline-block;border:1px solid var(--line);border-radius:99px;padding:2px 8px;margin:0 4px 5px 0;color:var(--muted);font-size:10px}.notes-heading{border-top:1px solid var(--line);padding-top:22px;margin-top:24px;display:flex;justify-content:space-between;align-items:center}.notes-heading h3{margin:0;font-weight:550}.note-card{border-bottom:1px solid var(--line);padding:16px 0}.note-card>button{font-size:12px;padding:5px 9px;margin:8px 6px 0 0}.note-meta{font-size:10px;color:var(--muted);margin:4px 0 10px}.note-title{font-size:14px;font-weight:600;margin:0}.markdown{font-size:13px;overflow-wrap:anywhere}.markdown h1,.markdown h2,.markdown h3{font-size:17px;margin:12px 0}.markdown pre{overflow:auto;background:var(--soft);padding:12px;border-radius:6px}.markdown img{display:none}.markdown blockquote{margin-left:0;padding-left:12px;border-left:3px solid var(--line);color:var(--muted)}.markdown p{margin:8px 0}.markdown td,.markdown th{padding:5px;position:static}.editor{display:grid;gap:10px}.editor label{display:grid;gap:4px;font-size:11px;color:var(--muted)}.editor textarea{min-height:230px;resize:vertical;font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;tab-size:2}.editor-actions{display:flex;gap:6px;flex-wrap:wrap}.editor-actions button{font-size:12px}.primary{background:var(--accent);color:var(--panel)}.primary:hover{background:var(--accent);color:var(--panel)}.empty{padding:44px 22px;color:var(--muted);text-align:center}.empty h2{font-size:21px}.muted{color:var(--muted);font-size:12px}.danger{color:var(--danger)}#status{background:var(--panel);border:1px solid var(--line);padding:12px 16px;border-radius:8px}#status:empty{display:none}footer{color:var(--muted);font-size:11px;text-align:center;padding:20px}.skip{position:absolute;left:12px;top:-100px;padding:8px;background:var(--panel);z-index:5}.skip:focus{top:8px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}[hidden]{display:none!important}
|
|
6435
|
+
@media(prefers-color-scheme:dark){:root{--bg:#151a17;--panel:#1c231e;--text:#e1e7dc;--muted:#a3b19e;--line:#354136;--accent:#a9c4a3;--soft:#28342b;--danger:#ffb4a9}.primary{color:#182019}}
|
|
6436
|
+
@media(max-width:1000px){.workspace-grid{grid-template-columns:1fr}.detail{position:static;max-height:none}.privacy{display:none}}@media(max-width:600px){header{padding:18px;gap:18px}header p{font-size:11px}main{padding:26px 14px}.unlock{padding:22px;margin:25px 0}h1{font-size:28px}.filters{gap:8px}.filters label{flex:1;min-width:125px}.filters .search{flex-basis:100%}th,td{padding:10px}.detail{padding:18px}.table-toolbar{padding:10px}.page-heading{align-items:start}.page-heading button{font-size:11px}.table-scroll{max-height:50vh}}
|
|
6437
|
+
`;
|
|
6438
|
+
function workspaceClient() {
|
|
6439
|
+
const el = (id) => document.getElementById(id);
|
|
6440
|
+
let capability = "";
|
|
6441
|
+
let offset = 0;
|
|
6442
|
+
let direction = "desc";
|
|
6443
|
+
let selectedPerson = null;
|
|
6444
|
+
let dirty = false;
|
|
6445
|
+
let activeSave = false;
|
|
6446
|
+
let activeEditor = null;
|
|
6447
|
+
let editorGeneration = 0;
|
|
6448
|
+
let detailLoading = false;
|
|
6449
|
+
let generation = 0;
|
|
6450
|
+
let detailGeneration = 0;
|
|
6451
|
+
let page = null;
|
|
6452
|
+
const status = (message) => {
|
|
6453
|
+
el("status").textContent = message;
|
|
6454
|
+
};
|
|
6455
|
+
const node = (tag, text = "", className = "") => {
|
|
6456
|
+
const element = document.createElement(tag);
|
|
6457
|
+
element.textContent = text;
|
|
6458
|
+
element.className = className;
|
|
6459
|
+
return element;
|
|
6460
|
+
};
|
|
6461
|
+
async function api(payload) {
|
|
6462
|
+
const response = await fetch("/api", { method: "POST", cache: "no-store", signal: AbortSignal.timeout(15000), headers: { "content-type": "application/json", authorization: `Bearer ${capability}` }, body: JSON.stringify(payload) });
|
|
6463
|
+
const envelope = await response.json();
|
|
6464
|
+
if (!response.ok || envelope.ok !== true) {
|
|
6465
|
+
const error = new Error(typeof envelope.message === "string" ? envelope.message : response.status === 401 ? "Access expired. Load the current access file again." : "The request failed.");
|
|
6466
|
+
Object.assign(error, { status: response.status });
|
|
6467
|
+
throw error;
|
|
6468
|
+
}
|
|
6469
|
+
return envelope.result;
|
|
6470
|
+
}
|
|
6471
|
+
function mayDiscard() {
|
|
6472
|
+
if (activeSave) {
|
|
6473
|
+
status("Wait for the current save outcome before switching notes or locking.");
|
|
6474
|
+
return false;
|
|
6475
|
+
}
|
|
6476
|
+
if (detailLoading) {
|
|
6477
|
+
status("Wait for the contact to finish loading before switching notes or locking.");
|
|
6478
|
+
return false;
|
|
6479
|
+
}
|
|
6480
|
+
return !dirty || window.confirm("Discard your unsaved note draft?");
|
|
6481
|
+
}
|
|
6482
|
+
function query() {
|
|
6483
|
+
const email = el("email").value;
|
|
6484
|
+
return { search: el("search").value, ...el("source").value ? { source: el("source").value } : {}, ...email ? { hasEmail: email === "yes" } : {}, doNotContact: el("dnc").value, sort: el("sort").value, direction, limit: 50, offset };
|
|
6485
|
+
}
|
|
6486
|
+
async function load() {
|
|
6487
|
+
const current = ++generation;
|
|
6488
|
+
status("Loading contacts\u2026");
|
|
6489
|
+
try {
|
|
6490
|
+
const result = await api({ action: "query", query: query() });
|
|
6491
|
+
if (current !== generation || !capability)
|
|
6492
|
+
return;
|
|
6493
|
+
page = result;
|
|
6494
|
+
const sourceSelect = el("source");
|
|
6495
|
+
const selectedSource = sourceSelect.value;
|
|
6496
|
+
sourceSelect.replaceChildren(new Option("All sources", ""));
|
|
6497
|
+
for (const facet of result.facets.sources)
|
|
6498
|
+
sourceSelect.add(new Option(`${facet.source} (${facet.count})`, facet.source));
|
|
6499
|
+
if (selectedSource && !result.facets.sources.some((facet) => facet.source === selectedSource))
|
|
6500
|
+
sourceSelect.add(new Option(selectedSource, selectedSource));
|
|
6501
|
+
sourceSelect.value = selectedSource;
|
|
6502
|
+
el("rows").replaceChildren();
|
|
6503
|
+
for (const person of result.items) {
|
|
6504
|
+
const row = node("tr", "", selectedPerson === person.id ? "selected" : "");
|
|
6505
|
+
const name = node("td");
|
|
6506
|
+
const open = node("button", person.displayName);
|
|
6507
|
+
open.type = "button";
|
|
6508
|
+
open.addEventListener("click", () => {
|
|
6509
|
+
if (mayDiscard()) {
|
|
6510
|
+
dirty = false;
|
|
6511
|
+
showPerson(person.id);
|
|
6512
|
+
}
|
|
6513
|
+
});
|
|
6514
|
+
name.append(open, node("small", person.title ?? person.primaryEmail ?? ""));
|
|
6515
|
+
const company = node("td", person.organization ?? "\u2014");
|
|
6516
|
+
company.append(node("small", person.sources.join(" \xB7 ")));
|
|
6517
|
+
const interactions = node("td", person.interactionCount.toLocaleString());
|
|
6518
|
+
interactions.append(node("small", person.doNotContact ? "Do not contact" : person.reciprocal ? "Reciprocal" : "One-way / unknown"));
|
|
6519
|
+
row.append(name, company, interactions);
|
|
6520
|
+
el("rows").append(row);
|
|
6521
|
+
}
|
|
6522
|
+
el("range").textContent = result.items.length === 0 ? `0 shown \xB7 ${result.total.toLocaleString()} matching contacts` : `${offset + 1}\u2013${offset + result.items.length} of ${result.total.toLocaleString()} contacts`;
|
|
6523
|
+
el("empty").hidden = result.items.length !== 0;
|
|
6524
|
+
el("page").textContent = `Page ${Math.floor(offset / 50) + 1}`;
|
|
6525
|
+
el("previous").disabled = offset === 0;
|
|
6526
|
+
el("next").disabled = result.nextOffset === null;
|
|
6527
|
+
status("");
|
|
6528
|
+
} catch (error) {
|
|
6529
|
+
if (current === generation)
|
|
6530
|
+
status(error instanceof Error ? error.message : "Contact query failed.");
|
|
6531
|
+
}
|
|
6532
|
+
}
|
|
6533
|
+
function renderNote(note, target) {
|
|
6534
|
+
const card = node("article", "", "note-card");
|
|
6535
|
+
card.dataset.noteId = String(note.id);
|
|
6536
|
+
card.append(node("h4", note.title ?? "Untitled note", "note-title"), node("p", `${note.occurredAt.slice(0, 10)} \xB7 ${note.source} original \xB7 revision ${note.revision}`, "note-meta"));
|
|
6537
|
+
const preview = node("div", "", "markdown");
|
|
6538
|
+
preview.innerHTML = note.previewHtml ?? "";
|
|
6539
|
+
card.append(preview);
|
|
6540
|
+
const edit = node("button", "Edit markdown");
|
|
6541
|
+
edit.type = "button";
|
|
6542
|
+
edit.dataset.noteAction = "edit";
|
|
6543
|
+
edit.addEventListener("click", () => {
|
|
6544
|
+
if (mayDiscard())
|
|
6545
|
+
editNote(note, card);
|
|
6546
|
+
});
|
|
6547
|
+
const history = node("button", "History");
|
|
6548
|
+
history.type = "button";
|
|
6549
|
+
history.dataset.noteAction = "history";
|
|
6550
|
+
history.addEventListener("click", () => {
|
|
6551
|
+
showHistory(note, card);
|
|
6552
|
+
});
|
|
6553
|
+
card.append(edit, history);
|
|
6554
|
+
target.append(card);
|
|
6555
|
+
}
|
|
6556
|
+
async function showHistory(note, card, beforeRevision, existing) {
|
|
6557
|
+
try {
|
|
6558
|
+
const history = await api({ action: "note-history", noteId: note.id, ...beforeRevision === undefined ? {} : { beforeRevision } });
|
|
6559
|
+
const container = existing ?? node("details");
|
|
6560
|
+
if (!existing) {
|
|
6561
|
+
container.setAttribute("open", "");
|
|
6562
|
+
container.append(node("summary", "Revision history"));
|
|
6563
|
+
}
|
|
6564
|
+
for (const item of history.items) {
|
|
6565
|
+
const entry = node("details");
|
|
6566
|
+
entry.append(node("summary", `${item.revision === 0 ? "Original imported content" : `User revision ${item.revision}`} \xB7 ${item.createdAt}`), node("pre", item.body));
|
|
6567
|
+
const restore = node("button", "Use this as a new draft");
|
|
6568
|
+
restore.type = "button";
|
|
6569
|
+
restore.addEventListener("click", () => {
|
|
6570
|
+
if (mayDiscard())
|
|
6571
|
+
editNote(note, card, item);
|
|
6572
|
+
});
|
|
6573
|
+
entry.append(restore);
|
|
6574
|
+
container.append(entry);
|
|
6575
|
+
}
|
|
6576
|
+
if (history.nextBeforeRevision !== null) {
|
|
6577
|
+
const older = node("button", "Older revisions");
|
|
6578
|
+
older.type = "button";
|
|
6579
|
+
older.dataset.noteAction = "older";
|
|
6580
|
+
older.addEventListener("click", () => {
|
|
6581
|
+
older.remove();
|
|
6582
|
+
showHistory(note, card, history.nextBeforeRevision ?? undefined, container);
|
|
6583
|
+
});
|
|
6584
|
+
container.append(older);
|
|
6585
|
+
}
|
|
6586
|
+
if (!existing)
|
|
6587
|
+
card.append(container);
|
|
6588
|
+
} catch (error) {
|
|
6589
|
+
status(error instanceof Error ? error.message : "History could not be loaded.");
|
|
6590
|
+
}
|
|
6591
|
+
}
|
|
6592
|
+
function editNote(note, card, restore) {
|
|
6593
|
+
if (activeSave || detailLoading)
|
|
6594
|
+
return;
|
|
6595
|
+
editorGeneration++;
|
|
6596
|
+
if (activeEditor && activeEditor.card !== card) {
|
|
6597
|
+
if (activeEditor.note.id === 0)
|
|
6598
|
+
activeEditor.card.remove();
|
|
6599
|
+
else {
|
|
6600
|
+
const replacement = node("div");
|
|
6601
|
+
renderNote(activeEditor.note, replacement);
|
|
6602
|
+
activeEditor.card.replaceWith(...replacement.childNodes);
|
|
6603
|
+
}
|
|
6604
|
+
}
|
|
6605
|
+
activeEditor = { note, card };
|
|
6606
|
+
card.dataset.noteId = String(note.id);
|
|
6607
|
+
const original = { title: note.title ?? "", body: note.body };
|
|
6608
|
+
card.replaceChildren();
|
|
6609
|
+
const editor = node("form", "", "editor");
|
|
6610
|
+
const titleLabel = node("label", "Title");
|
|
6611
|
+
const title = document.createElement("input");
|
|
6612
|
+
title.value = restore ? restore.title ?? "" : original.title;
|
|
6613
|
+
title.maxLength = 1024;
|
|
6614
|
+
title.spellcheck = false;
|
|
6615
|
+
title.autocomplete = "off";
|
|
6616
|
+
titleLabel.append(title);
|
|
6617
|
+
const bodyLabel = node("label", "Markdown note \xB7 private to this machine");
|
|
6618
|
+
const body = document.createElement("textarea");
|
|
6619
|
+
body.value = restore?.body ?? original.body;
|
|
6620
|
+
body.spellcheck = false;
|
|
6621
|
+
body.autocomplete = "off";
|
|
6622
|
+
body.setAttribute("autocorrect", "off");
|
|
6623
|
+
body.setAttribute("autocapitalize", "off");
|
|
6624
|
+
bodyLabel.append(body);
|
|
6625
|
+
const preview = node("div", "", "markdown");
|
|
6626
|
+
preview.hidden = true;
|
|
6627
|
+
const actions = node("div", "", "editor-actions");
|
|
6628
|
+
const save = node("button", "Save revision", "primary");
|
|
6629
|
+
save.type = "submit";
|
|
6630
|
+
const toggle = node("button", "Preview");
|
|
6631
|
+
toggle.type = "button";
|
|
6632
|
+
toggle.dataset.noteAction = "preview";
|
|
6633
|
+
toggle.addEventListener("click", async () => {
|
|
6634
|
+
try {
|
|
6635
|
+
preview.hidden = !preview.hidden;
|
|
6636
|
+
if (!preview.hidden) {
|
|
6637
|
+
const result = await api({ action: "preview", body: body.value });
|
|
6638
|
+
preview.innerHTML = result.html;
|
|
6639
|
+
}
|
|
6640
|
+
toggle.textContent = preview.hidden ? "Preview" : "Hide preview";
|
|
6641
|
+
} catch {
|
|
6642
|
+
status("Preview is unavailable. Your draft is unchanged.");
|
|
6643
|
+
}
|
|
6644
|
+
});
|
|
6645
|
+
const cancel = node("button", "Cancel");
|
|
6646
|
+
cancel.type = "button";
|
|
6647
|
+
cancel.dataset.noteAction = "cancel";
|
|
6648
|
+
cancel.addEventListener("click", () => {
|
|
6649
|
+
if (mayDiscard()) {
|
|
6650
|
+
dirty = false;
|
|
6651
|
+
if (selectedPerson !== null)
|
|
6652
|
+
showPerson(selectedPerson);
|
|
6653
|
+
}
|
|
6654
|
+
});
|
|
6655
|
+
const download = node("button", "Download draft");
|
|
6656
|
+
download.type = "button";
|
|
6657
|
+
download.addEventListener("click", () => {
|
|
6658
|
+
const url = URL.createObjectURL(new Blob([body.value], { type: "text/markdown;charset=utf-8" }));
|
|
6659
|
+
const link = document.createElement("a");
|
|
6660
|
+
link.href = url;
|
|
6661
|
+
link.download = `peopleblade-note-${note.id}.md`;
|
|
6662
|
+
link.click();
|
|
6663
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
6664
|
+
});
|
|
6665
|
+
actions.append(save, toggle, cancel, download);
|
|
6666
|
+
editor.append(titleLabel, bodyLabel, preview, actions);
|
|
6667
|
+
card.append(editor);
|
|
6668
|
+
let pending = null;
|
|
6669
|
+
let saving = false;
|
|
6670
|
+
let acknowledged = false;
|
|
6671
|
+
const changed = () => {
|
|
6672
|
+
editorGeneration++;
|
|
6673
|
+
dirty = title.value !== original.title || body.value !== original.body;
|
|
6674
|
+
};
|
|
6675
|
+
title.addEventListener("input", changed);
|
|
6676
|
+
body.addEventListener("input", changed);
|
|
6677
|
+
changed();
|
|
6678
|
+
editor.addEventListener("submit", async (event) => {
|
|
6679
|
+
event.preventDefault();
|
|
6680
|
+
if (saving || acknowledged || detailLoading)
|
|
6681
|
+
return;
|
|
6682
|
+
pending ??= {
|
|
6683
|
+
...note.id === 0 ? { action: "note-create", personId: note.personId, expectedPersonContextSha256: note.contextSha256, occurredAt: note.occurredAt } : { action: "note-update", noteId: note.id, expectedRevision: note.revision, expectedContextSha256: note.contextSha256 },
|
|
6684
|
+
requestId: crypto.randomUUID(),
|
|
6685
|
+
title: title.value.trim() === "" ? null : title.value,
|
|
6686
|
+
body: body.value
|
|
6687
|
+
};
|
|
6688
|
+
saving = true;
|
|
6689
|
+
activeSave = true;
|
|
6690
|
+
save.disabled = true;
|
|
6691
|
+
body.readOnly = true;
|
|
6692
|
+
title.readOnly = true;
|
|
6693
|
+
status("Saving an immutable revision\u2026");
|
|
6694
|
+
try {
|
|
6695
|
+
await api(pending);
|
|
6696
|
+
acknowledged = true;
|
|
6697
|
+
pending = null;
|
|
6698
|
+
dirty = false;
|
|
6699
|
+
save.textContent = "Saved";
|
|
6700
|
+
status("Note saved. Original content and history are preserved.");
|
|
6701
|
+
const refreshed = selectedPerson !== null && await showPerson(selectedPerson);
|
|
6702
|
+
if (!refreshed) {
|
|
6703
|
+
cancel.textContent = "Reload saved note";
|
|
6704
|
+
status("Note saved. Details could not be refreshed. Reload the saved note; it will not be submitted again.");
|
|
6705
|
+
}
|
|
6706
|
+
} catch (error) {
|
|
6707
|
+
const responseStatus = error.status;
|
|
6708
|
+
const conflict = responseStatus === 409;
|
|
6709
|
+
const rejected = responseStatus !== undefined && responseStatus >= 400 && responseStatus < 500;
|
|
6710
|
+
status(conflict ? "This note or its contact identity changed. Download your draft, then Cancel to reload before applying it." : rejected ? error instanceof Error ? error.message : "The note was rejected; correct the draft and try again." : "Save outcome is uncertain. Retry sends the same request safely; keep this tab open or download your draft.");
|
|
6711
|
+
if (rejected) {
|
|
6712
|
+
pending = null;
|
|
6713
|
+
body.readOnly = false;
|
|
6714
|
+
title.readOnly = false;
|
|
6715
|
+
}
|
|
6716
|
+
save.textContent = rejected ? "Save revision" : "Retry same save";
|
|
6717
|
+
} finally {
|
|
6718
|
+
saving = false;
|
|
6719
|
+
activeSave = false;
|
|
6720
|
+
save.disabled = acknowledged;
|
|
6721
|
+
}
|
|
6722
|
+
});
|
|
6723
|
+
body.addEventListener("keydown", (event) => {
|
|
6724
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
6725
|
+
event.preventDefault();
|
|
6726
|
+
editor.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
6727
|
+
}
|
|
6728
|
+
});
|
|
6729
|
+
body.focus();
|
|
6730
|
+
}
|
|
6731
|
+
async function showPerson(id, noteSearch = "") {
|
|
6732
|
+
const current = ++detailGeneration;
|
|
6733
|
+
const editorAtRequest = editorGeneration;
|
|
6734
|
+
const target = el("detail");
|
|
6735
|
+
detailLoading = true;
|
|
6736
|
+
target.inert = true;
|
|
6737
|
+
status("Loading person and notes\u2026");
|
|
6738
|
+
try {
|
|
6739
|
+
const result = await api({ action: "detail", personId: id, ...noteSearch ? { noteSearch } : {} });
|
|
6740
|
+
const notes = result.notes;
|
|
6741
|
+
if (current !== detailGeneration || !capability)
|
|
6742
|
+
return false;
|
|
6743
|
+
if (editorAtRequest !== editorGeneration) {
|
|
6744
|
+
status("Contact loading was canceled because the note draft changed.");
|
|
6745
|
+
return false;
|
|
6746
|
+
}
|
|
6747
|
+
activeEditor = null;
|
|
6748
|
+
selectedPerson = id;
|
|
6749
|
+
target.replaceChildren();
|
|
6750
|
+
target.append(node("span", `PERSON ${id} \xB7 ${result.memberCount} SOURCE RECORD${result.memberCount === 1 ? "" : "S"}`, "eyebrow"), node("h2", result.contact.displayName));
|
|
6751
|
+
const pills = node("div");
|
|
6752
|
+
result.contact.sources.forEach((source) => pills.append(node("span", source, "pill")));
|
|
6753
|
+
target.append(pills);
|
|
6754
|
+
const fields = node("dl");
|
|
6755
|
+
for (const [label, value] of [["Company", result.contact.organization], ["Title", result.contact.title], ["Preference", result.contact.doNotContact ? "Do not contact" : "No do-not-contact flag"], ["Interactions", result.contact.interactionCount.toLocaleString()]]) {
|
|
6756
|
+
fields.append(node("dt", label ?? ""), node("dd", value ?? "\u2014"));
|
|
6757
|
+
}
|
|
6758
|
+
fields.append(node("dt", "Methods"));
|
|
6759
|
+
const methods = node("dd");
|
|
6760
|
+
result.methods.items.forEach((method) => methods.append(node("span", `${method.kind}: ${method.value}`)));
|
|
6761
|
+
if (result.methods.truncated)
|
|
6762
|
+
methods.append(node("span", `Showing ${result.methods.items.length} of ${result.methods.total}. Use the CLI for a narrower query.`));
|
|
6763
|
+
fields.append(methods);
|
|
6764
|
+
target.append(fields);
|
|
6765
|
+
const heading = node("div", "", "notes-heading");
|
|
6766
|
+
heading.append(node("h3", "Private notes"), node("span", "LOCAL ONLY", "eyebrow"));
|
|
6767
|
+
target.append(heading);
|
|
6768
|
+
target.append(node("p", "Latest 20 matching notes. Search older notes below. Edits preserve source content; identity review and joins remain explicit CLI actions.", "muted"));
|
|
6769
|
+
const noteTools = node("form", "", "editor-actions");
|
|
6770
|
+
const searchLabel = node("label", "Search notes ");
|
|
6771
|
+
const noteQuery = document.createElement("input");
|
|
6772
|
+
noteQuery.type = "search";
|
|
6773
|
+
noteQuery.maxLength = 200;
|
|
6774
|
+
noteQuery.value = noteSearch;
|
|
6775
|
+
searchLabel.append(noteQuery);
|
|
6776
|
+
const searchNotes = node("button", "Find");
|
|
6777
|
+
searchNotes.type = "submit";
|
|
6778
|
+
const add = node("button", "New note");
|
|
6779
|
+
add.type = "button";
|
|
6780
|
+
add.dataset.noteAction = "create";
|
|
6781
|
+
noteTools.append(searchLabel, searchNotes, add);
|
|
6782
|
+
target.append(noteTools);
|
|
6783
|
+
noteTools.addEventListener("submit", (event) => {
|
|
6784
|
+
event.preventDefault();
|
|
6785
|
+
if (mayDiscard()) {
|
|
6786
|
+
dirty = false;
|
|
6787
|
+
showPerson(id, noteQuery.value);
|
|
6788
|
+
}
|
|
6789
|
+
});
|
|
6790
|
+
const noteContainer = node("div");
|
|
6791
|
+
target.append(noteContainer);
|
|
6792
|
+
add.addEventListener("click", () => {
|
|
6793
|
+
if (!mayDiscard())
|
|
6794
|
+
return;
|
|
6795
|
+
dirty = false;
|
|
6796
|
+
const draft = node("article", "", "note-card");
|
|
6797
|
+
noteContainer.prepend(draft);
|
|
6798
|
+
editNote({ id: 0, personId: id, title: null, body: "", occurredAt: new Date().toISOString(), source: "manual", revision: 0, contextSha256: result.noteContext.contextSha256 }, draft);
|
|
6799
|
+
});
|
|
6800
|
+
if (notes.length === 0)
|
|
6801
|
+
noteContainer.append(node("p", "No notes yet.", "muted"));
|
|
6802
|
+
for (const note of notes)
|
|
6803
|
+
renderNote(note, noteContainer);
|
|
6804
|
+
status("");
|
|
6805
|
+
return true;
|
|
6806
|
+
} catch (error) {
|
|
6807
|
+
if (current === detailGeneration)
|
|
6808
|
+
status(error instanceof Error ? error.message : "Contact details could not be loaded.");
|
|
6809
|
+
return false;
|
|
6810
|
+
} finally {
|
|
6811
|
+
if (current === detailGeneration) {
|
|
6812
|
+
detailLoading = false;
|
|
6813
|
+
target.inert = false;
|
|
6814
|
+
}
|
|
6815
|
+
}
|
|
6816
|
+
}
|
|
6817
|
+
el("access-file").addEventListener("change", async (event) => {
|
|
6818
|
+
try {
|
|
6819
|
+
const input = event.target;
|
|
6820
|
+
const file = input.files?.[0];
|
|
6821
|
+
input.value = "";
|
|
6822
|
+
if (!file || file.size > 2048)
|
|
6823
|
+
throw new Error("Choose the small access file printed by the running CLI.");
|
|
6824
|
+
const access = JSON.parse(await file.text());
|
|
6825
|
+
if (access.schemaVersion !== "peopleblade.workspace-access.v1" || access.origin !== location.origin || typeof access.capability !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(access.capability))
|
|
6826
|
+
throw new Error("This access file belongs to another workspace. Use the current CLI file and URL.");
|
|
6827
|
+
capability = access.capability;
|
|
6828
|
+
await api({ action: "ping" });
|
|
6829
|
+
el("unlock").hidden = true;
|
|
6830
|
+
el("book").hidden = false;
|
|
6831
|
+
await load();
|
|
6832
|
+
el("search").focus();
|
|
6833
|
+
} catch (error) {
|
|
6834
|
+
capability = "";
|
|
6835
|
+
status(error instanceof Error ? error.message : "Workspace could not be unlocked.");
|
|
6836
|
+
}
|
|
6837
|
+
});
|
|
6838
|
+
el("lock").addEventListener("click", () => {
|
|
6839
|
+
if (!mayDiscard())
|
|
6840
|
+
return;
|
|
6841
|
+
capability = "";
|
|
6842
|
+
dirty = false;
|
|
6843
|
+
generation++;
|
|
6844
|
+
detailGeneration++;
|
|
6845
|
+
activeEditor = null;
|
|
6846
|
+
selectedPerson = null;
|
|
6847
|
+
el("rows").replaceChildren();
|
|
6848
|
+
el("detail").replaceChildren();
|
|
6849
|
+
el("book").hidden = true;
|
|
6850
|
+
el("unlock").hidden = false;
|
|
6851
|
+
status("Workspace locked.");
|
|
6852
|
+
});
|
|
6853
|
+
el("filters").addEventListener("submit", (event) => {
|
|
6854
|
+
event.preventDefault();
|
|
6855
|
+
offset = 0;
|
|
6856
|
+
load();
|
|
6857
|
+
});
|
|
6858
|
+
el("reset").addEventListener("click", () => {
|
|
6859
|
+
HTMLFormElement.prototype.reset.call(el("filters"));
|
|
6860
|
+
offset = 0;
|
|
6861
|
+
load();
|
|
6862
|
+
});
|
|
6863
|
+
el("sort").addEventListener("change", () => {
|
|
6864
|
+
offset = 0;
|
|
6865
|
+
load();
|
|
6866
|
+
});
|
|
6867
|
+
el("direction").addEventListener("click", () => {
|
|
6868
|
+
direction = direction === "asc" ? "desc" : "asc";
|
|
6869
|
+
el("direction").textContent = direction === "asc" ? "Ascending \u2191" : "Descending \u2193";
|
|
6870
|
+
offset = 0;
|
|
6871
|
+
load();
|
|
6872
|
+
});
|
|
6873
|
+
el("previous").addEventListener("click", () => {
|
|
6874
|
+
offset = Math.max(0, offset - 50);
|
|
6875
|
+
load();
|
|
6876
|
+
});
|
|
6877
|
+
el("next").addEventListener("click", () => {
|
|
6878
|
+
if (page?.nextOffset !== null && page?.nextOffset !== undefined) {
|
|
6879
|
+
offset = page.nextOffset;
|
|
6880
|
+
load();
|
|
6881
|
+
}
|
|
6882
|
+
});
|
|
6883
|
+
window.addEventListener("beforeunload", (event) => {
|
|
6884
|
+
if (dirty || activeSave) {
|
|
6885
|
+
event.preventDefault();
|
|
6886
|
+
event.returnValue = "";
|
|
6887
|
+
}
|
|
6888
|
+
});
|
|
6889
|
+
}
|
|
6890
|
+
var workspaceScript = `(${workspaceClient.toString()})();`;
|
|
6891
|
+
|
|
6892
|
+
// src/local/workspace-security.ts
|
|
6893
|
+
import { timingSafeEqual } from "crypto";
|
|
6894
|
+
var securityHeaders = {
|
|
6895
|
+
"cache-control": "no-store",
|
|
6896
|
+
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; font-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; object-src 'none'",
|
|
6897
|
+
"referrer-policy": "no-referrer",
|
|
6898
|
+
"x-content-type-options": "nosniff",
|
|
6899
|
+
"x-frame-options": "DENY",
|
|
6900
|
+
"cross-origin-resource-policy": "same-origin",
|
|
6901
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=()"
|
|
6902
|
+
};
|
|
6903
|
+
|
|
6904
|
+
class WorkspaceRequestError extends Error {
|
|
6905
|
+
code;
|
|
6906
|
+
constructor(code, message) {
|
|
6907
|
+
super(message);
|
|
6908
|
+
this.code = code;
|
|
6909
|
+
}
|
|
6910
|
+
}
|
|
6911
|
+
function json(value, status = 200) {
|
|
6912
|
+
return Response.json(value, { status, headers: securityHeaders });
|
|
6913
|
+
}
|
|
6914
|
+
async function readPayload(request) {
|
|
6915
|
+
const maximum = 256 * 1024;
|
|
6916
|
+
const length = request.headers.get("content-length");
|
|
6917
|
+
if (length !== null && (!/^(0|[1-9][0-9]*)$/u.test(length) || Number(length) > maximum)) {
|
|
6918
|
+
request.body?.cancel().catch(() => {
|
|
6919
|
+
return;
|
|
6920
|
+
});
|
|
6921
|
+
throw new WorkspaceRequestError("invalid_request", "Request exceeds 256 KiB.");
|
|
6922
|
+
}
|
|
6923
|
+
if (request.body === null)
|
|
6924
|
+
throw new WorkspaceRequestError("invalid_request", "A JSON body is required.");
|
|
6925
|
+
const reader = request.body.getReader();
|
|
6926
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
6927
|
+
let size = 0;
|
|
6928
|
+
let text = "";
|
|
6929
|
+
try {
|
|
6930
|
+
for (;; ) {
|
|
6931
|
+
const chunk = await reader.read();
|
|
6932
|
+
if (chunk.done)
|
|
6933
|
+
break;
|
|
6934
|
+
size += chunk.value.byteLength;
|
|
6935
|
+
if (size > maximum)
|
|
6936
|
+
throw new Error("oversized");
|
|
6937
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
6938
|
+
}
|
|
6939
|
+
text += decoder.decode();
|
|
6940
|
+
return JSON.parse(text);
|
|
6941
|
+
} catch {
|
|
6942
|
+
reader.cancel().catch(() => {
|
|
6943
|
+
return;
|
|
6944
|
+
});
|
|
6945
|
+
throw new WorkspaceRequestError("invalid_request", "Request must be valid UTF-8 JSON within 256 KiB.");
|
|
6946
|
+
} finally {
|
|
6947
|
+
reader.releaseLock();
|
|
6948
|
+
}
|
|
6949
|
+
}
|
|
6950
|
+
function createWorkspaceHandler(input) {
|
|
6951
|
+
const expected = new URL(input.origin);
|
|
6952
|
+
if (expected.protocol !== "http:" || expected.hostname !== "127.0.0.1" || !expected.port || expected.origin !== input.origin || !/^[A-Za-z0-9_-]{43}$/u.test(input.capability)) {
|
|
6953
|
+
throw new Error("Invalid local workspace authority.");
|
|
6954
|
+
}
|
|
6955
|
+
const secret = Buffer.from(input.capability);
|
|
6956
|
+
return async (request) => {
|
|
6957
|
+
const url = new URL(request.url);
|
|
6958
|
+
if (url.origin !== input.origin || request.headers.get("host") !== expected.host || url.search !== "" || request.headers.get("sec-fetch-site") === "cross-site" || request.headers.has("origin") && request.headers.get("origin") !== input.origin) {
|
|
6959
|
+
request.body?.cancel().catch(() => {
|
|
6960
|
+
return;
|
|
6961
|
+
});
|
|
6962
|
+
return json({ error: "forbidden" }, 403);
|
|
6963
|
+
}
|
|
6964
|
+
if (request.method === "GET") {
|
|
6965
|
+
const asset = url.pathname === "/" ? [input.assets.html, "text/html; charset=utf-8"] : url.pathname === "/workspace.js" ? [input.assets.script, "text/javascript; charset=utf-8"] : url.pathname === "/workspace.css" ? [input.assets.css, "text/css; charset=utf-8"] : null;
|
|
6966
|
+
return asset === null ? json({ error: "not_found" }, 404) : new Response(asset[0], { headers: { ...securityHeaders, "content-type": asset[1] } });
|
|
6967
|
+
}
|
|
6968
|
+
if (request.method !== "POST" || url.pathname !== "/api")
|
|
6969
|
+
return json({ error: "not_found" }, 404);
|
|
6970
|
+
const supplied = request.headers.get("authorization")?.replace(/^Bearer /u, "") ?? "";
|
|
6971
|
+
if (request.headers.get("origin") !== input.origin || !/^[A-Za-z0-9_-]{43}$/u.test(supplied) || !timingSafeEqual(Buffer.from(supplied), secret)) {
|
|
6972
|
+
request.body?.cancel().catch(() => {
|
|
6973
|
+
return;
|
|
6974
|
+
});
|
|
6975
|
+
return json({ error: "unauthorized" }, 401);
|
|
6976
|
+
}
|
|
6977
|
+
if (request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") {
|
|
6978
|
+
return json({ error: "unsupported_media_type" }, 415);
|
|
6979
|
+
}
|
|
6980
|
+
try {
|
|
6981
|
+
return json({ ok: true, result: await input.dispatch(await readPayload(request)) });
|
|
6982
|
+
} catch (error) {
|
|
6983
|
+
if (error instanceof WorkspaceRequestError) {
|
|
6984
|
+
return json({ ok: false, error: error.code, message: error.message }, error.code === "not_found" ? 404 : error.code === "conflict" ? 409 : 400);
|
|
6985
|
+
}
|
|
6986
|
+
return json({ ok: false, error: "operation_failed", message: "The operation failed. Your existing data is preserved." }, 500);
|
|
6987
|
+
}
|
|
6988
|
+
};
|
|
6989
|
+
}
|
|
6990
|
+
|
|
6991
|
+
// src/local/workspace.ts
|
|
6992
|
+
var id = z5.number().int().positive().max(Number.MAX_SAFE_INTEGER);
|
|
6993
|
+
var requestSchema = z5.discriminatedUnion("action", [
|
|
6994
|
+
z5.object({ action: z5.literal("ping") }).strict(),
|
|
6995
|
+
z5.object({ action: z5.literal("query"), query: contactQueryInputSchema }).strict(),
|
|
6996
|
+
z5.object({ action: z5.literal("detail"), personId: id, noteSearch: z5.string().max(200).optional() }).strict(),
|
|
6997
|
+
z5.object({ action: z5.literal("notes"), personId: id, search: z5.string().max(200).optional() }).strict(),
|
|
6998
|
+
z5.object({ action: z5.literal("note"), noteId: id }).strict(),
|
|
6999
|
+
z5.object({ action: z5.literal("note-history"), noteId: id, beforeRevision: z5.number().int().min(1).optional() }).strict(),
|
|
7000
|
+
z5.object({ action: z5.literal("preview"), body: z5.string().max(65536) }).strict(),
|
|
7001
|
+
z5.object({
|
|
7002
|
+
action: z5.literal("note-update"),
|
|
7003
|
+
noteId: id,
|
|
7004
|
+
expectedRevision: z5.number().int().min(0).max(Number.MAX_SAFE_INTEGER - 1),
|
|
7005
|
+
expectedContextSha256: z5.string().regex(/^[0-9a-f]{64}$/u),
|
|
7006
|
+
requestId: z5.uuid(),
|
|
7007
|
+
title: z5.string().max(4096).nullable(),
|
|
7008
|
+
body: z5.string().max(65536)
|
|
7009
|
+
}).strict(),
|
|
7010
|
+
z5.object({
|
|
7011
|
+
action: z5.literal("note-create"),
|
|
7012
|
+
personId: id,
|
|
7013
|
+
expectedPersonContextSha256: z5.string().regex(/^[0-9a-f]{64}$/u),
|
|
7014
|
+
requestId: z5.uuid(),
|
|
7015
|
+
occurredAt: z5.string().max(64),
|
|
7016
|
+
title: z5.string().max(4096).nullable(),
|
|
7017
|
+
body: z5.string().max(65536)
|
|
7018
|
+
}).strict()
|
|
7019
|
+
]);
|
|
7020
|
+
function dispatchWorkspaceRequest(database, payload) {
|
|
7021
|
+
const parsed = requestSchema.safeParse(payload);
|
|
7022
|
+
if (!parsed.success)
|
|
7023
|
+
throw new WorkspaceRequestError("invalid_request", "Invalid workspace request. Check the action and its fields.");
|
|
7024
|
+
const input = parsed.data;
|
|
7025
|
+
try {
|
|
7026
|
+
switch (input.action) {
|
|
7027
|
+
case "ping":
|
|
7028
|
+
return { schemaVersion: "peopleblade.workspace.v1", localOnly: true };
|
|
7029
|
+
case "query":
|
|
7030
|
+
return queryLocalContacts(database, input.query);
|
|
7031
|
+
case "detail":
|
|
7032
|
+
return database.transaction(() => ({
|
|
7033
|
+
...getLocalContactDetail(database, input.personId),
|
|
7034
|
+
noteContext: personNoteContext(database, input.personId),
|
|
7035
|
+
notes: ((input.noteSearch?.trim()) ? searchPersonNotes(database, { personId: input.personId, query: input.noteSearch, limit: 20 }) : listPersonNotes(database, { personId: input.personId, limit: 20 })).map((note) => ({ ...note, previewHtml: previewNoteMarkdown(note.body) }))
|
|
7036
|
+
})).deferred();
|
|
7037
|
+
case "notes": {
|
|
7038
|
+
const notes = input.search?.trim() ? searchPersonNotes(database, { personId: input.personId, query: input.search, limit: 20 }) : listPersonNotes(database, { personId: input.personId, limit: 20 });
|
|
7039
|
+
return notes.map((note) => ({ ...note, previewHtml: previewNoteMarkdown(note.body) }));
|
|
7040
|
+
}
|
|
7041
|
+
case "note":
|
|
7042
|
+
return getPersonNote(database, input.noteId);
|
|
7043
|
+
case "note-history":
|
|
7044
|
+
return listPersonNoteRevisions(database, { noteId: input.noteId, beforeRevision: input.beforeRevision, limit: 50 });
|
|
7045
|
+
case "note-update":
|
|
7046
|
+
return revisePersonNote(database, input);
|
|
7047
|
+
case "note-create":
|
|
7048
|
+
return createWorkspaceNote(database, input);
|
|
7049
|
+
case "preview":
|
|
7050
|
+
return { html: previewNoteMarkdown(input.body) };
|
|
7051
|
+
}
|
|
7052
|
+
} catch (error) {
|
|
7053
|
+
if (error instanceof NoteRevisionError) {
|
|
7054
|
+
throw new WorkspaceRequestError(error.code === "not_found" ? "not_found" : error.code === "invalid_input" ? "invalid_request" : "conflict", error.message);
|
|
7055
|
+
}
|
|
7056
|
+
if (input.action === "preview")
|
|
7057
|
+
throw new WorkspaceRequestError("invalid_request", "Markdown preview requires at most 64 KiB of UTF-8 text.");
|
|
7058
|
+
throw error;
|
|
7059
|
+
}
|
|
7060
|
+
}
|
|
7061
|
+
function startLocalWorkspace(database, port = 0) {
|
|
7062
|
+
if (!Number.isSafeInteger(port) || port < 0 || port > 65535)
|
|
7063
|
+
throw new Error("Workspace port must be 0\u201365535.");
|
|
7064
|
+
const capability = randomBytes(32).toString("base64url");
|
|
7065
|
+
let handler = null;
|
|
7066
|
+
const server = Bun.serve({
|
|
7067
|
+
hostname: "127.0.0.1",
|
|
7068
|
+
port,
|
|
7069
|
+
maxRequestBodySize: 256 * 1024,
|
|
7070
|
+
idleTimeout: 20,
|
|
7071
|
+
fetch(request) {
|
|
7072
|
+
return handler === null ? new Response(null, { status: 503 }) : handler(request);
|
|
7073
|
+
},
|
|
7074
|
+
error() {
|
|
7075
|
+
return new Response("Workspace request failed.", { status: 500, headers: { "cache-control": "no-store" } });
|
|
7076
|
+
}
|
|
7077
|
+
});
|
|
7078
|
+
const origin = `http://127.0.0.1:${server.port}`;
|
|
7079
|
+
let directory;
|
|
7080
|
+
let accessFile;
|
|
7081
|
+
try {
|
|
7082
|
+
directory = mkdtempSync(join(tmpdir(), "peopleblade-workspace-"));
|
|
7083
|
+
chmodSync(directory, 448);
|
|
7084
|
+
accessFile = join(directory, "access.json");
|
|
7085
|
+
writeFileSync(accessFile, JSON.stringify({ schemaVersion: "peopleblade.workspace-access.v1", origin, capability }), { flag: "wx", mode: 384 });
|
|
7086
|
+
} catch (error) {
|
|
7087
|
+
server.stop(true);
|
|
7088
|
+
throw error;
|
|
5794
7089
|
}
|
|
7090
|
+
const fileIdentity = lstatSync(accessFile);
|
|
7091
|
+
handler = createWorkspaceHandler({
|
|
7092
|
+
origin,
|
|
7093
|
+
capability,
|
|
7094
|
+
assets: { html: workspaceHtml, css: workspaceCss, script: workspaceScript },
|
|
7095
|
+
dispatch: (payload) => dispatchWorkspaceRequest(database, payload)
|
|
7096
|
+
});
|
|
7097
|
+
let stopped = false;
|
|
7098
|
+
const stop = async () => {
|
|
7099
|
+
if (stopped)
|
|
7100
|
+
return;
|
|
7101
|
+
stopped = true;
|
|
7102
|
+
handler = null;
|
|
7103
|
+
await server.stop(true);
|
|
7104
|
+
try {
|
|
7105
|
+
const current = lstatSync(accessFile);
|
|
7106
|
+
if (current.isFile() && !current.isSymbolicLink() && current.ino === fileIdentity.ino && current.dev === fileIdentity.dev && current.nlink === 1)
|
|
7107
|
+
unlinkSync(accessFile);
|
|
7108
|
+
rmdirSync(directory);
|
|
7109
|
+
} catch {}
|
|
7110
|
+
};
|
|
7111
|
+
return { schemaVersion: "peopleblade.workspace.v1", origin, accessFile, localOnly: true, stop };
|
|
7112
|
+
}
|
|
7113
|
+
async function runLocalWorkspace(database, options = {}) {
|
|
7114
|
+
const workspace = startLocalWorkspace(database, options.port);
|
|
7115
|
+
const { stop: _stop, ...receipt } = workspace;
|
|
7116
|
+
if (options.json)
|
|
7117
|
+
console.log(JSON.stringify(receipt));
|
|
7118
|
+
else
|
|
7119
|
+
console.log(`PeopleBlade local workspace
|
|
7120
|
+
${workspace.origin}
|
|
7121
|
+
Select this access file in the page: ${workspace.accessFile}
|
|
7122
|
+
Keep this terminal open. Press Ctrl+C to lock and stop.`);
|
|
7123
|
+
let terminate = () => {
|
|
7124
|
+
return;
|
|
7125
|
+
};
|
|
5795
7126
|
try {
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
7127
|
+
await new Promise((resolve) => {
|
|
7128
|
+
terminate = () => resolve();
|
|
7129
|
+
process.once("SIGINT", terminate);
|
|
7130
|
+
process.once("SIGTERM", terminate);
|
|
7131
|
+
process.once("SIGHUP", terminate);
|
|
7132
|
+
});
|
|
7133
|
+
} finally {
|
|
7134
|
+
process.removeListener("SIGINT", terminate);
|
|
7135
|
+
process.removeListener("SIGTERM", terminate);
|
|
7136
|
+
process.removeListener("SIGHUP", terminate);
|
|
7137
|
+
await workspace.stop();
|
|
5799
7138
|
}
|
|
5800
7139
|
}
|
|
5801
7140
|
|
|
5802
7141
|
// src/local/cloud-client.ts
|
|
5803
|
-
import { createHash as createHash2, randomBytes } from "crypto";
|
|
5804
|
-
import { z as
|
|
7142
|
+
import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
|
|
7143
|
+
import { z as z8 } from "zod";
|
|
5805
7144
|
|
|
5806
7145
|
// src/local/cloud-configuration.ts
|
|
5807
|
-
import { z as
|
|
7146
|
+
import { z as z7 } from "zod";
|
|
5808
7147
|
|
|
5809
7148
|
// src/local/config.ts
|
|
5810
|
-
import { closeSync as closeSync3, constants as constants3, existsSync, mkdirSync, openSync as openSync3, readFileSync as readFileSync3, renameSync, writeFileSync } from "fs";
|
|
7149
|
+
import { closeSync as closeSync3, constants as constants3, existsSync, mkdirSync, openSync as openSync3, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync2 } from "fs";
|
|
5811
7150
|
import { dirname } from "path";
|
|
5812
|
-
import { z as
|
|
7151
|
+
import { z as z6 } from "zod";
|
|
5813
7152
|
|
|
5814
7153
|
// src/local/paths.ts
|
|
5815
7154
|
import { homedir, hostname, platform } from "os";
|
|
5816
|
-
import { join } from "path";
|
|
7155
|
+
import { join as join2 } from "path";
|
|
5817
7156
|
function peoplebladeDirectory() {
|
|
5818
7157
|
if (platform() === "darwin")
|
|
5819
|
-
return
|
|
7158
|
+
return join2(homedir(), "Library", "Application Support", "PeopleBlade");
|
|
5820
7159
|
if (platform() === "win32")
|
|
5821
|
-
return
|
|
5822
|
-
return
|
|
7160
|
+
return join2(process.env.LOCALAPPDATA ?? join2(homedir(), "AppData", "Local"), "PeopleBlade");
|
|
7161
|
+
return join2(process.env.XDG_DATA_HOME ?? join2(homedir(), ".local", "share"), "peopleblade");
|
|
5823
7162
|
}
|
|
5824
7163
|
function peoplebladeDatabasePath() {
|
|
5825
|
-
return process.env.PEOPLEBLADE_DATABASE ??
|
|
7164
|
+
return process.env.PEOPLEBLADE_DATABASE ?? join2(peoplebladeDirectory(), "peopleblade.sqlite3");
|
|
5826
7165
|
}
|
|
5827
7166
|
function peoplebladeConfigPath() {
|
|
5828
|
-
return
|
|
7167
|
+
return join2(peoplebladeDirectory(), "config.json");
|
|
5829
7168
|
}
|
|
5830
7169
|
function defaultDeviceName() {
|
|
5831
7170
|
return `${hostname() || "Mac"} \xB7 PeopleBlade CLI`.slice(0, 128);
|
|
5832
7171
|
}
|
|
5833
7172
|
|
|
5834
7173
|
// src/local/config.ts
|
|
5835
|
-
var configSchema =
|
|
5836
|
-
cloud:
|
|
5837
|
-
baseUrl:
|
|
5838
|
-
deviceId:
|
|
5839
|
-
token:
|
|
7174
|
+
var configSchema = z6.object({
|
|
7175
|
+
cloud: z6.object({
|
|
7176
|
+
baseUrl: z6.url().max(2048),
|
|
7177
|
+
deviceId: z6.uuid(),
|
|
7178
|
+
token: z6.string().min(20).max(512)
|
|
5840
7179
|
}).strict().nullable()
|
|
5841
7180
|
}).strict();
|
|
5842
7181
|
function readLocalConfig() {
|
|
@@ -5852,7 +7191,7 @@ function writeLocalConfig(config) {
|
|
|
5852
7191
|
const temporary = `${path}.stage-${process.pid}`;
|
|
5853
7192
|
const descriptor = openSync3(temporary, constants3.O_WRONLY | constants3.O_CREAT | constants3.O_EXCL, 384);
|
|
5854
7193
|
try {
|
|
5855
|
-
|
|
7194
|
+
writeFileSync2(descriptor, `${JSON.stringify(configSchema.parse(config), null, 2)}
|
|
5856
7195
|
`);
|
|
5857
7196
|
} finally {
|
|
5858
7197
|
closeSync3(descriptor);
|
|
@@ -5861,10 +7200,10 @@ function writeLocalConfig(config) {
|
|
|
5861
7200
|
}
|
|
5862
7201
|
|
|
5863
7202
|
// src/local/cloud-configuration.ts
|
|
5864
|
-
var cloudConfigurationSchema =
|
|
5865
|
-
baseUrl:
|
|
5866
|
-
deviceId:
|
|
5867
|
-
token:
|
|
7203
|
+
var cloudConfigurationSchema = z7.object({
|
|
7204
|
+
baseUrl: z7.url().max(2048),
|
|
7205
|
+
deviceId: z7.uuid(),
|
|
7206
|
+
token: z7.string().min(20).max(512)
|
|
5868
7207
|
}).strict();
|
|
5869
7208
|
function cloudConfiguration(override) {
|
|
5870
7209
|
const config = override ?? readLocalConfig().cloud;
|
|
@@ -5970,7 +7309,14 @@ function iso(value) {
|
|
|
5970
7309
|
function localDatabaseFingerprint(database) {
|
|
5971
7310
|
return localDatabaseInstanceId(database);
|
|
5972
7311
|
}
|
|
5973
|
-
function projectCloudContacts(database) {
|
|
7312
|
+
function projectCloudContacts(database, options = {}) {
|
|
7313
|
+
const selected = options.personIds === undefined ? undefined : [...new Set(options.personIds)];
|
|
7314
|
+
if (selected !== undefined && (selected.length > 100 || selected.some((id2) => !Number.isSafeInteger(id2) || id2 < 1))) {
|
|
7315
|
+
throw new Error("Projection selection requires at most 100 positive canonical person IDs.");
|
|
7316
|
+
}
|
|
7317
|
+
if (selected?.length === 0)
|
|
7318
|
+
return [];
|
|
7319
|
+
const selectionSql = selected === undefined ? "" : `AND p.id IN (${selected.map(() => "?").join(",")})`;
|
|
5974
7320
|
const rows = database.query(`
|
|
5975
7321
|
WITH
|
|
5976
7322
|
${canonicalInteractionRollupCtesSql},
|
|
@@ -6137,11 +7483,12 @@ function projectCloudContacts(database) {
|
|
|
6137
7483
|
LEFT JOIN source_rollup ON source_rollup.person_id = p.id
|
|
6138
7484
|
LEFT JOIN handle_rollup ON handle_rollup.person_id = p.id
|
|
6139
7485
|
LEFT JOIN resource_eligibility ON resource_eligibility.person_id = p.id
|
|
6140
|
-
WHERE resource_eligibility.person_id IS NULL
|
|
7486
|
+
WHERE (resource_eligibility.person_id IS NULL
|
|
6141
7487
|
OR resource_eligibility.has_active_resource = 1
|
|
6142
|
-
OR canonical_interaction_rollup.person_id IS NOT NULL
|
|
7488
|
+
OR canonical_interaction_rollup.person_id IS NOT NULL)
|
|
7489
|
+
${selectionSql}
|
|
6143
7490
|
ORDER BY p.id
|
|
6144
|
-
`).all();
|
|
7491
|
+
`).all(...selected ?? []);
|
|
6145
7492
|
const instance = localDatabaseFingerprint(database);
|
|
6146
7493
|
return rows.map((row) => {
|
|
6147
7494
|
const sources = sourceLabelSchema.array().parse(parseStringArray(row.sources_json).filter((source) => sourceLabelSchema.safeParse(source).success));
|
|
@@ -6182,49 +7529,49 @@ function projectCloudContacts(database) {
|
|
|
6182
7529
|
}
|
|
6183
7530
|
|
|
6184
7531
|
// src/local/cloud-client.ts
|
|
6185
|
-
var deviceStartResponse =
|
|
7532
|
+
var deviceStartResponse = z8.object({
|
|
6186
7533
|
deviceCode: deviceCodeSchema,
|
|
6187
|
-
userCode:
|
|
6188
|
-
verificationUri:
|
|
6189
|
-
expiresIn:
|
|
6190
|
-
interval:
|
|
7534
|
+
userCode: z8.string().regex(/^[23456789A-HJ-NP-Z]{8}$/u),
|
|
7535
|
+
verificationUri: z8.url().max(2048),
|
|
7536
|
+
expiresIn: z8.number().int().min(60).max(3600),
|
|
7537
|
+
interval: z8.number().int().min(1).max(30)
|
|
6191
7538
|
}).strict();
|
|
6192
|
-
var deviceStatusResponse =
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
7539
|
+
var deviceStatusResponse = z8.discriminatedUnion("status", [
|
|
7540
|
+
z8.object({ status: z8.literal("pending") }).strict(),
|
|
7541
|
+
z8.object({ status: z8.literal("expired") }).strict(),
|
|
7542
|
+
z8.object({ status: z8.literal("authorized"), deviceId: z8.uuid() }).strict()
|
|
6196
7543
|
]);
|
|
6197
|
-
var boundedModelCoordinateSchema =
|
|
6198
|
-
var usageOperationSchema =
|
|
6199
|
-
var usageProviderSchema =
|
|
6200
|
-
var usageUnitSchema =
|
|
6201
|
-
var cliEnrichmentUsageLimitResponse =
|
|
7544
|
+
var boundedModelCoordinateSchema = z8.string().min(1).max(256).regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/u);
|
|
7545
|
+
var usageOperationSchema = z8.string().regex(/^[a-z][a-z0-9_]{0,63}$/u);
|
|
7546
|
+
var usageProviderSchema = z8.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u);
|
|
7547
|
+
var usageUnitSchema = z8.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u);
|
|
7548
|
+
var cliEnrichmentUsageLimitResponse = z8.object({
|
|
6202
7549
|
operation: usageOperationSchema,
|
|
6203
7550
|
provider: usageProviderSchema,
|
|
6204
7551
|
unit: usageUnitSchema,
|
|
6205
|
-
maximum:
|
|
7552
|
+
maximum: z8.number().int().min(0).max(100)
|
|
6206
7553
|
}).strict();
|
|
6207
|
-
var cliEnrichmentPreviewResponse =
|
|
6208
|
-
previewId:
|
|
7554
|
+
var cliEnrichmentPreviewResponse = z8.object({
|
|
7555
|
+
previewId: z8.uuid(),
|
|
6209
7556
|
confirmationToken: cliEnrichmentConfirmationTokenSchema,
|
|
6210
|
-
selectedCount:
|
|
6211
|
-
availableCredits:
|
|
6212
|
-
usageLimits:
|
|
6213
|
-
policyVersion:
|
|
7557
|
+
selectedCount: z8.number().int().min(1).max(100),
|
|
7558
|
+
availableCredits: z8.number().int().min(0),
|
|
7559
|
+
usageLimits: z8.array(cliEnrichmentUsageLimitResponse).min(1).max(16),
|
|
7560
|
+
policyVersion: z8.literal(enrichmentPolicyVersion),
|
|
6214
7561
|
model: boundedModelCoordinateSchema,
|
|
6215
|
-
expiresAt:
|
|
7562
|
+
expiresAt: z8.iso.datetime({ offset: true })
|
|
6216
7563
|
}).strict();
|
|
6217
|
-
var cliPrioritizedEnrichmentContactResponse =
|
|
6218
|
-
localPersonId:
|
|
6219
|
-
enrichmentInputVersion:
|
|
6220
|
-
enrichmentInputSha256:
|
|
6221
|
-
rank:
|
|
6222
|
-
score:
|
|
6223
|
-
components:
|
|
6224
|
-
engagement:
|
|
6225
|
-
reciprocity:
|
|
6226
|
-
dataGaps:
|
|
6227
|
-
identityReadiness:
|
|
7564
|
+
var cliPrioritizedEnrichmentContactResponse = z8.object({
|
|
7565
|
+
localPersonId: z8.string().regex(/^[1-9][0-9]{0,18}$/u),
|
|
7566
|
+
enrichmentInputVersion: z8.literal(2),
|
|
7567
|
+
enrichmentInputSha256: z8.string().regex(/^[a-f0-9]{64}$/u),
|
|
7568
|
+
rank: z8.number().int().min(1).max(100),
|
|
7569
|
+
score: z8.number().int().min(0).max(1e4),
|
|
7570
|
+
components: z8.object({
|
|
7571
|
+
engagement: z8.number().int().min(0).max(5500),
|
|
7572
|
+
reciprocity: z8.number().int().min(0).max(1000),
|
|
7573
|
+
dataGaps: z8.number().int().min(0).max(2000),
|
|
7574
|
+
identityReadiness: z8.number().int().min(0).max(1500)
|
|
6228
7575
|
}).strict()
|
|
6229
7576
|
}).strict().superRefine((value, context) => {
|
|
6230
7577
|
const total = value.components.engagement + value.components.reciprocity + value.components.dataGaps + value.components.identityReadiness;
|
|
@@ -6232,39 +7579,39 @@ var cliPrioritizedEnrichmentContactResponse = z6.object({
|
|
|
6232
7579
|
context.addIssue({ code: "custom", path: ["score"], message: "Priority score components drifted" });
|
|
6233
7580
|
});
|
|
6234
7581
|
var cliPrioritizedEnrichmentPreviewResponse = cliEnrichmentPreviewResponse.extend({
|
|
6235
|
-
contacts:
|
|
6236
|
-
priority:
|
|
6237
|
-
policyVersion:
|
|
6238
|
-
asOf:
|
|
6239
|
-
receiptSha256:
|
|
6240
|
-
eligibleCount:
|
|
6241
|
-
executionCapacity:
|
|
6242
|
-
operation:
|
|
6243
|
-
maximum:
|
|
7582
|
+
contacts: z8.array(cliPrioritizedEnrichmentContactResponse).min(1).max(100),
|
|
7583
|
+
priority: z8.object({
|
|
7584
|
+
policyVersion: z8.literal(enrichmentPriorityPolicyVersion),
|
|
7585
|
+
asOf: z8.iso.datetime({ offset: true }),
|
|
7586
|
+
receiptSha256: z8.string().regex(/^[a-f0-9]{64}$/u),
|
|
7587
|
+
eligibleCount: z8.number().int().min(1).max(1e6),
|
|
7588
|
+
executionCapacity: z8.tuple([z8.object({
|
|
7589
|
+
operation: z8.literal("enrich_email"),
|
|
7590
|
+
maximum: z8.number().int().min(0).max(100)
|
|
6244
7591
|
}).strict()])
|
|
6245
7592
|
}).strict()
|
|
6246
7593
|
}).strict();
|
|
6247
|
-
var cliEnrichmentDispatchResponse =
|
|
6248
|
-
jobId:
|
|
6249
|
-
dispatchStatus:
|
|
6250
|
-
replayed:
|
|
7594
|
+
var cliEnrichmentDispatchResponse = z8.object({
|
|
7595
|
+
jobId: z8.uuid(),
|
|
7596
|
+
dispatchStatus: z8.enum(["dispatching", "launched", "indeterminate"]),
|
|
7597
|
+
replayed: z8.boolean()
|
|
6251
7598
|
}).strict();
|
|
6252
|
-
var enrichmentFieldSchema =
|
|
6253
|
-
var usageBillingSurfaceSchema =
|
|
7599
|
+
var enrichmentFieldSchema = z8.enum(["headline", "organization", "role", "location", "website", "publicEmail"]);
|
|
7600
|
+
var usageBillingSurfaceSchema = z8.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u);
|
|
6254
7601
|
var usageModelSchema = boundedModelCoordinateSchema.nullable();
|
|
6255
|
-
var usageBillingUnitSchema =
|
|
6256
|
-
var usageBillingCertaintySchema =
|
|
6257
|
-
var usageOutcomeSchema =
|
|
6258
|
-
var usageErrorCodeSchema =
|
|
6259
|
-
var usageCostCertaintySchema =
|
|
6260
|
-
var usageExecutionRouteSchema =
|
|
6261
|
-
var usageExecutionProfileSha256Schema =
|
|
6262
|
-
var boundedUsageCount =
|
|
6263
|
-
var cliEnrichmentUsageCoverageResponse =
|
|
6264
|
-
ledgerRuns:
|
|
6265
|
-
legacyRuns:
|
|
7602
|
+
var usageBillingUnitSchema = z8.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u).nullable();
|
|
7603
|
+
var usageBillingCertaintySchema = z8.enum(["unknown", "reported", "contractual", "conservative"]);
|
|
7604
|
+
var usageOutcomeSchema = z8.string().regex(/^[a-z][a-z0-9_]{0,63}$/u);
|
|
7605
|
+
var usageErrorCodeSchema = z8.string().regex(/^[A-Z0-9][A-Z0-9_:.-]{2,159}$/u).nullable();
|
|
7606
|
+
var usageCostCertaintySchema = z8.enum(["reported", "contractual", "estimated", "unknown", "reconciled"]);
|
|
7607
|
+
var usageExecutionRouteSchema = z8.string().regex(/^[a-z][a-z0-9_]{0,63}$/u);
|
|
7608
|
+
var usageExecutionProfileSha256Schema = z8.string().regex(/^[0-9a-f]{64}$/u).nullable();
|
|
7609
|
+
var boundedUsageCount = z8.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
|
7610
|
+
var cliEnrichmentUsageCoverageResponse = z8.object({
|
|
7611
|
+
ledgerRuns: z8.number().int().min(0).max(100),
|
|
7612
|
+
legacyRuns: z8.number().int().min(0).max(100)
|
|
6266
7613
|
}).strict();
|
|
6267
|
-
var cliEnrichmentProviderUsageResponse =
|
|
7614
|
+
var cliEnrichmentProviderUsageResponse = z8.object({
|
|
6268
7615
|
operation: usageOperationSchema,
|
|
6269
7616
|
executionRoute: usageExecutionRouteSchema,
|
|
6270
7617
|
executionProfileSha256: usageExecutionProfileSha256Schema,
|
|
@@ -6282,57 +7629,57 @@ var cliEnrichmentProviderUsageResponse = z6.object({
|
|
|
6282
7629
|
inputTokens: boundedUsageCount,
|
|
6283
7630
|
outputTokens: boundedUsageCount,
|
|
6284
7631
|
billingUnit: usageBillingUnitSchema,
|
|
6285
|
-
billedUnits:
|
|
7632
|
+
billedUnits: z8.number().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
6286
7633
|
billingCertainty: usageBillingCertaintySchema,
|
|
6287
|
-
billingUnknown:
|
|
6288
|
-
knownCostUsd:
|
|
7634
|
+
billingUnknown: z8.boolean(),
|
|
7635
|
+
knownCostUsd: z8.number().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
6289
7636
|
costCertainty: usageCostCertaintySchema,
|
|
6290
7637
|
unknownCostAttempts: boundedUsageCount
|
|
6291
7638
|
}).strict();
|
|
6292
|
-
var cliEnrichmentAggregateFieldSourceResponse =
|
|
7639
|
+
var cliEnrichmentAggregateFieldSourceResponse = z8.object({
|
|
6293
7640
|
field: enrichmentFieldSchema,
|
|
6294
7641
|
operation: usageOperationSchema,
|
|
6295
7642
|
provider: usageProviderSchema,
|
|
6296
|
-
contacts:
|
|
7643
|
+
contacts: z8.number().int().min(0).max(100)
|
|
6297
7644
|
}).strict();
|
|
6298
|
-
var cliEnrichmentFailureResponse =
|
|
7645
|
+
var cliEnrichmentFailureResponse = z8.object({
|
|
6299
7646
|
operation: usageOperationSchema,
|
|
6300
|
-
count:
|
|
7647
|
+
count: z8.number().int().min(1).max(100)
|
|
6301
7648
|
}).strict();
|
|
6302
|
-
var cliEnrichmentJobStatusV4Response =
|
|
6303
|
-
id:
|
|
6304
|
-
status:
|
|
6305
|
-
dispatchStatus:
|
|
6306
|
-
selectedCount:
|
|
6307
|
-
completedCount:
|
|
6308
|
-
failedCount:
|
|
6309
|
-
productCreditsSpent:
|
|
7649
|
+
var cliEnrichmentJobStatusV4Response = z8.object({
|
|
7650
|
+
id: z8.uuid(),
|
|
7651
|
+
status: z8.enum(["queued", "running", "complete", "partial", "failed"]),
|
|
7652
|
+
dispatchStatus: z8.enum(["dispatching", "launched", "indeterminate"]),
|
|
7653
|
+
selectedCount: z8.number().int().min(1).max(100),
|
|
7654
|
+
completedCount: z8.number().int().min(0).max(100),
|
|
7655
|
+
failedCount: z8.number().int().min(0).max(100),
|
|
7656
|
+
productCreditsSpent: z8.number().int().min(0).max(100),
|
|
6310
7657
|
usageCoverage: cliEnrichmentUsageCoverageResponse,
|
|
6311
|
-
providerUsage:
|
|
6312
|
-
fieldSources:
|
|
6313
|
-
failures:
|
|
6314
|
-
createdAt:
|
|
6315
|
-
completedAt:
|
|
7658
|
+
providerUsage: z8.array(cliEnrichmentProviderUsageResponse).max(256),
|
|
7659
|
+
fieldSources: z8.array(cliEnrichmentAggregateFieldSourceResponse).max(600),
|
|
7660
|
+
failures: z8.array(cliEnrichmentFailureResponse).max(9),
|
|
7661
|
+
createdAt: z8.iso.datetime({ offset: true }),
|
|
7662
|
+
completedAt: z8.iso.datetime({ offset: true }).nullable()
|
|
6316
7663
|
}).strict();
|
|
6317
|
-
var cliEnrichmentRunDetailResponse =
|
|
6318
|
-
localPersonId:
|
|
6319
|
-
status:
|
|
6320
|
-
errorCode:
|
|
7664
|
+
var cliEnrichmentRunDetailResponse = z8.object({
|
|
7665
|
+
localPersonId: z8.string().regex(/^[1-9][0-9]{0,18}$/u),
|
|
7666
|
+
status: z8.enum(["pending", "running", "complete", "failed"]),
|
|
7667
|
+
errorCode: z8.string().min(1).max(200).nullable(),
|
|
6321
7668
|
failureOperation: usageOperationSchema.nullable(),
|
|
6322
|
-
identityMatch:
|
|
6323
|
-
confidence:
|
|
6324
|
-
fieldsPresent:
|
|
6325
|
-
claimFields:
|
|
7669
|
+
identityMatch: z8.enum(["confirmed", "possible", "insufficient"]).nullable(),
|
|
7670
|
+
confidence: z8.number().int().min(0).max(100).nullable(),
|
|
7671
|
+
fieldsPresent: z8.array(enrichmentFieldSchema).max(6),
|
|
7672
|
+
claimFields: z8.array(enrichmentFieldSchema).max(6)
|
|
6326
7673
|
}).strict();
|
|
6327
|
-
var cliEnrichmentRunUsageResponse =
|
|
7674
|
+
var cliEnrichmentRunUsageResponse = z8.object({
|
|
6328
7675
|
operation: usageOperationSchema,
|
|
6329
7676
|
executionRoute: usageExecutionRouteSchema,
|
|
6330
7677
|
executionProfileSha256: usageExecutionProfileSha256Schema,
|
|
6331
|
-
attempt:
|
|
7678
|
+
attempt: z8.number().int().min(1).max(16),
|
|
6332
7679
|
billingSurface: usageBillingSurfaceSchema,
|
|
6333
7680
|
provider: usageProviderSchema,
|
|
6334
7681
|
model: usageModelSchema,
|
|
6335
|
-
status:
|
|
7682
|
+
status: z8.enum(["open", "succeeded", "failed", "indeterminate"]),
|
|
6336
7683
|
outcome: usageOutcomeSchema,
|
|
6337
7684
|
errorCode: usageErrorCodeSchema,
|
|
6338
7685
|
results: boundedUsageCount,
|
|
@@ -6340,10 +7687,10 @@ var cliEnrichmentRunUsageResponse = z6.object({
|
|
|
6340
7687
|
inputTokens: boundedUsageCount,
|
|
6341
7688
|
outputTokens: boundedUsageCount,
|
|
6342
7689
|
billingUnit: usageBillingUnitSchema,
|
|
6343
|
-
billedUnits:
|
|
7690
|
+
billedUnits: z8.number().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
6344
7691
|
billingCertainty: usageBillingCertaintySchema,
|
|
6345
|
-
billingUnknown:
|
|
6346
|
-
costUsd:
|
|
7692
|
+
billingUnknown: z8.boolean(),
|
|
7693
|
+
costUsd: z8.number().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
|
|
6347
7694
|
costCertainty: usageCostCertaintySchema
|
|
6348
7695
|
}).strict().superRefine((value, context) => {
|
|
6349
7696
|
if ((value.status === "open" || value.status === "succeeded") && value.errorCode !== null) {
|
|
@@ -6354,22 +7701,31 @@ var cliEnrichmentRunUsageResponse = z6.object({
|
|
|
6354
7701
|
});
|
|
6355
7702
|
}
|
|
6356
7703
|
});
|
|
6357
|
-
var cliEnrichmentRunFieldSourceResponse =
|
|
7704
|
+
var cliEnrichmentRunFieldSourceResponse = z8.object({
|
|
6358
7705
|
field: enrichmentFieldSchema,
|
|
6359
|
-
sources:
|
|
7706
|
+
sources: z8.array(z8.object({
|
|
6360
7707
|
operation: usageOperationSchema,
|
|
6361
7708
|
provider: usageProviderSchema,
|
|
6362
|
-
evidenceRows:
|
|
7709
|
+
evidenceRows: z8.number().int().min(1).max(5)
|
|
6363
7710
|
}).strict()).max(5)
|
|
6364
7711
|
}).strict();
|
|
6365
|
-
var cliEnrichmentJobDetailsV4Response =
|
|
6366
|
-
jobId:
|
|
6367
|
-
runs:
|
|
6368
|
-
usageCoverage:
|
|
6369
|
-
usage:
|
|
6370
|
-
fieldSources:
|
|
7712
|
+
var cliEnrichmentJobDetailsV4Response = z8.object({
|
|
7713
|
+
jobId: z8.uuid(),
|
|
7714
|
+
runs: z8.array(cliEnrichmentRunDetailResponse.extend({
|
|
7715
|
+
usageCoverage: z8.enum(["ledger", "legacy"]),
|
|
7716
|
+
usage: z8.array(cliEnrichmentRunUsageResponse).max(512),
|
|
7717
|
+
fieldSources: z8.array(cliEnrichmentRunFieldSourceResponse).max(6)
|
|
6371
7718
|
}).strict()).max(100)
|
|
6372
7719
|
}).strict();
|
|
7720
|
+
var cliEnrichmentPublicEmailLeftoverResponse = z8.object({
|
|
7721
|
+
localPersonId: z8.string().regex(/^[1-9][0-9]{0,18}$/u),
|
|
7722
|
+
runId: z8.uuid(),
|
|
7723
|
+
publicEmail: z8.email().max(1024),
|
|
7724
|
+
completedAt: z8.iso.datetime({ offset: true })
|
|
7725
|
+
}).strict();
|
|
7726
|
+
var cliEnrichmentPublicEmailsResponse = z8.object({
|
|
7727
|
+
publicEmails: z8.array(cliEnrichmentPublicEmailLeftoverResponse).max(1e4)
|
|
7728
|
+
}).strict();
|
|
6373
7729
|
function baseUrl(value) {
|
|
6374
7730
|
const parsed = new URL(value);
|
|
6375
7731
|
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["localhost", "127.0.0.1"].includes(parsed.hostname))) {
|
|
@@ -6393,7 +7749,7 @@ async function openBrowser(url) {
|
|
|
6393
7749
|
async function signInCloud(origin = "https://peopleblade.com", options = {}) {
|
|
6394
7750
|
const fetcher = options.fetcher ?? fetch;
|
|
6395
7751
|
const root = baseUrl(origin);
|
|
6396
|
-
const rawToken =
|
|
7752
|
+
const rawToken = randomBytes2(32).toString("base64url");
|
|
6397
7753
|
const tokenSha256 = createHash2("sha256").update(rawToken).digest("hex");
|
|
6398
7754
|
const started = deviceStartResponse.parse(await postJson(fetcher, `${root}/api/cli/device/start`, { deviceName: defaultDeviceName(), tokenSha256 }));
|
|
6399
7755
|
options.onCode?.(started.userCode, started.verificationUri);
|
|
@@ -6464,7 +7820,7 @@ function selectedLocalContacts(database, personIds, config) {
|
|
|
6464
7820
|
WHERE member.canonical_person_id IN (${placeholders}) AND person.do_not_contact = 1`).get(...unique);
|
|
6465
7821
|
if (doNotContact.count > 0)
|
|
6466
7822
|
throw new Error("Selection contains a do-not-contact identity.");
|
|
6467
|
-
const contacts = projectCloudContacts(database);
|
|
7823
|
+
const contacts = projectCloudContacts(database, { personIds: unique });
|
|
6468
7824
|
const byId = new Map(contacts.map((contact) => [Number(contact.localPersonId), contact]));
|
|
6469
7825
|
const selected = unique.map((personId) => byId.get(personId));
|
|
6470
7826
|
if (selected.some((contact) => contact === undefined)) {
|
|
@@ -6553,6 +7909,11 @@ async function cloudEnrichmentDetails(jobId, options = {}) {
|
|
|
6553
7909
|
const raw = await postJson(options.fetcher ?? fetch, `${config.baseUrl}/api/cli/enrich/details?v=${cliEnrichmentReadContractVersion}`, input, config.token);
|
|
6554
7910
|
return cliEnrichmentJobDetailsV4Response.parse(raw);
|
|
6555
7911
|
}
|
|
7912
|
+
async function cloudEnrichmentPublicEmails(options = {}) {
|
|
7913
|
+
const config = cloudConfiguration(options.configuration);
|
|
7914
|
+
const raw = await postJson(options.fetcher ?? fetch, `${config.baseUrl}/api/cli/enrich/public-emails?v=${cliEnrichmentReadContractVersion}`, {}, config.token);
|
|
7915
|
+
return cliEnrichmentPublicEmailsResponse.parse(raw);
|
|
7916
|
+
}
|
|
6556
7917
|
var cloudEnrichmentRevalidationBatchLimit = 10;
|
|
6557
7918
|
var maximumCloudEnrichmentRevalidationBatches = 1000;
|
|
6558
7919
|
function addBoundedRevalidationCount(left, right) {
|
|
@@ -6601,6 +7962,20 @@ async function revalidateHistoricalCloudEnrichment(options = {}) {
|
|
|
6601
7962
|
}
|
|
6602
7963
|
throw new Error("PeopleBlade historical enrichment revalidation exceeded its batch limit.");
|
|
6603
7964
|
}
|
|
7965
|
+
|
|
7966
|
+
class CloudEnrichmentReadError extends Error {
|
|
7967
|
+
jobId;
|
|
7968
|
+
dispatchStatus;
|
|
7969
|
+
nextCommand;
|
|
7970
|
+
constructor(jobId, dispatchStatus, cause) {
|
|
7971
|
+
const nextCommand = `peopleblade cloud enrich --status ${jobId} --json`;
|
|
7972
|
+
super(`Enrichment dispatch was accepted as ${jobId}, but status could not be read. Do not repeat dispatch; run ${nextCommand}.`, { cause });
|
|
7973
|
+
this.jobId = jobId;
|
|
7974
|
+
this.dispatchStatus = dispatchStatus;
|
|
7975
|
+
this.name = "CloudEnrichmentReadError";
|
|
7976
|
+
this.nextCommand = nextCommand;
|
|
7977
|
+
}
|
|
7978
|
+
}
|
|
6604
7979
|
async function startCloudEnrichment(database, confirmation, personIds, options = {}) {
|
|
6605
7980
|
const match = /^([0-9a-f-]{36})\.([A-Za-z0-9_-]{43})$/u.exec(confirmation);
|
|
6606
7981
|
if (match === null)
|
|
@@ -6617,7 +7992,14 @@ async function startCloudEnrichment(database, confirmation, personIds, options =
|
|
|
6617
7992
|
enrichmentInputSha256: contact.enrichmentInputSha256
|
|
6618
7993
|
}))
|
|
6619
7994
|
}, config.token));
|
|
6620
|
-
|
|
7995
|
+
const readStatus = async () => {
|
|
7996
|
+
try {
|
|
7997
|
+
return await cloudEnrichmentStatus(dispatch.jobId, { fetcher, configuration: config });
|
|
7998
|
+
} catch (cause) {
|
|
7999
|
+
throw new CloudEnrichmentReadError(dispatch.jobId, dispatch.dispatchStatus, cause);
|
|
8000
|
+
}
|
|
8001
|
+
};
|
|
8002
|
+
let status = await readStatus();
|
|
6621
8003
|
if (options.wait === false || dispatch.dispatchStatus !== "launched")
|
|
6622
8004
|
return { dispatch, status };
|
|
6623
8005
|
const pollIntervalMs = Math.max(250, Math.min(30000, options.pollIntervalMs ?? 2000));
|
|
@@ -6626,16 +8008,16 @@ async function startCloudEnrichment(database, confirmation, personIds, options =
|
|
|
6626
8008
|
if (["complete", "partial", "failed"].includes(status.status))
|
|
6627
8009
|
return { dispatch, status };
|
|
6628
8010
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
6629
|
-
status = await
|
|
8011
|
+
status = await readStatus();
|
|
6630
8012
|
}
|
|
6631
8013
|
throw new Error(`Enrichment job ${dispatch.jobId} is still running; inspect it with cloud enrich --status.`);
|
|
6632
8014
|
}
|
|
6633
8015
|
|
|
6634
8016
|
// src/local/database.ts
|
|
6635
8017
|
import { Database } from "bun:sqlite";
|
|
6636
|
-
import
|
|
8018
|
+
import crypto2 from "crypto";
|
|
6637
8019
|
import {
|
|
6638
|
-
chmodSync,
|
|
8020
|
+
chmodSync as chmodSync2,
|
|
6639
8021
|
closeSync as closeSync4,
|
|
6640
8022
|
constants as constants4,
|
|
6641
8023
|
existsSync as existsSync2,
|
|
@@ -6643,17 +8025,17 @@ import {
|
|
|
6643
8025
|
fstatSync as fstatSync3,
|
|
6644
8026
|
fsyncSync,
|
|
6645
8027
|
linkSync,
|
|
6646
|
-
lstatSync,
|
|
8028
|
+
lstatSync as lstatSync2,
|
|
6647
8029
|
mkdirSync as mkdirSync2,
|
|
6648
8030
|
openSync as openSync4,
|
|
6649
8031
|
readFileSync as readFileSync4,
|
|
6650
8032
|
realpathSync,
|
|
6651
8033
|
readdirSync,
|
|
6652
8034
|
statSync,
|
|
6653
|
-
unlinkSync,
|
|
6654
|
-
writeFileSync as
|
|
8035
|
+
unlinkSync as unlinkSync2,
|
|
8036
|
+
writeFileSync as writeFileSync3
|
|
6655
8037
|
} from "fs";
|
|
6656
|
-
import { basename, dirname as dirname2, join as
|
|
8038
|
+
import { basename, dirname as dirname2, join as join3, resolve } from "path";
|
|
6657
8039
|
import { fileURLToPath } from "url";
|
|
6658
8040
|
|
|
6659
8041
|
// src/local/phone-identity-migration.ts
|
|
@@ -7150,7 +8532,7 @@ function convergeLegacyProviderAccounts(database) {
|
|
|
7150
8532
|
}
|
|
7151
8533
|
|
|
7152
8534
|
// src/local/database.ts
|
|
7153
|
-
var migrationsDirectory =
|
|
8535
|
+
var migrationsDirectory = join3(dirname2(fileURLToPath(import.meta.url)), "migrations");
|
|
7154
8536
|
function migrationNames() {
|
|
7155
8537
|
return readdirSync(migrationsDirectory).filter((name) => name.endsWith(".sql")).sort();
|
|
7156
8538
|
}
|
|
@@ -7160,18 +8542,18 @@ function secureDirectory(path) {
|
|
|
7160
8542
|
const standardRoot = resolve(peoplebladeDirectory());
|
|
7161
8543
|
const physical = resolve(path);
|
|
7162
8544
|
if (!existed || physical === standardRoot || physical.startsWith(`${standardRoot}/`))
|
|
7163
|
-
|
|
8545
|
+
chmodSync2(path, 448);
|
|
7164
8546
|
}
|
|
7165
8547
|
function assertSecureBackupDirectory(path) {
|
|
7166
8548
|
const existed = existsSync2(path);
|
|
7167
8549
|
if (existed)
|
|
7168
|
-
assertSecureBackupDirectoryIdentity(
|
|
8550
|
+
assertSecureBackupDirectoryIdentity(lstatSync2(path), true);
|
|
7169
8551
|
secureDirectory(path);
|
|
7170
8552
|
const physical = realpathSync(path);
|
|
7171
|
-
assertSecureBackupDirectoryIdentity(
|
|
8553
|
+
assertSecureBackupDirectoryIdentity(lstatSync2(physical), true);
|
|
7172
8554
|
let ancestor = dirname2(physical);
|
|
7173
8555
|
while (true) {
|
|
7174
|
-
const identity =
|
|
8556
|
+
const identity = lstatSync2(ancestor);
|
|
7175
8557
|
assertSecureBackupDirectoryIdentity(identity, false);
|
|
7176
8558
|
const parent = dirname2(ancestor);
|
|
7177
8559
|
if (parent === ancestor)
|
|
@@ -7209,12 +8591,12 @@ function assertSecureFileIdentity(identity) {
|
|
|
7209
8591
|
}
|
|
7210
8592
|
}
|
|
7211
8593
|
function secureFile(path) {
|
|
7212
|
-
const identity =
|
|
8594
|
+
const identity = lstatSync2(path);
|
|
7213
8595
|
assertSecureFileIdentity(identity);
|
|
7214
|
-
|
|
8596
|
+
chmodSync2(path, 384);
|
|
7215
8597
|
}
|
|
7216
8598
|
function assertSameTemporaryFile(path, expected) {
|
|
7217
|
-
const current =
|
|
8599
|
+
const current = lstatSync2(path);
|
|
7218
8600
|
assertSecureFileIdentity(current);
|
|
7219
8601
|
if (current.dev !== expected.dev || current.ino !== expected.ino) {
|
|
7220
8602
|
throw new Error("PeopleBlade backup temporary file was replaced.");
|
|
@@ -7225,7 +8607,7 @@ function temporarySnapshotSidecars(path) {
|
|
|
7225
8607
|
}
|
|
7226
8608
|
function pathEntryExists(path) {
|
|
7227
8609
|
try {
|
|
7228
|
-
|
|
8610
|
+
lstatSync2(path);
|
|
7229
8611
|
return true;
|
|
7230
8612
|
} catch (error) {
|
|
7231
8613
|
if (error.code === "ENOENT")
|
|
@@ -7235,7 +8617,7 @@ function pathEntryExists(path) {
|
|
|
7235
8617
|
}
|
|
7236
8618
|
function unlinkIfPresent(path) {
|
|
7237
8619
|
try {
|
|
7238
|
-
|
|
8620
|
+
unlinkSync2(path);
|
|
7239
8621
|
} catch (error) {
|
|
7240
8622
|
if (error.code !== "ENOENT")
|
|
7241
8623
|
throw error;
|
|
@@ -7244,7 +8626,7 @@ function unlinkIfPresent(path) {
|
|
|
7244
8626
|
function unlinkTemporarySidecarIfPresent(path) {
|
|
7245
8627
|
let identity;
|
|
7246
8628
|
try {
|
|
7247
|
-
identity =
|
|
8629
|
+
identity = lstatSync2(path);
|
|
7248
8630
|
} catch (error) {
|
|
7249
8631
|
if (error.code === "ENOENT")
|
|
7250
8632
|
return;
|
|
@@ -7255,7 +8637,7 @@ function unlinkTemporarySidecarIfPresent(path) {
|
|
|
7255
8637
|
} catch {
|
|
7256
8638
|
throw new Error("PeopleBlade backup temporary SQLite sidecar was not a safe owned file.");
|
|
7257
8639
|
}
|
|
7258
|
-
|
|
8640
|
+
unlinkSync2(path);
|
|
7259
8641
|
}
|
|
7260
8642
|
function assertNoTemporarySidecars(path) {
|
|
7261
8643
|
for (const sidecar of temporarySnapshotSidecars(path)) {
|
|
@@ -7321,7 +8703,7 @@ function migrateLocalDatabase(database) {
|
|
|
7321
8703
|
continue;
|
|
7322
8704
|
database.exec("BEGIN IMMEDIATE");
|
|
7323
8705
|
try {
|
|
7324
|
-
database.exec(readFileSync4(
|
|
8706
|
+
database.exec(readFileSync4(join3(migrationsDirectory, filename), "utf8"));
|
|
7325
8707
|
database.query("INSERT INTO schema_migrations(version) VALUES (?)").run(filename);
|
|
7326
8708
|
database.exec("COMMIT");
|
|
7327
8709
|
} catch (error) {
|
|
@@ -7378,7 +8760,7 @@ function backupLocalDatabase(sourcePath, destinationPath) {
|
|
|
7378
8760
|
if (source === requestedDestination)
|
|
7379
8761
|
throw new Error("Backup destination cannot be the source database.");
|
|
7380
8762
|
const destinationDirectory = assertSecureBackupDirectory(dirname2(requestedDestination));
|
|
7381
|
-
const destination =
|
|
8763
|
+
const destination = join3(destinationDirectory, basename(requestedDestination));
|
|
7382
8764
|
if (existsSync2(destination))
|
|
7383
8765
|
throw new Error(`Backup already exists: ${destination}`);
|
|
7384
8766
|
if (source === destination)
|
|
@@ -7397,14 +8779,14 @@ function backupLocalDatabase(sourcePath, destinationPath) {
|
|
|
7397
8779
|
} finally {
|
|
7398
8780
|
database.close();
|
|
7399
8781
|
}
|
|
7400
|
-
const temporary =
|
|
8782
|
+
const temporary = join3(destinationDirectory, `.peopleblade-backup-${process.pid}-${crypto2.randomBytes(8).toString("hex")}`);
|
|
7401
8783
|
const descriptor = openSync4(temporary, constants4.O_WRONLY | constants4.O_CREAT | constants4.O_EXCL | (constants4.O_NOFOLLOW ?? 0), 384);
|
|
7402
8784
|
try {
|
|
7403
8785
|
try {
|
|
7404
8786
|
fchmodSync(descriptor, 384);
|
|
7405
8787
|
const identity = fstatSync3(descriptor);
|
|
7406
8788
|
assertNoTemporarySidecars(temporary);
|
|
7407
|
-
|
|
8789
|
+
writeFileSync3(descriptor, bytes);
|
|
7408
8790
|
fsyncSync(descriptor);
|
|
7409
8791
|
normalizeAndValidateSnapshot(temporary, identity);
|
|
7410
8792
|
fsyncSync(descriptor);
|
|
@@ -7412,7 +8794,7 @@ function backupLocalDatabase(sourcePath, destinationPath) {
|
|
|
7412
8794
|
closeSync4(descriptor);
|
|
7413
8795
|
}
|
|
7414
8796
|
linkSync(temporary, destination);
|
|
7415
|
-
|
|
8797
|
+
unlinkSync2(temporary);
|
|
7416
8798
|
} catch (error) {
|
|
7417
8799
|
unlinkIfPresent(temporary);
|
|
7418
8800
|
for (const sidecar of temporarySnapshotSidecars(temporary))
|
|
@@ -7431,11 +8813,11 @@ function standardBackupPath(label, databasePath = peoplebladeDatabasePath()) {
|
|
|
7431
8813
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(label))
|
|
7432
8814
|
throw new Error("Backup label is invalid.");
|
|
7433
8815
|
const timestamp2 = new Date().toISOString().replaceAll(/[-:.]/gu, "").replace("Z", "Z");
|
|
7434
|
-
return
|
|
8816
|
+
return join3(dirname2(databasePath), "backups", `${label}-${timestamp2}.sqlite3`);
|
|
7435
8817
|
}
|
|
7436
8818
|
|
|
7437
8819
|
// src/local/ensoul.ts
|
|
7438
|
-
import { randomBytes as
|
|
8820
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
7439
8821
|
import {
|
|
7440
8822
|
closeSync as closeSync5,
|
|
7441
8823
|
constants as constants5,
|
|
@@ -7443,25 +8825,25 @@ import {
|
|
|
7443
8825
|
fstatSync as fstatSync4,
|
|
7444
8826
|
fsyncSync as fsyncSync2,
|
|
7445
8827
|
linkSync as linkSync2,
|
|
7446
|
-
lstatSync as
|
|
8828
|
+
lstatSync as lstatSync3,
|
|
7447
8829
|
openSync as openSync5,
|
|
7448
8830
|
realpathSync as realpathSync2,
|
|
7449
|
-
unlinkSync as
|
|
7450
|
-
writeFileSync as
|
|
8831
|
+
unlinkSync as unlinkSync3,
|
|
8832
|
+
writeFileSync as writeFileSync4
|
|
7451
8833
|
} from "fs";
|
|
7452
|
-
import { basename as basename2, dirname as dirname3, join as
|
|
8834
|
+
import { basename as basename2, dirname as dirname3, join as join4, resolve as resolve2 } from "path";
|
|
7453
8835
|
|
|
7454
8836
|
// src/lib/ensoul-contracts.ts
|
|
7455
|
-
import { z as
|
|
8837
|
+
import { z as z9 } from "zod";
|
|
7456
8838
|
var ensoulSourcePacketVersion = "ensoul.source-packet.v1";
|
|
7457
8839
|
var peoplebladeEnsoulAdapter = "peopleblade";
|
|
7458
8840
|
var peoplebladeEnsoulPayloadSchema = "ensoul.public-enrichment-source.v1";
|
|
7459
8841
|
var ensoulDigestCanonicalization = "JCS-RFC8785";
|
|
7460
|
-
var digestSchema =
|
|
7461
|
-
var prefixedDigestSchema =
|
|
7462
|
-
var timestampSchema =
|
|
7463
|
-
var boundedText2 = (maximum) =>
|
|
7464
|
-
var positiveSafeInteger =
|
|
8842
|
+
var digestSchema = z9.string().regex(/^[a-f0-9]{64}$/u);
|
|
8843
|
+
var prefixedDigestSchema = z9.string().regex(/^sha256:[a-f0-9]{64}$/u);
|
|
8844
|
+
var timestampSchema = z9.iso.datetime({ offset: true });
|
|
8845
|
+
var boundedText2 = (maximum) => z9.string().trim().min(1).max(maximum);
|
|
8846
|
+
var positiveSafeInteger = z9.number().int().positive().max(Number.MAX_SAFE_INTEGER);
|
|
7465
8847
|
function assertWellFormedUnicode(value) {
|
|
7466
8848
|
for (let index = 0;index < value.length; index += 1) {
|
|
7467
8849
|
const code = value.charCodeAt(index);
|
|
@@ -7503,7 +8885,7 @@ function ensoulJcsCanonicalJson(value) {
|
|
|
7503
8885
|
}
|
|
7504
8886
|
throw new Error("JCS input is not valid JSON.");
|
|
7505
8887
|
}
|
|
7506
|
-
var ensoulSourceClassSchema =
|
|
8888
|
+
var ensoulSourceClassSchema = z9.enum([
|
|
7507
8889
|
"private_capture",
|
|
7508
8890
|
"polished_self_presentation",
|
|
7509
8891
|
"observed_behavior",
|
|
@@ -7512,24 +8894,24 @@ var ensoulSourceClassSchema = z7.enum([
|
|
|
7512
8894
|
"metadata",
|
|
7513
8895
|
"public_web_evidence"
|
|
7514
8896
|
]);
|
|
7515
|
-
var ensoulSourceRecordSemanticSchema =
|
|
8897
|
+
var ensoulSourceRecordSemanticSchema = z9.object({
|
|
7516
8898
|
id: boundedText2(200),
|
|
7517
8899
|
kind: boundedText2(100),
|
|
7518
8900
|
occurredAt: timestampSchema.optional(),
|
|
7519
8901
|
observedAt: timestampSchema.optional(),
|
|
7520
|
-
authorRole:
|
|
7521
|
-
contentRole:
|
|
7522
|
-
authorshipConfidence:
|
|
7523
|
-
sentStatus:
|
|
7524
|
-
visibility:
|
|
8902
|
+
authorRole: z9.enum(["subject", "counterpart", "third_party", "mixed", "unknown"]),
|
|
8903
|
+
contentRole: z9.enum(["original", "quoted", "forwarded", "summary", "ai_assisted", "mixed", "unknown"]),
|
|
8904
|
+
authorshipConfidence: z9.enum(["verified", "strong", "weak", "unknown"]),
|
|
8905
|
+
sentStatus: z9.enum(["sent", "draft", "received", "published", "unknown"]),
|
|
8906
|
+
visibility: z9.enum(["public", "private"]),
|
|
7525
8907
|
sourceClass: ensoulSourceClassSchema,
|
|
7526
|
-
content:
|
|
7527
|
-
text:
|
|
7528
|
-
title:
|
|
8908
|
+
content: z9.object({
|
|
8909
|
+
text: z9.string().max(50000).optional(),
|
|
8910
|
+
title: z9.string().max(1000).optional(),
|
|
7529
8911
|
url: httpUrlSchema.optional(),
|
|
7530
|
-
truncated:
|
|
8912
|
+
truncated: z9.boolean().optional()
|
|
7531
8913
|
}).strict().refine((content) => content.text !== undefined || content.title !== undefined || content.url !== undefined, "Record content requires text, title, or URL"),
|
|
7532
|
-
provenance:
|
|
8914
|
+
provenance: z9.object({
|
|
7533
8915
|
provider: boundedText2(100),
|
|
7534
8916
|
operation: boundedText2(160).optional(),
|
|
7535
8917
|
sourceId: boundedText2(300).optional(),
|
|
@@ -7542,17 +8924,17 @@ var ensoulSourceRecordSemanticSchema = z7.object({
|
|
|
7542
8924
|
var ensoulSourceRecordSchema = ensoulSourceRecordSemanticSchema.extend({
|
|
7543
8925
|
digest: prefixedDigestSchema
|
|
7544
8926
|
}).strict();
|
|
7545
|
-
var ensoulClaimSchema =
|
|
8927
|
+
var ensoulClaimSchema = z9.object({
|
|
7546
8928
|
id: boundedText2(200),
|
|
7547
8929
|
text: boundedText2(4000),
|
|
7548
|
-
recordIds:
|
|
7549
|
-
status:
|
|
7550
|
-
claimantRole:
|
|
7551
|
-
claimKind:
|
|
8930
|
+
recordIds: z9.array(boundedText2(200)).min(1).max(50).refine((values) => new Set(values).size === values.length, "Claim record IDs must be unique"),
|
|
8931
|
+
status: z9.enum(["source_reported", "adapter_structured", "contested"]),
|
|
8932
|
+
claimantRole: z9.enum(["subject", "counterpart", "third_party", "institutional", "adapter", "unknown"]),
|
|
8933
|
+
claimKind: z9.enum(["fact", "stated_belief", "reported_observation", "derived_index"]),
|
|
7552
8934
|
subjectLocalId: boundedText2(200),
|
|
7553
|
-
sensitivity:
|
|
8935
|
+
sensitivity: z9.enum(["ordinary", "sensitive_explicit"])
|
|
7554
8936
|
}).strict();
|
|
7555
|
-
var ensoulLimitationSchema =
|
|
8937
|
+
var ensoulLimitationSchema = z9.enum([
|
|
7556
8938
|
"Public-web evidence plus bounded local subject background only; this packet is not a complete account of the person.",
|
|
7557
8939
|
"Web authorship is unknown; no record is a direct subject voice sample.",
|
|
7558
8940
|
"Private messages and CRM notes are excluded.",
|
|
@@ -7560,41 +8942,41 @@ var ensoulLimitationSchema = z7.enum([
|
|
|
7560
8942
|
"Raw provider payloads, credentials, and unrelated contacts are excluded.",
|
|
7561
8943
|
"Do not infer sensitive traits, diagnoses, consent, or permission to act from this packet."
|
|
7562
8944
|
]);
|
|
7563
|
-
var peoplebladeEnsoulPacketBodySchema =
|
|
7564
|
-
schemaVersion:
|
|
7565
|
-
digestCanonicalization:
|
|
8945
|
+
var peoplebladeEnsoulPacketBodySchema = z9.object({
|
|
8946
|
+
schemaVersion: z9.literal(ensoulSourcePacketVersion),
|
|
8947
|
+
digestCanonicalization: z9.literal(ensoulDigestCanonicalization),
|
|
7566
8948
|
generatedAt: timestampSchema,
|
|
7567
|
-
subject:
|
|
8949
|
+
subject: z9.object({
|
|
7568
8950
|
localId: boundedText2(200),
|
|
7569
|
-
kind:
|
|
8951
|
+
kind: z9.literal("person"),
|
|
7570
8952
|
displayName: boundedText2(300).optional(),
|
|
7571
8953
|
identityBasis: boundedText2(1000)
|
|
7572
8954
|
}).strict(),
|
|
7573
|
-
scope:
|
|
7574
|
-
adapter:
|
|
7575
|
-
payloadSchema:
|
|
8955
|
+
scope: z9.object({
|
|
8956
|
+
adapter: z9.literal(peoplebladeEnsoulAdapter),
|
|
8957
|
+
payloadSchema: z9.literal(peoplebladeEnsoulPayloadSchema),
|
|
7576
8958
|
asOf: timestampSchema,
|
|
7577
8959
|
sourceCutoff: timestampSchema.optional(),
|
|
7578
|
-
completeness:
|
|
8960
|
+
completeness: z9.enum(["complete", "sampled", "bounded", "unknown"]),
|
|
7579
8961
|
sourceRevision: boundedText2(300),
|
|
7580
|
-
limits:
|
|
8962
|
+
limits: z9.object({
|
|
7581
8963
|
maxCurrentEnrichments: positiveSafeInteger,
|
|
7582
8964
|
maxRecords: positiveSafeInteger,
|
|
7583
8965
|
maxClaims: positiveSafeInteger,
|
|
7584
8966
|
maxPacketBytes: positiveSafeInteger,
|
|
7585
8967
|
oldestObservedAt: timestampSchema.nullable(),
|
|
7586
|
-
exactContactCoordinates:
|
|
7587
|
-
notes:
|
|
7588
|
-
messages:
|
|
7589
|
-
rawProviderPayloads:
|
|
8968
|
+
exactContactCoordinates: z9.literal("excluded-or-redacted"),
|
|
8969
|
+
notes: z9.literal("excluded"),
|
|
8970
|
+
messages: z9.literal("excluded"),
|
|
8971
|
+
rawProviderPayloads: z9.literal("excluded")
|
|
7590
8972
|
}).strict()
|
|
7591
8973
|
}).strict(),
|
|
7592
|
-
records:
|
|
7593
|
-
claims:
|
|
7594
|
-
limitations:
|
|
8974
|
+
records: z9.array(ensoulSourceRecordSchema).max(2000),
|
|
8975
|
+
claims: z9.array(ensoulClaimSchema).max(500),
|
|
8976
|
+
limitations: z9.array(ensoulLimitationSchema).min(1).max(32)
|
|
7595
8977
|
}).strict();
|
|
7596
8978
|
var peoplebladeEnsoulPacketSemanticSchema = peoplebladeEnsoulPacketBodySchema.extend({
|
|
7597
|
-
packetId:
|
|
8979
|
+
packetId: z9.string().trim().min(8).max(160)
|
|
7598
8980
|
}).strict();
|
|
7599
8981
|
var ensoulSourcePacketSchema = peoplebladeEnsoulPacketSemanticSchema.extend({
|
|
7600
8982
|
packetDigest: prefixedDigestSchema
|
|
@@ -8042,7 +9424,7 @@ function buildPeoplebladeEnsoulSourcePacket(database, requestedPersonId, options
|
|
|
8042
9424
|
}
|
|
8043
9425
|
function pathEntryExists2(path) {
|
|
8044
9426
|
try {
|
|
8045
|
-
|
|
9427
|
+
lstatSync3(path);
|
|
8046
9428
|
return true;
|
|
8047
9429
|
} catch (error) {
|
|
8048
9430
|
if (error.code === "ENOENT")
|
|
@@ -8051,7 +9433,7 @@ function pathEntryExists2(path) {
|
|
|
8051
9433
|
}
|
|
8052
9434
|
}
|
|
8053
9435
|
function assertPrivateOutputDirectory(path) {
|
|
8054
|
-
const identity =
|
|
9436
|
+
const identity = lstatSync3(path);
|
|
8055
9437
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
8056
9438
|
if (!identity.isDirectory() || identity.isSymbolicLink() || uid === null || identity.uid !== uid || (identity.mode & 18) !== 0)
|
|
8057
9439
|
throw new Error("Ensoul output directory must be a private, owned directory and not a symlink.");
|
|
@@ -8061,9 +9443,9 @@ function unlinkExpectedInode(path, expected) {
|
|
|
8061
9443
|
if (expected === null)
|
|
8062
9444
|
return;
|
|
8063
9445
|
try {
|
|
8064
|
-
const current =
|
|
9446
|
+
const current = lstatSync3(path);
|
|
8065
9447
|
if (current.dev === expected.dev && current.ino === expected.ino)
|
|
8066
|
-
|
|
9448
|
+
unlinkSync3(path);
|
|
8067
9449
|
} catch (error) {
|
|
8068
9450
|
if (error.code !== "ENOENT")
|
|
8069
9451
|
throw error;
|
|
@@ -8077,10 +9459,10 @@ function writePrivatePacket(path, contents) {
|
|
|
8077
9459
|
if (filename === "." || filename === "..")
|
|
8078
9460
|
throw new Error("Ensoul output requires a file path.");
|
|
8079
9461
|
const directory = assertPrivateOutputDirectory(dirname3(requested));
|
|
8080
|
-
const destination =
|
|
9462
|
+
const destination = join4(directory, filename);
|
|
8081
9463
|
if (pathEntryExists2(destination))
|
|
8082
9464
|
throw new Error("Ensoul output already exists; refusing to overwrite it.");
|
|
8083
|
-
const temporary =
|
|
9465
|
+
const temporary = join4(directory, `.peopleblade-ensoul-${process.pid}-${randomBytes3(12).toString("hex")}`);
|
|
8084
9466
|
let identity = null;
|
|
8085
9467
|
const descriptor = openSync5(temporary, constants5.O_WRONLY | constants5.O_CREAT | constants5.O_EXCL | (constants5.O_NOFOLLOW ?? 0), 384);
|
|
8086
9468
|
try {
|
|
@@ -8090,7 +9472,7 @@ function writePrivatePacket(path, contents) {
|
|
|
8090
9472
|
if (!identity.isFile() || identity.nlink !== 1 || uid === null || identity.uid !== uid) {
|
|
8091
9473
|
throw new Error("Ensoul output staging file is not a private, owned regular file.");
|
|
8092
9474
|
}
|
|
8093
|
-
|
|
9475
|
+
writeFileSync4(descriptor, contents, "utf8");
|
|
8094
9476
|
fsyncSync2(descriptor);
|
|
8095
9477
|
} catch (error) {
|
|
8096
9478
|
unlinkExpectedInode(temporary, identity);
|
|
@@ -8103,8 +9485,8 @@ function writePrivatePacket(path, contents) {
|
|
|
8103
9485
|
try {
|
|
8104
9486
|
linkSync2(temporary, destination);
|
|
8105
9487
|
destinationLinked = true;
|
|
8106
|
-
|
|
8107
|
-
const published =
|
|
9488
|
+
unlinkSync3(temporary);
|
|
9489
|
+
const published = lstatSync3(destination);
|
|
8108
9490
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
8109
9491
|
if (!published.isFile() || published.isSymbolicLink() || identity === null || published.dev !== identity.dev || published.ino !== identity.ino || published.nlink !== 1 || uid === null || published.uid !== uid || (published.mode & 511) !== 384) {
|
|
8110
9492
|
throw new Error("Ensoul output was not published as one private, owned regular file.");
|
|
@@ -8145,7 +9527,7 @@ function preparePeoplebladeEnsoulSource(database, requestedPersonId, outputPath,
|
|
|
8145
9527
|
|
|
8146
9528
|
// src/local/legacy-rolodex.ts
|
|
8147
9529
|
import { Database as Database2 } from "bun:sqlite";
|
|
8148
|
-
import { lstatSync as
|
|
9530
|
+
import { lstatSync as lstatSync4, realpathSync as realpathSync3 } from "fs";
|
|
8149
9531
|
function tableExists2(database, name) {
|
|
8150
9532
|
return database.query("SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?").get(name) !== null;
|
|
8151
9533
|
}
|
|
@@ -8167,7 +9549,7 @@ function binding(value) {
|
|
|
8167
9549
|
return value;
|
|
8168
9550
|
throw new Error("Legacy database value is not a supported SQLite binding.");
|
|
8169
9551
|
}
|
|
8170
|
-
function
|
|
9552
|
+
function json2(value) {
|
|
8171
9553
|
return canonicalJson(value);
|
|
8172
9554
|
}
|
|
8173
9555
|
function providerForKind(kind) {
|
|
@@ -8193,7 +9575,7 @@ function maxDate(left, right) {
|
|
|
8193
9575
|
}
|
|
8194
9576
|
function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
8195
9577
|
const sourcePhysical = realpathSync3(sourcePath);
|
|
8196
|
-
const identity =
|
|
9578
|
+
const identity = lstatSync4(sourcePhysical);
|
|
8197
9579
|
if (!identity.isFile() || identity.isSymbolicLink() || identity.nlink !== 1) {
|
|
8198
9580
|
throw new Error("Legacy Rolodex must be one regular database file.");
|
|
8199
9581
|
}
|
|
@@ -8243,7 +9625,7 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8243
9625
|
notes, metadata_json, created_at, updated_at
|
|
8244
9626
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
8245
9627
|
for (const [canonicalId, memberIds] of [...members].sort(([a], [b]) => a - b)) {
|
|
8246
|
-
const candidates = memberIds.map((
|
|
9628
|
+
const candidates = memberIds.map((id2) => legacyPeople.get(id2)).filter((item) => item !== undefined);
|
|
8247
9629
|
const root = candidates.find((person) => person.id === canonicalId) ?? candidates[0];
|
|
8248
9630
|
if (root === undefined)
|
|
8249
9631
|
throw new Error("Legacy identity component has no person.");
|
|
@@ -8253,7 +9635,7 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8253
9635
|
return rootValue;
|
|
8254
9636
|
return candidates.map((item) => item[key]).find((value) => value !== null && value !== "") ?? rootValue;
|
|
8255
9637
|
};
|
|
8256
|
-
const inserted = insertPerson.run(`rolodex-v1:${canonicalId}`, preferred("display_name"), preferred("given_name"), preferred("family_name"), preferred("organization"), preferred("title"), candidates.some((person) => person.do_not_contact === 1) ? 1 : 0, preferred("notes"),
|
|
9638
|
+
const inserted = insertPerson.run(`rolodex-v1:${canonicalId}`, preferred("display_name"), preferred("given_name"), preferred("family_name"), preferred("organization"), preferred("title"), candidates.some((person) => person.do_not_contact === 1) ? 1 : 0, preferred("notes"), json2({ legacy_person_ids: memberIds }), candidates.map((person) => person.created_at).sort()[0], candidates.map((person) => person.updated_at).sort().at(-1));
|
|
8257
9639
|
targetByCanonical.set(canonicalId, number(inserted.lastInsertRowid, "person id"));
|
|
8258
9640
|
}
|
|
8259
9641
|
const personFor = (legacyPersonId) => {
|
|
@@ -8278,7 +9660,7 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8278
9660
|
const fileHash = text(row.file_sha256, "source hash");
|
|
8279
9661
|
const importedAt = text(row.imported_at, "source imported_at");
|
|
8280
9662
|
const locator = nullableText2(row.file_path);
|
|
8281
|
-
const result2 = insertRun.run(providerForKind(kind), `${kind}:${legacyId}`, fileHash, locator === null ? null : sha256(locator),
|
|
9663
|
+
const result2 = insertRun.run(providerForKind(kind), `${kind}:${legacyId}`, fileHash, locator === null ? null : sha256(locator), json2({ legacy_source_id: legacyId, kind, name: row.name, row_count: row.row_count, metadata: JSON.parse(text(row.metadata_json, "metadata_json")) }), importedAt, importedAt);
|
|
8282
9664
|
sourceMap.set(legacyId, number(result2.lastInsertRowid, "source run id"));
|
|
8283
9665
|
sourceRuns += 1;
|
|
8284
9666
|
}
|
|
@@ -8289,7 +9671,7 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8289
9671
|
for (const row of source.query("SELECT * FROM emails ORDER BY id").all()) {
|
|
8290
9672
|
const targetPerson = personFor(number(row.person_id, "email person"));
|
|
8291
9673
|
const run = row.source_id === null ? null : sourceMap.get(number(row.source_id, "email source")) ?? null;
|
|
8292
|
-
const result2 = insertMethod.run(...[targetPerson, "email", row.address, row.normalized_address, "", row.is_primary, row.confidence, run, run,
|
|
9674
|
+
const result2 = insertMethod.run(...[targetPerson, "email", row.address, row.normalized_address, "", row.is_primary, row.confidence, run, run, json2({ legacy_email_id: row.id, source_row: row.source_row }), row.created_at].map(binding));
|
|
8293
9675
|
if (result2.changes > 0)
|
|
8294
9676
|
contactMethods += 1;
|
|
8295
9677
|
}
|
|
@@ -8297,7 +9679,7 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8297
9679
|
for (const row of source.query("SELECT * FROM phone_numbers ORDER BY id").all()) {
|
|
8298
9680
|
const targetPerson = personFor(number(row.person_id, "phone person"));
|
|
8299
9681
|
const run = row.source_id === null ? null : sourceMap.get(number(row.source_id, "phone source")) ?? null;
|
|
8300
|
-
const result2 = insertMethod.run(...[targetPerson, "phone", row.value, row.normalized_value, nullableText2(row.type) ?? "", 0, "exact", run, run,
|
|
9682
|
+
const result2 = insertMethod.run(...[targetPerson, "phone", row.value, row.normalized_value, nullableText2(row.type) ?? "", 0, "exact", run, run, json2({ legacy_phone_id: row.id, source_row: row.source_row }), row.created_at].map(binding));
|
|
8301
9683
|
if (result2.changes > 0)
|
|
8302
9684
|
contactMethods += 1;
|
|
8303
9685
|
}
|
|
@@ -8307,12 +9689,12 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8307
9689
|
metadata_json, created_at, updated_at
|
|
8308
9690
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, coalesce(?, CURRENT_TIMESTAMP), coalesce(?, CURRENT_TIMESTAMP))`);
|
|
8309
9691
|
const resourceSpecs = [
|
|
8310
|
-
{ table: "google_contacts", query: "SELECT * FROM google_contacts ORDER BY id", convert: (r) => ["google", r.account_subject, r.collection, r.resource_name, personFor(number(r.person_id, "google person")), null, null, 0, r.collection === "contacts" || r.collection === "other-contacts" ? 1 : 0, null, r.deleted === 1 ? 0 : 1, r.first_seen_source_id === null ? null : sourceMap.get(number(r.first_seen_source_id, "source")), r.last_seen_source_id === null ? null : sourceMap.get(number(r.last_seen_source_id, "source")),
|
|
8311
|
-
{ table: "apple_contacts", query: "SELECT * FROM apple_contacts ORDER BY id", convert: (r) => ["apple", r.account_key, "contact", `${r.store_key}:${r.contact_identifier}`, personFor(number(r.person_id, "apple person")), null, null, 0, 1, null, r.removed === 1 ? 0 : 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")),
|
|
8312
|
-
{ table: "linkedin_connections", query: "SELECT * FROM linkedin_connections ORDER BY id", convert: (r) => ["linkedin", r.account_key, "profile", r.profile_url, personFor(number(r.person_id, "linkedin person")), null, r.profile_url, 1, 1, [r.first_name, r.last_name].filter(Boolean).join(" ") || null, r.removed === 1 ? 0 : 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")),
|
|
8313
|
-
{ table: "telegram_contacts", query: "SELECT * FROM telegram_contacts ORDER BY id", convert: (r) => ["telegram", r.account_key, "contact", r.contact_key, personFor(number(r.person_id, "telegram person")), null, null, 0, 0, null, 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")),
|
|
8314
|
-
{ table: "whatsapp_contacts", query: "SELECT * FROM whatsapp_contacts ORDER BY id", convert: (r) => ["whatsapp", r.account_subject, "jid", r.contact_jid, personFor(number(r.person_id, "whatsapp person")), null, null, 0, 0, r.display_name, 1, null, null,
|
|
8315
|
-
{ table: "instagram_identities", query: "SELECT * FROM instagram_identities ORDER BY id", convert: (r) => ["instagram", r.account_key, r.provider_user_id === null ? "profile" : "user", r.provider_user_id ?? r.profile_href, personFor(number(r.person_id, "instagram person")), r.current_username, r.profile_href, 1, 0, r.current_username, 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")),
|
|
9692
|
+
{ table: "google_contacts", query: "SELECT * FROM google_contacts ORDER BY id", convert: (r) => ["google", r.account_subject, r.collection, r.resource_name, personFor(number(r.person_id, "google person")), null, null, 0, r.collection === "contacts" || r.collection === "other-contacts" ? 1 : 0, null, r.deleted === 1 ? 0 : 1, r.first_seen_source_id === null ? null : sourceMap.get(number(r.first_seen_source_id, "source")), r.last_seen_source_id === null ? null : sourceMap.get(number(r.last_seen_source_id, "source")), json2({ etag: r.etag, legacy_id: r.id }), r.created_at, r.updated_at] },
|
|
9693
|
+
{ table: "apple_contacts", query: "SELECT * FROM apple_contacts ORDER BY id", convert: (r) => ["apple", r.account_key, "contact", `${r.store_key}:${r.contact_identifier}`, personFor(number(r.person_id, "apple person")), null, null, 0, 1, null, r.removed === 1 ? 0 : 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")), json2({ store_key: r.store_key, contact_identifier: r.contact_identifier, legacy_id: r.id }), r.created_at, r.updated_at] },
|
|
9694
|
+
{ table: "linkedin_connections", query: "SELECT * FROM linkedin_connections ORDER BY id", convert: (r) => ["linkedin", r.account_key, "profile", r.profile_url, personFor(number(r.person_id, "linkedin person")), null, r.profile_url, 1, 1, [r.first_name, r.last_name].filter(Boolean).join(" ") || null, r.removed === 1 ? 0 : 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")), json2({ company: r.company, position: r.position, connected_on: r.connected_on, legacy_id: r.id }), r.created_at, r.updated_at] },
|
|
9695
|
+
{ table: "telegram_contacts", query: "SELECT * FROM telegram_contacts ORDER BY id", convert: (r) => ["telegram", r.account_key, "contact", r.contact_key, personFor(number(r.person_id, "telegram person")), null, null, 0, 0, null, 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")), json2({ telegram_user_id: r.telegram_user_id, phone: r.phone_normalized, legacy_id: r.id }), r.created_at, r.updated_at] },
|
|
9696
|
+
{ table: "whatsapp_contacts", query: "SELECT * FROM whatsapp_contacts ORDER BY id", convert: (r) => ["whatsapp", r.account_subject, "jid", r.contact_jid, personFor(number(r.person_id, "whatsapp person")), null, null, 0, 0, r.display_name, 1, null, null, json2({ jid_kind: r.jid_kind, phone: r.normalized_phone, display_name_basis: r.display_name_basis, legacy_id: r.id }), r.created_at, r.updated_at] },
|
|
9697
|
+
{ table: "instagram_identities", query: "SELECT * FROM instagram_identities ORDER BY id", convert: (r) => ["instagram", r.account_key, r.provider_user_id === null ? "profile" : "user", r.provider_user_id ?? r.profile_href, personFor(number(r.person_id, "instagram person")), r.current_username, r.profile_href, 1, 0, r.current_username, 1, sourceMap.get(number(r.first_seen_source_id, "source")), sourceMap.get(number(r.last_seen_source_id, "source")), json2({ legacy_id: r.id }), r.created_at, r.updated_at] }
|
|
8316
9698
|
];
|
|
8317
9699
|
for (const spec of resourceSpecs)
|
|
8318
9700
|
if (tableExists2(source, spec.table))
|
|
@@ -8357,7 +9739,7 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8357
9739
|
addMetric("instagram", text(r.account_key, "account"), number(r.person_id, "person"), number(r.outgoing_messages, "sent"), number(r.incoming_messages, "received"), number(r.outgoing_messages, "sent") + number(r.incoming_messages, "received"), number(r.conversation_count, "conversations"), nullableText2(r.first_message_at), nullableText2(r.last_message_at), number(r.outgoing_messages, "sent") > 0 && number(r.incoming_messages, "received") > 0, "unknown");
|
|
8358
9740
|
const insertMetric = target.query(`INSERT INTO interaction_metrics(provider,account_key,person_id,sent_count,received_count,interaction_count,conversation_count,first_interaction_at,last_interaction_at,reciprocal,completeness,metadata_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
|
|
8359
9741
|
for (const metric of metrics.values()) {
|
|
8360
|
-
insertMetric.run(metric.provider, metric.account, metric.person, metric.sent, metric.received, metric.count, metric.conversations, metric.first, metric.last, metric.reciprocal ? 1 : 0, metric.completeness,
|
|
9742
|
+
insertMetric.run(metric.provider, metric.account, metric.person, metric.sent, metric.received, metric.count, metric.conversations, metric.first, metric.last, metric.reciprocal ? 1 : 0, metric.completeness, json2(metric.metadata));
|
|
8361
9743
|
interactionMetrics += 1;
|
|
8362
9744
|
}
|
|
8363
9745
|
if (tableExists2(source, "tags") && tableExists2(source, "person_tags")) {
|
|
@@ -8373,16 +9755,16 @@ function migrateLegacyRolodex(sourcePath, destinationPath) {
|
|
|
8373
9755
|
if (tableExists2(source, "publications") && tableExists2(source, "publication_memberships")) {
|
|
8374
9756
|
const collectionMap = new Map;
|
|
8375
9757
|
for (const r of source.query("SELECT * FROM publications ORDER BY id").all()) {
|
|
8376
|
-
const result2 = target.query("INSERT INTO collections(provider,account_key,resource_id,name,metadata_json) VALUES (?,?,?,?,?)").run(text(r.platform, "platform"), "legacy", text(r.slug, "slug"), text(r.name, "name"),
|
|
9758
|
+
const result2 = target.query("INSERT INTO collections(provider,account_key,resource_id,name,metadata_json) VALUES (?,?,?,?,?)").run(text(r.platform, "platform"), "legacy", text(r.slug, "slug"), text(r.name, "name"), json2({ legacy_publication_id: r.id }));
|
|
8377
9759
|
collectionMap.set(number(r.id, "publication"), number(result2.lastInsertRowid, "collection"));
|
|
8378
9760
|
collections += 1;
|
|
8379
9761
|
}
|
|
8380
9762
|
for (const r of source.query("SELECT * FROM publication_memberships").all())
|
|
8381
|
-
target.query("INSERT INTO collection_memberships(collection_id,person_id,state,metadata_json,updated_at) VALUES (?,?,?,?,?)").run(collectionMap.get(number(r.publication_id, "publication")) ?? null, personFor(number(r.person_id, "person")), text(r.state, "state"),
|
|
9763
|
+
target.query("INSERT INTO collection_memberships(collection_id,person_id,state,metadata_json,updated_at) VALUES (?,?,?,?,?)").run(collectionMap.get(number(r.publication_id, "publication")) ?? null, personFor(number(r.person_id, "person")), text(r.state, "state"), json2(r), text(r.updated_at, "membership timestamp"));
|
|
8382
9764
|
}
|
|
8383
9765
|
renormalizePhoneIdentities(target, { transaction: false });
|
|
8384
9766
|
const result = { people: targetByCanonical.size, contactMethods, providerResources, sourceRuns, sourceRecords, interactionMetrics, tags, collections };
|
|
8385
|
-
target.query("INSERT INTO legacy_migrations(source_format,source_sha256,source_locator_sha256,result_json) VALUES ('rolodex-v1',?,?,?)").run(sourceSha256, sha256(sourcePhysical),
|
|
9767
|
+
target.query("INSERT INTO legacy_migrations(source_format,source_sha256,source_locator_sha256,result_json) VALUES ('rolodex-v1',?,?,?)").run(sourceSha256, sha256(sourcePhysical), json2(result));
|
|
8386
9768
|
target.exec("COMMIT");
|
|
8387
9769
|
return { ...result, cached: false };
|
|
8388
9770
|
} catch (error) {
|
|
@@ -8423,8 +9805,9 @@ function selfStatus(metadataJson) {
|
|
|
8423
9805
|
throw new Error("Provider resource identity metadata is malformed.");
|
|
8424
9806
|
}
|
|
8425
9807
|
}
|
|
8426
|
-
function profiles(database) {
|
|
8427
|
-
const
|
|
9808
|
+
function profiles(database, index, personIds) {
|
|
9809
|
+
const scoped = personIds !== undefined;
|
|
9810
|
+
const rows = database.query(`SELECT p.id, p.display_name, p.organization,
|
|
8428
9811
|
coalesce((SELECT json_group_array(normalized_value) FROM (
|
|
8429
9812
|
SELECT DISTINCT method.normalized_value
|
|
8430
9813
|
FROM contact_methods method
|
|
@@ -8458,19 +9841,37 @@ function profiles(database) {
|
|
|
8458
9841
|
AND (realm.id IS NULL OR realm.active=1)
|
|
8459
9842
|
ORDER BY value
|
|
8460
9843
|
)),'[]') AS resources
|
|
8461
|
-
FROM people p
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8465
|
-
|
|
9844
|
+
FROM people p
|
|
9845
|
+
${scoped ? "WHERE p.id IN (SELECT value FROM json_each(?))" : ""} ORDER BY p.id`).all(...scoped ? [JSON.stringify(personIds)] : []);
|
|
9846
|
+
const result = new Map;
|
|
9847
|
+
for (const row of rows) {
|
|
9848
|
+
const id2 = Number(row.id);
|
|
9849
|
+
const canonicalId = index.canonicalOf.get(id2);
|
|
9850
|
+
if (canonicalId === undefined)
|
|
9851
|
+
continue;
|
|
9852
|
+
result.set(id2, {
|
|
9853
|
+
id: id2,
|
|
9854
|
+
canonicalId,
|
|
8466
9855
|
displayName: nullableString(row.display_name),
|
|
8467
9856
|
organization: nullableString(row.organization),
|
|
8468
9857
|
emails: stringArray(String(row.emails)),
|
|
8469
9858
|
phones: stringArray(String(row.phones)),
|
|
8470
9859
|
resources: stringArray(String(row.resources))
|
|
8471
|
-
};
|
|
8472
|
-
|
|
8473
|
-
|
|
9860
|
+
});
|
|
9861
|
+
}
|
|
9862
|
+
return result;
|
|
9863
|
+
}
|
|
9864
|
+
function personNamesLookup(database) {
|
|
9865
|
+
const statement = database.query("SELECT display_name,organization FROM people WHERE id=?");
|
|
9866
|
+
const cache = new Map;
|
|
9867
|
+
return (personId) => {
|
|
9868
|
+
if (cache.has(personId))
|
|
9869
|
+
return cache.get(personId);
|
|
9870
|
+
const row = statement.get(personId);
|
|
9871
|
+
const names = row === null ? undefined : { displayName: nullableString(row.display_name), organization: nullableString(row.organization) };
|
|
9872
|
+
cache.set(personId, names);
|
|
9873
|
+
return names;
|
|
9874
|
+
};
|
|
8474
9875
|
}
|
|
8475
9876
|
function legacyEvidence(left, right, kind, signals, warnings) {
|
|
8476
9877
|
return {
|
|
@@ -8533,7 +9934,7 @@ function reviewedEvidenceDigest(decision) {
|
|
|
8533
9934
|
}
|
|
8534
9935
|
}
|
|
8535
9936
|
function suggestLegacyIdentities(database, kind, limit) {
|
|
8536
|
-
const values = [...profiles(database).values()];
|
|
9937
|
+
const values = [...profiles(database, componentIndex(database)).values()];
|
|
8537
9938
|
const suggestions = [];
|
|
8538
9939
|
const candidates = new Map;
|
|
8539
9940
|
const priority = { "exact-email": 3, "exact-phone": 2, "name-organization": 1 };
|
|
@@ -8703,10 +10104,29 @@ function eligibleOwners(database) {
|
|
|
8703
10104
|
}
|
|
8704
10105
|
return buckets;
|
|
8705
10106
|
}
|
|
8706
|
-
function
|
|
10107
|
+
function componentIndex(database) {
|
|
10108
|
+
const rows = database.query(`SELECT person_id,canonical_person_id FROM person_identity_components
|
|
10109
|
+
ORDER BY canonical_person_id,person_id`).all();
|
|
10110
|
+
const canonicalOf = new Map;
|
|
10111
|
+
const membersOf = new Map;
|
|
10112
|
+
for (const row of rows) {
|
|
10113
|
+
const personId = Number(row.person_id);
|
|
10114
|
+
const canonicalId = Number(row.canonical_person_id);
|
|
10115
|
+
canonicalOf.set(personId, canonicalId);
|
|
10116
|
+
const members = membersOf.get(canonicalId) ?? [];
|
|
10117
|
+
members.push(personId);
|
|
10118
|
+
membersOf.set(canonicalId, members);
|
|
10119
|
+
}
|
|
10120
|
+
return { canonicalOf, membersOf };
|
|
10121
|
+
}
|
|
10122
|
+
function memberList(index, canonicalId) {
|
|
10123
|
+
return JSON.stringify(index.membersOf.get(canonicalId) ?? []);
|
|
10124
|
+
}
|
|
10125
|
+
function componentSnapshot(database, canonicalId, index) {
|
|
10126
|
+
const memberIds = memberList(index, canonicalId);
|
|
8707
10127
|
const members = database.query(`SELECT person.id,person.display_name,person.organization
|
|
8708
|
-
FROM
|
|
8709
|
-
WHERE
|
|
10128
|
+
FROM people person
|
|
10129
|
+
WHERE person.id IN (SELECT value FROM json_each(?)) ORDER BY person.id`).all(memberIds).map((row) => ({
|
|
8710
10130
|
id: Number(row.id),
|
|
8711
10131
|
displayName: nullableString(row.display_name),
|
|
8712
10132
|
organization: nullableString(row.organization),
|
|
@@ -8719,11 +10139,10 @@ function componentSnapshot(database, canonicalId) {
|
|
|
8719
10139
|
realm.identity_namespace AS realm_identity_namespace,
|
|
8720
10140
|
realm.external_id_sha256 AS realm_external_id_sha256
|
|
8721
10141
|
FROM provider_resources resource
|
|
8722
|
-
JOIN person_identity_components component ON component.person_id=resource.person_id
|
|
8723
10142
|
LEFT JOIN source_realms realm ON realm.id=resource.source_realm_id
|
|
8724
|
-
WHERE
|
|
10143
|
+
WHERE resource.person_id IN (SELECT value FROM json_each(?)) AND resource.active=1
|
|
8725
10144
|
AND (realm.id IS NULL OR realm.active=1)
|
|
8726
|
-
ORDER BY resource.id`).all(
|
|
10145
|
+
ORDER BY resource.id`).all(memberIds).map((row) => ({
|
|
8727
10146
|
id: Number(row.id),
|
|
8728
10147
|
personId: Number(row.person_id),
|
|
8729
10148
|
provider: String(row.provider),
|
|
@@ -8740,14 +10159,13 @@ function componentSnapshot(database, canonicalId) {
|
|
|
8740
10159
|
method.value,method.normalized_value,method.label,method.metadata_json,
|
|
8741
10160
|
method.provider_resource_id
|
|
8742
10161
|
FROM contact_methods method
|
|
8743
|
-
JOIN person_identity_components component ON component.person_id=method.person_id
|
|
8744
10162
|
LEFT JOIN provider_resources resource ON resource.id=method.provider_resource_id
|
|
8745
10163
|
LEFT JOIN source_realms realm ON realm.id=resource.source_realm_id
|
|
8746
|
-
WHERE
|
|
10164
|
+
WHERE method.person_id IN (SELECT value FROM json_each(?)) AND method.active=1
|
|
8747
10165
|
AND method.identity_eligible=1 AND method.confidence='exact'
|
|
8748
10166
|
AND (resource.id IS NULL OR resource.active=1)
|
|
8749
10167
|
AND (realm.id IS NULL OR realm.active=1)
|
|
8750
|
-
ORDER BY method.kind,method.normalized_value,method.person_id,method.id`).all(
|
|
10168
|
+
ORDER BY method.kind,method.normalized_value,method.person_id,method.id`).all(memberIds).map((row) => ({
|
|
8751
10169
|
id: Number(row.id),
|
|
8752
10170
|
personId: Number(row.person_id),
|
|
8753
10171
|
kind: String(row.kind),
|
|
@@ -8800,13 +10218,12 @@ function componentRealmContradictions(left, right) {
|
|
|
8800
10218
|
}
|
|
8801
10219
|
return [...realms.values()].filter((group) => group.values.size > 1).map((group) => `distinct exact resources in ${group.label}`).sort();
|
|
8802
10220
|
}
|
|
8803
|
-
function componentInteractionLanes(database, canonicalId) {
|
|
10221
|
+
function componentInteractionLanes(database, canonicalId, index) {
|
|
8804
10222
|
const rows = database.query(`SELECT metric.provider,realm.service
|
|
8805
10223
|
FROM interaction_metrics metric
|
|
8806
|
-
JOIN person_identity_components component ON component.person_id=metric.person_id
|
|
8807
10224
|
LEFT JOIN source_realms realm ON realm.authority=metric.provider AND realm.account_key=metric.account_key
|
|
8808
|
-
WHERE
|
|
8809
|
-
ORDER BY metric.provider,realm.service`).all(canonicalId);
|
|
10225
|
+
WHERE metric.person_id IN (SELECT value FROM json_each(?)) AND (realm.id IS NULL OR realm.active=1)
|
|
10226
|
+
ORDER BY metric.provider,realm.service`).all(memberList(index, canonicalId));
|
|
8810
10227
|
const result = new Map;
|
|
8811
10228
|
for (const row of rows) {
|
|
8812
10229
|
const provider = String(row.provider);
|
|
@@ -8887,14 +10304,15 @@ function eligibleOwnerSemantic(owner) {
|
|
|
8887
10304
|
function observedCandidates(database, options) {
|
|
8888
10305
|
const allObservations = observedMethods(database);
|
|
8889
10306
|
const ownersBySignal = eligibleOwners(database);
|
|
8890
|
-
const
|
|
10307
|
+
const index = options.index ?? componentIndex(database);
|
|
10308
|
+
const names = personNamesLookup(database);
|
|
8891
10309
|
const snapshots = new Map;
|
|
8892
10310
|
const laneCache = new Map;
|
|
8893
10311
|
const snapshot = (canonicalId) => {
|
|
8894
10312
|
const cached = snapshots.get(canonicalId);
|
|
8895
10313
|
if (cached !== undefined)
|
|
8896
10314
|
return cached;
|
|
8897
|
-
const created = componentSnapshot(database, canonicalId);
|
|
10315
|
+
const created = componentSnapshot(database, canonicalId, index);
|
|
8898
10316
|
snapshots.set(canonicalId, created);
|
|
8899
10317
|
return created;
|
|
8900
10318
|
};
|
|
@@ -8902,7 +10320,7 @@ function observedCandidates(database, options) {
|
|
|
8902
10320
|
const cached = laneCache.get(canonicalId);
|
|
8903
10321
|
if (cached !== undefined)
|
|
8904
10322
|
return cached;
|
|
8905
|
-
const created = componentInteractionLanes(database, canonicalId);
|
|
10323
|
+
const created = componentInteractionLanes(database, canonicalId, index);
|
|
8906
10324
|
laneCache.set(canonicalId, created);
|
|
8907
10325
|
return created;
|
|
8908
10326
|
};
|
|
@@ -8923,7 +10341,7 @@ function observedCandidates(database, options) {
|
|
|
8923
10341
|
}
|
|
8924
10342
|
targetComponentsByPerson.set(observation.personId, targets);
|
|
8925
10343
|
}
|
|
8926
|
-
const selected = allObservations.filter((observation) => observation.kind === (options.kind === "observed-email" ? "email" : "phone") && (options.source === undefined || observation.provider === options.source) && (options.realmId === undefined || observation.realmId === options.realmId));
|
|
10344
|
+
const selected = allObservations.filter((observation) => observation.kind === (options.kind === "observed-email" ? "email" : "phone") && (options.source === undefined || observation.provider === options.source) && (options.realmId === undefined || observation.realmId === options.realmId) && (options.observationPersonIds === undefined || options.observationPersonIds.includes(observation.personId)));
|
|
8927
10345
|
const deduplicated = new Map;
|
|
8928
10346
|
for (const observation of selected) {
|
|
8929
10347
|
if (selfStatus(observation.resourceMetadataJson) === "true")
|
|
@@ -8955,10 +10373,10 @@ function observedCandidates(database, options) {
|
|
|
8955
10373
|
continue;
|
|
8956
10374
|
const leftId = Math.min(observation.personId, anchor.personId);
|
|
8957
10375
|
const rightId = Math.max(observation.personId, anchor.personId);
|
|
8958
|
-
const leftProfile =
|
|
8959
|
-
const rightProfile =
|
|
8960
|
-
const observationProfile =
|
|
8961
|
-
const targetProfile =
|
|
10376
|
+
const leftProfile = names(leftId);
|
|
10377
|
+
const rightProfile = names(rightId);
|
|
10378
|
+
const observationProfile = names(observation.personId);
|
|
10379
|
+
const targetProfile = names(anchor.personId);
|
|
8962
10380
|
if (leftProfile === undefined || rightProfile === undefined || observationProfile === undefined || targetProfile === undefined)
|
|
8963
10381
|
continue;
|
|
8964
10382
|
const candidateComponentIds = [...new Set(item.owners.map((owner) => owner.canonicalId))].sort((a, b) => a - b);
|
|
@@ -9108,9 +10526,9 @@ function assertReviewTransitionAllowed(current, digest) {
|
|
|
9108
10526
|
return;
|
|
9109
10527
|
throw new Error("This identity evidence has already been reviewed; request a fresh suggestion after evidence changes.");
|
|
9110
10528
|
}
|
|
9111
|
-
function assertMergeAllowed(database, leftCanonicalId, rightCanonicalId, supersededDecisionId = null) {
|
|
9112
|
-
const leftMembers =
|
|
9113
|
-
const rightMembers =
|
|
10529
|
+
function assertMergeAllowed(database, index, leftCanonicalId, rightCanonicalId, supersededDecisionId = null) {
|
|
10530
|
+
const leftMembers = index.membersOf.get(leftCanonicalId) ?? [];
|
|
10531
|
+
const rightMembers = index.membersOf.get(rightCanonicalId) ?? [];
|
|
9114
10532
|
const leftMarks = leftMembers.map(() => "?").join(",");
|
|
9115
10533
|
const rightMarks = rightMembers.map(() => "?").join(",");
|
|
9116
10534
|
const blockers = database.query(`SELECT count(*) AS count FROM identity_current_decisions
|
|
@@ -9130,7 +10548,8 @@ function insertDecision(database, left, right, action, digest, evidence, priorDe
|
|
|
9130
10548
|
function decideLegacyIdentity(database, parsed, action, note) {
|
|
9131
10549
|
if (action === "separate")
|
|
9132
10550
|
throw new Error("Use identity separate with the accepted decision ID.");
|
|
9133
|
-
const
|
|
10551
|
+
const index = componentIndex(database);
|
|
10552
|
+
const map = profiles(database, index, [parsed.left, parsed.right]);
|
|
9134
10553
|
const left = map.get(parsed.left);
|
|
9135
10554
|
const right = map.get(parsed.right);
|
|
9136
10555
|
if (left === undefined || right === undefined)
|
|
@@ -9149,17 +10568,20 @@ function decideLegacyIdentity(database, parsed, action, note) {
|
|
|
9149
10568
|
assertReviewTransitionAllowed(current, parsed.digest);
|
|
9150
10569
|
if (action === "accept") {
|
|
9151
10570
|
const superseded = current !== null && ["reject", "separate"].includes(current.action) && reviewedEvidenceDigest(current) !== parsed.digest ? current.id : null;
|
|
9152
|
-
assertMergeAllowed(database, left.canonicalId, right.canonicalId, superseded);
|
|
10571
|
+
assertMergeAllowed(database, index, left.canonicalId, right.canonicalId, superseded);
|
|
9153
10572
|
}
|
|
9154
10573
|
return insertDecision(database, parsed.left, parsed.right, action, parsed.digest, evidence, current?.id ?? null, note);
|
|
9155
10574
|
}
|
|
9156
10575
|
function decideObservedIdentity(database, parsed, action, note) {
|
|
9157
10576
|
if (action === "separate")
|
|
9158
10577
|
throw new Error("Use identity separate with the accepted decision ID.");
|
|
10578
|
+
const index = componentIndex(database);
|
|
9159
10579
|
const candidates = observedCandidates(database, {
|
|
9160
10580
|
kind: parsed.kind,
|
|
9161
10581
|
ambiguity: "all",
|
|
9162
|
-
suppressDecisions: false
|
|
10582
|
+
suppressDecisions: false,
|
|
10583
|
+
observationPersonIds: [parsed.left, parsed.right],
|
|
10584
|
+
index
|
|
9163
10585
|
});
|
|
9164
10586
|
const candidate = candidates.find((item) => item.suggestion.token === `id2.${parsed.left}.${parsed.right}.${parsed.kind}.${parsed.observationMethodId}.${item.digest}`);
|
|
9165
10587
|
if (candidate === undefined || candidate.digest !== parsed.digest) {
|
|
@@ -9168,16 +10590,16 @@ function decideObservedIdentity(database, parsed, action, note) {
|
|
|
9168
10590
|
if (action === "accept" && candidate.realmContradictions.length > 0) {
|
|
9169
10591
|
throw new Error("Distinct exact resources in one source realm block this identity merge.");
|
|
9170
10592
|
}
|
|
9171
|
-
const left =
|
|
9172
|
-
const right =
|
|
9173
|
-
if (left ===
|
|
10593
|
+
const left = index.canonicalOf.get(parsed.left);
|
|
10594
|
+
const right = index.canonicalOf.get(parsed.right);
|
|
10595
|
+
if (left === undefined || right === undefined || left === right) {
|
|
9174
10596
|
throw new Error("Identity evidence changed; request a fresh suggestion.");
|
|
9175
10597
|
}
|
|
9176
10598
|
const current = currentDecision(database, parsed.left, parsed.right);
|
|
9177
10599
|
assertReviewTransitionAllowed(current, parsed.digest);
|
|
9178
10600
|
if (action === "accept") {
|
|
9179
10601
|
const superseded = current !== null && ["reject", "separate"].includes(current.action) && reviewedEvidenceDigest(current) !== parsed.digest ? current.id : null;
|
|
9180
|
-
assertMergeAllowed(database, left
|
|
10602
|
+
assertMergeAllowed(database, index, left, right, superseded);
|
|
9181
10603
|
}
|
|
9182
10604
|
return insertDecision(database, parsed.left, parsed.right, action, parsed.digest, candidate.evidence, current?.id ?? null, note);
|
|
9183
10605
|
}
|
|
@@ -9226,6 +10648,275 @@ function separateIdentityDecision(database, acceptedDecisionId, note) {
|
|
|
9226
10648
|
throw error;
|
|
9227
10649
|
}
|
|
9228
10650
|
}
|
|
10651
|
+
var IDENTITY_AUTO_ACCEPT_NOTE = "auto:high-precision";
|
|
10652
|
+
var IDENTITY_AUTO_ACCEPT_KINDS = [
|
|
10653
|
+
"exact-email",
|
|
10654
|
+
"exact-phone",
|
|
10655
|
+
"observed-email",
|
|
10656
|
+
"observed-phone"
|
|
10657
|
+
];
|
|
10658
|
+
var DEFAULT_IDENTITY_AUTO_ACCEPT_KINDS = [
|
|
10659
|
+
"exact-email",
|
|
10660
|
+
"exact-phone",
|
|
10661
|
+
"observed-phone"
|
|
10662
|
+
];
|
|
10663
|
+
var DEFAULT_IDENTITY_AUTO_ACCEPT_LIMIT = 100;
|
|
10664
|
+
var MAX_IDENTITY_AUTO_ACCEPT_LIMIT = 500;
|
|
10665
|
+
var AUTO_ACCEPT_SUGGESTION_PAGE = 500;
|
|
10666
|
+
var REALM_CONTRADICTION_PREFIX = "distinct exact resources in";
|
|
10667
|
+
var CONFLICTING_TARGETS_WARNING = "this provider identity has observed methods pointing to different identity components";
|
|
10668
|
+
var OBSERVED_BASELINE_WARNING = /^unverified (?:email|phone) observation; review is required$/u;
|
|
10669
|
+
var OBSERVED_TOLERATED_WARNINGS = [
|
|
10670
|
+
OBSERVED_BASELINE_WARNING,
|
|
10671
|
+
/^normalized display names differ$/u,
|
|
10672
|
+
/^interaction history overlaps direct and Beeper /u
|
|
10673
|
+
];
|
|
10674
|
+
var PHONE_NAME_MISMATCH_DETAIL = "both records carry different display names; a shared phone joins only matching names or a nameless stub";
|
|
10675
|
+
var PHONE_COMPONENT_NAME_MISMATCH_DETAIL = "identity components carry different display names; a shared phone requires compatible names across both components";
|
|
10676
|
+
function isAutoAcceptKind(kind) {
|
|
10677
|
+
return IDENTITY_AUTO_ACCEPT_KINDS.includes(kind);
|
|
10678
|
+
}
|
|
10679
|
+
function displayNameTokens(displayName) {
|
|
10680
|
+
const raw = (displayName ?? "").trim();
|
|
10681
|
+
if (raw.length === 0 || raw.includes("@") && !/\s/u.test(raw))
|
|
10682
|
+
return [];
|
|
10683
|
+
const normalized = raw.normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{M}\p{N}]+/gu, " ").trim();
|
|
10684
|
+
if (normalized.length === 0 || /^[\p{N} ]+$/u.test(normalized))
|
|
10685
|
+
return [];
|
|
10686
|
+
return [...new Set(normalized.split(" "))];
|
|
10687
|
+
}
|
|
10688
|
+
function compatibleDisplayNames(left, right) {
|
|
10689
|
+
const leftTokens = displayNameTokens(left);
|
|
10690
|
+
const rightTokens = displayNameTokens(right);
|
|
10691
|
+
if (leftTokens.length === 0 || rightTokens.length === 0)
|
|
10692
|
+
return true;
|
|
10693
|
+
const [shorter, longer] = leftTokens.length <= rightTokens.length ? [leftTokens, rightTokens] : [rightTokens, leftTokens];
|
|
10694
|
+
const longerSet = new Set(longer);
|
|
10695
|
+
return shorter.every((token) => longerSet.has(token));
|
|
10696
|
+
}
|
|
10697
|
+
function phoneNameBlocker(suggestion) {
|
|
10698
|
+
if (suggestion.kind !== "exact-phone" && suggestion.kind !== "observed-phone")
|
|
10699
|
+
return null;
|
|
10700
|
+
if (compatibleDisplayNames(suggestion.left.displayName, suggestion.right.displayName))
|
|
10701
|
+
return null;
|
|
10702
|
+
return { reason: "name-mismatch", detail: PHONE_NAME_MISMATCH_DETAIL };
|
|
10703
|
+
}
|
|
10704
|
+
function identityAutoAcceptBlocker(suggestion) {
|
|
10705
|
+
if (!isAutoAcceptKind(suggestion.kind)) {
|
|
10706
|
+
return { reason: "ineligible-kind", detail: `${suggestion.kind} evidence always requires manual review` };
|
|
10707
|
+
}
|
|
10708
|
+
const realm = suggestion.warnings.find((warning) => warning.startsWith(REALM_CONTRADICTION_PREFIX));
|
|
10709
|
+
if (realm !== undefined)
|
|
10710
|
+
return { reason: "realm-contradiction", detail: realm };
|
|
10711
|
+
const observation = suggestion.observation;
|
|
10712
|
+
if (observation === undefined) {
|
|
10713
|
+
if (suggestion.warnings.length > 0 || suggestion.confidence !== "strong") {
|
|
10714
|
+
return { reason: "unreviewed-warning", detail: suggestion.warnings[0] ?? "suggestion is not strong" };
|
|
10715
|
+
}
|
|
10716
|
+
return phoneNameBlocker(suggestion);
|
|
10717
|
+
}
|
|
10718
|
+
if (observation.ambiguity !== "unique" || observation.candidateComponentCount !== 1) {
|
|
10719
|
+
return {
|
|
10720
|
+
reason: "ambiguous-candidates",
|
|
10721
|
+
detail: `observed value has ${observation.candidateComponentCount} eligible identity candidates`
|
|
10722
|
+
};
|
|
10723
|
+
}
|
|
10724
|
+
if (observation.observationComponentCount > 1) {
|
|
10725
|
+
return {
|
|
10726
|
+
reason: "multi-observation-cluster",
|
|
10727
|
+
detail: `observed value appears in ${observation.observationComponentCount} provider identity components`
|
|
10728
|
+
};
|
|
10729
|
+
}
|
|
10730
|
+
if (suggestion.warnings.includes(CONFLICTING_TARGETS_WARNING)) {
|
|
10731
|
+
return { reason: "conflicting-targets", detail: CONFLICTING_TARGETS_WARNING };
|
|
10732
|
+
}
|
|
10733
|
+
if (observation.selfStatus !== "false") {
|
|
10734
|
+
return { reason: "self-status-unknown", detail: `source self status is ${observation.selfStatus}` };
|
|
10735
|
+
}
|
|
10736
|
+
const unexpected = suggestion.warnings.find((warning) => !OBSERVED_TOLERATED_WARNINGS.some((pattern) => pattern.test(warning)));
|
|
10737
|
+
if (unexpected !== undefined)
|
|
10738
|
+
return { reason: "unreviewed-warning", detail: unexpected };
|
|
10739
|
+
return phoneNameBlocker(suggestion);
|
|
10740
|
+
}
|
|
10741
|
+
function componentLevelRealmBlocker(database, index, suggestion) {
|
|
10742
|
+
if (suggestion.observation !== undefined)
|
|
10743
|
+
return null;
|
|
10744
|
+
const left = index.canonicalOf.get(suggestion.leftPersonId);
|
|
10745
|
+
const right = index.canonicalOf.get(suggestion.rightPersonId);
|
|
10746
|
+
if (left === undefined || right === undefined || left === right) {
|
|
10747
|
+
return { reason: "stale-evidence", detail: "identity components changed during this run" };
|
|
10748
|
+
}
|
|
10749
|
+
const contradictions = componentRealmContradictions(componentSnapshot(database, left, index), componentSnapshot(database, right, index));
|
|
10750
|
+
const first = contradictions[0];
|
|
10751
|
+
return first === undefined ? null : { reason: "realm-contradiction", detail: first };
|
|
10752
|
+
}
|
|
10753
|
+
function componentLevelPhoneNameBlocker(database, index, suggestion) {
|
|
10754
|
+
if (suggestion.kind !== "exact-phone" && suggestion.kind !== "observed-phone")
|
|
10755
|
+
return null;
|
|
10756
|
+
const left = index.canonicalOf.get(suggestion.leftPersonId);
|
|
10757
|
+
const right = index.canonicalOf.get(suggestion.rightPersonId);
|
|
10758
|
+
const stale = {
|
|
10759
|
+
reason: "stale-evidence",
|
|
10760
|
+
detail: "identity components changed during this run"
|
|
10761
|
+
};
|
|
10762
|
+
if (left === undefined || right === undefined || left === right)
|
|
10763
|
+
return stale;
|
|
10764
|
+
const names = personNamesLookup(database);
|
|
10765
|
+
const componentNames = (canonicalId) => {
|
|
10766
|
+
const members = index.membersOf.get(canonicalId);
|
|
10767
|
+
if (members === undefined || members.length === 0)
|
|
10768
|
+
return null;
|
|
10769
|
+
const result = [];
|
|
10770
|
+
for (const personId of members) {
|
|
10771
|
+
const person = names(personId);
|
|
10772
|
+
if (person === undefined)
|
|
10773
|
+
return null;
|
|
10774
|
+
result.push(person.displayName);
|
|
10775
|
+
}
|
|
10776
|
+
return result;
|
|
10777
|
+
};
|
|
10778
|
+
const leftNames = componentNames(left);
|
|
10779
|
+
const rightNames = componentNames(right);
|
|
10780
|
+
if (leftNames === null || rightNames === null)
|
|
10781
|
+
return stale;
|
|
10782
|
+
if (leftNames.some((leftName) => rightNames.some((rightName) => !compatibleDisplayNames(leftName, rightName)))) {
|
|
10783
|
+
return { reason: "name-mismatch", detail: PHONE_COMPONENT_NAME_MISMATCH_DETAIL };
|
|
10784
|
+
}
|
|
10785
|
+
return null;
|
|
10786
|
+
}
|
|
10787
|
+
function normalizeAutoAcceptKinds(kinds) {
|
|
10788
|
+
const requested = kinds === undefined || kinds.length === 0 ? DEFAULT_IDENTITY_AUTO_ACCEPT_KINDS : kinds;
|
|
10789
|
+
const result = [];
|
|
10790
|
+
for (const kind of requested) {
|
|
10791
|
+
if (kind === "name-organization") {
|
|
10792
|
+
throw new Error("name-organization evidence is never eligible for automatic acceptance.");
|
|
10793
|
+
}
|
|
10794
|
+
if (!isAutoAcceptKind(kind))
|
|
10795
|
+
throw new Error("Identity auto-accept kinds must be exact-email, exact-phone, observed-email, or observed-phone.");
|
|
10796
|
+
if (!result.includes(kind))
|
|
10797
|
+
result.push(kind);
|
|
10798
|
+
}
|
|
10799
|
+
return result;
|
|
10800
|
+
}
|
|
10801
|
+
function autoAcceptIdentities(database, options = {}) {
|
|
10802
|
+
const kinds = normalizeAutoAcceptKinds(options.kinds);
|
|
10803
|
+
if (options.ambiguity !== undefined && options.ambiguity !== "unique") {
|
|
10804
|
+
throw new Error("Identity auto-accept reviews unique candidates only.");
|
|
10805
|
+
}
|
|
10806
|
+
const limit = options.limit ?? DEFAULT_IDENTITY_AUTO_ACCEPT_LIMIT;
|
|
10807
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_IDENTITY_AUTO_ACCEPT_LIMIT) {
|
|
10808
|
+
throw new Error(`Identity auto-accept limit must be between 1 and ${MAX_IDENTITY_AUTO_ACCEPT_LIMIT}.`);
|
|
10809
|
+
}
|
|
10810
|
+
if (options.source !== undefined && !/^[a-z][a-z0-9-]{0,63}$/u.test(options.source)) {
|
|
10811
|
+
throw new Error("Identity observation source is invalid.");
|
|
10812
|
+
}
|
|
10813
|
+
const dryRun = options.dryRun === true;
|
|
10814
|
+
const operatorNote = options.note?.trim() ?? "";
|
|
10815
|
+
const noteFor = (kind) => `${IDENTITY_AUTO_ACCEPT_NOTE} ${kind} unique${operatorNote.length > 0 ? `; ${operatorNote}` : ""}`;
|
|
10816
|
+
const accepted = [];
|
|
10817
|
+
const skipped = [];
|
|
10818
|
+
const resolved = new Set;
|
|
10819
|
+
const stale = new Map;
|
|
10820
|
+
const pairKey = (suggestion) => `${suggestion.leftPersonId}.${suggestion.rightPersonId}`;
|
|
10821
|
+
const skip = (suggestion, blocker) => {
|
|
10822
|
+
resolved.add(pairKey(suggestion));
|
|
10823
|
+
stale.delete(pairKey(suggestion));
|
|
10824
|
+
skipped.push({
|
|
10825
|
+
token: suggestion.token,
|
|
10826
|
+
kind: suggestion.kind,
|
|
10827
|
+
leftPersonId: suggestion.leftPersonId,
|
|
10828
|
+
rightPersonId: suggestion.rightPersonId,
|
|
10829
|
+
reason: blocker.reason,
|
|
10830
|
+
detail: blocker.detail,
|
|
10831
|
+
warnings: suggestion.warnings
|
|
10832
|
+
});
|
|
10833
|
+
};
|
|
10834
|
+
let index = null;
|
|
10835
|
+
const components = () => {
|
|
10836
|
+
index ??= componentIndex(database);
|
|
10837
|
+
return index;
|
|
10838
|
+
};
|
|
10839
|
+
let rounds = 0;
|
|
10840
|
+
let progress = true;
|
|
10841
|
+
while (progress && accepted.length < limit && rounds <= limit) {
|
|
10842
|
+
rounds += 1;
|
|
10843
|
+
progress = false;
|
|
10844
|
+
for (const kind of kinds) {
|
|
10845
|
+
if (accepted.length >= limit)
|
|
10846
|
+
break;
|
|
10847
|
+
const observed = kind === "observed-email" || kind === "observed-phone";
|
|
10848
|
+
const suggestions = suggestIdentities(database, {
|
|
10849
|
+
kind,
|
|
10850
|
+
limit: AUTO_ACCEPT_SUGGESTION_PAGE,
|
|
10851
|
+
...observed ? { ambiguity: "unique" } : {},
|
|
10852
|
+
...observed && options.source !== undefined ? { source: options.source } : {}
|
|
10853
|
+
});
|
|
10854
|
+
for (const suggestion of suggestions) {
|
|
10855
|
+
if (accepted.length >= limit)
|
|
10856
|
+
break;
|
|
10857
|
+
const key = pairKey(suggestion);
|
|
10858
|
+
if (resolved.has(key))
|
|
10859
|
+
continue;
|
|
10860
|
+
const blocker = identityAutoAcceptBlocker(suggestion) ?? (currentDecision(database, suggestion.leftPersonId, suggestion.rightPersonId) === null ? null : { reason: "prior-decision", detail: "a current decision already exists for this pair" }) ?? componentLevelRealmBlocker(database, components(), suggestion) ?? componentLevelPhoneNameBlocker(database, components(), suggestion);
|
|
10861
|
+
if (blocker !== null) {
|
|
10862
|
+
skip(suggestion, blocker);
|
|
10863
|
+
continue;
|
|
10864
|
+
}
|
|
10865
|
+
const entry = {
|
|
10866
|
+
token: suggestion.token,
|
|
10867
|
+
kind,
|
|
10868
|
+
leftPersonId: suggestion.leftPersonId,
|
|
10869
|
+
rightPersonId: suggestion.rightPersonId,
|
|
10870
|
+
score: suggestion.score
|
|
10871
|
+
};
|
|
10872
|
+
if (dryRun) {
|
|
10873
|
+
resolved.add(key);
|
|
10874
|
+
accepted.push({ decisionId: null, ...entry });
|
|
10875
|
+
continue;
|
|
10876
|
+
}
|
|
10877
|
+
try {
|
|
10878
|
+
const decision = decideIdentity(database, suggestion.token, "accept", noteFor(kind));
|
|
10879
|
+
index = null;
|
|
10880
|
+
resolved.add(key);
|
|
10881
|
+
stale.delete(key);
|
|
10882
|
+
accepted.push({ decisionId: decision.decisionId, ...entry });
|
|
10883
|
+
progress = true;
|
|
10884
|
+
} catch (error) {
|
|
10885
|
+
const message = error instanceof Error ? error.message : "unexpected failure";
|
|
10886
|
+
if (message === "Identity evidence changed; request a fresh suggestion.") {
|
|
10887
|
+
stale.set(key, suggestion);
|
|
10888
|
+
continue;
|
|
10889
|
+
}
|
|
10890
|
+
skip(suggestion, { reason: "decision-refused", detail: message });
|
|
10891
|
+
}
|
|
10892
|
+
}
|
|
10893
|
+
}
|
|
10894
|
+
if (dryRun)
|
|
10895
|
+
break;
|
|
10896
|
+
}
|
|
10897
|
+
for (const suggestion of stale.values()) {
|
|
10898
|
+
skip(suggestion, { reason: "stale-evidence", detail: "evidence changed during this run; rerun to re-evaluate" });
|
|
10899
|
+
}
|
|
10900
|
+
const byReason = {};
|
|
10901
|
+
for (const item of skipped)
|
|
10902
|
+
byReason[item.reason] = (byReason[item.reason] ?? 0) + 1;
|
|
10903
|
+
return {
|
|
10904
|
+
dryRun,
|
|
10905
|
+
policy: {
|
|
10906
|
+
kinds,
|
|
10907
|
+
ambiguity: "unique",
|
|
10908
|
+
limit,
|
|
10909
|
+
source: options.source ?? null,
|
|
10910
|
+
note: IDENTITY_AUTO_ACCEPT_NOTE
|
|
10911
|
+
},
|
|
10912
|
+
rounds,
|
|
10913
|
+
reviewed: resolved.size,
|
|
10914
|
+
limitReached: accepted.length >= limit,
|
|
10915
|
+
accepted,
|
|
10916
|
+
skipped,
|
|
10917
|
+
counts: { accepted: accepted.length, skipped: skipped.length, byReason }
|
|
10918
|
+
};
|
|
10919
|
+
}
|
|
9229
10920
|
function currentAcceptedObservedDecisions(database, observationsByMethod, source) {
|
|
9230
10921
|
const decisions = database.query(`SELECT id,evidence_json,created_at FROM identity_current_decisions
|
|
9231
10922
|
WHERE action='accept' AND json_extract(evidence_json,'$.version')=2
|
|
@@ -9348,7 +11039,7 @@ function auditObservedIdentityLinks(database, options = {}) {
|
|
|
9348
11039
|
}
|
|
9349
11040
|
|
|
9350
11041
|
// src/local/people-create.ts
|
|
9351
|
-
import { randomBytes as
|
|
11042
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
9352
11043
|
var MODE2 = "confirmed-person-v1";
|
|
9353
11044
|
var PROVIDER3 = "manual";
|
|
9354
11045
|
var ACCOUNT_KEY2 = "local";
|
|
@@ -9400,7 +11091,7 @@ function nameStableKey(displayName) {
|
|
|
9400
11091
|
schemaVersion: 1,
|
|
9401
11092
|
kind: "confirmed-name",
|
|
9402
11093
|
displayName,
|
|
9403
|
-
entropy:
|
|
11094
|
+
entropy: randomBytes4(32).toString("hex")
|
|
9404
11095
|
}))}`;
|
|
9405
11096
|
}
|
|
9406
11097
|
function explicitAttestationMetadata(note) {
|
|
@@ -9598,12 +11289,12 @@ function addConfirmedPerson(database, args) {
|
|
|
9598
11289
|
import { Database as Database3 } from "bun:sqlite";
|
|
9599
11290
|
import { createHash as createHash3 } from "crypto";
|
|
9600
11291
|
import {
|
|
9601
|
-
lstatSync as
|
|
11292
|
+
lstatSync as lstatSync5,
|
|
9602
11293
|
readdirSync as readdirSync2,
|
|
9603
11294
|
realpathSync as realpathSync4
|
|
9604
11295
|
} from "fs";
|
|
9605
11296
|
import { homedir as homedir2 } from "os";
|
|
9606
|
-
import { join as
|
|
11297
|
+
import { join as join5, resolve as resolve3 } from "path";
|
|
9607
11298
|
var EMAIL_PATTERN2 = /^(?=[\x21-\x7E]+$)[^@\s]+@[^@\s]+\.[^@\s]+$/u;
|
|
9608
11299
|
function clean(value) {
|
|
9609
11300
|
if (typeof value !== "string")
|
|
@@ -9614,7 +11305,7 @@ function clean(value) {
|
|
|
9614
11305
|
function normalizeEmail(value) {
|
|
9615
11306
|
return value.trim().toLocaleLowerCase("en-US");
|
|
9616
11307
|
}
|
|
9617
|
-
var DEFAULT_APPLE_CONTACTS_DIRECTORY =
|
|
11308
|
+
var DEFAULT_APPLE_CONTACTS_DIRECTORY = join5(homedir2(), "Library", "Application Support", "AddressBook");
|
|
9618
11309
|
var DEFAULT_APPLE_CONTACTS_ACCOUNT = "apple-contacts-main";
|
|
9619
11310
|
var MAX_SOURCE_DATABASES = 64;
|
|
9620
11311
|
var MAX_SOURCE_DATABASE_BYTES = 512 * 1024 * 1024;
|
|
@@ -9807,8 +11498,8 @@ function rowsByOwner(database, table, names) {
|
|
|
9807
11498
|
}
|
|
9808
11499
|
function sourceDatabasePaths(directory) {
|
|
9809
11500
|
const base = realpathSync4(resolve3(directory));
|
|
9810
|
-
const sourceDirectory =
|
|
9811
|
-
const sourceIdentity =
|
|
11501
|
+
const sourceDirectory = join5(base, "Sources");
|
|
11502
|
+
const sourceIdentity = lstatSync5(sourceDirectory);
|
|
9812
11503
|
if (!sourceIdentity.isDirectory() || sourceIdentity.isSymbolicLink()) {
|
|
9813
11504
|
throw new Error("Apple Contacts Sources must be a real directory");
|
|
9814
11505
|
}
|
|
@@ -9819,7 +11510,7 @@ function sourceDatabasePaths(directory) {
|
|
|
9819
11510
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(entry.name)) {
|
|
9820
11511
|
throw new Error("Apple Contacts source directory name is invalid");
|
|
9821
11512
|
}
|
|
9822
|
-
const candidateDirectory =
|
|
11513
|
+
const candidateDirectory = join5(sourceDirectory, entry.name);
|
|
9823
11514
|
const databases = readdirSync2(candidateDirectory, { withFileTypes: true }).filter((item) => item.isFile() && /^AddressBook-v\d+\.abcddb$/u.test(item.name));
|
|
9824
11515
|
if (databases.length === 0)
|
|
9825
11516
|
continue;
|
|
@@ -9828,8 +11519,8 @@ function sourceDatabasePaths(directory) {
|
|
|
9828
11519
|
const databaseName = databases[0]?.name;
|
|
9829
11520
|
if (databaseName === undefined)
|
|
9830
11521
|
throw new Error("Apple Contacts database name is missing");
|
|
9831
|
-
const candidate =
|
|
9832
|
-
const identity =
|
|
11522
|
+
const candidate = join5(candidateDirectory, databaseName);
|
|
11523
|
+
const identity = lstatSync5(candidate);
|
|
9833
11524
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
9834
11525
|
if (!identity.isFile() || identity.isSymbolicLink() || identity.size < 1 || identity.size > MAX_SOURCE_DATABASE_BYTES || uid !== null && identity.uid !== uid) {
|
|
9835
11526
|
throw new Error(`Apple Contacts store ${entry.name} is not a supported private database`);
|
|
@@ -9933,9 +11624,9 @@ function readStore(storeKey, path) {
|
|
|
9933
11624
|
values[child.name] = rows.map((row) => stableObject(row, metrics));
|
|
9934
11625
|
}
|
|
9935
11626
|
const serviceRows = childMaps.get("ZABCDMESSAGINGADDRESS")?.get(primaryKey) ?? [];
|
|
9936
|
-
values.ZABCDSERVICE = [...new Set(serviceRows.map((row) => row.ZSERVICE))].filter((
|
|
11627
|
+
values.ZABCDSERVICE = [...new Set(serviceRows.map((row) => row.ZSERVICE))].filter((id2) => typeof id2 === "number" && Number.isSafeInteger(id2)).map((id2) => serviceById.get(id2)).filter((row) => row !== undefined).map((row) => stableObject(row, metrics));
|
|
9937
11628
|
const customRows = childMaps.get("ZABCDCUSTOMPROPERTYVALUE")?.get(primaryKey) ?? [];
|
|
9938
|
-
values.ZABCDCUSTOMPROPERTY = [...new Set(customRows.map((row) => row.ZCUSTOMPROPERTY))].filter((
|
|
11629
|
+
values.ZABCDCUSTOMPROPERTY = [...new Set(customRows.map((row) => row.ZCUSTOMPROPERTY))].filter((id2) => typeof id2 === "number" && Number.isSafeInteger(id2)).map((id2) => customPropertyById.get(id2)).filter((row) => row !== undefined).map((row) => stableObject(row, metrics));
|
|
9939
11630
|
values.ZABCDGROUPS = (groups.get(primaryKey) ?? []).map((row) => stableObject(row, metrics));
|
|
9940
11631
|
const rawJson = stableJson({
|
|
9941
11632
|
schemaVersion: 1,
|
|
@@ -10170,15 +11861,15 @@ function syncAppleContacts(database, options = {}) {
|
|
|
10170
11861
|
}
|
|
10171
11862
|
|
|
10172
11863
|
// src/local/source-ingestion.ts
|
|
10173
|
-
import { z as
|
|
10174
|
-
var providerSchema =
|
|
10175
|
-
var boundedText3 = (maximum) =>
|
|
11864
|
+
import { z as z10 } from "zod";
|
|
11865
|
+
var providerSchema = z10.string().regex(/^[a-z][a-z0-9-]{0,63}$/u);
|
|
11866
|
+
var boundedText3 = (maximum) => z10.string().min(1).max(maximum).refine((value) => !/[\u0000\r]/u.test(value), "Text contains an unsupported control character");
|
|
10176
11867
|
var nullableText3 = (maximum) => boundedText3(maximum).nullable();
|
|
10177
|
-
var digestSchema2 =
|
|
10178
|
-
var timestampSchema2 =
|
|
11868
|
+
var digestSchema2 = z10.string().regex(/^[a-f0-9]{64}$/u);
|
|
11869
|
+
var timestampSchema2 = z10.iso.datetime({ offset: true });
|
|
10179
11870
|
var sqliteNoCase = (value) => value.replace(/[A-Z]/gu, (character) => character.toLowerCase());
|
|
10180
|
-
var jsonObjectSchema =
|
|
10181
|
-
var personSchema =
|
|
11871
|
+
var jsonObjectSchema = z10.record(z10.string(), z10.json()).refine((value) => Buffer.byteLength(canonicalJson(value), "utf8") <= 64 * 1024, "Metadata exceeds 64 KiB");
|
|
11872
|
+
var personSchema = z10.object({
|
|
10182
11873
|
displayName: nullableText3(1024),
|
|
10183
11874
|
givenName: nullableText3(1024),
|
|
10184
11875
|
middleName: nullableText3(1024),
|
|
@@ -10191,16 +11882,16 @@ var personSchema = z8.object({
|
|
|
10191
11882
|
title: nullableText3(1024),
|
|
10192
11883
|
birthday: birthdaySchema,
|
|
10193
11884
|
observationBasis: boundedText3(128),
|
|
10194
|
-
observationPriority:
|
|
11885
|
+
observationPriority: z10.number().int().min(0).max(1000),
|
|
10195
11886
|
metadata: jsonObjectSchema
|
|
10196
11887
|
}).strict();
|
|
10197
|
-
var resourceSchema =
|
|
11888
|
+
var resourceSchema = z10.object({
|
|
10198
11889
|
type: boundedText3(64),
|
|
10199
11890
|
id: boundedText3(4096),
|
|
10200
11891
|
username: nullableText3(2048),
|
|
10201
11892
|
profileUrl: nullableText3(4096),
|
|
10202
|
-
profileUrlIdentityEligible:
|
|
10203
|
-
nameIdentityEligible:
|
|
11893
|
+
profileUrlIdentityEligible: z10.boolean(),
|
|
11894
|
+
nameIdentityEligible: z10.boolean(),
|
|
10204
11895
|
displayName: nullableText3(2048),
|
|
10205
11896
|
metadata: jsonObjectSchema
|
|
10206
11897
|
}).strict().superRefine((resource, context) => {
|
|
@@ -10211,20 +11902,20 @@ var resourceSchema = z8.object({
|
|
|
10211
11902
|
message: "An identity-eligible profile URL must be a stored HTTP(S) profile URL"
|
|
10212
11903
|
});
|
|
10213
11904
|
});
|
|
10214
|
-
var methodSchema =
|
|
10215
|
-
kind:
|
|
11905
|
+
var methodSchema = z10.object({
|
|
11906
|
+
kind: z10.enum(["email", "phone", "url", "address", "date", "social", "other"]),
|
|
10216
11907
|
value: boundedText3(8192),
|
|
10217
11908
|
normalizedValue: boundedText3(8192),
|
|
10218
|
-
label:
|
|
10219
|
-
primary:
|
|
10220
|
-
confidence:
|
|
10221
|
-
identityEligible:
|
|
11909
|
+
label: z10.string().max(256),
|
|
11910
|
+
primary: z10.boolean(),
|
|
11911
|
+
confidence: z10.enum(["exact", "likely", "possible", "unknown"]),
|
|
11912
|
+
identityEligible: z10.boolean(),
|
|
10222
11913
|
metadata: jsonObjectSchema
|
|
10223
11914
|
}).strict();
|
|
10224
|
-
var contactSchema =
|
|
11915
|
+
var contactSchema = z10.object({
|
|
10225
11916
|
person: personSchema,
|
|
10226
11917
|
resource: resourceSchema,
|
|
10227
|
-
methods:
|
|
11918
|
+
methods: z10.array(methodSchema).max(1000)
|
|
10228
11919
|
}).strict().superRefine((contact, context) => {
|
|
10229
11920
|
const coordinates = new Set;
|
|
10230
11921
|
contact.methods.forEach((method, index) => {
|
|
@@ -10235,24 +11926,24 @@ var contactSchema = z8.object({
|
|
|
10235
11926
|
coordinates.add(coordinate);
|
|
10236
11927
|
});
|
|
10237
11928
|
});
|
|
10238
|
-
var completenessSchema =
|
|
10239
|
-
var coverageSchema =
|
|
10240
|
-
localEnumerationComplete:
|
|
10241
|
-
remoteSetComplete:
|
|
10242
|
-
truncated:
|
|
10243
|
-
absencePolicy:
|
|
10244
|
-
warnings:
|
|
11929
|
+
var completenessSchema = z10.enum(["complete", "partial", "lower-bound", "unknown"]);
|
|
11930
|
+
var coverageSchema = z10.object({
|
|
11931
|
+
localEnumerationComplete: z10.boolean(),
|
|
11932
|
+
remoteSetComplete: z10.boolean(),
|
|
11933
|
+
truncated: z10.boolean(),
|
|
11934
|
+
absencePolicy: z10.enum(["reconcile-observed", "preserve"]),
|
|
11935
|
+
warnings: z10.array(boundedText3(128)).max(32)
|
|
10245
11936
|
}).strict();
|
|
10246
|
-
var interactionSchema =
|
|
11937
|
+
var interactionSchema = z10.object({
|
|
10247
11938
|
resourceType: boundedText3(64),
|
|
10248
11939
|
resourceId: boundedText3(4096),
|
|
10249
|
-
sentCount:
|
|
10250
|
-
receivedCount:
|
|
10251
|
-
interactionCount:
|
|
10252
|
-
conversationCount:
|
|
11940
|
+
sentCount: z10.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
11941
|
+
receivedCount: z10.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
11942
|
+
interactionCount: z10.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
11943
|
+
conversationCount: z10.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable(),
|
|
10253
11944
|
firstInteractionAt: timestampSchema2.nullable(),
|
|
10254
11945
|
lastInteractionAt: timestampSchema2.nullable(),
|
|
10255
|
-
reciprocal:
|
|
11946
|
+
reciprocal: z10.boolean(),
|
|
10256
11947
|
completeness: completenessSchema,
|
|
10257
11948
|
metadata: jsonObjectSchema
|
|
10258
11949
|
}).strict().superRefine((interaction, context) => {
|
|
@@ -10265,32 +11956,32 @@ var interactionSchema = z8.object({
|
|
|
10265
11956
|
if (interaction.interactionCount === 0 && (interaction.firstInteractionAt !== null || interaction.lastInteractionAt !== null) || interaction.interactionCount > 0 && (interaction.firstInteractionAt === null || interaction.lastInteractionAt === null) || interaction.firstInteractionAt !== null && interaction.lastInteractionAt !== null && Date.parse(interaction.firstInteractionAt) > Date.parse(interaction.lastInteractionAt))
|
|
10266
11957
|
context.addIssue({ code: "custom", path: ["firstInteractionAt"], message: "Interaction timestamps do not match the observed count" });
|
|
10267
11958
|
});
|
|
10268
|
-
var sourceBindingSchema =
|
|
11959
|
+
var sourceBindingSchema = z10.object({
|
|
10269
11960
|
authId: boundedText3(128),
|
|
10270
11961
|
authSha256: digestSchema2,
|
|
10271
11962
|
subjectSha256: digestSchema2,
|
|
10272
11963
|
metadata: jsonObjectSchema
|
|
10273
11964
|
}).strict();
|
|
10274
|
-
var sourceRealmSchema =
|
|
11965
|
+
var sourceRealmSchema = z10.object({
|
|
10275
11966
|
service: boundedText3(64),
|
|
10276
11967
|
identityNamespace: boundedText3(128),
|
|
10277
11968
|
externalIdSha256: digestSchema2,
|
|
10278
11969
|
metadata: jsonObjectSchema
|
|
10279
11970
|
}).strict();
|
|
10280
|
-
var sourceRealmInventoryEntrySchema =
|
|
11971
|
+
var sourceRealmInventoryEntrySchema = z10.object({
|
|
10281
11972
|
accountKey: boundedText3(256),
|
|
10282
11973
|
sourceRealm: sourceRealmSchema
|
|
10283
11974
|
}).strict();
|
|
10284
|
-
var sourceRealmInventorySchema =
|
|
10285
|
-
schemaVersion:
|
|
11975
|
+
var sourceRealmInventorySchema = z10.object({
|
|
11976
|
+
schemaVersion: z10.literal(1),
|
|
10286
11977
|
provider: providerSchema,
|
|
10287
11978
|
observedAt: timestampSchema2,
|
|
10288
11979
|
sourceBinding: sourceBindingSchema,
|
|
10289
|
-
localEnumerationComplete:
|
|
10290
|
-
truncated:
|
|
10291
|
-
absencePolicy:
|
|
10292
|
-
realms:
|
|
10293
|
-
warnings:
|
|
11980
|
+
localEnumerationComplete: z10.boolean(),
|
|
11981
|
+
truncated: z10.boolean(),
|
|
11982
|
+
absencePolicy: z10.enum(["reconcile-observed", "preserve"]),
|
|
11983
|
+
realms: z10.array(sourceRealmInventoryEntrySchema).max(128),
|
|
11984
|
+
warnings: z10.array(boundedText3(128)).max(32)
|
|
10294
11985
|
}).strict().superRefine((inventory, context) => {
|
|
10295
11986
|
if (inventory.absencePolicy === "reconcile-observed" && (!inventory.localEnumerationComplete || inventory.truncated)) {
|
|
10296
11987
|
context.addIssue({
|
|
@@ -10313,8 +12004,8 @@ var sourceRealmInventorySchema = z8.object({
|
|
|
10313
12004
|
externalIds.add(entry.sourceRealm.externalIdSha256);
|
|
10314
12005
|
});
|
|
10315
12006
|
});
|
|
10316
|
-
var contactSourceSnapshotSchema =
|
|
10317
|
-
schemaVersion:
|
|
12007
|
+
var contactSourceSnapshotSchema = z10.object({
|
|
12008
|
+
schemaVersion: z10.literal(1),
|
|
10318
12009
|
provider: providerSchema,
|
|
10319
12010
|
accountKey: boundedText3(256),
|
|
10320
12011
|
mode: boundedText3(64),
|
|
@@ -10325,8 +12016,8 @@ var contactSourceSnapshotSchema = z8.object({
|
|
|
10325
12016
|
observedAt: timestampSchema2,
|
|
10326
12017
|
sourceBinding: sourceBindingSchema,
|
|
10327
12018
|
sourceRealm: sourceRealmSchema,
|
|
10328
|
-
contacts:
|
|
10329
|
-
interactions:
|
|
12019
|
+
contacts: z10.array(contactSchema).max(250000),
|
|
12020
|
+
interactions: z10.array(interactionSchema).max(250000),
|
|
10330
12021
|
metadata: jsonObjectSchema
|
|
10331
12022
|
}).strict().superRefine((snapshot, context) => {
|
|
10332
12023
|
if (snapshot.coverage.absencePolicy === "reconcile-observed" && (!snapshot.coverage.localEnumerationComplete || snapshot.coverage.truncated)) {
|
|
@@ -10366,19 +12057,19 @@ function resourceStableKey(snapshot, resource) {
|
|
|
10366
12057
|
return `source:${sha256(canonicalJson([snapshot.provider, snapshot.accountKey, resource.type, resource.id]))}`;
|
|
10367
12058
|
}
|
|
10368
12059
|
function parseCachedResult(value) {
|
|
10369
|
-
return
|
|
10370
|
-
account_key:
|
|
10371
|
-
source_rows:
|
|
10372
|
-
people_created:
|
|
10373
|
-
people_matched:
|
|
10374
|
-
methods_touched:
|
|
10375
|
-
interactions_touched:
|
|
10376
|
-
resources_removed:
|
|
10377
|
-
reconciled:
|
|
12060
|
+
return z10.object({
|
|
12061
|
+
account_key: z10.string(),
|
|
12062
|
+
source_rows: z10.number().int(),
|
|
12063
|
+
people_created: z10.number().int(),
|
|
12064
|
+
people_matched: z10.number().int(),
|
|
12065
|
+
methods_touched: z10.number().int(),
|
|
12066
|
+
interactions_touched: z10.number().int(),
|
|
12067
|
+
resources_removed: z10.number().int(),
|
|
12068
|
+
reconciled: z10.boolean(),
|
|
10378
12069
|
completeness: completenessSchema,
|
|
10379
|
-
local_enumeration_complete:
|
|
10380
|
-
remote_set_complete:
|
|
10381
|
-
truncated:
|
|
12070
|
+
local_enumeration_complete: z10.boolean(),
|
|
12071
|
+
remote_set_complete: z10.boolean(),
|
|
12072
|
+
truncated: z10.boolean()
|
|
10382
12073
|
}).strict().transform((result) => ({ ...result, cached: true })).parse(JSON.parse(value));
|
|
10383
12074
|
}
|
|
10384
12075
|
function bindSourceRealm(database, snapshot) {
|
|
@@ -16485,7 +18176,7 @@ function plainDataObject(value, label) {
|
|
|
16485
18176
|
}
|
|
16486
18177
|
return descriptors;
|
|
16487
18178
|
}
|
|
16488
|
-
function
|
|
18179
|
+
function positiveInteger2(value, label, maximum) {
|
|
16489
18180
|
if (value === undefined)
|
|
16490
18181
|
return;
|
|
16491
18182
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > maximum)
|
|
@@ -16507,9 +18198,9 @@ function prepareRequest2(value) {
|
|
|
16507
18198
|
const authId = descriptors.authId?.value;
|
|
16508
18199
|
if (typeof authId !== "string" || !/^[a-z][a-z0-9-]{0,127}$/u.test(authId))
|
|
16509
18200
|
return fail22("authId must be lowercase kebab text");
|
|
16510
|
-
const limitChats =
|
|
16511
|
-
const limitMessages =
|
|
16512
|
-
const maxParticipants =
|
|
18201
|
+
const limitChats = positiveInteger2(descriptors.limitChats?.value, "limitChats", 1e5);
|
|
18202
|
+
const limitMessages = positiveInteger2(descriptors.limitMessages?.value, "limitMessages", 1e6);
|
|
18203
|
+
const maxParticipants = positiveInteger2(descriptors.maxParticipants?.value, "maxParticipants", 2000);
|
|
16513
18204
|
return Object.freeze({
|
|
16514
18205
|
authId,
|
|
16515
18206
|
...limitChats === undefined ? {} : { limitChats },
|
|
@@ -16631,7 +18322,7 @@ function timestamp4(value, label) {
|
|
|
16631
18322
|
throw new Error(`${label} must be an ISO timestamp.`);
|
|
16632
18323
|
return new Date(milliseconds).toISOString();
|
|
16633
18324
|
}
|
|
16634
|
-
function
|
|
18325
|
+
function json3(value, label) {
|
|
16635
18326
|
const encoded = canonicalJson(value);
|
|
16636
18327
|
if (Buffer.byteLength(encoded, "utf8") > 64 * 1024)
|
|
16637
18328
|
throw new Error(`${label} exceeds its byte limit.`);
|
|
@@ -16657,9 +18348,9 @@ function recordSourceExecutionReceipt(database, input) {
|
|
|
16657
18348
|
const completedAt = timestamp4(input.completedAt, "Execution completion");
|
|
16658
18349
|
if (completedAt !== null && completedAt < startedAt)
|
|
16659
18350
|
throw new Error("Execution timing is invalid.");
|
|
16660
|
-
const usageJson =
|
|
16661
|
-
const costJson =
|
|
16662
|
-
const metadataJson =
|
|
18351
|
+
const usageJson = json3(input.usage, "Execution usage");
|
|
18352
|
+
const costJson = json3(input.cost, "Execution cost");
|
|
18353
|
+
const metadataJson = json3(input.metadata, "Execution metadata");
|
|
16663
18354
|
const existing = database.query(`SELECT id,provider,account_key,capability,operation,transport,
|
|
16664
18355
|
implementation_id,implementation_version,implementation_sha256,contract_sha256,
|
|
16665
18356
|
external_run_id_sha256,input_sha256,outcome,usage_json,cost_json,metadata_json,
|
|
@@ -16834,9 +18525,9 @@ var SUMMARY_CONTRACT_SHA256 = sha256(canonicalJson({
|
|
|
16834
18525
|
sourceVersion: "1.1.0"
|
|
16835
18526
|
}
|
|
16836
18527
|
}));
|
|
16837
|
-
function validateBeeperContactInteractionAccounts(
|
|
18528
|
+
function validateBeeperContactInteractionAccounts(summary2, connectedAccounts) {
|
|
16838
18529
|
const expected = [...connectedAccounts].map((account) => ({ accountId: account.accountId, service: account.service })).sort((left, right) => left.accountId.localeCompare(right.accountId));
|
|
16839
|
-
const observed =
|
|
18530
|
+
const observed = summary2.accounts.map((account) => ({ accountId: account.accountId, service: normalizeBeeperService(account.network) })).sort((left, right) => left.accountId.localeCompare(right.accountId));
|
|
16840
18531
|
if (canonicalJson(observed) !== canonicalJson(expected)) {
|
|
16841
18532
|
throw new Error("Beeper interaction summary connected-account inventory drifted");
|
|
16842
18533
|
}
|
|
@@ -16909,7 +18600,7 @@ function ledgerBeeperContactInteractionExport(database, accountKey2, result) {
|
|
|
16909
18600
|
completedAt: receipt.finishedAt
|
|
16910
18601
|
});
|
|
16911
18602
|
}
|
|
16912
|
-
function projectBeeperContactInteractions(
|
|
18603
|
+
function projectBeeperContactInteractions(summary2, coordinates) {
|
|
16913
18604
|
const accounts = new Map;
|
|
16914
18605
|
for (const coordinate2 of coordinates) {
|
|
16915
18606
|
const prior = accounts.get(coordinate2.rawAccountId);
|
|
@@ -16918,7 +18609,7 @@ function projectBeeperContactInteractions(summary, coordinates) {
|
|
|
16918
18609
|
}
|
|
16919
18610
|
accounts.set(coordinate2.rawAccountId, { accountKey: coordinate2.accountKey, service: coordinate2.service });
|
|
16920
18611
|
}
|
|
16921
|
-
const summaryAccounts = new Map(
|
|
18612
|
+
const summaryAccounts = new Map(summary2.accounts.map((account) => [
|
|
16922
18613
|
account.accountId,
|
|
16923
18614
|
normalizeBeeperService(account.network)
|
|
16924
18615
|
]));
|
|
@@ -16933,7 +18624,7 @@ function projectBeeperContactInteractions(summary, coordinates) {
|
|
|
16933
18624
|
]));
|
|
16934
18625
|
const grouped = new Map;
|
|
16935
18626
|
let matched = 0;
|
|
16936
|
-
for (const interaction of
|
|
18627
|
+
for (const interaction of summary2.interactions) {
|
|
16937
18628
|
const coordinate2 = contacts.get(`${interaction.accountId}\x00${interaction.contactId}`);
|
|
16938
18629
|
if (coordinate2 === undefined)
|
|
16939
18630
|
continue;
|
|
@@ -16954,11 +18645,11 @@ function projectBeeperContactInteractions(summary, coordinates) {
|
|
|
16954
18645
|
completeness: interaction.completeness,
|
|
16955
18646
|
metadata: {
|
|
16956
18647
|
basis: "wrench-contact-interaction-summary-v1",
|
|
16957
|
-
summarySha256:
|
|
18648
|
+
summarySha256: summary2.integrity.summarySha256,
|
|
16958
18649
|
sourceVersion: interaction.provenance.sourceVersion,
|
|
16959
18650
|
providerVersion: interaction.provenance.providerVersion,
|
|
16960
|
-
conversationScope:
|
|
16961
|
-
messageScope:
|
|
18651
|
+
conversationScope: summary2.scope.conversations,
|
|
18652
|
+
messageScope: summary2.scope.messages
|
|
16962
18653
|
}
|
|
16963
18654
|
});
|
|
16964
18655
|
grouped.set(coordinate2.accountKey, items);
|
|
@@ -16969,7 +18660,7 @@ function projectBeeperContactInteractions(summary, coordinates) {
|
|
|
16969
18660
|
return {
|
|
16970
18661
|
accountKey: accountKey2,
|
|
16971
18662
|
relationshipSourceSha256: sha256(canonicalJson({
|
|
16972
|
-
summarySha256:
|
|
18663
|
+
summarySha256: summary2.integrity.summarySha256,
|
|
16973
18664
|
accountKey: accountKey2,
|
|
16974
18665
|
interactions
|
|
16975
18666
|
})),
|
|
@@ -16979,10 +18670,10 @@ function projectBeeperContactInteractions(summary, coordinates) {
|
|
|
16979
18670
|
};
|
|
16980
18671
|
});
|
|
16981
18672
|
return {
|
|
16982
|
-
summarySha256:
|
|
16983
|
-
directRelationships:
|
|
18673
|
+
summarySha256: summary2.integrity.summarySha256,
|
|
18674
|
+
directRelationships: summary2.interactions.length,
|
|
16984
18675
|
matchedRelationships: matched,
|
|
16985
|
-
unmatchedRelationships:
|
|
18676
|
+
unmatchedRelationships: summary2.interactions.length - matched,
|
|
16986
18677
|
contactReferences: coordinates.length,
|
|
16987
18678
|
unmatchedContactReferences: coordinates.length - matched,
|
|
16988
18679
|
groups
|
|
@@ -18022,7 +19713,7 @@ var MESSAGING_SEARCH_CONTRACT_SHA256 = BEEPER_WRENCH_COMPATIBILITY.operations["m
|
|
|
18022
19713
|
var FINAL_ORIGIN2 = BEEPER_WRENCH_COMPATIBILITY.adapter.origin;
|
|
18023
19714
|
var MAX_QUERIES = 50;
|
|
18024
19715
|
var MAX_RESULTS = 20;
|
|
18025
|
-
var
|
|
19716
|
+
var SHA2562 = /^[a-f0-9]{64}$/u;
|
|
18026
19717
|
var AUTH_ID2 = /^[a-z][a-z0-9-]{0,127}$/u;
|
|
18027
19718
|
function record4(value, label) {
|
|
18028
19719
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -18076,7 +19767,7 @@ function integer3(value, label, maximum) {
|
|
|
18076
19767
|
}
|
|
18077
19768
|
function digest4(value, label) {
|
|
18078
19769
|
const parsed = text3(value, label, 64);
|
|
18079
|
-
if (!
|
|
19770
|
+
if (!SHA2562.test(parsed))
|
|
18080
19771
|
throw new Error(`${label} must be a SHA-256 digest`);
|
|
18081
19772
|
return parsed;
|
|
18082
19773
|
}
|
|
@@ -18305,8 +19996,8 @@ function searchCandidates(account, contacts, conversations) {
|
|
|
18305
19996
|
mergeCandidate(candidates, peers[0], "messaging.search");
|
|
18306
19997
|
}
|
|
18307
19998
|
return {
|
|
18308
|
-
candidates: [...candidates.entries()].map(([
|
|
18309
|
-
id,
|
|
19999
|
+
candidates: [...candidates.entries()].map(([id2, candidate]) => ({
|
|
20000
|
+
id: id2,
|
|
18310
20001
|
displayName: candidate.displayName,
|
|
18311
20002
|
username: candidate.username,
|
|
18312
20003
|
origins: [...candidate.origins].sort()
|
|
@@ -19540,10 +21231,10 @@ function googleContactStats(database) {
|
|
|
19540
21231
|
// src/local/providers/imessage.ts
|
|
19541
21232
|
import { Database as Database4 } from "bun:sqlite";
|
|
19542
21233
|
import { createHash as createHash6 } from "crypto";
|
|
19543
|
-
import { lstatSync as
|
|
21234
|
+
import { lstatSync as lstatSync6, realpathSync as realpathSync5 } from "fs";
|
|
19544
21235
|
import { homedir as homedir3 } from "os";
|
|
19545
|
-
import { join as
|
|
19546
|
-
var DEFAULT_IMESSAGE_DATABASE =
|
|
21236
|
+
import { join as join6, resolve as resolve4 } from "path";
|
|
21237
|
+
var DEFAULT_IMESSAGE_DATABASE = join6(homedir3(), "Library", "Messages", "chat.db");
|
|
19547
21238
|
var DEFAULT_IMESSAGE_ACCOUNT = "imessage-main";
|
|
19548
21239
|
var DEFAULT_IMESSAGE_PAGE_SIZE = 1000;
|
|
19549
21240
|
var MAX_DATABASE_BYTES = 4 * 1024 * 1024 * 1024;
|
|
@@ -19657,11 +21348,11 @@ function loadHandles(database) {
|
|
|
19657
21348
|
const result = new Map;
|
|
19658
21349
|
for (const row of allRows3(database, "SELECT ROWID,id,service FROM handle ORDER BY ROWID")) {
|
|
19659
21350
|
const rowid = safeInteger2(row.ROWID, "iMessage handle ROWID");
|
|
19660
|
-
const
|
|
21351
|
+
const id2 = boundedText4(row.id, "iMessage handle identity");
|
|
19661
21352
|
const service = boundedText4(row.service, "iMessage handle service");
|
|
19662
21353
|
if (result.has(rowid))
|
|
19663
21354
|
throw new Error("iMessage handle ROWIDs are duplicated");
|
|
19664
|
-
result.set(rowid, { hash: sha2566(
|
|
21355
|
+
result.set(rowid, { hash: sha2566(id2), service, identity: exactMessageHandleIdentity(id2) });
|
|
19665
21356
|
}
|
|
19666
21357
|
return result;
|
|
19667
21358
|
}
|
|
@@ -19987,7 +21678,7 @@ function syncIMessageRelationships(database, options = {}) {
|
|
|
19987
21678
|
if (!Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE)
|
|
19988
21679
|
throw new Error(`iMessage page size must be between 1 and ${MAX_PAGE_SIZE}`);
|
|
19989
21680
|
const requestedPath = resolve4(options.messagesDatabase ?? DEFAULT_IMESSAGE_DATABASE);
|
|
19990
|
-
const identity =
|
|
21681
|
+
const identity = lstatSync6(requestedPath);
|
|
19991
21682
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
19992
21683
|
if (!identity.isFile() || identity.isSymbolicLink() || identity.size < 1 || identity.size > MAX_DATABASE_BYTES || uid !== null && identity.uid !== uid) {
|
|
19993
21684
|
throw new Error("iMessage source must be a supported database owned by the current user");
|
|
@@ -20154,12 +21845,12 @@ function validateExtra(bytes, label) {
|
|
|
20154
21845
|
while (position < bytes.length) {
|
|
20155
21846
|
if (bytes.length - position < 4)
|
|
20156
21847
|
throw new Error(`ZIP ${label} contains a truncated extra field`);
|
|
20157
|
-
const
|
|
21848
|
+
const id2 = bytes.readUInt16LE(position);
|
|
20158
21849
|
const length = bytes.readUInt16LE(position + 2);
|
|
20159
21850
|
const next = end(position + 4, length, `${label} extra field`);
|
|
20160
21851
|
if (next > bytes.length)
|
|
20161
21852
|
throw new Error(`ZIP ${label} contains a truncated extra field`);
|
|
20162
|
-
if (
|
|
21853
|
+
if (id2 === ZIP64_EXTRA)
|
|
20163
21854
|
throw new Error("ZIP64 archives are not supported");
|
|
20164
21855
|
position = next;
|
|
20165
21856
|
}
|
|
@@ -20603,11 +22294,11 @@ function providerId2(value, label) {
|
|
|
20603
22294
|
return String(value);
|
|
20604
22295
|
}
|
|
20605
22296
|
function oneProviderId(value, label) {
|
|
20606
|
-
const
|
|
22297
|
+
const id2 = providerId2(value.id, `${label}.id`);
|
|
20607
22298
|
const userId = providerId2(value.user_id, `${label}.user_id`);
|
|
20608
|
-
if (
|
|
22299
|
+
if (id2 !== null && userId !== null && id2 !== userId)
|
|
20609
22300
|
throw new Error(`${label} contains conflicting exact provider IDs`);
|
|
20610
|
-
return
|
|
22301
|
+
return id2 ?? userId;
|
|
20611
22302
|
}
|
|
20612
22303
|
function profileHref(value, handle, label) {
|
|
20613
22304
|
const text5 = bounded(value, label, 2048, true);
|
|
@@ -20716,12 +22407,12 @@ function messageItems(member) {
|
|
|
20716
22407
|
const handle = optionalUsername(name);
|
|
20717
22408
|
if (handle !== null)
|
|
20718
22409
|
names.add(handle);
|
|
20719
|
-
const
|
|
20720
|
-
if (
|
|
22410
|
+
const id2 = oneProviderId(participant, label);
|
|
22411
|
+
if (id2 !== null && ids.has(id2))
|
|
20721
22412
|
throw new Error(`${label} repeats an ID`);
|
|
20722
|
-
if (
|
|
20723
|
-
ids.add(
|
|
20724
|
-
participants.push({ providerUserId:
|
|
22413
|
+
if (id2 !== null)
|
|
22414
|
+
ids.add(id2);
|
|
22415
|
+
participants.push({ providerUserId: id2, username: handle });
|
|
20725
22416
|
}
|
|
20726
22417
|
return dense(root.messages, `${member.memberName}.messages`, MAX_MESSAGES).map((value, index) => {
|
|
20727
22418
|
const label = `${member.memberName}.messages[${index}]`;
|
|
@@ -20902,33 +22593,33 @@ function importInstagramArchive(database, path, options) {
|
|
|
20902
22593
|
const values = aliases.get(handle);
|
|
20903
22594
|
return values?.size === 1 ? [...values.values()][0] : null;
|
|
20904
22595
|
};
|
|
20905
|
-
const ensure = (
|
|
22596
|
+
const ensure = (id2, href, handle, display) => {
|
|
20906
22597
|
if (handle === owner)
|
|
20907
22598
|
return null;
|
|
20908
22599
|
const canonicalHref = href ?? (handle === null ? null : `https://www.instagram.com/${handle}/`);
|
|
20909
|
-
if (
|
|
22600
|
+
if (id2 === null && canonicalHref === null)
|
|
20910
22601
|
return null;
|
|
20911
|
-
const byId =
|
|
22602
|
+
const byId = id2 === null ? null : getRow6(database, "SELECT id,person_id,username,profile_url FROM provider_resources WHERE provider=? AND account_key=? AND resource_type='user' AND resource_id=?", PROVIDER8, account, id2);
|
|
20912
22603
|
const byHref = canonicalHref === null ? null : getRow6(database, "SELECT id,person_id,username,profile_url FROM provider_resources WHERE provider=? AND account_key=? AND profile_url=? ORDER BY id LIMIT 1", PROVIDER8, account, canonicalHref);
|
|
20913
22604
|
if (byId !== null && byHref !== null && byId.person_id !== byHref.person_id)
|
|
20914
22605
|
throw new Error("Instagram ID and profile URL identify different people");
|
|
20915
22606
|
let personId = byId?.person_id ?? byHref?.person_id ?? null;
|
|
20916
22607
|
if (personId === null) {
|
|
20917
|
-
personId = insertedId6(run5(database, "INSERT INTO people(stable_key,display_name,metadata_json) VALUES (?,?,?)", `instagram:${sha2567(`${account}\x00${
|
|
22608
|
+
personId = insertedId6(run5(database, "INSERT INTO people(stable_key,display_name,metadata_json) VALUES (?,?,?)", `instagram:${sha2567(`${account}\x00${id2 ?? canonicalHref}`)}`, display ?? handle, stableJson4({ createdBy: "instagram-exact-resource" })));
|
|
20918
22609
|
peopleCreated += 1;
|
|
20919
22610
|
} else if (display !== null || handle !== null)
|
|
20920
22611
|
run5(database, "UPDATE people SET display_name=coalesce(display_name,?) WHERE id=?", display ?? handle, personId);
|
|
20921
22612
|
peopleTouched.add(personId);
|
|
20922
|
-
if (
|
|
22613
|
+
if (id2 !== null && byId === null) {
|
|
20923
22614
|
const prior = allRows4(database, "SELECT resource_id FROM provider_resources WHERE provider=? AND account_key=? AND resource_type='user' AND person_id=?", PROVIDER8, account, personId);
|
|
20924
22615
|
if (prior.length > 0)
|
|
20925
22616
|
throw new Error("Instagram profile is bound to conflicting numeric IDs");
|
|
20926
22617
|
}
|
|
20927
22618
|
let primary = byId?.id ?? byHref?.id ?? null;
|
|
20928
|
-
if (
|
|
20929
|
-
primary = insertedId6(run5(database, "INSERT INTO provider_resources(provider,account_key,resource_type,resource_id,person_id,username,profile_url,display_name,metadata_json) VALUES (?,?,'user',?,?,?,?,?,?)", PROVIDER8, account,
|
|
22619
|
+
if (id2 !== null && byId === null) {
|
|
22620
|
+
primary = insertedId6(run5(database, "INSERT INTO provider_resources(provider,account_key,resource_type,resource_id,person_id,username,profile_url,display_name,metadata_json) VALUES (?,?,'user',?,?,?,?,?,?)", PROVIDER8, account, id2, personId, handle, canonicalHref, display ?? handle, "{}"));
|
|
20930
22621
|
identitiesCreated += 1;
|
|
20931
|
-
} else if (
|
|
22622
|
+
} else if (id2 === null && canonicalHref !== null && byHref === null) {
|
|
20932
22623
|
primary = insertedId6(run5(database, "INSERT INTO provider_resources(provider,account_key,resource_type,resource_id,person_id,username,profile_url,display_name,metadata_json) VALUES (?,?,'profile',?,?,?,?,?,?)", PROVIDER8, account, canonicalHref, personId, handle, canonicalHref, display ?? handle, "{}"));
|
|
20933
22624
|
identitiesCreated += 1;
|
|
20934
22625
|
}
|
|
@@ -20936,7 +22627,7 @@ function importInstagramArchive(database, path, options) {
|
|
|
20936
22627
|
throw new Error("Instagram identity was not materialized");
|
|
20937
22628
|
run5(database, "UPDATE provider_resources SET username=coalesce(?,username),profile_url=coalesce(?,profile_url),profile_url_identity_eligible=CASE WHEN ? IS NULL THEN profile_url_identity_eligible ELSE 1 END,display_name=coalesce(?,display_name),active=1 WHERE id=?", handle, canonicalHref, canonicalHref, display ?? handle, primary);
|
|
20938
22629
|
touchedResources.add(primary);
|
|
20939
|
-
const identity = { personId, primaryResourceId: primary, username: handle, profileHref: canonicalHref, providerUserId:
|
|
22630
|
+
const identity = { personId, primaryResourceId: primary, username: handle, profileHref: canonicalHref, providerUserId: id2 };
|
|
20940
22631
|
observe(handle, identity);
|
|
20941
22632
|
return identity;
|
|
20942
22633
|
};
|
|
@@ -21233,14 +22924,14 @@ function extraFields(bytes, label) {
|
|
|
21233
22924
|
while (position < bytes.length) {
|
|
21234
22925
|
if (bytes.length - position < 4)
|
|
21235
22926
|
throw new Error(`X ZIP ${label} contains a truncated extra field`);
|
|
21236
|
-
const
|
|
22927
|
+
const id2 = bytes.readUInt16LE(position);
|
|
21237
22928
|
const length = bytes.readUInt16LE(position + 2);
|
|
21238
22929
|
const next = checkedEnd(position + 4, length, `${label} extra field`);
|
|
21239
22930
|
if (next > bytes.length)
|
|
21240
22931
|
throw new Error(`X ZIP ${label} contains a truncated extra field`);
|
|
21241
|
-
if (fields.has(
|
|
21242
|
-
throw new Error(`X ZIP ${label} contains duplicate extra field ${
|
|
21243
|
-
fields.set(
|
|
22932
|
+
if (fields.has(id2))
|
|
22933
|
+
throw new Error(`X ZIP ${label} contains duplicate extra field ${id2}`);
|
|
22934
|
+
fields.set(id2, bytes.subarray(position + 4, next));
|
|
21244
22935
|
position = next;
|
|
21245
22936
|
}
|
|
21246
22937
|
return fields;
|
|
@@ -21663,7 +23354,7 @@ function timestamp5(value, label) {
|
|
|
21663
23354
|
throw new Error(`${label} is not a timestamp`);
|
|
21664
23355
|
return new Date(milliseconds2).toISOString();
|
|
21665
23356
|
}
|
|
21666
|
-
function userLink(value,
|
|
23357
|
+
function userLink(value, id2, label) {
|
|
21667
23358
|
const raw = text5(value, label, 2048, true);
|
|
21668
23359
|
let parsed;
|
|
21669
23360
|
try {
|
|
@@ -21671,7 +23362,7 @@ function userLink(value, id, label) {
|
|
|
21671
23362
|
} catch (error) {
|
|
21672
23363
|
throw new Error(`${label} is not an X user link`, { cause: error });
|
|
21673
23364
|
}
|
|
21674
|
-
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "" || parsed.hash !== "" || !["twitter.com", "www.twitter.com", "x.com", "www.x.com"].includes(parsed.hostname) || parsed.pathname !== "/intent/user" || parsed.searchParams.size !== 1 || parsed.searchParams.get("user_id") !==
|
|
23365
|
+
if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "" || parsed.port !== "" || parsed.hash !== "" || !["twitter.com", "www.twitter.com", "x.com", "www.x.com"].includes(parsed.hostname) || parsed.pathname !== "/intent/user" || parsed.searchParams.size !== 1 || parsed.searchParams.get("user_id") !== id2) {
|
|
21675
23366
|
throw new Error(`${label} does not identify the exported X user ID`);
|
|
21676
23367
|
}
|
|
21677
23368
|
}
|
|
@@ -21844,11 +23535,11 @@ function parseTweetIdentities(member) {
|
|
|
21844
23535
|
const mentionLabel = `${label}.tweet.entities.user_mentions[${mentionIndex}]`;
|
|
21845
23536
|
const mention = plain2(value2, mentionLabel);
|
|
21846
23537
|
exactKeys5(mention, REVIEWED_MENTION_KEYS, mentionLabel);
|
|
21847
|
-
const
|
|
23538
|
+
const id2 = optionalOpaqueProviderId(mention.id, `${mentionLabel}.id`);
|
|
21848
23539
|
const idString = optionalOpaqueProviderId(mention.id_str, `${mentionLabel}.id_str`);
|
|
21849
|
-
if (
|
|
23540
|
+
if (id2 === null || idString === null)
|
|
21850
23541
|
throw new Error(`${mentionLabel} must contain both X user ID coordinates`);
|
|
21851
|
-
if (
|
|
23542
|
+
if (id2 !== idString)
|
|
21852
23543
|
throw new Error(`${mentionLabel} X user IDs disagree`);
|
|
21853
23544
|
if (!PROVIDER_ID2.test(idString))
|
|
21854
23545
|
continue;
|
|
@@ -21928,12 +23619,12 @@ function parseMessages(member, selfId, group, seenMessageIds, conversationMember
|
|
|
21928
23619
|
state.participants.add(selfId);
|
|
21929
23620
|
let directPeerId = null;
|
|
21930
23621
|
if (!group) {
|
|
21931
|
-
const ids = conversationId.split("-").map((
|
|
23622
|
+
const ids = conversationId.split("-").map((id2, part) => providerId3(id2, `${label}.conversationId[${part}]`));
|
|
21932
23623
|
if (ids.length !== 2 || !ids.includes(selfId))
|
|
21933
23624
|
throw new Error(`${label}.conversationId is not bound to the archive account`);
|
|
21934
|
-
for (const
|
|
21935
|
-
state.participants.add(
|
|
21936
|
-
directPeerId = ids.find((
|
|
23625
|
+
for (const id2 of ids)
|
|
23626
|
+
state.participants.add(id2);
|
|
23627
|
+
directPeerId = ids.find((id2) => id2 !== selfId) ?? null;
|
|
21937
23628
|
if (directPeerId === null)
|
|
21938
23629
|
throw new Error(`${label}.conversationId does not identify a direct-message peer`);
|
|
21939
23630
|
}
|
|
@@ -21956,7 +23647,7 @@ function parseMessages(member, selfId, group, seenMessageIds, conversationMember
|
|
|
21956
23647
|
state.participants.add(message.senderId);
|
|
21957
23648
|
if (message.recipientId !== null)
|
|
21958
23649
|
state.participants.add(message.recipientId);
|
|
21959
|
-
if (!group && [...state.participants].some((
|
|
23650
|
+
if (!group && [...state.participants].some((id2) => !conversationId.split("-").includes(id2)))
|
|
21960
23651
|
throw new Error(`${eventLabel} names a user outside its direct conversation`);
|
|
21961
23652
|
let providerUserId = null;
|
|
21962
23653
|
let direction = null;
|
|
@@ -21987,8 +23678,8 @@ function parseMessages(member, selfId, group, seenMessageIds, conversationMember
|
|
|
21987
23678
|
occurredAt = message.createdAt;
|
|
21988
23679
|
state.messageCount += 1;
|
|
21989
23680
|
} else if (["joinConversation", "participantsJoin", "participantsLeave", "conversationNameUpdate"].includes(eventKind)) {
|
|
21990
|
-
for (const
|
|
21991
|
-
state.participants.add(
|
|
23681
|
+
for (const id2 of validateMembershipEvent(event[eventKind], `${eventLabel}.${eventKind}`, eventKind))
|
|
23682
|
+
state.participants.add(id2);
|
|
21992
23683
|
} else
|
|
21993
23684
|
throw new Error(`${eventLabel} contains unreviewed event ${eventKind}`);
|
|
21994
23685
|
if (occurredAt !== null) {
|
|
@@ -22005,7 +23696,7 @@ function parseMessages(member, selfId, group, seenMessageIds, conversationMember
|
|
|
22005
23696
|
const evidence = [];
|
|
22006
23697
|
for (const [conversationId, state] of [...combined.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
22007
23698
|
const conversationSha256 = sha2568(conversationId);
|
|
22008
|
-
for (const providerUserId of [...state.participants].filter((
|
|
23699
|
+
for (const providerUserId of [...state.participants].filter((id2) => id2 !== selfId).sort()) {
|
|
22009
23700
|
const safe = { kind: "dm-participant", memberName: member.memberName, memberRecord: state.memberRecord, providerUserId, conversationSha256, group, messageCount: state.messageCount, firstMessageAt: state.first, lastMessageAt: state.last };
|
|
22010
23701
|
evidence.push({ ...safe, hash: sha2568(stableJson5(safe)) });
|
|
22011
23702
|
}
|
|
@@ -23293,7 +24984,7 @@ var DEFAULT_WHATSAPP_INTERACTION_PAGE_SIZE = 1000;
|
|
|
23293
24984
|
var MAX_PAGES2 = 1e5;
|
|
23294
24985
|
var MAX_CONTACTS5 = 250000;
|
|
23295
24986
|
var MAX_INTERACTIONS = 1e6;
|
|
23296
|
-
var
|
|
24987
|
+
var SHA2563 = /^[a-f0-9]{64}$/u;
|
|
23297
24988
|
var DECIMAL = /^(?:0|[1-9][0-9]{0,18})$/u;
|
|
23298
24989
|
var SUBJECT2 = /^whatsapp:(?:pn:[0-9]{5,20}|lid:[0-9]{5,32})$/u;
|
|
23299
24990
|
var USER_JID = /^([0-9]{5,20})@s\.whatsapp\.net$/u;
|
|
@@ -23351,7 +25042,7 @@ function bool3(value, label) {
|
|
|
23351
25042
|
}
|
|
23352
25043
|
function digest5(value, label) {
|
|
23353
25044
|
const parsed = text6(value, label, 64);
|
|
23354
|
-
if (!
|
|
25045
|
+
if (!SHA2563.test(parsed))
|
|
23355
25046
|
throw new Error(`${label} is invalid`);
|
|
23356
25047
|
return parsed;
|
|
23357
25048
|
}
|
|
@@ -23988,7 +25679,7 @@ function syncWhatsAppRelationships(database, options = {}) {
|
|
|
23988
25679
|
}
|
|
23989
25680
|
|
|
23990
25681
|
// src/cli/version.ts
|
|
23991
|
-
var peoplebladeVersion = "0.1
|
|
25682
|
+
var peoplebladeVersion = "0.2.1";
|
|
23992
25683
|
|
|
23993
25684
|
// src/cli/main.ts
|
|
23994
25685
|
var usage = `PeopleBlade \u2014 local-first contact intelligence
|
|
@@ -24000,6 +25691,13 @@ Core:
|
|
|
24000
25691
|
backup [PATH] Create a consistent private SQLite backup
|
|
24001
25692
|
migrate rolodex --from PATH Back up and migrate a legacy Rolodex database once
|
|
24002
25693
|
list [--search TEXT] [--limit N] Search the local contact book
|
|
25694
|
+
query [--search TEXT] Page the live contact book (default 50, maximum 100)
|
|
25695
|
+
[--source NAME] [--has-email yes|no] [--has-phone yes|no]
|
|
25696
|
+
[--do-not-contact all|exclude|only] [--sort name|organization|interactions|last-contact]
|
|
25697
|
+
[--direction asc|desc] [--limit N] [--offset N]
|
|
25698
|
+
people show ID Read bounded canonical contact details
|
|
25699
|
+
capabilities Discover supported sources and contracts without a database
|
|
25700
|
+
ui [--port N] Open the local-only CRM workspace (default: random loopback port)
|
|
24003
25701
|
people add --display-name NAME --confirm
|
|
24004
25702
|
Create one user-confirmed local person
|
|
24005
25703
|
[--email EMAIL] [--label NAME] [--note TEXT]
|
|
@@ -24009,6 +25707,9 @@ Core:
|
|
|
24009
25707
|
List reversible accepted observed decisions
|
|
24010
25708
|
identity suggest [--kind KIND] Review one evidence-bound possible duplicate
|
|
24011
25709
|
identity decide TOKEN ACTION Accept, reject, or defer one fresh suggestion
|
|
25710
|
+
identity auto-accept [--kinds LIST] [--source NAME] [--limit N] [--dry-run] [--note TEXT]
|
|
25711
|
+
Accept only unique, warning-free exact evidence and report every skip
|
|
25712
|
+
Default kinds: exact-email,exact-phone,observed-phone
|
|
24012
25713
|
identity separate DECISION_ID Reverse one current accepted identity decision
|
|
24013
25714
|
identity attest-email PERSON_ID EMAIL
|
|
24014
25715
|
Add one explicit, reversible exact email attestation
|
|
@@ -24016,7 +25717,7 @@ Core:
|
|
|
24016
25717
|
identity retract-email METHOD_ID Retract one explicit email attestation
|
|
24017
25718
|
identity accept-candidate TOKEN Materialize one reviewed staged provider candidate
|
|
24018
25719
|
|
|
24019
|
-
Sources:
|
|
25720
|
+
Sources (append --auto-accept to run identity auto-accept after a successful import):
|
|
24020
25721
|
contacts sync [--directory PATH] Sync macOS Contacts from the local AddressBook database
|
|
24021
25722
|
beeper sync [--auth ID] Sync connected Beeper accounts sequentially through Wrench
|
|
24022
25723
|
[--with-relationships] Add body-free lower-bound direct interaction counts
|
|
@@ -24048,6 +25749,7 @@ Cloud (optional):
|
|
|
24048
25749
|
cloud enrich --confirm TOKEN Launch a preview with the same --person-id values
|
|
24049
25750
|
cloud enrich --status JOB_ID Read one CLI-owned aggregate enrichment receipt
|
|
24050
25751
|
cloud enrich --details JOB_ID Read privacy-safe per-run tuning diagnostics
|
|
25752
|
+
cloud enrich --public-emails Read deduplicated publicEmail leftovers from completed runs
|
|
24051
25753
|
cloud enrich --revalidate-historical
|
|
24052
25754
|
Revalidate stored results under the current policy
|
|
24053
25755
|
|
|
@@ -24055,6 +25757,11 @@ Notes (local JSON; not synced to cloud):
|
|
|
24055
25757
|
notes add --person-id ID --occurred-at ISO --body TEXT
|
|
24056
25758
|
Append one dated note [--title TEXT] [--source NAME] [--source-id ID]
|
|
24057
25759
|
notes list --person-id ID List notes [--since ISO] [--until ISO] [--source NAME]
|
|
25760
|
+
notes show ID Read effective markdown and revision/context fences
|
|
25761
|
+
notes update ID --body-file PATH|- --expected-revision N
|
|
25762
|
+
--expected-context SHA256 --request-id UUID [--title TEXT | --clear-title]
|
|
25763
|
+
Append an idempotent, compare-and-swap note edit
|
|
25764
|
+
notes history ID Read immutable history [--before-revision N] [--limit N]
|
|
24058
25765
|
notes search QUERY Search note text [--person-id ID] [--since ISO] [--until ISO]
|
|
24059
25766
|
notes import --from granola.json Attach Granola summaries by exact email/phone, then unique name search; apply a new calendar email only to that unique match
|
|
24060
25767
|
|
|
@@ -24067,8 +25774,11 @@ Ensoul:
|
|
|
24067
25774
|
Write a private, source-bounded person packet
|
|
24068
25775
|
|
|
24069
25776
|
The default database is ${peoplebladeDatabasePath()}`;
|
|
25777
|
+
|
|
25778
|
+
class CliUsageError extends Error {
|
|
25779
|
+
}
|
|
24070
25780
|
function fail3(message) {
|
|
24071
|
-
throw new
|
|
25781
|
+
throw new CliUsageError(message);
|
|
24072
25782
|
}
|
|
24073
25783
|
function valueAfter(args, name) {
|
|
24074
25784
|
const index = args.indexOf(name);
|
|
@@ -24099,6 +25809,76 @@ function positive(value, label, fallback, maximum) {
|
|
|
24099
25809
|
fail3(`${label} is too large.`);
|
|
24100
25810
|
return parsed;
|
|
24101
25811
|
}
|
|
25812
|
+
function nonnegative(value, label, fallback, maximum) {
|
|
25813
|
+
if (value === undefined)
|
|
25814
|
+
return fallback;
|
|
25815
|
+
if (!/^(?:0|[1-9][0-9]*)$/u.test(value))
|
|
25816
|
+
fail3(`${label} must be a nonnegative integer.`);
|
|
25817
|
+
const number2 = Number(value);
|
|
25818
|
+
if (!Number.isSafeInteger(number2) || number2 > maximum)
|
|
25819
|
+
fail3(`${label} is too large.`);
|
|
25820
|
+
return number2;
|
|
25821
|
+
}
|
|
25822
|
+
function yesNo(value, label) {
|
|
25823
|
+
if (value === undefined)
|
|
25824
|
+
return;
|
|
25825
|
+
if (value !== "yes" && value !== "no")
|
|
25826
|
+
fail3(`${label} must be yes or no.`);
|
|
25827
|
+
return value === "yes";
|
|
25828
|
+
}
|
|
25829
|
+
async function readNoteMarkdown(path) {
|
|
25830
|
+
const maximum = 65536;
|
|
25831
|
+
if (path !== "-") {
|
|
25832
|
+
const descriptor = openSync9(resolve6(path), constants9.O_RDONLY | constants9.O_NONBLOCK | (constants9.O_NOFOLLOW ?? 0));
|
|
25833
|
+
try {
|
|
25834
|
+
const before = fstatSync8(descriptor);
|
|
25835
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > maximum || typeof process.getuid === "function" && before.uid !== process.getuid()) {
|
|
25836
|
+
fail3("Markdown input must be one owned regular file of at most 65536 bytes.");
|
|
25837
|
+
}
|
|
25838
|
+
const bytes2 = new Uint8Array(maximum + 1);
|
|
25839
|
+
let length2 = 0;
|
|
25840
|
+
for (;; ) {
|
|
25841
|
+
const count = readSync3(descriptor, bytes2, length2, bytes2.length - length2, null);
|
|
25842
|
+
length2 += count;
|
|
25843
|
+
if (length2 > maximum)
|
|
25844
|
+
fail3("Markdown input exceeds 65536 bytes.");
|
|
25845
|
+
if (count === 0)
|
|
25846
|
+
break;
|
|
25847
|
+
}
|
|
25848
|
+
const after = fstatSync8(descriptor);
|
|
25849
|
+
if (after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || after.ino !== before.ino || after.dev !== before.dev || length2 !== before.size) {
|
|
25850
|
+
fail3("Markdown input changed while it was being read.");
|
|
25851
|
+
}
|
|
25852
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes2.subarray(0, length2));
|
|
25853
|
+
} finally {
|
|
25854
|
+
closeSync9(descriptor);
|
|
25855
|
+
}
|
|
25856
|
+
}
|
|
25857
|
+
const reader = Bun.stdin.stream().getReader();
|
|
25858
|
+
const bytes = new Uint8Array(maximum);
|
|
25859
|
+
let length = 0;
|
|
25860
|
+
let done = false;
|
|
25861
|
+
try {
|
|
25862
|
+
for (;; ) {
|
|
25863
|
+
const next = await reader.read();
|
|
25864
|
+
if (next.done) {
|
|
25865
|
+
done = true;
|
|
25866
|
+
break;
|
|
25867
|
+
}
|
|
25868
|
+
if (next.value.byteLength > maximum - length)
|
|
25869
|
+
fail3("Markdown input exceeds 65536 bytes.");
|
|
25870
|
+
bytes.set(next.value, length);
|
|
25871
|
+
length += next.value.byteLength;
|
|
25872
|
+
}
|
|
25873
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
|
|
25874
|
+
} finally {
|
|
25875
|
+
if (!done)
|
|
25876
|
+
reader.cancel().catch(() => {
|
|
25877
|
+
return;
|
|
25878
|
+
});
|
|
25879
|
+
reader.releaseLock();
|
|
25880
|
+
}
|
|
25881
|
+
}
|
|
24102
25882
|
function print(value, asJson) {
|
|
24103
25883
|
if (asJson)
|
|
24104
25884
|
console.log(JSON.stringify(value, null, 2));
|
|
@@ -24107,12 +25887,29 @@ function print(value, asJson) {
|
|
|
24107
25887
|
else
|
|
24108
25888
|
console.log(JSON.stringify(value, null, 2));
|
|
24109
25889
|
}
|
|
25890
|
+
function flag(args, name) {
|
|
25891
|
+
const index = args.indexOf(name);
|
|
25892
|
+
if (index < 0)
|
|
25893
|
+
return false;
|
|
25894
|
+
args.splice(index, 1);
|
|
25895
|
+
return true;
|
|
25896
|
+
}
|
|
25897
|
+
var AUTO_ACCEPT_SOURCE_COMMANDS = new Set([
|
|
25898
|
+
"contacts sync",
|
|
25899
|
+
"beeper sync",
|
|
25900
|
+
"google sync",
|
|
25901
|
+
"imessage sync",
|
|
25902
|
+
"whatsapp sync",
|
|
25903
|
+
"linkedin import",
|
|
25904
|
+
"x import",
|
|
25905
|
+
"instagram import",
|
|
25906
|
+
"notes import"
|
|
25907
|
+
]);
|
|
24110
25908
|
async function main(argv) {
|
|
24111
25909
|
const args = [...argv];
|
|
24112
25910
|
const databasePath = valueAfter(args, "--db") ?? peoplebladeDatabasePath();
|
|
24113
|
-
const asJson = args
|
|
24114
|
-
|
|
24115
|
-
args.splice(args.indexOf("--json"), 1);
|
|
25911
|
+
const asJson = flag(args, "--json");
|
|
25912
|
+
const autoAccept = flag(args, "--auto-accept");
|
|
24116
25913
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
24117
25914
|
console.log(usage);
|
|
24118
25915
|
return;
|
|
@@ -24124,6 +25921,15 @@ async function main(argv) {
|
|
|
24124
25921
|
return;
|
|
24125
25922
|
}
|
|
24126
25923
|
const [command, subcommand, ...rest] = args;
|
|
25924
|
+
if (autoAccept && !AUTO_ACCEPT_SOURCE_COMMANDS.has(`${command} ${subcommand}`)) {
|
|
25925
|
+
fail3("--auto-accept is valid only with a local source sync or import command; use `identity auto-accept` directly otherwise.");
|
|
25926
|
+
}
|
|
25927
|
+
if (command === "capabilities") {
|
|
25928
|
+
if (args.length !== 1)
|
|
25929
|
+
fail3("capabilities takes no arguments.");
|
|
25930
|
+
print(peoplebladeCapabilities, true);
|
|
25931
|
+
return;
|
|
25932
|
+
}
|
|
24127
25933
|
if (command === "init") {
|
|
24128
25934
|
if (args.length !== 1)
|
|
24129
25935
|
fail3("init takes no arguments.");
|
|
@@ -24146,7 +25952,7 @@ async function main(argv) {
|
|
|
24146
25952
|
if (command === "cloud" && subcommand === "signin") {
|
|
24147
25953
|
if (rest.length)
|
|
24148
25954
|
fail3(`Unknown argument: ${rest[0]}`);
|
|
24149
|
-
const result = await signInCloud(undefined, { onCode: (code, verification) => console.
|
|
25955
|
+
const result = await signInCloud(undefined, { onCode: (code, verification) => console.error(`Authorize code ${code}
|
|
24150
25956
|
${verification}`) });
|
|
24151
25957
|
print(result, asJson);
|
|
24152
25958
|
return;
|
|
@@ -24183,7 +25989,22 @@ ${verification}`) });
|
|
|
24183
25989
|
}
|
|
24184
25990
|
initializeLocalDatabase(databasePath);
|
|
24185
25991
|
const database = connectLocalDatabase(databasePath);
|
|
25992
|
+
const printImport = (result, json4) => {
|
|
25993
|
+
if (!autoAccept) {
|
|
25994
|
+
print(result, json4);
|
|
25995
|
+
return;
|
|
25996
|
+
}
|
|
25997
|
+
print({ ...result, identityAutoAccept: autoAcceptIdentities(database) }, json4);
|
|
25998
|
+
};
|
|
24186
25999
|
try {
|
|
26000
|
+
if (command === "ui") {
|
|
26001
|
+
const options = [subcommand, ...rest].filter((item) => item !== undefined);
|
|
26002
|
+
const port = nonnegative(valueAfter(options, "--port"), "--port", 0, 65535);
|
|
26003
|
+
if (options.length)
|
|
26004
|
+
fail3(`Unknown argument: ${options[0]}`);
|
|
26005
|
+
await runLocalWorkspace(database, { port, json: asJson });
|
|
26006
|
+
return;
|
|
26007
|
+
}
|
|
24187
26008
|
if (command === "list") {
|
|
24188
26009
|
const options = [subcommand, ...rest].filter((item) => item !== undefined);
|
|
24189
26010
|
const search = valueAfter(options, "--search") ?? "";
|
|
@@ -24193,9 +26014,32 @@ ${verification}`) });
|
|
|
24193
26014
|
print(listLocalContacts(database, search, limit), asJson);
|
|
24194
26015
|
return;
|
|
24195
26016
|
}
|
|
26017
|
+
if (command === "query") {
|
|
26018
|
+
const options = [subcommand, ...rest].filter((item) => item !== undefined);
|
|
26019
|
+
const search = valueAfter(options, "--search") ?? "";
|
|
26020
|
+
const source = valueAfter(options, "--source");
|
|
26021
|
+
const doNotContact = valueAfter(options, "--do-not-contact") ?? "all";
|
|
26022
|
+
const sort = valueAfter(options, "--sort") ?? "interactions";
|
|
26023
|
+
const direction = valueAfter(options, "--direction") ?? "desc";
|
|
26024
|
+
const hasEmail = yesNo(valueAfter(options, "--has-email"), "--has-email");
|
|
26025
|
+
const hasPhone = yesNo(valueAfter(options, "--has-phone"), "--has-phone");
|
|
26026
|
+
const limit = positive(valueAfter(options, "--limit"), "--limit", 50, 100);
|
|
26027
|
+
const offset = nonnegative(valueAfter(options, "--offset"), "--offset", 0, 1e6);
|
|
26028
|
+
if (options.length)
|
|
26029
|
+
fail3(`Unknown argument: ${options[0]}`);
|
|
26030
|
+
const input = contactQueryInputSchema.parse({ search, source, doNotContact, sort, direction, hasEmail, hasPhone, limit, offset });
|
|
26031
|
+
print(queryLocalContacts(database, input), true);
|
|
26032
|
+
return;
|
|
26033
|
+
}
|
|
24196
26034
|
if (command === "people") {
|
|
26035
|
+
if (subcommand === "show") {
|
|
26036
|
+
if (rest.length !== 1)
|
|
26037
|
+
fail3("people show requires one ID.");
|
|
26038
|
+
print(getLocalContactDetail(database, positive(rest[0], "ID", 0, Number.MAX_SAFE_INTEGER)), true);
|
|
26039
|
+
return;
|
|
26040
|
+
}
|
|
24197
26041
|
if (subcommand !== "add")
|
|
24198
|
-
fail3("people supports add.");
|
|
26042
|
+
fail3("people supports add or show.");
|
|
24199
26043
|
const options = [...rest];
|
|
24200
26044
|
const displayName2 = valueAfter(options, "--display-name") ?? fail3("people add requires --display-name.");
|
|
24201
26045
|
const email2 = valueAfter(options, "--email");
|
|
@@ -24288,6 +26132,32 @@ ${verification}`) });
|
|
|
24288
26132
|
print(decideIdentity(database, token3, action, note), asJson);
|
|
24289
26133
|
return;
|
|
24290
26134
|
}
|
|
26135
|
+
if (command === "identity" && subcommand === "auto-accept") {
|
|
26136
|
+
const options = [...rest];
|
|
26137
|
+
const kindsValue = valueAfter(options, "--kinds");
|
|
26138
|
+
const ambiguity = valueAfter(options, "--ambiguity");
|
|
26139
|
+
const source = valueAfter(options, "--source");
|
|
26140
|
+
const limit = positive(valueAfter(options, "--limit"), "--limit", 100, 500);
|
|
26141
|
+
const note = valueAfter(options, "--note");
|
|
26142
|
+
const dryRun = flag(options, "--dry-run");
|
|
26143
|
+
if (ambiguity !== undefined && ambiguity !== "unique")
|
|
26144
|
+
fail3("identity auto-accept reviews unique candidates only; --ambiguity must be unique.");
|
|
26145
|
+
if (source !== undefined && !/^[a-z][a-z0-9-]{0,63}$/u.test(source))
|
|
26146
|
+
fail3("--source must be a lowercase provider name.");
|
|
26147
|
+
const kinds = kindsValue === undefined ? undefined : kindsValue.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
26148
|
+
if (kinds !== undefined && kinds.length === 0)
|
|
26149
|
+
fail3("--kinds requires a comma-separated list of evidence kinds.");
|
|
26150
|
+
if (options.length)
|
|
26151
|
+
fail3(`Unknown argument: ${options[0]}`);
|
|
26152
|
+
print(autoAcceptIdentities(database, {
|
|
26153
|
+
...kinds === undefined ? {} : { kinds },
|
|
26154
|
+
limit,
|
|
26155
|
+
dryRun,
|
|
26156
|
+
...source === undefined ? {} : { source },
|
|
26157
|
+
...note === undefined ? {} : { note }
|
|
26158
|
+
}), asJson);
|
|
26159
|
+
return;
|
|
26160
|
+
}
|
|
24291
26161
|
if (command === "identity" && subcommand === "separate") {
|
|
24292
26162
|
const options = [...rest];
|
|
24293
26163
|
const decisionIdValue = options.shift();
|
|
@@ -24362,6 +26232,9 @@ ${verification}`) });
|
|
|
24362
26232
|
const statusJobId = valueAfter(options, "--status");
|
|
24363
26233
|
const detailsJobId = valueAfter(options, "--details");
|
|
24364
26234
|
const prioritizeValue = valueAfter(options, "--prioritize");
|
|
26235
|
+
const publicEmails = options.includes("--public-emails");
|
|
26236
|
+
if (publicEmails)
|
|
26237
|
+
options.splice(options.indexOf("--public-emails"), 1);
|
|
24365
26238
|
const revalidateHistorical = options.includes("--revalidate-historical");
|
|
24366
26239
|
if (revalidateHistorical)
|
|
24367
26240
|
options.splice(options.indexOf("--revalidate-historical"), 1);
|
|
@@ -24371,11 +26244,14 @@ ${verification}`) });
|
|
|
24371
26244
|
const personIdValues = valuesAfter(options, "--person-id");
|
|
24372
26245
|
if (options.length)
|
|
24373
26246
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24374
|
-
if (statusJobId !== undefined && (detailsJobId !== undefined || revalidateHistorical || confirmation !== undefined || prioritizeValue !== undefined || personIdValues.length > 0 || noWait)) {
|
|
24375
|
-
fail3("--status cannot be combined with --details, --revalidate-historical, --confirm, --prioritize, --person-id, or --no-wait.");
|
|
26247
|
+
if (statusJobId !== undefined && (detailsJobId !== undefined || publicEmails || revalidateHistorical || confirmation !== undefined || prioritizeValue !== undefined || personIdValues.length > 0 || noWait)) {
|
|
26248
|
+
fail3("--status cannot be combined with --details, --public-emails, --revalidate-historical, --confirm, --prioritize, --person-id, or --no-wait.");
|
|
24376
26249
|
}
|
|
24377
|
-
if (detailsJobId !== undefined && (revalidateHistorical || confirmation !== undefined || prioritizeValue !== undefined || personIdValues.length > 0 || noWait)) {
|
|
24378
|
-
fail3("--details cannot be combined with --revalidate-historical, --confirm, --prioritize, --person-id, or --no-wait.");
|
|
26250
|
+
if (detailsJobId !== undefined && (publicEmails || revalidateHistorical || confirmation !== undefined || prioritizeValue !== undefined || personIdValues.length > 0 || noWait)) {
|
|
26251
|
+
fail3("--details cannot be combined with --public-emails, --revalidate-historical, --confirm, --prioritize, --person-id, or --no-wait.");
|
|
26252
|
+
}
|
|
26253
|
+
if (publicEmails && (revalidateHistorical || confirmation !== undefined || prioritizeValue !== undefined || personIdValues.length > 0 || noWait)) {
|
|
26254
|
+
fail3("--public-emails cannot be combined with --revalidate-historical, --confirm, --prioritize, --person-id, or --no-wait.");
|
|
24379
26255
|
}
|
|
24380
26256
|
if (revalidateHistorical && (confirmation !== undefined || prioritizeValue !== undefined || personIdValues.length > 0 || noWait)) {
|
|
24381
26257
|
fail3("--revalidate-historical cannot be combined with --confirm, --prioritize, --person-id, or --no-wait.");
|
|
@@ -24388,6 +26264,15 @@ ${verification}`) });
|
|
|
24388
26264
|
print(await cloudEnrichmentDetails(detailsJobId), asJson);
|
|
24389
26265
|
return;
|
|
24390
26266
|
}
|
|
26267
|
+
if (publicEmails) {
|
|
26268
|
+
const leftovers = await cloudEnrichmentPublicEmails();
|
|
26269
|
+
if (asJson) {
|
|
26270
|
+
print(leftovers, true);
|
|
26271
|
+
return;
|
|
26272
|
+
}
|
|
26273
|
+
print({ count: leftovers.publicEmails.length, sample: leftovers.publicEmails.slice(0, 5) }, false);
|
|
26274
|
+
return;
|
|
26275
|
+
}
|
|
24391
26276
|
if (revalidateHistorical) {
|
|
24392
26277
|
print(await revalidateHistoricalCloudEnrichment(), asJson);
|
|
24393
26278
|
return;
|
|
@@ -24416,6 +26301,55 @@ ${verification}`) });
|
|
|
24416
26301
|
print({ ...preview, nextCommand: `peopleblade cloud enrich --confirm ${preview.confirmation} ${selectedFlags}` }, asJson);
|
|
24417
26302
|
return;
|
|
24418
26303
|
}
|
|
26304
|
+
if (command === "notes" && subcommand === "show") {
|
|
26305
|
+
if (rest.length !== 1)
|
|
26306
|
+
fail3("notes show requires one ID.");
|
|
26307
|
+
print(getPersonNote(database, positive(rest[0], "ID", 0, Number.MAX_SAFE_INTEGER)), true);
|
|
26308
|
+
return;
|
|
26309
|
+
}
|
|
26310
|
+
if (command === "notes" && subcommand === "update") {
|
|
26311
|
+
const options = [...rest];
|
|
26312
|
+
const noteId = positive(options.shift(), "ID", 0, Number.MAX_SAFE_INTEGER);
|
|
26313
|
+
const path = valueAfter(options, "--body-file") ?? fail3("notes update requires --body-file PATH or -.");
|
|
26314
|
+
const revisionValue = valueAfter(options, "--expected-revision") ?? fail3("notes update requires --expected-revision.");
|
|
26315
|
+
const expectedRevision = nonnegative(revisionValue, "--expected-revision", 0, Number.MAX_SAFE_INTEGER);
|
|
26316
|
+
const expectedContextSha256 = valueAfter(options, "--expected-context") ?? fail3("notes update requires --expected-context.");
|
|
26317
|
+
const requestId = valueAfter(options, "--request-id") ?? fail3("notes update requires --request-id.");
|
|
26318
|
+
const title = valueAfter(options, "--title");
|
|
26319
|
+
const clearTitle = flag(options, "--clear-title");
|
|
26320
|
+
if (title !== undefined && clearTitle)
|
|
26321
|
+
fail3("--title and --clear-title are mutually exclusive.");
|
|
26322
|
+
if (options.length)
|
|
26323
|
+
fail3(`Unknown argument: ${options[0]}`);
|
|
26324
|
+
let currentTitle;
|
|
26325
|
+
if (title !== undefined)
|
|
26326
|
+
currentTitle = title;
|
|
26327
|
+
else if (clearTitle)
|
|
26328
|
+
currentTitle = null;
|
|
26329
|
+
else {
|
|
26330
|
+
if (expectedRevision === Number.MAX_SAFE_INTEGER)
|
|
26331
|
+
fail3("Expected note revision is unavailable.");
|
|
26332
|
+
const expected = listPersonNoteRevisions(database, { noteId, beforeRevision: expectedRevision + 1, limit: 1 }).items[0];
|
|
26333
|
+
if (expected?.revision !== expectedRevision)
|
|
26334
|
+
throw new NoteRevisionError("revision_conflict", "Expected note revision is unavailable. Reload the note before saving.");
|
|
26335
|
+
currentTitle = expected.title;
|
|
26336
|
+
}
|
|
26337
|
+
const body = await readNoteMarkdown(path);
|
|
26338
|
+
print(revisePersonNote(database, { noteId, expectedRevision, expectedContextSha256, requestId, title: currentTitle, body }), true);
|
|
26339
|
+
return;
|
|
26340
|
+
}
|
|
26341
|
+
if (command === "notes" && subcommand === "history") {
|
|
26342
|
+
const options = [...rest];
|
|
26343
|
+
const noteId = positive(options.shift(), "ID", 0, Number.MAX_SAFE_INTEGER);
|
|
26344
|
+
const before = valueAfter(options, "--before-revision");
|
|
26345
|
+
const limit = positive(valueAfter(options, "--limit"), "--limit", 50, 100);
|
|
26346
|
+
if (options.length)
|
|
26347
|
+
fail3(`Unknown argument: ${options[0]}`);
|
|
26348
|
+
print(listPersonNoteRevisions(database, { noteId, limit, ...before === undefined ? {} : {
|
|
26349
|
+
beforeRevision: positive(before, "--before-revision", 0, Number.MAX_SAFE_INTEGER)
|
|
26350
|
+
} }), true);
|
|
26351
|
+
return;
|
|
26352
|
+
}
|
|
24419
26353
|
if (command === "notes" && subcommand === "add") {
|
|
24420
26354
|
const options = [...rest];
|
|
24421
26355
|
const personIdValue = valueAfter(options, "--person-id") ?? fail3("notes add requires --person-id.");
|
|
@@ -24482,14 +26416,14 @@ ${verification}`) });
|
|
|
24482
26416
|
if (options.length)
|
|
24483
26417
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24484
26418
|
const payload = from === "-" ? parseGranolaImportJson(await new Response(Bun.stdin).text()) : readGranolaImportFile(resolve6(from));
|
|
24485
|
-
|
|
26419
|
+
printImport(importGranolaMeetings(database, payload), true);
|
|
24486
26420
|
return;
|
|
24487
26421
|
}
|
|
24488
26422
|
if (command === "research" && subcommand === "prepare") {
|
|
24489
|
-
const
|
|
26423
|
+
const id2 = positive(rest[0], "PERSON_ID", 0, Number.MAX_SAFE_INTEGER);
|
|
24490
26424
|
if (rest.length !== 1)
|
|
24491
26425
|
fail3("research prepare requires one person ID.");
|
|
24492
|
-
print(researchTemplate(database,
|
|
26426
|
+
print(researchTemplate(database, id2), true);
|
|
24493
26427
|
return;
|
|
24494
26428
|
}
|
|
24495
26429
|
if (command === "research" && subcommand === "apply") {
|
|
@@ -24519,7 +26453,7 @@ ${verification}`) });
|
|
|
24519
26453
|
options.splice(options.indexOf("--preserve"), 1);
|
|
24520
26454
|
if (options.length)
|
|
24521
26455
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24522
|
-
|
|
26456
|
+
printImport(syncAppleContacts(database, {
|
|
24523
26457
|
...contactsDirectory === undefined ? {} : { contactsDirectory },
|
|
24524
26458
|
...accountKey5 === undefined ? {} : { accountKey: accountKey5 },
|
|
24525
26459
|
reconcile: !preserve
|
|
@@ -24539,7 +26473,7 @@ ${verification}`) });
|
|
|
24539
26473
|
if (withRelationships)
|
|
24540
26474
|
console.error("Beeper relationships \xB7 deriving a body-free interaction summary through Wrench");
|
|
24541
26475
|
console.error("Beeper contacts \xB7 discovering connected accounts through Wrench");
|
|
24542
|
-
|
|
26476
|
+
printImport(syncBeeperContacts(database, {
|
|
24543
26477
|
authId: effectiveAuthId,
|
|
24544
26478
|
contactLimit,
|
|
24545
26479
|
withRelationships,
|
|
@@ -24593,7 +26527,7 @@ ${verification}`) });
|
|
|
24593
26527
|
const pageSize = positive(valueAfter(options, "--page-size"), "--page-size", 100, 100);
|
|
24594
26528
|
if (options.length)
|
|
24595
26529
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24596
|
-
|
|
26530
|
+
printImport(syncGoogleContacts(database, { ...authId3 === undefined ? {} : { authId: authId3 }, pageSize }), asJson);
|
|
24597
26531
|
return;
|
|
24598
26532
|
}
|
|
24599
26533
|
if (command === "google" && subcommand === "stats") {
|
|
@@ -24609,7 +26543,7 @@ ${verification}`) });
|
|
|
24609
26543
|
const pageSize = positive(valueAfter(options, "--page-size"), "--page-size", 1000, 5000);
|
|
24610
26544
|
if (options.length)
|
|
24611
26545
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24612
|
-
|
|
26546
|
+
printImport(syncIMessageRelationships(database, {
|
|
24613
26547
|
...messagesDatabase === undefined ? {} : { messagesDatabase },
|
|
24614
26548
|
...accountKey5 === undefined ? {} : { accountKey: accountKey5 },
|
|
24615
26549
|
pageSize
|
|
@@ -24623,7 +26557,7 @@ ${verification}`) });
|
|
|
24623
26557
|
const interactionPageSize = positive(valueAfter(options, "--interaction-page-size"), "--interaction-page-size", 1000, 1000);
|
|
24624
26558
|
if (options.length)
|
|
24625
26559
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24626
|
-
|
|
26560
|
+
printImport(syncWhatsAppRelationships(database, {
|
|
24627
26561
|
...authId3 === undefined ? {} : { authId: authId3 },
|
|
24628
26562
|
contactPageSize,
|
|
24629
26563
|
interactionPageSize
|
|
@@ -24639,7 +26573,7 @@ ${verification}`) });
|
|
|
24639
26573
|
const ownerProfileUrl = valueAfter(options, "--owner-profile-url");
|
|
24640
26574
|
if (options.length)
|
|
24641
26575
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24642
|
-
|
|
26576
|
+
printImport(importLinkedInArchive(database, archive, {
|
|
24643
26577
|
...accountKey5 === undefined ? {} : { accountKey: accountKey5 },
|
|
24644
26578
|
...ownerProfileUrl === undefined ? {} : { ownerProfileUrl }
|
|
24645
26579
|
}), asJson);
|
|
@@ -24659,7 +26593,7 @@ ${verification}`) });
|
|
|
24659
26593
|
const accountKey5 = valueAfter(options, "--account");
|
|
24660
26594
|
if (options.length)
|
|
24661
26595
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24662
|
-
|
|
26596
|
+
printImport(await importXArchive(database, archive, { ...accountKey5 === undefined ? {} : { accountKey: accountKey5 } }), asJson);
|
|
24663
26597
|
return;
|
|
24664
26598
|
}
|
|
24665
26599
|
if (command === "x" && subcommand === "stats") {
|
|
@@ -24677,7 +26611,7 @@ ${verification}`) });
|
|
|
24677
26611
|
const accountKey5 = valueAfter(options, "--account");
|
|
24678
26612
|
if (options.length)
|
|
24679
26613
|
fail3(`Unknown argument: ${options[0]}`);
|
|
24680
|
-
|
|
26614
|
+
printImport(importInstagramArchive(database, archive, { ownerUsername, ...accountKey5 === undefined ? {} : { accountKey: accountKey5 } }), asJson);
|
|
24681
26615
|
return;
|
|
24682
26616
|
}
|
|
24683
26617
|
if (command === "instagram" && subcommand === "stats") {
|
|
@@ -24700,6 +26634,22 @@ ${usage}`);
|
|
|
24700
26634
|
}
|
|
24701
26635
|
}
|
|
24702
26636
|
main(process.argv.slice(2)).catch((error) => {
|
|
24703
|
-
|
|
26637
|
+
const message = error instanceof Error ? error.message.slice(0, 2000) : "Unexpected failure.";
|
|
26638
|
+
if (process.argv.slice(2).includes("--json")) {
|
|
26639
|
+
console.error(JSON.stringify({
|
|
26640
|
+
schemaVersion: "peopleblade.error.v1",
|
|
26641
|
+
error: {
|
|
26642
|
+
code: error instanceof CliUsageError ? "invalid_arguments" : error instanceof ZodError ? "invalid_input" : error instanceof NoteRevisionError ? error.code : error instanceof CloudEnrichmentReadError ? "enrichment_status_unavailable" : "operation_failed",
|
|
26643
|
+
message,
|
|
26644
|
+
...error instanceof CloudEnrichmentReadError ? {
|
|
26645
|
+
jobId: error.jobId,
|
|
26646
|
+
dispatchStatus: error.dispatchStatus,
|
|
26647
|
+
nextCommand: error.nextCommand,
|
|
26648
|
+
dispatchRetrySafe: false
|
|
26649
|
+
} : {}
|
|
26650
|
+
}
|
|
26651
|
+
}));
|
|
26652
|
+
} else
|
|
26653
|
+
console.error(`peopleblade: ${message}`);
|
|
24704
26654
|
process.exitCode = 1;
|
|
24705
26655
|
});
|