@pleri/olam-cli 0.1.182 → 0.1.185

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 (117) hide show
  1. package/dist/agent-stream/agent-sdk-to-chunks.js +44 -30
  2. package/dist/ask/checkout.d.ts +19 -0
  3. package/dist/ask/checkout.d.ts.map +1 -0
  4. package/dist/ask/checkout.js +40 -0
  5. package/dist/ask/checkout.js.map +1 -0
  6. package/dist/ask/knowledge-pack-builder.d.ts +72 -0
  7. package/dist/ask/knowledge-pack-builder.d.ts.map +1 -0
  8. package/dist/ask/knowledge-pack-builder.js +91 -0
  9. package/dist/ask/knowledge-pack-builder.js.map +1 -0
  10. package/dist/ask/knowledge-pack.generated.d.ts +8 -0
  11. package/dist/ask/knowledge-pack.generated.d.ts.map +1 -0
  12. package/dist/ask/knowledge-pack.generated.js +1947 -0
  13. package/dist/ask/knowledge-pack.generated.js.map +1 -0
  14. package/dist/ask/one-shot.d.ts +21 -0
  15. package/dist/ask/one-shot.d.ts.map +1 -0
  16. package/dist/ask/one-shot.js +50 -0
  17. package/dist/ask/one-shot.js.map +1 -0
  18. package/dist/ask/repl.d.ts +30 -0
  19. package/dist/ask/repl.d.ts.map +1 -0
  20. package/dist/ask/repl.js +109 -0
  21. package/dist/ask/repl.js.map +1 -0
  22. package/dist/ask/sdk-client.d.ts +87 -0
  23. package/dist/ask/sdk-client.d.ts.map +1 -0
  24. package/dist/ask/sdk-client.js +118 -0
  25. package/dist/ask/sdk-client.js.map +1 -0
  26. package/dist/ask/system-prompt.d.ts +30 -0
  27. package/dist/ask/system-prompt.d.ts.map +1 -0
  28. package/dist/ask/system-prompt.js +31 -0
  29. package/dist/ask/system-prompt.js.map +1 -0
  30. package/dist/commands/ask.d.ts +27 -0
  31. package/dist/commands/ask.d.ts.map +1 -0
  32. package/dist/commands/ask.js +63 -0
  33. package/dist/commands/ask.js.map +1 -0
  34. package/dist/commands/auth-list-json.d.ts +53 -0
  35. package/dist/commands/auth-list-json.d.ts.map +1 -0
  36. package/dist/commands/auth-list-json.js +47 -0
  37. package/dist/commands/auth-list-json.js.map +1 -0
  38. package/dist/commands/auth.d.ts.map +1 -1
  39. package/dist/commands/auth.js +13 -0
  40. package/dist/commands/auth.js.map +1 -1
  41. package/dist/commands/doctor.js +11 -11
  42. package/dist/commands/doctor.js.map +1 -1
  43. package/dist/commands/keys-list-json.d.ts +55 -0
  44. package/dist/commands/keys-list-json.d.ts.map +1 -0
  45. package/dist/commands/keys-list-json.js +54 -0
  46. package/dist/commands/keys-list-json.js.map +1 -0
  47. package/dist/commands/keys.d.ts.map +1 -1
  48. package/dist/commands/keys.js +6 -0
  49. package/dist/commands/keys.js.map +1 -1
  50. package/dist/commands/lanes-list-json.d.ts +69 -0
  51. package/dist/commands/lanes-list-json.d.ts.map +1 -0
  52. package/dist/commands/lanes-list-json.js +42 -0
  53. package/dist/commands/lanes-list-json.js.map +1 -0
  54. package/dist/commands/lanes.d.ts.map +1 -1
  55. package/dist/commands/lanes.js +18 -7
  56. package/dist/commands/lanes.js.map +1 -1
  57. package/dist/commands/plans-list-json.d.ts +77 -0
  58. package/dist/commands/plans-list-json.d.ts.map +1 -0
  59. package/dist/commands/plans-list-json.js +61 -0
  60. package/dist/commands/plans-list-json.js.map +1 -0
  61. package/dist/commands/plans.d.ts.map +1 -1
  62. package/dist/commands/plans.js +10 -0
  63. package/dist/commands/plans.js.map +1 -1
  64. package/dist/commands/repos-list-json.d.ts +58 -0
  65. package/dist/commands/repos-list-json.d.ts.map +1 -0
  66. package/dist/commands/repos-list-json.js +45 -0
  67. package/dist/commands/repos-list-json.js.map +1 -0
  68. package/dist/commands/repos.d.ts +1 -1
  69. package/dist/commands/repos.d.ts.map +1 -1
  70. package/dist/commands/repos.js +12 -2
  71. package/dist/commands/repos.js.map +1 -1
  72. package/dist/commands/services.d.ts +47 -1
  73. package/dist/commands/services.d.ts.map +1 -1
  74. package/dist/commands/services.js +59 -33
  75. package/dist/commands/services.js.map +1 -1
  76. package/dist/commands/skills.d.ts +27 -0
  77. package/dist/commands/skills.d.ts.map +1 -1
  78. package/dist/commands/skills.js +17 -2
  79. package/dist/commands/skills.js.map +1 -1
  80. package/dist/commands/workspace-list-json.d.ts +73 -0
  81. package/dist/commands/workspace-list-json.d.ts.map +1 -0
  82. package/dist/commands/workspace-list-json.js +59 -0
  83. package/dist/commands/workspace-list-json.js.map +1 -0
  84. package/dist/commands/workspace.d.ts.map +1 -1
  85. package/dist/commands/workspace.js +7 -1
  86. package/dist/commands/workspace.js.map +1 -1
  87. package/dist/image-digests.json +8 -8
  88. package/dist/index.js +3170 -580
  89. package/dist/index.js.map +1 -1
  90. package/dist/lib/k8s-bootstrap.d.ts.map +1 -1
  91. package/dist/lib/k8s-bootstrap.js +13 -1
  92. package/dist/lib/k8s-bootstrap.js.map +1 -1
  93. package/dist/lib/k8s-secret-render.d.ts +2 -0
  94. package/dist/lib/k8s-secret-render.d.ts.map +1 -1
  95. package/dist/lib/k8s-secret-render.js +27 -0
  96. package/dist/lib/k8s-secret-render.js.map +1 -1
  97. package/dist/lib/peripheral-registry.d.ts +1 -1
  98. package/dist/lib/peripheral-registry.d.ts.map +1 -1
  99. package/dist/lib/peripheral-registry.js +13 -0
  100. package/dist/lib/peripheral-registry.js.map +1 -1
  101. package/dist/lib/upgrade-kubernetes.d.ts +6 -0
  102. package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
  103. package/dist/lib/upgrade-kubernetes.js +7 -1
  104. package/dist/lib/upgrade-kubernetes.js.map +1 -1
  105. package/dist/mcp-server.js +1167 -37
  106. package/hermes-bundle/version.json +1 -1
  107. package/host-cp/k8s/manifests/30-configmap.yaml +11 -6
  108. package/host-cp/k8s/manifests/50-deployment.yaml +15 -1
  109. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  110. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  111. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  112. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  113. package/host-cp/k8s/templates/chunks-postgres-secret-template.yaml +24 -0
  114. package/host-cp/k8s/templates/plan-chat-service-secret-template.yaml +35 -0
  115. package/host-cp/src/plan-chat-service.mjs +99 -74
  116. package/host-cp/src/server.mjs +141 -5
  117. package/package.json +4 -2
