@pleri/olam-cli 0.1.185 → 0.1.188

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 (58) hide show
  1. package/dist/ask/knowledge-pack-builder.d.ts.map +1 -1
  2. package/dist/ask/knowledge-pack-builder.js +5 -0
  3. package/dist/ask/knowledge-pack-builder.js.map +1 -1
  4. package/dist/ask/knowledge-pack.generated.d.ts.map +1 -1
  5. package/dist/ask/knowledge-pack.generated.js +406 -22
  6. package/dist/ask/knowledge-pack.generated.js.map +1 -1
  7. package/dist/commands/auth-status.js +2 -2
  8. package/dist/commands/auth-status.js.map +1 -1
  9. package/dist/commands/auth.js +1 -1
  10. package/dist/commands/auth.js.map +1 -1
  11. package/dist/commands/create.d.ts.map +1 -1
  12. package/dist/commands/create.js +4 -0
  13. package/dist/commands/create.js.map +1 -1
  14. package/dist/commands/install.js +2 -2
  15. package/dist/commands/install.js.map +1 -1
  16. package/dist/commands/services.d.ts.map +1 -1
  17. package/dist/commands/services.js +12 -0
  18. package/dist/commands/services.js.map +1 -1
  19. package/dist/commands/setup.js +1 -1
  20. package/dist/commands/setup.js.map +1 -1
  21. package/dist/commands/status.d.ts.map +1 -1
  22. package/dist/commands/status.js +4 -0
  23. package/dist/commands/status.js.map +1 -1
  24. package/dist/image-digests.json +8 -8
  25. package/dist/index.js +788 -368
  26. package/dist/lib/health-probes.d.ts +14 -0
  27. package/dist/lib/health-probes.d.ts.map +1 -1
  28. package/dist/lib/health-probes.js +41 -3
  29. package/dist/lib/health-probes.js.map +1 -1
  30. package/dist/mcp-server.js +95 -27
  31. package/hermes-bundle/version.json +1 -1
  32. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  33. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  34. package/host-cp/k8s/manifests/chunks-electric/10-serviceaccount.yaml +8 -0
  35. package/host-cp/k8s/manifests/chunks-electric/20-rbac.yaml +27 -0
  36. package/host-cp/k8s/manifests/chunks-electric/30-configmap.yaml +23 -0
  37. package/host-cp/k8s/manifests/chunks-electric/45-pvc.yaml +19 -0
  38. package/host-cp/k8s/manifests/chunks-electric/50-deployment.yaml +84 -0
  39. package/host-cp/k8s/manifests/chunks-electric/60-service.yaml +17 -0
  40. package/host-cp/k8s/manifests/chunks-postgres/10-serviceaccount.yaml +8 -0
  41. package/host-cp/k8s/manifests/chunks-postgres/20-rbac.yaml +29 -0
  42. package/host-cp/k8s/manifests/chunks-postgres/30-configmap.yaml +185 -0
  43. package/host-cp/k8s/manifests/chunks-postgres/45-pvc.yaml +24 -0
  44. package/host-cp/k8s/manifests/chunks-postgres/50-deployment.yaml +101 -0
  45. package/host-cp/k8s/manifests/chunks-postgres/60-service.yaml +24 -0
  46. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  47. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  48. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  49. package/host-cp/k8s/manifests/plan-chat-service/10-serviceaccount.yaml +8 -0
  50. package/host-cp/k8s/manifests/plan-chat-service/20-rbac.yaml +29 -0
  51. package/host-cp/k8s/manifests/plan-chat-service/30-configmap.yaml +36 -0
  52. package/host-cp/k8s/manifests/plan-chat-service/45-pvc.yaml +24 -0
  53. package/host-cp/k8s/manifests/plan-chat-service/50-deployment.yaml +135 -0
  54. package/host-cp/k8s/manifests/plan-chat-service/60-service.yaml +17 -0
  55. package/host-cp/src/plan-chat-service.mjs +216 -0
  56. package/host-cp/src/pr-cache.mjs +11 -2
  57. package/host-cp/src/server.mjs +36 -20
  58. package/package.json +1 -1
