@portel/photon-core 2.23.0 → 2.25.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 (49) hide show
  1. package/dist/base.d.ts +114 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +195 -2
  4. package/dist/base.js.map +1 -1
  5. package/dist/description-sanitizer.d.ts +34 -0
  6. package/dist/description-sanitizer.d.ts.map +1 -0
  7. package/dist/description-sanitizer.js +80 -0
  8. package/dist/description-sanitizer.js.map +1 -0
  9. package/dist/generator.d.ts +102 -0
  10. package/dist/generator.d.ts.map +1 -1
  11. package/dist/generator.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/memory.d.ts.map +1 -1
  17. package/dist/memory.js +28 -0
  18. package/dist/memory.js.map +1 -1
  19. package/dist/middleware.d.ts.map +1 -1
  20. package/dist/middleware.js +96 -0
  21. package/dist/middleware.js.map +1 -1
  22. package/dist/mixins.d.ts.map +1 -1
  23. package/dist/mixins.js +9 -2
  24. package/dist/mixins.js.map +1 -1
  25. package/dist/photon-loader-lite.js +41 -0
  26. package/dist/photon-loader-lite.js.map +1 -1
  27. package/dist/schedule.d.ts +41 -2
  28. package/dist/schedule.d.ts.map +1 -1
  29. package/dist/schedule.js +72 -16
  30. package/dist/schedule.js.map +1 -1
  31. package/dist/schema-extractor.d.ts +2 -1
  32. package/dist/schema-extractor.d.ts.map +1 -1
  33. package/dist/schema-extractor.js +135 -14
  34. package/dist/schema-extractor.js.map +1 -1
  35. package/dist/types.d.ts +9 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/base.ts +224 -2
  40. package/src/description-sanitizer.ts +102 -0
  41. package/src/generator.ts +93 -0
  42. package/src/index.ts +12 -0
  43. package/src/memory.ts +28 -0
  44. package/src/middleware.ts +98 -0
  45. package/src/mixins.ts +14 -2
  46. package/src/photon-loader-lite.ts +38 -0
  47. package/src/schedule.ts +98 -14
  48. package/src/schema-extractor.ts +147 -14
  49. package/src/types.ts +9 -0
package/src/base.ts CHANGED
@@ -48,6 +48,13 @@ import { ScheduleProvider } from './schedule.js';
48
48
  import * as path from 'path';
49
49
  import * as fs from 'fs';
50
50
  import { getPhotonDataDir } from './data-paths.js';
51
+ import type {
52
+ AskYield,
53
+ InputProvider,
54
+ SampleParams,
55
+ SamplingMessage,
56
+ SamplingProvider,
57
+ } from './generator.js';
51
58
 
52
59
  /**
53
60
  * Simple base class for creating Photons
@@ -71,6 +78,16 @@ export class Photon {
71
78
  */
72
79
  _photonNamespace?: string;
73
80
 
81
+ /**
82
+ * PHOTON_DIR this instance was loaded from - set by runtime loader.
83
+ * Pinned so .data/ resolves to the same root regardless of which
84
+ * process (CLI, daemon, worker) reads the photon back later. Without
85
+ * this pin, MemoryProvider falls back to getDefaultContext().baseDir
86
+ * and writes/reads can drift between cwd-derived locations.
87
+ * @internal
88
+ */
89
+ _baseDir?: string;
90
+
74
91
  /**
75
92
  * Absolute path to the .photon.ts/.photon.js source file - set by runtime loader
76
93
  * Used for storage() and assets() path resolution
@@ -78,6 +95,25 @@ export class Photon {
78
95
  */
79
96
  _photonFilePath?: string;
80
97
 
