From 44bd0109650b81ead64f96bb55756867b0756b23 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 4 Jun 2026 08:42:37 +0200 Subject: [PATCH] fix(nitro): Compile Junior content for serverless Load Junior app and plugin content from a private Nitro virtual module so Vercel functions no longer depend on copied app or package manifests at runtime. Keep filesystem-backed discovery for local and non-Nitro runtimes, and preserve includeFiles only for explicit dependency assets that the bundler cannot trace. Fixes #510 Co-Authored-By: GPT-5 Codex --- apps/example/scripts/check-vercel-output.mjs | 74 ++++- .../docs/reference/api/functions/createApp.md | 2 +- .../reference/api/functions/juniorNitro.md | 4 +- .../api/interfaces/JuniorAppOptions.md | 10 +- .../api/interfaces/JuniorNitroOptions.md | 12 +- packages/junior/src/app.ts | 25 +- packages/junior/src/build/compiled-content.ts | 201 +++++++++++++ .../junior/src/build/copy-build-content.ts | 77 ----- packages/junior/src/chat/content.ts | 282 ++++++++++++++++++ packages/junior/src/chat/discovery.ts | 61 ++-- packages/junior/src/chat/plugins/registry.ts | 129 +++++--- packages/junior/src/chat/prompt.ts | 60 ++-- packages/junior/src/chat/sandbox/sandbox.ts | 4 +- .../junior/src/chat/sandbox/skill-sync.ts | 25 +- packages/junior/src/chat/skills.ts | 46 +-- packages/junior/src/chat/slack/app-home.ts | 9 +- .../src/chat/tools/web/image-generate.ts | 10 +- packages/junior/src/nitro.ts | 55 ++-- packages/junior/src/reporting.ts | 13 +- packages/junior/src/virtual-modules.d.ts | 7 + .../integration/dashboard-reporting.test.ts | 2 - .../unit/build/copy-build-content.test.ts | 71 +---- .../unit/build/nitro-plugin-module.test.ts | 187 ++++++++---- .../unit/content/compiled-content.test.ts | 271 +++++++++++++++++ .../tests/unit/web/image-generate.test.ts | 2 +- packages/junior/tsup.config.ts | 1 + specs/plugin-runtime.md | 38 ++- 27 files changed, 1279 insertions(+), 399 deletions(-) create mode 100644 packages/junior/src/build/compiled-content.ts create mode 100644 packages/junior/src/chat/content.ts create mode 100644 packages/junior/tests/unit/content/compiled-content.test.ts diff --git a/apps/example/scripts/check-vercel-output.mjs b/apps/example/scripts/check-vercel-output.mjs index 2bf39c5ac..163a83712 100644 --- a/apps/example/scripts/check-vercel-output.mjs +++ b/apps/example/scripts/check-vercel-output.mjs @@ -17,6 +17,8 @@ const queueFunctionDir = path.join( "agent", "continue.func", ); +const compiledAppRoot = "/__junior_content__/app"; +const compiledNodeModulesRoot = "/__junior_content__/node_modules"; function fail(message) { throw new Error(`Vercel output check failed: ${message}`); @@ -28,6 +30,12 @@ function requireFile(filePath) { } } +function rejectFile(filePath) { + if (existsSync(filePath) && statSync(filePath).isFile()) { + fail(`unexpected copied file ${path.relative(appRoot, filePath)}`); + } +} + function requireDirectory(directoryPath) { if (!existsSync(directoryPath) || !statSync(directoryPath).isDirectory()) { fail(`missing directory ${path.relative(appRoot, directoryPath)}`); @@ -39,6 +47,32 @@ function readJson(filePath) { return JSON.parse(readFileSync(filePath, "utf8")); } +function readVirtualContent(functionDir) { + const virtualContentPath = path.join(functionDir, "_virtual", "content.mjs"); + requireFile(virtualContentPath); + const raw = readFileSync(virtualContentPath, "utf8"); + const match = + /^export const content = (.*);\n?$/s.exec(raw) ?? + /(?:^|\n)const content = (\{[\s\S]*\});\n\/\/#endregion\nexport \{ content \};\n?$/.exec( + raw, + ); + if (!match) { + fail( + `${path.relative(appRoot, virtualContentPath)} does not export a compiled content graph`, + ); + } + + return JSON.parse(match[1]); +} + +function requireCompiledFile(content, virtualPath, functionDir) { + if (typeof content?.files?.[virtualPath] !== "string") { + fail( + `${path.relative(appRoot, functionDir)} is missing compiled file ${virtualPath}`, + ); + } +} + function packageSourceHasPlugin(packageName) { const sourceDir = path.join( repoRoot, @@ -76,19 +110,36 @@ function assertQueueTrigger() { function assertFunctionHasJuniorContent(functionDir, pluginPackages) { requireFile(path.join(functionDir, "index.mjs")); - requireFile(path.join(functionDir, "app", "SOUL.md")); - requireFile( - path.join(functionDir, "app", "plugins", "example-bundle", "plugin.yaml"), + const content = readVirtualContent(functionDir); + if (content.appRoot !== compiledAppRoot) { + fail(`${path.relative(appRoot, functionDir)} has an unexpected app root`); + } + + requireCompiledFile(content, `${compiledAppRoot}/SOUL.md`, functionDir); + rejectFile(path.join(functionDir, "app", "SOUL.md")); + requireCompiledFile( + content, + `${compiledAppRoot}/plugins/example-bundle/plugin.yaml`, + functionDir, ); - requireFile( - path.join(functionDir, "app", "skills", "example-local", "SKILL.md"), + requireCompiledFile( + content, + `${compiledAppRoot}/skills/example-local/SKILL.md`, + functionDir, ); for (const packageName of pluginPackages) { - requireFile( + requireCompiledFile( + content, + `${compiledNodeModulesRoot}/${packageName}/plugin.yaml`, + functionDir, + ); + rejectFile( path.join(functionDir, "node_modules", packageName, "plugin.yaml"), ); } + + return content; } if (existsSync(path.join(appRoot, "api"))) { @@ -106,10 +157,17 @@ if (pluginPackages.length === 0) { fail("no plugin package fixtures were discovered for output validation"); } +const compiledGraphs = []; for (const functionDir of [serverFunctionDir, queueFunctionDir]) { - assertFunctionHasJuniorContent(functionDir, pluginPackages); + compiledGraphs.push( + assertFunctionHasJuniorContent(functionDir, pluginPackages), + ); +} + +if (JSON.stringify(compiledGraphs[0]) !== JSON.stringify(compiledGraphs[1])) { + fail("primary and queue functions have different compiled content graphs"); } console.log( - `Verified Vercel output for ${pluginPackages.length} plugin package(s) in primary and queue functions.`, + `Verified compiled Junior content for ${pluginPackages.length} plugin package(s) in primary and queue functions.`, ); diff --git a/packages/docs/src/content/docs/reference/api/functions/createApp.md b/packages/docs/src/content/docs/reference/api/functions/createApp.md index 29ee5c381..fe4820ab9 100644 --- a/packages/docs/src/content/docs/reference/api/functions/createApp.md +++ b/packages/docs/src/content/docs/reference/api/functions/createApp.md @@ -7,7 +7,7 @@ title: "createApp" > **createApp**(`options?`): `Promise`\<`Hono`\<`BlankEnv`, `BlankSchema`, `"/"`\>\> -Defined in: [app.ts:259](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L259) +Defined in: [app.ts:284](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L284) Create a Hono app with all Junior routes. diff --git a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md index 1ab6b52e6..d3f3e0c0d 100644 --- a/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md +++ b/packages/docs/src/content/docs/reference/api/functions/juniorNitro.md @@ -7,9 +7,9 @@ title: "juniorNitro" > **juniorNitro**(`options?`): `object` -Defined in: [nitro.ts:258](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L258) +Defined in: [nitro.ts:285](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L285) -Nitro module that configures deployment wiring and copies app/plugin content into the Vercel build output. +Nitro module that configures deployment wiring and private Junior runtime content. ## Parameters diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md index a59fa510f..72c1ce5fd 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorAppOptions" --- -Defined in: [app.ts:53](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L53) +Defined in: [app.ts:57](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L57) ## Properties @@ -13,7 +13,7 @@ Defined in: [app.ts:53](https://github.com/getsentry/junior/blob/main/packages/j > `optional` **configDefaults?**: `Record`\<`string`, `unknown`\> -Defined in: [app.ts:55](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L55) +Defined in: [app.ts:59](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L59) Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. @@ -23,7 +23,7 @@ Install-wide provider defaults (`provider.key` format). Channel overrides take p > `optional` **conversationWork?**: `VercelConversationWorkCallbackOptions` -Defined in: [app.ts:57](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L57) +Defined in: [app.ts:61](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L61) Queue consumer wiring for the durable conversation worker. @@ -33,7 +33,7 @@ Queue consumer wiring for the durable conversation worker. > `optional` **plugins?**: [`JuniorPluginSet`](/reference/api/interfaces/juniorpluginset/) -Defined in: [app.ts:59](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L59) +Defined in: [app.ts:63](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L63) Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin module. @@ -43,4 +43,4 @@ Direct plugin set override. Usually omitted when `juniorNitro()` uses a plugin m > `optional` **waitUntil?**: `WaitUntilFn` -Defined in: [app.ts:60](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L60) +Defined in: [app.ts:64](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L64) diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md index abb7510bd..318ed0268 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorNitroOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorNitroOptions" --- -Defined in: [nitro.ts:39](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L39) +Defined in: [nitro.ts:37](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L37) ## Properties @@ -13,7 +13,7 @@ Defined in: [nitro.ts:39](https://github.com/getsentry/junior/blob/main/packages > `optional` **conversationWorkQueueTopic?**: `string` -Defined in: [nitro.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L43) +Defined in: [nitro.ts:41](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L41) Vercel Queue topic for durable conversation work. Must match the runtime queue producer topic. @@ -23,7 +23,7 @@ Vercel Queue topic for durable conversation work. Must match the runtime queue p > `optional` **cwd?**: `string` -Defined in: [nitro.ts:40](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L40) +Defined in: [nitro.ts:38](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L38) --- @@ -31,7 +31,7 @@ Defined in: [nitro.ts:40](https://github.com/getsentry/junior/blob/main/packages > `optional` **includeFiles?**: `string`[] -Defined in: [nitro.ts:52](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L52) +Defined in: [nitro.ts:50](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L50) Extra file patterns to copy into the server output for files that the bundler cannot trace (e.g. dynamically imported providers). @@ -44,7 +44,7 @@ module resolution. Example: `"@earendil-works/pi-ai/dist/providers/*.js"` > `optional` **maxDuration?**: `number` -Defined in: [nitro.ts:41](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L41) +Defined in: [nitro.ts:39](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L39) --- @@ -52,6 +52,6 @@ Defined in: [nitro.ts:41](https://github.com/getsentry/junior/blob/main/packages > `optional` **plugins?**: `JuniorNitroPluginSource` -Defined in: [nitro.ts:45](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L45) +Defined in: [nitro.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/nitro.ts#L43) Plugin catalog set or runtime-safe plugin module. Direct sets must not include trusted hooks. diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 562b9a9ac..0ecd34b84 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -9,6 +9,7 @@ import { getPluginProviders, setPluginCatalogConfig, } from "@/chat/plugins/registry"; +import { setRuntimeContent, type JuniorCompiledContent } from "@/chat/content"; import { type AgentPluginRouteRegistration, getAgentPluginRoutes, @@ -106,6 +107,23 @@ async function resolveVirtualConfig(): Promise< } } +/** Resolve private runtime content from the virtual module injected by juniorNitro(). */ +async function resolveVirtualContent(): Promise< + JuniorCompiledContent | undefined +> { + try { + const mod: { + content?: JuniorCompiledContent; + } = await import("#junior/content"); + return mod.content; + } catch (error) { + if (!isMissingVirtualModule(error, "#junior/content")) { + throw error; + } + return undefined; + } +} + /** Resolve plugin configuration from the env fallback. */ function resolveEnvPluginCatalogConfig(): PluginCatalogConfig | undefined { const packages = readEnvPluginPackages(); @@ -116,6 +134,10 @@ function resolveEnvPluginCatalogConfig(): PluginCatalogConfig | undefined { } function isMissingVirtualConfig(error: unknown): boolean { + return isMissingVirtualModule(error, "#junior/config"); +} + +function isMissingVirtualModule(error: unknown, specifier: string): boolean { if (!(error instanceof Error)) { return false; } @@ -124,7 +146,7 @@ function isMissingVirtualConfig(error: unknown): boolean { (code === "ERR_PACKAGE_IMPORT_NOT_DEFINED" || code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && - error.message.includes("#junior/config") + error.message.includes(specifier) ); } @@ -257,6 +279,7 @@ function mountAgentPluginRoutes( /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { + setRuntimeContent(await resolveVirtualContent()); const virtualConfig = await resolveVirtualConfig(); const configuredPlugins = options?.plugins ?? virtualConfig?.pluginSet; const agentPlugins = diff --git a/packages/junior/src/build/compiled-content.ts b/packages/junior/src/build/compiled-content.ts new file mode 100644 index 000000000..9a3b892e7 --- /dev/null +++ b/packages/junior/src/build/compiled-content.ts @@ -0,0 +1,201 @@ +import { + existsSync, + readFileSync, + readdirSync, + realpathSync, + statSync, +} from "node:fs"; +import path from "node:path"; +import type { Nitro } from "nitro/types"; +import { + COMPILED_APP_ROOT, + COMPILED_NODE_MODULES_ROOT, + type JuniorCompiledContent, +} from "@/chat/content"; +import { + discoverInstalledPluginPackageContent, + type InstalledPluginPackageContent, +} from "@/chat/plugins/package-discovery"; + +function normalizeVirtualPath(targetPath: string): string { + return path.posix.resolve(targetPath.replace(/\\/g, "/")); +} + +function packageVirtualRoot(packageName: string): string { + return `${COMPILED_NODE_MODULES_ROOT}/${packageName}`; +} + +function toPosixRelative(base: string, targetPath: string): string { + return path.relative(base, targetPath).split(path.sep).join("/"); +} + +function addFile( + files: Record, + sourcePath: string, + virtualPath: string, +): void { + files[normalizeVirtualPath(virtualPath)] = + readFileSync(sourcePath).toString("base64"); +} + +function addDirectory( + files: Record, + sourceRoot: string, + virtualRoot: string, +): void { + if (!existsSync(sourceRoot)) { + return; + } + + const queue = [sourceRoot]; + const seenDirs = new Set(); + while (queue.length > 0) { + const dir = queue.shift() as string; + const realDir = realpathSync(dir); + if (seenDirs.has(realDir)) { + continue; + } + seenDirs.add(realDir); + + for (const entry of readdirSync(dir, { withFileTypes: true }).sort( + (left, right) => left.name.localeCompare(right.name), + )) { + const sourcePath = path.join(dir, entry.name); + const stat = statSync(sourcePath); + if (stat.isDirectory()) { + queue.push(sourcePath); + continue; + } + if (!stat.isFile()) { + continue; + } + + const relativePath = toPosixRelative(sourceRoot, sourcePath); + addFile(files, sourcePath, path.posix.join(virtualRoot, relativePath)); + } + } +} + +function packagePathMapper( + packagedContent: InstalledPluginPackageContent, +): (sourcePath: string) => string { + const packageRoots = packagedContent.packages + .map((pkg) => ({ + sourceRoot: path.resolve(pkg.dir), + virtualRoot: packageVirtualRoot(pkg.name), + })) + .sort((left, right) => right.sourceRoot.length - left.sourceRoot.length); + + return (sourcePath: string) => { + const resolved = path.resolve(sourcePath); + const pkg = packageRoots.find( + (candidate) => + resolved === candidate.sourceRoot || + resolved.startsWith(`${candidate.sourceRoot}${path.sep}`), + ); + if (!pkg) { + throw new Error( + `Configured plugin content is not inside a discovered package: ${sourcePath}`, + ); + } + + return normalizeVirtualPath( + path.posix.join( + pkg.virtualRoot, + toPosixRelative(pkg.sourceRoot, resolved), + ), + ); + }; +} + +function virtualizePackageContent( + packagedContent: InstalledPluginPackageContent, +): InstalledPluginPackageContent { + const mapPath = packagePathMapper(packagedContent); + + return { + packageNames: [...packagedContent.packageNames], + packages: packagedContent.packages.map((pkg) => ({ + name: pkg.name, + hasSkillsDir: pkg.hasSkillsDir, + dir: packageVirtualRoot(pkg.name), + })), + manifestRoots: packagedContent.manifestRoots.map(mapPath), + skillRoots: packagedContent.skillRoots.map(mapPath), + tracingIncludes: [...packagedContent.tracingIncludes], + }; +} + +function addPackageContent( + files: Record, + packagedContent: InstalledPluginPackageContent, +): void { + const mapPath = packagePathMapper(packagedContent); + + for (const root of packagedContent.manifestRoots) { + const manifestPath = path.join(root, "plugin.yaml"); + if (existsSync(manifestPath) && statSync(manifestPath).isFile()) { + addFile( + files, + manifestPath, + path.posix.join(mapPath(root), "plugin.yaml"), + ); + continue; + } + + addDirectory(files, root, mapPath(root)); + } + + for (const root of packagedContent.skillRoots) { + addDirectory(files, root, mapPath(root)); + } +} + +/** Build the private Junior content graph used by Nitro/serverless runtimes. */ +export function buildCompiledContentGraph( + cwd: string, + packageNames?: unknown, +): JuniorCompiledContent { + const files: Record = {}; + const appRoot = path.join(cwd, "app"); + if (existsSync(appRoot)) { + addDirectory(files, appRoot, COMPILED_APP_ROOT); + } + + const packagedContent = discoverInstalledPluginPackageContent(cwd, { + packageNames, + }); + addPackageContent(files, packagedContent); + const virtualPackageContent = virtualizePackageContent(packagedContent); + + return { + version: 1, + appRoot: COMPILED_APP_ROOT, + files, + skillRoots: [`${COMPILED_APP_ROOT}/skills`], + packageContent: virtualPackageContent, + }; +} + +/** Render the virtual module consumed by createApp() at runtime. */ +export function renderCompiledContentModule( + content: JuniorCompiledContent, +): string { + return `export const content = ${JSON.stringify(content)};\n`; +} + +/** Inject the private content graph virtual module for Nitro builds. */ +export function injectVirtualContent( + nitro: Nitro, + options: { + loadPackageNames?: () => Promise; + cwd: string; + }, +): void { + nitro.options.virtual["#junior/content"] = async () => { + const packageNames = await options.loadPackageNames?.(); + return renderCompiledContentModule( + buildCompiledContentGraph(options.cwd, packageNames), + ); + }; +} diff --git a/packages/junior/src/build/copy-build-content.ts b/packages/junior/src/build/copy-build-content.ts index 27708d601..36056c845 100644 --- a/packages/junior/src/build/copy-build-content.ts +++ b/packages/junior/src/build/copy-build-content.ts @@ -1,38 +1,8 @@ import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; -import { discoverInstalledPluginPackageContent } from "@/chat/plugins/package-discovery"; import { globToRegex } from "@/build/glob-to-regex"; import { isValidPackageName, resolvePackageDir } from "@/package-resolution"; -/** Copy app directory and plugin manifests into the server output. */ -export function copyAppAndPluginContent( - cwd: string, - serverRoot: string, - packageNames?: unknown, -): void { - copyIfExists(path.join(cwd, "app"), path.join(serverRoot, "app")); - - const packagedContent = discoverInstalledPluginPackageContent(cwd, { - packageNames, - }); - for (const root of packagedContent.manifestRoots) { - if (existsSync(path.join(root, "plugin.yaml"))) { - const manifestPath = path.join(root, "plugin.yaml"); - copyIfExists( - manifestPath, - resolveServerOutputPath(cwd, serverRoot, manifestPath), - ); - continue; - } - - copyRootIntoServerOutput(cwd, serverRoot, root); - } - - for (const root of packagedContent.skillRoots) { - copyRootIntoServerOutput(cwd, serverRoot, root); - } -} - /** Copy extra file patterns into server output for files the bundler cannot trace. */ export function copyIncludedFiles( cwd: string, @@ -146,50 +116,3 @@ function copyIfExists(source: string, target: string): boolean { cpSync(source, target, { recursive: true }); return true; } - -function copyRootIntoServerOutput( - cwd: string, - serverRoot: string, - root: string, -): void { - copyIfExists(root, resolveServerOutputPath(cwd, serverRoot, root)); -} - -function resolveServerOutputPath( - cwd: string, - serverRoot: string, - sourcePath: string, -): string { - const relative = path.relative(cwd, sourcePath); - if (isLocalRelativePath(relative)) { - return path.join(serverRoot, relative); - } - - const nodeModulesRelative = nodeModulesRelativePath(sourcePath); - if (nodeModulesRelative) { - return path.join(serverRoot, nodeModulesRelative); - } - - throw new Error( - `Cannot copy configured plugin content outside the app root or node_modules: ${sourcePath}`, - ); -} - -function isLocalRelativePath(relativePath: string): boolean { - return ( - Boolean(relativePath) && - !path.isAbsolute(relativePath) && - relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) - ); -} - -function nodeModulesRelativePath(sourcePath: string): string | null { - const parts = path.resolve(sourcePath).split(path.sep); - const nodeModulesIndex = parts.lastIndexOf("node_modules"); - if (nodeModulesIndex === -1 || nodeModulesIndex === parts.length - 1) { - return null; - } - - return path.join("node_modules", ...parts.slice(nodeModulesIndex + 1)); -} diff --git a/packages/junior/src/chat/content.ts b/packages/junior/src/chat/content.ts new file mode 100644 index 000000000..db76ea664 --- /dev/null +++ b/packages/junior/src/chat/content.ts @@ -0,0 +1,282 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; + +export const COMPILED_CONTENT_ROOT = "/__junior_content__"; +export const COMPILED_APP_ROOT = `${COMPILED_CONTENT_ROOT}/app`; +export const COMPILED_NODE_MODULES_ROOT = `${COMPILED_CONTENT_ROOT}/node_modules`; + +export interface RuntimeDirectoryEntry { + isDirectory: boolean; + isFile: boolean; + name: string; +} + +export interface RuntimeContentFile { + content: Buffer; + path: string; +} + +export interface RuntimePluginPackageContent { + packageNames: string[]; + packages: { + dir: string; + hasSkillsDir: boolean; + name: string; + }[]; + manifestRoots: string[]; + skillRoots: string[]; + tracingIncludes: string[]; +} + +export interface JuniorCompiledContent { + appRoot?: string; + files: Record; + packageContent?: RuntimePluginPackageContent; + skillRoots: string[]; + version: 1; +} + +let compiledContent: JuniorCompiledContent | undefined; +let contentVersion = 0; + +function normalizeCompiledPath(targetPath: string): string { + return path.posix.resolve(targetPath.replace(/\\/g, "/")); +} + +function isCompiledRuntimePath(targetPath: string): boolean { + return ( + targetPath === COMPILED_CONTENT_ROOT || + targetPath.startsWith(`${COMPILED_CONTENT_ROOT}/`) + ); +} + +function compiledFileBuffer(targetPath: string): Buffer | null { + const normalized = normalizeCompiledPath(targetPath); + if (!isCompiledRuntimePath(normalized)) { + return null; + } + + const raw = compiledContent?.files[normalized]; + return raw === undefined ? null : Buffer.from(raw, "base64"); +} + +function compiledPathIsDirectory(targetPath: string): boolean | null { + if (!compiledContent) { + return null; + } + + const normalized = normalizeCompiledPath(targetPath); + if (!isCompiledRuntimePath(normalized)) { + return null; + } + + const prefix = `${normalized}/`; + return Object.keys(compiledContent.files).some((filePath) => + filePath.startsWith(prefix), + ); +} + +function compiledDirectoryEntries( + targetPath: string, +): RuntimeDirectoryEntry[] | null { + if (!compiledContent) { + return null; + } + + const normalized = normalizeCompiledPath(targetPath); + if (!isCompiledRuntimePath(normalized)) { + return null; + } + + const prefix = `${normalized}/`; + const entries = new Map(); + + for (const filePath of Object.keys(compiledContent.files)) { + if (!filePath.startsWith(prefix)) { + continue; + } + + const relativePath = filePath.slice(prefix.length); + const [name, ...rest] = relativePath.split("/"); + if (!name) { + continue; + } + + const existing = entries.get(name); + if (rest.length === 0) { + entries.set(name, { + name, + isFile: true, + isDirectory: existing?.isDirectory ?? false, + }); + } else { + entries.set(name, { + name, + isFile: existing?.isFile ?? false, + isDirectory: true, + }); + } + } + + return [...entries.values()].sort((left, right) => + left.name.localeCompare(right.name), + ); +} + +function compiledFilesRecursive( + targetPath: string, +): RuntimeContentFile[] | null { + if (!compiledContent) { + return null; + } + + const normalized = normalizeCompiledPath(targetPath); + if (!isCompiledRuntimePath(normalized)) { + return null; + } + + const prefix = `${normalized}/`; + return Object.entries(compiledContent.files) + .filter(([filePath]) => filePath.startsWith(prefix)) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([filePath, raw]) => ({ + path: filePath, + content: Buffer.from(raw, "base64"), + })); +} + +/** Replace the active compiled content graph, or reset to filesystem-backed discovery. */ +export function setRuntimeContent( + content: JuniorCompiledContent | undefined, +): void { + if (compiledContent === content) { + return; + } + + compiledContent = content; + contentVersion += 1; +} + +/** Return a monotonic version for caches that depend on runtime content. */ +export function getRuntimeContentVersion(): number { + return contentVersion; +} + +/** Return the compiled app root when Nitro provided one. */ +export function getCompiledAppRoot(): string | undefined { + return compiledContent?.appRoot; +} + +/** Return compiled package content when Nitro provided it. */ +export function getCompiledPluginPackageContent(): + | RuntimePluginPackageContent + | undefined { + return compiledContent?.packageContent; +} + +/** Return compiled app-local skill roots when Nitro provided them. */ +export function getCompiledSkillRoots(): string[] { + return compiledContent?.skillRoots ?? []; +} + +/** Check whether a runtime path resolves to a file in compiled content or on disk. */ +export function runtimePathIsFile(targetPath: string): boolean { + if (compiledFileBuffer(targetPath)) { + return true; + } + + try { + return fs.statSync(targetPath).isFile(); + } catch { + return false; + } +} + +/** Check whether a runtime path resolves to a directory in compiled content or on disk. */ +export function runtimePathIsDirectory(targetPath: string): boolean { + const compiledDirectory = compiledPathIsDirectory(targetPath); + if (compiledDirectory !== null) { + return compiledDirectory; + } + + try { + return fs.statSync(targetPath).isDirectory(); + } catch { + return false; + } +} + +/** Read a UTF-8 runtime file from compiled content or disk. */ +export function readRuntimeFileSync(targetPath: string): string | null { + const compiled = compiledFileBuffer(targetPath); + if (compiled) { + return compiled.toString("utf8"); + } + + try { + return fs.readFileSync(targetPath, "utf8"); + } catch { + return null; + } +} + +/** Read a runtime file as bytes from compiled content or disk. */ +export async function readRuntimeFileBuffer( + targetPath: string, +): Promise { + const compiled = compiledFileBuffer(targetPath); + if (compiled) { + return compiled; + } + + return fsp.readFile(targetPath); +} + +/** Read a UTF-8 runtime file asynchronously from compiled content or disk. */ +export async function readRuntimeFile(targetPath: string): Promise { + const compiled = compiledFileBuffer(targetPath); + if (compiled) { + return compiled.toString("utf8"); + } + + return fsp.readFile(targetPath, "utf8"); +} + +/** List one runtime directory from compiled content or disk. */ +export function listRuntimeDirectoryEntries( + targetPath: string, +): RuntimeDirectoryEntry[] | null { + const compiledEntries = compiledDirectoryEntries(targetPath); + if (compiledEntries) { + return compiledEntries; + } + + try { + return fs.readdirSync(targetPath, { withFileTypes: true }).map((entry) => { + try { + const stat = fs.statSync(path.join(targetPath, entry.name)); + return { + name: entry.name, + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), + }; + } catch { + return { + name: entry.name, + isDirectory: entry.isDirectory(), + isFile: entry.isFile(), + }; + } + }); + } catch { + return null; + } +} + +/** List files below a runtime directory from compiled content, or return null for filesystem callers. */ +export function listCompiledFilesRecursive( + targetPath: string, +): RuntimeContentFile[] | null { + return compiledFilesRecursive(targetPath); +} diff --git a/packages/junior/src/chat/discovery.ts b/packages/junior/src/chat/discovery.ts index 7a4c83d90..3cac21088 100644 --- a/packages/junior/src/chat/discovery.ts +++ b/packages/junior/src/chat/discovery.ts @@ -1,6 +1,13 @@ -import fs, { statSync } from "node:fs"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + getCompiledAppRoot, + getCompiledSkillRoots, + listRuntimeDirectoryEntries, + runtimePathIsDirectory, + runtimePathIsFile, +} from "@/chat/content"; // --------------------------------------------------------------------------- // Filesystem helpers @@ -8,20 +15,12 @@ import { fileURLToPath } from "node:url"; /** Check whether a path exists and is a directory. */ export function isDirectory(targetPath: string): boolean { - try { - return statSync(targetPath).isDirectory(); - } catch { - return false; - } + return runtimePathIsDirectory(targetPath); } /** Check whether a path exists and is a regular file. */ export function isFile(targetPath: string): boolean { - try { - return statSync(targetPath).isFile(); - } catch { - return false; - } + return runtimePathIsFile(targetPath); } // --------------------------------------------------------------------------- @@ -230,6 +229,11 @@ export function resolveHomeDir( cwd: string = process.cwd(), options?: ResolveHomeDirOptions, ): string { + const compiledAppRoot = getCompiledAppRoot(); + if (compiledAppRoot) { + return compiledAppRoot; + } + const resolvedCwd = path.resolve(cwd); const directApp = path.resolve(resolvedCwd, "app"); if (pathExists(directApp) && hasAnyDataMarkers(directApp)) { @@ -265,6 +269,17 @@ export function resolveHomeDir( } function resolveContentRoots(subdir: "data" | "skills" | "plugins"): string[] { + const compiledAppRoot = getCompiledAppRoot(); + if (compiledAppRoot) { + if (subdir === "data") { + return [compiledAppRoot]; + } + if (subdir === "skills") { + return getCompiledSkillRoots(); + } + return [path.join(compiledAppRoot, "plugins")]; + } + if (subdir === "data") { return [homeDir()]; } @@ -347,18 +362,18 @@ const RESERVED_APP_FILES = new Set([ /** List non-reserved .md files in the app root for sandbox reference syncing. */ export function listReferenceFiles(): string[] { const appDir = homeDir(); - try { - const entries = fs.readdirSync(appDir, { withFileTypes: true }); - return entries - .filter( - (entry) => - entry.isFile() && - entry.name.endsWith(".md") && - !RESERVED_APP_FILES.has(entry.name), - ) - .map((entry) => path.join(appDir, entry.name)) - .sort(); - } catch { + const entries = listRuntimeDirectoryEntries(appDir); + if (!entries) { return []; } + + return entries + .filter( + (entry) => + entry.isFile && + entry.name.endsWith(".md") && + !RESERVED_APP_FILES.has(entry.name), + ) + .map((entry) => path.join(appDir, entry.name)) + .sort(); } diff --git a/packages/junior/src/chat/plugins/registry.ts b/packages/junior/src/chat/plugins/registry.ts index 6ea54a59a..ca2bf9ddf 100644 --- a/packages/junior/src/chat/plugins/registry.ts +++ b/packages/junior/src/chat/plugins/registry.ts @@ -1,6 +1,13 @@ -import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import type { CapabilityProviderDefinition } from "@/chat/capabilities/catalog"; +import { + getCompiledPluginPackageContent, + getRuntimeContentVersion, + listRuntimeDirectoryEntries, + readRuntimeFileSync, + runtimePathIsDirectory, + runtimePathIsFile, +} from "@/chat/content"; import type { CredentialBroker } from "@/chat/credentials/broker"; import { pluginRoots } from "@/chat/discovery"; import { logInfo, logWarn, setSpanAttributes } from "@/chat/logging"; @@ -150,6 +157,62 @@ function normalizePluginRoots(roots: string[]): string[] { return resolved; } +function emptyPluginPackageContent(): InstalledPluginPackageContent { + return { + packageNames: [], + packages: [], + manifestRoots: [], + skillRoots: [], + tracingIncludes: [], + }; +} + +function pathIsInsideRoot(targetPath: string, root: string): boolean { + const normalizedTarget = path.resolve(targetPath); + const normalizedRoot = path.resolve(root); + return ( + normalizedTarget === normalizedRoot || + normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) + ); +} + +function filterCompiledPluginPackageContent( + packagedContent: InstalledPluginPackageContent, +): InstalledPluginPackageContent { + const packageNames = normalizePluginPackageNames(pluginConfig?.packages); + if (packageNames.length === 0) { + return emptyPluginPackageContent(); + } + + const packagesByName = new Map( + packagedContent.packages.map((pkg) => [pkg.name, pkg]), + ); + const packages = packageNames.map((packageName) => { + const pkg = packagesByName.get(packageName); + if (!pkg) { + throw new Error( + `Plugin package "${packageName}" was configured but was not bundled by juniorNitro()`, + ); + } + return pkg; + }); + const packageDirs = packages.map((pkg) => pkg.dir); + const belongsToSelectedPackage = (targetPath: string) => + packageDirs.some((dir) => pathIsInsideRoot(targetPath, dir)); + + return { + packageNames, + packages, + manifestRoots: packagedContent.manifestRoots.filter( + belongsToSelectedPackage, + ), + skillRoots: packagedContent.skillRoots.filter(belongsToSelectedPackage), + tracingIncludes: packagedContent.tracingIncludes.filter( + belongsToSelectedPackage, + ), + }; +} + function getPluginCatalogSource(): PluginCatalogSource { const packagedContent = discoverConfiguredPluginPackageContent(); const localRoots = normalizePluginRoots(pluginRoots()); @@ -170,6 +233,7 @@ function getPluginCatalogSource(): PluginCatalogSource { manifestRoots, packagedSkillRoots, packageNames: [...packagedContent.packageNames].sort(), + contentVersion: getRuntimeContentVersion(), pluginConfig: pluginConfig ?? {}, }), }; @@ -240,6 +304,11 @@ function registerInlineManifests( } function discoverConfiguredPluginPackageContent(): InstalledPluginPackageContent { + const compiledPackageContent = getCompiledPluginPackageContent(); + if (compiledPackageContent) { + return filterCompiledPluginPackageContent(compiledPackageContent); + } + return discoverInstalledPluginPackageContent(process.cwd(), { packageNames: pluginConfig?.packages, }); @@ -258,67 +327,43 @@ function buildLoadedPluginState( const roots = source.manifestRoots; for (const pluginsRoot of roots) { - let entries: string[]; - let rootStat: ReturnType; - try { - rootStat = statSync(pluginsRoot); - } catch (error) { - logWarn( - "plugin_root_read_failed", - {}, - { - "file.directory": pluginsRoot, - "exception.message": - error instanceof Error ? error.message : String(error), - }, - "Failed to read plugin root", - ); - continue; - } - if (rootStat.isDirectory()) { + if (runtimePathIsDirectory(pluginsRoot)) { const manifestPath = path.join(pluginsRoot, "plugin.yaml"); - let hasRootManifest = false; - try { - hasRootManifest = statSync(manifestPath).isFile(); - } catch { - hasRootManifest = false; - } - if (hasRootManifest) { - const rawRootManifest = readFileSync(manifestPath, "utf8"); + if (runtimePathIsFile(manifestPath)) { + const rawRootManifest = readRuntimeFileSync(manifestPath); + if (rawRootManifest === null) { + continue; + } registerYamlPluginManifest(state, rawRootManifest, pluginsRoot); continue; } } - try { - entries = readdirSync(pluginsRoot); - } catch (error) { + + const entries = listRuntimeDirectoryEntries(pluginsRoot); + if (!entries) { logWarn( "plugin_root_read_failed", {}, { "file.directory": pluginsRoot, - "exception.message": - error instanceof Error ? error.message : String(error), + "exception.message": "directory could not be read", }, "Failed to read plugin root", ); continue; } - for (const entry of entries.sort()) { - const pluginDir = path.join(pluginsRoot, entry); - try { - const stat = statSync(pluginDir); - if (!stat.isDirectory()) continue; - } catch { + for (const entry of entries.sort((left, right) => + left.name.localeCompare(right.name), + )) { + const pluginDir = path.join(pluginsRoot, entry.name); + if (!entry.isDirectory) { continue; } const manifestPath = path.join(pluginDir, "plugin.yaml"); - let raw: string; - try { - raw = readFileSync(manifestPath, "utf8"); - } catch { + const raw = readRuntimeFileSync(manifestPath); + if (raw === null) { continue; // No manifest — skip } diff --git a/packages/junior/src/chat/prompt.ts b/packages/junior/src/chat/prompt.ts index bc31b9c96..7fdab2f4c 100644 --- a/packages/junior/src/chat/prompt.ts +++ b/packages/junior/src/chat/prompt.ts @@ -6,9 +6,9 @@ * stay separate from durable conversation history so compaction does not retain * runtime instructions as user text. */ -import fs from "node:fs"; import path from "node:path"; import { botConfig } from "@/chat/config"; +import { getRuntimeContentVersion, readRuntimeFileSync } from "@/chat/content"; import { TURN_CONTEXT_TAG } from "@/chat/turn-context-tag"; import { listReferenceFiles, @@ -43,8 +43,9 @@ function loadOptionalMarkdownFile( fileName: string, ): string | null { for (const resolved of candidates) { - try { - const raw = fs.readFileSync(resolved, "utf8").trim(); + const content = readRuntimeFileSync(resolved); + if (content !== null) { + const raw = content.trim(); if (raw.length > 0) { const loggedMarkdownFiles = getLoggedMarkdownFiles(); const logKey = `${fileName}:${resolved}`; @@ -61,8 +62,6 @@ function loadOptionalMarkdownFile( } return raw; } - } catch { - continue; } } @@ -90,7 +89,7 @@ function loadWorld(): string | null { return loadOptionalMarkdownFile(worldPathCandidates(), "WORLD.md"); } -export const JUNIOR_PERSONALITY = (() => { +function resolveJuniorPersonality(): string { try { return loadSoul(); } catch (error) { @@ -105,9 +104,9 @@ export const JUNIOR_PERSONALITY = (() => { ); return DEFAULT_SOUL; } -})(); +} -export const JUNIOR_WORLD = (() => { +function resolveJuniorWorld(): string | null { try { return loadWorld(); } catch (error) { @@ -122,7 +121,12 @@ export const JUNIOR_WORLD = (() => { ); return null; } -})(); +} + +/** Return the active Junior personality from compiled content or filesystem fallback. */ +export function getJuniorPersonality(): string { + return resolveJuniorPersonality(); +} function workspaceSkillDir(skillName: string): string { return sandboxSkillDir(skillName); @@ -433,15 +437,16 @@ function buildIdentitySection(): string { } function buildPersonalitySection(): string { - return ["# Personality", JUNIOR_PERSONALITY.trim()].join("\n"); + return ["# Personality", resolveJuniorPersonality().trim()].join("\n"); } function buildWorldSection(): string | null { - if (!JUNIOR_WORLD) { + const world = resolveJuniorWorld(); + if (!world) { return null; } - return ["# World", JUNIOR_WORLD.trim()].join("\n"); + return ["# World", world.trim()].join("\n"); } function buildRuntimeSection(params: { @@ -579,20 +584,29 @@ type TurnContextPromptInput = { configuration?: Record; }; -const STATIC_SYSTEM_PROMPT = [ - HEADER, - buildIdentitySection(), - buildPersonalitySection(), - buildWorldSection(), - buildBehaviorSection(), - buildOutputSection(), -] - .filter((section): section is string => Boolean(section)) - .join("\n\n"); +let staticSystemPromptCache: + | { contentVersion: number; prompt: string } + | undefined; /** Return byte-stable platform instructions shared by every conversation and turn. */ export function buildSystemPrompt(): string { - return STATIC_SYSTEM_PROMPT; + const contentVersion = getRuntimeContentVersion(); + if (staticSystemPromptCache?.contentVersion === contentVersion) { + return staticSystemPromptCache.prompt; + } + + const prompt = [ + HEADER, + buildIdentitySection(), + buildPersonalitySection(), + buildWorldSection(), + buildBehaviorSection(), + buildOutputSection(), + ] + .filter((section): section is string => Boolean(section)) + .join("\n\n"); + staticSystemPromptCache = { contentVersion, prompt }; + return prompt; } /** Build volatile runtime context that belongs in the user turn, not the system prompt. */ diff --git a/packages/junior/src/chat/sandbox/sandbox.ts b/packages/junior/src/chat/sandbox/sandbox.ts index 946e8a940..b71655fda 100644 --- a/packages/junior/src/chat/sandbox/sandbox.ts +++ b/packages/junior/src/chat/sandbox/sandbox.ts @@ -1,4 +1,4 @@ -import fs from "node:fs/promises"; +import { readRuntimeFile } from "@/chat/content"; import { logInfo, setSpanAttributes, @@ -298,7 +298,7 @@ export function createSandboxExecutor(options?: { resolveHostDataPath(referenceFiles, filePath); if (hostPath) { try { - const content = await fs.readFile(hostPath, "utf8"); + const content = await readRuntimeFile(hostPath); setSpanAttributes({ "app.sandbox.path.length": filePath.length, "app.sandbox.read.bytes": Buffer.byteLength(content, "utf8"), diff --git a/packages/junior/src/chat/sandbox/skill-sync.ts b/packages/junior/src/chat/sandbox/skill-sync.ts index 1e6acbfbe..cd3e1689c 100644 --- a/packages/junior/src/chat/sandbox/skill-sync.ts +++ b/packages/junior/src/chat/sandbox/skill-sync.ts @@ -1,5 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + listCompiledFilesRecursive, + readRuntimeFileBuffer, +} from "@/chat/content"; import { SANDBOX_DATA_ROOT, SANDBOX_SKILLS_ROOT, @@ -22,9 +26,14 @@ function toPosixRelative(base: string, absolute: string): string { return path.relative(base, absolute).split(path.sep).join("/"); } -async function listFilesRecursive(root: string): Promise { +async function listFilesRecursive(root: string): Promise { + const compiledFiles = listCompiledFilesRecursive(root); + if (compiledFiles) { + return compiledFiles; + } + const queue: string[] = [root]; - const files: string[] = []; + const files: SkillSyncFile[] = []; while (queue.length > 0) { const dir = queue.shift() as string; @@ -36,7 +45,10 @@ async function listFilesRecursive(root: string): Promise { if (entry.isDirectory()) { queue.push(absolute); } else if (entry.isFile()) { - files.push(absolute); + files.push({ + path: absolute, + content: await fs.readFile(absolute), + }); } } } @@ -59,14 +71,15 @@ async function buildSkillSyncFiles( for (const skill of availableSkills) { const skillFiles = await listFilesRecursive(skill.skillPath); - for (const absoluteFile of skillFiles) { + for (const file of skillFiles) { + const absoluteFile = file.path; const relative = toPosixRelative(skill.skillPath, absoluteFile); if (!relative || relative.startsWith("..")) { continue; } filesToWrite.push({ path: `${sandboxSkillDir(skill.name)}/${relative}`, - content: await fs.readFile(absoluteFile), + content: file.content, }); } @@ -87,7 +100,7 @@ async function buildSkillSyncFiles( const fileName = path.basename(absoluteFile); filesToWrite.push({ path: `${SANDBOX_DATA_ROOT}/${fileName}`, - content: await fs.readFile(absoluteFile), + content: await readRuntimeFileBuffer(absoluteFile), }); } } diff --git a/packages/junior/src/chat/skills.ts b/packages/junior/src/chat/skills.ts index 348379b06..854e5b2dc 100644 --- a/packages/junior/src/chat/skills.ts +++ b/packages/junior/src/chat/skills.ts @@ -1,7 +1,11 @@ -import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { parse as parseYaml } from "yaml"; +import { + getRuntimeContentVersion, + listRuntimeDirectoryEntries, + readRuntimeFile, +} from "@/chat/content"; import { skillRoots } from "@/chat/discovery"; import { logWarn } from "@/chat/logging"; import { @@ -332,7 +336,7 @@ async function readSkillDirectory( const skillFile = path.join(skillDir, "SKILL.md"); try { - const raw = await fs.readFile(skillFile, "utf8"); + const raw = await readRuntimeFile(skillFile); const parsed = parseSkillFile(raw, path.basename(skillDir)); if (!parsed.ok) { logWarn( @@ -379,7 +383,7 @@ export async function discoverSkills( options?: DiscoverSkillsOptions, ): Promise { const roots = resolveSkillRoots(options); - const cacheKey = roots.join(path.delimiter); + const cacheKey = `${getRuntimeContentVersion()}:${roots.join(path.delimiter)}`; if ( skillCache && skillCache.expiresAt > Date.now() && @@ -392,32 +396,30 @@ export async function discoverSkills( const seen = new Set(); for (const root of roots) { - try { - const entries = await fs.readdir(root, { withFileTypes: true }); - for (const entry of entries.sort((a, b) => - a.name.localeCompare(b.name), - )) { - if (!entry.isDirectory()) { - continue; - } - - const skill = await readSkillDirectory(path.join(root, entry.name)); - if (skill && !seen.has(skill.name)) { - seen.add(skill.name); - discovered.push(skill); - } - } - } catch (error) { + const entries = listRuntimeDirectoryEntries(root); + if (!entries) { logWarn( "skill_root_read_failed", {}, { "file.directory": root, - "exception.message": - error instanceof Error ? error.message : String(error), + "exception.message": "directory could not be read", }, "Failed to read skill root", ); + continue; + } + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory) { + continue; + } + + const skill = await readSkillDirectory(path.join(root, entry.name)); + if (skill && !seen.has(skill.name)) { + seen.add(skill.name); + discovered.push(skill); + } } } @@ -499,7 +501,7 @@ export async function loadSkillsByName( } const skillFile = path.join(meta.skillPath, "SKILL.md"); - const raw = await fs.readFile(skillFile, "utf8"); + const raw = await readRuntimeFile(skillFile); const parsed = parseSkillFile(raw, meta.name); if (!parsed.ok) { throw new Error(`Invalid skill file in ${skillFile}: ${parsed.error}`); diff --git a/packages/junior/src/chat/slack/app-home.ts b/packages/junior/src/chat/slack/app-home.ts index e095364bb..4bfd40c0f 100644 --- a/packages/junior/src/chat/slack/app-home.ts +++ b/packages/junior/src/chat/slack/app-home.ts @@ -1,6 +1,6 @@ -import fs from "node:fs"; import path from "node:path"; import type { WebClient, KnownBlock, SectionBlock } from "@slack/web-api"; +import { readRuntimeFileSync } from "@/chat/content"; import { hasRequiredOAuthScope } from "@/chat/credentials/oauth-scope"; import { homeDir } from "@/chat/discovery"; import { getMcpStoredOAuthCredentials } from "@/chat/mcp/auth-store"; @@ -30,13 +30,12 @@ function clampSectionText(text: string): string { function loadDescriptionText(): string { const descriptionPath = path.join(homeDir(), "DESCRIPTION.md"); - try { - const raw = fs.readFileSync(descriptionPath, "utf8").trim(); + const content = readRuntimeFileSync(descriptionPath); + if (content !== null) { + const raw = content.trim(); if (raw.length > 0) { return clampSectionText(raw); } - } catch { - // Use fallback when DESCRIPTION.md is absent. } return DEFAULT_DESCRIPTION_TEXT; } diff --git a/packages/junior/src/chat/tools/web/image-generate.ts b/packages/junior/src/chat/tools/web/image-generate.ts index e30319cfa..acb279984 100644 --- a/packages/junior/src/chat/tools/web/image-generate.ts +++ b/packages/junior/src/chat/tools/web/image-generate.ts @@ -7,24 +7,26 @@ import { getGatewayApiKey, MISSING_GATEWAY_CREDENTIALS_ERROR, } from "@/chat/pi/client"; -import { JUNIOR_PERSONALITY } from "@/chat/prompt"; +import { getJuniorPersonality } from "@/chat/prompt"; import { logInfo, logWarn } from "@/chat/logging"; const DEFAULT_IMAGE_MODEL = "google/gemini-3-pro-image"; -const ENRICHMENT_SYSTEM_PROMPT = `You are an image prompt enrichment agent. Your job is to rewrite image generation requests to reflect a specific visual identity and mood. +function buildEnrichmentSystemPrompt(): string { + return `You are an image prompt enrichment agent. Your job is to rewrite image generation requests to reflect a specific visual identity and mood. -${JUNIOR_PERSONALITY} +${getJuniorPersonality()} Rewrite the user's image request into a detailed image generation prompt that encodes this personality's visual aesthetic. Output ONLY the rewritten prompt text — no explanation, no wrapper.`; +} async function enrichImagePrompt(rawPrompt: string): Promise { try { const { text } = await completeText({ modelId: botConfig.fastModelId, - system: ENRICHMENT_SYSTEM_PROMPT, + system: buildEnrichmentSystemPrompt(), messages: [{ role: "user", content: rawPrompt, timestamp: Date.now() }], maxTokens: 1024, }); diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index bd2aaab40..c3e5c8095 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -3,11 +3,9 @@ import { statSync } from "node:fs"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import type { Nitro } from "nitro/types"; +import { injectVirtualContent } from "@/build/compiled-content"; import { applyRolldownTreeshakeWorkaround } from "@/build/rolldown-workarounds"; -import { - copyAppAndPluginContent, - copyIncludedFiles, -} from "@/build/copy-build-content"; +import { copyIncludedFiles } from "@/build/copy-build-content"; import { injectVirtualConfig, type RuntimePluginModule, @@ -283,7 +281,7 @@ function configureVercelDeployment(nitro: Nitro, options: JuniorNitroOptions) { }; } -/** Nitro module that configures deployment wiring and copies app/plugin content into the Vercel build output. */ +/** Nitro module that configures deployment wiring and private Junior runtime content. */ export function juniorNitro(options: JuniorNitroOptions = {}): { nitro: { setup(nitro: unknown): void }; } { @@ -331,33 +329,30 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { plugins: pluginCatalogConfig, trustedPluginRegistrations, }); + injectVirtualContent(nitro, { + cwd, + loadPackageNames: async () => { + const pluginSet = await loadConfiguredPluginSet(); + return pluginCatalogConfigFromPluginSet(pluginSet)?.packages; + }, + }); - const copyBuildContent = async () => { - const pluginSet = await loadConfiguredPluginSet(); - const compiledPluginCatalogConfig = - pluginCatalogConfigFromPluginSet(pluginSet); - copyAppAndPluginContent( - cwd, - nitro.options.output.serverDir, - compiledPluginCatalogConfig?.packages, - ); - copyIncludedFiles( - cwd, - nitro.options.output.serverDir, - options.includeFiles, - ); - }; - - nitro.hooks.hook("rollup:before", (_nitro, config) => { - const buildConfig = config as RollupLikeConfig; - buildConfig.plugins ??= []; - buildConfig.plugins.push({ - name: "junior:copy-build-content", - async writeBundle() { - await copyBuildContent(); - }, + if (options.includeFiles !== undefined) { + nitro.hooks.hook("rollup:before", (_nitro, config) => { + const buildConfig = config as RollupLikeConfig; + buildConfig.plugins ??= []; + buildConfig.plugins.push({ + name: "junior:copy-included-files", + writeBundle() { + copyIncludedFiles( + cwd, + nitro.options.output.serverDir, + options.includeFiles, + ); + }, + }); }); - }); + } }, }, }; diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index 2ad38d2e5..5bfdb9426 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -1,6 +1,6 @@ -import { readFileSync } from "node:fs"; import path from "node:path"; import { isRecord } from "@/chat/coerce"; +import { readRuntimeFileSync } from "@/chat/content"; import { homeDir } from "@/chat/discovery"; import type { PiMessage } from "@/chat/pi/messages"; import type { AgentTurnUsage } from "@/chat/usage"; @@ -195,15 +195,12 @@ export interface JuniorReporting { } function readDescriptionText(): string | undefined { - try { - const raw = readFileSync( - path.join(homeDir(), "DESCRIPTION.md"), - "utf8", - ).trim(); + const content = readRuntimeFileSync(path.join(homeDir(), "DESCRIPTION.md")); + if (content !== null) { + const raw = content.trim(); return raw || undefined; - } catch { - return undefined; } + return undefined; } async function readHealth(): Promise { diff --git a/packages/junior/src/virtual-modules.d.ts b/packages/junior/src/virtual-modules.d.ts index 48d9c77a3..511f8d391 100644 --- a/packages/junior/src/virtual-modules.d.ts +++ b/packages/junior/src/virtual-modules.d.ts @@ -7,3 +7,10 @@ declare module "#junior/config" { export const plugins: PluginCatalogConfig; export const trustedPluginRegistrations: string[]; } + +/** Private content graph injected by juniorNitro() at build time. */ +declare module "#junior/content" { + import type { JuniorCompiledContent } from "@/chat/content"; + + export const content: JuniorCompiledContent; +} diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index d97f2f3b7..aa67453b2 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -4,8 +4,6 @@ import type { PiMessage } from "@/chat/pi/messages"; vi.mock("@/chat/prompt", () => ({ buildSystemPrompt: vi.fn(() => "[system prompt]"), buildTurnContextPrompt: vi.fn(() => null), - JUNIOR_PERSONALITY: "", - JUNIOR_WORLD: null, })); const SYSTEM_MESSAGE = { diff --git a/packages/junior/tests/unit/build/copy-build-content.test.ts b/packages/junior/tests/unit/build/copy-build-content.test.ts index 211213116..0dd304d29 100644 --- a/packages/junior/tests/unit/build/copy-build-content.test.ts +++ b/packages/junior/tests/unit/build/copy-build-content.test.ts @@ -2,10 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { - copyAppAndPluginContent, - copyIncludedFiles, -} from "@/build/copy-build-content"; +import { copyIncludedFiles } from "@/build/copy-build-content"; const tempDirs: string[] = []; @@ -215,69 +212,3 @@ describe("copyIncludedFiles", () => { ).toBe("export {};\n"); }); }); - -describe("copyAppAndPluginContent", () => { - it("copies configured plugin packages resolved from ancestor node_modules", () => { - const workspaceRoot = makeTempDir(); - const cwd = path.join(workspaceRoot, "apps", "example"); - const serverRoot = makeTempDir(); - const packageDir = writePackage(workspaceRoot, "@acme/ancestor-plugin", { - entryPoint: false, - }); - - fs.mkdirSync(path.join(packageDir, "skills", "demo"), { recursive: true }); - fs.writeFileSync( - path.join(packageDir, "plugin.yaml"), - "name: ancestor\ndescription: Ancestor plugin\n", - "utf8", - ); - fs.writeFileSync( - path.join(packageDir, "skills", "demo", "SKILL.md"), - "---\nname: demo\ndescription: Demo\n---\n", - "utf8", - ); - fs.mkdirSync(cwd, { recursive: true }); - fs.writeFileSync( - path.join(cwd, "package.json"), - JSON.stringify({ - name: "example", - dependencies: { - "@acme/ancestor-plugin": "1.0.0", - }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(workspaceRoot, "package.json"), - JSON.stringify({ name: "workspace", private: true }), - "utf8", - ); - - copyAppAndPluginContent(cwd, serverRoot, ["@acme/ancestor-plugin"]); - - expect( - fs.existsSync( - path.join( - serverRoot, - "node_modules", - "@acme", - "ancestor-plugin", - "plugin.yaml", - ), - ), - ).toBe(true); - expect( - fs.existsSync( - path.join( - serverRoot, - "node_modules", - "@acme", - "ancestor-plugin", - "skills", - "demo", - "SKILL.md", - ), - ), - ).toBe(true); - }); -}); diff --git a/packages/junior/tests/unit/build/nitro-plugin-module.test.ts b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts index c008c706e..f97bdf531 100644 --- a/packages/junior/tests/unit/build/nitro-plugin-module.test.ts +++ b/packages/junior/tests/unit/build/nitro-plugin-module.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; import { afterEach, describe, expect, it } from "vitest"; +import { COMPILED_APP_ROOT } from "@/chat/content"; import { DEFAULT_CONVERSATION_WORK_QUEUE_TOPIC } from "@/chat/task-execution/vercel-queue"; import { JUNIOR_CONVERSATION_WORK_CALLBACK_ROUTE, @@ -409,27 +410,27 @@ describe("juniorNitro plugin modules", () => { expect(code).toContain( 'export const plugins = {"packages":["@acme/junior-demo"]};', ); - expect(rollupBeforeHooks).toHaveLength(1); + expect(rollupBeforeHooks).toHaveLength(0); }); - it("copies app and plugin content before Vercel route functions are cloned", async () => { + it("injects a compiled content graph for plugin module references", async () => { const tempRoot = await makeTempDir(); - const serverDir = path.join( - tempRoot, - ".vercel", - "output", - "functions", - "__server.func", + await fs.writeFile( + path.join(tempRoot, "plugins.mjs"), + [ + "export const plugins = {", + ' packageNames: ["@acme/junior-demo"],', + " registrations: [],", + "};", + "", + ].join("\n"), + "utf8", ); - const callbackDir = path.join( - tempRoot, - ".vercel", - "output", - "functions", - "api", - "internal", - "agent", - "continue.func", + await fs.mkdir(path.join(tempRoot, "app"), { recursive: true }); + await fs.writeFile( + path.join(tempRoot, "app", "SOUL.md"), + "Compiled soul\n", + "utf8", ); const packageDir = path.join( tempRoot, @@ -437,12 +438,6 @@ describe("juniorNitro plugin modules", () => { "@acme", "junior-demo", ); - await fs.mkdir(path.join(tempRoot, "app"), { recursive: true }); - await fs.writeFile( - path.join(tempRoot, "app", "SOUL.md"), - "Local soul\n", - "utf8", - ); await fs.mkdir(path.join(packageDir, "skills", "demo"), { recursive: true, }); @@ -456,7 +451,100 @@ describe("juniorNitro plugin modules", () => { "---\nname: demo\ndescription: Demo\n---\n", "utf8", ); + + const virtual: Record Promise) | string> = {}; + const nitro = { + hooks: { + hook() {}, + }, + options: { + output: { + serverDir: path.join(tempRoot, ".output", "server"), + }, + rootDir: tempRoot, + vercel: {}, + virtual, + }, + }; + + juniorNitro({ plugins: "./plugins" }).nitro.setup(nitro); + + const template = virtual["#junior/content"]; + expect(typeof template).toBe("function"); + const code = await (template as () => Promise)(); + const match = /^export const content = (.*);\n$/.exec(code); + expect(match?.[1]).toBeDefined(); + const content = JSON.parse(match?.[1] ?? "{}") as { + appRoot: string; + files: Record; + packageContent: { + manifestRoots: string[]; + skillRoots: string[]; + }; + }; + + expect(content.appRoot).toBe(COMPILED_APP_ROOT); + expect( + Buffer.from( + content.files[path.join(COMPILED_APP_ROOT, "SOUL.md")] ?? "", + "base64", + ).toString("utf8"), + ).toBe("Compiled soul\n"); + expect(content.packageContent.manifestRoots).toContain( + "/__junior_content__/node_modules/@acme/junior-demo", + ); + expect(content.packageContent.skillRoots).toContain( + "/__junior_content__/node_modules/@acme/junior-demo/skills", + ); + }); + + it("copies only explicitly included package files during Vercel builds", async () => { + const tempRoot = await makeTempDir(); + const serverDir = path.join( + tempRoot, + ".vercel", + "output", + "functions", + "__server.func", + ); + const packageDir = path.join( + tempRoot, + "node_modules", + "@acme", + "local-provider", + ); + await fs.mkdir(path.join(packageDir, "dist"), { + recursive: true, + }); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "@acme/local-provider", + main: "index.js", + }), + "utf8", + ); + await fs.writeFile( + path.join(packageDir, "index.js"), + "export {};\n", + "utf8", + ); + await fs.writeFile( + path.join(packageDir, "dist", "provider.js"), + "export const provider = true;\n", + "utf8", + ); await fs.mkdir(serverDir, { recursive: true }); + await fs.writeFile( + path.join(tempRoot, "package.json"), + JSON.stringify({ + name: "test-app", + dependencies: { + "@acme/local-provider": "1.0.0", + }, + }), + "utf8", + ); const rollupBeforeHooks: TestRollupBeforeHook[] = []; const virtual: Record Promise) | string> = {}; @@ -480,48 +568,33 @@ describe("juniorNitro plugin modules", () => { juniorNitro({ cwd: tempRoot, - plugins: defineJuniorPlugins(["@acme/junior-demo"]), + includeFiles: ["@acme/local-provider/dist/*.js"], }).nitro.setup(nitro); + expect(rollupBeforeHooks).toHaveLength(1); + const buildConfig: TestBuildConfig = { plugins: [] }; await rollupBeforeHooks[0]?.(nitro, buildConfig); const copyPlugin = buildConfig.plugins?.find( - (plugin) => plugin.name === "junior:copy-build-content", + (plugin) => plugin.name === "junior:copy-included-files", ); expect(copyPlugin).toBeDefined(); await copyPlugin?.writeBundle?.(); - await fs.cp(serverDir, callbackDir, { recursive: true }); - - for (const functionDir of [serverDir, callbackDir]) { - await expect( - fs.readFile(path.join(functionDir, "app", "SOUL.md"), "utf8"), - ).resolves.toBe("Local soul\n"); - await expect( - fs.readFile( - path.join( - functionDir, - "node_modules", - "@acme", - "junior-demo", - "plugin.yaml", - ), - "utf8", - ), - ).resolves.toContain("name: demo"); - await expect( - fs.readFile( - path.join( - functionDir, - "node_modules", - "@acme", - "junior-demo", - "skills", - "demo", - "SKILL.md", - ), - "utf8", + await expect( + fs.readFile( + path.join( + serverDir, + "node_modules", + "@acme", + "local-provider", + "dist", + "provider.js", ), - ).resolves.toContain("description: Demo"); - } + "utf8", + ), + ).resolves.toBe("export const provider = true;\n"); + await expect(fs.stat(path.join(serverDir, "app"))).rejects.toMatchObject({ + code: "ENOENT", + }); }); }); diff --git a/packages/junior/tests/unit/content/compiled-content.test.ts b/packages/junior/tests/unit/content/compiled-content.test.ts new file mode 100644 index 000000000..6415a24af --- /dev/null +++ b/packages/junior/tests/unit/content/compiled-content.test.ts @@ -0,0 +1,271 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildCompiledContentGraph } from "@/build/compiled-content"; +import { + COMPILED_APP_ROOT, + readRuntimeFileSync, + setRuntimeContent, +} from "@/chat/content"; +import { listReferenceFiles, resolveHomeDir } from "@/chat/discovery"; +import { + getPluginProviders, + setPluginCatalogConfig, +} from "@/chat/plugins/registry"; +import { + discoverSkills, + loadSkillsByName, + resetSkillDiscoveryCache, +} from "@/chat/skills"; +import { buildSystemPrompt } from "@/chat/prompt"; + +const originalCwd = process.cwd(); +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), "junior-compiled-content-"), + ); + tempDirs.push(tempDir); + return tempDir; +} + +async function writeSkill( + root: string, + name: string, + description: string, + body: string, +): Promise { + const skillDir = path.join(root, name); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + ["---", `name: ${name}`, `description: ${description}`, "---", body].join( + "\n", + ), + "utf8", + ); +} + +async function writePluginManifest( + root: string, + name: string, + description = `${name} plugin`, +): Promise { + await fs.mkdir(root, { recursive: true }); + await fs.writeFile( + path.join(root, "plugin.yaml"), + [`name: ${name}`, `description: ${description}`].join("\n"), + "utf8", + ); +} + +async function writePluginPackage( + root: string, + packageName: string, + pluginName: string, + description = `${pluginName} plugin`, +): Promise { + const packageRoot = path.join( + root, + "node_modules", + ...packageName.split("/"), + ); + await writePluginManifest(packageRoot, pluginName, description); + return packageRoot; +} + +afterEach(async () => { + process.chdir(originalCwd); + setRuntimeContent(undefined); + setPluginCatalogConfig(undefined); + resetSkillDiscoveryCache(); + for (const tempDir of tempDirs.splice(0)) { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +describe("compiled Junior content", () => { + it("loads app files, app plugins, app skills, and package plugins from the graph", async () => { + const tempRoot = await makeTempDir(); + const appRoot = path.join(tempRoot, "app"); + const appPluginRoot = path.join(appRoot, "plugins", "bundle"); + + await fs.mkdir(appRoot, { recursive: true }); + await fs.writeFile(path.join(appRoot, "SOUL.md"), "Compiled soul", "utf8"); + await fs.writeFile( + path.join(appRoot, "WORLD.md"), + "Compiled world", + "utf8", + ); + await fs.writeFile( + path.join(appRoot, "REFERENCE.md"), + "Compiled reference", + "utf8", + ); + await writeSkill( + path.join(appRoot, "skills"), + "local-skill", + "Local skill", + "Local body", + ); + + await writePluginManifest(appPluginRoot, "bundle", "Bundle plugin"); + await writeSkill( + path.join(appPluginRoot, "skills"), + "bundle-skill", + "Bundle skill", + "Bundle body", + ); + + const packageRoot = await writePluginPackage( + tempRoot, + "@acme/junior-demo", + "pkg", + "Package plugin", + ); + await writeSkill( + path.join(packageRoot, "skills"), + "pkg-skill", + "Package skill", + "Package body", + ); + await fs.writeFile( + path.join(tempRoot, "package.json"), + JSON.stringify({ + name: "compiled-content-app", + private: true, + dependencies: { + "@acme/junior-demo": "1.0.0", + }, + }), + "utf8", + ); + + const content = buildCompiledContentGraph(tempRoot, ["@acme/junior-demo"]); + expect(Object.keys(content.files).every((key) => !key.includes("\\"))).toBe( + true, + ); + await fs.rm(appRoot, { recursive: true, force: true }); + await fs.rm(path.join(tempRoot, "node_modules"), { + recursive: true, + force: true, + }); + const runtimeCwd = path.join(tempRoot, "runtime"); + await fs.mkdir(runtimeCwd); + process.chdir(runtimeCwd); + + setRuntimeContent(content); + setPluginCatalogConfig({ packages: ["@acme/junior-demo"] }); + + expect(resolveHomeDir()).toBe(COMPILED_APP_ROOT); + expect(readRuntimeFileSync(`${COMPILED_APP_ROOT}\\SOUL.md`)).toBe( + "Compiled soul", + ); + expect(buildSystemPrompt()).toContain("Compiled soul"); + expect(buildSystemPrompt()).toContain("Compiled world"); + expect(listReferenceFiles()).toEqual([ + path.join(COMPILED_APP_ROOT, "REFERENCE.md"), + ]); + + expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ + "bundle", + "pkg", + ]); + + const skills = await discoverSkills(); + expect(skills.map((skill) => [skill.name, skill.pluginProvider])).toEqual([ + ["bundle-skill", "bundle"], + ["local-skill", undefined], + ["pkg-skill", "pkg"], + ]); + + const loaded = await loadSkillsByName(["pkg-skill"], skills); + expect(loaded[0]?.body).toContain("Plugin Runtime Boundary"); + expect(loaded[0]?.body).toContain("Package body"); + }); + + it("filters compiled package plugins through the active plugin catalog", async () => { + const tempRoot = await makeTempDir(); + await writePluginPackage(tempRoot, "@acme/a", "a"); + await writePluginPackage(tempRoot, "@acme/b", "b"); + await fs.writeFile( + path.join(tempRoot, "package.json"), + JSON.stringify({ + name: "compiled-content-app", + private: true, + dependencies: { + "@acme/a": "1.0.0", + "@acme/b": "1.0.0", + }, + }), + "utf8", + ); + + setRuntimeContent( + buildCompiledContentGraph(tempRoot, ["@acme/a", "@acme/b"]), + ); + setPluginCatalogConfig({ packages: ["@acme/a"] }); + + expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ + "a", + ]); + }); + + it("reloads plugin manifests when compiled content is replaced", async () => { + const tempRoot = await makeTempDir(); + await fs.writeFile( + path.join(tempRoot, "package.json"), + JSON.stringify({ + name: "compiled-content-app", + private: true, + dependencies: { + "@acme/a": "1.0.0", + }, + }), + "utf8", + ); + + const packageRoot = await writePluginPackage( + tempRoot, + "@acme/a", + "a", + "old", + ); + setRuntimeContent(buildCompiledContentGraph(tempRoot, ["@acme/a"])); + setPluginCatalogConfig({ packages: ["@acme/a"] }); + expect(getPluginProviders()[0]?.manifest.description).toBe("old"); + + await writePluginManifest(packageRoot, "a", "new"); + setRuntimeContent(buildCompiledContentGraph(tempRoot, ["@acme/a"])); + + expect(getPluginProviders()[0]?.manifest.description).toBe("new"); + }); + + it("follows symlinked app plugin directories when compiling content", async () => { + const tempRoot = await makeTempDir(); + const linkedPluginSource = path.join(tempRoot, "linked-plugin-source"); + const linkedPluginTarget = path.join(tempRoot, "app", "plugins", "linked"); + await writePluginManifest(linkedPluginSource, "linked"); + await fs.mkdir(path.dirname(linkedPluginTarget), { recursive: true }); + await fs.symlink(linkedPluginSource, linkedPluginTarget, "dir"); + + const content = buildCompiledContentGraph(tempRoot); + expect( + readRuntimeFileSync( + path.join(COMPILED_APP_ROOT, "plugins", "linked", "plugin.yaml"), + ), + ).toBeNull(); + setRuntimeContent(content); + + expect( + readRuntimeFileSync( + path.join(COMPILED_APP_ROOT, "plugins", "linked", "plugin.yaml"), + ), + ).toContain("name: linked"); + expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ + "linked", + ]); + }); +}); diff --git a/packages/junior/tests/unit/web/image-generate.test.ts b/packages/junior/tests/unit/web/image-generate.test.ts index d654001ae..bb4bc16c3 100644 --- a/packages/junior/tests/unit/web/image-generate.test.ts +++ b/packages/junior/tests/unit/web/image-generate.test.ts @@ -11,7 +11,7 @@ vi.mock("@/chat/pi/client", () => ({ })); vi.mock("@/chat/prompt", () => ({ - JUNIOR_PERSONALITY: "test persona", + getJuniorPersonality: () => "test persona", })); import { completeText } from "@/chat/pi/client"; diff --git a/packages/junior/tsup.config.ts b/packages/junior/tsup.config.ts index ee128de9e..a1eb5b2cc 100644 --- a/packages/junior/tsup.config.ts +++ b/packages/junior/tsup.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ splitting: true, external: [ "#junior/config", + "#junior/content", "hono", "@sentry/node", // All runtime npm dependencies stay external diff --git a/specs/plugin-runtime.md b/specs/plugin-runtime.md index b59562293..e15b5eb18 100644 --- a/specs/plugin-runtime.md +++ b/specs/plugin-runtime.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-05-28 -- Last Edited: 2026-05-30 +- Last Edited: 2026-06-04 ## Purpose @@ -37,6 +37,36 @@ Plugin registry initialization is synchronous at module load so `discoverSkills( Plugin packages must be explicitly declared by plugin registrations. Runtime must never scan `node_modules`, `package.json` dependencies, or arbitrary filesystem paths to auto-discover plugins. +## Nitro Content Provider + +Nitro/serverless builds compile Junior app content into a private virtual module +instead of requiring each serverless function to rediscover files from +`process.cwd()`. The compiled graph includes app markdown, app-local skills, +app-local plugin manifests and skills, and explicitly configured plugin package +manifests and skills. The graph uses virtual server-only paths under +`/__junior_content__`; those paths are ownership identifiers, not public assets +or build-machine filesystem paths. + +`createApp()` hydrates the compiled provider before plugin validation and route +wiring. If the virtual module is absent, CLI, local dev, and non-Nitro runtimes +continue to use the filesystem provider. Plugin package discovery remains +explicit: the compiled provider may include only packages named by the +`defineJuniorPlugins(...)` set passed to `juniorNitro()`. + +Provider-backed reads must preserve the existing runtime boundaries: + +- plugin manifests are parsed and validated through the normal manifest parser +- plugin-owned skill roots still resolve back to their parent plugin before + skill bodies are loaded +- app reference markdown and skill files remain syncable into sandboxes +- SOUL, WORLD, DESCRIPTION, skill bodies, and plugin manifests are never emitted + as public Vercel static assets + +Nitro/Vercel builds must not copy Junior app/plugin content into each function +directory as the runtime source of truth. `juniorNitro({ includeFiles })` is +reserved for explicitly configured non-Junior package assets that a dependency +provider needs at runtime and the bundler cannot trace. + ## Registry Surface ```ts @@ -118,9 +148,9 @@ Trusted agent behavior is initialized from app code, not `plugin.yaml`. Apps export one runtime-safe `defineJuniorPlugins(...)` set and point `juniorNitro({ plugins: "./plugins" })` at it. `juniorNitro()` extracts package -names for build-time copying and emits a virtual module that imports the same -set at runtime. `createApp()` extracts trusted hooks from that virtual module -and validates that every registration has a matching manifest. Trusted +names for compiled content and emits a virtual module that imports the same set +at runtime. `createApp()` extracts trusted hooks from that virtual module and +validates that every registration has a matching manifest. Trusted factories carry their manifest inline, so runtime code is not declared from `plugin.yaml`.