@portel/photon-core 2.22.0 → 2.24.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.
Files changed (67) hide show
  1. package/dist/base.d.ts +56 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +100 -2
  4. package/dist/base.js.map +1 -1
  5. package/dist/bases-registry.d.ts +57 -0
  6. package/dist/bases-registry.d.ts.map +1 -0
  7. package/dist/bases-registry.js +127 -0
  8. package/dist/bases-registry.js.map +1 -0
  9. package/dist/data-paths.d.ts +31 -18
  10. package/dist/data-paths.d.ts.map +1 -1
  11. package/dist/data-paths.js +42 -43
  12. package/dist/data-paths.js.map +1 -1
  13. package/dist/description-sanitizer.d.ts +34 -0
  14. package/dist/description-sanitizer.d.ts.map +1 -0
  15. package/dist/description-sanitizer.js +80 -0
  16. package/dist/description-sanitizer.js.map +1 -0
  17. package/dist/index.d.ts +4 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/memory.d.ts.map +1 -1
  22. package/dist/memory.js +109 -1
  23. package/dist/memory.js.map +1 -1
  24. package/dist/middleware.d.ts.map +1 -1
  25. package/dist/middleware.js +96 -0
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mixins.d.ts.map +1 -1
  28. package/dist/mixins.js +9 -2
  29. package/dist/mixins.js.map +1 -1
  30. package/dist/path-resolver.d.ts +13 -1
  31. package/dist/path-resolver.d.ts.map +1 -1
  32. package/dist/path-resolver.js +23 -1
  33. package/dist/path-resolver.js.map +1 -1
  34. package/dist/photon-loader-lite.d.ts +17 -2
  35. package/dist/photon-loader-lite.d.ts.map +1 -1
  36. package/dist/photon-loader-lite.js +203 -26
  37. package/dist/photon-loader-lite.js.map +1 -1
  38. package/dist/schedule.d.ts +10 -1
  39. package/dist/schedule.d.ts.map +1 -1
  40. package/dist/schedule.js +20 -10
  41. package/dist/schedule.js.map +1 -1
  42. package/dist/schema-extractor.d.ts +9 -3
  43. package/dist/schema-extractor.d.ts.map +1 -1
  44. package/dist/schema-extractor.js +149 -17
  45. package/dist/schema-extractor.js.map +1 -1
  46. package/dist/stateful.d.ts +3 -2
  47. package/dist/stateful.d.ts.map +1 -1
  48. package/dist/stateful.js +18 -6
  49. package/dist/stateful.js.map +1 -1
  50. package/dist/types.d.ts +9 -1
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/base.ts +123 -2
  55. package/src/bases-registry.ts +141 -0
  56. package/src/data-paths.ts +43 -49
  57. package/src/description-sanitizer.ts +102 -0
  58. package/src/index.ts +20 -1
  59. package/src/memory.ts +109 -0
  60. package/src/middleware.ts +98 -0
  61. package/src/mixins.ts +14 -2
  62. package/src/path-resolver.ts +26 -1
  63. package/src/photon-loader-lite.ts +214 -33
  64. package/src/schedule.ts +26 -10
  65. package/src/schema-extractor.ts +164 -17
  66. package/src/stateful.ts +19 -6
  67. package/src/types.ts +9 -0
package/src/index.ts CHANGED
@@ -162,6 +162,12 @@ export { DependencyManager } from './dependency-manager.js';
162
162
 
163
163
  // Schema extraction
164
164
  export { SchemaExtractor, detectCapabilities, type PhotonCapability } from './schema-extractor.js';
165
+ export {
166
+ sanitizeDescription,
167
+ MAX_DESCRIPTION_LENGTH,
168
+ type SanitizerWarning,
169
+ type SanitizeResult,
170
+ } from './description-sanitizer.js';
165
171
 
166
172
  // Path resolution (Photon-specific paths)
