portable-agent-layer 0.93.0 → 0.95.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 +5 -1
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/research-entity/SKILL.md +57 -0
- package/assets/templates/hooks.antigravity.json +1 -1
- package/assets/templates/hooks.codex.json +11 -1
- package/assets/templates/hooks.copilot.json +11 -4
- package/assets/templates/hooks.cursor.json +8 -2
- package/assets/templates/pal-settings.json +1 -0
- package/assets/templates/settings.claude.json +55 -3
- package/package.json +1 -1
- package/src/cli/content.ts +23 -0
- package/src/cli/events.ts +47 -0
- package/src/cli/infer.ts +6 -0
- package/src/cli/knowledge-research.ts +93 -0
- package/src/cli/knowledge.ts +17 -4
- package/src/cli/tree.ts +4 -0
- package/src/hooks/AttentionEvent.ts +22 -0
- package/src/hooks/LoadContext.ts +2 -2
- package/src/hooks/PostToolOrchestrator.ts +20 -0
- package/src/hooks/PreCompactPersist.ts +4 -2
- package/src/hooks/PreserveTranscripts.ts +13 -0
- package/src/hooks/SessionClose.ts +5 -0
- package/src/hooks/UserPromptOrchestrator.ts +9 -2
- package/src/hooks/handlers/entity-extraction.ts +10 -3
- package/src/hooks/lib/attention-events.ts +43 -0
- package/src/hooks/lib/auto-update.ts +24 -1
- package/src/hooks/lib/commitment-events.ts +20 -0
- package/src/hooks/lib/discord-events.ts +25 -0
- package/src/hooks/lib/entity-events.ts +16 -0
- package/src/hooks/lib/entity-extraction.ts +24 -2
- package/src/hooks/lib/entity-research.ts +305 -0
- package/src/hooks/lib/entity-review.ts +19 -6
- package/src/hooks/lib/events.ts +252 -0
- package/src/hooks/lib/export.ts +1 -1
- package/src/hooks/lib/hook-timing.ts +33 -0
- package/src/hooks/lib/inference-events.ts +56 -0
- package/src/hooks/lib/inference.ts +51 -6
- package/src/hooks/lib/interaction.ts +74 -27
- package/src/hooks/lib/opencode-events.ts +59 -0
- package/src/hooks/lib/paths.ts +1 -0
- package/src/hooks/lib/post-tool.ts +66 -0
- package/src/hooks/lib/session-events.ts +211 -0
- package/src/hooks/lib/settings.ts +6 -0
- package/src/hooks/lib/stop.ts +27 -31
- package/src/hooks/lib/token-usage.ts +1 -0
- package/src/hooks/lib/tool-events.ts +106 -0
- package/src/hooks/lib/transcript-preserve.ts +133 -0
- package/src/hooks/lib/turn-extras.ts +78 -0
- package/src/hooks/lib/waiting-on-user.ts +75 -0
- package/src/hooks/lib/work-tracking.ts +17 -3
- package/src/targets/opencode/plugin.ts +33 -1
- package/src/tools/agent/interaction.ts +2 -2
- package/src/tools/agent/project.ts +4 -0
- package/src/tools/control-room/snooze.ts +2 -0
- package/src/tools/control-room/writes.ts +2 -0
- package/src/tools/knowledge/lib.ts +5 -0
- package/src/hooks/LedgerCommit.ts +0 -34
- package/src/hooks/LedgerUnapplied.ts +0 -47
|
@@ -13,14 +13,19 @@
|
|
|
13
13
|
import { autoUpdateOnClose } from "./lib/auto-update";
|
|
14
14
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
15
15
|
import { logError } from "./lib/log";
|
|
16
|
+
import { logSessionEnd } from "./lib/session-events";
|
|
16
17
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
17
18
|
import { readStdinJSON } from "./lib/stdin";
|
|
19
|
+
import { spawnTranscriptPreserve } from "./lib/transcript-preserve";
|
|
18
20
|
|
|
19
21
|
if (isPalSpawnedInference()) process.exit(0);
|
|
20
22
|
if (duplicatesCursorHooks()) process.exit(0);
|
|
21
23
|
|
|
24
|
+
spawnTranscriptPreserve();
|
|
25
|
+
|
|
22
26
|
try {
|
|
23
27
|
const input = await readStdinJSON<{ reason?: string }>();
|
|
28
|
+
logSessionEnd(input);
|
|
24
29
|
autoUpdateOnClose(input?.reason);
|
|
25
30
|
} catch (err) {
|
|
26
31
|
logError("SessionClose", err);
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { injectPromptContext } from "./handlers/inject-retrieval";
|
|
11
11
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
12
|
+
import { startHookTiming } from "./lib/hook-timing";
|
|
12
13
|
import { type HookTurnPayload, hookSessionId } from "./lib/hook-turn";
|
|
13
14
|
import { logDebug, logPromptSnapshot } from "./lib/log";
|
|
14
15
|
import { capturePrompt } from "./lib/prompt-capture";
|
|
@@ -28,9 +29,15 @@ const input = await readStdinJSON<PromptSubmitInput>();
|
|
|
28
29
|
logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
|
|
29
30
|
if (!input?.prompt) process.exit(0);
|
|
30
31
|
|
|
32
|
+
const timing = startHookTiming("prompt");
|
|
31
33
|
const sentAt = new Date();
|
|
32
34
|
const sessionId = hookSessionId(input);
|
|
33
|
-
const injected = await
|
|
35
|
+
const injected = await timing.time("inject-context", () =>
|
|
36
|
+
injectPromptContext(input.prompt, sessionId)
|
|
37
|
+
);
|
|
34
38
|
logPromptSnapshot(input.prompt, injected);
|
|
35
39
|
|
|
36
|
-
await
|
|
40
|
+
await timing.time("capture", () =>
|
|
41
|
+
capturePrompt(input.prompt, sessionId, sentAt, "UserPromptOrchestrator")
|
|
42
|
+
);
|
|
43
|
+
timing.log(sessionId);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stop handler: learn the people and companies the user mentioned since the last
|
|
3
|
-
* stop, so the prompt-time cards know them next time
|
|
4
|
-
*
|
|
3
|
+
* stop, so the prompt-time cards know them next time, and research the new ones on
|
|
4
|
+
* the web for review. Runs detached; one extraction call, only when the new text
|
|
5
|
+
* names someone, and one web research call per new entry.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { ingestEntities } from "../../tools/knowledge/ingest";
|
|
@@ -18,9 +19,10 @@ import {
|
|
|
18
19
|
unseenUserText,
|
|
19
20
|
worthExtracting,
|
|
20
21
|
} from "../lib/entity-extraction";
|
|
22
|
+
import { newResearchTargets, researchNewEntities } from "../lib/entity-research";
|
|
21
23
|
import { canInfer, inference } from "../lib/inference";
|
|
22
24
|
import { logDebug, logError } from "../lib/log";
|
|
23
|
-
import { identity, isEnabled } from "../lib/settings";
|
|
25
|
+
import { identity, isEnabled, isOptedIn } from "../lib/settings";
|
|
24
26
|
import { logTokenUsage } from "../lib/token-usage";
|
|
25
27
|
import { parseMessages } from "../lib/transcript";
|
|
26
28
|
|
|
@@ -65,6 +67,11 @@ export async function extractSessionEntities(
|
|
|
65
67
|
"entity-extraction",
|
|
66
68
|
`${written.people.length} people, ${written.companies.length} companies, ${plan.review.length} for review`
|
|
67
69
|
);
|
|
70
|
+
if (isOptedIn("entityResearch"))
|
|
71
|
+
await researchNewEntities(newResearchTargets(plan.ingest, written), {
|
|
72
|
+
principal: id.principal.name,
|
|
73
|
+
infer: inference,
|
|
74
|
+
});
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
if (process.argv[2] === "--run") {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the agent waits on the user: a permission prompt, or a notification that
|
|
3
|
+
* it needs them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { normalizeToolUse } from "./agent";
|
|
7
|
+
import { appendEvent, logQuietly } from "./events";
|
|
8
|
+
import { hookSessionId } from "./hook-turn";
|
|
9
|
+
import { markWaiting } from "./waiting-on-user";
|
|
10
|
+
|
|
11
|
+
function logPermissionPrompt(
|
|
12
|
+
payload: Record<string, unknown> | null,
|
|
13
|
+
tool: string | null,
|
|
14
|
+
now: Date = new Date()
|
|
15
|
+
): void {
|
|
16
|
+
logQuietly("permission_prompt", () => {
|
|
17
|
+
const session = hookSessionId(payload) ?? null;
|
|
18
|
+
appendEvent("permission_prompt", { tool }, { ts: now.toISOString(), session });
|
|
19
|
+
if (session)
|
|
20
|
+
markWaiting("permission", session, { ts: now.toISOString(), tool: tool ?? "" });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function logNotification(payload: Record<string, unknown> | null, now: Date): void {
|
|
25
|
+
logQuietly("notification", () => {
|
|
26
|
+
const session = hookSessionId(payload) ?? null;
|
|
27
|
+
const kind =
|
|
28
|
+
typeof payload?.notification_type === "string" ? payload.notification_type : null;
|
|
29
|
+
appendEvent("notification", { kind }, { ts: now.toISOString(), session });
|
|
30
|
+
if (session) markWaiting("notified", session, { ts: now.toISOString() });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Claude's PermissionRequest and Notification share one hook; it prints nothing. */
|
|
35
|
+
export function logAttention(
|
|
36
|
+
payload: Record<string, unknown> | null,
|
|
37
|
+
now: Date = new Date()
|
|
38
|
+
): void {
|
|
39
|
+
const event = payload?.hook_event_name ?? payload?.hookEventName;
|
|
40
|
+
if (event === "Notification") logNotification(payload, now);
|
|
41
|
+
if (event === "PermissionRequest")
|
|
42
|
+
logPermissionPrompt(payload, normalizeToolUse(payload)?.toolName ?? null, now);
|
|
43
|
+
}
|
|
@@ -15,6 +15,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
15
15
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { resolve } from "node:path";
|
|
17
17
|
import { cachedStatus, getInstalledVersion, isRepoMode } from "../handlers/update-check";
|
|
18
|
+
import { appendEvent, logQuietly } from "./events";
|
|
18
19
|
import { logDebug, logError } from "./log";
|
|
19
20
|
import { assets, palPkg, paths } from "./paths";
|
|
20
21
|
import { raw as rawSettings } from "./settings";
|
|
@@ -118,8 +119,23 @@ function hasUncommittedChanges(): boolean {
|
|
|
118
119
|
return (status.stdout ?? "").trim().length > 0;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
function outcomeOf(entry: AutoUpdateLedger): string {
|
|
123
|
+
if (!entry.ok) return "failed";
|
|
124
|
+
return entry.from === entry.to ? "unchanged" : "updated";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function secondsBetween(from?: string, to?: string): number | null {
|
|
128
|
+
if (!from || !to) return null;
|
|
129
|
+
return Math.round((Date.parse(to) - Date.parse(from)) / 1000);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function logUpdateEvent(fields: Record<string, unknown>): void {
|
|
133
|
+
logQuietly("pal_update", () => appendEvent("pal_update", fields));
|
|
134
|
+
}
|
|
135
|
+
|
|
121
136
|
function recordSkip(reason: string): void {
|
|
122
137
|
writeLedger({ ...readLedger(), skippedAt: new Date().toISOString(), skipped: reason });
|
|
138
|
+
logUpdateEvent({ outcome: "skipped", skipReason: reason });
|
|
123
139
|
logDebug("auto-update", `skipped: ${reason}`);
|
|
124
140
|
}
|
|
125
141
|
|
|
@@ -278,7 +294,7 @@ function update(): AutoUpdateLedger {
|
|
|
278
294
|
|
|
279
295
|
const ok = run.status === 0;
|
|
280
296
|
logDebug("auto-update", `update exited ${run.status}`);
|
|
281
|
-
|
|
297
|
+
const entry = writeLedger({
|
|
282
298
|
...readLedger(),
|
|
283
299
|
finishedAt: new Date().toISOString(),
|
|
284
300
|
ok,
|
|
@@ -286,6 +302,13 @@ function update(): AutoUpdateLedger {
|
|
|
286
302
|
to: getInstalledVersion(),
|
|
287
303
|
error: ok ? undefined : (run.error?.message ?? `exit ${run.status}`),
|
|
288
304
|
});
|
|
305
|
+
logUpdateEvent({
|
|
306
|
+
outcome: outcomeOf(entry),
|
|
307
|
+
from: entry.from ?? null,
|
|
308
|
+
to: entry.to ?? null,
|
|
309
|
+
durationSec: secondsBetween(entry.attemptedAt, entry.finishedAt),
|
|
310
|
+
});
|
|
311
|
+
return entry;
|
|
289
312
|
}
|
|
290
313
|
|
|
291
314
|
/** Hands the update to its own process, which outlives the session that closed. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A commitment is an ISC: completing it keeps it, retiring it drops it, and a
|
|
3
|
+
* snooze puts it off. Each change of state is one event, whichever surface made it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendEvent, logQuietly } from "./events";
|
|
7
|
+
|
|
8
|
+
export type CommitmentOutcome = "kept" | "dropped" | "snoozed" | "reopened";
|
|
9
|
+
|
|
10
|
+
export function logCommitment(
|
|
11
|
+
iscProject: string,
|
|
12
|
+
isc: number,
|
|
13
|
+
outcome: CommitmentOutcome,
|
|
14
|
+
surface: "cli" | "control-room",
|
|
15
|
+
snoozeDays: number | null = null
|
|
16
|
+
): void {
|
|
17
|
+
logQuietly("commitment", () =>
|
|
18
|
+
appendEvent("commitment", { iscProject, isc, outcome, surface, snoozeDays })
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A nag the Discord bot sent, and how long the user took to answer it. The bot
|
|
3
|
+
* runs outside PAL, so it reports both through `pal cli events discord-nag`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendEvent } from "./events";
|
|
7
|
+
|
|
8
|
+
export const NAG_STAGES = ["sent", "answered"] as const;
|
|
9
|
+
export type NagStage = (typeof NAG_STAGES)[number];
|
|
10
|
+
|
|
11
|
+
export function isNagStage(value: unknown): value is NagStage {
|
|
12
|
+
return typeof value === "string" && (NAG_STAGES as readonly string[]).includes(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function logDiscordNag(
|
|
16
|
+
stage: NagStage,
|
|
17
|
+
kind: string | null,
|
|
18
|
+
responseSec: number | null
|
|
19
|
+
): string | null {
|
|
20
|
+
return appendEvent("discord_nag", {
|
|
21
|
+
stage,
|
|
22
|
+
kind,
|
|
23
|
+
responseSec: stage === "answered" ? responseSec : null,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first time a person, company, idea or research topic enters the knowledge
|
|
3
|
+
* store. The name stays in the content store; the event carries only its kind.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendEvent, logQuietly } from "./events";
|
|
7
|
+
|
|
8
|
+
export function logEntityFirstSeen(
|
|
9
|
+
domain: string,
|
|
10
|
+
entityType: string,
|
|
11
|
+
name: string
|
|
12
|
+
): void {
|
|
13
|
+
logQuietly("entity_first_seen", () =>
|
|
14
|
+
appendEvent("entity_first_seen", { domain, entityType }, { content: { name } })
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -35,13 +35,35 @@ export interface ExtractedEntity {
|
|
|
35
35
|
fact: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface ResearchProfile {
|
|
39
|
+
match: "sure" | "unsure" | "none";
|
|
40
|
+
summary: string;
|
|
41
|
+
role: string;
|
|
42
|
+
organization: string;
|
|
43
|
+
website: string;
|
|
44
|
+
linkedin: string;
|
|
45
|
+
socials: { platform: string; url: string }[];
|
|
46
|
+
registry: {
|
|
47
|
+
name: string;
|
|
48
|
+
number: string;
|
|
49
|
+
taxNumber: string;
|
|
50
|
+
seat: string;
|
|
51
|
+
status: string;
|
|
52
|
+
managers: string[];
|
|
53
|
+
url: string;
|
|
54
|
+
};
|
|
55
|
+
news: { date: string; title: string; url: string }[];
|
|
56
|
+
sources: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
export interface ReviewItem {
|
|
39
60
|
ts: string;
|
|
40
61
|
source: string;
|
|
41
62
|
id: string;
|
|
42
|
-
reason: "ambiguous" | "unknown-existing" | "first-name-only";
|
|
63
|
+
reason: "ambiguous" | "unknown-existing" | "first-name-only" | "web-profile";
|
|
43
64
|
entity: ExtractedEntity;
|
|
44
65
|
candidates: string[];
|
|
66
|
+
profile?: ResearchProfile;
|
|
45
67
|
}
|
|
46
68
|
|
|
47
69
|
export interface ExtractionPlan {
|
|
@@ -49,7 +71,7 @@ export interface ExtractionPlan {
|
|
|
49
71
|
review: ReviewItem[];
|
|
50
72
|
}
|
|
51
73
|
|
|
52
|
-
function closedObject<P extends Record<string, unknown>>(properties: P) {
|
|
74
|
+
export function closedObject<P extends Record<string, unknown>>(properties: P) {
|
|
53
75
|
return {
|
|
54
76
|
type: "object" as const,
|
|
55
77
|
additionalProperties: false,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Researching a person or company on the web: website, LinkedIn, social accounts,
|
|
3
|
+
* the company registry entry and recent news, each with its source. A profile only
|
|
4
|
+
* reaches the store through the review queue.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IngestInput, IngestResult } from "../../tools/knowledge/ingest";
|
|
8
|
+
import {
|
|
9
|
+
closedObject,
|
|
10
|
+
type ExtractedEntity,
|
|
11
|
+
queueForReview,
|
|
12
|
+
type ResearchProfile,
|
|
13
|
+
type ReviewItem,
|
|
14
|
+
} from "./entity-extraction";
|
|
15
|
+
import type { inference } from "./inference";
|
|
16
|
+
import { logDebug, logError } from "./log";
|
|
17
|
+
import { logTokenUsage } from "./token-usage";
|
|
18
|
+
|
|
19
|
+
export interface ResearchTarget {
|
|
20
|
+
kind: ExtractedEntity["kind"];
|
|
21
|
+
name: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
organization: string;
|
|
24
|
+
fact: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResearchDeps {
|
|
28
|
+
principal: string;
|
|
29
|
+
infer: typeof inference;
|
|
30
|
+
today?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_TARGETS = 3;
|
|
34
|
+
const RESEARCH_TIMEOUT_MS = 480_000;
|
|
35
|
+
|
|
36
|
+
function created(written: IngestResult["people"]): Set<string> {
|
|
37
|
+
return new Set(written.filter((w) => w.created).map((w) => w.slug));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function target(
|
|
41
|
+
kind: ResearchTarget["kind"],
|
|
42
|
+
name: string,
|
|
43
|
+
slug: string,
|
|
44
|
+
organization: string | null | undefined,
|
|
45
|
+
fact: string | null | undefined
|
|
46
|
+
): ResearchTarget {
|
|
47
|
+
return { kind, name, slug, organization: organization ?? "", fact: fact ?? "" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** New companies, and new people with an organization to tell namesakes apart by. */
|
|
51
|
+
export function newResearchTargets(
|
|
52
|
+
input: IngestInput,
|
|
53
|
+
written: IngestResult
|
|
54
|
+
): ResearchTarget[] {
|
|
55
|
+
const newCompanies = created(written.companies);
|
|
56
|
+
const newPeople = created(written.people);
|
|
57
|
+
const companies = (input.companies ?? []).flatMap((c, i) => {
|
|
58
|
+
const slug = written.companies[i]?.slug ?? "";
|
|
59
|
+
return newCompanies.has(slug) ? [target("company", c.name, slug, "", c.context)] : [];
|
|
60
|
+
});
|
|
61
|
+
const people = (input.people ?? []).flatMap((p, i) => {
|
|
62
|
+
const slug = written.people[i]?.slug ?? "";
|
|
63
|
+
return newPeople.has(slug) && p.company
|
|
64
|
+
? [target("person", p.name, slug, p.company, p.context)]
|
|
65
|
+
: [];
|
|
66
|
+
});
|
|
67
|
+
return [...companies, ...people].slice(0, MAX_TARGETS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function researchSchema() {
|
|
71
|
+
const text = { type: "string" as const };
|
|
72
|
+
const list = (items: Record<string, unknown>) => ({ type: "array" as const, items });
|
|
73
|
+
return closedObject({
|
|
74
|
+
match: { type: "string" as const, enum: ["sure", "unsure", "none"] },
|
|
75
|
+
summary: text,
|
|
76
|
+
role: text,
|
|
77
|
+
organization: text,
|
|
78
|
+
website: text,
|
|
79
|
+
linkedin: text,
|
|
80
|
+
socials: list(closedObject({ platform: text, url: text })),
|
|
81
|
+
registry: closedObject({
|
|
82
|
+
name: text,
|
|
83
|
+
number: text,
|
|
84
|
+
taxNumber: text,
|
|
85
|
+
seat: text,
|
|
86
|
+
status: text,
|
|
87
|
+
managers: list(text),
|
|
88
|
+
url: text,
|
|
89
|
+
}),
|
|
90
|
+
news: list(closedObject({ date: text, title: text, url: text })),
|
|
91
|
+
sources: list(text),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const CHECKLIST = {
|
|
96
|
+
company: [
|
|
97
|
+
"- website: the official website.",
|
|
98
|
+
"- linkedin: the LinkedIn company page.",
|
|
99
|
+
"- socials: other official accounts (X, Facebook, Instagram, YouTube, GitHub).",
|
|
100
|
+
"- registry: its entry in the official company register of its country: registered name, registration number, tax number, registered seat, status (active, in liquidation…), managing directors, and the URL you read it on. For a Hungarian company (Kft., Zrt., Bt., Nyrt., a .hu site) that is the cégjegyzék: e-cegjegyzek.hu, or public mirrors such as ceginformacio.hu, nemzeticegtar.hu or opten.hu.",
|
|
101
|
+
"- news: up to five news items or public statements from the last 12 months, newest first.",
|
|
102
|
+
"- summary: two or three sentences on what the company does, its size and where it is based.",
|
|
103
|
+
],
|
|
104
|
+
person: [
|
|
105
|
+
"- role and organization: their current position and employer.",
|
|
106
|
+
"- website: their own site, if they have one.",
|
|
107
|
+
"- linkedin: their LinkedIn profile.",
|
|
108
|
+
"- socials: their accounts on X, GitHub, Instagram, YouTube or similar.",
|
|
109
|
+
"- news: up to five recent news items, interviews, talks or public statements from the last 12 months, newest first.",
|
|
110
|
+
"- summary: two or three sentences on who they are and what they work on.",
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function researchSystem(principal: string, kind: ResearchTarget["kind"]): string {
|
|
115
|
+
return [
|
|
116
|
+
`You research a ${kind} from ${principal}'s contact book on the web. Search the web and open pages; do not answer from memory.`,
|
|
117
|
+
"Find:",
|
|
118
|
+
...CHECKLIST[kind],
|
|
119
|
+
"Rules:",
|
|
120
|
+
`- match: "sure" when what you found is clearly this ${kind} (the same name AND it fits what ${principal} said about them), "unsure" when you found namesakes and cannot tell which, "none" when you found nothing. Only "sure" profiles are kept.`,
|
|
121
|
+
"- Only state what a page you opened or a search result shows. Never guess a URL, number or date.",
|
|
122
|
+
"- Every URL must be one you saw. List every page you used in sources.",
|
|
123
|
+
"- Leave a field empty, or a list empty, when you did not find it.",
|
|
124
|
+
"- Dates as YYYY-MM-DD, or YYYY-MM when the day is not given.",
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function researchUser(target: ResearchTarget, today: string): string {
|
|
129
|
+
return [
|
|
130
|
+
`Today is ${today}.`,
|
|
131
|
+
`${target.kind}: ${target.name}`,
|
|
132
|
+
target.organization && `organization: ${target.organization}`,
|
|
133
|
+
target.fact &&
|
|
134
|
+
`what ${target.kind === "person" ? "was said about them" : "was said about it"}: ${target.fact}`,
|
|
135
|
+
]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseProfile(output: string | undefined): ResearchProfile | null {
|
|
141
|
+
try {
|
|
142
|
+
return output ? (JSON.parse(output) as ResearchProfile) : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isWebUrl(url: string): boolean {
|
|
149
|
+
return /^https?:\/\/[^\s]+$/.test(url);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Drops any link that is not a plain http(s) URL, so the store never holds a guess dressed as one. */
|
|
153
|
+
function withWebUrlsOnly(p: ResearchProfile): ResearchProfile {
|
|
154
|
+
const url = (u: string) => (isWebUrl(u) ? u : "");
|
|
155
|
+
return {
|
|
156
|
+
...p,
|
|
157
|
+
website: url(p.website),
|
|
158
|
+
linkedin: url(p.linkedin),
|
|
159
|
+
socials: p.socials.filter((s) => isWebUrl(s.url)),
|
|
160
|
+
registry: { ...p.registry, url: url(p.registry.url) },
|
|
161
|
+
news: p.news.filter((n) => isWebUrl(n.url)),
|
|
162
|
+
sources: p.sources.filter(isWebUrl),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type ResearchOutcome =
|
|
167
|
+
| { found: true; profile: ResearchProfile }
|
|
168
|
+
| { found: false; reason: string };
|
|
169
|
+
|
|
170
|
+
export async function researchEntity(
|
|
171
|
+
target: ResearchTarget,
|
|
172
|
+
deps: ResearchDeps
|
|
173
|
+
): Promise<ResearchOutcome> {
|
|
174
|
+
const result = await deps.infer({
|
|
175
|
+
system: researchSystem(deps.principal, target.kind),
|
|
176
|
+
user: researchUser(target, deps.today ?? new Date().toISOString().slice(0, 10)),
|
|
177
|
+
tier: "medium",
|
|
178
|
+
timeout: RESEARCH_TIMEOUT_MS,
|
|
179
|
+
jsonSchema: researchSchema(),
|
|
180
|
+
caller: "entity-research",
|
|
181
|
+
web: true,
|
|
182
|
+
});
|
|
183
|
+
if (result.usage) logTokenUsage("entity-research", result.usage);
|
|
184
|
+
const profile = result.success ? parseProfile(result.output) : null;
|
|
185
|
+
if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
|
|
186
|
+
if (profile.match !== "sure")
|
|
187
|
+
return { found: false, reason: `match: ${profile.match}. ${profile.summary}`.trim() };
|
|
188
|
+
const checked = withWebUrlsOnly(profile);
|
|
189
|
+
if (checked.sources.length === 0) return { found: false, reason: "no sources given" };
|
|
190
|
+
return { found: true, profile: checked };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function researchItem(
|
|
194
|
+
target: ResearchTarget,
|
|
195
|
+
profile: ResearchProfile
|
|
196
|
+
): ReviewItem {
|
|
197
|
+
return {
|
|
198
|
+
id: crypto.randomUUID().slice(0, 8),
|
|
199
|
+
ts: new Date().toISOString(),
|
|
200
|
+
source: profile.sources[0],
|
|
201
|
+
reason: "web-profile",
|
|
202
|
+
entity: {
|
|
203
|
+
kind: target.kind,
|
|
204
|
+
name: target.name,
|
|
205
|
+
existing: target.slug,
|
|
206
|
+
aliases: [],
|
|
207
|
+
role: profile.role,
|
|
208
|
+
organization: profile.organization || target.organization,
|
|
209
|
+
relation: "",
|
|
210
|
+
fact: profile.summary,
|
|
211
|
+
},
|
|
212
|
+
candidates: target.slug ? [target.slug] : [],
|
|
213
|
+
profile,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function researchOrLog(
|
|
218
|
+
target: ResearchTarget,
|
|
219
|
+
deps: ResearchDeps
|
|
220
|
+
): Promise<ReviewItem[]> {
|
|
221
|
+
try {
|
|
222
|
+
const outcome = await researchEntity(target, deps);
|
|
223
|
+
if (outcome.found) return [researchItem(target, outcome.profile)];
|
|
224
|
+
logDebug("entity-research", `${target.slug}: ${outcome.reason}`);
|
|
225
|
+
return [];
|
|
226
|
+
} catch (err) {
|
|
227
|
+
logError(`entity-research:${target.slug}`, err);
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function researchNewEntities(
|
|
233
|
+
targets: ResearchTarget[],
|
|
234
|
+
deps: ResearchDeps
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const found = (await Promise.all(targets.map((t) => researchOrLog(t, deps)))).flat();
|
|
237
|
+
queueForReview(found);
|
|
238
|
+
logDebug("entity-research", `${found.length} of ${targets.length} found for review`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function registryLines(r: ResearchProfile["registry"]): string[] {
|
|
242
|
+
const fields: Array<[string, string]> = [
|
|
243
|
+
["Registered name", r.name],
|
|
244
|
+
["Registration number", r.number],
|
|
245
|
+
["Tax number", r.taxNumber],
|
|
246
|
+
["Seat", r.seat],
|
|
247
|
+
["Status", r.status],
|
|
248
|
+
["Managing directors", r.managers.join(", ")],
|
|
249
|
+
["Registry entry", r.url],
|
|
250
|
+
];
|
|
251
|
+
return fields.filter(([, v]) => v).map(([k, v]) => `- ${k}: ${v}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function section(title: string, lines: string[]): string[] {
|
|
255
|
+
return lines.length > 0 ? ["", `**${title}**`, ...lines] : [];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The profile as markdown: what the review list shows and what an accepted entry keeps. */
|
|
259
|
+
export function renderProfile(p: ResearchProfile): string {
|
|
260
|
+
const links = [
|
|
261
|
+
p.website && `- Website: ${p.website}`,
|
|
262
|
+
p.linkedin && `- LinkedIn: ${p.linkedin}`,
|
|
263
|
+
...p.socials.map((s) => `- ${s.platform}: ${s.url}`),
|
|
264
|
+
].filter((l): l is string => Boolean(l));
|
|
265
|
+
return [
|
|
266
|
+
p.summary,
|
|
267
|
+
...section("Links", links),
|
|
268
|
+
...section("Registry", registryLines(p.registry)),
|
|
269
|
+
...section(
|
|
270
|
+
"News",
|
|
271
|
+
p.news.map((n) => `- ${n.date} ${n.title} (${n.url})`)
|
|
272
|
+
),
|
|
273
|
+
...section(
|
|
274
|
+
"Sources",
|
|
275
|
+
p.sources.map((s) => `- ${s}`)
|
|
276
|
+
),
|
|
277
|
+
].join("\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function socialKey(platform: string): string {
|
|
281
|
+
return platform.toLowerCase().replace(/[^a-z0-9]+/g, "") || "link";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function hostname(url: string): string | null {
|
|
285
|
+
return url ? new URL(url).hostname.replace(/^www\./, "") : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The accepted profile, written to the entry it was researched for. */
|
|
289
|
+
export function profileIngest(item: ReviewItem, name: string): IngestInput {
|
|
290
|
+
const p = item.profile as ResearchProfile;
|
|
291
|
+
const slug = item.entity.existing || null;
|
|
292
|
+
const context = renderProfile(p);
|
|
293
|
+
if (item.entity.kind === "company")
|
|
294
|
+
return { companies: [{ name, slug, domain: hostname(p.website), context }] };
|
|
295
|
+
const social = Object.fromEntries(
|
|
296
|
+
[
|
|
297
|
+
["website", p.website],
|
|
298
|
+
["linkedin", p.linkedin],
|
|
299
|
+
...p.socials.map((s) => [socialKey(s.platform), s.url]),
|
|
300
|
+
].filter(([, url]) => url)
|
|
301
|
+
);
|
|
302
|
+
return {
|
|
303
|
+
people: [{ name, slug, title: p.role || null, social, context }],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { ingestEntities } from "../../tools/knowledge/ingest";
|
|
7
|
+
import { type IngestInput, ingestEntities } from "../../tools/knowledge/ingest";
|
|
8
8
|
import { loadKnownEntities, loadNameIndex } from "./entity-cards";
|
|
9
9
|
import { ingestInput, type ReviewItem, reviewQueueFile } from "./entity-extraction";
|
|
10
10
|
import { indexedEntities, type NamedEntity, type NameIndex } from "./entity-names";
|
|
11
|
+
import { profileIngest } from "./entity-research";
|
|
11
12
|
|
|
12
13
|
export interface AcceptChoice {
|
|
13
14
|
as?: string;
|
|
@@ -68,6 +69,22 @@ export function loadEntityReviewNudge(): string {
|
|
|
68
69
|
].join("\n");
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function acceptedInput(
|
|
73
|
+
item: ReviewItem,
|
|
74
|
+
choice: AcceptChoice,
|
|
75
|
+
index: NameIndex
|
|
76
|
+
): IngestInput {
|
|
77
|
+
if (!item.profile) {
|
|
78
|
+
const known = chosenEntity(item, choice.as, index);
|
|
79
|
+
return ingestInput(item.entity, index, known, choice.name);
|
|
80
|
+
}
|
|
81
|
+
const target = chosenEntity(item, choice.as ?? item.entity.existing, index);
|
|
82
|
+
return profileIngest(
|
|
83
|
+
{ ...item, entity: { ...item.entity, existing: target?.slug ?? "" } },
|
|
84
|
+
target?.title ?? choice.name ?? item.entity.name
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
71
88
|
export function rejectReview(id: string): ReviewItem | null {
|
|
72
89
|
return takeFromQueue(id);
|
|
73
90
|
}
|
|
@@ -77,11 +94,7 @@ export function acceptReview(id: string, choice: AcceptChoice = {}): Accepted |
|
|
|
77
94
|
const queued = pendingReviews().find((i) => i.id === id);
|
|
78
95
|
if (!queued) return null;
|
|
79
96
|
const index = loadNameIndex(loadKnownEntities());
|
|
80
|
-
const
|
|
81
|
-
const result = ingestEntities(
|
|
82
|
-
ingestInput(queued.entity, index, known, choice.name),
|
|
83
|
-
queued.source
|
|
84
|
-
);
|
|
97
|
+
const result = ingestEntities(acceptedInput(queued, choice, index), queued.source);
|
|
85
98
|
takeFromQueue(id);
|
|
86
99
|
const written = result.people[0] ?? result.companies[0];
|
|
87
100
|
return { item: queued, slug: written.slug, created: written.created };
|