@@ -219,6 +219,17 @@ function validateChunkInput(body) {
219
219
  return null;
220
220
  }
221
221
 
222
+ // P1 — promote idempotency window. Keyed by chunk_id; value is
223
+ // { worldId, specPath, promoteId, createdAt }. Entries expire after
224
+ // PROMOTE_DEDUPE_TTL_MS so a genuine re-promote (after operator intent)
225
+ // is allowed rather than silently re-routing to the original world.
226
+ // In-memory only: a process restart clears the map. For v1 this is
227
+ // sufficient — the same Linear-retry window (seconds) is far shorter
228
+ // than the TTL (30s). Persistent dedup (across restarts / multi-replica)
229
+ // is deferred to U2.
230
+ const PROMOTE_DEDUPE_TTL_MS = 30_000;
231
+ const _promoteDedupe = new Map(); // chunk_id → { worldId, specPath, promoteId, createdAt }
232
+
222
233
  /**
223
234
  * Build the HTTP request handler. Pure factory — easy to test against a
224
235
  * stubbed pool. Production callers pass a real pg.Pool.
@@ -240,6 +251,17 @@ export function createHandler({
240
251
  shapeDebugLog,
241
252
  createWorld,
242
253
  destroyWorld,
254
+ /**
255
+ * P1 — injectable dispatchTask callback. Accepts { worldId, containerName,
256
+ * task, tier } and dispatches the task to the newly-created world.
257
+ * Production callers wire in autoDispatchTask from @olam/core; tests inject
258
+ * a stub. When omitted, the promote endpoint returns 501.
259
+ *
260
+ * Tier routing: the caller is responsible for reading compute.default
261
+ * from the workspace config and passing it as opts.tier when applicable.
262
+ * This keeps plan-chat-service free from workspace-config coupling.
263
+ */
264
+ dispatchTask,
243
265
  /** B4 — optional override for tests. When supplied, replaces principalFromBearer
244
266
  * so the test harness can inject a hardcoded server-resolved actor_id and verify
245
267
  * the mismatch guard. Production callers omit this. */
