Skip to content
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ Running work log for this integration: [`WORKLOG.md`](WORKLOG.md).

**[corpuslaw.us/autopilot](https://corpuslaw.us/autopilot)** — the Formation
Autopilot from this repo (`autopilot/`), running on Alibaba Cloud Function
Compute. Describe a business in plain English; get a cited launch checklist and
a prefilled formation handoff.
Compute. Describe a business in plain English; get a launch checklist linked to
reviewed guides and the formation catalog, with unresolved requirements marked
Needs checking, and a prefilled formation handoff. Broad keyword law matches are
withheld until their relevance can be verified.

### 3. Drop a skill folder — or install a plugin

Expand Down Expand Up @@ -167,7 +169,8 @@ jurisdiction is not covered rather than guessing.
## What's in this repo

- **`autopilot/`** — Formation Autopilot: a Qwen Cloud agent that turns an
ambiguous founder description into a cited launch checklist and a prefilled,
ambiguous founder description into a launch checklist with guide and catalog
sources and a prefilled,
human-approved formation handoff. *(Qwen Cloud Hackathon Track 4 winner.)*
- **`mcp-server/`** — zero-dependency stdio MCP server, for clients that cannot
speak HTTP MCP.
Expand Down Expand Up @@ -205,7 +208,7 @@ See [docs/architecture.md](docs/architecture.md) for the Mermaid source.

## Two-lane model routing

The autopilot uses two Qwen models with different cost/quality profiles:
Legacy `/api/chat` uses two Qwen models with different cost/quality profiles:

- **Fast lane** (`QWEN_MODEL_FAST`, default `qwen-flash-2025-07-28`): all standard turns —
intent parsing, law search, checklist generation. Low latency, low spend.
Expand All @@ -215,6 +218,8 @@ The autopilot uses two Qwen models with different cost/quality profiles:

The lane switch is automatic. Routing logic lives in `autopilot/src/agent.ts`.
The hourly spend cap (`SPEND_CAP_TURNS_PER_HOUR`) applies across both lanes.
The sourced `/api/plan` path builds deterministic guide/catalog items and uses
the fast lane only for optional ordering; model output cannot add legal sources.

## Human in the loop (gates)

Expand Down Expand Up @@ -287,6 +292,8 @@ The server exposes:

- `GET /healthz` — liveness probe
- `POST /api/chat` — agent turn endpoint
- `POST /api/plan` — planning with guide and catalog sources; unresolved law remains Needs checking
- `POST /api/plan/handoff` — validate a ready-mode draft through the hosted handoff
- `GET /` — chat UI

Deploy to Alibaba Cloud Function Compute: see
Expand Down
47 changes: 47 additions & 0 deletions autopilot/src/plan-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/** Wire contract shared with corpus-agent-kit /api/plan, version 1. */
export type PlanningMode = "exploring" | "ready" | "formed";
export type EntityChoice = "llc" | "nonprofit" | null;
export type Campaign = Partial<Record<"utm_source" | "utm_medium" | "utm_campaign" | "utm_content" | "utm_term", string>>;
export interface PlanInput {
mode: PlanningMode;
activity: string;
operatingState: string | null;
operatingCity: string;
formationState: string | null;
entityType: EntityChoice;
campaign: Campaign;
}
export interface PlanSource {
id: string;
kind: "law" | "catalog" | "guide";
label: string;
url: string;
excerpt: string;
nodeId?: string;
}
export interface PlanItem {
id: string;
category: "entity" | "registered_agent" | "ein" | "annual_report" | "state_tax" | "local";
title: string;
body: string;
sourceIds: string[];
needsCheck: boolean;
}
export interface PlanResponse {
version: 1;
createdAt: string;
items: PlanItem[];
sources: PlanSource[];
pricing: { available: boolean; state: string | null; lines: string[]; checkedAt: string };
tools: string[];
warnings: string[];
}
export type ItemProgress = "todo" | "done" | "not_applicable" | "needs_check";
export interface PlanHandoff { url: string; missing: string[] }
export interface ServiceOffer {
id: "annual_report" | "renewal_bundle" | "registered_agent";
title: string;
available: boolean;
description: string;
href: string;
}
72 changes: 72 additions & 0 deletions autopilot/src/plan-evidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { PlanSource } from "./plan-contract.js";
import { record, STATES } from "./plan-validation.js";

export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const clean = (s: string) => s.trim().toLowerCase();
export function lawSource(nodeId: string, label: string, excerpt: string): PlanSource {
return { id: `law:${nodeId.toLowerCase()}`, kind: "law", nodeId: nodeId.toLowerCase(), label,
// Inspected app/code/[...path]/page.tsx accepts the stable UUID URL.
url: `https://corpuslaw.us/code/${nodeId.toLowerCase()}`, excerpt };
}

/** Parse only the actual numbered MCP transcript; never harvest arbitrary UUIDs/URLs. */
export function parseSearch(text: string, jurisdictionName: string): PlanSource[] {
if (!/^\d+ of \d+ results for /m.test(text)) return [];
const blocks = text.split(/\n\n/);
const result: PlanSource[] = [];
for (const block of blocks) {
const lines = block.split("\n");
const heading = lines[0]?.match(/^\d+\. (.+)$/)?.[1];
const id = lines.at(-1)?.match(/^ node_id: (\S+)$/)?.[1];
const where = lines[1]?.trim().split(" · ")[0];
if (!heading || heading.length > 400 || !id || !UUID.test(id) || clean(where ?? "") !== clean(jurisdictionName)) continue;
const excerpt = lines.slice(2, -1).map(l => l.trim()).join("\n");
if (!excerpt || excerpt.length > 3000) continue;
const note = text.startsWith("NOTE:") ? text.split("\n\n")[0] + "\n" : "";
if (note.length > 2000) continue;
result.push(lawSource(id, heading, note + excerpt));
if (result.length === 2) break;
}
return result;
}

/** get_node may prefix its JSON with an effective-date warning. Preserve it. */
export function parseNode(text: string, expectedId: string, jurisdictionName: string): PlanSource | null {
try {
const start = text.indexOf("{");
if (start < 0) return null;
const prefix = text.slice(0, start).trim();
if (prefix.length > 2000 || (prefix && !/^(?:NOTE|WARNING|NOT YET IN EFFECT)\b/i.test(prefix))) return null;
const node: unknown = JSON.parse(text.slice(start));
if (!record(node) || typeof node.nodeId !== "string" || !UUID.test(node.nodeId) || node.nodeId.toLowerCase() !== expectedId.toLowerCase() ||
typeof node.citation !== "string" || !node.citation || node.citation.length > 400 ||
typeof node.bodyText !== "string" || !node.bodyText.trim() || !record(node.jurisdiction) ||
typeof node.jurisdiction.name !== "string" || clean(node.jurisdiction.name) !== clean(jurisdictionName)) return null;
const heading = prefix ? prefix + "\n" : "";
const marker = "\n…truncated; open the source for the complete provision.";
const bodyLimit = Math.min(3000, 5000 - heading.length);
const body = node.bodyText.length > bodyLimit
? node.bodyText.slice(0, bodyLimit - marker.length) + marker : node.bodyText;
return lawSource(node.nodeId, node.citation, heading + body);
} catch { return null; }
}

export function exactLocalCoverage(text: string, state: string, city: string): { code: string; name: string } | null {
// Strip only an exact selected-state suffix; another state or a second comma fails closed.
const parts = city.split(",").map(s => s.trim());
if (parts.length > 2 || (parts.length === 2 && ![state, STATES[state]].some(s => clean(s) === clean(parts[1])))) return null;
const name = parts[0];
if (!name) return null;
const names = [name, `${name}, ${state}`, `${name}, ${STATES[state]}`].map(clean);
const matches: { code: string; name: string }[] = [];
for (const line of text.split("\n")) {
const row = line.match(/^- ([A-Z0-9-]+) — (.+) \((?:municipal|city)\): (.+)$/);
if (!row || !row[1].startsWith(`${state}-`) || !names.includes(clean(row[2]))) continue;
const status = row[3];
if (/PLACEHOLDER|not yet searchable/i.test(status)) continue;
const count = status.match(/([\d,]+) searchable chunks/);
if (!(count && Number(count[1].replace(/,/g, "")) > 0) && !/^delivered after the last daily count — searchable now;/.test(status)) continue;
matches.push({ code: row[1], name: row[2] });
}
return matches.length === 1 ? matches[0] : null;
}
89 changes: 89 additions & 0 deletions autopilot/src/plan-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { corpusBase } from "./tools/law-search.js";
import { PlanError, record } from "./plan-validation.js";

export type ToolName = "formation.requirements" | "law.search" | "law.get_node" | "law.list_coverage" | "formation.handoff";
export type ToolCall = (name: ToolName, args: Record<string, unknown>, signal: AbortSignal) => Promise<string>;
export const TOOL_TIMEOUT_MS = 4500;
export const PLAN_TIMEOUT_MS = 24000;
export const MAX_TOOL_BYTES = 131072;

/** Only a known hosted semantic failure is exposed, using our own categorical copy. */
export class UnsupportedFormationError extends PlanError {
constructor() {
super(400, "Corpus does not currently support formation for the selected state and entity. Choose another state or entity before continuing.");
}
}

/** Enforces a deadline even when an injected/third-party implementation ignores abort. */
export async function bounded<T>(work: (signal: AbortSignal) => Promise<T>, parent: AbortSignal, ms: number): Promise<T> {
const controller = new AbortController();
const abort = () => controller.abort();
parent.addEventListener("abort", abort, { once: true });
if (parent.aborted) abort();
const timer = setTimeout(abort, ms);
let rejectAbort: () => void = () => {};
const stopped = new Promise<never>((_, reject) => {
rejectAbort = () => reject(new PlanError(504, "Planning request timed out."));
controller.signal.addEventListener("abort", rejectAbort, { once: true });
if (controller.signal.aborted) rejectAbort();
});
try {
if (controller.signal.aborted) return await stopped;
return await Promise.race([work(controller.signal), stopped]);
} finally {
clearTimeout(timer);
parent.removeEventListener("abort", abort);
controller.signal.removeEventListener("abort", rejectAbort);
}
}

export async function readBoundedResponse(response: Response, signal: AbortSignal): Promise<string> {
if (!response.ok || !response.body) throw new Error("Tool unavailable");
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
const cancel = () => { void reader.cancel().catch(() => {}); };
signal.addEventListener("abort", cancel, { once: true });
try {
for (;;) {
signal.throwIfAborted();
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_TOOL_BYTES) throw new Error("Tool response too large");
chunks.push(value);
}
signal.throwIfAborted();
return Buffer.concat(chunks).toString("utf8");
} finally {
signal.removeEventListener("abort", cancel);
await reader.cancel().catch(() => {});
}
}

