@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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The permission gate (docs/architecture.md Phase 5): a tool on flypi's ask-before-use list does not run
|
|
3
|
+
* until the user has put it there. `permissions.askBeforeUse` in `.flypi/agent/settings.json`, next to the
|
|
4
|
+
* `memory` block src/memory/config.ts reads — one place for what flypi asks pi to do.
|
|
5
|
+
*
|
|
6
|
+
* The plan said "using tool annotations". It cannot: pi hands a `tool_call` handler a tool *name* and its
|
|
7
|
+
* arguments, and the MCP annotations a server announces (`destructiveHint`, `openWorldHint`) stay inside
|
|
8
|
+
* pi's MCP extension, which exposes none of its connection state to the host (docs/pi-api.md). So the list
|
|
9
|
+
* is of tool names, and the status panel (src/agent/mcp-status.ts) is where the annotations surface — a
|
|
10
|
+
* user reads "this tool says it is destructive" there and writes its name here.
|
|
11
|
+
*
|
|
12
|
+
* The list is re-read on every call rather than captured at session start, so the loop closes without a
|
|
13
|
+
* restart: the model is told the call was gated, the user adds the name in the file, and the next call runs.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { FlypiPaths } from "./paths.ts";
|
|
18
|
+
|
|
19
|
+
/** pi's own result shape for a blocked call (`ToolCallEventResult`). */
|
|
20
|
+
export const blockedToolCall = (reason: string) => ({ block: true, reason });
|
|
21
|
+
|
|
22
|
+
const BLOCKED = (toolName: string) =>
|
|
23
|
+
blockedToolCall(
|
|
24
|
+
`flypi did not run "${toolName}": it needs your approval first (it is on permissions.askBeforeUse in ` +
|
|
25
|
+
`.flypi/agent/settings.json). Ask the user whether this call may run; if they agree, they add "${toolName}" to that list and you can call it again.`,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `permissions.askBeforeUse` out of a parsed settings.json. Anything that is not a non-empty string is
|
|
30
|
+
* dropped: this file is also pi's, and a stray key there must not become a gate that matches nothing.
|
|
31
|
+
*/
|
|
32
|
+
export function askBeforeUseOf(settings: unknown): string[] {
|
|
33
|
+
const permissions = (settings as { permissions?: { askBeforeUse?: unknown } } | null)?.permissions;
|
|
34
|
+
const list = Array.isArray(permissions?.askBeforeUse) ? permissions.askBeforeUse : [];
|
|
35
|
+
return list.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "").map((entry) => entry.trim());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a tool is on the list. A name matches exactly; a name ending in `*` matches that prefix, which is
|
|
40
|
+
* how a whole server is gated (`mcp__filesystem__*`) without listing every tool it may grow.
|
|
41
|
+
*/
|
|
42
|
+
export function needsApproval(list: readonly string[], toolName: string): boolean {
|
|
43
|
+
return list.some((entry) => (entry.endsWith("*") ? toolName.startsWith(entry.slice(0, -1)) : entry === toolName));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The extension that enforces it. Registered by `createFlypiSession` like the other flypi extensions, so
|
|
48
|
+
* every session — the daemon's, `npm run serve`'s, a test's — is gated the same way.
|
|
49
|
+
*/
|
|
50
|
+
export function createPermissionGateExtension(paths: FlypiPaths): ExtensionFactory {
|
|
51
|
+
return (pi) => {
|
|
52
|
+
pi.on("tool_call", (event) => {
|
|
53
|
+
if (!needsApproval(readList(paths.settingsPath), event.toolName)) return;
|
|
54
|
+
return BLOCKED(event.toolName);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read the list now, so an edit takes effect on the next call. A settings.json that will not parse asks for
|
|
61
|
+
* nothing: pi itself would have refused to start the conversation over that file, and a gate that guessed at
|
|
62
|
+
* a name would block tools nobody asked to gate.
|
|
63
|
+
*/
|
|
64
|
+
function readList(settingsPath: string): string[] {
|
|
65
|
+
try {
|
|
66
|
+
return askBeforeUseOf(JSON.parse(readFileSync(settingsPath, "utf8")));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's MCP OAuth machinery, reached at the file pi's own MCP extension loads it from.
|
|
3
|
+
*
|
|
4
|
+
* pi does not export any of this from its package entry: `createMcpExtension()` is the only public door to
|
|
5
|
+
* the code, and it runs sign-in solely through pi's TUI (`/mcp`, which draws a terminal dialog). flypi signs
|
|
6
|
+
* a user in from the settings view of a headless server, so it calls the same function the extension calls —
|
|
7
|
+
* `signInMcpServer` — with a prompt of its own: the URL goes to the browser, the redirect comes back as a
|
|
8
|
+
* paste (docs/architecture.md Phase 5, risk 5).
|
|
9
|
+
*
|
|
10
|
+
* The shapes below are copied from pi's `.d.ts` files, which the package's `exports` map keeps unreachable
|
|
11
|
+
* as well. They describe four pi functions and nothing more; re-check them against
|
|
12
|
+
* `dist/extensions/mcp/oauth.d.ts` after a pi upgrade (docs/pi-api.md).
|
|
13
|
+
*/
|
|
14
|
+
import { getPackageDir, type McpExtensionOptions, type McpServerConfig } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import type { FlypiPaths } from "./paths.ts";
|
|
18
|
+
|
|
19
|
+
/** pi's `McpOAuthCredentialStore`, which `createMcpExtension({ credentials })` takes. */
|
|
20
|
+
export type PiMcpCredentials = NonNullable<McpExtensionOptions["credentials"]>;
|
|
21
|
+
|
|
22
|
+
/** What an authorization needs: pi's `McpOAuthSettings`, i.e. the `oauth` block of an `mcp.json` entry. */
|
|
23
|
+
export interface PiMcpOAuthSettings {
|
|
24
|
+
clientId?: string;
|
|
25
|
+
/** Already resolved — `${NAME}` and `!command` expanded. */
|
|
26
|
+
clientSecret?: string;
|
|
27
|
+
callbackPort?: number;
|
|
28
|
+
callbackUrl?: string;
|
|
29
|
+
scope?: string;
|
|
30
|
+
clientName?: string;
|
|
31
|
+
clientRegistration?: "dcr" | "cimd";
|
|
32
|
+
authServerMetadataUrl?: URL;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The two ways a sign-in finishes: the browser reaches the loopback callback, or the user pastes what it landed on. */
|
|
36
|
+
export interface PiMcpSignInPrompt {
|
|
37
|
+
showAuthorizationUrl(url: URL): void;
|
|
38
|
+
/** `undefined` or "" cancels. Abandoned once the loopback callback arrives. */
|
|
39
|
+
promptForRedirectUrl(signal: AbortSignal): Promise<string | undefined>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** pi's `McpOAuthCredentialStore.forServer()` — the per-server state handle `signInMcpServer` takes. */
|
|
43
|
+
export interface PiMcpServerStore {
|
|
44
|
+
load(): unknown;
|
|
45
|
+
save(state: { serverUrl: string; tokens?: { access_token?: string; token_type?: string } }): void | Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PiMcpRuntime {
|
|
49
|
+
signInMcpServer(options: { serverUrl: string; store: PiMcpServerStore; settings: PiMcpOAuthSettings; prompt: PiMcpSignInPrompt }): Promise<void>;
|
|
50
|
+
McpOAuthCredentialStore: new (backend: unknown, lockDir: string) => PiMcpCredentials & {
|
|
51
|
+
/** What a sign-in stored for this server, or nothing. Synchronous, like pi's own. */
|
|
52
|
+
tokens(name: string, serverUrl: string): { access_token?: string } | undefined;
|
|
53
|
+
forServer(name: string, serverUrl: string): PiMcpServerStore;
|
|
54
|
+
};
|
|
55
|
+
FileAuthStorageBackend: new (filePath: string) => unknown;
|
|
56
|
+
McpSignInCancelledError: new (...args: never[]) => Error;
|
|
57
|
+
/** `${NAME}` / `!command` → the value, or a thrown error naming what is missing. */
|
|
58
|
+
resolveConfigValueOrThrow: (value: string, description: string) => string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cached: Promise<PiMcpRuntime> | undefined;
|
|
62
|
+
|
|
63
|
+
/** Loaded once per process, and only when something actually needs it: this pulls in pi's MCP client. */
|
|
64
|
+
export function loadPiMcpRuntime(): Promise<PiMcpRuntime> {
|
|
65
|
+
const dir = getPackageDir();
|
|
66
|
+
return (cached ??= Promise.all([
|
|
67
|
+
import(pathToFileURL(join(dir, "dist/extensions/mcp/runtime.js")).href),
|
|
68
|
+
import(pathToFileURL(join(dir, "dist/core/auth-storage.js")).href),
|
|
69
|
+
import(pathToFileURL(join(dir, "dist/core/resolve-config-value.js")).href),
|
|
70
|
+
]).then(([runtime, authStorage, configValue]) => ({
|
|
71
|
+
signInMcpServer: runtime.signInMcpServer,
|
|
72
|
+
McpOAuthCredentialStore: runtime.McpOAuthCredentialStore,
|
|
73
|
+
FileAuthStorageBackend: authStorage.FileAuthStorageBackend,
|
|
74
|
+
McpSignInCancelledError: runtime.McpSignInCancelledError,
|
|
75
|
+
resolveConfigValueOrThrow: configValue.resolveConfigValueOrThrow,
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The credential store, pointed at flypi's own agent dir. pi's default is `mcp-auth.json` in *its* agent dir
|
|
81
|
+
* (`$PI_CODING_AGENT_DIR`, else `~/.pi/agent`), which is the config bleed this repo refuses everywhere else
|
|
82
|
+
* (risk 1) — so every consumer passes this: a session's connections, the status probe, and a sign-in.
|
|
83
|
+
*/
|
|
84
|
+
export async function mcpCredentials(paths: FlypiPaths): Promise<PiMcpCredentials & { tokens(name: string, serverUrl: string): { access_token?: string } | undefined }> {
|
|
85
|
+
const pi = await loadPiMcpRuntime();
|
|
86
|
+
return new pi.McpOAuthCredentialStore(new pi.FileAuthStorageBackend(paths.mcpAuthPath), paths.agentDir);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether pi authenticates this entry with OAuth: an HTTP server that sends no `Authorization` header of its
|
|
91
|
+
* own and delegates to no provider. That is pi's own rule, copied because the probe and the sign-in have to
|
|
92
|
+
* agree with what a session will do — otherwise the panel offers a sign-in the file will ignore.
|
|
93
|
+
*/
|
|
94
|
+
export function usesMcpOAuth(config: McpServerConfig): boolean {
|
|
95
|
+
if (!("url" in config) || config.auth?.provider) return false;
|
|
96
|
+
return !Object.keys(config.headers ?? {}).some((header) => header.toLowerCase() === "authorization");
|
|
97
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
createAgentSession,
|
|
5
|
+
createMcpExtension,
|
|
6
|
+
createToolSearchExtension,
|
|
7
|
+
DefaultResourceLoader,
|
|
8
|
+
type LoadedMcpConfig,
|
|
9
|
+
type McpServerConfig,
|
|
10
|
+
type McpServerEntry,
|
|
11
|
+
ModelRuntime,
|
|
12
|
+
type CreateAgentSessionOptions,
|
|
13
|
+
type ExtensionUIContext,
|
|
14
|
+
SessionManager,
|
|
15
|
+
SettingsManager,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { assertFlypiOwned, flypiPaths, type FlypiPaths } from "./paths.ts";
|
|
18
|
+
import { mcpCredentials, usesMcpOAuth } from "./pi-mcp.ts";
|
|
19
|
+
import { importEnvCredential, readAuth, resolveCredential } from "./onboarding.ts";
|
|
20
|
+
import { memoryConfig } from "../memory/config.ts";
|
|
21
|
+
import { createMemoryRecallExtension, memoryInstruction, memoryTool, recallServer } from "../memory/recall.ts";
|
|
22
|
+
import { backgroundMount, reviewPrompt, type ReviewParent } from "../memory/review.ts";
|
|
23
|
+
import { createPermissionGateExtension } from "./permission-gate.ts";
|
|
24
|
+
import { createSecretsExtension } from "../secrets/extension.ts";
|
|
25
|
+
import type { SecretStore } from "../secrets/vault.ts";
|
|
26
|
+
|
|
27
|
+
/** Derived from pi's own option types, so no transitive pi-ai / pi-agent-core import is needed here. */
|
|
28
|
+
export type FlypiModel = NonNullable<CreateAgentSessionOptions["model"]>;
|
|
29
|
+
export type FlypiThinkingLevel = NonNullable<CreateAgentSessionOptions["thinkingLevel"]>;
|
|
30
|
+
export type FlypiSession = NonNullable<Awaited<ReturnType<typeof createAgentSession>>["session"]>;
|
|
31
|
+
|
|
32
|
+
/** A configuration problem the user can act on. probe.ts prints `message`, never a stack trace. */
|
|
33
|
+
export class FlypiConfigError extends Error {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The chat renders markdown (ui/Chat.svelte, src/shared/markdown.ts) and nothing in pi's prompt asks
|
|
37
|
+
* for it — pi's only answer-shaping rules are "be concise" and "show file paths clearly" — so the
|
|
38
|
+
* format the renderer was built for is stated once, here. The loader's `appendSystemPrompt` becomes
|
|
39
|
+
* the prompt's addendum section, so it is byte-stable across turns like the rest of the system prompt.
|
|
40
|
+
*/
|
|
41
|
+
export const ANSWER_FORMAT_PROMPT =
|
|
42
|
+
"Answer in markdown — this interface renders it: headings for structure, fenced code blocks for code and command output, tables for comparisons, backticks for file paths and identifiers.";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one place flypi creates an `AgentSession`: probe, the WS server, and the tests all go
|
|
46
|
+
* through here, so the config-isolation invariant (docs/risks.md risk 1) is enforced once.
|
|
47
|
+
*
|
|
48
|
+
* `modelRuntime`/`model` are overridable so a test can pass the stub model
|
|
49
|
+
* (tests/helpers/stub-model.ts) instead of a real provider.
|
|
50
|
+
*/
|
|
51
|
+
export async function createFlypiSession(options: {
|
|
52
|
+
root?: string;
|
|
53
|
+
modelRuntime?: ModelRuntime;
|
|
54
|
+
model?: FlypiModel;
|
|
55
|
+
thinkingLevel?: FlypiThinkingLevel;
|
|
56
|
+
tools?: string[];
|
|
57
|
+
env?: Record<string, string | undefined>;
|
|
58
|
+
/** Supply an existing or opened session instead of starting a new one. */
|
|
59
|
+
sessionManager?: SessionManager;
|
|
60
|
+
/**
|
|
61
|
+
* What MCP servers this session gets, instead of what `.flypi/agent/mcp.json` says. Only the probe's
|
|
62
|
+
* `--no-memory` control needs it (docs/prompt-cache.md C1): renaming the real file aside to run the
|
|
63
|
+
* control left the memory server unconfigured whenever the process was killed mid-run
|
|
64
|
+
* (code-review.md finding 1), and the config is not something a measurement is allowed to edit.
|
|
65
|
+
*/
|
|
66
|
+
mcpConfig?: LoadedMcpConfig;
|
|
67
|
+
/**
|
|
68
|
+
* What automatic recall put in front of the model this turn, handed to the caller as it happens. The block
|
|
69
|
+
* itself is a session entry now (Phase 4.7 C2), but this is still how the debug panel learns *which* block
|
|
70
|
+
* the turn in front of it was shown, and the one thing it can never read out of the file is the note a
|
|
71
|
+
* failed search left on the message (docs/memory.md Phase 4).
|
|
72
|
+
*/
|
|
73
|
+
onMemoryBlock?: (block: string) => void;
|
|
74
|
+
/**
|
|
75
|
+
* flypi's own id for the conversation, so the turns the memory store records are keyed by the same id the
|
|
76
|
+
* UI and the registry use (docs/memory.md Phase 4). Left out, nothing is recorded about the turns.
|
|
77
|
+
*/
|
|
78
|
+
conversationId?: string;
|
|
79
|
+
/**
|
|
80
|
+
* The vault (docs/secrets.md Phase 5.5). Given, the session can reach it and every tool result, memory body
|
|
81
|
+
* and answer in it is redacted against it; omitted, the conversation has no secret tools at all — which is
|
|
82
|
+
* what a process that never opened the vault (a bare `probe.ts`, a unit test) should get.
|
|
83
|
+
*/
|
|
84
|
+
vault?: SecretStore;
|
|
85
|
+
/**
|
|
86
|
+
* This is a conversation a human is in, so it may spawn a review fork after every `memory.reviewInterval`
|
|
87
|
+
* delivered answers (docs/memory.md Phase 4.8). Off unless asked for: a probe, a daemon job, a subagent and
|
|
88
|
+
* a fork itself never spawn one, which is the whole suppression rule — there is no runner to fire.
|
|
89
|
+
*/
|
|
90
|
+
review?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The conversation's extension UI (src/server/extension-ui.ts): what `ctx.ui.notify/select/confirm/input`
|
|
93
|
+
* do. Absent, pi binds its no-op context and an extension command's output goes nowhere — which is what a
|
|
94
|
+
* process with no page attached (a probe, a fork) should get.
|
|
95
|
+
*/
|
|
96
|
+
extensionUi?: ExtensionUIContext;
|
|
97
|
+
/**
|
|
98
|
+
* Automatic recall (src/memory/recall.ts). False for a review fork: it inherits the parent's memory block
|
|
99
|
+
* rather than searching, logging a turn of its own, or auto-capturing over a conversation that is not its
|
|
100
|
+
* own. The MCP server is still connected, so the fork can still write what it learned.
|
|
101
|
+
*/
|
|
102
|
+
memoryRecall?: boolean;
|
|
103
|
+
} = {}): Promise<{ session: FlypiSession; paths: FlypiPaths; modelRuntime: ModelRuntime }> {
|
|
104
|
+
const paths = flypiPaths(options.root ?? process.cwd());
|
|
105
|
+
assertFlypiOwned(paths);
|
|
106
|
+
|
|
107
|
+
const modelRuntime =
|
|
108
|
+
options.modelRuntime ??
|
|
109
|
+
(await ModelRuntime.create({ authPath: paths.authPath, modelsPath: paths.modelsPath }));
|
|
110
|
+
|
|
111
|
+
const sessionManager = options.sessionManager ?? SessionManager.create(paths.root, paths.sessionsDir);
|
|
112
|
+
// A model the caller injects (the stub-model tests) wins. Otherwise the settings default is read — but
|
|
113
|
+
// it is *passed* only when the session has nothing to restore: createAgentSession restores a resumed
|
|
114
|
+
// session's own model from its `model_change` entries when no model is given, and answering over that
|
|
115
|
+
// is why a reopened chat went back to the default model while pi kept its thinking level. The settings
|
|
116
|
+
// default stays the fallback for a recorded model that can no longer be resolved, and pi's own
|
|
117
|
+
// findInitialModel applies it — so a session with no messages still starts where flypi starts one.
|
|
118
|
+
const resuming = sessionManager.buildSessionContext().messages.length > 0;
|
|
119
|
+
const model = options.model ?? (resuming ? undefined : resolveConfiguredModel(paths, modelRuntime, options.env));
|
|
120
|
+
|
|
121
|
+
// MCP is not part of an SDK session unless the host asks for it (docs/sdk.md), and pi's own MCP
|
|
122
|
+
// config loader reads `$PI_CODING_AGENT_DIR` (else `~/.pi/agent`) rather than the `agentDir` passed
|
|
123
|
+
// below — so both the extension and where it reads from are flypi's job (docs/risks.md risk 1).
|
|
124
|
+
const mcpConfig = options.mcpConfig ?? readFlypiMcpConfig(paths);
|
|
125
|
+
// pi keeps an OAuth server's tokens in `mcp-auth.json` in *its* agent dir, which for flypi would be the
|
|
126
|
+
// host `~/.pi/agent` — the same config bleed as the config file itself (risk 1). Only built when an entry
|
|
127
|
+
// could use it, because loading pi's OAuth machinery means loading its MCP client.
|
|
128
|
+
const mcpCredentialsStore = mcpConfig.servers.some((server) => server.config.enabled !== false && usesMcpOAuth(server.config))
|
|
129
|
+
? await mcpCredentials(paths)
|
|
130
|
+
: undefined;
|
|
131
|
+
// Resolved once, and used for both halves of the memory wiring: the instruction below and the recall
|
|
132
|
+
// extension have to agree about whether this session has a memory server at all.
|
|
133
|
+
const memoryServer = recallServer(mcpConfig);
|
|
134
|
+
// The review fork (docs/memory.md Phase 4.8): a conversation a human is in, a store to write to, and an
|
|
135
|
+
// interval that is not switched off. `session` is read from inside the closure, which runs turns later.
|
|
136
|
+
const reviewInterval = options.review && memoryServer ? memoryConfig(paths).review.interval : 0;
|
|
137
|
+
const review =
|
|
138
|
+
reviewInterval > 0 && memoryServer
|
|
139
|
+
? {
|
|
140
|
+
interval: reviewInterval,
|
|
141
|
+
fork: (parent: ReviewParent) =>
|
|
142
|
+
runReviewFork({ paths, modelRuntime, mcpConfig, memoryServer, vault: options.vault, tools: options.tools, parentSession: () => session }, parent),
|
|
143
|
+
}
|
|
144
|
+
: undefined;
|
|
145
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
146
|
+
cwd: paths.root,
|
|
147
|
+
agentDir: paths.agentDir,
|
|
148
|
+
// The loader, not an extension: these say the same thing on every turn, and a per-turn
|
|
149
|
+
// `before_agent_start` handler could only repeat them. The memory sentence used to sit at the top of
|
|
150
|
+
// the recalled block; there it rode in the newest message, the one region a prompt cache re-pays at
|
|
151
|
+
// the turn boundary, while the prompt is byte-stable and at the front of every request
|
|
152
|
+
// (docs/prompt-cache.md Phase 4.6 B1). A session with no memory server is not told about one.
|
|
153
|
+
appendSystemPrompt: [
|
|
154
|
+
ANSWER_FORMAT_PROMPT,
|
|
155
|
+
...(memoryServer ? [memoryInstruction(memoryServer.name)] : []),
|
|
156
|
+
],
|
|
157
|
+
extensionFactories: [
|
|
158
|
+
// flypi's gate first: a tool the user has not approved is refused before any other extension
|
|
159
|
+
// gets to act on the call (src/agent/permission-gate.ts).
|
|
160
|
+
createPermissionGateExtension(paths),
|
|
161
|
+
createMcpExtension({
|
|
162
|
+
loadConfig: () => mcpConfig,
|
|
163
|
+
logPath: paths.mcpLogPath,
|
|
164
|
+
// Where a signed-in server's token comes from: `.flypi/agent/mcp-auth.json`, the file the settings
|
|
165
|
+
// view's sign-in writes (src/agent/mcp-signin.ts). Left out, pi would read the host's.
|
|
166
|
+
...(mcpCredentialsStore ? { credentials: mcpCredentialsStore } : {}),
|
|
167
|
+
}),
|
|
168
|
+
// The only route to an MCP server the model was not given its tools for: `exposure: "deferred"`,
|
|
169
|
+
// and pi's own default, `codemode` — flypi loads no codemode extension, so without this tool such
|
|
170
|
+
// a server is connected, answers, and can never be used (docs/mcp.md, "Control tool exposure").
|
|
171
|
+
// The settings view offers those exposures, so the sessions have to be able to load them.
|
|
172
|
+
createToolSearchExtension(),
|
|
173
|
+
...(options.memoryRecall === false
|
|
174
|
+
? []
|
|
175
|
+
: [createMemoryRecallExtension(memoryServer, options.onMemoryBlock, { conversationId: options.conversationId, ...(review ? { review } : {}) })]),
|
|
176
|
+
// Registered only where a vault was opened, and last: the redaction it installs rewrites what the
|
|
177
|
+
// other extensions produced on the way to the transcript (docs/secrets.md Phase 5.5).
|
|
178
|
+
...(options.vault ? [createSecretsExtension(options.vault, { paths })] : []),
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
await resourceLoader.reload();
|
|
182
|
+
|
|
183
|
+
mkdirSync(paths.sessionsDir, { recursive: true });
|
|
184
|
+
const { session } = await createAgentSession({
|
|
185
|
+
cwd: paths.root,
|
|
186
|
+
agentDir: paths.agentDir,
|
|
187
|
+
modelRuntime,
|
|
188
|
+
model,
|
|
189
|
+
thinkingLevel: options.thinkingLevel,
|
|
190
|
+
resourceLoader,
|
|
191
|
+
sessionManager,
|
|
192
|
+
settingsManager: SettingsManager.create(paths.root, paths.agentDir),
|
|
193
|
+
tools: options.tools,
|
|
194
|
+
});
|
|
195
|
+
// Emits `session_start`, which is what connects the MCP servers; without it the tools never arrive.
|
|
196
|
+
// Bound once: a second `bindExtensions` would emit `session_start` again and reconnect every server.
|
|
197
|
+
// `rpc`, not `tui`: there is a UI, but it is not a terminal, and extensions guard terminal-only calls
|
|
198
|
+
// (`ctx.ui.custom`, widgets, themes) on that value.
|
|
199
|
+
await session.bindExtensions(options.extensionUi ? { uiContext: options.extensionUi, mode: "rpc" } : {});
|
|
200
|
+
// Registering it is not enough: `tool_search` comes up inactive. A `deferred` server's tools happen to
|
|
201
|
+
// activate it when they register, and a default-exposure (`codemode`) server's do not — the first version
|
|
202
|
+
// of this line was deleted as redundant and only the second case in mcp-tool-search.test.ts noticed.
|
|
203
|
+
// An add-only loadout, so pi keeps the MCP tools still connecting pending and declares them when they
|
|
204
|
+
// arrive (`setActiveToolsByName` in pi's own comment).
|
|
205
|
+
session.setActiveToolsByName([...session.getActiveToolNames(), "tool_search"]);
|
|
206
|
+
return { session, paths, modelRuntime };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* One review fork (docs/memory.md Phase 4.8): the parent's conversation replayed into a session that writes
|
|
211
|
+
* nothing to disk, asked one question — save what is worth saving, or say so — and disposed.
|
|
212
|
+
*
|
|
213
|
+
* It goes through `createFlypiSession` rather than `createAgentSession` so the config-isolation invariant
|
|
214
|
+
* (risk 1) is still enforced once, and so the request is built the same way the parent's was: same provider,
|
|
215
|
+
* model and thinking level, same system-prompt bytes, same advertised tools. That is what lets the fork extend
|
|
216
|
+
* a prefix the route has already read instead of paying ~30K tokens cold (docs/prompt-cache.md).
|
|
217
|
+
*
|
|
218
|
+
* What makes it safe is the session manager: `SessionManager.inMemory` seeded with the parent's entries is
|
|
219
|
+
* pi's own non-persisting session, so the fork can write memories and cannot write a line of the conversation
|
|
220
|
+
* a human is reading (risk 4). Its writes land `session_kind='background'` because its memory server is on the
|
|
221
|
+
* background mount, where the store refuses supersede, forget and `owner` (src/memory/mcp.ts).
|
|
222
|
+
*/
|
|
223
|
+
async function runReviewFork(
|
|
224
|
+
deps: {
|
|
225
|
+
paths: FlypiPaths;
|
|
226
|
+
modelRuntime: ModelRuntime;
|
|
227
|
+
mcpConfig: LoadedMcpConfig;
|
|
228
|
+
memoryServer: McpServerEntry;
|
|
229
|
+
vault?: SecretStore;
|
|
230
|
+
tools?: string[];
|
|
231
|
+
parentSession: () => FlypiSession;
|
|
232
|
+
},
|
|
233
|
+
parent: ReviewParent,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
const source = deps.parentSession();
|
|
236
|
+
const { session } = await createFlypiSession({
|
|
237
|
+
root: deps.paths.root,
|
|
238
|
+
modelRuntime: deps.modelRuntime,
|
|
239
|
+
// The parent's own model and thinking level, not the settings default: a fork on another model is a
|
|
240
|
+
// different request, and extending the parent's prefix is the whole cost argument.
|
|
241
|
+
...(source.model ? { model: source.model } : {}),
|
|
242
|
+
...(source.thinkingLevel ? { thinkingLevel: source.thinkingLevel } : {}),
|
|
243
|
+
...(deps.tools ? { tools: deps.tools } : {}),
|
|
244
|
+
...(deps.vault ? { vault: deps.vault } : {}),
|
|
245
|
+
// Same servers, names and exposures — only the memory server moved to the background mount.
|
|
246
|
+
mcpConfig: backgroundMount(deps.mcpConfig, deps.memoryServer.name),
|
|
247
|
+
sessionManager: SessionManager.inMemory(deps.paths.root, undefined, parent.entries),
|
|
248
|
+
memoryRecall: false,
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
// MCP tools arrive asynchronously, and asking before they land would advertise a smaller `tools[]` than
|
|
252
|
+
// the parent's: a different request, and a cold cache. Bounded, because a server that never answers must
|
|
253
|
+
// not hang a fork nobody is waiting on.
|
|
254
|
+
if (await toolsArrived(session, memoryTool(deps.memoryServer.name, "memory_write"))) {
|
|
255
|
+
await session.prompt(reviewPrompt(deps.memoryServer.name));
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
session.dispose();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Whether the memory tools are declared yet. A fork with no memory tools has nothing to be for. */
|
|
263
|
+
async function toolsArrived(session: FlypiSession, writeTool: string, deadlineMs = 10_000): Promise<boolean> {
|
|
264
|
+
const until = Date.now() + deadlineMs;
|
|
265
|
+
for (;;) {
|
|
266
|
+
if (session.getActiveToolNames().includes(writeTool)) return true;
|
|
267
|
+
if (Date.now() > until) return false;
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* `.flypi/agent/mcp.json` as the MCP extension wants it. Values stay exactly as written: pi expands
|
|
274
|
+
* `${NAME}` in `env`/`headers` itself when it builds a transport, and the settings panel writes the
|
|
275
|
+
* same file (src/agent/config-files.ts), so there is one source of truth for the servers.
|
|
276
|
+
*/
|
|
277
|
+
export function readFlypiMcpConfig(paths: FlypiPaths): LoadedMcpConfig {
|
|
278
|
+
const servers: McpServerEntry[] = [];
|
|
279
|
+
const errors: string[] = [];
|
|
280
|
+
if (!existsSync(paths.mcpPath)) return { servers, errors };
|
|
281
|
+
let parsed: unknown;
|
|
282
|
+
try {
|
|
283
|
+
parsed = JSON.parse(readFileSync(paths.mcpPath, "utf8"));
|
|
284
|
+
} catch (error) {
|
|
285
|
+
return { servers, errors: [`${paths.mcpPath}: ${(error as Error).message}`] };
|
|
286
|
+
}
|
|
287
|
+
const entries = (parsed as { mcpServers?: Record<string, Record<string, unknown>> } | undefined)?.mcpServers ?? {};
|
|
288
|
+
for (const [name, config] of Object.entries(entries)) {
|
|
289
|
+
// The rule pi applies itself (docs/mcp.md): an invalid entry is reported, it does not take the
|
|
290
|
+
// rest of the file down with it.
|
|
291
|
+
if (!config || (config.url === undefined && config.command === undefined)) {
|
|
292
|
+
errors.push(`${paths.mcpPath}: "${name}" needs a url (HTTP) or a command (stdio)`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
servers.push({ name, config: config as unknown as McpServerConfig, source: paths.mcpPath, scope: "global" });
|
|
296
|
+
}
|
|
297
|
+
return { servers, errors };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read `.flypi/agent/settings.json` for the default provider/model and check the credential,
|
|
302
|
+
* with the same actionable messages probe.ts prints. Imported env keys land in auth.json once.
|
|
303
|
+
*/
|
|
304
|
+
export function resolveConfiguredModel(
|
|
305
|
+
paths: FlypiPaths,
|
|
306
|
+
modelRuntime: ModelRuntime,
|
|
307
|
+
env: Record<string, string | undefined> = process.env,
|
|
308
|
+
): FlypiModel {
|
|
309
|
+
let settings: Record<string, unknown>;
|
|
310
|
+
let models: Record<string, unknown>;
|
|
311
|
+
try {
|
|
312
|
+
settings = JSON.parse(readFileSync(paths.settingsPath, "utf8")) as Record<string, unknown>;
|
|
313
|
+
models = JSON.parse(readFileSync(paths.modelsPath, "utf8")) as Record<string, unknown>;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw new FlypiConfigError(
|
|
316
|
+
`could not read ${paths.settingsPath} or ${paths.modelsPath}: ${(error as Error).message}`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const provider = settings.defaultProvider as string | undefined;
|
|
320
|
+
const modelId = settings.defaultModel as string | undefined;
|
|
321
|
+
if (!provider || !modelId) {
|
|
322
|
+
throw new FlypiConfigError(
|
|
323
|
+
`${paths.settingsPath} must set defaultProvider and defaultModel (flypi has no built-in default).`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
importEnvCredential(paths.authPath, provider, env);
|
|
328
|
+
const credential = resolveCredential({
|
|
329
|
+
provider,
|
|
330
|
+
models: models as never,
|
|
331
|
+
auth: readAuth(paths.authPath),
|
|
332
|
+
env,
|
|
333
|
+
});
|
|
334
|
+
if (!credential.ok) throw new FlypiConfigError(credential.message);
|
|
335
|
+
|
|
336
|
+
const model = modelRuntime.getModel(provider, modelId);
|
|
337
|
+
if (!model) {
|
|
338
|
+
const known = modelRuntime
|
|
339
|
+
.getModels()
|
|
340
|
+
.map((m) => `${m.provider}/${m.id}`)
|
|
341
|
+
.join(", ");
|
|
342
|
+
throw new FlypiConfigError(
|
|
343
|
+
`model "${provider}/${modelId}" is not defined in ${paths.modelsPath}. Defined: ${known || "(none)"}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return model;
|
|
347
|
+
}
|