@@ -248,6 +270,11 @@ export function createHandler({
248
270
  * default per-bearer rate limiter (60 req/min). Tests inject a stub with
249
271
  * lower capacity to exercise the 429 path quickly. Production callers omit. */
250
272
  rateLimiter,
273
+ /**
274
+ * P1 — override the dedupe map for tests. Allows tests to pre-populate
275
+ * or inspect the idempotency store. Production callers omit this.
276
+ */
277
+ _promoteDedupe: promoteDedupe = _promoteDedupe,
251
278
  }) {
252
279
  if (!pool) throw new Error('createHandler: { pool } required');
253
280
  if (typeof bearer !== 'string' || bearer.length === 0) {
@@ -837,6 +864,181 @@ export function createHandler({
837
864
  }
838
865
  }
839
866
 
867
+ // P1 — POST /v1/prototypes/:id/promote
868
+ //
869
+ // Accepts a prototype chunk_id in the URL path. Validates the chunk exists
870
+ // and is a prototype-shaped tool_use chunk, then dispatches a docker world
871
+ // to implement it. Returns 202 Accepted with { worldId, promoteId, specPath }
872
+ // before the docker world finishes booting (the boot is async).
873
+ //
874
+ // Idempotency: same chunk_id within PROMOTE_DEDUPE_TTL_MS returns
875
+ // 202 { action: "deduplicated", worldId } without creating a second world.
876
+ // Rationale: Linear retries on non-2xx; a 409 would trigger infinite retries.
877
+ // 202 + action body is the correct idempotent-promote semantic.
878
+ //
879
+ // Tier routing: the injected `dispatchTask` callback owns tier selection.
880
+ // The caller (production: WorldManager glue in server.mjs) passes the
881
+ // workspace's `compute.default` tier as part of the dispatchTask closure.
882
+ // This keeps plan-chat-service free from workspace config coupling.
883
+ async function handlePostPrototypePromote(req, res, chunkId) {
884
+ if (!checkAuth(req)) return unauthorized(res);
885
+
886
+ // createWorld + dispatchTask must both be wired. If either is absent,
887
+ // surface a clear 501 rather than crashing (mirrors crystallize guard).
888
+ if (typeof createWorld !== 'function' || typeof dispatchTask !== 'function') {
889
+ return send(res, 501, {
890
+ error: 'promote-not-wired',
891
+ message: 'createWorld and dispatchTask callbacks are required',
892
+ });
893
+ }
894
+
895
+ // Idempotency: check dedupe map before any DB reads.
896
+ const now = Date.now();
897
+ const existing = promoteDedupe.get(chunkId);
898
+ if (existing && now - existing.createdAt < PROMOTE_DEDUPE_TTL_MS) {
899
+ return send(res, 202, {
900
+ action: 'deduplicated',
901
+ worldId: existing.worldId,
902
+ promoteId: existing.promoteId,
903
+ specPath: existing.specPath,
904
+ });
905
+ }
906
+
907
+ // Parse optional body { targetWorldName?, specName? }
908
+ let body = {};
909
+ try {
910
+ body = await readJson(req);
911
+ } catch (err) {
912
+ return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
913
+ }
914
+
915
+ // Look up the chunk by id in the chunks table.
916
+ let chunk;
917
+ try {
918
+ const result = await pool.query(
919
+ `SELECT world_id, session_id, message_id, seq, actor_id, actor_type,
920
+ role, chunk, chunk_type
921
+ FROM chunks WHERE message_id = $1
922
+ LIMIT 1`,
923
+ [chunkId],
924
+ );
925
+ chunk = result.rows[0] ?? null;
926
+ } catch (err) {
927
+ return send(res, 500, {
928
+ error: 'query-failed',
929
+ message: String(err?.message ?? err),
930
+ });
931
+ }
932
+
933
+ if (!chunk) {
934
+ return send(res, 404, {
935
+ error: 'chunk-not-found',
936
+ message: `No chunk found with id '${chunkId}'`,
937
+ });
938
+ }
939
+
940
+ // Validate: must be a tool_use chunk with a prototype-shaped name.
941
+ if (chunk.chunk_type !== 'tool_use') {
942
+ return send(res, 400, {
943
+ error: 'invalid-chunk-type',
944
+ message: `chunk must have chunk_type='tool_use', got '${chunk.chunk_type}'`,
945
+ });
946
+ }
947
+
948
+ let parsedChunk;
949
+ try {
950
+ parsedChunk = JSON.parse(chunk.chunk);
951
+ } catch {
952
+ return send(res, 400, {
953
+ error: 'invalid-chunk-json',
954
+ message: 'chunk content is not valid JSON',
955
+ });
956
+ }
957
+
958
+ const toolName = typeof parsedChunk?.name === 'string' ? parsedChunk.name : '';
959
+ if (!toolName.includes('prototype')) {
960
+ return send(res, 400, {
961
+ error: 'not-a-prototype-chunk',
962
+ message: `chunk tool name '${toolName}' does not match prototype shape (name must include 'prototype')`,
963
+ });
964
+ }
965
+
966
+ // Derive world name and spec path from the chunk's input.
967
+ const input = parsedChunk.input ?? {};
968
+ const rawTitle =
969
+ (typeof body.specName === 'string' && body.specName.length > 0
970
+ ? body.specName
971
+ : typeof input.title === 'string' && input.title.length > 0
972
+ ? input.title
973
+ : 'prototype')
974
+ .toLowerCase()
975
+ .replace(/[^a-z0-9]+/g, '-')
976
+ .replace(/^-+|-+$/g, '')
977
+ .slice(0, 40) || 'prototype';
978
+
979
+ const worldName =
980
+ typeof body.targetWorldName === 'string' && body.targetWorldName.length > 0
981
+ ? body.targetWorldName
982
+ : `proto-${rawTitle}`;
983
+
984
+ const specPath = `design-specs/${rawTitle}-v1.spec.html`;
985
+ const specSource =
986
+ typeof input.source === 'string' ? input.source : JSON.stringify(input);
987
+
988
+ // Build the dispatch prompt: embed the spec source + instruct the agent
989
+ // to write it at specPath on first tool use. Per D3: the agent inside
990
+ // the world writes the file — host-cp does not need docker mount access.
991
+ const dispatchPrompt = [
992
+ `## Prototype implementation task`,
993
+ ``,
994
+ `A prototype has been promoted for implementation. Write the spec file`,
995
+ `at \`${specPath}\` as your first action, then implement it production-ready.`,
996
+ ``,
997
+ `### Spec source (write verbatim to ${specPath})`,
998
+ ``,
999
+ `\`\`\`html`,
1000
+ specSource,
1001
+ `\`\`\``,
1002
+ ``,
1003
+ `After writing the spec file, implement it fully — components, styles,`,
1004
+ `tests, and documentation. The spec is the source of truth.`,
1005
+ `If a file already exists at \`${specPath}\`, surface a conflict chunk`,
1006
+ `to the operator before overwriting (per D4 of the promote endpoint plan).`,
1007
+ ].join('\n');
1008
+
1009
+ // Create the world.
1010
+ let worldId;
1011
+ try {
1012
+ const world = await createWorld({ name: worldName });
1013
+ worldId = world.id;
1014
+ } catch (err) {
1015
+ return send(res, 500, {
1016
+ error: 'world-creation-failed',
1017
+ message: String(err?.message ?? err),
1018
+ });
1019
+ }
1020
+
1021
+ // Dispatch the task to the new world.
1022
+ try {
1023
+ await dispatchTask({ worldId, task: dispatchPrompt });
1024
+ } catch (err) {
1025
+ // The world was created but dispatch failed. Surface 502 so the caller
1026
+ // can retry dispatch manually via `olam dispatch <id> "<task>"`.
1027
+ return send(res, 502, {
1028
+ error: 'dispatch-failed',
1029
+ worldId,
1030
+ message: String(err?.message ?? err),
1031
+ });
1032
+ }
1033
+
1034
+ const promoteId = `promote-${chunkId}-${worldId}`;
1035
+
1036
+ // Record in dedupe map before returning.
1037
+ promoteDedupe.set(chunkId, { worldId, specPath, promoteId, createdAt: now });
1038
+
1039
+ return send(res, 202, { worldId, promoteId, specPath, status: 'dispatched' });
1040
+ }
1041
+
840
1042
  return async function handler(req, res) {
841
1043
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
842
1044
  if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
@@ -859,6 +1061,16 @@ export function createHandler({
859
1061
  if (req.method === 'PATCH') return handlePatchArtifact(req, res, id);
860
1062
  return send(res, 405, { error: 'method-not-allowed' });
861
1063
  }
1064
+
1065
+ // P1 — /v1/prototypes/:id/promote
1066
+ // startsWith + endsWith guard prevents shadowing a future /v1/prototypes
1067
+ // GET list route (per risk note in the plan). The match is exact.
1068
+ const promoteMatch = /^\/v1\/prototypes\/([^/]+)\/promote$/.exec(url.pathname);
1069
+ if (promoteMatch && req.method === 'POST') {
1070
+ const chunkId = decodeURIComponent(promoteMatch[1]);
1071
+ return handlePostPrototypePromote(req, res, chunkId);
1072
+ }
1073
+
862
1074
  return send(res, 404, { error: 'not-found' });
863
1075
  };
864
1076
  }
@@ -889,8 +1101,12 @@ export async function startService(opts = {}) {
889
1101
  electricUrl,
890
1102
  shapeDebug: opts.shapeDebug,
891
1103
  shapeDebugLog: opts.shapeDebugLog,
1104
+ createWorld: opts.createWorld,
1105
+ destroyWorld: opts.destroyWorld,
1106
+ dispatchTask: opts.dispatchTask,
892
1107
  resolveActor: opts.resolveActor,
893
1108
  rateLimiter: opts.rateLimiter,
1109
+ _promoteDedupe: opts._promoteDedupe,
894
1110
  });
895
1111
  const server = http.createServer((req, res) => {
896
1112
  handler(req, res).catch((err) => {
@@ -127,7 +127,7 @@ async function fetchPrData(prUrl, getToken) {
127
127
  /**
128
128
  * Create a PR data cache with TTL and concurrent-fetch coalescing.
129
129
  *
130
- * @returns {{ getPr: (prUrl: string, getToken: () => Promise<string|null>) => Promise<PrData|null> }}
130
+ * @returns {{ getPr: (prUrl: string, getToken: () => Promise<string|null>) => Promise<PrData|null>, deletePr: (prUrl: string) => void }}
131
131
  */
132
132
  export function createPrCache() {
133
133
  /** @type {Map<string, PrCacheEntry>} */
@@ -197,5 +197,14 @@ export function createPrCache() {
197
197
  }
198
198
  }
199
199
 
200
- return { getPr };
200
+ /**
201
+ * Evict a PR entry from the cache (call on world destroy).
202
+ *
203
+ * @param {string} prUrl
204
+ */
205
+ function deletePr(prUrl) {
206
+ cache.delete(prUrl);
207
+ }
208
+
209
+ return { getPr, deletePr };
201
210
  }
@@ -510,6 +510,9 @@ const prPoller = createPrMergePoller({
510
510
  WORLDS = next;
511
511
  persistRegistry();
512
512
  }
513
+ const prState = prStateStore.get(worldId);
514
+ if (prState?.pr_url) prCache.deletePr(prState.pr_url);
515
+ progressCache.delete(worldId);
513
516
  },
514
517
  pollIntervalMs: PR_POLL_INTERVAL_MS,
515
518
  gracePeriodMs: MERGE_GRACE_MS,
@@ -1366,6 +1369,9 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
1366
1369
  WORLDS = next;
1367
1370
  persistRegistry();
1368
1371
  }
1372
+ const prStateForDelete = prStateStore.get(id);
1373
+ if (prStateForDelete?.pr_url) prCache.deletePr(prStateForDelete.pr_url);
1374
+ progressCache.delete(id);
1369
1375
  return jsonReply(res, 200, { worlds: WORLDS });
1370
1376
  }
1371
1377
 
@@ -1526,7 +1532,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
1526
1532
  if (url.pathname === '/api/repos' && req.method === 'GET') {
1527
1533
  const config = loadGlobalConfig();
1528
1534
  if ('error' in config) {
1529
- return jsonReply(res, 500, { error: config.error });
1535
+ return jsonReply(res, 500, { error: config.error, message: config.error });
1530
1536
  }
1531
1537
  return jsonReply(res, 200, { repos: config.repos });
1532
1538
  }
@@ -1536,7 +1542,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
1536
1542
  if (url.pathname === '/api/runbooks' && req.method === 'GET') {
1537
1543
  const config = loadGlobalConfig();
1538
1544
  if ('error' in config) {
1539
- return jsonReply(res, 500, { error: config.error });
1545
+ return jsonReply(res, 500, { error: config.error, message: config.error });
1540
1546
  }
1541
1547
  return jsonReply(res, 200, { runbooks: config.runbooks });
1542
1548
  }
@@ -1679,7 +1685,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
1679
1685
  const body = await readRequestBody(req);
1680
1686
  const provider = body && typeof body === 'object' && body.provider ? String(body.provider) : 'claude';
1681
1687
  const label = body && typeof body === 'object' && body.label ? String(body.label) : '';
1682
- if (!label) return jsonReply(res, 400, { error: 'label_required' });
1688
+ if (!label) return jsonReply(res, 400, { error: 'label_required', message: 'label field is required' });
1683
1689
  const upstream = await authServiceFetch('POST', '/credentials/add', { provider, label });
1684
1690
  const data = await upstream.json();
1685
1691
  return jsonReply(res, upstream.status, data);
@@ -1996,9 +2002,9 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
1996
2002
  req.on('data', (c) => { body += c; });
1997
2003
  req.on('end', () => {
1998
2004
  let parsed;
1999
- try { parsed = JSON.parse(body || '{}'); } catch { return jsonReply(res, 400, { error: 'invalid_json' }); }
2005
+ try { parsed = JSON.parse(body || '{}'); } catch (e) { return jsonReply(res, 400, { error: 'invalid_json', message: e.message }); }
2000
2006
  const { worldId, prUrl, prNumber, prRepo } = parsed ?? {};
2001
- if (!worldId || !prUrl) return jsonReply(res, 400, { error: 'worldId and prUrl required' });
2007
+ if (!worldId || !prUrl) return jsonReply(res, 400, { error: 'missing_fields', message: 'worldId and prUrl required' });
2002
2008
  prStateStore.set(worldId, {
2003
2009
  pr_url: prUrl,
2004
2010
  pr_number: prNumber ?? null,
@@ -2023,9 +2029,9 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2023
2029
  req.on('data', (c) => { body += c; });
2024
2030
  req.on('end', () => {
2025
2031
  let parsed;
2026
- try { parsed = JSON.parse(body || '{}'); } catch { return jsonReply(res, 400, { error: 'invalid_json' }); }
2032
+ try { parsed = JSON.parse(body || '{}'); } catch (e) { return jsonReply(res, 400, { error: 'invalid_json', message: e.message }); }
2027
2033
  const existing = prStateStore.get(worldId);
2028
- if (!existing) return jsonReply(res, 404, { error: 'world not in PR state store' });
2034
+ if (!existing) return jsonReply(res, 404, { error: 'not_found', message: 'world not in PR state store' });
2029
2035
  const updates = {};
2030
2036
  if (typeof parsed.autoDestroyOnMerge === 'boolean') updates.auto_destroy_on_merge = parsed.autoDestroyOnMerge;
2031
2037
  prStateStore.set(worldId, updates);
@@ -2108,7 +2114,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2108
2114
  const id = decodeURIComponent(planConvMatch[1]);
2109
2115
  try {
2110
2116
  const conv = planOrchestrator.getConversation(id);
2111
- if (!conv) return jsonReply(res, 404, { error: 'not_found', id });
2117
+ if (!conv) return jsonReply(res, 404, { error: 'not_found', id, message: 'conversation not found' });
2112
2118
  // Return persisted turns alongside conversation metadata.
2113
2119
  const turns = planOrchestrator.getTurns(id);
2114
2120
  return jsonReply(res, 200, { ...conv, turns });
@@ -2128,7 +2134,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2128
2134
  const content = (body && typeof body === 'object' && typeof body.content === 'string')
2129
2135
  ? body.content.trim()
2130
2136
  : '';
2131
- if (!content) return jsonReply(res, 400, { error: 'content_required' });
2137
+ if (!content) return jsonReply(res, 400, { error: 'content_required', message: 'turn content must be a non-empty string' });
2132
2138
  const personaOverride = (body && typeof body === 'object' && typeof body.personaOverride === 'string')
2133
2139
  ? body.personaOverride
2134
2140
  : undefined;
@@ -2139,7 +2145,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2139
2145
  const result = await planOrchestrator.submitTurn({ conversationId, content, personaOverride, mentionedPersonas });
2140
2146
  return jsonReply(res, 202, result);
2141
2147
  } catch (err) {
2142
- if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId });
2148
+ if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
2143
2149
  return jsonReply(res, 500, { error: 'submit_failed', message: err.message });
2144
2150
  }
2145
2151
  }
@@ -2154,7 +2160,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2154
2160
  }
2155
2161
  const toPersona = body?.toPersona;
2156
2162
  if (typeof toPersona !== 'string' || !toPersona) {
2157
- return jsonReply(res, 400, { error: 'toPersona_required' });
2163
+ return jsonReply(res, 400, { error: 'toPersona_required', message: 'toPersona field is required' });
2158
2164
  }
2159
2165
  const mode = ['full', 'distilled', 'quoted'].includes(body?.mode) ? body.mode : 'full';
2160
2166
  const selectedTurnIds = Array.isArray(body?.selectedTurnIds) ? body.selectedTurnIds : [];
@@ -2164,7 +2170,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2164
2170
  });
2165
2171
  return jsonReply(res, 200, result);
2166
2172
  } catch (err) {
2167
- if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId });
2173
+ if (err.code === 'NOT_FOUND') return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
2168
2174
  return jsonReply(res, 500, { error: 'handoff_failed', message: err.message });
2169
2175
  }