@@ -1,4 +1,4 @@
1
1
  {
2
- "bundledAt": "2026-05-26T05:28:12.384Z",
2
+ "bundledAt": "2026-05-27T14:19:47.128Z",
3
3
  "kgFirstSha": "29a9ccce1b115d049e375c4a90eb5cf7c123e610e2d0590270a4db2cdbc64a28"
4
4
  }
@@ -24,12 +24,17 @@ data:
24
24
  OLAM_WORLDS_DB: "/data/worlds.db"
25
25
  OLAM_PLAN_DB_PATH: "/data/plan.db"
26
26
  OLAM_PLAN_DIR: "/data/plan"
27
- # Same /data routing for the plan-chat bearer gateway. K8s pod runs as
28
- # UID 1000 with readOnlyRootFilesystem: true, so the bearer file CAN
29
- # NEVER be created under /home/node/.olam even with mkdir-recursive
30
- # the env override is mandatory here, not optional. Bind-mounted /data
31
- # is the writable PVC.
32
- OLAM_PLAN_CHAT_SECRET_PATH: "/data/plan-chat-secret"
27
+ # Phase B Model B: bearer file is now sourced from the shared
28
+ # olam-plan-chat-secret Kubernetes Secret (mounted at /etc/olam-plan-chat/).
29
+ # Two readers, one source-of-truth replaces the per-pod /data/plan-chat-secret
30
+ # file that couldn't be shared across pods on RWO PVCs. The plan-chat-service
31
+ # pod also mounts the SAME Secret at the SAME path so bearer comparisons
32
+ # work both ways.
33
+ OLAM_PLAN_CHAT_SECRET_PATH: "/etc/olam-plan-chat/secret"
34
+ # In-cluster plan-chat-service URL. Rewritten by upgrade-kubernetes.ts step 2.5
35
+ # (buildK8sDnsUrl) — the default below is a sane fallback for raw
36
+ # `kubectl apply -f` operators who skip the CLI wrapper.
37
+ PLAN_CHAT_SERVICE_URL: "http://olam-plan-chat-service.olam.svc.cluster.local:3200"
33
38
  # NDJSON span sink + recovery ledger — route to the writable PVC mount at
34
39
  # /data rather than the default ~/.olam/logs (which resolves to
35
40
  # /home/node/.olam/logs and is not writable with readOnlyRootFilesystem: true).
@@ -118,7 +118,7 @@ spec:
118
118
  # k3d), started by `olam upgrade` Step 0.7 — not inside this Pod.
119
119
  containers:
120
120
  - name: olam-host-cp
121
- image: ghcr.io/pleri/olam-host-cp@sha256:20d84b6d490c633bc5a158b0f7f849152aba3cf1d2d45657360f627d8d41ec3f
121
+ image: ghcr.io/pleri/olam-host-cp@sha256:843bea5159a7b5ec792a7d0a9766c49eb0443180023526c0c21d56e32982fd4e
122
122
  imagePullPolicy: IfNotPresent
123
123
  securityContext:
124
124
  runAsNonRoot: true
