updates
This commit is contained in:
@@ -273,7 +273,7 @@ final class EmailHandlerService {
|
||||
|
||||
---
|
||||
|
||||
Please provide a complete, well-formatted response to this email. Your response will be sent as an HTML email.
|
||||
Please provide a complete, well-formatted response to this email. Write the reply directly — do not wrap it in code fences or markdown blocks.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
@@ -299,7 +299,8 @@ final class EmailHandlerService {
|
||||
- Be professional and courteous
|
||||
- Keep responses concise and relevant
|
||||
- Use proper email etiquette
|
||||
- Format your response using Markdown (it will be converted to HTML)
|
||||
- Format your response using Markdown (bold, lists, headings are welcome)
|
||||
- Do NOT wrap your response in code fences or ```html blocks — write the email body directly
|
||||
- If you need information from files, you have read-only access via MCP tools
|
||||
- Never claim to write, modify, or delete files (read-only access)
|
||||
- Sign emails appropriately
|
||||
@@ -412,27 +413,46 @@ final class EmailHandlerService {
|
||||
}
|
||||
|
||||
private func markdownToHTML(_ markdown: String) -> String {
|
||||
// Basic markdown to HTML conversion
|
||||
// TODO: Use proper markdown parser for production
|
||||
var html = markdown
|
||||
var text = markdown.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Paragraphs
|
||||
html = html.replacingOccurrences(of: "\n\n", with: "</p><p>")
|
||||
html = "<p>\(html)</p>"
|
||||
// Strip outer code fence wrapping the entire response
|
||||
// Models sometimes wrap their reply in ```html ... ``` or ``` ... ```
|
||||
let lines = text.components(separatedBy: "\n")
|
||||
if lines.count >= 2 {
|
||||
let first = lines[0].trimmingCharacters(in: .whitespaces)
|
||||
let last = lines[lines.count - 1].trimmingCharacters(in: .whitespaces)
|
||||
if (first == "```" || first.hasPrefix("```")) && last == "```" {
|
||||
text = lines.dropFirst().dropLast().joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
// Strip any remaining fenced code blocks (preserve content, remove fences)
|
||||
text = text.replacingOccurrences(
|
||||
of: #"```[a-z]*\n([\s\S]*?)```"#,
|
||||
with: "$1",
|
||||
options: .regularExpression
|
||||
)
|
||||
|
||||
// Bold
|
||||
html = html.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
|
||||
text = text.replacingOccurrences(of: #"\*\*(.+?)\*\*"#, with: "<strong>$1</strong>", options: .regularExpression)
|
||||
|
||||
// Italic
|
||||
html = html.replacingOccurrences(of: #"\*(.+?)\*"#, with: "<em>$1</em>", options: .regularExpression)
|
||||
// Italic (avoid matching bold's leftover *)
|
||||
text = text.replacingOccurrences(of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#, with: "<em>$1</em>", options: .regularExpression)
|
||||
|
||||
// Inline code
|
||||
html = html.replacingOccurrences(of: #"`(.+?)`"#, with: "<code>$1</code>", options: .regularExpression)
|
||||
text = text.replacingOccurrences(of: #"`([^`\n]+)`"#, with: "<code>$1</code>", options: .regularExpression)
|
||||
|
||||
// Line breaks
|
||||
html = html.replacingOccurrences(of: "\n", with: "<br>")
|
||||
// Split into paragraphs on double newlines, wrap each in <p>
|
||||
let paragraphs = text.components(separatedBy: "\n\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.map { para in
|
||||
let withBreaks = para.replacingOccurrences(of: "\n", with: "<br>")
|
||||
return "<p>\(withBreaks)</p>"
|
||||
}
|
||||
|
||||
return html
|
||||
return paragraphs.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Error Handling
|
||||
|
||||
@@ -112,6 +112,18 @@ class MCPService {
|
||||
private let anytypeService = AnytypeMCPService.shared
|
||||
private let paperlessService = PaperlessService.shared
|
||||
|
||||
// MARK: - Bash Approval State
|
||||
|
||||
struct PendingBashCommand: Identifiable {
|
||||
let id = UUID()
|
||||
let command: String
|
||||
let workingDirectory: String
|
||||
}
|
||||
|
||||
private(set) var pendingBashCommand: PendingBashCommand? = nil
|
||||
private var pendingBashContinuation: CheckedContinuation<[String: Any], Never>? = nil
|
||||
private(set) var bashSessionApproved: Bool = false
|
||||
|
||||
// MARK: - Tool Schema Generation
|
||||
|
||||
func getToolSchemas(onlineMode: Bool = false) -> [Tool] {
|
||||
@@ -220,6 +232,21 @@ class MCPService {
|
||||
tools.append(contentsOf: paperlessService.getToolSchemas())
|
||||
}
|
||||
|
||||
// Add bash_execute tool when bash is enabled
|
||||
if settings.bashEnabled {
|
||||
let workDir = settings.bashWorkingDirectory
|
||||
let timeout = settings.bashTimeout
|
||||
let approvalNote = settings.bashRequireApproval ? " User approval required before execution." : ""
|
||||
tools.append(makeTool(
|
||||
name: "bash_execute",
|
||||
description: "Execute a shell command via /bin/zsh and return stdout/stderr. Working directory: \(workDir). Timeout: \(timeout)s.\(approvalNote)",
|
||||
properties: [
|
||||
"command": prop("string", "The shell command to execute")
|
||||
],
|
||||
required: ["command"]
|
||||
))
|
||||
}
|
||||
|
||||
// Add web_search tool when online mode is active
|
||||
// (OpenRouter handles search natively via :online model suffix, so excluded here)
|
||||
if onlineMode {
|
||||
@@ -346,6 +373,20 @@ class MCPService {
|
||||
}
|
||||
return copyFile(source: source, destination: destination)
|
||||
|
||||
case "bash_execute":
|
||||
guard settings.bashEnabled else {
|
||||
return ["error": "Bash execution is disabled. Enable it in Settings > MCP."]
|
||||
}
|
||||
guard let command = args["command"] as? String, !command.isEmpty else {
|
||||
return ["error": "Missing required parameter: command"]
|
||||
}
|
||||
let workDir = settings.bashWorkingDirectory
|
||||
if settings.bashRequireApproval {
|
||||
return await executeBashWithApproval(command: command, workingDirectory: workDir)
|
||||
} else {
|
||||
return await runBashCommand(command, workingDirectory: workDir)
|
||||
}
|
||||
|
||||
case "web_search":
|
||||
let query = args["query"] as? String ?? ""
|
||||
guard !query.isEmpty else {
|
||||
@@ -706,6 +747,113 @@ class MCPService {
|
||||
return ["success": true, "source": resolvedSrc, "destination": resolvedDst]
|
||||
}
|
||||
|
||||
// MARK: - Bash Execution
|
||||
|
||||
private func executeBashWithApproval(command: String, workingDirectory: String) async -> [String: Any] {
|
||||
// If the user already approved all commands for this session, skip the UI
|
||||
if bashSessionApproved {
|
||||
return await runBashCommand(command, workingDirectory: workingDirectory)
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async {
|
||||
self.pendingBashCommand = PendingBashCommand(command: command, workingDirectory: workingDirectory)
|
||||
self.pendingBashContinuation = continuation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func approvePendingBashCommand(forSession: Bool = false) {
|
||||
guard let pending = pendingBashCommand, let cont = pendingBashContinuation else { return }
|
||||
pendingBashCommand = nil
|
||||
pendingBashContinuation = nil
|
||||
if forSession {
|
||||
bashSessionApproved = true
|
||||
}
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let result = await self.runBashCommand(pending.command, workingDirectory: pending.workingDirectory)
|
||||
cont.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
func denyPendingBashCommand() {
|
||||
guard pendingBashCommand != nil else { return }
|
||||
pendingBashCommand = nil
|
||||
pendingBashContinuation?.resume(returning: ["error": "User denied command execution"])
|
||||
pendingBashContinuation = nil
|
||||
}
|
||||
|
||||
func resetBashSessionApproval() {
|
||||
bashSessionApproved = false
|
||||
}
|
||||
|
||||
private func runBashCommand(_ command: String, workingDirectory: String) async -> [String: Any] {
|
||||
let timeoutSeconds = settings.bashTimeout
|
||||
let workDir = ((workingDirectory as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
Log.mcp.info("bash_execute: \(command)")
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.arguments = ["-c", command]
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: workDir, isDirectory: &isDir), isDir.boolValue {
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: workDir)
|
||||
}
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
var timedOut = false
|
||||
let timeoutItem = DispatchWorkItem {
|
||||
if process.isRunning {
|
||||
timedOut = true
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(timeoutSeconds), execute: timeoutItem)
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
timeoutItem.cancel()
|
||||
Log.mcp.error("bash_execute failed to start: \(error.localizedDescription)")
|
||||
continuation.resume(returning: ["error": "Failed to run command: \(error.localizedDescription)"])
|
||||
return
|
||||
}
|
||||
|
||||
timeoutItem.cancel()
|
||||
|
||||
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let exitCode = Int(process.terminationStatus)
|
||||
|
||||
Log.mcp.info("bash_execute exit=\(exitCode) stdout=\(stdout.count)b stderr=\(stderr.count)b timedOut=\(timedOut)")
|
||||
|
||||
var result: [String: Any] = ["exit_code": exitCode]
|
||||
if !stdout.isEmpty {
|
||||
let maxOut = 20_000
|
||||
result["stdout"] = stdout.count > maxOut
|
||||
? String(stdout.prefix(maxOut)) + "\n... (output truncated)"
|
||||
: stdout
|
||||
}
|
||||
if !stderr.isEmpty {
|
||||
result["stderr"] = String(stderr.prefix(5_000))
|
||||
}
|
||||
if timedOut {
|
||||
result["timed_out"] = true
|
||||
result["note"] = "Command terminated after \(timeoutSeconds)s timeout"
|
||||
}
|
||||
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gitignore Support
|
||||
|
||||
/// Reload gitignore rules for all allowed folders
|
||||
|
||||
@@ -447,6 +447,40 @@ class SettingsService {
|
||||
return !key.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Bash Execution Settings
|
||||
|
||||
var bashEnabled: Bool {
|
||||
get { cache["bashEnabled"] == "true" }
|
||||
set {
|
||||
cache["bashEnabled"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "bashEnabled", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var bashRequireApproval: Bool {
|
||||
get { cache["bashRequireApproval"].map { $0 == "true" } ?? true }
|
||||
set {
|
||||
cache["bashRequireApproval"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "bashRequireApproval", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
var bashWorkingDirectory: String {
|
||||
get { cache["bashWorkingDirectory"] ?? "~" }
|
||||
set {
|
||||
cache["bashWorkingDirectory"] = newValue
|
||||
DatabaseService.shared.setSetting(key: "bashWorkingDirectory", value: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var bashTimeout: Int {
|
||||
get { cache["bashTimeout"].flatMap(Int.init) ?? 30 }
|
||||
set {
|
||||
cache["bashTimeout"] = String(newValue)
|
||||
DatabaseService.shared.setSetting(key: "bashTimeout", value: String(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paperless-NGX Settings
|
||||
|
||||
var paperlessEnabled: Bool {
|
||||
|
||||
Reference in New Issue
Block a user