@femtomc/mu-server 26.3.1 → 26.3.2

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.
@@ -36,6 +36,39 @@ export async function heartbeatRoutes(request, url, deps, headers) {
36
36
  if ("prompt" in body && typeof body.prompt !== "string" && body.prompt !== null) {
37
37
  return Response.json({ error: "prompt must be string or null" }, { status: 400, headers });
38
38
  }
39
+ const parseNullableString = (value) => {
40
+ if (value === undefined)
41
+ return undefined;
42
+ if (value === null)
43
+ return null;
44
+ if (typeof value === "string")
45
+ return value;
46
+ return undefined;
47
+ };
48
+ const operatorProvider = parseNullableString(body.operator_provider);
49
+ if ("operator_provider" in body && operatorProvider === undefined) {
50
+ return Response.json({ error: "operator_provider must be string or null" }, { status: 400, headers });
51
+ }
52
+ const operatorModel = parseNullableString(body.operator_model);
53
+ if ("operator_model" in body && operatorModel === undefined) {
54
+ return Response.json({ error: "operator_model must be string or null" }, { status: 400, headers });
55
+ }
56
+ const operatorThinking = parseNullableString(body.operator_thinking);
57
+ if ("operator_thinking" in body && operatorThinking === undefined) {
58
+ return Response.json({ error: "operator_thinking must be string or null" }, { status: 400, headers });
59
+ }
60
+ const contextSessionId = parseNullableString(body.context_session_id);
61
+ if ("context_session_id" in body && contextSessionId === undefined) {
62
+ return Response.json({ error: "context_session_id must be string or null" }, { status: 400, headers });
63
+ }
64
+ const contextSessionFile = parseNullableString(body.context_session_file);
65
+ if ("context_session_file" in body && contextSessionFile === undefined) {
66
+ return Response.json({ error: "context_session_file must be string or null" }, { status: 400, headers });
67
+ }
68
+ const contextSessionDir = parseNullableString(body.context_session_dir);
69
+ if ("context_session_dir" in body && contextSessionDir === undefined) {
70
+ return Response.json({ error: "context_session_dir must be string or null" }, { status: 400, headers });
71
+ }
39
72
  const prompt = typeof body.prompt === "string" ? body.prompt : body.prompt === null ? null : undefined;
40
73
  const everyMs = typeof body.every_ms === "number" && Number.isFinite(body.every_ms)
41
74
  ? Math.max(0, Math.trunc(body.every_ms))
@@ -52,6 +85,12 @@ export async function heartbeatRoutes(request, url, deps, headers) {
52
85
  metadata: body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
53
86
  ? body.metadata
54
87
  : undefined,
88
+ operatorProvider,
89
+ operatorModel,
90
+ operatorThinking,
91
+ contextSessionId,
92
+ contextSessionFile,
93
+ contextSessionDir,
55
94
  });
56
95
  return Response.json({ ok: true, program }, { status: 201, headers });
57
96
  }
@@ -75,6 +114,15 @@ export async function heartbeatRoutes(request, url, deps, headers) {
75
114
  return Response.json({ error: "program_id is required" }, { status: 400, headers });
76
115
  }
