@connormartin/seed-network-agent 0.1.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 ADDED
@@ -0,0 +1,109 @@
1
+ # seed-network-agent
2
+
3
+ Seed Network Agent is a thin Seed Network wrapper around upstream Pi.
4
+
5
+ We do not fork Pi core here.
6
+
7
+ ## Boundary
8
+
9
+ Seed-owned behavior:
10
+ - `prompts/*`
11
+ - `src/extension/*` — Pi extension entrypoint and shared extension types
12
+ - `src/features/*` — feature modules registered by the extension
13
+ - `src/wizard.ts`
14
+ - `src/store.ts`
15
+
16
+ Pi boundary:
17
+ - `src/pi.ts`
18
+
19
+ Only `src/pi.ts` should import or execute Pi directly. Feature modules are normal upstream Pi extension code registered through `src/extension/index.ts`.
20
+
21
+ ## Feature layout
22
+
23
+ Features should be easy to find and remove. Add new Seed Agent behavior under `src/features/<feature-name>/`, then register it from `src/extension/index.ts`.
24
+
25
+ Current features:
26
+ - `features/branding` — Seed header/title customization.
27
+ - `features/onboarding` — `/onboarding` command and onboarding questionnaire.
28
+ - `features/agent-auth` — `/seed` Agent Auth management commands.
29
+ - `features/submit-deal-pdf` — `/submit-deal` PDF intake, local extraction, model interpretation, upload, and capability execution.
30
+
31
+ Pi supports this directory-extension shape directly: a multi-file extension is an `index.ts` entrypoint plus helper modules.
32
+
33
+ ## What we changed
34
+
35
+ 1. Start Pi with Seed Network Agent resources loaded.
36
+ 2. Register Seed feature modules from `src/extension/index.ts`.
37
+ 3. Add a deterministic onboarding wizard.
38
+ 4. Load versioned feature questionnaires from each feature directory.
39
+ 5. Save onboarding answers locally through `src/store.ts`.
40
+ 6. Add `/submit-deal` for local PDF deal intake.
41
+
42
+ ## What we do not change
43
+
44
+ - Pi model/provider logic
45
+ - Pi auth behavior
46
+ - Pi session engine
47
+ - Pi tool execution internals
48
+ - Pi TUI internals
49
+ - Pi package manager
50
+
51
+ ## Install
52
+
53
+ Install the published CLI globally:
54
+
55
+ ```bash
56
+ npm install -g @connormartin/seed-network-agent
57
+ seed
58
+ ```
59
+
60
+ Or run the latest version without a global install:
61
+
62
+ ```bash
63
+ npx @connormartin/seed-network-agent@latest
64
+ ```
65
+
66
+ The CLI checks npm for updates at startup at most once per day and prints the exact update command when a newer version is available. Disable this with `SEED_NETWORK_AGENT_NO_UPDATE_CHECK=1`.
67
+
68
+ ## Run locally
69
+
70
+ From the repository root:
71
+
72
+ ```bash
73
+ pnpm agent
74
+ ```
75
+
76
+ Build the publishable CLI from `apps/agent`:
77
+
78
+ ```bash
79
+ pnpm --filter @connormartin/seed-network-agent build
80
+ pnpm --filter @connormartin/seed-network-agent start
81
+ ```
82
+
83
+ Run `/onboarding` inside the agent to start the onboarding wizard.
84
+
85
+ Run `/submit-deal` inside the agent to create a Seed Network deal from a local pitch deck PDF and the first intake questions.
86
+
87
+ If you need the previous startup behavior, set `SEED_NETWORK_AGENT_AUTOSTART_ONBOARDING=true` before launching the agent.
88
+
89
+ You can forward normal Pi flags through the wrapper:
90
+
91
+ ```bash
92
+ pnpm agent -- --model anthropic/claude-sonnet-4-5
93
+ ```
94
+
95
+ Local onboarding progress is written under `~/.seed-network-agent/onboarding/`.
96
+
97
+ ## Seed Agent Auth
98
+
99
+ The wrapper can connect to the Seed Network web app as an Agent Auth client:
100
+
101
+ ```bash
102
+ /seed connect [provider-url]
103
+ /seed status
104
+ /seed sync-onboarding
105
+ /seed disconnect
106
+ ```
107
+
108
+ By default the provider URL is `SEED_NETWORK_AGENT_AUTH_PROVIDER` or `http://localhost:3000`.
109
+ Agent Auth identity and grants are stored locally under `~/.seed-network-agent/auth/`.
@@ -0,0 +1,111 @@
1
+ import { chmod, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export class SeedAgentAuthStorage {
5
+ root;
6
+ agentsDir;
7
+ providersDir;
8
+ constructor(baseDir = path.join(os.homedir(), ".seed-network-agent", "auth")) {
9
+ this.root = baseDir;
10
+ this.agentsDir = path.join(this.root, "agents");
11
+ this.providersDir = path.join(this.root, "providers");
12
+ }
13
+ async getHostIdentity() {
14
+ await ensurePrivateDir(this.root);
15
+ return readJson(path.join(this.root, "host.json"));
16
+ }
17
+ async setHostIdentity(host) {
18
+ await ensurePrivateDir(this.root);
19
+ await writeJsonAtomic(path.join(this.root, "host.json"), host);
20
+ }
21
+ async deleteHostIdentity() {
22
+ await rm(path.join(this.root, "host.json"), { force: true });
23
+ }
24
+ async getAgentConnection(agentId) {
25
+ await ensurePrivateDir(this.root);
26
+ await ensurePrivateDir(this.agentsDir);
27
+ return readJson(this.agentPath(agentId));
28
+ }
29
+ async setAgentConnection(agentId, conn) {
30
+ await ensurePrivateDir(this.root);
31
+ await ensurePrivateDir(this.agentsDir);
32
+ await writeJsonAtomic(this.agentPath(agentId), conn);
33
+ }
34
+ async deleteAgentConnection(agentId) {
35
+ await rm(this.agentPath(agentId), { force: true });
36
+ }
37
+ async listAgentConnections() {
38
+ await ensurePrivateDir(this.root);
39
+ await ensurePrivateDir(this.agentsDir);
40
+ return readJsonDir(this.agentsDir);
41
+ }
42
+ async getProviderConfig(issuer) {
43
+ await ensurePrivateDir(this.root);
44
+ await ensurePrivateDir(this.providersDir);
45
+ return readJson(this.providerPath(issuer));
46
+ }
47
+ async setProviderConfig(issuer, config) {
48
+ await ensurePrivateDir(this.root);
49
+ await ensurePrivateDir(this.providersDir);
50
+ await writeJsonAtomic(this.providerPath(issuer), config);
51
+ }
52
+ async listProviderConfigs() {
53
+ await ensurePrivateDir(this.root);
54
+ await ensurePrivateDir(this.providersDir);
55
+ return readJsonDir(this.providersDir);
56
+ }
57
+ agentPath(agentId) {
58
+ return path.join(this.agentsDir, `${safeFileName(agentId)}.json`);
59
+ }
60
+ providerPath(issuer) {
61
+ return path.join(this.providersDir, `${safeFileName(issuer)}.json`);
62
+ }
63
+ }
64
+ async function readJson(filePath) {
65
+ try {
66
+ const raw = await readFile(filePath, "utf8");
67
+ await chmod(filePath, 0o600);
68
+ return JSON.parse(raw);
69
+ }
70
+ catch (error) {
71
+ if (isNodeError(error) && error.code === "ENOENT")
72
+ return null;
73
+ throw error;
74
+ }
75
+ }
76
+ async function readJsonDir(dirPath) {
77
+ try {
78
+ const entries = await readdir(dirPath, { withFileTypes: true });
79
+ const values = [];
80
+ for (const entry of entries) {
81
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
82
+ continue;
83
+ const value = await readJson(path.join(dirPath, entry.name));
84
+ if (value !== null)
85
+ values.push(value);
86
+ }
87
+ return values;
88
+ }
89
+ catch (error) {
90
+ if (isNodeError(error) && error.code === "ENOENT")
91
+ return [];
92
+ throw error;
93
+ }
94
+ }
95
+ async function writeJsonAtomic(filePath, value) {
96
+ await ensurePrivateDir(path.dirname(filePath));
97
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
98
+ await writeFile(tmpPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
99
+ await chmod(tmpPath, 0o600);
100
+ await rename(tmpPath, filePath);
101
+ }
102
+ async function ensurePrivateDir(dirPath) {
103
+ await mkdir(dirPath, { recursive: true, mode: 0o700 });
104
+ await chmod(dirPath, 0o700);
105
+ }
106
+ function safeFileName(value) {
107
+ return Buffer.from(value).toString("base64url");
108
+ }
109
+ function isNodeError(error) {
110
+ return typeof error === "object" && error !== null && "code" in error;
111
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runPi } from "./pi.js";
3
+ await runPi(process.argv.slice(2));
@@ -0,0 +1,10 @@
1
+ import { registerAgentAuthCommands } from "../features/agent-auth/register.js";
2
+ import { registerBranding } from "../features/branding/register.js";
3
+ import { registerOnboarding } from "../features/onboarding/register.js";
4
+ import { registerSubmitDealPdf } from "../features/submit-deal-pdf/register.js";
5
+ export default function seedNetworkExtension(pi) {
6
+ registerBranding(pi);
7
+ registerOnboarding(pi);
8
+ registerSubmitDealPdf(pi);
9
+ registerAgentAuthCommands(pi);
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { connectSeedAgent, DEFAULT_PROVIDER_URL, disconnectSeedAgents, listSeedAgentConnections, syncOnboardingSubmission, } from "../../seed-agent-auth.js";
2
+ import { OnboardingStore } from "../../store.js";
3
+ export function registerAgentAuthCommands(pi) {
4
+ pi.registerCommand("seed", {
5
+ description: "Manage Seed Network Agent Auth: connect, status, sync-onboarding, disconnect",
6
+ getArgumentCompletions: completeSeedCommand,
7
+ handler: async (args, ctx) => {
8
+ if (ctx.hasUI === false) {
9
+ throw new Error("/seed requires interactive Pi UI.");
10
+ }
11
+ await handleSeedCommand(args, ctx);
12
+ },
13
+ });
14
+ }
15
+ function completeSeedCommand(prefix) {
16
+ const commands = [
17
+ { value: "connect", label: "connect", description: "Connect this local agent to Seed Network" },
18
+ { value: "status", label: "status", description: "Show saved Agent Auth connections and grants" },
19
+ { value: "sync-onboarding", label: "sync-onboarding", description: "Upload the latest local onboarding submission" },
20
+ { value: "disconnect", label: "disconnect", description: "Revoke and remove local Agent Auth connections" },
21
+ ];
22
+ const firstToken = prefix.trimStart().split(/\s+/, 1)[0] ?? "";
23
+ const filtered = commands.filter((command) => command.value.startsWith(firstToken));
24
+ return filtered.length > 0 ? filtered : null;
25
+ }
26
+ async function handleSeedCommand(args, ctx) {
27
+ const [subcommand = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
28
+ switch (subcommand) {
29
+ case "connect": {
30
+ const providerUrl = rest[0] ?? DEFAULT_PROVIDER_URL;
31
+ ctx.ui.notify(`Connecting Seed Network Agent to ${providerUrl}…`, "info");
32
+ const result = await connectSeedAgent(providerUrl, ctx.ui);
33
+ ctx.ui.notify(`Seed Network Agent connected: ${result.agentId} (${result.status}).`, "info");
34
+ return;
35
+ }
36
+ case "status": {
37
+ const connections = await listSeedAgentConnections();
38
+ if (connections.length === 0) {
39
+ ctx.ui.notify(`No Seed Network Agent Auth connection. Run /seed connect ${DEFAULT_PROVIDER_URL}`, "warning");
40
+ return;
41
+ }
42
+ const lines = connections.map((connection) => {
43
+ const grants = connection.capabilityGrants
44
+ .map((grant) => `${grant.capability}:${grant.status}`)
45
+ .join(", ");
46
+ return `${connection.providerName} ${connection.agentId} (${connection.mode}) — ${grants || "no grants"}`;
47
+ });
48
+ ctx.ui.notify(lines.join("\n"), "info");
49
+ return;
50
+ }
51
+ case "sync-onboarding": {
52
+ const submission = await new OnboardingStore().loadLatestSubmission();
53
+ if (!submission) {
54
+ ctx.ui.notify("No completed onboarding submission found. Run /onboarding first.", "warning");
55
+ return;
56
+ }
57
+ const result = await syncOnboardingSubmission(submission, ctx.ui);
58
+ ctx.ui.notify(`Seed Network onboarding synced: ${JSON.stringify(result.data ?? result)}`, "info");
59
+ return;
60
+ }
61
+ case "disconnect": {
62
+ const count = await disconnectSeedAgents(ctx.ui);
63
+ ctx.ui.notify(`Disconnected ${count} Seed Network Agent connection${count === 1 ? "" : "s"}.`, "info");
64
+ return;
65
+ }
66
+ default:
67
+ ctx.ui.notify("Unknown /seed command. Use: /seed connect [provider-url], /seed status, /seed sync-onboarding, /seed disconnect", "warning");
68
+ }
69
+ }
@@ -0,0 +1,25 @@
1
+ export function registerBranding(pi) {
2
+ pi.on("session_start", (_event, ctx) => {
3
+ installSeedBranding(ctx);
4
+ });
5
+ }
6
+ function installSeedBranding(ctx) {
7
+ ctx.ui.setTitle?.("The Seed Network");
8
+ ctx.ui.setHeader?.((_tui, theme) => ({
9
+ render(width) {
10
+ const rule = "─".repeat(Math.max(0, Math.min(width, 72)));
11
+ const accent = (value) => theme.fg("accent", value);
12
+ const muted = (value) => theme.fg("muted", value);
13
+ const dim = (value) => theme.fg("dim", value);
14
+ return [
15
+ "",
16
+ accent(rule),
17
+ `${accent("🌱 ")}${theme.bold("Seed Network Agent")}`,
18
+ dim("Run /onboarding to start onboarding, or /submit-deal to submit a company."),
19
+ accent(rule),
20
+ "",
21
+ ];
22
+ },
23
+ invalidate() { },
24
+ }));
25
+ }
@@ -0,0 +1,89 @@
1
+ {
2
+ "version": "onboarding.v1",
3
+ "title": "Seed Network onboarding",
4
+ "description": "A short local-only onboarding flow for Seed Network Agent.",
5
+ "questions": [
6
+ {
7
+ "id": "name",
8
+ "label": "Name",
9
+ "type": "text",
10
+ "required": true,
11
+ "prompt": "What should Seed Network Agent call you?",
12
+ "placeholder": "Your name"
13
+ },
14
+ {
15
+ "id": "role",
16
+ "label": "Role",
17
+ "type": "single_select",
18
+ "required": true,
19
+ "prompt": "Which role best describes you right now?",
20
+ "options": [
21
+ {
22
+ "value": "founder",
23
+ "label": "Founder"
24
+ },
25
+ {
26
+ "value": "investor",
27
+ "label": "Investor"
28
+ },
29
+ {
30
+ "value": "operator",
31
+ "label": "Operator"
32
+ },
33
+ {
34
+ "value": "builder",
35
+ "label": "Builder"
36
+ },
37
+ {
38
+ "value": "other",
39
+ "label": "Other"
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "id": "primary_goal",
45
+ "label": "Goal",
46
+ "type": "text",
47
+ "required": true,
48
+ "prompt": "What is the main outcome you want from Seed Network Agent?",
49
+ "placeholder": "e.g. track deal flow, manage investor follow-ups, prepare intro requests"
50
+ },
51
+ {
52
+ "id": "workflow_focus",
53
+ "label": "Focus",
54
+ "type": "single_select",
55
+ "required": true,
56
+ "prompt": "Which workflow should we optimize around first?",
57
+ "options": [
58
+ {
59
+ "value": "telegram_triage",
60
+ "label": "Telegram triage"
61
+ },
62
+ {
63
+ "value": "intro_requests",
64
+ "label": "Intro requests"
65
+ },
66
+ {
67
+ "value": "deal_intake",
68
+ "label": "Deal intake"
69
+ },
70
+ {
71
+ "value": "meeting_followups",
72
+ "label": "Meeting follow-ups"
73
+ },
74
+ {
75
+ "value": "other",
76
+ "label": "Something else"
77
+ }
78
+ ]
79
+ },
80
+ {
81
+ "id": "notes",
82
+ "label": "Notes",
83
+ "type": "text",
84
+ "required": false,
85
+ "prompt": "Anything else we should know for this local onboarding pass?",
86
+ "placeholder": "Optional"
87
+ }
88
+ ]
89
+ }
@@ -0,0 +1,78 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { getPreferredConnection, syncOnboardingSubmission } from "../../seed-agent-auth.js";
5
+ import { OnboardingStore } from "../../store.js";
6
+ import { runOnboardingWizard } from "../../wizard.js";
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const onboardingQuestionsPath = path.join(__dirname, "questionnaire.v1.json");
10
+ let autoStarted = false;
11
+ export function registerOnboarding(pi) {
12
+ pi.on("session_start", async (event, ctx) => {
13
+ if (event.reason !== "startup")
14
+ return;
15
+ if (process.env.SEED_NETWORK_AGENT_AUTOSTART_ONBOARDING !== "true")
16
+ return;
17
+ if (ctx.hasUI === false)
18
+ return;
19
+ if (autoStarted)
20
+ return;
21
+ autoStarted = true;
22
+ await startOnboarding(ctx, "auto");
23
+ });
24
+ pi.registerCommand("onboarding", {
25
+ description: "Start the Seed Network deterministic local onboarding wizard",
26
+ handler: async (_args, ctx) => {
27
+ if (ctx.hasUI === false) {
28
+ throw new Error("/onboarding requires interactive Pi UI.");
29
+ }
30
+ await startOnboarding(ctx, "manual");
31
+ },
32
+ });
33
+ }
34
+ async function startOnboarding(ctx, source) {
35
+ const questionnaire = await loadQuestionnaire();
36
+ const store = new OnboardingStore();
37
+ const progress = await store.loadProgress();
38
+ let initialAnswers;
39
+ if (progress?.questionnaireVersion === questionnaire.version) {
40
+ const answerCount = Object.keys(progress.answers).length;
41
+ const resume = await ctx.ui.confirm("Resume onboarding?", `Found ${answerCount} saved answer${answerCount === 1 ? "" : "s"} from ${progress.updatedAt}.\n\nResume from local progress?`);
42
+ if (resume)
43
+ initialAnswers = progress.answers;
44
+ }
45
+ const result = await runOnboardingWizard(ctx.ui, questionnaire, {
46
+ initialAnswers,
47
+ onAnswer: (question, answer) => store.recordAnswer(questionnaire.version, question, answer),
48
+ });
49
+ if (result.cancelled || !result.submission) {
50
+ const prefix = source === "auto" ? "Startup onboarding" : "Seed Network onboarding";
51
+ ctx.ui.notify(`${prefix} cancelled. Progress is saved locally.`, "warning");
52
+ return;
53
+ }
54
+ const submissionPath = await store.saveSubmission(result.submission);
55
+ ctx.ui.notify(`Seed Network onboarding saved locally: ${submissionPath}`, "info");
56
+ const connection = await getPreferredConnection();
57
+ if (!connection) {
58
+ ctx.ui.notify("Run /seed connect to sync this onboarding to Seed Network when ready.", "info");
59
+ return;
60
+ }
61
+ const shouldSync = await ctx.ui.confirm("Sync onboarding?", "Seed Network Agent is connected. Upload this local onboarding submission to your Seed Network account now?");
62
+ if (!shouldSync)
63
+ return;
64
+ try {
65
+ await syncOnboardingSubmission(result.submission, ctx.ui);
66
+ ctx.ui.notify("Seed Network onboarding synced.", "info");
67
+ }
68
+ catch (error) {
69
+ ctx.ui.notify(`Seed Network onboarding sync failed: ${errorMessage(error)}`, "error");
70
+ }
71
+ }
72
+ async function loadQuestionnaire(filePath = onboardingQuestionsPath) {
73
+ const raw = await readFile(filePath, "utf8");
74
+ return JSON.parse(raw);
75
+ }
76
+ function errorMessage(error) {
77
+ return error instanceof Error ? error.message : String(error);
78
+ }
@@ -0,0 +1,88 @@
1
+ import { MIN_EXTRACTED_TEXT_CHARS } from "./types.js";
2
+ export async function interpretDealText(text, interpreter) {
3
+ const trimmed = truncate(text.trim(), 24_000);
4
+ const warnings = [];
5
+ if (trimmed.length < MIN_EXTRACTED_TEXT_CHARS) {
6
+ return {
7
+ confidence: "low",
8
+ warnings: ["Skipped interpretation because extracted PDF text was insufficient."],
9
+ };
10
+ }
11
+ if (interpreter) {
12
+ try {
13
+ return sanitizeInterpretation(await interpreter(trimmed));
14
+ }
15
+ catch (error) {
16
+ warnings.push(`Pi model interpretation failed: ${errorMessage(error)}`);
17
+ }
18
+ }
19
+ else {
20
+ warnings.push("Pi model interpretation skipped: no active Pi model was available to the command.");
21
+ }
22
+ return sanitizeInterpretation({
23
+ ...heuristicInterpretation(trimmed),
24
+ confidence: "low",
25
+ warnings,
26
+ });
27
+ }
28
+ function heuristicInterpretation(text) {
29
+ const email = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0];
30
+ const links = Array.from(new Set(text.match(/https?:\/\/[^\s)\]}>\"]+/gi) ?? []));
31
+ const website = links.find((link) => !/(twitter|x\.com|linkedin|crunchbase|instagram|facebook)\./i.test(link));
32
+ const socialLinks = links
33
+ .filter((link) => /(twitter|x\.com|linkedin|crunchbase|instagram|facebook)\./i.test(link))
34
+ .slice(0, 8)
35
+ .join("\n");
36
+ return {
37
+ ...(website ? { website } : {}),
38
+ ...(email ? { email } : {}),
39
+ ...(socialLinks ? { socialLinks } : {}),
40
+ };
41
+ }
42
+ function sanitizeInterpretation(value) {
43
+ const warnings = [];
44
+ if (!isRecord(value))
45
+ return { confidence: "low", warnings: ["Interpretation did not return an object."] };
46
+ const confidence = parseConfidence(value.confidence);
47
+ const interpretedWarnings = Array.isArray(value.warnings)
48
+ ? value.warnings.filter((item) => typeof item === "string" && item.trim().length > 0)
49
+ : [];
50
+ return {
51
+ ...optionalString(value, "companyName", 160),
52
+ ...optionalString(value, "summary", 1000),
53
+ ...optionalString(value, "founderNames", 500),
54
+ ...optionalString(value, "website", 500),
55
+ ...optionalString(value, "email", 320),
56
+ ...optionalString(value, "socialLinks", 2000),
57
+ ...optionalNumber(value, "valuationUsd"),
58
+ ...optionalNumber(value, "totalAllocationUsd"),
59
+ ...optionalString(value, "terms", 2000),
60
+ confidence,
61
+ warnings: [...interpretedWarnings, ...warnings],
62
+ };
63
+ }
64
+ function optionalString(record, field, maxLength) {
65
+ const value = record[field];
66
+ if (typeof value !== "string")
67
+ return {};
68
+ const trimmed = truncate(value.trim(), maxLength);
69
+ return trimmed ? { [field]: trimmed } : {};
70
+ }
71
+ function optionalNumber(record, field) {
72
+ const value = record[field];
73
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
74
+ return {};
75
+ return { [field]: Math.round(value * 100) / 100 };
76
+ }
77
+ function parseConfidence(value) {
78
+ return value === "high" || value === "medium" || value === "low" ? value : "low";
79
+ }
80
+ function truncate(value, maxLength) {
81
+ return value.length <= maxLength ? value : value.slice(0, maxLength);
82
+ }
83
+ function isRecord(value) {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+ function errorMessage(error) {
87
+ return error instanceof Error ? error.message : String(error);
88
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { PDFParse } from "pdf-parse";
6
+ import { MAX_DEAL_DECK_BYTES, MIN_EXTRACTED_TEXT_CHARS, } from "./types.js";
7
+ export async function validateLocalPdf(inputPath) {
8
+ const resolvedPath = resolveLocalPath(inputPath);
9
+ const fileName = path.basename(resolvedPath);
10
+ const warnings = [];
11
+ if (!fileName.toLowerCase().endsWith(".pdf")) {
12
+ warnings.push("File extension is not .pdf, but magic bytes will be checked.");
13
+ }
14
+ let fileStat;
15
+ try {
16
+ fileStat = await stat(resolvedPath);
17
+ }
18
+ catch (error) {
19
+ throw new Error(`PDF file does not exist or is not readable: ${resolvedPath}`, { cause: error });
20
+ }
21
+ if (!fileStat.isFile())
22
+ throw new Error(`PDF path is not a regular file: ${resolvedPath}`);
23
+ if (fileStat.size <= 0)
24
+ throw new Error("PDF file is empty.");
25
+ if (fileStat.size > MAX_DEAL_DECK_BYTES)
26
+ throw new Error("PDF file exceeds 25MB.");
27
+ const buffer = await readFile(resolvedPath);
28
+ if (buffer.subarray(0, 5).toString("utf8") !== "%PDF-") {
29
+ throw new Error("File is not a PDF: missing %PDF- magic bytes.");
30
+ }
31
+ return {
32
+ path: resolvedPath,
33
+ fileName,
34
+ size: fileStat.size,
35
+ sha256: createHash("sha256").update(buffer).digest("hex"),
36
+ buffer,
37
+ warnings,
38
+ };
39
+ }
40
+ export async function extractPdfText(buffer) {
41
+ const warnings = [];
42
+ const extractedAt = new Date().toISOString();
43
+ try {
44
+ const parser = new PDFParse({ data: buffer });
45
+ try {
46
+ const result = await parser.getText();
47
+ const text = normalizeExtractedText(result.text ?? "");
48
+ const pageCount = typeof result.total === "number" ? result.total : undefined;
49
+ if (text.length < MIN_EXTRACTED_TEXT_CHARS) {
50
+ warnings.push("PDF text extraction returned very little text; the deck may be scanned/image-only.");
51
+ }
52
+ return {
53
+ parserVersion: "pdf-parse@2",
54
+ extractedAt,
55
+ text,
56
+ ...(pageCount ? { pageCount } : {}),
57
+ warnings,
58
+ };
59
+ }
60
+ finally {
61
+ await parser.destroy();
62
+ }
63
+ }
64
+ catch (error) {
65
+ warnings.push(`PDF text extraction failed: ${errorMessage(error)}`);
66
+ return {
67
+ parserVersion: "pdf-parse@2",
68
+ extractedAt,
69
+ text: "",
70
+ warnings,
71
+ };
72
+ }
73
+ }
74
+ function normalizeExtractedText(text) {
75
+ return text
76
+ .replace(/\u0000/g, "")
77
+ .replace(/[ \t]+\n/g, "\n")
78
+ .replace(/\n{4,}/g, "\n\n\n")
79
+ .trim();
80
+ }
81
+ function resolveLocalPath(inputPath) {
82
+ const stripped = inputPath.trim().replace(/^@/, "").replace(/^["']|["']$/g, "");
83
+ const unescaped = stripped.replace(/\\([ \\()&'\"\[\]{}!$`*?;<>|#~])/g, "$1");
84
+ const expanded = unescaped === "~" || unescaped.startsWith(`~${path.sep}`)
85
+ ? path.join(os.homedir(), unescaped.slice(2))
86
+ : unescaped;
87
+ return path.resolve(expanded);
88
+ }
89
+ function errorMessage(error) {
90
+ return error instanceof Error ? error.message : String(error);
91
+ }