@portel/photon-core 2.6.1 → 2.8.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.
@@ -51,41 +51,73 @@ export function hasAsyncMethods(ClassConstructor: new (...args: unknown[]) => un
51
51
  return false;
52
52
  }
53
53
 
54
+ /**
55
+ * Check if a class has any public methods (instance or static)
56
+ */
57
+ export function hasMethods(ClassConstructor: new (...args: unknown[]) => unknown): boolean {
58
+ const prototype = ClassConstructor.prototype;
59
+ for (const key of Object.getOwnPropertyNames(prototype)) {
60
+ if (key === 'constructor') continue;
61
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, key);
62
+ if (descriptor && typeof descriptor.value === 'function') {
63
+ return true;
64
+ }
65
+ }
66
+
67
+ for (const key of Object.getOwnPropertyNames(ClassConstructor)) {
68
+ if (['length', 'name', 'prototype'].includes(key)) continue;
69
+ const descriptor = Object.getOwnPropertyDescriptor(ClassConstructor, key);
70
+ if (descriptor && typeof descriptor.value === 'function') {
71
+ return true;
72
+ }
73
+ }
74
+
75
+ return false;
76
+ }
77
+
54
78
  /**
55
79
  * Find a single Photon class in a module
56
80
  *
57
81
  * Priority: default export first, then named exports.
58
- * Returns the first class with async methods, or null.
82
+ * Default exports are trusted unconditionally the file is named .photon.ts,
83
+ * so the user's intent is clear. For named exports, async methods are used
84
+ * as a heuristic to distinguish photon classes from helper classes.
59
85
  */
