portable-agent-layer 0.92.0 → 0.93.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.
@@ -98,6 +98,14 @@
98
98
  "type": "boolean",
99
99
  "description": "Let the rules you approved from your past corrections steer the agent: a rule triggered by your prompt is injected before the agent answers, and a reply that matches a rule's trigger is sent back to the agent once at stop. Each firing is logged to memory/adaptation/rule-events.jsonl. Set false to switch every rule off. Reply-side rules do not run on Cursor, whose stop payload carries no reply text, or on opencode, whose plugin does not run the stop check."
100
100
  },
101
+ "entityCards": {
102
+ "type": "boolean",
103
+ "description": "When a prompt names a person or company from the knowledge store, inject a short card about them before the agent answers. Reads files only; no model call."
104
+ },
105
+ "entityExtraction": {
106
+ "type": "boolean",
107
+ "description": "After a session stops, ask a background model which people and companies your own messages mentioned, and write clear matches and new full names to the knowledge store with the message as source. A name that could be more than one entry, or a first name alone, waits for `pal cli knowledge review` instead."
108
+ },
101
109
  "steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
102
110
  }
103
111
  },
@@ -33,6 +33,8 @@
33
33
  "claimCheck": true,
34
34
  "claimCheckBlocks": false,
35
35
  "adaptationRules": true,
36
+ "entityCards": true,
37
+ "entityExtraction": true,
36
38
  "steeringTestReport": false
37
39
  },
38
40
  "steering": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.92.0",
3
+ "version": "0.93.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,13 @@
6
6
  */
7
7
 
8
8
  import { readFileSync } from "node:fs";
9
+ import type { ReviewItem } from "../hooks/lib/entity-extraction";
10
+ import {
11
+ type AcceptChoice,
12
+ acceptReview,
13
+ pendingReviews,
14
+ rejectReview,
15
+ } from "../hooks/lib/entity-review";
9
16
  import { toPath } from "../hooks/lib/paths";
10
17
  import { buildGraph, resolveSlug, stats, traverse } from "../tools/knowledge/graph";
11
18
  import {
@@ -113,6 +120,36 @@ export const knowledgeCommand = group({
113
120
  },
114
121
  run: ({ values }) => cmdIngest(values.source ?? "manual", values.file),
115
122
  }),
123
+ review: group({
124
+ summary: "People and companies the background extractor would not guess",
125
+ fallback: "list",
126
+ commands: {
127
+ list: leaf({ summary: "Show what waits for an answer", run: cmdReviewList }),
128
+ accept: leaf({
129
+ summary: "Write an item, to an existing entry (--as) or a new one",
130
+ args: "<id>",
131
+ options: {
132
+ as: {
133
+ type: "string",
134
+ value: "<slug>",
135
+ description: "Existing entry to update",
136
+ },
137
+ name: {
138
+ type: "string",
139
+ value: "<name>",
140
+ description: "Name for a new entry (default: as extracted)",
141
+ },
142
+ },
143
+ run: ({ positionals, values }) =>
144
+ cmdReviewAccept(positionals[0], { as: values.as, name: values.name }),
145
+ }),
146
+ reject: leaf({
147
+ summary: "Drop an item without writing it",
148
+ args: "<id>",
149
+ run: ({ positionals }) => cmdReviewReject(positionals[0]),
150
+ }),
151
+ },
152
+ }),
116
153
  },
117
154
  details: VOCABULARY,
118
155
  });
@@ -578,3 +615,63 @@ async function cmdIngest(sourceId: string, file: string | undefined): Promise<nu
578
615
  console.log(JSON.stringify(summary, null, 2));
579
616
  return 0;
580
617
  }
