forge-openclaw-plugin 0.3.10 → 0.3.12
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/companion-iroh-src/src/lib.rs +328 -69
- package/dist/companion-iroh-src/src/main.rs +15 -2
- package/dist/openclaw/tools.js +2 -0
- package/dist/server/server/migrations/071_agent_runtime_claude_identity.sql +49 -0
- package/dist/server/server/src/app.js +32 -2
- package/dist/server/server/src/openapi.js +5 -2
- package/dist/server/server/src/repositories/agent-runtime-sessions.js +27 -0
- package/dist/server/server/src/repositories/settings.js +2 -1
- package/dist/server/server/src/services/companion-iroh.js +14 -8
- package/dist/server/server/src/types.js +2 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/071_agent_runtime_claude_identity.sql +49 -0
- package/skills/forge-openclaw/SKILL.md +13 -0
- package/skills/forge-openclaw/entity_conversation_playbooks.md +22 -0
- package/skills/forge-openclaw/psyche_entity_playbooks.md +22 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
use std::collections::HashMap;
|
|
1
2
|
use std::ffi::{CStr, CString, c_char};
|
|
2
|
-
use std::
|
|
3
|
+
use std::sync::{Arc, Mutex, OnceLock};
|
|
4
|
+
use std::time::{Duration, Instant};
|
|
3
5
|
|
|
6
|
+
use iroh::endpoint::Connection;
|
|
4
7
|
use iroh::{Endpoint, SecretKey};
|
|
5
8
|
use protocol::{
|
|
6
9
|
BridgeRequest, BridgeResponse, COMPANION_ALPN, FORGE_AGENT_NAME, ForgeHttpRequest,
|
|
@@ -12,6 +15,129 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
|
12
15
|
pub mod protocol;
|
|
13
16
|
|
|
14
17
|
const MAX_FRAME_BYTES: usize = 50 * 1024 * 1024;
|
|
18
|
+
const IROH_CLIENT_TIMING_HEADER: &str = "x-forge-iroh-client-timing-ms";
|
|
19
|
+
const IROH_CLIENT_CONNECTION_REUSED_HEADER: &str = "x-forge-iroh-client-connection-reused";
|
|
20
|
+
const FFI_RUNTIME_WORKER_THREADS: usize = 6;
|
|
21
|
+
#[cfg(test)]
|
|
22
|
+
const FOREGROUND_HEALTH_SYNC_STREAMS: usize = 6;
|
|
23
|
+
|
|
24
|
+
struct FfiIrohState {
|
|
25
|
+
runtime: tokio::runtime::Runtime,
|
|
26
|
+
endpoint: Mutex<Option<Arc<Endpoint>>>,
|
|
27
|
+
connections: Mutex<HashMap<ConnectionCacheKey, Arc<Connection>>>,
|
|
28
|
+
secret_key: SecretKey,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static FFI_STATE: OnceLock<Result<FfiIrohState, String>> = OnceLock::new();
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
34
|
+
struct ConnectionCacheKey {
|
|
35
|
+
node_id: String,
|
|
36
|
+
relay: Option<String>,
|
|
37
|
+
token: String,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl ConnectionCacheKey {
|
|
41
|
+
fn from_payload(payload: &PairPayload) -> Self {
|
|
42
|
+
Self {
|
|
43
|
+
node_id: payload.node_id.clone(),
|
|
44
|
+
relay: payload.relay.clone(),
|
|
45
|
+
token: payload.token.clone(),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn ffi_state() -> Result<&'static FfiIrohState, String> {
|
|
51
|
+
match FFI_STATE.get_or_init(FfiIrohState::new) {
|
|
52
|
+
Ok(state) => Ok(state),
|
|
53
|
+
Err(error) => Err(error.clone()),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
impl FfiIrohState {
|
|
58
|
+
fn new() -> Result<Self, String> {
|
|
59
|
+
let runtime = tokio::runtime::Builder::new_multi_thread()
|
|
60
|
+
.worker_threads(FFI_RUNTIME_WORKER_THREADS)
|
|
61
|
+
.enable_all()
|
|
62
|
+
.build()
|
|
63
|
+
.map_err(|error| format!("building Forge Iroh runtime: {error}"))?;
|
|
64
|
+
Ok(Self {
|
|
65
|
+
runtime,
|
|
66
|
+
endpoint: Mutex::new(None),
|
|
67
|
+
connections: Mutex::new(HashMap::new()),
|
|
68
|
+
secret_key: SecretKey::generate(),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async fn cached_endpoint(&self) -> Result<Arc<Endpoint>, String> {
|
|
73
|
+
if let Some(endpoint) = self.endpoint.lock().map_err(lock_error)?.as_ref().cloned() {
|
|
74
|
+
return Ok(endpoint);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let endpoint = Arc::new(
|
|
78
|
+
Endpoint::builder(iroh::endpoint::presets::N0)
|
|
79
|
+
.secret_key(self.secret_key.clone())
|
|
80
|
+
.bind()
|
|
81
|
+
.await
|
|
82
|
+
.map_err(|error| format!("binding Iroh endpoint: {error}"))?,
|
|
83
|
+
);
|
|
84
|
+
let mut guard = self.endpoint.lock().map_err(lock_error)?;
|
|
85
|
+
if let Some(existing) = guard.as_ref().cloned() {
|
|
86
|
+
return Ok(existing);
|
|
87
|
+
}
|
|
88
|
+
*guard = Some(endpoint.clone());
|
|
89
|
+
Ok(endpoint)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async fn cached_connection(
|
|
93
|
+
&self,
|
|
94
|
+
payload: &PairPayload,
|
|
95
|
+
) -> Result<(ConnectionCacheKey, Arc<Connection>, bool), String> {
|
|
96
|
+
let key = ConnectionCacheKey::from_payload(payload);
|
|
97
|
+
if let Some(connection) = self
|
|
98
|
+
.connections
|
|
99
|
+
.lock()
|
|
100
|
+
.map_err(lock_error)?
|
|
101
|
+
.get(&key)
|
|
102
|
+
.cloned()
|
|
103
|
+
{
|
|
104
|
+
return Ok((key, connection, true));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let endpoint = self.cached_endpoint().await?;
|
|
108
|
+
let connection = Arc::new(connect_iroh(endpoint, payload).await?);
|
|
109
|
+
let mut guard = self.connections.lock().map_err(lock_error)?;
|
|
110
|
+
if let Some(existing) = guard.get(&key).cloned() {
|
|
111
|
+
return Ok((key, existing, true));
|
|
112
|
+
}
|
|
113
|
+
guard.insert(key.clone(), connection.clone());
|
|
114
|
+
Ok((key, connection, false))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn evict_connection(&self, key: &ConnectionCacheKey, connection: &Arc<Connection>) {
|
|
118
|
+
let Ok(mut guard) = self.connections.lock() else {
|
|
119
|
+
connection.close(
|
|
120
|
+
iroh::endpoint::VarInt::from_u32(1),
|
|
121
|
+
b"forge connection cache lock failed",
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
};
|
|
125
|
+
if guard
|
|
126
|
+
.get(key)
|
|
127
|
+
.is_some_and(|existing| Arc::ptr_eq(existing, connection))
|
|
128
|
+
{
|
|
129
|
+
guard.remove(key);
|
|
130
|
+
connection.close(
|
|
131
|
+
iroh::endpoint::VarInt::from_u32(1),
|
|
132
|
+
b"forge request stream failed",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fn lock_error<T>(error: std::sync::PoisonError<T>) -> String {
|
|
139
|
+
format!("Forge Iroh client state lock poisoned: {error}")
|
|
140
|
+
}
|
|
15
141
|
|
|
16
142
|
#[derive(Debug, Deserialize)]
|
|
17
143
|
#[serde(rename_all = "camelCase")]
|
|
@@ -87,12 +213,9 @@ fn into_c_string(value: String) -> *mut c_char {
|
|
|
87
213
|
}
|
|
88
214
|
|
|
89
215
|
fn run_ffi_http_request(request: FfiHttpRequest) -> Result<FfiHttpResponse, String> {
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
.map_err(|error| format!("building Forge Iroh runtime: {error}"))?;
|
|
94
|
-
runtime.block_on(async move {
|
|
95
|
-
let response = send_http_request_over_iroh(request).await?;
|
|
216
|
+
let state = ffi_state()?;
|
|
217
|
+
state.runtime.block_on(async move {
|
|
218
|
+
let response = send_http_request_over_iroh(state, request).await?;
|
|
96
219
|
Ok(FfiHttpResponse {
|
|
97
220
|
ok: true,
|
|
98
221
|
status: Some(response.status),
|
|
@@ -103,74 +226,141 @@ fn run_ffi_http_request(request: FfiHttpRequest) -> Result<FfiHttpResponse, Stri
|
|
|
103
226
|
})
|
|
104
227
|
}
|
|
105
228
|
|
|
106
|
-
async fn send_http_request_over_iroh(
|
|
229
|
+
async fn send_http_request_over_iroh(
|
|
230
|
+
state: &FfiIrohState,
|
|
231
|
+
request: FfiHttpRequest,
|
|
232
|
+
) -> Result<ForgeHttpResponse, String> {
|
|
107
233
|
validate_pair_payload(&request.pair_payload)?;
|
|
108
234
|
validate_proxy_path(&request.path)?;
|
|
109
235
|
|
|
110
|
-
let
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
.await
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
.
|
|
118
|
-
.
|
|
119
|
-
.
|
|
120
|
-
.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
);
|
|
236
|
+
let total_started_at = Instant::now();
|
|
237
|
+
let connection_started_at = Instant::now();
|
|
238
|
+
let (cache_key, conn, connection_reused) =
|
|
239
|
+
state.cached_connection(&request.pair_payload).await?;
|
|
240
|
+
let connection_ms = elapsed_ms(connection_started_at);
|
|
241
|
+
match send_request_over_connection(&conn, request).await {
|
|
242
|
+
Ok((mut response, mut timing)) => {
|
|
243
|
+
timing.connection_ms = connection_ms;
|
|
244
|
+
timing.connection_reused = connection_reused;
|
|
245
|
+
timing.total_ms = elapsed_ms(total_started_at);
|
|
246
|
+
response.headers.push(HeaderPair {
|
|
247
|
+
name: IROH_CLIENT_TIMING_HEADER.to_string(),
|
|
248
|
+
value: timing.to_header_value(),
|
|
249
|
+
});
|
|
250
|
+
response.headers.push(HeaderPair {
|
|
251
|
+
name: IROH_CLIENT_CONNECTION_REUSED_HEADER.to_string(),
|
|
252
|
+
value: if connection_reused { "1" } else { "0" }.to_string(),
|
|
253
|
+
});
|
|
254
|
+
Ok(response)
|
|
128
255
|
}
|
|
129
|
-
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
&ForgeHttpRequest {
|
|
151
|
-
v: PROTOCOL_VERSION,
|
|
152
|
-
method: request.method,
|
|
153
|
-
path: request.path,
|
|
154
|
-
headers: request.headers,
|
|
155
|
-
body_base64: request.body_base64,
|
|
156
|
-
},
|
|
157
|
-
)
|
|
158
|
-
.await?;
|
|
159
|
-
let response = tokio::time::timeout(
|
|
160
|
-
Duration::from_secs(60),
|
|
161
|
-
read_json_frame::<ForgeHttpResponse, _>(&mut recv),
|
|
162
|
-
)
|
|
163
|
-
.await
|
|
164
|
-
.map_err(|_| "timed out waiting for Forge Iroh response".to_string())??;
|
|
165
|
-
conn.close(
|
|
166
|
-
iroh::endpoint::VarInt::from_u32(0),
|
|
167
|
-
b"forge request complete",
|
|
256
|
+
Err(error) => {
|
|
257
|
+
state.evict_connection(&cache_key, &conn);
|
|
258
|
+
Err(error)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async fn connect_iroh(
|
|
264
|
+
endpoint: Arc<Endpoint>,
|
|
265
|
+
payload: &PairPayload,
|
|
266
|
+
) -> Result<Connection, String> {
|
|
267
|
+
let node_id = payload
|
|
268
|
+
.node_id
|
|
269
|
+
.parse()
|
|
270
|
+
.map_err(|error| format!("parsing Iroh node id: {error}"))?;
|
|
271
|
+
let mut addr = iroh::EndpointAddr::new(node_id);
|
|
272
|
+
if let Some(relay) = payload.relay.as_deref() {
|
|
273
|
+
addr = addr.with_relay_url(
|
|
274
|
+
relay
|
|
275
|
+
.parse()
|
|
276
|
+
.map_err(|error| format!("parsing Iroh relay URL: {error}"))?,
|
|
168
277
|
);
|
|
169
|
-
Ok(response)
|
|
170
278
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
279
|
+
endpoint
|
|
280
|
+
.connect(addr, COMPANION_ALPN)
|
|
281
|
+
.await
|
|
282
|
+
.map_err(|error| format!("connecting over Forge Iroh bridge: {error}"))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async fn send_request_over_connection(
|
|
286
|
+
conn: &Connection,
|
|
287
|
+
request: FfiHttpRequest,
|
|
288
|
+
) -> Result<(ForgeHttpResponse, IrohClientTiming), String> {
|
|
289
|
+
let mut timing = IrohClientTiming::default();
|
|
290
|
+
let open_started_at = Instant::now();
|
|
291
|
+
let (mut send, mut recv) = conn
|
|
292
|
+
.open_bi()
|
|
293
|
+
.await
|
|
294
|
+
.map_err(|error| format!("opening Iroh stream: {error}"))?;
|
|
295
|
+
timing.open_stream_ms = elapsed_ms(open_started_at);
|
|
296
|
+
|
|
297
|
+
let bridge_started_at = Instant::now();
|
|
298
|
+
write_json_frame(
|
|
299
|
+
&mut send,
|
|
300
|
+
&BridgeRequest::Connect {
|
|
301
|
+
v: PROTOCOL_VERSION,
|
|
302
|
+
token: request.pair_payload.token.clone(),
|
|
303
|
+
agent: FORGE_AGENT_NAME.to_string(),
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
.await?;
|
|
307
|
+
let ack: BridgeResponse = read_json_frame(&mut recv).await?;
|
|
308
|
+
validate_bridge_response(&ack)?;
|
|
309
|
+
timing.bridge_ack_ms = elapsed_ms(bridge_started_at);
|
|
310
|
+
|
|
311
|
+
let write_started_at = Instant::now();
|
|
312
|
+
write_json_frame(
|
|
313
|
+
&mut send,
|
|
314
|
+
&ForgeHttpRequest {
|
|
315
|
+
v: PROTOCOL_VERSION,
|
|
316
|
+
method: request.method,
|
|
317
|
+
path: request.path,
|
|
318
|
+
headers: request.headers,
|
|
319
|
+
body_base64: request.body_base64,
|
|
320
|
+
},
|
|
321
|
+
)
|
|
322
|
+
.await?;
|
|
323
|
+
timing.write_request_ms = elapsed_ms(write_started_at);
|
|
324
|
+
|
|
325
|
+
let response_started_at = Instant::now();
|
|
326
|
+
let response = tokio::time::timeout(
|
|
327
|
+
Duration::from_secs(60),
|
|
328
|
+
read_json_frame::<ForgeHttpResponse, _>(&mut recv),
|
|
329
|
+
)
|
|
330
|
+
.await
|
|
331
|
+
.map_err(|_| "timed out waiting for Forge Iroh response".to_string())??;
|
|
332
|
+
timing.response_wait_ms = elapsed_ms(response_started_at);
|
|
333
|
+
Ok((response, timing))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#[derive(Debug, Default)]
|
|
337
|
+
struct IrohClientTiming {
|
|
338
|
+
connection_reused: bool,
|
|
339
|
+
connection_ms: u128,
|
|
340
|
+
open_stream_ms: u128,
|
|
341
|
+
bridge_ack_ms: u128,
|
|
342
|
+
write_request_ms: u128,
|
|
343
|
+
response_wait_ms: u128,
|
|
344
|
+
total_ms: u128,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
impl IrohClientTiming {
|
|
348
|
+
fn to_header_value(&self) -> String {
|
|
349
|
+
format!(
|
|
350
|
+
"reused={},total={},connection={},openStream={},bridgeAck={},writeRequest={},responseWait={}",
|
|
351
|
+
if self.connection_reused { 1 } else { 0 },
|
|
352
|
+
self.total_ms,
|
|
353
|
+
self.connection_ms,
|
|
354
|
+
self.open_stream_ms,
|
|
355
|
+
self.bridge_ack_ms,
|
|
356
|
+
self.write_request_ms,
|
|
357
|
+
self.response_wait_ms
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
fn elapsed_ms(started_at: Instant) -> u128 {
|
|
363
|
+
started_at.elapsed().as_millis()
|
|
174
364
|
}
|
|
175
365
|
|
|
176
366
|
fn validate_pair_payload(payload: &PairPayload) -> Result<(), String> {
|
|
@@ -276,4 +466,73 @@ mod tests {
|
|
|
276
466
|
fn proxy_path_rejects_absolute_urls() {
|
|
277
467
|
assert!(validate_proxy_path("https://example.com/api/v1/health").is_err());
|
|
278
468
|
}
|
|
469
|
+
|
|
470
|
+
#[test]
|
|
471
|
+
fn ffi_reuses_runtime_and_endpoint_between_requests() {
|
|
472
|
+
let state = ffi_state().expect("ffi state");
|
|
473
|
+
let first = state
|
|
474
|
+
.runtime
|
|
475
|
+
.block_on(state.cached_endpoint())
|
|
476
|
+
.expect("first endpoint");
|
|
477
|
+
let second = state
|
|
478
|
+
.runtime
|
|
479
|
+
.block_on(state.cached_endpoint())
|
|
480
|
+
.expect("second endpoint");
|
|
481
|
+
|
|
482
|
+
assert!(Arc::ptr_eq(&first, &second));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#[test]
|
|
486
|
+
fn connection_cache_key_tracks_remote_and_token() {
|
|
487
|
+
let payload = PairPayload {
|
|
488
|
+
v: PROTOCOL_VERSION,
|
|
489
|
+
node_id: "node-a".to_string(),
|
|
490
|
+
token: "token-a".to_string(),
|
|
491
|
+
host_name: Some("host".to_string()),
|
|
492
|
+
relay: Some("https://relay.example".to_string()),
|
|
493
|
+
};
|
|
494
|
+
let same = PairPayload {
|
|
495
|
+
host_name: None,
|
|
496
|
+
..payload.clone()
|
|
497
|
+
};
|
|
498
|
+
let different_token = PairPayload {
|
|
499
|
+
token: "token-b".to_string(),
|
|
500
|
+
..payload.clone()
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
assert_eq!(
|
|
504
|
+
ConnectionCacheKey::from_payload(&payload),
|
|
505
|
+
ConnectionCacheKey::from_payload(&same)
|
|
506
|
+
);
|
|
507
|
+
assert_ne!(
|
|
508
|
+
ConnectionCacheKey::from_payload(&payload),
|
|
509
|
+
ConnectionCacheKey::from_payload(&different_token)
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#[test]
|
|
514
|
+
fn iroh_client_timing_header_names_response_wait() {
|
|
515
|
+
let timing = IrohClientTiming {
|
|
516
|
+
connection_reused: true,
|
|
517
|
+
connection_ms: 1,
|
|
518
|
+
open_stream_ms: 2,
|
|
519
|
+
bridge_ack_ms: 3,
|
|
520
|
+
write_request_ms: 4,
|
|
521
|
+
response_wait_ms: 5,
|
|
522
|
+
total_ms: 15,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
assert_eq!(
|
|
526
|
+
timing.to_header_value(),
|
|
527
|
+
"reused=1,total=15,connection=1,openStream=2,bridgeAck=3,writeRequest=4,responseWait=5"
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#[test]
|
|
532
|
+
fn ffi_runtime_keeps_up_with_foreground_health_sync_window() {
|
|
533
|
+
assert!(
|
|
534
|
+
FFI_RUNTIME_WORKER_THREADS >= FOREGROUND_HEALTH_SYNC_STREAMS,
|
|
535
|
+
"Iroh FFI runtime workers should not be narrower than foreground HealthKit streams"
|
|
536
|
+
);
|
|
537
|
+
}
|
|
279
538
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
use std::path::{Path, PathBuf};
|
|
2
|
-
use std::time::Duration;
|
|
2
|
+
use std::time::{Duration, Instant};
|
|
3
3
|
|
|
4
4
|
use anyhow::{Context, anyhow};
|
|
5
5
|
use base64::Engine;
|
|
@@ -16,6 +16,7 @@ use tracing::{info, warn};
|
|
|
16
16
|
use tracing_subscriber::EnvFilter;
|
|
17
17
|
|
|
18
18
|
const MAX_FRAME_BYTES: usize = 50 * 1024 * 1024;
|
|
19
|
+
const IROH_HOST_TIMING_HEADER: &str = "x-forge-iroh-host-timing-ms";
|
|
19
20
|
|
|
20
21
|
#[derive(Parser, Debug)]
|
|
21
22
|
#[command(version, about = "Forge Companion transport over Iroh QUIC")]
|
|
@@ -276,9 +277,12 @@ async fn proxy_http_request(
|
|
|
276
277
|
.context("decoding request body")?;
|
|
277
278
|
builder = builder.body(bytes);
|
|
278
279
|
}
|
|
280
|
+
let request_started_at = Instant::now();
|
|
279
281
|
let response = builder.send().await.context("forwarding HTTP request")?;
|
|
282
|
+
let upstream_response_ms = request_started_at.elapsed().as_millis();
|
|
280
283
|
let status = response.status().as_u16();
|
|
281
|
-
let
|
|
284
|
+
let body_started_at = Instant::now();
|
|
285
|
+
let mut headers: Vec<HeaderPair> = response
|
|
282
286
|
.headers()
|
|
283
287
|
.iter()
|
|
284
288
|
.filter_map(|(name, value)| {
|
|
@@ -292,6 +296,15 @@ async fn proxy_http_request(
|
|
|
292
296
|
.bytes()
|
|
293
297
|
.await
|
|
294
298
|
.context("reading HTTP response body")?;
|
|
299
|
+
let body_read_ms = body_started_at.elapsed().as_millis();
|
|
300
|
+
let total_ms = request_started_at.elapsed().as_millis();
|
|
301
|
+
headers.push(HeaderPair {
|
|
302
|
+
name: IROH_HOST_TIMING_HEADER.to_string(),
|
|
303
|
+
value: format!(
|
|
304
|
+
"total={},upstreamResponse={},bodyRead={}",
|
|
305
|
+
total_ms, upstream_response_ms, body_read_ms
|
|
306
|
+
),
|
|
307
|
+
});
|
|
295
308
|
Ok(ForgeHttpResponse {
|
|
296
309
|
v: PROTOCOL_VERSION,
|
|
297
310
|
status,
|
package/dist/openclaw/tools.js
CHANGED
|
@@ -173,6 +173,7 @@ const lifeForceRouteSpecs = {
|
|
|
173
173
|
const workbenchRouteSpecs = {
|
|
174
174
|
boxCatalog: { method: "GET", path: "/api/v1/workbench/catalog/boxes" },
|
|
175
175
|
listFlows: { method: "GET", path: "/api/v1/workbench/flows" },
|
|
176
|
+
flowDetail: { method: "GET", path: "/api/v1/workbench/flows/:id" },
|
|
176
177
|
flowById: { method: "GET", path: "/api/v1/workbench/flows/:id" },
|
|
177
178
|
flowBySlug: {
|
|
178
179
|
method: "GET",
|
|
@@ -212,6 +213,7 @@ const workbenchRouteSpecs = {
|
|
|
212
213
|
method: "GET",
|
|
213
214
|
path: "/api/v1/workbench/flows/:id/output"
|
|
214
215
|
},
|
|
216
|
+
runHistory: { method: "GET", path: "/api/v1/workbench/flows/:id/runs" },
|
|
215
217
|
runs: { method: "GET", path: "/api/v1/workbench/flows/:id/runs" },
|
|
216
218
|
runDetail: {
|
|
217
219
|
method: "GET",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
INSERT OR IGNORE INTO users (
|
|
2
|
+
id, kind, handle, display_name, description, accent_color, created_at, updated_at
|
|
3
|
+
) VALUES (
|
|
4
|
+
'user_agent_claude',
|
|
5
|
+
'bot',
|
|
6
|
+
'claude',
|
|
7
|
+
'Claude Code',
|
|
8
|
+
'Claude Code runtime actor linked to Forge agent identity and Kanban ownership.',
|
|
9
|
+
'#f97316',
|
|
10
|
+
datetime('now'),
|
|
11
|
+
datetime('now')
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
UPDATE users
|
|
15
|
+
SET kind = 'bot',
|
|
16
|
+
handle = 'claude',
|
|
17
|
+
display_name = 'Claude Code',
|
|
18
|
+
description = 'Claude Code runtime actor linked to Forge agent identity and Kanban ownership.',
|
|
19
|
+
accent_color = '#f97316',
|
|
20
|
+
updated_at = datetime('now')
|
|
21
|
+
WHERE id = 'user_agent_claude';
|
|
22
|
+
|
|
23
|
+
UPDATE agent_identities
|
|
24
|
+
SET label = 'Forge Claude Code',
|
|
25
|
+
agent_type = 'claude',
|
|
26
|
+
provider = 'claude',
|
|
27
|
+
identity_key = COALESCE(identity_key, 'runtime:claude:legacy:default'),
|
|
28
|
+
machine_key = COALESCE(machine_key, 'legacy'),
|
|
29
|
+
persona_key = COALESCE(persona_key, 'default'),
|
|
30
|
+
description = 'Forge Claude Code runtime agent with stable Forge identity and linked Kanban user.',
|
|
31
|
+
updated_at = datetime('now')
|
|
32
|
+
WHERE lower(agent_type) = 'claude'
|
|
33
|
+
OR lower(label) IN ('forge claude', 'forge claude code', 'claude', 'claude code');
|
|
34
|
+
|
|
35
|
+
INSERT OR IGNORE INTO agent_identity_users (
|
|
36
|
+
agent_id, user_id, role, created_at, updated_at
|
|
37
|
+
)
|
|
38
|
+
SELECT id, 'user_agent_claude', 'primary', datetime('now'), datetime('now')
|
|
39
|
+
FROM agent_identities
|
|
40
|
+
WHERE provider = 'claude';
|
|
41
|
+
|
|
42
|
+
UPDATE agent_runtime_sessions
|
|
43
|
+
SET agent_label = 'Forge Claude Code',
|
|
44
|
+
agent_type = 'claude',
|
|
45
|
+
provider = 'claude',
|
|
46
|
+
updated_at = datetime('now')
|
|
47
|
+
WHERE provider = 'claude'
|
|
48
|
+
OR lower(agent_type) = 'claude'
|
|
49
|
+
OR lower(agent_label) IN ('forge claude', 'forge claude code', 'claude', 'claude code');
|
|
@@ -5134,9 +5134,11 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5134
5134
|
summary: "Dedicated graph-flow API. Use it for flow catalog reads, flow CRUD, execution, run history, published outputs, node results, and latest successful node outputs.",
|
|
5135
5135
|
routeKeys: [
|
|
5136
5136
|
"listFlows",
|
|
5137
|
+
"flowDetail",
|
|
5137
5138
|
"flowById",
|
|
5138
5139
|
"flowBySlug",
|
|
5139
5140
|
"publishedOutput",
|
|
5141
|
+
"runHistory",
|
|
5140
5142
|
"runs",
|
|
5141
5143
|
"runDetail",
|
|
5142
5144
|
"runNodes",
|
|
@@ -5160,9 +5162,11 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5160
5162
|
],
|
|
5161
5163
|
methodRoutes: {
|
|
5162
5164
|
listFlows: "GET /api/v1/workbench/flows",
|
|
5165
|
+
flowDetail: "GET /api/v1/workbench/flows/:id",
|
|
5163
5166
|
flowById: "GET /api/v1/workbench/flows/:id",
|
|
5164
5167
|
flowBySlug: "GET /api/v1/workbench/flows/by-slug/:slug",
|
|
5165
5168
|
publishedOutput: "GET /api/v1/workbench/flows/:id/output",
|
|
5169
|
+
runHistory: "GET /api/v1/workbench/flows/:id/runs",
|
|
5166
5170
|
runs: "GET /api/v1/workbench/flows/:id/runs",
|
|
5167
5171
|
runDetail: "GET /api/v1/workbench/flows/:id/runs/:runId",
|
|
5168
5172
|
runNodes: "GET /api/v1/workbench/flows/:id/runs/:runId/nodes",
|
|
@@ -5178,9 +5182,11 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5178
5182
|
},
|
|
5179
5183
|
readRoutes: {
|
|
5180
5184
|
listFlows: "/api/v1/workbench/flows",
|
|
5185
|
+
flowDetail: "/api/v1/workbench/flows/:id",
|
|
5181
5186
|
flowById: "/api/v1/workbench/flows/:id",
|
|
5182
5187
|
flowBySlug: "/api/v1/workbench/flows/by-slug/:slug",
|
|
5183
5188
|
publishedOutput: "/api/v1/workbench/flows/:id/output",
|
|
5189
|
+
runHistory: "/api/v1/workbench/flows/:id/runs",
|
|
5184
5190
|
runs: "/api/v1/workbench/flows/:id/runs",
|
|
5185
5191
|
runDetail: "/api/v1/workbench/flows/:id/runs/:runId",
|
|
5186
5192
|
runNodes: "/api/v1/workbench/flows/:id/runs/:runId/nodes",
|
|
@@ -5199,6 +5205,7 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5199
5205
|
notes: [
|
|
5200
5206
|
"Workbench is a dedicated execution surface, not a batch CRUD entity family.",
|
|
5201
5207
|
"Route-selection questions are internal. User-facing questions should ask whether the user needs the saved flow, its input contract, one run, one node, or the public result instead of reciting Workbench route keys.",
|
|
5208
|
+
"`flowDetail` is the plain saved-flow detail route-key alias for `flowById`, and `runHistory` is the plain run-history route-key alias for `runs`. Keep the older keys valid for existing agents, but prefer the clearer aliases in new examples and guidance.",
|
|
5202
5209
|
"Use the flow routes when the agent needs stable public input contracts, published outputs, node-level results, or reusable execution history.",
|
|
5203
5210
|
"If the user is still figuring out inputs or editable structure, read flow detail or box catalog before asking them to reconstruct structured inputs from memory.",
|
|
5204
5211
|
"For flow creation, clarify what the flow should reliably produce, which input contract it should accept, and which first node or box anchors the flow before asking for structured input details.",
|
|
@@ -5305,6 +5312,25 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5305
5312
|
"Use a distinct actor label such as Albert (codex) so Codex-originated work stays readable in Forge history.",
|
|
5306
5313
|
"The Forge MCP bridge now self-registers as a live agent session and heartbeats while the MCP server process stays alive."
|
|
5307
5314
|
]
|
|
5315
|
+
},
|
|
5316
|
+
claude: {
|
|
5317
|
+
label: "Claude Code",
|
|
5318
|
+
installSteps: [
|
|
5319
|
+
"Install Claude Code, then run npx forge-memory configure and select Claude Code.",
|
|
5320
|
+
"Keep Claude pointed at the same Forge origin, port, and shared data root used by the rest of the local runtime.",
|
|
5321
|
+
"Restart the Claude Code session after MCP configuration changes so the Forge MCP server starts cleanly."
|
|
5322
|
+
],
|
|
5323
|
+
verifyCommands: [
|
|
5324
|
+
"claude mcp list",
|
|
5325
|
+
"claude mcp get forge",
|
|
5326
|
+
"claude",
|
|
5327
|
+
`curl -s ${origin}/api/v1/health`
|
|
5328
|
+
],
|
|
5329
|
+
configNotes: [
|
|
5330
|
+
"Forge Memory writes one user-scope Claude MCP server named forge in ~/.claude.json.",
|
|
5331
|
+
"The Claude MCP server command is npx forge-memory mcp, so Claude shares the same Forge runtime and data root as OpenClaw, Hermes, and Codex.",
|
|
5332
|
+
"Use a distinct actor label such as Albert (claude) so Claude-originated work stays readable in Forge history."
|
|
5333
|
+
]
|
|
5308
5334
|
}
|
|
5309
5335
|
},
|
|
5310
5336
|
verificationPaths: {
|
|
@@ -5340,8 +5366,10 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5340
5366
|
movementTripPointDelete: "/api/v1/movement/trips/:id/points/:pointId",
|
|
5341
5367
|
workbenchBoxCatalog: "/api/v1/workbench/catalog/boxes",
|
|
5342
5368
|
workbenchFlows: "/api/v1/workbench/flows",
|
|
5369
|
+
workbenchFlowDetail: "/api/v1/workbench/flows/:id",
|
|
5343
5370
|
workbenchFlowBySlug: "/api/v1/workbench/flows/by-slug/:slug",
|
|
5344
5371
|
workbenchPublishedOutput: "/api/v1/workbench/flows/:id/output",
|
|
5372
|
+
workbenchRunHistory: "/api/v1/workbench/flows/:id/runs",
|
|
5345
5373
|
workbenchRuns: "/api/v1/workbench/flows/:id/runs",
|
|
5346
5374
|
workbenchRunDetail: "/api/v1/workbench/flows/:id/runs/:runId",
|
|
5347
5375
|
workbenchNodeResult: "/api/v1/workbench/flows/:id/runs/:runId/nodes/:nodeId",
|
|
@@ -5461,12 +5489,12 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5461
5489
|
reviewShortcutRule: "When the user is reviewing or correcting an existing record, ask what practical question they want the read or correction to answer, then narrow the saved object, timeframe, or route family first. Use the correct read posture before asking write-shaped questions: shared batch search or read hints for normal entities, wiki/calendar dedicated reads for specialized CRUD, read-model routes for overviews, and Movement, Life Force, or Workbench dedicated reads for those domain surfaces. After the read, answer the practical question before asking for any save, correction, link, run, enrichment, or publish detail. Do not reopen the whole intake unless the user is actually redefining the record.",
|
|
5462
5490
|
readModelWriteRule: "Self-observation is note-backed and should be written through observed notes with frontmatter.observedAt only when a lightweight episode observation is the right container. Do not use it as the default bucket for Psyche material: prefer trigger_report for one emotionally meaningful episode, behavior_pattern for functional analysis of a recurring loop, behavior for one repeated move, belief_entry for a core sentence, mode_guide_session or mode_profile for a central part-state, and wiki_page for durable memory such as books, articles, concepts, sources, or personal manuals. Sleep and workout sessions stay on batch CRUD by default; use the reflective review helpers only when enriching one already-known record after review.",
|
|
5463
5491
|
psycheOpeningQuestionRule: "Prefer a concrete opening question tied to the entity: ask when the value mattered, what happened the last time the pattern appeared, what cue or body signal came first before the behavior, what the belief starts saying about self or outcome, what feels most at risk inside the mode, what the part is trying to get the user to do or stop doing, or where the shift began in the incident. Reflect briefly before the question, choose one follow-up lane at a time, say what is becoming clearer before the next deeper question, and if several Psyche entities are visible hold the adjacent ones lightly until the main container is clear.",
|
|
5464
|
-
psycheHypothesisRule: "When one concrete Psyche example is visible, a helpful hypothesis should start from evidence in the user's own example, offer one testable interpretation, name the function without blame such as protection, prediction, relief, or cost, and ask whether the danger, need, or wording fits. Use the hypothesis timing checkpoint before asking a second or third deepening question: offer a hypothesis when one concrete episode, body cue, belief sentence, behavior, or mode voice is visible and the hypothesis would change the record shape, wording, links, or next action. Do not keep asking broad exploratory Psyche questions after the cue, meaning, protection, payoff, or cost is already visible. For behavior_pattern, belief_entry, mode_profile, mode_guide_session, and trigger_report, the next helpful move is usually one active formulation plus one correction question, not another passive reflection. Do not hypothesize yet when no concrete moment is visible, the user only wants a direct mechanical save, the user is flooded or unsafe, or the only available interpretation would be diagnosis-like, an origin story, or a certainty claim. Do not present schema, mode, belief, or pattern language as a verdict. If the user corrects the hypothesis, revise it once and move toward the saveable record shape instead of asking for another broad story.",
|
|
5492
|
+
psycheHypothesisRule: "When one concrete Psyche example is visible, a helpful hypothesis should start from evidence in the user's own example, offer one testable interpretation, name the function without blame such as protection, prediction, relief, or cost, and ask whether the danger, need, or wording fits. Use the hypothesis timing checkpoint before asking a second or third deepening question: offer a hypothesis when one concrete episode, body cue, belief sentence, behavior, or mode voice is visible and the hypothesis would change the record shape, wording, links, or next action. Do not keep asking broad exploratory Psyche questions after the cue, meaning, protection, payoff, or cost is already visible. For behavior_pattern, belief_entry, mode_profile, mode_guide_session, and trigger_report, the next helpful move is usually one active formulation plus one correction question, not another passive reflection. Hypotheses should reduce the formulation burden. Do not make the user prove the experience: after one hypothesis, ask one fit-or-correction question rather than a stack of evidence, origin, and repair questions. If accuracy needs grounding, ask for the smallest lived cue or contrast that would change the wording, danger, protection, payoff, cost, or record shape. Do not hypothesize yet when no concrete moment is visible, the user only wants a direct mechanical save, the user is flooded or unsafe, or the only available interpretation would be diagnosis-like, an origin story, or a certainty claim. Do not present schema, mode, belief, or pattern language as a verdict. If the user corrects the hypothesis, revise it once and move toward the saveable record shape instead of asking for another broad story.",
|
|
5465
5493
|
mixedIntentSequencingRule: "When one user message combines several Forge jobs, identify the primary job and the order of operations before asking a follow-up. If a read changes the truth of a later write, read first: Movement timeline or box detail before correction, Workbench run or node detail before editing or publishing, and Life Force overview before changing durable assumptions when the current energy picture is uncertain. If the user asks to understand and save Psyche material plus create a support record, formulate the primary Psyche record first, then derive the flashcard, note, link, task, or habit from the accepted wording. If the user already gave the concrete action, do not ask a broad lane question; say the product sequence briefly and ask only for the missing span, wording, flow, run, node, weekday, or link that changes the next action.",
|
|
5466
5494
|
duplicateDisambiguationRule: "Before creating or updating a normal stored entity when duplicate risk is plausible, search the shared batch entity route by entity type, distinctive title or wording, owner scope, and linked content. If a likely existing record appears, ask whether the user wants to update that record, link to it, or save a separate new record; do not reopen the whole create flow. For Psyche records, a similar belief, pattern, mode, trigger report, value, or flashcard is a formulation choice, not a duplicate error: compare the sentence, cue/payoff/cost, protective job, episode, urge sentence, or message and let the user choose update, link, or new version. For wiki_page and calendar_connection, use dedicated search/list/read routes before creating another page or connection. For Movement, Life Force, and Workbench, use the dedicated read lanes instead of batch duplicate search.",
|
|
5467
5495
|
destructiveActionRule: "Before deleting, archiving, invalidating, overwriting, disconnecting, or substantially replacing a Forge record or specialized object, confirm the exact target and what should remain understandable. Prefer normal soft-delete for stored entities unless the user explicitly asks for permanent removal. For Psyche records, preserve therapeutic history by asking whether the old belief, pattern, mode, trigger report, value, or flashcard should be updated, linked as history, archived, or kept distinct; do not delete it just because a cleaner formulation exists. For Movement, distinguish user-defined overlay deletion from automatic-box invalidation and stay/trip/point deletion, and read the specific span first when the target is uncertain. For calendar connections, Workbench flows, wiki pages, and questionnaire instruments, ask what downstream sync, published output, backlinks, run history, or completed runs should remain understandable before deleting or replacing the saved object.",
|
|
5468
5496
|
followUpQuestionRule: "After a substantive answer, do not restart the opener or jump to the next schema field. First say what became clearer in concrete language, then choose exactly one next lane: wording, boundary, placement, timing, route scope, link, hypothesis, or write confirmation. Ask the smallest question that would change the record shape, route choice, useful wording, timing, or links. If nothing decision-relevant would change, stop asking, summarize the working record, and act with consent.",
|
|
5469
|
-
antiDriftRule: "Avoid vague reflective filler and internal route language. Replace phrases like 'that sounds important' with the specific stake you heard, and replace API nouns like surface, CRUD, payload, mutation path, or endpoint with user-facing product nouns such as belief, pattern, note, wiki page, timeline, overlay, weekday template, flow, run, node result, or published output. If a question would only decorate the intake, skip it.",
|
|
5497
|
+
antiDriftRule: "Avoid vague reflective filler and internal route language. Keep a private action trace: intent, entity or dedicated domain lane, exact read/write/run tool, required target identifiers, and the one missing detail that would change the action. Do not narrate that trace to the user. Replace phrases like 'that sounds important' with the specific stake you heard, and replace API nouns like surface, CRUD, payload, mutation path, route key, or endpoint with user-facing product nouns such as belief, pattern, note, wiki page, timeline, overlay, missing stay, weekday template, flow, run, node result, or published output. Ask one product-language question when the trace is unclear; with the user, ask about the real thing: the span, place, weekday, flow, run, node, belief sentence, parent record, or save confirmation. When reporting actions, say the product result first: saved the belief, corrected the missing stay, updated the weekday energy pattern, or read the failed node. Mention route keys, HTTP paths, payloads, or batch routes only for implementation debugging. If a question would only decorate the intake, skip it.",
|
|
5470
5498
|
duplicateCheckRoute: "/api/v1/entities/search",
|
|
5471
5499
|
uiSuggestionRule: "offer_visual_ui_when_review_or_editing_would_be_easier",
|
|
5472
5500
|
browserFallbackRule: "Do not open the Forge UI or a browser just to create or update normal entities when the batch entity tools can do the job. Batch CRUD is the default for simple entities; avoid spamming the agent with a large one-route-per-entity mental model.",
|
|
@@ -5510,10 +5538,12 @@ function buildAgentOnboardingPayload(request) {
|
|
|
5510
5538
|
lifeForceWeekdayTemplate: '{"routeKey":"weekdayTemplate","pathParams":{"weekday":"monday"},"body":{"points":[{"hour":13,"freeAp":-4}]}}',
|
|
5511
5539
|
lifeForceFatigueSignal: '{"routeKey":"fatigueSignal","body":{"signal":"tired","intensity":7,"note":"Sharp post-lunch dip after clinic admin."}}',
|
|
5512
5540
|
workbenchFlowCatalog: '{"routeKey":"listFlows","query":{"includeArchived":false}}',
|
|
5541
|
+
workbenchFlowDetail: '{"routeKey":"flowDetail","pathParams":{"id":"flow_research_digest"}}',
|
|
5513
5542
|
workbenchBoxCatalog: '{"routeKey":"boxCatalog"}',
|
|
5514
5543
|
workbenchCreateFlow: '{"routeKey":"createFlow","body":{"title":"Research digest","slug":"research-digest","description":"Turn a topic into a cited digest with a stable published summary.","nodes":[],"edges":[]}}',
|
|
5515
5544
|
workbenchUpdateFlow: '{"routeKey":"updateFlow","pathParams":{"id":"flow_research_digest"},"body":{"description":"Keep the same input contract but add a stronger evidence-check node."}}',
|
|
5516
5545
|
workbenchDeleteFlow: '{"routeKey":"deleteFlow","pathParams":{"id":"flow_research_digest"}}',
|
|
5546
|
+
workbenchRunHistory: '{"routeKey":"runHistory","pathParams":{"id":"flow_research_digest"},"query":{"limit":10}}',
|
|
5517
5547
|
workbenchRunDetail: '{"routeKey":"runDetail","pathParams":{"id":"flow_research_digest","runId":"run_123"}}',
|
|
5518
5548
|
workbenchRunNodes: '{"routeKey":"runNodes","pathParams":{"id":"flow_research_digest","runId":"run_123"}}',
|
|
5519
5549
|
workbenchNodeResult: '{"routeKey":"nodeResult","pathParams":{"id":"flow_research_digest","runId":"run_123","nodeId":"node_summary"}}',
|
|
@@ -2380,7 +2380,7 @@ export function buildOpenApiDocument() {
|
|
|
2380
2380
|
identityKey: nullable({ type: "string" }),
|
|
2381
2381
|
provider: nullable({
|
|
2382
2382
|
type: "string",
|
|
2383
|
-
enum: ["openclaw", "hermes", "codex"]
|
|
2383
|
+
enum: ["openclaw", "hermes", "codex", "claude"]
|
|
2384
2384
|
}),
|
|
2385
2385
|
machineKey: nullable({ type: "string" }),
|
|
2386
2386
|
personaKey: nullable({ type: "string" }),
|
|
@@ -2488,7 +2488,10 @@ export function buildOpenApiDocument() {
|
|
|
2488
2488
|
agentId: nullable({ type: "string" }),
|
|
2489
2489
|
agentLabel: { type: "string" },
|
|
2490
2490
|
agentType: { type: "string" },
|
|
2491
|
-
provider: {
|
|
2491
|
+
provider: {
|
|
2492
|
+
type: "string",
|
|
2493
|
+
enum: ["openclaw", "hermes", "codex", "claude"]
|
|
2494
|
+
},
|
|
2492
2495
|
sessionKey: { type: "string" },
|
|
2493
2496
|
sessionLabel: { type: "string" },
|
|
2494
2497
|
actorLabel: { type: "string" },
|
|
@@ -49,6 +49,21 @@ function toReconnectPlan(row) {
|
|
|
49
49
|
automationSupported: false
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
|
+
if (row.provider === "claude") {
|
|
53
|
+
return agentRuntimeReconnectPlanSchema.parse({
|
|
54
|
+
summary: "Restart or resume Claude Code so the Forge MCP server starts again and reconnects to the shared Forge runtime.",
|
|
55
|
+
commands: [
|
|
56
|
+
"claude mcp list",
|
|
57
|
+
"claude mcp get forge",
|
|
58
|
+
`curl -s ${forgeBaseUrl}/api/v1/health`
|
|
59
|
+
],
|
|
60
|
+
notes: [
|
|
61
|
+
"Forge Memory configures Claude through a user-scope MCP server named forge that runs npx forge-memory mcp.",
|
|
62
|
+
"If Forge is local, keep Claude pointed at the same Forge origin, port, and shared data root."
|
|
63
|
+
],
|
|
64
|
+
automationSupported: false
|
|
65
|
+
});
|
|
66
|
+
}
|
|
52
67
|
return agentRuntimeReconnectPlanSchema.parse({
|
|
53
68
|
summary: "Restart or resume the Codex session so the Forge MCP bridge launches again and re-registers with Forge.",
|
|
54
69
|
commands: [
|
|
@@ -229,6 +244,9 @@ function canonicalRuntimeAgentLabel(provider) {
|
|
|
229
244
|
if (provider === "hermes") {
|
|
230
245
|
return "Forge Hermes";
|
|
231
246
|
}
|
|
247
|
+
if (provider === "claude") {
|
|
248
|
+
return "Forge Claude Code";
|
|
249
|
+
}
|
|
232
250
|
return "Forge Codex";
|
|
233
251
|
}
|
|
234
252
|
function canonicalRuntimeDescription(provider) {
|
|
@@ -253,6 +271,15 @@ function canonicalAgentUserSpec(provider) {
|
|
|
253
271
|
accentColor: "#a78bfa"
|
|
254
272
|
};
|
|
255
273
|
}
|
|
274
|
+
if (provider === "claude") {
|
|
275
|
+
return {
|
|
276
|
+
id: "user_agent_claude",
|
|
277
|
+
handle: "claude",
|
|
278
|
+
displayName: "Claude Code",
|
|
279
|
+
description: "Claude Code runtime actor linked to Forge agent identity and Kanban ownership.",
|
|
280
|
+
accentColor: "#f97316"
|
|
281
|
+
};
|
|
282
|
+
}
|
|
256
283
|
return {
|
|
257
284
|
id: "user_agent_codex",
|
|
258
285
|
handle: "codex",
|
|
@@ -415,7 +415,8 @@ function runtimeProviderFromAgentType(agentType) {
|
|
|
415
415
|
const normalized = normalizeAgentIdentityPart(agentType);
|
|
416
416
|
if (normalized === "openclaw" ||
|
|
417
417
|
normalized === "hermes" ||
|
|
418
|
-
normalized === "codex"
|
|
418
|
+
normalized === "codex" ||
|
|
419
|
+
normalized === "claude") {
|
|
419
420
|
return normalized;
|
|
420
421
|
}
|
|
421
422
|
return null;
|
|
@@ -23,6 +23,16 @@ export async function buildCompanionPairingTransport(input) {
|
|
|
23
23
|
"Manual HTTP/TCP pairing was explicitly requested."
|
|
24
24
|
]);
|
|
25
25
|
}
|
|
26
|
+
if (selectedFallback) {
|
|
27
|
+
const fallbackMode = fallbackModeFor(selectedFallback.apiBaseUrl, input.fallbackMode);
|
|
28
|
+
return manualHttpTransport(selectedFallback.apiBaseUrl, selectedFallback.uiBaseUrl, [
|
|
29
|
+
fallbackMode === "tailscale"
|
|
30
|
+
? "Tailscale HTTPS pairing was selected as the primary companion transport."
|
|
31
|
+
: "A phone-reachable direct companion URL was selected as the primary transport.",
|
|
32
|
+
"Forge did not include Iroh transport metadata because direct pairing is active.",
|
|
33
|
+
"Run pairing again without a phone-facing public URL if you want an Iroh pairing instead."
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
26
36
|
if (!shouldAutoStartIrohHost()) {
|
|
27
37
|
return manualHttpTransport(requestApiBaseUrl, requestUiBaseUrl, [
|
|
28
38
|
"Forge Iroh companion transport is unavailable in this runtime, so Forge fell back to direct HTTP."
|
|
@@ -34,18 +44,14 @@ export async function buildCompanionPairingTransport(input) {
|
|
|
34
44
|
pairPayload: snapshot.pairPayload,
|
|
35
45
|
alpn: snapshot.alpn ?? COMPANION_IROH_ALPN,
|
|
36
46
|
localBaseUrl: snapshot.localBaseUrl,
|
|
37
|
-
fallbackApiBaseUrl:
|
|
38
|
-
fallbackUiBaseUrl:
|
|
39
|
-
fallbackMode:
|
|
40
|
-
? fallbackModeFor(selectedFallback.apiBaseUrl, input.fallbackMode)
|
|
41
|
-
: "none",
|
|
47
|
+
fallbackApiBaseUrl: null,
|
|
48
|
+
fallbackUiBaseUrl: null,
|
|
49
|
+
fallbackMode: "none",
|
|
42
50
|
recreateCommand: snapshot.recreateCommand ?? undefined,
|
|
43
51
|
startedAt: snapshot.startedAt ?? undefined,
|
|
44
52
|
notes: [
|
|
45
53
|
"Default pairing uses Forge's Rust Iroh transport over QUIC first.",
|
|
46
|
-
|
|
47
|
-
? "The QR includes the selected direct URL only as an explicit fallback/direct path."
|
|
48
|
-
: "No direct HTTP fallback was selected for this QR.",
|
|
54
|
+
"No direct HTTP fallback was selected for this QR.",
|
|
49
55
|
"The QR payload carries the Iroh node id, host token, optional relay, and ALPN forge-companion/1.",
|
|
50
56
|
"Manual HTTP/TCP pairing remains available with --manual-http for advanced local setups."
|
|
51
57
|
]
|
|
@@ -289,7 +289,8 @@ export const defaultAgentScopePolicy = {
|
|
|
289
289
|
export const agentRuntimeProviderSchema = z.enum([
|
|
290
290
|
"openclaw",
|
|
291
291
|
"hermes",
|
|
292
|
-
"codex"
|
|
292
|
+
"codex",
|
|
293
|
+
"claude"
|
|
293
294
|
]);
|
|
294
295
|
export const agentRuntimeConnectionModeSchema = z.enum([
|
|
295
296
|
"operator_session",
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "forge-openclaw-plugin",
|
|
3
3
|
"name": "Forge",
|
|
4
4
|
"description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.12",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onStartup": true,
|
|
8
8
|
"onCapabilities": [
|
package/package.json
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
INSERT OR IGNORE INTO users (
|
|
2
|
+
id, kind, handle, display_name, description, accent_color, created_at, updated_at
|
|
3
|
+
) VALUES (
|
|
4
|
+
'user_agent_claude',
|
|
5
|
+
'bot',
|
|
6
|
+
'claude',
|
|
7
|
+
'Claude Code',
|
|
8
|
+
'Claude Code runtime actor linked to Forge agent identity and Kanban ownership.',
|
|
9
|
+
'#f97316',
|
|
10
|
+
datetime('now'),
|
|
11
|
+
datetime('now')
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
UPDATE users
|
|
15
|
+
SET kind = 'bot',
|
|
16
|
+
handle = 'claude',
|
|
17
|
+
display_name = 'Claude Code',
|
|
18
|
+
description = 'Claude Code runtime actor linked to Forge agent identity and Kanban ownership.',
|
|
19
|
+
accent_color = '#f97316',
|
|
20
|
+
updated_at = datetime('now')
|
|
21
|
+
WHERE id = 'user_agent_claude';
|
|
22
|
+
|
|
23
|
+
UPDATE agent_identities
|
|
24
|
+
SET label = 'Forge Claude Code',
|
|
25
|
+
agent_type = 'claude',
|
|
26
|
+
provider = 'claude',
|
|
27
|
+
identity_key = COALESCE(identity_key, 'runtime:claude:legacy:default'),
|
|
28
|
+
machine_key = COALESCE(machine_key, 'legacy'),
|
|
29
|
+
persona_key = COALESCE(persona_key, 'default'),
|
|
30
|
+
description = 'Forge Claude Code runtime agent with stable Forge identity and linked Kanban user.',
|
|
31
|
+
updated_at = datetime('now')
|
|
32
|
+
WHERE lower(agent_type) = 'claude'
|
|
33
|
+
OR lower(label) IN ('forge claude', 'forge claude code', 'claude', 'claude code');
|
|
34
|
+
|
|
35
|
+
INSERT OR IGNORE INTO agent_identity_users (
|
|
36
|
+
agent_id, user_id, role, created_at, updated_at
|
|
37
|
+
)
|
|
38
|
+
SELECT id, 'user_agent_claude', 'primary', datetime('now'), datetime('now')
|
|
39
|
+
FROM agent_identities
|
|
40
|
+
WHERE provider = 'claude';
|
|
41
|
+
|
|
42
|
+
UPDATE agent_runtime_sessions
|
|
43
|
+
SET agent_label = 'Forge Claude Code',
|
|
44
|
+
agent_type = 'claude',
|
|
45
|
+
provider = 'claude',
|
|
46
|
+
updated_at = datetime('now')
|
|
47
|
+
WHERE provider = 'claude'
|
|
48
|
+
OR lower(agent_type) = 'claude'
|
|
49
|
+
OR lower(agent_label) IN ('forge claude', 'forge claude code', 'claude', 'claude code');
|
|
@@ -70,6 +70,14 @@ stored-entity CRUD, an action workflow, specialized CRUD, or a specialized domai
|
|
|
70
70
|
surface. Name the path plainly enough that another agent could follow it without
|
|
71
71
|
guessing.
|
|
72
72
|
|
|
73
|
+
Keep that route plan internal unless the user asks for implementation detail. Track
|
|
74
|
+
the intent, entity or dedicated domain lane, exact tool or route key, target
|
|
75
|
+
identifiers, and one missing detail privately; with the user, ask about the real
|
|
76
|
+
thing: the span, place, weekday, flow, run, node, belief sentence, parent record, or
|
|
77
|
+
save confirmation. Report product actions such as "saved the belief", "corrected the
|
|
78
|
+
missing stay", "updated the weekday energy pattern", or "read the failed node" before
|
|
79
|
+
any route-key or endpoint detail.
|
|
80
|
+
|
|
73
81
|
- Batch CRUD is the default for normal stored entities, including `goal`, `project`,
|
|
74
82
|
`strategy`, `task`, `habit`, `tag`, `note`, `insight`, `calendar_event`,
|
|
75
83
|
`work_block_template`, `task_timebox`, all main Psyche records, basic Preferences
|
|
@@ -137,6 +145,8 @@ Concrete route-key examples for internal use:
|
|
|
137
145
|
`{"routeKey":"fatigueSignal","body":{"signal":"tired","intensity":7,"note":"Sharp post-lunch dip after clinic admin."}}`
|
|
138
146
|
- Workbench flow catalog:
|
|
139
147
|
`{"routeKey":"listFlows","query":{"includeArchived":false}}`
|
|
148
|
+
- Workbench flow detail:
|
|
149
|
+
`{"routeKey":"flowDetail","pathParams":{"id":"flow_research_digest"}}`
|
|
140
150
|
- Workbench box catalog:
|
|
141
151
|
`{"routeKey":"boxCatalog"}`
|
|
142
152
|
- Workbench flow creation:
|
|
@@ -145,6 +155,8 @@ Concrete route-key examples for internal use:
|
|
|
145
155
|
`{"routeKey":"updateFlow","pathParams":{"id":"flow_research_digest"},"body":{"description":"Keep the same input contract but add a stronger evidence-check node."}}`
|
|
146
156
|
- Workbench flow deletion:
|
|
147
157
|
`{"routeKey":"deleteFlow","pathParams":{"id":"flow_research_digest"}}`
|
|
158
|
+
- Workbench run history:
|
|
159
|
+
`{"routeKey":"runHistory","pathParams":{"id":"flow_research_digest"},"query":{"limit":10}}`
|
|
148
160
|
- Workbench run detail:
|
|
149
161
|
`{"routeKey":"runDetail","pathParams":{"id":"flow_research_digest","runId":"run_123"}}`
|
|
150
162
|
- Workbench run nodes:
|
|
@@ -321,6 +333,7 @@ Psyche interview rule:
|
|
|
321
333
|
- After the first real answer, choose one follow-up lane at a time: situation, sequence, meaning, protection, cost, longing/value, or tentative name.
|
|
322
334
|
- Do not minimize functional analysis, trigger chains, behavior patterns, modes, beliefs, or schema themes. Once at least one concrete example is clear, offer one careful interpretive hypothesis when it would help the user understand the function, protection, cost, belief, mode, or schema theme.
|
|
323
335
|
- Phrase interpretive hypotheses as collaborative and testable, not as verdicts. A good hypothesis says what the reaction may be protecting, predicting, relieving, or costing, then asks whether that lands or needs correction.
|
|
336
|
+
- For Psyche hypotheses, reduce the formulation burden. After one concrete example, offer one tentative function, danger, protection, payoff, or cost hypothesis and ask one fit-or-correction question. Do not make the user prove the experience, list evidence, or design repair before the wording feels held.
|
|
324
337
|
- Do not keep asking broad exploratory Psyche questions after the cue, meaning, protection, payoff, or cost is already visible. For `behavior_pattern`, `belief_entry`, `mode_profile`, `mode_guide_session`, and `trigger_report`, the next helpful move is usually one active formulation plus one correction question, not another passive reflection.
|
|
325
338
|
- Use the hypothesis timing checkpoint before asking a second or third deepening question: offer a hypothesis when one concrete episode, body cue, belief sentence, behavior, or mode voice is visible and the hypothesis would change the record shape, wording, links, or next action. Do not hypothesize yet when no concrete moment is visible, the user only wants a direct mechanical save, the user is flooded or unsafe, or the only available interpretation would be diagnosis-like, an origin story, or a certainty claim.
|
|
326
339
|
- If several Psyche containers are plausible, do not ask the user to choose from a taxonomy menu first. Reflect the lived difference, offer one careful hypothesis when a concrete example is visible, then distinguish the options in plain language: one episode as a `trigger_report`, a recurring loop as a `behavior_pattern`, one repeated move as `behavior`, one sentence as `belief_entry`, a part-state as `mode_profile` or `mode_guide_session`, or reusable future-labeling as `event_type` or `emotion_definition`.
|
|
@@ -124,6 +124,24 @@ With the user, say the human thing:
|
|
|
124
124
|
The API path still matters, but it should not leak into the question unless the user
|
|
125
125
|
is explicitly asking about implementation.
|
|
126
126
|
|
|
127
|
+
## Internal action trace, external wording
|
|
128
|
+
|
|
129
|
+
Before you ask or act, keep a private action trace: intent, entity or dedicated
|
|
130
|
+
domain lane, exact read/write/run tool, required target identifiers, and the one
|
|
131
|
+
missing detail that would change the action. Do not narrate that trace to the user.
|
|
132
|
+
|
|
133
|
+
- If the trace is clear, ask the user only for the missing real-world detail:
|
|
134
|
+
which span, place, weekday, flow, run, node, belief sentence, parent record, or
|
|
135
|
+
save confirmation.
|
|
136
|
+
- If the trace is not clear, ask one product-language question that resolves it
|
|
137
|
+
instead of presenting API options.
|
|
138
|
+
- When you report what you did, say the product action first: saved the belief,
|
|
139
|
+
corrected the missing stay, updated the weekday energy pattern, read the failed
|
|
140
|
+
node, or published the flow output. Mention route keys, HTTP paths, payloads, or
|
|
141
|
+
batch routes only for implementation debugging.
|
|
142
|
+
- This is especially important after mixed-intent requests. The user should feel a
|
|
143
|
+
coherent sequence, not see your internal routing table.
|
|
144
|
+
|
|
127
145
|
## Dedicated surface lane translation
|
|
128
146
|
|
|
129
147
|
Use this when Movement, Life Force, or Workbench work needs a route choice. The route
|
|
@@ -2203,6 +2221,8 @@ Lane-to-route map:
|
|
|
2203
2221
|
|
|
2204
2222
|
- discover or inspect flows:
|
|
2205
2223
|
`/api/v1/workbench/flows`, `/api/v1/workbench/flows/:id`, or `/api/v1/workbench/flows/by-slug/:slug`
|
|
2224
|
+
Use route key `flowDetail` for saved-flow detail by id; `flowById` remains valid
|
|
2225
|
+
for older agents.
|
|
2206
2226
|
- create, update, or delete a flow:
|
|
2207
2227
|
`POST /api/v1/workbench/flows`, then `PATCH /api/v1/workbench/flows/:id` or
|
|
2208
2228
|
`DELETE /api/v1/workbench/flows/:id` for an existing saved flow
|
|
@@ -2214,6 +2234,8 @@ Lane-to-route map:
|
|
|
2214
2234
|
`POST /api/v1/workbench/flows/:id/chat`
|
|
2215
2235
|
- inspect published output or run history:
|
|
2216
2236
|
`/api/v1/workbench/flows/:id/output` or `/api/v1/workbench/flows/:id/runs`
|
|
2237
|
+
Use route key `runHistory` for the run-history read; `runs` remains valid for
|
|
2238
|
+
older agents.
|
|
2217
2239
|
- inspect one run or node result:
|
|
2218
2240
|
`/api/v1/workbench/flows/:id/runs/:runId`,
|
|
2219
2241
|
`/api/v1/workbench/flows/:id/runs/:runId/nodes`,
|
|
@@ -256,6 +256,28 @@ example makes the hypothesis plausible, then let the user correct the danger,
|
|
|
256
256
|
protection, payoff, cost, or wording. If the correction is usable, revise once and move
|
|
257
257
|
toward the record shape instead of asking for another broad story.
|
|
258
258
|
|
|
259
|
+
## Hypothesis Without Cross-Examination
|
|
260
|
+
|
|
261
|
+
A hypothesis is meant to reduce the user's burden of formulation, not make them prove
|
|
262
|
+
the experience. It should help the user feel, "yes, that is closer to what is
|
|
263
|
+
happening," or "no, the danger is actually over here."
|
|
264
|
+
|
|
265
|
+
- Do not turn a tentative formulation into a demand for evidence. Avoid following a
|
|
266
|
+
hypothesis with a stack of questions about evidence, origin, and repair.
|
|
267
|
+
- After a hypothesis, ask one fit-or-correction question such as, "Does that fit, or
|
|
268
|
+
is the danger/need somewhere else?"
|
|
269
|
+
- If accuracy still needs grounding, ask for the smallest lived cue or contrast that
|
|
270
|
+
would change the formulation, not a courtroom-style proof.
|
|
271
|
+
- For `belief_entry`, test the sentence, prediction, or danger it names.
|
|
272
|
+
- For `behavior_pattern`, test the cue, protection, short-term payoff, long-term
|
|
273
|
+
cost, or replacement need.
|
|
274
|
+
- For `mode_profile` and `mode_guide_session`, test the part's job, fear, burden, or
|
|
275
|
+
impulse.
|
|
276
|
+
- For `trigger_report`, test the sequence, meaning, felt stake, or consequence.
|
|
277
|
+
- For `psyche_value`, `event_type`, and `emotion_definition`, test whether the wording
|
|
278
|
+
would help future recognition rather than asking the user to justify why the record
|
|
279
|
+
matters.
|
|
280
|
+
|
|
259
281
|
## Hypothesis To Record Bridge
|
|
260
282
|
|
|
261
283
|
Once a hypothesis lands or is corrected, turn it into a saveable Forge shape instead
|