portable-agent-layer 0.92.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/assets/schema/pal-settings.schema.json +12 -0
- package/assets/skills/research-entity/SKILL.md +57 -0
- package/assets/templates/pal-settings.json +3 -0
- package/package.json +1 -1
- package/src/cli/infer.ts +6 -0
- package/src/cli/knowledge-research.ts +93 -0
- package/src/cli/knowledge.ts +110 -0
- package/src/hooks/handlers/entity-extraction.ts +92 -0
- package/src/hooks/handlers/inject-retrieval.ts +4 -0
- package/src/hooks/lib/daily-nudge.ts +29 -6
- package/src/hooks/lib/entity-cards.ts +112 -0
- package/src/hooks/lib/entity-extraction.ts +356 -0
- package/src/hooks/lib/entity-names.ts +242 -0
- package/src/hooks/lib/entity-research.ts +305 -0
- package/src/hooks/lib/entity-review.ts +101 -0
- package/src/hooks/lib/inference.ts +41 -6
- package/src/hooks/lib/stop.ts +19 -0
- package/src/hooks/lib/token-usage.ts +2 -0
- package/src/tools/knowledge/ingest.ts +25 -8
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's answers to the extraction review queue: an accepted item is written
|
|
3
|
+
* to the entity the user names (or a new one), a rejected one is dropped.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { type IngestInput, ingestEntities } from "../../tools/knowledge/ingest";
|
|
8
|
+
import { loadKnownEntities, loadNameIndex } from "./entity-cards";
|
|
9
|
+
import { ingestInput, type ReviewItem, reviewQueueFile } from "./entity-extraction";
|
|
10
|
+
import { indexedEntities, type NamedEntity, type NameIndex } from "./entity-names";
|
|
11
|
+
import { profileIngest } from "./entity-research";
|
|
12
|
+
|
|
13
|
+
export interface AcceptChoice {
|
|
14
|
+
as?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Accepted {
|
|
19
|
+
item: ReviewItem;
|
|
20
|
+
slug: string;
|
|
21
|
+
created: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseItem(line: string): ReviewItem[] {
|
|
25
|
+
try {
|
|
26
|
+
return [JSON.parse(line) as ReviewItem];
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pendingReviews(): ReviewItem[] {
|
|
33
|
+
const file = reviewQueueFile();
|
|
34
|
+
if (!existsSync(file)) return [];
|
|
35
|
+
return readFileSync(file, "utf-8").split("\n").filter(Boolean).flatMap(parseItem);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function takeFromQueue(id: string): ReviewItem | null {
|
|
39
|
+
const items = pendingReviews();
|
|
40
|
+
const item = items.find((i) => i.id === id);
|
|
41
|
+
if (!item) return null;
|
|
42
|
+
const rest = items.filter((i) => i !== item);
|
|
43
|
+
writeFileSync(reviewQueueFile(), rest.map((i) => `${JSON.stringify(i)}\n`).join(""));
|
|
44
|
+
return item;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function chosenEntity(
|
|
48
|
+
item: ReviewItem,
|
|
49
|
+
slug: string | undefined,
|
|
50
|
+
index: NameIndex
|
|
51
|
+
): NamedEntity | null {
|
|
52
|
+
if (!slug) return null;
|
|
53
|
+
const domain = item.entity.kind === "person" ? "People" : "Companies";
|
|
54
|
+
const entity = indexedEntities(index).find(
|
|
55
|
+
(e) => e.domain === domain && e.slug === slug
|
|
56
|
+
);
|
|
57
|
+
if (!entity) throw new Error(`no ${domain} entry with slug "${slug}"`);
|
|
58
|
+
return entity;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadEntityReviewNudge(): string {
|
|
62
|
+
const count = pendingReviews().length;
|
|
63
|
+
if (count === 0) return "";
|
|
64
|
+
const waiting =
|
|
65
|
+
count === 1 ? "1 person or company waits" : `${count} people or companies wait`;
|
|
66
|
+
return [
|
|
67
|
+
"## Entity Review Due",
|
|
68
|
+
`👥 ${waiting} for the user's answer before the knowledge store records them — offer \`pal cli knowledge review\`.`,
|
|
69
|
+
].join("\n");
|
|
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
|
+
|
|
88
|
+
export function rejectReview(id: string): ReviewItem | null {
|
|
89
|
+
return takeFromQueue(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Writes the item to `as` when given, else to a new entity named `name` or as extracted. */
|
|
93
|
+
export function acceptReview(id: string, choice: AcceptChoice = {}): Accepted | null {
|
|
94
|
+
const queued = pendingReviews().find((i) => i.id === id);
|
|
95
|
+
if (!queued) return null;
|
|
96
|
+
const index = loadNameIndex(loadKnownEntities());
|
|
97
|
+
const result = ingestEntities(acceptedInput(queued, choice, index), queued.source);
|
|
98
|
+
takeFromQueue(id);
|
|
99
|
+
const written = result.people[0] ?? result.companies[0];
|
|
100
|
+
return { item: queued, slug: written.slug, created: written.created };
|
|
101
|
+
}
|
|
@@ -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
|
|
|
@@ -580,8 +602,12 @@ async function inferenceViaAntigravitySpawn(
|
|
|
580
602
|
* --disable-builtin-mcps → no MCP servers in the child (extra safety)
|
|
581
603
|
* --no-auto-update → prevent CLI self-update from delaying the spawn
|
|
582
604
|
* --no-color → clean stdout for capture
|
|
583
|
-
* --
|
|
584
|
-
*
|
|
605
|
+
* --available-tools=none → Copilot has no "no tools" flag; an allowlist
|
|
606
|
+
* naming an unknown tool hides every tool
|
|
607
|
+
* --deny-tool=… → backstop: deny rules win over any allow rule
|
|
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
|
|
585
611
|
*
|
|
586
612
|
* Copilot has no --system-prompt flag, so system + user + JSON-schema are
|
|
587
613
|
* concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately
|
|
@@ -589,16 +615,25 @@ async function inferenceViaAntigravitySpawn(
|
|
|
589
615
|
* survive cmd.exe when Bun.spawn resolves copilot to its Windows .cmd shim.
|
|
590
616
|
* Piping stdin keeps copilot non-interactive, so dropping -p costs nothing.
|
|
591
617
|
*/
|
|
592
|
-
export function buildCopilotArgs(
|
|
618
|
+
export function buildCopilotArgs(opts: InferenceOptions): string[] {
|
|
593
619
|
return [
|
|
594
620
|
"--no-custom-instructions",
|
|
595
621
|
"--disable-builtin-mcps",
|
|
596
622
|
"--no-auto-update",
|
|
597
623
|
"--no-color",
|
|
598
|
-
"--
|
|
624
|
+
"--silent",
|
|
625
|
+
...copilotTools(opts),
|
|
626
|
+
"--deny-tool=shell",
|
|
627
|
+
"--deny-tool=write",
|
|
599
628
|
];
|
|
600
629
|
}
|
|
601
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
|
+
|
|
602
637
|
/**
|
|
603
638
|
* Extract the agent's text reply from opencode --format json NDJSON output.
|
|
604
639
|
* Concatenates all `type:"text"` event payloads in order. Returns empty
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -168,6 +168,7 @@ export async function runStopHandlers(
|
|
|
168
168
|
// any in-hook budget. These spawn detached bun subprocesses that run the
|
|
169
169
|
// inference and write results to disk; they don't block this hook.
|
|
170
170
|
await detachSessionIntelligence(transcript, options.sessionId);
|
|
171
|
+
await detachEntityExtraction(transcript, options.sessionId);
|
|
171
172
|
await detachFailurePrinciple(transcript);
|
|
172
173
|
detachAgenda(options.sessionId);
|
|
173
174
|
// Failure auto-graduation is intentionally NOT wired here: every pattern it
|
|
@@ -325,6 +326,24 @@ async function detachSessionIntelligence(
|
|
|
325
326
|
}
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
async function detachEntityExtraction(
|
|
330
|
+
transcript: string,
|
|
331
|
+
sessionId?: string
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
if (!sessionId) return;
|
|
334
|
+
try {
|
|
335
|
+
const transcriptPath = await writeTranscriptTmp(transcript);
|
|
336
|
+
const scriptPath = resolve(assets.hooks(), "handlers", "entity-extraction.ts");
|
|
337
|
+
spawnDetachedInference(
|
|
338
|
+
scriptPath,
|
|
339
|
+
["--run", sessionId, transcriptPath],
|
|
340
|
+
"entity-extraction"
|
|
341
|
+
);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
logError("detachEntityExtraction", err);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
328
347
|
/**
|
|
329
348
|
* If a pending-failure exists, rename it to a unique path (race-free claim),
|
|
330
349
|
* write the transcript to tmp, spawn the failure-principle handler detached.
|
|
@@ -29,7 +29,14 @@ import {
|
|
|
29
29
|
|
|
30
30
|
// --- Public input shape -----------------------------------------------------
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
interface Addressing {
|
|
33
|
+
aliases?: string[] | null;
|
|
34
|
+
relation?: string | null;
|
|
35
|
+
/** The stored file to update, when its slug is not derived from the name or domain. */
|
|
36
|
+
slug?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PersonInput extends Addressing {
|
|
33
40
|
name: string;
|
|
34
41
|
role?: string | null;
|
|
35
42
|
title?: string | null;
|
|
@@ -40,7 +47,7 @@ export interface PersonInput {
|
|
|
40
47
|
[extra: string]: unknown;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
export interface CompanyInput {
|
|
50
|
+
export interface CompanyInput extends Addressing {
|
|
44
51
|
name: string;
|
|
45
52
|
domain?: string | null;
|
|
46
53
|
industry?: string | null;
|
|
@@ -201,7 +208,17 @@ function newPersonEntity(input: PersonInput, slug: string): Entity {
|
|
|
201
208
|
if (Object.keys(socials).length > 0) {
|
|
202
209
|
fm.socials = Object.entries(socials).map(([k, v]) => `${k}:${v}`);
|
|
203
210
|
}
|
|
204
|
-
return { domain: "People", slug, frontmatter: fm, body: "" };
|
|
211
|
+
return { domain: "People", slug, frontmatter: withAddressing(fm, input), body: "" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** How the user refers to an entity and stands towards it — both only ever grow. */
|
|
215
|
+
function withAddressing(fm: EntityFrontmatter, input: Addressing): EntityFrontmatter {
|
|
216
|
+
const prior = Array.isArray(fm.aliases) ? (fm.aliases as string[]) : undefined;
|
|
217
|
+
const aliases = mergeStringArray(prior, input.aliases ?? undefined);
|
|
218
|
+
if (aliases.length > 0) fm.aliases = aliases;
|
|
219
|
+
const relation = mergeScalar(fm.relation, input.relation);
|
|
220
|
+
if (relation) fm.relation = relation;
|
|
221
|
+
return fm;
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
function newCompanyEntity(input: CompanyInput, slug: string): Entity {
|
|
@@ -220,7 +237,7 @@ function newCompanyEntity(input: CompanyInput, slug: string): Entity {
|
|
|
220
237
|
if (input.industry) fm.industry = input.industry;
|
|
221
238
|
if (input.mentioned_as) fm.mentioned_as = input.mentioned_as;
|
|
222
239
|
if (input.sentiment) fm.sentiment = input.sentiment;
|
|
223
|
-
return { domain: "Companies", slug, frontmatter: fm, body: "" };
|
|
240
|
+
return { domain: "Companies", slug, frontmatter: withAddressing(fm, input), body: "" };
|
|
224
241
|
}
|
|
225
242
|
|
|
226
243
|
function mergePerson(prior: Entity, input: PersonInput): Entity {
|
|
@@ -248,7 +265,7 @@ function mergePerson(prior: Entity, input: PersonInput): Entity {
|
|
|
248
265
|
if (Object.keys(socials).length > 0) {
|
|
249
266
|
fm.socials = Object.entries(socials).map(([k, v]) => `${k}:${v}`);
|
|
250
267
|
}
|
|
251
|
-
return { ...prior, frontmatter: fm };
|
|
268
|
+
return { ...prior, frontmatter: withAddressing(fm, input) };
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
function mergeCompany(prior: Entity, input: CompanyInput): Entity {
|
|
@@ -260,7 +277,7 @@ function mergeCompany(prior: Entity, input: CompanyInput): Entity {
|
|
|
260
277
|
if (input.industry) {
|
|
261
278
|
fm.tags = mergeStringArray(fm.tags, industryToTopicTags(input.industry));
|
|
262
279
|
}
|
|
263
|
-
return { ...prior, frontmatter: fm };
|
|
280
|
+
return { ...prior, frontmatter: withAddressing(fm, input) };
|
|
264
281
|
}
|
|
265
282
|
|
|
266
283
|
function upsertPerson(
|
|
@@ -268,7 +285,7 @@ function upsertPerson(
|
|
|
268
285
|
sourceId: string,
|
|
269
286
|
rootDir?: string
|
|
270
287
|
): UpsertResult {
|
|
271
|
-
const slug = slugify(input.name);
|
|
288
|
+
const slug = input.slug || slugify(input.name);
|
|
272
289
|
if (!slug) throw new Error(`ingest: cannot slugify person name "${input.name}"`);
|
|
273
290
|
const prior = load("People", slug, rootDir);
|
|
274
291
|
const created = prior === null;
|
|
@@ -294,7 +311,7 @@ function upsertCompany(
|
|
|
294
311
|
rootDir?: string
|
|
295
312
|
): UpsertResult {
|
|
296
313
|
const baseKey = input.domain?.trim() ? input.domain : input.name;
|
|
297
|
-
const slug = slugify(baseKey);
|
|
314
|
+
const slug = input.slug || slugify(baseKey);
|
|
298
315
|
if (!slug) throw new Error(`ingest: cannot slugify company "${input.name}"`);
|
|
299
316
|
const prior = load("Companies", slug, rootDir);
|
|
300
317
|
const created = prior === null;
|