2170
2176
  }
@@ -2174,7 +2180,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2174
2180
  if (!await requirePlanCredential(res)) return;
2175
2181
  const conversationId = decodeURIComponent(planStreamMatch[1]);
2176
2182
  const conv = planOrchestrator.getConversation(conversationId);
2177
- if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId });
2183
+ if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
2178
2184
 
2179
2185
  res.writeHead(200, {
2180
2186
  'Content-Type': 'text/event-stream; charset=utf-8',
@@ -2214,7 +2220,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2214
2220
  const conversationId = decodeURIComponent(planAgentInviteMatch[1]);
2215
2221
  const personaId = decodeURIComponent(planAgentInviteMatch[2]);
2216
2222
  const conv = planOrchestrator.getConversation(conversationId);
2217
- if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId });
2223
+ if (!conv) return jsonReply(res, 404, { error: 'not_found', id: conversationId, message: 'conversation not found' });
2218
2224
  const agent = planOrchestrator.inviteLookout(conversationId, personaId);
2219
2225
  return jsonReply(res, 200, agent);
2220
2226
  }
@@ -2232,7 +2238,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2232
2238
  if (typeof body?.muted === 'boolean') updates.muted = body.muted;
2233
2239
  if (typeof body?.mode === 'string') updates.mode = body.mode;
