@portel/photon-core 2.14.0 → 2.16.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/base.ts CHANGED
@@ -40,11 +40,13 @@
40
40
  */
41
41
 
42
42
  import { MCPClient, MCPClientFactory, createMCPProxy } from '@portel/mcp';
43
- import { executionContext } from '@portel/cli';
43
+ import { executionContext, type CallerInfo } from '@portel/cli';
44
44
  import { getBroker } from './channels/index.js';
45
45
  import { withLock as withLockHelper } from './decorators.js';
46
46
  import { MemoryProvider } from './memory.js';
47
47
  import { ScheduleProvider } from './schedule.js';
48
+ import * as path from 'path';
49
+ import * as fs from 'fs';
48
50
 
49
51
  /**
50
52
  * Simple base class for creating Photons
@@ -61,6 +63,20 @@ export class Photon {
61
63
  */
62
64
  _photonName?: string;
63
65
 
66
+ /**
67
+ * Absolute path to the .photon.ts/.photon.js source file - set by runtime loader
68
+ * Used for storage() and assets() path resolution
69
+ * @internal
70
+ */
71
+ _photonFilePath?: string;
72
+
73
+ /**
74
+ * Dynamic photon resolver - injected by runtime loader
75
+ * Used by this.photon.use() for runtime photon access
76
+ * @internal
77
+ */
78
+ _photonResolver?: (name: string, instance?: string) => Promise<any>;
79
+
64
80
  /**
65
81
  * Scoped memory provider - lazy-initialized on first access
66
82
  * @internal
@@ -79,6 +95,28 @@ export class Photon {
79
95
  */
80
96
  _sessionId?: string;
81
97
 
98
+ /**
99
+ * Authenticated caller identity
100
+ *
101
+ * Populated from MCP OAuth when `@auth` is enabled on the photon.
102
+ * Returns the identity of whoever is calling the current method —
103
+ * human (via social login) or agent (via API key).
104
+ *
105
+ * Returns an anonymous caller if no auth token was provided.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * // In a method:
110
+ * const userId = this.caller.id; // stable user ID from JWT
111
+ * const name = this.caller.name; // display name
112
+ * const isAnon = this.caller.anonymous; // true if no auth
113
+ * ```
114
+ */
115
+ get caller(): CallerInfo {
116
+ const store = executionContext.getStore();
117
+ return store?.caller ?? { id: 'anonymous', anonymous: true };
118
+ }
119
+
82
120
  /**
83
121
  * Scoped key-value storage for photon data
84
122
  *
@@ -156,6 +194,103 @@ export class Photon {
156
194
  return this._schedule;
157
195
  }
158
196
 
197
+ /**
198
+ * Get an absolute path to a storage directory for this photon's data.
199
+ *
200
+ * Uses the symlink/installed path (not resolved) so data stays at the
201
+ * installed location. Directories are auto-created.
202
+ *
203
+ * @param subpath Sub-directory within the photon's data folder (e.g., 'auth', 'media')
204
+ * @returns Absolute path to the directory
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * const authDir = this.storage('auth');
209
+ * // ~/.photon/portel-dev/whatsapp/auth/
210
+ *
211
+ * const mediaDir = this.storage('media/images');
212
+ * // ~/.photon/portel-dev/whatsapp/media/images/
213
+ * ```
214
+ */
215
+ protected storage(subpath: string): string {
216
+ if (!this._photonFilePath) {
217
+ throw new Error(
218
+ 'storage() requires _photonFilePath to be set by the runtime loader. ' +
219
+ 'Ensure this photon is loaded through the standard runtime.'
220
+ );
221
+ }
222
+ const dir = path.dirname(this._photonFilePath);
223
+ const name = path.basename(this._photonFilePath).replace(/\.photon\.(ts|js)$/, '');
224
+ const target = path.join(dir, name, subpath);
225
+ fs.mkdirSync(target, { recursive: true });
226
+ return target;
227
+ }
228
+
229
+ /**
230
+ * Get an absolute path to an assets directory for this photon.
231
+ *
232
+ * Uses realpathSync to follow symlinks — assets travel with source code,
233
+ * not the installed location. Useful for marketplace-distributed resources
234
+ * like HTML templates, images, etc.
235
+ *
236
+ * @param subpath Sub-path within the assets folder (e.g., 'templates', 'icons/logo.png')
237
+ * @returns Absolute path to the asset file or directory
238
+ *
239
+ * @example
240
+ * ```typescript
241
+ * const templateDir = this.assets('templates');
242
+ * // /real/path/to/portel-dev/whatsapp/assets/templates/
243
+ *
244
+ * const logo = this.assets('icons/logo.png');
245
+ * // /real/path/to/portel-dev/whatsapp/assets/icons/logo.png
246
+ * ```
247
+ */
248
+ protected assets(subpath: string): string {
249
+ if (!this._photonFilePath) {
250
+ throw new Error(
251
+ 'assets() requires _photonFilePath to be set by the runtime loader. ' +
252
+ 'Ensure this photon is loaded through the standard runtime.'
253
+ );
254
+ }
255
+ const realPath = fs.realpathSync(this._photonFilePath);
256
+ const dir = path.dirname(realPath);
257
+ const name = path.basename(realPath).replace(/\.photon\.(ts|js)$/, '');
258
+ return path.join(dir, name, 'assets', subpath);
259
+ }
260
+
261
+ /**
262
+ * Dynamic photon access
263
+ *
264
+ * Provides runtime access to other photons by name, with optional instance selection.
265
+ * Supports both short names and namespace-qualified names.
266
+ *
267
+ * @example
268
+ * ```typescript
269
+ * // Get default instance
270
+ * const wa = await this.photon.use('whatsapp');
271
+ *
272
+ * // Get named instance
273
+ * const personal = await this.photon.use('whatsapp', 'personal');
274
+ *
275
+ * // Cross-namespace access
276
+ * const wa2 = await this.photon.use('portel-dev:whatsapp', 'work');
277
+ * ```
278
+ */
279
+ get photon(): { use: (name: string, instance?: string) => Promise<any> } {
280
+ const resolver = this._photonResolver;
281
+ return {
282
+ use: async (name: string, instance?: string) => {
283
+ if (!resolver) {
284
+ throw new Error(
285
+ 'this.photon.use() requires a runtime with photon resolution. ' +
286
+ 'Ensure this photon is loaded through the standard runtime.'
287
+ );
288
+ }
289
+ return resolver(name, instance);
290
+ },
291
+ };
292
+ }
293
+
159
294
  /**
160
295
  * Emit an event/progress update
161
296
  *
@@ -216,6 +351,45 @@ export class Photon {
216
351
  });
217
352
  }
218
353
  }
354
+
355
+ /**
356
+ * Render a formatted value as an intermediate result
357
+ *
358
+ * Sends a value to the client (Beam, CLI, MCP) rendered with the specified
359
+ * format — the same formats available via `@format` docblock tags. Each call
360
+ * replaces the previous render in the result panel.
361
+ *
362
+ * For custom formats, place an HTML renderer at `assets/formats/<name>.html`.
363
+ *
364
+ * @param format The format type (table, qr, chart:bar, dashboard, or custom)
365
+ * @param value The data to render — same shape as a return value with that @format
366
+ *
367
+ * @example
368
+ * ```typescript
369
+ * // Show a QR code mid-execution
370
+ * this.render('qr', { value: 'https://wa.link/...' });
371
+ *
372
+ * // Show a status table
373
+ * this.render('table', [['Step', 'Status'], ['Auth', 'Done']]);
374
+ *
375
+ * // Composite dashboard
376
+ * this.render('dashboard', {
377
+ * qr: { format: 'qr', data: 'https://wa.link/...' },
378
+ * status: { format: 'text', data: 'Scan the QR code above' }
379
+ * });
380
+ * ```
381
+ */
382
+ protected render(format: string, value: any): void;
383
+ protected render(): void;
384
+ protected render(format?: string, value?: any): void {
385
+ if (format === undefined) {
386
+ // Clear the render zone without rendering new content
387
+ this.emit({ emit: 'render:clear' });
388
+ } else {
389
+ this.emit({ emit: 'render', format, value });
390
+ }
391
+ }
392
+
219
393
  /**
220
394
  * Cross-photon call handler - injected by runtime
221
395
  * @internal
@@ -305,16 +479,13 @@ export class Photon {
305
479
  ]);
306
480
 
307
481
  // Get all property names from prototype chain
482
+ // Use getOwnPropertyDescriptor to avoid triggering getters (which may call storage())
308
483
  let current = prototype;
309
484
  while (current && current !== Photon.prototype) {
310
485
  Object.getOwnPropertyNames(current).forEach((name) => {
311
- // Skip private methods (starting with _) and convention methods
312
- if (
313
- !name.startsWith('_') &&
314
- !conventionMethods.has(name) &&
315
- typeof (prototype as any)[name] === 'function' &&
316
- !methods.includes(name)
317
- ) {
486
+ if (name.startsWith('_') || conventionMethods.has(name) || methods.includes(name)) return;
487
+ const desc = Object.getOwnPropertyDescriptor(current, name);
488
+ if (desc && typeof desc.value === 'function') {
318
489
  methods.push(name);
319
490
  }
320
491
  });
@@ -466,4 +637,106 @@ export class Photon {
466
637
  ): Promise<T> {
467
638
  return withLockHelper(lockName, fn, timeout);
468
639
  }
640
+
641
+ // ═══════════════════════════════════════════════════════════════════════════
642
+ // IDENTITY-AWARE LOCK MANAGEMENT
643
+ // ═══════════════════════════════════════════════════════════════════════════
644
+
645
+ /**
646
+ * Identity-aware lock handler - injected by runtime
647
+ * @internal
648
+ */
649
+ _lockHandler?: {
650
+ assign(lockName: string, holder: string, timeout?: number): Promise<boolean>;
651
+ transfer(lockName: string, fromHolder: string, toHolder: string, timeout?: number): Promise<boolean>;
652
+ release(lockName: string, holder: string): Promise<boolean>;
653
+ query(lockName: string): Promise<{ holder: string | null; acquiredAt?: number; expiresAt?: number }>;
654
+ };
655
+
656
+ /**
657
+ * Assign a lock to a specific caller (identity-aware)
658
+ *
659
+ * Unlike `withLock` which auto-acquires/releases around a function,
660
+ * this explicitly assigns a lock to a caller ID. The lock persists
661
+ * until transferred or released.
662
+ *
663
+ * @param lockName Name of the lock
664
+ * @param callerId Caller ID to assign the lock to
665
+ * @param timeout Lock timeout in ms (default 30000, auto-extended on transfer)
666
+ *
667
+ * @example
668
+ * ```typescript
669
+ * // Assign "turn" lock to first player
670
+ * await this.acquireLock('turn', this.caller.id);
671
+ * ```
672
+ */
673
+ protected async acquireLock(lockName: string, callerId: string, timeout?: number): Promise<boolean> {
674
+ if (!this._lockHandler) {
675
+ console.warn(`[photon] acquireLock('${lockName}'): no lock handler configured`);
676
+ return true;
677
+ }
678
+ return this._lockHandler.assign(lockName, callerId, timeout);
679
+ }
680
+
681
+ /**
682
+ * Transfer a lock from the current holder to another caller
683
+ *
684
+ * Only succeeds if `fromCallerId` is the current holder.
685
+ *
686
+ * @param lockName Name of the lock
687
+ * @param toCallerId Caller ID to transfer the lock to
688
+ * @param fromCallerId Current holder (defaults to this.caller.id)
689
+ *
690
+ * @example
691
+ * ```typescript
692
+ * // After a chess move, transfer turn to opponent
693
+ * await this.transferLock('turn', opponentId);
694
+ * ```
695
+ */
696
+ protected async transferLock(lockName: string, toCallerId: string, fromCallerId?: string): Promise<boolean> {
697
+ if (!this._lockHandler) {
698
+ console.warn(`[photon] transferLock('${lockName}'): no lock handler configured`);
699
+ return true;
700
+ }
701
+ return this._lockHandler.transfer(lockName, fromCallerId ?? this.caller.id, toCallerId);
702
+ }
703
+
704
+ /**
705
+ * Release a lock (make the method open to anyone)
706
+ *
707
+ * @param lockName Name of the lock
708
+ * @param callerId Holder to release from (defaults to this.caller.id)
709
+ *
710
+ * @example
711
+ * ```typescript
712
+ * // Presenter releases navigation control to audience
713
+ * await this.releaseLock('navigation');
714
+ * ```
715
+ */
716
+ protected async releaseLock(lockName: string, callerId?: string): Promise<boolean> {
717
+ if (!this._lockHandler) {
718
+ console.warn(`[photon] releaseLock('${lockName}'): no lock handler configured`);
719
+ return true;
720
+ }
721
+ return this._lockHandler.release(lockName, callerId ?? this.caller.id);
722
+ }
723
+
724
+ /**
725
+ * Query who holds a specific lock
726
+ *
727
+ * @param lockName Name of the lock
728
+ * @returns Lock holder info, or null holder if unlocked
729
+ *
730
+ * @example
731
+ * ```typescript
732
+ * const lock = await this.getLock('turn');
733
+ * if (lock.holder === this.caller.id) { ... }
734
+ * ```
735
+ */
736
+ protected async getLock(lockName: string): Promise<{ holder: string | null; acquiredAt?: number; expiresAt?: number }> {
737
+ if (!this._lockHandler) {
738
+ return { holder: null };
739
+ }
740
+ return this._lockHandler.query(lockName);
741
+ }
469
742
  }
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
@@ -74,6 +74,7 @@ export {
74
74
  executionContext,
75
75
  runWithContext,
76
76
  getContext,
77
+ type CallerInfo,
77
78
 
78
79
  // Text Utils
79
80
  TextUtils,
@@ -166,12 +167,15 @@ export { SchemaExtractor, detectCapabilities, type PhotonCapability } from './sc
166
167
  export {
167
168
  resolvePath,
168
169
  listFiles,
170
+ listFilesWithNamespace,
169
171
  ensureDir,
170
172
  resolvePhotonPath,
171
173
  listPhotonFiles,
174
+ listPhotonFilesWithNamespace,
172
175
  ensurePhotonDir,
173
176
  DEFAULT_PHOTON_DIR,
174
177
  type ResolverOptions,
178
+ type ListedPhoton,
175
179
  } from './path-resolver.js';
176
180
 
177
181
  // Types
@@ -239,6 +243,8 @@ export {
239
243
  type EmitArtifact,
240
244
  type EmitUI,
241
245
  type EmitQR,
246
+ type EmitRender,
247
+ type EmitRenderClear,
242
248
 
243
249
  // Checkpoint yield type
244
250
  type CheckpointYield,
package/src/mixins.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { MCPClient, MCPClientFactory, createMCPProxy } from '@portel/mcp';
26
- import { executionContext } from '@portel/cli';
26
+ import { executionContext, type CallerInfo } from '@portel/cli';
27
27
  import { getBroker } from './channels/index.js';
28
28
  import { MemoryProvider } from './memory.js';
29
29
  import { ScheduleProvider } from './schedule.js';
@@ -94,6 +94,14 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
94
94
  */
95
95
  private _mcpClients: Map<string, MCPClient & Record<string, (params?: any) => Promise<any>>> = new Map();
96
96
 
97
+ /**
98
+ * Authenticated caller identity from MCP OAuth
99
+ */
100
+ get caller(): CallerInfo {
101
+ const store = executionContext.getStore();
102
+ return store?.caller ?? { id: 'anonymous', anonymous: true };
103
+ }
104
+
97
105
  /**
98
106
  * Scoped key-value storage for photon data
99
107
  */
@@ -124,6 +132,18 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
124
132
  return this._schedule;
125
133
  }
126
134
 
135
+ /**
136
+ * Render a formatted value as an intermediate result.
137
+ * Each call replaces the previous render. Call with no args to clear.
138
+ */
139
+ protected render(format?: string, value?: any): void {
140
+ if (format === undefined) {
141
+ this.emit({ emit: 'render:clear' });
142
+ } else {
143
+ this.emit({ emit: 'render', format, value });
144
+ }
145
+ }
146
+
127
147
  /**
128
148
  * Emit an event/progress update
129
149
  */
@@ -139,9 +159,15 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
139
159
  }
140
160
 
141
161
  if (data && typeof data.channel === 'string') {
162
+ // Auto-prefix channel with photon name if not already namespaced
163
+ const channel = data.channel.includes(':')
164
+ ? data.channel
165
+ : this._photonName
166
+ ? `${this._photonName}:${data.channel}`
167
+ : data.channel;
142
168
  const broker = getBroker();
143
169
  broker.publish({
144
- channel: data.channel,
170
+ channel,
145
171
  event: data.event || 'message',
146
172
  data: data.data !== undefined ? data.data : data,
147
173
  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;