portable-agent-layer 0.92.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/assets/schema/pal-settings.schema.json +12 -0
- package/assets/skills/research-entity/SKILL.md +57 -0
- package/assets/templates/pal-settings.json +3 -0
- package/package.json +1 -1
- package/src/cli/infer.ts +6 -0
- package/src/cli/knowledge-research.ts +93 -0
- package/src/cli/knowledge.ts +110 -0
- package/src/hooks/handlers/entity-extraction.ts +92 -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 +356 -0
- package/src/hooks/lib/entity-names.ts +242 -0
- package/src/hooks/lib/entity-research.ts +305 -0
- package/src/hooks/lib/entity-review.ts +101 -0
- package/src/hooks/lib/inference.ts +41 -6
- package/src/hooks/lib/stop.ts +19 -0
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/knowledge/ingest.ts +25 -8
package/README.md
CHANGED
|
@@ -208,7 +208,9 @@ PAL ships with built-in skills that extend your agent's capabilities:
|
|
|
208
208
|
| `presentation` | Build branded slide decks from outlines |
|
|
209
209
|
| `projects` | Look up, resume, register, or manage tracked projects |
|
|
210
210
|
| `reflect` | Diagnose why a PAL behavior didn't trigger |
|
|
211
|
-
| `
|
|
211
|
+
| `research-entity` | Research a company or person on the web: links, registry entry, recent news |
|
|
212
|
+
| `rule-review` | Approve or deny the rules PAL drafted from your corrections |
|
|
213
|
+
| `telos` | Inspect or update goals, beliefs, strategies, narratives |
|
|
212
214
|
| `think` | Structured first-pass reasoning on a problem |
|
|
213
215
|
|
|
214
216
|
---
|
|
@@ -98,6 +98,18 @@
|
|
|
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
|
+
},
|
|
109
|
+
"entityResearch": {
|
|
110
|
+
"type": "boolean",
|
|
111
|
+
"description": "Opt-in, off by default. When extraction creates a company, or a person with a known organization, research it on the web through the active agent (up to three per stop): website, LinkedIn, social accounts, the company registry entry, recent news, each with its source. A profile the model is sure of waits in `pal cli knowledge review`; accepting writes it to the entry. The name and what you said about it reach the agent's web search. Claude Code, Codex and Copilot only. Needs entityExtraction."
|
|
112
|
+
},
|
|
101
113
|
"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
114
|
}
|
|
103
115
|
},
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: research-entity
|
|
3
|
+
license: MIT
|
|
4
|
+
description: "Research one company or person on the web and build a sourced profile: official website, LinkedIn, social accounts, the company registry entry (cégjegyzék for Hungarian companies), and recent news or statements. Use when the user asks to research, look up, profile, or background-check a specific company or person, or to enrich a knowledge-store entry."
|
|
5
|
+
argument-hint: <company or person name> [what you know about them]
|
|
6
|
+
metadata:
|
|
7
|
+
source: portable-agent-layer
|
|
8
|
+
triggers:
|
|
9
|
+
- "research-entity"
|
|
10
|
+
- "research entity"
|
|
11
|
+
- "research this company"
|
|
12
|
+
- "research this person"
|
|
13
|
+
- "look up the company"
|
|
14
|
+
- "company background"
|
|
15
|
+
- "background check"
|
|
16
|
+
- "cégjegyzék"
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
Research the company or person named in $ARGUMENTS and show a sourced profile. The research runs through `pal cli knowledge research`, which picks the active agent's web search and limits it to searching and reading pages. Never research with your own tools instead: the command is what keeps the result in the same shape on every agent.
|
|
20
|
+
|
|
21
|
+
## Workflow
|
|
22
|
+
|
|
23
|
+
1. Work out from $ARGUMENTS and the conversation:
|
|
24
|
+
- the exact name;
|
|
25
|
+
- whether it is a company or a person;
|
|
26
|
+
- for a person, where they work;
|
|
27
|
+
- one sentence of what the user said about them.
|
|
28
|
+
If you cannot tell a company from a person, ask.
|
|
29
|
+
2. Run the research. For a company:
|
|
30
|
+
```bash
|
|
31
|
+
pal cli knowledge research "<name>" --context "<what the user said>"
|
|
32
|
+
```
|
|
33
|
+
For a person, add `--person` and, when known, `--organization "<employer>"`:
|
|
34
|
+
```bash
|
|
35
|
+
pal cli knowledge research "<name>" --person --organization "<employer>" --context "<what the user said>"
|
|
36
|
+
```
|
|
37
|
+
It takes one to several minutes. Add `--queue` when the user wants the profile saved; it then waits in the review queue.
|
|
38
|
+
3. If the command exits 1, tell the user the reason it printed. Usual reasons:
|
|
39
|
+
- the model found only namesakes (`match: unsure`): ask for something that tells them apart, such as an employer, a city or a website, and run it again with that in `--context` or `--organization`;
|
|
40
|
+
- nothing was found (`match: none`);
|
|
41
|
+
- the active agent cannot search the web yet.
|
|
42
|
+
4. If it succeeds, show the profile it printed as it is. Do not add facts it did not print.
|
|
43
|
+
5. If you queued it, tell the user the review command it printed: `pal cli knowledge review accept <id>` writes the profile to the entry, and `pal cli knowledge review reject <id>` drops it.
|
|
44
|
+
|
|
45
|
+
## Output format
|
|
46
|
+
|
|
47
|
+
The profile as printed: summary, links, registry, news and sources. Then one line saying whether it was queued for review, with the accept command.
|
|
48
|
+
|
|
49
|
+
## When to use
|
|
50
|
+
|
|
51
|
+
- The user names a specific company or person and wants to know who they are, or their website, LinkedIn, accounts, registry data or recent news.
|
|
52
|
+
- The user wants an entry in the knowledge store filled in from the web.
|
|
53
|
+
|
|
54
|
+
## Do NOT use
|
|
55
|
+
|
|
56
|
+
- To pull every person and company out of an article, video or paste. Use `entities` for that.
|
|
57
|
+
- For a broad topic, market or question rather than one named entity. Use `deep-research` for that.
|
package/package.json
CHANGED
package/src/cli/infer.ts
CHANGED
|
@@ -18,6 +18,7 @@ interface InferValues {
|
|
|
18
18
|
schema?: string;
|
|
19
19
|
timeout?: string;
|
|
20
20
|
caller?: string;
|
|
21
|
+
web?: boolean;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
type Infer = typeof inference;
|
|
@@ -81,6 +82,7 @@ function requestFrom(values: InferValues, stdin: string): Parameters<Infer>[0] {
|
|
|
81
82
|
tier: tierOf(values.tier),
|
|
82
83
|
timeout: timeoutMs(values.timeout),
|
|
83
84
|
caller: values.caller ?? "cli-infer",
|
|
85
|
+
web: values.web ?? false,
|
|
84
86
|
};
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -137,6 +139,10 @@ export const inferCommand = leaf({
|
|
|
137
139
|
value: "<label>",
|
|
138
140
|
description: "Label for PAL's debug log",
|
|
139
141
|
},
|
|
142
|
+
web: {
|
|
143
|
+
type: "boolean",
|
|
144
|
+
description: "Let the model search and read the web (Claude, Codex, Copilot)",
|
|
145
|
+
},
|
|
140
146
|
},
|
|
141
147
|
run: async ({ values }) => inferFromCli(values, await Bun.stdin.text()),
|
|
142
148
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pal cli knowledge research — research one person or company on the web through
|
|
3
|
+
* the active agent, print the profile, and optionally queue it for review.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { loadKnownEntities, loadNameIndex } from "../hooks/lib/entity-cards";
|
|
7
|
+
import { queueForReview } from "../hooks/lib/entity-extraction";
|
|
8
|
+
import { lookupName } from "../hooks/lib/entity-names";
|
|
9
|
+
import {
|
|
10
|
+
type ResearchDeps,
|
|
11
|
+
type ResearchTarget,
|
|
12
|
+
renderProfile,
|
|
13
|
+
researchEntity,
|
|
14
|
+
researchItem,
|
|
15
|
+
} from "../hooks/lib/entity-research";
|
|
16
|
+
import { inference } from "../hooks/lib/inference";
|
|
17
|
+
import { identity } from "../hooks/lib/settings";
|
|
18
|
+
import { leaf, UsageError } from "../tools/lib/command";
|
|
19
|
+
|
|
20
|
+
interface ResearchValues {
|
|
21
|
+
person?: boolean;
|
|
22
|
+
organization?: string;
|
|
23
|
+
context?: string;
|
|
24
|
+
queue?: boolean;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function storedSlug(name: string, kind: ResearchTarget["kind"]): string {
|
|
29
|
+
const domain = kind === "person" ? "People" : "Companies";
|
|
30
|
+
const { entities, exact } = lookupName(name, loadNameIndex(loadKnownEntities()));
|
|
31
|
+
const matches = entities.filter((e) => e.domain === domain);
|
|
32
|
+
return exact && matches.length === 1 ? matches[0].slug : "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function targetFrom(name: string, values: ResearchValues): ResearchTarget {
|
|
36
|
+
const kind = values.person ? "person" : "company";
|
|
37
|
+
return {
|
|
38
|
+
kind,
|
|
39
|
+
name,
|
|
40
|
+
slug: storedSlug(name, kind),
|
|
41
|
+
organization: values.organization ?? "",
|
|
42
|
+
fact: values.context ?? "",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function researchFromCli(
|
|
47
|
+
name: string | undefined,
|
|
48
|
+
values: ResearchValues,
|
|
49
|
+
deps: ResearchDeps = { principal: identity().principal.name, infer: inference }
|
|
50
|
+
): Promise<number> {
|
|
51
|
+
if (!name?.trim()) throw new UsageError("name the person or company to research");
|
|
52
|
+
const target = targetFrom(name.trim(), values);
|
|
53
|
+
const outcome = await researchEntity(target, deps);
|
|
54
|
+
if (!outcome.found) {
|
|
55
|
+
console.error(`Nothing kept for "${target.name}": ${outcome.reason}`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
console.log(
|
|
59
|
+
values.json
|
|
60
|
+
? JSON.stringify(outcome.profile, null, 2)
|
|
61
|
+
: renderProfile(outcome.profile)
|
|
62
|
+
);
|
|
63
|
+
if (values.queue) {
|
|
64
|
+
const item = researchItem(target, outcome.profile);
|
|
65
|
+
queueForReview([item]);
|
|
66
|
+
console.log(`\nQueued for review: pal cli knowledge review accept ${item.id}`);
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const researchCommand = leaf({
|
|
72
|
+
summary: "Research a company (or --person) on the web: links, registry, news",
|
|
73
|
+
args: "<name>",
|
|
74
|
+
options: {
|
|
75
|
+
person: { type: "boolean", description: "Research a person, not a company" },
|
|
76
|
+
organization: {
|
|
77
|
+
type: "string",
|
|
78
|
+
value: "<name>",
|
|
79
|
+
description: "Where the person works, to tell namesakes apart",
|
|
80
|
+
},
|
|
81
|
+
context: {
|
|
82
|
+
type: "string",
|
|
83
|
+
value: "<text>",
|
|
84
|
+
description: "What you know about them, to tell namesakes apart",
|
|
85
|
+
},
|
|
86
|
+
queue: {
|
|
87
|
+
type: "boolean",
|
|
88
|
+
description: "Also queue the profile for `pal cli knowledge review`",
|
|
89
|
+
},
|
|
90
|
+
json: { type: "boolean", description: "Print the profile as JSON" },
|
|
91
|
+
},
|
|
92
|
+
run: ({ positionals, values }) => researchFromCli(positionals.join(" "), values),
|
|
93
|
+
});
|
package/src/cli/knowledge.ts
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
|
+
import type { ReviewItem } from "../hooks/lib/entity-extraction";
|
|
10
|
+
import { renderProfile } from "../hooks/lib/entity-research";
|
|
11
|
+
import {
|
|
12
|
+
type AcceptChoice,
|
|
13
|
+
acceptReview,
|
|
14
|
+
pendingReviews,
|
|
15
|
+
rejectReview,
|
|
16
|
+
} from "../hooks/lib/entity-review";
|
|
9
17
|
import { toPath } from "../hooks/lib/paths";
|
|
10
18
|
import { buildGraph, resolveSlug, stats, traverse } from "../tools/knowledge/graph";
|
|
11
19
|
import {
|
|
@@ -27,6 +35,7 @@ import {
|
|
|
27
35
|
type Status,
|
|
28
36
|
} from "../tools/knowledge/lib";
|
|
29
37
|
import { group, leaf, runCommand, UsageError } from "../tools/lib/command";
|
|
38
|
+
import { researchCommand } from "./knowledge-research";
|
|
30
39
|
|
|
31
40
|
const VOCABULARY = `Domains: ${DOMAINS.join(", ")}
|
|
32
41
|
Relation types: ${RELATION_TYPES.join(", ")}`;
|
|
@@ -113,6 +122,37 @@ export const knowledgeCommand = group({
|
|
|
113
122
|
},
|
|
114
123
|
run: ({ values }) => cmdIngest(values.source ?? "manual", values.file),
|
|
115
124
|
}),
|
|
125
|
+
research: researchCommand,
|
|
126
|
+
review: group({
|
|
127
|
+
summary: "People and companies the background extractor would not guess",
|
|
128
|
+
fallback: "list",
|
|
129
|
+
commands: {
|
|
130
|
+
list: leaf({ summary: "Show what waits for an answer", run: cmdReviewList }),
|
|
131
|
+
accept: leaf({
|
|
132
|
+
summary: "Write an item, to an existing entry (--as) or a new one",
|
|
133
|
+
args: "<id>",
|
|
134
|
+
options: {
|
|
135
|
+
as: {
|
|
136
|
+
type: "string",
|
|
137
|
+
value: "<slug>",
|
|
138
|
+
description: "Existing entry to update",
|
|
139
|
+
},
|
|
140
|
+
name: {
|
|
141
|
+
type: "string",
|
|
142
|
+
value: "<name>",
|
|
143
|
+
description: "Name for a new entry (default: as extracted)",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
run: ({ positionals, values }) =>
|
|
147
|
+
cmdReviewAccept(positionals[0], { as: values.as, name: values.name }),
|
|
148
|
+
}),
|
|
149
|
+
reject: leaf({
|
|
150
|
+
summary: "Drop an item without writing it",
|
|
151
|
+
args: "<id>",
|
|
152
|
+
run: ({ positionals }) => cmdReviewReject(positionals[0]),
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
116
156
|
},
|
|
117
157
|
details: VOCABULARY,
|
|
118
158
|
});
|
|
@@ -578,3 +618,73 @@ async function cmdIngest(sourceId: string, file: string | undefined): Promise<nu
|
|
|
578
618
|
console.log(JSON.stringify(summary, null, 2));
|
|
579
619
|
return 0;
|
|
580
620
|
}
|
|
621
|
+
|
|
622
|
+
// ── review ─────────────────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
function reviewReason(reason: ReviewItem["reason"]): string {
|
|
625
|
+
const reasons: Record<ReviewItem["reason"], string> = {
|
|
626
|
+
ambiguous: "could be more than one known entry",
|
|
627
|
+
"unknown-existing": "named an entry the store does not have",
|
|
628
|
+
"first-name-only": "first name only",
|
|
629
|
+
"web-profile": "found on the web",
|
|
630
|
+
};
|
|
631
|
+
return reasons[reason];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function reviewDetails(item: ReviewItem): string[] {
|
|
635
|
+
if (item.profile)
|
|
636
|
+
return [
|
|
637
|
+
item.entity.existing ? `for ${item.entity.existing}` : "as a new entry",
|
|
638
|
+
...renderProfile(item.profile).split("\n"),
|
|
639
|
+
];
|
|
640
|
+
const candidates =
|
|
641
|
+
item.candidates.length > 0 ? [`could be: ${item.candidates.join(", ")}`] : [];
|
|
642
|
+
return [item.entity.fact, ...candidates, `from ${item.source}`];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function reviewLines(item: ReviewItem): string[] {
|
|
646
|
+
const { kind, name } = item.entity;
|
|
647
|
+
return [
|
|
648
|
+
` ${item.id} ${kind} "${name}" — ${reviewReason(item.reason)}`,
|
|
649
|
+
...reviewDetails(item)
|
|
650
|
+
.filter(Boolean)
|
|
651
|
+
.map((line) => ` ${line}`),
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function cmdReviewList(): number {
|
|
656
|
+
const items = pendingReviews();
|
|
657
|
+
if (items.length === 0) {
|
|
658
|
+
console.log("Nothing waits for review.");
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
console.log(`\n${items.length} waiting for review\n`);
|
|
662
|
+
for (const item of items) console.log(reviewLines(item).join("\n"));
|
|
663
|
+
console.log(
|
|
664
|
+
"\nAccept: pal cli knowledge review accept <id> [--as <slug>] [--name <name>]\nReject: pal cli knowledge review reject <id>\n"
|
|
665
|
+
);
|
|
666
|
+
return 0;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function acceptOrExplain(id: string, choice: AcceptChoice) {
|
|
670
|
+
try {
|
|
671
|
+
return acceptReview(id, choice);
|
|
672
|
+
} catch (err) {
|
|
673
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function cmdReviewAccept(id: string, choice: AcceptChoice): number {
|
|
678
|
+
const accepted = acceptOrExplain(id, choice);
|
|
679
|
+
if (!accepted) throw new UsageError(`no review item "${id}"`);
|
|
680
|
+
const verb = accepted.created ? "Created" : "Updated";
|
|
681
|
+
console.log(`${verb} ${accepted.slug} from "${accepted.item.entity.name}".`);
|
|
682
|
+
return 0;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function cmdReviewReject(id: string): number {
|
|
686
|
+
const rejected = rejectReview(id);
|
|
687
|
+
if (!rejected) throw new UsageError(`no review item "${id}"`);
|
|
688
|
+
console.log(`Dropped "${rejected.entity.name}".`);
|
|
689
|
+
return 0;
|
|
690
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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, and research the new ones on
|
|
4
|
+
* the web for review. Runs detached; one extraction call, only when the new text
|
|
5
|
+
* names someone, and one web research call per new entry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ingestEntities } from "../../tools/knowledge/ingest";
|
|
9
|
+
import { excludedNames, loadKnownEntities, loadNameIndex } from "../lib/entity-cards";
|
|
10
|
+
import {
|
|
11
|
+
extractionSchema,
|
|
12
|
+
extractionSystem,
|
|
13
|
+
extractionUser,
|
|
14
|
+
markSeen,
|
|
15
|
+
parseExtraction,
|
|
16
|
+
planExtraction,
|
|
17
|
+
queueForReview,
|
|
18
|
+
seenCount,
|
|
19
|
+
unseenUserText,
|
|
20
|
+
worthExtracting,
|
|
21
|
+
} from "../lib/entity-extraction";
|
|
22
|
+
import { newResearchTargets, researchNewEntities } from "../lib/entity-research";
|
|
23
|
+
import { canInfer, inference } from "../lib/inference";
|
|
24
|
+
import { logDebug, logError } from "../lib/log";
|
|
25
|
+
import { identity, isEnabled, isOptedIn } from "../lib/settings";
|
|
26
|
+
import { logTokenUsage } from "../lib/token-usage";
|
|
27
|
+
import { parseMessages } from "../lib/transcript";
|
|
28
|
+
|
|
29
|
+
function sourceId(sessionId: string, count: number): string {
|
|
30
|
+
return `chat ${new Date().toISOString().slice(0, 10)} ${sessionId.slice(0, 8)} #${count}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @lintignore exercised directly by test/entity-extraction.test.ts */
|
|
34
|
+
export async function extractSessionEntities(
|
|
35
|
+
transcript: string,
|
|
36
|
+
sessionId?: string
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
if (!sessionId || !isEnabled("entityExtraction")) return;
|
|
39
|
+
const seen = seenCount(sessionId);
|
|
40
|
+
const { text, count } = unseenUserText(parseMessages(transcript), seen);
|
|
41
|
+
if (count <= seen) return;
|
|
42
|
+
|
|
43
|
+
const index = loadNameIndex(loadKnownEntities());
|
|
44
|
+
const excluded = excludedNames();
|
|
45
|
+
markSeen(sessionId, count);
|
|
46
|
+
if (!worthExtracting(text, index, excluded) || !canInfer()) return;
|
|
47
|
+
|
|
48
|
+
const id = identity();
|
|
49
|
+
const result = await inference({
|
|
50
|
+
system: extractionSystem(id.principal.name, id.ai.name),
|
|
51
|
+
user: extractionUser(text, index),
|
|
52
|
+
tier: "medium",
|
|
53
|
+
maxTokens: 900,
|
|
54
|
+
timeout: 90000,
|
|
55
|
+
jsonSchema: extractionSchema(),
|
|
56
|
+
caller: "entity-extraction",
|
|
57
|
+
sessionId,
|
|
58
|
+
});
|
|
59
|
+
if (result.usage) logTokenUsage("entity-extraction", result.usage);
|
|
60
|
+
if (!result.success || !result.output) return;
|
|
61
|
+
|
|
62
|
+
const source = sourceId(sessionId, count);
|
|
63
|
+
const plan = planExtraction(parseExtraction(result.output), index, excluded, source);
|
|
64
|
+
const written = ingestEntities(plan.ingest, source);
|
|
65
|
+
queueForReview(plan.review);
|
|
66
|
+
logDebug(
|
|
67
|
+
"entity-extraction",
|
|
68
|
+
`${written.people.length} people, ${written.companies.length} companies, ${plan.review.length} for review`
|
|
69
|
+
);
|
|
70
|
+
if (isOptedIn("entityResearch"))
|
|
71
|
+
await researchNewEntities(newResearchTargets(plan.ingest, written), {
|
|
72
|
+
principal: id.principal.name,
|
|
73
|
+
infer: inference,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (process.argv[2] === "--run") {
|
|
78
|
+
const sid = process.argv[3];
|
|
79
|
+
const transcriptPath = process.argv[4];
|
|
80
|
+
if (transcriptPath) {
|
|
81
|
+
const { readFile, unlink } = await import("node:fs/promises");
|
|
82
|
+
try {
|
|
83
|
+
const transcript = await readFile(transcriptPath, "utf-8");
|
|
84
|
+
await extractSessionEntities(transcript, sid === "" ? undefined : sid);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
logError("entity-extraction:run", err);
|
|
87
|
+
} finally {
|
|
88
|
+
await unlink(transcriptPath).catch(() => {});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
process.exit(0);
|
|
92
|
+
}
|
|
@@ -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
|
+
}
|