2234
2240
  const agent = planOrchestrator.updateLookout(conversationId, personaId, updates);
2235
- if (!agent) return jsonReply(res, 404, { error: 'not_found' });
2241
+ if (!agent) return jsonReply(res, 404, { error: 'not_found', message: 'lookout agent not found' });
2236
2242
  return jsonReply(res, 200, agent);
2237
2243
  }
2238
2244
 
@@ -2253,7 +2259,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2253
2259
  const changed = action === 'dismiss'
2254
2260
  ? planOrchestrator.dismissSignal(conversationId, signalId)
2255
2261
  : planOrchestrator.useSignal(conversationId, signalId);
2256
- if (!changed) return jsonReply(res, 404, { error: 'not_found' });
2262
+ if (!changed) return jsonReply(res, 404, { error: 'not_found', message: 'signal not found' });
2257
2263
  return jsonReply(res, 200, { ok: true });
2258
2264
  }
2259
2265
 
@@ -2717,7 +2723,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2717
2723
  if (serversMatch && req.method === 'GET') {
2718
2724
  const worldId = decodeURIComponent(serversMatch[1]);
2719
2725
  if (!(worldId in WORLDS)) {
2720
- return jsonReply(res, 404, { error: 'unknown_world', worldId });
2726
+ return jsonReply(res, 404, { error: 'unknown_world', worldId, message: 'world not in registry' });
2721
2727
  }
2722
2728
  return handleListServers(req, res, worldId);
2723
2729
  }