618
+
619
+ // ── review ─────────────────────────────────────────────────────────
620
+
621
+ function reviewReason(reason: ReviewItem["reason"]): string {
622
+ const reasons: Record<ReviewItem["reason"], string> = {
623
+ ambiguous: "could be more than one known entry",
624
+ "unknown-existing": "named an entry the store does not have",
625
+ "first-name-only": "first name only",
626
+ };
627
+ return reasons[reason];
628
+ }
629
+
630
+ function reviewLines(item: ReviewItem): string[] {
631
+ const { kind, name, fact } = item.entity;
632
+ const candidates =
633
+ item.candidates.length > 0 ? `could be: ${item.candidates.join(", ")}` : "";
634
+ return [
635
+ ` ${item.id} ${kind} "${name}" — ${reviewReason(item.reason)}`,
636
+ ...[fact, candidates, `from ${item.source}`]
637
+ .filter(Boolean)
638
+ .map((line) => ` ${line}`),
639
+ ];
640
+ }
641
+
642
+ function cmdReviewList(): number {
643
+ const items = pendingReviews();
644
+ if (items.length === 0) {
645
+ console.log("Nothing waits for review.");
646
+ return 0;
647
+ }
648
+ console.log(`\n${items.length} waiting for review\n`);
649
+ for (const item of items) console.log(reviewLines(item).join("\n"));
650
+ console.log(
651
+ "\nAccept: pal cli knowledge review accept <id> [--as <slug>] [--name <name>]\nReject: pal cli knowledge review reject <id>\n"
652
+ );
653
+ return 0;
654
+ }
655
+
656
+ function acceptOrExplain(id: string, choice: AcceptChoice) {
657
+ try {
658
+ return acceptReview(id, choice);
659
+ } catch (err) {
660
+ throw new UsageError(err instanceof Error ? err.message : String(err));
661
+ }
662
+ }
663
+
664
+ function cmdReviewAccept(id: string, choice: AcceptChoice): number {
665
+ const accepted = acceptOrExplain(id, choice);
666
+ if (!accepted) throw new UsageError(`no review item "${id}"`);
667
+ const verb = accepted.created ? "Created" : "Updated";
668
+ console.log(`${verb} ${accepted.slug} from "${accepted.item.entity.name}".`);
669
+ return 0;
670
+ }
671
+
672
+ function cmdReviewReject(id: string): number {
673
+ const rejected = rejectReview(id);
674
+ if (!rejected) throw new UsageError(`no review item "${id}"`);
675
+ console.log(`Dropped "${rejected.entity.name}".`);
676
+ return 0;
677
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Stop handler: learn the people and companies the user mentioned since the last
3
+ * stop, so the prompt-time cards know them next time. Runs detached; one model
4
+ * call at most, and only when the new text names someone.
5
+ */
6
+
7
+ import { ingestEntities } from "../../tools/knowledge/ingest";
8
+ import { excludedNames, loadKnownEntities, loadNameIndex } from "../lib/entity-cards";
9
+ import {
10
+ extractionSchema,
11
+ extractionSystem,
12
+ extractionUser,
13
+ markSeen,
14
+ parseExtraction,
15
+ planExtraction,
16
+ queueForReview,
17
+ seenCount,
18
+ unseenUserText,
19
+ worthExtracting,
20
+ } from "../lib/entity-extraction";
21
+ import { canInfer, inference } from "../lib/inference";
22
+ import { logDebug, logError } from "../lib/log";
23
+ import { identity, isEnabled } from "../lib/settings";
24
+ import { logTokenUsage } from "../lib/token-usage";
25
+ import { parseMessages } from "../lib/transcript";
26
+
27
+ function sourceId(sessionId: string, count: number): string {
28
+ return `chat ${new Date().toISOString().slice(0, 10)} ${sessionId.slice(0, 8)} #${count}`;
29
+ }
30
+
31
+ /** @lintignore exercised directly by test/entity-extraction.test.ts */
32
+ export async function extractSessionEntities(
33
+ transcript: string,
34
+ sessionId?: string
35
+ ): Promise<void> {
36
+ if (!sessionId || !isEnabled("entityExtraction")) return;
37
+ const seen = seenCount(sessionId);
38
+ const { text, count } = unseenUserText(parseMessages(transcript), seen);
39
+ if (count <= seen) return;
40
+
41
+ const index = loadNameIndex(loadKnownEntities());
42
+ const excluded = excludedNames();
43
+ markSeen(sessionId, count);
44
+ if (!worthExtracting(text, index, excluded) || !canInfer()) return;
45
+
46
+ const id = identity();
47
+ const result = await inference({
48
+ system: extractionSystem(id.principal.name, id.ai.name),
49
+ user: extractionUser(text, index),
50
+ tier: "medium",
51
+ maxTokens: 900,
52
+ timeout: 90000,
53
+ jsonSchema: extractionSchema(),
54
+ caller: "entity-extraction",
55
+ sessionId,
56
+ });
57
+ if (result.usage) logTokenUsage("entity-extraction", result.usage);
58
+ if (!result.success || !result.output) return;
59
+
60
+ const source = sourceId(sessionId, count);
61
+ const plan = planExtraction(parseExtraction(result.output), index, excluded, source);
62
+ const written = ingestEntities(plan.ingest, source);
63
+ queueForReview(plan.review);
64
+ logDebug(
65
+ "entity-extraction",
66
+ `${written.people.length} people, ${written.companies.length} companies, ${plan.review.length} for review`
67
+ );
68
+ }
69
+
70
+ if (process.argv[2] === "--run") {
71
+ const sid = process.argv[3];
72
+ const transcriptPath = process.argv[4];
73
+ if (transcriptPath) {
74
+ const { readFile, unlink } = await import("node:fs/promises");
75
+ try {
76
+ const transcript = await readFile(transcriptPath, "utf-8");
77
+ await extractSessionEntities(transcript, sid === "" ? undefined : sid);
78
+ } catch (err) {
79
+ logError("entity-extraction:run", err);
80
+ } finally {
81
+ await unlink(transcriptPath).catch(() => {});
82
+ }
83
+ }
84
+ process.exit(0);
85
+ }
@@ -10,6 +10,7 @@
10
10
  import { promptRulesReminder } from "../lib/adaptation-steering";
11
11
  import { promptContextResponse } from "../lib/agent";
12
12
  import { dueNudgeReminder } from "../lib/daily-nudge";
13
+ import { getEntityReminder } from "../lib/entity-cards";
13
14
  import { observeTurn } from "../lib/interaction";
14
15
  import { logDebug, logError } from "../lib/log";
15
16
  import { parkPromptContext } from "../lib/parked-context";
@@ -91,6 +92,9 @@ export async function getPromptContext(
91
92
  withinBudget(() => dueNudgeReminder(), BUDGET_MS),
92
93
  userText && getSteeringReminder(userText),
93
94
  userText && getSkillReminder(userText),
95
+ userText &&
96
+ isEnabled("entityCards") &&
97
+ withinBudget(() => getEntityReminder(userText), BUDGET_MS),
94
98
  userText && (await getRetrievalReminder(userText)),
95
99
  ].filter((p): p is string => Boolean(p));
96
100
 
@@ -3,9 +3,10 @@ import { resolve } from "node:path";
3
3
  import { loadReflectNudge } from "../handlers/reflect-trigger";
4
4
  import { loadAlgorithmReviewNudge } from "./algorithm-review";
5
5
  import { loadAnalyzeNudge } from "./analyze-nudge";
6
+ import { loadEntityReviewNudge } from "./entity-review";
6
7
  import { ensureDir, paths } from "./paths";
7
8
  import { loadReactionAuditNudge } from "./reaction-audit";
8
- import { isOptedIn } from "./settings";
9
+ import { isEnabled, isOptedIn } from "./settings";
9
10
  import { localDay } from "./wall-clock";
10
11
 
11
12
  type ShownOn = Record<string, string>;
@@ -14,6 +15,7 @@ interface DueNudge {
14
15
  key: string;
15
16
  command: string;
16
17
  load: () => string;
18
+ enabled?: () => boolean;
17
19
  }
18
20
 
19
21
  const DUE_NUDGES: DueNudge[] = [
@@ -27,6 +29,27 @@ const DUE_NUDGES: DueNudge[] = [
27
29
  { key: "analyze", command: "/pal-analyze", load: loadAnalyzeNudge },
28
30
  ];
29
31
 
32
+ function entityReviewNudge(): DueNudge {
33
+ return {
34
+ key: "entity-review",
35
+ command: "pal cli knowledge review",
36
+ load: loadEntityReviewNudge,
37
+ enabled: () => isEnabled("entityExtraction"),
38
+ };
39
+ }
40
+
41
+ function dueNudges(): DueNudge[] {
42
+ return [...DUE_NUDGES, entityReviewNudge()];
43
+ }
44
+
45
+ function dueRemindersOn(): boolean {
46
+ return isOptedIn("dueReminders");
47
+ }
48
+
49
+ function isSwitchedOn(nudge: DueNudge): boolean {
50
+ return (nudge.enabled ?? dueRemindersOn)();
51
+ }
52
+
30
53
  function shownPath(): string {
31
54
  return resolve(ensureDir(paths.state()), "nudges-shown.json");
32
55
  }
@@ -53,7 +76,7 @@ export function pendingToday(
53
76
  }
54
77
 
55
78
  export function acknowledgeMentioned(reply: string, now: Date = new Date()): void {
56
- const mentioned = DUE_NUDGES.filter((nudge) => reply.includes(nudge.command));
79
+ const mentioned = dueNudges().filter((nudge) => reply.includes(nudge.command));
57
80
  if (!mentioned.length) return;
58
81
  const today = localDay(now);
59
82
  const shownOn = readShownOn();
@@ -62,10 +85,10 @@ export function acknowledgeMentioned(reply: string, now: Date = new Date()): voi
62
85
  }
63
86
 
64
87
  export function dueNudgeSections(now: Date = new Date()): string[] {
65
- if (!isOptedIn("dueReminders")) return [];
66
- return DUE_NUDGES.map((nudge) => pendingToday(nudge.key, nudge.load(), now)).filter(
67
- Boolean
68
- );
88
+ return dueNudges()
89
+ .filter(isSwitchedOn)
90
+ .map((nudge) => pendingToday(nudge.key, nudge.load(), now))
91
+ .filter(Boolean);
69
92
  }
70
93
 
71
94
  export function dueNudgeReminder(now: Date = new Date()): string | null {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Prompt-time cards for the people and companies a prompt names, so the user
3
+ * never has to re-explain who someone is. Reads files only; no model call.
4
+ */
5
+
6
+ import { type Entity, list } from "../../tools/knowledge/lib";
7
+ import {
8
+ buildNameIndex,
9
+ entityKey,
10
+ findMentions,
11
+ type Mention,
12
+ type NamedEntity,
13
+ type NameIndex,
14
+ } from "./entity-names";
15
+ import { identity } from "./settings";
16
+
17
+ const MAX_CARDS = 3;
18
+ const MAX_LATEST = 160;
19
+
20
+ function stringList(value: unknown): string[] {
21
+ return Array.isArray(value)
22
+ ? value.filter((v): v is string => typeof v === "string")
23
+ : [];
24
+ }
25
+
26
+ function textField(entity: Entity, key: string): string {
27
+ const value = entity.frontmatter[key];
28
+ return typeof value === "string" ? value.trim() : "";
29
+ }
30
+
31
+ function namedEntity(entity: Entity): NamedEntity {
32
+ return {
33
+ slug: entity.slug,
34
+ domain: entity.domain === "Companies" ? "Companies" : "People",
35
+ title: entity.frontmatter.title,
36
+ aliases: stringList(entity.frontmatter.aliases),
37
+ domainName: textField(entity, "domain_name") || undefined,
38
+ };
39
+ }
40
+
41
+ export function loadKnownEntities(rootDir?: string): Entity[] {
42
+ return [...list("People", rootDir), ...list("Companies", rootDir)];
43
+ }
44
+
45
+ export function excludedNames(): string[] {
46
+ const id = identity();
47
+ return [id.principal.name, id.ai.name];
48
+ }
49
+
50
+ export function loadNameIndex(entities: Entity[]): NameIndex {
51
+ return buildNameIndex(entities.map(namedEntity), excludedNames());
52
+ }
53
+
54
+ function isSourceLine(line: string): boolean {
55
+ return (
56
+ line.startsWith("### ") ||
57
+ line.startsWith("<!--") ||
58
+ /^(?:role|importance|mentioned_as|sentiment): /.test(line)
59
+ );
60
+ }
61
+
62
+ /** The newest thing the store learned about this entity, in a sentence or two. */
63
+ export function latestNote(body: string): string {
64
+ const sections = body.split(/\n(?=### )/);
65
+ const text = (sections.at(-1) ?? "")
66
+ .split("\n")
67
+ .map((l) => l.trim())
68
+ .filter((l) => l && !isSourceLine(l))
69
+ .join(" ")
70
+ .replace(/\*\*/g, "");
71
+ return text.length > MAX_LATEST ? `${text.slice(0, MAX_LATEST - 1)}…` : text;
72
+ }
73
+
74
+ function describe(entity: Entity): string {
75
+ const fields =
76
+ entity.domain === "People"
77
+ ? [
78
+ textField(entity, "position") || textField(entity, "role"),
79
+ textField(entity, "company"),
80
+ ]
81
+ : [textField(entity, "industry")];
82
+ const facts = [...fields, textField(entity, "relation")].filter(
83
+ (f) => f && f !== "subject"
84
+ );
85
+ const latest = latestNote(entity.body);
86
+ return [facts.join(", "), latest].filter(Boolean).join(". ");
87
+ }
88
+
89
+ function card(mention: Mention, bySlug: Map<string, Entity>): string {
90
+ if (mention.entities.length > 1) {
91
+ const names = mention.entities.map((e) => e.title).join(", ");
92
+ return `- "${mention.said}" could be: ${names}. Ask which one if it matters.`;
93
+ }
94
+ const entity = bySlug.get(entityKey(mention.entities[0]));
95
+ const summary = entity ? describe(entity) : "";
96
+ const name = `- **${mention.entities[0].title}**`;
97
+ return summary ? `${name}: ${summary}` : name;
98
+ }
99
+
100
+ export function entityReminder(prompt: string, entities: Entity[]): string | null {
101
+ const mentions = findMentions(prompt, loadNameIndex(entities)).slice(0, MAX_CARDS);
102
+ if (mentions.length === 0) return null;
103
+ const bySlug = new Map(entities.map((e) => [entityKey(e), e]));
104
+ return [
105
+ "**Known people and companies in this message** (PAL knowledge store):",
106
+ ...mentions.map((m) => card(m, bySlug)),
107
+ ].join("\n");
108
+ }
109
+
110
+ export function getEntityReminder(prompt: string): string | null {
111
+ return entityReminder(prompt, loadKnownEntities());
112
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Turning what the user said about people and companies into knowledge-store
3
+ * writes. A clear match or a new name is written; a name that could be more than
4
+ * one known entity waits in the review queue instead of being guessed.
5
+ */
6
+
7
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import type {
10
+ CompanyInput,
11
+ IngestInput,
12
+ PersonInput,
13
+ } from "../../tools/knowledge/ingest";
14
+ import {
15
+ findMentions,
16
+ foldName,
17
+ indexedEntities,
18
+ isExcluded,
19
+ lookupName,
20
+ type NamedEntity,
21
+ type NameIndex,
22
+ proseOnly,
23
+ } from "./entity-names";
24
+ import { paths } from "./paths";
25
+ import { isSystemText, stripInjectedTags } from "./prompt-text";
26
+
27
+ export interface ExtractedEntity {
28
+ kind: "person" | "company";
29
+ name: string;
30
+ existing: string;
31
+ aliases: string[];
32
+ role: string;
33
+ organization: string;
34
+ relation: string;
35
+ fact: string;
36
+ }
37
+
38
+ export interface ReviewItem {
39
+ ts: string;
40
+ source: string;
41
+ id: string;
42
+ reason: "ambiguous" | "unknown-existing" | "first-name-only";
43
+ entity: ExtractedEntity;
44
+ candidates: string[];
45
+ }
46
+
47
+ export interface ExtractionPlan {
48
+ ingest: IngestInput;
49
+ review: ReviewItem[];
50
+ }
51
+
52
+ function closedObject<P extends Record<string, unknown>>(properties: P) {
53
+ return {
54
+ type: "object" as const,
55
+ additionalProperties: false,
56
+ properties,
57
+ required: Object.keys(properties),
58
+ };
59
+ }
60
+
61
+ export function extractionSchema() {
62
+ const text = { type: "string" as const };
63
+ const entity = closedObject({
64
+ kind: { type: "string" as const, enum: ["person", "company"] },
65
+ name: text,
66
+ existing: text,
67
+ aliases: { type: "array" as const, items: text },
68
+ role: text,
69
+ organization: text,
70
+ relation: text,
71
+ fact: text,
72
+ });
73
+ return closedObject({ entities: { type: "array" as const, items: entity } });
74
+ }
75
+
76
+ const MAX_TEXT = 4000;
77
+ const MIN_TEXT = 20;
78
+ const MAX_KNOWN = 120;
79
+ const CURSOR_KEEP = 200;
80
+
81
+ interface Message {
82
+ role: string;
83
+ content: unknown;
84
+ }
85
+
86
+ function userTexts(messages: Message[]): string[] {
87
+ return messages
88
+ .filter((m) => m.role === "user" && typeof m.content === "string")
89
+ .map((m) => stripInjectedTags(m.content as string))
90
+ .filter((t) => t.length > 0 && !isSystemText(t));
91
+ }
92
+
93
+ /** The user's words since the last extraction, and the new high-water mark. */
94
+ export function unseenUserText(
95
+ messages: Message[],
96
+ seen: number
97
+ ): { text: string; count: number } {
98
+ const texts = userTexts(messages);
99
+ const fresh = texts.slice(Math.min(seen, texts.length));
100
+ const text = fresh.map((t, i) => `${i + 1}. ${proseOnly(t).trim()}`).join("\n");
101
+ return { text: text.slice(-MAX_TEXT), count: texts.length };
102
+ }
103
+
104
+ function hasUnknownProperName(text: string, ignored: Set<string>): boolean {
105
+ const sentences = proseOnly(text).split(/[.!?\n]+/);
106
+ return sentences.some((s) =>
107
+ s
108
+ .trim()
109
+ .split(/\s+/)
110
+ .slice(1)
111
+ .some((w) => /^\p{Lu}\p{Ll}/u.test(w) && !ignored.has(foldName(w)))
112
+ );
113
+ }
114
+
115
+ /** Cheap gate before a model call: a known name, or a capitalised word mid-sentence. */
116
+ export function worthExtracting(
117
+ text: string,
118
+ index: NameIndex,
119
+ excluded: string[]
120
+ ): boolean {
121
+ if (proseOnly(text).trim().length < MIN_TEXT) return false;
122
+ if (findMentions(text, index).length > 0) return true;
123
+ return hasUnknownProperName(text, new Set(excluded.map(foldName)));
124
+ }
125
+
126
+ function knownLine(entity: NamedEntity): string {
127
+ const aliases =
128
+ entity.aliases.length > 0 ? ` (also: ${entity.aliases.join(", ")})` : "";
129
+ const kind = entity.domain === "People" ? "person" : "company";
130
+ return `${entity.slug} | ${kind} | ${entity.title}${aliases}`;
131
+ }
132
+
133
+ export function extractionSystem(principal: string, assistant: string): string {
134
+ return [
135
+ `You keep a contact book for ${principal}. From ${principal}'s own messages to an AI assistant, list the people and companies ${principal} talks about as part of their work and life.`,
136
+ `- Never list ${principal} or the assistant (${assistant}).`,
137
+ `- People: individuals ${principal} deals with (colleagues, clients, partners, friends, family, contacts). Skip public figures cited only for their work.`,
138
+ `- Companies: only organisations ${principal} has a relationship with: employer, own company, client, prospect, partner, contractor, school. Skip tools, products, platforms, languages and vendors that are merely used or discussed.`,
139
+ "- existing: the slug of the matching known entry below, or empty when it is someone new.",
140
+ "- aliases: other names the messages use for it (nickname, short form), not the main name.",
141
+ `- role, organization (a person's company), relation (how ${principal} relates to it: client, partner, colleague, friend, employer, prospect, own company): only what the messages state, else empty.`,
142
+ "- fact: one sentence of what the messages state about it, else empty. Never guess.",
143
+ "- Ignore names that appear only inside pasted logs, code or email headers.",
144
+ "Return an empty list when nobody qualifies.",
145
+ ].join("\n");
146
+ }
147
+
148
+ export function extractionUser(text: string, index: NameIndex): string {
149
+ const known = indexedEntities(index)
150
+ .filter((e) => !isExcluded(e, index))
151
+ .slice(0, MAX_KNOWN)
152
+ .map(knownLine);
153
+ return `Known entries (slug | kind | name):\n${known.join("\n") || "(none)"}\n\nMessages:\n${text}`;
154
+ }
155
+
156
+ function domainOf(kind: ExtractedEntity["kind"]): NamedEntity["domain"] {
157
+ return kind === "person" ? "People" : "Companies";
158
+ }
159
+
160
+ function namesUser(e: ExtractedEntity, excluded: string[]): boolean {
161
+ const own = new Set(excluded.filter(Boolean).map(foldName));
162
+ return [e.name, ...e.aliases].some((n) => own.has(foldName(n)));
163
+ }
164
+
165
+ type Resolution =
166
+ | { kind: "new" }
167
+ | { kind: "known"; entity: NamedEntity }
168
+ | { kind: "review"; reason: ReviewItem["reason"]; candidates: NamedEntity[] };
169
+
170
+ function resolveExisting(e: ExtractedEntity, sameDomain: NamedEntity[]): Resolution {
171
+ const entity = sameDomain.find((c) => c.slug === e.existing);
172
+ return entity
173
+ ? { kind: "known", entity }
174
+ : { kind: "review", reason: "unknown-existing", candidates: [] };
175
+ }
176
+
177
+ /** "Dan" alone may be a known Daniel under a nickname the store hasn't seen yet. */
178
+ function newcomer(e: ExtractedEntity): Resolution {
179
+ const firstNameOnly = e.kind === "person" && !/\s/.test(e.name.trim());
180
+ return firstNameOnly
181
+ ? { kind: "review", reason: "first-name-only", candidates: [] }
182
+ : { kind: "new" };
183
+ }
184
+
185
+ function resolveEntity(e: ExtractedEntity, index: NameIndex): Resolution {
186
+ const domain = domainOf(e.kind);
187
+ const sameDomain = indexedEntities(index).filter((c) => c.domain === domain);
188
+ if (e.existing) return resolveExisting(e, sameDomain);
189
+ const { entities, exact } = lookupName(e.name, index);
190
+ const candidates = entities.filter((c) => c.domain === domain);
191
+ if (candidates.length === 0) return newcomer(e);
192
+ if (exact && candidates.length === 1) return { kind: "known", entity: candidates[0] };
193
+ return { kind: "review", reason: "ambiguous", candidates };
194
+ }
195
+
196
+ function otherNames(e: ExtractedEntity, storedName: string): string[] {
197
+ const names = [e.name, ...e.aliases].map((n) => n.trim()).filter(Boolean);
198
+ return [...new Set(names)].filter((n) => foldName(n) !== foldName(storedName));
199
+ }
200
+
201
+ /** "Acme" for a stored "Acme Kft." links the person to that file, not a new stub. */
202
+ function storedOrganization(organization: string, index: NameIndex): string | null {
203
+ if (!organization.trim()) return null;
204
+ const { entities, exact } = lookupName(organization, index);
205
+ const companies = entities.filter((c) => c.domain === "Companies");
206
+ return exact && companies.length === 1 ? companies[0].title : organization;
207
+ }
208
+
209
+ function personInput(
210
+ e: ExtractedEntity,
211
+ name: string,
212
+ index: NameIndex,
213
+ slug: string | null
214
+ ): PersonInput {
215
+ return {
216
+ name,
217
+ slug,
218
+ role: e.role || null,
219
+ company: storedOrganization(e.organization, index),
220
+ context: e.fact || null,
221
+ aliases: otherNames(e, name),
222
+ relation: e.relation || null,
223
+ };
224
+ }
225
+
226
+ function companyInput(
227
+ e: ExtractedEntity,
228
+ name: string,
229
+ slug: string | null
230
+ ): CompanyInput {
231
+ return {
232
+ name,
233
+ slug,
234
+ context: e.fact || null,
235
+ aliases: otherNames(e, name),
236
+ relation: e.relation || null,
237
+ };
238
+ }
239
+
240
+ /** Ingest input for one extracted entity: the known file it updates, or a new one. */
241
+ export function ingestInput(
242
+ e: ExtractedEntity,
243
+ index: NameIndex,
244
+ known: NamedEntity | null,
245
+ newName?: string
246
+ ): IngestInput {
247
+ const name = known?.title ?? (newName?.trim() || e.name.trim());
248
+ const slug = known?.slug ?? null;
249
+ return e.kind === "person"
250
+ ? { people: [personInput(e, name, index, slug)] }
251
+ : { companies: [companyInput(e, name, slug)] };
252
+ }
253
+
254
+ export function planExtraction(
255
+ extracted: ExtractedEntity[],
256
+ index: NameIndex,
257
+ excluded: string[],
258
+ source: string
259
+ ): ExtractionPlan {
260
+ const plan: ExtractionPlan = { ingest: { people: [], companies: [] }, review: [] };
261
+ const queue = (
262
+ entity: ExtractedEntity,
263
+ reason: ReviewItem["reason"],
264
+ candidates: NamedEntity[]
265
+ ) =>
266
+ plan.review.push({
267
+ id: crypto.randomUUID().slice(0, 8),
268
+ ts: new Date().toISOString(),
269
+ source,
270
+ reason,
271
+ entity,
272
+ candidates: candidates.map((c) => c.slug).sort(),
273
+ });
274
+ for (const e of extracted) {
275
+ if (!e.name.trim() || namesUser(e, excluded)) continue;
276
+ const resolution = resolveEntity(e, index);
277
+ if (resolution.kind === "review") {
278
+ queue(e, resolution.reason, resolution.candidates);
279
+ continue;
280
+ }
281
+ if (resolution.kind === "known" && isExcluded(resolution.entity, index)) continue;
282
+ const known = resolution.kind === "known" ? resolution.entity : null;
283
+ const input = ingestInput(e, index, known);
284
+ plan.ingest.people?.push(...(input.people ?? []));
285
+ plan.ingest.companies?.push(...(input.companies ?? []));
286
+ }
287
+ return plan;
288
+ }
289
+
290
+ export function parseExtraction(output: string): ExtractedEntity[] {
291
+ try {
292
+ const parsed = JSON.parse(output) as { entities?: ExtractedEntity[] };
293
+ return Array.isArray(parsed.entities) ? parsed.entities : [];
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
298
+
299
+ function cursorFile(): string {
300
+ return resolve(paths.state(), "entity-extraction.json");
301
+ }
302
+
303
+ function readCursors(): Record<string, number> {
304
+ const file = cursorFile();
305
+ if (!existsSync(file)) return {};
306
+ try {
307
+ return JSON.parse(readFileSync(file, "utf-8")) as Record<string, number>;
308
+ } catch {
309
+ return {};
310
+ }
311
+ }
312
+
313
+ export function seenCount(sessionId: string): number {
314
+ return readCursors()[sessionId] ?? 0;
315
+ }
316
+
317
+ export function markSeen(sessionId: string, count: number): void {
318
+ const cursors = Object.entries({ ...readCursors(), [sessionId]: count }).slice(
319
+ -CURSOR_KEEP
320
+ );
321
+ writeFileSync(
322
+ cursorFile(),
323
+ `${JSON.stringify(Object.fromEntries(cursors), null, 2)}\n`
324
+ );
325
+ }
326
+
327
+ export function reviewQueueFile(): string {
328
+ return resolve(paths.knowledge(), "_review.jsonl");
329
+ }
330
+
331
+ export function queueForReview(items: ReviewItem[]): void {
332
+ if (items.length === 0) return;
333
+ appendFileSync(reviewQueueFile(), items.map((i) => `${JSON.stringify(i)}\n`).join(""));
334
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Which known people and companies a text mentions. A lookup against the names
3
+ * already in the knowledge store, never a guess at what a name looks like.
4
+ */
5
+
6
+ export interface NamedEntity {
7
+ slug: string;
8
+ domain: "People" | "Companies";
9
+ title: string;
10
+ aliases: string[];
11
+ domainName?: string;
12
+ }
13
+
14
+ interface Term {
15
+ tokens: string[];
16
+ entity: NamedEntity;
17
+ partial: boolean;
18
+ }
19
+
20
+ export interface NameIndex {
21
+ terms: Term[];
22
+ excluded: Set<string>;
23
+ }
24
+
25
+ export interface Mention {
26
+ said: string;
27
+ entities: NamedEntity[];
28
+ }
29
+
30
+ interface Token {
31
+ raw: string;
32
+ folded: string;
33
+ gapBefore: string;
34
+ }
35
+
36
+ const MIN_PARTIAL = 3;
37
+
38
+ function nonProse(): RegExp[] {
39
+ return [
40
+ /```[\s\S]*?```/g,
41
+ /`[^`\n]*`/g,
42
+ /\bhttps?:\/\/\S+/g,
43
+ /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g,
44
+ /[\w.-]+(?:\/[\w.-]+)+/g,
45
+ /<[^>\n]*>/g,
46
+ ];
47
+ }
48
+
49
+ function withoutLegalSuffix(name: string): string {
50
+ return name.replace(
51
+ /\s+(?:kft|nyrt|zrt|bt|kkt|ltd|inc|llc|gmbh|korlatolt felelossegu tarsasag)\.?$/,
52
+ ""
53
+ );
54
+ }
55
+
56
+ function breaksSentence(gap: string): boolean {
57
+ return /[.!?:;\n]/.test(gap);
58
+ }
59
+
60
+ export function foldName(name: string): string {
61
+ return name
62
+ .normalize("NFKD")
63
+ .replace(/\p{M}/gu, "")
64
+ .toLowerCase()
65
+ .replace(/['’]s$/, "")
66
+ .replace(/[.,]+$/, "")
67
+ .trim();
68
+ }
69
+
70
+ export function proseOnly(text: string): string {
71
+ return nonProse().reduce((out, re) => out.replace(re, " "), text);
72
+ }
73
+
74
+ function tokenize(text: string): Token[] {
75
+ const tokens: Token[] = [];
76
+ let last = 0;
77
+ for (const m of text.matchAll(/[\p{L}\p{N}](?:[\p{L}\p{N}'’.-]*[\p{L}\p{N}])?/gu)) {
78
+ const start = m.index ?? 0;
79
+ tokens.push({
80
+ raw: m[0],
81
+ folded: foldName(m[0]),
82
+ gapBefore: text.slice(last, start),
83
+ });
84
+ last = start + m[0].length;
85
+ }
86
+ return tokens;
87
+ }
88
+
89
+ function words(name: string): string[] {
90
+ return tokenize(name).map((t) => t.folded);
91
+ }
92
+
93
+ function companyNames(entity: NamedEntity): string[] {
94
+ const bare = withoutLegalSuffix(foldName(entity.title));
95
+ const site = entity.domainName ? foldName(entity.domainName).split(".")[0] : "";
96
+ return [entity.title, bare, site, ...entity.aliases];
97
+ }
98
+
99
+ function personNames(entity: NamedEntity): string[] {
100
+ const parts = words(entity.title);
101
+ const reversed = parts.length === 2 ? `${parts[1]} ${parts[0]}` : "";
102
+ return [entity.title, reversed, ...entity.aliases];
103
+ }
104
+
105
+ function fullTerms(entity: NamedEntity): Term[] {
106
+ const names = entity.domain === "People" ? personNames(entity) : companyNames(entity);
107
+ return names
108
+ .map(words)
109
+ .filter((tokens) => tokens.join(" ").length >= 2)
110
+ .map((tokens) => ({ tokens, entity, partial: false }));
111
+ }
112
+
113
+ function partialTerms(entity: NamedEntity): Term[] {
114
+ if (entity.domain !== "People") return [];
115
+ const parts = words(entity.title);
116
+ if (parts.length < 2) return [];
117
+ return parts
118
+ .filter((p) => p.length >= MIN_PARTIAL)
119
+ .map((p) => ({ tokens: [p], entity, partial: true }));
120
+ }
121
+
122
+ /** Names in `excludedNames` (the user, the assistant) are indexed so they consume
123
+ * their words, but never reported. */
124
+ export function buildNameIndex(
125
+ entities: NamedEntity[],
126
+ excludedNames: string[]
127
+ ): NameIndex {
128
+ const excludedFolded = new Set(excludedNames.filter(Boolean).map(foldName));
129
+ const excluded = new Set(
130
+ entities
131
+ .filter((e) => fullTerms(e).some((t) => excludedFolded.has(t.tokens.join(" "))))
132
+ .map(entityKey)
133
+ );
134
+ const terms = entities.flatMap((e) => [...fullTerms(e), ...partialTerms(e)]);
135
+ terms.sort(
136
+ (a, b) => Number(a.partial) - Number(b.partial) || b.tokens.length - a.tokens.length
137
+ );
138
+ return { terms, excluded };
139
+ }
140
+
141
+ function matchesAt(tokens: Token[], at: number, term: Term): boolean {
142
+ return term.tokens.every((w, i) => tokens[at + i]?.folded === w);
143
+ }
144
+
145
+ function isCapitalized(token: Token | undefined): boolean {
146
+ return Boolean(token && /^\p{Lu}/u.test(token.raw));
147
+ }
148
+
149
+ function joinedTo(token: Token | undefined): boolean {
150
+ return Boolean(
151
+ token && /^\s+$/.test(token.gapBefore) && !breaksSentence(token.gapBefore)
152
+ );
153
+ }
154
+
155
+ function startsSentence(tokens: Token[], at: number): boolean {
156
+ return at === 0 || breaksSentence(tokens[at].gapBefore);
157
+ }
158
+
159
+ /** "Daniel Miessler" is not a known Dániel: a lone given name next to an unknown
160
+ * capitalised word belongs to someone else. */
161
+ function besideUnknownName(tokens: Token[], at: number): boolean {
162
+ const next = tokens[at + 1];
163
+ if (joinedTo(next) && isCapitalized(next)) return true;
164
+ const prev = tokens[at - 1];
165
+ return joinedTo(tokens[at]) && isCapitalized(prev) && !startsSentence(tokens, at - 1);
166
+ }
167
+
168
+ function isLoneWordOfPerson(term: Term): boolean {
169
+ return term.entity.domain === "People" && term.tokens.length === 1;
170
+ }
171
+
172
+ function termsAt(index: NameIndex, tokens: Token[], at: number): Term[] {
173
+ const first = index.terms.find((t) => matchesAt(tokens, at, t));
174
+ if (!first) return [];
175
+ if (isLoneWordOfPerson(first) && besideUnknownName(tokens, at)) return [];
176
+ return index.terms.filter(
177
+ (t) =>
178
+ t.partial === first.partial &&
179
+ t.tokens.length === first.tokens.length &&
180
+ matchesAt(tokens, at, t)
181
+ );
182
+ }
183
+
184
+ function addMention(
185
+ found: Map<string, Mention>,
186
+ said: string,
187
+ terms: Term[],
188
+ index: NameIndex
189
+ ) {
190
+ const entities = distinctEntities(terms);
191
+ if (entities.some((e) => isExcluded(e, index))) return;
192
+ const key = entities.map(entityKey).sort().join("|");
193
+ if (!found.has(key)) found.set(key, { said, entities });
194
+ }
195
+
196
+ export function findMentions(text: string, index: NameIndex): Mention[] {
197
+ const tokens = tokenize(proseOnly(text));
198
+ const found = new Map<string, Mention>();
199
+ let at = 0;
200
+ while (at < tokens.length) {
201
+ const terms = termsAt(index, tokens, at);
202
+ const width = terms[0]?.tokens.length ?? 1;
203
+ if (terms.length > 0) {
204
+ const said = tokens
205
+ .slice(at, at + width)
206
+ .map((t) => t.raw)
207
+ .join(" ");
208
+ addMention(found, said, terms, index);
209
+ }
210
+ at += width;
211
+ }
212
+ return [...found.values()];
213
+ }
214
+
215
+ /** The entities a name could mean; `exact` when it is someone's full name or alias
216
+ * rather than one word of a longer name. */
217
+ export function lookupName(
218
+ name: string,
219
+ index: NameIndex
220
+ ): { entities: NamedEntity[]; exact: boolean } {
221
+ const folded = words(name).join(" ");
222
+ const named = (t: Term) => t.tokens.join(" ") === folded;
223
+ const full = index.terms.filter((t) => !t.partial && named(t));
224
+ if (full.length > 0) return { entities: distinctEntities(full), exact: true };
225
+ return { entities: distinctEntities(index.terms.filter(named)), exact: false };
226
+ }
227
+
228
+ export function indexedEntities(index: NameIndex): NamedEntity[] {
229
+ return distinctEntities(index.terms);
230
+ }
231
+
232
+ export function isExcluded(entity: NamedEntity, index: NameIndex): boolean {
233
+ return index.excluded.has(entityKey(entity));
234
+ }
235
+
236
+ export function entityKey(entity: { domain: string; slug: string }): string {
237
+ return `${entity.domain}/${entity.slug}`;
238
+ }
239
+
240
+ function distinctEntities(terms: Term[]): NamedEntity[] {
241
+ return [...new Map(terms.map((t) => [entityKey(t.entity), t.entity])).values()];
242
+ }
@@ -0,0 +1,88 @@
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 { 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
+
12
+ export interface AcceptChoice {
13
+ as?: string;
14
+ name?: string;
15
+ }
16
+
17
+ export interface Accepted {
18
+ item: ReviewItem;
19
+ slug: string;
20
+ created: boolean;
21
+ }
22
+
23
+ function parseItem(line: string): ReviewItem[] {
24
+ try {
25
+ return [JSON.parse(line) as ReviewItem];
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ export function pendingReviews(): ReviewItem[] {
32
+ const file = reviewQueueFile();
33
+ if (!existsSync(file)) return [];
34
+ return readFileSync(file, "utf-8").split("\n").filter(Boolean).flatMap(parseItem);
35
+ }
36
+
37
+ function takeFromQueue(id: string): ReviewItem | null {
38
+ const items = pendingReviews();
39
+ const item = items.find((i) => i.id === id);
40
+ if (!item) return null;
41
+ const rest = items.filter((i) => i !== item);
42
+ writeFileSync(reviewQueueFile(), rest.map((i) => `${JSON.stringify(i)}\n`).join(""));
43
+ return item;
44
+ }
45
+
46
+ function chosenEntity(
47
+ item: ReviewItem,
48
+ slug: string | undefined,
49
+ index: NameIndex
50
+ ): NamedEntity | null {
51
+ if (!slug) return null;
52
+ const domain = item.entity.kind === "person" ? "People" : "Companies";
53
+ const entity = indexedEntities(index).find(
54
+ (e) => e.domain === domain && e.slug === slug
55
+ );
56
+ if (!entity) throw new Error(`no ${domain} entry with slug "${slug}"`);
57
+ return entity;
58
+ }
59
+
60
+ export function loadEntityReviewNudge(): string {
61
+ const count = pendingReviews().length;
62
+ if (count === 0) return "";
63
+ const waiting =
64
+ count === 1 ? "1 person or company waits" : `${count} people or companies wait`;
65
+ return [
66
+ "## Entity Review Due",
67
+ `👥 ${waiting} for the user's answer before the knowledge store records them — offer \`pal cli knowledge review\`.`,
68
+ ].join("\n");
69
+ }
70
+
71
+ export function rejectReview(id: string): ReviewItem | null {
72
+ return takeFromQueue(id);
73
+ }
74
+
75
+ /** Writes the item to `as` when given, else to a new entity named `name` or as extracted. */
76
+ export function acceptReview(id: string, choice: AcceptChoice = {}): Accepted | null {
77
+ const queued = pendingReviews().find((i) => i.id === id);
78
+ if (!queued) return null;
79
+ const index = loadNameIndex(loadKnownEntities());
80
+ const known = chosenEntity(queued, choice.as, index);
81
+ const result = ingestEntities(
82
+ ingestInput(queued.entity, index, known, choice.name),
83
+ queued.source
84
+ );
85
+ takeFromQueue(id);
86
+ const written = result.people[0] ?? result.companies[0];
87
+ return { item: queued, slug: written.slug, created: written.created };
88
+ }
@@ -580,8 +580,10 @@ async function inferenceViaAntigravitySpawn(
580
580
  * --disable-builtin-mcps → no MCP servers in the child (extra safety)
581
581
  * --no-auto-update → prevent CLI self-update from delaying the spawn
582
582
  * --no-color → clean stdout for capture
583
- * --allow-all-tools → REQUIRED for non-interactive mode (without it,
584
- * copilot prompts for tool-use confirmation)
583
+ * --available-tools=none → Copilot has no "no tools" flag; an allowlist
584
+ * naming an unknown tool hides every tool
585
+ * --deny-tool=… → backstop: deny rules win over any allow rule
586
+ * --silent → keeps the tool-filter notices out of stdout
585
587
  *
586
588
  * Copilot has no --system-prompt flag, so system + user + JSON-schema are
587
589
  * concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
@@ -595,7 +597,11 @@ export function buildCopilotArgs(_opts: InferenceOptions): string[] {
595
597
  "--disable-builtin-mcps",
596
598
  "--no-auto-update",
597
599
  "--no-color",
598
- "--allow-all-tools",
600
+ "--silent",
601
+ "--available-tools=none",
602
+ "--deny-tool=shell",
603
+ "--deny-tool=write",
604
+ "--deny-tool=url",
599
605
  ];
600
606
  }
601
607
 
@@ -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,7 @@ type TokenCaller =
14
14
  | "failure"
15
15
  | "session-name"
16
16
  | "session-intelligence"
17
+ | "entity-extraction"
17
18
  | "relationship"
18
19
  | "self-model"
19
20
  | "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;