77
116
  try {
117
+ const parseNullableString = (value) => {
118
+ if (value === undefined)
119
+ return undefined;
120
+ if (value === null)
121
+ return null;
122
+ if (typeof value === "string")
123
+ return value;
124
+ return undefined;
125
+ };
78
126
  const updateOpts = {
79
127
  programId,
80
128
  title: typeof body.title === "string" ? body.title : undefined,
@@ -98,6 +146,48 @@ export async function heartbeatRoutes(request, url, deps, headers) {
98
146
  return Response.json({ error: "prompt must be string or null" }, { status: 400, headers });
99
147
  }
100
148
  }
149
+ if ("operator_provider" in body) {
150
+ const value = parseNullableString(body.operator_provider);
151
+ if (value === undefined) {
152
+ return Response.json({ error: "operator_provider must be string or null" }, { status: 400, headers });
153
+ }
154
+ updateOpts.operatorProvider = value;
155
+ }
156
+ if ("operator_model" in body) {
157
+ const value = parseNullableString(body.operator_model);
158
+ if (value === undefined) {
159
+ return Response.json({ error: "operator_model must be string or null" }, { status: 400, headers });
160
+ }
161
+ updateOpts.operatorModel = value;
162
+ }
163
+ if ("operator_thinking" in body) {
164
+ const value = parseNullableString(body.operator_thinking);
165
+ if (value === undefined) {
166
+ return Response.json({ error: "operator_thinking must be string or null" }, { status: 400, headers });
167
+ }
168
+ updateOpts.operatorThinking = value;
169
+ }
170
+ if ("context_session_id" in body) {
171
+ const value = parseNullableString(body.context_session_id);
172
+ if (value === undefined) {
173
+ return Response.json({ error: "context_session_id must be string or null" }, { status: 400, headers });
174
+ }
175
+ updateOpts.contextSessionId = value;
176
+ }
177
+ if ("context_session_file" in body) {
178
+ const value = parseNullableString(body.context_session_file);
179
+ if (value === undefined) {
180
+ return Response.json({ error: "context_session_file must be string or null" }, { status: 400, headers });
181
+ }
182
+ updateOpts.contextSessionFile = value;
183
+ }
184
+ if ("context_session_dir" in body) {
185
+ const value = parseNullableString(body.context_session_dir);
186
+ if (value === undefined) {
187
+ return Response.json({ error: "context_session_dir must be string or null" }, { status: 400, headers });
188
+ }
189
+ updateOpts.contextSessionDir = value;
190
+ }
101
191
  const result = await deps.heartbeatPrograms.update(updateOpts);
102
192
  if (result.ok) {
103
193
  return Response.json(result, { headers });
@@ -9,6 +9,12 @@ export type HeartbeatProgramSnapshot = {
9
9
  every_ms: number;
10
10
  reason: string;
11
11
  metadata: Record<string, unknown>;
12
+ operator_provider: string | null;
13
+ operator_model: string | null;
14
+ operator_thinking: string | null;
15
+ context_session_id: string | null;
16
+ context_session_file: string | null;
17
+ context_session_dir: string | null;
12
18
  created_at_ms: number;
13
19
  updated_at_ms: number;
14
20
  last_triggered_at_ms: number | null;
@@ -67,6 +73,12 @@ export type HeartbeatProgramRegistryOpts = {
67
73
  prompt: string | null;
68
74
  reason: string;
69
75
  metadata: Record<string, unknown>;
76
+ operatorProvider: string | null;
77
+ operatorModel: string | null;
78
+ operatorThinking: string | null;
79
+ contextSessionId: string | null;
80
+ contextSessionFile: string | null;
81
+ contextSessionDir: string | null;
70
82
  triggeredAtMs: number;
71
83
  }) => Promise<HeartbeatProgramDispatchResult>;
72
84
  onTickEvent?: (event: HeartbeatProgramTickEvent) => void | Promise<void>;
@@ -88,6 +100,12 @@ export declare class HeartbeatProgramRegistry {
88
100
  reason?: string;
89
101
  enabled?: boolean;
90
102
  metadata?: Record<string, unknown>;
103
+ operatorProvider?: string | null;
104
+ operatorModel?: string | null;
105
+ operatorThinking?: string | null;
106
+ contextSessionId?: string | null;
107
+ contextSessionFile?: string | null;
108
+ contextSessionDir?: string | null;
91
109
  }): Promise<HeartbeatProgramSnapshot>;
92
110
  update(opts: {
93
111
  programId: string;
@@ -97,6 +115,12 @@ export declare class HeartbeatProgramRegistry {
97
115
  reason?: string;
98
116
  enabled?: boolean;
99
117
  metadata?: Record<string, unknown>;
118
+ operatorProvider?: string | null;
119
+ operatorModel?: string | null;
120
+ operatorThinking?: string | null;
121
+ contextSessionId?: string | null;
122
+ contextSessionFile?: string | null;
123
+ contextSessionDir?: string | null;
100
124
  }): Promise<HeartbeatProgramOperationResult>;
101
125
  remove(programId: string): Promise<HeartbeatProgramOperationResult>;
102
126
  trigger(opts: {
@@ -19,6 +19,13 @@ function normalizePrompt(value) {
19
19
  }
20
20
  return value;
21
21
  }
22
+ function normalizeOptionalString(value) {
23
+ if (typeof value !== "string") {
24
+ return null;
25
+ }
26
+ const trimmed = value.trim();
27
+ return trimmed.length > 0 ? trimmed : null;
28
+ }
22
29
  function normalizeProgram(row) {
23
30
  if (!row || typeof row !== "object" || Array.isArray(row)) {
24
31
  return null;
@@ -43,7 +50,7 @@ function normalizeProgram(row) {
43
50
  const lastResultRaw = typeof record.last_result === "string" ? record.last_result.trim().toLowerCase() : null;
44
51
  const lastResult = lastResultRaw === "ok" || lastResultRaw === "coalesced" || lastResultRaw === "failed" ? lastResultRaw : null;
45
52
  const reason = typeof record.reason === "string" && record.reason.trim().length > 0 ? record.reason.trim() : "scheduled";
46
- return {
53
+ const normalized = {
47
54
  v: 1,
48
55
  program_id: programId,
49
56
  title,
@@ -52,12 +59,29 @@ function normalizeProgram(row) {
52
59
  every_ms: everyMs,
53
60
  reason,
54
61
  metadata: sanitizeMetadata(record.metadata),
62
+ operator_provider: normalizeOptionalString(record.operator_provider),
63
+ operator_model: normalizeOptionalString(record.operator_model),
64
+ operator_thinking: normalizeOptionalString(record.operator_thinking),
65
+ context_session_id: normalizeOptionalString(record.context_session_id),
66
+ context_session_file: normalizeOptionalString(record.context_session_file),
67
+ context_session_dir: normalizeOptionalString(record.context_session_dir),
55
68
  created_at_ms: createdAt,
56
69
  updated_at_ms: updatedAt,
57
70
  last_triggered_at_ms: lastTriggeredAt,
58
71
  last_result: lastResult,
59
72
  last_error: typeof record.last_error === "string" ? record.last_error : null,
60
73
  };
74
+ const hasProvider = Boolean(normalized.operator_provider);
75
+ const hasModel = Boolean(normalized.operator_model);
76
+ if (hasProvider !== hasModel) {
77
+ normalized.operator_provider = null;
78
+ normalized.operator_model = null;
79
+ normalized.operator_thinking = null;
80
+ }
81
+ else if (!hasProvider) {
82
+ normalized.operator_thinking = null;
83
+ }
84
+ return normalized;
61
85
  }
62
86
  function sortPrograms(programs) {
63
87
  return [...programs].sort((a, b) => {
@@ -67,6 +91,16 @@ function sortPrograms(programs) {
67
91
  return a.program_id.localeCompare(b.program_id);
68
92
  });
69
93
  }
94
+ function validateOperatorRouting(program) {
95
+ const hasProvider = typeof program.operator_provider === "string" && program.operator_provider.length > 0;
96
+ const hasModel = typeof program.operator_model === "string" && program.operator_model.length > 0;
97
+ if (hasProvider !== hasModel) {
98
+ throw new Error("heartbeat_program_operator_provider_model_pair_required");
99
+ }
100
+ if (program.operator_thinking && !hasProvider) {
101
+ throw new Error("heartbeat_program_operator_thinking_requires_provider_model");
102
+ }
103
+ }
70
104
  export class HeartbeatProgramRegistry {
71
105
  #store;
72
106
  #heartbeatScheduler;
@@ -196,6 +230,12 @@ export class HeartbeatProgramRegistry {
196
230
  prompt: program.prompt,
197
231
  reason: heartbeatReason,
198
232
  metadata: { ...program.metadata },
233
+ operatorProvider: program.operator_provider,
234
+ operatorModel: program.operator_model,
235
+ operatorThinking: program.operator_thinking,
236
+ contextSessionId: program.context_session_id,
237
+ contextSessionFile: program.context_session_file,
238
+ contextSessionDir: program.context_session_dir,
199
239
  triggeredAtMs: nowMs,
200
240
  });
201
241
  if (result.status === "ok") {
@@ -297,12 +337,19 @@ export class HeartbeatProgramRegistry {
297
337
  every_ms: this.#normalizeEveryMs(typeof opts.everyMs === "number" && Number.isFinite(opts.everyMs) ? opts.everyMs : 15_000),
298
338
  reason: opts.reason?.trim() || "scheduled",
299
339
  metadata: sanitizeMetadata(opts.metadata),
340
+ operator_provider: normalizeOptionalString(opts.operatorProvider),
341
+ operator_model: normalizeOptionalString(opts.operatorModel),
342
+ operator_thinking: normalizeOptionalString(opts.operatorThinking),
343
+ context_session_id: normalizeOptionalString(opts.contextSessionId),
344
+ context_session_file: normalizeOptionalString(opts.contextSessionFile),
345
+ context_session_dir: normalizeOptionalString(opts.contextSessionDir),
300
346
  created_at_ms: nowMs,
301
347
  updated_at_ms: nowMs,
302
348
  last_triggered_at_ms: null,
303
349
  last_result: null,
304
350
  last_error: null,
305
351
  };
352
+ validateOperatorRouting(program);
306
353
  this.#programs.set(program.program_id, program);
307
354
  this.#applySchedule(program);
308
355
  await this.#persist();
@@ -346,6 +393,25 @@ export class HeartbeatProgramRegistry {
346
393
  if (opts.metadata) {
347
394
  program.metadata = sanitizeMetadata(opts.metadata);
348
395
  }
396
+ if ("operatorProvider" in opts) {
397
+ program.operator_provider = normalizeOptionalString(opts.operatorProvider);
398
+ }
399
+ if ("operatorModel" in opts) {
400
+ program.operator_model = normalizeOptionalString(opts.operatorModel);
401
+ }
402
+ if ("operatorThinking" in opts) {
403
+ program.operator_thinking = normalizeOptionalString(opts.operatorThinking);
404
+ }
405
+ if ("contextSessionId" in opts) {
406
+ program.context_session_id = normalizeOptionalString(opts.contextSessionId);
407
+ }
408
+ if ("contextSessionFile" in opts) {
409
+ program.context_session_file = normalizeOptionalString(opts.contextSessionFile);
410
+ }
411
+ if ("contextSessionDir" in opts) {
412
+ program.context_session_dir = normalizeOptionalString(opts.contextSessionDir);
413
+ }
414
+ validateOperatorRouting(program);
349
415
  const nowMs = Math.trunc(this.#nowMs());
350
416
  program.updated_at_ms = nowMs;
351
417
  this.#applySchedule(program);
package/dist/server.js CHANGED
@@ -209,16 +209,37 @@ function createServer(options = {}) {
209
209
  message: opts.message,
210
210
  payload: opts.payload,
211
211
  });
212
+ const operatorProvider = stringField(opts.payload, "operator_provider");
213
+ const operatorModel = stringField(opts.payload, "operator_model");
214
+ const operatorThinking = stringField(opts.payload, "operator_thinking");
215
+ const contextSessionId = stringField(opts.payload, "context_session_id");
216
+ const contextSessionFile = stringField(opts.payload, "context_session_file");
217
+ const contextSessionDir = stringField(opts.payload, "context_session_dir");
218
+ const defaultHeartbeatSessionId = wakeSource === "heartbeat_program" && programId ? `heartbeat-program:${programId}` : null;
219
+ const operatorSessionId = contextSessionId ?? defaultHeartbeatSessionId;
220
+ const autonomousMetadata = {
221
+ wake_id: wakeId,
222
+ wake_source: wakeSource,
223
+ program_id: programId,
224
+ source_ts_ms: sourceTsMs,
225
+ };
226
+ if (operatorProvider)
227
+ autonomousMetadata.operator_provider = operatorProvider;
228
+ if (operatorModel)
229
+ autonomousMetadata.operator_model = operatorModel;
230
+ if (operatorThinking)
231
+ autonomousMetadata.operator_thinking = operatorThinking;
232
+ if (operatorSessionId)
233
+ autonomousMetadata.operator_session_id = operatorSessionId;
234
+ if (contextSessionFile)
235
+ autonomousMetadata.operator_session_file = contextSessionFile;
236
+ if (contextSessionDir)
237
+ autonomousMetadata.operator_session_dir = contextSessionDir;
212
238
  const turnResult = await autonomousIngress({
213
239
  text: ingressText,
214
240
  repoRoot: context.repoRoot,
215
241
  requestId: turnRequestId,
216
- metadata: {
217
- wake_id: wakeId,
218
- wake_source: wakeSource,
219
- program_id: programId,
220
- source_ts_ms: sourceTsMs,
221
- },
242
+ metadata: autonomousMetadata,
222
243
  });
223
244
  if (turnResult.kind === "noop" || turnResult.kind === "invalid") {
224
245
  decision = {
@@ -12,18 +12,31 @@ export function createServerProgramCoordination(opts) {
12
12
  heartbeatScheduler: opts.heartbeatScheduler,
13
13
  dispatchWake: async (wakeOpts) => {
14
14
  const prompt = wakeOpts.prompt && wakeOpts.prompt.trim().length > 0 ? wakeOpts.prompt : null;
15
+ const payload = {
16
+ wake_source: "heartbeat_program",
17
+ source_ts_ms: wakeOpts.triggeredAtMs,
18
+ program_id: wakeOpts.programId,
19
+ title: wakeOpts.title,
20
+ prompt,
21
+ reason: wakeOpts.reason,
22
+ metadata: wakeOpts.metadata,
23
+ };
24
+ if (wakeOpts.operatorProvider)
25
+ payload.operator_provider = wakeOpts.operatorProvider;
26
+ if (wakeOpts.operatorModel)
27
+ payload.operator_model = wakeOpts.operatorModel;
28
+ if (wakeOpts.operatorThinking)
29
+ payload.operator_thinking = wakeOpts.operatorThinking;
30
+ if (wakeOpts.contextSessionId)
31
+ payload.context_session_id = wakeOpts.contextSessionId;
32
+ if (wakeOpts.contextSessionFile)
33
+ payload.context_session_file = wakeOpts.contextSessionFile;
34
+ if (wakeOpts.contextSessionDir)
35
+ payload.context_session_dir = wakeOpts.contextSessionDir;
15
36
  const wakeResult = await opts.emitOperatorWake({
16
37
  dedupeKey: `heartbeat-program:${wakeOpts.programId}`,
17
38
  message: prompt ?? `Heartbeat wake: ${wakeOpts.title}`,
18
- payload: {
19
- wake_source: "heartbeat_program",
20
- source_ts_ms: wakeOpts.triggeredAtMs,
21
- program_id: wakeOpts.programId,
22
- title: wakeOpts.title,
23
- prompt,
24
- reason: wakeOpts.reason,
25
- metadata: wakeOpts.metadata,
26
- },
39
+ payload,
27
40
  });
28
41
  if (wakeResult.status === "coalesced") {
29
42
  return { status: "coalesced", reason: wakeResult.reason };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@femtomc/mu-server",
3
- "version": "26.3.1",
3
+ "version": "26.3.2",
4
4
  "description": "HTTP API server for mu control-plane transport/session plus run/activity scheduling coordination.",
5
5
  "keywords": [
6
6
  "mu",
@@ -30,8 +30,8 @@
30
30
  "start": "bun run dist/cli.js"
31
31
  },
32
32
  "dependencies": {
33
- "@femtomc/mu-agent": "26.3.1",
34
- "@femtomc/mu-control-plane": "26.3.1",
35
- "@femtomc/mu-core": "26.3.1"
33
+ "@femtomc/mu-agent": "26.3.2",
34
+ "@femtomc/mu-control-plane": "26.3.2",
35
+ "@femtomc/mu-core": "26.3.2"
36
36
  }
37
37
  }