98
+ /**
99
+ * Stat snapshot captured when the current photon instance was loaded.
100
+ * Used by executeTool() to detect out-of-band edits so CLI-direct and
101
+ * lite-loader callers see new code immediately after a file save.
102
+ * See `_photonReloader` for the callback invoked on change.
103
+ * @internal
104
+ */
105
+ _photonSourceStat?: { mtimeMs: number; size: number; ino: number };
106
+
107
+ /**
108
+ * Callback the loader registers so executeTool() can trigger a live
109
+ * reload when `_photonFilePath` has changed since load. The loader is
110
+ * expected to re-evaluate the file, replace the cached module, and
111
+ * update `_photonSourceStat` on success. Missing callback means the
112
+ * gate is a no-op (the original behavior).
113
+ * @internal
114
+ */
115
+ _photonReloader?: () => Promise<void>;
116
+
81
117
  /**
82
118
  * Dynamic photon resolver - injected by runtime loader
83
119
  * Used by this.photon.use() for runtime photon access
@@ -125,6 +161,127 @@ export class Photon {
125
161
  return store?.caller ?? { id: 'anonymous', anonymous: true };
126
162
  }
127
163
 
164
+ /**
165
+ * Ask the caller a yes/no question and await the answer.
166
+ *
167
+ * Imperative sugar over the existing `{ ask: 'confirm' }` yield.
168
+ * Works in any method (generator or plain async), routed through the
169
+ * runtime's elicitation pipeline — returns `true`/`false` based on the
170
+ * user's response in whichever surface the client offers (Beam
171
+ * dialog, Claude confirm prompt, CLI readline, etc.).
172
+ *
173
+ * @throws If no client with elicitation capability is connected.
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * if (await this.confirm('Delete all records?')) {
178
+ * await purge();
179
+ * }
180
+ * ```
181
+ */
182
+ async confirm(question: string): Promise<boolean> {
183
+ const provider = this._resolveInputProvider('this.confirm()');
184
+ const result = await provider({ ask: 'confirm', question, message: question } as AskYield);
185
+ return Boolean(result);
186
+ }
187
+
188
+ /**
189
+ * Pose an arbitrary elicitation request and await the answer.
190
+ *
191
+ * Accepts any `AskYield` (text, password, select, number, form, etc.)
192
+ * and returns the client's response. Prefer `this.confirm()` for
193
+ * yes/no; use `yield { ask: ... }` inside async-generator workflows.
194
+ * `this.elicit()` is the imperative form for plain async methods
195
+ * that need a single input without the generator plumbing.
196
+ *
197
+ * The name matches MCP spec terminology (`elicitation/create`) to
198
+ * avoid ambiguity with the legacy `this.ask(type, message, opts)`
199
+ * factory, which just constructs an `AskYield` object for use with
200
+ * `yield`.
201
+ *
202
+ * @throws If no client with elicitation capability is connected.
203
+ */
204
+ async elicit<T = unknown>(params: AskYield): Promise<T> {
205
+ const provider = this._resolveInputProvider('this.elicit()');
206
+ return (await provider(params)) as T;
207
+ }
208
+
209
+ /**
210
+ * Ask the client's LLM to generate text (MCP `sampling/createMessage`).
211
+ *
212
+ * The caller's agent model generates the completion — no API key
213
+ * needed in the photon, and inference cost is borne by whoever is
214
+ * driving the tool. The client must declare the `sampling`
215
+ * capability during initialize; otherwise this throws.
216
+ *
217
+ * Returns just the generated text for the common case. For
218
+ * multi-block / image / structured responses, access the underlying
219
+ * provider via the runtime.
220
+ *
221
+ * @example
222
+ * ```typescript
223
+ * async summarize(params: { text: string }) {
224
+ * return await this.sample({
225
+ * prompt: `Summarize this in one sentence:\n\n${params.text}`,
226
+ * maxTokens: 128,
227
+ * });
228
+ * }
229
+ * ```
230
+ */
231
+ async sample(params: SampleParams): Promise<string> {
232
+ const store = executionContext.getStore() as { samplingProvider?: SamplingProvider } | undefined;
233
+ const provider = store?.samplingProvider;
234
+ if (!provider) {
235
+ throw new Error(
236
+ 'this.sample() requires the connected MCP client to declare the ' +
237
+ '`sampling` capability. None is available in this invocation — ' +
238
+ "either the client didn't declare sampling during initialize, or " +
239
+ 'this method is being called outside an MCP request context (e.g. ' +
240
+ 'from a scheduled task without a live session).'
241
+ );
242
+ }
243
+ if (!params.prompt && !params.messages?.length) {
244
+ throw new Error('this.sample() requires either `prompt` or `messages`.');
245
+ }
246
+ const messages: SamplingMessage[] =
247
+ params.messages ??
248
+ [{ role: 'user', content: { type: 'text', text: params.prompt! } }];
249
+ const result = await provider({
250
+ messages,
251
+ systemPrompt: params.systemPrompt,
252
+ maxTokens: params.maxTokens ?? 1024,
253
+ temperature: params.temperature,
254
+ modelPreferences: params.modelPreferences,
255
+ stopSequences: params.stopSequences,
256
+ includeContext: params.includeContext,
257
+ });
258
+ const first = Array.isArray(result.content) ? result.content[0] : result.content;
259
+ if (first && first.type === 'text') return first.text;
260
+ // Non-text responses (image-only) — return empty string rather than
261
+ // a JSON-stringified blob so callers can reliably concatenate the
262
+ // result. Users needing image output should hit the provider directly.
263
+ return '';
264
+ }
265
+
266
+ /**
267
+ * Internal: resolve the runtime-supplied input provider, throwing a
268
+ * clear error if none is attached to the current execution context.
269
+ * @internal
270
+ */
271
+ private _resolveInputProvider(forMethod: string): InputProvider {
272
+ const store = executionContext.getStore() as { inputProvider?: InputProvider } | undefined;
273
+ const provider = store?.inputProvider;
274
+ if (!provider) {
275
+ throw new Error(
276
+ `${forMethod} requires a connected MCP client that supports elicitation. ` +
277
+ 'The runtime attaches an input provider for every tool invocation; ' +
278
+ "this call is running outside that context (e.g. a background task " +
279
+ 'without a session, or a client that declined elicitation).'
280
+ );
281
+ }
282
+ return provider;
283
+ }
284
+
128
285
  /**
129
286
  * Scoped key-value storage for photon data
130
287
  *
@@ -153,7 +310,12 @@ export class Photon {
153
310
  .replace(/([A-Z])/g, '-$1')
154
311
  .toLowerCase()
155
312
  .replace(/^-/, '');
156
- this._memory = new MemoryProvider(name, this._sessionId, this._photonNamespace);
313
+ this._memory = new MemoryProvider(
314
+ name,
315
+ this._sessionId,
316
+ this._photonNamespace,
317
+ this._baseDir
318
+ );
157
319
  }
158
320
  return this._memory;
159
321
  }
@@ -197,11 +359,27 @@ export class Photon {
197
359
  .replace(/([A-Z])/g, '-$1')
198
360
  .toLowerCase()
199
361
  .replace(/^-/, '');
200
- this._schedule = new ScheduleProvider(name);
362
+ this._schedule = new ScheduleProvider(
363
+ name,
364
+ this._baseDir,
365
+ this._scheduleUnscheduleHook
366
+ );
201
367
  }
202
368
  return this._schedule;
203
369
  }
204
370
 
371
+ /**
372
+ * Runtime-injected hook that evicts an in-memory cron registration.
373
+ * The photon runtime (photon-repo loader) attaches a callback that
374
+ * IPCs the daemon's `unschedule` request; photon-core doesn't know
375
+ * about the daemon, so the hook is passed through to ScheduleProvider
376
+ * which calls it after unlinking the disk file. Without the hook,
377
+ * `this.schedule.cancel()` leaves a ghost registration that keeps
378
+ * firing until the next daemon restart.
379
+ * @internal
380
+ */
381
+ _scheduleUnscheduleHook?: import('./schedule.js').UnscheduleHook;
382
+
205
383
  /**
206
384
  * Get an absolute path to a storage directory for this photon's data.
207
385
  *
@@ -624,6 +802,15 @@ export class Photon {
624
802
  * Execute a tool method
625
803
  */
