@portel/photon-core 2.9.2 → 2.9.4

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, NotificationSubscription } from './types.js';
14
14
  import { parseDuration, parseRate } from './utils/duration.js';
15
15
  import { builtinRegistry, type MiddlewareDeclaration } from './middleware.js';
16
16
 
@@ -18,8 +18,12 @@ 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;
25
+ /** Notification subscription from @notify-on tag */
26
+ notificationSubscriptions?: NotificationSubscription;
23
27
  }
24
28
 
25
29
  /**
@@ -47,13 +51,19 @@ export class SchemaExtractor {
47
51
  const templates: TemplateInfo[] = [];
48
52
  const statics: StaticInfo[] = [];
49
53
 
50
- // Configuration schema tracking
54
+ // Settings schema tracking (new property-based approach)
55
+ let settingsSchema: SettingsSchema | undefined;
56
+
57
+ // Configuration schema tracking (deprecated method-based approach)
51
58
  let configSchema: ConfigSchema = {
52
59
  hasConfigureMethod: false,
53
60
  hasGetConfigMethod: false,
54
61
  params: [],
55
62
  };
56
63
 
64
+ // Notification subscriptions tracking (from @notify-on tag)
65
+ let notificationSubscriptions: NotificationSubscription | undefined;
66
+
57
67
  try {
58
68
  // If source doesn't contain a class declaration, wrap it in one
59
69
  let sourceToParse = source;
@@ -70,7 +80,7 @@ export class SchemaExtractor {
70
80
  );
71
81
 
72
82
  // Helper to process a method declaration
73
- const processMethod = (member: ts.MethodDeclaration) => {
83
+ const processMethod = (member: ts.MethodDeclaration, isStatefulClass: boolean = false) => {
74
84
  const methodName = member.name.getText(sourceFile);
75
85
 
76
86
  // Skip private methods (prefixed with _)
@@ -78,32 +88,32 @@ export class SchemaExtractor {
78
88
  return;
79
89
  }
80
90
 
81
- // Handle configuration convention methods specially
91
+ // Track configure/getConfig for backward compat metadata (deprecated)
92
+ // These are no longer hidden — they become normal visible tools
82
93
  if (methodName === 'configure') {
83
94
  configSchema.hasConfigureMethod = true;
84
- const jsdoc = this.getJSDocComment(member, sourceFile);
85
- configSchema.description = this.extractDescription(jsdoc);
95
+ const jsdocConfig = this.getJSDocComment(member, sourceFile);
96
+ configSchema.description = this.extractDescription(jsdocConfig);
86
97
 
87
- // Extract configure() parameters as config schema
88
98
  const paramsType = this.getFirstParameterType(member, sourceFile);
89
99
  if (paramsType) {
90
- const { properties, required } = this.buildSchemaFromType(paramsType, sourceFile);
91
- const paramDocs = this.extractParamDocs(jsdoc);
100
+ const { properties: configProps, required: configRequired } = this.buildSchemaFromType(paramsType, sourceFile);
101
+ const paramDocs = this.extractParamDocs(jsdocConfig);
92
102
 
93
- configSchema.params = Object.keys(properties).map(name => ({
103
+ configSchema.params = Object.keys(configProps).map(name => ({
94
104
  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,
105
+ type: configProps[name].type || 'string',
106
+ description: paramDocs.get(name) || configProps[name].description,
107
+ required: configRequired.includes(name),
108
+ defaultValue: configProps[name].default,
99
109
  }));
100
110
  }
101
- return; // Don't add configure() as a tool
111
+ // Fall through configure() is now a normal tool (not hidden)
102
112
  }
103
113
 
104
114
  if (methodName === 'getConfig') {
105
115
  configSchema.hasGetConfigMethod = true;
106
- return; // Don't add getConfig() as a tool
116
+ // Fall through getConfig() is now a normal tool (not hidden)
107
117
  }
108
118
 
109
119
  const jsdoc = this.getJSDocComment(member, sourceFile);
@@ -212,6 +222,19 @@ export class SchemaExtractor {
212
222
  // Check for static keyword on the method
213
223
  const isStaticMethod = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) || false;
214
224
 
225
+ // Event emission for @stateful classes: all public methods emit events automatically
226
+ const emitsEventData = isStatefulClass ? {
227
+ emitsEvent: true,
228
+ eventName: methodName,
229
+ eventPayload: {
230
+ method: 'string',
231
+ params: 'object',
232
+ result: 'any',
233
+ timestamp: 'string',
234
+ instance: 'string',
235
+ },
236
+ } : {};
237
+
215
238
  tools.push({
216
239
  name: methodName,
217
240
  description,
@@ -246,15 +269,123 @@ export class SchemaExtractor {
246
269
  ...(deprecated !== undefined ? { deprecated } : {}),
247
270
  // Unified middleware declarations (new — runtime uses this)
248
271
  ...(middleware.length > 0 ? { middleware } : {}),
272
+ // Event emission (for @stateful classes)
273
+ ...emitsEventData,
274
+ });
275
+ }
276
+ };
277
+
278
+ // Helper to extract settings from a `protected settings = { ... }` property
279
+ const processSettingsProperty = (member: ts.PropertyDeclaration, classNode: ts.ClassDeclaration) => {
280
+ const name = member.name.getText(sourceFile);
281
+ if (name !== 'settings') return;
282
+
283
+ // Must be protected
284
+ const isProtected = member.modifiers?.some(
285
+ m => m.kind === ts.SyntaxKind.ProtectedKeyword
286
+ );
287
+ if (!isProtected) return;
288
+
289
+ // Must have an object literal initializer
290
+ if (!member.initializer || !ts.isObjectLiteralExpression(member.initializer)) return;
291
+
292
+ // Get class-level JSDoc for @property descriptions
293
+ const classJsdoc = this.getJSDocComment(classNode as any, sourceFile);
294
+ const propertyDocs = new Map<string, string>();
295
+ const propertyRegex = /@property\s+(\w+)\s+(.*?)(?=\n\s*\*\s*@|\n\s*\*\/|\n\s*\*\s*$)/gs;
296
+ let propMatch: RegExpExecArray | null;
297
+ while ((propMatch = propertyRegex.exec(classJsdoc)) !== null) {
298
+ propertyDocs.set(propMatch[1], propMatch[2].trim());
299
+ }
300
+
301
+ const properties: SettingsProperty[] = [];
302
+
303
+ for (const prop of member.initializer.properties) {
304
+ if (!ts.isPropertyAssignment(prop)) continue;
305
+ const propName = prop.name.getText(sourceFile);
306
+
307
+ // Determine type and default from the initializer
308
+ let type = 'string';
309
+ let defaultValue: any = undefined;
310
+ let required = false;
311
+
312
+ const init = prop.initializer;
313
+
314
+ if (ts.isNumericLiteral(init)) {
315
+ type = 'number';
316
+ defaultValue = Number(init.text);
317
+ } else if (ts.isStringLiteral(init)) {
318
+ type = 'string';
319
+ defaultValue = init.text;
320
+ } else if (init.kind === ts.SyntaxKind.TrueKeyword) {
321
+ type = 'boolean';
322
+ defaultValue = true;
323
+ } else if (init.kind === ts.SyntaxKind.FalseKeyword) {
324
+ type = 'boolean';
325
+ defaultValue = false;
326
+ } else if (init.kind === ts.SyntaxKind.UndefinedKeyword) {
327
+ // `key: undefined` — required, type inferred from as-expression or defaults to string
328
+ required = true;
329
+ } else if (ts.isAsExpression(init)) {
330
+ // `key: undefined as string | undefined` or `key: 5 as number`
331
+ const innerInit = init.expression;
332
+ const isUndefined = innerInit.kind === ts.SyntaxKind.UndefinedKeyword ||
333
+ (ts.isIdentifier(innerInit) && innerInit.text === 'undefined');
334
+ if (isUndefined) {
335
+ required = true;
336
+ } else if (ts.isNumericLiteral(innerInit)) {
337
+ type = 'number';
338
+ defaultValue = Number(innerInit.text);
339
+ } else if (ts.isStringLiteral(innerInit)) {
340
+ type = 'string';
341
+ defaultValue = innerInit.text;
342
+ } else if (innerInit.kind === ts.SyntaxKind.TrueKeyword || innerInit.kind === ts.SyntaxKind.FalseKeyword) {
343
+ type = 'boolean';
344
+ defaultValue = innerInit.kind === ts.SyntaxKind.TrueKeyword;
345
+ }
346
+ // Try to get type from the as-expression type annotation
347
+ const typeText = init.type.getText(sourceFile).replace(/\s*\|\s*undefined/g, '').trim();
348
+ if (typeText === 'number') type = 'number';
349
+ else if (typeText === 'boolean') type = 'boolean';
350
+ else if (typeText === 'string') type = 'string';
351
+ } else if (ts.isArrayLiteralExpression(init)) {
352
+ type = 'array';
353
+ defaultValue = [];
354
+ }
355
+
356
+ properties.push({
357
+ name: propName,
358
+ type,
359
+ description: propertyDocs.get(propName),
360
+ default: defaultValue,
361
+ required,
249
362
  });
250
363
  }
364
+
365
+ settingsSchema = {
366
+ hasSettings: true,
367
+ properties,
368
+ description: propertyDocs.size > 0 ? 'Photon settings' : undefined,
369
+ };
251
370
  };
252
371
 
253
372
  // Visit all nodes in the AST
254
373
  const visit = (node: ts.Node) => {
255
374
  // Look for class declarations
256
375
  if (ts.isClassDeclaration(node)) {
376
+ // Check if this class has @stateful decorator
377
+ const classJsdoc = this.getJSDocComment(node as any, sourceFile);
378
+ const isStatefulClass = /@stateful\b/i.test(classJsdoc);
379
+
380
+ // Extract notification subscriptions from @notify-on tag
381
+ notificationSubscriptions = this.extractNotifyOn(classJsdoc);
382
+
257
383
  node.members.forEach((member) => {
384
+ // Detect `protected settings = { ... }` property
385
+ if (ts.isPropertyDeclaration(member)) {
386
+ processSettingsProperty(member, node);
387
+ }
388
+
258
389
  // Process all public methods (sync or async)
259
390
  // Skip private/protected — only public methods become tools
260
391
  if (ts.isMethodDeclaration(member)) {
@@ -262,7 +393,7 @@ export class SchemaExtractor {
262
393
  m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword
263
394
  );
264
395
  if (!isPrivate) {
265
- processMethod(member);
396
+ processMethod(member, isStatefulClass);
266
397
  }
267
398
  }
268
399
  });
@@ -276,11 +407,23 @@ export class SchemaExtractor {
276
407
  console.error('Failed to parse TypeScript source:', error.message);
277
408
  }
278
409
 
279
- // Only include configSchema if there's a configure() method
280
410
  const result: ExtractedMetadata = { tools, templates, statics };
411
+
412
+ // Include settingsSchema if detected
413
+ if (settingsSchema) {
414
+ result.settingsSchema = settingsSchema;
415
+ }
416
+
417
+ // Include configSchema if there's a configure() method (deprecated)
281
418
  if (configSchema.hasConfigureMethod) {
282
419
  result.configSchema = configSchema;
283
420
  }
421
+
422
+ // Include notification subscriptions if detected
423
+ if (notificationSubscriptions) {
424
+ result.notificationSubscriptions = notificationSubscriptions;
425
+ }
426
+
284
427
  return result;
285
428
  }
286
429
 
@@ -1230,9 +1373,6 @@ export class SchemaExtractor {
1230
1373
  if (constraints.pattern !== undefined) {
1231
1374
  s.pattern = constraints.pattern;
1232
1375
  }
1233
- if (constraints.format !== undefined) {
1234
- s.format = constraints.format;
1235
- }
1236
1376
  } else if (s.type === 'array') {
1237
1377
  if (constraints.min !== undefined) {
1238
1378
  s.minItems = constraints.min;
@@ -1246,6 +1386,9 @@ export class SchemaExtractor {
1246
1386
  }
1247
1387
 
1248
1388
  // Apply type-agnostic constraints
1389
+ if (constraints.format !== undefined) {
1390
+ s.format = constraints.format;
1391
+ }
1249
1392
  if (constraints.default !== undefined) {
1250
1393
  s.default = constraints.default;
1251
1394
  }
@@ -1352,6 +1495,30 @@ export class SchemaExtractor {
1352
1495
  return /@async\b/i.test(jsdocContent);
1353
1496
  }
1354
1497
 
1498
+ /**
1499
+ * Extract notification subscriptions from @notify-on tag
1500
+ * Specifies which event types this photon is interested in
1501
+ * Format: @notify-on mentions, deadlines, errors
1502
+ */
1503
+ private extractNotifyOn(jsdocContent: string): NotificationSubscription | undefined {
1504
+ const notifyMatch = jsdocContent.match(/@notify-on\s+([^\n]+)/i);
1505
+ if (!notifyMatch) {
1506
+ return undefined;
1507
+ }
1508
+
1509
+ // Parse comma-separated event types
1510
+ const watchFor = notifyMatch[1]
1511
+ .split(',')
1512
+ .map(s => s.trim())
1513
+ .filter(s => s.length > 0);
1514
+
1515
+ if (watchFor.length === 0) {
1516
+ return undefined;
1517
+ }
1518
+
1519
+ return { watchFor };
1520
+ }
1521
+
1355
1522
  // ═══════════════════════════════════════════════════════════════════════════════
