@pleri/olam-cli 0.1.159 → 0.1.160

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 (72) hide show
  1. package/dist/agent-stream/agent-sdk-to-chunks.js +3 -0
  2. package/dist/agent-stream/driver-runner.js +9 -4
  3. package/dist/agent-stream/host-driver-launch.js +48 -0
  4. package/dist/commands/flywheel/check-persona-skeleton.d.ts +30 -2
  5. package/dist/commands/flywheel/check-persona-skeleton.d.ts.map +1 -1
  6. package/dist/commands/flywheel/check-persona-skeleton.js +143 -6
  7. package/dist/commands/flywheel/check-persona-skeleton.js.map +1 -1
  8. package/dist/commands/flywheel/diversity-check.d.ts +12 -2
  9. package/dist/commands/flywheel/diversity-check.d.ts.map +1 -1
  10. package/dist/commands/flywheel/diversity-check.js +56 -6
  11. package/dist/commands/flywheel/diversity-check.js.map +1 -1
  12. package/dist/commands/flywheel/index.d.ts.map +1 -1
  13. package/dist/commands/flywheel/index.js +2 -0
  14. package/dist/commands/flywheel/index.js.map +1 -1
  15. package/dist/commands/flywheel/install-shims.d.ts +36 -3
  16. package/dist/commands/flywheel/install-shims.d.ts.map +1 -1
  17. package/dist/commands/flywheel/install-shims.js +118 -7
  18. package/dist/commands/flywheel/install-shims.js.map +1 -1
  19. package/dist/commands/flywheel/k10-measure.d.ts +12 -2
  20. package/dist/commands/flywheel/k10-measure.d.ts.map +1 -1
  21. package/dist/commands/flywheel/k10-measure.js +55 -6
  22. package/dist/commands/flywheel/k10-measure.js.map +1 -1
  23. package/dist/commands/flywheel/migrate-overlays.d.ts +115 -0
  24. package/dist/commands/flywheel/migrate-overlays.d.ts.map +1 -0
  25. package/dist/commands/flywheel/migrate-overlays.js +766 -0
  26. package/dist/commands/flywheel/migrate-overlays.js.map +1 -0
  27. package/dist/commands/flywheel/sanitize-persona-output.d.ts +33 -2
  28. package/dist/commands/flywheel/sanitize-persona-output.d.ts.map +1 -1
  29. package/dist/commands/flywheel/sanitize-persona-output.js +94 -6
  30. package/dist/commands/flywheel/sanitize-persona-output.js.map +1 -1
  31. package/dist/commands/memory/index.d.ts.map +1 -1
  32. package/dist/commands/memory/index.js +2 -0
  33. package/dist/commands/memory/index.js.map +1 -1
  34. package/dist/commands/memory/install-hooks.d.ts +22 -0
  35. package/dist/commands/memory/install-hooks.d.ts.map +1 -0
  36. package/dist/commands/memory/install-hooks.js +156 -0
  37. package/dist/commands/memory/install-hooks.js.map +1 -0
  38. package/dist/commands/skills-doctor.js +2 -2
  39. package/dist/commands/skills-doctor.js.map +1 -1
  40. package/dist/commands/skills-source.d.ts.map +1 -1
  41. package/dist/commands/skills-source.js +10 -0
  42. package/dist/commands/skills-source.js.map +1 -1
  43. package/dist/commands/skills.d.ts.map +1 -1
  44. package/dist/commands/skills.js +169 -1
  45. package/dist/commands/skills.js.map +1 -1
  46. package/dist/image-digests.json +7 -7
  47. package/dist/index.js +3046 -718
  48. package/dist/lib/flywheel-probes.d.ts +58 -0
  49. package/dist/lib/flywheel-probes.d.ts.map +1 -0
  50. package/dist/lib/flywheel-probes.js +163 -0
  51. package/dist/lib/flywheel-probes.js.map +1 -0
  52. package/dist/lib/shim-generator.d.ts +51 -0
  53. package/dist/lib/shim-generator.d.ts.map +1 -0
  54. package/dist/lib/shim-generator.js +88 -0
  55. package/dist/lib/shim-generator.js.map +1 -0
  56. package/dist/lib/skills-apply-overlays.d.ts +35 -0
  57. package/dist/lib/skills-apply-overlays.d.ts.map +1 -0
  58. package/dist/lib/skills-apply-overlays.js +243 -0
  59. package/dist/lib/skills-apply-overlays.js.map +1 -0
  60. package/dist/mcp-server.js +1106 -453
  61. package/hermes-bundle/version.json +1 -1
  62. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  63. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  64. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  65. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  66. package/host-cp/k8s/manifests/memory-service/30-configmap.yaml +11 -0
  67. package/host-cp/k8s/manifests/memory-service/35-configmap-iii-config.yaml +76 -0
  68. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +11 -1
  69. package/host-cp/src/crystallize-planning.mjs +261 -0
  70. package/host-cp/src/plan-chat-service.mjs +84 -2
  71. package/host-cp/src/planning-sessions.mjs +270 -0
  72. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
