diff --git a/.github/ghcr-verify-context/Dockerfile b/.github/ghcr-verify-context/Dockerfile deleted file mode 100644 index 0eef1a6..0000000 --- a/.github/ghcr-verify-context/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -# Minimal image to verify Actions can push to ghcr.io/pengine-ai/... -FROM scratch -COPY README.md /README.md diff --git a/.github/scripts/tools-publish/detect-matrix.sh b/.github/scripts/tools-publish/detect-matrix.sh index c187956..7e4f932 100755 --- a/.github/scripts/tools-publish/detect-matrix.sh +++ b/.github/scripts/tools-publish/detect-matrix.sh @@ -27,10 +27,10 @@ else continue fi if echo "$changed" | grep -q "^tools/mcp-tools.json$"; then - old_ver=$(git show HEAD~1:tools/mcp-tools.json 2>/dev/null \ - | jq -r --arg s "$s" '.tools[] | select(.id | endswith("/" + $s)) | .current // ""' 2>/dev/null || echo "") - new_ver=$(jq -r --arg s "$s" '.tools[] | select(.id | endswith("/" + $s)) | .current' "$REGISTRY") - if [[ -n "$old_ver" && "$old_ver" != "$new_ver" ]]; then + old_blob=$(git show HEAD~1:tools/mcp-tools.json 2>/dev/null \ + | jq -c --arg s "$s" '.tools[]? | select(.id | endswith("/" + $s))' 2>/dev/null || echo "") + new_blob=$(jq -c --arg s "$s" '.tools[] | select(.id | endswith("/" + $s))' "$REGISTRY") + if [[ "$old_blob" != "$new_blob" ]]; then slugs="$slugs $s" fi fi diff --git a/.github/scripts/tools-publish/read-tool-manifest.sh b/.github/scripts/tools-publish/read-tool-manifest.sh index 654c1ff..5edf506 100755 --- a/.github/scripts/tools-publish/read-tool-manifest.sh +++ b/.github/scripts/tools-publish/read-tool-manifest.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Writes image, version, npm_pkg, npm_ver to GITHUB_OUTPUT for one tool slug. +# Writes image, version, and multiline build_args to GITHUB_OUTPUT for one tool slug. +# Each tool must define either upstream_mcp_npm or upstream_mcp_pypi (not both). # Usage: TOOL_SLUG=file-manager (env) or first argument. set -euo pipefail @@ -15,11 +16,34 @@ VERSION=$(jq -r --arg s "$SUFFIX" '.tools[] | select(.id | endswith("/" + $s)) | echo "image=$IMAGE" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" -PKG=$(jq -r --arg s "$SUFFIX" '.tools[] | select(.id | endswith("/" + $s)) | .upstream_mcp_npm.package // ""' "$REGISTRY") -NPM_VER=$(jq -r --arg s "$SUFFIX" '.tools[] | select(.id | endswith("/" + $s)) | .upstream_mcp_npm.version // ""' "$REGISTRY") -if [[ -z "$PKG" || -z "$NPM_VER" ]]; then - echo "::error::${REGISTRY}: tool '${SUFFIX}' must define non-empty upstream_mcp_npm.package and upstream_mcp_npm.version" >&2 +npm_pkg=$(jq -r --arg s "$SUFFIX" '.tools[] | select(.id | endswith("/" + $s)) | .upstream_mcp_npm.package // ""' "$REGISTRY") +npm_ver=$(jq -r --arg s "$SUFFIX" '.tools[] | select(.id | endswith("/" + $s)) | .upstream_mcp_npm.version // ""' "$REGISTRY") +pypi_pkg=$(jq -r --arg s "$SUFFIX" '.tools[] | select(.id | endswith("/" + $s)) | .upstream_mcp_pypi.package // ""' "$REGISTRY") +pypi_ver=$(jq -r --arg s "$SUFFIX" '.tools[] | select(.id | endswith("/" + $s)) | .upstream_mcp_pypi.version // ""' "$REGISTRY") + +has_npm=0 +[[ -n "$npm_pkg" && -n "$npm_ver" ]] && has_npm=1 +has_pypi=0 +[[ -n "$pypi_pkg" && -n "$pypi_ver" ]] && has_pypi=1 + +if [[ "$has_npm" -eq 1 && "$has_pypi" -eq 1 ]]; then + echo "::error::${REGISTRY}: tool '${SUFFIX}' must not set both upstream_mcp_npm and upstream_mcp_pypi" >&2 exit 1 fi -echo "npm_pkg=$PKG" >> "$GITHUB_OUTPUT" -echo "npm_ver=$NPM_VER" >> "$GITHUB_OUTPUT" +if [[ "$has_npm" -eq 0 && "$has_pypi" -eq 0 ]]; then + echo "::error::${REGISTRY}: tool '${SUFFIX}' must define upstream_mcp_npm or upstream_mcp_pypi" >&2 + exit 1 +fi + +{ + echo 'build_args<> "$GITHUB_OUTPUT" diff --git a/.github/scripts/tools-publish/smoke-test-mcp.sh b/.github/scripts/tools-publish/smoke-test-mcp.sh index 9286c74..dc70ad0 100755 --- a/.github/scripts/tools-publish/smoke-test-mcp.sh +++ b/.github/scripts/tools-publish/smoke-test-mcp.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Pull image by digest and run one MCP initialize JSON-RPC round-trip. -# Env: IMAGE_WITH_DIGEST (e.g. ghcr.io/org/img@sha256:...). +# Env: IMAGE_WITH_DIGEST (e.g. ghcr.io/org/img@sha256:...). Optional TOOL_SLUG for argv quirks. set -euo pipefail if [[ -z "${IMAGE_WITH_DIGEST:-}" ]]; then @@ -9,8 +9,15 @@ if [[ -z "${IMAGE_WITH_DIGEST:-}" ]]; then fi docker pull "$IMAGE_WITH_DIGEST" + +# Filesystem MCP expects at least one allowed root on argv; others ignore extra args. +extra=() +if [[ "${TOOL_SLUG:-}" == "file-manager" ]]; then + extra=(/tmp) +fi + RESP=$(echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.1"}}}' \ - | timeout 15 docker run --rm -i --network=none "$IMAGE_WITH_DIGEST" /tmp \ + | timeout 15 docker run --rm -i --network=none "$IMAGE_WITH_DIGEST" "${extra[@]}" \ | head -1) echo "$RESP" | jq -e '.result.serverInfo' > /dev/null \ || { echo "::error::MCP init failed: $RESP"; exit 1; } diff --git a/.github/workflows/ghcr-verify.yml b/.github/workflows/ghcr-verify.yml deleted file mode 100644 index 0d87bc4..0000000 --- a/.github/workflows/ghcr-verify.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Verify GHCR login - -# Manual-only: push a tiny image to ghcr.io/... to debug GITHUB_TOKEN + GHCR. - -on: - workflow_dispatch: - inputs: - image: - description: 'Full image name without tag (e.g. ghcr.io/pengine-ai/pengine-file-manager)' - required: false - default: 'ghcr.io/pengine-ai/pengine-ghcr-verify' - -permissions: - contents: read - packages: write - -jobs: - verify: - runs-on: ubuntu-latest - env: - # workflow_dispatch input can be cleared in UI; fall back to default. - VERIFY_IMAGE: ${{ github.event.inputs.image || 'ghcr.io/pengine-ai/pengine-ghcr-verify' }} - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Push probe image - id: build - uses: docker/build-push-action@v6 - with: - context: .github/ghcr-verify-context - file: .github/ghcr-verify-context/Dockerfile - platforms: linux/amd64 - push: true - provenance: false - sbom: false - tags: ${{ env.VERIFY_IMAGE }}:verify-${{ github.run_id }} - - - name: Pull probe (read check) - env: - REF: ${{ env.VERIFY_IMAGE }}@${{ steps.build.outputs.digest }} - run: | - set -euo pipefail - docker pull "$REF" - - - name: Summary - env: - REF: ${{ env.VERIFY_IMAGE }}@${{ steps.build.outputs.digest }} - TAG: ${{ env.VERIFY_IMAGE }}:verify-${{ github.run_id }} - run: | - { - echo '### GHCR verify' - echo "Push succeeded for **\`$TAG\`** (digest below)." - echo "Read check: \`docker pull\` by digest succeeded." - echo "" - echo "**Digest:** \`$REF\`" - echo "" - echo 'You can delete the probe tag in **Packages** when finished.' - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/tools-publish.yml b/.github/workflows/tools-publish.yml index e23a503..d315aeb 100644 --- a/.github/workflows/tools-publish.yml +++ b/.github/workflows/tools-publish.yml @@ -81,9 +81,7 @@ jobs: push: true provenance: false sbom: false - build-args: | - UPSTREAM_MCP_NPM_PACKAGE=${{ steps.cfg.outputs.npm_pkg }} - UPSTREAM_MCP_NPM_VERSION=${{ steps.cfg.outputs.npm_ver }} + build-args: ${{ steps.cfg.outputs.build_args }} tags: ${{ steps.cfg.outputs.image }}:${{ steps.cfg.outputs.version }}-ci-arm64-${{ github.run_id }} - name: Build and push (linux/amd64) @@ -95,9 +93,7 @@ jobs: push: true provenance: false sbom: false - build-args: | - UPSTREAM_MCP_NPM_PACKAGE=${{ steps.cfg.outputs.npm_pkg }} - UPSTREAM_MCP_NPM_VERSION=${{ steps.cfg.outputs.npm_ver }} + build-args: ${{ steps.cfg.outputs.build_args }} tags: ${{ steps.cfg.outputs.image }}:${{ steps.cfg.outputs.version }}-ci-amd64-${{ github.run_id }} - name: Merge multi-arch manifest @@ -117,6 +113,7 @@ jobs: - name: Smoke test (MCP init handshake) env: IMAGE_WITH_DIGEST: ${{ steps.cfg.outputs.image }}@${{ steps.merge.outputs.digest }} + TOOL_SLUG: ${{ matrix.slug }} run: bash .github/scripts/tools-publish/smoke-test-mcp.sh - name: Summary diff --git a/doc/tool-engine/manual-publish.md b/doc/tool-engine/manual-publish.md index 5a40b03..efa82b5 100644 --- a/doc/tool-engine/manual-publish.md +++ b/doc/tool-engine/manual-publish.md @@ -79,20 +79,20 @@ After a successful push, get the digest: podman image inspect "${IMAGE}:${VERSION}" --format '{{index .RepoDigests 0}}' ``` -Update the `sha256:…` value in the matching `versions[]` entry in **`tools/mcp-tools.json`**. The app fetches this file from GitHub at runtime; the embedded `src-tauri/src/modules/tool_engine/tools.json` is the offline fallback. +Update the `sha256:…` value in the matching `versions[]` entry in **`tools/mcp-tools.json`**. The app fetches this file from GitHub at runtime and embeds the same file at compile time as the offline fallback. --- -## Updating upstream npm versions +## Updating upstream npm and PyPI versions -Run the update script (like `npm update` for tool images): +Run the update script (like `npm update` / PyPI bump for tool images): ```bash ./tools/update-upstream.sh # check all tools ./tools/update-upstream.sh file-manager # check one tool ``` -This checks the npm registry for newer versions, bumps `mcp-tools.json`, and prints a summary. Commit, push, and CI builds only the affected tools. +`./tools/update-upstream.sh` checks the **npm** registry for tools that declare `upstream_mcp_npm` and the **PyPI** registry for tools that declare `upstream_mcp_pypi`, bumps `mcp-tools.json` when a newer version exists, and prints a summary. Commit, push, and CI builds only the affected tools. In PR descriptions, note that the script may have changed either npm or PyPI pins (or both) depending on the tool. --- @@ -132,8 +132,7 @@ CI passes these as `docker build` args so you bump the npm version in the regist ## Files -- **`tools/mcp-tools.json`** — tool registry (all tools, versions, digests, npm). CI and the app read this. +- **`tools/mcp-tools.json`** — single-source tool registry (all tools, versions, digests, upstream). CI, the app at runtime, and the embedded offline fallback (`include_str!`) all read this file. - **`tools//Dockerfile`** — image build context. -- **`tools/update-upstream.sh`** — bump upstream npm versions (like `npm update`). -- **`src-tauri/src/modules/tool_engine/tools.json`** — embedded catalog (offline fallback). Update after publish. +- **`tools/update-upstream.sh`** — bump upstream **npm** and **PyPI** package versions (registry checks for each tool’s ecosystem). - **`.github/workflows/tools-publish.yml`** — CI workflow. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ee2b6d7..87bf9f2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2969,6 +2969,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "teloxide", + "tempfile", "tokio", "tokio-stream", "tower-http", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 22f4094..daf70c7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,3 +35,6 @@ socket2 = "0.5" fastrand = "2" tauri-plugin-dialog = "2" +[dev-dependencies] +tempfile = "3" + diff --git a/src-tauri/src/infrastructure/http_server.rs b/src-tauri/src/infrastructure/http_server.rs index 590a584..b2495fd 100644 --- a/src-tauri/src/infrastructure/http_server.rs +++ b/src-tauri/src/infrastructure/http_server.rs @@ -13,6 +13,7 @@ use chrono::Utc; use serde::{Deserialize, Serialize}; use socket2::{Domain, Socket, Type}; use std::convert::Infallible; +use std::io::ErrorKind; use std::time::Duration; use tokio_stream::{Stream, StreamExt}; use tower_http::cors::{Any, CorsLayer}; @@ -104,6 +105,10 @@ pub async fn start_server(state: AppState) { "/v1/toolengine/uninstall", post(handle_toolengine_uninstall), ) + .route( + "/v1/toolengine/private-folder", + put(handle_toolengine_private_folder_put), + ) .route("/v1/toolengine/custom", get(handle_toolengine_custom_list)) .route("/v1/toolengine/custom", post(handle_toolengine_custom_add)) .route( @@ -379,11 +384,21 @@ async fn handle_mcp_filesystem_put( mcp_service::set_filesystem_allowed_paths(&mut cfg, &paths); let mut note = None::; + let bot_id = state + .connection + .lock() + .await + .as_ref() + .map(|c| c.bot_id.clone()); match &catalog_result { Ok(cat) => { - if let Err(e) = - te_service::sync_workspace_mounted_tools_for_catalog(&mut cfg, &paths, cat) - { + if let Err(e) = te_service::sync_workspace_mounted_tools_for_catalog( + &mut cfg, + &paths, + cat, + &state.mcp_config_path, + bot_id, + ) { note = Some(e); } } @@ -483,6 +498,34 @@ fn is_valid_server_name(name: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') } +/// True when two stdio entries launch the same process/bind mounts; differs only in fields we can +/// patch without spawning a new stdio child (e.g. `direct_return`). +fn mcp_stdio_identity_ignores_direct_return( + old: &crate::modules::mcp::types::ServerEntry, + new: &crate::modules::mcp::types::ServerEntry, +) -> bool { + use crate::modules::mcp::types::ServerEntry; + match (old, new) { + ( + ServerEntry::Stdio { + command: c0, + args: a0, + env: e0, + private_host_path: p0, + .. + }, + ServerEntry::Stdio { + command: c1, + args: a1, + env: e1, + private_host_path: p1, + .. + }, + ) => c0 == c1 && a0 == a1 && e0 == e1 && p0 == p1, + _ => false, + } +} + async fn handle_mcp_server_upsert( State(state): State, Path(name): Path, @@ -509,7 +552,7 @@ async fn handle_mcp_server_upsert( } } - { + let old_entry = { let _guard = state.mcp_config_mutex.lock().await; let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { ( @@ -518,7 +561,12 @@ async fn handle_mcp_server_upsert( ) })?; - cfg.servers.insert(name.clone(), entry); + let old = cfg.servers.get(&name).cloned(); + if old.as_ref() == Some(&entry) { + return Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))); + } + + cfg.servers.insert(name.clone(), entry.clone()); mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { ( @@ -526,14 +574,51 @@ async fn handle_mcp_server_upsert( Json(ErrorResponse { error: e }), ) })?; - } + + old + }; + + let try_direct_patch = match (&old_entry, &entry) { + (Some(old_e), new_e) if mcp_stdio_identity_ignores_direct_return(old_e, new_e) => { + matches!( + (old_e, new_e), + ( + crate::modules::mcp::types::ServerEntry::Stdio { + direct_return: a, + .. + }, + crate::modules::mcp::types::ServerEntry::Stdio { + direct_return: b, + .. + }, + ) if a != b + ) + } + _ => false, + }; + + let patch_direct_return = match &entry { + crate::modules::mcp::types::ServerEntry::Stdio { direct_return, .. } => *direct_return, + _ => false, + }; state .emit_log("mcp", &format!("server '{name}' saved")) .await; let bg = state.clone(); + let name_bg = name.clone(); tokio::spawn(async move { + if try_direct_patch + && mcp_service::patch_stdio_direct_return_in_registry( + &bg, + &name_bg, + patch_direct_return, + ) + .await + { + return; + } if let Err(e) = mcp_service::rebuild_registry_into_state(&bg).await { bg.emit_log( "mcp", @@ -621,10 +706,25 @@ async fn handle_toolengine_catalog( let installed_ids = te_service::installed_tool_ids(&state.mcp_config_path); + let cfg_snap = state + .mcp_config_path + .exists() + .then(|| mcp_service::read_config(&state.mcp_config_path).ok()) + .flatten(); + let tools: Vec = catalog .tools .iter() .map(|t| { + let stored_pf = cfg_snap.as_ref().and_then(|c| { + let k = te_service::server_key(&t.id); + match c.servers.get(&k)? { + crate::modules::mcp::types::ServerEntry::Stdio { + private_host_path, .. + } => private_host_path.as_deref(), + _ => None, + } + }); let commands: Vec = t .commands .iter() @@ -635,6 +735,18 @@ async fn handle_toolengine_catalog( }) }) .collect(); + let private_folder_json = t.private_folder.as_ref().map(|pf| { + serde_json::json!({ + "container_path": pf.container_path, + "file_env_var": pf.file_env_var, + "file_extension": pf.file_extension, + }) + }); + let private_host_resolved: Option = t.private_folder.as_ref().map(|_| { + te_service::resolve_private_host_path(&state.mcp_config_path, &t.id, stored_pf) + .to_string_lossy() + .into_owned() + }); serde_json::json!({ "id": t.id, "name": t.name, @@ -642,6 +754,8 @@ async fn handle_toolengine_catalog( "description": t.description, "installed": installed_ids.contains(&t.id), "commands": commands, + "private_folder": private_folder_json, + "private_host_path": private_host_resolved, }) }) .collect(); @@ -659,6 +773,12 @@ struct ToolEngineActionBody { tool_id: String, } +#[derive(Deserialize)] +struct PutToolPrivateFolderBody { + tool_id: String, + path: String, +} + async fn handle_toolengine_install( State(state): State, Json(body): Json, @@ -725,6 +845,159 @@ async fn handle_toolengine_install( Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } +async fn handle_toolengine_private_folder_put( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let tool_id = body.tool_id.trim().to_string(); + let path = body.path.trim().to_string(); + if tool_id.is_empty() || path.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "tool_id and path are required".into(), + }), + )); + } + + let catalog = te_service::load_catalog().await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; + + let entry = catalog + .tools + .iter() + .find(|t| t.id == tool_id) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("unknown tool '{tool_id}'"), + }), + ) + })?; + + if entry.private_folder.is_none() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "this catalog tool does not declare private_folder".into(), + }), + )); + } + + if !std::path::Path::new(&path).is_absolute() { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "path must be an absolute host directory".into(), + }), + )); + } + + let bot_id = state + .connection + .lock() + .await + .as_ref() + .map(|c| c.bot_id.clone()); + + { + let _guard = state.mcp_config_mutex.lock().await; + let mut cfg = mcp_service::load_or_init_config(&state.mcp_config_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; + + let key = te_service::server_key(&tool_id); + { + let Some(server_ent) = cfg.servers.get_mut(&key) else { + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("tool '{tool_id}' is not installed"), + }), + )); + }; + match server_ent { + crate::modules::mcp::types::ServerEntry::Stdio { + private_host_path, .. + } => { + if let Err(e) = tokio::fs::create_dir_all(&path).await { + let status = match e.kind() { + ErrorKind::PermissionDenied => StatusCode::FORBIDDEN, + ErrorKind::AlreadyExists => StatusCode::CONFLICT, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + return Err(( + status, + Json(ErrorResponse { + error: format!("cannot create directory: {e}"), + }), + )); + } + *private_host_path = Some(path.clone()); + } + _ => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: "tool server entry is not stdio".into(), + }), + )); + } + } + } + + let host_paths = mcp_service::filesystem_allowed_paths(&cfg); + te_service::sync_workspace_mounted_tools_for_catalog( + &mut cfg, + &host_paths, + &catalog, + &state.mcp_config_path, + bot_id, + ) + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; + + mcp_service::save_config(&state.mcp_config_path, &cfg).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: e }), + ) + })?; + } + + state + .emit_log( + "toolengine", + &format!("private data folder for {tool_id} set to {path}"), + ) + .await; + + let bg = state.clone(); + tokio::spawn(async move { + if let Err(e) = mcp_service::rebuild_registry_into_state(&bg).await { + bg.emit_log( + "mcp", + &format!("ERROR: MCP registry rebuild failed after private-folder update: {e}"), + ) + .await; + } + }); + + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + async fn handle_toolengine_uninstall( State(state): State, Json(body): Json, diff --git a/src-tauri/src/modules/bot/agent.rs b/src-tauri/src/modules/bot/agent.rs index ea51f1a..a7f29cf 100644 --- a/src-tauri/src/modules/bot/agent.rs +++ b/src-tauri/src/modules/bot/agent.rs @@ -1,12 +1,31 @@ +use crate::modules::memory::{self, MemoryProvider, SessionCommand}; use crate::modules::ollama::service as ollama; use crate::modules::tool_engine::service::workspace_app_bind_pairs; -use crate::shared::state::AppState; +use crate::shared::state::{AppState, MemorySession}; +use chrono::Utc; use serde_json::json; use std::time::{Duration, Instant}; const MAX_STEPS: usize = 3; -/// Ollama sometimes returns `function.arguments` as a JSON string; normalize to an object. +fn memory_hint(session_active: Option<&str>, diary_active: bool) -> String { + let status = match session_active { + Some(name) if diary_active => { + format!(" Diary recording ACTIVE (`{name}`); your replies are NOT saved.") + } + Some(name) => format!( + " Chat session ACTIVE (`{name}`); host records each turn — do NOT call memory write tools." + ), + None => String::new(), + }; + format!( + "\nMemory MCP server connected. Host controls recording via keywords \ +(\"captain's log\" / \"record\" to start; \"close session\" / \"over and out\" / \ +\"Commander out\" to stop). Use read tools (`read_graph`, `search_nodes`, \ +`open_nodes`) when prior context helps.{status}" + ) +} + fn tool_call_arguments(call: &serde_json::Value) -> serde_json::Value { let raw = call.get("function").and_then(|f| f.get("arguments")); match raw { @@ -19,9 +38,8 @@ fn tool_call_arguments(call: &serde_json::Value) -> serde_json::Value { } fn fmt_duration(d: Duration) -> String { - let ms = d.as_millis(); - if ms < 1000 { - format!("{ms}ms") + if d.as_millis() < 1000 { + format!("{}ms", d.as_millis()) } else { format!("{:.1}s", d.as_secs_f64()) } @@ -36,49 +54,293 @@ pub enum ReplySource { pub struct TurnResult { pub text: String, pub source: ReplySource, + pub suppress_telegram_reply: bool, +} + +impl TurnResult { + fn reply(text: impl Into) -> Self { + Self { + text: text.into(), + source: ReplySource::Model, + suppress_telegram_reply: false, + } + } + + fn suppressed() -> Self { + Self { + text: String::new(), + source: ReplySource::Model, + suppress_telegram_reply: true, + } + } } +// ── Public entry point ───────────────────────────────────────────── + pub async fn run_turn(state: &AppState, user_message: &str) -> Result { - let model = if let Some(selected) = state.preferred_ollama_model.read().await.clone() { - selected + if let Some(cmd) = memory::detect_session_command(user_message) { + return match cmd { + SessionCommand::Start => handle_recording_start(state, false).await, + SessionCommand::DiaryStart => handle_recording_start(state, true).await, + SessionCommand::End => handle_recording_end(state, false).await, + SessionCommand::DiaryEnd => handle_recording_end(state, true).await, + }; + } + + if let Some(s) = state.memory_session.read().await.clone() { + if s.diary_only { + return handle_diary_line(state, user_message).await; + } + } + + let result = run_model_turn(state, user_message).await?; + spawn_memory_save(state, user_message, &result.text).await; + Ok(result) +} + +// ── Memory recording handlers ────────────────────────────────────── + +async fn handle_recording_start(state: &AppState, diary: bool) -> Result { + let Some(memory) = MemoryProvider::detect(&*state.mcp.read().await) else { + return Ok(TurnResult::reply( + "No memory server is connected. Install a memory tool in Dashboard → MCP Tools first.", + )); + }; + + if let Some(existing) = state.memory_session.read().await.clone() { + let msg = if existing.diary_only == diary { + format!( + "Already recording as `{}`. Say \"over and out\" to end it.", + existing.entity_name + ) + } else if existing.diary_only { + "Diary recording is active — say \"record end\" or \"over and out\" first.".into() + } else { + "A chat session is active — say \"close session\" or \"over and out\" first.".into() + }; + return Ok(TurnResult::reply(msg)); + } + + let now = Utc::now(); + let prefix = if diary { "diary" } else { "session" }; + let entity_name = memory::entity_name(prefix, now); + let description = if diary { + format!( + "Diary opened at {} UTC (user lines only).", + now.format("%Y-%m-%d %H:%M:%S") + ) } else { - ollama::active_model().await? + format!( + "Chat session opened at {} UTC.", + now.format("%Y-%m-%d %H:%M:%S") + ) }; - let (ollama_tools, has_tools) = { - let reg = state.mcp.read().await; - (reg.ollama_tools(), !reg.is_empty()) + if let Err(e) = memory.start_session(&entity_name, &description).await { + state + .emit_log("memory", &format!("failed to open {prefix}: {e}")) + .await; + return Ok(TurnResult::reply(format!("Could not open {prefix}: {e}"))); + } + + *state.memory_session.write().await = Some(MemorySession { + entity_name: entity_name.clone(), + turn_count: 0, + diary_only: diary, + }); + + state + .emit_log( + "memory", + &format!("{prefix} opened on {}: {entity_name}", memory.server_name()), + ) + .await; + + let reply = if diary { + format!( + "Diary recording started (`{entity_name}`). Your lines are saved silently. \ + Say \"record end\" or \"over and out\" to stop." + ) + } else { + format!( + "Captain's log opened as `{entity_name}`. Every message is saved to memory. \ + Say \"close session\", \"end log\", or \"Commander out\" to close it." + ) + }; + Ok(TurnResult::reply(reply)) +} + +async fn handle_recording_end(state: &AppState, diary_only: bool) -> Result { + let session = { + let mut guard = state.memory_session.write().await; + match guard.as_ref() { + None => { + return Ok(TurnResult::reply("No memory recording is active.")); + } + Some(s) if diary_only && !s.diary_only => { + return Ok(TurnResult::reply( + "Not in diary mode — say \"close session\" or \"over and out\" to end the chat session.", + )); + } + _ => guard.take().unwrap(), + } }; - let fs_context = { - let paths = state.cached_filesystem_paths.read().await.clone(); - let host_lines: String = workspace_app_bind_pairs(&paths) - .iter() - .map(|(host, cpath)| format!(" - {cpath} ← {host}")) - .collect::>() - .join("\n"); - let roots_note = if paths.is_empty() { - "No shared folders are configured yet — the container only allows **`/tmp`** for MCP file tools. \ - To read a project like `pengine`, add its folder in Dashboard → MCP Tools (File Manager) first; \ - then use **`/app//README.md`** (folder-name = last path segment)." + if let Some(memory) = MemoryProvider::detect(&*state.mcp.read().await) { + let kind = if session.diary_only { + "Diary" } else { - "Use the **`/app/...`** paths below only — not host paths like /Users/…, and not **`/mcp/...`** (that is the server working directory, not a file root)." + "Session" }; - format!( - "\nFile Manager runs in a container. Allowed file roots are **`/tmp`** plus **`/app/`** for each folder you add in MCP Tools.\n\ - {roots_note}\n\ - Relative paths in tools are resolved under **`/app/`** (e.g. **`pengine/README.md`** → **`/app/pengine/README.md`**).\n\ -{host_lines}\n" + let note = format!( + "{kind} closed at {} UTC after {} turn(s).", + Utc::now().format("%Y-%m-%d %H:%M:%S"), + session.turn_count + ); + if let Err(e) = memory.append(&session.entity_name, ¬e).await { + state + .emit_log("memory", &format!("close note not saved: {e}")) + .await; + } + } + + let kind = if session.diary_only { + "diary" + } else { + "session" + }; + state + .emit_log( + "memory", + &format!( + "{kind} closed: {} ({} turn(s))", + session.entity_name, session.turn_count + ), + ) + .await; + + Ok(TurnResult::reply(format!( + "Memory {kind} `{}` closed after {} turn(s).", + session.entity_name, session.turn_count + ))) +} + +async fn handle_diary_line(state: &AppState, user_message: &str) -> Result { + let Some(session) = state.memory_session.read().await.clone() else { + return Ok(TurnResult::reply( + "Diary ended; send \"record\" to start a new one.", + )); + }; + let Some(mem) = MemoryProvider::detect(&*state.mcp.read().await) else { + *state.memory_session.write().await = None; + return Ok(TurnResult::reply( + "Memory server disconnected — diary stopped.", + )); + }; + + spawn_append( + state, + &mem, + &session.entity_name, + format!("[diary] {user_message}"), + ) + .await; + Ok(TurnResult::suppressed()) +} + +// ── Background memory persistence ────────────────────────────────── + +async fn spawn_memory_save(state: &AppState, user_message: &str, reply: &str) { + let Some(session) = state.memory_session.read().await.clone() else { + return; + }; + if session.diary_only { + return; + } + let Some(mem) = MemoryProvider::detect(&*state.mcp.read().await) else { + state + .emit_log( + "memory", + &format!( + "session `{}` active but no memory server — turn dropped", + session.entity_name + ), + ) + .await; + return; + }; + + let content = format!("[user] {user_message}\n[assistant] {reply}"); + spawn_append(state, &mem, &session.entity_name, content).await; +} + +async fn spawn_append(state: &AppState, mem: &MemoryProvider, entity: &str, content: String) { + let state_bg = state.clone(); + let entity = entity.to_string(); + let mem_server = mem.provider_clone(); + tokio::spawn(async move { + let mem = mem_server; + match mem.append(&entity, &content).await { + Ok(()) => { + if let Some(s) = state_bg.memory_session.write().await.as_mut() { + if s.entity_name == entity { + s.turn_count += 1; + } + } + } + Err(e) => { + state_bg + .emit_log("memory", &format!("append to `{entity}` failed: {e}")) + .await; + } + } + }); +} + +// ── Model turn with tool loop ────────────────────────────────────── + +async fn run_model_turn(state: &AppState, user_message: &str) -> Result { + let model = match state.preferred_ollama_model.read().await.clone() { + Some(m) => m, + None => ollama::active_model().await?, + }; + + let (ollama_tools, has_tools, has_memory) = { + let reg = state.mcp.read().await; + ( + reg.ollama_tools(), + !reg.is_empty(), + MemoryProvider::detect(®).is_some(), ) }; + let mem_snapshot = state.memory_session.read().await.clone(); + let system = if has_tools { + let fs_hint = { + let paths = state.cached_filesystem_paths.read().await.clone(); + if paths.is_empty() { + String::new() + } else { + let mounts: String = workspace_app_bind_pairs(&paths) + .iter() + .map(|(host, cpath)| format!("{cpath} ← {host}")) + .collect::>() + .join(", "); + format!("\nFile tools use container paths under /app/. Mounts: {mounts}. Use /app/… paths only.") + } + }; + let mem_hint = if has_memory { + memory_hint( + mem_snapshot.as_ref().map(|s| s.entity_name.as_str()), + mem_snapshot.as_ref().is_some_and(|s| s.diary_only), + ) + } else { + String::new() + }; format!( - "You are a helpful assistant with tool access.\n\ - Rules:\n\ - - Call a tool ONLY when you need external data you don't already have.\n\ - - After receiving tool results, answer the user's question immediately in the same response.\n\ - - Be concise and direct.{fs_context}" + "Helpful assistant with tools. Call a tool ONLY when you need external data. \ + After tool results, answer immediately. Be concise.{fs_hint}{mem_hint}" ) } else { "Answer concisely.".to_string() @@ -93,9 +355,8 @@ pub async fn run_turn(state: &AppState, user_message: &str) -> Result Result Result = Vec::new(); - - for call in &tool_calls { - let name = call - .get("function") - .and_then(|f| f.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let args = tool_call_arguments(call); + // Resolve under one lock, then execute in parallel. + let prepared: Vec<_> = { + let reg = state.mcp.read().await; + tool_calls + .iter() + .map(|call| { + let name = call + .get("function") + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let args = tool_call_arguments(call); + let resolved = reg.prepare_tool_invocation(&name, args); + (name, resolved) + }) + .collect() + }; + let t0 = Instant::now(); + let mut handles = Vec::with_capacity(prepared.len()); + for (name, resolved) in &prepared { state.emit_log("tool", &format!("[{step}] {name}")).await; + match resolved { + Ok((provider, tool_name, _, args)) => { + let (p, tn, a) = (provider.clone(), tool_name.clone(), args.clone()); + handles.push(tokio::spawn(async move { p.call_tool(&tn, a).await })); + } + Err(_) => { + handles.push(tokio::spawn(async { Err("resolve failed".to_string()) })); + } + } + } - let t_tool = Instant::now(); - let resolved = { - let reg = state.mcp.read().await; - reg.resolve_tool(&name) - }; - let (result_text, is_direct) = match resolved { - Ok((provider, tool_name, direct)) => { - match provider.call_tool(&tool_name, args).await { - Ok(text) => { - state - .emit_log("tool", &format!("result ({} bytes)", text.len())) - .await; - (text, direct) - } - Err(e) => { - state.emit_log("tool", &format!("error: {e}")).await; - (format!("ERROR: {e}"), false) - } - } + let mut direct_replies: Vec = Vec::new(); + for (i, handle) in handles.into_iter().enumerate() { + let (name, resolved) = &prepared[i]; + let (text, is_direct) = match handle.await { + Ok(Ok(text)) => { + let direct = resolved.as_ref().map(|(_, _, d, _)| *d).unwrap_or(false); + state + .emit_log("tool", &format!("{name}: {} bytes", text.len())) + .await; + (text, direct) } - Err(e) => { - state.emit_log("tool", &format!("error: {e}")).await; + Ok(Err(e)) => { + state.emit_log("tool", &format!("{name} error: {e}")).await; (format!("ERROR: {e}"), false) } + Err(e) => { + state + .emit_log("tool", &format!("{name} panicked: {e}")) + .await; + ("ERROR: task panicked".to_string(), false) + } }; - state - .emit_log( - "time", - &format!("tool {name} {}", fmt_duration(t_tool.elapsed())), - ) - .await; - - tool_results.push((name.clone(), result_text.clone())); if is_direct { - direct_replies.push(result_text.clone()); + direct_replies.push(text.clone()); } - if let Some(arr) = messages.as_array_mut() { - arr.push(json!({ - "role": "tool", - "name": name, - "content": result_text, - })); + arr.push(json!({ "role": "tool", "name": name, "content": &text })); } + tool_results.push((name.clone(), text)); } + state + .emit_log( + "time", + &format!("{} tool(s) {}", prepared.len(), fmt_duration(t0.elapsed())), + ) + .await; if !direct_replies.is_empty() { return Ok(TurnResult { text: direct_replies.join("\n\n"), source: ReplySource::Tool, + suppress_telegram_reply: false, }); } } - // Phase 2: tools ran but model didn't produce a good answer yet. - // Make a clean summarization call — no tools, plain Q&A with inlined data. + // Phase 2: summarize tool results if model didn't answer inline. if !tool_results.is_empty() { - let mut data_block = String::new(); + let mut data = String::new(); for (name, content) in &tool_results { - data_block.push_str(&format!("--- {name} result ---\n{content}\n")); + data.push_str(&format!("--- {name} ---\n{content}\n")); } let summary_messages = json!([ - { - "role": "system", - "content": "Answer the user's question using ONLY the data provided below. Be concise and direct." - }, - { - "role": "user", - "content": format!("{user_message}\n\nData:\n{data_block}") - } + { "role": "system", "content": "Answer using ONLY the data below. Be concise." }, + { "role": "user", "content": format!("{user_message}\n\nData:\n{data}") } ]); - let empty = json!([]); - let t_summary = Instant::now(); - let summary_result = ollama::chat_with_tools(&model, &summary_messages, &empty).await?; - let summary_msg = summary_result.message; + let t0 = Instant::now(); + let result = ollama::chat_with_tools(&model, &summary_messages, &json!([])).await?; state - .emit_log( - "time", - &format!("summarize {}", fmt_duration(t_summary.elapsed())), - ) + .emit_log("time", &format!("summarize {}", fmt_duration(t0.elapsed()))) .await; - let text = summary_msg + let text = result + .message .get("content") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - if !text.trim().is_empty() { - state.emit_log("tool", "answered from tool data").await; return Ok(TurnResult { text, source: ReplySource::Model, + suppress_telegram_reply: false, }); } - let fallback = tool_results - .last() - .map(|(_, c)| c.clone()) - .expect("tool_results must be non-empty here after guard"); - state - .emit_log("tool", "empty summary, returning raw tool output") - .await; + let fallback = tool_results.into_iter().last().unwrap().1; return Ok(TurnResult { text: fallback, source: ReplySource::Tool, + suppress_telegram_reply: false, }); } diff --git a/src-tauri/src/modules/bot/service.rs b/src-tauri/src/modules/bot/service.rs index 517fbd2..dbdf475 100644 --- a/src-tauri/src/modules/bot/service.rs +++ b/src-tauri/src/modules/bot/service.rs @@ -122,6 +122,12 @@ async fn text_handler(bot: Bot, msg: Message, state: AppState) -> ResponseResult match result { Ok(turn) => { + if turn.suppress_telegram_reply { + state + .emit_log("reply", "[diary line saved; no Telegram reply]") + .await; + return Ok(()); + } let reply = if turn.text.trim().is_empty() { "(no reply)".to_string() } else { diff --git a/src-tauri/src/modules/mcp/client.rs b/src-tauri/src/modules/mcp/client.rs index 90245d9..e754384 100644 --- a/src-tauri/src/modules/mcp/client.rs +++ b/src-tauri/src/modules/mcp/client.rs @@ -2,6 +2,7 @@ use super::transport::StdioTransport; use super::types::ToolDef; use serde_json::{json, Value}; use std::collections::HashMap; +use std::sync::RwLock; use std::time::Duration; /// `podman run` + `npx -y` inside the container can exceed a minute on cold cache / slow networks. @@ -10,7 +11,7 @@ const MCP_CONNECT_CALL_TIMEOUT: Duration = Duration::from_secs(300); pub struct McpClient { pub server_name: String, transport: StdioTransport, - pub tools: Vec, + tool_defs: RwLock>, } impl McpClient { @@ -47,10 +48,26 @@ impl McpClient { Ok(Self { server_name, transport, - tools, + tool_defs: RwLock::new(tools), }) } + /// Snapshot of tool definitions (names, schemas, `direct_return`, …). + pub fn tools(&self) -> Vec { + self.tool_defs + .read() + .expect("tool_defs lock poisoned") + .clone() + } + + /// Update the `direct_return` flag on every tool for this server without reconnecting stdio. + pub fn set_all_direct_return(&self, direct_return: bool) { + let mut tools = self.tool_defs.write().expect("tool_defs lock poisoned"); + for t in tools.iter_mut() { + t.direct_return = direct_return; + } + } + pub async fn call_tool(&self, name: &str, args: Value) -> Result { let result = self .transport diff --git a/src-tauri/src/modules/mcp/native.rs b/src-tauri/src/modules/mcp/native.rs index ecc78e8..cca5b64 100644 --- a/src-tauri/src/modules/mcp/native.rs +++ b/src-tauri/src/modules/mcp/native.rs @@ -87,8 +87,11 @@ pub fn tool_manager_named(server_key: &str, state: AppState) -> NativeProvider { "Manage container-based tools from the catalog. All catalog tools (e.g. File Manager) \ are user-managed and can be freely installed or uninstalled on request. \ Use action 'list' to see all available catalog tools and their install status. \ - Use action 'install' with a tool_id to install a tool. \ - Use action 'uninstall' with a tool_id to remove an installed tool. \ + Use action 'install' with a tool_id to install one tool. \ + Use action 'install_all' (no tool_id) to install every catalog tool not yet installed — \ + prefer this when the user asks to install all tools. Never use 'uninstall_all' for that. \ + Use action 'uninstall' with a tool_id to remove one installed tool. \ + Use action 'uninstall_all' (no tool_id) only when the user asks to remove every catalog tool. \ Always call this tool when the user asks to install, uninstall, or list tools." .to_string(), ), @@ -98,12 +101,12 @@ pub fn tool_manager_named(server_key: &str, state: AppState) -> NativeProvider { "properties": { "action": { "type": "string", - "enum": ["list", "install", "uninstall"], - "description": "The operation: 'list' to show available tools, 'install' or 'uninstall' to change a tool" + "enum": ["list", "install", "install_all", "uninstall", "uninstall_all"], + "description": "The operation: 'list'; 'install' / 'uninstall' for one tool; 'install_all' / 'uninstall_all' for every catalog tool at once" }, "tool_id": { "type": "string", - "description": "Required for install/uninstall. Use the exact id from the 'list' output (e.g. 'pengine/file-manager'). Call with action 'list' first if unsure." + "description": "Required for install and uninstall only. Omit for list, install_all, and uninstall_all. Use the exact id from the 'list' output (e.g. 'pengine/file-manager')." } } }), @@ -132,6 +135,7 @@ async fn handle_tool_manager( .ok_or("missing 'tool_id' for install")?; handle_install_tool(tool_id, state).await } + "install_all" => handle_install_all_tools(state).await, "uninstall" => { let tool_id = args .get("tool_id") @@ -139,6 +143,7 @@ async fn handle_tool_manager( .ok_or("missing 'tool_id' for uninstall")?; handle_uninstall_tool(tool_id, state).await } + "uninstall_all" => handle_uninstall_all_tools(state).await, _ => Err(format!("unknown action: {action}")), } } @@ -178,11 +183,91 @@ async fn handle_install_tool(tool_id: &str, state: &AppState) -> Result Result { + let runtime = tool_engine_runtime::detect_runtime().await.ok_or( + "No container runtime (Docker/Podman) found. Please install Docker or Podman first.", + )?; + + let summary = { + let _te_guard = state.tool_engine_mutex.lock().await; + state + .emit_log( + "toolengine", + "installing all missing catalog tools via chat…", + ) + .await; + let log_state = state.clone(); + let log_fn: tool_engine_service::LogFn = Box::new(move |msg: &str| { + let s = log_state.clone(); + let m = msg.to_string(); + tokio::spawn(async move { s.emit_log("toolengine", &m).await }); + }); + let out = tool_engine_service::install_all_catalog_tools( + &runtime, + &state.mcp_config_path, + &state.mcp_config_mutex, + &log_fn, + ) + .await; + state + .emit_log("toolengine", "catalog install-all finished via chat") + .await; + out + }?; + + if let Err(e) = mcp_service::rebuild_registry_into_state(state).await { + state + .emit_log( + "mcp", + &format!("registry rebuild after install_all failed: {e}"), + ) + .await; + return Err(e); + } + + Ok(summary) +} + async fn handle_uninstall_tool(tool_id: &str, state: &AppState) -> Result { run_tool_mutation(tool_id, state, "uninstall", ToolAction::Uninstall).await?; Ok(format!("Tool '{tool_id}' uninstalled successfully.")) } +async fn handle_uninstall_all_tools(state: &AppState) -> Result { + let runtime = tool_engine_runtime::detect_runtime().await.ok_or( + "No container runtime (Docker/Podman) found. Please install Docker or Podman first.", + )?; + + let summary = { + let _te_guard = state.tool_engine_mutex.lock().await; + state + .emit_log("toolengine", "uninstalling all catalog tools via chat…") + .await; + let out = tool_engine_service::uninstall_all_catalog_tools( + &runtime, + &state.mcp_config_path, + &state.mcp_config_mutex, + ) + .await; + state + .emit_log("toolengine", "catalog uninstall-all finished via chat") + .await; + out + }?; + + if let Err(e) = mcp_service::rebuild_registry_into_state(state).await { + state + .emit_log( + "mcp", + &format!("registry rebuild after uninstall_all failed: {e}"), + ) + .await; + return Err(e); + } + + Ok(summary) +} + enum ToolAction { Install, Uninstall, diff --git a/src-tauri/src/modules/mcp/registry.rs b/src-tauri/src/modules/mcp/registry.rs index 4a1ed44..95a3962 100644 --- a/src-tauri/src/modules/mcp/registry.rs +++ b/src-tauri/src/modules/mcp/registry.rs @@ -111,10 +111,10 @@ impl Provider { } } - pub fn tools(&self) -> &[ToolDef] { + pub fn tools(&self) -> Vec { match self { - Provider::Native(n) => &n.tools, - Provider::Mcp(c) => &c.tools, + Provider::Native(n) => n.tools.clone(), + Provider::Mcp(c) => c.tools(), } } @@ -147,7 +147,7 @@ impl ToolRegistry { let cached_ollama_tools = build_ollama_tools(&providers); let cached_tool_names = providers .iter() - .flat_map(|p| p.tools().iter()) + .flat_map(|p| p.tools()) .filter(|t| !is_deprecated_mcp_tool(t)) .map(|t| t.name.clone()) .collect(); @@ -161,9 +161,8 @@ impl ToolRegistry { pub fn all_tools(&self) -> Vec { self.providers .iter() - .flat_map(|p| p.tools().iter()) + .flat_map(|p| p.tools()) .filter(|t| !is_deprecated_mcp_tool(t)) - .cloned() .collect() } @@ -181,14 +180,36 @@ impl ToolRegistry { self.cached_tool_names.is_empty() } - pub async fn call_tool(&self, name: &str, args: Value) -> Result<(String, bool), String> { - let (provider, tool, direct) = self.resolve_tool(name)?; - let args = match &provider { - Provider::Mcp(c) if c.server_name == "te_pengine-file-manager" => { + /// Read-only access to the registered providers. Used by capability detectors + /// (e.g. `memory::MemoryProvider::detect`) that pick a server by its tool shape. + pub fn providers(&self) -> &[Provider] { + &self.providers + } + + fn normalize_tool_args_for_provider(provider: &Provider, args: Value) -> Value { + match provider { + Provider::Mcp(c) if is_pengine_file_manager_server_key(c.server_name.as_str()) => { normalize_file_manager_tool_args(args) } _ => args, - }; + } + } + + /// Resolve a tool and rewrite arguments (e.g. File Manager `/mcp/...` → `/app/...`). + /// Call [`Provider::call_tool`] with the returned name and args **after** releasing any lock on this registry. + pub fn prepare_tool_invocation( + &self, + name: &str, + args: Value, + ) -> Result<(Provider, String, bool, Value), String> { + let (provider, tool, direct) = self.resolve_tool(name)?; + let args = Self::normalize_tool_args_for_provider(&provider, args); + Ok((provider, tool, direct, args)) + } + + pub async fn call_tool(&self, name: &str, args: Value) -> Result<(String, bool), String> { + let (provider, tool, direct) = self.resolve_tool(name)?; + let args = Self::normalize_tool_args_for_provider(&provider, args); let text = provider.call_tool(&tool, args).await?; Ok((text, direct)) } @@ -200,17 +221,17 @@ impl ToolRegistry { }; if server.is_none() { - let mut found: Vec<(&Provider, &ToolDef)> = Vec::new(); + let mut found: Vec<(Provider, ToolDef)> = Vec::new(); for provider in &self.providers { - if let Some(def) = provider.tools().iter().find(|t| t.name == tool) { - found.push((provider, def)); + if let Some(def) = provider.tools().into_iter().find(|t| t.name == tool) { + found.push((provider.clone(), def)); } } return match found.len() { 0 => Err(format!("tool not found: {name}")), 1 => { - let (p, d) = found[0]; - Ok((p.clone(), tool.to_string(), d.direct_return)) + let (p, d) = found.into_iter().next().expect("len 1"); + Ok((p, tool.to_string(), d.direct_return)) } _ => { let servers: Vec<_> = found.iter().map(|(p, _)| p.server_name()).collect(); @@ -228,7 +249,7 @@ impl ToolRegistry { if !provider.server_name().eq_ignore_ascii_case(key) { continue; } - if let Some(def) = provider.tools().iter().find(|t| t.name == tool) { + if let Some(def) = provider.tools().into_iter().find(|t| t.name == tool) { return Ok((provider.clone(), tool.to_string(), def.direct_return)); } } @@ -237,6 +258,11 @@ impl ToolRegistry { } } +/// `mcp.json` key for the catalog tool `pengine/file-manager` (same formula as `tool_engine::server_key`). +fn is_pengine_file_manager_server_key(key: &str) -> bool { + key.eq_ignore_ascii_case("te_pengine-file-manager") +} + /// Hide tools the server marks as deprecated (e.g. filesystem `read_file` → use `read_text_file`). fn is_deprecated_mcp_tool(tool: &ToolDef) -> bool { tool.description @@ -246,10 +272,29 @@ fn is_deprecated_mcp_tool(tool: &ToolDef) -> bool { .contains("DEPRECATED") } +/// Strip `"description"` keys from nested property objects inside a JSON Schema to reduce +/// token count. Keeps `type`, `properties`, `required`, `enum`, `items`, `default`, etc. +fn compact_schema(schema: &Value) -> Value { + match schema { + Value::Object(map) => { + let mut out = serde_json::Map::with_capacity(map.len()); + for (k, v) in map { + if k == "description" { + continue; + } + out.insert(k.clone(), compact_schema(v)); + } + Value::Object(out) + } + Value::Array(arr) => Value::Array(arr.iter().map(compact_schema).collect()), + other => other.clone(), + } +} + fn build_ollama_tools(providers: &[Provider]) -> Value { let arr: Vec = providers .iter() - .flat_map(|p| p.tools().iter()) + .flat_map(|p| p.tools()) .filter(|t| !is_deprecated_mcp_tool(t)) .map(|t| { json!({ @@ -257,7 +302,7 @@ fn build_ollama_tools(providers: &[Provider]) -> Value { "function": { "name": t.name, "description": t.description.clone().unwrap_or_default(), - "parameters": t.input_schema, + "parameters": compact_schema(&t.input_schema), } }) }) @@ -301,4 +346,35 @@ mod tests { let out = normalize_file_manager_tool_args(raw); assert_eq!(out["path"], "/app/pengine/readme.md"); } + + #[test] + fn compact_schema_strips_descriptions() { + let schema = json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The file path to read" + }, + "encoding": { + "type": "string", + "description": "Character encoding", + "enum": ["utf-8", "ascii"] + } + }, + "required": ["path"] + }); + let compact = compact_schema(&schema); + assert_eq!(compact["type"], "object"); + assert_eq!(compact["required"], json!(["path"])); + assert!(compact["properties"]["path"].get("description").is_none()); + assert_eq!(compact["properties"]["path"]["type"], "string"); + assert!(compact["properties"]["encoding"] + .get("description") + .is_none()); + assert_eq!( + compact["properties"]["encoding"]["enum"], + json!(["utf-8", "ascii"]) + ); + } } diff --git a/src-tauri/src/modules/mcp/service.rs b/src-tauri/src/modules/mcp/service.rs index ee780be..c78cece 100644 --- a/src-tauri/src/modules/mcp/service.rs +++ b/src-tauri/src/modules/mcp/service.rs @@ -158,6 +158,7 @@ pub async fn connect_one_server( args, env, direct_return, + .. } => match McpClient::connect( server_key.to_string(), command.clone(), @@ -168,7 +169,7 @@ pub async fn connect_one_server( .await { Ok(client) => { - let n = client.tools.len(); + let n = client.tools().len(); let cmd_word = if n == 1 { "command" } else { "commands" }; let dr = if *direct_return { " direct_return" } else { "" }; let msg = format!("{server_key} stdio ({n} {cmd_word}{dr})"); @@ -197,6 +198,42 @@ pub async fn build_mcp_providers(cfg: &McpConfig) -> (Vec, Vec (providers, status) } +/// Flip `direct_return` on every tool for one connected stdio server — no new MCP handshake and no +/// reconnect for other servers. Used when `mcp.json` changes only that flag. +/// +/// Returns `true` if a matching stdio provider was found and updated. +pub async fn patch_stdio_direct_return_in_registry( + state: &crate::shared::state::AppState, + server_key: &str, + direct_return: bool, +) -> bool { + let _rebuild = state.mcp_rebuild_mutex.lock().await; + let mut patched = false; + { + let reg = state.mcp.read().await; + for p in reg.providers() { + if !p.server_name().eq_ignore_ascii_case(server_key) { + continue; + } + if let Provider::Mcp(client) = p { + client.set_all_direct_return(direct_return); + patched = true; + break; + } + } + } + if patched { + state + .emit_log( + "mcp", + &format!("server '{server_key}' direct_return updated (no full reconnect)"), + ) + .await; + emit_registry_changed_event(state).await; + } + patched +} + /// Reload `mcp.json` from disk and replace the in-memory tool registry. /// /// Call only after the file on disk is up to date. Holds `mcp_rebuild_mutex` for the full connect @@ -224,11 +261,21 @@ pub async fn rebuild_registry_into_state( }; let paths = filesystem_allowed_paths(&cfg); + let bot_id = state + .connection + .lock() + .await + .as_ref() + .map(|c| c.bot_id.clone()); let mut ws_changed = false; match &catalog_result { Ok(cat) => { match crate::modules::tool_engine::service::sync_workspace_mounted_tools_for_catalog( - &mut cfg, &paths, cat, + &mut cfg, + &paths, + cat, + &state.mcp_config_path, + bot_id, ) { Ok(changed) => ws_changed |= changed, Err(e) => { diff --git a/src-tauri/src/modules/mcp/types.rs b/src-tauri/src/modules/mcp/types.rs index 9b62f4a..de14ccd 100644 --- a/src-tauri/src/modules/mcp/types.rs +++ b/src-tauri/src/modules/mcp/types.rs @@ -17,7 +17,7 @@ pub struct McpConfig { } /// One logical MCP server. Same top-level shape for every backend: `type` picks the loader. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ServerEntry { /// In-process tool pack; `id` selects a built-in (e.g. `dice`). @@ -33,6 +33,10 @@ pub enum ServerEntry { /// sending them back to the model for summarisation. #[serde(default)] direct_return: bool, + /// For catalog tools that declare `private_folder`: the host directory currently mounted + /// into the container. Defaults to `$APP_DATA/tool-data//`; user overrides land here. + #[serde(default, skip_serializing_if = "Option::is_none")] + private_host_path: Option, }, } diff --git a/src-tauri/src/modules/memory/mod.rs b/src-tauri/src/modules/memory/mod.rs new file mode 100644 index 0000000..0a38136 --- /dev/null +++ b/src-tauri/src/modules/memory/mod.rs @@ -0,0 +1,341 @@ +//! Generic, backend-agnostic memory capability. +//! +//! The agent talks to `MemoryProvider` without knowing which MCP server is behind it. +//! Detection uses **tool shape** (tool names + JSON Schema checks), or the official +//! catalog server key `te_pengine-memory` as fallback. +//! +//! ## Session policy lives here, not in the agent +//! +//! Keyword phrases and entity naming are defined in this module. Swapping the backing +//! MCP server never touches them. +//! +//! ## Adding a new memory backend +//! +//! 1. Add a [`Backend`] variant. +//! 2. Add a detection arm in [`MemoryProvider::detect`]. +//! 3. Add match arms in [`MemoryProvider::start_session`] / [`MemoryProvider::append`]. + +use crate::modules::mcp::registry::{Provider, ToolRegistry}; +use crate::modules::mcp::types::ToolDef; +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; + +/// Phrases that open a full-transcript session (user + assistant each turn). +pub const SESSION_START_PHRASES: &[&str] = &[ + "remember this session", + "save this session", + "captain's log", + "captains log", + "begin log", +]; + +/// Phrases that end any active recording (session or diary). +pub const SESSION_END_PHRASES: &[&str] = &[ + "close session", + "leave session", + "over and out", + "quit", + "exit", + "end log", +]; + +/// Start diary-only recording (user lines only). +pub const DIARY_START_PHRASES: &[&str] = &["record"]; + +/// Stop diary-only recording. +pub const DIARY_END_PHRASES: &[&str] = &["record end"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionCommand { + Start, + End, + DiaryStart, + DiaryEnd, +} + +/// Normalize a message for keyword matching: map curly quotes to ASCII, strip trailing +/// non-alphanumeric chars, lowercase. All keywords are ASCII so this is sufficient. +fn normalize(msg: &str) -> String { + let mapped: String = msg + .chars() + .map(|c| match c { + '\u{2018}' | '\u{2019}' => '\'', + '\u{201C}' | '\u{201D}' => '"', + c => c, + }) + .collect(); + mapped + .trim() + .trim_end_matches(|c: char| !c.is_alphanumeric()) + .to_lowercase() +} + +/// ` out` with 3–4 tokens. Rank must be present. +fn is_starfleet_signoff(s: &str) -> bool { + let toks: Vec<&str> = s.split_whitespace().collect(); + if !(3..=4).contains(&toks.len()) { + return false; + } + matches!(toks[0], "commander" | "captain") + && toks.last() == Some(&"out") + && toks[1..toks.len() - 1] + .iter() + .all(|t| !t.is_empty() && t.chars().all(|c| c.is_alphabetic())) +} + +/// Match a user message against keyword lists. Only exact (full-message) matches count. +pub fn detect_session_command(msg: &str) -> Option { + let n = normalize(msg); + if DIARY_END_PHRASES.iter().any(|p| n == *p) { + return Some(SessionCommand::DiaryEnd); + } + if DIARY_START_PHRASES.iter().any(|p| n == *p) { + return Some(SessionCommand::DiaryStart); + } + if SESSION_START_PHRASES.iter().any(|p| n == *p) { + return Some(SessionCommand::Start); + } + if SESSION_END_PHRASES.iter().any(|p| n == *p) { + return Some(SessionCommand::End); + } + if is_starfleet_signoff(&n) { + return Some(SessionCommand::End); + } + None +} + +/// Sortable entity name: `-YYYYMMDDThhmmssZ`. +pub fn entity_name(prefix: &str, at: DateTime) -> String { + format!("{prefix}-{}", at.format("%Y%m%dT%H%M%SZ")) +} + +const PENGINE_MEMORY_SERVER_KEY: &str = "te_pengine-memory"; + +enum Backend { + KnowledgeGraph, +} + +pub struct MemoryProvider { + backend: Backend, + provider: Provider, +} + +/// Check if a JSON Schema has `properties..items.properties` containing all `keys`. +fn schema_has_array_item_keys(schema: &Value, array_prop: &str, keys: &[&str]) -> bool { + schema + .get("properties") + .and_then(|p| p.get(array_prop)) + .and_then(|a| a.get("items")) + .and_then(|i| i.get("properties")) + .and_then(|p| p.as_object()) + .is_some_and(|props| keys.iter().all(|k| props.contains_key(*k))) +} + +fn is_knowledge_graph_shape(tools: &[ToolDef]) -> bool { + let create = tools.iter().find(|t| t.name == "create_entities"); + let add = tools.iter().find(|t| t.name == "add_observations"); + match (create, add) { + (Some(c), Some(a)) => { + schema_has_array_item_keys( + &c.input_schema, + "entities", + &["name", "entityType", "observations"], + ) && schema_has_array_item_keys( + &a.input_schema, + "observations", + &["entityName", "contents"], + ) + } + _ => false, + } +} + +impl MemoryProvider { + pub fn detect(reg: &ToolRegistry) -> Option { + for p in reg.providers() { + let tools = p.tools(); + let has_tools = tools.iter().any(|t| t.name == "create_entities") + && tools.iter().any(|t| t.name == "add_observations"); + if !has_tools { + continue; + } + if is_knowledge_graph_shape(&tools) || p.server_name() == PENGINE_MEMORY_SERVER_KEY { + return Some(Self { + backend: Backend::KnowledgeGraph, + provider: p.clone(), + }); + } + } + None + } + + pub fn server_name(&self) -> &str { + self.provider.server_name() + } + + pub fn provider_clone(&self) -> Self { + Self { + backend: Backend::KnowledgeGraph, + provider: self.provider.clone(), + } + } + + pub async fn start_session(&self, name: &str, description: &str) -> Result<(), String> { + match self.backend { + Backend::KnowledgeGraph => { + let args = json!({ + "entities": [{ + "name": name, + "entityType": "ChatSession", + "observations": [description], + }] + }); + if let Err(e) = self.provider.call_tool("create_entities", args).await { + let el = e.to_lowercase(); + if !el.contains("already exist") && !el.contains("duplicate") { + return Err(e); + } + } + } + } + Ok(()) + } + + pub async fn append(&self, entity_name: &str, content: &str) -> Result<(), String> { + match self.backend { + Backend::KnowledgeGraph => { + let args = json!({ + "observations": [{ + "entityName": entity_name, + "contents": [content], + }] + }); + self.provider.call_tool("add_observations", args).await?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_phrases_match_exactly_ignoring_case_and_punctuation() { + for p in SESSION_START_PHRASES { + assert_eq!(detect_session_command(p), Some(SessionCommand::Start)); + assert_eq!( + detect_session_command(&p.to_uppercase()), + Some(SessionCommand::Start) + ); + assert_eq!( + detect_session_command(&format!(" {p}.")), + Some(SessionCommand::Start) + ); + } + } + + #[test] + fn end_phrases_match_exactly() { + for p in SESSION_END_PHRASES { + assert_eq!(detect_session_command(p), Some(SessionCommand::End)); + } + } + + #[test] + fn diary_phrases() { + assert_eq!( + detect_session_command("record"), + Some(SessionCommand::DiaryStart) + ); + assert_eq!( + detect_session_command("record end"), + Some(SessionCommand::DiaryEnd) + ); + assert_eq!( + detect_session_command("record."), + Some(SessionCommand::DiaryStart) + ); + } + + #[test] + fn casual_mentions_do_not_trigger() { + assert_eq!(detect_session_command("I want to quit my job"), None); + assert_eq!(detect_session_command("exit the building safely"), None); + assert_eq!( + detect_session_command("please remember this session later"), + None + ); + assert_eq!(detect_session_command("logging out"), None); + assert_eq!(detect_session_command("I need to head out"), None); + } + + #[test] + fn starfleet_signoff_closes_session() { + assert_eq!( + detect_session_command("Commander Worf out"), + Some(SessionCommand::End) + ); + assert_eq!( + detect_session_command("Captain Picard out."), + Some(SessionCommand::End) + ); + assert_eq!( + detect_session_command("commander data out"), + Some(SessionCommand::End) + ); + assert_eq!( + detect_session_command("Captain Jean Luc out"), + Some(SessionCommand::End) + ); + assert_eq!(detect_session_command("Captain Jean Luc Picard out"), None); + assert_eq!(detect_session_command("Captain Picard 2 out"), None); + assert_eq!(detect_session_command("Kirk out"), None); + } + + #[test] + fn captains_log_opens_session() { + assert_eq!( + detect_session_command("Captain's Log"), + Some(SessionCommand::Start) + ); + assert_eq!( + detect_session_command("captains log"), + Some(SessionCommand::Start) + ); + assert_eq!( + detect_session_command("Captain\u{2019}s log"), + Some(SessionCommand::Start) + ); + assert_eq!( + detect_session_command("captain\u{2019}s log\u{2026}"), + Some(SessionCommand::Start) + ); + } + + #[test] + fn entity_names_are_sortable_and_prefixed() { + let t1 = chrono::DateTime::parse_from_rfc3339("2026-04-16T10:00:00Z") + .unwrap() + .with_timezone(&Utc); + let t2 = chrono::DateTime::parse_from_rfc3339("2026-04-16T11:30:00Z") + .unwrap() + .with_timezone(&Utc); + assert!(entity_name("session", t1) < entity_name("session", t2)); + assert!(entity_name("session", t1).starts_with("session-")); + assert!(entity_name("diary", t1).starts_with("diary-")); + } + + #[test] + fn cjk_trailing_punctuation_stripped() { + assert_eq!( + detect_session_command("record\u{3002}"), + Some(SessionCommand::DiaryStart) + ); + assert_eq!( + detect_session_command("quit\u{FF01}"), + Some(SessionCommand::End) + ); + } +} diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index 0493163..1252ce7 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -1,4 +1,5 @@ pub mod bot; pub mod mcp; +pub mod memory; pub mod ollama; pub mod tool_engine; diff --git a/src-tauri/src/modules/tool_engine/service.rs b/src-tauri/src/modules/tool_engine/service.rs index 009f8e8..9483dc4 100644 --- a/src-tauri/src/modules/tool_engine/service.rs +++ b/src-tauri/src/modules/tool_engine/service.rs @@ -1,16 +1,20 @@ use super::runtime::RuntimeInfo; -use super::types::{ToolCatalog, ToolEntry, VersionEntry}; +use super::types::{PrivateFolderConfig, ToolCatalog, ToolEntry, VersionEntry}; use crate::modules::mcp::service as mcp_service; use crate::modules::mcp::types::{CustomToolEntry, McpConfig, ServerEntry}; use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::{Mutex, OnceLock}; +use std::time::SystemTime; use tokio::io::{AsyncBufReadExt, BufReader}; -const EMBEDDED_CATALOG: &str = include_str!("tools.json"); +/// Placeholder bot id for private-folder paths until a real session id is synced. +const BOT_ID_FALLBACK: &str = "default"; -/// Remote registry URL — raw GitHub content. The app fetches this at runtime so -/// users get new tools / version bumps without waiting for a Pengine app update. +const EMBEDDED_CATALOG: &str = include_str!("../../../../tools/mcp-tools.json"); + +/// Runtime-fetched catalog (GitHub raw) so tool list updates without an app release. const REMOTE_CATALOG_URL: &str = "https://raw.githubusercontent.com/pengine-ai/pengine/main/tools/mcp-tools.json"; @@ -23,7 +27,7 @@ const TE_PREFIX: &str = "te_"; /// Server key prefix for custom (developer-added) tool entries. const TE_CUSTOM_PREFIX: &str = "te_custom_"; -/// Sole MCP root when no shared folders are set yet (standard path in Linux images; no extra image dirs). +/// In-image workspace stub when no host folders are mounted yet. pub const EMPTY_WORKSPACE_CONTAINER_ROOT: &str = "/tmp"; /// Parse and validate a catalog JSON string. Returns `None` if parsing fails @@ -36,14 +40,93 @@ fn parse_catalog(json: &str) -> Option { Some(cat) } +static LOCAL_TOOLS_CATALOG_MTIME_CACHE: OnceLock< + Mutex>, +> = OnceLock::new(); + +fn local_tools_catalog_mtime_cache() -> &'static Mutex> +{ + LOCAL_TOOLS_CATALOG_MTIME_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + /// Load the embedded (compile-time) catalog. Always succeeds on a valid build. pub fn load_embedded_catalog() -> Result { - serde_json::from_str(EMBEDDED_CATALOG).map_err(|e| format!("parse embedded tools.json: {e}")) + serde_json::from_str(EMBEDDED_CATALOG) + .map_err(|e| format!("parse embedded mcp-tools.json: {e}")) +} + +/// Local `tools/mcp-tools.json` next to the crate or executable; with `debug_assertions` or +/// `LOCAL_TOOLS_CATALOG=1`, also walks up to 8 parents from `current_dir` (e.g. `tauri dev`). +fn try_load_local_tools_catalog() -> Option { + let mut paths: Vec = Vec::new(); + paths.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tools/mcp-tools.json")); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + paths.push(dir.join("tools/mcp-tools.json")); + } + } + let ancestor_walk = cfg!(debug_assertions) + || std::env::var("LOCAL_TOOLS_CATALOG") + .map(|v| { + let v = v.trim(); + v.eq_ignore_ascii_case("true") || v == "1" + }) + .unwrap_or(false); + if ancestor_walk { + if let Ok(mut cwd) = std::env::current_dir() { + for _ in 0..8 { + paths.push(cwd.join("tools/mcp-tools.json")); + if !cwd.pop() { + break; + } + } + } + } + let cache = local_tools_catalog_mtime_cache(); + for p in paths { + let mtime = match std::fs::metadata(&p) { + Ok(m) => m.modified().unwrap_or(SystemTime::UNIX_EPOCH), + Err(_) => continue, + }; + { + let map = cache + .lock() + .expect("local tools catalog cache mutex poisoned"); + if let Some((cached_mtime, cat)) = map.get(&p) { + if *cached_mtime == mtime { + return Some(cat.clone()); + } + } + } + let Ok(json) = std::fs::read_to_string(&p) else { + continue; + }; + if let Some(cat) = parse_catalog(&json) { + log::info!("loaded tool catalog from {}", p.display()); + let mut map = cache + .lock() + .expect("local tools catalog cache mutex poisoned"); + map.insert(p, (mtime, cat.clone())); + return Some(cat); + } + log::warn!( + "found {} but it did not parse as catalog schema v1", + p.display() + ); + } + None } -/// Fetch the remote catalog from GitHub, falling back to the embedded catalog -/// on network errors, timeouts, or parse failures. +/// Local file, then remote URL, then embedded `mcp-tools.json`. pub async fn load_catalog() -> Result { + if let Some(cat) = try_load_local_tools_catalog() { + log::info!( + "using local tools/mcp-tools.json (revision {}); remote fetch skipped", + cat.catalog_revision + ); + return Ok(cat); + } + match fetch_remote_catalog().await { Ok(cat) => { log::info!("using remote catalog (revision {})", cat.catalog_revision); @@ -82,6 +165,116 @@ pub fn server_key(tool_id: &str) -> String { format!("{TE_PREFIX}{}", tool_id.replace('/', "-")) } +/// Default host directory for a catalog tool's `private_folder` (`/tool-data//`). +pub fn default_private_data_dir(mcp_config_path: &Path, tool_id: &str) -> PathBuf { + let base = mcp_config_path.parent().unwrap_or_else(|| Path::new(".")); + base.join("tool-data").join(tool_id.replace('/', "-")) +} + +/// Resolve the host path for private tool data: explicit `mcp.json` override, else [`default_private_data_dir`]. +pub fn resolve_private_host_path( + mcp_config_path: &Path, + tool_id: &str, + stored: Option<&str>, +) -> PathBuf { + if let Some(s) = stored.map(str::trim).filter(|s| !s.is_empty()) { + PathBuf::from(s) + } else { + default_private_data_dir(mcp_config_path, tool_id) + } +} + +fn ensure_private_data_dir(path: &Path) -> Result<(), String> { + std::fs::create_dir_all(path) + .map_err(|e| format!("create private tool data dir {}: {e}", path.display())) +} + +/// Per-container env entry that points the MCP server at its bot-scoped state file +/// inside the bind-mounted private folder. +fn private_folder_container_env(pf: &PrivateFolderConfig, bot_id: &str) -> (String, String) { + let root = pf.container_path.trim_end_matches('/'); + let value = format!("{root}/{bot_id}.{}", pf.file_extension); + (pf.file_env_var.clone(), value) +} + +/// Everything the container needs to mount and address the private folder in one bundle. +pub struct PrivateBind<'a> { + pub host_path: &'a Path, + pub config: &'a PrivateFolderConfig, + pub bot_id: &'a str, +} + +fn catalog_tool_stdio_eq(a: &ServerEntry, b: &ServerEntry) -> bool { + match (a, b) { + ( + ServerEntry::Stdio { + command: c1, + args: a1, + env: e1, + direct_return: d1, + private_host_path: p1, + }, + ServerEntry::Stdio { + command: c2, + args: a2, + env: e2, + direct_return: d2, + private_host_path: p2, + }, + ) => c1 == c2 && a1 == a2 && e1 == e2 && d1 == d2 && p1 == p2, + _ => false, + } +} + +/// Rebuild argv for one installed catalog tool from `mcp.json` + catalog entry. +/// The container env is baked into argv via `-e` flags, so `ServerEntry.env` stays empty +/// (host-process env does not propagate into the container). +fn rebuild_installed_catalog_tool_stdio( + entry: &ToolEntry, + host_paths: &[String], + mcp_config_path: &Path, + prev: &ServerEntry, + bot_id: Option<&str>, +) -> Result, String> { + let ServerEntry::Stdio { + command, + direct_return, + private_host_path, + .. + } = prev + else { + return Ok(None); + }; + + let pb_buf = if entry.private_folder.is_some() { + let pb = + resolve_private_host_path(mcp_config_path, &entry.id, private_host_path.as_deref()); + ensure_private_data_dir(&pb)?; + Some(pb) + } else { + None + }; + let bid = bot_id.unwrap_or(BOT_ID_FALLBACK); + let private_bind: Option = match (&pb_buf, &entry.private_folder) { + (Some(pb), Some(pf)) => Some(PrivateBind { + host_path: pb.as_path(), + config: pf, + bot_id: bid, + }), + _ => None, + }; + + let args = podman_run_argv_for_tool(entry, host_paths, private_bind.as_ref())?; + + Ok(Some(ServerEntry::Stdio { + command: command.clone(), + args, + env: HashMap::new(), + direct_return: *direct_return, + private_host_path: private_host_path.clone(), + })) +} + fn sanitize_mount_label(name: &str) -> String { let s: String = name .chars() @@ -128,6 +321,7 @@ pub fn workspace_app_bind_pairs(host_paths: &[String]) -> Vec<(String, String)> pub fn podman_run_argv_for_tool( entry: &ToolEntry, host_paths: &[String], + private_bind: Option<&PrivateBind<'_>>, ) -> Result, String> { if entry.append_workspace_roots && !entry.mount_workspace { return Err("catalog: append_workspace_roots requires mount_workspace".into()); @@ -139,16 +333,18 @@ pub fn podman_run_argv_for_tool( "run".into(), "--rm".into(), "-i".into(), - "--network=none".into(), format!("--cpus={}", entry.limits.cpus), format!("--memory={}", entry.limits.memory), ]; + if entry.network_isolated { + args.insert(3, "--network=none".into()); + } + if entry.container_read_only_rootfs { args.push("--read-only".into()); } - // Compute the host→container layout once and reuse it for both bind mounts and root args. let bind_pairs = if entry.mount_workspace { workspace_app_bind_pairs(host_paths) } else { @@ -164,6 +360,21 @@ pub fn podman_run_argv_for_tool( ); } + if let Some(pb) = private_bind { + let host_s = pb.host_path.to_str().ok_or_else(|| { + format!( + "private data path must be valid UTF-8: {}", + pb.host_path.display() + ) + })?; + args.push(format!( + "-v={host_s}:{}:rw", + pb.config.container_path.trim_end_matches('/') + )); + let (k, v) = private_folder_container_env(pb.config, pb.bot_id); + args.push(format!("--env={k}={v}")); + } + args.push(image_ref); args.extend(entry.mcp_server_cmd.iter().cloned()); @@ -209,10 +420,7 @@ fn resolve_current_digest(entry: &ToolEntry) -> Result, String> { Ok(Some(ver.digest.clone())) } -/// The OCI image reference for a tool entry. -/// -/// - **Production** (real digest): `ghcr.io/pengine-ai/pengine-file-manager@sha256:abc123…` -/// - **Dev** (placeholder digest): `ghcr.io/pengine-ai/pengine-file-manager:0.1.0` (tagged) +/// `image@digest` when pinned, else `image:current_version` (dev / placeholder digest). fn image_reference(entry: &ToolEntry) -> Result { match resolve_current_digest(entry)? { Some(digest) => Ok(format!("{}@{}", entry.image, digest)), @@ -234,9 +442,7 @@ async fn image_present(runtime: &RuntimeInfo, image: &str) -> bool { /// A callback for streaming log lines during long-running operations. pub type LogFn = Box; -/// Ensure the tool image is available locally. Tries to pull from the registry first; -/// if the image is already present (e.g. from a local `podman build`), uses it directly. -/// All log lines are prefixed with `[tool_id]` so the frontend can filter by tool. +/// Pull if missing; accept a local image when the digest is not pinned. Logs are prefixed with `[tool_id]`. async fn ensure_tool_image( runtime: &RuntimeInfo, entry: &ToolEntry, @@ -259,9 +465,6 @@ async fn ensure_tool_image( match run_streaming_tagged(cmd, log, &tag).await { Ok(()) => {} Err(e) => { - // If using a tag (dev mode, no pinned digest), the pull failure is expected - // when the image hasn't been published yet. Check if it appeared locally - // (e.g. concurrent build, or tag resolves to a local image). if !pinned && image_present(runtime, &image_ref).await { log(&format!("{tag} pull failed but image found locally")); return Ok(()); @@ -275,7 +478,6 @@ async fn ensure_tool_image( } } - // Verify image is now present after pull. if !image_present(runtime, &image_ref).await { return Err(format!( "pull completed but `{}` is not visible to `{}`", @@ -343,47 +545,42 @@ pub fn installed_tool_ids(mcp_config_path: &Path) -> Vec { .collect() } -/// Rewrite every **installed** catalog tool that uses `mount_workspace` so argv matches `host_paths` -/// (empty list → in-image stub root only). Returns whether `mcp.json` should be saved. -/// -/// Pass the catalog from [`load_catalog`] (or tests) so callers can fetch **before** holding -/// `mcp_config_mutex`, avoiding network I/O under that lock. +/// Refresh installed catalog stdio argv for `host_paths` / private-folder env. Returns whether to save `mcp.json`. +/// Callers should pass a catalog from [`load_catalog`] before taking `mcp_config_mutex` to avoid I/O under the lock. pub fn sync_workspace_mounted_tools_for_catalog( cfg: &mut McpConfig, host_paths: &[String], catalog: &ToolCatalog, + mcp_config_path: &Path, + bot_id: Option, ) -> Result { + let bid = bot_id.as_deref(); let mut changed = false; for entry in &catalog.tools { let key = server_key(&entry.id); - let Some(ServerEntry::Stdio { - command, - args, - env, - direct_return, - }) = cfg.servers.get(&key) - else { + let Some(prev) = cfg.servers.get(&key) else { continue; }; - let new_args = podman_run_argv_for_tool(entry, host_paths)?; - if args == &new_args { + let Some(new_entry) = + rebuild_installed_catalog_tool_stdio(entry, host_paths, mcp_config_path, prev, bid)? + else { + log::warn!( + "sync_workspace_mounted_tools_for_catalog: skip server {key} (tool {}): mcp.json entry is not stdio; expected te_ catalog stdio", + entry.id + ); continue; - } - - let new_entry = ServerEntry::Stdio { - command: command.clone(), - args: new_args, - env: env.clone(), - direct_return: *direct_return, }; - cfg.servers.insert(key, new_entry); - changed = true; + + if !catalog_tool_stdio_eq(prev, &new_entry) { + cfg.servers.insert(key, new_entry); + changed = true; + } } Ok(changed) } -/// Pull a whitelisted container image by digest and register it as an MCP stdio server in `mcp.json`. +/// Pull (if needed) and append a catalog tool as an MCP stdio server in `mcp.json`. pub async fn install_tool( tool_id: &str, runtime: &RuntimeInfo, @@ -403,13 +600,31 @@ pub async fn install_tool( let _cfg_guard = mcp_cfg_lock.lock().await; let mut cfg = mcp_service::load_or_init_config(mcp_config_path)?; let host_paths = mcp_service::filesystem_allowed_paths(&cfg); - let args = podman_run_argv_for_tool(entry, &host_paths)?; + + let pb_buf = if entry.private_folder.is_some() { + let pb = resolve_private_host_path(mcp_config_path, tool_id, None); + ensure_private_data_dir(&pb)?; + Some(pb) + } else { + None + }; + let private_bind: Option = match (&pb_buf, &entry.private_folder) { + (Some(pb), Some(pf)) => Some(PrivateBind { + host_path: pb.as_path(), + config: pf, + bot_id: BOT_ID_FALLBACK, + }), + _ => None, + }; + + let args = podman_run_argv_for_tool(entry, &host_paths, private_bind.as_ref())?; let server_entry = ServerEntry::Stdio { command: runtime.binary.clone(), args, env: HashMap::new(), direct_return: entry.direct_return, + private_host_path: None, }; cfg.servers.insert(server_key(tool_id), server_entry); @@ -418,24 +633,77 @@ pub async fn install_tool( Ok(()) } -/// Remove an MCP Tool Engine entry from `mcp.json` and remove the container image. +/// [`install_tool`] for each id not in [`installed_tool_ids`]. Does not rebuild the MCP registry. +pub async fn install_all_catalog_tools( + runtime: &RuntimeInfo, + mcp_config_path: &Path, + mcp_cfg_lock: &tokio::sync::Mutex<()>, + log: &LogFn, +) -> Result { + let catalog = load_catalog().await?; + let installed: HashSet = installed_tool_ids(mcp_config_path).into_iter().collect(); + let to_install: Vec<&ToolEntry> = catalog + .tools + .iter() + .filter(|t| !installed.contains(t.id.as_str())) + .collect(); + + if to_install.is_empty() { + return Ok("All catalog tools are already installed.".to_string()); + } + + let mut succeeded: Vec = Vec::new(); + let mut failures: Vec = Vec::new(); + for entry in to_install { + match install_tool( + entry.id.as_str(), + runtime, + mcp_config_path, + mcp_cfg_lock, + log, + ) + .await + { + Ok(()) => succeeded.push(entry.id.clone()), + Err(e) => failures.push(format!("{}: {e}", entry.id)), + } + } + + let mut msg = String::new(); + if !succeeded.is_empty() { + msg.push_str(&format!( + "Installed {} tool(s): {}.", + succeeded.len(), + succeeded.join(", ") + )); + } + if !failures.is_empty() { + if !msg.is_empty() { + msg.push(' '); + } + msg.push_str("Failed: "); + msg.push_str(&failures.join("; ")); + } + + if succeeded.is_empty() && !failures.is_empty() { + return Err(msg); + } + Ok(msg) +} + +/// Drop the server entry from `mcp.json` and `rmi` the image ref stored in that argv. pub async fn uninstall_tool( tool_id: &str, runtime: &RuntimeInfo, mcp_config_path: &Path, mcp_cfg_lock: &tokio::sync::Mutex<()>, ) -> Result<(), String> { - // Read the installed image ref from mcp.json before removing the entry, so we - // remove the image that was actually pulled — not whatever the catalog currently - // resolves to (which may have been updated since install). let key = server_key(tool_id); let mut installed_image_ref: Option = None; if mcp_config_path.exists() { let _cfg_guard = mcp_cfg_lock.lock().await; let mut cfg = mcp_service::read_config(mcp_config_path)?; if let Some(ServerEntry::Stdio { args, .. }) = cfg.servers.get(&key) { - // In the podman run argv the image ref is the first non-flag token - // after "run" (flags start with `-`; "run" itself is skipped). installed_image_ref = args .iter() .skip_while(|a| *a == "run") @@ -446,7 +714,6 @@ pub async fn uninstall_tool( mcp_service::save_config(mcp_config_path, &cfg)?; } - // Remove the container image — prefer the ref from the installed entry. let image_ref = match installed_image_ref { Some(r) => Some(r), None => load_catalog() @@ -465,7 +732,47 @@ pub async fn uninstall_tool( Ok(()) } -// ── Custom tools (developer-added Docker images, local only) ────────── +/// [`uninstall_tool`] for each [`installed_tool_ids`] entry. Does not rebuild the MCP registry. +pub async fn uninstall_all_catalog_tools( + runtime: &RuntimeInfo, + mcp_config_path: &Path, + mcp_cfg_lock: &tokio::sync::Mutex<()>, +) -> Result { + let ids = installed_tool_ids(mcp_config_path); + if ids.is_empty() { + return Ok("No catalog tools were installed.".to_string()); + } + + let mut removed: Vec = Vec::new(); + let mut failures: Vec = Vec::new(); + for tool_id in ids { + match uninstall_tool(tool_id.as_str(), runtime, mcp_config_path, mcp_cfg_lock).await { + Ok(()) => removed.push(tool_id), + Err(e) => failures.push(format!("{tool_id}: {e}")), + } + } + + let mut msg = String::new(); + if !removed.is_empty() { + msg.push_str(&format!( + "Uninstalled {} tool(s): {}.", + removed.len(), + removed.join(", ") + )); + } + if !failures.is_empty() { + if !msg.is_empty() { + msg.push(' '); + } + msg.push_str("Failed: "); + msg.push_str(&failures.join("; ")); + } + + if removed.is_empty() && !failures.is_empty() { + return Err(msg); + } + Ok(msg) +} /// Server key for a custom tool entry in `mcp.json`. fn custom_server_key(key: &str) -> String { @@ -520,8 +827,7 @@ pub fn list_custom_tools(mcp_config_path: &Path) -> Vec { .unwrap_or_default() } -/// Add a custom Docker image as an MCP tool. Pulls the image, registers it in `mcp.json`, -/// and stores the entry in `custom_tools` so the dashboard can list it. +/// Pull, append `custom_tools`, and register a stdio server in `mcp.json`. pub async fn add_custom_tool( entry: CustomToolEntry, runtime: &RuntimeInfo, @@ -531,14 +837,12 @@ pub async fn add_custom_tool( ) -> Result<(), String> { let tag = format!("[custom/{}]", entry.key); - // Pull the image (no digest pinning for custom tools — developer controls the tag). log(&format!("{tag} pulling {}…", entry.image)); let mut cmd = tokio::process::Command::new(&runtime.binary); cmd.args(["pull", &entry.image]); match run_streaming_tagged(cmd, log, &tag).await { Ok(()) => log(&format!("{tag} image pulled")), Err(e) => { - // Check if the image is already present locally (e.g. local build). if image_present(runtime, &entry.image).await { log(&format!("{tag} pull failed but image found locally")); } else { @@ -551,7 +855,6 @@ pub async fn add_custom_tool( let mut cfg = mcp_service::load_or_init_config(mcp_config_path)?; let host_paths = mcp_service::filesystem_allowed_paths(&cfg); - // Prevent duplicate keys. if cfg.custom_tools.iter().any(|t| t.key == entry.key) { return Err(format!("custom tool '{}' already exists", entry.key)); } @@ -562,6 +865,7 @@ pub async fn add_custom_tool( args, env: HashMap::new(), direct_return: entry.direct_return, + private_host_path: None, }; cfg.servers @@ -590,7 +894,6 @@ pub async fn remove_custom_tool( cfg.servers.remove(&custom_server_key(key)); mcp_service::save_config(mcp_config_path, &cfg)?; - // Best-effort image removal. let _ = tokio::process::Command::new(&runtime.binary) .args(["rmi", &removed.image]) .output() @@ -609,6 +912,7 @@ pub fn sync_custom_tools_if_installed(cfg: &mut McpConfig, host_paths: &[String] args, env, direct_return, + private_host_path, }) = cfg.servers.get(&key) else { continue; @@ -624,6 +928,7 @@ pub fn sync_custom_tools_if_installed(cfg: &mut McpConfig, host_paths: &[String] args: new_args, env: env.clone(), direct_return: *direct_return, + private_host_path: private_host_path.clone(), }; cfg.servers.insert(key, new_entry); changed = true; @@ -634,6 +939,7 @@ pub fn sync_custom_tools_if_installed(cfg: &mut McpConfig, host_paths: &[String] #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; #[test] fn workspace_app_layout() { @@ -666,6 +972,16 @@ mod tests { .expect("file-manager catalog pins upstream MCP npm"); assert!(u.package.contains("server-filesystem")); assert!(!u.version.is_empty()); + let mem = catalog + .tools + .iter() + .find(|t| t.id == "pengine/memory") + .expect("memory in embedded catalog"); + let mp = mem + .private_folder + .as_ref() + .expect("memory declares private_folder"); + assert_eq!(mp.file_env_var, "MEMORY_FILE_PATH"); } #[test] @@ -690,7 +1006,7 @@ mod tests { .find(|v| v.version == fm.current) .unwrap(); assert_eq!(ver.digest, "sha256:placeholder"); - let argv = podman_run_argv_for_tool(fm, &[]).expect("argv"); + let argv = podman_run_argv_for_tool(fm, &[], None).expect("argv"); let tagged = format!("{}:{}", fm.image, fm.current); let image_ref = argv .iter() @@ -701,4 +1017,43 @@ mod tests { "placeholder must not use @digest: {image_ref}" ); } + + #[test] + fn memory_catalog_has_private_folder_and_argv_includes_bind_and_env() { + let catalog = load_embedded_catalog().unwrap(); + let mem = catalog + .tools + .iter() + .find(|t| t.id == "pengine/memory") + .expect("memory in catalog"); + let pf = mem + .private_folder + .as_ref() + .expect("memory declares private_folder"); + assert_eq!(pf.container_path, "/mcp/data"); + assert_eq!(pf.file_env_var, "MEMORY_FILE_PATH"); + + let tmp = tempdir().expect("tempdir"); + let pb = PrivateBind { + host_path: tmp.path(), + config: pf, + bot_id: "12345", + }; + let argv = podman_run_argv_for_tool(mem, &[], Some(&pb)).expect("argv"); + + let want_mount = format!( + "-v={}:/mcp/data:rw", + tmp.path().to_str().expect("utf8 tmp path") + ); + assert!( + argv.iter().any(|a| a == &want_mount), + "missing mount: argv={argv:?}" + ); + + let want_env = "--env=MEMORY_FILE_PATH=/mcp/data/12345.json".to_string(); + assert!( + argv.iter().any(|a| a == &want_env), + "missing -e flag: argv={argv:?}" + ); + } } diff --git a/src-tauri/src/modules/tool_engine/tools.json b/src-tauri/src/modules/tool_engine/tools.json deleted file mode 100644 index d6ee129..0000000 --- a/src-tauri/src/modules/tool_engine/tools.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "schema_version": 1, - "generated_at": "2026-04-13T00:00:00Z", - "catalog_revision": 2, - "valid_until": "2026-05-13T00:00:00Z", - "minimum_pengine_version": "0.5.0", - "tools": [ - { - "id": "pengine/file-manager", - "name": "File Manager", - "description": "Filesystem MCP in a container. Add folders in MCP Tools; each mounts at /app/. Install works before any folder is set.", - "image": "ghcr.io/pengine-ai/pengine-file-manager", - "current": "0.1.0", - "versions": [ - { - "version": "0.1.0", - "digest": "sha256:placeholder", - "released_at": "2026-04-12T00:00:00Z", - "yanked": false, - "revoked": false, - "security": false - } - ], - "container_read_only_rootfs": false, - "mount_read_only": true, - "mount_workspace": true, - "append_workspace_roots": true, - "direct_return": true, - "upstream_mcp_npm": { - "package": "@modelcontextprotocol/server-filesystem", - "version": "2026.1.14" - }, - "mcp_server_cmd": [], - "commands": [ - { "name": "read_text_file", "description": "Read a file as UTF-8 text; optional head/tail line limits" }, - { "name": "read_media_file", "description": "Read image or audio as base64 with MIME type" }, - { "name": "read_multiple_files", "description": "Read several files in one call" }, - { "name": "write_file", "description": "Create or overwrite a file" }, - { "name": "edit_file", "description": "Pattern-based selective edits with optional dry run" }, - { "name": "create_directory", "description": "Create a directory (and parents)" }, - { "name": "list_directory", "description": "List entries with [FILE]/[DIR] prefixes" }, - { "name": "list_directory_with_sizes", "description": "List directory with sizes and optional sort" }, - { "name": "move_file", "description": "Move or rename a file or directory" }, - { "name": "search_files", "description": "Recursive glob search under a path" }, - { "name": "directory_tree", "description": "Recursive JSON tree of directory contents" }, - { "name": "get_file_info", "description": "Metadata: size, times, type, permissions" }, - { "name": "list_allowed_directories", "description": "List MCP roots currently allowed" } - ], - "limits": { - "cpus": "0.5", - "memory": "256m", - "timeout_secs": 30 - } - } - ] -} diff --git a/src-tauri/src/modules/tool_engine/types.rs b/src-tauri/src/modules/tool_engine/types.rs index e033f75..b7ecf25 100644 --- a/src-tauri/src/modules/tool_engine/types.rs +++ b/src-tauri/src/modules/tool_engine/types.rs @@ -43,6 +43,25 @@ pub struct UpstreamMcpNpm { pub version: String, } +/// PyPI package pinned inside a container image (Python MCP servers). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpstreamMcpPypi { + pub package: String, + pub version: String, +} + +/// Declares that a tool keeps mutable state on disk. The app bind-mounts a host +/// directory to `container_path` and sets `file_env_var` on the container to +/// `/.` so state is scoped per connected bot. +/// Host directory defaults to `$APP_DATA/tool-data//` and can be overridden by +/// `PUT /v1/toolengine/private-folder` (`{ "tool_id", "path" }`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrivateFolderConfig { + pub container_path: String, + pub file_env_var: String, + pub file_extension: String, +} + /// One entry in the tool catalog (`tools.json`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolEntry { @@ -84,6 +103,17 @@ pub struct ToolEntry { /// When set, image build (`tools-publish.yml`) installs this npm package at this version. #[serde(default, skip_serializing_if = "Option::is_none")] pub upstream_mcp_npm: Option, + /// When set, image build installs this PyPI package at this version (Python MCP servers). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_mcp_pypi: Option, + /// When true (default), run the tool container with `--network=none`. Set false for servers + /// that need outbound network (e.g. web fetch). + #[serde(default = "default_true")] + pub network_isolated: bool, + /// When set, the app bind-mounts a host folder into the container and passes a per-bot file + /// path via env so the tool can persist state (e.g. the Memory server's knowledge-graph JSON). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub private_folder: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/shared/state.rs b/src-tauri/src/shared/state.rs index d069e06..35ab56f 100644 --- a/src-tauri/src/shared/state.rs +++ b/src-tauri/src/shared/state.rs @@ -21,6 +21,13 @@ pub struct LogEntry { pub message: String, } +#[derive(Debug, Clone)] +pub struct MemorySession { + pub entity_name: String, + pub turn_count: u32, + pub diary_only: bool, +} + #[derive(Clone)] pub struct AppState { pub connection: Arc>>, @@ -38,6 +45,8 @@ pub struct AppState { pub preferred_ollama_model: Arc>>, pub cached_filesystem_paths: Arc>>, pub tool_engine_mutex: Arc>, + /// Active memory-session recording (toggled by keyword commands; see `bot::agent`). + pub memory_session: Arc>>, } impl AppState { @@ -58,6 +67,7 @@ impl AppState { preferred_ollama_model: Arc::new(RwLock::new(None)), cached_filesystem_paths: Arc::new(RwLock::new(Vec::new())), tool_engine_mutex: Arc::new(Mutex::new(())), + memory_session: Arc::new(RwLock::new(None)), } } diff --git a/src/modules/mcp/components/McpServerCard.tsx b/src/modules/mcp/components/McpServerCard.tsx index 2887e61..f6c7c4a 100644 --- a/src/modules/mcp/components/McpServerCard.tsx +++ b/src/modules/mcp/components/McpServerCard.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { fetchToolCatalog, putToolPrivateFolder } from "../../toolengine"; import { workspaceAppContainerMountPaths } from "../../../shared/workspaceMounts"; import { fetchMcpConfig, @@ -8,6 +9,11 @@ import { type ServerEntryStdio, } from ".."; +/** `pengine/memory` → `te_pengine-memory` (matches Rust `server_key`). */ +function teServerKeyForToolId(toolId: string): string { + return `te_${toolId.replace(/\//g, "-")}`; +} + type Props = { name: string; entry: ServerEntry; @@ -224,6 +230,13 @@ function InlineEditForm({ const [teApplyError, setTeApplyError] = useState(null); const [teApplyBusy, setTeApplyBusy] = useState(false); + /** Catalog tool id (e.g. `pengine/memory`) when this server uses `private_folder`. */ + const [tePrivateToolId, setTePrivateToolId] = useState(null); + const [tePrivatePathInput, setTePrivatePathInput] = useState(""); + const [tePrivatePickError, setTePrivatePickError] = useState(null); + const [tePrivateApplyError, setTePrivateApplyError] = useState(null); + const [tePrivateApplyBusy, setTePrivateApplyBusy] = useState(false); + useEffect(() => { if (!isTeFileManager) return; void (async () => { @@ -232,6 +245,30 @@ function InlineEditForm({ })(); }, [isTeFileManager, name]); + useEffect(() => { + if (!name.startsWith("te_")) { + setTePrivateToolId(null); + setTePrivatePathInput(""); + return; + } + let cancelled = false; + void (async () => { + const cat = await fetchToolCatalog(5000); + if (cancelled) return; + const t = cat?.find((x) => teServerKeyForToolId(x.id) === name && x.private_folder != null); + if (t) { + setTePrivateToolId(t.id); + setTePrivatePathInput(t.private_host_path ?? ""); + } else { + setTePrivateToolId(null); + setTePrivatePathInput(""); + } + })(); + return () => { + cancelled = true; + }; + }, [name]); + const isFs = argsTextLooksLikeFilesystem(argsText); // ── Filesystem folder helpers (read/write the args textarea) ────── @@ -327,9 +364,50 @@ function InlineEditForm({ onCancel(); }; + const pickTePrivateFolder = async () => { + setTePrivatePickError(null); + try { + const { invoke } = await import("@tauri-apps/api/core"); + try { + const picked = await invoke("pick_mcp_filesystem_folder"); + if (picked) setTePrivatePathInput(picked); + } catch (invokeErr) { + setTePrivatePickError( + invokeErr instanceof Error ? invokeErr.message : "Could not open folder picker", + ); + } + } catch { + setTePrivatePickError("Folder picker needs the desktop app (Tauri)."); + } + }; + + const applyTePrivateFolder = async () => { + if (!tePrivateToolId) return; + setTePrivateApplyError(null); + const path = tePrivatePathInput.trim(); + if (!path) { + setTePrivateApplyError("Enter a host folder path or use Choose folder."); + return; + } + setTePrivateApplyBusy(true); + const result = await putToolPrivateFolder(tePrivateToolId, path, 120_000); + setTePrivateApplyBusy(false); + if (!result.ok) { + setTePrivateApplyError(result.error ?? "Could not save"); + return; + } + await onReloadServers?.(); + onCancel(); + }; + // ── Submit ──────────────────────────────────────────────────────── + const privatePathBaseline = entry.private_host_path ?? ""; + const hasUnsavedPrivate = + tePrivateToolId != null && tePrivatePathInput.trim() !== privatePathBaseline.trim(); + const handleSubmit = async () => { + if (hasUnsavedPrivate || tePrivateApplyBusy) return; const args = argsText .split("\n") .map((l) => l.trim()) @@ -348,6 +426,7 @@ function InlineEditForm({ args, env, direct_return: directReturn, + private_host_path: entry.private_host_path ?? null, }); }; @@ -414,6 +493,54 @@ function InlineEditForm({ )} + {tePrivateToolId && ( +
+

+ Private data folder (host) +

+

+ This tool keeps state on disk in a single host directory (bind-mounted into the + container). Use Choose folder or paste a path, then Apply — same idea as File + Manager's shared folders, but only for this tool's data file(s). +

+ {tePrivatePickError && ( +

+ {tePrivatePickError} +

+ )} +
+ setTePrivatePathInput(e.target.value)} + placeholder="/path/to/memory-data" + className="min-w-0 flex-1 rounded-md border border-white/15 bg-white/5 px-2 py-1.5 font-mono text-[11px] text-white outline-none placeholder:text-white/20 focus:border-white/30" + /> + +
+ {tePrivateApplyError && ( +

+ {tePrivateApplyError} +

+ )} + +
+ )} + {/* Filesystem folder helper (npx server-filesystem) */} {isFs && ( -
+
+ {hasUnsavedPrivate ? ( +

+ Apply data folder first (or revert the path field) before Save. +

+ ) : null}