@portel/photon-core 2.14.0 → 2.15.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.
package/src/generator.ts CHANGED
@@ -730,6 +730,23 @@ export interface EmitQR {
730
730
  value: string;
731
731
  }
732
732
 
733
+ /**
734
+ * Render emit — sends a formatted intermediate result to the client.
735
+ * The value is rendered using the same format pipeline as @format return values.
736
+ * Custom formats are resolved from assets/formats/<name>.html.
737
+ */
738
+ export interface EmitRender {
739
+ emit: 'render';
740
+ /** Format type (table, qr, chart:bar, dashboard, or custom name) */
741
+ format: string;
742
+ /** Data to render — same shape as a return value with that @format */
743
+ value: any;
744
+ }
745
+
746
+ export interface EmitRenderClear {
747
+ emit: 'render:clear';
748
+ }
749
+
733
750
  export type EmitYield =
734
751
  | EmitStatus
735
752
  | EmitProgress
@@ -739,7 +756,9 @@ export type EmitYield =
739
756
  | EmitThinking
740
757
  | EmitArtifact
741
758
  | EmitUI
742
- | EmitQR;
759
+ | EmitQR
760
+ | EmitRender
761
+ | EmitRenderClear;
743
762
 
744
763
  // ══════════════════════════════════════════════════════════════════════════════
745
764
  // COMBINED TYPES
package/src/index.ts CHANGED
@@ -166,12 +166,15 @@ export { SchemaExtractor, detectCapabilities, type PhotonCapability } from './sc
166
166
  export {
167
167
  resolvePath,
168
168
  listFiles,
169
+ listFilesWithNamespace,
169
170
  ensureDir,
170
171
  resolvePhotonPath,
171
172
  listPhotonFiles,
173
+ listPhotonFilesWithNamespace,
172
174
  ensurePhotonDir,
173
175
  DEFAULT_PHOTON_DIR,
174
176
  type ResolverOptions,
177
+ type ListedPhoton,
175
178
  } from './path-resolver.js';
176
179
 
177
180
  // Types
@@ -239,6 +242,8 @@ export {
239
242
  type EmitArtifact,
240
243
  type EmitUI,
241
244
  type EmitQR,
245
+ type EmitRender,
246
+ type EmitRenderClear,
242
247
 
243
248
  // Checkpoint yield type
244
249
  type CheckpointYield,
package/src/mixins.ts CHANGED
@@ -124,6 +124,18 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
124
124
  return this._schedule;
125
125
  }
126
126
 
127
+ /**
128
+ * Render a formatted value as an intermediate result.
129
+ * Each call replaces the previous render. Call with no args to clear.
130
+ */
131
+ protected render(format?: string, value?: any): void {
132
+ if (format === undefined) {
133
+ this.emit({ emit: 'render:clear' });
134
+ } else {
135
+ this.emit({ emit: 'render', format, value });
136
+ }
137
+ }
138
+
127
139
  /**
128
140
  * Emit an event/progress update
129
141
  */
@@ -139,9 +151,15 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
139
151
  }
140
152
 