1
  {
2
- "bundledAt": "2026-05-21T16:31:13.129Z",
2
+ "bundledAt": "2026-05-22T05:49:33.599Z",
3
3
  "kgFirstSha": "29a9ccce1b115d049e375c4a90eb5cf7c123e610e2d0590270a4db2cdbc64a28"
4
4
  }
@@ -111,7 +111,7 @@ spec:
111
111
  # k3d), started by `olam upgrade` Step 0.7 — not inside this Pod.
112
112
  containers:
113
113
  - name: olam-host-cp
114
- image: ghcr.io/pleri/olam-host-cp@sha256:53c6548f6930231a6f905f4a3ae1f49dbc66e52233b64a09e539b4ffa21180db
114
+ image: ghcr.io/pleri/olam-host-cp@sha256:3bf4a89af3544e382bf2d708ff73baa6704cf91a0b509f8b1a153fbe603a4223
115
115
  imagePullPolicy: IfNotPresent
116
116
  securityContext:
117
117
  runAsNonRoot: true
@@ -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:d1b13f12d87d5b119d6495214c26d8c8255deb996d37193f3b4fa47363ab9367
73
+ image: ghcr.io/pleri/olam-auth@sha256:a7b1e4c0ddee4fc6bfb2689c4d23d8bc0fcc95bc7b42a28d977b990f1408505b
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:e7276f9ea4d359dcb8a0d623e701e290f51c565fc8b6e3c14bea75b1b780d23d
64
+ image: ghcr.io/pleri/olam-kg-service@sha256:72fdfb96981903cd83d0b6ad997985bad86a7892c0d1ec7c5dcc9b4d9f8f44db
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:d53a7538ca405f4d8c0c4be67d3961617304f07623839eb0de75f9cd2b47b914
71
+ image: ghcr.io/pleri/olam-mcp-auth@sha256:d8fb62e437142bf352e0d6f637c2b912baa592f25f4abbac1acc2c8cced976c2
72
72
  imagePullPolicy: IfNotPresent
73
73
  securityContext:
74
74
  runAsNonRoot: true
@@ -22,3 +22,14 @@ data:
22
22
  # AGENTMEMORY_HOST=0.0.0.0 but ConfigMap override is explicit defense against
23
23
  # a future image regression reverting to 127.0.0.1.
24
24
  AGENTMEMORY_HOST: "0.0.0.0"
