mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Third PR: depends on #15728 and #15727. The user has reviewed and authorized merging this experimental feature stack. ## Thinking Path > - Paperclip manages agent work through tasks and governed connections. > - Chat connections already bind external conversations to those tasks. > - Speko handles voice and telephony, while the selected Paperclip agent does the work. > - A call needs durable session authority, ordered follow-ups, and safe result delivery. > - This PR adds repeatable qualification for the Speko voice task integration. > - This PR depends on #15728, which depends on #15727. Speko stays experimental. ## Linked Issues or Issue Description **Problem or motivation** Phone and browser voice conversations cannot use the chat connection task lifecycle. A separate voice runtime is needed to preserve task authority, retries, and reply history. **Proposed solution** Add company-scoped voice sessions, signed callbacks, durable replies, inbound intake, and an agent-facing outbound-call tool. Reuse existing tasks, queues, secrets, and publications. **Roadmap alignment** This extends the experimental chat connector entry points under Agent Chat and Connected Apps. The project owner requested this feature. Refs #11418 for the original provider catalog proposal. This implementation uses the newer chat connection infrastructure rather than an MCP catalog-only integration. Refs #15721 for remote OpenCode startup. ## What Changed - Add a real Paperclip server/database E2E fixture that stubs only external Speko transport and the execution provider. - Add authenticated multi-user and cross-company qualification journeys. - Add Storybook interaction, accessibility, responsive, and visual-suite integration. - Add an E01–E16 runbook, component-to-story inventory, and current redacted qualification report. - Keep historical private call traces out of the PR. Provider proof tools are opt-in. ## Verification - All 21 standalone proof tests and browser client/server syntax checks pass. They cover signatures, redaction, cleanup retry, final audio saves, bounded report transport and late server write ordering. No live calls were placed. - All 27 focused UI/controller regressions, UI typecheck, token gates, and 28 map/catalog structural tests pass. The shared connection cache test covers pause/resume invalidation during the 30-second fresh period. - Storybook builds 157 Speko production-component stories. The complete prior 156-story set passed all 624 checks on UI source249cbc616a. After the final cache repair, all 152 affected page/launcher checks passed across both themes, 390/1200 widths and reduced motion. Real microphone access and external provider requests are blocked. - Hosted Storybook origin isolation and representative narrow-screen stories pass. Provider requests remain blocked. - Source annotations follow the current generated-catalog contract. No generated catalog or lockfile is included. Three inherited GitHub-heading tests passed after master reconciliation. - Commit905bc5da5dhas 55 passing checks, including required verify/E2E gates, and a fresh Greptile 5/5 review with zero unresolved findings. Local fixture success does not establish provider or spoken-playback acceptance. - The separate integrated browser run passed two journeys and failed browser answer retrieval. Real browser/telephone playback and timing acceptance remain outstanding. See the dated qualification report and E01–E16 runbook. Speko stays experimental. ## Risks - Voice sessions add migrations and provider-dependent lifecycle behavior. Migrations preserve existing chat and AI transports. - Callback origins must be reachable HTTPS. Provider keys and signing secrets remain vaulted. - Unverified callers receive task-scoped low-trust authority. Caller ID does not grant private task access. - Outbound telephony incurs provider charges. Provider acceptance is not proof that the caller heard an answer. - Keep the feature behind the existing experimental chat setting until the real browser, inbound, outbound, and delayed-result acceptance matrix passes. ## Model Used OpenAI Codex, GPT-6, with tool use and code execution. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
90 lines
4.7 KiB
JavaScript
90 lines
4.7 KiB
JavaScript
import { randomBytes } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { resolve } from "node:path";
|
|
import { createProof, createProofServer, PROOF_PROMPT, toolDefinitions } from "./proof.mjs";
|
|
|
|
// This probe is intentionally outside the app. Credentials never enter a
|
|
// browser bundle, repository fixture, or printed provider response.
|
|
const statePath = resolve(process.env.SPEKO_PROOF_STATE ?? `${homedir()}/.paperclip/speko-proof/state.json`);
|
|
const save = async (state) => writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
async function api(path, body) {
|
|
const key = process.env.SPEKO_API_KEY ?? process.env.SPEKO_MCP_API_KEY;
|
|
if (!key) throw new Error("Set SPEKO_API_KEY or SPEKO_MCP_API_KEY in the server environment");
|
|
const response = await fetch(`https://api.speko.dev${path}`, {
|
|
method: body === undefined ? "GET" : "POST",
|
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
// No automatic mutation retry: an uncertain creation must be reconciled.
|
|
if (!response.ok) throw new Error(`Speko ${body ? "POST" : "GET"} ${path} returned HTTP ${response.status}`);
|
|
return response.json();
|
|
}
|
|
|
|
async function main() {
|
|
const command = process.argv[2];
|
|
if (command === "init") {
|
|
await mkdir(resolve(statePath, ".."), { recursive: true, mode: 0o700 });
|
|
const state = { signingSecret: `whsec_${randomBytes(32).toString("base64")}`, createdAt: new Date().toISOString(), toolIds: {} };
|
|
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
console.log("Initialized private proof state. No provider calls made.");
|
|
return;
|
|
}
|
|
const state = JSON.parse(await readFile(statePath, "utf8"));
|
|
if (command === "provision") {
|
|
const definitions = toolDefinitions(process.env.SPEKO_PROOF_PUBLIC_ORIGIN);
|
|
if (state.pendingMutation) throw new Error("A previous provider mutation has an uncertain outcome. Inspect the dedicated proof agent in Speko before clearing pendingMutation in private state.");
|
|
if (!state.agentId) {
|
|
state.pendingMutation = "create_agent";
|
|
await save(state);
|
|
const agent = await api("/v1/agents", {
|
|
name: "Paperclip delayed voice proof",
|
|
systemPrompt: PROOF_PROMPT,
|
|
firstMessage: "This is a synthetic Paperclip test. Say start the test when you are ready.",
|
|
intent: { language: "en-US", optimizeFor: "latency" },
|
|
runMode: "cascade",
|
|
});
|
|
if (typeof agent.id !== "string") throw new Error("Unexpected agent response; reconcile creation in Speko");
|
|
state.agentId = agent.id;
|
|
delete state.pendingMutation;
|
|
await save(state);
|
|
}
|
|
for (const definition of definitions) {
|
|
if (state.toolIds[definition.name]) continue;
|
|
state.pendingMutation = `create_tool:${definition.name}`;
|
|
await save(state);
|
|
const tool = await api(`/v1/agents/${encodeURIComponent(state.agentId)}/tools`, {
|
|
...definition, source: { ...definition.source, secret: state.signingSecret },
|
|
});
|
|
if (typeof tool.id !== "string") throw new Error("Unexpected tool response; reconcile creation in Speko");
|
|
state.toolIds[definition.name] = tool.id;
|
|
delete state.pendingMutation;
|
|
await save(state);
|
|
}
|
|
state.publicOrigin = process.env.SPEKO_PROOF_PUBLIC_ORIGIN;
|
|
await save(state);
|
|
console.log(JSON.stringify({ agentId: state.agentId, tools: Object.keys(state.toolIds), liveQualification: "not_established" }));
|
|
return;
|
|
}
|
|
if (command === "serve") {
|
|
const port = Number(process.env.SPEKO_PROOF_PORT ?? 3198);
|
|
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Invalid SPEKO_PROOF_PORT");
|
|
const proof = createProof({ signingSecret: state.signingSecret, onEvent: (event) => console.log(JSON.stringify(event)) });
|
|
const server = createProofServer(proof);
|
|
server.listen(port, "127.0.0.1", () => console.log(`Synthetic probe listening on 127.0.0.1:${port}; expires after 15 minutes`));
|
|
const stop = async () => {
|
|
proof.close();
|
|
server.close();
|
|
server.closeAllConnections();
|
|
await writeFile(resolve(statePath, "../evidence.json"), `${JSON.stringify(proof.evidence(), null, 2)}\n`, { mode: 0o600 });
|
|
};
|
|
const expiry = setTimeout(() => { void stop(); }, 15 * 60_000);
|
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { clearTimeout(expiry); void stop(); });
|
|
return;
|
|
}
|
|
throw new Error("Usage: node cli.mjs init|provision|serve");
|
|
}
|
|
|
|
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
|