@portel/photon-core 1.3.0 → 1.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.
@@ -10,7 +10,7 @@
10
10
 
11
11
  import * as fs from 'fs/promises';
12
12
  import * as ts from 'typescript';
13
- import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency } from './types.js';
13
+ import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency, PhotonDependency, ResolvedInjection, PhotonAssets, UIAsset, PromptAsset, ResourceAsset } from './types.js';
14
14
 
15
15
  export interface ExtractedMetadata {
16
16
  tools: ExtractedSchema[];
@@ -67,13 +67,13 @@ export class SchemaExtractor {
67
67
  const isGenerator = member.asteriskToken !== undefined;
68
68
 
69
69
  // Extract parameter type information
70
+ // Extract parameter type (may be undefined for no-arg methods)
70
71
  const paramsType = this.getFirstParameterType(member, sourceFile);
71
- if (!paramsType) {
72
- return; // Skip methods without proper params
73
- }
74
72
 
75
- // Build schema from TypeScript type
76
- const { properties, required } = this.buildSchemaFromType(paramsType, sourceFile);
73
+ // Build schema from TypeScript type (empty for no-arg methods)
74
+ const { properties, required } = paramsType
75
+ ? this.buildSchemaFromType(paramsType, sourceFile)
76
+ : { properties: {}, required: [] };
77
77
 
78
78
  // Extract descriptions from JSDoc
79
79
  const paramDocs = this.extractParamDocs(jsdoc);
@@ -130,6 +130,7 @@ export class SchemaExtractor {
130
130
  else {
131
131
  const outputFormat = this.extractFormat(jsdoc);
132
132
  const yields = isGenerator ? this.extractYieldsFromJSDoc(jsdoc) : undefined;
133
+ const isStateful = this.hasStatefulTag(jsdoc);
133
134
 
134
135
  tools.push({
135
136
  name: methodName,
@@ -138,6 +139,7 @@ export class SchemaExtractor {
138
139
  ...(outputFormat ? { outputFormat } : {}),
139
140
  ...(isGenerator ? { isGenerator: true } : {}),
140
141
  ...(yields && yields.length > 0 ? { yields } : {}),
142
+ ...(isStateful ? { isStateful: true } : {}),
141
143
  });
142
144
  }
143
145
  };
@@ -450,6 +452,15 @@ export class SchemaExtractor {
450
452
  return null;
451
453
  }
452
454
 
455
+ /**
456
+ * Check if a type is a primitive (string, number, boolean)
457
+ * Primitives are injected from environment variables
458
+ */
459
+ private isPrimitiveType(type: string): boolean {
460
+ const normalizedType = type.trim().toLowerCase();
461
+ return ['string', 'number', 'boolean'].includes(normalizedType);
462
+ }
463
+
453
464
  /**
454
465
  * Extract constructor parameters for config injection
455
466
  */
@@ -486,6 +497,7 @@ export class SchemaExtractor {
486
497
  isOptional,
487
498
  hasDefault,
488
499
  defaultValue,
500
+ isPrimitive: this.isPrimitiveType(type),
489
501
  });
490
502
  }
491
503
  });
@@ -808,6 +820,14 @@ export class SchemaExtractor {
808
820
  return /@Static/i.test(jsdocContent);
809
821
  }
810
822
 
