feat(coding): add PI-105 product file host API

This commit is contained in:
2026-08-23 18:17:30 +08:00
parent 7680bfb0cf
commit 9520872d2f
17 changed files with 1419 additions and 38 deletions

View File

@@ -0,0 +1,171 @@
import type {
CodingProjectFileContent,
CodingProjectFileEntry,
CodingTextSearchResult,
ConversationChangesSnapshot,
ProductCodingCommand,
ProductCodingSkill,
ProductPiCommandInput,
} from '../../shared/coding-product-tools';
import { createCodingConversationStore } from '../coding-projects/conversation-store';
import type { CodingAttachmentStore } from '../coding-projects/attachment-store';
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
import { CodingProjectFileService } from '../coding-projects/project-files';
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
export interface ActiveCodingProject {
id: string;
path: string;
}
export interface CodingProductHost {
fileStatus(): Promise<CodingProjectFileEntry[]>;
findFiles(query: string, limit?: number): Promise<CodingProjectFileEntry[]>;
fileContent(path: string): Promise<CodingProjectFileContent>;
searchText(pattern: string): Promise<CodingTextSearchResult[]>;
listSkills(agentId?: string): Promise<ProductCodingSkill[]>;
listCommands(conversationId: string): Promise<ProductCodingCommand[]>;
getChanges(conversationId: string): Promise<ConversationChangesSnapshot | null>;
}
export interface CodingProductComposition {
attachments: CodingAttachmentStore;
productTools: PiProductTools;
host: CodingProductHost;
}
export class CodingProductHostError extends Error {
constructor(
readonly status: 404 | 409,
readonly code: string,
message: string,
) {
super(message);
}
}
export interface CodingProductHostOptions {
getActiveProject(): Promise<ActiveCodingProject | null>;
productTools: PiProductTools;
files?: CodingProjectFileService;
listPiCommands?(conversationId: string): Promise<unknown>;
}
function normalizePiCommands(value: unknown): ProductPiCommandInput[] {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
const candidates = Array.isArray(value)
? value
: Array.isArray(record?.commands)
? record.commands
: Array.isArray(record?.data)
? record.data
: [];
return candidates.flatMap((candidate) => {
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
const command = candidate as Record<string, unknown>;
if (typeof command.name !== 'string') return [];
return [{
name: command.name,
...(typeof command.description === 'string' ? { description: command.description } : {}),
}];
});
}
export function createCodingProductHost(options: CodingProductHostOptions): CodingProductHost {
const files = options.files ?? new CodingProjectFileService();
async function activeProject(): Promise<ActiveCodingProject> {
const project = await options.getActiveProject();
if (!project) {
throw new CodingProductHostError(
409,
'CODING_ACTIVE_PROJECT_REQUIRED',
'No active coding project is selected',
);
}
return project;
}
async function selectedSkillIds(
projectPath: string,
agentId?: string,
): Promise<readonly string[]> {
if (!agentId) return [];
const config = await readCodingProjectConfigV2(projectPath);
if (config.status !== 'valid') {
throw new CodingProductHostError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
const agent = config.config.agents.find((candidate) => (
candidate.id === agentId && candidate.enabled && !candidate.archivedAt
));
if (!agent) {
throw new CodingProductHostError(
404,
'CODING_AGENT_NOT_FOUND',
'Coding project Agent does not exist',
);
}
return agent.skillIds;
}
async function conversationContext(conversationId: string): Promise<{
project: ActiveCodingProject;
skillIds: readonly string[];
}> {
const project = await activeProject();
const conversation = await createCodingConversationStore(project.path).get(conversationId);
if (!conversation) {
throw new CodingProductHostError(
404,
'CODING_CONVERSATION_NOT_FOUND',
'Coding Conversation does not exist',
);
}
return {
project,
skillIds: await selectedSkillIds(project.path, conversation.agentId),
};
}
return {
async fileStatus() {
const project = await activeProject();
return await files.status(project.path);
},
async findFiles(query, limit) {
const project = await activeProject();
return await files.find(project.path, query, limit);
},
async fileContent(filePath) {
const project = await activeProject();
return await files.content(project.path, filePath);
},
async searchText(pattern) {
const project = await activeProject();
return await files.search(project.path, pattern);
},
async listSkills(agentId) {
const project = await activeProject();
return await options.productTools.listSkills(
await selectedSkillIds(project.path, agentId),
);
},
async listCommands(conversationId) {
const context = await conversationContext(conversationId);
const piCommands = options.listPiCommands
? normalizePiCommands(await options.listPiCommands(conversationId))
: [];
return await options.productTools.listCommands(context.skillIds, piCommands);
},
async getChanges(conversationId) {
await conversationContext(conversationId);
return options.productTools.getChanges(conversationId);
},
};
}

View File

@@ -14,6 +14,7 @@ import type {
import type { StaticArtifactSnapshot } from '../services/static-release-server';
import type { BackgroundLifecycleController } from '../main/background-lifecycle';
import type { ReleaseJobManager } from '../services/release-job';
import type { CodingProductComposition } from './coding-product-services';
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
@@ -77,4 +78,5 @@ export interface HostApiContext {
imageWorkspace?: DesignWorkspaceModule;
lifecycle?: BackgroundLifecycleController;
releaseJobs?: ReleaseJobManager;
codingProducts?: CodingProductComposition;
}

View File

@@ -17,6 +17,7 @@ import { handleUsageRoutes } from './routes/usage';
import { handleFileRoutes } from './routes/files';
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
import { handleAgentBrowserRoutes } from './routes/agent-browser';
import { handleCodingFileRoutes } from './routes/coding-files';
export type HostApiRouteHandler = (
req: IncomingMessage,
@@ -42,6 +43,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
handleWorksRoutes,
handleAgentBrowserRoutes,
handleUserSyncRoutes,
handleCodingFileRoutes,
handleOpencodeRoutes,
handleSettingsRoutes,
handleProviderRoutes,

View File

@@ -0,0 +1,138 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { HostApiContext } from '../context';
import { CodingProductHostError } from '../coding-product-services';
import { sendJson } from '../route-utils';
function unavailable(res: ServerResponse): void {
sendJson(res, 503, {
success: false,
code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE',
error: 'Coding product tools are unavailable',
});
}
function serviceError(res: ServerResponse, error: unknown): void {
if (error instanceof CodingProductHostError) {
sendJson(res, error.status, {
success: false,
code: error.code,
error: error.message,
});
return;
}
const code = error && typeof error === 'object' && 'code' in error
? String(error.code)
: '';
if (code === 'ENOENT') {
sendJson(res, 404, {
success: false,
code: 'CODING_FILE_NOT_FOUND',
error: 'Project file does not exist',
});
return;
}
const message = error instanceof Error ? error.message : '';
const knownInputError = new Set([
'File query is required',
'File query is too long',
'Search pattern is required',
'Search pattern is too long',
'Project file path must be relative',
'Project file path escapes the active project',
'Project file path is not a file',
'Binary project files cannot be previewed',
'Project file is not valid UTF-8 text',
]);
if (knownInputError.has(message)) {
sendJson(res, 400, {
success: false,
code: 'CODING_FILE_REQUEST_INVALID',
error: message,
});
return;
}
sendJson(res, 500, {
success: false,
code: 'CODING_PRODUCT_TOOL_FAILED',
error: 'Coding product request failed',
});
}
export async function handleCodingFileRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
const host = ctx.codingProducts?.host;
const fixedGetRoutes = new Set([
'/api/coding/files/status',
'/api/coding/files/find',
'/api/coding/files/content',
'/api/coding/search',
'/api/coding/skills',
]);
const commandMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/commands$/);
const changesMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/changes$/);
if (!fixedGetRoutes.has(url.pathname) && !commandMatch && !changesMatch) return false;
if (req.method !== 'GET') return false;
if (!host) {
unavailable(res);
return true;
}
try {
if (url.pathname === '/api/coding/files/status') {
sendJson(res, 200, { files: await host.fileStatus() });
return true;
}
if (url.pathname === '/api/coding/files/find') {
const query = url.searchParams.get('query')?.trim() ?? '';
const rawLimit = url.searchParams.get('limit');
const parsedLimit = rawLimit ? Number(rawLimit) : undefined;
sendJson(res, 200, {
files: await host.findFiles(
query,
parsedLimit !== undefined && Number.isSafeInteger(parsedLimit) && parsedLimit > 0
? parsedLimit
: undefined,
),
});
return true;
}
if (url.pathname === '/api/coding/files/content') {
sendJson(res, 200, {
file: await host.fileContent(url.searchParams.get('path')?.trim() ?? ''),
});
return true;
}
if (url.pathname === '/api/coding/search') {
sendJson(res, 200, {
matches: await host.searchText(url.searchParams.get('pattern')?.trim() ?? ''),
});
return true;
}
if (url.pathname === '/api/coding/skills') {
sendJson(res, 200, {
skills: await host.listSkills(url.searchParams.get('agentId')?.trim() || undefined),
});
return true;
}
if (commandMatch) {
sendJson(res, 200, {
commands: await host.listCommands(decodeURIComponent(commandMatch[1])),
});
return true;
}
if (changesMatch) {
sendJson(res, 200, {
changes: await host.getChanges(decodeURIComponent(changesMatch[1])),
});
return true;
}
} catch (error) {
serviceError(res, error);
return true;
}
return false;
}

