@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 +109 -0
- package/dist/agent-auth-storage.js +111 -0
- package/dist/cli.js +3 -0
- package/dist/extension/index.js +10 -0
- package/dist/extension/pi-types.js +1 -0
- package/dist/features/agent-auth/register.js +69 -0
- package/dist/features/branding/register.js +25 -0
- package/dist/features/onboarding/questionnaire.v1.json +89 -0
- package/dist/features/onboarding/register.js +78 -0
- package/dist/features/submit-deal-pdf/interpretation.js +88 -0
- package/dist/features/submit-deal-pdf/local-pdf.js +91 -0
- package/dist/features/submit-deal-pdf/pipeline.js +74 -0
- package/dist/features/submit-deal-pdf/questionnaire.v1.json +47 -0
- package/dist/features/submit-deal-pdf/register.js +100 -0
- package/dist/features/submit-deal-pdf/types.js +3 -0
- package/dist/features/submit-deal-pdf/upload-client.js +69 -0
- package/dist/onboarding-extension.js +1 -0
- package/dist/pi.js +60 -0
- package/dist/seed-agent-auth.js +211 -0
- package/dist/store.js +92 -0
- package/dist/update-check.js +131 -0
- package/dist/wizard.js +116 -0
- package/package.json +39 -0
- package/prompts/SYSTEM.md +12 -0
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,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
|
+
}
|