@@ -147,6 +147,13 @@ spec:
147
147
  readOnly: true
148
148
  - name: tmp
149
149
  mountPath: /tmp
150
+ # Phase B Model B: shared olam-plan-chat-secret mounted read-only
151
+ # so renderSpaShell can inject window.__OLAM_PLAN_CHAT_BEARER__.
152
+ # Plan-chat-service mounts the SAME Secret at the SAME path so
153
+ # bearer compares match across pods.
154
+ - name: plan-chat-secret
155
+ mountPath: /etc/olam-plan-chat
156
+ readOnly: true
150
157
  # docker-socket volumeMount REMOVED in olam-k3d-on-mac-substrate-
151
158
  # decision Phase B B2. Docker access now goes via TCP to the
152
159
  # docker-socket-proxy ExternalName Service in the olam namespace.
@@ -191,6 +198,13 @@ spec:
191
198
  type: DirectoryOrCreate
192
199
  - name: tmp
193
200
  emptyDir: {}
201
+ - name: plan-chat-secret
202
+ secret:
203
+ secretName: olam-plan-chat-secret
204
+ defaultMode: 0400
205
+ items:
206
+ - key: PLAN_CHAT_SECRET
207
+ path: secret
194
208
  # host-colima + docker-socket volumes REMOVED in olam-k3d-on-mac-
195
209
  # substrate-decision Phase B B2 (2026-05-21). R3-A's two-volume
196
210
  # hostPath approach is fully retracted: round-4 R4-W2-F demonstrated
@@ -70,7 +70,7 @@ spec:
70
70
  mountPath: /data
71
71
  containers:
72
72
  - name: olam-auth-service
73
- image: ghcr.io/pleri/olam-auth@sha256:57d5f69395f9d4199035bb1e8156ffa1a4b815e50f6ecef28c2fc082f4d40e23
73
+ image: ghcr.io/pleri/olam-auth@sha256:59de0618b656c45ed75465aefab637bd3b50ef803ae8d802c828c26d97183328
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -61,7 +61,7 @@ spec:
61
61
  mountPath: /data
62
62
  containers:
63
63
  - name: olam-kg-service
64
- image: ghcr.io/pleri/olam-kg-service@sha256:7b48bc20dead674fb01f5e01f1fc43fee2b589c05156a7b5384504f42e96a18a
64
+ image: ghcr.io/pleri/olam-kg-service@sha256:b53af63d452bb04420a1f89b5834888a324ee3995f45c908fa9321ec76b84047
65
65
  imagePullPolicy: IfNotPresent
66
66
  securityContext:
67
67
  runAsNonRoot: true
@@ -68,7 +68,7 @@ spec:
68
68
  mountPath: /data
69
69
  containers:
70
70
  - name: olam-mcp-auth-service
71
- image: ghcr.io/pleri/olam-mcp-auth@sha256:9cbf2fb4c79a6b447282da5f1a190c730eb9c85e5df1c0c16dc238c34352c583
71
+ image: ghcr.io/pleri/olam-mcp-auth@sha256:635c1518e45cc0a1b5859cfb412d5a6eb9ab389630553eb9cdb6c903d97d8d15
72
72
  imagePullPolicy: IfNotPresent
73
73
  securityContext:
74
74
  runAsNonRoot: true
@@ -70,7 +70,7 @@ spec:
70
70
  # bootstrap-placeholder comment + run `npm run refresh:manifest-digests`
71
71
  # once ghcr.io/pleri/olam-memory-service has a real published digest.
72
72
  # bootstrap-placeholder: pre-publish; refresh after first release
