portable-agent-layer 0.109.0 → 0.111.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 +1 -1
- package/assets/skills/research-entity/SKILL.md +6 -0
- package/assets/skills/scientific-research/SKILL.md +58 -12
- package/package.json +1 -1
- package/src/cli/infer.ts +1 -1
- package/src/cli/knowledge-research.ts +6 -1
- package/src/hooks/handlers/entity-extraction.ts +2 -0
- package/src/hooks/lib/company-register-router.ts +24 -0
- package/src/hooks/lib/company-register.ts +25 -0
- package/src/hooks/lib/entity-extraction.ts +1 -0
- package/src/hooks/lib/entity-research.ts +88 -3
- package/src/hooks/lib/hungarian-registry.ts +194 -0
- package/src/hooks/lib/inference.ts +43 -18
- package/src/hooks/lib/models.ts +70 -26
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ pal cli status # check your setup
|
|
|
90
90
|
| `pal cli patterns` | How you work across projects, read from the event log |
|
|
91
91
|
| `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
|
|
92
92
|
| `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
|
|
93
|
-
| `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium
|
|
93
|
+
| `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium\|large\|flagship` (on Claude: Haiku, Sonnet, Opus, Fable; on Codex: Luna, Sol, Sol at high effort, Astra; on Antigravity: Flash at low, medium and high effort, and no flagship; an agent without the tier asked refuses it), `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
|
|
94
94
|
| `pal cli actor [label <name>]` | Show or rename the actor — who caused a record. Travels with an export, so a shared memory can tell two people apart |
|
|
95
95
|
| `pal cli machine [label <name>]` | Show or rename this install — where a record was written. Never leaves the machine |
|
|
96
96
|
| `pal cli telos` | Which TELOS topics are answered, in interview order, and which one comes next |
|
|
@@ -23,6 +23,7 @@ Research the company or person named in $ARGUMENTS and show a sourced profile. T
|
|
|
23
23
|
1. Work out from $ARGUMENTS and the conversation:
|
|
24
24
|
- the exact name;
|
|
25
25
|
- whether it is a company or a person;
|
|
26
|
+
- for a company, its registered name with the legal form, not a brand or short name. The legal form is what routes it to its country's company register;
|
|
26
27
|
- for a person, where they work;
|
|
27
28
|
- one sentence of what the user said about them.
|
|
28
29
|
If you cannot tell a company from a person, ask.
|
|
@@ -34,6 +35,11 @@ Research the company or person named in $ARGUMENTS and show a sourced profile. T
|
|
|
34
35
|
```bash
|
|
35
36
|
pal cli knowledge research "<name>" --person --organization "<employer>" --context "<what the user said>"
|
|
36
37
|
```
|
|
38
|
+
When the legal form places the company in a country PAL has a register for, the command first reads that register directly, because web search does not index it. An exact registered-name match fills in the registration number, tax number, seat, main activity, managing directors and status, and the profile is kept even when the web finds nothing else. A brand name, a similar name, or a company from a country without a register goes through web search alone.
|
|
39
|
+
|
|
40
|
+
| Country | Legal forms | Register read |
|
|
41
|
+
| ------- | ----------- | ------------- |
|
|
42
|
+
| Hungary | Kft., Zrt., Nyrt., Bt., Kkt., or spelled out | ceginfo.hu for the registration number, nemzeticegtar.hu for the record |
|
|
37
43
|
It takes one to several minutes. Add `--queue` when the user wants the profile saved; it then waits in the review queue.
|
|
38
44
|
3. If the command exits 1, tell the user the reason it printed. Usual reasons:
|
|
39
45
|
- 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`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: scientific-research
|
|
3
3
|
license: MIT
|
|
4
|
-
description: "Answers scientific questions from trustworthy primary sources only (PubMed-indexed peer-reviewed literature, clinical guidelines from ERC, ESC, AHA, CDC and specialty societies, NEJM, The Lancet, JAMA, BMJ, UpToDate when accessible), with every claim cited to a source that was actually read, graded by evidence level and year, and cross-checked by a verifier agent. Use for EVERY health, medical, nutrition, drug, supplement, symptom, disease, treatment, diagnosis, exercise-physiology or other empirical scientific question, including 'is it safe to', 'does X work', 'what does the evidence say', 'side effects of', 'how much should I take' and 'what do the guidelines recommend'. Never answer such questions from memory; route them here."
|
|
4
|
+
description: "Answers scientific questions from trustworthy primary sources only (PubMed-indexed peer-reviewed literature, clinical guidelines from ERC, ESC, AHA, CDC and specialty societies, NEJM, The Lancet, JAMA, BMJ, UpToDate when accessible), with every claim cited to a source that was actually read, graded by evidence level and year, its funding and any vendor ties named, and cross-checked by a verifier agent. Excludes preprints and MDPI journals and prefers SJR Q1 journals. Use for EVERY health, medical, nutrition, drug, supplement, symptom, disease, treatment, diagnosis, exercise-physiology or other empirical scientific question, including 'is it safe to', 'does X work', 'what does the evidence say', 'side effects of', 'how much should I take' and 'what do the guidelines recommend'. Never answer such questions from memory; route them here."
|
|
5
5
|
argument-hint: <scientific or health question>
|
|
6
6
|
metadata:
|
|
7
7
|
source: portable-agent-layer
|
|
@@ -57,9 +57,26 @@ specialty society (WHO, NICE, EMA, FDA and their peers). Label each such
|
|
|
57
57
|
source in the answer as "outside the core list".
|
|
58
58
|
|
|
59
59
|
Not sources, under any circumstances: blogs, news sites, forums, supplement or
|
|
60
|
-
vendor sites, encyclopedias, press releases, lecture slides, model memory.
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
vendor sites, encyclopedias, press releases, lecture slides, model memory.
|
|
61
|
+
|
|
62
|
+
Excluded even when they look peer-reviewed:
|
|
63
|
+
|
|
64
|
+
- **Preprints and repositories**: bioRxiv, medRxiv, PsyArXiv, arXiv, SSRN, Research
|
|
65
|
+
Square, OSF Preprints, Preprints.org and any other server of unreviewed
|
|
66
|
+
manuscripts. Do not search them. PubMed and Europe PMC also index preprints, so
|
|
67
|
+
add `NOT preprint[pt]` to PubMed queries and drop any record whose publication type
|
|
68
|
+
is Preprint.
|
|
69
|
+
- **MDPI journals** (Nutrients, Medicina, IJMS, Healthcare and the rest), identified
|
|
70
|
+
by publisher, not by title: a DOI starting with `10.3390` or a page on `mdpi.com`.
|
|
71
|
+
Add `-site:mdpi.com` to web searches.
|
|
72
|
+
|
|
73
|
+
### Journal rank
|
|
74
|
+
|
|
75
|
+
Prefer journals ranked Q1 by SCImago (SJR, `scimagojr.com`) in the subject category
|
|
76
|
+
that fits the question. Rank breaks ties within one evidence level and never
|
|
77
|
+
outranks study design: a Q2 RCT beats a Q1 observational study. Cite a journal below
|
|
78
|
+
Q1 only when no Q1 source covers the claim, and label it with its quartile, for
|
|
79
|
+
example "SJR Q2". Guidelines and official bodies are not ranked.
|
|
63
80
|
|
|
64
81
|
## Citation and evidence rules
|
|
65
82
|
|
|
@@ -74,12 +91,23 @@ For every claim record and later print:
|
|
|
74
91
|
review.
|
|
75
92
|
- **Year** of publication or of the guideline edition, so stale evidence is visible.
|
|
76
93
|
When a newer edition of a guideline exists, cite the newer one.
|
|
94
|
+
- **Journal quartile**: the SJR quartile of the journal, for every journal article.
|
|
95
|
+
- **Funding and conflicts of interest**, read from the source's Funding, Acknowledgements
|
|
96
|
+
and Competing Interests sections, never inferred. Name the funders, then add one label:
|
|
97
|
+
- **vendor-funded**: a funder sells the product or intervention studied, or is a
|
|
98
|
+
trade group of its sellers (manufacturer, supplement brand, food or drug industry
|
|
99
|
+
association).
|
|
100
|
+
- **vendor-linked authors**: the funding is independent, but an author is employed,
|
|
101
|
+
paid or holds shares in such a company.
|
|
102
|
+
- **funding not stated**: the opened source has no funding statement.
|
|
103
|
+
- **funding not accessible**: the statement sits behind a paywall that did not open.
|
|
77
104
|
|
|
78
105
|
Apply these tests before keeping a claim:
|
|
79
106
|
|
|
80
107
|
1. Does the opened source state this claim, in this population, at this dose or in
|
|
81
108
|
this setting? Paraphrase the source, do not extrapolate from it.
|
|
82
|
-
2. Does the source belong to the allowed set
|
|
109
|
+
2. Does the source belong to the allowed set, and is it neither a preprint nor an MDPI
|
|
110
|
+
journal? If not, discard the claim.
|
|
83
111
|
3. Is the number or direction of effect quoted exactly as the source gives it
|
|
84
112
|
(effect size, confidence interval, absolute versus relative risk)?
|
|
85
113
|
|
|
@@ -144,12 +172,20 @@ Rules:
|
|
|
144
172
|
- Search only your lane's domains (use site: restricted queries). Open each
|
|
145
173
|
candidate and read the relevant section before using it.
|
|
146
174
|
- Prefer the newest guideline edition, then systematic reviews and meta-analyses,
|
|
147
|
-
then RCTs, then observational work.
|
|
175
|
+
then RCTs, then observational work. Within one level, prefer journals ranked SJR
|
|
176
|
+
Q1 in the question's subject category.
|
|
177
|
+
- Never search or cite preprint servers (bioRxiv, medRxiv, PsyArXiv, arXiv, SSRN,
|
|
178
|
+
Research Square, OSF Preprints, Preprints.org). Add NOT preprint[pt] to PubMed
|
|
179
|
+
queries and drop any record typed Preprint.
|
|
180
|
+
- Never cite an MDPI journal: a DOI starting with 10.3390 or a page on mdpi.com. Add
|
|
181
|
+
-site:mdpi.com to web searches.
|
|
148
182
|
- For each claim return: the claim in one sentence, quoted as the source states it;
|
|
149
183
|
population and setting; effect size with confidence interval when given; evidence
|
|
150
184
|
level (guideline recommendation with class/level or GRADE, meta-analysis, RCT,
|
|
151
185
|
observational, expert opinion); year; full citation (authors or body, title,
|
|
152
|
-
journal or body, year, PMID or DOI or URL)
|
|
186
|
+
journal or body, year, PMID or DOI or URL); the journal's SJR quartile; funding:
|
|
187
|
+
the funders named in the source and one label, vendor-funded, vendor-linked
|
|
188
|
+
authors, funding not stated or funding not accessible.
|
|
153
189
|
- Lane D only: if UpToDate does not open or shows a paywall, return the single line
|
|
154
190
|
"UpToDate inaccessible" and nothing else.
|
|
155
191
|
- Do not fill gaps from memory. If your lane has nothing on a sub-question, say
|
|
@@ -180,8 +216,15 @@ For every claim:
|
|
|
180
216
|
numbers. Mark "confirmed" or "could not verify: <what differs>".
|
|
181
217
|
3. Confirm the source is in the allowed set: PubMed-indexed peer-reviewed journal,
|
|
182
218
|
ERC/ESC/AHA/CDC or another official health body or specialty society, NEJM, The
|
|
183
|
-
Lancet, JAMA, BMJ, or UpToDate.
|
|
184
|
-
allowed
|
|
219
|
+
Lancet, JAMA, BMJ, or UpToDate. A preprint, a record typed Preprint in PubMed, or
|
|
220
|
+
an MDPI journal (DOI starting with 10.3390) is never allowed. Otherwise mark
|
|
221
|
+
"could not verify: source not allowed".
|
|
222
|
+
4. Check the journal's SJR quartile on scimagojr.com and correct it when the lane
|
|
223
|
+
got it wrong.
|
|
224
|
+
5. Open the source's Funding and Competing Interests sections and confirm the
|
|
225
|
+
funders and the label (vendor-funded, vendor-linked authors, funding not stated,
|
|
226
|
+
funding not accessible). Correct the label when it differs, and flag every
|
|
227
|
+
vendor-funded or vendor-linked study.
|
|
185
228
|
|
|
186
229
|
Then return three sections:
|
|
187
230
|
- Confirmed matches: claims supported by two or more independent sources, with
|
|
@@ -203,8 +246,10 @@ Then:
|
|
|
203
246
|
1. **Bottom line** — two to four sentences answering the question directly, with
|
|
204
247
|
citation numbers.
|
|
205
248
|
2. **Key findings** — one bullet per claim: the claim, inline citation number(s)
|
|
206
|
-
`[n]`, evidence level, year.
|
|
207
|
-
|
|
249
|
+
`[n]`, evidence level, year, funding label. Write **vendor-funded** or
|
|
250
|
+
**vendor-linked authors** in bold and name the company. Group by sub-question
|
|
251
|
+
when there are several. Mark any source outside the core list and any journal
|
|
252
|
+
below SJR Q1 with its quartile.
|
|
208
253
|
3. **Agreement and contradictions** — the verifier's confirmed matches and
|
|
209
254
|
contradictions, each contradiction with who says what and the likely reason.
|
|
210
255
|
4. **What is uncertain / not found** — sub-questions with no trustworthy evidence
|
|
@@ -214,7 +259,8 @@ Then:
|
|
|
214
259
|
5. **Method** — one line: parallel subagents or sequential lanes; UpToDate read or
|
|
215
260
|
inaccessible; number of sources opened.
|
|
216
261
|
6. **References** — numbered list matching the inline numbers: authors or body,
|
|
217
|
-
title, journal or body, year, PMID or DOI or URL
|
|
262
|
+
title, journal or body, year, PMID or DOI or URL, SJR quartile, and funding (the
|
|
263
|
+
funders and the label).
|
|
218
264
|
|
|
219
265
|
For a question about the user's own health, add exactly one plain sentence after the
|
|
220
266
|
Bottom line: this is a summary of published evidence, not individual medical advice,
|
package/package.json
CHANGED
package/src/cli/infer.ts
CHANGED
|
@@ -136,7 +136,7 @@ export const inferCommand = leaf({
|
|
|
136
136
|
tier: {
|
|
137
137
|
type: "string",
|
|
138
138
|
value: "<tier>",
|
|
139
|
-
description: `Model size: ${INFERENCE_TIERS.join("
|
|
139
|
+
description: `Model size: ${INFERENCE_TIERS.join(", ")} (default small)`,
|
|
140
140
|
},
|
|
141
141
|
system: { type: "string", value: "<file>", description: "System prompt file" },
|
|
142
142
|
schema: {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* the active agent, print the profile, and optionally queue it for review.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { lookupCompanyRegister } from "../hooks/lib/company-register-router";
|
|
6
7
|
import { loadKnownEntities, loadNameIndex } from "../hooks/lib/entity-cards";
|
|
7
8
|
import { queueForReview } from "../hooks/lib/entity-extraction";
|
|
8
9
|
import { lookupName } from "../hooks/lib/entity-names";
|
|
@@ -46,7 +47,11 @@ function targetFrom(name: string, values: ResearchValues): ResearchTarget {
|
|
|
46
47
|
export async function researchFromCli(
|
|
47
48
|
name: string | undefined,
|
|
48
49
|
values: ResearchValues,
|
|
49
|
-
deps: ResearchDeps = {
|
|
50
|
+
deps: ResearchDeps = {
|
|
51
|
+
principal: identity().principal.name,
|
|
52
|
+
infer: inference,
|
|
53
|
+
registry: lookupCompanyRegister,
|
|
54
|
+
}
|
|
50
55
|
): Promise<number> {
|
|
51
56
|
if (!name?.trim()) throw new UsageError("name the person or company to research");
|
|
52
57
|
const target = targetFrom(name.trim(), values);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { ingestEntities } from "../../tools/knowledge/ingest";
|
|
9
|
+
import { lookupCompanyRegister } from "../lib/company-register-router";
|
|
9
10
|
import { excludedNames, loadKnownEntities, loadNameIndex } from "../lib/entity-cards";
|
|
10
11
|
import {
|
|
11
12
|
extractionSchema,
|
|
@@ -69,6 +70,7 @@ export async function extractSessionEntities(
|
|
|
69
70
|
await researchNewEntities(newResearchTargets(plan.ingest, written), {
|
|
70
71
|
principal: id.principal.name,
|
|
71
72
|
infer: inference,
|
|
73
|
+
registry: lookupCompanyRegister,
|
|
72
74
|
});
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official company registers, routed by country. A company is looked up only in the
|
|
3
|
+
* register of the country its name places it in; any other name goes to web search
|
|
4
|
+
* alone. To add a country, implement CompanyRegister in its own module and add it to
|
|
5
|
+
* REGISTERS.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CompanyRegister, Fetch, RegisterEntry } from "./company-register";
|
|
9
|
+
import { hungarianRegister } from "./hungarian-registry";
|
|
10
|
+
|
|
11
|
+
const REGISTERS: CompanyRegister[] = [hungarianRegister];
|
|
12
|
+
|
|
13
|
+
/** Null when no register recognizes the name, and when its register has no exact match. */
|
|
14
|
+
export async function lookupCompanyRegister(
|
|
15
|
+
name: string,
|
|
16
|
+
fetchImpl: Fetch = fetch,
|
|
17
|
+
registers: CompanyRegister[] = REGISTERS
|
|
18
|
+
): Promise<RegisterEntry | null> {
|
|
19
|
+
const wanted = name.trim();
|
|
20
|
+
const register = registers.find((r) => r.recognizes(wanted));
|
|
21
|
+
if (!register) return null;
|
|
22
|
+
const company = await register.lookup(wanted, fetchImpl);
|
|
23
|
+
return company && { ...company, country: register.country };
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** What a country's company register implements to join the routing table in company-register-router.ts. */
|
|
2
|
+
|
|
3
|
+
export interface RegisteredCompany {
|
|
4
|
+
name: string;
|
|
5
|
+
fullName: string;
|
|
6
|
+
number: string;
|
|
7
|
+
taxNumber: string;
|
|
8
|
+
seat: string;
|
|
9
|
+
status: string;
|
|
10
|
+
activity: string;
|
|
11
|
+
founded: string;
|
|
12
|
+
managers: string[];
|
|
13
|
+
url: string;
|
|
14
|
+
sources: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type RegisterEntry = RegisteredCompany & { country: string };
|
|
18
|
+
|
|
19
|
+
export type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
20
|
+
|
|
21
|
+
export interface CompanyRegister {
|
|
22
|
+
country: string;
|
|
23
|
+
recognizes: (name: string) => boolean;
|
|
24
|
+
lookup: (name: string, fetchImpl: Fetch) => Promise<RegisteredCompany | null>;
|
|
25
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { IngestInput, IngestResult } from "../../tools/knowledge/ingest";
|
|
8
|
+
import type { RegisterEntry } from "./company-register";
|
|
8
9
|
import {
|
|
9
10
|
closedObject,
|
|
10
11
|
type ExtractedEntity,
|
|
@@ -27,6 +28,7 @@ export interface ResearchDeps {
|
|
|
27
28
|
principal: string;
|
|
28
29
|
infer: typeof inference;
|
|
29
30
|
today?: string;
|
|
31
|
+
registry?: (name: string) => Promise<RegisterEntry | null>;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const MAX_TARGETS = 3;
|
|
@@ -83,6 +85,7 @@ export function researchSchema() {
|
|
|
83
85
|
taxNumber: text,
|
|
84
86
|
seat: text,
|
|
85
87
|
status: text,
|
|
88
|
+
activity: text,
|
|
86
89
|
managers: list(text),
|
|
87
90
|
url: text,
|
|
88
91
|
}),
|
|
@@ -96,7 +99,7 @@ const CHECKLIST = {
|
|
|
96
99
|
"- website: the official website.",
|
|
97
100
|
"- linkedin: the LinkedIn company page.",
|
|
98
101
|
"- socials: other official accounts (X, Facebook, Instagram, YouTube, GitHub).",
|
|
99
|
-
"- 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.",
|
|
102
|
+
"- registry: its entry in the official company register of its country: registered name, registration number, tax number, registered seat, status (active, in liquidation…), main activity, 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.",
|
|
100
103
|
"- news: up to five news items or public statements from the last 12 months, newest first.",
|
|
101
104
|
"- summary: two or three sentences on what the company does, its size and where it is based.",
|
|
102
105
|
],
|
|
@@ -124,13 +127,30 @@ export function researchSystem(principal: string, kind: ResearchTarget["kind"]):
|
|
|
124
127
|
].join("\n");
|
|
125
128
|
}
|
|
126
129
|
|
|
127
|
-
function
|
|
130
|
+
function registryFacts(entry: RegisterEntry): string {
|
|
131
|
+
return [
|
|
132
|
+
"The company register, already read: use it to tell this company from namesakes and do not search the register again.",
|
|
133
|
+
`registered name: ${entry.fullName || entry.name}`,
|
|
134
|
+
`registration number: ${entry.number}`,
|
|
135
|
+
entry.seat && `seat: ${entry.seat}`,
|
|
136
|
+
entry.activity && `main activity: ${entry.activity}`,
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function researchUser(
|
|
143
|
+
target: ResearchTarget,
|
|
144
|
+
today: string,
|
|
145
|
+
registry: RegisterEntry | null
|
|
146
|
+
): string {
|
|
128
147
|
return [
|
|
129
148
|
`Today is ${today}.`,
|
|
130
149
|
`${target.kind}: ${target.name}`,
|
|
131
150
|
target.organization && `organization: ${target.organization}`,
|
|
132
151
|
target.fact &&
|
|
133
152
|
`what ${target.kind === "person" ? "was said about them" : "was said about it"}: ${target.fact}`,
|
|
153
|
+
registry && registryFacts(registry),
|
|
134
154
|
]
|
|
135
155
|
.filter(Boolean)
|
|
136
156
|
.join("\n");
|
|
@@ -166,13 +186,76 @@ export type ResearchOutcome =
|
|
|
166
186
|
| { found: true; profile: ResearchProfile }
|
|
167
187
|
| { found: false; reason: string };
|
|
168
188
|
|
|
189
|
+
async function registryEntry(
|
|
190
|
+
target: ResearchTarget,
|
|
191
|
+
deps: ResearchDeps
|
|
192
|
+
): Promise<RegisterEntry | null> {
|
|
193
|
+
if (target.kind !== "company" || !deps.registry) return null;
|
|
194
|
+
try {
|
|
195
|
+
return await deps.registry(target.name);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
logError(`entity-research:registry:${target.name}`, err);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function registryOnlyProfile(entry: RegisterEntry): ResearchProfile {
|
|
203
|
+
const founded = entry.founded ? `, founded ${entry.founded}` : "";
|
|
204
|
+
const what = entry.activity || `company registered in ${entry.country}`;
|
|
205
|
+
return {
|
|
206
|
+
match: "sure",
|
|
207
|
+
summary: `${entry.name}: ${what}. Seat: ${entry.seat}${founded}.`,
|
|
208
|
+
role: "",
|
|
209
|
+
organization: "",
|
|
210
|
+
website: "",
|
|
211
|
+
linkedin: "",
|
|
212
|
+
socials: [],
|
|
213
|
+
registry: registryOf(entry),
|
|
214
|
+
news: [],
|
|
215
|
+
sources: [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function registryOf(entry: RegisterEntry): ResearchProfile["registry"] {
|
|
220
|
+
return {
|
|
221
|
+
name: entry.fullName || entry.name,
|
|
222
|
+
number: entry.number,
|
|
223
|
+
taxNumber: entry.taxNumber,
|
|
224
|
+
seat: entry.seat,
|
|
225
|
+
status: entry.status,
|
|
226
|
+
activity: entry.activity,
|
|
227
|
+
managers: entry.managers,
|
|
228
|
+
url: entry.url,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** What the register says replaces what the model read, and an exact register match is
|
|
233
|
+
* enough to keep the profile even when the web turned up nothing. */
|
|
234
|
+
function withRegistry(
|
|
235
|
+
profile: ResearchProfile | null,
|
|
236
|
+
entry: RegisterEntry
|
|
237
|
+
): ResearchProfile {
|
|
238
|
+
const base =
|
|
239
|
+
profile?.match === "sure" ? withWebUrlsOnly(profile) : registryOnlyProfile(entry);
|
|
240
|
+
return {
|
|
241
|
+
...base,
|
|
242
|
+
registry: registryOf(entry),
|
|
243
|
+
sources: [...new Set([...entry.sources, ...base.sources])],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
169
247
|
export async function researchEntity(
|
|
170
248
|
target: ResearchTarget,
|
|
171
249
|
deps: ResearchDeps
|
|
172
250
|
): Promise<ResearchOutcome> {
|
|
251
|
+
const registry = await registryEntry(target, deps);
|
|
173
252
|
const result = await deps.infer({
|
|
174
253
|
system: researchSystem(deps.principal, target.kind),
|
|
175
|
-
user: researchUser(
|
|
254
|
+
user: researchUser(
|
|
255
|
+
target,
|
|
256
|
+
deps.today ?? new Date().toISOString().slice(0, 10),
|
|
257
|
+
registry
|
|
258
|
+
),
|
|
176
259
|
tier: "medium",
|
|
177
260
|
timeout: RESEARCH_TIMEOUT_MS,
|
|
178
261
|
jsonSchema: researchSchema(),
|
|
@@ -180,6 +263,7 @@ export async function researchEntity(
|
|
|
180
263
|
web: true,
|
|
181
264
|
});
|
|
182
265
|
const profile = result.success ? parseProfile(result.output) : null;
|
|
266
|
+
if (registry) return { found: true, profile: withRegistry(profile, registry) };
|
|
183
267
|
if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
|
|
184
268
|
if (profile.match !== "sure")
|
|
185
269
|
return { found: false, reason: `match: ${profile.match}. ${profile.summary}`.trim() };
|
|
@@ -243,6 +327,7 @@ function registryLines(r: ResearchProfile["registry"]): string[] {
|
|
|
243
327
|
["Tax number", r.taxNumber],
|
|
244
328
|
["Seat", r.seat],
|
|
245
329
|
["Status", r.status],
|
|
330
|
+
["Main activity", r.activity],
|
|
246
331
|
["Managing directors", r.managers.join(", ")],
|
|
247
332
|
["Registry entry", r.url],
|
|
248
333
|
];
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Hungarian company register, read directly. Web search does not index it, so a
|
|
3
|
+
* young or small Kft. is invisible to a search-only lookup. Only a name ending in a
|
|
4
|
+
* Hungarian legal form is recognized: ceginfo.hu resolves the exact name to its
|
|
5
|
+
* registration number, nemzeticegtar.hu shows the record for free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CompanyRegister, Fetch, RegisteredCompany } from "./company-register";
|
|
9
|
+
|
|
10
|
+
interface Page {
|
|
11
|
+
url: string;
|
|
12
|
+
html: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CEGINFO = "https://ceginfo.hu/";
|
|
16
|
+
const NEMZETICEGTAR = "https://www.nemzeticegtar.hu/";
|
|
17
|
+
const USER_AGENT = "Mozilla/5.0 (compatible; PAL knowledge research)";
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
19
|
+
const MAX_REDIRECTS = 5;
|
|
20
|
+
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
21
|
+
const SEARCH_TOKEN_RE = /name="ceginfo_csrf_token" value="([^"]+)"/;
|
|
22
|
+
const OFFICERS_RE = /Cégjegyzésre jogosultak<\/h3>([\s\S]*?)<\/ul>/;
|
|
23
|
+
const PROCEEDINGS_RE = /<p[^>]*>([^<]*(?:felszámolási|végelszámolási)[^<]*)<\/p>/;
|
|
24
|
+
|
|
25
|
+
const LONG_FORMS: Array<[RegExp, string]> = [
|
|
26
|
+
[/korlátolt felelősségű társaság$/i, "kft"],
|
|
27
|
+
[/zártkörűen működő részvénytársaság$/i, "zrt"],
|
|
28
|
+
[/nyilvánosan működő részvénytársaság$/i, "nyrt"],
|
|
29
|
+
[/betéti társaság$/i, "bt"],
|
|
30
|
+
[/közkereseti társaság$/i, "kkt"],
|
|
31
|
+
];
|
|
32
|
+
const SHORT_FORM_RE = /(?:^|\s)(?:kft|zrt|nyrt|bt|kkt|rt)\.?$/i;
|
|
33
|
+
|
|
34
|
+
function isHungarianCompanyName(name: string): boolean {
|
|
35
|
+
return SHORT_FORM_RE.test(name) || LONG_FORMS.some(([re]) => re.test(name));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function comparable(name: string): string {
|
|
39
|
+
const short = LONG_FORMS.reduce((n, [re, form]) => n.replace(re, form), name);
|
|
40
|
+
return short.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ENTITIES: Record<string, string> = { amp: "&", quot: '"', nbsp: " " };
|
|
44
|
+
|
|
45
|
+
function decoded(html: string): string {
|
|
46
|
+
return html.replace(/&(#x?[0-9a-f]+|\w+);/gi, (whole, code: string) => {
|
|
47
|
+
if (code.startsWith("#x"))
|
|
48
|
+
return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
|
|
49
|
+
if (code.startsWith("#")) return String.fromCodePoint(Number(code.slice(1)));
|
|
50
|
+
return ENTITIES[code] ?? whole;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function plainText(html: string): string {
|
|
55
|
+
return decoded(html.replace(/<[^>]+>/g, ""))
|
|
56
|
+
.replace(/\s+/g, " ")
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function remember(response: Response, jar: Map<string, string>): void {
|
|
61
|
+
for (const cookie of response.headers.getSetCookie()) {
|
|
62
|
+
const [pair] = cookie.split(";");
|
|
63
|
+
const at = pair.indexOf("=");
|
|
64
|
+
jar.set(pair.slice(0, at), pair.slice(at + 1));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One browsing session: cookies carried across requests and redirects followed by hand,
|
|
69
|
+
* because a search result lives in the session the search was posted from. */
|
|
70
|
+
function session(fetchImpl: Fetch) {
|
|
71
|
+
const jar = new Map<string, string>();
|
|
72
|
+
return async (url: string, form?: URLSearchParams): Promise<Page> => {
|
|
73
|
+
let current = url;
|
|
74
|
+
let body = form;
|
|
75
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
76
|
+
const response = await fetchImpl(current, {
|
|
77
|
+
method: body ? "POST" : "GET",
|
|
78
|
+
body,
|
|
79
|
+
redirect: "manual",
|
|
80
|
+
headers: {
|
|
81
|
+
"user-agent": USER_AGENT,
|
|
82
|
+
cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
|
|
83
|
+
},
|
|
84
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
85
|
+
});
|
|
86
|
+
remember(response, jar);
|
|
87
|
+
const location = response.headers.get("location");
|
|
88
|
+
if (location && REDIRECTS.has(response.status)) {
|
|
89
|
+
current = new URL(location, current).href;
|
|
90
|
+
body = undefined;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!response.ok) throw new Error(`${current}: HTTP ${response.status}`);
|
|
94
|
+
return { url: current, html: await response.text() };
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`${url}: too many redirects`);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface Hit {
|
|
101
|
+
name: string;
|
|
102
|
+
number: string;
|
|
103
|
+
slug: string;
|
|
104
|
+
url: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function searchHits(html: string): Hit[] {
|
|
108
|
+
const results = html.matchAll(
|
|
109
|
+
/<a href="([^"]*\/ceg-adatlap\/([a-z0-9-]+)-(\d{10})\.html)"[^>]*>[\s\S]*?<h2[^>]*>([\s\S]*?)<\/h2>/g
|
|
110
|
+
);
|
|
111
|
+
return Array.from(results, ([, url, slug, number, title]) => ({
|
|
112
|
+
name: plainText(title.replace(/<small[\s\S]*?<\/small>/g, "")),
|
|
113
|
+
number,
|
|
114
|
+
slug,
|
|
115
|
+
url: url.replace(/(?<!:)\/\//g, "/"),
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function findRegistered(
|
|
120
|
+
name: string,
|
|
121
|
+
browse: ReturnType<typeof session>
|
|
122
|
+
): Promise<Hit | null> {
|
|
123
|
+
const home = await browse(CEGINFO);
|
|
124
|
+
const token = SEARCH_TOKEN_RE.exec(home.html)?.[1];
|
|
125
|
+
if (!token) throw new Error("ceginfo.hu: the search form has no token");
|
|
126
|
+
const results = await browse(
|
|
127
|
+
`${CEGINFO}cegkereso/rapid`,
|
|
128
|
+
new URLSearchParams({ ceginfo_csrf_token: token, rapid: name })
|
|
129
|
+
);
|
|
130
|
+
const exact = searchHits(results.html).filter(
|
|
131
|
+
(hit) => comparable(hit.name) === comparable(name)
|
|
132
|
+
);
|
|
133
|
+
return exact.length === 1 ? exact[0] : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function field(html: string, label: string): string {
|
|
137
|
+
const value = new RegExp(
|
|
138
|
+
String.raw`<h6[^>]*>\s*${label}\s*</h6>\s*<p[^>]*>([\s\S]*?)</p>`,
|
|
139
|
+
"i"
|
|
140
|
+
).exec(html)?.[1];
|
|
141
|
+
return value ? plainText(value) : "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Names only: the register also prints each officer's mother's name and home address. */
|
|
145
|
+
function managers(html: string): string[] {
|
|
146
|
+
const block = OFFICERS_RE.exec(html)?.[1] ?? "";
|
|
147
|
+
return Array.from(block.matchAll(/<strong[^>]*>([\s\S]*?)<\/strong>/g), ([, n]) =>
|
|
148
|
+
plainText(n)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function proceedings(html: string): string {
|
|
153
|
+
const sentence = PROCEEDINGS_RE.exec(html)?.[1];
|
|
154
|
+
return sentence ? plainText(sentence) : "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formattedNumber(digits: string): string {
|
|
158
|
+
return `${digits.slice(0, 2)}-${digits.slice(2, 4)}-${digits.slice(4)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function entryFrom(hit: Hit, record: Page): RegisteredCompany {
|
|
162
|
+
const html = record.html;
|
|
163
|
+
return {
|
|
164
|
+
name: hit.name,
|
|
165
|
+
fullName: field(html, "Teljes név"),
|
|
166
|
+
number: formattedNumber(hit.number),
|
|
167
|
+
taxNumber: field(html, "Adószám"),
|
|
168
|
+
seat: field(html, "székhely"),
|
|
169
|
+
status: proceedings(html),
|
|
170
|
+
activity: field(html, "Főtevékenység"),
|
|
171
|
+
founded: field(html, "Alapítás éve"),
|
|
172
|
+
managers: managers(html),
|
|
173
|
+
url: record.url,
|
|
174
|
+
sources: [hit.url, record.url],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Null for anything but one exact match. */
|
|
179
|
+
async function lookupHungarianCompany(
|
|
180
|
+
name: string,
|
|
181
|
+
fetchImpl: Fetch
|
|
182
|
+
): Promise<RegisteredCompany | null> {
|
|
183
|
+
const browse = session(fetchImpl);
|
|
184
|
+
const hit = await findRegistered(name, browse);
|
|
185
|
+
if (!hit) return null;
|
|
186
|
+
const record = await browse(`${NEMZETICEGTAR}${hit.slug}-c${hit.number}.html`);
|
|
187
|
+
return entryFrom(hit, record);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const hungarianRegister: CompanyRegister = {
|
|
191
|
+
country: "Hungary",
|
|
192
|
+
recognizes: isHungarianCompanyName,
|
|
193
|
+
lookup: lookupHungarianCompany,
|
|
194
|
+
};
|
|
@@ -62,6 +62,7 @@ import { logDebug, logError } from "./log";
|
|
|
62
62
|
import {
|
|
63
63
|
type FixedModelRoute,
|
|
64
64
|
type InferenceTier,
|
|
65
|
+
inferenceEffort,
|
|
65
66
|
inferenceModel,
|
|
66
67
|
isFixedModelRoute,
|
|
67
68
|
} from "./models";
|
|
@@ -224,10 +225,21 @@ export function plannedInferenceRoute(
|
|
|
224
225
|
return { agent: previewRoute().agent, ...plannedRoute({ user: "", tier, caller }) };
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
function routeWithModel(
|
|
229
|
+
opts: InferenceOptions
|
|
230
|
+
): ReturnType<typeof plannedRoute> | { error: string } {
|
|
231
|
+
try {
|
|
232
|
+
return plannedRoute(opts);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
227
238
|
export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
|
|
239
|
+
const planned = routeWithModel(opts);
|
|
240
|
+
if ("error" in planned) return { success: false, error: planned.error };
|
|
228
241
|
const started = performance.now();
|
|
229
242
|
const result = await checkedInference(opts);
|
|
230
|
-
const planned = plannedRoute(opts);
|
|
231
243
|
logInferenceCall(opts, planned, result, performance.now() - started);
|
|
232
244
|
return { ...result, model: planned.model ?? undefined };
|
|
233
245
|
}
|
|
@@ -524,9 +536,15 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
|
|
|
524
536
|
"--ephemeral",
|
|
525
537
|
"--json",
|
|
526
538
|
...(opts.web ? ["-c", "web_search=live"] : []),
|
|
539
|
+
...codexEffortArgs(opts),
|
|
527
540
|
];
|
|
528
541
|
}
|
|
529
542
|
|
|
543
|
+
function codexEffortArgs(opts: InferenceOptions): string[] {
|
|
544
|
+
const effort = inferenceEffort("codex-spawn", opts.tier);
|
|
545
|
+
return effort ? ["-c", `model_reasoning_effort="${effort}"`] : [];
|
|
546
|
+
}
|
|
547
|
+
|
|
530
548
|
interface CodexItem {
|
|
531
549
|
type?: string;
|
|
532
550
|
text?: string;
|
|
@@ -1124,31 +1142,38 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
|
|
|
1124
1142
|
// structured-output schema for JSON-mode callers.
|
|
1125
1143
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1126
1144
|
|
|
1145
|
+
export function buildOpenAiBody(opts: InferenceOptions): Record<string, unknown> {
|
|
1146
|
+
const { system, user, maxTokens = 500, jsonSchema } = opts;
|
|
1147
|
+
const messages: Array<{ role: string; content: string }> = [];
|
|
1148
|
+
if (system) messages.push({ role: "system", content: system });
|
|
1149
|
+
messages.push({ role: "user", content: user });
|
|
1150
|
+
|
|
1151
|
+
const body: Record<string, unknown> = {
|
|
1152
|
+
model: modelFor("openai-api", opts),
|
|
1153
|
+
max_tokens: maxTokens,
|
|
1154
|
+
messages,
|
|
1155
|
+
};
|
|
1156
|
+
const effort = inferenceEffort("openai-api", opts.tier);
|
|
1157
|
+
if (effort) body.reasoning_effort = effort;
|
|
1158
|
+
if (jsonSchema) {
|
|
1159
|
+
body.response_format = {
|
|
1160
|
+
type: "json_schema",
|
|
1161
|
+
json_schema: { name: "structured_response", strict: true, schema: jsonSchema },
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
return body;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1127
1167
|
async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceResult> {
|
|
1128
1168
|
const apiKey = withPalEnv().PAL_OPENAI_API_KEY;
|
|
1129
1169
|
if (!apiKey) return { success: false };
|
|
1130
1170
|
|
|
1131
|
-
const {
|
|
1171
|
+
const { timeout = 15000 } = opts;
|
|
1132
1172
|
|
|
1133
1173
|
try {
|
|
1134
1174
|
const controller = new AbortController();
|
|
1135
1175
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
1136
|
-
|
|
1137
|
-
const messages: Array<{ role: string; content: string }> = [];
|
|
1138
|
-
if (system) messages.push({ role: "system", content: system });
|
|
1139
|
-
messages.push({ role: "user", content: user });
|
|
1140
|
-
|
|
1141
|
-
const body: Record<string, unknown> = {
|
|
1142
|
-
model: modelFor("openai-api", opts),
|
|
1143
|
-
max_tokens: maxTokens,
|
|
1144
|
-
messages,
|
|
1145
|
-
};
|
|
1146
|
-
if (jsonSchema) {
|
|
1147
|
-
body.response_format = {
|
|
1148
|
-
type: "json_schema",
|
|
1149
|
-
json_schema: { name: "structured_response", strict: true, schema: jsonSchema },
|
|
1150
|
-
};
|
|
1151
|
-
}
|
|
1176
|
+
const body = buildOpenAiBody(opts);
|
|
1152
1177
|
|
|
1153
1178
|
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1154
1179
|
method: "POST",
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -6,11 +6,13 @@ import type { AgentType } from "./agent";
|
|
|
6
6
|
|
|
7
7
|
export const HAIKU_MODEL = "claude-haiku-4-5-20251001";
|
|
8
8
|
export const HAIKU_5_5_MODEL = "claude-haiku-5-5";
|
|
9
|
-
export const SONNET_MODEL = "claude-sonnet-5";
|
|
10
|
-
export const
|
|
9
|
+
export const SONNET_MODEL = "claude-sonnet-5-5";
|
|
10
|
+
export const OPUS_MODEL = "claude-opus-5-5";
|
|
11
|
+
export const FABLE_MODEL = "claude-fable-5-1";
|
|
11
12
|
|
|
12
|
-
export const INFERENCE_TIERS = ["small", "medium"] as const;
|
|
13
|
+
export const INFERENCE_TIERS = ["small", "medium", "large", "flagship"] as const;
|
|
13
14
|
export type InferenceTier = (typeof INFERENCE_TIERS)[number];
|
|
15
|
+
type ReasoningEffort = "low" | "medium" | "high";
|
|
14
16
|
|
|
15
17
|
export type FixedModelRoute =
|
|
16
18
|
| "claude-spawn"
|
|
@@ -19,24 +21,37 @@ export type FixedModelRoute =
|
|
|
19
21
|
| "openai-api"
|
|
20
22
|
| "antigravity-spawn";
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
type RouteModels = Record<InferenceTier, string> & { large?: string };
|
|
24
|
+
type RouteModels = Record<"small" | "medium" | "large", string> & { flagship?: string };
|
|
24
25
|
|
|
25
26
|
const ANTHROPIC_MODELS: RouteModels = {
|
|
26
27
|
small: HAIKU_MODEL,
|
|
27
28
|
medium: SONNET_MODEL,
|
|
28
|
-
large:
|
|
29
|
+
large: OPUS_MODEL,
|
|
30
|
+
flagship: FABLE_MODEL,
|
|
29
31
|
};
|
|
30
32
|
const OPENAI_MODELS: RouteModels = {
|
|
31
33
|
small: "gpt-6-luna",
|
|
32
34
|
medium: "gpt-6-sol",
|
|
33
|
-
large: "gpt-6-
|
|
35
|
+
large: "gpt-6-sol",
|
|
36
|
+
flagship: "gpt-6-astra",
|
|
34
37
|
};
|
|
35
|
-
/** An Antigravity subagent's `model:` takes only a tier name, so `large` is `pro`. */
|
|
36
38
|
const GEMINI_MODELS: RouteModels = {
|
|
37
39
|
small: "gemini-3.8-flash-low",
|
|
38
|
-
medium: "gemini-3.8-flash-
|
|
39
|
-
large: "
|
|
40
|
+
medium: "gemini-3.8-flash-medium",
|
|
41
|
+
large: "gemini-3.8-flash-high",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const OPENAI_EFFORTS: Partial<Record<InferenceTier, ReasoningEffort>> = { large: "high" };
|
|
45
|
+
const INFERENCE_EFFORTS: Partial<
|
|
46
|
+
Record<FixedModelRoute, Partial<Record<InferenceTier, ReasoningEffort>>>
|
|
47
|
+
> = {
|
|
48
|
+
"codex-spawn": OPENAI_EFFORTS,
|
|
49
|
+
"openai-api": OPENAI_EFFORTS,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** An Antigravity subagent's `model:` takes only a tier name, never a model id. */
|
|
53
|
+
const SUBAGENT_FLAGSHIP: Partial<Record<FixedModelRoute, string>> = {
|
|
54
|
+
"antigravity-spawn": "pro",
|
|
40
55
|
};
|
|
41
56
|
|
|
42
57
|
/**
|
|
@@ -63,9 +78,13 @@ export function isFixedModelRoute(route: string): route is FixedModelRoute {
|
|
|
63
78
|
*/
|
|
64
79
|
const ANTHROPIC_ON_HAIKU_5_5 = new Set(["rule-drafter", "correction-kind"]);
|
|
65
80
|
|
|
66
|
-
function evaluatedModel(
|
|
67
|
-
|
|
68
|
-
|
|
81
|
+
function evaluatedModel(
|
|
82
|
+
models: RouteModels,
|
|
83
|
+
tier: InferenceTier,
|
|
84
|
+
caller?: string
|
|
85
|
+
): string | undefined {
|
|
86
|
+
const onAnthropicSmall = models === ANTHROPIC_MODELS && tier === "small";
|
|
87
|
+
return onAnthropicSmall && caller && ANTHROPIC_ON_HAIKU_5_5.has(caller)
|
|
69
88
|
? HAIKU_5_5_MODEL
|
|
70
89
|
: undefined;
|
|
71
90
|
}
|
|
@@ -76,7 +95,17 @@ export function inferenceModel(
|
|
|
76
95
|
caller?: string
|
|
77
96
|
): string {
|
|
78
97
|
const models = INFERENCE_MODELS[route];
|
|
79
|
-
|
|
98
|
+
const model = evaluatedModel(models, tier, caller) ?? models[tier];
|
|
99
|
+
if (!model) throw new Error(`${route} has no ${tier} model`);
|
|
100
|
+
return model;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The reasoning effort a tier asks of a model it shares with another tier; undefined keeps the model's default. */
|
|
104
|
+
export function inferenceEffort(
|
|
105
|
+
route: FixedModelRoute,
|
|
106
|
+
tier: InferenceTier = "small"
|
|
107
|
+
): ReasoningEffort | undefined {
|
|
108
|
+
return INFERENCE_EFFORTS[route]?.[tier];
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
const AGENT_SPAWN_ROUTE: Partial<Record<AgentType, FixedModelRoute>> = {
|
|
@@ -88,11 +117,13 @@ const AGENT_SPAWN_ROUTE: Partial<Record<AgentType, FixedModelRoute>> = {
|
|
|
88
117
|
/**
|
|
89
118
|
* The model an agent's `skill-author` / `subagent-author` runs on, or undefined
|
|
90
119
|
* when `create-skill` and `create-subagent` should author inline. A new provider
|
|
91
|
-
* needs a
|
|
120
|
+
* needs a flagship model on its route and a platform block in assets/agents/*-author.md.
|
|
92
121
|
*/
|
|
93
122
|
export function flagshipAuthorModel(agent: AgentType): string | undefined {
|
|
94
123
|
const route = AGENT_SPAWN_ROUTE[agent];
|
|
95
|
-
return route
|
|
124
|
+
return route
|
|
125
|
+
? (SUBAGENT_FLAGSHIP[route] ?? INFERENCE_MODELS[route].flagship)
|
|
126
|
+
: undefined;
|
|
96
127
|
}
|
|
97
128
|
|
|
98
129
|
export interface ModelPricing {
|
|
@@ -131,18 +162,31 @@ const withoutWritePremium = (
|
|
|
131
162
|
cacheRead,
|
|
132
163
|
});
|
|
133
164
|
|
|
165
|
+
const withWritePremium = (
|
|
166
|
+
input: number,
|
|
167
|
+
cacheRead: number,
|
|
168
|
+
output: number
|
|
169
|
+
): ModelPricing => ({
|
|
170
|
+
input,
|
|
171
|
+
output,
|
|
172
|
+
cacheWrite5m: input * 1.25,
|
|
173
|
+
cacheWrite1h: input * 1.25,
|
|
174
|
+
cacheRead,
|
|
175
|
+
});
|
|
176
|
+
|
|
134
177
|
/**
|
|
135
178
|
* Standard-tier rates from https://developers.openai.com/api/docs/pricing; prompts over
|
|
136
|
-
* 272K tokens cost more, and PAL's never get there.
|
|
179
|
+
* 272K tokens cost more, and PAL's never get there. GPT-6 and GPT-5.6 bill cache writes
|
|
180
|
+
* at 1.25x input; older models publish no cache-write price, so writes bill as input.
|
|
137
181
|
*/
|
|
138
182
|
const OPENAI_PRICING: Record<string, ModelPricing> = {
|
|
139
|
-
"gpt-6-astra":
|
|
140
|
-
"gpt-6.1-sol":
|
|
141
|
-
"gpt-6-sol":
|
|
142
|
-
"gpt-6-luna":
|
|
143
|
-
"gpt-5.6-sol":
|
|
144
|
-
"gpt-5.6-terra":
|
|
145
|
-
"gpt-5.6-luna":
|
|
183
|
+
"gpt-6-astra": withWritePremium(10, 1, 50),
|
|
184
|
+
"gpt-6.1-sol": withWritePremium(2, 0.1, 10),
|
|
185
|
+
"gpt-6-sol": withWritePremium(2, 0.2, 10),
|
|
186
|
+
"gpt-6-luna": withWritePremium(0.1, 0.01, 0.5),
|
|
187
|
+
"gpt-5.6-sol": withWritePremium(4, 0.4, 20),
|
|
188
|
+
"gpt-5.6-terra": withWritePremium(2, 0.2, 12),
|
|
189
|
+
"gpt-5.6-luna": withWritePremium(0.2, 0.02, 1.2),
|
|
146
190
|
"gpt-5.5": withoutWritePremium(5, 0.5, 30),
|
|
147
191
|
"gpt-5.5-pro": withoutWritePremium(30, 30, 180),
|
|
148
192
|
"gpt-5.4": withoutWritePremium(2.5, 0.25, 15),
|
|
@@ -176,14 +220,14 @@ export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
|
176
220
|
cacheWrite1h: 0.2,
|
|
177
221
|
cacheRead: 0.01,
|
|
178
222
|
},
|
|
179
|
-
|
|
223
|
+
[FABLE_MODEL]: {
|
|
180
224
|
input: 10,
|
|
181
225
|
output: 50,
|
|
182
226
|
cacheWrite5m: 12.5,
|
|
183
227
|
cacheWrite1h: 20,
|
|
184
228
|
cacheRead: 0.25,
|
|
185
229
|
},
|
|
186
|
-
|
|
230
|
+
"claude-fable-5": {
|
|
187
231
|
input: 10,
|
|
188
232
|
output: 50,
|
|
189
233
|
cacheWrite5m: 12.5,
|