@portel/photon-core 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/asset-discovery.d.ts +25 -0
  2. package/dist/asset-discovery.d.ts.map +1 -0
  3. package/dist/asset-discovery.js +144 -0
  4. package/dist/asset-discovery.js.map +1 -0
  5. package/dist/base.d.ts +1 -1
  6. package/dist/base.d.ts.map +1 -1
  7. package/dist/base.js +12 -6
  8. package/dist/base.js.map +1 -1
  9. package/dist/class-detection.d.ts +32 -0
  10. package/dist/class-detection.d.ts.map +1 -0
  11. package/dist/class-detection.js +86 -0
  12. package/dist/class-detection.js.map +1 -0
  13. package/dist/compiler.d.ts +22 -0
  14. package/dist/compiler.d.ts.map +1 -0
  15. package/dist/compiler.js +48 -0
  16. package/dist/compiler.js.map +1 -0
  17. package/dist/config.d.ts +63 -0
  18. package/dist/config.d.ts.map +1 -0
  19. package/dist/config.js +117 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/design-system/tokens.d.ts +66 -0
  22. package/dist/design-system/tokens.d.ts.map +1 -1
  23. package/dist/design-system/tokens.js +324 -44
  24. package/dist/design-system/tokens.js.map +1 -1
  25. package/dist/env-utils.d.ts +61 -0
  26. package/dist/env-utils.d.ts.map +1 -0
  27. package/dist/env-utils.js +171 -0
  28. package/dist/env-utils.js.map +1 -0
  29. package/dist/index.d.ts +9 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +27 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/mcp-apps.d.ts +130 -0
  34. package/dist/mcp-apps.d.ts.map +1 -0
  35. package/dist/mcp-apps.js +87 -0
  36. package/dist/mcp-apps.js.map +1 -0
  37. package/dist/mime-types.d.ts +13 -0
  38. package/dist/mime-types.d.ts.map +1 -0
  39. package/dist/mime-types.js +47 -0
  40. package/dist/mime-types.js.map +1 -0
  41. package/dist/rendering/index.d.ts +49 -0
  42. package/dist/rendering/index.d.ts.map +1 -1
  43. package/dist/rendering/index.js +153 -0
  44. package/dist/rendering/index.js.map +1 -1
  45. package/dist/schema-extractor.d.ts +18 -1
  46. package/dist/schema-extractor.d.ts.map +1 -1
  47. package/dist/schema-extractor.js +81 -11
  48. package/dist/schema-extractor.js.map +1 -1
  49. package/dist/types.d.ts +75 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/validation.d.ts +51 -0
  53. package/dist/validation.d.ts.map +1 -0
  54. package/dist/validation.js +249 -0
  55. package/dist/validation.js.map +1 -0
  56. package/dist/version-check.d.ts +22 -0
  57. package/dist/version-check.d.ts.map +1 -0
  58. package/dist/version-check.js +91 -0
  59. package/dist/version-check.js.map +1 -0
  60. package/package.json +12 -2
  61. package/src/asset-discovery.ts +160 -0
  62. package/src/base.ts +12 -5
  63. package/src/class-detection.ts +94 -0
  64. package/src/compiler.ts +57 -0
  65. package/src/config.ts +134 -0
  66. package/src/design-system/tokens.ts +381 -57
  67. package/src/env-utils.ts +216 -0
  68. package/src/index.ts +106 -0
  69. package/src/mcp-apps.ts +204 -0
  70. package/src/mime-types.ts +49 -0
  71. package/src/rendering/index.ts +197 -0
  72. package/src/schema-extractor.ts +95 -12
  73. package/src/types.ts +82 -0
  74. package/src/validation.ts +363 -0
  75. package/src/version-check.ts +92 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Asset Discovery Utilities
