portable-agent-layer 0.110.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.
@@ -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`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.110.0",
3
+ "version": "0.111.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = { principal: identity().principal.name, infer: inference }
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
+ }
@@ -49,6 +49,7 @@ export interface ResearchProfile {
49
49
  taxNumber: string;
50
50
  seat: string;
51
51
  status: string;
52
+ activity: string;
52
53
  managers: string[];
53
54
  url: string;
54
55
  };
@@ -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 researchUser(target: ResearchTarget, today: string): string {
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(target, deps.today ?? new Date().toISOString().slice(0, 10)),
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
+ };