/** MCP tools return prose in structuredContent.text / content[].text, not result objects. */
export const hostedTool: ToolCall = async (name, args, signal) => {
const response = await fetch(`${corpusBase()}/api/mcp`, {
method: "POST", signal,
headers: { "Content-Type": "application/json", ...(process.env.CORPUS_API_KEY ? { Authorization: `Bearer ${process.env.CORPUS_API_KEY}` } : {}) },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
});
const json: unknown = JSON.parse(await readBoundedResponse(response, signal));
if (!record(json) || json.error || !record(json.result)) throw new Error("Tool failed");
const result = json.result;
let text: unknown = record(result.structuredContent) ? result.structuredContent.text : undefined;
if (text === undefined && Array.isArray(result.content)) {
text = result.content.filter(c => record(c) && c.type === "text" && typeof c.text === "string").map(c => c.text).join("\n");
}
if (result.isError || (record(result.structuredContent) && result.structuredContent.isError)) {
const kind = args.entityType === "llc" ? "LLC" : args.entityType === "nonprofit" ? "Nonprofit" : null;
if ((name === "formation.requirements" || name === "formation.handoff") && kind &&
typeof args.state === "string" && /^[A-Z]{2}$/.test(args.state) && typeof text === "string" &&
text.startsWith(`${kind} formation is not available in US-${args.state}. LLC formation is available in `)) {
throw new UnsupportedFormationError();
}
throw new Error("Tool failed");
}
if (typeof text !== "string" || !text.trim() || /^Error:/i.test(text.trim())) throw new Error("Tool text missing");
return text;
};
38 changes: 38 additions & 0 deletions autopilot/src/plan-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { PlanInput } from "./plan-contract.js";