25
+ # III_REST_PORT is the env var the agentmemory CLI wrapper reads when it
26
+ # polls its iii subprocess for readiness (cli.mjs:155 — `process.env
27
+ # ["III_REST_PORT"] || "3111"`). The iii engine itself binds the port
28
+ # declared in iii-config.yaml's iii-http worker (overridden via the
29
+ # olam-memory-service-iii-config ConfigMap to 3110, so it does not
30
+ # collide with the metrics-proxy on 3111). Without this env var the
31
+ # wrapper polls 3111 forever, prints "iii-engine did not become ready",
32
+ # and exits — entrypoint propagates the exit, container restarts, and
33
+ # the liveness probe returns 502 from the proxy (its backend was never
34
+ # up). Must equal the iii-http port in 35-configmap-iii-config.yaml.
35
+ III_REST_PORT: "3110"
@@ -0,0 +1,76 @@
1
+ # Overrides the iii-config.yaml shipped inside the agentmemory image so the
2
+ # iii engine binds the INTERNAL port (3110) instead of the EXTERNAL port
3
+ # (3111). The shipped yaml hardcodes `port: 3111` and the agentmemory CLI
4
+ # reads its bind from yaml (NOT from the AGENTMEMORY_PORT env var), so
5
+ # entrypoint.sh's `AGENTMEMORY_PORT=3110` override has no effect.
6
+ #
7
+ # Without this override, the engine and the metrics-proxy both try to bind
8
+ # 0.0.0.0:3111. The proxy starts first and wins the port; the engine fails
9
+ # silently. Probes to /agentmemory/livez hit the proxy and get forwarded to
10
+ # 127.0.0.1:3110, where nothing is listening — proxy returns 502, readiness
11
+ # fails, container restarts.
12
+ #
13
+ # Mounted at /usr/local/lib/node_modules/@agentmemory/agentmemory/dist/iii-config.yaml
14
+ # via subPath in 50-deployment.yaml.
15
+ apiVersion: v1
16
+ kind: ConfigMap
17
+ metadata:
18
+ name: olam-memory-service-iii-config
19
+ namespace: olam
20
+ labels:
21
+ app: olam-memory-service
22
+ olam.io/component: peripheral
23
+ data:
24
+ iii-config.yaml: |
25
+ workers:
26
+ - name: iii-http
27
+ config:
28
+ port: 3110
29
+ host: 0.0.0.0
30
+ default_timeout: 180000
31
+ cors:
32
+ allowed_origins: ["http://localhost:3111", "http://localhost:3113", "http://127.0.0.1:3111", "http://127.0.0.1:3113"]
33
+ allowed_methods: [GET, POST, PUT, DELETE, OPTIONS]
34
+ - name: iii-state
35
+ config:
36
+ adapter:
37
+ name: kv
38
+ config:
39
+ store_method: file_based
40
+ file_path: ./data/state_store.db
41
+ - name: iii-queue
42
+ config:
43
+ adapter:
44
+ name: builtin
45
+ - name: iii-pubsub
46
+ config:
47
+ adapter:
48
+ name: local
49
+ - name: iii-cron
50
+ config:
51
+ adapter:
52
+ name: kv
53
+ - name: iii-stream
54
+ config:
55
+ port: 3112
56
+ host: 0.0.0.0
57
+ adapter:
58
+ name: kv
59
+ config:
60
+ store_method: file_based
61
+ file_path: ./data/stream_store
62
+ - name: iii-observability
63
+ config:
64
+ enabled: true
65
+ service_name: agentmemory
66
+ exporter: memory
67
+ sampling_ratio: 1.0
68
+ metrics_enabled: true
69
+ logs_enabled: true
70
+ logs_console_output: true
71
+ - name: iii-exec
72
+ config:
73
+ watch:
74
+ - src/**/*.ts
75
+ exec:
76
+ - node dist/index.mjs
@@ -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:023bde810b0594829c8aa553b88a64cf53b81b6374085c7a92fb6102450fa3ff
73
+ image: ghcr.io/pleri/olam-memory-service@sha256:bc377f94911baff74f7b91c44ea471580fdfdc1947e757dd6f550675084312d6
74
74
  imagePullPolicy: IfNotPresent
75
75
  securityContext:
76
76
  runAsNonRoot: true
@@ -93,6 +93,13 @@ spec:
93
93
  mountPath: /data
94
94
  - name: tmp
95
95
  mountPath: /tmp
96
+ # Overrides the shipped iii-config.yaml so the engine binds the
97
+ # internal port (3110) instead of colliding with the metrics-proxy
98
+ # on 3111. See 35-configmap-iii-config.yaml for full rationale.
99
+ - name: iii-config-override
100
+ mountPath: /usr/local/lib/node_modules/@agentmemory/agentmemory/dist/iii-config.yaml
101
+ subPath: iii-config.yaml
102
+ readOnly: true
96
103
  readinessProbe:
97
104
  httpGet:
98
105
  # D15 (LOAD-BEARING): memory-service health path is /agentmemory/livez.
@@ -126,3 +133,6 @@ spec:
126
133
  claimName: olam-memory-data
127
134
  - name: tmp
128
135
  emptyDir: {}
