@portel/photon-core 2.6.1 → 2.7.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/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
+ }
@@ -178,6 +178,7 @@ export class SchemaExtractor {
178
178
  const yields = isGenerator ? this.extractYieldsFromJSDoc(jsdoc) : undefined;
179
179
  const isStateful = this.hasStatefulTag(jsdoc);
180
180
  const autorun = this.hasAutorunTag(jsdoc);
181
+ const isAsync = this.hasAsyncTag(jsdoc);
181
182
 
182
183
  // Daemon features
183
184
  const webhook = this.extractWebhook(jsdoc, methodName);
@@ -199,6 +200,7 @@ export class SchemaExtractor {
199
200
  ...(yields && yields.length > 0 ? { yields } : {}),
200
201
  ...(isStateful ? { isStateful: true } : {}),
201
202
  ...(autorun ? { autorun: true } : {}),
203
+ ...(isAsync ? { isAsync: true } : {}),
202
204
  ...(isStaticMethod ? { isStatic: true } : {}),
203
205
  // Daemon features
204
206
  ...(webhook !== undefined ? { webhook } : {}),
@@ -657,7 +659,7 @@ export class SchemaExtractor {
657
659
  */
658
660
  private extractDescription(jsdocContent: string): string {
659
661
  // 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];
662
+ 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
663
 
662
664
  // Remove leading * from each line and trim
663
665
  const lines = beforeTags
@@ -1076,6 +1078,14 @@ export class SchemaExtractor {
1076
1078
  return /@autorun/i.test(jsdocContent);
1077
1079
  }
1078
1080
 
1081
+ /**
1082
+ * Check if JSDoc contains @async tag
1083
+ * Indicates this method runs in background — returns execution ID immediately
1084
+ */
1085
+ private hasAsyncTag(jsdocContent: string): boolean {
1086
+ return /@async\b/i.test(jsdocContent);
1087
+ }
1088
+
1079
1089
  // ═══════════════════════════════════════════════════════════════════════════════
1080
1090
  // DAEMON FEATURE EXTRACTION
1081
1091
  // ═══════════════════════════════════════════════════════════════════════════════
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