141
153
  if (data && typeof data.channel === 'string') {
154
+ // Auto-prefix channel with photon name if not already namespaced
155
+ const channel = data.channel.includes(':')
156
+ ? data.channel
157
+ : this._photonName
158
+ ? `${this._photonName}:${data.channel}`
159
+ : data.channel;
142
160
  const broker = getBroker();
143
161
  broker.publish({
144
- channel: data.channel,
162
+ channel,
145
163
  event: data.event || 'message',
146
164
  data: data.data !== undefined ? data.data : data,
147
165
  timestamp: Date.now(),
@@ -3,6 +3,14 @@
3
3
  *
4
4
  * Generic path resolution utilities used by photon, lumina, ncp.
5
5
  * Configurable file extensions and default directories.
6
+ *
7
+ * Supports namespace-based directory structure:
8
+ * ~/.photon/
9
+ * portel-dev/ ← namespace (marketplace author)
10
+ * whatsapp.photon.ts
11
+ * local/ ← implicit namespace for user-created photons
12
+ * todo.photon.ts
13
+ * legacy.photon.ts ← flat files (pre-migration, still supported)
6
14
  */
7
15
 
8
16
  import * as fs from 'fs/promises';
@@ -37,9 +45,17 @@ const defaultOptions: Required<ResolverOptions> = {
37
45
  defaultDir: DEFAULT_PHOTON_DIR,
38
46
  };
39
47
 
48
+ /** Directories to skip when scanning for namespace subdirectories */
49
+ const SKIP_DIRS = new Set([
50
+ 'state', 'context', 'env', '.cache', '.config',
51
+ 'node_modules', 'marketplace', 'photons', 'templates',
52
+ ]);
53
+
40
54
  /**
41
- * Resolve a file path from name
42
- * Looks in the specified working directory, or uses absolute path if provided
55
+ * Resolve a file path from name.
56
+ *
57
+ * Supports namespace-qualified names: 'namespace:photonName'
58
+ * For unqualified names, searches flat files first, then namespace subdirectories.
43
59
  */
44
60
  export async function resolvePath(
45
61
  name: string,
@@ -59,66 +75,172 @@ export async function resolvePath(
59
75
  }
60
76
  }
61
77
 
78
+ // Parse namespace:name format
79
+ const colonIndex = name.indexOf(':');
80
+ let namespace: string | undefined;
81
+ let photonName: string;
82
+ if (colonIndex !== -1) {
83
+ namespace = name.slice(0, colonIndex);
84
+ photonName = name.slice(colonIndex + 1);
85
+ } else {
86
+ photonName = name;
87
+ }
88
+
62
89
  // Remove extension if provided (match any configured extension)
63
- let basename = name;
90
+ let basename = photonName;
64
91
  for (const ext of opts.extensions) {
65
- if (name.endsWith(ext)) {
66
- basename = name.slice(0, -ext.length);
92
+ if (photonName.endsWith(ext)) {
93
+ basename = photonName.slice(0, -ext.length);
67
94
  break;
68
95
  }
69
96
  }
70
97
 
71
- // Try each extension
98
+ // If namespace is specified, search only that namespace directory
99
+ if (namespace) {
100
+ for (const ext of opts.extensions) {
101
+ const filePath = path.join(dir, namespace, `${basename}${ext}`);
102
+ try {
103
+ await fs.access(filePath);
104
+ return filePath;
105
+ } catch {
106
+ // Continue
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ // Unqualified name: search flat files first (backward compat)
72
113
  for (const ext of opts.extensions) {
73
114
  const filePath = path.join(dir, `${basename}${ext}`);
74
115
  try {
75
116
  await fs.access(filePath);
76
117
  return filePath;
77
118
  } catch {
78
- // Continue to next extension
119
+ // Continue
120
+ }
121
+ }
122
+
123
+ // Then search namespace subdirectories (one level deep)
124
+ try {
125
+ const entries = await fs.readdir(dir, { withFileTypes: true });
126
+ for (const entry of entries) {
127
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
128
+ continue;
129
+ }
130
+ for (const ext of opts.extensions) {
131
+ const filePath = path.join(dir, entry.name, `${basename}${ext}`);
132
+ try {
133
+ await fs.access(filePath);
134
+ return filePath;
135
+ } catch {
136
+ // Continue
137
+ }
138
+ }
79
139
  }
140
+ } catch {
141
+ // dir doesn't exist
80
142
  }
81
143
 
82
- // Not found
83
144
  return null;
84
145
  }
85
146
 
86
147
  /**
87
- * List all matching files in a directory
148
+ * Result from listing files, including namespace information.
149
+ */
150
+ export interface ListedPhoton {
151
+ /** Short name (e.g., 'whatsapp') */
152
+ name: string;
153
+ /** Namespace (e.g., 'portel-dev') or empty string for flat/root-level files */
154
+ namespace: string;
155
+ /** Qualified name (e.g., 'portel-dev:whatsapp' or 'whatsapp' for flat) */
156
+ qualifiedName: string;
157
+ /** Full absolute path to the file */
158
+ filePath: string;
159
+ }
160
+
161
+ /**
162
+ * List all matching files in a directory.
163
+ *
164
+ * Scans both flat files (backward compat) and namespace subdirectories.
165
+ * Returns short names for backward compatibility.
88
166
  */
89
167
  export async function listFiles(
90
168
  workingDir?: string,
91
169
  options?: ResolverOptions
92
170
  ): Promise<string[]> {
171
+ const listed = await listFilesWithNamespace(workingDir, options);
172
+ return listed.map((l) => l.name).sort();
173
+ }
174
+
175
+ /**
176
+ * List all matching files with full namespace metadata.
177
+ *
178
+ * Scans flat files at the root level and one level of namespace subdirectories.
179
+ */
180
+ export async function listFilesWithNamespace(
181
+ workingDir?: string,
182
+ options?: ResolverOptions
183
+ ): Promise<ListedPhoton[]> {
93
184
  const opts = { ...defaultOptions, ...options };
94
185
  const dir = expandTilde(workingDir || opts.defaultDir);
186
+ const results: ListedPhoton[] = [];
95
187
 
96
188
  try {
97
- // Ensure directory exists
98
189
  await fs.mkdir(dir, { recursive: true });
99
-
100
190
  const entries = await fs.readdir(dir, { withFileTypes: true });
101
- const files: string[] = [];
102
191
 
192
+ // Scan flat files at root level (backward compat / pre-migration)
103
193
  for (const entry of entries) {
104
- // Include both regular files and symlinks
105
194
  if (entry.isFile() || entry.isSymbolicLink()) {
106
- // Check if file matches any extension
107
195
  for (const ext of opts.extensions) {
108
196
  if (entry.name.endsWith(ext)) {
109
- // Remove extension for display
110
197
  const name = entry.name.slice(0, -ext.length);
111
- files.push(name);
198
+ results.push({
199
+ name,
200
+ namespace: '',
201
+ qualifiedName: name,
202
+ filePath: path.join(dir, entry.name),
203
+ });
112
204
  break;
113
205
  }
114
206
  }
115
207
  }
116
208
  }
117
209
 
118
- return files.sort();
210
+ // Scan namespace subdirectories (one level deep)
211
+ for (const entry of entries) {
212
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
213
+ continue;
214
+ }
215
+
216
+ const nsDir = path.join(dir, entry.name);
217
+ try {
218
+ const nsEntries = await fs.readdir(nsDir, { withFileTypes: true });
219
+ for (const nsEntry of nsEntries) {
220
+ if (nsEntry.isFile() || nsEntry.isSymbolicLink()) {
221
+ for (const ext of opts.extensions) {
222
+ if (nsEntry.name.endsWith(ext)) {
223
+ const name = nsEntry.name.slice(0, -ext.length);
224
+ results.push({
225
+ name,
226
+ namespace: entry.name,
227
+ qualifiedName: `${entry.name}:${name}`,
228
+ filePath: path.join(nsDir, nsEntry.name),
229
+ });
230
+ break;
231
+ }
232
+ }
233
+ }
234
+ }
235
+ } catch {
236
+ // Namespace dir unreadable, skip
237
+ }
238
+ }
119
239
  } catch {
120
- return [];
240
+ // Root dir doesn't exist
121
241
  }
242
+
243
+ return results;
122
244
  }
123
245
 
124
246
  /**
@@ -132,4 +254,5 @@ export async function ensureDir(dir?: string): Promise<void> {
132
254
  // Convenience aliases for photon-specific usage
133
255
  export const resolvePhotonPath = resolvePath;
134
256
  export const listPhotonFiles = listFiles;
257
+ export const listPhotonFilesWithNamespace = listFilesWithNamespace;
135
258
  export const ensurePhotonDir = ensureDir;
@@ -368,7 +368,7 @@ function wrapStatefulMethods(
368
368
 
369
369
  // Skip framework-injected methods from withPhotonCapabilities
370
370
  const frameworkMethods = new Set([
371
- 'emit', 'call', 'mcp', 'setMCPFactory', 'onInitialize', 'onShutdown',
371
+ 'emit', 'render', 'call', 'mcp', 'setMCPFactory', 'onInitialize', 'onShutdown',
372
372
  ]);
373
373
 
374
374
  // Walk the prototype chain to find all public methods
@@ -1329,6 +1329,7 @@ export class SchemaExtractor {
1329
1329
  .replace(/\{@pattern\s+[^}]+\}/g, '')
1330
1330
  .replace(/\{@format\s+[^}]+\}/g, '')
1331
1331
  .replace(/\{@choice\s+[^}]+\}/g, '')
1332
+ .replace(/\{@choice-from\s+[^}]+\}/g, '')
1332
1333
  .replace(/\{@field\s+[^}]+\}/g, '')
1333
1334
  .replace(/\{@default\s+[^}]+\}/g, '')
1334
1335
  .replace(/\{@unique(?:Items)?\s*\}/g, '')
@@ -1404,6 +1405,12 @@ export class SchemaExtractor {
1404
1405
  paramConstraints.enum = choices;
1405
1406
  }
1406
1407
 
1408
+ // Extract {@choice-from toolName} or {@choice-from toolName.field}
1409
+ const choiceFromMatch = description.match(/\{@choice-from\s+([^}]+)\}/);
1410
+ if (choiceFromMatch) {
1411
+ paramConstraints.choiceFrom = choiceFromMatch[1].trim();
1412
+ }
1413
+
1407
1414
  // Extract {@field type} - hints for UI form rendering
1408
1415
  const fieldMatch = description.match(/\{@field\s+([a-z]+)\}/);
1409
1416
  if (fieldMatch) {
@@ -1526,7 +1533,7 @@ export class SchemaExtractor {
1526
1533
  }
1527
1534
 
1528
1535
  // Validate no unknown {@...} tags (typos in constraint names)
1529
- const allKnownTags = ['min', 'max', 'pattern', 'format', 'choice', 'field', 'default', 'unique', 'uniqueItems',
1536
+ const allKnownTags = ['min', 'max', 'pattern', 'format', 'choice', 'choice-from', 'field', 'default', 'unique', 'uniqueItems',
1530
1537
  'example', 'multipleOf', 'deprecated', 'readOnly', 'writeOnly', 'label', 'placeholder',
1531
1538
  'hint', 'hidden', 'accept', 'minItems', 'maxItems'];
1532
1539
  const unknownTagRegex = /\{@([\w-]+)\s*(?:\s+[^}]*)?\}/g;
@@ -1715,6 +1722,10 @@ export class SchemaExtractor {
1715
1722
  s.enum = constraints.enum;
1716
1723
  }
1717
1724
  }
1725
+ // Apply dynamic choice provider (x-choiceFrom extension)
1726
+ if (constraints.choiceFrom !== undefined) {
1727
+ s['x-choiceFrom'] = constraints.choiceFrom;
1728
+ }
1718
1729
  // Apply field hint for UI rendering
1719
1730
  if (constraints.field !== undefined) {
1720
1731
  s.field = constraints.field;
@@ -2652,7 +2663,7 @@ export class SchemaExtractor {
2652
2663
  };
2653
2664
  }
2654
2665
 
2655
- // Check if matches an @photon declaration
2666
+ // Check if matches an @photon declaration (exact match)
2656
2667
  if (photonMap.has(param.name)) {
2657
2668
  return {
2658
2669
  param,
@@ -2661,6 +2672,25 @@ export class SchemaExtractor {
2661
2672
  };
2662
2673
  }
2663
2674
 
2675
+ // Instance-aware DI: if paramName ends with a photon dep name (case-insensitive),
2676
+ // the prefix becomes the instance name.
2677
+ // e.g., personalWhatsapp + @photon whatsapp → instance "personal" of whatsapp
2678
+ // workWhatsapp + @photon whatsapp → instance "work" of whatsapp
2679
+ for (const [depName, dep] of photonMap) {
2680
+ const lowerParam = param.name.toLowerCase();
2681
+ const lowerDep = depName.toLowerCase();
2682
+ if (lowerParam.endsWith(lowerDep) && lowerParam.length > lowerDep.length) {
2683
+ const prefix = param.name.slice(0, param.name.length - depName.length);
2684
+ // Ensure the prefix is a valid instance name (lowercase the first char)
2685
+ const instanceName = prefix.charAt(0).toLowerCase() + prefix.slice(1);
2686
+ return {
2687
+ param,
2688
+ injectionType: 'photon' as const,
2689
+ photonDependency: { ...dep, instanceName: instanceName || undefined },
2690
+ };
2691
+ }
2692
+ }
2693
+
2664
2694
  // Non-primitive with default on @stateful class → persisted state
2665
2695
  if (isStateful && param.hasDefault) {
2666
2696
  return {
@@ -2826,7 +2856,15 @@ export class SchemaExtractor {
2826
2856
  // Link UI asset to this method
2827
2857
  const asset = uiAssets.find(a => a.id === uiId);
2828
2858
  if (asset) {
2829
- asset.linkedTool = methodName;
2859
+ // First method wins as primary (used for app detection)
2860
+ if (!asset.linkedTool) {
2861
+ asset.linkedTool = methodName;
2862
+ }
2863
+ // Track all methods that reference this UI
2864
+ if (!asset.linkedTools) asset.linkedTools = [];
2865
+ if (!asset.linkedTools.includes(methodName)) {
2866
+ asset.linkedTools.push(methodName);
2867
+ }
2830
2868
  }
2831
2869
  }
2832
2870
  }
@@ -2896,6 +2934,7 @@ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'in
2896
2934
  export function detectCapabilities(source: string): Set<PhotonCapability> {
2897
2935
  const caps = new Set<PhotonCapability>();
2898
2936
  if (/this\.emit\s*\(/.test(source)) caps.add('emit');
2937
+ if (/this\.render\s*\(/.test(source)) caps.add('emit'); // render() needs emit injection
2899
2938
  if (/this\.memory\b/.test(source)) caps.add('memory');
2900
2939
  if (/this\.call\s*\(/.test(source)) caps.add('call');
2901
2940
  if (/this\.mcp\s*\(/.test(source)) caps.add('mcp');
package/src/types.ts CHANGED
@@ -432,8 +432,10 @@ export interface UIAsset {
432
432
  resolvedPath?: string;
433
433
  /** MIME type (detected from extension) */
434
434
  mimeType?: string;
435
- /** Tool this UI is linked to (from method @ui annotation) */
435
+ /** Primary tool this UI is linked to (first method with @ui annotation — used for app detection) */
436
436
  linkedTool?: string;
437
+ /** All tools that reference this UI asset (multiple methods can share one template) */
438
+ linkedTools?: string[];
437
439
  /** MCP resource URI (set by loader, e.g., 'ui://photon-name/main-ui') */
438
440
  uri?: string;
439
441
  }