millas 0.2.12-beta-2 → 0.2.13

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 (57) hide show
  1. package/package.json +3 -2
  2. package/src/admin/Admin.js +122 -38
  3. package/src/admin/ViewContext.js +12 -3
  4. package/src/admin/resources/AdminResource.js +10 -0
  5. package/src/admin/static/admin.css +95 -14
  6. package/src/admin/views/layouts/base.njk +23 -34
  7. package/src/admin/views/pages/detail.njk +16 -5
  8. package/src/admin/views/pages/error.njk +65 -0
  9. package/src/admin/views/pages/list.njk +127 -2
  10. package/src/admin/views/partials/form-scripts.njk +7 -3
  11. package/src/admin/views/partials/form-widget.njk +2 -1
  12. package/src/admin/views/partials/icons.njk +64 -0
  13. package/src/ai/AIManager.js +954 -0
  14. package/src/ai/AITokenBudget.js +250 -0
  15. package/src/ai/PromptGuard.js +216 -0
  16. package/src/ai/agents.js +218 -0
  17. package/src/ai/conversation.js +213 -0
  18. package/src/ai/drivers.js +734 -0
  19. package/src/ai/files.js +249 -0
  20. package/src/ai/media.js +303 -0
  21. package/src/ai/pricing.js +152 -0
  22. package/src/ai/provider_tools.js +114 -0
  23. package/src/ai/types.js +356 -0
  24. package/src/commands/createsuperuser.js +17 -4
  25. package/src/commands/serve.js +2 -4
  26. package/src/container/AppInitializer.js +39 -15
  27. package/src/container/Application.js +31 -1
  28. package/src/core/foundation.js +1 -1
  29. package/src/errors/HttpError.js +32 -16
  30. package/src/facades/AI.js +411 -0
  31. package/src/facades/Hash.js +67 -0
  32. package/src/facades/Process.js +144 -0
  33. package/src/hashing/Hash.js +262 -0
  34. package/src/http/HtmlEscape.js +162 -0
  35. package/src/http/MillasRequest.js +63 -7
  36. package/src/http/MillasResponse.js +70 -4
  37. package/src/http/ResponseDispatcher.js +21 -27
  38. package/src/http/SafeFilePath.js +195 -0
  39. package/src/http/SafeRedirect.js +62 -0
  40. package/src/http/SecurityBootstrap.js +70 -0
  41. package/src/http/helpers.js +40 -125
  42. package/src/http/index.js +10 -1
  43. package/src/http/middleware/CsrfMiddleware.js +258 -0
  44. package/src/http/middleware/RateLimiter.js +314 -0
  45. package/src/http/middleware/SecurityHeaders.js +281 -0
  46. package/src/i18n/Translator.js +10 -2
  47. package/src/logger/LogRedactor.js +247 -0
  48. package/src/logger/Logger.js +1 -1
  49. package/src/logger/formatters/JsonFormatter.js +11 -4
  50. package/src/logger/formatters/PrettyFormatter.js +3 -1
  51. package/src/logger/formatters/SimpleFormatter.js +14 -3
  52. package/src/middleware/ThrottleMiddleware.js +27 -4
  53. package/src/process/Process.js +333 -0
  54. package/src/router/MiddlewareRegistry.js +27 -2
  55. package/src/scaffold/templates.js +3 -0
  56. package/src/validation/Validator.js +348 -607
  57. package/src/admin.zip +0 -0
