@masyv/relay 0.2.0 → 0.4.0
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/core/src/agents/aider.rs +75 -0
- package/core/src/agents/claude.rs +68 -0
- package/core/src/agents/codex.rs +12 -18
- package/core/src/agents/copilot.rs +63 -0
- package/core/src/agents/mod.rs +12 -4
- package/core/src/agents/ollama.rs +16 -39
- package/core/src/agents/opencode.rs +66 -0
- package/core/src/capture/session.rs +9 -5
- package/core/src/handoff/mod.rs +24 -13
- package/core/src/lib.rs +4 -0
- package/core/src/main.rs +29 -2
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//! Aider agent adapter — launches the aider TUI interactively.
|
|
2
|
+
//! https://github.com/paul-gauthier/aider
|
|
3
|
+
|
|
4
|
+
use super::Agent;
|
|
5
|
+
use crate::{AgentStatus, HandoffResult};
|
|
6
|
+
use anyhow::Result;
|
|
7
|
+
use std::process::Command;
|
|
8
|
+
|
|
9
|
+
pub struct AiderAgent {
|
|
10
|
+
model: String,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
impl AiderAgent {
|
|
14
|
+
pub fn new(model: Option<&str>) -> Self {
|
|
15
|
+
Self {
|
|
16
|
+
model: model.unwrap_or("sonnet").to_string(),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fn find_binary() -> Option<String> {
|
|
21
|
+
let output = Command::new("which").arg("aider").output().ok()?;
|
|
22
|
+
if output.status.success() {
|
|
23
|
+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
24
|
+
} else {
|
|
25
|
+
None
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl Agent for AiderAgent {
|
|
31
|
+
fn name(&self) -> &str { "aider" }
|
|
32
|
+
|
|
33
|
+
fn check_available(&self) -> AgentStatus {
|
|
34
|
+
match Self::find_binary() {
|
|
35
|
+
Some(path) => AgentStatus {
|
|
36
|
+
name: "aider".into(),
|
|
37
|
+
available: true,
|
|
38
|
+
reason: format!("Found at {path}"),
|
|
39
|
+
version: Command::new(&path).arg("--version").output().ok()
|
|
40
|
+
.filter(|o| o.status.success())
|
|
41
|
+
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()),
|
|
42
|
+
},
|
|
43
|
+
None => AgentStatus {
|
|
44
|
+
name: "aider".into(),
|
|
45
|
+
available: false,
|
|
46
|
+
reason: "Not found. Install: pip install aider-chat".into(),
|
|
47
|
+
version: None,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
|
|
53
|
+
let binary = Self::find_binary().unwrap_or("aider".into());
|
|
54
|
+
let tmp = std::env::temp_dir().join("relay_handoff.md");
|
|
55
|
+
std::fs::write(&tmp, handoff_prompt)?;
|
|
56
|
+
|
|
57
|
+
let status = Command::new(&binary)
|
|
58
|
+
.current_dir(project_dir)
|
|
59
|
+
.arg("--model")
|
|
60
|
+
.arg(&self.model)
|
|
61
|
+
.arg("--message")
|
|
62
|
+
.arg(handoff_prompt)
|
|
63
|
+
.stdin(std::process::Stdio::inherit())
|
|
64
|
+
.stdout(std::process::Stdio::inherit())
|
|
65
|
+
.stderr(std::process::Stdio::inherit())
|
|
66
|
+
.status()?;
|
|
67
|
+
|
|
68
|
+
Ok(HandoffResult {
|
|
69
|
+
agent: "aider".into(),
|
|
70
|
+
success: status.success(),
|
|
71
|
+
message: format!("Aider ({}) session ended", self.model),
|
|
72
|
+
handoff_file: Some(tmp.to_string_lossy().to_string()),
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
//! Claude CLI agent — starts a new Claude Code session with full context.
|
|
2
|
+
//! Useful when the rate limit resets or you have a second subscription.
|
|
3
|
+
|
|
4
|
+
use super::Agent;
|
|
5
|
+
use crate::{AgentStatus, HandoffResult};
|
|
6
|
+
use anyhow::Result;
|
|
7
|
+
use std::process::Command;
|
|
8
|
+
|
|
9
|
+
pub struct ClaudeAgent;
|
|
10
|
+
|
|
11
|
+
impl ClaudeAgent {
|
|
12
|
+
pub fn new() -> Self { Self }
|
|
13
|
+
|
|
14
|
+
fn find_binary() -> Option<String> {
|
|
15
|
+
let output = Command::new("which").arg("claude").output().ok()?;
|
|
16
|
+
if output.status.success() {
|
|
17
|
+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
18
|
+
} else {
|
|
19
|
+
None
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl Agent for ClaudeAgent {
|
|
25
|
+
fn name(&self) -> &str { "claude" }
|
|
26
|
+
|
|
27
|
+
fn check_available(&self) -> AgentStatus {
|
|
28
|
+
match Self::find_binary() {
|
|
29
|
+
Some(path) => AgentStatus {
|
|
30
|
+
name: "claude".into(),
|
|
31
|
+
available: true,
|
|
32
|
+
reason: format!("Found at {path}"),
|
|
33
|
+
version: Command::new(&path).arg("--version").output().ok()
|
|
34
|
+
.filter(|o| o.status.success())
|
|
35
|
+
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()),
|
|
36
|
+
},
|
|
37
|
+
None => AgentStatus {
|
|
38
|
+
name: "claude".into(),
|
|
39
|
+
available: false,
|
|
40
|
+
reason: "Not found. Install: npm install -g @anthropic-ai/claude-code".into(),
|
|
41
|
+
version: None,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
|
|
47
|
+
let binary = Self::find_binary().unwrap_or("claude".into());
|
|
48
|
+
let tmp = std::env::temp_dir().join("relay_handoff.md");
|
|
49
|
+
std::fs::write(&tmp, handoff_prompt)?;
|
|
50
|
+
|
|
51
|
+
// Launch Claude interactively with the handoff as initial prompt
|
|
52
|
+
let status = Command::new(&binary)
|
|
53
|
+
.current_dir(project_dir)
|
|
54
|
+
.arg("--resume")
|
|
55
|
+
.arg(handoff_prompt)
|
|
56
|
+
.stdin(std::process::Stdio::inherit())
|
|
57
|
+
.stdout(std::process::Stdio::inherit())
|
|
58
|
+
.stderr(std::process::Stdio::inherit())
|
|
59
|
+
.status()?;
|
|
60
|
+
|
|
61
|
+
Ok(HandoffResult {
|
|
62
|
+
agent: "claude".into(),
|
|
63
|
+
success: status.success(),
|
|
64
|
+
message: "Claude session ended".into(),
|
|
65
|
+
handoff_file: Some(tmp.to_string_lossy().to_string()),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
package/core/src/agents/codex.rs
CHANGED
|
@@ -69,33 +69,27 @@ impl Agent for CodexAgent {
|
|
|
69
69
|
let tmp = std::env::temp_dir().join("relay_handoff.md");
|
|
70
70
|
std::fs::write(&tmp, handoff_prompt)?;
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
|
|
72
|
+
// Launch Codex INTERACTIVELY with the handoff as the initial prompt.
|
|
73
|
+
// This opens the Codex TUI so the user can keep working with it.
|
|
74
|
+
// stdin/stdout/stderr are inherited so the user sees the Codex UI.
|
|
75
|
+
let status = Command::new(&binary)
|
|
74
76
|
.current_dir(project_dir)
|
|
75
|
-
.arg("exec")
|
|
76
77
|
.arg("--full-auto")
|
|
77
78
|
.arg("-m")
|
|
78
79
|
.arg(&self.model)
|
|
79
80
|
.arg(handoff_prompt)
|
|
80
|
-
.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if !stdout.is_empty() {
|
|
86
|
-
println!("{stdout}");
|
|
87
|
-
}
|
|
88
|
-
if !stderr.is_empty() {
|
|
89
|
-
eprintln!("{stderr}");
|
|
90
|
-
}
|
|
81
|
+
.stdin(std::process::Stdio::inherit())
|
|
82
|
+
.stdout(std::process::Stdio::inherit())
|
|
83
|
+
.stderr(std::process::Stdio::inherit())
|
|
84
|
+
.status()?;
|
|
91
85
|
|
|
92
86
|
Ok(HandoffResult {
|
|
93
87
|
agent: "codex".into(),
|
|
94
|
-
success:
|
|
95
|
-
message: if
|
|
96
|
-
format!("Codex ({})
|
|
88
|
+
success: status.success(),
|
|
89
|
+
message: if status.success() {
|
|
90
|
+
format!("Codex ({}) session ended", self.model)
|
|
97
91
|
} else {
|
|
98
|
-
format!("Codex exited with code {:?}",
|
|
92
|
+
format!("Codex exited with code {:?}", status.code())
|
|
99
93
|
},
|
|
100
94
|
handoff_file: Some(tmp.to_string_lossy().to_string()),
|
|
101
95
|
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//! GitHub Copilot CLI agent adapter.
|
|
2
|
+
|
|
3
|
+
use super::Agent;
|
|
4
|
+
use crate::{AgentStatus, HandoffResult};
|
|
5
|
+
use anyhow::Result;
|
|
6
|
+
use std::process::Command;
|
|
7
|
+
|
|
8
|
+
pub struct CopilotAgent;
|
|
9
|
+
|
|
10
|
+
impl CopilotAgent {
|
|
11
|
+
pub fn new() -> Self { Self }
|
|
12
|
+
|
|
13
|
+
fn find_binary() -> Option<String> {
|
|
14
|
+
let output = Command::new("which").arg("copilot").output().ok()?;
|
|
15
|
+
if output.status.success() {
|
|
16
|
+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
17
|
+
} else {
|
|
18
|
+
None
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl Agent for CopilotAgent {
|
|
24
|
+
fn name(&self) -> &str { "copilot" }
|
|
25
|
+
|
|
26
|
+
fn check_available(&self) -> AgentStatus {
|
|
27
|
+
match Self::find_binary() {
|
|
28
|
+
Some(path) => AgentStatus {
|
|
29
|
+
name: "copilot".into(),
|
|
30
|
+
available: true,
|
|
31
|
+
reason: format!("Found at {path}"),
|
|
32
|
+
version: None, // copilot --version hangs, skip it
|
|
33
|
+
},
|
|
34
|
+
None => AgentStatus {
|
|
35
|
+
name: "copilot".into(),
|
|
36
|
+
available: false,
|
|
37
|
+
reason: "Not found. Install: gh extension install github/gh-copilot".into(),
|
|
38
|
+
version: None,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
|
|
44
|
+
let binary = Self::find_binary().unwrap_or("copilot".into());
|
|
45
|
+
let tmp = std::env::temp_dir().join("relay_handoff.md");
|
|
46
|
+
std::fs::write(&tmp, handoff_prompt)?;
|
|
47
|
+
|
|
48
|
+
let status = Command::new(&binary)
|
|
49
|
+
.current_dir(project_dir)
|
|
50
|
+
.arg(handoff_prompt)
|
|
51
|
+
.stdin(std::process::Stdio::inherit())
|
|
52
|
+
.stdout(std::process::Stdio::inherit())
|
|
53
|
+
.stderr(std::process::Stdio::inherit())
|
|
54
|
+
.status()?;
|
|
55
|
+
|
|
56
|
+
Ok(HandoffResult {
|
|
57
|
+
agent: "copilot".into(),
|
|
58
|
+
success: status.success(),
|
|
59
|
+
message: "Copilot session ended".into(),
|
|
60
|
+
handoff_file: Some(tmp.to_string_lossy().to_string()),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
package/core/src/agents/mod.rs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
pub mod aider;
|
|
2
|
+
pub mod claude;
|
|
1
3
|
pub mod codex;
|
|
4
|
+
pub mod copilot;
|
|
2
5
|
pub mod gemini;
|
|
3
6
|
pub mod ollama;
|
|
4
7
|
pub mod openai;
|
|
8
|
+
pub mod opencode;
|
|
5
9
|
|
|
6
10
|
use crate::{AgentStatus, Config, HandoffResult};
|
|
7
11
|
use anyhow::Result;
|
|
@@ -18,10 +22,14 @@ pub fn get_agents(config: &Config) -> Vec<Box<dyn Agent>> {
|
|
|
18
22
|
let mut agents: Vec<Box<dyn Agent>> = Vec::new();
|
|
19
23
|
for name in &config.general.priority {
|
|
20
24
|
match name.as_str() {
|
|
21
|
-
"codex"
|
|
22
|
-
"gemini"
|
|
23
|
-
"ollama"
|
|
24
|
-
"openai"
|
|
25
|
+
"codex" => agents.push(Box::new(codex::CodexAgent::new(&config.agents.codex))),
|
|
26
|
+
"gemini" => agents.push(Box::new(gemini::GeminiAgent::new(&config.agents.gemini))),
|
|
27
|
+
"ollama" => agents.push(Box::new(ollama::OllamaAgent::new(&config.agents.ollama))),
|
|
28
|
+
"openai" => agents.push(Box::new(openai::OpenAIAgent::new(&config.agents.openai))),
|
|
29
|
+
"aider" => agents.push(Box::new(aider::AiderAgent::new(None))),
|
|
30
|
+
"claude" => agents.push(Box::new(claude::ClaudeAgent::new())),
|
|
31
|
+
"copilot" => agents.push(Box::new(copilot::CopilotAgent::new())),
|
|
32
|
+
"opencode" => agents.push(Box::new(opencode::OpenCodeAgent::new())),
|
|
25
33
|
_ => {} // unknown agent, skip
|
|
26
34
|
}
|
|
27
35
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
use super::Agent;
|
|
4
4
|
use crate::{AgentStatus, HandoffResult, OllamaConfig};
|
|
5
5
|
use anyhow::Result;
|
|
6
|
+
use std::process::Command;
|
|
6
7
|
|
|
7
8
|
pub struct OllamaAgent {
|
|
8
9
|
url: String,
|
|
@@ -22,43 +23,24 @@ impl Agent for OllamaAgent {
|
|
|
22
23
|
fn name(&self) -> &str { "ollama" }
|
|
23
24
|
|
|
24
25
|
fn check_available(&self) -> AgentStatus {
|
|
25
|
-
//
|
|
26
|
+
// Use curl with a 2s timeout — avoids ureq hanging on refused connections
|
|
26
27
|
let tag_url = format!("{}/api/tags", self.url);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
let models = body.get("models")
|
|
31
|
-
.and_then(|m| m.as_array())
|
|
32
|
-
.map(|a| a.len())
|
|
33
|
-
.unwrap_or(0);
|
|
28
|
+
let output = Command::new("curl")
|
|
29
|
+
.args(["--silent", "--max-time", "2", &tag_url])
|
|
30
|
+
.output();
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
if has_model {
|
|
46
|
-
AgentStatus {
|
|
47
|
-
name: "ollama".into(),
|
|
48
|
-
available: true,
|
|
49
|
-
reason: format!("Running at {}, {} models, '{}' available", self.url, models, self.model),
|
|
50
|
-
version: Some(self.model.clone()),
|
|
51
|
-
}
|
|
52
|
-
} else {
|
|
53
|
-
AgentStatus {
|
|
54
|
-
name: "ollama".into(),
|
|
55
|
-
available: true,
|
|
56
|
-
reason: format!("Running but model '{}' not pulled. {} models available", self.model, models),
|
|
57
|
-
version: None,
|
|
58
|
-
}
|
|
32
|
+
match output {
|
|
33
|
+
Ok(o) if o.status.success() => {
|
|
34
|
+
let body: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_default();
|
|
35
|
+
let models = body.get("models").and_then(|m| m.as_array()).map(|a| a.len()).unwrap_or(0);
|
|
36
|
+
AgentStatus {
|
|
37
|
+
name: "ollama".into(),
|
|
38
|
+
available: true,
|
|
39
|
+
reason: format!("Running at {}, {} models available", self.url, models),
|
|
40
|
+
version: Some(self.model.clone()),
|
|
59
41
|
}
|
|
60
42
|
}
|
|
61
|
-
|
|
43
|
+
_ => AgentStatus {
|
|
62
44
|
name: "ollama".into(),
|
|
63
45
|
available: false,
|
|
64
46
|
reason: format!("Not reachable at {}", self.url),
|
|
@@ -69,7 +51,6 @@ impl Agent for OllamaAgent {
|
|
|
69
51
|
|
|
70
52
|
fn execute(&self, handoff_prompt: &str, _project_dir: &str) -> Result<HandoffResult> {
|
|
71
53
|
let url = format!("{}/api/generate", self.url);
|
|
72
|
-
|
|
73
54
|
let body = serde_json::json!({
|
|
74
55
|
"model": self.model,
|
|
75
56
|
"prompt": handoff_prompt,
|
|
@@ -81,11 +62,7 @@ impl Agent for OllamaAgent {
|
|
|
81
62
|
.send_json(&body)?;
|
|
82
63
|
|
|
83
64
|
let resp_json: serde_json::Value = resp.into_json()?;
|
|
84
|
-
let text = resp_json
|
|
85
|
-
.get("response")
|
|
86
|
-
.and_then(|r| r.as_str())
|
|
87
|
-
.unwrap_or("(no response)");
|
|
88
|
-
|
|
65
|
+
let text = resp_json.get("response").and_then(|r| r.as_str()).unwrap_or("(no response)");
|
|
89
66
|
println!("{text}");
|
|
90
67
|
|
|
91
68
|
Ok(HandoffResult {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//! OpenCode agent adapter — Go-based coding agent.
|
|
2
|
+
//! https://github.com/opencode-ai/opencode
|
|
3
|
+
|
|
4
|
+
use super::Agent;
|
|
5
|
+
use crate::{AgentStatus, HandoffResult};
|
|
6
|
+
use anyhow::Result;
|
|
7
|
+
use std::process::Command;
|
|
8
|
+
|
|
9
|
+
pub struct OpenCodeAgent;
|
|
10
|
+
|
|
11
|
+
impl OpenCodeAgent {
|
|
12
|
+
pub fn new() -> Self { Self }
|
|
13
|
+
|
|
14
|
+
fn find_binary() -> Option<String> {
|
|
15
|
+
let output = Command::new("which").arg("opencode").output().ok()?;
|
|
16
|
+
if output.status.success() {
|
|
17
|
+
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
18
|
+
} else {
|
|
19
|
+
None
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
impl Agent for OpenCodeAgent {
|
|
25
|
+
fn name(&self) -> &str { "opencode" }
|
|
26
|
+
|
|
27
|
+
fn check_available(&self) -> AgentStatus {
|
|
28
|
+
match Self::find_binary() {
|
|
29
|
+
Some(path) => AgentStatus {
|
|
30
|
+
name: "opencode".into(),
|
|
31
|
+
available: true,
|
|
32
|
+
reason: format!("Found at {path}"),
|
|
33
|
+
version: Command::new(&path).arg("--version").output().ok()
|
|
34
|
+
.filter(|o| o.status.success())
|
|
35
|
+
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()),
|
|
36
|
+
},
|
|
37
|
+
None => AgentStatus {
|
|
38
|
+
name: "opencode".into(),
|
|
39
|
+
available: false,
|
|
40
|
+
reason: "Not found. Install: go install github.com/opencode-ai/opencode@latest".into(),
|
|
41
|
+
version: None,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn execute(&self, handoff_prompt: &str, project_dir: &str) -> Result<HandoffResult> {
|
|
47
|
+
let binary = Self::find_binary().unwrap_or("opencode".into());
|
|
48
|
+
let tmp = std::env::temp_dir().join("relay_handoff.md");
|
|
49
|
+
std::fs::write(&tmp, handoff_prompt)?;
|
|
50
|
+
|
|
51
|
+
let status = Command::new(&binary)
|
|
52
|
+
.current_dir(project_dir)
|
|
53
|
+
.arg(handoff_prompt)
|
|
54
|
+
.stdin(std::process::Stdio::inherit())
|
|
55
|
+
.stdout(std::process::Stdio::inherit())
|
|
56
|
+
.stderr(std::process::Stdio::inherit())
|
|
57
|
+
.status()?;
|
|
58
|
+
|
|
59
|
+
Ok(HandoffResult {
|
|
60
|
+
agent: "opencode".into(),
|
|
61
|
+
success: status.success(),
|
|
62
|
+
message: "OpenCode session ended".into(),
|
|
63
|
+
handoff_file: Some(tmp.to_string_lossy().to_string()),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
//! tool calls with results, errors, and decisions.
|
|
4
4
|
|
|
5
5
|
use std::path::Path;
|
|
6
|
+
use std::sync::atomic::AtomicUsize;
|
|
7
|
+
|
|
8
|
+
/// Controls how many conversation turns to keep. Set before calling read_latest_session.
|
|
9
|
+
pub static MAX_CONVERSATION_TURNS: AtomicUsize = AtomicUsize::new(25);
|
|
6
10
|
|
|
7
11
|
/// Extracted session info from Claude's transcript.
|
|
8
12
|
pub struct SessionInfo {
|
|
@@ -143,7 +147,7 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
143
147
|
|
|
144
148
|
conversation.push(ConversationTurn {
|
|
145
149
|
role: "tool_result".into(),
|
|
146
|
-
content: truncate(&result_text,
|
|
150
|
+
content: truncate(&result_text, 200),
|
|
147
151
|
});
|
|
148
152
|
}
|
|
149
153
|
}
|
|
@@ -168,7 +172,7 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
168
172
|
}
|
|
169
173
|
conversation.push(ConversationTurn {
|
|
170
174
|
role: "user".into(),
|
|
171
|
-
content: truncate(&user_text,
|
|
175
|
+
content: truncate(&user_text, 300),
|
|
172
176
|
});
|
|
173
177
|
}
|
|
174
178
|
}
|
|
@@ -185,7 +189,7 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
185
189
|
if !text.is_empty() {
|
|
186
190
|
conversation.push(ConversationTurn {
|
|
187
191
|
role: "assistant".into(),
|
|
188
|
-
content: truncate(text,
|
|
192
|
+
content: truncate(text, 200),
|
|
189
193
|
});
|
|
190
194
|
// Extract decisions
|
|
191
195
|
for line in text.lines() {
|
|
@@ -230,8 +234,8 @@ fn parse_session_transcript(path: &std::path::Path) -> SessionInfo {
|
|
|
230
234
|
decisions.dedup();
|
|
231
235
|
decisions.truncate(15);
|
|
232
236
|
|
|
233
|
-
// Keep last N conversation turns
|
|
234
|
-
let max_turns =
|
|
237
|
+
// Keep last N conversation turns — caller can override via max_conversation_turns
|
|
238
|
+
let max_turns = MAX_CONVERSATION_TURNS.load(std::sync::atomic::Ordering::Relaxed);
|
|
235
239
|
if conversation.len() > max_turns {
|
|
236
240
|
let skip = conversation.len() - max_turns;
|
|
237
241
|
conversation = conversation.into_iter().skip(skip).collect();
|
package/core/src/handoff/mod.rs
CHANGED
|
@@ -117,29 +117,40 @@ pub fn build_handoff(
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// ── Instructions for agent ─────────────────────────────────
|
|
120
|
+
let deadline_note = if snapshot.deadline.is_some() {
|
|
121
|
+
"\nIMPORTANT: There is a DEADLINE. If the user asks you to continue, prioritise the current task."
|
|
122
|
+
} else { "" };
|
|
123
|
+
|
|
120
124
|
let instructions = format!(
|
|
121
125
|
"## INSTRUCTIONS\n\n\
|
|
122
|
-
You
|
|
123
|
-
The
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
You have been given the full context from a Claude Code session.\n\
|
|
127
|
+
The context above shows what was being worked on, what decisions were made,\n\
|
|
128
|
+
what files were changed, and what the last state was.\n\
|
|
129
|
+
\n\
|
|
130
|
+
DO NOT immediately start working on anything.\n\
|
|
131
|
+
Instead, briefly confirm you have the context by saying something like:\n\
|
|
132
|
+
\"Context restored from your Claude session. I can see you were working on [brief summary]. What would you like me to do?\"\n\
|
|
126
133
|
\n\
|
|
127
|
-
|
|
134
|
+
Then WAIT for the user to tell you what to do next.\n\
|
|
135
|
+
\n\
|
|
136
|
+
Working directory: {}\
|
|
128
137
|
{}",
|
|
129
138
|
snapshot.project_dir,
|
|
130
|
-
|
|
131
|
-
"THERE IS A DEADLINE. Prioritise completing the current task."
|
|
132
|
-
} else { "" }
|
|
139
|
+
deadline_note
|
|
133
140
|
);
|
|
134
141
|
sections.push(instructions);
|
|
135
142
|
|
|
136
143
|
let mut full = sections.join("\n\n");
|
|
137
144
|
|
|
138
|
-
//
|
|
139
|
-
let
|
|
140
|
-
if
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
// Hard cap at max_tokens (rough estimate: chars / 3.5)
|
|
146
|
+
let max_chars = (max_tokens as f64 * 3.5) as usize;
|
|
147
|
+
if full.len() > max_chars {
|
|
148
|
+
// Find a valid UTF-8 char boundary
|
|
149
|
+
let mut end = max_chars;
|
|
150
|
+
while end > 0 && !full.is_char_boundary(end) {
|
|
151
|
+
end -= 1;
|
|
152
|
+
}
|
|
153
|
+
full.truncate(end);
|
|
143
154
|
full.push_str("\n\n[...truncated to fit context limit]");
|
|
144
155
|
}
|
|
145
156
|
|
package/core/src/lib.rs
CHANGED
package/core/src/main.rs
CHANGED
|
@@ -44,6 +44,14 @@ enum Commands {
|
|
|
44
44
|
/// Don't execute — just print the handoff package
|
|
45
45
|
#[arg(long)]
|
|
46
46
|
dry_run: bool,
|
|
47
|
+
|
|
48
|
+
/// How many conversation turns to include (default: 25)
|
|
49
|
+
#[arg(long, default_value = "25")]
|
|
50
|
+
turns: usize,
|
|
51
|
+
|
|
52
|
+
/// What to include: all, conversation, git, todos (comma-separated)
|
|
53
|
+
#[arg(long, default_value = "all")]
|
|
54
|
+
include: String,
|
|
47
55
|
},
|
|
48
56
|
|
|
49
57
|
/// Show current session snapshot (what would be handed off)
|
|
@@ -84,14 +92,33 @@ fn main() -> Result<()> {
|
|
|
84
92
|
});
|
|
85
93
|
|
|
86
94
|
match cli.command {
|
|
87
|
-
Commands::Handoff { to, deadline, dry_run } => {
|
|
95
|
+
Commands::Handoff { to, deadline, dry_run, turns, include } => {
|
|
88
96
|
eprintln!("{}", "⚡ Relay — capturing session state...".yellow().bold());
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
// Set conversation turn limit before capture
|
|
99
|
+
relay::capture::session::MAX_CONVERSATION_TURNS
|
|
100
|
+
.store(turns, std::sync::atomic::Ordering::Relaxed);
|
|
101
|
+
|
|
102
|
+
let mut snapshot = capture::capture_snapshot(
|
|
91
103
|
&project_dir,
|
|
92
104
|
deadline.as_deref(),
|
|
93
105
|
)?;
|
|
94
106
|
|
|
107
|
+
// Filter sections based on --include flag
|
|
108
|
+
let includes: Vec<&str> = include.split(',').map(|s| s.trim()).collect();
|
|
109
|
+
if !includes.contains(&"all") {
|
|
110
|
+
if !includes.contains(&"conversation") {
|
|
111
|
+
snapshot.conversation.clear();
|
|
112
|
+
}
|
|
113
|
+
if !includes.contains(&"git") {
|
|
114
|
+
snapshot.git_state = None;
|
|
115
|
+
snapshot.recent_files.clear();
|
|
116
|
+
}
|
|
117
|
+
if !includes.contains(&"todos") {
|
|
118
|
+
snapshot.todos.clear();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
95
122
|
let target = to.as_deref().unwrap_or("auto");
|
|
96
123
|
let handoff_text = handoff::build_handoff(
|
|
97
124
|
&snapshot,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@masyv/relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Relay — When Claude's rate limit hits, another agent picks up exactly where you left off. Captures session state and hands off to Codex, Gemini, Ollama, or GPT-4.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "./scripts/build.sh",
|