1356
1523
  // DAEMON FEATURE EXTRACTION
1357
1524
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1815,23 +1982,29 @@ export class SchemaExtractor {
1815
1982
  extractPhotonDependencies(source: string): PhotonDependency[] {
1816
1983
  const dependencies: PhotonDependency[] = [];
1817
1984
 
1818
- // Match @photon <name> <source> pattern
1985
+ // Match @photon <name> <source> pattern, where source may include :instanceName suffix
1986
+ // e.g., @photon homeTodos todo:home → source='todo', instanceName='home'
1819
1987
  // Source ends at: newline, end of comment (*), or @ (next tag)
1820
- const photonRegex = /@photon\s+(\w+)\s+([^\s*@\n]+)/g;
1988
+ const photonRegex = /@photon\s+(\w+)\s+([^\s*@\n:]+)(?::(\w[\w-]*))?/g;
1821
1989
 
1822
1990
  let match;
1823
1991
  while ((match = photonRegex.exec(source)) !== null) {
1824
- const [, name, rawSource] = match;
1992
+ const [, name, rawSource, instanceName] = match;
1825
1993
  const photonSource = rawSource.trim();
1826
1994
 
1827
1995
  // Determine source type
1828
1996
  const sourceType = this.classifyPhotonSource(photonSource);
1829
1997
 
1830
- dependencies.push({
1998
+ const dep: PhotonDependency = {
1831
1999
  name,
1832
2000
  source: photonSource,
1833
2001
  sourceType,
1834
- });
2002
+ };
2003
+ if (instanceName) {
2004
+ dep.instanceName = instanceName;
2005
+ }
2006
+
2007
+ dependencies.push(dep);
1835
2008
  }
1836
2009
 
1837
2010
  return dependencies;
package/src/types.ts CHANGED
@@ -191,6 +191,26 @@ export interface ExtractedSchema {
191
191
 
192
192
  /** When true, method uses individual params instead of a single params object */
193
193
  simpleParams?: boolean;
194
+
195
+ // ═══ EVENT EMISSION ═══
196
+
197
+ /**
198
+ * True if this method automatically emits an event on execution
199
+ * (for @stateful classes, all public methods emit automatically)
200
+ */
201
+ emitsEvent?: boolean;
202
+
203
+ /**
204
+ * Event name that will be emitted when this method is called
205
+ * For @stateful classes, defaults to the method name (e.g., 'add', 'done')
206
+ */
207
+ eventName?: string;
208
+
209
+ /**
210
+ * Event payload structure — what data will be sent with the event
211
+ * Typically mirrors the return type of the method
212
+ */
213
+ eventPayload?: Record<string, string>;
194
214
  }
195
215
 
196
216
  export interface PhotonClass {
@@ -302,6 +322,8 @@ export interface PhotonDependency {
302
322
  source: string;
303
323
  /** Resolved source type */
304
324
  sourceType: 'marketplace' | 'github' | 'npm' | 'local';
325
+ /** Named instance to load (e.g., 'home' from `@photon homeTodos todo:home`) */
326
+ instanceName?: string;
305
327
  }
306
328
 
307
329
  /**
@@ -487,6 +509,8 @@ export interface PhotonClassExtended extends PhotonClass {
487
509
  assets?: PhotonAssets;
488
510
  /** Names of injected @photon dependencies (for client-side event routing) */
489
511
  injectedPhotons?: string[];
512
+ /** Settings schema if the photon has `protected settings = { ... }` */
513
+ settingsSchema?: SettingsSchema;
490
514
  }
491
515
 
492
516
  /** @deprecated Use PhotonClassExtended instead */
@@ -642,10 +666,56 @@ export interface WorkflowRun {
642
666
  }
643
667
 
644
668
  // ══════════════════════════════════════════════════════════════════════════════
645
- // CONFIGURATION CONVENTION
669
+ // SETTINGS (first-class property-driven configuration)
670
+ // ══════════════════════════════════════════════════════════════════════════════
671
+
672
+ /**
673
+ * A single property in a photon's settings declaration
674
+ */
675
+ export interface SettingsProperty {
676
+ /** Property name */
677
+ name: string;
678
+ /** JSON Schema type (string, number, boolean, etc.) */
679
+ type: string;
680
+ /** Description from @property JSDoc tag */
681
+ description?: string;
682
+ /** Default value from the object literal (undefined means elicitation required) */
683
+ default?: any;
684
+ /** True if default is undefined — runtime must elicit this value */
685
+ required: boolean;
686
+ }
687
+
688
+ /**
689
+ * Settings schema extracted from a photon's `protected settings = { ... }` property
690
+ *
691
+ * Example:
692
+ * ```typescript
693
+ * /**
694
+ * * @property wipLimit WIP limit for in-progress tasks (1-20)
695
+ * * @property staleTaskDays Days before a task is considered stale
696
+ * *\/
697
+ * protected settings = {
698
+ * wipLimit: 5,
699
+ * staleTaskDays: 7,
700
+ * projectsRoot: undefined as string | undefined,
701
+ * };
702
+ * ```
703
+ */
704
+ export interface SettingsSchema {
705
+ /** Whether a settings property was detected */
706
+ hasSettings: boolean;
707
+ /** Individual settings properties */
708
+ properties: SettingsProperty[];
709
+ /** Description from class-level JSDoc */
710
+ description?: string;
711
+ }
712
+
713
+ // ══════════════════════════════════════════════════════════════════════════════
714
+ // CONFIGURATION CONVENTION (deprecated — use settings property instead)
646
715
  // ══════════════════════════════════════════════════════════════════════════════
647
716
 
648
717
  /**
718
+ * @deprecated Use SettingsProperty instead
649
719
  * Configuration parameter extracted from configure() method
650
720
  */
651
721
  export interface ConfigParam {
@@ -662,35 +732,8 @@ export interface ConfigParam {
662
732
  }
663
733
 
664
734
  /**
735
+ * @deprecated Use SettingsSchema instead
665
736
  * 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
737
  */
695
738
  export interface ConfigSchema {
696
739
  /** Whether configure() method exists */
@@ -702,3 +745,31 @@ export interface ConfigSchema {
702
745
  /** Description from configure() JSDoc */
703
746
  description?: string;
704
747
  }
748
+
749
+ // ══════════════════════════════════════════════════════════════════════════════
750
+ // NOTIFICATION SUBSCRIPTIONS (@notify-on tag)
751
+ // ══════════════════════════════════════════════════════════════════════════════
752
+
753
+ /**
754
+ * Notification subscription declaration extracted from @notify-on tag
755
+ * Specifies which event types this photon cares about for notifications
756
+ */
757
+ export interface NotificationSubscription {
758
+ /** Event types this photon wants to be notified about */
759
+ watchFor: string[];
760
+ }
761
+
762
+ /**
763
+ * Notification metadata attached to method return values via __notification property
764
+ * Specifies priority and type of notification when an event occurs
765
+ */
766
+ export interface NotificationMetadata {
767
+ /** Type of notification (mentions, deadline, error, etc.) */
768
+ type: string;
769
+ /** Priority level for display and handling */
770
+ priority: 'critical' | 'warning' | 'info';
771
+ /** Optional message to display */
772
+ message?: string;
773
+ /** Optional tags for additional filtering */
774
+ tags?: string[];
775
+ }