feat(FN-2262): merge fusion/fn-2262 (auto-resolved)
- feat(FN-2262): document Paperclip runtime configuration and constraints - docs(FN-2261): update README with implementation details - feat(FN-2261): add paperclip runtime resolution compatibility tests - feat(FN-2261): add runtime adapter and registration tests - feat(FN-2261): integrate adapter into plugin entrypoint - feat(FN-2261): implement PaperclipRuntimeAdapter - fix(FN-2261): remove pi-coding-agent re-exports from types.ts - feat(FN-2261): define runtime types for Paperclip plugin - feat(FN-2260): merge fusion/fn-2260
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
|
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react";
|
||||||
import type { DetectedDevServerCommand, DevServerSession, DevServerState } from "../api";
|
import type { DetectedDevServerCommand } from "../api";
|
||||||
import { useDevServer } from "../hooks/useDevServer";
|
import { useDevServer } from "../hooks/useDevServer";
|
||||||
import { useDevServerConfig } from "../hooks/useDevServerConfig";
|
|
||||||
import { useDevServerLogs } from "../hooks/useDevServerLogs";
|
import { useDevServerLogs } from "../hooks/useDevServerLogs";
|
||||||
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
|
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
@@ -37,32 +36,6 @@ function normalizeCwdToSource(cwd: string): string {
|
|||||||
return cwd === "." ? "root" : cwd;
|
return cwd === "." ? "root" : cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLegacyServerState(serverState: DevServerState): DevServerSession {
|
|
||||||
return {
|
|
||||||
config: {
|
|
||||||
id: serverState.id ?? "default",
|
|
||||||
name: serverState.name ?? "Dev Server",
|
|
||||||
command: serverState.command ?? "",
|
|
||||||
cwd: serverState.cwd ?? ".",
|
|
||||||
},
|
|
||||||
status: serverState.status as DevServerSession["status"],
|
|
||||||
runtime: serverState.pid
|
|
||||||
? {
|
|
||||||
pid: serverState.pid,
|
|
||||||
startedAt: serverState.startedAt ?? new Date().toISOString(),
|
|
||||||
exitCode: serverState.exitCode ?? undefined,
|
|
||||||
previewUrl: serverState.previewUrl,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
previewUrl: serverState.previewUrl ?? serverState.detectedUrl ?? serverState.manualUrl ?? undefined,
|
|
||||||
logHistory: (serverState.logs ?? []).map((line) => ({
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
stream: line.startsWith("[stderr]") ? "stderr" as const : "stdout" as const,
|
|
||||||
text: line.replace(/^\[stderr\]\s*/, ""),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSourceToCwd(source: string | null | undefined): string | null {
|
function normalizeSourceToCwd(source: string | null | undefined): string | null {
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return null;
|
return null;
|
||||||
@@ -105,33 +78,21 @@ function truncateCommand(command: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
||||||
const devServerState = useDevServer(projectId);
|
|
||||||
|
|
||||||
const legacyServerState = (devServerState.serverState as DevServerState | null | undefined) ?? null;
|
|
||||||
const session = devServerState.session
|
|
||||||
?? (legacyServerState ? normalizeLegacyServerState(legacyServerState) : null);
|
|
||||||
const detectedCommands = devServerState.detectedCommands ?? devServerState.candidates ?? [];
|
|
||||||
const previewUrl = devServerState.previewUrl ?? session?.previewUrl ?? legacyServerState?.previewUrl ?? null;
|
|
||||||
const error = devServerState.error ?? null;
|
|
||||||
const startServer = devServerState.startServer
|
|
||||||
?? (async (command: string, cwd?: string) => devServerState.start(command, cwd));
|
|
||||||
const stopServer = devServerState.stopServer ?? devServerState.stop;
|
|
||||||
const restartServer = devServerState.restartServer ?? devServerState.restart;
|
|
||||||
const setPreviewUrl = devServerState.setPreviewUrl ?? devServerState.setManualUrl;
|
|
||||||
const detectCommands = devServerState.detectCommands ?? devServerState.detect;
|
|
||||||
const refresh = devServerState.refresh ?? devServerState.refreshStatus;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
config,
|
session,
|
||||||
loading: configLoading,
|
sessions,
|
||||||
selectScript,
|
logs,
|
||||||
clearSelection,
|
detectedCommands,
|
||||||
setPreviewUrlOverride,
|
previewUrl,
|
||||||
} = useDevServerConfig(projectId);
|
isLoading,
|
||||||
|
error,
|
||||||
const manualPreviewUrlOverride = config?.previewUrlOverride ?? legacyServerState?.manualPreviewUrl ?? null;
|
startServer,
|
||||||
const effectivePreviewUrl = manualPreviewUrlOverride ?? previewUrl;
|
stopServer,
|
||||||
const isLoading = (devServerState.isLoading ?? devServerState.loading ?? false) || configLoading;
|
restartServer,
|
||||||
|
setPreviewUrl,
|
||||||
|
detectCommands,
|
||||||
|
refresh,
|
||||||
|
} = useDevServer(projectId);
|
||||||
|
|
||||||
const status = session?.status ?? "stopped";
|
const status = session?.status ?? "stopped";
|
||||||
const isRunning = status === "running" || status === "starting";
|
const isRunning = status === "running" || status === "starting";
|
||||||
@@ -146,26 +107,28 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
|||||||
loadMore: loadMoreLogs,
|
loadMore: loadMoreLogs,
|
||||||
} = useDevServerLogs(projectId, Boolean(projectId));
|
} = useDevServerLogs(projectId, Boolean(projectId));
|
||||||
|
|
||||||
const selectedSource = config?.selectedSource ?? (session?.config?.cwd ? normalizeCwdToSource(session.config.cwd) : null);
|
const effectivePreviewUrl = previewUrl;
|
||||||
|
const selectedSource = session?.config?.cwd ?? null;
|
||||||
|
|
||||||
const [showCandidates, setShowCandidates] = useState(true);
|
const [showCandidates, setShowCandidates] = useState(true);
|
||||||
const [commandInput, setCommandInput] = useState("");
|
const [commandInput, setCommandInput] = useState("");
|
||||||
const [previewInput, setPreviewInput] = useState("");
|
const [previewInput, setPreviewInput] = useState("");
|
||||||
const [selectedScript, setSelectedScript] = useState<string | null>(config?.selectedScript ?? null);
|
const [selectedScript, setSelectedScript] = useState<string | null>(null);
|
||||||
const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null);
|
const [actionInFlight, setActionInFlight] = useState<"start" | "stop" | "restart" | "preview" | null>(null);
|
||||||
|
|
||||||
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
|
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
|
||||||
|
|
||||||
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
|
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
|
||||||
const previewEmbedState = usePreviewEmbed(previewEmbedUrl);
|
const {
|
||||||
const embedStatus = previewEmbedState.embedStatus;
|
embedStatus,
|
||||||
const setEmbedStatus = previewEmbedState.setEmbedStatus;
|
setEmbedStatus,
|
||||||
const resetEmbedStatus = previewEmbedState.resetEmbedStatus ?? (previewEmbedState as { resetEmbed?: () => void }).resetEmbed ?? (() => {});
|
resetEmbedStatus,
|
||||||
const iframeRef = previewEmbedState.iframeRef;
|
iframeRef,
|
||||||
const isEmbedded = previewEmbedState.isEmbedded;
|
isEmbedded,
|
||||||
const isBlocked = previewEmbedState.isBlocked;
|
isBlocked,
|
||||||
const blockReason = previewEmbedState.blockReason ?? previewEmbedState.embedContext ?? null;
|
blockReason,
|
||||||
const retry = previewEmbedState.retry ?? (() => resetEmbedStatus());
|
retry,
|
||||||
|
} = usePreviewEmbed(previewEmbedUrl);
|
||||||
|
|
||||||
const [showFallback, setShowFallback] = useState(false);
|
const [showFallback, setShowFallback] = useState(false);
|
||||||
const prevStatusRef = useRef(embedStatus);
|
const prevStatusRef = useRef(embedStatus);
|
||||||
@@ -188,10 +151,6 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
|||||||
setShowFallback(false);
|
setShowFallback(false);
|
||||||
}, [effectivePreviewUrl]);
|
}, [effectivePreviewUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedScript(config?.selectedScript ?? null);
|
|
||||||
}, [config?.selectedScript]);
|
|
||||||
|
|
||||||
const selectedCandidate = useMemo(() => {
|
const selectedCandidate = useMemo(() => {
|
||||||
if (!selectedScript) {
|
if (!selectedScript) {
|
||||||
return null;
|
return null;
|
||||||
@@ -318,28 +277,14 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
|||||||
setSelectedScript(candidate.scriptName);
|
setSelectedScript(candidate.scriptName);
|
||||||
setShowCandidates(false);
|
setShowCandidates(false);
|
||||||
setCommandInput(candidate.command);
|
setCommandInput(candidate.command);
|
||||||
|
|
||||||
void selectScript({
|
|
||||||
name: candidate.scriptName,
|
|
||||||
command: candidate.command,
|
|
||||||
source: normalizeCwdToSource(candidate.cwd),
|
|
||||||
}).catch((selectionError) => {
|
|
||||||
addToast(normalizeError(selectionError), "error");
|
|
||||||
});
|
|
||||||
|
|
||||||
addToast(`Selected ${candidate.scriptName} script.`, "success");
|
addToast(`Selected ${candidate.scriptName} script.`, "success");
|
||||||
}, [addToast, selectScript]);
|
}, [addToast]);
|
||||||
|
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
setSelectedScript(null);
|
setSelectedScript(null);
|
||||||
setShowCandidates(true);
|
setShowCandidates(true);
|
||||||
|
|
||||||
void clearSelection().catch((selectionError) => {
|
|
||||||
addToast(normalizeError(selectionError), "error");
|
|
||||||
});
|
|
||||||
|
|
||||||
addToast("Cleared selected dev server script.", "success");
|
addToast("Cleared selected dev server script.", "success");
|
||||||
}, [addToast, clearSelection]);
|
}, [addToast]);
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
const trimmedCommand = commandInput.trim();
|
const trimmedCommand = commandInput.trim();
|
||||||
@@ -373,10 +318,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
|||||||
|
|
||||||
void runAction(
|
void runAction(
|
||||||
"preview",
|
"preview",
|
||||||
async () => {
|
() => setPreviewUrl(nextUrl),
|
||||||
await setPreviewUrlOverride(nextUrl);
|
|
||||||
await setPreviewUrl(nextUrl);
|
|
||||||
},
|
|
||||||
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
|
nextUrl ? "Preview URL updated." : "Preview URL override cleared.",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -387,7 +329,7 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
|
|||||||
}
|
}
|
||||||
}, [error, refresh]);
|
}, [error, refresh]);
|
||||||
|
|
||||||
const isManualPreviewOverride = Boolean(manualPreviewUrlOverride);
|
const isManualPreviewOverride = false; // With session model, previewUrl is always auto-detected
|
||||||
|
|
||||||
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
|
const startDisabled = status === "starting" || status === "running" || actionInFlight !== null;
|
||||||
const stopDisabled = status === "stopped" || actionInFlight !== null;
|
const stopDisabled = status === "stopped" || actionInFlight !== null;
|
||||||
|
|||||||
@@ -73,19 +73,65 @@ function createConfig(overrides: Partial<DevServerConfig> = {}): DevServerConfig
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
function legacyStateToSession(legacy: DevServerState) {
|
||||||
return {
|
return {
|
||||||
candidates: [],
|
config: {
|
||||||
serverState: createState(),
|
id: legacy.id ?? "default",
|
||||||
|
name: legacy.name ?? "Dev Server",
|
||||||
|
command: legacy.command ?? "",
|
||||||
|
cwd: legacy.cwd ?? ".",
|
||||||
|
},
|
||||||
|
status: legacy.status,
|
||||||
|
runtime: legacy.pid
|
||||||
|
? {
|
||||||
|
pid: legacy.pid,
|
||||||
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||||
|
exitCode: legacy.exitCode ?? undefined,
|
||||||
|
previewUrl: legacy.previewUrl,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl ?? null,
|
||||||
|
logHistory: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
||||||
|
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
|
||||||
|
const candidates = (overrides.candidates as unknown[] | undefined) ?? [];
|
||||||
|
return {
|
||||||
|
// legacy API (still read by some tests as aliases)
|
||||||
logs: [],
|
logs: [],
|
||||||
start: vi.fn().mockResolvedValue(undefined),
|
|
||||||
stop: vi.fn().mockResolvedValue(undefined),
|
|
||||||
restart: vi.fn().mockResolvedValue(undefined),
|
|
||||||
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
|
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
detect: vi.fn().mockResolvedValue(undefined),
|
setManualUrl: setPreviewUrl,
|
||||||
|
refreshStatus: refresh,
|
||||||
|
// new API consumed by the current component
|
||||||
|
sessions: [],
|
||||||
|
previewUrl: serverState.previewUrl ?? null,
|
||||||
|
isLoading: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
// the following must come AFTER `...overrides` so aliases track the
|
||||||
|
// overridden legacy fields.
|
||||||
|
candidates,
|
||||||
|
serverState,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
restart,
|
||||||
|
setPreviewUrl,
|
||||||
|
detect,
|
||||||
|
session: legacyStateToSession(serverState),
|
||||||
|
detectedCommands: candidates,
|
||||||
|
startServer: start,
|
||||||
|
stopServer: stop,
|
||||||
|
restartServer: restart,
|
||||||
|
detectCommands: detect,
|
||||||
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,37 +57,69 @@ function createState(overrides: Partial<DevServerState> = {}): DevServerState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
function legacyStateToSession(legacy: DevServerState) {
|
||||||
return {
|
return {
|
||||||
candidates: [
|
config: {
|
||||||
{
|
id: legacy.id ?? "default",
|
||||||
name: "dev",
|
name: legacy.name ?? "Dev Server",
|
||||||
command: "pnpm dev",
|
command: legacy.command ?? "",
|
||||||
scriptName: "dev",
|
cwd: legacy.cwd ?? ".",
|
||||||
cwd: ".",
|
},
|
||||||
source: "root",
|
status: legacy.status,
|
||||||
label: "project · dev (root)",
|
runtime: legacy.pid
|
||||||
},
|
? {
|
||||||
{
|
pid: legacy.pid,
|
||||||
name: "start",
|
startedAt: legacy.startedAt ?? new Date().toISOString(),
|
||||||
command: "pnpm start --filter web",
|
exitCode: legacy.exitCode ?? undefined,
|
||||||
scriptName: "start",
|
previewUrl: legacy.previewUrl,
|
||||||
cwd: "apps/web",
|
}
|
||||||
source: "apps/web",
|
: undefined,
|
||||||
workspaceName: "@demo/web",
|
previewUrl: legacy.previewUrl ?? legacy.detectedUrl ?? legacy.manualUrl,
|
||||||
label: "@demo/web · start (apps/web)",
|
logHistory: [],
|
||||||
},
|
};
|
||||||
],
|
}
|
||||||
serverState: createState(),
|
|
||||||
|
function createDevServerHookState(overrides: Record<string, unknown> = {}) {
|
||||||
|
const defaultCandidates = [
|
||||||
|
{ name: "dev", command: "pnpm dev", scriptName: "dev", cwd: ".", source: "root", label: "project · dev (root)" },
|
||||||
|
{ name: "start", command: "pnpm start --filter web", scriptName: "start", cwd: "apps/web", source: "apps/web", workspaceName: "@demo/web", label: "@demo/web · start (apps/web)" },
|
||||||
|
];
|
||||||
|
const candidates = (overrides.candidates as typeof defaultCandidates | undefined) ?? defaultCandidates;
|
||||||
|
const start = (overrides.start as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const stop = (overrides.stop as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const restart = (overrides.restart as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const setPreviewUrl = (overrides.setPreviewUrl as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const detect = (overrides.detect as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const refresh = (overrides.refresh as ReturnType<typeof vi.fn> | undefined) ?? vi.fn().mockResolvedValue(undefined);
|
||||||
|
const serverState = (overrides.serverState as DevServerState | undefined) ?? createState();
|
||||||
|
return {
|
||||||
|
// legacy API (still read by some tests as aliases)
|
||||||
logs: ["ready"],
|
logs: ["ready"],
|
||||||
start: vi.fn().mockResolvedValue(undefined),
|
|
||||||
stop: vi.fn().mockResolvedValue(undefined),
|
|
||||||
restart: vi.fn().mockResolvedValue(undefined),
|
|
||||||
setPreviewUrl: vi.fn().mockResolvedValue(undefined),
|
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
detect: vi.fn().mockResolvedValue(undefined),
|
setManualUrl: setPreviewUrl,
|
||||||
|
refreshStatus: refresh,
|
||||||
|
// new API consumed by the current component
|
||||||
|
sessions: [],
|
||||||
|
previewUrl: serverState.previewUrl ?? null,
|
||||||
|
isLoading: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
// the following must come AFTER `...overrides` so aliases track the
|
||||||
|
// overridden legacy fields (start, serverState, candidates, ...).
|
||||||
|
candidates,
|
||||||
|
serverState,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
restart,
|
||||||
|
setPreviewUrl,
|
||||||
|
detect,
|
||||||
|
session: legacyStateToSession(serverState),
|
||||||
|
detectedCommands: candidates,
|
||||||
|
startServer: start,
|
||||||
|
stopServer: stop,
|
||||||
|
restartServer: restart,
|
||||||
|
detectCommands: detect,
|
||||||
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,47 +285,29 @@ describe("DevServerView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking a candidate persists selection via selectScript", async () => {
|
it("clicking a candidate shows the selected-script summary", async () => {
|
||||||
const selectScript = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ selectScript }));
|
|
||||||
|
|
||||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
|
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(selectScript).toHaveBeenCalledWith({
|
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
|
||||||
name: "dev",
|
|
||||||
command: "pnpm dev",
|
|
||||||
source: "root",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights the selected candidate", () => {
|
it("re-shows the candidates list when the user clicks Change after selecting", () => {
|
||||||
mockUseDevServerConfig.mockReturnValue(
|
|
||||||
createConfigHookState({
|
|
||||||
config: createConfig({
|
|
||||||
selectedScript: "dev",
|
|
||||||
selectedSource: "root",
|
|
||||||
selectedCommand: "pnpm dev",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
|
||||||
|
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("dev-server-change-selection"));
|
fireEvent.click(screen.getByTestId("dev-server-change-selection"));
|
||||||
|
|
||||||
const selected = screen.getByTestId("dev-server-candidate-dev-root");
|
expect(screen.getByTestId("dev-server-candidates")).toBeInTheDocument();
|
||||||
expect(selected).toHaveClass("dev-server-candidate--selected");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves preview URL override from input", async () => {
|
it("saves preview URL override from input", async () => {
|
||||||
const setPreviewUrlOverride = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const setPreviewUrl = vi.fn().mockResolvedValue(undefined);
|
const setPreviewUrl = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
mockUseDevServerConfig.mockReturnValue(createConfigHookState({ setPreviewUrlOverride }));
|
|
||||||
mockUseDevServer.mockReturnValue(createDevServerHookState({ setPreviewUrl }));
|
mockUseDevServer.mockReturnValue(createDevServerHookState({ setPreviewUrl }));
|
||||||
|
|
||||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||||
@@ -304,7 +318,6 @@ describe("DevServerView", () => {
|
|||||||
fireEvent.click(screen.getByTestId("dev-server-set-preview"));
|
fireEvent.click(screen.getByTestId("dev-server-set-preview"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(setPreviewUrlOverride).toHaveBeenCalledWith("http://localhost:3000");
|
|
||||||
expect(setPreviewUrl).toHaveBeenCalledWith("http://localhost:3000");
|
expect(setPreviewUrl).toHaveBeenCalledWith("http://localhost:3000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -334,19 +347,11 @@ describe("DevServerView", () => {
|
|||||||
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
|
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders selected script summary when config has a selection", () => {
|
it("renders selected script summary after user selects a script", () => {
|
||||||
mockUseDevServerConfig.mockReturnValue(
|
|
||||||
createConfigHookState({
|
|
||||||
config: createConfig({
|
|
||||||
selectedScript: "dev",
|
|
||||||
selectedSource: "root",
|
|
||||||
selectedCommand: "pnpm dev",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
render(<DevServerView addToast={addToast} projectId="project-a" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("dev-server-candidate-dev-root"));
|
||||||
|
|
||||||
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
|
expect(screen.getByTestId("dev-server-selected-summary")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
|
|
||||||
export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error";
|
export type EmbedStatus = "unknown" | "loading" | "embedded" | "blocked" | "error";
|
||||||
export type EmbedDetectionMethod = "auto" | "manual" | null;
|
export type EmbedDetectionMethod = "auto" | "manual" | null;
|
||||||
|
|
||||||
|
const BLOCKED_CONTEXT = "This preview appears to block iframe embedding. Open it in a new tab instead.";
|
||||||
|
const ERROR_CONTEXT = "The preview URL could not be loaded. Verify the server is running and the URL is correct.";
|
||||||
|
const TIMEOUT_CONTEXT = "Preview is taking longer than expected and may block iframe embedding.";
|
||||||
|
|
||||||
interface UsePreviewEmbedOptions {
|
interface UsePreviewEmbedOptions {
|
||||||
loadTimeoutMs?: number;
|
loadTimeoutMs?: number;
|
||||||
detectionMethod?: EmbedDetectionMethod;
|
detectionMethod?: EmbedDetectionMethod;
|
||||||
@@ -19,18 +23,12 @@ interface UsePreviewEmbedResult {
|
|||||||
// Extended API for direct status control (backward compatibility)
|
// Extended API for direct status control (backward compatibility)
|
||||||
setEmbedStatus: (status: EmbedStatus) => void;
|
setEmbedStatus: (status: EmbedStatus) => void;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
// Legacy alias for backward compatibility
|
// Legacy aliases for backward compatibility
|
||||||
embedContext: string | null;
|
embedContext: string | null;
|
||||||
handleIframeLoad: () => void;
|
handleIframeLoad: () => void;
|
||||||
handleIframeError: () => void;
|
handleIframeError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LOAD_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
const BLOCKED_CONTEXT = "This preview appears to block iframe embedding. Open it in a new tab instead.";
|
|
||||||
const ERROR_CONTEXT = "The preview URL could not be loaded. Verify the server is running and the URL is correct.";
|
|
||||||
const TIMEOUT_CONTEXT = "Preview is taking longer than expected and may block iframe embedding.";
|
|
||||||
|
|
||||||
function getContextForStatus(status: EmbedStatus): string | null {
|
function getContextForStatus(status: EmbedStatus): string | null {
|
||||||
if (status === "blocked") {
|
if (status === "blocked") {
|
||||||
return BLOCKED_CONTEXT;
|
return BLOCKED_CONTEXT;
|
||||||
@@ -44,14 +42,14 @@ function getContextForStatus(status: EmbedStatus): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult {
|
export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOptions = {}): UsePreviewEmbedResult {
|
||||||
const { loadTimeoutMs = DEFAULT_LOAD_TIMEOUT_MS, detectionMethod: initialDetectionMethod = null } = options;
|
const { loadTimeoutMs = 10000, detectionMethod: initialDetectionMethod = null } = options;
|
||||||
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
const timeoutRef = useRef<number | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown");
|
const [embedStatus, setEmbedStatusState] = useState<EmbedStatus>("unknown");
|
||||||
const [blockReason, setBlockReason] = useState<string | null>(null);
|
const [blockReason, setBlockReason] = useState<string | null>(null);
|
||||||
const [detectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
|
const [detectionMethod, setDetectionMethod] = useState<EmbedDetectionMethod>(initialDetectionMethod);
|
||||||
|
|
||||||
const clearLoadingTimeout = useCallback(() => {
|
const clearLoadingTimeout = useCallback(() => {
|
||||||
if (timeoutRef.current !== null) {
|
if (timeoutRef.current !== null) {
|
||||||
@@ -60,24 +58,63 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setEmbedStatus = useCallback(
|
const setEmbedStatus = useCallback((status: EmbedStatus) => {
|
||||||
(status: EmbedStatus) => {
|
setEmbedStatusState(status);
|
||||||
clearLoadingTimeout();
|
setBlockReason(getContextForStatus(status));
|
||||||
setEmbedStatusState(status);
|
}, []);
|
||||||
setBlockReason(getContextForStatus(status));
|
|
||||||
},
|
|
||||||
[clearLoadingTimeout],
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetEmbedStatus = useCallback(() => {
|
const setBlockedByTimeout = useCallback(() => {
|
||||||
|
setEmbedStatusState("blocked");
|
||||||
|
setBlockReason(TIMEOUT_CONTEXT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
clearLoadingTimeout();
|
clearLoadingTimeout();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
setEmbedStatusState("unknown");
|
||||||
|
setBlockReason(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setEmbedStatusState("unknown");
|
setEmbedStatusState("unknown");
|
||||||
setBlockReason(null);
|
setBlockReason(null);
|
||||||
}, [clearLoadingTimeout]);
|
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
let canceled = false;
|
||||||
resetEmbedStatus();
|
queueMicrotask(() => {
|
||||||
}, [resetEmbedStatus]);
|
if (canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmbedStatusState("loading");
|
||||||
|
setBlockReason(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
clearLoadingTimeout();
|
||||||
|
};
|
||||||
|
}, [clearLoadingTimeout, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (embedStatus !== "loading") {
|
||||||
|
clearLoadingTimeout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timeoutRef.current = null;
|
||||||
|
setBlockedByTimeout();
|
||||||
|
}, loadTimeoutMs);
|
||||||
|
|
||||||
|
timeoutRef.current = timer;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (timeoutRef.current === timer) {
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [clearLoadingTimeout, embedStatus, loadTimeoutMs, setBlockedByTimeout]);
|
||||||
|
|
||||||
const handleIframeLoad = useCallback(() => {
|
const handleIframeLoad = useCallback(() => {
|
||||||
const iframeEl = iframeRef.current;
|
const iframeEl = iframeRef.current;
|
||||||
@@ -114,50 +151,21 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
|||||||
|
|
||||||
setEmbedStatusState("unknown");
|
setEmbedStatusState("unknown");
|
||||||
setBlockReason(null);
|
setBlockReason(null);
|
||||||
|
|
||||||
let canceled = false;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (canceled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEmbedStatusState("loading");
|
|
||||||
setBlockReason(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
clearLoadingTimeout();
|
|
||||||
};
|
|
||||||
}, [clearLoadingTimeout, url]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!url || embedStatus !== "loading") {
|
|
||||||
clearLoadingTimeout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
timeoutRef.current = null;
|
|
||||||
setEmbedStatusState("blocked");
|
|
||||||
setBlockReason(TIMEOUT_CONTEXT);
|
|
||||||
}, loadTimeoutMs);
|
|
||||||
|
|
||||||
timeoutRef.current = timer;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
if (timeoutRef.current === timer) {
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [clearLoadingTimeout, embedStatus, loadTimeoutMs, url]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return clearLoadingTimeout;
|
|
||||||
}, [clearLoadingTimeout]);
|
}, [clearLoadingTimeout]);
|
||||||
|
|
||||||
const isEmbedded = embedStatus === "embedded";
|
const resetEmbedStatus = useCallback(() => {
|
||||||
const isBlocked = embedStatus === "blocked" || embedStatus === "error";
|
clearLoadingTimeout();
|
||||||
|
setEmbedStatusState("unknown");
|
||||||
|
setBlockReason(null);
|
||||||
|
}, [clearLoadingTimeout]);
|
||||||
|
|
||||||
|
const retry = resetEmbedStatus;
|
||||||
|
|
||||||
|
const isEmbedded = useMemo(() => embedStatus === "embedded", [embedStatus]);
|
||||||
|
const isBlocked = useMemo(
|
||||||
|
() => embedStatus === "blocked" || embedStatus === "error",
|
||||||
|
[embedStatus],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embedStatus,
|
embedStatus,
|
||||||
@@ -167,6 +175,7 @@ export function usePreviewEmbed(url: string | null, options: UsePreviewEmbedOpti
|
|||||||
detectionMethod,
|
detectionMethod,
|
||||||
iframeRef,
|
iframeRef,
|
||||||
resetEmbedStatus,
|
resetEmbedStatus,
|
||||||
|
// Legacy aliases
|
||||||
setEmbedStatus,
|
setEmbedStatus,
|
||||||
retry,
|
retry,
|
||||||
embedContext: blockReason,
|
embedContext: blockReason,
|
||||||
|
|||||||
6
packages/engine/src/auth-storage.d.ts
vendored
Normal file
6
packages/engine/src/auth-storage.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { AuthStorage } from "@mariozechner/pi-coding-agent";
|
||||||
|
export declare function getFusionAuthPath(home?: string): string;
|
||||||
|
export declare function getFusionModelsPath(home?: string): string;
|
||||||
|
export declare function getModelRegistryModelsPath(home?: string): string;
|
||||||
|
export declare function createFusionAuthStorage(): AuthStorage;
|
||||||
|
//# sourceMappingURL=auth-storage.d.ts.map
|
||||||
1
packages/engine/src/auth-storage.d.ts.map
Normal file
1
packages/engine/src/auth-storage.d.ts.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"auth-storage.d.ts","sourceRoot":"","sources":["auth-storage.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAiB5D,wBAAgB,iBAAiB,CAAC,IAAI,SAAe,GAAG,MAAM,CAE7D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,SAAe,GAAG,MAAM,CAE/D;AAgBD,wBAAgB,0BAA0B,CAAC,IAAI,SAAe,GAAG,MAAM,CAOtE;AAmDD,wBAAgB,uBAAuB,IAAI,WAAW,CA6CrD"}
|
||||||
114
packages/engine/src/auth-storage.js
Normal file
114
packages/engine/src/auth-storage.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { AuthStorage } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
||||||
|
function getHomeDir() {
|
||||||
|
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||||
|
}
|
||||||
|
export function getFusionAuthPath(home = getHomeDir()) {
|
||||||
|
return join(home, ".fusion", "agent", "auth.json");
|
||||||
|
}
|
||||||
|
export function getFusionModelsPath(home = getHomeDir()) {
|
||||||
|
return join(home, ".fusion", "agent", "models.json");
|
||||||
|
}
|
||||||
|
function getLegacyAuthPaths(home = getHomeDir()) {
|
||||||
|
return [
|
||||||
|
join(home, ".pi", "agent", "auth.json"),
|
||||||
|
join(home, ".pi", "auth.json"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function getLegacyModelsPaths(home = getHomeDir()) {
|
||||||
|
return [
|
||||||
|
join(home, ".pi", "agent", "models.json"),
|
||||||
|
join(home, ".pi", "models.json"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
export function getModelRegistryModelsPath(home = getHomeDir()) {
|
||||||
|
const fusionModelsPath = getFusionModelsPath(home);
|
||||||
|
if (existsSync(fusionModelsPath)) {
|
||||||
|
return fusionModelsPath;
|
||||||
|
}
|
||||||
|
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
|
||||||
|
}
|
||||||
|
function readLegacyCredentials(authPaths = getLegacyAuthPaths()) {
|
||||||
|
const credentials = {};
|
||||||
|
for (const authPath of authPaths) {
|
||||||
|
if (!existsSync(authPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(authPath, "utf-8"));
|
||||||
|
for (const [provider, credential] of Object.entries(parsed)) {
|
||||||
|
credentials[provider] ??= credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Ignore invalid legacy auth files and continue with other candidates.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
function resolveStoredApiKey(key) {
|
||||||
|
if (!key)
|
||||||
|
return undefined;
|
||||||
|
return process.env[key] ?? key;
|
||||||
|
}
|
||||||
|
function resolveOAuthApiKey(providerId, credential) {
|
||||||
|
if (credential.type !== "oauth" ||
|
||||||
|
typeof credential.access !== "string" ||
|
||||||
|
typeof credential.refresh !== "string" ||
|
||||||
|
typeof credential.expires !== "number" ||
|
||||||
|
Date.now() >= credential.expires) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return getOAuthProvider(providerId)?.getApiKey(credential);
|
||||||
|
}
|
||||||
|
function resolveStoredCredentialApiKey(providerId, credential) {
|
||||||
|
if (credential?.type === "api_key") {
|
||||||
|
return resolveStoredApiKey(credential.key);
|
||||||
|
}
|
||||||
|
if (credential?.type === "oauth") {
|
||||||
|
return resolveOAuthApiKey(providerId, credential);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
export function createFusionAuthStorage() {
|
||||||
|
const primary = AuthStorage.create(getFusionAuthPath());
|
||||||
|
let legacyCredentials = readLegacyCredentials();
|
||||||
|
return new Proxy(primary, {
|
||||||
|
get(target, prop, receiver) {
|
||||||
|
if (prop === "reload") {
|
||||||
|
return () => {
|
||||||
|
target.reload();
|
||||||
|
legacyCredentials = readLegacyCredentials();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prop === "get") {
|
||||||
|
return (provider) => target.get(provider) ?? legacyCredentials[provider];
|
||||||
|
}
|
||||||
|
if (prop === "has") {
|
||||||
|
return (provider) => target.has(provider) || provider in legacyCredentials;
|
||||||
|
}
|
||||||
|
if (prop === "hasAuth") {
|
||||||
|
return (provider) => target.hasAuth(provider) || Boolean(legacyCredentials[provider]);
|
||||||
|
}
|
||||||
|
if (prop === "getAll") {
|
||||||
|
return () => ({ ...legacyCredentials, ...target.getAll() });
|
||||||
|
}
|
||||||
|
if (prop === "list") {
|
||||||
|
return () => Array.from(new Set([...Object.keys(legacyCredentials), ...target.list()]));
|
||||||
|
}
|
||||||
|
if (prop === "getApiKey") {
|
||||||
|
return async (provider) => {
|
||||||
|
const primaryKey = await target.getApiKey(provider);
|
||||||
|
if (primaryKey)
|
||||||
|
return primaryKey;
|
||||||
|
return resolveStoredCredentialApiKey(provider, legacyCredentials[provider]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Reflect.get(target, prop, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=auth-storage.js.map
|
||||||
1
packages/engine/src/auth-storage.js.map
Normal file
1
packages/engine/src/auth-storage.js.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"auth-storage.js","sourceRoot":"","sources":["auth-storage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAY7D,SAAS,UAAU;IACjB,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;AAClE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAI,GAAG,UAAU,EAAE;IACnD,OAAO,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC;AACrD,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAAI,GAAG,UAAU,EAAE;IACrD,OAAO,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC;AACvD,CAAC;AAED,SAAS,kBAAkB,CAAC,IAAI,GAAG,UAAU,EAAE;IAC7C,OAAO;QACL,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,WAAW,CAAC;QACvC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,WAAW,CAAC;KAC/B,CAAC;AACJ,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAI,GAAG,UAAU,EAAE;IAC/C,OAAO;QACL,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,CAAC;QACzC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,aAAa,CAAC;KACjC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,IAAI,GAAG,UAAU,EAAE;IAC5D,MAAM,gBAAgB,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,UAAU,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACjC,OAAO,gBAAgB,CAAC;IAC1B,CAAC;IAED,OAAO,oBAAoB,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,IAAI,gBAAgB,CAAC;AACrG,CAAC;AAED,SAAS,qBAAqB,CAAC,SAAS,GAAG,kBAAkB,EAAE;IAC7D,MAAM,WAAW,GAAqC,EAAE,CAAC;IAEzD,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1B,SAAS;QACX,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAqC,CAAC;YAC/F,KAAK,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5D,WAAW,CAAC,QAAQ,CAAC,KAAK,UAAU,CAAC;YACvC,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,uEAAuE;QACzE,CAAC;IACH,CAAC;IAED,OAAO,WAAW,CAAC;AACrB,CAAC;AAED,SAAS,mBAAmB,CAAC,GAAuB;IAClD,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC;AACjC,CAAC;AAED,SAAS,kBAAkB,CAAC,UAAkB,EAAE,UAA4B;IAC1E,IACE,UAAU,CAAC,IAAI,KAAK,OAAO;QAC3B,OAAO,UAAU,CAAC,MAAM,KAAK,QAAQ;QACrC,OAAO,UAAU,CAAC,OAAO,KAAK,QAAQ;QACtC,OAAO,UAAU,CAAC,OAAO,KAAK,QAAQ;QACtC,IAAI,CAAC,GAAG,EAAE,IAAI,UAAU,CAAC,OAAO,EAChC,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,OAAO,gBAAgB,CAAC,UAAU,CAAC,EAAE,SAAS,CAAC,UAA8B,CAAC,CAAC;AACjF,CAAC;AAED,SAAS,6BAA6B,CAAC,UAAkB,EAAE,UAAwC;IACjG,IAAI,UAAU,EAAE,IAAI,KAAK,SAAS,EAAE,CAAC;QACnC,OAAO,mBAAmB,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,UAAU,EAAE,IAAI,KAAK,OAAO,EAAE,CAAC;QACjC,OAAO,kBAAkB,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,uBAAuB;IACrC,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxD,IAAI,iBAAiB,GAAG,qBAAqB,EAAE,CAAC;IAEhD,OAAO,IAAI,KAAK,CAAC,OAAO,EAAE;QACxB,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ;YACxB,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,GAAG,EAAE;oBACV,MAAM,CAAC,MAAM,EAAE,CAAC;oBAChB,iBAAiB,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,CAAC,CAAC;YACJ,CAAC;YAED,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;gBACnB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,iBAAiB,CAAC,QAAQ,CAAC,CAAC;YACnF,CAAC;YAED,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;gBACnB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,iBAAiB,CAAC;YACrF,CAAC;YAED,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;gBACvB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC;YAChG,CAAC;YAED,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,iBAAiB,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YAC9D,CAAC;YAED,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;gBACpB,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;YAC1F,CAAC;YAED,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;gBACzB,OAAO,KAAK,EAAE,QAAgB,EAAE,EAAE;oBAChC,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;oBACpD,IAAI,UAAU;wBAAE,OAAO,UAAU,CAAC;oBAElC,OAAO,6BAA6B,CAAC,QAAQ,EAAE,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAC9E,CAAC,CAAC;YACJ,CAAC;YAED,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC;QAC7C,CAAC;KACF,CAAgB,CAAC;AACpB,CAAC"}
|
||||||
26
packages/engine/src/context-limit-detector.d.ts
vendored
Normal file
26
packages/engine/src/context-limit-detector.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Context limit error detection.
|
||||||
|
*
|
||||||
|
* Classifies errors from LLM providers that indicate the conversation context
|
||||||
|
* has grown too large for the model's window. Used by the executor to trigger
|
||||||
|
* compact-and-resume recovery before falling back to kill/requeue.
|
||||||
|
*
|
||||||
|
* Patterns are intentionally conservative — we only match errors that
|
||||||
|
* explicitly reference context/token overflow, NOT generic rate limits or
|
||||||
|
* server errors (those are handled by usage-limit-detector and transient-error-detector).
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Check if an error message indicates a context-window overflow.
|
||||||
|
*
|
||||||
|
* Returns true only when the message explicitly references context overflow
|
||||||
|
* from a known LLM provider pattern. Returns false for:
|
||||||
|
* - Rate limit errors (handled by usage-limit-detector)
|
||||||
|
* - Transient network errors (handled by transient-error-detector)
|
||||||
|
* - Generic "limit exceeded" without context keywords (false positive prevention)
|
||||||
|
* - "Aborted" errors without context signal
|
||||||
|
*
|
||||||
|
* @param message — The error message string to classify
|
||||||
|
* @returns true if the message indicates a context overflow
|
||||||
|
*/
|
||||||
|
export declare function isContextLimitError(message: string): boolean;
|
||||||
|
//# sourceMappingURL=context-limit-detector.d.ts.map
|
||||||
1
packages/engine/src/context-limit-detector.d.ts.map
Normal file
1
packages/engine/src/context-limit-detector.d.ts.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"context-limit-detector.d.ts","sourceRoot":"","sources":["context-limit-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAoCH;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAG5D"}
|
||||||
63
packages/engine/src/context-limit-detector.js
Normal file
63
packages/engine/src/context-limit-detector.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Context limit error detection.
|
||||||
|
*
|
||||||
|
* Classifies errors from LLM providers that indicate the conversation context
|
||||||
|
* has grown too large for the model's window. Used by the executor to trigger
|
||||||
|
* compact-and-resume recovery before falling back to kill/requeue.
|
||||||
|
*
|
||||||
|
* Patterns are intentionally conservative — we only match errors that
|
||||||
|
* explicitly reference context/token overflow, NOT generic rate limits or
|
||||||
|
* server errors (those are handled by usage-limit-detector and transient-error-detector).
|
||||||
|
*/
|
||||||
|
/** Patterns that indicate a context-window overflow from the LLM provider. */
|
||||||
|
const CONTEXT_OVERFLOW_PATTERNS = [
|
||||||
|
// Anthropic: "prompt is too long: X tokens > Y maximum"
|
||||||
|
/prompt is too long/i,
|
||||||
|
// OpenAI (Completions & Responses): "exceeds the context window"
|
||||||
|
/exceeds?\s+the\s+context\s+window/i,
|
||||||
|
// Google Gemini: "input token count exceeds the maximum"
|
||||||
|
/input token count exceeds/i,
|
||||||
|
// xAI (Grok): "maximum prompt length is X but request contains Y"
|
||||||
|
/maximum prompt length/i,
|
||||||
|
// Groq: "reduce the length of the messages"
|
||||||
|
/reduce the length of the messages/i,
|
||||||
|
// Mistral: "too large for model with Y maximum context length"
|
||||||
|
/too large for model with.*maximum context length/i,
|
||||||
|
// OpenRouter (all backends): "maximum context length is X tokens"
|
||||||
|
/maximum context length is \d+ tokens/i,
|
||||||
|
// llama.cpp: "exceeds the available context size"
|
||||||
|
/exceeds?\s+the\s+available\s+context\s+size/i,
|
||||||
|
// LM Studio: "greater than the context length"
|
||||||
|
/greater than the context length/i,
|
||||||
|
// Kimi: "exceeded model token limit"
|
||||||
|
/exceeded model token limit/i,
|
||||||
|
// Generic catch-all: "context length exceeded" / "context window exceeded"
|
||||||
|
/context (?:length|window|size) exceeded/i,
|
||||||
|
// Token limit patterns with context keywords
|
||||||
|
/token limit.*context/i,
|
||||||
|
/too many tokens/i,
|
||||||
|
// Anthropic variant: "messages with that many tokens would exceed"
|
||||||
|
/tokens? would exceed/i,
|
||||||
|
// Provider JSON error envelope variant: "context window exceeds limit (2013)"
|
||||||
|
// Matches when "context window" and "exceeds" appear together (order-flexible)
|
||||||
|
/context\s+window\s+exceeds/i,
|
||||||
|
];
|
||||||
|
/**
|
||||||
|
* Check if an error message indicates a context-window overflow.
|
||||||
|
*
|
||||||
|
* Returns true only when the message explicitly references context overflow
|
||||||
|
* from a known LLM provider pattern. Returns false for:
|
||||||
|
* - Rate limit errors (handled by usage-limit-detector)
|
||||||
|
* - Transient network errors (handled by transient-error-detector)
|
||||||
|
* - Generic "limit exceeded" without context keywords (false positive prevention)
|
||||||
|
* - "Aborted" errors without context signal
|
||||||
|
*
|
||||||
|
* @param message — The error message string to classify
|
||||||
|
* @returns true if the message indicates a context overflow
|
||||||
|
*/
|
||||||
|
export function isContextLimitError(message) {
|
||||||
|
if (!message)
|
||||||
|
return false;
|
||||||
|
return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(message));
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=context-limit-detector.js.map
|
||||||
1
packages/engine/src/context-limit-detector.js.map
Normal file
1
packages/engine/src/context-limit-detector.js.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"context-limit-detector.js","sourceRoot":"","sources":["context-limit-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,8EAA8E;AAC9E,MAAM,yBAAyB,GAAa;IAC1C,wDAAwD;IACxD,qBAAqB;IACrB,iEAAiE;IACjE,oCAAoC;IACpC,yDAAyD;IACzD,4BAA4B;IAC5B,kEAAkE;IAClE,wBAAwB;IACxB,4CAA4C;IAC5C,oCAAoC;IACpC,+DAA+D;IAC/D,mDAAmD;IACnD,kEAAkE;IAClE,uCAAuC;IACvC,kDAAkD;IAClD,8CAA8C;IAC9C,+CAA+C;IAC/C,kCAAkC;IAClC,qCAAqC;IACrC,6BAA6B;IAC7B,2EAA2E;IAC3E,0CAA0C;IAC1C,6CAA6C;IAC7C,uBAAuB;IACvB,kBAAkB;IAClB,mEAAmE;IACnE,uBAAuB;IACvB,8EAA8E;IAC9E,+EAA+E;IAC/E,6BAA6B;CAC9B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAAe;IACjD,IAAI,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC;IAC3B,OAAO,yBAAyB,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;AAC5E,CAAC"}
|
||||||
68
packages/engine/src/logger.d.ts
vendored
Normal file
68
packages/engine/src/logger.d.ts
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight structured logger for the `@fusion/engine` package.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import { createLogger } from "./logger.js";
|
||||||
|
* const log = createLogger("my-module");
|
||||||
|
* log.log("hello"); // → console.error("[my-module] hello")
|
||||||
|
* log.warn("oops"); // → console.warn("[my-module] oops")
|
||||||
|
* log.error("fail"); // → console.error("[my-module] fail")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* All engine subsystems should use the pre-built instances exported below
|
||||||
|
* rather than calling `console.*` directly. This gives us a single point
|
||||||
|
* of control for filtering, suppressing (e.g. in tests), or redirecting
|
||||||
|
* engine log output in the future.
|
||||||
|
*/
|
||||||
|
export interface Logger {
|
||||||
|
log(message: string, ...args: unknown[]): void;
|
||||||
|
warn(message: string, ...args: unknown[]): void;
|
||||||
|
error(message: string, ...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a structured logger that prefixes every message with `[prefix]`.
|
||||||
|
*
|
||||||
|
* @param prefix - Short subsystem name, e.g. `"scheduler"` or `"executor"`.
|
||||||
|
* @returns A `Logger` whose output is prefixed and sent to stderr. Keeping
|
||||||
|
* engine logs off stdout prevents command/test output consumers from
|
||||||
|
* receiving Fusion execution chatter.
|
||||||
|
*/
|
||||||
|
export declare function createLogger(prefix: string): Logger;
|
||||||
|
/** Logger for the scheduler subsystem. */
|
||||||
|
export declare const schedulerLog: Logger;
|
||||||
|
/** Logger for the task executor subsystem. */
|
||||||
|
export declare const executorLog: Logger;
|
||||||
|
/** Logger for the triage processor subsystem. */
|
||||||
|
export declare const triageLog: Logger;
|
||||||
|
/** Logger for the pi agent session subsystem. */
|
||||||
|
export declare const piLog: Logger;
|
||||||
|
/** Logger for extension discovery/provider registration. */
|
||||||
|
export declare const extensionsLog: Logger;
|
||||||
|
/** Logger for the merge/auto-merge subsystem. */
|
||||||
|
export declare const mergerLog: Logger;
|
||||||
|
/** Logger for the worktree pool subsystem. */
|
||||||
|
export declare const worktreePoolLog: Logger;
|
||||||
|
/** Logger for the review subsystem. */
|
||||||
|
export declare const reviewerLog: Logger;
|
||||||
|
/** Logger for the PR monitor subsystem. */
|
||||||
|
export declare const prMonitorLog: Logger;
|
||||||
|
/** Logger for the project runtime subsystem. */
|
||||||
|
export declare const runtimeLog: Logger;
|
||||||
|
/** Logger for the IPC subsystem. */
|
||||||
|
export declare const ipcLog: Logger;
|
||||||
|
/** Logger for the project manager subsystem. */
|
||||||
|
export declare const projectManagerLog: Logger;
|
||||||
|
/** Logger for the hybrid executor subsystem. */
|
||||||
|
export declare const hybridExecutorLog: Logger;
|
||||||
|
/** Logger for the mission autopilot subsystem. */
|
||||||
|
export declare const autopilotLog: Logger;
|
||||||
|
/** Logger for the heartbeat execution subsystem. */
|
||||||
|
export declare const heartbeatLog: Logger;
|
||||||
|
/** Logger for remote node runtime/client subsystems. */
|
||||||
|
export declare const remoteNodeLog: Logger;
|
||||||
|
/** Logger for periodic node health monitor subsystem. */
|
||||||
|
export declare const nodeHealthMonitorLog: Logger;
|
||||||
|
/** Logger for the peer exchange (gossip) subsystem. */
|
||||||
|
export declare const peerExchangeLog: Logger;
|
||||||
|
//# sourceMappingURL=logger.d.ts.map
|
||||||
1
packages/engine/src/logger.d.ts.map
Normal file
1
packages/engine/src/logger.d.ts.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAanD;AAED,0CAA0C;AAC1C,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,8CAA8C;AAC9C,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,iDAAiD;AACjD,eAAO,MAAM,KAAK,QAAqB,CAAC;AAExC,4DAA4D;AAC5D,eAAO,MAAM,aAAa,QAA6B,CAAC;AAExD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,8CAA8C;AAC9C,eAAO,MAAM,eAAe,QAAgC,CAAC;AAE7D,uCAAuC;AACvC,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,2CAA2C;AAC3C,eAAO,MAAM,YAAY,QAA6B,CAAC;AAEvD,gDAAgD;AAChD,eAAO,MAAM,UAAU,QAA0B,CAAC;AAElD,oCAAoC;AACpC,eAAO,MAAM,MAAM,QAAsB,CAAC;AAE1C,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,kDAAkD;AAClD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,oDAAoD;AACpD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,wDAAwD;AACxD,eAAO,MAAM,aAAa,QAA8B,CAAC;AAEzD,yDAAyD;AACzD,eAAO,MAAM,oBAAoB,QAAsC,CAAC;AAExE,uDAAuD;AACvD,eAAO,MAAM,eAAe,QAAgC,CAAC"}
|
||||||
76
packages/engine/src/logger.js
Normal file
76
packages/engine/src/logger.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight structured logger for the `@fusion/engine` package.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* import { createLogger } from "./logger.js";
|
||||||
|
* const log = createLogger("my-module");
|
||||||
|
* log.log("hello"); // → console.error("[my-module] hello")
|
||||||
|
* log.warn("oops"); // → console.warn("[my-module] oops")
|
||||||
|
* log.error("fail"); // → console.error("[my-module] fail")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* All engine subsystems should use the pre-built instances exported below
|
||||||
|
* rather than calling `console.*` directly. This gives us a single point
|
||||||
|
* of control for filtering, suppressing (e.g. in tests), or redirecting
|
||||||
|
* engine log output in the future.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Create a structured logger that prefixes every message with `[prefix]`.
|
||||||
|
*
|
||||||
|
* @param prefix - Short subsystem name, e.g. `"scheduler"` or `"executor"`.
|
||||||
|
* @returns A `Logger` whose output is prefixed and sent to stderr. Keeping
|
||||||
|
* engine logs off stdout prevents command/test output consumers from
|
||||||
|
* receiving Fusion execution chatter.
|
||||||
|
*/
|
||||||
|
export function createLogger(prefix) {
|
||||||
|
const tag = `[${prefix}]`;
|
||||||
|
return {
|
||||||
|
log(message, ...args) {
|
||||||
|
console.error(`${tag} ${message}`, ...args);
|
||||||
|
},
|
||||||
|
warn(message, ...args) {
|
||||||
|
console.warn(`${tag} ${message}`, ...args);
|
||||||
|
},
|
||||||
|
error(message, ...args) {
|
||||||
|
console.error(`${tag} ${message}`, ...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Logger for the scheduler subsystem. */
|
||||||
|
export const schedulerLog = createLogger("scheduler");
|
||||||
|
/** Logger for the task executor subsystem. */
|
||||||
|
export const executorLog = createLogger("executor");
|
||||||
|
/** Logger for the triage processor subsystem. */
|
||||||
|
export const triageLog = createLogger("triage");
|
||||||
|
/** Logger for the pi agent session subsystem. */
|
||||||
|
export const piLog = createLogger("pi");
|
||||||
|
/** Logger for extension discovery/provider registration. */
|
||||||
|
export const extensionsLog = createLogger("extensions");
|
||||||
|
/** Logger for the merge/auto-merge subsystem. */
|
||||||
|
export const mergerLog = createLogger("merger");
|
||||||
|
/** Logger for the worktree pool subsystem. */
|
||||||
|
export const worktreePoolLog = createLogger("worktree-pool");
|
||||||
|
/** Logger for the review subsystem. */
|
||||||
|
export const reviewerLog = createLogger("reviewer");
|
||||||
|
/** Logger for the PR monitor subsystem. */
|
||||||
|
export const prMonitorLog = createLogger("pr-monitor");
|
||||||
|
/** Logger for the project runtime subsystem. */
|
||||||
|
export const runtimeLog = createLogger("runtime");
|
||||||
|
/** Logger for the IPC subsystem. */
|
||||||
|
export const ipcLog = createLogger("ipc");
|
||||||
|
/** Logger for the project manager subsystem. */
|
||||||
|
export const projectManagerLog = createLogger("project-manager");
|
||||||
|
/** Logger for the hybrid executor subsystem. */
|
||||||
|
export const hybridExecutorLog = createLogger("hybrid-executor");
|
||||||
|
/** Logger for the mission autopilot subsystem. */
|
||||||
|
export const autopilotLog = createLogger("autopilot");
|
||||||
|
/** Logger for the heartbeat execution subsystem. */
|
||||||
|
export const heartbeatLog = createLogger("heartbeat");
|
||||||
|
/** Logger for remote node runtime/client subsystems. */
|
||||||
|
export const remoteNodeLog = createLogger("remote-node");
|
||||||
|
/** Logger for periodic node health monitor subsystem. */
|
||||||
|
export const nodeHealthMonitorLog = createLogger("node-health-monitor");
|
||||||
|
/** Logger for the peer exchange (gossip) subsystem. */
|
||||||
|
export const peerExchangeLog = createLogger("peer-exchange");
|
||||||
|
//# sourceMappingURL=logger.js.map
|
||||||
1
packages/engine/src/logger.js.map
Normal file
1
packages/engine/src/logger.js.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,GAAG,GAAG,IAAI,MAAM,GAAG,CAAC;IAC1B,OAAO;QACL,GAAG,CAAC,OAAe,EAAE,GAAG,IAAe;YACrC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC9C,CAAC;QACD,IAAI,CAAC,OAAe,EAAE,GAAG,IAAe;YACtC,OAAO,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC7C,CAAC;QACD,KAAK,CAAC,OAAe,EAAE,GAAG,IAAe;YACvC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,IAAI,OAAO,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC;QAC9C,CAAC;KACF,CAAC;AACJ,CAAC;AAED,0CAA0C;AAC1C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,iDAAiD;AACjD,MAAM,CAAC,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;AAExC,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAExD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC;AAE7D,uCAAuC;AACvC,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,2CAA2C;AAC3C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAEvD,gDAAgD;AAChD,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;AAElD,oCAAoC;AACpC,MAAM,CAAC,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;AAE1C,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,kDAAkD;AAClD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,oDAAoD;AACpD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,wDAAwD;AACxD,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;AAEzD,yDAAyD;AACzD,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAAC,qBAAqB,CAAC,CAAC;AAExE,uDAAuD;AACvD,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC"}
|
||||||
95
packages/engine/src/pi.d.ts
vendored
Normal file
95
packages/engine/src/pi.d.ts
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Shared pi SDK setup for fn engine agents.
|
||||||
|
*
|
||||||
|
* Uses Fusion auth for writes and legacy pi auth as a read-only fallback.
|
||||||
|
* Provides factory functions for creating triage and executor agent sessions.
|
||||||
|
*/
|
||||||
|
import { SessionManager, type AgentSession, type ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { type SkillSelectionContext } from "./skill-resolver.js";
|
||||||
|
export interface AgentResult {
|
||||||
|
session: AgentSession;
|
||||||
|
/** Path to the persisted session file (undefined for in-memory sessions). */
|
||||||
|
sessionFile?: string;
|
||||||
|
}
|
||||||
|
export interface PromptableSession extends AgentSession {
|
||||||
|
promptWithFallback: (prompt: string, options?: unknown) => Promise<void>;
|
||||||
|
}
|
||||||
|
export declare function promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Extract a human-readable model description from an AgentSession.
|
||||||
|
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
|
||||||
|
* or `"unknown model"` when the session has no model set.
|
||||||
|
*/
|
||||||
|
export declare function describeModel(session: AgentSession): string;
|
||||||
|
/**
|
||||||
|
* Default instructions used when calling `session.compact()` for loop recovery.
|
||||||
|
* These guide the compaction summary to preserve essential context while
|
||||||
|
* freeing up the context window for continued work.
|
||||||
|
*/
|
||||||
|
export declare const COMPACTION_FALLBACK_INSTRUCTIONS: string;
|
||||||
|
/**
|
||||||
|
* Compact an agent session's context to free up the context window.
|
||||||
|
*
|
||||||
|
* Uses the SDK's native `session.compact()` method when available (the
|
||||||
|
* preferred path — it produces structured, LLM-generated summaries).
|
||||||
|
*
|
||||||
|
* @param session — The agent session to compact
|
||||||
|
* @param customInstructions — Optional instructions for the compaction summary.
|
||||||
|
* When not provided, uses COMPACTION_FALLBACK_INSTRUCTIONS.
|
||||||
|
* @returns The compaction result with summary and token metrics, or null if
|
||||||
|
* compaction was not available or failed.
|
||||||
|
*/
|
||||||
|
export declare function compactSessionContext(session: AgentSession, customInstructions?: string): Promise<{
|
||||||
|
summary: string;
|
||||||
|
tokensBefore: number;
|
||||||
|
} | null>;
|
||||||
|
export interface AgentOptions {
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
tools?: "coding" | "readonly";
|
||||||
|
customTools?: ToolDefinition[];
|
||||||
|
onText?: (delta: string) => void;
|
||||||
|
onThinking?: (delta: string) => void;
|
||||||
|
onToolStart?: (name: string, args?: Record<string, unknown>) => void;
|
||||||
|
onToolEnd?: (name: string, isError: boolean, result?: unknown) => void;
|
||||||
|
/** Default model provider (e.g. "anthropic"). Used with `defaultModelId` to select a specific model. */
|
||||||
|
defaultProvider?: string;
|
||||||
|
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */
|
||||||
|
defaultModelId?: string;
|
||||||
|
/** Optional fallback model provider used when the primary selected model hits
|
||||||
|
* a retryable provider-side failure such as rate limiting or overload. */
|
||||||
|
fallbackProvider?: string;
|
||||||
|
/** Optional fallback model ID used with `fallbackProvider`. */
|
||||||
|
fallbackModelId?: string;
|
||||||
|
/** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */
|
||||||
|
defaultThinkingLevel?: string;
|
||||||
|
/** Optional pre-configured SessionManager. When provided, the agent session
|
||||||
|
* uses this instead of creating an in-memory session. Pass a file-based
|
||||||
|
* SessionManager to enable session persistence and pause/resume. */
|
||||||
|
sessionManager?: SessionManager;
|
||||||
|
/** Optional skill selection context. When provided, the agent session's
|
||||||
|
* skills are filtered according to project execution settings and any
|
||||||
|
* caller-requested skill names. Omit to use default skill discovery
|
||||||
|
* (all discovered skills included). */
|
||||||
|
skillSelection?: SkillSelectionContext;
|
||||||
|
/** Convenience: skill names to include in the session. When provided
|
||||||
|
* (and `skillSelection` is not), auto-constructs a SkillSelectionContext
|
||||||
|
* from the cwd and these names. Ignored when `skillSelection` is set. */
|
||||||
|
skills?: string[];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Wrap tools with worktree boundary validation.
|
||||||
|
* When cwd is a worktree path, file operations are validated against worktree boundaries.
|
||||||
|
*
|
||||||
|
* @param tools - Array of tool definitions to wrap
|
||||||
|
* @param worktreePath - Absolute path to the worktree directory (if applicable)
|
||||||
|
* @param projectRoot - Absolute path to the project root (if applicable)
|
||||||
|
* @returns Wrapped tools with boundary validation
|
||||||
|
*/
|
||||||
|
export declare function wrapToolsWithBoundary(tools: ToolDefinition[], worktreePath: string | null, projectRoot: string | null): ToolDefinition[];
|
||||||
|
/**
|
||||||
|
* Create a pi agent session configured for fn.
|
||||||
|
* Reuses the user's existing pi auth and model configuration.
|
||||||
|
*/
|
||||||
|
export declare function createFnAgent(options: AgentOptions): Promise<AgentResult>;
|
||||||
|
//# sourceMappingURL=pi.d.ts.map
|
||||||
1
packages/engine/src/pi.d.ts.map
Normal file
1
packages/engine/src/pi.d.ts.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"pi.d.ts","sourceRoot":"","sources":["pi.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,EASL,cAAc,EAEd,KAAK,YAAY,EACjB,KAAK,cAAc,EACpB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,YAAY,CAAC;IACtB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,iBAAkB,SAAQ,YAAY;IACrD,kBAAkB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAkCD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDhH;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAI3D;AAED;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,QAKlC,CAAC;AA8GZ;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,YAAY,EACrB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAsB3D;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC9B,WAAW,CAAC,EAAE,cAAc,EAAE,CAAC;IAC/B,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACrE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACvE,wGAAwG;IACxG,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oGAAoG;IACpG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;+EAC2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8HAA8H;IAC9H,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;yEAEqE;IACrE,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;4CAGwC;IACxC,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC;;8EAE0E;IAC1E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AA4QD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,cAAc,EAAE,EACvB,YAAY,EAAE,MAAM,GAAG,IAAI,EAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,GACzB,cAAc,EAAE,CAmDlB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAoQ/E"}
|
||||||
753
packages/engine/src/pi.js
Normal file
753
packages/engine/src/pi.js
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
/**
|
||||||
|
* Shared pi SDK setup for fn engine agents.
|
||||||
|
*
|
||||||
|
* Uses Fusion auth for writes and legacy pi auth as a read-only fallback.
|
||||||
|
* Provides factory functions for creating triage and executor agent sessions.
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { exec } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { basename, dirname, join, relative, isAbsolute, resolve } from "node:path";
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
import { createAgentSession, createCodingTools, createExtensionRuntime, createReadOnlyTools, DefaultResourceLoader, DefaultPackageManager, discoverAndLoadExtensions, ModelRegistry, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, resolvePiExtensionProjectRoot } from "@fusion/core";
|
||||||
|
import { resolveSessionSkills, createSkillsOverrideFromSelection, } from "./skill-resolver.js";
|
||||||
|
import { isContextLimitError } from "./context-limit-detector.js";
|
||||||
|
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
|
||||||
|
import { piLog, extensionsLog } from "./logger.js";
|
||||||
|
function getSessionStateError(session) {
|
||||||
|
const error = session.state?.error;
|
||||||
|
return typeof error === "string" ? error : "";
|
||||||
|
}
|
||||||
|
function clearSessionStateError(session) {
|
||||||
|
const state = session.state;
|
||||||
|
if (!state || typeof state !== "object" || !("error" in state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.error = undefined;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Best effort only. Some session implementations may expose readonly state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function promptSessionAndCheck(session, prompt, options) {
|
||||||
|
clearSessionStateError(session);
|
||||||
|
if (options === undefined) {
|
||||||
|
await session.prompt(prompt);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await session.prompt(prompt, options);
|
||||||
|
}
|
||||||
|
const stateError = getSessionStateError(session);
|
||||||
|
if (stateError) {
|
||||||
|
throw new Error(stateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function promptWithFallback(session, prompt, options) {
|
||||||
|
const maybePromptable = session;
|
||||||
|
if (typeof maybePromptable.promptWithFallback === "function") {
|
||||||
|
piLog.log(`promptWithFallback: delegating to session.promptWithFallback (prompt length=${prompt.length})`);
|
||||||
|
await maybePromptable.promptWithFallback(prompt, options);
|
||||||
|
piLog.log("promptWithFallback: completed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
piLog.log(`promptWithFallback: calling session.prompt (prompt length=${prompt.length})`);
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(session, prompt, options);
|
||||||
|
piLog.log("promptWithFallback: prompt completed");
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
if (!isContextLimitError(errorMessage)) {
|
||||||
|
piLog.error(`promptWithFallback: non-context error — propagating: ${errorMessage}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Context limit error — attempt auto-compaction and retry once
|
||||||
|
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, options);
|
||||||
|
if (promptMemoryRetry.recovered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (promptMemoryRetry.error) {
|
||||||
|
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
|
||||||
|
if (!isContextLimitError(retryMessage)) {
|
||||||
|
throw promptMemoryRetry.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
|
||||||
|
await flushMemoryBeforeSessionCompaction(session);
|
||||||
|
const compactResult = await compactSessionContext(session);
|
||||||
|
if (!compactResult) {
|
||||||
|
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(session, prompt, options);
|
||||||
|
piLog.log("promptWithFallback: prompt completed after auto-compaction");
|
||||||
|
}
|
||||||
|
catch (retryErr) {
|
||||||
|
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||||
|
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
|
||||||
|
throw err; // Throw original error to preserve original context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Extract a human-readable model description from an AgentSession.
|
||||||
|
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
|
||||||
|
* or `"unknown model"` when the session has no model set.
|
||||||
|
*/
|
||||||
|
export function describeModel(session) {
|
||||||
|
const model = session.model;
|
||||||
|
if (!model)
|
||||||
|
return "unknown model";
|
||||||
|
return `${model.provider}/${model.id}`;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Default instructions used when calling `session.compact()` for loop recovery.
|
||||||
|
* These guide the compaction summary to preserve essential context while
|
||||||
|
* freeing up the context window for continued work.
|
||||||
|
*/
|
||||||
|
export const COMPACTION_FALLBACK_INSTRUCTIONS = [
|
||||||
|
"Summarize all completed steps concisely.",
|
||||||
|
"Preserve the current step number and any in-progress work details.",
|
||||||
|
"Keep references to key files, decisions, and error states.",
|
||||||
|
"Discard verbose tool output, repeated attempts, and exploration history.",
|
||||||
|
].join(" ");
|
||||||
|
const MAX_COMPACTED_PROMPT_MEMORY_CHARS = 8_000;
|
||||||
|
function compactMarkdownMemorySection(sectionBody) {
|
||||||
|
const lines = sectionBody.split("\n");
|
||||||
|
const kept = [];
|
||||||
|
let used = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trimEnd();
|
||||||
|
const normalized = trimmed.trimStart();
|
||||||
|
const isUseful = normalized.startsWith("##")
|
||||||
|
|| normalized.startsWith("- ")
|
||||||
|
|| normalized.startsWith("* ")
|
||||||
|
|| /^\d+\.\s/.test(normalized)
|
||||||
|
|| normalized.length === 0;
|
||||||
|
if (!isUseful) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextLength = used + trimmed.length + 1;
|
||||||
|
if (nextLength > MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
kept.push(trimmed);
|
||||||
|
used = nextLength;
|
||||||
|
}
|
||||||
|
const compacted = kept.join("\n").trim();
|
||||||
|
if (compacted.length >= sectionBody.trim().length) {
|
||||||
|
return sectionBody.trim();
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
compacted,
|
||||||
|
"",
|
||||||
|
`<!-- Memory compacted from ${sectionBody.length} characters to avoid context overflow. Use memory tools or the selected memory file later only if essential. -->`,
|
||||||
|
].join("\n").trim();
|
||||||
|
}
|
||||||
|
function compactPromptMemory(prompt) {
|
||||||
|
const sectionPattern = /(^|\n)(## (?:Project Memory|Agent Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
|
||||||
|
let changed = false;
|
||||||
|
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix, heading, body) => {
|
||||||
|
const trimmedBody = body.trim();
|
||||||
|
if (trimmedBody.length <= MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
const compacted = compactMarkdownMemorySection(trimmedBody);
|
||||||
|
if (compacted.length >= trimmedBody.length) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return `${prefix}${heading}${compacted}`;
|
||||||
|
});
|
||||||
|
return changed && compactedPrompt.length < prompt.length ? compactedPrompt : null;
|
||||||
|
}
|
||||||
|
async function retryWithCompactedPromptMemory(session, prompt, options) {
|
||||||
|
const compactedPrompt = compactPromptMemory(prompt);
|
||||||
|
if (!compactedPrompt) {
|
||||||
|
return { recovered: false };
|
||||||
|
}
|
||||||
|
piLog.log(`promptWithFallback: retrying with compacted prompt memory (${prompt.length} → ${compactedPrompt.length} chars)`);
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(session, compactedPrompt, options);
|
||||||
|
piLog.log("promptWithFallback: prompt completed after prompt-memory compaction");
|
||||||
|
return { recovered: true };
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
piLog.error(`promptWithFallback: retry after prompt-memory compaction failed: ${errorMessage}`);
|
||||||
|
return { recovered: false, error: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function flushMemoryBeforeSessionCompaction(session) {
|
||||||
|
if (session.__fusionMemoryAppendAvailable !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flushPrompt = [
|
||||||
|
"Before context compaction, preserve only unresolved durable memory if needed.",
|
||||||
|
"If memory_append is available and you learned reusable project decisions, conventions, pitfalls, or open loops that are not already saved, append them now.",
|
||||||
|
"Use layer=\"long-term\" for durable facts and layer=\"daily\" for running notes/open loops.",
|
||||||
|
"If there is nothing durable to save, reply exactly: NONE.",
|
||||||
|
].join("\n");
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(session, flushPrompt);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
piLog.warn(`promptWithFallback: memory flush before compaction skipped: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Compact an agent session's context to free up the context window.
|
||||||
|
*
|
||||||
|
* Uses the SDK's native `session.compact()` method when available (the
|
||||||
|
* preferred path — it produces structured, LLM-generated summaries).
|
||||||
|
*
|
||||||
|
* @param session — The agent session to compact
|
||||||
|
* @param customInstructions — Optional instructions for the compaction summary.
|
||||||
|
* When not provided, uses COMPACTION_FALLBACK_INSTRUCTIONS.
|
||||||
|
* @returns The compaction result with summary and token metrics, or null if
|
||||||
|
* compaction was not available or failed.
|
||||||
|
*/
|
||||||
|
export async function compactSessionContext(session, customInstructions) {
|
||||||
|
const instructions = customInstructions ?? COMPACTION_FALLBACK_INSTRUCTIONS;
|
||||||
|
// Check if session.compact is available (runtime capability detection)
|
||||||
|
if (typeof session.compact !== "function") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await session.compact(instructions);
|
||||||
|
if (result && typeof result === "object") {
|
||||||
|
return {
|
||||||
|
summary: result.summary ?? "",
|
||||||
|
tokensBefore: result.tokensBefore ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
piLog.warn(`Context compaction failed (will fall through to kill/requeue): ${msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resolveConfiguredModel(modelRegistry, kind, provider, modelId) {
|
||||||
|
if (!provider || !modelId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const model = modelRegistry.find(provider, modelId);
|
||||||
|
if (model) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
// Fall back to constructing a model on-the-fly if the provider is known.
|
||||||
|
// This mirrors the pi CLI's buildFallbackModel behaviour, which accepts any
|
||||||
|
// model ID for a configured provider (e.g. any OpenRouter model string) even
|
||||||
|
// when it isn't in the built-in or custom model list.
|
||||||
|
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider);
|
||||||
|
if (providerModels.length > 0) {
|
||||||
|
const baseModel = providerModels[0];
|
||||||
|
piLog.warn(`${kind} model ${provider}/${modelId} not in registry; using provider base model as template`);
|
||||||
|
return { ...baseModel, id: modelId, name: modelId };
|
||||||
|
}
|
||||||
|
throw new Error(`Configured ${kind} model ${provider}/${modelId} was not found in the pi model registry. ` +
|
||||||
|
"Open Settings and choose a model from /api/models, or update your pi model configuration.");
|
||||||
|
}
|
||||||
|
function isRetryableModelSelectionError(message) {
|
||||||
|
const normalized = message.toLowerCase();
|
||||||
|
return normalized.includes("rate limit")
|
||||||
|
|| normalized.includes("too many requests")
|
||||||
|
|| normalized.includes("429")
|
||||||
|
|| normalized.includes("401")
|
||||||
|
|| normalized.includes("403")
|
||||||
|
|| normalized.includes("unauthorized")
|
||||||
|
|| normalized.includes("forbidden")
|
||||||
|
|| normalized.includes("authentication")
|
||||||
|
|| normalized.includes("invalid api key")
|
||||||
|
|| normalized.includes("invalid key")
|
||||||
|
|| normalized.includes("api key")
|
||||||
|
|| normalized.includes("overloaded")
|
||||||
|
|| normalized.includes("quota")
|
||||||
|
|| normalized.includes("capacity")
|
||||||
|
|| normalized.includes("temporarily unavailable")
|
||||||
|
|| normalized.includes("invalid temperature");
|
||||||
|
}
|
||||||
|
function readJsonObject(path) {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hasPackageManagerSettings(settings) {
|
||||||
|
return Array.isArray(settings.packages) || Array.isArray(settings.npmCommand);
|
||||||
|
}
|
||||||
|
function siblingAgentDir(agentDir, siblingRoot) {
|
||||||
|
if (basename(agentDir) !== "agent") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return join(dirname(dirname(agentDir)), siblingRoot, "agent");
|
||||||
|
}
|
||||||
|
function createReadOnlyPiSettingsView(cwd, agentDir) {
|
||||||
|
const projectRoot = resolvePiExtensionProjectRoot(cwd);
|
||||||
|
const fusionAgentDir = agentDir.includes(`${join(".fusion", "agent")}`)
|
||||||
|
? agentDir
|
||||||
|
: siblingAgentDir(agentDir, ".fusion");
|
||||||
|
const legacyAgentDir = agentDir.includes(`${join(".pi", "agent")}`)
|
||||||
|
? agentDir
|
||||||
|
: siblingAgentDir(agentDir, ".pi");
|
||||||
|
const legacyGlobalSettings = legacyAgentDir ? readJsonObject(join(legacyAgentDir, "settings.json")) : {};
|
||||||
|
const fusionGlobalSettings = fusionAgentDir ? readJsonObject(join(fusionAgentDir, "settings.json")) : {};
|
||||||
|
const directGlobalSettings = readJsonObject(join(agentDir, "settings.json"));
|
||||||
|
const globalSettings = { ...legacyGlobalSettings, ...directGlobalSettings, ...fusionGlobalSettings };
|
||||||
|
const fusionProjectSettings = readJsonObject(join(projectRoot, ".fusion", "settings.json"));
|
||||||
|
const mergedSettings = { ...globalSettings, ...fusionProjectSettings };
|
||||||
|
return {
|
||||||
|
getGlobalSettings: () => structuredClone(globalSettings),
|
||||||
|
getProjectSettings: () => structuredClone(fusionProjectSettings),
|
||||||
|
getNpmCommand: () => Array.isArray(mergedSettings.npmCommand)
|
||||||
|
? [...mergedSettings.npmCommand]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function getPackageManagerAgentDir() {
|
||||||
|
const fusionAgentDir = getFusionAgentDir();
|
||||||
|
const legacyAgentDir = getLegacyPiAgentDir();
|
||||||
|
const fusionSettings = readJsonObject(join(fusionAgentDir, "settings.json"));
|
||||||
|
const legacySettings = readJsonObject(join(legacyAgentDir, "settings.json"));
|
||||||
|
if (hasPackageManagerSettings(fusionSettings) || !existsSync(legacyAgentDir)) {
|
||||||
|
return fusionAgentDir;
|
||||||
|
}
|
||||||
|
if (hasPackageManagerSettings(legacySettings)) {
|
||||||
|
return legacyAgentDir;
|
||||||
|
}
|
||||||
|
return existsSync(fusionAgentDir) ? fusionAgentDir : legacyAgentDir;
|
||||||
|
}
|
||||||
|
async function registerExtensionProviders(cwd, modelRegistry) {
|
||||||
|
try {
|
||||||
|
const agentDir = getPackageManagerAgentDir();
|
||||||
|
const packageManager = new DefaultPackageManager({
|
||||||
|
cwd,
|
||||||
|
agentDir,
|
||||||
|
settingsManager: createReadOnlyPiSettingsView(cwd, agentDir),
|
||||||
|
});
|
||||||
|
const resolvedPaths = await packageManager.resolve();
|
||||||
|
const packageExtensionPaths = resolvedPaths.extensions
|
||||||
|
.filter((resource) => resource.enabled)
|
||||||
|
.map((resource) => resource.path);
|
||||||
|
const extensionsResult = await discoverAndLoadExtensions([...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths], cwd, join(resolvePiExtensionProjectRoot(cwd), ".fusion", "disabled-auto-extension-discovery"));
|
||||||
|
for (const { path, error } of extensionsResult.errors) {
|
||||||
|
extensionsLog.warn(`Failed to load ${path}: ${error}`);
|
||||||
|
}
|
||||||
|
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
|
||||||
|
try {
|
||||||
|
modelRegistry.registerProvider(name, config);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
extensionsLog.warn(`Failed to register provider from ${extensionPath}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extensionsResult.runtime.pendingProviderRegistrations = [];
|
||||||
|
modelRegistry.refresh();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
extensionsLog.error(`Failed to discover extensions: ${message}`);
|
||||||
|
createExtensionRuntime();
|
||||||
|
modelRegistry.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── Worktree Path Boundary Helpers ──────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Detect if a path is a task worktree under `.worktrees/`.
|
||||||
|
* Returns the project root if the path is a worktree, otherwise null.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* `/project/.worktrees/fn-001` → `/project`
|
||||||
|
* `/project/.worktrees/fn-001/src/file.ts` → `/project`
|
||||||
|
* `/project` → null (not a worktree)
|
||||||
|
*/
|
||||||
|
function getProjectRootFromWorktree(cwd) {
|
||||||
|
// Match paths like /project/.worktrees/task-id or /project/.worktrees/task-id/...
|
||||||
|
const match = cwd.match(/^(.+?)\/\.worktrees\/[^/]+/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async function isRegisteredGitWorktree(projectRoot, worktreePath) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
const resolvedWorktree = resolve(worktreePath);
|
||||||
|
return stdout.split("\n").some((line) => line.startsWith("worktree ") && resolve(line.slice("worktree ".length)) === resolvedWorktree);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function isCompleteGitWorktree(worktreePath) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
|
||||||
|
cwd: worktreePath,
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
return resolve(stdout.trim()) === resolve(worktreePath);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function assertValidWorktreeSession(cwd, projectRoot) {
|
||||||
|
if (!existsSync(cwd)) {
|
||||||
|
throw new Error(`Refusing to start coding agent in missing worktree: ${cwd}`);
|
||||||
|
}
|
||||||
|
if (!existsSync(join(cwd, ".git")) || !await isCompleteGitWorktree(cwd)) {
|
||||||
|
throw new Error(`Refusing to start coding agent in incomplete worktree: ${cwd}`);
|
||||||
|
}
|
||||||
|
if (!await isRegisteredGitWorktree(projectRoot, cwd)) {
|
||||||
|
throw new Error(`Refusing to start coding agent in unregistered git worktree: ${cwd}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Check if a path is allowed to be accessed from a worktree session.
|
||||||
|
* Rules:
|
||||||
|
* - Paths inside the worktree are always allowed
|
||||||
|
* - Project root .fusion/memory/ files are allowed (for durable project learnings)
|
||||||
|
* - Task attachments under .fusion/tasks/N/attachments/ are allowed (for reading context files)
|
||||||
|
* - All other paths outside the worktree are rejected
|
||||||
|
*
|
||||||
|
* @param worktreePath - Absolute path to the worktree directory
|
||||||
|
* @param projectRoot - Absolute path to the project root (derived from worktree)
|
||||||
|
* @param requestedPath - The path being accessed
|
||||||
|
* @returns true if allowed, false if rejected
|
||||||
|
*/
|
||||||
|
function isWorktreeAllowedPath(worktreePath, projectRoot, requestedPath) {
|
||||||
|
// Normalize paths
|
||||||
|
const worktreeResolved = resolve(worktreePath);
|
||||||
|
const projectRootResolved = resolve(projectRoot);
|
||||||
|
const requestedResolved = isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(worktreeResolved, requestedPath);
|
||||||
|
// Check if path is inside the worktree
|
||||||
|
const relToWorktree = relative(worktreeResolved, requestedResolved);
|
||||||
|
if (!relToWorktree.startsWith("..") && !isAbsolute(relToWorktree)) {
|
||||||
|
return true; // Path is inside the worktree
|
||||||
|
}
|
||||||
|
// Exception: project root `.fusion/memory/` files for durable project learnings
|
||||||
|
const relToProjectRoot = relative(projectRootResolved, requestedResolved).replace(/\\/g, "/");
|
||||||
|
if (relToProjectRoot === ".fusion/memory" ||
|
||||||
|
relToProjectRoot === ".fusion/memory/" ||
|
||||||
|
relToProjectRoot.startsWith(".fusion/memory/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Exception: task attachments under `.fusion/tasks/*/attachments/*`
|
||||||
|
if (relToProjectRoot.match(/^\.fusion\/tasks\/[^/]+\/attachments\//)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// All other paths outside the worktree are rejected
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Wrap tools with worktree boundary validation.
|
||||||
|
* When cwd is a worktree path, file operations are validated against worktree boundaries.
|
||||||
|
*
|
||||||
|
* @param tools - Array of tool definitions to wrap
|
||||||
|
* @param worktreePath - Absolute path to the worktree directory (if applicable)
|
||||||
|
* @param projectRoot - Absolute path to the project root (if applicable)
|
||||||
|
* @returns Wrapped tools with boundary validation
|
||||||
|
*/
|
||||||
|
export function wrapToolsWithBoundary(tools, worktreePath, projectRoot) {
|
||||||
|
if (!worktreePath || !projectRoot) {
|
||||||
|
return tools; // Not a worktree session, no wrapping needed
|
||||||
|
}
|
||||||
|
return tools.map((tool) => {
|
||||||
|
// Only wrap tools that access the filesystem
|
||||||
|
const fileToolNames = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
|
||||||
|
if (!fileToolNames.has(tool.name)) {
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
// Store the original execute function
|
||||||
|
const originalExecute = tool.execute;
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
execute: async (...args) => {
|
||||||
|
const _toolCallId = args[0];
|
||||||
|
const params = args[1];
|
||||||
|
const _signal = args[2];
|
||||||
|
// Check path argument for file operations
|
||||||
|
const pathArg = params.path;
|
||||||
|
if (pathArg && !isWorktreeAllowedPath(worktreePath, projectRoot, pathArg)) {
|
||||||
|
const relToProject = relative(projectRoot, pathArg);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Path "${relToProject}" is outside the worktree boundary. ` +
|
||||||
|
`Coding agents can only modify files inside the current worktree. ` +
|
||||||
|
`Exception: .fusion/memory/ (project root) and .fusion/tasks/*/attachments/* are permitted for reading.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For bash, also check the working directory if specified
|
||||||
|
const cwdArg = params.cwd;
|
||||||
|
if (tool.name === "bash" && cwdArg && !isWorktreeAllowedPath(worktreePath, projectRoot, cwdArg)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Working directory is outside the worktree boundary. ` +
|
||||||
|
`Commands must run inside the worktree.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Call the original tool implementation with all arguments passed through
|
||||||
|
return originalExecute(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a pi agent session configured for fn.
|
||||||
|
* Reuses the user's existing pi auth and model configuration.
|
||||||
|
*/
|
||||||
|
export async function createFnAgent(options) {
|
||||||
|
piLog.log(`createFnAgent called (cwd=${options.cwd}, tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
|
||||||
|
const authStorage = createFusionAuthStorage();
|
||||||
|
const modelRegistry = new ModelRegistry(authStorage, getModelRegistryModelsPath());
|
||||||
|
await registerExtensionProviders(options.cwd, modelRegistry);
|
||||||
|
const tools = options.tools === "readonly"
|
||||||
|
? createReadOnlyTools(options.cwd)
|
||||||
|
: createCodingTools(options.cwd);
|
||||||
|
// Detect if this is a worktree session and apply path boundaries
|
||||||
|
const worktreePath = options.cwd;
|
||||||
|
const projectRoot = getProjectRootFromWorktree(worktreePath);
|
||||||
|
if (projectRoot) {
|
||||||
|
await assertValidWorktreeSession(worktreePath, projectRoot);
|
||||||
|
}
|
||||||
|
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, projectRoot);
|
||||||
|
// Compaction is explicitly enabled to prevent context-window overflow during
|
||||||
|
// long-running agent conversations (triage, execution, review, merge).
|
||||||
|
// When the context fills up, pi auto-compacts the conversation history to
|
||||||
|
// keep the session alive without manual intervention. This must remain enabled
|
||||||
|
// as a reliability safeguard — disabling it would cause overflow failures.
|
||||||
|
const settingsManager = SettingsManager.inMemory({
|
||||||
|
compaction: { enabled: true },
|
||||||
|
retry: { enabled: true, maxRetries: 3 },
|
||||||
|
});
|
||||||
|
// Resolve explicit model selection if provider and model ID are specified
|
||||||
|
const selectedModel = resolveConfiguredModel(modelRegistry, "primary", options.defaultProvider, options.defaultModelId);
|
||||||
|
const fallbackModel = resolveConfiguredModel(modelRegistry, "fallback", options.fallbackProvider, options.fallbackModelId);
|
||||||
|
// Resolve skill selection: explicit skillSelection wins over convenience `skills`
|
||||||
|
let effectiveSkillSelection = options.skillSelection;
|
||||||
|
if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {
|
||||||
|
piLog.log(`Using skills from convenience parameter: [${options.skills.join(", ")}]`);
|
||||||
|
effectiveSkillSelection = {
|
||||||
|
projectRootDir: options.cwd,
|
||||||
|
requestedSkillNames: options.skills,
|
||||||
|
sessionPurpose: "executor",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Resolve skill selection if provided
|
||||||
|
let skillsOverrideFn;
|
||||||
|
if (effectiveSkillSelection) {
|
||||||
|
const selectionResult = resolveSessionSkills(effectiveSkillSelection);
|
||||||
|
if (selectionResult.diagnostics.length > 0) {
|
||||||
|
const purpose = effectiveSkillSelection.sessionPurpose ?? "skills";
|
||||||
|
for (const diag of selectionResult.diagnostics) {
|
||||||
|
piLog.warn(`[skills] [${purpose}] ${diag.type}: ${diag.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
skillsOverrideFn = createSkillsOverrideFromSelection(selectionResult, {
|
||||||
|
requestedSkillNames: effectiveSkillSelection.requestedSkillNames,
|
||||||
|
sessionPurpose: effectiveSkillSelection.sessionPurpose,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const resourceLoader = new DefaultResourceLoader({
|
||||||
|
cwd: options.cwd,
|
||||||
|
settingsManager,
|
||||||
|
systemPromptOverride: () => options.systemPrompt,
|
||||||
|
appendSystemPromptOverride: () => [],
|
||||||
|
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
||||||
|
});
|
||||||
|
await resourceLoader.reload();
|
||||||
|
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
|
||||||
|
const createSessionWithModel = async (modelOverride) => {
|
||||||
|
return createAgentSession({
|
||||||
|
cwd: options.cwd,
|
||||||
|
authStorage,
|
||||||
|
modelRegistry,
|
||||||
|
resourceLoader,
|
||||||
|
tools: wrappedTools,
|
||||||
|
customTools: options.customTools,
|
||||||
|
sessionManager,
|
||||||
|
settingsManager,
|
||||||
|
...(modelOverride ? { model: modelOverride } : {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let sessionResult;
|
||||||
|
let usingFallback = false;
|
||||||
|
try {
|
||||||
|
sessionResult = await createSessionWithModel(selectedModel);
|
||||||
|
piLog.log(`Session created successfully (model=${selectedModel ? `${selectedModel.provider}/${selectedModel.id}` : "default"})`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (!fallbackModel || !selectedModel || !isRetryableModelSelectionError(err?.message || "")) {
|
||||||
|
piLog.error(`Session creation failed: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
piLog.warn(`Primary model failed (${err.message}), trying fallback`);
|
||||||
|
usingFallback = true;
|
||||||
|
sessionResult = await createSessionWithModel(fallbackModel);
|
||||||
|
piLog.log("Fallback session created successfully");
|
||||||
|
}
|
||||||
|
const { session } = sessionResult;
|
||||||
|
session.__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
|
||||||
|
const promptableSession = session;
|
||||||
|
promptableSession.promptWithFallback = async (prompt, promptOptions) => {
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(session, prompt, promptOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errorMessage = err?.message || "";
|
||||||
|
if (isContextLimitError(errorMessage)) {
|
||||||
|
// Context limit error — attempt auto-compaction and retry once
|
||||||
|
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions);
|
||||||
|
if (promptMemoryRetry.recovered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (promptMemoryRetry.error) {
|
||||||
|
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
|
||||||
|
if (!isContextLimitError(retryMessage)) {
|
||||||
|
throw promptMemoryRetry.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
|
||||||
|
await flushMemoryBeforeSessionCompaction(session);
|
||||||
|
const compactResult = await compactSessionContext(session);
|
||||||
|
if (compactResult) {
|
||||||
|
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(session, prompt, promptOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (retryErr) {
|
||||||
|
const retryErrorMessage = retryErr?.message || "";
|
||||||
|
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
|
||||||
|
// Throw original error to preserve original context
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
usingFallback = true;
|
||||||
|
try {
|
||||||
|
session.dispose();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
piLog.warn(`Failed to dispose session during model fallback swap: ${msg}`);
|
||||||
|
}
|
||||||
|
const fallbackSessionResult = await createSessionWithModel(fallbackModel);
|
||||||
|
const fallbackSession = fallbackSessionResult.session;
|
||||||
|
fallbackSession.__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
|
||||||
|
if (options.defaultThinkingLevel) {
|
||||||
|
fallbackSession.setThinkingLevel(options.defaultThinkingLevel);
|
||||||
|
}
|
||||||
|
fallbackSession.subscribe((event) => {
|
||||||
|
if (event.type === "message_update") {
|
||||||
|
const msgEvent = event.assistantMessageEvent;
|
||||||
|
if (msgEvent.type === "text_delta") {
|
||||||
|
options.onText?.(msgEvent.delta);
|
||||||
|
}
|
||||||
|
else if (msgEvent.type === "thinking_delta") {
|
||||||
|
options.onThinking?.(msgEvent.delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === "tool_execution_start") {
|
||||||
|
options.onToolStart?.(event.toolName, event.args);
|
||||||
|
}
|
||||||
|
if (event.type === "tool_execution_end") {
|
||||||
|
options.onToolEnd?.(event.toolName, event.isError, event.result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(fallbackSession));
|
||||||
|
Object.assign(promptableSession, fallbackSession);
|
||||||
|
promptableSession.promptWithFallback = fallbackSession.promptWithFallback ?? promptableSession.promptWithFallback;
|
||||||
|
// Retry with fallback model, also with auto-compaction support
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (fallbackErr) {
|
||||||
|
const fallbackErrorMessage = fallbackErr?.message || "";
|
||||||
|
if (isContextLimitError(fallbackErrorMessage)) {
|
||||||
|
const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, promptOptions);
|
||||||
|
if (promptMemoryRetry.recovered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (promptMemoryRetry.error) {
|
||||||
|
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
|
||||||
|
if (!isContextLimitError(retryMessage)) {
|
||||||
|
throw promptMemoryRetry.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
piLog.warn("promptWithFallback: fallback session context limit error — attempting auto-compaction");
|
||||||
|
await flushMemoryBeforeSessionCompaction(fallbackSession);
|
||||||
|
const compactResult = await compactSessionContext(fallbackSession);
|
||||||
|
if (compactResult) {
|
||||||
|
piLog.log(`promptWithFallback: fallback compaction succeeded (${compactResult.tokensBefore} tokens) — retrying`);
|
||||||
|
try {
|
||||||
|
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (retryErr) {
|
||||||
|
const retryErrorMessage = retryErr?.message || "";
|
||||||
|
piLog.error(`promptWithFallback: fallback retry after auto-compaction failed: ${retryErrorMessage}`);
|
||||||
|
throw fallbackErr; // Throw original fallback error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
piLog.error("promptWithFallback: fallback compaction unavailable — propagating original error");
|
||||||
|
throw fallbackErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw fallbackErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Apply thinking level if specified
|
||||||
|
if (options.defaultThinkingLevel) {
|
||||||
|
promptableSession.setThinkingLevel(options.defaultThinkingLevel);
|
||||||
|
}
|
||||||
|
// Wire up event listeners
|
||||||
|
promptableSession.subscribe((event) => {
|
||||||
|
if (event.type === "message_update") {
|
||||||
|
const msgEvent = event.assistantMessageEvent;
|
||||||
|
if (msgEvent.type === "text_delta") {
|
||||||
|
options.onText?.(msgEvent.delta);
|
||||||
|
}
|
||||||
|
else if (msgEvent.type === "thinking_delta") {
|
||||||
|
options.onThinking?.(msgEvent.delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type === "tool_execution_start") {
|
||||||
|
options.onToolStart?.(event.toolName, event.args);
|
||||||
|
}
|
||||||
|
if (event.type === "tool_execution_end") {
|
||||||
|
options.onToolEnd?.(event.toolName, event.isError, event.result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { session: promptableSession, sessionFile: promptableSession.sessionFile };
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=pi.js.map
|
||||||
1
packages/engine/src/pi.js.map
Normal file
1
packages/engine/src/pi.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -822,6 +822,98 @@ describe("PluginRunner", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Paperclip runtime compatibility", () => {
|
||||||
|
/**
|
||||||
|
* Verify that the paperclip runtime registration from
|
||||||
|
* plugins/fusion-plugin-paperclip-runtime is correctly resolvable
|
||||||
|
* through the engine's runtime resolution system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
it("should resolve paperclip runtime when registered", () => {
|
||||||
|
const paperclipRuntime = {
|
||||||
|
metadata: {
|
||||||
|
runtimeId: "paperclip",
|
||||||
|
name: "Paperclip Runtime",
|
||||||
|
description: "Paperclip-backed AI session using the user's configured pi provider and model",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
factory: vi.fn().mockResolvedValue({}),
|
||||||
|
};
|
||||||
|
mockPluginLoader.getPluginRuntimes.mockReturnValue([
|
||||||
|
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = pluginRunner.getRuntimeById("paperclip");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.pluginId).toBe("fusion-plugin-paperclip-runtime");
|
||||||
|
expect(result?.runtime.metadata.runtimeId).toBe("paperclip");
|
||||||
|
expect(result?.runtime.metadata.name).toBe("Paperclip Runtime");
|
||||||
|
expect(result?.runtime.metadata.description).toContain("Paperclip");
|
||||||
|
expect(result?.runtime.metadata.version).toBe("1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should expose paperclip runtime metadata correctly", () => {
|
||||||
|
const paperclipRuntime = {
|
||||||
|
metadata: {
|
||||||
|
runtimeId: "paperclip",
|
||||||
|
name: "Paperclip Runtime",
|
||||||
|
description: "Paperclip-backed AI session using the user's configured pi provider and model",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
factory: vi.fn().mockResolvedValue({}),
|
||||||
|
};
|
||||||
|
mockPluginLoader.getPluginRuntimes.mockReturnValue([
|
||||||
|
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtimes = pluginRunner.getPluginRuntimes();
|
||||||
|
const paperclip = runtimes.find(r => r.runtime.metadata.runtimeId === "paperclip");
|
||||||
|
|
||||||
|
expect(paperclip).toBeDefined();
|
||||||
|
expect(paperclip?.runtime.metadata).toEqual({
|
||||||
|
runtimeId: "paperclip",
|
||||||
|
name: "Paperclip Runtime",
|
||||||
|
description: "Paperclip-backed AI session using the user's configured pi provider and model",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow factory invocation for paperclip runtime", async () => {
|
||||||
|
const mockAdapter = {
|
||||||
|
id: "paperclip",
|
||||||
|
name: "Paperclip Runtime",
|
||||||
|
createSession: vi.fn(),
|
||||||
|
promptWithFallback: vi.fn(),
|
||||||
|
describeModel: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
};
|
||||||
|
const paperclipRuntime = {
|
||||||
|
metadata: {
|
||||||
|
runtimeId: "paperclip",
|
||||||
|
name: "Paperclip Runtime",
|
||||||
|
description: "Paperclip-backed AI session using the user's configured pi provider and model",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
factory: vi.fn().mockResolvedValue(mockAdapter),
|
||||||
|
};
|
||||||
|
mockPluginLoader.getPluginRuntimes.mockReturnValue([
|
||||||
|
{ pluginId: "fusion-plugin-paperclip-runtime", runtime: paperclipRuntime as any },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = pluginRunner.getRuntimeById("paperclip");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
|
||||||
|
// Invoke the factory (simulating runtime instantiation)
|
||||||
|
const context = { pluginId: "fusion-plugin-paperclip-runtime" };
|
||||||
|
const runtime = (await result!.runtime.factory(context as any)) as typeof mockAdapter;
|
||||||
|
|
||||||
|
expect(paperclipRuntime.factory).toHaveBeenCalledWith(context);
|
||||||
|
expect(runtime).toBe(mockAdapter);
|
||||||
|
expect(runtime.id).toBe("paperclip");
|
||||||
|
expect(runtime.name).toBe("Paperclip Runtime");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getLoader() / getStore()", () => {
|
describe("getLoader() / getStore()", () => {
|
||||||
it("should return the plugin loader", () => {
|
it("should return the plugin loader", () => {
|
||||||
const loader = pluginRunner.getLoader();
|
const loader = pluginRunner.getLoader();
|
||||||
|
|||||||
112
packages/engine/src/skill-resolver.d.ts
vendored
Normal file
112
packages/engine/src/skill-resolver.d.ts
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Skill selection resolver for deterministic session skill sets.
|
||||||
|
*
|
||||||
|
* Computes which skills should be available in agent sessions based on:
|
||||||
|
* 1. Project execution-enabled skill patterns from settings
|
||||||
|
* 2. Optional caller-requested skill names (for per-task overrides)
|
||||||
|
*
|
||||||
|
* The resolver reads project settings files directly (read-only) and produces
|
||||||
|
* a filter set used by createFnAgent's DefaultResourceLoader.skillsOverride.
|
||||||
|
*/
|
||||||
|
import type { ResourceDiagnostic, Skill } from "@mariozechner/pi-coding-agent";
|
||||||
|
/**
|
||||||
|
* Context for skill selection resolution.
|
||||||
|
*/
|
||||||
|
export interface SkillSelectionContext {
|
||||||
|
/**
|
||||||
|
* Absolute path to the project root for reading settings.
|
||||||
|
*/
|
||||||
|
projectRootDir: string;
|
||||||
|
/**
|
||||||
|
* Optional explicit skill names the caller wants (e.g., from task config).
|
||||||
|
* These are skill names (not IDs), matched case-insensitively against Skill.name.
|
||||||
|
*/
|
||||||
|
requestedSkillNames?: string[];
|
||||||
|
/**
|
||||||
|
* Diagnostic label for log messages (e.g., "executor", "triage", "reviewer").
|
||||||
|
*/
|
||||||
|
sessionPurpose?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Diagnostic about a configured or requested skill.
|
||||||
|
*/
|
||||||
|
export interface SkillDiagnostic {
|
||||||
|
type: "info" | "warning" | "error";
|
||||||
|
message: string;
|
||||||
|
skillName?: string;
|
||||||
|
skillPath?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Result of skill selection resolution.
|
||||||
|
*/
|
||||||
|
export interface SkillSelectionResult {
|
||||||
|
/**
|
||||||
|
* Set of skill file paths to include in the session.
|
||||||
|
* Used by skillsOverride to filter discovered skills.
|
||||||
|
*/
|
||||||
|
allowedSkillPaths: Set<string>;
|
||||||
|
/**
|
||||||
|
* Set of skill file paths that were explicitly excluded by project patterns.
|
||||||
|
* These paths were disabled via -prefix patterns.
|
||||||
|
* Used by skillsOverride to distinguish "disabled" (exists but excluded) from "missing" (doesn't exist).
|
||||||
|
*/
|
||||||
|
excludedSkillPaths: Set<string>;
|
||||||
|
/**
|
||||||
|
* Diagnostics about configured/requested skills.
|
||||||
|
*/
|
||||||
|
diagnostics: SkillDiagnostic[];
|
||||||
|
/**
|
||||||
|
* Whether filtering should be applied.
|
||||||
|
* false = all discovered skills pass through (no patterns configured, no requested names)
|
||||||
|
* true = skills are filtered according to allowedSkillPaths
|
||||||
|
*/
|
||||||
|
filterActive: boolean;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Compute deterministic skill selection from project settings and optional requested names.
|
||||||
|
*
|
||||||
|
* Resolution rules:
|
||||||
|
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
|
||||||
|
* 2. If skill patterns exist:
|
||||||
|
* - + prefix or no prefix = add to allowed set
|
||||||
|
* - - prefix = exclude from allowed set
|
||||||
|
* - Last entry wins for duplicate paths
|
||||||
|
* 3. If requestedSkillNames provided:
|
||||||
|
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
|
||||||
|
* - Case-insensitive matching against Skill.name
|
||||||
|
* 4. Diagnostics produced for:
|
||||||
|
* - Patterns that don't match discovered skills (warning)
|
||||||
|
* - Requested names not matching any discovered skill (warning)
|
||||||
|
*/
|
||||||
|
export declare function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult;
|
||||||
|
/**
|
||||||
|
* Options for skills override filtering.
|
||||||
|
* We track requested names here so we can validate against base.skills.
|
||||||
|
*/
|
||||||
|
export interface SkillsOverrideOptions {
|
||||||
|
/** Set of allowed skill paths */
|
||||||
|
allowedSkillPaths: Set<string>;
|
||||||
|
/** Set of explicitly excluded skill paths (from -patterns). If not provided, defaults to empty set. */
|
||||||
|
excludedSkillPaths?: Set<string>;
|
||||||
|
/** Whether filtering is active */
|
||||||
|
filterActive: boolean;
|
||||||
|
/** Requested skill names for diagnostic purposes */
|
||||||
|
requestedSkillNames?: string[];
|
||||||
|
/** Session purpose for log messages */
|
||||||
|
sessionPurpose?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
|
||||||
|
*
|
||||||
|
* @param selection - The skill selection result from resolveSessionSkills
|
||||||
|
* @param options - Additional options for the override
|
||||||
|
* @returns A skillsOverride callback for DefaultResourceLoader
|
||||||
|
*/
|
||||||
|
export declare function createSkillsOverrideFromSelection(selection: SkillSelectionResult, options?: Omit<SkillsOverrideOptions, "allowedSkillPaths" | "filterActive">): (base: {
|
||||||
|
skills: Skill[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
}) => {
|
||||||
|
skills: Skill[];
|
||||||
|
diagnostics: ResourceDiagnostic[];
|
||||||
|
};
|
||||||
|
//# sourceMappingURL=skill-resolver.d.ts.map
|
||||||
1
packages/engine/src/skill-resolver.d.ts.map
Normal file
1
packages/engine/src/skill-resolver.d.ts.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"skill-resolver.d.ts","sourceRoot":"","sources":["skill-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,+BAA+B,CAAC;AAK/E;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;OAEG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAE/B;;OAEG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAE/B;;;;OAIG;IACH,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAEhC;;OAEG;IACH,WAAW,EAAE,eAAe,EAAE,CAAC;IAE/B;;;;OAIG;IACH,YAAY,EAAE,OAAO,CAAC;CACvB;AAqED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,oBAAoB,CA0GzF;AAID;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,iCAAiC;IACjC,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,uGAAuG;IACvG,kBAAkB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,kCAAkC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,oBAAoB,EAC/B,OAAO,GAAE,IAAI,CAAC,qBAAqB,EAAE,mBAAmB,GAAG,cAAc,CAAM,GAC9E,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,WAAW,EAAE,kBAAkB,EAAE,CAAA;CAAE,KAAK;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,WAAW,EAAE,kBAAkB,EAAE,CAAA;CAAE,CAoG1H"}
|
||||||
271
packages/engine/src/skill-resolver.js
Normal file
271
packages/engine/src/skill-resolver.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Skill selection resolver for deterministic session skill sets.
|
||||||
|
*
|
||||||
|
* Computes which skills should be available in agent sessions based on:
|
||||||
|
* 1. Project execution-enabled skill patterns from settings
|
||||||
|
* 2. Optional caller-requested skill names (for per-task overrides)
|
||||||
|
*
|
||||||
|
* The resolver reads project settings files directly (read-only) and produces
|
||||||
|
* a filter set used by createFnAgent's DefaultResourceLoader.skillsOverride.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { piLog } from "./logger.js";
|
||||||
|
// ── Settings Reading ─────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Read a JSON object from a file path.
|
||||||
|
* Returns empty object if file doesn't exist or is invalid.
|
||||||
|
*/
|
||||||
|
function readJsonObject(path) {
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Read project settings from .fusion/settings.json.
|
||||||
|
*/
|
||||||
|
function readProjectSettings(projectRootDir) {
|
||||||
|
const fusionSettings = join(projectRootDir, ".fusion", "settings.json");
|
||||||
|
if (existsSync(fusionSettings)) {
|
||||||
|
const parsed = readJsonObject(fusionSettings);
|
||||||
|
// Only return skill-relevant fields
|
||||||
|
return {
|
||||||
|
skills: Array.isArray(parsed.skills) ? parsed.skills : undefined,
|
||||||
|
packages: Array.isArray(parsed.packages) ? parsed.packages : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
// ── Pattern Normalization ────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Normalize a skill pattern by removing the + prefix (enabled by default).
|
||||||
|
* Returns the path portion of the pattern.
|
||||||
|
*/
|
||||||
|
function normalizePattern(pattern) {
|
||||||
|
if (pattern.startsWith("+") || pattern.startsWith("-")) {
|
||||||
|
return pattern.slice(1);
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Check if a pattern is an exclusion pattern (-prefixed).
|
||||||
|
*/
|
||||||
|
function isExclusionPattern(pattern) {
|
||||||
|
return pattern.startsWith("-");
|
||||||
|
}
|
||||||
|
// ── Main Resolution Logic ────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Compute deterministic skill selection from project settings and optional requested names.
|
||||||
|
*
|
||||||
|
* Resolution rules:
|
||||||
|
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
|
||||||
|
* 2. If skill patterns exist:
|
||||||
|
* - + prefix or no prefix = add to allowed set
|
||||||
|
* - - prefix = exclude from allowed set
|
||||||
|
* - Last entry wins for duplicate paths
|
||||||
|
* 3. If requestedSkillNames provided:
|
||||||
|
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
|
||||||
|
* - Case-insensitive matching against Skill.name
|
||||||
|
* 4. Diagnostics produced for:
|
||||||
|
* - Patterns that don't match discovered skills (warning)
|
||||||
|
* - Requested names not matching any discovered skill (warning)
|
||||||
|
*/
|
||||||
|
export function resolveSessionSkills(context) {
|
||||||
|
const { projectRootDir, requestedSkillNames } = context;
|
||||||
|
// Read project settings
|
||||||
|
const settings = readProjectSettings(projectRootDir);
|
||||||
|
// Collect all skill patterns from settings
|
||||||
|
const skillPatterns = [];
|
||||||
|
// Top-level skills patterns
|
||||||
|
if (settings.skills) {
|
||||||
|
for (const pattern of settings.skills) {
|
||||||
|
if (typeof pattern === "string") {
|
||||||
|
skillPatterns.push(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Package-scoped skill patterns
|
||||||
|
if (settings.packages) {
|
||||||
|
for (const pkg of settings.packages) {
|
||||||
|
if (typeof pkg === "object" && pkg !== null && "skills" in pkg && Array.isArray(pkg.skills)) {
|
||||||
|
for (const pattern of pkg.skills) {
|
||||||
|
if (typeof pattern === "string") {
|
||||||
|
skillPatterns.push(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasPatterns = skillPatterns.length > 0;
|
||||||
|
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
|
||||||
|
// If no patterns and no requested names, no filtering needed
|
||||||
|
if (!hasPatterns && !hasRequestedNames) {
|
||||||
|
return {
|
||||||
|
allowedSkillPaths: new Set(),
|
||||||
|
excludedSkillPaths: new Set(),
|
||||||
|
diagnostics: [],
|
||||||
|
filterActive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Build allowed and excluded sets from patterns
|
||||||
|
// Last entry wins for duplicate paths: we track the "final decision" per path
|
||||||
|
const finalDecisions = new Map(); // true = allowed, false = excluded
|
||||||
|
for (const pattern of skillPatterns) {
|
||||||
|
const path = normalizePattern(pattern);
|
||||||
|
const isExclusion = isExclusionPattern(pattern);
|
||||||
|
finalDecisions.set(path, !isExclusion);
|
||||||
|
}
|
||||||
|
// Build allowed and excluded sets from final decisions
|
||||||
|
const allowedSet = new Set();
|
||||||
|
const excludedSet = new Set();
|
||||||
|
for (const [path, allowed] of finalDecisions) {
|
||||||
|
if (allowed) {
|
||||||
|
allowedSet.add(path);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
excludedSet.add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Determine if filtering is active
|
||||||
|
// filterActive is true when:
|
||||||
|
// - Patterns exist (some skills are explicitly configured)
|
||||||
|
// - OR only requested names are provided (filter to those names)
|
||||||
|
const filterActive = hasPatterns || hasRequestedNames;
|
||||||
|
// Produce diagnostics for patterns (we can't check against actual discovered skills here,
|
||||||
|
// so we note which patterns are configured)
|
||||||
|
const diagnostics = [];
|
||||||
|
if (hasPatterns) {
|
||||||
|
for (const pattern of skillPatterns) {
|
||||||
|
if (!isExclusionPattern(pattern)) {
|
||||||
|
// Note: We don't have access to discovered skills here to check if pattern matches
|
||||||
|
// The actual validation happens in createSkillsOverrideFromSelection when base.skills is available
|
||||||
|
const path = normalizePattern(pattern);
|
||||||
|
diagnostics.push({
|
||||||
|
type: "info",
|
||||||
|
message: `Configured skill pattern: ${pattern}`,
|
||||||
|
skillPath: path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasRequestedNames) {
|
||||||
|
for (const name of requestedSkillNames) {
|
||||||
|
diagnostics.push({
|
||||||
|
type: "info",
|
||||||
|
message: `Requested skill: ${name}`,
|
||||||
|
skillName: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowedSkillPaths: allowedSet,
|
||||||
|
excludedSkillPaths: excludedSet,
|
||||||
|
diagnostics,
|
||||||
|
filterActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
|
||||||
|
*
|
||||||
|
* @param selection - The skill selection result from resolveSessionSkills
|
||||||
|
* @param options - Additional options for the override
|
||||||
|
* @returns A skillsOverride callback for DefaultResourceLoader
|
||||||
|
*/
|
||||||
|
export function createSkillsOverrideFromSelection(selection, options = {}) {
|
||||||
|
const { allowedSkillPaths, excludedSkillPaths, filterActive } = selection;
|
||||||
|
const { requestedSkillNames, sessionPurpose } = options;
|
||||||
|
return (base) => {
|
||||||
|
// If filtering is not active, return base unchanged
|
||||||
|
if (!filterActive) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
// Determine the effective filter criteria
|
||||||
|
// When requestedSkillNames is provided without patterns, filter by name
|
||||||
|
// When patterns are provided, filter by file path
|
||||||
|
const hasPatterns = allowedSkillPaths.size > 0;
|
||||||
|
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
|
||||||
|
// Filter skills
|
||||||
|
// Skills must match the inclusion criteria AND not be in the exclusion list
|
||||||
|
const hasExcluded = excludedSkillPaths.size > 0;
|
||||||
|
let filteredSkills;
|
||||||
|
if (hasRequestedNames) {
|
||||||
|
// Filter by requested names (case-insensitive match)
|
||||||
|
const requestedNamesLower = new Set(requestedSkillNames.map((n) => n.toLowerCase()));
|
||||||
|
filteredSkills = base.skills.filter((skill) => requestedNamesLower.has(skill.name.toLowerCase()) && !excludedSkillPaths.has(skill.filePath));
|
||||||
|
}
|
||||||
|
else if (hasPatterns) {
|
||||||
|
// Filter by file path (in allowed set AND not in excluded set)
|
||||||
|
filteredSkills = base.skills.filter((skill) => allowedSkillPaths.has(skill.filePath) && !excludedSkillPaths.has(skill.filePath));
|
||||||
|
}
|
||||||
|
else if (hasExcluded) {
|
||||||
|
// Only exclusions set - filter out excluded skills
|
||||||
|
filteredSkills = base.skills.filter((skill) => !excludedSkillPaths.has(skill.filePath));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// No filter criteria - this shouldn't happen if filterActive is true
|
||||||
|
filteredSkills = base.skills;
|
||||||
|
}
|
||||||
|
// Build diagnostics for missing and disabled skills
|
||||||
|
const newDiagnostics = [];
|
||||||
|
// Check for excluded paths that DO match a discovered skill (disabled)
|
||||||
|
// These are skills that exist but were explicitly excluded by project patterns
|
||||||
|
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
|
||||||
|
const discoveredPaths = new Set(base.skills.map((s) => s.filePath));
|
||||||
|
for (const excludedPath of excludedSkillPaths) {
|
||||||
|
if (discoveredPaths.has(excludedPath)) {
|
||||||
|
// Skill exists but was disabled by project patterns
|
||||||
|
// Use "warning" type since ResourceDiagnostic only supports warning|error|collision
|
||||||
|
newDiagnostics.push({
|
||||||
|
type: "warning",
|
||||||
|
message: `Skill at '${excludedPath}' exists but is disabled by project execution settings${purpose}`,
|
||||||
|
path: excludedPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// If the path doesn't match any discovered skill, it's not a disabled skill - it's just not relevant
|
||||||
|
}
|
||||||
|
// Check for configured patterns (allowed paths) that don't match any discovered skill
|
||||||
|
// Note: At this point, we have access to base.skills for validation
|
||||||
|
for (const allowedPath of allowedSkillPaths) {
|
||||||
|
if (!discoveredPaths.has(allowedPath)) {
|
||||||
|
// Allowed path doesn't match any discovered skill - this is a missing/invalid pattern
|
||||||
|
newDiagnostics.push({
|
||||||
|
type: "warning",
|
||||||
|
message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`,
|
||||||
|
path: allowedPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for requested names that don't match any discovered skill
|
||||||
|
if (requestedSkillNames) {
|
||||||
|
const discoveredNamesLower = new Set(base.skills.map((s) => s.name.toLowerCase()));
|
||||||
|
for (const requestedName of requestedSkillNames) {
|
||||||
|
if (!discoveredNamesLower.has(requestedName.toLowerCase())) {
|
||||||
|
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
|
||||||
|
newDiagnostics.push({
|
||||||
|
type: "warning",
|
||||||
|
message: `Requested skill '${requestedName}' not found in discovered skills${purpose}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Log diagnostics if any
|
||||||
|
if (newDiagnostics.length > 0) {
|
||||||
|
const _purpose = sessionPurpose ? `[${sessionPurpose}]` : "skills";
|
||||||
|
for (const diag of newDiagnostics) {
|
||||||
|
piLog.warn(`[skills] ${diag.type}: ${diag.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skills: filteredSkills,
|
||||||
|
diagnostics: [...base.diagnostics, ...newDiagnostics],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=skill-resolver.js.map
|
||||||
1
packages/engine/src/skill-resolver.js.map
Normal file
1
packages/engine/src/skill-resolver.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -52,13 +52,9 @@ describe("paperclip-runtime plugin", () => {
|
|||||||
|
|
||||||
describe("runtime factory invocation", () => {
|
describe("runtime factory invocation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Mock @fusion/engine for createFnAgent
|
vi.mock("../../../../packages/engine/src/pi.js", () => ({
|
||||||
vi.mock("@fusion/engine", () => ({
|
|
||||||
createFnAgent: vi.fn().mockResolvedValue({ session: {} }),
|
createFnAgent: vi.fn().mockResolvedValue({ session: {} }),
|
||||||
promptWithFallback: vi.fn(),
|
promptWithFallback: vi.fn(),
|
||||||
}));
|
|
||||||
// Mock describeModel
|
|
||||||
vi.mock("../../engine/src/pi.js", () => ({
|
|
||||||
describeModel: vi.fn().mockReturnValue("mock/model"),
|
describeModel: vi.fn().mockReturnValue("mock/model"),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -73,7 +69,7 @@ describe("paperclip-runtime plugin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return an adapter with correct id and name", async () => {
|
it("should return an adapter with correct id and name", async () => {
|
||||||
const runtime = await plugin.runtime!.factory({} as any);
|
const runtime = (await plugin.runtime!.factory({} as any)) as PaperclipRuntimeAdapter;
|
||||||
expect(runtime.id).toBe("paperclip");
|
expect(runtime.id).toBe("paperclip");
|
||||||
expect(runtime.name).toBe("Paperclip Runtime");
|
expect(runtime.name).toBe("Paperclip Runtime");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,18 +9,12 @@ import { PaperclipRuntimeAdapter } from "../runtime-adapter.js";
|
|||||||
|
|
||||||
// ── Mock Modules ────────────────────────────────────────────────────────────────
|
// ── Mock Modules ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Mock @fusion/engine for createFnAgent and promptWithFallback
|
|
||||||
const mockCreateFnAgent = vi.fn();
|
const mockCreateFnAgent = vi.fn();
|
||||||
const mockPromptWithFallback = vi.fn();
|
const mockPromptWithFallback = vi.fn();
|
||||||
|
|
||||||
vi.mock("@fusion/engine", () => ({
|
vi.mock("../../../../packages/engine/src/pi.js", () => ({
|
||||||
createFnAgent: mockCreateFnAgent,
|
createFnAgent: mockCreateFnAgent,
|
||||||
promptWithFallback: mockPromptWithFallback,
|
promptWithFallback: mockPromptWithFallback,
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the relative import of describeModel from pi.ts
|
|
||||||
// This uses require() in the adapter, so we mock the entire module
|
|
||||||
vi.mock("../../engine/src/pi.js", () => ({
|
|
||||||
describeModel: vi.fn().mockReturnValue("mock/anthropic-claude"),
|
describeModel: vi.fn().mockReturnValue("mock/anthropic-claude"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -169,7 +163,7 @@ describe("PaperclipRuntimeAdapter", () => {
|
|||||||
describe("describeModel", () => {
|
describe("describeModel", () => {
|
||||||
it("should return model description from pi describeModel", () => {
|
it("should return model description from pi describeModel", () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { describeModel } = require("../../engine/src/pi.js");
|
const { describeModel } = require("../../../../packages/engine/src/pi.js");
|
||||||
|
|
||||||
const mockSession = { model: { provider: "anthropic", id: "claude-sonnet-4-5" } };
|
const mockSession = { model: { provider: "anthropic", id: "claude-sonnet-4-5" } };
|
||||||
const result = adapter.describeModel(mockSession as any);
|
const result = adapter.describeModel(mockSession as any);
|
||||||
|
|||||||
@@ -30,8 +30,12 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./types.js";
|
import type {
|
||||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
AgentRuntime,
|
||||||
|
AgentRuntimeOptions,
|
||||||
|
AgentSession,
|
||||||
|
AgentSessionResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
// ── describeModel (from pi.ts, not re-exported from @fusion/engine) ─────────────
|
// ── describeModel (from pi.ts, not re-exported from @fusion/engine) ─────────────
|
||||||
//
|
//
|
||||||
@@ -40,8 +44,16 @@ import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
|||||||
// This is acceptable within the monorepo workspace. External plugins would need a
|
// This is acceptable within the monorepo workspace. External plugins would need a
|
||||||
// different approach (e.g., the engine could export it publicly in the future).
|
// different approach (e.g., the engine could export it publicly in the future).
|
||||||
//
|
//
|
||||||
|
type PiModule = {
|
||||||
|
createFnAgent: (options: unknown) => Promise<AgentSessionResult>;
|
||||||
|
promptWithFallback: (session: unknown, prompt: string, options?: unknown) => Promise<void>;
|
||||||
|
describeModel: (session: unknown) => string;
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { describeModel: getModelDescription } = require("../../engine/src/pi.js");
|
const loadPiModule = (): PiModule => require("../../../packages/engine/src/pi.js") as PiModule;
|
||||||
|
|
||||||
|
const { describeModel: getModelDescription } = loadPiModule();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paperclip runtime adapter implementing the Fusion AgentRuntime interface.
|
* Paperclip runtime adapter implementing the Fusion AgentRuntime interface.
|
||||||
@@ -69,7 +81,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
|||||||
* @returns Promise resolving to the session result with session and optional sessionFile
|
* @returns Promise resolving to the session result with session and optional sessionFile
|
||||||
*/
|
*/
|
||||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||||
const { createFnAgent } = await import("@fusion/engine");
|
const { createFnAgent } = loadPiModule();
|
||||||
return createFnAgent({
|
return createFnAgent({
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
systemPrompt: options.systemPrompt,
|
systemPrompt: options.systemPrompt,
|
||||||
@@ -103,7 +115,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
|||||||
* @param options - Optional prompt options (e.g., images for vision)
|
* @param options - Optional prompt options (e.g., images for vision)
|
||||||
*/
|
*/
|
||||||
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||||
const { promptWithFallback: pwf } = await import("@fusion/engine");
|
const { promptWithFallback: pwf } = loadPiModule();
|
||||||
return pwf(session, prompt, options);
|
return pwf(session, prompt, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,58 @@
|
|||||||
/**
|
/**
|
||||||
* Paperclip Runtime Plugin - Type Definitions
|
* Paperclip Runtime Plugin - Type Definitions
|
||||||
*
|
*
|
||||||
* Re-exports runtime contract types from @fusion/engine and plugin types from @fusion/plugin-sdk.
|
* The plugin runtime contract is defined locally in this example plugin to avoid
|
||||||
* These types define the interface that the Paperclip runtime adapter must implement.
|
* a hard compile-time dependency on internal engine package exports.
|
||||||
*
|
|
||||||
* ## Type Sources
|
|
||||||
*
|
|
||||||
* - `AgentRuntime`, `AgentRuntimeOptions`, `AgentSessionResult`: from @fusion/engine (FN-2256 contract)
|
|
||||||
* - `PluginRuntimeRegistration`, `PluginRuntimeManifestMetadata`, `FusionPlugin`: from @fusion/plugin-sdk
|
|
||||||
*
|
|
||||||
* ## Internal Types
|
|
||||||
*
|
|
||||||
* `AgentSession` and `ToolDefinition` are used internally in the adapter implementation
|
|
||||||
* but are NOT re-exported here since they come from @mariozechner/pi-coding-agent,
|
|
||||||
* which is not a direct dependency of this plugin. They are accessible via
|
|
||||||
* `AgentSessionResult.session` and `AgentRuntimeOptions.customTools` respectively.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Agent Runtime Contract (from @fusion/engine) ──────────────────────────────
|
// ── Local Agent Runtime Contract ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Minimal session shape used by the runtime adapter. */
|
||||||
|
export interface AgentSession {
|
||||||
|
dispose?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for creating an agent session. Mirrors createFnAgent inputs used by the adapter. */
|
||||||
|
export interface AgentRuntimeOptions {
|
||||||
|
cwd: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
tools?: unknown;
|
||||||
|
customTools?: unknown;
|
||||||
|
onText?: (text: string) => void;
|
||||||
|
onThinking?: (text: string) => void;
|
||||||
|
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||||
|
onToolEnd?: (toolName: string, result?: unknown) => void;
|
||||||
|
defaultProvider?: string;
|
||||||
|
defaultModelId?: string;
|
||||||
|
fallbackProvider?: string;
|
||||||
|
fallbackModelId?: string;
|
||||||
|
defaultThinkingLevel?: string;
|
||||||
|
sessionManager?: unknown;
|
||||||
|
skillSelection?: unknown;
|
||||||
|
skills?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of creating a session. */
|
||||||
|
export interface AgentSessionResult {
|
||||||
|
session: AgentSession;
|
||||||
|
sessionFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent runtime adapter interface. */
|
||||||
|
export interface AgentRuntime {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
|
||||||
|
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
|
||||||
|
describeModel(session: AgentSession): string;
|
||||||
|
dispose?(session: AgentSession): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plugin Registration Types (from @fusion/plugin-sdk) ─────────────────────
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
/**
|
|
||||||
* Agent runtime adapter interface.
|
|
||||||
*
|
|
||||||
* All session runtimes (default pi runtime, plugin-provided runtimes) must
|
|
||||||
* implement this interface to ensure consistent behavior across engine subsystems.
|
|
||||||
*/
|
|
||||||
AgentRuntime,
|
|
||||||
/**
|
|
||||||
* Options for creating an agent session.
|
|
||||||
* Mirrors the options accepted by createFnAgent.
|
|
||||||
*/
|
|
||||||
AgentRuntimeOptions,
|
|
||||||
/**
|
|
||||||
* Result of creating an agent session.
|
|
||||||
*/
|
|
||||||
AgentSessionResult,
|
|
||||||
} from "@fusion/engine";
|
|
||||||
|
|
||||||
// ── Plugin Registration Types (from @fusion/plugin-sdk) ───────────────────────
|
|
||||||
|
|
||||||
export type {
|
|
||||||
/**
|
|
||||||
* Plugin runtime registration metadata.
|
|
||||||
* Contains identity and versioning information for a runtime.
|
|
||||||
*/
|
|
||||||
PluginRuntimeManifestMetadata,
|
PluginRuntimeManifestMetadata,
|
||||||
/**
|
|
||||||
* Plugin runtime factory function.
|
|
||||||
* Creates a runtime instance when the plugin is loaded.
|
|
||||||
*/
|
|
||||||
PluginRuntimeFactory,
|
PluginRuntimeFactory,
|
||||||
/**
|
|
||||||
* Plugin runtime registration with metadata and factory.
|
|
||||||
* The primary registration format used by Fusion's plugin system.
|
|
||||||
*/
|
|
||||||
PluginRuntimeRegistration,
|
PluginRuntimeRegistration,
|
||||||
/**
|
|
||||||
* Fusion plugin definition.
|
|
||||||
* The main export type for all Fusion plugins.
|
|
||||||
*/
|
|
||||||
FusionPlugin,
|
FusionPlugin,
|
||||||
} from "@fusion/plugin-sdk";
|
} from "@fusion/plugin-sdk";
|
||||||
|
|
||||||
// ── Note on describeModel ──────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// describeModel is NOT exported from @fusion/engine's public API.
|
|
||||||
// It is defined in packages/engine/src/pi.ts but only used internally.
|
|
||||||
// Plugin adapters should import describeModel directly from the relative path:
|
|
||||||
// import { describeModel } from "../../engine/src/pi.js";
|
|
||||||
//
|
|
||||||
// This relative import is only valid within the monorepo workspace.
|
|
||||||
// External plugins would need a different approach.
|
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -471,7 +471,7 @@ importers:
|
|||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
|
||||||
plugins/fusion-plugin-hermes-runtime:
|
plugins/fusion-plugin-paperclip-runtime:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fusion/plugin-sdk':
|
'@fusion/plugin-sdk':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
packages:
|
packages:
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
- "plugins/examples/*"
|
- "plugins/examples/*"
|
||||||
- "plugins/fusion-plugin-hermes-runtime"
|
- "plugins/fusion-plugin-paperclip-runtime"
|
||||||
|
|||||||
Reference in New Issue
Block a user