export class PlanError extends Error {
constructor(public status: number, message: string) { super(message); }
}

export const STATES: Record<string, string> = Object.fromEntries([
"AL:Alabama", "AK:Alaska", "AZ:Arizona", "AR:Arkansas", "CA:California", "CO:Colorado",
"CT:Connecticut", "DE:Delaware", "DC:District of Columbia", "FL:Florida", "GA:Georgia",
"HI:Hawaii", "ID:Idaho", "IL:Illinois", "IN:Indiana", "IA:Iowa", "KS:Kansas", "KY:Kentucky",
"LA:Louisiana", "ME:Maine", "MD:Maryland", "MA:Massachusetts", "MI:Michigan", "MN:Minnesota",
"MS:Mississippi", "MO:Missouri", "MT:Montana", "NE:Nebraska", "NV:Nevada", "NH:New Hampshire",
"NJ:New Jersey", "NM:New Mexico", "NY:New York", "NC:North Carolina", "ND:North Dakota",
"OH:Ohio", "OK:Oklahoma", "OR:Oregon", "PA:Pennsylvania", "RI:Rhode Island", "SC:South Carolina",
"SD:South Dakota", "TN:Tennessee", "TX:Texas", "UT:Utah", "VT:Vermont", "VA:Virginia",
"WA:Washington", "WV:West Virginia", "WI:Wisconsin", "WY:Wyoming",
].map(s => s.split(":")));