View File

@@ -1,29 +1,23 @@
import { spawn } from 'node:child_process';
import { open, stat } from 'node:fs/promises';
import path from 'node:path';
import type {
ConversationChangedFile,
ConversationChangesSnapshot,
CodingProjectFileStatus,
} from '../../shared/coding-product-tools';
export type {
ConversationChangedFile,
ConversationChangesSnapshot,
} from '../../shared/coding-product-tools';
const MAX_CHANGED_FILES = 200;
const MAX_DIFF_BYTES = 64 * 1024;
const MAX_UNTRACKED_PREVIEW_BYTES = 8 * 1024;
const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
export type ConversationChangedFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
export interface ConversationChangedFile {
path: string;
status: ConversationChangedFileStatus;
diff?: string;
preview?: string;
truncated?: boolean;
}
export interface ConversationChangesSnapshot {
conversationId: string;
runId: string;
git: boolean;
baselineHead: string | null;
files: ConversationChangedFile[];
}
export type ConversationChangedFileStatus = CodingProjectFileStatus;
export interface GitCommandResult {
code: number;

View File

@@ -0,0 +1,318 @@
import { open, lstat, readdir, realpath } from 'node:fs/promises';
import path from 'node:path';
import type {
CodingProjectFileContent,
CodingProjectFileEntry,
CodingProjectFileStatus,
CodingTextSearchResult,
} from '../../shared/coding-product-tools';
import {
ProcessConversationGitAdapter,
type ConversationGitAdapter,
type GitCommandResult,
} from './conversation-change-tracker';
const MAX_STATUS_RESULTS = 200;
const DEFAULT_FIND_RESULTS = 20;
const MAX_FIND_RESULTS = 200;
const MAX_DISCOVERED_FILES = 20_000;
const MAX_CONTENT_BYTES = 256 * 1024;
const MAX_SEARCH_FILE_BYTES = 1024 * 1024;
const MAX_SEARCH_RESULTS = 200;
const MAX_SEARCH_LINE_CHARS = 2_048;
const SKIPPED_DIRECTORIES = new Set([
'.git',
'.next',
'.nuxt',
'.svelte-kit',
'build',
'coverage',
'dist',
'node_modules',
'out',
]);
function normalizeRelativePath(value: string): string {
const raw = value.trim();
const slashPath = raw.replaceAll('\\', '/');
if (!slashPath || slashPath.includes('\0') || path.isAbsolute(raw)
|| path.win32.isAbsolute(raw) || path.posix.isAbsolute(slashPath)) {
throw new Error('Project file path must be relative');
}
const normalized = path.posix.normalize(slashPath).replace(/^\.\//, '');
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
throw new Error('Project file path escapes the active project');
}
return normalized;
}
function projectTarget(projectPath: string, relativePath: string): string {
const root = path.resolve(projectPath);
const target = path.resolve(root, ...relativePath.split('/'));
const relative = path.relative(root, target);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Project file path escapes the active project');
}
return target;
}
async function containedExistingTarget(projectPath: string, relativePath: string): Promise<string> {
const root = await realpath(path.resolve(projectPath));
const target = await realpath(projectTarget(root, relativePath));
const relative = path.relative(root, target);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Project file path escapes the active project');
}
return target;
}
function statusFromSignature(signature: string, renamed: boolean): CodingProjectFileStatus {
if (signature === '??') return 'untracked';
if (renamed || signature.includes('R')) return 'renamed';
if (signature.includes('D')) return 'deleted';
if (signature.includes('A')) return 'added';
return 'modified';
}
function parseStatus(value: string): CodingProjectFileEntry[] {
const records = value.split('\0');
const files: CodingProjectFileEntry[] = [];
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
if (!record || record.startsWith('! ')) continue;
let filePath: string | undefined;
let status: CodingProjectFileStatus;
if (record.startsWith('? ')) {
filePath = record.slice(2);
status = 'untracked';
} else {
const renamed = record.startsWith('2 ');
const match = renamed
? record.match(/^2 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s)
: record.match(/^1 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s);
if (!match) continue;
filePath = match[2];
status = statusFromSignature(match[1], renamed);
if (renamed) index += 1;
}
const normalized = normalizeRelativePath(filePath);
files.push({
path: normalized,
name: path.posix.basename(normalized),
type: 'file',
status,
});
if (files.length >= MAX_STATUS_RESULTS) break;
}
return files;
}
async function readBoundedFile(
projectPath: string,
relativePath: string,
maxBytes: number,
): Promise<{ data: Buffer; truncated: boolean }> {
const target = await containedExistingTarget(projectPath, relativePath);
const metadata = await lstat(target);
if (!metadata.isFile()) throw new Error('Project file path is not a file');
const handle = await open(target, 'r');
const data = Buffer.alloc(Math.min(metadata.size, maxBytes + 1));
let bytesRead: number;
try {
({ bytesRead } = await handle.read(data, 0, data.byteLength, 0));
} finally {
await handle.close();
}
return {
data: data.subarray(0, Math.min(bytesRead, maxBytes)),
truncated: metadata.size > maxBytes || bytesRead > maxBytes,
};
}
function decodeText(data: Buffer, allowIncompleteSuffix = false): string {
if (data.includes(0)) throw new Error('Binary project files cannot be previewed');
try {
return new TextDecoder('utf-8', { fatal: true }).decode(
data,
allowIncompleteSuffix ? { stream: true } : undefined,
);
} catch {
throw new Error('Project file is not valid UTF-8 text');
}
}
async function fallbackFileList(projectPath: string): Promise<string[]> {
const root = path.resolve(projectPath);
const files: string[] = [];
const pending = [''];
while (pending.length > 0 && files.length < MAX_DISCOVERED_FILES) {
const relativeDirectory = pending.shift() as string;
const target = relativeDirectory
? path.join(root, ...relativeDirectory.split('/'))
: root;
let entries;
try {
entries = await readdir(target, { withFileTypes: true });
} catch {
continue;
}
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const relativePath = relativeDirectory
? `${relativeDirectory}/${entry.name}`
: entry.name;
if (entry.isDirectory()) {
if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(relativePath);
} else if (entry.isFile()) {
files.push(relativePath);
if (files.length >= MAX_DISCOVERED_FILES) break;
}
}
}
return files;
}
export class CodingProjectFileService {
constructor(
private readonly git: ConversationGitAdapter = new ProcessConversationGitAdapter(),
) {}
async status(projectPath: string): Promise<CodingProjectFileEntry[]> {
const result = await this.gitResult(projectPath, [
'status',
'--porcelain=v2',
'-z',
'--untracked-files=all',
'--',
'.',
]);
return result?.code === 0 ? parseStatus(result.stdout) : [];
}
async find(
projectPath: string,
query: string,
requestedLimit = DEFAULT_FIND_RESULTS,
): Promise<CodingProjectFileEntry[]> {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) throw new Error('File query is required');
if (normalizedQuery.length > 200) throw new Error('File query is too long');
const limit = Math.min(
Number.isSafeInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : DEFAULT_FIND_RESULTS,
MAX_FIND_RESULTS,
);
const paths = await this.listFiles(projectPath);
const matches = paths
.filter((filePath) => filePath.toLocaleLowerCase().includes(normalizedQuery))
.sort((left, right) => {
const leftName = path.posix.basename(left).toLocaleLowerCase();
const rightName = path.posix.basename(right).toLocaleLowerCase();
const leftPrefix = leftName.startsWith(normalizedQuery) ? 0 : 1;
const rightPrefix = rightName.startsWith(normalizedQuery) ? 0 : 1;
return leftPrefix - rightPrefix || left.length - right.length || left.localeCompare(right);
})
.slice(0, limit);
return await Promise.all(matches.map(async (filePath) => {
let size: number | undefined;
try {
const metadata = await lstat(projectTarget(projectPath, filePath));
if (metadata.isFile()) size = metadata.size;
} catch {
// A concurrently removed result remains useful by path.
}
return {
path: filePath,
name: path.posix.basename(filePath),
type: 'file' as const,
...(size === undefined ? {} : { size }),
};
}));
}
async content(projectPath: string, requestedPath: string): Promise<CodingProjectFileContent> {
const relativePath = normalizeRelativePath(requestedPath);
const result = await readBoundedFile(projectPath, relativePath, MAX_CONTENT_BYTES);
return {
path: relativePath,
content: decodeText(result.data, result.truncated),
truncated: result.truncated,
};
}
async search(projectPath: string, pattern: string): Promise<CodingTextSearchResult[]> {
const needle = pattern.trim();
if (!needle) throw new Error('Search pattern is required');
if (needle.length > 512) throw new Error('Search pattern is too long');
const foldedNeedle = needle.toLocaleLowerCase();
const matches: CodingTextSearchResult[] = [];
for (const filePath of await this.listFiles(projectPath)) {
if (matches.length >= MAX_SEARCH_RESULTS) break;
let content: string;
try {
const bounded = await readBoundedFile(projectPath, filePath, MAX_SEARCH_FILE_BYTES);
if (bounded.truncated) continue;
content = decodeText(bounded.data);
} catch {
continue;
}
const lines = content.split(/\r?\n/);
for (let lineIndex = 0; lineIndex < lines.length && matches.length < MAX_SEARCH_RESULTS; lineIndex += 1) {
const line = lines[lineIndex];
const foldedLine = line.toLocaleLowerCase();
const first = foldedLine.indexOf(foldedNeedle);
if (first < 0) continue;
const windowStart = Math.max(0, first - 256);
const lineText = line.slice(windowStart, windowStart + MAX_SEARCH_LINE_CHARS);
const foldedText = lineText.toLocaleLowerCase();
const submatches = [];
let offset = 0;
while (submatches.length < 20) {
const start = foldedText.indexOf(foldedNeedle, offset);
if (start < 0) break;
const end = start + needle.length;
submatches.push({ text: lineText.slice(start, end), start, end });
offset = Math.max(end, start + 1);
}
matches.push({
path: filePath,
name: path.posix.basename(filePath),
lineNumber: lineIndex + 1,
lineText,
submatches,
});
}
}
return matches;
}
private async listFiles(projectPath: string): Promise<string[]> {
const repository = await this.gitResult(projectPath, ['rev-parse', '--is-inside-work-tree']);
if (repository?.code === 0 && repository.stdout.trim() === 'true') {
const listed = await this.gitResult(projectPath, [
'ls-files',
'-co',
'--exclude-standard',
'-z',
'--',
'.',
]);
if (listed?.code === 0) {
return [...new Set(listed.stdout
.split('\0')
.filter(Boolean)
.map(normalizeRelativePath))]
.sort()
.slice(0, MAX_DISCOVERED_FILES);
}
}
return await fallbackFileList(projectPath);
}
private async gitResult(projectPath: string, args: readonly string[]): Promise<GitCommandResult | null> {
try {
return await this.git.run(projectPath, args);
} catch {
return null;
}
}
}