626
804
  async executeTool(toolName: string, parameters: any, options?: { outputHandler?: (data: any) => void }): Promise<any> {
805
+ // Stat-gate: close the edit→dispatch race on non-daemon paths. The
806
+ // daemon has its own equivalent at src/daemon/server.ts; this runs
807
+ // for CLI-direct dispatch and for callers using the lite loader's
808
+ // programmatic photon() API. If the source file has changed since
809
+ // the instance was loaded, hand off to the loader-registered
810
+ // reloader before dispatching so the first call after a save sees
811
+ // the new code.
812
+ await this._statGateIfStale();
813
+
627
814
  const method = (this as any)[toolName];
628
815
 
629
816
  if (!method || typeof method !== 'function') {
@@ -642,6 +829,41 @@ export class Photon {
642
829
  });
643
830
  }
644
831
 
832
+ /**
833
+ * If a reloader is registered and the source file has changed since it
834
+ * was last loaded, invoke the reloader. Silent on missing file or
835
+ * reloader failure — dispatch continues on the stale instance rather
836
+ * than throwing for an observability concern.
837
+ */
838
+ private async _statGateIfStale(): Promise<void> {
839
+ const filePath = this._photonFilePath;
840
+ const reloader = this._photonReloader;
841
+ const cached = this._photonSourceStat;
842
+ if (!filePath || !reloader || !cached) return;
843
+ let current: { mtimeMs: number; size: number; ino: number } | null = null;
844
+ try {
845
+ const s = fs.statSync(filePath);
846
+ current = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
847
+ } catch {
848
+ // Source gone or unreadable — nothing to gate against. Dispatch
849
+ // will surface the missing-photon error through its own path.
850
+ return;
851
+ }
852
+ if (
853
+ current.mtimeMs === cached.mtimeMs &&
854
+ current.size === cached.size &&
855
+ current.ino === cached.ino
856
+ ) {
857
+ return;
858
+ }
859
+ try {
860
+ await reloader();
861
+ } catch {
862
+ // Reloader errors are non-fatal — keep running on the stale
863
+ // instance rather than making dispatch itself fail.
864
+ }
865
+ }
866
+
645
867
  /**
646
868
  * Invoke the onError observability hook with a bounded timeout. Never
647
869
  * suppresses the original error and never throws itself — a throw or
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Tool-description sanitizer.
3
+ *
4
+ * Defends against MCP tool-description poisoning (OWASP MCP #3) by stripping
5
+ * invisible codepoints and redacting embedded prompt-injection markers from
6
+ * JSDoc descriptions before they reach the model.
7
+ *
8
+ * Called by the schema extractor at parse time; any photon loaded via the
9
+ * runtime benefits automatically. Intentionally conservative — it only
10
+ * touches obvious attack patterns. Legitimate documentation does not use
11
+ * zero-width spaces or contain `[[SYSTEM: ...]]` directives.
12
+ */
13
+
14
+ /**
15
+ * Unicode ranges that are invisible to humans but preserved by LLM tokenizers.
16
+ * Stripped unconditionally — no legitimate doc string needs them.
17
+ *
18
+ * U+200B ZERO WIDTH SPACE
19
+ * U+200C ZERO WIDTH NON-JOINER
20
+ * U+200D ZERO WIDTH JOINER
21
+ * U+200E LEFT-TO-RIGHT MARK
22
+ * U+200F RIGHT-TO-LEFT MARK
23
+ * U+202A-E LRE/RLE/PDF/LRO/RLO (bidi override)
24
+ * U+2060 WORD JOINER
25
+ * U+2066-9 LRI/RLI/FSI/PDI (bidi isolate)
26
+ * U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM)
27
+ */
28
+ const INVISIBLE_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060\u2066-\u2069\uFEFF]/g;
29
+
30
+ /**
31
+ * Instruction-bracket patterns commonly used in tool-poisoning payloads.
32
+ * Each match is replaced with `[REDACTED]` and counted as a warning.
33
+ */
34
+ const INSTRUCTION_PATTERNS: Array<{ name: string; pattern: RegExp }> = [
35
+ // Bracketed system directives: [[SYSTEM: ...]], [INST] ... [/INST], <|im_start|>...<|im_end|>
36
+ { name: 'bracketed-system', pattern: /\[{1,2}\s*SYSTEM\s*:[^\]]*\]{1,2}/gi },
37
+ { name: 'llama-inst', pattern: /\[\/?INST\]/gi },
38
+ { name: 'chatml-tag', pattern: /<\|im_(?:start|end|sep)\|>/gi },
39
+ // Verbatim instruction phrases that only appear in jailbreaks, never in docs.
40
+ { name: 'ignore-previous', pattern: /\bignore\s+(?:all\s+|the\s+)?(?:previous|prior|above)\s+instructions?\b/gi },
41
+ { name: 'disregard-previous', pattern: /\bdisregard\s+(?:all\s+|the\s+)?(?:previous|prior|above)\b/gi },
42
+ { name: 'system-prompt-literal', pattern: /\bSYSTEM[_\s-]?PROMPT\b/g },
43
+ ];
44
+
45
+ /** Max length of a cleaned description. Anything longer is an attack surface. */
46
+ export const MAX_DESCRIPTION_LENGTH = 2000;
47
+
48
+ export interface SanitizerWarning {
49
+ /** Label of the rule that fired. */
50
+ rule: string;
51
+ /** Short slice of the matched text for forensics (capped at 80 chars). */
52
+ sample: string;
53
+ }
54
+
55
+ export interface SanitizeResult {
56
+ cleaned: string;
57
+ warnings: SanitizerWarning[];
58
+ /** Whether the string was truncated because it exceeded MAX_DESCRIPTION_LENGTH. */
59
+ truncated: boolean;
60
+ }
61
+
62
+ /**
63
+ * Strip invisible codepoints, redact instruction markers, and cap length.
64
+ * Idempotent: sanitize(sanitize(x).cleaned).cleaned === sanitize(x).cleaned
65
+ * so callers can apply it defensively without risking duplicate `[REDACTED]`
66
+ * tokens.
67
+ */
68
+ export function sanitizeDescription(input: string): SanitizeResult {
69
+ const warnings: SanitizerWarning[] = [];
70
+
71
+ // Step 1: strip invisibles. Counts as one warning if any were present.
72
+ const withoutInvisibles = input.replace(INVISIBLE_CHARS, (match) => {
73
+ warnings.push({ rule: 'invisible-char', sample: codepointSample(match) });
74
+ return '';
75
+ });
76
+
77
+ // Step 2: redact each known instruction pattern.
78
+ let redacted = withoutInvisibles;
79
+ for (const { name, pattern } of INSTRUCTION_PATTERNS) {
80
+ redacted = redacted.replace(pattern, (match) => {
81
+ warnings.push({ rule: name, sample: match.slice(0, 80) });
82
+ return '[REDACTED]';
83
+ });
84
+ }
85
+
86
+ // Step 3: cap length. An attacker-friendly 50KB description is itself a
87
+ // prompt-injection vector via context-window flooding (OWASP MCP #10).
88
+ let truncated = false;
89
+ if (redacted.length > MAX_DESCRIPTION_LENGTH) {
90
+ redacted = redacted.slice(0, MAX_DESCRIPTION_LENGTH) + '…';
91
+ truncated = true;
92
+ }
93
+
94
+ return { cleaned: redacted, warnings, truncated };
95
+ }
96
+
97
+ function codepointSample(s: string): string {
98
+ return Array.from(s)
99
+ .map((c) => 'U+' + c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0'))
100
+ .join(',')
101
+ .slice(0, 80);
102
+ }
package/src/generator.ts CHANGED
@@ -921,6 +921,99 @@ export type InputProvider = (ask: AskYield) => Promise<any>;
921
921
  */
922
922
  export type OutputHandler = (emit: EmitYield) => void | Promise<void>;
923
923
 
924
+ // ══════════════════════════════════════════════════════════════════════════════
925
+ // SAMPLING - Photon-to-client LLM requests (MCP sampling/createMessage)
926
+ // ══════════════════════════════════════════════════════════════════════════════
927
+
928
+ /**
929
+ * A message in an MCP sampling conversation.
930
+ *
931
+ * Mirrors `SamplingMessage` in the MCP spec: `role` plus either text or
932
+ * image content. Most photon callers only need `{ role: 'user', content:
933
+ * { type: 'text', text: '...' } }` — use the `prompt` shortcut on
934
+ * `SampleParams` to build that automatically.
935
+ */
936
+ export interface SamplingMessage {
937
+ role: 'user' | 'assistant';
938
+ content:
939
+ | { type: 'text'; text: string }
940
+ | { type: 'image'; data: string; mimeType: string };
941
+ }
942
+
943
+ /**
944
+ * Preferences passed to the client's sampling LLM (MCP modelPreferences).
945
+ * All fields are advisory — the client picks the final model.
946
+ */
947
+ export interface ModelPreferences {
948
+ /** Hints at desired models, in priority order (e.g. `[{name:'claude-3-5-sonnet'}]`) */
949
+ hints?: Array<{ name: string }>;
950
+ /** 0-1 weight favoring cheaper models */
951
+ costPriority?: number;
952
+ /** 0-1 weight favoring faster responses */
953
+ speedPriority?: number;
954
+ /** 0-1 weight favoring stronger models */
955
+ intelligencePriority?: number;
956
+ }
957
+
958
+ /**
959
+ * Parameters for `this.sample()`.
960
+ *
961
+ * Pick ONE of `prompt` or `messages`. `prompt` is the ergonomic path:
962
+ * it becomes a single user-role text message. `messages` gives full
963
+ * control over multi-turn conversations or image content.
964
+ */
965
+ export interface SampleParams {
966
+ /** Convenience: wraps the string as a single user message */
967
+ prompt?: string;
968
+ /** Full message array (alternative to `prompt`) */
969
+ messages?: SamplingMessage[];
970
+ /** Optional system prompt */
971
+ systemPrompt?: string;
972
+ /**
973
+ * Max tokens the client should generate. MCP spec requires this field;
974
+ * defaults to 1024 when omitted. Keep modest — sampling is not free.
975
+ */
976
+ maxTokens?: number;
977
+ temperature?: number;
978
+ modelPreferences?: ModelPreferences;
979
+ stopSequences?: string[];
980
+ /** Whether the client should include this or other servers' context */
981
+ includeContext?: 'none' | 'thisServer' | 'allServers';
982
+ }
983
+
984
+ /**
985
+ * Result of a sampling request (a `CreateMessageResult` from MCP).
986
+ * The `content` union accommodates both single-block responses (the
987
+ * common case) and multi-block responses from tool-capable sampling.
988
+ */
989
+ export interface SamplingResult {
990
+ role: 'assistant';
991
+ content:
992
+ | { type: 'text'; text: string }
993
+ | { type: 'image'; data: string; mimeType: string }
994
+ | Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }>;
995
+ model: string;
996
+ stopReason?: string;
997
+ }
998
+
999
+ /**
1000
+ * Runtime hook that forwards a sampling request to the MCP client.
1001
+ *
1002
+ * Runtimes implement this by calling the client's
1003
+ * `sampling/createMessage`. When the client doesn't declare the
1004
+ * `sampling` capability, the runtime should throw or omit the provider
1005
+ * entirely — `this.sample()` surfaces a clear error in that case.
1006
+ */
1007
+ export type SamplingProvider = (params: {
1008
+ messages: SamplingMessage[];
1009
+ systemPrompt?: string;
1010
+ maxTokens: number;
1011
+ temperature?: number;
1012
+ modelPreferences?: ModelPreferences;
1013
+ stopSequences?: string[];
1014
+ includeContext?: 'none' | 'thisServer' | 'allServers';
1015
+ }) => Promise<SamplingResult>;
1016
+
924
1017
  /**
925
1018
  * Configuration for generator execution
926
1019
  */
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 {
@@ -259,6 +265,12 @@ export {
259
265
  type OutputHandler,
260
266
  type GeneratorExecutorConfig,
261
267
  type ExtractedAsk,
268
+ // Sampling (MCP createMessage) — powers this.sample()
269
+ type SampleParams,
270
+ type SamplingMessage,
271
+ type SamplingResult,
272
+ type SamplingProvider,
273
+ type ModelPreferences,
262
274
  } from './generator.js';
263
275
 
264
276
  // Stateful Workflow Execution
package/src/memory.ts CHANGED
@@ -294,6 +294,33 @@ function findAndMigrateStrandedMemory(
294
294
  }
295
295
  }
296
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
+
297
324
  function resolveDir(
298
325
  photonId: string,
299
326
  namespace: string,
@@ -301,6 +328,7 @@ function resolveDir(
301
328
  sessionId?: string,
302
329
  baseDir?: string
303
330
  ): string {
331
+ warnIfBaseDirMissing(photonId, baseDir);
304
332
  switch (scope) {
305
333
  case 'photon': {
306
334
  const newDir = getPhotonMemoryDir(namespace, photonId, baseDir);
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