73
- image: ghcr.io/pleri/olam-memory-service@sha256:d94c6699ca3937f67a873b2b6bb28a1d40317f9c0a780f780f45b41c5d103f2d
73
+ image: ghcr.io/pleri/olam-memory-service@sha256:efad791c760420e6bccc68120621aba97a2532da517eb3f7d0e0349f6a2f8a06
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -0,0 +1,24 @@
1
+ # Secret TEMPLATE for olam-chunks-postgres.
2
+ #
3
+ # Generates a random 64-char hex POSTGRES_PASSWORD on first apply (via
4
+ # k8s-secret-render.ts generate-if-missing). The Secret is consumed by:
5
+ # - chunks-postgres StatefulSet (envFrom → POSTGRES_PASSWORD)
6
+ # - chunks-electric Deployment (env: valueFrom.secretKeyRef)
7
+ # - plan-chat-service Deployment (env: valueFrom.secretKeyRef)
8
+ #
9
+ # All three resolve the SAME random value because the secret-renderer
10
+ # persists generated values in ~/.olam/k8s-secrets-state.json so reapply
11
+ # is idempotent (no rotation unless --rotate-secrets).
12
+ apiVersion: v1
13
+ kind: Secret
14
+ metadata:
15
+ name: olam-chunks-postgres-secret
16
+ namespace: olam
17
+ labels:
18
+ app: olam-chunks-postgres
19
+ olam.io/component: substrate
20
+ type: Opaque
21
+ stringData:
22
+ # Postgres superuser password. Generated by the CLI's secret-renderer on
23
+ # first apply (no host-side file to read; this is in-cluster-only state).
24
+ POSTGRES_PASSWORD: "REPLACE_ME_GENERATE_RANDOM_HEX"
@@ -0,0 +1,35 @@
1
+ # Secret TEMPLATE for olam-plan-chat-secret.
2
+ #
3
+ # This file is a TEMPLATE — it MUST NOT be applied directly without substituting
4
+ # the placeholder values. The placeholders are intentionally invalid; a raw
5
+ # `kubectl apply` will result in auth failures rather than silently shipping
6
+ # fake credentials.
7
+ #
8
+ # Preferred substitution (keeps secrets out of git):
9
+ # kubectl create secret generic olam-plan-chat-secret -n olam \
10
+ # --from-literal=PLAN_CHAT_SECRET=$(cat ~/.olam/plan-chat-secret) \
11
+ # --dry-run=client -o yaml | kubectl apply -f -
12
+ #
13
+ # This template lives in packages/host-cp/k8s/templates/ (NOT manifests/)
14
+ # so that `kubectl apply -f manifests/plan-chat-service/` does NOT apply it —
15
+ # operators must explicitly handle Secret provisioning before applying manifests.
16
+ #
17
+ # Architecture: this Secret is mounted by BOTH the host-cp pod (so its
18
+ # renderSpaShell can inject window.__OLAM_PLAN_CHAT_BEARER__) AND the
19
+ # plan-chat-service pod (so its bearer-auth gate timing-safe-compares incoming
20
+ # Authorization: Bearer headers against the same value). One source-of-truth,
21
+ # two readers — replaces the previous "/data/plan-chat-secret in host-cp PVC"
22
+ # pattern that couldn't be shared across pods (RWO PVC).
23
+ apiVersion: v1
24
+ kind: Secret
25
+ metadata:
26
+ name: olam-plan-chat-secret
27
+ namespace: olam
28
+ labels:
29
+ olam.io/component: substrate
30
+ type: Opaque
31
+ stringData:
32
+ # Shared bearer secret for plan-chat-service's POST /v1/chunks and
33
+ # GET /v1/shape endpoints. host-cp injects this into window.__OLAM_PLAN_CHAT_BEARER__.
34
+ # Source: cat ~/.olam/plan-chat-secret
35
+ PLAN_CHAT_SECRET: "REPLACE_ME_FROM_HOME_DOTOLAM_PLAN_CHAT_SECRET"
@@ -327,88 +327,113 @@ export function createHandler({
327
327
 
328
328
  // B4 — capture message_usage when the adapter forwarded token usage.
329
329
  // body.usage is optional (only present on assistant turns with SDK usage).
330
+ //
331
+ // perf: SQL-2 through SQL-5 (INSERT message_usage, SUM, dedup check,
332
+ // INSERT threshold chunk) are batched inside a single BEGIN/COMMIT so
333
+ // the 4 sequential round-trips share one connection checkout and one
334
+ // server-side transaction flush instead of 4 independent pool checkouts.
335
+ // SQL-1 (INSERT chunks above) stays outside the txn so its 23505 dedup
336
+ // guard reaches the caller independently as a 409 — folding it in would
337
+ // cause a threshold-chunk PK conflict to silently roll back the primary
338
+ // chunk write.
330
339
  if (body.usage && typeof body.usage === 'object') {
331
340
  const usage = body.usage;
332
341
  const model = typeof body.model === 'string' && body.model.length > 0
333
342
  ? body.model
334
343
  : 'claude-sonnet-4-6';
335
- await pool.query(
336
- `INSERT INTO message_usage
337
- (world_id, session_id, message_id, actor_id, model,
338
- input_tokens, output_tokens, cache_read_tokens, cache_create_tokens)
339
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
340
- ON CONFLICT (message_id, actor_id) DO NOTHING`,
341
- [
342
- body.world_id,
343
- body.session_id,
344
- body.message_id,
345
- principal.actorId,
346
- model,
347
- Number(usage.input_tokens ?? 0),
348
- Number(usage.output_tokens ?? 0),
349
- Number(usage.cache_read_input_tokens ?? 0),
350
- Number(usage.cache_creation_input_tokens ?? 0),
351
- ],
352
- );
353
-
354
- // B6 — 80% threshold dedup'd system chunk.
355
- // After inserting message_usage, check if the per-actor cumulative
356
- // token count for this session has crossed 80% of the model's context
357
- // cap. If so, and no prior threshold chunk exists for (session_id,
358
- // actor_id), emit exactly one dedup'd system chunk.
359
- //
360
- // Token math: sum(input_tokens + cache_read_tokens + cache_create_tokens)
361
- // per (session_id, actor_id), matching Claude Code statusline.
362
- const contextCap = CONTEXT_CAPS_LOCAL[model] ?? DEFAULT_CONTEXT_CAP;
363
- const sumResult = await pool.query(
364
- `SELECT COALESCE(SUM(input_tokens + cache_read_tokens + cache_create_tokens), 0) AS total_used
365
- FROM message_usage
366
- WHERE session_id = $1 AND actor_id = $2`,
367
- [body.session_id, principal.actorId],
368
- );
369
- const totalUsed = Number(sumResult.rows[0]?.total_used ?? 0);
370
- const usedPct = Math.floor((totalUsed / contextCap) * 100);
371
-
372
- if (usedPct >= THRESHOLD_PCT) {
373
- // Dedup check: scan chunks for an existing threshold-crossing system
374
- // chunk for this (session_id, actor_id). Prefix-match is sufficient
375
- // because THRESHOLD_CHUNK_PREFIX is unique to this purpose.
376
- const dupResult = await pool.query(
377
- `SELECT 1 FROM chunks
378
- WHERE session_id = $1 AND actor_id = $2 AND actor_type = 'system'
379
- AND chunk LIKE $3
380
- LIMIT 1`,
381
- [body.session_id, principal.actorId, `${THRESHOLD_CHUNK_PREFIX}%`],
344
+ const client = await pool.connect();
345
+ try {
346
+ await client.query('BEGIN');
347
+
348
+ // SQL-2 INSERT message_usage
349
+ await client.query(
350
+ `INSERT INTO message_usage
351
+ (world_id, session_id, message_id, actor_id, model,
352
+ input_tokens, output_tokens, cache_read_tokens, cache_create_tokens)
353
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
354
+ ON CONFLICT (message_id, actor_id) DO NOTHING`,
355
+ [
356
+ body.world_id,
357
+ body.session_id,
358
+ body.message_id,
359
+ principal.actorId,
360
+ model,
361
+ Number(usage.input_tokens ?? 0),
362
+ Number(usage.output_tokens ?? 0),
363
+ Number(usage.cache_read_input_tokens ?? 0),
364
+ Number(usage.cache_creation_input_tokens ?? 0),
365
+ ],
382
366
  );
383
- const alreadyEmitted = (dupResult.rows ?? []).length > 0;
384
-
385
- if (!alreadyEmitted) {
386
- const personaLabel = principal.actorId;
387
- const thresholdChunk =
388
- `${THRESHOLD_CHUNK_PREFIX} ${personaLabel} at ${usedPct}%`;
389
- // Emit the threshold system chunk. Re-use the same message_id +
390
- // a seq derived from the current seq + 1 to avoid PK conflict.
391
- // Use body.seq + 1000 as a high-offset seq so it sorts AFTER the
392
- // triggering chunk but doesn't collide with normal seq values.
393
- const thresholdSeq = body.seq + 1000;
394
- await pool.query(
395
- `INSERT INTO chunks
396
- (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
397
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
398
- ON CONFLICT (message_id, seq) DO NOTHING`,
399
- [
400
- body.world_id,
401
- body.session_id,
402
- body.message_id,
403
- thresholdSeq,
404
- principal.actorId,
405
- 'system',
406
- 'system',
407
- thresholdChunk,
408
- 'text',
409
- ],
367
+
368
+ // B6 — 80% threshold dedup'd system chunk.
369
+ // After inserting message_usage, check if the per-actor cumulative
370
+ // token count for this session has crossed 80% of the model's context
371
+ // cap. If so, and no prior threshold chunk exists for (session_id,
372
+ // actor_id), emit exactly one dedup'd system chunk.
373
+ //
374
+ // Token math: sum(input_tokens + cache_read_tokens + cache_create_tokens)
375
+ // per (session_id, actor_id), matching Claude Code statusline.
376
+ //
377
+ // SQL-3 SUM cumulative tokens (reads within the same txn so the
378
+ // just-inserted usage row is visible without waiting for COMMIT).
379
+ const contextCap = CONTEXT_CAPS_LOCAL[model] ?? DEFAULT_CONTEXT_CAP;
380
+ const sumResult = await client.query(
381
+ `SELECT COALESCE(SUM(input_tokens + cache_read_tokens + cache_create_tokens), 0) AS total_used
382
+ FROM message_usage
383
+ WHERE session_id = $1 AND actor_id = $2`,
384
+ [body.session_id, principal.actorId],
385
+ );
386
+ const totalUsed = Number(sumResult.rows[0]?.total_used ?? 0);
387
+ const usedPct = Math.floor((totalUsed / contextCap) * 100);
388
+
389
+ if (usedPct >= THRESHOLD_PCT) {
390
+ // SQL-4 — dedup check: scan chunks for an existing threshold-crossing
391
+ // system chunk for this (session_id, actor_id). Prefix-match is
392
+ // sufficient because THRESHOLD_CHUNK_PREFIX is unique to this purpose.
393
+ const dupResult = await client.query(
394
+ `SELECT 1 FROM chunks
395
+ WHERE session_id = $1 AND actor_id = $2 AND actor_type = 'system'
396
+ AND chunk LIKE $3
397
+ LIMIT 1`,
398
+ [body.session_id, principal.actorId, `${THRESHOLD_CHUNK_PREFIX}%`],
410
399
  );
400
+ const alreadyEmitted = (dupResult.rows ?? []).length > 0;
401
+
402
+ if (!alreadyEmitted) {
403
+ const personaLabel = principal.actorId;
404
+ const thresholdChunk =
405
+ `${THRESHOLD_CHUNK_PREFIX} ${personaLabel} at ${usedPct}%`;
406
+ // SQL-5 — Emit the threshold system chunk. Re-use the same message_id +
407
+ // a seq derived from the current seq + 1 to avoid PK conflict.
408
+ // Use body.seq + 1000 as a high-offset seq so it sorts AFTER the
409
+ // triggering chunk but doesn't collide with normal seq values.
410
+ const thresholdSeq = body.seq + 1000;
411
+ await client.query(
412
+ `INSERT INTO chunks
413
+ (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
414
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
415
+ ON CONFLICT (message_id, seq) DO NOTHING`,
416
+ [
417
+ body.world_id,
418
+ body.session_id,
419
+ body.message_id,
420
+ thresholdSeq,
421
+ principal.actorId,
422
+ 'system',
423
+ 'system',
424
+ thresholdChunk,
425
+ 'text',
426
+ ],
427
+ );
428
+ }
411
429
  }
430
+
431
+ await client.query('COMMIT');
432
+ } catch (txnErr) {
433
+ await client.query('ROLLBACK').catch(() => undefined);
434
+ throw txnErr;
435
+ } finally {
436
+ client.release();
412
437
  }
413
438
  }
414
439
  // H2 (plan-chat-spa-canonical-surface Phase G) — extract commit_plan /
@@ -95,6 +95,7 @@ import {
95
95
  } from './routes/process-port.mjs';
96
96
  import { instrumentHandler, renderMetrics } from './metrics.mjs';
97
97
  import { handleDispatchFromEmail } from './lib/email-dispatch.mjs';
98
+ import { handleDispatchFromLinear } from './lib/linear-dispatch.mjs';
98
99
  import { emitTierSuggestion } from '../dispatch/auto-tier-scheduler.mjs';
99
100
  import { isServeOnly, isOrchestrationRoute, ORCHESTRATION_UNAVAILABLE } from './serve-only-config.mjs';
100
101
 
@@ -190,6 +191,18 @@ const OLAM_EMAIL_ATTACHMENTS_ROOT =
190
191
  (HOST_CP_MODE === 'container'
191
192
  ? '/data/email-attachments'
192
193
  : path.join(os.homedir(), '.olam', 'email-attachments'));
194
+ // Linear-webhook trigger (POST /v1/dispatch-from-linear).
195
+ // OLAM_LINEAR_WEBHOOK_SECRET — shared secret set in Linear webhook settings.
196
+ // OLAM_LINEAR_WORLD_ID — optional; when set, route events to this worldId
197
+ // instead of spawning a new world each time.
198
+ // See docs/architecture/linear-as-trigger.md.
199
+ const OLAM_LINEAR_WEBHOOK_SECRET = process.env.OLAM_LINEAR_WEBHOOK_SECRET ?? '';
200
+ const OLAM_LINEAR_WORLD_ID = process.env.OLAM_LINEAR_WORLD_ID ?? '';
201
+
202
+ // In-flight delivery IDs for deduplication. Linear retries on non-2xx;
203
+ // we MUST return 2xx for duplicates so retries terminate. Never use 409.
204
+ const linearInFlight = new Set();
205
+
193
206
  const WORLD_NAMES_PATH =
194
207
  process.env.OLAM_WORLD_NAMES_PATH ??
195
208
  (HOST_CP_MODE === 'container'
@@ -319,6 +332,28 @@ function readAnthropicBaseUrl() {
319
332
  return '';
320
333
  }
321
334
 
335
+ /**
336
+ * Resolve the default `repoUrl` to inject into cloud-dispatch bodies. The
337
+ * cloud Sandbox coding loop clones this repo into `/workspace/repo` before
338
+ * running claude. Source order: OLAM_DOGFOOD_REPO_URL env, then
339
+ * ~/.olam/dogfood-repo-url file (one bare URL, chmod 600). Absent → empty
340
+ * string → no injection (dispatch runs text-only, the pre-coding-default
341
+ * behaviour). Mirrors readAnthropicBaseUrl() so operators have ONE pattern
342
+ * for cloud-dispatch defaults.
343
+ */
344
+ function readDogfoodRepoUrl() {
345
+ const fromOlamEnv = process.env['OLAM_DOGFOOD_REPO_URL'];
346
+ if (fromOlamEnv && fromOlamEnv.length > 0) return fromOlamEnv.trim();
347
+ try {
348
+ const file = path.join(os.homedir(), '.olam', 'dogfood-repo-url');
349
+ const content = fs.readFileSync(file, 'utf-8').trim();
350
+ if (content.length > 0) return content;
351
+ } catch {
352
+ // file absent — fall through
353
+ }
354
+ return '';
355
+ }
356
+
322
357
  /** @type {Record<string, number>} */
323
358
  let WORLDS = {};
324
359
 
@@ -974,6 +1009,7 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
974
1009
  header_name: 'authorization',
975
1010
  header_format: 'Bearer <token>',
976
1011
  hint: 'SPA: set document.cookie = `olam_host_cp_token=${token}; path=/; samesite=strict` then fetch (`/api/world/...`) freely.',
1012
+ cloud_enabled: Boolean(process.env.OLAM_CLOUD_URL && process.env.OLAM_SHOWCASE_PASSWORD),
977
1013
  });
978
1014
  }
979
1015
 
@@ -2447,6 +2483,62 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2447
2483
  return;
2448
2484
  }
2449
2485
 
2486
+ // POST /v1/dispatch-from-linear — Linear webhook receiver.
2487
+ //
2488
+ // Linear POSTs a JSON payload and an HMAC-SHA256 signature in
2489
+ // X-Linear-Signature. host-cp validates the signature, deduplicates
2490
+ // by webhookId (Linear retries on non-2xx — duplicates MUST return 202),
2491
+ // and either routes to a pinned world (OLAM_LINEAR_WORLD_ID) or returns
2492
+ // a spawn_pending descriptor for the MCP/CLI layer.
2493
+ //
2494
+ // Dedup rule: HTTP 202 { action: 'deduplicated' } — NEVER 409.
2495
+ // Body cap: 1 MiB (Linear payloads are small JSON, no attachments).
2496
+ if (url.pathname === '/v1/dispatch-from-linear' && req.method === 'POST') {
2497
+ const chunks = [];
2498
+ let size = 0;
2499
+ const MAX_BODY = 1 * 1024 * 1024;
2500
+ let aborted = false;
2501
+ req.on('data', (chunk) => {
2502
+ size += chunk.length;
2503
+ if (size > MAX_BODY) {
2504
+ aborted = true;
2505
+ jsonReply(res, 413, { error: 'body_too_large', maxBytes: MAX_BODY });
2506
+ req.destroy();
2507
+ return;
2508
+ }
2509
+ chunks.push(chunk);
2510
+ });
2511
+ req.on('end', async () => {
2512
+ if (aborted) return;
2513
+ const rawBody = Buffer.concat(chunks);
2514
+ const signature = req.headers['x-linear-signature'] ?? '';
2515
+ let payload;
2516
+ try {
2517
+ payload = JSON.parse(rawBody.toString('utf8') || '{}');
2518
+ } catch (err) {
2519
+ return jsonReply(res, 400, { error: 'invalid_json', message: err.message });
2520
+ }
2521
+ try {
2522
+ const result = await handleDispatchFromLinear({
2523
+ rawBody,
2524
+ payload,
2525
+ signature,
2526
+ secret: OLAM_LINEAR_WEBHOOK_SECRET,
2527
+ worlds: WORLDS,
2528
+ inFlight: linearInFlight,
2529
+ targetWorldId: OLAM_LINEAR_WORLD_ID || undefined,
2530
+ });
2531
+ return jsonReply(res, result.status, result.body);
2532
+ } catch (err) {
2533
+ return jsonReply(res, 500, {
2534
+ error: 'dispatch_failed',
2535
+ message: err instanceof Error ? err.message : String(err),
2536
+ });
2537
+ }
2538
+ });
2539
+ return;
2540
+ }
2541
+
2450
2542
  if (url.pathname === '/api/cloud-dispatch' && req.method === 'POST') {
2451
2543
  const cloudUrl = process.env.OLAM_CLOUD_URL;
2452
2544
  const showcasePw = process.env.OLAM_SHOWCASE_PASSWORD;
@@ -2473,14 +2565,38 @@ const server = http.createServer(instrumentHandler('host-cp', async (req, res) =
2473
2565
  const planId = parsed.session_id ?? 'default';
2474
2566
  const basicAuth = Buffer.from(`operator:${showcasePw}`).toString('base64');
2475
2567
 
2568
+ // SPA shape → plan-DO shape normalisation: the SPA posts a chat-shaped
2569
+ // body ({ messages: [{ role, content }] }) while plan-DO's /v1/dispatch
2570
+ // expects a top-level `prompt` string. Synthesise `prompt` from
2571
+ // `messages` when it is absent so both call shapes reach plan-DO intact.
2572
+ // Existing curl/CLI callers that already supply `prompt` are unaffected.
2573
+ if (Array.isArray(parsed.messages) && !parsed.prompt) {
2574
+ parsed = {
2575
+ ...parsed,
2576
+ prompt: parsed.messages.map((m) => m?.content ?? '').filter(Boolean).join('\n\n'),
2577
+ };
2578
+ // Keep `messages` too — plan-DO may use it for multi-turn fidelity later.
2579
+ }
2580
+
2476
2581
  // Gap 3: enrich the dispatch body with the operator's anthropicBaseUrl
2477
2582
  // so plan-DO can propagate it to spawned CF Sandbox child worlds.
2478
2583
  // Only injected when not already set by the SPA (SPA has no auth-worker
2479
2584
  // config knowledge — host-cp is the sole injection point).
2480
2585
  const anthropicBaseUrl = readAnthropicBaseUrl();
2481
- const enriched = anthropicBaseUrl && !parsed.anthropicBaseUrl
2482
- ? JSON.stringify({ ...parsed, anthropicBaseUrl })
2483
- : body;
2586
+ // Sibling injection: default repoUrl for the cloud Sandbox coding loop.
2587
+ // The SPA has no repo-config knowledge today (Decision 2 in
2588
+ // olam-builds-olam-cloud-dogfood: no SPA repo-selector); host-cp is the
2589
+ // injection point. Source order: OLAM_DOGFOOD_REPO_URL env, then
2590
+ // ~/.olam/dogfood-repo-url file. Absent → no injection (the dispatch
2591
+ // runs text-only, like before this change).
2592
+ const dogfoodRepoUrl = readDogfoodRepoUrl();
2593
+ let enrichedObj = null;
2594
+ if (anthropicBaseUrl && !parsed.anthropicBaseUrl) enrichedObj = { ...(enrichedObj ?? parsed), anthropicBaseUrl };
2595
+ if (dogfoodRepoUrl && !parsed.repoUrl) enrichedObj = { ...(enrichedObj ?? parsed), repoUrl: dogfoodRepoUrl };
2596
+ // Use `parsed` (not raw `body`) as the no-enrichment fallback so that the
2597
+ // messages→prompt normalisation above is always forwarded even when no
2598
+ // env-var enrichments are applied.
2599
+ const enriched = enrichedObj ? JSON.stringify(enrichedObj) : JSON.stringify(parsed);
2484
2600
 
2485
2601
  // Phase H h2: attach CF Access service-token headers when configured
2486
2602
  // (machine-to-machine auth). Additive alongside Basic auth. CF Access
@@ -3282,6 +3398,25 @@ function buildPlanChatBearerInjection() {
3282
3398
  }
3283
3399
  }
3284
3400
 
3401
+ /**
3402
+ * Build the cloud-enabled flag injection. plan-chat-spa's CloudToggleChip
3403
+ * (`getCloudAvailability` in `lib/plan-config.ts`) reads
3404
+ * `window.__OLAM_CLOUD_ENABLED__` to decide whether the Cloud toggle is
3405
+ * live; absent → the chip stays disabled and only links to the setup doc.
3406
+ *
3407
+ * The toggle should be live exactly when host-cp can actually proxy
3408
+ * `/api/cloud-dispatch` → plan-DO — which is the SAME condition the
3409
+ * cloud-dispatch handler guards with its `503 cloud_dispatch_not_configured`:
3410
+ * BOTH `OLAM_CLOUD_URL` and `OLAM_SHOWCASE_PASSWORD` set. Mirror it exactly
3411
+ * so the UI never offers a toggle that would 503 on dispatch.
3412
+ */
3413
+ function buildCloudEnabledInjection() {
3414
+ const enabled = Boolean(
3415
+ process.env.OLAM_CLOUD_URL && process.env.OLAM_SHOWCASE_PASSWORD,
3416
+ );
3417
+ return enabled ? `<script>window.__OLAM_CLOUD_ENABLED__=true;</script>` : '';
3418
+ }
3419
+
3285
3420
  // Phase E5 (ATOMIC SERVING CUTOVER) — BOOTSTRAP_SCRIPT no longer injected.
3286
3421
  //
3287
3422
  // host-cp now serves plan-chat-spa exclusively. plan-chat-spa's own
@@ -3307,6 +3442,7 @@ function buildPlanChatBearerInjection() {
3307
3442
  async function renderSpaShell(filePath, pathname) {
3308
3443
  const stat = fs.statSync(filePath);
3309
3444
  const bearerInjection = buildPlanChatBearerInjection();
3445
+ const cloudInjection = buildCloudEnabledInjection();
3310
3446
  // Phase E5: BOOTSTRAP_SCRIPT is never injected — plan-chat-spa's own
3311
3447
  // worldFetch.ts shim owns the cookie-bootstrap + path-rewrite contract.
3312
3448
  // We still assert the wildcard invariant so a future re-narrowing of
@@ -3315,7 +3451,7 @@ async function renderSpaShell(filePath, pathname) {
3315
3451
  const skipBootstrap = isPlanningPath(pathname);
3316
3452
  // Cache key includes bearer length (the only per-render-varying input
3317
3453
  // now that bootstrap injection is constant-empty).
3318
- const cacheKey = stat.mtimeMs + ':' + bearerInjection.length;
3454
+ const cacheKey = stat.mtimeMs + ':' + bearerInjection.length + ':' + cloudInjection.length;
3319
3455
  const cached = _spaCacheByKey.get(cacheKey);
3320
3456
  if (cached !== undefined) return cached;
3321
3457
  let html = fs.readFileSync(filePath, 'utf-8');
@@ -3329,7 +3465,7 @@ async function renderSpaShell(filePath, pathname) {
3329
3465
  // is set before the SPA bundle reads it. No bootstrap shim — see the
3330
3466
  // block comment above (Phase E5 cutover).
3331
3467
  void skipBootstrap; // wildcard invariant: always true; documents intent
3332
- html = html.replace(/<head>/i, `<head>\n ${bearerInjection}`);
3468
+ html = html.replace(/<head>/i, `<head>\n ${bearerInjection}${cloudInjection}`);
3333
3469
  _spaCacheByKey.set(cacheKey, html);
3334
3470
  return html;
3335
3471
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pleri/olam-cli",
3
- "version": "0.1.182",
3
+ "version": "0.1.185",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "olam": "./bin/olam.cjs"
@@ -26,7 +26,8 @@
26
26
  "access": "public"
27
27
  },
28
28
  "scripts": {
29
- "build": "tsc",
29
+ "build": "npm run gen:knowledge-pack && tsc",
30
+ "gen:knowledge-pack": "node scripts/gen-knowledge-pack.mjs",
30
31
  "dev": "tsx src/index.ts",
31
32
  "test": "vitest run --passWithNoTests",
32
33
  "test:ci": "vitest run --reporter=basic --passWithNoTests",
@@ -45,6 +46,7 @@
45
46
  "ssh2": "^1.16.0",
46
47
  "yaml": "^2.7.0",
47
48
  "@inquirer/prompts": "^7.0.0",
49
+ "@anthropic-ai/claude-agent-sdk": "^0.2.56",
48
50
  "zod-to-json-schema": "^3.24.0",
49
51
  "playwright-core": "~1.59.0",
50
52
  "@napi-rs/keyring": "^1.1.6"