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