136
+ - name: iii-config-override
137
+ configMap:
138
+ name: olam-memory-service-iii-config
@@ -0,0 +1,261 @@
1
+ // crystallize-planning — atomic-or-compensating chunk-copy from a planning
2
+ // session (_planning world) into a freshly provisioned real world.
3
+ //
4
+ // APPEND-ONLY CONSTRAINT: The chunks table has a NO_DELETE + NO_UPDATE
5
+ // trigger (chunks_append_only_trigger). If chunk-copy fails mid-batch,
6
+ // any chunks already INSERTed under the new worldId STAY in the database.
7
+ // Compensating cleanup only calls destroyWorld (world container teardown) —
8
+ // it CANNOT delete the orphaned chunks. Those orphan chunks are harmless:
9
+ // • idx_chunks_planning only covers world_id='_planning' rows.
10
+ // • The destroyed world container no longer exists, so no subscriber
11
+ // will ever observe those orphans through the normal shape proxy.
12
+ // • Any future re-crystallize creates a fresh worldId, fresh session_id.
13
+ //
14
+ // IDEMPOTENCY:
15
+ // • If crystallize_status is 'crystallized' (with a stored worldId),
16
+ // return immediately — the work is already done.
17
+ // • If crystallize_status is 'in_progress', we cannot safely resume
18
+ // (we don't know how far the previous copy got, and the chunk INSERT
19
+ // is not idempotent by worldId+sessionId alone — the PRIMARY KEY is
20
+ // (message_id, seq), so the same chunk could be re-inserted into a
21
+ // different new session without collision). Safe behavior: return
22
+ // the current status so the UI can display "in progress" and the
23
+ // operator can force-retry after manual inspection.
24
+ //
25
+ // SLUG RULE: lowercased, non-alphanum → hyphens, max 40 chars.
26
+ // Matches the dev-substrate stub in plan-chat-spa/src/server/index.ts
27
+ // (confirmed by reading that file's crystallize stub, around line 983).
28
+
29
+ import { randomUUID } from 'node:crypto';
30
+ import { PLANNING_WORLD_ID } from '@olam/chunks/schema';
31
+ import { setCrystallizeStatus } from './planning-sessions.mjs';
32
+
33
+ /**
34
+ * Slug a plan title into a world-name-safe string.
35
+ * Lowercased, non-alphanum → hyphens, max 40 chars, leading/trailing
36
+ * hyphens removed. Falls back to 'plan' if result is empty.
37
+ *
38
+ * @param {string} title
39
+ * @returns {string}
40
+ */
41
+ function slugTitle(title) {
42
+ const base = title
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '')
46
+ .slice(0, 40);
47
+ return base || 'plan';
48
+ }
49
+
50
+ /**
51
+ * Read the current crystallize_status + crystallized_world_id for a session.
52
+ *
53
+ * @param {object} pool
54
+ * @param {string} sessionId
55
+ * @returns {Promise<{crystallize_status: string, crystallized_world_id: string | null}>}
56
+ */
57
+ async function readCrystallizeState(pool, sessionId) {
58
+ const result = await pool.query(
59
+ `SELECT crystallize_status, crystallized_world_id
60
+ FROM planning_sessions
61
+ WHERE session_id = $1`,
62
+ [sessionId],
63
+ );
64
+ if (result.rows.length === 0) {
65
+ return { crystallize_status: 'open', crystallized_world_id: null };
66
+ }
67
+ const row = result.rows[0];
68
+ return {
69
+ crystallize_status: row.crystallize_status,
70
+ crystallized_world_id: row.crystallized_world_id ?? null,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * SELECT all planning chunks for a session, ordered by seq.
76
+ *
77
+ * @param {object} pool
78
+ * @param {string} sessionId
79
+ * @returns {Promise<Array<{world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type}>>}
80
+ */
81
+ async function selectPlanningChunks(pool, sessionId) {
82
+ const result = await pool.query(
83
+ `SELECT world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type
84
+ FROM chunks
85
+ WHERE world_id = $1 AND session_id = $2
86
+ ORDER BY seq ASC`,
87
+ [PLANNING_WORLD_ID, sessionId],
88
+ );
89
+ return result.rows;
90
+ }
91
+
92
+ /**
93
+ * INSERT a single chunk into the new world's session.
94
+ * Uses the original message_id + seq verbatim; only world_id and
95
+ * session_id change to point at the new world's session.
96
+ *
97
+ * @param {object} pool
98
+ * @param {object} chunk — row from the planning session
99
+ * @param {string} newWorldId
100
+ * @param {string} newSessionId
101
+ * @returns {Promise<void>}
102
+ */
103
+ async function insertChunkIntoNewWorld(pool, chunk, newWorldId, newSessionId) {
104
+ await pool.query(
105
+ `INSERT INTO chunks
106
+ (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
107
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
108
+ [
109
+ newWorldId,
110
+ newSessionId,
111
+ chunk.message_id,
112
+ chunk.seq,
113
+ chunk.actor_id,
114
+ chunk.actor_type,
115
+ chunk.role,
116
+ chunk.chunk,
117
+ chunk.chunk_type,
118
+ ],
119
+ );
120
+ }
121
+
122
+ /**
123
+ * INSERT a system marker chunk into the ORIGINAL planning session to
124
+ * leave an audit trail of crystallization. The marker lands at
125
+ * world_id='_planning' + the original sessionId.
126
+ *
127
+ * @param {object} pool
128
+ * @param {string} sessionId — original planning session id
129
+ * @param {string} worldId — newly created world id
130
+ * @param {number} phaseCount — number of phases in the plan
131
+ * @returns {Promise<void>}
132
+ */
133
+ async function insertMarkerChunk(pool, sessionId, worldId, phaseCount) {
134
+ const messageId = randomUUID();
135
+ // Find the current max seq so the marker doesn't collide.
136
+ const seqResult = await pool.query(
137
+ `SELECT COALESCE(MAX(seq), -1) AS max_seq
138
+ FROM chunks
139
+ WHERE world_id = $1 AND session_id = $2`,
140
+ [PLANNING_WORLD_ID, sessionId],
141
+ );
142
+ const nextSeq = Number(seqResult.rows[0].max_seq) + 1;
143
+ await pool.query(
144
+ `INSERT INTO chunks
145
+ (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
146
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
147
+ [
148
+ PLANNING_WORLD_ID,
149
+ sessionId,
150
+ messageId,
151
+ nextSeq,
152
+ 'system',
153
+ 'system',
154
+ 'system',
155
+ `Plan crystallized into world "${worldId}" (${phaseCount} phase${phaseCount === 1 ? '' : 's'}).`,
156
+ 'text',
157
+ ],
158
+ );
159
+ }
160
+
161
+ /**
162
+ * crystallizePlanningSession
163
+ *
164
+ * 4-phase atomic-or-compensating process:
165
+ * 1. Set crystallize_status='in_progress'
166
+ * 2. Call createWorld({ name: slugged-title }) → { id: worldId }
167
+ * 3. SELECT all chunks in _planning/sessionId; INSERT each into new world
168
+ * 4. Set crystallize_status='crystallized' (with worldId); INSERT marker chunk
169
+ *
170
+ * Compensating pattern on partial failure:
171
+ * - If createWorld throws: set status='failed', rethrow. destroyWorld NOT called.
172
+ * - If chunk-copy throws mid-batch: set status='failed', call destroyWorld(worldId),
173
+ * rethrow. Orphan chunks already INSERTed stay (append-only; see file header).
174
+ *
175
+ * Idempotency:
176
+ * - Already 'crystallized': return immediately without re-running.
177
+ * - Already 'in_progress': return current status (safe short-circuit; see header).
178
+ *
179
+ * @param {object} opts
180
+ * @param {object} opts.pool — pg.Pool-compatible with .query()
181
+ * @param {string} opts.sessionId — planning session to crystallize
182
+ * @param {string} opts.planTitle — plan title (used for world name slug)
183
+ * @param {Array} opts.planPhases — array of phase objects (name, acceptance, risks?)
184
+ * @param {Function} opts.createWorld — async ({ name }) => { id: string, ... }
185
+ * @param {Function} opts.destroyWorld — async (worldId) => void
186
+ *
187
+ * @returns {Promise<{worldId: string, status: string, new_session_id: string}>}
188
+ * @throws on failure (crystallize_status already set to 'failed' when thrown)
189
+ */
190
+ export async function crystallizePlanningSession({
191
+ pool,
192
+ sessionId,
193
+ planTitle,
194
+ planPhases,
195
+ createWorld,
196
+ destroyWorld,
197
+ }) {
198
+ // ── Idempotency guard ────────────────────────────────────────────────────
199
+ const currentState = await readCrystallizeState(pool, sessionId);
200
+
201
+ if (currentState.crystallize_status === 'crystallized') {
202
+ return {
203
+ worldId: currentState.crystallized_world_id,
204
+ status: `crystallized:${currentState.crystallized_world_id}`,
205
+ new_session_id: null,
206
+ };
207
+ }
208
+
209
+ if (currentState.crystallize_status === 'in_progress') {
210
+ // Cannot safely resume without knowing how far the copy got.
211
+ // Return current status so the UI shows 'in_progress'.
212
+ return {
213
+ worldId: currentState.crystallized_world_id,
214
+ status: 'in_progress',
215
+ new_session_id: null,
216
+ };
217
+ }
218
+
219
+ // ── Phase 1: mark in_progress ────────────────────────────────────────────
220
+ await setCrystallizeStatus({ pool, sessionId, status: 'in_progress', worldId: null });
221
+
222
+ // ── Phase 2: create world ────────────────────────────────────────────────
223
+ let worldId;
224
+ try {
225
+ const worldName = slugTitle(planTitle);
226
+ const world = await createWorld({ name: worldName });
227
+ worldId = world.id;
228
+ } catch (err) {
229
+ await setCrystallizeStatus({ pool, sessionId, status: 'failed', worldId: null });
230
+ throw err;
231
+ }
232
+
233
+ // ── Phase 3: copy chunks into new world ──────────────────────────────────
234
+ const newSessionId = randomUUID();
235
+ try {
236
+ const chunks = await selectPlanningChunks(pool, sessionId);
237
+ for (const chunk of chunks) {
238
+ await insertChunkIntoNewWorld(pool, chunk, worldId, newSessionId);
239
+ }
240
+ } catch (err) {
241
+ await setCrystallizeStatus({ pool, sessionId, status: 'failed', worldId: null });
242
+ try {
243
+ await destroyWorld(worldId);
244
+ } catch {
245
+ // Compensating destroy failure is non-fatal — the world may already
246
+ // be partially torn down or the destroy operation may not be
247
+ // reversible. Log is left to the caller's context.
248
+ }
249
+ throw err;
250
+ }
251
+
252
+ // ── Phase 4: mark crystallized + insert marker ───────────────────────────
253
+ await setCrystallizeStatus({ pool, sessionId, status: 'crystallized', worldId });
254
+ await insertMarkerChunk(pool, sessionId, worldId, planPhases.length);
255
+
256
+ return {
257
+ worldId,
258
+ status: `crystallized:${worldId}`,
259
+ new_session_id: newSessionId,
260
+ };
261
+ }
@@ -35,6 +35,8 @@ import { Readable } from 'node:stream';
35
35
  import { URL } from 'node:url';
36
36
  import pg from 'pg';
37
37
  import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
38
+ import { listPlanningSessions } from './planning-sessions.mjs';
39
+ import { crystallizePlanningSession } from './crystallize-planning.mjs';
38
40
 
39
41
  const DEFAULT_PORT = 3200;
40
42
  const DEFAULT_DB_URL = 'postgres://postgres:spike@localhost:54321/chunks';
@@ -42,6 +44,7 @@ const DEFAULT_ELECTRIC_URL = 'http://localhost:30001';
42
44
 
43
45
  const ACTOR_TYPES = new Set(['agent', 'operator', 'codex', 'system']);
44
46
  const ROLES = new Set(['user', 'assistant', 'tool', 'system']);
47
+ const CHUNK_TYPES = new Set(['text', 'tool_use']);
45
48
 
46
49
  // PB2 — scope-ID shape. world_id + session_id query params are interpolated
47
50
  // into the upstream Electric `where` clause; the regex IS the SQL-injection
@@ -172,6 +175,10 @@ function validateChunkInput(body) {
172
175
  if (!Number.isInteger(body.seq) || body.seq < 0) return 'seq must be a non-negative integer';
173
176
  if (typeof body.chunk !== 'string') return 'chunk must be a string';
174
177
  if (!ROLES.has(body.role)) return `role must be one of ${[...ROLES].join(', ')}`;
178
+ // Optional chunk_type field — defaults to 'text' when absent.
179
+ if ('chunk_type' in body && !CHUNK_TYPES.has(body.chunk_type)) {
180
+ return `chunk_type must be one of ${[...CHUNK_TYPES].join(', ')}`;
181
+ }
175
182
  // actor_id and actor_type from the client are IGNORED (server-derived).
176
183
  return null;
177
184
  }
@@ -195,6 +202,8 @@ export function createHandler({
195
202
  electricUrl,
196
203
  shapeDebug,
197
204
  shapeDebugLog,
205
+ createWorld,
206
+ destroyWorld,
198
207
  }) {
199
208
  if (!pool) throw new Error('createHandler: { pool } required');
200
209
  if (typeof bearer !== 'string' || bearer.length === 0) {
@@ -231,8 +240,8 @@ export function createHandler({
231
240
  try {
232
241
  await pool.query(
233
242
  `INSERT INTO chunks
234
- (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk)
235
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
243
+ (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
244
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
236
245
  [
237
246
  body.world_id,
238
247
  body.session_id,
@@ -242,6 +251,7 @@ export function createHandler({
242
251
  principal.actorType,
243
252
  body.role,
244
253
  body.chunk,
254
+ body.chunk_type ?? 'text',
245
255
  ],
246
256
  );
247
257
  } catch (err) {
@@ -257,6 +267,7 @@ export function createHandler({
257
267
  seq: body.seq,
258
268
  actor_id: principal.actorId,
259
269
  actor_type: principal.actorType,
270
+ chunk_type: body.chunk_type ?? 'text',
260
271
  });
261
272
  }
262
273
 
@@ -413,11 +424,82 @@ export function createHandler({
413
424
  }
414
425
  }
415
426
 
427
+ async function handleGetPlanningSessions(req, res, url) {
428
+ if (!checkAuth(req)) return unauthorized(res);
429
+ // Derive actorId from the bearer principal (no body on GET).
430
+ const principal = principalFromBearer(bearer, null);
431
+ const limitParam = url.searchParams.get('limit');
432
+ const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 200) : 50;
433
+ try {
434
+ const sessions = await listPlanningSessions({ pool, actorId: principal.actorId, limit });
435
+ return send(res, 200, { sessions });
436
+ } catch (err) {
437
+ return send(res, 500, { error: 'list-failed', message: String(err?.message ?? err) });
438
+ }
439
+ }
440
+
441
+ async function handlePostCrystallize(req, res) {
442
+ if (!checkAuth(req)) return unauthorized(res);
443
+ let body;
444
+ try {
445
+ body = await readJson(req);
446
+ } catch (err) {
447
+ return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
448
+ }
449
+
450
+ // Validate required fields: session_id + plan.title + plan.phases
451
+ if (!body || typeof body.session_id !== 'string' || body.session_id.length === 0) {
452
+ return badRequest(res, 'session_id required');
453
+ }
454
+ if (!body.plan || typeof body.plan.title !== 'string' || body.plan.title.length === 0) {
455
+ return badRequest(res, 'plan.title required');
456
+ }
457
+ if (!Array.isArray(body.plan.phases)) {
458
+ return badRequest(res, 'plan.phases must be an array');
459
+ }
460
+
461
+ // createWorld + destroyWorld callbacks must be wired at handler creation
462
+ // time (production: WorldManager closures; tests: stubs). If they are not
463
+ // provided, surface a clear 501 rather than crashing.
464
+ if (typeof createWorld !== 'function' || typeof destroyWorld !== 'function') {
465
+ return send(res, 501, {
466
+ ok: false,
467
+ error: 'crystallize-not-wired',
468
+ status: 'failed',
469
+ });
470
+ }
471
+
472
+ try {
473
+ const result = await crystallizePlanningSession({
474
+ pool,
475
+ sessionId: body.session_id,
476
+ planTitle: body.plan.title,
477
+ planPhases: body.plan.phases,
478
+ createWorld,
479
+ destroyWorld,
480
+ });
481
+ return send(res, 200, {
482
+ ok: true,
483
+ created_world_id: result.worldId,
484
+ world_url: result.worldId ? `/world/${result.worldId}` : null,
485
+ status: result.status,
486
+ });
487
+ } catch (err) {
488
+ return send(res, 500, {
489
+ ok: false,
490
+ error: String(err?.message ?? err),
491
+ status: 'failed',
492
+ });
493
+ }
494
+ }
495
+
416
496
  return async function handler(req, res) {
417
497
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
418
498
  if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
419
499
  if (req.method === 'POST' && url.pathname === '/v1/chunks') return handlePostChunks(req, res);
420
500
  if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
501
+ if (req.method === 'GET' && url.pathname === '/v1/planning-sessions') return handleGetPlanningSessions(req, res, url);
502
+ if (req.method === 'POST' && url.pathname === '/v1/crystallize') return handlePostCrystallize(req, res);
421
503
  return send(res, 404, { error: 'not-found' });
422
504
  };
423
505
  }