167
173
  export {
@@ -172,6 +178,7 @@ export {
172
178
  resolvePhotonPath,
173
179
  listPhotonFiles,
174
180
  listPhotonFilesWithNamespace,
181
+ listPhotonSourceFiles,
175
182
  ensurePhotonDir,
176
183
  DEFAULT_PHOTON_DIR,
177
184
  type ResolverOptions,
@@ -494,7 +501,14 @@ export {
494
501
 
495
502
  // ===== PHOTON LOADER LITE =====
496
503
  // Direct TypeScript API for loading .photon.ts files with full enhancements
497
- export { photon, clearPhotonCache, type PhotonOptions, type PhotonEvent } from './photon-loader-lite.js';
504
+ export {
505
+ photon,
506
+ clearPhotonCache,
507
+ disposePhoton,
508
+ disposeAllPhotons,
509
+ type PhotonOptions,
510
+ type PhotonEvent,
511
+ } from './photon-loader-lite.js';
498
512
 
499
513
  // ===== FILE WATCHING =====
500
514
  // Reusable photon file watcher with symlink resolution, debouncing, rename handling
@@ -592,3 +606,8 @@ export {
592
606
  // ===== DATA PATHS =====
593
607
  // Central data path resolver — single source of truth for all runtime data locations
594
608
  export * from './data-paths.js';
609
+
610
+ // ===== BASES REGISTRY =====
611
+ // Daemon-owned registry of every PHOTON_DIR served. Used to discover schedules
612
+ // and other per-base data on daemon startup. See data-paths getBasesRegistryPath.
613
+ export * from './bases-registry.js';
package/src/memory.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  getLegacyMemoryDir,
28
28
  getLegacyGlobalMemoryDir,
29
29
  getLegacySessionMemoryDir,
30
+ getDataRoot,
30
31
  } from './data-paths.js';
31
32
 
32
33
  export type MemoryScope = 'photon' | 'session' | 'global';
@@ -216,6 +217,110 @@ export class FileMemoryBackend implements MemoryBackend {
216
217
  // SCOPE RESOLUTION
217
218
  // ════════════════════════════════════════════════════════════════════════════════
218
219
 
220
+ /**
221
+ * Compatibility shim — one-release migration only.
222
+ *
223
+ * Under docs/internals/PHOTON-DIR-AND-NAMESPACE.md the namespace comes
224
+ * purely from directory position and never changes silently. But installs
225
+ * that ran with the old git-remote-based namespace detection may have data
226
+ * sitting under a stale namespace bucket. On first read, if the canonical
227
+ * dir is empty but exactly one sibling namespace contains this photon's
228
+ * memory, migrate it atomically to the canonical location.
229
+ *
230
+ * Multiple matches → ambiguous; warn to stderr so the user can consolidate
231
+ * manually rather than proceeding with empty memory.
232
+ *
233
+ * @deprecated Remove this helper and its caller one release after the
234
+ * PHOTON_DIR-and-namespace change ships.
235
+ */
236
+ function findAndMigrateStrandedMemory(
237
+ photonId: string,
238
+ canonicalDir: string,
239
+ baseDir?: string
240
+ ): string | null {
241
+ const dataRoot = getDataRoot(baseDir);
242
+ let entries: string[];
243
+ try {
244
+ entries = fsSync.readdirSync(dataRoot);
245
+ } catch {
246
+ return null;
247
+ }
248
+
249
+ const matches: string[] = [];
250
+ for (const entry of entries) {
251
+ // Skip non-namespace buckets. `_global`, `_sessions`, `.cache`, `tasks`
252
+ // live at the same level but are reserved names.
253
+ if (entry.startsWith('_') || entry.startsWith('.') || entry === 'tasks') continue;
254
+ const candidate = path.join(dataRoot, entry, photonId, 'memory');
255
+ try {
256
+ const stat = fsSync.statSync(candidate);
257
+ if (stat.isDirectory()) {
258
+ // Only count non-empty dirs as real data.
259
+ if (fsSync.readdirSync(candidate).length > 0) matches.push(candidate);
260
+ }
261
+ } catch {
262
+ // Not a match, continue.
263
+ }
264
+ }
265
+
266
+ if (matches.length === 0) return null;
267
+ if (matches.length > 1) {
268
+ process.stderr.write(
269
+ `[photon] warning: photon '${photonId}' has memory data stranded under multiple namespaces:\n` +
270
+ matches.map((m) => ` - ${m}`).join('\n') +
271
+ `\n Canonical path is ${canonicalDir}.\n` +
272
+ ` Move the correct one into the canonical path and delete the others to consolidate.\n`
273
+ );
274
+ return null;
275
+ }
276
+
277
+ const stranded = matches[0];
278
+ try {
279
+ fsSync.mkdirSync(path.dirname(canonicalDir), { recursive: true });
280
+ fsSync.renameSync(stranded, canonicalDir);
281
+ // Clean up now-empty parent if it has no siblings.
282
+ try {
283
+ const strandedParent = path.dirname(stranded);
284
+ if (fsSync.readdirSync(strandedParent).length === 0) fsSync.rmdirSync(strandedParent);
285
+ const strandedNs = path.dirname(strandedParent);
286
+ if (fsSync.readdirSync(strandedNs).length === 0) fsSync.rmdirSync(strandedNs);
287
+ } catch {
288
+ // Non-fatal: leave parent dirs if cleanup fails.
289
+ }
290
+ return canonicalDir;
291
+ } catch {
292
+ // Cross-device rename or permission issue — fall back to reading in place.
293
+ return stranded;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Photons whose loader pinned a baseDir get deterministic paths regardless
299
+ * of which process reads them back. When baseDir is missing, getBase()
300
+ * silently falls back to PHOTON_DIR env or ~/.photon, which means the same
301
+ * photon can write to <repo>/.data/ from a CLI process and read from
302
+ * ~/.photon/.data/ from a daemon worker started in a different cwd. We
303
+ * warn once per photon so this drift is noticeable rather than silent.
304
+ *
305
+ * To suppress in test environments where the fallback is intentional, set
306
+ * PHOTON_MEMORY_NO_BASEDIR_WARN=1.
307
+ */
308
+ const _baseDirFallbackWarned = new Set<string>();
309
+ function warnIfBaseDirMissing(photonId: string, baseDir?: string): void {
310
+ if (baseDir) return;
311
+ if (process.env.PHOTON_MEMORY_NO_BASEDIR_WARN) return;
312
+ if (_baseDirFallbackWarned.has(photonId)) return;
313
+ _baseDirFallbackWarned.add(photonId);
314
+ process.stderr.write(
315
+ `[photon-core] memory.resolveDir for "${photonId}" got no baseDir — ` +
316
+ `falling back to PHOTON_DIR env or ~/.photon. If this photon was ` +
317
+ `loaded with a workingDir, the loader is failing to set ` +
318
+ `instance._baseDir. Symptom: writes from one process land at the ` +
319
+ `loader's baseDir but reads from another process land at the env ` +
320
+ `fallback. See memory-baseDir-resolution-bug for details.\n`
321
+ );
322
+ }
323
+
219
324
  function resolveDir(
220
325
  photonId: string,
221
326
  namespace: string,
@@ -223,12 +328,16 @@ function resolveDir(
223
328
  sessionId?: string,
224
329
  baseDir?: string
225
330
  ): string {
331
+ warnIfBaseDirMissing(photonId, baseDir);
226
332
  switch (scope) {
227
333
  case 'photon': {
228
334
  const newDir = getPhotonMemoryDir(namespace, photonId, baseDir);
229
335
  if (!fsSync.existsSync(newDir)) {
230
336
  const legacyDir = getLegacyMemoryDir(photonId, baseDir);
231
337
  if (fsSync.existsSync(legacyDir)) return legacyDir;
338
+ // Last resort: data stranded under a different namespace bucket.
339
+ const recovered = findAndMigrateStrandedMemory(photonId, newDir, baseDir);
340
+ if (recovered) return recovered;
232
341
  }
233
342
  return newDir;
234
343
  }
package/src/middleware.ts CHANGED
@@ -674,6 +674,102 @@ const bulkheadMiddleware = defineMiddleware<{ maxConcurrent: number }>({
674
674
  },
675
675
  });
676
676
 
677
+ // --- mask (phase 85) ---
678
+ // Post-execution redaction pass. Rewrites named fields in the result with
679
+ // a masked placeholder before handoff to the transport layer. Defense
680
+ // against oversharing and accidental PII exposure (OWASP MCP #10).
681
+ // Runs AFTER execution, BEFORE __meta attachment (phase ≥ 85).
682
+
683
+ function maskValue(value: unknown, keys: string[], placeholder: string): unknown {
684
+ if (value === null || value === undefined) return value;
685
+ if (Array.isArray(value)) {
686
+ return value.map((v) => maskValue(v, keys, placeholder));
687
+ }
688
+ if (typeof value === 'object') {
689
+ const src = value as Record<string, unknown>;
690
+ const out: Record<string, unknown> = {};
691
+ for (const k of Object.keys(src)) {
692
+ if (keys.includes(k)) {
693
+ out[k] = placeholder;
694
+ } else {
695
+ out[k] = maskValue(src[k], keys, placeholder);
696
+ }
697
+ }
698
+ return out;
699
+ }
700
+ return value;
701
+ }
702
+
703
+ const maskMiddleware = defineMiddleware<{ fields: string[]; placeholder: string }>({
704
+ name: 'mask',
705
+ phase: 85,
706
+ parseShorthand(value: string) {
707
+ const fields = value
708
+ .split(/[,\s]+/)
709
+ .map((s) => s.trim())
710
+ .filter(Boolean);
711
+ return { fields, placeholder: '[REDACTED]' };
712
+ },
713
+ parseConfig(raw) {
714
+ const fields = (raw.fields || '')
715
+ .split(/[,\s]+/)
716
+ .map((s) => s.trim())
717
+ .filter(Boolean);
718
+ return { fields, placeholder: raw.placeholder?.trim() || '[REDACTED]' };
719
+ },
720
+ create(config, _state) {
721
+ return async (ctx, next) => {
722
+ const result = await next();
723
+ if (config.fields.length === 0) return result;
724
+ return maskValue(result, config.fields, config.placeholder);
725
+ };
726
+ },
727
+ });
728
+
729
+ // --- maxResponseBytes (phase 88) ---
730
+ // Caps the serialized response size. Hard truncates with a warning marker.
731
+ // Prevents context-window flooding (OWASP MCP #10) — an oversized result
732
+ // is an attack vector on its own even when the data is legitimate.
733
+ // Runs after @mask so the cap applies to the redacted payload.
734
+
735
+ const maxResponseBytesMiddleware = defineMiddleware<{ limit: number }>({
736
+ name: 'maxResponseBytes',
737
+ phase: 88,
738
+ parseShorthand(value: string) {
739
+ return { limit: Math.max(1, parseInt(value.trim(), 10) || 0) };
740
+ },
741
+ parseConfig(raw) {
742
+ const v = raw.limit || raw.bytes || raw.max;
743
+ return { limit: Math.max(1, parseInt(v || '0', 10) || 0) };
744
+ },
745
+ create(config, _state) {
746
+ return async (ctx, next) => {
747
+ const result = await next();
748
+ if (!config.limit || config.limit <= 0) return result;
749
+ let serialized: string;
750
+ try {
751
+ serialized = typeof result === 'string' ? result : JSON.stringify(result);
752
+ } catch {
753
+ return result; // unserializable → leave untouched
754
+ }
755
+ const byteLen = Buffer.byteLength(serialized, 'utf8');
756
+ if (byteLen <= config.limit) return result;
757
+ const truncated = serialized.slice(
758
+ 0,
759
+ Math.max(0, config.limit - 32)
760
+ );
761
+ return {
762
+ truncated: true,
763
+ reason: 'maxResponseBytes',
764
+ limit: config.limit,
765
+ originalBytes: byteLen,
766
+ tool: `${ctx.photon}.${ctx.tool}`,
767
+ preview: truncated,
768
+ };
769
+ };
770
+ },
771
+ });
772
+
677
773
  // ═══════════════════════════════════════════════════════════════════════════════
678
774
  // GLOBAL BUILT-IN REGISTRY
679
775
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -691,6 +787,8 @@ builtinRegistry.register(queuedMiddleware);
691
787
  builtinRegistry.register(lockedMiddleware);
692
788
  builtinRegistry.register(timeoutMiddleware);
693
789
  builtinRegistry.register(retryableMiddleware);
790
+ builtinRegistry.register(maskMiddleware);
791
+ builtinRegistry.register(maxResponseBytesMiddleware);
694
792
 
695
793
  // ═══════════════════════════════════════════════════════════════════════════════
696
794
  // PIPELINE ASSEMBLY
package/src/mixins.ts CHANGED
@@ -58,6 +58,13 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
58
58
  */
59
59
  _photonName?: string;
60
60
  _photonNamespace?: string;
61
+ /**
62
+ * PHOTON_DIR this instance was loaded from - set by runtime loader.
63
+ * Pinned so memory and other .data/-rooted state resolve to the same
64
+ * root regardless of which process reads back later.
65
+ * @internal
66
+ */
67
+ _baseDir?: string;
61
68
 
62
69
  /**
63
70
  * Session ID for session-scoped memory - set by runtime
@@ -113,7 +120,12 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
113
120
  .replace(/([A-Z])/g, '-$1')
114
121
  .toLowerCase()
115
122
  .replace(/^-/, '');
116
- this._memory = new MemoryProvider(name, this._sessionId, this._photonNamespace);
123
+ this._memory = new MemoryProvider(
124
+ name,
125
+ this._sessionId,
126
+ this._photonNamespace,
127
+ this._baseDir
128
+ );
117
129
  }
118
130
  return this._memory;
119
131
  }
@@ -128,7 +140,7 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
128
140
  .replace(/([A-Z])/g, '-$1')
129
141
  .toLowerCase()
130
142
  .replace(/^-/, '');
131
- this._schedule = new ScheduleProvider(name);
143
+ this._schedule = new ScheduleProvider(name, this._baseDir);
132
144
  }
133
145
  return this._schedule;
134
146
  }
@@ -18,7 +18,10 @@ import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
20
 
21
- export const DEFAULT_PHOTON_DIR = path.join(os.homedir(), '.photon');
21
+ import { DEFAULT_PHOTON_DIR } from './data-paths.js';
22
+ // Re-exported so existing `import { DEFAULT_PHOTON_DIR } from '@portel/photon-core'`
23
+ // callers keep working. One canonical definition lives in data-paths.ts.
24
+ export { DEFAULT_PHOTON_DIR };
22
25
 
23
26
  /**
24
27
  * Expand tilde (~) to user's home directory
@@ -53,6 +56,28 @@ const SKIP_DIRS = new Set([
53
56
  'node_modules', 'marketplace', 'photons', 'templates',
54
57
  ]);
55
58
 
59
+ /**
60
+ * List photon source file names in a directory. Returns just filenames
61
+ * (not full paths) so callers can choose to either join with the dir
62
+ * or operate on the basename directly. Missing/unreadable dirs return [].
63
+ *
64
+ * Replaces scattered `readdirSync(dir).filter(f => f.endsWith('.photon.ts'))`
65
+ * expressions so the default extension list stays consistent.
66
+ */
67
+ export function listPhotonSourceFiles(
68
+ dir: string,
69
+ options?: { extensions?: string[] },
70
+ ): string[] {
71
+ const extensions = options?.extensions ?? defaultOptions.extensions;
72
+ try {
73
+ return fsSync
74
+ .readdirSync(dir)
75
+ .filter((f) => extensions.some((ext) => f.endsWith(ext)));
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
56
81
  /**
57
82
  * Resolve a file path from name.
58
83
  *