@@ -2727,7 +2733,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2727
2733
  const worldId = decodeURIComponent(bridgesMatch[1]);
2728
2734
  const portSegment = bridgesMatch[3] ? parseInt(bridgesMatch[3], 10) : null;
2729
2735
  if (!(worldId in WORLDS)) {
2730
- return jsonReply(res, 404, { error: 'unknown_world', worldId });
2736
+ return jsonReply(res, 404, { error: 'unknown_world', worldId, message: 'world not in registry' });
2731
2737
  }
2732
2738
  return handleServerBridges(req, res, worldId, portSegment);
2733
2739
  }
@@ -2798,7 +2804,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2798
2804
  if (v1WorldsStatusMatch && req.method === 'GET') {
2799
2805
  const worldId = decodeURIComponent(v1WorldsStatusMatch[1]);
2800
2806
  if (!V1_WORLD_ID.test(worldId)) {
2801
- return jsonReply(res, 400, { error: 'invalid_world_id', worldId });
2807
+ return jsonReply(res, 400, { error: 'invalid_world_id', worldId, message: 'worldId contains invalid characters' });
2802
2808
  }
2803
2809
  try {
2804
2810
  const report = await hostCpEngine.getWorldStatus(worldId);
@@ -2816,7 +2822,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2816
2822
  if (v1WorldsLogsMatch && req.method === 'GET') {
2817
2823
  const worldId = decodeURIComponent(v1WorldsLogsMatch[1]);
2818
2824
  if (!V1_WORLD_ID.test(worldId)) {
2819
- return jsonReply(res, 400, { error: 'invalid_world_id', worldId });
2825
+ return jsonReply(res, 400, { error: 'invalid_world_id', worldId, message: 'worldId contains invalid characters' });
2820
2826
  }
2821
2827
  const tailParam = url.searchParams.get('tail');
2822
2828
  const followParam = url.searchParams.get('follow');
@@ -3466,6 +3472,7 @@ async function renderSpaShell(filePath, pathname) {
3466
3472
  // block comment above (Phase E5 cutover).
3467
3473
  void skipBootstrap; // wildcard invariant: always true; documents intent
3468
3474
  html = html.replace(/<head>/i, `<head>\n ${bearerInjection}${cloudInjection}`);
3475
+ if (_spaCacheByKey.size > 10) _spaCacheByKey.clear();
3469
3476
  _spaCacheByKey.set(cacheKey, html);
3470
3477
  return html;
3471
3478
  }
@@ -3660,6 +3667,15 @@ server.listen(PORT, '0.0.0.0', () => {
3660
3667
  } else if (AUTH_SERVICE_URL) {
3661
3668
  console.log(` auth-service=${AUTH_SERVICE_URL} (X-Olam-Secret configured)`);
3662
3669
  }
3670
+ // Warn when only one of the two cloud env vars is set — the most common
3671
+ // misconfiguration that silently disables the Cloud toggle in the SPA.
3672
+ const hasCloudUrl = Boolean(process.env.OLAM_CLOUD_URL);
3673
+ const hasShowcasePw = Boolean(process.env.OLAM_SHOWCASE_PASSWORD);
3674
+ if (hasCloudUrl && !hasShowcasePw) {
3675
+ console.warn(' [cloud] OLAM_CLOUD_URL set but OLAM_SHOWCASE_PASSWORD missing — cloud_enabled will be false');
3676
+ } else if (hasShowcasePw && !hasCloudUrl) {
3677
+ console.warn(' [cloud] OLAM_SHOWCASE_PASSWORD set but OLAM_CLOUD_URL missing — cloud_enabled will be false');
3678
+ }
3663
3679
  });
3664
3680
 
3665
3681
  // Graceful shutdown so docker compose down → SIGTERM → flush + close.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.185",
3
+ "version": "0.1.188",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"