@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.
- package/dist/base.d.ts +114 -0
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +195 -2
- package/dist/base.js.map +1 -1
- package/dist/description-sanitizer.d.ts +34 -0
- package/dist/description-sanitizer.d.ts.map +1 -0
- package/dist/description-sanitizer.js +80 -0
- package/dist/description-sanitizer.js.map +1 -0
- package/dist/generator.d.ts +102 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +28 -0
- package/dist/memory.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +96 -0
- package/dist/middleware.js.map +1 -1
- package/dist/mixins.d.ts.map +1 -1
- package/dist/mixins.js +9 -2
- package/dist/mixins.js.map +1 -1
- package/dist/photon-loader-lite.js +41 -0
- package/dist/photon-loader-lite.js.map +1 -1
- package/dist/schedule.d.ts +41 -2
- package/dist/schedule.d.ts.map +1 -1
- package/dist/schedule.js +72 -16
- package/dist/schedule.js.map +1 -1
- package/dist/schema-extractor.d.ts +2 -1
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +135 -14
- package/dist/schema-extractor.js.map +1 -1
- package/dist/types.d.ts +9 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/base.ts +224 -2
- package/src/description-sanitizer.ts +102 -0
- package/src/generator.ts +93 -0
- package/src/index.ts +12 -0
- package/src/memory.ts +28 -0
- package/src/middleware.ts +98 -0
- package/src/mixins.ts +14 -2
- package/src/photon-loader-lite.ts +38 -0
- package/src/schedule.ts +98 -14
- package/src/schema-extractor.ts +147 -14
- 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(
|
|
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(
|
|
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
|