@@ -0,0 +1,954 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ AIMessage, AIResponse, AIStreamEvent, Schema, Thread,
5
+ AIError, AIStructuredOutputError,
6
+ } = require('./types');
7
+
8
+ const {
9
+ AnthropicDriver, OpenAIDriver, GeminiDriver, OllamaDriver,
10
+ GroqDriver, MistralDriver, XAIDriver, DeepSeekDriver, AzureDriver,
11
+ CohereDriver, ElevenLabsDriver,
12
+ } = require('./drivers');
13
+
14
+ const {
15
+ PendingImage, AIImageResponse,
16
+ PendingAudio, AIAudioResponse,
17
+ PendingTranscription, AITranscriptionResponse,
18
+ PendingReranking, AIRerankResponse,
19
+ } = require('./media');
20
+
21
+ const { ConversationThread } = require('./conversation');
22
+ const { AIFilesAPI, AIStoresAPI } = require('./files');
23
+ const { AGENT_DEFINITIONS, BuiltinAgent } = require('./agents');
24
+ const { CostCalculator } = require('./pricing');
25
+ const { WebSearch, WebFetch, FileSearch } = require('./provider_tools');
26
+ const { PromptGuard } = require('./PromptGuard');
27
+
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // PendingRequest — fluent builder for a single text AI call
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ class PendingRequest {
33
+ constructor(manager) {
34
+ this._manager = manager;
35
+ this._provider = null;
36
+ this._model = null;
37
+ this._messages = [];
38
+ this._systemPrompt = null;
39
+ this._tools = [];
40
+ this._toolChoice = null;
41
+ this._schema = null;
42
+ this._maxTokens = null;
43
+ this._temperature = undefined;
44
+ this._topP = undefined;
45
+ this._stopSeqs = [];
46
+ this._thinking = false;
47
+ this._thinkingBudget = 8000;
48
+ this._fallbacks = [];
49
+ this._retries = 1;
50
+ this._retryDelay = 1000;
51
+ this._cache = false;
52
+ this._cacheTtl = 3600;
53
+ this._middleware = [];
54
+ this._tokenBudget = null;
55
+ this._onToken = null;
56
+ this._providerOpts = {};
57
+ this._guardPrompts = false; // Phase 4: prompt injection protection
58
+ }
59
+
60
+ // ── Provider / model ───────────────────────────────────────────────────────
61
+ using(provider) { this._provider = provider; return this; }
62
+ model(model) { this._model = model; return this; }
63
+
64
+ // ── Messages ───────────────────────────────────────────────────────────────
65
+ system(prompt) { this._systemPrompt = String(prompt); return this; }
66
+ withMessage(content, role = 'user') { this._messages.push(new AIMessage(role, content)); return this; }
67
+
68
+ /**
69
+ * Enable prompt injection protection for this request.
70
+ * When enabled:
71
+ * - The system prompt gets a boundary instruction prepended
72
+ * - User messages are wrapped in <user_input> tags
73
+ *
74
+ * AI.system('You are helpful.').guardPrompts().generate(userInput)
75
+ */
76
+ guardPrompts(enabled = true) { this._guardPrompts = enabled; return this; }
77
+
78
+ async withThread(thread) {
79
+ if (thread._systemPrompt) this._systemPrompt = thread._systemPrompt;
80
+ const msgs = await thread.toArray();
81
+ this._messages = msgs.map(m => new AIMessage(m.role, m.content, m));
82
+ return this;
83
+ }
84
+
85
+ // ── Parameters ─────────────────────────────────────────────────────────────
86
+ maxTokens(n) { this._maxTokens = n; return this; }
87
+ temperature(t) { this._temperature = t; return this; }
88
+ topP(p) { this._topP = p; return this; }
89
+ stop(seqs) { this._stopSeqs = Array.isArray(seqs) ? seqs : [seqs]; return this; }
90
+ think(budget = 8000) { this._thinking = true; this._thinkingBudget = budget; return this; }
91
+
92
+ /**
93
+ * Pass provider-specific options.
94
+ * .providerOptions({ openai: { reasoning: { effort: 'low' } }, anthropic: { thinking: { budget_tokens: 1024 } } })
95
+ */
96
+ providerOptions(opts) { this._providerOpts = opts; return this; }
97
+
98
+ // ── Tools ──────────────────────────────────────────────────────────────────
99
+ tools(tools) { this._tools = Array.isArray(tools) ? tools : [tools]; return this; }
100
+ toolChoice(choice) { this._toolChoice = choice; return this; }
101
+
102
+ // ── Structured output ──────────────────────────────────────────────────────
103
+ structured(schema) { this._schema = schema; return this; }
104
+
105
+ // ── Reliability ────────────────────────────────────────────────────────────
106
+ retry(times, delay = 1000) { this._retries = times; this._retryDelay = delay; return this; }
107
+ fallback(providers) { this._fallbacks = Array.isArray(providers) ? providers : [providers]; return this; }
108
+ cache(ttl = 3600) { this._cache = true; this._cacheTtl = ttl; return this; }
109
+ tokenBudget(n) { this._tokenBudget = n; return this; }
110
+
111
+ // ── Middleware ─────────────────────────────────────────────────────────────
112
+ use(fn) { this._middleware.push(fn); return this; }
113
+ onToken(fn) { this._onToken = fn; return this; }
114
+
115
+ // ── Execution ──────────────────────────────────────────────────────────────
116
+
117
+ async generate(prompt) {
118
+ if (prompt) this.withMessage(prompt);
119
+ return this._execute();
120
+ }
121
+
122
+ async *stream(prompt) {
123
+ if (prompt) this.withMessage(prompt);
124
+ yield* this._executeStream();
125
+ }
126
+
127
+ /**
128
+ * Stream with Vercel AI SDK data protocol format (for use with useChat / useCompletion).
129
+ * Returns an async generator of encoded string chunks.
130
+ *
131
+ * // Express SSE endpoint
132
+ * app.get('/chat', async (req, res) => {
133
+ * res.setHeader('Content-Type', 'text/event-stream');
134
+ * res.setHeader('x-vercel-ai-data-stream', 'v1');
135
+ * for await (const chunk of AI.usingVercelProtocol().stream(req.query.q)) {
136
+ * res.write(chunk);
137
+ * }
138
+ * res.end();
139
+ * });
140
+ */
141
+ usingVercelProtocol() { this._vercel = true; return this; }
142
+
143
+ /**
144
+ * Queue this AI call. Returns a QueuedAIRequest with .then() and .catch().
145
+ *
146
+ * AI.queue('Summarize this...').then(res => console.log(res.text)).catch(err => ...);
147
+ */
148
+ queue(prompt) {
149
+ if (prompt) this.withMessage(prompt);
150
+ return new QueuedAIRequest(this);
151
+ }
152
+
153
+ /**
154
+ * Agentic tool loop — auto-executes tool calls until model stops or maxIterations reached.
155
+ */
156
+ async agent(prompt, maxIterations = 10) {
157
+ if (prompt) this.withMessage(prompt);
158
+ let iterations = 0;
159
+ while (iterations < maxIterations) {
160
+ const response = await this._execute();
161
+ iterations++;
162
+ if (!response.hasToolCalls) return response;
163
+ this._messages.push(response.toMessage());
164
+ const toolResults = await Promise.all(
165
+ response.toolCalls.map(async tc => {
166
+ const tool = this._tools.find(t => t.name === tc.name);
167
+ if (!tool) return AIMessage.tool(tc.id, tc.name, `Error: tool "${tc.name}" not found`);
168
+ try {
169
+ const result = await tool.handler(tc.arguments);
170
+ return AIMessage.tool(tc.id, tc.name, typeof result === 'string' ? result : JSON.stringify(result));
171
+ } catch (err) {
172
+ return AIMessage.tool(tc.id, tc.name, `Error: ${err.message}`);
173
+ }
174
+ })
175
+ );
176
+ for (const tr of toolResults) this._messages.push(tr);
177
+ }
178
+ throw new AIError(`Agent exceeded maximum iterations (${maxIterations})`, this._provider);
179
+ }
180
+
181
+ // ── Internal ───────────────────────────────────────────────────────────────
182
+
183
+ _buildRequest() {
184
+ const messages = [...this._messages];
185
+
186
+ // ── Phase 4: prompt injection protection ──────────────────────────────────
187
+ let systemPrompt = this._systemPrompt;
188
+ if (this._guardPrompts && systemPrompt) {
189
+ systemPrompt = PromptGuard.systemBoundary(systemPrompt);
190
+ }
191
+
192
+ if (systemPrompt) messages.unshift(new AIMessage('system', systemPrompt));
193
+
194
+ // If guardPrompts is enabled, wrap the last user message in boundary tags
195
+ if (this._guardPrompts) {
196
+ const lastUserIdx = messages.map(m => m.role).lastIndexOf('user');
197
+ if (lastUserIdx !== -1) {
198
+ const lastUser = messages[lastUserIdx];
199
+ if (typeof lastUser.content === 'string' &&
200
+ !lastUser.content.startsWith('<user_input>')) {
201
+ messages[lastUserIdx] = new AIMessage('user', PromptGuard.wrap(lastUser.content));
202
+ }
203
+ }
204
+ }
205
+
206
+ if (this._schema) {
207
+ const schemaHint = `\n\nYou must respond with valid JSON matching this schema:\n${JSON.stringify(this._schema.toJSONSchema(), null, 2)}\n\nRespond ONLY with JSON. No explanation, no markdown fences.`;
208
+ const last = messages[messages.length - 1];
209
+ if (last && last.role === 'user') messages[messages.length - 1] = new AIMessage('user', last.content + schemaHint);
210
+ }
211
+
212
+ return {
213
+ provider: this._provider, model: this._model,
214
+ messages: messages.map(m => m.toJSON()),
215
+ tools: this._tools, toolChoice: this._toolChoice, schema: this._schema,
216
+ maxTokens: this._maxTokens, temperature: this._temperature, topP: this._topP,
217
+ stopSequences: this._stopSeqs, thinking: this._thinking, thinkingBudget: this._thinkingBudget,
218
+ providerOptions: this._providerOpts,
219
+ };
220
+ }
221
+
222
+ _cacheKey(req) { return `ai:${req.provider}:${req.model}:${JSON.stringify(req.messages)}`; }
223
+
224
+ async _execute() {
225
+ const request = this._buildRequest();
226
+ const cacheKey = this._cache ? this._cacheKey(request) : null;
227
+
228
+ if (cacheKey && this._manager._cache) {
229
+ const cached = await this._manager._cache.get(cacheKey).catch(() => null);
230
+ if (cached) return new AIResponse(cached);
231
+ }
232
+
233
+ const run = async (req) => {
234
+ const driver = this._manager._resolveDriver(req.provider || this._manager._default);
235
+ let response = await this._executeWithRetry(driver, req);
236
+
237
+ if (this._tokenBudget && response.totalTokens > this._tokenBudget) {
238
+ throw new AIError(`Token budget exceeded: ${response.totalTokens} > ${this._tokenBudget}`, req.provider);
239
+ }
240
+
241
+ if (this._schema && response.text) {
242
+ let parsed;
243
+ try {
244
+ parsed = this._schema.validate(JSON.parse(response.text.replace(/```json|```/g, '').trim()));
245
+ } catch (err) {
246
+ const retryMessages = [...req.messages, new AIMessage('assistant', response.text).toJSON(),
247
+ new AIMessage('user', `Your response was not valid JSON. Error: ${err.message}. Respond ONLY with valid JSON.`).toJSON()];
248
+ const retry = await driver.complete({ ...req, messages: retryMessages });
249
+ try { parsed = this._schema.validate(JSON.parse(retry.text.replace(/```json|```/g, '').trim())); } catch (e2) { throw new AIStructuredOutputError(e2.message, retry.text); }
250
+ response = retry;
251
+ }
252
+ response._parsed = parsed;
253
+ Object.defineProperty(response, 'parsed', { get: () => response._parsed });
254
+ }
255
+ return response;
256
+ };
257
+
258
+ const runWithMiddleware = [...this._middleware].reduceRight((next, mw) => (req) => mw(req, next), run);
259
+
260
+ let response;
261
+ try {
262
+ response = await runWithMiddleware(request);
263
+ } catch (err) {
264
+ for (const fb of this._fallbacks) {
265
+ const fbProvider = typeof fb === 'string' ? fb : fb.provider;
266
+ const fbModel = typeof fb === 'object' ? fb.model : null;
267
+ try {
268
+ const driver = this._manager._resolveDriver(fbProvider);
269
+ response = await this._executeWithRetry(driver, { ...request, provider: fbProvider, model: fbModel || request.model });
270
+ break;
271
+ } catch (_) {}
272
+ }
273
+ if (!response) throw err;
274
+ }
275
+
276
+ if (cacheKey && this._manager._cache && response) {
277
+ this._manager._cache.set(cacheKey, { text: response.text, role: response.role, model: response.model, provider: response.provider, inputTokens: response.inputTokens, outputTokens: response.outputTokens, toolCalls: response.toolCalls, finishReason: response.finishReason }, this._cacheTtl).catch(() => {});
278
+ }
279
+
280
+ return response;
281
+ }
282
+
283
+ async _executeWithRetry(driver, request) {
284
+ let lastErr;
285
+ for (let i = 1; i <= this._retries; i++) {
286
+ try { return await driver.complete(request); } catch (err) { lastErr = err; if (i < this._retries) await new Promise(r => setTimeout(r, this._retryDelay * i)); }
287
+ }
288
+ throw lastErr;
289
+ }
290
+
291
+ async *_executeStream() {
292
+ const request = this._buildRequest();
293
+ const driver = this._manager._resolveDriver(request.provider || this._manager._default);
294
+ for await (const event of driver.stream(request)) {
295
+ if (event.type === 'delta' && this._onToken) this._onToken(event.data.text);
296
+ if (this._vercel) {
297
+ // Vercel AI SDK data stream protocol v1
298
+ if (event.type === 'delta') yield `0:${JSON.stringify(event.data.text)}
299
+ `;
300
+ if (event.type === 'complete') yield `d:${JSON.stringify({ finishReason: event.data.finishReason || 'stop', usage: { promptTokens: event.data.inputTokens, completionTokens: event.data.outputTokens } })}
301
+ `;
302
+ } else {
303
+ yield event;
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ // ─────────────────────────────────────────────────────────────────────────────
310
+ // QueuedAIRequest — deferred AI call via the Millas Queue system
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+
313
+ class QueuedAIRequest {
314
+ constructor(pendingRequest) {
315
+ this._request = pendingRequest;
316
+ this._thenFns = [];
317
+ this._catchFns = [];
318
+ // Defer to next tick so .then/.catch can be chained before execution
319
+ setImmediate(() => this._run());
320
+ }
321
+
322
+ then(fn) { this._thenFns.push(fn); return this; }
323
+ catch(fn) { this._catchFns.push(fn); return this; }
324
+
325
+ async _run() {
326
+ try {
327
+ const response = await this._request._execute();
328
+ for (const fn of this._thenFns) await fn(response);
329
+ } catch (err) {
330
+ if (this._catchFns.length) {
331
+ for (const fn of this._catchFns) await fn(err);
332
+ } else {
333
+ // Surface unhandled rejections
334
+ process.nextTick(() => { throw err; });
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ // ─────────────────────────────────────────────────────────────────────────────
341
+ // AIManager
342
+ // ─────────────────────────────────────────────────────────────────────────────
343
+
344
+ class AIManager {
345
+ constructor(config = {}) {
346
+ this._config = config;
347
+ this._default = config.default || 'anthropic';
348
+ this._audioProvider = config.audioProvider || null;
349
+ this._rerankProvider = config.rerankProvider || 'cohere';
350
+ this._drivers = new Map();
351
+ this._cache = null;
352
+ this._db = null;
353
+ this._storage = null;
354
+ this._debug = false;
355
+ this._defaults = {}; // named profiles: { chat: { temperature: 0.7 } }
356
+ this._registeredTools = new Map(); // global tool registry
357
+ this._agentDefs = new Map(Object.entries(AGENT_DEFINITIONS));
358
+ this._threadCache = new Map(); // userId -> ConversationThread (in-memory fallback)
359
+ }
360
+
361
+ // ── Driver resolution ──────────────────────────────────────────────────────
362
+
363
+ _resolveDriver(name) {
364
+ if (this._drivers.has(name)) return this._drivers.get(name);
365
+ const cfg = (this._config.providers || {})[name] || {};
366
+ const drivers = {
367
+ anthropic: () => new AnthropicDriver(cfg),
368
+ openai: () => new OpenAIDriver(cfg),
369
+ gemini: () => new GeminiDriver(cfg),
370
+ ollama: () => new OllamaDriver(cfg),
371
+ groq: () => new GroqDriver(cfg),
372
+ mistral: () => new MistralDriver(cfg),
373
+ xai: () => new XAIDriver(cfg),
374
+ deepseek: () => new DeepSeekDriver(cfg),
375
+ azure: () => new AzureDriver(cfg),
376
+ cohere: () => new CohereDriver(cfg),
377
+ elevenlabs: () => new ElevenLabsDriver(cfg),
378
+ };
379
+ if (!drivers[name]) throw new AIError(`Unknown AI provider: "${name}". Supported: ${Object.keys(drivers).join(', ')}`, name);
380
+ const driver = drivers[name]();
381
+ this._drivers.set(name, driver);
382
+ return driver;
383
+ }
384
+
385
+ // ── Text generation ────────────────────────────────────────────────────────
386
+
387
+ using(provider) { return new PendingRequest(this).using(provider); }
388
+ model(model) { return new PendingRequest(this).model(model); }
389
+ system(prompt) { return new PendingRequest(this).system(prompt); }
390
+ temperature(t) { return new PendingRequest(this).temperature(t); }
391
+ maxTokens(n) { return new PendingRequest(this).maxTokens(n); }
392
+ tools(tools) { return new PendingRequest(this).tools(tools); }
393
+ think(budget) { return new PendingRequest(this).think(budget); }
394
+ retry(n, delay) { return new PendingRequest(this).retry(n, delay); }
395
+ fallback(providers) { return new PendingRequest(this).fallback(providers); }
396
+ cache(ttl) { return new PendingRequest(this).cache(ttl); }
397
+ tokenBudget(n) { return new PendingRequest(this).tokenBudget(n); }
398
+ structured(schema) { return new PendingRequest(this).structured(schema); }
399
+ use(fn) { return new PendingRequest(this).use(fn); }
400
+ onToken(fn) { return new PendingRequest(this).onToken(fn); }
401
+ providerOptions(opts) { return new PendingRequest(this).providerOptions(opts); }
402
+ guardPrompts(enabled) { return new PendingRequest(this).guardPrompts(enabled); }
403
+
404
+ /** Expose PromptGuard utilities directly on the AI facade */
405
+ get PromptGuard() { return PromptGuard; }
406
+
407
+ /** Simplest text call. */
408
+ text(prompt) { return new PendingRequest(this).generate(prompt); }
409
+ stream(prompt) { return new PendingRequest(this).stream(prompt); }
410
+ agent(prompt, maxIterations) { return new PendingRequest(this).agent(prompt, maxIterations); }
411
+
412
+ /**
413
+ * Queue an AI call to run in the background.
414
+ *
415
+ * AI.queue('Summarize this long document...')
416
+ * .then(res => console.log(res.text))
417
+ * .catch(err => console.error(err));
418
+ */
419
+ queue(prompt) { return new PendingRequest(this).queue(prompt); }
420
+ withThread(thread) { return new PendingRequest(this).withThread(thread); }
421
+
422
+ // ── Embeddings ─────────────────────────────────────────────────────────────
423
+
424
+ async embed(texts, model = null, provider = null) {
425
+ const driver = this._resolveDriver(provider || this._default);
426
+ return driver.embed(texts, model);
427
+ }
428
+
429
+ // ── Image generation ────────────────────────────────────────────────────────
430
+
431
+ /**
432
+ * Generate an image.
433
+ *
434
+ * const img = await AI.image('A donut on a counter').landscape().generate();
435
+ * await img.store('images/donut.png');
436
+ */
437
+ image(prompt) {
438
+ const p = new PendingImage(this, prompt);
439
+ AIImageResponse._storage = this._storage;
440
+ return p;
441
+ }
442
+
443
+ // ── Audio TTS ──────────────────────────────────────────────────────────────
444
+
445
+ /**
446
+ * Text-to-speech.
447
+ *
448
+ * const audio = await AI.speak('Hello world').female().generate();
449
+ * await audio.store('audio/greeting.mp3');
450
+ */
451
+ speak(text) {
452
+ AIAudioResponse._storage = this._storage;
453
+ return new PendingAudio(this, text);
454
+ }
455
+
456
+ // ── Speech-to-text ─────────────────────────────────────────────────────────
457
+
458
+ /**
459
+ * Transcription from a file path, storage path, or buffer.
460
+ *
461
+ * const t = await AI.transcribe.fromPath('/audio.mp3').diarize().generate();
462
+ * const t = await AI.transcribe.fromStorage('uploads/audio.mp3').generate();
463
+ */
464
+ get transcribe() {
465
+ const manager = this;
466
+ PendingTranscription._storage = this._storage;
467
+ return {
468
+ fromPath: (path) => new PendingTranscription(manager, { type: 'path', value: path }),
469
+ fromStorage: (path) => new PendingTranscription(manager, { type: 'storage', value: path }),
470
+ fromBuffer: (buf, filename, mimeType) => new PendingTranscription(manager, { type: 'buffer', value: buf, filename, mimeType }),
471
+ };
472
+ }
473
+
474
+ // ── Reranking ──────────────────────────────────────────────────────────────
475
+
476
+ /**
477
+ * Rerank documents by relevance.
478
+ *
479
+ * const result = await AI.rerank(['doc1', 'doc2', 'doc3']).rerank('my query');
480
+ * console.log(result.first.document);
481
+ */
482
+ rerank(documents) {
483
+ return new PendingReranking(this, documents);
484
+ }
485
+
486
+ // ── Files ────────────────────────────────────────────────────────────────────
487
+
488
+ /**
489
+ * Upload and manage files stored with AI providers.
490
+ *
491
+ * const f = await AI.files.fromPath('/report.pdf').put();
492
+ * const f = await AI.files.fromStorage('uploads/doc.pdf').put();
493
+ * const f = await AI.files.fromUrl('https://example.com/doc.pdf').put();
494
+ * const f = await AI.files.fromId('file-abc').get();
495
+ * await AI.files.fromId('file-abc').delete();
496
+ */
497
+ get files() { return new AIFilesAPI(this); }
498
+
499
+ // ── Vector stores ─────────────────────────────────────────────────────────────
500
+
501
+ /**
502
+ * Create and manage vector stores for RAG / file search.
503
+ *
504
+ * const store = await AI.stores.create('Knowledge Base');
505
+ * await store.add(AI.files.fromPath('/doc.pdf'));
506
+ * const store = await AI.stores.get('vs_abc');
507
+ * await AI.stores.delete('vs_abc');
508
+ */
509
+ get stores() { return new AIStoresAPI(this); }
510
+
511
+ // ── Provider-native tools ─────────────────────────────────────────────────────
512
+
513
+ /**
514
+ * Built-in provider tools — executed by the AI provider itself.
515
+ *
516
+ * AI.tools([AI.WebSearch()]).generate('What happened today?')
517
+ * AI.tools([AI.WebSearch().max(5).allow(['bbc.com'])]).generate('...')
518
+ * AI.tools([AI.WebFetch()]).generate('Summarize https://example.com')
519
+ * AI.tools([AI.FileSearch({ stores: ['vs_abc'] })]).generate('...')
520
+ */
521
+ WebSearch(opts) { return new WebSearch(opts); }
522
+ WebFetch(opts) { return new WebFetch(opts); }
523
+ FileSearch(opts) { return new FileSearch(opts); }
524
+
525
+ // ── Conversation threads ────────────────────────────────────────────────────
526
+
527
+ /**
528
+ * In-memory thread with optional auto-summarisation.
529
+ *
530
+ * const thread = AI.thread('You are helpful.');
531
+ */
532
+ thread(systemPrompt = null) {
533
+ return new Thread(systemPrompt);
534
+ }
535
+
536
+ /**
537
+ * DB-persisted conversation thread.
538
+ *
539
+ * const thread = await AI.conversation.forUser(user.id).create();
540
+ * await thread.addUser('Hello');
541
+ * const res = await AI.withThread(thread).generate();
542
+ * await thread.addAssistant(res.text);
543
+ */
544
+ get conversation() {
545
+ const db = this._db;
546
+ return {
547
+ forUser: (userId, agent = null) => ConversationThread.forUser(userId, agent, db),
548
+ continue: (id) => ConversationThread.continue(id, db),
549
+ list: (userId, agent, limit) => ConversationThread.list(userId, agent, limit),
550
+ };
551
+ }
552
+
553
+ // ── Prompt templates ────────────────────────────────────────────────────────
554
+
555
+ prompt(template) { const { Prompt } = require('./types'); return Prompt.make(template); }
556
+
557
+ // ── Layer 1: Zero-config entry point ──────────────────────────────────────
558
+
559
+ /**
560
+ * Zero-config chat. Picks best provider, handles memory automatically.
561
+ *
562
+ * await AI.chat('Hello');
563
+ * await AI.chat('Hello', { userId: user.id });
564
+ * await AI.chat('Hello', { userId: user.id, agent: 'coding' });
565
+ * await AI.chat('Hello', { provider: 'openai', model: 'gpt-4o' });
566
+ */
567
+ async chat(prompt, opts = {}) {
568
+ const {
569
+ userId = null,
570
+ agent = null,
571
+ provider = null,
572
+ model = null,
573
+ temperature,
574
+ stream = false,
575
+ } = opts;
576
+
577
+ // Apply named profile defaults
578
+ const profile = this._defaults[agent || 'chat'] || this._defaults['chat'] || {};
579
+
580
+ // Resolve system prompt and temperature from agent definition if given
581
+ const agentDef = agent ? this._agentDefs.get(agent) : null;
582
+ const system = agentDef?.systemPrompt || null;
583
+ const temp = temperature ?? agentDef?.temperature ?? profile.temperature ?? 0.7;
584
+
585
+ let req = new PendingRequest(this);
586
+ if (provider) req = req.using(provider);
587
+ if (model) req = req.model(model);
588
+ if (system) req = req.system(system);
589
+ req = req.temperature(temp);
590
+
591
+ // Auto tool injection — registered tools for the agent
592
+ const tools = this._getRelevantTools(prompt, agentDef);
593
+ if (tools.length) req = req.tools(tools);
594
+
595
+ // Apply global defaults
596
+ if (profile.maxTokens) req = req.maxTokens(profile.maxTokens);
597
+
598
+ // Auto-memory: load or create thread if userId given
599
+ if (userId) {
600
+ const thread = await this._getOrCreateThread(userId, agent || 'chat');
601
+ await thread.addUser(prompt);
602
+ req = await req.withThread(thread);
603
+ const res = await req.generate();
604
+ await thread.addAssistant(res.text);
605
+ this._attachMeta(res, { agent, toolsUsed: tools.map(t => t.name) });
606
+ this._attachCost(res);
607
+ if (this._debug) this._logDebug(res);
608
+ return res;
609
+ }
610
+
611
+ const res = await req.generate(prompt);
612
+ this._attachMeta(res, { agent, toolsUsed: tools.map(t => t.name) });
613
+ this._attachCost(res);
614
+ if (this._debug) this._logDebug(res);
615
+ return res;
616
+ }
617
+
618
+ // ── Layer 2: Prebuilt agents ───────────────────────────────────────────────
619
+
620
+ /**
621
+ * Get a prebuilt agent by name.
622
+ *
623
+ * await AI.agent('coding').ask('Fix this bug: ...');
624
+ * await AI.agent('writing').ask('Rewrite professionally: ...');
625
+ * await AI.agent('support').ask('Customer says: ...', { userId: user.id });
626
+ *
627
+ * Built-in agents: general, coding, writing, support, analyst, research, translator, summarizer
628
+ */
629
+ agent(name, overrides = {}) {
630
+ const def = this._agentDefs.get(name);
631
+ if (!def) {
632
+ const available = [...this._agentDefs.keys()].join(', ');
633
+ throw new AIError(`Unknown agent: "${name}". Available: ${available}`, null);
634
+ }
635
+ return new BuiltinAgent(this, def, overrides);
636
+ }
637
+
638
+ /**
639
+ * Register a custom agent definition.
640
+ *
641
+ * AI.registerAgent('legal', {
642
+ * label: 'Legal Assistant',
643
+ * temperature: 0.1,
644
+ * tools: [],
645
+ * systemPrompt: 'You are a legal assistant...',
646
+ * });
647
+ */
648
+ registerAgent(name, definition) {
649
+ this._agentDefs.set(name, definition);
650
+ return this;
651
+ }
652
+
653
+ // ── Layer 3: Use-case APIs ─────────────────────────────────────────────────
654
+
655
+ /**
656
+ * Summarize text.
657
+ *
658
+ * await AI.summarize(article);
659
+ * await AI.summarize(article, { length: 'short', provider: 'openai' });
660
+ * // length: 'short' (1-2 sentences) | 'medium' (paragraph) | 'long' (detailed)
661
+ */
662
+ async summarize(text, opts = {}) {
663
+ const lengthGuide = {
664
+ short: 'in 1-2 sentences',
665
+ medium: 'in one concise paragraph',
666
+ long: 'in detail, preserving all key points',
667
+ }[opts.length || 'medium'];
668
+
669
+ const res = await this._useCase(
670
+ `Summarize the following text ${lengthGuide}. Return only the summary, no preamble.
671
+
672
+ ${text}`,
673
+ { temperature: 0.3, ...opts }
674
+ );
675
+ return res;
676
+ }
677
+
678
+ /**
679
+ * Translate text to a target language.
680
+ *
681
+ * await AI.translate('Hello, how are you?', 'Swahili');
682
+ * await AI.translate(text, 'French', { formal: true });
683
+ */
684
+ async translate(text, targetLanguage, opts = {}) {
685
+ const formalityHint = opts.formal === true ? 'Use formal register.' :
686
+ opts.formal === false ? 'Use informal/conversational register.' : '';
687
+ const res = await this._useCase(
688
+ `Translate the following text to ${targetLanguage}. ${formalityHint} Return only the translation, no explanation.
689
+
690
+ ${text}`,
691
+ { temperature: 0.2, ...opts }
692
+ );
693
+ return res;
694
+ }
695
+
696
+ /**
697
+ * Classify text into one of the given categories.
698
+ *
699
+ * await AI.classify('I love this product!', ['positive', 'negative', 'neutral']);
700
+ * await AI.classify(email, ['spam', 'important', 'newsletter'], { explain: true });
701
+ */
702
+ async classify(text, categories, opts = {}) {
703
+ const catList = categories.map(c => `"${c}"`).join(', ');
704
+ const explainHint = opts.explain
705
+ ? 'Respond with JSON: { "category": "...", "confidence": 0.0-1.0, "reason": "one sentence" }'
706
+ : `Respond with only the category name — one of: ${catList}`;
707
+
708
+ const res = await this._useCase(
709
+ `Classify the following text into one of these categories: ${catList}.
710
+ ${explainHint}
711
+
712
+ Text:
713
+ ${text}`,
714
+ { temperature: 0.1, ...opts }
715
+ );
716
+
717
+ if (opts.explain) {
718
+ try {
719
+ res._parsed = JSON.parse(res.text.replace(/```json|```/g, '').trim());
720
+ Object.defineProperty(res, 'parsed', { get: () => res._parsed });
721
+ } catch {}
722
+ }
723
+ return res;
724
+ }
725
+
726
+ /**
727
+ * Extract structured data from text using a Schema.
728
+ *
729
+ * const { Schema } = require('millas/facades/AI');
730
+ *
731
+ * const res = await AI.extract(invoiceText, Schema.define({
732
+ * vendor: { type: 'string' },
733
+ * amount: { type: 'number' },
734
+ * date: { type: 'string' },
735
+ * currency: { type: 'string' },
736
+ * }));
737
+ *
738
+ * res.parsed.vendor // 'Acme Corp'
739
+ * res.parsed.amount // 1250.00
740
+ */
741
+ async extract(text, schema, opts = {}) {
742
+ const res = await this._useCase(text, { structured: schema, temperature: 0.1, ...opts });
743
+ return res;
744
+ }
745
+
746
+ // ── Tool registry ──────────────────────────────────────────────────────────
747
+
748
+ /**
749
+ * Register a tool globally. Registered tools are available to all agents
750
+ * and auto-discovered by AI.chat() based on keyword matching.
751
+ *
752
+ * AI.registerTool(weatherTool);
753
+ * AI.registerTool(calendarTool);
754
+ */
755
+ registerTool(tool) {
756
+ this._registeredTools.set(tool.name, tool);
757
+ return this;
758
+ }
759
+
760
+ /**
761
+ * Unregister a tool.
762
+ * AI.unregisterTool('get_weather');
763
+ */
764
+ unregisterTool(name) {
765
+ this._registeredTools.delete(name);
766
+ return this;
767
+ }
768
+
769
+ // ── Cost & pricing ─────────────────────────────────────────────────────────
770
+
771
+ /**
772
+ * Estimate cost for a prompt before sending.
773
+ *
774
+ * const est = AI.estimateCost('My prompt...', 'claude-sonnet-4-20250514');
775
+ * console.log(est.estimated.formatted); // '$0.0002'
776
+ * console.log(est.note);
777
+ */
778
+ estimateCost(prompt, model = null, expectedOutputTokens = 500) {
779
+ const m = model || (this._config.providers?.[this._default])?.model || this._default;
780
+ return CostCalculator.estimate(prompt, m, expectedOutputTokens);
781
+ }
782
+
783
+ // ── Debug mode ─────────────────────────────────────────────────────────────
784
+
785
+ /**
786
+ * Enable or disable debug logging.
787
+ *
788
+ * AI.debug(true);
789
+ *
790
+ * Outputs for every call:
791
+ * [AI] Provider: anthropic | Model: claude-sonnet-4-20250514
792
+ * [AI] Tokens: 120 in / 340 out | Cost: $0.0063 | Latency: 820ms
793
+ * [AI] Fallback used: no | Tools called: [weatherTool]
794
+ */
795
+ debug(enabled = true) {
796
+ this._debug = enabled;
797
+ return this;
798
+ }
799
+
800
+ // ── Named defaults ─────────────────────────────────────────────────────────
801
+
802
+ /**
803
+ * Set default options per use case.
804
+ *
805
+ * AI.defaults({
806
+ * chat: { temperature: 0.7 },
807
+ * coding: { temperature: 0, provider: 'anthropic' },
808
+ * writing: { temperature: 0.8, maxTokens: 2000 },
809
+ * });
810
+ */
811
+ defaults(profiles) {
812
+ Object.assign(this._defaults, profiles);
813
+ return this;
814
+ }
815
+
816
+ // ── Internal helpers ───────────────────────────────────────────────────────
817
+
818
+ _useCase(prompt, opts = {}) {
819
+ let req = new PendingRequest(this);
820
+ if (opts.provider) req = req.using(opts.provider);
821
+ if (opts.model) req = req.model(opts.model);
822
+ if (opts.temperature !== undefined) req = req.temperature(opts.temperature);
823
+ if (opts.maxTokens) req = req.maxTokens(opts.maxTokens);
824
+ if (opts.structured) req = req.structured(opts.structured);
825
+ return req.generate(prompt).then(res => {
826
+ this._attachCost(res);
827
+ if (this._debug) this._logDebug(res);
828
+ return res;
829
+ });
830
+ }
831
+
832
+ _getRegisteredTools() {
833
+ return [...this._registeredTools.values()];
834
+ }
835
+
836
+ _getRelevantTools(prompt, agentDef = null) {
837
+ if (!this._registeredTools.size) return [];
838
+
839
+ // Agent-specific tools
840
+ if (agentDef?.tools === 'all') return this._getRegisteredTools();
841
+ if (Array.isArray(agentDef?.tools) && agentDef.tools.length) {
842
+ return agentDef.tools.map(n => this._registeredTools.get(n)).filter(Boolean);
843
+ }
844
+
845
+ // Level 1 auto-discovery: keyword matching between prompt and tool description+name
846
+ const promptLower = prompt.toLowerCase();
847
+ return this._getRegisteredTools().filter(tool => {
848
+ const keywords = (tool.name + ' ' + (tool.description || ''))
849
+ .toLowerCase()
850
+ .split(/[\s_-]+/);
851
+ return keywords.some(kw => kw.length > 3 && promptLower.includes(kw));
852
+ });
853
+ }
854
+
855
+ async _getOrCreateThread(userId, agent = 'chat') {
856
+ const key = `${userId}:${agent}`;
857
+
858
+ // If DB is available, use persistent threads
859
+ if (this._db) {
860
+ return ConversationThread.forUser(userId, agent, this._db).create();
861
+ }
862
+
863
+ // Fall back to in-memory thread per user+agent
864
+ if (!this._threadCache.has(key)) {
865
+ this._threadCache.set(key, new Thread());
866
+ }
867
+ return this._threadCache.get(key);
868
+ }
869
+
870
+ _attachCost(res) {
871
+ if (!res || !res.model) return;
872
+ const cost = CostCalculator.forResponse(res);
873
+ if (cost) {
874
+ res.cost = cost;
875
+ Object.defineProperty(res, 'cost', { value: cost, configurable: true });
876
+ }
877
+ }
878
+
879
+ _attachMeta(res, extra = {}) {
880
+ if (!res) return;
881
+ res.meta = {
882
+ provider: res.provider,
883
+ model: res.model,
884
+ inputTokens: res.inputTokens,
885
+ outputTokens: res.outputTokens,
886
+ totalTokens: res.totalTokens,
887
+ finishReason: res.finishReason,
888
+ ...extra,
889
+ };
890
+ }
891
+
892
+ _logDebug(res) {
893
+ const cost = res.cost ? ` | Cost: ${res.cost.formatted}` : '';
894
+ const tools = res.meta?.toolsUsed?.length ? ` | Tools: [${res.meta.toolsUsed.join(', ')}]` : '';
895
+ const latency = res._latency ? ` | Latency: ${res._latency}ms` : '';
896
+ console.log(
897
+ `\n[AI] Provider: ${res.provider} | Model: ${res.model}` +
898
+ `\n[AI] Tokens: ${res.inputTokens} in / ${res.outputTokens} out${cost}${latency}${tools}\n`
899
+ );
900
+ }
901
+
902
+ // ── Configuration ──────────────────────────────────────────────────────────
903
+
904
+ configure(config) {
905
+ Object.assign(this._config, config);
906
+ if (config.default) this._default = config.default;
907
+ if (config.audioProvider) this._audioProvider = config.audioProvider;
908
+ if (config.rerankProvider) this._rerankProvider = config.rerankProvider;
909
+ this._drivers.clear();
910
+ return this;
911
+ }
912
+
913
+ setCache(cache) { this._cache = cache; return this; }
914
+ setDb(db) { this._db = db; ConversationThread._db = db; return this; }
915
+ setStorage(s) { this._storage = s; return this; }
916
+ }
917
+
918
+ // ── Singleton ─────────────────────────────────────────────────────────────────
919
+ // Reads ai: config from config/app.js if present, falls back to process.env.
920
+
921
+ function _loadAiConfig() {
922
+ try {
923
+ const path = require('path');
924
+ const fs = require('fs');
925
+ const appConfig = path.join(process.cwd(), 'config', 'app.js');
926
+ if (fs.existsSync(appConfig)) {
927
+ const cfg = require(appConfig);
928
+ if (cfg.ai) return cfg.ai;
929
+ }
930
+ } catch { /* fall through to env defaults */ }
931
+
932
+ // env fallback — works without config/app.js (e.g. in tests)
933
+ return {
934
+ default: process.env.AI_PROVIDER || 'anthropic',
935
+ providers: {
936
+ anthropic: { apiKey: process.env.ANTHROPIC_API_KEY, model: process.env.ANTHROPIC_MODEL },
937
+ openai: { apiKey: process.env.OPENAI_API_KEY, model: process.env.OPENAI_MODEL },
938
+ gemini: { apiKey: process.env.GEMINI_API_KEY, model: process.env.GEMINI_MODEL },
939
+ ollama: { baseUrl: process.env.OLLAMA_BASE_URL, model: process.env.OLLAMA_MODEL },
940
+ groq: { apiKey: process.env.GROQ_API_KEY, model: process.env.GROQ_MODEL },
941
+ mistral: { apiKey: process.env.MISTRAL_API_KEY, model: process.env.MISTRAL_MODEL },
942
+ xai: { apiKey: process.env.XAI_API_KEY, model: process.env.XAI_MODEL },
943
+ deepseek: { apiKey: process.env.DEEPSEEK_API_KEY, model: process.env.DEEPSEEK_MODEL },
944
+ cohere: { apiKey: process.env.COHERE_API_KEY, model: process.env.COHERE_MODEL },
945
+ elevenlabs: { apiKey: process.env.ELEVENLABS_API_KEY },
946
+ },
947
+ };
948
+ }
949
+
950
+ const defaultAI = new AIManager(_loadAiConfig());
951
+
952
+ module.exports = defaultAI;
953
+ module.exports.AIManager = AIManager;
954
+ module.exports.PendingRequest = PendingRequest;