3
+ *
4
+ * Discover and extract UI, prompt, and resource assets from Photon files.
5
+ * Extracted from photon's loader.ts.
6
+ *
7
+ * Depends on: getMimeType (from ./mime-types), SchemaExtractor (from ./schema-extractor)
8
+ */
9
+
10
+ import * as fs from 'fs/promises';
11
+ import * as path from 'path';
12
+ import { getMimeType } from './mime-types.js';
13
+ import { SchemaExtractor } from './schema-extractor.js';
14
+ import type { PhotonAssets } from './types.js';
15
+
16
+ /**
17
+ * Check if a file or directory exists
18
+ */
19
+ async function fileExists(filePath: string): Promise<boolean> {
20
+ try {
21
+ await fs.access(filePath);
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Discover and extract assets from a Photon file
30
+ *
31
+ * Convention:
32
+ * - Asset folder: {photon-name}/ next to {photon-name}.photon.ts
33
+ * - Subfolder: ui/, prompts/, resources/
34
+ *
35
+ * @param photonPath - Absolute path to the .photon.ts file
36
+ * @param source - Source code content of the Photon file
37
+ */
38
+ export async function discoverAssets(
39
+ photonPath: string,
40
+ source: string,
41
+ ): Promise<PhotonAssets | undefined> {
42
+ const extractor = new SchemaExtractor();
43
+ const dir = path.dirname(photonPath);
44
+ const basename = path.basename(photonPath, '.photon.ts');
45
+
46
+ // Convention: asset folder has same name as photon (without .photon.ts)
47
+ const assetFolder = path.join(dir, basename);
48
+
49
+ // Check if asset folder exists
50
+ let folderExists = false;
51
+ try {
52
+ const stat = await fs.stat(assetFolder);
53
+ folderExists = stat.isDirectory();
54
+ } catch {
55
+ // Folder doesn't exist
56
+ }
57
+
58
+ // Extract explicit asset declarations from source annotations
59
+ const assets = extractor.extractAssets(source, folderExists ? assetFolder : undefined);
60
+
61
+ // If no folder exists and no explicit declarations, skip
62
+ if (
63
+ !folderExists &&
64
+ assets.ui.length === 0 &&
65
+ assets.prompts.length === 0 &&
66
+ assets.resources.length === 0
67
+ ) {
68
+ return undefined;
69
+ }
70
+
71
+ if (folderExists) {
72
+ // Resolve paths for explicitly declared assets
73
+ for (const ui of assets.ui) {
74
+ ui.resolvedPath = path.resolve(assetFolder, ui.path.replace(/^\.\//, ''));
75
+ }
76
+ for (const prompt of assets.prompts) {
77
+ prompt.resolvedPath = path.resolve(assetFolder, prompt.path.replace(/^\.\//, ''));
78
+ }
79
+ for (const resource of assets.resources) {
80
+ resource.resolvedPath = path.resolve(assetFolder, resource.path.replace(/^\.\//, ''));
81
+ }
82
+
83
+ // Auto-discover assets from folder structure
84
+ await autoDiscoverAssets(assetFolder, assets);
85
+ }
86
+
87
+ return assets;
88
+ }
89
+
90
+ /**
91
+ * Auto-discover assets from the ui/, prompts/, resources/ subdirectories
92
+ */
93
+ export async function autoDiscoverAssets(
94
+ assetFolder: string,
95
+ assets: PhotonAssets,
96
+ ): Promise<void> {
97
+ // Auto-discover UI files
98
+ const uiDir = path.join(assetFolder, 'ui');
99
+ if (await fileExists(uiDir)) {
100
+ try {
101
+ const files = await fs.readdir(uiDir);
102
+ for (const file of files) {
103
+ const id = path.basename(file, path.extname(file));
104
+ if (!assets.ui.find((u) => u.id === id)) {
105
+ assets.ui.push({
106
+ id,
107
+ path: `./ui/${file}`,
108
+ resolvedPath: path.join(uiDir, file),
109
+ mimeType: getMimeType(file),
110
+ });
111
+ }
112
+ }
113
+ } catch {
114
+ // Ignore errors
115
+ }
116
+ }
117
+
118
+ // Auto-discover prompt files
119
+ const promptsDir = path.join(assetFolder, 'prompts');
120
+ if (await fileExists(promptsDir)) {
121
+ try {
122
+ const files = await fs.readdir(promptsDir);
123
+ for (const file of files) {
124
+ if (file.endsWith('.md') || file.endsWith('.txt')) {
125
+ const id = path.basename(file, path.extname(file));
126
+ if (!assets.prompts.find((p) => p.id === id)) {
127
+ assets.prompts.push({
128
+ id,
129
+ path: `./prompts/${file}`,
130
+ resolvedPath: path.join(promptsDir, file),
131
+ });
132
+ }
133
+ }
134
+ }
135
+ } catch {
136
+ // Ignore errors
137
+ }
138
+ }
139
+
140
+ // Auto-discover resource files
141
+ const resourcesDir = path.join(assetFolder, 'resources');
142
+ if (await fileExists(resourcesDir)) {
143
+ try {
144
+ const files = await fs.readdir(resourcesDir);
145
+ for (const file of files) {
146
+ const id = path.basename(file, path.extname(file));
147
+ if (!assets.resources.find((r) => r.id === id)) {
148
+ assets.resources.push({
149
+ id,
150
+ path: `./resources/${file}`,
151
+ resolvedPath: path.join(resourcesDir, file),
152
+ mimeType: getMimeType(file),
153
+ });
154
+ }
155
+ }
156
+ } catch {
157
+ // Ignore errors
158
+ }
159
+ }
160
+ }
package/src/base.ts CHANGED
@@ -125,22 +125,29 @@ export class PhotonMCP {
125
125
 
126
126
  /**
127
127
  * Get all tool methods from this class
128
- * Returns all public async methods except lifecycle hooks
128
+ * Returns all public async methods except lifecycle hooks and configuration methods
129
129
  */
130
130
  static getToolMethods(): string[] {
131
131
  const prototype = this.prototype;
132
132
  const methods: string[] = [];
133
133
 
134
+ // Methods that are conventions, not tools
135
+ const conventionMethods = new Set([
136
+ 'constructor',
137
+ 'onInitialize', // Lifecycle hook
138
+ 'onShutdown', // Lifecycle hook
139
+ 'configure', // Configuration convention
140
+ 'getConfig', // Configuration convention
141
+ ]);
142
+
134
143
  // Get all property names from prototype chain
135
144
  let current = prototype;
136
145
  while (current && current !== PhotonMCP.prototype) {
137
146
  Object.getOwnPropertyNames(current).forEach((name) => {
138
- // Skip constructor, private methods (starting with _), and lifecycle hooks
147
+ // Skip private methods (starting with _) and convention methods
139
148
  if (
140
- name !== 'constructor' &&
141
149
  !name.startsWith('_') &&
142
- name !== 'onInitialize' &&
143
- name !== 'onShutdown' &&
150
+ !conventionMethods.has(name) &&
144
151
  typeof (prototype as any)[name] === 'function' &&
145
152
  !methods.includes(name)
146
153
  ) {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Class Detection Utilities
3
+ *
4
+ * Shared logic for detecting Photon classes in ES modules.
5
+ * Extracted from photon, ncp, and lumina loaders.
6
+ */
7
+
8
+ /**
9
+ * Check if a value is a class constructor
10
+ */
11
+ export function isClass(fn: unknown): fn is new (...args: unknown[]) => unknown {
12
+ return typeof fn === 'function' && /^\s*class\s+/.test(fn.toString());
13
+ }
14
+
15
+ /**
16
+ * Check if a class has async methods (instance or static)
17
+ *
18
+ * Checks for AsyncFunction, AsyncGeneratorFunction, and GeneratorFunction
19
+ * on both prototype (instance methods) and the class itself (static methods).
20
+ */
21
+ export function hasAsyncMethods(ClassConstructor: new (...args: unknown[]) => unknown): boolean {
22
+ const asyncCtorNames = new Set([
23
+ 'AsyncFunction',
24
+ 'AsyncGeneratorFunction',
25
+ 'GeneratorFunction',
26
+ ]);
27
+
28
+ // Check instance methods on prototype
29
+ const prototype = ClassConstructor.prototype;
30
+ for (const key of Object.getOwnPropertyNames(prototype)) {
31
+ if (key === 'constructor') continue;
32
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
33
+ if (descriptor && typeof descriptor.value === 'function') {
34
+ if (asyncCtorNames.has(descriptor.value.constructor.name)) {
35
+ return true;
36
+ }
37
+ }
38
+ }
39
+
40
+ // Check static methods on the class itself
41
+ for (const key of Object.getOwnPropertyNames(ClassConstructor)) {
42
+ if (['length', 'name', 'prototype'].includes(key)) continue;
43
+ const descriptor = Object.getOwnPropertyDescriptor(ClassConstructor, key);
44
+ if (descriptor && typeof descriptor.value === 'function') {
45
+ if (asyncCtorNames.has(descriptor.value.constructor.name)) {
46
+ return true;
47
+ }
48
+ }
49
+ }
50
+
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Find a single Photon class in a module
56
+ *
57
+ * Priority: default export first, then named exports.
58
+ * Returns the first class with async methods, or null.
59
+ */
60
+ export function findPhotonClass(module: Record<string, unknown>): (new (...args: unknown[]) => unknown) | null {
61
+ // Try default export first
62
+ if (module.default && isClass(module.default)) {
63
+ if (hasAsyncMethods(module.default)) {
64
+ return module.default;
65
+ }
66
+ }
67
+
68
+ // Try named exports
69
+ for (const exportedItem of Object.values(module)) {
70
+ if (isClass(exportedItem) && hasAsyncMethods(exportedItem)) {
71
+ return exportedItem;
72
+ }
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Find all Photon classes in a module
80
+ *
81
+ * Returns every exported class that has async methods.
82
+ * Used by NCP which may load multiple classes from one file.
83
+ */
84
+ export function findPhotonClasses(module: Record<string, unknown>): Array<new (...args: unknown[]) => unknown> {
85
+ const classes: Array<new (...args: unknown[]) => unknown> = [];
86
+
87
+ for (const exportedItem of Object.values(module)) {
88
+ if (isClass(exportedItem) && hasAsyncMethods(exportedItem)) {
89
+ classes.push(exportedItem);
90
+ }
91
+ }
92
+
93
+ return classes;
94
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * TypeScript Compiler Utilities
3
+ *
4
+ * Shared esbuild-based TypeScript compilation with caching.
5
+ * Extracted from photon and ncp loaders.
6
+ *
7
+ * NOTE: No esbuild dependency in package.json — the consumer must provide it.
8
+ * Uses `await import('esbuild')` for dynamic resolution.
9
+ */
10
+
11
+ import * as fs from 'fs/promises';
12
+ import * as path from 'path';
13
+ import * as crypto from 'crypto';
14
+
15
+ /**
16
+ * Compile a .photon.ts file to JavaScript and cache the result
17
+ *
18
+ * @param tsFilePath - Absolute path to the TypeScript source file
19
+ * @param options.cacheDir - Directory to store compiled output
20
+ * @param options.content - Optional pre-read file content (avoids extra read)
21
+ * @returns Absolute path to the compiled .mjs file
22
+ */
23
+ export async function compilePhotonTS(
24
+ tsFilePath: string,
25
+ options: { cacheDir: string; content?: string },
26
+ ): Promise<string> {
27
+ const source = options.content ?? (await fs.readFile(tsFilePath, 'utf-8'));
28
+ const hash = crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
29
+
30
+ const fileName = path.basename(tsFilePath, '.ts');
31
+ const cachedJsPath = path.join(options.cacheDir, `${fileName}.${hash}.mjs`);
32
+
33
+ // Check if cached version exists
34
+ try {
35
+ await fs.access(cachedJsPath);
36
+ return cachedJsPath;
37
+ } catch {
38
+ // Cache miss — compile
39
+ }
40
+
41
+ // Dynamic import — consumer must have esbuild installed
42
+ const esbuild = await import('esbuild');
43
+ const result = await esbuild.transform(source, {
44
+ loader: 'ts',
45
+ format: 'esm',
46
+ target: 'es2022',
47
+ sourcemap: 'inline',
48
+ });
49
+
50
+ // Ensure cache directory exists
51
+ await fs.mkdir(options.cacheDir, { recursive: true });
52
+
53
+ // Write compiled JavaScript
54
+ await fs.writeFile(cachedJsPath, result.code, 'utf-8');
55
+
56
+ return cachedJsPath;
57
+ }
package/src/config.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Photon Configuration Utilities
3
+ *
4
+ * Provides standard config storage for photons that implement the configure() convention.
5
+ * Config is stored at ~/.photon/{photonName}/config.json
6
+ *
7
+ * Usage in a Photon:
8
+ * ```typescript
9
+ * import { loadPhotonConfig, savePhotonConfig, getPhotonConfigPath } from '@portel/photon-core';
10
+ *
11
+ * export default class MyPhoton extends PhotonMCP {
12
+ * async configure(params: { apiKey: string }) {
13
+ * savePhotonConfig('my-photon', params);
14
+ * return { success: true, config: params };
15
+ * }
16
+ *
17
+ * async getConfig() {
18
+ * return loadPhotonConfig('my-photon');
19
+ * }
20
+ * }
21
+ * ```
22
+ */
23
+
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import * as os from 'os';
27
+
28
+ /**
29
+ * Get the config directory for photons
30
+ * Default: ~/.photon/
31
+ */
32
+ export function getPhotonConfigDir(): string {
33
+ return process.env.PHOTON_CONFIG_DIR || path.join(os.homedir(), '.photon');
34
+ }
35
+
36
+ /**
37
+ * Get the config file path for a specific photon
38
+ * @param photonName The photon name (kebab-case)
39
+ * @returns Path to config.json for this photon
40
+ */
41
+ export function getPhotonConfigPath(photonName: string): string {
42
+ const safeName = photonName.replace(/[^a-zA-Z0-9_-]/g, '_');
43
+ return path.join(getPhotonConfigDir(), safeName, 'config.json');
44
+ }
45
+
46
+ /**
47
+ * Load configuration for a photon
48
+ * @param photonName The photon name (kebab-case)
49
+ * @param defaults Default values if config doesn't exist
50
+ * @returns The config object or defaults
51
+ */
52
+ export function loadPhotonConfig<T extends Record<string, any>>(
53
+ photonName: string,
54
+ defaults?: T
55
+ ): T {
56
+ const configPath = getPhotonConfigPath(photonName);
57
+
58
+ try {
59
+ if (fs.existsSync(configPath)) {
60
+ const content = fs.readFileSync(configPath, 'utf-8');
61
+ const config = JSON.parse(content);
62
+ // Merge with defaults
63
+ return defaults ? { ...defaults, ...config } : config;
64
+ }
65
+ } catch (error) {
66
+ // Log but don't throw - return defaults
67
+ if (process.env.PHOTON_DEBUG) {
68
+ console.error(`Failed to load config for ${photonName}:`, error);
69
+ }
70
+ }
71
+
72
+ return defaults || ({} as T);
73
+ }
74
+
75
+ /**
76
+ * Save configuration for a photon
77
+ * @param photonName The photon name (kebab-case)
78
+ * @param config The configuration object to save
79
+ */
80
+ export function savePhotonConfig<T extends Record<string, any>>(
81
+ photonName: string,
82
+ config: T
83
+ ): void {
84
+ const configPath = getPhotonConfigPath(photonName);
85
+ const configDir = path.dirname(configPath);
86
+
87
+ // Ensure directory exists
88
+ if (!fs.existsSync(configDir)) {
89
+ fs.mkdirSync(configDir, { recursive: true });
90
+ }
91
+
92
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
93
+ }
94
+
95
+ /**
96
+ * Check if a photon has been configured
97
+ * @param photonName The photon name (kebab-case)
98
+ * @returns true if config file exists
99
+ */
100
+ export function hasPhotonConfig(photonName: string): boolean {
101
+ return fs.existsSync(getPhotonConfigPath(photonName));
102
+ }
103
+
104
+ /**
105
+ * Delete configuration for a photon
106
+ * @param photonName The photon name (kebab-case)
107
+ */
108
+ export function deletePhotonConfig(photonName: string): void {
109
+ const configPath = getPhotonConfigPath(photonName);
110
+ if (fs.existsSync(configPath)) {
111
+ fs.unlinkSync(configPath);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * List all configured photons
117
+ * @returns Array of photon names that have config
118
+ */
119
+ export function listConfiguredPhotons(): string[] {
120
+ const configDir = getPhotonConfigDir();
121
+
122
+ if (!fs.existsSync(configDir)) {
123
+ return [];
124
+ }
125
+
126
+ try {
127
+ return fs.readdirSync(configDir, { withFileTypes: true })
128
+ .filter(entry => entry.isDirectory())
129
+ .filter(entry => fs.existsSync(path.join(configDir, entry.name, 'config.json')))
130
+ .map(entry => entry.name);
131
+ } catch {
132
+ return [];
133
+ }
134
+ }