@portel/photon-core 2.9.2 → 2.9.3

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, PhotonDependency, CLIDependency, ResolvedInjection, PhotonAssets, UIAsset, PromptAsset, ResourceAsset, ConfigSchema, ConfigParam } from './types.js';
13
+ import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency, PhotonDependency, CLIDependency, ResolvedInjection, PhotonAssets, UIAsset, PromptAsset, ResourceAsset, ConfigSchema, ConfigParam, SettingsSchema, SettingsProperty } from './types.js';
14
14
  import { parseDuration, parseRate } from './utils/duration.js';
15
15
  import { builtinRegistry, type MiddlewareDeclaration } from './middleware.js';
16
16
 
@@ -18,7 +18,9 @@ export interface ExtractedMetadata {
18
18
  tools: ExtractedSchema[];
19
19
  templates: TemplateInfo[];
20
20
  statics: StaticInfo[];
21
- /** Configuration schema from configure() method */
21
+ /** Settings schema from `protected settings = { ... }` property */
22
+ settingsSchema?: SettingsSchema;
23
+ /** @deprecated Configuration schema from configure() method */
22
24
  configSchema?: ConfigSchema;
23
25
  }
24
26
 
@@ -47,7 +49,10 @@ export class SchemaExtractor {
47
49
  const templates: TemplateInfo[] = [];
48
50
  const statics: StaticInfo[] = [];
49
51
 
50
- // Configuration schema tracking
52
+ // Settings schema tracking (new property-based approach)
53
+ let settingsSchema: SettingsSchema | undefined;
54
+
55
+ // Configuration schema tracking (deprecated method-based approach)
51
56
  let configSchema: ConfigSchema = {
52
57
  hasConfigureMethod: false,
53
58
  hasGetConfigMethod: false,
@@ -78,32 +83,32 @@ export class SchemaExtractor {
78
83
  return;
79
84
  }
80
85
 
81
- // Handle configuration convention methods specially
86
+ // Track configure/getConfig for backward compat metadata (deprecated)
87
+ // These are no longer hidden — they become normal visible tools
82
88
  if (methodName === 'configure') {
83
89
  configSchema.hasConfigureMethod = true;
84
- const jsdoc = this.getJSDocComment(member, sourceFile);
85
- configSchema.description = this.extractDescription(jsdoc);
90
+ const jsdocConfig = this.getJSDocComment(member, sourceFile);
91
+ configSchema.description = this.extractDescription(jsdocConfig);
86
92
 
87
- // Extract configure() parameters as config schema
88
93
  const paramsType = this.getFirstParameterType(member, sourceFile);
89
94
  if (paramsType) {
90
- const { properties, required } = this.buildSchemaFromType(paramsType, sourceFile);
91
- const paramDocs = this.extractParamDocs(jsdoc);
95
+ const { properties: configProps, required: configRequired } = this.buildSchemaFromType(paramsType, sourceFile);
96
+ const paramDocs = this.extractParamDocs(jsdocConfig);
92
97
 
93
- configSchema.params = Object.keys(properties).map(name => ({
98
+ configSchema.params = Object.keys(configProps).map(name => ({
94
99
  name,
95
- type: properties[name].type || 'string',
96
- description: paramDocs.get(name) || properties[name].description,
97
- required: required.includes(name),
98
- defaultValue: properties[name].default,
100
+ type: configProps[name].type || 'string',
101
+ description: paramDocs.get(name) || configProps[name].description,
102
+ required: configRequired.includes(name),
103
+ defaultValue: configProps[name].default,
99
104
  }));
100
105
  }
101
- return; // Don't add configure() as a tool
106
+ // Fall through configure() is now a normal tool (not hidden)
102
107
  }
103
108
 
104
109
  if (methodName === 'getConfig') {
105
110
  configSchema.hasGetConfigMethod = true;
106
- return; // Don't add getConfig() as a tool
111
+ // Fall through getConfig() is now a normal tool (not hidden)
107
112
  }
108
113
 
109
114
  const jsdoc = this.getJSDocComment(member, sourceFile);
@@ -250,11 +255,110 @@ export class SchemaExtractor {
250
255
  }
251
256
  };
252
257
 
258
+ // Helper to extract settings from a `protected settings = { ... }` property
259
+ const processSettingsProperty = (member: ts.PropertyDeclaration, classNode: ts.ClassDeclaration) => {
260
+ const name = member.name.getText(sourceFile);
261
+ if (name !== 'settings') return;
262
+
263
+ // Must be protected
264
+ const isProtected = member.modifiers?.some(
265
+ m => m.kind === ts.SyntaxKind.ProtectedKeyword
266
+ );
267
+ if (!isProtected) return;
268
+
269
+ // Must have an object literal initializer
270
+ if (!member.initializer || !ts.isObjectLiteralExpression(member.initializer)) return;
271
+
272
+ // Get class-level JSDoc for @property descriptions
273
+ const classJsdoc = this.getJSDocComment(classNode as any, sourceFile);
274
+ const propertyDocs = new Map<string, string>();
275
+ const propertyRegex = /@property\s+(\w+)\s+(.*?)(?=\n\s*\*\s*@|\n\s*\*\/|\n\s*\*\s*$)/gs;
276
+ let propMatch: RegExpExecArray | null;
277
+ while ((propMatch = propertyRegex.exec(classJsdoc)) !== null) {
278
+ propertyDocs.set(propMatch[1], propMatch[2].trim());
279
+ }
280
+
281
+ const properties: SettingsProperty[] = [];
282
+
283
+ for (const prop of member.initializer.properties) {
284
+ if (!ts.isPropertyAssignment(prop)) continue;
285
+ const propName = prop.name.getText(sourceFile);
286
+
287
+ // Determine type and default from the initializer
288
+ let type = 'string';
289
+ let defaultValue: any = undefined;
290
+ let required = false;
291
+
292
+ const init = prop.initializer;
293
+
294
+ if (ts.isNumericLiteral(init)) {
295
+ type = 'number';
296
+ defaultValue = Number(init.text);
297
+ } else if (ts.isStringLiteral(init)) {
298
+ type = 'string';
299
+ defaultValue = init.text;
300
+ } else if (init.kind === ts.SyntaxKind.TrueKeyword) {
301
+ type = 'boolean';
302
+ defaultValue = true;
303
+ } else if (init.kind === ts.SyntaxKind.FalseKeyword) {
304
+ type = 'boolean';
305
+ defaultValue = false;
306
+ } else if (init.kind === ts.SyntaxKind.UndefinedKeyword) {
307
+ // `key: undefined` — required, type inferred from as-expression or defaults to string
308
+ required = true;
309
+ } else if (ts.isAsExpression(init)) {
310
+ // `key: undefined as string | undefined` or `key: 5 as number`
311
+ const innerInit = init.expression;
312
+ const isUndefined = innerInit.kind === ts.SyntaxKind.UndefinedKeyword ||
313
+ (ts.isIdentifier(innerInit) && innerInit.text === 'undefined');
314
+ if (isUndefined) {
315
+ required = true;
316
+ } else if (ts.isNumericLiteral(innerInit)) {
317
+ type = 'number';
318
+ defaultValue = Number(innerInit.text);
319
+ } else if (ts.isStringLiteral(innerInit)) {
320
+ type = 'string';
321
+ defaultValue = innerInit.text;
322
+ } else if (innerInit.kind === ts.SyntaxKind.TrueKeyword || innerInit.kind === ts.SyntaxKind.FalseKeyword) {
323
+ type = 'boolean';
324
+ defaultValue = innerInit.kind === ts.SyntaxKind.TrueKeyword;
325
+ }
326
+ // Try to get type from the as-expression type annotation
327
+ const typeText = init.type.getText(sourceFile).replace(/\s*\|\s*undefined/g, '').trim();
328
+ if (typeText === 'number') type = 'number';
329
+ else if (typeText === 'boolean') type = 'boolean';
330
+ else if (typeText === 'string') type = 'string';
331
+ } else if (ts.isArrayLiteralExpression(init)) {
332
+ type = 'array';
333
+ defaultValue = [];
334
+ }
335
+
336
+ properties.push({
337
+ name: propName,
338
+ type,
339
+ description: propertyDocs.get(propName),
340
+ default: defaultValue,
341
+ required,
342
+ });
343
+ }
344
+
345
+ settingsSchema = {
346
+ hasSettings: true,
347
+ properties,
348
+ description: propertyDocs.size > 0 ? 'Photon settings' : undefined,
349
+ };
350
+ };
351
+
253
352
  // Visit all nodes in the AST
254
353
  const visit = (node: ts.Node) => {
255
354
  // Look for class declarations
256
355
  if (ts.isClassDeclaration(node)) {
257
356
  node.members.forEach((member) => {
357
+ // Detect `protected settings = { ... }` property
358
+ if (ts.isPropertyDeclaration(member)) {
359
+ processSettingsProperty(member, node);
360
+ }
361
+
258
362
  // Process all public methods (sync or async)
259
363
  // Skip private/protected — only public methods become tools
260
364
  if (ts.isMethodDeclaration(member)) {
@@ -276,11 +380,18 @@ export class SchemaExtractor {
276
380
  console.error('Failed to parse TypeScript source:', error.message);
277
381
  }
278
382
 
279
- // Only include configSchema if there's a configure() method
280
383
  const result: ExtractedMetadata = { tools, templates, statics };
384
+
385
+ // Include settingsSchema if detected
386
+ if (settingsSchema) {
387
+ result.settingsSchema = settingsSchema;
388
+ }
389
+
390
+ // Include configSchema if there's a configure() method (deprecated)
281
391
  if (configSchema.hasConfigureMethod) {
282
392
  result.configSchema = configSchema;
283
393
  }
394
+
284
395
  return result;
285
396
  }
286
397
 
@@ -1230,9 +1341,6 @@ export class SchemaExtractor {
1230
1341
  if (constraints.pattern !== undefined) {
1231
1342
  s.pattern = constraints.pattern;
1232
1343
  }
1233
- if (constraints.format !== undefined) {
1234
- s.format = constraints.format;
1235
- }
1236
1344
  } else if (s.type === 'array') {
1237
1345
  if (constraints.min !== undefined) {
1238
1346
  s.minItems = constraints.min;
@@ -1246,6 +1354,9 @@ export class SchemaExtractor {
1246
1354
  }
1247
1355
 
1248
1356
  // Apply type-agnostic constraints
1357
+ if (constraints.format !== undefined) {
1358
+ s.format = constraints.format;
1359
+ }
1249
1360
  if (constraints.default !== undefined) {
1250
1361
  s.default = constraints.default;
1251
1362
  }
@@ -1815,23 +1926,29 @@ export class SchemaExtractor {
1815
1926
  extractPhotonDependencies(source: string): PhotonDependency[] {
1816
1927
  const dependencies: PhotonDependency[] = [];
1817
1928
 
1818
- // Match @photon <name> <source> pattern
1929
+ // Match @photon <name> <source> pattern, where source may include :instanceName suffix
1930
+ // e.g., @photon homeTodos todo:home → source='todo', instanceName='home'
1819
1931
  // Source ends at: newline, end of comment (*), or @ (next tag)
1820
- const photonRegex = /@photon\s+(\w+)\s+([^\s*@\n]+)/g;
1932
+ const photonRegex = /@photon\s+(\w+)\s+([^\s*@\n:]+)(?::(\w[\w-]*))?/g;
1821
1933
 
1822
1934
  let match;
1823
1935
  while ((match = photonRegex.exec(source)) !== null) {
1824
- const [, name, rawSource] = match;
1936
+ const [, name, rawSource, instanceName] = match;
1825
1937
  const photonSource = rawSource.trim();
1826
1938
 
1827
1939
  // Determine source type
1828
1940
  const sourceType = this.classifyPhotonSource(photonSource);
1829
1941
 
1830
- dependencies.push({
1942
+ const dep: PhotonDependency = {
1831
1943
  name,
1832
1944
  source: photonSource,
1833
1945
  sourceType,
1834
- });
1946
+ };
1947
+ if (instanceName) {
1948
+ dep.instanceName = instanceName;
1949
+ }
1950
+
1951
+ dependencies.push(dep);
1835
1952
  }
1836
1953
 
1837
1954
  return dependencies;
package/src/types.ts CHANGED
@@ -302,6 +302,8 @@ export interface PhotonDependency {
302
302
  source: string;
303
303
  /** Resolved source type */
304
304
  sourceType: 'marketplace' | 'github' | 'npm' | 'local';
305
+ /** Named instance to load (e.g., 'home' from `@photon homeTodos todo:home`) */
306
+ instanceName?: string;
305
307
  }
306
308
 
307
309
  /**
@@ -487,6 +489,8 @@ export interface PhotonClassExtended extends PhotonClass {
487
489
  assets?: PhotonAssets;
488
490
  /** Names of injected @photon dependencies (for client-side event routing) */
489
491
  injectedPhotons?: string[];
492
+ /** Settings schema if the photon has `protected settings = { ... }` */
493
+ settingsSchema?: SettingsSchema;
490
494
  }
491
495
 
492
496
  /** @deprecated Use PhotonClassExtended instead */
@@ -642,10 +646,56 @@ export interface WorkflowRun {
642
646
  }
643
647
 
644
648
  // ══════════════════════════════════════════════════════════════════════════════
645
- // CONFIGURATION CONVENTION
649
+ // SETTINGS (first-class property-driven configuration)
646
650
  // ══════════════════════════════════════════════════════════════════════════════
647
651
 
648
652
  /**
653
+ * A single property in a photon's settings declaration
654
+ */
655
+ export interface SettingsProperty {
656
+ /** Property name */
657
+ name: string;
658
+ /** JSON Schema type (string, number, boolean, etc.) */
659
+ type: string;
660
+ /** Description from @property JSDoc tag */
661
+ description?: string;
662
+ /** Default value from the object literal (undefined means elicitation required) */
663
+ default?: any;
664
+ /** True if default is undefined — runtime must elicit this value */
665
+ required: boolean;
666
+ }
667
+
668
+ /**
669
+ * Settings schema extracted from a photon's `protected settings = { ... }` property
670
+ *
671
+ * Example:
672
+ * ```typescript
673
+ * /**
674
+ * * @property wipLimit WIP limit for in-progress tasks (1-20)
675
+ * * @property staleTaskDays Days before a task is considered stale
676
+ * *\/
677
+ * protected settings = {
678
+ * wipLimit: 5,
679
+ * staleTaskDays: 7,
680
+ * projectsRoot: undefined as string | undefined,
681
+ * };
682
+ * ```
683
+ */
684
+ export interface SettingsSchema {
685
+ /** Whether a settings property was detected */
686
+ hasSettings: boolean;
687
+ /** Individual settings properties */
688
+ properties: SettingsProperty[];
689
+ /** Description from class-level JSDoc */
690
+ description?: string;
691
+ }
692
+
693
+ // ══════════════════════════════════════════════════════════════════════════════
694
+ // CONFIGURATION CONVENTION (deprecated — use settings property instead)
695
+ // ══════════════════════════════════════════════════════════════════════════════
696
+
697
+ /**
698
+ * @deprecated Use SettingsProperty instead
649
699
  * Configuration parameter extracted from configure() method
650
700
  */
651
701
  export interface ConfigParam {
@@ -662,35 +712,8 @@ export interface ConfigParam {
662
712
  }
663
713
 
664
714
  /**
715
+ * @deprecated Use SettingsSchema instead
665
716
  * Configuration schema extracted from a Photon's configure() method
666
- *
667
- * The configure() method is a by-convention method for photon configuration.
668
- * Similar to how main() makes a photon a UI application, configure() makes
669
- * it a configurable photon.
670
- *
671
- * When present, the framework will:
672
- * 1. Extract parameter schema from the method signature
673
- * 2. Present a configuration UI during install/setup
674
- * 3. Store config at ~/.photon/{photonName}/config.json
675
- * 4. Make config available via getConfig()
676
- *
677
- * Example:
678
- * ```typescript
679
- * export default class MyPhoton extends Photon {
680
- * async configure(params: {
681
- * apiEndpoint: string;
682
- * maxRetries?: number;
683
- * }) {
684
- * // Save config - framework handles storage
685
- * return { success: true };
686
- * }
687
- *
688
- * async getConfig() {
689
- * // Read config - framework handles loading
690
- * return loadPhotonConfig('my-photon');
691
- * }
692
- * }
693
- * ```
694
717
  */
695
718
  export interface ConfigSchema {
696
719
  /** Whether configure() method exists */