View File

@@ -4,21 +4,17 @@ import {
BUNDLED_CODING_SKILL_IDS,
type BundledCodingSkillId,
} from '../../shared/coding-skills';
import type {
ProductCodingCommand,
ProductCodingSkill,
ProductPiCommandInput,
} from '../../shared/coding-product-tools';
export interface ProductCodingSkill {
id: BundledCodingSkillId;
name: string;
description: string;
selected: boolean;
}
export interface ProductCodingCommand {
name: string;
title: string;
description: string;
source: 'makelore' | 'pi' | 'skill';
skillId?: BundledCodingSkillId;
}
export type {
ProductCodingCommand,
ProductCodingSkill,
ProductPiCommandInput,
} from '../../shared/coding-product-tools';
const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [
{ name: 'models', title: '切换模型', description: '切换当前会话后续轮次使用的模型', source: 'makelore' },
@@ -28,6 +24,8 @@ const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [
{ name: 'recover', title: '恢复会话', description: '重新创建当前会话 worker 并恢复状态', source: 'makelore' },
] as const;
const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
function frontmatterScalar(content: string, key: string): string | undefined {
const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
if (!block) return undefined;
@@ -70,14 +68,15 @@ export async function listProductCodingSkills(
export function buildProductCodingCommandCatalog(
skills: readonly ProductCodingSkill[],
piCommands: readonly { name: string; description?: string }[] = [],
piCommands: readonly ProductPiCommandInput[] = [],
): ProductCodingCommand[] {
const commands = [...MAKELORE_COMMANDS];
const used = new Set(commands.map((command) => command.name));
const used = new Set(commands.map((command) => command.name.toLocaleLowerCase()));
for (const command of piCommands) {
const name = command.name.trim();
if (!name || /\s/u.test(name) || used.has(name)) continue;
used.add(name);
const key = name.toLocaleLowerCase();
if (!COMMAND_NAME_PATTERN.test(name) || used.has(key)) continue;
used.add(key);
commands.push({
name,
title: name,
@@ -86,8 +85,9 @@ export function buildProductCodingCommandCatalog(
});
}
for (const skill of skills) {
if (!skill.selected || used.has(skill.id)) continue;
used.add(skill.id);
const key = skill.id.toLocaleLowerCase();
if (!skill.selected || used.has(key)) continue;
used.add(key);
commands.push({
name: skill.id,
title: skill.name,

View File

@@ -8,6 +8,11 @@ import {
buildProductCodingCommandCatalog,
listProductCodingSkills,
} from '../../coding-projects/skill-registry';
import type {
ProductCodingCommand,
ProductCodingSkill,
ProductPiCommandInput,
} from '../../../shared/coding-product-tools';
import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts';
import { PiAgentBrowserTool } from './extensions/agent-browser';
import { reportChangedFiles } from './extensions/changed-file';
@@ -65,6 +70,17 @@ export class PiProductTools {
return this.changeTracker.getSnapshot(conversationId);
}
listSkills(skillIds: readonly string[]): Promise<ProductCodingSkill[]> {
return listProductCodingSkills(this.options.bundledSkillsDir, skillIds);
}
async listCommands(
skillIds: readonly string[],
piCommands: readonly ProductPiCommandInput[] = [],
): Promise<ProductCodingCommand[]> {
return buildProductCodingCommandCatalog(await this.listSkills(skillIds), piCommands);
}
async markBash(conversationId: string, runId: string): Promise<void> {
await this.changeTracker.markProjectRefresh(conversationId, runId);
}
@@ -92,7 +108,7 @@ export class PiProductTools {
return await reportChangedFiles(this.changeTracker, context, input);
}
if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable');
const skills = await listProductCodingSkills(this.options.bundledSkillsDir, context.skillIds);
const skills = await this.listSkills(context.skillIds);
const details: RuntimeContextDetailsV1 = {
schema: 'runtime-context.v1',
skills,

View File

@@ -95,6 +95,10 @@ import {
} from '../image-workspace/local-workspace';
import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace';
import type { DesignWorkspaceModule } from '../image-workspace/module';
import { CodingAttachmentStore } from '../coding-projects/attachment-store';
import { PiProductTools } from '../coding-runtime/pi/product-tools';
import { resolveBundledCodingSkillsDir } from '../coding-runtime/pi/resource-loader';
import { createCodingProductHost } from '../api/coding-product-services';
// Diagnostic package: force Chromium networking onto HTTP/1.1 for transport A/B testing.
app.commandLine.appendSwitch('disable-http2');
@@ -549,6 +553,29 @@ async function initialize(): Promise<void> {
window.webContents.send('release-job:status', status);
}
});
const codingAttachments = new CodingAttachmentStore(
join(app.getPath('userData'), 'coding-runtime', 'attachments'),
);
const piProductTools = new PiProductTools({
browser: agentBrowser,
attachments: codingAttachments,
bundledSkillsDir: resolveBundledCodingSkillsDir({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath(),
}),
});
const codingProducts = {
attachments: codingAttachments,
productTools: piProductTools,
host: createCodingProductHost({
productTools: piProductTools,
getActiveProject: async () => {
const project = await opencodeProjectStore.getActiveProject();
return project ? { id: project.id, path: project.path } : null;
},
}),
};
const hostApiContext: HostApiContext = {
opencodeManager,
opencodeProjectStore,
@@ -559,6 +586,7 @@ async function initialize(): Promise<void> {
imageWorkspace,
lifecycle: backgroundLifecycle,
releaseJobs,
codingProducts,
};
registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext);