@portel/photon-core 2.17.6 → 2.18.1

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.
Files changed (71) hide show
  1. package/dist/audit.d.ts +4 -0
  2. package/dist/audit.d.ts.map +1 -1
  3. package/dist/audit.js +107 -36
  4. package/dist/audit.js.map +1 -1
  5. package/dist/base.d.ts +81 -12
  6. package/dist/base.d.ts.map +1 -1
  7. package/dist/base.js +80 -7
  8. package/dist/base.js.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/compiler.js +9 -1
  11. package/dist/compiler.js.map +1 -1
  12. package/dist/config.d.ts +14 -28
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +48 -46
  15. package/dist/config.js.map +1 -1
  16. package/dist/data-paths.d.ts +115 -0
  17. package/dist/data-paths.d.ts.map +1 -0
  18. package/dist/data-paths.js +243 -0
  19. package/dist/data-paths.js.map +1 -0
  20. package/dist/dependency-manager.d.ts +1 -1
  21. package/dist/dependency-manager.d.ts.map +1 -1
  22. package/dist/dependency-manager.js +13 -5
  23. package/dist/dependency-manager.js.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/instance-store.d.ts +22 -11
  29. package/dist/instance-store.d.ts.map +1 -1
  30. package/dist/instance-store.js +63 -28
  31. package/dist/instance-store.js.map +1 -1
  32. package/dist/memory.d.ts +8 -6
  33. package/dist/memory.d.ts.map +1 -1
  34. package/dist/memory.js +49 -35
  35. package/dist/memory.js.map +1 -1
  36. package/dist/mixins.d.ts.map +1 -1
  37. package/dist/mixins.js +22 -3
  38. package/dist/mixins.js.map +1 -1
  39. package/dist/path-resolver.d.ts +7 -2
  40. package/dist/path-resolver.d.ts.map +1 -1
  41. package/dist/path-resolver.js +75 -2
  42. package/dist/path-resolver.js.map +1 -1
  43. package/dist/photon-loader-lite.d.ts +2 -0
  44. package/dist/photon-loader-lite.d.ts.map +1 -1
  45. package/dist/photon-loader-lite.js +3 -3
  46. package/dist/photon-loader-lite.js.map +1 -1
  47. package/dist/schedule.d.ts.map +1 -1
  48. package/dist/schedule.js +11 -7
  49. package/dist/schedule.js.map +1 -1
  50. package/dist/schema-extractor.js +1 -1
  51. package/dist/schema-extractor.js.map +1 -1
  52. package/dist/stateful.d.ts +2 -1
  53. package/dist/stateful.d.ts.map +1 -1
  54. package/dist/stateful.js +4 -3
  55. package/dist/stateful.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/audit.ts +111 -38
  58. package/src/base.ts +117 -19
  59. package/src/compiler.ts +10 -1
  60. package/src/config.ts +59 -46
  61. package/src/data-paths.ts +289 -0
  62. package/src/dependency-manager.ts +13 -5
  63. package/src/index.ts +4 -0
  64. package/src/instance-store.ts +70 -30
  65. package/src/memory.ts +60 -38
  66. package/src/mixins.ts +24 -3
  67. package/src/path-resolver.ts +75 -3
  68. package/src/photon-loader-lite.ts +5 -3
  69. package/src/schedule.ts +11 -7
  70. package/src/schema-extractor.ts +1 -1
  71. package/src/stateful.ts +5 -2
package/src/audit.ts CHANGED
@@ -19,9 +19,14 @@
19
19
 
20
20
  import * as fs from 'fs';
21
21
  import * as path from 'path';
22
- import * as os from 'os';
23
22
  import * as crypto from 'crypto';
24
23
 
24
+ import {
25
+ getPhotonLogsDir,
26
+ getLegacyLogsDir,
27
+ getDataRoot,
28
+ } from './data-paths.js';
29
+
25
30
  /**
26
31
  * A single execution record
27
32
  */
@@ -73,19 +78,25 @@ export function generateExecutionId(): string {
73
78
  }
74
79
 
75
80
  /**
76
- * Get the logs directory for a photon
81
+ * Get the logs directory for a photon.
82
+ * New path: .data/{namespace}/{photonName}/logs/
83
+ * Falls back to legacy ~/.photon/logs/{photonId}/ for reads.
77
84
  */
78
- function getLogDir(photonId: string): string {
79
- const safeName = photonId.replace(/[^a-zA-Z0-9_-]/g, '_');
80
- const baseDir = process.env.PHOTON_LOG_DIR || path.join(os.homedir(), '.photon', 'logs');
81
- return path.join(baseDir, safeName);
85
+ function getLogDir(photonId: string, namespace?: string): string {
86
+ const ns = namespace || 'local';
87
+ const newDir = getPhotonLogsDir(ns, photonId);
88
+ if (!fs.existsSync(newDir)) {
89
+ const legacyDir = getLegacyLogsDir(photonId);
90
+ if (fs.existsSync(legacyDir)) return legacyDir;
91
+ }
92
+ return newDir;
82
93
  }
83
94
 
84
95
  /**
85
96
  * Get the executions log file path
86
97
  */
87
- function getLogPath(photonId: string): string {
88
- return path.join(getLogDir(photonId), 'executions.jsonl');
98
+ function getLogPath(photonId: string, namespace?: string): string {
99
+ return path.join(getLogDir(photonId, namespace), 'executions.jsonl');
89
100
  }
90
101
 
91
102
  /**
@@ -234,16 +245,9 @@ export class AuditTrail {
234
245
  return this.findInLog(getLogPath(photonId), executionId);
235
246
  }
236
247
 
237
- // Otherwise, search all photon logs
238
- const baseDir = process.env.PHOTON_LOG_DIR || path.join(os.homedir(), '.photon', 'logs');
239
- if (!fs.existsSync(baseDir)) return null;
240
-
241
- const dirs = fs.readdirSync(baseDir, { withFileTypes: true })
242
- .filter(d => d.isDirectory())
243
- .map(d => d.name);
244
-
245
- for (const dir of dirs) {
246
- const found = this.findInLog(path.join(baseDir, dir, 'executions.jsonl'), executionId);
248
+ // Search all photon logs across namespaces in .data/
249
+ for (const logPath of this.allLogPaths()) {
250
+ const found = this.findInLog(logPath, executionId);
247
251
  if (found) return found;
248
252
  }
249
253
 
@@ -260,17 +264,8 @@ export class AuditTrail {
260
264
  const result = [root];
261
265
 
262
266
  // Find all children across all photon logs
263
- const baseDir = process.env.PHOTON_LOG_DIR || path.join(os.homedir(), '.photon', 'logs');
264
- if (!fs.existsSync(baseDir)) return result;
265
-
266
- const dirs = fs.readdirSync(baseDir, { withFileTypes: true })
267
- .filter(d => d.isDirectory())
268
- .map(d => d.name);
269
-
270
- for (const dir of dirs) {
271
- const logPath = path.join(baseDir, dir, 'executions.jsonl');
267
+ for (const logPath of this.allLogPaths()) {
272
268
  if (!fs.existsSync(logPath)) continue;
273
-
274
269
  const content = fs.readFileSync(logPath, 'utf-8');
275
270
  const lines = content.trim().split('\n').filter(Boolean);
276
271
 
@@ -294,13 +289,47 @@ export class AuditTrail {
294
289
  * List all photons that have execution logs
295
290
  */
296
291
  listPhotons(): string[] {
297
- const baseDir = process.env.PHOTON_LOG_DIR || path.join(os.homedir(), '.photon', 'logs');
298
- if (!fs.existsSync(baseDir)) return [];
292
+ const results: string[] = [];
293
+ const dataRoot = getDataRoot();
294
+ if (!fs.existsSync(dataRoot)) return results;
295
+
296
+ try {
297
+ // Scan .data/{ns}/{photon}/logs/executions.jsonl
298
+ const nsDirs = fs.readdirSync(dataRoot, { withFileTypes: true })
299
+ .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'));
300
+
301
+ for (const nsDir of nsDirs) {
302
+ const nsPath = path.join(dataRoot, nsDir.name);
303
+ const photonDirs = fs.readdirSync(nsPath, { withFileTypes: true })
304
+ .filter(e => e.isDirectory());
305
+
306
+ for (const pDir of photonDirs) {
307
+ if (fs.existsSync(path.join(nsPath, pDir.name, 'logs', 'executions.jsonl'))) {
308
+ results.push(pDir.name);
309
+ }
310
+ }
311
+ }
312
+ } catch {
313
+ // Unreadable
314
+ }
299
315
 
300
- return fs.readdirSync(baseDir, { withFileTypes: true })
301
- .filter(d => d.isDirectory())
302
- .filter(d => fs.existsSync(path.join(baseDir, d.name, 'executions.jsonl')))
303
- .map(d => d.name);
316
+ // Also check legacy path
317
+ try {
318
+ const legacyDir = path.join(path.dirname(dataRoot), 'logs');
319
+ if (fs.existsSync(legacyDir)) {
320
+ const dirs = fs.readdirSync(legacyDir, { withFileTypes: true })
321
+ .filter(d => d.isDirectory())
322
+ .filter(d => fs.existsSync(path.join(legacyDir, d.name, 'executions.jsonl')))
323
+ .map(d => d.name);
324
+ for (const d of dirs) {
325
+ if (!results.includes(d)) results.push(d);
326
+ }
327
+ }
328
+ } catch {
329
+ // Legacy dir doesn't exist
330
+ }
331
+
332
+ return results;
304
333
  }
305
334
 
306
335
  /**
@@ -366,10 +395,12 @@ export class AuditTrail {
366
395
  return;
367
396
  }
368
397
 
369
- // Clear all
370
- const baseDir = process.env.PHOTON_LOG_DIR || path.join(os.homedir(), '.photon', 'logs');
371
- if (fs.existsSync(baseDir)) {
372
- fs.rmSync(baseDir, { recursive: true, force: true });
398
+ // Clear all — remove log dirs inside .data/{ns}/{photon}/logs/
399
+ for (const logPath of this.allLogPaths()) {
400
+ const logDir = path.dirname(logPath);
401
+ if (fs.existsSync(logDir)) {
402
+ fs.rmSync(logDir, { recursive: true, force: true });
403
+ }
373
404
  }
374
405
  }
375
406
 
@@ -402,6 +433,48 @@ export class AuditTrail {
402
433
  return output;
403
434
  }
404
435
 
436
+ /**
437
+ * Collect all executions.jsonl paths across namespaces
438
+ */
439
+ private allLogPaths(): string[] {
440
+ const paths: string[] = [];
441
+ const dataRoot = getDataRoot();
442
+
443
+ try {
444
+ if (fs.existsSync(dataRoot)) {
445
+ const nsDirs = fs.readdirSync(dataRoot, { withFileTypes: true })
446
+ .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'));
447
+
448
+ for (const nsDir of nsDirs) {
449
+ const nsPath = path.join(dataRoot, nsDir.name);
450
+ try {
451
+ const photonDirs = fs.readdirSync(nsPath, { withFileTypes: true })
452
+ .filter(e => e.isDirectory());
453
+ for (const pDir of photonDirs) {
454
+ const logPath = path.join(nsPath, pDir.name, 'logs', 'executions.jsonl');
455
+ if (fs.existsSync(logPath)) paths.push(logPath);
456
+ }
457
+ } catch { /* skip unreadable ns dir */ }
458
+ }
459
+ }
460
+ } catch { /* data root doesn't exist */ }
461
+
462
+ // Also check legacy
463
+ try {
464
+ const legacyDir = path.join(path.dirname(dataRoot), 'logs');
465
+ if (fs.existsSync(legacyDir)) {
466
+ const dirs = fs.readdirSync(legacyDir, { withFileTypes: true })
467
+ .filter(d => d.isDirectory());
468
+ for (const d of dirs) {
469
+ const logPath = path.join(legacyDir, d.name, 'executions.jsonl');
470
+ if (fs.existsSync(logPath)) paths.push(logPath);
471
+ }
472
+ }
473
+ } catch { /* legacy doesn't exist */ }
474
+
475
+ return paths;
476
+ }
477
+
405
478
  /**
406
479
  * Find a record by ID in a specific log file
407
480
  */
package/src/base.ts CHANGED
@@ -47,6 +47,7 @@ import { MemoryProvider } from './memory.js';
47
47
  import { ScheduleProvider } from './schedule.js';
48
48
  import * as path from 'path';
49
49
  import * as fs from 'fs';
50
+ import { getPhotonDataDir } from './data-paths.js';
50
51
 
51
52
  /**
52
53
  * Simple base class for creating Photons
@@ -63,6 +64,13 @@ export class Photon {
63
64
  */
64
65
  _photonName?: string;
65
66
 
67
+ /**
68
+ * Photon namespace (marketplace owner) - set by runtime loader
69
+ * Used for data path resolution: .data/{namespace}/{name}/
70
+ * @internal
71
+ */
72
+ _photonNamespace?: string;
73
+
66
74
  /**
67
75
  * Absolute path to the .photon.ts/.photon.js source file - set by runtime loader
68
76
  * Used for storage() and assets() path resolution
@@ -145,7 +153,7 @@ export class Photon {
145
153
  .replace(/([A-Z])/g, '-$1')
146
154
  .toLowerCase()
147
155
  .replace(/^-/, '');
148
- this._memory = new MemoryProvider(name, this._sessionId);
156
+ this._memory = new MemoryProvider(name, this._sessionId, this._photonNamespace);
149
157
  }
150
158
  return this._memory;
151
159
  }
@@ -219,9 +227,9 @@ export class Photon {
219
227
  'Ensure this photon is loaded through the standard runtime.'
220
228
  );
221
229
  }
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);
230
+ const name = this._photonName || path.basename(this._photonFilePath).replace(/\.photon\.(ts|js)$/, '');
231
+ const ns = this._photonNamespace || 'local';
232
+ const target = path.join(getPhotonDataDir(ns, name), subpath);
225
233
  fs.mkdirSync(target, { recursive: true });
226
234
  return target;
227
235
  }
@@ -386,35 +394,125 @@ export class Photon {
386
394
  * format — the same formats available via `@format` docblock tags. Each call
387
395
  * replaces the previous render in the result panel.
388
396
  *
397
+ * Also supports UI feedback formats: status, progress, toast.
389
398
  * For custom formats, place an HTML renderer at `assets/formats/<name>.html`.
390
399
  *
391
- * @param format The format type (table, qr, chart:bar, dashboard, or custom)
392
- * @param value The data to render — same shape as a return value with that @format
400
+ * @param format The format type (table, qr, status, progress, toast, guide, or custom)
401
+ * @param value The data to render — shape depends on format
393
402
  *
394
403
  * @example
395
404
  * ```typescript
396
- * // Show a QR code mid-execution
397
- * this.render('qr', { value: 'https://wa.link/...' });
398
- *
399
- * // Show a status table
405
+ * // Status message
406
+ * this.render('status', 'Connecting...');
407
+ * this.render('status', { message: 'Error!', type: 'error' });
408
+ *
409
+ * // Progress bar (0–1)
410
+ * this.render('progress', 0.5);
411
+ * this.render('progress', { value: 0.75, message: 'Almost done' });
412
+ *
413
+ * // Toast notification
414
+ * this.render('toast', 'Saved!');
415
+ * this.render('toast', { message: 'Done!', type: 'success' });
416
+ *
417
+ * // Multi-step guide
418
+ * this.render('guide', [
419
+ * { label: 'Create bot', status: 'done' },
420
+ * { label: 'Enter token', status: 'active' },
421
+ * { label: 'Connect', status: 'pending' },
422
+ * ]);
423
+ *
424
+ * // Formatted data
400
425
  * this.render('table', [['Step', 'Status'], ['Auth', 'Done']]);
401
- *
402
- * // Composite dashboard
403
- * this.render('dashboard', {
404
- * qr: { format: 'qr', data: 'https://wa.link/...' },
405
- * status: { format: 'text', data: 'Scan the QR code above' }
406
- * });
426
+ * this.render('qr', { value: 'https://wa.link/...' });
407
427
  * ```
408
428
  */
409
429
  protected render(format: string, value: any): void;
410
430
  protected render(): void;
411
431
  protected render(format?: string, value?: any): void {
412
432
  if (format === undefined) {
413
- // Clear the render zone without rendering new content
414
433
  this.emit({ emit: 'render:clear' });
415
- } else {
416
- this.emit({ emit: 'render', format, value });
434
+ return;
417
435
  }
436
+
437
+ // UI feedback formats — emit native shapes the frontend already handles
438
+ switch (format) {
439
+ case 'status':
440
+ this.emit(typeof value === 'string'
441
+ ? { emit: 'status', message: value }
442
+ : { emit: 'status', ...value });
443
+ return;
444
+ case 'progress':
445
+ this.emit(typeof value === 'number'
446
+ ? { emit: 'progress', value }
447
+ : { emit: 'progress', ...value });
448
+ return;
449
+ case 'toast':
450
+ this.emit(typeof value === 'string'
451
+ ? { emit: 'toast', message: value }
452
+ : { emit: 'toast', ...value });
453
+ return;
454
+ }
455
+
456
+ // All other formats — generic render
457
+ this.emit({ emit: 'render', format, value });
458
+ }
459
+
460
+ /**
461
+ * Channel interface for communicating with connected clients (e.g. Claude Code).
462
+ * No-ops silently when the photon is not marked with @channel.
463
+ *
464
+ * Call directly to send a message:
465
+ * this.channel('Hello', { chat_id: '123' })
466
+ *
467
+ * Use .respond() to answer permission requests:
468
+ * this.channel.respond(request_id, 'allow')
469
+ *
470
+ * Use .onPermission() to handle incoming permission requests:
471
+ * this.channel.onPermission((req) => { ... })
472
+ */
473
+ protected channel: {
474
+ (content: string, meta?: Record<string, string>): void;
475
+ respond(requestId: string, behavior: 'allow' | 'deny'): void;
476
+ onPermission(handler: (request: { request_id: string; tool_name: string; description: string; input_preview: string }) => void): void;
477
+ } = Object.assign(
478
+ (_content: string, _meta?: Record<string, string>) => {
479
+ // Injected by the loader — no-op by default
480
+ },
481
+ {
482
+ respond: (_requestId: string, _behavior: 'allow' | 'deny') => {
483
+ // Injected by the loader — no-op by default
484
+ },
485
+ onPermission: (_handler: (request: any) => void) => {
486
+ // Injected by the loader — no-op by default
487
+ },
488
+ }
489
+ );
490
+
491
+ /**
492
+ * Create a blocking input request for use in generator methods.
493
+ *
494
+ * Returns a yield object — use with `yield` in async generators:
495
+ * ```typescript
496
+ * const name = yield this.ask('text', 'What is your name?');
497
+ * ```
498
+ *
499
+ * @param type Input type: text, password, confirm, select, number, file, date, form, url
500
+ * @param message The prompt message shown to the user
501
+ * @param options Type-specific options (placeholder, pattern, min/max, etc.)
502
+ *
503
+ * @example
504
+ * ```typescript
505
+ * async *setup() {
506
+ * const token = yield this.ask('password', 'Enter API key:');
507
+ * const env = yield this.ask('select', 'Environment:', {
508
+ * options: ['dev', 'staging', 'prod']
509
+ * });
510
+ * const confirmed = yield this.ask('confirm', `Deploy to ${env}?`);
511
+ * }
512
+ * ```
513
+ */
514
+ protected ask(type: string, message: string, options?: Record<string, any>): { ask: string; message: string; [key: string]: any } {
515
+ return { ask: type, message, ...options };
418
516
  }
419
517
 
420
518
  /**
package/src/compiler.ts CHANGED
@@ -174,8 +174,17 @@ export async function compilePhotonTS(
174
174
  // Ensure cache directory exists
175
175
  await fs.mkdir(options.cacheDir, { recursive: true });
176
176
 
177
+ // Inject createRequire shim so CJS dependencies work in ESM context.
178
+ // This is needed for Node.js — Bun handles CJS/ESM interop natively.
179
+ const requireShim = `import { createRequire as __createRequire } from 'module';
180
+ import { fileURLToPath as __fileURLToPath } from 'url';
181
+ const __filename = __fileURLToPath(import.meta.url);
182
+ const require = __createRequire(import.meta.url);
183
+ `;
184
+ const code = requireShim + result.code;
185
+
177
186
  // Write compiled JavaScript
178
- await fs.writeFile(cachedJsPath, result.code, 'utf-8');
187
+ await fs.writeFile(cachedJsPath, code, 'utf-8');
179
188
 
180
189
  return cachedJsPath;
181
190
  }
package/src/config.ts CHANGED
@@ -1,69 +1,73 @@
1
1
  /**
2
2
  * Photon Configuration Utilities
3
3
  *
4
- * Provides standard config storage for photons that implement the configure() convention.
5
- * Config is stored at ~/.photon/{photonName}/config.json
4
+ * Provides standard config storage for photons.
5
+ * Config is stored at .data/{namespace}/{photonName}/config.json
6
6
  *
7
7
  * Usage in a Photon:
8
8
  * ```typescript
9
- * import { loadPhotonConfig, savePhotonConfig, getPhotonConfigPath } from '@portel/photon-core';
9
+ * import { loadPhotonConfig, savePhotonConfig } from '@portel/photon-core';
10
10
  *
11
11
  * export default class MyPhoton extends Photon {
12
12
  * async configure(params: { apiKey: string }) {
13
13
  * savePhotonConfig('my-photon', params);
14
14
  * return { success: true, config: params };
15
15
  * }
16
- *
17
- * async getConfig() {
18
- * return loadPhotonConfig('my-photon');
19
- * }
20
16
  * }
21
17
  * ```
22
18
  */
23
19
 
24
20
  import * as fs from 'fs';
25
21
  import * as path from 'path';
26
- import * as os from 'os';
22
+
23
+ import {
24
+ getPhotonConfigPath as getNewConfigPath,
25
+ getLegacyPhotonConfigPath,
26
+ getDataRoot,
27
+ } from './data-paths.js';
27
28
 
28
29
  /**
29
- * Get the config directory for photons
30
- * Default: ~/.photon/
30
+ * Get the config file path for a specific photon.
31
+ * Uses new .data/ layout, falls back to legacy path for reads.
31
32
  */
32
- export function getPhotonConfigDir(): string {
33
- return process.env.PHOTON_CONFIG_DIR || path.join(os.homedir(), '.photon');
33
+ export function getPhotonConfigPath(photonName: string, namespace?: string): string {
34
+ const ns = namespace || 'local';
35
+ const newPath = getNewConfigPath(ns, photonName);
36
+
37
+ // Fallback: check legacy path for existing config
38
+ if (!fs.existsSync(newPath)) {
39
+ const legacyPath = getLegacyPhotonConfigPath(photonName);
40
+ if (fs.existsSync(legacyPath)) return legacyPath;
41
+ }
42
+
43
+ return newPath;
34
44
  }
35
45
 
36
46
  /**
37
- * Get the config file path for a specific photon
38
- * @param photonName The photon name (kebab-case)
39
- * @returns Path to config.json for this photon
47
+ * Get the config directory for photons (legacy compat)
48
+ * @deprecated Use getPhotonConfigPath with namespace instead
40
49
  */
41
- export function getPhotonConfigPath(photonName: string): string {
42
- const safeName = photonName.replace(/[^a-zA-Z0-9_-]/g, '_');
43
- return path.join(getPhotonConfigDir(), safeName, 'config.json');
50
+ export function getPhotonConfigDir(): string {
51
+ return process.env.PHOTON_CONFIG_DIR || getDataRoot();
44
52
  }
45
53
 
46
54
  /**
47
55
  * Load configuration for a photon
48
- * @param photonName The photon name (kebab-case)
49
- * @param defaults Default values if config doesn't exist
50
- * @returns The config object or defaults
51
56
  */
52
57
  export function loadPhotonConfig<T extends Record<string, any>>(
53
58
  photonName: string,
54
- defaults?: T
59
+ defaults?: T,
60
+ namespace?: string
55
61
  ): T {
56
- const configPath = getPhotonConfigPath(photonName);
62
+ const configPath = getPhotonConfigPath(photonName, namespace);
57
63
 
58
64
  try {
59
65
  if (fs.existsSync(configPath)) {
60
66
  const content = fs.readFileSync(configPath, 'utf-8');
61
67
  const config = JSON.parse(content);
62
- // Merge with defaults
63
68
  return defaults ? { ...defaults, ...config } : config;
64
69
  }
65
70
  } catch (error) {
66
- // Log but don't throw - return defaults
67
71
  if (process.env.PHOTON_DEBUG) {
68
72
  console.error(`Failed to load config for ${photonName}:`, error);
69
73
  }
@@ -73,18 +77,17 @@ export function loadPhotonConfig<T extends Record<string, any>>(
73
77
  }
74
78
 
75
79
  /**
76
- * Save configuration for a photon
77
- * @param photonName The photon name (kebab-case)
78
- * @param config The configuration object to save
80
+ * Save configuration for a photon (always writes to new .data/ path)
79
81
  */
80
82
  export function savePhotonConfig<T extends Record<string, any>>(
81
83
  photonName: string,
82
- config: T
84
+ config: T,
85
+ namespace?: string
83
86
  ): void {
84
- const configPath = getPhotonConfigPath(photonName);
87
+ const ns = namespace || 'local';
88
+ const configPath = getNewConfigPath(ns, photonName);
85
89
  const configDir = path.dirname(configPath);
86
90
 
87
- // Ensure directory exists
88
91
  if (!fs.existsSync(configDir)) {
89
92
  fs.mkdirSync(configDir, { recursive: true });
90
93
  }
@@ -94,19 +97,16 @@ export function savePhotonConfig<T extends Record<string, any>>(
94
97
 
95
98
  /**
96
99
  * Check if a photon has been configured
97
- * @param photonName The photon name (kebab-case)
98
- * @returns true if config file exists
99
100
  */
100
- export function hasPhotonConfig(photonName: string): boolean {
101
- return fs.existsSync(getPhotonConfigPath(photonName));
101
+ export function hasPhotonConfig(photonName: string, namespace?: string): boolean {
102
+ return fs.existsSync(getPhotonConfigPath(photonName, namespace));
102
103
  }
103
104
 
104
105
  /**
105
106
  * Delete configuration for a photon
106
- * @param photonName The photon name (kebab-case)
107
107
  */
108
- export function deletePhotonConfig(photonName: string): void {
109
- const configPath = getPhotonConfigPath(photonName);
108
+ export function deletePhotonConfig(photonName: string, namespace?: string): void {
109
+ const configPath = getPhotonConfigPath(photonName, namespace);
110
110
  if (fs.existsSync(configPath)) {
111
111
  fs.unlinkSync(configPath);
112
112
  }
@@ -114,21 +114,34 @@ export function deletePhotonConfig(photonName: string): void {
114
114
 
115
115
  /**
116
116
  * List all configured photons
117
- * @returns Array of photon names that have config
118
117
  */
119
118
  export function listConfiguredPhotons(): string[] {
120
- const configDir = getPhotonConfigDir();
119
+ const dataRoot = getDataRoot();
121
120
 
122
- if (!fs.existsSync(configDir)) {
121
+ if (!fs.existsSync(dataRoot)) {
123
122
  return [];
124
123
  }
125
124
 
125
+ const results: string[] = [];
126
126
  try {
127
- return fs.readdirSync(configDir, { withFileTypes: true })
128
- .filter(entry => entry.isDirectory())
129
- .filter(entry => fs.existsSync(path.join(configDir, entry.name, 'config.json')))
130
- .map(entry => entry.name);
127
+ // Scan namespace directories inside .data/
128
+ const nsDirs = fs.readdirSync(dataRoot, { withFileTypes: true })
129
+ .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'));
130
+
131
+ for (const nsDir of nsDirs) {
132
+ const nsPath = path.join(dataRoot, nsDir.name);
133
+ const photonDirs = fs.readdirSync(nsPath, { withFileTypes: true })
134
+ .filter(e => e.isDirectory());
135
+
136
+ for (const pDir of photonDirs) {
137
+ if (fs.existsSync(path.join(nsPath, pDir.name, 'config.json'))) {
138
+ results.push(pDir.name);
139
+ }
140
+ }
141
+ }
131
142
  } catch {
132
- return [];
143
+ // Data root doesn't exist or is unreadable
133
144
  }
145
+
146
+ return results;
134
147
  }