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:
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":"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"}
|
||||
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()", () => {
|
||||
it("should return the plugin loader", () => {
|
||||
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
Reference in New Issue
Block a user