@marifold/service 0.60.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.
@@ -0,0 +1,851 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createMarifoldService = createMarifoldService;
7
+ exports.startMarifoldService = startMarifoldService;
8
+ const fastify_1 = __importDefault(require("fastify"));
9
+ const core_1 = require("@marifold/core");
10
+ const ProfileRoutes_1 = require("./ProfileRoutes");
11
+ const RunRoutes_1 = require("./RunRoutes");
12
+ const Security_1 = require("./Security");
13
+ const StaticRoutes_1 = require("./StaticRoutes");
14
+ const Sse_1 = require("./Sse");
15
+ const Validation_1 = require("./Validation");
16
+ const API_VERSION = 'v1';
17
+ const DEFAULT_HOST = '127.0.0.1';
18
+ const DEFAULT_PORT = 32140;
19
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
20
+ /** Base64 image attachments ride the JSON body; fastify's 1 MiB default
21
+ * would reject them. Still gated by bind scope, source network, CORS, and any
22
+ * configured bearer auth whenever the service leaves loopback. */
23
+ const BODY_LIMIT_BYTES = 25 * 1024 * 1024;
24
+ function createMarifoldService(options) {
25
+ const host = options.host ?? DEFAULT_HOST;
26
+ const security = (0, Security_1.resolveSecurityOptions)(options.loadedConfig.config.service, {
27
+ token: options.auth?.token,
28
+ corsOrigins: options.cors?.origins,
29
+ });
30
+ const runtime = new core_1.MarifoldRuntime({ loadedConfig: options.loadedConfig });
31
+ const server = (0, fastify_1.default)({ logger: options.logger ?? false, bodyLimit: BODY_LIMIT_BYTES });
32
+ (0, Security_1.registerSecurity)(server, {
33
+ ...security,
34
+ access: LOOPBACK_HOSTS.has(host) ? 'loopback' : 'private',
35
+ boundHost: host,
36
+ });
37
+ const scheduler = (options.scheduler ?? true)
38
+ ? runtime.createScheduler(message => server.log.info(message))
39
+ : undefined;
40
+ scheduler?.start();
41
+ // Messaging bridge(s) run inside the same long-lived process as the HTTP API
42
+ // and scheduler, so one `marifold service` powers everything (TUI, future
43
+ // Web/desktop/mobile, Telegram).
44
+ const telegramBridge = runtime.createTelegramBridge(message => server.log.info(message));
45
+ telegramBridge?.start();
46
+ server.marifoldTelegram = telegramBridge ? { profile: telegramBridge.profile } : undefined;
47
+ const runRegistry = runtime.createRunRegistry(message => server.log.info(message));
48
+ const skillAppInstances = runtime.createSkillAppInstanceRegistry();
49
+ // Plain /ask and /chat/stream requests are not RunRegistry entries, but they
50
+ // can still persist a final exchange. Keep session-scoped requests visible
51
+ // to destructive history routes so a late completion cannot recreate a
52
+ // session that was just deleted or truncated.
53
+ const activeSessionRequests = new Map();
54
+ const activeProfileRequests = new Map();
55
+ const beginSessionRequest = (sessionId, profile) => {
56
+ if (sessionId)
57
+ activeSessionRequests.set(sessionId, (activeSessionRequests.get(sessionId) ?? 0) + 1);
58
+ if (profile)
59
+ activeProfileRequests.set(profile, (activeProfileRequests.get(profile) ?? 0) + 1);
60
+ return () => {
61
+ if (sessionId) {
62
+ const remaining = (activeSessionRequests.get(sessionId) ?? 1) - 1;
63
+ if (remaining > 0)
64
+ activeSessionRequests.set(sessionId, remaining);
65
+ else
66
+ activeSessionRequests.delete(sessionId);
67
+ }
68
+ if (profile) {
69
+ const remaining = (activeProfileRequests.get(profile) ?? 1) - 1;
70
+ if (remaining > 0)
71
+ activeProfileRequests.set(profile, remaining);
72
+ else
73
+ activeProfileRequests.delete(profile);
74
+ }
75
+ };
76
+ };
77
+ const hasActiveSessionRequest = (sessionId) => (activeSessionRequests.get(sessionId) ?? 0) > 0;
78
+ (0, RunRoutes_1.registerRunRoutes)(server, runRegistry);
79
+ (0, ProfileRoutes_1.registerProfileRoutes)(server, runtime, {
80
+ isProfileActive: profile => (activeProfileRequests.get(profile) ?? 0) > 0
81
+ || runRegistry.list().some(run => run.profile === profile && run.finishedAt === undefined),
82
+ });
83
+ const webDir = resolveServiceWebDir(options);
84
+ if (webDir)
85
+ (0, StaticRoutes_1.registerStaticRoutes)(server, webDir);
86
+ server.addHook('onClose', async () => {
87
+ telegramBridge?.stop();
88
+ scheduler?.stop();
89
+ runRegistry.close();
90
+ skillAppInstances.close();
91
+ runtime.close();
92
+ });
93
+ server.setErrorHandler((error, _request, reply) => {
94
+ const normalized = normalizeError(error);
95
+ reply.status(normalized.statusCode).send({
96
+ ok: false,
97
+ error: normalized.error,
98
+ });
99
+ });
100
+ server.setNotFoundHandler((request, reply) => {
101
+ reply.status(404).send({
102
+ ok: false,
103
+ error: {
104
+ code: 'NOT_FOUND',
105
+ message: `Route not found: ${request.method} ${request.url}`,
106
+ },
107
+ });
108
+ });
109
+ server.get('/health', async () => ({
110
+ ok: true,
111
+ service: 'marifold',
112
+ apiVersion: API_VERSION,
113
+ }));
114
+ server.get('/v1/status', async () => ({
115
+ ok: true,
116
+ service: 'marifold',
117
+ apiVersion: API_VERSION,
118
+ localOnly: true,
119
+ configPath: options.loadedConfig.configPath,
120
+ foundConfig: options.loadedConfig.foundConfig,
121
+ default: options.loadedConfig.config.default,
122
+ paths: options.loadedConfig.config.paths,
123
+ }));
124
+ server.get('/v1/config', async () => ({
125
+ ok: true,
126
+ config: publicConfig(options.loadedConfig, Boolean(security.token)),
127
+ }));
128
+ // Mirrors the CLI's `config set <key> <value>` exactly (same dotted-key
129
+ // routing and validation); returns the sanitized view, never raw secrets.
130
+ server.patch('/v1/config', async (request) => {
131
+ const body = (0, Validation_1.objectBody)(request.body);
132
+ runtime.setConfigValue((0, Validation_1.requiredString)(body.key, 'key'), (0, Validation_1.stringValue)(body.value, 'value'));
133
+ return { ok: true, config: publicConfig(options.loadedConfig, Boolean(security.token)) };
134
+ });
135
+ server.get('/v1/providers', async () => ({
136
+ ok: true,
137
+ providers: Object.entries(options.loadedConfig.config.providers)
138
+ .sort(([a], [b]) => a.localeCompare(b))
139
+ .map(([name, provider]) => ({
140
+ name,
141
+ ...publicProvider(provider),
142
+ })),
143
+ }));
144
+ // Live reachability probe for every provider (CLI `provider status`).
145
+ // Sanitized: key/token presence booleans and env-var *names* only.
146
+ server.get('/v1/providers/status', async () => ({
147
+ ok: true,
148
+ providers: await runtime.providerStatus(),
149
+ }));
150
+ // Models the provider actually serves right now (feeds the model picker).
151
+ server.get('/v1/providers/:name/models', async (request) => ({
152
+ ok: true,
153
+ ...(await runtime.listProviderModels(request.params.name)),
154
+ }));
155
+ server.delete('/v1/providers/:name', async (request) => {
156
+ const result = runtime.removeProvider(request.params.name);
157
+ return {
158
+ ok: true,
159
+ ...result,
160
+ config: publicConfig(options.loadedConfig, Boolean(security.token)),
161
+ models: modelsView(options.loadedConfig),
162
+ };
163
+ });
164
+ // Available skills (name + usage) for the composer's $-autocomplete,
165
+ // profile-scoped so profile skills shadow global ones.
166
+ server.get('/v1/skills', async (request) => ({
167
+ ok: true,
168
+ skills: runtime.listSkills(request.query.profile).map(skillHint),
169
+ }));
170
+ // Resolve a `$skill [args]` invocation in code so Web/service clients do not
171
+ // spend an agent loop searching the filesystem for a skill already indexed
172
+ // by Marifold.
173
+ server.post('/v1/skills/resolve', async (request) => {
174
+ const body = (0, Validation_1.objectBody)(request.body);
175
+ const profile = (0, Validation_1.optionalStringField)('profile', body.profile).profile;
176
+ return {
177
+ ok: true,
178
+ invocation: runtime.resolveSkillInvocation((0, Validation_1.requiredString)(body.invocation, 'invocation'), profile),
179
+ };
180
+ });
181
+ // SkillApp source stays server-owned. Every renderer receives the same
182
+ // statically compiled JSON contract and can only submit typed state.
183
+ server.get('/v1/apps', async () => ({
184
+ ok: true,
185
+ apps: runtime.listApps(),
186
+ }));
187
+ server.get('/v1/apps/:name', async (request, reply) => {
188
+ const app = runtime.getApp(request.params.name);
189
+ if (!app) {
190
+ reply.status(404);
191
+ return {
192
+ ok: false,
193
+ error: {
194
+ code: 'APP_NOT_FOUND',
195
+ message: `App not found: ${request.params.name}`,
196
+ },
197
+ };
198
+ }
199
+ return { ok: true, app };
200
+ });
201
+ // SkillApps are statically compiled templates with ephemeral service-owned
202
+ // state. Both buttons and on-change triggers execute the same declared
203
+ // model + app-local Skill operation and return a normalized result.
204
+ server.post('/v1/apps/:name/instances', async (request, reply) => {
205
+ const instance = skillAppInstances.create(request.params.name);
206
+ reply.status(201);
207
+ return { ok: true, instance };
208
+ });
209
+ server.patch('/v1/app-instances/:id/state', async (request) => {
210
+ const body = (0, Validation_1.objectBody)(request.body);
211
+ return {
212
+ ok: true,
213
+ ...(await skillAppInstances.update(request.params.id, (0, Validation_1.objectBody)(body.values))),
214
+ };
215
+ });
216
+ server.post('/v1/app-instances/:id/operations/:operation', async (request) => ({
217
+ ok: true,
218
+ ...(await skillAppInstances.run(request.params.id, request.params.operation)),
219
+ }));
220
+ server.delete('/v1/app-instances/:id', async (request) => ({
221
+ ok: true,
222
+ deleted: skillAppInstances.delete(request.params.id),
223
+ }));
224
+ server.get('/v1/models', async () => ({
225
+ ok: true,
226
+ default: {
227
+ provider: options.loadedConfig.config.default.provider,
228
+ model: options.loadedConfig.config.default.model,
229
+ },
230
+ options: [...options.loadedConfig.config.models.options],
231
+ }));
232
+ // Model management (CLI `model add`/`rm`/`default`). Provider entries may be
233
+ // created/updated here, but never with secrets — raw api_key values stay
234
+ // CLI/file-only by design; the wire accepts the env-var *name* at most.
235
+ server.post('/v1/models', async (request, reply) => {
236
+ const body = (0, Validation_1.objectBody)(request.body);
237
+ runtime.addModelOption((0, Validation_1.requiredString)(body.provider, 'provider'), (0, Validation_1.requiredString)(body.model, 'model'), {
238
+ ...(body.type !== undefined ? { type: parseProviderTypeField(body.type) } : {}),
239
+ ...(0, Validation_1.optionalStringField)('baseUrl', body.baseUrl),
240
+ ...(0, Validation_1.optionalStringField)('apiKeyEnv', body.apiKeyEnv),
241
+ });
242
+ reply.status(201);
243
+ return modelsView(options.loadedConfig);
244
+ });
245
+ server.delete('/v1/models', async (request) => {
246
+ const body = (0, Validation_1.objectBody)(request.body);
247
+ const result = runtime.removeModelOption((0, Validation_1.requiredString)(body.provider, 'provider'), (0, Validation_1.requiredString)(body.model, 'model'));
248
+ return { ...modelsView(options.loadedConfig), ...result };
249
+ });
250
+ server.put('/v1/models/default', async (request) => {
251
+ const body = (0, Validation_1.objectBody)(request.body);
252
+ runtime.setDefaultModel((0, Validation_1.requiredString)(body.provider, 'provider'), (0, Validation_1.requiredString)(body.model, 'model'));
253
+ return modelsView(options.loadedConfig);
254
+ });
255
+ server.get('/v1/profiles', async () => ({
256
+ ok: true,
257
+ profiles: runtime.listProfiles(),
258
+ }));
259
+ server.get('/v1/profiles/:name', async (request) => ({
260
+ ok: true,
261
+ profile: runtime.getProfile(request.params.name),
262
+ }));
263
+ server.get('/v1/profiles/:name/memories', async (request) => {
264
+ const includeSuperseded = parseBooleanQuery(request.query.all);
265
+ const limit = parseLimitQuery(request.query.limit);
266
+ const entries = runtime.listMemories(request.params.name, includeSuperseded);
267
+ return {
268
+ ok: true,
269
+ profile: request.params.name,
270
+ memories: limit === undefined ? entries : entries.slice(0, limit),
271
+ };
272
+ });
273
+ server.get('/v1/sessions', async (request) => ({
274
+ ok: true,
275
+ sessions: runtime.listSessions(parseLimitQuery(request.query.limit) ?? 50, request.query.profile, {
276
+ archived: parseBooleanQuery(request.query.archived),
277
+ ...(request.query.q?.trim() ? { search: request.query.q } : {}),
278
+ }),
279
+ }));
280
+ server.get('/v1/sessions/:id', async (request, reply) => {
281
+ const session = runtime.getSession(request.params.id);
282
+ if (!session) {
283
+ reply.status(404);
284
+ return {
285
+ ok: false,
286
+ error: {
287
+ code: 'SESSION_NOT_FOUND',
288
+ message: `Session not found: ${request.params.id}`,
289
+ },
290
+ };
291
+ }
292
+ return { ok: true, session };
293
+ });
294
+ server.get('/v1/sessions/:id/attachments/:userTurnIndex/:attachmentIndex', async (request, reply) => {
295
+ const userTurnIndex = nonNegativeIntegerPath(request.params.userTurnIndex, 'userTurnIndex');
296
+ const attachmentIndex = nonNegativeIntegerPath(request.params.attachmentIndex, 'attachmentIndex');
297
+ const attachment = runtime.getSessionAttachment(request.params.id, userTurnIndex, attachmentIndex);
298
+ if (!attachment?.data) {
299
+ reply.status(404);
300
+ return {
301
+ ok: false,
302
+ error: {
303
+ code: 'SESSION_ATTACHMENT_NOT_FOUND',
304
+ message: 'Session attachment not found.',
305
+ },
306
+ };
307
+ }
308
+ reply
309
+ .type(attachment.mediaType)
310
+ .header('cache-control', 'private, max-age=60')
311
+ .header('x-content-type-options', 'nosniff');
312
+ return reply.send(Buffer.from(attachment.data, 'base64'));
313
+ });
314
+ server.patch('/v1/sessions/:id', async (request, reply) => {
315
+ const body = (0, Validation_1.objectBody)(request.body);
316
+ const hasTitle = Object.prototype.hasOwnProperty.call(body, 'title');
317
+ const hasPinned = Object.prototype.hasOwnProperty.call(body, 'pinned');
318
+ const hasArchived = Object.prototype.hasOwnProperty.call(body, 'archived');
319
+ if (!hasTitle && !hasPinned && !hasArchived) {
320
+ throw core_1.MarifoldError.configInvalid('At least one of title, pinned, or archived is required.');
321
+ }
322
+ if (hasTitle && body.title !== null && typeof body.title !== 'string') {
323
+ throw core_1.MarifoldError.configInvalid('title must be a string or null.');
324
+ }
325
+ if (hasPinned && typeof body.pinned !== 'boolean') {
326
+ throw core_1.MarifoldError.configInvalid('pinned must be a boolean.');
327
+ }
328
+ if (hasArchived && typeof body.archived !== 'boolean') {
329
+ throw core_1.MarifoldError.configInvalid('archived must be a boolean.');
330
+ }
331
+ const updated = runtime.updateSessionDisplay(request.params.id, {
332
+ ...(hasTitle ? { title: body.title } : {}),
333
+ ...(hasPinned ? { pinned: body.pinned } : {}),
334
+ ...(hasArchived ? { archived: body.archived } : {}),
335
+ });
336
+ if (!updated) {
337
+ reply.status(404);
338
+ return {
339
+ ok: false,
340
+ error: {
341
+ code: 'SESSION_NOT_FOUND',
342
+ message: `Session not found: ${request.params.id}`,
343
+ },
344
+ };
345
+ }
346
+ return { ok: true, session: runtime.getSession(request.params.id) };
347
+ });
348
+ server.delete('/v1/sessions/:id', async (request) => {
349
+ if (hasActiveSessionRequest(request.params.id)
350
+ || runRegistry.list().some(run => run.sessionId === request.params.id && run.status === 'running')) {
351
+ throw core_1.MarifoldError.agentRunInvalid('Cancel the active request and wait for it to finish before deleting this session.');
352
+ }
353
+ return {
354
+ ok: true,
355
+ deleted: runtime.deleteSession(request.params.id),
356
+ };
357
+ });
358
+ server.post('/v1/sessions/:id/truncate', async (request, reply) => {
359
+ const body = (0, Validation_1.objectBody)(request.body);
360
+ const userTurnIndex = body.fromUserTurnIndex;
361
+ if (typeof userTurnIndex !== 'number' || !Number.isInteger(userTurnIndex) || userTurnIndex < 0) {
362
+ throw core_1.MarifoldError.configInvalid('fromUserTurnIndex must be a non-negative integer.');
363
+ }
364
+ if (hasActiveSessionRequest(request.params.id)
365
+ || runRegistry.list().some(run => run.sessionId === request.params.id && run.status === 'running')) {
366
+ throw core_1.MarifoldError.agentRunInvalid('Cancel the active request before editing this session history.');
367
+ }
368
+ const result = runtime.truncateSessionFromUserTurn(request.params.id, userTurnIndex);
369
+ if (!result.found) {
370
+ reply.status(404);
371
+ return {
372
+ ok: false,
373
+ error: {
374
+ code: 'SESSION_NOT_FOUND',
375
+ message: `Session not found: ${request.params.id}`,
376
+ },
377
+ };
378
+ }
379
+ return { ok: true, truncated: result.removedTurns > 0, removedTurns: result.removedTurns };
380
+ });
381
+ // Manually compact a session now (the /compact command): summarize older turns.
382
+ server.post('/v1/sessions/:id/compact', async (request) => {
383
+ const body = (0, Validation_1.objectBody)(request.body);
384
+ const result = await runtime.compactSession(request.params.id, {
385
+ profile: (0, Validation_1.requiredString)(body.profile, 'profile'),
386
+ ...(typeof body.provider === 'string' ? { provider: body.provider } : {}),
387
+ ...(typeof body.model === 'string' ? { model: body.model } : {}),
388
+ ...(typeof body.think === 'boolean' ? { think: body.think } : {}),
389
+ });
390
+ return { ok: true, ...result };
391
+ });
392
+ server.post('/v1/ask', async (request) => {
393
+ const input = parseRunRequest(request.body);
394
+ const endRequest = beginSessionRequest(input.sessionId, input.profile ?? options.loadedConfig.config.default.profile);
395
+ try {
396
+ return {
397
+ ok: true,
398
+ response: await runtime.ask(input),
399
+ };
400
+ }
401
+ finally {
402
+ endRequest();
403
+ }
404
+ });
405
+ server.post('/v1/chat/stream', async (request, reply) => {
406
+ const input = parseRunRequest(request.body);
407
+ const endRequest = beginSessionRequest(input.sessionId, input.profile ?? options.loadedConfig.config.default.profile);
408
+ try {
409
+ await streamChat(reply, runtime, input);
410
+ }
411
+ finally {
412
+ endRequest();
413
+ }
414
+ });
415
+ server.get('/v1/schedules', async () => ({
416
+ ok: true,
417
+ schedules: runtime.listSchedules(),
418
+ }));
419
+ server.get('/v1/schedules/:id', async (request, reply) => {
420
+ const schedule = runtime.getSchedule(request.params.id);
421
+ if (!schedule) {
422
+ reply.status(404);
423
+ return {
424
+ ok: false,
425
+ error: {
426
+ code: 'SCHEDULE_NOT_FOUND',
427
+ message: `Schedule not found: ${request.params.id}`,
428
+ },
429
+ };
430
+ }
431
+ return { ok: true, schedule };
432
+ });
433
+ server.post('/v1/tasks', async (request, reply) => {
434
+ reply.status(201);
435
+ return {
436
+ ok: true,
437
+ task: runtime.createTask(parseTaskCreateInput(request.body)),
438
+ };
439
+ });
440
+ server.get('/v1/tasks', async (request) => ({
441
+ ok: true,
442
+ tasks: runtime.listTasks(parseTaskListOptions(request.query)),
443
+ }));
444
+ server.get('/v1/tasks/:id', async (request, reply) => {
445
+ const task = runtime.getTask(request.params.id);
446
+ if (!task) {
447
+ reply.status(404);
448
+ return {
449
+ ok: false,
450
+ error: {
451
+ code: 'TASK_NOT_FOUND',
452
+ message: `Task not found: ${request.params.id}`,
453
+ },
454
+ };
455
+ }
456
+ return { ok: true, task };
457
+ });
458
+ server.patch('/v1/tasks/:id', async (request) => ({
459
+ ok: true,
460
+ task: runtime.updateTask(request.params.id, parseTaskUpdateInput(request.body)),
461
+ }));
462
+ server.post('/v1/tasks/:id/events', async (request) => ({
463
+ ok: true,
464
+ task: runtime.appendTaskEvent(request.params.id, parseTaskEventInput(request.body)),
465
+ }));
466
+ server.delete('/v1/tasks/:id', async (request) => ({
467
+ ok: true,
468
+ deleted: runtime.deleteTask(request.params.id),
469
+ }));
470
+ return server;
471
+ }
472
+ async function startMarifoldService(options) {
473
+ const host = options.host ?? DEFAULT_HOST;
474
+ const port = options.port ?? DEFAULT_PORT;
475
+ const webDir = resolveServiceWebDir(options);
476
+ const server = createMarifoldService({ ...options, host, web: { dir: webDir } });
477
+ try {
478
+ const address = await server.listen({ host, port });
479
+ return {
480
+ server,
481
+ address,
482
+ host,
483
+ port,
484
+ ...(webDir ? { webDir } : {}),
485
+ telegram: server.marifoldTelegram,
486
+ };
487
+ }
488
+ catch (error) {
489
+ // createMarifoldService starts the scheduler/runtime before listen().
490
+ // Always run Fastify's onClose hooks when binding fails, otherwise an
491
+ // EADDRINUSE attempt leaves a ghost process alive on those background
492
+ // handles even though it never served a request.
493
+ try {
494
+ await server.close();
495
+ }
496
+ catch {
497
+ // Preserve the actionable listen error. Close is best-effort here, and
498
+ // individual lifecycle owners also stop from the onClose hook.
499
+ }
500
+ throw error;
501
+ }
502
+ }
503
+ function resolveServiceWebDir(options) {
504
+ return options.web?.dir
505
+ ?? options.loadedConfig.config.service?.webDir
506
+ ?? (0, StaticRoutes_1.resolveBundledWebDir)();
507
+ }
508
+ async function streamChat(reply, runtime, request) {
509
+ let closed = false;
510
+ let completion;
511
+ // A disconnected client must tear down the in-flight provider request, not
512
+ // just stop the SSE writes — otherwise the model keeps generating unbilled-
513
+ // for output after the browser tab is gone.
514
+ const abort = new AbortController();
515
+ reply.hijack();
516
+ reply.raw.on('close', () => {
517
+ closed = true;
518
+ abort.abort();
519
+ });
520
+ reply.raw.writeHead(200, Sse_1.SSE_HEADERS);
521
+ // No id:/retry: here on purpose — a chat POST is one-shot (an EventSource
522
+ // reconnect would re-run the prompt); only the runs stream is resumable.
523
+ const stopHeartbeat = (0, Sse_1.startSseHeartbeat)(reply);
524
+ try {
525
+ for await (const chunk of runtime.stream({ ...request, signal: abort.signal }, summary => {
526
+ completion = summary;
527
+ }, text => {
528
+ if (!closed)
529
+ (0, Sse_1.writeSse)(reply, 'reasoning', { text });
530
+ })) {
531
+ if (closed)
532
+ break;
533
+ (0, Sse_1.writeSse)(reply, 'chunk', { text: chunk });
534
+ }
535
+ if (!closed) {
536
+ (0, Sse_1.writeSse)(reply, 'done', {
537
+ ...(completion?.usage ? { usage: completion.usage } : {}),
538
+ ...(completion?.latencyMs !== undefined ? { latencyMs: completion.latencyMs } : {}),
539
+ });
540
+ }
541
+ }
542
+ catch (error) {
543
+ if (!closed) {
544
+ (0, Sse_1.writeSse)(reply, 'error', normalizeError(error).error);
545
+ (0, Sse_1.writeSse)(reply, 'done', {});
546
+ }
547
+ }
548
+ finally {
549
+ stopHeartbeat();
550
+ if (!closed)
551
+ reply.raw.end();
552
+ }
553
+ }
554
+ function parseRunRequest(value) {
555
+ const body = (0, Validation_1.objectBody)(value);
556
+ return {
557
+ prompt: (0, Validation_1.requiredString)(body.prompt, 'prompt'),
558
+ ...(0, Validation_1.optionalStringField)('profile', body.profile),
559
+ ...(0, Validation_1.optionalStringField)('provider', body.provider),
560
+ ...(0, Validation_1.optionalStringField)('model', body.model),
561
+ ...(0, Validation_1.optionalStringField)('sessionId', body.sessionId),
562
+ ...(0, Validation_1.optionalStringField)('userTurn', body.userTurn),
563
+ ...(0, Validation_1.optionalBooleanField)('isolated', body.isolated),
564
+ ...(0, Validation_1.optionalNonNegativeIntegerField)('replaceUserTurnIndex', body.replaceUserTurnIndex),
565
+ ...(0, Validation_1.optionalBooleanField)('memories', body.memories),
566
+ ...(0, Validation_1.optionalBooleanField)('think', body.think),
567
+ ...(0, Validation_1.optionalBooleanField)('profileContext', body.profileContext),
568
+ ...(0, Validation_1.optionalBooleanField)('originalImages', body.originalImages),
569
+ ...(0, Validation_1.optionalImagesField)(body.images),
570
+ ...(body.instructions !== undefined ? { instructions: (0, Validation_1.stringArray)(body.instructions, 'instructions') } : {}),
571
+ };
572
+ }
573
+ function parseTaskCreateInput(value) {
574
+ const body = (0, Validation_1.objectBody)(value);
575
+ return {
576
+ objective: (0, Validation_1.requiredString)(body.objective, 'objective'),
577
+ ...(0, Validation_1.optionalStringField)('title', body.title),
578
+ ...(0, Validation_1.optionalStringField)('profile', body.profile),
579
+ ...(0, Validation_1.optionalStringField)('sessionId', body.sessionId),
580
+ ...(0, Validation_1.optionalStringField)('summary', body.summary),
581
+ ...(0, Validation_1.optionalStringField)('nextAction', body.nextAction),
582
+ ...optionalTaskStatusField('status', body.status),
583
+ ...optionalTagsField(body.tags),
584
+ ...optionalPlanField(body.plan),
585
+ };
586
+ }
587
+ function parseTaskUpdateInput(value) {
588
+ const body = (0, Validation_1.objectBody)(value);
589
+ const input = {};
590
+ assignStringIfPresent(input, body, 'title');
591
+ assignStringIfPresent(input, body, 'objective');
592
+ assignStringIfPresent(input, body, 'profile');
593
+ assignStringIfPresent(input, body, 'sessionId');
594
+ assignStringIfPresent(input, body, 'summary');
595
+ assignStringIfPresent(input, body, 'nextAction');
596
+ if (Object.prototype.hasOwnProperty.call(body, 'status'))
597
+ input.status = taskStatus(body.status);
598
+ if (Object.prototype.hasOwnProperty.call(body, 'tags'))
599
+ input.tags = (0, Validation_1.stringArray)(body.tags, 'tags');
600
+ if (Object.prototype.hasOwnProperty.call(body, 'plan'))
601
+ input.plan = planArray(body.plan);
602
+ return input;
603
+ }
604
+ function parseTaskEventInput(value) {
605
+ const body = (0, Validation_1.objectBody)(value);
606
+ return {
607
+ message: (0, Validation_1.requiredString)(body.message, 'message'),
608
+ ...(body.kind === undefined ? {} : { kind: taskEventKind(body.kind) }),
609
+ ...(0, Validation_1.optionalStringField)('stepId', body.stepId),
610
+ ...(body.metadata === undefined ? {} : { metadata: metadataObject(body.metadata) }),
611
+ };
612
+ }
613
+ function parseTaskListOptions(query) {
614
+ return {
615
+ ...(query.status === undefined ? {} : { status: taskStatus(query.status) }),
616
+ ...(query.limit === undefined ? {} : { limit: parseLimitQuery(query.limit) }),
617
+ };
618
+ }
619
+ const PROVIDER_TYPES = ['ollama', 'openai-compatible', 'anthropic'];
620
+ function parseProviderTypeField(value) {
621
+ const type = (0, Validation_1.stringValue)(value, 'type');
622
+ const known = PROVIDER_TYPES.find(candidate => candidate === type);
623
+ if (!known)
624
+ throw core_1.MarifoldError.configInvalid(`type must be one of ${PROVIDER_TYPES.join(', ')}.`);
625
+ return known;
626
+ }
627
+ /** The GET /v1/models payload — returned by every model write for refresh-free clients. */
628
+ function modelsView(loadedConfig) {
629
+ return {
630
+ ok: true,
631
+ default: {
632
+ provider: loadedConfig.config.default.provider,
633
+ model: loadedConfig.config.default.model,
634
+ },
635
+ options: [...loadedConfig.config.models.options],
636
+ };
637
+ }
638
+ function publicConfig(loadedConfig, hasEffectiveToken) {
639
+ const service = loadedConfig.config.service;
640
+ return {
641
+ default: loadedConfig.config.default,
642
+ models: loadedConfig.config.models,
643
+ memory: loadedConfig.config.memory,
644
+ paths: loadedConfig.config.paths,
645
+ // Resolved (defaults merged) and secret-free — clients need the global
646
+ // [agent] to compute a profile's effective permissions.
647
+ agent: (0, core_1.resolveAgentConfig)(loadedConfig.config.agent),
648
+ webSearch: (() => {
649
+ const search = (0, core_1.resolveWebSearchConfig)(loadedConfig.config.webSearch);
650
+ return {
651
+ enabled: search.enabled,
652
+ maxResults: search.maxResults,
653
+ provider: search.provider,
654
+ ...(search.apiKeyEnv ? { apiKeyEnv: search.apiKeyEnv } : {}),
655
+ ...(search.scrape !== undefined ? { scrape: search.scrape } : {}),
656
+ ...(search.proxy ? { proxy: search.proxy } : {}),
657
+ hasApiKey: Boolean(search.apiKey),
658
+ };
659
+ })(),
660
+ // Sanitized [service] view: the token value never leaves the process.
661
+ service: {
662
+ ...(service?.webDir ? { webDir: service.webDir } : {}),
663
+ ...(service?.tokenEnv ? { tokenEnv: service.tokenEnv } : {}),
664
+ corsOrigins: service?.corsOrigins ?? [],
665
+ hasToken: hasEffectiveToken,
666
+ },
667
+ providers: Object.fromEntries(Object.entries(loadedConfig.config.providers)
668
+ .sort(([a], [b]) => a.localeCompare(b))
669
+ .map(([name, provider]) => [name, publicProvider(provider)])),
670
+ };
671
+ }
672
+ function skillHint(skill) {
673
+ const vars = skill.variables
674
+ .map(variable => (variable.required ? `<${variable.name}>` : `[${variable.name}]`))
675
+ .join(' ');
676
+ return {
677
+ name: skill.name,
678
+ description: skill.description,
679
+ usage: `$${skill.name}${vars ? ` ${vars}` : ''}`,
680
+ };
681
+ }
682
+ function publicProvider(provider) {
683
+ return {
684
+ type: provider.type,
685
+ ...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
686
+ ...(provider.apiKeyEnv ? { apiKeyEnv: provider.apiKeyEnv } : {}),
687
+ // proxy is a non-secret URL like baseUrl, so it crosses the wire in the
688
+ // clear (unlike api_key). A proxy URL *can* embed credentials
689
+ // (user:pass@host); that's the caller's choice, same as a secret in baseUrl.
690
+ ...(provider.proxy ? { proxy: provider.proxy } : {}),
691
+ hasApiKey: Boolean(provider.apiKey),
692
+ hasOauthToken: Boolean(provider.oauthToken),
693
+ hasApiKeyExpiresAt: provider.apiKeyExpiresAt !== undefined,
694
+ };
695
+ }
696
+ function normalizeError(error) {
697
+ if (error instanceof core_1.MarifoldError) {
698
+ return {
699
+ statusCode: statusCodeForError(error),
700
+ error: {
701
+ code: error.code,
702
+ message: error.message,
703
+ ...(Object.keys(error.details).length > 0 ? { details: error.details } : {}),
704
+ },
705
+ };
706
+ }
707
+ if (error instanceof Error) {
708
+ return {
709
+ statusCode: 500,
710
+ error: {
711
+ code: 'INTERNAL_ERROR',
712
+ message: error.message,
713
+ },
714
+ };
715
+ }
716
+ return {
717
+ statusCode: 500,
718
+ error: {
719
+ code: 'INTERNAL_ERROR',
720
+ message: String(error),
721
+ },
722
+ };
723
+ }
724
+ function statusCodeForError(error) {
725
+ if (error.code === 'TASK_NOT_FOUND'
726
+ || error.code === 'SCHEDULE_NOT_FOUND'
727
+ || error.code === 'SKILL_NOT_FOUND'
728
+ || error.code === 'APP_NOT_FOUND'
729
+ || error.code === 'RUN_NOT_FOUND'
730
+ || error.code === 'APPROVAL_NOT_FOUND'
731
+ || error.code === 'USER_INPUT_NOT_FOUND') {
732
+ return 404;
733
+ }
734
+ if (error.code === 'CONFIG_INVALID'
735
+ || error.code === 'IMAGE_INVALID'
736
+ || error.code === 'PROFILE_INVALID'
737
+ || error.code === 'MEMORY_INVALID'
738
+ || error.code === 'TASK_INVALID'
739
+ || error.code === 'SCHEDULE_INVALID'
740
+ || error.code === 'AGENT_TOOL_INVALID'
741
+ || error.code === 'AGENT_RUN_INVALID'
742
+ || error.code === 'SKILL_INVALID'
743
+ || error.code === 'APP_INVALID') {
744
+ return 400;
745
+ }
746
+ if (error.code === 'CONFIG_FILE_NOT_FOUND')
747
+ return 404;
748
+ if (error.code === 'UNAUTHORIZED')
749
+ return 401;
750
+ if (error.code === 'NETWORK_FORBIDDEN' || error.code === 'ORIGIN_FORBIDDEN')
751
+ return 403;
752
+ if (error.code === 'RUN_LIMIT_EXCEEDED')
753
+ return 429;
754
+ if (error.code === 'PROVIDER_ERROR')
755
+ return 502;
756
+ return 500;
757
+ }
758
+ function optionalTaskStatusField(key, value) {
759
+ if (value === undefined)
760
+ return {};
761
+ return { [key]: taskStatus(value) };
762
+ }
763
+ function optionalTagsField(value) {
764
+ if (value === undefined)
765
+ return {};
766
+ return { tags: (0, Validation_1.stringArray)(value, 'tags') };
767
+ }
768
+ function optionalPlanField(value) {
769
+ if (value === undefined)
770
+ return {};
771
+ return { plan: planArray(value) };
772
+ }
773
+ function assignStringIfPresent(input, body, key) {
774
+ if (!Object.prototype.hasOwnProperty.call(body, key))
775
+ return;
776
+ const value = body[key];
777
+ if (value === undefined)
778
+ return;
779
+ if (value === null) {
780
+ input[key] = '';
781
+ return;
782
+ }
783
+ input[key] = (0, Validation_1.stringValue)(value, key);
784
+ }
785
+ function planArray(value) {
786
+ if (!Array.isArray(value))
787
+ throw core_1.MarifoldError.configInvalid('plan must be an array.');
788
+ return value.map((item, index) => {
789
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
790
+ throw core_1.MarifoldError.configInvalid(`plan[${index}] must be an object.`);
791
+ }
792
+ const step = item;
793
+ return {
794
+ text: (0, Validation_1.requiredString)(step.text, `plan[${index}].text`),
795
+ ...(0, Validation_1.optionalStringField)('id', step.id),
796
+ ...(step.status === undefined ? {} : { status: stepStatus(step.status) }),
797
+ };
798
+ });
799
+ }
800
+ function metadataObject(value) {
801
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
802
+ throw core_1.MarifoldError.configInvalid('metadata must be an object.');
803
+ }
804
+ const metadata = {};
805
+ for (const [key, item] of Object.entries(value)) {
806
+ metadata[key] = (0, Validation_1.stringValue)(item, `metadata.${key}`);
807
+ }
808
+ return metadata;
809
+ }
810
+ function parseLimitQuery(value) {
811
+ if (value === undefined)
812
+ return undefined;
813
+ const parsed = Number.parseInt(value, 10);
814
+ if (!Number.isInteger(parsed) || parsed < 1) {
815
+ throw core_1.MarifoldError.configInvalid('limit must be a positive integer.');
816
+ }
817
+ return parsed;
818
+ }
819
+ function nonNegativeIntegerPath(value, name) {
820
+ const parsed = Number(value);
821
+ if (!Number.isInteger(parsed) || parsed < 0) {
822
+ throw core_1.MarifoldError.configInvalid(`${name} must be a non-negative integer.`);
823
+ }
824
+ return parsed;
825
+ }
826
+ function parseBooleanQuery(value) {
827
+ if (value === undefined)
828
+ return false;
829
+ const normalized = value.trim().toLowerCase();
830
+ if (normalized === 'true' || normalized === '1' || normalized === 'yes')
831
+ return true;
832
+ if (normalized === 'false' || normalized === '0' || normalized === 'no')
833
+ return false;
834
+ throw core_1.MarifoldError.configInvalid('Boolean query values must be true or false.');
835
+ }
836
+ function taskStatus(value) {
837
+ if (value === 'running' || value === 'blocked' || value === 'completed' || value === 'failed' || value === 'cancelled')
838
+ return value;
839
+ throw core_1.MarifoldError.configInvalid(`Invalid task status '${String(value)}'.`);
840
+ }
841
+ function stepStatus(value) {
842
+ if (value === 'pending' || value === 'in_progress' || value === 'completed' || value === 'skipped' || value === 'cancelled')
843
+ return value;
844
+ throw core_1.MarifoldError.configInvalid(`Invalid task step status '${String(value)}'.`);
845
+ }
846
+ function taskEventKind(value) {
847
+ if (value === 'progress' || value === 'decision' || value === 'observation' || value === 'blocker' || value === 'verification' || value === 'note')
848
+ return value;
849
+ throw core_1.MarifoldError.configInvalid(`Invalid task event kind '${String(value)}'.`);
850
+ }
851
+ //# sourceMappingURL=MarifoldService.js.map