60
86
  export function findPhotonClass(module: Record<string, unknown>): (new (...args: unknown[]) => unknown) | null {
61
- // Try default export first
87
+ // Default export = the user's photon class. Trust it.
62
88
  if (module.default && isClass(module.default)) {
63
- if (hasAsyncMethods(module.default)) {
64
- return module.default;
65
- }
89
+ return module.default;
66
90
  }
67
91
 
68
- // Try named exports
92
+ // Named exports: prefer classes with async methods (likely the photon),
93
+ // but fall back to any class with public methods if none are async
94
+ let fallback: (new (...args: unknown[]) => unknown) | null = null;
95
+
69
96
  for (const exportedItem of Object.values(module)) {
70
- if (isClass(exportedItem) && hasAsyncMethods(exportedItem)) {
71
- return exportedItem;
97
+ if (isClass(exportedItem)) {
98
+ if (hasAsyncMethods(exportedItem)) {
99
+ return exportedItem;
100
+ }
101
+ if (!fallback && hasMethods(exportedItem)) {
102
+ fallback = exportedItem;
103
+ }
72
104
  }
73
105
  }
74
106
 
75
- return null;
107
+ return fallback;
76
108
  }
77
109
 
78
110
  /**
79
111
  * Find all Photon classes in a module
80
112
  *
81
- * Returns every exported class that has async methods.
113
+ * Returns every exported class that has methods.
82
114
  * Used by NCP which may load multiple classes from one file.
83
115
  */
84
116
  export function findPhotonClasses(module: Record<string, unknown>): Array<new (...args: unknown[]) => unknown> {
85
117
  const classes: Array<new (...args: unknown[]) => unknown> = [];
86
118
 
87
119
  for (const exportedItem of Object.values(module)) {
88
- if (isClass(exportedItem) && hasAsyncMethods(exportedItem)) {
120
+ if (isClass(exportedItem) && hasMethods(exportedItem)) {
89
121
  classes.push(exportedItem);
90
122
  }
91
123
  }
package/src/index.ts CHANGED
@@ -365,6 +365,7 @@ export {
365
365
  // Shared Photon class detection for loaders
366
366
  export {
367
367
  isClass,
368
+ hasMethods,
368
369
  hasAsyncMethods,
369
370
  findPhotonClass,
370
371
  findPhotonClasses,
@@ -434,6 +435,24 @@ export {
434
435
  assertArray,
435
436
  } from './validation.js';
436
437
 
438
+ // ===== EXECUTION AUDIT TRAIL =====
439
+ // Zero-effort execution recording for debugging and observability
440
+ export {
441
+ AuditTrail,
442
+ getAuditTrail,
443
+ setAuditTrail,
444
+ generateExecutionId,
445
+ type ExecutionRecord,
446
+ type AuditQueryOptions,
447
+ } from './audit.js';
448
+
449
+ // ===== SCOPED MEMORY =====
450
+ // Framework-level key-value storage (this.memory on PhotonMCP)
451
+ export {
452
+ MemoryProvider,
453
+ type MemoryScope,
454
+ } from './memory.js';
455
+
437
456
  // ===== ASSET DISCOVERY =====
438
457
  // Discover UI, prompt, and resource assets from Photon files
439
458
  export {
package/src/memory.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Scoped Memory System
3
+ *
4
+ * Framework-level key-value storage for photons that eliminates
5
+ * boilerplate file I/O. Available as `this.memory` on PhotonMCP.
6
+ *
7
+ * Three scopes:
8
+ * | Scope | Meaning | Storage |
9
+ * |----------|----------------------------------|-----------------------------------|
10
+ * | photon | Private to this photon (default) | ~/.photon/data/{photonId}/ |
11
+ * | session | Per-user session (Beam sessions) | ~/.photon/sessions/{sessionId}/ |
12
+ * | global | Shared across all photons | ~/.photon/data/_global/ |
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * export default class TodoList extends PhotonMCP {
17
+ * async add({ text }: { text: string }) {
18
+ * const items = await this.memory.get<Task[]>('items') ?? [];
19
+ * items.push({ id: crypto.randomUUID(), text });
20
+ * await this.memory.set('items', items);
21
+ * return items;
22
+ * }
23
+ * }
24
+ * ```
25
+ */
26
+
27
+ import * as fs from 'fs';
28
+ import * as path from 'path';
29
+ import * as os from 'os';
30
+
31
+ export type MemoryScope = 'photon' | 'session' | 'global';
32
+
33
+ /**
34
+ * Get the base data directory
35
+ */
36
+ function getDataDir(): string {
37
+ return process.env.PHOTON_DATA_DIR || path.join(os.homedir(), '.photon', 'data');
38
+ }
39
+
40
+ /**
41
+ * Get the sessions directory
42
+ */
43
+ function getSessionsDir(): string {
44
+ return process.env.PHOTON_SESSIONS_DIR || path.join(os.homedir(), '.photon', 'sessions');
45
+ }
46
+
47
+ /**
48
+ * Resolve storage directory for a given scope
49
+ */
50
+ function resolveDir(photonId: string, scope: MemoryScope, sessionId?: string): string {
51
+ const safeName = photonId.replace(/[^a-zA-Z0-9_-]/g, '_');
52
+
53
+ switch (scope) {
54
+ case 'photon':
55
+ return path.join(getDataDir(), safeName);
56
+ case 'session':
57
+ if (!sessionId) {
58
+ throw new Error('Session ID required for session-scoped memory. Set via memory.sessionId.');
59
+ }
60
+ const safeSession = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
61
+ return path.join(getSessionsDir(), safeSession, safeName);
62
+ case 'global':
63
+ return path.join(getDataDir(), '_global');
64
+ default:
65
+ throw new Error(`Unknown memory scope: ${scope}`);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Get the file path for a key within a directory
71
+ */
72
+ function keyPath(dir: string, key: string): string {
73
+ const safeKey = key.replace(/[^a-zA-Z0-9_.-]/g, '_');
74
+ return path.join(dir, `${safeKey}.json`);
75
+ }
76
+
77
+ /**
78
+ * Scoped Memory Provider
79
+ *
80
+ * Provides key-value storage with automatic JSON serialization.
81
+ * Each key is stored as a separate file for atomic operations.
82
+ */
83
+ export class MemoryProvider {
84
+ private _photonId: string;
85
+ private _sessionId?: string;
86
+
87
+ constructor(photonId: string, sessionId?: string) {
88
+ this._photonId = photonId;
89
+ this._sessionId = sessionId;
90
+ }
91
+
92
+ /**
93
+ * Current session ID (can be updated by the runtime)
94
+ */
95
+ get sessionId(): string | undefined {
96
+ return this._sessionId;
97
+ }
98
+
99
+ set sessionId(id: string | undefined) {
100
+ this._sessionId = id;
101
+ }
102
+
103
+ /**
104
+ * Get a value from memory
105
+ *
106
+ * @param key The key to retrieve
107
+ * @param scope Storage scope (default: 'photon')
108
+ * @returns The stored value, or null if not found
109
+ */
110
+ async get<T = any>(key: string, scope: MemoryScope = 'photon'): Promise<T | null> {
111
+ const dir = resolveDir(this._photonId, scope, this._sessionId);
112
+ const filePath = keyPath(dir, key);
113
+
114
+ try {
115
+ if (!fs.existsSync(filePath)) return null;
116
+ const content = fs.readFileSync(filePath, 'utf-8');
117
+ return JSON.parse(content) as T;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Set a value in memory
125
+ *
126
+ * @param key The key to store
127
+ * @param value The value (must be JSON-serializable)
128
+ * @param scope Storage scope (default: 'photon')
129
+ */
130
+ async set<T = any>(key: string, value: T, scope: MemoryScope = 'photon'): Promise<void> {
131
+ const dir = resolveDir(this._photonId, scope, this._sessionId);
132
+
133
+ if (!fs.existsSync(dir)) {
134
+ fs.mkdirSync(dir, { recursive: true });
135
+ }
136
+
137
+ const filePath = keyPath(dir, key);
138
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
139
+ }
140
+
141
+ /**
142
+ * Delete a key from memory
143
+ *
144
+ * @param key The key to delete
145
+ * @param scope Storage scope (default: 'photon')
146
+ * @returns true if the key existed and was deleted
147
+ */
148
+ async delete(key: string, scope: MemoryScope = 'photon'): Promise<boolean> {
149
+ const dir = resolveDir(this._photonId, scope, this._sessionId);
150
+ const filePath = keyPath(dir, key);
151
+
152
+ if (fs.existsSync(filePath)) {
153
+ fs.unlinkSync(filePath);
154
+ return true;
155
+ }
156
+ return false;
157
+ }
158
+
159
+ /**
160
+ * Check if a key exists in memory
161
+ *
162
+ * @param key The key to check
163
+ * @param scope Storage scope (default: 'photon')
164
+ */
165
+ async has(key: string, scope: MemoryScope = 'photon'): Promise<boolean> {
166
+ const dir = resolveDir(this._photonId, scope, this._sessionId);
167
+ return fs.existsSync(keyPath(dir, key));
168
+ }
169
+
170
+ /**
171
+ * List all keys in memory for a scope
172
+ *
173
+ * @param scope Storage scope (default: 'photon')
174
+ */
175
+ async keys(scope: MemoryScope = 'photon'): Promise<string[]> {
176
+ const dir = resolveDir(this._photonId, scope, this._sessionId);
177
+
178
+ if (!fs.existsSync(dir)) return [];
179
+
180
+ try {
181
+ return fs.readdirSync(dir)
182
+ .filter(f => f.endsWith('.json'))
183
+ .map(f => f.slice(0, -5)); // Remove .json extension
184
+ } catch {
185
+ return [];
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Clear all keys in a scope
191
+ *
192
+ * @param scope Storage scope (default: 'photon')
193
+ */
194
+ async clear(scope: MemoryScope = 'photon'): Promise<void> {
195
+ const dir = resolveDir(this._photonId, scope, this._sessionId);
196
+
197
+ if (fs.existsSync(dir)) {
198
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
199
+ for (const file of files) {
200
+ fs.unlinkSync(path.join(dir, file));
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Get all key-value pairs in a scope
207
+ *
208
+ * @param scope Storage scope (default: 'photon')
209
+ */
210
+ async getAll<T = any>(scope: MemoryScope = 'photon'): Promise<Record<string, T>> {
211
+ const allKeys = await this.keys(scope);
212
+ const result: Record<string, T> = {};
213
+
214
+ for (const key of allKeys) {
215
+ const value = await this.get<T>(key, scope);
216
+ if (value !== null) {
217
+ result[key] = value;
218
+ }
219
+ }
220
+
221
+ return result;
222
+ }
223
+
224
+ /**
225
+ * Update a value atomically (read-modify-write)
226
+ *
227
+ * @param key The key to update
228
+ * @param updater Function that receives current value and returns new value
229
+ * @param scope Storage scope (default: 'photon')
230
+ */
231
+ async update<T = any>(
232
+ key: string,
233
+ updater: (current: T | null) => T,
234
+ scope: MemoryScope = 'photon'
235
+ ): Promise<T> {
236
+ const current = await this.get<T>(key, scope);
237
+ const updated = updater(current);
238
+ await this.set(key, updated, scope);
239
+ return updated;
240
+ }
241
+ }
@@ -109,14 +109,11 @@ export class SchemaExtractor {
109
109
  // Check if this is an async generator method (has asterisk token)
110
110
  const isGenerator = member.asteriskToken !== undefined;
111
111
 
112
- // Extract parameter type information
113
- // Extract parameter type (may be undefined for no-arg methods)
114
- const paramsType = this.getFirstParameterType(member, sourceFile);
115
-
116
- // Build schema from TypeScript type (empty for no-arg methods)
117
- const { properties, required } = paramsType
118
- ? this.buildSchemaFromType(paramsType, sourceFile)
119
- : { properties: {}, required: [] };
112
+ // Extract parameter schema from method signature
113
+ // Supports both patterns:
114
+ // add(item: string) → { item: { type: "string" } }
115
+ // add(params: { item: string }) → { item: { type: "string" } }
116
+ const { properties, required, simpleParams } = this.extractMethodParams(member, sourceFile);
120
117
 
121
118
  // Extract descriptions from JSDoc
122
119
  const paramDocs = this.extractParamDocs(jsdoc);
@@ -178,6 +175,7 @@ export class SchemaExtractor {
178
175
  const yields = isGenerator ? this.extractYieldsFromJSDoc(jsdoc) : undefined;
179
176
  const isStateful = this.hasStatefulTag(jsdoc);
180
177
  const autorun = this.hasAutorunTag(jsdoc);
178
+ const isAsync = this.hasAsyncTag(jsdoc);
181
179
 
182
180
  // Daemon features
183
181
  const webhook = this.extractWebhook(jsdoc, methodName);
@@ -199,7 +197,9 @@ export class SchemaExtractor {
199
197
  ...(yields && yields.length > 0 ? { yields } : {}),
200
198
  ...(isStateful ? { isStateful: true } : {}),
201
199
  ...(autorun ? { autorun: true } : {}),
200
+ ...(isAsync ? { isAsync: true } : {}),
202
201
  ...(isStaticMethod ? { isStatic: true } : {}),
202
+ ...(simpleParams ? { simpleParams: true } : {}),
203
203
  // Daemon features
204
204
  ...(webhook !== undefined ? { webhook } : {}),
205
205
  ...(scheduled ? { scheduled } : {}),
@@ -213,10 +213,15 @@ export class SchemaExtractor {
213
213
  // Look for class declarations
214
214
  if (ts.isClassDeclaration(node)) {
215
215
  node.members.forEach((member) => {
216
- // Look for async methods (including async generators with *)
217
- if (ts.isMethodDeclaration(member) &&
218
- member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)) {
219
- processMethod(member);
216
+ // Process all public methods (sync or async)
217
+ // Skip private/protected — only public methods become tools
218
+ if (ts.isMethodDeclaration(member)) {
219
+ const isPrivate = member.modifiers?.some(
220
+ m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword
221
+ );
222
+ if (!isPrivate) {
223
+ processMethod(member);
224
+ }
220
225
  }
221
226
  });
222
227
  }
@@ -280,6 +285,61 @@ export class SchemaExtractor {
280
285
  return firstParam.type;
281
286
  }
282
287
 
288
+ /**
289
+ * Extract method parameters into JSON schema properties.
290
+ *
291
+ * Handles two patterns:
292
+ * 1. Object param: add(params: { item: string }) → extracts inner properties
293
+ * 2. Simple params: add(item: string) or add(a: number, b: number) → each param becomes a property
294
+ */
295
+ private extractMethodParams(method: ts.MethodDeclaration, sourceFile: ts.SourceFile): { properties: Record<string, any>, required: string[], simpleParams?: boolean } {
296
+ if (method.parameters.length === 0) {
297
+ return { properties: {}, required: [] };
298
+ }
299
+
300
+ const firstParam = method.parameters[0];
301
+ const firstType = firstParam.type;
302
+
303
+ // Pattern 1: Single object param — add(params: { item: string })
304
+ // Unwrap the object type's properties directly
305
+ if (firstType && method.parameters.length === 1) {
306
+ // Direct type literal: { item: string }
307
+ if (ts.isTypeLiteralNode(firstType)) {
308
+ return this.buildSchemaFromType(firstType, sourceFile);
309
+ }
310
+ // Union containing object literal: { item: string } | string
311
+ if (ts.isUnionTypeNode(firstType)) {
312
+ for (const memberType of firstType.types) {
313
+ if (ts.isTypeLiteralNode(memberType)) {
314
+ return this.buildSchemaFromType(memberType, sourceFile);
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ // Pattern 2: Simple typed params — add(item: string) or add(a: number, b: number)
321
+ // Flag as simpleParams so the runtime destructures the params object into individual args
322
+ const properties: Record<string, any> = {};
323
+ const required: string[] = [];
324
+
325
+ for (const param of method.parameters) {
326
+ const paramName = param.name.getText(sourceFile);
327
+ const isOptional = param.questionToken !== undefined || param.initializer !== undefined;
328
+
329
+ if (!isOptional) {
330
+ required.push(paramName);
331
+ }
332
+
333
+ if (param.type) {
334
+ properties[paramName] = this.typeNodeToSchema(param.type, sourceFile);
335
+ } else {
336
+ properties[paramName] = { type: 'string' };
337
+ }
338
+ }
339
+
340
+ return { properties, required, simpleParams: true };
341
+ }
342
+
283
343
  /**
284
344
  * Build JSON schema from TypeScript type node
285
345
  * Extracts: type, optional, readonly
@@ -657,7 +717,7 @@ export class SchemaExtractor {
657
717
  */
658
718
  private extractDescription(jsdocContent: string): string {
659
719
  // Split by @param to get only the description part (also stop at other @tags)
660
- const beforeTags = jsdocContent.split(/@(?:param|example|returns?|throws?|see|since|deprecated|version|author|license|ui|icon|format|stateful|autorun|webhook|cron|scheduled|locked|Template|Static|mcp|photon|cli|tags|dependencies|csp|visibility)\b/)[0];
720
+ const beforeTags = jsdocContent.split(/@(?:param|example|returns?|throws?|see|since|deprecated|version|author|license|ui|icon|format|stateful|autorun|async|webhook|cron|scheduled|locked|Template|Static|mcp|photon|cli|tags|dependencies|csp|visibility)\b/)[0];
661
721
 
662
722
  // Remove leading * from each line and trim
663
723
  const lines = beforeTags
@@ -1076,6 +1136,14 @@ export class SchemaExtractor {
1076
1136
  return /@autorun/i.test(jsdocContent);
1077
1137
  }
1078
1138
 
1139
+ /**
1140
+ * Check if JSDoc contains @async tag
1141
+ * Indicates this method runs in background — returns execution ID immediately
1142
+ */
1143
+ private hasAsyncTag(jsdocContent: string): boolean {
1144
+ return /@async\b/i.test(jsdocContent);
1145
+ }
1146
+
1079
1147
  // ═══════════════════════════════════════════════════════════════════════════════
1080
1148
  // DAEMON FEATURE EXTRACTION
1081
1149
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1473,6 +1541,7 @@ export class SchemaExtractor {
1473
1541
  const params = this.extractConstructorParams(source);
1474
1542
  const mcpDeps = this.extractMCPDependencies(source);
1475
1543
  const photonDeps = this.extractPhotonDependencies(source);
1544
+ const isStateful = /@stateful\s+true/.test(source);
1476
1545
 
1477
1546
  // Build lookup maps
1478
1547
  const mcpMap = new Map(mcpDeps.map(d => [d.name, d]));
@@ -1507,6 +1576,15 @@ export class SchemaExtractor {
1507
1576
  };
1508
1577
  }
1509
1578
 
1579
+ // Non-primitive with default on @stateful class → persisted state
1580
+ if (isStateful && param.hasDefault) {
1581
+ return {
1582
+ param,
1583
+ injectionType: 'state' as const,
1584
+ stateKey: param.name,
1585
+ };
1586
+ }
1587
+
1510
1588
  // Non-primitive without declaration - treat as env var (will likely fail at runtime)
1511
1589
  const envVarName = this.toEnvVarName(mcpName, param.name);
1512
1590
  return {
package/src/types.ts CHANGED
@@ -63,6 +63,8 @@ export interface ExtractedSchema {
63
63
  isStateful?: boolean;
64
64
  /** True if this method should auto-execute when selected (idempotent, no required params) */
65
65
  autorun?: boolean;
66
+ /** True if this method runs in background — returns execution ID immediately */
67
+ isAsync?: boolean;
66
68
  /** True if this is a static method (class-level, no instance needed) */
67
69
  isStatic?: boolean;
68
70
 
@@ -110,7 +112,7 @@ export interface ConstructorParam {
110
112
  /**
111
113
  * Injection type for constructor parameters
112
114
  */
113
- export type InjectionType = 'env' | 'mcp' | 'photon';
115
+ export type InjectionType = 'env' | 'mcp' | 'photon' | 'state';
114
116
 
115
117
  /**
116
118
  * Resolved injection info for a constructor parameter
@@ -124,6 +126,8 @@ export interface ResolvedInjection {
124
126
  photonDependency?: PhotonDependency;
125
127
  /** For 'env' - the environment variable name */
126
128
  envVarName?: string;
129
+ /** For 'state' - the key name in the persisted snapshot JSON */
130
+ stateKey?: string;
127
131
  }
128
132
 
129
133
  /**