@makerstufferupper/flypi 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +44 -2
- package/assets/screenshot.png +0 -0
- package/bin/flypi.mjs +28 -0
- package/dist/ui/assets/index-CG1CuGxu.js +105 -0
- package/dist/ui/assets/index-qaKjH5fH.css +1 -0
- package/dist/ui/flypi-logo-dark.jpeg +0 -0
- package/dist/ui/flypi-logo-light.jpeg +0 -0
- package/dist/ui/index.html +17 -0
- package/package.json +71 -5
- package/scripts/benchmark-nli.ts +273 -0
- package/scripts/flypi.mjs +303 -0
- package/scripts/probe-turn-boundary.ts +97 -0
- package/scripts/probe.ts +119 -0
- package/scripts/record-transcript.ts +63 -0
- package/scripts/reembed-memory.ts +41 -0
- package/scripts/release.mjs +35 -0
- package/scripts/secret.ts +24 -0
- package/scripts/serve-daemon.ts +101 -0
- package/scripts/serve-memory.ts +78 -0
- package/scripts/serve.ts +89 -0
- package/scripts/smoke-memory.mjs +239 -0
- package/scripts/smoke-secrets.mjs +153 -0
- package/scripts/smoke.mjs +59 -0
- package/scripts/start.mjs +294 -0
- package/scripts/turn-boundary.ts +52 -0
- package/scripts/update.ts +92 -0
- package/scripts/warm-models.ts +41 -0
- package/src/agent/config-files.ts +529 -0
- package/src/agent/mcp-signin.ts +137 -0
- package/src/agent/mcp-status.ts +168 -0
- package/src/agent/onboarding.ts +74 -0
- package/src/agent/packages.ts +150 -0
- package/src/agent/paths.ts +80 -0
- package/src/agent/permission-gate.ts +70 -0
- package/src/agent/pi-mcp.ts +97 -0
- package/src/agent/runtime.ts +347 -0
- package/src/daemon/index.ts +442 -0
- package/src/daemon/jobs.ts +202 -0
- package/src/daemon/update.ts +248 -0
- package/src/memory/capture.ts +109 -0
- package/src/memory/config.ts +90 -0
- package/src/memory/consolidate.ts +218 -0
- package/src/memory/db.ts +393 -0
- package/src/memory/embed.ts +154 -0
- package/src/memory/judge.ts +167 -0
- package/src/memory/mcp.ts +496 -0
- package/src/memory/media.ts +135 -0
- package/src/memory/progress.ts +39 -0
- package/src/memory/recall.ts +528 -0
- package/src/memory/review.ts +86 -0
- package/src/memory/store.ts +1034 -0
- package/src/memory/wiki.ts +140 -0
- package/src/secrets/cli.ts +248 -0
- package/src/secrets/config.ts +133 -0
- package/src/secrets/crypto.ts +180 -0
- package/src/secrets/extension.ts +253 -0
- package/src/secrets/redact.ts +132 -0
- package/src/secrets/vault.ts +396 -0
- package/src/server/event-log.ts +49 -0
- package/src/server/extension-ui.ts +118 -0
- package/src/server/index.ts +398 -0
- package/src/server/memory-api.ts +158 -0
- package/src/server/protocol.ts +947 -0
- package/src/server/run-store.ts +136 -0
- package/src/server/secrets-api.ts +88 -0
- package/src/server/session-registry.ts +593 -0
- package/src/shared/attachments.ts +40 -0
- package/src/shared/cache-probe.ts +47 -0
- package/src/shared/markdown.ts +17 -0
- package/src/shared/metrics.ts +212 -0
- package/src/shared/sqlite.ts +74 -0
- package/src/shared/transcript.ts +404 -0
- package/src/shared/turn-boundary.ts +98 -0
- package/src/shared/views.ts +31 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing an MCP server in from the settings view (docs/architecture.md Phase 5, risk 5): the half the
|
|
3
|
+
* status panel left open — a server with an `oauth` entry used to be reported as unreachable, which is true
|
|
4
|
+
* but not something a user can act on.
|
|
5
|
+
*
|
|
6
|
+
* This is pi's own authorization-code flow (PKCE, dynamic client registration or a pre-registered client),
|
|
7
|
+
* run by pi's `signInMcpServer` — see src/agent/pi-mcp.ts for why flypi calls it directly rather than
|
|
8
|
+
* through the MCP extension. The only flypi part is the prompt: the authorization URL goes to the browser
|
|
9
|
+
* as an `mcpAuth` frame, and the address the browser lands on comes back as a paste. That paste is the whole
|
|
10
|
+
* point — when flypi runs somewhere a browser cannot reach (a box reached over SSH), the loopback callback
|
|
11
|
+
* pi also listens for never arrives, and the sign-in is otherwise impossible.
|
|
12
|
+
*
|
|
13
|
+
* Tokens land in `.flypi/agent/mcp-auth.json`, which is what a conversation's own connections read next time
|
|
14
|
+
* (src/agent/runtime.ts) and what the status probe sends when it asks a server whether it answers
|
|
15
|
+
* (src/agent/mcp-status.ts).
|
|
16
|
+
*/
|
|
17
|
+
import type { McpServerEntry } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { FlypiPaths } from "./paths.ts";
|
|
19
|
+
import { loadPiMcpRuntime, mcpCredentials, usesMcpOAuth, type PiMcpOAuthSettings, type PiMcpRuntime } from "./pi-mcp.ts";
|
|
20
|
+
import { readFlypiMcpConfig } from "./runtime.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How long a sign-in may wait for the paste. pi's CLI uses its MCP request timeout for the same wait; this
|
|
24
|
+
* is longer on purpose — a person opening a URL on a different device, approving it, and copying an address
|
|
25
|
+
* is slower than a network call, and cancelling a sign-in out from under them is the failure to avoid.
|
|
26
|
+
*/
|
|
27
|
+
export const SIGN_IN_TIMEOUT_MS = 5 * 60_000;
|
|
28
|
+
|
|
29
|
+
/** One sign-in per server name at a time, process-wide: two flows for one server would race on its tokens. */
|
|
30
|
+
const waiting = new Map<string, (redirectUrl: string | undefined) => void>();
|
|
31
|
+
|
|
32
|
+
/** Whether a paste would reach a sign-in right now — what the settings view's button is disabled by. */
|
|
33
|
+
export function signInIsWaiting(name: string): boolean {
|
|
34
|
+
return waiting.has(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hand the pasted address to the sign-in for `name`. Empty cancels. Returns false when nothing is waiting,
|
|
39
|
+
* which is what the server turns into an error frame rather than a dropped frame.
|
|
40
|
+
*/
|
|
41
|
+
export function submitRedirect(name: string, redirectUrl: string): boolean {
|
|
42
|
+
const paste = waiting.get(name);
|
|
43
|
+
if (!paste) return false;
|
|
44
|
+
paste(redirectUrl.trim() || undefined);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run the sign-in for one entry of `.flypi/agent/mcp.json`. Resolves once the tokens are stored; throws with
|
|
50
|
+
* the sentence a reader acts on — the settings view shows whatever comes back.
|
|
51
|
+
*/
|
|
52
|
+
export async function signIn(
|
|
53
|
+
paths: FlypiPaths,
|
|
54
|
+
name: string,
|
|
55
|
+
onAuthorizationUrl: (url: string) => void,
|
|
56
|
+
timeoutMs = SIGN_IN_TIMEOUT_MS,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const entry = readFlypiMcpConfig(paths).servers.find((server) => server.name === name);
|
|
59
|
+
if (!entry) throw new Error(`no MCP server named "${name}" in ${paths.mcpPath}`);
|
|
60
|
+
const target = signInTarget(entry, paths.mcpPath);
|
|
61
|
+
if ("refusal" in target) throw new Error(target.refusal);
|
|
62
|
+
if (waiting.has(name)) throw new Error(`a sign-in for "${name}" is already waiting for its redirect URL`);
|
|
63
|
+
|
|
64
|
+
// The slot is taken before anything is loaded, so a second click while pi's OAuth machinery is still
|
|
65
|
+
// arriving is refused too rather than starting a second flow for the same server.
|
|
66
|
+
let paste: (redirectUrl: string | undefined) => void = () => {};
|
|
67
|
+
const pasted = new Promise<string | undefined>((resolve) => (paste = resolve));
|
|
68
|
+
const timer = setTimeout(() => paste(undefined), timeoutMs);
|
|
69
|
+
timer.unref();
|
|
70
|
+
waiting.set(name, paste);
|
|
71
|
+
// Assigned inside the try, so the catch can still name pi's own cancellation error.
|
|
72
|
+
let pi: PiMcpRuntime | undefined;
|
|
73
|
+
try {
|
|
74
|
+
pi = await loadPiMcpRuntime();
|
|
75
|
+
const credentials = await mcpCredentials(paths);
|
|
76
|
+
await pi.signInMcpServer({
|
|
77
|
+
serverUrl: target.url,
|
|
78
|
+
store: credentials.forServer(name, target.url),
|
|
79
|
+
settings: oauthSettings(pi.resolveConfigValueOrThrow, entry),
|
|
80
|
+
prompt: {
|
|
81
|
+
showAuthorizationUrl: (authorizationUrl) => onAuthorizationUrl(authorizationUrl.href),
|
|
82
|
+
// pi races this against its own loopback callback, so a browser on this machine finishes the
|
|
83
|
+
// sign-in without the paste; the paste is what makes it finish when one cannot.
|
|
84
|
+
promptForRedirectUrl: () => pasted,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (pi && error instanceof pi.McpSignInCancelledError) {
|
|
89
|
+
throw new Error(`sign-in to "${name}" was cancelled, or no redirect URL arrived within ${Math.round(timeoutMs / 1000)}s`);
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
waiting.delete(name);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The URL to authorize against, or the sentence explaining why this entry cannot be signed in to. The rule
|
|
100
|
+
* is pi's own (src/agent/pi-mcp.ts): an HTTP server authenticates with OAuth when it sends no
|
|
101
|
+
* `Authorization` header of its own and delegates to no provider — anything else is a credential to fix in
|
|
102
|
+
* the file, not a sign-in to do.
|
|
103
|
+
*/
|
|
104
|
+
function signInTarget(entry: McpServerEntry, mcpPath: string): { url: string } | { refusal: string } {
|
|
105
|
+
const config = entry.config;
|
|
106
|
+
if (!("url" in config)) return { refusal: `"${entry.name}" is a command flypi starts locally: there is nothing to sign in to` };
|
|
107
|
+
if (config.enabled === false) return { refusal: `"${entry.name}" is disabled in ${mcpPath}` };
|
|
108
|
+
if (!usesMcpOAuth(config)) {
|
|
109
|
+
return {
|
|
110
|
+
refusal: config.auth?.provider
|
|
111
|
+
? `"${entry.name}" sends the token of provider "${config.auth.provider}" — that is a /login credential, not an OAuth sign-in`
|
|
112
|
+
: `"${entry.name}" sends its own Authorization header, so it is not an OAuth server — fix that header in ${mcpPath}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { url: config.url };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The entry's `oauth` block as pi wants it, resolved the way pi resolves it: only `clientSecret` can name an
|
|
120
|
+
* environment variable or a command, and a reference that does not resolve is an error naming the entry.
|
|
121
|
+
*/
|
|
122
|
+
function oauthSettings(resolve: (value: string, description: string) => string, entry: McpServerEntry): PiMcpOAuthSettings {
|
|
123
|
+
const oauth = "oauth" in entry.config ? entry.config.oauth : undefined;
|
|
124
|
+
if (!oauth) return {};
|
|
125
|
+
return {
|
|
126
|
+
clientId: oauth.clientId,
|
|
127
|
+
...(oauth.clientSecret === undefined
|
|
128
|
+
? {}
|
|
129
|
+
: { clientSecret: resolve(oauth.clientSecret, `MCP server "${entry.name}" oauth.clientSecret`) }),
|
|
130
|
+
callbackPort: oauth.callbackPort,
|
|
131
|
+
callbackUrl: oauth.callbackUrl,
|
|
132
|
+
scope: oauth.scope,
|
|
133
|
+
clientName: oauth.clientName,
|
|
134
|
+
clientRegistration: oauth.clientRegistration,
|
|
135
|
+
...(oauth.authServerMetadataUrl ? { authServerMetadataUrl: new URL(oauth.authServerMetadataUrl) } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP status panel (docs/architecture.md Phase 5): is each server in `.flypi/agent/mcp.json` actually
|
|
3
|
+
* reachable, how many tools does it offer, what did it complain about.
|
|
4
|
+
*
|
|
5
|
+
* pi keeps its own connections inside the MCP extension and shows the host none of that state — not over
|
|
6
|
+
* the SDK, not in the `.flypi/agent/mcp.log` file it is handed (docs/pi-api.md). So this asks the servers
|
|
7
|
+
* directly, the way `pi mcp list` does: connect, list tools, close. It is a second set of connections for
|
|
8
|
+
* as long as the request takes, and it does not touch the ones a running conversation holds — which is the
|
|
9
|
+
* honest answer to "is it up", since the connections a session made are not the ones this panel describes
|
|
10
|
+
* after a config edit (an existing conversation keeps its own, docs/architecture.md Phase 5).
|
|
11
|
+
*
|
|
12
|
+
* The tool list carries the two annotations that mean something to a reader: `destructiveHint` ("this can
|
|
13
|
+
* destroy something") and `openWorldHint` ("this reaches outside the machine"). That is what a user puts on
|
|
14
|
+
* `permissions.askBeforeUse` in src/agent/permission-gate.ts — the gate itself never sees an annotation.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
18
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
19
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
20
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
21
|
+
import type { McpServerEntry } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { FlypiPaths } from "./paths.ts";
|
|
23
|
+
import { mcpCredentials, usesMcpOAuth, type PiMcpCredentials } from "./pi-mcp.ts";
|
|
24
|
+
import { readFlypiMcpConfig } from "./runtime.ts";
|
|
25
|
+
|
|
26
|
+
/** "not answering" has to be distinguishable from "not up yet", so a probe that hangs is a failure. */
|
|
27
|
+
export type McpServerState = "connected" | "disabled" | "failed" | "needs-auth";
|
|
28
|
+
|
|
29
|
+
export interface McpToolRow {
|
|
30
|
+
name: string;
|
|
31
|
+
/** What the server announced about the tool, not what flypi guessed. Absent means not announced. */
|
|
32
|
+
destructive: boolean;
|
|
33
|
+
openWorld: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface McpStatusRow {
|
|
37
|
+
name: string;
|
|
38
|
+
state: McpServerState;
|
|
39
|
+
transport: "http" | "stdio";
|
|
40
|
+
/** Set for `failed` / `needs-auth`, in the server's own words. */
|
|
41
|
+
error?: string;
|
|
42
|
+
tools: McpToolRow[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface McpStatusSnapshot {
|
|
46
|
+
servers: McpStatusRow[];
|
|
47
|
+
/** The tail of `.flypi/agent/mcp.log`: pi's own words about the connections a session actually made. */
|
|
48
|
+
log: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** pi's own startup wait for one server (docs/mcp.md); matching it means this never gives up before pi would. */
|
|
52
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
53
|
+
/** The log rotates at 5 MB (pi's MCP extension); the panel shows the end of it, not all of it. */
|
|
54
|
+
const LOG_TAIL_BYTES = 8 * 1024;
|
|
55
|
+
|
|
56
|
+
/** Every configured server, probed in parallel: one slow one must not add up behind the others. */
|
|
57
|
+
export async function mcpStatus(paths: FlypiPaths, timeoutMs = PROBE_TIMEOUT_MS): Promise<McpStatusSnapshot> {
|
|
58
|
+
const config = readFlypiMcpConfig(paths);
|
|
59
|
+
// The OAuth servers need what a sign-in stored (see transportFor), so the probe reads the same file a
|
|
60
|
+
// conversation's connections read.
|
|
61
|
+
const credentials = await mcpCredentials(paths);
|
|
62
|
+
const rows = await Promise.all(config.servers.map((entry) => probe(entry, credentials, timeoutMs)));
|
|
63
|
+
return { servers: rows, log: mcpLogTail(paths) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Connect and list, then close. Never throws: a server that will not answer is a row, not an error frame. */
|
|
67
|
+
async function probe(entry: McpServerEntry, credentials: PiMcpCredentials, timeoutMs: number): Promise<McpStatusRow> {
|
|
68
|
+
const config = entry.config;
|
|
69
|
+
const base = { name: entry.name, transport: "command" in config ? ("stdio" as const) : ("http" as const) };
|
|
70
|
+
if (config.enabled === false) return { ...base, state: "disabled", tools: [] };
|
|
71
|
+
|
|
72
|
+
const client = new Client({ name: "flypi-status", version: "0.0.0" });
|
|
73
|
+
try {
|
|
74
|
+
await withTimeout(client.connect(await transportFor(entry, credentials)), timeoutMs);
|
|
75
|
+
const listed = await withTimeout(client.listTools(), timeoutMs);
|
|
76
|
+
return {
|
|
77
|
+
...base,
|
|
78
|
+
state: "connected",
|
|
79
|
+
tools: listed.tools.map((tool) => ({
|
|
80
|
+
name: tool.name,
|
|
81
|
+
destructive: tool.annotations?.destructiveHint === true,
|
|
82
|
+
openWorld: tool.annotations?.openWorldHint === true,
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// A 401 is its own state: the server is there and the credential is missing or wrong, which is a
|
|
87
|
+
// different thing to fix than a server that is not up. It is read off the SDK's own status
|
|
88
|
+
// (`StreamableHTTPError.code`), because the message alone is not enough — a 401 with an empty body
|
|
89
|
+
// says nothing but "Error POSTing to endpoint".
|
|
90
|
+
const state: McpServerState = httpStatusOf(error) === 401 ? "needs-auth" : "failed";
|
|
91
|
+
return { ...base, state, error: (error as Error).message ?? String(error), tools: [] };
|
|
92
|
+
} finally {
|
|
93
|
+
await client.close().catch(() => undefined);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The HTTP status of a failed MCP request, or undefined when the failure was not an HTTP answer. */
|
|
98
|
+
function httpStatusOf(error: unknown): number | undefined {
|
|
99
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
100
|
+
return typeof code === "number" ? code : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The transport pi would build for this entry: URL and headers for HTTP, spawned process for stdio.
|
|
105
|
+
* `${NAME}` in a header or env value is expanded here because this is a second client and pi's own
|
|
106
|
+
* expansion does not apply to it — same rule as src/memory/recall.ts.
|
|
107
|
+
* `shortcut: no "!command" values here either; add them with recall.ts if an entry ever uses one.`
|
|
108
|
+
*/
|
|
109
|
+
async function transportFor(entry: McpServerEntry, credentials: PiMcpCredentials): Promise<Transport> {
|
|
110
|
+
const config = entry.config;
|
|
111
|
+
if ("command" in config) {
|
|
112
|
+
return new StdioClientTransport({
|
|
113
|
+
command: config.command,
|
|
114
|
+
args: config.args ?? [],
|
|
115
|
+
...(config.cwd ? { cwd: config.cwd } : {}),
|
|
116
|
+
env: { ...getDefaultEnvironment(), ...expand(Object.entries(config.env ?? {})) },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const headers = expand(Object.entries(config.headers ?? {}));
|
|
120
|
+
// A server the user signed in to in a browser: send the token that sign-in stored, the way pi's own
|
|
121
|
+
// connection does. Without it the panel would call a server "needs authorisation" one second after the
|
|
122
|
+
// sign-in that just worked (docs/architecture.md Phase 5).
|
|
123
|
+
// shortcut: no refresh here — a token that expired since shows as needs-auth, and signing in again is the fix.
|
|
124
|
+
const token = usesMcpOAuth(config) ? credentials.tokens(entry.name, config.url)?.access_token : undefined;
|
|
125
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
126
|
+
return new StreamableHTTPClientTransport(new URL(config.url), Object.keys(headers).length ? { requestInit: { headers } } : {});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const expand = (entries: [string, string | undefined][]): Record<string, string> =>
|
|
130
|
+
Object.fromEntries(
|
|
131
|
+
entries.map(([name, value]) => [name, (value ?? "").replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, env: string) => process.env[env] ?? "")]),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/** A hung server is the one failure mode that would otherwise leave the panel waiting forever. */
|
|
135
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
136
|
+
return new Promise<T>((resolve, reject) => {
|
|
137
|
+
const timer = setTimeout(() => reject(new Error(`no answer within ${ms / 1000}s`)), ms);
|
|
138
|
+
promise.then(
|
|
139
|
+
(value) => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
resolve(value);
|
|
142
|
+
},
|
|
143
|
+
(error) => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
reject(error);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The last `LOG_TAIL_BYTES` of pi's MCP log, whole lines only. Empty when there is no log, which is the
|
|
153
|
+
* normal state of a machine where nothing has gone wrong; the panel says "nothing logged" rather than
|
|
154
|
+
* leaving a blank box (risk 8).
|
|
155
|
+
*/
|
|
156
|
+
export function mcpLogTail(paths: FlypiPaths, bytes = LOG_TAIL_BYTES): string {
|
|
157
|
+
if (!existsSync(paths.mcpLogPath)) return "";
|
|
158
|
+
let raw: Buffer;
|
|
159
|
+
try {
|
|
160
|
+
raw = readFileSync(paths.mcpLogPath);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return `could not read ${paths.mcpLogPath}: ${(error as Error).message}`;
|
|
163
|
+
}
|
|
164
|
+
const cut = Math.max(0, raw.length - bytes);
|
|
165
|
+
const tail = raw.subarray(cut).toString("utf8");
|
|
166
|
+
// Only when bytes were actually dropped: the first surviving line is then a partial one.
|
|
167
|
+
return cut > 0 ? tail.replace(/^[^\n]*\n/, "") : tail;
|
|
168
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** pi's auth.json shape: { "<provider>": { "type": "api_key", "key": "..." } } */
|
|
5
|
+
export type AuthStorage = Record<string, { type: string; key?: string } | undefined>;
|
|
6
|
+
|
|
7
|
+
export type ResolvedCredential =
|
|
8
|
+
| { ok: true; provider: string; source: "auth.json" | "models.json" | "env" }
|
|
9
|
+
| { ok: false; provider: string; message: string };
|
|
10
|
+
|
|
11
|
+
/** `lmstudio` -> `LMSTUDIO_API_KEY`, `openai` -> `OPENAI_API_KEY`. */
|
|
12
|
+
export function credentialEnvName(provider: string): string {
|
|
13
|
+
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasValue(value: unknown): boolean {
|
|
17
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decide whether a provider can be used, without throwing: a missing credential is a
|
|
22
|
+
* message the user can act on, not a stack trace.
|
|
23
|
+
*
|
|
24
|
+
* pi's own precedence is auth.json > models.json apiKey > env, so this reports the
|
|
25
|
+
* source pi would actually use. `env` is listed last because pi reads it last.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveCredential(input: {
|
|
28
|
+
provider: string;
|
|
29
|
+
models: { providers?: Record<string, { apiKey?: unknown }> | undefined };
|
|
30
|
+
auth: AuthStorage;
|
|
31
|
+
env: Record<string, string | undefined>;
|
|
32
|
+
}): ResolvedCredential {
|
|
33
|
+
const { provider, models, auth, env } = input;
|
|
34
|
+
if (hasValue(auth[provider]?.key)) return { ok: true, provider, source: "auth.json" };
|
|
35
|
+
if (hasValue(models.providers?.[provider]?.apiKey)) return { ok: true, provider, source: "models.json" };
|
|
36
|
+
if (hasValue(env[credentialEnvName(provider)])) return { ok: true, provider, source: "env" };
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
provider,
|
|
40
|
+
message:
|
|
41
|
+
`no credential for provider "${provider}". Set ${credentialEnvName(provider)} in the environment, ` +
|
|
42
|
+
`or add {"${provider}":{"type":"api_key","key":"…"}} to the model credential file.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readAuth(authPath: string): AuthStorage {
|
|
47
|
+
if (!existsSync(authPath)) return {};
|
|
48
|
+
return JSON.parse(readFileSync(authPath, "utf8")) as AuthStorage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Store a key in pi's own credential file (0600), merging unrelated providers. */
|
|
52
|
+
export function writeAuthKey(authPath: string, provider: string, key: string): void {
|
|
53
|
+
const auth = readAuth(authPath);
|
|
54
|
+
auth[provider] = { type: "api_key", key };
|
|
55
|
+
mkdirSync(dirname(authPath), { recursive: true });
|
|
56
|
+
writeFileSync(authPath, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
|
|
57
|
+
chmodSync(authPath, 0o600);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Move an environment-supplied key into the credential file so later runs (and the
|
|
62
|
+
* daemon) do not depend on the caller's shell. Returns false when there is nothing to do.
|
|
63
|
+
*/
|
|
64
|
+
export function importEnvCredential(
|
|
65
|
+
authPath: string,
|
|
66
|
+
provider: string,
|
|
67
|
+
env: Record<string, string | undefined>,
|
|
68
|
+
): boolean {
|
|
69
|
+
const key = env[credentialEnvName(provider)];
|
|
70
|
+
if (!hasValue(key)) return false;
|
|
71
|
+
if (hasValue(readAuth(authPath)[provider]?.key)) return false;
|
|
72
|
+
writeAuthKey(authPath, provider, key as string);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension packages — pi's own mechanism (pi's docs/packages.md), pointed at flypi's agent dir so the
|
|
3
|
+
* settings view can install and update them (docs/protocol.md `packages` / `savePackages`).
|
|
4
|
+
*
|
|
5
|
+
* Nothing here reimplements a package: pi's `DefaultPackageManager` owns the source forms (`npm:…`, a git
|
|
6
|
+
* URL, a path), where the code lands (`<agentDir>/npm/node_modules`, a git checkout, or the path itself),
|
|
7
|
+
* what counts as an update and how one is reconciled. `agentDir` is `.flypi/agent`, so an installed
|
|
8
|
+
* extension never reaches the host `~/.pi` (docs/risks.md risk 1) — `assertFlypiOwned` is what guarantees
|
|
9
|
+
* that, and it runs in `createFlypiSession` before any session loads these packages.
|
|
10
|
+
*
|
|
11
|
+
* The declaration lives in `.flypi/agent/settings.json` under `packages`, the same file the settings panel
|
|
12
|
+
* edits the default model in. pi's `SettingsManager` writes only the field it changed, so a `defaultProvider`
|
|
13
|
+
* or a `permissions` block survives an install — and it refuses to write at all when that file will not
|
|
14
|
+
* parse, which is why `applyPackageEdits` checks for a load error before touching anything rather than
|
|
15
|
+
* reporting an install that never landed.
|
|
16
|
+
*
|
|
17
|
+
* **Updates are checked, not fetched, by the panel.** `checkForAvailableUpdates()` reaches the registry and
|
|
18
|
+
* every git remote, so it runs on the daemon's daily rhythm (src/daemon/index.ts) and its answer is kept in
|
|
19
|
+
* `.flypi/package-check.json` — the same shape as flypi's own update stamp (src/daemon/update.ts). The panel
|
|
20
|
+
* reads the stamp; the daemon installs what it found during the same idle stretch it installs flypi with.
|
|
21
|
+
* A source that names a version (`npm:x@1.2.0`) or a git ref is pinned, and pi leaves pinned sources where
|
|
22
|
+
* they are: naming a version is asking for that version.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { FlypiConfigError } from "./runtime.ts";
|
|
28
|
+
import type { FlypiPaths } from "./paths.ts";
|
|
29
|
+
|
|
30
|
+
/** How long the package answer stays good — the daemon's day, the same rhythm as flypi's own update check. */
|
|
31
|
+
export const PACKAGE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
/** The answer, next to flypi's own update stamp, so the panel reports it without reaching the network. */
|
|
33
|
+
export const PACKAGE_CHECK_FILE = join(".flypi", "package-check.json");
|
|
34
|
+
|
|
35
|
+
/** One `packages` entry, as the panel shows it. */
|
|
36
|
+
export interface PackageRow {
|
|
37
|
+
/** The source exactly as settings.json holds it — `npm:…`, a git URL, or a path. */
|
|
38
|
+
source: string;
|
|
39
|
+
/** `project` is an entry in `<root>/.pi/settings.json`; flypi's own edits are always `user`. */
|
|
40
|
+
scope: "user" | "project";
|
|
41
|
+
/** Declared, but the code is not on disk. A session would install it on start; the panel says so. */
|
|
42
|
+
installed: boolean;
|
|
43
|
+
/** Where the code is: `.flypi/agent/npm/node_modules/<name>`, a checkout, or the local path. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** A newer release is published (npm) or the remote moved (git). Only from the daemon's stamp. */
|
|
46
|
+
update?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PackageSnapshot {
|
|
50
|
+
packages: PackageRow[];
|
|
51
|
+
/** When the daemon last asked the registries; absent until it has. */
|
|
52
|
+
checkedAt?: string;
|
|
53
|
+
/** A `settings.json` pi could not read — said, rather than shown as "no packages". */
|
|
54
|
+
errors: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PackageEdits {
|
|
58
|
+
install?: string[];
|
|
59
|
+
remove?: string[];
|
|
60
|
+
update?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What the daemon wrote: which sources have an update waiting. */
|
|
64
|
+
export interface PackageCheck {
|
|
65
|
+
checkedAt: string;
|
|
66
|
+
/** The `packages` entries, exactly as settings.json holds them. */
|
|
67
|
+
updates: string[];
|
|
68
|
+
error?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** pi's package manager, on flypi's own agent dir, plus the settings file it writes through. */
|
|
72
|
+
function packages(paths: FlypiPaths): { manager: DefaultPackageManager; settings: SettingsManager } {
|
|
73
|
+
const settings = SettingsManager.create(paths.root, paths.agentDir);
|
|
74
|
+
return { manager: new DefaultPackageManager({ cwd: paths.root, agentDir: paths.agentDir, settingsManager: settings }), settings };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What pi could not read or write, named by file — a settings.json it cannot parse is never silently "empty". */
|
|
78
|
+
function settingsErrors(settings: SettingsManager): string[] {
|
|
79
|
+
return settings.drainErrors().map((failure) => `${failure.path ?? failure.scope}: ${failure.error.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The list, merged with the daemon's answer. Reaches no network, so the panel can ask on every open. */
|
|
83
|
+
export function readPackages(paths: FlypiPaths): PackageSnapshot {
|
|
84
|
+
const { manager, settings } = packages(paths);
|
|
85
|
+
const check = readPackageCheck(paths.root);
|
|
86
|
+
return {
|
|
87
|
+
packages: manager.listConfiguredPackages().map((entry) => ({
|
|
88
|
+
source: entry.source,
|
|
89
|
+
scope: entry.scope,
|
|
90
|
+
installed: entry.installedPath !== undefined,
|
|
91
|
+
path: entry.installedPath ?? "",
|
|
92
|
+
...(check?.updates.includes(entry.source) ? { update: true } : {}),
|
|
93
|
+
})),
|
|
94
|
+
...(check ? { checkedAt: check.checkedAt } : {}),
|
|
95
|
+
errors: settingsErrors(settings),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Install, remove or update, then hand back the list as it is now. Applied in order and one at a time:
|
|
101
|
+
* pi's own npm/git calls are what fail, and a later edit in the same request still runs against what the
|
|
102
|
+
* earlier ones wrote. A `settings.json` that will not parse is refused up front — pi would install the code
|
|
103
|
+
* and then decline to record it, which is an install that never lands.
|
|
104
|
+
*/
|
|
105
|
+
export async function applyPackageEdits(paths: FlypiPaths, edits: PackageEdits): Promise<PackageSnapshot> {
|
|
106
|
+
const { manager, settings } = packages(paths);
|
|
107
|
+
const unreadable = settingsErrors(settings);
|
|
108
|
+
if (unreadable.length > 0) throw new FlypiConfigError(unreadable.join("; "));
|
|
109
|
+
for (const source of edits.install ?? []) await manager.installAndPersist(source);
|
|
110
|
+
for (const source of edits.remove ?? []) await manager.removeAndPersist(source);
|
|
111
|
+
for (const source of edits.update ?? []) await manager.update(source);
|
|
112
|
+
// pi queues settings writes; the file has to be on disk before the snapshot re-reads it.
|
|
113
|
+
await settings.flush();
|
|
114
|
+
const errors = settingsErrors(settings);
|
|
115
|
+
const snapshot = readPackages(paths);
|
|
116
|
+
return { ...snapshot, errors: [...errors, ...snapshot.errors] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ask the registries and remotes which configured sources have something newer. An unreachable registry is
|
|
121
|
+
* the answer, not a crash — the daemon asks this on a machine that may be offline.
|
|
122
|
+
*/
|
|
123
|
+
export async function checkPackageUpdates(paths: FlypiPaths): Promise<PackageCheck> {
|
|
124
|
+
const { manager } = packages(paths);
|
|
125
|
+
try {
|
|
126
|
+
return { checkedAt: new Date().toISOString(), updates: (await manager.checkForAvailableUpdates()).map((entry) => entry.source) };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { checkedAt: new Date().toISOString(), updates: [], error: (error as Error).message };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Is the answer too old to trust (or absent)? */
|
|
133
|
+
export function dueForPackageCheck(check: PackageCheck | null, now = Date.now()): boolean {
|
|
134
|
+
const at = Date.parse(check?.checkedAt ?? "");
|
|
135
|
+
return Number.isNaN(at) || now - at >= PACKAGE_CHECK_INTERVAL_MS;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function readPackageCheck(root: string): PackageCheck | null {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(readFileSync(join(root, PACKAGE_CHECK_FILE), "utf8")) as PackageCheck;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function writePackageCheck(root: string, check: PackageCheck): void {
|
|
147
|
+
const file = join(root, PACKAGE_CHECK_FILE);
|
|
148
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
149
|
+
writeFileSync(file, `${JSON.stringify(check, null, 2)}\n`);
|
|
150
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every path flypi owns lives under `<repo>/.flypi/`. Nothing here may resolve
|
|
6
|
+
* inside the host `~/.pi` (docs/risks.md risk 1). Pass these to pi explicitly —
|
|
7
|
+
* never rely on pi's defaults, which do point at `~/.pi/agent`.
|
|
8
|
+
*/
|
|
9
|
+
export interface FlypiPaths {
|
|
10
|
+
/** Repo root, i.e. the directory containing `.flypi/`. */
|
|
11
|
+
root: string;
|
|
12
|
+
/** pi's "agent directory": settings.json, models.json, auth.json, mcp.json. */
|
|
13
|
+
agentDir: string;
|
|
14
|
+
/** pi session JSONL files. */
|
|
15
|
+
sessionsDir: string;
|
|
16
|
+
/** SQLite memory store (Phase 3) — the memory component's own database. */
|
|
17
|
+
memoryDir: string;
|
|
18
|
+
/** The memory database file. Named once so no entrypoint can point at a second one by mistake. */
|
|
19
|
+
memoryDbPath: string;
|
|
20
|
+
/** The backend's own database: the run record (Phase 4.5). Never opened by the memory component. */
|
|
21
|
+
runDbPath: string;
|
|
22
|
+
/** Embedding-model cache (Phase 3). */
|
|
23
|
+
modelsDir: string;
|
|
24
|
+
/** Files pasted into the web UI, which the agent then opens by path (docs/protocol.md). */
|
|
25
|
+
uploadsDir: string;
|
|
26
|
+
authPath: string;
|
|
27
|
+
modelsPath: string;
|
|
28
|
+
settingsPath: string;
|
|
29
|
+
mcpPath: string;
|
|
30
|
+
/** Where pi's MCP extension appends server log messages (`logPath`), and where the status panel reads them back. */
|
|
31
|
+
mcpLogPath: string;
|
|
32
|
+
/**
|
|
33
|
+
* pi's MCP OAuth state (client registration, access/refresh tokens, the pending PKCE verifier), keyed by
|
|
34
|
+
* server name and URL. Named here because pi's default is `<agent-dir>/mcp-auth.json` of *its* agent dir,
|
|
35
|
+
* which for flypi would be the host `~/.pi/agent` (risk 1).
|
|
36
|
+
*/
|
|
37
|
+
mcpAuthPath: string;
|
|
38
|
+
/** The vault's key file (docs/secrets.md): mode 0600, created on first use. Never the model keys. */
|
|
39
|
+
secretKeyPath: string;
|
|
40
|
+
/** Where `secret_file` writes a plaintext for a command that only takes a path (ssh, gpg). */
|
|
41
|
+
secretsDir: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function flypiPaths(root: string = process.cwd()): FlypiPaths {
|
|
45
|
+
const base = resolve(root);
|
|
46
|
+
const dir = join(base, ".flypi");
|
|
47
|
+
const agentDir = join(dir, "agent");
|
|
48
|
+
return {
|
|
49
|
+
root: base,
|
|
50
|
+
agentDir,
|
|
51
|
+
sessionsDir: join(dir, "sessions"),
|
|
52
|
+
memoryDir: join(dir, "memory"),
|
|
53
|
+
memoryDbPath: join(dir, "memory", "flypi.db"),
|
|
54
|
+
runDbPath: join(dir, "backend", "runs.db"),
|
|
55
|
+
modelsDir: join(dir, "models"),
|
|
56
|
+
uploadsDir: join(dir, "uploads"),
|
|
57
|
+
authPath: join(agentDir, "auth.json"),
|
|
58
|
+
modelsPath: join(agentDir, "models.json"),
|
|
59
|
+
settingsPath: join(agentDir, "settings.json"),
|
|
60
|
+
mcpPath: join(agentDir, "mcp.json"),
|
|
61
|
+
mcpLogPath: join(agentDir, "mcp.log"),
|
|
62
|
+
mcpAuthPath: join(agentDir, "mcp-auth.json"),
|
|
63
|
+
secretKeyPath: join(dir, "secret.key"),
|
|
64
|
+
secretsDir: join(dir, "secrets"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The invariant as a runtime check rather than a comment: flypi must never read
|
|
70
|
+
* or write the host agent dir. Throws with an actionable message.
|
|
71
|
+
*/
|
|
72
|
+
export function assertFlypiOwned(paths: FlypiPaths): void {
|
|
73
|
+
const host = resolve(join(homedir(), ".pi"));
|
|
74
|
+
if (paths.root === host || paths.root.startsWith(host + "/")) {
|
|
75
|
+
throw new Error(`flypi config resolves inside the host pi directory ${paths.root} — move .flypi/ outside ~/.pi`);
|
|
76
|
+
}
|
|
77
|
+
if (paths.agentDir !== join(paths.root, ".flypi", "agent")) {
|
|
78
|
+
throw new Error(`agentDir must be .flypi/agent, got ${paths.agentDir}`);
|
|
79
|
+
}
|
|
80
|
+
}
|