export const CAMPAIGN_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"] as const;
const FIELDS = ["mode", "activity", "operatingState", "operatingCity", "formationState", "entityType", "campaign"];
export function record(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === "object" && !Array.isArray(v);
}

export function validatePlanInput(v: unknown): PlanInput {
const bad = () => { throw new PlanError(400, "Invalid planning input."); };
if (!record(v) || Object.keys(v).some(k => !FIELDS.includes(k)) || FIELDS.some(k => !Object.hasOwn(v, k))) return bad();
if (!["exploring", "ready", "formed"].includes(v.mode as string)) return bad();
if (v.entityType !== null && v.entityType !== "llc" && v.entityType !== "nonprofit") return bad();
for (const key of ["formationState", "operatingState"]) {
if (v[key] !== null && (typeof v[key] !== "string" || !Object.hasOwn(STATES, v[key] as string))) return bad();
}
if (typeof v.activity !== "string" || v.activity.length > 500 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(v.activity)) return bad();
if (typeof v.operatingCity !== "string" || v.operatingCity.length > 100 || /[\x00-\x1f\x7f]/.test(v.operatingCity)) return bad();
if (!record(v.campaign) || Object.keys(v.campaign).some(k => !CAMPAIGN_KEYS.includes(k as typeof CAMPAIGN_KEYS[number]))) return bad();
if (Object.values(v.campaign).some(s => typeof s !== "string" || s.length > 100 || !/^[A-Za-z0-9_.~-]*$/.test(s))) return bad();
return { ...v, activity: v.activity.replace(/\s+/g, " ").trim(), operatingCity: v.operatingCity.trim(), campaign: { ...v.campaign } } as PlanInput;
}
Loading
Loading