Files
makelore/electron/api/coding-composition.ts

340 lines
13 KiB
TypeScript

import path from 'node:path';
import type { AgentBrowserModule } from '../agent-browser';
import { CodingAttachmentStore } from '../coding-projects/attachment-store';
import { createCodingConversationStore } from '../coding-projects/conversation-store';
import { CodingProjectService } from '../coding-projects/project-service';
import {
createCodingProjectStore,
type CodingProjectStore,
type CodingProjectStorage,
} from '../coding-projects/project-store';
import { CodingConversationService } from '../coding-runtime/conversation-service';
import { PiManagedExtensionHost } from '../coding-runtime/pi/extension-host';
import { PiManagedInputRevisionCoordinator } from '../coding-runtime/pi/managed-input-revision';
import { PiProductTools } from '../coding-runtime/pi/product-tools';
import {
buildPiProviderCatalog,
resolvePiProviderCredentialFromSecretStore,
selectPiProviderModel,
} from '../coding-runtime/pi/provider-config';
import {
createPiManagedWorkerOpener,
PiConversationRuntime,
} from '../coding-runtime/pi/runtime';
import { PiSessionRegistry } from '../coding-runtime/pi/session-registry';
import { createPiManagedSubagentChildOpener } from '../coding-runtime/pi/subagent-child';
import { PiSubagentScheduler } from '../coding-runtime/pi/subagent';
import { PiProcessBudget, PiWorkerPool } from '../coding-runtime/pi/worker-pool';
import { getProviderService } from '../services/providers/provider-service';
import {
isCodingProviderAuthenticationError,
refreshCodingProviderCredential,
} from './coding-provider-auth';
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
import { createDataServiceOperations } from '../services/data-service-client';
import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session';
import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader';
import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1';
import { createProjectPluginService } from '../coding-plugins/project-service';
import {
createCodingCapabilityRegistry,
type CodingCapabilityRegistry,
} from '../coding-plugins/registry';
import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service';
import { PluginPolicyClient } from '../services/plugin-policy-client';
import {
loadBundledCodingPluginDefinitionsSync,
resolveBundledCodingPluginRootPaths,
} from '../coding-plugins/manifest';
import {
createCodingProjectPluginService,
type CodingProjectPluginService,
} from './coding-product-services';
export interface CodingCompositionPaths {
executablePath: string;
cliPath: string;
userDataDir: string;
bundledSkillsDir: string;
}
export interface CreateCodingCompositionOptions {
storage: CodingProjectStorage;
projectStore?: CodingProjectStore;
browser: AgentBrowserModule;
paths: CodingCompositionPaths;
getLocalProxyCredential?(): string | undefined;
acquireBackgroundLease?(lease: { id: string; kind: 'coding-run' }): () => void;
}
export function resolveCodingPiRuntimePaths(input: {
isPackaged: boolean;
resourcesPath: string;
appPath: string;
executablePath: string;
}): Pick<CodingCompositionPaths, 'executablePath' | 'cliPath'> {
return {
executablePath: input.executablePath,
cliPath: input.isPackaged
? path.join(input.resourcesPath, 'pi-runtime', 'dist', 'cli.js')
: path.join(
input.appPath,
'node_modules',
'@earendil-works',
'pi-coding-agent',
'dist',
'cli.js',
),
};
}
export function createCodingComposition(
options: CreateCodingCompositionOptions,
): CodingProductComposition {
const getLocalProxyCredential = options.getLocalProxyCredential;
const bundledPluginsDir = path.join(
path.dirname(path.resolve(options.paths.bundledSkillsDir)),
'coding-plugins',
);
const pluginDefinitions = loadBundledCodingPluginDefinitionsSync(bundledPluginsDir);
const pluginRoots = resolveBundledCodingPluginRootPaths(bundledPluginsDir);
const pluginSkillSources = pluginDefinitions.flatMap((definition, index) => (
definition.skills.map((skill) => ({
id: skill.id,
pluginId: definition.id,
directory: path.join(pluginRoots[index] as string, path.dirname(skill.entryPath)),
entryPath: path.basename(skill.entryPath),
}))
));
const projectStore = options.projectStore ?? createCodingProjectStore(options.storage);
const attachments = new CodingAttachmentStore(
path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'),
);
const productTools = new PiProductTools({
browser: options.browser,
attachments,
bundledSkillsDir: options.paths.bundledSkillsDir,
pluginSkillSources,
});
const extensionHost = new PiManagedExtensionHost();
extensionHost.configureProductTools(productTools);
const conversationStores = new Map<string, ReturnType<typeof createCodingConversationStore>>();
const conversationStoreForProject = (projectPath: string) => {
const existing = conversationStores.get(projectPath);
if (existing) return existing;
const created = createCodingConversationStore(projectPath);
conversationStores.set(projectPath, created);
return created;
};
const registry = new PiSessionRegistry({
projectStore,
createConversationStore: conversationStoreForProject,
});
const revisions = new PiManagedInputRevisionCoordinator();
const processBudget = new PiProcessBudget();
const loadProviderInput = async () => ({
accounts: await getProviderService().listAccounts(),
modelSummaries: [],
});
let runtime: PiConversationRuntime | undefined;
let plugins: CodingProjectPluginService | undefined;
let previewDataSession: PreviewDataSessionManager | undefined;
const projects = new CodingProjectService(projectStore, {
migration: {
resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel(
legacyModel,
await getProviderService().listAccounts(),
),
},
createConversationStore: conversationStoreForProject,
onResourcesChanged: async (project) => {
runtime?.markResourcesStale();
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onProjectIdentityChanging: async (project) => {
previewDataSession?.invalidate('project_identity_changed');
await plugins?.deactivate(project.path);
await options.browser.close(project.path);
},
onProjectDeactivated: async (project, reason) => {
previewDataSession?.invalidate('project_deactivated');
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
await Promise.allSettled([
plugins?.deactivate(project.path),
options.browser.close(project.path),
...conversations.map(({ id }) => runtime?.dispose(id, reason)),
]);
},
});
const dataService = createDataServiceOperations({ projects });
const dataServiceAdapter = createDataServicePluginAdapter(dataService);
const policyClient = new PluginPolicyClient();
const projectPlugins = createProjectPluginService({
knownPluginIds: pluginDefinitions.map(({ id }) => id),
onManagedInputsChanged: async ({ projectPath }) => {
runtime?.markResourcesStale();
const conversations = await conversationStoreForProject(projectPath).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onAdapterDeactivated: async ({ projectPath, pluginId }) => {
previewDataSession?.invalidate('project_deactivated');
await plugins?.deactivate(projectPath, pluginId);
},
});
const capabilityRegistry = createCodingCapabilityRegistry({
policyClient,
projectPlugins,
adapters: [dataServiceAdapter],
definitions: pluginDefinitions,
getDurableProjectId: async (projectPath, localProjectId) => {
const active = await projects.requireActiveRealProjectWithIdentity(projectPath);
if (active.project.id !== localProjectId) {
throw new Error('The trusted coding project does not match the active project');
}
return active.projectId;
},
});
const refreshingCapabilityRegistry: CodingCapabilityRegistry = {
async resolveWorkerResources(input) {
await policyClient.refresh();
return await capabilityRegistry.resolveWorkerResources(input);
},
async invoke(input) {
return await capabilityRegistry.invoke(input);
},
};
productTools.configureCapabilityRegistry(refreshingCapabilityRegistry);
plugins = createCodingProjectPluginService({
projects,
projectPlugins,
policyClient,
adapters: [dataServiceAdapter],
definitions: pluginDefinitions,
});
const workerPool = new PiWorkerPool({
processBudget,
revisionCoordinator: revisions,
openWorker: createPiManagedWorkerOpener({
registry,
executablePath: options.paths.executablePath,
cliPath: options.paths.cliPath,
userDataDir: options.paths.userDataDir,
bundledSkillsDir: options.paths.bundledSkillsDir,
loadProviderInput,
resolveCredential: resolvePiProviderCredentialFromSecretStore,
...(getLocalProxyCredential
? { getLocalProxyCredential: async () => getLocalProxyCredential() }
: {}),
extensionHost,
capabilityRegistry: refreshingCapabilityRegistry,
}),
});
const childOpener = createPiManagedSubagentChildOpener({
projectStore,
executablePath: options.paths.executablePath,
cliPath: options.paths.cliPath,
userDataDir: options.paths.userDataDir,
bundledSkillsDir: options.paths.bundledSkillsDir,
extensionHost,
loadProviderInput,
resolveCredential: resolvePiProviderCredentialFromSecretStore,
getRevision: () => revisions.current,
...(getLocalProxyCredential
? { getLocalProxyCredential: async () => getLocalProxyCredential() }
: {}),
capabilityRegistry: refreshingCapabilityRegistry,
});
const subagents = new PiSubagentScheduler({
openChild: childOpener,
processBudget,
reclaimProcessCapacity: (signal) => workerPool.reclaimIdleWorker(signal),
});
runtime = new PiConversationRuntime({
pool: workerPool,
registry,
extensionHost,
subagentScheduler: subagents,
resolveModel: async (model) => selectPiProviderModel(
buildPiProviderCatalog(await loadProviderInput()),
model,
),
isAuthenticationError: isCodingProviderAuthenticationError,
refreshCredential: refreshCodingProviderCredential,
resolveImages: async (refs) => await Promise.all(refs.map(async ({ attachmentId }) => {
const record = await attachments.read(attachmentId);
return {
type: 'image',
data: record.data.toString('base64'),
mimeType: record.mime,
};
})),
...(options.acquireBackgroundLease
? { acquireBackgroundLease: options.acquireBackgroundLease }
: {}),
});
const conversations = new CodingConversationService(projects, runtime, {
archiveSession: async ({ projectId, sessionKey }) => {
await archivePiConversationSession({
userDataDir: options.paths.userDataDir,
projectId,
sessionKey,
});
},
});
const host = createCodingProductHost({
projects,
productTools,
getEnabledPluginIds: (projectPath) => projectPlugins.getEnabledPluginIds(projectPath),
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
});
previewDataSession = createPreviewDataSessionManager({ projects });
if (typeof options.browser.configurePreviewDataSession === 'function') {
options.browser.configurePreviewDataSession(previewDataSession);
}
const unsubscribeBrowserLifecycle = typeof options.browser.subscribeLifecycle === 'function'
? options.browser.subscribeLifecycle((event) => {
previewDataSession?.handleAgentBrowserLifecycle(event);
})
: () => undefined;
return {
attachments,
dataService,
plugins,
previewDataSession,
productTools,
projects,
conversations,
runtime,
host,
async sleep(reason) {
if (reason === 'background_sleep' && runtime.hasActiveWork()) return;
if (reason === 'auth_cleanup') {
const active = await projects.getActiveProject();
if (active) await plugins?.deactivate(active.path);
}
const conversationIds = runtime.getDiagnostics().workers.map((worker) => worker.conversationId);
await Promise.allSettled(conversationIds.map((conversationId) => (
runtime.dispose(conversationId, reason)
)));
},
async shutdown() {
previewDataSession?.dispose();
if (typeof options.browser.configurePreviewDataSession === 'function') {
options.browser.configurePreviewDataSession(undefined);
}
unsubscribeBrowserLifecycle();
const active = await projects.getActiveProject();
if (active) await plugins?.deactivate(active.path);
await subagents.close();
await runtime.shutdown();
},
};
}