portable-agent-layer 0.92.0 → 0.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Researching a person or company on the web: website, LinkedIn, social accounts,
3
+ * the company registry entry and recent news, each with its source. A profile only
4
+ * reaches the store through the review queue.
5
+ */
6
+
7
+ import type { IngestInput, IngestResult } from "../../tools/knowledge/ingest";
8
+ import {
9
+ closedObject,
10
+ type ExtractedEntity,
11
+ queueForReview,
12
+ type ResearchProfile,
13
+ type ReviewItem,
14
+ } from "./entity-extraction";
15
+ import type { inference } from "./inference";
16
+ import { logDebug, logError } from "./log";
17
+ import { logTokenUsage } from "./token-usage";
18
+
19
+ export interface ResearchTarget {
20
+ kind: ExtractedEntity["kind"];
21
+ name: string;
22
+ slug: string;
23
+ organization: string;
24
+ fact: string;
25
+ }
26
+
27
+ export interface ResearchDeps {
28
+ principal: string;
29
+ infer: typeof inference;
30
+ today?: string;
31
+ }
32
+
33
+ const MAX_TARGETS = 3;
34
+ const RESEARCH_TIMEOUT_MS = 480_000;
35
+
36
+ function created(written: IngestResult["people"]): Set<string> {
37
+ return new Set(written.filter((w) => w.created).map((w) => w.slug));
38
+ }
39
+
40
+ function target(
41
+ kind: ResearchTarget["kind"],
42
+ name: string,
43
+ slug: string,
44
+ organization: string | null | undefined,
45
+ fact: string | null | undefined
46
+ ): ResearchTarget {
47
+ return { kind, name, slug, organization: organization ?? "", fact: fact ?? "" };
48
+ }
49
+
50
+ /** New companies, and new people with an organization to tell namesakes apart by. */
51
+ export function newResearchTargets(
52
+ input: IngestInput,
53
+ written: IngestResult
54
+ ): ResearchTarget[] {
55
+ const newCompanies = created(written.companies);
56
+ const newPeople = created(written.people);
57
+ const companies = (input.companies ?? []).flatMap((c, i) => {
58
+ const slug = written.companies[i]?.slug ?? "";
59
+ return newCompanies.has(slug) ? [target("company", c.name, slug, "", c.context)] : [];
60
+ });
61
+ const people = (input.people ?? []).flatMap((p, i) => {
62
+ const slug = written.people[i]?.slug ?? "";
63
+ return newPeople.has(slug) && p.company
64
+ ? [target("person", p.name, slug, p.company, p.context)]
65
+ : [];
66
+ });
67
+ return [...companies, ...people].slice(0, MAX_TARGETS);
68
+ }
69
+
70
+ export function researchSchema() {
71
+ const text = { type: "string" as const };
72
+ const list = (items: Record<string, unknown>) => ({ type: "array" as const, items });
73
+ return closedObject({
74
+ match: { type: "string" as const, enum: ["sure", "unsure", "none"] },
75
+ summary: text,
76
+ role: text,
77
+ organization: text,
78
+ website: text,
79
+ linkedin: text,
80
+ socials: list(closedObject({ platform: text, url: text })),
81
+ registry: closedObject({
82
+ name: text,
83
+ number: text,
84
+ taxNumber: text,
85
+ seat: text,
86
+ status: text,
87
+ managers: list(text),
88
+ url: text,
89
+ }),
90
+ news: list(closedObject({ date: text, title: text, url: text })),
91
+ sources: list(text),
92
+ });
93
+ }
94
+
95
+ const CHECKLIST = {
96
+ company: [
97
+ "- website: the official website.",
98
+ "- linkedin: the LinkedIn company page.",
99
+ "- socials: other official accounts (X, Facebook, Instagram, YouTube, GitHub).",
100
+ "- registry: its entry in the official company register of its country: registered name, registration number, tax number, registered seat, status (active, in liquidation…), managing directors, and the URL you read it on. For a Hungarian company (Kft., Zrt., Bt., Nyrt., a .hu site) that is the cégjegyzék: e-cegjegyzek.hu, or public mirrors such as ceginformacio.hu, nemzeticegtar.hu or opten.hu.",
101
+ "- news: up to five news items or public statements from the last 12 months, newest first.",
102
+ "- summary: two or three sentences on what the company does, its size and where it is based.",
103
+ ],
104
+ person: [
105
+ "- role and organization: their current position and employer.",
106
+ "- website: their own site, if they have one.",
107
+ "- linkedin: their LinkedIn profile.",
108
+ "- socials: their accounts on X, GitHub, Instagram, YouTube or similar.",
109
+ "- news: up to five recent news items, interviews, talks or public statements from the last 12 months, newest first.",
110
+ "- summary: two or three sentences on who they are and what they work on.",
111
+ ],
112
+ };
113
+
114
+ export function researchSystem(principal: string, kind: ResearchTarget["kind"]): string {
115
+ return [
116
+ `You research a ${kind} from ${principal}'s contact book on the web. Search the web and open pages; do not answer from memory.`,
117
+ "Find:",
118
+ ...CHECKLIST[kind],
119
+ "Rules:",
120
+ `- match: "sure" when what you found is clearly this ${kind} (the same name AND it fits what ${principal} said about them), "unsure" when you found namesakes and cannot tell which, "none" when you found nothing. Only "sure" profiles are kept.`,
121
+ "- Only state what a page you opened or a search result shows. Never guess a URL, number or date.",
122
+ "- Every URL must be one you saw. List every page you used in sources.",
123
+ "- Leave a field empty, or a list empty, when you did not find it.",
124
+ "- Dates as YYYY-MM-DD, or YYYY-MM when the day is not given.",
125
+ ].join("\n");
126
+ }
127
+
128
+ function researchUser(target: ResearchTarget, today: string): string {
129
+ return [
130
+ `Today is ${today}.`,
131
+ `${target.kind}: ${target.name}`,
132
+ target.organization && `organization: ${target.organization}`,
133
+ target.fact &&
134
+ `what ${target.kind === "person" ? "was said about them" : "was said about it"}: ${target.fact}`,
135
+ ]
136
+ .filter(Boolean)
137
+ .join("\n");
138
+ }
139
+
140
+ function parseProfile(output: string | undefined): ResearchProfile | null {
141
+ try {
142
+ return output ? (JSON.parse(output) as ResearchProfile) : null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function isWebUrl(url: string): boolean {
149
+ return /^https?:\/\/[^\s]+$/.test(url);
150
+ }
151
+
152
+ /** Drops any link that is not a plain http(s) URL, so the store never holds a guess dressed as one. */
153
+ function withWebUrlsOnly(p: ResearchProfile): ResearchProfile {
154
+ const url = (u: string) => (isWebUrl(u) ? u : "");
155
+ return {
156
+ ...p,
157
+ website: url(p.website),
158
+ linkedin: url(p.linkedin),
159
+ socials: p.socials.filter((s) => isWebUrl(s.url)),
160
+ registry: { ...p.registry, url: url(p.registry.url) },
161
+ news: p.news.filter((n) => isWebUrl(n.url)),
162
+ sources: p.sources.filter(isWebUrl),
163
+ };
164
+ }
165
+
166
+ export type ResearchOutcome =
167
+ | { found: true; profile: ResearchProfile }
168
+ | { found: false; reason: string };
169
+
170
+ export async function researchEntity(
171
+ target: ResearchTarget,
172
+ deps: ResearchDeps
173
+ ): Promise<ResearchOutcome> {
174
+ const result = await deps.infer({
175
+ system: researchSystem(deps.principal, target.kind),
176
+ user: researchUser(target, deps.today ?? new Date().toISOString().slice(0, 10)),
177
+ tier: "medium",
178
+ timeout: RESEARCH_TIMEOUT_MS,
179
+ jsonSchema: researchSchema(),
180
+ caller: "entity-research",
181
+ web: true,
182
+ });
183
+ if (result.usage) logTokenUsage("entity-research", result.usage);
184
+ const profile = result.success ? parseProfile(result.output) : null;
185
+ if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
186
+ if (profile.match !== "sure")
187
+ return { found: false, reason: `match: ${profile.match}. ${profile.summary}`.trim() };
188
+ const checked = withWebUrlsOnly(profile);
189
+ if (checked.sources.length === 0) return { found: false, reason: "no sources given" };
190
+ return { found: true, profile: checked };
191
+ }
192
+
193
+ export function researchItem(
194
+ target: ResearchTarget,
195
+ profile: ResearchProfile
196
+ ): ReviewItem {
197
+ return {
198
+ id: crypto.randomUUID().slice(0, 8),
199
+ ts: new Date().toISOString(),
200
+ source: profile.sources[0],
201
+ reason: "web-profile",
202
+ entity: {
203
+ kind: target.kind,
204
+ name: target.name,
205
+ existing: target.slug,
206
+ aliases: [],
207
+ role: profile.role,
208
+ organization: profile.organization || target.organization,
209
+ relation: "",
210
+ fact: profile.summary,
211
+ },
212
+ candidates: target.slug ? [target.slug] : [],
213
+ profile,
214
+ };
215
+ }
216
+
217
+ async function researchOrLog(
218
+ target: ResearchTarget,
219
+ deps: ResearchDeps
220
+ ): Promise<ReviewItem[]> {
221
+ try {
222
+ const outcome = await researchEntity(target, deps);
223
+ if (outcome.found) return [researchItem(target, outcome.profile)];
224
+ logDebug("entity-research", `${target.slug}: ${outcome.reason}`);
225
+ return [];
226
+ } catch (err) {
227
+ logError(`entity-research:${target.slug}`, err);
228
+ return [];
229
+ }
230
+ }
231
+
232
+ export async function researchNewEntities(
233
+ targets: ResearchTarget[],
234
+ deps: ResearchDeps
235
+ ): Promise<void> {
236
+ const found = (await Promise.all(targets.map((t) => researchOrLog(t, deps)))).flat();
237
+ queueForReview(found);
238
+ logDebug("entity-research", `${found.length} of ${targets.length} found for review`);
239
+ }
240
+
241
+ function registryLines(r: ResearchProfile["registry"]): string[] {
242
+ const fields: Array<[string, string]> = [
243
+ ["Registered name", r.name],
244
+ ["Registration number", r.number],
245
+ ["Tax number", r.taxNumber],
246
+ ["Seat", r.seat],
247
+ ["Status", r.status],
248
+ ["Managing directors", r.managers.join(", ")],
249
+ ["Registry entry", r.url],
250
+ ];
251
+ return fields.filter(([, v]) => v).map(([k, v]) => `- ${k}: ${v}`);
252
+ }
253
+
254
+ function section(title: string, lines: string[]): string[] {
255
+ return lines.length > 0 ? ["", `**${title}**`, ...lines] : [];
256
+ }
257
+
258
+ /** The profile as markdown: what the review list shows and what an accepted entry keeps. */
259
+ export function renderProfile(p: ResearchProfile): string {
260
+ const links = [
261
+ p.website && `- Website: ${p.website}`,
262
+ p.linkedin && `- LinkedIn: ${p.linkedin}`,
263
+ ...p.socials.map((s) => `- ${s.platform}: ${s.url}`),
264
+ ].filter((l): l is string => Boolean(l));
265
+ return [
266
+ p.summary,
267
+ ...section("Links", links),
268
+ ...section("Registry", registryLines(p.registry)),
269
+ ...section(
270
+ "News",
271
+ p.news.map((n) => `- ${n.date} ${n.title} (${n.url})`)
272
+ ),
273
+ ...section(
274
+ "Sources",
275
+ p.sources.map((s) => `- ${s}`)
276
+ ),
277
+ ].join("\n");
278
+ }
279
+
280
+ function socialKey(platform: string): string {
281
+ return platform.toLowerCase().replace(/[^a-z0-9]+/g, "") || "link";
282
+ }
283
+
284
+ function hostname(url: string): string | null {
285
+ return url ? new URL(url).hostname.replace(/^www\./, "") : null;
286
+ }
287
+
288
+ /** The accepted profile, written to the entry it was researched for. */
289
+ export function profileIngest(item: ReviewItem, name: string): IngestInput {
290
+ const p = item.profile as ResearchProfile;
291
+ const slug = item.entity.existing || null;
292
+ const context = renderProfile(p);
293
+ if (item.entity.kind === "company")
294
+ return { companies: [{ name, slug, domain: hostname(p.website), context }] };
295
+ const social = Object.fromEntries(
296
+ [
297
+ ["website", p.website],
298
+ ["linkedin", p.linkedin],
299
+ ...p.socials.map((s) => [socialKey(s.platform), s.url]),
300
+ ].filter(([, url]) => url)
301
+ );
302
+ return {
303
+ people: [{ name, slug, title: p.role || null, social, context }],
304
+ };
305
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The user's answers to the extraction review queue: an accepted item is written
3
+ * to the entity the user names (or a new one), a rejected one is dropped.
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { type IngestInput, ingestEntities } from "../../tools/knowledge/ingest";
8
+ import { loadKnownEntities, loadNameIndex } from "./entity-cards";
9
+ import { ingestInput, type ReviewItem, reviewQueueFile } from "./entity-extraction";
10
+ import { indexedEntities, type NamedEntity, type NameIndex } from "./entity-names";
11
+ import { profileIngest } from "./entity-research";
12
+
13
+ export interface AcceptChoice {
14
+ as?: string;
15
+ name?: string;
16
+ }
17
+
18
+ export interface Accepted {
19
+ item: ReviewItem;
20
+ slug: string;
21
+ created: boolean;
22
+ }
23
+
24
+ function parseItem(line: string): ReviewItem[] {
25
+ try {
26
+ return [JSON.parse(line) as ReviewItem];
27
+ } catch {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ export function pendingReviews(): ReviewItem[] {
33
+ const file = reviewQueueFile();
34
+ if (!existsSync(file)) return [];
35
+ return readFileSync(file, "utf-8").split("\n").filter(Boolean).flatMap(parseItem);
36
+ }
37
+
38
+ function takeFromQueue(id: string): ReviewItem | null {
39
+ const items = pendingReviews();
40
+ const item = items.find((i) => i.id === id);
41
+ if (!item) return null;
42
+ const rest = items.filter((i) => i !== item);
43
+ writeFileSync(reviewQueueFile(), rest.map((i) => `${JSON.stringify(i)}\n`).join(""));
44
+ return item;
45
+ }
46
+
47
+ function chosenEntity(
48
+ item: ReviewItem,
49
+ slug: string | undefined,
50
+ index: NameIndex
51
+ ): NamedEntity | null {
52
+ if (!slug) return null;
53
+ const domain = item.entity.kind === "person" ? "People" : "Companies";
54
+ const entity = indexedEntities(index).find(
55
+ (e) => e.domain === domain && e.slug === slug
56
+ );
57
+ if (!entity) throw new Error(`no ${domain} entry with slug "${slug}"`);
58
+ return entity;
59
+ }
60
+
61
+ export function loadEntityReviewNudge(): string {
62
+ const count = pendingReviews().length;
63
+ if (count === 0) return "";
64
+ const waiting =
65
+ count === 1 ? "1 person or company waits" : `${count} people or companies wait`;
66
+ return [
67
+ "## Entity Review Due",
68
+ `👥 ${waiting} for the user's answer before the knowledge store records them — offer \`pal cli knowledge review\`.`,
69
+ ].join("\n");
70
+ }
71
+
72
+ function acceptedInput(
73
+ item: ReviewItem,
74
+ choice: AcceptChoice,
75
+ index: NameIndex
76
+ ): IngestInput {
77
+ if (!item.profile) {
78
+ const known = chosenEntity(item, choice.as, index);
79
+ return ingestInput(item.entity, index, known, choice.name);
80
+ }
81
+ const target = chosenEntity(item, choice.as ?? item.entity.existing, index);
82
+ return profileIngest(
83
+ { ...item, entity: { ...item.entity, existing: target?.slug ?? "" } },
84
+ target?.title ?? choice.name ?? item.entity.name
85
+ );
86
+ }
87
+
88
+ export function rejectReview(id: string): ReviewItem | null {
89
+ return takeFromQueue(id);
90
+ }
91
+
92
+ /** Writes the item to `as` when given, else to a new entity named `name` or as extracted. */
93
+ export function acceptReview(id: string, choice: AcceptChoice = {}): Accepted | null {
94
+ const queued = pendingReviews().find((i) => i.id === id);
95
+ if (!queued) return null;
96
+ const index = loadNameIndex(loadKnownEntities());
97
+ const result = ingestEntities(acceptedInput(queued, choice, index), queued.source);
98
+ takeFromQueue(id);
99
+ const written = result.people[0] ?? result.companies[0];
100
+ return { item: queued, slug: written.slug, created: written.created };
101
+ }
@@ -134,6 +134,13 @@ function previewRoute(): RoutePreview {
134
134
  }
135
135
 
136
136
  /** True if any inference path is currently usable (subscription CLI OR API key). */
137
+ /** Routes whose CLI can be limited to web search and fetch alone. */
138
+ function canSearchWeb(): boolean {
139
+ if (isClaude() && hasClaudeBinary()) return true;
140
+ if (isCodex() && hasCodexBinary()) return true;
141
+ return isCopilot() && hasCopilotBinary();
142
+ }
143
+
137
144
  export function canInfer(): boolean {
138
145
  if (isClaude() && hasClaudeBinary()) return true;
139
146
  if (isCodex() && hasCodexBinary()) return true;
@@ -158,6 +165,8 @@ interface InferenceOptions {
158
165
  caller?: string;
159
166
  /** Session ID the call is associated with — appears in debug logs as sessionId=X */
160
167
  sessionId?: string;
168
+ /** Let the model search and read the web, and nothing else; a route that can't fails */
169
+ web?: boolean;
161
170
  }
162
171
 
163
172
  interface InferenceResult {
@@ -210,6 +219,10 @@ async function routeInference(opts: InferenceOptions): Promise<InferenceResult>
210
219
  const caller = opts.caller ?? "anonymous";
211
220
  const session = opts.sessionId ?? "-";
212
221
  const tag = `caller=${caller} sessionId=${session}`;
222
+ if (opts.web && !canSearchWeb()) {
223
+ logDebug("inference", `${tag} refuse: no web route agent=${agent}`);
224
+ return { success: false, error: `web research is not available on ${agent} yet` };
225
+ }
213
226
  if (isClaude()) {
214
227
  const bin = getClaudeBinary();
215
228
  if (bin) {
@@ -342,6 +355,15 @@ function hasAntigravityBinary(): boolean {
342
355
  * line. System, user and any JSON-schema instruction all travel together on
343
356
  * stdin instead, the same way every other agent receives them.
344
357
  */
358
+ const CLAUDE_WEB_TOOLS = "WebSearch,WebFetch";
359
+
360
+ /** --tools alone makes the web tools visible; --allowed-tools spares them the prompt. */
361
+ function claudeTools(opts: InferenceOptions): string[] {
362
+ return opts.web
363
+ ? ["--tools", CLAUDE_WEB_TOOLS, "--allowed-tools", CLAUDE_WEB_TOOLS]
364
+ : ["--tools", ""];
365
+ }
366
+
345
367
  export function buildClaudeArgs(
346
368
  opts: InferenceOptions,
347
369
  systemPromptFile?: string
@@ -350,8 +372,7 @@ export function buildClaudeArgs(
350
372
  "--print",
351
373
  "--model",
352
374
  modelFor("claude-spawn", opts),
353
- "--tools",
354
- "",
375
+ ...claudeTools(opts),
355
376
  "--output-format",
356
377
  "text",
357
378
  "--setting-sources",
@@ -447,6 +468,7 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
447
468
  "--sandbox",
448
469
  "read-only",
449
470
  "--ephemeral",
471
+ ...(opts.web ? ["-c", "web_search=live"] : []),
450
472
  ];
451
473
  }
452
474
 
@@ -580,8 +602,12 @@ async function inferenceViaAntigravitySpawn(
580
602
  * --disable-builtin-mcps → no MCP servers in the child (extra safety)
581
603
  * --no-auto-update → prevent CLI self-update from delaying the spawn
582
604
  * --no-color → clean stdout for capture
583
- * --allow-all-tools → REQUIRED for non-interactive mode (without it,
584
- * copilot prompts for tool-use confirmation)
605
+ * --available-tools=none → Copilot has no "no tools" flag; an allowlist
606
+ * naming an unknown tool hides every tool
607
+ * --deny-tool=… → backstop: deny rules win over any allow rule
608
+ * --silent → keeps the tool-filter notices out of stdout
609
+ * web: only web_fetch, pre-approved for every URL; Copilot has no search tool,
610
+ * so it fetches search pages itself
585
611
  *
586
612
  * Copilot has no --system-prompt flag, so system + user + JSON-schema are
587
613
  * concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
@@ -589,16 +615,25 @@ async function inferenceViaAntigravitySpawn(
589
615
  * survive cmd.exe when Bun.spawn resolves copilot to its Windows .cmd shim.
590
616
  * Piping stdin keeps copilot non-interactive, so dropping -p costs nothing.
591
617
  */
592
- export function buildCopilotArgs(_opts: InferenceOptions): string[] {
618
+ export function buildCopilotArgs(opts: InferenceOptions): string[] {
593
619
  return [
594
620
  "--no-custom-instructions",
595
621
  "--disable-builtin-mcps",
596
622
  "--no-auto-update",
597
623
  "--no-color",
598
- "--allow-all-tools",
624
+ "--silent",
625
+ ...copilotTools(opts),
626
+ "--deny-tool=shell",
627
+ "--deny-tool=write",
599
628
  ];
600
629
  }
601
630
 
631
+ function copilotTools(opts: InferenceOptions): string[] {
632
+ return opts.web
633
+ ? ["--available-tools=web_fetch", "--allow-tool=web_fetch", "--allow-all-urls"]
634
+ : ["--available-tools=none", "--deny-tool=url"];
635
+ }
636
+
602
637
  /**
603
638
  * Extract the agent's text reply from opencode --format json NDJSON output.
604
639
  * Concatenates all `type:"text"` event payloads in order. Returns empty
@@ -168,6 +168,7 @@ export async function runStopHandlers(
168
168
  // any in-hook budget. These spawn detached bun subprocesses that run the
169
169
  // inference and write results to disk; they don't block this hook.
170
170
  await detachSessionIntelligence(transcript, options.sessionId);
171
+ await detachEntityExtraction(transcript, options.sessionId);
171
172
  await detachFailurePrinciple(transcript);
172
173
  detachAgenda(options.sessionId);
173
174
  // Failure auto-graduation is intentionally NOT wired here: every pattern it
@@ -325,6 +326,24 @@ async function detachSessionIntelligence(
325
326
  }
326
327
  }
327
328
 
329
+ async function detachEntityExtraction(
330
+ transcript: string,
331
+ sessionId?: string
332
+ ): Promise<void> {
333
+ if (!sessionId) return;
334
+ try {
335
+ const transcriptPath = await writeTranscriptTmp(transcript);
336
+ const scriptPath = resolve(assets.hooks(), "handlers", "entity-extraction.ts");
337
+ spawnDetachedInference(
338
+ scriptPath,
339
+ ["--run", sessionId, transcriptPath],
340
+ "entity-extraction"
341
+ );
342
+ } catch (err) {
343
+ logError("detachEntityExtraction", err);
344
+ }
345
+ }
346
+
328
347
  /**
329
348
  * If a pending-failure exists, rename it to a unique path (race-free claim),
330
349
  * write the transcript to tmp, spawn the failure-principle handler detached.
@@ -14,6 +14,8 @@ type TokenCaller =
14
14
  | "failure"
15
15
  | "session-name"
16
16
  | "session-intelligence"
17
+ | "entity-extraction"
18
+ | "entity-research"
17
19
  | "relationship"
18
20
  | "self-model"
19
21
  | "agenda-serves"
@@ -29,7 +29,14 @@ import {
29
29
 
30
30
  // --- Public input shape -----------------------------------------------------
31
31
 
32
- export interface PersonInput {
32
+ interface Addressing {
33
+ aliases?: string[] | null;
34
+ relation?: string | null;
35
+ /** The stored file to update, when its slug is not derived from the name or domain. */
36
+ slug?: string | null;
37
+ }
38
+
39
+ export interface PersonInput extends Addressing {
33
40
  name: string;
34
41
  role?: string | null;
35
42
  title?: string | null;
@@ -40,7 +47,7 @@ export interface PersonInput {
40
47
  [extra: string]: unknown;
41
48
  }
42
49
 
43
- export interface CompanyInput {
50
+ export interface CompanyInput extends Addressing {
44
51
  name: string;
45
52
  domain?: string | null;
46
53
  industry?: string | null;
@@ -201,7 +208,17 @@ function newPersonEntity(input: PersonInput, slug: string): Entity {
201
208
  if (Object.keys(socials).length > 0) {
202
209
  fm.socials = Object.entries(socials).map(([k, v]) => `${k}:${v}`);
203
210
  }
204
- return { domain: "People", slug, frontmatter: fm, body: "" };
211
+ return { domain: "People", slug, frontmatter: withAddressing(fm, input), body: "" };
212
+ }
213
+
214
+ /** How the user refers to an entity and stands towards it — both only ever grow. */
215
+ function withAddressing(fm: EntityFrontmatter, input: Addressing): EntityFrontmatter {
216
+ const prior = Array.isArray(fm.aliases) ? (fm.aliases as string[]) : undefined;
217
+ const aliases = mergeStringArray(prior, input.aliases ?? undefined);
218
+ if (aliases.length > 0) fm.aliases = aliases;
219
+ const relation = mergeScalar(fm.relation, input.relation);
220
+ if (relation) fm.relation = relation;
221
+ return fm;
205
222
  }
206
223
 
207
224
  function newCompanyEntity(input: CompanyInput, slug: string): Entity {
@@ -220,7 +237,7 @@ function newCompanyEntity(input: CompanyInput, slug: string): Entity {
220
237
  if (input.industry) fm.industry = input.industry;
221
238
  if (input.mentioned_as) fm.mentioned_as = input.mentioned_as;
222
239
  if (input.sentiment) fm.sentiment = input.sentiment;
223
- return { domain: "Companies", slug, frontmatter: fm, body: "" };
240
+ return { domain: "Companies", slug, frontmatter: withAddressing(fm, input), body: "" };
224
241
  }
225
242
 
226
243
  function mergePerson(prior: Entity, input: PersonInput): Entity {
@@ -248,7 +265,7 @@ function mergePerson(prior: Entity, input: PersonInput): Entity {
248
265
  if (Object.keys(socials).length > 0) {
249
266
  fm.socials = Object.entries(socials).map(([k, v]) => `${k}:${v}`);
250
267
  }
251
- return { ...prior, frontmatter: fm };
268
+ return { ...prior, frontmatter: withAddressing(fm, input) };
252
269
  }
253
270
 
254
271
  function mergeCompany(prior: Entity, input: CompanyInput): Entity {
@@ -260,7 +277,7 @@ function mergeCompany(prior: Entity, input: CompanyInput): Entity {
260
277
  if (input.industry) {
261
278
  fm.tags = mergeStringArray(fm.tags, industryToTopicTags(input.industry));
262
279
  }
263
- return { ...prior, frontmatter: fm };
280
+ return { ...prior, frontmatter: withAddressing(fm, input) };
264
281
  }
265
282
 
266
283
  function upsertPerson(
@@ -268,7 +285,7 @@ function upsertPerson(
268
285
  sourceId: string,
269
286
  rootDir?: string
270
287
  ): UpsertResult {
271
- const slug = slugify(input.name);
288
+ const slug = input.slug || slugify(input.name);
272
289
  if (!slug) throw new Error(`ingest: cannot slugify person name "${input.name}"`);
273
290
  const prior = load("People", slug, rootDir);
274
291
  const created = prior === null;
@@ -294,7 +311,7 @@ function upsertCompany(
294
311
  rootDir?: string
295
312
  ): UpsertResult {
296
313
  const baseKey = input.domain?.trim() ? input.domain : input.name;
297
- const slug = slugify(baseKey);
314
+ const slug = input.slug || slugify(baseKey);
298
315
  if (!slug) throw new Error(`ingest: cannot slugify company "${input.name}"`);
299
316
  const prior = load("Companies", slug, rootDir);
300
317
  const created = prior === null;