diff --git a/Cargo.lock b/Cargo.lock index fa76fbb0..a2ead53c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "memory-sync-gui" -version = "2026.10324.10015" +version = "2026.10324.11958" dependencies = [ "dirs", "proptest", @@ -4349,7 +4349,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tnmsc" -version = "2026.10324.10015" +version = "2026.10324.11958" dependencies = [ "clap", "dirs", @@ -4369,7 +4369,7 @@ dependencies = [ [[package]] name = "tnmsc-logger" -version = "2026.10324.10015" +version = "2026.10324.11958" dependencies = [ "chrono", "napi", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "tnmsc-md-compiler" -version = "2026.10324.10015" +version = "2026.10324.11958" dependencies = [ "markdown", "napi", @@ -4396,7 +4396,7 @@ dependencies = [ [[package]] name = "tnmsc-script-runtime" -version = "2026.10324.10015" +version = "2026.10324.11958" dependencies = [ "napi", "napi-build", diff --git a/Cargo.toml b/Cargo.toml index 4ca06e65..7ee82c02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "2026.10324.10325" +version = "2026.10324.11958" edition = "2024" license = "AGPL-3.0-only" authors = ["TrueNine"] diff --git a/cli/npm/darwin-arm64/package.json b/cli/npm/darwin-arm64/package.json index 675732ab..5db38fa3 100644 --- a/cli/npm/darwin-arm64/package.json +++ b/cli/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-arm64", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "os": [ "darwin" ], diff --git a/cli/npm/darwin-x64/package.json b/cli/npm/darwin-x64/package.json index e22bdc40..343ca74a 100644 --- a/cli/npm/darwin-x64/package.json +++ b/cli/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-darwin-x64", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "os": [ "darwin" ], diff --git a/cli/npm/linux-arm64-gnu/package.json b/cli/npm/linux-arm64-gnu/package.json index 35c6064d..5944bace 100644 --- a/cli/npm/linux-arm64-gnu/package.json +++ b/cli/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-arm64-gnu", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "os": [ "linux" ], diff --git a/cli/npm/linux-x64-gnu/package.json b/cli/npm/linux-x64-gnu/package.json index 3ad2f63b..9953739b 100644 --- a/cli/npm/linux-x64-gnu/package.json +++ b/cli/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-linux-x64-gnu", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "os": [ "linux" ], diff --git a/cli/npm/win32-x64-msvc/package.json b/cli/npm/win32-x64-msvc/package.json index ff461e83..3e5ad568 100644 --- a/cli/npm/win32-x64-msvc/package.json +++ b/cli/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-cli-win32-x64-msvc", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "os": [ "win32" ], diff --git a/cli/package.json b/cli/package.json index 7b403886..a0da80c8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-cli", "type": "module", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "description": "TrueNine Memory Synchronization CLI", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/cli/src/bridge/node.rs b/cli/src/bridge/node.rs index 0e25d7e9..5a4dee04 100644 --- a/cli/src/bridge/node.rs +++ b/cli/src/bridge/node.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode, Stdio}; +use std::sync::{Mutex, OnceLock}; use crate::{ BridgeCommandResult, CliError, @@ -25,6 +26,41 @@ fn strip_win_prefix(path: PathBuf) -> PathBuf { } const PACKAGE_NAME: &str = "@truenine/memory-sync-cli"; +static PLUGIN_RUNTIME_CACHE: OnceLock>> = OnceLock::new(); +static NODE_CACHE: OnceLock>> = OnceLock::new(); + +fn read_cached_success(cache: &Mutex>) -> Option { + match cache.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } +} + +fn store_cached_success(cache: &Mutex>, value: &T) { + match cache.lock() { + Ok(mut guard) => { + *guard = Some(value.clone()); + } + Err(poisoned) => { + *poisoned.into_inner() = Some(value.clone()); + } + } +} + +fn detect_with_cached_success(cache: &Mutex>, detect: F) -> Option +where + F: FnOnce() -> Option, +{ + if let Some(cached) = read_cached_success(cache) { + return Some(cached); + } + + let detected = detect(); + if let Some(value) = detected.as_ref() { + store_cached_success(cache, value); + } + detected +} /// Locate the plugin runtime JS entry point. /// @@ -37,6 +73,11 @@ const PACKAGE_NAME: &str = "@truenine/memory-sync-cli"; /// 6. npm/pnpm global install: `/@truenine/memory-sync-cli/dist/plugin-runtime.mjs` /// 7. Embedded JS extracted to `~/.aindex/.cache/plugin-runtime-.mjs` pub(crate) fn find_plugin_runtime() -> Option { + let cache = PLUGIN_RUNTIME_CACHE.get_or_init(|| Mutex::new(None)); + detect_with_cached_success(cache, detect_plugin_runtime) +} + +fn detect_plugin_runtime() -> Option { let mut candidates: Vec = Vec::new(); // Relative to binary location @@ -166,6 +207,11 @@ fn extract_embedded_runtime() -> Option { /// Find the `node` executable. pub(crate) fn find_node() -> Option { + let cache = NODE_CACHE.get_or_init(|| Mutex::new(None)); + detect_with_cached_success(cache, detect_node) +} + +fn detect_node() -> Option { // Try `node` in PATH if Command::new("node") .arg("--version") @@ -452,6 +498,8 @@ fn find_index_mjs() -> Option { #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; + use std::sync::Mutex; #[test] fn test_strip_win_prefix_with_prefix() { @@ -473,4 +521,29 @@ mod tests { let result = strip_win_prefix(path.clone()); assert_eq!(result, path); } + + #[test] + fn test_detect_with_cached_success_retries_until_success() { + let cache = Mutex::new(None); + let attempts = Cell::new(0); + + let first = detect_with_cached_success(&cache, || { + attempts.set(attempts.get() + 1); + Option::::None + }); + assert_eq!(first, None); + + let second = detect_with_cached_success(&cache, || { + attempts.set(attempts.get() + 1); + Some(String::from("node")) + }); + assert_eq!(second, Some(String::from("node"))); + + let third = detect_with_cached_success(&cache, || { + attempts.set(attempts.get() + 1); + Some(String::from("other")) + }); + assert_eq!(third, Some(String::from("node"))); + assert_eq!(attempts.get(), 2); + } } diff --git a/cli/src/config.plugins-fast-path.test.ts b/cli/src/config.plugins-fast-path.test.ts new file mode 100644 index 00000000..6dc21219 --- /dev/null +++ b/cli/src/config.plugins-fast-path.test.ts @@ -0,0 +1,50 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {afterEach, describe, expect, it, vi} from 'vitest' + +import {defineConfig} from './config' + +const {collectInputContextMock} = vi.hoisted(() => ({ + collectInputContextMock: vi.fn(async () => { + throw new Error('collectInputContext should not run for plugins fast path') + }) +})) + +vi.mock('./inputs/runtime', async importOriginal => { + const actual = await importOriginal() + + return { + ...actual, + collectInputContext: collectInputContextMock + } +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('defineConfig plugins fast path', () => { + it('skips input collection for plugins runtime commands', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-plugins-fast-path-')) + + try { + const result = await defineConfig({ + loadUserConfig: false, + pipelineArgs: ['node', 'tnmsc', 'plugins', '--json'], + pluginOptions: { + workspaceDir: tempWorkspace, + plugins: [] + } + }) + + expect(collectInputContextMock).not.toHaveBeenCalled() + expect(result.context.workspace.directory.path).toBe(tempWorkspace) + expect(result.context.aindexDir).toBe(path.join(tempWorkspace, 'aindex')) + expect(result.outputPlugins).toEqual([]) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + } + }) +}) diff --git a/cli/src/config.test.ts b/cli/src/config.test.ts index feb57b86..0971dc3f 100644 --- a/cli/src/config.test.ts +++ b/cli/src/config.test.ts @@ -120,6 +120,35 @@ describe('defineConfig', () => { } }) + it('does not run builtin mutating input effects when shorthand plugins is explicitly empty', async () => { + const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-shorthand-empty-plugins-')) + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-shorthand-empty-home-')) + const orphanSkillDir = path.join(tempWorkspace, 'aindex', 'dist', 'skills', 'orphan-skill') + const orphanSkillFile = path.join(orphanSkillDir, 'SKILL.md') + + process.env.HOME = tempHome + process.env.USERPROFILE = tempHome + delete process.env.HOMEDRIVE + delete process.env.HOMEPATH + + fs.mkdirSync(orphanSkillDir, {recursive: true}) + fs.writeFileSync(orphanSkillFile, 'orphan\n', 'utf8') + + try { + const result = await defineConfig({ + workspaceDir: tempWorkspace, + plugins: [] + }) + + expect(result.context.workspace.directory.path).toBe(tempWorkspace) + expect(fs.existsSync(orphanSkillFile)).toBe(true) + } + finally { + fs.rmSync(tempWorkspace, {recursive: true, force: true}) + fs.rmSync(tempHome, {recursive: true, force: true}) + } + }) + it('accepts legacy input capabilities in pluginOptions.plugins without crashing', async () => { const tempWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-legacy-input-capabilities-')) diff --git a/cli/src/config.ts b/cli/src/config.ts index dd081861..a242c1b0 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -14,14 +14,18 @@ import type { UserConfigFile, WindowsOptions } from './plugins/plugin-core' +import * as path from 'node:path' import {checkVersionControl} from './Aindex' import {getConfigLoader} from './ConfigLoader' -import {collectInputContext} from './inputs/runtime' +import {collectInputContext, resolveRuntimeCommand} from './inputs/runtime' import { createLogger, + FilePathKind, + PathPlaceholders, toOutputCollectedContext, validateOutputScopeOverridesForPlugins } from './plugins/plugin-core' +import {resolveUserPath} from './runtime-environment' /** * Pipeline configuration containing collected context and output plugins @@ -32,6 +36,13 @@ export interface PipelineConfig { readonly userConfigOptions: Required } +interface ResolvedPluginSetup { + readonly mergedOptions: Required + readonly outputPlugins: readonly OutputPlugin[] + readonly inputCapabilities: readonly InputCapability[] + readonly userConfigFile?: UserConfigFile +} + function isOutputPlugin(plugin: InputCapability | OutputPlugin): plugin is OutputPlugin { return 'declarativeOutput' in plugin } @@ -287,18 +298,68 @@ function isDefineConfigOptions(options: PluginOptions | DefineConfigOptions): op || 'pipelineArgs' in options } -/** - * Define configuration with support for user config files. - * - * Configuration priority (highest to lowest): - * 1. Programmatic options passed to defineConfig - * 2. Global config file (~/.aindex/.tnmsc.json) - * 3. Default values - * - * @param options - Plugin options or DefineConfigOptions - */ -export async function defineConfig(options: PluginOptions | DefineConfigOptions = {}): Promise { - let shouldLoadUserConfig: boolean, // Normalize options +function getProgrammaticPluginDeclaration( + options: PluginOptions | DefineConfigOptions +): { + readonly hasExplicitProgrammaticPlugins: boolean + readonly explicitProgrammaticPlugins?: PluginOptions['plugins'] +} { + if (isDefineConfigOptions(options)) { + return { + hasExplicitProgrammaticPlugins: Object.hasOwn(options.pluginOptions ?? {}, 'plugins'), + explicitProgrammaticPlugins: options.pluginOptions?.plugins + } + } + + return { + hasExplicitProgrammaticPlugins: Object.hasOwn(options, 'plugins'), + explicitProgrammaticPlugins: options.plugins + } +} + +function resolvePathForMinimalContext(rawPath: string, workspaceDir: string): string { + let resolvedPath = rawPath + + if (resolvedPath.includes(PathPlaceholders.WORKSPACE)) { + resolvedPath = resolvedPath.replace(PathPlaceholders.WORKSPACE, workspaceDir) + } + + return path.normalize(resolveUserPath(resolvedPath)) +} + +function createMinimalOutputCollectedContext( + options: Required +): OutputCollectedContext { + const workspaceDir = resolvePathForMinimalContext(options.workspaceDir, '') + const aindexDir = path.join(workspaceDir, options.aindex.dir) + + return toOutputCollectedContext({ + workspace: { + directory: { + pathKind: FilePathKind.Absolute, + path: workspaceDir, + getDirectoryName: () => path.basename(workspaceDir) + }, + projects: [] + }, + aindexDir + }) +} + +function shouldUsePluginsFastPath(pipelineArgs?: readonly string[]): boolean { + return resolveRuntimeCommand(pipelineArgs) === 'plugins' +} + +async function resolvePluginSetup( + options: PluginOptions | DefineConfigOptions = {} +): Promise< + ResolvedPluginSetup & { + readonly pipelineArgs?: readonly string[] + readonly userConfigFound: boolean + readonly userConfigSources: readonly string[] + } +> { + let shouldLoadUserConfig: boolean, cwd: string | undefined, pluginOptions: PluginOptions, configLoaderOptions: ConfigLoaderOptions | undefined, @@ -324,9 +385,7 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions pipelineArgs = void 0 } - const hasExplicitProgrammaticPlugins = Object.hasOwn(pluginOptions, 'plugins') - const explicitProgrammaticPlugins = pluginOptions.plugins - let userConfigOptions: Partial = {} // Load user config if enabled + let userConfigOptions: Partial = {} let userConfigFound = false let userConfigSources: readonly string[] = [] let userConfigFile: UserConfigFile | undefined @@ -347,12 +406,13 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions } } - const mergedOptions = mergeConfig(userConfigOptions, pluginOptions) // Merge: defaults <- user config <- programmatic options + const mergedOptions = mergeConfig(userConfigOptions, pluginOptions) const {plugins = [], logLevel} = mergedOptions const logger = createLogger('defineConfig', logLevel) - if (userConfigFound) logger.info('user config loaded', {sources: userConfigSources}) - else { + if (userConfigFound) { + logger.info('user config loaded', {sources: userConfigSources}) + } else { logger.info('no user config found, using defaults/programmatic options', { workspaceDir: mergedOptions.workspaceDir, aindexDir: mergedOptions.aindex.dir, @@ -364,6 +424,46 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions const inputCapabilities = plugins.filter(isInputCapability) validateOutputScopeOverridesForPlugins(outputPlugins, mergedOptions) + return { + mergedOptions, + outputPlugins, + inputCapabilities, + ...userConfigFile != null && {userConfigFile}, + ...pipelineArgs != null && {pipelineArgs}, + userConfigFound, + userConfigSources + } +} + +/** + * Define configuration with support for user config files. + * + * Configuration priority (highest to lowest): + * 1. Programmatic options passed to defineConfig + * 2. Global config file (~/.aindex/.tnmsc.json) + * 3. Default values + * + * @param options - Plugin options or DefineConfigOptions + */ +export async function defineConfig(options: PluginOptions | DefineConfigOptions = {}): Promise { + const { + hasExplicitProgrammaticPlugins, + explicitProgrammaticPlugins + } = getProgrammaticPluginDeclaration(options) + const { + mergedOptions, + outputPlugins, + inputCapabilities, + userConfigFile, + pipelineArgs + } = await resolvePluginSetup(options) + const logger = createLogger('defineConfig', mergedOptions.logLevel) + + if (shouldUsePluginsFastPath(pipelineArgs)) { + const context = createMinimalOutputCollectedContext(mergedOptions) + return {context, outputPlugins, userConfigOptions: mergedOptions} + } + const merged = await collectInputContext({ userConfigOptions: mergedOptions, ...inputCapabilities.length > 0 ? {capabilities: inputCapabilities} : {}, @@ -372,7 +472,7 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions ...userConfigFile != null ? {userConfig: userConfigFile} : {} }) - if (merged.workspace == null) throw new Error('Workspace not initialized by any plugin') // Validate workspace exists + if (merged.workspace == null) throw new Error('Workspace not initialized by any plugin') const inputContext: InputCollectedContext = { workspace: merged.workspace, @@ -393,7 +493,9 @@ export async function defineConfig(options: PluginOptions | DefineConfigOptions const context = toOutputCollectedContext(inputContext) - if (merged.aindexDir != null) checkVersionControl(merged.aindexDir, logger) // Check version control status for aindex + if (merged.aindexDir != null) { + checkVersionControl(merged.aindexDir, logger) + } return {context, outputPlugins, userConfigOptions: mergedOptions} } diff --git a/cli/src/core/config/mod.rs b/cli/src/core/config/mod.rs index a3edda83..5129bdbc 100644 --- a/cli/src/core/config/mod.rs +++ b/cli/src/core/config/mod.rs @@ -331,7 +331,10 @@ fn resolve_preferred_wsl_host_home_dirs_for( .into_iter() .flatten() { - if !preferred_home_dirs.iter().any(|existing| existing == &candidate) { + if !preferred_home_dirs + .iter() + .any(|existing| existing == &candidate) + { preferred_home_dirs.push(candidate); } } @@ -440,12 +443,19 @@ fn build_required_wsl_config_resolution_error(users_root: &Path) -> String { ) } -fn is_wsl_runtime_for(os_name: &str, wsl_distro_name: Option<&str>, wsl_interop: Option<&str>, release: &str) -> bool { +fn is_wsl_runtime_for( + os_name: &str, + wsl_distro_name: Option<&str>, + wsl_interop: Option<&str>, + release: &str, +) -> bool { if os_name != "linux" { return false; } - if wsl_distro_name.is_some_and(|value| !value.is_empty()) || wsl_interop.is_some_and(|value| !value.is_empty()) { + if wsl_distro_name.is_some_and(|value| !value.is_empty()) + || wsl_interop.is_some_and(|value| !value.is_empty()) + { return true; } @@ -1255,7 +1265,10 @@ mod tests { .and_then(|wsl2| wsl2.instances) { Some(StringOrStrings::Single(instance)) => assert_eq!(instance, "Ubuntu"), - other => panic!("expected merged windows.wsl2.instances value, got {:?}", other), + other => panic!( + "expected merged windows.wsl2.instances value, got {:?}", + other + ), } } diff --git a/cli/src/inputs/input-agentskills.ts b/cli/src/inputs/input-agentskills.ts index 8ade7018..129a430c 100644 --- a/cli/src/inputs/input-agentskills.ts +++ b/cli/src/inputs/input-agentskills.ts @@ -1,3 +1,4 @@ +import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' import type {Dirent} from 'node:fs' import type { ILogger, @@ -15,8 +16,7 @@ import type {ResourceScanResult} from './input-agentskills-types' import {Buffer} from 'node:buffer' import * as nodePath from 'node:path' -import {mdxToMd} from '@truenine/md-compiler' -import {parseMarkdown, transformMdxReferencesToMd} from '@truenine/md-compiler/markdown' +import {transformMdxReferencesToMd} from '@truenine/md-compiler/markdown' import { buildConfigDiagnostic, buildDiagnostic, @@ -35,6 +35,7 @@ import { validateSkillMetadata } from '../plugins/plugin-core' import {assertNoResidualModuleSyntax, MissingCompiledPromptError} from '../plugins/plugin-core/DistPromptGuards' +import {readPromptArtifact} from '../plugins/plugin-core/PromptArtifactCache' import { formatPromptCompilerDiagnostic, resolveSourcePathForDistFile @@ -287,15 +288,11 @@ class ResourceProcessor { private async processChildDoc(relativePath: string, filePath: string): Promise { try { - const rawContent = this.ctx.fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - const compileResult = await mdxToMd(rawContent, { - globalScope: this.ctx.globalScope, - extractMetadata: true, - basePath: nodePath.dirname(filePath), - filePath + const artifact = await readPromptArtifact(filePath, { + mode: 'dist', + globalScope: this.ctx.globalScope }) - const compiledContent = transformMdxReferencesToMd(compileResult.content) + const compiledContent = transformMdxReferencesToMd(artifact.content) assertNoResidualModuleSyntax(compiledContent, filePath) return { @@ -303,9 +300,9 @@ class ResourceProcessor { content: compiledContent, length: compiledContent.length, filePathKind: FilePathKind.Relative, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, + markdownAst: artifact.parsed.markdownAst, + markdownContents: artifact.parsed.markdownContents, + ...artifact.parsed.rawFrontMatter != null && {rawFrontMatter: artifact.parsed.rawFrontMatter}, relativePath, dir: { pathKind: FilePathKind.Relative, @@ -595,30 +592,26 @@ async function createSkillPrompt( compiledMetadata?: Record, warnedDerivedNames?: Set ): Promise { - const {logger, globalScope, fs} = ctx + const {logger, fs} = ctx const distFilePath = nodePath.join(skillAbsoluteDir, 'skill.mdx') const sourceFilePath = fs.existsSync(nodePath.join(sourceSkillAbsoluteDir, 'skill.src.mdx')) ? nodePath.join(sourceSkillAbsoluteDir, 'skill.src.mdx') : distFilePath let rawContent = content - let parsed: ReturnType> | undefined, + let parsed: ParsedMarkdown | undefined, distMetadata: Record | undefined if (fs.existsSync(distFilePath)) { - rawContent = fs.readFileSync(distFilePath, 'utf8') - parsed = parseMarkdown(rawContent) - - const compileResult = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: skillAbsoluteDir, - filePath: distFilePath + const artifact = await readPromptArtifact(distFilePath, { + mode: 'dist', + globalScope: ctx.globalScope }) - - content = transformMdxReferencesToMd(compileResult.content) + rawContent = artifact.rawMdx + parsed = artifact.parsed as ParsedMarkdown + content = transformMdxReferencesToMd(artifact.content) assertNoResidualModuleSyntax(content, distFilePath) - distMetadata = compileResult.metadata.fields + distMetadata = artifact.metadata } const exportMetadata = mergeDefinedSkillMetadata( @@ -790,6 +783,7 @@ export class SkillInputCapability extends AbstractInputCapability { kind: PromptKind.Skill, entryFileName: 'skill', localeExtensions: SourceLocaleExtensions, + hydrateSourceContents: false, isDirectoryStructure: true, createPrompt: async (content, locale, name, metadata) => { const skillDistDir = pathModule.join(distSkillDir, name) diff --git a/cli/src/inputs/input-command.ts b/cli/src/inputs/input-command.ts index 148d6314..69026663 100644 --- a/cli/src/inputs/input-command.ts +++ b/cli/src/inputs/input-command.ts @@ -95,6 +95,7 @@ export class CommandInputCapability extends AbstractInputCapability { { kind: PromptKind.Command, localeExtensions: SourceLocaleExtensions, + hydrateSourceContents: false, isDirectoryStructure: false, createPrompt: (content, locale, name, metadata) => this.createCommandPrompt( content, diff --git a/cli/src/inputs/input-global-memory.ts b/cli/src/inputs/input-global-memory.ts index 3b5de5dd..c23faf34 100644 --- a/cli/src/inputs/input-global-memory.ts +++ b/cli/src/inputs/input-global-memory.ts @@ -2,9 +2,7 @@ import type {InputCapabilityContext, InputCollectedContext} from '../plugins/plu import process from 'node:process' -import {mdxToMd} from '@truenine/md-compiler' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' import {getGlobalConfigPath} from '@/ConfigLoader' import { buildConfigDiagnostic, @@ -15,6 +13,7 @@ import { import {getEffectiveHomeDir} from '@/runtime-environment' import {AbstractInputCapability, FilePathKind, GlobalConfigDirectoryType, PromptKind} from '../plugins/plugin-core' import {assertNoResidualModuleSyntax} from '../plugins/plugin-core/DistPromptGuards' +import {readPromptArtifact} from '../plugins/plugin-core/PromptArtifactCache' import {formatPromptCompilerDiagnostic} from '../plugins/plugin-core/PromptCompilerDiagnostics' export class GlobalMemoryInputCapability extends AbstractInputCapability { @@ -52,18 +51,14 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability { return {} } - const rawContent = fs.readFileSync(globalMemoryFile, 'utf8') - const parsed = parseMarkdown(rawContent) - - let compiledContent: string + let compiledContent: string, + artifact: Awaited> try { - const compileResult = await mdxToMd(rawContent, { - ...globalScope != null && {globalScope}, - extractMetadata: true, - basePath: path.dirname(globalMemoryFile), - filePath: globalMemoryFile + artifact = await readPromptArtifact(globalMemoryFile, { + mode: 'dist', + globalScope }) - compiledContent = compileResult.content + compiledContent = artifact.content assertNoResidualModuleSyntax(compiledContent, globalMemoryFile) } catch (e) { @@ -115,9 +110,9 @@ export class GlobalMemoryInputCapability extends AbstractInputCapability { content: compiledContent, length: compiledContent.length, filePathKind: FilePathKind.Relative, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, + ...artifact.parsed.rawFrontMatter != null && {rawFrontMatter: artifact.parsed.rawFrontMatter}, + markdownAst: artifact.parsed.markdownAst, + markdownContents: artifact.parsed.markdownContents, dir: { pathKind: FilePathKind.Relative, path: path.basename(globalMemoryFile), diff --git a/cli/src/inputs/input-project-prompt.ts b/cli/src/inputs/input-project-prompt.ts index 44ee79e6..3b9625b7 100644 --- a/cli/src/inputs/input-project-prompt.ts +++ b/cli/src/inputs/input-project-prompt.ts @@ -9,9 +9,7 @@ import type { import process from 'node:process' -import {mdxToMd} from '@truenine/md-compiler' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' -import {parseMarkdown} from '@truenine/md-compiler/markdown' import {getGlobalConfigPath} from '@/ConfigLoader' import { buildConfigDiagnostic, @@ -21,6 +19,7 @@ import { } from '@/diagnostics' import {AbstractInputCapability, FilePathKind, PromptKind, WORKSPACE_ROOT_PROJECT_NAME} from '../plugins/plugin-core' import {assertNoResidualModuleSyntax} from '../plugins/plugin-core/DistPromptGuards' +import {readPromptArtifact} from '../plugins/plugin-core/PromptArtifactCache' import {formatPromptCompilerDiagnostic} from '../plugins/plugin-core/PromptCompilerDiagnostics' const PROJECT_MEMORY_FILE = 'agt.mdx' @@ -91,24 +90,18 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { globalScope: InputCapabilityContext['globalScope'], projectConfig: Project['projectConfig'] ): Promise { - const {fs, path, logger} = ctx + const {fs, logger} = ctx if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return try { - const rawContent = fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - - let content: string + let artifact: Awaited> try { - const {content: compiledContent} = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: path.dirname(filePath), - filePath + artifact = await readPromptArtifact(filePath, { + mode: 'dist', + globalScope }) - content = compiledContent - assertNoResidualModuleSyntax(content, filePath) + assertNoResidualModuleSyntax(artifact.content, filePath) } catch (e) { if (e instanceof CompilerDiagnosticError) { @@ -151,13 +144,13 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { const rootMemoryPrompt: ProjectRootMemoryPrompt = { type: PromptKind.ProjectRootMemory, - content, - length: content.length, + content: artifact.content, + length: artifact.content.length, filePathKind: FilePathKind.Relative, - ...parsed.yamlFrontMatter != null && {yamlFrontMatter: parsed.yamlFrontMatter}, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, + ...artifact.parsed.yamlFrontMatter != null && {yamlFrontMatter: artifact.parsed.yamlFrontMatter as YAMLFrontMatter}, + ...artifact.parsed.rawFrontMatter != null && {rawFrontMatter: artifact.parsed.rawFrontMatter}, + markdownAst: artifact.parsed.markdownAst, + markdownContents: artifact.parsed.markdownContents, dir: { pathKind: FilePathKind.Root, path: '', @@ -202,19 +195,13 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return try { - const rawContent = fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - - let content: string + let artifact: Awaited> try { - const {content: compiledContent} = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: projectPath, - filePath + artifact = await readPromptArtifact(filePath, { + mode: 'dist', + globalScope }) - content = compiledContent - assertNoResidualModuleSyntax(content, filePath) + assertNoResidualModuleSyntax(artifact.content, filePath) } catch (e) { if (e instanceof CompilerDiagnosticError) { @@ -257,13 +244,13 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { return { type: PromptKind.ProjectRootMemory, - content, - length: content.length, + content: artifact.content, + length: artifact.content.length, filePathKind: FilePathKind.Relative, - ...parsed.yamlFrontMatter != null && {yamlFrontMatter: parsed.yamlFrontMatter}, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, + ...artifact.parsed.yamlFrontMatter != null && {yamlFrontMatter: artifact.parsed.yamlFrontMatter as YAMLFrontMatter}, + ...artifact.parsed.rawFrontMatter != null && {rawFrontMatter: artifact.parsed.rawFrontMatter}, + markdownAst: artifact.parsed.markdownAst, + markdownContents: artifact.parsed.markdownContents, dir: { pathKind: FilePathKind.Root, path: '', @@ -345,23 +332,17 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { targetProjectPath: string, globalScope: InputCapabilityContext['globalScope'] ): Promise { - const {fs, path, logger} = ctx + const {path, logger} = ctx const filePath = path.join(shadowChildDir, PROJECT_MEMORY_FILE) try { - const rawContent = fs.readFileSync(filePath, 'utf8') - const parsed = parseMarkdown(rawContent) - - let content: string + let artifact: Awaited> try { - const {content: compiledContent} = await mdxToMd(rawContent, { - globalScope, - extractMetadata: true, - basePath: shadowChildDir, - filePath + artifact = await readPromptArtifact(filePath, { + mode: 'dist', + globalScope }) - content = compiledContent - assertNoResidualModuleSyntax(content, filePath) + assertNoResidualModuleSyntax(artifact.content, filePath) } catch (e) { if (e instanceof CompilerDiagnosticError) { @@ -408,13 +389,13 @@ export class ProjectPromptInputCapability extends AbstractInputCapability { return { type: PromptKind.ProjectChildrenMemory, - content, - length: content.length, + content: artifact.content, + length: artifact.content.length, filePathKind: FilePathKind.Relative, - ...parsed.yamlFrontMatter != null && {yamlFrontMatter: parsed.yamlFrontMatter}, - ...parsed.rawFrontMatter != null && {rawFrontMatter: parsed.rawFrontMatter}, - markdownAst: parsed.markdownAst, - markdownContents: parsed.markdownContents, + ...artifact.parsed.yamlFrontMatter != null && {yamlFrontMatter: artifact.parsed.yamlFrontMatter as YAMLFrontMatter}, + ...artifact.parsed.rawFrontMatter != null && {rawFrontMatter: artifact.parsed.rawFrontMatter}, + markdownAst: artifact.parsed.markdownAst, + markdownContents: artifact.parsed.markdownContents, dir: { pathKind: FilePathKind.Relative, path: relativePath, diff --git a/cli/src/inputs/input-readme.ts b/cli/src/inputs/input-readme.ts index 086f9098..1e7cfe2a 100644 --- a/cli/src/inputs/input-readme.ts +++ b/cli/src/inputs/input-readme.ts @@ -2,7 +2,6 @@ import type {InputCapabilityContext, InputCollectedContext, ReadmeFileKind, Read import process from 'node:process' -import {mdxToMd} from '@truenine/md-compiler' import {CompilerDiagnosticError, ScopeError} from '@truenine/md-compiler/errors' import {getGlobalConfigPath} from '@/ConfigLoader' import { @@ -13,6 +12,7 @@ import { } from '@/diagnostics' import {AbstractInputCapability, FilePathKind, PromptKind, README_FILE_KIND_MAP} from '../plugins/plugin-core' import {assertNoResidualModuleSyntax} from '../plugins/plugin-core/DistPromptGuards' +import {readPromptArtifact} from '../plugins/plugin-core/PromptArtifactCache' import {formatPromptCompilerDiagnostic} from '../plugins/plugin-core/PromptCompilerDiagnostics' const ALL_FILE_KINDS = Object.entries(README_FILE_KIND_MAP) as [ReadmeFileKind, {src: string, out: string}][] @@ -86,16 +86,13 @@ export class ReadmeMdInputCapability extends AbstractInputCapability { if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) continue try { - const rawContent = fs.readFileSync(filePath, 'utf8') - let content: string try { - const {content: compiledContent} = await mdxToMd(rawContent, { - ...globalScope != null && {globalScope}, - extractMetadata: true, - basePath: currentDir, - filePath + const artifact = await readPromptArtifact(filePath, { + mode: 'dist', + globalScope }) + const {content: compiledContent} = artifact content = compiledContent assertNoResidualModuleSyntax(content, filePath) } diff --git a/cli/src/inputs/input-rule.ts b/cli/src/inputs/input-rule.ts index 303e5017..e0810657 100644 --- a/cli/src/inputs/input-rule.ts +++ b/cli/src/inputs/input-rule.ts @@ -35,6 +35,7 @@ export class RuleInputCapability extends AbstractInputCapability { { kind: PromptKind.Rule, localeExtensions: SourceLocaleExtensions, + hydrateSourceContents: false, isDirectoryStructure: false, createPrompt: async (content, _locale, name, metadata) => { const yamlFrontMatter = metadata as RuleYAMLFrontMatter | undefined diff --git a/cli/src/inputs/input-subagent.ts b/cli/src/inputs/input-subagent.ts index bec3b96b..ebbc0b06 100644 --- a/cli/src/inputs/input-subagent.ts +++ b/cli/src/inputs/input-subagent.ts @@ -120,6 +120,7 @@ export class SubAgentInputCapability extends AbstractInputCapability { { kind: PromptKind.SubAgent, localeExtensions: SourceLocaleExtensions, + hydrateSourceContents: false, isDirectoryStructure: false, createPrompt: (content, locale, name, metadata) => this.createSubAgentPrompt( content, diff --git a/cli/src/inputs/runtime.ts b/cli/src/inputs/runtime.ts index c09f0e46..25da33a7 100644 --- a/cli/src/inputs/runtime.ts +++ b/cli/src/inputs/runtime.ts @@ -71,7 +71,7 @@ function createBuiltinInputReaderCapabilities(): InputCapability[] { ] } -function resolveRuntimeCommand( +export function resolveRuntimeCommand( pipelineArgs?: readonly string[] ): InputCapabilityContext['runtimeCommand'] { if (pipelineArgs == null || pipelineArgs.length === 0) return 'execute' diff --git a/cli/src/plugins/plugin-core.ts b/cli/src/plugins/plugin-core.ts index dd2c5c74..8e121e34 100644 --- a/cli/src/plugins/plugin-core.ts +++ b/cli/src/plugins/plugin-core.ts @@ -106,6 +106,12 @@ export type { TransformedMcpConfig } from './plugin-core/McpConfigManager' +export { + clearPromptArtifactCache, + compileRawPromptArtifact, + readPromptArtifact +} from './plugin-core/PromptArtifactCache' + export { deriveSubAgentIdentity, flattenPromptPath, diff --git a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts index 6100c915..504d1e4c 100644 --- a/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts +++ b/cli/src/plugins/plugin-core/AbstractOutputPlugin.ts @@ -6,7 +6,7 @@ import type {CommandPrompt, CommandSeriesPluginOverride, ILogger, OutputCleanCon import {Buffer} from 'node:buffer' import * as path from 'node:path' import process from 'node:process' -import {buildPromptTomlArtifact, mdxToMd} from '@truenine/md-compiler' +import {buildPromptTomlArtifact} from '@truenine/md-compiler' import {buildMarkdownWithFrontMatter, buildMarkdownWithRawFrontMatter} from '@truenine/md-compiler/markdown' import {buildConfigDiagnostic, diagnosticLines} from '@/diagnostics' import {getEffectiveHomeDir} from '@/runtime-environment' @@ -17,6 +17,7 @@ import { filterByProjectConfig } from './filters' import {GlobalScopeCollector} from './GlobalScopeCollector' +import {compileRawPromptArtifact} from './PromptArtifactCache' import {resolveSkillName, resolveSubAgentCanonicalName} from './PromptIdentity' import {resolveTopicScopes} from './scopePolicy' import {OUTPUT_SCOPE_TOPICS} from './types' @@ -1332,14 +1333,13 @@ export abstract class AbstractOutputPlugin extends AbstractPlugin implements Out }) const scopeCollector = new GlobalScopeCollector({toolPreset: this.toolPreset}) const globalScope = scopeCollector.collect() - const result = await mdxToMd(cmd.rawMdxContent, { + const result = await compileRawPromptArtifact({ + filePath: cmd.dir.getAbsolutePath(), globalScope, - extractMetadata: true, - basePath: cmd.dir.basePath, - filePath: cmd.dir.getAbsolutePath() + rawMdx: cmd.rawMdxContent }) compiledContent = result.content - compiledFrontMatter = result.metadata.fields as typeof cmd.yamlFrontMatter + compiledFrontMatter = result.metadata as typeof cmd.yamlFrontMatter useRecompiledFrontMatter = true } diff --git a/cli/src/plugins/plugin-core/InputTypes.ts b/cli/src/plugins/plugin-core/InputTypes.ts index 25b9d90f..db55dbda 100644 --- a/cli/src/plugins/plugin-core/InputTypes.ts +++ b/cli/src/plugins/plugin-core/InputTypes.ts @@ -337,6 +337,9 @@ export interface LocalizedReadOptions { /** Entry file name (without extension, e.g., 'skill' for skills) */ readonly entryFileName?: string + /** Whether source contents should be hydrated and compiled in addition to dist */ + readonly hydrateSourceContents?: boolean + /** Create prompt from content */ readonly createPrompt: (content: string, locale: Locale, name: string, metadata?: Record) => T | Promise diff --git a/cli/src/plugins/plugin-core/LocalizedPromptReader.ts b/cli/src/plugins/plugin-core/LocalizedPromptReader.ts index 4a2bdc7c..6765aa08 100644 --- a/cli/src/plugins/plugin-core/LocalizedPromptReader.ts +++ b/cli/src/plugins/plugin-core/LocalizedPromptReader.ts @@ -13,8 +13,6 @@ import type { PromptKind, ReadError } from './types' -import {mdxToMd} from '@truenine/md-compiler' -import {parseMarkdown} from '@truenine/md-compiler/markdown' // Re-export types for convenience import { buildDiagnostic, buildFileOperationDiagnostic, @@ -26,6 +24,7 @@ import { MissingCompiledPromptError, ResidualModuleSyntaxError } from './DistPromptGuards' +import {readPromptArtifact} from './PromptArtifactCache' import { formatPromptCompilerDiagnostic, resolveSourcePathForDistFile @@ -314,6 +313,7 @@ export class LocalizedPromptReader { isDirectoryStructure = true ): Promise | null> { const {localeExtensions, entryFileName, createPrompt, kind} = options + const hydrateSourceContents = options.hydrateSourceContents ?? true const baseFileName = entryFileName ?? name const zhExtensions = this.normalizeExtensions(localeExtensions.zh) @@ -321,9 +321,11 @@ export class LocalizedPromptReader { const srcZhPath = this.resolveLocalizedPath(srcEntryDir, baseFileName, zhExtensions) const srcEnPath = this.resolveLocalizedPath(srcEntryDir, baseFileName, enExtensions) const distPath = this.path.join(distEntryDir, `${baseFileName}.mdx`) - const existingSourcePath = this.exists(srcZhPath) + const hasSourceZh = this.exists(srcZhPath) + const hasSourceEn = this.exists(srcEnPath) + const existingSourcePath = hasSourceZh ? srcZhPath - : this.exists(srcEnPath) + : hasSourceEn ? srcEnPath : void 0 const diagnosticContext: ReaderDiagnosticContext = { @@ -334,15 +336,17 @@ export class LocalizedPromptReader { } const distContent = await this.readDistContent(distPath, createPrompt, name, diagnosticContext) - const zhContent = await this.readLocaleContent(srcZhPath, 'zh', createPrompt, name, String(kind)) - const enContent = await this.readLocaleContent(srcEnPath, 'en', createPrompt, name, String(kind)) + const zhContent = hasSourceZh && hydrateSourceContents + ? await this.readLocaleContent(srcZhPath, 'zh', createPrompt, name, String(kind)) + : null + const enContent = hasSourceEn && hydrateSourceContents + ? await this.readLocaleContent(srcEnPath, 'en', createPrompt, name, String(kind)) + : null const hasDist = distContent != null - const hasSrcZh = zhContent != null - const hasSrcEn = enContent != null - const sourcePath = hasSrcZh ? srcZhPath : hasSrcEn ? srcEnPath : void 0 + const sourcePath = hasSourceZh ? srcZhPath : hasSourceEn ? srcEnPath : void 0 - if (!hasDist && !hasSrcZh && !hasSrcEn) { + if (!hasDist && !hasSourceZh && !hasSourceEn) { this.logger.warn(buildDiagnostic({ code: 'LOCALIZED_PROMPT_ARTIFACTS_MISSING', title: `Missing source and dist prompt artifacts for ${name}`, @@ -372,10 +376,10 @@ export class LocalizedPromptReader { }) } - const src: LocalizedPrompt['src'] = hasSrcZh + const src: LocalizedPrompt['src'] = hydrateSourceContents && zhContent != null ? { zh: zhContent, - ...hasSrcEn && {en: enContent}, + ...enContent != null && {en: enContent}, default: zhContent, defaultLocale: 'zh' } @@ -392,13 +396,13 @@ export class LocalizedPromptReader { ...hasDist && {dist: distContent}, metadata: { hasDist, - hasMultipleLocales: hasSrcEn, + hasMultipleLocales: hasSourceEn, isDirectoryStructure, ...children && children.length > 0 && {children} }, paths: { - ...hasSrcZh && {zh: srcZhPath}, - ...hasSrcEn && {en: srcEnPath}, + ...hasSourceZh && {zh: srcZhPath}, + ...hasSourceEn && {en: srcEnPath}, ...hasDist && {dist: distPath} } } @@ -416,6 +420,7 @@ export class LocalizedPromptReader { isSingleFile = false ): Promise | null> { const {localeExtensions, createPrompt, kind} = options + const hydrateSourceContents = options.hydrateSourceContents ?? true const zhExtensions = this.normalizeExtensions(localeExtensions.zh) const enExtensions = this.normalizeExtensions(localeExtensions.en) @@ -425,9 +430,11 @@ export class LocalizedPromptReader { const fullSrcZhPath = isSingleFile ? srcZhPath : this.path.join(srcDir, srcZhPath) const fullSrcEnPath = isSingleFile ? srcEnPath : this.path.join(srcDir, srcEnPath) - const existingSourcePath = this.exists(fullSrcZhPath) + const hasSourceZh = this.exists(fullSrcZhPath) + const hasSourceEn = this.exists(fullSrcEnPath) + const existingSourcePath = hasSourceZh ? fullSrcZhPath - : this.exists(fullSrcEnPath) + : hasSourceEn ? fullSrcEnPath : void 0 const diagnosticContext: ReaderDiagnosticContext = { @@ -438,15 +445,17 @@ export class LocalizedPromptReader { } const distContent = await this.readDistContent(distPath, createPrompt, name, diagnosticContext) - const zhContent = await this.readLocaleContent(fullSrcZhPath, 'zh', createPrompt, name, String(kind)) - const enContent = await this.readLocaleContent(fullSrcEnPath, 'en', createPrompt, name, String(kind)) + const zhContent = hasSourceZh && hydrateSourceContents + ? await this.readLocaleContent(fullSrcZhPath, 'zh', createPrompt, name, String(kind)) + : null + const enContent = hasSourceEn && hydrateSourceContents + ? await this.readLocaleContent(fullSrcEnPath, 'en', createPrompt, name, String(kind)) + : null const hasDist = distContent != null - const hasSrcZh = zhContent != null - const hasSrcEn = enContent != null - const sourcePath = hasSrcZh ? fullSrcZhPath : hasSrcEn ? fullSrcEnPath : void 0 + const sourcePath = hasSourceZh ? fullSrcZhPath : hasSourceEn ? fullSrcEnPath : void 0 - if (!hasDist && !hasSrcZh && !hasSrcEn) { + if (!hasDist && !hasSourceZh && !hasSourceEn) { this.logger.warn(buildDiagnostic({ code: 'LOCALIZED_PROMPT_ARTIFACTS_MISSING', title: `Missing source and dist prompt artifacts for ${name}`, @@ -476,10 +485,10 @@ export class LocalizedPromptReader { }) } - const src: LocalizedPrompt['src'] = hasSrcZh + const src: LocalizedPrompt['src'] = hydrateSourceContents && zhContent != null ? { zh: zhContent, - ...hasSrcEn && {en: enContent}, + ...enContent != null && {en: enContent}, default: zhContent, defaultLocale: 'zh' } @@ -492,12 +501,12 @@ export class LocalizedPromptReader { ...hasDist && {dist: distContent}, metadata: { hasDist, - hasMultipleLocales: hasSrcEn, + hasMultipleLocales: hasSourceEn, isDirectoryStructure: false }, paths: { - ...hasSrcZh && {zh: fullSrcZhPath}, - ...hasSrcEn && {en: fullSrcEnPath}, + ...hasSourceZh && {zh: fullSrcZhPath}, + ...hasSourceEn && {en: fullSrcEnPath}, ...hasDist && {dist: distPath} } } @@ -513,31 +522,24 @@ export class LocalizedPromptReader { if (!this.exists(filePath)) return null try { - const rawMdx = this.fs.readFileSync(filePath, 'utf8') - const stats = this.fs.statSync(filePath) - - const compileResult = await mdxToMd(rawMdx, { // Compile MDX to Markdown - globalScope: this.globalScope, - extractMetadata: true, - basePath: this.path.dirname(filePath), - filePath + const artifact = await readPromptArtifact(filePath, { + mode: 'source', + globalScope: this.globalScope }) - assertNoResidualModuleSyntax(compileResult.content, filePath) + assertNoResidualModuleSyntax(artifact.content, filePath) - const parsed = parseMarkdown(rawMdx) // Parse front matter - - const prompt = await createPrompt(compileResult.content, locale, name, compileResult.metadata.fields) // Create prompt object + const prompt = await createPrompt(artifact.content, locale, name, artifact.metadata) const result: LocalizedContent = { - content: compileResult.content, - lastModified: stats.mtime, + content: artifact.content, + lastModified: artifact.lastModified, filePath } - if (rawMdx.length > 0) { // Add optional fields only if they exist - Object.assign(result, {rawMdx}) + if (artifact.rawMdx.length > 0) { + Object.assign(result, {rawMdx: artifact.rawMdx}) } - if (parsed.yamlFrontMatter != null) Object.assign(result, {frontMatter: parsed.yamlFrontMatter}) + if (artifact.parsed.yamlFrontMatter != null) Object.assign(result, {frontMatter: artifact.parsed.yamlFrontMatter}) if (prompt != null) Object.assign(result, {prompt}) return result @@ -570,33 +572,28 @@ export class LocalizedPromptReader { if (!this.exists(filePath)) return null try { - const rawMdx = this.fs.readFileSync(filePath, 'utf8') - const stats = this.fs.statSync(filePath) - const compileResult = await mdxToMd(rawMdx, { - globalScope: this.globalScope, - extractMetadata: true, - basePath: this.path.dirname(filePath), - filePath + const artifact = await readPromptArtifact(filePath, { + mode: 'dist', + globalScope: this.globalScope }) - assertNoResidualModuleSyntax(compileResult.content, filePath) - const parsed = parseMarkdown(rawMdx) + assertNoResidualModuleSyntax(artifact.content, filePath) const prompt = await createPrompt( - compileResult.content, + artifact.content, 'zh', name, - compileResult.metadata.fields + artifact.metadata ) const result: LocalizedContent = { - content: compileResult.content, - lastModified: stats.mtime, + content: artifact.content, + lastModified: artifact.lastModified, prompt, filePath, - rawMdx + rawMdx: artifact.rawMdx } - if (parsed.yamlFrontMatter != null) Object.assign(result, {frontMatter: parsed.yamlFrontMatter}) + if (artifact.parsed.yamlFrontMatter != null) Object.assign(result, {frontMatter: artifact.parsed.yamlFrontMatter}) return result } catch (error) { this.logger.error(this.buildDistReadDiagnostic(error, filePath, diagnosticContext)) diff --git a/cli/src/plugins/plugin-core/PromptArtifactCache.test.ts b/cli/src/plugins/plugin-core/PromptArtifactCache.test.ts new file mode 100644 index 00000000..9708baf5 --- /dev/null +++ b/cli/src/plugins/plugin-core/PromptArtifactCache.test.ts @@ -0,0 +1,203 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {afterEach, describe, expect, it, vi} from 'vitest' + +import { + clearPromptArtifactCache, + compileRawPromptArtifact, + readPromptArtifact +} from './PromptArtifactCache' + +const {mdxToMdMock, parseMarkdownMock} = vi.hoisted(() => ({ + mdxToMdMock: vi.fn(async (content: string) => ({ + content: `compiled:${content.trim()}`, + metadata: { + fields: { + compiled: true + } + } + })), + parseMarkdownMock: vi.fn((content: string) => { + const frontMatterMatch = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(content) + + if (frontMatterMatch != null) { + const rawFrontMatter = `---\n${frontMatterMatch[1]}\n---` + const markdownContent = frontMatterMatch[2].trim() + + return { + yamlFrontMatter: { + title: 'frontmatter' + }, + rawFrontMatter, + contentWithoutFrontMatter: markdownContent, + markdownAst: { + type: 'root' + }, + markdownContents: [markdownContent] + } + } + + const trimmed = content.trim() + return { + yamlFrontMatter: void 0, + rawFrontMatter: void 0, + contentWithoutFrontMatter: trimmed, + markdownAst: { + type: 'root' + }, + markdownContents: [trimmed] + } + }) +})) + +vi.mock('@truenine/md-compiler', () => ({ + mdxToMd: mdxToMdMock +})) + +vi.mock('@truenine/md-compiler/markdown', () => ({ + parseMarkdown: parseMarkdownMock +})) + +afterEach(() => { + clearPromptArtifactCache() + vi.clearAllMocks() +}) + +describe('prompt artifact cache', () => { + it('caches repeated source prompt compilation by file mtime', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-prompt-cache-source-')) + const filePath = path.join(tempDir, 'prompt.src.mdx') + + try { + fs.writeFileSync(filePath, 'Hello prompt', 'utf8') + + const first = await readPromptArtifact(filePath, { + mode: 'source' + }) + const second = await readPromptArtifact(filePath, { + mode: 'source' + }) + + expect(first.content).toBe('compiled:Hello prompt') + expect(second.content).toBe('compiled:Hello prompt') + expect(mdxToMdMock).toHaveBeenCalledTimes(1) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) + + it('reads export-default dist artifacts without recompiling', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-prompt-cache-dist-')) + const filePath = path.join(tempDir, 'prompt.mdx') + + try { + fs.writeFileSync(filePath, [ + 'export default {', + ' description: \'dist description\',', + ' version: \'1.0.0\'', + '}', + '', + 'Compiled body', + '' + ].join('\n'), 'utf8') + + const artifact = await readPromptArtifact(filePath, { + mode: 'dist' + }) + + expect(artifact.content).toBe('Compiled body') + expect(artifact.metadata).toEqual({ + description: 'dist description', + version: '1.0.0' + }) + expect(mdxToMdMock).not.toHaveBeenCalled() + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) + + it('still compiles frontmatter dist artifacts so MDX body syntax is resolved', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-prompt-cache-frontmatter-dist-')) + const filePath = path.join(tempDir, 'prompt.mdx') + + try { + fs.writeFileSync(filePath, [ + '---', + 'title: demo', + '---', + '', + 'Hello {profile.name}', + '' + ].join('\n'), 'utf8') + + const artifact = await readPromptArtifact(filePath, { + mode: 'dist' + }) + + expect(artifact.content).toContain('compiled:') + expect(artifact.metadata).toEqual({ + compiled: true + }) + expect(mdxToMdMock).toHaveBeenCalledTimes(1) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) + + it('falls back to mdx compilation when export-default metadata is not JSON5-compatible', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tnmsc-prompt-cache-dist-fallback-')) + const filePath = path.join(tempDir, 'prompt.mdx') + + try { + fs.writeFileSync(filePath, [ + 'export default {', + ' description: `template literal metadata`,', + '}', + '', + 'Compiled body', + '' + ].join('\n'), 'utf8') + + const artifact = await readPromptArtifact(filePath, { + mode: 'dist' + }) + + expect(artifact.content).toContain('compiled:export default') + expect(mdxToMdMock).toHaveBeenCalledTimes(1) + } + finally { + fs.rmSync(tempDir, {recursive: true, force: true}) + } + }) + + it('caches raw prompt recompilation for identical tool preset inputs', async () => { + const resultA = await compileRawPromptArtifact({ + filePath: '/tmp/command.mdx', + rawMdx: 'Tool preset body', + cacheMtimeMs: 42, + globalScope: { + tool: { + preset: 'demo' + } + } as never + }) + const resultB = await compileRawPromptArtifact({ + filePath: '/tmp/command.mdx', + rawMdx: 'Tool preset body', + cacheMtimeMs: 42, + globalScope: { + tool: { + preset: 'demo' + } + } as never + }) + + expect(resultA.content).toBe('compiled:Tool preset body') + expect(resultB.content).toBe('compiled:Tool preset body') + expect(mdxToMdMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/cli/src/plugins/plugin-core/PromptArtifactCache.ts b/cli/src/plugins/plugin-core/PromptArtifactCache.ts new file mode 100644 index 00000000..2ad98dfe --- /dev/null +++ b/cli/src/plugins/plugin-core/PromptArtifactCache.ts @@ -0,0 +1,317 @@ +import type {MdxGlobalScope} from '@truenine/md-compiler/globals' +import type {ParsedMarkdown} from '@truenine/md-compiler/markdown' +import * as fs from 'node:fs' +import * as path from 'node:path' +import {mdxToMd} from '@truenine/md-compiler' +import {parseMarkdown} from '@truenine/md-compiler/markdown' +import JSON5 from 'json5' + +export interface PromptArtifact { + readonly rawMdx: string + readonly parsed: ParsedMarkdown + readonly content: string + readonly metadata: Record + readonly lastModified: Date +} + +export interface ReadPromptArtifactOptions { + readonly mode: 'source' | 'dist' + readonly globalScope?: MdxGlobalScope | undefined + readonly rawMdx?: string | undefined + readonly lastModified?: Date | undefined +} + +export interface CompileRawPromptArtifactOptions { + readonly filePath: string + readonly globalScope?: MdxGlobalScope | undefined + readonly rawMdx: string + readonly cacheMtimeMs?: number | undefined +} + +export interface RawPromptCompilation { + readonly content: string + readonly metadata: Record +} + +interface CachedPromptArtifactValue { + readonly artifact: PromptArtifact + readonly stamp: number +} + +const promptArtifactCache = new Map>() +const rawPromptCompilationCache = new Map>() +const EXPORT_DEFAULT_PREFIX_PATTERN = /^export\s+default\s*/u + +function normalizeForCache(value: unknown): unknown { + if (value == null || typeof value !== 'object') { + return value + } + + if (Array.isArray(value)) { + return value.map(normalizeForCache) + } + + const normalizedEntries = Object.entries(value as Record) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, nestedValue]) => [key, normalizeForCache(nestedValue)] as const) + return Object.fromEntries(normalizedEntries) +} + +function stableSerialize(value: unknown): string { + return JSON.stringify(normalizeForCache(value)) +} + +function buildArtifactCacheKey( + filePath: string, + stamp: number, + options: ReadPromptArtifactOptions +): string { + return [ + path.resolve(filePath), + stamp, + options.mode, + stableSerialize(options.globalScope ?? {}) + ].join('::') +} + +function buildRawCompilationCacheKey( + options: CompileRawPromptArtifactOptions +): string { + return [ + path.resolve(options.filePath), + options.cacheMtimeMs ?? options.rawMdx.length, + stableSerialize(options.globalScope ?? {}), + stableSerialize(options.rawMdx) + ].join('::') +} + +function trimMetadataPrefix(content: string): string { + return content.replace(/^\s*;?\s*/u, '').trim() +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function extractObjectLiteral(source: string, startIndex: number): {value: string, endIndex: number} | null { + if (source[startIndex] !== '{') { + return null + } + + let depth = 0 + let inString: string | undefined + let escaped = false + let inLineComment = false + let inBlockComment = false + + for (let index = startIndex; index < source.length; index++) { + const current = source[index] + const next = source[index + 1] + + if (current == null) { + break + } + + if (inLineComment) { + if (current === '\n') { + inLineComment = false + } + continue + } + + if (inBlockComment) { + if (current === '*' && next === '/') { + inBlockComment = false + index++ + } + continue + } + + if (escaped) { + escaped = false + continue + } + + if (inString != null) { + if (current === '\\') { + escaped = true + continue + } + if (current === inString) { + inString = void 0 + } + continue + } + + if (current === '"' || current === '\'' || current === '`') { + inString = current + continue + } + + if (current === '/' && next === '/') { + inLineComment = true + index++ + continue + } + + if (current === '/' && next === '*') { + inBlockComment = true + index++ + continue + } + + if (current === '{') { + depth++ + continue + } + + if (current !== '}') { + continue + } + + depth-- + if (depth === 0) { + return { + value: source.slice(startIndex, index + 1), + endIndex: index + 1 + } + } + } + + return null +} + +function tryReadFastDistArtifact( + rawMdx: string +): {content: string, metadata: Record} | null { + const trimmed = rawMdx.trimStart() + + // Frontmatter and plain markdown dist prompts still need mdxToMd because the body + // may contain unresolved MDX expressions or components. + const prefixMatch = EXPORT_DEFAULT_PREFIX_PATTERN.exec(trimmed) + if (prefixMatch == null) return null + + const objectStartIndex = prefixMatch[0].length + const objectLiteral = extractObjectLiteral(trimmed, objectStartIndex) + if (objectLiteral == null) { + return null + } + + let metadata: unknown + try { + metadata = JSON5.parse(objectLiteral.value) + } + catch { + return null + } + + if (!isRecord(metadata)) { + return null + } + + return { + content: trimMetadataPrefix(trimmed.slice(objectLiteral.endIndex)), + metadata + } +} + +async function buildPromptArtifact( + filePath: string, + options: ReadPromptArtifactOptions +): Promise { + const rawMdx = options.rawMdx ?? fs.readFileSync(filePath, 'utf8') + const lastModified = options.lastModified ?? fs.statSync(filePath).mtime + const parsed = parseMarkdown(rawMdx) + + if (options.mode === 'dist') { + const fastDistArtifact = tryReadFastDistArtifact(rawMdx) + if (fastDistArtifact != null) { + return { + rawMdx, + parsed, + content: fastDistArtifact.content, + metadata: fastDistArtifact.metadata, + lastModified + } + } + } + + const compileResult = await mdxToMd(rawMdx, { + globalScope: options.globalScope, + extractMetadata: true, + basePath: path.dirname(filePath), + filePath + }) + + return { + rawMdx, + parsed, + content: compileResult.content, + metadata: compileResult.metadata.fields, + lastModified + } +} + +export async function readPromptArtifact( + filePath: string, + options: ReadPromptArtifactOptions +): Promise { + const lastModified = options.lastModified ?? fs.statSync(filePath).mtime + const stamp = lastModified.getTime() + const cacheKey = buildArtifactCacheKey(filePath, stamp, options) + const cached = promptArtifactCache.get(cacheKey) + if (cached != null) { + return (await cached).artifact + } + + const pendingArtifact = buildPromptArtifact(filePath, { + ...options, + lastModified + }).then(artifact => ({ + artifact, + stamp + })) + promptArtifactCache.set(cacheKey, pendingArtifact) + + try { + return (await pendingArtifact).artifact + } + catch (error) { + promptArtifactCache.delete(cacheKey) + throw error + } +} + +export async function compileRawPromptArtifact( + options: CompileRawPromptArtifactOptions +): Promise { + const cacheKey = buildRawCompilationCacheKey(options) + const cached = rawPromptCompilationCache.get(cacheKey) + if (cached != null) { + return cached + } + + const pendingCompilation = mdxToMd(options.rawMdx, { + globalScope: options.globalScope, + extractMetadata: true, + basePath: path.dirname(options.filePath), + filePath: options.filePath + }).then(result => ({ + content: result.content, + metadata: result.metadata.fields + })) + rawPromptCompilationCache.set(cacheKey, pendingCompilation) + + try { + return await pendingCompilation + } + catch (error) { + rawPromptCompilationCache.delete(cacheKey) + throw error + } +} + +export function clearPromptArtifactCache(): void { + promptArtifactCache.clear() + rawPromptCompilationCache.clear() +} diff --git a/doc/package.json b/doc/package.json index 9afc469f..96d825eb 100644 --- a/doc/package.json +++ b/doc/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-docs", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "private": true, "description": "Chinese-first manifesto-led documentation site for @truenine/memory-sync.", "engines": { diff --git a/gui/package.json b/gui/package.json index fd3ce60f..1df7e082 100644 --- a/gui/package.json +++ b/gui/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync-gui", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "private": true, "engines": { "node": ">=25.2.1", diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index a57d5782..c024e418 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "memory-sync-gui" -version = "2026.10324.10325" +version = "2026.10324.11958" description = "Memory Sync desktop GUI application" authors.workspace = true edition.workspace = true diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json index 4bd3d760..006c8b37 100644 --- a/gui/src-tauri/tauri.conf.json +++ b/gui/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "productName": "Memory Sync", "identifier": "org.truenine.memory-sync", "build": { diff --git a/libraries/logger/package.json b/libraries/logger/package.json index 0a2492ca..347418a8 100644 --- a/libraries/logger/package.json +++ b/libraries/logger/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/logger", "type": "module", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "private": true, "description": "Rust-powered structured logger for Node.js via N-API", "license": "AGPL-3.0-only", diff --git a/libraries/logger/src/lib.rs b/libraries/logger/src/lib.rs index f386384c..6b8c1eb4 100644 --- a/libraries/logger/src/lib.rs +++ b/libraries/logger/src/lib.rs @@ -240,12 +240,16 @@ fn colorize_key(key: &str) -> String { } fn colorize_level(level: LogLevel) -> String { - (level.color_fn())(&to_json_string_literal(&level.as_str().to_ascii_uppercase())) + (level.color_fn())(&to_json_string_literal( + &level.as_str().to_ascii_uppercase(), + )) } fn render_json_value(value: &Value, pretty: bool, depth: usize) -> String { match value { - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => colorize_scalar(value), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + colorize_scalar(value) + } Value::Array(items) => { if items.is_empty() { return "[]".to_string(); @@ -276,7 +280,11 @@ fn render_json_value(value: &Value, pretty: bool, depth: usize) -> String { let parts: Vec = map .iter() .map(|(key, nested)| { - format!("{}:{}", colorize_key(key), render_json_value(nested, false, depth + 1)) + format!( + "{}:{}", + colorize_key(key), + render_json_value(nested, false, depth + 1) + ) }) .collect(); return format!("{{{}}}", parts.join(",")); @@ -439,7 +447,12 @@ fn scalar_to_copy_text(value: &Value) -> String { } } -fn extend_copy_text_value(lines: &mut Vec, label: Option<&str>, value: &Value, depth: usize) { +fn extend_copy_text_value( + lines: &mut Vec, + label: Option<&str>, + value: &Value, + depth: usize, +) { let prefix = " ".repeat(depth); match value { @@ -497,12 +510,18 @@ fn value_to_copy_text_lines(value: &Value) -> Vec { } fn is_diagnostic_payload(payload: &Value) -> bool { - payload - .as_object() - .is_some_and(|map| map.contains_key("copyText") && map.contains_key("code") && map.contains_key("title")) + payload.as_object().is_some_and(|map| { + map.contains_key("copyText") && map.contains_key("code") && map.contains_key("title") + }) } -fn render_output(timestamp: &str, level: LogLevel, namespace: &str, payload: &Value, pretty: bool) -> String { +fn render_output( + timestamp: &str, + level: LogLevel, + namespace: &str, + payload: &Value, + pretty: bool, +) -> String { if !pretty { return format!( "{{{}:[{},{},{}],{}:{}}}", @@ -701,7 +720,13 @@ fn emit_log_record(level: LogLevel, namespace: &str, payload: Value, pretty: boo payload: payload.clone(), }; - let output = render_output(&ts, level, namespace, &payload, pretty && is_diagnostic_payload(&payload)); + let output = render_output( + &ts, + level, + namespace, + &payload, + pretty && is_diagnostic_payload(&payload), + ); print_output(level, &output); record } @@ -1086,7 +1111,13 @@ mod tests { Value::String("hello".to_string()), )])); - let rendered = render_output("00:00:00.000", LogLevel::Info, "logger-test", &payload, false); + let rendered = render_output( + "00:00:00.000", + LogLevel::Info, + "logger-test", + &payload, + false, + ); assert!(rendered.contains('\u{1b}')); assert!(!rendered.contains("\\u001b")); @@ -1108,14 +1139,23 @@ mod tests { Value::String("C:\\runtime\\plugin\\\"quoted\"\nnext".to_string()), )])); - let rendered = render_output("00:00:00.000", LogLevel::Warn, "logger-test", &payload, false); + let rendered = render_output( + "00:00:00.000", + LogLevel::Warn, + "logger-test", + &payload, + false, + ); let plain = strip_ansi(&rendered); let parsed: Value = match serde_json::from_str(&plain) { Ok(value) => value, Err(error) => panic!("failed to parse rendered json: {error}\n{plain}"), }; - assert_eq!(parsed["_"]["message"], "C:\\runtime\\plugin\\\"quoted\"\nnext"); + assert_eq!( + parsed["_"]["message"], + "C:\\runtime\\plugin\\\"quoted\"\nnext" + ); } #[test] @@ -1126,7 +1166,13 @@ mod tests { } }); - let rendered = render_output("00:00:00.000", LogLevel::Info, "PluginPipeline", &payload, false); + let rendered = render_output( + "00:00:00.000", + LogLevel::Info, + "PluginPipeline", + &payload, + false, + ); let plain = strip_ansi(&rendered); let parsed: Value = match serde_json::from_str(&plain) { Ok(value) => value, @@ -1158,7 +1204,13 @@ mod tests { }, )); - let rendered = render_output("00:00:00.000", LogLevel::Warn, "logger-test", &payload, true); + let rendered = render_output( + "00:00:00.000", + LogLevel::Warn, + "logger-test", + &payload, + true, + ); assert!(rendered.contains("\n")); assert!(!rendered.contains("\\u001b")); @@ -1187,13 +1239,20 @@ mod tests { exact_fix: None, possible_fixes: None, details: Some(Map::from_iter([ - ("path".to_string(), Value::String("C:\\runtime\\plugin".to_string())), + ( + "path".to_string(), + Value::String("C:\\runtime\\plugin".to_string()), + ), ("phase".to_string(), Value::String("cleanup".to_string())), ])), }, ); - assert!(record.copy_text.contains(&" path: C:\\runtime\\plugin".to_string())); + assert!( + record + .copy_text + .contains(&" path: C:\\runtime\\plugin".to_string()) + ); assert!(record.copy_text.contains(&" phase: cleanup".to_string())); assert!(!record.copy_text.iter().any(|line| line == "{")); } diff --git a/libraries/md-compiler/package.json b/libraries/md-compiler/package.json index 5599f81d..2a60746d 100644 --- a/libraries/md-compiler/package.json +++ b/libraries/md-compiler/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/md-compiler", "type": "module", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "private": true, "description": "Rust-powered MDX→Markdown compiler for Node.js with pure-TS fallback", "license": "AGPL-3.0-only", diff --git a/libraries/script-runtime/package.json b/libraries/script-runtime/package.json index b695e349..47388437 100644 --- a/libraries/script-runtime/package.json +++ b/libraries/script-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/script-runtime", "type": "module", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "private": true, "description": "Rust-backed TypeScript proxy runtime for tnmsc", "license": "AGPL-3.0-only", diff --git a/libraries/script-runtime/src/lib.rs b/libraries/script-runtime/src/lib.rs index 25e7f4ad..7854d2f6 100644 --- a/libraries/script-runtime/src/lib.rs +++ b/libraries/script-runtime/src/lib.rs @@ -5,6 +5,7 @@ use std::fs; use std::io::Read; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock}; use std::time::Duration; use serde::Deserialize; @@ -18,6 +19,42 @@ struct ResolvePublicPathContext { timeout_ms: Option, } +static NODE_COMMAND_CACHE: OnceLock>> = OnceLock::new(); + +fn read_cached_success(cache: &Mutex>) -> Option { + match cache.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } +} + +fn store_cached_success(cache: &Mutex>, value: &T) { + match cache.lock() { + Ok(mut guard) => { + *guard = Some(value.clone()); + } + Err(poisoned) => { + *poisoned.into_inner() = Some(value.clone()); + } + } +} + +fn detect_with_cached_success_result( + cache: &Mutex>, + detect: F, +) -> Result +where + F: FnOnce() -> Result, +{ + if let Some(cached) = read_cached_success(cache) { + return Ok(cached); + } + + let detected = detect()?; + store_cached_success(cache, &detected); + Ok(detected) +} + fn normalize_path(path: &Path) -> Result { let mut normalized = PathBuf::new(); @@ -129,6 +166,11 @@ fn candidate_node_commands() -> Vec { } fn find_node_command() -> Result { + let cache = NODE_COMMAND_CACHE.get_or_init(|| Mutex::new(None)); + detect_with_cached_success_result(cache, detect_node_command) +} + +fn detect_node_command() -> Result { for candidate in candidate_node_commands() { let status = Command::new(&candidate) .arg("--version") @@ -253,8 +295,11 @@ mod napi_binding { #[cfg(test)] mod tests { - use super::validate_public_path_impl; + use super::{detect_with_cached_success_result, validate_public_path_impl}; + use std::cell::Cell; + use std::ffi::OsString; use std::path::PathBuf; + use std::sync::Mutex; #[test] fn validate_public_path_rejects_absolute_paths() { @@ -291,4 +336,31 @@ mod tests { assert!(validated_path.ends_with(PathBuf::from("____git").join("info").join("exclude"))); Ok(()) } + + #[test] + fn detect_with_cached_success_result_retries_until_success() -> Result<(), String> { + let cache = Mutex::new(None); + let attempts = Cell::new(0); + + let first = detect_with_cached_success_result(&cache, || { + attempts.set(attempts.get() + 1); + Err(String::from("missing")) + }); + assert!(first.is_err()); + + let second = detect_with_cached_success_result(&cache, || { + attempts.set(attempts.get() + 1); + Ok::(OsString::from("node")) + })?; + assert_eq!(second, OsString::from("node")); + + let third = detect_with_cached_success_result(&cache, || { + attempts.set(attempts.get() + 1); + Ok::(OsString::from("other")) + })?; + assert_eq!(third, OsString::from("node")); + assert_eq!(attempts.get(), 2); + + Ok(()) + } } diff --git a/mcp/package.json b/mcp/package.json index add45a60..6a043735 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@truenine/memory-sync-mcp", "type": "module", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "description": "MCP stdio server for managing memory-sync prompt sources and translation artifacts", "author": "TrueNine", "license": "AGPL-3.0-only", diff --git a/package.json b/package.json index 061f3428..26dffc7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@truenine/memory-sync", - "version": "2026.10324.10325", + "version": "2026.10324.11958", "description": "Cross-AI-tool prompt synchronisation toolkit (CLI + Tauri desktop GUI) — one ruleset, multi-target adaptation. Monorepo powered by pnpm + Turbo.", "license": "AGPL-3.0-only", "keywords": [