823
+ /**
824
+ * Check if JSDoc contains @stateful tag
825
+ * Indicates this method is a stateful workflow that supports checkpoint/resume
826
+ */
827
+ private hasStatefulTag(jsdocContent: string): boolean {
828
+ return /@stateful/i.test(jsdocContent);
829
+ }
830
+
811
831
  /**
812
832
  * Extract URI pattern from @Static tag
813
833
  * Example: @Static github://repos/{owner}/{repo}/readme
@@ -946,4 +966,331 @@ export class SchemaExtractor {
946
966
  // Default: GitHub shorthand (owner/repo)
947
967
  return 'github';
948
968
  }
969
+
970
+ /**
971
+ * Extract Photon dependencies from source code
972
+ * Parses @photon tags in file-level or class-level JSDoc comments
973
+ *
974
+ * Format: @photon <name> <source>
975
+ *
976
+ * Source formats:
977
+ * - Marketplace: rss-feed (simple name from marketplace)
978
+ * - GitHub: portel-dev/photons/rss-feed
979
+ * - npm package: npm:@portel/rss-feed-photon
980
+ * - Local path: ./my-photon.photon.ts
981
+ *
982
+ * Example:
983
+ * ```
984
+ * /**
985
+ * * @photon rssFeed rss-feed
986
+ * * @photon custom ./my-photon.photon.ts
987
+ * *\/
988
+ * ```
989
+ */
990
+ extractPhotonDependencies(source: string): PhotonDependency[] {
991
+ const dependencies: PhotonDependency[] = [];
992
+
993
+ // Match @photon <name> <source> pattern
994
+ // Source ends at: newline, end of comment (*), or @ (next tag)
995
+ const photonRegex = /@photon\s+(\w+)\s+([^\s*@\n]+)/g;
996
+
997
+ let match;
998
+ while ((match = photonRegex.exec(source)) !== null) {
999
+ const [, name, rawSource] = match;
1000
+ const photonSource = rawSource.trim();
1001
+
1002
+ // Determine source type
1003
+ const sourceType = this.classifyPhotonSource(photonSource);
1004
+
1005
+ dependencies.push({
1006
+ name,
1007
+ source: photonSource,
1008
+ sourceType,
1009
+ });
1010
+ }
1011
+
1012
+ return dependencies;
1013
+ }
1014
+
1015
+ /**
1016
+ * Classify Photon source type based on format
1017
+ */
1018
+ private classifyPhotonSource(source: string): 'marketplace' | 'github' | 'npm' | 'local' {
1019
+ // npm package: npm:@scope/package or npm:package
1020
+ if (source.startsWith('npm:')) {
1021
+ return 'npm';
1022
+ }
1023
+
1024
+ // Local path (relative or absolute, or ends with .photon.ts)
1025
+ if (source.startsWith('./') || source.startsWith('../') ||
1026
+ source.startsWith('/') || source.startsWith('~') ||
1027
+ /^[A-Za-z]:[\\/]/.test(source) ||
1028
+ source.endsWith('.photon.ts')) {
1029
+ return 'local';
1030
+ }
1031
+
1032
+ // GitHub: has at least 2 slashes (owner/repo/photon) or 1 slash (owner/repo)
1033
+ if ((source.match(/\//g) || []).length >= 1) {
1034
+ return 'github';
1035
+ }
1036
+
1037
+ // Default: Marketplace (simple name like "rss-feed")
1038
+ return 'marketplace';
1039
+ }
1040
+
1041
+ /**
1042
+ * Resolve all injections for a Photon class
1043
+ * Determines how each constructor parameter should be injected:
1044
+ * - Primitives (string, number, boolean) → env var
1045
+ * - Non-primitives matching @mcp → MCP client
1046
+ * - Non-primitives matching @photon → Photon instance
1047
+ *
1048
+ * @param source The Photon source code
1049
+ * @param mcpName The MCP name (for env var prefixing)
1050
+ */
1051
+ resolveInjections(source: string, mcpName: string): ResolvedInjection[] {
1052
+ const params = this.extractConstructorParams(source);
1053
+ const mcpDeps = this.extractMCPDependencies(source);
1054
+ const photonDeps = this.extractPhotonDependencies(source);
1055
+
1056
+ // Build lookup maps
1057
+ const mcpMap = new Map(mcpDeps.map(d => [d.name, d]));
1058
+ const photonMap = new Map(photonDeps.map(d => [d.name, d]));
1059
+
1060
+ return params.map(param => {
1061
+ // Primitives → env var
1062
+ if (param.isPrimitive) {
1063
+ const envVarName = this.toEnvVarName(mcpName, param.name);
1064
+ return {
1065
+ param,
1066
+ injectionType: 'env' as const,
1067
+ envVarName,
1068
+ };
1069
+ }
1070
+
1071
+ // Check if matches an @mcp declaration
1072
+ if (mcpMap.has(param.name)) {
1073
+ return {
1074
+ param,
1075
+ injectionType: 'mcp' as const,
1076
+ mcpDependency: mcpMap.get(param.name),
1077
+ };
1078
+ }
1079
+
1080
+ // Check if matches an @photon declaration
1081
+ if (photonMap.has(param.name)) {
1082
+ return {
1083
+ param,
1084
+ injectionType: 'photon' as const,
1085
+ photonDependency: photonMap.get(param.name),
1086
+ };
1087
+ }
1088
+
1089
+ // Non-primitive without declaration - treat as env var (will likely fail at runtime)
1090
+ const envVarName = this.toEnvVarName(mcpName, param.name);
1091
+ return {
1092
+ param,
1093
+ injectionType: 'env' as const,
1094
+ envVarName,
1095
+ };
1096
+ });
1097
+ }
1098
+
1099
+ /**
1100
+ * Convert MCP name and parameter name to environment variable name
1101
+ * Example: (filesystem, workdir) → FILESYSTEM_WORKDIR
1102
+ */
1103
+ private toEnvVarName(mcpName: string, paramName: string): string {
1104
+ const mcpPrefix = mcpName.toUpperCase().replace(/-/g, '_');
1105
+ const paramSuffix = paramName
1106
+ .replace(/([A-Z])/g, '_$1')
1107
+ .toUpperCase()
1108
+ .replace(/^_/, '');
1109
+ return `${mcpPrefix}_${paramSuffix}`;
1110
+ }
1111
+
1112
+ // ════════════════════════════════════════════════════════════════════════════
1113
+ // ASSET EXTRACTION - @ui, @prompt, @resource annotations
1114
+ // ════════════════════════════════════════════════════════════════════════════
1115
+
1116
+ /**
1117
+ * Extract all assets from Photon source code
1118
+ * Parses @ui, @prompt, @resource annotations from class-level JSDoc
1119
+ *
1120
+ * Format:
1121
+ * - @ui <id> <path> - UI templates for MCP Apps
1122
+ * - @prompt <id> <path> - Static MCP prompts
1123
+ * - @resource <id> <path> - Static MCP resources
1124
+ *
1125
+ * Example:
1126
+ * ```
1127
+ * /**
1128
+ * * @ui preferences ./ui/preferences.html
1129
+ * * @prompt system ./prompts/system.md
1130
+ * * @resource config ./resources/config.json
1131
+ * *\/
1132
+ * export default class MyPhoton extends PhotonMCP { ... }
1133
+ * ```
1134
+ */
1135
+ extractAssets(source: string, assetFolder?: string): PhotonAssets {
1136
+ const ui = this.extractUIAssets(source);
1137
+ const prompts = this.extractPromptAssets(source);
1138
+ const resources = this.extractResourceAssets(source);
1139
+
1140
+ // Also extract method-level @ui annotations (links UI to specific tool)
1141
+ this.extractMethodUILinks(source, ui);
1142
+
1143
+ return {
1144
+ ui,
1145
+ prompts,
1146
+ resources,
1147
+ assetFolder,
1148
+ };
1149
+ }
1150
+
1151
+ /**
1152
+ * Extract UI assets from @ui annotations
1153
+ * Format: @ui <id> <path>
1154
+ * Path must start with ./ or / to distinguish from method-level @ui references
1155
+ */
1156
+ private extractUIAssets(source: string): UIAsset[] {
1157
+ const assets: UIAsset[] = [];
1158
+ // Path must start with ./ or / to be a declaration (not a reference)
1159
+ const uiRegex = /@ui\s+(\w[\w-]*)\s+(\.\/[^\s*]+|\/[^\s*]+)/g;
1160
+
1161
+ let match;
1162
+ while ((match = uiRegex.exec(source)) !== null) {
1163
+ const [, id, path] = match;
1164
+ assets.push({
1165
+ id,
1166
+ path,
1167
+ mimeType: this.getMimeTypeFromPath(path),
1168
+ });
1169
+ }
1170
+
1171
+ return assets;
1172
+ }
1173
+
1174
+ /**
1175
+ * Extract prompt assets from @prompt annotations
1176
+ * Format: @prompt <id> <path>
1177
+ * Path must start with ./ or / to be a valid declaration
1178
+ */
1179
+ private extractPromptAssets(source: string): PromptAsset[] {
1180
+ const assets: PromptAsset[] = [];
1181
+ const promptRegex = /@prompt\s+(\w[\w-]*)\s+(\.\/[^\s*]+|\/[^\s*]+)/g;
1182
+
1183
+ let match;
1184
+ while ((match = promptRegex.exec(source)) !== null) {
1185
+ const [, id, path] = match;
1186
+ assets.push({
1187
+ id,
1188
+ path,
1189
+ });
1190
+ }
1191
+
1192
+ return assets;
1193
+ }
1194
+
1195
+ /**
1196
+ * Extract resource assets from @resource annotations
1197
+ * Format: @resource <id> <path>
1198
+ * Path must start with ./ or / to be a valid declaration
1199
+ */
1200
+ private extractResourceAssets(source: string): ResourceAsset[] {
1201
+ const assets: ResourceAsset[] = [];
1202
+ const resourceRegex = /@resource\s+(\w[\w-]*)\s+(\.\/[^\s*]+|\/[^\s*]+)/g;
1203
+
1204
+ let match;
1205
+ while ((match = resourceRegex.exec(source)) !== null) {
1206
+ const [, id, path] = match;
1207
+ assets.push({
1208
+ id,
1209
+ path,
1210
+ mimeType: this.getMimeTypeFromPath(path),
1211
+ });
1212
+ }
1213
+
1214
+ return assets;
1215
+ }
1216
+
1217
+ /**
1218
+ * Extract method-level @ui annotations that link UI to tools
1219
+ * Format: @ui <id> on a method's JSDoc
1220
+ */
1221
+ private extractMethodUILinks(source: string, uiAssets: UIAsset[]): void {
1222
+ try {
1223
+ const sourceFile = ts.createSourceFile(
1224
+ 'temp.ts',
1225
+ source,
1226
+ ts.ScriptTarget.Latest,
1227
+ true
1228
+ );
1229
+
1230
+ const visit = (node: ts.Node) => {
1231
+ if (ts.isClassDeclaration(node)) {
1232
+ node.members.forEach((member) => {
1233
+ if (ts.isMethodDeclaration(member) &&
1234
+ member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)) {
1235
+ const jsdoc = this.getJSDocComment(member, sourceFile);
1236
+ const methodName = member.name.getText(sourceFile);
1237
+
1238
+ // Check for @ui <id> in method JSDoc
1239
+ const uiMatch = jsdoc.match(/@ui\s+(\S+)/);
1240
+ if (uiMatch) {
1241
+ const uiId = uiMatch[1];
1242
+ // Link UI asset to this method
1243
+ const asset = uiAssets.find(a => a.id === uiId);
1244
+ if (asset) {
1245
+ asset.linkedTool = methodName;
1246
+ }
1247
+ }
1248
+ }
1249
+ });
1250
+ }
1251
+ ts.forEachChild(node, visit);
1252
+ };
1253
+
1254
+ visit(sourceFile);
1255
+ } catch (error: any) {
1256
+ // Silently fail - UI links are optional
1257
+ }
1258
+ }
1259
+
1260
+ /**
1261
+ * Get MIME type from file extension
1262
+ */
1263
+ private getMimeTypeFromPath(path: string): string {
1264
+ const ext = path.toLowerCase().split('.').pop() || '';
1265
+ const mimeTypes: Record<string, string> = {
1266
+ // Web
1267
+ 'html': 'text/html',
1268
+ 'htm': 'text/html',
1269
+ 'css': 'text/css',
1270
+ 'js': 'application/javascript',
1271
+ 'mjs': 'application/javascript',
1272
+ 'jsx': 'text/jsx',
1273
+ 'ts': 'text/typescript',
1274
+ 'tsx': 'text/tsx',
1275
+ // Data
1276
+ 'json': 'application/json',
1277
+ 'yaml': 'application/yaml',
1278
+ 'yml': 'application/yaml',
1279
+ 'xml': 'application/xml',
1280
+ 'csv': 'text/csv',
1281
+ // Documents
1282
+ 'md': 'text/markdown',
1283
+ 'txt': 'text/plain',
1284
+ 'pdf': 'application/pdf',
1285
+ // Images
1286
+ 'png': 'image/png',
1287
+ 'jpg': 'image/jpeg',
1288
+ 'jpeg': 'image/jpeg',
1289
+ 'gif': 'image/gif',
1290
+ 'svg': 'image/svg+xml',
1291
+ 'webp': 'image/webp',
1292
+ 'ico': 'image/x-icon',
1293
+ };
1294
+ return mimeTypes[ext] || 'application/octet-stream';
1295
+ }
949
1296
  }