@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.
- package/dist/agent-stream/agent-sdk-to-chunks.js +3 -0
- package/dist/agent-stream/driver-runner.js +9 -4
- package/dist/agent-stream/host-driver-launch.js +48 -0
- package/dist/commands/flywheel/check-persona-skeleton.d.ts +30 -2
- package/dist/commands/flywheel/check-persona-skeleton.d.ts.map +1 -1
- package/dist/commands/flywheel/check-persona-skeleton.js +143 -6
- package/dist/commands/flywheel/check-persona-skeleton.js.map +1 -1
- package/dist/commands/flywheel/diversity-check.d.ts +12 -2
- package/dist/commands/flywheel/diversity-check.d.ts.map +1 -1
- package/dist/commands/flywheel/diversity-check.js +56 -6
- package/dist/commands/flywheel/diversity-check.js.map +1 -1
- package/dist/commands/flywheel/index.d.ts.map +1 -1
- package/dist/commands/flywheel/index.js +2 -0
- package/dist/commands/flywheel/index.js.map +1 -1
- package/dist/commands/flywheel/install-shims.d.ts +36 -3
- package/dist/commands/flywheel/install-shims.d.ts.map +1 -1
- package/dist/commands/flywheel/install-shims.js +118 -7
- package/dist/commands/flywheel/install-shims.js.map +1 -1
- package/dist/commands/flywheel/k10-measure.d.ts +12 -2
- package/dist/commands/flywheel/k10-measure.d.ts.map +1 -1
- package/dist/commands/flywheel/k10-measure.js +55 -6
- package/dist/commands/flywheel/k10-measure.js.map +1 -1
- package/dist/commands/flywheel/migrate-overlays.d.ts +115 -0
- package/dist/commands/flywheel/migrate-overlays.d.ts.map +1 -0
- package/dist/commands/flywheel/migrate-overlays.js +766 -0
- package/dist/commands/flywheel/migrate-overlays.js.map +1 -0
- package/dist/commands/flywheel/sanitize-persona-output.d.ts +33 -2
- package/dist/commands/flywheel/sanitize-persona-output.d.ts.map +1 -1
- package/dist/commands/flywheel/sanitize-persona-output.js +94 -6
- package/dist/commands/flywheel/sanitize-persona-output.js.map +1 -1
- package/dist/commands/memory/index.d.ts.map +1 -1
- package/dist/commands/memory/index.js +2 -0
- package/dist/commands/memory/index.js.map +1 -1
- package/dist/commands/memory/install-hooks.d.ts +22 -0
- package/dist/commands/memory/install-hooks.d.ts.map +1 -0
- package/dist/commands/memory/install-hooks.js +156 -0
- package/dist/commands/memory/install-hooks.js.map +1 -0
- package/dist/commands/skills-doctor.js +2 -2
- package/dist/commands/skills-doctor.js.map +1 -1
- package/dist/commands/skills-source.d.ts.map +1 -1
- package/dist/commands/skills-source.js +10 -0
- package/dist/commands/skills-source.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +169 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/image-digests.json +7 -7
- package/dist/index.js +3046 -718
- package/dist/lib/flywheel-probes.d.ts +58 -0
- package/dist/lib/flywheel-probes.d.ts.map +1 -0
- package/dist/lib/flywheel-probes.js +163 -0
- package/dist/lib/flywheel-probes.js.map +1 -0
- package/dist/lib/shim-generator.d.ts +51 -0
- package/dist/lib/shim-generator.d.ts.map +1 -0
- package/dist/lib/shim-generator.js +88 -0
- package/dist/lib/shim-generator.js.map +1 -0
- package/dist/lib/skills-apply-overlays.d.ts +35 -0
- package/dist/lib/skills-apply-overlays.d.ts.map +1 -0
- package/dist/lib/skills-apply-overlays.js +243 -0
- package/dist/lib/skills-apply-overlays.js.map +1 -0
- package/dist/mcp-server.js +1106 -453
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/memory-service/30-configmap.yaml +11 -0
- package/host-cp/k8s/manifests/memory-service/35-configmap-iii-config.yaml +76 -0
- package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +11 -1
- package/host-cp/src/crystallize-planning.mjs +261 -0
- package/host-cp/src/plan-chat-service.mjs +84 -2
- package/host-cp/src/planning-sessions.mjs +270 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
}
|