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.
- package/assets/schema/pal-settings.schema.json +8 -0
- package/assets/templates/pal-settings.json +2 -0
- package/package.json +1 -1
- package/src/cli/knowledge.ts +97 -0
- package/src/hooks/handlers/entity-extraction.ts +85 -0
- package/src/hooks/handlers/inject-retrieval.ts +4 -0
- package/src/hooks/lib/daily-nudge.ts +29 -6
- package/src/hooks/lib/entity-cards.ts +112 -0
- package/src/hooks/lib/entity-extraction.ts +334 -0
- package/src/hooks/lib/entity-names.ts +242 -0
- package/src/hooks/lib/entity-review.ts +88 -0
- package/src/hooks/lib/inference.ts +9 -3
- package/src/hooks/lib/stop.ts +19 -0
- package/src/hooks/lib/token-usage.ts +1 -0
- package/src/tools/knowledge/ingest.ts +25 -8
|
@@ -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
|
},
|
package/package.json
CHANGED
package/src/cli/knowledge.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
* --
|
|
584
|
-
*
|
|
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
|
-
"--
|
|
600
|
+
"--silent",
|
|
601
|
+
"--available-tools=none",
|
|
602
|
+
"--deny-tool=shell",
|
|
603
|
+
"--deny-tool=write",
|
|
604
|
+
"--deny-tool=url",
|
|
599
605
|
];
|
|
600
606
|
}
|
|
601
607
|
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -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.
|
|
@@ -29,7 +29,14 @@ import {
|
|
|
29
29
|
|
|
30
30
|
// --- Public input shape -----------------------------------------------------
|
|
31
31
|
|
|
32
|
-
|
|
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;
|